commit 5ebc506921a86f3413deef20631f53f44b8fcb4b Author: Matthieu Bessat Date: Fri Jan 19 16:39:49 2024 +0100 initial commit diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..1bfaa3e --- /dev/null +++ b/.editorconfig @@ -0,0 +1,10 @@ +root = true + +[*] +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +charset = utf-8 +indent_style = tab +indent_size = tab +tab_width = 4 diff --git a/.fossil-settings/allow-symlinks b/.fossil-settings/allow-symlinks new file mode 100644 index 0000000..e8fd903 --- /dev/null +++ b/.fossil-settings/allow-symlinks @@ -0,0 +1 @@ +on \ No newline at end of file diff --git a/.fossil-settings/ignore-glob b/.fossil-settings/ignore-glob new file mode 100644 index 0000000..b0917d5 --- /dev/null +++ b/.fossil-settings/ignore-glob @@ -0,0 +1,14 @@ +src/data/* +src/*.tar.gz +src/*.asc +src/*.log +src/include/lib/KD2 +src/debug_sql.sqlite +src/modules/* +src/config.local.php +build/windows/*.exe +build/windows/php.zip +build/windows/install_dir +build/*.tar.gz* +build/debian/*.deb +src/psalm.phar \ No newline at end of file diff --git a/.fossil-settings/manifest b/.fossil-settings/manifest new file mode 100644 index 0000000..e8fd903 --- /dev/null +++ b/.fossil-settings/manifest @@ -0,0 +1 @@ +on \ No newline at end of file diff --git a/.fslckout b/.fslckout new file mode 100644 index 0000000..747eed6 Binary files /dev/null and b/.fslckout differ diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..43df021 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +src/data diff --git a/.php-version b/.php-version new file mode 100644 index 0000000..5029165 --- /dev/null +++ b/.php-version @@ -0,0 +1 @@ +8.1.26 diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..73fc10c --- /dev/null +++ b/.travis.yml @@ -0,0 +1,21 @@ +language: php +php: + - '7.2' + - '7.3' + - '7.4' + +install: + - make -C src deps + +script: + - php tests/run.php + +notifications: + irc: + channels: + - "chat.freenode.net#garradin" + template: + - "%{build_number} by %{author} on %{branch}: %{message} " + - "Build details: %{build_url}" + use_notice: false + skip_join: true \ No newline at end of file diff --git a/COPYING b/COPYING new file mode 100644 index 0000000..dba13ed --- /dev/null +++ b/COPYING @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + 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 Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/README.md b/README.md new file mode 100644 index 0000000..76246de --- /dev/null +++ b/README.md @@ -0,0 +1,32 @@ +# Paheko - Le gestionnaire d'association + +Paheko est un logiciel de gestion d'association. + +Plus d'infos sur le site de développement ici : [fossil.kd2.org/paheko](https://fossil.kd2.org/paheko/) + +[Documentation développeuse⋅développeur](https://fossil.kd2.org/paheko/wiki?name=Documentation+d%C3%A9veloppeur) + +Il est possible d'essayer gratuitement sur la plateforme [Paheko.cloud](https://paheko.cloud/). + +Le code sur Github n'est qu'un miroir, le développement principal se passe sur Fossil, mais les PR sont quand même possibles sur Github. + +**PR/Patch :** sauf si c'est une correction de bug, le mieux est de discuter de la modification sur dev(arobase)paheko.cloud avant de proposer le patch, pour qu'il ait plus de chances d'être accepté. + +## Licence + +GNU Affero GPL v3 (voir fichier COPYING) + +Cette licence permet la libre redistribution, utilisation et modification du logiciel. + +La seule condition est de re-partager les éventuelles modifications apportées. + +Cette clause s'applique même si le logiciel n'est pas distribué et simplement installé sur un serveur. + +## Code utilisé + +Inclus les bibliothèques suivantes : + +* [KD2fw](https://fossil.kd2.org/kd2fw/) - Copyright : 2001-2022+ BohwaZ - Licence : GNU AGPL v3 +* [Gibberish AES](https://github.com/mdp/gibberish-aes) - Copyright : Mark Percival 2008 - http://markpercival.us - Licence : MIT +* [Parsedown](https://github.com/erusev/parsedown) - Copyright Emanuil Rusev - License MIT +* [Unzipit.js](https://github.com/greggman/unzipit) - Copyright (c) 2019 Gregg Tavares 2014 Josh Wolfe - License MIT \ No newline at end of file diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..8f2b727 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,15 @@ +# Security Policy + +We take the security of Garradin very seriously. + +Nous prenons la sécurité de Garradin au sérieux. + +## Supported Versions + +Only the latest stable branch is supported. + +## Reporting a Vulnerability + +If you find a security issue, please contact us: security@garradin.eu + +Vous pouvez nous contacter à l'adresse e-mail ci-dessus si vous trouvez un problème de sécurité. diff --git a/archives/0.7.0_migration.sql b/archives/0.7.0_migration.sql new file mode 100644 index 0000000..903b4f3 --- /dev/null +++ b/archives/0.7.0_migration.sql @@ -0,0 +1,64 @@ +CREATE TABLE plugins_signaux +-- Association entre plugins et signaux (hooks) +( + signal TEXT NOT NULL, + plugin TEXT NOT NULL REFERENCES plugins (id), + callback TEXT NOT NULL, + PRIMARY KEY (signal, plugin) +); + +CREATE TABLE compta_rapprochement +-- Rapprochement entre compta et relevés de comptes +( + operation INTEGER NOT NULL PRIMARY KEY REFERENCES compta_journal (id), + date TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + auteur INTEGER NOT NULL REFERENCES membres (id) +); + +CREATE TABLE fichiers +-- Données sur les fichiers +( + id INTEGER NOT NULL PRIMARY KEY, + nom TEXT NOT NULL, -- nom de fichier (par exemple image1234.jpeg) + type TEXT NULL, -- Type MIME + image INTEGER NOT NULL DEFAULT 0, -- 1 = image reconnue + datetime TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, -- Date d'ajout ou mise à jour du fichier + id_contenu INTEGER NOT NULL REFERENCES fichiers_contenu (id) +); + +CREATE INDEX fichiers_date ON fichiers (datetime); + +CREATE TABLE fichiers_contenu +-- Contenu des fichiers +( + id INTEGER NOT NULL PRIMARY KEY, + hash TEXT NOT NULL, -- Hash SHA1 du contenu du fichier + taille INTEGER NOT NULL, -- Taille en octets + contenu BLOB NULL +); + +CREATE UNIQUE INDEX fichiers_hash ON fichiers_contenu (hash); + +CREATE TABLE fichiers_membres +-- Associations entre fichiers et membres (photo de profil par exemple) +( + fichier INTEGER NOT NULL REFERENCES fichiers (id), + id INTEGER NOT NULL REFERENCES membres (id), + PRIMARY KEY(fichier, id) +); + +CREATE TABLE fichiers_wiki_pages +-- Associations entre fichiers et pages du wiki +( + fichier INTEGER NOT NULL REFERENCES fichiers (id), + id INTEGER NOT NULL REFERENCES wiki_pages (id), + PRIMARY KEY(fichier, id) +); + +CREATE TABLE fichiers_compta_journal +-- Associations entre fichiers et journal de compta (pièce comptable par exemple) +( + fichier INTEGER NOT NULL REFERENCES fichiers (id), + id INTEGER NOT NULL REFERENCES compta_journal (id), + PRIMARY KEY(fichier, id) +); \ No newline at end of file diff --git a/archives/0.7.2_migration.sql b/archives/0.7.2_migration.sql new file mode 100644 index 0000000..9aa7dc1 --- /dev/null +++ b/archives/0.7.2_migration.sql @@ -0,0 +1,24 @@ +-- Colonne manquante +ALTER TABLE rappels_envoyes ADD COLUMN id_rappel INTEGER NULL REFERENCES rappels (id); + +-- Un bug a permis d'insérer des comptes avec des lettres minuscules, créant des problèmes +-- corrigeons donc les comptes pour les mettre en majuscules. + +UPDATE compta_comptes SET id = UPPER(id); + +-- Le champ id_auteur était à NOT NULL, il faut corriger ça pour pouvoir avoir un rapprochement anonyme +-- une fois que le membre a été supprimé + +CREATE TABLE compta_rapprochement2 +-- Rapprochement entre compta et relevés de comptes +( + id_operation INTEGER NOT NULL PRIMARY KEY REFERENCES compta_journal (id), + date TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + id_auteur INTEGER NULL REFERENCES membres (id) +); + +INSERT INTO compta_rapprochement2 SELECT operation, date, auteur FROM compta_rapprochement; + +DROP TABLE compta_rapprochement; + +ALTER TABLE compta_rapprochement2 RENAME TO compta_rapprochement; \ No newline at end of file diff --git a/archives/0.8.0_migration.sql b/archives/0.8.0_migration.sql new file mode 100644 index 0000000..2be5f6e --- /dev/null +++ b/archives/0.8.0_migration.sql @@ -0,0 +1,85 @@ +-- Ajouter champ pour OTP +ALTER TABLE membres ADD COLUMN secret_otp TEXT NULL; + +-- Ajouter champ clé PGP +ALTER TABLE membres ADD COLUMN clef_pgp TEXT NULL; + +-------------------------------------------------------------------------------- +-- Mise à jour des tables contenant un champ date pour ajouter la contrainte -- +-- Ceci afin de forcer les champs à contenir un format de date correct -- +-- On en profite pour ajouter les ON DELETE nécessaires -- +-------------------------------------------------------------------------------- + +-- Convertir les dates UNIX en date Y-m-d, apparemment il y en a encore parfois ? +UPDATE wiki_pages SET date_creation = datetime(date_creation, "unixepoch") WHERE CAST(date_creation AS INT) = date_creation; +UPDATE wiki_pages SET date_creation = datetime(date_creation) WHERE datetime(date_creation) != date_creation; + +-- Renommage des tables qu'il faut mettre à jour +ALTER TABLE cotisations_membres RENAME TO cotisations_membres_old; +ALTER TABLE rappels RENAME TO rappels_old; +ALTER TABLE rappels_envoyes RENAME TO rappels_envoyes_old; +ALTER TABLE wiki_pages RENAME TO wiki_pages_old; +ALTER TABLE wiki_revisions RENAME TO wiki_revisions_old; +ALTER TABLE compta_categories RENAME TO compta_categories_old; +ALTER TABLE compta_comptes_bancaires RENAME TO compta_comptes_bancaires_old; +ALTER TABLE compta_exercices RENAME TO compta_exercices_old; +ALTER TABLE compta_journal RENAME TO compta_journal_old; +ALTER TABLE compta_rapprochement RENAME TO compta_rapprochement_old; +ALTER TABLE fichiers RENAME TO fichiers_old; +ALTER TABLE membres_operations RENAME TO membres_operations_old; +ALTER TABLE membres_categories RENAME TO membres_categories_old; + +-- Suppression des index pour que les nouveaux soient liés aux nouvelles tables +DROP INDEX cm_unique; +DROP INDEX wiki_uri; +DROP INDEX wiki_revisions_id_page; +DROP INDEX wiki_revisions_id_auteur; +DROP INDEX compta_operations_exercice; +DROP INDEX compta_operations_date; +DROP INDEX compta_operations_comptes; +DROP INDEX compta_operations_auteur; +DROP INDEX fichiers_date; + +-- Suppression ancienne table recherche +DROP TABLE wiki_recherche; + +-- Suppression des triggers +-- Sinon les nouveaux ne seront pas créés sur la nouvelle table +DROP TRIGGER wiki_recherche_delete; +DROP TRIGGER wiki_recherche_update; +DROP TRIGGER wiki_recherche_contenu_insert; +DROP TRIGGER wiki_recherche_contenu_chiffre; + +-- Création des tables mises à jour (et de leurs index) +.read 0.8.0_schema.sql + +-- Copie des données +INSERT INTO cotisations_membres SELECT * FROM cotisations_membres_old; +INSERT INTO rappels SELECT * FROM rappels_old; +INSERT INTO rappels_envoyes SELECT id, id_membre, id_cotisation, id_rappel, date, media FROM rappels_envoyes_old; +INSERT INTO wiki_pages SELECT * FROM wiki_pages_old; +INSERT INTO wiki_revisions SELECT * FROM wiki_revisions_old; +INSERT INTO compta_categories SELECT * FROM compta_categories_old; +INSERT INTO compta_comptes_bancaires SELECT * FROM compta_comptes_bancaires_old; +INSERT INTO compta_exercices SELECT * FROM compta_exercices_old; +INSERT INTO compta_journal SELECT *, NULL FROM compta_journal_old; +INSERT INTO compta_rapprochement SELECT * FROM compta_rapprochement_old; +INSERT INTO fichiers SELECT * FROM fichiers_old; +INSERT INTO membres_operations SELECT * FROM membres_operations_old; +INSERT INTO membres_categories SELECT id, nom, droit_wiki, droit_membres, droit_compta, + droit_inscription, droit_connexion, droit_config, cacher, id_cotisation_obligatoire FROM membres_categories_old; + +-- Suppression des anciennes tables +DROP TABLE cotisations_membres_old; +DROP TABLE rappels_old; +DROP TABLE rappels_envoyes_old; +DROP TABLE wiki_pages_old; +DROP TABLE wiki_revisions_old; +DROP TABLE compta_categories_old; +DROP TABLE compta_comptes_bancaires_old; +DROP TABLE compta_exercices_old; +DROP TABLE compta_journal_old; +DROP TABLE compta_rapprochement_old; +DROP TABLE fichiers_old; +DROP TABLE membres_operations_old; +DROP TABLE membres_categories_old; diff --git a/archives/0.8.0_schema.sql b/archives/0.8.0_schema.sql new file mode 100644 index 0000000..f1f2434 --- /dev/null +++ b/archives/0.8.0_schema.sql @@ -0,0 +1,390 @@ +CREATE TABLE IF NOT EXISTS config ( +-- Configuration de Garradin + cle TEXT PRIMARY KEY NOT NULL, + valeur TEXT +); + +-- On stocke ici les ID de catégorie de compta correspondant aux types spéciaux +-- compta_categorie_cotisations => id_categorie +-- compta_categorie_dons => id_categorie + +CREATE TABLE IF NOT EXISTS membres_categories +-- Catégories de membres +( + id INTEGER PRIMARY KEY NOT NULL, + nom TEXT NOT NULL, + description TEXT NULL, + + droit_wiki INTEGER NOT NULL DEFAULT 1, + droit_membres INTEGER NOT NULL DEFAULT 1, + droit_compta INTEGER NOT NULL DEFAULT 1, + droit_inscription INTEGER NOT NULL DEFAULT 0, + droit_connexion INTEGER NOT NULL DEFAULT 1, + droit_config INTEGER NOT NULL DEFAULT 0, + cacher INTEGER NOT NULL DEFAULT 0, + + id_cotisation_obligatoire INTEGER NULL REFERENCES cotisations (id) ON DELETE SET NULL +); + +-- Membres de l'asso +-- Table dynamique générée par l'application +-- voir Garradin\Membres\Champs.php + +CREATE TABLE IF NOT EXISTS membres_sessions +-- Sessions +( + selecteur TEXT NOT NULL, + hash TEXT NOT NULL, + id_membre INTEGER NOT NULL REFERENCES membres (id) ON DELETE CASCADE, + expire INT NOT NULL, + + PRIMARY KEY (selecteur, id_membre) +); + +CREATE TABLE IF NOT EXISTS cotisations +-- Types de cotisations et activités +( + id INTEGER PRIMARY KEY NOT NULL, + id_categorie_compta INTEGER NULL, -- NULL si le type n'est pas associé automatiquement à la compta + + intitule TEXT NOT NULL, + description TEXT NULL, + montant REAL NOT NULL, + + duree INTEGER NULL, -- En jours + debut TEXT NULL, -- timestamp + fin TEXT NULL, + + FOREIGN KEY (id_categorie_compta) REFERENCES compta_categories (id) +); + +CREATE TABLE IF NOT EXISTS cotisations_membres +-- Enregistrement des cotisations et activités +( + id INTEGER NOT NULL PRIMARY KEY, + id_membre INTEGER NOT NULL REFERENCES membres (id) ON DELETE CASCADE, + id_cotisation INTEGER NOT NULL REFERENCES cotisations (id) ON DELETE CASCADE, + + date TEXT NOT NULL DEFAULT CURRENT_DATE CHECK (date(date) IS NOT NULL AND date(date) = date) +); + +CREATE UNIQUE INDEX IF NOT EXISTS cm_unique ON cotisations_membres (id_membre, id_cotisation, date); + +CREATE TABLE IF NOT EXISTS membres_operations +-- Liaision des enregistrement des paiements en compta +( + id_membre INTEGER NOT NULL REFERENCES membres (id) ON DELETE CASCADE, + id_operation INTEGER NOT NULL REFERENCES compta_journal (id) ON DELETE CASCADE, + id_cotisation INTEGER NULL REFERENCES cotisations_membres (id) ON DELETE SET NULL, + + PRIMARY KEY (id_membre, id_operation) +); + +CREATE TABLE IF NOT EXISTS rappels +-- Rappels de devoir renouveller une cotisation +( + id INTEGER NOT NULL PRIMARY KEY, + id_cotisation INTEGER NOT NULL REFERENCES cotisations (id) ON DELETE CASCADE, + + delai INTEGER NOT NULL, -- Délai en jours pour envoyer le rappel + + sujet TEXT NOT NULL, + texte TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS rappels_envoyes +-- Enregistrement des rappels envoyés à qui et quand +( + id INTEGER NOT NULL PRIMARY KEY, + + id_membre INTEGER NOT NULL REFERENCES membres (id) ON DELETE CASCADE, + id_cotisation INTEGER NOT NULL REFERENCES cotisations (id) ON DELETE CASCADE, + id_rappel INTEGER NULL REFERENCES rappels (id) ON DELETE CASCADE, + + date TEXT NOT NULL DEFAULT CURRENT_DATE CHECK (date(date) IS NOT NULL AND date(date) = date), + + media INTEGER NOT NULL -- Média utilisé pour le rappel : 1 = email, 2 = courrier, 3 = autre +); + +-- +-- WIKI +-- + +CREATE TABLE IF NOT EXISTS wiki_pages +-- Pages du wiki +( + id INTEGER PRIMARY KEY NOT NULL, + uri TEXT NOT NULL, -- URI unique (équivalent NomPageWiki) + titre TEXT NOT NULL, + date_creation TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP CHECK (datetime(date_creation) IS NOT NULL AND datetime(date_creation) = date_creation), + date_modification TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP CHECK (datetime(date_modification) IS NOT NULL AND datetime(date_modification) = date_modification), + parent INTEGER NOT NULL DEFAULT 0, -- ID de la page parent + revision INTEGER NOT NULL DEFAULT 0, -- Numéro de révision (commence à 0 si pas de texte, +1 à chaque changement du texte) + droit_lecture INTEGER NOT NULL DEFAULT 0, -- Accès en lecture (-1 = public [site web], 0 = tous ceux qui ont accès en lecture au wiki, 1+ = ID de groupe) + droit_ecriture INTEGER NOT NULL DEFAULT 0 -- Accès en écriture (0 = tous ceux qui ont droit d'écriture sur le wiki, 1+ = ID de groupe) +); + +CREATE UNIQUE INDEX IF NOT EXISTS wiki_uri ON wiki_pages (uri); + +CREATE VIRTUAL TABLE IF NOT EXISTS wiki_recherche USING fts4 +-- Table dupliquée pour chercher une page +( + id INT PRIMARY KEY NOT NULL, -- Clé externe obligatoire + titre TEXT NOT NULL, + contenu TEXT NULL, -- Contenu de la dernière révision + FOREIGN KEY (id) REFERENCES wiki_pages(id) +); + +CREATE TABLE IF NOT EXISTS wiki_revisions +-- Révisions du contenu des pages +( + id_page INTEGER NOT NULL REFERENCES wiki_pages (id) ON DELETE CASCADE, + revision INTEGER NULL, + + id_auteur INTEGER NULL REFERENCES membres (id) ON DELETE SET NULL, + + contenu TEXT NOT NULL, + modification TEXT NULL, -- Description des modifications effectuées + chiffrement INTEGER NOT NULL DEFAULT 0, -- 1 si le contenu est chiffré, 0 sinon + date TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP CHECK (datetime(date) IS NOT NULL AND datetime(date) = date), + + PRIMARY KEY(id_page, revision) +); + +CREATE INDEX IF NOT EXISTS wiki_revisions_id_page ON wiki_revisions (id_page); +CREATE INDEX IF NOT EXISTS wiki_revisions_id_auteur ON wiki_revisions (id_auteur); + +-- Triggers pour synchro avec table wiki_pages +CREATE TRIGGER IF NOT EXISTS wiki_recherche_delete AFTER DELETE ON wiki_pages + BEGIN + DELETE FROM wiki_recherche WHERE id = old.id; + END; + +CREATE TRIGGER IF NOT EXISTS wiki_recherche_update AFTER UPDATE OF id, titre ON wiki_pages + BEGIN + UPDATE wiki_recherche SET id = new.id, titre = new.titre WHERE id = old.id; + END; + +-- Trigger pour mettre à jour le contenu de la table de recherche lors d'une nouvelle révision +CREATE TRIGGER IF NOT EXISTS wiki_recherche_contenu_insert AFTER INSERT ON wiki_revisions WHEN new.chiffrement != 1 + BEGIN + UPDATE wiki_recherche SET contenu = new.contenu WHERE id = new.id_page; + END; + +-- Si le contenu est chiffré, la recherche n'affiche pas de contenu +CREATE TRIGGER IF NOT EXISTS wiki_recherche_contenu_chiffre AFTER INSERT ON wiki_revisions WHEN new.chiffrement = 1 + BEGIN + UPDATE wiki_recherche SET contenu = '' WHERE id = new.id_page; + END; + +/* +CREATE TABLE wiki_suivi +-- Suivi des pages +( + id_membre INTEGER NOT NULL, + id_page INTEGER NOT NULL, + + PRIMARY KEY (id_membre, id_page), + + FOREIGN KEY (id_page) REFERENCES wiki_pages (id), -- Clé externe obligatoire + FOREIGN KEY (id_membre) REFERENCES membres (id) -- Clé externe obligatoire +); +*/ + +-- +-- COMPTA +-- + +CREATE TABLE IF NOT EXISTS compta_exercices +-- Exercices +( + id INTEGER NOT NULL PRIMARY KEY, + + libelle TEXT NOT NULL, + + debut TEXT NOT NULL DEFAULT CURRENT_DATE CHECK (date(debut) IS NOT NULL AND date(debut) = debut), + fin TEXT NULL DEFAULT NULL CHECK (fin IS NULL OR (date(fin) IS NOT NULL AND date(fin) = fin)), + + cloture INTEGER NOT NULL DEFAULT 0 +); + + +CREATE TABLE IF NOT EXISTS compta_comptes +-- Plan comptable +( + id TEXT NOT NULL PRIMARY KEY, -- peut contenir des lettres, eg. 53A, 53B, etc. + parent TEXT NOT NULL DEFAULT 0, + + libelle TEXT NOT NULL, + + position INTEGER NOT NULL, -- position actif/passif/charge/produit + plan_comptable INTEGER NOT NULL DEFAULT 1, -- 1 = fait partie du plan comptable, 0 = a été ajouté par l'utilisateur + desactive INTEGER NOT NULL DEFAULT 0 -- 1 = compte historique désactivé +); + +CREATE INDEX IF NOT EXISTS compta_comptes_parent ON compta_comptes (parent); + +CREATE TABLE IF NOT EXISTS compta_comptes_bancaires +-- Comptes bancaires +( + id TEXT NOT NULL PRIMARY KEY, + + banque TEXT NOT NULL, + + iban TEXT NULL, + bic TEXT NULL, + + FOREIGN KEY(id) REFERENCES compta_comptes(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS compta_projets +-- Projets (compta analytique) +( + id INTEGER PRIMARY KEY NOT NULL, + + libelle TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS compta_journal +-- Journal des opérations comptables +( + id INTEGER PRIMARY KEY NOT NULL, + + libelle TEXT NOT NULL, + remarques TEXT NULL, + numero_piece TEXT NULL, -- N° de pièce comptable + + montant REAL NOT NULL, + + date TEXT NOT NULL DEFAULT CURRENT_DATE CHECK (date(date) IS NOT NULL AND date(date) = date), + moyen_paiement TEXT NULL, + numero_cheque TEXT NULL, + + compte_debit TEXT NULL, -- N° du compte dans le plan, NULL est utilisé pour une opération qui vient d'un exercice précédent + compte_credit TEXT NULL, -- N° du compte dans le plan + + id_exercice INTEGER NULL DEFAULT NULL, -- En cas de compta simple, l'exercice est permanent (NULL) + id_auteur INTEGER NULL, + id_categorie INTEGER NULL, -- Numéro de catégorie (en mode simple) + id_projet INTEGER NULL, + + FOREIGN KEY(moyen_paiement) REFERENCES compta_moyens_paiement(code), + FOREIGN KEY(compte_debit) REFERENCES compta_comptes(id), + FOREIGN KEY(compte_credit) REFERENCES compta_comptes(id), + FOREIGN KEY(id_exercice) REFERENCES compta_exercices(id), + FOREIGN KEY(id_auteur) REFERENCES membres(id) ON DELETE SET NULL, + FOREIGN KEY(id_categorie) REFERENCES compta_categories(id) ON DELETE SET NULL, + FOREIGN KEY(id_projet) REFERENCES compta_projets(id) ON DELETE SET NULL +); + +CREATE INDEX IF NOT EXISTS compta_operations_exercice ON compta_journal (id_exercice); +CREATE INDEX IF NOT EXISTS compta_operations_date ON compta_journal (date); +CREATE INDEX IF NOT EXISTS compta_operations_comptes ON compta_journal (compte_debit, compte_credit); +CREATE INDEX IF NOT EXISTS compta_operations_auteur ON compta_journal (id_auteur); + +CREATE TABLE IF NOT EXISTS compta_moyens_paiement +-- Moyens de paiement +( + code TEXT NOT NULL PRIMARY KEY, + nom TEXT NOT NULL +); + +--INSERT INTO compta_moyens_paiement (code, nom) VALUES ('AU', 'Autre'); +INSERT OR IGNORE INTO compta_moyens_paiement (code, nom) VALUES ('CB', 'Carte bleue'); +INSERT OR IGNORE INTO compta_moyens_paiement (code, nom) VALUES ('CH', 'Chèque'); +INSERT OR IGNORE INTO compta_moyens_paiement (code, nom) VALUES ('ES', 'Espèces'); +INSERT OR IGNORE INTO compta_moyens_paiement (code, nom) VALUES ('PR', 'Prélèvement'); +INSERT OR IGNORE INTO compta_moyens_paiement (code, nom) VALUES ('TI', 'TIP'); +INSERT OR IGNORE INTO compta_moyens_paiement (code, nom) VALUES ('VI', 'Virement'); + +CREATE TABLE IF NOT EXISTS compta_categories +-- Catégories pour simplifier le plan comptable +( + id INTEGER NOT NULL PRIMARY KEY, + type INTEGER NOT NULL DEFAULT 1, -- 1 = recette, -1 = dépense, 0 = autre (utilisé uniquement pour l'interface) + + intitule TEXT NOT NULL, + description TEXT NULL, + + compte TEXT NOT NULL, -- Compte affecté par cette catégorie + + FOREIGN KEY(compte) REFERENCES compta_comptes(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS plugins +( + id TEXT NOT NULL PRIMARY KEY, + officiel INTEGER NOT NULL DEFAULT 0, + nom TEXT NOT NULL, + description TEXT NULL, + auteur TEXT NULL, + url TEXT NULL, + version TEXT NOT NULL, + menu INTEGER NOT NULL DEFAULT 0, + config TEXT NULL +); + +CREATE TABLE IF NOT EXISTS plugins_signaux +-- Association entre plugins et signaux (hooks) +( + signal TEXT NOT NULL, + plugin TEXT NOT NULL REFERENCES plugins (id), + callback TEXT NOT NULL, + PRIMARY KEY (signal, plugin) +); + +CREATE TABLE IF NOT EXISTS compta_rapprochement +-- Rapprochement entre compta et relevés de comptes +( + id_operation INTEGER NOT NULL PRIMARY KEY REFERENCES compta_journal (id) ON DELETE CASCADE, + date TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP CHECK (datetime(date) IS NOT NULL AND datetime(date) = date), + id_auteur INTEGER NULL REFERENCES membres (id) +); + +CREATE TABLE IF NOT EXISTS fichiers +-- Données sur les fichiers +( + id INTEGER NOT NULL PRIMARY KEY, + nom TEXT NOT NULL, -- nom de fichier (par exemple image1234.jpeg) + type TEXT NULL, -- Type MIME + image INTEGER NOT NULL DEFAULT 0, -- 1 = image reconnue + datetime TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP CHECK (datetime(datetime) IS NOT NULL AND datetime(datetime) = datetime), -- Date d'ajout ou mise à jour du fichier + id_contenu INTEGER NOT NULL REFERENCES fichiers_contenu (id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS fichiers_date ON fichiers (datetime); + +CREATE TABLE IF NOT EXISTS fichiers_contenu +-- Contenu des fichiers +( + id INTEGER NOT NULL PRIMARY KEY, + hash TEXT NOT NULL, -- Hash SHA1 du contenu du fichier + taille INTEGER NOT NULL, -- Taille en octets + contenu BLOB NULL +); + +CREATE UNIQUE INDEX IF NOT EXISTS fichiers_hash ON fichiers_contenu (hash); + +CREATE TABLE IF NOT EXISTS fichiers_membres +-- Associations entre fichiers et membres (photo de profil par exemple) +( + fichier INTEGER NOT NULL REFERENCES fichiers (id), + id INTEGER NOT NULL REFERENCES membres (id), + PRIMARY KEY(fichier, id) +); + +CREATE TABLE IF NOT EXISTS fichiers_wiki_pages +-- Associations entre fichiers et pages du wiki +( + fichier INTEGER NOT NULL REFERENCES fichiers (id), + id INTEGER NOT NULL REFERENCES wiki_pages (id), + PRIMARY KEY(fichier, id) +); + +CREATE TABLE IF NOT EXISTS fichiers_compta_journal +-- Associations entre fichiers et journal de compta (pièce comptable par exemple) +( + fichier INTEGER NOT NULL REFERENCES fichiers (id), + id INTEGER NOT NULL REFERENCES compta_journal (id), + PRIMARY KEY(fichier, id) +); diff --git a/archives/0.8.3_migration.sql b/archives/0.8.3_migration.sql new file mode 100644 index 0000000..393340b --- /dev/null +++ b/archives/0.8.3_migration.sql @@ -0,0 +1,11 @@ +-- Ajout d'une clause ON DELETE SET NULL sur la table cotisations +ALTER TABLE cotisations_membres RENAME TO cotisations_membres_old; + +-- Création des tables mises à jour (et de leurs index) +.read 0.8.3_schema.sql + +-- Copie des données +INSERT INTO cotisations_membres SELECT * FROM cotisations_membres_old; + +-- Suppression des anciennes tables +DROP TABLE cotisations_membres_old; diff --git a/archives/0.8.3_schema.sql b/archives/0.8.3_schema.sql new file mode 100644 index 0000000..0e2cd1b --- /dev/null +++ b/archives/0.8.3_schema.sql @@ -0,0 +1,388 @@ +CREATE TABLE IF NOT EXISTS config ( +-- Configuration de Garradin + cle TEXT PRIMARY KEY NOT NULL, + valeur TEXT +); + +-- On stocke ici les ID de catégorie de compta correspondant aux types spéciaux +-- compta_categorie_cotisations => id_categorie +-- compta_categorie_dons => id_categorie + +CREATE TABLE IF NOT EXISTS membres_categories +-- Catégories de membres +( + id INTEGER PRIMARY KEY NOT NULL, + nom TEXT NOT NULL, + description TEXT NULL, + + droit_wiki INTEGER NOT NULL DEFAULT 1, + droit_membres INTEGER NOT NULL DEFAULT 1, + droit_compta INTEGER NOT NULL DEFAULT 1, + droit_inscription INTEGER NOT NULL DEFAULT 0, + droit_connexion INTEGER NOT NULL DEFAULT 1, + droit_config INTEGER NOT NULL DEFAULT 0, + cacher INTEGER NOT NULL DEFAULT 0, + + id_cotisation_obligatoire INTEGER NULL REFERENCES cotisations (id) ON DELETE SET NULL +); + +-- Membres de l'asso +-- Table dynamique générée par l'application +-- voir Garradin\Membres\Champs.php + +CREATE TABLE IF NOT EXISTS membres_sessions +-- Sessions +( + selecteur TEXT NOT NULL, + hash TEXT NOT NULL, + id_membre INTEGER NOT NULL REFERENCES membres (id) ON DELETE CASCADE, + expire INT NOT NULL, + + PRIMARY KEY (selecteur, id_membre) +); + +CREATE TABLE IF NOT EXISTS cotisations +-- Types de cotisations et activités +( + id INTEGER PRIMARY KEY NOT NULL, + id_categorie_compta INTEGER NULL REFERENCES compta_categories (id) ON DELETE SET NULL, -- NULL si le type n'est pas associé automatiquement à la compta + + intitule TEXT NOT NULL, + description TEXT NULL, + montant REAL NOT NULL, + + duree INTEGER NULL, -- En jours + debut TEXT NULL, -- timestamp + fin TEXT NULL +); + +CREATE TABLE IF NOT EXISTS cotisations_membres +-- Enregistrement des cotisations et activités +( + id INTEGER NOT NULL PRIMARY KEY, + id_membre INTEGER NOT NULL REFERENCES membres (id) ON DELETE CASCADE, + id_cotisation INTEGER NOT NULL REFERENCES cotisations (id) ON DELETE CASCADE, + + date TEXT NOT NULL DEFAULT CURRENT_DATE CHECK (date(date) IS NOT NULL AND date(date) = date) +); + +CREATE UNIQUE INDEX IF NOT EXISTS cm_unique ON cotisations_membres (id_membre, id_cotisation, date); + +CREATE TABLE IF NOT EXISTS membres_operations +-- Liaison des enregistrement des paiements en compta +( + id_membre INTEGER NOT NULL REFERENCES membres (id) ON DELETE CASCADE, + id_operation INTEGER NOT NULL REFERENCES compta_journal (id) ON DELETE CASCADE, + id_cotisation INTEGER NULL REFERENCES cotisations_membres (id) ON DELETE SET NULL, + + PRIMARY KEY (id_membre, id_operation) +); + +CREATE TABLE IF NOT EXISTS rappels +-- Rappels de devoir renouveller une cotisation +( + id INTEGER NOT NULL PRIMARY KEY, + id_cotisation INTEGER NOT NULL REFERENCES cotisations (id) ON DELETE CASCADE, + + delai INTEGER NOT NULL, -- Délai en jours pour envoyer le rappel + + sujet TEXT NOT NULL, + texte TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS rappels_envoyes +-- Enregistrement des rappels envoyés à qui et quand +( + id INTEGER NOT NULL PRIMARY KEY, + + id_membre INTEGER NOT NULL REFERENCES membres (id) ON DELETE CASCADE, + id_cotisation INTEGER NOT NULL REFERENCES cotisations (id) ON DELETE CASCADE, + id_rappel INTEGER NULL REFERENCES rappels (id) ON DELETE CASCADE, + + date TEXT NOT NULL DEFAULT CURRENT_DATE CHECK (date(date) IS NOT NULL AND date(date) = date), + + media INTEGER NOT NULL -- Média utilisé pour le rappel : 1 = email, 2 = courrier, 3 = autre +); + +-- +-- WIKI +-- + +CREATE TABLE IF NOT EXISTS wiki_pages +-- Pages du wiki +( + id INTEGER PRIMARY KEY NOT NULL, + uri TEXT NOT NULL, -- URI unique (équivalent NomPageWiki) + titre TEXT NOT NULL, + date_creation TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP CHECK (datetime(date_creation) IS NOT NULL AND datetime(date_creation) = date_creation), + date_modification TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP CHECK (datetime(date_modification) IS NOT NULL AND datetime(date_modification) = date_modification), + parent INTEGER NOT NULL DEFAULT 0, -- ID de la page parent + revision INTEGER NOT NULL DEFAULT 0, -- Numéro de révision (commence à 0 si pas de texte, +1 à chaque changement du texte) + droit_lecture INTEGER NOT NULL DEFAULT 0, -- Accès en lecture (-1 = public [site web], 0 = tous ceux qui ont accès en lecture au wiki, 1+ = ID de groupe) + droit_ecriture INTEGER NOT NULL DEFAULT 0 -- Accès en écriture (0 = tous ceux qui ont droit d'écriture sur le wiki, 1+ = ID de groupe) +); + +CREATE UNIQUE INDEX IF NOT EXISTS wiki_uri ON wiki_pages (uri); + +CREATE VIRTUAL TABLE IF NOT EXISTS wiki_recherche USING fts4 +-- Table dupliquée pour chercher une page +( + id INT PRIMARY KEY NOT NULL, -- Clé externe obligatoire + titre TEXT NOT NULL, + contenu TEXT NULL, -- Contenu de la dernière révision + FOREIGN KEY (id) REFERENCES wiki_pages(id) +); + +CREATE TABLE IF NOT EXISTS wiki_revisions +-- Révisions du contenu des pages +( + id_page INTEGER NOT NULL REFERENCES wiki_pages (id) ON DELETE CASCADE, + revision INTEGER NULL, + + id_auteur INTEGER NULL REFERENCES membres (id) ON DELETE SET NULL, + + contenu TEXT NOT NULL, + modification TEXT NULL, -- Description des modifications effectuées + chiffrement INTEGER NOT NULL DEFAULT 0, -- 1 si le contenu est chiffré, 0 sinon + date TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP CHECK (datetime(date) IS NOT NULL AND datetime(date) = date), + + PRIMARY KEY(id_page, revision) +); + +CREATE INDEX IF NOT EXISTS wiki_revisions_id_page ON wiki_revisions (id_page); +CREATE INDEX IF NOT EXISTS wiki_revisions_id_auteur ON wiki_revisions (id_auteur); + +-- Triggers pour synchro avec table wiki_pages +CREATE TRIGGER IF NOT EXISTS wiki_recherche_delete AFTER DELETE ON wiki_pages + BEGIN + DELETE FROM wiki_recherche WHERE id = old.id; + END; + +CREATE TRIGGER IF NOT EXISTS wiki_recherche_update AFTER UPDATE OF id, titre ON wiki_pages + BEGIN + UPDATE wiki_recherche SET id = new.id, titre = new.titre WHERE id = old.id; + END; + +-- Trigger pour mettre à jour le contenu de la table de recherche lors d'une nouvelle révision +CREATE TRIGGER IF NOT EXISTS wiki_recherche_contenu_insert AFTER INSERT ON wiki_revisions WHEN new.chiffrement != 1 + BEGIN + UPDATE wiki_recherche SET contenu = new.contenu WHERE id = new.id_page; + END; + +-- Si le contenu est chiffré, la recherche n'affiche pas de contenu +CREATE TRIGGER IF NOT EXISTS wiki_recherche_contenu_chiffre AFTER INSERT ON wiki_revisions WHEN new.chiffrement = 1 + BEGIN + UPDATE wiki_recherche SET contenu = '' WHERE id = new.id_page; + END; + +/* +CREATE TABLE wiki_suivi +-- Suivi des pages +( + id_membre INTEGER NOT NULL, + id_page INTEGER NOT NULL, + + PRIMARY KEY (id_membre, id_page), + + FOREIGN KEY (id_page) REFERENCES wiki_pages (id), -- Clé externe obligatoire + FOREIGN KEY (id_membre) REFERENCES membres (id) -- Clé externe obligatoire +); +*/ + +-- +-- COMPTA +-- + +CREATE TABLE IF NOT EXISTS compta_exercices +-- Exercices +( + id INTEGER NOT NULL PRIMARY KEY, + + libelle TEXT NOT NULL, + + debut TEXT NOT NULL DEFAULT CURRENT_DATE CHECK (date(debut) IS NOT NULL AND date(debut) = debut), + fin TEXT NULL DEFAULT NULL CHECK (fin IS NULL OR (date(fin) IS NOT NULL AND date(fin) = fin)), + + cloture INTEGER NOT NULL DEFAULT 0 +); + + +CREATE TABLE IF NOT EXISTS compta_comptes +-- Plan comptable +( + id TEXT NOT NULL PRIMARY KEY, -- peut contenir des lettres, eg. 53A, 53B, etc. + parent TEXT NOT NULL DEFAULT 0, + + libelle TEXT NOT NULL, + + position INTEGER NOT NULL, -- position actif/passif/charge/produit + plan_comptable INTEGER NOT NULL DEFAULT 1, -- 1 = fait partie du plan comptable, 0 = a été ajouté par l'utilisateur + desactive INTEGER NOT NULL DEFAULT 0 -- 1 = compte historique désactivé +); + +CREATE INDEX IF NOT EXISTS compta_comptes_parent ON compta_comptes (parent); + +CREATE TABLE IF NOT EXISTS compta_comptes_bancaires +-- Comptes bancaires +( + id TEXT NOT NULL PRIMARY KEY, + + banque TEXT NOT NULL, + + iban TEXT NULL, + bic TEXT NULL, + + FOREIGN KEY(id) REFERENCES compta_comptes(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS compta_projets +-- Projets (compta analytique) +( + id INTEGER PRIMARY KEY NOT NULL, + + libelle TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS compta_journal +-- Journal des opérations comptables +( + id INTEGER PRIMARY KEY NOT NULL, + + libelle TEXT NOT NULL, + remarques TEXT NULL, + numero_piece TEXT NULL, -- N° de pièce comptable + + montant REAL NOT NULL, + + date TEXT NOT NULL DEFAULT CURRENT_DATE CHECK (date(date) IS NOT NULL AND date(date) = date), + moyen_paiement TEXT NULL, + numero_cheque TEXT NULL, + + compte_debit TEXT NULL, -- N° du compte dans le plan, NULL est utilisé pour une opération qui vient d'un exercice précédent + compte_credit TEXT NULL, -- N° du compte dans le plan + + id_exercice INTEGER NULL DEFAULT NULL, -- En cas de compta simple, l'exercice est permanent (NULL) + id_auteur INTEGER NULL, + id_categorie INTEGER NULL, -- Numéro de catégorie (en mode simple) + id_projet INTEGER NULL, + + FOREIGN KEY(moyen_paiement) REFERENCES compta_moyens_paiement(code), + FOREIGN KEY(compte_debit) REFERENCES compta_comptes(id), + FOREIGN KEY(compte_credit) REFERENCES compta_comptes(id), + FOREIGN KEY(id_exercice) REFERENCES compta_exercices(id), + FOREIGN KEY(id_auteur) REFERENCES membres(id) ON DELETE SET NULL, + FOREIGN KEY(id_categorie) REFERENCES compta_categories(id) ON DELETE SET NULL, + FOREIGN KEY(id_projet) REFERENCES compta_projets(id) ON DELETE SET NULL +); + +CREATE INDEX IF NOT EXISTS compta_operations_exercice ON compta_journal (id_exercice); +CREATE INDEX IF NOT EXISTS compta_operations_date ON compta_journal (date); +CREATE INDEX IF NOT EXISTS compta_operations_comptes ON compta_journal (compte_debit, compte_credit); +CREATE INDEX IF NOT EXISTS compta_operations_auteur ON compta_journal (id_auteur); + +CREATE TABLE IF NOT EXISTS compta_moyens_paiement +-- Moyens de paiement +( + code TEXT NOT NULL PRIMARY KEY, + nom TEXT NOT NULL +); + +--INSERT INTO compta_moyens_paiement (code, nom) VALUES ('AU', 'Autre'); +INSERT OR IGNORE INTO compta_moyens_paiement (code, nom) VALUES ('CB', 'Carte bleue'); +INSERT OR IGNORE INTO compta_moyens_paiement (code, nom) VALUES ('CH', 'Chèque'); +INSERT OR IGNORE INTO compta_moyens_paiement (code, nom) VALUES ('ES', 'Espèces'); +INSERT OR IGNORE INTO compta_moyens_paiement (code, nom) VALUES ('PR', 'Prélèvement'); +INSERT OR IGNORE INTO compta_moyens_paiement (code, nom) VALUES ('TI', 'TIP'); +INSERT OR IGNORE INTO compta_moyens_paiement (code, nom) VALUES ('VI', 'Virement'); + +CREATE TABLE IF NOT EXISTS compta_categories +-- Catégories pour simplifier le plan comptable +( + id INTEGER NOT NULL PRIMARY KEY, + type INTEGER NOT NULL DEFAULT 1, -- 1 = recette, -1 = dépense, 0 = autre (utilisé uniquement pour l'interface) + + intitule TEXT NOT NULL, + description TEXT NULL, + + compte TEXT NOT NULL, -- Compte affecté par cette catégorie + + FOREIGN KEY(compte) REFERENCES compta_comptes(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS plugins +( + id TEXT NOT NULL PRIMARY KEY, + officiel INTEGER NOT NULL DEFAULT 0, + nom TEXT NOT NULL, + description TEXT NULL, + auteur TEXT NULL, + url TEXT NULL, + version TEXT NOT NULL, + menu INTEGER NOT NULL DEFAULT 0, + config TEXT NULL +); + +CREATE TABLE IF NOT EXISTS plugins_signaux +-- Association entre plugins et signaux (hooks) +( + signal TEXT NOT NULL, + plugin TEXT NOT NULL REFERENCES plugins (id), + callback TEXT NOT NULL, + PRIMARY KEY (signal, plugin) +); + +CREATE TABLE IF NOT EXISTS compta_rapprochement +-- Rapprochement entre compta et relevés de comptes +( + id_operation INTEGER NOT NULL PRIMARY KEY REFERENCES compta_journal (id) ON DELETE CASCADE, + date TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP CHECK (datetime(date) IS NOT NULL AND datetime(date) = date), + id_auteur INTEGER NULL REFERENCES membres (id) +); + +CREATE TABLE IF NOT EXISTS fichiers +-- Données sur les fichiers +( + id INTEGER NOT NULL PRIMARY KEY, + nom TEXT NOT NULL, -- nom de fichier (par exemple image1234.jpeg) + type TEXT NULL, -- Type MIME + image INTEGER NOT NULL DEFAULT 0, -- 1 = image reconnue + datetime TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP CHECK (datetime(datetime) IS NOT NULL AND datetime(datetime) = datetime), -- Date d'ajout ou mise à jour du fichier + id_contenu INTEGER NOT NULL REFERENCES fichiers_contenu (id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS fichiers_date ON fichiers (datetime); + +CREATE TABLE IF NOT EXISTS fichiers_contenu +-- Contenu des fichiers +( + id INTEGER NOT NULL PRIMARY KEY, + hash TEXT NOT NULL, -- Hash SHA1 du contenu du fichier + taille INTEGER NOT NULL, -- Taille en octets + contenu BLOB NULL +); + +CREATE UNIQUE INDEX IF NOT EXISTS fichiers_hash ON fichiers_contenu (hash); + +CREATE TABLE IF NOT EXISTS fichiers_membres +-- Associations entre fichiers et membres (photo de profil par exemple) +( + fichier INTEGER NOT NULL REFERENCES fichiers (id), + id INTEGER NOT NULL REFERENCES membres (id), + PRIMARY KEY(fichier, id) +); + +CREATE TABLE IF NOT EXISTS fichiers_wiki_pages +-- Associations entre fichiers et pages du wiki +( + fichier INTEGER NOT NULL REFERENCES fichiers (id), + id INTEGER NOT NULL REFERENCES wiki_pages (id), + PRIMARY KEY(fichier, id) +); + +CREATE TABLE IF NOT EXISTS fichiers_compta_journal +-- Associations entre fichiers et journal de compta (pièce comptable par exemple) +( + fichier INTEGER NOT NULL REFERENCES fichiers (id), + id INTEGER NOT NULL REFERENCES compta_journal (id), + PRIMARY KEY(fichier, id) +); diff --git a/archives/0.8.4_migration.sql b/archives/0.8.4_migration.sql new file mode 100644 index 0000000..993159d --- /dev/null +++ b/archives/0.8.4_migration.sql @@ -0,0 +1,3 @@ +-- Mise à jour des URI du wiki pour ne pas inclure les tirets en début et fin de chaîne +-- (problème de concordance entre API PHP et données SQLite) +UPDATE wiki_pages SET uri = trim(uri, '-') WHERE uri != trim(uri, '-'); diff --git a/archives/0.9.0_migration.sql b/archives/0.9.0_migration.sql new file mode 100644 index 0000000..11eb5dd --- /dev/null +++ b/archives/0.9.0_migration.sql @@ -0,0 +1,35 @@ +-- Désactivation de l'accès aux membres, pour les groupes qui n'avaient que le droit de lecture +-- car maintenant ce droit permet de voir les fiches de membres complètes +UPDATE membres_categories SET droit_membres = 0 WHERE droit_membres = 1; + +-- Suppression de la colonne description des catégories +ALTER TABLE membres_categories RENAME TO membres_categories_old; + +-- Mise à jour table compta_rapprochement: la foreign key sur membres est passée +-- à ON DELETE SET NULL +ALTER TABLE compta_rapprochement RENAME TO compta_rapprochement_old; + +-- Re-créer la table +-- Créer également les nouvelles tables email +.read 0.9.0_schema.sql + +-- Copie des données, sauf la colonne description +INSERT INTO membres_categories SELECT id, nom, droit_wiki, + droit_membres, droit_compta, droit_inscription, + droit_connexion, droit_config, cacher, + id_cotisation_obligatoire FROM membres_categories_old; + +-- Suppression des anciennes tables +DROP TABLE membres_categories_old; + +-- Migration des données +INSERT INTO compta_rapprochement SELECT * FROM compta_rapprochement_old; +DROP TABLE compta_rapprochement_old; + +-- Cette variable n'est plus utilisée +DELETE FROM config WHERE cle = 'email_envoi_automatique'; + +ALTER TABLE plugins ADD COLUMN menu_condition TEXT NULL; + +-- Supprimer le début dans le nom des plugins +UPDATE plugins_signaux SET callback = replace(callback, 'Garradin\Plugin\', ''); \ No newline at end of file diff --git a/archives/0.9.0_schema.sql b/archives/0.9.0_schema.sql new file mode 100644 index 0000000..a6138c0 --- /dev/null +++ b/archives/0.9.0_schema.sql @@ -0,0 +1,35 @@ +-- Désactivation de l'accès aux membres, pour les groupes qui n'avaient que le droit de lecture +-- car maintenant ce droit permet de voir les fiches de membres complètes +UPDATE membres_categories SET droit_membres = 0 WHERE droit_membres = 1; + +-- Suppression de la colonne description des catégories +ALTER TABLE membres_categories RENAME TO membres_categories_old; + +-- Mise à jour table compta_rapprochement: la foreign key sur membres est passée +-- à ON DELETE SET NULL +ALTER TABLE compta_rapprochement RENAME TO compta_rapprochement_old; + +-- Re-créer la table +-- Créer également les nouvelles tables email +.read schema.sql + +-- Copie des données, sauf la colonne description +INSERT INTO membres_categories SELECT id, nom, droit_wiki, + droit_membres, droit_compta, droit_inscription, + droit_connexion, droit_config, cacher, + id_cotisation_obligatoire FROM membres_categories_old; + +-- Suppression des anciennes tables +DROP TABLE membres_categories_old; + +-- Migration des données +INSERT INTO compta_rapprochement SELECT * FROM compta_rapprochement_old; +DROP TABLE compta_rapprochement_old; + +-- Cette variable n'est plus utilisée +DELETE FROM config WHERE cle = 'email_envoi_automatique'; + +ALTER TABLE plugins ADD COLUMN menu_condition TEXT NULL; + +-- Supprimer le début dans le nom des plugins +UPDATE plugins_signaux SET callback = replace(callback, 'Garradin\Plugin\', ''); \ No newline at end of file diff --git a/archives/0.9.1_migration.sql b/archives/0.9.1_migration.sql new file mode 100644 index 0000000..1c852c7 --- /dev/null +++ b/archives/0.9.1_migration.sql @@ -0,0 +1,14 @@ +-- Il manquait une clause ON DELETE SET NULL sur la foreign key +-- de cotisations quand on faisait une mise à jour depuis une +-- ancienne version +ALTER TABLE cotisations RENAME TO cotisations_old; + +.read 0.9.1_schema.sql + +INSERT INTO cotisations SELECT * FROM cotisations_old; + +DROP TABLE cotisations_old; + +-- Changer le compte des reports automatiques +UPDATE compta_journal SET compte_debit = '890' WHERE compte_debit IS NULL; +UPDATE compta_journal SET compte_credit = '890' WHERE compte_credit IS NULL; diff --git a/archives/0.9.1_schema.sql b/archives/0.9.1_schema.sql new file mode 100644 index 0000000..e94e5d7 --- /dev/null +++ b/archives/0.9.1_schema.sql @@ -0,0 +1,400 @@ +CREATE TABLE IF NOT EXISTS config ( +-- Configuration de Garradin + cle TEXT PRIMARY KEY NOT NULL, + valeur TEXT +); + +-- On stocke ici les ID de catégorie de compta correspondant aux types spéciaux +-- compta_categorie_cotisations => id_categorie +-- compta_categorie_dons => id_categorie + +CREATE TABLE IF NOT EXISTS membres_categories +-- Catégories de membres +( + id INTEGER PRIMARY KEY NOT NULL, + nom TEXT NOT NULL, + + droit_wiki INTEGER NOT NULL DEFAULT 1, + droit_membres INTEGER NOT NULL DEFAULT 1, + droit_compta INTEGER NOT NULL DEFAULT 1, + droit_inscription INTEGER NOT NULL DEFAULT 0, + droit_connexion INTEGER NOT NULL DEFAULT 1, + droit_config INTEGER NOT NULL DEFAULT 0, + cacher INTEGER NOT NULL DEFAULT 0, + + id_cotisation_obligatoire INTEGER NULL REFERENCES cotisations (id) ON DELETE SET NULL +); + +-- Membres de l'asso +-- Table dynamique générée par l'application +-- voir Garradin\Membres\Champs.php + +CREATE TABLE IF NOT EXISTS membres_sessions +-- Sessions +( + selecteur TEXT NOT NULL, + hash TEXT NOT NULL, + id_membre INTEGER NOT NULL REFERENCES membres (id) ON DELETE CASCADE, + expire INT NOT NULL, + + PRIMARY KEY (selecteur, id_membre) +); + +CREATE TABLE IF NOT EXISTS cotisations +-- Types de cotisations et activités +( + id INTEGER PRIMARY KEY NOT NULL, + id_categorie_compta INTEGER NULL REFERENCES compta_categories (id) ON DELETE SET NULL, -- NULL si le type n'est pas associé automatiquement à la compta + + intitule TEXT NOT NULL, + description TEXT NULL, + montant REAL NOT NULL, + + duree INTEGER NULL, -- En jours + debut TEXT NULL, -- timestamp + fin TEXT NULL +); + +CREATE TABLE IF NOT EXISTS cotisations_membres +-- Enregistrement des cotisations et activités +( + id INTEGER NOT NULL PRIMARY KEY, + id_membre INTEGER NOT NULL REFERENCES membres (id) ON DELETE CASCADE, + id_cotisation INTEGER NOT NULL REFERENCES cotisations (id) ON DELETE CASCADE, + + date TEXT NOT NULL DEFAULT CURRENT_DATE CHECK (date(date) IS NOT NULL AND date(date) = date) +); + +CREATE UNIQUE INDEX IF NOT EXISTS cm_unique ON cotisations_membres (id_membre, id_cotisation, date); + +CREATE TABLE IF NOT EXISTS membres_operations +-- Liaison des enregistrement des paiements en compta +( + id_membre INTEGER NOT NULL REFERENCES membres (id) ON DELETE CASCADE, + id_operation INTEGER NOT NULL REFERENCES compta_journal (id) ON DELETE CASCADE, + id_cotisation INTEGER NULL REFERENCES cotisations_membres (id) ON DELETE SET NULL, + + PRIMARY KEY (id_membre, id_operation) +); + +CREATE TABLE IF NOT EXISTS rappels +-- Rappels de devoir renouveller une cotisation +( + id INTEGER NOT NULL PRIMARY KEY, + id_cotisation INTEGER NOT NULL REFERENCES cotisations (id) ON DELETE CASCADE, + + delai INTEGER NOT NULL, -- Délai en jours pour envoyer le rappel + + sujet TEXT NOT NULL, + texte TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS rappels_envoyes +-- Enregistrement des rappels envoyés à qui et quand +( + id INTEGER NOT NULL PRIMARY KEY, + + id_membre INTEGER NOT NULL REFERENCES membres (id) ON DELETE CASCADE, + id_cotisation INTEGER NOT NULL REFERENCES cotisations (id) ON DELETE CASCADE, + id_rappel INTEGER NULL REFERENCES rappels (id) ON DELETE CASCADE, + + date TEXT NOT NULL DEFAULT CURRENT_DATE CHECK (date(date) IS NOT NULL AND date(date) = date), + + media INTEGER NOT NULL -- Média utilisé pour le rappel : 1 = email, 2 = courrier, 3 = autre +); + +-- +-- WIKI +-- + +CREATE TABLE IF NOT EXISTS wiki_pages +-- Pages du wiki +( + id INTEGER PRIMARY KEY NOT NULL, + uri TEXT NOT NULL, -- URI unique (équivalent NomPageWiki) + titre TEXT NOT NULL, + date_creation TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP CHECK (datetime(date_creation) IS NOT NULL AND datetime(date_creation) = date_creation), + date_modification TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP CHECK (datetime(date_modification) IS NOT NULL AND datetime(date_modification) = date_modification), + parent INTEGER NOT NULL DEFAULT 0, -- ID de la page parent + revision INTEGER NOT NULL DEFAULT 0, -- Numéro de révision (commence à 0 si pas de texte, +1 à chaque changement du texte) + droit_lecture INTEGER NOT NULL DEFAULT 0, -- Accès en lecture (-1 = public [site web], 0 = tous ceux qui ont accès en lecture au wiki, 1+ = ID de groupe) + droit_ecriture INTEGER NOT NULL DEFAULT 0 -- Accès en écriture (0 = tous ceux qui ont droit d'écriture sur le wiki, 1+ = ID de groupe) +); + +CREATE UNIQUE INDEX IF NOT EXISTS wiki_uri ON wiki_pages (uri); + +CREATE VIRTUAL TABLE IF NOT EXISTS wiki_recherche USING fts4 +-- Table dupliquée pour chercher une page +( + id INT PRIMARY KEY NOT NULL, -- Clé externe obligatoire + titre TEXT NOT NULL, + contenu TEXT NULL, -- Contenu de la dernière révision + FOREIGN KEY (id) REFERENCES wiki_pages(id) +); + +CREATE TABLE IF NOT EXISTS wiki_revisions +-- Révisions du contenu des pages +( + id_page INTEGER NOT NULL REFERENCES wiki_pages (id) ON DELETE CASCADE, + revision INTEGER NULL, + + id_auteur INTEGER NULL REFERENCES membres (id) ON DELETE SET NULL, + + contenu TEXT NOT NULL, + modification TEXT NULL, -- Description des modifications effectuées + chiffrement INTEGER NOT NULL DEFAULT 0, -- 1 si le contenu est chiffré, 0 sinon + date TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP CHECK (datetime(date) IS NOT NULL AND datetime(date) = date), + + PRIMARY KEY(id_page, revision) +); + +CREATE INDEX IF NOT EXISTS wiki_revisions_id_page ON wiki_revisions (id_page); +CREATE INDEX IF NOT EXISTS wiki_revisions_id_auteur ON wiki_revisions (id_auteur); + +-- Triggers pour synchro avec table wiki_pages +CREATE TRIGGER IF NOT EXISTS wiki_recherche_delete AFTER DELETE ON wiki_pages + BEGIN + DELETE FROM wiki_recherche WHERE id = old.id; + END; + +CREATE TRIGGER IF NOT EXISTS wiki_recherche_update AFTER UPDATE OF id, titre ON wiki_pages + BEGIN + UPDATE wiki_recherche SET id = new.id, titre = new.titre WHERE id = old.id; + END; + +-- Trigger pour mettre à jour le contenu de la table de recherche lors d'une nouvelle révision +CREATE TRIGGER IF NOT EXISTS wiki_recherche_contenu_insert AFTER INSERT ON wiki_revisions WHEN new.chiffrement != 1 + BEGIN + UPDATE wiki_recherche SET contenu = new.contenu WHERE id = new.id_page; + END; + +-- Si le contenu est chiffré, la recherche n'affiche pas de contenu +CREATE TRIGGER IF NOT EXISTS wiki_recherche_contenu_chiffre AFTER INSERT ON wiki_revisions WHEN new.chiffrement = 1 + BEGIN + UPDATE wiki_recherche SET contenu = '' WHERE id = new.id_page; + END; + +/* +CREATE TABLE wiki_suivi +-- Suivi des pages +( + id_membre INTEGER NOT NULL, + id_page INTEGER NOT NULL, + + PRIMARY KEY (id_membre, id_page), + + FOREIGN KEY (id_page) REFERENCES wiki_pages (id), -- Clé externe obligatoire + FOREIGN KEY (id_membre) REFERENCES membres (id) -- Clé externe obligatoire +); +*/ + +-- +-- COMPTA +-- + +CREATE TABLE IF NOT EXISTS compta_exercices +-- Exercices +( + id INTEGER NOT NULL PRIMARY KEY, + + libelle TEXT NOT NULL, + + debut TEXT NOT NULL DEFAULT CURRENT_DATE CHECK (date(debut) IS NOT NULL AND date(debut) = debut), + fin TEXT NULL DEFAULT NULL CHECK (fin IS NULL OR (date(fin) IS NOT NULL AND date(fin) = fin)), + + cloture INTEGER NOT NULL DEFAULT 0 +); + + +CREATE TABLE IF NOT EXISTS compta_comptes +-- Plan comptable +( + id TEXT NOT NULL PRIMARY KEY, -- peut contenir des lettres, eg. 53A, 53B, etc. + parent TEXT NOT NULL DEFAULT 0, + + libelle TEXT NOT NULL, + + position INTEGER NOT NULL, -- position actif/passif/charge/produit + plan_comptable INTEGER NOT NULL DEFAULT 1, -- 1 = fait partie du plan comptable, 0 = a été ajouté par l'utilisateur + desactive INTEGER NOT NULL DEFAULT 0 -- 1 = compte historique désactivé +); + +CREATE INDEX IF NOT EXISTS compta_comptes_parent ON compta_comptes (parent); + +CREATE TABLE IF NOT EXISTS compta_comptes_bancaires +-- Comptes bancaires +( + id TEXT NOT NULL PRIMARY KEY, + + banque TEXT NOT NULL, + + iban TEXT NULL, + bic TEXT NULL, + + FOREIGN KEY(id) REFERENCES compta_comptes(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS compta_projets +-- Projets (compta analytique) +( + id INTEGER PRIMARY KEY NOT NULL, + + libelle TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS compta_journal +-- Journal des opérations comptables +( + id INTEGER PRIMARY KEY NOT NULL, + + libelle TEXT NOT NULL, + remarques TEXT NULL, + numero_piece TEXT NULL, -- N° de pièce comptable + + montant REAL NOT NULL, + + date TEXT NOT NULL DEFAULT CURRENT_DATE CHECK (date(date) IS NOT NULL AND date(date) = date), + moyen_paiement TEXT NULL, + numero_cheque TEXT NULL, + + compte_debit TEXT NULL, -- N° du compte dans le plan, NULL est utilisé pour une opération qui vient d'un exercice précédent + compte_credit TEXT NULL, -- N° du compte dans le plan + + id_exercice INTEGER NULL DEFAULT NULL, -- En cas de compta simple, l'exercice est permanent (NULL) + id_auteur INTEGER NULL, + id_categorie INTEGER NULL, -- Numéro de catégorie (en mode simple) + id_projet INTEGER NULL, + + FOREIGN KEY(moyen_paiement) REFERENCES compta_moyens_paiement(code), + FOREIGN KEY(compte_debit) REFERENCES compta_comptes(id), + FOREIGN KEY(compte_credit) REFERENCES compta_comptes(id), + FOREIGN KEY(id_exercice) REFERENCES compta_exercices(id), + FOREIGN KEY(id_auteur) REFERENCES membres(id) ON DELETE SET NULL, + FOREIGN KEY(id_categorie) REFERENCES compta_categories(id) ON DELETE SET NULL, + FOREIGN KEY(id_projet) REFERENCES compta_projets(id) ON DELETE SET NULL +); + +CREATE INDEX IF NOT EXISTS compta_operations_exercice ON compta_journal (id_exercice); +CREATE INDEX IF NOT EXISTS compta_operations_date ON compta_journal (date); +CREATE INDEX IF NOT EXISTS compta_operations_comptes ON compta_journal (compte_debit, compte_credit); +CREATE INDEX IF NOT EXISTS compta_operations_auteur ON compta_journal (id_auteur); + +CREATE TABLE IF NOT EXISTS compta_moyens_paiement +-- Moyens de paiement +( + code TEXT NOT NULL PRIMARY KEY, + nom TEXT NOT NULL +); + +--INSERT INTO compta_moyens_paiement (code, nom) VALUES ('AU', 'Autre'); +INSERT OR IGNORE INTO compta_moyens_paiement (code, nom) VALUES ('CB', 'Carte bleue'); +INSERT OR IGNORE INTO compta_moyens_paiement (code, nom) VALUES ('CH', 'Chèque'); +INSERT OR IGNORE INTO compta_moyens_paiement (code, nom) VALUES ('ES', 'Espèces'); +INSERT OR IGNORE INTO compta_moyens_paiement (code, nom) VALUES ('PR', 'Prélèvement'); +INSERT OR IGNORE INTO compta_moyens_paiement (code, nom) VALUES ('TI', 'TIP'); +INSERT OR IGNORE INTO compta_moyens_paiement (code, nom) VALUES ('VI', 'Virement'); + +CREATE TABLE IF NOT EXISTS compta_categories +-- Catégories pour simplifier le plan comptable +( + id INTEGER NOT NULL PRIMARY KEY, + type INTEGER NOT NULL DEFAULT 1, -- 1 = recette, -1 = dépense, 0 = autre (utilisé uniquement pour l'interface) + + intitule TEXT NOT NULL, + description TEXT NULL, + + compte TEXT NOT NULL, -- Compte affecté par cette catégorie + + FOREIGN KEY(compte) REFERENCES compta_comptes(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS plugins +( + id TEXT NOT NULL PRIMARY KEY, + officiel INTEGER NOT NULL DEFAULT 0, + nom TEXT NOT NULL, + description TEXT NULL, + auteur TEXT NULL, + url TEXT NULL, + version TEXT NOT NULL, + menu INTEGER NOT NULL DEFAULT 0, + menu_condition TEXT NULL, + config TEXT NULL +); + +CREATE TABLE IF NOT EXISTS plugins_signaux +-- Association entre plugins et signaux (hooks) +( + signal TEXT NOT NULL, + plugin TEXT NOT NULL REFERENCES plugins (id), + callback TEXT NOT NULL, + PRIMARY KEY (signal, plugin) +); + +CREATE TABLE IF NOT EXISTS compta_rapprochement +-- Rapprochement entre compta et relevés de comptes +( + id_operation INTEGER NOT NULL PRIMARY KEY REFERENCES compta_journal (id) ON DELETE CASCADE, + date TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP CHECK (datetime(date) IS NOT NULL AND datetime(date) = date), + id_auteur INTEGER NULL REFERENCES membres (id) ON DELETE SET NULL +); + +CREATE TABLE IF NOT EXISTS fichiers +-- Données sur les fichiers +( + id INTEGER NOT NULL PRIMARY KEY, + nom TEXT NOT NULL, -- nom de fichier (par exemple image1234.jpeg) + type TEXT NULL, -- Type MIME + image INTEGER NOT NULL DEFAULT 0, -- 1 = image reconnue + datetime TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP CHECK (datetime(datetime) IS NOT NULL AND datetime(datetime) = datetime), -- Date d'ajout ou mise à jour du fichier + id_contenu INTEGER NOT NULL REFERENCES fichiers_contenu (id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS fichiers_date ON fichiers (datetime); + +CREATE TABLE IF NOT EXISTS fichiers_contenu +-- Contenu des fichiers +( + id INTEGER NOT NULL PRIMARY KEY, + hash TEXT NOT NULL, -- Hash SHA1 du contenu du fichier + taille INTEGER NOT NULL, -- Taille en octets + contenu BLOB NULL +); + +CREATE UNIQUE INDEX IF NOT EXISTS fichiers_hash ON fichiers_contenu (hash); + +CREATE TABLE IF NOT EXISTS fichiers_membres +-- Associations entre fichiers et membres (photo de profil par exemple) +( + fichier INTEGER NOT NULL REFERENCES fichiers (id), + id INTEGER NOT NULL REFERENCES membres (id), + PRIMARY KEY(fichier, id) +); + +CREATE TABLE IF NOT EXISTS fichiers_wiki_pages +-- Associations entre fichiers et pages du wiki +( + fichier INTEGER NOT NULL REFERENCES fichiers (id), + id INTEGER NOT NULL REFERENCES wiki_pages (id), + PRIMARY KEY(fichier, id) +); + +CREATE TABLE IF NOT EXISTS fichiers_compta_journal +-- Associations entre fichiers et journal de compta (pièce comptable par exemple) +( + fichier INTEGER NOT NULL REFERENCES fichiers (id), + id INTEGER NOT NULL REFERENCES compta_journal (id), + PRIMARY KEY(fichier, id) +); + +CREATE TABLE IF NOT EXISTS recherches +-- Recherches enregistrées +( + id INTEGER NOT NULL PRIMARY KEY, + id_membre INTEGER NULL REFERENCES membres (id) ON DELETE CASCADE, -- Si non NULL, alors la recherche ne sera visible que par le membre associé + intitule TEXT NOT NULL, + creation TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP CHECK (datetime(creation) IS NOT NULL AND datetime(creation) = creation), + cible TEXT NOT NULL, -- "membres" ou "compta_journal" + type TEXT NOT NULL, -- "json" ou "sql" + contenu TEXT NOT NULL +); diff --git a/archives/0.9.5_schema.sql b/archives/0.9.5_schema.sql new file mode 100644 index 0000000..0226f92 --- /dev/null +++ b/archives/0.9.5_schema.sql @@ -0,0 +1,414 @@ +CREATE TABLE IF NOT EXISTS config ( +-- Configuration de Garradin + cle TEXT PRIMARY KEY NOT NULL, + valeur TEXT +); + +-- On stocke ici les ID de catégorie de compta correspondant aux types spéciaux +-- compta_categorie_cotisations => id_categorie +-- compta_categorie_dons => id_categorie + +CREATE TABLE IF NOT EXISTS membres_categories +-- Catégories de membres +( + id INTEGER PRIMARY KEY NOT NULL, + nom TEXT NOT NULL, + + droit_wiki INTEGER NOT NULL DEFAULT 1, + droit_membres INTEGER NOT NULL DEFAULT 1, + droit_compta INTEGER NOT NULL DEFAULT 1, + droit_inscription INTEGER NOT NULL DEFAULT 0, + droit_connexion INTEGER NOT NULL DEFAULT 1, + droit_config INTEGER NOT NULL DEFAULT 0, + cacher INTEGER NOT NULL DEFAULT 0, + + id_cotisation_obligatoire INTEGER NULL REFERENCES cotisations (id) ON DELETE SET NULL +); + +-- Membres de l'asso +-- Table dynamique générée par l'application +-- voir Garradin\Membres\Champs.php + +CREATE TABLE IF NOT EXISTS membres_sessions +-- Sessions +( + selecteur TEXT NOT NULL, + hash TEXT NOT NULL, + id_membre INTEGER NOT NULL REFERENCES membres (id) ON DELETE CASCADE, + expire INT NOT NULL, + + PRIMARY KEY (selecteur, id_membre) +); + +CREATE TABLE IF NOT EXISTS cotisations +-- Types de cotisations et activités +( + id INTEGER PRIMARY KEY NOT NULL, + id_categorie_compta INTEGER NULL REFERENCES compta_categories (id) ON DELETE SET NULL, -- NULL si le type n'est pas associé automatiquement à la compta + + intitule TEXT NOT NULL, + description TEXT NULL, + montant REAL NOT NULL, + + duree INTEGER NULL, -- En jours + debut TEXT NULL, -- timestamp + fin TEXT NULL +); + +CREATE TABLE IF NOT EXISTS cotisations_membres +-- Enregistrement des cotisations et activités +( + id INTEGER NOT NULL PRIMARY KEY, + id_membre INTEGER NOT NULL REFERENCES membres (id) ON DELETE CASCADE, + id_cotisation INTEGER NOT NULL REFERENCES cotisations (id) ON DELETE CASCADE, + + date TEXT NOT NULL DEFAULT CURRENT_DATE CHECK (date(date) IS NOT NULL AND date(date) = date) +); + +CREATE UNIQUE INDEX IF NOT EXISTS cm_unique ON cotisations_membres (id_membre, id_cotisation, date); + +CREATE TABLE IF NOT EXISTS membres_operations +-- Liaison des enregistrement des paiements en compta +( + id_membre INTEGER NOT NULL REFERENCES membres (id) ON DELETE CASCADE, + id_operation INTEGER NOT NULL REFERENCES compta_journal (id) ON DELETE CASCADE, + id_cotisation INTEGER NULL REFERENCES cotisations_membres (id) ON DELETE SET NULL, + + PRIMARY KEY (id_membre, id_operation) +); + +CREATE TABLE IF NOT EXISTS rappels +-- Rappels de devoir renouveller une cotisation +( + id INTEGER NOT NULL PRIMARY KEY, + id_cotisation INTEGER NOT NULL REFERENCES cotisations (id) ON DELETE CASCADE, + + delai INTEGER NOT NULL, -- Délai en jours pour envoyer le rappel + + sujet TEXT NOT NULL, + texte TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS rappels_envoyes +-- Enregistrement des rappels envoyés à qui et quand +( + id INTEGER NOT NULL PRIMARY KEY, + + id_membre INTEGER NOT NULL REFERENCES membres (id) ON DELETE CASCADE, + id_cotisation INTEGER NOT NULL REFERENCES cotisations (id) ON DELETE CASCADE, + id_rappel INTEGER NULL REFERENCES rappels (id) ON DELETE CASCADE, + + date TEXT NOT NULL DEFAULT CURRENT_DATE CHECK (date(date) IS NOT NULL AND date(date) = date), + + media INTEGER NOT NULL -- Média utilisé pour le rappel : 1 = email, 2 = courrier, 3 = autre +); + +-- +-- WIKI +-- + +CREATE TABLE IF NOT EXISTS wiki_pages +-- Pages du wiki +( + id INTEGER PRIMARY KEY NOT NULL, + uri TEXT NOT NULL, -- URI unique (équivalent NomPageWiki) + titre TEXT NOT NULL, + date_creation TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP CHECK (datetime(date_creation) IS NOT NULL AND datetime(date_creation) = date_creation), + date_modification TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP CHECK (datetime(date_modification) IS NOT NULL AND datetime(date_modification) = date_modification), + parent INTEGER NOT NULL DEFAULT 0, -- ID de la page parent + revision INTEGER NOT NULL DEFAULT 0, -- Numéro de révision (commence à 0 si pas de texte, +1 à chaque changement du texte) + droit_lecture INTEGER NOT NULL DEFAULT 0, -- Accès en lecture (-1 = public [site web], 0 = tous ceux qui ont accès en lecture au wiki, 1+ = ID de groupe) + droit_ecriture INTEGER NOT NULL DEFAULT 0 -- Accès en écriture (0 = tous ceux qui ont droit d'écriture sur le wiki, 1+ = ID de groupe) +); + +CREATE UNIQUE INDEX IF NOT EXISTS wiki_uri ON wiki_pages (uri); + +CREATE VIRTUAL TABLE IF NOT EXISTS wiki_recherche USING fts4 +-- Table dupliquée pour chercher une page +( + id INT PRIMARY KEY NOT NULL, -- Clé externe obligatoire + titre TEXT NOT NULL, + contenu TEXT NULL, -- Contenu de la dernière révision + FOREIGN KEY (id) REFERENCES wiki_pages(id) +); + +CREATE TABLE IF NOT EXISTS wiki_revisions +-- Révisions du contenu des pages +( + id_page INTEGER NOT NULL REFERENCES wiki_pages (id) ON DELETE CASCADE, + revision INTEGER NULL, + + id_auteur INTEGER NULL REFERENCES membres (id) ON DELETE SET NULL, + + contenu TEXT NOT NULL, + modification TEXT NULL, -- Description des modifications effectuées + chiffrement INTEGER NOT NULL DEFAULT 0, -- 1 si le contenu est chiffré, 0 sinon + date TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP CHECK (datetime(date) IS NOT NULL AND datetime(date) = date), + + PRIMARY KEY(id_page, revision) +); + +CREATE INDEX IF NOT EXISTS wiki_revisions_id_page ON wiki_revisions (id_page); +CREATE INDEX IF NOT EXISTS wiki_revisions_id_auteur ON wiki_revisions (id_auteur); + +-- Triggers pour synchro avec table wiki_pages +CREATE TRIGGER IF NOT EXISTS wiki_recherche_delete AFTER DELETE ON wiki_pages + BEGIN + DELETE FROM wiki_recherche WHERE id = old.id; + END; + +CREATE TRIGGER IF NOT EXISTS wiki_recherche_update AFTER UPDATE OF id, titre ON wiki_pages + BEGIN + UPDATE wiki_recherche SET id = new.id, titre = new.titre WHERE id = old.id; + END; + +-- Trigger pour mettre à jour le contenu de la table de recherche lors d'une nouvelle révision +CREATE TRIGGER IF NOT EXISTS wiki_recherche_contenu_insert AFTER INSERT ON wiki_revisions WHEN new.chiffrement != 1 + BEGIN + UPDATE wiki_recherche SET contenu = new.contenu WHERE id = new.id_page; + END; + +-- Si le contenu est chiffré, la recherche n'affiche pas de contenu +CREATE TRIGGER IF NOT EXISTS wiki_recherche_contenu_chiffre AFTER INSERT ON wiki_revisions WHEN new.chiffrement = 1 + BEGIN + UPDATE wiki_recherche SET contenu = '' WHERE id = new.id_page; + END; + +/* +CREATE TABLE wiki_suivi +-- Suivi des pages +( + id_membre INTEGER NOT NULL, + id_page INTEGER NOT NULL, + + PRIMARY KEY (id_membre, id_page), + + FOREIGN KEY (id_page) REFERENCES wiki_pages (id), -- Clé externe obligatoire + FOREIGN KEY (id_membre) REFERENCES membres (id) -- Clé externe obligatoire +); +*/ + +-- +-- COMPTA +-- + +CREATE TABLE IF NOT EXISTS compta_exercices +-- Exercices +( + id INTEGER NOT NULL PRIMARY KEY, + + libelle TEXT NOT NULL, + + debut TEXT NOT NULL DEFAULT CURRENT_DATE CHECK (date(debut) IS NOT NULL AND date(debut) = debut), + fin TEXT NULL DEFAULT NULL CHECK (fin IS NULL OR (date(fin) IS NOT NULL AND date(fin) = fin)), + + cloture INTEGER NOT NULL DEFAULT 0 +); + + +CREATE TABLE IF NOT EXISTS compta_comptes +-- Plan comptable +( + id TEXT NOT NULL PRIMARY KEY, -- peut contenir des lettres, eg. 53A, 53B, etc. + parent TEXT NOT NULL DEFAULT 0, + + libelle TEXT NOT NULL, + + position INTEGER NOT NULL, -- position actif/passif/charge/produit + plan_comptable INTEGER NOT NULL DEFAULT 1, -- 1 = fait partie du plan comptable, 0 = a été ajouté par l'utilisateur + desactive INTEGER NOT NULL DEFAULT 0 -- 1 = compte historique désactivé +); + +CREATE INDEX IF NOT EXISTS compta_comptes_parent ON compta_comptes (parent); + +CREATE TABLE IF NOT EXISTS compta_comptes_bancaires +-- Comptes bancaires +( + id TEXT NOT NULL PRIMARY KEY, + + banque TEXT NOT NULL, + + iban TEXT NULL, + bic TEXT NULL, + + FOREIGN KEY(id) REFERENCES compta_comptes(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS compta_projets +-- Projets (compta analytique) +( + id INTEGER PRIMARY KEY NOT NULL, + + libelle TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS compta_journal +-- Journal des opérations comptables +( + id INTEGER PRIMARY KEY NOT NULL, + + libelle TEXT NOT NULL, + remarques TEXT NULL, + numero_piece TEXT NULL, -- N° de pièce comptable + + montant REAL NOT NULL, + + date TEXT NOT NULL DEFAULT CURRENT_DATE CHECK (date(date) IS NOT NULL AND date(date) = date), + moyen_paiement TEXT NULL, + numero_cheque TEXT NULL, + + compte_debit TEXT NULL, -- N° du compte dans le plan, NULL est utilisé pour une opération qui vient d'un exercice précédent + compte_credit TEXT NULL, -- N° du compte dans le plan + + id_exercice INTEGER NULL DEFAULT NULL, -- En cas de compta simple, l'exercice est permanent (NULL) + id_auteur INTEGER NULL, + id_categorie INTEGER NULL, -- Numéro de catégorie (en mode simple) + id_projet INTEGER NULL, + + FOREIGN KEY(moyen_paiement) REFERENCES compta_moyens_paiement(code), + FOREIGN KEY(compte_debit) REFERENCES compta_comptes(id), + FOREIGN KEY(compte_credit) REFERENCES compta_comptes(id), + FOREIGN KEY(id_exercice) REFERENCES compta_exercices(id), + FOREIGN KEY(id_auteur) REFERENCES membres(id) ON DELETE SET NULL, + FOREIGN KEY(id_categorie) REFERENCES compta_categories(id) ON DELETE SET NULL, + FOREIGN KEY(id_projet) REFERENCES compta_projets(id) ON DELETE SET NULL +); + +CREATE INDEX IF NOT EXISTS compta_operations_exercice ON compta_journal (id_exercice); +CREATE INDEX IF NOT EXISTS compta_operations_date ON compta_journal (date); +CREATE INDEX IF NOT EXISTS compta_operations_comptes ON compta_journal (compte_debit, compte_credit); +CREATE INDEX IF NOT EXISTS compta_operations_auteur ON compta_journal (id_auteur); + +CREATE TABLE IF NOT EXISTS compta_moyens_paiement +-- Moyens de paiement +( + code TEXT NOT NULL PRIMARY KEY, + nom TEXT NOT NULL +); + +--INSERT INTO compta_moyens_paiement (code, nom) VALUES ('AU', 'Autre'); +INSERT OR IGNORE INTO compta_moyens_paiement (code, nom) VALUES ('CB', 'Carte bleue'); +INSERT OR IGNORE INTO compta_moyens_paiement (code, nom) VALUES ('CH', 'Chèque'); +INSERT OR IGNORE INTO compta_moyens_paiement (code, nom) VALUES ('ES', 'Espèces'); +INSERT OR IGNORE INTO compta_moyens_paiement (code, nom) VALUES ('PR', 'Prélèvement'); +INSERT OR IGNORE INTO compta_moyens_paiement (code, nom) VALUES ('TI', 'TIP'); +INSERT OR IGNORE INTO compta_moyens_paiement (code, nom) VALUES ('VI', 'Virement'); + +CREATE TABLE IF NOT EXISTS compta_categories +-- Catégories pour simplifier le plan comptable +( + id INTEGER NOT NULL PRIMARY KEY, + type INTEGER NOT NULL DEFAULT 1, -- 1 = recette, -1 = dépense, 0 = autre (utilisé uniquement pour l'interface) + + intitule TEXT NOT NULL, + description TEXT NULL, + + compte TEXT NOT NULL, -- Compte affecté par cette catégorie + + FOREIGN KEY(compte) REFERENCES compta_comptes(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS plugins +( + id TEXT NOT NULL PRIMARY KEY, + officiel INTEGER NOT NULL DEFAULT 0, + nom TEXT NOT NULL, + description TEXT NULL, + auteur TEXT NULL, + url TEXT NULL, + version TEXT NOT NULL, + menu INTEGER NOT NULL DEFAULT 0, + menu_condition TEXT NULL, + config TEXT NULL +); + +CREATE TABLE IF NOT EXISTS plugins_signaux +-- Association entre plugins et signaux (hooks) +( + signal TEXT NOT NULL, + plugin TEXT NOT NULL REFERENCES plugins (id), + callback TEXT NOT NULL, + PRIMARY KEY (signal, plugin) +); + +CREATE TABLE IF NOT EXISTS compta_rapprochement +-- Rapprochement entre compta et relevés de comptes +( + id_operation INTEGER NOT NULL PRIMARY KEY REFERENCES compta_journal (id) ON DELETE CASCADE, + date TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP CHECK (datetime(date) IS NOT NULL AND datetime(date) = date), + id_auteur INTEGER NULL REFERENCES membres (id) ON DELETE SET NULL +); + +CREATE TABLE IF NOT EXISTS fichiers +-- Données sur les fichiers +( + id INTEGER NOT NULL PRIMARY KEY, + nom TEXT NOT NULL, -- nom de fichier (par exemple image1234.jpeg) + type TEXT NULL, -- Type MIME + image INTEGER NOT NULL DEFAULT 0, -- 1 = image reconnue + datetime TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP CHECK (datetime(datetime) IS NOT NULL AND datetime(datetime) = datetime), -- Date d'ajout ou mise à jour du fichier + id_contenu INTEGER NOT NULL REFERENCES fichiers_contenu (id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS fichiers_date ON fichiers (datetime); + +CREATE TABLE IF NOT EXISTS fichiers_contenu +-- Contenu des fichiers +( + id INTEGER NOT NULL PRIMARY KEY, + hash TEXT NOT NULL, -- Hash SHA1 du contenu du fichier + taille INTEGER NOT NULL, -- Taille en octets + contenu BLOB NULL +); + +CREATE UNIQUE INDEX IF NOT EXISTS fichiers_hash ON fichiers_contenu (hash); + +CREATE TABLE IF NOT EXISTS fichiers_membres +-- Associations entre fichiers et membres (photo de profil par exemple) +( + fichier INTEGER NOT NULL REFERENCES fichiers (id), + id INTEGER NOT NULL REFERENCES membres (id), + PRIMARY KEY(fichier, id) +); + +CREATE TABLE IF NOT EXISTS fichiers_wiki_pages +-- Associations entre fichiers et pages du wiki +( + fichier INTEGER NOT NULL REFERENCES fichiers (id), + id INTEGER NOT NULL REFERENCES wiki_pages (id), + PRIMARY KEY(fichier, id) +); + +CREATE TABLE IF NOT EXISTS fichiers_compta_journal +-- Associations entre fichiers et journal de compta (pièce comptable par exemple) +( + fichier INTEGER NOT NULL REFERENCES fichiers (id), + id INTEGER NOT NULL REFERENCES compta_journal (id), + PRIMARY KEY(fichier, id) +); + +CREATE TABLE IF NOT EXISTS recherches +-- Recherches enregistrées +( + id INTEGER NOT NULL PRIMARY KEY, + id_membre INTEGER NULL REFERENCES membres (id) ON DELETE CASCADE, -- Si non NULL, alors la recherche ne sera visible que par le membre associé + intitule TEXT NOT NULL, + creation TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP CHECK (datetime(creation) IS NOT NULL AND datetime(creation) = creation), + cible TEXT NOT NULL, -- "membres" ou "compta_journal" + type TEXT NOT NULL, -- "json" ou "sql" + contenu TEXT NOT NULL +); + + +CREATE TABLE IF NOT EXISTS compromised_passwords_cache +-- Cache des hash de mots de passe compromis +( + hash TEXT NOT NULL PRIMARY KEY +); + +CREATE TABLE IF NOT EXISTS compromised_passwords_cache_ranges +-- Cache des préfixes de mots de passe compromis +( + prefix TEXT NOT NULL PRIMARY KEY, + date INTEGER NOT NULL +); \ No newline at end of file diff --git a/archives/1.0.0_schema.sql b/archives/1.0.0_schema.sql new file mode 100644 index 0000000..bc0c986 --- /dev/null +++ b/archives/1.0.0_schema.sql @@ -0,0 +1,406 @@ +CREATE TABLE IF NOT EXISTS config ( +-- Configuration de Garradin + cle TEXT PRIMARY KEY NOT NULL, + valeur TEXT +); + +CREATE TABLE IF NOT EXISTS membres_categories +-- Catégories de membres +( + id INTEGER PRIMARY KEY NOT NULL, + nom TEXT NOT NULL, + + droit_wiki INTEGER NOT NULL DEFAULT 1, + droit_membres INTEGER NOT NULL DEFAULT 1, + droit_compta INTEGER NOT NULL DEFAULT 1, + droit_inscription INTEGER NOT NULL DEFAULT 0, + droit_connexion INTEGER NOT NULL DEFAULT 1, + droit_config INTEGER NOT NULL DEFAULT 0, + cacher INTEGER NOT NULL DEFAULT 0 +); + +-- Membres de l'asso +-- Table dynamique générée par l'application +-- voir Garradin\Membres\Champs.php + +CREATE TABLE IF NOT EXISTS membres_sessions +-- Sessions +( + selecteur TEXT NOT NULL, + hash TEXT NOT NULL, + id_membre INTEGER NOT NULL REFERENCES membres (id) ON DELETE CASCADE, + expire INT NOT NULL, + + PRIMARY KEY (selecteur, id_membre) +); + +CREATE TABLE IF NOT EXISTS services +-- Types de services (cotisations) +( + id INTEGER PRIMARY KEY NOT NULL, + + label TEXT NOT NULL, + description TEXT NULL, + + duration INTEGER NULL CHECK (duration IS NULL OR duration > 0), -- En jours + start_date TEXT NULL CHECK (start_date IS NULL OR date(start_date) = start_date), + end_date TEXT NULL CHECK (end_date IS NULL OR (date(end_date) = end_date AND date(end_date) >= date(start_date))) +); + +CREATE TABLE IF NOT EXISTS services_fees +( + id INTEGER PRIMARY KEY NOT NULL, + + label TEXT NOT NULL, + description TEXT NULL, + + amount INTEGER NULL, + formula TEXT NULL, -- Formule de calcul du montant de la cotisation, si cotisation dynamique (exemple : membres.revenu_imposable * 0.01) + + id_service INTEGER NOT NULL REFERENCES services (id) ON DELETE CASCADE, + id_account INTEGER NULL REFERENCES acc_accounts (id) ON DELETE SET NULL CHECK (id_account IS NULL OR id_year IS NOT NULL), -- NULL si le type n'est pas associé automatiquement à la compta + id_year INTEGER NULL REFERENCES acc_years (id) ON DELETE SET NULL -- NULL si le type n'est pas associé automatiquement à la compta +); + +CREATE TABLE IF NOT EXISTS services_users +-- Enregistrement des cotisations et activités +( + id INTEGER NOT NULL PRIMARY KEY, + id_user INTEGER NOT NULL REFERENCES membres (id) ON DELETE CASCADE, + id_service INTEGER NOT NULL REFERENCES services (id) ON DELETE CASCADE, + id_fee INTEGER NULL REFERENCES services_fees (id) ON DELETE CASCADE, + + paid INTEGER NOT NULL DEFAULT 0, + expected_amount INTEGER NULL, + + date TEXT NOT NULL DEFAULT CURRENT_DATE CHECK (date(date) IS NOT NULL AND date(date) = date), + expiry_date TEXT NULL CHECK (date(expiry_date) IS NULL OR date(expiry_date) = expiry_date) +); + +CREATE UNIQUE INDEX IF NOT EXISTS su_unique ON services_users (id_user, id_service, date); + +CREATE INDEX IF NOT EXISTS su_service ON services_users (id_service); +CREATE INDEX IF NOT EXISTS su_fee ON services_users (id_fee); +CREATE INDEX IF NOT EXISTS su_paid ON services_users (paid); +CREATE INDEX IF NOT EXISTS su_expiry ON services_users (expiry_date); + +CREATE TABLE IF NOT EXISTS services_reminders +-- Rappels de devoir renouveller une cotisation +( + id INTEGER NOT NULL PRIMARY KEY, + id_service INTEGER NOT NULL REFERENCES services (id) ON DELETE CASCADE, + + delay INTEGER NOT NULL, -- Délai en jours pour envoyer le rappel + + subject TEXT NOT NULL, + body TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS services_reminders_sent +-- Enregistrement des rappels envoyés à qui et quand +( + id INTEGER NOT NULL PRIMARY KEY, + + id_user INTEGER NOT NULL REFERENCES membres (id) ON DELETE CASCADE, + id_service INTEGER NOT NULL REFERENCES services (id) ON DELETE CASCADE, + id_reminder INTEGER NOT NULL REFERENCES services_reminders (id) ON DELETE CASCADE, + + date TEXT NOT NULL DEFAULT CURRENT_DATE CHECK (date(date) IS NOT NULL AND date(date) = date) +); + +CREATE UNIQUE INDEX IF NOT EXISTS srs_index ON services_reminders_sent (id_user, id_service, id_reminder, date); + +CREATE INDEX IF NOT EXISTS srs_reminder ON services_reminders_sent (id_reminder); +CREATE INDEX IF NOT EXISTS srs_user ON services_reminders_sent (id_user); + +-- +-- WIKI +-- + +CREATE TABLE IF NOT EXISTS wiki_pages +-- Pages du wiki +( + id INTEGER PRIMARY KEY NOT NULL, + uri TEXT NOT NULL, -- URI unique (équivalent NomPageWiki) + titre TEXT NOT NULL, + date_creation TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP CHECK (datetime(date_creation) IS NOT NULL AND datetime(date_creation) = date_creation), + date_modification TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP CHECK (datetime(date_modification) IS NOT NULL AND datetime(date_modification) = date_modification), + parent INTEGER NOT NULL DEFAULT 0, -- ID de la page parent + revision INTEGER NOT NULL DEFAULT 0, -- Numéro de révision (commence à 0 si pas de texte, +1 à chaque changement du texte) + droit_lecture INTEGER NOT NULL DEFAULT 0, -- Accès en lecture (-1 = public [site web], 0 = tous ceux qui ont accès en lecture au wiki, 1+ = ID de groupe) + droit_ecriture INTEGER NOT NULL DEFAULT 0 -- Accès en écriture (0 = tous ceux qui ont droit d'écriture sur le wiki, 1+ = ID de groupe) +); + +CREATE UNIQUE INDEX IF NOT EXISTS wiki_uri ON wiki_pages (uri); + +CREATE VIRTUAL TABLE IF NOT EXISTS wiki_recherche USING fts4 +-- Table dupliquée pour chercher une page +( + id INT PRIMARY KEY NOT NULL, -- Clé externe obligatoire + titre TEXT NOT NULL, + contenu TEXT NULL, -- Contenu de la dernière révision + FOREIGN KEY (id) REFERENCES wiki_pages(id) +); + +CREATE TABLE IF NOT EXISTS wiki_revisions +-- Révisions du contenu des pages +( + id_page INTEGER NOT NULL REFERENCES wiki_pages (id) ON DELETE CASCADE, + revision INTEGER NULL, + + id_auteur INTEGER NULL REFERENCES membres (id) ON DELETE SET NULL, + + contenu TEXT NOT NULL, + modification TEXT NULL, -- Description des modifications effectuées + chiffrement INTEGER NOT NULL DEFAULT 0, -- 1 si le contenu est chiffré, 0 sinon + date TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP CHECK (datetime(date) IS NOT NULL AND datetime(date) = date), + + PRIMARY KEY(id_page, revision) +); + +CREATE INDEX IF NOT EXISTS wiki_revisions_id_page ON wiki_revisions (id_page); +CREATE INDEX IF NOT EXISTS wiki_revisions_id_auteur ON wiki_revisions (id_auteur); + +-- Triggers pour synchro avec table wiki_pages +CREATE TRIGGER IF NOT EXISTS wiki_recherche_delete AFTER DELETE ON wiki_pages + BEGIN + DELETE FROM wiki_recherche WHERE id = old.id; + END; + +CREATE TRIGGER IF NOT EXISTS wiki_recherche_update AFTER UPDATE OF id, titre ON wiki_pages + BEGIN + UPDATE wiki_recherche SET id = new.id, titre = new.titre WHERE id = old.id; + END; + +-- Trigger pour mettre à jour le contenu de la table de recherche lors d'une nouvelle révision +CREATE TRIGGER IF NOT EXISTS wiki_recherche_contenu_insert AFTER INSERT ON wiki_revisions WHEN new.chiffrement != 1 + BEGIN + UPDATE wiki_recherche SET contenu = new.contenu WHERE id = new.id_page; + END; + +-- Si le contenu est chiffré, la recherche n'affiche pas de contenu +CREATE TRIGGER IF NOT EXISTS wiki_recherche_contenu_chiffre AFTER INSERT ON wiki_revisions WHEN new.chiffrement = 1 + BEGIN + UPDATE wiki_recherche SET contenu = '' WHERE id = new.id_page; + END; + +-- +-- COMPTA +-- + +CREATE TABLE IF NOT EXISTS acc_charts +-- Plans comptables : il peut y en avoir plusieurs +( + id INTEGER NOT NULL PRIMARY KEY, + country TEXT NOT NULL, + code TEXT NULL, -- NULL = plan comptable créé par l'utilisateur + label TEXT NOT NULL, + archived INTEGER NOT NULL DEFAULT 0 -- 1 = archivé, non-modifiable +); + +CREATE TABLE IF NOT EXISTS acc_accounts +-- Comptes des plans comptables +( + id INTEGER NOT NULL PRIMARY KEY, + id_chart INTEGER NOT NULL REFERENCES acc_charts ON DELETE CASCADE, + + code TEXT NOT NULL, -- peut contenir des lettres, eg. 53A, 53B, etc. + + label TEXT NOT NULL, + description TEXT NULL, + + position INTEGER NOT NULL, -- position actif/passif/charge/produit + type INTEGER NOT NULL DEFAULT 0, -- Type de compte spécial : banque, caisse, en attente d'encaissement, etc. + user INTEGER NOT NULL DEFAULT 1 -- 1 = fait partie du plan comptable original, 0 = a été ajouté par l'utilisateur +); + +CREATE UNIQUE INDEX IF NOT EXISTS acc_accounts_codes ON acc_accounts (code, id_chart); +CREATE INDEX IF NOT EXISTS acc_accounts_type ON acc_accounts (type); +CREATE INDEX IF NOT EXISTS acc_accounts_position ON acc_accounts (position); + +CREATE TABLE IF NOT EXISTS acc_years +-- Exercices +( + id INTEGER NOT NULL PRIMARY KEY, + + label TEXT NOT NULL, + + start_date TEXT NOT NULL CHECK (date(start_date) IS NOT NULL AND date(start_date) = start_date), + end_date TEXT NOT NULL CHECK (date(end_date) IS NOT NULL AND date(end_date) = end_date), + + closed INTEGER NOT NULL DEFAULT 0, + + id_chart INTEGER NOT NULL REFERENCES acc_charts (id) +); + +CREATE TRIGGER IF NOT EXISTS acc_years_delete BEFORE DELETE ON acc_years BEGIN + UPDATE services_fees SET id_account = NULL, id_year = NULL WHERE id_year = OLD.id; +END; + +CREATE INDEX IF NOT EXISTS acc_years_closed ON acc_years (closed); + +CREATE TABLE IF NOT EXISTS acc_transactions +-- Opérations comptables +( + id INTEGER PRIMARY KEY NOT NULL, + + type INTEGER NOT NULL DEFAULT 0, -- Type d'écriture, 0 = avancée (normale) + status INTEGER NOT NULL DEFAULT 0, -- Statut (bitmask) + + label TEXT NOT NULL, + notes TEXT NULL, + reference TEXT NULL, -- N° de pièce comptable + + date TEXT NOT NULL DEFAULT CURRENT_DATE CHECK (date(date) IS NOT NULL AND date(date) = date), + + validated INTEGER NOT NULL DEFAULT 0, -- 1 = écriture validée, non modifiable + + hash TEXT NULL, + prev_hash TEXT NULL, + + id_year INTEGER NOT NULL REFERENCES acc_years(id), + id_creator INTEGER NULL REFERENCES membres(id) ON DELETE SET NULL, + id_related INTEGER NULL REFERENCES acc_transactions(id) ON DELETE SET NULL -- écriture liée (par ex. remboursement d'une dette) +); + +CREATE INDEX IF NOT EXISTS acc_transactions_year ON acc_transactions (id_year); +CREATE INDEX IF NOT EXISTS acc_transactions_date ON acc_transactions (date); +CREATE INDEX IF NOT EXISTS acc_transactions_related ON acc_transactions (id_related); +CREATE INDEX IF NOT EXISTS acc_transactions_type ON acc_transactions (type, id_year); +CREATE INDEX IF NOT EXISTS acc_transactions_status ON acc_transactions (status); + +CREATE TABLE IF NOT EXISTS acc_transactions_lines +-- Lignes d'écritures d'une opération +( + id INTEGER PRIMARY KEY NOT NULL, + + id_transaction INTEGER NOT NULL REFERENCES acc_transactions (id) ON DELETE CASCADE, + id_account INTEGER NOT NULL REFERENCES acc_accounts (id), -- N° du compte dans le plan comptable + + credit INTEGER NOT NULL, + debit INTEGER NOT NULL, + + reference TEXT NULL, -- Référence de paiement, eg. numéro de chèque + label TEXT NULL, + + reconciled INTEGER NOT NULL DEFAULT 0, + + id_analytical INTEGER NULL REFERENCES acc_accounts(id) ON DELETE SET NULL, + + CONSTRAINT line_check1 CHECK ((credit * debit) = 0), + CONSTRAINT line_check2 CHECK ((credit + debit) > 0) +); + +CREATE INDEX IF NOT EXISTS acc_transactions_lines_transaction ON acc_transactions_lines (id_transaction); +CREATE INDEX IF NOT EXISTS acc_transactions_lines_account ON acc_transactions_lines (id_account); +CREATE INDEX IF NOT EXISTS acc_transactions_lines_analytical ON acc_transactions_lines (id_analytical); +CREATE INDEX IF NOT EXISTS acc_transactions_lines_reconciled ON acc_transactions_lines (reconciled); + +CREATE TABLE IF NOT EXISTS acc_transactions_users +-- Liaison des écritures et des membres +( + id_user INTEGER NOT NULL REFERENCES membres (id) ON DELETE CASCADE, + id_transaction INTEGER NOT NULL REFERENCES acc_transactions (id) ON DELETE CASCADE, + id_service_user INTEGER NULL REFERENCES services_users (id) ON DELETE SET NULL, + + PRIMARY KEY (id_user, id_transaction) +); + +CREATE INDEX IF NOT EXISTS acc_transactions_users_service ON acc_transactions_users (id_service_user); + +CREATE TABLE IF NOT EXISTS plugins +( + id TEXT NOT NULL PRIMARY KEY, + officiel INTEGER NOT NULL DEFAULT 0, + nom TEXT NOT NULL, + description TEXT NULL, + auteur TEXT NULL, + url TEXT NULL, + version TEXT NOT NULL, + menu INTEGER NOT NULL DEFAULT 0, + menu_condition TEXT NULL, + config TEXT NULL +); + +CREATE TABLE IF NOT EXISTS plugins_signaux +-- Association entre plugins et signaux (hooks) +( + signal TEXT NOT NULL, + plugin TEXT NOT NULL REFERENCES plugins (id), + callback TEXT NOT NULL, + PRIMARY KEY (signal, plugin) +); + +CREATE TABLE IF NOT EXISTS fichiers +-- Données sur les fichiers +( + id INTEGER NOT NULL PRIMARY KEY, + nom TEXT NOT NULL, -- nom de fichier (par exemple image1234.jpeg) + type TEXT NULL, -- Type MIME + image INTEGER NOT NULL DEFAULT 0, -- 1 = image reconnue + datetime TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP CHECK (datetime(datetime) IS NOT NULL AND datetime(datetime) = datetime), -- Date d'ajout ou mise à jour du fichier + id_contenu INTEGER NOT NULL REFERENCES fichiers_contenu (id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS fichiers_date ON fichiers (datetime); + +CREATE TABLE IF NOT EXISTS fichiers_contenu +-- Contenu des fichiers +( + id INTEGER NOT NULL PRIMARY KEY, + hash TEXT NOT NULL, -- Hash SHA1 du contenu du fichier + taille INTEGER NOT NULL, -- Taille en octets + contenu BLOB NULL +); + +CREATE UNIQUE INDEX IF NOT EXISTS fichiers_hash ON fichiers_contenu (hash); + +CREATE TABLE IF NOT EXISTS fichiers_membres +-- Associations entre fichiers et membres (photo de profil par exemple) +( + fichier INTEGER NOT NULL REFERENCES fichiers (id) ON DELETE CASCADE, + id INTEGER NOT NULL REFERENCES membres (id) ON DELETE CASCADE, + PRIMARY KEY(fichier, id) +); + +CREATE TABLE IF NOT EXISTS fichiers_wiki_pages +-- Associations entre fichiers et pages du wiki +( + fichier INTEGER NOT NULL REFERENCES fichiers (id) ON DELETE CASCADE, + id INTEGER NOT NULL REFERENCES wiki_pages (id) ON DELETE CASCADE, + PRIMARY KEY(fichier, id) +); + +CREATE TABLE IF NOT EXISTS fichiers_acc_transactions +-- Associations entre fichiers et journal de compta (pièce comptable par exemple) +( + fichier INTEGER NOT NULL REFERENCES fichiers (id) ON DELETE CASCADE, + id INTEGER NOT NULL REFERENCES acc_transactions (id) ON DELETE CASCADE, + PRIMARY KEY(fichier, id) +); + +CREATE TABLE IF NOT EXISTS recherches +-- Recherches enregistrées +( + id INTEGER NOT NULL PRIMARY KEY, + id_membre INTEGER NULL REFERENCES membres (id) ON DELETE CASCADE, -- Si non NULL, alors la recherche ne sera visible que par le membre associé + intitule TEXT NOT NULL, + creation TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP CHECK (datetime(creation) IS NOT NULL AND datetime(creation) = creation), + cible TEXT NOT NULL, -- "membres" ou "compta" + type TEXT NOT NULL, -- "json" ou "sql" + contenu TEXT NOT NULL +); + + +CREATE TABLE IF NOT EXISTS compromised_passwords_cache +-- Cache des hash de mots de passe compromis +( + hash TEXT NOT NULL PRIMARY KEY +); + +CREATE TABLE IF NOT EXISTS compromised_passwords_cache_ranges +-- Cache des préfixes de mots de passe compromis +( + prefix TEXT NOT NULL PRIMARY KEY, + date INTEGER NOT NULL +); \ No newline at end of file diff --git a/archives/plan_comptable.json b/archives/plan_comptable.json new file mode 100644 index 0000000..541150f --- /dev/null +++ b/archives/plan_comptable.json @@ -0,0 +1,1760 @@ +{ + "1": { + "code": 1, + "nom": "Classe 1 \u2014 Comptes de capitaux (Fonds propres, emprunts et dettes assimil\u00e9s)", + "parent": 0, + "position": 1 + }, + "10": { + "code": 10, + "nom": "FONDS ASSOCIATIFS ET R\u00c9SERVES", + "parent": 1, + "position": 1 + }, + "102": { + "code": 102, + "nom": "Fonds associatif sans droit de reprise", + "parent": 10, + "position": 1 + }, + "1021": { + "code": 1021, + "nom": "Valeur du patrimoine int\u00e9gr\u00e9", + "parent": 102, + "position": 1 + }, + "1022": { + "code": 1022, + "nom": "Fonds statutaire", + "parent": 102, + "position": 1 + }, + "1024": { + "code": 1024, + "nom": "Apports sans droit de reprise", + "parent": 102, + "position": 1 + }, + "103": { + "code": 103, + "nom": "Fonds associatif avec droit de reprise", + "parent": 10, + "position": 1 + }, + "1034": { + "code": 1034, + "nom": "Apports avec droit de reprise", + "parent": 103, + "position": 1 + }, + "105": { + "code": 105, + "nom": "\u00c9carts de r\u00e9\u00e9valuation", + "parent": 10, + "position": 1 + }, + "106": { + "code": 106, + "nom": "R\u00e9serves", + "parent": 10, + "position": 1 + }, + "1063": { + "code": 1063, + "nom": "R\u00e9serves statutaires ou contractuelles", + "parent": 106, + "position": 1 + }, + "1064": { + "code": 1064, + "nom": "R\u00e9serves r\u00e9glement\u00e9es", + "parent": 106, + "position": 1 + }, + "1068": { + "code": 1068, + "nom": "Autres r\u00e9serves (dont r\u00e9serves pour projet associatif)", + "parent": 106, + "position": 1 + }, + "11": { + "code": 11, + "nom": "REPORT \u00c0 NOUVEAU", + "parent": 1, + "position": 1 + }, + "110": { + "code": 110, + "nom": "Report \u00e0 nouveau (Solde cr\u00e9diteur)", + "parent": 11, + "position": 1 + }, + "119": { + "code": 119, + "nom": "Report \u00e0 nouveau (Solde d\u00e9biteur)", + "parent": 11, + "position": 1 + }, + "12": { + "code": 12, + "nom": "R\u00c9SULTAT NET DE L'EXERCICE", + "parent": 1, + "position": 1 + }, + "120": { + "code": 120, + "nom": "R\u00e9sultat de l'exercice (exc\u00e9dent)", + "parent": 12, + "position": 1 + }, + "129": { + "code": 129, + "nom": "R\u00e9sultat de l'exercice (d\u00e9ficit)", + "parent": 12, + "position": 1 + }, + "13": { + "code": 13, + "nom": "SUBVENTIONS D'INVESTISSEMENT AFFECT\u00c9ES A DES BIENS NON RENOUVELABLES", + "parent": 1, + "position": 1 + }, + "131": { + "code": 131, + "nom": "Subventions d'investissement (renouvelables)", + "parent": 13, + "position": 1 + }, + "139": { + "code": 139, + "nom": "Subventions d'investissement inscrites au compte de r\u00e9sultat", + "parent": 13, + "position": 1 + }, + "14": { + "code": 14, + "nom": "PROVISIONS REGLEMENT\u00c9ES", + "parent": 1, + "position": 1 + }, + "15": { + "code": 15, + "nom": "PROVISIONS", + "parent": 1, + "position": 1 + }, + "151": { + "code": 151, + "nom": "Provisions pour risques", + "parent": 15, + "position": 1 + }, + "157": { + "code": 157, + "nom": "Provisions pour charges \u00e0 r\u00e9partir sur plusieurs exercices", + "parent": 15, + "position": 1 + }, + "158": { + "code": 158, + "nom": "Autres provisions pour charges", + "parent": 15, + "position": 1 + }, + "16": { + "code": 16, + "nom": "EMPRUNTS ET DETTES ASSIMIL\u00c9ES", + "parent": 1, + "position": 1 + }, + "164": { + "code": 164, + "nom": "Emprunts aupr\u00e8s des \u00e9tablissements de cr\u00e9dits", + "parent": 16, + "position": 1 + }, + "165": { + "code": 165, + "nom": "D\u00e9p\u00f4ts et cautionnements re\u00e7us", + "parent": 16, + "position": 1 + }, + "167": { + "code": 167, + "nom": "Emprunts et dettes assorties de conditions particuli\u00e8res", + "parent": 16, + "position": 1 + }, + "168": { + "code": 168, + "nom": "Autres emprunts et dettes assimil\u00e9s", + "parent": 16, + "position": 1 + }, + "17": { + "code": 17, + "nom": "DETTES RATTACH\u00c9ES \u00c0 DES PARTICIPATIONS", + "parent": 1, + "position": 1 + }, + "18": { + "code": 18, + "nom": "COMPTES DE LIAISON DES \u00c9TABLISSEMENTS", + "parent": 1, + "position": 1 + }, + "181": { + "code": 181, + "nom": "Apports permanents entre si\u00e8ge social et \u00e9tablissements", + "parent": 18, + "position": 1 + }, + "185": { + "code": 185, + "nom": "Biens et prestations de services \u00e9chang\u00e9s entre \u00e9tablissements et si\u00e8ge social", + "parent": 18, + "position": 1 + }, + "186": { + "code": 186, + "nom": "Biens et prestations de services \u00e9chang\u00e9s entre \u00e9tablissements (charges)", + "parent": 18, + "position": 1 + }, + "187": { + "code": 187, + "nom": "Biens et prestations de services \u00e9chang\u00e9s entre \u00e9tablissements (produits)", + "parent": 18, + "position": 1 + }, + "19": { + "code": 19, + "nom": "FONDS D\u00c9DI\u00c9S", + "parent": 1, + "position": 1 + }, + "194": { + "code": 194, + "nom": "Fonds d\u00e9di\u00e9s sur subventions de fonctionnement", + "parent": 19, + "position": 1 + }, + "195": { + "code": 195, + "nom": "Fonds d\u00e9di\u00e9s sur dons manuels affect\u00e9s", + "parent": 19, + "position": 1 + }, + "197": { + "code": 197, + "nom": "Fonds d\u00e9di\u00e9s sur legs et donations affect\u00e9s", + "parent": 19, + "position": 1 + }, + "198": { + "code": 198, + "nom": "Exc\u00e9dent disponible apr\u00e8s affectation au projet associatif", + "parent": 19, + "position": 1 + }, + "199": { + "code": 199, + "nom": "Reprise des fonds affect\u00e9s au projet associatif", + "parent": 19, + "position": 1 + }, + "2": { + "code": 2, + "nom": "Classe 2 \u2014 Comptes d'immobilisations", + "parent": 0, + "position": 2 + }, + "20": { + "code": 20, + "nom": "IMMOBILISATIONS INCORPORELLES", + "parent": 2, + "position": 2 + }, + "200": { + "code": 200, + "nom": "Immobilisations incorporelles", + "parent": 20, + "position": 2 + }, + "21": { + "code": 21, + "nom": "IMMOBILISATIONS CORPORELLES", + "parent": 2, + "position": 2 + }, + "210": { + "code": 210, + "nom": "Investissements", + "parent": 21, + "position": 2 + }, + "22": { + "code": 22, + "nom": "IMMOBILISATIONS GREV\u00c9ES DE DROITS", + "parent": 2, + "position": 2 + }, + "228": { + "code": 228, + "nom": "Immobilisations grev\u00e9es de droits", + "parent": 22, + "position": 2 + }, + "229": { + "code": 229, + "nom": "Droits des propri\u00e9taires", + "parent": 22, + "position": 2 + }, + "23": { + "code": 23, + "nom": "IMMOBILISATIONS EN COURS", + "parent": 2, + "position": 2 + }, + "231": { + "code": 231, + "nom": "Immobilisations corporelles en cours", + "parent": 23, + "position": 2 + }, + "238": { + "code": 238, + "nom": "Avances et acomptes vers\u00e9s sur commande d'immobilisations corporelles", + "parent": 23, + "position": 2 + }, + "26": { + "code": 26, + "nom": "PARTICIPATIONS ET CR\u00c9ANCES RATTACH\u00c9ES A DES PARTICIPATIONS", + "parent": 2, + "position": 2 + }, + "261": { + "code": 261, + "nom": "Titres de participation", + "parent": 26, + "position": 2 + }, + "27": { + "code": 27, + "nom": "AUTRES IMMOBILISATIONS FINANCI\u00c8RES", + "parent": 2, + "position": 2 + }, + "270": { + "code": 270, + "nom": "Participations financi\u00e8res", + "parent": 27, + "position": 2 + }, + "275": { + "code": 275, + "nom": "D\u00e9p\u00f4ts et cautionnements vers\u00e9s", + "parent": 27, + "position": 2 + }, + "28": { + "code": 28, + "nom": "AMORTISSEMENTS DES IMMOBILISATIONS", + "parent": 2, + "position": 2 + }, + "280": { + "code": 280, + "nom": "Amortissements des immobilisations incorporelles", + "parent": 28, + "position": 2 + }, + "281": { + "code": 281, + "nom": "Amortissements des immobilisations corporelles", + "parent": 28, + "position": 2 + }, + "29": { + "code": 29, + "nom": "D\u00c9PR\u00c9CIATION DES IMMOBILISATIONS", + "parent": 2, + "position": 2 + }, + "290": { + "code": 290, + "nom": "D\u00e9pr\u00e9ciation des immobilisations incorporelles", + "parent": 29, + "position": 2 + }, + "291": { + "code": 291, + "nom": "D\u00e9pr\u00e9ciation des immobilisations corporelles", + "parent": 29, + "position": 2 + }, + "3": { + "code": 3, + "nom": "Classe 3 \u2014 Comptes de stocks", + "parent": 0, + "position": 2 + }, + "31": { + "code": 31, + "nom": "MATIERES PREMIERES ET FOURNITURES", + "parent": 3, + "position": 2 + }, + "311": { + "code": 311, + "nom": "Mati\u00e8res", + "parent": 31, + "position": 2 + }, + "317": { + "code": 317, + "nom": "Fournitures", + "parent": 31, + "position": 2 + }, + "32": { + "code": 32, + "nom": "AUTRES APPROVISIONNEMENTS", + "parent": 3, + "position": 2 + }, + "321": { + "code": 321, + "nom": "Mati\u00e8res consommables", + "parent": 32, + "position": 2 + }, + "322": { + "code": 322, + "nom": "Fournitures consommables", + "parent": 32, + "position": 2 + }, + "33": { + "code": 33, + "nom": "EN-COURS DE PRODUCTION DE BIENS", + "parent": 3, + "position": 2 + }, + "331": { + "code": 331, + "nom": "Produits en cours", + "parent": 33, + "position": 2 + }, + "335": { + "code": 335, + "nom": "Travaux en cours", + "parent": 33, + "position": 2 + }, + "34": { + "code": 34, + "nom": "EN-COURS DE PRODUCTION DE SERVICES", + "parent": 3, + "position": 2 + }, + "35": { + "code": 35, + "nom": "STOCKS DE PRODUITS", + "parent": 3, + "position": 2 + }, + "351": { + "code": 351, + "nom": "Produits interm\u00e9diaires", + "parent": 35, + "position": 2 + }, + "355": { + "code": 355, + "nom": "Produits finis", + "parent": 35, + "position": 2 + }, + "358": { + "code": 358, + "nom": "Produits r\u00e9siduels", + "parent": 35, + "position": 2 + }, + "3581": { + "code": 3581, + "nom": "D\u00e9chets", + "parent": 358, + "position": 2 + }, + "3585": { + "code": 3585, + "nom": "Rebuts", + "parent": 358, + "position": 2 + }, + "3586": { + "code": 3586, + "nom": "Mati\u00e8re de r\u00e9cup\u00e9ration", + "parent": 358, + "position": 2 + }, + "37": { + "code": 37, + "nom": "STOCKS DE MARCHANDISES", + "parent": 3, + "position": 2 + }, + "370": { + "code": 370, + "nom": "Autres stocks de marchandises", + "parent": 37, + "position": 2 + }, + "39": { + "code": 39, + "nom": "PROVISIONS POUR DEPRECIATION DES STOCKS ET EN-COURS", + "parent": 3, + "position": 2 + }, + "391": { + "code": 391, + "nom": "Provisions pour d\u00e9pr\u00e9ciation des mati\u00e8res premi\u00e8res et fournitures", + "parent": 39, + "position": 2 + }, + "4": { + "code": 4, + "nom": "Classe 4 \u2014 Comptes de tiers", + "parent": 0, + "position": 3 + }, + "40": { + "code": 40, + "nom": "FOURNISSEURS ET COMPTES RATTACH\u00c9S", + "parent": 4, + "position": 1 + }, + "401": { + "code": 401, + "nom": "Fournisseurs", + "parent": 40, + "position": 1 + }, + "4010": { + "code": 4010, + "nom": "Autres fournisseurs", + "parent": 401, + "position": 1 + }, + "408": { + "code": 408, + "nom": "Fournisseurs - Factures non parvenues", + "parent": 40, + "position": 1 + }, + "409": { + "code": 409, + "nom": "Avances aux fournisseurs", + "parent": 40, + "position": 2 + }, + "41": { + "code": 41, + "nom": "USAGERS ET COMPTES RATTACH\u00c9S", + "parent": 4, + "position": 2 + }, + "411": { + "code": 411, + "nom": "Usagers", + "parent": 41, + "position": 2 + }, + "4110": { + "code": 4110, + "nom": "Autres usagers", + "parent": 411, + "position": 2 + }, + "419": { + "code": 419, + "nom": "Avances aux usagers", + "parent": 41, + "position": 1 + }, + "42": { + "code": 42, + "nom": "PERSONNEL ET COMPTES RATTACH\u00c9S", + "parent": 4, + "position": 1 + }, + "421": { + "code": 421, + "nom": "Personnel - R\u00e9mun\u00e9rations dues", + "parent": 42, + "position": 1 + }, + "4210": { + "code": 4210, + "nom": "Autres membres du personnel", + "parent": 421, + "position": 1 + }, + "425": { + "code": 425, + "nom": "Personnel - Avances et acomptes", + "parent": 42, + "position": 2 + }, + "428": { + "code": 428, + "nom": "Personnel - Charges \u00e0 payer et produits \u00e0 recevoir", + "parent": 42, + "position": 1 + }, + "43": { + "code": 43, + "nom": "S\u00c9CURIT\u00c9 SOCIALE ET AUTRES ORGANISMES SOCIAUX", + "parent": 4, + "position": 1 + }, + "430": { + "code": 430, + "nom": "Dettes et cr\u00e9dits envers les organismes sociaux", + "parent": 43, + "position": 1 + }, + "431": { + "code": 431, + "nom": "S\u00e9curit\u00e9 sociale", + "parent": 43, + "position": 1 + }, + "437": { + "code": 437, + "nom": "Autres organismes sociaux", + "parent": 43, + "position": 1 + }, + "4372": { + "code": 4372, + "nom": "Mutuelles", + "parent": 437, + "position": 1 + }, + "4373": { + "code": 4373, + "nom": "Caisse de retraite et de pr\u00e9voyance", + "parent": 437, + "position": 1 + }, + "4374": { + "code": 4374, + "nom": "Caisse d'allocations de ch\u00f4mage - P\u00f4le emploi", + "parent": 437, + "position": 1 + }, + "4375": { + "code": 4375, + "nom": "AGESSA", + "parent": 437, + "position": 1 + }, + "4378": { + "code": 4378, + "nom": "Autres organismes sociaux - Divers", + "parent": 437, + "position": 1 + }, + "438": { + "code": 438, + "nom": "Organismes sociaux - Charges \u00e0 payer et produits \u00e0 recevoir", + "parent": 43, + "position": 1 + }, + "4382": { + "code": 4382, + "nom": "Charges sociales sur cong\u00e9s \u00e0 payer", + "parent": 438, + "position": 1 + }, + "4386": { + "code": 4386, + "nom": "Autres charges \u00e0 payer", + "parent": 438, + "position": 1 + }, + "4387": { + "code": 4387, + "nom": "Produits \u00e0 recevoir", + "parent": 438, + "position": 2 + }, + "439": { + "code": 439, + "nom": "Avances aupr\u00e8s des organismes sociaux", + "parent": 43, + "position": 1 + }, + "44": { + "code": 44, + "nom": "\u00c9TAT ET AUTRES COLLECTIVIT\u00c9S PUBLIQUES", + "parent": 4, + "position": 2 + }, + "441": { + "code": 441, + "nom": "\u00c9tat - Subventions \u00e0 recevoir", + "parent": 44, + "position": 2 + }, + "4411": { + "code": 4411, + "nom": "Subventions d'investissement", + "parent": 441, + "position": 2 + }, + "4417": { + "code": 4417, + "nom": "Subventions d'exploitation", + "parent": 441, + "position": 2 + }, + "4418": { + "code": 4418, + "nom": "Subventions d'\u00e9quilibre", + "parent": 441, + "position": 2 + }, + "4419": { + "code": 4419, + "nom": "Avances sur subventions", + "parent": 441, + "position": 2 + }, + "442": { + "code": 442, + "nom": "\u00c9tat - Imp\u00f4ts et taxes recouvrables sur des tiers", + "parent": 44, + "position": 1 + }, + "444": { + "code": 444, + "nom": "\u00c9tat - Imp\u00f4ts sur les b\u00e9n\u00e9fices", + "parent": 44, + "position": 2 + }, + "445": { + "code": 445, + "nom": "\u00c9tat - Taxes sur le chiffre d'affaires", + "parent": 44, + "position": 2 + }, + "4455": { + "code": 4455, + "nom": "Taxes sur le chiffre d'affaires \u00e0 d\u00e9caisser", + "parent": 445, + "position": 2 + }, + "44551": { + "code": 44551, + "nom": "TVA \u00e0 d\u00e9caisser", + "parent": 4455, + "position": 2 + }, + "44558": { + "code": 44558, + "nom": "Taxes assimil\u00e9es \u00e0 la TVA", + "parent": 4455, + "position": 2 + }, + "4456": { + "code": 4456, + "nom": "Taxes sur le chiffre d'affaires d\u00e9ductibles", + "parent": 445, + "position": 2 + }, + "44562": { + "code": 44562, + "nom": "TVA sur immobilisations", + "parent": 4456, + "position": 2 + }, + "44566": { + "code": 44566, + "nom": "TVA sur autres biens et services", + "parent": 4456, + "position": 2 + }, + "4457": { + "code": 4457, + "nom": "Taxes sur le chiffre d'affaires collect\u00e9es par l'association", + "parent": 445, + "position": 2 + }, + "4458": { + "code": 4458, + "nom": "Taxes sur le chiffre d'affaires \u00e0 r\u00e9gulariser ou en attente", + "parent": 445, + "position": 2 + }, + "44581": { + "code": 44581, + "nom": "Acomptes - R\u00e9gime simplifi\u00e9 d'imposition", + "parent": 4458, + "position": 2 + }, + "44582": { + "code": 44582, + "nom": "Acomptes - R\u00e9gime du forfait", + "parent": 4458, + "position": 2 + }, + "44583": { + "code": 44583, + "nom": "Remboursement de taxes sur le chiffre d'affaires demand\u00e9", + "parent": 4458, + "position": 2 + }, + "44584": { + "code": 44584, + "nom": "TVA r\u00e9cup\u00e9r\u00e9e d'avance", + "parent": 4458, + "position": 2 + }, + "44586": { + "code": 44586, + "nom": "Taxes sur le chiffre d'affaires sur factures non parvenues", + "parent": 4458, + "position": 2 + }, + "44587": { + "code": 44587, + "nom": "Taxes sur le chiffre d'affaires sur factures \u00e0 \u00e9tablir", + "parent": 4458, + "position": 2 + }, + "447": { + "code": 447, + "nom": "Autres imp\u00f4ts, taxes et versements assimil\u00e9s", + "parent": 44, + "position": 1 + }, + "4471": { + "code": 4471, + "nom": "Autres imp\u00f4ts, taxes et versements assimil\u00e9s sur r\u00e9mun\u00e9rations (Administration des imp\u00f4ts)", + "parent": 447, + "position": 1 + }, + "44711": { + "code": 44711, + "nom": "Taxe sur les salaires", + "parent": 4471, + "position": 1 + }, + "44713": { + "code": 44713, + "nom": "Participation des employeurs \u00e0 la formation professionnelle continue", + "parent": 4471, + "position": 1 + }, + "44714": { + "code": 44714, + "nom": "Cotisation par d\u00e9faut d'investissement obligatoire dans la construction", + "parent": 4471, + "position": 1 + }, + "44718": { + "code": 44718, + "nom": "Autres imp\u00f4ts, taxes et versements assimil\u00e9s", + "parent": 4471, + "position": 1 + }, + "4473": { + "code": 4473, + "nom": "Autres imp\u00f4ts, taxes et versements assimil\u00e9s sur r\u00e9mun\u00e9rations (Autres organismes)", + "parent": 447, + "position": 1 + }, + "44733": { + "code": 44733, + "nom": "Participation des employeurs \u00e0 la formation professionnelle continue", + "parent": 4473, + "position": 1 + }, + "44734": { + "code": 44734, + "nom": "Participation des employeurs \u00e0 l'effort de construction (versements \u00e0 fonds perdus)", + "parent": 4473, + "position": 1 + }, + "4475": { + "code": 4475, + "nom": "Autres imp\u00f4ts, taxes et versements assimil\u00e9s (Administration des imp\u00f4ts)", + "parent": 447, + "position": 1 + }, + "4477": { + "code": 4477, + "nom": "Autres imp\u00f4ts, taxes et versements assimil\u00e9s (Autres organismes)", + "parent": 447, + "position": 1 + }, + "448": { + "code": 448, + "nom": "\u00c9tat - Charges \u00e0 payer et produits \u00e0 recevoir", + "parent": 44, + "position": 1 + }, + "4482": { + "code": 4482, + "nom": "Charges fiscales sur cong\u00e9s \u00e0 payer", + "parent": 448, + "position": 1 + }, + "4486": { + "code": 4486, + "nom": "Autres charges \u00e0 payer", + "parent": 448, + "position": 1 + }, + "4487": { + "code": 4487, + "nom": "Produits \u00e0 recevoir", + "parent": 448, + "position": 2 + }, + "449": { + "code": 449, + "nom": "Avances aupr\u00e8s de l'\u00e9tat et des collectivit\u00e9s publiques", + "parent": 44, + "position": 1 + }, + "45": { + "code": 45, + "nom": "CONF\u00c9D\u00c9RATION, F\u00c9D\u00c9RATION, UNIONS ET ASSOCIATIONS AFFILI\u00c9ES", + "parent": 4, + "position": 3 + }, + "451": { + "code": 451, + "nom": "Conf\u00e9d\u00e9ration, f\u00e9d\u00e9ration et associations affili\u00e9es - Compte courant", + "parent": 45, + "position": 3 + }, + "455": { + "code": 455, + "nom": "Soci\u00e9taires - Comptes courants", + "parent": 45, + "position": 3 + }, + "46": { + "code": 46, + "nom": "D\u00c9BITEURS DIVERS ET CR\u00c9DITEURS DIVERS", + "parent": 4, + "position": 3 + }, + "467": { + "code": 467, + "nom": "Autres comptes d\u00e9biteurs et cr\u00e9diteurs", + "parent": 46, + "position": 3 + }, + "468": { + "code": 468, + "nom": "Divers - Charges \u00e0 payer et produits \u00e0 recevoir", + "parent": 46, + "position": 3 + }, + "4686": { + "code": 4686, + "nom": "Charges \u00e0 payer", + "parent": 468, + "position": 1 + }, + "4687": { + "code": 4687, + "nom": "Produits \u00e0 recevoir", + "parent": 468, + "position": 2 + }, + "47": { + "code": 47, + "nom": "COMPTES TRANSITOIRES OU D'ATTENTE", + "parent": 4, + "position": 3 + }, + "471": { + "code": 471, + "nom": "Recettes \u00e0 classer", + "parent": 47, + "position": 1 + }, + "472": { + "code": 472, + "nom": "D\u00e9penses \u00e0 classer et \u00e0 r\u00e9gulariser", + "parent": 47, + "position": 2 + }, + "48": { + "code": 48, + "nom": "COMPTES DE R\u00c9GULARISATION", + "parent": 4, + "position": 3 + }, + "481": { + "code": 481, + "nom": "Charges \u00e0 r\u00e9partir sur plusieurs exercices", + "parent": 48, + "position": 2 + }, + "486": { + "code": 486, + "nom": "Charges constat\u00e9es d'avance", + "parent": 48, + "position": 2 + }, + "487": { + "code": 487, + "nom": "Produits constat\u00e9s d'avance", + "parent": 48, + "position": 1 + }, + "49": { + "code": 49, + "nom": "DEPRECIATION DES COMPTES DE TIERS", + "parent": 4, + "position": 2 + }, + "491": { + "code": 491, + "nom": "D\u00e9pr\u00e9ciation des comptes clients", + "parent": 49, + "position": 2 + }, + "496": { + "code": 496, + "nom": "D\u00e9pr\u00e9ciation des comptes d\u00e9biteurs divers", + "parent": 49, + "position": 2 + }, + "5": { + "code": 5, + "nom": "Classe 5 \u2014 Comptes financiers", + "parent": 0, + "position": 2 + }, + "50": { + "code": 50, + "nom": "VALEURS MOBILI\u00c8RES DE PLACEMENT", + "parent": 5, + "position": 2 + }, + "51": { + "code": 51, + "nom": "BANQUES, \u00c9TABLISSEMENTS FINANCIERS ET ASSIMIL\u00c9S", + "parent": 5, + "position": 2 + }, + "511": { + "code": 511, + "nom": "Valeurs à l'encaissement", + "parent": 51, + "position": 2 + }, + "5112": { + "code": 5112, + "nom": "Chèques à encaisser", + "parent": 511, + "position": 2 + }, + "5115": { + "code": 5115, + "nom": "Paiements par carte à encaisser", + "parent": 511, + "position": 2 + }, + "512": { + "code": 512, + "nom": "Banques", + "parent": 51, + "position": 2 + }, + "53": { + "code": 53, + "nom": "CAISSE", + "parent": 5, + "position": 2 + }, + "530": { + "code": 530, + "nom": "Caisse", + "parent": 53, + "position": 2 + }, + "54": { + "code": 54, + "nom": "R\u00c9GIES D'AVANCES ET ACCR\u00c9DITIFS", + "parent": 5, + "position": 2 + }, + "58": { + "code": 58, + "nom": "VIREMENTS INTERNES", + "parent": 5, + "position": 2 + }, + "59": { + "code": 59, + "nom": "PROVISIONS POUR D\u00c9PR\u00c9CIATION DES COMPTES FINANCIERS", + "parent": 5, + "position": 2 + }, + "6": { + "code": 6, + "nom": "Classe 6 \u2014 Comptes de charges", + "parent": 0, + "position": 8 + }, + "60": { + "code": 60, + "nom": "ACHATS", + "parent": 6, + "position": 8 + }, + "601": { + "code": 601, + "nom": "Achats stock\u00e9s - Mati\u00e8res premi\u00e8res et fournitures", + "parent": 60, + "position": 8 + }, + "602": { + "code": 602, + "nom": "Achats stock\u00e9s - Autres approvisionnements", + "parent": 60, + "position": 8 + }, + "604": { + "code": 604, + "nom": "Achat d'\u00e9tudes et prestations de services", + "parent": 60, + "position": 8 + }, + "606": { + "code": 606, + "nom": "Achats non stock\u00e9s de mati\u00e8res et fournitures", + "parent": 60, + "position": 8 + }, + "6061": { + "code": 6061, + "nom": "Fournitures non stockables (eau, \u00e9nergie...)", + "parent": 606, + "position": 8 + }, + "6063": { + "code": 6063, + "nom": "Fournitures d'entretien et de petit \u00e9quipement", + "parent": 606, + "position": 8 + }, + "6064": { + "code": 6064, + "nom": "Fournitures administratives", + "parent": 606, + "position": 8 + }, + "6068": { + "code": 6068, + "nom": "Autres mati\u00e8res et fournitures", + "parent": 606, + "position": 8 + }, + "607": { + "code": 607, + "nom": "Achats de marchandises", + "parent": 60, + "position": 8 + }, + "61": { + "code": 61, + "nom": "SERVICES EXT\u00c9RIEURS", + "parent": 6, + "position": 8 + }, + "611": { + "code": 611, + "nom": "Sous-traitance g\u00e9n\u00e9rale", + "parent": 61, + "position": 8 + }, + "612": { + "code": 612, + "nom": "Redevances de cr\u00e9dit-bail", + "parent": 61, + "position": 8 + }, + "613": { + "code": 613, + "nom": "Locations", + "parent": 61, + "position": 8 + }, + "614": { + "code": 614, + "nom": "Charges locatives et de co-propri\u00e9t\u00e9", + "parent": 61, + "position": 8 + }, + "615": { + "code": 615, + "nom": "Entretiens et r\u00e9parations", + "parent": 61, + "position": 8 + }, + "616": { + "code": 616, + "nom": "Primes d'assurance", + "parent": 61, + "position": 8 + }, + "618": { + "code": 618, + "nom": "Divers", + "parent": 61, + "position": 8 + }, + "62": { + "code": 62, + "nom": "AUTRES SERVICES EXT\u00c9RIEURS", + "parent": 6, + "position": 8 + }, + "621": { + "code": 621, + "nom": "Personnel ext\u00e9rieur \u00e0 l'association", + "parent": 62, + "position": 8 + }, + "622": { + "code": 622, + "nom": "R\u00e9mun\u00e9rations d'interm\u00e9diaires et honoraires", + "parent": 62, + "position": 8 + }, + "6226": { + "code": 6226, + "nom": "Honoraires", + "parent": 622, + "position": 8 + }, + "6227": { + "code": 6227, + "nom": "Frais d'actes et de contentieux", + "parent": 622, + "position": 8 + }, + "6228": { + "code": 6228, + "nom": "Divers", + "parent": 622, + "position": 8 + }, + "623": { + "code": 623, + "nom": "Publicit\u00e9, publications, relations publiques", + "parent": 62, + "position": 8 + }, + "624": { + "code": 624, + "nom": "Transports de biens et transports collectifs du personnel", + "parent": 62, + "position": 8 + }, + "625": { + "code": 625, + "nom": "D\u00e9placements, missions et r\u00e9ceptions", + "parent": 62, + "position": 8 + }, + "626": { + "code": 626, + "nom": "Frais postaux et de t\u00e9l\u00e9communications", + "parent": 62, + "position": 8 + }, + "627": { + "code": 627, + "nom": "Services bancaires et assimil\u00e9s", + "parent": 62, + "position": 8 + }, + "628": { + "code": 628, + "nom": "Divers", + "parent": 62, + "position": 8 + }, + "63": { + "code": 63, + "nom": "IMP\u00d4TS, TAXES ET VERSEMENTS ASSIMIL\u00c9S", + "parent": 6, + "position": 8 + }, + "631": { + "code": 631, + "nom": "Imp\u00f4ts, taxes et versements assimil\u00e9s sur r\u00e9mun\u00e9rations (Administration des imp\u00f4ts)", + "parent": 63, + "position": 8 + }, + "6311": { + "code": 6311, + "nom": "Taxes sur les salaires", + "parent": 631, + "position": 8 + }, + "6313": { + "code": 6313, + "nom": "Participations des employeurs \u00e0 la formation professionnelle continue", + "parent": 631, + "position": 8 + }, + "635": { + "code": 635, + "nom": "Autres imp\u00f4ts, taxes et versements assimil\u00e9s (Administration des imp\u00f4ts)", + "parent": 63, + "position": 8 + }, + "6351": { + "code": 6351, + "nom": "Imp\u00f4ts directs (sauf imp\u00f4ts sur les b\u00e9n\u00e9fices)", + "parent": 635, + "position": 8 + }, + "6353": { + "code": 6353, + "nom": "Imp\u00f4ts indirects", + "parent": 635, + "position": 8 + }, + "637": { + "code": 637, + "nom": "Autres imp\u00f4ts, taxes et versements assimil\u00e9s (Autres organismes)", + "parent": 63, + "position": 8 + }, + "64": { + "code": 64, + "nom": "CHARGES DE PERSONNEL", + "parent": 6, + "position": 8 + }, + "641": { + "code": 641, + "nom": "R\u00e9mun\u00e9rations du personnel", + "parent": 64, + "position": 8 + }, + "643": { + "code": 643, + "nom": "R\u00e9mun\u00e9rations du personnel artistique et assimil\u00e9s", + "parent": 64, + "position": 8 + }, + "645": { + "code": 645, + "nom": "Charges de s\u00e9curit\u00e9 sociale et de pr\u00e9voyance", + "parent": 64, + "position": 8 + }, + "647": { + "code": 647, + "nom": "Autres charges sociales", + "parent": 64, + "position": 8 + }, + "648": { + "code": 648, + "nom": "Autres charges de personnel", + "parent": 64, + "position": 8 + }, + "65": { + "code": 65, + "nom": "AUTRES CHARGES DE GESTION COURANTE", + "parent": 6, + "position": 8 + }, + "652": { + "code": 652, + "nom": "Licences fédérales", + "parent": 652, + "position": 8 + }, + "658": { + "code": 658, + "nom": "Charges diverses de gestion courante", + "parent": 65, + "position": 8 + }, + "66": { + "code": 66, + "nom": "CHARGES FINANCI\u00c8RES", + "parent": 6, + "position": 8 + }, + "661": { + "code": 661, + "nom": "Charges d'int\u00e9r\u00eats", + "parent": 66, + "position": 8 + }, + "67": { + "code": 67, + "nom": "CHARGES EXCEPTIONNELLES", + "parent": 6, + "position": 8 + }, + "671": { + "code": 671, + "nom": "Charges exceptionnelles sur op\u00e9rations de gestion", + "parent": 67, + "position": 8 + }, + "6713": { + "code": 6713, + "nom": "Dons, lib\u00e9ralit\u00e9s", + "parent": 671, + "position": 8 + }, + "678": { + "code": 678, + "nom": "Autres charges exceptionnelles", + "parent": 67, + "position": 8 + }, + "6788": { + "code": 6788, + "nom": "Charges exceptionnelles diverses", + "parent": 678, + "position": 8 + }, + "68": { + "code": 68, + "nom": "DOTATIONS AUX AMORTISSEMENTS, D\u00c9PR\u00c9CIATIONS, PROVISIONS ET ENGAGEMENTS", + "parent": 6, + "position": 8 + }, + "681": { + "code": 681, + "nom": "Dotations aux amortissements, d\u00e9pr\u00e9ciations et provisions - Charges d'exploitation", + "parent": 68, + "position": 8 + }, + "6811": { + "code": 6811, + "nom": "Dotations aux amortissements des immobilisations incorporelles et corporelles", + "parent": 681, + "position": 8 + }, + "68111": { + "code": 68111, + "nom": "Immobilisations incorporelles", + "parent": 6811, + "position": 8 + }, + "68112": { + "code": 68112, + "nom": "Immobilisations corporelles", + "parent": 6811, + "position": 8 + }, + "686": { + "code": 686, + "nom": "Dotations aux amortissements, d\u00e9pr\u00e9ciations et provisions - Charges financi\u00e8res", + "parent": 68, + "position": 8 + }, + "69": { + "code": 69, + "nom": "PARTICIPATION DES SALARI\u00c9S - IMP\u00d4TS SUR LES B\u00c9N\u00c9FICES ET ASSIMIL\u00c9S", + "parent": 6, + "position": 8 + }, + "695": { + "code": 695, + "nom": "Imp\u00f4ts sur les soci\u00e9t\u00e9s (y compris imp\u00f4ts sur les soci\u00e9t\u00e9s des personnes morales non lucratives)", + "parent": 69, + "position": 8 + }, + "7": { + "code": 7, + "nom": "Classe 7 \u2014 Comptes de produits", + "parent": 0, + "position": 4 + }, + "70": { + "code": 70, + "nom": "VENTES DE PRODUITS FINIS, PRESTATIONS DE SERVICES, MARCHANDISES", + "parent": 7, + "position": 4 + }, + "701": { + "code": 701, + "nom": "Ventes de produits finis", + "parent": 70, + "position": 4 + }, + "706": { + "code": 706, + "nom": "Prestations de services", + "parent": 70, + "position": 4 + }, + "707": { + "code": 707, + "nom": "Ventes de marchandises", + "parent": 70, + "position": 4 + }, + "708": { + "code": 708, + "nom": "Produits des activit\u00e9s annexes", + "parent": 70, + "position": 4 + }, + "71": { + "code": 71, + "nom": "PRODUCTION STOCK\u00c9E (OU D\u00c9STOCKAGE)", + "parent": 7, + "position": 4 + }, + "72": { + "code": 72, + "nom": "PRODUCTION IMMOBILIS\u00c9E", + "parent": 7, + "position": 4 + }, + "74": { + "code": 74, + "nom": "SUBVENTIONS D'EXPLOITATION", + "parent": 7, + "position": 4 + }, + "740": { + "code": 740, + "nom": "Subventions re\u00e7ues", + "parent": 74, + "position": 4 + }, + "75": { + "code": 75, + "nom": "AUTRES PRODUITS DE GESTION COURANTE", + "parent": 7, + "position": 4 + }, + "754": { + "code": 754, + "nom": "Collectes", + "parent": 75, + "position": 4 + }, + "756": { + "code": 756, + "nom": "Cotisations", + "parent": 75, + "position": 4 + }, + "758": { + "code": 758, + "nom": "Produits divers de gestion courante", + "parent": 75, + "position": 4 + }, + "7587": { + "code": 7587, + "nom": "Ventes de dons en nature", + "parent": 758, + "position": 4 + }, + "7588": { + "code": 7588, + "nom": "Autres produits de la g\u00e9n\u00e9rosit\u00e9 du public", + "parent": 758, + "position": 4 + }, + "76": { + "code": 76, + "nom": "PRODUITS FINANCIERS", + "parent": 7, + "position": 4 + }, + "760": { + "code": 760, + "nom": "Produits financiers", + "parent": 76, + "position": 4 + }, + "77": { + "code": 77, + "nom": "PRODUITS EXCEPTIONNELS", + "parent": 7, + "position": 4 + }, + "771": { + "code": 771, + "nom": "Produits exceptionnels sur op\u00e9rations de gestion", + "parent": 77, + "position": 4 + }, + "7713": { + "code": 7713, + "nom": "Lib\u00e9ralit\u00e9s re\u00e7ues", + "parent": 771, + "position": 4 + }, + "7715": { + "code": 7715, + "nom": "Subventions d'\u00e9quilibre", + "parent": 771, + "position": 4 + }, + "775": { + "code": 775, + "nom": "Produits des cessions d'\u00e9l\u00e9ments d'actifs", + "parent": 77, + "position": 4 + }, + "778": { + "code": 778, + "nom": "Autres produits exceptionnels", + "parent": 77, + "position": 4 + }, + "7780": { + "code": 7780, + "nom": "Manifestations diverses", + "parent": 778, + "position": 4 + }, + "7788": { + "code": 7788, + "nom": "Produits exceptionnels divers", + "parent": 778, + "position": 4 + }, + "78": { + "code": 78, + "nom": "REPRISES SUR AMORTISSEMENTS ET PROVISIONS", + "parent": 7, + "position": 4 + }, + "79": { + "code": 79, + "nom": "TRANSFERT DE CHARGES", + "parent": 7, + "position": 4 + }, + "791": { + "code": 791, + "nom": "Transferts de charges d'exploitation", + "parent": 79, + "position": 4 + }, + "796": { + "code": 796, + "nom": "Transferts de charges financi\u00e8res", + "parent": 79, + "position": 4 + }, + "797": { + "code": 797, + "nom": "Transferts de charges exceptionnels", + "parent": 79, + "position": 4 + }, + "8": { + "code": 8, + "nom": "Classe 8 \u00ad\u2014 Comptes sp\u00e9ciaux", + "parent": 0, + "position": 12 + }, + "86": { + "code": 86, + "nom": "R\u00c9PARTITION PAR NATURE DE CHARGES", + "parent": 8, + "position": 8 + }, + "861": { + "code": 861, + "nom": "Mise \u00e0 dispositions gratuites de biens", + "parent": 86, + "position": 8 + }, + "862": { + "code": 862, + "nom": "Prestations", + "parent": 86, + "position": 8 + }, + "864": { + "code": 864, + "nom": "Personnel b\u00e9n\u00e9vole", + "parent": 86, + "position": 8 + }, + "87": { + "code": 87, + "nom": "R\u00c9PARTITION PAR NATURE DE RESSOURCES", + "parent": 8, + "position": 4 + }, + "870": { + "code": 870, + "nom": "B\u00e9n\u00e9volat", + "parent": 87, + "position": 4 + }, + "871": { + "code": 871, + "nom": "Prestations en nature", + "parent": 87, + "position": 4 + }, + "875": { + "code": 875, + "nom": "Dons en nature", + "parent": 87, + "position": 4 + }, + "89": { + "code": 89, + "nom": "BILAN", + "parent": 8, + "position": 3 + }, + "890": { + "code": 890, + "nom": "Bilan d'ouverture", + "parent": 89, + "position": 3 + }, + "891": { + "code": 891, + "nom": "Bilan de clôture", + "parent": 89, + "position": 3 + }, + "9": { + "code": 9, + "nom": "Classe 9 \u2014 Comptes analytiques", + "parent": 0, + "position": 12 + } +} \ No newline at end of file diff --git a/build/debian/config.debian.php b/build/debian/config.debian.php new file mode 100644 index 0000000..14664fc --- /dev/null +++ b/build/debian/config.debian.php @@ -0,0 +1,141 @@ + DEBIAN/md5sums + +true && { + echo "Generating Debian-specific files..." + cp ${THISDIR}/../../COPYING ${DEBLOCALPREFIX}/share/doc/${PACKAGE_DEBNAME}/copyright +} || { + echo "Fail." + exit 1 +} + +true && { + cat < DEBIAN/postinst +#!/bin/sh + +chown www-data:www-data /var/lib/paheko /var/cache/paheko +chown root:www-data /etc/paheko +chmod g=rX,o= /etc/paheko +chmod ug=rwX,o= /var/lib/paheko /var/cache/paheko +EOF + + chmod +x DEBIAN/postinst + +} + +true && { + CHANGELOG=${DEBLOCALPREFIX}/share/doc/${PACKAGE_DEBNAME}/changelog.gz + cat < ${CHANGELOG} +${PACKAGE_DEBNAME} ${PACKAGE_DEB_VERSION}; urgency=low + +This release has no changes over the core source distribution. It has +simply been Debianized. + +Packaged by ${USER} on +${PACKAGE_TIME}. + +EOF + +} + +# doc. +DOCDIR=${DEBLOCALPREFIX}/share/doc/${PACKAGE_DEBNAME} + +true && { + echo "Generating doc..." + cp ${THISDIR}/../../README.md ${DOCDIR} + a2x --doctype manpage --format manpage ${THISDIR}/manpage.txt + mkdir -p ${DEBLOCALPREFIX}/share/man/man1 + gzip -c ${THISDIR}/paheko.1 > ${DEBLOCALPREFIX}/share/man/man1/${PACKAGE_DEBNAME}.1.gz + rm -f ${THISDIR}/paheko.1 +} || { + echo "Fail." + exit 1 +} + +true && { + CONTROL=DEBIAN/control + echo "Generating ${CONTROL}..." + cat < ${CONTROL} +Package: ${PACKAGE_DEBNAME} +Section: web +Priority: optional +Maintainer: Paheko +Architecture: ${DEB_ARCH_NAME} +Depends: dash | bash, php-cli (>=7.4), php-sqlite3, php-intl, php-mbstring, sensible-utils +Version: ${PACKAGE_DEB_VERSION} +Suggests: php-imagick +Replaces: garradin (<< 1.2.3~) +Breaks: garradin (<< 1.2.3~) +Homepage: https://fossil.kd2.org/paheko/ +Description: Paheko is a tool to manage non-profit organizations. + It's only available in french. +Description-fr: Gestionnaire d'association en interface web ou CLI. + Paheko est un gestionnaire d'association à but non lucratif. + Il permet de gérer les membres, leur adhésion et leurs contributions financières. + Les membres peuvent se connecter eux-même et modifier leurs informations + ou communiquer entre eux par e-mail. Une gestion précise des droits et + autorisations est possible. Il est également possible de faire des + envois de mails en groupe. + . + Un module de comptabilité à double entrée assure une gestion financière + complète digne d'un vrai logiciel de comptabilité : suivi des opérations, + graphiques, bilan annuel, compte de résultat, exercices, etc. + . + Il y a également la possibilité de publier un site web simple, + et un gestionnaire de documents permettant de gérer les fichiers de + l'association. + +EOF + +} + + +true && { + fakeroot dpkg-deb -b ${DEBROOT} ${DEBFILE} + echo "Package file created:" + ls -la ${DEBFILE} + dpkg-deb --info ${DEBFILE} +} + +cd - >/dev/null +true && { + echo "Cleaning up..." + rm -fr ${DEBROOT} + rm -rf ${SRCDIR} +} + +echo "Done :)" diff --git a/build/debian/manpage.txt b/build/debian/manpage.txt new file mode 100644 index 0000000..5600198 --- /dev/null +++ b/build/debian/manpage.txt @@ -0,0 +1,106 @@ +PAHEKO(1) +========= +:doctype: manpage + + +NAME +---- +paheko - Gestionnaire d'association à but non lucratif + + +SYNOPSIS +-------- +*paheko* ['OPTIONS'] ['COMMANDE'] ['BASE'] + + +DESCRIPTION +----------- +Lancer paheko(1) sans argument lance le serveur web intégré sur +l'adresse localhost:8081 et le navigateur web par défaut. + +*BASE* défini le chemin de la base de données (fichier .sqlite) à +utiliser. Par défaut, si aucune base n'est spécifiée, c'est le fichier +'association.sqlite' situé dans le répertoire des données qui est +utilisé. + +OPTIONS +------- +*-p, --port*='PORT':: +Défini le port utilisé par le serveur web. +Par défaut c'est le port 8081 qui est utilisé. + +*-b, --bind*='IP':: +Adresse IP où sera exposé le serveur web. +Par défaut c'est 127.0.0.1 qui est utilisé. + +Utiliser 0.0.0.0 pour que le serveur web soit accessible d'autres +machines. (Attention cela peut présenter un risque de sécurité.) + +*-v, --verbose*:: +Affiche les messages du serveur web. + +*-h, --help*:: +Affiche un message d'aide sur l'utilisation de la commande. + +COMMANDES +--------- +*server*:: +Lance le serveur web autonome de Paheko sans lancer de navigateur +web. + +*ui*:: +Lance le serveur web autonome et le navigateur par défaut. + +EXIT STATUS +----------- +*0*:: +Succès + +*1*:: +Erreur + +EMPLACEMENTS DE STOCKAGE +------------------------ +Les données sont stockées dans $XDG_DATA_HOME/paheko. +Généralement c'est ~/.local/share/paheko + +CONFIGURATION +------------- +Il est possible de créer un fichier de configuration dans +$XDG_CONFIG_HOME/paheko/config.local.php + +Voir la documentation pour plus de détails sur les constantes +de configuration acceptées. + +INSTALLATION SERVEUR WEB +------------------------ +Il est possible d'utiliser ce package avec Apache pour héberger +une instance Paheko. + +La procédure est détaillée ici : +https://fossil.kd2.org/paheko/wiki?name=Installation%20sous%20Debian-Ubuntu + +Les données et plugins seront stockés dans le répertoire +/var/lib/paheko + +BUGS +---- +Voir https://fossil.kd2.org/paheko/ pour un accès au bugtracker. + + +AUTEUR +------ +Paheko est développé par bohwaz et d'autres contributeurs. + + +RESSOURCES +---------- + +Site principal : + + +COPYING +------- +Copyright \(C) 2011-2023 BohwaZ. Free use of this software is +granted under the terms of the GNU Affero General Public License v3 +(AGPL). diff --git a/build/debian/paheko b/build/debian/paheko new file mode 100755 index 0000000..e5cdad5 --- /dev/null +++ b/build/debian/paheko @@ -0,0 +1,123 @@ +#!/bin/sh + +ROOT=/usr/share/paheko/www +#ROOT=~/fossil/paheko/src/www +ROUTER=${ROOT}/_route.php +PORT=8081 +ADDRESS="127.0.0.1" +VERBOSE=0 +PID_FILE="${XDG_RUNTIME_DIR}/paheko/pid" + +[ ! -d `dirname $PID_FILE` ] && mkdir -p `dirname $PID_FILE` + +# Execute getopt +ARGS=`getopt -o "pb:vh" -l "port:,bind:,verbose,help" -n "paheko" -- "$@"` + +# Bad arguments +if [ $? -ne 0 ]; +then + exit 1 +fi + +# A little magic +eval set -- "$ARGS" + +# Now go through all the options +while true; +do + case "$1" in + -p|--port) + PORT=$2 + shift;; + + -b|--bind) + ADDRESS=$2 + shift;; + + -v|--verbose) + VERBOSE=1 + shift;; + + -h|--help) + cat < /dev/null 2>&1 && rm -f $PID_FILE + +PHP_CLI_SERVER_WORKER=2 + +[ $VERBOSE = 1 ] && { + php -S ${ADDRESS}:${PORT} -t ${ROOT} -d variables_order=EGPCS ${ROUTER} & +} || { + php -S ${ADDRESS}:${PORT} -t ${ROOT} -d variables_order=EGPCS ${ROUTER} > /dev/null 2>&1 & +} + +php_pid=$! + +echo $php_pid > $PID_FILE + +sleep .5 + +[ "$CMD" = "ui" ] && { + URL="http://${ADDRESS}:${PORT}/admin/" + [ "$DISPLAY" != "" ] && { + sensible-browser ${URL} & + } || { + www-browser ${URL} & + } +} || { + wait $php_pid +} diff --git a/build/debian/paheko.desktop b/build/debian/paheko.desktop new file mode 100644 index 0000000..0768d67 --- /dev/null +++ b/build/debian/paheko.desktop @@ -0,0 +1,6 @@ +[Desktop Entry] +Name=Paheko +Exec=paheko +Icon=paheko +Type=Application +Categories=Office;Finance;Database \ No newline at end of file diff --git a/build/debian/paheko.menu b/build/debian/paheko.menu new file mode 100644 index 0000000..cf09772 --- /dev/null +++ b/build/debian/paheko.menu @@ -0,0 +1,3 @@ +?package(paheko):needs="X11" section="Applications/Office"\ + title="Paheko" command="/usr/bin/paheko"\ + icon="/usr/share/paheko/paheko.png" diff --git a/build/debian/paheko.png b/build/debian/paheko.png new file mode 100644 index 0000000..7d57a9e Binary files /dev/null and b/build/debian/paheko.png differ diff --git a/build/windows/Makefile b/build/windows/Makefile new file mode 100644 index 0000000..18659ce --- /dev/null +++ b/build/windows/Makefile @@ -0,0 +1,81 @@ +.PHONY := php installer clean publish +PHP_ARCHIVE := https://windows.php.net/downloads/releases/php-8.2.10-nts-Win32-vs16-x64.zip + +all: installer + +php.zip: + wget ${PHP_ARCHIVE} -O php.zip + +php: php.zip + mkdir -p install_dir/php + unzip -o php.zip -d install_dir/php > /dev/null + + # Remove unused files + @cd install_dir/php && rm -rf \ + phpdbg.exe \ + php8phpdbg.dll \ + php8embed.lib \ + php-cgi.exe \ + php.ini-* \ + dev \ + phar* \ + nghttp2.dll \ + libpq.dll \ + libenchant + + # Remove unused extensions + @cd install_dir/php/ext && rm -f \ + php_bz2.dll \ + php_com_dotnet.dll \ + php_curl.dll \ + php_dba.dll \ + php_dl_test.dll \ + php_enchant.dll \ + php_exif.dll \ + php_ffi.dll \ + php_ftp.dll \ + php_gmp.dll \ + php_imap.dll \ + php_ldap.dll \ + php_mysqli.dll \ + php_oci8_19.dll \ + php_odbc.dll \ + php_opcache.dll \ + php_pdo_firebird.dll \ + php_pdo_mysql.dll \ + php_pdo_oci.dll \ + php_pdo_odbc.dll \ + php_pdo_pgsql.dll \ + php_pdo_sqlite.dll \ + php_pgsql.dll \ + php_shmop.dll \ + php_snmp.dll \ + php_soap.dll \ + php_sysvshm.dll \ + php_xsl.dll \ + php_zend_test.dll + + du -hs install_dir/php + +installer: clean php + $(eval VERSION=$(shell cat ../../src/VERSION)) + # NSIS only accepts numbers as version + $(eval NSIS_VERSION=$(shell sed -E 's/-(alpha|beta|rc)[0-9]+//' ../../src/VERSION)) + mkdir -p install_dir + cp ../paheko-${VERSION}.tar.gz install_dir/ + cd install_dir && tar xzf paheko-${VERSION}.tar.gz && mv paheko-${VERSION} paheko + cp config.local.php install_dir/paheko/ + cp php.ini install_dir/php + cp launch.bat install_dir + cp paheko.ico install_dir + rm -f install_dir/paheko-${VERSION}.tar.gz + makensis -V3 -DNVERSION=${NSIS_VERSION} -DVERSION=${VERSION} paheko.nsis + +clean: + rm -rf install_dir + +publish: + $(eval VERSION=$(shell cat ../../src/VERSION)) + fossil uv ls | grep '^paheko-.*\.exe' | xargs fossil uv rm + fossil uv add paheko-${VERSION}.exe + fossil uv sync diff --git a/build/windows/README.md b/build/windows/README.md new file mode 100644 index 0000000..4ea144e --- /dev/null +++ b/build/windows/README.md @@ -0,0 +1,5 @@ +# Paheko Windows build + +## Requirements + +NSIS: `apt install nsis` \ No newline at end of file diff --git a/build/windows/config.local.php b/build/windows/config.local.php new file mode 100644 index 0000000..c370400 --- /dev/null +++ b/build/windows/config.local.php @@ -0,0 +1,34 @@ + NUL diff --git a/build/windows/paheko.ico b/build/windows/paheko.ico new file mode 100644 index 0000000..1132a9a Binary files /dev/null and b/build/windows/paheko.ico differ diff --git a/build/windows/paheko.nsis b/build/windows/paheko.nsis new file mode 100644 index 0000000..a00dab2 --- /dev/null +++ b/build/windows/paheko.nsis @@ -0,0 +1,175 @@ +# From https://www.conjur.org/blog/building-a-windows-installer-from-a-linux-ci-pipeline/ +!define APP_NAME "Paheko" +!define COMP_NAME "Paheko.cloud" +#!define WEB_SITE "https://paheko.cloud/" +#!define VERSION "0.0.0.1" +!define COPYRIGHT "Paheko" +!define DESCRIPTION "Gestion d'association simple et efficace" +!define INSTALLER_NAME "paheko-${VERSION}.exe" +!define MAIN_APP_EXE "launch.bat" +!define ICON "paheko.ico" +#!define BANNER "[CHANGEME Installer Banner Filename .bmp]" +#!define LICENSE_TXT "[CHANGEME License Text Document]" + +!define INSTALL_DIR "$PROGRAMFILES64\${APP_NAME}" +!define INSTALL_TYPE "SetShellVarContext all" +!define REG_ROOT "HKLM" +!define REG_APP_PATH "Software\Microsoft\Windows\CurrentVersion\App Paths\${MAIN_APP_EXE}" +!define UNINSTALL_PATH "Software\Microsoft\Windows\CurrentVersion\Uninstall\${APP_NAME}" +!define REG_START_MENU "Start Menu Folder" + +var SM_Folder + +###################################################################### + +VIProductVersion "${NVERSION}.0" +VIAddVersionKey "ProductName" "${APP_NAME}" +VIAddVersionKey "CompanyName" "${COMP_NAME}" +VIAddVersionKey "LegalCopyright" "${COPYRIGHT}" +VIAddVersionKey "FileDescription" "${DESCRIPTION}" +VIAddVersionKey "FileVersion" "${VERSION}" + +###################################################################### + +SetCompressor /SOLID Lzma +Name "${APP_NAME}" +Caption "${APP_NAME}" +OutFile "${INSTALLER_NAME}" +BrandingText "${APP_NAME}" +#InstallDirRegKey "${REG_ROOT}" "${REG_APP_PATH}" "" +InstallDir "${INSTALL_DIR}" + +###################################################################### + +!define MUI_ICON "${ICON}" +!define MUI_UNICON "${ICON}" +Icon "${ICON}" + +!ifdef BANNER +!define MUI_WELCOMEFINISHPAGE_BITMAP "${BANNER}" +!define MUI_UNWELCOMEFINISHPAGE_BITMAP "${BANNER}" +!endif + +###################################################################### + +!include "MUI2.nsh" + +!define MUI_ABORTWARNING +!define MUI_UNABORTWARNING + +!insertmacro MUI_PAGE_WELCOME + +!ifdef LICENSE_TXT +!insertmacro MUI_PAGE_LICENSE "${LICENSE_TXT}" +!endif + +!insertmacro MUI_PAGE_DIRECTORY + +!ifdef REG_START_MENU +!define MUI_STARTMENUPAGE_DEFAULTFOLDER "${APP_NAME}" +!define MUI_STARTMENUPAGE_REGISTRY_ROOT "${REG_ROOT}" +!define MUI_STARTMENUPAGE_REGISTRY_KEY "${UNINSTALL_PATH}" +!define MUI_STARTMENUPAGE_REGISTRY_VALUENAME "${REG_START_MENU}" +!insertmacro MUI_PAGE_STARTMENU Application $SM_Folder +!endif + +!insertmacro MUI_PAGE_INSTFILES + +!insertmacro MUI_PAGE_FINISH + +!insertmacro MUI_UNPAGE_CONFIRM + +!insertmacro MUI_UNPAGE_INSTFILES + +!insertmacro MUI_UNPAGE_FINISH + +!insertmacro MUI_LANGUAGE "French" + +###################################################################### + +Section -MainProgram + ${INSTALL_TYPE} + + SetOverwrite ifnewer + SetOutPath "$INSTDIR" + File /r "install_dir\\" + +SectionEnd + +###################################################################### + +Section -Icons_Reg +SetOutPath "$INSTDIR" +WriteUninstaller "$INSTDIR\uninstall.exe" + +!ifdef REG_START_MENU +!insertmacro MUI_STARTMENU_WRITE_BEGIN Application +CreateDirectory "$SMPROGRAMS\$SM_Folder" +CreateShortCut "$SMPROGRAMS\$SM_Folder\${APP_NAME}.lnk" "$INSTDIR\${MAIN_APP_EXE}" "" "$INSTDIR\paheko.ico" +CreateShortCut "$DESKTOP\${APP_NAME}.lnk" "$INSTDIR\${MAIN_APP_EXE}" "" "$INSTDIR\paheko.ico" +CreateShortCut "$SMPROGRAMS\$SM_Folder\Desinstaller ${APP_NAME}.lnk" "$INSTDIR\uninstall.exe" + +!ifdef WEB_SITE +WriteIniStr "$INSTDIR\${APP_NAME} website.url" "InternetShortcut" "URL" "${WEB_SITE}" +CreateShortCut "$SMPROGRAMS\$SM_Folder\${APP_NAME} Website.lnk" "$INSTDIR\${APP_NAME} website.url" +!endif +!insertmacro MUI_STARTMENU_WRITE_END +!endif + +!ifndef REG_START_MENU +CreateDirectory "$SMPROGRAMS\${APP_NAME}" +CreateShortCut "$SMPROGRAMS\${APP_NAME}\${APP_NAME}.lnk" "$INSTDIR\${MAIN_APP_EXE}" "" "$INSTDIR\paheko.ico" +CreateShortCut "$DESKTOP\${APP_NAME}.lnk" "$INSTDIR\${MAIN_APP_EXE}" "" "$INSTDIR\paheko.ico" +CreateShortCut "$SMPROGRAMS\${APP_NAME}\Desinstaller ${APP_NAME}.lnk" "$INSTDIR\uninstall.exe" + +!ifdef WEB_SITE +WriteIniStr "$INSTDIR\${APP_NAME} website.url" "InternetShortcut" "URL" "${WEB_SITE}" +CreateShortCut "$SMPROGRAMS\${APP_NAME}\${APP_NAME} Website.lnk" "$INSTDIR\${APP_NAME} website.url" +!endif +!endif + +WriteRegStr ${REG_ROOT} "${REG_APP_PATH}" "" "$INSTDIR\${MAIN_APP_EXE}" +WriteRegStr ${REG_ROOT} "${UNINSTALL_PATH}" "DisplayName" "${APP_NAME}" +WriteRegStr ${REG_ROOT} "${UNINSTALL_PATH}" "UninstallString" "$INSTDIR\uninstall.exe" +WriteRegStr ${REG_ROOT} "${UNINSTALL_PATH}" "DisplayIcon" "$INSTDIR\paheko.ico" +WriteRegStr ${REG_ROOT} "${UNINSTALL_PATH}" "DisplayVersion" "${VERSION}" +WriteRegStr ${REG_ROOT} "${UNINSTALL_PATH}" "Publisher" "${COMP_NAME}" + +!ifdef WEB_SITE +WriteRegStr ${REG_ROOT} "${UNINSTALL_PATH}" "URLInfoAbout" "${WEB_SITE}" +!endif +SectionEnd + +###################################################################### + +Section Uninstall +${INSTALL_TYPE} + +RmDir /r "$INSTDIR" + +!ifdef REG_START_MENU +!insertmacro MUI_STARTMENU_GETFOLDER "Application" $SM_Folder +Delete "$SMPROGRAMS\$SM_Folder\${APP_NAME}.lnk" +Delete "$SMPROGRAMS\$SM_Folder\Desinstaller ${APP_NAME}.lnk" +!ifdef WEB_SITE +Delete "$SMPROGRAMS\$SM_Folder\${APP_NAME} Website.lnk" +!endif +Delete "$DESKTOP\${APP_NAME}.lnk" + +RmDir "$SMPROGRAMS\$SM_Folder" +!endif + +!ifndef REG_START_MENU +Delete "$SMPROGRAMS\${APP_NAME}\${APP_NAME}.lnk" +Delete "$SMPROGRAMS\${APP_NAME}\Desinstaller ${APP_NAME}.lnk" +!ifdef WEB_SITE +Delete "$SMPROGRAMS\${APP_NAME}\${APP_NAME} Website.lnk" +!endif +Delete "$DESKTOP\${APP_NAME}.lnk" + +RmDir "$SMPROGRAMS\${APP_NAME}" +!endif + +DeleteRegKey ${REG_ROOT} "${REG_APP_PATH}" +DeleteRegKey ${REG_ROOT} "${UNINSTALL_PATH}" +SectionEnd \ No newline at end of file diff --git a/build/windows/php.ini b/build/windows/php.ini new file mode 100644 index 0000000..a227e75 --- /dev/null +++ b/build/windows/php.ini @@ -0,0 +1,95 @@ +[PHP] +engine = On +short_open_tag = Off +precision = 14 +output_buffering = 4096 +zlib.output_compression = Off +implicit_flush = Off +unserialize_callback_func = +serialize_precision = -1 +disable_functions = +disable_classes = +zend.enable_gc = On +zend.exception_ignore_args = Off +zend.exception_string_param_max_len = 15 +expose_php = On +max_execution_time = 30 +max_input_time = 60 +memory_limit = 128M +error_reporting = E_ALL +display_errors = On +display_startup_errors = On +log_errors = On +ignore_repeated_errors = Off +ignore_repeated_source = Off +report_memleaks = On +variables_order = "GPCS" +request_order = "GP" +register_argc_argv = Off +auto_globals_jit = On +post_max_size = 256M +auto_prepend_file = +auto_append_file = +default_mimetype = "text/html" +default_charset = "UTF-8" +doc_root = +user_dir = +extension_dir = "ext" +enable_dl = Off +file_uploads = On +upload_max_filesize = 256M +max_file_uploads = 20 +allow_url_fopen = On +allow_url_include = Off +default_socket_timeout = 60 + +extension=fileinfo +extension=gd +extension=gettext +extension=intl +extension=mbstring +extension=openssl +extension=sodium +extension=sqlite3 +extension=tidy + +[CLI Server] +cli_server.color = On + +[mail function] +SMTP = localhost +smtp_port = 25 +mail.add_x_header = Off + +[bcmath] +bcmath.scale = 0 + +[Session] +session.save_handler = files +session.use_strict_mode = 0 +session.use_cookies = 1 +session.use_only_cookies = 1 +session.name = PHPSESSID +session.auto_start = 0 +session.cookie_lifetime = 0 +session.cookie_path = / +session.cookie_domain = +session.cookie_httponly = +session.cookie_samesite = +session.serialize_handler = php +session.gc_probability = 1 +session.gc_divisor = 1000 +session.gc_maxlifetime = 1440 +session.referer_check = +session.cache_limiter = nocache +session.cache_expire = 180 +session.use_trans_sid = 0 +session.sid_length = 26 +session.trans_sid_tags = "a=href,area=href,frame=src,form=" +session.sid_bits_per_character = 5 + +[Assertion] +zend.assertions = -1 + +[Tidy] +tidy.clean_output = Off diff --git a/doc/admin/api.md b/doc/admin/api.md new file mode 100644 index 0000000..192572d --- /dev/null +++ b/doc/admin/api.md @@ -0,0 +1,390 @@ +Une API de type REST est disponible dans Paheko. + +Pour accéder à l'API il faut un identifiant et un mot de passe, à créer dans le menu ==Configuration==, onglet ==Fonctions avancées==, puis ==API==. + +L'API peut ensuite recevoir des requêtes REST sur l'URL `https://adresse_association/api/{chemin}/`. + +Remplacer =={chemin}== par un des chemins de l'API (voir ci-dessous). La méthode HTTP à utiliser est spécifiée pour chaque chemin. + +Pour les requêtes de type `POST`, les paramètres peuvent être envoyés par le client sous forme de formulaire HTTP classique (`application/x-www-form-urlencoded`) ou sous forme d'objet JSON. Dans ce cas le `Content-Type` doit être positionné sur `application/json`. + +Les réponses sont faites en JSON par défaut. + +<> + +# Utiliser l'API + +N'importe quel client HTTP capable de gérer TLS (HTTPS) et l'authentification basique fonctionnera. + +En ligne de commande il est possible d'utiliser `curl`. Exemple pour télécharger la base de données : + +``` +curl https://test:coucou@[identifiant_association].paheko.cloud/api/download -o association.sqlite +``` + +On peut aussi utiliser `wget` en n'oubliant pas l'option `--auth-no-challenge` sinon l'authentification ne fonctionnera pas : + +``` +wget https://test:coucou@[identifiant_association].paheko.cloud/api/download --auth-no-challenge -O association.sqlite +``` + +Exemple pour créer une écriture sous forme de formulaire : + +``` +curl -v "http://test:test@[identifiant_association].paheko.cloud/api/accounting/transaction" -F id_year=1 -F label=Test -F "date=01/02/2023" … +``` + +Ou sous forme d'objet JSON : + +``` +curl -v "http://test:test@[identifiant_association].paheko.cloud/api/accounting/transaction" -H 'Content-Type: application/json' -d '{"id_year":1, "label": "Test écriture", "date": "01/02/2023"}' +``` + + +# Authentification + +Il ne faut pas oublier de fournir le nom d'utilisateur et mot de passe en HTTP : + +``` +curl http://test:abcd@paheko.monasso.tld/api/download/ +``` + +# Erreurs + +En cas d'erreur un code HTTP 4XX sera fourni, et le contenu sera un objet JSON avec une clé `error` contenant le message d'erreur. + +# Chemins + +## sql (POST) + +Permet d'exécuter une requête SQL `SELECT` (uniquement, pas de requête UPDATE, DELETE, INSERT, etc.) sur la base de données. La requête SQL doit être passée dans le corps de la requête HTTP, ou dans le paramètre `sql`. Le résultat est retourné dans la clé `results` de l'objet JSON. + +S'il n'y a pas de limite à la requête, une limite à 1000 résultats sera ajoutée obligatoirement. + +``` +curl https://test:abcd@paheko.monasso.tld/api/sql/ -d 'SELECT * FROM membres LIMIT 5;' +``` + +**ATTENTION :** Les requêtes en écriture (`INSERT, DELETE, UPDATE, CREATE TABLE`, etc.) ne sont pas acceptées, il n'est pas possible de modifier la base de données directement via Paheko, afin d'éviter les soucis de données corrompues. + +Depuis la version 1.2.8, il est possible d'utiliser le paramètre `format` pour choisir le format renvoyé : + +* `json` (défaut) : renvoie un objet JSON, dont la clé est `"results"` et contient un tableau de la liste des membres trouvés +* `csv` : renvoie un fichier CSV +* `ods` : renvoie un tableau LibreOffice Calc (ODS) +* `xlsx` : renvoie un tableau Excel (XLSX) + +Exemple : + +``` +curl https://test:abcd@paheko.monasso.tld/api/sql/ -F sql='SELECT * FROM membres LIMIT 5;' -F format=csv +``` + +## Téléchargements + +### download (GET) + +Télécharger la base de données complète. Renvoie directement le fichier SQLite de la base de données. + +Exemple : + +``` +curl https://test:abcd@paheko.monasso.tld/api/download -o db.sqlite +``` + +### download/files (GET) + +*(Depuis la version 1.3.4)* + +Télécharger un fichier ZIP contenant tous les fichiers (documents, fichiers des écritures, des membres, modules modifiés, etc.). + +Exemple : + +``` +curl https://test:abcd@paheko.monasso.tld/api/download/files -o backup_files.zip +``` + +## Site web + +### web/list (GET) + +Renvoie la liste des pages du site web. + +### web/attachment/{PAGE_URI}/{FILENAME} (GET) + +Renvoie le fichier joint correspondant à la page et nom de fichier indiqués. + +### web/page/{PAGE_URI} (GET) + +Renvoie un objet JSON avec toutes les infos de la page donnée. + +Rajouter le paramètre `?html` à l'URL pour obtenir en plus une clé `html` dans l'objet JSON qui contiendra la page au format HTML. + +### web/html/{PAGE_URI} (GET) + +Renvoie uniquement le contenu de la page au format HTML. + +## Membres + +### user/import (PUT) + +Permet d'importer un fichier de tableur (CSV/XLSX/ODS) de la liste des membres, comme si c'était fait depuis l'interface de Paheko. + +Cette route nécessite une clé d'API ayant les droits d'administration, car importer un fichier peut permettre de modifier l'identifiant de connexion d'un administrateur et donc potentiellement d'obtenir l'accès à l'interface d'administration. + +Paheko s'attend à ce que la première est ligne du tableau contienne le nom des colonnes, et que le nom des colonnes correspond au nom des champs de la fiche membre (ou à leur nom unique). Par exemple si votre fiche membre contient les champs *Nom et prénom* et *Adresse postale*, alors le fichier fourni devra ressembler à ceci : + +| Nom et prénom | Adresse postale | +| :- | :- | +| Ada Lovelace | 42 rue du binaire, 21000 DIJON | + +Ou à ceci : + +| nom_prenom | adresse_postale | +| :- | :- | +| Ada Lovelace | 42 rue du binaire, 21000 DIJON | + +La méthode renvoie un code HTTP `200 OK` si l'import s'est bien passé, sinon un code 400 et un message d'erreur JSON dans le corps de la réponse. + +Utilisez la route `user/import/preview` avant pour vérifier que l'import correspond à ce que vous attendez. + +Exemple pour modifier le nom du membre n°42 : + +``` +echo 'numero,nom' > membres.csv +echo '42,"Nouveau nom"' >> membres.csv +curl https://test:abcd@monpaheko.tld/api/user/import -T membres.csv +``` + +#### Paramètres + +Les paramètres sont à spécifier dans l'URL, dans la query string. + +Depuis la version 1.2.8 il est possible d'utiliser un paramètre supplémentaire `mode` contenant une de ces options pour spécifier le mode d'import : + +* `auto` (défaut si le mode n'est pas spécifié) : met à jour la fiche d'un membre si son numéro existe, sinon crée un membre si le numéro de membre indiqué n'existe pas ou n'est pas renseigné +* `create` : ne fait que créer de nouvelles fiches de membre, si le numéro de membre existe déjà une erreur sera produite +* `update` : ne fait que mettre à jour les fiches de membre en utilisant le numéro de membre comme référence, si le numéro de membre n'existe pas une erreur sera produite + +Depuis la version 1.3.0 il est possible de spécifier : + +* le nombre de lignes à ignorer avec le paramètre `skip_lines=X` : elles ne seront pas importées. Par défaut la première ligne est ignorée. +* la correspondance des colonnes avec des paramètres `column[x]` ou `x` est le numéro de la colonne (la numérotation commence à zéro), et la valeur contient le nom unique du champ de la fiche membre. + +Exemple : + +``` +curl https://test:abcd@monpaheko.tld/api/user/import?mode=create&column[0]=nom_prenom&column[1]=code_postal&skip_lines=0 -T membres.csv +``` + +### user/import (POST) + +Identique à la même méthode en `PUT`, mais les paramètres sont passés dans le corps de la requête, avec le fichier, dont le nom sera alors `file`. + +``` +curl https://test:abcd@monpaheko.tld/api/user/import \ + -F mode=create \ + -F 'column[0]=nom_prenom' \ + -F 'column[1]=code_postal' \ + -F skip_lines=0 \ + -F file=@membres.csv +``` + +### user/import/preview (PUT) + +Identique à `user/import`, mais l'import n'est pas enregistré, et la route renvoie les modifications qui seraient effectuées en important le fichier : + +* `errors` : liste des erreurs d'import +* `created` : liste des membres ajoutés, chaque objet contenant tous les champs de la fiche membre qui serait créée +* `modified` : liste des membres modifiés, chaque membre aura une clé `id` et une clé `name`, ainsi qu'un objet `changed` contenant la liste des champs modifiés. Chaque champ modifié aura 2 propriétés `old` et `new`, contenant respectivement l'ancienne valeur du champ et la nouvelle. +* `unchanged` : liste des membres mentionnés dans l'import, mais qui ne seront pas affectés. Pour chaque membre une clé `name` et une clé `id` indiquant le nom et l'identifiant unique numérique du membre + +Note : si `errors` n'est pas vide, alors il sera impossible d'importer le fichier avec `user/import`. + +Exemple de retour : + +``` +{ + "created": [ + { + "numero": 3434351, + "nom": "Bla Bli Blu" + } + ], + "modified": [ + { + "id": 1, + "name": "Ada Lovelace", + "changed": { + "nom": { + "old": "Ada Lvelavce", + "new": "Ada Lovelace" + } + } + } + ], + "unchanged": [ + { + "id": 2, + "name": "Paul Muad'Dib" + } + ] +} +``` + + +### user/import/preview (POST) + +Idem quel la méthode en `PUT` mais accepte les paramètres dans le corps de la requête (voir ci-dessus). + +## Activités + +### services/subscriptions/import (PUT) + +_(Depuis Paheko 1.3.2)_ + +Permet d'importer les inscriptions des membres aux activités à partir d'un fichier CSV. Les activités et tarifs doivent déjà exister avant l'import. + +Les colonnes suivantes peuvent être utilisées : + +* Numéro de membre`**` +* Activité`**` +* Tarif +* Date d'inscription`**` +* Date d'expiration +* Montant à régler +* Payé ? + +Les colonnes suivies de deux astérisques (`**`) sont obligatoires. + +Exemple : + +``` +echo '"Numéro de membre","Activité","Tarif","Date d'inscription","Date d'expiration","Montant à régler","Payé ?"' > /tmp/inscriptions.csv +echo '42,"Cours de théâtre","Tarif adulte","01/09/2023","01/07/2023","123,50","Non"' >> /tmp/inscriptions.csv +curl https://test:abcd@monpaheko.tld/api/services/subscriptions/import -T /tmp/inscriptions.csv +``` + +## Erreurs + +Paheko dispose d'un système dédié à la gestion des erreurs internes, compatible avec les formats des logiciels AirBrake et errbit. + +### errors/report (POST) + +Permet d'envoyer un rapport d'erreur (au format airbrake/errbit/Paheko), comme si c'était une erreur locale. + +### errors/log (GET) + +Renvoie le log d'erreurs système, au format airbrake/errbit ([voir la doc AirBrake pour un exemple du format](https://airbrake.io/docs/api/#create-notice-v3)) + +## Comptabilité + +### accounting/years (GET) + +Renvoie la liste des exercices. + +### accounting/charts (GET) + +Renvoie la liste des plans comptables. + +### accounting/charts/{ID_CHART}/accounts (GET) + +Renvoie la liste des comptes pour le plan comptable indiqué (voir `id_chart` dans la liste des exercices). + +### accounting/years/{ID_YEAR}/journal (GET) + +Renvoie le journal général des écritures de l'exercice indiqué. + +Note : il est possible d'utiliser `current` comme paramètre pour `{ID_YEAR}` pour désigner l'exercice ouvert en cours. S'il y a plusieurs exercices ouverts, alors le plus ancien sera choisi. + +### accounting/years/{ID_YEAR}/account/journal (GET) + +Renvoie le journal des écritures d'un compte pour l'exercice indiqué. + +Le compte est spécifié soit via le paramètre `code`, soit via le paramètre `id`. Exemple : `/accounting/years/4/account/journal?code=512A` + +Note : il est possible d'utiliser `current` comme paramètre pour `{ID_YEAR}` pour désigner l'exercice ouvert en cours. S'il y a plusieurs exercices ouverts, alors le plus ancien sera choisi. + +### accounting/transaction/{ID_TRANSACTION} (GET) + +Renvoie les détails de l'écriture indiquée. + +### accounting/transaction/{ID_TRANSACTION} (POST) + +Modifie l'écriture indiquée. Voir plus bas le format attendu. + +### accounting/transaction/{ID_TRANSACTION}/users (GET) + +Renvoie la liste des membres liés à une écriture. + +### accounting/transaction/{ID_TRANSACTION}/users (POST) + +Met à jour la liste des membres liés à une écriture, en utilisant les ID de membres passés dans un tableau nommé `users`. + +``` + curl -v "http://…/api/accounting/transaction/9337/users" -F 'users[]=2' +``` + +### accounting/transaction/{ID_TRANSACTION}/users (DELETE) + +Efface la liste des membres liés à une écriture. + +### accounting/transaction/{ID_TRANSACTION}/subscriptions (GET) + +(Depuis la version 1.3.6) + +Renvoie la liste des inscriptions (aux activités) liées à une écriture. + +### accounting/transaction/{ID_TRANSACTION}/subscriptions (POST) + +(Depuis la version 1.3.6) + +Met à jour la liste des inscriptions liées à une écriture, en utilisant les ID d'inscriptions passés dans un tableau nommé `subscriptions`. + +``` + curl -v "http://…/api/accounting/transaction/9337/subscriptions" -F 'subscriptions[]=2' +``` + +### accounting/transaction/{ID_TRANSACTION}/subscriptions (DELETE) + +(Depuis la version 1.3.6) + +Efface la liste des inscriptions liées à une écriture. + +### accounting/transaction (POST) + +Crée une nouvelle écriture, renvoie les détails si l'écriture a été créée. Voir plus bas le format attendu. + +#### Structure pour créer / modifier une écriture + +Les champs à spécifier pour créer ou modifier une écriture sont les suivants : + +* `id_year` +* `date` (format YYYY-MM-DD) +* `type` peut être un type d'écriture simplifié (2 lignes) : `EXPENSE` (dépense), `REVENUE` (recette), `TRANSFER` (virement), `DEBT` (dette), `CREDIT` (créance), ou `ADVANCED` pour une écriture multi-ligne +* `amount` (uniquement pour les écritures simplifiées) : contient le montant de l'écriture +* `credit` (uniquement pour les écritures simplifiées) : contient le numéro du compte porté au crédit +* `debit` (uniquement pour les écritures simplifiées) : contient le numéro du compte porté au débit +* `lines` (pour les écritures multi-lignes) : un tableau dont chaque ligne doit contenir : + * `account` (numéro du compte) ou `id_account` (ID unique du compte) + * `credit` : montant à inscrire au crédit (doit être zéro ou non renseigné si `debit` est renseigné, et vice-versa) + * `debit` : montant à inscrire au débit + * `label` (facultatif) : libellé de la ligne + * `reference` (facultatif) : référence de la ligne (aussi appelé référence du paiement pour les écritures simplifiées) + * `id_project` : ID unique du projet à affecter + +Champs optionnels : + +* `reference` : numéro de pièce comptable +* `notes` : remarques (texte multi ligne) +* `id_project` : ID unique du projet à affecter (pour les écritures simplifiées uniquement) +* `payment_reference` (uniquement pour les écritures simplifiées) : référence de paiement +* `linked_users` : Tableau des IDs des membres à lier à l'écriture *(depuis 1.3.3)* +* `linked_transactions` : Tableau des IDs des écritures à lier à l'écriture *(depuis 1.3.5)* +* `linked_subscriptions` : Tableau des IDs des inscriptions à lier à l'écriture *(depuis 1.3.6)* + +Exemple : + +``` +curl -F 'id_year=12' -F 'label=Test' -F 'date=01/02/2022' -F 'type=EXPENSE' -F 'amount=42' -F 'debit=512A' -F 'credit=601' … +``` \ No newline at end of file diff --git a/doc/admin/brindille.md b/doc/admin/brindille.md new file mode 100644 index 0000000..142fbcc --- /dev/null +++ b/doc/admin/brindille.md @@ -0,0 +1,493 @@ +Title: Documentation du langage Brindille dans Paheko + +{{{.nav +* [Modules](modules.html) +* **[Documentation Brindille](brindille.html)** +* [Fonctions](brindille_functions.html) +* [Sections](brindille_sections.html) +* [Filtres](brindille_modifiers.html) +}}} + +<> + +# Introduction + +La syntaxe utilisée dans les squelettes du site web et des modules s'appelle **Brindille**. + +Si vous avez déjà fait de la programmation, elle ressemble à un mélange de Mustache, Smarty, Twig et PHP. + +Son but est de permettre une grande flexibilité, sans avoir à utiliser un "vrai" langage de programmation, mais en s'en rapprochant suffisamment quand même. + +## Fichiers + +Un fichier texte contenant du code Brindille est appelé un **squelette**. + +Seuls les fichiers ayant une des extensions `.tpl`, `.html`, `.htm`, `.skel` ou `.xml` seront traités par Brindille. +De même, les fichiers qui n'ont pas d'extension seront également traités par Brindille. + +Les autres types de fichiers seront renvoyés sans traitement, comme des fichiers "bruts". En d'autres termes, il n'est pas possible de mettre du code *Brindille* dans des fichiers qui ne sont pas des fichiers textes. + +# Syntaxe de base + +## Variables + +En programmation, une variable est une référence vers une donnée stockée en mémoire. + +Dans Brindille, une variable commence par le symbole dollar `$` et suivi d'un nom. + +Le nom est composé de lettres minuscules (sans accents), de chiffres et de tirets bas (`[a-z0-9_]`). + +Exemples de variables : + +``` +$config +$compte_32 +$nom_de_variable_long +``` + +### Types de variables + +Dans Brindille, une variable peut avoir un des types suivants : + +* `null` : utilisé pour une variable qui n'est pas définie, ou une variable définie qui n'a pas de valeur +* `boolean` : valeur booléenne, peut seulement avoir `true` ou `false` comme valeur +* `integer` : nombre entier (sans virgule). Exemple : `4200`. +* `float` : nombre à virgule flottante, exemple `3.14`. Leur usage est déconseillé, car les erreurs de calcul sont possibles, les ordinateurs ne sachant pas compter de manière précise avec les nombres à virgule flottante. Exemple : `0.2+0.3` est différent de `0.5`. +* `string` : chaîne de texte (aussi appelé chaîne de caractères, car c'est une suite de caractères). Exemple : `coucou`. +* `array` : tableau. + +### Tableaux + +Les tableaux sont une sorte de dictionnaire, ou pour chaque entrée (appelée "clé") on peut associer une valeur. qui peut être de n'importe lequel des types listés ci-dessus, y compris un autre tableau. + +Les tableaux peuvent être de deux types : + +1. tableau indexé (liste) : dans ce cas les clés ne sont pas choisies, on ajoute simplement des valeurs, et l'ordinateur incrémente le numéro de la clé à chaque nouvelle entrée dans le tableau. La numérotation commence au chiffre zéro. +2. tableau associatif (dictionnaire) : les clés sont des nombres ou des chaînes de texte, et permettent, comme dans un dictionnaire, de choisir la clé. + +Exemple de tableau indexé : + +``` +[ + 0 => 'Texte 1', + 1 => 'Texte 2' +] +``` + +Exemple de tableau associatif : + +``` +[ + 'un' => 'Texte 1', + 'deux' => 'Texte 2' +] +``` + +## Affichage de variable + +Une variable est affichée à l'aide de la syntaxe : `{{$date}}` affichera la valeur brute de la date par exemple : `2020-01-31 16:32:00`. + +La variable peut être modifiée à l'aide de filtres de modification, qui sont ajoutés avec le symbole de la barre verticale (pipe `|`) : `{{$date|date_long}}` affichera une date au format long : `jeudi 7 mars 2021`. + +Ces filtres peuvent accepter des paramètres, séparés par deux points `:`. Exemple : `{{$date|date:"d/m/Y"}}` affichera `31/01/2020`. + +Par défaut la variable sera recherchée dans le contexte actuel de la section, si elle n'est pas trouvée elle sera recherchée dans le contexte parent (section parente), etc. jusqu'à trouver la variable. + +### Protection contre le HTML (échappement) + +Par défaut le filtre `escape` est appliqué à toutes les variables affichées, pour protéger les variables contre les injections de code HTML. + +Ce filtre modifie (on dit qu'il "échappe") les caractères HTML `<>&"'` présents dans une chaîne de texte en entités HTML, évitant que le HTML éventuellement présent dans la chaîne de texte soit interprété par le navigateur. + +Ce filtre est appliqué en dernier, après les autres filtres. Il est possible de contourner cet automatisme en rajoutant le filtre `escape` ou `raw` explicitement. `raw` désactive tout échappement, alors que `escape` est utilisé pour changer l'ordre d'échappement. Exemple : + +``` +{{:assign text = "Coucou\nça va ?" }} +{{$text|escape|nl2br}} +``` + +Donnera bien `Coucou
ça va ?`. Si on n'avait pas indiqué le filtre `escape` le résultat serait `Coucou<br />ça va ?`. + +### Variables de tableaux + +Il est possible de faire référence à une clé d'un tableau avec la notation à points : `{{$article.date}}` renverra la clé `date` du tableau stocké dans la variable `$article`. + +### Échappement des caractères spéciaux dans les chaînes de caractère + +Pour inclure un caractère spécial (retour de ligne, guillemets ou apostrophe) dans une chaîne de caractère il suffit d'utiliser un antislash : + +``` +{{:assign text="Retour \n à la ligne"}} +{{:assign text="Utiliser des \"apostrophes\"}} +``` + +## Ordre de recherche des variables + +Par défaut les variables sont recherchées dans l'ordre inverse, c'est à dire que sont d'abord recherchées les variables avec le nom demandé dans la section courante. Si la variable n'existe pas dans la section courante, alors elle est recherchée dans la section parente, et ainsi de suite jusqu'à ce que la variable soit trouvée, où qu'il n'y ait plus de section parente. + +Prenons cet exemple : + +``` +{{#articles uri="Actualite"}} +

{{$title}}

+ {{#images parent=$path limit=1}} + {{$title}} + {{/images}} +{{/articles}} +``` + +Dans la section `articles`, `$title` est une variable de l'article, donc la variable est celle de l'article. + +Dans la section `images`, les images n'ayant pas de titre, la variable sera celle de l'article de la section parente, alors que `$thumb_url` sera lié à l'image. + +## Conflit de noms de variables + +Imaginons que nous voulions mettre un lien vers l'article sur l'image de l'exemple précédent : + +``` +{{#articles uri="Actualite"}} +

{{$title}}

+ {{#images parent=$path limit=1}} + + {{/images}} +{{/articles}} +``` + +Problème, ici `$url` fera référence à l'URL de l'image elle-même, et non pas l'URL de l'article. + +La solution est d'ajouter un point au début du nom de variable : `{{$.url}}`. + +Un point au début d'un nom de variable signifie que la variable est recherchée à partir de la section précédente. Il est possible d'utiliser plusieurs points, chaque point correspond à un niveau à remonter. Ainsi `$.url` cherchera la variable dans la section parente (et ses sections parentes si elle n'existe pas, etc.). De même, `$..url` cherchera dans la section parente de la section parente. + +## Création manuelle de variable + +### Variable simple + +La création d'une variable se fait via l'appel de la fonction `{{:assign}}`. + +Exemple : + +``` +{{:assign source='wiki'}} +{{* est identique à : *}} +{{:assign var='source' value='wiki'}} +``` + +Un deuxième appel à `{{:assign}}` avec le même nom de variable écrase la valeur précédente + +``` +{{:assign var='source' value='wiki'}} +{{:assign var='source' value='documentation'}} + +{{$source}} +{{* => Affiche documentation *}} +``` + +### Nom de variable dynamique + +Il est possible de créer une variable dont une partie du nom est dynamique. + +``` +{{:assign type='user'}} +{{:assign var='allowed_%s'|args:$type value='jeanne'}} +{{:assign type='side'}} +{{:assign var='allowed_%s'|args:$type value='admin'}} + +{{$allowed_user}} => jeanne +{{$allowed_side}} => admin +``` + +[Documentation complète de la fonction {{:assign}}](brindille_functions.html#assign). + +### Tableaux *(array)* + +Pour créer des tableaux, il suffit d'utiliser des points `.` dans le nom de la variable (ex : `colors.yellow`). Il n'y a pas besoin d'initialiser le tableau avant de le remplir. + +``` +{{:assign var='colors.admin' value='blue'}} +{{:assign var='colors.website' value='grey'}} +{{:assign var='colors.debug' value='yellow'}} +``` + +On accède ensuite à la valeur d'un élément du tableau avec la même syntaxe : `{{$colors.website}}` + +Méthode rapide de création du même tableau : + +``` +{{:assign var='colors' admin='blue' website='grey' debug='yellow'}} +``` + +Pour ajouter un élément à la suite du tableau sans spécifier de clef *(push)*, il suffit de terminer le nom de la variable par un point `.` sans suffixe. + +Exemple : + +``` +{{* Ajouter les valeurs 17, 43 et 214 dans $processed_ids *}} + +{{:assign var='processed_ids.' value=17}} +{{:assign var='processed_ids.' value=43}} +{{:assign var='processed_ids.' value=214}} +``` + +Donnera le tableau suivant : + +``` +[ + 0 => 17, + 1 => 43, + 2 => 214 +] +``` + +#### Clef dynamique de tableau + +Il est possible d'accéder dynamiquement à un des éléments d'un tableau de la manière suivante : + +``` +{{:assign location='admin'}} +{{:assign var='location_color' from='colors.%s'|args:$location}} + +{{$location_color}} => blue +``` + +Exemple plus complexe : + +``` +{{:assign var='type_whitelist.text' value=1}} +{{:assign var='type_whitelist.html' value=1}} + +{{#foreach from=$documents item='document'}} + {{:assign var='allowed' value='type_whitelist.%s'|args:$document->type}} + {{if $allowed !== null}} + {{:include file='document/'|cat:$type:'.tpl' keep='document'}} + {{/if}} +{{/foreach}} +``` + +Il est également possible de créer un membre dynamique d'un tableau en conjuguant les syntaxes précédentes. + +Exemple : + +``` +{{:assign var='type_whitelist.%s'|args:$type value=1}} +``` + +## Conditions + +Il est possible d'utiliser des conditions de type **"si"** (`if`), **"sinon si"** (`elseif`) et **"sinon"** (`else`). Celles-ci sont terminées par un block **"fin si"** (`/if`). + +``` +{{if $date|date:"%Y" > 2020}} + La date est en 2020 +{{elseif $article.status == 'draft'}} + La page est un brouillon +{{else}} + Autre chose. +{{/if}} +``` + +Une condition peut être évaluée comme *vraie*, dans ce cas la partie qui suit le bloc `{{if …}}` sera exécutée. + +Si la condition est évaluée comme *fausse*, alors la partie qui suit le bloc `{{if …}}` ne sera pas exécutée. Dans ce cas, soit une condition `{{elseif …}}` suivante est vraie et exécutée, mais sinon c'est le contenu du bloc `{{else}}` qui est exécuté et affiché. + +Dans une condition on peut utiliser : + +* une variable : `$nom_variable` +* une variable avec des filtres : `$nom_variable|filtre1:parametre1` +* une valeur (nombre, constante) : `42`, `-42`, `42.02` `null`, `true`, `false` +* des opérateurs de comparaison +* des opérateurs logiques + +Attention : on ne peut pas grouper les conditions avec des parenthèses. + +Les comparaisons supportées sont les suivantes : + +| Opérateur de comparaison | Explication | +| :- | :- | +| `==` | égalité faible, ne prend pas en compte le type : `1 == true` et `2 == 2.00` serons évalués comme vrais | +| `===` | égalité forte, prend en compte le type : `1 === 1` sera vrai, mais `1 === true` sera faux | +| `!=` | différent de, en comparaison faible | +| `!==` | différent de, en comparaison forte | +| `>` | supérieur à | +| `>=` | supérieur ou égal à | +| `<` | inférieur à | +| `<=` | inférieur ou égal à | + +Il est aussi possible de précéder une variable de l'opérateur `!`, c'est un raccourci pour `$variable == false`. + +Voir [les opérateurs de comparaison PHP pour plus de détails](https://www.php.net/manual/fr/language.operators.comparison.php). + +Les opérateurs logiques supportés sont : + +| Opérateur | Explication | +| :- | :- | +| `&&` | Vrai si les conditions à gauche et à droite sont vraies | +| `||` | Vrai si une des conditions à gauche ou à droite est vraie | + +Exemples : + +* `false && true` : sera évalué comme faux +* `false || true` : sera évalué comme vrai + + +### Tester si une variable existe + +Brindille ne fait pas de différences entre une variable qui n'existe pas, et une variable définie à `null`. +On peut donc tester l'existence d'une variable en la comparant à `null` comme ceci : + +``` +{{if $session !== null}} + Session en cours pour l'utilisateur/trice {{$session.user.name}}. +{{else}} + Session inexistante. +{{/if}} +``` + +## Fonctions + +### Fonctions natives + +Une fonction va répondre à certains paramètres et renvoyer un résultat ou réaliser une action. + +**Un bloc de fonction commence par le signe deux points `:`.** + +``` +{{:http code=404}} +``` + +Contrairement aux autres types de blocs, et comme pour les variables, il n'y a pas de bloc fermant (avec un slash `/`). + +## Sections + +Une section est une partie de la page qui sera répétée une fois, plusieurs fois, ou zéro fois, selon ses paramètres et le résultat (c'est une "boucle"). Une section commence par un bloc avec un signe hash (`#`) et se termine par un bloc avec un slash (`/`). + +Un exemple simple avec une section qui n'aura qu'une seule répétition : + +``` +{{#categories uri=$_GET.uri}} +

{{$title}}

+{{/categories}} +``` + +Il est possible d'utiliser une condition `{{else}}` avant la fin du bloc pour avoir du contenu alternatif si la section ne se répète pas (dans ce cas si aucune catégorie ne correspond au critère). + +Un exemple de sous-section + +``` +{{#categories uri=$_GET.uri}} +

{{$title}}

+ + {{#articles parent=$path order="published DESC" limit="10"}} +

+

{{$content|truncate:600:"..."}}

+ {{else}} +

Aucun article trouvé.

+ {{/articles}} + +{{/categories}} +``` + +Voir la référence des sections pour voir quelles sont les sections possibles et quel est leur comportement. + +## Bloc litéral + +Pour qu'une partie du code ne soit pas interprété, pour éviter les conflits avec certaines syntaxes, il est possible d'utiliser un bloc `literal` : + +``` +{{literal}} + +{{/literal}} +``` + + +## Commentaires + +Les commentaires sont figurés dans des blocs qui commencent et se terminent par une étoile (`*`) : + +``` +{{* Ceci est un commentaire +Il sera supprimé du résultat final +Il peut contenir du code qui ne sera pas interprété : +{{if $test}} +OK +{{/if}} +*}} +``` + + +# Liste des variables définies par défaut + +Ces variables sont définies tout le temps : + +| Nom de la variable | Valeur | +| :- | :- | +| `$_GET` | Tableau contenant tous les paramètres passés dans la chaîne de requêtre de l'URL. | +| `$_POST` | Tableau de tous les éléments de formulaire envoyés lors d'une requête POST. | +| `$root_url` | Adresse racine du site web Paheko. | +| `$request_url` | Adresse de la page courante. | +| `$admin_url` | Adresse de la racine de l'administration Paheko. | +| `$visitor_lang` | Langue préférée du visiteur, sur 2 lettres (exemple : `fr`, `en`, etc.). | +| `$logged_user` | Informations sur le membre actuellement connecté dans l'administration (vide si non connecté). | +| `$dialog` | Vaut `TRUE` si la page est dans un dialogue (iframe sous forme de pop-in dans l'administration). | +| `$now` | Contient la date et heure courante. | +| `$config.org_name` | Nom de l'association | +| `$config.org_email` | Adresse e-mail de l'association | +| `$config.org_phone` | Numéro de téléphone de l'association | +| `$config.org_address` | Adresse postale de l'association | +| `$config.org_web` | Adresse du site web de l'association | +| `$config.files.logo` | Adresse du logo de l'association, si définit dans la personnalisation | +| `$config.files.favicon` | Adresse de l'icône de favoris de l'association, si défini dans la personnalisation | +| `$config.files.signature` | Adresse de l'image de signature, si défini dans la personnalisation | + +À celles-ci s'ajoutent [les variables spéciales des modules](modules.html#variables_speciales) lorsque le script est chargé dans un module. + +# Erreurs + +Si une erreur survient dans un squelette, que ça soit au niveau d'une erreur de syntaxe, ou une erreur dans une fonction, filtre ou section, alors elle sera affichée selon les règles suivantes : + +* si le membre connecté est administrateur, une erreur est affichée avec le code du squelette ; +* sinon l'erreur est affichée sans le code. + + +# Avertissement sur la sécurité des requêtes SQL + +Attention, en utilisant la section `{{#select ...}}`, ou une des sections SQL (voir plus bas), avec des paramètres qui ne seraient pas protégés, il est possible qu'une personne mal intentionnée ait accès à des parties de la base de données à laquelle vous ne désirez pas donner accès. + +Pour protéger contre cela il est essentiel d'utiliser les paramètres nommés. + +Exemple de requête dangereuse : + +``` +{{#sql select="*" tables="users" where="id = %s"|args:$_GET.id}} +... +{{/sql}} +``` + +On se dit que la requête finale sera donc : `SELECT * FROM users WHERE id = 42;` si le numéro 42 est passé dans le paramètre `id` de la page. + +Imaginons qu'une personne mal-intentionnée indique dans le paramètre `id` de la page la chaîne de caractère suivante : `0 OR 1`. Dans ce cas la requête exécutée sera `SELECT * FROM users WHERE id = 0 OR 1;`. Cela aura pour effet de lister tous les membres, au lieu d'un seul. + +Pour protéger contre cela il convient d'utiliser un paramètre nommé : + +``` +{{#sql select="*" tables="users" where="id = :id" :id=$_GET.id}} +``` + +Dans ce cas la requête malveillante générée sera `SELECT * FROM users WHERE id = '0 OR 1';`. Ce qui aura pour effet de ne lister aucun membre. + +## Mesures prises pour la sécurité des données + +Dans Brindille, il n'est pas possible de modifier ou supprimer des éléments dans la base de données avec les requêtes SQL directement. Seules les requêtes SQL en lecture (`SELECT`) sont permises. + +Cependant certaines fonctions permettent de modifier ou créer des éléments précis (écritures par exemple), ce qui peut avoir un effet de remplir ou modifier des données par une personne mal-intentionnée, donc attention à leur utilisation. + +Les autres mesures prises sont : + +* impossibilité d'accéder à certaines données sensibles (mot de passe, logs de connexion, etc.) +* incitation forte à utiliser les paramètres nommés dans la documentation +* protection automatique des variables dans la section `{{#select}}` +* fourniture de fonctions pour protéger les chaînes de caractères contre l'injection SQL \ No newline at end of file diff --git a/doc/admin/brindille_functions.md b/doc/admin/brindille_functions.md new file mode 100644 index 0000000..19bcaca --- /dev/null +++ b/doc/admin/brindille_functions.md @@ -0,0 +1,828 @@ +Title: Référence des fonctions Brindille + +{{{.nav +* [Modules](modules.html) +* [Documentation Brindille](brindille.html) +* **[Fonctions](brindille_functions.html)** +* [Sections](brindille_sections.html) +* [Filtres](brindille_modifiers.html) +}}} + +<> + +# Fonctions généralistes + +## assign + +Permet d'assigner une valeur dans une variable. + +| Paramètre | Optionnel / obligatoire ? | Fonction | +| :- | :- | :- | +| `.` | optionnel | Assigner toutes les variables du contexte (section) actuel | +| `var` | optionnel | Nom de la variable à créer ou modifier | +| `value` | optionnel | Valeur de la variable | +| `from` | optionnel | Recopier la valeur depuis la variable ayant le nom fourni dans ce paramètre. | + +Tous les autres paramètres sont considérés comme des variables à assigner. + +Exemple : + +``` +{{:assign blabla="Coucou"}} + +{{$blabla}} +``` + +Il est possible d'assigner toutes les variables d'une section dans une variable en utilisant le paramètre point `.` (`.="nom_de_variable"`). Cela permet de capturer le contenu d'une section pour le réutiliser à un autre endroit. + +``` +{{#pages uri="Informations" limit=1}} +{{:assign .="infos"}} +{{/pages}} + +{{$infos.title}} +``` + +Il est aussi possible de remonter dans les sections parentes en utilisant plusieurs points. Ainsi deux points remonteront à la section parente, trois points à la section parente de la section parente, etc. + +``` +{{#foreach from=$infos item="info"}} + {{#foreach from=$info item="sous_info"}} + {{if $sous_info.titre == 'Coucou'}} + {{:assign ..="info_importante"}} + {{/if}} + {{/foreach}} +{{/foreach}} + +{{$info_importante.titre}} +``` + +En utilisant le paramètre spécial `var`, tous les autres paramètres passés sont ajoutés à la variable donnée en valeur : + +``` +{{:assign var="tableau" label="Coucou" name="Pif le chien"}} +{{$tableau.label}} +{{$tableau.name}} +``` + +De la même manière on peut écraser une variable avec le paramètre spécial `value`: + +``` +{{:assign var="tableau" value=$infos}} +``` + +Il est également possible de créer des tableaux avec la syntaxe `.` dans le nom de la variable : + +``` +{{:assign var="liste.comptes.530" label="Caisse"}} +{{:assign var="liste.comptes.512" label="Banque"}} + +{{#foreach from=$liste.comptes}} +{{$key}} = {{$value.label}} +{{/foreach}} +``` + +Il est possible de rajouter des éléments à un tableau simplement en utilisant un point seul : + +``` +{{:assign var="liste.comptes." label="530 - Caisse"}} +{{:assign var="liste.comptes." label="512 - Banque"}} +``` + +Enfin, il est possible de faire référence à une variable de manière dynamique en utilisant le paramètre spécial `from` : + +``` +{{:assign var="tableau" a="Coucou" b="Test !"}} +{{:assign var="titre" from="tableau.%s"|args:"b"}} +{{$titre}} -> Affichera "Test !", soit la valeur de {{$tableau.b}} +``` + +## break + +Interrompt une section. + +## continue + +Passe à l'itération suivante d'une section. Le code situé entre cette instruction et la fin de la section ne sera pas exécuté. + +``` +{{#foreach from=$list item="event"}} + {{if $event.date == '2023-01-01'}} + {{:continue}} + {{/if}} + {{$event.title}} +{{/foreach}} +``` + +Il est possible de passer à l'itération suivante d'une section parente en utilisant un chiffre en paramètre : + +``` +{{#foreach from=$list item="event"}} + {{$event.title}} + {{#foreach from=$event.people item="person"}} + {{if $person.name == 'bohwaz'}} + {{:continue 2}} + {{/if}} + - {{$person.name}} + {{/foreach}} +{{/foreach}} +``` + +## debug + +Cette fonction permet d'afficher le contenu d'une ou plusieurs variables : + +``` +{{:debug test=$title}} +``` + +Affichera : + +``` +array(1) { + ["test"] => string(6) "coucou" +} +``` + +Si aucun paramètre n'est spécifié, alors toutes les variables définies sont renvoyées. Utile pour découvrir quelles sont les variables accessibles dans une section par exemple. + + +## error + +Affiche un message d'erreur et arrête le traitement à cet endroit. + +| Paramètre | Optionnel / obligatoire ? | Fonction | +| :- | :- | :- | +| `message` | **obligatoire** | Message d'erreur à afficher | + +Exemple : + +``` +{{if $_POST.nombre != 42}} + {{:error message="Le nombre indiqué n'est pas 42"}} +{{/if}} +``` + +## form_errors + +Affiche les erreurs du formulaire courant (au format HTML). + +## http + +Permet de modifier les entêtes HTTP renvoyés par la page. Cette fonction doit être appelée au tout début du squelette, avant tout autre code ou ligne vide. + +| Paramètre | Optionnel / obligatoire ? | Fonction | +| :- | :- | :- | +| `code` | *optionnel* | Modifie le code HTTP renvoyé. [Liste des codes HTTP](https://fr.wikipedia.org/wiki/Liste_des_codes_HTTP) | +| `redirect` | *optionnel* | Rediriger vers l'adresse URL indiquée en valeur. | +| `type` | *optionnel* | Modifie le type MIME renvoyé | +| `download` | *optionnel* | Force la page à être téléchargée sous le nom indiqué. | +| `inline` | *optionnel* | Force la page à être affichée, et peut ensuite être téléchargée sous le nom indiqué (utile pour la généraion de PDF : permet d'afficher le PDF dans le navigateur avant de le télécharger). | + +Note : si le type `application/pdf` est indiqué (ou juste `pdf`), la page sera convertie en PDF à la volée. Il est possible de forcer le téléchargement du fichier en utilisant le paramètre `download`. + +Exemples : + +``` +{{:http code=404}} +{{:http redirect="/Nos-Activites/"}} +{{:http redirect="https://mon-site-web.tld/"}} +{{:http type="application/svg+xml"}} +{{:http type="pdf" download="liste_membres_ca.pdf"}} +``` + +## include + +Permet d'inclure un autre squelette. + +Paramètres : + +| Paramètre | Optionnel / obligatoire ? | Fonction | +| :- | :- | :- | +| `file` | **obligatoire** | Nom du squelette à inclure | +| `keep` | *optionnel* | Liste de noms de variables à conserver | +| `capture` | *optionnel* | Si renseigné, au lieu d'afficher le squelette, son contenu sera enregistré dans la variable de ce nom. | +| … | *optionnel* | Tout autre paramètre sera utilisé comme variable qui n'existea qu'à l'intérieur du squelette inclus. | + +``` +{{* Affiche le contenu du squelette "navigation.html" dans le même répertoire que le squelette d'origine *}} +{{:include file="./navigation.html"}} +``` + +Par défaut, les variables du squelette parent sont transmis au squelette inclus, mais les variables définies dans le squelette inclus ne sont pas transmises au squelette parent. Exemple : + +``` +{{* Squelette page.html *}} +{{:assign title="Super titre !"}} +{{:include file="./_head.html"}} +{{$nav}} +``` +``` +{{* Squelette _head.html *}} +

{{$title}}

+{{:assign nav="Accueil > %s"|args:$title}} +``` + +Dans ce cas, la dernière ligne du premier squelette (`{{$nav}}`) n'affichera rien, car la variable définie dans le second squelette n'en sortira pas. Pour indiquer qu'une variable doit être transmise au squelette parent, il faut utiliser le paramètre `keep`: + +``` +{{:include file="./_head.html" keep="nav"}} +``` + +On peut spécifier plusieurs noms de variables, séparés par des virgules, et utiliser la notation à points : + +``` +{{:include file="./_head.html" keep="nav,article.title,name"}} +{{$nav}} +{{$article.title}} +{{$name}} +``` + +On peut aussi capturer le résultat d'un squelette dans une variable : + +``` +{{:include file="./_test.html" capture="test"}} +{{:assign var="test" value=$test|replace:'TITRE':'Ceci est un titre'}} +{{$test}} +``` + +Il est possible d'assigner de nouvelles variables au contexte du include en les déclarant comme paramètres tout comme on le ferait avec `{{:assign}}` : + +``` +{{:include file="./_head.html" title='%s documentation'|args:$doc.label visitor=$user}} +``` + +## captcha + +Permet de générer une question qui doit être répondue correctement par l'utilisateur pour valider une action. Utile pour empêcher les robots spammeurs d'effectuer une action. + +L'utilisation simplifiée utilise un de ces deux paramètres : + +| Paramètre | Fonction | +| :- | :- | +| `html` | Si `true`, crée un élément de formulaire HTML et le texte demandant à l'utilisateur de répondre à la question | +| `verify` | Si `true`, vérifie que l'utilisateur a correctement répondu à la question | + +L'utilisation avancée utilise d'abord ces deux paramètres : + +| Paramètre | Fonction | +| :- | :- | +| `assign_hash` | Nom de la variable où assigner le hash (à mettre dans un ``) | +| `assign_number` | Nom de la variable où assigner le nombre de la question (à afficher à l'utilisateur) | + +Puis on vérifie : + +| Paramètre | Fonction | +| :- | :- | +| `verify_hash` | Valeur qui servira comme hash de vérification (valeur du ``) | +| `verify_number` | Valeur qui représente la réponse de l'utilisateur | +| `assign_error` | Si spécifié, le message d'erreur sera placé dans cette variable, sinon il sera affiché directement. | + +Exemple : + +``` +{{if $_POST.send}} + {{:captcha verify_hash=$_POST.h verify_number=$_POST.n assign_error="error"}} + {{if $error}} +

Mauvaise réponse

+ {{else}} + ... + {{/if}} +{{/if}} + +
+{{:captcha assign_hash="hash" assign_number="number"}} +

Merci de recopier le nombre suivant en chiffres : {{$number}}

+

+ + + +

+
+``` + +## mail + +Permet d'envoyer un e-mail à une ou des adresses indiquées (sous forme de tableau). + +Restrictions : + +* le message est toujours envoyé en format texte ; +* l'expéditeur est toujours l'adresse de l'association ; +* l'envoi est limité à une seule adresse e-mail externe (adresse qui n'est pas celle d'un membre) dans une page ; +* l'envoi est limité à maximum 10 adresses e-mails internes (adresses de membres) dans une page ; +* un message envoyé à une adresse e-mail externe ne peut pas contenir une adresse web (`https://...`) autre que celle de l'association. + +Note : il est également conseillé d'utiliser la fonction `captcha` pour empêcher l'envoi de spam. + +| Paramètre | Obligatoire ou optionnel ? | Fonction | +| :- | :- | :- | +| `to` | **obligatoire** | Adresse email destinataire (seule l'adresse e-mail elle-même est acceptée, pas de nom) | +| `subject` | **obligatoire** | Sujet du message | +| `body` | **obligatoire** | Corps du message | +| `block_urls` | *optionnel* | (`true` ou `false`) Permet de bloquer l'envoi si le message contient une adresse `https://…` | +| `attach_file` | *optionnel* | Chemin vers un ou plusieurs documents à joindre au message (situé dans les documents) | +| `attach_from` | *optionnel* | Chemin vers un ou plusieurs squelettes à joindre au message (par exemple pour joindre un document généré) | + +Pour le destinataire, il est possible de spécifier un tableau : + +``` +{{:assign var="recipients[]" value="membre1@framasoft.net"}} +{{:assign var="recipients[]" value="membre2@chatons.org"}} +{{:mail to=$recipients subject="Coucou" body="Contenu du message\nNouvelle ligne"}} +``` + +Exemple de formulaire de contact : + +``` +{{if !$_POST.email|check_email}} +

L'adresse e-mail indiquée est invalide.

+{{elseif $_POST.message|trim == ''}} +

Le message est vide

+{{elseif $_POST.send}} + {{:captcha verify=true}} + {{:mail to=$config.org_email subject="Formulaire de contact" body="%s a écrit :\n\n%s"|args:$_POST.email:$_POST.message block_urls=true}} +

Votre message nous a bien été transmis !

+{{/if}} + +
+
+
+
+
{{:captcha html=true}}
+
+

+
+``` + +## redirect + +Redirige vers une nouvelle page. + +Avec le paramètre `force`, si la page actuelle est ouverte dans une fenêtre modale (grâce à la cible `_dialog`), alors la fenêtre modale est fermée, et la redirection se passe dans la page parente. + +Avec le paramètre `to`, si la page actuelle est ouverte dans une fenêtre modal (grâce à la cible `_dialog`), alors la fenêtre modale est fermée, et la page parente est rechargée. Si la page n'est pas ouvertre dans dans une fenêtre modale, la redirection est effectuée. + +Seules les adresses internes sont acceptées, il n'est pas possible de rediriger vers une adresse extérieure. + +| Paramètre | Obligatoire ou optionnel ? | Fonction | +| :- | :- | :- | +| `force` | optionnel | Adresse de redirection forcée | +| `to` | optionnel | Adresse de redirection si pas dans une fenêtre modale | + +Si `to=null` est utilisé, alors la fenêtre modale sera fermée. Ou, si la page n'est pas dans une fenêtre modale, la page courante sera rechargée. + +## api + +Permet d'appeler l'API de Paheko, que ça soit sur l'instance locale, en cours, ou une autre instance externe. + +Voir la [documentation de l'API](https://paheko.cloud/api) pour la liste des fonctions disponibles. + +| Paramètre | Obligatoire ou optionnel ? | Fonction | +| :- | :- | :- | +| `method` | obligatoire | Méthode de requête : `GET`, `POST`, etc. | +| `path` | obligatoire | Chemin de la méthode de l'API à appeler. | +| `fail` | optionnel | Booléen. Si `true`, alors une erreur sera affichée si la requête échoue. Si `false`, aucune erreur ne sera affichée. Défaut : `true`. | +| `assign` | optionnel | Capturer le résultat dans cette variable. | +| `assign_code` | optionnel | Capturer le code de retour dans cette variable. | + +Par défaut, les requêtes sont réalisées sur la base de données locale, dans ce cas les paramètres suivants sont également disponibles : + +| Paramètre | Obligatoire ou optionnel ? | Fonction | +| :- | :- | :- | +| `access` | optionnel | Niveau d'autorisation de l'API (défaut : `admin`). | + + +``` +{{:assign var="users." value=42}} +{{:api + method="POST" + path="accounting/transaction" + assign="result" + + id_year=1 + type="revenue" + date="01/01/2023" + label="Don de Ada Lovelace" + reference="DON-0001" + payment_reference="Credit Mutuel 00042" + amount="51,49" + debit="756" + credit="512A" + linked_users=$users +}} + +L'écriture n°{{$result.id}} a été créée. +``` + +Mais cette fonction permet également d'appeler une API Paheko distante, dans ce cas les paramètres suivants sont nécessaires : + +| Paramètre | Obligatoire ou optionnel ? | Fonction | +| :- | :- | :- | +| `url` | obligatoire | Adresse HTTP de l'instance Paheko distante. | +| `user` | obligatoire | Identifiant d'accès à l'API distante. | +| `password` | obligatoire | Mot de passe d'accès à l'API distante. | + +``` +{{:api + method="POST" + path="sql" + sql="SELECT * FROM users;" + url="https://mon-asso.paheko.cloud/" + user="zmgyfr1qnm" + password="OAqFTLFzujJWr6lLn1Mu7w" + assign="result" + assign_code="code" + fail=false +}} + +{{if $code == 200}} + Il y a {{$result.count}} résultats. +{{else}} + La requête a échoué : code {{$code}} — {{$result.error}} +{{/if}} + +``` + +# Fonctions relatives aux Modules + +## save + +Enregistre des données, sous la forme d'un document, dans la base de données, pour le module courant. + +| Paramètre | Obligatoire ou optionnel ? | Fonction | +| :- | :- | :- | +| `key` | optionnel | Clé unique du document | +| `id` | optionnel | Numéro unique du document | +| `validate_schema` | optionnel | Fichier de schéma JSON à utiliser pour valider les données avant enregistrement | +| `validate_only` | optionnel | Liste des paramètres à valider (par exemple pour ne faire qu'une mise à jour partielle), séparés par des virgules. | +| `assign_new_id` | optionnel | Si renseigné, le nouveau numéro unique du document sera indiqué dans cette variable. | +| … | optionnel | Autres paramètres : traités comme des valeurs à enregistrer dans le document | + +Si ni `key` ni `id` ne sont indiqués, un nouveau document sera créé avec un nouveau numéro (ID) unique. + +Si le document indiqué existe déjà, il sera mis à jour. Les valeurs nulles (`NULL`) seront effacées. + +``` +{{:save key="facture_43" nom="Atelier mobile" montant=250}} +``` + +Enregistrera dans la base de données le document suivant sous la clé `facture_43` : + +``` +{"nom": "Atelier mobile", "montant": 250} +``` + +Exemple de mise à jour : + +``` +{{:save key="facture_43" montant=300}} +``` + +Exemple de récupération du nouvel ID : + +``` +{{:save titre="Coucou !" assign_new_id="id"}} +Le document n°{{$id}} a bien été enregistré. +``` + +### Validation avec un schéma JSON + +``` +{{:save titre="Coucou" texte="Très long" validate_schema="./document.schema.json"}} +``` + +Pour ne valider qu'une partie du schéma, par exemple si on veut faire une mise à jour du document : + +``` +{{:save key="test" titre="Coucou" validate_schema="./document.schema.json" validate_only="titre"}} +``` + +## delete + +Supprime un document lié au module courant. + +| Paramètre | Obligatoire ou optionnel ? | Fonction | +| :- | :- | :- | +| `key` | optionnel | Clé unique du document | +| `id` | optionnel | Numéro unique du document | + +Il est possible de spécifier d'autres paramètres, ou une clause `where` et des paramètres dont le nom commence par deux points. + +* Supprimer le document avec la clé `facture_43` : `{{:delete key="facture_43"}}` +* Supprimer le document avec la clé `ABCD` et dont la propriété `type` du document correspond à la valeur `facture` : `{{:delete key="ABCD" type="facture"}}` +* Supprimer tous les documents : `{{:delete}}` +* Supprimer tous les documents ayant le type `facture` : `{{:delete type="facture"}}` +* Supprimer tous les documents de type `devis` ayant une date dans le passé : `{{:delete :type="devis" where="$$.type = :type AND $$.date < datetime()"}}` + +## read + +Lire un fichier stocké dans les fichiers du code du module. + +| Paramètre | Obligatoire ou optionnel ? | Fonction | +| :- | :- | :- | +| `file` | obligatoire | Chemin du fichier à lire | +| `assign` | optionnel | Variable dans laquelle placer le contenu du fichier. | + +Si le paramètre `assign` n'est pas utilisé, le contenu du fichier sera affiché directement. + +Exemple pour lire un fichier JSON : + +``` +{{#read file="baremes.json" assign="baremes"}} +{{:assign baremes=$baremes|json_decode}} +Barème kilométrique pour une voiture de 3 CV : {{$baremes.voiture.3cv}} +``` + +Exemple pour lire un fichier CSV : + +``` +{{#read file="baremes.csv" assign="baremes"}} +{{:assign baremes=$baremes|trim|explode:"\n"}} + +{{#foreach from=$baremes item="line"}} + {{:assign bareme=$line|str_getcsv}} + Nom du barème : {{$bareme.0}} + Calcul : {{$bareme.1}} +{{/foreach}} +``` + +## admin_header + +Affiche l'entête de l'administration de l'association. + +| Paramètre | Obligatoire ou optionnel ? | Fonction | +| :- | :- | :- | +| `title` | *optionnel* | Titre de la page | +| `layout` | *optionnel* | Aspect de la page. Peut être `public` pour une page publique simple (sans le menu), ou `raw` pour une page vierge (sans aucun menu ni autre élément). Défaut : vide (affichage du menu) | +| `current` | *optionnel* | Indique quel élément dans le menu de gauche doit être marqué comme sélectionné | +| `custom_css` | *optionnel* | Fichier CSS supplémentaire à appeler dans le `` | + +``` +{{:admin_header title="Gestion des dons" current="acc"}} +``` + +Liste des choix possibles pour `current` : + +* `home` : menu Accueil +* `users` : menu Membres +* `users/new` : sous-menu "Ajouter" de Membres +* `users/services` : sous-menu "Activités et cotisations" de Membres +* `users/mailing` : sous-menu "Message collectif" de Membres +* `acc` : menu Comptabilité +* `acc/new` : sous-menu "Saisie" de Comptabilité +* `acc/accounts` : sous-menu "Comptes" +* `acc/simple` : sous-menu "Suivi des écritures" +* `acc/years` : sous-menu "Exercices et rapports" +* `docs` : menu Documents +* `web` : menu Site web +* `config` : menu Configuration +* `me` : menu "Mes infos personnelles" +* `me/services` : sous-menu "Mes activités et cotisations" + +Exemple d'utilisation de `custom_css` depuis un module : + +``` +{{:admin_header title="Mon module" custom_css="./style.css"}} +``` + +## admin_footer + +Affiche le pied de page de l'administration de l'association. + +``` +{{:admin_footer}} +``` + +## delete_form + +Affiche un formulaire demandant la confirmation de suppression d'un élément. + +| Paramètre | Obligatoire ou optionnel ? | Fonction | +| :- | :- | :- | +| `legend` | **obligatoire** | Libellé de l'élément `` du formulaire | +| `warning` | **obligatoire** | Libellé de la question de suppression (en gros en rouge) | +| `alert` | *optionnel* | Message d'alerte supplémentaire (bloc jaune) | +| `info` | *optionnel* | Informations liées à la suppression (expliquant ce qui va être impacté par la suppression) | +| `confirm` | *optionnel* | Libellé de la case à cocher pour la suppression, si ce paramètre est absent ou `NULL`, la case à cocher ne sera pas affichée. | + +Le formulaire envoie un `POST` avec le bouton ayant le nom `delete`. Si le paramètre `confirm` est renseigné, alors la case à cochée aura le nom `confirm_delete`. + +Exemple : + +``` +{{#load id=$_GET.id assign="invoice"}} +{{else}} + {{:error message="Facture introuvable"}} +{{/load}} + +{{#form on="delete"}} + {{if !$_POST.confirm_delete}} + {{:error message="Merci de cocher la case"}} + {{/if}} + {{:delete id=$invoice.id}} +{{/form}} + +{{:form_errors}} + +{{:delete_form + legend="Suppression d'une facture" + warning="Supprimer la facture n°%d ?"|args:$invoice.id + info="Le devis lié sera également supprimé" + alert="La facture sera définitivement perdue !" + confirm="Cocher cette case pour confirmer la suppression de la facture" +}} +``` + +## input + +Crée un champ de formulaire HTML. Cette fonction est une extension à la balise `` en HTML, mais permet plus de choses. + +| Paramètre | Obligatoire ou optionnel ? | Fonction | +| :- | :- | :- | +| `name` | **obligatoire** | Nom du champ | +| `type` | **obligatoire** | Type de champ | +| `required` | *optionnel* | Mettre à `true` si le champ est obligatoire | +| `label` | *optionnel* | Libellé du champ | +| `help` | *optionnel* | Texte d'aide, affiché sous le champ | +| `default` | *optionnel* | Valeur du champ par défaut, si le formulaire n'a pas été envoyé, et que la valeur dans `source` est vide | +| `source` | *optionnel* | Source de pré-remplissage du champ. Si le nom du champ est `montant`, alors la valeur de `[source].montant` sera affichée si présente. | + +Si `label` ou `help` sont spécifiés, le champ sera intégré à une balise HTML `
`, et le libellé sera intégré à une balise `
`. Dans ce cas il faut donc que le champ soit dans une liste `
`. Si ces deux paramètres ne sont pas spécifiés, le champ sera le seul tag HTML. + +``` +
+ {{:input name="amount" type="money" label="Montant" required=true}} +
+``` + +Note : le champ aura comme `id` la valeur `f_[name]`. Ainsi un champ avec `amount` comme `name` aura `id="f_amount"`. + +### Valeur du champ + +La valeur du champ est remplie avec : + +* la valeur dans `$_POST` qui correspond au `name` ; +* sinon la valeur dans `source` (tableau) avec le même nom (exemple : `$source[name]`) ; +* sinon la valeur de `default` est utilisée. + +Note : le paramètre `value` n'est pas supporté sauf pour checkbox et radio. + +### Types de champs supportés + +* les types classiques de `input` en HTML : text, search, email, url, file, date, checkbox, radio, password, etc. + * Note : pour checkbox et radio, il faut utiliser le paramètre `value` en plus pour spécifier la valeur. +* `textarea` +* `money` créera un champ qui attend une valeur de monnaie au format décimal +* `datetime` créera un champ date et un champ texte pour entrer l'heure au format `HH:MM` +* `radio-btn` créera un champ de type radio mais sous la forme d'un gros bouton +* `select` crée un sélecteur de type ``, mais avec des ``. Dans ce cas il convient d'indiquer un tableau associatif à deux niveaux dans le paramètre `options`. +* `list` crée un champ permettant de sélectionner un ou des éléments (selon si le paramètre `multiple` est `true` ou `false`) dans un formulaire externe. Le paramètre `can_delete` indique si l'utilisateur peut supprimer l'élément déjà sélectionné (si `multiple=false`). La sélection se fait à partir d'un formulaire dont l'URL doit être spécifiée dans le paramètre `target`. Les formulaires actuellement supportés sont : + * `!acc/charts/accounts/selector.php?targets=X` pour sélectionner un compte du plan comptable, où X est une liste de types de comptes qu'il faut permettre de choisir (séparés par des `:`) + * `!users/selector.php` pour sélectionner un membre + +## button + +Affiche un bouton, similaire à `', $page['id'], htmlspecialchars($page['label'])); + } + else { + $out .= sprintf('%s', + isset($page['accesskey']) ? 'accesskey="' . strtoupper($page['accesskey']) . '"' : '', + str_replace('DDD', $page['id'], $url), + htmlspecialchars($page['label']) + ); + } + + $out .= "\n"; + } + + $out .= ''; + return $out; + } +} \ No newline at end of file diff --git a/src/include/lib/Paheko/Email/Emails.php b/src/include/lib/Paheko/Email/Emails.php new file mode 100644 index 0000000..2c2b97c --- /dev/null +++ b/src/include/lib/Paheko/Email/Emails.php @@ -0,0 +1,687 @@ + 'Texte brut', + Render::FORMAT_MARKDOWN => 'MarkDown', + ]; + + /** + * Email sending contexts + */ + const CONTEXT_BULK = 1; + const CONTEXT_PRIVATE = 2; + const CONTEXT_SYSTEM = 0; + const CONTEXT_NOTIFICATION = 3; + + /** + * When we reach that number of fails, the address is treated as permanently invalid, unless reset by a verification. + */ + const FAIL_LIMIT = 5; + + static public function appendToQueue(int $context, string $email, array $data = []) + { + + } + + /** + * Add a message to the sending queue using templates + * @param int $context + * @param iterable $recipients List of recipients, this accepts a wide range of types: + * - a single e-mail address + * - array of e-mail addresses as values ['a@b.c', 'd@e.f'] + * - array of user entities + * - array where each key is the email address, and the value is an array or a \stdClass containing + * pgp_key, data and user items + * @param string $sender + * @param string $subject + * @param UserTemplate|string $content + * @return void + */ + static public function queue(int $context, iterable $recipients, ?string $sender, string $subject, $content, array $attachments = []): void + { + if (DISABLE_EMAIL) { + return; + } + + foreach ($attachments as $i => $file) { + if (!is_object($file) || !($file instanceof File) || $file->context() != $file::CONTEXT_ATTACHMENTS) { + throw new \InvalidArgumentException(sprintf('Attachment #%d is not a valid file', $i)); + } + } + + $list = []; + + // Build email list + foreach ($recipients as $key => $r) { + $data = []; + $emails = []; + $user = null; + $pgp_key = null; + + if (is_array($r)) { + $user = $r['user'] ?? null; + $data = $r['data'] ?? null; + $pgp_key = $r['pgp_key'] ?? null; + } + elseif (is_object($r) && $r instanceof User) { + $user = $r; + $data = $r->asArray(); + $pgp_key = $user->pgp_key ?? null; + } + elseif (is_object($r)) { + $user = $r->user ?? null; + $data = $r->data ?? null; + $pgp_key = $user->pgp_key ?? ($r->pgp_key ?? null); + } + + // Get e-mail address from key + if (is_string($key) && false !== strpos($key, '@')) { + $emails[] = $key; + } + // Get e-mail address from value + elseif (is_string($r) && false !== strpos($r, '@')) { + $emails[] = $r; + } + // Get email list from user object + elseif ($user) { + $emails = $user->getEmails(); + } + else { + // E-mail not found + continue; + } + + // Filter out invalid addresses + foreach ($emails as $key => $value) { + if (!preg_match('/.+@.+\..+$/', $value)) { + unset($emails[$key]); + } + } + + if (!count($emails)) { + continue; + } + + $data = compact('user', 'data', 'pgp_key'); + + foreach ($emails as $value) { + $list[$value] = $data; + } + } + + if (!count($list)) { + return; + } + + $recipients = $list; + unset($list); + + $is_system = $context === self::CONTEXT_SYSTEM; + $template = (!$is_system && $content instanceof UserTemplate) ? $content : null; + + if ($template) { + $template->toggleSafeMode(true); + } + + $signal = Plugins::fire('email.queue.before', true, + compact('context', 'recipients', 'sender', 'subject', 'content', 'attachments')); + + // queue handling was done by a plugin, stop here + if ($signal && $signal->isStopped()) { + return; + } + + $db = DB::getInstance(); + $db->begin(); + $html = null; + $main_tpl = null; + + // Apart from SYSTEM emails, all others should be wrapped in the email.html template + if (!$is_system) { + $main_tpl = new UserTemplate('web/email.html'); + } + + if (!$is_system && !$template) { + // If E-Mail does not have placeholders, we can render the MarkDown just once for HTML + $html = Render::render(Render::FORMAT_MARKDOWN, null, $content); + } + + foreach ($recipients as $recipient => $r) { + $data = $r['data']; + $recipient_pgp_key = $r['pgp_key']; + + // We won't try to reject invalid/optout recipients here, + // it's done in the queue clearing (more efficient) + $recipient_hash = Email::getHash($recipient); + + // Replace placeholders: {{$name}}, etc. + if ($template) { + $template->assignArray((array) $data, null, false); + + // Disable HTML escaping for plaintext emails + $template->setEscapeDefault(null); + $content = $template->fetch(); + + // Add Markdown rendering + $content_html = Render::render(Render::FORMAT_MARKDOWN, null, $content); + } + else { + $content_html = $html; + } + + if (!$is_system) { + // Wrap HTML content in the email skeleton + $main_tpl->assignArray([ + 'html' => $content_html, + 'address' => $recipient, + 'data' => $data, + 'context' => $context, + 'from' => $sender, + ]); + + $content_html = $main_tpl->fetch(); + } + + $signal = Plugins::fire('email.queue.insert', true, + compact('context', 'recipient', 'sender', 'subject', 'content', 'recipient_hash', 'recipient_pgp_key', 'content_html', 'attachments')); + + if ($signal && $signal->isStopped()) { + // queue insert was done by a plugin, stop here + continue; + } + + unset($signal); + + $db->insert('emails_queue', compact('sender', 'subject', 'context', 'recipient', 'recipient_pgp_key', 'recipient_hash', 'content', 'content_html')); + + // Clean up memory + unset($content_html); + + $id = $db->lastInsertId(); + + foreach ($attachments as $file) { + $db->insert('emails_queue_attachments', ['id_queue' => $id, 'path' => $file->path]); + } + } + + $db->commit(); + + $signal = Plugins::fire('email.queue.after', true, + compact('context', 'recipients', 'sender', 'subject', 'content', 'attachments')); + + if ($signal && $signal->isStopped()) { + return; + } + + // If no crontab is used, then the queue should be run now + if (!USE_CRON) { + self::runQueue(); + } + // Always send system emails right away + elseif ($is_system) { + self::runQueue(self::CONTEXT_SYSTEM); + } + } + + /** + * Return an Email entity from the optout code + */ + static public function getEmailFromOptout(string $code): ?Email + { + $hash = base64_decode(str_pad(strtr($code, '-_', '+/'), strlen($code) % 4, '=', STR_PAD_RIGHT)); + + if (!$hash) { + return null; + } + + $hash = bin2hex($hash); + return EM::findOne(Email::class, 'SELECT * FROM @TABLE WHERE hash = ?;', $hash); + } + + /** + * Sets the address as invalid (no email can be sent to this address ever) + */ + static public function markAddressAsInvalid(string $address): void + { + $e = self::getEmail($address); + + if (!$e) { + return; + } + + $e->set('invalid', true); + $e->set('optout', false); + $e->set('verified', false); + $e->save(); + } + + /** + * Return an Email entity from an email address + */ + static public function getEmail(string $address): ?Email + { + return EM::findOne(Email::class, 'SELECT * FROM @TABLE WHERE hash = ?;', Email::getHash(strtolower($address))); + } + + /** + * Return or create a new email entity + */ + static public function getOrCreateEmail(string $address): ?Email + { + $address = strtolower($address); + + if (!Email::isAddressValid($address, false)) { + return null; + } + + $e = self::getEmail($address); + + if (!$e) { + $e = self::createEmail($address); + } + + return $e; + } + + static public function createEmail(string $address): Email + { + $e = new Email; + $e->added = new \DateTime; + $e->hash = $e::getHash($address); + $e->validate($address); + $e->save(); + return $e; + } + + /** + * Run the queue of emails that are waiting to be sent + */ + static public function runQueue(?int $context = null): ?int + { + $db = DB::getInstance(); + + $queue = self::listQueueAndMarkAsSending($context); + $ids = []; + + $save_sent = function () use (&$ids, $db) { + if (!count($ids)) { + return null; + } + + $db->exec(sprintf('UPDATE emails_queue SET sending = 2 WHERE %s;', $db->where('id', $ids))); + $ids = []; + }; + + $limit_time = strtotime('1 month ago'); + $count = 0; + $all_attachments = []; + + // listQueue nettoie déjà la queue + foreach ($queue as $row) { + // We allow system emails to be sent to invalid addresses after a while, and to optout addresses all the time + if ($row->optout || $row->invalid || $row->fail_count >= self::FAIL_LIMIT) { + if ($row->context != self::CONTEXT_SYSTEM || (!$row->optout && $row->last_sent > $limit_time)) { + self::deleteFromQueue($row->id); + continue; + } + } + + // Create email address in database + if (!$row->email_hash) { + $email = self::getOrCreateEmail($row->recipient); + + if (!$email || !$email->canSend()) { + // Email address is invalid, skip + self::deleteFromQueue($row->id); + continue; + } + } + + $headers = [ + 'From' => $row->sender, + 'To' => $row->recipient, + 'Subject' => $row->subject, + ]; + + try { + $attachments = $db->getAssoc('SELECT id, path FROM emails_queue_attachments WHERE id_queue = ?;', $row->id); + $all_attachments = array_merge($all_attachments, $attachments); + $sent = self::send($row->context, $row->recipient_hash, $headers, $row->content, $row->content_html, $row->recipient_pgp_key, $attachments); + + // Keep waiting until email is sent + if (!$sent) { + continue; + } + } + catch (\Exception $e) { + // If sending fails, at least save what has been sent so far + // so they won't get re-sent again + $save_sent(); + throw $e; + } + + $ids[] = $row->id; + $count++; + + // Mark messages as sent from time to time + // to avoid starting from the beginning if the queue is killed + // and also avoid passing too many IDs to SQLite at once + if (count($ids) >= 50) { + $save_sent(); + } + } + + // Update emails list and send count + // then delete messages from queue + $db->begin(); + $db->exec(sprintf(' + UPDATE emails_queue SET sending = 2 WHERE %s; + INSERT OR IGNORE INTO %s (hash) SELECT recipient_hash FROM emails_queue WHERE sending = 2; + UPDATE %2$s SET sent_count = sent_count + 1, last_sent = datetime() + WHERE hash IN (SELECT recipient_hash FROM emails_queue WHERE sending = 2); + DELETE FROM emails_queue WHERE sending = 2;', + $db->where('id', $ids), + Email::TABLE)); + $db->commit(); + + $unused_attachments = array_diff($all_attachments, $db->getAssoc('SELECT id, path FROM emails_queue_attachments;')); + + foreach ($unused_attachments as $path) { + $file = Files::get($path); + + if ($file) { + $file->delete(); + } + } + + return $count; + } + + /** + * Lists the queue, marks listed elements as "sending" + * @return array + */ + static protected function listQueueAndMarkAsSending(?int $context = null): array + { + $queue = self::listQueue($context); + + if (!count($queue)) { + return $queue; + } + + $ids = []; + + foreach ($queue as $row) { + $ids[] = $row->id; + } + + $db = DB::getInstance(); + $db->update('emails_queue', ['sending' => 1, 'sending_started' => new \DateTime], $db->where('id', $ids)); + + return $queue; + } + + /** + * Returns the lits of emails waiting to be sent, except invalid ones and emails that haved failed too much + * + * DO NOT USE for sending, use listQueueAndMarkAsSending instead, or there might be multiple processes sending + * the same email over and over. + * + * @param int|null $context Context to list, leave NULL to have all contexts + * @return array + */ + static protected function listQueue(?int $context = null): array + { + // Clean-up the queue from reject emails + self::purgeQueueFromRejected(); + + // Reset messages that failed during the queue run + self::resetFailed(); + + $condition = null === $context ? '' : sprintf(' AND context = %d', $context); + + return DB::getInstance()->get(sprintf('SELECT q.*, e.optout, e.verified, e.hash AS email_hash, + e.invalid, e.fail_count, strftime(\'%%s\', e.last_sent) AS last_sent + FROM emails_queue q + LEFT JOIN emails e ON e.hash = q.recipient_hash + WHERE q.sending = 0 %s;', $condition)); + } + + static public function countQueue(): int + { + return DB::getInstance()->count('emails_queue'); + } + + /** + * Supprime de la queue les messages liés à des adresses invalides + * ou qui ne souhaitent plus recevoir de message + * @return boolean + */ + static protected function purgeQueueFromRejected(): void + { + DB::getInstance()->delete('emails_queue', + 'recipient_hash IN (SELECT hash FROM emails WHERE (invalid = 1 OR fail_count >= ?) + AND last_sent >= datetime(\'now\', \'-1 month\'));', + self::FAIL_LIMIT); + } + + /** + * If emails have been marked as sending but sending failed, mark them for resend after a while + */ + static protected function resetFailed(): void + { + $sql = 'UPDATE emails_queue SET sending = 0, sending_started = NULL + WHERE sending = 1 AND sending_started < datetime(\'now\', \'-3 hours\');'; + DB::getInstance()->exec($sql); + } + + /** + * Supprime un message de la queue d'envoi + * @param integer $id + * @return boolean + */ + static protected function deleteFromQueue($id) + { + return DB::getInstance()->delete('emails_queue', 'id = ?', (int)$id); + } + + static public function getRejectionStatusClause(string $prefix): string + { + $prefix .= '.'; + + return sprintf('CASE + WHEN %1$soptout = 1 THEN \'Désinscription globale\' + WHEN %1$sinvalid = 1 THEN \'Invalide\' + WHEN %1$sfail_count >= %2$d THEN \'Trop d\'\'erreurs\' + ELSE \'\' + END', $prefix, self::FAIL_LIMIT); + } + + static public function listRejectedUsers(): DynamicList + { + $db = DB::getInstance(); + $email_field = 'u.' . $db->quoteIdentifier(DynamicFields::getFirstEmailField()); + + $columns = [ + 'id' => [ + 'select' => 'e.id', + ], + 'identity' => [ + 'label' => 'Membre', + 'select' => DynamicFields::getNameFieldsSQL('u'), + ], + 'email' => [ + 'label' => 'Adresse', + 'select' => $email_field, + ], + 'user_id' => [ + 'select' => 'u.id', + ], + 'hash' => [ + ], + 'status' => [ + 'label' => 'Statut', + 'select' => self::getRejectionStatusClause('e'), + ], + 'sent_count' => [ + 'label' => 'Messages envoyés', + ], + 'fail_log' => [ + 'label' => 'Journal d\'erreurs', + ], + 'last_sent' => [ + 'label' => 'Dernière tentative d\'envoi', + ], + 'optout' => [], + 'fail_count' => [], + ]; + + $tables = sprintf('emails e INNER JOIN users u ON %s IS NOT NULL AND %1$s != \'\' AND e.hash = email_hash(%1$s)', $email_field); + + $conditions = sprintf('e.invalid = 1 OR e.fail_count >= %d', self::FAIL_LIMIT); + + $list = new DynamicList($columns, $tables, $conditions); + $list->orderBy('last_sent', true); + $list->setModifier(function (&$row) { + $row->last_sent = $row->last_sent ? new \DateTime($row->last_sent) : null; + }); + return $list; + } + + static public function sendMessage(int $context, Mail_Message $message): bool + { + if (DISABLE_EMAIL) { + return false; + } + + $signal = Plugins::fire('email.send.before', true, compact('context', 'message'), ['sent' => null]); + + if ($signal && $signal->isStopped()) { + return $signal->getOut('sent') ?? true; + } + + if (SMTP_HOST) { + $const = '\KD2\SMTP::' . strtoupper(SMTP_SECURITY); + $secure = constant($const); + + $smtp = new SMTP(SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASSWORD, $secure, SMTP_HELO_HOSTNAME); + + $smtp->send($message); + } + else { + $message->send(); + } + + Plugins::fire('email.send.after', false, compact('context', 'message')); + return true; + } + + /** + * Handle a bounce message + * @param string $raw_message Raw MIME message from SMTP + */ + static public function handleBounce(string $raw_message): ?array + { + $message = new Mail_Message; + $message->parse($raw_message); + + $return = $message->identifyBounce(); + $address = $return['recipient'] ?? null; + + $signal = Plugins::fire('email.bounce', false, compact('address', 'message', 'return', 'raw_message')); + + if ($signal && $signal->isStopped()) { + return null; + } + + if (!$return) { + return null; + } + + if ($return['type'] == 'autoreply') { + // Ignore auto-responders + return $return; + } + elseif ($return['type'] == 'genuine') { + // Forward emails that are not automatic to the organization email + $config = Config::getInstance(); + + $new = new Mail_Message; + $new->setHeaders([ + 'To' => $config->org_email, + 'Subject' => 'Réponse à un message que vous avez envoyé', + ]); + + $new->setBody('Veuillez trouver ci-joint une réponse à un message que vous avez envoyé à un de vos membre.'); + + $new->attachMessage($message->output()); + + self::sendMessage(self::CONTEXT_SYSTEM, $new); + return $return; + } + + return self::handleManualBounce($return['recipient'], $return['type'], $return['message']); + } + + static public function handleManualBounce(string $address, string $type, ?string $message): ?array + { + $return = compact('address', 'type', 'message'); + $email = self::getOrCreateEmail($address); + + if (!$email) { + return null; + } + + $email->hasFailed($return); + Plugins::fire('email.bounce.save.before', false, compact('email', 'return', 'type', 'message')); + $email->save(); + + return $return; + } + + + static public function getFromHeader(string $name = null, string $email = null): string + { + $config = Config::getInstance(); + + if (null === $name) { + $name = $config->org_name; + } + if (null === $email) { + $email = $config->org_email; + } + + $name = str_replace('"', '\\"', $name); + $name = str_replace(',', '', $name); // Remove commas + + return sprintf('"%s" <%s>', $name, $email); + } + +} diff --git a/src/include/lib/Paheko/Email/Mailings.php b/src/include/lib/Paheko/Email/Mailings.php new file mode 100644 index 0000000..0feaae3 --- /dev/null +++ b/src/include/lib/Paheko/Email/Mailings.php @@ -0,0 +1,183 @@ + [ + ], + 'subject' => [ + 'label' => 'Sujet', + ], + 'nb_recipients' => [ + 'label' => 'Destinataires', + 'select' => '(SELECT COUNT(*) FROM mailings_recipients WHERE id_mailing = mailings.id)', + ], + 'sent' => [ + 'label' => 'Date d\'envoi', + 'order' => 'id %s', + ], + ]; + + $tables = 'mailings'; + + $list = new DynamicList($columns, $tables); + $list->orderBy('sent', true); + return $list; + } + + static public function get(int $id): ?Mailing + { + return EntityManager::findOneById(Mailing::class, $id); + } + + static public function create(string $subject, string $target_type, ?string $target_value, ?string $target_label): Mailing + { + $db = DB::getInstance(); + $db->begin(); + + $m = new Mailing; + $m->set('subject', $subject); + $m->importForm(compact('subject', 'target_type', 'target_value', 'target_label')); + $m->save(); + $m->populate(); + + $db->commit(); + return $m; + } + + static public function anonymize(): void + { + $em = EntityManager::getInstance(Mailing::class); + $db = DB::getInstance(); + + $db->begin(); + foreach ($em->iterate('SELECT * FROM @TABLE WHERE sent < datetime(\'now\', \'-6 month\') AND anonymous = 0;') as $m) { + $m->anonymize(); + $m->set('anonymous', true); + $m->save(); + } + + $db->commit(); + } + + static public function listTargets(string $type): array + { + if ($type === 'field') { + $list = self::listCheckboxFieldsTargets(); + } + elseif ($type === 'category') { + $list = Categories::listWithStats(Categories::WITHOUT_HIDDEN); + } + elseif ($type === 'service') { + $list = iterator_to_array(Services::listWithStats(true)->iterate()); + } + elseif ($type === 'search') { + $list = Search::list(SearchEntity::TARGET_USERS, Session::getUserId()); + $list = array_filter($list, fn($s) => $s->hasUserId()); + array_walk($search_list, function (&$s) { + $s = (object) ['label' => $s->label, 'id' => $s->id, 'count' => $s->countResults()]; + }); + + } + else { + throw new \InvalidArgumentException('Unknown target type: ' . $type); + } + + if (!count($list)) { + throw new UserException('Il n\'y aucun résultat correspondant à cette cible d\'envoi.'); + } + + return $list; + } + + static public function listCheckboxFieldsTargets(): array + { + $fields = DynamicFields::getInstance()->fieldsByType('checkbox'); + + if (!count($fields)) { + return []; + } + + $db = DB::getInstance(); + $sql = []; + + foreach ($fields as $field) { + $sql[] = sprintf('SELECT %s AS name, %s AS label, COUNT(*) AS count FROM users WHERE %s = 1 AND id_category IN (SELECT id FROM users_categories WHERE hidden = 0)', + $db->quote($field->name), + $db->quote($field->label), + $db->quoteIdentifier($field->name) + ); + } + + $sql = implode(' UNION ALL ', $sql); + return $db->get($sql); + } + + static public function getOptoutUsersList(): DynamicList + { + $db = DB::getInstance(); + $email_field = 'u.' . $db->quoteIdentifier(DynamicFields::getFirstEmailField()); + + $columns = [ + 'id' => [ + 'select' => 'e.id', + ], + 'identity' => [ + 'label' => 'Membre', + 'select' => DynamicFields::getNameFieldsSQL('u'), + ], + 'email' => [ + 'label' => 'Adresse', + 'select' => $email_field, + ], + 'user_id' => [ + 'select' => 'u.id', + ], + 'hash' => [ + ], + 'status' => [ + 'label' => 'Désinscription', + 'select' => 'CASE WHEN e.optout = 1 THEN \'Désinscription globale\' ELSE o.target_label END', + ], + 'sent_count' => [ + 'label' => 'Messages envoyés', + ], + 'last_sent' => [ + 'label' => 'Dernière tentative d\'envoi', + ], + 'optout' => [], + 'target_type' => [], + 'target_label' => [], + ]; + + $tables = sprintf('users u + INNER JOIN emails e ON e.hash = email_hash(%1$s) + LEFT JOIN mailings_optouts o ON o.email_hash = e.hash', $email_field); + + $conditions = sprintf('%s IS NOT NULL AND %1$s != \'\' AND (e.optout = 1 OR o.email_hash IS NOT NULL)', $email_field); + + $list = new DynamicList($columns, $tables, $conditions); + $list->orderBy('last_sent', true); + $list->setModifier(function (&$row) { + $row->last_sent = $row->last_sent ? new \DateTime($row->last_sent) : null; + }); + return $list; + } + +} diff --git a/src/include/lib/Paheko/Email/Templates.php b/src/include/lib/Paheko/Email/Templates.php new file mode 100644 index 0000000..776a02c --- /dev/null +++ b/src/include/lib/Paheko/Email/Templates.php @@ -0,0 +1,53 @@ +assign($variables); + $tpl->setEscapeType('disable'); + $body = trim($tpl->fetch('emails/' . $template)); + $subject = $tpl->getTemplateVars('subject'); + + if (!$subject) { + throw new \LogicException('Template did not define a subject'); + } + + Emails::queue(Emails::CONTEXT_SYSTEM, (array)$to, null, $subject, $body); + } + + static public function loginChanged(User $user): void + { + $login_field = DynamicFields::getLoginField(); + self::send($user, 'login_changed.tpl', ['new_login' => $user->$login_field]); + } + + static public function passwordRecovery(string $email, string $recovery_url, ?string $pgp_key): void + { + self::send([$email => compact('pgp_key')], 'password_recovery.tpl', compact('recovery_url')); + } + + static public function passwordChanged(User $user): void + { + $ip = Utils::getIP(); + $login_field = DynamicFields::getLoginField(); + $login = $user->$login_field; + self::send($user, 'password_changed.tpl', compact('ip', 'login')); + } + + static public function verifyAddress(string $email, string $verify_url): void + { + self::send($email, 'verify_email.tpl', compact('verify_url')); + } +} diff --git a/src/include/lib/Paheko/Entities/API_Credentials.php b/src/include/lib/Paheko/Entities/API_Credentials.php new file mode 100644 index 0000000..c1aaafc --- /dev/null +++ b/src/include/lib/Paheko/Entities/API_Credentials.php @@ -0,0 +1,39 @@ + 'Peut lire les données', + Session::ACCESS_WRITE => 'Peut lire et modifier les données', + Session::ACCESS_ADMIN => 'Peut tout faire, y compris supprimer les données', + ]; + + public function selfCheck(): void + { + parent::selfCheck(); + + $this->assert(trim($this->label) !== '', 'La description ne peut être laissée vide.'); + $this->assert(trim($this->key) !== '', 'La clé ne peut être laissée vide.'); + $this->assert(trim($this->secret) !== '', 'Le secret ne peut être laissé vide.'); + $this->assert(array_key_exists($this->access_level, self::ACCESS_LEVELS)); + + $this->assert(preg_match('/^[a-z0-9_]+$/', $this->key), 'L\'identifiant ne peut contenir que des lettres, des chiffres et des tirets bas.'); + } +} diff --git a/src/include/lib/Paheko/Entities/Accounting/Account.php b/src/include/lib/Paheko/Entities/Accounting/Account.php new file mode 100644 index 0000000..e471936 --- /dev/null +++ b/src/include/lib/Paheko/Entities/Accounting/Account.php @@ -0,0 +1,959 @@ + [ + '^1' => self::LIABILITY, + '^2' => self::ASSET, + '^3' => self::ASSET, + '^4' => self::ASSET_OR_LIABILITY, + '^5' => self::ASSET_OR_LIABILITY, + '^6' => self::EXPENSE, + '^7' => self::REVENUE, + '^86' => self::EXPENSE, + '^87' => self::REVENUE, + ], + 'BE' => [ + '^69' => self::ASSET_OR_LIABILITY, + '^6' => self::EXPENSE, + '^79' => self::ASSET_OR_LIABILITY, + '^7' => self::REVENUE, + '^5' => self::ASSET_OR_LIABILITY, + '^4' => self::ASSET_OR_LIABILITY, + '^3' => self::ASSET, + '^2' => self::ASSET, + '^1' => self::LIABILITY, + ], + 'CH' => [ + '^1' => self::ASSET, + '^2' => self::LIABILITY, + '^3(?!910)|^4910' => self::EXPENSE, + '^4(?!910)|^3910' => self::REVENUE, + '^5' => self::ASSET_OR_LIABILITY, + ], + ]; + + /** + * Codes that should be enforced according to type (and vice-versa) + */ + const LOCAL_TYPES = [ + 'FR' => [ + self::TYPE_BANK => '512', + self::TYPE_TEMPORARY_TRANSFER => '580', + self::TYPE_CASH => '53', + self::TYPE_OUTSTANDING => '511', + self::TYPE_THIRD_PARTY => '4', + self::TYPE_EXPENSE => '6', + self::TYPE_REVENUE => '7', + self::TYPE_VOLUNTEERING_EXPENSE => '86', + self::TYPE_VOLUNTEERING_REVENUE => '87', + self::TYPE_OPENING => '890', + self::TYPE_CLOSING => '891', + self::TYPE_POSITIVE_RESULT => '120', + self::TYPE_NEGATIVE_RESULT => '129', + self::TYPE_APPROPRIATION_RESULT => '1068', + self::TYPE_CREDIT_REPORT => '110', + self::TYPE_DEBIT_REPORT => '119', + ], + 'BE' => [ + self::TYPE_APPROPRIATION_RESULT => '139', + self::TYPE_CREDIT_REPORT => '4931', + self::TYPE_DEBIT_REPORT => '4932', + self::TYPE_BANK => '56', + self::TYPE_CASH => '570', + self::TYPE_OUTSTANDING => '499', + self::TYPE_EXPENSE => '6', + self::TYPE_REVENUE => '7', + self::TYPE_POSITIVE_RESULT => '692', + self::TYPE_NEGATIVE_RESULT => '690', + self::TYPE_THIRD_PARTY => '4', + self::TYPE_OPENING => '890', + self::TYPE_CLOSING => '891', + ], + 'CH' => [ + self::TYPE_BANK => '102', + self::TYPE_CASH => '100', + self::TYPE_OUTSTANDING => '109', + self::TYPE_THIRD_PARTY => '5', + self::TYPE_EXPENSE => '3', + self::TYPE_REVENUE => '4', + self::TYPE_OPENING => '9100', + self::TYPE_CLOSING => '9101', + self::TYPE_POSITIVE_RESULT => '29991', + self::TYPE_NEGATIVE_RESULT => '29999', + self::TYPE_APPROPRIATION_RESULT => '2910', + self::TYPE_CREDIT_REPORT => '2990', + self::TYPE_DEBIT_REPORT => '2990', + ], + ]; + + const LIST_COLUMNS = [ + 'id' => [ + 'select' => 't.id', + 'label' => 'N°', + ], + 'id_line' => [ + 'select' => 'l.id', + ], + 'date' => [ + 'label' => 'Date', + 'select' => 't.date', + 'order' => 'date %s, id %1$s', + ], + 'debit' => [ + 'select' => 'l.debit', + 'label' => 'Débit', + ], + 'credit' => [ + 'select' => 'l.credit', + 'label' => 'Crédit', + ], + 'change' => [ + 'select' => '(l.debit - l.credit) * %d', + 'label' => 'Mouvement', + ], + 'sum' => [ + 'select' => NULL, + 'label' => 'Solde cumulé', + 'only_with_order' => 'date', + ], + 'reference' => [ + 'label' => 'Pièce comptable', + 'select' => 't.reference', + ], + 'type' => [ + 'select' => 't.type', + ], + 'label' => [ + 'select' => 't.label', + 'label' => 'Libellé', + ], + 'line_label' => [ + 'select' => 'l.label', + 'label' => 'Libellé ligne' + ], + 'line_reference' => [ + 'label' => 'Réf. ligne', + 'select' => 'l.reference', + ], + 'id_project' => [ + 'select' => 'l.id_project', + ], + 'project_code' => [ + 'select' => 'IFNULL(p.code, SUBSTR(p.label, 1, 10) || \'…\')', + 'label' => 'Projet', + ], + 'locked' => [ + 'label' => 'Verrouillée', + 'header_icon' => 'lock', + 'select' => 'CASE WHEN t.hash IS NOT NULL THEN \'Oui\' ELSE \'\' END', + ], + 'files' => [ + 'label' => 'Fichiers joints', + 'header_icon' => 'attach', + 'select' => '(SELECT COUNT(tf.id_file) FROM acc_transactions_files tf INNER JOIN files f ON f.id = tf.id_file WHERE tf.id_transaction = t.id AND f.trash IS NULL)', + ], + 'status' => [ + 'select' => 't.status', + ], + ]; + + protected ?int $id; + protected int $id_chart; + protected string $code; + protected string $label; + protected ?string $description; + protected int $position = 0; + protected int $type; + protected bool $user = false; + protected bool $bookmark = false; + + protected $_position = []; + protected ?Chart $_chart = null; + + static protected ?array $_charts; + + public function selfCheck(): void + { + $db = DB::getInstance(); + + $this->assert(trim($this->code) !== '', 'Le numéro de compte ne peut rester vide.'); + $this->assert(trim($this->label) !== '', 'L\'intitulé de compte ne peut rester vide.'); + + // Only enforce code limits if the account is new, or if the code is changed + if (!$this->exists() || $this->isModified('code')) { + $this->assert(strlen($this->code) <= 20, 'Le numéro de compte ne peut faire plus de 20 caractères.'); + $this->assert(preg_match('/^[a-z0-9_]+$/i', $this->code), 'Le numéro de compte ne peut comporter que des lettres et des chiffres.'); + } + + $this->assert(strlen($this->label) <= 200, 'L\'intitulé de compte ne peut faire plus de 200 caractères.'); + $this->assert(!isset($this->description) || strlen($this->description) <= 2000, 'La description de compte ne peut faire plus de 2000 caractères.'); + + $this->assert(!empty($this->id_chart), 'Aucun plan comptable lié'); + + $where = 'code = ? AND id_chart = ?'; + $where .= $this->exists() ? sprintf(' AND id != %d', $this->id()) : ''; + + if ($db->test(self::TABLE, $where, $this->code, $this->id_chart)) { + throw new ValidationException(sprintf('Le numéro "%s" est déjà utilisé par un autre compte.', $this->code)); + } + + $this->assert(isset($this->type)); + + $this->checkLocalRules(); + + $this->assert(array_key_exists($this->type, self::TYPES_NAMES), 'Type invalide: ' . $this->type); + $this->assert(array_key_exists($this->position, self::POSITIONS_NAMES), 'Position invalide'); + + parent::selfCheck(); + } + + protected function getCountry(): ?string + { + if (!isset(self::$_charts)) { + self::$_charts = DB::getInstance()->getGrouped('SELECT id, country, code FROM acc_charts;'); + } + + return self::$_charts[$this->id_chart]->country ?? null; + } + + protected function isChartOfficial(): bool + { + $country = $this->getCountry(); + return !empty(self::$_charts[$this->id_chart]->code); + } + + /** + * This sets the account position according to local rules + * if the chart is linked to a country, but only + * if the account is user-created, or if the chart is non-official + */ + protected function getLocalPosition(string $country = null): ?int + { + if (!func_num_args()) { + $country = $this->getCountry(); + } + + $is_official = $this->isChartOfficial(); + + if (!$country) { + return null; + } + + // Do not change position of official chart accounts + if (!$this->user && $is_official) { + return null; + } + + foreach (self::LOCAL_POSITIONS[$country] as $pattern => $position) { + if (preg_match('/' . $pattern . '/', $this->code)) { + return $position; + } + } + + return null; + } + + protected function getLocalType(string $country = null): int + { + if (!func_num_args()) { + $country = $this->getCountry(); + } + + if (!$country) { + return self::TYPE_NONE; + } + + foreach (self::LOCAL_TYPES[$country] as $type => $number) { + if ($this->matchType($type, $country)) { + return $type; + } + } + + return self::TYPE_NONE; + } + + protected function matchType(int $type, string $country = null): bool + { + if (func_num_args() < 2) { + $country = $this->getCountry(); + } + + $pattern = self::LOCAL_TYPES[$country][$type] ?? null; + + if (!$pattern) { + return false; + } + + if (in_array($type, self::COMMON_TYPES)) { + $pattern = sprintf('/^%s.+/', $pattern); + } + else { + $pattern = sprintf('/^%s$/', $pattern); + } + + return (bool) preg_match($pattern, $this->code); + } + + public function setLocalRules(string $country = null): void + { + if (!func_num_args()) { + $country = $this->getCountry(); + } + + if (!$country) { + $this->set('type', 0); + return; + } + + $this->set('type', $this->getLocalType($country)); + + if (null !== ($p = $this->getLocalPosition($country))) { + // If the allowed local position is asset OR liability, we allow either one of those 3 choices + if ($p != self::ASSET_OR_LIABILITY + || !in_array($this->position, [self::ASSET_OR_LIABILITY, self::ASSET, self::LIABILITY])) { + $this->set('position', $p); + } + } + + if (!isset($this->type)) { + $this->set('type', 0); + } + } + + public function checkLocalRules(): void + { + $country = $this->getCountry(); + + if (!$this->type) { + return; + } + + if (!isset(self::LOCAL_TYPES[$country][$this->type])) { + return; + } + + $this->assert($this->matchType($this->type), sprintf('Compte "%s - %s" : le numéro des comptes de type "%s" doit commencer par "%s" (%s).', $this->code, $this->label, self::TYPES_NAMES[$this->type], self::LOCAL_TYPES[$country][$this->type], $this->code)); + } + + public function getNewNumberAvailable(?string $base = null): ?string + { + $base ??= $this->getNumberBase(); + + if (!$base) { + return $base; + } + + $pattern = $base . '_%'; + + $db = DB::getInstance(); + $used_codes = $db->getAssoc(sprintf('SELECT code, code FROM %s WHERE code LIKE ? AND id_chart = ?;', Account::TABLE), $pattern, $this->id_chart); + $used_codes = array_values($used_codes); + $used_codes = array_map(fn($a) => substr($a, strlen($base)), $used_codes); + + $count = $db->count(Account::TABLE, 'id_chart = ? AND code LIKE ?', $this->id_chart, $pattern); + $letter = null; + + // Make sure we don't reuse an existing code + while (!$letter || in_array($letter, $used_codes)) { + // Get new account code, eg. 512A, 99AA, 99BZ etc. + $letter = Utils::num2alpha($count++); + } + + return $letter; + } + + public function getNumberUserPart(): ?string + { + $base = $this->getNumberBase(); + + if (!$base) { + return $base; + } + + return substr($this->code, strlen($base)); + } + + public function getNumberBase(): ?string + { + if (!$this->type) { + return null; + } + + $country = $this->getCountry(); + + if (!isset(self::LOCAL_TYPES[$country][$this->type])) { + return null; + } + + + return self::LOCAL_TYPES[$country][$this->type]; + } + + public function listJournal(int $year_id, bool $simple = false, ?DateTimeInterface $start = null, ?DateTimeInterface $end = null) + { + $db = DB::getInstance(); + $columns = self::LIST_COLUMNS; + + // Don't show locked column if no transactions are locked + if (!$db->test('acc_transactions', 'hash IS NOT NULL')) { + unset($columns['locked']); + } + + $tables = 'acc_transactions_lines l + INNER JOIN acc_transactions t ON t.id = l.id_transaction + LEFT JOIN acc_projects p ON p.id = l.id_project'; + $conditions = sprintf('l.id_account = %d AND t.id_year = %d', $this->id(), $year_id); + + $sum = null; + $reverse = $this->isReversed($simple, $year_id) ? -1 : 1; + + if ($start) { + $conditions .= sprintf(' AND t.date >= %s', $db->quote($start->format('Y-m-d'))); + } + + if ($end) { + $conditions .= sprintf(' AND t.date <= %s', $db->quote($end->format('Y-m-d'))); + } + + $columns['change']['select'] = sprintf($columns['change']['select'], $reverse); + + if ($simple) { + unset($columns['debit']['label'], $columns['credit']['label'], $columns['line_label']['label']); + $columns['line_reference']['label'] = 'Réf. paiement'; + } + else { + unset($columns['change']['label']); + } + + $list = new DynamicList($columns, $tables, $conditions); + $list->orderBy('date', true); + $list->setCount('COUNT(*)'); + $list->setPageSize(null); // Because with paging we can't calculate the running sum + $list->setModifier(function (&$row) use (&$sum, &$list, $reverse, $year_id, $start, $end) { + if (property_exists($row, 'sum')) { + // Reverse running sum needs the last sum, first + if ($list->desc && null === $sum) { + $sum = $this->getSumAtDate($year_id, ($end ?? new \DateTime($row->date))->modify('+1 day')) * -1 * $reverse; + } + elseif (!$list->desc) { + if (null === $sum && $start) { + $sum = $this->getSumAtDate($year_id, $start) * -1 * $reverse; + } + + $sum += $row->change; + } + + $row->sum = $sum; + + if ($list->desc) { + $sum -= $row->change; + } + } + + $row->date = \DateTime::createFromFormat('!Y-m-d', $row->date); + }); + + $list->setExportCallback(function (&$row) { + static $columns = ['change', 'sum', 'credit', 'debit']; + foreach ($columns as $key) { + if (isset($row->$key)) { + $row->$key = Utils::money_format($row->$key, '.', '', false); + } + } + }); + + return $list; + } + + /** + * Renvoie TRUE si le solde du compte est inversé en vue simplifiée (= crédit - débit, au lieu de débit - crédit) + * @return boolean + */ + public function isReversed(bool $simple, int $id_year): bool + { + $is_reversed = Accounts::isReversed($simple, $this->type); + + if (!$is_reversed) { + return $is_reversed; + } + + $position = $this->getPosition($id_year); + + if ($position == self::ASSET || $position == self::EXPENSE) { + return false; + } + + return true; + } + + public function getPosition(int $id_year): int + { + $position = $this->_position[$id_year] ?? $this->position; + + if ($position == self::ASSET_OR_LIABILITY) { + $balance = DB::getInstance()->firstColumn('SELECT debit - credit FROM acc_accounts_balances WHERE id = ? AND id_year = ?;', $this->id, $id_year); + $position = $balance > 0 ? self::ASSET : self::LIABILITY; + } + + $this->_position[$id_year] = $position; + + return $position; + } + + public function getReconcileJournal(int $year_id, DateTimeInterface $start_date, DateTimeInterface $end_date, bool $only_non_reconciled = false) + { + if ($end_date < $start_date) { + throw new ValidationException('La date de début ne peut être avant la date de fin.'); + } + + $condition = $only_non_reconciled ? ' AND l.reconciled = 0' : ''; + + $db = DB::getInstance(); + $sql = 'SELECT l.debit, l.credit, t.id, t.date, t.reference, l.reference AS line_reference, t.label, l.label AS line_label, l.reconciled, l.id AS id_line + FROM acc_transactions_lines l + INNER JOIN acc_transactions t ON t.id = l.id_transaction + WHERE l.id_account = ? AND t.id_year = ? AND t.date >= ? AND t.date <= ? %s + ORDER BY t.date, t.id;'; + $rows = $db->iterate(sprintf($sql, $condition), $this->id(), $year_id, $start_date->format('Y-m-d'), $end_date->format('Y-m-d')); + + $sum = $this->getSumAtDate($year_id, $start_date); + $reconciled_sum = $this->getSumAtDate($year_id, $start_date, true); + + $start_sum = false; + + foreach ($rows as $row) { + if (!$start_sum) { + yield (object) ['sum' => $sum, 'date' => $start_date]; + $start_sum = true; + } + + $row->date = \DateTime::createFromFormat('Y-m-d', $row->date); + $sum += ($row->credit - $row->debit); + $row->running_sum = $sum; + + if ($row->reconciled) { + $reconciled_sum += ($row->credit - $row->debit); + } + + $row->reconciled_sum = $reconciled_sum; + + yield $row; + } + + if (!$only_non_reconciled) { + yield (object) ['sum' => $sum, 'reconciled_sum' => $reconciled_sum, 'date' => $end_date]; + } + } + + public function getDepositJournal(int $year_id, array $checked = []): DynamicList + { + $columns = [ + 'id' => [ + 'label' => 'Num.', + 'select' => 't.id', + ], + 'date' => [ + 'select' => 't.date', + 'label' => 'Date', + 'order' => 't.date %s, t.id %1$s', + ], + 'reference' => [ + 'select' => 't.reference', + 'label' => 'Réf. écriture', + ], + 'line_reference' => [ + 'select' => 'l.reference', + 'label' => 'Réf. paiement', + ], + 'label' => [ + 'label' => 'Libellé', + 'select' => 't.label', + ], + 'amount' => [ + 'label' => 'Montant', + 'select' => 'l.debit', + ], + 'running_sum' => [ + 'label' => 'Solde cumulé', + 'only_with_order' => 'date', + 'select' => null, + ], + 'credit' => [ + 'select' => 'l.credit', + ], + 'debit' => [ + 'select' => 'l.debit', + ], + 'id_account' => [ + 'select' => 'l.id_account', + ], + 'id_line' => [ + 'select' => 'l.id', + ], + 'id_project' => [ + 'select' => 'l.id_project', + ], + ]; + + $tables = 'acc_transactions_lines l INNER JOIN acc_transactions t ON t.id = l.id_transaction'; + $conditions = sprintf('t.id_year = %d AND l.id_account = %d AND l.credit = 0 AND NOT (t.status & %d)', + $year_id, $this->id(), Transaction::STATUS_DEPOSIT); + + $list = new DynamicList($columns, $tables, $conditions); + $list->setPageSize(null); + $list->orderBy('date', true); + $list->setModifier(function (&$row) use (&$sum, $checked) { + $sum += ($row->credit - $row->debit); + $row->running_sum = $sum; + $row->checked = array_key_exists($row->id, $checked); + }); + + return $list; + } + + public function getDepositMissingBalance(int $year_id): int + { + $deposit_balance = DB::getInstance()->firstColumn('SELECT SUM(l.debit) + FROM acc_transactions_lines l + INNER JOIN acc_transactions t ON t.id = l.id_transaction + WHERE t.id_year = ? AND l.id_account = ? AND l.credit = 0 AND NOT (t.status & ?) + ORDER BY t.date, t.id;', + $year_id, $this->id(), Transaction::STATUS_DEPOSIT); + $account_balance = $this->getSum($year_id)->balance; + + return $account_balance - $deposit_balance; + } + + public function getSum(int $year_id, bool $simple = false): ?\stdClass + { + $sum = DB::getInstance()->first('SELECT balance, credit, debit + FROM acc_accounts_balances + WHERE id = ? AND id_year = ?;', $this->id(), $year_id); + + return $sum ?: null; + } + + + public function getSumAtDate(int $year_id, DateTimeInterface $date, bool $reconciled_only = false): int + { + $sql = sprintf('SELECT SUM(l.credit) - SUM(l.debit) + FROM acc_transactions_lines l + INNER JOIN acc_transactions t ON t.id = l.id_transaction + WHERE l.id_account = ? AND t.id_year = ? AND t.date < ? %s;', + $reconciled_only ? 'AND l.reconciled = 1' : ''); + return (int) DB::getInstance()->firstColumn($sql, $this->id(), $year_id, $date->format('Y-m-d')); + } + + public function importLimitedForm(?array $source = null) + { + if (null === $source) { + $source = $_POST; + } + + $data = array_intersect_key($source, array_flip(['type', 'description'])); + parent::import($data); + } + + public function canDelete(): bool + { + if ($this->chart()->code && !$this->user) { + return false; + } + + return !DB::getInstance()->firstColumn(sprintf('SELECT 1 FROM %s WHERE id_account = ? LIMIT 1;', Line::TABLE), $this->id()); + } + + /** + * An account properties (position, label and code) can only be changed if: + * * it's either a user-created account or an account part of a user-created chart + * * has no transactions in a closed year + * @return bool + */ + public function canEdit(): bool + { + if (!$this->exists()) { + return true; + } + + $db = DB::getInstance(); + $is_user = $this->user ?: $db->test(Chart::TABLE, 'id = ? AND code IS NULL', $this->id_chart); + + if (!$is_user) { + return false; + } + + $sql = sprintf('SELECT 1 FROM %s l + INNER JOIN %s t ON t.id = l.id_transaction + INNER JOIN %s y ON y.id = t.id_year + WHERE l.id_account = ? AND y.closed = 1 + LIMIT 1;', Line::TABLE, Transaction::TABLE, Year::TABLE); + $has_transactions_in_closed_year = $db->firstColumn($sql, $this->id()); + + if ($has_transactions_in_closed_year) { + return false; + } + + return true; + } + + /** + * We can set account position if: + * - account is not in a supported chart country + * - account is not part of an official chart + * - account is not affected by local position rules + */ + public function canSetPosition(): bool + { + if (!$this->getCountry()) { + return true; + } + + if ($this->isChartOfficial() && !$this->user) { + return false; + } + + if ($this->type || $this->getLocalType()) { + return false; + } + + if (null !== $this->getLocalPosition()) { + return false; + } + + return true; + } + + /** + * We can set account asset or liability if: + * - local position rules allow for asset or liability + */ + public function canSetAssetOrLiabilityPosition(): bool + { + if (!$this->getCountry()) { + return true; + } + + if ($this->isChartOfficial() && !$this->user) { + return false; + } + + $type = $this->type ?: $this->getLocalType(); + + if ($type == self::TYPE_THIRD_PARTY) { + return true; + } + elseif ($type) { + return false; + } + + $position = $this->getLocalPosition(); + + if ($position == self::ASSET_OR_LIABILITY) { + return true; + } + + return false; + } + + public function chart(): Chart + { + $this->_chart ??= Charts::get($this->id_chart); + return $this->_chart; + } + + public function save(bool $selfcheck = true): bool + { + $this->setLocalRules(); + DB::getInstance()->exec(sprintf('REPLACE INTO config (key, value) VALUES (\'last_chart_change\', %d);', time())); + + return parent::save($selfcheck); + } + + public function position_name(): string + { + return self::POSITIONS_NAMES[$this->position]; + } + + public function type_name(): string + { + return self::TYPES_NAMES[$this->type]; + } + + public function importForm(array $source = null) + { + if (null === $source) { + $source = $_POST; + } + + if (isset($source['code_value'], $source['code_base'])) { + $source['code'] = trim($source['code_base']) . trim($source['code_value']); + } + + parent::importForm($source); + } + + public function level(): int + { + $level = strlen($this->code); + + if ($level > 6) { + $level = 6; + } + + return $level; + } + + public function isListedAsFavourite(): bool + { + if ($this->bookmark) { + return true; + } + + if ($this->user) { + return true; + } + + return DB::getInstance()->test('acc_transactions_lines', 'id_account = ?', $this->id); + } + + public function createOpeningBalance(Year $year, int $amount, ?string $label = null): Transaction + { + $accounts = $year->accounts(); + $opening_account = $accounts->getOpeningAccountId(); + + if (!$opening_account) { + throw new UserException('Impossible de créer la balance d\'ouverture : le plan comptable sélectionné n\'a pas de compte 890 — Balance d\'ouverture.'); + } + + $t = new Transaction; + $t->label = $label ?? 'Solde d\'ouverture du compte'; + $t->date = clone $year->start_date; + $t->type = $t::TYPE_ADVANCED; + $t->notes = 'Créé automatiquement à l\'ajout du compte'; + $t->id_year = $year->id; + + + $credit = $amount > 0 ? 0 : abs($amount); + $debit = $amount < 0 ? 0 : abs($amount); + $t->addLine(Line::create($this->id(), $credit, $debit)); + $t->addLine(Line::create($opening_account, $debit, $credit)); + return $t; + } + +} diff --git a/src/include/lib/Paheko/Entities/Accounting/Chart.php b/src/include/lib/Paheko/Entities/Accounting/Chart.php new file mode 100644 index 0000000..9088734 --- /dev/null +++ b/src/include/lib/Paheko/Entities/Accounting/Chart.php @@ -0,0 +1,192 @@ + 'Belgique', + 'FR' => 'France', + 'CH' => 'Suisse', + ]; + + const REQUIRED_COLUMNS = ['code', 'label', 'description', 'position', 'bookmark']; + + const COLUMNS = [ + 'code' => 'Numéro', + 'label' => 'Libellé', + 'description' => 'Description', + 'position' => 'Position', + 'added' => 'Ajouté', + 'bookmark' => 'Favori', + ]; + + public function selfCheck(): void + { + $this->assert(trim($this->label) !== '', 'Le libellé ne peut rester vide.'); + $this->assert(strlen($this->label) <= 200, 'Le libellé ne peut faire plus de 200 caractères.'); + $this->assert(null === $this->country || array_key_exists($this->country, self::COUNTRY_LIST), 'Pays inconnu'); + parent::selfCheck(); + } + + public function accounts() + { + return new Accounts($this->id()); + } + + public function importForm(array $source = null) + { + if (null === $source) { + $source = $_POST; + } + + // Don't allow to change country + if (isset($this->code)) { + unset($source['country']); + } + + unset($source['code']); + + return Entity::importForm($source); + } + + public function canDelete() + { + return !DB::getInstance()->firstColumn(sprintf('SELECT 1 FROM %s WHERE id_chart = ? LIMIT 1;', Year::TABLE), $this->id()); + } + + public function importCSV(string $file, bool $update = false): void + { + $db = DB::getInstance(); + $positions = array_flip(Account::POSITIONS_NAMES); + $types = array_flip(Account::TYPES_NAMES); + + $db->begin(); + + try { + foreach (CSV::import($file, self::COLUMNS, self::REQUIRED_COLUMNS) as $line => $row) { + $account = null; + + if ($update) { + $account = EntityManager::findOne(Account::class, 'SELECT * FROM @TABLE WHERE code = ? AND id_chart = ?;', $row['code'], $this->id()); + } + + if (!$account) { + $account = new Account; + $account->id_chart = $this->id(); + } + + try { + if (!isset($positions[$row['position']])) { + throw new ValidationException('Position inconnue : ' . $row['position']); + } + // Don't update user-set values + if ($account->exists()) { + unset($row['bookmark'], $row['description']); + } + else { + $row['user'] = !empty($row['added']); + $row['bookmark'] = !empty($row['bookmark']); + } + + $row['position'] = $positions[$row['position']]; + + $account->importForm($row); + $account->save(); + } + catch (ValidationException $e) { + throw new UserException(sprintf('Ligne %d : %s', $line, $e->getMessage())); + } + } + + $db->commit(); + } + catch (\Exception $e) { + $db->rollback(); + throw $e; + } + } + + + /** + * Return all accounts from current chart + */ + public function export(): \Generator + { + $res = DB::getInstance()->iterate('SELECT + code, label, description, position, user, bookmark + FROM acc_accounts WHERE id_chart = ? ORDER BY code COLLATE NOCASE;', + $this->id); + + foreach ($res as $row) { + $row->position = Account::POSITIONS_NAMES[$row->position]; + $row->user = $row->user ? 'Ajouté' : ''; + $row->bookmark = $row->bookmark ? 'Favori' : ''; + yield $row; + } + } + + public function resetAccountsRules(): void + { + $db = DB::getInstance(); + $db->begin(); + + try { + foreach ($this->accounts()->listAll() as $account) { + $account->setLocalRules($this->country); + $account->save(); + } + } + catch (UserException $e) { + $db->rollback(); + throw $e; + } + + $db->commit(); + } + + public function save(bool $selfcheck = true): bool + { + $country_modified = $this->isModified('country'); + $exists = $this->exists(); + + $ok = parent::save($selfcheck); + + // Change account types + if ($ok && $exists && $country_modified) { + $this->resetAccountsRules(); + } + + return $ok; + } + + public function country_code(): ?string + { + if (!$this->code) { + return null; + } + + return strtolower($this->country . '_' . $this->code); + } +} diff --git a/src/include/lib/Paheko/Entities/Accounting/Line.php b/src/include/lib/Paheko/Entities/Accounting/Line.php new file mode 100644 index 0000000..caca76d --- /dev/null +++ b/src/include/lib/Paheko/Entities/Accounting/Line.php @@ -0,0 +1,80 @@ +id_account = $id_account; + $line->credit = $credit; + $line->debit = $debit; + $line->label = $label; + $line->reference = $reference; + + return $line; + } + + public function filterUserValue(string $type, $value, string $key) + { + if ($key == 'credit' || $key == 'debit') { + $value = Utils::moneyToInteger($value); + } + elseif ($key == 'id_project' && $value == 0) { + $value = null; + } + + $value = parent::filterUserValue($type, $value, $key); + + return $value; + } + + public function selfCheck(): void + { + // We don't check that the account exists here + // The fact that the account is in the right chart is checked in Transaction::selfCheck + + $this->assert($this->reference === null || strlen($this->reference) < 200, 'La référence doit faire moins de 200 caractères.'); + $this->assert($this->label === null || strlen($this->label) < 200, 'La référence doit faire moins de 200 caractères.'); + $this->assert($this->id_account !== null, 'Aucun compte n\'a été indiqué.'); + $this->assert($this->credit || $this->debit, 'Aucun montant au débit ou au crédit'); + $this->assert($this->credit >= 0 && $this->debit >= 0, 'Le montant ne peut être négatif'); + $this->assert($this->credit + $this->debit < 100000000000, 'Le montant ne peut être supérieur à un milliard'); + $this->assert(($this->credit * $this->debit) === 0 && ($this->credit + $this->debit) > 0, 'Ligne non équilibrée : crédit ou débit doit valoir zéro.'); + + $this->assert(null === $this->id_project || DB::getInstance()->test(Project::TABLE, 'id = ?', $this->id_project), 'Le projet analytique indiqué n\'existe pas.'); + $this->assert(!empty($this->id_transaction), 'Aucune écriture n\'a été indiquée pour cette ligne.'); + parent::selfCheck(); + } + + public function asDetailsArray(): array + { + return [ + 'Compte' => $this->id_account ? Accounts::getCodeAndLabel($this->id_account) : null, + 'Libellé' => $this->label, + 'Référence' => $this->reference, + 'Crédit' => Utils::money_format($this->credit), + 'Débit' => Utils::money_format($this->debit), + ]; + } + +} diff --git a/src/include/lib/Paheko/Entities/Accounting/Project.php b/src/include/lib/Paheko/Entities/Accounting/Project.php new file mode 100644 index 0000000..a5b679e --- /dev/null +++ b/src/include/lib/Paheko/Entities/Accounting/Project.php @@ -0,0 +1,66 @@ +code) { + $this->assert(trim($this->code) !== '', 'Le numéro de projet est invalide.'); + $this->assert(strlen($this->code) <= 100, 'Le numéro de projet est trop long.'); + $this->assert(preg_match('/^[A-Z0-9_]+$/', $this->code), 'Le numéro de projet ne peut comporter que des lettres majuscules et des chiffres.'); + + $db = DB::getInstance(); + + if ($this->exists()) { + $this->assert(!$db->test(self::TABLE, 'code = ? AND id != ?', $this->code, $this->id()), 'Ce code est déjà utilisé par un autre projet.'); + } + else { + $this->assert(!$db->test(self::TABLE, 'code = ?', $this->code), 'Ce code est déjà utilisé par un autre projet.'); + } + } + + $this->assert(trim($this->label) !== '', 'L\'intitulé de projet ne peut rester vide.'); + $this->assert(strlen($this->label) <= 200, 'L\'intitulé de compte ne peut faire plus de 200 caractères.'); + + if (null !== $this->description) { + $this->assert(trim($this->description) !== '', 'L\'intitulé de projet est invalide.'); + $this->assert(strlen($this->description) <= 2000, 'L\'intitulé de compte ne peut faire plus de 2000 caractères.'); + } + + + parent::selfCheck(); + } + + public function importForm(?array $source = null) + { + if (null === $source) { + $source = $_POST; + } + + if (!empty($source['code'])) { + $source['code'] = strtoupper($source['code']); + } + + $source['archived'] = !empty($source['archived']); + + parent::importForm($source); + } +} diff --git a/src/include/lib/Paheko/Entities/Accounting/Transaction.php b/src/include/lib/Paheko/Entities/Accounting/Transaction.php new file mode 100644 index 0000000..2a650a0 --- /dev/null +++ b/src/include/lib/Paheko/Entities/Accounting/Transaction.php @@ -0,0 +1,1626 @@ + 'En attente de règlement', + 2 => 'Réglé', + 4 => 'Déposé en banque', + ]; + + const TYPES_NAMES = [ + 'Avancé', + 'Recette', + 'Dépense', + 'Virement', + 'Dette', + 'Créance', + ]; + + const LOCKED_PROPERTIES = [ + 'label', + 'reference', + 'date', + 'id_year', + 'prev_id', + 'prev_hash', + ]; + + const LOCKED_LINE_PROPERTIES = [ + 'id_account', + 'debit', + 'credit', + 'label', + 'reference', + ]; + + protected ?int $id; + protected ?int $type = null; + protected int $status = 0; + protected string $label; + protected ?string $notes = null; + protected ?string $reference = null; + + protected Date $date; + + protected ?string $hash = null; + protected ?int $prev_id = null; + protected ?string $prev_hash = null; + + protected int $id_year; + protected ?int $id_creator = null; + + protected $_lines; + protected $_old_lines = []; + + protected $_accounts = []; + protected $_default_selector = []; + + static public function getTypeFromAccountType(int $account_type) + { + switch ($account_type) { + case Account::TYPE_REVENUE: + return self::TYPE_REVENUE; + case Account::TYPE_EXPENSE: + return self::TYPE_EXPENSE; + case Account::TYPE_THIRD_PARTY: + return self::TYPE_DEBT; + case Account::TYPE_BANK: + case Account::TYPE_CASH: + case Account::TYPE_OUTSTANDING: + return self::TYPE_TRANSFER; + default: + return self::TYPE_ADVANCED; + } + } + + public function findTypeFromAccounts(): int + { + if (count($this->getLines()) != 2) { + return self::TYPE_ADVANCED; + } + + $types = []; + + foreach ($this->getLinesWithAccounts() as $line) { + if ($line->account_position == Account::REVENUE && $line->credit) { + $types[] = self::TYPE_REVENUE; + } + elseif ($line->account_position == Account::EXPENSE && $line->debit) { + $types[] = self::TYPE_EXPENSE; + } + } + + // Did not find a expense/revenue account: fall back to advanced + // (or if one line is expense and the other is revenue) + if (count($types) != 1) { + return self::TYPE_ADVANCED; + } + + return current($types); + } + + public function getLinesWithAccounts(bool $as_array = false, bool $amount_as_int = true): array + { + $db = EntityManager::getInstance(Line::class)->DB(); + + // Merge data from accounts with lines + $accounts = []; + $projects = []; + $lines_with_accounts = []; + + foreach ($this->getLines() as $line) { + if (!array_key_exists($line->id_account, $this->_accounts)) { + $accounts[] = $line->id_account; + } + + if ($line->id_project) { + $projects[] = $line->id_project; + } + } + + // Remove NULL accounts + $accounts = array_filter($accounts); + + if (count($accounts)) { + $sql = sprintf('SELECT id, label, code, position FROM acc_accounts WHERE %s;', $db->where('id', 'IN', $accounts)); + // Don't use array_merge here or keys will be lost + $this->_accounts = $this->_accounts + $db->getGrouped($sql); + } + + if (count($projects)) { + $projects = $db->getAssoc(sprintf('SELECT id, label FROM acc_projects WHERE %s;', $db->where('id', $projects))); + } + + foreach ($this->getLines() as &$line) { + $l = $line->asArray(); + $l['account_code'] = $this->_accounts[$line->id_account]->code ?? null; + $l['account_label'] = $this->_accounts[$line->id_account]->label ?? null; + $l['account_position'] = $this->_accounts[$line->id_account]->position ?? null; + $l['project_name'] = $projects[$line->id_project] ?? null; + $l['account_selector'] = [$line->id_account => sprintf('%s — %s', $l['account_code'], $l['account_label'])]; + $l['line'] =& $line; + + if (!$as_array) { + $l = (object) $l; + } + + if (!$amount_as_int) { + $l['debit'] = CommonModifiers::money_raw($l['debit']); + $l['credit'] = CommonModifiers::money_raw($l['credit']); + } + + $lines_with_accounts[] = $l; + } + + unset($line); + + return $lines_with_accounts; + } + + public function getLines(): array + { + if (null === $this->_lines && $this->exists()) { + $em = EntityManager::getInstance(Line::class); + $this->_lines = $em->all('SELECT * FROM @TABLE WHERE id_transaction = ? ORDER BY id;', $this->id); + } + elseif (null === $this->_lines) { + $this->_lines = []; + } + + return $this->_lines; + } + + public function countLines(): int + { + return count($this->getLines()); + } + + public function removeLine(Line $remove) + { + $new = []; + + foreach ($this->getLines() as $line) { + if ($line->id === $remove->id) { + $this->_old_lines[] = $remove; + } + else { + $new[] = $line; + } + } + + $this->_lines = $new; + } + + public function resetLines() + { + $this->_old_lines = $this->getLines(); + $this->_lines = []; + } + + public function getLine(int $id) + { + foreach ($this->getLines() as $line) { + if ($line->id === $id) { + return $line; + } + } + + return null; + } + + public function getCreditLine(): ?Line + { + if ($this->type == self::TYPE_ADVANCED) { + return null; + } + + foreach ($this->getLines() as $line) { + if ($line->credit) { + return $line; + } + } + + return null; + } + + public function getDebitLine(): ?Line + { + if ($this->type == self::TYPE_ADVANCED) { + return null; + } + + foreach ($this->getLines() as $line) { + if ($line->debit) { + return $line; + } + } + + return null; + } + + public function getFirstLine() + { + $lines = $this->getLines(); + + if (!count($lines)) { + return null; + } + + return reset($lines); + } + + public function getLinesCreditSum() + { + $sum = 0; + + foreach ($this->getLines() as $line) { + $sum += $line->credit; + } + + return $sum; + } + + public function getLinesDebitSum() + { + $sum = 0; + + foreach ($this->getLines() as $line) { + $sum += $line->debit; + } + + return $sum; + } + + static public function getFormLines(?array $source = null): array + { + if (null === $source) { + $source = $_POST['lines'] ?? []; + } + + if (empty($source) || !is_array($source)) { + return []; + } + + $lines = Utils::array_transpose($source); + + foreach ($lines as &$line) { + if (isset($line['credit'])) { + $line['credit'] = Utils::moneyToInteger($line['credit']); + } + if (isset($line['debit'])) { + $line['debit'] = Utils::moneyToInteger($line['debit']); + } + } + + unset($line); + + return $lines; + } + + public function hasReconciledLines(): bool + { + foreach ($this->getLines() as $line) { + if (!empty($line->reconciled)) { + return true; + } + } + + return false; + } + + public function getProjectId(): ?int + { + $lines = $this->getLines(); + + if (!count($lines)) { + return null; + } + + $id_project = null; + + foreach ($lines as $line) { + if ($line->id_project != $id_project) { + $id_project = $line->id_project; + break; + } + } + + return $id_project; + } + + /** + * Creates a new Transaction entity (not saved) from an existing one, + * trying to adapt to a different chart if possible + * @param int $id + * @param Year $year Target year + * @return Transaction + */ + public function duplicate(Year $year): Transaction + { + $new = new Transaction; + + $copy = ['type', 'label', 'notes', 'reference']; + + foreach ($copy as $field) { + $new->$field = $this->$field; + } + + $copy = ['credit', 'debit', 'id_account', 'label', 'reference', 'id_project']; + $lines = DB::getInstance()->get('SELECT + l.credit, l.debit, l.label, l.reference, b.id AS id_account, c.id AS id_project + FROM acc_transactions_lines l + INNER JOIN acc_accounts a ON a.id = l.id_account + LEFT JOIN acc_accounts b ON b.code = a.code AND b.id_chart = ? + LEFT JOIN acc_projects c ON c.id = l.id_project + WHERE l.id_transaction = ?;', + $year->chart()->id, + $this->id() + ); + + foreach ($lines as $l) { + $line = new Line; + foreach ($copy as $field) { + // Do not copy id_account when it is null, as it will trigger an error (invalid entity) + if ($field == 'id_account' && !isset($l->$field)) { + continue; + } + + $line->$field = $l->$field; + } + + $new->addLine($line); + } + + // Only set date if valid + if ($this->date >= $year->start_date && $this->date <= $year->end_date) { + $new->date = clone $this->date; + } + + return $new; + } + + public function payment_reference(): ?string + { + $line = current($this->getLines()); + + if (!$line) { + return null; + } + + return $line->reference; + } + + + public function getHash(): string + { + if (!$this->id_year) { + throw new \LogicException('Il n\'est pas possible de hasher un mouvement qui n\'est pas associé à un exercice'); + } + + $hash = hash_init('sha256'); + $values = $this->asArray(true); + $values = array_intersect_key($values, array_flip(self::LOCKED_PROPERTIES)); + + hash_update($hash, implode(',', array_keys($values))); + hash_update($hash, implode(',', $values)); + + foreach ($this->getLines() as $line) { + $values = $line->asArray(true); + $values = array_intersect_key($values, array_flip(self::LOCKED_LINE_PROPERTIES)); + + hash_update($hash, implode(',', array_keys($values))); + hash_update($hash, implode(',', $values)); + } + + return hash_final($hash, false); + } + + public function isVerified(): bool + { + if (!$this->prev_id) { + return false; + } + + if (!$this->prev_hash) { + return false; + } + + return $this->verify(); + } + + public function isLocked(): bool + { + // locking just got set + if ($this->hash && array_key_exists('hash', $this->_modified) && $this->_modified['hash'] === null) { + return false; + } + + return $this->hash === null ? false : true; + } + + public function canSaveChanges(): bool + { + if (!$this->isLocked()) { + return true; + } + + if ($this->isModified('hash')) { + return false; + } + + foreach (self::LOCKED_PROPERTIES as $prop) { + if ($this->isModified($prop)) { + return false; + } + } + + foreach ($this->getLines() as $line) { + foreach (self::LOCKED_LINE_PROPERTIES as $prop) { + if ($line->isModified($prop)) { + return false; + } + } + } + + return true; + } + + public function assertCanBeModified(): void + { + // Allow to change the status + if (count($this->_modified) === 1 && array_key_exists('status', $this->_modified)) { + return; + } + + // We allow to change notes and id_project in a locked transaction + if (!$this->canSaveChanges()) { + throw new ValidationException('Il n\'est pas possible de modifier une écriture qui a été verrouillée'); + } + + $db = DB::getInstance(); + + if (isset($this->id_year) && $db->test(Year::TABLE, 'id = ? AND closed = 1', $this->id_year)) { + throw new ValidationException('Il n\'est pas possible de créer ou modifier une écriture dans un exercice clôturé'); + } + } + + public function verify(): bool + { + return hash_equals($this->getHash(), $this->hash); + } + + public function lock(): void + { + // Select last locked transaction + $prev = DB::getInstance()->first('SELECT MAX(id) AS id, hash FROM acc_transactions WHERE hash IS NOT NULL AND id_year = ?;', $this->id_year); + + $this->set('prev_id', $prev->id ?? null); + $this->set('prev_hash', $prev->hash ?? null); + $this->set('hash', $this->getHash()); + $this->save(); + } + + public function addLine(Line $line) + { + $this->_lines[] = $line; + } + + public function sum(): int + { + $sum = 0; + + foreach ($this->getLines() as $line) { + $sum += $line->credit; + // Because credit == debit, we only use credit + } + + return $sum; + } + + public function save(bool $selfcheck = true): bool + { + if ($this->type == self::TYPE_DEBT || $this->type == self::TYPE_CREDIT) { + // Debts and credits add a waiting status + // Don't use if ($this->exists()) here as the type can be changed on an existing transaction + if (!$this->hasStatus(self::STATUS_PAID)) { + $this->addStatus(self::STATUS_WAITING); + } + } + else { + $this->removeStatus(self::STATUS_WAITING); + } + + $this->assertCanBeModified(); + $this->selfCheck(); + + $lines = $this->getLinesWithAccounts(); + + // Self check lines before saving Transaction + foreach ($lines as $i => $l) { + $line = $l->line; + $line->id_transaction = -1; // Get around validation of id_transaction being not null + + if (empty($l->account_code)) { + throw new ValidationException('Le compte spécifié n\'existe pas.'); + } + + if ($this->type == self::TYPE_EXPENSE && $l->account_position == Account::REVENUE) { + throw new ValidationException(sprintf('Line %d : il n\'est pas possible d\'attribuer un compte de produit (%s) à une dépense', $i+1, $l->account_code)); + } + + if ($this->type == self::TYPE_REVENUE && $l->account_position == Account::EXPENSE) { + throw new ValidationException(sprintf('Line %d : il n\'est pas possible d\'attribuer un compte de charge (%s) à une recette', $i+1, $l->account_code)); + } + + try { + $line->selfCheck(); + } + catch (ValidationException $e) { + // Add line number to message + throw new ValidationException(sprintf('Ligne %d : %s', $i+1, $e->getMessage()), 0, $e); + } + } + + if ($this->exists() && $this->status & self::STATUS_ERROR) { + // Remove error status when changed + $this->removeStatus(self::STATUS_ERROR); + } + + $db = DB::getInstance(); + $db->begin(); + + if (!parent::save()) { + return false; + } + + foreach ($lines as $line) { + $line = $line->line; // Fetch real object + $line->id_transaction = $this->id(); + $line->save(false); + } + + foreach ($this->_old_lines as $line) { + if ($line->exists()) { + $line->delete(); + } + } + + $db->commit(); + + return true; + } + + public function removeStatus(int $property) { + $this->set('status', $this->status & ~$property); + } + + public function addStatus(int $property) { + $this->set('status', $this->status | $property); + } + + public function hasStatus(int $property): bool + { + return $this->status & $property; + } + + public function isPaid(): bool + { + return $this->hasStatus(self::STATUS_PAID); + } + + public function markPaid() { + $this->removeStatus(self::STATUS_WAITING); + $this->addStatus(self::STATUS_PAID); + } + + public function isWaiting(): bool + { + if ($this->type !== self::TYPE_DEBT && $this->type !== self::TYPE_CREDIT) { + return false; + } + + return $this->hasStatus(self::STATUS_WAITING); + } + + public function delete(): bool + { + if ($this->validated) { + throw new ValidationException('Il n\'est pas possible de supprimer une écriture qui a été validée'); + } + + $db = DB::getInstance(); + + if ($db->test(Year::TABLE, 'id = ? AND closed = 1', $this->id_year)) { + throw new ValidationException('Il n\'est pas possible de supprimer une écriture qui fait partie d\'un exercice clôturé'); + } + + // FIXME when lettering is properly implemented: mark parent transaction non-deposited when deleting a deposit transaction + + Files::delete($this->getAttachementsDirectory()); + + return parent::delete(); + } + + public function selfCheck(): void + { + $db = DB::getInstance(); + + $this->assert(!empty($this->id_year), 'L\'ID de l\'exercice doit être renseigné.'); + + $this->assert(!empty($this->label) && trim((string)$this->label) !== '', 'Le champ libellé ne peut rester vide.'); + $this->assert(strlen($this->label) <= 200, 'Le champ libellé ne peut faire plus de 200 caractères.'); + $this->assert(!isset($this->reference) || strlen($this->reference) <= 200, 'Le champ numéro de pièce comptable ne peut faire plus de 200 caractères.'); + $this->assert(!isset($this->notes) || strlen($this->notes) <= 2000, 'Le champ remarques ne peut faire plus de 2000 caractères.'); + $this->assert(!empty($this->date), 'Le champ date ne peut rester vide.'); + + $this->assert(null !== $this->id_year, 'Aucun exercice spécifié.'); + $this->assert(array_key_exists($this->type, self::TYPES_NAMES), 'Type d\'écriture inconnu : ' . $this->type); + $this->assert(null === $this->id_creator || $db->test('users', 'id = ?', $this->id_creator), 'Le membre créateur de l\'écriture n\'existe pas ou plus'); + + $is_in_year = $db->test(Year::TABLE, 'id = ? AND start_date <= ? AND end_date >= ?', $this->id_year, $this->date->format('Y-m-d'), $this->date->format('Y-m-d')); + + if (!$is_in_year) { + $year = Years::get($this->id_year); + throw new ValidationException(sprintf('La date (%s) de l\'écriture ne correspond pas à l\'exercice "%s" : la date doit être entre le %s et le %s.', + Utils::shortDate($this->date), + $year->label ?? '', + Utils::shortDate($year->start_date), + Utils::shortDate($year->end_date) + )); + } + + $total = 0; + + $lines = $this->getLines(); + $count = count($lines); + + $this->assert($count > 0, 'Cette écriture ne comporte aucune ligne.'); + $this->assert($count >= 2, 'Cette écriture comporte moins de deux lignes.'); + $this->assert($count == 2 || $this->type == self::TYPE_ADVANCED, sprintf('Une écriture de type "%s" ne peut comporter que deux lignes au maximum.', self::TYPES_NAMES[$this->type])); + + $accounts_ids = []; + $chart_id = $db->firstColumn('SELECT id_chart FROM acc_years WHERE id = ?;', $this->id_year); + + foreach ($lines as $k => $line) { + $k = $k+1; + $this->assert(!empty($line->id_account), sprintf('Ligne %d: aucun compte n\'est défini', $k)); + $this->assert($line->credit || $line->debit, sprintf('Ligne %d: Aucun montant au débit ou au crédit', $k)); + $this->assert($line->credit >= 0 && $line->debit >= 0, sprintf('Ligne %d: Le montant ne peut être négatif', $k)); + $this->assert(($line->credit * $line->debit) === 0 && ($line->credit + $line->debit) > 0, sprintf('Ligne %d: non équilibrée, crédit ou débit doit valoir zéro.', $k)); + $this->assert($db->test(Account::TABLE, 'id = ? AND id_chart = ?', $line->id_account, $chart_id), sprintf('Ligne %d: le compte spécifié n\'est pas lié au bon plan comptable', $k)); + + $total += $line->credit; + $total -= $line->debit; + } + + // check that transaction type is respected, or fall back to advanced + if ($this->type != self::TYPE_ADVANCED) { + $details = $this->getDetails(); + + foreach ($details as $detail) { + $line = $detail->direction == 'credit' ? $this->getCreditLine() : $this->getDebitLine(); + $ok = $db->test(Account::TABLE, 'id = ? AND ' . $db->where('type', $detail->targets), $line->id_account); + + if (!$ok) { + $this->set('type', self::TYPE_ADVANCED); + break; + } + } + } + + $this->assert(0 === $total, sprintf('Écriture non équilibrée : déséquilibre (%s) entre débits et crédits', Utils::money_format($total))); + + // Foreign keys constraints will check for validity of id_creator and id_year + + parent::selfCheck(); + } + + public function importFromDepositForm(?array $source = null): void + { + if (null === $source) { + $source = $_POST; + } + + if (empty($source['amount'])) { + throw new UserException('Montant non précisé'); + } + + $this->type = self::TYPE_ADVANCED; + $amount = $source['amount']; + + $account = Form::getSelectorValue($source['account_transfer']); + + if (!$account) { + throw new ValidationException('Aucun compte de dépôt n\'a été sélectionné'); + } + + $line = new Line; + $line->importForm([ + 'debit' => $amount, + 'credit' => 0, + 'id_account' => $account, + ]); + + $this->addLine($line); + + $this->importForm($source); + } + + public function importForm(array $source = null) + { + $source ??= $_POST; + + // Transpose lines (HTML transaction forms) + if (!empty($source['lines']) && is_array($source['lines']) && is_string(key($source['lines']))) { + try { + $source['lines'] = Utils::array_transpose($source['lines']); + } + catch (\InvalidArgumentException $e) { + throw new ValidationException('Aucun compte sélectionné pour certaines lignes.'); + } + + unset($source['_lines']); + } + + if (isset($source['type'])) { + $this->set('type', (int)$source['type']); + } + + // Simple two-lines transaction + if (isset($source['amount']) && $this->type != self::TYPE_ADVANCED && isset($this->type)) { + if (empty($source['amount'])) { + throw new ValidationException('Montant non précisé'); + } + + $accounts = $this->getTypesDetails($source)[$this->type]->accounts; + + // either supply debit/credit keys or simple accounts + if (!isset($source['debit'], $source['credit'])) { + foreach ($accounts as $account) { + if (empty($account->selector_value)) { + throw new ValidationException(sprintf('%s : aucun compte n\'a été sélectionné', $account->label)); + } + } + } + + $line = [ + 'reference' => $source['payment_reference'] ?? null, + ]; + + $source['lines'] = [ + $line + [ + $accounts[0]->direction => $source['amount'], + $accounts[1]->direction => 0, + 'account_selector' => $accounts[0]->selector_value, + 'account' => $source[$accounts[0]->direction] ?? null, + ], + $line + [ + $accounts[1]->direction => $source['amount'], + $accounts[0]->direction => 0, + 'account_selector' => $accounts[1]->selector_value, + 'account' => $source[$accounts[1]->direction] ?? null, + ], + ]; + + if ($this->type != self::TYPE_TRANSFER || Config::getInstance()->analytical_set_all) { + $source['lines'][0]['id_project'] = $source['id_project'] ?? null; + } + + if (Config::getInstance()->analytical_set_all) { + $source['lines'][1]['id_project'] = $source['lines'][0]['id_project']; + } + + unset($line, $accounts, $account, $source['simple']); + } + + // Add/update lines objects + if (isset($source['lines']) && is_array($source['lines'])) { + $lines = $this->getLines(); + $db = DB::getInstance(); + + foreach ($source['lines'] as $i => $line) { + if (isset($line['account_selector'])) { + $line['id_account'] = Form::getSelectorValue($line['account_selector']); + } + elseif (isset($line['account'])) { + if (empty($this->id_year) && empty($source['id_year'])) { + throw new ValidationException('L\'identifiant de l\'exercice comptable n\'est pas précisé.'); + } + + $id_chart = $id_chart ?? $db->firstColumn('SELECT id_chart FROM acc_years WHERE id = ?;', $source['id_year'] ?? $this->id_year); + $line['id_account'] = $db->firstColumn('SELECT id FROM acc_accounts WHERE code = ? AND id_chart = ?;', $line['account'], $id_chart); + + if (empty($line['id_account'])) { + throw new ValidationException(sprintf('Le compte avec le code "%s" sur la ligne %d n\'existe pas.', $line['account'], $i+1)); + } + } + + if (empty($line['id_account'])) { + throw new ValidationException(sprintf('Ligne %d : aucun compte n\'a été sélectionné', $i + 1)); + } + + + if (array_key_exists($i, $lines)) { + $new = false; + $l = $lines[$i]; + } + else { + $new = true; + $l = new Line; + } + + $l->importForm($line); + + if ($l->isModified('debit') || $l->isModified('credit') || $l->isModified('id_account')) { + $l->set('reconciled', false); + } + + if ($new) { + $this->addLine($l); + } + } + + // Remove extra lines + if (count($lines) > count($source['lines'])) { + $max = count($source['lines']); + + foreach ($lines as $j => $line) { + if ($j >= $max) { + $this->_old_lines[] = $line; + unset($this->_lines[$j]); + } + } + + // reset array indexes + $this->_lines = array_values($this->_lines); + } + } + + return parent::importForm($source); + } + + public function importFromNewForm(?array $source = null): void + { + $source ??= $_POST; + + $type = $source['type'] ?? ($this->type ?? self::TYPE_ADVANCED); + + if (self::TYPE_ADVANCED != $type && !isset($source['amount'])) { + throw new UserException('Montant non précisé'); + } + + $this->importForm($source); + } + + public function importFromAPI(?array $source = null): void + { + $source ??= $_POST; + + if (isset($source['type']) && ctype_alpha($source['type']) && defined(self::class . '::TYPE_' . strtoupper($source['type']))) { + $source['type'] = constant(self::class . '::TYPE_' . strtoupper($source['type'])); + } + + if (isset($source['id_year'])) { + $y = $source['id_year']; + + if ($source['id_year'] === 'current') { + $source['id_year'] = Years::getCurrentOpenYearId(); + } + elseif ($source['id_year'] === 'match') { + if (isset($source['date'])) { + $date = self::filterUserDateValue($source['date']); + } + else { + $date = null; + } + + $source['id_year'] = Years::getMatchingOpenYearId($date); + } + + if (!$source['id_year']) { + throw new UserException(sprintf('Cannot find a valid open year matching "%s"', $y)); + } + } + + $this->importFromNewForm($source); + } + + public function importFromPayoffForm(\stdClass $payoff, ?array $source = null): void + { + $source ??= $_POST; + + if ($source['type'] == 99) { + // Just make sure we can't trigger importFromNewForm + unset($source['lines']); + + $id_project = isset($source['id_project']) ? intval($source['id_project']) : null; + $source['type'] = self::TYPE_ADVANCED; + + if (!$payoff->multiple) { + if (empty($source['amount'])) { + throw new ValidationException('Montant non précisé'); + } + + $amount = Utils::moneyToInteger($source['amount']); + + foreach ($this->getLines() as $line) { + if ($line->debit != 0) { + $line->set('debit', $amount); + } + else { + $line->set('credit', $amount); + } + } + } + + if (empty($source['payoff_account']) || !is_array($source['payoff_account'])) { + throw new ValidationException('Aucun compte de règlement sélectionné.'); + } + + $payoff->payment_line->set('id_account', (int)key($source['payoff_account'])); + $payoff->payment_line->set('reference', $source['payment_reference'] ?? null); + $payoff->payment_line->set('id_project', $id_project); + + if (Config::getInstance()->analytical_set_all) { + foreach ($this->getLines() as $line) { + $line->set('id_project', $id_project); + } + } + + $source['lines'] = $this->getLinesWithAccounts(true, false); + } + + $this->importFromNewForm($source); + } + + public function importFromBalanceForm(Year $year, ?array $source = null): void + { + $source ??= $_POST; + + $this->label = 'Balance d\'ouverture'; + $this->date = $year->start_date; + $this->id_year = $year->id(); + $this->type = self::TYPE_ADVANCED; + $this->addStatus(self::STATUS_OPENING_BALANCE); + + $this->importFromNewForm($source); + + $diff = $this->getLinesCreditSum() - $this->getLinesDebitSum(); + + if (!$diff) { + return; + } + + // Add final balance line + $line = new Line; + + if ($diff > 0) { + $line->debit = $diff; + } + else { + $line->credit = abs($diff); + } + + $open_account = EntityManager::findOne(Account::class, 'SELECT * FROM @TABLE WHERE id_chart = ? AND type = ? LIMIT 1;', $year->id_chart, Account::TYPE_OPENING); + + if (!$open_account) { + throw new ValidationException('Aucun compte de bilan d\'ouverture n\'existe dans le plan comptable'); + } + + $line->id_account = $open_account->id(); + + $this->addLine($line); + } + + public function year() + { + return EntityManager::findOneById(Year::class, $this->id_year); + } + + public function listFiles() + { + return Files::list($this->getAttachementsDirectory()); + } + + public function getAttachementsDirectory(): string + { + return File::CONTEXT_TRANSACTION . '/' . $this->id(); + } + + public function setDefaultAccount(int $type, string $direction, int $id): void + { + $this->_default_selector[$type][$direction] = Accounts::getSelector($id); + } + + /** + * Return tuples of accounts selectors according to each "simplified" type + */ + public function getTypesDetails(?array $source = null) + { + if (null === $source) { + $source = $_POST; + } + + $details = [ + self::TYPE_REVENUE => [ + 'accounts' => [ + [ + 'label' => 'Type de recette', + 'targets' => [Account::TYPE_REVENUE], + 'direction' => 'credit', + 'defaults' => [ + self::TYPE_CREDIT => 'credit', + ], + ], + [ + 'label' => 'Compte d\'encaissement', + 'targets' => [Account::TYPE_BANK, Account::TYPE_CASH, Account::TYPE_OUTSTANDING], + 'direction' => 'debit', + 'defaults' => [ + self::TYPE_EXPENSE => 'credit', + self::TYPE_TRANSFER => 'credit', + ], + ], + ], + 'label' => self::TYPES_NAMES[self::TYPE_REVENUE], + ], + self::TYPE_EXPENSE => [ + 'accounts' => [ + [ + 'label' => 'Type de dépense', + 'targets' => [Account::TYPE_EXPENSE], + 'direction' => 'debit', + 'defaults' => [ + self::TYPE_DEBT => 'debit', + ], + ], + [ + 'label' => 'Compte de décaissement', + 'targets' => [Account::TYPE_BANK, Account::TYPE_CASH, Account::TYPE_OUTSTANDING], + 'direction' => 'credit', + 'defaults' => [ + self::TYPE_REVENUE => 'debit', + self::TYPE_TRANSFER => 'credit', + ], + ], + ], + 'label' => self::TYPES_NAMES[self::TYPE_EXPENSE], + 'help' => null, + ], + self::TYPE_TRANSFER => [ + 'accounts' => [ + [ + 'label' => 'De', + 'targets' => [Account::TYPE_BANK, Account::TYPE_CASH, Account::TYPE_OUTSTANDING, Account::TYPE_TEMPORARY_TRANSFER], + 'direction' => 'credit', + 'defaults' => [ + self::TYPE_EXPENSE => 'credit', + self::TYPE_REVENUE => 'debit', + ], + ], + [ + 'label' => 'Vers', + 'targets' => [Account::TYPE_BANK, Account::TYPE_CASH, Account::TYPE_OUTSTANDING, Account::TYPE_TEMPORARY_TRANSFER], + 'direction' => 'debit', + ], + ], + 'label' => self::TYPES_NAMES[self::TYPE_TRANSFER], + 'help' => 'Dépôt en banque, virement interne, etc.', + ], + self::TYPE_DEBT => [ + 'accounts' => [ + [ + 'label' => 'Type de dette (dépense)', + 'targets' => [Account::TYPE_EXPENSE], + 'direction' => 'debit', + 'defaults' => [ + self::TYPE_EXPENSE => 'debit', + ], + ], + [ + 'label' => 'Compte de tiers', + 'targets' => [Account::TYPE_THIRD_PARTY], + 'direction' => 'credit', + 'defaults' => [ + self::TYPE_CREDIT => 'debit', + ], + ], + ], + 'label' => self::TYPES_NAMES[self::TYPE_DEBT], + 'help' => 'Quand l\'association doit de l\'argent à un membre ou un fournisseur', + ], + self::TYPE_CREDIT => [ + 'accounts' => [ + [ + 'label' => 'Type de créance (recette)', + 'targets' => [Account::TYPE_REVENUE], + 'direction' => 'credit', + 'defaults' => [ + self::TYPE_REVENUE => 'credit', + ], + ], + [ + 'label' => 'Compte de tiers', + 'targets' => [Account::TYPE_THIRD_PARTY], + 'direction' => 'debit', + 'defaults' => [ + self::TYPE_DEBT => 'credit', + ], + ], + ], + 'label' => self::TYPES_NAMES[self::TYPE_CREDIT], + 'help' => 'Quand un membre ou un client doit de l\'argent à l\'association', + ], + self::TYPE_ADVANCED => [ + 'accounts' => [], + 'label' => self::TYPES_NAMES[self::TYPE_ADVANCED], + 'help' => 'Choisir les comptes du plan comptable, ventiler une écriture sur plusieurs comptes, etc.', + ], + ]; + + // Find out which lines are credit and debit + $current_accounts = []; + + foreach ($this->getLinesWithAccounts() as $i => $l) { + if ($l->debit) { + $current_accounts['debit'] = $l->account_selector; + } + elseif ($l->credit) { + $current_accounts['credit'] = $l->account_selector; + } + + if (count($current_accounts) == 2) { + break; + } + } + + foreach ($details as $key => &$type) { + $type = (object) $type; + $type->id = $key; + foreach ($type->accounts as &$account) { + $account = (object) $account; + $account->targets_string = implode(':', $account->targets); + $account->selector_name = sprintf('simple[%s][%s]', $key, $account->direction); + + $d = null; + + // Copy selector value for current type + if ($type->id == $this->type) { + $d = $account->direction; + } + else { + $d = $account->defaults[$this->type] ?? null; + } + + if ($d) { + $account->selector_value = $source['simple'][$key][$d] ?? ($current_accounts[$d] ?? null); + } + + if (empty($account->selector_value) && isset($this->_default_selector[$key][$account->direction])) { + $account->selector_value = $this->_default_selector[$key][$account->direction]; + } + + $account->id = isset($account->selector_value) ? key($account->selector_value) : null; + $account->name = isset($account->selector_value) ? current($account->selector_value) : null; + } + } + + unset($account, $type); + + return $details; + } + + public function getDetails(): ?array + { + if ($this->type == self::TYPE_ADVANCED) { + return null; + } + + $details = $this->getTypesDetails(); + + return [ + 'left' => $details[$this->type]->accounts[0], + 'right' => $details[$this->type]->accounts[1], + ]; + } + + public function payOffFrom(Transaction $transaction): ?\stdClass + { + $this->_related = $transaction; + $this->id_related = $this->_related->id(); + $this->label = ($this->_related->type == Transaction::TYPE_DEBT ? 'Règlement de dette : ' : 'Règlement de créance : ') . $this->_related->label; + $this->type = self::TYPE_ADVANCED; + + $out = (object) [ + 'id' => $this->_related->id, + 'amount' => $this->_related->sum(), + 'id_project' => $this->_related->getProjectId(), + 'type' => $this->_related->type, + ]; + + return $out; + } + + public function getTypeName(): string + { + return self::TYPES_NAMES[$this->type]; + } + + public function asDetailsArray(bool $modified = false): array + { + $lines = []; + $debit = 0; + $credit = 0; + + foreach ($this->getLines() as $i => $line) { + $lines[$i+1] = $line->asDetailsArray(); + + $debit += $line->debit; + $credit +=$line->credit; + } + + $src = $this->asArray(); + + return [ + 'Numéro' => $src['id'] ?? '--', + 'Type' => self::TYPES_NAMES[$src['type'] ?? self::TYPE_ADVANCED], + 'Libellé' => $src['label'] ?? null, + 'Date' => isset($src['date']) ? $src['date']->format('d/m/Y') : null, + 'Pièce comptable' => $src['reference'] ?? null, + 'Remarques' => $src['notes'] ?? null, + 'Total crédit' => Utils::money_format($debit), + 'Total débit' => Utils::money_format($credit), + 'Lignes' => $lines, + ]; + } + + public function asJournalArray(): array + { + $out = $this->asArray(); + + if ($this->exists()) { + $out['url'] = $this->url(); + } + + $out['lines'] = $this->getLinesWithAccounts(); + foreach ($out['lines'] as &$line) { + unset($line->line); + } + unset($line); + return $out; + } + + /** + * Compare transaction, to see if something has changed + */ + public function diff(): ?array + { + $out = [ + 'transaction' => [], + 'lines' => [], + 'lines_new' => [], + 'lines_removed' => [], + ]; + + foreach ($this->_modified as $key => $old) { + $out['transaction'][$key] = [$old, $this->$key]; + } + + static $keys = [ + 'id_account' => 'Numéro de compte', + 'label' => 'Libellé ligne', + 'reference' => 'Référence ligne', + 'credit' => 'Crédit', + 'debit' => 'Débit', + 'id_project' => 'Projet', + ]; + + $new_lines = []; + $old_lines = []; + + foreach ($this->getLines() as $i => $line) { + if ($line->exists()) { + $diff = []; + + foreach ($keys as $key => $label) { + if ($line->isModified($key)) { + $diff[$key] = [$line->getModifiedProperty($key), $line->$key]; + } + } + + if (count($diff)) { + if (isset($diff['id_project'])) { + $diff['project'] = [Projects::getName($diff['id_project'][0]), Projects::getName($diff['id_project'][1])]; + } + + if (isset($diff['id_account'])) { + $diff['account'] = [Accounts::getCodeAndLabel($diff['id_account'][0]), Accounts::getCodeAndLabel($diff['id_account'][1])]; + } + } + + $l = array_merge($line->asArray(), compact('diff')); + + $l['account'] = Accounts::getCodeAndLabel($l['id_account']); + $l['project'] = Projects::getName($l['id_project']); + + $out['lines'][$i] = $l; + } + else { + $new_line = []; + + foreach ($keys as $key => $label) { + $new_line[$key] = $line->$key; + } + + $new_lines[] = $new_line; + } + } + + foreach ($this->_old_lines as $line) { + $old_line = []; + + foreach ($keys as $key => $label) { + $old_line[$key] = $line->$key; + } + + $old_lines[] = $old_line; + } + + // Append new lines and changed lines + foreach ($new_lines as $i => $new_line) { + if (!in_array($new_line, $old_lines)) { + $new_line['account'] = Accounts::getCodeAndLabel($new_line['id_account']); + $new_line['project'] = Projects::getName($new_line['id_project']); + $out['lines_new'][] = $new_line; + } + } + + // Append removed lines + foreach ($old_lines as $i => $old_line) { + if (!in_array($old_line, $new_lines)) { + $old_line['account'] = Accounts::getCodeAndLabel($old_line['id_account']); + $old_line['project'] = Projects::getName($old_line['id_project']); + $out['lines_removed'][] = $old_line; + } + } + + if (!count($out['transaction']) && !count($out['lines']) && !count($out['lines_new']) && !count($out['lines_removed'])) { + return null; + } + + return $out; + } + + public function url(): string + { + return Utils::getLocalURL('!acc/transactions/details.php?id=' . $this->id()); + } + + public function getProject(): ?array + { + $id = $this->getProjectId(); + + if (!$id) { + return null; + } + + $name = Projects::getName($id); + return compact('id', 'name'); + } + + public function getPaymentReference(): ?string + { + foreach ($this->getLines() as $line) { + if ($line->reference) { + return $line->reference; + } + } + + return null; + } + + /** + * Quick-fill transaction from query parameters + */ + public function setDefaultsFromQueryString(Accounts $accounts): ?array + { + if (!empty($_POST)) { + return null; + } + + $amount = null; + $id_project = null; + $lines = [[], []]; + $linked_users = null; + + // a = amount, in single currency units + if (isset($_GET['a'])) { + $amount = Utils::moneyToInteger($_GET['a']); + } + + // a00 = Amount, in cents + if (isset($_GET['a00'])) { + $amount = (int)$_GET['a00']; + } + + // l = label + if (isset($_GET['l'])) { + $this->set('label', $_GET['l']); + } + + // r = reference + if (isset($_GET['r'])) { + $this->set('reference', $_GET['r']); + } + + // dt = date + if (isset($_GET['dt'])) { + $date = Entity::filterUserDateValue($_GET['dt']); + + if (null !== $date && $date instanceof Date) { + $this->set('date', $date); + } + } + + // t = type + if (isset($_GET['t'])) { + $this->set('type', (int) $_GET['t']); + } + + if (isset($_GET['p'])) { + $id_project = (int) $_GET['p']; + } + + static $bank_types = [Account::TYPE_BANK, Account::TYPE_CASH, Account::TYPE_OUTSTANDING]; + + // ab = Bank/cash account + if (isset($_GET['ab']) + && ($a = $accounts->getWithCode($_GET['ab'])) + && in_array($a->type, $bank_types)) { + $this->setDefaultAccount(self::TYPE_REVENUE, 'debit', $a->id); + $this->setDefaultAccount(self::TYPE_EXPENSE, 'credit', $a->id); + $this->setDefaultAccount(self::TYPE_TRANSFER, 'credit', $a->id); + } + + // ar = Revenue account + if (isset($_GET['ar']) + && ($a = $accounts->getWithCode($_GET['ar'])) + && $a->type == $a::TYPE_REVENUE) { + $this->setDefaultAccount(self::TYPE_REVENUE, 'credit', $a->id); + $this->setDefaultAccount(self::TYPE_CREDIT, 'credit', $a->id); + } + + // ae = Expense account + if (isset($_GET['ae']) + && ($a = $accounts->getWithCode($_GET['ae'])) + && $a->type == $a::TYPE_EXPENSE) { + $this->setDefaultAccount(self::TYPE_EXPENSE, 'debit', $a->id); + $this->setDefaultAccount(self::TYPE_DEBT, 'debit', $a->id); + } + + // at = Transfer account + if (isset($_GET['at']) + && ($a = $accounts->getWithCode($_GET['at'])) + && in_array($a->type, $bank_types)) { + $this->setDefaultAccount(self::TYPE_TRANSFER, 'debit', $a->id); + } + + // a3 = Third-party account + if (isset($_GET['a3']) + && ($a = $accounts->getWithCode($_GET['a3'])) + && $a->type == $a::TYPE_THIRD_PARTY) { + $this->setDefaultAccount(self::TYPE_CREDIT, 'debit', $a->id); + $this->setDefaultAccount(self::TYPE_DEBT, 'credit', $a->id); + } + + // Pre-fill from lllines + if (isset($_GET['ll']) && is_array($_GET['ll'])) { + $lines = []; + foreach ($_GET['ll'] as $l) { + $lines[] = [ + 'debit' => $l['d0'] ?? Utils::moneyToInteger($l['d'] ?? ''), + 'credit' => $l['c0'] ?? Utils::moneyToInteger($l['c'] ?? ''), + 'account_selector' => $accounts->getSelectorFromCode($l['a'] ?? null), + 'label' => $l['l'] ?? null, + 'reference' => $l['r'] ?? null, + ]; + } + + // Make sure we have at least two lines + $lines = array_merge($lines, array_fill(0, max(0, 2 - count($lines)), [])); + } + + if (isset($_GET['u'])) { + $linked_users = []; + $i = 0; + + foreach ((array) $_GET['u'] as $key => $value) { + if ($key != $i++ && $value) { + $id = (int) $key; + $linked_services[$id] = (int) $value; + } + else { + $id = (int) $value; + } + + $name = Users::getName($id); + + if ($name) { + $linked_users[$id] = $name; + } + } + } + + if (isset($_GET['pr'])) { + $_POST['payment_reference'] = trim($_GET['pr']); + } + + return compact('lines', 'id_project', 'amount', 'linked_users'); + } + + public function saveLinks(?array $source = null): void + { + $source ??= $_POST; + + if (empty($source['users'])) { + $this->deleteLinkedUsers(); + } + elseif (is_array($source['users']) && count($source['users'])) { + $this->updateLinkedUsers(array_keys($source['users'])); + } + + if (empty($source['linked'])) { + $this->deleteLinkedTransactions(); + } + elseif (is_array($source['linked']) && count($source['linked'])) { + $this->updateLinkedTransactions(array_keys($source['linked'])); + } + } +} \ No newline at end of file diff --git a/src/include/lib/Paheko/Entities/Accounting/TransactionLinksTrait.php b/src/include/lib/Paheko/Entities/Accounting/TransactionLinksTrait.php new file mode 100644 index 0000000..d694693 --- /dev/null +++ b/src/include/lib/Paheko/Entities/Accounting/TransactionLinksTrait.php @@ -0,0 +1,60 @@ +DB(); + $db->delete('acc_transactions_links', 'id_transaction = ? OR id_related = ?', $this->id(), $this->id()); + } + + public function updateLinkedTransactions(array $ids): void + { + $ids = array_values($ids); + $ids = array_map('intval', $ids); + + $db = EntityManager::getInstance(self::class)->DB(); + + $db->begin(); + $this->deleteLinkedTransactions(); + + foreach ($ids as $id) { + $db->preparedQuery('INSERT OR IGNORE INTO acc_transactions_links (id_transaction, id_related) VALUES (?, ?);', $this->id(), (int)$id); + } + + $db->commit(); + } + + public function linkToTransaction(int $id): void + { + $db = EntityManager::getInstance(self::class)->DB(); + + if ($db->test(self::TABLE, 'id_transaction = ? OR id_related = ?', $this->id, $this->id)) { + return; + } + + $params = ['id_transaction' => $this->id(), 'id_related' => $this->id()]; + + $db->insert(self::TABLE, $params); + } + + public function listLinkedTransactions() + { + return EntityManager::getInstance(self::class)->all('SELECT t.* + FROM @TABLE AS t + INNER JOIN acc_transactions_links AS l ON (l.id_transaction = t.id OR l.id_related = t.id) + WHERE (l.id_transaction = ? OR l.id_related = ?) AND t.id != ? ORDER BY t.id;', $this->id(), $this->id(), $this->id()); + } + + public function listLinkedTransactionsAssoc() + { + return EntityManager::getInstance(self::class)->DB()->getAssoc('SELECT t.id, t.id + FROM acc_transactions AS t + INNER JOIN acc_transactions_links AS l ON (l.id_transaction = t.id OR l.id_related = t.id) + WHERE (l.id_transaction = ? OR l.id_related = ?) AND t.id != ? GROUP BY t.id ORDER BY t.id;', $this->id(), $this->id(), $this->id()); + } +} diff --git a/src/include/lib/Paheko/Entities/Accounting/TransactionSubscriptionsTrait.php b/src/include/lib/Paheko/Entities/Accounting/TransactionSubscriptionsTrait.php new file mode 100644 index 0000000..ef7b26a --- /dev/null +++ b/src/include/lib/Paheko/Entities/Accounting/TransactionSubscriptionsTrait.php @@ -0,0 +1,74 @@ +DB(); + + return $db->preparedQuery('REPLACE INTO acc_transactions_users (id_transaction, id_user, id_service_user) + SELECT ?, id_user, id FROM services_users WHERE id = ?;', + $this->id(), + $id_subscription + ); + } + + public function deleteAllSubscriptionLinks(): void + { + $db = EntityManager::getInstance(self::class)->DB(); + $db->delete('acc_transactions_users', 'id_transaction = ? AND id_service_user IS NOT NULL', $this->id()); + } + + public function deleteSubscriptionLink(int $id): void + { + $db = EntityManager::getInstance(self::class)->DB(); + $db->delete('acc_transactions_users', 'id_transaction = ? AND id_service_user = ?', $this->id(), $id); + } + + public function listSubscriptionLinks(): array + { + $db = EntityManager::getInstance(self::class)->DB(); + $identity_column = DynamicFields::getNameFieldsSQL('u'); + $number_column = DynamicFields::getNumberFieldSQL('u'); + $sql = sprintf('SELECT s.*, %s AS user_identity, %s AS user_number, l.id_service_user AS id_subscription + FROM users u + INNER JOIN acc_transactions_users l ON l.id_user = u.id + INNER JOIN services_users s ON s.id = l.id_service_user + WHERE l.id_transaction = ? AND l.id_service_user IS NOT NULL;', $identity_column, $number_column); + return $db->get($sql, $this->id()); + } + + public function updateSubscriptionLinks(array $subscriptions): void + { + $subscriptions = array_values($subscriptions); + + foreach ($subscriptions as $i => $subscription) { + if (!(is_int($subscription) || (is_string($subscription) && ctype_digit($subscription)))) { + throw new ValidationException(sprintf('Array item #%d: "%s" is not a valid subscription ID', $i, $subscription)); + } + } + + $db = EntityManager::getInstance(self::class)->DB(); + + $db->begin(); + $this->deleteAllSubscriptionLinks(); + + foreach ($subscriptions as $id) { + $db->preparedQuery('INSERT OR IGNORE INTO acc_transactions_users (id_transaction, id_user, id_service_user) + SELECT ?, id_user, id FROM services_users WHERE id = ?;', + $this->id(), + (int)$id + ); + } + + $db->commit(); + } +} diff --git a/src/include/lib/Paheko/Entities/Accounting/TransactionUsersTrait.php b/src/include/lib/Paheko/Entities/Accounting/TransactionUsersTrait.php new file mode 100644 index 0000000..1b405bf --- /dev/null +++ b/src/include/lib/Paheko/Entities/Accounting/TransactionUsersTrait.php @@ -0,0 +1,61 @@ +DB(); + $db->delete('acc_transactions_users', 'id_transaction = ? AND id_service_user IS NULL', $this->id()); + } + + public function updateLinkedUsers(array $users): void + { + $users = array_values($users); + + foreach ($users as $i => $user) { + if (!(is_int($user) || (is_string($user) && ctype_digit($user)))) { + throw new ValidationException(sprintf('Array item #%d: "%s" is not a valid user ID', $i, $user)); + } + } + + $db = EntityManager::getInstance(self::class)->DB(); + + $db->begin(); + $this->deleteLinkedUsers(); + + foreach ($users as $id) { + $db->preparedQuery('INSERT OR IGNORE INTO acc_transactions_users (id_transaction, id_user, id_service_user) VALUES (?, ?, NULL);', $this->id(), (int)$id); + } + + $db->commit(); + } + + public function listLinkedUsers(): array + { + $db = EntityManager::getInstance(self::class)->DB(); + $identity_column = DynamicFields::getNameFieldsSQL('u'); + $number_column = DynamicFields::getNumberFieldSQL('u'); + $sql = sprintf('SELECT u.id, %s AS identity, %s AS number + FROM users u + INNER JOIN acc_transactions_users l ON l.id_user = u.id + WHERE l.id_transaction = ? AND l.id_service_user IS NULL + ORDER BY id;', $identity_column, $number_column); + return $db->get($sql, $this->id()); + } + + public function listLinkedUsersAssoc(): array + { + $db = EntityManager::getInstance(self::class)->DB(); + $identity_column = DynamicFields::getNameFieldsSQL('u'); + $sql = sprintf('SELECT u.id, %s AS identity + FROM users u + INNER JOIN acc_transactions_users l ON l.id_user = u.id + WHERE l.id_transaction = ? AND l.id_service_user IS NULL;', $identity_column); + return $db->getAssoc($sql, $this->id()); + } +} diff --git a/src/include/lib/Paheko/Entities/Accounting/Year.php b/src/include/lib/Paheko/Entities/Accounting/Year.php new file mode 100644 index 0000000..b9019b5 --- /dev/null +++ b/src/include/lib/Paheko/Entities/Accounting/Year.php @@ -0,0 +1,259 @@ +assert(trim($this->label) !== '', 'Le libellé ne peut rester vide.'); + $this->assert(strlen($this->label) <= 200, 'Le libellé ne peut faire plus de 200 caractères.'); + $this->assert($this->start_date instanceof \DateTime, 'La date de début de l\'exercice n\'est pas définie.'); + $this->assert($this->end_date instanceof \DateTime, 'La date de début de l\'exercice n\'est pas définie.'); + + $this->assert($this->start_date < $this->end_date, 'La date de fin doit être postérieure à la date de début'); + + $db = DB::getInstance(); + + $this->assert($this->id_chart !== null); + parent::selfCheck(); + + if ($this->exists()) { + $this->assert( + !$db->test(Transaction::TABLE, 'id_year = ? AND date < ?', $this->id(), $this->start_date->format('Y-m-d')), + 'Des écritures de cet exercice ont une date antérieure à la date de début de l\'exercice.' + ); + + $this->assert( + !$db->test(Transaction::TABLE, 'id_year = ? AND date > ?', $this->id(), $this->end_date->format('Y-m-d')), + 'Des écritures de cet exercice ont une date postérieure à la date de fin de l\'exercice.' + ); + } + } + + public function close(int $user_id): void + { + if ($this->closed) { + throw new \LogicException('Cet exercice est déjà clôturé'); + } + + $this->set('closed', true); + $this->save(); + } + + public function reopen(int $user_id): void + { + if (!$this->closed) { + throw new \LogicException('This year is already open'); + } + + $closing_id = $this->accounts()->getClosingAccountId(); + + if (!$closing_id) { + throw new UserException('Aucun compte n\'est indiqué comme compte de clôture dans le plan comptable'); + } + + $this->set('closed', false); + $this->save(); + + Log::add(Log::MESSAGE, [ + 'message' => sprintf('Réouverture de l\'exercice', $this->label), + 'entity' => self::class, + 'id' => $this->id(), + ]); + + // Create validated transaction to show that someone has reopened the year + $t = new Transaction; + $t->import([ + 'id_year' => $this->id(), + 'label' => sprintf('Exercice réouvert le %s', date('d/m/Y à H:i:s')), + 'type' => Transaction::TYPE_ADVANCED, + 'date' => $this->end_date->format('d/m/Y'), + 'id_creator' => $user_id, + 'notes' => 'Écriture automatique créée lors de la réouverture, à des fins de transparence. Cette écriture ne peut pas être supprimée ni modifiée.', + ]); + + $line = new Line; + $line->import([ + 'debit' => 0, + 'credit' => 1, + 'id_account' => $closing_id, + ]); + $t->addLine($line); + + $line = new Line; + $line->import([ + 'debit' => 1, + 'credit' => 0, + 'id_account' => $closing_id, + ]); + $t->addLine($line); + + // Lock transaction + $t->lock(); + + $t->save(); + } + + /** + * Splits an accounting year between the current year and another one, at a given date + * Any transaction after the given date will be moved to the target year. + */ + public function split(\DateTime $date, Year $target): void + { + if ($this->closed) { + throw new \LogicException('Cet exercice est déjà clôturé'); + } + + if ($target->closed) { + throw new \LogicException('L\'exercice cible est déjà clôturé'); + } + + DB::getInstance()->preparedQuery('UPDATE acc_transactions SET id_year = ? WHERE id_year = ? AND date > ?;', + $target->id(), $this->id(), $date->format('Y-m-d')); + } + + public function delete(): bool + { + $db = DB::getInstance(); + $ids = $db->getAssoc('SELECT id, id FROM acc_transactions WHERE id_year = ?;', $this->id()); + + + // Delete all files + foreach ($ids as $id) { + Files::delete(File::CONTEXT_TRANSACTION . '/' . $id); + } + + // Manual delete of transactions, as there is a voluntary safeguard in SQL: no cascade + $db->preparedQuery('DELETE FROM acc_transactions WHERE id_year = ?;', $this->id()); + + return parent::delete(); + } + + public function countTransactions(): int + { + $db = DB::getInstance(); + return $db->count(Transaction::TABLE, $db->where('id_year', $this->id())); + } + + public function chart() + { + return EntityManager::findOneById(Chart::class, $this->id_chart); + } + + public function accounts() + { + return new Accounts($this->id_chart); + } + + public function label_years() + { + $start = Utils::date_fr($this->start_date, 'Y'); + $end = Utils::date_fr($this->end_date, 'Y'); + return $start == $end ? $start : sprintf('%s-%s', $start, substr($end, -2)); + } + + + /** + * List common accounts used in this year, grouped by type + * @return array + */ + public function listCommonAccountsGrouped(array $types = null, bool $hide_empty = false): array + { + if (null === $types) { + // If we want all types, then we will get used or bookmarked accounts in common types + // and only bookmarked accounts for other types, grouped in "Others" + $target = Account::COMMON_TYPES; + } + else { + $target = $types; + } + + $out = []; + + foreach ($target as $type) { + $out[$type] = (object) [ + 'label' => Account::TYPES_NAMES[$type], + 'type' => $type, + 'accounts' => [], + ]; + } + + if (null === $types) { + $out[0] = (object) [ + 'label' => 'Autres', + 'type' => 0, + 'accounts' => [], + ]; + } + + $db = DB::getInstance(); + + $sql = sprintf('SELECT a.* FROM acc_accounts a + LEFT JOIN acc_transactions_lines b ON b.id_account = a.id + LEFT JOIN acc_transactions c ON c.id = b.id_transaction AND c.id_year = %d + WHERE a.id_chart = %d AND ((a.%s AND (a.bookmark = 1 OR a.user = 1 OR c.id IS NOT NULL)) %s) + GROUP BY a.id + ORDER BY type, code COLLATE NOCASE;', + $this->id(), + $this->id_chart, + $db->where('type', $target), + (null === $types) ? 'OR (a.bookmark = 1)' : '' + ); + + $query = $db->iterate($sql); + + foreach ($query as $row) { + $t = in_array($row->type, $target, true) ? $row->type : 0; + $out[$t]->accounts[] = $row; + } + + if ($hide_empty) { + foreach ($out as $key => $v) { + if (!count($v->accounts)) { + unset($out[$key]); + } + } + } + + return $out; + } + + public function hasOpeningBalance(): bool + { + return DB::getInstance()->test(Transaction::TABLE, 'id_year = ? AND status & ?', $this->id(), Transaction::STATUS_OPENING_BALANCE); + } + + public function deleteOpeningBalance(): void + { + $em = EntityManager::getInstance(Transaction::class); + $list = $em->iterate('SELECT * FROM @TABLE WHERE id_year = ? AND status & ?', $this->id(), Transaction::STATUS_OPENING_BALANCE); + + foreach ($list as $t) { + $t->delete(); + } + } +} diff --git a/src/include/lib/Paheko/Entities/Email/Email.php b/src/include/lib/Paheko/Entities/Email/Email.php new file mode 100644 index 0000000..beba7ad --- /dev/null +++ b/src/include/lib/Paheko/Entities/Email/Email.php @@ -0,0 +1,280 @@ +hash . SECRET_KEY); + return substr($code, 0, 10); + } + + public function sendVerification(string $email): void + { + if (self::getHash($email) !== $this->hash) { + throw new UserException('Adresse email inconnue'); + } + + $verify_url = self::getOptoutURL($this->hash) . '&v=' . $this->getVerificationCode(); + EmailsTemplates::verifyAddress($email, $verify_url); + } + + public function canSendVerificationAfterFail(): bool + { + $limit_date = new \DateTime($this->optout ? self::RESEND_VERIFICATION_DELAY_OPTOUT : self::RESEND_VERIFICATION_DELAY); + return isset($this->last_sent) ? $this->last_sent < $limit_date : false; + } + + public function verify(string $code): bool + { + if ($code !== $this->getVerificationCode()) { + return false; + } + + $this->set('verified', true); + $this->set('optout', false); + $this->set('invalid', false); + $this->set('fail_count', 0); + $this->set('fail_log', null); + return true; + } + + public function validate(string $email): bool + { + if (!$this->canSend()) { + return false; + } + + try { + self::validateAddress($email); + } + catch (UserException $e) { + $this->setFailedValidation($e->getMessage()); + return false; + } + + return true; + } + + public function setFailedValidation(string $message): void + { + $this->hasFailed(['type' => 'permanent', 'message' => $message]); + } + + static public function isAddressValid(string $email, bool $mx_check = true): bool + { + try { + self::validateAddress($email); + return true; + } + catch (UserException $e) { + return false; + } + } + + static public function validateAddress(string $email, bool $mx_check = true): void + { + $pos = strrpos($email, '@'); + + if ($pos === false) { + throw new UserException('Adresse e-mail invalide : vérifiez que vous n\'avez pas fait une faute de frappe.'); + } + + $user = substr($email, 0, $pos); + $host = substr($email, $pos+1); + + // Ce domaine n'existe pas (MX inexistant), erreur de saisie courante + if ($host == 'gmail.fr') { + throw new UserException('Adresse invalide : "gmail.fr" n\'existe pas, il faut utiliser "gmail.com"'); + } + + if (preg_match('![/@]!', $user)) { + throw new UserException('Adresse e-mail invalide : vérifiez que vous n\'avez pas fait une faute de frappe.'); + } + + if (!SMTP::checkEmailIsValid($email, false)) { + if (!trim($host)) { + throw new UserException('Adresse e-mail invalide : vérifiez que vous n\'avez pas fait une faute de frappe.'); + } + + foreach (self::COMMON_DOMAINS as $common_domain) { + similar_text($common_domain, $host, $percent); + + if ($percent > 90) { + throw new UserException(sprintf('Adresse e-mail invalide : avez-vous fait une erreur, par exemple "%s" à la place de "%s" ?', $host, $common_domain)); + } + } + + throw new UserException('Adresse e-mail invalide : vérifiez que vous n\'avez pas fait une faute de frappe.'); + } + + // Windows does not support MX lookups + if (PHP_OS_FAMILY == 'Windows' || !$mx_check) { + return; + } + + self::checkMX($host); + } + + static public function checkMX(string $host) + { + if (PHP_OS_FAMILY == 'Windows') { + return; + } + + getmxrr($host, $mx_list); + + if (empty($mx_list)) { + throw new UserException('Adresse e-mail invalide (le domaine indiqué n\'a pas de service e-mail) : vérifiez que vous n\'avez pas fait une faute de frappe.'); + } + + foreach ($mx_list as $mx) { + if (preg_match(self::BLACKLIST_MANUAL_VALIDATION_MX, $mx)) { + throw new UserException('Adresse e-mail invalide : impossible d\'envoyer des mails à un service (de type mailinblack ou spamenmoins) qui demande une validation manuelle de l\'expéditeur. Merci de choisir une autre adresse e-mail.'); + } + } + } + + public function canSend(): bool + { + if (!empty($this->optout)) { + return false; + } + + if (!empty($this->invalid)) { + return false; + } + + if ($this->hasReachedFailLimit()) { + return false; + } + + return true; + } + + public function hasReachedFailLimit(): bool + { + return !empty($this->fail_count) && ($this->fail_count >= Emails::FAIL_LIMIT); + } + + public function incrementSentCount(): void + { + $this->set('sent_count', $this->sent_count+1); + } + + public function setOptout(string $message = null): void + { + $this->set('optout', true); + $this->appendFailLog($message ?? 'Demande de désinscription'); + } + + public function appendFailLog(string $message): void + { + $log = $this->fail_log ?? ''; + + if ($log) { + $log .= "\n"; + } + + $log .= date('d/m/Y H:i:s - ') . trim($message); + $this->set('fail_log', $log); + } + + public function hasFailed(array $return): void + { + if (!isset($return['type'])) { + throw new \InvalidArgumentException('Bounce email type not supplied in argument.'); + } + + // Treat complaints as opt-out + if ($return['type'] == 'complaint') { + $this->set('optout', true); + $this->appendFailLog("Un signalement de spam a été envoyé par le destinataire.\n: " . $return['message']); + } + elseif ($return['type'] == 'permanent') { + $this->set('invalid', true); + $this->set('fail_count', $this->fail_count+1); + $this->appendFailLog($return['message']); + } + elseif ($return['type'] == 'temporary') { + $this->set('fail_count', $this->fail_count+1); + $this->appendFailLog($return['message']); + } + } + + public function save(bool $selfcheck = true): bool + { + $optout = false; + + if ($this->isModified('optout')) { + $optout = true; + } + + $return = parent::save($selfcheck); + + if ($return && $optout) { + // Delete all specific optouts when opting out of everything + DB::getInstance()->preparedQuery('DELETE FROM mailings_optouts WHERE email_hash = ?;', $this->hash); + } + + return $return; + } +} diff --git a/src/include/lib/Paheko/Entities/Email/Mailing.php b/src/include/lib/Paheko/Entities/Email/Mailing.php new file mode 100644 index 0000000..666b4f4 --- /dev/null +++ b/src/include/lib/Paheko/Entities/Email/Mailing.php @@ -0,0 +1,401 @@ + 'Tous les membres (sauf catégories cachées)', + 'field' => 'Champ de la fiche membre', + 'category' => 'Catégorie', + 'service' => 'Inscrits à jour d\'une activité', + 'search' => 'Recherche enregistrée', + ]; + + protected ?int $id = null; + protected string $subject; + protected ?string $body; + + /** + * We need to store these in order to have opt-out per-target + */ + protected ?string $target_type; + protected ?string $target_value; + protected ?string $target_label; + + /** + * Leave sender name and email NULL to use org name + email + */ + protected ?string $sender_name; + protected ?string $sender_email; + + /** + * NULL when the mailing has not been sent yet + */ + protected ?DateTime $sent; + + /** + * TRUE when the list of recipients has been anonymized + * @var boolean + */ + protected bool $anonymous = false; + + public function selfCheck(): void + { + parent::selfCheck(); + + $this->assert(trim($this->subject) !== '', 'Le sujet ne peut rester vide.'); + $this->assert(!isset($this->body) || trim($this->body) !== '', 'Le corps du message ne peut rester vide.'); + + if (isset($this->sender_name) || isset($this->sender_email)) { + $this->assert(trim($this->sender_name) !== '', 'Le nom d\'expéditeur est vide.'); + $this->assert(trim($this->sender_email) !== '', 'L\'adresse e-mail de l\'expéditeur est manquante.'); + $this->assert(Email::isAddressValid($this->sender_email), 'L\'adresse e-mail de l\'expéditeur est invalide.'); + } + } + + public function getTargetTypeLabel(): string + { + return self::TARGETS_TYPES[$this->target_type] ?? ''; + } + + public function populate(): void + { + if ($this->target_type !== 'all' && empty($this->target_value)) { + throw new \InvalidArgumentException('Missing target ID'); + } + + if ($this->target_type === 'field') { + $recipients = Users::iterateEmailsByField($this->target_value, true); + } + elseif ($this->target_type === 'all') { + $recipients = Users::iterateEmailsByCategory(null); + } + elseif ($this->target_type === 'category') { + $recipients = Users::iterateEmailsByCategory((int) $this->target_value); + } + elseif ($this->target_type === 'search') { + $recipients = Users::iterateEmailsBySearch((int) $this->target_value); + } + elseif ($this->target_type === 'service') { + $recipients = Users::iterateEmailsByActiveService((int) $this->target_value); + } + else { + throw new \InvalidArgumentException('Invalid target'); + } + + $db = DB::getInstance(); + $db->begin(); + $count = 0; + + foreach ($recipients as $email => $data) { + // Ignore empty emails, normally NULL emails are already discarded in WHERE clauses + // But, just to be sure + if (empty($email)) { + continue; + } + + $this->addRecipient($email, $data); + $count++; + } + + if (!$count) { + $db->rollback(); + throw new UserException('La liste de destinataires sélectionnée ne comporte aucun membre, ou aucun avec une adresse e-mail renseignée.'); + } + + $this->cleanupRecipients(); + + $db->commit(); + } + + /** + * Remove opt-out recipients from list + */ + public function cleanupRecipients(): void + { + + } + + public function addRecipient(string $email, ?stdClass $data = null): void + { + if (!$this->exists()) { + throw new \LogicException('Mailing does not exist'); + } + + $email = strtolower(trim($email)); + $e = Emails::getEmail($email); + + if ($e && !$e->canSend()) { + $data = null; + } + else { + try { + // Validate e-mail address, but not MX (quick check) + Email::validateAddress($email, false); + } + catch (UserException $ex) { + $e = Emails::createEmail($email); + $e->setFailedValidation($ex->getMessage()); + $data = null; + } + } + + $this->cleanExtraData($data); + + DB::getInstance()->insert('mailings_recipients', [ + 'id_mailing' => $this->id, + 'id_email' => $e ? $e->id : null, + 'email' => $email, + 'extra_data' => $data ? json_encode($data) : null, + ]); + } + + protected function cleanExtraData(?stdClass &$data): void + { + if (null === $data) { + return; + } + + // Clean up users, just in case password/PGP key/etc. are included + foreach (DynamicField::SYSTEM_FIELDS as $key => $type) { + unset($data->$key); + } + + // Just in case the password has another column name + foreach ($data as $key => $value) { + if (is_string($value) && substr($value, 0, 2) === '$2') { + unset($data->$key); + } + } + } + + public function listRecipients(): \Generator + { + $db = DB::getInstance(); + $sql = sprintf('SELECT email, extra_data AS data, %s AS _name FROM mailings_recipients WHERE id_mailing = %d ORDER BY id;', + $this->getNameFieldsSQL(), + $this->id() + ); + + foreach ($db->iterate($sql) as $row) { + $data = $row->data ? json_decode($row->data) : null; + yield $row->email => [ + 'email' => $row->email, + 'data' => $data, + '_name' => $row->_name ?? null, + 'pgp_key' => $data->pgp_key ?? null, + ]; + } + } + + protected function getNameFieldsSQL(string $prefix = ''): string + { + $prefix = $prefix ? $prefix . '.' : $prefix; + $fields = DynamicFields::getNameFields(); + $db = DB::getInstance(); + $out = []; + + foreach ($fields as $field) { + $field = $db->quote('$.' . $field); + $out[] = sprintf('json_extract(%sextra_data, %s)', $prefix, $field); + } + + $out = implode(' || \' \' || ', $out); + return $out; + } + + public function getRecipientsList(): DynamicList + { + $db = DB::getInstance(); + $columns = [ + 'id' => [ + 'select' => 'r.id', + ], + 'id_user' => [ + 'select' => sprintf('json_extract(r.extra_data, %s)', $db->quote('$.id')), + ], + 'id_email' => [ + 'select' => 'r.id_email', + ], + 'user_number' => [ + 'label' => 'Numéro de membre', + 'select' => sprintf('json_extract(r.extra_data, %s)', $db->quote('$.' . DynamicFields::getNumberField())), + 'export' => true, + ], + 'email' => [ + 'label' => 'Adresse', + 'order' => 'r.email COLLATE NOCASE %s', + 'select' => 'r.email', + ], + 'name' => [ + 'label' => 'Nom', + 'select' => $this->getNameFieldsSQL('r'), + ], + 'status' => [ + 'label' => 'Erreur', + 'select' => sprintf('CASE WHEN o.email_hash IS NOT NULL THEN \'Désinscription de cet envoi\' ELSE (%s) END', Emails::getRejectionStatusClause('e')), + ], + 'has_extra_data' => [ + 'select' => 'r.extra_data IS NOT NULL', + ], + ]; + + $tables = 'mailings_recipients AS r + LEFT JOIN emails e ON e.id = r.id_email + LEFT JOIN mailings_optouts o ON e.hash = o.email_hash AND o.target_type = :target_type AND o.target_value = :target_value'; + $conditions = 'id_mailing = ' . $this->id; + + $list = new DynamicList($columns, $tables, $conditions); + $list->setParameter(':target_type', $this->target_type); + $list->setParameter(':target_value', $this->target_value); + $list->orderBy('email', false); + $list->setTitle('Liste des destinataires'); + return $list; + } + + public function countRecipients(): int + { + return DB::getInstance()->count('mailings_recipients', 'id_mailing = ?', $this->id); + } + + public function anonymize(): void + { + DB::getInstance()->preparedQuery('UPDATE mailings_recipients SET email = NULL, extra_data = NULL WHERE id_mailing = ?;', $this->id); + } + + public function deleteRecipient(int $id): void + { + DB::getInstance()->delete('mailings_recipients', 'id = ? AND id_mailing = ?', $id, $this->id); + } + + public function getRecipientExtraData(int $id): ?stdClass + { + $value = DB::getInstance()->firstColumn('SELECT extra_data FROM mailings_recipients WHERE id = ?;', $id); + $value = !$value ? null : json_decode($value, false); + + $this->cleanExtraData($value); + return $value; + } + + public function getFrom(): string + { + $config = Config::getInstance(); + return sprintf('"%s" <%s>', $this->sender_name ?? $config->org_name, $this->sender_email ?? $config->org_email); + } + + /** + * @return UserTemplate|string + */ + public function getBody() + { + if (!isset($this->body)) { + return ''; + } + + if (false !== strpos($this->body, '{{')) { + return UserTemplate::createFromUserString($this->body); + } + else { + return $this->body; + } + } + + public function getPreview(int $id = null): string + { + $db = DB::getInstance(); + + $where = $id ? 'id = ?' : '1 ORDER BY RANDOM()'; + $sql = sprintf('SELECT extra_data FROM mailings_recipients WHERE id_mailing = %d AND %s LIMIT 1;', $this->id(), $where); + $args = $id ? (array)$id : []; + + $r = $db->firstColumn($sql, ...$args); + + if (!$r) { + throw new UserException('Cette adresse ne fait pas partie des destinataires'); + } + + $r = json_decode($r, true); + + $body = $this->getBody(); + + if ($body instanceof UserTemplate) { + $body->assignArray($r, null, false); + + try { + $body = $body->fetch(); + } + catch (\KD2\Brindille_Exception $e) { + throw new UserException('Erreur de syntaxe dans le corps du message :' . PHP_EOL . $e->getPrevious()->getMessage(), 0, $e); + } + } + + $render = Render::FORMAT_MARKDOWN; + return Render::render($render, null, $body); + } + + public function getHTMLPreview(string $address = null, bool $append_footer = false): string + { + $html = $this->getPreview($address); + $tpl = new UserTemplate('web/email.html'); + $tpl->assignArray(compact('html'), null, false); + + $out = $tpl->fetch(); + + if ($append_footer) { + $out = Emails::appendHTMLOptoutFooter($out, 'javascript:alert(\'--\');'); + } + + return $out; + } + + public function send(): void + { + $this->selfCheck(); + + if (!isset($this->body)) { + throw new UserException('Le corps du message est vide.'); + } + + $sender = null; + + if (isset($this->sender_name, $this->sender_email)) { + $sender = Emails::getFromHeader($this->sender_name, $this->sender_email); + } + + Emails::queue(Emails::CONTEXT_BULK, + $this->listRecipients(), + $sender, + $this->subject, + $this->getBody() + ); + + $this->set('sent', new DateTime); + + $this->save(); + + Log::add(Log::SENT, ['entity' => get_class($this), 'id' => $this->id()]); + } +} diff --git a/src/include/lib/Paheko/Entities/Email/Message.php b/src/include/lib/Paheko/Entities/Email/Message.php new file mode 100644 index 0000000..c7bb357 --- /dev/null +++ b/src/include/lib/Paheko/Entities/Email/Message.php @@ -0,0 +1,206 @@ +assignArray((array) $data, null, false); + + // Disable HTML escaping for plaintext emails + $template->setEscapeDefault(null); + $this->body = $template->fetch(); + + if ($markdown) { + // Use Markdown rendering for HTML emails + $this->body_html = Render::render(Render::FORMAT_MARKDOWN, null, $this->body); + } + } + + public function createHTMLFromMarkdownBody(?string $body = null): void + { + if (null !== $body) { + $this->body = $body; + } + + $this->body_html = Render::render(Render::FORMAT_MARKDOWN, null, $this->body); + } + + public function wrapHTML(): ?string + { + if ($this->context === self::CONTEXT_SYSTEM) { + return null; + } + + if (null === self::$main_tpl) { + self::$main_tpl = new UserTemplate('web/email.html'); + } + + // Wrap HTML content in the email skeleton + $main_tpl->assignArray([ + 'html' => $this->body_html, + 'address' => $this->recipient, + 'context' => $this->context, + 'sender' => $this->sender, + 'message' => $this, + ]); + + return $main_tpl->fetch(); + } + + public function getOptoutText(): string + { + $out = "Vous recevez ce message car vous êtes dans nos contacts.\n"; + + if (isset($this->context_optout)) { + $out .= "Pour vous désinscrire uniquement de ces envois, cliquez ici :\n"; + $out .= "[context_optout_url]\n\n"; + } + + $out .= "Pour ne plus jamais recevoir aucun message de notre part cliquez ici :\n"; + $out .= "[optout_url]\n\n"; + return $out; + + } + + public function getOptoutFooter(): string + { + return strtr($this->getOptoutText(), [ + '[context_optout_url]' => $this->getContextSpecificOptoutURL(), + '[optout_url]' => $this->getOptoutURL(), + ]); + } + + public function appendHTMLOptoutFooter(): string + { + $text = nl2br(htmlspecialchars($this->getOptoutText())); + + if (isset($this->context_optout)) { + $button = sprintf('Me désinscrire de ces envois uniquement

', $this->getContextSpecificOptoutURL()); + $text = str_replace('[context_optout_url]', $button, $text); + } + + $button = sprintf('Me désinscrire de tous les envois

', $this->getOptoutURL()); + $text = str_replace('[optout_url]', $button, $text); + + $footer = '

'; + $footer .= $text; + + $html = $this->body_html; + + if (stripos($html, '') !== false) { + $html = str_ireplace('', $footer . '', $html); + } + else { + $html .= $footer; + } + + return $html; + } + + public function getOptoutURL(): string + { + return Email::getOptoutURL($this->recipient_hash); + } + + public function getContextSpecificOptoutURL(): ?string + { + if (!isset($this->context_optout)) { + return null; + } + + return Email::getOptoutURL() . '&c=' . $this->context_optout; + } + + + public function save(bool $selfcheck = true): bool + { + + } + + public function send(): bool + { + $config = Config::getInstance(); + $message = new Mail_Message; + + $message->setHeader('From', $this->sender ?? self::getDefaultFromHeader()); + $message->setHeader('To', $this->recipient); + $message->setHeader('Subject', $this->subject); + + if (!$message->getFrom()) { + $message->setHeader('From', self::getFromHeader()); + } + + if (MAIL_SENDER) { + $message->setHeader('Reply-To', $message->getFromAddress()); + $message->setHeader('From', self::getFromHeader($message->getFromName(), MAIL_SENDER)); + } + + $message->setMessageId(); + + $text = $this->body; + $html = $this->body_html; + + // Append unsubscribe, except for password reminders + if ($this->context != self::CONTEXT_SYSTEM) { + $url = $this->getContextSpecificOptoutURL() ?? $this->getOptoutURL(); + + // RFC 8058 + $message->setHeader('List-Unsubscribe', sprintf('<%s>', $url)); + $message->setHeader('List-Unsubscribe-Post', 'Unsubscribe=Yes'); + + $text .= sprintf("\n\n-- \n%s\n\n%s", $config->org_name, $this->getOptoutText()); + + if (null !== $html) { + $html = $this->appendHTMLOptoutFooter(); + } + } + + $message->setBody($text); + + if (null !== $html) { + $message->addPart('text/html', $html); + } + + $message->setHeader('Return-Path', MAIL_RETURN_PATH ?? $config->org_email); + $message->setHeader('X-Auto-Response-Suppress', 'All'); // This is to avoid getting auto-replies from Exchange servers + + foreach ($attachments as $path) { + $file = Files::get($path); + + if (!$file) { + continue; + } + + $message->addPart($file->mime, $file->fetch(), $file->name); + } + + static $can_use_encryption = null; + + if (null === $can_use_encryption) { + $can_use_encryption = Security::canUseEncryption(); + } + + if ($this->recipient_pgp_key && $can_use_encryption) { + $message->encrypt($this->recipient_pgp_key); + } + + return Emails::sendMessage($context, $message); + } +} \ No newline at end of file diff --git a/src/include/lib/Paheko/Entities/Files/File.php b/src/include/lib/Paheko/Entities/Files/File.php new file mode 100644 index 0000000..126adfa --- /dev/null +++ b/src/include/lib/Paheko/Entities/Files/File.php @@ -0,0 +1,1530 @@ + [['trim'], ['resize', 150]], + '250px' => [['trim'], ['resize', 250]], + '500px' => [['resize', 500]], + '750px' => [['resize', 750]], + 'crop-256px' => [['trim'], ['cropResize', 256, 256]], + ]; + + const THUMB_CACHE_ID = 'file.thumb.%s.%s'; + + const THUMB_SIZE_TINY = '250px'; + const THUMB_SIZE_SMALL = '500px'; + const THUMB_SIZE_LARGE = '750px'; + + const CONTEXT_TRASH = 'trash'; + const CONTEXT_DOCUMENTS = 'documents'; + const CONTEXT_USER = 'user'; + const CONTEXT_TRANSACTION = 'transaction'; + const CONTEXT_CONFIG = 'config'; + const CONTEXT_WEB = 'web'; + const CONTEXT_MODULES = 'modules'; + const CONTEXT_ATTACHMENTS = 'attachments'; + const CONTEXT_VERSIONS = 'versions'; + const CONTEXT_EXTENSIONS = 'ext'; + + const CONTEXTS_NAMES = [ + self::CONTEXT_TRASH => 'Corbeille', + self::CONTEXT_DOCUMENTS => 'Documents', + self::CONTEXT_USER => 'Fiches des membres', + self::CONTEXT_TRANSACTION => 'Écritures comptables', + self::CONTEXT_CONFIG => 'Configuration', + self::CONTEXT_WEB => 'Site web', + self::CONTEXT_MODULES => 'Code des modules', + self::CONTEXT_ATTACHMENTS => 'Fichiers joints aux messages', + self::CONTEXT_VERSIONS => 'Versions', + self::CONTEXT_EXTENSIONS => 'Extensions', + ]; + + const VERSIONED_CONTEXTS = [ + self::CONTEXT_DOCUMENTS, + self::CONTEXT_TRANSACTION, + self::CONTEXT_USER, + ]; + + const IMAGE_TYPES = [ + 'image/png', + 'image/gif', + 'image/jpeg', + 'image/webp', + ]; + + const PREVIEW_TYPES = [ + // We expect modern browsers to be able to preview a PDF file + // even if the user has disabled PDF opening in browser + // (something we cannot detect) + 'application/pdf', + 'audio/mpeg', + 'audio/ogg', + 'audio/wave', + 'audio/wav', + 'audio/x-wav', + 'audio/x-pn-wav', + 'audio/webm', + 'video/webm', + 'video/ogg', + 'application/ogg', + 'video/mp4', + 'image/png', + 'image/gif', + 'image/jpeg', + 'image/webp', + 'image/svg+xml', + 'text/plain', + 'text/html', + ]; + + const FORBIDDEN_CHARACTERS = [ + '..', // double dot + "\0", // NUL + '/', // slash + // invalid characters in Windows + '\\', ':', '*', '?', '"', '<', '>', '|', + ]; + + // https://book.hacktricks.xyz/pentesting-web/file-upload + const FORBIDDEN_EXTENSIONS = '!^(?:cgi|exe|sh|bash|com|pif|jspx?|jar|js[wxv]|action|do|php(?:s|\d+)?|pht|phtml?|shtml|phar|htaccess|inc|cfml?|cfc|dbm|swf|pl|perl|py|pyc|asp|so)$!i'; + + public function selfCheck(): void + { + $this->assert($this->type === self::TYPE_DIRECTORY || $this->type === self::TYPE_FILE, 'Unknown file type'); + $this->assert($this->type === self::TYPE_DIRECTORY || $this->size !== null, 'File size must be set'); + $this->assert(trim($this->name) !== '', 'Le nom de fichier ne peut rester vide'); + $this->assert(strlen($this->path), 'Le chemin ne peut rester vide'); + $this->assert(null === $this->parent || strlen($this->parent), 'Le chemin ne peut rester vide'); + $this->assert(false === strpos($this->path, '//')); + $this->assert(null === $this->parent || false === strpos($this->parent, '//')); + } + + public function save(bool $selfcheck = true): bool + { + if ($this->parent) { + Files::ensureDirectoryExists($this->parent); + } + + $ok = parent::save(); + + $context = $this->context(); + + // Link file to transaction/user + if ($ok && $this->type === self::TYPE_FILE && in_array($context, [self::CONTEXT_USER, self::CONTEXT_TRANSACTION])) { + // Only insert if ID exists in table + $db = DB::getInstance(); + + if ($context == self::CONTEXT_USER) { + $id = (int)Utils::basename(Utils::dirname($this->parent)); + $field = Utils::basename($this->parent); + + if (!$id || !$field) { + return $ok; + } + + // The field does not exist anymore, don't link + if (!DynamicFields::get($field)) { + return $ok; + } + + $sql = sprintf('INSERT OR IGNORE INTO %s_files (id_file, id_user, field) SELECT %d, %d, %s FROM %1$s WHERE id = %3$d;', + 'users', + $this->id(), + $id, + $db->quote($field) + ); + } + else { + $id = (int)Utils::basename($this->parent); + + if (!$id) { + return $ok; + } + + $sql = sprintf('INSERT OR IGNORE INTO %s_files (id_file, id_transaction) SELECT %d, %d FROM %1$s WHERE id = %3$d;', + 'acc_transactions', + $this->id(), + $id + ); + } + + $db->exec($sql); + } + + return $ok; + } + + public function context(): string + { + $value = strtok($this->path, '/'); + strtok(''); + return $value; + } + + public function parent(): File + { + return Files::get($this->parent); + } + + public function getLocalFilePath(): ?string + { + $path = Files::callStorage('getLocalFilePath', $this); + + if (null === $path || !file_exists($path)) { + return null; + } + + return $path; + } + + public function etag(): string + { + if (isset($this->md5)) { + return $this->md5; + } + elseif (!$this->isDir()) { + return md5($this->path . $this->size . $this->modified->getTimestamp()); + } + else { + return md5($this->path . $this->getRecursiveSize() . $this->getRecursiveLastModified()); + } + } + + public function rehash($pointer = null): void + { + if ($this->isDir()) { + return; + } + + $path = !$pointer ? $this->getLocalFilePath() : null; + + if ($path) { + $hash = md5_file($path); + } + else { + $p = $pointer ?? $this->getReadOnlyPointer(); + + if (!$p) { + return; + } + + $hash = hash_init('md5'); + + while (!feof($p)) { + hash_update($hash, fread($p, 8192)); + } + + $hash = hash_final($hash); + + if (null === $pointer) { + fclose($p); + } + else { + fseek($pointer, 0, SEEK_SET); + } + } + + $this->set('md5', $hash); + } + + /** + * Return TRUE if the file can be previewed natively in a browser + * @return bool + */ + public function canPreview(): bool + { + if (in_array($this->mime, self::PREVIEW_TYPES)) { + return true; + } + + if (!WOPI_DISCOVERY_URL) { + return false; + } + + if ($this->getWopiURL()) { + return true; + } + + return false; + } + + public function moveToTrash(bool $mark_as_trash = true): void + { + if ($this->context() === self::CONTEXT_TRASH) { + return; + } + + $db = DB::getInstance(); + $db->begin(); + + // We need to put files in a specific subdirectory + // or we might overwrite files previously put in trash + // (for example you move "accounting/report.ods" to "trash/accounting/reports.ods", + // then you move "accounting/" to "trash/accounting/", this would delete the previously + // trashed "reports.ods" file) + $hash = md5($this->path); + $hash = substr(date('Y-m-d.His.') . $hash, 0, 40); + + // Only mark the root folder as trashed, but still move everything else + $this->set('trash', new \DateTime); + + // Move versions as well + if ($v = Files::get(self::CONTEXT_VERSIONS . '/' . $this->path)) { + $v->rename(self::CONTEXT_TRASH . '/' . $hash . '/' . $v->path, false); + } + + // ->rename() will ->save() + $this->rename(self::CONTEXT_TRASH . '/' . $hash . '/' . $this->path, false); + + Plugins::fire('file.trash', false, ['file' => $this]); + + $db->commit(); + } + + public function restoreFromTrash(): void + { + if ($this->context() !== self::CONTEXT_TRASH) { + return; + } + + $db = DB::getInstance(); + $db->begin(); + + $root = strtok($this->path, '/') . '/' . strtok('/'); + $orig_path = strtok(''); + + $this->set('trash', null); + + $v = Files::get($root . '/' . self::CONTEXT_VERSIONS . '/' . $orig_path); + + // Restore versions + if ($v) { + $v->rename(self::CONTEXT_VERSIONS . '/' . $orig_path, false); + } + + // rename() will do the save() + $this->rename($orig_path, false); + + Plugins::fire('file.restore', false, ['file' => $this]); + + $db->commit(); + } + + public function deleteCache(): void + { + // This also deletes thumbnail links + Web_Cache::delete($this->uri()); + $this->deleteThumbnails(); + } + + /** + * Delete file from local database, but not the file from the storage itself + */ + public function deleteSafe(): bool + { + $this->deleteCache(); + return parent::delete(); + } + + public function delete(): bool + { + Files::assertStorageIsUnlocked(); + + $db = DB::getInstance(); + $db->begin(); + + // Delete actual file content + $ok = Files::callStorage('delete', $this); + + // Also delete sub-directories and files, if the storage backend is not able to do it + // (eg. object storage) + if (!$ok && $this->type == self::TYPE_DIRECTORY) { + foreach (Files::list($this->path) as $file) { + if (!$file->delete()) { + $db->rollback(); + return false; + } + } + } + elseif (!$ok) { + throw new \LogicException('Storage backend couldn\'t delete a file'); + } + + Plugins::fire('file.delete', false, ['file' => $this]); + + $this->deleteCache(); + $this->deleteVersions(); + + $r = parent::delete(); + + $db->commit(); + + return $r; + } + + + /** + * Copy the current file to a new location + * @param string $target Target path + * @return self + */ + public function copy(string $new_path): self + { + if ($this->isDir()) { + throw new \LogicException('Cannot copy a directory'); + } + + $path = $this->getLocalFilePath(); + $pointer = $path ? null : $this->getReadOnlyPointer(); + + return Files::createFrom($new_path, compact('path', 'pointer')); + } + + /** + * Change ONLY the file name, not the parent path + * @param string $new_name New file name + * @return bool + */ + public function changeFileName(string $new_name, bool $check_session = true, bool $check_exists = false): bool + { + self::validateFileName($new_name); + + $v = $this->getVersionsDirectory(); + + $r = $this->rename(ltrim($this->parent . '/' . $new_name, '/'), $check_session, $check_exists); + + // Rename versions directory as well + if ($v && $r) { + $v->changeFileName($new_name); + } + + return $r; + } + + /** + * Change ONLY the directory where the file is + * @param string $target New directory path + * @return bool + */ + public function move(string $target, bool $check_session = true, bool $check_exists = false): bool + { + $v = $this->getVersionsDirectory(); + + $r = $this->rename($target . '/' . $this->name, $check_session); + + if ($r && $v) { + $v->rename(self::CONTEXT_VERSIONS . '/' . $this->path); + } + + return $r; + } + + /** + * Rename a file, this can include moving it (the UNIX way) + * @param string $new_path Target path + * @return bool + */ + public function rename(string $new_path, bool $check_session = true, bool $check_exists = false): bool + { + $name = Utils::basename($new_path); + + self::validatePath($new_path); + self::validateFileName($name); + + if ($check_session) { + self::validateCanHTML($name, $new_path); + } + + if ($new_path == $this->path) { + throw new UserException(sprintf('Impossible de renommer "%s" lui-même', $this->path)); + } + + if (0 === strpos($new_path . '/', $this->path . '/')) { + if ($this->type != self::TYPE_DIRECTORY) { + throw new UserException(sprintf('Impossible de renommer "%s" vers "%s"', $this->path, $new_path)); + } + } + + $parent = Utils::dirname($new_path); + $is_dir = $this->isDir(); + + $db = DB::getInstance(); + $db->begin(); + + // Does the target already exist? + $exists = Files::get($new_path); + + if ($exists && $check_exists) { + throw new UserException('Un fichier de ce nom existe déjà.'); + } + + // List sub-files and sub-directories now, before they are changed + $list = $is_dir ? Files::list($this->path) : []; + + // Make sure parent target directory exists + Files::ensureDirectoryExists($parent); + + // Save current object for storage use + $old = clone $this; + + // Update internal values + $this->set('parent', $parent); + $this->set('path', $new_path); + $this->set('name', $name); + + // If the target does not exist already, move the current file now + // this will avoid ensureDirectoryExists to create a duplicate + if (!$exists) { + $r = $this->save(); + } + + // Move each file to the new target + if ($is_dir) { + foreach ($list as $file) { + $file->move($new_path . trim(substr($file->parent, strlen($old->path)), '/'), $check_session); + } + } + + if ($exists) { + if ($is_dir) { + // Make sure trash state is transmitted to target path + $exists->set('trash', $this->trash); + $r = $exists->save(); + + // We assume that at this point, everything inside the source directory + // has been moved to the existing target directory + // So we can delete the source directory from the database + // (both $this and $exists point to the same path, so we can't save $this) + parent::delete(); + $db->commit(); + + return $r; + } + else { + // Overwrite existing file + $exists->deleteSafe(); + } + + unset($exists); + $r = $this->save(); + } + + if (!$is_dir) { + // Actually move the file + Files::callStorage('rename', $old, $new_path); + } + + Plugins::fire('file.rename', false, ['file' => $this, 'new_path' => $new_path]); + + $db->commit(); + + return $r; + } + + public function setContent(string $content): self + { + $this->store(['content' => rtrim($content)]); + return $this; + } + + /** + * Store contents in file, either from a local path, from a binary string or from a pointer + * + * @param array $source [path, content or pointer] + * @param string $source_content + * @param bool $index_search Set to FALSE if you don't want the document to be indexed in the file search + * @return self + */ + public function store(array $source): self + { + if (!$this->path || !$this->name) { + throw new \LogicException('Cannot store a file that does not have a target path and name'); + } + + if ($this->type == self::TYPE_DIRECTORY) { + throw new \LogicException('Cannot store a directory'); + } + + if (!isset($source['path']) && !isset($source['content']) && !isset($source['pointer'])) { + throw new \InvalidArgumentException('Unknown source type'); + } + elseif (count(array_filter($source, fn($a) => !is_null($a))) != 1) { + throw new \InvalidArgumentException('Invalid source type'); + } + + Files::assertStorageIsUnlocked(); + + $delete_after = false; + $path = $source['path'] ?? null; + $content = $source['content'] ?? null; + $pointer = $source['pointer'] ?? null; + $new = !$this->exists(); + + if ($path) { + $this->set('size', filesize($path)); + Files::checkQuota($this->size); + $this->set('md5', md5_file($path)); + } + elseif (null !== $content) { + $this->set('size', strlen($content)); + Files::checkQuota($this->size); + $this->set('md5', md5($content)); + } + elseif ($pointer) { + // See https://github.com/php/php-src/issues/9441 + $meta = stream_get_meta_data($pointer); + + if (isset($meta['uri']) && $meta['uri'] == 'php://input') { + while (!feof($pointer)) { + fread($pointer, 8192); + } + } + elseif (0 !== fseek($pointer, 0, SEEK_END)) { + throw new \RuntimeException('Stream is not seekable'); + } + + $this->set('size', ftell($pointer)); + fseek($pointer, 0, SEEK_SET); + Files::checkQuota($this->size); + + $this->rehash($pointer); + } + + // File hasn't changed + if (!$new && !$this->isModified('md5')) { + return $this; + } + + // Check that it's a real image + if ($this->image) { + if ($path) { + $blob = file_get_contents($path, false, null, 0, 1000); + } + elseif ($pointer) { + $blob = fread($pointer, 1000); + fseek($pointer, 0, SEEK_SET); + } + else { + $blob = substr($content, 0, 1000); + } + + if ($size = Blob::getSize($blob)) { + // This is to avoid pixel flood attacks + if ($size[0] > 8000 || $size[1] > 8000) { + throw new ValidationException('Cette image est trop grande (taille max 8000 x 8000 pixels)'); + } + + // Recompress PNG files from base64, assuming they are coming + // from JS canvas which doesn't know how to gzip (d'oh!) + if ($size[2] == 'image/png' && null !== $content) { + $i = Image::createFromBlob($content); + $content = $i->output('png', true); + $this->set('size', strlen($content)); + unset($i); + } + } + elseif ($type = Blob::getType($blob)) { + // WebP is fine, but we cannot get its size + } + else { + // Not an image + $this->set('image', false); + } + } + + $db = DB::getInstance(); + $db->begin(); + + // Set modified time if not already set before + if (!$this->isModified('modified')) { + $this->set('modified', new \DateTime); + } + + // Only archive previous version if it was more than 0 bytes + if (!$new && $this->getModifiedProperty('size') !== 0 && $this->size > 0) { + $this->createVersion(); + $this->pruneVersions(); + } + + // Save metadata now, and rollback if required + $this->save(); + + try { + if (null !== $path) { + $return = Files::callStorage('storePath', $this, $path); + } + elseif (null !== $content) { + $return = Files::callStorage('storeContent', $this, $content); + } + else { + $return = Files::callStorage('storePointer', $this, $pointer); + } + + if (!$return) { + throw new UserException('Le fichier n\'a pas pu être enregistré.'); + } + + Plugins::fire('file.store', false, ['file' => $this]); + + if (!$new) { + Plugins::fire('file.overwrite', false, ['file' => $this]); + } + else { + Plugins::fire('file.create', false, ['file' => $this]); + } + + $this->deleteCache(); + + // Index regular files, not directories + if ($this->type == self::TYPE_FILE) { + $this->indexForSearch(compact('content', 'path', 'pointer')); + } + + $db->commit(); + + return $this; + } + catch (\Exception $e) { + $db->rollback(); + throw $e; + } + finally { + if (null !== $pointer) { + fclose($pointer); + } + } + } + + public function indexForSearch(?array $source = null, ?string $title = null, ?string $forced_mime = null): void + { + $mime = $forced_mime ?? $this->mime; + $ext = $this->extension(); + $content = null; + + if ($this->isDir() && (!$mime || !isset($source['content']))) { + return; + } + + // Store content in search table + if (substr($mime, 0, 5) == 'text/') { + $content = $source['content'] ?? $this->fetch(); + + if ($mime === 'text/html' || $mime == 'text/xml') { + $content = html_entity_decode(strip_tags($content), ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML401); + } + } + elseif ($ext == 'pdf' && PDFTOTEXT_COMMAND === 'mupdf') { + if (empty($source)) { + // Prefer path to pointer + $source['path'] = $this->getLocalFilePath(); + $source['pointer'] = $source['path'] ? null : $this->getReadOnlyPointer(); + } + + $tmpfile = null; + + try { + // mutool convert doesn't handle stdin/stdout :( + if (isset($source['pointer'])) { + fseek($source['pointer'], 0, SEEK_END); + $size = ftell($source['pointer']); + rewind($source['pointer']); + + if ($size >= 500*1024*1024) { + throw new \OverflowException('PDF file is too large'); + } + + $tmpfile = tempnam(STATIC_CACHE_ROOT, 'pdftotext-'); + $fp = fopen($tmpfile, 'wb'); + + while (!feof($source['pointer'])) { + fwrite($fp, fread($source['pointer'], 8192)); + } + + fclose($fp); + } + elseif (isset($source['content'])) { + $tmpfile = tempnam(STATIC_CACHE_ROOT, 'pdftotext-'); + file_put_contents($tmpfile, $source['content']); + } + + $tmpdest = tempnam(STATIC_CACHE_ROOT, 'pdftotext-out-'); + + $cmd = sprintf('mutool convert -F text -o %s %s', + Utils::escapeshellarg($tmpdest), + Utils::escapeshellarg($tmpfile ?? $source['path']) + ); + + Utils::exec($cmd, 2, null, null); + $content = file_get_contents($tmpdest); + } + catch (\OverflowException $e) { + // PDF extraction was longer than 2 seconds: PDF file is likely too large + $content = null; + } + + if ($tmpfile) { + Utils::safe_unlink($tmpfile); + } + + Utils::safe_unlink($tmpdest); + } + elseif (in_array($ext, self::EXTENSIONS_TEXT_CONVERT) && is_array($source)) { + + if (empty($source)) { + $source['pointer'] = $this->getReadOnlyPointer(); + $source['path'] = $source['pointer'] ? null : $this->getLocalFilePath(); + } + + $content = ToText::from($source); + } + else { + $content = null; + } + + // Only index valid UTF-8 + if (isset($content) && preg_match('//u', $content)) { + // Truncate text at 150KB + $content = substr(trim($content), 0, 150*1024); + } + else { + $content = null; + } + + if (null === $content && null === $title) { + // This is already the same as what has been inserted by SQLite + return; + } + + $db = DB::getInstance(); + $db->preparedQuery('REPLACE INTO files_search (docid, path, title, content) VALUES (?, ?, ?, ?);', + $this->id(), $this->path, $title ?? $this->name, $content); + } + + /** + * Returns true if this is a vector or bitmap image + * as 'image' property is only for bitmaps + * @return boolean + */ + public function isImage(): bool + { + if ($this->image || $this->mime == 'image/svg+xml') { + return true; + } + + return false; + } + + public function isDir(): bool + { + return $this->type == self::TYPE_DIRECTORY; + } + + public function iconShape(): ?string + { + if ($this->isImage()) { + return 'image'; + } + elseif ($this->isDir()) { + return 'directory'; + } + + return Files::getIconShape($this->name); + } + + /** + * Full URL with https://... + */ + public function url(bool $download = false): string + { + $base = in_array($this->context(), [self::CONTEXT_WEB, self::CONTEXT_MODULES, self::CONTEXT_CONFIG]) ? WWW_URL : BASE_URL; + $url = $base . $this->uri(); + + if ($download) { + $url .= '?download'; + } + + return $url; + } + + /** + * Returns local URI, eg. user/1245/file.jpg + */ + public function uri(): string + { + if ($this->context() == self::CONTEXT_WEB) { + if ($this->isDir()) { + $parts = [$this->name]; + } + else { + $parts = [Utils::basename($this->parent), $this->name]; + } + } + else { + $parts = explode('/', $this->path); + } + + $parts = array_map('rawurlencode', $parts); + + return implode('/', $parts); + } + + /** + * Return a HTML link to the file + */ + public function link(Session $session, ?string $thumb = null, bool $allow_edit = false, ?string $url = null) + { + if ($thumb == 'auto') { + if ($this->hasThumbnail()) { + $thumb = '150px'; + } + else { + $thumb = 'icon'; + } + } + + if ($thumb === 'icon') { + $label = sprintf('', Utils::iconUnicode($this->iconShape())); + } + elseif ($thumb) { + $label = sprintf('%s', htmlspecialchars($this->thumb_url($thumb)), htmlspecialchars($this->name)); + } + else { + $label = preg_replace('/[_.-]/', '­$0', htmlspecialchars($this->name)); + } + + $editor = $this->editorType(); + + if ($url) { + $attrs = sprintf('href="%s"', Utils::getLocalURL($url)); + } + elseif ($editor && ($allow_edit || $editor == 'wopi') && $this->canWrite($session)) { + $attrs = sprintf('href="%s" target="_dialog" data-dialog-class="fullscreen"', + Utils::getLocalURL('!common/files/edit.php?p=') . rawurlencode($this->path)); + } + elseif ($this->canPreview($session)) { + $attrs = sprintf('href="%s" target="_dialog" data-mime="%s" data-caption="%s"', + $this->isImage() ? $this->url() : Utils::getLocalURL('!common/files/preview.php?p=') . rawurlencode($this->path), + $this->mime, + $this->name + ); + } + else { + $attrs = sprintf('href="%s" target="_blank"', $this->url(true)); + } + + return sprintf('%s', $attrs, $label); + } + + /** + * Envoie le fichier au client HTTP + */ + public function serve($download = null): void + { + // Only simple files can be served, not directories + if ($this->type != self::TYPE_FILE) { + header('HTTP/1.1 404 Not Found', true, 404); + throw new UserException('Page non trouvée', 404); + } + + $this->_serve(null, $download); + + if (($path = $this->getLocalFilePath()) && in_array($this->context(), [self::CONTEXT_WEB, self::CONTEXT_CONFIG])) { + Web_Cache::link($this->uri(), $path); + } + } + + protected function _serve(?string $path = null, $download = null): void + { + if ($this->isPublic()) { + Utils::HTTPCache($this->etag(), $this->modified->getTimestamp()); + } + else { + // Disable browser cache + header('Pragma: private'); + header('Expires: -1'); + header('Cache-Control: private, must-revalidate, post-check=0, pre-check=0'); + } + + header('X-Powered-By: Paheko/PHP'); + + if (null === $path) { + $type = $this->mime; + + // Force CSS mimetype + if (substr($this->name, -4) == '.css') { + $type = 'text/css'; + } + elseif (substr($this->name, -3) == '.js') { + $type = 'text/javascript'; + } + + if (substr($type, 0, 5) == 'text/') { + $type .= ';charset=utf-8'; + } + + header(sprintf('Content-Type: %s', $type)); + header(sprintf('Content-Disposition: %s; filename="%s"', $download ? 'attachment' : 'inline', is_string($download) ? $download : $this->name)); + } + elseif (!file_exists($path)) { + throw new UserException('Le contenu du fichier est introuvable.'); + } + + // Use X-SendFile, if available, and storage has a local copy + if (Router::isXSendFileEnabled()) { + $local_path = $path ?? Files::callStorage('getLocalFilePath', $this); + + if ($local_path) { + Router::xSendFile($local_path); + return; + } + } + + // Disable gzip, against buffering issues + if (function_exists('apache_setenv')) { + @apache_setenv('no-gzip', 1); + } + + @ini_set('zlib.output_compression', 'Off'); + + // Don't return Content-Length on OVH, as their HTTP 2.0 proxy is buggy + // @see https://fossil.kd2.org/paheko/tktview/8b342877cda6ef7023b16277daa0ec8e39d949f8 + if (HOSTING_PROVIDER !== 'OVH') { + header(sprintf('Content-Length: %d', $path ? filesize($path) : $this->size)); + } + + if (@ob_get_length()) { + @ob_clean(); + } + + if (null === $path) { + $pointer = $this->getReadOnlyPointer(); + + if (null === $pointer) { + $path = $this->getLocalFilePath(); + } + } + + if (null !== $path) { + readfile($path); + } + elseif (null !== $pointer) { + while (!feof($pointer)) { + echo fread($pointer, 32*1024); + } + + fclose($pointer); + } + else { + header('HTTP/1.1 404 Not Found', true, 404); + header('Content-Type: text/html', true); + throw new UserException('Le contenu de ce fichier est introuvable', 404); + } + } + + public function fetch() + { + if ($this->type == self::TYPE_DIRECTORY) { + throw new \LogicException('Cannot fetch a directory'); + } + + $p = $this->getReadOnlyPointer(); + + if (null === $p) { + $path = Files::callStorage('getLocalFilePath', $this); + + if (!$path || !file_exists($path)) { + return ''; + } + + return file_get_contents($path); + } + + $out = ''; + + while (!feof($p)) { + $out .= fread($p, 8192); + } + + fclose($p); + return $out; + } + + public function render(?string $user_prefix = null) + { + $editor_type = $this->renderFormat(); + + if ($editor_type == 'skriv' || $editor_type == 'markdown') { + return Render::render($editor_type, $this->path, $this->fetch(), $user_prefix); + } + elseif ($editor_type == 'text') { + return sprintf('

%s
', htmlspecialchars($this->fetch())); + } + else { + throw new \LogicException('Cannot render file of this type'); + } + } + + public function pathHash(): string + { + return sha1($this->path); + } + + public function isPublic(): bool + { + $context = $this->context(); + + if ($context == self::CONTEXT_MODULES || $context == self::CONTEXT_WEB) { + return true; + } + + if ($context == self::CONTEXT_CONFIG) { + $file = array_search($this->path, Config::FILES); + + if ($file && in_array($file, Config::FILES_PUBLIC)) { + return true; + } + } + + return false; + } + + public function path_uri(): string + { + return rawurlencode($this->path); + } + + public function parent_uri(): string + { + return $this->parent ? rawurlencode($this->parent) : ''; + } + + public function getFormatDescription(): string + { + switch ($this->extension()) { + case 'odt': return 'Document LibreOffice'; + case 'ods': return 'Tableur LibreOffice'; + case 'odp': return 'Présentation LibreOffice'; + case 'odg': return 'Dessin LibreOffice'; + case 'doc': + case 'docx': return 'Document Microsoft Office'; + case 'xls': + case 'xlsx': return 'Tableur Microsoft Office'; + case 'ppt': + case 'pptx': return 'Présentation Microsoft Office'; + case 'pdf': return 'Document PDF'; + case 'png': + case 'webp': + case 'jpeg': + case 'jpg': + case 'gif': + return 'Image'; + case 'epub': + case 'mobi': + return 'Livre électronique'; + case 'md': return 'Texte MarkDown'; + case 'txt': return 'Texte'; + case 'mp3': + case 'ogg': + case 'aac': + case 'flac': + case 'opus': + case 'wav': + case 'wma': + return 'Fichier audio'; + case 'mkv': + case 'mp4': + case 'avi': + case 'mov': + case 'webm': + return 'Fichier vidéo'; + default: return 'Fichier'; + } + } + + public function extension(): ?string + { + $pos = strrpos($this->name, '.'); + + if (false === $pos) { + return null; + } + + return strtolower(substr($this->name, $pos+1)); + } + + static public function filterName(string $name): string + { + foreach (self::FORBIDDEN_CHARACTERS as $char) { + $name = str_replace($char, '', $name); + } + + return $name; + } + + static public function validateFileName(string $name): void + { + if (0 === strpos($name, '.ht') || $name == '.user.ini') { + throw new ValidationException('Nom de fichier interdit'); + } + + if (strlen($name) > 250) { + throw new ValidationException('Nom de fichier trop long'); + } + + foreach (self::FORBIDDEN_CHARACTERS as $char) { + if (strpos($name, $char) !== false) { + throw new ValidationException('Nom de fichier invalide, le caractère suivant est interdit : ' . $char); + } + } + + $extension = strtolower(substr($name, strrpos($name, '.')+1)); + + if (preg_match(self::FORBIDDEN_EXTENSIONS, $extension)) { + throw new ValidationException(sprintf('Extension de fichier "%s" non autorisée, merci de renommer le fichier avant envoi.', $extension)); + } + } + + static public function validatePath(string $path): array + { + if (false != strpos($path, '..')) { + throw new ValidationException('Chemin invalide: ' . $path); + } + + $parts = explode('/', trim($path, '/')); + + if (count($parts) < 1) { + throw new ValidationException('Chemin invalide: ' . $path); + } + + $context = array_shift($parts); + + if (!array_key_exists($context, self::CONTEXTS_NAMES)) { + throw new ValidationException('Contexte invalide: ' . $context); + } + + $name = array_pop($parts); + $ref = implode('/', $parts); + return [$context, $ref ?: null, $name]; + } + + /** + * Only admins can create or rename files to .html / .js + * This is to avoid XSS attacks from a non-admin user + */ + static public function validateCanHTML(string $name, string $path, ?Session $session = null): void + { + if (!preg_match('/\.(?:htm|js|xhtm)/', $name)) { + return; + } + + $session ??= Session::getInstance(); + + if (0 === strpos($path, self::CONTEXT_MODULES . '/web') && $session->canAccess($session::SECTION_WEB, $session::ACCESS_ADMIN)) { + return; + } + + if ($session->canAccess($session::SECTION_CONFIG, $session::ACCESS_ADMIN)) { + return; + } + + throw new ValidationException('Seuls les administrateurs peuvent créer des fichiers de ce type.'); + } + + public function renderFormat(): ?string + { + if (substr($this->name, -6) == '.skriv') { + $format = Render::FORMAT_SKRIV; + } + elseif (substr($this->name, -3) == '.md') { + $format = Render::FORMAT_MARKDOWN; + } + elseif (substr($this->mime, 0, 5) == 'text/' && $this->mime != 'text/html') { + $format = 'text'; + } + else { + $format = null; + } + + return $format; + } + + public function editorType(): ?string + { + static $text_extensions = ['css', 'txt', 'xml', 'html', 'htm', 'tpl', 'ini']; + + $ext = $this->extension(); + + $format = $this->renderFormat(); + + if ($format == Render::FORMAT_SKRIV || $format == Render::FORMAT_MARKDOWN) { + return 'web'; + } + elseif ($format == 'text' || in_array($ext, $text_extensions)) { + return 'code'; + } + elseif (!WOPI_DISCOVERY_URL) { + return null; + } + + if ($this->getWopiURL('edit')) { + return 'wopi'; + } + + return null; + } + + public function getWopiURL(?string $action = null): ?string + { + if (!WOPI_DISCOVERY_URL) { + return null; + } + + $cache_file = SHARED_CACHE_ROOT . '/wopi.json'; + static $data = null; + + if (null === $data) { + // We are caching discovery for 15 days, there is no need to request the server all the time + if (file_exists($cache_file) && filemtime($cache_file) >= 3600*24*15) { + $data = json_decode(file_get_contents($cache_file), true); + } + + if (!$data) { + try { + $data = WOPI::discover(WOPI_DISCOVERY_URL); + file_put_contents($cache_file, json_encode($data)); + } + catch (\RuntimeException $e) { + return null; + } + } + } + + $ext = $this->extension(); + $url = null; + + if ($action) { + $url = $data['extensions'][$ext][$action] ?? null; + $url ??= $data['mimetypes'][$this->mime][$action] ?? null; + } + elseif (isset($data['extensions'][$ext])) { + $url = current($data['extensions'][$ext]); + } + elseif (isset($data['mimetypes'][$this->mime])) { + $url = current($data['mimetypes'][$this->mime]); + } + + return $url; + } + + public function editorHTML(bool $readonly = false): ?string + { + $url = $this->getWopiURL('edit'); + + if (!$url) { + return null; + } + + $wopi = new WOPI; + $url = $wopi->setEditorOptions($url, [ + // Undocumented editor parameters + // see https://github.com/nextcloud/richdocuments/blob/2338e2ff7078040d54fc0c70a96c8a1b860f43a0/src/helpers/url.js#L49 + 'lang' => 'fr', + //'closebutton' => 1, + //'revisionhistory' => 1, + //'title' => 'Test', + 'permission' => $readonly || !$this->canWrite() ? 'readonly' : '', + ]); + $wopi->setStorage(new Storage(Session::getInstance())); + return $wopi->getEditorHTML($url, $this->path, $this->name); + } + + public function export(): array + { + return $this->asArray(true) + ['url' => $this->url()]; + } + + /** + * Returns a sharing link for a file, valid + * @param int $expiry Expiry, in hours + * @param string|null $password + * @return string + */ + public function createShareLink(int $expiry = 24, ?string $password = null): string + { + $expiry = intval(time() / 3600) + $expiry; + + $hash = $this->_createShareHash($expiry, $password); + + $expiry -= intval(gmmktime(0, 0, 0, 8, 1, 2022) / 3600); + $expiry = base_convert($expiry, 10, 36); + + return sprintf('%s%s?s=%s%s:%s', WWW_URL, $this->uri(), $password ? ':' : '', $hash, $expiry); + } + + protected function _createShareHash(int $expiry, ?string $password): string + { + $password = trim((string)$password) ?: null; + + $str = sprintf('%s:%s:%s:%s', SECRET_KEY, $this->path, $expiry, $password); + + $hash = hash('sha256', $str, true); + $hash = substr($hash, 0, 10); + $hash = Security::base64_encode_url_safe($hash); + return $hash; + } + + public function checkShareLinkRequiresPassword(string $str): bool + { + return substr($str, 0, 1) == ':'; + } + + public function checkShareLink(string $str, ?string $password): bool + { + $str = ltrim($str, ':'); + + $hash = strtok($str, ':'); + $expiry = strtok(''); + + if (!ctype_alnum($expiry)) { + return false; + } + + $expiry = (int)base_convert($expiry, 36, 10); + $expiry += intval(gmmktime(0, 0, 0, 8, 1, 2022) / 3600); + + if ($expiry < time()/3600) { + return false; + } + + $hash_check = $this->_createShareHash($expiry, $password); + + return hash_equals($hash, $hash_check); + } + + public function touch($date = null): void + { + if (null === $date) { + $date = new \DateTime; + } + elseif (!($date instanceof \DateTimeInterface) && ctype_digit($date)) { + $date = new \DateTime('@' . $date); + } + elseif (!($date instanceof \DateTimeInterface)) { + throw new \InvalidArgumentException('Invalid date string: ' . $date); + } + + Files::assertStorageIsUnlocked(); + Files::callStorage('touch', $this, $date); + $this->set('modified', $date); + $this->save(); + } + + public function getReadOnlyPointer() + { + return Files::callStorage('getReadOnlyPointer', $this); + } + + public function getRecursiveSize(): int + { + if ($this->type == self::TYPE_FILE) { + return $this->size; + } + + $db = DB::getInstance(); + return $db->firstColumn('SELECT SUM(size) FROM files + WHERE type = ? AND path LIKE ? ESCAPE \'!\';', + File::TYPE_FILE, + $db->escapeLike($this->path, '!') . '/%' + ) ?: 0; + } + + public function getRecursiveLastModified(): int + { + if ($this->type == self::TYPE_FILE) { + return $this->modified; + } + + $db = DB::getInstance(); + return $db->firstColumn('SELECT strftime(\'%s\', MAX(modified)) FROM files + WHERE type = ? AND path LIKE ? ESCAPE \'!\';', + File::TYPE_FILE, + $db->escapeLike($this->path, '!') . '/%' + ) ?: 0; + } + + public function webdav_root_url(): string + { + return BASE_URL . 'dav/' . $this->context() . '/'; + } +} diff --git a/src/include/lib/Paheko/Entities/Files/FilePermissionsTrait.php b/src/include/lib/Paheko/Entities/Files/FilePermissionsTrait.php new file mode 100644 index 0000000..c723fa5 --- /dev/null +++ b/src/include/lib/Paheko/Entities/Files/FilePermissionsTrait.php @@ -0,0 +1,163 @@ +checkShareLink($share_hash, $share_password)) { + return; + } + + if ($this->checkShareLinkRequiresPassword($share_hash)) { + $tpl = Template::getInstance(); + $has_password = (bool) $share_password; + + $tpl->assign(compact('has_password')); + $tpl->display('ask_share_password.tpl'); + return; + } + } + + if (!$this->canRead($session)) { + header('HTTP/1.1 403 Forbidden', true, 403); + throw new UserException('Vous n\'avez pas accès à ce fichier.', 403); + return; + } + } + + public function canRead(Session $session = null): bool + { + // Web pages and config files are always public + if ($this->isPublic()) { + return true; + } + + $session ??= Session::getInstance(); + + return $session->checkFilePermission($this->path, 'read'); + } + + public function canShare(Session $session = null): bool + { + $session ??= Session::getInstance(); + + if (!$session->isLogged()) { + return false; + } + + return $session->checkFilePermission($this->path, 'share'); + } + + public function canWrite(Session $session = null): bool + { + $session ??= Session::getInstance(); + + if (!$session->isLogged()) { + return false; + } + + return $session->checkFilePermission($this->path, 'write'); + } + + public function canDelete(Session $session = null): bool + { + $session ??= Session::getInstance(); + + if (!$session->isLogged()) { + return false; + } + + // Deny delete of directories in web context + if ($this->isDir() && $this->context() == self::CONTEXT_WEB) { + return false; + } + + return $session->checkFilePermission($this->path, 'delete'); + } + + public function canMoveTo(string $destination, Session $session = null): bool + { + $session ??= Session::getInstance(); + + if (!$session->isLogged()) { + return false; + } + + return $session->checkFilePermission($this->path, 'move') && $this->canDelete() && self::canCreate($destination); + } + + public function canCopyTo(string $destination, Session $session = null): bool + { + $session ??= Session::getInstance(); + + if (!$session->isLogged()) { + return false; + } + + return $this->canRead() && self::canCreate($destination); + } + + public function canCreateDirHere(Session $session = null) + { + if (!$this->isDir()) { + return false; + } + + $session ??= Session::getInstance(); + + if (!$session->isLogged()) { + return false; + } + + return $session->checkFilePermission($this->path, 'mkdir'); + } + + static public function canCreateDir(string $path, Session $session = null) + { + $session ??= Session::getInstance(); + + if (!$session->isLogged()) { + return false; + } + + return $session->checkFilePermission($path, 'mkdir'); + } + + public function canCreateHere(Session $session = null): bool + { + if (!$this->isDir()) { + return false; + } + + $session ??= Session::getInstance(); + + if (!$session->isLogged()) { + return false; + } + + return $session->checkFilePermission($this->path, 'create'); + } + + public function canRename(Session $session = null): bool + { + return $this->canCreate($this->parent ?? '', $session); + } + + static public function canCreate(string $path, Session $session = null): bool + { + $session ??= Session::getInstance(); + + if (!$session->isLogged()) { + return false; + } + + return $session->checkFilePermission($path, 'create'); + } +} diff --git a/src/include/lib/Paheko/Entities/Files/FileThumbnailTrait.php b/src/include/lib/Paheko/Entities/Files/FileThumbnailTrait.php new file mode 100644 index 0000000..4d55d03 --- /dev/null +++ b/src/include/lib/Paheko/Entities/Files/FileThumbnailTrait.php @@ -0,0 +1,503 @@ +image && !$this->hasThumbnail()) { + return; + } + + if (!isset($this->md5)) { + return; + } + + // clean up thumbnails + foreach (self::ALLOWED_THUMB_SIZES as $size => $operations) { + Static_Cache::remove(sprintf(self::THUMB_CACHE_ID, $this->md5, $size)); + + $uri = $this->thumb_uri($size, false); + + if ($uri) { + Web_Cache::delete($uri); + } + } + + if (!$this->image && $this->hasThumbnail()) { + Static_Cache::remove(sprintf(self::THUMB_CACHE_ID, $this->md5, 'document')); + } + } + + public function asImageObject(): ?Image + { + if (!$this->image) { + $path = $this->createDocumentThumbnail(); + + if (!$path) { + throw new \RuntimeException('Cannot get image object as document thumbnail does not exist'); + } + } + else { + $path = $this->getLocalFilePath(); + $pointer = $path !== null ? null : $this->getReadOnlyPointer(); + } + + if ($path) { + $i = new Image($path); + } + elseif ($pointer) { + $i = Image::createFromPointer($pointer, null, true); + } + else { + return null; + } + + return $i; + } + + public function thumb_uri($size = null, bool $with_hash = true): ?string + { + // Don't try to generate thumbnails for large files (> 25 MB), except if it's a video + if ($this->size > 1024*1024*25 && substr($this->mime ?? '', 0, 6) !== 'video/') { + return null; + } + + $ext = $this->extension(); + + if ($this->image) { + $ext = 'webp'; + } + elseif ($ext === 'md' || $ext === 'txt') { + $ext = 'svg'; + } + // We expect opendocument files to have an embedded thumbnail + elseif (in_array($ext, self::$_opendocument_extensions)) { + $ext = 'webp'; + } + elseif (null !== $this->getDocumentThumbnailCommand()) { + $ext = 'webp'; + } + else { + return null; + } + + if (is_int($size)) { + $size .= 'px'; + } + + $size = isset(self::ALLOWED_THUMB_SIZES[$size]) ? $size : key(self::ALLOWED_THUMB_SIZES); + $uri = sprintf('%s.%s.%s', $this->uri(), $size, $ext); + + if ($with_hash) { + $uri .= '?h=' . substr($this->etag(), 0, 10); + } + + return $uri; + } + + public function thumb_url($size = null, bool $with_hash = true): ?string + { + $uri = $this->thumb_uri($size, $with_hash); + + if (!$uri) { + return $uri; + } + + $base = in_array($this->context(), [self::CONTEXT_WEB, self::CONTEXT_MODULES, self::CONTEXT_CONFIG]) ? WWW_URL : BASE_URL; + return $base . $uri; + } + + public function hasThumbnail(): bool + { + return $this->thumb_url() !== null; + } + + protected function getDocumentThumbnailCommand(): ?string + { + if (!DOCUMENT_THUMBNAIL_COMMANDS || !is_array(DOCUMENT_THUMBNAIL_COMMANDS)) { + return null; + } + + static $libreoffice_extensions = ['doc', 'docx', 'ods', 'xls', 'xlsx', 'odp', 'odt', 'ppt', 'pptx', 'odg']; + static $mupdf_extensions = ['pdf', 'xps', 'cbz', 'epub', 'svg']; + static $collabora_extensions = ['doc', 'docx', 'ods', 'xls', 'xlsx', 'odp', 'odt', 'ppt', 'pptx', 'odg', 'pdf', 'svg']; + + $ext = $this->extension(); + + if (in_array('mupdf', DOCUMENT_THUMBNAIL_COMMANDS) && in_array($ext, $mupdf_extensions)) { + return 'mupdf'; + } + elseif (in_array('unoconvert', DOCUMENT_THUMBNAIL_COMMANDS) && in_array($ext, $libreoffice_extensions)) { + return 'unoconvert'; + } + elseif (in_array('collabora', DOCUMENT_THUMBNAIL_COMMANDS) + && class_exists('CurlFile') + && in_array($ext, $collabora_extensions) + && $this->getWopiURL()) { + return 'collabora'; + } + // Generate video thumbnails, for up to 1GB in size + elseif (in_array('ffmpeg', DOCUMENT_THUMBNAIL_COMMANDS) + && $this->mime + && substr($this->mime, 0, 6) === 'video/' + && $this->size < 1024*1024*1024) { + return 'ffmpeg'; + } + + return null; + } + + /** + * Extract PNG thumbnail from odt/ods/odp/odg ZIP archives. + * This is the most efficient way to get a thumbnail. + */ + protected function extractOpenDocumentThumbnail(string $destination): bool + { + $zip = new ZipReader; + + // We are not going to extract the archive, so it does not matter + $zip->enableSecurityCheck(false); + + $pointer = $this->getReadOnlyPointer(); + + try { + if ($pointer) { + $zip->setPointer($pointer); + } + else { + $zip->open($this->getLocalFilePath()); + } + + $i = 0; + $found = false; + + foreach ($zip->iterate() as $path => $entry) { + // There should not be more than 100 files in an opendocument archive, surely? + if (++$i > 100) { + break; + } + + // We only care about the thumbnail + if ($path !== 'Thumbnails/thumbnail.png') { + continue; + } + + // Thumbnail is larger than 500KB, abort, it's probably too weird + if ($entry['size'] > 1024*500) { + break; + } + + $zip->extract($entry, $destination); + $found = true; + break; + } + } + catch (\RuntimeException $e) { + // Invalid archive + $found = false; + } + + unset($zip); + + if ($pointer) { + fclose($pointer); + } + + return $found; + } + + /** + * Create a document thumbnail using external commands or Collabora Online API + */ + protected function createDocumentThumbnail(): ?string + { + $cache_id = sprintf(self::THUMB_CACHE_ID, $this->md5, 'document'); + $destination = Static_Cache::getPath($cache_id); + + if (in_array($this->extension(), self::$_opendocument_extensions) && $this->extractOpenDocumentThumbnail($destination)) { + return $destination; + } + + $command = $this->getDocumentThumbnailCommand(); + + if (!$command) { + return null; + } + + if (file_exists($destination) && filesize($destination)) { + return $destination; + } + + $local_path = $this->getLocalFilePath(); + $tmpfile = null; + + if (!$local_path) { + $p = $this->getReadOnlyPointer(); + + if (!$p) { + throw new \LogicException('The file cannot be found in storage, unable to create thumbnail: ' . $this->path); + } + + $tmpfile = tempnam(CACHE_ROOT, 'thumb-'); + $fp = fopen($tmpfile, 'wb'); + + while (!feof($p)) { + fwrite($fp, fread($p, 8192)); + } + + fclose($p); + fclose($fp); + unset($p, $fp); + } + + try { + if ($command === 'collabora') { + $url = parse_url(WOPI_DISCOVERY_URL); + $url = sprintf('%s://%s:%s/lool/convert-to', $url['scheme'], $url['host'], $url['port'] ?? ($url['scheme'] === 'https' ? 443 : 80)); + + // see https://vmiklos.hu/blog/pdf-convert-to.html + // but does not seem to be working right now (limited to PDF export?) + /* + $options = [ + 'PageRange' => ['type' => 'string', 'value' => '1'], + 'PixelWidth' => ['type' => 'int', 'value' => 10], + 'PixelHeight' => ['type' => 'int', 'value' => 10], + ]; + */ + + $curl = \curl_init($url); + curl_setopt($curl, CURLOPT_POST, 1); + curl_setopt($curl, CURLOPT_POSTFIELDS, [ + 'format' => 'png', + //'options' => json_encode($options), + 'file' => new \CURLFile($tmpfile ?? $local_path, $this->mime, $this->name), + ]); + + $fp = fopen($destination, 'wb'); + curl_setopt($curl, CURLOPT_FILE, $fp); + + curl_exec($curl); + $info = curl_getinfo($curl); + + if ($error = curl_error($curl)) { + Utils::safe_unlink($destination); + throw new \RuntimeException(sprintf('cURL error on "%s": %s', $url, $error)); + } + + curl_close($curl); + fclose($fp); + unset($curl); + + if (($code = $info['http_code']) != 200) { + Utils::safe_unlink($destination); + throw new \RuntimeException('Cannot fetch thumbnail from Collabora: code ' . $code . "\n" . json_encode($info)); + } + } + else { + if ($command === 'mupdf') { + // The single '1' at the end is to tell only to render the first page + $cmd = sprintf('mutool draw -F png -o %s -w 500 -h 500 -r 72 %s 1 2>&1', + Utils::escapeshellarg($destination), + Utils::escapeshellarg($tmpfile ?? $local_path) + ); + } + elseif ($command === 'unoconvert') { + // --filter-options PixelWidth=500 --filter-options PixelHeight=500 + // see https://github.com/unoconv/unoserver/issues/85 + // see https://github.com/unoconv/unoserver/issues/86 + $cmd = sprintf('unoconvert --convert-to png %s %s 2>&1', + Utils::escapeshellarg($tmpfile ?? $local_path), + Utils::escapeshellarg($destination) + ); + } + elseif ($command === 'ffmpeg') { + $cmd = sprintf('ffmpeg -ss 00:00:02 -i %s -frames:v 1 -f image2 -c png -vf scale="min(900\, iw)":-1 %s 2>&1', + Utils::escapeshellarg($tmpfile ?? $local_path), + Utils::escapeshellarg($destination) + ); + } + + $output = ''; + $code = Utils::exec($cmd, 5, null, function($data) use (&$output) { $output .= $data; }); + + // Don't trust code as it can return != 0 even if generation was OK + + if (!file_exists($destination) || filesize($destination) < 10) { + Utils::safe_unlink($destination); + throw new \RuntimeException($command . ' execution failed with code: ' . $code . "\n" . $output); + } + } + } + finally { + if ($tmpfile) { + Utils::safe_unlink($tmpfile); + } + } + + return $destination; + } + + /** + * Create a SVG thumbnail of a text/markdown file + * It's easy, we just transform it to HTML and embed the HTML in the SVG! + */ + protected function createSVGThumbnail(array $operations, string $destination): void + { + $width = 150; + + foreach ($operations as $operation) { + if ($operation[0] === 'resize') { + $width = (int) $operation[1]; + break; + } + } + + $text = substr($this->fetch(), 0, 1200); + $text = Markdown::instance()->text($text); + + $out = ' + + + ' . $text . ' + + '; + + file_put_contents($destination, $out); + } + + protected function createThumbnail(string $size, string $destination): void + { + $operations = self::ALLOWED_THUMB_SIZES[$size]; + + if ($this->extension() === 'md' || $this->extension() === 'txt') { + $this->createSVGThumbnail($operations, $destination); + return; + } + + $i = $this->asImageObject(); + + if (!$i) { + return; + } + + // Always autorotate first + $i->autoRotate(); + + $allowed_operations = ['resize', 'cropResize', 'flip', 'rotate', 'crop', 'trim']; + + if (!$this->image) { + array_unshift($operations, ['trim']); + } + + foreach ($operations as $operation) { + $arguments = array_slice($operation, 1); + $operation = $operation[0]; + + if (!in_array($operation, $allowed_operations)) { + throw new \InvalidArgumentException('Opération invalide: ' . $operation); + } + + $i->$operation(...$arguments); + } + + $format = null; + + if ($i->format() !== 'gif') { + $format = ['webp', null]; + } + + $i->save($destination, $format); + } + + /** + * Envoie une miniature à la taille indiquée au client HTTP + */ + public function serveThumbnail(string $size = null): void + { + if (!$this->hasThumbnail()) { + throw new UserException('Il n\'est pas possible de fournir une miniature pour ce fichier.', 404); + } + + if (!array_key_exists($size, self::ALLOWED_THUMB_SIZES)) { + throw new UserException('Cette taille de miniature n\'est pas autorisée.'); + } + + $cache_id = sprintf(self::THUMB_CACHE_ID, $this->md5, $size); + $destination = Static_Cache::getPath($cache_id); + + if (!Static_Cache::exists($cache_id)) { + try { + $this->createThumbnail($size, $destination); + } + catch (\RuntimeException $e) { + ErrorManager::reportExceptionSilent($e); + header('Content-Type: image/svg+xml', true); + echo $this->getMissingThumbnail($size); + return; + } + } + + $ext = $this->extension(); + + if ($ext === 'md' || $ext === 'txt') { + $type = 'image/svg+xml'; + } + else { + // We can lie here, it might be something else, it does not matter + $type = 'image/webp'; + } + + header('Content-Type: ' . $type, true); + $this->_serve($destination, false); + + if (in_array($this->context(), [self::CONTEXT_WEB, self::CONTEXT_CONFIG])) { + $uri = $this->thumb_uri($size, false); + Web_Cache::link($uri, $destination); + } + } + + protected function getMissingThumbnail(string $size): string + { + $w = preg_replace('/[^\d]+/', '', $size) ?: 150; + $h = intval($w / (2/3)); + return sprintf(' + + + + + + + + + + + + + + + ', $w, $h); + } +} diff --git a/src/include/lib/Paheko/Entities/Files/FileVersionsTrait.php b/src/include/lib/Paheko/Entities/Files/FileVersionsTrait.php new file mode 100644 index 0000000..7b34de1 --- /dev/null +++ b/src/include/lib/Paheko/Entities/Files/FileVersionsTrait.php @@ -0,0 +1,248 @@ +getModifiedProperty('size')) { + return; + } + + if (!in_array($this->context(), self::VERSIONED_CONTEXTS)) { + return; + } + + $policy = Files::getVersioningPolicy(); + + // Versioning is disabled + if ('none' === $policy) { + return; + } + + $config = Config::getInstance(); + $max_size = FILE_VERSIONING_MAX_SIZE ?? $config->file_versioning_max_size; + + // Don't version large files + if ($this->size > $max_size*1024*1024) { + return; + } + + $last = EM::getInstance(File::class)->col('SELECT name FROM @TABLE WHERE parent = ? ORDER BY name DESC LIMIT 1;', + self::CONTEXT_VERSIONS . '/' . $this->path); + + if ($last) { + $v = (int) strtok($last, '.'); + strtok(''); + $v++; + } + else { + $v = 1; + } + + if ($ts = $this->getModifiedProperty('modified')) { + $ts = clone $ts; + } + else { + $ts = $this->modified; + } + + // file pattern: versions/ORIGINAL_PATH/000001.TIMESTAMP.NAME + $name = $this->createVersionName($v, $ts->getTimestamp()); + $this->copy(sprintf('%s/%s/%s', self::CONTEXT_VERSIONS, $this->path, $name)); + } + + /** + * Delete versions linked to this file + */ + public function deleteVersions(): bool + { + $parent = Files::get(self::CONTEXT_VERSIONS . '/' . $this->path); + + if ($parent) { + return $parent->delete(); + } + + return true; + } + + public function pruneVersions(): void + { + $now = time(); + + $policy = Files::getVersioningPolicy(); + + // Versioning is disabled, but keep old versions + if ('none' === $policy) { + return; + } + + $versions_policy = Config::VERSIONING_POLICIES[$policy]['intervals']; + ksort($versions_policy); + + // last step + $max_step = $versions_policy[-1] ?? null; + unset($versions_policy[-1]); + + $versions = $this->listVersions(); + // Sort by timestamp, not by version + uasort($versions, fn($a, $b) => $a->timestamp == $b->timestamp ? 0 : ($a->timestamp > $b->timestamp ? -1 : 1)); + + $delete = []; + reset($versions_policy); + $step = current($versions_policy); + $last_timestamp = null; + + foreach ($versions as $v) { + if ($v->name) { + continue; + } + + $version_diff = $now - $v->timestamp; + + while ($version_diff > key($versions_policy) && $step !== false) { + // Skip to next interval by fetching next step + $step = next($versions_policy); + $last_timestamp = null; + } + + // Use max step value if we have reached the max interval + if (false === $step) { + $step = $max_step; + } + + // Keep named versions, but make them count for next step + if ($v->name) { + $keep = true; + } + elseif (!$last_timestamp) { + $keep = true; + } + elseif (($last_timestamp - $v->timestamp) > $step) { + $keep = true; + } + else { + // This version interval is already filled, delete + $delete[] = $v; + $keep = false; + } + + if ($keep) { + // Keep this version + $last_timestamp = $v->timestamp; + } + } + + unset($v); + + foreach ($delete as $v) { + $v->file->delete(); + } + } + + public function getVersion(int $v): ?self + { + $v = EM::getInstance(File::class)->one( + 'SELECT * FROM @TABLE WHERE parent = ? AND name LIKE ? LIMIT 1;', + self::CONTEXT_VERSIONS . '/' . $this->path, + sprintf('%08d.%%', $v) + ); + + if (!$v) { + throw new \InvalidArgumentException('Version not found'); + } + + return $v; + } + + public function getVersionMetadata(File $v): \stdClass + { + $out = (object) [ + 'version' => (int) strtok($v->name, '.'), + 'timestamp' => (int) strtok('.'), + 'name' => strtok(''), + 'size' => $v->size, + 'file' => $v, + ]; + + $out->date = \DateTime::createFromFormat('U', $out->timestamp); + + // Set to local timezone + $out->date->setTimeZone((new \DateTime)->getTimeZone()); + + return $out; + } + + public function renameVersion(int $v, string $name): void + { + $v = $this->getVersion($v); + + $meta = $this->getVersionMetadata($v); + $new_name = $this->createVersionName($meta->version, $meta->timestamp, trim($name)); + + $v->changeFileName($new_name); + } + + public function restoreVersion(int $v): void + { + $v = $this->getVersion($v); + + $this->createVersion(); + $v->rename($this->path); + } + + public function deleteVersion(int $v): void + { + $v = $this->getVersion($v); + $v->delete(); + } + + public function downloadVersion(int $v): void + { + $v = $this->getVersion($v); + $v->serve($this->name); + } + + public function listVersions(): array + { + $out = []; + $i = 1; + + foreach (Files::list(self::CONTEXT_VERSIONS . '/' . $this->path) as $v) { + $out[$v->name] = $this->getVersionMetadata($v); + } + + krsort($out); + return $out; + } + + public function getVersionsDirectory(): ?File + { + if ($this->context() === self::CONTEXT_VERSIONS) { + return null; + } + + return Files::get(self::CONTEXT_VERSIONS . '/' . $this->path); + } +} diff --git a/src/include/lib/Paheko/Entities/Module.php b/src/include/lib/Paheko/Entities/Module.php new file mode 100644 index 0000000..9f86abe --- /dev/null +++ b/src/include/lib/Paheko/Entities/Module.php @@ -0,0 +1,763 @@ + 'icône sur la page d\'accueil', + self::SNIPPET_USER => 'en bas de la fiche d\'un membre', + self::SNIPPET_TRANSACTION => 'en bas de la fiche d\'une écriture', + self::SNIPPET_MY_SERVICES => 'sur la page "Mes activités"', + self::SNIPPET_MY_DETAILS => 'sur la page "Mes infos personnelles"', + self::SNIPPET_BEFORE_NEW_TRANSACTION => 'avant le formulaire de saisie d\'écriture', + ]; + + const VALID_NAME_REGEXP = '/^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$/'; + + const TABLE = 'modules'; + + protected ?int $id; + + /** + * Directory name + */ + protected string $name; + + protected string $label; + protected ?string $description; + protected ?string $author; + protected ?string $author_url; + protected ?string $restrict_section; + protected ?int $restrict_level; + protected bool $home_button; + protected bool $menu; + protected ?\stdClass $config; + protected bool $enabled; + protected bool $web; + + /** + * System modules are always available, disabling them only hides the links + */ + protected bool $system; + + public function selfCheck(): void + { + $this->assert(preg_match(self::VALID_NAME_REGEXP, $this->name), 'Nom unique de module invalide: ' . $this->name); + $this->assert(trim($this->label) !== '', 'Le libellé ne peut rester vide'); + $this->assert(!isset($this->author_url) || preg_match('!^(?:https?://|mailto:)!', $this->author_url), 'L\'adresse du site de l\'auteur est invalide'); + $this->assert(!isset($this->restrict_section) || in_array($this->restrict_section, Session::SECTIONS, true), 'Restriction de section invalide'); + $this->assert(!isset($this->restrict_level) || in_array($this->restrict_level, Session::ACCESS_LEVELS, true), 'Restriction de niveau invalide'); + + if (!$this->exists()) { + $this->assert(!DB::getInstance()->test(self::TABLE, 'name = ?', $this->name), 'Un module existe déjà avec ce nom unique'); + } + } + + public function importForm(array $source = null) + { + if (null === $source) { + $source = $_POST; + } + + if (isset($source['restrict'])) { + $this->set('restrict_section', strtok($source['restrict'], '_') ?: null); + $this->set('restrict_level', (int)strtok('') ?: null); + } + + parent::importForm($source); + } + /** + * Fills information from module.ini file + */ + public function updateFromINI(bool $use_local = true): bool + { + if ($use_local && ($file = Files::get($this->path(self::META_FILE)))) { + $ini = $file->fetch(); + $from_dist = false; + } + elseif (file_exists($this->distPath(self::META_FILE))) { + $ini = file_get_contents($this->distPath(self::META_FILE)); + $from_dist = true; + } + else { + return false; + } + + try { + $ini = Utils::parse_ini_string($ini, false, \INI_SCANNER_TYPED); + } + catch (\RuntimeException $e) { + throw new ValidationException(sprintf('Le fichier module.ini est invalide pour "%s" : %s', $this->name, $e->getMessage(), 0, $e)); + } + + if (empty($ini)) { + return false; + } + + $ini = (object) $ini; + + if (!isset($ini->name)) { + return false; + } + + $restrict_section = null; + $restrict_level = null; + + if (isset($ini->restrict_section, $ini->restrict_level) + && array_key_exists($ini->restrict_level, Session::ACCESS_LEVELS) + && in_array($ini->restrict_section, Session::SECTIONS)) { + $restrict_section = $ini->restrict_section; + $restrict_level = Session::ACCESS_LEVELS[$ini->restrict_level]; + } + + $this->set('label', $ini->name); + $this->set('description', $ini->description ?? null); + $this->set('author', $ini->author ?? null); + $this->set('author_url', $ini->author_url ?? null); + $this->set('web', !empty($ini->web)); + $this->set('home_button', !empty($ini->home_button)); + $this->set('menu', !empty($ini->menu)); + $this->set('restrict_section', $restrict_section); + $this->set('restrict_level', $restrict_level); + + if ($from_dist && !empty($ini->system)) { + $this->set('system', true); + } + + return true; + } + + public function exportToIni(): void + { + $ini = ''; + + foreach ($this->asArray() as $key => $value) { + if ($key == 'name' || $key == 'id') { + continue; + } + + if ($key == 'label') { + $key = 'name'; + } + + if ($key == 'restrict_level') { + $value = array_search($value, Session::ACCESS_LEVELS); + } + + if (is_bool($value)) { + $value = $value ? 'true' : 'false'; + } + elseif (null === $value || trim($value) === '') { + $value = 'null'; + } + elseif (is_string($value)) { + $value = strtr($value, ['"' => '\\"', "'" => "\\'", '$' => '\\$']); + $value = '"' . $value . '"'; + } + + $ini .= sprintf("%s = %s\n", $key, $value); + } + + Files::createFromString($this->path('module.ini'), $ini); + } + + public function updateTemplates(): void + { + $check = self::SNIPPETS + [self::CONFIG_FILE => 'Config']; + $templates = []; + $db = DB::getInstance(); + + $db->begin(); + $db->delete('modules_templates', 'id_module = ' . (int)$this->id()); + + foreach ($check as $file => $label) { + if (Files::exists($this->path($file)) || file_exists($this->distPath($file))) { + $templates[] = $file; + $db->insert('modules_templates', ['id_module' => $this->id(), 'name' => $file]); + } + } + + foreach ($this->listFiles('snippets/markdown') as $file) { + $db->insert('modules_templates', ['id_module' => $this->id(), 'name' => $file->path]); + } + + $db->commit(); + } + + public function icon_url(): ?string + { + if (!$this->hasFile(self::ICON_FILE)) { + return null; + } + + return $this->url(self::ICON_FILE); + } + + public function config_url(): ?string + { + if (!$this->hasFile(self::CONFIG_FILE)) { + return null; + } + + return $this->url(self::CONFIG_FILE); + } + + public function storage_root(): string + { + return File::CONTEXT_EXTENSIONS . '/m/' . $this->name; + } + + public function path(string $file = null): string + { + return self::ROOT . '/' . $this->name . ($file ? '/' . $file : ''); + } + + public function distPath(string $file = null): string + { + return self::DIST_ROOT . '/' . $this->name . ($file ? '/' . $file : ''); + } + + public function dir(): ?File + { + return Files::get($this->path()); + } + + public function storage(): ?File + { + return Files::get($this->storage_root()); + } + + public function hasFile(string $file): bool + { + return $this->hasLocalFile($file) || $this->hasDistFile($file); + } + + public function hasDist(): bool + { + return file_exists($this->distPath()); + } + + public function hasLocal(): bool + { + return Files::exists($this->path()); + } + + public function hasLocalFile(string $path): bool + { + return Files::exists($this->path($path)); + } + + public function hasDistFile(string $path): bool + { + return @file_exists($this->distPath($path)); + } + + public function fetchFile(string $path): ?string + { + if ($this->hasLocalFile($path)) { + return $this->fetchLocalFile($path); + } + + return $this->fetchDistFile($path); + } + + public function fetchLocalFile(string $path): ?string + { + $file = Files::get($this->path($path)); + return !$file ? null : $file->fetch(); + } + + public function fetchDistFile(string $path): ?string + { + return @file_get_contents($this->distPath($path)) ?: null; + } + + public function hasConfig(): bool + { + return DB::getInstance()->test('modules_templates', 'id_module = ? AND name = ?', $this->id(), self::CONFIG_FILE); + } + + public function hasData(): bool + { + return DB::getInstance()->test('sqlite_master', 'type = \'table\' AND name = ?', sprintf('module_data_%s', $this->name)); + } + + public function getDataSize(): int + { + return DB::getInstance()->getTableSize(sprintf('module_data_%s', $this->name)); + } + + public function getCodeSize(): int + { + $dir = $this->dir(); + + if ($dir) { + return $dir->getRecursiveSize(); + } + + return 0; + } + + public function getFilesSize(): int + { + $dir = $this->storage(); + + if ($dir) { + return $dir->getRecursiveSize(); + } + + return 0; + } + + public function canDelete(): bool + { + return !$this->enabled && $this->hasLocal() && !$this->hasDist(); + } + + public function canReset(): bool + { + return $this->hasLocal() && $this->hasDist(); + } + + public function canDeleteData(): bool + { + return !empty($this->config) || $this->hasData(); + } + + public function listFiles(?string $path = null): array + { + $out = []; + $base = File::CONTEXT_MODULES . '/' . $this->name; + + if ($path && false !== strpos($path, '..')) { + return []; + } + + $path ??= ''; + $local_path = trim($base . '/' . $path, '/'); + + foreach (Files::list($local_path) as $file) { + $_path = substr($file->path, strlen($base . '/')); + + $out[$file->name] = (object) [ + 'name' => $file->name, + 'dir' => $file->isDir(), + 'path' => $_path, + 'file_path' => $file->path, + 'type' => $file->mime, + 'local' => true, + 'dist' => false, + 'file' => $file, + ]; + } + + $dist_path = $this->distPath($path); + + if (is_dir($dist_path)) { + foreach (scandir($dist_path) as $file) { + if (substr($file, 0, 1) == '.') { + continue; + } + + if (isset($out[$file])) { + $out[$file]->dist = true; + continue; + } + + $out[$file] = (object) [ + 'name' => $file, + 'type' => mime_content_type($dist_path . '/' . $file), + 'dir' => is_dir($dist_path . '/' . $file), + 'path' => trim($path . '/' . $file, '/'), + 'local' => false, + 'dist' => true, + 'file_path' => $base . '/' . trim($path . '/' . $file, '/'), + 'file' => null, + 'dist_path' => $dist_path . '/' . $file, + ]; + } + } + + foreach ($out as &$file) { + $file->editable = !$file->dir && (UserTemplate::isTemplate($file->path) + || substr($file->type, 0, 5) === 'text/' + || preg_match('/\.(?:json|md|skriv|html|css|js|ini)$/', $file->name)); + $file->open_url = '!common/files/preview.php?p=' . rawurlencode($file->file_path); + $file->edit_url = '!common/files/edit.php?fallback=code&p=' . rawurlencode($file->file_path); + $file->delete_url = '!common/files/delete.php?p=' . rawurlencode($file->file_path); + } + + unset($file); + + uasort($out, function ($a, $b) { + if ($a->dir == $b->dir) { + return strnatcasecmp($a->name, $b->name); + } + elseif ($a->dir && !$b->dir) { + return -1; + } + else { + return 1; + } + }); + + + return $out; + } + + public function delete(): bool + { + $this->resetChanges(); + $this->deleteData(); + + return parent::delete(); + } + + public function resetChanges(): void + { + $dir = $this->dir(); + + if ($dir) { + $dir->delete(); + } + } + + public function deleteData(): void + { + DB::getInstance()->exec(sprintf('DROP TABLE IF EXISTS module_data_%s; UPDATE modules SET config = NULL WHERE name = \'%1$s\';', $this->name)); + + // Delete all files + if ($dir = Files::get($this->storage_root())) { + $dir->delete(); + } + } + + public function url(string $file = '', array $params = null) + { + if (null !== $params) { + $params = '?' . http_build_query($params); + } + + if ($this->web && $this->enabled && !$file) { + return BASE_URL; + } + + return sprintf('%sm/%s/%s%s', BASE_URL, $this->name, $file, $params); + } + + public function public_url(string $file = '', array $params = null) + { + return str_replace(BASE_URL, WWW_URL, $this->url($file, $params)); + } + + public function isValidPath(string $path): bool + { + return (bool) preg_match('!^(?:[\w\d_-]+/)*[\w\d_-]+(?:\.[\w\d_-]+)*$!i', $path); + } + + public function validatePath(string $path): void + { + if (!$this->isValidPath($path)) { + throw new \InvalidArgumentException('Invalid skeleton name'); + } + } + + public function template(string $file) + { + if ($file == self::CONFIG_FILE) { + Session::getInstance()->requireAccess(Session::SECTION_CONFIG, Session::ACCESS_ADMIN); + } + + $this->validatePath($file); + + $ut = new UserTemplate($this->name . '/' . $file); + $ut->setModule($this); + + return $ut; + } + + public function fetch(string $file, array $params): string + { + $ut = $this->template($file); + $ut->assignArray($params); + return $ut->fetch(); + } + + public function serve(string $path, bool $has_local_file, array $params = []): void + { + if (substr(Utils::basename($path), 0, 1) === '.') { + throw new UserException('Unknown path', 404); + } + + if (UserTemplate::isTemplate($path)) { + // Error if path is not valid + // we allow any path for static files, but not for skeletons + if (!$this->isValidPath($path)) { + if ($this->web) { + $path = '404.html'; + } + else { + http_response_code(404); + throw new UserException('This address is invalid.'); + } + } + + if ($this->web) { + $this->serveWeb($path, $params); + return; + } + else { + $ut = $this->template($path); + $ut->serve($params); + } + + return; + } + // Serve a static file from a user module + elseif ($has_local_file) { + $file = Files::get(File::CONTEXT_MODULES . '/' . $this->name . '/' . $path); + + if (!$file) { + throw new UserException('Invalid path'); + } + + $file->validateCanRead(); + $file->serve(); + } + // Serve a static file from dist path + else { + $type = $this->getFileTypeFromExtension($path); + $real_path = $this->distPath($path); + + if (!is_file($real_path)) { + throw new UserException('Invalid path', 404); + } + + if ($this->web) { + // Create symlink to static file + Cache::link($path, $real_path); + } + + http_response_code(200); + header(sprintf('Content-Type: %s;charset=utf-8', $type), true); + readfile($real_path); + flush(); + } + } + + public function serveWeb(string $path, array $params): void + { + $uri = $params['uri'] ?? null; + + // Fire signal before display of a web page + $plugin_params = ['path' => $path, 'uri' => $uri, 'module' => $this]; + $module = $this; + + $signal = Plugins::fire('web.request.before', true, compact('path', 'uri', 'module')); + + if ($signal && $signal->isStopped()) { + return; + } + + unset($signal); + + $type = null; + + $ut = $this->template($path); + $ut->assignArray($params); + $content = $ut->fetch(); + $type = $ut->getContentType(); + $code = $ut->getStatusCode(); + + if ($uri !== null && preg_match('!html|xml|text!', $type) && !$ut->get('nocache') && $code == 200) { + $cache = true; + } + else { + $cache = false; + } + + // Call plugins, allowing them to modify the content + $signal = Plugins::fire( + 'web.request', + true, + compact('path', 'uri', 'module', 'content', 'type', 'cache', 'code'), + compact('type', 'cache', 'content', 'code') + ); + + if ($signal && $signal->isStopped()) { + return; + } + + if ($signal) { + $ut->setHeader('type', $signal->getOut('type')); + $ut->setHeader('code', $signal->getOut('code')); + $cache = $signal->getOut('cache'); + $content = $signal->getOut('content'); + } + + unset($signal); + + $ut->dumpHeaders(); + + if ($type == 'application/pdf') { + Utils::streamPDF($content); + } + else { + echo $content; + } + + if ($cache) { + Cache::store($uri, $content); + } + + Plugins::fire('web.request.after', false, compact('path', 'uri', 'module', 'content', 'type', 'cache')); + } + + public function getFileTypeFromExtension(string $path): ?string + { + $dot = strrpos($path, '.'); + + // Templates with no extension are returned as HTML by default + // unless {{:http type=...}} is used + if ($dot === false) { + return 'text/html'; + } + + // Templates with no extension are returned as HTML by default + // unless {{:http type=...}} is used + if ($dot === false) { + return 'text/html'; + } + + $ext = substr($path, $dot+1); + + // Common types + switch ($ext) { + case 'txt': + return 'text/plain'; + case 'html': + case 'htm': + case 'tpl': + case 'btpl': + case 'skel': + return 'text/html'; + case 'xml': + return 'text/xml'; + case 'css': + return 'text/css'; + case 'js': + return 'text/javascript'; + case 'png': + case 'gif': + case 'webp': + return 'image/' . $ext; + case 'svg': + return 'image/svg+xml'; + case 'jpeg': + case 'jpg': + return 'image/jpeg'; + default: + return null; + } + } + + public function export(Session $session): void + { + $download_name = 'module_' . $this->name; + + header('Content-type: application/zip'); + header(sprintf('Content-Disposition: attachment; filename="%s"', $download_name. '.zip')); + + $target = 'php://output'; + $zip = new ZipWriter($target); + $zip->setCompression(9); + + $add = function ($path) use ($zip, &$add) { + foreach ($this->listFiles($path) as $file) { + if ($file->dir) { + $add($file->path); + } + elseif ($file->local) { + if ($pointer = $file->file->getReadOnlyPointer()) { + $zip->addFromPointer($file->file_path, $pointer); + } + elseif ($path = $file->file->getLocalFilePath()) { + $zip->addFromPath($file->file_path, $path); + } + } + else { + $zip->addFromPath($file->file_path, $file->dist_path); + } + } + }; + + $add(null); + + $zip->close(); + } + + public function save(bool $selfcheck = true): bool + { + $enabled_web = isset($this->_modified['enabled']) && $this->enabled && $this->web; + $r = parent::save($selfcheck); + + if ($r && $enabled_web) { + DB::getInstance()->preparedQuery('UPDATE modules SET enabled = 0 WHERE web = 1 AND enabled = 1 AND name != ?;', $this->name); + } + + return $r; + } + + public function listSnippets(): array + { + $out = []; + + foreach (DB::getInstance()->iterate('SELECT name FROM modules_templates WHERE id_module = ? AND name LIKE \'snippets/%\';', $this->id()) as $row) { + $label = self::SNIPPETS[$row->name] ?? null; + + if (!$label && ($match = sscanf($row->name, 'snippets/markdown/%[^.].html'))) { + $label = sprintf('extension MarkDown <<%s>>, utilisable dans les pages du site web', current($match)); + } + + $out[$row->name] = $label; + } + + return $out; + } +} diff --git a/src/include/lib/Paheko/Entities/Plugin.php b/src/include/lib/Paheko/Entities/Plugin.php new file mode 100644 index 0000000..19ed353 --- /dev/null +++ b/src/include/lib/Paheko/Entities/Plugin.php @@ -0,0 +1,434 @@ +name); + } + + public function selfCheck(): void + { + $this->assert(preg_match('/^' . Plugins::NAME_REGEXP . '$/', $this->name), 'Nom unique d\'extension invalide: ' . $this->name); + $this->assert(isset($this->label) && trim($this->label) !== '', sprintf('%s : le nom de l\'extension ("name") ne peut rester vide', $this->name)); + $this->assert(isset($this->label) && trim($this->version) !== '', sprintf('%s : la version ne peut rester vide', $this->name)); + + if ($this->hasCode() || $this->enabled) { + $this->assert($this->hasFile(self::META_FILE), 'Le fichier plugin.ini est absent'); + $this->assert(!$this->menu || $this->hasFile(self::INDEX_FILE), 'Le fichier admin/index.php n\'existe pas alors que la directive "menu" est activée.'); + $this->assert(!$this->home_button || $this->hasFile(self::INDEX_FILE), 'Le fichier admin/index.php n\'existe pas alors que la directive "home_button" est activée.'); + $this->assert(!$this->home_button || $this->hasFile(self::ICON_FILE), 'Le fichier admin/icon.svg n\'existe pas alors que la directive "home_button" est activée.'); + } + + $this->assert(!isset($this->restrict_section) || in_array($this->restrict_section, Session::SECTIONS, true), 'Restriction de section invalide'); + $this->assert(!isset($this->restrict_level) || in_array($this->restrict_level, Session::ACCESS_LEVELS, true), 'Restriction de niveau invalide'); + } + + public function setBrokenMessage(string $str) + { + $this->_broken_message = $str; + } + + public function getBrokenMessage(): ?string + { + return $this->_broken_message; + } + + /** + * Fills information from plugin.ini file + */ + public function updateFromINI(): bool + { + if (!$this->hasFile(self::META_FILE)) { + return false; + } + + try { + $ini = Utils::parse_ini_file($this->path(self::META_FILE), false); + } + catch (\RuntimeException $e) { + throw new ValidationException(sprintf('Le fichier plugin.ini est invalide pour "%s" : %s', $this->name, $e->getMessage(), 0, $e)); + } + + if (empty($ini)) { + return false; + } + + $ini = (object) $ini; + + if (!isset($ini->name)) { + return false; + } + + $this->assert(empty($ini->min_version) || version_compare(\Paheko\paheko_version(), $ini->min_version, '>='), sprintf('L\'extension "%s" nécessite Paheko version %s ou supérieure.', $this->name, $ini->min_version)); + + $restrict_section = null; + $restrict_level = null; + + if (isset($ini->restrict_section, $ini->restrict_level) + && array_key_exists($ini->restrict_level, Session::ACCESS_LEVELS) + && in_array($ini->restrict_section, Session::SECTIONS)) { + $restrict_section = $ini->restrict_section; + $restrict_level = Session::ACCESS_LEVELS[$ini->restrict_level]; + } + + $this->set('label', $ini->name); + $this->set('version', $ini->version); + $this->set('description', $ini->description ?? null); + $this->set('author', $ini->author ?? null); + $this->set('author_url', $ini->author_url ?? null); + $this->set('home_button', !empty($ini->home_button)); + $this->set('menu', !empty($ini->menu)); + $this->set('restrict_section', $restrict_section); + $this->set('restrict_level', $restrict_level); + + return true; + } + + public function icon_url(): ?string + { + if (!$this->hasFile(self::ICON_FILE)) { + return null; + } + + return $this->url(self::ICON_FILE); + } + + public function path(string $file = null): ?string + { + $path = Plugins::getPath($this->name); + + if (!$path) { + return null; + } + + return $path . ($file ? '/' . $file : ''); + } + + public function hasFile(string $file): bool + { + $path = $this->path($file); + + if (!$path) { + return false; + } + + return file_exists($path); + } + + public function hasConfig(): bool + { + return $this->hasFile(self::CONFIG_FILE); + } + + public function fetchFile(string $path): ?string + { + if (!$this->hasFile($path)) { + return null; + } + + return file_get_contents($this->path($path)); + } + + public function url(string $file = '', array $params = null) + { + if (null !== $params) { + $params = '?' . http_build_query($params); + } + + if (substr($file, 0, 6) == 'admin/') { + $url = ADMIN_URL; + $file = substr($file, 6); + } + else { + $url = WWW_URL; + } + + return sprintf('%sp/%s/%s%s', $url, $this->name, $file, $params); + } + + public function storage_root(): string + { + return File::CONTEXT_EXTENSIONS . '/p/' . $this->name; + } + + public function getConfig(string $key = null) + { + if (is_null($key)) { + return $this->config; + } + + if ($this->config && property_exists($this->config, $key)) { + return $this->config->$key; + } + + return null; + } + + public function setConfigProperty(string $key, $value = null) + { + if (null === $this->config) { + $this->config = new \stdClass; + } + + if (is_null($value)) { + unset($this->config->$key); + } + else { + $this->config->$key = $value; + } + + $this->_modified['config'] = true; + } + + public function setConfig(\stdClass $config) + { + $this->config = $config; + $this->_modified['config'] = true; + } + + /** + * Associer un signal à un callback du plugin + * @param string $signal Nom du signal (par exemple boucle.agenda pour la boucle de type AGENDA) + * @param mixed $callback Callback, sous forme d'un nom de fonction ou de méthode statique + * @return boolean TRUE + */ + public function registerSignal(string $signal, callable $callback): void + { + $callable_name = ''; + + if (!is_callable($callback, true, $callable_name) || !is_string($callable_name)) + { + throw new \LogicException('Le callback donné n\'est pas valide.'); + } + + // pour empêcher d'appeler des méthodes de Paheko après un import de base de données "hackée" + if (strpos($callable_name, 'Paheko\\Plugin\\') !== 0) + { + throw new \LogicException('Le callback donné n\'utilise pas le namespace Paheko\\Plugin : ' . $callable_name); + } + + $db = DB::getInstance(); + + $callable_name = str_replace('Paheko\\Plugin\\', '', $callable_name); + + $db->preparedQuery('INSERT OR REPLACE INTO plugins_signals VALUES (?, ?, ?);', [$signal, $this->name, $callable_name]); + } + + public function unregisterSignal(string $signal): void + { + DB::getInstance()->preparedQuery('DELETE FROM plugins_signals WHERE plugin = ? AND signal = ?;', [$this->name, $signal]); + } + + public function delete(): bool + { + if ($this->hasFile(self::UNINSTALL_FILE)) { + $this->call(self::UNINSTALL_FILE, true); + } + + // Delete all files + if ($dir = Files::get($this->storage_root())) { + $dir->delete(); + } + + $db = DB::getInstance(); + $db->delete('plugins_signals', 'plugin = ?', $this->name); + return parent::delete(); + } + + /** + * Renvoie TRUE si le plugin a besoin d'être mis à jour + * (si la version notée dans la DB est différente de la version notée dans paheko_plugin.ini) + * @return boolean TRUE si le plugin doit être mis à jour, FALSE sinon + */ + public function needUpgrade(): bool + { + try { + $infos = (object) Utils::parse_ini_file($this->path(self::META_FILE), false); + } + catch (\RuntimeException $e) { + return false; + } + + if (version_compare($this->version, $infos->version, '!=')) { + return true; + } + + return false; + } + + /** + * Mettre à jour le plugin + * Appelle le fichier upgrade.php dans l'archive si celui-ci existe. + */ + public function upgrade(): void + { + $this->updateFromINI(); + + if ($this->hasFile(self::UPGRADE_FILE)) { + $this->call(self::UPGRADE_FILE, true); + } + + $this->save(); + } + + public function upgradeIfRequired(): void + { + if ($this->needUpgrade()) { + $this->upgrade(); + } + } + + public function oldVersion(): ?string + { + return $this->getModifiedProperty('version'); + } + + public function call(string $file, bool $allow_protected = false): void + { + $file = ltrim($file, './'); + + if (preg_match('!(?:\.\.|[/\\\\]\.|\.[/\\\\])!', $file)) { + throw new UserException('Chemin de fichier incorrect.'); + } + + if (!$allow_protected && in_array($file, self::PROTECTED_FILES)) { + throw new UserException('Le fichier ' . $file . ' ne peut être appelé par cette méthode.'); + } + + $path = $this->path($file); + + if (!file_exists($path)) { + throw new UserException(sprintf('Le fichier "%s" n\'existe pas dans le plugin "%s"', $file, $this->name)); + } + + if (is_dir($path)) { + throw new UserException(sprintf('Sécurité : impossible de lister le répertoire "%s" du plugin "%s".', $file, $this->name)); + } + + $is_private = (0 === strpos($file, 'admin/')); + + // Créer l'environnement d'exécution du plugin + if (substr($file, -4) === '.php') { + if (substr($file, 0, 6) == 'admin/' || substr($file, 0, 7) == 'public/') { + define('Paheko\PLUGIN_ROOT', $this->path()); + define('Paheko\PLUGIN_URL', WWW_URL . 'p/' . $this->name . '/'); + define('Paheko\PLUGIN_ADMIN_URL', ADMIN_URL .'p/' . $this->name . '/'); + define('Paheko\PLUGIN_QSP', '?'); + + $tpl = Template::getInstance(); + + if ($is_private) { + require ROOT . '/www/admin/_inc.php'; + $tpl->assign('current', 'plugin_' . $this->name); + } + + $tpl->assign('plugin', $this); + $tpl->assign('plugin_url', \Paheko\PLUGIN_URL); + $tpl->assign('plugin_admin_url', \Paheko\PLUGIN_ADMIN_URL); + $tpl->assign('plugin_root', \Paheko\PLUGIN_ROOT); + } + + $plugin = $this; + + include $path; + } + else { + Plugins::routeStatic($this->name, $file); + } + } + + public function route(string $uri): void + { + $uri = ltrim($uri, '/'); + $session = Session::getInstance(); + + if (0 === strpos($uri, 'admin/')) { + if (!$session->isLogged()) { + Utils::redirect('!login.php'); + } + + // Restrict access + if (isset($this->restrict_section, $this->restrict_level)) { + $session->requireAccess($this->restrict_section, $this->restrict_level); + } + } + + if (!$uri || substr($uri, -1) == '/') { + $uri .= 'index.php'; + } + + try { + $this->call($uri); + } + catch (UserException $e) { + http_response_code(404); + throw new UserException($e->getMessage()); + } + } + + public function isAvailable(): bool + { + return $this->hasFile(self::META_FILE); + } +} \ No newline at end of file diff --git a/src/include/lib/Paheko/Entities/Search.php b/src/include/lib/Paheko/Entities/Search.php new file mode 100644 index 0000000..8663935 --- /dev/null +++ b/src/include/lib/Paheko/Entities/Search.php @@ -0,0 +1,336 @@ + 'Recherche avancée', + self::TYPE_SQL => 'Recherche SQL', + self::TYPE_SQL_UNPROTECTED => 'Recherche SQL non protégée', + ]; + + const TARGET_USERS = 'users'; + const TARGET_ACCOUNTING = 'accounting'; + const TARGET_ALL = 'all'; + + const TARGETS = [ + self::TARGET_USERS => 'Membres', + self::TARGET_ACCOUNTING => 'Comptabilité', + ]; + + /** + * Match the last LIMIT clause from the SQL query + * Will match: + * SELECT * FROM table LIMIT 5 -> LIMIT 5 + * SELECT ... (... LIMIT 5) LIMIT 10 -> LIMIT 10 + * SELECT * FROM (SELECT * FROM bla LIMIT 10) -> no match (as the limit is in a subquery) + */ + const LIMIT_REGEXP = '/LIMIT\s+\d+(?:\s*,\s*-?\d+|\s+OFFSET\s+-?\d+)?(?!.*LIMIT\s+-?\d+|.*\))/is'; + + protected ?int $id; + protected ?int $id_user = null; + protected string $label; + protected \DateTime $created; + protected string $target; + protected string $type; + protected string $content; + + protected $_result = null; + protected $_as = null; + + public function selfCheck(): void + { + parent::selfCheck(); + + $this->assert(strlen('label') > 0, 'Le champ libellé doit être renseigné'); + $this->assert(strlen('label') <= 500, 'Le champ libellé est trop long'); + + $db = DB::getInstance(); + + if ($this->id_user !== null) { + $this->assert($db->test('users', 'id = ?', $this->id_user), 'Numéro de membre inconnu'); + } + + $this->assert(array_key_exists($this->type, self::TYPES)); + $this->assert(array_key_exists($this->target, self::TARGETS)); + + $this->assert(strlen($this->content), 'Le contenu de la recherche ne peut être vide'); + + if ($this->type === self::TYPE_JSON) { + $this->assert(json_decode($this->content) !== null, 'Recherche invalide pour le type JSON'); + } + } + + public function getDynamicList(): DynamicList + { + if ($this->type == self::TYPE_JSON) { + return $this->getAdvancedSearch()->make($this->content); + } + else { + throw new \LogicException('SQL search cannot be used as dynamic list'); + } + } + + public function getAdvancedSearch(): AdvancedSearch + { + if ($this->target == self::TARGET_ACCOUNTING) { + $class = 'Paheko\Accounting\AdvancedSearch'; + } + else { + $class = 'Paheko\Users\AdvancedSearch'; + } + + if (null === $this->_as || !is_a($this->_as, $class)) { + $this->_as = new $class; + } + + return $this->_as; + } + + public function transformToSQL() + { + if ($this->type != self::TYPE_JSON) { + throw new \LogicException('Cannot transform a non-JSON search to SQL'); + } + + $sql = $this->getDynamicList()->SQL(); + + // Remove indentation + $sql = preg_replace('/^\s*/m', '', $sql); + + $this->set('content', $sql); + $this->set('type', self::TYPE_SQL); + } + + public function SQL(array $options = []): string + { + if ($this->type == self::TYPE_JSON) { + $sql = $this->getDynamicList()->SQL(); + } + else { + $sql = $this->content; + } + + $has_limit = stripos($sql, 'LIMIT') !== false; + + // force LIMIT + if (!empty($options['limit'])) { + $regexp = $has_limit ? self::LIMIT_REGEXP : '/;[^;]*$|(_result && empty($options['no_cache'])) { + return $this->_result; + } + + $sql = $this->SQL($options); + + $allowed_tables = $this->getProtectedTables(); + $db = DB::getInstance(); + + try { + $db->toggleUnicodeLike(true); + + // Lock database against changes + $db->setReadOnly(true); + + $st = $db->protectSelect($allowed_tables, $sql); + $result = $db->execute($st); + + $db->setReadOnly(false); + + if (empty($options['no_cache'])) { + $this->_result = $result; + } + + return $result; + } + catch (DB_Exception $e) { + throw new UserException('Erreur dans la requête : ' . $e->getMessage(), 0, $e); + } + finally { + $db->toggleUnicodeLike(false); + } + } + + public function getHeader(array $options = []): array + { + $r = $this->query($options); + $columns = []; + + for ($i = 0; $i < $r->numColumns(); $i++) { + $columns[] = $r->columnName($i); + } + + return $columns; + } + + public function iterateResults(): iterable + { + $r = $this->query(); + + while ($row = $r->fetchArray(\SQLITE3_NUM)) { + yield $row; + } + } + + public function hasUserId(): bool + { + try { + $sql = $this->SQL(); + + if (!preg_match('/(?:FROM|JOIN)\s+users/i', $sql)) { + return false; + } + + $header = $this->getHeader(['limit' => 1, 'no_cache' => true]); + } + catch (UserException $e) { + return false; + } + + if (!in_array('id', $header) && !in_array('_user_id', $header)) { + return false; + } + + return true; + } + + public function hasLimit(): bool + { + if ($this->type === self::TYPE_JSON) { + return false; + } + + return (bool) preg_match(self::LIMIT_REGEXP, $this->SQL()); + } + + public function countResults(bool $ignore_errors = true): ?int + { + $sql = $this->SQL(['no_limit' => true, 'no_cache' => true]); + $sql = rtrim($sql); + $sql = rtrim($sql, ';'); + $sql = 'SELECT COUNT(*) FROM (' . $sql . ')'; + + $allowed_tables = $this->getProtectedTables(); + $db = DB::getInstance(); + + try { + $db->toggleUnicodeLike(true); + + // Lock database against changes + $db->setReadOnly(true); + $st = $db->protectSelect($allowed_tables, $sql); + $r = $db->execute($st); + $db->setReadOnly(false); + + $count = (int) $r->fetchArray(\SQLITE3_NUM)[0] ?? 0; + $r->finalize(); + $st->close(); + return $count; + } + catch (DB_Exception $e) { + if ($ignore_errors) { + return null; + } + + throw new UserException('Erreur dans la requête : ' . $e->getMessage(), 0, $e); + } + finally { + $db->toggleUnicodeLike(false); + } + } + + public function export(string $format, string $title = 'Recherche') + { + CSV::export($format, $title, $this->iterateResults(), $this->getHeader()); + } + + public function schema(): array + { + $out = []; + $db = DB::getInstance(); + + foreach ($this->getAdvancedSearch()->schemaTables() as $table => $comment) { + $schema = $db->getTableSchema($table); + $schema['comment'] = $comment; + $out[$table] = $schema; + } + + return $out; + } + + public function getProtectedTables(): ?array + { + if ($this->type != self::TYPE_SQL || $this->target == self::TARGET_ALL) { + return null; + } + + $list = $this->getAdvancedSearch()->tables(); + $tables = []; + + foreach ($list as $name) { + $tables[$name] = null; + } + + return $tables; + } + + public function getGroups(): array + { + if ($this->type != self::TYPE_JSON) { + throw new \LogicException('Only JSON searches can use this method'); + } + + return json_decode($this->content, true)['groups']; + } + + public function quick(string $query): DynamicList + { + $this->content = json_encode($this->getAdvancedSearch()->simple($query, false)); + $this->type = self::TYPE_JSON; + return $this->getDynamicList(); + } +} diff --git a/src/include/lib/Paheko/Entities/Services/Fee.php b/src/include/lib/Paheko/Entities/Services/Fee.php new file mode 100644 index 0000000..4472bab --- /dev/null +++ b/src/include/lib/Paheko/Entities/Services/Fee.php @@ -0,0 +1,256 @@ +assert(trim($this->label) !== '', 'Le libellé doit être renseigné'); + $this->assert(strlen((string) $this->label) <= 200, 'Le libellé doit faire moins de 200 caractères'); + $this->assert(strlen((string) $this->description) <= 2000, 'La description doit faire moins de 2000 caractères'); + $this->assert(null === $this->amount || $this->amount > 0, 'Le montant est invalide : ' . $this->amount); + $this->assert($this->id_service, 'Aucun service n\'a été indiqué pour ce tarif.'); + $this->assert((null === $this->id_account && null === $this->id_year) + || (null !== $this->id_account && null !== $this->id_year), 'Le compte doit être indiqué avec l\'exercice'); + $this->assert(null === $this->id_account || $db->test(Account::TABLE, 'id = ?', $this->id_account), 'Le compte indiqué n\'existe pas'); + $this->assert(null === $this->id_year || $db->test(Year::TABLE, 'id = ?', $this->id_year), 'L\'exercice indiqué n\'existe pas'); + $this->assert(null === $this->id_account || $db->test(Account::TABLE, 'id = ? AND id_chart = (SELECT id_chart FROM acc_years WHERE id = ?)', $this->id_account, $this->id_year), 'Le compte sélectionné ne correspond pas à l\'exercice'); + $this->assert(null === $this->id_project || $db->test(Project::TABLE, 'id = ?', $this->id_project), 'Le projet sélectionné n\'existe pas.'); + + if (null !== $this->formula && ($error = $this->checkFormula())) { + throw new ValidationException('Formule de calcul invalide: ' . $error); + } + + $this->assert(null === $this->amount || null === $this->formula, 'Il n\'est pas possible de spécifier à la fois une formule et un montant'); + } + + public function getAmountForUser(int $user_id): ?int + { + if ($this->amount) { + return $this->amount; + } + elseif ($this->formula) { + $db = DB::getInstance(); + return (int) $db->firstColumn($this->getFormulaSQL(), $user_id); + } + + return null; + } + + protected function getFormulaSQL() + { + return sprintf('SELECT (%s) FROM users WHERE id = ?;', $this->formula); + } + + protected function checkFormula(): ?string + { + try { + $db = DB::getInstance(); + $sql = $this->getFormulaSQL(); + $db->protectSelect(['users' => null, 'services_users' => null, 'services' => null, 'services_fees' => null], $sql); + return null; + } + catch (DB_Exception $e) { + return $e->getMessage(); + } + } + + public function service() + { + return EntityManager::findOneById(Service::class, $this->id_service); + } + + public function allUsersList(bool $include_hidden_categories = false): DynamicList + { + $identity = DynamicFields::getNameFieldsSQL('u'); + + $columns = [ + 'id_user' => [ + 'select' => 'su.id_user', + ], + 'service_label' => [ + 'select' => 's.label', + 'label' => 'Activité', + 'export' => true, + ], + 'fee_label' => [ + 'select' => 'sf.label', + 'label' => 'Tarif', + 'export' => true, + ], + 'user_number' => [ + 'label' => 'Numéro de membre', + 'select' => 'u.' . DynamicFields::getNumberField(), + 'export' => true, + ], + 'identity' => [ + 'label' => 'Membre', + 'select' => $identity, + ], + 'paid' => [ + 'label' => 'Payé ?', + 'select' => 'su.paid', + 'order' => 'su.paid %s, su.date %1$s', + ], + 'paid_amount' => [ + 'label' => 'Montant payé', + 'select' => 'CASE WHEN tu.id_service_user IS NOT NULL THEN SUM(l.credit) ELSE NULL END', + ], + 'date' => [ + 'label' => 'Date', + 'select' => 'su.date', + ], + ]; + + $tables = 'services_users su + INNER JOIN users u ON u.id = su.id_user + INNER JOIN services_fees sf ON sf.id = su.id_fee + INNER JOIN services s ON s.id = sf.id_service + INNER JOIN (SELECT id, MAX(date) FROM services_users GROUP BY id_user, id_fee) AS su2 ON su2.id = su.id + LEFT JOIN acc_transactions_users tu ON tu.id_service_user = su.id + LEFT JOIN acc_transactions_lines l ON l.id_transaction = tu.id_transaction'; + $conditions = sprintf('su.id_fee = %d', $this->id()); + + if (!$include_hidden_categories) { + $conditions .= ' AND u.id_category NOT IN (SELECT id FROM users_categories WHERE hidden = 1)'; + } + + $list = new DynamicList($columns, $tables, $conditions); + $list->groupBy('su.id_user'); + $list->orderBy('paid', true); + $list->setCount('COUNT(DISTINCT su.id_user)'); + + $list->setExportCallback(function (&$row) { + $row->paid_amount = $row->paid_amount ? Utils::money_format($row->paid_amount, '.', '', false) : null; + }); + + return $list; + } + + public function activeUsersList(bool $include_hidden_categories = false): DynamicList + { + $list = $this->allUsersList(); + $conditions = sprintf('su.id_fee = %d AND (su.expiry_date >= date() OR su.expiry_date IS NULL) + AND su.paid = 1', $this->id()); + + if (!$include_hidden_categories) { + $conditions .= ' AND u.id_category NOT IN (SELECT id FROM users_categories WHERE hidden = 1)'; + } + + $list->setConditions($conditions); + return $list; + } + + public function unpaidUsersList(bool $include_hidden_categories = false): DynamicList + { + $list = $this->allUsersList(); + $conditions = sprintf('su.id_fee = %d AND su.paid = 0', $this->id()); + + if (!$include_hidden_categories) { + $conditions .= ' AND u.id_category NOT IN (SELECT id FROM users_categories WHERE hidden = 1)'; + } + + $list->setConditions($conditions); + return $list; + } + + public function expiredUsersList(bool $include_hidden_categories = false): DynamicList + { + $list = $this->allUsersList(); + $conditions = sprintf('su.id_fee = %d AND su.expiry_date < date()', $this->id()); + + if (!$include_hidden_categories) { + $conditions .= ' AND u.id_category NOT IN (SELECT id FROM users_categories WHERE hidden = 1)'; + } + + $list->setConditions($conditions); + return $list; + } + + + public function getUsers(bool $paid_only = false): array + { + $where = $paid_only ? 'AND paid = 1' : ''; + $id_field = DynamicFields::getNameFieldsSQL('u'); + $sql = sprintf('SELECT su.id_user, %s FROM services_users su INNER JOIN users u ON u.id = su.id_user WHERE su.id_fee = ? %s;', $id_field, $where); + return DB::getInstance()->getAssoc($sql, $this->id()); + } + + public function hasSubscriptions(): bool + { + return DB::getInstance()->test('services_users', 'id_fee = ?', $this->id()); + } +} diff --git a/src/include/lib/Paheko/Entities/Services/Reminder.php b/src/include/lib/Paheko/Entities/Services/Reminder.php new file mode 100644 index 0000000..2d76ed3 --- /dev/null +++ b/src/include/lib/Paheko/Entities/Services/Reminder.php @@ -0,0 +1,153 @@ +assert($this->id_service, 'Aucun service n\'a été indiqué pour ce tarif.'); + $this->assert(trim($this->subject) !== '', 'Le sujet doit être renseigné'); + $this->assert(strlen($this->subject) <= 200, 'Le sujet doit faire moins de 200 caractères'); + $this->assert(trim($this->body) !== '', 'Le corps du message doit être renseigné'); + $this->assert(strlen($this->body) <= 64000, 'Le corps du message doit faire moins de 64.000 caractères'); + $this->assert($this->delay !== null, 'Le délai de rappel doit être renseigné'); + } + + public function service() + { + return EntityManager::findOneById(Service::class, $this->id_service); + } + + public function importForm(array $source = null) + { + if (null === $source) { + $source = $_POST; + } + + if (isset($source['delay_type'])) { + if (1 == $source['delay_type'] && !empty($source['delay_before'])) { + $source['delay'] = (int)$source['delay_before'] * -1; + } + elseif (2 == $source['delay_type'] && !empty($source['delay_after'])) { + $source['delay'] = (int)$source['delay_after']; + } + else { + $source['delay'] = 0; + } + } + + // Warning: inverse logic here + if (!empty($source['yes_before'])) { + $source['not_before_date'] = null; + } + elseif (isset($source['yes_before']) && empty($source['yes_before'])) { + $source['not_before_date'] = date('Y-m-d'); + } + + parent::importForm($source); + } + + public function sentList(): DynamicList + { + $id_field = DynamicFields::getNameFieldsSQL('u'); + $db = DB::getInstance(); + + $columns = [ + 'id_user' => [ + 'select' => 'srs.id_user', + ], + 'identity' => [ + 'label' => 'Membre', + 'select' => $id_field, + ], + 'reminder_date' => [ + 'label' => 'Date d\'envoi', + 'select' => 'srs.sent_date', + 'order' => 'srs.sent_date %s, srs.id %1$s', + ], + ]; + + $tables = 'services_reminders_sent srs + INNER JOIN users u ON u.id = srs.id_user'; + $conditions = sprintf('srs.id_reminder = %d', $this->id()); + + $list = new DynamicList($columns, $tables, $conditions); + $list->orderBy('reminder_date', true); + return $list; + } + + public function pendingList(): DynamicList + { + $db = DB::getInstance(); + + $columns = [ + 'id_user' => [ + 'select' => 'id', + ], + 'identity' => [ + 'label' => 'Membre', + ], + 'expiry_date' => [ + 'label' => 'Date d\'expiration', + ], + 'reminder_date' => [ + 'label' => 'Date d\'envoi', + ], + ]; + + $conditions = sprintf('su.id_service = %d AND sr.id = %d', $this->id_service, $this->id); + $tables = '(' . Reminders::getPendingSQL(false, $conditions) . ') AS pending'; + + $list = new DynamicList($columns, $tables); + $list->orderBy('expiry_date', false); + return $list; + } + + public function getPreview(int $id_user): ?string + { + $conditions = sprintf('su.id_service = %d AND su.id_user = %d AND sr.id = %d', $this->id_service, $id_user, $this->id); + $sql = Reminders::getPendingSQL(false, $conditions); + $db = DB::getInstance(); + + foreach ($db->iterate($sql) as $reminder) { + $m = Reminders::createMessage($reminder); + return $m->getMessage($reminder); + } + + return null; + } + + public function deleteHistory(): void + { + $db = DB::getInstance(); + $db->exec(sprintf('DELETE FROM services_reminders_sent WHERE id_reminder = %s;', $this->id)); + } +} diff --git a/src/include/lib/Paheko/Entities/Services/ReminderMessage.php b/src/include/lib/Paheko/Entities/Services/ReminderMessage.php new file mode 100644 index 0000000..f0d23d3 --- /dev/null +++ b/src/include/lib/Paheko/Entities/Services/ReminderMessage.php @@ -0,0 +1,112 @@ +body ?? $this->reminder()->body; + + if (false !== strpos($body, '{{')) { + $body = '{{**keep_whitespaces**}}' . $body; + return UserTemplate::createFromUserString($body); + } + else { + return $body; + } + } + + public function getMessage(stdClass $reminder): string + { + $body = $this->getBody($reminder); + + $r = self::getMessageVariables($reminder); + + if ($body instanceof UserTemplate) { + $body->assignArray($r, null, false); + + try { + $body = $body->fetch(); + } + catch (\KD2\Brindille_Exception $e) { + throw new UserException('Erreur de syntaxe dans le corps du message :' . PHP_EOL . $e->getPrevious()->getMessage(), 0, $e); + } + } + + return $body; + } + + static public function getMessageVariables(stdClass $reminder): array + { + $reminder = Fees::addUserAmountToObject($reminder, $reminder->id_user); + $reminder->user_amount = CommonModifiers::money_currency($reminder->user_amount ?? 0, true, false, false); + $reminder->reminder_date = CommonModifiers::date_short($reminder->reminder_date); + $reminder->expiry_date = CommonModifiers::date_short($reminder->expiry_date); + + return (array) $reminder; + } + + public function reminder(): ?Reminder + { + if (!$this->id_reminder) { + return null; + } + + $this->_reminder ??= Reminders::get($this->id_reminder); + return $this->_reminder; + } + + public function send(stdClass $reminder, $body = null) + { + $body ??= $this->getBody($reminder); + $data = ['data' => (array)self::getMessageVariables($reminder)]; + + foreach (DynamicFields::getEmailFields() as $email_field) { + $email = $reminder->$email_field ?? null; + + if (empty($email)) { + continue; + } + + $data['data']['email'] = $email; + + // Envoi du mail + Emails::queue(Emails::CONTEXT_PRIVATE, [$email => $data], null, $reminder->subject, $body); + } + + $this->save(); + + Plugins::fire('reminder.send.after', false, compact('reminder')); + } + +} diff --git a/src/include/lib/Paheko/Entities/Services/Service.php b/src/include/lib/Paheko/Entities/Services/Service.php new file mode 100644 index 0000000..b9cf4d5 --- /dev/null +++ b/src/include/lib/Paheko/Entities/Services/Service.php @@ -0,0 +1,212 @@ +assert(trim((string) $this->label) !== '', 'Le libellé doit être renseigné'); + $this->assert(strlen((string) $this->label) <= 200, 'Le libellé doit faire moins de 200 caractères'); + $this->assert(strlen((string) $this->description) <= 2000, 'La description doit faire moins de 2000 caractères'); + $this->assert(!isset($this->duration, $this->start_date, $this->end_date) || $this->duration || ($this->start_date && $this->end_date), 'Seulement une option doit être choisie : durée ou dates de début et de fin de validité'); + $this->assert(null === $this->start_date || $this->start_date instanceof \DateTimeInterface); + $this->assert(null === $this->end_date || $this->end_date instanceof \DateTimeInterface); + $this->assert(null === $this->duration || (is_int($this->duration) && $this->duration > 0), 'La durée n\'est pas valide'); + $this->assert(null === $this->start_date || $this->end_date >= $this->start_date, 'La date de fin de validité ne peut être avant la date de début'); + } + + public function importForm(?array $source = null) + { + if (null === $source) { + $source = $_POST; + } + + if (isset($source['period'])) { + if (1 == $source['period']) { + $source['start_date'] = $source['end_date'] = null; + } + elseif (2 == $source['period']) { + $source['duration'] = null; + } + else { + $source['duration'] = $source['start_date'] = $source['end_date'] = null; + } + } + + if (isset($source['archived_present']) && empty($source['archived'])) { + $source['archived'] = false; + } + + parent::importForm($source); + } + + public function fees() + { + return new Fees($this->id()); + } + + public function allUsersList(bool $include_hidden_categories = false): DynamicList + { + $id_field = DynamicFields::getNameFieldsSQL('u'); + $columns = [ + 'id_user' => [ + ], + 'end_date' => [ + ], + 'service_label' => [ + 'select' => 's.label', + 'label' => 'Activité', + 'export' => true, + ], + 'fee_label' => [ + 'select' => 'sf.label', + 'label' => 'Tarif', + 'export' => true, + ], + 'user_number' => [ + 'label' => 'Numéro de membre', + 'select' => 'u.' . DynamicFields::getNumberField(), + 'export' => true, + ], + 'identity' => [ + 'label' => 'Membre', + 'select' => $id_field, + ], + 'status' => [ + 'label' => 'Statut', + 'select' => 'CASE WHEN su.expiry_date < date() THEN -1 WHEN su.expiry_date >= date() THEN 1 ELSE 0 END', + ], + 'paid' => [ + 'label' => 'Payé ?', + 'select' => 'su.paid', + 'order' => 'su.paid %s, su.date %1$s', + ], + 'expiry' => [ + 'label' => 'Date d\'expiration', + 'select' => 'MAX(su.expiry_date)', + ], + 'fee' => [ + 'label' => 'Tarif', + 'select' => 'sf.label', + ], + 'date' => [ + 'label' => 'Date d\'inscription', + 'select' => 'su.date', + ], + ]; + + $tables = 'services_users su + INNER JOIN users u ON u.id = su.id_user + INNER JOIN services s ON s.id = su.id_service + LEFT JOIN services_fees sf ON sf.id = su.id_fee + INNER JOIN (SELECT id, MAX(date) FROM services_users GROUP BY id_user, id_service) AS su2 ON su2.id = su.id'; + $conditions = sprintf('su.id_service = %d', $this->id()); + + if (!$include_hidden_categories) { + $conditions .= ' AND u.id_category NOT IN (SELECT id FROM users_categories WHERE hidden = 1)'; + } + + $list = new DynamicList($columns, $tables, $conditions); + $list->groupBy('su.id_user'); + $list->orderBy('paid', true); + $list->setCount('COUNT(DISTINCT su.id_user)'); + + $list->setExportCallback(function (&$row) { + $row->status = $row->status == -1 ? 'En retard' : ($row->status == 1 ? 'En cours' : ''); + $row->paid = $row->paid ? 'Oui' : 'Non'; + }); + + return $list; + } + + public function activeUsersList(bool $include_hidden_categories = false): DynamicList + { + $list = $this->allUsersList(); + $conditions = sprintf('su.id_service = %d AND (su.expiry_date >= date() OR su.expiry_date IS NULL) + AND su.paid = 1', $this->id()); + + if (!$include_hidden_categories) { + $conditions .= ' AND u.id_category NOT IN (SELECT id FROM users_categories WHERE hidden = 1)'; + } + + $list->setConditions($conditions); + return $list; + } + + public function unpaidUsersList(bool $include_hidden_categories = false): DynamicList + { + $list = $this->allUsersList(); + $conditions = sprintf('su.id_service = %d AND su.paid = 0', $this->id()); + + if (!$include_hidden_categories) { + $conditions .= ' AND u.id_category NOT IN (SELECT id FROM users_categories WHERE hidden = 1)'; + } + + $list->setConditions($conditions); + return $list; + } + + public function expiredUsersList(bool $include_hidden_categories = false): DynamicList + { + $list = $this->allUsersList(); + $conditions = sprintf('su.id_service = %d AND su.expiry_date < date()', $this->id()); + + if (!$include_hidden_categories) { + $conditions .= ' AND u.id_category NOT IN (SELECT id FROM users_categories WHERE hidden = 1)'; + } + + $list->setConditions($conditions); + return $list; + } + + public function hasSubscriptions(): bool + { + return DB::getInstance()->test('services_users', 'id_service = ?', $this->id()); + } + + public function getUsers(bool $paid_only = false) { + $where = $paid_only ? 'AND paid = 1' : ''; + $id_field = DynamicFields::getNameFieldsSQL('u'); + $sql = sprintf('SELECT su.id_user, %s FROM services_users su INNER JOIN users u ON u.id = su.id_user WHERE su.id_service = ? %s;', $id_field, $where); + return DB::getInstance()->getAssoc($sql, $this->id()); + } + + public function long_label(): string + { + if ($this->duration) { + $duration = sprintf('%d jours', $this->duration); + } + elseif ($this->start_date) + $duration = sprintf('du %s au %s', $this->start_date->format('d/m/Y'), $this->end_date->format('d/m/Y')); + else { + $duration = 'ponctuelle'; + } + + return sprintf('%s — %s', $this->label, $duration); + } +} diff --git a/src/include/lib/Paheko/Entities/Services/Service_User.php b/src/include/lib/Paheko/Entities/Services/Service_User.php new file mode 100644 index 0000000..6170179 --- /dev/null +++ b/src/include/lib/Paheko/Entities/Services/Service_User.php @@ -0,0 +1,278 @@ +assert($this->id_service, 'Aucune activité spécifiée'); + $this->assert($this->id_user, 'Aucun membre spécifié'); + $this->assert(!$this->isDuplicate(), 'Cette activité a déjà été enregistrée pour ce membre, ce tarif et cette date'); + + $db = DB::getInstance(); + // don't allow an id_fee that does not match a service + if (null !== $this->id_fee && !$db->test(Fee::TABLE, 'id = ? AND id_service = ?', $this->id_fee, $this->id_service)) { + $this->set('id_fee', null); + } + } + + public function isDuplicate(bool $using_date = true): bool + { + if (!isset($this->id_user, $this->id_service)) { + throw new \LogicException('Entity does not define either user or service'); + } + + $params = [ + 'id_user' => $this->id_user, + 'id_service' => $this->id_service, + 'id_fee' => $this->id_fee, + ]; + + if ($using_date) { + $params['date'] = $this->date->format('Y-m-d'); + } + + $where = array_map(fn($k) => sprintf('%s = ?', $k), array_keys($params)); + $where = implode(' AND ', $where); + + if ($this->exists()) { + $where .= sprintf(' AND id != %d', $this->id()); + } + + return DB::getInstance()->test(self::TABLE, $where, array_values($params)); + } + + public function importForm(?array $source = null) + { + if (null === $source) { + $source = $_POST; + } + + $service = null; + + if (!empty($source['id_service']) && empty($source['expiry_date'])) { + $service = $this->_service = Services::get((int) $source['id_service']); + + if (!$service) { + throw new \LogicException('The requested service is not found'); + } + + if ($service->duration) { + $dt = new Date; + $dt->modify(sprintf('+%d days', $service->duration)); + $this->set('expiry_date', $dt); + } + elseif ($service->end_date) { + $this->set('expiry_date', $service->end_date); + } + else { + $this->set('expiry_date', null); + } + } + + if (!empty($source['id_service'])) { + if (!$service) { + $service = $this->_service = Services::get((int) $source['id_service']); + } + } + + return parent::importForm($source); + } + + public function service(): Service + { + if (null === $this->_service) { + $this->_service = Services::get($this->id_service); + } + + return $this->_service; + } + + /** + * Returns the Fee entity linked to this subscription + * This can be NULL if there was no fee existing at the time of subscription + * (that way you can use subscriptions without fees if you want) + */ + public function fee(): ?Fee + { + if (null === $this->id_fee) { + return null; + } + + if (null === $this->_fee) { + $this->_fee = Fees::get($this->id_fee); + } + + return $this->_fee; + } + + public function addPayment(int $user_id, ?array $source = null): Transaction + { + if (null === $source) { + $source = $_POST; + } + + if (!$this->id_fee) { + throw new \RuntimeException('Cannot add a payment to a subscription that is not linked to a fee'); + } + + if (!$this->fee()->id_year) { + throw new ValidationException('Le tarif indiqué ne possède pas d\'exercice lié'); + } + + if (empty($source['amount'])) { + throw new ValidationException('Montant non précisé'); + } + + $account = Form::getSelectorValue($source['account_selector'] ?? null); + + if (!$account) { + throw new ValidationException('Aucune compte n\'a été sélectionné.'); + } + + $label = $this->service()->label; + + if ($this->fee()->label != $label) { + $label .= ' - ' . $this->fee()->label; + } + + $label .= sprintf(' (%s)', Users::getName($this->id_user)); + + $transaction = Transactions::create(array_merge($source, [ + 'type' => Transaction::TYPE_REVENUE, + 'label' => $label, + 'id_project' => $source['id_project'] ?? $this->fee()->id_project, + 'simple' => [Transaction::TYPE_REVENUE => [ + 'credit' => [$this->fee()->id_account => null], + 'debit' => $source['account_selector'], + ]], + 'id_year' => $this->fee()->id_year, + ])); + + $transaction->id_creator = $user_id; + $transaction->id_year = $this->fee()->id_year; + $transaction->type = Transaction::TYPE_REVENUE; + + $transaction->save(); + $transaction->linkToSubscription($this->id()); + + return $transaction; + } + + public function updateExpectedAmount(): void + { + $fee = $this->fee(); + + if ($fee && $fee->id_account && $this->id_user) { + $this->set('expected_amount', $fee->getAmountForUser($this->id_user)); + } + else { + $this->set('expected_amount', null); + } + } + + static public function createFromForm(array &$users, int $creator_id, bool $from_copy = false, ?array $source = null): self + { + if (null === $source) { + $source = $_POST; + } + + $db = DB::getInstance(); + $db->begin(); + + if (!count($users)) { + throw new ValidationException('Aucun membre n\'a été sélectionné.'); + } + + $multiple_users = count($users) > 1; + $errors = []; + + foreach ($users as $id => $name) { + $su = new self; + $su->date = new Date; + $su->importForm($source); + $su->id_user = (int) $id; + + if (empty($su->id_service)) { + throw new ValidationException('Aucune activité n\'a été sélectionnée.'); + } + + $su->updateExpectedAmount(); + + if ($su->isDuplicate($from_copy ? false : true)) { + if ($from_copy) { + continue; + } + else { + $errors[] = $name; + + if (!$multiple_users) { + throw new ValidationException(sprintf('%s : Cette activité a déjà été enregistrée pour ce membre et cette date', $name)); + } + + unset($users[$id]); + continue; + } + } + + $su->save(); + + if ($su->id_fee && $su->fee()->id_account + && !empty($source['amount']) + && !empty($source['create_payment'])) { + try { + $su->addPayment($creator_id, $source); + } + catch (ValidationException $e) { + if ($e->getMessage() == 'Il n\'est pas possible de créer ou modifier une écriture dans un exercice clôturé') { + throw new ValidationException('Impossible d\'enregistrer l\'inscription : ce tarif d\'activité est lié à un exercice clôturé. Merci de modifier le tarif et choisir un autre exercice.', 0, $e); + } + else { + throw $e; + } + } + } + } + + if (count($errors)) { + $db->rollback(); + + throw new ValidationException(sprintf("Les membres suivants ne pourront pas être inscrits car ils sont déjà inscrits à cette activité et à la date indiquée :\n%s\n\nValidez à nouveau le formulaire pour confirmer les inscriptions des autres membres.", implode(', ', $errors))); + } + + $db->commit(); + + return $su; + } +} \ No newline at end of file diff --git a/src/include/lib/Paheko/Entities/Signal.php b/src/include/lib/Paheko/Entities/Signal.php new file mode 100644 index 0000000..396cae8 --- /dev/null +++ b/src/include/lib/Paheko/Entities/Signal.php @@ -0,0 +1,85 @@ +name = $name; + $this->stoppable = $stoppable; + $this->in = $in; + $this->out = $out; + } + + public function isStoppable(): bool + { + return $this->stoppable; + } + + public function isStopped(): bool + { + return $this->stop; + } + + public function stop(): void + { + if (!$this->stoppable) { + throw new \LogicException('Trying to stop a non-stoppable signal'); + } + + $this->stop = true; + } + + public function getName(): string + { + return $this->name; + } + + public function getIn(?string $name = null) + { + if (null === $name) { + return $this->in; + } + + if (!array_key_exists($name, $this->in)) { + throw new \LogicException(sprintf('Cannot get incoming variable "%s" in signal "%s": unknown variable name', $name, $this->name)); + } + + return $this->in[$name]; + } + + public function setOutArray(array $out): void + { + $this->out = $out; + } + + public function setOut(string $name, $value): void + { + $this->out[$name] = $value; + } + + public function getOut(?string $name = null) + { + if (null === $name) { + return $this->out; + } + + return $this->out[$name] ?? null; + } +} diff --git a/src/include/lib/Paheko/Entities/Users/Category.php b/src/include/lib/Paheko/Entities/Users/Category.php new file mode 100644 index 0000000..74ba671 --- /dev/null +++ b/src/include/lib/Paheko/Entities/Users/Category.php @@ -0,0 +1,143 @@ + [ + 'label' => 'Les membres de cette catégorie peuvent-ils se connecter ?', + 'shape' => Utils::ICONS['login'], + 'options' => [ + Session::ACCESS_NONE => 'N\'a pas le droit de se connecter', + Session::ACCESS_READ => 'A le droit de se connecter', + ], + ], + 'users' => [ + 'label' => 'Gestion des membres', + 'shape' => Utils::ICONS['users'], + 'options' => [ + Session::ACCESS_NONE => 'Pas d\'accès', + Session::ACCESS_READ => 'Lecture uniquement (peut voir les informations personnelles de tous les membres, y compris leurs inscriptions à des activités)', + Session::ACCESS_WRITE => 'Lecture & écriture (peut ajouter et modifier des membres, mais pas les supprimer ni les changer de catégorie, peut inscrire des membres à des activités, peut envoyer des messages collectifs)', + Session::ACCESS_ADMIN => 'Administration (peut tout faire)', + ], + ], + 'accounting' => [ + 'label' => 'Comptabilité', + 'shape' => Utils::ICONS['money'], + 'options' => [ + Session::ACCESS_NONE => 'Pas d\'accès', + Session::ACCESS_READ => 'Lecture uniquement (peut lire toutes les informations de tous les exercices)', + Session::ACCESS_WRITE => 'Lecture & écriture (peut ajouter des écritures, mais pas les modifier ni les supprimer)', + Session::ACCESS_ADMIN => 'Administration (peut tout faire)', + ], + ], + 'documents' => [ + 'label' => 'Documents', + 'shape' => Utils::ICONS['folder'], + 'options' => [ + Session::ACCESS_NONE => 'Pas d\'accès', + Session::ACCESS_READ => 'Lecture uniquement (peut lire tous les fichiers)', + Session::ACCESS_WRITE => 'Lecture & écriture (peut ajouter, modifier et déplacer des fichiers, mais pas les supprimer)', + Session::ACCESS_ADMIN => 'Administration (peut tout faire, notamment mettre des fichiers dans la corbeille)', + ], + ], + 'web' => [ + 'label' => 'Gestion du site web', + 'shape' => Utils::ICONS['globe'], + 'options' => [ + Session::ACCESS_NONE => 'Pas d\'accès', + Session::ACCESS_READ => 'Lecture uniquement (peut lire les pages)', + Session::ACCESS_WRITE => 'Lecture & écriture (peut ajouter et modifier des pages et catégories, mais pas les supprimer)', + Session::ACCESS_ADMIN => 'Administration (peut tout faire)', + ], + ], + 'config' => [ + 'label' => 'Les membres de cette catégorie peuvent-ils modifier la configuration ?', + 'shape' => Utils::ICONS['settings'], + 'options' => [ + Session::ACCESS_NONE => 'Ne peut pas modifier la configuration', + Session::ACCESS_ADMIN => 'Peut modifier la configuration', + ], + ], + ]; + + public function selfCheck(): void + { + parent::selfCheck(); + + $this->assert(trim($this->name) !== '', 'Le nom de catégorie ne peut rester vide.'); + + foreach (self::PERMISSIONS as $key => $perm) { + $this->assert(array_key_exists($this->{'perm_' . $key}, $perm['options']), 'Invalid value for perm_' . $key); + } + } + + public function delete(): bool + { + $db = DB::getInstance(); + $config = Config::getInstance(); + + if ($this->id() == $config->get('default_category')) { + throw new UserException('Il est interdit de supprimer la catégorie définie par défaut dans la configuration.'); + } + + if ($db->test('users', 'id_category = ?', $this->id())) { + throw new UserException('La catégorie contient encore des membres, il n\'est pas possible de la supprimer.'); + } + + return parent::delete(); + } + + public function setAllPermissions(int $access): void + { + foreach (self::PERMISSIONS as $key => $perm) { + // Restrict to the maximum access level, as some permissions only allow up to READ + $perm_access = min($access, max(array_keys($perm['options']))); + $this->set('perm_' . $key, $perm_access); + } + } + + public function getPermissions(): array + { + $out = []; + + foreach (self::PERMISSIONS as $key => $perm) { + $out[$key] = $this->{'perm_' . $key}; + } + + return $out; + } + + public function count(): int + { + return DB::getInstance()->count(self::TABLE, 'id = ?', $this->id); + } +} diff --git a/src/include/lib/Paheko/Entities/Users/DynamicField.php b/src/include/lib/Paheko/Entities/Users/DynamicField.php new file mode 100644 index 0000000..cce4cf5 --- /dev/null +++ b/src/include/lib/Paheko/Entities/Users/DynamicField.php @@ -0,0 +1,395 @@ + 'Adresse E-Mail', + 'url' => 'Adresse URL', + 'checkbox' => 'Case à cocher', + 'date' => 'Date', + 'datetime' => 'Date et heure', + 'month' => 'Mois et année', + 'year' => 'Année', + 'file' => 'Fichier', + 'password' => 'Mot de passe', + 'number' => 'Nombre', + 'decimal' => 'Nombre à virgule', + 'tel' => 'Numéro de téléphone', + 'select' => 'Sélecteur à choix unique', + 'multiple' => 'Sélecteur à choix multiple', + 'country' => 'Sélecteur de pays', + 'text' => 'Texte libre, une ligne', + 'datalist' => 'Texte libre, une ligne, à choix multiple', + 'textarea' => 'Texte libre, plusieurs lignes', + 'virtual' => 'Calculé', + ]; + + const PHP_TYPES = [ + 'email' => '?string', + 'url' => '?string', + 'checkbox' => 'bool', + 'date' => '?' . Date::class, + 'datetime' => '?DateTime', + 'month' => '?string', + 'year' => '?int', + 'file' => '?string', + 'password' => '?string', + 'number' => '?integer', + 'decimal' => '?float', + 'tel' => '?string', + 'select' => '?string', + 'multiple' => '?int', + 'country' => '?string', + 'text' => '?string', + 'textarea' => '?string', + 'datalist' => '?string', + 'virtual' => 'dynamic', + ]; + + const SQL_TYPES = [ + 'email' => 'TEXT', + 'url' => 'TEXT', + 'checkbox' => 'INTEGER NOT NULL DEFAULT 0', + 'date' => 'TEXT', + 'datetime' => 'TEXT', + 'month' => 'TEXT', + 'year' => 'INTEGER', + 'file' => 'TEXT', + 'password' => 'TEXT', + 'number' => 'INTEGER', + 'decimal' => 'FLOAT', + 'tel' => 'TEXT', + 'select' => 'TEXT', + 'multiple' => 'INTEGER', + 'country' => 'TEXT', + 'text' => 'TEXT', + 'textarea' => 'TEXT', + 'datalist' => 'TEXT', + 'virtual' => null, + ]; + + const SEARCH_TYPES = [ + 'email', + 'text', + 'textarea', + 'url', + ]; + + const LOGIN_FIELD_TYPES = [ + 'email', + 'url', + 'text', + 'number', + 'tel', + ]; + + const NAME_FIELD_TYPES = [ + 'text', + 'select', + 'number', + 'url', + 'email', + ]; + + const SQL_CONSTRAINTS = [ + 'checkbox' => '%1s = 1 OR %1s = 0', + 'date' => '%1s IS NULL OR (date(%1$s) IS NOT NULL AND date(%1s) = %1$s)', + 'datetime' => '%1s IS NULL OR (date(%1$s) IS NOT NULL AND date(%1s) = %1$s)', + 'month' => '%1s IS NULL OR (date(%1s || \'-03\') = %1$s || \'-03\')', // Use 3rd day to avoid any potential issue with timezones + ]; + + const SYSTEM_FIELDS = [ + 'id' => '?int', + 'id_category' => 'int', + 'pgp_key' => '?string', + 'otp_secret' => '?string', + 'date_login' => '?DateTime', + 'date_updated' => '?DateTime', + 'id_parent' => '?int', + 'is_parent' => 'bool', + 'preferences' => '?stdClass', + ]; + + const SYSTEM_FIELDS_SQL = [ + 'id INTEGER PRIMARY KEY,', + 'id_category INTEGER NOT NULL REFERENCES users_categories(id),', + 'date_login TEXT NULL CHECK (date_login IS NULL OR datetime(date_login) = date_login),', + 'date_updated TEXT NULL CHECK (date_updated IS NULL OR datetime(date_updated) = date_updated),', + 'otp_secret TEXT NULL,', + 'pgp_key TEXT NULL,', + 'id_parent INTEGER NULL REFERENCES users(id) ON DELETE SET NULL CHECK (id_parent IS NULL OR is_parent = 0),', + 'is_parent INTEGER NOT NULL DEFAULT 0,', + 'preferences TEXT NULL,' + ]; + + public function sql_type(): string + { + if ($this->type == 'checkbox') { + return 'INTEGER'; + } + + return self::SQL_TYPES[$this->type]; + } + + public function delete(): bool + { + if (!$this->canDelete()) { + throw new ValidationException('Ce champ est utilisé en interne, il n\'est pas possible de le supprimer'); + } + + foreach (DynamicFields::getVirtualFields() as $field) { + if ($field->isReferencing($this->name)) { + throw new ValidationException(sprintf('Ce champ ne peut être supprimé, car le champ calculé "%s" en a besoin pour fonctionner.', $field->label)); + } + } + + if ($this->type == 'file') { + // Delete all linked files + $glob = sprintf('%s/*/%s', File::CONTEXT_USER, $this->name); + + foreach (Files::glob($glob) as $file) { + $file->delete(); + } + + DB::getInstance()->preparedQuery('DELETE FROM users_files WHERE field = ?;', $this->name); + } + + return parent::delete(); + } + + public function canSetDefaultValue(): bool + { + return in_array($this->type ?? null, ['text', 'textarea', 'number', 'select', 'multiple']); + } + + public function isPreset(): bool + { + return (bool) ($this->system & self::PRESET); + } + + public function isName(): bool + { + return (bool) ($this->system & self::NAMES); + } + + public function isNumber(): bool + { + return (bool) ($this->system & self::NUMBER); + } + + public function isVirtual(): bool + { + return $this->type == 'virtual'; + } + + public function isReferencing(string $name): bool + { + return $this->isVirtual() && preg_match('/\b' . $name . '\b/', $this->sql); + } + + public function canDelete(): bool + { + if ($this->system & self::PASSWORD || $this->system & self::NUMBER || $this->system & self::NAMES || $this->system & self::LOGIN) { + return false; + } + + return true; + } + + public function hasSearchCache(): bool + { + return in_array($this->type, DynamicField::SEARCH_TYPES); + } + + public function selfCheck(): void + { + // Disallow name change if the field exists + if ($this->exists()) { + $this->assert(!$this->isModified('name')); + $this->assert(!$this->isModified('type')); + } + + $this->name = strtolower($this->name); + + $this->assert(in_array($this->management_access_level, Session::ACCESS_LEVELS, true)); + $this->assert(in_array($this->user_access_level, Session::ACCESS_LEVELS, true)); + + $this->assert(!array_key_exists($this->name, self::SYSTEM_FIELDS), 'Ce nom de champ est déjà utilisé par un champ système, merci d\'en choisir un autre.'); + $this->assert(preg_match('!^[a-z][a-z0-9]*(_[a-z0-9]+)*$!', $this->name), 'Le nom du champ est invalide : ne sont acceptés que les lettres minuscules et les chiffres (éventuellement séparés par un underscore).'); + + $this->assert(trim($this->label) != '', 'Le libellé est obligatoire.'); + $this->assert($this->type && array_key_exists($this->type, self::TYPES), 'Type de champ invalide.'); + + if ($this->system & self::PASSWORD) { + $this->assert($this->type == 'password', 'Le champ mot de passe ne peut être d\'un type différent de mot de passe.'); + } + + if ($this->type == 'multiple' || $this->type == 'select') { + $this->options = array_filter($this->options); + $this->assert(is_array($this->options) && count($this->options) >= 1 && trim((string)current($this->options)) !== '', 'Ce champ nécessite de comporter au moins une option possible: ' . $this->name); + } + + $db = DB::getInstance(); + + if (!$this->exists()) { + $this->assert(!$db->test(self::TABLE, 'name = ?', $this->name), 'Ce nom de champ est déjà utilisé par un autre champ: ' . $this->name); + } + else { + $this->assert(!$db->test(self::TABLE, 'name = ? AND id != ?', $this->name, $this->id()), 'Ce nom de champ est déjà utilisé par un autre champ.'); + } + + if ($this->exists()) { + $this->assert($this->system & self::PRESET || !array_key_exists($this->name, DynamicFields::getInstance()->getPresets()), 'Ce nom de champ est déjà utilisé par un champ pré-défini.'); + } + + if ($this->type === 'virtual') { + $this->assert(null !== $this->sql && strlen(trim($this->sql)), 'Le code SQL est manquant'); + + try { + $db->protectSelect(['users' => []], sprintf('SELECT (%s) FROM users;', $this->sql)); + } + catch (\KD2\DB\DB_Exception $e) { + throw new ValidationException('Le code SQL du champ calculé est invalide: ' . $e->getMessage(), 0, $e); + } + } + } + + public function importForm(array $source = null) + { + if (null === $source) { + $source = $_POST; + } + + $source['required'] = !empty($source['required']) ? true : false; + $source['list_table'] = !empty($source['list_table']) ? true : false; + + return parent::importForm($source); + } + + public function getRealType(): ?string + { + $db = DB::getInstance(); + $type = $db->firstColumn(sprintf('SELECT TYPEOF(%s) FROM users_view WHERE %1$s IS NOT NULL LIMIT 1;', $db->quoteIdentifier($this->name))); + return strtolower($type) ?: null; + } + + public function hasNullValues(): bool + { + $db = DB::getInstance(); + return (bool) $db->firstColumn(sprintf('SELECT 1 FROM users_view WHERE %1$s IS NULL LIMIT 1;', $db->quoteIdentifier($this->name))); + } + + public function getStringValue($value): ?string + { + if (null === $value) { + return null; + } + + switch ($this->type) { + case 'multiple': + // Useful for search results, if a value is not a number + if (!is_numeric($value)) { + return ''; + } + + $out = []; + + foreach ($this->options as $b => $name) + { + if ($value & (0x01 << $b)) + $out[] = $name; + } + + return implode(', ', $out); + case 'checkbox': + return $value ? 'Oui' : ''; + case 'date': + return CommonModifiers::date_short($value, false); + case 'datetime': + return CommonModifiers::date_short($value, true); + case 'country': + return Utils::getCountryName($value); + default: + return (string) $value; + } + } +} diff --git a/src/include/lib/Paheko/Entities/Users/User.php b/src/include/lib/Paheko/Entities/Users/User.php new file mode 100644 index 0000000..70c4fbb --- /dev/null +++ b/src/include/lib/Paheko/Entities/Users/User.php @@ -0,0 +1,707 @@ +property = 'value' to set a property value on this class + * as they will not be saved using save(). Please use $user->set('property', 'value'). + * + * This is because dynamic properties are set as public, and __set is not called. + * TODO: change to storing properties in an array + */ +#[\AllowDynamicProperties] +class User extends Entity +{ + const NAME = 'Membre'; + const PRIVATE_URL = '!users/details.php?id=%d'; + + const MINIMUM_PASSWORD_LENGTH = 8; + + const TABLE = 'users'; + + const PREFERENCES = [ + 'folders_gallery' => false, + 'page_size' => 100, + 'accounting_expert' => false, + 'dark_theme' => false, + 'force_handheld' => false, + ]; + + protected bool $_loading = false; + + public function __construct() + { + $this->reloadProperties(); + + parent::__construct(); + } + + protected function reloadProperties(): void + { + if (empty(self::$_types_cache[static::class])) { + $this->_types = DynamicField::SYSTEM_FIELDS; + + $fields = DynamicFields::getInstance()->all(); + + foreach ($fields as $key => $config) { + // Fallback to dynamic, if a field type has been deleted + $this->_types[$key] = DynamicField::PHP_TYPES[$config->type] ?? 'dynamic'; + } + } + elseif (empty($this->_types)) { + $this->_types = self::$_types_cache[static::class]; + } + + self::_loadEntityTypesCache($this->_types); + + $this->_loading = true; + + foreach ($this->_types as $key => $v) { + if (!property_exists($this, $key)) { + $this->$key = null; + } + } + + $this->_loading = false; + } + + public function __wakeup(): void + { + $this->reloadProperties(); + } + + public function set(string $key, $value) { + if ($this->_loading && $value === null) { + $this->$key = $value; + return; + } + + // Don't bother for type with virtual columns + // also don't set it as modified as we don't save the value + if ($this->_types[$key] === 'dynamic') { + $this->$key = $value; + return; + } + + // Filter double/triple spaces instead of double spaces, + // to help users who try to log in, see https://fossil.kd2.org/paheko/info/c3295fe0af72e4b3 + // Only when setting a new value + if (is_string($value) && false !== strpos($value, ' ') && DynamicFields::get($key)->type == 'text') { + $value = preg_replace('![ ]{2,}!', ' ', $value); + } + + return parent::set($key, $value); + } + + public function selfCheck(): void + { + $this->assert(!empty($this->id_category), 'Aucune catégorie n\'a été sélectionnée.'); + + $df = DynamicFields::getInstance(); + + foreach ($df->all() as $field) { + $value = $this->{$field->name}; + + if (null !== $value) { + if ($field->type === 'email') { + $this->assert($value === null || SMTP::checkEmailIsValid($value, false), sprintf('"%s" : l\'adresse e-mail "%s" n\'est pas valide.', $field->label, $value)); + } + elseif ($field->type === 'checkbox') { + $this->assert($value === false || $value === true, sprintf('"%s" : la valeur de ce champ n\'est pas valide.', $field->label)); + } + } + + if (!$field->required || $field->system & $field::PASSWORD) { + continue; + } + + if (empty($value) && ($field->system & $field::NUMBER)) { + $this->setNumberIfEmpty(); + continue; + } + + $this->assert(null !== $value, sprintf('"%s" : ce champ est requis', $field->label)); + + if (is_bool($value) && $field->required) { + $this->assert($value === true, sprintf('"%s" : ce champ doit être coché', $field->label)); + } + elseif (!is_array($value) && !is_object($value) && !is_bool($value)) { + $this->assert('' !== trim((string)$value), sprintf('"%s" : ce champ ne peut être vide', $field->label)); + } + + if ($field->type === 'select') { + $this->assert(in_array($value, $field->options), sprintf('"%s" : la valeur "%s" ne fait pas partie des options possibles', $field->label, $value)); + } + elseif ($field->type === 'country') { + $this->assert(strlen($value) === 2, sprintf('"%s" : un champ pays ne peut contenir que deux lettres', $field->label)); + $this->assert(Utils::getCountryName($value) !== null, sprintf('"%s" : pays inconnu : "%s"', $field->label, $value)); + } + } + + // check user number + $field = DynamicFields::getNumberField(); + $this->assert($this->$field !== null && ctype_digit((string)$this->$field), 'Numéro de membre invalide : ne peut contenir que des chiffres'); + + $db = DB::getInstance(); + + if (!$this->exists()) { + $number_exists = $db->test(self::TABLE, sprintf('%s = ?', $db->quoteIdentifier($field)), $this->$field); + } + else { + $number_exists = $db->test(self::TABLE, sprintf('%s = ? AND id != ?', $db->quoteIdentifier($field)), $this->$field, $this->id()); + } + + $this->assert(!$number_exists, 'Ce numéro de membre est déjà attribué à un autre membre.'); + + $field = DynamicFields::getLoginField(); + if ($this->$field !== null) { + if (!$this->exists()) { + $login_exists = $db->test(self::TABLE, sprintf('%s = ? COLLATE NOCASE', $db->quoteIdentifier($field)), $this->$field); + } + else { + $login_exists = $db->test(self::TABLE, sprintf('%s = ? COLLATE NOCASE AND id != ?', $db->quoteIdentifier($field)), $this->$field, $this->id()); + } + + $this->assert(!$login_exists, sprintf('Le champ "%s" (utilisé comme identifiant de connexion) est déjà utilisé par un autre membre. Il doit être unique pour chaque membre.', $df->fieldByKey($field)->label)); + } + + if ($this->id_parent !== null) { + $this->assert(!$this->is_parent, 'Un membre ne peut être responsable et rattaché en même temps.'); + $this->assert($this->id_parent > 0, 'Invalid parent ID'); + $this->assert(!$this->exists() || $this->id_parent != $this->id(), 'Invalid parent ID'); + $this->assert(!$db->test(self::TABLE, 'id = ? AND id_parent IS NOT NULL', $this->id_parent), 'Le membre sélectionné comme responsable est déjà rattaché à un autre membre.'); + } + } + + public function delete(): bool + { + $session = Session::getInstance(); + + if ($session->isLogged()) { + $user = $session->getUser(); + + if ($user->id == $this->id) { + throw new UserException('Il n\'est pas possible de supprimer son propre compte. Merci de demander à un autre administrateur de le faire.'); + } + } + + Files::delete($this->attachmentsDirectory()); + + return parent::delete(); + } + + public function asArray(bool $for_database = false): array + { + $out = parent::asArray($for_database); + + // Remove virtual columns + if ($for_database) { + foreach (DynamicFields::getInstance()->all() as $field) { + if ($field->type == 'virtual') { + unset($out[$field->name]); + } + } + } + + return $out; + } + + public function asModuleArray(): array + { + $out = $this->asArray(); + $out['_name'] = $this->name(); + $out['_email'] = $this->email(); + $out['_number'] = $this->number(); + $out['_login'] = $this->login(); + return $out; + } + + public function asDetailsArray(bool $modified_values = false): array + { + $list = DynamicFields::getInstance()->all(); + $out = []; + + foreach ($list as $field) { + $key = $field->name; + + if ($modified_values && $this->isModified($key)) { + $out[$key] = $this->getModifiedProperty($key); + } + else { + $out[$key] = $this->$key; + } + + $out[$key] = $field->getStringValue($out[$key]); + } + + return $out; + } + + public function save(bool $selfcheck = true): bool + { + if (!count($this->_modified) && $this->exists()) { + return true; + } + + $columns = array_intersect(DynamicFields::getInstance()->getSearchColumns(), array_keys($this->_modified)); + $login_field = DynamicFields::getLoginField(); + $login_modified = $this->_modified[$login_field] ?? null; + $password_modified = $this->_modified['password'] ?? null; + + $this->set('date_updated', new \DateTime); + + parent::save($selfcheck); + + // We are not using a trigger as it would make modifying the users table from outside impossible + // (because the transliterate_to_ascii function does not exist) + if (count($columns)) { + DynamicFields::getInstance()->rebuildUserSearchCache($this->id()); + } + + if ($login_modified && $this->password) { + EmailTemplates::loginChanged($this); + Log::add(Log::LOGIN_CHANGE, null, $this->id()); + } + + if ($password_modified && $this->password && $this->id == Session::getUserId()) { + EmailTemplates::passwordChanged($this); + } + + if ($password_modified) { + Log::add(Log::LOGIN_PASSWORD_CHANGE, null, $this->id()); + Plugins::fire('user.change.password.after', false, ['user' => $this]); + } + + if ($login_modified) { + Plugins::fire('user.change.login.after', false, ['user' => $this, 'old_login' => $login_modified]); + } + + return true; + } + + public function category(): Category + { + return Categories::get($this->id_category); + } + + public function attachmentsDirectory(): string + { + return File::CONTEXT_USER . '/' . $this->id(); + } + + public function listFiles(string $field_name = null): array + { + return Files::listForUser($this->id, $field_name); + } + + public function login(): ?string + { + $field = DynamicFields::getLoginField(); + return (string)$this->$field ?: null; + } + + public function number(): ?string + { + $field = DynamicFields::getNumberField(); + return (string)$this->$field ?: null; + } + + public function setNumberIfEmpty(): void + { + $field = DynamicFields::getNumberField(); + + if (!empty($this->$field)) { + return; + } + + $db = DB::getInstance(); + $new = $db->firstColumn(sprintf('SELECT MAX(%s) + 1 FROM %s WHERE %1$s IS NOT NULL;', $db->quoteIdentifier($field), User::TABLE)); + $new = $new ?: $db->count(User::TABLE); + $this->set($field, (int)$new); + } + + public function name(): string + { + $out = []; + + foreach (DynamicFields::getNameFields() as $key) { + $out[] = $this->$key; + } + + return implode(' ', $out); + } + + public function email(): ?string + { + $field = DynamicFields::getFirstEmailField(); + return (string)$this->$field ?: null; + } + + public function importForm(array $source = null) + { + if (null === $source) { + $source = $_POST; + } + + // Don't allow changing security credentials from form + unset($source['id_category'], $source['password'], $source['otp_secret'], $source['pgp_key']); + + if (isset($source['id_parent']) && is_array($source['id_parent'])) { + $source['id_parent'] = Form::getSelectorValue($source['id_parent']); + } + + foreach (DynamicFields::getInstance()->fieldsByType('multiple') as $f) { + if (!(isset($source[$f->name . '_present']) || isset($source[$f->name]))) { + continue; + } + + if (isset($source[$f->name]) && is_string($source[$f->name])) { + $source[$f->name] = array_map('trim', explode(',', $source[$f->name])); + } + + $options = isset($source[$f->name]) && is_array($source[$f->name]) ? $source[$f->name] : []; + + $v = 0; + + foreach ($f->options as $k => $label) { + if (in_array($label, $options, true)) { + $k = 0x01 << $k; + $v |= $k; + } + } + + $source[$f->name] = $v ?: null; + } + + // Handle unchecked checkbox in HTML form: no value returned + foreach (DynamicFields::getInstance()->fieldsByType('checkbox') as $f) { + if (!(isset($source[$f->name . '_present']) || isset($source[$f->name]))) { + continue; + } + + $source[$f->name] = !empty($source[$f->name]); + } + + foreach (DynamicFields::getInstance()->fieldsByType('country') as $f) { + if (!isset($source[$f->name])) { + continue; + } + + if (strlen($source[$f->name]) !== 2) { + $source[$f->name] = Utils::getCountryCode($source[$f->name]); + } + + $source[$f->name] = $source[$f->name] ?: null; + } + + return parent::importForm($source); + } + + public function importSecurityForm(bool $user_mode = true, array $source = null, Session $session = null) + { + $source ??= $_POST; + + $allowed = ['password', 'password_check', 'password_confirmed', 'password_delete', 'otp_secret', 'otp_disable', 'pgp_key', 'otp_code']; + $source = array_intersect_key($source, array_flip($allowed)); + + $session = Session::getInstance(); + + if ($user_mode && !Session::getInstance()->checkPassword($source['password_check'] ?? null, $this->password)) { + $this->assert( + $session->checkPassword($source['password_check'] ?? null, $this->password), + 'Le mot de passe fourni ne correspond pas au mot de passe actuel. Merci de bien vouloir renseigner votre mot de passe courant pour confirmer les changements.' + ); + } + + if (!empty($source['password_delete'])) { + $source['password'] = null; + } + elseif (isset($source['password'])) { + $source['password'] = trim($source['password']); + + // Maximum bcrypt password length + $this->assert(strlen($source['password']) <= 72, sprintf('Le mot de passe doit faire moins de %d caractères.', 72)); + $this->assert(strlen($source['password']) >= self::MINIMUM_PASSWORD_LENGTH, sprintf('Le mot de passe doit faire au moins %d caractères.', self::MINIMUM_PASSWORD_LENGTH)); + $this->assert(hash_equals($source['password'], trim($source['password_confirmed'] ?? '')), 'La vérification du mot de passe doit être identique au mot de passe.'); + $this->assert(!$session->isPasswordCompromised($source['password']), 'Le mot de passe choisi figure dans une liste de mots de passe compromis (piratés), il ne peut donc être utilisé ici. Si vous l\'avez utilisé sur d\'autres sites il est recommandé de le changer sur ces autres sites également.'); + + $source['password'] = $session::hashPassword($source['password']); + } + + if (!empty($source['otp_disable'])) { + $source['otp_secret'] = null; + } + elseif (isset($source['otp_secret'])) { + $this->assert(trim($source['otp_code'] ?? '') !== '', 'Le code TOTP doit être renseigné pour confirmer l\'opération'); + $this->assert($session->checkOTP($source['otp_secret'], $source['otp_code']), 'Le code TOTP entré n\'est pas valide.'); + } + + if (!empty($source['pgp_key'])) { + $this->assert($session->getPGPFingerprint($source['pgp_key']), 'Clé PGP invalide : impossible de récupérer l\'empreinte de la clé.'); + } + + // Don't allow user to change password if the password field cannot be changed by user + if ($user_mode && !$this->canChangePassword($session)) { + unset($source['password']); + } + + unset($source['password_confirmed'], $source['password_check']); + + parent::importForm($source); + } + + public function getEmails(): array + { + $out = []; + + foreach (DynamicFields::getEmailFields() as $f) { + if (isset($this->$f) && trim($this->$f)) { + $out[] = strtolower($this->$f); + } + } + + return $out; + } + + public function canEmail(): bool + { + return count($this->getEmails()) > 0; + } + + public function getNameAndEmail(): string + { + $email_field = DynamicFields::getFirstEmailField(); + + return sprintf('"%s" <%s>', $this->name(), $this->{$email_field}); + } + + public function isChild(): bool + { + return (bool) $this->id_parent; + } + + public function getParentName(): ?string + { + if (!$this->isChild()) { + return null; + } + + return Users::getName($this->id_parent); + } + + public function getParentSelector(): ?array + { + if (!$this->isChild()) { + return null; + } + + return [$this->id_parent => $this->getParentName()]; + } + + public function hasChildren(): bool + { + return DB::getInstance()->test(self::TABLE, 'id_parent = ?', $this->id()); + } + + public function listChildren(): array + { + $name = DynamicFields::getNameFieldsSQL(); + return DB::getInstance()->getGrouped(sprintf('SELECT id, %s AS name FROM %s WHERE id_parent = ?;', $name, self::TABLE), $this->id()); + } + + public function listSiblings(): array + { + if (!$this->id_parent) { + return []; + } + + $name = DynamicFields::getNameFieldsSQL(); + return DB::getInstance()->getGrouped(sprintf('SELECT id, %s AS name FROM %s WHERE id_parent = ? AND id != ?;', $name, self::TABLE), $this->id_parent, $this->id()); + } + + public function sendMessage(string $subject, string $message, bool $send_copy, ?User $from = null) + { + $config = Config::getInstance(); + $email_field = DynamicFields::getFirstEmailField(); + + $from = $from ? $from->getNameAndEmail() : null; + + Emails::queue(Emails::CONTEXT_PRIVATE, [$this->{$email_field} => ['pgp_key' => $this->pgp_key]], $from, $subject, $message); + + if ($send_copy) { + Emails::queue(Emails::CONTEXT_PRIVATE, [$config->org_email], null, $subject, $message); + } + } + + public function checkLoginFieldForUserEdit() + { + $session = Session::getInstance(); + + if (!$session->canAccess($session::SECTION_CONFIG, $session::ACCESS_ADMIN)) { + return; + } + + $field = DynamicFields::getLoginField(); + + if (!$this->isModified($field)) { + return; + } + + if (!isset($this->$field) || trim($this->$field) !== '') { + return; + } + + throw new UserException("Le champ identifiant ne peut être laissé vide pour un administrateur, sinon vous ne pourriez plus vous connecter."); + } + + public function canChangePassword(Session $session): bool + { + if ($session->canAccess($session::SECTION_USERS, $session::ACCESS_ADMIN)) { + return true; + } + + $password_field = current(DynamicFields::getInstance()->fieldsBySystemUse('password')); + return $password_field->user_access_level === Session::ACCESS_WRITE; + } + + public function checkDuplicate(): ?int + { + $id_field = DynamicFields::getNameFieldsSQL(); + $db = DB::getInstance(); + return $db->firstColumn(sprintf('SELECT id FROM %s WHERE %s = ?;', self::TABLE, $id_field), $this->name()) ?: null; + } + + public function getPreference(string $key) + { + return $this->preferences->{$key} ?? null; + } + + public function setPreference(string $key, $value): void + { + if (isset($this->$key)) { + settype($value, gettype(self::PREFERENCES[$key])); + } + + if (null === $this->preferences) { + $this->preferences = new \stdClass; + } + + $this->preferences->{$key} = $value; + $this->_modified['preferences'] = null; + } + + public function deletePreference(string $key): void + { + if (null === $this->preferences || !isset($this->preferences->{$key})) { + return; + } + + unset($this->preferences->{$key}); + $this->_modified['preferences'] = null; + } + + /** + * Save preferences if they have been modified + */ + public function __destruct() + { + // We can't save preferences if user does not exist (eg. LDAP/Forced Login via LOCAL_LOGIN) + if (!$this->exists()) { + return; + } + + // Nothing to save + if (!$this->isModified('preferences')) { + return; + } + + + DB::getInstance()->update(self::TABLE, ['preferences' => json_encode($this->preferences)], 'id = ' . $this->id()); + $this->clearModifiedProperties(['preferences']); + } + + public function url(): string + { + return Utils::getLocalURL(sprintf(self::PRIVATE_URL, $this->id)); + } + + public function diff(): array + { + $out = []; + + foreach ($this->_modified as $key => $old) { + $out[$key] = [$old, $this->$key]; + } + + return $out; + } + + public function downloadExport(): void + { + $services_list = Services_User::perUserList($this->id); + $services_list->setPageSize(null); + + $export_data = [ + 'user' => $this, + 'services' => $services_list->asArray(true), + ]; + + $tpl = Template::getInstance(); + $tpl->assign('user', $this); + $tpl->assign(compact('services_list')); + $html = $tpl->fetch('me/export.tpl'); + + $name = sprintf('%s - Donnees - %s.zip', Config::getInstance()->get('org_name'), $this->name()); + header('Content-type: application/zip'); + header(sprintf('Content-Disposition: attachment; filename="%s"', $name)); + + $zip = new ZipWriter('php://output'); + $zip->setCompression(9); + + $zip->add('info.html', $html); + $zip->add('info.json', json_encode($export_data)); + + foreach ($this->listFiles() as $file) { + $pointer = $file->getReadOnlyPointer(); + $path = !$pointer ? $file->getLocalFilePath() : null; + $zip->add($file->path, null, $path, $pointer); + + if ($pointer) { + fclose($pointer); + } + } + + $zip->close(); + } +} diff --git a/src/include/lib/Paheko/Entities/Web/Page.php b/src/include/lib/Paheko/Entities/Web/Page.php new file mode 100644 index 0000000..e0d0524 --- /dev/null +++ b/src/include/lib/Paheko/Entities/Web/Page.php @@ -0,0 +1,528 @@ + 'MarkDown', + Render::FORMAT_ENCRYPTED => 'Chiffré', + Render::FORMAT_SKRIV => 'SkrivML', + ]; + + const STATUS_ONLINE = 'online'; + const STATUS_DRAFT = 'draft'; + + const STATUS_LIST = [ + self::STATUS_ONLINE => 'En ligne', + self::STATUS_DRAFT => 'Brouillon', + ]; + + const TYPE_CATEGORY = 1; + const TYPE_PAGE = 2; + + const TYPES = [ + self::TYPE_CATEGORY => 'Category', + self::TYPE_PAGE => 'Page', + ]; + + const TEMPLATES = [ + self::TYPE_PAGE => 'article.html', + self::TYPE_CATEGORY => 'category.html', + ]; + + const DUPLICATE_URI_ERROR = 42; + + protected ?File $_dir = null; + protected ?array $_attachments = null; + protected ?array $_tagged_attachments = null; + protected ?string $_html = null; + + static public function create(int $type, ?int $id_parent, string $title, string $status = self::STATUS_ONLINE): self + { + $page = new self; + $data = compact('type', 'id_parent', 'title', 'status'); + $data['content'] = ''; + + $page->importForm($data); + $page->published = new \DateTime; + $page->modified = new \DateTime; + $page->type = $type; + + $db = DB::getInstance(); + if ($db->test(self::TABLE, 'uri = ?', $page->uri)) { + $page->importForm(['uri' => $page->uri . date('-Y-m-d-His')]); + } + + return $page; + } + + public function dir(bool $force_reload = false): File + { + if (null === $this->_dir || $force_reload) { + $this->_dir = Files::get(File::CONTEXT_WEB . '/' . $this->uri); + + if (null === $this->_dir) { + $this->_dir = Files::mkdir(File::CONTEXT_WEB . '/' . $this->uri); + } + } + + return $this->_dir; + } + + public function url(): string + { + return WWW_URL . $this->uri; + } + + public function template(): string + { + return self::TEMPLATES[$this->type]; + } + + public function asTemplateArray(): array + { + $out = $this->asArray(); + $out['path'] = $this->path(); + $out['parent'] = Utils::dirname($out['path']); + $out['url'] = $this->url(); + $out['html'] = trim($this->content) !== '' ? $this->render() : ''; + $row['has_attachments'] = $this->hasAttachments(); + return $out; + } + + public function render(bool $admin = false): string + { + $user_prefix = $admin ? ADMIN_URL . 'web/?uri=' : null; + + $this->_html ??= Render::render($this->format, $this->dir_path(), $this->content, $user_prefix); + + return $this->_html; + } + + public function excerpt(int $length = 500): string + { + return $this->preview(Modifiers::truncate($this->content, $length)); + } + + public function requiresExcerpt(int $length = 500): bool + { + return mb_strlen($this->content) > $length; + } + + public function preview(string $content): string + { + $user_prefix = ADMIN_URL . 'web/?uri='; + return Render::render($this->format, $this->dir_path(), $content, $user_prefix); + } + + public function dir_path(): string + { + return File::CONTEXT_WEB . '/' . $this->uri; + } + + /** + * Return page path according to parents, eg. category_uri/sub_category_uri/page_uri + */ + public function path(): string + { + if (!isset($this->_path)) { + $sql = sprintf('SELECT GROUP_CONCAT(uri, \'/\') FROM (%s);', + rtrim(sprintf(Web::BREADCRUMBS_SQL, $this->id()), '; ') + ); + + $this->_path = DB::getInstance()->firstColumn($sql); + } + + return $this->_path; + } + + public function listVersions(): DynamicList + { + $name_field = DynamicFields::getNameFieldsSQL('u'); + + $columns = [ + 'id' => ['select' => 'v.id'], + 'id_user' => ['select' => 'v.id_user'], + 'date' => [ + 'select' => 'v.date', + 'label' => 'Date', + ], + 'author' => [ + 'label' => 'Auteur', + 'select' => $name_field, + ], + 'size' => [ + 'label' => 'Longueur du texte', + 'select' => 'v.size', + ], + 'changes' => [ + 'label' => 'Évolution', + 'select' => 'v.changes', + ], + ]; + + $tables = 'web_pages_versions v + LEFT JOIN users u ON u.id = v.id_user'; + $conditions = sprintf('v.id_page = %d', $this->id()); + $list = new DynamicList($columns, $tables, $conditions); + $list->orderBy('id', true); + + return $list; + } + + public function getVersion(int $id): ?\stdClass + { + return DB::getInstance()->first('SELECT a.*, + IFNULL((SELECT content FROM web_pages_versions WHERE id_page = a.id_page AND id < a.id ORDER BY id DESC LIMIT 1), \'\') AS previous_content + FROM web_pages_versions a + WHERE a.id_page = ? AND a.id = ?;', $this->id(), $id) ?: null; + } + + public function syncSearch(): void + { + if ($this->format == Render::FORMAT_ENCRYPTED) { + $content = null; + } + else { + $content = $this->render(); + } + + $this->dir()->indexForSearch(compact('content'), $this->title, 'text/html'); + } + + public function saveNewVersion(?int $user_id): bool + { + $content_modified = $this->isModified('content'); + $prev_content = $this->getModifiedProperty('content'); + + $r = $this->save(); + + if ($content_modified) { + $db = DB::getInstance(); + $l = mb_strlen($this->content); + + $version = [ + 'id_user' => $user_id, + 'id_page' => $this->id(), + 'date' => new \DateTime, + 'content' => $this->content, + 'size' => $l, + 'changes' => $l - mb_strlen($prev_content), + ]; + + $db->insert('web_pages_versions', $version); + $version['id'] = $db->lastInsertId(); + + Plugins::fire('web.page.version.new', false, [ + 'entity' => $this, + 'content' => $this->content, + 'old_content' => $prev_content, + 'version' => (object) $version, + ]); + } + + return $r; + } + + public function save(bool $selfcheck = true): bool + { + $dir = null; + + if ($this->isModified('uri')) { + $dir = Files::get(File::CONTEXT_WEB . '/' . $this->getModifiedProperty('uri')); + } + + // Update modified date if required + if (count($this->_modified) && !isset($this->_modified['modified'])) { + $this->set('modified', new \DateTime); + } + + $update_search = $this->isModified('content') || $this->isModified('format'); + + parent::save($selfcheck); + + if ($dir) { + $dir->rename($this->dir_path()); + } + + if ($update_search) { + $this->syncSearch(); + } + + Cache::clear(); + + return true; + } + + public function delete(): bool + { + $dir = $this->dir(); + + $r = parent::delete(); + + if ($r && $dir) { + $dir->delete(); + } + + Cache::clear(); + return $r; + } + + public function selfCheck(): void + { + $db = DB::getInstance(); + $this->assert($this->type === self::TYPE_CATEGORY || $this->type === self::TYPE_PAGE, 'Unknown page type'); + $this->assert(array_key_exists($this->status, self::STATUS_LIST), 'Unknown page status'); + $this->assert(array_key_exists($this->format, self::FORMATS_LIST), 'Unknown page format'); + $this->assert(trim($this->title) !== '', 'Le titre ne peut rester vide'); + $this->assert(mb_strlen($this->title) <= 200, 'Le titre ne peut faire plus de 200 caractères'); + $this->assert(trim($this->uri) !== '', 'L\'URI ne peut rester vide'); + $this->assert(strlen($this->uri) <= 150, 'L\'URI ne peut faire plus de 150 caractères'); + + if ($this->exists()) { + $this->assert($this->id_parent !== $this->id(), 'Invalid parent page'); + } + + $this->assert(!$this->exists() || !$db->test(self::TABLE, 'uri = ? AND id != ?', $this->uri, $this->id()), 'Cette adresse URI est déjà utilisée par une autre page, merci d\'en choisir une autre : ' . $this->uri, self::DUPLICATE_URI_ERROR); + $this->assert($this->exists() || !$db->test(self::TABLE, 'uri = ?', $this->uri), 'Cette adresse URI est déjà utilisée par une autre page, merci d\'en choisir une autre : ' . $this->uri, self::DUPLICATE_URI_ERROR); + } + + public function importForm(array $source = null) + { + if (null === $source) { + $source = $_POST; + } + + if (isset($source['date']) && isset($source['date_time'])) { + $source['published'] = $source['date'] . ' ' . $source['date_time']; + } + + if (isset($source['title']) && !$this->exists()) { + $source['uri'] = $source['title']; + } + + if (isset($source['uri'])) { + $source['uri'] = Utils::transformTitleToURI($source['uri']); + + if (!$this->exists()) { + $source['uri'] = strtolower($source['uri']); + } + } + + $uri = $source['uri'] ?? ($this->uri ?? null); + + if (array_key_exists('id_parent', $source) && is_array($source['id_parent'])) { + $source['id_parent'] = Form::getSelectorValue($source['id_parent']) ?: null; + } + + if (!empty($source['encryption']) ) { + $this->set('format', Render::FORMAT_ENCRYPTED); + } + elseif (empty($source['format'])) { + $this->set('format', Render::FORMAT_MARKDOWN); + } + + $this->set('status', empty($source['status']) ? self::STATUS_ONLINE : $source['status']); + + return parent::importForm($source); + } + + public function getBreadcrumbs(): array + { + return Web::getBreadcrumbs($this->id()); + } + + public function listAttachments(): array + { + if (null === $this->_attachments) { + $list = Files::list($this->dir_path()); + + // Remove sub-directories + $list = array_filter($list, fn ($a) => $a->type != $a::TYPE_DIRECTORY); + + $this->_attachments = $list; + } + + return $this->_attachments; + } + + /** + * List attachments that are cited in the text content + */ + public function listTaggedAttachments(): array + { + if (null === $this->_tagged_attachments) { + $this->render(); + $this->_tagged_attachments = Render::listAttachments($this->dir_path()); + } + + return $this->_tagged_attachments; + } + + /** + * List attachments that are *NOT* cited in the text content + */ + public function listOrphanAttachments(): array + { + $used = $this->listTaggedAttachments(); + $orphans = []; + + foreach ($this->listAttachments() as $file) { + if (!in_array($file->uri(), $used)) { + $orphans[] = $file->uri(); + } + } + + return $orphans; + } + + public function hasAttachments(): bool + { + foreach ($this->listAttachments() as $attachment) { + return true; + } + + return false; + } + + /** + * Return list of images + * If $all is FALSE then this will only return images that are not present in the content + */ + public function getImageGallery(bool $all = true): array + { + return $this->getAttachmentsGallery($all, true); + } + + /** + * Return list of files + * If $all is FALSE then this will only return files that are not present in the content + */ + public function getAttachmentsGallery(bool $all = true, bool $images = false): array + { + $out = []; + $tagged = []; + + if (!$all) { + $tagged = $this->listTaggedAttachments(); + } + + foreach ($this->listAttachments() as $a) { + if ($images && !$a->isImage()) { + continue; + } + elseif (!$images && $a->isImage()) { + continue; + } + + // Skip + if (!$all && in_array($a->name, $tagged)) { + continue; + } + + $out[] = $a; + } + + return $out; + } + + /** + * Return list of internal links in page that link to non-existing pages + */ + public function checkInternalPagesLinks(?array &$pages = null): array + { + if ($this->format == Render::FORMAT_ENCRYPTED) { + return []; + } + + $renderer = Render::getRenderer($this->format, $this->dir_path()); + $renderer->render($this->content); + $errors = []; + + foreach ($renderer->listLinks() as $link) { + if ($link['type'] !== 'page') { + continue; + } + + $uri = strtok($link['uri'], '#'); + strtok(''); + + if (null !== $pages && !array_key_exists($uri, $pages)) { + $errors[$uri] = $link['label']; + } + elseif (null === $pages && !Web::getByURI($uri)) { + $errors[$uri] = $link['label']; + } + } + + return $errors; + } + + public function hasSubPages(): bool + { + return DB::getInstance()->test('web_pages', 'id_parent = ?', $this->id()); + } + + public function toggleType(): void + { + $has_sub_pages = $this->hasSubPages(); + + if ($has_sub_pages) { + $this->set('type', self::TYPE_CATEGORY); + } + elseif ($this->type == self::TYPE_CATEGORY) { + $this->set('type', self::TYPE_PAGE); + } + else { + $this->set('type', self::TYPE_CATEGORY); + } + } + + public function isCategory(): bool + { + return $this->type == self::TYPE_CATEGORY; + } + + public function isOnline(): bool + { + return $this->status == self::STATUS_ONLINE; + } +} diff --git a/src/include/lib/Paheko/Entity.php b/src/include/lib/Paheko/Entity.php new file mode 100644 index 0000000..023582a --- /dev/null +++ b/src/include/lib/Paheko/Entity.php @@ -0,0 +1,232 @@ +import($source); + } + catch (\UnexpectedValueException $e) { + throw new ValidationException($e->getMessage(), 0, $e); + } + } + + static public function filterUserDateValue(?string $value): ?\DateTime + { + $value = trim((string) $value); + + if (!$value) { + return null; + } + + if (preg_match('!^20\d{2}[01]\d[0123]\d$!', $value)) { + return Date::createFromFormat('!Ymd', $value); + } + elseif (ctype_digit($value)) { + return new DateTime('@' . $value); + } + elseif ($v = \DateTime::createFromFormat('!Y-m-d H:i:s', $value)) { + return $v; + } + elseif ($v = \DateTime::createFromFormat('!Y-m-d H:i', $value)) { + return $v; + } + elseif ($v = Date::createFromFormat('!Y-m-d', $value)) { + return $v; + } + elseif (preg_match('!^\d{2}/\d{2}/\d{4}\s\d{2}:\d{2}!', $value)) { + return \DateTime::createFromFormat('!d/m/Y H:i', substr($value, 0, 16)); + } + elseif (preg_match('!^\d{2}/\d{2}/\d{2}$!', $value)) { + $year = substr($value, -2); + + // Make sure recent years are in the 21st century + if ($year < date('y') + 10) { + $year = sprintf('20%02d', $year); + } + // while old dates remain in the old one + else { + $year = sprintf('19%02d', $year); + } + + return Date::createFromFormat('d/m/Y', substr($value, 0, -2) . $year); + } + elseif (preg_match('!^\d{2}/\d{2}/\d{4}$!', $value)) { + return Date::createFromFormat('!d/m/Y', $value); + } + elseif (preg_match('!^\d{4}/\d{2}/\d{2}$!', $value)) { + return Date::createFromFormat('!Y/m/d', $value); + } + elseif (null !== $value) { + throw new ValidationException('Format de date invalide (merci d\'utiliser le format JJ/MM/AAAA) : ' . $value); + } + + return null; + } + + protected function filterUserValue(string $type, $value, string $key) + { + if ($type == 'date' || $type == Date::class) { + if ($value instanceof Date) { + return $value; + } + elseif ($value instanceof \DateTimeInterface) { + return Date::createFromInterface($value); + } + + $d = self::filterUserDateValue($value); + + if (!$d) { + return $d; + } + + $y = $d->format('Y'); + if ($y < 1900 || $y > 2100) { + throw new ValidationException(sprintf('Date invalide (%s) : doit être entre 1900 et 2100', $key)); + } + + return $d; + + } + elseif (($type == 'DateTime' || $type === 'DateTimeInterface') && is_string($value)) { + $d = self::filterUserDateValue($value); + return $d; + } + + return parent::filterUserValue($type, $value, $key); + } + + protected function assert($test, string $message = null, int $code = 0): void + { + if ($test) { + return; + } + + if (null === $message) { + $backtrace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 2); + $caller_class = array_pop($backtrace); + $caller = array_pop($backtrace); + $message = sprintf('Entity assertion fail from class %s on line %d', $caller_class['class'], $caller['line']); + throw new \UnexpectedValueException($message); + } + else { + throw new ValidationException($message, $code); + } + } + + // Add plugin signals to save/delete + public function save(bool $selfcheck = true): bool + { + $name = get_class($this); + $name = str_replace('Paheko\Entities\\', '', $name); + + // We are doing selfcheck here before sending the before event + if ($selfcheck) { + $this->selfCheck(); + } + + $new = $this->exists() ? false : true; + $modified = $this->isModified(); + $entity = $this; + $params = compact('entity', 'new', 'modified'); + + $signals = [ + // Specific entity signal + 'entity.' . $name . '.save', + // Generic entity signal + 'entity.save', + ]; + + if ($new) { + $signals[] = 'entity.' . $name . '.create'; + $signals[] = 'entity.create'; + } + elseif ($modified) { + $signals[] = 'entity.' . $name . '.modify'; + $signals[] = 'entity.modify'; + $params['modified_properties'] = $this->getModifiedProperties(); + } + + foreach ($signals as $signal_name) { + $signal = Plugins::fire($signal_name . '.before', true, $params); + + if ($signal && $signal->isStopped()) { + return true; + } + } + + $params['success'] = parent::save(false); + + // Log creation/edit, but don't record stuff that doesn't change anything + if ($this::NAME && ($new || $modified)) { + Log::add($new ? Log::CREATE : Log::EDIT, ['entity' => get_class($this), 'id' => $this->id()]); + } + + foreach ($signals as $signal_name) { + Plugins::fire($signal_name . '.after', false, $params); + } + + return $params['success']; + } + + public function delete(): bool + { + $type = get_class($this); + $type = str_replace('Paheko\Entities\\', '', $type); + $name = 'entity.' . $type . '.delete'; + + $id = $this->id(); + $entity = $this; + + // Specific entity signal + $signal = Plugins::fire($name . '.before', true, compact('entity', 'id')); + + if ($signal && $signal->isStopped()) { + return true; + } + + // Generic entity signal + $signal = Plugins::fire('entity.delete.before', true, compact('entity', 'id')); + + if ($signal && $signal->isStopped()) { + return true; + } + + $success = parent::delete(); + + if ($this::NAME) { + Log::add(Log::DELETE, ['entity' => get_class($this), 'id' => $id]); + } + + Plugins::fire($name . '.after', false, compact('entity', 'success', 'id')); + Plugins::fire('entity.delete.after', false, compact('entity', 'success', 'id')); + + return $success; + } +} diff --git a/src/include/lib/Paheko/Extensions.php b/src/include/lib/Paheko/Extensions.php new file mode 100644 index 0000000..e200c86 --- /dev/null +++ b/src/include/lib/Paheko/Extensions.php @@ -0,0 +1,256 @@ +firstColumn(' + SELECT 1 FROM modules WHERE enabled = 1 AND web = 0 + UNION ALL + SELECT 1 FROM plugins WHERE enabled = 1 AND name != \'welcome\';'); + } + + static public function listAvailableButtons(): array + { + $list = self::listDisabled(); + + // Sort items by label + uasort($list, fn ($a, $b) => strnatcasecmp($a->label, $b->label)); + + foreach ($list as &$item) { + $url = sprintf('%s/%s/', $item->type == 'plugin' ? ADMIN_URL . 'p' : BASE_URL . 'm', $item->name); + $item = CommonFunctions::linkButton([ + 'label' => $item->label, + 'icon' => $url . 'icon.svg', + 'href' => '!config/ext/?install=1&focus=' . $item->name, + ]); + } + + return $list; + } + + static public function get(string $type, string $name) + { + if ($type === 'module') { + return Modules::get($name); + } + elseif ($type === 'plugin') { + $ext = Plugins::get($name); + $ext ??= Plugins::getInstallable($name); + return $ext; + } + else { + throw new \InvalidArgumentException('Invalid type: ' . $type); + } + } + + static public function toggle(string $type, string $name, bool $enabled) + { + if ($type === 'plugin') { + $plugin = Plugins::get($name); + + if (!$plugin && $enabled) { + $plugin = Plugins::install($name); + } + elseif ($plugin) { + $plugin->set('enabled', $enabled); + $plugin->save(); + } + else { + return null; + } + + return $plugin; + } + elseif ($type === 'module') { + $m = Modules::get($name); + + if (!$m) { + return null; + } + + $m->set('enabled', $enabled); + $m->save(); + + return $m; + } + else { + throw new \InvalidArgumentException('Invalid type: ' . $type); + } + } + + static public function normalize($item): \stdClass + { + $type = $item instanceof Plugin ? 'plugin' : 'module'; + $c = $item; + $item = (object) $c->asArray(); + $item->$type = $c; + $item->type = $type; + $item->label = $c->label ?? $c->name; + $item->icon_url = $c->icon_url(); + $item->details_url = sprintf('details.php?type=%s&name=%s', $type, $c->name); + $item->config_url = $c->hasConfig() ? $c->url($c::CONFIG_FILE) : null; + $item->installed = $type == 'plugin' ? $c->exists() : true; + $item->missing = $type == 'plugin' ? !$c->hasCode() : false; + $item->broken_message = $type == 'plugin' ? $c->getBrokenMessage() : false; + $item->readme = $c->hasFile($c::README_FILE); + + $item->url = null; + + if ($c->hasFile($c::INDEX_FILE)) { + $item->url = $c->url($type == 'plugin' ? 'admin/' : ''); + } + + return $item; + } + + static protected function filterList(array &$list): void + { + foreach ($list as &$item) { + $item = self::normalize($item); + } + + unset($item); + + usort($list, fn ($a, $b) => strnatcasecmp($a->label ?? $a->name, $b->label ?? $b->name)); + + array_walk($list, fn(&$a) => $a = (object) $a); + } + + static public function listDisabled(): array + { + $list = []; + + foreach (EM::getInstance(Module::class)->iterate('SELECT * FROM @TABLE WHERE enabled = 0;') as $m) { + $list[$m->name] = $m; + } + + foreach (Plugins::listInstallable() as $name => $p) { + $list[$name] = $p; + } + + foreach (Plugins::listInstalled() as $p) { + if ($p->enabled) { + continue; + } + + $list[$p->name] = $p; + } + + self::filterList($list); + return $list; + } + + static public function listEnabled(): array + { + $list = []; + + foreach (EM::getInstance(Module::class)->iterate('SELECT * FROM @TABLE WHERE enabled = 1;') as $m) { + $list[$m->name] = $m; + } + + foreach (Plugins::listInstalled() as $p) { + if (!$p->enabled) { + continue; + } + + if (!$p->hasCode()) { + $p->set('enabled', false); + $p->save(); + continue; + } + + $list[$p->name] = $p; + } + + self::filterList($list); + return $list; + } + + static public function listMenu(Session $session): array + { + $list = []; + + $sql = 'SELECT \'module\' AS type, name, label, restrict_section, restrict_level FROM modules WHERE menu = 1 AND enabled = 1 + UNION ALL + SELECT \'plugin\' AS type, name, label, restrict_section, restrict_level FROM plugins WHERE menu = 1 AND enabled = 1;'; + + foreach (DB::getInstance()->get($sql) as $item) { + if ($item->restrict_section && !$session->canAccess($item->restrict_section, $item->restrict_level)) { + continue; + } + + $list[$item->type . '_' . $item->name] = $item; + } + + // Sort items by label + uasort($list, fn ($a, $b) => strnatcasecmp($a->label, $b->label)); + + foreach ($list as &$item) { + $item = sprintf('%s', + $item->type == 'plugin' ? ADMIN_URL . 'p' : BASE_URL . 'm', + $item->name, + $item->label + ); + } + + unset($item); + + // Append plugins from signals + $signal = Plugins::fire('menu.item', false, compact('session'), $list); + + return $signal ? $signal->getOut() : $list; + } + + static public function listHomeButtons(Session $session): array + { + $list = []; + + $sql = 'SELECT \'module\' AS type, name, label, restrict_section, restrict_level FROM modules WHERE home_button = 1 AND enabled = 1 + UNION ALL + SELECT \'plugin\' AS type, name, label, restrict_section, restrict_level FROM plugins WHERE home_button = 1 AND enabled = 1;'; + + foreach (DB::getInstance()->get($sql) as $item) { + if ($item->restrict_section && !$session->canAccess($item->restrict_section, $item->restrict_level)) { + continue; + } + + $list[$item->type . '_' . $item->name] = $item; + } + + // Sort items by label + uasort($list, fn ($a, $b) => strnatcasecmp($a->label, $b->label)); + + foreach ($list as &$item) { + $url = sprintf('%s/%s/', $item->type == 'plugin' ? ADMIN_URL . 'p' : BASE_URL . 'm', $item->name); + $item = CommonFunctions::linkButton([ + 'label' => $item->label, + 'icon' => $url . 'icon.svg', + 'href' => $url, + ]); + } + + unset($item); + + foreach (Modules::snippets(Modules::SNIPPET_HOME_BUTTON) as $name => $v) { + $list['module_' . $name] = $v; + } + + $signal = Plugins::fire('home.button', false, ['user' => $session->getUser(), 'session' => $session], $list); + + return $signal ? $signal->getOut() : $list; + } + +} diff --git a/src/include/lib/Paheko/Files/Files.php b/src/include/lib/Paheko/Files/Files.php new file mode 100644 index 0000000..b94eb35 --- /dev/null +++ b/src/include/lib/Paheko/Files/Files.php @@ -0,0 +1,1111 @@ + File::CONTEXT_USER . '/' . $s::getUserId() . '/', + 'Documents de l\'association' => File::CONTEXT_DOCUMENTS, + 'Fichiers des membres' => File::CONTEXT_USER . '//', + 'Fichiers des écritures comptables' => File::CONTEXT_TRANSACTION . '//', + 'Fichiers du site web (contenu des pages, images, etc.)' => File::CONTEXT_WEB . '//', + 'Fichiers de la configuration (logo, etc.)' => File::CONTEXT_CONFIG, + 'Code des modules' => File::CONTEXT_MODULES, + ]; + + $out = []; + + foreach ($contexts as $name => $path) { + $out[$name] = $perm[$path] ?? null; + } + + return $out; + } + + /** + * Returns an array of all file permissions for a given user + */ + static public function buildUserPermissions(Session $s): array + { + $is_admin = $s->canAccess($s::SECTION_CONFIG, $s::ACCESS_ADMIN); + + $p = []; + + // Note USER context files are managed in Session::checkFilePermission + + $p[File::CONTEXT_CONFIG] = [ + 'mkdir' => false, + 'move' => false, + 'create' => false, + 'read' => $s->isLogged(), // All config files can be accessed by all logged-in users + 'write' => $is_admin, + 'delete' => false, + 'share' => false, + ]; + + // Modules source code + $p[File::CONTEXT_MODULES . '/'] = [ + 'mkdir' => $is_admin, + 'move' => $is_admin, + 'create' => $is_admin, + 'read' => $s->isLogged(), + 'write' => $is_admin, + 'delete' => $is_admin, + 'share' => false, + ]; + + // Modules source code + $p[File::CONTEXT_MODULES] = [ + 'mkdir' => false, + 'move' => false, + 'create' => false, + 'read' => $s->isLogged(), + 'write' => false, + 'delete' => false, + 'share' => false, + ]; + + $p[File::CONTEXT_WEB . '//'] = [ + 'mkdir' => false, + 'move' => false, + 'create' => $s->canAccess($s::SECTION_WEB, $s::ACCESS_WRITE), + 'read' => $s->canAccess($s::SECTION_WEB, $s::ACCESS_READ), + 'write' => $s->canAccess($s::SECTION_WEB, $s::ACCESS_WRITE), + 'delete' => $s->canAccess($s::SECTION_WEB, $s::ACCESS_WRITE), + 'share' => false, + ]; + + // At root level of web you can only create new articles + $p[File::CONTEXT_WEB] = [ + 'mkdir' => $s->canAccess($s::SECTION_WEB, $s::ACCESS_WRITE), + 'move' => false, + 'create' => false, + 'read' => $s->canAccess($s::SECTION_WEB, $s::ACCESS_READ), + 'write' => false, + 'delete' => false, + 'share' => false, + ]; + + // Documents: you can do everything as long as you have access + $p[File::CONTEXT_DOCUMENTS . '/'] = [ + 'mkdir' => $s->canAccess($s::SECTION_DOCUMENTS, $s::ACCESS_WRITE), + 'move' => $s->canAccess($s::SECTION_DOCUMENTS, $s::ACCESS_WRITE), + 'create' => $s->canAccess($s::SECTION_DOCUMENTS, $s::ACCESS_WRITE), + 'read' => $s->canAccess($s::SECTION_DOCUMENTS, $s::ACCESS_READ), + 'write' => $s->canAccess($s::SECTION_DOCUMENTS, $s::ACCESS_WRITE), + 'delete' => $s->canAccess($s::SECTION_DOCUMENTS, $s::ACCESS_ADMIN), + 'share' => $s->canAccess($s::SECTION_DOCUMENTS, $s::ACCESS_WRITE), + ]; + + // The root directory cannot be deleted or renamed/moved + $p[File::CONTEXT_DOCUMENTS] = [ + 'mkdir' => $s->canAccess($s::SECTION_DOCUMENTS, $s::ACCESS_WRITE), + 'move' => false, + 'create' => $s->canAccess($s::SECTION_DOCUMENTS, $s::ACCESS_WRITE), + 'read' => $s->canAccess($s::SECTION_DOCUMENTS, $s::ACCESS_READ), + 'write' => $s->canAccess($s::SECTION_DOCUMENTS, $s::ACCESS_WRITE), + 'delete' => false, + 'share' => $s->canAccess($s::SECTION_DOCUMENTS, $s::ACCESS_WRITE), + ]; + + // You can write in transaction subdirectories + $p[File::CONTEXT_TRANSACTION . '//'] = [ + 'mkdir' => false, + 'move' => false, + 'create' => $s->canAccess($s::SECTION_ACCOUNTING, $s::ACCESS_WRITE), + 'read' => $s->canAccess($s::SECTION_ACCOUNTING, $s::ACCESS_READ), + 'write' => $s->canAccess($s::SECTION_ACCOUNTING, $s::ACCESS_WRITE), + 'delete' => $s->canAccess($s::SECTION_ACCOUNTING, $s::ACCESS_ADMIN), + 'share' => $s->canAccess($s::SECTION_ACCOUNTING, $s::ACCESS_WRITE), + ]; + + // But not in root + $p[File::CONTEXT_TRANSACTION] = [ + 'mkdir' => false, + 'move' => false, + 'write' => false, + 'create' => false, + 'delete' => false, + 'read' => $s->canAccess($s::SECTION_ACCOUNTING, $s::ACCESS_READ), + 'share' => false, + ]; + + // Not in trash + $p[File::CONTEXT_TRASH] = [ + 'mkdir' => false, + 'move' => false, + 'write' => false, + 'create' => false, + 'delete' => $s->canAccess($s::SECTION_DOCUMENTS, $s::ACCESS_ADMIN), + 'read' => $s->canAccess($s::SECTION_DOCUMENTS, $s::ACCESS_READ), + 'share' => false, + ]; + + // Not in versions + $p[File::CONTEXT_VERSIONS] = [ + 'mkdir' => false, + 'move' => false, + 'write' => false, + 'create' => false, + 'delete' => false, + 'read' => false, + 'share' => false, + ]; + + $p[''] = [ + 'mkdir' => false, + 'move' => false, + 'write' => false, + 'create' => false, + 'delete' => false, + 'read' => true, + 'share' => false, + ]; + + + return $p; + } + + static public function search(string $search, string $path = null): array + { + if (strlen($search) > 100) { + throw new ValidationException('Recherche trop longue : maximum 100 caractères'); + } + + $where = ''; + $params = [trim($search)]; + + if (null !== $path) { + $where = ' AND path LIKE ?'; + $params[] = $path; + } + + $query = sprintf('SELECT + *, + dirname(path) AS parent, + snippet(files_search, \'\', \'\', \'…\', 2, -30) AS snippet, + rank(matchinfo(files_search), 0, 1.0, 1.0) AS points + FROM files_search + WHERE files_search MATCH ? %s + ORDER BY points DESC + LIMIT 0,50;', $where); + + $out = []; + + try { + $db = DB::getInstance(); + $db->begin(); + + foreach ($db->iterate($query, ...$params) as $row) { + $out[] = $row; + } + + $db->commit(); + } + catch (DB_Exception $e) { + if (strpos($e->getMessage(), 'malformed MATCH') !== false) { + throw new UserException('Motif de recherche invalide', 0, $e); + } + + throw $e; + } + + return $out; + } + + static protected function _getParentClause(?string $parent = null): string + { + $db = DB::getInstance(); + + if (!$parent) { + return $db->where('path', 'IN', array_keys(File::CONTEXTS_NAMES)); + } + else { + File::validatePath($parent); + return 'parent = ' . $db->quote($parent); + } + } + + /** + * Returns a list of files and directories inside a parent path + * This is not recursive and will only return files and directories + * directly in the specified $parent path. + */ + static public function list(?string $parent = null): array + { + $where = self::_getParentClause($parent); + $sql = sprintf('SELECT * FROM @TABLE WHERE %s ORDER BY type DESC, name COLLATE NOCASE ASC;', $where); + + return EM::getInstance(File::class)->allAssoc($sql, 'path'); + } + + static public function getDynamicList(?string $parent = null): DynamicList + { + $columns = [ + 'icon' => ['label' => '', 'select' => NULL], + 'name' => [ + 'label' => 'Nom', + 'order' => 'type DESC, name COLLATE U_NOCASE %s', + ], + 'size' => [ + 'label' => 'Taille', + 'order' => 'type DESC, size %s, name COLLATE U_NOCASE ASC', + ], + 'modified' => [ + 'label' => 'Modifié', + 'order' => 'type DESC, modified %s, name COLLATE U_NOCASE ASC', + ], + ]; + + $tables = File::TABLE; + $conditions = self::_getParentClause($parent); + + $list = new DynamicList($columns, $tables, $conditions); + + $list->orderBy('name', false); + $list->setEntity(File::class); + + // Don't take conditions for saving preferences hash + $list->togglePreferenceHashElement('conditions', false); + + return $list; + } + + static public function listForUser(int $id, string $field_name = null): array + { + $files = []; + $path = (string) $id; + + if ($field_name) { + $path .= '/' . $field_name; + return self::listForContext(File::CONTEXT_USER, $path); + } + + $list = self::listForContext(File::CONTEXT_USER, $path); + + if (!$list) { + return []; + } + + foreach ($list as $dir) { + foreach (Files::list($dir->path) as $file) { + $files[] = $file; + } + } + + return $files; + } + + /** + * Returns a list of files or directories matching a glob pattern + * only * and ? characters are supported in pattern + * Sub-directories are not returned + */ + static public function glob(string $pattern): array + { + return EM::getInstance(File::class)->all( + 'SELECT * FROM @TABLE WHERE path GLOB ? AND path NOT GLOB ? ORDER BY type DESC, name COLLATE U_NOCASE ASC;', + $pattern, + $pattern . '/*' + ); + } + + static public function zipAll(?string $target = null): void + { + // Extend execution time, up to one hour, just in case + if (false === strpos(@ini_get('disable_functions'), 'set_time_limit')) { + @set_time_limit(3600); + } + + @ini_set('max_execution_time', 3600); + + Files::zip($target, array_keys(File::CONTEXTS_NAMES), null); + } + + /** + * Creates a ZIP file archive from multiple paths + * @param null|string $target Target file name, if left NULL, then will be sent to browser + * @param array $paths List of paths to append to ZIP file + * @param Session $session Logged-in user session, if set access rights to the path will be checked, + * if left NULL, then no check will be made (!). + */ + static public function zip(?string $target, array $paths, ?Session $session, ?string $download_name = null): void + { + if (!$target) { + $download_name ??= Config::getInstance()->org_name . ' - Documents'; + header('Content-type: application/zip'); + header(sprintf('Content-Disposition: attachment; filename="%s"', $download_name. '.zip')); + $target = 'php://output'; + } + + $zip = new ZipWriter($target); + $zip->setCompression(0); + + foreach ($paths as $path) { + foreach (Files::listRecursive($path, $session, false) as $file) { + if ($file->isDir()) { + continue; + } + + $pointer = $file->getReadOnlyPointer(); + $path = !$pointer ? $file->getLocalFilePath() : null; + + if (!$path && !$pointer) { + continue; + } + + $zip->add($file->path, null, $path, $pointer); + + if ($pointer) { + fclose($pointer); + } + } + } + + $zip->close(); + } + + static public function listRecursive(?string $path = null, ?Session $session = null, bool $include_directories = true): \Generator + { + $params = []; + $db = DB::getInstance(); + + if (!$path) { + $sql = 'SELECT * FROM @TABLE WHERE parent IS NULL;'; + } + else { + $sql = 'SELECT * FROM @TABLE WHERE path = ? OR (parent = ? OR parent LIKE ? ESCAPE \'!\')'; + + if (!$include_directories) { + $sql .= ' AND type != ' . File::TYPE_DIRECTORY; + } + + $sql .= ';'; + $params = [$path, $path, $db->escapeLike($path, '!') . '/%']; + } + + $list = EM::getInstance(File::class)->iterate($sql, ...$params); + + foreach ($list as $file) { + if ($session && !$file->canRead($session)) { + continue; + } + + yield $file->path => $file; + } + } + + static public function all() + { + $sql = 'SELECT * FROM @TABLE;'; + return EM::getInstance(File::class)->all($sql); + } + + /** + * List files and directories inside a context (first-level directory) + */ + static public function listForContext(string $context, ?string $ref = null): array + { + $path = $context; + + if ($ref) { + $path .= '/' . $ref; + } + + return self::list($path) ?? []; + } + + /** + * Delete a specified file/directory path + */ + static public function delete(string $path): void + { + $file = self::get($path); + + if (!$file) { + return; + } + + $file->delete(); + } + + static public function callStorage(string $function, ...$args) + { + $class_name = __NAMESPACE__ . '\\Storage\\' . FILE_STORAGE_BACKEND; + + call_user_func([$class_name, 'configure'], FILE_STORAGE_CONFIG); + + return call_user_func_array([$class_name, $function], $args); + } + + static public function ensureContextsExists(): void + { + foreach (File::CONTEXTS_NAMES as $key => $label) { + self::ensureDirectoryExists($key); + } + } + + /** + * Returns a file, if it doesn't exist, NULL is returned + * If the file exists in DB but not in storage, it is deleted from DB + */ + static public function get(?string $path): ?File + { + if (null === $path) { + // Root always exists, but is virtual + $file = new File; + $file->path = ''; + $file->name = ''; + $file->parent = null; + $file->type = $file::TYPE_DIRECTORY; + return $file; + } + + try { + File::validatePath($path); + } + catch (ValidationException $e) { + return null; + } + + $file = EM::findOne(File::class, 'SELECT * FROM @TABLE WHERE path = ? LIMIT 1;', $path); + + if (!$file && array_key_exists($path, File::CONTEXTS_NAMES)) { + self::ensureContextsExists(); + $file = EM::findOne(File::class, 'SELECT * FROM @TABLE WHERE path = ? LIMIT 1;', $path); + } + + if (!$file) { + return null; + } + + return $file; + } + + static public function exists(string $path): bool + { + return DB::getInstance()->test('files', 'path = ?', $path); + } + + static public function getFromURI(string $uri): ?File + { + $uri = rawurldecode($uri); + $uri = trim($uri, '/'); + + $context = strtok($uri, '/'); + $other = strtok(''); + + // Not in a context? it's probably a web page attachment + if (false === strpos($other, '/') && !array_key_exists($context, File::CONTEXTS_NAMES)) { + $uri = File::CONTEXT_WEB . '/' . $uri; + } + + return self::get($uri); + } + + static public function getContext(string $path): ?string + { + $pos = strpos($path, '/'); + + if (false === $pos) { + return $path; + } + + $context = substr($path, 0, $pos); + + if (!$context) { + return null; + } + + if (!array_key_exists($context, File::CONTEXTS_NAMES)) { + return null; + } + + return $context; + } + + static public function getContextRef(string $path): ?string + { + $context = strtok($path, '/'); + $ref = strtok('/'); + strtok(''); + return $ref ?: null; + } + + static public function getBreadcrumbs(string $path): array + { + $parts = explode('/', $path); + $breadcrumbs = []; + $path = ''; + + foreach ($parts as $part) { + $path = trim($path . '/' . $part, '/'); + $breadcrumbs[$path] = $part; + } + + return $breadcrumbs; + } + + static public function getContextsDiskUsage(): array + { + $sql = 'SELECT SUBSTR(path, 1, INSTR(path, \'/\') - 1) AS context, SUM(size) AS total + FROM files + WHERE type = ? + GROUP BY SUBSTR(path, 1, INSTR(path, \'/\'));'; + + $quotas = DB::getInstance()->getAssoc($sql, File::TYPE_FILE); + $list = []; + + foreach (File::CONTEXTS_NAMES as $context => $name) { + $list[$context] = ['label' => $name, 'size' => $quotas[$context] ?? null]; + } + + uasort($list, fn($a, $b) => $a['size'] == $b['size'] ? 0 : ($a['size'] > $b['size'] ? -1 : 1)); + + return $list; + } + + static public function getContextDiskUsage(string $context): int + { + $sql = 'SELECT SUM(size) FROM files WHERE type = ? AND path LIKE ?;'; + return (int) DB::getInstance()->firstColumn($sql, File::TYPE_FILE, $context . '/%'); + } + + static public function getQuota(): float + { + return FILE_STORAGE_QUOTA ?? self::callStorage('getQuota'); + } + + static public function getUsedQuota(): float + { + return (float) DB::getInstance()->firstColumn('SELECT SUM(size) FROM files;'); + } + + static public function getRemainingQuota(float $used_quota = null): float + { + if (FILE_STORAGE_QUOTA) { + return max(0, FILE_STORAGE_QUOTA - ($used_quota ?? self::getUsedQuota())); + } + else { + return self::callStorage('getRemainingQuota'); + } + } + + static public function checkQuota(int $size = 0): void + { + if (!self::$quota) { + return; + } + + $remaining = self::getRemainingQuota(); + + if (($remaining - (float) $size) <= 0) { + throw new ValidationException('L\'espace disque est insuffisant pour réaliser cette opération'); + } + } + + static protected function create(string $parent, string $name, array $source = []): File + { + Files::checkQuota(); + + File::validateFileName($name); + File::validatePath($parent); + + File::validateCanHTML($name, $parent); + + $target = $parent . '/' . $name; + + self::ensureDirectoryExists($parent); + $finfo = \finfo_open(\FILEINFO_MIME_TYPE); + + $file = self::get($target); + + if (!$file) { + $file = new File; + $file->set('path', $target); + $file->set('parent', $parent); + $file->set('name', $name); + } + + if (isset($source['pointer'])) { + if (0 !== fseek($source['pointer'], 0, SEEK_END)) { + throw new \RuntimeException('Stream is not seekable'); + } + + $file->set('size', ftell($source['pointer'])); + fseek($source['pointer'], 0, SEEK_SET); + $file->set('mime', mime_content_type($source['pointer'])); + } + elseif (isset($source['path'])) { + $file->set('mime', finfo_file($finfo, $source['path'])); + $file->set('size', filesize($source['path'])); + $file->set('modified', new \DateTime('@' . filemtime($source['path']))); + } + elseif (isset($source['content'])) { + $file->set('size', strlen($source['content'])); + $file->set('mime', finfo_buffer($finfo, $source['content'])); + } + else { + $file->set('size', 0); + $file->set('mime', 'text/plain'); + } + + $file->set('image', in_array($file->mime, $file::IMAGE_TYPES)); + + // Force empty files as text/plain + if ($file->mime == 'application/x-empty' && !$file->size) { + $file->set('mime', 'text/plain'); + } + + return $file; + } + + static public function createDocument(string $parent, string $name, string $extension): File + { + // From https://github.com/nextcloud/richdocuments/tree/2338e2ff7078040d54fc0c70a96c8a1b860f43a0/emptyTemplates + // We need to copy an empty template, or Collabora will create flat-XML file + if ($extension == 'ods') { + $tpl = 'UEsDBBQAAAAAAOw6wVCFbDmKLgAAAC4AAAAIAAAAbWltZXR5cGVhcHBsaWNhdGlvbi92bmQub2FzaXMub3BlbmRvY3VtZW50LnNwcmVhZHNoZWV0UEsDBBQAAAAIABxZFFFL43PrmgAAAEABAAAVAAAATUVUQS1JTkYvbWFuaWZlc3QueG1slVDRDoMgDHz3KwjvwvZK1H9poEYSKETqon8vLpluWfawPrXXy921XQTyIxY2r0asMVA5x14uM5kExRdDELEYtiZlJJfsEpHYfPLNXd2kGBpRqzvB0QdsK3nexIUtIbQZeOqllhcc0XloecvYS8g5eAvsE+kHOfWMod7dVckzgisTIkv9p61NxIdGveBHAMaV9bGu0p3++tXQ7FBLAwQUAAAACAAAWRRRA4GGVIkAAAD/AAAACwAAAGNvbnRlbnQueG1sXY/RCsIwDEWf9SvG3uv0Ncz9S01TLLTNWFJwf29xbljzEu49N1wysvcBCRxjSZTVIGetu3ulmAU2eu/LkoGtBIFsEwkoAs+U9yv4TcPtcu2nc1dn/DqCS5hVuqG1fe0y3iIZRxg/+LQzW5ST1YBGdI3Uwge7tcpDy7yQdfIk0i03NMFD/n85vQFQSwECFAMUAAAAAADsOsFQhWw5ii4AAAAuAAAACAAAAAAAAAAAAAAAtIEAAAAAbWltZXR5cGVQSwECFAMUAAAACAAcWRRRS+Nz65oAAABAAQAAFQAAAAAAAAAAAAAAtIFUAAAATUVUQS1JTkYvbWFuaWZlc3QueG1sUEsBAhQDFAAAAAgAAFkUUQOBhlSJAAAA/wAAAAsAAAAAAAAAAAAAALSBIQEAAGNvbnRlbnQueG1sUEsFBgAAAAADAAMAsgAAANMBAAAAAA=='; + } + elseif ($extension == 'odp') { + $tpl = 'UEsDBBQAAAAAAC6dVEszJqyoLwAAAC8AAAAIAAAAbWltZXR5cGVhcHBsaWNhdGlvbi92bmQub2FzaXMub3BlbmRvY3VtZW50LnByZXNlbnRhdGlvblBLAwQUAAAACAAsYRRRP7fJFJoAAABBAQAAFQAAAE1FVEEtSU5GL21hbmlmZXN0LnhtbJVQwQqDMAy97ytK77bbNaj/EmpkhTYtNg79+1VhujF2WC5JXh7vJWkjsh+pCLwKtcTA5Wg7PU8MCYsvwBipgDhImXhIbo7EAp98uJmrVv1F1WgPcPSBmkqeVnVicwhNRrl32uoTjjR4bGTN1GnMOXiH4hPbBw9mX8O8u5s8Ual552j7p69LLJtIPeHHBkKL2G1cpVv79az+8gRQSwMEFAAAAAgAMl4UUXz4vRWJAAAA/gAAAAsAAABjb250ZW50LnhtbF2P0QqDMAxFn+dXiO+d22tw/ksXUyjYpJgI8+8tOGVdXsK994Qkg4QQkWASXBOxORS20ttPmlnhSF/dujCI16jAPpGCIUgmPqfgl4bn/dGNTVtq+DqKS8ymbT82t9MLZZELHslNhHOd+dUkeYvo1LaZ6vAt01bkpfNCWm4ouPAB9hV5yf8fx2YHUEsBAhQDFAAAAAAALp1USzMmrKgvAAAALwAAAAgAAAAAAAAAAAAAALSBAAAAAG1pbWV0eXBlUEsBAhQDFAAAAAgALGEUUT+3yRSaAAAAQQEAABUAAAAAAAAAAAAAALSBVQAAAE1FVEEtSU5GL21hbmlmZXN0LnhtbFBLAQIUAxQAAAAIADJeFFF8+L0ViQAAAP4AAAALAAAAAAAAAAAAAAC0gSIBAABjb250ZW50LnhtbFBLBQYAAAAAAwADALIAAADUAQAAAAA='; + } + else { + $extension = 'odt'; + $tpl = 'UEsDBBQAAAAAAPMbH0texjIMJwAAACcAAAAIAAAAbWltZXR5cGVhcHBsaWNhdGlvbi92bmQub2FzaXMub3BlbmRvY3VtZW50LnRleHRQSwMEFAAAAAgA3U0SUeqX5meSAAAAMQEAABUAAABNRVRBLUlORi9tYW5pZmVzdC54bWyVUEEOgzAMu+8VqHfa7Rq1/CUqQavUphUNE/wemDTYNO2wW2I7thWbkMNAVeA1NHOKXI/VqWlkyFhDBcZEFcRDLsR99lMiFvjUw01fVXdp7AEMIVK7CcelObEpxrag3J0y6oQT9QFbWQo5haXE4FFCZvPgXj8r6PdkLTSLMv+E+cyyX26df8TunmanN19rvr7TrVBLAwQUAAAACACQThJRWmJBaH8AAADjAAAACwAAAGNvbnRlbnQueG1sXY/RCsMgDEXf+xWj767ba+j8FxcjCGpKE6H9+wlbRfYUbs69uWTlECISeMaaqahBLtrm7cipCHzpa657AXYSBYrLJKAIvFG5UjC64Xl/zHZaf0pwj5vKYq9FaA0mOCTjCdMAXFXOTiMa0TNRI/3Im/3ZfUqHttQysqnL/0/sB1BLAQIUAxQAAAAAAPMbH0texjIMJwAAACcAAAAIAAAAAAAAAAAAAACkgQAAAABtaW1ldHlwZVBLAQIUAxQAAAAIAN1NElHql+ZnkgAAADEBAAAVAAAAAAAAAAAAAACkgU0AAABNRVRBLUlORi9tYW5pZmVzdC54bWxQSwECFAMUAAAACACQThJRWmJBaH8AAADjAAAACwAAAAAAAAAAAAAApIESAQAAY29udGVudC54bWxQSwUGAAAAAAMAAwCyAAAAugEAAAAA'; + } + + $target = $parent . '/' . $name . '.' . $extension; + + if (self::exists($target)) { + throw new ValidationException('Un document existe déjà avec ce nom : ' . $name . '.' .$extension); + } + + return Files::createFromString($target, base64_decode($tpl)); + } + + static public function createObject(string $target) + { + $parent = Utils::dirname($target); + $name = Utils::basename($target); + return self::create($parent, $name); + } + + static public function createFrom(string $target, array $source): File + { + $target = preg_replace('!\.\.|//!', '', $target); + $parent = Utils::dirname($target); + $name = Utils::basename($target); + $file = self::create($parent, $name, $source); + $file->store($source); + return $file; + } + + /** + * Create and store a file from a local path + * @param string $target Target parent path + name + * @param string $path Source file path + * @return File + */ + static public function createFromPath(string $target, string $path): File + { + return self::createFrom($target, compact('path')); + } + + /** + * Create and store a file from a string + * @param string $target Target parent path + name + * @param string $content Source file contents + * @return File + */ + static public function createFromString(string $target, string $content): File + { + return self::createFrom($target, compact('content')); + } + + static public function createFromPointer(string $target, $pointer): File + { + return self::createFrom($target, compact('pointer')); + } + + /** + * Upload multiple files + * @param string $parent Target parent directory (eg. 'documents/Logos') + * @param string $key The name of the file input in the HTML form (this MUST have a '[]' at the end of the name) + * @return array list of File objects created + */ + static public function uploadMultiple(string $parent, string $key): array + { + // Detect if it's actually a single file + if (isset($_FILES[$key]['name']) && !is_array($_FILES[$key]['name'])) { + return [self::upload($parent, $key)]; + } + + if (!isset($_FILES[$key]['name'][0])) { + throw new UserException('Aucun fichier reçu'); + } + + // Transpose array + // see https://www.php.net/manual/en/features.file-upload.multiple.php#53240 + $files = Utils::array_transpose($_FILES[$key]); + $out = []; + + // First check all files + foreach ($files as $file) { + if (!empty($file['error'])) { + throw new UserException(self::getUploadErrorMessage($file['error'])); + } + + if (empty($file['size']) || empty($file['name'])) { + throw new UserException('Fichier reçu invalide : vide ou sans nom de fichier.'); + } + + if (!is_uploaded_file($file['tmp_name'])) { + throw new \RuntimeException('Le fichier n\'a pas été envoyé de manière conventionnelle.'); + } + } + + // Then create files + foreach ($files as $file) { + $name = File::filterName($file['name']); + $out[] = self::createFromPath($parent . '/' . $name, $file['tmp_name']); + } + + return $out; + } + + /** + * Upload a file using POST from a HTML form + * @param string $parent Target parent directory (eg. 'documents/Logos') + * @param string $key The name of the file input in the HTML form + * @return self Created file object + */ + static public function upload(string $parent, string $key, ?string $name = null): File + { + if (!isset($_FILES[$key]) || !is_array($_FILES[$key])) { + throw new UserException('Aucun fichier reçu'); + } + + $file = $_FILES[$key]; + + if (!empty($file['error'])) { + throw new UserException(self::getUploadErrorMessage($file['error'])); + } + + if (empty($file['size']) || empty($file['name'])) { + throw new UserException('Fichier reçu invalide : vide ou sans nom de fichier.'); + } + + if (!is_uploaded_file($file['tmp_name'])) { + throw new \RuntimeException('Le fichier n\'a pas été envoyé de manière conventionnelle.'); + } + + $name = File::filterName($name ?? $file['name']); + + return self::createFromPath($parent . '/' . $name, $file['tmp_name']); + } + + + /** + * Récupération du message d'erreur + * @param integer $error Code erreur du $_FILE + * @return string Message d'erreur + */ + static public function getUploadErrorMessage($error) + { + switch ($error) + { + case UPLOAD_ERR_INI_SIZE: + return 'Le fichier excède la taille permise par la configuration.'; + case UPLOAD_ERR_FORM_SIZE: + return 'Le fichier excède la taille permise par le formulaire.'; + case UPLOAD_ERR_PARTIAL: + return 'L\'envoi du fichier a été interrompu.'; + case UPLOAD_ERR_NO_FILE: + return 'Aucun fichier n\'a été reçu.'; + case UPLOAD_ERR_NO_TMP_DIR: + return 'Pas de répertoire temporaire pour stocker le fichier.'; + case UPLOAD_ERR_CANT_WRITE: + return 'Impossible d\'écrire le fichier sur le disque du serveur.'; + case UPLOAD_ERR_EXTENSION: + return 'Une extension du serveur a interrompu l\'envoi du fichier.'; + default: + return 'Erreur inconnue: ' . $error; + } + } + + /** + * Create a new directory + * @param string $parent Target parent path + * @param string $name Target name + * @param bool $create_parent Create parent directories if they don't exist + * @return self + */ + static public function mkdir(string $path, bool $create_parent = true, bool $throw_on_conflict = true): File + { + $path = trim($path, '/'); + $parent = Utils::dirname($path) ?: null; + $name = Utils::basename($path); + + $name = File::filterName($name); + $path = trim($parent . '/' . $name, '/'); + + File::validateFileName($name); + File::validatePath($path); + + Files::checkQuota(); + + if (self::exists($path)) { + if ($throw_on_conflict) { + throw new ValidationException('Le nom de répertoire choisi existe déjà: ' . $path); + } + + return self::get($path); + } + + if ($parent && $create_parent) { + self::mkdir($parent, true, false); + } + + $file = new File; + $type = $file::TYPE_DIRECTORY; + $file->import(compact('path', 'name', 'parent') + [ + 'type' => file::TYPE_DIRECTORY, + 'image' => false, + ]); + + $file->modified = new \DateTime; + $file->save(); + + Plugins::fire('file.mkdir', false, compact('file')); + + return $file; + } + + static public function ensureDirectoryExists(string $path): void + { + $parts = explode('/', trim($path, '/')); + $parts = array_filter($parts); + $tree = ''; + + $db = DB::getInstance(); + $db->begin(); + + foreach ($parts as $part) { + $parent = $tree ?: null; + $tree = trim($tree . '/' . $part, '/'); + + // Make sure directory exists AND is not in trash + $db->preparedQuery('INSERT OR IGNORE INTO files (path, parent, name, type) VALUES (?, ?, ?, ?);', + $tree, $parent, $part, File::TYPE_DIRECTORY); + } + + $db->commit(); + } + + /** + * Return list of context that can be read by currently logged user + */ + static public function listReadAccessContexts(?Session $session): array + { + if (!$session->isLogged()) { + return []; + } + + $list = []; + + if ($session->canAccess($session::SECTION_CONFIG, $session::ACCESS_ADMIN)) { + $access[] = File::CONTEXT_CONFIG; + $access[] = File::CONTEXT_MODULES; + } + + if ($session->canAccess($session::SECTION_ACCOUNTING, $session::ACCESS_READ)) { + $access[] = File::CONTEXT_TRANSACTION; + } + + if ($session->canAccess($session::SECTION_USERS, $session::ACCESS_READ)) { + $access[] = File::CONTEXT_USER; + } + + if ($session->canAccess($session::SECTION_DOCUMENTS, $session::ACCESS_READ)) { + $access[] = File::CONTEXT_DOCUMENTS; + } + + if ($session->canAccess($session::SECTION_WEB, $session::ACCESS_READ)) { + $access[] = File::CONTEXT_WEB; + } + + return array_intersect_key(File::CONTEXTS_NAMES, array_flip($access)); + } + + /** + * Remove empty directories + */ + static public function pruneEmptyDirectories(string $parent): void + { + // Select all directories that don't contain any sub-files, even in sub-sub-sub directories + $sql = 'SELECT d.* FROM files d + LEFT JOIN files f ON f.type = ? AND f.path LIKE d.path || \'/%\' + WHERE d.type = ? AND d.parent LIKE ? ESCAPE \'!\' + GROUP BY d.path + HAVING COUNT(f.id) = 0 + ORDER BY d.path DESC;'; + + $like = DB::getInstance()->escapeLike($parent, '!') . '/%'; + + // Do not use iterate() here, as the next row might be deleted before we fetch it + $i = EM::getInstance(File::class)->all($sql, File::TYPE_FILE, File::TYPE_DIRECTORY, $like); + + foreach ($i as $dir) { + $dir->delete(); + } + } + + /** + * For each versioned file, prune old version + */ + static public function pruneOldVersions(): void + { + $sql = 'SELECT a.* FROM files a + INNER JOIN files b ON b.path = \'%s/\' || a.path AND b.type = %d + WHERE a.type = %d AND a.path NOT LIKE \'%s/%%\';'; + + $sql = sprintf($sql, File::CONTEXT_VERSIONS, File::TYPE_DIRECTORY, File::TYPE_FILE, File::CONTEXT_VERSIONS); + + $i = EM::getInstance(File::class)->iterate($sql); + + foreach ($i as $file) { + $file->pruneVersions(); + } + } + + /** + * For each versioned file, prune old version + */ + static public function deleteAllVersions(): void + { + $sql = 'SELECT * FROM files WHERE type = %d AND path LIKE \'%s/%%\';'; + + $sql = sprintf($sql, File::TYPE_DIRECTORY, File::CONTEXT_VERSIONS); + + $i = EM::getInstance(File::class)->iterate($sql); + + foreach ($i as $file) { + $file->delete(); + } + } + + static public function getVersioningPolicy(): string + { + if (!self::$versioning) { + return 'none'; + } + + return FILE_VERSIONING_POLICY ?? Config::getInstance()->file_versioning_policy; + } + + static public function getIconShape(string $name) + { + $ext = substr($name, strrpos($name, '.') + 1); + $ext = strtolower($ext); + + switch ($ext) { + case 'ods': + case 'xls': + case 'xlsx': + case 'csv': + return 'table'; + case 'odt': + case 'doc': + case 'docx': + case 'rtf': + return 'document'; + case 'pdf': + return 'pdf'; + case 'odp': + case 'ppt': + case 'pptx': + return 'gallery'; + case 'txt': + case 'skriv': + return 'text'; + case 'md': + return 'markdown'; + case 'html': + case 'css': + case 'js': + case 'tpl': + return 'code'; + case 'mkv': + case 'mp4': + case 'avi': + case 'ogm': + case 'ogv': + return 'video'; + case 'png': + case 'jpg': + case 'jpeg': + case 'webp': + case 'gif': + case 'svg': + return 'image'; + } + + return 'document'; + } +} diff --git a/src/include/lib/Paheko/Files/Storage.php b/src/include/lib/Paheko/Files/Storage.php new file mode 100644 index 0000000..67af787 --- /dev/null +++ b/src/include/lib/Paheko/Files/Storage.php @@ -0,0 +1,188 @@ +begin(); + + $cache_files = Files::list($path); + $local_files = Files::callStorage('listFiles', $path); + + foreach ($local_files as $file) { + if ($file->type == $file::TYPE_DIRECTORY) { + self::sync($file->path, $callback); + unset($cache_files[$file->path]); + continue; + } + + $cache_differs = false; + $cache = $cache_files[$file->path] ?? null; + + if ($cache) { + if ($cache->modified->getTimestamp() !== $file->modified->getTimestamp()) { + $cache_differs = true; + } + elseif ($cache->size !== $file->size) { + $cache_differs = true; + } + + unset($cache_files[$file->path]); + } + + if ($cache && !$cache_differs) { + continue; + } + + if ($cache) { + $cache->deleteSafe(); + } + else { + $file->deleteCache(); + } + + // Don't index versioned files as trashed + if ($file->context() === $file::CONTEXT_TRASH && strpos($file->path, $file::CONTEXT_TRASH . $file::CONTEXT_VERSIONS) === 0) { + $file->set('trash', $file->modified); + } + + // Re-create MD5 hash + $file->rehash(); + + // save() will *also* add the file to the users_files or transactions_files table + $file->save(); + + if ($callback) { + $callback($cache ? 'update' : 'create', $file); + } + } + + unset($file, $cache); + + // Delete cached files that are not in backend storage from cache + foreach ($cache_files as $file) { + // Don't use ->delete() here as it would trigger delete from storage even if there was a bug + // but we don't want to risk losing any data + $file->deleteSafe(); + + if ($callback) { + $callback('delete_cache', $file); + } + } + + $db->commit(); + } + + /** + * Copy all files from a storage backend to another one + * This can be used to move from SQLite to FileSystem for example + * Note that this only copies files, and is not removing them from the source storage backend. + */ + static public function migrate(string $from, string $to, $from_config = null, $to_config = null, ?callable $callback = null): void + { + self::call($from, 'configure', $from_config); + self::call($to, 'configure', $to_config); + + $db = DB::getInstance(); + + try { + if (self::call($from, 'isLocked')) { + throw new \RuntimeException('Storage is locked: ' . $from); + } + + if (self::call($to, 'isLocked')) { + throw new \RuntimeException('Storage is locked: ' . $to); + } + + self::call($from, 'lock'); + self::call($to, 'lock'); + + $db->begin(); + $i = 0; + + foreach (Files::all() as $file) { + if ($file->isDir()) { + continue; + } + + if (++$i >= 100) { + $db->commit(); + $db->begin(); + $i = 0; + } + + if (null !== $callback) { + $callback('copy', $file); + } + + if ($pointer = self::call($from, 'getReadOnlyPointer', $file)) { + self::call($to, 'storePointer', $file, $pointer); + fclose($pointer); + } + elseif (($path = self::call($from, 'getLocalFilePath', $file)) && file_exists($path)) { + self::call($to, 'storePath', $file, $path); + } + else { + $errors[] = sprintf('%s: no pointer or local file path found in "%s"', $file->path, $from); + } + } + + $db->commit(); + } + catch (RuntimeException $e) { + throw new \RuntimeException('Migration failed', 0, $e); + } + finally { + if ($db->inTransaction()) { + $db->rollback(); + } + + self::call($from, 'unlock'); + self::call($to, 'unlock'); + } + } + + /** + * Delete all files from a storage backend + */ + static public function truncate(string $backend, $config = null): void + { + self::call($backend, 'configure', $config); + self::call($backend, 'truncate'); + } + + /** + * Do cleanup + */ + static public function cleanup(): void + { + Files::callStorage('cleanup'); + } +} diff --git a/src/include/lib/Paheko/Files/Storage/FileSystem.php b/src/include/lib/Paheko/Files/Storage/FileSystem.php new file mode 100644 index 0000000..6f9a568 --- /dev/null +++ b/src/include/lib/Paheko/Files/Storage/FileSystem.php @@ -0,0 +1,312 @@ +modified->getTimestamp()); + } + + return $return; + } + + static public function storeContent(File $file, string $source_content): bool + { + $target = self::getLocalFilePath($file); + self::ensureParentDirectoryExists($target); + + Utils::safe_mkdir(CACHE_ROOT, null, true); + $tmpfile = tempnam(CACHE_ROOT, 'file-'); + $return = file_put_contents($tmpfile, $source_content) === false ? false : true; + + if ($return) { + rename($tmpfile, $target); + touch($target, $file->modified->getTimestamp()); + } + + return $return; + } + + static public function storePointer(File $file, $pointer): bool + { + $target = self::getLocalFilePath($file); + self::ensureParentDirectoryExists($target); + + Utils::safe_mkdir(CACHE_ROOT, null, true); + $tmpfile = tempnam(CACHE_ROOT, 'file-'); + $fp = fopen($tmpfile, 'w'); + + while (!feof($pointer)) { + fwrite($fp, fread($pointer, 8192)); + } + + fclose($fp); + + rename($tmpfile, $target); + touch($target, $file->modified->getTimestamp()); + + return true; + } + + static public function getLocalFilePath(File $file): ?string + { + return self::_getStoragePath($file->path); + } + + static protected function _getStoragePath(string $path): string + { + $path = self::_getRoot() . '/' . $path; + return str_replace('/', DIRECTORY_SEPARATOR, $path); + } + + static public function getReadOnlyPointer(File $file) + { + try { + return fopen(self::getLocalFilePath($file), 'rb'); + } + catch (\Throwable $e) { + if (false !== strpos($e->getMessage(), 'No such file')) { + return null; + } + + throw $e; + } + } + + static public function touch(File $file, \DateTime $date): void + { + $path = self::getLocalFilePath($file); + touch($path, $date->getTimestamp()); + } + + static public function rename(File $file, string $new_path): bool + { + $path = self::getLocalFilePath($file); + $new_path = self::_getStoragePath($new_path); + + if (!file_exists($path)) { + return true; + } + + // Overwrite + if (file_exists($new_path)) { + Utils::deleteRecursive($new_path, true); + } + + self::ensureParentDirectoryExists($new_path); + + rename($path, $new_path); + return true; + } + + static public function delete(File $file): bool + { + $path = self::getLocalFilePath($file); + + if (!file_exists($path)) { + return true; + } + + Utils::deleteRecursive($path, true); + return true; + } + + /** + * @see https://www.crazyws.fr/dev/fonctions-php/fonction-disk-free-space-et-disk-total-space-pour-ovh-2JMH9.html + * @see https://github.com/jdel/sspks/commit/a890e347f32e9e3e50a0dd82398947633872bf38 + */ + static public function getQuota(): float + { + $quota = disk_total_space(self::_getRoot()); + return $quota === false ? (float) \PHP_INT_MAX : (float) $quota; + } + + static public function getRemainingQuota(): float + { + $quota = @disk_free_space(self::_getRoot()); + return $quota === false ? (float) \PHP_INT_MAX : (float) $quota; + } + + static public function truncate(): void + { + Utils::deleteRecursive(self::_getRoot(), false); + } + + static public function lock(): void + { + touch(self::_getRoot() . DIRECTORY_SEPARATOR . '.lock'); + } + + static public function unlock(): void + { + Utils::safe_unlink(self::_getRoot() . DIRECTORY_SEPARATOR . '.lock'); + } + + static public function isLocked(): bool + { + return file_exists(self::_getRoot() . DIRECTORY_SEPARATOR . '.lock'); + } + + static protected function _SplToFile(\SplFileInfo $spl): ?File + { + // may return slash + // see comments https://www.php.net/manual/fr/splfileinfo.getfilename.php + // don't use getBasename as it is locale-dependent! + $name = trim($spl->getFilename(), '/'); + $root = self::_getRoot(); + + $path = substr($spl->getPathname(), strlen($root . DIRECTORY_SEPARATOR)); + $path = str_replace(DIRECTORY_SEPARATOR, '/', $path); + + try { + File::validateFileName($name); + File::validatePath($path); + } + catch (ValidationException $e) { + // Invalid files paths or names cannot be added to file cache + return null; + } + + $parent = Utils::dirname($path); + + if ($parent == '.' || !$parent) { + $parent = null; + } + + $data = [ + 'id' => null, + 'name' => $name, + 'path' => $path, + 'parent' => $parent, + 'modified' => new \DateTime('@' . $spl->getMTime()), + 'size' => $spl->isDir() ? null : $spl->getSize(), + 'type' => $spl->isDir() ? File::TYPE_DIRECTORY : File::TYPE_FILE, + 'mime' => $spl->isDir() ? null : mime_content_type($spl->getRealPath()), + 'md5' => null, + 'trash' => null, + ]; + + $data['modified']->setTimeZone(new \DateTimeZone(date_default_timezone_get())); + $data['image'] = (int) in_array($data['mime'], File::IMAGE_TYPES); + + $file = new File; + $file->load($data); + + return $file; + } + + static public function listFiles(?string $path = null): array + { + $root = self::_getRoot(); + $fullpath = $root . DIRECTORY_SEPARATOR . $path; + + if (!file_exists($fullpath)) { + return []; + } + + $files = []; + + foreach (new \FilesystemIterator($fullpath, \FilesystemIterator::SKIP_DOTS) as $file) { + // Seems that SKIP_DOTS does not work all the time? + if ($file->getFilename()[0] == '.') { + continue; + } + + $obj = self::_SplToFile($file); + + // Skip invalid files + if (null === $obj) { + continue; + } + + // Used to make sorting easier + // directory_blabla + // file_image.jpeg + $files[$file->getFilename()] = $obj; + } + + return $files; + } + + static public function cleanup(): void + { + self::_cleanupDirectory(null); + } + + /** + * Delete empty directories + */ + static protected function _cleanupDirectory(?string $path): void + { + $path ??= self::_getRoot();; + + foreach (glob($path . '/*', GLOB_ONLYDIR | GLOB_NOSORT) as $dir) { + self::_cleanupDirectory($dir); + @rmdir($dir); + } + } +} diff --git a/src/include/lib/Paheko/Files/Storage/SQLite.php b/src/include/lib/Paheko/Files/Storage/SQLite.php new file mode 100644 index 0000000..2b4126a --- /dev/null +++ b/src/include/lib/Paheko/Files/Storage/SQLite.php @@ -0,0 +1,172 @@ +openBlob('files_contents', 'content', $file->id()); + } + catch (\Exception $e) { + if (!strstr($e->getMessage(), 'no such rowid')) { + throw $e; + } + + return null; + } + + return $blob; + } + + static public function storePath(File $file, string $path): bool + { + return self::store($file, compact('path')); + } + + static public function storeContent(File $file, string $content): bool + { + return self::store($file, compact('content')); + } + + static public function storePointer(File $file, $pointer): bool + { + return self::store($file, compact('pointer')); + } + + static protected function store(File $file, array $source): bool + { + if (!isset($source['path']) && !isset($source['content']) && !isset($source['pointer'])) { + throw new \InvalidArgumentException('Unknown source type'); + } + elseif (count($source) != 1) { + throw new \InvalidArgumentException('Invalid source type'); + } + + $content = $path = $pointer = null; + extract($source); + + $db = DB::getInstance(); + + $file->save(); + + $id = $file->id(); + + $db->preparedQuery('INSERT OR REPLACE INTO files_contents (id, content) VALUES (?, zeroblob(?));', + $id, $file->size); + + $blob = $db->openBlob('files_contents', 'content', $id, 'main', \SQLITE3_OPEN_READWRITE); + + if (null !== $content) { + fwrite($blob, $content); + } + elseif ($path) { + $pointer = fopen($path, 'rb'); + } + + if ($pointer) { + while (!feof($pointer)) { + fwrite($blob, fread($pointer, 8192)); + } + + if ($path) { + fclose($pointer); + } + } + + fclose($blob); + + return true; + } + + static public function getLocalFilePath(File $file): ?string + { + return null; + } + + static public function touch(File $file, \DateTime $date): void + { + } + + static public function delete(File $file): bool + { + // Nothing to do, files_contents is deleted when files row is deleted (cascade) + return true; + } + + static public function rename(File $file, string $new_path): bool + { + return true; + } + + /** + * @see https://www.crazyws.fr/dev/fonctions-php/fonction-disk-free-space-et-disk-total-space-pour-ovh-2JMH9.html + * @see https://github.com/jdel/sspks/commit/a890e347f32e9e3e50a0dd82398947633872bf38 + */ + static public function getQuota(): float + { + $quota = @disk_total_space(DATA_ROOT); + return $quota === false ? (float) \PHP_INT_MAX : (float) $quota; + } + + static public function getRemainingQuota(): float + { + $quota = @disk_free_space(DATA_ROOT); + return $quota === false ? (float) \PHP_INT_MAX : (float) $quota; + } + + static public function truncate(): void + { + $db = DB::getInstance(); + $db->exec('DELETE FROM files_contents; VACUUM;'); + } + + static public function lock(): void + { + DB::getInstance()->exec('CREATE TABLE IF NOT EXISTS files_lock (lock);'); + } + + static public function unlock(): void + { + DB::getInstance()->exec('DROP TABLE IF EXISTS files_lock;'); + } + + static public function isLocked(): bool + { + return DB::getInstance()->test('sqlite_master', 'name = ? AND type = ?', 'files_lock', 'table'); + } + + static public function listFiles(?string $path = null): array + { + // Doesn't make sense + throw new \LogicException('SQLite storage cannot list local files'); + } + + static public function cleanup(): void + { + } +} diff --git a/src/include/lib/Paheko/Files/Storage/StorageInterface.php b/src/include/lib/Paheko/Files/Storage/StorageInterface.php new file mode 100644 index 0000000..7e7a0fd --- /dev/null +++ b/src/include/lib/Paheko/Files/Storage/StorageInterface.php @@ -0,0 +1,146 @@ + [ + 'select' => 't.id', + 'label' => 'N°', + ], + 'label' => [ + 'select' => 't.label', + 'label' => 'Libellé', + ], + 'date' => [ + 'label' => 'Date', + 'select' => 't.date', + 'order' => 't.date %s, t.id %1$s', + ], + 'reference' => [ + 'label' => 'Pièce comptable', + 'select' => 't.reference', + ], + 'year' => [ + 'select' => 'y.label', + 'label' => 'Exercice', + 'order' => 'y.end_date %s, t.date %1$s, t.id %1$s', + ], + 'path' => [ + 'select' => '\'transaction/\' || t.id', + ], + ]; + + static public function list() + { + Files::pruneEmptyDirectories(File::CONTEXT_TRANSACTION); + + $columns = self::LIST_COLUMNS; + + $tables = 'acc_transactions_files tf + INNER JOIN acc_transactions t ON t.id = tf.id_transaction + INNER JOIN acc_years y ON t.id_year = y.id + INNER JOIN files f ON f.id = tf.id_file AND f.trash IS NULL'; + + $list = new DynamicList($columns, $tables); + $list->orderBy('id', true); + $list->groupBy('t.id'); + $list->setCount('COUNT(DISTINCT tf.id_transaction)'); + $list->setModifier(function (&$row) { + $row->date = \DateTime::createFromFormat('!Y-m-d', $row->date); + }); + + return $list; + } +} \ No newline at end of file diff --git a/src/include/lib/Paheko/Files/Trash.php b/src/include/lib/Paheko/Files/Trash.php new file mode 100644 index 0000000..ddbc8a5 --- /dev/null +++ b/src/include/lib/Paheko/Files/Trash.php @@ -0,0 +1,68 @@ + [ + 'label' => 'Type', + 'header_icon' => 'folder', + 'order' => 'type = 2 %s, name COLLATE U_NOCASE %1$s', + ], + 'name' => [ + 'label' => 'Nom', + ], + 'parent' => [ + 'label' => 'Chemin d\'origine', + 'select' => 'SUBSTR(parent, 1 + LENGTH(\'trash/\') + 40 + 1)', + ], + 'path' => [ + ], + 'trash' => [ + 'label' => 'Supprimé le', + 'order' => 'path %s', + ], + 'size' => [ + 'label' => 'Taille', + 'select' => 'CASE WHEN type = 1 THEN size ELSE (SELECT SUM(size) FROM files f2 WHERE f2.path LIKE files.path || \'/%\') END', + ], + ]; + + static public function list(): DynamicList + { + $columns = self::LIST_COLUMNS; + + $tables = File::TABLE; + + $conditions = 'trash IS NOT NULL OR (path LIKE \'trash/%\' AND type = 1)'; + + $list = new DynamicList($columns, $tables, $conditions); + $list->orderBy('trash', true); + + return $list; + } + + static public function clean(string $expiry = '-30 days'): void + { + $past = new \DateTime($expiry); + $list = EM::getInstance(File::class)->all('SELECT * FROM @TABLE WHERE trash IS NOT NULL AND trash < ?;', $past); + + foreach ($list as $file) { + $file->delete(); + } + } + + static public function getSize(): int + { + $db = DB::getInstance(); + return $db->firstColumn('SELECT SUM(size) FROM files WHERE trash IS NOT NULL OR path LIKE \'trash/%\';') ?: 0; + } + +} diff --git a/src/include/lib/Paheko/Files/Users.php b/src/include/lib/Paheko/Files/Users.php new file mode 100644 index 0000000..122aa96 --- /dev/null +++ b/src/include/lib/Paheko/Files/Users.php @@ -0,0 +1,46 @@ + [ + 'label' => 'Numéro', + ], + 'identity' => [ + 'select' => '', + 'label' => '', + ], + 'path' => [ + 'select' => '\'user/\' || u.id', + ], + 'id' => [ + 'label' => null, + 'select' => 'u.id', + ], + ]; + + static public function list(): DynamicList + { + Files::pruneEmptyDirectories(File::CONTEXT_USER); + + $columns = self::LIST_COLUMNS; + $columns['identity']['select'] = DF::getNameFieldsSQL('u'); + $columns['identity']['label'] = DF::getNameLabel(); + $columns['number']['select'] = DF::getNumberField(); + + $tables = 'users_files uf INNER JOIN users u ON uf.id_user = u.id INNER JOIN files f ON uf.id_file = f.id AND f.trash IS NULL'; + + $list = new DynamicList($columns, $tables); + $list->orderBy('number', false); + $list->groupBy('uf.id_user'); + $list->setCount('COUNT(DISTINCT uf.id_user)'); + + return $list; + } +} \ No newline at end of file diff --git a/src/include/lib/Paheko/Files/WebDAV/NextCloud.php b/src/include/lib/Paheko/Files/WebDAV/NextCloud.php new file mode 100644 index 0000000..b966f87 --- /dev/null +++ b/src/include/lib/Paheko/Files/WebDAV/NextCloud.php @@ -0,0 +1,275 @@ +temporary_chunks_path = CACHE_ROOT . '/webdav.chunks'; + $this->setRootURL(WWW_URL); + } + + public function route(?string $uri = null): bool + { + $ua = $_SERVER['HTTP_USER_AGENT'] ?? ''; + + // Currently, iOS apps are broken + if (stristr($ua, 'nextcloud-ios') || stristr($ua, 'owncloudapp')) { + throw new WebDAV_Exception('Your client is not compatible with this server. Consider using a different WebDAV client.', 403); + } + + return parent::route($uri); + } + + public function auth(?string $login, ?string $password): bool + { + $session = Session::getInstance(); + + if ($session->isLogged()) { + return true; + } + + if (!$login || !$password) { + return false; + } + + if ($session->checkAppCredentials($login, $password)) { + return true; + } + + if ($session->login($login, $password)) { + return true; + } + + return false; + } + + public function getUserName(): ?string + { + $s = Session::getInstance(); + return $s->isLogged() ? $s->user()->name() : null; + } + + public function setUserName(string $login): bool + { + return true; + } + + public function getUserQuota(): array + { + return [ + 'free' => Files::getRemainingQuota(), + 'used' => Files::getUsedQuota(), + 'total' => Files::getQuota(), + ]; + } + + public function generateToken(): string + { + return Session::getInstance()->generateAppToken(); + } + + public function validateToken(string $token): ?array + { + return Session::getInstance()->verifyAppToken($_POST['token']); + } + + public function getLoginURL(?string $token): string + { + if ($token) { + return sprintf('%slogin.php?app=%s', ADMIN_URL, $token); + } + else { + return sprintf('%slogin.php?app=redirect', ADMIN_URL); + } + } + + public function getDirectDownloadSecret(string $uri, string $login): string + { + return hash_hmac('sha1', $uri, SECRET_KEY); + } + + protected function cleanChunks(): void + { + // 36 hours + $expire = time() - 36*3600; + + foreach (glob($this->temporary_chunks_path . '/*') as $dir) { + $first_file = current(glob($dir . '/*')); + + if (filemtime($first_file) < $expire) { + Utils::deleteRecursive($dir, true); + } + } + } + + public function storeChunk(string $login, string $name, string $part, $pointer): void + { + $this->cleanChunks(); + + $path = $this->temporary_chunks_path . '/' . $name; + @mkdir($path, 0777, true); + + $file_path = $path . '/' . $part; + $out = fopen($file_path, 'wb'); + $quota = $this->getUserQuota(); + + $used = array_sum(array_map(fn($a) => filesize($a), glob($path . '/*'))); + $used += $quota['used']; + + while (!feof($pointer)) { + $data = fread($pointer, 8192); + $used += strlen($used); + + if ($used > $quota['free']) { + $this->deleteChunks($login, $name); + throw new WebDAV_Exception('Your quota does not allow for the upload of this file', 403); + } + + fwrite($out, $data); + } + + fclose($out); + fclose($pointer); + } + + public function deleteChunks(string $login, string $name): void + { + $path = $this->temporary_chunks_path . '/' . $name; + Utils::deleteRecursive($path, true); + } + + public function listChunks(string $login, string $name): array + { + $path = $this->temporary_chunks_path . '/' . $name; + $list = glob($path . '/*'); + $list = array_map(fn($a) => str_replace($path . '/', '', $a), $list); + return $list; + } + + public function assembleChunks(string $login, string $name, string $target, ?int $mtime): array + { + $parent = Utils::dirname($target); + $parent = Files::get($parent); + + if (!$parent || $parent->type != $parent::TYPE_DIRECTORY) { + throw new WebDAV_Exception('Target parent directory does not exist', 409); + } + + $path = $this->temporary_chunks_path . '/' . $name; + $tmp_file = $path . '/__complete'; + + $target = $this->prefix . $target; + + $exists = Files::exists($target); + + try { + $out = fopen($tmp_file, 'wb'); + $processed = 0; + + foreach (glob($path . '/*') as $file) { + if ($file == $tmp_file) { + continue; + } + + $in = fopen($file, 'rb'); + + while (!feof($in)) { + $data = fread($in, 8192); + fwrite($out, $data); + $processed += strlen($data); + } + + fclose($in); + } + + fclose($out); + $file = Files::createFromPath($target, $tmp_file); + + if ($mtime) { + $file->touch($mtime); + } + } + finally { + $this->deleteChunks($login, $name); + Utils::safe_unlink($tmp_file); + } + + return ['created' => !$exists, 'etag' => $file->etag()]; + } + + public function serveThumbnail(string $uri, int $width, int $height, bool $crop = false, bool $preview = false): void + { + if (!preg_match('/\.(?:jpe?g|gif|png|webp)$/', $uri)) { + http_response_code(404); + return; + } + + $this->requireAuth(); + $uri = preg_replace(self::WEBDAV_BASE_REGEXP, '', $uri); + $file = Files::get(File::CONTEXT_DOCUMENTS . '/' . $uri); + + if (!$file) { + throw new WebDAV_Exception('Not found', 404); + } + + if (!$file->image) { + throw new WebDAV_Exception('Not an image', 404); + } + + if ($crop) { + $size = 'crop-256px'; + } + elseif ($width >= 500 || $height >= 500) { + $size = '500px'; + } + else { + $size = '150px'; + } + + $file->validateCanRead(); + + $this->server->log('Serving thumbnail for: %s - size: %s', $uri, $size); + + try { + $file->serveThumbnail($size); + } + catch (UserException $e) { + throw new WebDAV_Exception($e->getMessage(), $e->getCode(), $e); + } + } + + protected function nc_avatar(): void + { + header('X-NC-IsCustomAvatar: 1'); + + $file = Config::getInstance()->file('icon'); + + if (!$file) { + $path = ROOT . '/www/admin/static/icon.png'; + + header('Content-Type: image/png'); + header('Last-Modified: ' . gmdate(DATE_ISO8601)); + header('Content-Length: ' . filesize($path)); + readfile($path); + } + else { + $file->serveThumbnail('crop-256px'); + } + } +} diff --git a/src/include/lib/Paheko/Files/WebDAV/Server.php b/src/include/lib/Paheko/Files/WebDAV/Server.php new file mode 100644 index 0000000..413edc9 --- /dev/null +++ b/src/include/lib/Paheko/Files/WebDAV/Server.php @@ -0,0 +1,110 @@ +setStorage($storage); + $wopi->setServer($dav); + + return $wopi->route($uri); + } + + static public function route(?string $uri = null): bool + { + $uri = '/' . ltrim($uri, '/'); + + if (self::wopiRoute($uri)) { + return true; + } + + $dav = new WebDAV; + $nc = new NextCloud($dav); + $storage = new Storage(Session::getInstance(), $nc); + $dav->setStorage($storage); + + $method = $_SERVER['REQUEST_METHOD'] ?? null; + + // Always say YES to OPTIONS + if ($method == 'OPTIONS') { + $dav->http_options(); + return true; + } + + + $nc->setServer($dav); + + if ($r = $nc->route($uri)) { + // NextCloud route already replied something, stop here + return true; + } + + // If NextCloud layer didn't return anything + // it means we fall back to the default WebDAV server + // available on the root path. We need to handle a + // classic login/password auth here. + + if (0 !== strpos($uri, '/dav/')) { + return false; + } + + if (!self::auth()) { + http_response_code(401); + header('WWW-Authenticate: Basic realm="Please login"'); + return true; + } + + $dav->setBaseURI('/dav/'); + + return $dav->route($uri); + } + + static public function auth(): bool + { + $session = Session::getInstance(); + + if ($session->isLogged()) { + return true; + } + + $login = $_SERVER['PHP_AUTH_USER'] ?? null; + $password = $_SERVER['PHP_AUTH_PW'] ?? null; + + if (!isset($login, $password)) { + return false; + } + + if ($session->loginAPI($login, $password)) { + return true; + } + + if ($session->login($login, $password)) { + return true; + } + + return false; + } +} diff --git a/src/include/lib/Paheko/Files/WebDAV/Session.php b/src/include/lib/Paheko/Files/WebDAV/Session.php new file mode 100644 index 0000000..fd52341 --- /dev/null +++ b/src/include/lib/Paheko/Files/WebDAV/Session.php @@ -0,0 +1,164 @@ +_user = (new User)->import([$name => $login . ' (API)']); + + $this->user = -1; + $this->_permissions = []; + + foreach (Category::PERMISSIONS as $perm => $data) { + $this->_permissions[$perm] = $access; + } + + return true; + } + + /** + * Create a temporary app token for an external service session (eg. NextCloud) + */ + public function generateAppToken(): string + { + $token = hash('sha256', random_bytes(10)); + + $expiry = time() + 30*60; // 30 minutes + $this->storeRememberMeSelector('tok_' . $token, 'waiting', $expiry, null); + + return $token; + } + + /** + * Validate the temporary token once the user has logged-in + */ + public function validateAppToken(string $token): bool + { + if (!ctype_alnum($token) || strlen($token) > 64) { + return false; + } + + $token = $this->getRememberMeSelector('tok_' . $token); + + if (!$token || $token->hash != 'waiting') { + return false; + } + + $user = $this->getUser(); + + if (!$user) { + throw new \LogicException('Cannot create a token if the user is not logged-in'); + } + + DB::getInstance()->preparedQuery('UPDATE users_sessions + SET hash = \'ok\', id_user = ?, expiry = expiry + 30*60 + WHERE selector = ?;', + $user->id, $this->cookie_name . '_' . $token->selector); + + return true; + } + + /** + * Verify temporary app token and create a session, + * this is similar to "remember me" sessions but without cookies + */ + public function verifyAppToken(string $token): ?array + { + if (!ctype_alnum($token) || strlen($token) > 64) { + return null; + } + + $token = $this->getRememberMeSelector('tok_' . $token); + + if (!$token || $token->hash != 'ok') { + return null; + } + + // Delete temporary token + $this->deleteRememberMeSelector($token->selector); + + if ($token->expiry < time()) { + return null; + } + + $new_token = base_convert(sha1(random_bytes(10)), 16, 36); + $selector = 'app_' . substr($new_token, 0, 16); + $selector = $this->createSelectorValues($token->user_id, $token->user_password, null, $selector); + $this->storeRememberMeSelector($selector->selector, $selector->hash, $selector->expiry, $token->user_id); + + $login = $selector->selector; + $password = $selector->verifier; + + return compact('login', 'password'); + } + + + public function createAppCredentials(): \stdClass + { + if (!$this->isLogged()) { + throw new \LogicException('User is not logged'); + } + + $user = $this->getUser(); + $token = base_convert(sha1(random_bytes(10)), 16, 36); + $selector = 'app_' . substr($token, 0, 16); + $selector = $this->createSelectorValues($user->id, $user->password, null, $selector); + $this->storeRememberMeSelector($selector->selector, $selector->hash, $selector->expiry, $user->id); + + $login = $selector->selector; + $password = $selector->verifier; + $redirect = sprintf(NextCloud::AUTH_REDIRECT_URL, WWW_URL, $login, $password); + + return (object) compact('login', 'password', 'redirect'); + } + + public function checkAppCredentials(string $login, string $password): ?User + { + $selector = $this->getRememberMeSelector($login); + + if (!$selector) { + return null; + } + + if (!$this->checkRememberMeSelector($selector, $password)) { + $this->deleteRememberMeSelector($selector->selector); + return null; + } + + $this->_user = Users::get($selector->user_id); + + if (!$this->_user) { + return null; + } + + $this->user = $selector->user_id; + + return $this->_user; + } +} diff --git a/src/include/lib/Paheko/Files/WebDAV/Storage.php b/src/include/lib/Paheko/Files/WebDAV/Storage.php new file mode 100644 index 0000000..72fd15a --- /dev/null +++ b/src/include/lib/Paheko/Files/WebDAV/Storage.php @@ -0,0 +1,500 @@ +session = $session; + $this->nextcloud = $nextcloud; + } + + protected function populateRootCache(): void + { + if (isset($this->cache)) { + return; + } + + $access = Files::listReadAccessContexts($this->session); + + $this->cache = ['' => Files::get('')]; + + foreach ($access as $context => $name) { + $this->cache[$context] = Files::get($context); + $this->root[] = $context; + } + } + + protected function load(string $uri) + { + $this->populateRootCache(); + + $uri = $uri ?: null; + + if (!isset($this->cache[$uri])) { + $this->cache[$uri] = Files::get($uri); + + if (!$this->cache[$uri]) { + return null; + } + } + + return $this->cache[$uri]; + } + + /** + * @extends + */ + public function list(string $uri, ?array $properties): iterable + { + $this->populateRootCache(); + + if (!$uri) { + foreach ($this->root as $name) { + yield $name => null; + } + return; + } + + $file = $this->load($uri); + + if (!$file) { + return null; + } + + if ($file->type != $file::TYPE_DIRECTORY) { + return; + } + + foreach (Files::list($uri) as $file) { + $path = $uri . '/' . $file->name; + $this->cache[$path] = $file; + yield $file->name => null; + } + } + + /** + * @extends + */ + public function get(string $uri): ?array + { + $file = $this->load($uri); + + if (!$file) { + throw new WebDAV_Exception('File Not Found', 404); + } + + if (!$file->canRead($this->session)) { + throw new WebDAV_Exception('Vous n\'avez pas accès à ce chemin', 403); + } + + $type = $file->type; + + // Serve files + if ($type == File::TYPE_DIRECTORY) { + return null; + } + + $path = $file->getLocalFilePath(); + + if ($path && Router::isXSendFileEnabled()) { + Router::xSendFile($path); + return ['stop' => true]; + } + + $pointer = $file->getReadOnlyPointer(); + + // We trust the WebDAV server to be more efficient that File::serve + // with serving a file for WebDAV clients + if (!$pointer && $path) { + return ['path' => $path]; + } + elseif (!$pointer) { + throw new WebDAV_Exception('File Content not found', 404); + } + + return ['resource' => $pointer]; + } + + /** + * @extends + */ + public function exists(string $uri): bool + { + $this->populateRootCache(); + + if (isset($this->cache[$uri])) { + return true; + } + + return Files::exists($uri); + } + + protected function get_file_property(string $uri, string $name, int $depth) + { + $file = $this->load($uri); + $is_dir = $file->type == File::TYPE_DIRECTORY; + + if (!$file) { + throw new \LogicException('File does not exist'); + } + + switch ($name) { + case 'DAV::getcontentlength': + return $is_dir ? null : $file->size; + case 'DAV::getcontenttype': + return $is_dir ? null : $file->mime; + case 'DAV::resourcetype': + return $is_dir ? 'collection' : ''; + case 'DAV::getlastmodified': + return $file->modified ?? null; + case 'DAV::displayname': + return $file->name; + case 'DAV::ishidden': + return false; + case 'DAV::getetag': + return $file->etag(); + case 'DAV::lastaccessed': + return null; + case 'DAV::creationdate': + return $file->modified ?? null; + case WebDAV::PROP_DIGEST_MD5: + if ($file->type != File::TYPE_FILE) { + return null; + } + + return $file->md5 ?? null; + // NextCloud stuff + case NextCloud::PROP_NC_HAS_PREVIEW: + return $file->image ? 'true' : 'false'; + case NextCloud::PROP_NC_IS_ENCRYPTED: + return 'false'; + case NextCloud::PROP_OC_SHARETYPES: + return WebDAV::EMPTY_PROP_VALUE; + case NextCloud::PROP_OC_DOWNLOADURL: + return $this->nextcloud->getDirectDownloadURL($uri, $this->session::getUserId()); + case Nextcloud::PROP_NC_RICH_WORKSPACE: + return ''; + case NextCloud::PROP_OC_ID: + // fileId is required by NextCloud desktop client + if (!isset($file->id)) { + // Root directory doesn't have a ID, give something random instead + return 10000000; + } + + return $file->id(); + case NextCloud::PROP_OC_PERMISSIONS: + $permissions = [ + NextCloud::PERM_READ => $file->canRead($this->session), + NextCloud::PERM_WRITE => $file->canWrite($this->session), + NextCloud::PERM_DELETE => $file->canDelete($this->session), + NextCloud::PERM_RENAME => $file->canRename($this->session), + NextCloud::PERM_MOVE => $file->canRename($this->session), + NextCloud::PERM_CREATE_FILES_DIRS => $file->canCreateHere($this->session), + ]; + + $permissions = array_filter($permissions, fn($a) => $a); + return implode('', array_keys($permissions)); + case 'DAV::quota-available-bytes': + return Files::getRemainingQuota(); + case 'DAV::quota-used-bytes': + return Files::getUsedQuota(); + case NextCloud::PROP_OC_SIZE: + return $file->getRecursiveSize(); + case WOPI::PROP_USER_NAME: + return $this->session->getUser()->name(); + case WOPI::PROP_USER_ID: + return $this->session->getUser()->id; + case WOPI::PROP_READ_ONLY: + return $file->canWrite($this->session) ? false : true; + case WOPI::PROP_FILE_URL: + $id = gzcompress($uri); + $id = WOPI::base64_encode_url_safe($id); + return BASE_URL . 'wopi/files/' . $id; + default: + break; + } + + return null; + } + + /** + * @extends + */ + public function propfind(string $uri, ?array $properties, int $depth): ?array + { + $this->populateRootCache(); + $file = $this->load($uri); + + if (!$file) { + return null; + } + + if (null === $properties) { + $properties = array_merge(WebDAV::BASIC_PROPERTIES, ['DAV::getetag', NextCloud::PROP_OC_ID]); + } + + $out = []; + + // Generate a new token for WOPI, and provide also TTL + if (in_array(WOPI::PROP_TOKEN, $properties)) { + $out = $this->createWopiToken($uri); + unset($properties[WOPI::PROP_TOKEN], $properties[WOPI::PROP_TOKEN_TTL]); + } + + foreach ($properties as $name) { + $v = $this->get_file_property($uri, $name, $depth); + + if (null !== $v) { + $out[$name] = $v; + } + } + + return $out; + } + + public function put(string $uri, $pointer, ?string $hash_algo, ?string $hash): bool + { + if (!strpos($uri, '/')) { + throw new WebDAV_Exception('Impossible de créer un fichier ici', 403); + } + + if (preg_match(self::PUT_IGNORE_PATTERN, basename($uri))) { + return false; + } + + $target = Files::get($uri); + + if ($target && $target->type === $target::TYPE_DIRECTORY) { + throw new WebDAV_Exception('Target is a directory', 409); + } + + $new = !$target ? true : false; + + if ($new && !File::canCreate($uri, $this->session)) { + throw new WebDAV_Exception('Vous n\'avez pas l\'autorisation de créer ce fichier', 403); + } + elseif (!$new && !$target->canWrite($this->session)) { + throw new WebDAV_Exception('Vous n\'avez pas l\'autorisation de modifier ce fichier', 403); + } + + $h = $hash ? hash_init($hash_algo == 'MD5' ? 'md5' : 'sha1') : null; + + while (!feof($pointer)) { + if ($h) { + hash_update($h, fread($pointer, 8192)); + } + else { + fread($pointer, 8192); + } + } + + if ($h) { + if (hash_final($h) != $hash) { + throw new WebDAV_Exception('The data sent does not match the supplied hash', 400); + } + } + + // Check size + $size = ftell($pointer); + + try { + Files::checkQuota($size); + } + catch (ValidationException $e) { + throw new WebDAV_Exception($e->getMessage(), 403); + } + + rewind($pointer); + + if ($new) { + $target = Files::createFromPointer($uri, $pointer); + } + else { + $target->store(compact('pointer')); + } + + return $new; + } + + /** + * @extends + */ + public function delete(string $uri): void + { + if (!strpos($uri, '/')) { + throw new WebDAV_Exception('Ce répertoire ne peut être supprimé', 403); + } + + $target = Files::get($uri); + + if (!$target) { + throw new WebDAV_Exception('This file does not exist', 404); + } + + if (!$target->canDelete($this->session)) { + throw new WebDAV_Exception('Vous n\'avez pas l\'autorisation de supprimer ce fichier', 403); + } + + $target->moveToTrash(); + } + + protected function copymove(bool $move, string $uri, string $destination): bool + { + if (!strpos($uri, '/')) { + throw new WebDAV_Exception('Ce répertoire ne peut être modifié', 403); + } + + $source = Files::get($uri); + + if (!$source) { + throw new WebDAV_Exception('File not found', 404); + } + + if (!$source->canMoveTo($destination, $this->session)) { + throw new WebDAV_Exception('Vous n\'avez pas l\'autorisation de déplacer ce fichier', 403); + } + + if (!$move) { + if ($source->size > Files::getRemainingQuota()) { + throw new WebDAV_Exception('Your quota is exhausted', 403); + } + } + + $overwritten = Files::exists($destination); + + if ($overwritten) { + $this->delete($destination); + } + + $method = $move ? 'rename' : 'copy'; + + $source->$method($destination); + + return $overwritten; + } + + /** + * @extends + */ + public function copy(string $uri, string $destination): bool + { + return $this->copymove(false, $uri, $destination); + } + + /** + * @extends + */ + public function move(string $uri, string $destination): bool + { + return $this->copymove(true, $uri, $destination); + } + + /** + * @extends + */ + public function mkcol(string $uri): void + { + if (!strpos($uri, '/')) { + throw new WebDAV_Exception('Impossible de créer un répertoire ici', 403); + } + + if (!File::canCreateDir($uri)) { + throw new WebDAV_Exception('Vous n\'avez pas l\'autorisation de créer un répertoire ici', 403); + } + + if (Files::exists($uri)) { + throw new WebDAV_Exception('There is already a file with that name', 405); + } + + if (!Files::exists(Utils::dirname($uri))) { + throw new WebDAV_Exception('The parent directory does not exist', 409); + } + + Files::mkdir($uri); + } + + /** + * @extends + */ + public function touch(string $uri, \DateTimeInterface $datetime): bool + { + $file = Files::get($uri); + + if (!$file) { + return false; + } + + $file->touch($datetime); + return true; + } + + protected function createWopiToken(string $uri) + { + $ttl = time()+(3600*10); + $session_id = $this->session->id(); + $hash = WebDAV::hmac(compact('uri', 'ttl', 'session_id'), SECRET_KEY); + $data = sprintf('%s_%s_%s', $hash, $session_id, $ttl); + + return [ + WOPI::PROP_TOKEN => WOPI::base64_encode_url_safe($data), + WOPI::PROP_TOKEN_TTL => $ttl * 1000, + ]; + } + + public function getWopiURI(string $id, string $token): ?string + { + $id = WOPI::base64_decode_url_safe($id); + $uri = gzuncompress($id); + $token_decode = WOPI::base64_decode_url_safe($token); + $hash = strtok($token_decode, '_'); + $session_id = strtok('_'); + $ttl = (int) strtok(''); + $check = WebDAV::hmac(compact('uri', 'ttl', 'session_id'), SECRET_KEY); + + if (!hash_equals($hash, $check)) { + return null; + } + + if ($ttl < time()) { + return null; + } + + $this->session->setId($session_id); + $this->session->start(true); + + if (!$this->session->isLogged()) { + return null; + } + + return $uri; + } +} diff --git a/src/include/lib/Paheko/Files/WebDAV/WebDAV.php b/src/include/lib/Paheko/Files/WebDAV/WebDAV.php new file mode 100644 index 0000000..b6bf7ff --- /dev/null +++ b/src/include/lib/Paheko/Files/WebDAV/WebDAV.php @@ -0,0 +1,46 @@ + + + ', + ADMIN_URL); + $out = str_replace('', $body, $out); + } + + return $out; + } + + public function log(string $message, ...$params) + { + Router::log('DAV: ' . $message, ...$params); + } +} diff --git a/src/include/lib/Paheko/Form.php b/src/include/lib/Paheko/Form.php new file mode 100644 index 0000000..9b12366 --- /dev/null +++ b/src/include/lib/Paheko/Form.php @@ -0,0 +1,129 @@ +addError('Le fichier envoyé dépasse la taille autorisée'); + } + } + + public function run(callable $fn, ?string $csrf_key = null, ?string $redirect = null, bool $follow_redirect = false): bool + { + $js = false !== strpos($_SERVER['HTTP_ACCEPT'] ?? '', '/json'); + + try { + if (null !== $csrf_key && !\KD2\Form::tokenCheck($csrf_key)) { + throw new ValidationException('Une erreur est survenue, merci de bien vouloir renvoyer le formulaire.', 401); + } + + call_user_func($fn); + + if (null !== $redirect) { + if ($js) { + http_response_code(204); + exit; + } + elseif (array_key_exists('_dialog', $_GET)) { + Utils::reloadParentFrame($follow_redirect ? $redirect : null); + } + + Utils::redirect($redirect); + } + + return true; + } + catch (UserException $e) { + Form::reportUserException($e); + + if ($js) { + http_response_code($e->getCode() >= 400 ? $e->getCode() : 400); + header('Content-Type: application/json; charset="utf-8"', true); + echo json_encode(['message' => $e->getMessage()]); + exit; + } + + $this->addError($e); + + return false; + } + } + + static public function reportUserException(UserException $e): void + { + if (REPORT_USER_EXCEPTIONS === 2) { + throw $e; + } + elseif (REPORT_USER_EXCEPTIONS === 1) { + \KD2\ErrorManager::reportExceptionSilent($e); + } + } + + public function runIf($condition, callable $fn, ?string $csrf_key = null, ?string $redirect = null, bool $follow_redirect = false): ?bool + { + if (is_string($condition) && empty($_POST[$condition])) { + return null; + } + elseif (is_bool($condition) && !$condition) { + return null; + } + + return $this->run($fn, $csrf_key, $redirect, $follow_redirect); + } + + public function hasErrors() + { + return (count($this->errors) > 0); + } + + public function &getErrors() + { + return $this->errors; + } + + public function addError($msg) + { + $this->errors[] = $msg; + } + + public function getErrorMessages() + { + return $this->errors; + } + + public function __invoke($key) + { + return \KD2\Form::get($key); + } + + /** + * Returns a value from a custom list selector + * see CommonFunctions::input + */ + static public function getSelectorValue($value) { + if (!is_array($value)) { + return $value; + } + + $values = array_filter(array_keys($value)); + + if (count($values) == 1) { + return current($values); + } + elseif (!count($values)) { + return ''; // Empty + } + else { + return $values; + } + } +} \ No newline at end of file diff --git a/src/include/lib/Paheko/Install.php b/src/include/lib/Paheko/Install.php new file mode 100644 index 0000000..e91969b --- /dev/null +++ b/src/include/lib/Paheko/Install.php @@ -0,0 +1,494 @@ +query('PRAGMA compile_options;'); + + while ($row = $res->fetchArray(\SQLITE3_NUM)) { + if (0 !== strpos($row[0], 'ENABLE_')) { + continue; + } + + $options .= substr($row[0], strlen('ENABLE_')) . ','; + } + + (new HTTP)->POST(PING_URL, [ + 'id' => sha1(WWW_URL . SECRET_KEY . ROOT), + 'version' => paheko_version(), + 'sqlite' => \SQLite3::version()['versionString'], + 'php' => PHP_VERSION, + 'sqlite_options' => trim($options, ', '), + ]); + } + + /** + * Reset the database to empty and create a new user with the same password + */ + static public function reset(Session $session, string $password, array $options = []) + { + $config = (object) Config::getInstance()->asArray(); + $user = $session->getUser(); + + if (!$session->checkPassword($password, $user->password)) { + throw new UserException('Le mot de passe ne correspond pas.'); + } + + if (!trim($config->org_name)) { + throw new UserException('Le nom de l\'association est vide, merci de le renseigner dans la configuration.'); + } + + if (!trim($user->name())) { + throw new UserException('L\'utilisateur connecté ne dispose pas de nom, merci de le renseigner.'); + } + + if (!trim($user->email())) { + throw new UserException('L\'utilisateur connecté ne dispose pas d\'adresse e-mail, merci de la renseigner.'); + } + + $name = date('Y-m-d-His-') . 'avant-remise-a-zero'; + + Backup::create($name); + + // Keep a backup file of files + if (FILE_STORAGE_BACKEND == 'FileSystem') { + $name = 'documents_' . $name . '.zip'; + Files::zipAll(CACHE_ROOT . '/' . $name); + Files::callStorage('truncate'); + @mkdir(FILE_STORAGE_CONFIG . '/documents'); + @rename(CACHE_ROOT . '/' . $name, FILE_STORAGE_CONFIG . '/documents/' . $name); + } + + Config::deleteInstance(); + DB::getInstance()->close(); + DB::deleteInstance(); + + file_put_contents(CACHE_ROOT . '/reset', json_encode([ + 'password' => $session::hashPassword($password), + 'name' => $user->name(), + 'email' => $user->email(), + 'organization' => $config->org_name, + 'country' => $config->country, + ])); + + rename(DB_FILE, sprintf(DATA_ROOT . '/association.%s.sqlite', date('Y-m-d-His-') . 'avant-remise-a-zero')); + + self::showProgressSpinner('!install.php', 'Remise à zéro en cours…'); + exit; + } + + /** + * Continues reset after page reload + */ + static public function checkReset() + { + if (!file_exists(CACHE_ROOT . '/reset')) { + return; + } + + $data = json_decode(file_get_contents(CACHE_ROOT . '/reset')); + + if (!$data) { + throw new \LogicException('Invalid reset data'); + } + + try { + // We can't use the real password, as it might not be valid (too short or compromised) + self::install($data->country ?? 'FR', $data->organization ?? 'Association', $data->name, $data->email, md5($data->password)); + + // Restore password + DB::getInstance()->preparedQuery('UPDATE users SET password = ? WHERE id = 1;', $data->password); + $session = Session::getInstance(); + $session->logout(); + $session->forceLogin(1); + } + catch (\Exception $e) { + Config::deleteInstance(); + DB::getInstance()->close(); + DB::deleteInstance(); + Utils::safe_unlink(DB_FILE); + throw $e; + } + + @unlink(CACHE_ROOT . '/reset'); + + Utils::redirect('!config/advanced/?msg=RESET'); + } + + static protected function assert(bool $assertion, string $message) + { + if (!$assertion) { + throw new ValidationException($message); + } + } + + static public function installFromForm(array $source = null): void + { + if (null === $source) { + $source = $_POST; + } + + self::assert(isset($source['name']) && trim($source['name']) !== '', 'Le nom de l\'association n\'est pas renseigné'); + + if (is_array(LOCAL_LOGIN)) { + $source['user_name'] = LOCAL_LOGIN['user']['_name'] ?? 'Administrateur'; + $source['password'] = sha1(random_bytes(10)); + $source['user_email'] = 'administrateur@association.example'; + } + else { + self::assert(isset($source['user_name']) && trim($source['user_name']) !== '', 'Le nom du membre n\'est pas renseigné'); + self::assert(isset($source['user_email']) && trim($source['user_email']) !== '', 'L\'adresse email du membre n\'est pas renseignée'); + self::assert(isset($source['password']) && isset($source['password_confirmed']) && trim($source['password']) !== '', 'Le mot de passe n\'est pas renseigné'); + + self::assert((bool)filter_var($source['user_email'], FILTER_VALIDATE_EMAIL), 'Adresse email invalide'); + + self::assert(strlen($source['password']) >= User::MINIMUM_PASSWORD_LENGTH, 'Le mot de passe est trop court'); + self::assert($source['password'] === $source['password_confirmed'], 'La vérification du mot de passe ne correspond pas'); + } + + try { + self::install($source['country'], $source['name'], $source['user_name'], $source['user_email'], $source['password']); + self::ping(); + } + catch (\Exception $e) { + @unlink(DB_FILE); + throw $e; + } + } + + static public function install(string $country_code, string $name, string $user_name, string $user_email, string $user_password): void + { + if (file_exists(DB_FILE)) { + throw new UserException('La base de données existe déjà.'); + } + + self::checkAndCreateDirectories(); + Files::disableQuota(); + $db = DB::getInstance(); + + $db->requireFeatures('cte', 'json_patch', 'fts4', 'date_functions_in_constraints', 'index_expressions', 'rename_column', 'upsert'); + + // Création de la base de données + $db->begin(); + $db->exec('PRAGMA application_id = ' . DB::APPID . ';'); + $db->setVersion(paheko_version()); + $db->exec(file_get_contents(DB_SCHEMA)); + $db->commit(); + + file_put_contents(SHARED_CACHE_ROOT . '/version', paheko_version()); + + $currency = $country_code == 'CH' ? 'CHF' : '€'; + + // Configuration de base + $config = Config::getInstance(); + $config->setCreateFlag(); + $config->import([ + 'org_name' => $name, + 'org_email' => $user_email, + 'currency' => $currency, + 'country' => $country_code, + 'site_disabled' => true, + 'log_retention' => 365, + 'auto_logout' => 2*60, + 'analytical_set_all' => true, + 'file_versioning_policy' => 'min', + 'file_versioning_max_size' => 2, + ]); + + $fields = DynamicFields::getInstance(); + $fields->install(); + + // Create default category for common users + $cat = new Category; + $cat->setAllPermissions(Session::ACCESS_NONE); + $cat->importForm([ + 'name' => 'Membres actifs', + 'perm_connect' => Session::ACCESS_READ, + ]); + $cat->save(); + + $config->set('default_category', $cat->id()); + + // Create default category for ancient users + $cat = new Category; + $cat->importForm([ + 'name' => 'Anciens membres', + 'hidden' => 1, + ]); + $cat->setAllPermissions(Session::ACCESS_NONE); + $cat->save(); + + // Create default category for admins + $cat = new Category; + $cat->importForm([ + 'name' => 'Administrateurs', + ]); + $cat->setAllPermissions(Session::ACCESS_ADMIN); + $cat->save(); + + // Create first user + $user = new User; + $user->set('id_category', $cat->id()); + $user->importForm([ + 'numero' => 1, + 'nom' => $user_name, + 'email' => $user_email, + 'pays' => 'FR', + ]); + + $user->importSecurityForm(false, [ + 'password' => $user_password, + 'password_confirmed' => $user_password, + ]); + + $user->save(); + + $config->set('files', array_map(fn () => null, $config::FILES)); + + $welcome_text = sprintf("Bienvenue dans l'administration de %s !\n\nUtilisez le menu à gauche pour accéder aux différentes sections.\n\nSi vous êtes perdu, n'hésitez pas à consulter l'aide :-)", $name); + + $config->setFile('admin_homepage', $welcome_text); + + // Import accounting chart + $chart = Charts::installCountryDefault($country_code); + + // Create an example saved search (users) + $query = (object) [ + 'groups' => [[ + 'operator' => 'AND', + 'conditions' => [ + [ + 'column' => 'lettre_infos', + 'operator' => '= 1', + 'values' => [], + ], + ], + ]], + 'order' => 'numero', + 'desc' => true, + 'limit' => '10000', + ]; + + $search = new Search; + $search->import([ + 'label' => 'Inscrits à la lettre d\'information', + 'target' => $search::TARGET_USERS, + 'type' => $search::TYPE_JSON, + 'content' => json_encode($query), + ]); + $search->created = new \DateTime; + $search->save(); + + // Create an example saved search (accounting) + $query = (object) [ + 'groups' => [[ + 'operator' => 'AND', + 'conditions' => [ + [ + 'column' => 'p.code', + 'operator' => 'IS NULL', + 'values' => [], + ], + ], + ]], + 'order' => 't.id', + 'desc' => false, + 'limit' => '100', + ]; + + + $search = new Search; + $search->import([ + 'label' => 'Écritures sans projet', + 'target' => $search::TARGET_ACCOUNTING, + 'type' => $search::TYPE_JSON, + 'content' => json_encode($query), + ]); + $search->created = new \DateTime; + $search->save(); + + $config->save(); + + Plugins::refresh(); + Modules::refresh(); + + // Install welcome plugin if available + $has_welcome_plugin = Plugins::exists('welcome'); + + if ($has_welcome_plugin) { + Plugins::install('welcome'); + } + + if (FILE_STORAGE_BACKEND != 'SQLite') { + Storage::sync(); + } + + Files::enableQuota(); + } + + static public function checkAndCreateDirectories() + { + // Vérifier que les répertoires vides existent, sinon les créer + $paths = [ + DATA_ROOT, + CACHE_ROOT, + SHARED_CACHE_ROOT, + USER_TEMPLATES_CACHE_ROOT, + STATIC_CACHE_ROOT, + SMARTYER_CACHE_ROOT, + SHARED_USER_TEMPLATES_CACHE_ROOT, + ]; + + foreach ($paths as $path) + { + $index_file = $path . '/index.html'; + Utils::safe_mkdir($path, 0777, true); + + if (!is_dir($path)) + { + throw new \RuntimeException('Le répertoire '.$path.' n\'existe pas ou n\'est pas un répertoire.'); + } + + // On en profite pour vérifier qu'on peut y lire et écrire + if (!is_writable($path) || !is_readable($path)) + { + throw new \RuntimeException('Le répertoire '.$path.' n\'est pas accessible en lecture/écriture.'); + } + if (file_exists($index_file) AND (!is_writable($index_file) || !is_readable($index_file))) { + throw new \RuntimeException('Le fichier ' . $index_file . ' n\'est pas accessible en lecture/écriture.'); + } + + // Some basic safety against misconfigured hosts + file_put_contents($index_file, '404 Not Found

Not Found

The requested URL was not found on this server.

'); + } + + return true; + } + + static public function setLocalConfig(string $key, $value, bool $overwrite = true): void + { + $path = ROOT . DIRECTORY_SEPARATOR . CONFIG_FILE; + + if (!is_writable(ROOT)) { + throw new \RuntimeException('Impossible de créer le fichier de configuration "'. CONFIG_FILE .'". Le répertoire "'. ROOT . '" n\'est pas accessible en écriture.'); + } + + $new_line = sprintf('const %s = %s;', $key, var_export($value, true)); + + if (@filesize($path)) { + $config = file_get_contents($path); + + $pattern = sprintf('/^.*(?:const\s+%s|define\s*\(.*%1$s).*$/m', $key); + + $config = preg_replace($pattern, $new_line, $config, -1, $count); + + if ($count && !$overwrite) { + return; + } + + if (!$count) { + $config = preg_replace('/\?>.*/s', '', $config); + $config .= PHP_EOL . $new_line . PHP_EOL; + } + } + else { + $config = '', Utils::getLocalURL($next)) : ''; + + printf(' + + + + + %s + + +
+

%s

+
', $next, nl2br(htmlspecialchars($message))); + + flush(); + } +} diff --git a/src/include/lib/Paheko/Log.php b/src/include/lib/Paheko/Log.php new file mode 100644 index 0000000..a42859e --- /dev/null +++ b/src/include/lib/Paheko/Log.php @@ -0,0 +1,202 @@ + 'Connexion refusée', + self::LOGIN_SUCCESS => 'Connexion réussie', + self::LOGIN_RECOVER => 'Mot de passe perdu', + self::LOGIN_PASSWORD_CHANGE => 'Modification de mot de passe', + self::LOGIN_CHANGE => 'Modification d\'identifiant', + self::LOGIN_AS => 'Connexion par un administrateur', + + self::CREATE => 'Création', + self::DELETE => 'Suppression', + self::EDIT => 'Modification', + self::SENT => 'Envoi', + + self::MESSAGE => '', + ]; + + static public function add(int $type, ?array $details = null, int $id_user = null): void + { + if (defined('Paheko\INSTALL_PROCESS')) { + return; + } + + if ($type != self::LOGIN_FAIL) { + $keep = Config::getInstance()->log_retention; + + // Don't log anything + if ($keep == 0) { + return; + } + } + + if (isset($details['entity'])) { + $details['entity'] = str_replace('Paheko\Entities\\', '', $details['entity']); + } + + $ip = Utils::getIP(); + $session = Session::getInstance(); + $id_user ??= Session::getUserId(); + + DB::getInstance()->insert('logs', [ + 'id_user' => $id_user, + 'type' => $type, + 'details' => $details ? json_encode($details) : null, + 'ip_address' => $ip, + 'created' => new \DateTime, + ]); + } + + static public function clean(): void + { + $config = Config::getInstance(); + $db = DB::getInstance(); + + $days_delete = $config->log_retention; + + // Delete old logs according to configuration + $db->exec(sprintf('DELETE FROM logs + WHERE type != %d AND type != %d AND created < datetime(\'now\', \'-%d days\');', + self::LOGIN_FAIL, self::LOGIN_RECOVER, $days_delete)); + + // Delete failed login attempts and reminders after 30 days + $db->exec(sprintf('DELETE FROM logs WHERE type = %d OR type = %d AND created < datetime(\'now\', \'-%d days\');', + self::LOGIN_FAIL, self::LOGIN_RECOVER, 30)); + } + + /** + * Returns TRUE if the current IP address has done too many failed login attempts + * @return int 1 if banned from logging in, -1 if a captcha should be presented, 0 if no restriction is in place + */ + static public function isLocked(): int + { + $ip = Utils::getIP(); + + // is IP locked out? + $sql = sprintf('SELECT COUNT(*) FROM logs WHERE type = ? AND ip_address = ? AND created > datetime(\'now\', \'-%d seconds\');', self::LOCKOUT_DELAY); + $count = DB::getInstance()->firstColumn($sql, self::LOGIN_FAIL, $ip); + + if ($count >= self::LOCKOUT_ATTEMPTS) { + return 1; + } + + if ($count >= self::SOFT_LOCKOUT_ATTEMPTS) { + return -1; + } + + return 0; + } + + static public function list(array $params = []): DynamicList + { + $id_field = DynamicFields::getNameFieldsSQL('u'); + + $columns = [ + 'id_user' => [ + ], + 'created' => [ + 'label' => 'Date' + ], + 'identity' => [ + 'label' => isset($params['id_self']) ? null : (isset($params['history']) ? 'Membre à l\'origine de la modification' : 'Membre'), + 'select' => $id_field, + ], + 'type_icon' => [ + 'select' => null, + 'order' => null, + 'label' => '', + ], + 'type' => [ + 'label' => 'Action', + ], + 'details' => [ + 'label' => 'Détails', + ], + 'ip_address' => [ + 'label' => 'Adresse IP', + ], + ]; + + $tables = 'logs LEFT JOIN users u ON u.id = logs.id_user'; + + if (isset($params['id_user'])) { + $conditions = 'logs.id_user = ' . (int)$params['id_user']; + } + elseif (isset($params['id_self'])) { + $conditions = sprintf('logs.id_user = %d AND type < 10', (int)$params['id_self']); + } + elseif (isset($params['history'])) { + $conditions = sprintf('logs.type IN (%d, %d, %d) AND json_extract(logs.details, \'$.entity\') = \'Users\\User\' AND json_extract(logs.details, \'$.id\') = %d', self::CREATE, self::EDIT, self::DELETE, (int)$params['history']); + } + else { + $conditions = '1'; + } + + $list = new DynamicList($columns, $tables, $conditions); + $list->orderBy('created', true); + $list->setCount('COUNT(logs.id)'); + $list->setModifier(function (&$row) { + //$row->created = \DateTime::createFromFormat('!Y-m-d H:i:s', $row->created); + $row->details = $row->details ? json_decode($row->details) : null; + $row->type_label = $row->type == self::MESSAGE ? ($row->details->message ?? '') : self::ACTIONS[$row->type]; + + if (isset($row->details->entity)) { + $const = 'Paheko\Entities\\' . $row->details->entity . '::NAME'; + + if (defined($const) + && ($value = constant($const))) { + $row->entity_name = $value; + } + + $const = 'Paheko\Entities\\' . $row->details->entity . '::PRIVATE_URL'; + + if (isset($row->details->id, $row->details->entity) + && $row->type !== self::DELETE + && defined($const) + && ($value = constant($const))) { + $row->entity_url = sprintf($value, $row->details->id); + } + } + }); + + return $list; + } +} diff --git a/src/include/lib/Paheko/Plugins.php b/src/include/lib/Paheko/Plugins.php new file mode 100644 index 0000000..c55074c --- /dev/null +++ b/src/include/lib/Paheko/Plugins.php @@ -0,0 +1,364 @@ + 'text/css', + 'gif' => 'image/gif', + 'htm' => 'text/html', + 'html' => 'text/html', + 'ico' => 'image/x-ico', + 'jpe' => 'image/jpeg', + 'jpg' => 'image/jpeg', + 'jpeg' => 'image/jpeg', + 'js' => 'application/javascript', + 'pdf' => 'application/pdf', + 'png' => 'image/png', + 'xml' => 'text/xml', + 'svg' => 'image/svg+xml', + 'webp' => 'image/webp', + 'md' => 'text/x-markdown', + ]; + + /** + * Set to false to disable signal firing + * @var boolean + */ + static protected $signals = true; + + static public function toggleSignals(bool $enabled) + { + self::$signals = $enabled; + } + + static public function getPrivateURL(string $id, string $path = '') + { + return ADMIN_URL . 'p/' . $id . '/' . ltrim($path, '/'); + } + + static public function getPublicURL(string $id, string $path = '') + { + return WWW_URL . 'p/' . $id . '/' . ltrim($path, '/'); + } + + static public function getPath(string $name): ?string + { + if (file_exists(PLUGINS_ROOT . '/' . $name)) { + return PLUGINS_ROOT . '/' . $name; + } + elseif (file_exists(PLUGINS_ROOT . '/' . $name . '.tar.gz')) { + return 'phar://' . PLUGINS_ROOT . '/' . $name . '.tar.gz'; + } + + return null; + } + + static public function routeStatic(string $name, string $uri): bool + { + $path = self::getPath($name); + + if (!$path) { + throw new \RuntimeException('Invalid plugin: ' . $name); + } + + if (!preg_match('!^(?:public/|admin/)!', $uri) || false !== strpos($uri, '..')) { + return false; + } + + $path .= '/' . $uri; + + if (!file_exists($path)) { + return false; + } + + // Récupération du type MIME à partir de l'extension + $pos = strrpos($path, '.'); + $ext = substr($path, $pos+1); + + $mime = self::MIME_TYPES[$ext] ?? 'text/plain'; + + header('Content-Type: ' .$mime); + header('Cache-Control: public, max-age=3600'); + header('Last-Modified: ' . date(DATE_RFC7231, filemtime($path))); + + // Don't return Content-Length on OVH, as their HTTP 2.0 proxy is buggy + // @see https://fossil.kd2.org/paheko/tktview/8b342877cda6ef7023b16277daa0ec8e39d949f8 + if (HOSTING_PROVIDER !== 'OVH') { + header('Content-Length: ' . filesize($path)); + } + + readfile($path); + return true; + } + + static public function exists(string $name): bool + { + return self::getPath($name) !== null; + } + + /** + * Fire a plugin signal + * @param string $name Signal name + * @param bool $stoppable Set to TRUE if the signal can be stopped + * @param array $in Set to a list of INcoming parameters + * @param array $out Set to a list of possible OUTgoing parameters (callbacks can still set any other keys in this array, just they might not be used then) + * @return Signal|null Signal if a signal was run, null if no signal was registered + */ + static public function fire(string $name, bool $stoppable = false, array $in = [], array $out = []): ?Signal + { + if (!self::$signals) { + return null; + } + + $signal = null; + + // Process SYSTEM_SIGNALS first + foreach (SYSTEM_SIGNALS as $system_signal) { + if (key($system_signal) != $name) { + continue; + } + + if (!is_callable(current($system_signal))) { + throw new \LogicException(sprintf('System signal: cannot call "%s" for signal "%s"', current($system_signal), $name)); + } + + $signal ??= new Signal($name, $stoppable, $in, $out); + + call_user_func(current($system_signal), $signal, null); + + if ($signal->isStopped()) { + return $signal; + } + } + + $list = DB::getInstance()->iterate('SELECT s.* FROM plugins_signals AS s INNER JOIN plugins p ON p.name = s.plugin + WHERE s.signal = ? AND p.enabled = 1;', $name); + + static $plugins = []; + + foreach ($list as $row) { + $plugins[$row->plugin] ??= Plugins::get($row->plugin); + $plugin = $plugins[$row->plugin]; + + // Don't call plugins when the code has vanished + if (!$plugin->hasCode()) { + continue; + } + + $callback = 'Paheko\\Plugin\\' . $row->callback; + + // Ignore non-callable plugins + if (!is_callable($callback)) { + ErrorManager::reportExceptionSilent(new \LogicException(sprintf( + 'Plugin has registered signal "%s" but callback "%s" is not a callable', + $name, + $callback + ))); + continue; + } + + $signal ??= new Signal($name, $stoppable, $in, $out); + + call_user_func($callback, $signal, $plugin); + + if ($signal->isStopped()) { + return $signal; + } + } + + return $signal; + } + + static public function get(string $name): ?Plugin + { + return EM::findOne(Plugin::class, 'SELECT * FROM @TABLE WHERE name = ?;', $name); + } + + static public function listInstalled(): array + { + $list = EM::getInstance(Plugin::class)->all('SELECT * FROM @TABLE ORDER BY label COLLATE NOCASE ASC;'); + + foreach ($list as $key => $p) { + try { + $p->selfCheck(); + } + catch (ValidationException $e) { + if (self::removeBroken($p->name)) { + unset($list[$key]); + } + else { + $p->setBrokenMessage($e->getMessage()); + } + } + } + + return $list; + } + + /** + * Remove old/broken extensions if they are still installed somehow + */ + static public function removeBroken(string $name): bool + { + if ($name === 'garradin_eu') { + DB::getInstance()->exec(' + DELETE FROM plugins_signals WHERE plugin = \'garradin_eu\'; + DELETE FROM plugins WHERE name = \'garradin_eu\'; + '); + return true; + } + + return false; + } + + static public function refresh(): array + { + $db = DB::getInstance(); + $existing = $db->getAssoc(sprintf('SELECT id, name FROM %s;', Plugin::TABLE)); + $errors = []; + + foreach ($existing as $name) { + $f = self::get($name); + try { + $f->updateFromINI(); + $f->save(); + } + catch (ValidationException $e) { + $errors[] = $name . ': ' . $e->getMessage(); + } + } + + return $errors; + } + + static public function getInstallable(string $name): ?Plugin + { + if (!file_exists(PLUGINS_ROOT . '/' . $name) && !file_exists(PLUGINS_ROOT . '/' . $name . '.tar.gz')) { + return null; + } + + $p = new Plugin; + $p->name = $name; + $p->updateFromINI(); + + try { + $p->selfCheck(); + } + catch (ValidationException $e) { + $p->setBrokenMessage($e->getMessage()); + } + + return $p; + } + + /** + * Liste les plugins téléchargés mais non installés + */ + static public function listInstallable(bool $check_exists = true): array + { + $list = []; + + if ($check_exists) { + $exists = DB::getInstance()->getAssoc('SELECT name, name FROM plugins;'); + } + else { + $exists = []; + } + + foreach (glob(PLUGINS_ROOT . '/*') as $file) + { + if (substr($file, 0, 1) == '.') { + continue; + } + + if (is_dir($file) && file_exists($file . '/' . Plugin::META_FILE)) { + $file = basename($file); + $name = $file; + } + elseif (substr($file, -7) == '.tar.gz' && file_exists('phar://' . $file . '/' . Plugin::META_FILE)) { + $file = basename($file); + $name = substr($file, 0, -7); + } + else { + continue; + } + + // Ignore existing plugins + if (in_array($name, $exists)) { + continue; + } + + $list[$name] = self::getInstallable($name); + } + + ksort($list); + + return $list; + } + + static public function install(string $name): Plugin + { + $p = new Plugin; + $p->name = $name; + + if (!$p->hasFile($p::META_FILE)) { + throw new UserException(sprintf('Le plugin "%s" n\'est pas une extension Paheko : fichier plugin.ini manquant.', $name)); + } + + $p->updateFromINI(); + + $db = DB::getInstance(); + $db->begin(); + $p->set('enabled', true); + $p->save(); + + if ($p->hasFile($p::INSTALL_FILE)) { + $p->call($p::INSTALL_FILE, true); + } + + $db->commit(); + return $p; + } + + /** + * Upgrade all plugins if required + * This is run after an upgrade, a database restoration, or in the Plugins page + */ + static public function upgradeAllIfRequired(): bool + { + $i = 0; + + foreach (self::listInstalled() as $plugin) { + // Ignore plugins if code is no longer available + if (!$plugin->isAvailable()) { + continue; + } + + if ($plugin->needUpgrade()) { + $plugin->upgrade(); + $i++; + } + + unset($plugin); + } + + return $i > 0; + } +} diff --git a/src/include/lib/Paheko/Search.php b/src/include/lib/Paheko/Search.php new file mode 100644 index 0000000..d43dd79 --- /dev/null +++ b/src/include/lib/Paheko/Search.php @@ -0,0 +1,59 @@ +all($sql, ...$params); + } + + static public function listAssoc(string $target, ?int $id_user): array + { + $out = []; + + foreach (self::list($target, $id_user) as $row) { + $out[$row->id] = $row->label; + } + + return $out; + } + + static public function get(int $id): ?SE + { + return EM::findOneById(SE::class, $id); + } + + static public function quick(string $target, string $query): DynamicList + { + $s = new SE; + $s->target = $target; + return $s->quick($query); + } + + static public function fromSQL(string $sql): SE + { + $s = new SE; + $s->type = $s::TYPE_SQL; + $s->content = $sql; + $s->target = $s::TARGET_ALL; + return $s; + } +} diff --git a/src/include/lib/Paheko/Services/Fees.php b/src/include/lib/Paheko/Services/Fees.php new file mode 100644 index 0000000..2967f39 --- /dev/null +++ b/src/include/lib/Paheko/Services/Fees.php @@ -0,0 +1,151 @@ +service_id = $id; + } + + static public function get(int $id) + { + return EntityManager::findOneById(Fee::class, $id); + } + + static public function updateYear(Year $old, Year $new): bool + { + $db = DB::getInstance(); + + if ($new->id_chart == $old->id_chart) { + $db->preparedQuery('UPDATE services_fees SET id_year = ? WHERE id_year = ?;', $new->id(), $old->id()); + return true; + } + else { + $db->preparedQuery('UPDATE services_fees SET id_year = NULL, id_account = NULL WHERE id_year = ?;', $old->id()); + return false; + } + } + + /** + * If $user_id is specified, then it will return a column 'user_amount' containing the amount that this specific user should pay + */ + static public function listAllByService(?int $user_id = null) + { + $db = DB::getInstance(); + + $sql = 'SELECT *, CASE WHEN amount THEN amount ELSE NULL END AS user_amount + FROM services_fees ORDER BY id_service, amount IS NULL, label COLLATE U_NOCASE;'; + $result = $db->get($sql); + + if (!$user_id) { + return $result; + } + + foreach ($result as &$row) { + if (!$row->formula) { + continue; + } + + $row = self::addUserAmountToObject($row, $user_id); + + if (!empty($row->user_amount_error)) { + $row->user_amount = -1; + $row->label .= sprintf(' (**FORMULE DE CALCUL INVALIDE: %s**)', $row->user_amount_error); + $row->description .= "\n\n**MERCI DE CORRIGER LA FORMULE**"; + } + } + + return $result; + } + + static public function addUserAmountToObject(\stdClass $object, int $user_id): \stdClass + { + if (!empty($object->amount)) { + $object->user_amount = $object->amount; + } + + if (empty($object->formula)) { + return $object; + } + + try { + $sql = sprintf('SELECT (%s) FROM users WHERE id = %d;', $object->formula, $user_id); + $object->user_amount = DB::getInstance()->firstColumn($sql); + } + catch (DB_Exception $e) { + $object->user_amount_error = $e->getMessage(); + } + + return $object; + } + + static public function listGroupedById(): array + { + return EntityManager::getInstance(Fee::class)->allAssoc('SELECT * FROM services_fees ORDER BY label COLLATE U_NOCASE;', 'id'); + } + + public function listWithStats(): DynamicList + { + $db = DB::getInstance(); + $hidden_cats = array_keys(Categories::listAssoc(Categories::HIDDEN_ONLY)); + + $sql = sprintf('DROP TABLE IF EXISTS fees_list_stats; + CREATE TEMP TABLE IF NOT EXISTS fees_list_stats (id_fee, id_user, ok, expired, paid); + INSERT INTO fees_list_stats SELECT + id_fee, id_user, + CASE WHEN (su.expiry_date IS NULL OR su.expiry_date >= date()) AND su.paid = 1 THEN 1 ELSE 0 END, + CASE WHEN su.expiry_date < date() THEN 1 ELSE 0 END, + paid + FROM services_users su + INNER JOIN (SELECT id, MAX(date) FROM services_users GROUP BY id_user, id_fee) su2 ON su2.id = su.id + INNER JOIN users u ON u.id = su.id_user WHERE u.%s', + $db->where('id_category', 'NOT IN', $hidden_cats)); + + $db->exec($sql); + + $columns = [ + 'id' => [], + 'formula' => [], + 'label' => [ + 'label' => 'Tarif', + ], + 'amount' => [ + 'label' => 'Montant', + 'select' => 'CASE WHEN formula IS NOT NULL THEN -1 WHEN amount IS NOT NULL THEN amount ELSE 0 END', + ], + 'nb_users_ok' => [ + 'label' => 'Membres à jour', + 'order' => null, + 'select' => '(SELECT COUNT(DISTINCT id_user) FROM fees_list_stats WHERE id_fee = fees.id AND ok = 1)', + ], + 'nb_users_expired' => [ + 'label' => 'Membres expirés', + 'order' => null, + 'select' => '(SELECT COUNT(DISTINCT id_user) FROM fees_list_stats WHERE id_fee = fees.id AND expired = 1)', + ], + 'nb_users_unpaid' => [ + 'label' => 'Membres en attente de règlement', + 'order' => null, + 'select' => '(SELECT COUNT(DISTINCT id_user) FROM fees_list_stats WHERE id_fee = fees.id AND paid = 0)', + ], + ]; + + $list = new DynamicList($columns, 'services_fees AS fees', 'id_service = ' . $this->service_id); + $list->setPageSize(null); + $list->orderBy('label', false); + return $list; + } +} \ No newline at end of file diff --git a/src/include/lib/Paheko/Services/Reminders.php b/src/include/lib/Paheko/Services/Reminders.php new file mode 100644 index 0000000..cd40a8b --- /dev/null +++ b/src/include/lib/Paheko/Services/Reminders.php @@ -0,0 +1,155 @@ +get('SELECT s.label AS service_label, sr.* FROM services_reminders sr INNER JOIN services s ON s.id = sr.id_service + ORDER BY s.label COLLATE U_NOCASE;'); + } + + static public function get(int $id) + { + return EntityManager::findOneById(Reminder::class, $id); + } + + static public function listSentForUser(int $user_id) + { + $columns = [ + 'label' => [ + 'label' => 'Activité', + 'select' => 's.label', + ], + 'delay' => [ + 'label' => 'Délai du rappel', + 'select' => 'r.delay', + ], + 'date' => [ + 'label' => 'Date d\'envoi du message', + 'select' => 'srs.sent_date', + ], + ]; + + $tables = 'services_reminders_sent srs + LEFT JOIN services_reminders r ON r.id = srs.id_reminder + INNER JOIN services s ON s.id = srs.id_service'; + $conditions = sprintf('srs.id_user = %d', $user_id); + + $list = new DynamicList($columns, $tables, $conditions); + $list->orderBy('date', true); + return $list; + } + + static public function listSentForReminder(int $reminder_id) + { + return DB::getInstance()->get('SELECT srs.sent_date, r.delay, s.label, rs.id AS sent_id, s.id AS service_id + FROM services_reminders_sent srs + INNER JOIN services_reminders r ON r.id = srs.id_reminder + INNER JOIN services s ON s.id = srs.id_service + WHERE rs.id_reminder = ?;', $reminder_id); + } + + static public function listForService(int $service_id) + { + return DB::getInstance()->get('SELECT * FROM services_reminders WHERE id_service = ? ORDER BY delay, subject;', $service_id); + } + + static public function getPendingSQL(bool $due_only = true, string $conditions = '1') + { + $db = DB::getInstance(); + + $sql = 'SELECT + u.*, %s AS identity, + u.id AS id_user, + date(su.expiry_date, sr.delay || \' days\') AS reminder_date, + ABS(julianday(date()) - julianday(su.expiry_date)) AS nb_days, + MAX(sr.delay) AS delay, sr.subject, sr.body, s.label, s.description, + su.expiry_date, sr.id AS id_reminder, su.id_service, su.id_user, + sf.label AS fee_label, sf.amount, sf.formula + FROM services_reminders sr + INNER JOIN services s ON s.id = sr.id_service AND s.archived = 0 + -- Select latest subscription to a service (MAX) only + INNER JOIN (SELECT MAX(su2.expiry_date) AS expiry_date, su2.id_user, su2.id_service, su2.id_fee FROM services_users AS su2 GROUP BY id_user, id_service) AS su ON s.id = su.id_service + -- Select fee + LEFT JOIN services_fees sf ON sf.id = su.id_fee + -- Join with users, but not ones part of a hidden category + INNER JOIN users u ON su.id_user = u.id + AND (%s) + AND (u.id_category NOT IN (SELECT id FROM users_categories WHERE hidden = 1)) + -- Join with sent reminders to exclude users that already have received this reminder + LEFT JOIN (SELECT id, MAX(due_date) AS due_date, id_user, id_reminder FROM services_reminders_sent GROUP BY id_user, id_reminder) AS srs ON su.id_user = srs.id_user AND srs.id_reminder = sr.id + WHERE + (sr.not_before_date IS NULL OR sr.not_before_date <= date(su.expiry_date, sr.delay || \' days\')) + AND (srs.id IS NULL OR srs.due_date < date(su.expiry_date, (sr.delay - 1) || \' days\')) + AND %s + AND %s + GROUP BY su.id_user, sr.id_service + ORDER BY su.id_user'; + + $emails = DynamicFields::getEmailFields(); + $emails = array_map(fn($e) => sprintf('u.%s IS NOT NULL', $db->quoteIdentifier($e)), $emails); + $emails = implode(' OR ', $emails); + + $sql = sprintf($sql, + DynamicFields::getNameFieldsSQL('u'), + $emails, + $due_only ? 'date() > date(su.expiry_date, sr.delay || \' days\')' : '1', + $conditions + ); + + return $sql; + } + + static public function createMessage(stdClass $reminder): ReminderMessage + { + $m = new ReminderMessage; + $m->import([ + 'id_service' => $reminder->id_service, + 'id_user' => $reminder->id_user, + 'id_reminder' => $reminder->id_reminder, + 'due_date' => $reminder->reminder_date, + ]); + + return $m; + } + + /** + * Envoi des rappels automatiques par e-mail + * @return boolean TRUE en cas de succès + */ + static public function sendPending(): void + { + $db = DB::getInstance(); + $sql = self::getPendingSQL(true); + + $date = new \DateTime; + + $db->begin(); + $body = null; + + foreach ($db->iterate($sql) as $row) { + $m = self::createMessage($row); + + // Create body user template only once + $body ??= $m->getBody($row); + + $m->set('sent_date', $date); + $m->send($row, $body); + } + + $db->commit(); + } +} diff --git a/src/include/lib/Paheko/Services/Services.php b/src/include/lib/Paheko/Services/Services.php new file mode 100644 index 0000000..6acea13 --- /dev/null +++ b/src/include/lib/Paheko/Services/Services.php @@ -0,0 +1,139 @@ +getAssoc('SELECT id, label FROM services ORDER BY label COLLATE U_NOCASE;'); + } + + static public function listAssocWithFees() + { + $out = []; + + foreach (self::listGroupedWithFees(null, 2) as $service) { + $out[$service->label] = [ + 's' . $service->id => '— Tous les tarifs —', + ]; + + foreach ($service->fees as $fee) { + $out[$service->label]['f' . $fee->id] = $fee->label; + } + } + + return $out; + } + + static public function count() + { + return DB::getInstance()->count(Service::TABLE, 1); + } + + static public function listGroupedWithFees(?int $user_id = null) + { + $sql = 'SELECT + id, label, duration, start_date, end_date, description, + CASE WHEN end_date IS NOT NULL THEN end_date WHEN duration IS NOT NULL THEN date(\'now\', \'+\'||duration||\' days\') ELSE NULL END AS expiry_date + FROM services + WHERE archived = 0 ORDER BY label COLLATE U_NOCASE;'; + + $services = DB::getInstance()->getGrouped($sql); + $fees = Fees::listAllByService($user_id); + $out = []; + + foreach ($services as $service) { + $out[$service->id] = $service; + $out[$service->id]->fees = []; + } + + foreach ($fees as $fee) { + if (isset($out[$fee->id_service])) { + $out[$fee->id_service]->fees[] = $fee; + } + } + + return $out; + } + + static public function listArchivedWithStats(): DynamicList + { + $list = self::listWithStats(); + $list->setConditions('archived = 1'); + return $list; + } + + static public function listWithStats(): DynamicList + { + $db = DB::getInstance(); + $hidden_cats = array_keys(Categories::listAssoc(Categories::HIDDEN_ONLY)); + + $sql = sprintf('DROP TABLE IF EXISTS services_list_stats; + CREATE TEMP TABLE IF NOT EXISTS services_list_stats (id_service, id_user, ok, expired, paid); + INSERT INTO services_list_stats SELECT + id_service, id_user, + CASE WHEN (su.expiry_date IS NULL OR su.expiry_date >= date()) AND su.paid = 1 THEN 1 ELSE 0 END, + CASE WHEN su.expiry_date < date() THEN 1 ELSE 0 END, + paid + FROM services_users su + INNER JOIN (SELECT id, MAX(date) FROM services_users GROUP BY id_user, id_service) su2 ON su2.id = su.id + INNER JOIN users u ON u.id = su.id_user WHERE u.%s', + $db->where('id_category', 'NOT IN', $hidden_cats)); + + $db->exec($sql); + + + $columns = [ + 'id' => [], + 'duration' => [], + 'start_date' => [], + 'end_date' => [], + 'label' => [ + 'label' => 'Activité', + ], + 'date' => [ + 'label' => 'Période', + 'order' => 'start_date %s, duration %1$s', + 'select' => 'CASE WHEN start_date IS NULL THEN duration ELSE NULL END', + ], + 'nb_users_ok' => [ + 'label' => 'Membres à jour', + 'order' => null, + 'select' => '(SELECT COUNT(DISTINCT id_user) FROM services_list_stats WHERE id_service = services.id AND ok = 1)', + ], + 'nb_users_expired' => [ + 'label' => 'Membres expirés', + 'order' => null, + 'select' => '(SELECT COUNT(DISTINCT id_user) FROM services_list_stats WHERE id_service = services.id AND expired = 1)', + ], + 'nb_users_unpaid' => [ + 'label' => 'Membres en attente de règlement', + 'order' => null, + 'select' => '(SELECT COUNT(DISTINCT id_user) FROM services_list_stats WHERE id_service = services.id AND paid = 0)', + ], + ]; + + $list = new DynamicList($columns, 'services', 'archived = 0'); + $list->setPageSize(null); + $list->orderBy('label', false); + return $list; + } + + static public function hasArchivedServices(): bool + { + return DB::getInstance()->test(Service::TABLE, 'archived = 1'); + } +} \ No newline at end of file diff --git a/src/include/lib/Paheko/Services/Services_User.php b/src/include/lib/Paheko/Services/Services_User.php new file mode 100644 index 0000000..f07ff68 --- /dev/null +++ b/src/include/lib/Paheko/Services/Services_User.php @@ -0,0 +1,232 @@ +count(Service_User::TABLE, 'id_user = ?', $user_id); + } + + static public function listDistinctForUser(int $user_id) + { + return DB::getInstance()->get('SELECT + s.label, MAX(su.date) AS last_date, su.expiry_date AS expiry_date, sf.label AS fee_label, su.paid, s.end_date, + CASE WHEN su.expiry_date < date() THEN -1 WHEN su.expiry_date >= date() THEN 1 ELSE 0 END AS status, + CASE WHEN s.end_date < date() THEN 1 ELSE 0 END AS archived + FROM services_users su + INNER JOIN services s ON s.id = su.id_service + LEFT JOIN services_fees sf ON sf.id = su.id_fee + WHERE su.id_user = ? + AND s.archived = 0 + GROUP BY su.id_service ORDER BY expiry_date DESC;', $user_id); + } + + static public function perUserList(int $user_id, ?int $only_id = null, ?\DateTime $after = null): DynamicList + { + $columns = [ + 'archived' => [ + 'select' => 's.archived', + ], + 'id' => [ + 'select' => 'su.id', + ], + 'id_account' => [ + 'select' => 'sf.id_account', + ], + 'id_year' => [ + 'select' => 'sf.id_year', + ], + 'account_code' => [ + 'select' => 'a.code', + ], + 'has_transactions' => [ + 'select' => 'tu.id_user', + ], + 'label' => [ + 'select' => 's.label', + 'label' => 'Activité', + ], + 'fee' => [ + 'label' => 'Tarif', + 'select' => 'sf.label', + ], + 'date' => [ + 'label' => 'Date d\'inscription', + 'select' => 'su.date', + ], + 'expiry' => [ + 'label' => 'Date d\'expiration', + 'select' => 'MAX(su.expiry_date)', + ], + 'paid' => [ + 'label' => 'Payé', + 'select' => 'su.paid', + ], + 'amount' => [ + 'label' => 'Reste à régler', + 'select' => 'CASE WHEN su.paid = 1 AND COUNT(tl.debit) = 0 THEN NULL + ELSE MAX(0, expected_amount - IFNULL(SUM(tl.debit), 0)) END', + ], + 'expected_amount' => [], + ]; + + $tables = 'services_users su + INNER JOIN services s ON s.id = su.id_service + LEFT JOIN services_fees sf ON sf.id = su.id_fee + LEFT JOIN acc_accounts a ON sf.id_account = a.id + LEFT JOIN acc_transactions_users tu ON tu.id_service_user = su.id + LEFT JOIN acc_transactions_lines tl ON tl.id_transaction = tu.id_transaction'; + $conditions = sprintf('su.id_user = %d', $user_id); + + if ($only_id) { + $conditions .= sprintf(' AND su.id = %d', $only_id); + } + + if ($after) { + $conditions .= sprintf(' AND su.date >= %s', DB::getInstance()->quote($after->format('Y-m-d'))); + } + + $list = new DynamicList($columns, $tables, $conditions); + + $list->setExportCallback(function (&$row) { + $row->amount = $row->amount ? Utils::money_format($row->amount, '.', '', false) : null; + }); + + $list->orderBy('date', true); + $list->groupBy('su.id'); + $list->setCount('COUNT(DISTINCT su.id)'); + return $list; + } + + static protected function iterateImport(CSV_Custom $csv, array &$errors = null): \Generator + { + $number_field = DynamicFields::getNumberField(); + $services = Services::listAssoc(); + $fees = Fees::listGroupedById(); + + foreach ($csv->iterate() as $i => $row) { + try { + if (empty($row->$number_field)) { + throw new UserException('Aucun numéro de membre n\'a été indiqué'); + } + + $id_user = Users::getIdFromNumber($row->$number_field); + + if (!$id_user) { + throw new UserException(sprintf('Le numéro de membre "%s" n\'existe pas', $row->$number_field)); + } + + $id_service = array_search($row->service, $services); + + if (!$id_service) { + throw new UserException(sprintf('L\'activité "%s" n\'existe pas', $row->service)); + } + + if (empty($row->date)) { + throw new UserException('La date est vide'); + } + + $id_fee = null; + + if (!empty($row->fee)) { + foreach ($fees as $fee) { + if (strcasecmp($fee->label, $row->fee) === 0 && $fee->id_service === $id_service) { + $id_fee = $fee->id; + break; + } + } + + if (!$id_fee) { + throw new UserException(sprintf('Le tarif "%s" n\'existe pas pour cette activité', $row->fee)); + } + } + + $su = new Service_User; + $su->set('id_user', $id_user); + $su->set('id_service', $id_service); + $su->set('id_fee', $id_fee); + unset($row->fee, $row->service, $row->$number_field); + + if (empty($row->paid) || strtolower(trim($row->paid)) === 'non') { + $row->paid = false; + } + else { + $row->paid = true; + } + + $su->import((array)$row); + + yield $i => $su; + } + catch (UserException $e) { + if (null !== $errors) { + $errors[] = sprintf('Ligne %d : %s', $i, $e->getMessage()); + continue; + } + + throw $e; + } + } + } + + static public function import(CSV_Custom $csv): void + { + $db = DB::getInstance(); + $db->begin(); + + foreach (self::iterateImport($csv) as $i => $su) { + try { + $su->save(); + } + catch (UserException $e) { + throw new UserException(sprintf('Ligne %d : %s', $i, $e->getMessage()), 0, $e); + } + } + + $db->commit(); + } + + static public function listImportColumns(): array + { + $number_field = DynamicFields::getNumberField(); + + return [ + $number_field => 'Numéro de membre', + 'service' => 'Activité', + 'fee' => 'Tarif', + 'paid' => 'Payé ?', + 'expected_amount' => 'Montant à régler', + 'date' => 'Date d\'inscription', + 'expiry_date' => 'Date d\'expiration', + ]; + } + + static public function listMandatoryImportColumns(): array + { + $number_field = DynamicFields::getNumberField(); + + return [ + $number_field, + 'service', + 'date', + ]; + } +} \ No newline at end of file diff --git a/src/include/lib/Paheko/Static_Cache.php b/src/include/lib/Paheko/Static_Cache.php new file mode 100644 index 0000000..dba9797 --- /dev/null +++ b/src/include/lib/Paheko/Static_Cache.php @@ -0,0 +1,88 @@ + (time() - (int)$expire)) ? false : true; + } + + static public function get(string $id): string + { + $path = self::getPath($id); + return file_get_contents($path); + } + + static public function display(string $id): void + { + $path = self::getPath($id); + readfile($path); + } + + static public function exists(string $id): bool + { + return file_exists(self::getPath($id)); + } + + static public function remove(string $id): bool + { + $path = self::getPath($id); + return Utils::safe_unlink($path); + } +} diff --git a/src/include/lib/Paheko/Template.php b/src/include/lib/Paheko/Template.php new file mode 100644 index 0000000..ab2aa1a --- /dev/null +++ b/src/include/lib/Paheko/Template.php @@ -0,0 +1,451 @@ +assign('table_export', false); + $this->assign('pdf_export', false); + + if ($session->isLogged()) { + if (isset($_GET['_pdf'])) { + $this->assign('pdf_export', true); + return $this->PDF($template); + } + elseif (isset($_GET['_export']) && $_GET['_export'] === 'test') { + $this->assign('table_export', true); + } + elseif (isset($_GET['_export'])) { + $this->assign('table_export', true); + $html = $this->fetch($template); + + if (!stripos($html, '([^<]+)!', $html, $match)) { + $title = html_entity_decode(trim($match[1])); + } + + return CSV::exportHTML($_GET['_export'], $html, $title); + } + } + + return parent::display($template); + } + + public function PDF(?string $template = null, ?string $title = null) + { + $out = $this->fetch($template); + + if (!$title && preg_match('!(.*)!U', $out, $match)) { + $title = trim($match[1]); + } + + header('Content-type: application/pdf'); + header(sprintf('Content-Disposition: attachment; filename="%s.pdf"', Utils::safeFileName($title ?: 'Page'))); + Utils::streamPDF($out); + return $this; + } + + private function __clone() + { + } + + public function __construct($template = null, Template &$parent = null) + { + parent::__construct($template, $parent); + + if (null === $parent) { + if (self::$_instance !== null) { + throw new \LogicException('Instance already exists'); + } + } + // For included templates just return a new instance, + // the singleton is only to get the 'master' Template object + else { + return $this; + } + + Translate::extendSmartyer($this); + + $cache_dir = SMARTYER_CACHE_ROOT; + + if (!file_exists($cache_dir)) { + Utils::safe_mkdir($cache_dir, 0777, true); + } + + $this->setTemplatesDir(ROOT . '/templates'); + $this->setCompiledDir($cache_dir); + $this->setNamespace('Paheko'); + + $this->assign('version_hash', Utils::getVersionHash()); + + $this->assign('www_url', WWW_URL); + $this->assign('admin_url', ADMIN_URL); + $this->assign('admin_uri', ADMIN_URI); + $this->assign('base_url', BASE_URL); + $this->assign('help_pattern_url', HELP_PATTERN_URL); + $this->assign('help_url', sprintf(HELP_URL, str_replace('/admin/', '', Utils::getSelfURI(false)))); + $this->assign('self_url', Utils::getSelfURI()); + $this->assign('self_url_no_qs', Utils::getSelfURI(false)); + + $session = null; + + if (!defined('Paheko\INSTALL_PROCESS')) { + $session = Session::getInstance(); + $this->assign('config', Config::getInstance()); + } + else { + $this->assign('config', null); + } + + $is_logged = $session ? $session->isLogged() : null; + + $this->assign('session', $session); + $this->assign('is_logged', $is_logged); + $this->assign('logged_user', $is_logged ? $session->getUser() : null); + + $this->assign('dialog', isset($_GET['_dialog']) ? ($_GET['_dialog'] ?: true) : false); + + $this->register_compile_function('continue', function (Smartyer $s, $pos, $block, $name, $raw_args) { + if ($block == 'continue') + { + return 'continue;'; + } + }); + + $this->register_compile_function('use', function (Smartyer $s, $pos, $block, $name, $raw_args) { + if ($name == 'use') + { + return sprintf('use %s;', $raw_args); + } + }); + + $this->register_function('form_errors', [$this, 'formErrors']); + + $this->register_function('size_meter', [$this, 'sizeMeter']); + $this->register_function('copy_button', [$this, 'copyButton']); + $this->register_function('custom_colors', [$this, 'customColors']); + $this->register_function('plugin_url', ['Paheko\Utils', 'plugin_url']); + $this->register_function('diff', [$this, 'diff']); + $this->register_function('display_permissions', [$this, 'displayPermissions']); + + $this->register_function('csrf_field', function ($params) { + return Form::tokenHTML($params['key']); + }); + + $this->register_function('enable_upload_here', function ($params) { + $csrf_key = 'upload_file_' . md5($params['path']); + $url = Utils::getLocalURL('!common/files/upload.php?p=' . rawurlencode($params['path'])); + return sprintf(' data-upload-url="%s" data-upload-token-value="%s" data-upload-token-name="%s" ', + htmlspecialchars($url), + Form::tokenGenerate($csrf_key), + Form::tokenFieldName($csrf_key), + ); + }); + + $this->register_block('linkmenu', [CommonFunctions::class, 'linkmenu']); + + $this->register_modifier('strlen', fn($a) => strlen($a ?? '')); + $this->register_modifier('format_weight', [Utils::class, 'format_weight']); + $this->register_modifier('dump', ['KD2\ErrorManager', 'dump']); + $this->register_modifier('get_country_name', ['Paheko\Utils', 'getCountryName']); + $this->register_modifier('abs', function($a) { return abs($a ?? 0); }); + $this->register_modifier('percent_of', function($a, $b) { return !$b ? $b : round($a / $b * 100); }); + + $this->register_modifier('linkify_transactions', function ($str) { + $str = preg_replace_callback('/(?<=^|\s)(https?:\/\/.*?)(?=\s|$)/', function ($m) { + return sprintf('%1$s', htmlspecialchars($m[1])); + }, $str); + + return preg_replace_callback('/(?<=^|\s)#(\d+)(?=\s|$)/', function ($m) { + return sprintf('#%2$d', + Utils::getLocalURL('!acc/transactions/details.php?id='), + $m[1] + ); + }, $str); + }); + + $this->register_modifier('restore_snippet_markup', function ($str) { + return preg_replace('!<(/?mark)>!', '<$1>', $str); + }); + + foreach (CommonModifiers::PHP_MODIFIERS_LIST as $name => $params) { + $this->register_modifier($name, [CommonModifiers::class, $name]); + } + + foreach (CommonModifiers::MODIFIERS_LIST as $key => $name) { + $this->register_modifier(is_int($key) ? $name : $key, is_int($key) ? [CommonModifiers::class, $name] : $name); + } + + foreach (CommonFunctions::FUNCTIONS_LIST as $key => $name) { + $this->register_function(is_int($key) ? $name : $key, is_int($key) ? [CommonFunctions::class, $name] : $name); + } + + $this->register_modifier('local_url', [Utils::class, 'getLocalURL']); + + // Overwrite default money modifiers + $this->register_modifier('money', [CommonModifiers::class, 'money_html']); + $this->register_modifier('money_currency', [CommonModifiers::class, 'money_currency_html']); + } + + + protected function formErrors($params) + { + $form = $this->getTemplateVars('form'); + + if (!$form || !$form->hasErrors()) + { + return ''; + } + + $errors = $form->getErrorMessages(!empty($params['membre']) ? true : false); + + foreach ($errors as &$error) { + if ($error instanceof UserException) { + if ($html = $error->getHTMLMessage()) { + $message = $html; + } + else { + $message = nl2br($this->escape($error->getMessage())); + } + + if ($error->hasDetails()) { + $message = '

' . $message . '

' . $error->getDetailsHTML(); + } + + $error = $message; + } + else { + $error = nl2br($this->escape($error)); + } + + /* Not used currently + $error = preg_replace_callback('/\[([^\]]*)\]\(([^\)]+)\)/', + fn ($m) => sprintf('%s', Utils::getLocalURL($m[2]), $m[1] ?? $m[2]) + ); + */ + } + + return '
  • ' . implode('
  • ', $errors) . '
'; + } + + protected function customColors() + { + $config = defined('Paheko\INSTALL_PROCESS') ? null : Config::getInstance(); + + $c1 = ADMIN_COLOR1; + $c2 = ADMIN_COLOR2; + $bg = ADMIN_BACKGROUND_IMAGE; + + if (!FORCE_CUSTOM_COLORS && $config) { + $c1 = $config->get('color1') ?: $c1; + $c2 = $config->get('color2') ?: $c2; + + if ($url = $config->fileURL('admin_background')) { + $bg = $url; + } + } + + $out = ' + '; + + if ($config && $url = $config->fileURL('admin_css')) { + $out .= "\n" . sprintf('', $url); + } + + return sprintf($out, CommonModifiers::css_hex_to_rgb($c1), CommonModifiers::css_hex_to_rgb($c2), $bg); + } + + protected function diff(array $params) + { + if (isset($params['old'], $params['new'])) { + $diff = \KD2\SimpleDiff::diff_to_array(false, $params['old'], $params['new'], $params['context'] ?? 3); + } + else { + throw new \BadFunctionCallException('Paramètres old et new requis.'); + } + + $out = ''; + + if (isset($params['old_label'], $params['new_label'])) { + $out .= sprintf( + '', + htmlspecialchars($params['old_label']), + htmlspecialchars($params['new_label']) + ); + } + + $out .= ''; + + $prev = key($diff); + + foreach ($diff as $i=>$line) + { + if ($i > $prev + 1) + { + $out .= ''; + } + + list($type, $old, $new) = $line; + + $class1 = $class2 = ''; + $t1 = $t2 = ''; + + if ($type == \KD2\SimpleDiff::INS) + { + $class2 = 'ins'; + $t2 = ''; + $old = htmlspecialchars($old, ENT_QUOTES, 'UTF-8'); + $new = htmlspecialchars($new, ENT_QUOTES, 'UTF-8'); + } + elseif ($type == \KD2\SimpleDiff::DEL) + { + $class1 = 'del'; + $t1 = ''; + $old = htmlspecialchars($old, ENT_QUOTES, 'UTF-8'); + $new = htmlspecialchars($new, ENT_QUOTES, 'UTF-8'); + } + elseif ($type == \KD2\SimpleDiff::CHANGED) + { + $class1 = 'del'; + $class2 = 'ins'; + $t1 = ''; + $t2 = ''; + + $lineDiff = \KD2\SimpleDiff::wdiff($old, $new); + $lineDiff = htmlspecialchars($lineDiff, ENT_QUOTES, 'UTF-8'); + + // Don't show new things in deleted line + $old = preg_replace('!\{\+(?:.*)\+\}!U', '', $lineDiff); + $old = str_replace(' ', ' ', $old); + $old = str_replace('-] [-', ' ', $old); + $old = preg_replace('!\[-(.*)-\]!U', '\\1', $old); + + // Don't show old things in added line + $new = preg_replace('!\[-(?:.*)-\]!U', '', $lineDiff); + $new = str_replace(' ', ' ', $new); + $new = str_replace('+} {+', ' ', $new); + $new = preg_replace('!\{\+(.*)\+\}!U', '\\1', $new); + } + else + { + $old = htmlspecialchars($old, ENT_QUOTES, 'UTF-8'); + $new = htmlspecialchars($new, ENT_QUOTES, 'UTF-8'); + } + + $out .= ''; + $out .= ''; + $out .= ''; + $out .= ''; + $out .= ''; + $out .= ''; + $out .= ''; + + $prev = $i; + } + + $out .= '
%s%s

'.($i+1).''.$t1.''.$old.''.$t2.''.$new.'
'; + return $out; + } + + protected function displayPermissions(array $params): string + { + $out = []; + + if (isset($params['section'], $params['level'])) { + if (is_string($params['level'])) { + $params['level'] = Session::ACCESS_LEVELS[$params['level']]; + } + $list = [$params['section'] => Category::PERMISSIONS[$params['section']]]; + $perms = (object) ['perm_' . $params['section'] => $params['level']]; + } + else { + $perms = $params['permissions']; + $list = Category::PERMISSIONS; + } + + foreach ($list as $name => $config) { + $access = $perms->{'perm_' . $name}; + $label = sprintf('%s : %s', $config['label'], $config['options'][$access]); + $out[$name] = sprintf('%s', $access, $name, htmlspecialchars($label), $config['shape']); + } + + return implode(' ', $out); + } + + protected function copyButton(array $params): string + { + return sprintf('%s', htmlspecialchars($params['label'])); + } + + // We cannot use here as Firefox sucks :( + protected function sizeMeter(array $params): string + { + $out = sprintf('<%s class="quota %s">', $params['tag'] ?? 'span', $params['class'] ?? ''); + + $attributes = ''; + + if (!empty($params['href'])) { + $params['meter_tag'] = 'a'; + $attributes .= sprintf(' href="%s"', htmlspecialchars(Utils::getLocalURL($params['href']))); + } + else { + $params['meter_tag'] = 'span'; + } + + if (!empty($params['title'])) { + $attributes .= sprintf(' title="%s"', htmlspecialchars($params['title'])); + } + + $more = ''; + + if (isset($params['more'])) { + $more = '' . $params['more'] . ''; + } + + $text = sprintf($params['text'] ?? '%s', Utils::format_bytes($params['value']), Utils::format_bytes($params['total'])); + + $out .= sprintf('<%s class="meter" style="--quota-percent: %s" %s>%s%s', + $params['meter_tag'], + round(100 * $params['value'] / ($params['total'] ?: 1)), + $attributes, + $text, + $more + ); + + $out .= sprintf('', $params['tag'] ?? 'span'); + return $out; + } +} diff --git a/src/include/lib/Paheko/Upgrade.php b/src/include/lib/Paheko/Upgrade.php new file mode 100644 index 0000000..1b15f79 --- /dev/null +++ b/src/include/lib/Paheko/Upgrade.php @@ -0,0 +1,363 @@ +version(); + + if (version_compare($v, paheko_version(), '>=')) + { + return false; + } + + Install::checkAndCreateDirectories(); + + if (!$v || version_compare($v, self::MIN_REQUIRED_VERSION, '<')) + { + throw new UserException(sprintf("Votre version de Paheko est trop ancienne pour être mise à jour. Mettez à jour vers Paheko %s avant de faire la mise à jour vers cette version.", self::MIN_REQUIRED_VERSION)); + } + + if (Static_Cache::exists('upgrade')) + { + $path = Static_Cache::getPath('upgrade'); + throw new UserException('Une mise à jour est déjà en cours.' + . PHP_EOL . 'Si celle-ci a échouée et que vous voulez ré-essayer, supprimez le fichier suivant:' + . PHP_EOL . $path); + } + + return true; + } + + static public function upgrade() + { + $db = DB::getInstance(); + $v = $db->version(); + + // Rename namespace in config file, before starting any upgrade + if (version_compare($v, '1.3.0', '<')) { + $config_path = ROOT . '/' . CONFIG_FILE; + + if (file_exists($config_path) && is_writable($config_path)) { + $contents = file_get_contents($config_path); + + $new = strtr($contents, [ + 'namespace Garradin' => 'namespace Paheko', + ' Garradin\\' => ' Paheko\\', + '\'Garradin\\' => '\'Paheko\\', + '"Garradin\\' => '"Paheko\\', + '\\Garradin\\' => '\\Paheko\\', + ]); + + if ($new !== $contents) { + file_put_contents($config_path, $new); + Install::showProgressSpinner('!upgrade.php?a=' . time(), 'Suite de la mise à jour…'); + exit; + } + } + } + + Plugins::toggleSignals(false); + + Static_Cache::store('upgrade', 'Updating'); + + // Créer une sauvegarde automatique + $backup_file = sprintf(DATA_ROOT . '/association.pre_upgrade-%s.sqlite', paheko_version()); + Backup::make($backup_file); + + // Extend execution time, just in case + if (false === strpos(@ini_get('disable_functions'), 'set_time_limit')) { + @set_time_limit(600); + } + + @ini_set('max_execution_time', 600); + + try { + if (version_compare($v, '1.1.21', '<')) { + $db->beginSchemaUpdate(); + // Add id_analytical column to services_fees + $db->import(ROOT . '/include/data/1.1.21_migration.sql'); + $db->commitSchemaUpdate(); + } + + if (version_compare($v, '1.1.22', '<')) { + $db->beginSchemaUpdate(); + // Create acc_accounts_balances view + $db->import(ROOT . '/include/data/1.1.0_schema.sql'); + $db->commitSchemaUpdate(); + } + + if (version_compare($v, '1.1.23', '<')) { + $db->begin(); + // Create acc_accounts_projects_balances view + $db->import(ROOT . '/include/data/1.1.0_schema.sql'); + $db->commit(); + } + + if (version_compare($v, '1.1.24', '<')) { + $db->begin(); + + // Delete acc_accounts_projects_ebalances view + $db->exec('DROP VIEW IF EXISTS acc_accounts_projects_balances;'); + + $db->commit(); + } + + if (version_compare($v, '1.1.25', '<')) { + $db->begin(); + + // Just add email tables + $db->import(ROOT . '/include/data/1.1.0_schema.sql'); + + // Rename signals + $db->import(ROOT . '/include/data/1.1.25_migration.sql'); + + $db->commit(); + } + + if (version_compare($v, '1.1.27', '<')) { + // Just add api_credentials tables + $db->import(ROOT . '/include/data/1.1.0_schema.sql'); + } + + if (version_compare($v, '1.1.28', '<')) { + $db->createFunction('html_decode', 'htmlspecialchars_decode'); + $db->exec('UPDATE files_search SET content = html_decode(content) WHERE content IS NOT NULL;'); + } + + if (version_compare($v, '1.1.29', '<')) { + $db->import(ROOT . '/include/data/1.1.29_migration.sql'); + } + + if (version_compare($v, '1.1.30', '<')) { + require ROOT . '/include/migrations/1.1/30.php'; + } + + if (version_compare($v, '1.1.31', '<')) { + $db->import(ROOT . '/include/migrations/1.1/31.sql'); + } + + if (version_compare($v, '1.2.0', '<')) { + $db->beginSchemaUpdate(); + $db->import(ROOT . '/include/migrations/1.2/1.2.0.sql'); + Charts::updateInstalled('fr_pca_2018'); + Charts::updateInstalled('fr_pca_1999'); + Charts::updateInstalled('fr_pcc_2020'); + Charts::updateInstalled('fr_pcg_2014'); + Charts::updateInstalled('be_pcmn_2019'); + $db->commitSchemaUpdate(); + } + + if (version_compare($v, '1.2.1', '<')) { + $db->beginSchemaUpdate(); + $db->import(ROOT . '/include/migrations/1.2/1.2.1.sql'); + Charts::resetRules(['FR', 'CH', 'BE']); + $db->commitSchemaUpdate(); + } + + if (version_compare($v, '1.2.2', '<')) { + require ROOT . '/include/migrations/1.2/1.2.2.php'; + } + + if (version_compare($v, '1.3.0-rc1', '<')) { + require ROOT . '/include/migrations/1.3/1.3.0.php'; + } + + if (version_compare($v, '1.3.0-alpha1', '>=') && version_compare($v, '1.3.0-rc2', '<')) { + require ROOT . '/include/migrations/1.3/1.3.0-rc2.php'; + } + + if (version_compare($v, '1.3.0-alpha1', '>=') && version_compare($v, '1.3.0-rc5', '<')) { + require ROOT . '/include/migrations/1.3/1.3.0-rc5.php'; + } + + if (version_compare($v, '1.3.0-rc7', '<')) { + require ROOT . '/include/migrations/1.3/1.3.0-rc7.php'; + } + + if (version_compare($v, '1.3.0-rc12', '<')) { + $db->import(ROOT . '/include/migrations/1.3/1.3.0-rc12.sql'); + } + + if (version_compare($v, '1.3.0-alpha1', '>=') && version_compare($v, '1.3.0-rc13', '<')) { + $db->beginSchemaUpdate(); + $db->import(ROOT . '/include/migrations/1.3/1.3.0-rc13.sql'); + $db->commitSchemaUpdate(); + } + + if (version_compare($v, '1.3.0-alpha1', '>=') && version_compare($v, '1.3.0-rc14', '<')) { + require ROOT . '/include/migrations/1.3/1.3.0-rc14.php'; + } + + if (version_compare($v, '1.3.0-rc15', '<')) { + $db->import(ROOT . '/include/migrations/1.3/1.3.0-rc15.sql'); + } + + if (version_compare($v, '1.3.2', '<')) { + $db->import(ROOT . '/include/migrations/1.3/1.3.2.sql'); + } + + if (version_compare($v, '1.3.3', '<')) { + $db->beginSchemaUpdate(); + $db->import(ROOT . '/include/migrations/1.3/1.3.3.sql'); + $db->commitSchemaUpdate(); + } + + if (version_compare($v, '1.3.5', '<')) { + $db->beginSchemaUpdate(); + $db->import(ROOT . '/include/migrations/1.3/1.3.5.sql'); + $db->commitSchemaUpdate(); + } + + if (version_compare($v, '1.3.6', '<')) { + require ROOT . '/include/migrations/1.3/1.3.6.php'; + } + + Plugins::upgradeAllIfRequired(); + + // Vérification de la cohérence des clés étrangères + $db->foreignKeyCheck(); + + // Delete local cached files + Utils::resetCache(USER_TEMPLATES_CACHE_ROOT); + Utils::resetCache(STATIC_CACHE_ROOT); + + $cache_version_file = SHARED_CACHE_ROOT . '/version'; + $cache_version = file_exists($cache_version_file) ? trim(file_get_contents($cache_version_file)) : null; + + // Only delete system cache when it's required + if (paheko_version() !== $cache_version) { + Utils::resetCache(SMARTYER_CACHE_ROOT); + } + + file_put_contents($cache_version_file, paheko_version()); + $db->setVersion(paheko_version()); + + // reset last version check + $db->exec('UPDATE config SET value = NULL WHERE key = \'last_version_check\';'); + + Static_Cache::remove('upgrade'); + } + catch (\Throwable $e) + { + if ($db->inTransaction()) { + $db->rollback(); + } + + $db->close(); + rename($backup_file, DB_FILE); + + Static_Cache::remove('upgrade'); + + if ($e instanceof UserException) { + $e = new \RuntimeException($e->getMessage(), 0, $e); + } + + throw $e; + } + + Install::ping(); + + $session = Session::getInstance(); + $session->logout(); + } + + static public function getLatestVersion(): ?\stdClass + { + if (!ENABLE_TECH_DETAILS && !ENABLE_UPGRADES) { + return null; + } + + $config = Config::getInstance(); + $last = $config->get('last_version_check'); + + if ($last) { + $last = json_decode($last); + } + + // Only check once every two weeks + if ($last && $last->time > (time() - 3600 * 24 * 5)) { + return $last; + } + + return null; + } + + static public function fetchLatestVersion(): ?\stdClass + { + if (!ENABLE_TECH_DETAILS && !ENABLE_UPGRADES) { + return null; + } + + $config = Config::getInstance(); + $last = $config->get('last_version_check'); + + if ($last) { + $last = json_decode($last); + } + + // Only check once every two weeks + if ($last && $last->time > (time() - 3600 * 24 * 7)) { + return $last; + } + + $current_version = paheko_version(); + $last = (object) ['time' => time(), 'version' => null]; + $config->set('last_version_check', json_encode($last)); + $config->save(); + + $last->version = self::getInstaller()->latest(); + + if (version_compare($last->version, $current_version, '<=')) { + $last->version = null; + } + + $config->set('last_version_check', json_encode($last)); + $config->save(); + + return $last; + } + + static public function getInstaller(): FossilInstaller + { + if (!isset(self::$installer)) { + $i = new FossilInstaller(WEBSITE, ROOT, CACHE_ROOT, '!^paheko-(.*)\.tar\.gz$!'); + $i->setPublicKeyFile(ROOT . '/pubkey.asc'); + + if (0 === ($pos = strpos(CACHE_ROOT, ROOT))) { + $i->addIgnoredPath(substr(CACHE_ROOT, strlen(ROOT) + 1)); + } + + if (0 === ($pos = strpos(DATA_ROOT, ROOT))) { + $i->addIgnoredPath(substr(DATA_ROOT, strlen(ROOT) + 1)); + } + + if (0 === ($pos = strpos(SHARED_CACHE_ROOT, ROOT))) { + $i->addIgnoredPath(substr(SHARED_CACHE_ROOT, strlen(ROOT) + 1)); + } + + $i->addIgnoredPath('config.local.php'); + self::$installer = $i; + } + + return self::$installer; + } +} \ No newline at end of file diff --git a/src/include/lib/Paheko/UserException.php b/src/include/lib/Paheko/UserException.php new file mode 100644 index 0000000..87f55ef --- /dev/null +++ b/src/include/lib/Paheko/UserException.php @@ -0,0 +1,68 @@ +message = $message; + } + + public function getHTMLMessage(): ?string { + return $this->html_message; + } + + public function setHTMLMessage(string $html): void { + $this->html_message = $html; + } + + public function setDetails($details) { + $this->details = $details; + } + + public function getDetails() { + return $this->details; + } + + public function hasDetails(): bool { + return $this->details !== null; + } + + public function getDetailsHTML() { + if (func_num_args() == 1) { + $details = func_get_arg(0); + } + else { + $details = $this->details; + } + + if (null === $details) { + return '(nul)'; + } + + if ($details instanceof \DateTimeInterface) { + return $details->format('d/m/Y'); + } + + if (!is_array($details)) { + return nl2br(htmlspecialchars($details)); + } + + $out = ''; + + foreach ($details as $key => $value) { + $out .= sprintf('', htmlspecialchars($key), $this->getDetailsHTML($value)); + } + + $out .= '
%s%s
'; + + return $out; + } +} diff --git a/src/include/lib/Paheko/UserTemplate/CommonFunctions.php b/src/include/lib/Paheko/UserTemplate/CommonFunctions.php new file mode 100644 index 0000000..67a8863 --- /dev/null +++ b/src/include/lib/Paheko/UserTemplate/CommonFunctions.php @@ -0,0 +1,897 @@ +$name) && !is_null($source->$name)) { + $current_value = $source->$name; + } + elseif (isset($source) && is_array($source) && isset($source[$name])) { + $current_value = $source[$name]; + } + elseif (isset($default) && ($type != 'checkbox' || empty($_POST))) { + $current_value = $default; + } + + if ($type == 'date' || $type === 'time') { + if ((is_string($current_value) && !preg_match('!^\d+:\d+$!', $current_value)) || is_int($current_value)) { + try { + $current_value = Entity::filterUserDateValue((string)$current_value); + } + catch (ValidationException $e) { + $current_value = null; + } + } + + if (is_object($current_value) && $current_value instanceof \DateTimeInterface) { + if ($type == 'date') { + $current_value = $current_value->format('d/m/Y'); + } + else { + $current_value = $current_value->format('H:i'); + } + } + } + elseif ($type == 'time' && is_object($current_value) && $current_value instanceof \DateTimeInterface) { + $current_value = $current_value->format('H:i'); + } + elseif ($type == 'password') { + $current_value = null; + } + elseif ($type == 'time' && is_string($current_value)) { + if ($v = \DateTime::createFromFormat('!Y-m-d H:i:s', $current_value)) { + $current_value = $v->format('H:i'); + } + elseif ($v = \DateTime::createFromFormat('!Y-m-d H:i', $current_value)) { + $current_value = $v->format('H:i'); + } + } + + $attributes['id'] = 'f_' . preg_replace('![^a-z0-9_-]!i', '', $name); + $attributes['name'] = $name; + + if (!isset($attributes['autocomplete']) && ($type == 'money' || $type == 'password')) { + $attributes['autocomplete'] = 'off'; + } + + if ($type == 'radio' || $type == 'checkbox' || $type == 'radio-btn') { + $attributes['id'] .= '_' . (strlen($value) > 30 ? md5($value) : preg_replace('![^a-z0-9_-]!i', '', $value)); + + if ($current_value == $value && $current_value !== null) { + $attributes['checked'] = 'checked'; + } + + $attributes['value'] = $value; + } + elseif ($type == 'date') { + $type = 'text'; + $attributes['placeholder'] = 'JJ/MM/AAAA'; + $attributes['data-input'] = 'date'; + $attributes['size'] = 12; + $attributes['maxlength'] = 10; + $attributes['pattern'] = '\d\d?/\d\d?/\d{4}'; + } + elseif ($type == 'time') { + $type = 'text'; + $attributes['placeholder'] = 'HH:MM'; + $attributes['data-input'] = 'time'; + $attributes['size'] = 8; + $attributes['maxlength'] = 5; + $attributes['pattern'] = '\d\d?:\d\d?'; + } + elseif ($type == 'year') { + $type = 'number'; + $attributes['size'] = 4; + $attributes['maxlength'] = 4; + $attributes['pattern'] = '\d'; + } + elseif ($type == 'weight') { + $type = 'number'; + $attributes['placeholder'] = '1,312'; + $attributes['size'] = 10; + $attributes['step'] = '0.001'; + $suffix = ' kg'; + + if (null !== $current_value && !$current_value_from_user) { + $current_value = str_replace(',', '.', Utils::format_weight($current_value)); + } + } + elseif ($type == 'money') { + $attributes['class'] = rtrim('money ' . ($attributes['class'] ?? '')); + } + + // Create attributes string + if (!empty($attributes['required'])) { + $attributes['required'] = 'required'; + } + else { + unset($attributes['required']); + } + + if (!empty($attributes['autofocus'])) { + $attributes['autofocus'] = 'autofocus'; + } + else { + unset($attributes['autofocus']); + } + + if (!empty($attributes['disabled'])) { + $attributes['disabled'] = 'disabled'; + unset($attributes['required']); + } + else { + unset($attributes['disabled']); + } + + if (!empty($attributes['readonly'])) { + $attributes['readonly'] = 'readonly'; + } + else { + unset($attributes['readonly']); + } + + if (!empty($attributes['required']) || !empty($params['prefix_required'])) { + $required_label = ' (obligatoire)'; + } + else { + $required_label = ' (facultatif)'; + } + + $attributes_string = $attributes; + + array_walk($attributes_string, function (&$v, $k) { + $v = sprintf('%s="%s"', $k, htmlspecialchars((string)$v)); + }); + + $attributes_string = implode(' ', $attributes_string); + + if (isset($label)) { + $label = htmlspecialchars((string)$label); + $label = preg_replace_callback('!\[icon=([\w-]+)\]!', fn ($match) => self::icon(['shape' => $match[1]]), $label); + } + + if ($type === 'radio-btn') { + if (!empty($attributes['disabled'])) { + $attributes['class'] = ($attributes['class'] ?? '') . ' disabled'; + } + + $radio = self::input(array_merge($params, ['type' => 'radio', 'label' => null, 'help' => null, 'disabled' => $attributes['disabled'] ?? null])); + + $input = sprintf('
%s + +
', + $attributes['class'] ?? '', + $radio, + $attributes['id'], + $label, + isset($params['help']) ? '

' . nl2br(htmlspecialchars($params['help'])) . '

' : '' + ); + + unset($help, $label); + } + elseif ($type === 'select') { + $input = sprintf(''; + } + elseif ($type === 'select_groups') { + $input = sprintf(''; + } + elseif ($type === 'textarea') { + $input = sprintf('', $attributes_string, htmlspecialchars((string)$current_value)); + } + elseif ($type === 'list') { + $multiple = !empty($attributes['multiple']); + $can_delete = $multiple || !empty($attributes['can_delete']); + $values = ''; + $delete_btn = self::button(['shape' => 'delete']); + + if (null !== $current_value && (is_array($current_value) || is_object($current_value))) { + foreach ($current_value as $v => $l) { + if (empty($l) || trim($l) === '') { + continue; + } + + $values .= sprintf(' %3$s %s', htmlspecialchars((string)$name), htmlspecialchars((string)$v), htmlspecialchars((string)$l), $can_delete ? $delete_btn : ''); + } + } + + $button = self::button([ + 'shape' => $multiple ? 'plus' : 'menu', + 'label' => $multiple ? 'Ajouter' : 'Sélectionner', + 'required' => $attributes['required'] ?? null, + 'value' => Utils::getLocalURL($attributes['target']), + 'data-multiple' => $multiple ? '1' : '0', + 'data-can-delete' => (int) $can_delete, + 'data-name' => $name, + 'data-max' => $attributes['max'] ?? 0, + ]); + + $input = sprintf('%s%s', htmlspecialchars($attributes['id']), $button, $values); + } + elseif ($type === 'money') { + if (null !== $current_value && !$current_value_from_user) { + $current_value = Utils::money_format($current_value, ',', ''); + } + + if ((string) $current_value === '0') { + $current_value = ''; + } + + $currency = Config::getInstance()->currency; + $input = sprintf('%s', $attributes_string, htmlspecialchars((string) $current_value), $currency); + } + else { + $value = isset($attributes['value']) ? '' : sprintf(' value="%s"', htmlspecialchars((string)$current_value)); + $input = sprintf('', $type, $attributes_string, $value); + } + + if ($type === 'file') { + $input .= sprintf('', Utils::return_bytes(Utils::getMaxUploadSize())); + } + elseif ($type === 'checkbox') { + $input = sprintf('', preg_replace('/(?=\[|$)/', '_present', $name, 1)) . $input; + } + elseif (!empty($copy)) { + $input .= sprintf('', $params['name']); + } + + $input .= $suffix; + + // No label? then we only want the input without the widget + if (empty($label)) { + if (!array_key_exists('label', $params) && ($type == 'radio' || $type == 'checkbox')) { + $input .= sprintf('', $attributes['id']); + } + + return $input; + } + + $out = ''; + + if (!empty($params['prefix_title'])) { + $out .= sprintf('
%s
', + $attributes['id'], + htmlspecialchars($params['prefix_title']), + $required_label + ); + } + + if (!empty($params['prefix_help'])) { + $out .= sprintf('
%s
', + htmlspecialchars($params['prefix_help']) + ); + } + + $label = sprintf('', $attributes['id'], $label); + + if ($type == 'radio' || $type == 'checkbox') { + $out .= sprintf('
%s %s', $input, $label); + + if (isset($help)) { + $out .= sprintf(' (%s)', htmlspecialchars($help)); + } + + $out .= '
'; + } + else { + $out .= sprintf('
%s%s
%s
', $label, $required_label, $input); + + if ($type == 'file' && empty($params['no_size_limit'])) { + $out .= sprintf('
Taille maximale : %s
', Utils::format_bytes(Utils::getMaxUploadSize())); + } + + if (isset($help)) { + $out .= sprintf('
%s
', nl2br(htmlspecialchars($help))); + } + } + + return $out; + } + + static public function icon(array $params): string + { + if (isset($params['shape']) && isset($params['html']) && $params['html'] == false) { + return Utils::iconUnicode($params['shape']); + } + + if (!isset($params['shape']) && !isset($params['url'])) { + throw new \RuntimeException('Missing parameter: shape or url'); + } + + $html = ''; + + if (isset($params['url'])) { + $html = self::getIconHTML(['icon' => $params['url']]); + unset($params['url']); + } + + $html .= htmlspecialchars($params['label'] ?? ''); + unset($params['label']); + + self::setIconAttribute($params); + + $attributes = array_diff_key($params, ['shape']); + $attributes = array_map(fn($v, $k) => sprintf('%s="%s"', $k, htmlspecialchars($v)), + $attributes, array_keys($attributes)); + + $attributes = implode(' ', $attributes); + + return sprintf('%s', $attributes, $html); + } + + static public function link(array $params): string + { + $href = $params['href']; + $label = $params['label']; + $prefix = $params['prefix'] ?? ''; + + // href can be prefixed with '!' to make the URL relative to ADMIN_URL + if (substr($href, 0, 1) == '!') { + $href = ADMIN_URL . substr($params['href'], 1); + } + + // propagate _dialog param if we are in an iframe + if (isset($_GET['_dialog']) && !isset($params['target'])) { + $href .= (strpos($href, '?') === false ? '?' : '&') . '_dialog'; + } + + if (!isset($params['class'])) { + $params['class'] = ''; + } + + unset($params['href'], $params['label'], $params['prefix']); + + array_walk($params, function (&$v, $k) { + $v = sprintf('%s="%s"', $k, htmlspecialchars((string)$v)); + }); + + $params = implode(' ', $params); + + $label = $label ? sprintf('%s', htmlspecialchars($label)) : ''; + + return sprintf('%s%s', htmlspecialchars($href), $params, $prefix, $label); + } + + static public function button(array $params): string + { + $label = isset($params['label']) ? htmlspecialchars((string)$params['label']) : ''; + unset($params['label']); + + self::setIconAttribute($params); + + if (!isset($params['type'])) { + $params['type'] = 'button'; + } + + if (!isset($params['class'])) { + $params['class'] = ''; + } + + if (isset($params['name']) && !isset($params['value'])) { + $params['value'] = 1; + } + + $prefix = ''; + $suffix = ''; + + if (isset($params['icon'])) { + $prefix = self::getIconHTML($params); + unset($params['icon'], $params['icon_html']); + } + + if (isset($params['csrf_key'])) { + $suffix .= Form::tokenHTML($params['csrf_key']); + unset($params['csrf_key']); + } + + $params['class'] .= ' icn-btn'; + + // Remove NULL params + $params = array_filter($params); + + array_walk($params, function (&$v, $k) { + $v = sprintf('%s="%s"', $k, htmlspecialchars((string)$v)); + }); + + $params = implode(' ', $params); + + return sprintf('%s', $params, $prefix, $label, $suffix); + } + + static public function linkbutton(array $params): string + { + self::setIconAttribute($params); + + if (isset($params['icon']) || isset($params['icon_html'])) { + $params['prefix'] = self::getIconHTML($params); + unset($params['icon'], $params['icon_html']); + } + + if (!isset($params['class'])) { + $params['class'] = ''; + } + + $params['class'] .= ' icn-btn'; + + return self::link($params); + } + + static protected function getIconHTML(array $params): string + { + if (isset($params['icon_html'])) { + return '' . $params['icon_html'] . ''; + } + + return sprintf(' ', + htmlspecialchars(Utils::getLocalURL($params['icon'])) + ); + } + + static protected function setIconAttribute(array &$params): void + { + if (isset($params['shape'])) { + $params['data-icon'] = Utils::iconUnicode($params['shape']); + } + + unset($params['shape']); + } + + static public function exportmenu(array $params): string + { + $url = $params['href'] ?? Utils::getSelfURI(); + $suffix = $params['suffix'] ?? 'export='; + + $url = str_replace([$suffix . 'csv', $suffix . 'ods', $suffix . 'xlsx'], '', $url); + $url = rtrim($url, '?&'); + + if (false !== strpos($url, '?')) { + $url .= '&'; + } + else { + $url .= '?'; + } + + $url .= $suffix; + + $xlsx = $params['xlsx'] ?? null; + + if (null === $xlsx) { + $xlsx = !empty(CALC_CONVERT_COMMAND); + } + + if (!empty($params['form'])) { + $name = $params['name'] ?? 'export'; + $out = self::button(['value' => 'csv', 'shape' => 'export', 'label' => 'Export CSV', 'name' => $name, 'type' => 'submit']); + $out .= self::button(['value' => 'ods', 'shape' => 'export', 'label' => 'Export LibreOffice', 'name' => $name, 'type' => 'submit']); + + if ($xlsx) { + $out .= self::button(['value' => 'xlsx', 'shape' => 'export', 'label' => 'Export Excel', 'name' => $name, 'type' => 'submit']); + } + } + else { + $out = self::linkButton(['href' => $url . 'csv', 'label' => 'Export CSV', 'shape' => 'export']); + $out .= ' ' . self::linkButton(['href' => $url . 'ods', 'label' => 'Export LibreOffice', 'shape' => 'export']); + + if ($xlsx !== false) { + $out .= ' ' . self::linkButton(['href' => $url . 'xlsx', 'label' => 'Export Excel', 'shape' => 'export']); + } + } + + $params = array_merge($params, ['shape' => 'export', 'label' => $params['label'] ?? 'Export…']); + return self::linkmenu($params, $out); + } + + static public function linkmenu(array $params, ?string $content): string + { + if (null === $content) { + return ''; + } + + if (!empty($params['right'])) { + $params['class'] = 'menu-btn-right'; + } + + $out = sprintf(' + + %s + ', + htmlspecialchars($params['class'] ?? ''), + Utils::iconUnicode($params['shape']), + htmlspecialchars($params['label']) + ); + + $out .= $content . ' + '; + + return $out; + } + + static public function delete_form(array $params): string + { + if (!isset($params['legend'], $params['warning'], $params['csrf_key'])) { + throw new \InvalidArgumentException('Missing parameter: legend, warning and csrf_key are required'); + } + + $tpl = Template::getInstance(); + $tpl->assign($params); + return $tpl->fetch('common/delete_form.tpl'); + } + + static public function edit_user_field(array $params): string + { + if (isset($params['field'])) { + $field = $params['field']; + } + else { + $name = $params['name'] ?? $params['key'] ?? null; + + if (null === $name) { + throw new \RuntimeException('Missing "name" parameter'); + } + + $field = DynamicFields::get($name); + } + + if (!($field instanceof DynamicField)) { + throw new \LogicException('This field does not exist.'); + } + + $context = $params['context'] ?? 'module'; + + if (!in_array($context, ['user_edit', 'admin_new', 'admin_edit', 'module'])) { + throw new \InvalidArgumentException('Invalid "context" parameter value: ' . $context); + } + + $source = $params['user'] ?? $params['source'] ?? null; + + $name = $field->name; + $type = $field->type; + + // The password must be changed using a specific field + if ($field->system & $field::PASSWORD) { + return ''; + } + // Files are managed out of the form + elseif ($type == 'file') { + return ''; + } + // VIRTUAL columns cannot be edited + elseif ($type == 'virtual') { + return ''; + } + elseif ($context === 'user_edit' && $field->user_access_level === Session::ACCESS_NONE) { + return ''; + } + elseif ($context === 'user_edit' && $field->user_access_level === Session::ACCESS_READ) { + $v = self::user_field(['name' => $name, 'value' => $params['user']->$name]); + return sprintf('
%s
%s
', $field->label, $v ?: 'Non renseigné'); + } + + $params = [ + 'type' => $type, + 'name' => $name, + 'label' => $field->label, + 'source' => $source, + 'disabled' => !empty($disabled), + 'required' => $field->required, + 'help' => $field->help, + // Fix for autocomplete, lpignore is for Lastpass + 'autocomplete' => 'off', + 'data-lpignore' => 'true', + ]; + + // Multiple choice checkboxes is a specific thingy + if ($type == 'multiple') { + $options = $field->options; + + if (isset($_POST[$name]) && is_array($_POST[$name])) { + $value = 0; + + foreach ($_POST[$name] as $k => $v) { + if (array_key_exists($k, $options) && !empty($v)) { + $value |= 0x01 << $k; + } + } + } + else { + $value = $params['source']->$name ?? null; + } + + // Forcer la valeur à être un entier (depuis PHP 7.1) + $value = (int)$value; + $has_title = false; + $out = ''; + + foreach ($options as $k => $v) + { + $b = 0x01 << (int)$k; + + $p = [ + 'type' => 'checkbox', + 'label' => $v, + 'value' => $v, + 'default' => ($value & $b) ? $v : null, + 'name' => sprintf('%s[%d]', $name, $k), + ]; + + if (!$has_title) { + $has_title = true; + $p['prefix_title'] = $field->label; + $p['prefix_help'] = $field->help; + $p['prefix_required'] = $field->required; + } + + $out .= CommonFunctions::input($p); + } + + return $out; + } + elseif ($type == 'select') { + $params['options'] = array_combine($field->options, $field->options); + $params['default_empty'] = '—'; + } + elseif ($type == 'country') { + $params['type'] = 'select'; + $params['options'] = Utils::getCountryList(); + $params['default'] = Config::getInstance()->get('country'); + } + elseif ($type == 'checkbox') { + $params['value'] = 1; + $params['label'] = 'Oui'; + $params['prefix_title'] = $field->label; + } + elseif ($field->system & $field::NUMBER && $context === 'admin_new') { + $params['default'] = DB::getInstance()->firstColumn(sprintf('SELECT MAX(%s) + 1 FROM %s;', $name, User::TABLE)); + $params['required'] = false; + } + elseif ($type === 'number') { + $params['step'] = '1'; + $params['pattern'] = '\\d+'; + } + elseif ($type === 'decimal') { + $params['type'] = 'number'; + $params['step'] = 'any'; + } + elseif ($type === 'datalist') { + $options = ''; + + foreach ($field->options as $value) { + $options .= sprintf('', htmlspecialchars($value)); + } + + $params['type'] = 'text'; + $params['list'] = 'list-' . $params['name']; + $params['suffix'] = sprintf('%s', $params['list'], $options); + } + + if ($field->default_value === 'NOW()') { + $params['default'] = new \DateTime; + } + elseif (!empty($field->default_value)) { + $params['default'] = $field->default_value; + } + + $out = CommonFunctions::input($params); + + if (($context === 'admin_new' || $context === 'admin_edit') && $field->system & $field::LOGIN) { + $out .= '
(Sera utilisé comme identifiant de connexion si le membre a le droit de se connecter.)
'; + } + + if ($context === 'admin_new' && $field->system & $field::NUMBER) { + $out .= '
Doit être unique, laisser vide pour que le numéro soit attribué automatiquement.
'; + } + elseif ($context === 'admin_edit' && $field->system & $field::NUMBER) { + $out .= '
Doit être unique pour chaque membre.
'; + } + + return $out; + } + + static public function user_field(array $params): string + { + if (isset($params['field'])) { + $field = $params['field']; + } + else { + $name = $params['name'] ?? $params['key'] ?? null; + + if (null === $name) { + throw new \RuntimeException('Missing "name" parameter'); + } + + $field = DynamicFields::get($name); + } + + if ($field && !($field instanceof DynamicField)) { + throw new \LogicException('This field does not exist.'); + } + + $v = $params['value'] ?? null; + + $out = ''; + + if (!$field) { + $out = htmlspecialchars((string)$v); + } + elseif ($field->type == 'checkbox') { + $out = $v ? 'Oui' : 'Non'; + } + elseif (null === $v) { + return ''; + } + elseif ($field->type == 'file') { + if (!$v) { + return ''; + } + + $files = explode(';', $v); + $count = 0; + $label = ''; + + foreach ($files as $path) { + if (!preg_match('!\.(?:png|jpe?g|gif|webp)$!i', $path)) { + $count++; + continue; + } + elseif ($label !== '') { + $count++; + continue; + } + + $url = BASE_URL . $path . '?150px'; + $label .= sprintf( + '%s', + htmlspecialchars($url), + htmlspecialchars($field->label) + ); + } + + if ($count) { + $label .= ($count != count($files) ? '+' : '') + . ($count == 1 ? '1 fichier' : $count . ' fichiers'); + } + + if ($label !== '') { + if (isset($params['files_href'])) { + $label = sprintf('%s', Utils::getLocalURL($params['files_href']), $label); + } + + $out = '
' . $label . '
'; + } + } + elseif ($field->type === 'password') { + $out = '*****'; + } + elseif ($field->type === 'email' && empty($params['link_name_id'])) { + $out = '' . htmlspecialchars($v) . ''; + } + elseif ($field->type === 'tel' && empty($params['link_name_id'])) { + $out = '' . htmlspecialchars(CommonModifiers::format_phone_number($v)) . ''; + } + elseif ($field->type === 'url' && empty($params['link_name_id'])) { + $out ='' . htmlspecialchars($v) . ''; + } + elseif ($field->type === 'number' || $field->type === 'decimal') { + $out = str_replace('.', ',', htmlspecialchars($v)); + } + else { + $v = $field->getStringValue($v); + $out = nl2br(htmlspecialchars((string) $v)); + } + + if (!empty($params['link_name_id']) && ($name === 'identity' || ($field && $field->isName() && substr($out, 0, 2) !== '%s', Utils::getLocalURL('!users/details.php?id=' . (int)$params['link_name_id']), $out); + } + + return $out; + } +} diff --git a/src/include/lib/Paheko/UserTemplate/CommonModifiers.php b/src/include/lib/Paheko/UserTemplate/CommonModifiers.php new file mode 100644 index 0000000..767b9f9 --- /dev/null +++ b/src/include/lib/Paheko/UserTemplate/CommonModifiers.php @@ -0,0 +1,416 @@ + ['string', '?int='], + 'htmlentities' => ['string', 'int=', '?string=', 'bool='], + 'htmlspecialchars' => ['string', 'int=', '?string=', 'bool='], + 'trim' => ['string', 'string='], + 'ltrim' => ['string', 'string='], + 'rtrim' => ['string', 'string='], + 'md5' => ['string', 'bool='], + 'sha1' => ['string', 'bool='], + 'nl2br' => ['string', 'bool='], + 'strlen' => ['string'], + 'strpos' => ['string', 'string', 'int='], + 'strrpos' => ['string', 'string', 'int='], + 'wordwrap' => ['string', 'int=', 'string=', 'bool='], + 'strip_tags' => ['string', '?array='], + 'boolval' => ['mixed'], + 'intval' => ['mixed'], + 'floatval' => ['mixed'], + 'strval' => ['mixed'], + 'substr' => ['string', 'int', '?int='], + 'http_build_query' => ['array', 'string=', '?string=', 'int='], + 'str_getcsv' => ['string', 'string=', 'string=', 'string='], + ]; + + /** + * Used for PHP modifiers + */ + static public function __callStatic(string $name, array $arguments) + { + if (!array_key_exists($name, self::PHP_MODIFIERS_LIST)) { + throw new \LogicException('Invalid method: ' . $name); + } + + // Verify arguments + foreach (self::PHP_MODIFIERS_LIST[$name] as $i => $param) { + $nullable = false; + $optional = false; + + if (substr($param, -1) === '=') { + $optional = true; + $param = substr($param, 0, -1); + } + + if (substr($param, 0, 1) === '?') { + $nullable = true; + $param = substr($param, 1); + } + + if (!array_key_exists($i, $arguments)) { + // Stop at first optional and not provided argument + if ($optional) { + break; + } + + throw new \BadMethodCallException(sprintf('Missing parameter %d of type %s', $i+1, $param)); + } + + $value =& $arguments[$i]; + + if ($nullable && (null === $value || '' === $value)) { + $value = null; + } + elseif ($param !== 'mixed') { + settype($value, $param); + } + } + + unset($value, $i, $param); + + return call_user_func_array($name, $arguments); + } + + const MODIFIERS_LIST = [ + 'protect_contact', + 'markdown', + 'money', + 'money_raw', + 'money_currency', + 'money_html', + 'money_currency_html', + 'relative_date', + 'relative_date_short', + 'date_short', + 'date_long', + 'date_hour', + 'date', + 'strftime', + 'size_in_bytes' => [Utils::class, 'format_bytes'], + 'typo', + 'css_hex_to_rgb', + 'css_hex_extract_hsv', + 'toupper', + 'tolower', + 'ucwords', + 'ucfirst', + 'lcfirst', + 'abs', + 'format_phone_number', + ]; + + static public function protect_contact(?string $contact, ?string $type = null): string + { + if (!trim($contact)) + return ''; + + if ($type == 'mail' || strpos($contact, '@')) { + $user = strtok($contact, '@'); + $domain = strtok('.'); + $ext = strtok(''); + + return sprintf('', + htmlspecialchars($user), htmlspecialchars($domain), htmlspecialchars($ext)); + } + else { + $label = preg_replace_callback('/[a-zA-Z0-9@]/', fn ($match) => '&#' . ord($match[0]) . ';', htmlspecialchars($contact)); + $url = htmlspecialchars($type ? $type . ':' : '') . $label; + return sprintf('%s', $url, $label); + } + } + + static public function markdown($str): string + { + $md = new Markdown(null, null); + return $md->render($str); + } + + static public function money($number, bool $hide_empty = true, bool $force_sign = false, bool $html = false): string + { + if ($hide_empty && !$number) { + return ''; + } + + $sign = ($force_sign && $number > 0) ? '+' : ''; + + $out = $sign . Utils::money_format($number, ',', $html ? ' ' : ' ', $hide_empty); + + if ($html) { + $out = sprintf('%s', $out); + } + + return $out; + } + + static public function money_raw($number, bool $hide_empty = true): string + { + return Utils::money_format($number, ',', '', $hide_empty); + } + + static public function money_currency($number, bool $hide_empty = true, bool $force_sign = false, bool $html = false): string + { + $out = self::money($number, $hide_empty, $force_sign, $html); + + if ($out !== '') { + $out .= ($html ? ' ' : ' ') . Config::getInstance()->get('currency'); + } + + return $out; + } + + static public function money_html($number, bool $hide_empty = true, bool $force_sign = false): string + { + return self::money($number, $hide_empty, $force_sign, true); + } + + static public function money_currency_html($number, bool $hide_empty = true, bool $force_sign = false): string + { + return '' . self::money_currency($number, $hide_empty, $force_sign, true) . ''; + } + + static public function date_long($ts, bool $with_hour = false): ?string + { + return Utils::strftime_fr($ts, '%A %e %B %Y' . ($with_hour ? ' à %Hh%M' : '')); + } + + static public function date_short($ts, bool $with_hour = false): ?string + { + return Utils::shortDate($ts, $with_hour); + } + + static public function date_hour($ts, bool $minutes_only_if_required = false): ?string + { + $ts = Utils::get_datetime($ts); + + if (null === $ts) { + return null; + } + + if ($minutes_only_if_required && $ts->format('i') == '00') { + return $ts->format('H\h'); + } + else { + return $ts->format('H\hi'); + } + } + + static public function strftime($ts, string $format, string $locale = 'fr'): ?string + { + if ($locale == 'fr') { + return Utils::strftime_fr($ts, $format); + } + + $ts = Utils::get_datetime($ts); + + if (!$ts) { + return $ts; + } + + return @strftime($format, $ts->getTimestamp()); + } + + static public function date($ts, string $format = null, string $locale = 'fr'): ?string + { + if (null === $format) { + $format = 'd/m/Y à H:i'; + } + elseif (preg_match('/^DATE_[\w\d]+$/', $format)) { + $format = constant('DateTime::' . $format); + } + + if ($locale == 'fr') { + return Utils::date_fr($ts, $format); + } + + $ts = Utils::get_datetime($ts); + return date($format, $ts); + } + + static public function relative_date($ts, bool $with_hour = false): string + { + $day = null; + + if (null === $ts) { + return ''; + } + + $date = Utils::get_datetime($ts); + + if ($date->format('Ymd') == date('Ymd')) + { + $day = 'aujourd\'hui'; + } + elseif ($date->format('Ymd') == date('Ymd', strtotime('yesterday'))) + { + $day = 'hier'; + } + elseif ($date->format('Ymd') == date('Ymd', strtotime('tomorrow'))) + { + $day = 'demain'; + } + elseif ($date->getTimestamp() > time() - 3600*24*7 && $date->getTimestamp() < time()) { + $day = sprintf('il y a %d jours', round((time() - $date->getTimestamp()) / (3600*24))); + } + elseif ($date->format('Y') == date('Y')) + { + $day = strtolower(Utils::strftime_fr($date, '%A %e %B')); + } + else + { + $day = strtolower(Utils::strftime_fr($date, '%e %B %Y')); + } + + if ($with_hour) + { + $hour = $date->format('H\hi'); + return sprintf('%s, %s', $day, $hour); + } + + return $day; + } + + static public function relative_date_short($ts, bool $with_hour = false): string + { + $day = null; + + if (null === $ts) { + return ''; + } + + $date = Utils::get_datetime($ts); + + if ($date->format('Ymd') == date('Ymd')) + { + $day = 'aujourd\'hui'; + } + elseif ($date->format('Ymd') == date('Ymd', strtotime('yesterday'))) + { + $day = 'hier'; + } + elseif ($date->format('Ymd') == date('Ymd', strtotime('tomorrow'))) + { + $day = 'demain'; + } + elseif ($date->getTimestamp() > time() - 3600*24*7) { + $day = sprintf('il y a %d jours', round((time() - $date->getTimestamp()) / (3600*24))); + } + elseif ($date->format('Y') == date('Y')) + { + $day = strtolower(Utils::strftime_fr($date, '%e %B')); + } + else + { + $day = strtolower(Utils::strftime_fr($date, '%d/%m/%Y')); + } + + if ($with_hour) + { + $hour = $date->format('H\hi'); + return sprintf('%s, %s', $day, $hour); + } + + return $day; + } + + static public function typo($str, $locale = 'fr') + { + $str = preg_replace('/[\h]*([?!:»;])(?=\s|$)/us', "\xc2\xa0\\1", $str); + $str = preg_replace('/(?<=^|\s)([«])[\h]*/u', "\\1\xc2\xa0", $str); + return $str; + } + + static public function css_hex_to_rgb($str): ?string { + $hex = sscanf((string)$str, '#%02x%02x%02x'); + + if (empty($hex)) { + return null; + } + + return implode(', ', $hex); + } + + static public function css_hex_extract_hsv($str): array { + list($h, $s, $v) = Utils::rgbToHsv($str); + $h = (int)$h; + $s = floor(100 * $s); + $v = floor(100 * $v); + return compact('h', 's', 'v'); + } + + static public function toupper($str): string + { + return function_exists('mb_strtoupper') ? mb_strtoupper($str) : strtoupper($str); + } + + static public function tolower($str): string + { + return function_exists('mb_strtolower') ? mb_strtolower($str) : strtolower($str); + } + + static public function ucwords($str): string + { + return function_exists('mb_convert_case') ? mb_convert_case($str, \MB_CASE_TITLE) : ucwords($str); + } + + static public function ucfirst($str): string + { + return function_exists('mb_strtoupper') ? mb_strtoupper(mb_substr($str, 0, 1)) . mb_substr($str, 1) : ucfirst($str); + } + + static public function lcfirst($str): string + { + return function_exists('mb_strtolower') ? mb_strtolower(mb_substr($str, 0, 1)) . mb_substr($str, 1) : ucfirst($str); + } + + static public function abs($in) + { + if (false !== strpos((string)$in, '.')) { + $in = (float) $in; + } + else { + $in = (int) $in; + } + + return abs($in); + } + + static public function format_phone_number($n) + { + if (empty($n)) { + return ''; + } + + $country = Config::getInstance()->get('country'); + + if ($country !== 'FR') { + return $n; + } + + if ($n[0] === '0' && strlen($n) === 10) { + $n = preg_replace('!(\d{2})!', '\\1 ', $n); + } + + return $n; + } +} diff --git a/src/include/lib/Paheko/UserTemplate/Functions.php b/src/include/lib/Paheko/UserTemplate/Functions.php new file mode 100644 index 0000000..5eb9258 --- /dev/null +++ b/src/include/lib/Paheko/UserTemplate/Functions.php @@ -0,0 +1,828 @@ + [self::class, 'compile_break'], + ':continue' => [self::class, 'compile_continue'], + ':return' => [self::class, 'compile_return'], + ]; + + /** + * Compile function to break inside a loop + */ + static public function compile_break(string $name, string $params, UserTemplate $tpl, int $line) + { + $in_loop = false; + foreach ($tpl->_stack as $element) { + if ($element[0] == $tpl::SECTION) { + $in_loop = true; + break; + } + } + + if (!$in_loop) { + throw new Brindille_Exception(sprintf('Error on line %d: break can only be used inside a section', $line)); + } + + return ''; + } + + /** + * Compile function to continue inside a loop + */ + static public function compile_continue(string $name, string $params, UserTemplate $tpl, int $line) + { + $in_loop = 0; + foreach ($tpl->_stack as $element) { + if ($element[0] == $tpl::SECTION) { + $in_loop++; + } + } + + $i = ctype_digit(trim($params)) ? (int)$params : 1; + + if ($in_loop < $i) { + throw new Brindille_Exception(sprintf('Error on line %d: continue can only be used inside a section', $line)); + } + + return sprintf('', $i); + } + + static public function compile_return(string $name, string $params, UserTemplate $tpl, int $line) + { + return ''; + } + + static public function admin_header(array $params): string + { + $tpl = Template::getInstance(); + $tpl->assign($params); + + if (Session::getInstance()->isLogged()) { + $tpl->assign('plugins_menu', Extensions::listMenu(Session::getInstance())); + } + + return $tpl->fetch('_head.tpl'); + } + + static public function admin_footer(array $params): string + { + $tpl = Template::getInstance(); + $tpl->assign($params); + return $tpl->fetch('_foot.tpl'); + } + + static public function password_input(): string + { + $tpl = Template::getInstance(); + return $tpl->fetch('users/_password_form.tpl'); + } + + static public function save(array $params, UserTemplate $tpl, int $line): void + { + if (!$tpl->module) { + throw new Brindille_Exception('Module name could not be found'); + } + + $table = 'module_data_' . $tpl->module->name; + + if (!empty($params['key'])) { + if ($params['key'] == 'uuid') { + $params['key'] = Utils::uuid(); + } + + $field = 'key'; + $where_value = $params['key']; + } + elseif (!empty($params['id'])) { + $field = 'id'; + $where_value = $params['id']; + } + else { + $where_value = null; + $field = null; + } + + $key = $params['key'] ?? null; + $id = $params['id'] ?? null; + $assign_new_id = $params['assign_new_id'] ?? null; + $validate = $params['validate_schema'] ?? null; + $validate_only = $params['validate_only'] ?? null; + + unset($params['key'], $params['id'], $params['assign_new_id'], $params['validate_schema'], $params['validate_only']); + + $db = DB::getInstance(); + + if ($key == 'config') { + $result = $db->firstColumn(sprintf('SELECT config FROM %s WHERE name = ?;', Module::TABLE), $tpl->module->name); + } + else { + $db->exec(sprintf(' + CREATE TABLE IF NOT EXISTS %s ( + id INTEGER NOT NULL PRIMARY KEY, + key TEXT NULL, + document TEXT NOT NULL + ); + CREATE UNIQUE INDEX IF NOT EXISTS %1$s_key ON %1$s (key);', $table)); + + if ($field) { + $result = $db->firstColumn(sprintf('SELECT document FROM %s WHERE %s;', $table, ($field . ' = ?')), $where_value); + } + else { + $result = null; + } + } + + // Merge before update + if ($result) { + $result = json_decode((string) $result, true); + $params = array_merge($result, $params); + } + + if (!empty($validate)) { + $schema = self::_readFile($validate, 'validate_schema', $tpl, $line); + + if ($validate_only && is_string($validate_only)) { + $validate_only = explode(',', $validate_only); + $validate_only = array_map('trim', $validate_only); + } + else { + $validate_only = null; + } + + try { + $s = JSONSchema::fromString($schema); + + if ($validate_only) { + $s->validateOnly($params, $validate_only); + } + else { + $s->validate($params); + } + } + catch (\RuntimeException $e) { + throw new Brindille_Exception(sprintf("ligne %d: impossible de valider le schéma:\n%s\n\n%s", + $line, $e->getMessage(), json_encode($params, JSON_PRETTY_PRINT))); + } + } + + $value = json_encode($params); + + if ($key == 'config') { + $db->update(Module::TABLE, ['config' => $value], 'name = :name', ['name' => $tpl->module->name]); + return; + } + + $document = $value; + if (!$result) { + $db->insert($table, compact('id', 'document', 'key')); + + if ($assign_new_id) { + $tpl->assign($assign_new_id, $db->lastInsertId()); + } + } + else { + $db->update($table, compact('document'), sprintf('%s = :match', $field), ['match' => $where_value]); + } + } + + static public function delete(array $params, UserTemplate $tpl, int $line): void + { + if (!$tpl->module) { + throw new Brindille_Exception('Module name could not be found'); + } + + $db = DB::getInstance(); + $table = 'module_data_' . $tpl->module->name; + + // No table? No problem! + if (!$db->test('sqlite_master', 'name = ? AND type = \'table\'', $table)) { + return; + } + + $where = []; + $args = []; + $i = 0; + + foreach ($params as $key => $value) { + if ($key[0] == ':') { + $args[substr($key, 1)] = $value; + } + elseif ($key == 'where') { + $where[] = Sections::_moduleReplaceJSONExtract($value, $table); + } + else { + if ($key == 'id') { + $value = (int) $value; + } + + if ($key !== 'id' && $key !== 'key') { + $args['key_' . $i] = '$.' . $key; + $key = sprintf('json_extract(document, :key_%d)', $i); + } + + $where[] = $key . ' = :value_' . $i; + $args['value_' . $i] = $value; + $i++; + } + } + + $where = implode(' AND ', $where); + $db->delete($table, $where, $args); + } + + static public function captcha(array $params, UserTemplate $tpl, int $line) + { + $secret = md5(SECRET_KEY . Utils::getSelfURL(false)); + + if (isset($params['html'])) { + $c = Security::createCaptcha($secret, $params['lang'] ?? 'fr'); + return sprintf(' + + ', + $c['spellout'], $c['hash']); + } + elseif (isset($params['assign_hash']) && isset($params['assign_number'])) { + $c = Security::createCaptcha($secret, $params['lang'] ?? 'fr'); + $tpl->assign($params['assign_hash'], $c['hash']); + $tpl->assign($params['assign_number'], $c['spellout']); + } + elseif (isset($params['verify'])) { + $hash = $_POST['f_c_43'] ?? ''; + $number = $_POST['f_c_42'] ?? ''; + } + elseif (array_key_exists('verify_number', $params)) { + $hash = $params['verify_hash'] ?? ''; + $number = $params['verify_number'] ?? ''; + } + else { + throw new Brindille_Exception(sprintf('Line %d: no valid arguments supplied for "captcha" function', $line)); + } + + $error = 'Réponse invalide à la vérification anti-robot'; + + if (!Security::checkCaptcha($secret, trim($hash), trim($number))) { + if (isset($params['assign_error'])) { + $tpl->assign($params['assign_error'], $error); + } + else { + throw new UserException($error); + } + } + } + + static public function mail(array $params, UserTemplate $ut, int $line) + { + if (empty($params['to'])) { + throw new Brindille_Exception(sprintf('Ligne %d: argument "to" manquant pour la fonction "mail"', $line)); + } + + if (empty($params['subject'])) { + throw new Brindille_Exception(sprintf('Ligne %d: argument "subject" manquant pour la fonction "mail"', $line)); + } + + if (empty($params['body'])) { + throw new Brindille_Exception(sprintf('Ligne %d: argument "body" manquant pour la fonction "mail"', $line)); + } + + if (!empty($params['block_urls']) && preg_match('!https?://!', $params['subject'] . $params['body'])) { + throw new UserException('Merci de ne pas inclure d\'adresse web (http:…) dans le message'); + } + + $attachments = []; + + if (!empty($params['attach_file'])) { + $attachments = (array) $params['attach_file']; + + foreach ($attachments as &$file) { + $f = Files::get($file); + + if (!$f) { + throw new UserException(sprintf('Le fichier à joindre "%s" n\'existe pas', $file)); + } + + if (!$f->canRead()) { + throw new UserException(sprintf('Vous n\'avez pas le droit d\'accéder au fichier à joindre "%s"', $file)); + } + + $file = $f; + } + + unset($file); + } + elseif (!empty($params['attach_from'])) { + if (empty($ut->module)) { + throw new UserException('"attach_from" can only be called from within a module'); + } + + $attachments = (array) $params['attach_from']; + + foreach ($attachments as &$file) { + $file = self::getFilePath($file, 'attach_from', $ut, $line); + $file = $ut->fetchToAttachment($file); + } + + unset($file); + } + + static $external = 0; + static $internal = 0; + + if (is_string($params['to'])) { + $params['to'] = [$params['to']]; + } + + if (!count($params['to'])) { + throw new Brindille_Exception(sprintf('Ligne %d: aucune adresse destinataire n\'a été précisée pour la fonction "mail"', $line)); + } + + foreach ($params['to'] as &$to) { + $to = trim($to); + Email::validateAddress($to); + } + + unset($to); + + // Restrict sending recipients + if (!$ut->isTrusted()) { + $db = DB::getInstance(); + $email_field = DynamicFields::getFirstEmailField(); + $internal_count = $db->count('users', $db->where($email_field, 'IN', $params['to'])); + $external_count = count($params['to']) - $internal_count; + + if (($external_count + $external) > 1) { + throw new Brindille_Exception(sprintf('Ligne %d: l\'envoi d\'email à une adresse externe est limité à un envoi par page', $line)); + } + + if (($internal_count + $internal) > 10) { + throw new Brindille_Exception(sprintf('Ligne %d: l\'envoi d\'email à une adresse interne est limité à 10 envois par page', $line)); + } + + if ($external_count + && preg_match_all('!(https?://.*?)(?=\s|$)!', $params['subject'] . ' ' . $params['body'], $match, PREG_PATTERN_ORDER)) { + $config = Config::getInstance(); + + foreach ($match[1] as $m) { + $allowed = false; + + if (0 === strpos($m, WWW_URL)) { + $allowed = true; + } + elseif (0 === strpos($m, BASE_URL)) { + $allowed = true; + } + elseif ($config->org_web && 0 === strpos($m, $config->org_web)) { + $allowed = true; + } + + if (!$allowed) { + throw new Brindille_Exception(sprintf('Ligne %d: l\'envoi d\'email à une adresse externe interdit l\'utilisation d\'une adresse web autre que le site de l\'association : %s', $line, $m)); + } + } + } + } + + $context = count($params['to']) == 1 ? Emails::CONTEXT_PRIVATE : Emails::CONTEXT_BULK; + Emails::queue($context, $params['to'], null, $params['subject'], $params['body'], $attachments); + + if (!$ut->isTrusted()) { + $internal += $internal_count; + $external_count += $external_count; + } + } + + static public function debug(array $params, UserTemplate $tpl) + { + if (!count($params)) { + $params = $tpl->getAllVariables(); + } + + $dump = htmlspecialchars(ErrorManager::dump($params)); + + // FIXME: only send back HTML when content-type is text/html, or send raw text + $out = sprintf('
%s
', $dump); + + if (!empty($params['stop'])) { + echo $out; exit; + } + + return $out; + } + + static public function error(array $params, UserTemplate $tpl, int $line) + { + if (isset($params['admin'])) { + throw new Brindille_Exception($params['admin']); + } + + throw new UserException($params['message'] ?? 'Erreur du module'); + } + + static protected function getFilePath(?string $path, string $arg_name, UserTemplate $ut, int $line) + { + if (empty($path)) { + throw new Brindille_Exception(sprintf('Ligne %d: argument "%s" manquant', $line, $arg_name)); + } + + if (substr($path, 0, 2) == './') { + $path = Utils::dirname($ut->_tpl_path) . substr($path, 1); + } + elseif (substr($path, 0, 1) != '/') { + $path = Utils::dirname($ut->_tpl_path) . '/' . $path; + } + + $parts = explode('/', $path); + $out = []; + + foreach ($parts as $part) { + if ($part == '..') { + array_pop($out); + } + else { + $out[] = $part; + } + } + + $out = implode('/', $out); + + if (preg_match('!\.\.|://|/\.|^\.!', $out)) { + throw new Brindille_Exception(sprintf('Ligne %d: argument "%s" invalide', $line, $arg_name)); + } + + return $out; + } + + static public function _readFile(string $file, string $arg_name, UserTemplate $ut, int $line): string + { + $path = self::getFilePath($file ?? null, $arg_name, $ut, $line); + + $file = Files::get(File::CONTEXT_MODULES . '/' . $path); + + if ($file) { + $content = $file->fetch(); + } + elseif (file_exists(ROOT . '/modules/' . $path)) { + $content = file_get_contents(ROOT . '/modules/' . $path); + } + else { + throw new Brindille_Exception(sprintf('Ligne %d : le fichier appelé "%s" n\'existe pas', $line, $path)); + } + + return $content; + } + + static public function read(array $params, UserTemplate $ut, int $line): string + { + $content = self::_readFile($params['file'] ?? '', 'file', $ut, $line); + + if (!empty($params['assign'])) { + $ut::__assign(['var' => $params['assign'], 'value' => $content], $ut, $line); + return ''; + } + + return $content; + } + + static public function signature(): string + { + $file = Config::getInstance()->file('signature'); + + if (!$file) { + return ''; + } + + // We can't just use the image URL as it would not be accessible by PDF programs + $url = 'data:image/png;base64,' . base64_encode($file->fetch()); + + return sprintf('
Signature
', $url); + } + + static public function include(array $params, UserTemplate $ut, int $line): void + { + $path = self::getFilePath($params['file'] ?? null, 'file', $ut, $line); + + // Avoid recursive loops + $from = $ut->get('included_from') ?? []; + + if (in_array($path, $from)) { + throw new Brindille_Exception(sprintf('Ligne %d : boucle infinie d\'inclusion détectée : %s', $line, $path)); + } + + try { + $include = new UserTemplate($path); + $include->setParent($ut); + } + catch (\InvalidArgumentException $e) { + throw new Brindille_Exception(sprintf('Ligne %d : fonction "include" : le fichier à inclure "%s" n\'existe pas', $line, $path)); + } + + $params['included_from'] = array_merge($from, [$path]); + + $include->assignArray(array_merge($ut->getAllVariables(), $params)); + + if (!empty($params['capture']) && preg_match('/^[a-z0-9_]+$/', $params['capture'])) { + $ut::__assign([$params['capture'] => $include->fetch()], $ut, $line); + } + else { + $include->display(); + } + + if (isset($params['keep'])) { + $keep = explode(',', $params['keep']); + $keep = array_map('trim', $keep); + + foreach ($keep as $name) { + // Transmit variables + $ut::__assign(['var' => $name, 'value' => $include->get($name)], $ut, $line); + } + } + + // Transmit nocache to parent template + if ($include->get('nocache')) { + $ut::__assign(['nocache' => true], $ut, $line); + } + } + + static public function http(array $params, UserTemplate $tpl): void + { + if (headers_sent()) { + return; + } + + if (isset($params['redirect'])) { + + if (isset($params['code'])) { + http_response_code((int)$params['code']); + } + + Utils::redirectDialog($params['redirect']); + } + + if (isset($params['code'])) { + $tpl->setHeader('code', $params['code']); + } + + if (!empty($params['type'])) { + if ($params['type'] == 'pdf') { + $params['type'] = 'application/pdf'; + } + + $tpl->setHeader('type', $params['type']); + } + + if (isset($params['download'])) { + $tpl->setHeader('disposition', 'attachment'); + $tpl->setHeader('filename', $params['download']); + } + elseif (isset($params['inline'])) { + $tpl->setHeader('disposition', 'inline'); + $tpl->setHeader('filename', $params['inline']); + } + } + + static public function _getFormKey(): string + { + return 'form_' . md5(Utils::getSelfURI(false)); + } + + /** + * @override + */ + static public function button(array $params): string + { + // Always add CSRF protection when a submit button is present in the form + if (isset($params['type']) && $params['type'] == 'submit') { + $key = self::_getFormKey(); + $params['csrf_key'] = $key; + } + + return CommonFunctions::button($params); + } + + /** + * @override + */ + static public function delete_form(array $params, UserTemplate $tpl): string + { + $params['csrf_key'] = self::_getFormKey(); + return self::form_errors([], $tpl) . CommonFunctions::delete_form($params); + } + + static public function form_errors(array $params, UserTemplate $tpl): string + { + if (($e = $tpl->get('form_errors')) && is_array($e)) { + return sprintf('

%s

', nl2br(htmlspecialchars(implode("\n", $e)))); + } + + return ''; + } + + static public function redirect(array $params): void + { + if (isset($params['force'])) { + Utils::redirectDialog($params['force']); + } + elseif (isset($_GET['_dialog'])) { + Utils::reloadParentFrame(); + } + else { + Utils::redirectDialog($params['to'] ?? null); + } + } + + static public function admin_files(array $params, UserTemplate $ut): string + { + if (empty($ut->module)) { + throw new Brindille_Exception('Module could not be found'); + } + + $tpl = Template::getInstance(); + + if (!isset($params['edit'])) { + $params['edit'] = false; + } + + if (!isset($params['upload'])) { + $params['upload'] = $params['edit']; + } + + if (isset($params['path']) && preg_match('!/\.|\.\.!', $params['path'])) { + throw new Brindille_Exception(sprintf('Line %d: "path" parameter is invalid: "%s"', $line, $params['path'])); + } + + $path = isset($params['path']) && preg_match('/^[a-z0-9_-]+$/i', $params['path']) ? '/' . $params['path'] : ''; + + $tpl->assign($params); + $tpl->assign('path', $ut->module->storage_root() . $path); + return '

Fichiers joints

' . $tpl->fetch('common/files/_context_list.tpl') . '
'; + } + + static public function delete_file(array $params, UserTemplate $ut, int $line): void + { + if (empty($ut->module)) { + throw new Brindille_Exception('Module could not be found'); + } + + if (empty($params['path'])) { + throw new Brindille_Exception(sprintf('Line %d: "path" parameter is missing or empty', $line)); + } + + if (preg_match('!/\.|\.\.!', $params['path'])) { + throw new Brindille_Exception(sprintf('Line %d: "path" parameter is invalid: "%s"', $line, $params['path'])); + } + + Files::delete($ut->module->storage_root() . '/' . $params['path']); + } + + static public function api(array $params, UserTemplate $ut, int $line): void + { + if (empty($params['path'])) { + throw new Brindille_Exception('"path" parameter is missing'); + } + + if (empty($params['method'])) { + throw new Brindille_Exception('"method" parameter is missing'); + } + + $path = trim($params['path'], '/'); + $method = strtoupper($params['method']); + $assign = $params['assign'] ?? null; + $assign_code = $params['assign_code'] ?? null; + $fail = $params['fail'] ?? true; + $access_level = null; + $url = null; + + unset($params['method'], $params['path'], $params['assign'], $params['assign_code'], $params['fail']); + + if (isset($params['url'])) { + if (empty($params['user'])) { + throw new Brindille_Exception('"user" parameter is missing'); + } + + if (empty($params['password'])) { + throw new Brindille_Exception('"password" parameter is missing'); + } + + $url = str_replace('://', '://' . $params['user'] . '@' . $params['password'], $params['url']); + unset($params['user'], $params['password'], $params['url']); + } + else { + $access_level = $params['access'] ?? 'admin'; + unset($params['access_level']); + } + + $body = null; + + if ($method !== 'GET' && array_key_exists('body', $params)) { + $body = $params['body']; + unset($params['body']); + } + + $code = null; + + // External HTTP request + if ($url) { + $http = new HTTP; + $url = rtrim($url, '/') . '/' . $path; + $body ??= json_encode($params); + + if ($method === 'POST') { + $r = $http->POST($url, $body, $http::JSON); + } + else { + $r = $http->GET($url); + } + + if ($fail && $r->fail) { + $body = json_decode($r->body); + throw new Brindille_Exception(sprintf('External API request failed: %d - %s', $r->status, $body->error ?? $r->error)); + } + + $code = $r->status; + $return = $r->body; + + if ($assign && isset($r->headers['Content-Type']) && false !== strpos($r->headers['Content-Type'], '/json')) { + $return = @json_decode($return); + } + } + // Internal request + else { + $api = new API($method, $path, $params); + + if ($access_level) { + $api->setAccessLevelByName($access_level); + } + + $api->setAllowedFilesRoot($ut->module->storage_root()); + + try { + $return = $api->route(); + } + catch (APIException $e) { + if ($fail) { + throw new Brindille_Exception(sprintf('Internal API request failed: %d - %s', $e->getCode(), $e->getMessage())); + } + } + } + + if ($assign_code) { + $ut->assign($assign_code, $code); + } + + if ($assign) { + $ut->assign($assign, $return); + } + } +} diff --git a/src/include/lib/Paheko/UserTemplate/Modifiers.php b/src/include/lib/Paheko/UserTemplate/Modifiers.php new file mode 100644 index 0000000..6cecf05 --- /dev/null +++ b/src/include/lib/Paheko/UserTemplate/Modifiers.php @@ -0,0 +1,607 @@ + [Utils::class, 'moneyToInteger'], + 'array_transpose' => [Utils::class, 'array_transpose'], + 'check_email', + 'gettype', + 'arrayval', + 'explode', + 'implode', + 'keys', + 'values', + 'has', + 'has_key', + 'in', + 'key_in', + 'sort', + 'ksort', + 'max', + 'min', + 'quote_sql_identifier', + 'quote_sql', + 'sql_where', + 'sql_user_fields', + 'urlencode', + 'count_words', + 'or', + 'uuid', + 'key', + ]; + + const MODIFIERS_WITH_INSTANCE_LIST = [ + 'map', + ]; + + const LEADING_NUMBER_REGEXP = '/^([\d.]+)\s*[.\)]\s*/'; + + static public function replace($str, $find, $replace = null): string + { + if (is_array($find) && null === $replace) { + return strtr($str, $find); + } + + return str_replace((string)$find, (string)$replace, (string)$str); + } + + static public function regexp_replace($str, $pattern, $replace) + { + return preg_replace((string) $pattern, (string) $replace, (string) $str); + } + + static public function regexp_match($str, $pattern) + { + return (int) preg_match((string) $pattern, (string) $str); + } + + static public function match($str, $pattern) + { + return (int) (stripos($str, $pattern) !== false); + } + + static public function check_email($str) + { + if (!trim((string)$str)) { + return false; + } + + try { + Email::validateAddress((string)$str); + } + catch (UserException $e) { + return false; + } + + return true; + } + + /** + * UTF-8 aware intelligent substr + * @param string $str UTF-8 string + * @param integer $length Maximum string length + * @param string $placeholder Placeholder text to append at the string if it has been cut + * @param boolean $strict_cut If true then will cut in the middle of words + * @return string String cut to $length or shorter + * @example |truncate:10:" (click to read more)":true + */ + static public function truncate($str, $length = 80, $placeholder = '…', $strict_cut = false): string + { + if (mb_strlen($str) <= $length) { + return $str; + } + + $str = mb_substr($str, 0, $length); + + if (!$strict_cut) { + $cut = preg_replace('/[^\s.,;!?]*$/su', '', $str); + + if (trim($cut) == '') { + $cut = $str; + } + + $str = $cut; + } + + return trim($str) . $placeholder; + } + + static public function excerpt($str, $length = 600): string + { + $str = strip_tags($str); + $str = self::truncate($str, $length); + $str = preg_replace("/\n{2,}/", '

', $str); + return '

' . $str . '

'; + } + + static public function atom_date($date) + { + return Utils::date_fr($date, DATE_ATOM); + } + + static public function xml_escape($str) + { + return htmlspecialchars($str, ENT_XML1 | ENT_QUOTES); + } + + static public function json_decode($str) + { + return json_decode($str, true); + } + + static public function json_encode($obj) + { + return json_encode($obj, JSON_PRETTY_PRINT); + } + + static public function minify(string $str, string $language = 'js'): string + { + // Remove comments + $str = preg_replace('!/\*.*?\*/!s', '', $str); + + if ($language === 'css') { + static $regexp = <<<'EOS' + (?six) + # quotes + ( + "(?:[^"\\]++|\\.)*+" + | '(?:[^'\\]++|\\.)*+' + ) + | + # ; before } (and the spaces after it while we're here) + \s*+ ; \s*+ ( } ) \s*+ + | + # all spaces around meta chars/operators + \s*+ ( [*$~^|]?+= | [{};,>~+-] | !important\b ) \s*+ + | + # spaces right of ( [ : + ( [[(:] ) \s++ + | + # spaces left of ) ] + \s++ ( [])] ) + | + # spaces left (and right) of : + \s++ ( : ) \s*+ + # but not in selectors: not followed by a { + (?! + (?> + [^{}"']++ + | "(?:[^"\\]++|\\.)*+" + | '(?:[^'\\]++|\\.)*+' + )*+ + { + ) + | + # spaces at beginning/end of string + ^ \s++ | \s++ \z + | + # double spaces to single + (\s)\s+ +EOS; + + $str = preg_replace('%' . $regexp . '%', '$1$2$3$4$5$6$7$8', $str); + } + else { + $str = preg_replace('!\s{2,}!', ' ', $str); + $str = preg_replace('!(\{|\(|\[)\s+!', '$1', $str); + $str = preg_replace('!\s+(\}|\)|\])!', '$1', $str); + } + + return $str; + } + + static public function remove_leading_number($str): string + { + return preg_replace(self::LEADING_NUMBER_REGEXP, '', trim($str)); + } + + static public function get_leading_number($str): ?string + { + $match = preg_match(self::LEADING_NUMBER_REGEXP, $str, $match); + return $match[1] ?? null; + } + + static public function spell_out_number($number, string $locale = 'fr_FR', string $currency = 'euros'): string + { + $number = str_replace(',', '.', $number); + $number = strtok($number, '.'); + $decimals = strtok(''); + + $out = numfmt_create($locale, \NumberFormatter::SPELLOUT)->format((float) $number); + $out .= ' ' . $currency; + + if ($decimals > 0) { + $out .= sprintf(' et %s cents', numfmt_create($locale, \NumberFormatter::SPELLOUT)->format((float) $decimals)); + } + + return trim($out); + } + + static public function parse_date($value) + { + if ($value instanceof \DateTimeInterface) { + return $value->format('Y-m-d'); + } + + if (empty($value) || !is_string($value)) { + return null; + } + + if (preg_match('!^\d{2}/\d{2}/\d{2}$!', $value)) { + return \DateTime::createFromFormat('!d/m/y', $value)->format('Y-m-d'); + } + elseif (preg_match('!^\d{2}/\d{2}/\d{4}$!', $value)) { + return \DateTime::createFromFormat('!d/m/Y', $value)->format('Y-m-d'); + } + elseif (preg_match('!^\d{4}-\d{2}-\d{2}$!', $value)) { + return $value; + } + else { + return false; + } + } + + static public function parse_datetime($value) + { + if ($value instanceof \DateTimeInterface) { + return $value->format('Y-m-d H:i:s'); + } + + if (empty($value) || !is_string($value)) { + return null; + } + + if (preg_match('!^\d{2}/\d{2}/\d{4}$\s+\d{2}:\d{2}!', $value)) { + return \DateTime::createFromFormat('!d/m/Y', $value)->format('Y-m-d H:i'); + } + elseif (preg_match('!^\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}(:\d{2})?$!', $value, $match)) { + return $value . (isset($match[1]) ? '' : ':00'); + } + else { + return false; + } + } + + static public function parse_time($value) + { + if ($value instanceof \DateTimeInterface) { + return $value->format('H:i'); + } + + if (empty($value) || !is_string($value)) { + return null; + } + + if (false !== strpos($value, ':')) { + $t = explode(':', $value); + } + elseif (false !== strpos($value, 'h')) { + $t = explode('h', $value); + } + else { + return null; + } + + if (empty($t[0]) || !ctype_digit($t[0]) || $t[0] < 0 || $t[0] > 23) { + return false; + } + + if (empty($t[1]) || !ctype_digit($t[1]) || $t[1] < 0 || $t[1] > 59) { + return false; + } + + return sprintf('%02d:%02d', $t[0], $t[1]); + } + + static public function math(string $expression, ... $params) + { + static $tokens_list = [ + 'function' => '(?:round|ceil|floor|cos|sin|tan|asin|acos|atan|sinh|cosh|tanh|abs|max|min|exp|sqrt|log10|log|pi|random_int)\(', + 'open' => '\(', + 'close' => '\)', + 'number' => '-?\d+(?:[\.]\d+)?', + 'sign' => '[+\-\*\/%]', + 'separator' => ',', + 'space' => '\s+', + ]; + + // Treat comma as dot in strings + foreach ($params as &$param) { + $param = str_replace(',', '.', (string)$param); + } + + unset($param); + + $expression = vsprintf($expression, $params); + + try { + $tokens = Brindille::tokenize($expression, $tokens_list); + } + catch (\InvalidArgumentException $e) { + throw new Brindille_Exception('Invalid value: ' . $e->getMessage()); + } + + $stack = []; + $expression = ''; + + foreach ($tokens as $i => $token) { + if ($token->type == 'function') { + $stack[] = ['function' => $token->value, 'value' => &$value]; + } + elseif ($token->type == 'open') { + $stack[] = ['function' => null, 'value' => &$value]; + } + elseif ($token->type == 'close') { + $last = array_pop($stack); + + if (!$last) { + throw new Brindille_Exception('Invalid closing parenthesis, on position ' . $token->offset); + } + } + elseif ($token->type == 'separator') { + if (empty(end($stack)['function'])) { + throw new Brindille_Exception('Invalid comma outside of a function, on position ' . $token->offset); + } + } + elseif ($token->type === 'number') { + // Add spaces around numbers, so that 0--1 is treated as 0 - -1 = 0 + 1 + $token->value = ' ' . $token->value . ' '; + } + elseif ($token->type === 'sign') { + if ($tokens[$i-1]->type === 'sign') { + throw new Brindille_Exception('Invalid sign following a sign, on position ' . $token->offset); + } + } + + $expression .= $token->value; + } + + if (count($stack)) { + throw new Brindille_Exception('Unmatched open parenthesis, on position ' . $token->offset); + } + + try { + return @eval('return ' . $expression . ';') ?: 0; + } + catch (\Throwable $e) { + throw new Brindille_Exception(sprintf('Syntax error: "%s" (in "%s")', $e->getMessage(), $expression), 0, $e); + } + } + + static public function map(UserTemplate $tpl, int $line, $array, string $modifier, ...$params): array + { + if (!is_array($array)) { + throw new Brindille_Exception('Supplied argument is not an array'); + } + + $callback = null; + + if (!$tpl->checkModifierExists($modifier)) { + throw new Brindille_Exception('Unknown modifier: ' . $modifier); + } + + $out = []; + + foreach ($array as $key => $value) { + $out[$key] = $tpl->callModifier($modifier, $line, $value, ...$params); + } + + return $out; + } + + static public function gettype($v): string + { + $type = gettype($v); + + switch($type) { + case 'object': + return 'array'; + case 'double': + return 'float'; + case 'NULL': + return 'null'; + case 'resource': + case 'resource (closed)': + case 'unknown type': + throw new \LogicException('Unexpected type: ' . $type); + default: + return $type; + } + } + + static public function arrayval($v): array + { + return (array) $v; + } + + static public function explode($string, string $separator): array + { + return explode($separator, (string)$string); + } + + static public function implode($array, string $separator): ?string + { + if (!is_array($array) && !is_object($array)) { + return $array; + } + + return implode($separator, (array) $array); + } + + static public function keys($array) + { + return array_keys((array)$array); + } + + static public function key($array, $key) + { + return $array[$key] ?? null; + } + + static public function values($array) + { + return array_values((array)$array); + } + + static public function has($in, $value, $strict = false) + { + return in_array($value, (array)$in, $strict); + } + + static public function in($value, $array, $strict = false) + { + return in_array($value, (array)$array, $strict); + } + + static public function has_key($in, $key) + { + return array_key_exists($key, (array)$in); + } + + static public function key_in($key, $array) + { + return array_key_exists($key, (array)$array); + } + + static public function ksort($value) + { + $value = (array)$value; + uksort($value, 'strnatcasecmp'); + return $value; + } + + static public function sort($value) + { + $value = (array)$value; + natcasesort($value); + return $value; + } + + static public function max($value) + { + return max((array)$value); + } + + static public function min($value) + { + return min((array)$value); + } + + static public function quote_sql_identifier($in, string $prefix = '') + { + if (null === $in) { + return ''; + } + + $db = DB::getInstance(); + + if ($prefix) { + $prefix = $db->quoteIdentifier($prefix) . '.'; + } + + if (is_array($in) || is_object($in)) { + return array_map(fn($a) => $prefix . $db->quoteIdentifier($a), (array) $in); + } + + return $prefix . $db->quoteIdentifier($in); + } + + static public function quote_sql($in) + { + if (null === $in) { + return ''; + } + + $db = DB::getInstance(); + + if (is_array($in) || is_object($in)) { + return array_map([$db, 'quote'], (array) $in); + } + + return $db->quote($in); + } + + static public function sql_where(...$args) + { + return DB::getInstance()->where(...$args); + } + + static public function sql_user_fields($list, string $prefix = '', string $glue = ' '): string + { + $db = DB::getInstance(); + $prefix = $prefix ? $db->quoteIdentifier($prefix) . '.' : ''; + $out = []; + $glue = $db->quote($glue); + + foreach ((array) $list as $field) { + if (!DynamicFields::get($field)) { + continue; + } + + $out[] = sprintf('COALESCE(%s || %s%s, \'\')', $glue, $prefix, $db->quoteIdentifier($field)); + } + + if (!count($out)) { + return 'NULL'; + } + + return sprintf('LTRIM(%s, %s)', implode(' || ', $out), $glue); + } + + static public function urlencode($str): string + { + return rawurlencode($str ?? ''); + } + + static public function count_words($str): int + { + return preg_match_all('/\S+/u', $str); + } + + static public function or($in, $else) + { + if (empty($in) || (is_string($in) && trim($in) === '')) { + return $else; + } + + return $in; + } + + static public function uuid() + { + return Utils::uuid(); + } +} diff --git a/src/include/lib/Paheko/UserTemplate/Modules.php b/src/include/lib/Paheko/UserTemplate/Modules.php new file mode 100644 index 0000000..ba37b06 --- /dev/null +++ b/src/include/lib/Paheko/UserTemplate/Modules.php @@ -0,0 +1,465 @@ +begin(); + + $existing = $db->getAssoc(sprintf('SELECT id, name FROM %s;', Module::TABLE)); + $list = self::listRaw(); + + $create = array_diff($list, $existing); + $delete = array_diff($existing, $list); + $existing = array_diff($list, $create); + + $errors = []; + + foreach ($create as $name) { + try { + self::create($name); + } + catch (ValidationException $e) { + $errors[] = $name . ': ' . $e->getMessage(); + } + } + + foreach ($delete as $name) { + self::get($name)->delete(); + } + + foreach ($existing as $name) { + try { + $f = self::get($name); + $f->updateFromINI(); + $f->save(); + $f->updateTemplates(); + } + catch (ValidationException $e) { + $errors[] = $name . ': ' . $e->getMessage(); + } + } + + if (!$db->test(Module::TABLE, 'web = 1 AND enabled = 1')) { + $db->exec('UPDATE modules SET enabled = 1 WHERE id = (SELECT id FROM modules WHERE web = 1 ORDER BY system DESC LIMIT 1);'); + } + + $db->commit(); + + return $errors; + } + + static public function refreshEnabledModules(): void + { + $db = DB::getInstance(); + $db->begin(); + + foreach (self::list() as $module) { + try { + $module->updateFromINI(); + $module->save(); + $module->updateTemplates(); + } + catch (ValidationException $e) { + // Ignore errors here + } + } + + $db->commit(); + } + + /** + * List modules names from locally installed directories + */ + static public function listRaw(bool $include_installed = true): array + { + $list = []; + + // First list modules bundled + foreach (glob(Module::DIST_ROOT . '/*') as $file) { + if (!is_dir($file)) { + continue; + } + + $name = Utils::basename($file); + $list[$name] = $name; + } + + if ($include_installed) { + // Then add modules in files + foreach (Files::list(Module::ROOT) as $file) { + if ($file->type != $file::TYPE_DIRECTORY) { + continue; + } + + $list[$file->name] = $file->name; + } + } + + sort($list); + return $list; + } + + /** + * List locally installed modules, directly from the filesystem, without creating them in the database cache + * (used in Install form) + */ + static public function listLocal(): array + { + $list = self::listRaw(false); + $out = []; + + foreach ($list as $name) { + $m = new Module; + $m->name = $name; + + if (!$m->updateFromINI(false)) { + continue; + } + + $out[$name] = $m; + } + + return $out; + } + + static public function create(string $name): ?Module + { + $module = new Module; + $module->name = $name; + + if (!$module->updateFromINI()) { + return null; + } + + $module->save(); + $module->updateTemplates(); + return $module; + } + + /** + * List modules from the database + */ + static public function list(): array + { + return EM::getInstance(Module::class)->all('SELECT * FROM @TABLE ORDER BY label COLLATE NOCASE ASC;'); + } + + static public function snippetsAsString(string $snippet, array $variables = []): string + { + return implode("\n", self::snippets($snippet, $variables)); + } + + static public function snippets(string $snippet, array $variables = []): array + { + $out = []; + + foreach ($variables as &$var) { + if (is_object($var) && $var instanceof User) { + $var = $var->asModuleArray(); + } + } + + unset($var); + + foreach (self::listForSnippet($snippet) as $module) { + // Maybe the cache was wrong and the template doesn't exist anymore + if (!$module->hasFile($snippet)) { + continue; + } + + $out[$module->name] = $module->fetch($snippet, $variables); + } + + return array_filter($out, fn($a) => trim($a) !== ''); + } + + static public function listForSnippet(string $snippet): array + { + return EM::getInstance(Module::class)->all('SELECT f.* FROM @TABLE f + INNER JOIN modules_templates t ON t.id_module = f.id + WHERE t.name = ? AND f.enabled = 1 + ORDER BY f.label COLLATE NOCASE ASC;', $snippet); + } + + static public function get(string $name): ?Module + { + return EM::findOne(Module::class, 'SELECT * FROM @TABLE WHERE name = ?;', $name); + } + + static public function isEnabled(string $name): bool + { + return (bool) EM::getInstance(Module::class)->col('SELECT 1 FROM @TABLE WHERE name = ? AND enabled = 1;', $name); + } + + static public function getWeb(): Module + { + $module = EM::findOne(Module::class, 'SELECT * FROM @TABLE WHERE web = 1 AND enabled = 1 LIMIT 1;'); + + // Just in case + if (!$module) { + $module = EM::findOne(Module::class, 'SELECT * FROM @TABLE WHERE web = 1 LIMIT 1;'); + + if (!$module) { + // Maybe we need to rescan modules? + self::refresh(); + $module = EM::findOne(Module::class, 'SELECT * FROM @TABLE WHERE web = 1 LIMIT 1;'); + + if (!$module) { + throw new \LogicException('No web module exists'); + } + } + + $module->set('enabled', true); + $module->save(); + } + + return $module; + } + + static public function route(string $uri): void + { + $page = null; + $path = null; + $has_local_file = null; + $has_dist_file = null; + + // We are looking for a module + if (substr($uri, 0, 2) == 'm/') { + $path = substr($uri, 2); + $name = strtok($path, '/'); + $path = strtok(''); + $module = self::get($name); + + if (!$module) { + http_response_code(404); + throw new UserException('This page does not exist.'); + } + } + // Or: we are looking for the "web" module + else { + $module = self::getWeb(); + } + + // If path ends with trailing slash, then ask for index.html + if (!$path || substr($path, -1) == '/') { + $path .= 'index.html'; + } + + // Find out web path + if ($module->web && $module->enabled && substr($uri, 0, 2) !== 'm/') { + $uri = rawurldecode($uri); + + if ($uri == '') { + $path = 'index.html'; + } + elseif ($module->hasLocalFile($uri)) { + $path = $uri; + $has_local_file = true; + } + elseif ($module->hasDistFile($uri)) { + $path = $uri; + $has_dist_file = true; + } + elseif (($page = Web::getByURI($uri)) && $page->status == Page::STATUS_ONLINE) { + $path = $page->template(); + $page = $page->asTemplateArray(); + } + else { + $path = '404.html'; + } + } + // 404 if module is not enabled, except for icon + elseif (!$module->enabled && !$module->system && $path != Module::ICON_FILE) { + http_response_code(404); + throw new UserException('This page is currently disabled.'); + } + + // Restrict access + if (isset($module->restrict_section, $module->restrict_level)) { + $session = Session::getInstance(); + + if (!$session->isLogged()) { + Utils::redirect('!login.php'); + } + + $session->requireAccess($module->restrict_section, $module->restrict_level); + } + + $has_local_file ??= $module->hasLocalFile($path); + $has_dist_file ??= !$has_local_file && $module->hasDistFile($path); + + // Check if the file actually exists in the module + if (!$has_local_file && !$has_dist_file) { + + http_response_code(404); + throw new UserException('This path does not exist, sorry.'); + } + + $module->serve($path, $has_local_file, compact('uri', 'page')); + } + + static public function import(string $path, bool $overwrite = false): ?Module + { + $zip = new ZipReader; + + try { + $zip->open($path); + } + catch (\OutOfBoundsException $e) { + throw new \InvalidArgumentException('Invalid ZIP file: ' . $e->getMessage(), 0, $e); + } + + $module_name = null; + $files = []; + + foreach ($zip->iterate() as $name => $file) { + if ($name == 'modules' || $file['dir']) { + continue; + } + + if (strpos($name, 'modules/') !== 0) { + continue; + } + + $_mod = strtok(substr($name, strlen('modules/')), '/'); + $_name = strtok(''); + + if (!$module_name) { + if (!$_mod || !preg_match(Module::VALID_NAME_REGEXP, $_mod)) { + throw new \InvalidArgumentException('Invalid module name (allowed: [a-z][a-z0-9]*(_[a-z0-9])*): ' . $_mod); + } + + $module_name = $_mod; + } + elseif ($module_name !== $_mod) { + throw new \InvalidArgumentException('Two different modules names found.'); + } + + $files[$_name] = $name; + } + + if (!$module_name || !count($files)) { + throw new \InvalidArgumentException('No module found in archive'); + } + + if (!array_key_exists('module.ini', $files)) { + throw new \InvalidArgumentException('Missing "module.ini" file in module'); + } + + $base = File::CONTEXT_MODULES . '/' . $module_name; + + if (Files::exists($base) && !$overwrite) { + return null; + } + + try { + $module = self::get($module_name); + + if (!$module) { + $module = new Module; + $module->name = $module_name; + } + + foreach ($files as $local_name => $source) { + $content = $zip->fetch($source); + + // Don't store file if it already exists and is the same + if ($module->hasLocalFile($local_name) && ($file = Files::get($base . '/' . $local_name))) { + if ($file->md5 == md5($content)) { + continue; + } + } + + // Same for dist file + if ($dist_file = $module->fetchDistFile($local_name)) { + if (md5($dist_file) == md5($content)) { + continue; + } + } + + Files::createFromString($base . '/' . $local_name, $content); + } + + if (!$module->updateFromINI()) { + throw new ValidationException('Le fichier module.ini est invalide.'); + } + + $module->save(); + $module->updateTemplates(); + + return $module; + } + catch (ValidationException $e) { + $dir = Files::get($base); + + // Delete any extracted files so far + if ($dir) { + $dir->delete(); + } + + throw new \InvalidArgumentException('Invalid file: ' . $e->getMessage(), 0, $e); + } + catch (\Exception $e) { + $dir = Files::get($base); + + // Delete any extracted files so far + if ($dir) { + $dir->delete(); + } + + throw $e; + } + finally { + unset($zip); + } + } +} diff --git a/src/include/lib/Paheko/UserTemplate/Sections.php b/src/include/lib/Paheko/UserTemplate/Sections.php new file mode 100644 index 0000000..38dfc0f --- /dev/null +++ b/src/include/lib/Paheko/UserTemplate/Sections.php @@ -0,0 +1,1361 @@ + [self::class, 'selectStart'], + '/select' => [self::class, 'selectEnd'], + '#form' => [self::class, 'formStart'], + '/form' => [self::class, 'formEnd'], + 'else:form' => [self::class, 'formElse'], + '#capture' => [self::class, 'captureStart'], + '/capture' => [self::class, 'captureEnd'], + ]; + + const SQL_RESERVED_PARAMS = [ + 'select', + 'tables', + 'where', + 'group', + 'having', + 'order', + 'begin', + 'limit', + 'assign', + 'debug', + 'count', + ]; + + /** + * List of tables and columns that are restricted in SQL queries + * + * ~column means the column will always be returned as NULL + * -column or !table means trying to access this column or table will return an error + * see KD2/DB/SQLite3 code for details + * + * Note: column restrictions are only possible with PHP >= 8.0 + */ + const SQL_TABLES = [ + // Allow access to all tables + '*' => null, + // Restrict access to private fields in users + 'users' => ['~password', '~pgp_key', '~otp_secret'], + // Restrict access to some private tables + '!emails' => null, + '!emails_queue' => null, + '!compromised_passwords_cache' => null, + '!compromised_passwords_cache_ranges' => null, + '!api_credentials' => null, + '!plugins_signals' => null, + '!config' => null, + '!users_sessions' => null, + '!logs' => null, + ]; + + static protected $_cache = []; + + static public function selectStart(string $name, string $sql, UserTemplate $tpl, int $line): string + { + $sql = strtok($sql, ';'); + $extra_params = strtok(''); + + $i = 0; + $params = ''; + + $sql = preg_replace_callback('/\{(.*?)\}/', function ($match) use (&$params, &$i) { + // Raw SQL + if ('!' === substr($match[1], 0, 1)) { + $params .= ' !' . $i . '=' . substr($match[1], 1); + return '!' . $i++; + } + else { + $params .= ' :p' . $i . '=' . $match[1]; + return ':p' . $i++; + } + }, $sql); + + $sql = 'SELECT ' . $sql; + $sql = var_export($sql, true); + + $params .= ' sql=' . $sql . ' ' . $extra_params; + + return $tpl->_section('sql', $params, $line); + } + + static public function selectEnd(string $name, string $params, UserTemplate $tpl, int $line): string + { + return $tpl->_close('sql', '{{/select}}'); + } + + static public function formStart(string $name, string $params_str, UserTemplate $tpl, int $line): string + { + $tpl->_push($tpl::SECTION, 'form'); + + $params = $tpl->_parseArguments($params_str, $line); + + if (isset($params['on']) + && ($on = $tpl->getValueFromArgument($params['on'])) + && preg_match('/^[a-z0-9-]+$/i', $on)) { + $if = sprintf('$_POST[%s]', var_export($on, true)); + } + else { + $if = '$_POST'; + } + + unset($params['on']); + $params = $tpl->_exportArguments($params); + + return sprintf(''; + /* + . sprintf('$params = %s; ', $params) + . '$form_errors = []; ' + . 'if (!\KD2\Form::check(\'form_\' . $hash, $rules, $form_errors)) { ' + . '$this->assign(\'form_errors\', \KD2\Form::getErrorMessages($form_errors, \'fr\')); ' + . '} ?>'; + */ + } + + static public function formElse(string $name, string $params_str, UserTemplate $tpl, int $line): string + { + return 'assign(\'form_errors\', [$e->getMessage()]); ' + . '?>'; + } + + static public function formEnd(string $name, string $params_str, UserTemplate $tpl, int $line): string + { + if ($tpl->_lastName() !== 'form') { + throw new Brindille_Exception(sprintf('"%s": block closing does not match last block "%s" opened', $name . $params_str, $tpl->_lastName())); + } + + $type = $tpl->_lastType(); + $tpl->_pop(); + + $out = ''; + + if ($type === $tpl::SECTION) { + $out .= self::formElse($name, $params_str, $tpl, $line); + } + + $out .= ''; + + $out = str_replace(' ?>_parseArguments($params_str, $line); + + if (!isset($params['assign']) || !is_string($params['assign'])) { + throw new Brindille_Exception(sprintf('"%s": missing "assign" parameter', $name)); + } + + $assign = $tpl->getValueFromArgument($params['assign']); + + $tpl->_push($tpl::SECTION, 'capture'); + + return sprintf('', + var_export($assign, true)); + } + + static public function captureEnd(string $name, string $params_str, UserTemplate $tpl, int $line): string + { + $last = $tpl->_lastName(); + + if ($last !== 'capture') { + throw new Brindille_Exception(sprintf('"%s": block closing does not match last block "%s" opened', $name . $params_str, $last)); + } + + $tpl->_pop(); + + return sprintf('assign(array_pop($capture_assign), ob_get_clean()); ?>'); + } + + static protected function _debug(string $str): void + { + echo sprintf('
%s
', htmlspecialchars($str)); + } + + static protected function _debugExplain(string $sql): void + { + $explain = ''; + + try { + $r = DB::getInstance()->get('EXPLAIN QUERY PLAN ' . $sql); + + foreach ($r as $e) { + $explain .= $e->detail . "\n"; + } + } + catch (DB_Exception $e) { + $explain = 'Error: ' . $e->getMessage(); + } + + self::_debug($explain); + } + + static protected function cache(string $id, callable $callback) + { + if (!array_key_exists($id, self::$_cache)) { + self::$_cache[$id] = $callback(); + } + + return self::$_cache[$id]; + } + + /** + * Creates indexes for json_extract expressions + */ + static protected function _createModuleIndexes(string $table, string $where): void + { + preg_match_all('/json_extract\s*\(\s*document\s*,\s*(?:\'(.*?)\'|\"(.*?)\")\s*\)/', $where, $match, PREG_SET_ORDER); + + if (!count($match)) { + return; + } + + $search_params = []; + + foreach ($match as $m) { + $search_params[$m[2] ?? $m[1]] = $m[0]; + } + + if (!count($search_params)) { + return; + } + + ksort($search_params); + $hash = sha1(implode('', array_keys($search_params))); + + $db = DB::getInstance(); + + try { + $db->exec(sprintf('CREATE INDEX IF NOT EXISTS %s_auto_%s ON %1$s (%s);', $table, $hash, implode(', ', $search_params))); + } + catch (DB_Exception $e) { + throw new Brindille_Exception(sprintf("à la ligne %d, impossible de créer l'index, erreur SQL :\n%s\n\nRequête exécutée :\n%s", $line, $db->lastErrorMsg(), $sql)); + } + } + + static public function load(array $params, UserTemplate $tpl, int $line): \Generator + { + $name = $params['module'] ?? ($tpl->module->name ?? null); + + if (!$name) { + throw new Brindille_Exception('Unique module name could not be found'); + } + + $table = 'module_data_' . $name; + $params['tables'] = $table; + + $db = DB::getInstance(); + $has_table = $db->test('sqlite_master', 'type = \'table\' AND name = ?', $table); + + if (!$has_table) { + return; + } + + $delete_table = null; + + // Cannot use json_each with authorizer before SQLite 3.41.0 + // @see https://sqlite.org/forum/forumpost/d28110be11 + if (isset($params['each']) && !$db->hasFeatures('json_each_readonly')) { + $t = 'module_tmp_each' . md5($params['each']); + + // We create a temporary table, to get around authorizer issues in SQLite + $db->exec(sprintf('DROP TABLE IF EXISTS %s; CREATE TEMP TABLE IF NOT EXISTS %1$s (id, key, value, document);', $t)); + $db->exec(sprintf('INSERT INTO %s SELECT a.id, a.key, value, a.document FROM %s AS a, json_each(a.document, %s);', + $t, $table, $db->quote('$.' . trim($params['each'])) + )); + + $params['tables'] = $t; + $params['select'] = 'value'; + unset($params['each']); + } + elseif (isset($params['each'])) { + $params['tables'] = sprintf('%s AS a, json_each(a.document, %s)', $table, $db->quote('$.' . trim($params['each']))); + unset($params['each']); + } + + if (!isset($params['where'])) { + $params['where'] = '1'; + } + else { + $params['where'] = self::_moduleReplaceJSONExtract($params['where'], $table); + } + + if (array_key_exists('key', $params)) { + $params['where'] .= ' AND key = :key'; + $params['limit'] = 1; + $params[':key'] = $params['key']; + unset($params['key']); + } + elseif (array_key_exists('id', $params)) { + $params['where'] .= ' AND id = :id'; + $params['limit'] = 1; + $params[':id'] = $params['id']; + unset($params['id']); + } + + // Replace '$.name = "value"' parameters with json_extract + foreach ($params as $key => $value) { + $k = substr($key, 0, 1); + if ($k == ':' || in_array($key, self::SQL_RESERVED_PARAMS)) { + continue; + } + + if (is_bool($value)) { + $v = '= ' . (int) $value; + } + elseif (null === $value) { + $v = 'IS NULL'; + } + else { + $v = sprintf(':quick_%s', sha1($key)); + $params[$v] = $value; + $v = '= ' . $v; + } + + $params['where'] .= sprintf(' AND json_extract(document, %s) %s', $db->quote('$.' . $key), $v); + unset($params[$key]); + } + + $s = 'id, key, document AS json'; + + if (isset($params['select'])) { + $params['select'] = $s . ', ' . self::_moduleReplaceJSONExtract($params['select'], $table); + } + else { + $params['select'] = $s; + } + + if (isset($params['group'])) { + $params['group'] = self::_moduleReplaceJSONExtract($params['group'], $table); + } + + if (isset($params['having'])) { + $params['having'] = self::_moduleReplaceJSONExtract($params['having'], $table); + } + + if (isset($params['order'])) { + $params['order'] = self::_moduleReplaceJSONExtract($params['order'], $table); + } + + // Try to create an index if required + self::_createModuleIndexes($table, $params['where']); + + $assign = $params['assign'] ?? null; + unset($params['assign']); + + $query = self::sql($params, $tpl, $line); + + foreach ($query as $row) { + if (isset($row['json'])) { + $json = json_decode($row['json'], true); + + if (is_array($json)) { + unset($row['json']); + $row = array_merge($row, $json); + } + } + + if (isset($assign)) { + $tpl::__assign(['var' => $assign, 'value' => $row], $tpl, $line); + } + + yield $row; + } + } + + static protected function _getModuleColumnsFromSchema(string $schema, ?string $columns, UserTemplate $tpl, int $line): array + { + $schema = Functions::_readFile($schema, 'schema', $tpl, $line); + $schema = json_decode($schema, true); + + if (!$schema) { + throw new Brindille_Exception(sprintf("ligne %d: impossible de lire le schéma:\n%s", + $line, json_last_error_msg())); + } + + if (empty($schema['properties'])) { + return []; + } + + $out = []; + + if (null !== $columns) { + $columns = explode(',', $columns); + $columns = array_map('trim', $columns); + } + else { + $columns = array_keys($schema['properties']); + } + + foreach ($columns as $key) { + $rule = $schema['properties'][$key] ?? null; + + // This column is not in the schema + if (!$rule) { + continue; + } + + $types = is_array($rule['type']) ? $rule['type'] : [$rule['type']]; + + $out[$key] = [ + 'label' => $rule['description'] ?? null, + 'select' => sprintf('json_extract(document, \'$.%s\')', $key), + '_json_decode' => in_array('array', $types) || in_array('object', $types), + ]; + } + + return $out; + } + + static public function _moduleReplaceJSONExtract(string $str, string $table): string + { + $str = str_replace('@TABLE', $table, $str); + + if (!strstr($str, '$')) { + return $str; + } + + $db = DB::getInstance(); + + return preg_replace_callback( + '/(?:([\w\d]+)\.)?\$(\$[\[\.][\w\d\.\[\]#]+)/', + fn ($m) => sprintf('json_extract(%sdocument, %s)', + !empty($m[1]) ? $db->quote($m[1]) . '.' : '', + $db->quote($m[2]) + ), + $str + ); + } + + static public function list(array $params, UserTemplate $tpl, int $line): \Generator + { + if (empty($params['schema']) && empty($params['select'])) { + throw new Brindille_Exception('Missing schema parameter'); + } + + $name = $params['module'] ?? ($tpl->module->name ?? null); + + if (!$name) { + throw new Brindille_Exception('Unique module name could not be found'); + } + + $table = 'module_data_' . $name; + + $db = DB::getInstance(); + $has_table = $db->test('sqlite_master', 'type = \'table\' AND name = ?', $table); + + if (!$has_table) { + return; + } + + if (!isset($params['where'])) { + $where = '1'; + } + else { + $where = self::_moduleReplaceJSONExtract($params['where'], $table); + } + + $columns = []; + + if (!empty($params['select'])) { + foreach (explode(';', $params['select']) as $i => $c) { + $c = trim($c); + + $pos = strripos($c, ' AS '); + + if ($pos) { + $select = trim(substr($c, 0, $pos)); + $label = str_replace("''", "'", trim(substr($c, $pos + 5), ' \'"')); + } + else { + $select = $c; + $label = null; + } + + if ($select === '*') { + throw new Brindille_Exception(sprintf('Line %d: "*" cannot be used in "select" parameter', $line)); + } + + $select = self::_moduleReplaceJSONExtract($select, $table); + + $columns['col' . ($i + 1)] = compact('label', 'select'); + } + + if (isset($params['order'])) { + if (!is_int($params['order']) && !ctype_digit($params['order'])) { + throw new Brindille_Exception(sprintf('Line %d: "order" parameter must be the number of the column (starting from 1)', $line)); + } + + $params['order'] = 'col' . (int)$params['order']; + } + } + else { + $columns = self::_getModuleColumnsFromSchema($params['schema'], $params['columns'] ?? null, $tpl, $line); + } + + $columns['id'] = []; + $columns['key'] = []; + $columns['document'] = []; + + $list = new DynamicList($columns, $table); + + static $reserved_keywords = ['max', 'order', 'desc', 'debug', 'explain', 'schema', 'columns', 'select', 'where', 'module', 'disable_user_ordering', 'check']; + + foreach ($params as $key => $value) { + if ($key[0] == ':') { + if (false !== strpos($where, $key)) { + $list->setParameter(substr($key, 1), $value); + } + } + elseif (!in_array($key, $reserved_keywords)) { + $hash = sha1($key); + $where .= sprintf(' AND json_extract(document, %s) = :quick_%s', $db->quote('$.' . $key), $hash); + $list->setParameter('quick_' . $hash, $value); + } + } + + $list->setConditions($where); + $list->setPageSize((int) ($params['max'] ?? 50)); + + if (isset($params['order'])) { + $list->orderBy($params['order'], $params['desc'] ?? false); + } + + $list->setModifier(function(&$row) use ($columns) { + $row->original = clone $row; + unset($row->original->id, $row->original->key, $row->original->document); + + // Decode arrays/objects + foreach ($columns as $name => $column) { + if (!empty($column['_json_decode']) && isset($row->$name) && is_string($row->$name)) { + $row->$name = json_decode($row->$name, true); + } + } + + if (null !== $row->document) { + $row = array_merge(json_decode($row->document, true), (array)$row); + } + else { + $row = (array) $row; + } + }); + + $list->setExportCallback(function(&$row) { + $row = $row['original']; + }); + + // Try to create an index if required + self::_createModuleIndexes($table, $where); + + if (empty($params['disable_user_ordering'])) { + $list->loadFromQueryString(); + } + + if (!empty($params['debug'])) { + self::_debug($list->SQL()); + } + + if (!empty($params['explain'])) { + self::_debugExplain($list->SQL()); + } + + try { + $i = $list->iterate(); + + // If there is nothing to iterate, just stop + if (!$i->valid()) { + return; + } + } + catch (DB_Exception $e) { + throw new Brindille_Exception(sprintf("Line %d: invalid SQL query: %s\nQuery: %s", $line, $e->getMessage(), $list->SQL())); + } + + $tpl = Template::getInstance(); + + /* + FIXME: Export is broken currently + $export_url = Utils::getSelfURI(); + $export_url .= strstr($export_url, '?') ? '&export=' : '?export='; + printf('

%s

', $tpl->widgetExportMenu(['href' => $export_url, 'class' => 'menu-btn-right'])); + */ + + $tpl->assign(compact('list')); + $tpl->assign('check', $params['check'] ?? false); + $tpl->assign('disable_user_ordering', $params['disable_user_ordering'] ?? false); + $tpl->display('common/dynamic_list_head.tpl'); + + yield from $i; + + echo ''; + echo ''; + + echo $list->getHTMLPagination(); + } + + static protected function getAccountCodeCondition($codes, string $column = 'code') + { + if (!is_array($codes)) { + $codes = explode(',', $codes); + } + + $db = DB::getInstance(); + + foreach ($codes as &$code) { + $code = $column . ' LIKE ' . $db->quote($code); + } + + unset($code); + + return implode(' OR ', $codes); + } + + static public function balances(array $params, UserTemplate $tpl, int $line): \Generator + { + $db = DB::getInstance(); + + $params['where'] ??= ''; + $params['tables'] = 'acc_accounts_balances'; + + if (isset($params['codes'])) { + $params['where'] .= sprintf(' AND (%s)', self::getAccountCodeCondition($params['codes'])); + unset($params['codes']); + } + + if (isset($params['year'])) { + $params['where'] .= ' AND id_year = :year'; + $params[':year'] = $params['year']; + unset($params['year']); + } + + $params['select'] = $params['select'] ?? 'SUM(credit) AS credit, SUM(debit) AS debit, SUM(balance) AS balance, label, code'; + + return self::sql($params, $tpl, $line); + } + + static public function accounts(array $params, UserTemplate $tpl, int $line): \Generator + { + $db = DB::getInstance(); + + $params['where'] ??= ''; + $params['tables'] = 'acc_accounts'; + + if (isset($params['codes'])) { + $params['codes'] = explode(',', $params['codes']); + + foreach ($params['codes'] as &$code) { + $code = 'code LIKE ' . $db->quote($code); + } + + $params['where'] .= sprintf(' AND (%s)', implode(' OR ', $params['codes'])); + + unset($code, $params['codes']); + } + elseif (isset($params['id'])) { + $params['where'] .= ' AND id = :id'; + $params[':id'] = (int) $params['id']; + unset($params['id']); + } + + return self::sql($params, $tpl, $line); + } + + static public function years(array $params, UserTemplate $tpl, int $line): \Generator + { + $params['tables'] = 'acc_years'; + + $params['where'] ??= ''; + + if (isset($params['closed'])) { + $params['where'] .= sprintf(' AND closed = %d', $params['closed']); + unset($params['closed']); + } + + return self::sql($params, $tpl, $line); + } + + static public function users(array $params, UserTemplate $tpl, int $line): \Generator + { + $params['where'] ??= ''; + + $db = DB::getInstance(); + + $id_field = DynamicFields::getNameFieldsSQL('u'); + $login_field = DynamicFields::getLoginField(); + $number_field = DynamicFields::getNumberField(); + $email_field = DynamicFields::getFirstEmailField(); + + if (empty($params['select'])) { + $params['select'] = 'u.*'; + } + + $params['select'] .= sprintf(', u.id AS id, %s AS _name, u.%s AS _login, u.%s AS _number, u.%s AS _email', + $id_field, + $db->quoteIdentifier($login_field), + $db->quoteIdentifier($number_field), + $db->quoteIdentifier($email_field) + ); + + $params['tables'] = 'users_view AS u'; + + if (isset($params['id']) && is_array($params['id'])) { + $params['id'] = array_map('intval', $params['id']); + $params['where'] .= ' AND u.' . $db->where('id', $params['id']); + unset($params['id']); + } + elseif (isset($params['id'])) { + $params['where'] .= ' AND u.id = :id'; + $params[':id'] = (int) $params['id']; + unset($params['id']); + } + elseif (isset($params['id_parent'])) { + $params['where'] .= ' AND u.id_parent = :id_parent'; + $params[':id_parent'] = (int) $params['id_parent']; + unset($params['id_parent']); + } + + if (!empty($params['search_name'])) { + $params['tables'] .= sprintf(' INNER JOIN users_search AS us ON us.id = u.id AND %s LIKE :search_name ESCAPE \'\\\' COLLATE NOCASE', + DynamicFields::getNameFieldsSearchableSQL('us')); + $params[':search_name'] = '%' . Utils::unicodeTransliterate($params['search_name']) . '%'; + unset($params['search_name']); + } + + if (empty($params['order'])) { + $params['order'] = 'u.id'; + } + + $files_fields = array_keys(DynamicFields::getInstance()->fieldsByType('file')); + + foreach (self::sql($params, $tpl, $line) as $row) { + foreach ($row as $key => &$value) { + if (in_array($key, $files_fields)) { + $value = array_map(fn($a) => $a->export(), array_values(Files::list(File::CONTEXT_USER . '/' . $row['id'] . '/' . $key))); + } + } + + unset($value); + + yield $row; + } + } + + static public function subscriptions(array $params, UserTemplate $tpl, int $line): \Generator + { + $params['where'] ??= ''; + + $number_field = DynamicFields::getNumberField(); + + $params['select'] = sprintf('su.expiry_date, su.date, s.label, su.paid, su.expected_amount'); + $params['tables'] = 'services_users su INNER JOIN services s ON s.id = su.id_service'; + + if (isset($params['user'])) { + $params['where'] .= ' AND su.id_user = :id_user'; + $params[':id_user'] = (int) $params['user']; + unset($params['user']); + } + + if (isset($params['id_service'])) { + $params['where'] .= ' AND su.id_service = :id_service'; + $params[':id_service'] = (int) $params['id_service']; + unset($params['id_service']); + } + + if (!empty($params['active'])) { + $params['having'] = 'MAX(su.expiry_date) >= date()'; + unset($params['active']); + } + + if (isset($params['active']) && empty($params['active'])) { + $params['having'] = 'MAX(su.expiry_date) < date()'; + unset($params['active']); + } + + if (empty($params['order'])) { + $params['order'] = 'su.id'; + } + + $params['group'] = 'su.id_user, su.id_service'; + + return self::sql($params, $tpl, $line); + } + + static public function transactions(array $params, UserTemplate $tpl, int $line): \Generator + { + $db = DB::getInstance(); + $params['where'] ??= ''; + + $id_field = DynamicFields::getNameFieldsSQL(); + + $params['select'] = sprintf('t.*, SUM(l.credit) AS credit, SUM(l.debit) AS debit, + GROUP_CONCAT(DISTINCT a.code) AS accounts_codes, + (SELECT GROUP_CONCAT(DISTINCT %s) FROM users WHERE id IN (SELECT id_user FROM acc_transactions_users WHERE id_transaction = t.id)) AS users_names', $id_field); + $params['tables'] = 'acc_transactions AS t + INNER JOIN acc_transactions_lines AS l ON l.id_transaction = t.id + INNER JOIN acc_accounts AS a ON l.id_account = a.id'; + $params['group'] = 't.id'; + + if (isset($params['id']) && is_array($params['id'])) { + $params['where'] .= ' AND t.' . $db->where('id', array_map('intval', $params['id'])); + unset($params['id']); + } + elseif (isset($params['id'])) { + $params['where'] .= ' AND t.id = :id'; + $params[':id'] = (int) $params['id']; + unset($params['id']); + } + elseif (isset($params['user'])) { + $params['where'] .= ' AND t.id IN (SELECT id_transaction FROM acc_transactions_users WHERE id_user = :id_user)'; + $params[':id_user'] = (int) $params['user']; + unset($params['user']); + } + + if (isset($params['debit_codes'])) { + $params['where'] .= sprintf(' AND l.credit = 0 AND (%s)', self::getAccountCodeCondition($params['debit_codes'], 'a.code')); + } + elseif (isset($params['credit_codes'])) { + $params['where'] .= sprintf(' AND l.debit = 0 AND (%s)', self::getAccountCodeCondition($params['credit_codes'], 'a.code')); + } + + unset($params['debit_codes'], $params['credit_codes']); + + if (isset($params['order']) && ctype_alpha(substr((string) $params['order'], 0, 1))) { + $params['order'] = 't.' . $params['order']; + } + + return self::sql($params, $tpl, $line); + } + + static public function transaction_lines(array $params, UserTemplate $tpl, int $line): \Generator + { + $params['where'] ??= ''; + + if (isset($params['transaction'])) { + $params['where'] .= ' AND l.id_transaction = :transaction'; + $params[':transaction'] = (int) $params['transaction']; + unset($params['transaction']); + } + + $id_field = DynamicFields::getNameFieldsSQL('u'); + + $params['select'] = sprintf('l.*, a.code AS account_code, a.label AS account_label'); + $params['tables'] = 'acc_transactions_lines AS l + INNER JOIN acc_accounts AS a ON l.id_account = a.id'; + + return self::sql($params, $tpl, $line); + } + + static public function transaction_users(array $params, UserTemplate $tpl, int $line): \Generator + { + $params['where'] ??= ''; + + if (isset($params['id_transaction'])) { + $params['where'] = ' AND tu.id_transaction = :id_transaction'; + $params[':id_transaction'] = (int) $params['id_transaction']; + unset($params['id_transaction']); + } + + $id_field = DynamicFields::getNameFieldsSQL('u'); + $email_field = DB::getInstance()->quoteIdentifier(DynamicFields::getFirstEmailField()); + + $params['select'] = sprintf('tu.*, %s AS name, %1$s AS _name, u.%s AS _email, u.*', $id_field, $email_field); + $params['tables'] = 'acc_transactions_users tu + INNER JOIN users u ON u.id = tu.id_user'; + + return self::sql($params, $tpl, $line); + } + + static public function restrict(array $params, UserTemplate $tpl, int $line): ?\Generator + { + $session = Session::getInstance(); + + if (!$session->isLogged()) { + if (!empty($params['block'])) { + if (!headers_sent()) { + // FIXME: implement redirect to correct URL after login + Utils::redirect('!login.php'); + } + + throw new UserException('Vous n\'avez pas accès à cette page.'); + } + + return null; + } + + if (empty($params['level']) && empty($params['section'])) { + yield []; + return null; + } + + $convert = [ + 'none' => $session::ACCESS_NONE, + 'read' => $session::ACCESS_READ, + 'write' => $session::ACCESS_WRITE, + 'admin' => $session::ACCESS_ADMIN, + ]; + + if (empty($params['level']) || !array_key_exists($params['level'], $convert)) { + throw new Brindille_Exception(sprintf("Ligne %d: 'restrict' niveau d'accès inconnu : %s", $line, $params['level'] ?? '')); + } + + if (empty($params['section']) || !in_array($params['section'], $session::SECTIONS)) { + throw new Brindille_Exception(sprintf("Ligne %d: 'restrict' section d'accès inconnu : %s", $line, $params['section'] ?? '')); + } + + $ok = $session->canAccess($params['section'], $convert[$params['level']]); + + if ($ok) { + yield []; + return null; + } + + if (!empty($params['block'])) { + throw new UserException('Vous n\'avez pas accès à cette page.'); + } + + return null; + } + + static public function breadcrumbs(array $params, UserTemplate $tpl, int $line): \Generator + { + if (isset($params['id_page'])) { + $id = (int) $params['id_page']; + } + elseif (isset($params['path']) || isset($params['uri'])) { + $id = self::_getPageIdFromPath($params['path'] ?? $params['uri']); + } + else { + throw new Brindille_Exception('"id_page", "uri" or "path" parameter is mandatory and is missing'); + } + + if (!$id) { + return; + } + + foreach (Web::getBreadcrumbs($id) as $row) { + $row->url = '/' . $row->uri; + yield (array) $row; + } + } + + static public function categories(array $params, UserTemplate $tpl, int $line): \Generator + { + $params['where'] ??= ''; + $params['where'] .= ' AND w.type = ' . Page::TYPE_CATEGORY; + return self::pages($params, $tpl, $line); + } + + static public function articles(array $params, UserTemplate $tpl, int $line): \Generator + { + $params['where'] ??= ''; + $params['where'] .= ' AND w.type = ' . Page::TYPE_PAGE; + return self::pages($params, $tpl, $line); + } + + static public function pages(array $params, UserTemplate $tpl, int $line): \Generator + { + $params['where'] ??= ''; + $params['select'] = 'w.*'; + $params['tables'] = 'web_pages w'; + $params['where'] .= ' AND status = :status'; + $params[':status'] = Page::STATUS_ONLINE; + + $allowed_tables = self::SQL_TABLES; + + if (array_key_exists('search', $params)) { + if (trim((string) $params['search']) === '') { + return; + } + + $params[':search'] = substr(trim($params['search']), 0, 100); + unset($params['search']); + + $params['tables'] .= ' INNER JOIN files_search ON files_search.path = \'web/\' || w.uri'; + $params['select'] .= ', rank(matchinfo(files_search), 0, 1.0, 1.0) AS points, snippet(files_search, \'\', \'\', \'…\', 2) AS snippet'; + $params['where'] .= ' AND files_search MATCH :search'; + + $params['order'] = 'points DESC'; + $params['limit'] = '30'; + + // There is a bug in SQLite3 < 3.41.0 + // where virtual tables (eg. FTS4) will trigger UPDATEs in the authorizer, + // making the request fail. + // So we will disable the authorizer here. + // From a security POV, this is a compromise, but in PHP < 8 there was no authorizer + // at all. + // @see https://sqlite.org/forum/forumpost/e11b51ca555f82147a1cbb58dc640b441e5f126cf6d7400753f62e82ca11ba88 + if (\SQLite3::version()['versionNumber'] < 3041000) { + $allowed_tables = null; + } + } + + if (isset($params['path'])) { + $params['uri'] = Utils::basename($params['path']); + unset($params['path']); + } + + if (isset($params['uri'])) { + $params['where'] .= ' AND w.uri = :uri'; + $params['limit'] = 1; + $params[':uri'] = $params['uri']; + unset($params['uri']); + } + + if (array_key_exists('parent', $params)) { + if (null === $params['parent']) { + $params['where'] .= ' AND w.id_parent IS NULL'; + } + else { + if (substr($params['parent'], 0, 1) === '!') { + $params['parent'] = substr($params['parent'], 1); + $params['where'] .= ' AND w.id_parent != (SELECT id FROM web_pages WHERE uri = :parent)'; + } + else { + $params['where'] .= ' AND w.id_parent = (SELECT id FROM web_pages WHERE uri = :parent)'; + } + + $params[':parent'] = Utils::basename(trim((string) $params['parent'])); + } + + unset($params['parent']); + } + + if (array_key_exists('id_parent', $params)) { + if (null === $params['id_parent']) { + $params['where'] .= ' AND w.id_parent IS NULL'; + } + else { + $params['where'] .= ' AND w.id_parent = :id_parent'; + $params[':id_parent'] = (int) $params['id_parent']; + } + + unset($params['id_parent']); + } + + if (isset($params['future'])) { + $params['where'] .= sprintf(' AND w.published %s datetime(\'now\', \'localtime\')', $params['future'] ? '>' : '<='); + unset($params['future']); + } + + foreach (self::sql($params, $tpl, $line, $allowed_tables) as $row) { + if (empty($params['count'])) { + $data = $row; + unset($data['points'], $data['snippet']); + + $page = new Page; + $page->exists(true); + $page->load($data); + + if (isset($row['snippet'])) { + $row['snippet'] = preg_replace('!(\s*)!', '$1', $row['snippet']); + if (preg_match('!(.*?)!', $row['snippet'], $match)) { + $row['url_highlight'] = $page->url() . '#:~:text=' . rawurlencode($match[1]); + } + else { + $row['url_highlight'] = $page->url(); + } + } + + $row = array_merge($row, $page->asTemplateArray()); + } + + yield $row; + } + } + + static public function images(array $params, UserTemplate $tpl, int $line): \Generator + { + $params['where'] ??= ''; + $params['where'] .= ' AND image = 1'; + return self::attachments($params, $tpl, $line); + } + + static public function documents(array $params, UserTemplate $tpl, int $line): \Generator + { + $params['where'] ??= ''; + $params['where'] .= ' AND image = 0'; + return self::attachments($params, $tpl, $line); + } + + static protected function _getPageIdFromPath(string $path): ?int + { + return self::cache('page_id_' . md5($path), function () use ($path) { + $db = DB::getInstance(); + return $db->firstColumn('SELECT id FROM web_pages WHERE uri = ?;', Utils::basename($path)) ?: null; + }); + } + + static public function attachments(array $params, UserTemplate $tpl, int $line): \Generator + { + $id = null; + + if (!empty($params['id_page'])) { + $id = (int)$params['id_page']; + } + elseif (!empty($params['parent'])) { + $id = self::_getPageIdFromPath($params['parent']); + } + else { + throw new Brindille_Exception('La section "attachments" doit obligatoirement comporter un paramètre "id_page" ou "parent"'); + } + + if (!$id) { + return; + } + + $db = DB::getInstance(); + $params['where'] ??= ''; + + // Fetch page + $page = self::cache('page_' . $id, function () use ($id, $db) { + $page = Web::get($id); + + if (!$page) { + return null; + } + + // Store attachments in temp table + $db->begin(); + $db->exec('CREATE TEMP TABLE IF NOT EXISTS web_pages_attachments (page_id, uri, path, name, modified, image, data);'); + + foreach ($page->listAttachments() as $file) { + if ($file->type != File::TYPE_FILE) { + continue; + } + + $row = $file->asArray(); + $row['title'] = str_replace(['_', '-'], ' ', $file->name); + $row['title'] = preg_replace('!\.[^\.]{3,5}$!', '', $row['title']); + $row['extension'] = strtoupper(preg_replace('!^.*\.([^\.]{3,5})$!', '$1', $file->name)); + $row['format'] = $file->getFormatDescription(); + $row['url'] = $file->url(); + $row['download_url'] = $file->url(true); + $row['thumb_url'] = $file->thumb_url(); + $row['small_url'] = $file->thumb_url(File::THUMB_SIZE_SMALL); + $row['large_url'] = $file->thumb_url(File::THUMB_SIZE_LARGE); + + $db->preparedQuery('INSERT OR REPLACE INTO web_pages_attachments VALUES (?, ?, ?, ?, ?, ?, ?);', + $page->id(), $file->uri(), $file->path, $file->name, $file->modified, $file->isImage(), json_encode($row)); + } + + $db->commit(); + + return $page; + }); + + // Page not found + if (!$page) { + return; + } + + $params['select'] = 'data'; + $params['tables'] = 'web_pages_attachments'; + $params['where'] .= ' AND page_id = :page'; + $params[':page'] = $page->id(); + unset($params['page']); + + // Generate a temporary table containing the list of files included in the text + if (!empty($params['except_in_text'])) { + // Don't regenerate that table for each section called in the page, + // we assume the content and list of files will not change between sections + self::cache('page_files_text_' . $id, function () use ($page, $db) { + $db->begin(); + + // Put files mentioned in the text in a temporary table + $db->exec('CREATE TEMP TABLE IF NOT EXISTS files_tmp_in_text (page_id, uri);'); + + foreach ($page->listTaggedAttachments() as $uri) { + $db->insert('files_tmp_in_text', ['page_id' => $page->id(), 'uri' => $uri]); + } + + + $db->commit(); + }); + + $params['where'] .= sprintf(' AND uri NOT IN (SELECT uri FROM files_tmp_in_text WHERE page_id = %d)', $page->id()); + } + + if (empty($params['order'])) { + $params['order'] = 'name'; + } + + if ($params['order'] == 'name') { + $params['order'] .= ' COLLATE U_NOCASE'; + } + + foreach (self::sql($params, $tpl, $line) as $row) { + yield json_decode($row['data'], true); + } + } + + static public function module(array $params, UserTemplate $tpl, int $line): \Generator + { + if (empty($params['name'])) { + throw new Brindille_Exception('Missing parameter "name"'); + } + + $module = Modules::get($params['name']); + + if (!$module || !$module->enabled) { + return null; + } + + $out = $module->asArray(); + $out['path'] = 'modules/' . $module->name; + $out['url'] = $module->url(); + $out['public_url'] = $module->public_url(); + + yield $out; + } + + static public function sql(array $params, UserTemplate $tpl, int $line, ?array $allowed_tables = self::SQL_TABLES): \Generator + { + static $defaults = [ + 'select' => '*', + 'order' => '1', + 'begin' => 0, + 'limit' => 10000, + 'where' => '', + ]; + + if (isset($params['sql'])) { + $sql = $params['sql']; + + // Replace raw SQL parameters (this is for #select section) + foreach ($params as $k => $v) { + if (substr($k, 0, 1) == '!') { + $r = '/' . preg_quote($k, '/') . '\b/'; + $sql = preg_replace($r, (string)$v, $sql); + } + } + } + else { + if (empty($params['tables'])) { + throw new Brindille_Exception(sprintf('"sql" section: missing parameter "tables" on line %d', $line)); + } + + foreach ($defaults as $key => $default_value) { + if (!isset($params[$key])) { + $params[$key] = $default_value; + } + } + + // Allow for count=true, count=1 and also count="DISTINCT user_id" count="id" + if (!empty($params['count'])) { + $params['select'] = sprintf('COUNT(%s) AS count', $params['count'] == 1 ? '*' : $params['count']); + $params['order'] = '1'; + } + + if (!empty($params['where']) && !preg_match('/^\s*AND\s+/i', $params['where'])) { + $params['where'] = ' AND ' . $params['where']; + } + + $sql = sprintf('SELECT %s FROM %s WHERE 1 %s %s %s ORDER BY %s LIMIT %d,%d;', + $params['select'], + $params['tables'], + $params['where'] ?? '', + isset($params['group']) ? 'GROUP BY ' . $params['group'] : '', + isset($params['having']) ? 'HAVING ' . $params['having'] : '', + $params['order'], + $params['begin'], + $params['limit'] + ); + } + + $db = DB::getInstance(); + + try { + // Lock database against changes + $db->setReadOnly(true); + + $statement = $db->protectSelect($allowed_tables, $sql); + + $args = []; + + foreach ($params as $key => $value) { + if (substr($key, 0, 1) == ':') { + if (is_object($value) || is_array($value)) { + throw new Brindille_Exception(sprintf("à la ligne %d : Section 'sql': le paramètre '%s' est un tableau.", $line, $key)); + } + + $args[substr($key, 1)] = $value; + } + } + + $result = $db->execute($statement, $args); + + if (!empty($params['debug'])) { + self::_debug($statement->getSQL(true)); + } + + if (!empty($params['explain'])) { + self::_debugExplain($statement->getSQL(true)); + } + + $db->setReadOnly(false); + } + catch (DB_Exception $e) { + throw new Brindille_Exception(sprintf("à la ligne %d erreur SQL :\n%s\n\nRequête exécutée :\n%s", $line, $db->lastErrorMsg(), $sql)); + } + + while ($row = $result->fetchArray(\SQLITE3_ASSOC)) + { + if (isset($params['assign'])) { + $tpl::__assign(['var' => $params['assign'], 'value' => $row], $tpl, $line); + } + + yield $row; + } + } +} diff --git a/src/include/lib/Paheko/UserTemplate/UserTemplate.php b/src/include/lib/Paheko/UserTemplate/UserTemplate.php new file mode 100644 index 0000000..02607fc --- /dev/null +++ b/src/include/lib/Paheko/UserTemplate/UserTemplate.php @@ -0,0 +1,649 @@ +setCode($content); + $tpl->toggleSafeMode(true); + $tpl->setEscapeDefault(null); + $templates[$hash] = $tpl; + + return $tpl; + } + + static public function getRootVariables() + { + if (null !== self::$root_variables) { + return self::$root_variables; + } + + static $keys = ['color1', 'color2', 'site_disabled', 'org_name', 'org_address', 'org_email', 'org_phone', 'org_web', 'org_infos', 'currency', 'country', 'files']; + + $config = Config::getInstance(); + + $files = $config::FILES; + + // Put URL in files array + array_walk($files, function (&$v, $k) use ($config) { + $v = $config->fileURL($k); + }); + + $config = array_intersect_key($config->asArray(), array_flip($keys)); + $config['files'] = $files; + + // @deprecated + // FIXME: remove in a future version + $config['nom_asso'] = $config['org_name']; + $config['adresse_asso'] = $config['org_address']; + $config['email_asso'] = $config['org_email']; + $config['telephone_asso'] = $config['org_phone']; + $config['site_asso'] = $config['org_web']; + $config['user_fields'] = [ + 'number' => DynamicFields::getNumberField(), + 'login' => DynamicFields::getLoginField(), + 'email' => DynamicFields::getEmailFields(), + 'name' => DynamicFields::getNameFields(), + 'name_sql' => DynamicFields::getNameFieldsSQL(), + ]; + + $session = Session::getInstance(); + $is_logged = $session->isLogged(); + + self::$root_variables = [ + 'version_hash' => Utils::getVersionHash(), + 'root_url' => WWW_URL, + 'root_uri' => WWW_URI, + 'request_url' => Utils::getRequestURI(), + 'admin_url' => ADMIN_URL, + 'base_url' => BASE_URL, + 'site_url' => $config['site_disabled'] && $config['org_web'] ? $config['org_web'] : WWW_URL, + '_GET' => &$_GET, + '_POST' => &$_POST, + 'visitor_lang' => Translate::getHttpLang(), + 'config' => $config, + 'now' => time(), + 'is_logged' => $is_logged, + 'logged_user' => $is_logged ? $session->getUser()->asModuleArray() : null, + 'dialog' => isset($_GET['_dialog']) ? ($_GET['_dialog'] ?: true) : false, + 'pdf_enabled' => PDF_COMMAND !== null, + ]; + + return self::$root_variables; + } + + public function __construct(?string $path = null) + { + if ($path) { + $path = trim($path, '/'); + } + + $this->_tpl_path = $path ?? ''; + + if ($path && $file = Files::get(File::CONTEXT_MODULES . '/' . $path)) { + $this->setLocalSource($file); + } + elseif ($path) { + $this->setSource(self::DIST_ROOT . $path); + } + + $this->assignArray(self::getRootVariables()); + + $this->registerAll(); + + Plugins::fire('usertemplate.init', false, ['template' => $this]); + } + + /** + * Toggle safe mode + * + * If set to TRUE, then all functions and sections are removed, except foreach. + * Only modifiers can be used. + * Useful for templates where you don't want the user to be able to do SQL queries etc. + * + * @param bool $enable + * @return void + */ + public function toggleSafeMode(bool $safe_mode): void + { + if ($safe_mode) { + $this->_functions = []; + $this->_sections = []; + $this->_blocks = []; + + // Register default Brindille modifiers + $this->registerDefaults(); + } + else { + $this->registerAll(); + } + } + + public function setEscapeDefault(?string $default): void + { + $this->escape_default = $default; + + if (null === $default) { + $this->registerModifier('escape', fn($str) => $str); + } + else { + $this->registerModifier('escape', fn ($str) => htmlspecialchars((string)$str) ); + } + } + + public function registerAll() + { + // Register default Brindille modifiers + $this->registerDefaults(); + + // Common modifiers + foreach (CommonModifiers::MODIFIERS_LIST as $key => $name) { + $this->registerModifier(is_int($key) ? $name : $key, is_int($key) ? [CommonModifiers::class, $name] : $name); + } + + foreach (CommonFunctions::FUNCTIONS_LIST as $key => $name) { + $this->registerFunction(is_int($key) ? $name : $key, is_int($key) ? [CommonFunctions::class, $name] : $name); + } + + // PHP modifiers + foreach (CommonModifiers::PHP_MODIFIERS_LIST as $name => $params) { + $this->registerModifier($name, [CommonModifiers::class, $name]); + } + + // Local modifiers + foreach (Modifiers::MODIFIERS_LIST as $key => $name) { + $this->registerModifier(is_int($key) ? $name : $key, is_int($key) ? [Modifiers::class, $name] : $name); + } + + foreach (Modifiers::MODIFIERS_WITH_INSTANCE_LIST as $key => $name) { + $this->registerModifier(is_int($key) ? $name : $key, is_int($key) ? [Modifiers::class, $name] : $name, true); + } + + // Local functions + foreach (Functions::FUNCTIONS_LIST as $name) { + $this->registerFunction($name, [Functions::class, $name]); + } + + foreach (Functions::COMPILE_FUNCTIONS_LIST as $name => $callback) { + $this->registerCompileBlock($name, $callback); + } + + // Local sections + foreach (Sections::SECTIONS_LIST as $name) { + $this->registerSection($name, [Sections::class, $name]); + } + + foreach (Sections::COMPILE_SECTIONS_LIST as $name => $callback) { + $this->registerCompileBlock($name, $callback); + } + } + + /** + * Load template code from a user-stored file + */ + public function setLocalSource(File $file) + { + if ($file->type != $file::TYPE_FILE) { + throw new \LogicException('Cannot construct a UserTemplate with a directory'); + } + + $this->file = $file; + $this->modified = $file->modified->getTimestamp(); + + $this->cache_path = USER_TEMPLATES_CACHE_ROOT; + } + + /** + * Load template code from a filesystem file + */ + public function setSource(string $path) + { + if (!($this->modified = @filemtime($path))) { + throw new \InvalidArgumentException('File not found: ' . $path); + } + + $this->file = null; + $this->path = $path; + + // Use shared cache for default templates + $this->cache_path = SHARED_USER_TEMPLATES_CACHE_ROOT; + } + + public function isTrusted(): bool + { + return isset($this->path) && !isset($this->code) && !isset($this->file); + } + + /** + * Load template code from a string + */ + public function setCode(string $code) + { + $this->code = $code; + $this->file = null; + $this->path = null; + $this->modified = time(); + // Use custom cache for user templates + $this->cache_path = USER_TEMPLATES_CACHE_ROOT; + } + + protected function _getCachePath() + { + $hash = sha1($this->file ? $this->file->path : ($this->code ?: $this->path)); + return sprintf('%s/%s.php', $this->cache_path, $hash); + } + + public function display(): void + { + $compiled_path = $this->_getCachePath(); + + if (!is_dir(dirname($compiled_path))) { + // Force cache directory mkdir + Utils::safe_mkdir(dirname($compiled_path), 0777, true); + } + + try { + if (file_exists($compiled_path) && filemtime($compiled_path) >= $this->modified) { + require $compiled_path; + return; + } + + $tmp_path = $compiled_path . '.tmp'; + + if ($this->code) { + $source = $this->code; + } + elseif ($this->file) { + $source = $this->file->fetch(); + } + else { + $source = file_get_contents($this->path); + } + + $code = $this->compile($source); + file_put_contents($tmp_path, $code); + + require $tmp_path; + + @rename($tmp_path, $compiled_path); + } + catch (Brindille_Exception $e) { + $path = $this->file ? $this->file->path : ($this->code ? 'code' : str_replace(ROOT, '…', $this->path)); + $is_user_code = $this->file || $this->code || ($this->module && $this->module->hasLocal()); + + $message = sprintf("Erreur dans '%s' :\n%s", $path, $e->getMessage()); + + if (!$is_user_code) { + // We want errors in shipped code to be reported, it is not normal + throw new \RuntimeException($message, 0, $e); + } + elseif (Session::getInstance()->isAdmin()) { + // Report error to admin with the highlighted line + $this->error($e, $message); + return; + } + else { + // Only report error + throw new UserException($message, 0, $e); + } + } + catch (\Throwable $e) { + // Don't delete temporary file as it can be used to debug + throw $e; + } + finally { + @unlink($tmp_path); + } + } + + public function fetch(): string + { + ob_start(); + + try { + $this->display(); + } + catch (\Throwable $e) { + ob_end_clean(); + throw $e; + } + + return ob_get_clean(); + } + + public function fetchAsAttachment(?string $type = null): File + { + $content = $this->fetch(); + $type ??= $this->getContentType(); + $name = $this->getHeader('filename') ?? 'document'; + + // Sanitize file name + $name = Utils::transliterateToAscii($name); + $name = preg_replace('/[^\w\d\.]+/U', ' ', $name); + $name = substr($name, -128); + + if ($type == 'application/pdf' && substr($name, -4) !== '.pdf') { + $name .= '.pdf'; + } + + File::validateFileName($name); + + $target = File::CONTEXT_ATTACHMENTS . '/' . md5($content) . '/' . $name; + + if ($type == 'application/pdf') { + $tmp = Utils::filePDF($content); + $file = Files::createFromPath($target, $tmp); + Utils::safe_unlink($tmp); + } + else { + $file = Files::createFromString($target, $content); + } + + return $file; + } + + public function fetchToAttachment(string $uri): File + { + $parts = explode('?', $uri, 2); + $path = $parts[0] ?? ''; + $query = $parts[1] ?? ''; + parse_str($query, $qs); + + $ut = new UserTemplate($path); + $ut->setModule($this->module); + $ut->assignArray(['_POST' => [], '_GET' => $qs]); + return $ut->fetchAsAttachment(); + } + + static public function isTemplate(string $filename): bool + { + $dot = strrpos($filename, '.'); + + // Templates with no extension are returned as HTML by default + // unless {{:http type=...}} is used + if ($dot === false) { + return true; + } + + $ext = substr($filename, $dot+1); + + switch ($ext) { + case 'html': + case 'htm': + case 'tpl': + case 'btpl': + case 'b': + case 'skel': + case 'xml': + return true; + default: + return false; + } + } + + public function getStatusCode(): int + { + return (int) ($this->headers['code'] ?? 200); + } + + public function getContentType(): string + { + return $this->headers['type'] ?? 'text/html'; + } + + public function serve(): void + { + $path = $this->path ?? $this->file->path; + + if (!self::isTemplate($path)) { + throw new \InvalidArgumentException('Not a valid template file extension: ' . $this->path); + } + + $content = $this->fetch(); + + $this->dumpHeaders(); + + if ($this->getContentType() == 'application/pdf') { + Utils::streamPDF($content); + } + else { + echo $content; + } + } + + public function error(\Exception $e, string $message) + { + $header = ini_get('error_prepend_string'); + $header = preg_replace('!(.*?)!is', '', $header); + echo $header; + + $name = strtok($this->_tpl_path, '/'); + strtok(''); + + $path = $this->file->name ?? $this->path; + $location = sprintf('Dans le code du module "%s"', $name); + + printf('

%s

%s

', + $location, nl2br(htmlspecialchars($message))); + + if ($this->code || !preg_match('/Line (\d+)\s*:/i', $message, $match)) { + return; + } + + $line = $match[1] - 1; + + if ($this->file) { + $file = explode("\n", $this->file->fetch()); + } + else { + $file = file($path); + } + + $start = max(0, $line - 5); + $max = min(count($file), $line + 6); + + echo '
';
+
+		for ($i = $start; $i < $max; $i++) {
+			$code = sprintf('%d%s', $i + 1, htmlspecialchars($file[$i]));
+
+			if ($i == $line) {
+				$code = sprintf('%s', $code);
+			}
+
+			echo rtrim($code) . "\n";
+		}
+
+		echo '
'; + exit; + } + + public function setHeader(string $name, string $value): void + { + if ($this->parent) { + // Setting headers on included template does not make sense, + // instead pass this to parent template + $this->parent->setHeader($name, $value); + } + else { + $this->headers[$name] = $value; + } + } + + public function getHeader(string $name): ?string + { + return $this->headers[$name] ?? null; + } + + public function dumpHeaders(): void + { + if (isset($this->headers['code'])) { + $code = $this->headers['code']; + + static $codes = [ + 100 => 'Continue', + 101 => 'Switching Protocols', + 102 => 'Processing', + 200 => 'OK', + 201 => 'Created', + 202 => 'Accepted', + 203 => 'Non-Authoritative Information', + 204 => 'No Content', + 205 => 'Reset Content', + 206 => 'Partial Content', + 207 => 'Multi-Status', + 300 => 'Multiple Choices', + 301 => 'Moved Permanently', + 302 => 'Found', + 303 => 'See Other', + 304 => 'Not Modified', + 305 => 'Use Proxy', + 306 => 'Switch Proxy', + 307 => 'Temporary Redirect', + 400 => 'Bad Request', + 401 => 'Unauthorized', + 402 => 'Payment Required', + 403 => 'Forbidden', + 404 => 'Not Found', + 405 => 'Method Not Allowed', + 406 => 'Not Acceptable', + 407 => 'Proxy Authentication Required', + 408 => 'Request Timeout', + 409 => 'Conflict', + 410 => 'Gone', + 411 => 'Length Required', + 412 => 'Precondition Failed', + 413 => 'Request Entity Too Large', + 414 => 'Request-URI Too Long', + 415 => 'Unsupported Media Type', + 416 => 'Requested Range Not Satisfiable', + 417 => 'Expectation Failed', + 418 => 'I\'m a teapot', + 422 => 'Unprocessable Entity', + 423 => 'Locked', + 424 => 'Failed Dependency', + 425 => 'Unordered Collection', + 426 => 'Upgrade Required', + 449 => 'Retry With', + 450 => 'Blocked by Windows Parental Controls', + 500 => 'Internal Server Error', + 501 => 'Not Implemented', + 502 => 'Bad Gateway', + 503 => 'Service Unavailable', + 504 => 'Gateway Timeout', + 505 => 'HTTP Version Not Supported', + 506 => 'Variant Also Negotiates', + 507 => 'Insufficient Storage', + 509 => 'Bandwidth Limit Exceeded', + 510 => 'Not Extended', + ]; + + if (!isset($codes[$code])) { + throw new Brindille_Exception('Code HTTP inconnu: ' . $code); + } + + header(sprintf('HTTP/1.1 %d %s', $code, $codes[$code]), true); + } + + if (isset($this->headers['type'])) { + header(sprintf('Content-Type: %s; charset=utf-8', $this->headers['type']), true); + } + + if (isset($this->headers['disposition'])) { + header(sprintf('Content-Disposition: %s; filename="%s"', + $this->headers['disposition'], + Utils::safeFileName($this->headers['filename']) + ), true); + } + } + + public function _callFunction(string $name, array $params, int $line) { + try { + return call_user_func($this->_functions[$name], $params, $this, $line); + } + catch (UserException $e) { + throw $e; + } + catch (\Exception $e) { + throw new Brindille_Exception(sprintf("line %d: function '%s' has returned an error: %s\nParameters: %s", $line, $name, $e->getMessage(), substr(var_export($params, true), 6)), 0, $e); + } + } + + public function setParent(UserTemplate $tpl) + { + $this->parent = $tpl; + $this->setModule($tpl->module); + } + + public function setModule(?Module $module): void + { + if (!$module) { + return; + } + + $this->module = $module; + $this->assign('module', array_merge($module->asArray(false), [ + 'config' => json_decode(json_encode($module->config), true), + 'url' => $module->url(), + 'public_url' => $module->public_url(), + 'storage_root' => $module->storage_root(), + ])); + } +} diff --git a/src/include/lib/Paheko/Users/AdvancedSearch.php b/src/include/lib/Paheko/Users/AdvancedSearch.php new file mode 100644 index 0000000..7a431e7 --- /dev/null +++ b/src/include/lib/Paheko/Users/AdvancedSearch.php @@ -0,0 +1,305 @@ + $fields::getNameLabel(), + 'type' => 'text', + 'null' => true, + 'select' => $fields::getNameFieldsSQL('u'), + 'where' => $fields::getNameFieldsSearchableSQL('us') . ' %s', + 'order' => $order, + ]; + + $columns['is_parent'] = [ + 'label' => 'Est responsable', + 'type' => 'boolean', + 'null' => false, + 'select' => 'CASE WHEN u.is_parent = 1 THEN \'Oui\' ELSE \'Non\' END', + 'where' => 'u.is_parent %s', + ]; + + $columns['is_child'] = [ + 'label' => 'Est rattaché', + 'type' => 'boolean', + 'null' => false, + 'select' => 'CASE WHEN u.id_parent IS NOT NULL THEN \'Oui\' ELSE \'Non\' END', + 'where' => 'u.id_parent IS NOT NULL', + ]; + + foreach ($fields->all() as $name => $field) + { + /* + // already included in identity + if ($field->system & $field::NAME) { + continue; + } + */ + + // nope + if ($field->system & $field::PASSWORD) { + continue; + } + + $identifier = $db->quoteIdentifier($name); + + $column = [ + 'label' => $field->label, + 'type' => 'text', + 'null' => true, + 'select' => sprintf('u.%s', $identifier), + 'where' => sprintf('%s.%s %%s', $field->hasSearchCache() ? 'us' : 'u', $identifier), + ]; + + if ($fields->isText($name)) { + $column['order'] = sprintf('%s COLLATE U_NOCASE %%s', $name); + } + + if ($field->type == 'checkbox') + { + $column['type'] = 'boolean'; + $column['null'] = false; + } + elseif ($field->type == 'select') + { + $column['type'] = 'enum'; + $column['values'] = array_combine($field->options, $field->options); + } + elseif ($field->type == 'multiple') + { + $column['type'] = 'bitwise'; + $column['values'] = $field->options; + } + elseif ($field->type == 'date' || $field->type == 'datetime') + { + $column['type'] = $field->type; + } + elseif ($field->type == 'number') + { + $column['type'] = 'integer'; + } + elseif ($field->type === 'file') { + $column['type'] = 'integer'; + $column['null'] = false; + $column['label'] .= ' (nombres de fichiers)'; + $column['select'] = sprintf('(SELECT GROUP_CONCAT(f.path, \';\') FROM users_files AS uf INNER JOIN files AS f ON f.id = uf.id_file WHERE uf.id_user = u.id AND uf.field = %s)', $db->quote($field->name)); + $column['where'] = sprintf('(SELECT COUNT(*) FROM users_files AS uf WHERE uf.id_user = u.id AND uf.field = %s) %%s', $db->quote($field->name)); + } + elseif ($field->type === 'virtual') { + $type = $field->getRealType(); + + if ($type === 'integer' || $type === 'real') { + $type = 'integer'; + } + else { + $type = 'text'; + } + + $column['type'] = $type; + $column['null'] = $field->hasNullValues(); + } + + if ($field->type == 'tel') { + $column['normalize'] = 'tel'; + } + + $columns[$name] = $column; + } + + $names = $fields::getNameFields(); + + if (count($names) == 1) { + unset($columns[$names[0]]); + } + + $columns['id_category'] = [ + 'label' => 'Catégorie', + 'type' => 'enum', + 'null' => false, + 'values' => $db->getAssoc('SELECT id, name FROM users_categories ORDER BY name COLLATE U_NOCASE;'), + 'select' => '(SELECT name FROM users_categories WHERE id = id_category)', + 'where' => 'id_category %s', + ]; + + $columns['service'] = [ + 'label' => 'Est inscrit à l\'activité', + 'type' => 'enum', + 'null' => false, + 'values' => $db->getAssoc('SELECT id, label FROM services ORDER BY label COLLATE U_NOCASE;'), + 'select' => '\'Inscrit\'', + 'where' => 'id IN (SELECT id_user FROM services_users WHERE id_service %s)', + ]; + + $columns['service_not'] = [ + 'label' => 'N\'est pas inscrit à l\'activité', + 'type' => 'enum', + 'null' => false, + 'values' => $db->getAssoc('SELECT id, label FROM services ORDER BY label COLLATE U_NOCASE;'), + 'select' => '\'Inscrit\'', + 'where' => 'id NOT IN (SELECT id_user FROM services_users WHERE id_service %s)', + ]; + + $columns['service_active'] = [ + 'label' => 'Est à jour de l\'activité', + 'type' => 'enum', + 'null' => false, + 'values' => $db->getAssoc('SELECT id, label FROM services ORDER BY label COLLATE U_NOCASE;'), + 'select' => '\'À jour\'', + 'where' => 'id IN (SELECT id_user FROM (SELECT id_user, MAX(expiry_date) AS edate FROM services_users WHERE id_service %s GROUP BY id_user) WHERE edate >= date())', + ]; + + $columns['service_expired'] = [ + 'label' => 'N\'est pas à jour de l\'activité', + 'type' => 'enum', + 'null' => false, + 'values' => $db->getAssoc('SELECT id, label FROM services ORDER BY label COLLATE U_NOCASE;'), + 'select' => '\'Expiré\'', + 'where' => 'id IN (SELECT id_user FROM (SELECT id_user, MAX(expiry_date) AS edate FROM services_users WHERE id_service %s GROUP BY id_user) WHERE edate < date())', + ]; + + return $columns; + } + + public function schemaTables(): array + { + return [ + 'users' => 'Membres', + 'users_categories' => 'Catégories de membres', + 'services' => 'Activités', + 'services_fees' => 'Tarifs des activités', + 'services_users' => 'Inscriptions aux activités', + ]; + } + + public function tables(): array + { + return array_merge(array_keys($this->schemaTables()), [ + 'users_search', + 'user_files', + 'user_view', + ]); + } + + public function simple(string $query, bool $allow_redirect = false): \stdClass + { + $operator = 'LIKE %?%'; + $db = DB::getInstance(); + + if (is_numeric(trim($query))) + { + $column = DynamicFields::getNumberField(); + $operator = '= ?'; + } + elseif (strpos($query, '@') !== false) + { + $column = DynamicFields::getFirstEmailField(); + } + else + { + $column = 'identity'; + } + + if ($allow_redirect) { + $c = $column; + $table = 'users'; + + if ($column == 'identity') { + $c = DynamicFields::getNameFieldsSearchableSQL(); + + if (!$c) { + throw new UserException('Aucun champ texte n\'est indiqué comme identité des membres, il n\'est pas possible de faire une recherche.'); + } + + $table = 'users_search'; + } + + // Try to redirect to user if there is only one user + if ($operator == '= ?') { + $sql = sprintf('SELECT id, COUNT(*) AS count FROM %s WHERE %s = ?;', $table, $c); + $single_query = (int) $query; + } + else { + $sql = sprintf('SELECT id, COUNT(*) AS count FROM %s WHERE %s LIKE ?;', $table, $c); + $single_query = '%' . trim($query) . '%'; + } + + if (($row = $db->first($sql, $single_query)) && $row->count == 1) { + Utils::redirect('!users/details.php?id=' . $row->id); + } + } + + $query = [[ + 'operator' => 'AND', + 'conditions' => [ + [ + 'column' => $column, + 'operator' => $operator, + 'values' => [$query], + ], + ], + ]]; + + return (object) [ + 'groups' => $query, + 'order' => $column, + 'desc' => false, + ]; + } + + public function make(string $query): DynamicList + { + $tables = 'users_view AS u INNER JOIN users_search AS us USING (id)'; + $list = $this->makeList($query, $tables, 'identity', false, ['id', 'identity']); + + $list->setExportCallback([Users::class, 'exportRowCallback']); + return $list; + } + + public function defaults(): \stdClass + { + return (object) ['groups' => [[ + 'operator' => 'AND', + 'join_operator' => null, + 'conditions' => [ + [ + 'column' => 'identity', + 'operator' => 'LIKE %?%', + 'values' => [''], + ], + ], + ]]]; + } +} diff --git a/src/include/lib/Paheko/Users/Categories.php b/src/include/lib/Paheko/Users/Categories.php new file mode 100644 index 0000000..1a6c94c --- /dev/null +++ b/src/include/lib/Paheko/Users/Categories.php @@ -0,0 +1,76 @@ +getAssoc(sprintf('SELECT id, name FROM %s WHERE 1 %s ORDER BY name COLLATE U_NOCASE;', + Category::TABLE, self::getHiddenClause($hidden) + )); + } + + static public function listAssocWithStats(?int $hidden = null): array + { + $db = DB::getInstance(); + + $categories = [0 => (object) [ + 'label' => 'Toutes, sauf cachées', + 'count' => $db->count(User::TABLE, 'id_category NOT IN (SELECT id FROM users_categories WHERE hidden = 1)'), + ]]; + + if ($hidden !== self::WITHOUT_HIDDEN) { + $categories[-1] = (object) [ + 'label' => 'Toutes, même cachées', + 'count' => $db->count(User::TABLE), + ]; + } + + $format = '%s (%d membres)'; + + return $categories + $db->getGrouped(sprintf( + 'SELECT id, name AS label, (SELECT COUNT(*) FROM %s WHERE %1$s.id_category = %s.id) AS count + FROM %2$s + WHERE 1 %s + ORDER BY name COLLATE U_NOCASE;', + User::TABLE, + Category::TABLE, + self::getHiddenClause($hidden) + )); + } + + static public function listWithStats(?int $hidden = null): array + { + return DB::getInstance()->getGrouped(sprintf('SELECT c.id, c.*, + (SELECT COUNT(*) FROM users WHERE id_category = c.id) AS count + FROM %s c WHERE 1 %s ORDER BY c.name COLLATE U_NOCASE;', + Category::TABLE, self::getHiddenClause($hidden) + )); + } +} diff --git a/src/include/lib/Paheko/Users/DynamicFields.php b/src/include/lib/Paheko/Users/DynamicFields.php new file mode 100644 index 0000000..d682f4e --- /dev/null +++ b/src/include/lib/Paheko/Users/DynamicFields.php @@ -0,0 +1,1135 @@ + [], + 'password' => [], + 'name' => [], + 'number' => [], + ]; + + protected array $_presets; + + protected array $_deleted = []; + + static protected $_instance; + + static public function getInstance(): self + { + if (null === self::$_instance) { + self::$_instance = new self; + } + + return self::$_instance; + } + + static public function get(string $key) + { + return self::getInstance()->fieldByKey($key); + } + + /** + * Returns the list of columns containing an email address (there might be more than one) + * @return array + */ + static public function getEmailFields(): array + { + return array_keys(self::getInstance()->fieldsByType('email')); + } + + static public function getFirstEmailField(): string + { + return key(self::getInstance()->fieldsByType('email')); + } + + static public function getNumberField(): string + { + return key(self::getInstance()->fieldsBySystemUse('number')); + } + + static public function getNumberFieldSQL(): string + { + return DB::getInstance()->quoteIdentifier(self::getNumberField()); + } + + static public function getLoginField(): string + { + return key(self::getInstance()->fieldsBySystemUse('login')); + } + + static public function getNameFields(): array + { + return array_keys(self::getInstance()->fieldsBySystemUse('name')); + } + + static public function getVirtualFields(): array + { + return self::getInstance()->fieldsByType('virtual'); + } + + static public function getNameFromArray($in): ?string + { + $out = []; + + foreach (array_keys(self::getInstance()->fieldsBySystemUse('name')) as $f) { + if (is_array($in) && array_key_exists($f, $in)) { + $out[] = $in[$f]; + } + elseif (is_object($in) && property_exists($in, $f)) { + $out[] = $in->$f; + } + } + + return implode(' ', $out) ?: null; + } + + static public function getNameLabel(): string + { + $list = self::getInstance()->fieldsBySystemUse('name'); + $labels = []; + + foreach ($list as $field) { + $labels[] = $field->label; + } + + return implode(', ', $labels); + } + + static public function getFirstNameField(): string + { + return key(self::getInstance()->fieldsBySystemUse('name')); + } + + static public function getFirstSearchableNameField(): ?string + { + foreach (self::getInstance()->fieldsBySystemUse('name') as $field) { + if ($field->hasSearchCache()) { + return $field->name; + } + } + + return null; + } + + static public function getNameFieldsSQL(?string $prefix = null): string + { + $fields = self::getNameFields(); + $db = DB::getInstance(); + + if ($prefix) { + $fields = array_map(fn($v) => $prefix . '.' . $db->quoteIdentifier($v), $fields); + } + + if (count($fields) == 1) { + return $fields[0]; + } + + foreach ($fields as &$field) { + $field = sprintf('IFNULL(%s, \'\')', $field); + } + + unset($field); + + $fields = implode(' || \' \' || ', $fields); + $fields = sprintf('TRIM(%s)', $fields); + return $fields; + } + + static public function getNameFieldsSearchableSQL(?string $prefix = null): ?string + { + $fields = []; + + foreach (self::getInstance()->fieldsBySystemUse('name') as $field) { + if (!$field->hasSearchCache()) { + continue; + } + + $fields[] = $field->name; + } + + // There are no indexed fields in the name, eg. only the user number, then discard the index + if (!count($fields)) { + return null; + } + + $db = DB::getInstance(); + + if ($prefix) { + $fields = array_map(fn($v) => $prefix . '.' . $db->quoteIdentifier($v), $fields); + } + + foreach ($fields as &$field) { + $field = sprintf('IFNULL(%s, \'\')', $field); + } + + unset($field); + + $fields = implode(' || \' \' || ', $fields); + $fields = sprintf('TRIM(%s)', $fields); + return $fields; + } + + protected function __construct(bool $load = true) + { + if ($load) { + $this->reload(); + } + } + + protected function reload() + { + $db = DB::getInstance(); + $i = EM::getInstance(DynamicField::class)->iterate('SELECT * FROM @TABLE ORDER BY sort_order;'); + + foreach ($i as $field) { + $this->_fields[$field->name] = $field; + } + + $this->reloadCache(); + } + + public function install(): void + { + $presets = $this->getDefaultPresets(); + + foreach ($presets as $name => $preset) { + $field = $this->addFieldFromPreset($name); + + if ($name == 'password') { + $name = 'password'; + $field->system |= $field::PASSWORD; + } + + if ($name == 'email') { + $field->system |= $field::LOGIN; + } + + if ($name == 'nom') { + $field->system |= $field::NAMES; + } + + if ($name == 'numero') { + $field->system |= $field::NUMBER; + } + } + + $this->save(); + } + + public function addFieldFromPreset(string $name): DynamicField + { + $data = $this->getPresets()[$name]; + + foreach ($data->depends ?? [] as $depends) { + if (!$this->fieldByKey($depends)) { + throw new \LogicException(sprintf('Cannot add "%s" preset if "%s" preset is not installed.', $name, $depends)); + } + } + + $data->user_access_level ??= Session::ACCESS_READ; + $data->management_access_level ??= Session::ACCESS_READ; + $data->required ??= false; + $data->list_table ??= false; + + $field = new DynamicField; + $field->system |= $field::PRESET; + $field->set('name', $name); + $field->import((array)$data); + $field->sort_order = $this->getLastOrderIndex(); + + $this->add($field); + + return $field; + } + + protected function reloadCache() + { + $this->_fields_by_type = []; + + foreach ($this->_fields_by_system_use as &$list) { + $list = []; + } + + unset($list); + + foreach ($this->_fields as $key => $field) { + if (!isset($this->_fields_by_type[$field->type])) { + $this->_fields_by_type[$field->type] = []; + } + + $this->_fields_by_type[$field->type][$key] = $field; + + if (!$field->system) { + continue; + } + + if ($field->system & $field::PASSWORD) { + $this->_fields_by_system_use['password'][$key] = $field; + } + + if ($field->system & $field::NAMES) { + $this->_fields_by_system_use['name'][$key] = $field; + } + + if ($field->system & $field::NUMBER) { + $this->_fields_by_system_use['number'][$key] = $field; + } + + if ($field->system & $field::LOGIN) { + $this->_fields_by_system_use['login'][$key] = $field; + } + } + } + + public function fieldsByType(string $type): array + { + return $this->_fields_by_type[$type] ?? []; + } + + public function fieldByKey(string $key): ?DynamicField + { + return $this->_fields[$key] ?? null; + } + + public function fieldById(int $id): ?DynamicField + { + foreach ($this->_fields as $field) { + if ($field->id === $id) { + return $field; + } + } + + return null; + } + + public function fieldsBySystemUse(string $use): array + { + return $this->_fields_by_system_use[$use] ?? []; + } + + public function getEntityTypes(): array + { + $types = []; + + foreach ($this->_fields as $key => $field) { + $types[$key] = $field->type; + } + + return $types; + } + + public function getPresets(): array + { + if (!isset($this->_presets)) + { + $this->_presets = Utils::parse_ini_file(self::PRESETS_FILE, true); + + foreach ($this->_presets as &$preset) { + $preset = (object) $preset; + } + + unset($preset); + } + + return $this->_presets; + } + + /** + * Return list of presets that are not installed already + */ + public function getInstallablePresets(): array + { + $list = array_diff_key($this->getPresets(), $this->_fields); + $installed =& $this->_fields; + array_walk($list, function (&$p) use ($installed) { + $p->disabled = false; + + foreach ($p->depends ?? [] as $d) { + if (!array_key_exists($d, $installed)) { + $p->disabled = true; + break; + } + } + }); + + uasort($list, fn($a, $b) => strnatcasecmp($a->label, $b->label)); + + return $list; + } + + public function getDefaultPresets(): array + { + return array_filter($this->getPresets(), fn ($row) => $row->default ?? false); + } + + public function installPreset(string $name): DynamicField + { + $preset = $this->getInstallablePresets()[$name] ?? null; + + if (!$preset) { + throw new \InvalidArgumentException('This field cannot be installed.'); + } + + return $this->addFieldFromPreset($name); + } + + /** + * Import from old INI config + * @deprecated Only use when migrating from an old version + */ + static public function fromOldINI(string $config, string $login_field, string $name_field, string $number_field) + { + $db = DB::getInstance(); + $config = Utils::parse_ini_string($config, true); + + $presets = Utils::parse_ini_file(self::PRESETS_FILE, true); + + $i = 0; + + $self = new self(false); + $fields = [ + 'date_connexion' => 'date_login', + 'date_inscription' => 'date_inscription', + 'clef_pgp' => 'pgp_key', + 'secret_otp' => 'otp_secret', + 'id_category' => 'id_category', + ]; + + $defaults = [ + 'help' => null, + 'private' => false, + 'editable' => true, + 'mandatory' => false, + 'list_row' => null, + ]; + + foreach ($config as $name => $data) { + $field = new DynamicField; + + $fields[$name] = $name; + + if ($data['type'] == 'checkbox' || $data['type'] == 'multiple') { + // A checkbox/multiple checkbox can either be 0 or 1, not NULL + $db->exec(sprintf('UPDATE membres SET %s = 0 WHERE %1$s IS NULL OR %1$s = \'\';', $db->quoteIdentifier($name))); + } + else { + // Make sure data is NULL if empty + $db->exec(sprintf('UPDATE membres SET %s = NULL WHERE %1$s = \'\';', $db->quoteIdentifier($name))); + } + + if ($name == 'passe') { + $name = 'password'; + $data['title'] = 'Mot de passe'; + $field->system |= $field::PASSWORD; + $fields['passe'] = 'password'; + } + + if ($name == $login_field) { + $field->system |= $field::LOGIN; + } + + if ($name == $name_field) { + $field->system |= $field::NAMES; + } + + if ($name == $number_field) { + $field->system |= $field::NUMBER; + $data['help'] = null; + $data['mandatory'] = true; + $data['editable'] = false; + } + + $data = array_merge($defaults, $data); + + if (array_key_exists($name, $presets)) { + $field->system = $field->system | $field::PRESET; + } + + $field->set('name', $name); + $field->set('label', (string)$data['title']); + $field->set('type', (string)$data['type']); + $field->set('help', empty($data['help']) ? null : (string)$data['help']); + $field->set('user_access_level', $data['editable'] ? Session::ACCESS_WRITE : ($data['private'] ? Session::ACCESS_NONE : Session::ACCESS_READ)); + $field->set('management_access_level', Session::ACCESS_READ); + $field->set('required', (bool) $data['mandatory']); + $field->set('list_table', (bool) $data['list_row']); + $field->set('sort_order', $i++); + $field->set('options', $data['options'] ?? null); + $self->add($field); + } + + // Create date_inscription + $field = $self->addFieldFromPreset('date_inscription'); + $self->add($field); + + self::$_instance = $self; + + $self->createTable(); + $self->createIndexes(); + $self->createTriggers(); + $self->copy('membres', User::TABLE, $fields); + + $self->rebuildSearchTable(true); + + return $self; + } + + public function isText(string $field) + { + $type = $this->_fields[$field]->type; + return (DynamicField::SQL_TYPES[$type] ?? null) === 'TEXT'; + } + + public function getKeys() + { + return array_keys($this->_fields); + } + + public function all() + { + return $this->_fields; + } + + public function allExceptPassword() + { + return array_filter($this->_fields, function ($a) { + return !($a->system & DynamicField::PASSWORD); + }); + } + + public function listAssocNames() + { + $out = []; + + foreach ($this->_fields as $key => $field) { + if ($field->system & $field::PASSWORD) { + continue; + } + + $out[$key] = $field->label; + } + + return $out; + } + + public function listImportAssocNames() + { + $out = []; + + foreach ($this->_fields as $key => $field) { + if ($field->system & $field::PASSWORD) { + continue; + } + + // Skip fields where the value cannot be imported + if ($field->type == 'file' || $field->type == 'virtual') { + continue; + } + + $out[$key] = $field->label; + } + + return $out; + } + + public function listImportRequiredAssocNames(bool $require_number = true) + { + $out = []; + + foreach ($this->_fields as $key => $field) { + if ($field->system & $field::PASSWORD) { + continue; + } + + // Skip fields where the value cannot be imported + if ($field->type == 'file' || $field->type == 'virtual') { + continue; + } + + if (!$field->required) { + continue; + } + + if (!$require_number && $field->system & $field::NUMBER) { + continue; + } + + $out[$key] = $field->label; + } + + return $out; + } + + public function getListedFields(): array + { + $fields = array_filter( + $this->_fields, + fn ($a, $b) => empty($a->list_table) ? false : true, + ARRAY_FILTER_USE_BOTH + ); + + uasort($fields, function ($a, $b) { + if ($a->sort_order == $b->sort_order) + return 0; + + return ($a->sort_order > $b->sort_order) ? 1 : -1; + }); + + return $fields; + } + + public function getSQLSchema(string $table_name = User::TABLE): string + { + $db = DB::getInstance(); + + $create = DynamicField::SYSTEM_FIELDS_SQL; + + end($this->_fields); + + // Find out which field is the last one to be a real column + do { + $field = !isset($field) ? current($this->_fields) : prev($this->_fields); + $type = DynamicField::SQL_TYPES[$field->type] ?? null; + } + while ($type === null); + + $last_one = $field->name; + + foreach ($this->_fields as $key => $cfg) + { + $type = DynamicField::SQL_TYPES[$cfg->type] ?? null; + + // Skip fields that don't have a type (= virtual fields) + if (!$type) { + continue; + } + + $line = sprintf('%s %s', $db->quoteIdentifier($key), $type); + + if ($type == 'TEXT' && $cfg->type != 'password') { + $line .= ' COLLATE NOCASE'; + } + + if ($last_one != $key) { + $line .= ','; + } + + if (!empty($cfg->label)) + { + $line .= ' -- ' . str_replace(["\n", "\r"], '', $cfg->label); + } + + $create[] = $line; + } + + $sql = sprintf("CREATE TABLE %s\n(\n\t%s\n);", $table_name, implode("\n\t", $create)); + return $sql; + } + + public function getSearchColumns(): array + { + $c = array_keys(array_filter($this->_fields, fn ($f) => $f->hasSearchCache())); + return $c = array_combine($c, $c); + } + + public function getSQLCopy(string $old_table_name, string $new_table_name = User::TABLE, array $fields = null, string $function = null): string + { + $db = DB::getInstance(); + unset($fields['id']); + + $source = []; + + foreach ($fields as $src_key => $dst_key) { + /* Don't cast currently as this can create duplicate records when data was wrong :( + $field = $this->get($dst_key); + + if ($field) { + $source[] = sprintf('CAST(%s AS %s)', $db->quoteIdentifier($src_key), $field->sql_type()); + } + */ + $source[] = $db->quoteIdentifier($src_key); + } + + if ($function) { + $source = array_map(fn($a) => $function . '(' . $a . ')', $source); + } + + return sprintf('INSERT INTO %s (id, %s) SELECT id, %s FROM %s;', + $new_table_name, + implode(', ', array_map([$db, 'quoteIdentifier'], $fields)), + implode(', ', $source), + $old_table_name + ); + } + + public function copy(string $old_table_name, string $new_table_name = User::TABLE, ?array $fields = null): void + { + $sql = $this->getSQLCopy($old_table_name, $new_table_name, $fields); + DB::getInstance()->exec($sql); + } + + public function createTable(string $table_name = User::TABLE): void + { + $db = DB::getInstance(); + $schema = $this->getSQLSchema($table_name); + $db->exec($schema); + } + + public function createIndexes(string $table_name = User::TABLE): void + { + $id_field = null; + $db = DB::getInstance(); + + if ($id_field = $this->getLoginField()) { + // Mettre les champs identifiant vides à NULL pour pouvoir créer un index unique + $db->exec(sprintf('UPDATE %s SET %s = NULL WHERE %2$s = \'\';', + $table_name, $id_field)); + + $collation = ''; + + if ($this->isText($id_field)) { + $collation = ' COLLATE NOCASE'; + } + + // Création de l'index unique + $db->exec(sprintf('CREATE UNIQUE INDEX IF NOT EXISTS users_id_field ON %s (%s%s);', $table_name, $id_field, $collation)); + } + + $db->exec(sprintf('CREATE UNIQUE INDEX IF NOT EXISTS users_number ON %s (%s);', $table_name, $this->getNumberField())); + $db->exec(sprintf('CREATE INDEX IF NOT EXISTS users_category ON %s (id_category);', $table_name)); + $db->exec(sprintf('CREATE INDEX IF NOT EXISTS users_parent ON %s (id_parent);', $table_name)); + $db->exec(sprintf('CREATE INDEX IF NOT EXISTS users_is_parent ON %s (is_parent);', $table_name)); + } + + public function createTriggers(string $table_name = User::TABLE): void + { + // These triggers are to set id_parent to the ID of the user on parent user, when the user has children + $db = DB::getInstance(); + $db->exec(sprintf(' + CREATE TRIGGER %1$s_parent_trigger_update_new AFTER UPDATE OF id_parent ON %2$s BEGIN + UPDATE users SET is_parent = 1 WHERE id = NEW.id_parent; + END; + CREATE TRIGGER %1$s_parent_trigger_update_old AFTER UPDATE OF id_parent ON %2$s BEGIN + -- Set is_parent to 0 if user has no longer any children + UPDATE %1$s SET is_parent = 0 WHERE id = OLD.id_parent + AND 0 = (SELECT COUNT(*) FROM %2$s WHERE id_parent = OLD.id_parent); + END; + CREATE TRIGGER %1$s_parent_trigger_insert AFTER INSERT ON %2$s BEGIN + SELECT CASE WHEN NEW.id_parent IS NULL THEN RAISE(IGNORE) ELSE 0 END; + UPDATE users SET is_parent = 1 WHERE id = NEW.id_parent; + END; + CREATE TRIGGER %1$s_parent_trigger_delete AFTER DELETE ON %2$s BEGIN + SELECT CASE WHEN OLD.id_parent IS NULL THEN RAISE(IGNORE) ELSE 0 END; + -- Set is_parent to 0 if user has no longer any children + UPDATE %2$s SET is_parent = 0 WHERE id = OLD.id_parent + AND 0 = (SELECT COUNT(*) FROM %2$s WHERE id_parent = OLD.id_parent); + END; + -- Keep logs for create/delete/edit actions, just make them anonymous + CREATE TRIGGER %1$s_delete_logs BEFORE DELETE ON %2$s BEGIN + UPDATE logs SET id_user = NULL WHERE id_user = OLD.id AND type >= 10; + END;', $table_name, $table_name)); + } + + public function createView(string $table_name = User::TABLE): void + { + $db = DB::getInstance(); + $virtual_fields = []; + + foreach ($this->fieldsByType('virtual') as $field) { + $virtual_fields[] = sprintf('(%s) AS %s', $field->sql, $field->name); + } + + $virtual_fields = implode(', ', $virtual_fields); + + if (strlen($virtual_fields)) { + $virtual_fields = ', ' . $virtual_fields; + } + + $sql = sprintf(' + DROP VIEW IF EXISTS %s_view; + CREATE VIEW IF NOT EXISTS %1$s_view + AS + SELECT * %s + FROM %1$s; + ', $table_name, $virtual_fields); + $db->exec($sql); + } + + /** + * Enregistre les changements de champs en base de données + */ + public function rebuildUsersTable(array $fields): void + { + $db = DB::getInstance(); + + $fields = array_combine($fields, $fields); + + // Virtual fields cannot be copied + foreach ($this->_fields as $f) { + $sql_type = DynamicField::SQL_TYPES[$f->type] ?? null; + + if (!$sql_type) { + unset($fields[$f->name]); + } + } + + // Always copy system fields + $system_fields = array_keys(DynamicField::SYSTEM_FIELDS); + $fields = array_merge(array_combine($system_fields, $system_fields), $fields); + + $in_transaction = $db->inTransaction(); + + if (!$in_transaction) { + $db->beginSchemaUpdate(); + } + + $this->createTable(User::TABLE . '_tmp'); + + // No need to copy if the table does not exist (that's the case during first setup) + if ($db->firstColumn('SELECT 1 FROM sqlite_master WHERE type = \'table\' AND name = ?;', User::TABLE)) { + $this->copy(User::TABLE, User::TABLE . '_tmp', $fields); + } + + $db->exec(sprintf('DROP TABLE IF EXISTS %s;', User::TABLE)); + $db->exec(sprintf('ALTER TABLE %s_tmp RENAME TO %1$s;', User::TABLE)); + + $this->createIndexes(User::TABLE); + $this->createTriggers(User::TABLE); + $this->createView(User::TABLE); + + $this->rebuildSearchTable(false); + + if (!$in_transaction) { + $db->commitSchemaUpdate(); + } + } + + public function rebuildSearchTable(bool $from_users_table = true): void + { + $db = DB::getInstance(); + $db->begin(); + + $search_table = User::TABLE . '_search'; + $columns = $this->getSearchColumns(); + $columns_sql = array_map([$db, 'quoteIdentifier'], $columns); + $columns_sql = implode(",\n\t", $columns_sql); + + $sql = sprintf("CREATE TABLE IF NOT EXISTS %s\n(\n\tid INTEGER PRIMARY KEY NOT NULL REFERENCES %s (id) ON DELETE CASCADE,\n\t%s\n);", $search_table . '_tmp', User::TABLE, $columns_sql); + + $db->exec($sql); + + if ($from_users_table && $db->firstColumn('SELECT 1 FROM sqlite_master WHERE type = \'table\' AND name = ?;', User::TABLE)) { + // This is slower but is necessary sometimes + $sql = $this->getSQLCopy(User::TABLE, $search_table . '_tmp', $columns, 'transliterate_to_ascii'); + } + elseif ($db->firstColumn('SELECT 1 FROM sqlite_master WHERE type = \'table\' AND name = ?;', $search_table)) { + $sql = $this->getSQLCopy($search_table, $search_table . '_tmp', $columns); + } + else { + $sql = null; + } + + if ($sql) { + $db->exec($sql); + } + + $db->exec(sprintf('DROP TABLE IF EXISTS %s;', $search_table)); + $db->exec(sprintf('ALTER TABLE %s_tmp RENAME TO %1$s;', $search_table)); + + foreach ($columns as $column) { + $sql = sprintf("CREATE INDEX IF NOT EXISTS %s ON %s (%s);\n", + $db->quoteIdentifier($search_table . '_' . $column), + $search_table, + $db->quoteIdentifier($column) + ); + + $db->exec($sql); + } + + $db->commit(); + } + + public function rebuildUserSearchCache(int $id): void + { + $db = DB::getInstance(); + $columns = $this->getSearchColumns(); + $keys = array_map([$db, 'quoteIdentifier'], $columns); + $copy = array_map(fn($c) => sprintf('transliterate_to_ascii(%s)', $c), $keys); + + $sql = sprintf('INSERT OR REPLACE INTO %s_search (id, %s) SELECT id, %s FROM %1$s WHERE id = %d;', + User::TABLE, + implode(', ', $keys), + implode(', ', $copy), + $id + ); + + $db->exec($sql); + } + + public function add(DynamicField $df) + { + $this->_fields[$df->name] = $df; + $this->reloadCache(); + } + + public function delete(string $name) + { + $this->_deleted[] = $this->_fields[$name]; + unset($this->_fields[$name]); + + $this->reloadCache(); + } + + public function save(bool $allow_rebuild = true) + { + if (empty($this->_fields_by_system_use['number'])) { + throw new ValidationException('Aucun champ de numéro de membre n\'existe'); + } + + if (count($this->_fields_by_system_use['number']) != 1) { + throw new ValidationException('Un seul champ peut être défini comme numéro'); + } + + if (empty($this->_fields_by_system_use['name'])) { + throw new ValidationException('Aucun champ de nom de membre n\'existe'); + } + + if (empty($this->_fields_by_system_use['login'])) { + throw new ValidationException('Aucun champ d\'identifiant de connexion n\'existe'); + } + + if (count($this->_fields_by_system_use['login']) != 1) { + throw new ValidationException('Un seul champ peut être défini comme identifiant'); + } + + if (empty($this->_fields_by_system_use['password'])) { + throw new ValidationException('Aucun champ de mot de passe n\'existe'); + } + + if (count($this->_fields_by_system_use['password']) != 1) { + throw new ValidationException('Un seul champ peut être défini comme mot de passe'); + } + + $rebuild = false; + + $db = DB::getInstance(); + + // We need to disable foreign keys BEFORE we start the transaction + // this means that the config_users_fields table CANNOT have any foreign keys + $db->beginSchemaUpdate(); + + $copy = []; + + foreach ($this->_fields as $field) { + if (!$field->exists()) { + $rebuild = true; + } + else { + $copy[] = $field->name; + } + + if ($field->isModified()) { + $field->save(); + } + } + + foreach ($this->_deleted as $f) { + $f->delete(); + $rebuild = true; + } + + $this->_deleted = []; + + if ($rebuild && $allow_rebuild) { + // FIXME/TODO: use ALTER TABLE ... DROP COLUMN for SQLite 3.35.0+ + // some conditions apply + // https://www.sqlite.org/lang_altertable.html#altertabdropcol + $this->rebuildUsersTable($copy); + } + + $db->commitSchemaUpdate(); + + $this->reload(); + } + + public function setOrderAll(array $order) + { + foreach (array_values($order) as $sort => $key) { + if (!array_key_exists($key, $this->_fields)) { + throw new \InvalidArgumentException('Unknown field name: ' . $key); + } + + $this->_fields[$key]->set('sort_order', $sort); + } + } + + public function getLastOrderIndex() + { + return count($this->_fields); + } + + public function listEligibleLoginFields(): array + { + $out = []; + + foreach ($this->_fields as $field) { + if (!in_array($field->type, $field::LOGIN_FIELD_TYPES)) { + continue; + } + + $out[$field->name] = $field->label; + } + + return $out; + } + + protected function isUnique(string $field): bool + { + $db = DB::getInstance(); + + // First check that the field can be used as login + $sql = sprintf('SELECT (COUNT(DISTINCT transliterate_to_ascii(%s)) = COUNT(*)) FROM users WHERE %1$s IS NOT NULL AND %1$s != \'\';', $field); + + return (bool) $db->firstColumn($sql); + } + + public function changeLoginField(string $new_field, ?Session $session = null): void + { + $old_field = self::getLoginField(); + + if ($old_field === $new_field) { + return; + } + + if (empty($this->_fields[$new_field])) { + throw new \InvalidArgumentException('This field does not exist.'); + } + + $type = $this->_fields[$new_field]->type; + + if (!in_array($type, DynamicField::LOGIN_FIELD_TYPES)) { + throw new \InvalidArgumentException('This field cannot be used as a login field.'); + } + + if ($session) { + $user = $session->getUser(); + + if (empty($user->$new_field)) { + throw new UserException(sprintf('Le champ "%s" ne peut être utilisé comme champ de connexion car il est vide dans votre fiche de membre. Sinon vous ne pourriez plus vous connecter.', $this->_fields[$new_field]->label)); + } + } + + $db = DB::getInstance(); + + // First check that the field can be used as login + if (!$this->isUnique($new_field)) { + throw new UserException(sprintf('Le champ "%s" comporte des doublons et ne peut donc pas servir comme identifiant unique de connexion.', $this->_fields[$new_field]->label)); + } + + // Change login field in fields config table + $sql = sprintf('UPDATE %s SET system = system & ~%d WHERE system & %2$d; + UPDATE %1$s SET system = system | %2$d WHERE name = %s;', + self::TABLE, + DynamicField::LOGIN, + $db->quote($new_field) + ); + + $db->exec($sql); + + // Reload dynamic fields cache + $this->reload(); + + // Regenerate login index + $db->exec('DROP INDEX IF EXISTS users_id_field;'); + $this->createIndexes(); + } + + public function listEligibleNameFields(): array + { + $out = []; + + foreach ($this->_fields as $field) { + if (!in_array($field->type, $field::NAME_FIELD_TYPES)) { + continue; + } + + $out[$field->name] = $field->label; + } + + return $out; + } + + public function changeNameFields(array $fields): void + { + if ($fields === self::getNameFields()) { + return; + } + + $fields = array_unique($fields); + + if (count($fields) < 1) { + throw new UserException('Aucun champ n\'a été sélectionné pour l\'identité des membres.'); + } + + $has_text = false; + + foreach ($fields as $field) { + if (empty($this->_fields[$field])) { + throw new \InvalidArgumentException('This field does not exist: ' . $field); + } + + $type = $this->_fields[$field]->type; + + if (!in_array($type, DynamicField::NAME_FIELD_TYPES)) { + throw new \InvalidArgumentException('This field cannot be used as a name field: ' . $field); + } + + if ($type !== 'number') { + $has_text = true; + } + } + + if (!$has_text) { + throw new UserException('Aucun champ texte n\'a été sélectionné pour l\'identité des membres. Au moins un champ texte doit être sélectionné.'); + } + + $db = DB::getInstance(); + + $sql = sprintf('UPDATE %s SET system = system & ~%d WHERE system & %2$d; + UPDATE %1$s SET system = system | %2$d WHERE %s;', + self::TABLE, + DynamicField::NAMES, + $db->where('name', $fields) + ); + + $db->begin(); + $db->exec($sql); + $db->commit(); + + $this->reload(); + } +} diff --git a/src/include/lib/Paheko/Users/Session.php b/src/include/lib/Paheko/Users/Session.php new file mode 100644 index 0000000..377b076 --- /dev/null +++ b/src/include/lib/Paheko/Users/Session.php @@ -0,0 +1,735 @@ + self::ACCESS_NONE, + 'read' => self::ACCESS_READ, + 'write' => self::ACCESS_WRITE, + 'admin' => self::ACCESS_ADMIN, + ]; + + // Personalisation de la config de UserSession + protected bool $non_locking = true; + protected $cookie_name = 'pko'; + protected $remember_me_cookie_name = 'pkop'; + protected $remember_me_expiry = '+3 months'; + + protected ?User $_user; + protected ?array $_permissions; + protected ?array $_files_permissions; + + static protected $_instance = null; + + static public function getInstance() + { + // Use of static is important for Files\WebDAV\Session + return static::$_instance ?: static::$_instance = new static; + } + + public function __clone() + { + throw new \LogicException('Cannot clone'); + } + + public function __construct() + { + if (static::$_instance !== null) { + throw new \LogicException('Wrong call, use getInstance'); + } + + $url = parse_url(ADMIN_URL); + + parent::__construct(DB::getInstance(), [ + 'cookie_domain' => $url['host'], + 'cookie_path' => preg_replace('!/admin/$!', '/', $url['path']), + 'cookie_secure' => HTTP::getScheme() == 'https' ? true : false, + ]); + + $this->sid_in_url_secret = '&spko=' . sha1(SECRET_KEY); + } + + public function isPasswordCompromised($password) + { + if (!isset($this->http)) { + $this->http = new \KD2\HTTP; + } + + // Vérifier s'il n'y a pas un plugin qui gère déjà cet aspect + // notamment en installation mutualisée c'est plus efficace + $signal = Plugins::fire('password.check', true, ['password' => $password]); + + if ($signal && $signal->isStopped()) { + return (bool) $signal->getOut('is_compromised'); + } + + unset($signal); + + return parent::isPasswordCompromised($password); + } + + protected function getUserForLogin($login) + { + $id_field = DynamicFields::getLoginField(); + + // Ne renvoie un membre que si celui-ci a le droit de se connecter + $query = 'SELECT u.id, u.%1$s AS login, u.password, u.otp_secret + FROM users AS u + INNER JOIN users_categories AS c ON c.id = u.id_category + WHERE u.%1$s = ? COLLATE NOCASE AND c.perm_connect >= %2$d + LIMIT 1;'; + + $query = sprintf($query, $id_field, self::ACCESS_READ); + + return $this->db->first($query, $login); + } + + protected function getUserDataForSession($id) + { + $user = Users::get($id); + + if (!$user) { + return null; + } + + return $id; + } + + protected function storeRememberMeSelector($selector, $hash, $expiry, $user_id) + { + $selector = $this->cookie_name . '_' . $selector; + return $this->db->insert('users_sessions', [ + 'selector' => $selector, + 'hash' => $hash, + 'expiry' => $expiry, + 'id_user' => $user_id, + ]); + } + + protected function expireRememberMeSelectors() + { + return $this->db->delete('users_sessions', $this->db->where('expiry', '<', time())); + } + + protected function getRememberMeSelector($selector) + { + $selector = $this->cookie_name . '_' . $selector; + return $this->db->first('SELECT REPLACE(selector, ?, \'\') AS selector, hash, + s.id_user AS user_id, u.password AS user_password, expiry + FROM users_sessions AS s + LEFT JOIN users AS u ON u.id = s.id_user + WHERE s.selector = ? LIMIT 1;', $this->cookie_name . '_', $selector); + } + + protected function deleteRememberMeSelector($selector) + { + $selector = $this->cookie_name . '_' . $selector; + return $this->db->delete('users_sessions', $this->db->where('selector', $selector)); + } + + protected function deleteAllRememberMeSelectors($user_id) + { + return $this->db->delete('users_sessions', $this->db->where('id_user', $user_id)); + } + + public function isLogged(bool $disable_local_login = false) + { + $logged = parent::isLogged(); + + if ($logged && !$disable_local_login && LOCAL_LOGIN && LOCAL_LOGIN !== -1 && LOCAL_LOGIN !== $this->user) { + $logged = false; + } + + // Ajout de la gestion de LOCAL_LOGIN + if (!$logged && !$disable_local_login && LOCAL_LOGIN) { + $logged = $this->forceLogin(LOCAL_LOGIN); + } + + return $logged; + } + + public function forceLogin($login) + { + // Force login with a static user, that is not in the local database + // this is useful for using a SSO like LDAP for example + if (is_array($login)) { + $this->_user = (new User)->import($login['user'] ?? []); + + if (isset($login['user']['_name'])) { + $name = DynamicFields::getFirstNameField(); + $this->_user->$name = $login['user']['_name']; + } + + $this->_permissions = []; + + foreach (Category::PERMISSIONS as $perm => $data) { + $this->_permissions[$perm] = $login['permissions'][$perm] ?? self::ACCESS_NONE; + } + + return true; + } + + // Look for the first user with the permission to manage the configuration + if (-1 === $login) { + $login = $this->db->firstColumn('SELECT id FROM users + WHERE id_category IN (SELECT id FROM users_categories WHERE perm_config = ?) + LIMIT 1', self::ACCESS_ADMIN); + } + + // Only login if required + if ($login > 0 && ($this->user ?? null) != $login) { + return $this->create($login); + } + + return isset($this->user) ? true : false; + } + + public function login($login, $password, $remember_me = false) + { + $success = parent::login($login, $password, $remember_me); + $user_agent = substr($_SERVER['HTTP_USER_AGENT'] ?? '', 0, 150) ?: null; + + if (true === $success) { + Log::add(Log::LOGIN_SUCCESS, compact('user_agent')); + + // Mettre à jour la date de connexion + $this->db->preparedQuery('UPDATE users SET date_login = datetime() WHERE id = ?;', [$this->getUser()->id]); + } + // $success can be 'OTP' as well + elseif (!$success) { + if ($user = $this->getUserForLogin($login)) { + Log::add(Log::LOGIN_FAIL, compact('user_agent'), $user->id); + } + else { + Log::add(Log::LOGIN_FAIL, compact('user_agent')); + } + } + + Plugins::fire('user.login.after', false, compact('login', 'password', 'remember_me', 'success')); + + // Clean up logs + Log::clean(); + + return $success; + } + + public function loginOTP(string $code): bool + { + $this->start(); + $user_id = $_SESSION['userSessionRequireOTP']->user->id ?? null; + $user_agent = substr($_SERVER['HTTP_USER_AGENT'] ?? '', 0, 150) ?: null; + $details = compact('user_agent') + ['otp' => true]; + + $success = parent::loginOTP($code); + + if ($success) { + Log::add(Log::LOGIN_SUCCESS, $details, $user_id); + + // Mettre à jour la date de connexion + $this->db->preparedQuery('UPDATE users SET date_login = datetime() WHERE id = ?;', [$user_id]); + } + else { + Log::add(Log::LOGIN_FAIL, $details, $user_id); + } + + Plugins::fire('user.login.otp', false, compact('success', 'user_id')); + + return $success; + } + + public function logout(bool $all = false) + { + $this->_user = null; + $this->_permissions = null; + $this->_files_permissions = null; + + return parent::logout(); + } + + public function recoverPasswordSend(string $id): void + { + $user = $this->fetchUserForPasswordRecovery($id); + + if (!$user) { + throw new UserException('Aucun membre trouvé avec cette adresse e-mail, ou le membre trouvé n\'a pas le droit de se connecter.'); + } + + if ($user->perm_connect == self::ACCESS_NONE) { + throw new UserException('Ce membre n\'a pas le droit de se connecter.'); + } + + $email = DynamicFields::getFirstEmailField(); + + if (!trim($user->$email)) { + throw new UserException('Ce membre n\'a pas d\'adresse e-mail renseignée dans son profil.'); + } + + $user_agent = substr($_SERVER['HTTP_USER_AGENT'] ?? '', 0, 150) ?: null; + Log::add(Log::LOGIN_RECOVER, compact('user_agent'), $user->id); + + $query = $this->makePasswordRecoveryQuery($user); + + $url = ADMIN_URL . 'password.php?c=' . $query; + + EmailsTemplates::passwordRecovery($user->$email, $url, $user->pgp_key); + } + + protected function fetchUserForPasswordRecovery(string $identifier, ?string $identifier_field = null): ?\stdClass + { + $db = DB::getInstance(); + + $identifier_field ??= DynamicFields::getLoginField(); + $email_field = DynamicFields::getFirstEmailField(); + + if ($identifier_field === 'id') { + $identifier = (int) $identifier; + } + else { + $identifier = trim($identifier); + } + + // Fetch user, must have an email + $sql = sprintf('SELECT u.id, u.%s AS email, u.password, u.pgp_key, c.perm_connect + FROM users u + INNER JOIN users_categories c ON c.id = u.id_category + WHERE u.%s = ? COLLATE U_NOCASE + AND u.%1$s IS NOT NULL + LIMIT 1;', + $db->quoteIdentifier($email_field), + $db->quoteIdentifier($identifier_field)); + + return $db->first($sql, $identifier) ?: null; + } + + protected function makePasswordRecoveryHash(\stdClass $user, ?int $expire = null): string + { + // valide pour 1 heure minimum + $expire = $expire ?? ceil((time() - strtotime('2017-01-01')) / 3600) + 1; + + $hash = hash_hmac('sha256', $user->email . $user->id . $user->password . $expire, SECRET_KEY, true); + $hash = substr(Security::base64_encode_url_safe($hash), 0, 16); + return $hash; + } + + protected function makePasswordRecoveryQuery(\stdClass $user): string + { + $expire = ceil((time() - strtotime('2017-01-01')) / 3600) + 1; + $hash = $this->makePasswordRecoveryHash($user, $expire); + $id = base_convert($user->id, 10, 36); + $expire = base_convert($expire, 10, 36); + return sprintf('%s.%s.%s', $id, $expire, $hash); + } + + /** + * Check that the supplied query is valid, if so, return the user information + * @param string $query User-supplied query + */ + public function checkRecoveryPasswordQuery(string $query): ?\stdClass + { + if (substr_count($query, '.') !== 2) { + return null; + } + + list($id, $expire, $email_hash) = explode('.', $query); + + $id = base_convert($id, 36, 10); + $expire = base_convert($expire, 36, 10); + + $expire_timestamp = ($expire * 3600) + strtotime('2017-01-01'); + + // Check that the query has not expired yet + if (time() / 3600 > $expire_timestamp) { + return null; + } + + // Fetch user info + $user = $this->fetchUserForPasswordRecovery($id, 'id'); + + if (!$user) { + return null; + } + + // Check hash using secret data from the user + $hash = $this->makePasswordRecoveryHash($user, $expire); + + if (!hash_equals($hash, $email_hash)) { + return null; + } + + return $user; + } + + public function recoverPasswordChange(string $query, string $password, string $password_confirmed) + { + $user = $this->checkRecoveryPasswordQuery($query); + + if (null === $user) { + throw new UserException('Le code permettant de changer le mot de passe a expiré. Merci de bien vouloir recommencer la procédure.'); + } + + $ue = Users::get($user->id); + $ue->importSecurityForm(false, compact('password', 'password_confirmed')); + $ue->save(false); + EmailsTemplates::passwordChanged($ue); + } + + public function user(): ?User + { + return $this->getUser(); + } + + static public function getPreference(string $key) + { + $user = self::getLoggedUser(); + return $user ? $user->getPreference($key) : null; + } + + static public function getLoggedUser(): ?User + { + $s = self::getInstance(); + + if (!$s->isLogged()) { + return null; + } + + return $s->getUser(); + } + + /** + * Returns cookie string for PDF printing + */ + static public function getCookie(): ?string + { + $i = self::getInstance(); + + if (!$i->isLogged()) { + return null; + } + + return sprintf('%s=%s', $i->cookie_name, $i->id()); + } + + static public function getCookieSecret(): string + { + return self::getInstance()->sid_in_url_secret; + } + + public function getUser() + { + if (isset($this->_user)) { + return $this->_user; + } + + if (!$this->isLogged()) + { + throw new \LogicException('User is not logged in.'); + } + + $this->_user = Users::get($this->user); + + if (!$this->_user) { + $this->logout(); + // User does not exist anymore + } + + $this->_permissions = null; + $this->_files_permissions = null; + return $this->_user; + } + + static public function getUserId(): ?int + { + $i = self::getInstance(); + + if (!$i->isLogged()) { + return null; + } + + return $i->getUser()->id; + } + + public function canAccess(string $section, int $permission): bool + { + if (!$this->isLogged()) { + return false; + } + + if (!isset($this->_permissions)) { + $this->_permissions = $this->user()->category()->getPermissions(); + } + + $perm = $this->_permissions[$section] ?? null; + + if (null === $perm) { + throw new \InvalidArgumentException('Unknown section: ' . $section); + } + + return ($perm >= $permission); + } + + public function requireAccess(string $section, int $permission): void + { + if (!$this->canAccess($section, $permission)) { + throw new UserException('Vous n\'avez pas le droit d\'accéder à cette page.'); + } + } + + /** + * Check permissions for extensions files (plugins, modules) + */ + public function checkExtensionFilePermissions(string $path, string $permission): bool + { + $context = strtok($path, '/'); + $type = strtok('/'); + $name = strtok('/'); + $file_path = strtok(''); + + if (empty($name) || empty($type) || ($type !== 'm' && $type !== 'p') || empty($file_path)) { + return false; + } + + $public = substr($file_path, 0, 7) === 'public/'; + + $base = $context . '/' . $type . '/' . $name . '/'; + + if ($public) { + $base .= 'public/'; + } + + // Build cache + if (!isset($this->_files_permissions[$base])) { + $read = $write = false; + // Public files + if (!$this->isLogged() && $public) { + $read = true; + } + // Other files are private + elseif ($this->isLogged()) { + if ('p' === $type) { + $ext = Plugins::get($name); + } + else { + $ext = Modules::get($name); + } + + $read = $write = $ext->restrict_section ? $this->canAccess($ext->restrict_section, $ext->restrict_level) : false; + } + + $this->_files_permissions[$base] = [ + 'mkdir' => $write, + 'move' => $write, + 'write' => $write, + 'create' => $write, + 'delete' => $write, + 'read' => $read, + 'share' => $write, + ]; + } + + return $this->_files_permissions[$base][$permission]; + } + + public function checkFilePermission(string $path, string $permission): bool + { + $path = ltrim($path, '/'); + + if (!isset($this->_files_permissions)) { + $this->_files_permissions = Files::buildUserPermissions($this); + } + + $context = strtok($path, '/'); + $b = strtok('/'); + $c = strtok('/'); + strtok(''); + + // Check permissions for plugins and modules files + if ($context === File::CONTEXT_EXTENSIONS) { + return $this->checkExtensionFilePermissions($path, $permission); + } + + static $default = [ + 'mkdir' => false, + 'move' => false, + 'create' => false, + 'read' => false, + 'write' => false, + 'delete' => false, + 'share' => false, + ]; + + $file_permissions = $default; + + // Access to user files is quite specific as it is defined per field + if ($context === File::CONTEXT_USER) { + if (!$c) { + if ($this->canAccess(self::SECTION_USERS, self::ACCESS_READ)) { + $file_permissions['read'] = true; + } + + return $file_permissions[$permission]; + } + + // Always match by field name + $path_level = $context . '//' . $c; + + $field = DynamicFields::get($c); + + if (!$field) { + return $default[$permission]; + } + + if ($this->isLogged() && (int)$b === $this::getUserId()) { + $read = $field->user_access_level >= self::ACCESS_READ; + $write = $field->user_access_level >= self::ACCESS_WRITE; + } + else { + $read = $this->canAccess(self::SECTION_USERS, $field->management_access_level); + $write = $this->canAccess(self::SECTION_USERS, self::ACCESS_WRITE) && $this->canAccess(self::SECTION_USERS, $field->management_access_level); + } + + $file_permissions['read'] = $read; + $file_permissions['write'] = + $file_permissions['delete'] = + $file_permissions['create'] = $write; + return $file_permissions[$permission]; + } + else { + // Remove components + $path_level = preg_replace('!/[^/]+!', '/', $path); + } + + foreach ($this->_files_permissions as $context => $permissions) { + if (!array_key_exists($permission, $permissions)) { + throw new \InvalidArgumentException(sprintf('Unknown permission "%s" in context "%s"', $permission, $context)); + } + + if ('' === $context + || $context == $path + || 0 === strpos($path, $context) + || 0 === strpos($path_level, $context)) { + return $permissions[$permission]; + } + } + + throw new \InvalidArgumentException(sprintf('Unknown context: %s', $path)); + } + + public function getFilePermissions(string $path): ?array + { + if (!isset($this->_files_permissions)) { + $this->_files_permissions = Files::buildUserPermissions($this); + } + + return $this->_files_permissions[$path] ?? null; + } + + public function requireFilePermission(string $context, string $permission) + { + if (!$this->checkFilePermission($context, $permission)) { + throw new UserException('Vous n\'avez pas le droit d\'effectuer cette action.'); + } + } + + // Ici checkOTP utilise NTP en second recours + public function checkOTP($secret, $code) + { + if (Security_OTP::TOTP($secret, $code)) + { + return true; + } + + // Vérifier encore, mais avec le temps NTP + // au cas où l'horloge du serveur n'est pas à l'heure + if (\Paheko\NTP_SERVER + && ($time = Security_OTP::getTimeFromNTP(\Paheko\NTP_SERVER)) + && Security_OTP::TOTP($secret, $code, $time)) + { + return true; + } + + return false; + } + + public function getNewOTPSecret() + { + $config = Config::getInstance(); + $out = []; + $out['secret'] = Security_OTP::getRandomSecret(); + $out['secret_display'] = implode(' ', str_split($out['secret'], 4)); + + $icon = $config->fileURL('icon'); + $out['url'] = Security_OTP::getOTPAuthURL($config->org_name, $out['secret'], 'totp', $icon); + + $qrcode = new QRCode($out['url']); + $out['qrcode'] = 'data:image/svg+xml;base64,' . base64_encode($qrcode->toSVG()); + + return $out; + } + + public function countActiveSessions(): int + { + $selector = $this->getRememberMeCookie()->selector ?? null; + $user = $this->getUser(); + return DB::getInstance()->count('users_sessions', 'id_user = ? AND selector != ?', $user->id(), $this->cookie_name . '_' . $selector) + 1; + } + + public function isAdmin(): bool + { + return $this->canAccess(self::SECTION_CONNECT, self::ACCESS_READ) + && $this->canAccess(self::SECTION_CONFIG, self::ACCESS_ADMIN); + } +} diff --git a/src/include/lib/Paheko/Users/Users.php b/src/include/lib/Paheko/Users/Users.php new file mode 100644 index 0000000..cb6162c --- /dev/null +++ b/src/include/lib/Paheko/Users/Users.php @@ -0,0 +1,592 @@ +default_category; + $user = new User; + $user->set('id_category', $default_category); + return $user; + } + + static public function iterateAssocByCategory(?int $id_category = null): iterable + { + $where = $id_category ? sprintf('id_category = %d', $id_category) : 'id_category IN (SELECT id FROM users_categories WHERE hidden = 0)'; + + $sql = sprintf('SELECT id, %s AS name FROM users WHERE %s ORDER BY name COLLATE U_NOCASE;', + DynamicFields::getNameFieldsSQL(), + $where); + + foreach (DB::getInstance()->iterate($sql) as $row) { + yield $row->id => $row->name; + } + } + + static protected function iterateEmails(array $sql, string $email_column = '_email'): \Generator + { + foreach (DB::getInstance()->iterate(implode(' UNION ALL ', $sql)) as $row) { + yield $row->$email_column => $row; + } + } + + /** + * Return a list for all emails for a specific mailing checkbox + */ + static public function iterateEmailsByField(string $field_name, $field_value): iterable + { + $db = DB::getInstance(); + $field = DynamicFields::get($field_name); + + if (!$field) { + throw new \InvalidArgumentException('Unknown field: ' . $field_name); + } + + if (is_bool($field_value)) { + $field_value = (int)$field_value; + } + else { + $field_value = $db->quote($field_value); + } + + $sql = []; + $where = sprintf('%s = %d', $db->quoteIdentifier($field->name), $field_value); + $where .= ' AND id_category IN (SELECT id FROM users_categories WHERE hidden = 0)'; + + $fields = DynamicFields::getEmailFields(); + + foreach ($fields as $field) { + $sql[] = sprintf('SELECT *, %s AS _email, NULL AS preferences FROM users WHERE %s AND %1$s IS NOT NULL', $db->quoteIdentifier($field), $where); + } + + return self::iterateEmails($sql); + } + + /** + * Return a list for all emails by category + * @param int|null $id_category If NULL, then all categories except hidden ones will be returned + */ + static public function iterateEmailsByCategory(?int $id_category = null): iterable + { + $db = DB::getInstance(); + $fields = DynamicFields::getEmailFields(); + $sql = []; + $where = $id_category ? sprintf('id_category = %d', $id_category) : 'id_category IN (SELECT id FROM users_categories WHERE hidden = 0)'; + + foreach ($fields as $field) { + $sql[] = sprintf('SELECT *, %s AS _email, NULL AS preferences FROM users WHERE %s AND %1$s IS NOT NULL', $db->quoteIdentifier($field), $where); + } + + return self::iterateEmails($sql); + } + + /** + * Return a list of all emails by service (user must be active) + */ + static public function iterateEmailsByActiveService(int $id_service): iterable + { + $db = DB::getInstance(); + + // Create a temporary table + if (!$db->test('sqlite_temp_master', 'type = \'table\' AND name=\'users_active_services\'')) { + $db->exec('DROP TABLE IF EXISTS users_active_services; + CREATE TEMPORARY TABLE IF NOT EXISTS users_active_services (id, service); + INSERT INTO users_active_services SELECT id_user, id_service FROM ( + SELECT id_user, id_service, MAX(expiry_date) FROM services_users + WHERE expiry_date IS NULL OR expiry_date >= date() + GROUP BY id_user, id_service + ); + DELETE FROM users_active_services WHERE id IN (SELECT id FROM users WHERE id_category IN (SELECT id FROM users_categories WHERE hidden =1));'); + } + + $fields = DynamicFields::getEmailFields(); + $sql = []; + + foreach ($fields as $field) { + $sql[] = sprintf('SELECT u.*, u.%s AS _email, NULL AS preferences FROM users u INNER JOIN users_active_services s ON s.id = u.id + WHERE s.service = %d AND %1$s IS NOT NULL', $db->quoteIdentifier($field), $id_service); + } + + return self::iterateEmails($sql); + } + + static public function iterateEmailsBySearch(int $id_search): iterable + { + $db = DB::getInstance(); + + $s = Search::get($id_search); + // Make sure the query is protected and safe, by doing a protectSelect + $s->query(['limit' => 1]); + + $header = $s->getHeader(); + $id_column = null; + + if (in_array('id', $header)) { + $id_column = 'id'; + } + elseif (in_array('_user_id', $header)) { + $id_column = '_user_id'; + } + else { + throw new UserException('La recherche ne comporte pas de colonne "id" ou "_user_id", et donc ne permet pas l\'envoi d\'email.'); + } + + $columns = array_map([$db, 'quoteIdentifier'], $header); + $columns = implode(', ', $columns); + + // We only need the user id, store it in a temporary table for now + $db->exec(sprintf('DROP TABLE IF EXISTS users_tmp_search; CREATE TEMPORARY TABLE IF NOT EXISTS users_tmp_search (%s);', $columns)); + $db->exec(sprintf('INSERT INTO users_tmp_search SELECT * FROM (%s)', $s->SQL(['no_limit' => true]))); + + $fields = DynamicFields::getEmailFields(); + + $sql = []; + + foreach ($fields as $field) { + $sql[] = sprintf('SELECT s.*, u.*, u.%s AS _email, NULL AS preferences + FROM users u + INNER JOIN users_tmp_search AS s ON s.%s = u.id + WHERE u.%1$s IS NOT NULL', + $db->quoteIdentifier($field), + $db->quoteIdentifier($id_column) + ); + } + + return self::iterateEmails($sql); + } + + static public function listByCategory(?int $id_category = null, ?Session $session = null): DynamicList + { + $db = DB::getInstance(); + $df = DynamicFields::getInstance(); + $number_field = $df->getNumberField(); + $name_fields = $df->getNameFields(); + + $columns = [ + '_user_id' => [ + 'select' => 'u.id', + ], + '_user_name_index' => [ + 'select' => $df::getNameFieldsSearchableSQL('s'), + ], + ]; + + $number_column = [ + 'label' => 'Num.', + 'select' => 'u.' . $db->quoteIdentifier($number_field), + ]; + + $identity_column = [ + 'label' => $df->getNameLabel(), + 'select' => $df->getNameFieldsSQL('u'), + 'order' => '_user_name_index %s', + ]; + + $fields = $df->getListedFields(); + + foreach ($fields as $key => $config) { + // Skip number field + if ($key === $number_field) { + if (null !== $number_column) { + $columns['number'] = $number_column; + $number_column = null; + } + + continue; + } + // Skip name fields + elseif (in_array($key, $name_fields)) { + if (null !== $identity_column) { + $columns['identity'] = $identity_column; + $identity_column = null; + } + + continue; + } + + if ($session && !$session->canAccess(Session::SECTION_USERS, $config->management_access_level)) { + continue; + } + + $columns[$key] = [ + 'label' => $config->label, + 'select' => 'u.' . $db->quoteIdentifier($key), + ]; + + if ($config->hasSearchCache($key)) { + $columns[$key]['order'] = sprintf('s.%s %%s', $db->quoteIdentifier($key)); + } + + if ($config->type == 'file') { + $columns[$key]['select'] = sprintf('(SELECT GROUP_CONCAT(f.path, \';\') + FROM users_files uf + INNER JOIN files f ON f.id = uf.id_file AND f.trash IS NULL + WHERE uf.id_user = u.id AND uf.field = %s)', + $db->quote($key) + ); + } + } + + if (null !== $identity_column) { + $columns['identity'] = $identity_column; + } + + $tables = 'users_view u'; + $tables .= ' INNER JOIN users_search s ON s.id = u.id'; + + if ($db->test('users', 'is_parent = 1')) { + $tables .= ' LEFT JOIN users b ON b.id = u.id_parent'; + + $columns['id_parent'] = [ + 'label' => 'Rattaché à', + 'select' => 'u.id_parent', + 'order' => 'u.id_parent IS NULL, _parent_name COLLATE U_NOCASE %s, _user_name_index %1$s', + ]; + + $columns['_parent_name'] = [ + 'select' => sprintf('CASE WHEN u.id_parent IS NOT NULL THEN %s ELSE NULL END', $df->getNameFieldsSQL('b')), + ]; + + $columns['is_parent'] = [ + 'label' => 'Responsable', + 'select' => 'u.is_parent', + 'order' => 'u.is_parent DESC, _user_name_index %1$s', + ]; + } + + if (!$id_category) { + $conditions = sprintf('u.id_category IN (SELECT id FROM users_categories WHERE hidden = 0)'); + } + elseif ($id_category > 0) { + $conditions = sprintf('u.id_category = %d', $id_category); + } + else { + $conditions = '1'; + } + + $order = 'identity'; + + if (!isset($columns[$order])) { + $order = key($fields) ?? 'number'; + } + + $list = new DynamicList($columns, $tables, $conditions); + $list->orderBy($order, false); + + return $list; + } + + static public function get(int $id): ?User + { + return EM::findOneById(User::class, $id, 'users_view'); + } + + static public function getName(int $id): ?string + { + $name = DynamicFields::getNameFieldsSQL(); + $found = EM::getInstance(User::class)->col(sprintf('SELECT %s FROM @TABLE WHERE id = ?;', $name), $id); + $found = (string) $found; + return $found ?: null; + } + + static public function getNames(array $ids): array + { + $name = DynamicFields::getNameFieldsSQL(); + $db = EM::getInstance(User::class)->DB(); + return $db->getAssoc(sprintf('SELECT id, %s FROM users WHERE %s;', $name, $db->where('id', $ids))); + } + + static public function getFromNumber(string $number): ?User + { + $field = DynamicFields::getNumberFieldSQL(); + return EM::findOne(User::class, 'SELECT * FROM @TABLE_view WHERE ' . $field . ' = ?', $number); + } + + static public function getIdFromNumber(string $number): ?int + { + $field = DynamicFields::getNumberFieldSQL(); + return EM::getInstance(User::class)->col('SELECT id FROM @TABLE WHERE ' . $field . ' = ?', $number) ?: null; + } + + static public function getNameFromNumber(string $number): ?string + { + $name = DynamicFields::getNameFieldsSQL(); + $field = DynamicFields::getNumberFieldSQL(); + $found = EM::getInstance(User::class)->col(sprintf('SELECT %s FROM @TABLE WHERE %s = ?;', $name, $field), $number); + $found = (string) $found; + return $found ?: null; + } + + static public function deleteSelected(array $ids): void + { + $ids = array_map('intval', $ids); + + if ($logged_user_id = Session::getUserId()) { + if (in_array($logged_user_id, $ids)) { + throw new UserException('Il n\'est pas possible de supprimer son propre compte.'); + } + } + + foreach ($ids as $id) { + Files::delete(File::CONTEXT_USER . '/' . $id); + } + + $db = DB::getInstance(); + + // Suppression du membre + $db->delete(User::TABLE, $db->where('id', $ids)); + } + + static public function deleteFilesSelected(array $ids): void + { + $ids = array_map('intval', $ids); + + foreach ($ids as $id) { + Files::delete(File::CONTEXT_USER . '/' . $id); + } + } + + static public function changeCategorySelected(int $category_id, array $ids): void + { + $db = DB::getInstance(); + + if (!$db->test(Category::TABLE, 'id = ?', $category_id)) { + throw new \InvalidArgumentException('Invalid category ID: ' . $category_id); + } + + $ids = array_map('intval', $ids); + + // Don't allow current user ID to change his/her category + $logged_user_id = Session::getUserId(); + $ids = array_filter($ids, fn($a) => $a != $logged_user_id); + + $db->update(User::TABLE, + ['id_category' => $category_id], + $db->where('id', $ids) + ); + } + + static public function exportSelected(string $format, array $ids): void + { + $db = DB::getInstance(); + + $ids = array_map('intval', $ids); + $where = $db->where('id', $ids); + $name = sprintf('Liste de %d membres', count($ids)); + self::exportWhere($format, $name, $where); + } + + static public function exportCategory(string $format, int $id_category): void + { + if ($id_category == -1) { + $name = 'Tous les membres'; + $where = '1'; + } + elseif (!$id_category) { + $name = 'Membres sauf catégories cachées'; + $where = 'id_category NOT IN (SELECT id FROM users_categories WHERE hidden = 1)'; + } + else { + $cat = Categories::get($id_category); + $name = sprintf('Membres - %s', $cat->name); + $where = sprintf('id_category = %d', $id_category); + } + + self::exportWhere($format, $name, $where); + } + + static public function export(string $format): void + { + self::exportWhere($format, 'Tous les membres', '1'); + } + + static protected function exportWhere(string $format, string $name, string $where): void + { + $df = DynamicFields::getInstance(); + $db = DB::getInstance(); + + $header = $df->listAssocNames(); + $columns = array_keys($header); + $columns = array_map([$db, 'quoteIdentifier'], $columns); + $columns = implode(', ', $columns); + $header['category'] = 'Catégorie'; + + $i = $db->iterate(sprintf('SELECT %s, (SELECT name FROM users_categories WHERE id = u.id_category) AS category FROM users_view u WHERE %s;', $columns, $where)); + + CSV::export($format, $name, $i, $header, [self::class, 'exportRowCallback']); + } + + static public function exportRowCallback(&$row) { + $df = DynamicFields::getInstance(); + + foreach ($row as $key => &$value) { + $field = $df->get($key); + + if (!$field || null === $value) { + continue; + } + + if ($field->type === 'date' && is_string($value)) { + $value = \DateTime::createFromFormat('!Y-m-d', $value); + } + elseif ($field->type === 'datetime' && is_string($value)) { + $value = \DateTime::createFromFormat('!Y-m-d', $value); + } + else { + $value = $field->getStringValue($value); + } + } + + unset($value); + } + + static public function importReport(CSV_Custom $csv, string $mode, ?int $logged_user_id = null): array + { + if (!in_array($mode, self::IMPORT_MODES)) { + throw new \InvalidArgumentException('Invalid import mode: ' . $mode); + } + + $report = ['created' => [], 'modified' => [], 'unchanged' => [], 'errors' => []]; + + if ($logged_user_id) { + $report['has_logged_user'] = false; + } + + foreach (self::iterateImport($csv, $mode, $report['errors']) as $line => $user) { + if ($logged_user_id && $user->id == $logged_user_id) { + $report['has_logged_user'] = true; + continue; + } + + try { + $user->selfCheck(); + } + catch (UserException $e) { + $report['errors'][] = sprintf('Ligne %d (%s) : %s', $line, $user->name(), $e->getMessage()); + continue; + } + + if (!$user->exists()) { + $report['created'][] = $user; + } + elseif ($user->isModified()) { + $report['modified'][] = $user; + } + else { + $report['unchanged'][] = $user; + } + } + + return $report; + } + + static public function import(CSV_Custom $csv, string $mode, ?int $logged_user_id = null): void + { + if (!in_array($mode, self::IMPORT_MODES)) { + throw new \InvalidArgumentException('Invalid import mode: ' . $mode); + } + + $db = DB::getInstance(); + $db->begin(); + + foreach (self::iterateImport($csv, $mode) as $i => $user) { + // Skip logged user, to avoid changing own login field + if ($logged_user_id && $user->id == $logged_user_id) { + continue; + } + + try { + $user->save(); + } + catch (UserException $e) { + throw new UserException(sprintf('Ligne %d : %s', $i, $e->getMessage()), 0, $e); + } + } + + $db->commit(); + } + + static public function iterateImport(CSV_Custom $csv, string $mode, ?array &$errors = null): \Generator + { + if (!in_array($mode, self::IMPORT_MODES)) { + throw new \InvalidArgumentException('Invalid import mode: ' . $mode); + } + + $number_field = DynamicFields::getNumberField(); + + foreach ($csv->iterate() as $i => $row) { + $user = null; + + try { + if ($mode === 'update') { + if (empty($row->$number_field)) { + throw new UserException('Aucun numéro de membre n\'a été indiqué'); + } + + $user = self::getFromNumber($row->$number_field); + + if (!$user) { + $msg = sprintf('Le membre avec le numéro "%s" n\'existe pas.', $row->$number_field); + throw new UserException($msg); + } + } + elseif ($mode === 'auto' && !empty($row->$number_field)) { + $user = self::getFromNumber($row->$number_field); + } + + if (!$user) { + $user = self::create(); + + if ($mode === 'create' || empty($row->$number_field)) { + $user->$number_field = null; + $user->setNumberIfEmpty(); + unset($row->$number_field); + } + } + + $user->importForm((array)$row); + yield $i => $user; + } + catch (UserException $e) { + if (null !== $errors) { + $errors[] = sprintf('Ligne %d : %s', $i, $e->getMessage()); + continue; + } + + throw $e; + } + } + } +} diff --git a/src/include/lib/Paheko/Utils.php b/src/include/lib/Paheko/Utils.php new file mode 100644 index 0000000..f6c5121 --- /dev/null +++ b/src/include/lib/Paheko/Utils.php @@ -0,0 +1,1613 @@ + '↑', + 'down' => '↓', + 'export' => '↷', + 'import' => '↶', + 'reset' => '↺', + 'upload' => '⇑', + 'download' => '⇓', + 'home' => '⌂', + 'print' => '⎙', + 'star' => '★', + 'check' => '☑', + 'settings' => '☸', + 'alert' => '⚠', + 'mail' => '✉', + 'edit' => '✎', + 'delete' => '✘', + 'help' => '❓', + 'plus' => '➕', + 'minus' => '➖', + 'login' => '⇥', + 'logout' => '⤝', + 'eye-off' => '⤫', + 'menu' => '𝍢', + 'eye' => '👁', + 'user' => '👤', + 'users' => '👪', + 'calendar' => '📅', + 'attach' => '📎', + 'search' => '🔍', + 'lock' => '🔒', + 'unlock' => '🔓', + 'folder' => '🗀', + 'document' => '🗅', + 'bold' => 'B', + 'italic' => 'I', + 'header' => 'H', + 'text' => 'T', + 'paragraph' => '§', + 'list-ol' => '1', + 'list-ul' => '•', + 'table' => '◫', + 'radio-unchecked' => '◯', + 'uncheck' => '☐', + 'radio-checked' => '⬤', + 'image' => '🖻', + 'left' => '←', + 'right' => '→', + 'column' => '▚', + 'del-column' => '🮔', + 'reload' => '🗘', + 'gallery' => '🖼', + 'code' => '<', + 'markdown' => 'M', + 'globe' => '🌍', + 'video' => '▶', + 'quote' => '«', + 'money' => '€', + 'pdf' => 'P', + 'trash' => '🗑', + 'history' => '⌚', + ]; + + const FRENCH_DATE_NAMES = [ + 'January' => 'janvier', + 'February' => 'février', + 'March' => 'mars', + 'April' => 'avril', + 'May' => 'mai', + 'June' => 'juin', + 'July' => 'juillet', + 'August' => 'août', + 'September' => 'septembre', + 'October' => 'octobre', + 'November' => 'novembre', + 'December' => 'décembre', + 'Monday' => 'lundi', + 'Tuesday' => 'mardi', + 'Wednesday' => 'mercredi', + 'Thursday' => 'jeudi', + 'Friday' => 'vendredi', + 'Saturday' => 'samedi', + 'Sunday' => 'dimanche', + 'Jan' => 'jan', + 'Feb' => 'fév', + 'Mar' => 'mar', + 'Apr' => 'avr', + 'Jun' => 'juin', + 'Jul' => 'juil', + 'Aug' => 'août', + 'Sep' => 'sep', + 'Oct' => 'oct', + 'Nov' => 'nov', + 'Dec' => 'déc', + 'Mon' => 'lun', + 'Tue' => 'mar', + 'Wed' => 'mer', + 'Thu' => 'jeu', + 'Fri' => 'ven', + 'Sat' => 'sam', + 'Sun' => 'dim', + ]; + + static public function get_datetime($ts) + { + if (null === $ts) { + return null; + } + + if (is_object($ts) && $ts instanceof \DateTimeInterface) { + return $ts; + } + elseif (is_numeric($ts)) { + $ts = new \DateTime('@' . $ts); + $ts->setTimezone(new \DateTimeZone(date_default_timezone_get())); + return $ts; + } + elseif (preg_match('!^\d{2}/\d{2}/\d{4}$!', $ts)) { + return \DateTime::createFromFormat('!d/m/Y', $ts); + } + elseif (strlen($ts) == 10) { + return \DateTime::createFromFormat('!Y-m-d', $ts); + } + elseif (strlen($ts) == 19) { + return \DateTime::createFromFormat('Y-m-d H:i:s', $ts); + } + elseif (strlen($ts) == 16) { + return \DateTime::createFromFormat('!Y-m-d H:i', $ts); + } + else { + return null; + } + } + + static public function strftime_fr($ts, $format) + { + $ts = self::get_datetime($ts); + + if (null === $ts) { + return $ts; + } + + $date = Translate::strftime($format, $ts, 'fr_FR'); + return $date; + } + + static public function date_fr($ts, $format = null) + { + $ts = self::get_datetime($ts); + + if (null === $ts) { + return $ts; + } + + if (is_null($format)) + { + $format = 'd/m/Y à H:i'; + } + + $date = $ts->format($format); + + $date = strtr($date, self::FRENCH_DATE_NAMES); + return $date; + } + + static public function shortDate($ts, bool $with_hour = false): ?string + { + return self::date_fr($ts, 'd/m/Y' . ($with_hour ? ' à H\hi' : '')); + } + + /** + * @deprecated + */ + static public function checkDate($str) + { + if (!preg_match('!^(\d{4})-(\d{2})-(\d{2})$!', $str, $match)) + return false; + + if (!checkdate($match[2], $match[3], $match[1])) + return false; + + return true; + } + + /** + * @deprecated + */ + static public function checkDateTime($str) + { + if (!preg_match('!^(\d{4}-\d{2}-\d{2})[T ](\d{2}):(\d{2})!', $str, $match)) + return false; + + if (!self::checkDate($match[1])) + return false; + + if ((int) $match[2] < 0 || (int) $match[2] > 23) + return false; + + if ((int) $match[3] < 0 || (int) $match[3] > 59) + return false; + + if (isset($match[4]) && ((int) $match[4] < 0 || (int) $match[4] > 59)) + return false; + + return true; + } + + static public function moneyToInteger($value) + { + if (null === $value || trim($value) === '') { + return 0; + } + + if (!preg_match('/^(-?)(\d+)(?:[,.](\d{1,3}))?/', $value, $match)) { + throw new UserException(sprintf('Le montant est invalide : %s. Exemple de format accepté : 142,02', $value)); + } + + $cents = $match[3] ?? '0'; + + if (strlen($cents) === 1) { + $cents .= '0'; + } + + $more = (int) substr($cents, 2, 1); + $cents = (int) substr($cents, 0, 2); + + if ($more >= 5) { + $cents++; + } + + $value = $match[1] . $match[2] . str_pad((string)$cents, 2, '0', STR_PAD_LEFT); + $value = (int) $value; + return $value; + } + + static public function weightToInteger($number): ?int + { + if (null === $number || '' === $number) { + return null; + } + + if (-1 == $number) { + return -1; + } + + $w = explode('.', str_replace(',', '.', $number)); + $a = $w[0] ?: '0'; + $b = substr(($w[1] ?? '0') . '000', 0, 3); + return intval($a . $b); + } + + static public function format_weight($number, bool $empty_is_zero = false, bool $append_unit = false): string + { + if (empty($number) || $number < 0) { + return $empty_is_zero ? '0' : ''; + } + + $decimals = substr($number, -3); + + if ((int)$decimals > 0) { + $decimals = ',' . substr('000' . $decimals, -3); + } + else { + $decimals = ''; + } + + $out = (int)substr($number, 0, -3) . $decimals; + + if ($append_unit) { + $out .= ' kg'; + } + + return $out; + } + + static public function money_format($number, ?string $dec_point = ',', string $thousands_sep = ' ', $zero_if_empty = true): string { + if ($number == 0) { + return $zero_if_empty ? '0' : '0,00'; + } + + $sign = $number < 0 ? '-' : ''; + + // Convert floats to string, and THEN to integer + // to avoid truncating numbers + // see https://fossil.kd2.org/paheko/tktview/a29df35328fdf783b98edb60f038f248c3af9b38 + if (!is_int($number)) { + $number = (int)(string)$number; + } + + $number = abs((int)(string) $number); + + $decimals = substr('0' . $number, -2); + $number = (int) substr($number, 0, -2); + + if ($dec_point === null) { + $decimals = null; + } + + return sprintf('%s%s%s%s', $sign, number_format($number, 0, $dec_point, $thousands_sep), $dec_point, $decimals); + } + + static public function getLocalURL(string $url = '', ?string $default_prefix = null): string + { + if (substr($url, 0, 1) == '!') { + return ADMIN_URL . substr($url, 1); + } + elseif (substr($url, 0, 7) == '/admin/') { + return ADMIN_URL . substr($url, 7); + } + elseif (substr($url, 0, 2) == './') { + $base = self::getSelfURI(); + $base = preg_replace('!/[^/]*$!', '/', $base); + $base = trim($base, '/'); + return '/' . $base . '/' . substr($url, 2); + } + elseif (substr($url, 0, 1) == '/' && ($pos = strpos($url, WWW_URI)) === 0) { + return WWW_URL . substr($url, strlen(WWW_URI)); + } + elseif (substr($url, 0, 1) == '/') { + return WWW_URL . substr($url, 1); + } + elseif (substr($url, 0, 5) == 'http:' || substr($url, 0, 6) == 'https:') { + return $url; + } + elseif ($url == '') { + return ADMIN_URL; + } + else { + if (null !== $default_prefix) { + $default_prefix = self::getLocalURL($default_prefix); + } + + return $default_prefix . $url; + } + } + + static public function getRequestURI() + { + if (!empty($_SERVER['REQUEST_URI'])) + return $_SERVER['REQUEST_URI']; + else + return false; + } + + static public function getSelfURL($qs = true) + { + $uri = self::getSelfURI($qs); + + // Make absolute URI relative to parent URI + if (0 === strpos($uri, WWW_URI . 'admin/')) { + $uri = substr($uri, strlen(WWW_URI . 'admin/')); + } + + return ADMIN_URL . ltrim($uri, '/'); + } + + static public function getSelfURI($qs = true) + { + $uri = self::getRequestURI(); + + if ($qs !== true && (strpos($uri, '?') !== false)) + { + $uri = substr($uri, 0, strpos($uri, '?')); + } + + if (is_array($qs)) + { + $uri .= '?' . http_build_query($qs); + } + + return $uri; + } + + static public function getModifiedURL(string $new) + { + return HTTP::mergeURLs(self::getSelfURI(), $new); + } + + static public function redirectDialog(?string $destination = null, bool $exit = true): void + { + if (isset($_GET['_dialog'])) { + self::reloadParentFrame($destination, $exit); + } + else { + self::redirect($destination, $exit); + } + } + + static public function reloadParentFrameIfDialog(?string $destination = null): void + { + if (!isset($_GET['_dialog'])) { + return; + } + + self::reloadParentFrame($destination); + } + + static public function reloadParentFrame(?string $destination = null, bool $exit = true): void + { + $url = self::getLocalURL($destination ?? '!'); + + echo ' + + + + + + + +

Cliquer ici pour continuer + + '; + + if ($exit) { + exit; + } + } + + public static function redirect(?string $destination = null, bool $exit = true) + { + $destination ??= ''; + $destination = self::getLocalURL($destination); + + if (isset($_GET['_dialog'])) { + $destination .= (strpos($destination, '?') === false ? '?' : '&') . '_dialog'; + + if (!empty($_GET['_dialog'])) { + $destination .= '=' . rawurlencode($_GET['_dialog']); + } + } + + if (PHP_SAPI == 'cli') { + echo 'Please visit ' . $destination . PHP_EOL; + exit; + } + + if (headers_sent()) + { + echo + ''. + ' ' . + ' ' . + ' '. + ' '. + '

'. + ' '. + ''; + + if ($exit) + exit(); + + return true; + } + + header("Location: " . $destination); + + if ($exit) { + exit; + } + } + + static public function getIP() + { + if (!empty($_SERVER['REMOTE_ADDR'])) + return $_SERVER['REMOTE_ADDR']; + return ''; + } + + static public function getCountryList() + { + return Translate::getCountriesList('fr'); + } + + static public function getCountryName(string $code): ?string + { + $code = strtoupper($code); + $list = self::getCountryList(); + return $list[$code] ?? null; + } + + static public function getCountryCode(string $find): ?string + { + if (!strlen($find)) { + return null; + } + + $list = self::getCountryList(); + + foreach ($list as $code => $name) { + if (strnatcasecmp($find, $name) === 0) { + return $code; + } + } + + return null; + } + + static public function transliterateToAscii($str, $charset='UTF-8') + { + // Don't process empty strings + if (!trim($str)) + return $str; + + // We only process non-ascii strings + if (preg_match('!^[[:ascii:]]+$!', $str)) + return $str; + + $str = htmlentities($str, ENT_NOQUOTES, $charset); + + $str = preg_replace('#&([A-za-z])(?:acute|cedil|circ|grave|orn|ring|slash|th|tilde|uml);#', '\1', $str); + $str = preg_replace('#&([A-za-z]{2})(?:lig);#', '\1', $str); // pour les ligatures e.g. 'œ' + + $str = preg_replace('#&[^;]+;#', '', $str); // supprime les autres caractères + $str = preg_replace('![^[:ascii:]]+!', '', $str); + + return $str; + } + + /** + * Transforme les tags HTML basiques en tags SkrivML + * @param string $str Texte d'entrée + * @return string Texte transformé + */ + static public function HTMLToSkriv($str) + { + $str = preg_replace('/

(\V*?)<\/h3>/', '=== $1 ===', $str); + $str = preg_replace('/(\V*)<\/b>/', '**$1**', $str); + $str = preg_replace('/(\V*?)<\/strong>/', '**$1**', $str); + $str = preg_replace('/(\V*?)<\/i>/', '\'\'$1\'\'', $str); + $str = preg_replace('/(\V*?)<\/em>/', '\'\'$1\'\'', $str); + $str = preg_replace('/
  • (\V*?)<\/li>/', '* $1', $str); + $str = preg_replace('/
      |<\/ul>/', '', $str); + $str = preg_replace('/(\V*?)<\/a>/', '[[$2 | $1]]', $str); + return $str; + } + + static public function safe_unlink(string $path): bool + { + if (!@unlink($path)) + { + return true; + } + + if (!file_exists($path)) + { + return true; + } + + throw new \RuntimeException(sprintf('Impossible de supprimer le fichier %s: %s', $path, error_get_last())); + + return true; + } + + static public function safe_mkdir($path, $mode = null, $recursive = false) + { + if (null === $mode && file_exists(DATA_ROOT)) { + $mode = fileperms(DATA_ROOT); + } + elseif (null === $mode && file_exists(ROOT)) { + $mode = fileperms(ROOT); + } + + return @mkdir($path, $mode, $recursive) || is_dir($path); + } + + /** + * Does a recursive list using glob(), this is faster than using Recursive iterators + * @param string $path Target path + * @param string $pattern Pattern + * @param int $flags glob() Flags + * @return array + */ + static public function recursiveGlob(string $path, string $pattern = '*', int $flags = 0): array + { + $target = $path . DIRECTORY_SEPARATOR . $pattern; + $list = []; + + // glob is the fastest way to recursely list directories and files apparently + // after comparing with opendir(), dir() and filesystem recursive iterators + foreach(glob($target, $flags) as $file) { + $file = basename($file); + + if ($file[0] == '.') { + continue; + } + + $list[] = $file; + + if (is_dir($path . DIRECTORY_SEPARATOR . $file)) { + foreach (self::recursiveGlob($path . DIRECTORY_SEPARATOR . $file, $pattern, $flags) as $subfile) { + $list[] = $file . DIRECTORY_SEPARATOR . $subfile; + } + } + } + + return $list; + } + + static public function suggestPassword() + { + return Security::getRandomPassphrase(ROOT . '/include/data/dictionary.fr'); + } + + static public function normalizePhoneNumber($n) + { + return preg_replace('![^\d\+\(\)p#,;-]!', '', trim($n)); + } + + static public function write_ini_string($in) + { + $out = ''; + $get_ini_line = function ($key, $value) use (&$get_ini_line) + { + if (is_bool($value)) + { + return $key . ' = ' . ($value ? 'true' : 'false'); + } + elseif (is_numeric($value)) + { + return $key . ' = ' . $value; + } + elseif (is_array($value) || is_object($value)) + { + $out = ''; + $value = (array) $value; + foreach ($value as $row) + { + $out .= $get_ini_line($key . '[]', $row) . "\n"; + } + + return substr($out, 0, -1); + } + else + { + return $key . ' = "' . str_replace('"', '\\"', $value) . '"'; + } + }; + + foreach ($in as $key=>$value) + { + if ((is_array($value) || is_object($value)) && is_string($key)) + { + $out .= '[' . $key . "]\n"; + + foreach ($value as $row_key=>$row_value) + { + $out .= $get_ini_line($row_key, $row_value) . "\n"; + } + + $out .= "\n"; + } + else + { + $out .= $get_ini_line($key, $value) . "\n"; + } + } + + return $out; + } + + static public function getMaxUploadSize() + { + $limits = [ + self::return_bytes(ini_get('upload_max_filesize')), + self::return_bytes(ini_get('post_max_size')) + ]; + + return min(array_filter($limits)); + } + + + static public function return_bytes($size_str) + { + if ($size_str == '-1') + { + return false; + } + + if (PHP_VERSION_ID >= 80200) { + return ini_parse_quantity($size_str); + } + + switch (substr($size_str, -1)) + { + case 'G': case 'g': return (int)$size_str * pow(1024, 3); + case 'M': case 'm': return (int)$size_str * pow(1024, 2); + case 'K': case 'k': return (int)$size_str * 1024; + default: return $size_str; + } + } + + static public function format_bytes($size, bool $bytes = false) + { + if ($size > (1024 * 1024 * 1024 * 1024)) { + $size = $size / 1024 / 1024 / 1024 / 1024; + + if ($size < 10) { + $decimals = $size == (int) $size ? 0 : 1; + return number_format(round($size, 1), $decimals, ',', '') . ' To'; + } + else { + return round($size) . ' To'; + } + } + elseif ($size > (1024 * 1024 * 1024)) { + $size = $size / 1024 / 1024 / 1024; + + if ($size < 10) { + $decimals = $size == (int) $size ? 0 : 1; + return number_format(round($size, 1), $decimals, ',', '') . ' Go'; + } + else { + return ceil($size) . ' Go'; + } + } + elseif ($size > (1024 * 1024)) { + $size = $size / 1024 / 1024; + $decimals = $size == (int) $size ? 0 : 2; + return ceil($size) . ' Mo'; + } + elseif ($size > 1024) { + return ceil($size / 1024) . ' Ko'; + } + elseif ($bytes) { + return $size . ' octets'; + } + elseif (!$size) { + return '0 o'; + } + else { + return '< 1 Ko'; + } + } + + static public function createEmptyDirectory(string $path) + { + Utils::safe_mkdir($path, 0777, true); + + if (!is_dir($path)) + { + throw new UserException('Le répertoire '.$path.' n\'existe pas ou n\'est pas un répertoire.'); + } + + // On en profite pour vérifier qu'on peut y lire et écrire + if (!is_writable($path) || !is_readable($path)) + { + throw new UserException('Le répertoire '.$path.' n\'est pas accessible en lecture/écriture.'); + } + + // Some basic safety against misconfigured hosts + file_put_contents($path . '/index.html', '404 Not Found

      Not Found

      The requested URL was not found on this server.

      '); + } + + static public function resetCache(string $path): void + { + if (!file_exists($path)) { + self::createEmptyDirectory($path); + return; + } + + $dir = dir($path); + + while ($file = $dir->read()) { + if (substr($file, 0, 1) == '.' || is_dir($path . DIRECTORY_SEPARATOR . $file)) { + continue; + } + + self::safe_unlink($path . DIRECTORY_SEPARATOR . $file); + } + + $dir->close(); + } + + static public function deleteRecursive(string $path, bool $delete_self = false): bool + { + if (!file_exists($path)) { + return false; + } + + if (is_file($path)) { + return self::safe_unlink($path); + } + + $dir = dir($path); + if (!$dir) return false; + + while ($file = $dir->read()) + { + if ($file == '.' || $file == '..') + continue; + + if (is_dir($path . DIRECTORY_SEPARATOR . $file)) + { + if (!self::deleteRecursive($path . DIRECTORY_SEPARATOR . $file, true)) + return false; + } + else + { + self::safe_unlink($path . DIRECTORY_SEPARATOR . $file); + } + } + + $dir->close(); + + if ($delete_self) { + rmdir($path); + } + + return true; + } + + static public function plugin_url($params = []) + { + if (isset($params['id'])) + { + $url = ADMIN_URL . 'p/' . $params['id'] . '/'; + } + elseif (defined('Paheko\PLUGIN_ADMIN_URL')) + { + $url = PLUGIN_ADMIN_URL; + } + else { + throw new \RuntimeException('Missing plugin URL'); + } + + if (!empty($params['file'])) + $url .= $params['file']; + + if (!empty($params['query'])) + { + $url .= '?'; + + if (!(is_numeric($params['query']) && (int)$params['query'] === 1) && $params['query'] !== true) + $url .= $params['query']; + } + + return $url; + } + + static public function iconUnicode(string $shape): string + { + if (!isset(self::ICONS[$shape])) { + throw new \UnexpectedValueException('Unknown icon shape: ' . $shape); + } + + return self::ICONS[$shape]; + } + + static public function array_transpose(?array $array): array + { + $out = []; + + if (!$array) { + return $out; + } + + $max = 0; + + foreach ($array as $rows) { + if (!is_array($rows)) { + throw new \UnexpectedValueException('Invalid multi-dimensional array: not an array: ' . gettype($rows)); + } + + $max = max($max, count($rows)); + } + + foreach ($array as $column => $rows) { + // Match number of rows of largest sub-array, in case there is a missing row in a column + if ($max != count($rows)) { + $rows = array_merge($rows, array_fill(0, $max - count($rows), null)); + } + + foreach ($rows as $k => $v) { + if (!isset($out[$k])) { + $out[$k] = []; + } + + $out[$k][$column] = $v; + } + } + + return $out; + } + + static public function rgbHexToDec(string $hex) + { + return sscanf($hex, '#%02x%02x%02x'); + } + + /** + * Converts an RGB color value to HSV. Conversion formula + * adapted from http://en.wikipedia.org/wiki/HSV_color_space. + * Assumes r, g, and b are contained in the set [0, 255] and + * returns h, s, and v in the set [0, 1]. + * + * @param Number r The red color value + * @param Number g The green color value + * @param Number b The blue color value + * @return Array The HSV representation + */ + static public function rgbToHsv($r, $g = null, $b = null) + { + if (is_string($r) && is_null($g) && is_null($b)) + { + list($r, $g, $b) = self::rgbHexToDec($r); + } + + $r /= 255; + $g /= 255; + $b /= 255; + $max = max($r, $g, $b); + $min = min($r, $g, $b); + $h = $s = $v = $max; + + $d = $max - $min; + //$s = ($max == 0) ? 0 : $d / $max; + $l = ($max + $min) / 2; + $s = $l > 0.5 ? $d / ((2 - $max - $min) ?: 1) : $d / (($max + $min) ?: 1); + + if($max == $min) + { + $h = 0; // achromatic + } + else + { + switch($max) + { + case $r: $h = ($g - $b) / $d + ($g < $b ? 6 : 0); break; + case $g: $h = ($b - $r) / $d + 2; break; + case $b: $h = ($r - $g) / $d + 4; break; + } + $h /= 6; + } + + return array($h * 360, $s, $l); + } + + static public function HTTPCache(?string $hash, ?int $last_change, int $max_age = 3600): bool + { + $etag = isset($_SERVER['HTTP_IF_NONE_MATCH']) ? trim($_SERVER['HTTP_IF_NONE_MATCH'], '"\' ') : null; + $last_modified = isset($_SERVER['HTTP_IF_MODIFIED_SINCE']) ? strtotime($_SERVER['HTTP_IF_MODIFIED_SINCE']) : null; + + $etag = $etag ? str_replace('-gzip', '', $etag) : null; + + header(sprintf('Cache-Control: private, max-age=%d', $max_age), true); + header_remove('Expires'); + + if ($last_change) { + header(sprintf('Last-Modified: %s GMT', gmdate('D, d M Y H:i:s', $last_change)), true); + } + + if ($hash) { + $hash = md5(Utils::getVersionHash() . $hash); + header(sprintf('Etag: "%s"', $hash), true); + } + + if (($etag && $etag === $hash) || ($last_modified && $last_modified >= $last_change)) { + http_response_code(304); + exit; + } + + return false; + } + + static public function transformTitleToURI($str) + { + $str = Utils::transliterateToAscii($str); + + $str = preg_replace('![^\w\d_-]!i', '-', $str); + $str = preg_replace('!-{2,}!', '-', $str); + $str = trim($str, '-'); + + return $str; + } + + static public function safeFileName(string $str): string + { + $str = Utils::transliterateToAscii($str); + $str = preg_replace('![^\w\d_ -]!i', '.', $str); + $str = preg_replace('!\.{2,}!', '.', $str); + $str = trim($str, '.'); + return $str; + } + + /** + * dirname may have undefined behaviour depending on the locale! + */ + static public function dirname(string $str): string + { + $str = str_replace(DIRECTORY_SEPARATOR, '/', $str); + return substr($str, 0, strrpos($str, '/')); + } + + /** + * basename may have undefined behaviour depending on the locale! + */ + static public function basename(string $str): string + { + $str = str_replace(DIRECTORY_SEPARATOR, '/', $str); + $str = trim($str, '/'); + $str = substr($str, strrpos($str, '/')); + $str = trim($str, '/'); + return $str; + } + + static public function unicodeTransliterate($str): ?string + { + if ($str === null) { + return null; + } + + $str = str_replace('’', '\'', $str); // Normalize French apostrophe + + return transliterator_transliterate('Any-Latin; Latin-ASCII; Lower()', $str); + } + + static public function unicodeCaseComparison($a, $b): int + { + if (!isset(self::$collator) && function_exists('collator_create')) { + self::$collator = \Collator::create('fr_FR'); + + // This is what makes the comparison case insensitive + // https://www.php.net/manual/en/collator.setstrength.php + self::$collator->setAttribute(\Collator::STRENGTH, \Collator::PRIMARY); + + // Don't use \Collator::NUMERIC_COLLATION here as it goes against what would feel logic + // for account ordering + // with NUMERIC_COLLATION: 1, 2, 10, 11, 101 + // without: 1, 10, 101, 11, 2 + } + + // Make sure we have UTF-8 + // If we don't, we may end up with malformed database, eg. "row X missing from index" errors + // when doing an integrity check + $a = self::utf8_encode($a); + $b = self::utf8_encode($b); + + if (isset(self::$collator)) { + return (int) self::$collator->compare($a, $b); + } + + $a = strtoupper(self::transliterateToAscii($a)); + $b = strtoupper(self::transliterateToAscii($b)); + + return strcmp($a, $b); + } + + static public function utf8_encode(?string $str): ?string + { + if (null === $str) { + return null; + } + + // Check if string is already UTF-8 encoded or not + if (preg_match('//u', $str)) { + return $str; + } + + return !preg_match('//u', $str) ? self::iso8859_1_to_utf8($str) : $str; + } + + /** + * Poly-fill to encode a ISO-8859-1 string to UTF-8 for PHP >= 9.0 + * @see https://php.watch/versions/8.2/utf8_encode-utf8_decode-deprecated + */ + static public function iso8859_1_to_utf8(string $s): string + { + if (PHP_VERSION_ID < 90000) { + return @utf8_encode($s); + } + + $s .= $s; + $len = strlen($s); + + for ($i = $len >> 1, $j = 0; $i < $len; ++$i, ++$j) { + switch (true) { + case $s[$i] < "\x80": + $s[$j] = $s[$i]; + break; + case $s[$i] < "\xC0": + $s[$j] = "\xC2"; + $s[++$j] = $s[$i]; + break; + default: + $s[$j] = "\xC3"; + $s[++$j] = chr(ord($s[$i]) - 64); + break; + } + } + + return substr($s, 0, $j); + } + + /** + * Transforms a unicode string to lowercase AND removes all diacritics + * + * @see https://www.matthecat.com/supprimer-les-accents-d-une-chaine-avec-php.html + */ + static public function unicodeCaseFold(?string $str): string + { + if (null === $str || trim($str) === '') { + return ''; + } + + $str = str_replace('’', '\'', $str); // Normalize French apostrophe + + if (!isset(self::$transliterator) && function_exists('transliterator_create')) { + self::$transliterator = \Transliterator::create('Any-Latin; NFD; [:Nonspacing Mark:] Remove; NFC; Lower();'); + } + + if (isset(self::$transliterator)) { + return self::$transliterator->transliterate($str); + } + + return strtoupper(self::transliterateToAscii($str)); + } + + static public function knatcasesort(array $array) + { + uksort($array, [self::class, 'unicodeCaseComparison']); + return $array; + } + + static public function appendCookieToURLs(string $str): string + { + $cookie = Session::getCookie(); + $secret = Session::getCookieSecret(); + + if (!$cookie) { + return $str; + } + + // Append session cookie to URLs, so that tags and others work + $r = preg_quote(WWW_URL, '!'); + $r = '!(?<=["\'])((?:/|' . $r . ').*?)(?=["\'])!'; + $str = preg_replace_callback($r, function ($match) use ($cookie, $secret): string { + if (false !== strpos($match[1], '?')) { + $separator = '&'; + } + else { + $separator = '?'; + } + + if (substr($match[1], 0, 1) === '/') { + $url = BASE_URL . ltrim($match[1], '/'); + } + else { + $url = $match[1]; + } + + return $url . $separator . $cookie . htmlspecialchars($secret); + }, $str); + + return $str; + } + + /** + * Escape a command-line argument, because escapeshellarg is stripping UTF-8 characters (d'oh) + * @see https://markushedlund.com/dev/php-escapeshellarg-with-unicodeutf-8-support/ + */ + static public function escapeshellarg(string $arg): string + { + if (PHP_OS_FAMILY === 'Windows') { + return '"' . str_replace(array('"', '%'), array('', ''), $arg) . '"'; + } + else { + return "'" . str_replace("'", "'\\''", $arg) . "'"; + } + } + + /** + * Execute a system command with a timeout + * @see https://blog.dubbelboer.com/2012/08/24/execute-with-timeout.html + */ + static public function exec(string $cmd, int $timeout, ?callable $stdin, ?callable $stdout, ?callable $stderr = null): int + { + if (!function_exists('proc_open') || !function_exists('proc_terminate') + || preg_match('/proc_(?:open|terminate|get_status|close)/', ini_get('disable_functions'))) { + throw new \RuntimeException('Execution of system commands is disabled.'); + } + + $descriptorspec = [ + 0 => ["pipe", "r"], // stdin is a pipe that the child will read from + 1 => ["pipe", "w"], // stdout is a pipe that the child will write to + 2 => ['pipe', 'w'], // stderr + ]; + + $process = proc_open($cmd, $descriptorspec, $pipes); + + if (!is_resource($process)) { + throw new \RuntimeException('Cannot execute command: ' . $cmd); + } + + // $pipes now looks like this: + // 0 => writeable handle connected to child stdin + // 1 => readable handle connected to child stdout + + // Set to non-blocking + stream_set_blocking($pipes[0], false); + stream_set_blocking($pipes[1], false); + stream_set_blocking($pipes[2], false); + + $timeout_ms = $timeout * 1000000; // in microseconds + + if (null !== $stdin) { + // Send STDIN + fwrite($pipes[0], $stdin()); + } + + fclose($pipes[0]); + $code = 0; + + while ($timeout_ms > 0) { + $start = microtime(true); + + // Wait until we have output or the timer expired. + $read = [$pipes[1]]; + $other = []; + + if (null !== $stderr) { + $read[] = $pipes[2]; + } + + // Wait every 0.5 seconds + stream_select($read, $other, $other, 0, 500000); + + // Get the status of the process. + // Do this before we read from the stream, + // this way we can't lose the last bit of output if the process dies between these functions. + $status = proc_get_status($process); + + // We must get the exit code when it is sent, or we won't be able to get it later + if ($status['exitcode'] > -1) { + $code = $status['exitcode']; + } + + // Read the contents from the buffer. + // This function will always return immediately as the stream is non-blocking. + if (null !== $stdout) { + $stdout(stream_get_contents($pipes[1])); + } + + if (null !== $stderr) { + $stderr(stream_get_contents($pipes[2])); + } + + if (!$status['running']) { + // Break from this loop if the process exited before the timeout. + break; + } + + // Subtract the number of microseconds that we waited. + $timeout_ms -= (microtime(true) - $start) * 1000000; + } + + fclose($pipes[1]); + fclose($pipes[2]); + + if ($status['running']) { + proc_terminate($process, 9); + throw new \OverflowException(sprintf("Command killed after taking more than %d seconds: \n%s", $timeout, $cmd)); + } + + proc_close($process); + + return $code; + } + + /** + * Displays a PDF from a string, only works when PDF_COMMAND constant is set to "prince" + * @param string $str HTML string + * @return void + */ + static public function streamPDF(string $str): void + { + if (!PDF_COMMAND) { + throw new \LogicException('PDF generation is disabled'); + } + + $str = self::appendCookieToURLs($str); + + if (PDF_COMMAND == 'auto') { + // Try to see if there's a plugin + $in = ['string' => $str]; + + $signal = Plugins::fire('pdf.stream', true, $in); + + if ($signal && $signal->isStopped()) { + return; + } + + unset($signal, $in); + } + + // Only Prince handles using STDIN and STDOUT + if (PDF_COMMAND != 'prince') { + $file = self::filePDF($str); + readfile($file); + unlink($file); + return; + } + + // 3 seconds is plenty enough to fetch resources, right? + $cmd = 'prince --http-timeout=3 --pdf-profile="PDF/A-3b" -o - -'; + + // Prince is fast, right? Fingers crossed + self::exec($cmd, 10, fn () => $str, fn ($data) => print($data)); + + if (PDF_USAGE_LOG) { + file_put_contents(PDF_USAGE_LOG, date("Y-m-d H:i:s\n"), FILE_APPEND); + } + } + + /** + * Creates a PDF file from a HTML string + * @param string $str HTML string + * @return string File path of the PDF file (temporary), you must delete or move it + */ + static public function filePDF(string $str): ?string + { + $cmd = PDF_COMMAND; + + if (!$cmd) { + throw new \LogicException('PDF generation is disabled'); + } + + $source = sprintf('%s/print-%s.html', CACHE_ROOT, md5(random_bytes(16))); + $target = str_replace('.html', '.pdf', $source); + + $str = self::appendCookieToURLs($str); + + Utils::safe_mkdir(CACHE_ROOT, null, true); + file_put_contents($source, $str); + + if ($cmd == 'auto') { + // Try to see if there's a plugin + $in = ['source' => $source, 'target' => $target]; + + $signal = Plugins::fire('pdf.create', true, $in); + + if ($signal && $signal->isStopped()) { + Utils::safe_unlink($source); + return $target; + } + + unset($in, $signal); + + // Try to find a local executable + $list = ['prince', 'chromium', 'wkhtmltopdf', 'weasyprint']; + $cmd = null; + + foreach ($list as $program) { + if (shell_exec('which ' . $program)) { + $cmd = $program; + break; + } + } + + // We still haven't found anything + if (!$cmd) { + throw new \LogicException('Aucun programme de création de PDF trouvé, merci d\'en installer un : https://fossil.kd2.org/paheko/wiki?name=Configuration'); + } + } + + $timeout = 25; + + switch ($cmd) { + case 'prince': + $timeout = 10; + $cmd = 'prince --http-timeout=3 --pdf-profile="PDF/A-3b" -o %2$s %1$s'; + break; + case 'chromium': + $cmd = 'chromium --headless --timeout=5000 --disable-gpu --run-all-compositor-stages-before-draw --print-to-pdf-no-header --print-to-pdf=%2$s %1$s'; + break; + case 'wkhtmltopdf': + $cmd = 'wkhtmltopdf -q --print-media-type --enable-local-file-access --disable-smart-shrinking --encoding "UTF-8" %s %s'; + break; + case 'weasyprint': + $timeout = 60; + $cmd = 'weasyprint %1$s %2$s'; + break; + default: + break; + } + + $cmd = sprintf($cmd, self::escapeshellarg($source), self::escapeshellarg($target)); + $cmd .= ' 2>&1'; + + $output = ''; + + try { + self::exec($cmd, $timeout, null, function($data) use (&$output) { $output .= $data; }); + } + finally { + Utils::safe_unlink($source); + } + + if (!file_exists($target)) { + throw new \RuntimeException('PDF command failed: ' . $output); + } + + if (PDF_USAGE_LOG) { + file_put_contents(PDF_USAGE_LOG, date("Y-m-d H:i:s\n"), FILE_APPEND); + } + + return $target; + } + + /** + * Integer to A-Z, AA-ZZ, AAA-ZZZ, etc. + * @see https://www.php.net/manual/fr/function.base-convert.php#94874 + */ + static public function num2alpha(int $n): string { + $r = ''; + for ($i = 1; $n >= 0 && $i < 10; $i++) { + $r = chr(0x41 + intval($n % pow(26, $i) / pow(26, $i - 1))) . $r; + $n -= pow(26, $i); + } + return $r; + } + + static public function uuid(): string + { + $uuid = bin2hex(random_bytes(16)); + + return sprintf('%08s-%04s-4%03s-%04x-%012s', + // 32 bits for "time_low" + substr($uuid, 0, 8), + // 16 bits for "time_mid" + substr($uuid, 8, 4), + // 16 bits for "time_hi_and_version", + // four most significant bits holds version number 4 + substr($uuid, 13, 3), + // 16 bits: + // * 8 bits for "clk_seq_hi_res", + // * 8 bits for "clk_seq_low", + // two most significant bits holds zero and one for variant DCE1.1 + hexdec(substr($uuid, 16, 4)) & 0x3fff | 0x8000, + // 48 bits for "node" + substr($uuid, 20, 12) + ); + } + + /** + * Hash de la version pour les éléments statiques (cache) + * + * On ne peut pas utiliser la version directement comme query string + * pour les éléments statiques (genre /admin/static/admin.css?v0.9.0) + * car cela dévoilerait la version de Paheko utilisée, posant un souci + * en cas de faille, on cache donc la version utilisée, chaque instance + * aura sa propre version + */ + static public function getVersionHash(): string + { + return substr(sha1(paheko_version() . paheko_manifest() . ROOT . SECRET_KEY), 0, 10); + } + + /** + * Génération pagination à partir de la page courante ($current), + * du nombre d'items total ($total), et du nombre d'items par page ($bypage). + * $listLength représente la longueur d'items de la pagination à génerer + * + * @param int $current + * @param int $total + * @param int $bypage + * @param int $listLength + * @param bool $showLast Toggle l'affichage du dernier élément de la pagination + * @return array|null + */ + static public function getGenericPagination($current, $total, $bypage, $listLength = 11, $showLast = true) + { + if ($total <= $bypage) + return null; + + $total = ceil($total / $bypage); + + if ($total < $current) + return null; + + $length = ($listLength / 2); + + $begin = $current - ceil($length); + if ($begin < 1) + { + $begin = 1; + } + + $end = $begin + $listLength; + if($end > $total) + { + $begin -= ($end - $total); + $end = $total; + } + if ($begin < 1) + { + $begin = 1; + } + if($end==($total-1)) { + $end = $total; + } + if($begin == 2) { + $begin = 1; + } + $out = []; + + if ($current > 1) { + $out[] = ['id' => $current - 1, 'label' => '« ' . 'Page précédente', 'class' => 'prev', 'accesskey' => 'a']; + } + + if ($begin > 1) { + $out[] = ['id' => 1, 'label' => '1 ...', 'class' => 'first']; + } + + for ($i = $begin; $i <= $end; $i++) + { + $out[] = ['id' => $i, 'label' => $i, 'class' => ($i == $current) ? 'current' : '']; + } + + if ($showLast && $end < $total) { + $out[] = ['id' => $total, 'label' => '... ' . $total, 'class' => 'last']; + } + + if ($current < $total) { + $out[] = ['id' => $current + 1, 'label' => 'Page suivante' . ' »', 'class' => 'next', 'accesskey' => 'z']; + } + + return $out; + } + + static public function parse_ini_file(string $path, bool $sections = false) + { + return self::parse_ini_string(file_get_contents($path), $sections); + } + + /** + * Safe alternative to parse_ini_string without constant/variable expansion + * but still type values, like INI_SCANNER_TYPED + */ + static public function parse_ini_string(string $ini, bool $sections = false) + { + try { + $ini = parse_ini_string($ini, $sections, \INI_SCANNER_RAW); + } + catch (\Throwable $e) { + throw new \RuntimeException($e->getMessage(), 0, $e); + } + + return self::_resolve_ini_types($ini); + } + + static protected function _resolve_ini_types(array $ini) + { + foreach ($ini as $key => &$value) { + if (is_array($value)) { + $value = self::_resolve_ini_types($value); + } + elseif ($value === 'FALSE' || $value === 'false' || $value === 'off' || $value === 'no' || $value === 'none') { + $value = false; + } + elseif ($value === 'TRUE' || $value === 'true' || $value === 'on' || $value === 'yes') { + $value = true; + } + elseif ($value === 'NULL' || $value === 'null') { + $value = null; + } + elseif (ctype_digit($value)) { + $value = (int)$value; + } + else { + $value = str_replace('\n', "\n", $value); + } + } + + unset($value); + + return $ini; + } +} diff --git a/src/include/lib/Paheko/Web/Cache.php b/src/include/lib/Paheko/Web/Cache.php new file mode 100644 index 0000000..3000bfd --- /dev/null +++ b/src/include/lib/Paheko/Web/Cache.php @@ -0,0 +1,180 @@ + $host, + '%host.2%' => substr($host, 0, 2), + ]); + + return $path; + } + + static public function getPath(string $uri, bool $append_extension = true): string + { + $uri = rawurldecode($uri); + $uri = '/' . ltrim($uri, '/'); + + $target = self::$root . '/' . md5($uri); + + if ($append_extension) { + $ext = self::getFileExtension($uri) ?? '.html'; + $target .= $ext; + } + + return $target; + } + + static public function getFileExtension(string $name): ?string + { + if (preg_match('/\.[a-z0-9]{1,10}$/', $name, $match)) { + return $match[0]; + } + + return null; + } + + static public function clear(): void + { + if (!self::init()) { + return; + } + + Utils::deleteRecursive(self::$root, false); + } + + static public function delete(string $uri): void + { + if (!self::init()) { + return; + } + + $uri = rawurldecode($uri); + $uri = '/' . ltrim($uri, '/'); + + $target = self::getPath($uri, false); + + foreach (glob($target . '*') as $file) { + Utils::safe_unlink($file); + } + } + + static public function init(): bool + { + if (!WEB_CACHE_ROOT) { + return false; + } + + // Symlinks on Windows… Not sure if that works + if (PHP_OS_FAMILY == 'Windows') { + return false; + } + + // Only Apache is supported, no need to create useless cache files with other servers + if (false === strpos($_SERVER['SERVER_SOFTWARE'] ?? '', 'Apache')) { + return false; + } + + if (isset(self::$root)) { + return true; + } + + self::$root = rtrim(self::getRoot(), '/'); + + if (!file_exists(self::$root)) { + Utils::safe_mkdir(self::$root, 0777, true); + } + + $cache_root = Utils::dirname(WEB_CACHE_ROOT); + + // Create symlink for self-hosting with .htaccess + if (!file_exists(ROOT . '/www/.cache') && file_exists($cache_root) && is_writable(ROOT . '/www')) { + @symlink($cache_root, ROOT . '/www/.cache'); + } + + return true; + } + + static public function link(string $uri, string $destination): void + { + if (!self::init()) { + return; + } + + $target = self::getPath($uri); + + @unlink($target); + @symlink($destination, $target); + } + + static public function store(string $uri, string $html): void + { + // Do not store if the page content might be influenced by either POST, query string, or logged-in user + if (!isset($_GET['__reload']) && ($_SERVER['REQUEST_METHOD'] != 'GET' || !empty($_SERVER['QUERY_STRING']) || isset($_COOKIE['pko']))) { + return; + } + + if (!self::init()) { + return; + } + + $ext = self::getFileExtension($uri); + $is_html = false !== stripos($html, ' + document.addEventListener(\'DOMContentLoaded\', () => { + var now = +(new Date) / 1000; + if (now < %d || location.hash) { + return; + } + + fetch(location.href + \'?__reload\').then(r => r.text()).then(r => { + var x = window.pageX, y = window.pageY; + document.open(); + document.write(r); + document.close(); + window.scrollTo(x, y); + }); + }); + + path = $path; + + if ($path) { + $this->context = strtok($path, '/'); + strtok(''); + + if ($this->context === File::CONTEXT_WEB) { + $this->parent = $path; + $this->uri = Utils::basename($path); + $this->link_prefix = $user_prefix ?? WWW_URI; + } + else { + $this->parent = Utils::dirname($path); + $this->uri = $this->parent; + + if ($this->context === File::CONTEXT_DOCUMENTS) { + $prefix_path = $this->parent; + } + else { + $prefix_path = File::CONTEXT_DOCUMENTS; + } + + $this->link_prefix = $user_prefix ?? ADMIN_URL . 'common/files/preview.php?p=' . $prefix_path . '/'; + $this->link_suffix = '.md'; + } + + $this->uri = str_replace('%2F', '/', rawurlencode($this->uri)); + } + } + + abstract public function render(string $content): string; + + public function hasPath(): bool + { + return isset($this->path); + } + + public function registerAttachment(string $uri) + { + Render::registerAttachment($this->path, $uri); + } + + public function listLinks(): array + { + return $this->links; + } + + public function listAttachments(): array + { + if (!isset($this->parent)) { + return []; + } + + $this->loadAttachments(); + + return $this->attachments; + } + + protected function loadAttachments(): void + { + if (isset($this->attachments)) { + return; + } + + $this->attachments = Files::list($this->parent); + } + + public function listImagesFilenames(): array + { + $out = array_filter($this->listAttachments(), fn ($a) => $a->image); + array_walk($out, fn(&$a) => $a = $a->name); + return $out; + } + + public function resolveAttachment(string $uri): ?File + { + $prefix = $this->uri; + $pos = strpos($uri, '/'); + + if ($pos === 0) { + // Absolute URL: treat it as absolute! + $uri = ltrim($uri, '/'); + } + else { + // Handle relative URIs + $uri = $prefix . '/' . $uri; + } + + $this->loadAttachments(); + + $uri = ltrim($uri, '/'); + $path = $uri; + + $uri = explode('/', $uri); + $uri = array_map('rawurlencode', $uri); + $uri = implode('/', $uri); + + $context = strtok($this->path, '/'); + strtok(''); + + $attachment = null; + + if ($context === File::CONTEXT_WEB) { + foreach ($this->listAttachments() as $file) { + if ($file->uri() === $uri) { + $attachment = $file; + break; + } + } + } + else { + $attachment = $this->attachments[$path] ?? null; + } + + $this->registerAttachment($uri); + + return $attachment; + } + + public function outputHTML(string $content): string + { + $content = trim($content); + + if ($content === '') { + return $content; + } + + $content = preg_replace_callback(';(]*)(href=["\']?((?!#)[^\s\'"]+)[\'"]?)([^>]*>)(.*?)
      ;is', function ($match) { + $label = trim(html_entity_decode(strip_tags($match[5]))) ?: null; + $href = sprintf(' href="%s"', htmlspecialchars($this->resolveLink(htmlspecialchars_decode($match[3]), $label))); + return $match[1] . $href . $match[4] . $match[5] . ''; + }, $content); + + $content = '
      ' . $content . '
      '; + return $content; + } + + public function resolveLink(string $uri, ?string $label = null): string + { + $first = substr($uri, 0, 1); + + if ($first === '/' || $first === '!') { + $uri = $first === '!' ? Utils::getLocalURL($uri) : $uri; + $this->links[] = ['type' => 'internal', 'uri' => $uri, 'label' => $label]; + return $uri; + } + + $pos = strpos($uri, ':'); + + if ($pos !== false && (substr($uri, 0, 7) === 'http://' || substr($uri, 0, 8) === 'https://')) { + $this->links[] = ['type' => 'external', 'uri' => $uri, 'label' => $label]; + return $uri; + } + elseif ($pos !== false) { + $this->links[] = ['type' => 'other', 'uri' => $uri, 'label' => $label]; + return $uri; + } + else { + $this->links[] = ['type' => 'page', 'uri' => $uri, 'label' => $label]; + } + + if (strpos(Utils::basename($uri), '.') === false) { + $uri .= $this->link_suffix; + } + + return $this->link_prefix . $uri; + } +} \ No newline at end of file diff --git a/src/include/lib/Paheko/Web/Render/Encrypted.php b/src/include/lib/Paheko/Web/Render/Encrypted.php new file mode 100644 index 0000000..13eb7b1 --- /dev/null +++ b/src/include/lib/Paheko/Web/Render/Encrypted.php @@ -0,0 +1,18 @@ +assign('admin_url', ADMIN_URL); + $tpl->assign(compact('content')); + return $tpl->fetch('common/files/_file_render_encrypted.tpl'); + } +} diff --git a/src/include/lib/Paheko/Web/Render/Extensions.php b/src/include/lib/Paheko/Web/Render/Extensions.php new file mode 100644 index 0000000..b6d719a --- /dev/null +++ b/src/include/lib/Paheko/Web/Render/Extensions.php @@ -0,0 +1,309 @@ +/!\ ' . htmlspecialchars($msg, ENT_QUOTES, 'UTF-8') . ''; + } + + static public function setRenderer(AbstractRender $renderer) + { + self::$renderer = $renderer; + } + + static public function getList(): array + { + $list = [ + 'file' => [self::class, 'file'], + 'fichier' => [self::class, 'file'], + 'image' => [self::class, 'image'], + 'gallery' => [self::class, 'gallery'], + 'video' => [self::class, 'video'], + 'paheko' => [self::class, 'paheko'], + ]; + + $signal = Plugins::fire('render.extensions.init', false, $list); + + if ($signal) { + $list = array_merge($list, $signal->getOut()); + } + + return $list; + } + + static public function paheko(bool $block, array $args, ?string $content): string + { + if (!empty($args['doc'])) { + if (!preg_match('/^[\w_-]+$/i', $args['doc'])) { + return '[Invalid doc]'; + } + + $path = ROOT . '/www/admin/static/doc/' . $args['doc'] . '.html'; + + if (!file_exists($path)) { + return '[Invalid doc]'; + } + + $body = file_get_contents($path); + $body = substr($body, strpos($body, 'class="web-content"')); + $body = substr($body, strpos($body, '>')+1); + $body = substr($body, 0, strrpos($body, '(?:(?!).)*?;s', '', $body); + + // Replace images + $body = preg_replace(';src="(?!https?:|/);', 'src="' . ADMIN_URL . 'static/doc/', $body); + + // Replace links + $body = preg_replace_callback('!href="([a-z_-]+)\.html!', + function($match) use ($args, $replace) { + $url = $match[1]; + if (array_key_exists($url, $replace)) { + $url = $replace[$url]; + } + else { + $url = str_replace('_', '-', $url); + + if (isset($args['prefix'])) { + $url = $args['prefix'] . $url; + } + } + + return 'href="' . $url; + }, $body); + return $body; + } + elseif (isset($args['version']) || in_array('version', $args)) { + return \Paheko\paheko_version(); + } + + return ''; + } + + static public function gallery(bool $block, array $args, ?string $content): string + { + $type = 'gallery'; + + if (isset($args['type'])) { + $type = $args['type']; + } + elseif (isset($args[0])) { + $type = $args[0]; + } + + if (!in_array($type, ['gallery', 'slideshow'])) { + $type = 'gallery'; + } + + $out = sprintf('
      ', $type); + $index = ''; + + if (trim((string)$content) === '') { + $images = self::$renderer->listImagesFilenames(); + } + else { + $images = explode("\n", $content); + } + + $i = 1; + + foreach ($images as $line) { + $line = trim($line); + + if ($line === '') { + continue; + } + + $img = strtok($line, '|'); + $label = strtok(''); + $size = $type === 'slideshow' ? File::THUMB_SIZE_LARGE : File::THUMB_SIZE_TINY; + + $out .= sprintf('
      %s
      ', self::img($img, $size, $label ?: null)); + } + + $out .= '
      '; + return $out; + } + + static public function file(bool $block, array $args): string + { + $name = $args[0] ?? null; + $caption = $args[1] ?? null; + + if (!$name || !self::$renderer->hasPath()) { + return self::error('Tag file : aucun nom de fichier indiqué.'); + } + + if (empty($caption)) { + $caption = substr($name, 0, strrpos($name, '.')); + } + + $file = self::$renderer->resolveAttachment($name); + + if (!$file) { + return self::error('Tag file : nom de fichier introuvable.'); + } + + $ext = $file->extension(); + $thumb = ''; + + if ($thumb_url = $file->thumb_url()) { + $thumb = sprintf('%s', + htmlspecialchars($thumb_url), + htmlspecialchars($file->name) + ); + } + + return sprintf( + '
      %s
      %s %s — %s
      ', + htmlspecialchars($ext), + htmlspecialchars($file->url()), + $thumb, + htmlspecialchars($caption), + htmlspecialchars($file->getFormatDescription()), + htmlspecialchars(Utils::format_bytes($file->size)) + ); + } + + static public function video(bool $block, array $args): string + { + $name = $args['file'] ?? ($args[0] ?? null); + + if (!$name || !self::$renderer->hasPath()) { + return self::error('Tag video : aucun nom de fichier indiqué.'); + } + + $poster = $args['poster'] ?? ($args[1] ?? null); + $subs = $args['subtitles'] ?? ($args[2] ?? null); + $video = self::$renderer->resolveAttachment($name); + + if (!$video) { + return self::error('Tag video : nom de fichier introuvable.'); + } + + if ($poster) { + $poster = self::$renderer->resolveAttachment($poster); + $poster = $poster ? $poster->url() : null; + } + else { + $poster = $video->thumb_url('750px'); + } + + $params = ''; + + if ($subs) { + $subs = self::$renderer->resolveAttachment($subs); + + // Automagically manage .srt files + // Inspired by https://github.com/codeit-ninja/SRT-Support-for-HTML5-videos + if (substr($subs->name, -4) === '.srt') { + $params .= ' onplay="var k = this.querySelector(\'track\'); fetch(k.src).then((r) => r.text()).then((t) => { t = \'WEBVTT\n\n\' + t.split(/\n/g).map(line => line.replace(/((\d+:){0,2}\d+),(\d+)/g, \'$1.$3\')).join(\'\n\'); k.src = window.URL.createObjectURL(new Blob([t])); } ); return true;"'; + } + + $subs = sprintf('', htmlspecialchars($subs->url())); + } + + if (isset($args['width'])) { + $params .= sprintf(' width="%d"', $args['width']); + } + + if (isset($args['height'])) { + $params .= sprintf(' height="%d"', $args['height']); + } + + return sprintf('
      ', + $poster ? 'metadata' : 'none', + htmlspecialchars($poster), + htmlspecialchars($video->url()), + $params, + $subs + ); + } + + static public function image(bool $block, array $args): string + { + static $align_replace = ['gauche' => 'left', 'droite' => 'right', 'centre' => 'center']; + + $name = $args['file'] ?? ($args[0] ?? null); + $align = $args['align'] ?? ($args[1] ?? null); + $caption = $args['caption'] ?? (isset($args[2]) ? implode(' ', array_slice($args, 2)) : null); + + $align = strtr((string)$align, $align_replace); + + if (!$name || !self::$renderer->hasPath()) { + return self::error('Tag image : aucun nom de fichier indiqué.'); + } + + if ($align === 'center') { + $size = File::THUMB_SIZE_LARGE; + } + elseif ($align === 'left' || $align === 'right') { + $size = File::THUMB_SIZE_TINY; + } + else { + $size = null; + } + + $out = self::img($name, $align ? $size : null, $caption); + + if (!empty($align)) { + if ($caption) { + $caption = sprintf('
      %s
      ', htmlspecialchars($caption)); + } + + $out = sprintf('
      %s%s
      ', $align, $out, $caption); + } + + return $out; + } + + static protected function img(string $name, ?string $thumb_size = File::THUMB_SIZE_TINY, ?string $caption = null): string + { + $file = self::$renderer->resolveAttachment($name); + + if (!$file) { + return self::error('Tag image : nom de fichier inconnu : ' . $name); + } + + $svg = substr($name, -4) == '.svg'; + $thumb_url = null; + $url = $file->url(); + + if ($svg || !$thumb_size) { + $thumb_url = $url; + } + else { + $thumb_url = $file->thumb_url($thumb_size); + } + + return sprintf('%s', + htmlspecialchars($url), + htmlspecialchars($thumb_url ?? $url), + htmlspecialchars($caption ?? '') + ); + } +} diff --git a/src/include/lib/Paheko/Web/Render/Markdown.php b/src/include/lib/Paheko/Web/Render/Markdown.php new file mode 100644 index 0000000..c82f1ae --- /dev/null +++ b/src/include/lib/Paheko/Web/Render/Markdown.php @@ -0,0 +1,70 @@ + $callback) { + self::$md->registerExtension($name, $callback); + } + + self::$md->registerDefaultExtensionCallback([self::class, 'defaultExtensionCallback']); + } + + Extensions::setRenderer($this); + + $content = self::$md->text($content); + + return $this->outputHTML($content); + } + + static public function defaultExtensionCallback(bool $block, array $params, ?string $content, string $name, KD2_Markdown $md): string + { + $args = array_merge($params, compact('block', 'params', 'content')); + + $out = Modules::snippetsAsString(sprintf(Modules::SNIPPET_MARKDOWN_EXTENSION, $name), $args); + + if (!$out) { + return $md::error(sprintf("L'extension <<%s>> n'existe pas.", $name), $block); + } + + return $out; + } +} diff --git a/src/include/lib/Paheko/Web/Render/Render.php b/src/include/lib/Paheko/Web/Render/Render.php new file mode 100644 index 0000000..8e42083 --- /dev/null +++ b/src/include/lib/Paheko/Web/Render/Render.php @@ -0,0 +1,51 @@ +render($content); + } + + static public function getRenderer(string $format, ?string $path, string $link_prefix = null) + { + if ($format == self::FORMAT_SKRIV) { + return new Skriv($path, $link_prefix); + } + // Keep legacy format as it is sometimes used in upgrades + else if ($format == self::FORMAT_ENCRYPTED) { + return new Encrypted($path, $link_prefix); + } + else if ($format == self::FORMAT_MARKDOWN) { + return new Markdown($path, $link_prefix); + } + else { + throw new \LogicException('Invalid format: ' . $format); + } + } + + static public function registerAttachment(string $path, string $uri): void + { + $hash = md5($path); + + if (!array_key_exists($hash, self::$attachments)) { + self::$attachments[$hash] = []; + } + + self::$attachments[$hash][$uri] = true; + } + + static public function listAttachments(?string $path) { + return array_keys(self::$attachments[md5($path)] ?? []); + } +} diff --git a/src/include/lib/Paheko/Web/Render/Skriv.php b/src/include/lib/Paheko/Web/Render/Skriv.php new file mode 100644 index 0000000..fe49df0 --- /dev/null +++ b/src/include/lib/Paheko/Web/Render/Skriv.php @@ -0,0 +1,39 @@ +resolveAttachment($match[1]); + return $a ? $a->url() : $match[1]; + }, $str); + + if (!isset(self::$skriv)) { + self::$skriv = new SkrivLite; + + self::$skriv->registerExtensions(Extensions::getList()); + } + + Extensions::setRenderer($this); + + $str = CommonModifiers::typo($str); + $str = self::$skriv->render($str); + + return $this->outputHTML($str); + } +} diff --git a/src/include/lib/Paheko/Web/Router.php b/src/include/lib/Paheko/Web/Router.php new file mode 100644 index 0000000..382910b --- /dev/null +++ b/src/include/lib/Paheko/Web/Router.php @@ -0,0 +1,253 @@ + $k . ': ' . $v, $headers, array_keys($headers))) + ); + + if ($method != 'GET' && $method != 'OPTIONS' && $method != 'HEAD') { + self::log("ROUTER: <= Request body:\n%s", file_get_contents('php://input')); + } + } + + // Redirect old URLs (pre-1.1) + if ($uri === 'feed/atom/') { + Utils::redirect('/atom.xml'); + } + elseif ($uri === 'favicon.ico') { + http_response_code(301); + header('Location: ' . Config::getInstance()->fileURL('favicon'), true); + return; + } + // Default robots.txt if website is disabled + elseif ($uri === 'robots.txt' && Config::getInstance()->site_disabled) { + http_response_code(200); + header('Content-Type: text/plain'); + echo "User-agent: *\nDisallow: /admin/\n"; + echo "User-agent: GPTBot\nDisallow: /\n"; + return; + } + // Add trailing slash to URLs if required + elseif (($first === 'p' || $first === 'm') && preg_match('!^(?:admin/p|p|m)/\w+$!', $uri)) { + Utils::redirect('/' . $uri . '/'); + } + elseif ((($first === 'admin' && 0 === strpos($uri, 'admin/p/')) || $first === 'p') + && preg_match('!^(?:admin/p|p)/(' . Plugins::NAME_REGEXP . ')/(.*)$!', $uri, $match) + && Plugins::exists($match[1])) { + $uri = ($first === 'admin' ? 'admin/' : 'public/') . $match[2]; + + + if ($match[2] === 'icon.svg' || substr($uri, -3) === '.md') { + $r = Plugins::routeStatic($match[1], $uri); + + if ($r) { + return; + } + } + else { + $plugin = Plugins::get($match[1]); + + if ($plugin && $plugin->enabled) { + $plugin->route($uri); + return; + } + } + } + + // Other admin/plugin routes are not found + if ($first === 'admin' || $first === 'p') { + throw new UserException('Cette page ne semble pas exister.', 404); + } + elseif ($first === 'api') { + API::routeHttpRequest(substr($uri, 4)); + return; + } + // Route WebDAV requests to WebDAV server + elseif ((in_array($uri, self::DAV_ROUTES) || in_array($first, self::DAV_ROUTES)) + && WebDAV_Server::route($uri)) { + return; + } + // Redirect PROPFIND requests to WebDAV, required for some WebDAV clients + elseif ($method === 'PROPFIND') { + header('Location: /dav/documents/'); + return; + } + // Don't try to route paths with no slash, files are always in a sub-directory + elseif ($uri && false !== strpos($uri, '/') && self::routeFile($uri)) { + return; + } + + // Redirect to ADMIN_URL if website is disabled + // (but not for content.css) + if (Config::getInstance()->site_disabled && $uri !== 'content.css' && $first !== 'm') { + if ($uri === '') { + Utils::redirect(ADMIN_URL); + } + else { + throw new UserException('Cette page n\'existe pas.', 404); + } + } + + // Let modules handle the request + Modules::route($uri); + } + + static public function routeFile(string $uri): bool + { + $context = strtok($uri, '/'); + strtok(''); + + $size = null; + + if (false !== strpos($uri, 'px.') && preg_match('/\.([\da-z-]+px)\.(?:webp|svg)$/', $uri, $match)) { + $uri = substr($uri, 0, -strlen($match[0])); + $size = $match[1]; + } + + $file = Files::getFromURI($uri); + + // We can't serve directories + if ($file && $file->isDir()) { + $file = null; + } + + if (!$file) { + // URL has a context but is not a file? stop here + if ($context && array_key_exists($context, File::CONTEXTS_NAMES)) { + throw new UserException('Cette adresse n\'existe pas ou plus.', 404); + } + + return false; + } + + if ($file->trash) { + throw new UserException('Cette page n\'existe pas.', 404); + } + + foreach ($_GET as $key => $v) { + if (array_key_exists($key, File::ALLOWED_THUMB_SIZES)) { + $size = $key; + break; + } + } + + $session = Session::getInstance(); + + $signal = Plugins::fire('http.request.file.before', true, compact('file', 'uri', 'session')); + + if ($signal && $signal->isStopped()) { + // If a plugin handled the request, let's stop here + return true; + } + + $file->validateCanRead($session, $_GET['s'] ?? null, $_POST['p'] ?? null); + + if ($size) { + $file->serveThumbnail($size); + } + else { + $file->serve(isset($_GET['download'])); + } + + Plugins::fire('http.request.file.after', false, compact('file', 'uri', 'session')); + + return true; + } + + static public function log(string $message, ...$params) + { + if (!HTTP_LOG_FILE) { + return; + } + + static $log = ''; + + if (!$log) { + register_shutdown_function(function () use (&$log) { + file_put_contents(HTTP_LOG_FILE, $log, FILE_APPEND); + }); + } + + $log .= vsprintf($message, $params) . "\n\n"; + } + + static public function isXSendFileEnabled(): bool + { + if (!ENABLE_XSENDFILE || !isset($_SERVER['SERVER_SOFTWARE'])) { + return false; + } + + if (stristr($_SERVER['SERVER_SOFTWARE'], 'apache') + && function_exists('apache_get_modules') + && in_array('mod_xsendfile', apache_get_modules())) { + return true; + } + else if (stristr($_SERVER['SERVER_SOFTWARE'], 'lighttpd')) { + return true; + } + + return false; + } + + static public function xSendFile(string $path): void + { + header('X-Sendfile: ' . $path); + } +} diff --git a/src/include/lib/Paheko/Web/Sync.php b/src/include/lib/Paheko/Web/Sync.php new file mode 100644 index 0000000..a62a640 --- /dev/null +++ b/src/include/lib/Paheko/Web/Sync.php @@ -0,0 +1,247 @@ +set('title', $value); + } + elseif ($key == 'published') { + $page->set('published', new \DateTime($value)); + } + elseif ($key == 'modified') { + $page->set('modified', new \DateTime($value)); + } + elseif ($key == 'format') { + $value = strtolower($value); + + if (!array_key_exists($value, Page::FORMATS_LIST)) { + throw new \LogicException('Unknown format: ' . $value); + } + + $page->set('format', $value); + } + elseif ($key == 'type') { + $value = strtolower($value); + + if ($value == strtolower(Page::TYPES[Page::TYPE_CATEGORY])) { + $value = Page::TYPE_CATEGORY; + } + elseif ($value == strtolower(Page::TYPES[Page::TYPE_PAGE])) { + $value = Page::TYPE_PAGE; + } + else { + throw new \LogicException('Unknown type: ' . $value); + } + + $page->set('type', $value); + } + elseif ($key == 'status') { + $value = strtolower($value); + + if (!array_key_exists($value, Page::STATUS_LIST)) { + throw new \LogicException('Unknown status: ' . $value); + } + + $page->set('status', $value); + } + else { + // Ignore other metadata + } + } + + $page->set('content', trim($content, "\n\r")); + + return true; + } + + static protected function loadFromFile(Page $page, File $file): bool + { + // Don't update if page was modified in DB since + if ($page->modified && $page->modified > $file->modified) { + return false; + } + + $m = null; + + if ($page->modified) { + $m = clone $page->modified; + } + + if (!self::importFromRaw($page, $file->fetch())) { + throw new \LogicException('Invalid page content: ' . $file->parent); + } + + if (empty($page->modified)) { + $page->set('modified', $file->modified); + } + + if ($m && $m > $page->modified) { + return false; + } + + if (!isset($page->type) || $page->type != $page::TYPE_CATEGORY) { + $type = $page::TYPE_PAGE; + + foreach (Files::list($page->dir_path) as $file) { + if ($file->isDir()) { + $type = $page::TYPE_CATEGORY; + break; + } + } + + $page->set('type', $type); + } + else { + $page->set('type', $page::TYPE_CATEGORY); + } + + return true; + } + + static public function fromFile(File $file): Page + { + $page = new Page; + + $path = substr($file->parent, strlen(File::CONTEXT_WEB . '/')); + $parent = Utils::basename($path) ?: null; + + if ($parent) { + $parent = Web::getByUri($parent); + $parent = $parent ? $parent->id() : null; + } + + $page->importForm([ + 'id_parent' => $parent, + 'uri' => Utils::basename($file->parent), + ]); + + self::loadFromFile($page, $file); + return $page; + } + + static public function flatten(?string $path = null) + { + $path ??= File::CONTEXT_WEB; + + foreach (Files::list($path) as $file) { + if ($file->isDir()) { + self::flatten($file->path); + + if (substr_count($file->path, '/') >= 2) { + $file->rename(File::CONTEXT_WEB . '/' . $file->name, false); + } + } + } + } + + /** + * This syncs the whole website between the actual files and the web_pages table + */ + static public function sync(bool $force = false): array + { + // This is only useful if web pages are stored outside of the database + if (FILE_STORAGE_BACKEND == 'SQLite' && !$force) { + return []; + } + + $path = File::CONTEXT_WEB; + $errors = []; + + $list = iterator_to_array(Files::listRecursive($path, null, false)); + $exists = []; + + foreach ($list as $path => $file) { + if ($file->isDir() || $file->name !== 'index.txt') { + continue; + } + + $exists[$path] = Utils::basename(Utils::dirname($path)); + } + + $db = DB::getInstance(); + + $in_db = $db->getAssoc('SELECT uri, uri FROM web_pages;'); + + $deleted = array_diff($in_db, $exists); + $new = array_diff($exists, $in_db); + $intersection = array_intersect($in_db, $exists); + + if ($deleted) { + $db->exec(sprintf('DELETE FROM web_pages WHERE %s;', $db->where('uri', $deleted))); + } + + sort($new); + + foreach ($new as $path => $uri) { + $f = Files::get($path); + + if (!$f) { + // This is a directory without content, ignore + continue; + } + + try { + $page = self::fromFile($f); + $page->save(); + } + catch (ValidationException $e) { + // Ignore validation errors, just don't add pages to index + $errors[] = sprintf('Erreur à l\'import, page "%s": %s', str_replace(File::CONTEXT_WEB . '/', '', $f->parent), $e->getMessage()); + } + } + + if (count($new) || count($deleted)) { + Cache::clear(); + } + + foreach ($intersection as $path => $uri) { + $file = Files::get($path); + $page = Web::getByUri($uri); + + if ($page && $file && self::loadFromFile($page, $file)) { + $page->save(); + } + } + + self::flatten(); + + Storage::cleanup(); + + return $errors; + } +} diff --git a/src/include/lib/Paheko/Web/Web.php b/src/include/lib/Paheko/Web/Web.php new file mode 100644 index 0000000..c6d6521 --- /dev/null +++ b/src/include/lib/Paheko/Web/Web.php @@ -0,0 +1,178 @@ +uri = substr($result->path, strlen(File::CONTEXT_WEB) + 1); + } + + unset($result); + + return $results; + } + + static public function getBreadcrumbs(int $id): array + { + return DB::getInstance()->getGrouped(sprintf(self::BREADCRUMBS_SQL, $id)); + } + + static protected function getParentClause(?int $id_parent): string + { + if ($id_parent) { + return 'id_parent = ' . $id_parent; + } + else { + return 'id_parent IS NULL'; + } + } + + static public function listCategories(?int $id_parent): array + { + $sql = sprintf('SELECT * FROM @TABLE WHERE %s AND type = %d ORDER BY title COLLATE U_NOCASE;', self::getParentClause($id_parent), Page::TYPE_CATEGORY); + return EM::getInstance(Page::class)->all($sql); + } + + static public function listPages(?int $id_parent, bool $order_by_date = true): array + { + $order = $order_by_date ? 'published DESC' : 'title COLLATE U_NOCASE'; + $sql = sprintf('SELECT * FROM @TABLE WHERE %s AND type = %d ORDER BY %s;', self::getParentClause($id_parent), Page::TYPE_PAGE, $order); + return EM::getInstance(Page::class)->all($sql); + } + + static public function listAll(): array + { + $sql = 'SELECT * FROM @TABLE ORDER BY title COLLATE U_NOCASE;'; + return EM::getInstance(Page::class)->all($sql); + } + + static public function getDraftsList(?int $id_parent): DynamicList + { + $list = self::getPagesList($id_parent); + $list->setParameter('status', Page::STATUS_DRAFT); + $list->setPageSize(1000); + return $list; + } + + static public function getPagesList(?int $id_parent): DynamicList + { + $columns = [ + 'id' => [], + 'uri' => [ + ], + 'title' => [ + 'label' => 'Titre', + 'order' => 'title COLLATE U_NOCASE %s', + ], + 'published' => [ + 'label' => 'Publication', + ], + 'modified' => [ + 'label' => 'Modification', + ], + ]; + + $tables = Page::TABLE; + $conditions = self::getParentClause($id_parent) . ' AND type = :type AND status = :status'; + + $list = new DynamicList($columns, $tables, $conditions); + $list->setParameter('type', Page::TYPE_PAGE); + $list->setParameter('status', Page::STATUS_ONLINE); + $list->orderBy('title', false); + return $list; + } + + static public function getAllList(): DynamicList + { + $db = DB::getInstance(); + + $columns = [ + 'id' => [], + 'uri' => [ + ], + 'title' => [ + 'label' => 'Titre', + 'order' => 'title COLLATE U_NOCASE %s', + ], + 'path' => [ + 'label' => 'Catégorie', + 'select' => sprintf('(SELECT GROUP_CONCAT(title, \' > \') FROM (%s))', + rtrim(sprintf(Web::BREADCRUMBS_SQL, 'p.id_parent'), '; ')), + ], + 'draft' => [ + 'label' => 'Brouillon', + 'select' => sprintf('CASE WHEN p.status = %s THEN 1 ELSE 0 END', + $db->quote(Page::STATUS_DRAFT), + ), + 'order' => 'status %s, title COLLATE U_NOCASE %1$s', + ], + 'published' => [ + 'label' => 'Publication', + ], + 'modified' => [ + 'label' => 'Modification', + ], + ]; + + $tables = Page::TABLE . ' AS p'; + + $list = new DynamicList($columns, $tables); + $list->orderBy('title', false); + $list->setPageSize(null); + return $list; + } + + static public function getByURI(string $uri): ?Page + { + return EM::findOne(Page::class, 'SELECT * FROM @TABLE WHERE uri = ?;', $uri); + } + + static public function get(int $id): ?Page + { + return EM::findOne(Page::class, 'SELECT * FROM @TABLE WHERE id = ?;', $id); + } + + static public function checkAllInternalPagesLinks(): array + { + $sql = 'SELECT * FROM @TABLE ORDER BY title COLLATE U_NOCASE;'; + $list = []; + + foreach (EM::getInstance(Page::class)->iterate($sql) as $page) { + $list[$page->uri] = $page; + } + + $errors = []; + + foreach ($list as $page) { + if (count($page->checkInternalPagesLinks($list)) > 0) { + $errors[] = $page; + } + } + + return $errors; + } +} diff --git a/src/include/lib/dependencies.list b/src/include/lib/dependencies.list new file mode 100644 index 0000000..544fbfc --- /dev/null +++ b/src/include/lib/dependencies.list @@ -0,0 +1,43 @@ +KD2/data/ +KD2/DB/AbstractEntity.php +KD2/DB/DB.php +KD2/DB/Date.php +KD2/DB/EntityManager.php +KD2/DB/SQLite3.php +KD2/Brindille.php +KD2/ErrorManager.php +KD2/Form.php +KD2/FossilInstaller.php +KD2/Graphics/Blob.php +KD2/Graphics/Image.php +KD2/Graphics/BarCode.php +KD2/Graphics/QRCode.php +KD2/Graphics/SVG/Pie.php +KD2/Graphics/SVG/Plot.php +KD2/Graphics/SVG/Bar.php +KD2/JSONSchema.php +KD2/HTML/CSSParser.php +KD2/HTML/TableExport.php +KD2/HTML/TableToCSV.php +KD2/HTML/TableToODS.php +KD2/HTML/TableToXLSX.php +KD2/HTML/Markdown.php +KD2/HTML/Markdown_Extensions.php +KD2/HTTP.php +KD2/Mail_Message.php +KD2/Office/Calc/Writer.php +KD2/Office/ToText.php +KD2/Security.php +KD2/Security_OTP.php +KD2/SimpleDiff.php +KD2/SkrivLite.php +KD2/Smartyer.php +KD2/SMTP.php +KD2/Translate.php +KD2/UserSession.php +KD2/WebDAV/AbstractStorage.php +KD2/WebDAV/NextCloud.php +KD2/WebDAV/Server.php +KD2/WebDAV/WOPI.php +KD2/ZipReader.php +KD2/ZipWriter.php diff --git a/src/include/migrations/1.1/30.php b/src/include/migrations/1.1/30.php new file mode 100644 index 0000000..a6586c9 --- /dev/null +++ b/src/include/migrations/1.1/30.php @@ -0,0 +1,26 @@ +exec(sprintf('ATTACH \'%s\' AS old;', $old_db)); + +$chart_id = $db->firstColumn('SELECT id FROM acc_charts WHERE code = \'PCA_2018\' AND country = \'FR\';'); + +// We cannot use UPDATE FROM as it doesn't work with old SQLite < 3.33.0 +$db->exec(sprintf('UPDATE acc_accounts AS a + SET description = (SELECT b.description FROM old.acc_accounts b WHERE b.id = a.id), + type = (SELECT b.type FROM old.acc_accounts b WHERE b.id = a.id) + WHERE a.id_chart = %d AND EXISTS (SELECT b.id FROM old.acc_accounts b WHERE b.id = a.id AND b.label = a.label AND b.code = a.code);', $chart_id)); + +$db->exec('DETACH \'old\';'); diff --git a/src/include/migrations/1.1/31.sql b/src/include/migrations/1.1/31.sql new file mode 100644 index 0000000..85e7f05 --- /dev/null +++ b/src/include/migrations/1.1/31.sql @@ -0,0 +1,45 @@ +-- New config value +INSERT OR IGNORE INTO config (key, value) VALUES ('analytical_set_all', 1); + +-- Fix charts positions + +-- Force all third party accounts to be in position 3 (active or passive) +UPDATE acc_accounts SET position = 3 + WHERE id_chart IN (SELECT id FROM acc_charts WHERE country = 'FR') + AND position NOT IN (0, 1, 2, 3) + AND SUBSTR(code, 1, 1) IN ('1', '2', '3', '4', '5'); + +-- Force all expense accounts to be in expense position +UPDATE acc_accounts SET position = 4 + WHERE id_chart IN (SELECT id FROM acc_charts WHERE country = 'FR') + AND position NOT IN (0, 4) + AND code LIKE '6%'; + +-- Force all revenue accounts to be in revenue position +UPDATE acc_accounts SET position = 5 + WHERE id_chart IN (SELECT id FROM acc_charts WHERE country = 'FR') + AND position NOT IN (0, 5) + AND code LIKE '7%'; + +-- Force all volunteering accounts +UPDATE acc_accounts SET position = 4 + WHERE id_chart IN (SELECT id FROM acc_charts WHERE country = 'FR') + AND position NOT IN (0, 4) + AND code LIKE '86_%'; + +UPDATE acc_accounts SET position = 5 + WHERE id_chart IN (SELECT id FROM acc_charts WHERE country = 'FR') + AND position NOT IN (0, 5) + AND code LIKE '87_%'; + +-- Change code from accounts that are outside their class +UPDATE acc_accounts SET code = '9' || code WHERE type = 7 AND code NOT LIKE '9%'; + +-- Remove bank for banks that are outside their class +UPDATE acc_accounts SET type = 0 WHERE type = 1 AND code NOT LIKE '5%'; +UPDATE acc_accounts SET type = 0 WHERE type = 2 AND code NOT LIKE '5%'; +UPDATE acc_accounts SET type = 0 WHERE type = 3 AND code NOT LIKE '5%'; +UPDATE acc_accounts SET type = 0 WHERE type = 4 AND code NOT LIKE '4%'; +UPDATE acc_accounts SET type = 0 WHERE type = 5 AND code NOT LIKE '6%'; +UPDATE acc_accounts SET type = 0 WHERE type = 6 AND code NOT LIKE '7%'; +UPDATE acc_accounts SET type = 0 WHERE type = 8 AND code NOT LIKE '8%'; diff --git a/src/include/migrations/1.2/1.2.0.sql b/src/include/migrations/1.2/1.2.0.sql new file mode 100644 index 0000000..18d70e3 --- /dev/null +++ b/src/include/migrations/1.2/1.2.0.sql @@ -0,0 +1,74 @@ +ALTER TABLE acc_accounts RENAME TO acc_accounts_old; +ALTER TABLE acc_transactions_lines RENAME TO acc_transactions_lines_old; +ALTER TABLE services_fees RENAME TO services_fees_old; + +.read schema.sql + +INSERT OR IGNORE INTO acc_projects (code, label, description) + SELECT + a.code, + a.label, + a.description + FROM acc_accounts_old a + WHERE a.type = 7; + +-- Copy data to change the column name from acc_accounts to acc_projects +INSERT INTO services_fees SELECT * FROM services_fees_old; +INSERT INTO acc_transactions_lines SELECT * FROM acc_transactions_lines_old; + +-- Update references to analytical accounts +UPDATE services_fees AS a + SET id_project = (SELECT b.id FROM acc_projects AS b INNER JOIN acc_accounts_old c ON c.code = b.code WHERE c.id = a.id_project) + WHERE id_project IS NOT NULL; + +UPDATE acc_transactions_lines AS a + SET id_project = (SELECT b.id FROM acc_projects AS b INNER JOIN acc_accounts_old c ON c.code = b.code WHERE c.id = a.id_project) + WHERE id_project IS NOT NULL; + +-- Remove first 99 and 9 from code (added in 1.1.30) +UPDATE acc_projects AS a SET code = SUBSTR(code, 3) + WHERE SUBSTR(code, 1, 2) = '99' AND LENGTH(code) > 2 + AND NOT EXISTS (SELECT id FROM acc_projects WHERE code = SUBSTR(a.code, 3)); +UPDATE acc_projects AS a SET code = SUBSTR(code, 2) + WHERE SUBSTR(code, 1, 1) = '9' AND LENGTH(code) > 1 + AND NOT EXISTS (SELECT id FROM acc_projects WHERE code = SUBSTR(a.code, 2)); + +UPDATE acc_transactions_lines SET id_project = NULL WHERE id_project NOT IN (SELECT id FROM acc_projects); + +INSERT INTO acc_accounts SELECT *, CASE WHEN type > 0 AND type <= 8 THEN 1 ELSE 0 END FROM acc_accounts_old; + +-- Delete old analytical accounts +DELETE FROM acc_accounts AS a WHERE + (SELECT COUNT(*) FROM acc_transactions_lines AS b WHERE b.id_account = a.id) = 0 + AND (type = 7 + OR (id_chart IN (SELECT id FROM acc_charts WHERE country = 'FR') + AND type != 7 + AND code LIKE '9%' + AND user = 0 + ) + ); + +UPDATE acc_accounts SET type = 0; + +UPDATE acc_accounts SET type = 1 WHERE code LIKE '512_%' AND id_chart IN (SELECT id FROM acc_charts WHERE country = 'FR'); +UPDATE acc_accounts SET type = 2 WHERE code LIKE '53_%' AND id_chart IN (SELECT id FROM acc_charts WHERE country = 'FR'); +UPDATE acc_accounts SET type = 3 WHERE code LIKE '511_%' AND id_chart IN (SELECT id FROM acc_charts WHERE country = 'FR'); +UPDATE acc_accounts SET type = 4 WHERE code LIKE '4_%' AND id_chart IN (SELECT id FROM acc_charts WHERE country = 'FR'); +UPDATE acc_accounts SET type = 5 WHERE code LIKE '6_%' AND id_chart IN (SELECT id FROM acc_charts WHERE country = 'FR'); +UPDATE acc_accounts SET type = 6 WHERE code LIKE '7_%' AND id_chart IN (SELECT id FROM acc_charts WHERE country = 'FR'); +UPDATE acc_accounts SET type = 7 WHERE code LIKE '86_%' AND id_chart IN (SELECT id FROM acc_charts WHERE country = 'FR'); +UPDATE acc_accounts SET type = 8 WHERE code LIKE '87_%' AND id_chart IN (SELECT id FROM acc_charts WHERE country = 'FR'); +UPDATE acc_accounts SET type = 9 WHERE code = '890' AND id_chart IN (SELECT id FROM acc_charts WHERE country = 'FR'); +UPDATE acc_accounts SET type = 10 WHERE code = '891' AND id_chart IN (SELECT id FROM acc_charts WHERE country = 'FR'); +UPDATE acc_accounts SET type = 11 WHERE code = '120' AND id_chart IN (SELECT id FROM acc_charts WHERE country = 'FR'); +UPDATE acc_accounts SET type = 12 WHERE code = '129' AND id_chart IN (SELECT id FROM acc_charts WHERE country = 'FR'); +UPDATE acc_accounts SET type = 13 WHERE code = '1068' AND id_chart IN (SELECT id FROM acc_charts WHERE country = 'FR'); +UPDATE acc_accounts SET type = 14 WHERE code = '110' AND id_chart IN (SELECT id FROM acc_charts WHERE country = 'FR'); +UPDATE acc_accounts SET type = 15 WHERE code = '119' AND id_chart IN (SELECT id FROM acc_charts WHERE country = 'FR'); + + + +-- Cleanup +DROP TABLE acc_accounts_old; +DROP TABLE acc_transactions_lines_old; +DROP TABLE services_fees_old; \ No newline at end of file diff --git a/src/include/migrations/1.2/1.2.1.sql b/src/include/migrations/1.2/1.2.1.sql new file mode 100644 index 0000000..e992642 --- /dev/null +++ b/src/include/migrations/1.2/1.2.1.sql @@ -0,0 +1,26 @@ +ALTER TABLE acc_charts RENAME TO acc_charts_old; +DROP VIEW acc_accounts_balances; + +.read schema.sql + +INSERT INTO acc_charts SELECT * FROM acc_charts_old; + +-- Reset country if code was official, changing the country should not have been possible +UPDATE acc_charts SET country = 'FR' WHERE code IS NOT NULL AND code != 'PCMN_2019'; +UPDATE acc_charts SET country = 'BE' WHERE code IS NOT NULL AND code = 'PCMN_2019'; + +-- Reset country to FR for countries using something similar +UPDATE acc_charts SET country = 'FR' WHERE country IN ('GN', 'TN', 'RE', 'CN', 'PF', 'MW', 'CI', 'GP', 'GA', 'DE', 'NC'); + +-- Set country to NULL if outside of supported countries +UPDATE acc_charts SET country = NULL WHERE country NOT IN ('FR', 'BE', 'CH'); + +-- Reset type to zero if not supported +UPDATE acc_accounts SET type = 0 WHERE id_chart IN (SELECT id FROM acc_charts WHERE country IS NULL); + +-- Reset other charts is done in PHP code + +-- Fix some search +UPDATE recherches SET contenu = REPLACE(contenu, 'a2.code', 'p.code') WHERE contenu LIKE '%a2.code%'; + +DROP TABLE acc_charts_old; \ No newline at end of file diff --git a/src/include/migrations/1.2/1.2.2.php b/src/include/migrations/1.2/1.2.2.php new file mode 100644 index 0000000..c57079d --- /dev/null +++ b/src/include/migrations/1.2/1.2.2.php @@ -0,0 +1,31 @@ + 0 + AND c.country = \'FR\' + GROUP BY a.id;'; + +$db->begin(); + +foreach ($db->iterate($sql) as $row) { + $new_code = '5120' . substr($row->code, 2); + + if ($db->firstColumn('SELECT 1 FROM acc_accounts WHERE code = ? AND id_chart = ?;', $new_code, $row->id_chart)) { + $new_code = '51200' . substr($row->code, 2); + } + + // Change code + $db->preparedQuery('UPDATE acc_accounts SET code = ?, type = 1, user = ? WHERE id = ?;', [$new_code, $row->chart_code ? 1 : 0, $row->id]); + + // If the account was part of the official chart, put it back in the chart + if ($row->chart_code && !$row->user) { + $db->preparedQuery('INSERT INTO acc_accounts (id_chart, code, label, position, user) VALUES (?, ?, ?, ?, ?);', + [$row->id_chart, $row->code, $row->label, $row->position, 0]); + } +} + +$db->commit(); diff --git a/src/include/migrations/1.2/schema.sql b/src/include/migrations/1.2/schema.sql new file mode 100644 index 0000000..af65bd6 --- /dev/null +++ b/src/include/migrations/1.2/schema.sql @@ -0,0 +1,453 @@ +CREATE TABLE IF NOT EXISTS config ( + key TEXT PRIMARY KEY NOT NULL, + value TEXT NULL +); + +CREATE TABLE IF NOT EXISTS users_categories +-- Users categories, mainly used to manage rights +( + id INTEGER PRIMARY KEY NOT NULL, + name TEXT NOT NULL, + + -- Permissions, 0 = no access, 1 = read-only, 2 = read-write, 9 = admin + perm_web INTEGER NOT NULL DEFAULT 1, + perm_documents INTEGER NOT NULL DEFAULT 1, + perm_users INTEGER NOT NULL DEFAULT 1, + perm_accounting INTEGER NOT NULL DEFAULT 1, + + perm_subscribe INTEGER NOT NULL DEFAULT 0, + perm_connect INTEGER NOT NULL DEFAULT 1, + perm_config INTEGER NOT NULL DEFAULT 0, + + hidden INTEGER NOT NULL DEFAULT 0 +); + +CREATE INDEX IF NOT EXISTS users_categories_hidden ON users_categories (hidden); + +-- Membres de l'asso +-- Table dynamique générée par l'application +-- voir Garradin\Membres\Champs.php + +CREATE TABLE IF NOT EXISTS membres_sessions +-- Sessions +( + selecteur TEXT NOT NULL, + hash TEXT NOT NULL, + id_membre INTEGER NOT NULL REFERENCES membres (id) ON DELETE CASCADE, + expire INT NOT NULL, + + PRIMARY KEY (selecteur, id_membre) +); + +CREATE TABLE IF NOT EXISTS services +-- Types de services (cotisations) +( + id INTEGER PRIMARY KEY NOT NULL, + + label TEXT NOT NULL, + description TEXT NULL, + + duration INTEGER NULL CHECK (duration IS NULL OR duration > 0), -- En jours + start_date TEXT NULL CHECK (start_date IS NULL OR date(start_date) = start_date), + end_date TEXT NULL CHECK (end_date IS NULL OR (date(end_date) = end_date AND date(end_date) >= date(start_date))) +); + +CREATE TABLE IF NOT EXISTS services_fees +( + id INTEGER PRIMARY KEY NOT NULL, + + label TEXT NOT NULL, + description TEXT NULL, + + amount INTEGER NULL, + formula TEXT NULL, -- Formule de calcul du montant de la cotisation, si cotisation dynamique (exemple : membres.revenu_imposable * 0.01) + + id_service INTEGER NOT NULL REFERENCES services (id) ON DELETE CASCADE, + id_account INTEGER NULL REFERENCES acc_accounts (id) ON DELETE SET NULL CHECK (id_account IS NULL OR id_year IS NOT NULL), -- NULL if fee is not linked to accounting, this is reset using a trigger if the year is deleted + id_year INTEGER NULL REFERENCES acc_years (id) ON DELETE SET NULL, -- NULL if fee is not linked to accounting + id_project INTEGER NULL REFERENCES acc_projects (id) ON DELETE SET NULL +); + +CREATE TABLE IF NOT EXISTS services_users +-- Enregistrement des cotisations et activités +( + id INTEGER NOT NULL PRIMARY KEY, + id_user INTEGER NOT NULL REFERENCES membres (id) ON DELETE CASCADE, + id_service INTEGER NOT NULL REFERENCES services (id) ON DELETE CASCADE, + id_fee INTEGER NULL REFERENCES services_fees (id) ON DELETE CASCADE, -- This can be NULL if there is no fee for the service + + paid INTEGER NOT NULL DEFAULT 0, + expected_amount INTEGER NULL, + + date TEXT NOT NULL DEFAULT CURRENT_DATE CHECK (date(date) IS NOT NULL AND date(date) = date), + expiry_date TEXT NULL CHECK (date(expiry_date) IS NULL OR date(expiry_date) = expiry_date) +); + +CREATE UNIQUE INDEX IF NOT EXISTS su_unique ON services_users (id_user, id_service, date); + +CREATE INDEX IF NOT EXISTS su_service ON services_users (id_service); +CREATE INDEX IF NOT EXISTS su_fee ON services_users (id_fee); +CREATE INDEX IF NOT EXISTS su_paid ON services_users (paid); +CREATE INDEX IF NOT EXISTS su_expiry ON services_users (expiry_date); + +CREATE TABLE IF NOT EXISTS services_reminders +-- Rappels de devoir renouveller une cotisation +( + id INTEGER NOT NULL PRIMARY KEY, + id_service INTEGER NOT NULL REFERENCES services (id) ON DELETE CASCADE, + + delay INTEGER NOT NULL, -- Délai en jours pour envoyer le rappel + + subject TEXT NOT NULL, + body TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS services_reminders_sent +-- Enregistrement des rappels envoyés à qui et quand +( + id INTEGER NOT NULL PRIMARY KEY, + + id_user INTEGER NOT NULL REFERENCES membres (id) ON DELETE CASCADE, + id_service INTEGER NOT NULL REFERENCES services (id) ON DELETE CASCADE, + id_reminder INTEGER NOT NULL REFERENCES services_reminders (id) ON DELETE CASCADE, + + sent_date TEXT NOT NULL DEFAULT CURRENT_DATE CHECK (date(sent_date) IS NOT NULL AND date(sent_date) = sent_date), + due_date TEXT NOT NULL CHECK (date(due_date) IS NOT NULL AND date(due_date) = due_date) +); + +CREATE UNIQUE INDEX IF NOT EXISTS srs_index ON services_reminders_sent (id_user, id_service, id_reminder, due_date); + +CREATE INDEX IF NOT EXISTS srs_reminder ON services_reminders_sent (id_reminder); +CREATE INDEX IF NOT EXISTS srs_user ON services_reminders_sent (id_user); + +-- +-- COMPTA +-- + +CREATE TABLE IF NOT EXISTS acc_charts +-- Plans comptables : il peut y en avoir plusieurs +( + id INTEGER NOT NULL PRIMARY KEY, + country TEXT NULL, + code TEXT NULL, -- NULL = plan comptable créé par l'utilisateur + label TEXT NOT NULL, + archived INTEGER NOT NULL DEFAULT 0 -- 1 = archivé, non-modifiable +); + +CREATE TABLE IF NOT EXISTS acc_accounts +-- Comptes des plans comptables +( + id INTEGER NOT NULL PRIMARY KEY, + id_chart INTEGER NOT NULL REFERENCES acc_charts ON DELETE CASCADE, + + code TEXT NOT NULL, -- peut contenir des lettres, eg. 53A, 53B, etc. + + label TEXT NOT NULL, + description TEXT NULL, + + position INTEGER NOT NULL, -- position actif/passif/charge/produit + type INTEGER NOT NULL DEFAULT 0, -- Type de compte spécial : banque, caisse, en attente d'encaissement, etc. + user INTEGER NOT NULL DEFAULT 1, -- 0 = fait partie du plan comptable original, 1 = a été ajouté par l'utilisateur + bookmark INTEGER NOT NULL DEFAULT 0 -- 1 = is marked as favorite +); + +CREATE UNIQUE INDEX IF NOT EXISTS acc_accounts_codes ON acc_accounts (code, id_chart); +CREATE INDEX IF NOT EXISTS acc_accounts_type ON acc_accounts (type); +CREATE INDEX IF NOT EXISTS acc_accounts_position ON acc_accounts (position); +CREATE INDEX IF NOT EXISTS acc_accounts_bookmarks ON acc_accounts (id_chart, bookmark, code); + +-- Balance des comptes par exercice +CREATE VIEW IF NOT EXISTS acc_accounts_balances +AS + SELECT id_year, id, label, code, type, debit, credit, bookmark, + CASE -- 3 = dynamic asset or liability depending on balance + WHEN position = 3 AND (debit - credit) > 0 THEN 1 -- 1 = Asset (actif) comptes fournisseurs, tiers créditeurs + WHEN position = 3 THEN 2 -- 2 = Liability (passif), comptes clients, tiers débiteurs + ELSE position + END AS position, + CASE + WHEN position IN (1, 4) -- 1 = asset, 4 = expense + OR (position = 3 AND (debit - credit) > 0) + THEN + debit - credit + ELSE + credit - debit + END AS balance, + CASE WHEN debit - credit > 0 THEN 1 ELSE 0 END AS is_debt + FROM ( + SELECT t.id_year, a.id, a.label, a.code, a.position, a.type, a.bookmark, + SUM(l.credit) AS credit, + SUM(l.debit) AS debit + FROM acc_accounts a + INNER JOIN acc_transactions_lines l ON l.id_account = a.id + INNER JOIN acc_transactions t ON t.id = l.id_transaction + GROUP BY t.id_year, a.id + ); + +CREATE TABLE IF NOT EXISTS acc_projects +-- Analytical projects +( + id INTEGER NOT NULL PRIMARY KEY, + + code TEXT NULL, + + label TEXT NOT NULL, + description TEXT NULL, + + archived INTEGER NOT NULL DEFAULT 0 +); + +CREATE UNIQUE INDEX IF NOT EXISTS acc_projects_code ON acc_projects (code); +CREATE INDEX IF NOT EXISTS acc_projects_list ON acc_projects (archived, code); + +CREATE TABLE IF NOT EXISTS acc_years +-- Exercices +( + id INTEGER NOT NULL PRIMARY KEY, + + label TEXT NOT NULL, + + start_date TEXT NOT NULL CHECK (date(start_date) IS NOT NULL AND date(start_date) = start_date), + end_date TEXT NOT NULL CHECK (date(end_date) IS NOT NULL AND date(end_date) = end_date), + + closed INTEGER NOT NULL DEFAULT 0, + + id_chart INTEGER NOT NULL REFERENCES acc_charts (id) +); + +CREATE INDEX IF NOT EXISTS acc_years_closed ON acc_years (closed); + +-- Make sure id_account is reset when a year is deleted +CREATE TRIGGER IF NOT EXISTS acc_years_delete BEFORE DELETE ON acc_years BEGIN + UPDATE services_fees SET id_account = NULL, id_year = NULL WHERE id_year = OLD.id; +END; + +CREATE TABLE IF NOT EXISTS acc_transactions +-- Opérations comptables +( + id INTEGER PRIMARY KEY NOT NULL, + + type INTEGER NOT NULL DEFAULT 0, -- Type d'écriture, 0 = avancée (normale) + status INTEGER NOT NULL DEFAULT 0, -- Statut (bitmask) + + label TEXT NOT NULL, + notes TEXT NULL, + reference TEXT NULL, -- N° de pièce comptable + + date TEXT NOT NULL DEFAULT CURRENT_DATE CHECK (date(date) IS NOT NULL AND date(date) = date), + + validated INTEGER NOT NULL DEFAULT 0, -- 1 = écriture validée, non modifiable + + hash TEXT NULL, + prev_hash TEXT NULL, + + id_year INTEGER NOT NULL REFERENCES acc_years(id), + id_creator INTEGER NULL REFERENCES membres(id) ON DELETE SET NULL, + id_related INTEGER NULL REFERENCES acc_transactions(id) ON DELETE SET NULL -- écriture liée (par ex. remboursement d'une dette) +); + +CREATE INDEX IF NOT EXISTS acc_transactions_year ON acc_transactions (id_year); +CREATE INDEX IF NOT EXISTS acc_transactions_date ON acc_transactions (date); +CREATE INDEX IF NOT EXISTS acc_transactions_related ON acc_transactions (id_related); +CREATE INDEX IF NOT EXISTS acc_transactions_type ON acc_transactions (type, id_year); +CREATE INDEX IF NOT EXISTS acc_transactions_status ON acc_transactions (status); + +CREATE TABLE IF NOT EXISTS acc_transactions_lines +-- Lignes d'écritures d'une opération +( + id INTEGER PRIMARY KEY NOT NULL, + + id_transaction INTEGER NOT NULL REFERENCES acc_transactions (id) ON DELETE CASCADE, + id_account INTEGER NOT NULL REFERENCES acc_accounts (id), -- N° du compte dans le plan comptable + + credit INTEGER NOT NULL, + debit INTEGER NOT NULL, + + reference TEXT NULL, -- Référence de paiement, eg. numéro de chèque + label TEXT NULL, + + reconciled INTEGER NOT NULL DEFAULT 0, + + id_project INTEGER NULL REFERENCES acc_projects(id) ON DELETE SET NULL, + + CONSTRAINT line_check1 CHECK ((credit * debit) = 0), + CONSTRAINT line_check2 CHECK ((credit + debit) > 0) +); + +CREATE INDEX IF NOT EXISTS acc_transactions_lines_transaction ON acc_transactions_lines (id_transaction); +CREATE INDEX IF NOT EXISTS acc_transactions_lines_account ON acc_transactions_lines (id_account); +CREATE INDEX IF NOT EXISTS acc_transactions_lines_project ON acc_transactions_lines (id_project); +CREATE INDEX IF NOT EXISTS acc_transactions_lines_reconciled ON acc_transactions_lines (reconciled); + +CREATE TABLE IF NOT EXISTS acc_transactions_users +-- Liaison des écritures et des membres +( + id_user INTEGER NOT NULL REFERENCES membres (id) ON DELETE CASCADE, + id_transaction INTEGER NOT NULL REFERENCES acc_transactions (id) ON DELETE CASCADE, + id_service_user INTEGER NULL REFERENCES services_users (id) ON DELETE SET NULL, + + PRIMARY KEY (id_user, id_transaction) +); + +CREATE INDEX IF NOT EXISTS acc_transactions_users_service ON acc_transactions_users (id_service_user); + +CREATE TABLE IF NOT EXISTS plugins +( + id TEXT NOT NULL PRIMARY KEY, + officiel INTEGER NOT NULL DEFAULT 0, + nom TEXT NOT NULL, + description TEXT NULL, + auteur TEXT NULL, + url TEXT NULL, + version TEXT NOT NULL, + menu INTEGER NOT NULL DEFAULT 0, + menu_condition TEXT NULL, + config TEXT NULL +); + +CREATE TABLE IF NOT EXISTS plugins_signaux +-- Association entre plugins et signaux (hooks) +( + signal TEXT NOT NULL, + plugin TEXT NOT NULL REFERENCES plugins (id), + callback TEXT NOT NULL, + PRIMARY KEY (signal, plugin) +); + +CREATE TABLE IF NOT EXISTS api_credentials +( + id INTEGER NOT NULL PRIMARY KEY, + label TEXT NOT NULL, + key TEXT NOT NULL, + secret TEXT NOT NULL, + created TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + last_use TEXT NULL, + access_level INT NOT NULL +); + +CREATE UNIQUE INDEX IF NOT EXISTS api_credentials_key ON api_credentials (key); + +---------- FILES ---------------- + +CREATE TABLE IF NOT EXISTS files +-- Files metadata +( + id INTEGER NOT NULL PRIMARY KEY, + path TEXT NOT NULL, + parent TEXT NOT NULL, + name TEXT NOT NULL, -- File name + type INTEGER NOT NULL, -- File type, 1 = file, 2 = directory + mime TEXT NULL, + size INT NULL, + modified TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP CHECK (datetime(modified) = modified), + image INT NOT NULL DEFAULT 0, + + CHECK (type = 2 OR (mime IS NOT NULL AND size IS NOT NULL)) +); + +-- Unique index as this is used to make up a file path +CREATE UNIQUE INDEX IF NOT EXISTS files_unique ON files (path); +CREATE INDEX IF NOT EXISTS files_parent ON files (parent); +CREATE INDEX IF NOT EXISTS files_name ON files (name); +CREATE INDEX IF NOT EXISTS files_modified ON files (modified); + +CREATE TABLE IF NOT EXISTS files_contents +-- Files contents (empty if using another storage backend) +( + id INTEGER NOT NULL PRIMARY KEY REFERENCES files(id) ON DELETE CASCADE, + compressed INT NOT NULL DEFAULT 0, + content BLOB NOT NULL +); + +CREATE VIRTUAL TABLE IF NOT EXISTS files_search USING fts4 +-- Search inside files content +( + tokenize=unicode61, -- Available from SQLITE 3.7.13 (2012) + path TEXT NOT NULL, + title TEXT NULL, + content TEXT NOT NULL, -- Text content + notindexed=path +); + +CREATE TABLE IF NOT EXISTS web_pages +( + id INTEGER NOT NULL PRIMARY KEY, + parent TEXT NOT NULL, -- Parent path, empty = web root + path TEXT NOT NULL, -- Full page directory name + uri TEXT NOT NULL, -- Page identifier + file_path TEXT NOT NULL, -- Full file path for contents + type INTEGER NOT NULL, -- 1 = Category, 2 = Page + status TEXT NOT NULL, + format TEXT NOT NULL, + published TEXT NOT NULL CHECK (datetime(published) = published), + modified TEXT NOT NULL CHECK (datetime(modified) = modified), + title TEXT NOT NULL, + content TEXT NOT NULL +); + +CREATE UNIQUE INDEX IF NOT EXISTS web_pages_path ON web_pages (path); +CREATE UNIQUE INDEX IF NOT EXISTS web_pages_uri ON web_pages (uri); +CREATE UNIQUE INDEX IF NOT EXISTS web_pages_file_path ON web_pages (file_path); +CREATE INDEX IF NOT EXISTS web_pages_parent ON web_pages (parent); +CREATE INDEX IF NOT EXISTS web_pages_published ON web_pages (published); +CREATE INDEX IF NOT EXISTS web_pages_title ON web_pages (title); + +-- FIXME: rename to english +CREATE TABLE IF NOT EXISTS recherches +-- Recherches enregistrées +( + id INTEGER NOT NULL PRIMARY KEY, + id_membre INTEGER NULL REFERENCES membres (id) ON DELETE CASCADE, -- Si non NULL, alors la recherche ne sera visible que par le membre associé + intitule TEXT NOT NULL, + creation TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP CHECK (datetime(creation) IS NOT NULL AND datetime(creation) = creation), + cible TEXT NOT NULL, -- "membres" ou "compta" + type TEXT NOT NULL, -- "json" ou "sql" + contenu TEXT NOT NULL +); + + +CREATE TABLE IF NOT EXISTS compromised_passwords_cache +-- Cache des hash de mots de passe compromis +( + hash TEXT NOT NULL PRIMARY KEY +); + +CREATE TABLE IF NOT EXISTS compromised_passwords_cache_ranges +-- Cache des préfixes de mots de passe compromis +( + prefix TEXT NOT NULL PRIMARY KEY, + date INTEGER NOT NULL +); + +CREATE TABLE IF NOT EXISTS emails ( +-- List of emails addresses +-- We are not storing actual email addresses here for privacy reasons +-- So that we can keep the record (for opt-out reasons) even when the +-- email address has been removed from the users table + id INTEGER NOT NULL PRIMARY KEY, + hash TEXT NOT NULL, + verified INTEGER NOT NULL DEFAULT 0, + optout INTEGER NOT NULL DEFAULT 0, + invalid INTEGER NOT NULL DEFAULT 0, + fail_count INTEGER NOT NULL DEFAULT 0, + sent_count INTEGER NOT NULL DEFAULT 0, + fail_log TEXT NULL, + last_sent TEXT NULL, + added TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE UNIQUE INDEX IF NOT EXISTS emails_hash ON emails (hash); + +CREATE TABLE IF NOT EXISTS emails_queue ( +-- List of emails waiting to be sent + id INTEGER NOT NULL PRIMARY KEY, + sender TEXT NULL, + recipient TEXT NOT NULL, + recipient_hash TEXT NOT NULL, + subject TEXT NOT NULL, + content TEXT NOT NULL, + content_html TEXT NULL, + sending INTEGER NOT NULL DEFAULT 0, -- Will be changed to 1 when the queue run will start + sending_started TEXT NULL, -- Will be filled with the datetime when the email sending was started + context INTEGER NOT NULL +); diff --git a/src/include/migrations/1.3/1.3.0-rc12.sql b/src/include/migrations/1.3/1.3.0-rc12.sql new file mode 100644 index 0000000..207e300 --- /dev/null +++ b/src/include/migrations/1.3/1.3.0-rc12.sql @@ -0,0 +1 @@ +UPDATE config_users_fields SET required = 0 WHERE system & (0x01 << 1); diff --git a/src/include/migrations/1.3/1.3.0-rc13.sql b/src/include/migrations/1.3/1.3.0-rc13.sql new file mode 100644 index 0000000..05aad9f --- /dev/null +++ b/src/include/migrations/1.3/1.3.0-rc13.sql @@ -0,0 +1,34 @@ +ALTER TABLE config_users_fields RENAME TO config_users_fields_old; +DROP INDEX config_users_fields_name; + +CREATE TABLE IF NOT EXISTS config_users_fields ( + id INTEGER NOT NULL PRIMARY KEY, + name TEXT NOT NULL, + sort_order INTEGER NOT NULL, + type TEXT NOT NULL, + label TEXT NOT NULL, + help TEXT NULL, + required INTEGER NOT NULL DEFAULT 0, + user_access_level INTEGER NOT NULL DEFAULT 0, + management_access_level INTEGER NOT NULL DEFAULT 1, + list_table INTEGER NOT NULL DEFAULT 0, + options TEXT NULL, + default_value TEXT NULL, + sql TEXT NULL, + system TEXT NULL +); + +CREATE UNIQUE INDEX IF NOT EXISTS config_users_fields_name ON config_users_fields (name); + +INSERT INTO config_users_fields + SELECT id, name, sort_order, type, label, help, required, + CASE WHEN write_access = 1 THEN 2 + WHEN read_access = 1 THEN 1 + ELSE 0 END, + 1, + list_table, + options, + default_value, + sql, + system + FROM config_users_fields_old; diff --git a/src/include/migrations/1.3/1.3.0-rc14.php b/src/include/migrations/1.3/1.3.0-rc14.php new file mode 100644 index 0000000..d10e10b --- /dev/null +++ b/src/include/migrations/1.3/1.3.0-rc14.php @@ -0,0 +1,11 @@ +beginSchemaUpdate(); +$db->import(ROOT . '/include/migrations/1.3/1.3.0-rc14.sql'); +$db->commitSchemaUpdate(); + +Sync::flatten(); diff --git a/src/include/migrations/1.3/1.3.0-rc14.sql b/src/include/migrations/1.3/1.3.0-rc14.sql new file mode 100644 index 0000000..c17d8c2 --- /dev/null +++ b/src/include/migrations/1.3/1.3.0-rc14.sql @@ -0,0 +1,41 @@ +ALTER TABLE web_pages RENAME TO web_pages_old; +DROP INDEX IF EXISTS web_pages_path; +DROP INDEX IF EXISTS web_pages_dir_path; +DROP INDEX IF EXISTS web_pages_uri; +DROP INDEX IF EXISTS web_pages_parent; +DROP INDEX IF EXISTS web_pages_published; +DROP INDEX IF EXISTS web_pages_title; + +CREATE TABLE IF NOT EXISTS web_pages +( + id INTEGER NOT NULL PRIMARY KEY, + id_parent INTEGER NULL REFERENCES web_pages(id) ON DELETE CASCADE, + uri TEXT NOT NULL, -- Page identifier + type INTEGER NOT NULL, -- 1 = Category, 2 = Page + status TEXT NOT NULL, + format TEXT NOT NULL, + published TEXT NOT NULL CHECK (datetime(published) IS NOT NULL AND datetime(published) = published), + modified TEXT NOT NULL CHECK (datetime(modified) IS NOT NULL AND datetime(modified) = modified), + title TEXT NOT NULL, + content TEXT NOT NULL +); + +CREATE UNIQUE INDEX IF NOT EXISTS web_pages_uri ON web_pages (uri); +CREATE INDEX IF NOT EXISTS web_pages_id_parent ON web_pages (id_parent); +CREATE INDEX IF NOT EXISTS web_pages_published ON web_pages (published); +CREATE INDEX IF NOT EXISTS web_pages_title ON web_pages (title); + +INSERT INTO web_pages SELECT + id, + (SELECT id FROM web_pages_old WHERE path = a.parent), + uri, + type, + status, + format, + published, + modified, + title, + content + FROM web_pages_old AS a; + +DROP TABLE web_pages_old; diff --git a/src/include/migrations/1.3/1.3.0-rc15.sql b/src/include/migrations/1.3/1.3.0-rc15.sql new file mode 100644 index 0000000..defd522 --- /dev/null +++ b/src/include/migrations/1.3/1.3.0-rc15.sql @@ -0,0 +1,26 @@ +-- Replace old placeholders +UPDATE services_reminders SET body = REPLACE(body, '#IDENTITE', '{{$identity}}'), + subject = REPLACE(subject, '#IDENTITE', ''); +UPDATE services_reminders SET body = REPLACE(body, '#NB_JOURS', '{{$nb_days}}'), + subject = REPLACE(subject, '#NB_JOURS', ''); +UPDATE services_reminders SET body = REPLACE(body, '#DELAI', '{{$delay}}'), + subject = REPLACE(subject, '#DELAI', ''); +UPDATE services_reminders SET body = REPLACE(body, '#DATE_RAPPEL', '{{$reminder_date}}'), + subject = REPLACE(subject, '#DATE_RAPPEL', ''); +UPDATE services_reminders SET body = REPLACE(body, '#DATE_EXPIRATION', '{{$expiry_date}}'), + subject = REPLACE(subject, '#DATE_EXPIRATION', ''); + +UPDATE services_reminders SET body = REPLACE(body, '#NOM_ASSO', '{{$config.org_name}}'), + subject = REPLACE(subject, '#NOM_ASSO', (SELECT value FROM config WHERE key = 'org_name')); +UPDATE services_reminders SET body = REPLACE(body, '#ADRESSE_ASSO', '{{$config.org_address}}'), + subject = REPLACE(subject, '#ADRESSE_ASSO', ''); +UPDATE services_reminders SET body = REPLACE(body, '#EMAIL_ASSO', '{{$config.org_email}}'), + subject = REPLACE(subject, '#EMAIL_ASSO', ''); +UPDATE services_reminders SET body = REPLACE(body, '#SITE_ASSO', '{{$config.org_web}}'), + subject = REPLACE(subject, '#SITE_ASSO', ''); +UPDATE services_reminders SET body = REPLACE(body, '#URL_RACINE', '{{$root_url}}'), + subject = REPLACE(subject, '#URL_RACINE', ''); +UPDATE services_reminders SET body = REPLACE(body, '#URL_SITE', '{{$site_url}}'), + subject = REPLACE(subject, '#URL_SITE', ''); +UPDATE services_reminders SET body = REPLACE(body, '#URL_ADMIN', '{{$admin_url}}'), + subject = REPLACE(subject, '#URL_ADMIN', ''); \ No newline at end of file diff --git a/src/include/migrations/1.3/1.3.0-rc2.php b/src/include/migrations/1.3/1.3.0-rc2.php new file mode 100644 index 0000000..ecbddd7 --- /dev/null +++ b/src/include/migrations/1.3/1.3.0-rc2.php @@ -0,0 +1,19 @@ +beginSchemaUpdate(); + +$fields = DynamicFields::getInstance(); + +foreach ($fields->all() as $field) { + if ($field->type == 'generated') { + $field->delete(); + } +} + +$fields->save(); + +$db->commitSchemaUpdate(); diff --git a/src/include/migrations/1.3/1.3.0-rc5.php b/src/include/migrations/1.3/1.3.0-rc5.php new file mode 100644 index 0000000..fe25edb --- /dev/null +++ b/src/include/migrations/1.3/1.3.0-rc5.php @@ -0,0 +1,42 @@ +beginSchemaUpdate(); +$db->dropIndexes(); + +$db->import(ROOT . '/include/migrations/1.3/1.3.0-rc5.sql'); + +$db->commitSchemaUpdate(); + +if (FILE_STORAGE_BACKEND == 'FileSystem' && file_exists(FILE_STORAGE_CONFIG)) { + // Trash works differently now + $db->exec('UPDATE files SET trash = NULL WHERE trash IS NOT NULL;'); + + rename(FILE_STORAGE_CONFIG, FILE_STORAGE_CONFIG . '.deprecated'); + Files::disableVersioning(); + + foreach (Files::all() as $file) { + if ($file->isDir()) { + continue; + } + + // Copy files from old location to new + $path = sprintf(FILE_STORAGE_CONFIG . '.deprecated/%.2s/%1$s', md5($file->id())); + + if (!file_exists($path)) { + $file->delete(); + continue; + } + + $file->store(compact('path')); + Utils::safe_unlink($path); + } +} diff --git a/src/include/migrations/1.3/1.3.0-rc5.sql b/src/include/migrations/1.3/1.3.0-rc5.sql new file mode 100644 index 0000000..d693c9d --- /dev/null +++ b/src/include/migrations/1.3/1.3.0-rc5.sql @@ -0,0 +1,8 @@ +ALTER TABLE web_pages RENAME TO web_pages_old; + +.read schema.sql + +-- Drop foreign key constant between web_pages and files, as files can just be a cache, +-- with missing web pages directories +INSERT INTO web_pages SELECT * FROM web_pages_old; +DROP TABLE web_pages_old; diff --git a/src/include/migrations/1.3/1.3.0-rc7.php b/src/include/migrations/1.3/1.3.0-rc7.php new file mode 100644 index 0000000..1e8f2ef --- /dev/null +++ b/src/include/migrations/1.3/1.3.0-rc7.php @@ -0,0 +1,9 @@ +exec('BEGIN; + REPLACE INTO config VALUES (\'file_versioning_policy\', \'none\'); + REPLACE INTO config VALUES (\'file_versioning_max_size\', 5); + UPDATE web_pages SET format = \'encrypted\' WHERE format = \'skriv/encrypted\'; + COMMIT;'); diff --git a/src/include/migrations/1.3/1.3.0.php b/src/include/migrations/1.3/1.3.0.php new file mode 100644 index 0000000..f8aa883 --- /dev/null +++ b/src/include/migrations/1.3/1.3.0.php @@ -0,0 +1,214 @@ +beginSchemaUpdate(); + +Files::disableQuota(); +Files::disableVersioning(); + +// There seems to be some plugins table left on some database even when the plugin has been removed +if (!$db->test('plugins', 'id = ?', 'taima')) { + $db->exec(' + DROP TABLE IF EXISTS plugin_taima_entries; + DROP TABLE IF EXISTS plugin_taima_tasks; + '); +} + +// We need to drop indexes, are they will be left, but linked to old tables +// and new ones won't be re-created +$db->dropIndexes(); + +// Get old keys +$config = (object) $db->getAssoc('SELECT key, value FROM config WHERE key IN (\'champs_membres\', \'champ_identifiant\', \'champ_identite\');'); + +// Create config_users_fields table +$db->exec(' +CREATE TABLE IF NOT EXISTS config_users_fields ( + id INTEGER NOT NULL PRIMARY KEY, + name TEXT NOT NULL, + sort_order INTEGER NOT NULL, + type TEXT NOT NULL, + label TEXT NOT NULL, + help TEXT NULL, + required INTEGER NOT NULL DEFAULT 0, + user_access_level INTEGER NOT NULL DEFAULT 0, + management_access_level INTEGER NOT NULL DEFAULT 1, + list_table INTEGER NOT NULL DEFAULT 0, + options TEXT NULL, + default_value TEXT NULL, + sql TEXT NULL, + system TEXT NULL +);'); + +// Migrate users table +$df = \Paheko\Users\DynamicFields::fromOldINI($config->champs_membres, $config->champ_identifiant, $config->champ_identite, 'numero'); +$df->save(false); + +$trim_field = function (string $name) use ($db) { + $db->exec(sprintf("UPDATE users SET %s = TRIM(REPLACE(REPLACE(%1\$s, X'0D' || X'0A', X'0A'), X'0D', X'0A'), ' ' || X'0D' || X'0A') WHERE %1\$s IS NOT NULL AND %1\$s != '';", $db->quoteIdentifier($name))); +}; + +// Normalize line breaks in user fields, and trim +foreach ($df->all() as $name => $field) { + if (!$df->isText($name)) { + continue; + } + + try { + $trim_field($name); + } + catch (DB_Exception $e) { + if (false === strpos($e->getMessage(), 'UNIQUE constraint failed') + || $name !== $config->champ_identifiant + || !$df->get('numero')) { + throw $e; + } + + // Change login field if current login field is not unique after trim + $df->changeLoginField('numero'); + $trim_field($name); + } +} + +// Migrate other stuff +$db->import(ROOT . '/include/migrations/1.3/1.3.0.sql'); + +// Reindex all files in search, as moving files was broken +$db->exec('DELETE FROM files_search;'); + +Files::ensureContextsExists(); + +if (FILE_STORAGE_BACKEND == 'FileSystem') { + $root = FILE_STORAGE_CONFIG; + + // Move skeletons to new path + if (file_exists($root . '/skel')) { + if (!file_exists($root . '/modules')) { + Utils::safe_mkdir($root . '/modules', 0777, true); + } + + if (!file_exists($root . '/modules/web')) { + rename($root . '/skel', $root . '/modules/web'); + } + } + + Storage::sync(); + WebSync::sync(); +} +else { + // Move files from old table to new + $db->exec(' + REPLACE INTO files + SELECT f.id, path, parent, name, type, mime, size, modified, image, md5(fc.content), NULL + FROM files_old f INNER JOIN files_contents_old fc ON fc.id = f.id; + REPLACE INTO files_contents (id, content) SELECT id, content FROM files_contents_old;'); + + // Move skeletons from skel/ to modules/web/ + Files::mkdir('modules/web'); + $db->exec('UPDATE files SET path = REPLACE(path, \'skel/\', \'modules/web\'), parent = REPLACE(parent, \'skel/\', \'modules/web\') + WHERE parent LIKE \'skel/%\';'); +} + +$db->exec(' + DROP TABLE files_contents_old; + DROP TABLE files_old; +'); + +// Prepend "./" to includes functions file parameter in web skeletons +foreach (Files::list('modules/web') as $file) { + if ($file->type == $file::TYPE_DIRECTORY) { + continue; + } + + foreach (Files::list(File::CONTEXT_MODULES . '/web') as $file) { + if ($file->type != File::TYPE_FILE || !preg_match('/\.(?:txt|css|js|html|htm)$/', $file->name)) { + continue; + } + + $file->setContent(preg_replace('/(\s+file=")(\w+)/', '$1./$2', $file->fetch())); + } +} + +// Update searches +foreach ($db->iterate('SELECT * FROM searches;') as $row) { + if ($row->type == 'json') { + $json = json_decode($row->content); + + if (!$json) { + $db->delete('searches', 'id = ?', $row->id); + continue; + } + + $json->groups = $json->query; + unset($json->query, $json->limit); + + $content = json_encode($json); + } + else { + $content = preg_replace('/\bmembres\b/', 'users', $row->content); + } + + $db->update('searches', ['content' => $content], 'id = ' . (int) $row->id); +} + +// Add signature to config files +$files = $db->firstColumn('SELECT value FROM config WHERE key = \'files\';'); +$files = json_decode($files); +$files->signature = null; +$db->exec(sprintf('REPLACE INTO config (key, value) VALUES (\'files\', %s);', $db->quote(json_encode($files)))); + +Modules::refresh(); + +if ($db->test('sqlite_master', 'type = \'table\' AND name = ?', 'plugin_reservations_categories')) { + $db->import(__DIR__ . '/1.3.0_bookings.sql'); + $m = Modules::get('bookings'); + + if ($m) { + $m->enabled = true; + $m->save(); + } +} + +$db->commitSchemaUpdate(); + +$db->begin(); + +// Delete index.txt files +// This needs to be done AFTER commit schema update as it disables foreign key actions +foreach (Files::all() as $file) { + if ($file->isDir()) { + continue; + } + + if ($file->context() == $file::CONTEXT_WEB && $file->name == 'index.txt') { + $file->delete(); + continue; + } + + // Reindex file contents + $file->indexForSearch(); + + // Save files in DB + $file->save(); + +} + +// Reindex web pages +foreach (Web::listAll() as $page) { + $page->syncSearch(); +} + + +$db->commit(); diff --git a/src/include/migrations/1.3/1.3.0.sql b/src/include/migrations/1.3/1.3.0.sql new file mode 100644 index 0000000..b367001 --- /dev/null +++ b/src/include/migrations/1.3/1.3.0.sql @@ -0,0 +1,114 @@ +-- The new users table has already been created and copied +ALTER TABLE plugins RENAME TO plugins_old; +ALTER TABLE plugins_signaux RENAME TO plugins_signaux_old; + +-- References old membres table +ALTER TABLE services_users RENAME TO services_users_old; -- Also take id_fee into account for unique key +ALTER TABLE services_reminders_sent RENAME TO services_reminders_sent_old; +ALTER TABLE acc_transactions RENAME TO acc_transactions_old; +ALTER TABLE acc_transactions_users RENAME TO acc_transactions_users_old; + +-- Fix error on foreign key +ALTER TABLE acc_charts RENAME TO acc_charts_old; + +ALTER TABLE emails_queue RENAME TO emails_queue_old; + +DROP VIEW acc_accounts_balances; + +ALTER TABLE files RENAME TO files_old; +ALTER TABLE files_contents RENAME TO files_contents_old; +ALTER TABLE web_pages RENAME TO web_pages_old; + +UPDATE web_pages_old SET format = 'encrypted' WHERE format = 'skriv/encrypted'; + +.read 1.3.0_schema.sql + +INSERT OR IGNORE INTO web_pages + SELECT id, + CASE WHEN parent = '' THEN NULL ELSE (SELECT id FROM web_pages_old WHERE uri = replace(a.parent, rtrim(a.parent, replace(a.parent, '/', '')), '')) END, + uri, type, status, format, published, modified, title, content + FROM web_pages_old AS a; +DROP TABLE web_pages_old; + +UPDATE acc_charts_old SET country = 'FR' WHERE country IS NULL; +INSERT INTO acc_charts SELECT * FROM acc_charts_old; +DROP TABLE acc_charts_old; + +-- Add recipient_pgp_key column +INSERT INTO emails_queue + SELECT id, sender, recipient, recipient_hash, NULL, subject, content, content_html, sending, sending_started, context + FROM emails_queue_old; + +DROP TABLE emails_queue_old; + +INSERT INTO users_sessions SELECT * FROM membres_sessions; +DROP TABLE membres_sessions; + +INSERT INTO services_users SELECT * FROM services_users_old; + +INSERT INTO services_reminders_sent SELECT * FROM services_reminders_sent_old; + +INSERT INTO acc_transactions + SELECT + id, type, status, label, notes, reference, date, + NULL, NULL, NULL, --hash/prev_id/prev_hash + id_year, id_creator, id_related + FROM acc_transactions_old; + +INSERT INTO acc_transactions_users SELECT * FROM acc_transactions_users_old; + +DROP TABLE services_reminders_sent_old; +DROP TABLE acc_transactions_users_old; +DROP TABLE acc_transactions_old; +DROP TABLE services_users_old; + +-- Remove old plugin as it cannot be uninstalled as it no longer exists +DELETE FROM plugins_old WHERE id = 'ouvertures'; +DELETE FROM plugins_signaux_old WHERE plugin = 'ouvertures'; + +-- Delete old reservations plugin +DELETE FROM plugins_old WHERE id = 'reservations'; + +-- Rename plugins table columns to English +INSERT INTO plugins (name, label, description, author, author_url, version, config, enabled, menu, restrict_level, restrict_section) + SELECT id, nom, description, auteur, url, version, config, 1, menu, + CASE WHEN menu_condition IS NOT NULL THEN 2 ELSE NULL END, + CASE WHEN menu_condition IS NOT NULL THEN 'users' ELSE NULL END + FROM plugins_old; +INSERT INTO plugins_signals SELECT * FROM plugins_signaux_old; + +DROP TABLE plugins_signaux_old; +DROP TABLE plugins_old; + +INSERT INTO searches SELECT * FROM recherches; +UPDATE searches SET target = 'accounting' WHERE target = 'compta'; +UPDATE searches SET target = 'users' WHERE target = 'membres'; + +DROP TABLE recherches; + +INSERT INTO config VALUES ('log_retention', 365); + +-- Rename config keys to english +UPDATE config SET key = 'default_category' WHERE key = 'categorie_membres'; +UPDATE config SET key = 'color1' WHERE key = 'couleur1'; +UPDATE config SET key = 'color2' WHERE key = 'couleur2'; +UPDATE config SET key = 'country' WHERE key = 'pays'; +UPDATE config SET key = 'currency' WHERE key = 'monnaie'; +UPDATE config SET key = 'backup_frequency' WHERE key = 'frequence_sauvegardes'; +UPDATE config SET key = 'backup_limit' WHERE key = 'nombre_sauvegardes'; + +UPDATE config SET key = 'org_name' WHERE key = 'nom_asso'; +UPDATE config SET key = 'org_address' WHERE key = 'adresse_asso'; +UPDATE config SET key = 'org_email' WHERE key = 'email_asso'; +UPDATE config SET key = 'org_phone' WHERE key = 'telephone_asso'; +UPDATE config SET key = 'org_web' WHERE key = 'site_asso'; + +-- This is now part of the config_users_fields table +DELETE FROM config WHERE key IN ('champs_membres', 'champ_identite', 'champ_identifiant'); + +-- Seems that some installations had this leftover? Let's just drop it. +DROP TABLE IF EXISTS srs_old; + +-- Drop membres +DROP TABLE IF EXISTS membres; + diff --git a/src/include/migrations/1.3/1.3.0_bookings.sql b/src/include/migrations/1.3/1.3.0_bookings.sql new file mode 100644 index 0000000..5068b45 --- /dev/null +++ b/src/include/migrations/1.3/1.3.0_bookings.sql @@ -0,0 +1,43 @@ +CREATE TABLE IF NOT EXISTS module_data_bookings ( + id INTEGER NOT NULL PRIMARY KEY, + key TEXT NULL, + document TEXT NOT NULL +); +CREATE UNIQUE INDEX IF NOT EXISTS module_data_bookings_key ON module_data_bookings (key); + +INSERT INTO module_data_bookings SELECT + NULL, uuid(), + json_object( + 'type', 'event', + 'label', nom, + 'description', description, + 'use_openings', json('false'), + 'openings_seats', NULL, + 'openings_slots', NULL, + 'openings_delay', NULL, + 'use_closings', json('false'), + 'fields', json_array(), + 'email', NULL, + 'archived', json('false') + ) + FROM plugin_reservations_categories; + +INSERT INTO module_data_bookings SELECT + NULL, uuid(), + json_object( + 'type', 'booking', + 'event', (SELECT key FROM module_data_bookings + WHERE json_extract(document, '$.label') = (SELECT nom FROM plugin_reservations_categories + WHERE id = (SELECT categorie FROM plugin_reservations_creneaux WHERE id = plugin_reservations_personnes.creneau))), + 'slot', NULL, + 'date', date, + 'name', nom, + 'email', NULL, + 'id_user', NULL, + 'fields', NULL + ) + FROM plugin_reservations_personnes; + +DROP TABLE plugin_reservations_categories; +DROP TABLE plugin_reservations_creneaux; +DROP TABLE plugin_reservations_personnes; diff --git a/src/include/migrations/1.3/1.3.0_schema.sql b/src/include/migrations/1.3/1.3.0_schema.sql new file mode 100644 index 0000000..db7e618 --- /dev/null +++ b/src/include/migrations/1.3/1.3.0_schema.sql @@ -0,0 +1,607 @@ +--- +--- Main stuff +--- + +CREATE TABLE IF NOT EXISTS config ( +-- Configuration, key/value store + key TEXT PRIMARY KEY NOT NULL, + value TEXT NULL +); + +CREATE TABLE IF NOT EXISTS config_users_fields ( + id INTEGER NOT NULL PRIMARY KEY, + name TEXT NOT NULL, + sort_order INTEGER NOT NULL, + type TEXT NOT NULL, + label TEXT NOT NULL, + help TEXT NULL, + required INTEGER NOT NULL DEFAULT 0, + user_access_level INTEGER NOT NULL DEFAULT 0, + management_access_level INTEGER NOT NULL DEFAULT 1, + list_table INTEGER NOT NULL DEFAULT 0, + options TEXT NULL, + default_value TEXT NULL, + sql TEXT NULL, + system TEXT NULL +); + +CREATE UNIQUE INDEX IF NOT EXISTS config_users_fields_name ON config_users_fields (name); + +CREATE TABLE IF NOT EXISTS plugins +( + id INTEGER NOT NULL PRIMARY KEY, + name TEXT NOT NULL, + label TEXT NOT NULL, + description TEXT NULL, + author TEXT NULL, + author_url TEXT NULL, + version TEXT NOT NULL, + menu INT NOT NULL DEFAULT 0, + home_button INT NOT NULL DEFAULT 0, + restrict_section TEXT NULL, + restrict_level INT NULL, + config TEXT NULL, + enabled INTEGER NOT NULL DEFAULT 0 +); + +CREATE UNIQUE INDEX IF NOT EXISTS plugins_name ON plugins (name); + +CREATE TABLE IF NOT EXISTS plugins_signals +-- Link between plugins and signals +( + signal TEXT NOT NULL, + plugin TEXT NOT NULL REFERENCES plugins (name), + callback TEXT NOT NULL, + PRIMARY KEY (signal, plugin) +); + +CREATE TABLE IF NOT EXISTS modules +-- List of modules +( + id INTEGER NOT NULL PRIMARY KEY, + name TEXT NOT NULL, + label TEXT NOT NULL, + description TEXT NULL, + author TEXT NULL, + author_url TEXT NULL, + menu INT NOT NULL DEFAULT 0, + home_button INT NOT NULL DEFAULT 0, + restrict_section TEXT NULL, + restrict_level INT NULL, + config TEXT NULL, + enabled INTEGER NOT NULL DEFAULT 0, + web INTEGER NOT NULL DEFAULT 0, + system INTEGER NOT NULL DEFAULT 0 +); + +CREATE UNIQUE INDEX IF NOT EXISTS modules_name ON modules (name); + +CREATE TABLE IF NOT EXISTS modules_templates +-- List of forms special templates +( + id INTEGER NOT NULL PRIMARY KEY, + id_module INTEGER NOT NULL REFERENCES modules (id) ON DELETE CASCADE, + name TEXT NOT NULL +); + +CREATE UNIQUE INDEX IF NOT EXISTS modules_templates_name ON modules_templates (id_module, name); + +CREATE TABLE IF NOT EXISTS api_credentials +( + id INTEGER NOT NULL PRIMARY KEY, + label TEXT NOT NULL, + key TEXT NOT NULL, + secret TEXT NOT NULL, + created TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + last_use TEXT NULL, + access_level INT NOT NULL +); + +CREATE UNIQUE INDEX IF NOT EXISTS api_credentials_key ON api_credentials (key); + +CREATE TABLE IF NOT EXISTS searches +-- Saved searches +( + id INTEGER NOT NULL PRIMARY KEY, + id_user INTEGER NULL REFERENCES users (id) ON DELETE CASCADE, -- If not NULL, then search will only be visible by this user + label TEXT NOT NULL, + created TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP CHECK (datetime(created) IS NOT NULL AND datetime(created) = created), + target TEXT NOT NULL, -- "users" ou "accounting" + type TEXT NOT NULL, -- "json" ou "sql" + content TEXT NOT NULL +); + + +CREATE TABLE IF NOT EXISTS compromised_passwords_cache +-- Cache des hash de mots de passe compromis +( + hash TEXT NOT NULL PRIMARY KEY +); + +CREATE TABLE IF NOT EXISTS compromised_passwords_cache_ranges +-- Cache des préfixes de mots de passe compromis +( + prefix TEXT NOT NULL PRIMARY KEY, + date INTEGER NOT NULL +); + +CREATE TABLE IF NOT EXISTS emails ( +-- List of emails addresses +-- We are not storing actual email addresses here for privacy reasons +-- So that we can keep the record (for opt-out reasons) even when the +-- email address has been removed from the users table + id INTEGER NOT NULL PRIMARY KEY, + hash TEXT NOT NULL, + verified INTEGER NOT NULL DEFAULT 0, + optout INTEGER NOT NULL DEFAULT 0, + invalid INTEGER NOT NULL DEFAULT 0, + fail_count INTEGER NOT NULL DEFAULT 0, + sent_count INTEGER NOT NULL DEFAULT 0, + fail_log TEXT NULL, + last_sent TEXT NULL, + added TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE UNIQUE INDEX IF NOT EXISTS emails_hash ON emails (hash); + +CREATE TABLE IF NOT EXISTS emails_queue ( +-- List of emails waiting to be sent + id INTEGER NOT NULL PRIMARY KEY, + sender TEXT NULL, + recipient TEXT NOT NULL, + recipient_hash TEXT NOT NULL, + recipient_pgp_key TEXT NULL, + subject TEXT NOT NULL, + content TEXT NOT NULL, + content_html TEXT NULL, + sending INTEGER NOT NULL DEFAULT 0, -- Will be changed to 1 when the queue run will start + sending_started TEXT NULL, -- Will be filled with the datetime when the email sending was started + context INTEGER NOT NULL +); + +CREATE TABLE IF NOT EXISTS emails_queue_attachments ( + id INTEGER NOT NULL PRIMARY KEY, + id_queue INTEGER NOT NULL REFERENCES emails_queue (id) ON DELETE CASCADE, + path TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS mailings ( + id INTEGER NOT NULL PRIMARY KEY, + subject TEXT NOT NULL, + body TEXT NULL, + sender_name TEXT NULL, + sender_email TEXT NULL, + sent TEXT NULL CHECK (datetime(sent) IS NULL OR datetime(sent) = sent), + anonymous INTEGER NOT NULL DEFAULT 0 +); + +CREATE INDEX IF NOT EXISTS mailings_sent ON mailings (sent); + +CREATE TABLE IF NOT EXISTS mailings_recipients ( + id INTEGER NOT NULL PRIMARY KEY, + id_mailing INTEGER NOT NULL REFERENCES mailings (id) ON DELETE CASCADE, + email TEXT NULL, + id_email TEXT NULL REFERENCES emails (id) ON DELETE CASCADE, + extra_data TEXT NULL +); + +CREATE INDEX IF NOT EXISTS mailings_recipients_id ON mailings_recipients (id); + +--- +--- Users +--- + +-- CREATE TABLE users (...); +-- Organization users table, dynamically created, see config_users_fields table + +CREATE TABLE IF NOT EXISTS users_categories +-- Users categories, mainly used to manage rights +( + id INTEGER PRIMARY KEY NOT NULL, + name TEXT NOT NULL, + + -- Permissions, 0 = no access, 1 = read-only, 2 = read-write, 9 = admin + perm_web INTEGER NOT NULL DEFAULT 1, + perm_documents INTEGER NOT NULL DEFAULT 1, + perm_users INTEGER NOT NULL DEFAULT 1, + perm_accounting INTEGER NOT NULL DEFAULT 1, + + perm_subscribe INTEGER NOT NULL DEFAULT 0, + perm_connect INTEGER NOT NULL DEFAULT 1, + perm_config INTEGER NOT NULL DEFAULT 0, + + hidden INTEGER NOT NULL DEFAULT 0 +); + +CREATE INDEX IF NOT EXISTS users_categories_hidden ON users_categories (hidden); +CREATE INDEX IF NOT EXISTS users_categories_name ON users_categories (name); +CREATE INDEX IF NOT EXISTS users_categories_hidden_name ON users_categories (hidden, name); + +CREATE TABLE IF NOT EXISTS users_sessions +-- Permanent sessions for logged-in users +( + selector TEXT NOT NULL PRIMARY KEY, + hash TEXT NOT NULL, + id_user INTEGER NULL REFERENCES users (id) ON DELETE CASCADE, + expiry INT NOT NULL +); + +CREATE TABLE IF NOT EXISTS logs +( + id INTEGER NOT NULL PRIMARY KEY, + id_user INTEGER NULL REFERENCES users (id) ON DELETE CASCADE, + type INTEGER NOT NULL, + details TEXT NULL, + created TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP CHECK (datetime(created) IS NOT NULL AND datetime(created) = created), + ip_address TEXT NULL +); + +CREATE INDEX IF NOT EXISTS logs_ip ON logs (ip_address, type, created); +CREATE INDEX IF NOT EXISTS logs_user ON logs (id_user, type, created); +CREATE INDEX IF NOT EXISTS logs_created ON logs (created); + +--- +--- Services +--- + +CREATE TABLE IF NOT EXISTS services +-- Services types (French: cotisations) +( + id INTEGER PRIMARY KEY NOT NULL, + + label TEXT NOT NULL, + description TEXT NULL, + + duration INTEGER NULL CHECK (duration IS NULL OR duration > 0), -- En jours + start_date TEXT NULL CHECK (start_date IS NULL OR date(start_date) = start_date), + end_date TEXT NULL CHECK (end_date IS NULL OR (date(end_date) = end_date AND date(end_date) >= date(start_date))) +); + +CREATE TABLE IF NOT EXISTS services_fees +-- Services fees +( + id INTEGER PRIMARY KEY NOT NULL, + + label TEXT NOT NULL, + description TEXT NULL, + + amount INTEGER NULL, + formula TEXT NULL, -- Formula to calculate fee amount dynamically (this contains a SQL statement) + + id_service INTEGER NOT NULL REFERENCES services (id) ON DELETE CASCADE, + id_account INTEGER NULL REFERENCES acc_accounts (id) ON DELETE SET NULL CHECK (id_account IS NULL OR id_year IS NOT NULL), -- NULL if fee is not linked to accounting, this is reset using a trigger if the year is deleted + id_year INTEGER NULL REFERENCES acc_years (id) ON DELETE SET NULL, -- NULL if fee is not linked to accounting + id_project INTEGER NULL REFERENCES acc_projects (id) ON DELETE SET NULL +); + +CREATE TABLE IF NOT EXISTS services_users +-- Records of services and fees linked to users +( + id INTEGER NOT NULL PRIMARY KEY, + id_user INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE, + id_service INTEGER NOT NULL REFERENCES services (id) ON DELETE CASCADE, + id_fee INTEGER NULL REFERENCES services_fees (id) ON DELETE CASCADE, -- This can be NULL if there is no fee for the service + + paid INTEGER NOT NULL DEFAULT 0, + expected_amount INTEGER NULL, + + date TEXT NOT NULL DEFAULT CURRENT_DATE CHECK (date(date) IS NOT NULL AND date(date) = date), + expiry_date TEXT NULL CHECK (date(expiry_date) IS NULL OR date(expiry_date) = expiry_date) +); + +CREATE UNIQUE INDEX IF NOT EXISTS su_unique ON services_users (id_user, id_service, id_fee, date); + +CREATE INDEX IF NOT EXISTS su_service ON services_users (id_service); +CREATE INDEX IF NOT EXISTS su_fee ON services_users (id_fee); +CREATE INDEX IF NOT EXISTS su_paid ON services_users (paid); +CREATE INDEX IF NOT EXISTS su_expiry ON services_users (expiry_date); + +CREATE TABLE IF NOT EXISTS services_reminders +-- Reminders for service expiry +( + id INTEGER NOT NULL PRIMARY KEY, + id_service INTEGER NOT NULL REFERENCES services (id) ON DELETE CASCADE, + + delay INTEGER NOT NULL, -- Delay in days before or after expiry date + + subject TEXT NOT NULL, + body TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS services_reminders_sent +-- Records of sent reminders, to keep track +( + id INTEGER NOT NULL PRIMARY KEY, + + id_user INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE, + id_service INTEGER NOT NULL REFERENCES services (id) ON DELETE CASCADE, + id_reminder INTEGER NOT NULL REFERENCES services_reminders (id) ON DELETE CASCADE, + + sent_date TEXT NOT NULL DEFAULT CURRENT_DATE CHECK (date(sent_date) IS NOT NULL AND date(sent_date) = sent_date), + due_date TEXT NOT NULL CHECK (date(due_date) IS NOT NULL AND date(due_date) = due_date) +); + +CREATE UNIQUE INDEX IF NOT EXISTS srs_index ON services_reminders_sent (id_user, id_service, id_reminder, due_date); + +CREATE INDEX IF NOT EXISTS srs_reminder ON services_reminders_sent (id_reminder); +CREATE INDEX IF NOT EXISTS srs_user ON services_reminders_sent (id_user); + +-- +-- Accounting +-- + +CREATE TABLE IF NOT EXISTS acc_charts +-- Accounting charts (plans comptables) +( + id INTEGER NOT NULL PRIMARY KEY, + country TEXT NOT NULL, + code TEXT NULL, -- the code is NULL if the chart is user-created or imported + label TEXT NOT NULL, + archived INTEGER NOT NULL DEFAULT 0 -- 1 = archived, cannot be changed +); + +CREATE TABLE IF NOT EXISTS acc_accounts +-- Accounts of the charts (comptes) +( + id INTEGER NOT NULL PRIMARY KEY, + id_chart INTEGER NOT NULL REFERENCES acc_charts (id) ON DELETE CASCADE, + + code TEXT NOT NULL, -- can contain numbers and letters, eg. 53A, 53B... + + label TEXT NOT NULL, + description TEXT NULL, + + position INTEGER NOT NULL, -- position in the balance sheet (position actif/passif/charge/produit) + type INTEGER NOT NULL DEFAULT 0, -- type (category) of favourite account: bank, cash, third party, etc. + user INTEGER NOT NULL DEFAULT 1, -- 0 = is part of the original chart, 0 = has been added by the user + bookmark INTEGER NOT NULL DEFAULT 0 -- 1 = is marked as favorite +); + +CREATE UNIQUE INDEX IF NOT EXISTS acc_accounts_codes ON acc_accounts (code, id_chart); +CREATE INDEX IF NOT EXISTS acc_accounts_type ON acc_accounts (type); +CREATE INDEX IF NOT EXISTS acc_accounts_position ON acc_accounts (position); +CREATE INDEX IF NOT EXISTS acc_accounts_bookmarks ON acc_accounts (id_chart, bookmark, code); + +-- Balance des comptes par exercice +CREATE VIEW IF NOT EXISTS acc_accounts_balances +AS + SELECT id_year, id, label, code, type, debit, credit, bookmark, + CASE -- 3 = dynamic asset or liability depending on balance + WHEN position = 3 AND (debit - credit) > 0 THEN 1 -- 1 = Asset (actif) comptes fournisseurs, tiers créditeurs + WHEN position = 3 THEN 2 -- 2 = Liability (passif), comptes clients, tiers débiteurs + ELSE position + END AS position, + CASE + WHEN position IN (1, 4) -- 1 = asset, 4 = expense + OR (position = 3 AND (debit - credit) > 0) + THEN + debit - credit + ELSE + credit - debit + END AS balance, + CASE WHEN debit - credit > 0 THEN 1 ELSE 0 END AS is_debt + FROM ( + SELECT t.id_year, a.id, a.label, a.code, a.position, a.type, a.bookmark, + SUM(l.credit) AS credit, + SUM(l.debit) AS debit + FROM acc_accounts a + INNER JOIN acc_transactions_lines l ON l.id_account = a.id + INNER JOIN acc_transactions t ON t.id = l.id_transaction + GROUP BY t.id_year, a.id + ); +CREATE TABLE IF NOT EXISTS acc_projects +-- Analytical projects +( + id INTEGER NOT NULL PRIMARY KEY, + + code TEXT NULL, + + label TEXT NOT NULL, + description TEXT NULL, + + archived INTEGER NOT NULL DEFAULT 0 +); + +CREATE UNIQUE INDEX IF NOT EXISTS acc_projects_code ON acc_projects (code); +CREATE INDEX IF NOT EXISTS acc_projects_list ON acc_projects (archived, code); + +CREATE TABLE IF NOT EXISTS acc_years +-- Years (exercices) +( + id INTEGER NOT NULL PRIMARY KEY, + + label TEXT NOT NULL, + + start_date TEXT NOT NULL CHECK (date(start_date) IS NOT NULL AND date(start_date) = start_date), + end_date TEXT NOT NULL CHECK (date(end_date) IS NOT NULL AND date(end_date) = end_date), + + closed INTEGER NOT NULL DEFAULT 0, -- 0 = open, 1 = closed + + id_chart INTEGER NOT NULL REFERENCES acc_charts (id) +); + +CREATE INDEX IF NOT EXISTS acc_years_closed ON acc_years (closed); + +-- Make sure id_account is reset when a year is deleted +CREATE TRIGGER IF NOT EXISTS acc_years_delete BEFORE DELETE ON acc_years BEGIN + UPDATE services_fees SET id_account = NULL, id_year = NULL WHERE id_year = OLD.id; +END; + +CREATE TABLE IF NOT EXISTS acc_transactions +-- Transactions (écritures comptables) +( + id INTEGER PRIMARY KEY NOT NULL, + + type INTEGER NOT NULL DEFAULT 0, -- Transaction type, zero is advanced + status INTEGER NOT NULL DEFAULT 0, -- Status (bitmask) + + label TEXT NOT NULL, + notes TEXT NULL, + reference TEXT NULL, -- N° de pièce comptable + + date TEXT NOT NULL DEFAULT CURRENT_DATE CHECK (date(date) IS NOT NULL AND date(date) = date), + + hash TEXT NULL, + prev_id INTEGER NULL REFERENCES acc_transactions(id) ON DELETE SET NULL, + prev_hash TEXT NULL, + + id_year INTEGER NOT NULL REFERENCES acc_years(id), + id_creator INTEGER NULL REFERENCES users(id) ON DELETE SET NULL, + id_related INTEGER NULL REFERENCES acc_transactions(id) ON DELETE SET NULL -- linked transaction (eg. payment of a debt) +); + +CREATE INDEX IF NOT EXISTS acc_transactions_year ON acc_transactions (id_year); +CREATE INDEX IF NOT EXISTS acc_transactions_date ON acc_transactions (date); +CREATE INDEX IF NOT EXISTS acc_transactions_related ON acc_transactions (id_related); +CREATE INDEX IF NOT EXISTS acc_transactions_type ON acc_transactions (type, id_year); +CREATE INDEX IF NOT EXISTS acc_transactions_status ON acc_transactions (status); +CREATE INDEX IF NOT EXISTS acc_transactions_hash ON acc_transactions (hash); +CREATE INDEX IF NOT EXISTS acc_transactions_reference ON acc_transactions (reference); + +CREATE TABLE IF NOT EXISTS acc_transactions_lines +-- Transactions lines (lignes des écritures) +( + id INTEGER PRIMARY KEY NOT NULL, + + id_transaction INTEGER NOT NULL REFERENCES acc_transactions (id) ON DELETE CASCADE, + id_account INTEGER NOT NULL REFERENCES acc_accounts (id), + + credit INTEGER NOT NULL, + debit INTEGER NOT NULL, + + reference TEXT NULL, -- Usually a payment reference (par exemple numéro de chèque) + label TEXT NULL, + + reconciled INTEGER NOT NULL DEFAULT 0, + + id_project INTEGER NULL REFERENCES acc_projects(id) ON DELETE SET NULL, + + CONSTRAINT line_check1 CHECK ((credit * debit) = 0), + CONSTRAINT line_check2 CHECK ((credit + debit) > 0) +); + +CREATE INDEX IF NOT EXISTS acc_transactions_lines_transaction ON acc_transactions_lines (id_transaction); +CREATE INDEX IF NOT EXISTS acc_transactions_lines_account ON acc_transactions_lines (id_account); +CREATE INDEX IF NOT EXISTS acc_transactions_lines_project ON acc_transactions_lines (id_project); +CREATE INDEX IF NOT EXISTS acc_transactions_lines_reconciled ON acc_transactions_lines (reconciled); + +CREATE TABLE IF NOT EXISTS acc_transactions_users +-- Linking transactions and users +( + id_user INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE, + id_transaction INTEGER NOT NULL REFERENCES acc_transactions (id) ON DELETE CASCADE, + id_service_user INTEGER NULL REFERENCES services_users (id) ON DELETE SET NULL, + + PRIMARY KEY (id_user, id_transaction, id_service_user) +); + +CREATE INDEX IF NOT EXISTS acc_transactions_users_service ON acc_transactions_users (id_service_user); + +---------- FILES ---------------- + +CREATE TABLE IF NOT EXISTS files +-- Files metadata +( + id INTEGER NOT NULL PRIMARY KEY, + path TEXT NOT NULL, + parent TEXT NULL REFERENCES files(path) ON DELETE CASCADE ON UPDATE CASCADE, + name TEXT NOT NULL, -- File name + type INTEGER NOT NULL, -- File type, 1 = file, 2 = directory + mime TEXT NULL, + size INT NULL, + modified TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP CHECK (datetime(modified) IS NOT NULL AND datetime(modified) = modified), + image INT NOT NULL DEFAULT 0, + md5 TEXT NULL, + trash TEXT NULL CHECK (datetime(trash) IS NULL OR datetime(trash) = trash), + + CHECK (type = 2 OR (mime IS NOT NULL AND size IS NOT NULL)) +); + +-- Unique index as this is used to make up a file path +CREATE UNIQUE INDEX IF NOT EXISTS files_unique ON files (path); +CREATE INDEX IF NOT EXISTS files_parent ON files (parent); +CREATE INDEX IF NOT EXISTS files_type_parent ON files (type, parent, path); +CREATE INDEX IF NOT EXISTS files_name ON files (name); +CREATE INDEX IF NOT EXISTS files_modified ON files (modified); +CREATE INDEX IF NOT EXISTS files_trash ON files (trash); +CREATE INDEX IF NOT EXISTS files_size ON files (size); + +CREATE TABLE IF NOT EXISTS files_contents +-- Files contents (empty if using another storage backend) +( + id INTEGER NOT NULL PRIMARY KEY REFERENCES files(id) ON DELETE CASCADE, + content BLOB NOT NULL +); + +CREATE VIRTUAL TABLE IF NOT EXISTS files_search USING fts4 +-- Search inside files content +( + tokenize=unicode61, -- Available from SQLITE 3.7.13 (2012) + path TEXT NOT NULL, + title TEXT NOT NULL, + content TEXT NULL, -- Text content + notindexed=path +); + +-- Delete/insert search item when item is deleted/inserted from files +CREATE TRIGGER IF NOT EXISTS files_search_bd BEFORE DELETE ON files BEGIN + DELETE FROM files_search WHERE docid = OLD.rowid; +END; + +CREATE TRIGGER IF NOT EXISTS files_search_ai AFTER INSERT ON files BEGIN + INSERT INTO files_search (docid, path, title, content) VALUES (NEW.rowid, NEW.path, NEW.name, NULL); +END; + +CREATE TRIGGER IF NOT EXISTS files_search_au AFTER UPDATE OF name, path ON files BEGIN + UPDATE files_search SET path = NEW.path, title = NEW.name WHERE docid = NEW.rowid; +END; + +CREATE TABLE IF NOT EXISTS acc_transactions_files +-- Link between transactions and files +( + id_file INTEGER NOT NULL PRIMARY KEY REFERENCES files(id) ON DELETE CASCADE, + id_transaction INTEGER NOT NULL REFERENCES acc_transactions(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS acc_transactions_files_transaction ON acc_transactions_files (id_transaction); + +CREATE TABLE IF NOT EXISTS users_files +-- Link between users and files +( + id_file INTEGER NOT NULL PRIMARY KEY REFERENCES files(id) ON DELETE CASCADE, + id_user INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + field TEXT NOT NULL REFERENCES config_users_fields (name) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS users_files_user ON users_files (id_user); +CREATE INDEX IF NOT EXISTS users_files_user_field ON users_files (id_user, field); + +CREATE TABLE IF NOT EXISTS web_pages +( + id INTEGER NOT NULL PRIMARY KEY, + id_parent INTEGER NULL REFERENCES web_pages(id) ON DELETE CASCADE, + uri TEXT NOT NULL, -- Page identifier + type INTEGER NOT NULL, -- 1 = Category, 2 = Page + status TEXT NOT NULL, + format TEXT NOT NULL, + published TEXT NOT NULL CHECK (datetime(published) IS NOT NULL AND datetime(published) = published), + modified TEXT NOT NULL CHECK (datetime(modified) IS NOT NULL AND datetime(modified) = modified), + title TEXT NOT NULL, + content TEXT NOT NULL +); + +CREATE UNIQUE INDEX IF NOT EXISTS web_pages_uri ON web_pages (uri); +CREATE INDEX IF NOT EXISTS web_pages_id_parent ON web_pages (id_parent); +CREATE INDEX IF NOT EXISTS web_pages_published ON web_pages (published); +CREATE INDEX IF NOT EXISTS web_pages_title ON web_pages (title); + +CREATE TABLE IF NOT EXISTS web_pages_versions +( + id INTEGER NOT NULL PRIMARY KEY, + id_page INTEGER NOT NULL REFERENCES web_pages ON DELETE CASCADE, + id_user INTEGER NULL REFERENCES users (id) ON DELETE SET NULL, + date TEXT NOT NULL CHECK (datetime(date) IS NOT NULL AND datetime(date) = date), + size INTEGER NOT NULL, + changes INTEGER NOT NULL, + content TEXT NOT NULL +); diff --git a/src/include/migrations/1.3/1.3.2.sql b/src/include/migrations/1.3/1.3.2.sql new file mode 100644 index 0000000..180fff0 --- /dev/null +++ b/src/include/migrations/1.3/1.3.2.sql @@ -0,0 +1 @@ +UPDATE config_users_fields SET default_value = 'NOW()' WHERE default_value = '''=NOW''' OR default_value = '=NOW'; diff --git a/src/include/migrations/1.3/1.3.3.sql b/src/include/migrations/1.3/1.3.3.sql new file mode 100644 index 0000000..f34e4f0 --- /dev/null +++ b/src/include/migrations/1.3/1.3.3.sql @@ -0,0 +1,57 @@ +CREATE TABLE IF NOT EXISTS acc_transactions_links +( + id_transaction INTEGER NOT NULL REFERENCES acc_transactions(id) ON DELETE CASCADE, + id_related INTEGER NOT NULL REFERENCES acc_transactions(id) ON DELETE CASCADE CHECK (id_transaction != id_related), + PRIMARY KEY (id_transaction, id_related) +); + +CREATE INDEX IF NOT EXISTS acc_transactions_lines_id_transaction ON acc_transactions_links (id_transaction); +CREATE INDEX IF NOT EXISTS acc_transactions_lines_id_related ON acc_transactions_links (id_related); + +-- Move id_related to separate many-to-many table +INSERT INTO acc_transactions_links SELECT id, id_related FROM acc_transactions WHERE id_related IS NOT NULL; + +DROP INDEX acc_transactions_related; +DROP INDEX acc_transactions_year; +DROP INDEX acc_transactions_date; +DROP INDEX acc_transactions_type; +DROP INDEX acc_transactions_status; +DROP INDEX acc_transactions_hash; +DROP INDEX acc_transactions_reference; + +ALTER TABLE acc_transactions RENAME TO acc_transactions_old; + +CREATE TABLE IF NOT EXISTS acc_transactions +-- Transactions (écritures comptables) +( + id INTEGER PRIMARY KEY NOT NULL, + + type INTEGER NOT NULL DEFAULT 0, -- Transaction type, zero is advanced + status INTEGER NOT NULL DEFAULT 0, -- Status (bitmask) + + label TEXT NOT NULL, + notes TEXT NULL, + reference TEXT NULL, -- N° de pièce comptable + + date TEXT NOT NULL DEFAULT CURRENT_DATE CHECK (date(date) IS NOT NULL AND date(date) = date), + + hash TEXT NULL, + prev_id INTEGER NULL REFERENCES acc_transactions(id) ON DELETE SET NULL, + prev_hash TEXT NULL, + + id_year INTEGER NOT NULL REFERENCES acc_years(id), + id_creator INTEGER NULL REFERENCES users(id) ON DELETE SET NULL +); + +CREATE INDEX IF NOT EXISTS acc_transactions_year ON acc_transactions (id_year); +CREATE INDEX IF NOT EXISTS acc_transactions_date ON acc_transactions (date); +CREATE INDEX IF NOT EXISTS acc_transactions_type ON acc_transactions (type, id_year); +CREATE INDEX IF NOT EXISTS acc_transactions_status ON acc_transactions (status); +CREATE INDEX IF NOT EXISTS acc_transactions_hash ON acc_transactions (hash); +CREATE INDEX IF NOT EXISTS acc_transactions_reference ON acc_transactions (reference); + +INSERT INTO acc_transactions + SELECT id, type, status, label, notes, reference, date, hash, prev_id, prev_hash, id_year, id_creator + FROM acc_transactions_old; + +DROP TABLE acc_transactions_old; diff --git a/src/include/migrations/1.3/1.3.5.sql b/src/include/migrations/1.3/1.3.5.sql new file mode 100644 index 0000000..c5c29f3 --- /dev/null +++ b/src/include/migrations/1.3/1.3.5.sql @@ -0,0 +1 @@ +CREATE VIEW IF NOT EXISTS users_view AS SELECT * FROM users; diff --git a/src/include/migrations/1.3/1.3.6.php b/src/include/migrations/1.3/1.3.6.php new file mode 100644 index 0000000..9d4258e --- /dev/null +++ b/src/include/migrations/1.3/1.3.6.php @@ -0,0 +1,12 @@ +beginSchemaUpdate(); +$db->import(ROOT . '/include/migrations/1.3/1.3.6.sql'); +$db->commitSchemaUpdate(); + diff --git a/src/include/migrations/1.3/1.3.6.sql b/src/include/migrations/1.3/1.3.6.sql new file mode 100644 index 0000000..eacf55b --- /dev/null +++ b/src/include/migrations/1.3/1.3.6.sql @@ -0,0 +1,111 @@ +-- Delete old unmaintained plugin +DELETE FROM plugins_signals WHERE plugin = 'git_documents'; +DELETE FROM plugins WHERE name = 'git_documents'; + +-- Fix access level of number field +UPDATE config_users_fields SET user_access_level = 1 WHERE user_access_level = 2 AND name = 'numero'; + +-- Update services to add archived column +ALTER TABLE services RENAME TO services_old; + +CREATE TABLE IF NOT EXISTS services +-- Services types (French: cotisations) +( + id INTEGER PRIMARY KEY NOT NULL, + + label TEXT NOT NULL, + description TEXT NULL, + + duration INTEGER NULL CHECK (duration IS NULL OR duration > 0), -- En jours + start_date TEXT NULL CHECK (start_date IS NULL OR date(start_date) = start_date), + end_date TEXT NULL CHECK (end_date IS NULL OR (date(end_date) = end_date AND date(end_date) >= date(start_date))), + + archived INTEGER NOT NULL DEFAULT 0 +); + +INSERT INTO services + SELECT *, CASE WHEN end_date IS NOT NULL AND end_date < datetime() THEN 1 ELSE 0 END FROM services_old; + +DROP TABLE services_old; + +-- Update services_reminders to add not_before_date +ALTER TABLE services_reminders RENAME TO services_reminders_old; + +CREATE TABLE IF NOT EXISTS services_reminders +-- Reminders for service expiry +( + id INTEGER NOT NULL PRIMARY KEY, + id_service INTEGER NOT NULL REFERENCES services (id) ON DELETE CASCADE, + + delay INTEGER NOT NULL, -- Delay in days before or after expiry date + + subject TEXT NOT NULL, + body TEXT NOT NULL, + + not_before_date TEXT NULL CHECK (date(not_before_date) IS NULL OR date(not_before_date) = not_before_date) -- Don't send reminder to users if they expire before this date +); + +INSERT INTO services_reminders SELECT *, NULL FROM services_reminders_old; +DROP TABLE services_reminders_old; + +ALTER TABLE services_reminders_sent RENAME TO services_reminders_sent_old; +DROP INDEX IF EXISTS srs_index; +DROP INDEX IF EXISTS srs_reminder; +DROP INDEX IF EXISTS srs_user; + +-- Allow NULL for id_reminder +CREATE TABLE IF NOT EXISTS services_reminders_sent +-- Records of sent reminders, to keep track +( + id INTEGER NOT NULL PRIMARY KEY, + + id_user INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE, + id_service INTEGER NOT NULL REFERENCES services (id) ON DELETE CASCADE, + id_reminder INTEGER NULL REFERENCES services_reminders (id) ON DELETE SET NULL, + + sent_date TEXT NOT NULL DEFAULT CURRENT_DATE CHECK (date(sent_date) IS NOT NULL AND date(sent_date) = sent_date), + due_date TEXT NOT NULL CHECK (date(due_date) IS NOT NULL AND date(due_date) = due_date) +); + +INSERT INTO services_reminders_sent SELECT * FROM services_reminders_sent_old; +DROP TABLE services_reminders_sent_old; + +CREATE UNIQUE INDEX IF NOT EXISTS srs_index ON services_reminders_sent (id_user, id_service, id_reminder, due_date); + +CREATE INDEX IF NOT EXISTS srs_reminder ON services_reminders_sent (id_reminder); +CREATE INDEX IF NOT EXISTS srs_user ON services_reminders_sent (id_user); + +-- Update mailings +ALTER TABLE mailings RENAME TO mailings_old; + +DROP INDEX IF EXISTS mailings_sent; + +CREATE TABLE IF NOT EXISTS mailings ( + id INTEGER NOT NULL PRIMARY KEY, + subject TEXT NOT NULL, + body TEXT NULL, + target_type TEXT NULL, + target_value TEXT NULL, + target_label TEXT NULL, + sender_name TEXT NULL, + sender_email TEXT NULL, + sent TEXT NULL CHECK (datetime(sent) IS NULL OR datetime(sent) = sent), + anonymous INTEGER NOT NULL DEFAULT 0 +); + +CREATE INDEX IF NOT EXISTS mailings_sent ON mailings (sent); + +INSERT INTO mailings (id, subject, body, sender_name, sender_email, sent, anonymous) + SELECT id, subject, body, sender_name, sender_email, sent, anonymous + FROM mailings_old; + +DROP TABLE mailings_old; + +CREATE TABLE IF NOT EXISTS mailings_optouts ( + email_hash TEXT NOT NULL, + target_type TEXT NOT NULL, + target_value TEXT NOT NULL, + target_label TEXT NOT NULL +); + +CREATE UNIQUE INDEX IF NOT EXISTS mailings_optouts_unique ON mailings_optouts (email_hash, target_type, target_value); diff --git a/src/include/migrations/1.3/schema.sql b/src/include/migrations/1.3/schema.sql new file mode 100644 index 0000000..5586c75 --- /dev/null +++ b/src/include/migrations/1.3/schema.sql @@ -0,0 +1,630 @@ +--- +--- Main stuff +--- + +CREATE TABLE IF NOT EXISTS config ( +-- Configuration, key/value store + key TEXT PRIMARY KEY NOT NULL, + value TEXT NULL +); + +CREATE TABLE IF NOT EXISTS config_users_fields ( + id INTEGER NOT NULL PRIMARY KEY, + name TEXT NOT NULL, + sort_order INTEGER NOT NULL, + type TEXT NOT NULL, + label TEXT NOT NULL, + help TEXT NULL, + required INTEGER NOT NULL DEFAULT 0, + user_access_level INTEGER NOT NULL DEFAULT 0, + management_access_level INTEGER NOT NULL DEFAULT 1, + list_table INTEGER NOT NULL DEFAULT 0, + options TEXT NULL, + default_value TEXT NULL, + sql TEXT NULL, + system TEXT NULL +); + +CREATE UNIQUE INDEX IF NOT EXISTS config_users_fields_name ON config_users_fields (name); + +CREATE TABLE IF NOT EXISTS plugins +( + id INTEGER NOT NULL PRIMARY KEY, + name TEXT NOT NULL, + label TEXT NOT NULL, + description TEXT NULL, + author TEXT NULL, + author_url TEXT NULL, + version TEXT NOT NULL, + menu INT NOT NULL DEFAULT 0, + home_button INT NOT NULL DEFAULT 0, + restrict_section TEXT NULL, + restrict_level INT NULL, + config TEXT NULL, + enabled INTEGER NOT NULL DEFAULT 0 +); + +CREATE UNIQUE INDEX IF NOT EXISTS plugins_name ON plugins (name); + +CREATE TABLE IF NOT EXISTS plugins_signals +-- Link between plugins and signals +( + signal TEXT NOT NULL, + plugin TEXT NOT NULL REFERENCES plugins (name), + callback TEXT NOT NULL, + PRIMARY KEY (signal, plugin) +); + +CREATE TABLE IF NOT EXISTS modules +-- List of modules +( + id INTEGER NOT NULL PRIMARY KEY, + name TEXT NOT NULL, + label TEXT NOT NULL, + description TEXT NULL, + author TEXT NULL, + author_url TEXT NULL, + menu INT NOT NULL DEFAULT 0, + home_button INT NOT NULL DEFAULT 0, + restrict_section TEXT NULL, + restrict_level INT NULL, + config TEXT NULL, + enabled INTEGER NOT NULL DEFAULT 0, + web INTEGER NOT NULL DEFAULT 0, + system INTEGER NOT NULL DEFAULT 0 +); + +CREATE UNIQUE INDEX IF NOT EXISTS modules_name ON modules (name); + +CREATE TABLE IF NOT EXISTS modules_templates +-- List of forms special templates +( + id INTEGER NOT NULL PRIMARY KEY, + id_module INTEGER NOT NULL REFERENCES modules (id) ON DELETE CASCADE, + name TEXT NOT NULL +); + +CREATE UNIQUE INDEX IF NOT EXISTS modules_templates_name ON modules_templates (id_module, name); + +CREATE TABLE IF NOT EXISTS api_credentials +( + id INTEGER NOT NULL PRIMARY KEY, + label TEXT NOT NULL, + key TEXT NOT NULL, + secret TEXT NOT NULL, + created TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + last_use TEXT NULL, + access_level INT NOT NULL +); + +CREATE UNIQUE INDEX IF NOT EXISTS api_credentials_key ON api_credentials (key); + +CREATE TABLE IF NOT EXISTS searches +-- Saved searches +( + id INTEGER NOT NULL PRIMARY KEY, + id_user INTEGER NULL REFERENCES users (id) ON DELETE CASCADE, -- If not NULL, then search will only be visible by this user + label TEXT NOT NULL, + created TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP CHECK (datetime(created) IS NOT NULL AND datetime(created) = created), + target TEXT NOT NULL, -- "users" ou "accounting" + type TEXT NOT NULL, -- "json" ou "sql" + content TEXT NOT NULL +); + + +CREATE TABLE IF NOT EXISTS compromised_passwords_cache +-- Cache des hash de mots de passe compromis +( + hash TEXT NOT NULL PRIMARY KEY +); + +CREATE TABLE IF NOT EXISTS compromised_passwords_cache_ranges +-- Cache des préfixes de mots de passe compromis +( + prefix TEXT NOT NULL PRIMARY KEY, + date INTEGER NOT NULL +); + +CREATE TABLE IF NOT EXISTS emails ( +-- List of emails addresses +-- We are not storing actual email addresses here for privacy reasons +-- So that we can keep the record (for opt-out reasons) even when the +-- email address has been removed from the users table + id INTEGER NOT NULL PRIMARY KEY, + hash TEXT NOT NULL, + verified INTEGER NOT NULL DEFAULT 0, + optout INTEGER NOT NULL DEFAULT 0, + invalid INTEGER NOT NULL DEFAULT 0, + fail_count INTEGER NOT NULL DEFAULT 0, + sent_count INTEGER NOT NULL DEFAULT 0, + fail_log TEXT NULL, + last_sent TEXT NULL, + added TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE UNIQUE INDEX IF NOT EXISTS emails_hash ON emails (hash); + +CREATE TABLE IF NOT EXISTS emails_queue ( +-- List of emails waiting to be sent + id INTEGER NOT NULL PRIMARY KEY, + sender TEXT NULL, + recipient TEXT NOT NULL, + recipient_hash TEXT NOT NULL, + recipient_pgp_key TEXT NULL, + subject TEXT NOT NULL, + content TEXT NOT NULL, + content_html TEXT NULL, + sending INTEGER NOT NULL DEFAULT 0, -- Will be changed to 1 when the queue run will start + sending_started TEXT NULL, -- Will be filled with the datetime when the email sending was started + context INTEGER NOT NULL +); + +CREATE TABLE IF NOT EXISTS emails_queue_attachments ( + id INTEGER NOT NULL PRIMARY KEY, + id_queue INTEGER NOT NULL REFERENCES emails_queue (id) ON DELETE CASCADE, + path TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS mailings ( + id INTEGER NOT NULL PRIMARY KEY, + subject TEXT NOT NULL, + body TEXT NULL, + target_type TEXT NULL, + target_value TEXT NULL, + target_label TEXT NULL, + sender_name TEXT NULL, + sender_email TEXT NULL, + sent TEXT NULL CHECK (datetime(sent) IS NULL OR datetime(sent) = sent), + anonymous INTEGER NOT NULL DEFAULT 0 +); + +CREATE INDEX IF NOT EXISTS mailings_sent ON mailings (sent); + +CREATE TABLE IF NOT EXISTS mailings_recipients ( + id INTEGER NOT NULL PRIMARY KEY, + id_mailing INTEGER NOT NULL REFERENCES mailings (id) ON DELETE CASCADE, + email TEXT NULL, + id_email TEXT NULL REFERENCES emails (id) ON DELETE CASCADE, + extra_data TEXT NULL +); + +CREATE INDEX IF NOT EXISTS mailings_recipients_id ON mailings_recipients (id); + +CREATE TABLE IF NOT EXISTS mailings_optouts ( + email_hash TEXT NOT NULL, + target_type TEXT NOT NULL, + target_value TEXT NOT NULL, + target_label TEXT NOT NULL +); + +CREATE UNIQUE INDEX IF NOT EXISTS mailings_optouts_unique ON mailings_optouts (email_hash, target_type, target_value); + +--- +--- Users +--- + +-- CREATE TABLE users (...); +-- Organization users table, dynamically created, see config_users_fields table + +CREATE TABLE IF NOT EXISTS users_categories +-- Users categories, mainly used to manage rights +( + id INTEGER PRIMARY KEY NOT NULL, + name TEXT NOT NULL, + + -- Permissions, 0 = no access, 1 = read-only, 2 = read-write, 9 = admin + perm_web INTEGER NOT NULL DEFAULT 1, + perm_documents INTEGER NOT NULL DEFAULT 1, + perm_users INTEGER NOT NULL DEFAULT 1, + perm_accounting INTEGER NOT NULL DEFAULT 1, + + perm_subscribe INTEGER NOT NULL DEFAULT 0, + perm_connect INTEGER NOT NULL DEFAULT 1, + perm_config INTEGER NOT NULL DEFAULT 0, + + hidden INTEGER NOT NULL DEFAULT 0 +); + +CREATE INDEX IF NOT EXISTS users_categories_hidden ON users_categories (hidden); +CREATE INDEX IF NOT EXISTS users_categories_name ON users_categories (name); +CREATE INDEX IF NOT EXISTS users_categories_hidden_name ON users_categories (hidden, name); + +CREATE TABLE IF NOT EXISTS users_sessions +-- Permanent sessions for logged-in users +( + selector TEXT NOT NULL PRIMARY KEY, + hash TEXT NOT NULL, + id_user INTEGER NULL REFERENCES users (id) ON DELETE CASCADE, + expiry INT NOT NULL +); + +CREATE TABLE IF NOT EXISTS logs +( + id INTEGER NOT NULL PRIMARY KEY, + id_user INTEGER NULL REFERENCES users (id) ON DELETE CASCADE, + type INTEGER NOT NULL, + details TEXT NULL, + created TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP CHECK (datetime(created) IS NOT NULL AND datetime(created) = created), + ip_address TEXT NULL +); + +CREATE INDEX IF NOT EXISTS logs_ip ON logs (ip_address, type, created); +CREATE INDEX IF NOT EXISTS logs_user ON logs (id_user, type, created); +CREATE INDEX IF NOT EXISTS logs_created ON logs (created); + +--- +--- Services +--- + +CREATE TABLE IF NOT EXISTS services +-- Services types (French: cotisations) +( + id INTEGER PRIMARY KEY NOT NULL, + + label TEXT NOT NULL, + description TEXT NULL, + + duration INTEGER NULL CHECK (duration IS NULL OR duration > 0), -- En jours + start_date TEXT NULL CHECK (start_date IS NULL OR date(start_date) = start_date), + end_date TEXT NULL CHECK (end_date IS NULL OR (date(end_date) = end_date AND date(end_date) >= date(start_date))) +); + +CREATE TABLE IF NOT EXISTS services_fees +-- Services fees +( + id INTEGER PRIMARY KEY NOT NULL, + + label TEXT NOT NULL, + description TEXT NULL, + + amount INTEGER NULL, + formula TEXT NULL, -- Formula to calculate fee amount dynamically (this contains a SQL statement) + + id_service INTEGER NOT NULL REFERENCES services (id) ON DELETE CASCADE, + id_account INTEGER NULL REFERENCES acc_accounts (id) ON DELETE SET NULL CHECK (id_account IS NULL OR id_year IS NOT NULL), -- NULL if fee is not linked to accounting, this is reset using a trigger if the year is deleted + id_year INTEGER NULL REFERENCES acc_years (id) ON DELETE SET NULL, -- NULL if fee is not linked to accounting + id_project INTEGER NULL REFERENCES acc_projects (id) ON DELETE SET NULL +); + +CREATE TABLE IF NOT EXISTS services_users +-- Records of services and fees linked to users +( + id INTEGER NOT NULL PRIMARY KEY, + id_user INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE, + id_service INTEGER NOT NULL REFERENCES services (id) ON DELETE CASCADE, + id_fee INTEGER NULL REFERENCES services_fees (id) ON DELETE CASCADE, -- This can be NULL if there is no fee for the service + + paid INTEGER NOT NULL DEFAULT 0, + expected_amount INTEGER NULL, + + date TEXT NOT NULL DEFAULT CURRENT_DATE CHECK (date(date) IS NOT NULL AND date(date) = date), + expiry_date TEXT NULL CHECK (date(expiry_date) IS NULL OR date(expiry_date) = expiry_date) +); + +CREATE UNIQUE INDEX IF NOT EXISTS su_unique ON services_users (id_user, id_service, id_fee, date); + +CREATE INDEX IF NOT EXISTS su_service ON services_users (id_service); +CREATE INDEX IF NOT EXISTS su_fee ON services_users (id_fee); +CREATE INDEX IF NOT EXISTS su_paid ON services_users (paid); +CREATE INDEX IF NOT EXISTS su_expiry ON services_users (expiry_date); + +CREATE TABLE IF NOT EXISTS services_reminders +-- Reminders for service expiry +( + id INTEGER NOT NULL PRIMARY KEY, + id_service INTEGER NOT NULL REFERENCES services (id) ON DELETE CASCADE, + + delay INTEGER NOT NULL, -- Delay in days before or after expiry date + + subject TEXT NOT NULL, + body TEXT NOT NULL, + + not_before_date TEXT NULL CHECK (date(not_before_date) IS NULL OR date(not_before_date) = not_before_date) -- Don't send reminder to users if they expire before this date +); + +CREATE TABLE IF NOT EXISTS services_reminders_sent +-- Records of sent reminders, to keep track +( + id INTEGER NOT NULL PRIMARY KEY, + + id_user INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE, + id_service INTEGER NOT NULL REFERENCES services (id) ON DELETE CASCADE, + id_reminder INTEGER NULL REFERENCES services_reminders (id) ON DELETE SET NULL, + + sent_date TEXT NOT NULL DEFAULT CURRENT_DATE CHECK (date(sent_date) IS NOT NULL AND date(sent_date) = sent_date), + due_date TEXT NOT NULL CHECK (date(due_date) IS NOT NULL AND date(due_date) = due_date) +); + +CREATE UNIQUE INDEX IF NOT EXISTS srs_index ON services_reminders_sent (id_user, id_service, id_reminder, due_date); + +CREATE INDEX IF NOT EXISTS srs_reminder ON services_reminders_sent (id_reminder); +CREATE INDEX IF NOT EXISTS srs_user ON services_reminders_sent (id_user); + +-- +-- Accounting +-- + +CREATE TABLE IF NOT EXISTS acc_charts +-- Accounting charts (plans comptables) +( + id INTEGER NOT NULL PRIMARY KEY, + country TEXT NOT NULL, + code TEXT NULL, -- the code is NULL if the chart is user-created or imported + label TEXT NOT NULL, + archived INTEGER NOT NULL DEFAULT 0 -- 1 = archived, cannot be changed +); + +CREATE TABLE IF NOT EXISTS acc_accounts +-- Accounts of the charts (comptes) +( + id INTEGER NOT NULL PRIMARY KEY, + id_chart INTEGER NOT NULL REFERENCES acc_charts (id) ON DELETE CASCADE, + + code TEXT NOT NULL, -- can contain numbers and letters, eg. 53A, 53B... + + label TEXT NOT NULL, + description TEXT NULL, + + position INTEGER NOT NULL, -- position in the balance sheet (position actif/passif/charge/produit) + type INTEGER NOT NULL DEFAULT 0, -- type (category) of favourite account: bank, cash, third party, etc. + user INTEGER NOT NULL DEFAULT 1, -- 0 = is part of the original chart, 0 = has been added by the user + bookmark INTEGER NOT NULL DEFAULT 0 -- 1 = is marked as favorite +); + +CREATE UNIQUE INDEX IF NOT EXISTS acc_accounts_codes ON acc_accounts (code, id_chart); +CREATE INDEX IF NOT EXISTS acc_accounts_type ON acc_accounts (type); +CREATE INDEX IF NOT EXISTS acc_accounts_position ON acc_accounts (position); +CREATE INDEX IF NOT EXISTS acc_accounts_bookmarks ON acc_accounts (id_chart, bookmark, code); + +-- Balance des comptes par exercice +CREATE VIEW IF NOT EXISTS acc_accounts_balances +AS + SELECT id_year, id, label, code, type, debit, credit, bookmark, + CASE -- 3 = dynamic asset or liability depending on balance + WHEN position = 3 AND (debit - credit) > 0 THEN 1 -- 1 = Asset (actif) comptes fournisseurs, tiers créditeurs + WHEN position = 3 THEN 2 -- 2 = Liability (passif), comptes clients, tiers débiteurs + ELSE position + END AS position, + CASE + WHEN position IN (1, 4) -- 1 = asset, 4 = expense + OR (position = 3 AND (debit - credit) > 0) + THEN + debit - credit + ELSE + credit - debit + END AS balance, + CASE WHEN debit - credit > 0 THEN 1 ELSE 0 END AS is_debt + FROM ( + SELECT t.id_year, a.id, a.label, a.code, a.position, a.type, a.bookmark, + SUM(l.credit) AS credit, + SUM(l.debit) AS debit + FROM acc_accounts a + INNER JOIN acc_transactions_lines l ON l.id_account = a.id + INNER JOIN acc_transactions t ON t.id = l.id_transaction + GROUP BY t.id_year, a.id + ); + +CREATE TABLE IF NOT EXISTS acc_projects +-- Analytical projects +( + id INTEGER NOT NULL PRIMARY KEY, + + code TEXT NULL, + + label TEXT NOT NULL, + description TEXT NULL, + + archived INTEGER NOT NULL DEFAULT 0 +); + +CREATE UNIQUE INDEX IF NOT EXISTS acc_projects_code ON acc_projects (code); +CREATE INDEX IF NOT EXISTS acc_projects_list ON acc_projects (archived, code); + +CREATE TABLE IF NOT EXISTS acc_years +-- Years (exercices) +( + id INTEGER NOT NULL PRIMARY KEY, + + label TEXT NOT NULL, + + start_date TEXT NOT NULL CHECK (date(start_date) IS NOT NULL AND date(start_date) = start_date), + end_date TEXT NOT NULL CHECK (date(end_date) IS NOT NULL AND date(end_date) = end_date), + + closed INTEGER NOT NULL DEFAULT 0, -- 0 = open, 1 = closed + + id_chart INTEGER NOT NULL REFERENCES acc_charts (id) +); + +CREATE INDEX IF NOT EXISTS acc_years_closed ON acc_years (closed); + +-- Make sure id_account is reset when a year is deleted +CREATE TRIGGER IF NOT EXISTS acc_years_delete BEFORE DELETE ON acc_years BEGIN + UPDATE services_fees SET id_account = NULL, id_year = NULL WHERE id_year = OLD.id; +END; + +CREATE TABLE IF NOT EXISTS acc_transactions +-- Transactions (écritures comptables) +( + id INTEGER PRIMARY KEY NOT NULL, + + type INTEGER NOT NULL DEFAULT 0, -- Transaction type, zero is advanced + status INTEGER NOT NULL DEFAULT 0, -- Status (bitmask) + + label TEXT NOT NULL, + notes TEXT NULL, + reference TEXT NULL, -- N° de pièce comptable + + date TEXT NOT NULL DEFAULT CURRENT_DATE CHECK (date(date) IS NOT NULL AND date(date) = date), + + hash TEXT NULL, + prev_id INTEGER NULL REFERENCES acc_transactions(id) ON DELETE SET NULL, + prev_hash TEXT NULL, + + id_year INTEGER NOT NULL REFERENCES acc_years(id), + id_creator INTEGER NULL REFERENCES users(id) ON DELETE SET NULL +); + +CREATE INDEX IF NOT EXISTS acc_transactions_year ON acc_transactions (id_year); +CREATE INDEX IF NOT EXISTS acc_transactions_date ON acc_transactions (date); +CREATE INDEX IF NOT EXISTS acc_transactions_type ON acc_transactions (type, id_year); +CREATE INDEX IF NOT EXISTS acc_transactions_status ON acc_transactions (status); +CREATE INDEX IF NOT EXISTS acc_transactions_hash ON acc_transactions (hash); +CREATE INDEX IF NOT EXISTS acc_transactions_reference ON acc_transactions (reference); + +CREATE TABLE IF NOT EXISTS acc_transactions_lines +-- Transactions lines (lignes des écritures) +( + id INTEGER PRIMARY KEY NOT NULL, + + id_transaction INTEGER NOT NULL REFERENCES acc_transactions (id) ON DELETE CASCADE, + id_account INTEGER NOT NULL REFERENCES acc_accounts (id), + + credit INTEGER NOT NULL, + debit INTEGER NOT NULL, + + reference TEXT NULL, -- Usually a payment reference (par exemple numéro de chèque) + label TEXT NULL, + + reconciled INTEGER NOT NULL DEFAULT 0, + + id_project INTEGER NULL REFERENCES acc_projects(id) ON DELETE SET NULL, + + CONSTRAINT line_check1 CHECK ((credit * debit) = 0), + CONSTRAINT line_check2 CHECK ((credit + debit) > 0) +); + +CREATE INDEX IF NOT EXISTS acc_transactions_lines_transaction ON acc_transactions_lines (id_transaction); +CREATE INDEX IF NOT EXISTS acc_transactions_lines_account ON acc_transactions_lines (id_account); +CREATE INDEX IF NOT EXISTS acc_transactions_lines_project ON acc_transactions_lines (id_project); +CREATE INDEX IF NOT EXISTS acc_transactions_lines_reconciled ON acc_transactions_lines (reconciled); + +CREATE TABLE IF NOT EXISTS acc_transactions_links +( + id_transaction INTEGER NOT NULL REFERENCES acc_transactions(id) ON DELETE CASCADE, + id_related INTEGER NOT NULL REFERENCES acc_transactions(id) ON DELETE CASCADE CHECK (id_transaction != id_related), + PRIMARY KEY (id_transaction, id_related) +); + +CREATE INDEX IF NOT EXISTS acc_transactions_lines_id_transaction ON acc_transactions_links (id_transaction); +CREATE INDEX IF NOT EXISTS acc_transactions_lines_id_related ON acc_transactions_links (id_related); + +CREATE TABLE IF NOT EXISTS acc_transactions_users +-- Linking transactions and users +( + id_user INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE, + id_transaction INTEGER NOT NULL REFERENCES acc_transactions (id) ON DELETE CASCADE, + id_service_user INTEGER NULL REFERENCES services_users (id) ON DELETE SET NULL, + + PRIMARY KEY (id_user, id_transaction, id_service_user) +); + +CREATE INDEX IF NOT EXISTS acc_transactions_users_service ON acc_transactions_users (id_service_user); + +---------- FILES ---------------- + +CREATE TABLE IF NOT EXISTS files +-- Files metadata +( + id INTEGER NOT NULL PRIMARY KEY, + path TEXT NOT NULL, + parent TEXT NULL REFERENCES files(path) ON DELETE CASCADE ON UPDATE CASCADE, + name TEXT NOT NULL, -- File name + type INTEGER NOT NULL, -- File type, 1 = file, 2 = directory + mime TEXT NULL, + size INT NULL, + modified TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP CHECK (datetime(modified) IS NOT NULL AND datetime(modified) = modified), + image INT NOT NULL DEFAULT 0, + md5 TEXT NULL, + trash TEXT NULL CHECK (datetime(trash) IS NULL OR datetime(trash) = trash), + + CHECK (type = 2 OR (mime IS NOT NULL AND size IS NOT NULL)) +); + +-- Unique index as this is used to make up a file path +CREATE UNIQUE INDEX IF NOT EXISTS files_unique ON files (path); +CREATE INDEX IF NOT EXISTS files_parent ON files (parent); +CREATE INDEX IF NOT EXISTS files_type_parent ON files (type, parent, path); +CREATE INDEX IF NOT EXISTS files_name ON files (name); +CREATE INDEX IF NOT EXISTS files_modified ON files (modified); +CREATE INDEX IF NOT EXISTS files_trash ON files (trash); +CREATE INDEX IF NOT EXISTS files_size ON files (size); + +CREATE TABLE IF NOT EXISTS files_contents +-- Files contents (empty if using another storage backend) +( + id INTEGER NOT NULL PRIMARY KEY REFERENCES files(id) ON DELETE CASCADE, + content BLOB NOT NULL +); + +CREATE VIRTUAL TABLE IF NOT EXISTS files_search USING fts4 +-- Search inside files content +( + tokenize=unicode61, -- Available from SQLITE 3.7.13 (2012) + path TEXT NOT NULL, + title TEXT NOT NULL, + content TEXT NULL, -- Text content + notindexed=path +); + +-- Delete/insert search item when item is deleted/inserted from files +CREATE TRIGGER IF NOT EXISTS files_search_bd BEFORE DELETE ON files BEGIN + DELETE FROM files_search WHERE docid = OLD.rowid; +END; + +CREATE TRIGGER IF NOT EXISTS files_search_ai AFTER INSERT ON files BEGIN + INSERT INTO files_search (docid, path, title, content) VALUES (NEW.rowid, NEW.path, NEW.name, NULL); +END; + +CREATE TRIGGER IF NOT EXISTS files_search_au AFTER UPDATE OF name, path ON files BEGIN + UPDATE files_search SET path = NEW.path, title = NEW.name WHERE docid = NEW.rowid; +END; + +CREATE TABLE IF NOT EXISTS acc_transactions_files +-- Link between transactions and files +( + id_file INTEGER NOT NULL PRIMARY KEY REFERENCES files(id) ON DELETE CASCADE, + id_transaction INTEGER NOT NULL REFERENCES acc_transactions(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS acc_transactions_files_transaction ON acc_transactions_files (id_transaction); + +CREATE TABLE IF NOT EXISTS users_files +-- Link between users and files +( + id_file INTEGER NOT NULL PRIMARY KEY REFERENCES files(id) ON DELETE CASCADE, + id_user INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + field TEXT NOT NULL REFERENCES config_users_fields (name) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS users_files_user ON users_files (id_user); +CREATE INDEX IF NOT EXISTS users_files_user_field ON users_files (id_user, field); + +CREATE TABLE IF NOT EXISTS web_pages +( + id INTEGER NOT NULL PRIMARY KEY, + id_parent INTEGER NULL REFERENCES web_pages(id) ON DELETE CASCADE, + uri TEXT NOT NULL, -- Page identifier + type INTEGER NOT NULL, -- 1 = Category, 2 = Page + status TEXT NOT NULL, + format TEXT NOT NULL, + published TEXT NOT NULL CHECK (datetime(published) IS NOT NULL AND datetime(published) = published), + modified TEXT NOT NULL CHECK (datetime(modified) IS NOT NULL AND datetime(modified) = modified), + title TEXT NOT NULL, + content TEXT NOT NULL +); + +CREATE UNIQUE INDEX IF NOT EXISTS web_pages_uri ON web_pages (uri); +CREATE INDEX IF NOT EXISTS web_pages_id_parent ON web_pages (id_parent); +CREATE INDEX IF NOT EXISTS web_pages_published ON web_pages (published); +CREATE INDEX IF NOT EXISTS web_pages_title ON web_pages (title); + +CREATE TABLE IF NOT EXISTS web_pages_versions +( + id INTEGER NOT NULL PRIMARY KEY, + id_page INTEGER NOT NULL REFERENCES web_pages ON DELETE CASCADE, + id_user INTEGER NULL REFERENCES users (id) ON DELETE SET NULL, + date TEXT NOT NULL CHECK (datetime(date) IS NOT NULL AND datetime(date) = date), + size INTEGER NOT NULL, + changes INTEGER NOT NULL, + content TEXT NOT NULL +); diff --git a/src/include/test_required.php b/src/include/test_required.php new file mode 100644 index 0000000..9c4f724 --- /dev/null +++ b/src/include/test_required.php @@ -0,0 +1,96 @@ +\n\n\nErreur\n\n"; + echo ''; + echo "\n\n\n

      Erreur

      \n

      Le problème suivant empêche Paheko de fonctionner :

      \n"; + echo '

      ' . htmlspecialchars($message, ENT_QUOTES, 'UTF-8') . '

      '; + echo '

      Pour plus d\'informations consulter '; + echo 'l\'aide sur les problèmes à l\'installation.

      '; + echo "\n\n"; + } + else + { + echo "[ERREUR] Le problème suivant empêche Paheko de fonctionner :\n"; + echo $message . "\n"; + echo "Pour plus d'informations consulter http://fossil.kd2.org/paheko/wiki?name=Probl%C3%A8mes%20fr%C3%A9quents\n"; + } + + exit; +} + +test_required( + version_compare(phpversion(), '7.4', '>='), + 'PHP 7.4 ou supérieur requis. PHP version ' . phpversion() . ' installée.' +); + +test_required( + defined('CRYPT_BLOWFISH') && CRYPT_BLOWFISH, + 'L\'algorithme de hashage de mot de passe Blowfish n\'est pas présent (pas installé ou pas compilé).' +); + +test_required( + class_exists('\IntlDateFormatter') && function_exists('\idn_to_ascii'), + 'L\'extension "intl" n\'est pas installée mais est nécessaire (apt install php-intl).' +); + +test_required( + function_exists('\mb_strlen'), + 'L\'extension "mbstring" n\'est pas installée mais est nécessaire (apt install php-mbstring).' +); + +test_required( + class_exists('SQLite3'), + 'Le module de base de données SQLite3 n\'est pas disponible.' +); + +$v = \SQLite3::version(); + +test_required( + //$db->requireFeatures('cte', 'json_patch', 'fts4', 'date_functions_in_constraints', 'index_expressions', 'rename_column', 'upsert'); + // 3.25.0 = RENAME COLUMN + UPSERT + version_compare($v['versionString'], '3.25', '>='), + 'SQLite3 version 3.25 ou supérieur requise. Version installée : ' . $v['versionString'] +); + +test_required( + file_exists(__DIR__ . '/lib/KD2'), + 'Librairie KD2 non disponible.' +); + +$db = new \SQLite3(':memory:'); +$r = $db->query('PRAGMA compile_options;'); +$options = []; +while ($row = $r->fetchArray(\SQLITE3_NUM)) { + $options[] = $row[0]; +} + +test_required( + in_array('ENABLE_FTS4', $options) || in_array('ENABLE_FTS3', $options), + 'Le module SQLite3 FTS4 (permettant de faire des recherches) n\'est pas installé ou activé.' +); + +test_required( + in_array('ENABLE_JSON1', $options) + || (version_compare($v['versionString'], '3.38', '>=') && !in_array('OMIT_JSON', $options)), + 'Le module SQLite3 JSON1 (utilisé dans les formulaires) n\'est pas installé.' +); + +test_required( + class_exists('Phar'), + 'Le module "Phar" n\'est pas disponible, il faut l\'installer.' +); diff --git a/src/index.php b/src/index.php new file mode 100644 index 0000000..6a2df71 --- /dev/null +++ b/src/index.php @@ -0,0 +1,6 @@ + +
      + Merci de confirmer la réservation + +
      +
      +
      Vous allez réserver pour :
      +

      {{$event.label}}

      +

      {{$date|date_long}} à {{$date|date_hour}}

      +

      {{:linkbutton shape="left" label="Annuler" href="?event=%s"|args:$event.key}} +

      +
      +
      + +
      + Mes informations +
      +
      + {{:input type="text" name="name" label="Prénom et nom" required=true default=$logged_user._name onfocus="this.select()"}} + {{if !$event.email}} + {{:input type="checkbox" name="notify" value="1" label="Recevoir une confirmation d'inscription par e-mail" onchange="g.toggle('dl.email', this.checked);" help="facultatif"}} + {{/if}} +
      + {{if !$event.email && !$_POST.email}} + {{:assign email_hidden=true}} + {{#restrict section="users" level=$module.config.access}} + {{:assign email_disabled=false}} + {{else}} + {{if $logged_user._email}} + {{:assign email_disabled=true}} + {{/if}} + {{/restrict}} + {{/if}} +
      + {{:input type="email" name="email" label="Adresse e-mail" required=true help="Une confirmation d'inscription vous sera envoyée à cette adresse" default=$logged_user._email onfocus="this.select()" onkeyup="g.toggle('.mycaptcha', this.value != '');" disabled=$email_disabled}} +
      +
      + {{#foreach from=$event.fields key="i" item="field"}} + {{if $field.type == 'checkbox'}} + {{:assign value="Coché"}} + {{else}} + {{:assign value=null}} + {{/if}} + {{:input name="fields[%d]"|args:$i label=$field.label type=$field.type help=$field.help required=$field.required value=$value}} + + {{/foreach}} +
      +
      +
      + + {{if !$logged_user}} +

      Vérification contre les robots

      {{:captcha html=true}}
      + {{/if}} +

      + {{:button type="submit" name="book" label="Confirmer la réservation" shape="right" class="main"}} +

      + \ No newline at end of file diff --git a/src/modules/bookings/_create_slots_list.tpl b/src/modules/bookings/_create_slots_list.tpl new file mode 100644 index 0000000..3bc7122 --- /dev/null +++ b/src/modules/bookings/_create_slots_list.tpl @@ -0,0 +1,121 @@ +{{if $event === null}} + {{:error admin="Missing mandatory variable"}} +{{/if}} + + +{{**** Construction de la liste des créneaux ****}} +{{:assign repeat="%d-1"|math:$event.max_weeks}} + +{{if $event.use_closings || $event.use_openings}} + {{#module name="openings"}} + {{* Prendre en compte les périodes de fermeture depuis le module "ouvertures" *}} + {{if $event.use_closings}} + {{#foreach from=$config.closed item="slot"}} + {{:assign + start="%s %s, 00:00:00"|args:$slot.close_day:$slot.close_month|strtotime + end="%s %s, 23:59:59"|args:$slot.reopen_day:$slot.reopen_month|strtotime + }} + {{if $end < $start}} + {{:assign + end="%s %s, 23:59:59, +1 year"|args:$slot.reopen_day:$slot.reopen_month|strtotime + }} + {{/if}} + {{:assign var="closed." start=$start end=$end}} + {{/foreach}} + {{/if}} + + {{* Créer les créneaux à partir du module "ouvertures" *}} + {{if $event.use_openings && $event.openings_slots > 0}} + {{:assign now="now"|strtotime}} + {{#foreach from=$config.open item="slot"}} + {{if $slot.frequency != "this"}} + {{:assign + start="%s %s of this month, %s:00"|args:$slot.frequency:$slot.day:$slot.open|strtotime + end="%s %s of this month, %s:00"|args:$slot.frequency:$slot.day:$slot.close|strtotime + }} + {{* Try next month *}} + {{if $end < $now}} + {{:assign + start="%s %s of next month, %s:00"|args:$slot.frequency:$slot.day:$slot.open|strtotime + end="%s %s of next month, %s:00"|args:$slot.frequency:$slot.day:$slot.close|strtotime + }} + {{/if}} + {{else}} + {{:assign + start="%s %s, %s:00"|args:$slot.frequency:$slot.day:$slot.open|strtotime + end="%s %s, %s:00"|args:$slot.frequency:$slot.day:$slot.close|strtotime + }} + {{* Try next week *}} + {{if $end < $now}} + {{:assign + start="next %s, %s:00"|args:$slot.day:$slot.open|strtotime + end="next %s, %s:00"|args:$slot.day:$slot.close|strtotime + }} + {{/if}} + {{/if}} + + {{#foreach count=$event.openings_slots key="i"}} + {{if $start >= $end}} + {{:break}} + {{/if}} + {{:assign var="slots.%d"|args:$start frequency=$slot.frequency day=$slot.day open=$start|date:'H:i' seats=$event.openings_seats}} + {{:assign start="%d+(%d*60)"|math:$start:$event.openings_delay}} + {{/foreach}} + + {{if $slot.frequency === 'this' && $repeat > 0}} + {{#foreach count=$repeat}} + {{:assign date=$start|date:'Y-m-d'}} + {{:assign start="%s, next %s, %s"|args:$date:$slot.day:$slot.open|strtotime}} + {{#foreach count=$event.openings_slots key="i"}} + {{:assign var="slots.%d"|args:$start frequency=$slot.frequency day=$slot.day open=$start|date:'H:i' seats=$event.openings_seats}} + {{:assign start="%d+(%d*60)"|math:$start:$event.openings_delay}} + {{/foreach}} + {{/foreach}} + {{/if}} + {{/foreach}} + {{/if}} + {{/module}} +{{/if}} + +{{if !$event.use_openings}} + {{#load + type="slot" + event=$event.key + where="$$.seats > 0 AND ($$.date IS NULL OR ($$.date > date() OR ($$.date = date() AND $$.open <= strftime('%H:%M'))))" + order="$$.frequency = 'only' DESC, $$.frequency = 'this' DESC, $$.date, $$.day = 'monday' DESC, $$.day = 'tuesday' DESC, $$.day = 'wednesday' DESC, $$.day = 'thursday' DESC, $$.day = 'friday' DESC, $$.open ASC"}} + {{if $frequency == 'this'}} + {{:assign timestamp="%s %s, %s"|args:$frequency:$day:$open|strtotime}} + {{elseif !$date}} + {{:assign timestamp="%s %s of this month, %s"|args:$frequency:$day:$open|strtotime}} + {{else}} + {{:assign timestamp="%s %s"|args:$date:$open|strtotime}} + {{/if}} + + {{* Make sure slot is in the future, can't book for past dates *}} + {{if $frequency != 'only' && $timestamp < $now|date:'U'}} + {{if $frequency == 'this'}} + {{:assign timestamp="next %s, %s"|args:$day:$open|strtotime}} + {{else}} + {{:assign timestamp="%s %s of next month, %s"|args:$frequency:$day:$open|strtotime}} + {{/if}} + {{/if}} + + {{* This shouldn't happen, but make sure it doesn't *}} + {{if $timestamp < $now|date:'U'}} + {{:continue}} + {{/if}} + + {{:assign .="slots.%d"|args:$timestamp}} + + {{if $frequency == 'this' && $repeat > 0}} + {{#foreach count=$repeat}} + {{:assign date=$timestamp|date:'Y-m-d'}} + {{:assign timestamp="%s, next %s, %s"|args:$date:$day:$open|strtotime}} + {{:assign ..="slots.%d"|args:$timestamp}} + {{/foreach}} + {{/if}} + {{/load}} +{{/if}} + +{{* Make sure we sort slots by datetime *}} +{{:assign slots=$slots|ksort}} diff --git a/src/modules/bookings/_form.html b/src/modules/bookings/_form.html new file mode 100644 index 0000000..67d692d --- /dev/null +++ b/src/modules/bookings/_form.html @@ -0,0 +1,188 @@ +{{if "random_int(1, 10)"|math == 10}} + {{* Prune old bookings from time to time *}} + {{:delete type="booking" where="date($$.date) < date('now', '-1 month')"}} +{{/if}} + +{{if !$module.config.access}} + {{:assign var="module.config.access" value="write"}} +{{/if}} + + +{{if $_GET.event}} + {{#load type="event" key=$_GET.event assign="event" archived=false}} + {{:assign event_selected=true}} + {{else}} + {{:error message="L'événement indiqué est introuvable"}} + {{/load}} +{{else}} + {{#load type="event" archived=false group="$$.type HAVING COUNT(*) = 1" assign="event"}} + {{/load}} +{{/if}} + +{{#form on="cancel"}} + {{:delete key=$_POST.cancel type="booking"}} + {{:redirect to="./?deleted=%s"|args:$_POST.cancel}} +{{/form}} + +{{:form_errors}} + +{{if $_GET.deleted}} +

      + La réservation a bien été annulée. +

      +{{/if}} + +{{if !$_GET.slot_time}} +
      + {{* New booking *}} + {{if $_GET.b}} + {{if $_GET.booked}} +

      + Votre réservation a bien été enregistrée. +

      + {{/if}} + + {{#load type="booking" key=$_GET.b}} + {{#load type="event" key=$event assign="e"}}{{/load}} + {{:assign var="booking" label=$e.label name=$name event=$e.key key=$key date_str=$date|date_long date=$date|strtotime time_str=$date|date_hour}} +
      +
      Vous avez réservé :
      +

      {{$e.label}}

      +

      {{$date|date_long}} à {{$date|date_hour}}

      +
      (Au nom de : {{$name}})
      +
      + {{:button type="submit" name="cancel" value=$key label="Annuler cette réservation" shape="delete" class="main"}} +
      +
      + {{else}} +

      La réservation indiquée est introuvable, elle a peut-être déjà été annulée.

      + {{/load}} + {{/if}} + {{* My bookings *}} + {{if $logged_user.id}} + {{#load type="booking" id_user=$logged_user.id where="key != :booking" :booking=$_GET.b|or:''}} + {{#load type="event" key=$event assign="e"}}{{/load}} +
      +
      Vous avez réservé :
      +

      {{$e.label}}

      +

      {{$date|date_long}} à {{$date|date_hour}}

      +
      (Au nom de : {{$name}})
      +
      + {{:button type="submit" name="cancel" value=$key label="Annuler cette réservation" shape="delete" }} +
      +
      + {{/load}} + {{/if}} + +
      + + {{if !$logged_user}} + + {{/if}} +{{/if}} + +{{if !$event}} + +
      +

      Réservations

      +

      Choisir un événement :

      + {{#load type="event" order="$$.label" archived=false}} + + {{else}} +

      Il n'y a aucun événement réservable.

      + {{/load}} +
      + +{{else}} + {{#form on="book"}} + {{if !$_GET.slot_time || !$_GET.slot_time|parse_datetime}} + {{:error message="Date invalide"}} + {{/if}} + + {{if $_GET.slot_key}} + {{* Vérification que le créneau sélectionné existe bien *}} + {{#load key=$_GET.slot_key type="slot" assign="slot"}} + {{else}} + {{:error message="Créneau introuvable"}} + {{/load}} + + {{* Vérification qu'il y a encore des places dispo *}} + {{#load event=$event.key where="datetime($$.date) = datetime(:date)" slot=$slot.key :date=$_GET.slot_time type="booking" count=true}} + {{if $count >= $slot.seats}} + {{:error message="Ce créneau est déjà plein, aucune place n'est disponible, désolé."}} + {{/if}} + {{/load}} + {{/if}} + + {{if !$_POST.name|trim}} + {{:error message="Le nom doit être renseigné."}} + {{/if}} + + {{if $event.email && !$_POST.email|trim}} + {{:error message="L'adresse e-mail doit être renseignée."}} + {{/if}} + + {{if $_POST.email|trim && !$_POST.email|trim|check_email}} + {{:error message="Adresse e-mail invalide."}} + {{/if}} + + {{if $_POST.email|trim && !$logged_user}} + {{:captcha verify=true}} + {{/if}} + + {{:assign fields=null}} + + {{#foreach from=$event.fields key="i" item="field"}} + {{:assign var="value" from="_POST.fields.%d"|args:$i}} + {{:assign var="fields." label=$field.label value=$value|trim|or:null}} + {{if $field.required && !$value|trim}} + {{:error message="Le champ '%s' doit être renseigné."|args:$field.label}} + {{/if}} + {{/foreach}} + + {{:assign key=""|uuid}} + {{:save type="booking" + key=$key + validate_schema="./booking.schema.json" + slot=$slot.key + event=$event.key + date=$_GET.slot_time + name=$_POST.name|trim + email=$_POST.email|trim|or:null + id_user=$logged_user.id + fields=$fields + }} + + {{if $_POST.email|trim}} + {{:assign my_date=$_GET.slot_time|parse_datetime|date_long:true}} + {{:assign url="%s?b=%s"|args:$module.url:$key}} + {{:mail to=$_POST.email|trim subject="Réservation du %s"|args:$my_date body="Vous avez réservé pour : %s\n\nLe : %s\n\nPour annuler votre réservation, cliquez ici :\n%s"|args:$event.label:$my_date:$url}} + {{/if}} + + {{:redirect to="./?b=%s&booked=1"|args:$key}} + {{/form}} + + {{if $_GET.slot_key !== null && $_GET.slot_time}} + + {{:include file="./_confirm_slot_form.html"}} + + {{else}} + + {{:include file="./_create_slots_list.tpl" event=$event keep="slots,closed"}} + {{:include file="./_slots_list.html" event=$event slots=$slots event_selected=$event_selected closed=$closed}} + + {{/if}} + +{{/if}} \ No newline at end of file diff --git a/src/modules/bookings/_nav.html b/src/modules/bookings/_nav.html new file mode 100644 index 0000000..3cf3310 --- /dev/null +++ b/src/modules/bookings/_nav.html @@ -0,0 +1,45 @@ +{{if !$module.config.access}} + {{:assign var="module.config.access" value="write"}} +{{/if}} + +{{#restrict section="config" level="admin"}} + {{:assign var="access_config" value=true}} +{{/restrict}} +{{#restrict section="users" level=$module.config.access}} + {{:assign var="access_bookings" value=true}} +{{/restrict}} + +{{if $access_config || $access_bookings}} + +{{/if}} diff --git a/src/modules/bookings/_slots_list.html b/src/modules/bookings/_slots_list.html new file mode 100644 index 0000000..673eef3 --- /dev/null +++ b/src/modules/bookings/_slots_list.html @@ -0,0 +1,104 @@ +{{if $slots === null || $event === null}} + {{:error admin="Missing mandatory variable"}} +{{/if}} + +{{:assign last_date=null}} +{{:assign slots_available=false}} + +
      + +
      + {{if $event_selected}} +

      {{:linkbutton shape="left" label="Liste des événements" href="./"}}

      + {{/if}} + +

      {{$event.label}}

      + + {{if $event.description}} + {{$event.description|raw|markdown}} + {{/if}} +
      + +
      +

      Créneaux disponibles

      + +{{#foreach from=$slots item="slot" key="timestamp"}} + {{* take into account the closing dates *}} + {{if $event.use_closings}} + {{#foreach from=$closed item="closing"}} + {{if $closing.start <= $timestamp && $closing.end >= $timestamp}} + {{if $slot.frequency == 'only'}} + {{* Don't cancel specific dates if they are in a closed time, it's probably an event during a closed time *}} + {{:continue}} + {{elseif $slot.frequency == 'this'}} + {{:assign end_date="%d+86400"|math:$closing.end|date:'Y-m-d'}} + {{:assign timestamp="%s, %s %s, %s"|args:$end_date:$slot.frequency:$slot.day:$slot.open|strtotime}} + {{else}} + {{:assign end_date="%d+86400"|math:$closing.end|date:'Y-m'}} + {{:assign timestamp="%s, %s %s, %s"|args:$end_date:$slot.frequency:$slot.day:$slot.open|strtotime}} + {{/if}} + {{/if}} + {{/foreach}} + {{/if}} + + {{:assign this_date=$timestamp|date:"Y-m-d"}} + {{:assign this_datetime=$timestamp|date:"Y-m-d H:i"}} + + {{:assign count=0}} + {{if $slot.key}} + {{#load count=true slot=$slot.key date=$this_datetime}} + {{:assign count=$count}} + {{/load}} + {{else}} + {{#load count=true where="$$.slot IS NULL" date=$this_datetime}} + {{:assign count=$count}} + {{/load}} + {{/if}} + + {{:assign available="max(0, %d-%d)"|math:$slot.seats:$count}} + + {{if $available > 0 && $slots_available === false}} + {{:assign slots_available=true}} + {{/if}} + + {{if $this_date != $last_date}} + {{if $last_date}} +
    + + {{/if}} + {{:assign last_date=$this_date}} +
    +

    {{$timestamp|date_long}}

    +
      + {{/if}} + + {{if !$available}} + {{:assign disabled=true}} + {{else}} + {{:assign disabled=false}} + {{/if}} + + {{if $available == 1}} + {{:assign label="1 place disponible"}} + {{else}} + {{:assign label="%d places disponibles"|args:$available}} + {{/if}} + +
    • + + {{$timestamp|date_hour}} + {{$label}} + +
    • +{{/foreach}} + +{{if $last_date}} +
    +
    +{{/if}} + +{{if !$slots_available}} +

    Aucun créneau n'est disponible.

    +{{/if}} + +
  • \ No newline at end of file diff --git a/src/modules/bookings/booking.schema.json b/src/modules/bookings/booking.schema.json new file mode 100644 index 0000000..f943412 --- /dev/null +++ b/src/modules/bookings/booking.schema.json @@ -0,0 +1,44 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["booking"] + }, + "slot": { + "type": ["null", "string"] + }, + "event": { + "type": "string" + }, + "date": { + "type": "string", + "format": "datetime" + }, + "name": { + "type": "string" + }, + "email": { + "type": ["null", "string"] + }, + "id_user": { + "type": ["null", "integer"] + }, + "fields": { + "type": ["null", "array"], + "items": { + "type": "object", + "properties": { + "label": { + "type": "string" + }, + "value": { + "type": ["null", "string"] + } + } + } + } + }, + "required": ["type", "event", "slot", "date", "name", "email", "id_user", "fields"] +} \ No newline at end of file diff --git a/src/modules/bookings/bookings.html b/src/modules/bookings/bookings.html new file mode 100644 index 0000000..26a3081 --- /dev/null +++ b/src/modules/bookings/bookings.html @@ -0,0 +1,84 @@ +{{if !$module.config.access}} + {{:assign var="module.config.access" value="write"}} +{{/if}} + +{{#restrict section="users" level=$module.config.access block=true}}{{/restrict}} + +{{if $_GET.event}} + {{#load type="event" key=$_GET.event assign="event"}} + {{else}} + {{:error message="L'événement indiqué est introuvable"}} + {{/load}} +{{else}} + {{#load type="event" group="$$.type HAVING COUNT(*) = 1" assign="event"}} + {{/load}} +{{/if}} + +{{if $event}} + {{:assign title="Inscriptions — %s"|args:$event.label}} +{{else}} + {{:assign title="Inscriptions"}} +{{/if}} + +{{:admin_header title=$title custom_css="./style.css" current="module_bookings"}} + +{{:include file="./_nav.html" current="bookings"}} + +{{if !$event}} + +
    + {{#load type="event" order="$$.label"}} +
    +

    {{$label}}

    +

    Voir les inscrits pour cet événement

    +
    + {{else}} +

    Il n'y a aucun événement.

    + {{/load}} +
    + +{{else}} + + {{if $_GET.cancel}} + {{:delete key=$_GET.cancel type="booking" event=$event.key}} + {{:redirect to="?event=%s"|args:$event.key}} + {{/if}} + + +
    + + {{:assign current_day=null}} +

    + {{:exportmenu right=true}} +

    + {{#list select="date($$.date) AS 'Date'; $$.date AS 'Heure'; $$.name AS 'Nom'; json_object('email', json_quote($$.email), 'fields', json($$.fields)) AS 'Autres informations'" type="booking" event=$event.key where="date($$.date) >= date()" order="2"}} + + + {{if $col1 !== $current_day}} + {{$date|date_long}} + {{:assign current_day=$col1}} + {{/if}} + + {{$date|date_hour}} + {{$name}} + + {{if $email}}{{$email}}
    {{/if}} + {{#foreach from=$fields item="field"}} + {{$field.label}} : {{$field.value}}
    + {{/foreach}} + + + {{:linkbutton shape="delete" label="Annuler" href="?event=%s&cancel=%s"|args:$event.key:$key onclick="return confirm('Annuler ?');"}} + + + {{else}} +

    Aucune réservation.

    + {{/list}} + +

    Note : les réservations passées (hier et avant) ne sont pas affichées et sont automatiquement supprimées.

    + +
    +{{/if}} + + +{{:admin_footer}} \ No newline at end of file diff --git a/src/modules/bookings/config.html b/src/modules/bookings/config.html new file mode 100644 index 0000000..86c800e --- /dev/null +++ b/src/modules/bookings/config.html @@ -0,0 +1,38 @@ +{{#restrict section="config" level="admin" block=true}}{{/restrict}} +{{:admin_header title="Configuration" current="module_bookings"}} + +{{:include file="./_nav.html" current="config"}} + +{{if $_GET.ok}} +

    Configuration enregistrée.

    +{{/if}} + +{{#list select="$$.label AS \'Événément\'" where="$$.type = 'event'" order="1"}} + + {{$label}} + + {{if !$use_openings}} + {{:linkbutton label="Configurer les créneaux" href="config_event_slots.html?id=%s"|args:$key shape="calendar"}} + {{/if}} + {{:linkbutton label="Modifier" href="config_event.html?id=%s"|args:$key shape="edit"}} + {{:linkbutton label="Supprimer" href="config_event_delete.html?id=%s"|args:$key shape="delete" target="_dialog"}} + + +{{else}} +

    + Il n'y a aucun événement configuré.
    + {{:linkbutton shape="plus" label="Nouvel événement" href="config_event.html?new=1"}} +

    +{{/list}} + +
    +

    Accès à la réservation

    +

    Les membres connectés pourront réserver un créneau via le menu « Réservations » à gauche.

    +

    Les non-membres pourront réserver un créneau via l'adresse suivante :
    + {{:input copy=true name="url" type="url" readonly=true default=$module.public_url size=$module.public_url|strlen}} + {{:linkbutton href=$module.public_url label="Ouvrir" target="_blank" shape="eye"}} +

    +

    Les gestionnaires pourront visionner les réservations et gérer les inscrit⋅e⋅s dans le menu « Réservations ».

    +
    + +{{:admin_footer}} \ No newline at end of file diff --git a/src/modules/bookings/config_access.html b/src/modules/bookings/config_access.html new file mode 100644 index 0000000..749e642 --- /dev/null +++ b/src/modules/bookings/config_access.html @@ -0,0 +1,25 @@ +{{#restrict section="config" level="admin" block=true}}{{/restrict}} +{{#form on="save"}} + {{:save key="config" access=$_POST.access}} + {{:redirect to="config.html?ok=1"}} +{{/form}} + +{{:admin_header title="Configuration des accès" current="module_bookings"}} + +{{:include file="./_nav.html" current="config" subcurrent="access"}} + +
    +
    + Qui peut voir et gérer les inscriptions ? +
    + {{:input type="radio" name="access" source=$module.config value="none" label="Tous les membres connectés"}} + {{:input type="radio" name="access" source=$module.config value="read" label="Seulement les membres pouvant lire la liste des membres"}} + {{:input type="radio" name="access" source=$module.config default="write" value="write" label="Seulement les membres pouvant ajouter et modifier des membres"}} +
    +

    + {{:button type="submit" label="Enregistrer" name="save" shape="right" class="main"}} +

    +
    +
    + +{{:admin_footer}} \ No newline at end of file diff --git a/src/modules/bookings/config_event.html b/src/modules/bookings/config_event.html new file mode 100644 index 0000000..b17e5ce --- /dev/null +++ b/src/modules/bookings/config_event.html @@ -0,0 +1,158 @@ +{{#restrict section="config" level="admin" block=true}}{{/restrict}} +{{:admin_header title="Configuration d'un événement" current="module_bookings"}} + +{{if $_GET.id}} + {{#load key=$_GET.id type="event" assign="event"}} + {{:assign key=$key}} + {{else}} + {{:error message="Événément introuvable"}} + {{/load}} +{{else}} + {{:assign key=""|uuid}} +{{/if}} + +{{:include file="./_nav.html" current="config" event=null}} + +{{:assign var="types" + checkbox="Case à cocher" + date="Date" + datetime="Date et heure" + tel="Numéro de téléphone" + text="Texte" + textarea="Texte multi-lignes" +}} + +{{#form on="save"}} + {{#foreach from=$_POST.fields|array_transpose item="field"}} + {{if !$field.label|trim || !$field.type}} + {{:continue}} + {{/if}} + {{if !$types|keys|has:$field.type}} + {{:error message="Type inconnu: %s"|args:$field.type}} + {{/if}} + {{:assign var="fields." required=$field.required|boolval type=$field.type label=$field.label help=$field.help|or:null}} + {{/foreach}} + {{:save key=$key + validate_schema="./event.schema.json" + type="event" + label=$_POST.label|trim + description=$_POST.description|trim|or:null + max_weeks=$_POST.max_weeks|intval|or:1 + use_closings=$_POST.use_closings|boolval + use_openings=$_POST.use_openings|boolval + openings_seats=$_POST.openings_seats|intval|or:null + openings_slots=$_POST.openings_slots|intval|or:null + openings_delay=$_POST.openings_delay|intval|or:null + email=$_POST.email|boolval + archived=$_POST.archived|boolval + fields=$fields|arrayval + }} + {{:redirect to="./config.html?ok=1"}} +{{/form}} + +{{:form_errors}} + +
    +
    + Événément +
    + {{:input type="text" name="label" label="Nom" required=true source=$event}} + {{:input type="textarea" cols=70 rows=7 name="description" label="Description" required=false source=$event help="Sera affiché sur la page d'inscription à l'événement"}} +
    Syntaxe MarkDown acceptée. {{:linkbutton shape="help" target="_dialog" href="!static/doc/markdown.html" label="Aide de la syntaxe MarkDown"}}
    + {{:input type="checkbox" label="Archiver cet événement" name="archived" source=$event value=1}} +
    Si coché, cet événement ne sera plus visible dans la liste et ne pourra plus être réservé.
    + {{:input type="number" min=1 step=1 max=12 name="max_weeks" label="Nombre de semaines réservables pour les événements hebdomadaires" help="Par exemple, pour un événement tous les vendredis, inscrire le chiffre 2 ici permettra de réserver pour ce vendredi, et le vendredi suivant." source=$event default=1}} + {{#module name="openings"}} +
    Synchronisation avec les ouvertures
    + {{:input type="checkbox" name="use_closings" value=1 source=$event label="Ne pas proposer de créneaux pendant les périodes de fermeture"}} + {{:input type="checkbox" name="use_openings" value=1 source=$event label="Utiliser les horaires d'ouverture comme créneaux"}} +
    Si cette case est cochée, les créneaux seront automatiquement configurés à partir des informations indiquées dans l'extension {{:link href="%sconfig.html"|args:$url label="Horaires d'ouverture"}}.
    Sinon, il faudra configurer les créneaux manuellement.
    +
    {{:linkbutton shape="settings" href="%sconfig.html"|args:$url label="Configurer les ouvertures et fermetures"}}
    +
    +
    +
    + Pour chaque ouverture… +
    + {{:input type="number" min=0 name="openings_slots" source=$event label="Nombre de créneaux par ouverture" default=1 required=true}} + {{:input type="number" min=0 name="openings_seats" source=$event label="Nombre de places par créneau" required=true}} + {{:assign var="delay_options" 5="5 minutes" 10="10 minutes" 15="15 minutes" 20="20 minutes" 30="30 minutes" 60="1 heure" 120="2 heures" 240="4 heures"}} +
    +
    + {{:input type="select" name="openings_delay" source=$event label="Intervalle entre chaque créneau" options=$delay_options required=true}} + {{/module}} +
    +
    + +
    + Informations à demander aux inscrits +
    +
    Pour chaque inscription, le nom de la personne sera toujours demandé.
    + {{:input type="checkbox" name="email" value="1" label="Demander aussi l'adresse e-mail" source=$event}} +
    Si la personne indique une adresse e-mail, une confirmation d'inscription lui sera envoyée. Cocher cette case pour rendre l'adresse e-mail obligatoire.
    +
    +
    + +
    + Autres informations à demander aux inscrits +

    + À utiliser si l'inscription nécessite d'autres informations, comme un numéro de téléphone par exemple. +

    + + + + + + + + + + + + {{:assign var="select" 0="Facultatif" 1="Obligatoire"}} + {{#foreach from=$event.fields item="field"}} + + + + + + + + {{else}} + + + + + + + + {{/foreach}} + +
    Type de champLibellé du champTexte d'aide en dessous du champChamp obligatoire ?
    {{:input type="select" name="fields[type][]" default=$field.type options=$types}}{{:input type="text" name="fields[label][]" default=$field.label}}{{:input type="text" name="fields[help][]" default=$field.help}}{{:input type="select" name="fields[required][]" default=$field.required options=$select required=true}}{{:button shape="minus" label="Enlever cette ligne" onclick="this.parentNode.parentNode.remove();"}}
    {{:input type="select" name="fields[type][]" options=$types default="text"}}{{:input type="text" name="fields[label][]" default=$field.label}}{{:input type="text" name="fields[help][]" default=$field.help}}{{:input type="select" name="fields[required][]" default=$field.required options=$select required=true}}{{:button shape="minus" label="Enlever ce champ" onclick="this.parentNode.parentNode.remove();"}}
    +

    + {{:button shape="plus" label="Ajouter un champ" onclick="var a = $('.fields tbody')[0].lastElementChild; var b = a.cloneNode(true); b.querySelectorAll('input, select').forEach((e) => e.value = null); a.parentNode.append(b);"}} +

    +
    + +

    + {{:button type="submit" shape="right" name="save" label="Enregistrer" class="main"}} +

    + +
    + + + +{{:admin_footer}} \ No newline at end of file diff --git a/src/modules/bookings/config_event_delete.html b/src/modules/bookings/config_event_delete.html new file mode 100644 index 0000000..55bab8c --- /dev/null +++ b/src/modules/bookings/config_event_delete.html @@ -0,0 +1,17 @@ +{{#restrict section="config" level="admin" block=true}}{{/restrict}} + +{{#load key=$_GET.id type="event" assign="event"}} +{{else}} + {{:error message="Événément introuvable"}} +{{/load}} + +{{#form on="delete"}} + {{:delete where="key = :key OR $$.event = :key" :key=$event.key}} + {{:redirect to="./config.html"}} +{{/form}} + +{{:admin_header title="Supprimer un événement" current="module_bookings"}} + +{{:delete_form legend="Supprimer cet événement ?" warning="Supprimer l'événement \"%s\" ?"|args:$event.label info="Les créneaux et réservations liées seront perdues."}} + +{{:admin_footer}} \ No newline at end of file diff --git a/src/modules/bookings/config_event_slots.html b/src/modules/bookings/config_event_slots.html new file mode 100644 index 0000000..0ec2396 --- /dev/null +++ b/src/modules/bookings/config_event_slots.html @@ -0,0 +1,69 @@ +{{#restrict section="config" level="admin" block=true}}{{/restrict}} + +{{if $_GET.id}} + {{#load key=$_GET.id assign="event"}}{{/load}} +{{/if}} + +{{if !$event.key}} + {{:error message="Événément introuvable"}} +{{/if}} + +{{:admin_header title="%s — Créneaux"|args:$event.label current="module_bookings"}} + +{{:include file="./_nav.html" current="config" event=$event}} + +{{if $_GET.ok}} +

    Créneaux enregistrés.

    +{{/if}} + +{{:include file="./_common.tpl" keep="frequencies,days"}} + +{{#load count=true type="slot" event=$event.key}} + {{if $count}} + + + {{#load type="slot" event=$event.key order="$$.frequency = 'only' DESC, $$.frequency = 'this' DESC, $$.date, $$.day = 'monday' DESC, $$.day = 'tuesday' DESC, $$.day = 'wednesday' DESC, $$.day = 'thursday' DESC, $$.day = 'friday' DESC, $$.open ASC"}} + + + + + + {{/load}} + +
    + {{if $date}} + {{$date|date_short}} + {{else}} + {{:assign var="f" from="frequencies.%s"|args:$frequency}} + {{:assign var="d" from="days.%s"|args:$day}} + {{$f}} + {{$d}} + {{/if}} + à {{$open}} + + {{$seats}} places + + {{:linkbutton label="Modifier" href="config_slot.html?id=%s"|args:$key shape="edit" target="_dialog"}} + {{:linkbutton label="Supprimer" href="config_slot_delete.html?id=%s"|args:$key shape="delete" target="_dialog"}} +
    + + {{else}} + +

    + Il n'y a aucun créneau configuré.
    + {{:linkbutton shape="plus" label="Nouveau créneau" href="config_slot.html?new=%s"|args:$event.key target="_dialog"}} +

    + + {{/if}} +{{/load}} + +
    +

    Accès la réservation de créneaux pour cet événement

    +

    Les non-membres pourront réserver un créneau de cet événement directement via l'adresse suivante :
    + {{:assign url="%s?event=%s"|args:$module.url:$event.key}} + {{:input copy=true name="url" type="url" readonly=true default=$url size=$url|strlen}} +

    +
    + + +{{:admin_footer}} \ No newline at end of file diff --git a/src/modules/bookings/config_slot.html b/src/modules/bookings/config_slot.html new file mode 100644 index 0000000..e86eefb --- /dev/null +++ b/src/modules/bookings/config_slot.html @@ -0,0 +1,100 @@ +{{#restrict section="config" level="admin" block=true}}{{/restrict}} + +{{if $_GET.id}} + {{#load key=$_GET.id assign="slot"}} + {{:assign key=$key}} + {{:assign event=$event}} + {{else}} + {{:error message="Créneau introuvable"}} + {{/load}} +{{else}} + {{:assign key=""|uuid}} + {{#load key=$_GET.new}} + {{:assign event=$key}} + {{else}} + {{:error message="Événement inconnu"}} + {{/load}} +{{/if}} + +{{:admin_header title="Configuration d'un créneau" current="module_bookings"}} +{{:include file="./_common.tpl" keep="frequencies,days"}} + +{{#form on="save"}} + {{if !$frequencies|keys|has:$_POST.frequency}} + {{:error message="Fréquence invalide"}} + {{/if}} + {{if $_POST.frequency == 'only'}} + {{:assign date=$_POST.date|parse_date}} + {{:assign day=null}} + {{if !$date}} + {{:error message="Date invalide"}} + {{/if}} + {{else}} + {{:assign date=null}} + {{:assign day=$_POST.day}} + {{if !$days|keys|has:$day}} + {{:error message="Jour invalide"}} + {{/if}} + {{/if}} + {{if !$_POST.open|parse_time}} + {{:error message="Heure de début invalide"}} + {{/if}} + + {{:save key=$key + validate_schema="./slot.schema.json" + type="slot" + event=$event + frequency=$_POST.frequency + day=$day + date=$date|or:null + open=$_POST.open|parse_time + seats=$_POST.seats|intval + }} + {{:redirect to="./config_event_slots.html?id=%s&ok=1"|args:$event}} +{{/form}} + +{{:form_errors}} + +
    +
    + Date et heure +
    + {{:input type="select" name="frequency" required=true label="Fréquence" options=$frequencies source=$slot default="every"}} +
    +
    + {{:input type="date" name="date" required=true label="Date" source=$slot}} +
    +
    + {{:input type="select" name="day" required=true label="Jour" options=$days source=$slot}} +
    +
    + {{:input type="time" name="open" required=true label="Heure de début" source=$slot}} +
    +
    + +
    + Jauge +
    + {{:input type="number" name="seats" required=true min=0 step=1 label="Nombre de places disponibles" source=$slot}} +
    +
    + +

    + {{:button type="submit" shape="right" name="save" label="Enregistrer" class="main"}} +

    + +
    + + + +{{:admin_footer}} \ No newline at end of file diff --git a/src/modules/bookings/config_slot_delete.html b/src/modules/bookings/config_slot_delete.html new file mode 100644 index 0000000..3f818fe --- /dev/null +++ b/src/modules/bookings/config_slot_delete.html @@ -0,0 +1,17 @@ +{{#restrict section="config" level="admin" block=true}}{{/restrict}} + +{{#load key=$_GET.id type="slot" assign="slot"}} +{{else}} + {{:error message="Événément introuvable"}} +{{/load}} + +{{#form on="delete"}} + {{:delete where="key = :key OR $$.slot = :key" :key=$slot.key}} + {{:redirect to="./config_event_slots.html?id=%s"|args:$slot.event}} +{{/form}} + +{{:admin_header title="Supprimer un créneau" current="module_bookings"}} + +{{:delete_form legend="Supprimer ce créneau ?" warning="Supprimer le créneau ?" info="Les réservations liées à ce créneau seront perdues."}} + +{{:admin_footer}} \ No newline at end of file diff --git a/src/modules/bookings/event.schema.json b/src/modules/bookings/event.schema.json new file mode 100644 index 0000000..88169b8 --- /dev/null +++ b/src/modules/bookings/event.schema.json @@ -0,0 +1,62 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["event"] + }, + "label": { + "type": "string" + }, + "description": { + "type": ["null", "string"] + }, + "use_openings": { + "type": "boolean" + }, + "max_weeks": { + "type": "integer" + }, + "openings_seats": { + "type": ["null", "integer"] + }, + "openings_slots": { + "type": ["null", "integer"] + }, + "openings_delay": { + "type": ["null", "integer"] + }, + "use_closings": { + "type": "boolean" + }, + "fields": { + "type": "array", + "items": { + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "label": { + "type": "string" + }, + "help": { + "type": ["string", "null"] + }, + "required": { + "type": "boolean" + } + }, + "required": ["type", "label", "help"] + } + }, + "email": { + "type": "boolean" + }, + "archived": { + "type": "boolean" + } + }, + "required": ["type", "label", "description", "use_openings", "openings_seats", "openings_slots", "openings_delay", "use_closings", "fields", "email", "archived"] +} \ No newline at end of file diff --git a/src/modules/bookings/icon.svg b/src/modules/bookings/icon.svg new file mode 100644 index 0000000..38369ee --- /dev/null +++ b/src/modules/bookings/icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/modules/bookings/index.html b/src/modules/bookings/index.html new file mode 100644 index 0000000..72f24dd --- /dev/null +++ b/src/modules/bookings/index.html @@ -0,0 +1,18 @@ +{{:assign var="custom_css." value="./style.css?2023-11-14"}} +{{:assign var="custom_css." value="/content.css"}} +{{if !$logged_user}}{{:assign layout="public"}}{{/if}} +{{:admin_header title="Réservations" custom_css=$custom_css layout=$layout current="module_bookings"}} + +{{if $logged_user}} + {{:include file="./_nav.html" current="index"}} +{{/if}} + +{{:include file="./_form.html" current="index"}} + +{{if !$logged_user}} +

    + {{:linkbutton shape="left" label="Retourner sur notre site" href=$site_url}} +

    +{{/if}} + +{{:admin_footer}} \ No newline at end of file diff --git a/src/modules/bookings/local_bookings.js b/src/modules/bookings/local_bookings.js new file mode 100644 index 0000000..a50c64f --- /dev/null +++ b/src/modules/bookings/local_bookings.js @@ -0,0 +1,56 @@ +/** + * Save bookings to local storage + * @return {[type]} [description] + */ +(function () { + var s = localStorage; + var bookings = s.getItem('bookings'); + + if (typeof bookings == 'string') { + bookings = JSON.parse(bookings); + } + + if (typeof bookings != 'object' || bookings === null) { + bookings = {}; + } + + // Delete booking + if (a = document.querySelector('[data-delete-booking]')) { + var key = a.dataset.deleteBooking; + + if (bookings.hasOwnProperty(key)) { + delete bookings[key]; + s.setItem('bookings', JSON.stringify(bookings)); + } + } + // Append booking + else if (a = document.querySelector('[data-new-booking]')) { + var data = JSON.parse(a.dataset.newBooking); + bookings[data.key] = data; + s.setItem('bookings', JSON.stringify(bookings)); + return; + } + + if (!Object.keys(bookings).length) { + return; + } + + const escape = (unsafe) => { + return unsafe.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """).replace(/'/g, "'"); + }; + + const tpl = document.getElementById('booking'); + + Object.values(bookings).forEach((b) => { + if (b.date <= Math.floor(Date.now() / 1000)) { + // Booking is old, delete it + delete bookings[b.key]; + s.setItem('bookings', JSON.stringify(bookings)); + } + + let html = tpl.innerHTML; + + html = html.replace(/#(\w+)#/g, (_, m) => escape(b[m] ?? '')); + tpl.parentNode.insertAdjacentHTML('beforeend', html); + }); +})(); \ No newline at end of file diff --git a/src/modules/bookings/module.ini b/src/modules/bookings/module.ini new file mode 100644 index 0000000..28803e6 --- /dev/null +++ b/src/modules/bookings/module.ini @@ -0,0 +1,6 @@ +name="Réservations" +description="Permet aux membres et visiteurs de s'inscrire à des événements et créneaux" +author="Paheko" +author_url="https://paheko.cloud/" +home_button=true +menu=true diff --git a/src/modules/bookings/slot.schema.json b/src/modules/bookings/slot.schema.json new file mode 100644 index 0000000..c713166 --- /dev/null +++ b/src/modules/bookings/slot.schema.json @@ -0,0 +1,40 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["slot"] + }, + "event": { + "type": "string" + }, + "date": { + "description": "Date", + "format": "date", + "type": ["string", "null"] + }, + "frequency": { + "description": "Fréquence", + "type": "string", + "enum": ["only", "this", "first", "second", "third", "fourth", "fifth", "last"] + }, + "day": { + "description": "Jour", + "type": ["string", "null"], + "enum": [null, "monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"] + }, + "open": { + "description": "Heure de début", + "type": "string", + "pattern": "^(2[0-3]|[01][0-9]):([0-5][0-9])$", + "minimum": 0 + }, + "seats": { + "description": "Nombre de places", + "type": "integer", + "minimum": 0 + } + }, + "required": ["type", "event", "date", "frequency", "day", "open", "seats"] +} diff --git a/src/modules/bookings/style.css b/src/modules/bookings/style.css new file mode 100644 index 0000000..14247d6 --- /dev/null +++ b/src/modules/bookings/style.css @@ -0,0 +1,188 @@ +.booking_event, .booking_events { + max-width: 50rem; +} + +.booking_event > .event > h1 { + margin-bottom: 1rem; + text-align: center; +} + +.booking_event > .event .web-content { + padding-top: .8rem; + border-top: .2rem solid #999; +} + +.booking_event fieldset { + border: none; + border-top: .2rem solid #999; +} + +.booking_event fieldset legend { + text-align: center; + font-size: 1.5em; +} + +.booking_event fieldset article { + max-width: 30rem; + margin: 1rem auto; +} + +.booking_event fieldset.info { + text-align: center; + border-radius: 0; +} + +.booking_event fieldset.info .help { + font-size: .9em; +} + +.booking_event p.submit { + max-width: 30rem; + margin: 1rem auto; + text-align: center; +} + +.booking_event fieldset article h3 { + margin-bottom: .5rem; +} + +.booking_event .slots { + text-align: center; +} + +.booking_event .slots article { + border: 1px solid #ccc; + margin: 1em; + border-radius: .5rem; + overflow: hidden; +} + +.booking_event .slots article h3 { + background: #eee; + padding: .5rem; +} + +.booking_event .slots ul { + display: flex; + flex-direction: row; + flex-wrap: wrap; + justify-content: center; +} + +.booking_event .slots li a { + display: block; + background: #dfd; + box-shadow: 0px 0px 5px #ccc; + padding: .5rem; + text-decoration: none; + margin: .5rem 1rem; + color: unset; + border-radius: .5rem; + text-align: center; + transition: background-color .2s, box-shadow .2s; +} + +.booking_event .slots li a:hover { + box-shadow: 0px 0px 5px orange; + background: #FFD580; +} + +.booking_event .slots li a strong { + display: block; + font-size: 1.5em; + text-decoration: underline; +} + +.booking_event .slots li a em { + color: #666; + font-style: normal; +} + +.booking_event .slots li .available_1 { + background: #fcc; +} + +.booking_event .slots li .available_2 { + background: #ffc; +} + +.booking_event .slots li .available_0, .booking_event .slots li a.available_0:hover { + background: #ddd; + cursor: not-allowed; + color: #999; + box-shadow: 0px 0px 5px #ccc; +} + +.booking_event .slots li .available_0 strong { + text-decoration: line-through; +} + +.mycaptcha { + text-align: center; + padding: .5em; + background: #ddd; + border-radius: .5em; +} + +.mycaptcha h4 { + border-bottom: .1rem solid #666; + margin-bottom: .5rem; + font-size: 1.2em; +} + +.mycaptcha label { + display: block; +} + +.booking_events { + text-align: center; +} + +.booking_events article { + margin: 1rem; + font-size: 1.2em; +} + +.my_bookings { + max-width: 40rem; + margin: 1rem 5rem; +} + +.my_bookings article { + margin: 1rem; + text-align: center; + padding: .5em; + border: .2rem solid #ddd; + border-radius: .5em; +} + +.my_bookings article form { + margin: 1rem 0; +} + +.my_bookings article h5 { + font-weight: normal; + color: #666; + margin: .5rem; +} + +.my_bookings article h3 span { + border-radius: .5rem; + padding: .2rem .3rem; + color: darkred; + background: #fdd; +} + +.booking_list article { + margin: 1rem; +} + +p.back { + text-align: center; + margin-top: 5em; +} + +.booking_event p.back { + margin-top: 0; + margin-bottom: 2em; +} \ No newline at end of file diff --git a/src/modules/cheque_deposit/config.html b/src/modules/cheque_deposit/config.html new file mode 100644 index 0000000..0a44fed --- /dev/null +++ b/src/modules/cheque_deposit/config.html @@ -0,0 +1,47 @@ +{{if $module.config.accounts|gettype === 'string'}} + {{:include file="/receipt_donation/_upgrade.tpl" keep="module.config.accounts"}} +{{/if}} + +{{#form on="save"}} + {{if !$_POST.accounts|count}} + {{:error message="Aucun compte n'a été renseigné."}} + {{/if}} + + {{:save key="config" + accounts=$_POST.accounts|arrayval + }} + {{:redirect to="?ok=1"}} +{{/form}} + +{{:admin_header title="Configuration bordereau de remise de chèques"}} + +{{:form_errors}} + +{{if $_GET.ok}} +

    Configuration enregistrée.

    +{{/if}} + +
    + +
    + Configuration +
    + {{:assign var="default_account.5112" value="5112 — Chèques en attente de dépôt"}} + {{:input required=true name="accounts" multiple=true target="!acc/charts/accounts/selector.php?targets=3&key=code" type="list" label="Comptes liés aux remises de chèques" source=$module.config default=$default_account}} +
    + Pour chaque compte indiqué dans ce champ, le formulaire de remise de chèque sera proposé (en dessous de la fiche de l'écriture). +
    +
    +
    + +

    + Astuce : pour faire apparaître le numéro (RIB) du compte bancaire dans le bordereau, inscrire celui-ci dans la description du compte bancaire au plan comptable. +

    + +

    + {{:button type="submit" name="save" label="Enregistrer" shape="right" class="main"}} +

    + +
    + +{{:admin_footer}} \ No newline at end of file diff --git a/src/modules/cheque_deposit/icon.svg b/src/modules/cheque_deposit/icon.svg new file mode 100644 index 0000000..441bc2d --- /dev/null +++ b/src/modules/cheque_deposit/icon.svg @@ -0,0 +1 @@ + diff --git a/src/modules/cheque_deposit/index.html b/src/modules/cheque_deposit/index.html new file mode 100644 index 0000000..d16e7b2 --- /dev/null +++ b/src/modules/cheque_deposit/index.html @@ -0,0 +1,49 @@ +{{#restrict block=true section="accounting" level="read"}} +{{/restrict}} +{{if !$_GET.id}} + + {{:admin_header title="Remise de chèques" current="acc"}} + +

    Le bordereau de remise de chèque s'affichera en dessous des écritures de remise en banque.

    + + {{:admin_footer}} +{{else}} + {{#transactions id=$_GET.id}} + {{:assign date_short=$date|date_format:"%d-%m-%Y"}} + {{:include file="/receipt/_header.html" page_size="A5" title="Bordereau chèques - %d - %s"|args:$id:$date_short}} + +

    Bordereau de remise de chèques

    + +

    Écriture n°{{if $reference}}{{$reference}}{{else}}{{$id}}{{/if}} — {{$date|date_short}}

    + + {{#transaction_lines transaction=$id where="debit > 0"}} +

    Compte de dépôt: {{$account_label}}

    + {{:assign account_id=$id_account}} + {{/transaction_lines}} + + {{#accounts id=$account_id where="description IS NOT NULL"}} +

    {{$description|escape|nl2br}}

    + {{/accounts}} + + + {{:assign i=1}} + {{#transaction_lines transaction=$id where="credit > 0"}} + + + + + + + {{:assign i="%d+1"|math:$i}} + {{/transaction_lines}} +
    {{$i}}.{{$label}}{{$reference}}{{$credit|raw|money_currency}}
    + +

    Nombre de chèques : {{"%d-1"|math:$i}}

    + +

    Total : {{$credit|raw|money_currency}}

    + + {{:include file="/receipt/_footer.html"}} + {{else}} + {{:error message="Le numéro d'écriture fourni n'existe pas."}} + {{/transactions}} +{{/if}} \ No newline at end of file diff --git a/src/modules/cheque_deposit/module.ini b/src/modules/cheque_deposit/module.ini new file mode 100644 index 0000000..6da0975 --- /dev/null +++ b/src/modules/cheque_deposit/module.ini @@ -0,0 +1,7 @@ +name="Bordereau de remise de chèques" +description="Permet d'imprimer un bordereau de remise de chèques à partir d'une écriture de dépôt." +author="Paheko" +author_url="https://paheko.cloud/" +home_button=false +restrict_section="accounting" +restrict_level="read" \ No newline at end of file diff --git a/src/modules/cheque_deposit/snippets/transaction_details.html b/src/modules/cheque_deposit/snippets/transaction_details.html new file mode 100644 index 0000000..95ca9bd --- /dev/null +++ b/src/modules/cheque_deposit/snippets/transaction_details.html @@ -0,0 +1,27 @@ +{{if $module.config.accounts === null}} + {{:assign var="module.config.accounts.5112" value='5112'}} +{{elseif $module.config.accounts|gettype === 'string'}} + {{:include file="/receipt_donation/_upgrade.tpl" keep="module.config.accounts"}} +{{/if}} + +{{#foreach from=$transaction_lines item="line"}} + {{if !$line.credit}} + {{:continue}} + {{/if}} + + {{#foreach from=$module.config.accounts key="code"}} + {{if $line.account_code|strpos:$code === 0}} + {{:assign show=true}} + {{:break}} + {{/if}} + {{/foreach}} + + {{if $show}} + {{:break}} + {{/if}} +{{/foreach}} + + +{{if $show}} + {{:include file="/receipt/snippets/transaction_details.html" show=true}} +{{/if}} \ No newline at end of file diff --git a/src/modules/expenses_claims/NOTES_DEV.md b/src/modules/expenses_claims/NOTES_DEV.md new file mode 100644 index 0000000..31e563d --- /dev/null +++ b/src/modules/expenses_claims/NOTES_DEV.md @@ -0,0 +1,3 @@ +## Mettre à jour les barème kilométriques + +Copier-coller avec LibreOffice les colonnes dans "bareme.csv" à partir de diff --git a/src/modules/expenses_claims/_calcul_bareme.tpl b/src/modules/expenses_claims/_calcul_bareme.tpl new file mode 100644 index 0000000..a37f4cc --- /dev/null +++ b/src/modules/expenses_claims/_calcul_bareme.tpl @@ -0,0 +1,50 @@ +{{if !$distance || !$vehicule}} + {{:error message="Fonction 'calcul_bareme' : paramètre distance ou vehicule manquant"}} +{{/if}} + +{{if $vehicule === "speedbike"}} + {{:assign vehicule="ecyclomoteur"}} +{{/if}} + +{{if $vehicule|substr:0:1 === 'e'}} + {{:assign + bonus="1.2" + vehicule=$vehicule|substr:1 + }} +{{else}} + {{:assign bonus="1"}} +{{/if}} + +{{:read file="./baremes.csv" assign="baremes"}} +{{:assign baremes=$baremes|trim|explode:"\n"}} + +{{#foreach from=$baremes item="bareme"}} + {{:assign bareme=$bareme|str_getcsv}} + {{if $vehicule !== $bareme.0}} + {{:continue}} + {{/if}} + + {{if $bareme.0 === 'cyclomoteur' || $bareme.0|substr:4 === 'moto'}} + {{if $distance <= 3000}} + {{:assign calcul=$bareme.1}} + {{elseif $distance <= 6000}} + {{:assign calcul=$bareme.2}} + {{else}} + {{:assign calcul=$bareme.3}} + {{/if}} + {{else}} + {{if $distance <= 5000}} + {{:assign calcul=$bareme.1}} + {{elseif $distance <= 20000}} + {{:assign calcul=$bareme.2}} + {{else}} + {{:assign calcul=$bareme.3}} + {{/if}} + {{/if}} + {{:break}} +{{/foreach}} + + +{{:assign calcul_maths=$calcul|replace:"x":"*"|replace:",":"."|replace:"d":$distance|regexp_replace:"/[^0-9\.*]/":""}} + +{{:assign resultat="round(%s*%f, 2)"|math:$calcul_maths:$bonus}} diff --git a/src/modules/expenses_claims/_config_default.tpl b/src/modules/expenses_claims/_config_default.tpl new file mode 100644 index 0000000..39b1191 --- /dev/null +++ b/src/modules/expenses_claims/_config_default.tpl @@ -0,0 +1,80 @@ +{{#load type="category" limit=1}} +{{else}} + {{:assign var="account" value=null}} + {{:assign var="account.6251" value="6251 — Frais de déplacement"}} + {{:save type="category" schema="./category.schema.json" + key=""|uuid + label="Déplacement (forfaitaire)" + account=$account + expense_type="flat_rate" + price=5000 + }} + + {{:assign var="account" value=null}} + {{:assign var="account.6251" value="6251 — Frais de déplacement"}} + {{:save type="category" schema="./category.schema.json" + key=""|uuid + label="Déplacement (au kilomètre)" + account=$account + expense_type="km_vehicle" + }} + + {{:assign var="account" value=null}} + {{:assign var="account.626" value="626 — Frais postaux ou télécommunication"}} + {{:save type="category" schema="./category.schema.json" + key=""|uuid + label="Frais postaux ou télécommunication" + account=$account + expense_type="other" + }} + + {{:assign var="account" value=null}} + {{:assign var="account.6063" value="6063 — Fournitures d'entretien et petit équipement"}} + {{:save type="category" schema="./category.schema.json" + key=""|uuid + label="Fournitures d'entretien et petit équipement" + account=$account + expense_type="other" + }} + + {{:assign var="account" value=null}} + {{:assign var="account.6065" value="6065 — Petits logiciels"}} + {{:save type="category" schema="./category.schema.json" + key=""|uuid + label="Logiciels à faible valeur (< 500 €)" + account=$account + expense_type="other" + }} + + {{:assign var="account" value=null}} + {{:assign var="account.625" value="625 — Frais de réception"}} + {{:save type="category" schema="./category.schema.json" + key=""|uuid + label="Nourriture et autres frais de réception" + account=$account + expense_type="other" + }} +{{/load}} + +{{:assign var="vehicles" + speedbike="Vélo électrique rapide (speed bike, > 25 km/h)" + ecyclomoteur="Moto ou scooter électrique <= 50 cc" + emoto1cv="Moto ou scooter électrique > 50 cc — 1 ou 2 CV" + emoto3cv="Moto ou scooter électrique > 50 cc — 3 à 5 CV" + emoto6cv="Moto ou scooter électrique > 50 cc — 6 CV et plus" + e3cv="Voiture électrique — 3CV et moins" + e4cv="Voiture électrique — 4CV" + e5cv="Voiture électrique — 5CV" + e6cv="Voiture électrique — 6CV" + e7cv="Voiture électrique — 7CV et plus" + + 3cv="Voiture thermique — 3CV et moins" + 4cv="Voiture thermique — 4CV" + 5cv="Voiture thermique — 5CV" + 6cv="Voiture thermique — 6CV" + 7cv="Voiture thermique — 7CV et plus" + cyclomoteur="Moto ou scooter thermique <= 50 cc" + moto1cv="Moto ou scooter thermique > 50 cc — 1 ou 2 CV" + moto3cv="Moto ou scooter thermique > 50 cc — 3 à 5 CV" + moto6cv="Moto ou scooter thermique > 50 cc — 6 CV et plus" +}} diff --git a/src/modules/expenses_claims/abandon.html b/src/modules/expenses_claims/abandon.html new file mode 100644 index 0000000..f9d3285 --- /dev/null +++ b/src/modules/expenses_claims/abandon.html @@ -0,0 +1,110 @@ +{{#restrict section="accounting" level="write" block=true}}{{/restrict}} + +{{#load assign="claim" key=$_GET.claim}} +{{else}} + {{:error message="Cette note de frais n'existe pas"}} +{{/load}} + +{{if $claim.status !== 'payable'}} + {{:error message="Cette note de frais n'est pas en attente de paiement: %s"|args:$claim.status}} +{{/if}} + +{{#transactions id=$claim.id_transaction}} + {{:assign total=$debit}} +{{/transactions}} + +{{if $claim.payments|count}} + {{#transactions id=$claim.payments}} + {{:assign total="%d-%d"|math:$total:$credit}} + {{/transactions}} +{{/if}} + +{{if $total <= 0}} + {{:error message="Cette note de frais a déjà été réglée ou abandonnée"}} +{{/if}} + +{{#form on="save"}} + {{if !$_POST.label|trim}} + {{:error message="Le libellé doit être renseigné."}} + {{/if}} + + {{:assign var="lines." debit=$total|money_raw account=$_POST.account|keys|key:0}} + {{:api + method="POST" + path="accounting/transaction" + assign="result" + + id_year=$_POST.id_year|intval + type="revenue" + date=$_POST.date + label=$_POST.label|trim + reference="NDF-%d"|args:$claim.number + notes=$_POST.notes|trim|or:null + amount=$total|money_raw + debit=$_POST.account|keys|key:0 + credit=$_POST.abandon_account|keys|key:0 + linked_users=$claim.user_id + linked_transactions=$claim.id_transaction|intval + }} + {{:assign var="claim.payments." value=$result.id}} + + {{:save key=$claim.key status="paid" payments=$claim.payments}} + {{:http redirect="details.html?key=%s"|args:$claim.key}} +{{/form}} + +{{#load type="line" claim=$claim.key}} + {{:assign var="notes" value="%s\n\n%s"|args:$notes:$description}} +{{/load}} +{{:assign var="default_account.4110" value="4110 — Autres usagers"}} +{{:assign var="default_abandon_account.75412" value="75412 — Abandons de frais par les bénévoles"}} +{{#years closed=false}} + {{:assign var="open_years.%d"|args:$id value=$label}} + {{if $start_date <= $now && $end_date >= $now}} + {{:assign best_year=$id}} + {{/if}} +{{/years}} +{{:assign default_label="Abandon note de frais n°%d"|args:$claim.number}} + +{{if !$open_years|count}} + {{:error message="Aucun exercice n'est ouvert, il n'est pas possible de créer une écriture et donc d'accepter cette note de frais."}} +{{/if}} + +{{:admin_header title="Abandonner la note de frais n°%d"|args:$claim.number}} + +{{:form_errors}} + +
    +
    + Écriture d'abandon de note de frais +
    + {{:input type="select" default=$best_year name="id_year" label="Exercice" required=true options=$open_years}} + {{:input type="date" default=$now name="date" label="Date" required=true}} + {{:input type="text" default=$default_label name="label" label="Libellé" required=true}} + {{:input type="list" name="account" label="Compte de tiers" required=true target="!acc/charts/accounts/selector.php?targets=4&key=code&year=%d"|args:$best_year default=$default_account}} + {{:input type="list" name="abandon_account" label="Compte d'abandon" required=true target="!acc/charts/accounts/selector.php?targets=6&key=code&year=%d"|args:$best_year default=$default_abandon_account}} + {{:input type="textarea" name="notes" label="Remarques" default=$notes|trim cols=50 rows=5}} +
    +
    + +

    + {{:button type="submit" shape="right" label="Abandonner le remboursement" name="save" class="main"}} +

    +

    + La note de frais sera marquée comme étant réglée. +

    +
    + + + +{{:admin_footer}} \ No newline at end of file diff --git a/src/modules/expenses_claims/accept.html b/src/modules/expenses_claims/accept.html new file mode 100644 index 0000000..8cd908b --- /dev/null +++ b/src/modules/expenses_claims/accept.html @@ -0,0 +1,139 @@ +{{#restrict section="accounting" level="write" block=true}}{{/restrict}} + +{{#load assign="claim" key=$_GET.claim}} +{{else}} + {{:error message="Cette note de frais n'existe pas"}} +{{/load}} + +{{if $claim.status !== 'waiting'}} + {{:error message="Cette note de frais n'est pas en attente de validation: %s"|args:$claim.status}} +{{/if}} + +{{#form on="save"}} + {{:assign total=0}} + {{:assign var="upload_path" value="%s/%s"|args:$module.storage_root:$claim.key}} + + {{#foreach from=$_POST.lines|array_transpose}} + {{:assign var="lines." debit=$amount label=$label account=$account|keys|key:0}} + {{:assign amount=$amount|money_int}} + {{:assign total="%d+%d"|math:$amount:$total}} + {{/foreach}} + + {{if !$total}} + {{:error message="Cette note de frais ne comporte aucune ligne"}} + {{/if}} + + {{if !$_POST.label|trim}} + {{:error message="Le libellé doit être renseigné."}} + {{/if}} + + {{:assign var="lines." credit=$total|money_raw account=$_POST.account|keys|key:0 label="Dette envers le membre"}} + + {{:api + method="POST" + path="accounting/transaction" + assign="result" + + id_year=$_POST.id_year|intval + type="advanced" + date=$_POST.date + label=$_POST.label|trim + reference="NDF-%d"|args:$claim.number + lines=$lines + notes=$_POST.notes|trim|or:null + linked_users=$claim.user_id + move_attachments_from=$upload_path + }} + {{:save key=$claim.key status="payable" id_transaction=$result.id}} + + {{#users id=$claim.user_id}} + {{if !$_email}} + {{:break}} + {{/if}} + {{:include file="./messages/accept.txt" capture="message" message=$_POST.message|trim claim=$claim}} + {{:mail to=$_email subject="Votre note de frais a été acceptée" body=$message|raw}} + {{/users}} + + {{:http redirect="details.html?key=%s"|args:$claim.key}} +{{/form}} + +{{#load type="line" claim=$claim.key}} + {{:assign var="notes" value="%s\n\n%s"|args:$notes:$description}} +{{/load}} +{{:assign var="default_account.4110" value="4110 — Autres usagers"}} +{{#years closed=false}} + {{:assign var="open_years.%d"|args:$id value=$label}} + {{if $start_date <= $now && $end_date >= $now}} + {{:assign best_year=$id}} + {{/if}} +{{/years}} +{{:assign default_label="Note de frais n°%d"|args:$claim.number}} + +{{if !$open_years|count}} + {{:error message="Aucun exercice n'est ouvert, il n'est pas possible de créer une écriture et donc d'accepter cette note de frais."}} +{{/if}} + +{{:admin_header title="Accepter la note de frais n°%d"|args:$claim.number}} + +{{:form_errors}} + +
    +
    + Écriture de note de frais +
    + {{:input type="select" default=$best_year name="id_year" label="Exercice" required=true options=$open_years}} + {{:input type="date" default=$now name="date" label="Date" required=true}} + {{:input type="text" default=$claim.label|or:$default_label name="label" label="Libellé" required=true}} + {{:input type="list" name="account" label="Compte de tiers" required=true target="!acc/charts/accounts/selector.php?targets=4&key=code&year=%d"|args:$best_year default=$default_account}} + {{:input type="textarea" name="notes" label="Remarques" default=$notes|trim cols=50 rows=5}} +
    +
    + +
    + Lignes de la note de frais + + + + + + + + + + + + {{#load type="line" claim=$claim.key}} + {{:assign default_account=null}} + {{:assign var="default_account.%s"|args:$account value=$account}} + + + + + + + {{/load}} + +
    CompteLibelléRéférenceMontant
    {{:input type="text" name="lines[label][]" default=$label required=false}}{{:input type="text" name="lines[reference][]" default=$reference required=false size=10}}{{:input type="money" name="lines[amount][]" default=$amount required=true}}
    +
    +

    + {{:button type="submit" shape="right" label="Accepter" name="save" class="main"}} +

    +

    + La note de frais sera transformée en écriture comptable. Les fichiers joints seront déplacés dans l'écriture. +

    +
    + + + +{{:admin_footer}} \ No newline at end of file diff --git a/src/modules/expenses_claims/baremes.csv b/src/modules/expenses_claims/baremes.csv new file mode 100644 index 0000000..b198590 --- /dev/null +++ b/src/modules/expenses_claims/baremes.csv @@ -0,0 +1,9 @@ +3cv,"d x 0,529","(d x 0,316) + 1 065","d x 0,370" +4cv,"d x 0,606","(d x 0,340) + 1 330","d x 0,407" +5cv,"d x 0,636","(d x 0,357) + 1 395","d x 0,427" +6cv,"d x 0,665","(d x 0,374) + 1 457","d x 0,447" +7cv,"d x 0,697","(d x 0,394) + 1 515","d x 0,470" +moto1cv,"d x 0,395","(d x 0,099) + 891","d x 0,248" +moto3cv,"d x 0,468","(d x 0,082) + 1 158","d x 0,275" +moto6cv,"d x 0,606","(d x 0,079) + 1 583","d x 0,343" +cyclomoteur,"d x 0,315","(d x 0,079) + 711","d x 0,198" diff --git a/src/modules/expenses_claims/cat_delete.html b/src/modules/expenses_claims/cat_delete.html new file mode 100644 index 0000000..0f2b40d --- /dev/null +++ b/src/modules/expenses_claims/cat_delete.html @@ -0,0 +1,18 @@ +{{#restrict block=true section="config" level="admin"}}{{/restrict}} + +{{#load type="category" key=$_GET.key assign="cat"}} + {{:assign key=$key}} +{{else}} + {{:error message="La catégorie indiquée n'existe pas"}} +{{/load}} + +{{#form on="delete"}} + {{:delete key=$key}} + {{:http redirect="./config.html"}} +{{/form}} + +{{:admin_header title="Supprimer"}} + +{{:delete_form legend="Supprimer une catégorie" warning="Supprimer la catégorie '%s' ?"|args:$cat.label}} + +{{:admin_footer}} diff --git a/src/modules/expenses_claims/cat_edit.html b/src/modules/expenses_claims/cat_edit.html new file mode 100644 index 0000000..d51c10b --- /dev/null +++ b/src/modules/expenses_claims/cat_edit.html @@ -0,0 +1,77 @@ +{{#restrict block=true section="config" level="admin"}}{{/restrict}} + +{{if $_GET.key !== 'new'}} + {{#load type="category" key=$_GET.key assign="cat"}} + {{:assign key=$key}} + {{else}} + {{:error message="La catégorie indiquée n'existe pas"}} + {{/load}} +{{else}} + {{:assign key=""|uuid}} +{{/if}} + +{{#form on="save"}} + {{if !$_POST.label|trim}} + {{:error message="Le nom de la catégorie ne peut être laissé vide."}} + {{/if}} + + {{:save type="category" + schema="./category.schema.json" + key=$key + label=$_POST.label|trim + account=$_POST.account|arrayval|or:null + price=$_POST.price|money_int|or:null + km=$_POST.vehicle|strval|trim|or:null + expense_type=$_POST.expense_type + notes=$_POST.notes|trim|or:null + }} + {{:redirect to="./config.html"}} + +{{/form}} + +{{:admin_header title="Catégorie de note de frais"}} + +{{:form_errors}} + +{{:assign var="types" + other="Autres" + flat_rate="Forfaitaire" + km_vehicle="Kilométrique, selon le type de véhicule, au barème légal" + km_free="Kilométrique, avec un barème personnalisé" +}} + +
    + +
    + Catégorie +
    + {{:input type="text" name="label" label="Nom de la catégorie" required=true source=$cat}} + {{:input type="textarea" cols=50 rows=5 name="notes" label="Instructions" required=false source=$cat help="Ces instructions seront affichées en dessous du champ description.\nUtile par exemple pour indiquer quelles sont les informations supplémentaires que le membre doit renseigner."}} + {{:input type="list" required=false name="account" label="Compte de dépense" source=$cat target="!acc/charts/accounts/selector.php?targets=5&key=code"}} + {{:input type="select" name="expense_type" required=true label="Type de dépense" options=$types source=$cat}} +
    +
    + {{:input type="money" name="price" label="Prix au kilomètre" required=true source=$cat}} +
    +
    + {{:input type="money" name="price" label="Prix unitaire" required=true source=$cat}} +
    +
    + +

    + {{:button type="submit" name="save" label="Enregistrer" shape="right" class="main"}} +

    + + + +
    + +{{:admin_footer}} \ No newline at end of file diff --git a/src/modules/expenses_claims/category.schema.json b/src/modules/expenses_claims/category.schema.json new file mode 100644 index 0000000..83944b6 --- /dev/null +++ b/src/modules/expenses_claims/category.schema.json @@ -0,0 +1,32 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["category"] + }, + "label": { + "type": "string", + "description": "Nom de la catégorie" + }, + "notes": { + "type": ["string", "null"], + "description": "Instructions" + }, + "account": { + "type": ["string", "null"], + "description": "Code comptable" + }, + "price": { + "type": ["integer", "null"], + "description": "Prix unitaire" + }, + "expense_type": { + "type": "string", + "description": "Type de dépense", + "enum": ["other", "km_free", "km_vehicle"] + } + }, + "required": ["type", "label", "notes", "account", "expense_type"] +} diff --git a/src/modules/expenses_claims/claim.schema.json b/src/modules/expenses_claims/claim.schema.json new file mode 100644 index 0000000..bd3fc36 --- /dev/null +++ b/src/modules/expenses_claims/claim.schema.json @@ -0,0 +1,46 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["claim"] + }, + "number": { + "description": "Nombre unique", + "type": "integer" + }, + "label": { + "description": "Libellé", + "type": ["string", "null"] + }, + "date": { + "description": "Date", + "type": "string", + "format": "date" + }, + "user_id": { + "description": "ID membre", + "type": "integer" + }, + "user_name": { + "description": "Nom membre", + "type": "string" + }, + "status": { + "description": "Statut", + "type": "string", + "enum": ["draft", "waiting", "payable", "paid", "cancelled"] + }, + "id_transaction": { + "type": "integer" + }, + "payments": { + "type": "array", + "items": { + "type": "integer" + } + } + }, + "required": ["type", "number", "label", "date", "user_id", "user_name", "status"] +} diff --git a/src/modules/expenses_claims/config.html b/src/modules/expenses_claims/config.html new file mode 100644 index 0000000..9528152 --- /dev/null +++ b/src/modules/expenses_claims/config.html @@ -0,0 +1,49 @@ +{{#restrict block=true section="config" level="admin"}}{{/restrict}} +{{:include file="./_config_default.tpl" keep="vehicles"}} + +{{#form on="save"}} + {{:save key="config" notify_users=$_POST.notify_users|arrayval}} + {{:redirect to="./config.html?msg=ok"}} +{{/form}} + +{{:admin_header title="Configuration des notes de frais"}} + +{{if $_GET.msg === 'ok'}} +

    La configuration a été enregistrée.

    +{{/if}} + +{{:form_errors}} + + + +{{#list type="category" schema="./category.schema.json" columns="label" order="label" disable_user_ordering=true}} + + {{$label}} + + {{:linkbutton shape="edit" href="./cat_edit.html?key=%s"|args:$key label="Modifier"}} + {{:linkbutton shape="delete" href="./cat_delete.html?key=%s"|args:$key label="Supprimer" target="_dialog"}} + + +{{/list}} + +
    + +
    + Notifications +
    + {{:input type="list" name="notify_users" source=$module.config default=$config.org_email label="Membres à notifier lors du dépôt d'une nouvelle note de frais" help="Si ce champ est laissé vide, la notification sera envoyée à l'adresse e-mail de l'association. Maximum 10 membres." required=false multiple=true target="!users/selector.php" max=10}} +
    +
    + +

    + {{:button type="submit" name="save" label="Enregistrer" shape="right" class="main"}} +

    + +
    + +{{:admin_footer}} \ No newline at end of file diff --git a/src/modules/expenses_claims/delete.html b/src/modules/expenses_claims/delete.html new file mode 100644 index 0000000..4e72b1b --- /dev/null +++ b/src/modules/expenses_claims/delete.html @@ -0,0 +1,53 @@ +{{#load assign="claim" key=$_GET.claim}} +{{else}} + {{:error message="Cette note de frais n'existe pas"}} +{{/load}} + +{{#restrict section="accounting" level="write"}} + {{:assign is_admin=true}} +{{else}} + {{:assign is_admin=false}} + {{if $claim.user_id !== $logged_user.id}} + {{:error message="L'accès à cette note de frais est interdit"}} + {{/if}} +{{/restrict}} + +{{if $claim.status === 'draft' || $claim.status === 'cancelled'}} + {{:assign can_delete=true}} +{{/if}} + +{{if !$is_admin && !$can_delete}} + {{:error message="Vous n'avez pas le droit de supprimer cette note de frais"}} +{{/if}} + +{{:assign var="upload_path" value=$claim.key}} + +{{#form on="delete"}} + {{if !$can_delete && !$_POST.confirm_delete}} + {{:error message="La case de confirmation n'est pas cochée"}} + {{/if}} + + {{:delete_file path=$upload_path}} + {{:delete type="line" claim=$claim.key}} + {{:delete type="claim" key=$claim.key}} + {{:http redirect="./"}} +{{/form}} + +{{:admin_header title="Supprimer note de frais n°%d"|args:$claim.number}} + +{{if !$can_delete}} + {{:assign confirm="Cette note de frais a été acceptée, cocher cette case pour supprimer la note de frais (les écritures liées ne seront pas supprimées)"}} +{{/if}} + +{{if $claim.id_transaction}} + {{:assign info="Attention : l'écriture #%d liée à la note de frais ne sera pas supprimée."|args:$claim.id_transaction}} +{{/if}} + +{{:delete_form + legend="Supprimer une note de frais" + warning="Supprimer la note de frais n°%d ?"|args:$claim.number + confirm=$confirm + info=$info +}} + +{{:admin_footer}} \ No newline at end of file diff --git a/src/modules/expenses_claims/deny.html b/src/modules/expenses_claims/deny.html new file mode 100644 index 0000000..6e6b842 --- /dev/null +++ b/src/modules/expenses_claims/deny.html @@ -0,0 +1,58 @@ +{{#restrict section="accounting" level="write" block=true}}{{/restrict}} + +{{#load assign="claim" key=$_GET.claim}} +{{else}} + {{:error message="Cette note de frais n'existe pas"}} +{{/load}} + +{{if $claim.status !== 'waiting'}} + {{:error message="Cette note de frais n'est pas en attente de validation: %s"|args:$claim.status}} +{{/if}} + +{{:assign var="upload_path" value=$claim.key}} + +{{#form on="save"}} + {{#users id=$claim.user_id}} + {{if !$_email}} + {{:break}} + {{/if}} + {{:include file="./messages/deny.txt" capture="message" message=$_POST.message|trim claim=$claim action=$_POST.action}} + {{:mail to=$_email subject="Votre note de frais a été refusée" body=$message|raw}} + {{/users}} + + {{if $_POST.action === 'delete'}} + {{:delete_file path=$upload_path}} + {{:delete type="line" claim=$claim.key}} + {{:delete type="claim" key=$claim.key}} + {{:redirect to="./"}} + {{elseif $_POST.action === 'cancel'}} + {{:save status="cancelled" key=$claim.key}} + {{else}} + {{:save status="draft" key=$claim.key}} + {{/if}} + + {{:redirect to="details.html?key=%s"|args:$claim.key}} +{{/form}} + +{{:admin_header title="Refuser la note de frais n°%d"|args:$claim.number}} + +{{:form_errors}} + +
    +
    + Refuser la note de frais ? +
    + {{:input type="textarea" name="message" cols=50 rows=5 label="Motif de refus" help="Ce message sera envoyé au membre à l'origine de la note de frais."}} +
    (obligatoire)
    + {{:input type="radio" name="action" value="draft" label="Repasser la note de frais en brouillon" help="Le membre pourra la modifier et la soumettre à nouveau." default="draft"}} + {{:input type="radio" name="action" value="cancel" label="Annuler la note de frais" help="Le membre ne pourra pas la modifier, il ne pourra que la supprimer."}} + {{:input type="radio" name="action" value="delete" label="Supprimer la note de frais" help="La note de frais et ses pièces jointes seront supprimées."}} +
    +
    + +

    + {{:button type="submit" shape="right" label="Refuser" name="save" class="main"}} +

    +
    + +{{:admin_footer}} \ No newline at end of file diff --git a/src/modules/expenses_claims/details.html b/src/modules/expenses_claims/details.html new file mode 100644 index 0000000..d7cb83b --- /dev/null +++ b/src/modules/expenses_claims/details.html @@ -0,0 +1,227 @@ +{{if $_GET.key}} + {{#load assign="claim" key=$_GET.key}}{{/load}} +{{elseif $_GET.num}} + {{#load assign="claim" number=$_GET.num|intval}}{{/load}} +{{/if}} + +{{if !$claim}} + {{:error message="Cette note de frais n'existe pas"}} +{{/if}} + +{{#restrict section="accounting" level="write"}} + {{:assign is_admin=true}} +{{else}} + {{:assign is_admin=false}} + {{if $claim.user_id !== $logged_user.id}} + {{:error message="L'accès à cette note de frais est interdit"}} + {{/if}} +{{/restrict}} + +{{:assign var="upload_path" value=$claim.key}} + +{{if $claim.status === 'draft'}} + {{#form on="validate"}} + {{:save key=$claim.key status="waiting"}} + {{:include file="./messages/validate.txt" claim=$claim capture="message"}} + + {{if $module.config.notify_users|count}} + {{#users id=$module.config.notify_users|keys}} + {{:assign var="notify_emails." value=$_email}} + {{/users}} + {{else}} + {{:assign var="notify_emails." value=$config.org_email}} + {{/if}} + {{:mail to=$notify_emails subject="Une nouvelle note de frais a été soumise" body=$message|raw}} + {{:http redirect="details.html?key=%s"|args:$claim.key}} + {{/form}} + + {{#form on="delete_line"}} + {{:delete type="line" claim=$claim.key key=$_POST.delete_line}} + {{:http redirect="details.html?key=%s"|args:$claim.key}} + {{/form}} + +{{elseif $claim.status === 'waiting'}} + {{if $claim.user_id === $logged_user.id}} + {{#form on="cancel"}} + {{:save key=$claim.key status="cancelled"}} + {{:http redirect="details.html?key=%s"|args:$claim.key}} + {{/form}} + {{/if}} +{{/if}} + +{{:admin_header title="Note de frais n°%d — %s"|args:$claim.number:$claim.label}} + +
    + + + +
    + +{{:form_errors}} + +{{#load select="SUM($$.amount) AS total" where="$$.type = 'line' AND $$.claim = :claim" :claim=$claim.key}} + {{:assign total=$total}} +{{/load}} + +
    + +{{if $claim.status === 'draft' && $total}} +
    +

    Statut : brouillon

    +

    {{:button shape="check" name="validate" label="Valider" type="submit" class="main"}}

    +

    En cliquant sur ce bouton, la note de frais sera transmise aux comptables, elle ne pourra plus être modifiée.

    +
    +{{elseif $claim.status === 'waiting'}} +
    +

    Statut : en attente de validation

    +

    {{:button shape="delete" name="cancel" label="Annuler" type="submit"}}

    +

    En cliquant sur ce bouton, la note de frais sera annulée.

    +
    +{{/if}} + + +{{if $is_admin}} + {{if $claim.status === 'waiting'}} +
    + Accepter cette note de frais ? +

    {{:linkbutton href="accept.html?claim=%s"|args:$claim.key shape="check" label="Accepter cette note de frais" class="main"}}

    +

    Une confirmation sera demandée. Il sera possible de modifier les comptes associés.

    +

    {{:linkbutton href="deny.html?claim=%s"|args:$claim.key shape="delete" label="Refuser cette note de frais"}}

    +
    + {{/if}} +{{/if}} + +
    +
    Numéro de note de frais
    +
    {{$claim.number}}
    +
    Objet
    +
    {{$claim.label|or:"— Non spécifié —"}}
    +
    Date
    +
    {{$claim.date|date_short}}
    +
    Membre
    +
    + {{if $claim.user_name && $claim.user_id && $is_admin}} + {{:link href="!users/details.php?id=%d"|args:$claim.user_id label=$claim.user_name}} + {{else}} + {{$claim.user_name}} + {{/if}} +
    +
    Statut
    +
    + {{if $claim.status === 'draft'}} + Brouillon + {{elseif $claim.status === 'waiting'}} + En attente d'acceptation + {{elseif $claim.status === 'payable'}} + Acceptée, en attente de paiement + {{elseif $claim.status === 'paid'}} + Payée + {{elseif $claim.status === 'cancelled'}} + Annulée + {{/if}} +
    + +
    Montant total
    +
    {{$total|money_currency:false:false}}
    + + {{if $claim.id_transaction}} +
    Écriture de note de frais
    +
    {{:link class="num" href="!acc/transactions/details.php?id=%d"|args:$claim.id_transaction label="#%d"|args:$claim.id_transaction}}
    + {{/if}} + + + {{#foreach from=$claim.transactions item="id"}} +
    Écriture de paiement
    +
    {{:link class="num" href="!acc/transactions/details.php?id=%d"|args:$id label="#%d"|args:$id}}
    + {{/foreach}} +
    + +{{if $claim.status === 'draft'}} +

    + {{:linkbutton shape="plus" label="Ajouter une ligne à la note de frais" href="./line.html?claim=%s"|args:$claim.key target="_dialog"}} +

    +{{/if}} +{{#list + select="$$.label AS 'Libellé'; $$.description AS 'Description'; $$.reference AS 'Réf. justificatif'; $$.category AS 'Catégorie'; $$.amount AS 'Montant'" + order=1 + desc=false + where="$$.type = 'line' AND $$.claim = :claim" + :claim=$claim.key +}} + + {{$label}} + {{$description|escape|nl2br}} + {{$reference}} + {{$category}} + {{$amount|money_currency}} + + {{if $.claim.status === 'draft'}} + {{:button name="delete_line" type="submit" value=$key label="Supprimer" shape="delete"}} + {{/if}} + + +{{else}} +

    + Aucune ligne dans cette note de frais.
    +

    +{{/list}} +
    + +{{if $claim.status === 'payable' || $claim.status === 'paid'}} +

    Les fichiers joints ont été déplacés dans l'écriture de note de frais {{:link class="num" href="!acc/transactions/details.php?id=%d"|args:$claim.id_transaction label="#%d"|args:$claim.id_transaction}}

    +

    Paiements (remboursements)

    +{{else}} + {{if $claim.status === 'cancelled'}} + {{:assign edit=true upload=false}} + {{elseif $claim.user_id === $logged_user.id && $claim.status === 'draft'}} + {{:assign edit=true upload=true}} + {{elseif $is_admin}} + {{:assign edit=true upload=true}} + {{else}} + {{:assign edit=false upload=false}} + {{/if}} + {{:admin_files edit=$edit upload=$upload path=$upload_path use_trash=false}} +{{/if}} + +{{if $claim.payments}} + + + + + + + + + + + {{#transactions id=$claim.payments}} + + + + + + + {{/transactions}} + +
    Num.DateLibelléMontant
    {{:link class="num" href="!acc/transactions/details.php?id=%d"|args:$id label="#%d"|args:$id}}{{$date|date_short}}{{$label}}{{$credit|money_currency}}
    +{{/if}} + +{{if $claim.status === 'payable'}} +

    + {{:linkbutton shape="plus" label="Saisir un paiement" href="payment.html?claim=%s"|args:$claim.key target="_dialog"}} + {{:linkbutton shape="reload" label="Transformer en abandon de frais" href="abandon.html?claim=%s"|args:$claim.key target="_dialog"}} +

    +{{/if}} + +{{:admin_footer}} diff --git a/src/modules/expenses_claims/duplicate.html b/src/modules/expenses_claims/duplicate.html new file mode 100644 index 0000000..682ccb6 --- /dev/null +++ b/src/modules/expenses_claims/duplicate.html @@ -0,0 +1,79 @@ +{{#load assign="claim" key=$_GET.claim}} +{{else}} + {{:error message="Cette note de frais n'existe pas"}} +{{/load}} + +{{#restrict section="accounting" level="write"}} + {{:assign is_admin=true}} +{{else}} + {{:assign is_admin=false}} +{{/restrict}} + +{{#form on="save"}} + {{#load select="MAX($$.number) AS number" type="claim" assign="max"}} + {{/load}} + {{:assign new_number="%d+1"|math:$max.number}} + {{:assign new_key=""|uuid}} + + {{if $is_admin}} + {{#foreach from=$_POST.user key="id" item="name"}} + {{:assign user_id=$id user_name=$name}} + {{else}} + {{:error message="Aucun membre sélectionné"}} + {{/foreach}} + {{else}} + {{:assign user_id=$logged_user.id user_name=$logged_user._name}} + {{/if}} + + {{:save + validate_schema="./claim.schema.json" + key=$new_key + number=$new_number + type="claim" + label=$_POST.label|trim|or:null + date=$now|date_format:"%Y-%m-%d" + user_id=$user_id + user_name=$user_name + status="draft" + }} + {{#load type="line" claim=$claim.key}} + {{:save validate_schema="./line.schema.json" + key="uuid" + type="line" + claim=$new_key + label=$label + category=$category + account=$account|strval|or:null + description=$description + amount=$amount + reference=$reference + + }} + {{/load}} + {{:redirect to="./details.html?key=%s"|args:$new_key}} +{{/form}} + +{{:admin_header title="Dupliquer une note de frais"}} + +{{:form_errors}} + +
    +
    + Dupliquer la note de frais n°{{$claim.number}} +
    + {{if $is_admin}} + {{:assign var="me.%d"|args:$logged_user.id value=$logged_user._name}} + {{:input type="list" name="user" label="Membre" required=true default=$me target="!users/selector.php"}} + {{else}} +
    Membre
    +
    {{$logged_user._name}}
    + {{/if}} + {{:input type="text" name="label" label="Objet de la note de frais" required=false help="Courte description de l'objet de la note de frais." source=$claim}} +
    +
    +

    + {{:button type="submit" shape="right" label="Dupliquer" name="save" class="main"}} +

    +
    + +{{:admin_footer}} diff --git a/src/modules/expenses_claims/edit.html b/src/modules/expenses_claims/edit.html new file mode 100644 index 0000000..845d6fb --- /dev/null +++ b/src/modules/expenses_claims/edit.html @@ -0,0 +1,41 @@ +{{#restrict section="accounting" level="write"}} + {{:assign is_admin=true}} +{{else}} + {{:assign is_admin=false}} +{{/restrict}} + +{{#load assign="claim" key=$_GET.claim}} +{{else}} + {{:error message="Cette note de frais n'existe pas"}} +{{/load}} + +{{if !$is_admin && $claim.status !== 'draft'}} + {{:error message="Cette note de frais n'est pas en brouillon"}} +{{/if}} + +{{#form on="save"}} + {{:save + validate_schema="./claim.schema.json" + key=$claim.key + label=$_POST.label|trim|or:null + }} + {{:redirect to="./details.html?key=%s"|args:$key}} +{{/form}} + +{{:admin_header title="Modifier la note de frais"}} + +{{:form_errors}} + +
    +
    + Modifier la note de frais +
    + {{:input type="text" name="label" label="Objet de la note de frais" required=false help="Courte description de l'objet de la note de frais." source=$claim}} +
    +
    +

    + {{:button type="submit" shape="right" label="Enregistrer" name="save" class="main"}} +

    +
    + +{{:admin_footer}} \ No newline at end of file diff --git a/src/modules/expenses_claims/icon.svg b/src/modules/expenses_claims/icon.svg new file mode 100644 index 0000000..ae23317 --- /dev/null +++ b/src/modules/expenses_claims/icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/modules/expenses_claims/index.html b/src/modules/expenses_claims/index.html new file mode 100644 index 0000000..ccc14f6 --- /dev/null +++ b/src/modules/expenses_claims/index.html @@ -0,0 +1,57 @@ +{{:admin_header title="Notes de frais"}} + + + +{{:assign where="$$.user_id = %d"|args:$logged_user.id}} + +{{#restrict section="accounting" level="write"}} + {{if $_GET.status === 'all'}} + {{:assign where="1"}} + {{elseif $_GET.status}} + {{:assign status=$_GET.status|quote_sql}} + {{:assign where="$$.status = %s"|args:$status}} + {{/if}} +{{/restrict}} + +{{#list + select="$$.number AS 'Numéro'; $$.user_name AS 'Membre'; $$.date AS 'Date'; $$.label AS 'Libellé'; CASE $$.status WHEN 'waiting' THEN 'À accepter' WHEN 'payable' THEN 'À payer' WHEN 'paid' THEN 'Payée' WHEN 'cancelled' THEN 'Annulée' ELSE 'Brouillon' END AS 'Statut'"|args:$config.user_fields.name_sql + order=1 + desc=true + where="$$.type = 'claim' AND %s"|args:$where +}} + + {{:link href="details.html?key=%s"|args:$key label=$number}} + {{$col2}} + {{$date|date_short}} + {{$col4}} + {{$col5}} + + {{:linkbutton href="details.html?key=%s"|args:$key label="Ouvrir" shape="menu"}} + + +{{else}} +

    + Aucune note de frais ici. +

    +{{/list}} + +{{:admin_footer}} diff --git a/src/modules/expenses_claims/line.html b/src/modules/expenses_claims/line.html new file mode 100644 index 0000000..6745c72 --- /dev/null +++ b/src/modules/expenses_claims/line.html @@ -0,0 +1,189 @@ +{{#load assign="claim" key=$_GET.claim}} +{{else}} + {{:error message="Cette note de frais n'existe pas"}} +{{/load}} + +{{if $claim.status !== 'draft'}} + {{:error message="Cette note de frais n'est pas un brouillon, il n'est pas possible de la modifier."}} +{{/if}} + +{{:include file="./_config_default.tpl" keep="module.config,vehicles"}} + +{{:assign var="categories_select.other" value="Autre"}} + +{{#load type="category" assign="categories." order="$$.label COLLATE U_NOCASE"}} + {{:assign var="categories_select[%s]"|args:$key value=$label}} +{{/load}} + +{{#form on="save"}} + {{if !$_POST.label|trim}} + {{:error message="Le libellé ne peut être laissé vide"}} + {{/if}} + + {{if $_POST.category !== 'other'}} + {{#load type="category" key=$_POST.category assign="cat"}} + {{else}} + {{:error message="Catégorie inconnue"}} + {{/load}} + {{/if}} + + {{if $cat.expense_type === 'km_vehicle'}} + {{if !$_POST.vehicle|trim}} + {{:error message="Aucun véhicule sélectionné"}} + {{elseif $_POST.distance|intval < 1}} + {{:error message="La distance parcourue ne peut être inférieure à 1 km"}} + {{/if}} + + {{:include file="./_calcul_bareme.tpl" vehicule=$_POST.vehicle distance=$_POST.distance keep="resultat,calcul,bonus"}} + {{if $bonus > 1}} + {{:assign bonus="+20% (véhicule électrique)"}} + {{else}} + {{:assign bonus="Non"}} + {{/if}} + {{:assign var="vehicle_name" from="vehicles.%s"|args:$_POST.vehicle}} + {{:assign + amount=$resultat + description="Départ : %s\nArrivée : %s\nVéhicule : %s\nDistance : %s km\nCalcul : %s\nBonus : %s\n\n%s"|args:$_POST.depart:$_POST.arrivee:$vehicle_name:$_POST.distance:$calcul:$bonus:$_POST.description|trim + }} + {{elseif $cat.expense_type === 'km_free'}} + {{if $_POST.distance|intval < 1}} + {{:error message="La distance parcourue ne peut être inférieure à 1 km"}} + {{/if}} + + {{:assign bareme=$cat.price|money_currency:false:false:false}} + {{:assign + amount="%d*%d"|math:$_POST.distance:$cat.price|money_raw + description="Départ : %s\nArrivée : %s\nDistance : %s km\nBarème : %s par km\n\n%s"|args:$_POST.depart:$_POST.arrivee:$_POST.distance:$bareme:$_POST.description|trim + }} + {{/if}} + + {{:save + validate_schema="./line.schema.json" + key="uuid" + type="line" + claim=$claim.key + label=$_POST.label|trim + category=$cat.label|or:'Autre' + account=$cat.account|keys|key:0|strval|or:null + description=$description|or:$_POST.description|trim|or:null + amount=$amount|or:$_POST.amount|money_int + reference=$_POST.reference|trim|or:null + }} + {{:http redirect="details.html?key=%s"|args:$claim.key}} +{{/form}} + +{{:admin_header title="Ajouter une ligne à la note de frais n°%d"|args:$claim.number}} + +{{:form_errors}} + +
    +
    + Dépense +
    + {{:input type="text" name="label" label="Libellé" help="Inclure ici une description courte, par exemple 'Abonnement fibre atelier, mars 2023'." required=true}} + {{:input type="select" name="category" options=$categories_select label="Catégorie" required=true default_empty="— Sélectionner une catégorie —"}} +
    +
    + {{:input type="textarea" name="description" label="Description"}} +
    + {{:input type="text" name="reference" label="Référence du justificatif" help="Par exemple : numéro de facture, nom du magasin, etc."}} +
    +
    + {{:input type="select" name="vehicle" options=$vehicles label="Type de véhicule" required=true}} +
    -- +
    +
    +
    + {{:input type="text" name="depart" label="Lieu de départ" required=true}} + {{:input type="text" name="arrivee" label="Lieu d'arrivée" required=true}} + {{:input type="number" name="distance" options=$vehicles label="Distance parcourue" required=true min=1 step=1 size=5}} +
    + Le barème sera calculé lors de l'enregistrement, en fonction de la distance et du type de véhicule. +
    +
    +
    + {{:input type="money" name="amount" label="Montant total" required=true}} +
    +
    + +

    + {{:button type="submit" shape="right" label="Enregistrer" name="save" class="main"}} +

    +
    + + + +{{:admin_footer}} diff --git a/src/modules/expenses_claims/line.schema.json b/src/modules/expenses_claims/line.schema.json new file mode 100644 index 0000000..1be6db9 --- /dev/null +++ b/src/modules/expenses_claims/line.schema.json @@ -0,0 +1,38 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["line"] + }, + "claim": { + "type": "string" + }, + "label": { + "description": "Libellé", + "type": ["string"] + }, + "account": { + "description": "Code comptable", + "type": ["string", "null"] + }, + "category": { + "description": "Catégorie", + "type": ["string", "null"] + }, + "description": { + "description": "Description", + "type": ["string", "null"] + }, + "amount": { + "description": "Montant", + "type": "integer" + }, + "reference": { + "description": "Réf.", + "type": ["string", "null"] + } + }, + "required": ["type", "claim", "label", "account", "category", "description", "amount", "reference"] +} diff --git a/src/modules/expenses_claims/messages/accept.txt b/src/modules/expenses_claims/messages/accept.txt new file mode 100644 index 0000000..879854b --- /dev/null +++ b/src/modules/expenses_claims/messages/accept.txt @@ -0,0 +1,6 @@ +{{**keep_whitespaces**}} +Bonjour, + +votre note de frais n°{{$claim.number}} a été acceptée. + +Cordialement. \ No newline at end of file diff --git a/src/modules/expenses_claims/messages/deny.txt b/src/modules/expenses_claims/messages/deny.txt new file mode 100644 index 0000000..feb453a --- /dev/null +++ b/src/modules/expenses_claims/messages/deny.txt @@ -0,0 +1,25 @@ +{{**keep_whitespaces**}} +Bonjour, + +votre note de frais n°{{$claim.number}} a été refusée pour le motif suivant : + +{{if !$message}}(aucun motif indiqué){{else}}{{$message|raw}}{{/if}} + +{{if $action === 'delete'}} +La note de frais a été supprimée. +{{elseif $action === 'draft'}} +La note de frais est repassée en brouillon. + +Vous pouvez la corriger et re-demander sa validation ici : + +{{$module.url|raw}}details.html?key={{$claim.key}} +{{elseif $action === 'cancel'}} +La note de frais a été annulée. + +Vous pouvez la supprimer à cette adresse : + +{{$module.url|raw}}details.html?key={{$claim.key}} +{{/if}} + + +Cordialement. \ No newline at end of file diff --git a/src/modules/expenses_claims/messages/validate.txt b/src/modules/expenses_claims/messages/validate.txt new file mode 100644 index 0000000..5f4ed8d --- /dev/null +++ b/src/modules/expenses_claims/messages/validate.txt @@ -0,0 +1,11 @@ +{{**keep_whitespaces**}} +Bonjour, + +Une nouvelle note de frais a été soumise par un membre. + +Cliquez sur ce lien pour la consulter, puis l'accepter ou la refuser : + +{{$module.url|raw}}details.html?key={{$claim.key}} + + +Cordialement. \ No newline at end of file diff --git a/src/modules/expenses_claims/module.ini b/src/modules/expenses_claims/module.ini new file mode 100644 index 0000000..8415240 --- /dev/null +++ b/src/modules/expenses_claims/module.ini @@ -0,0 +1,7 @@ +name="Notes de frais" +description="Permet aux membres de créer des notes de frais, et ensuite aux personnes gérant la comptabilité de les valider et transformer en écritures comptables." +author="Paheko" +author_url="https://paheko.cloud/" +home_button=true +restrict_section="connect" +restrict_level="read" diff --git a/src/modules/expenses_claims/new.html b/src/modules/expenses_claims/new.html new file mode 100644 index 0000000..6ae255c --- /dev/null +++ b/src/modules/expenses_claims/new.html @@ -0,0 +1,60 @@ +{{#restrict section="accounting" level="write"}} + {{:assign is_admin=true}} +{{else}} + {{:assign is_admin=false}} +{{/restrict}} + +{{#form on="save"}} + {{#load select="MAX($$.number) AS number" type="claim" assign="max"}} + {{/load}} + {{:assign new_number="%d+1"|math:$max.number}} + {{:assign key=""|uuid}} + + {{if $is_admin}} + {{#foreach from=$_POST.user key="id" item="name"}} + {{:assign user_id=$id user_name=$name}} + {{else}} + {{:error message="Aucun membre sélectionné"}} + {{/foreach}} + {{else}} + {{:assign user_id=$logged_user.id user_name=$logged_user._name}} + {{/if}} + + {{:save + validate_schema="./claim.schema.json" + key=$key + number=$new_number + type="claim" + label=$_POST.label|trim|or:null + date=$now|date_format:"%Y-%m-%d" + user_id=$user_id + user_name=$user_name + status="draft" + }} + {{:redirect to="./details.html?key=%s"|args:$key}} +{{/form}} + +{{:admin_header title="Nouvelle note de frais"}} + +{{:form_errors}} + +
    +
    + Nouvelle note de frais +
    + {{if $is_admin}} + {{:assign var="me.%d"|args:$logged_user.id value=$logged_user._name}} + {{:input type="list" name="user" label="Membre" required=true default=$me target="!users/selector.php"}} + {{else}} +
    Membre
    +
    {{$logged_user._name}}
    + {{/if}} + {{:input type="text" name="label" label="Objet de la note de frais" required=false help="Courte description de l'objet de la note de frais."}} +
    +
    +

    + {{:button type="submit" shape="right" label="Créer" name="save" class="main"}} +

    +
    + +{{:admin_footer}} diff --git a/src/modules/expenses_claims/payment.html b/src/modules/expenses_claims/payment.html new file mode 100644 index 0000000..3726587 --- /dev/null +++ b/src/modules/expenses_claims/payment.html @@ -0,0 +1,108 @@ +{{#restrict section="accounting" level="write" block=true}}{{/restrict}} + +{{#load assign="claim" key=$_GET.claim}} +{{else}} + {{:error message="Cette note de frais n'existe pas"}} +{{/load}} + +{{if $claim.status !== 'payable'}} + {{:error message="Cette note de frais n'est pas en attente de paiement: %s"|args:$claim.status}} +{{/if}} + +{{#form on="save"}} + {{:assign var="lines." credit=$_POST.amount account=$_POST.account|keys|key:0}} + {{:assign var="lines." debit=$_POST.amount account="4110"}} + {{:api + method="POST" + path="accounting/transaction" + assign="result" + + id_year="match" + type="advanced" + date=$_POST.date + label=$_POST.label + reference="Remboursement NDF-%d"|args:$claim.number + linked_users=$claim.user_id + lines=$lines + linked_transactions=$claim.id_transaction|intval + }} + + {{if $_POST.paid}} + {{:assign new_status="paid"}} + {{else}} + {{:assign new_status=$claim.status}} + {{/if}} + + {{:assign var="claim.payments." value=$result.id}} + {{:save + validate_schema="./claim.schema.json" + payments=$claim.payments + key=$claim.key + status=$new_status + }} + {{:redirect to="./details.html?key=%s"|args:$claim.key}} +{{/form}} + +{{#transactions id=$claim.id_transaction}} + {{:assign total=$debit}} +{{/transactions}} + +{{if $claim.payments|count}} + {{#transactions id=$claim.payments}} + {{:assign total="%d-%d"|math:$total:$credit}} + {{/transactions}} +{{/if}} + +{{if $claim.label}} + {{:assign default_label="Remboursement note de frais n°%d : %s"|args:$claim.number:$claim.label}} +{{else}} + {{:assign default_label="Remboursement note de frais n°%d"|args:$claim.number}} +{{/if}} + +{{#years closed=false}} + {{:assign var="open_years.%d"|args:$id value=$label}} + {{if $start_date <= $now && $end_date >= $now}} + {{:assign best_year=$id}} + {{/if}} +{{/years}} + +{{if !$open_years|count}} + {{:error message="Aucun exercice n'est ouvert, il n'est pas possible de créer une écriture et donc d'accepter cette note de frais."}} +{{/if}} + +{{:admin_header title="Nouveau paiement"}} + +{{:form_errors}} + +
    +
    + Nouveau paiement de remboursement +
    + {{:input type="select" default=$best_year name="id_year" label="Exercice" required=true options=$open_years}} + {{:input type="date" default=$now name="date" label="Date" required=true}} + {{:input type="text" default=$default_label name="label" label="Libellé" required=true}} + {{:input type="money" name="amount" label="Montant" required=true default=$total}} + {{:input type="list" name="account" label="Compte de paiement" required=true target="!acc/charts/accounts/selector.php?targets=1:2:3&key=code&year=%d"|args:$best_year}} + {{:input type="checkbox" name="paid" value=1 default=1 label="Marquer cette note de frais comme payée"}} +
    +
    +

    + {{:button type="submit" shape="right" label="Enregistrer" name="save" class="main"}} +

    +
    + + + + +{{:admin_footer}} \ No newline at end of file diff --git a/src/modules/expenses_claims/snippets/transaction_details.html b/src/modules/expenses_claims/snippets/transaction_details.html new file mode 100644 index 0000000..9377f33 --- /dev/null +++ b/src/modules/expenses_claims/snippets/transaction_details.html @@ -0,0 +1,7 @@ +{{if $transaction.reference|regexp_match:"/\bNDF-\d+\b/"}} + {{:assign var="num" value=$transaction.reference|regexp_replace:"/^.*\bNDF-(\d+)\b.*$/":"$1"|intval}} +

    {{$module.label}}

    +

    + {{:linkbutton href="%sdetails.html?num=%d"|args:$module.url:$num label="Voir la note de frais associée" shape="eye"}} +

    +{{/if}} \ No newline at end of file diff --git a/src/modules/membership_card/_carte.html b/src/modules/membership_card/_carte.html new file mode 100644 index 0000000..7290e7e --- /dev/null +++ b/src/modules/membership_card/_carte.html @@ -0,0 +1,65 @@ +{{if $module.config.photo}} + {{:assign var="_photo" from="%s.0"|args:$module.config.photo}} +{{/if}} + + + {{if $module.config.logo == 1 && $config.files.logo}} + + {{elseif $module.config.logo == 2 && $config.files.logo}} + + {{/if}} + + {{if $_photo}} + + {{/if}} + +
    + {{if $module.config.fields|has:$number_field}} + + {{/if}} + + {{if $module.config.header}} +
    + {{$module.config.header|markdown|raw}} +
    + {{/if}} + +

    {{$_name}}

    + + {{if $module.config.fields}} +
      + {{if $module.config.fields|has:"_category"}} +
    • + {{if $module.config.show_fields_names}}Catégorie :{{/if}} + {{#select name FROM users_categories WHERE id = {$id_category|intval};}} + {{$name}} + {{/select}} +
    • + {{/if}} + {{#select name, label, type FROM config_users_fields}} + {{if !$module.config.fields|has:$name}} + {{:continue}} + {{/if}} + {{:assign var="value" from=$name}} + {{if $value && $name != $number_field}} +
    • + {{if $module.config.show_fields_names}}{{$label}} :{{/if}} + {{:user_field name=$name value=$value}} +
    • + {{/if}} + {{/select}} +
    + {{/if}} + + {{if $module.config.id_service}} + {{#subscriptions user=$id id_service=$module.config.id_service active=true}} +

    Jusqu'au {{$expiry_date|date_short}}

    + {{else}} + {{#subscriptions user=$id id_service=$module.config.id_service active=false}} +

    Expiré

    + {{/subscriptions}} + {{/subscriptions}} + {{/if}} + +
    + \ No newline at end of file diff --git a/src/modules/membership_card/carte.css b/src/modules/membership_card/carte.css new file mode 100644 index 0000000..c111284 --- /dev/null +++ b/src/modules/membership_card/carte.css @@ -0,0 +1,142 @@ +@page single { + size: 85mm 55mm; + margin: 0; + padding: 0; +} + +html, * { margin: 0; padding: 0; } +body { + font-family: sans-serif; + font-size: 10pt; + overflow: hidden; +} + +main { + display: flex; + flex-wrap: wrap; + grid-gap: 10mm; + align-items: center; + justify-content: center; +} + +main.preview { + background: #fff; + padding: 2em; +} + +main.preview article { + box-shadow: 2px 2px 10px #000; +} + +main.single { + page: single; +} + +.sheet { + grid-gap: 0; + padding: .5em 0; + background: #fff; +} + +.sheet table { + border-collapse: collapse; +} + +.sheet td { + border: 2px dashed #ccc; +} + +article { + background: #fff; + display: block; + position: relative; + width: 85mm; + height: 55mm; + overflow: hidden; + break-inside: avoid; +} +article > div { + padding: 2.5mm; +} +article.with-images > div { + padding-right: calc(1mm + 100px); +} +ul { + list-style: none; +} + +.logo { + max-width: 100px; + max-height: 100px; + position: absolute; + right: 0; + bottom: 0; +} +.photo { + max-width: 100px; + max-height: 100px; + position: absolute; + right: 0; + top: 0; + z-index: 100; +} +.bglogo { + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0; + height: 100%; + margin: 0 auto; + z-index: 10; + opacity: 0.2; +} +.content { + margin-bottom: .5em; + z-index: 1000; +} +h1 { + font-size: 14pt; + margin-bottom: .2em; +} +.number { + position: absolute; + right: 5px; + top: 5px; + border-radius: 50%; + height: 15mm; + width: 15mm; + background: #666; + color: #fff; + display: flex; + align-items: center; + flex-direction: column; + justify-content: center; + border: 3px solid #fff; + z-index: 10000; +} +.number.with-photo { + bottom: 5px; + top: auto; +} +.number.with-photo.with-logo { + right: calc(100px / 2); + top: calc(55mm / 2 - 15mm / 2); +} +.number span:nth-child(1) { + font-size: 8pt; + margin-top: -.7em; +} +.number span:nth-child(2) { + font-size: 14pt; +} + +h3 { + margin-top: .5em; + font-weight: normal; + font-size: 11pt; +} + +.web-content p { + margin-bottom: .4rem; +} \ No newline at end of file diff --git a/src/modules/membership_card/carte.html b/src/modules/membership_card/carte.html new file mode 100644 index 0000000..53d3e94 --- /dev/null +++ b/src/modules/membership_card/carte.html @@ -0,0 +1,68 @@ +{{#restrict section="users" level="read"}} + {{:assign var="id" value=$_GET.id|intval}} +{{else}} + {{* On autorise l'utilisateur à voir sa propre carte de membre *}} + {{if $logged_user.id}} + {{:assign var="id" value=$logged_user.id}} + {{else}} + {{:error message="Vous n'avez pas accès à cette page."}} + {{/if}} +{{/restrict}} + +{{if !$id}} + {{:error message="Aucun numéro de membre n'a été fourni"}} +{{/if}} + +{{if null === $module.config}} + {{:assign var="module.config" + header="**%s**\n%s"|args:$config.org_name:$config.org_address + logo=2 + fields=null + photo="photo" + id_service=null + }} +{{/if}} + +{{#select name FROM config_users_fields WHERE system & (1 << 3) LIMIT 1}} + {{:assign var="number_field" value=$name}} +{{/select}} + +{{#users id=$_GET.id}} + {{:assign title="%s - Carte de membre"|args:$_name}} + + {{if $_GET.print == 'pdf'}} + {{:http type="pdf" download="%s.pdf"|args:$title}} + {{/if}} + + + + {{$title}} + + + + + + {{if $_GET.mode == 'print'}} +
    + {{elseif $_GET.mode == 'preview'}} +
    + {{/if}} + + {{:include file="./_carte.html"}} + + {{if $_GET.mode != 'embed'}} +
    + {{/if}} + + {{if $_GET.print == 'yes'}} + + {{/if}} + + + +{{else}} + {{:error message="Le numéro de membre fourni n'existe pas."}} +{{/users}} diff --git a/src/modules/membership_card/config.html b/src/modules/membership_card/config.html new file mode 100644 index 0000000..894a00e --- /dev/null +++ b/src/modules/membership_card/config.html @@ -0,0 +1,94 @@ +{{:admin_header title="Configuration des cartes de membres"}} + +{{#form on="save"}} + {{:save key="config" + validate_schema="./config.schema.json" + header=$_POST.header|trim|or:null + logo=$_POST.logo|intval + fields=$_POST.fields|or:null + photo=$_POST.photo|or:null + id_service=$_POST.id_service|intval|or:null + show_fields_names=$_POST.show_fields_names|boolval + email_subject=$_POST.email_subject|trim + email_body=$_POST.email_body|trim + }} + {{:redirect to="?ok=1"}} +{{/form}} + +{{if $_GET.ok}} +

    Configuration enregistrée.

    +{{/if}} + +{{:form_errors}} + +
    + +
    + Configuration du modèle de carte de membre +
    + {{:input type="textarea" cols="30" rows="4" name="header" required=false source=$module.config label="En-tête, à afficher en haut de la carte" default="**%s**\n%s"|args:$config.org_name:$config.org_address}} +
    Syntaxe Markdown acceptée. + {{:linkbutton shape="help" label="Aide de la syntaxe Markdown" href="!static/doc/markdown.html"}} +
    + +
    + {{:input type="radio" name="logo" value=0 source=$module.config label="Ne pas afficher" default=1}} + {{:input type="radio" name="logo" value=1 source=$module.config label="En petit en bas à droite" default=1}} + {{:input type="radio" name="logo" value=2 source=$module.config label="En filigrane (fond)"}} + +
    Champs des fiches membre à afficher sur la carte de membre
    +
    (Le nom du membre est toujours affiché.)
    + {{if $module.config.fields|has:"_category"}} + {{:assign var="checked" value="_category"}} + {{else}} + {{:assign var="checked" value=null}} + {{/if}} + + {{:input type="checkbox" name="fields[]" value="_category" label="Catégorie" default=$checked}} + + {{#select * FROM config_users_fields WHERE type NOT IN ('file', 'password') AND system & (1 << 4) = 0 ORDER BY sort_order}} + {{if $module.config.fields|has:$name}} + {{:assign var="checked" value=$name}} + {{else}} + {{:assign var="checked" value=null}} + {{/if}} + {{:input type="checkbox" name="fields[]" value=$name label=$label default=$checked}} + {{/select}} + +
    Nom des champs
    + {{:input type="checkbox" name="show_fields_names" value=1 label="Afficher le nom des champs" source=$module.config}} +
    + Si coché, le nom du champ sera affiché avant sa valeur. + Exemple : Nom : Elsa Triolet +
    + + {{:assign var="file_fields." value="— Aucun, ne pas afficher de photo —"}} + {{#select name, label FROM config_users_fields WHERE type = 'file' ORDER BY sort_order;}}{{:assign var="file_fields.%s"|args:$name value=$label}}{{/select}} + + {{if $file_fields|count > 1}} + {{:input type="select" name="photo" label="Champ utilisé pour la photo du membre" options=$file_fields required=true source=$module.config}} + {{/if}} + + {{:assign var="services." value="— Aucune, ne pas afficher d'activité —"}} + {{#select id, label FROM services WHERE end_date IS NULL OR end_date >= date() ORDER BY label COLLATE U_NOCASE;}}{{:assign var="services.%s"|args:$id value=$label}}{{/select}} + {{if $services|count}} + {{:input type="select" name="id_service" label="Activité à afficher" options=$services required=true help="Utile pour afficher par exemple la date d'expiration de la cotisation annuelle." source=$module.config}} + {{/if}} +
    +
    + +
    + Message par défaut lors de l'envoi de la carte par e-mail +
    + {{:input name="email_subject" type="text" label="Sujet du message" required=true source=$module.config default="Votre carte de membre"}} + {{:input name="email_body" type="textarea" label="Corps du message" cols="70" rows="7" required=true source=$module.config default="Bonjour !\n\nVeuillez trouver ci-joint votre carte de membre au format PDF."}} +
    +
    + +

    + {{:button type="submit" name="save" label="Enregistrer" shape="right" class="main"}} +

    + +
    + +{{:admin_footer}} \ No newline at end of file diff --git a/src/modules/membership_card/config.schema.json b/src/modules/membership_card/config.schema.json new file mode 100644 index 0000000..c571467 --- /dev/null +++ b/src/modules/membership_card/config.schema.json @@ -0,0 +1,40 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "header": { + "description": "En-tête", + "type": ["string", "null"] + }, + "logo": { + "description": "Affichage du logo", + "type": "integer", + "enum": [0, 1, 2] + }, + "fields": { + "description": "Champs à afficher", + "type": ["null", "array"], + "items": { + "type": "string" + } + }, + "show_fields_names": { + "type": ["boolean"] + }, + "photo": { + "description": "Champ pour la photo", + "type": ["string", "null"] + }, + "id_service": { + "description": "Activité", + "type": ["integer", "null"] + }, + "email_subject": { + "type": "string" + }, + "email_body": { + "type": "string" + } + }, + "required": [ "header", "logo", "fields", "show_fields_names", "photo", "id_service", "email_subject", "email_body" ] +} \ No newline at end of file diff --git a/src/modules/membership_card/envoyer.html b/src/modules/membership_card/envoyer.html new file mode 100644 index 0000000..6592929 --- /dev/null +++ b/src/modules/membership_card/envoyer.html @@ -0,0 +1,49 @@ +{{#restrict section="users" level="read" block=true}}{{/restrict}} + +{{#users id=$_GET.id assign="user"}} +{{else}} + {{:error message="Ce membre n'existe pas"}} +{{/users}} + +{{if !$user._email}} + {{:error message="Ce membre ne dispose pas d'adresse e-mail"}} +{{/if}} + +{{:admin_header title="Envoyer une carte de membre"}} + +{{#form on="send"}} + {{:mail + to=$user._email + subject=$_POST.email_subject|trim + body=$_POST.email_body|trim + attach_from="./carte.html?id=%d&print=pdf&mode=print"|args:$user.id + }} + {{:redirect to="envoyer.html?id=%d&sent"|args:$user.id}} +{{/form}} + +{{if $_GET.sent}} +

    La carte a bien été envoyée par e-mail.

    +{{/if}} + +{{:form_errors}} + +
    +
    + Envoyer une carte de membre +
    + +
    +
    +
    Destinataire
    +
    {{$user._name}}
    + {{:input name="email_subject" type="text" source=$module.config default="Votre carte de membre" label="Sujet du message" required=true}} + {{:input name="email_body" type="textarea" cols="70" rows=7 source=$module.config default="Bonjour !\n\nVeuillez trouver ci-joint votre carte de membre au format PDF." label="Corps du message" required=true}} +
    +
    +

    + {{:button type="submit" label="Envoyer" name="send" shape="right" class="main"}} +

    +
    + + +{{:admin_footer}} diff --git a/src/modules/membership_card/icon.svg b/src/modules/membership_card/icon.svg new file mode 100644 index 0000000..1a9f7f2 --- /dev/null +++ b/src/modules/membership_card/icon.svg @@ -0,0 +1 @@ + diff --git a/src/modules/membership_card/index.html b/src/modules/membership_card/index.html new file mode 100644 index 0000000..6aa3d88 --- /dev/null +++ b/src/modules/membership_card/index.html @@ -0,0 +1,54 @@ +{{#restrict section="users" level="read"}} + {{:admin_header title="Cartes de membres"}} + + {{#restrict section="config" level="admin"}} + + + + {{/restrict}} + +
    +
    + Créer une planche de cartes de membres +
    + {{:input type="radio-btn" name="target" value="category" default="category" label="Tous les membres d'une catégorie"}} + {{:input type="radio-btn" name="target" value="some" label="Seulement certains membres" help="Sélectionnez cette option pour sélectionner les membres"}} +
    +
    + {{#select id, name FROM users_categories WHERE hidden = 0 ORDER BY name COLLATE NOCASE}} + {{:assign var="categories.%d"|args:$id value=$name}} + {{/select}} + {{:input type="select" name="category" options=$categories label="Catégorie" required=true}} +
    +
    + {{:input type="list" target="!users/selector.php" multiple=true required=true label="Membres à faire figurer" name="users"}} +
    +
    +

    + {{if $pdf_enabled}} + {{:button type="submit" label="Générer en PDF" name="pdf" shape="pdf" class="main"}} + {{/if}} + {{:button type="submit" label="Prévisualiser" name="preview" shape="eye"}} + {{:button type="submit" label="Imprimer la planche" name="print" shape="print"}} +

    +
    + + + +{{else}} + {{:admin_header title="Ma carte de membre"}} + + {{:include file="./snippets/user_details.html" user=$logged_user}} + +{{/restrict}} + +{{:admin_footer}} diff --git a/src/modules/membership_card/module.ini b/src/modules/membership_card/module.ini new file mode 100644 index 0000000..c0bd353 --- /dev/null +++ b/src/modules/membership_card/module.ini @@ -0,0 +1,5 @@ +name="Carte de membre" +description="Impression de carte de membre, à l'unité, par planche de plusieurs membres, ou export en PDF." +author="Paheko" +author_url="https://paheko.cloud/" +home_button=true diff --git a/src/modules/membership_card/planche.html b/src/modules/membership_card/planche.html new file mode 100644 index 0000000..288a876 --- /dev/null +++ b/src/modules/membership_card/planche.html @@ -0,0 +1,54 @@ +{{#restrict section="users" level="read" block=true}}{{/restrict}} +{{if $_POST.category}} + {{#select id FROM users WHERE id_category = :category ORDER BY !name; :category=$_POST.category|intval !name=$config.user_fields.name_sql}} + {{:assign var="users." value=$id}} + {{$_name}} + {{/select}} +{{else}} + {{:assign users=$_POST.users|keys}} +{{/if}} +{{:assign count=$users|count}} +{{:assign title="%d cartes de membre"|args:$count}} +{{if $_POST.pdf}} + {{:http type="pdf" inline="%s.pdf"|args:$title}} +{{/if}} + + + + {{$title}} + + + + + +{{#select name FROM config_users_fields WHERE system & (1 << 3) LIMIT 1}} + {{:assign var="number_field" value=$name}} +{{/select}} + +
    + + + {{:assign i=0}} + {{#users id=$users order=$config.user_fields.name_sql}} + {{if "%d %% 2"|math:$i == 0}} + + {{/if}} + + {{if "%d %% 2"|math:$i != 0}} + + {{/if}} + {{:assign i="%d+1"|math:$i}} + {{/users}} +
    {{:include file="./_carte.html"}}
    + +
    + +{{if $_POST.print}} + +{{/if}} + + + \ No newline at end of file diff --git a/src/modules/membership_card/snippets/my_details.html b/src/modules/membership_card/snippets/my_details.html new file mode 100644 index 0000000..0036597 --- /dev/null +++ b/src/modules/membership_card/snippets/my_details.html @@ -0,0 +1 @@ +{{:include file="./user_details.html"}} \ No newline at end of file diff --git a/src/modules/membership_card/snippets/user_details.html b/src/modules/membership_card/snippets/user_details.html new file mode 100644 index 0000000..90c33e3 --- /dev/null +++ b/src/modules/membership_card/snippets/user_details.html @@ -0,0 +1,16 @@ +

    {{$module.label}}

    +
    +
    + +
    + +

    + {{if $pdf_enabled}} + {{:linkbutton href="%scarte.html?id=%d&print=pdf&mode=print"|args:$module.url:$user.id label="Télécharger en PDF" shape="download"}} + {{/if}} + {{:linkbutton href="%scarte.html?id=%d&print=yes&mode=print"|args:$module.url:$user.id target="_blank" label="Imprimer" shape="print"}} + {{if $pdf_enabled && $user._email}} + {{:linkbutton href="%senvoyer.html?id=%d"|args:$module.url:$user.id target="_dialog" label="Envoyer" shape="mail"}} + {{/if}} +

    +
    diff --git a/src/modules/openings/_all.html b/src/modules/openings/_all.html new file mode 100644 index 0000000..c8b304f --- /dev/null +++ b/src/modules/openings/_all.html @@ -0,0 +1,46 @@ +{{:include file="./_common.tpl" keep="months,days,frequencies"}} + + +{{if $config.open|count}} +
    +

    Horaires d'ouverture 

    + + + {{#foreach from=$config.open item="slot"}} + + + + + {{/foreach}} +
    + {{if $slot.frequency != "this"}} + {{$slot.frequency|replace:$frequencies}} + {{$slot.day|replace:$days}} + du mois + {{else}} + {{$slot.day|replace:$days}} + {{/if}} + {{$slot.open}} — {{$slot.close}}
    +
    +{{/if}} + + +{{if $config.closed|count}} +
    +

    Fermetures

    + +
      + {{#foreach from=$config.closed item="slot"}} +
    • + {{if $slot.close_day === $slot.reopen_day && $slot.close_month === $slot.reopen_month}} + le {{$slot.close_day}} {{$slot.close_month|replace:$months}} + {{else}} + du {{$slot.close_day}} {{$slot.close_month|replace:$months}} + au {{$slot.reopen_day}} {{$slot.reopen_month|replace:$months}} inclus + {{/if}} + {{if $slot.reason|trim}}({{$slot.reason}}){{/if}} +
    • + {{/foreach}} +
    +
    +{{/if}} diff --git a/src/modules/openings/_common.tpl b/src/modules/openings/_common.tpl new file mode 100644 index 0000000..f73d2f2 --- /dev/null +++ b/src/modules/openings/_common.tpl @@ -0,0 +1,34 @@ +{{:assign var="days" + monday = "lundi" + tuesday = "mardi" + wednesday = "mercredi" + thursday = "jeudi" + friday = "vendredi" + saturday = "samedi" + sunday = "dimanche" +}} + +{{:assign var="frequencies" + this = "tous les" + first = "premier" + second = "second" + third = "troisième" + fourth = "quatrième" + fifth = "cinquième" + last = "dernier" +}} + +{{:assign var="months" + january = 'janvier' + february = 'février' + march = 'mars' + april = 'avril' + may = 'mai' + june = 'juin' + july = 'juillet' + august = 'août' + september = 'septembre' + october = 'octobre' + november = 'novembre' + december = 'décembre' +}} \ No newline at end of file diff --git a/src/modules/openings/_next_opening.html b/src/modules/openings/_next_opening.html new file mode 100644 index 0000000..630c90e --- /dev/null +++ b/src/modules/openings/_next_opening.html @@ -0,0 +1,72 @@ +{{:assign now="now"|strtotime}} +{{:assign today=$now|date:'Ymd'}} + +{{#foreach from=$config.closed item="slot"}} + {{:assign + start="%s %s, 00:00:00"|args:$slot.close_day:$slot.close_month|strtotime + end="%s %s, 23:59:59"|args:$slot.reopen_day:$slot.reopen_month|strtotime + }} + {{if $end < $start}} + {{:assign + end="%s %s, 23:59:59, +1 year"|args:$slot.reopen_day:$slot.reopen_month|strtotime + }} + {{/if}} + {{if $now >= $start && $now <= $end}} +

    Actuellement fermé jusqu'au {{$end|date_long}} inclus

    + {{:assign stop=1 closed=1}} + {{:break}} + {{/if}} +{{/foreach}} + +{{if !$stop}} + {{#foreach from=$config.open item="slot"}} + {{if $slot.frequency != "this"}} + {{:assign + start="%s %s of this month, %s:00"|args:$slot.frequency:$slot.day:$slot.open|strtotime + end="%s %s of this month, %s:00"|args:$slot.frequency:$slot.day:$slot.close|strtotime + }} + {{* Try next month *}} + {{if $start < $now && $end < $now}} + {{:assign + start="%s %s of next month, %s:00"|args:$slot.frequency:$slot.day:$slot.open|strtotime + end="%s %s of next month, %s:00"|args:$slot.frequency:$slot.day:$slot.close|strtotime + }} + {{/if}} + {{else}} + {{:assign + start="%s %s, %s:00"|args:$slot.frequency:$slot.day:$slot.open|strtotime + end="%s %s, %s:00"|args:$slot.frequency:$slot.day:$slot.close|strtotime + }} + + {{* Try next week *}} + {{if $end < $now}} + {{:assign + start="next %s, %s:00"|args:$slot.day:$slot.open|strtotime + end="next %s, %s:00"|args:$slot.day:$slot.close|strtotime + }} + {{/if}} + {{/if}} + {{if $now >= $start && $now <= $end}} +

    Ouvert aujourd'hui jusqu'à {{$slot.close}}

    + {{:assign stop=1}} + {{:break}} + {{elseif $end < $now}} + {{:continue}} + {{/if}} + + {{:assign diff="%d-%d"|math:$now:$start}} + {{if $diff > $closest}} + {{:assign closest=$diff closest_slot=$slot closest_date=$start}} + {{/if}} + {{/foreach}} +{{/if}} + +{{if !$stop && $closest}} + {{if $closest_date|date:'Ymd' == $now|date:'Ymd'}} +

    Prochaine ouverture : aujourd'hui à {{$closest_slot.open}}

    + {{elseif $closest_date|date:'Ymd' == "tomorrow"|strtotime|date:'Ymd'}} +

    Prochaine ouverture : demain à {{$closest_slot.open}}

    + {{else}} +

    Prochaine ouverture : le {{$closest_date|strftime:"%A %e %B"}} à {{$closest_slot.open}}

    + {{/if}} +{{/if}} \ No newline at end of file diff --git a/src/modules/openings/config.html b/src/modules/openings/config.html new file mode 100644 index 0000000..266aae2 --- /dev/null +++ b/src/modules/openings/config.html @@ -0,0 +1,104 @@ +{{#restrict section="config" level="admin" block=true}}{{/restrict}} +{{:admin_header title="Configuration des horaires d'ouverture"}} + +{{if $_POST.save}} + {{#foreach from=$_POST.slots|array_transpose key="i" item="slot"}} + {{:assign line="%d+1"|math:$i}} + {{:assign open=$slot.open|explode:':' close=$slot.close|explode:':'}} + {{:assign var="slot" + day=$slot.day + frequency=$slot.frequency + open='%02d:%02d'|args:$open.0:$open.1 + close='%02d:%02d'|args:$close.0:$close.1 + }} + {{* Use timestamp to order slots *}} + {{:assign timestamp="%s %s of this month, %s:00"|args:$slot.frequency:$slot.day:$slot.open|strtotime}} + + {{if !$timestamp}} + {{:error message="Date invalide : %s %s of this month, %s:00"|args:$slot.frequency:$slot.day:$slot.open}} + {{/if}} + + {{:assign var="slots.%d"|args:$timestamp value=$slot}} + + {{if !"%s %s"|args:$slot.frequency:$slot.day|trim|strtotime}} + {{:assign error="Ouvertures - ligne %d : le sélecteur de jour est invalide: %s %s"|args:$line:$slot.frequency:$slot.day}} + {{:break}} + {{elseif !$slot.open|regexp_match:'/^(2[0-3]|[01][0-9]):([0-5][0-9])$/'}} + {{:assign error="Ouvertures - ligne %d: heure d'ouverture invalide."|args:$line}} + {{:break}} + {{elseif !$slot.close|regexp_match:'/^(2[0-3]|[01][0-9]):([0-5][0-9])$/'}} + {{:assign error="Ouvertures - ligne %d: heure de fermeture invalide."|args:$line}} + {{:break}} + {{/if}} + {{/foreach}} + + {{if $error}} +

    {{$error}}

    + {{else}} + {{:save key="config" + validate_schema="./schema.json" + open=$slots|ksort|values + closed=$_POST.closed|array_transpose + }} + {{:http redirect="?ok=1"}} + {{/if}} +{{/if}} + +{{if $_GET.ok}} +

    Configuration enregistrée.

    +{{/if}} + +
    + +
    + Heures d'ouvertures +

    Indiquer ici les jours et heures d'ouverture. Ils apparaîtront sur le site web.

    + + + + + + + + + + + + +
    OccurrenceJourDeÀ
    +

    {{:button shape="plus" label="Ajouter une ligne"}}

    +
    + +
    + Jours de fermeture exceptionnelle +

    Indiquer ici les périodes de fermeture pour jours fériés, congés, etc.

    + + + + + + + + + + + +
    DuAuRaison (facultatif)
    +

    {{:button shape="plus" label="Ajouter une ligne"}}

    +
    + +

    + {{:button type="submit" name="save" label="Enregistrer" shape="right" class="main"}} +

    + + {{:include file="./_common.tpl" keep="months,frequencies,days"}} + + +
    + +{{:admin_footer}} \ No newline at end of file diff --git a/src/modules/openings/config.js b/src/modules/openings/config.js new file mode 100644 index 0000000..a9f3c3d --- /dev/null +++ b/src/modules/openings/config.js @@ -0,0 +1,152 @@ +if (!open_data) { + open_data = { + 'closed': [{'close_day': '25', 'close_month': 'december', 'reopen_day': '2', 'reopen_month': 'january'}], + 'open': [{ + 'frequency': 'this', + 'day': 'saturday', + 'open': '15:00', + 'close': '19:00' + }] + }; +} + +const open_row = ` + + + + + + + + + + + + + + + +`; + +const close_row = ` + + + + + + + + inclus + + + + + + + +`; + +const populate_select = (s, data) => { + Object.entries(data).forEach((e) => { + const [k, v] = e; + var o = ``; + s.insertAdjacentHTML('beforeend', o); + }); +} + +const add_slot_row = (data) => { + $('fieldset.slots table tbody')[0].insertAdjacentHTML('beforeend', open_row); + var r = $('fieldset.slots table tbody tr:last-child')[0]; + r.querySelector('button').onclick = removeRow; + var s = r.querySelectorAll('select'); + populate_select(s[0], frequencies); + populate_select(s[1], days); + + if (!data) { + return; + } + + Object.entries(data).forEach((e) => { + const [k, v] = e; + r.querySelector('[name*=' + k + ']').value = !v ? '' : v; + }); +}; + +const add_closed_row = (data) => { + $('fieldset.closed table tbody')[0].insertAdjacentHTML('beforeend', close_row); + var r = $('fieldset.closed table tbody tr:last-child')[0]; + r.querySelector('button').onclick = removeRow; + + var s = r.querySelectorAll('select'); + populate_select(s[0], months); + populate_select(s[1], months); + + if (!data) { + return; + } + + Object.entries(data).forEach((e) => { + const [k, v] = e; + r.querySelector('[name*=' + k + ']').value = !v ? '' : v; + }); +}; + +$('fieldset.slots p.actions button')[0].onclick = () => { + var rows = $('fieldset.slots table tbody tr'); + + if (!rows.length) { + add_slot_row(); + return; + } + + var n = rows[rows.length - 1].cloneNode(true); + n.querySelector('button').onclick = removeRow; + rows[0].parentNode.appendChild(n); +}; + +$('fieldset.closed p.actions button')[0].onclick = () => { + var rows = $('fieldset.closed table tbody tr'); + + if (!rows.length) { + add_closed_row(); + return; + } + + var n = rows[rows.length - 1].cloneNode(true); + n.querySelector('button').onclick = removeRow; + rows[0].parentNode.appendChild(n); + +} + +open_data.open.forEach((slot) => { + add_slot_row(slot); +}); + +open_data.closed.forEach((slot) => { + add_closed_row(slot); +}); + + +function removeRow(e) { + var row = e.target.parentNode.parentNode; + var table = row.parentNode.parentNode; + + if (table.rows.length <= 2) + { + return false; + } + + row.parentNode.removeChild(row); + return false; +} + +function addRow(e) { + var table = e.parentNode.parentNode.querySelector('table'); + var row = table.rows[table.rows.length-1]; + row.parentNode.appendChild(row.cloneNode(true)); + return false; +} diff --git a/src/modules/openings/icon.svg b/src/modules/openings/icon.svg new file mode 100644 index 0000000..8163f63 --- /dev/null +++ b/src/modules/openings/icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/modules/openings/module.ini b/src/modules/openings/module.ini new file mode 100644 index 0000000..70652fa --- /dev/null +++ b/src/modules/openings/module.ini @@ -0,0 +1,4 @@ +name="Horaires d'ouverture" +description="Permet d'afficher sur la page d'accueil les jours et horaires d'ouverture" +author="Paheko" +author_url="https://paheko.cloud/" diff --git a/src/modules/openings/schema.json b/src/modules/openings/schema.json new file mode 100644 index 0000000..fdf27d0 --- /dev/null +++ b/src/modules/openings/schema.json @@ -0,0 +1,74 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "closed": { + "description": "Jours de fermeture", + "type": "array", + "items": { + "type": "object", + "properties": { + "day_close": { + "description": "Numéro du jour", + "type": "integer", + "minimum": 1, + "maximum": 31 + }, + "month_close": { + "description": "Nom du jour", + "type": "string", + "enum": ["january", "february", "march", "april", "may", "june", + "july", "august", "september", "october", "november", "december"] + }, + "day_reopen": { + "description": "Numéro du jour", + "type": "integer", + "minimum": 1, + "maximum": 31 + }, + "month_reopen": { + "description": "Nom du jour", + "type": "string", + "enum": ["january", "february", "march", "april", "may", "june", + "july", "august", "september", "october", "november", "december"] + } + } + } + }, + "open": { + "description": "Jours d'ouverture", + "type": "array", + "items": { + "type": "object", + "properties": { + "frequency": { + "description": "Fréquence", + "type": "string", + "enum": ["this", "first", "second", "third", "fourth", "fifth", "last"] + }, + "day": { + "description": "Jour", + "type": "string", + "enum": ["monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"] + }, + "open": { + "description": "Heure d'ouverture", + "type": "string", + "pattern": "^(2[0-3]|[01][0-9]):([0-5][0-9])$", + "minimum": 0 + }, + "close": { + "description": "Heure de fermeture", + "type": "string", + "pattern": "^(2[0-3]|[01][0-9]):([0-5][0-9])$", + "minimum": 0 + } + }, + "required": ["frequency", "day", "open", "close"] + }, + "minItems": 0, + "maxItems": 100 + } + }, + "required": [ "closed", "open" ] +} \ No newline at end of file diff --git a/src/modules/openings/snippets/home_button.html b/src/modules/openings/snippets/home_button.html new file mode 100644 index 0000000..ac8f766 --- /dev/null +++ b/src/modules/openings/snippets/home_button.html @@ -0,0 +1,3 @@ +{{#restrict section="web" level="write"}} + {{:linkbutton label="Heures d'ouverture" href="%sconfig.html"|args:$module.url icon="%sicon.svg"|args:$module.url}} +{{/restrict}} \ No newline at end of file diff --git a/src/modules/receipt/_footer.html b/src/modules/receipt/_footer.html new file mode 100644 index 0000000..33a261e --- /dev/null +++ b/src/modules/receipt/_footer.html @@ -0,0 +1,13 @@ +
    + + + +{{if $_GET.print == 'yes'}} + +{{/if}} + + + \ No newline at end of file diff --git a/src/modules/receipt/_header.html b/src/modules/receipt/_header.html new file mode 100644 index 0000000..e19396c --- /dev/null +++ b/src/modules/receipt/_header.html @@ -0,0 +1,67 @@ +{{if $_GET.print === 'pdf'}} + {{:http type="pdf" download="%s.pdf"|args:$title}} +{{/if}} + + + + {{$title}} + + + + + + +{{if !$_GET.print && !$hide_buttons}} +
    + {{if $back_link && !$dialog}} + {{:linkbutton href=$back_link label="Retour" shape="left"}} + {{/if}} + {{:linkbutton href="%s&print=yes"|args:$request_url label="Imprimer" target="_blank" shape="print"}} + {{if $pdf_enabled}} + {{:linkbutton href="%s&print=pdf"|args:$request_url label="Télécharger en PDF" shape="download"}} + {{/if}} + {{$buttons|raw}} +
    +{{/if}} + +
    + +{{if !$hide_header}} +
    + {{if $config.files.logo}} + + {{/if}} +
    +

    {{$config.org_name}}

    + {{if $config.org_infos}}

    {{$config.org_infos|escape|nl2br}}

    {{/if}} +

    {{$config.org_address|replace:"\n":" — "}}

    +

    + {{if $config.org_phone}}{{$config.org_phone}}{{/if}} + {{if $config.org_email}}{{$config.org_email}}{{/if}} + {{if $config.org_web}}{{$config.org_web}}{{/if}} +

    +
    +
    +{{/if}} + +
    \ No newline at end of file diff --git a/src/modules/receipt/form.css b/src/modules/receipt/form.css new file mode 100644 index 0000000..3ba88f7 --- /dev/null +++ b/src/modules/receipt/form.css @@ -0,0 +1,270 @@ +@font-face { + font-family: 'paheko'; + src: url('../../admin/static/font/paheko.eot'); + src: url('../../admin/static/font/paheko.eot#iefix') format('embedded-opentype'), + url('../../admin/static/font/paheko.woff') format('woff'), + url('../../admin/static/font/paheko.woff2') format('woff2'), + url('../../admin/static/font/paheko.ttf') format('truetype'), + url('../../admin/static/font/paheko.svg#paheko') format('svg'); + font-weight: normal; + font-style: normal; +} + +@media print { + @page { + margin: 1cm; + size: A4; + @bottom { + content: "Page " counter(page) " / " counter(pages); + font-size: 8pt; + margin-bottom: 10mm; + text-align: center; + } + } + + #buttons { + display: none; + } +} + +@media screen { + html, body { + background: #666; + } + #page { + position: relative; + margin: 0 auto; + padding: 1cm; + background: #fff; + width: 210mm; + height: 297mm; + } +} + +body, div, dl, dt, dd, ul, ol, li, h1, h2, h3, h4, h5, h6, +pre, form, fieldset, input, textarea, p, blockquote, th, td, +figure, article, aside, section, header, footer { + padding: 0; + margin: 0; +} +fieldset, img { + border: 0; +} +table { + border-collapse: collapse; + border-spacing: 0; +} +caption, th { + text-align: left; +} +th, td { + vertical-align: top; +} + +h1 { font-size: 2em; } +h2 { font-size: 1.5em; } +h3 { font-size: 1.2em; } +h4 { font-size: 1em; } +h5 { font-size: 0.9em; } +h6 { font-size: 0.8em; } + +dl, ul, ol, h1, h2, h3, h4, h5, h6, pre, p, blockquote, figure, table { + margin-bottom: .75rem; +} + +body { + font-family: Arial, Helvetica, sans-serif; + color: #000; + font-size: 10pt; +} + +header.organization { + display: flex; + justify-content: center; + align-items: center; + margin-bottom: 1rem; +} + +header.organization + main h1:first-child { + border-top: 2px solid #999; + padding-top: 1rem; +} + +header.organization * { + margin: 0; + padding: 0; +} + +header.organization .logo img { + margin-right: 1em; + max-height: 80px; +} + +header.organization h1, header.organization h2 { + margin-bottom: .5rem; +} + +header.organization h1 { + font-size: 1.2rem; +} +header.organization h2 { + font-size: 1rem; + font-weight: normal; +} +header.organization h3 { + font-size: .8rem; + font-weight: normal; +} +header.organization h4 { + font-size: .8rem; + font-weight: normal; + display: flex; +} +header.organization h4 a { + display: block; + margin-right: 1rem; +} + +#buttons { + text-align: center; +} + +#buttons a { + margin: 1em; + font-size: 1.2em; +} + + +a.icn-btn { + cursor: pointer; + color: #fff; + border: 1px solid #ccc; + background: #333; + user-select: none; + display: inline-block; + font-size: inherit; + border-radius: .2em; + padding: .2em .4em; + margin: .2em .5em; + white-space: pre; + transition: box-shadow .2s; + text-decoration: underline; +} + +a.icn-btn:hover { + text-decoration: none; + box-shadow: 0px 0px 5px orange; +} + +[data-icon]:before, .main[data-icon]:after { + display: inline-block; + font-family: "paheko", sans-serif; + text-shadow: 1px 1px 1px #000; + padding-right: .5em; + font-size: 1.2em; + line-height: .8em; + vertical-align: middle; + content: attr(data-icon); + font-weight: normal; +} + +table { + border-collapse: collapse; + width: 100%; +} + +table th, table td { + padding: .2em; + border: 1px solid #999; + text-align: left; +} + +td.money { + text-align: right; +} + +.watermark { + position: absolute; + font-size: 180pt; + color: rgba(0, 0, 0, 0.2); + top: 0; + left: 0; + right: 0; + bottom: 0; + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; +} + +.watermark-red { + color: rgba(128, 0, 0, 0.4); +} + +.watermark div { + transform: rotate(-45deg); +} + +figure.signature { + text-align: center; +} + +figure.signature img { + max-height: 2.5cm; +} + +.placeholder { + border-bottom: 1px dashed #666; + min-height: 1em; +} + +.info.block { + border: 1px solid black; + padding: .5em; + margin: 1rem 0; +} + +.info.block h2 { + border-bottom: 2px solid #000; +} + +.info.block *:last-child { + margin-bottom: 0; +} + +.info.block h4 strong { + background: yellow; + border: 1px solid #999; + padding: .2rem; +} + +.user.block { + border: 1px solid black; + padding: .2em; + margin: 1rem 0; +} + +td ul.checkboxes { + margin: 0; +} + +ul.checkboxes { + list-style: none; +} + +ul.checkboxes li { + margin-right: 1.5em; + display: inline-block; +} + +ul.checkboxes li::before { + content: ""; + width: 1.5em; + height: 1.5em; + vertical-align: middle; + background: url('data:image/svg+xml;utf8,'); + background-size: contain; + margin-right: .5em; + display: inline-block; +} \ No newline at end of file diff --git a/src/modules/receipt/icon.svg b/src/modules/receipt/icon.svg new file mode 100644 index 0000000..58b4b21 --- /dev/null +++ b/src/modules/receipt/icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/modules/receipt/index.html b/src/modules/receipt/index.html new file mode 100644 index 0000000..c56d650 --- /dev/null +++ b/src/modules/receipt/index.html @@ -0,0 +1,80 @@ +{{if $_GET.me}} + {{#transactions user=$logged_user.id}} + {{if $id == $_GET.me}} + {{:assign id=$id}} + {{:break}} + {{/if}} + {{/transactions}} + {{if !$id}} + {{:error message="Ce reçu n'existe pas"}} + {{/if}} +{{else}} + {{#restrict block=true section="accounting" level="read"}} + {{/restrict}} + {{:assign id=$_GET.id trusted=true}} +{{/if}} + +{{if !$id}} + + {{:admin_header title="Reçu de paiement" current="acc"}} + +

    Aucun numéro d'écriture n'a été fourni.

    + +
    +
    + Créer un reçu de paiement +
    + {{:input type="number" name="id" label="Numéro d'écriture" required=true}} +
    +
    + +

    + {{:button type="submit" name="save" label="Voir le reçu" shape="right" class="main"}} +

    +
    + + {{:admin_footer}} + +{{else}} + {{#transactions id=$id}} + {{if $trusted}} + {{:assign var="name" value=$_GET.name|or:$users_names}} + {{else}} + {{:assign var="name" value=$users_names}} + {{/if}} + + {{:include file="./_header.html" page_size="A5" title="Reçu de paiement %d - %s"|args:$id:$name}} + +

    Reçu de paiement

    + +

    Référence n°{{$id}} — {{$date|date_short}}

    + +

    L'association « {{$config.nom_asso}} » atteste avoir reçu de la part de :

    + +

    {{$name}}

    + +

    un paiement d'un montant de :

    + +

    {{$credit|raw|money_currency:false}}

    + +

    pour le motif suivant :

    + +

    {{$label}}

    + + {{if $notes}} +

    {{$notes|escape|nl2br}}

    + {{/if}} + +

    + Ce reçu n'est pas un reçu fiscal et ne donne pas droit à une réduction d'impôt au bénéfice des dispositions du code général des impôts. +

    + +

    (Reçu généré le {{$now|date_short}})

    + + {{:signature}} + + {{:include file="./_footer.html"}} + {{else}} + {{:error message="Le numéro d'écriture fourni n'existe pas."}} + {{/transactions}} +{{/if}} diff --git a/src/modules/receipt/module.ini b/src/modules/receipt/module.ini new file mode 100644 index 0000000..14d54f0 --- /dev/null +++ b/src/modules/receipt/module.ini @@ -0,0 +1,5 @@ +name="Reçu de paiement" +description="Reçu de paiement simple pour les écritures liées à des membres, et créditant un compte de produit, ou de tiers. Le reçu sera accessible sous chaque écriture comptable, et dans la page 'Mes activités' de chaque membre." +author="Paheko" +author_url="https://paheko.cloud/" +system=1 \ No newline at end of file diff --git a/src/modules/receipt/send.html b/src/modules/receipt/send.html new file mode 100644 index 0000000..677c8dc --- /dev/null +++ b/src/modules/receipt/send.html @@ -0,0 +1,39 @@ +{{:admin_header title="Envoyer un reçu"}} + +{{#transaction_users id_transaction=$_GET.id|intval limit=1 assign="user"}} +{{/transaction_users}} + +{{#form on="send"}} + {{:assign var="name" value=$_POST.name|urlencode}} + {{:mail + to=$_POST.email_to|trim + subject=$_POST.email_subject|trim + body=$_POST.email_body|trim + attach_from="./index.html?id=%d&print=pdf&name=%s"|args:$_GET.id:$name + }} + {{:redirect to="send.html?id=%d&sent"|args:$_GET.id}} +{{/form}} + +{{if $_GET.sent !== null}} +

    Le reçu a bien été envoyé par e-mail.

    +{{/if}} + +{{:form_errors}} + +
    +
    + Envoyer un reçu +
    + {{:input type="text" name="name" required=true default=$user._name label="Nom de la personne à inscrire sur le reçu"}} + {{:input type="email" name="email_to" required=true default=$user._email label="Adresse du destinataire"}} + {{:input name="email_subject" type="text" source=$module.config default="Votre reçu" label="Sujet du message" required=true}} + {{:input name="email_body" type="textarea" cols="70" rows=7 source=$module.config default="Bonjour !\n\nVeuillez trouver ci-joint votre reçu au format PDF." label="Corps du message" required=true}} +
    +
    +

    + {{:button type="submit" label="Envoyer" name="send" shape="right" class="main"}} +

    +
    + + +{{:admin_footer}} diff --git a/src/modules/receipt/snippets/my_services.html b/src/modules/receipt/snippets/my_services.html new file mode 100644 index 0000000..6727ac3 --- /dev/null +++ b/src/modules/receipt/snippets/my_services.html @@ -0,0 +1,21 @@ +

    Reçus de paiements

    + +{{:assign var="codes." value="4%"}} +{{:assign var="codes." value="7%"}} + + +{{#transactions user=$logged_user.id order="date DESC" credit_codes=$codes}} + + + + + + +{{/transactions}} +
    {{$date|date_short}}{{$label}}{{$credit|money_currency}} + {{if $pdf_enabled}} + {{:linkbutton href="%s?me=%d&print=pdf"|args:$module.url:$id shape="download" label="Télécharger le reçu (PDF)"}} + {{else}} + {{:linkbutton href="%s?me=%d"|args:$module.url:$id target="_dialog" shape="document" label="Voir le reçu"}} + {{/if}} +
    \ No newline at end of file diff --git a/src/modules/receipt/snippets/transaction_details.html b/src/modules/receipt/snippets/transaction_details.html new file mode 100644 index 0000000..aa3dc34 --- /dev/null +++ b/src/modules/receipt/snippets/transaction_details.html @@ -0,0 +1,22 @@ +{{if $show === null}} + {{#foreach from=$transaction_lines item="line"}} + {{if $line.account_code|regexp_match:'/^7|^4/' && $line.credit}} + {{:assign show=true}} + {{:break}} + {{/if}} + {{/foreach}} +{{/if}} + +{{if $show}} +

    {{$module.label}}

    +

    + {{:linkbutton href="%s?id=%d"|args:$module.url:$transaction.id target="_dialog" label="Prévisualiser" shape="eye"}} + {{if $pdf_enabled}} + {{:linkbutton href="%s?id=%d&print=pdf"|args:$module.url:$transaction.id label="Télécharger en PDF" shape="download"}} + {{/if}} + {{:linkbutton href="%s?id=%d&print=yes"|args:$module.url:$transaction.id target="_blank" label="Imprimer" shape="print"}} + {{if $pdf_enabled}} + {{:linkbutton href="%ssend.html?id=%d"|args:$module.url:$transaction.id target="_dialog" label="Envoyer" shape="mail"}} + {{/if}} +

    +{{/if}} \ No newline at end of file diff --git a/src/modules/receipt_donation/_upgrade.tpl b/src/modules/receipt_donation/_upgrade.tpl new file mode 100644 index 0000000..3fd9ba6 --- /dev/null +++ b/src/modules/receipt_donation/_upgrade.tpl @@ -0,0 +1,6 @@ +{{#foreach from=$module.config.accounts|explode:','|map:'trim' item="a"}} + {{:assign var="module.config.accounts.%s"|args:$a value=$a}} +{{/foreach}} +{{:save key="config" + accounts=$module.config.accounts|arrayval +}} diff --git a/src/modules/receipt_donation/config.html b/src/modules/receipt_donation/config.html new file mode 100644 index 0000000..fdc6de0 --- /dev/null +++ b/src/modules/receipt_donation/config.html @@ -0,0 +1,50 @@ +{{if $module.config.accounts|gettype === 'string'}} + {{:include file="./_upgrade.tpl" keep="module.config.accounts"}} +{{/if}} + +{{#form on="save"}} + {{:save key="config" + accounts=$_POST.accounts|arrayval + email_subject=$_POST.email_subject|trim + email_body=$_POST.email_body|trim + }} + {{:redirect to="?ok=1"}} +{{/form}} + +{{:admin_header title="Configuration du reçu de don"}} + +{{:form_errors}} + +{{if $_GET.ok}} +

    Configuration enregistrée.

    +{{/if}} + +
    + +
    + Configuration du modèle de reçu +
    + {{:assign var="default_account.754" value="754 — Ressources liées à la générosité du public"}} + {{:input required=false name="accounts" multiple=true target="!acc/charts/accounts/selector.php?targets=6&key=code" type="list" label="Comptes éligibles aux reçus de don" source=$module.config default=$default_account}} +
    + Le reçu de don sera proposé, en dessous de la fiche de l'écriture, pour chaque compte sélectionné et ses sous-compts.
    + Laisser vide pour que le reçu de don soit proposé quel que soit le compte. +
    +
    +
    + +
    + Message par défaut lors de l'envoi du reçu par e-mail +
    + {{:input name="email_subject" type="text" label="Sujet du message" required=true source=$module.config default="Votre reçu de don"}} + {{:input name="email_body" type="textarea" label="Corps du message" cols="70" rows="7" required=true source=$module.config default="Bonjour !\n\nVeuillez trouver ci-joint votre reçu de don au format PDF."}} +
    +
    + +

    + {{:button type="submit" name="save" label="Enregistrer" shape="right" class="main"}} +

    + +
    + +{{:admin_footer}} \ No newline at end of file diff --git a/src/modules/receipt_donation/icon.svg b/src/modules/receipt_donation/icon.svg new file mode 100644 index 0000000..6634792 --- /dev/null +++ b/src/modules/receipt_donation/icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/modules/receipt_donation/index.html b/src/modules/receipt_donation/index.html new file mode 100644 index 0000000..b6996f4 --- /dev/null +++ b/src/modules/receipt_donation/index.html @@ -0,0 +1,50 @@ +{{if !$_GET.id}} + + {{:admin_header title="Reçu de don" current="acc"}} + +

    Aucun numéro d'écriture n'a été fourni.

    + +
    +
    + Créer un reçu de don +
    + {{:input type="number" name="id" label="Numéro d'écriture" required=true}} +
    +
    + +

    + {{:button type="submit" name="save" label="Voir le reçu (PDF)" shape="right" class="main"}} +

    +
    + + {{:admin_footer}} + +{{else}} + {{#transactions id=$_GET.id}} + {{:assign var="name" value=$_GET.name|or:$users_names}} + {{:include file="/receipt/_header.html" page_size="A5" title="Reçu de don %d - %s"|args:$id:$name}} + +

    Reçu de don

    + +

    Référence n°{{$id}} — {{$date|date_short}}

    + +

    L'association « {{$config.nom_asso}} » atteste avoir reçu de la part de :

    + +

    {{$name}}

    + +

    un don d'un montant de :

    + +

    {{$credit|raw|money_currency:false}}

    +

    le {{$date|date_short}}

    + +

    + Ce reçu n'est pas un reçu fiscal et ne donne pas droit à une réduction d'impôt au bénéfice des dispositions des articles 200, 238 bis et 885-0 V bis A du code général des impôts. +

    + + {{:signature}} + + {{:include file="/receipt/_footer.html"}} + {{else}} + {{:error message="Le numéro d'écriture fourni n'existe pas."}} + {{/transactions}} +{{/if}} \ No newline at end of file diff --git a/src/modules/receipt_donation/module.ini b/src/modules/receipt_donation/module.ini new file mode 100644 index 0000000..4c0ddcd --- /dev/null +++ b/src/modules/receipt_donation/module.ini @@ -0,0 +1,6 @@ +name="Reçu de don" +description="Reçu de don simple, sans valeur fiscale" +author="Paheko" +author_url="https://paheko.cloud/" +restrict_section="accounting" +restrict_level="read" diff --git a/src/modules/receipt_donation/send.html b/src/modules/receipt_donation/send.html new file mode 100644 index 0000000..e686bb9 --- /dev/null +++ b/src/modules/receipt_donation/send.html @@ -0,0 +1 @@ +{{:include file="/receipt/send.html"}} diff --git a/src/modules/receipt_donation/snippets/transaction_details.html b/src/modules/receipt_donation/snippets/transaction_details.html new file mode 100644 index 0000000..15058cf --- /dev/null +++ b/src/modules/receipt_donation/snippets/transaction_details.html @@ -0,0 +1,36 @@ +{{if $module.config.accounts === null}} + {{:assign var="module.config.accounts.756" value="756"}} +{{elseif $module.config.accounts|gettype === 'string'}} + {{:include file="../_upgrade.tpl" keep="module.config.accounts"}} +{{/if}} + +{{#foreach from=$transaction_lines item="line"}} + {{if !$line.credit}} + {{:continue}} + {{/if}} + + {{#foreach from=$module.config.accounts key="code"}} + {{if $line.account_code|strpos:$code === 0}} + {{:assign show=true}} + {{:break}} + {{/if}} + {{/foreach}} + + {{if $show}} + {{:break}} + {{/if}} +{{/foreach}} + +{{if $show}} +

    {{$module.label}}

    +

    + {{:linkbutton href="%s?id=%d"|args:$module.url:$transaction.id target="_dialog" label="Prévisualiser" shape="eye"}} + {{if $pdf_enabled}} + {{:linkbutton href="%s?id=%d&print=pdf"|args:$module.url:$transaction.id label="Télécharger en PDF" shape="download"}} + {{/if}} + {{:linkbutton href="%s?id=%d&print=yes"|args:$module.url:$transaction.id target="_blank" label="Imprimer" shape="print"}} + {{if $pdf_enabled}} + {{:linkbutton href="%ssend.html?id=%d"|args:$module.url:$transaction.id target="_dialog" label="Envoyer" shape="mail"}} + {{/if}} +

    +{{/if}} diff --git a/src/modules/recus_fiscaux/_config_default.tpl b/src/modules/recus_fiscaux/_config_default.tpl new file mode 100644 index 0000000..1684aae --- /dev/null +++ b/src/modules/recus_fiscaux/_config_default.tpl @@ -0,0 +1,56 @@ +{{* Mise à jour depuis version 2022 vers 2023 *}} +{{if $module.config.comptes_don_nature && !$module.config.comptes_don_abandon_frais}} + {{:save key="config" + validate_schema="./config.schema.json" + comptes_don_abandon_frais=$module.config.comptes_don_nature + comptes_don_nature=null + numero_asso=$module.config.numero_asso + champ_entreprise_numero=$module.config.champ_entreprise_numero + champ_entreprise_forme=$module.config.champ_entreprise_forme + }} + {{:assign var="module.config.comptes_don_abandon_frais" value=$module.config.comptes_don_nature}} + {{:assign var="module.config.comptes_don_nature" value=null}} +{{/if}} + +{{if !$module.config}} + {{* Valeurs par défaut *}} + {{:assign var="module.config" + objet_asso="" + type_asso="defaut" + art200=false + art238=false + art978=false + email_subject="Votre reçu de don" + email_body="Bonjour !\n\nVeuillez trouver ci-joint votre reçu de don au format PDF." + }} + {{:assign var="module.config.comptes_don.754" value="754 — Ressources liées à la générosité du public"}} + {{:assign var="module.config.comptes_don_abandon_frais.75412" value="75412 — Abandons de frais par les bénévoles"}} + {{:assign var="module.config.comptes_especes.530" value="530 — Caisse"}} + {{:assign var="module.config.comptes_cheques.5112" value="5112 — Chèques à encaisser"}} + {{:assign var="module.config.champs_adresse" + adresse="adresse" + code_postal="code_postal" + ville="ville" + }} +{{/if}} + +{{:assign var="types_asso.defaut" + label="Organisme d'intérêt général ou reconnu d'utilité publique" + help="66 % du montant versé, dans la limite de 20 % du revenu imposable." + case="7UF" +}} +{{:assign var="types_asso.personnes" + label="Association fournissant gratuitement une aide alimentaire ou des soins médicaux à des personnes en difficultés ou favorisant leur logement" + help="75% du montant versé pour un don d'un montant inférieur ou égal à 1000 €.\nLa fraction au-delà de 1000 € ouvre droit à une réduction d'impôt de 66 % du montant donné." + case="7UD" +}} +{{:assign var="types_asso.cultuelle" + label="Association cultuelle ou de bienfaisance et établissement public reconnus d’Alsace-Moselle" + help="75 % du montant versé, dans la limite de 562 €. La fraction au-delà de 562 € ouvre droit à une réduction d'impôt de 66 %." + case="7UJ" +}} +{{:assign var="types_asso.syndicat" + label="Syndicat" + help="66% des cotisations versées, dans la limite de 1% du revenu brut imposable." + case="7AC" +}} diff --git a/src/modules/recus_fiscaux/_recu.html b/src/modules/recus_fiscaux/_recu.html new file mode 100644 index 0000000..9ce203c --- /dev/null +++ b/src/modules/recus_fiscaux/_recu.html @@ -0,0 +1,215 @@ +{{#restrict block=true section="accounting" level="write"}} +{{/restrict}} + +{{if !$_POST}} + {{:error message="Aucune donnée fournie"}} +{{/if}} + +{{if null === $r}} + {{:assign var="r" value=$_POST}} + {{:assign var="r.montant" value=$r.montant|money_int}} + {{:assign var="r.montant_nature" value=$r.montant_nature|money_int}} + {{:assign var="r.montant_numeraire" value=$r.montant_numeraire|money_int}} + {{:assign var="r.date" value=$r.date|parse_date}} + {{:assign total="%d+%d"|math:$r.montant_numeraire:$r.montant_nature}} +{{/if}} + +{{if $r.annees}} + {{:assign var="r.periode_annee" value=$r.annees}} +{{/if}} + +{{:include file="./_config_default.tpl" keep="module.config,types_asso"}} + +{{:assign var="type" from="types_asso.%s"|args:$module.config.type_asso}} + +{{if !$r.entreprise}} +
    + {{if $module.config.type_asso === 'syndicat'}} +

    Merci pour votre cotisation !

    +

    Elle vous permet de déduire de votre impôt sur le revenu {{$type.help}} Sauf si vous êtes aux frais réels, dans ce cas, déclarez-la dans vos frais.

    +

    Le crédit d'impôt vous sera remboursé en cas de non-imposition ou d'imposition inférieure au montant du crédit.

    + {{else}} +

    Merci pour votre soutien !

    +

    Il vous permet de déduire de votre impôt sur le revenu {{$type.help}}

    + {{/if}} + +

    Comment déclarer ?

    + +

    Vous devez utiliser la déclaration complémentaire 2042-RICI. Lors de la déclaration en ligne, cochez à l’étape 3 la case « Réductions et crédits d’impôts » située dans la rubrique « Charges ».

    +

    Inscrivez la somme de {{$r.montant|raw|money_currency:true}} dans la case + {{$type.case}} +

    +

    Conservez ce reçu au moins 3 ans. N'envoyez ce reçu au service des impôts que si vous déclarez vos impôts avec un formulaire papier. Aucun envoi n'est nécessaire pour les déclarations sur Internet.

    +
    +{{/if}} + +

    + {{if $module.config.type_asso === 'syndicat'}} + Reçu des cotisations syndicales versées au titre de l'article 199 quater C du code général des impôts + {{elseif $r.entreprise}} + Reçu des dons et versements effectués par les entreprises au titre de l’article 238 bis du code général des impôts + {{else}} + Reçu des dons et versements effectués par les particuliers au titre des articles 200 et 978 du code général des impôts + {{/if}} +

    + +

    Numéro d'ordre du reçu : {{if $preview}}(Brouillon){{else}}N°%ID%{{/if}}

    + +

    Bénéficiaire des versements

    + + + + + + + + + + + + + + + {{if $module.config.type_asso !== 'syndicat'}} + + + + + + + + + {{/if}} +
    Nom :{{$config.org_name}}
    Adresse :{{$config.org_address|escape|replace:"\n":" — "}}
    Type :{{$type.label}}
    Numéro SIREN ou RNA :{{$module.config.numero_asso}}
    Objet :{{$module.config.objet_asso|escape|replace:"\n":" — "}} +
    + +

    Donateur

    + + + + + + + + + + + {{if $r.entreprise}} + + + + + + + + + {{/if}} +
    Nom :{{$r.nom}}
    Adresse :{{$r.adresse|escape|replace:"\n":" — "}}
    Forme juridique :{{$r.entreprise_forme}}
    Numéro SIREN :{{$r.entreprise_numero}}
    + +{{if $r.entreprise}} +

    + L’organisme bénéficiaire reconnaît avoir reçu, au titre de la réduction d’impôt prévue à l’article 238 bis du code général des impôts, des dons en nature pour une valeur en euros égale à : + + {{$r.montant_nature|money_raw|spell_out_number:'fr_FR':'euros'}} + (***{{$r.montant_nature|raw|money_currency:false}}***). +

    + +

    Description exhaustive des biens et prestations reçus et acceptés (nature et quantité) et détail des salariés mis à disposition :

    +

    + {{$r.description_nature|escape|nl2br|or:"—Néant—"}} +

    + +

    L’organisme bénéficiaire reconnaît avoir reçu, au titre de la réduction d’impôt prévue à l’article 238 bis du code général des impôts, des versements pour une valeur totale égale à : + + {{$r.montant_numeraire|money_raw|spell_out_number:'fr_FR':'euros'}} + (***{{$r.montant_numeraire|raw|money_currency:false}}***). +

    + + {{if $r.montant_numeraire}} +

    Mode de versement :

    +
      + {{if $r.moyens_especes}} +
    • Remise d’espèces
    • + {{/if}} + {{if $r.moyens_cheques}} +
    • Chèque
    • + {{/if}} + {{if $r.moyens_autres}} +
    • Virement, prélèvement, carte bancaire, ou autre
    • + {{/if}} +
    + {{/if}} + +

    Montant total des dons et versements reçus par l’organisme : + + {{$total|money_raw|spell_out_number:'fr_FR':'euros'}} + (***{{$total|raw|money_currency:false}}***). +

    + +

    Date ou période au cours de laquelle les dons et versements ont été effectués : {{if $r.periode_annee}}cumul {{$r.periode_annee}}{{else}}{{$r.periode_date|date_short}}{{/if}}

    + +{{else}} + + {{if $module.config.type_asso === 'syndicat'}} +

    Le bénéficiaire reconnaît avoir reçu des cotisations ouvrant droit à réduction d’impôt d’un montant de : + {{$r.montant|money_raw|spell_out_number:'fr_FR':'euros'}} + (***{{$r.montant|raw|money_currency:false}}***). +

    + +

    Date du versement : {{if $r.periode_annee}}cumul {{$r.periode_annee}}{{else}}{{$r.periode_date|date_short}}{{/if}}

    + {{else}} +

    Le bénéficiaire reconnaît avoir reçu des dons et versements ouvrant droit à réduction d’impôt d’un montant de : + {{$r.montant|money_raw|spell_out_number:'fr_FR':'euros'}} + (***{{$r.montant|raw|money_currency:false}}***). +

    + +

    Date du versement ou du don : {{if $r.periode_annee}}cumul {{$r.periode_annee}}{{else}}{{$r.periode_date|date_short}}{{/if}}

    + +

    Le bénéficiaire certifie sur l’honneur que les dons et versements qu’il reçoit ouvrent droit à la réduction d’impôt prévue à l’article :
    + {{if $module.config.art200}}[X] 200 du CGI{{/if}} + {{if $module.config.art978}}[X] 978 du CGI{{/if}} +

    + {{/if}} + + + {{if $module.config.type_asso !== 'syndicat'}} + + + + + {{/if}} + + + + + {{if $r.numeraire}} + + + + + {{/if}} +
    Forme du don :
    • Don manuel
    Nature : +
      + {{if $r.numeraire}}
    • Numéraire
    • {{/if}} + {{if $r.nature}}
    • Don en nature
    • {{/if}} + {{if $r.abandon_frais}}
    • Frais engagés par les bénévoles, dont ils renoncent expressément au remboursement
    • {{/if}} +
    +
    Mode de versement : +
      + {{if $r.moyens_especes}} +
    • Remise d’espèces
    • + {{/if}} + {{if $r.moyens_cheques}} +
    • Chèque
    • + {{/if}} + {{if $r.moyens_autres}} +
    • Virement, prélèvement, carte bancaire, ou autre
    • + {{/if}} +
    +
    + +{{/if}} + +

    Date du reçu : {{$r.date|date_short}}

    + +{{* La signature n'est pas stockée dans le reçu *}} \ No newline at end of file diff --git a/src/modules/recus_fiscaux/annuler.html b/src/modules/recus_fiscaux/annuler.html new file mode 100644 index 0000000..75a3794 --- /dev/null +++ b/src/modules/recus_fiscaux/annuler.html @@ -0,0 +1,42 @@ +{{#restrict block=true section="accounting" level="write"}} +{{/restrict}} + +{{if !$_GET.id}} + {{:error message="Aucun numéro de reçu fourni."}} +{{/if}} + +{{#load id=$_GET.id}} + {{:assign .="recu"}} +{{else}} + {{:error message="Le numéro de reçu fourni n'a pas été trouvé"}} +{{/load}} + +{{#form on="annuler"}} + {{:save + validate_schema="./recu.schema.json" + id=$recu.id + annule=true + }} + {{:redirect to="voir.html?id=%d"|args:$recu.id}} +{{/form}} + +{{:admin_header title="Annuler un reçu fiscal" current="acc"}} + +{{:form_errors}} + +
    +
    + Annuler un reçu +

    Annuler le reçu n°{{$recu.id}} ?

    +
    + +

    + Note : un reçu annulé ne peut pas être restauré, il faudra créer un nouveau reçu. +

    + +

    + {{:button type="submit" name="annuler" shape="delete" label="Annuler ce reçu" class="main"}} +

    +
    + +{{:admin_footer}} \ No newline at end of file diff --git a/src/modules/recus_fiscaux/config.html b/src/modules/recus_fiscaux/config.html new file mode 100644 index 0000000..1123d9c --- /dev/null +++ b/src/modules/recus_fiscaux/config.html @@ -0,0 +1,197 @@ +{{:admin_header title="Configuration des reçus fiscaux"}} + +{{:include file="./_config_default.tpl" keep="module.config,types_asso"}} + +{{#form on="save"}} + {{if $_POST.type_asso !== 'syndicat'}} + {{if !$_POST.objet_asso|trim}} + {{:error message="L'objet de l'association n'a pas été renseigné."}} + {{elseif !$_POST.numero_asso}} + {{:error message="Le numéro SIREN ou RNA n'a pas été renseigné."}} + {{/if}} + {{/if}} + + {{if !$_POST.comptes_don|count}} + {{:error message="Aucun compte de don éligible aux reçus n'a été renseigné."}} + {{elseif !$_POST.comptes_especes|count}} + {{:error message="Aucun compte d'espèces n'a été renseigné."}} + {{elseif !$_POST.comptes_cheques|count}} + {{:error message="Aucun compte de chèques n'a été renseigné."}} + {{elseif !$_POST.art200|boolval && !$_POST.art238bis|boolval && !$_POST.art978|boolval}} + {{:error message="Merci de cocher au moins un des articles donnant éligibilité aux réductions d'impôt."}} + {{elseif !$_POST.champs_adresse|count}} + {{:error message="Aucun champ d'adresse n'a été coché."}} + {{/if}} + + {{:save key="config" + validate_schema="./config.schema.json" + numero_asso=$_POST.numero_asso|strval|trim + objet_asso=$_POST.objet_asso|strval|trim + type_asso=$_POST.type_asso|strval|trim + comptes_don=$_POST.comptes_don|arrayval + comptes_don_nature=$_POST.comptes_don_nature|arrayval|or:null + comptes_don_abandon_frais=$_POST.comptes_don_abandon_frais|arrayval|or:null + comptes_especes=$_POST.comptes_especes|arrayval + comptes_cheques=$_POST.comptes_cheques|arrayval + art200=$_POST.art200|boolval + art238bis=$_POST.art238bis|boolval + art978=$_POST.art978|boolval + champs_adresse=$_POST.champs_adresse|arrayval + champ_entreprise_numero=$_POST.champ_entreprise_numero|strval|trim|or:null + champ_entreprise_forme=$_POST.champ_entreprise_forme|strval|trim|or:null + email_subject=$_POST.email_subject|trim + email_body=$_POST.email_body|trim + }} + {{:redirect to="?ok=1"}} +{{/form}} + +{{if !$dialog}} + +{{/if}} + +{{if $_GET.msg == 'MISSING'}} +

    + Pour pouvoir générer des reçus, il faut renseigner le numéro SIREN ou RNA, l'objet de l'association et cocher un des articles de loi ouvrant l'éligibilité à la réduction d'impôt. +

    +{{elseif $_GET.msg == 'MISSING_NUMBER'}} +

    + Merci de bien vouloir renseigner le numéro SIREN ou RNA de l'association. +

    +{{/if}} + +{{:form_errors}} + +{{if $_GET.ok}} +

    Configuration enregistrée.

    +{{/if}} + +
    + +

    Toute modification ne s'appliquera qu'aux reçus créés, pas aux reçus existants, qui ne peuvent être modifiés pour des raisons légales.

    + +
    + Informations sur l'association +
    +
    (obligatoire)
    + {{#foreach from=$types_asso key="type"}} + {{:input type="radio-btn" required=true name="type_asso" value=$type label=$label help="Réduction de %s"|args:$help source=$module.config}} + {{/foreach}} + +
    Note : les réductions ne sont données qu'à titre indicatif. Il est possible que ces taux aient changé, ou soient différents pour les entreprises ou l'impôt sur la fortune.
    +
    + +
    +
    (obligatoire)
    + + {{:input type="checkbox" name="art200" value="1" source=$module.config label="Article 200" help="Réduction d'impôt pour les particuliers"}} + {{:input type="checkbox" name="art238bis" value="1" source=$module.config label="Article 238 bis" help="Pour les entreprises"}} + {{:input type="checkbox" name="art978" value="1" source=$module.config label="Article 978" help="Pour les personnes soumises à l'impôt sur la fortune immobilière"}} + + {{:input required=true name="numero_asso" type="text" label="Numéro SIREN ou RNA de l'association" source=$module.config}} + + {{:input required=true name="objet_asso" type="textarea" label="Objet de l'association" source=$module.config cols="70" rows="4" help="Inscrire ici l'objet de l'association, conformément aux statuts."}} +
    +
    + +
    + Détection automatique selon les comptes utilisés +
    + {{:input type="list" required=true name="comptes_don" label="Comptes éligibles aux reçus" source=$module.config target="!acc/charts/accounts/selector.php?targets=6&key=code" multiple=true}} +
    + Pour chaque compte indiqué dans ce champ, le reçu de don sera proposé (en dessous de la fiche de l'écriture). +
    +
    + +
    + {{:input type="list" required=false name="comptes_don_abandon_frais" label="Comptes pour les abandons de frais des bénévoles" source=$module.config target="!acc/charts/accounts/selector.php?targets=6&key=code" multiple=true}} +
    + Par exemple 75412. Utilisé pour cocher automatiquement la bonne case dans le reçu. +
    + + {{:input type="list" required=false name="comptes_don_nature" label="Comptes pour les dons en nature" source=$module.config target="!acc/charts/accounts/selector.php?targets=6&key=code" multiple=true}} +
    + Utilisé pour cocher automatiquement la bonne case dans le reçu. +
    +
    + +
    + {{:input required=true name="comptes_especes" type="list" multiple=true label="Comptes de caisse" source=$module.config target="!acc/charts/accounts/selector.php?targets=2&key=code"}} +
    + Par exemple 530. Utilisé pour cocher automatiquement la bonne case dans le reçu. +
    + {{:input required=true name="comptes_cheques" type="list" multiple=true label="Comptes pour les chèques" source=$module.config target="!acc/charts/accounts/selector.php?targets=3&key=code"}} +
    + Par exemple 5112. Utilisé pour cocher automatiquement la bonne case dans le reçu. +
    +
    +
    + +
    + Configuration des champs +
    +
    Champs à utiliser pour l'adresse du reçu :
    + {{#select name, label FROM config_users_fields WHERE type NOT IN ('file', 'password', 'virtual', 'multiple', 'checkbox', 'date', 'datetime') ORDER BY sort_order}} + {{:assign var="fields.%s"|args:$name value=$label}} + {{:assign var="checked" from="module.config.champs_adresse.%s"|args:$name}} +
    {{:input type="checkbox" name="champs_adresse[%s]"|args:$name value=$name label=$label source=$module.config default=$checked}}
    + {{/select}} +
    +
    + +
    + Gestion des reçus pour entreprises +

    Depuis 2023, les entreprises doivent faire l'objet d'un reçu différent. Si l'entreprise a fait un don en nature, le reçu doit être renseigné manuellement.
    + Si vous indiquez ci-dessous les champs SIREN et forme juridique de l'entreprise, les fiches membres disposant de ces informations seront traitées conformément au nouveau reçu.
    + Dans le cas contraire toutes les fiches membres seront traitées comme étant des particuliers. +

    +
    + {{:input type="select" name="champ_entreprise_numero" default_empty="— Aucun —" label="Champ du numéro SIREN de l'entreprise" required=false options=$fields source=$module.config}} + {{:input type="select" name="champ_entreprise_forme" default_empty="— Aucun —" label="Champ de la forme juridique de l'entreprise" required=false options=$fields source=$module.config}} +
    +
    + +
    + Message par défaut lors de l'envoi du reçu par e-mail +
    + {{:input name="email_subject" type="text" label="Sujet du message" required=true source=$module.config}} + {{:input name="email_body" type="textarea" label="Corps du message" cols="70" rows="7" required=true source=$module.config}} +
    +
    + + +

    + {{:button type="submit" name="save" label="Enregistrer" shape="right" class="main"}} +

    + +
    + + + +{{:admin_footer}} \ No newline at end of file diff --git a/src/modules/recus_fiscaux/config.schema.json b/src/modules/recus_fiscaux/config.schema.json new file mode 100644 index 0000000..e37ebe2 --- /dev/null +++ b/src/modules/recus_fiscaux/config.schema.json @@ -0,0 +1,62 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "numero_asso": { + "description": "Numéro SIREN ou RNA de l'association", + "type": ["string", "null"] + }, + "objet_asso": { + "description": "Objet de l'association", + "type": "string" + }, + "type_asso": { + "description": "Type d'organisme", + "type": "string", + "enum": ["defaut", "personnes" , "cultuelle", "syndicat"] + }, + "art200": { + "type": "boolean" + }, + "art238bis": { + "type": "boolean" + }, + "art978": { + "type": "boolean" + }, + "comptes_don": { + "type": "object" + }, + "comptes_don_nature": { + "type": ["object", "null"] + }, + "comptes_don_abandon_frais": { + "type": ["object", "null"] + }, + "comptes_especes": { + "type": "object" + }, + "comptes_cheques": { + "type": "object" + }, + "champs_adresse": { + "type": "object" + }, + "champ_entreprise_numero": { + "type": ["string", "null"] + }, + "champ_entreprise_forme": { + "type": ["string", "null"] + }, + "email_subject": { + "type": "string" + }, + "email_body": { + "type": "string" + } + }, + "required": [ "numero_asso", "objet_asso", "type_asso", "art200", "art238bis", "art978", "comptes_don", + "comptes_don_abandon_frais", "comptes_don_nature", "comptes_especes", "comptes_cheques", "champs_adresse", + "champ_entreprise_numero", "champ_entreprise_forme", + "email_subject", "email_body" ] +} \ No newline at end of file diff --git a/src/modules/recus_fiscaux/envoyer.html b/src/modules/recus_fiscaux/envoyer.html new file mode 100644 index 0000000..f0de1be --- /dev/null +++ b/src/modules/recus_fiscaux/envoyer.html @@ -0,0 +1,53 @@ +{{#restrict section="accounting" level="write" block=true}}{{/restrict}} + +{{:include file="./_config_default.tpl" keep="module.config"}} + +{{#load id=$_GET.id assign="recu"}} +{{else}} + {{:error message="Ce reçu n'existe pas"}} +{{/load}} + +{{if $recu.linked_user}} + {{#users id=$recu.linked_user}} + {{:assign email=$_email}} + {{/users}} +{{/if}} + +{{:admin_header title="Envoyer un reçu"}} + +{{#form on="send"}} + {{:mail + to=$_POST.email_to|trim + subject=$_POST.email_subject|trim + body=$_POST.email_body|trim + attach_from="./recu.html?id=%d&print=pdf"|args:$recu.id + }} + {{:redirect to="envoyer.html?id=%d&sent=1"|args:$recu.id}} +{{/form}} + +{{if $_GET.sent}} +

    Le reçu a bien été envoyée par e-mail.

    +{{/if}} + +{{:form_errors}} + +
    +
    + Envoyer un reçu fiscal +
    +
    Numéro du reçu
    +
    {{$recu.id}}
    +
    Donateur
    +
    {{$recu.nom}}
    + {{:input type="email" name="email_to" required=true default=$email label="Adresse du destinataire"}} + {{:input name="email_subject" type="text" source=$module.config label="Sujet du message" required=true}} + {{:input name="email_body" type="textarea" cols="70" rows=7 source=$module.config label="Corps du message" required=true}} +
    +
    +

    + {{:button type="submit" label="Envoyer" name="send" shape="right" class="main"}} +

    +
    + + +{{:admin_footer}} diff --git a/src/modules/recus_fiscaux/generer.html b/src/modules/recus_fiscaux/generer.html new file mode 100644 index 0000000..e0ae0fd --- /dev/null +++ b/src/modules/recus_fiscaux/generer.html @@ -0,0 +1,256 @@ +{{#restrict block=true section="accounting" level="write"}} +{{/restrict}} + +{{if $module.config.type_asso !== 'syndicat'}} + {{if !$module.config.objet_asso}} + {{:redirect force="config.html?msg=MISSING"}} + {{elseif !$module.config.numero_asso}} + {{:redirect force="config.html?msg=MISSING_NUMBER"}} + {{/if}} +{{/if}} + +{{:include file="./_config_default.tpl" keep="module.config"}} + +{{#form on="create"}} + {{#foreach from=$_POST.new key="line" item="row"}} + {{:assign row=$row|json_decode}} + {{:assign skip=false}} + + {{if $row.id_transaction|trim|strlen}} + {{:assign id_transaction=$row.id_transaction|explode:','|map:intval}} + {{:assign in_transaction="value"|sql_where:'IN':$id_transaction}} + {{#load each="linked_transactions" where="$$.linked_transactions IS NOT NULL AND $$.annule = 0 AND %s"|args:$in_transaction}} + {{:assign var="skipped." value="- ligne %d : le reçu n°%d a déjà été créé pour l'écriture #%d"|args:$line:$id:$value}} + {{:assign skip=true}} + {{/load}} + {{/if}} + + {{if $skip}} + {{:continue}} + {{/if}} + + {{:assign var="row.date" value=$now}} + + {{:include file="./_recu.html" capture="recu" r=$row}} + {{:save + validate_schema="./recu.schema.json" + assign_new_id="new_id" + nom=$row.nom + date=$now|date_format:'%Y-%m-%d' + montant=$row.montant + linked_user=$row.id_user + linked_transactions=$row.id_transaction|explode:','|map:intval + annule=false + id_year=$_GET.id_year|intval + recu=$recu + }} + {{/foreach}} + + {{if $skipped}} + {{:assign skipped=$skipped|implode:"\n"}} + {{:error message="Des reçus n'ont pas pu être créés car leurs écritures étaient déjà utilisées dans d'autres reçus :\n%s\nPour créer de nouveaux reçus liés à ces écritures, il faut déjà annuler les reçus existants."|args:$skipped}} + {{/if}} + + {{:redirect to=$module.url}} +{{/form}} + +{{:admin_header title="Générer des reçus" current="acc"}} + + +{{if !$dialog}} + +{{/if}} + +{{:form_errors}} + +{{if !$_GET.id_year}} + {{#years order="end_date DESC"}} + {{:assign var="years.%d"|args:$id value=$label}} + {{else}} + {{:error message="Aucun exercice comptable n'est disponible."}} + {{/years}} + +
    +
    + Générer tous les reçus d'un exercice +
    + {{:input type="select" name="id_year" label="Exercice" required=true label="Exercice" options=$years}} +
    +
    + +

    + {{:button type="submit" shape="right" label="Prévisualiser" class="main"}} +

    +
    +{{else}} + {{* Récupération des comptes et soldes *}} + {{:assign var="codes_don" value=$module.config.comptes_don|keys|map:strval}} + {{:assign var="codes_don_abandon_frais" value=$module.config.comptes_don_abandon_frais|keys|map:strval}} + {{:assign var="codes_don_nature" value=$module.config.comptes_don_nature|keys|map:strval}} + {{:assign var="codes_especes" value=$module.config.comptes_especes|keys|map:strval}} + {{:assign var="codes_cheques" value=$module.config.comptes_cheques|keys|map:strval}} + {{:assign var="champs_adresse" value=$module.config.champs_adresse|sql_user_fields:"u":" — "}} + {{:assign var="champs_nom" value=$config.user_fields.name|sql_user_fields:"u"}} + {{:assign var="champs_numero" value=$config.user_fields.number|sql_user_fields:"u"}} + {{:assign var="champ_entreprise_numero" value=$module.config.champ_entreprise_numero|sql_user_fields:"u"}} + {{:assign var="champ_entreprise_forme" value=$module.config.champ_entreprise_forme|sql_user_fields:"u"}} + {{:assign var="total" value=0}} + {{:assign var="count" value=0}} + + {{#select + *, + GROUP_CONCAT(tid, ',') AS id_transaction, + SUM(total_numeraire) AS total_numeraire, + SUM(total_abandon_frais) AS total_abandon_frais, + SUM(total_nature) AS total_nature, + SUM(total_especes) AS total_especes, + SUM(total_cheques) AS total_cheques, + SUM(total_numeraire) - SUM(total_especes) - SUM(total_cheques) AS total_autres, + entreprise_forme IS NOT NULL AND entreprise_numero IS NOT NULL AS entreprise + FROM ( + SELECT + strftime('%Y', t.date) AS periode_annee, + u.id AS id_user, + !champs_numero AS numero, + t.id AS tid, + SUM(l1.credit) AS montant, + a1.code AS account, + !champs_nom AS nom, + !champs_adresse AS adresse, + !champ_entreprise_forme AS entreprise_forme, + !champ_entreprise_numero AS entreprise_numero, + -- Paiements en espèces? + (SELECT SUM(l2.credit + l2.debit) FROM acc_transactions_lines l2 + INNER JOIN acc_accounts a2 ON a2.id = l2.id_account AND a2.!codes_especes + WHERE l2.credit = 0 AND l2.id_transaction = t.id LIMIT 1) AS total_especes, + -- Paiements en chèques? + (SELECT SUM(l2.credit + l2.debit) FROM acc_transactions_lines l2 + INNER JOIN acc_accounts a2 ON a2.id = l2.id_account AND a2.!codes_cheques + WHERE l2.credit = 0 AND l2.id_transaction = t.id LIMIT 1) AS total_cheques, + -- Dons en abandon de frais ? + (SELECT SUM(l2.credit + l2.debit) FROM acc_transactions_lines l2 + INNER JOIN acc_accounts a2 ON a2.id = l2.id_account AND a2.!codes_don_abandon_frais + WHERE l2.debit = 0 AND l2.id_transaction = t.id) AS total_abandon_frais, + -- Dons en nature ? + (SELECT SUM(l2.credit + l2.debit) FROM acc_transactions_lines l2 + INNER JOIN acc_accounts a2 ON a2.id = l2.id_account AND a2.!codes_don_nature + WHERE l2.debit = 0 AND l2.id_transaction = t.id) AS total_nature, + -- Dons en numeraire ? + (SELECT SUM(l2.credit + l2.debit) FROM acc_transactions_lines l2 + INNER JOIN acc_accounts a2 ON a2.id = l2.id_account AND a2.!codes_don + WHERE l2.debit = 0 AND l2.id_transaction = t.id) AS total_numeraire + + FROM acc_transactions t + INNER JOIN acc_transactions_users tu ON tu.id_transaction = t.id + INNER JOIN users u ON tu.id_user = u.id + INNER JOIN acc_transactions_lines l1 ON l1.id_transaction = t.id AND l1.credit != 0 + INNER JOIN acc_accounts a1 ON a1.id = l1.id_account AND (a1.!codes_don_nature OR a1.!codes_don OR a1.!codes_don_abandon_frais) + + WHERE t.id_year = {$_GET.id_year|intval} + + GROUP BY tu.id_user + ) + GROUP BY id_user + ORDER BY nom COLLATE U_NOCASE; + + !champs_nom=$champs_nom + !champs_adresse=$champs_adresse + !champs_numero=$champs_numero + !champ_entreprise_forme=$champ_entreprise_forme + !champ_entreprise_numero=$champ_entreprise_numero + !codes_don_nature="code"|sql_where:"IN":$codes_don_nature + !codes_don_abandon_frais="code"|sql_where:"IN":$codes_don_abandon_frais + !codes_don="code"|sql_where:"IN":$codes_don + !codes_especes="code"|sql_where:"IN":$codes_especes + !codes_cheques="code"|sql_where:"IN":$codes_cheques + assign="rows." + }} + {{/select}} + + {{if !$rows}} +

    Aucune écriture correspondant aux comptes configurés n'a été trouvée.

    + {{else}} +
    + + + + + + + + + + + + + + + + + + + {{#foreach from=$rows key="line" item="row"}} + {{if $entreprise && $total_nature|or:$total_abandon_frais}} + {{:assign locked=true}} + {{:assign has_locked=true}} + {{else}} + {{:assign locked=false}} + {{/if}} + + + + + + + + + + + + + + {{:assign var="total" value="%d+%d"|math:$total:$montant}} + {{:assign var="count" value="%d+1"|math:$count}} + {{/foreach}} + + + + + + + + + +
    Num.Nom du membreAdresseEspècesChèqueAutresAbandon de fraisEn natureMontant des dons
    + {{:assign line="%d+1"|math:$line}} + {{if !$locked}} + {{:input type="checkbox" name="new[%d]"|args:$line value=$row|json_encode checked=true}} + {{else}} + {{:icon shape="alert" title="Saisie manuelle obligatoire"}} + {{/if}} + + {{:link href="!users/details.php?id=%d"|args:$id_user label=$numero}} + {{$nom}}{{$adresse}}{{if $total_especes}}{{:icon shape="check"}}{{/if}}{{if $total_cheques}}{{:icon shape="check"}}{{/if}}{{if $total_autres}}{{:icon shape="check"}}{{/if}}{{if $total_abandon_frais}}{{:icon shape="check"}}{{/if}}{{if $total_nature}}{{:icon shape="check"}}{{/if}}{{$montant|money}} + {{:linkbutton shape="plus" href="nouveau.html?id_user=%d&type=user"|args:$id_user label="Saisie manuelle du reçu"}} + {{:linkbutton shape="menu" href="!acc/transactions/user.php?id=%d&year=%d"|args:$id_user:$_GET.id_year label="Liste des écritures" target="_dialog"}} +
    Total des dons{{$total|money}}
    + +

    + Note : un reçu créé ne peut plus être modifié, mais seulement annulé. + {{if $has_locked}} +
    + Les lignes ayant le symbole {{:icon shape="alert" title="Saisie manuelle obligatoire"}} nécessitent une saisie manuelle obligatoire (entreprises ayant effectué un don en nature). + {{/if}} +

    + +

    + {{:button type="submit" name="create" shape="right" label="Créer ces reçus" class="main"}} +

    + +
    + {{/if}} +{{/if}} + +{{:admin_footer}} \ No newline at end of file diff --git a/src/modules/recus_fiscaux/icon.svg b/src/modules/recus_fiscaux/icon.svg new file mode 100644 index 0000000..5d5bd3f --- /dev/null +++ b/src/modules/recus_fiscaux/icon.svg @@ -0,0 +1 @@ + diff --git a/src/modules/recus_fiscaux/index.html b/src/modules/recus_fiscaux/index.html new file mode 100644 index 0000000..dee2e5a --- /dev/null +++ b/src/modules/recus_fiscaux/index.html @@ -0,0 +1,115 @@ +{{#restrict block=true section="accounting" level="read"}} +{{/restrict}} + +{{:include file="./_config_default.tpl" keep="module.config"}} + +{{:admin_header title="Reçus fiscaux" current="acc"}} + + + +
    +
    +
    + Filtrer par année +

    + {{:assign var="years." value="— Voir toutes les années —"}} + + {{#load select="SUBSTR($$.date, 1, 4) AS year" group="SUBSTR($$.date, 1, 4)"}} + {{:assign var="years.%d"|args:$year value=$year}} + {{/load}} + + {{:input type="select" name="year" options=$years default=$_GET.year|or:0 required=true onchange="this.form.submit();"}} +

    +
    +
    + +
    +
    + Chercher un reçu +

    + {{:input type="search" name="q" placeholder="Nom, numéro, ou date" default=$_GET.q}} + {{:button type="submit" label="Chercher" shape="right"}} +

    +
    +
    +
    + +{{if $_GET.q}} + {{if $_GET.q|regexp_match:'/^\d+$/'}} + {{:assign where="id = :id_search" id=$_GET.q|intval}} + {{elseif $_GET.q|parse_date}} + {{:assign where="$$.date = :date" date=$_GET.q|parse_date}} + {{else}} + {{:assign nom2=$_GET.q|trim|regexp_replace:'/[!%_]/':'!$0'}} + {{:assign where="$$.nom LIKE :nom ESCAPE '!'" nom="%%%s%%"|args:$nom2}} + {{/if}} +{{elseif $_GET.year}} + {{:assign where="SUBSTR($$.date, 1, 4) = :year"}} +{{/if}} + +{{#list + select="id AS 'Numéro'; $$.nom AS 'Nom du bénéficiaire'; $$.date AS 'Date d''émission'; $$.montant AS 'Montant'; + CASE WHEN $$.annule = 1 THEN 'Annulé' ELSE '' END AS 'Annulé ?'" + where=$where + order=1 desc=true + :year=$_GET.year + :date=$date + :id_search=$id + :nom=$nom +}} + + + {{:link href="recu.html?id=%d"|args:$id label=$id target="_dialog"}} + + + {{if $linked_user}} + {{:link href="!users/details.php?id=%d"|args:$linked_user label=$nom}} + {{else}} + {{$nom}} + {{/if}} + + + {{$date|date_short}} + + + {{$montant|raw|money_currency}} + + + {{if $annule}}Annulé{{/if}} + + + {{if !$annule}} + {{:linkbutton shape="delete" label="Annuler" href="annuler.html?id=%d"|args:$id target="_dialog"}} + {{/if}} + {{:linkbutton shape="eye" label="Ouvrir" href="recu.html?id=%d"|args:$id target="_dialog"}} + {{:linkbutton shape="mail" label="Envoyer" href="envoyer.html?id=%d"|args:$id target="_dialog"}} + + +{{else}} +

    + {{if $_GET.q}} + Aucun reçu n'a été trouvé pour la recherche "{{$_GET.q}}". + {{elseif $_GET.year}} + Aucun reçu n'a été trouvé pour l'année sélectionnée. + {{else}} + Aucun reçu n'a encore été créé. + {{/if}} +

    +{{/list}} + +{{:admin_footer}} \ No newline at end of file diff --git a/src/modules/recus_fiscaux/module.ini b/src/modules/recus_fiscaux/module.ini new file mode 100644 index 0000000..e3e0680 --- /dev/null +++ b/src/modules/recus_fiscaux/module.ini @@ -0,0 +1,7 @@ +name="Reçus fiscaux" +description="Permet de générer des reçus fiscaux. Conforme aux exigences fiscales de 2022." +author="Paheko" +author_url="https://paheko.cloud/" +home_button=true +restrict_section="accounting" +restrict_level="read" \ No newline at end of file diff --git a/src/modules/recus_fiscaux/nouveau.html b/src/modules/recus_fiscaux/nouveau.html new file mode 100644 index 0000000..6485fd8 --- /dev/null +++ b/src/modules/recus_fiscaux/nouveau.html @@ -0,0 +1,405 @@ +{{#restrict block=true section="accounting" level="write"}} +{{/restrict}} + +{{if $module.config.type_asso !== 'syndicat'}} + {{if !$module.config.objet_asso}} + {{:redirect force="config.html?msg=MISSING"}} + {{elseif !$module.config.numero_asso}} + {{:redirect force="config.html?msg=MISSING_NUMBER"}} + {{/if}} +{{/if}} + +{{:include file="./_config_default.tpl" keep="module.config"}} + +{{if $_GET.user}} + {{#foreach from=$_GET.user key="id" item="user"}} + {{:assign id_user=$id|intval}} + {{/foreach}} +{{elseif $_GET.id_user}} + {{:assign id_user=$_GET.id_user|intval}} +{{/if}} + +{{:assign var="default.date" value=$now}} + +{{#load limit=1}} + {{:assign id_choice=false}} +{{else}} + {{:assign id_choice=true}} +{{/load}} + +{{#form on="create"}} + {{if !$_POST.date|trim|parse_date}} + {{:error message="Date d'émission invalide ou vide."}} + {{elseif !$_POST.nom|trim}} + {{:error message="Le nom du donateur ne peut être laissé vide."}} + {{elseif !$_POST.adresse|trim}} + {{:error message="L'adresse du donateur ne peut être laissée vide."}} + {{/if}} + + {{if $_POST.entreprise}} + {{if !$_POST.montant_nature|intval && !$_POST.montant_numeraire|intval}} + {{:error message="Aucun montant n'a été saisi."}} + {{elseif $_POST.montant_numeraire && !$_POST.moyens_especes && !$_POST.moyens_cheques && !$_POST.moyens_autres}} + {{:error message="Aucun moyen de paiement n'a été coché."}} + {{elseif $_POST.montant_nature && !$_POST.description_nature|trim}} + {{:error message="La description des dons en nature doit être renseignée."}} + {{elseif !$_POST.entreprise_forme|trim}} + {{:error message="La forme juridique de l'entreprise doit être renseignée."}} + {{elseif !$_POST.entreprise_numero|trim}} + {{:error message="Le numéro SIREN de l'entreprise doit être renseigné."}} + {{/if}} + + {{:assign montant_nature=$_POST.montant_nature|money_int + montant_numeraire=$_POST.montant_numeraire|money_int}} + {{:assign total="%d+%d"|math:$montant_numeraire:$montant_nature}} + {{else}} + {{if !$_POST.numeraire && !$_POST.nature && !$_POST.abandon_frais}} + {{:error message="Le type de don n'a pas été sélectionné."}} + {{elseif $_POST.numeraire && !$_POST.moyens_especes && !$_POST.moyens_cheques && !$_POST.moyens_autres}} + {{:error message="Aucun moyen de paiement n'a été coché."}} + {{elseif !$_POST.montant|trim || $_POST.montant < 0}} + {{:error message="Le montant du don ne peut être laissé vide."}} + {{/if}} + + {{:assign total=$_POST.montant|money_int}} + {{/if}} + + {{:assign id_transaction=null}} + + {{if $_POST.id_transaction|trim|strlen}} + {{:assign id_transaction=$_POST.id_transaction|explode:','|map:intval}} + {{:assign in_transaction="value"|sql_where:'IN':$id_transaction}} + {{#load each="linked_transactions" where="$$.linked_transactions IS NOT NULL AND $$.annule = 0 AND %s"|args:$in_transaction}} + {{:error message="L'écriture #%d est déjà utilisée dans le reçu n°%d. Il faut déjà annuler ce reçu pour pouvoir le re-créer."|args:$value:$id}} + {{/load}} + {{/if}} + + {{if $id_choice && $_POST.number|intval > 0}} + {{:assign number=$_POST.number|intval}} + {{else}} + {{:assign number=null}} + {{/if}} + + {{:include file="./_recu.html" capture="recu"}} + {{:save + validate_schema="./recu.schema.json" + id=$number + assign_new_id="new_id" + nom=$_POST.nom|trim + date=$_POST.date|parse_date + montant=$total + linked_user=$_POST.id_user|intval|or:null + linked_transactions=$id_transaction + id_year=$_POST.id_year + annule=false + entreprise=$_POST.entreprise|boolval + recu=$recu + }} + + {{if !$dialog}} + {{:redirect to="voir.html?id=%d"|args:$new_id}} + {{else}} + {{:redirect to="recu.html?id=%d"|args:$new_id}} + {{/if}} +{{/form}} + +{{:admin_header title="Créer un nouveau reçu fiscal" current="acc"}} + +{{:assign var="champs_adresse" value=$module.config.champs_adresse|sql_user_fields:"u":" — "}} +{{:assign var="champs_nom" value=$config.user_fields.name|sql_user_fields:"u"}} + +{{:assign var="codes_don" value=$module.config.comptes_don|keys|map:strval}} +{{:assign var="codes_don_abandon_frais" value=$module.config.comptes_don_abandon_frais|keys|map:strval}} +{{:assign var="codes_don_nature" value=$module.config.comptes_don_nature|keys|map:strval}} +{{:assign var="codes_especes" value=$module.config.comptes_especes|keys|map:strval}} +{{:assign var="codes_cheques" value=$module.config.comptes_cheques|keys|map:strval}} + +{{if $id_user}} + {{#select *, !champs_adresse AS adresse, !champs_nom AS nom FROM users u WHERE id = {$id_user|intval}; + !champs_adresse=$champs_adresse + !champs_nom=$champs_nom + }} + {{:assign var="default.address" value=$adresse|trim:" —"}} + {{:assign var="default.name" value=$nom}} + {{:assign var="default.entreprise_numero" from=$module.config.champ_entreprise_numero}} + {{:assign var="default.entreprise_forme" from=$module.config.champ_entreprise_forme}} + {{else}} + {{:error message="Ce membre n'existe pas."}} + {{/select}} + + {{:assign var="default.id_user" value=$id_user}} + + {{* Récupération des comptes et soldes *}} + {{#select + *, + COUNT(DISTINCT id) AS nombre, + GROUP_CONCAT(DISTINCT id) AS id_transaction, + SUM(total) AS total, + SUM(total_especes) AS total_especes, + SUM(total_cheques) AS total_cheques, + SUM(total_abandon_frais) AS total_abandon_frais, + SUM(total_nature) AS total_nature, + SUM(total_numeraire) AS total_numeraire, + year, + strftime('%d/%m/%Y', date) AS date + FROM ( + SELECT + t.id AS id, + t.date, + SUM(l1.credit) AS total, + strftime('%Y', t.date) AS year, + -- Paiements en espèces? + (SELECT SUM(l2.credit + l2.debit) FROM acc_transactions_lines l2 + INNER JOIN acc_accounts a2 ON a2.id = l2.id_account AND a2.!codes_especes + WHERE l2.credit = 0 AND l2.id_transaction = t.id LIMIT 1) AS total_especes, + -- Paiements en chèques? + (SELECT SUM(l2.credit + l2.debit) FROM acc_transactions_lines l2 + INNER JOIN acc_accounts a2 ON a2.id = l2.id_account AND a2.!codes_cheques + WHERE l2.credit = 0 AND l2.id_transaction = t.id LIMIT 1) AS total_cheques, + -- Dons en abandon de frais ? + (SELECT SUM(l2.credit + l2.debit) FROM acc_transactions_lines l2 + INNER JOIN acc_accounts a2 ON a2.id = l2.id_account AND a2.!codes_don_abandon_frais + WHERE l2.debit = 0 AND l2.id_transaction = t.id) AS total_abandon_frais, + -- Dons en nature ? + (SELECT SUM(l2.credit + l2.debit) FROM acc_transactions_lines l2 + INNER JOIN acc_accounts a2 ON a2.id = l2.id_account AND a2.!codes_don_nature + WHERE l2.debit = 0 AND l2.id_transaction = t.id) AS total_nature, + -- Dons en numeraire ? + (SELECT SUM(l2.credit + l2.debit) FROM acc_transactions_lines l2 + INNER JOIN acc_accounts a2 ON a2.id = l2.id_account AND a2.!codes_don + WHERE l2.debit = 0 AND l2.id_transaction = t.id) AS total_numeraire + FROM acc_transactions t + INNER JOIN acc_transactions_users tu ON tu.id_transaction = t.id AND tu.id_user = {$id_user} + INNER JOIN acc_transactions_lines l1 ON l1.id_transaction = t.id AND l1.debit = 0 + INNER JOIN acc_accounts a1 ON a1.id = l1.id_account AND (a1.!codes_don_nature OR a1.!codes_don OR a1.!codes_don_abandon_frais) + GROUP BY t.id + ) GROUP BY year ORDER BY year; + !codes_don_nature="code"|sql_where:"IN":$codes_don_nature + !codes_don_abandon_frais="code"|sql_where:"IN":$codes_don_abandon_frais + !codes_don="code"|sql_where:"IN":$codes_don + !codes_especes="code"|sql_where:"IN":$codes_especes + !codes_cheques="code"|sql_where:"IN":$codes_cheques + }} + {{:assign .="user_years.%d"|args:$year}} + {{/select}} + + {{#foreach from=$user_years key="year" item="data"}} + {{:assign total_money=$data.total|money_currency}} + {{:assign var="user_select.%d"|args:$year value="%d (%s)"|args:$year:$total_money}} + {{/foreach}} + + +{{elseif $_GET.id_transaction}} + {{#select + u.id AS id_user, + t.id AS id_transactions, + SUM(l1.credit) AS total, + t.date AS date, + !champs_nom AS name, + !champs_adresse AS address, + -- Paiements en espèces? + (SELECT SUM(l2.credit + l2.debit) FROM acc_transactions_lines l2 + INNER JOIN acc_accounts a2 ON a2.id = l2.id_account AND a2.!codes_especes + WHERE l2.credit = 0 AND l2.id_transaction = id_transaction LIMIT 1) AS total_especes, + -- Paiements en chèques? + (SELECT SUM(l2.credit + l2.debit) FROM acc_transactions_lines l2 + INNER JOIN acc_accounts a2 ON a2.id = l2.id_account AND a2.!codes_cheques + WHERE l2.credit = 0 AND l2.id_transaction = id_transaction LIMIT 1) AS total_cheques, + -- Dons en abandon de frais ? + (SELECT SUM(l2.credit + l2.debit) FROM acc_transactions_lines l2 + INNER JOIN acc_accounts a2 ON a2.id = l2.id_account AND a2.!codes_don_abandon_frais + WHERE l2.debit = 0 AND l2.id_transaction = id_transaction) AS total_abandon_frais, + -- Dons en nature ? + (SELECT SUM(l2.credit + l2.debit) FROM acc_transactions_lines l2 + INNER JOIN acc_accounts a2 ON a2.id = l2.id_account AND a2.!codes_don_nature + WHERE l2.debit = 0 AND l2.id_transaction = id_transaction) AS total_nature, + -- Dons en numeraire ? + (SELECT SUM(l2.credit + l2.debit) FROM acc_transactions_lines l2 + INNER JOIN acc_accounts a2 ON a2.id = l2.id_account AND a2.!codes_don + WHERE l2.debit = 0 AND l2.id_transaction = id_transaction) AS total_numeraire + FROM acc_transactions t + INNER JOIN acc_transactions_users tu ON tu.id_transaction = t.id + INNER JOIN users u ON u.id = tu.id_user + INNER JOIN acc_transactions_lines l1 ON l1.id_transaction = t.id AND l1.debit = 0 + INNER JOIN acc_accounts a1 ON a1.id = l1.id_account AND (a1.!codes_don_nature OR a1.!codes_don OR a1.!codes_don_abandon_frais) + WHERE t.id = {$_GET.id_transaction|intval} + ; + !champs_nom=$champs_nom + !champs_adresse=$champs_adresse + !codes_don_nature="code"|sql_where:"IN":$codes_don_nature + !codes_don_abandon_frais="code"|sql_where:"IN":$codes_don_abandon_frais + !codes_don="code"|sql_where:"IN":$codes_don + !codes_especes="code"|sql_where:"IN":$codes_especes + !codes_cheques="code"|sql_where:"IN":$codes_cheques + + assign="default" + }} + {{else}} + {{:error message="Numéro d'écriture inconnu"}} + {{/select}} + + {{if $default.total_numeraire}} + {{:assign var="default.numeraire" value=$default.total_numeraire|boolval}} + {{:assign var="default.autre" value=$default.total_nature|boolval}} + {{/if}} +{{/if}} + +{{if !$dialog}} + +{{/if}} + +{{:form_errors}} + +{{if !$_GET.type}} +
    +
    + Nouveau reçu +
    + {{:input type="radio-btn" name="type" value="user" label="Créer un reçu pour un membre"}} + {{:input type="radio-btn" name="type" value="transaction" label="Créer un reçu à partir d'une écriture"}} + {{:input type="radio-btn" name="type" value="vierge" label="Créer un reçu vierge"}} +
    +
    + + + + +
    + + + +{{else}} +
    + {{if $id_choice}} +
    + Numéro du premier reçu +

    Si vous avez déjà créé des reçus pour cette année, modifiez ce numéro pour avoir une numérotation qui se suit. Le numéro des reçus suivants ne pourra pas être modifié.

    +
    + {{:input type="number" step=1 name="number" label="Numéro du reçu" required=true default=1}} +
    +
    + {{/if}} + +
    + Bénéficiaire +
    + {{:input type="text" name="nom" label="Nom du bénéficiaire" required=true default=$default.name}} + {{:input type="textarea" cols="50" rows="3" name="adresse" label="Adresse du bénéficiaire" required=true default=$default.address}} + {{if $module.config.art238bis && $module.config.type_asso !== 'syndicat'}} + {{if $default.entreprise_forme && $default.entreprise_numero}} + {{:assign is_entreprise=1}} + {{/if}} + {{:input type="checkbox" name="entreprise" value=1 label="Le bénéficiaire est une entreprise" default=$is_entreprise}} + {{/if}} +
    + {{if $module.config.type_asso !== 'syndicat'}} +
    + {{:input type="text" required=true name="entreprise_forme" label="Forme juridique" source=$default}} + {{:input type="text" required=true name="entreprise_numero" label="Numéro SIREN" source=$default}} +
    + {{/if}} +
    + +
    + Période des reçus +
    + {{if $user_select}} + {{:input type="select" name="annees" label="Pour quelle année le reçu doit-il être généré ?" required=true options=$user_select}} + {{elseif $default.date}} + {{:input type="date" name="periode_date" required=true label="Date de versement du don" default=$default.date}} + {{else}} + {{:input type="year" name="periode_annee" required=true label="Année de versement des dons"}} + {{/if}} + {{:input type="date" name="date" required=true label="Date du reçu" default=$default.date}} +
    +
    + +
    + Versements +
    + {{:input type="money" name="montant" required=true label="Montant des versements" default=$default.total}} +
    + + {{if $module.config.type_asso === 'syndicat'}} + + {{else}} +
    +
    Type de don
    + {{:input type="checkbox" name="numeraire" value="1" label="Don en numéraire (en euros)" default=1}} + {{:input type="checkbox" name="abandon_frais" value="1" label="Frais engagés par les bénévoles, dont ils renoncent expressément au remboursement" default=$default.abandon_frais}} + {{:input type="checkbox" name="nature" value="1" label="Don en nature" help="Par exemple don de matériel, prêt de local, prestation de service…" default=$defaut.nature}} +
    + {{/if}} +
    + +
    + Dons et versements effectués par l’entreprise +
    + {{:input type="money" name="montant_nature" required=true label="Montant des dons en nature" default=$default.total_nature|or:0 help="Inscrire ici le chiffre zéro si aucun don en nature n'a été effectué."}} + {{:input type="textarea" rows=5 cols=70 name="description_nature" required=true label="Description exhaustive des dons en nature" help="Décrire ici la nature et quantité des biens et prestations reçus et acceptés, et le détail des salariés mis à disposition"}} + {{:input type="money" name="montant_numeraire" required=true label="Montant des dons en numéraire" default=$default.total_numeraire|or:0 help="Inscrire ici le chiffre zéro si aucun don numéraire n'a été effectué."}} +
    +
    + + + +

    + Note : un reçu créé ne peut plus être modifié, mais seulement annulé. +

    + +

    + + + {{:button type="button" name="preview" shape="eye" label="Prévisualiser"}} + {{:button type="submit" name="create" shape="right" label="Créer ce reçu" class="main"}} +

    +
    + + +{{/if}} + +{{:admin_footer}} \ No newline at end of file diff --git a/src/modules/recus_fiscaux/nouveau_form.js b/src/modules/recus_fiscaux/nouveau_form.js new file mode 100644 index 0000000..3fd7cb4 --- /dev/null +++ b/src/modules/recus_fiscaux/nouveau_form.js @@ -0,0 +1,77 @@ +$('#f_numeraire_1, #f_nature_1').forEach((i) => { + i.onchange = selectType; +}); + +var numeraire = $('#f_numeraire_1'); + +function selectType() { + g.toggle('.type-numeraire', numeraire.checked, false); +} + +selectType(); + +var e = $('#f_entreprise_1'); + +function selectEntreprise() { + g.toggle('.entreprise', e ? e.checked : false); + g.toggle('.particulier', e ? !e.checked : true); + +} + +if (e) { + e.onchange = selectEntreprise; +} + +selectEntreprise(); + +var y = $('#f_annees'); + +if (y) { + function selectYear() { + $('#f_nature_1, #f_numeraire_1, #f_moyens_especes_1, #f_moyens_cheques_1, #f_moyens_autres_1').forEach((e) => e.checked = false); + + let year = y.value; + let d = user_years[year]; + $('#f_montant').value = g.formatMoney(d.total); + + $('#f_moyens_especes_1').checked = d.total_especes > 0; + $('#f_moyens_cheques_1').checked = d.total_cheques > 0; + $('#f_moyens_autres_1').checked = d.total_numeraire > 0 && (d.total_numeraire > d.total_especes + d.total_cheques); + $('#f_montant').form.id_transaction.value = d.id_transaction; + $('#f_numeraire_1').checked = d.total_numeraire > 0; + + var nature = $('#f_nature_1'); + + if (!nature) { + return; + } + + nature.checked = d.total_nature > 0; + $('#f_abandon_frais_1').checked = d.total_abandon_frais > 0; + $('#f_montant_numeraire').value = g.formatMoney(d.total_numeraire); + $('#f_montant_nature').value = g.formatMoney(d.total_nature + d.total_abandon_frais); + + selectType(); + } + + y.onchange = selectYear; + selectYear(); +} +else if ($('#f_periode_date').value == '') { + g.toggle('.periode-date', false); +} +else { + g.toggle('.periode-annee', false); +} + +let p = $('[name=preview]')[0]; + +p.addEventListener('click', (e) => { + let form = e.target.form; + form.action = "previsualiser.html"; + form.target = "dialog"; + g.openFrameDialog('about:blank', {height: 'auto'}); + form.submit(); + form.action = ""; + form.target = ""; +}); \ No newline at end of file diff --git a/src/modules/recus_fiscaux/previsualiser.html b/src/modules/recus_fiscaux/previsualiser.html new file mode 100644 index 0000000..bc57c42 --- /dev/null +++ b/src/modules/recus_fiscaux/previsualiser.html @@ -0,0 +1,32 @@ +{{#restrict block=true section="accounting" level="write"}} +{{/restrict}} + +{{if !$_POST}} + {{:error message="Aucune donnée fournie"}} +{{/if}} + +{{:assign var="preview" value=1}} + +{{:include file="/receipt/_header.html" + page_size="A4" + title="Reçu fiscal %06d - %s"|args:$id:$r.nom + hide_buttons=true +}} + +
    +
    Brouillon
    +
    + +{{:include file="./_recu.html"}} + +
    + {{if $config.files.signature}} + Signature association + {{elseif $config.files.logo}} + Signature association + {{/if}} +
    + +{{:include file="/receipt/_footer.html"}} + + diff --git a/src/modules/recus_fiscaux/recap.html b/src/modules/recus_fiscaux/recap.html new file mode 100644 index 0000000..6e9f21f --- /dev/null +++ b/src/modules/recus_fiscaux/recap.html @@ -0,0 +1,36 @@ +{{#restrict block=true section="accounting" level="read"}} +{{/restrict}} + +{{:include file="./_config_default.tpl"}} + +{{:admin_header title="Reçus fiscaux — Récapitulatif pour déclaration" current="acc"}} + + + +
    +

    Depuis 2022, l'administration fiscale demande aux associations délivrant des reçus fiscaux de procéder à une déclaration annuelle :

    +
      +
    • du montant cumulé des dons et versements perçus ayant donné lieu à l’émission d’un reçu fiscal ;
    • +
    • du nombre de reçus délivrés.
    • +
    +

    {{:linkbutton shape="right" target="_blank" label="Déclarer les dons auprès de l'administration" href="https://www.demarches-simplifiees.fr/commencer/declaration-des-dons"}}

    +
    + +{{#list select="SUBSTR($$.date, 1, 4) AS 'Année'; SUM($$.montant) AS 'Montant cumulé'; COUNT(id) AS 'Nombre de reçus'" where="$$.annule = 0" order=1}} + + {{$col1}} + {{$col2|raw|money_currency}} + {{$col3}} + +{{else}} +

    + Il n'y a aucun reçu enregistré. +

    +{{/list}} + +{{:admin_footer}} \ No newline at end of file diff --git a/src/modules/recus_fiscaux/recu.html b/src/modules/recus_fiscaux/recu.html new file mode 100644 index 0000000..9643fa5 --- /dev/null +++ b/src/modules/recus_fiscaux/recu.html @@ -0,0 +1,34 @@ +{{#restrict block=true section="accounting" level="write"}} +{{/restrict}} + +{{if !$_GET.id}} + {{:error message="Aucun numéro de reçu n'a été fourni"}} +{{/if}} + +{{#load id=$_GET.id}} + {{:include file="/receipt/_header.html" + page_size="A4" + title="Reçu fiscal %06d - %s"|args:$id:$nom + }} + + {{if $annule}} +
    +
    Annulé
    +
    + {{/if}} + + {{:assign var="recu" value=$recu|replace:'%ID%':$id}} + {{$recu|raw}} + +
    + {{if $config.files.signature}} + Signature association + {{elseif $config.files.logo}} + Signature association + {{/if}} +
    + + {{:include file="/receipt/_footer.html"}} +{{else}} + {{:error message="Le numéro de reçu fourni n'a pas été trouvé"}} +{{/load}} diff --git a/src/modules/recus_fiscaux/recu.schema.json b/src/modules/recus_fiscaux/recu.schema.json new file mode 100644 index 0000000..0900aab --- /dev/null +++ b/src/modules/recus_fiscaux/recu.schema.json @@ -0,0 +1,44 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "annule": { + "description": "Annulé", + "type": "boolean" + }, + "entreprise": { + "description": "Entreprise", + "type": "boolean" + }, + "date": { + "description": "Date d'émission", + "type": "string", + "format": "date" + }, + "nom": { + "description": "Nom du bénéficiaire", + "type": "string" + }, + "montant": { + "description": "Montant", + "type": "integer", + "minimum": 0 + }, + "linked_user": { + "type": ["null", "integer"] + }, + "linked_transactions": { + "type": ["null", "array"], + "items": { + "type": "integer" + } + }, + "recu": { + "type": "string" + }, + "id_year": { + "type": ["null", "integer"] + } + }, + "required": [ "annule", "date", "nom", "montant", "recu", "linked_user", "linked_transactions", "id_year"] +} \ No newline at end of file diff --git a/src/modules/recus_fiscaux/snippets/transaction_details.html b/src/modules/recus_fiscaux/snippets/transaction_details.html new file mode 100644 index 0000000..56eba6d --- /dev/null +++ b/src/modules/recus_fiscaux/snippets/transaction_details.html @@ -0,0 +1 @@ +{{*FIXME TODO*}} \ No newline at end of file diff --git a/src/modules/recus_fiscaux/snippets/user_details.html b/src/modules/recus_fiscaux/snippets/user_details.html new file mode 100644 index 0000000..b12f3ac --- /dev/null +++ b/src/modules/recus_fiscaux/snippets/user_details.html @@ -0,0 +1,21 @@ +

    {{$module.label}}

    + +{{#restrict section="accounting" level="write"}} +

    + {{:linkbutton shape="plus" label="Créer un reçu fiscal" href="%snouveau.html?type=user&id_user=%d"|args:$module.url:$user.id target="_dialog"}} +

    +{{/restrict}} + + +{{#load linked_user=$user.id}} + + + + + + +{{/load}} +
    {{:link href="%srecu.html?id=%d"|args:$module.url:$id target="_dialog" label=$id}}{{$date|date_short}}{{$montant|raw|money_currency_html}} + {{:linkbutton shape="eye" label="Ouvrir" href="%srecu.html?id=%d"|args:$module.url:$id target="_dialog"}} + {{:linkbutton shape="mail" label="Envoyer" href="%senvoyer.html?id=%d"|args:$module.url:$id target="_dialog"}} +
    \ No newline at end of file diff --git a/src/modules/recus_fiscaux/voir.html b/src/modules/recus_fiscaux/voir.html new file mode 100644 index 0000000..a84282f --- /dev/null +++ b/src/modules/recus_fiscaux/voir.html @@ -0,0 +1,12 @@ +{{#restrict block=true section="accounting" level="write"}} +{{/restrict}} + +{{:admin_header title="Reçu fiscal" current="acc" hide_title=true}} + +

    + {{:linkbutton shape="left" label="Retour à la liste des reçus" href="./"}} +

    + + + +{{:admin_footer}} \ No newline at end of file diff --git a/src/modules/signup/config.html b/src/modules/signup/config.html new file mode 100644 index 0000000..e6671ec --- /dev/null +++ b/src/modules/signup/config.html @@ -0,0 +1,79 @@ +{{#form on="save"}} + {{:save key="config" + validate_schema="./config.schema.json" + header=$_POST.header|trim|or:null + fields=$_POST.fields|arrayval|or:null + email_waiting_subject=$_POST.email_waiting_subject|trim + email_waiting_body=$_POST.email_waiting_body|trim + email_confirmed_subject=$_POST.email_confirmed_subject|trim + email_confirmed_body=$_POST.email_confirmed_body|trim + }} + {{:redirect to="?ok=1"}} +{{/form}} + +{{:admin_header title="Configuration du formulaire d'inscription"}} + +{{if $_GET.ok}} +

    Configuration enregistrée.

    +{{/if}} + +{{:form_errors}} + +
    + +
    + Informations générales +
    + {{:input type="textarea" cols="70" rows="15" name="header" required=false source=$module.config label="Informations d'inscription" default="Vous voulez rejoindre notre association ?\n\nRenseignez les champs ci-dessous avec vos informations.\n\nUne fois votre fiche renseignée, nous validerons votre inscription dès que possible."}} +
    Syntaxe Markdown acceptée. + {{:linkbutton shape="help" label="Aide de la syntaxe Markdown" href="!static/doc/markdown.html"}} +
    +
    +
    + +
    + Champs que le membre pourra pré-remplir +

    + Chacun des champs cochés sera visible dans la fiche d'inscription, et le membre pourra le renseigner. +

    +
    + {{#select * FROM config_users_fields WHERE type NOT IN ('file', 'password', 'virtual') AND system & (1 << 3) = 0 ORDER BY sort_order}} + {{if $module.config.fields|has:$name}} + {{:assign var="checked" value=$name}} + {{else}} + {{:assign var="checked" value=null}} + {{/if}} + {{:input type="checkbox" name="fields[]" value=$name label=$label default=$checked}} + {{/select}} +
    +
    + +
    + Message envoyé lors de l'enregistrement de la pré-inscription +

    + Ce message sera envoyé au membre quand sa pré-inscription est enregistrée et est en attente de validation par un⋅e gestionnaire. +

    +
    + {{:input name="email_waiting_subject" type="text" label="Sujet du message" required=true source=$module.config default="Votre demande d'inscription a été enregistrée"}} + {{:input name="email_waiting_body" type="textarea" label="Corps du message" cols="70" rows="7" required=true source=$module.config default="Bonjour !\n\nNous avons bien enregistré votre demande d'inscription.\n\nVous recevrez un message quand elle aura été validée par une administratice ou un administrateur."}} +
    +
    + +
    + Message envoyé lors de la validation de l'inscription +

    + Ce message sera envoyé au membre quand un⋅e gestionnaire aura validé son inscription. +

    +
    + {{:input name="email_confirmed_subject" type="text" label="Sujet du message" required=true source=$module.config default="Bienvenue !"}} + {{:input name="email_confirmed_body" type="textarea" label="Corps du message" cols="70" rows="7" required=true source=$module.config default="Bonjour !\n\nVotre inscription est validée."}} +
    +
    + +

    + {{:button type="submit" name="save" label="Enregistrer" shape="right" class="main"}} +

    + +
    + +{{:admin_footer}} \ No newline at end of file diff --git a/src/modules/signup/config.schema.json b/src/modules/signup/config.schema.json new file mode 100644 index 0000000..1ac6dd4 --- /dev/null +++ b/src/modules/signup/config.schema.json @@ -0,0 +1,37 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "header": { + "description": "Informations", + "type": ["string", "null"] + }, + "fields": { + "description": "Champs", + "type": ["null", "array"], + "items": { + "type": "string" + } + }, + "email_waiting_subject": { + "type": "string" + }, + "email_waiting_body": { + "type": "string" + }, + "email_confirmed_subject": { + "type": "string" + }, + "email_confirmed_body": { + "type": "string" + } + }, + "required": [ + "header", + "fields", + "email_waiting_subject", + "email_waiting_body", + "email_confirmed_subject", + "email_confirmed_body" + ] +} \ No newline at end of file diff --git a/src/modules/signup/icon.svg b/src/modules/signup/icon.svg new file mode 100644 index 0000000..0d5f4a0 --- /dev/null +++ b/src/modules/signup/icon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/modules/signup/ignore b/src/modules/signup/ignore new file mode 100644 index 0000000..e69de29 diff --git a/src/modules/signup/index.html b/src/modules/signup/index.html new file mode 100644 index 0000000..7b904ea --- /dev/null +++ b/src/modules/signup/index.html @@ -0,0 +1,5 @@ +{{if !$logged_user}} + {{:include file="./signup.html"}} +{{else}} + {{:include file="./list.html"}} +{{/if}} diff --git a/src/modules/signup/module.ini b/src/modules/signup/module.ini new file mode 100644 index 0000000..2338b06 --- /dev/null +++ b/src/modules/signup/module.ini @@ -0,0 +1,6 @@ +name="Inscriptions (alpha)" +description="(En test) Permet aux visiteurs de s'inscrire à l'association depuis le site web.\nLes inscriptions sont ensuite validées par une personne en charge de la gestion des membres." +author="Paheko" +author_url="https://paheko.cloud/" +home_button=true +menu=false diff --git a/src/modules/signup/signup.html b/src/modules/signup/signup.html new file mode 100644 index 0000000..c63cb19 --- /dev/null +++ b/src/modules/signup/signup.html @@ -0,0 +1,33 @@ +{{#form on="save"}} +{{/form}} + +{{:assign var="custom_css." value="/content.css"}} +{{:admin_header title="Inscription" custom_css=$custom_css layout="public"}} + +{{if $module.config.header}} + {{$module.config.header|raw|markdown}} +{{/if}} + +{{:form_errors}} + +
    +
    + Merci de renseigner vos informations +
    + {{#select * FROM config_users_fields WHERE !fields_list ORDER BY sort_order; + !fields_list="name"|sql_where:$module.config.fields}} + {{:edit_user_field name=$name}} + {{/select}} +
    +
    + +

    + {{:button type="submit" name="save" label="Enregistrer" shape="right" class="main"}} +

    +
    + +

    + {{:linkbutton shape="left" label="Retourner sur notre site" href=$site_url}} +

    + +{{:admin_footer}} \ No newline at end of file diff --git a/src/modules/transactions_templates/delete.html b/src/modules/transactions_templates/delete.html new file mode 100644 index 0000000..5b2702f --- /dev/null +++ b/src/modules/transactions_templates/delete.html @@ -0,0 +1,17 @@ +{{#restrict section="accounting" level="write" block=true}}{{/restrict}} + +{{#load assign="tpl" id=$_GET.id|intval}} +{{else}} + {{:error message="Aucun modèle trouvé"}} +{{/load}} + +{{#form on="delete"}} + {{:delete id=$tpl.id}} + {{:http redirect="./"}} +{{/form}} + +{{:admin_header title="Supprimer modèle"}} + +{{:delete_form legend="Supprimer un modèle" warning="Supprimer le modèle '%s' ?"|args:$tpl.name}} + +{{:admin_footer}} diff --git a/src/modules/transactions_templates/edit.html b/src/modules/transactions_templates/edit.html new file mode 100644 index 0000000..a126d52 --- /dev/null +++ b/src/modules/transactions_templates/edit.html @@ -0,0 +1,151 @@ +{{#restrict section="accounting" level="write" block=true}}{{/restrict}} +{{:admin_header title="Modèle d'écriture"}} + +{{:assign var="types" 1="Recette" 2="Dépense" 3="Virement" 4="Dette" 5="Créance" 0="Avancé"}} + +{{if $_GET.id}} + {{#load assign="tpl" id=$_GET.id|intval}}{{/load}} +{{/if}} + +{{if !$tpl}} + {{* 2 empty lines by default *}} + {{:assign var="tpl.ll.0" l=""}} + {{:assign var="tpl.ll.1" l=""}} +{{/if}} + +{{if $_POST.save}} + {{if $_POST.t == 0}} + {{:assign var="lines" value=$_POST.ll|array_transpose}} + {{/if}} + {{:save id=$tpl.id + validate_schema="./template.schema.json" + name=$_POST.name|strval|trim + t=$_POST.t|intval + l=$_POST.l|strval|trim|or:null + dt=$_POST.dt|strval|trim|or:null + r=$_POST.r|strval|trim|or:null + a00=$_POST.a00|money_int + pr=$_POST.pr|strval|or:null + p=$_POST.p|intval|or:null + ar=$_POST.ar|strval|trim|or:null + ae=$_POST.ae|strval|trim|or:null + ab=$_POST.ab|strval|trim|or:null + at=$_POST.at|strval|trim|or:null + a3=$_POST.a3|strval|trim|or:null + ll=$lines|or:null + }} + {{:http redirect="./"}} +{{/if}} + +{{#select id, CASE WHEN code IS NOT NULL THEN code || ' — ' || label ELSE label END AS label FROM acc_projects ORDER BY code, label}} + {{:assign var="projects.%d"|args:$id value=$label}} +{{/select}} + +
    +
    + {{if !$tpl}}Nouveau modèle{{else}}Modifier le modèle{{/if}} +
    + {{:input type="text" required=true name="name" label="Nom du modèle" source=$tpl}} + {{:input type="select" name="t" label="Type" options=$types required=true source=$tpl}} + {{:input type="text" name="l" label="Libellé" source=$tpl}} + {{:input type="date" name="dt" label="Date" source=$tpl}} + {{:input type="text" name="r" label="Numéro de pièce comptable" source=$tpl}} +
    +
    + {{:input type="money" name="a00" label="Montant" source=$tpl}} + {{:input type="text" name="pr" label="Référence de paiement" source=$tpl}} + {{if $projects}} + {{:input type="select" name="p" label="Projet" options=$projects source=$tpl default_empty="— Aucun —"}} + {{/if}} +
    +
    + {{:input type="text" name="ar" label="Code du compte de recette" source=$tpl}} +
    +
    + {{:input type="text" name="ae" label="Code du compte de dépense" source=$tpl}} +
    +
    + {{:input type="text" name="ab" label="Code du compte de caisse ou de banque" source=$tpl}} +
    +
    + {{:input type="text" name="at" label="Code du compte de caisse ou de banque destinataire" source=$tpl}} +
    +
    + {{:input type="text" name="a3" label="Code du compte de tiers" source=$tpl}} +
    +
    + +
    + Lignes de l'écriture + + + + + + + + + + + + + {{#foreach from=$tpl.ll key="k" item="line"}} + + + + + + + + + {{/foreach}} + +
    Code du compteDébitCréditLibellé ligneRéf. ligne
    {{:input type="text" name="ll[d][]" default=$line.d size=5}}{{:input type="text" name="ll[c][]" default=$line.c size=5}}{{:input type="text" name="ll[l][]" default=$line.l}}{{:input type="text" name="ll[r][]" default=$line.r size=10}}{{:button label="Enlever" title="Enlever la ligne" shape="minus" class="line_del"}}
    + {{:button label="Ajouter une ligne" shape="plus" class="line_add"}} +
    + +

    + {{:button type="submit" shape="right" label="Enregistrer" name="save" class="main"}} +

    +
    + + + +{{:admin_footer}} diff --git a/src/modules/transactions_templates/icon.svg b/src/modules/transactions_templates/icon.svg new file mode 100644 index 0000000..835e489 --- /dev/null +++ b/src/modules/transactions_templates/icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/modules/transactions_templates/index.html b/src/modules/transactions_templates/index.html new file mode 100644 index 0000000..32ab707 --- /dev/null +++ b/src/modules/transactions_templates/index.html @@ -0,0 +1,31 @@ +{{#restrict section="accounting" level="write" block=true}}{{/restrict}} +{{:admin_header title="Modèles d'écritures"}} + + + +{{#list + select="$$.name AS 'Nom'" + order=1 desc=false +}} + {{:assign var="qs" t=$t l=$l dt=$dt r=$r a00=$a00 pr=$pr p=$p ar=$ar ae=$ae ab=$ab at=$at a3=$a3 ll=$ll}} + {{:assign qs=$qs|http_build_query}} + {{:assign url="!acc/transactions/new.php?%s"|args:$qs}} + + {{:link href=$url label=$name}} + + {{:linkbutton href=$url label="Saisir cette écriture" shape="plus"}} + {{:linkbutton href="edit.html?id=%d"|args:$id label="Modifier" shape="edit"}} + {{:linkbutton target="_dialog" href="delete.html?id=%d"|args:$id label="Supprimer" shape="delete"}} + + +{{else}} +

    + Aucun modèle d'écriture. +

    +{{/list}} + +{{:admin_footer}} diff --git a/src/modules/transactions_templates/module.ini b/src/modules/transactions_templates/module.ini new file mode 100644 index 0000000..2b7c0f0 --- /dev/null +++ b/src/modules/transactions_templates/module.ini @@ -0,0 +1,7 @@ +name="Modèles d'écriture" +description="Pour créer des modèles d'écritures comptables, qui permettront ensuite de créer des écritures rapidement." +author="Paheko" +author_url="https://paheko.cloud/" +home_button=true +restrict_section="accounting" +restrict_level="write" diff --git a/src/modules/transactions_templates/snippets/transaction_new.html b/src/modules/transactions_templates/snippets/transaction_new.html new file mode 100644 index 0000000..a7ea9c7 --- /dev/null +++ b/src/modules/transactions_templates/snippets/transaction_new.html @@ -0,0 +1,33 @@ +{{if !$_POST}} + +
    +
    + Saisir à partir d'un modèle : +
    + +
    + {{:linkbutton shape="edit" href=$module.url label="Modifier les modèles"}} +
    +
    + +{{/if}} \ No newline at end of file diff --git a/src/modules/transactions_templates/template.schema.json b/src/modules/transactions_templates/template.schema.json new file mode 100644 index 0000000..cd949d2 --- /dev/null +++ b/src/modules/transactions_templates/template.schema.json @@ -0,0 +1,89 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "name": { + "description": "Libellé du modèle", + "type": "string" + }, + "t": { + "description": "Type de l'écriture", + "type": "integer" + }, + "l": { + "description": "Libellé de l'écriture", + "type": ["string", "null"] + }, + "dt": { + "description": "Date de l'écriture", + "type": ["string", "null"] + }, + "r": { + "description": "Référence de l'écriture", + "type": ["string", "null"] + }, + "a00": { + "description": "Montant de l'écriture (simplifiée)", + "type": ["null", "integer"] + }, + "pr": { + "description": "Référence de paiement", + "type": ["string", "null"] + }, + "p": { + "description": "ID du projet analytique", + "type": ["integer", "null"] + }, + "ar": { + "description": "Code du compte de recette (produit)", + "type": ["string", "null"] + }, + "ae": { + "description": "Code du compte de dépense (charge)", + "type": ["string", "null"] + }, + "ab": { + "description": "Code du compte de banque ou caisse (pour écritures de recette, dépense ou virement)", + "type": ["string", "null"] + }, + "at": { + "description": "Code du compte de banque ou de caisse destinataire du virement", + "type": ["string", "null"] + }, + "a3": { + "description": "Code du compte de tiers", + "type": ["string", "null"] + }, + "ll": { + "description": "Lignes de l'écriture avancée", + "type": ["array", "null"], + "items": { + "type": "object", + "properties": { + "a": { + "description": "Code du compte", + "type": ["string", "null"] + }, + "d": { + "description": "Débit", + "type": ["string", "null"] + }, + "c": { + "description": "Crédit", + "type": ["string", "null"] + }, + "l": { + "description": "Libellé ligne", + "type": ["string", "null"] + }, + "r": { + "description": "Référence ligne", + "type": ["string", "null"] + } + } + }, + "required": [ "a", "d", "c", "l", "r" ] + } + }, + "required": [ "name", "t", "l", "dt", "r", "a00", "p", "ar", "ae", "ab", "at", "a3", "ll"] +} \ No newline at end of file diff --git a/src/modules/web/404.html b/src/modules/web/404.html new file mode 100644 index 0000000..05a11f7 --- /dev/null +++ b/src/modules/web/404.html @@ -0,0 +1,8 @@ +{{:http code=404}} +{{:include file="./_head.html" title="Page non trouvée"}} + +

    + Cette page n'existe pas. +

    + +{{:include file="./_foot.html"}} diff --git a/src/modules/web/_breadcrumbs.html b/src/modules/web/_breadcrumbs.html new file mode 100644 index 0000000..7c3ec71 --- /dev/null +++ b/src/modules/web/_breadcrumbs.html @@ -0,0 +1,8 @@ + diff --git a/src/modules/web/_foot.html b/src/modules/web/_foot.html new file mode 100644 index 0000000..2bf22d1 --- /dev/null +++ b/src/modules/web/_foot.html @@ -0,0 +1,13 @@ + + +
    + +
    + Propulsé par Paheko — logiciel libre de gestion d'association
    + Ce site n'utilise aucun pisteur, conformément au RGPD — Mentions légales +
    + + + + + \ No newline at end of file diff --git a/src/modules/web/_head.html b/src/modules/web/_head.html new file mode 100644 index 0000000..9ecbb8f --- /dev/null +++ b/src/modules/web/_head.html @@ -0,0 +1,117 @@ + + + + + {{if $title}}{{$title}} — {{/if}}{{$config.org_name}} + + + + + + + + + + + {{if $config.files.logo}} + + {{/if}} + + + + + + +
    + +
    +

    + + {{if $config.files.logo}} + + {{else}} + {{$config.org_name}} + {{/if}} + +

    + + {{if $config.org_address || $config.org_phone || $config.org_email}} +
    + {{if $config.files.logo}} +

    {{$config.org_name}}

    + {{/if}} + {{if $config.org_address}} +

    + {{* Lien permettant d'ouvrir l'adresse dans l'app de cartographie du téléphone, ou OpenStreetMap sinon *}} + + + {{$config.org_address|escape|nl2br}} + +

    + {{/if}} + {{if $config.org_phone}} +
    {{$config.org_phone|raw|protect_contact:'tel'}}
    + {{/if}} + {{if $config.org_email}} +
    {{$config.org_email|raw|protect_contact}}
    + {{/if}} +
    + {{/if}} +
    + + + +
    diff --git a/src/modules/web/article.html b/src/modules/web/article.html new file mode 100644 index 0000000..7626a81 --- /dev/null +++ b/src/modules/web/article.html @@ -0,0 +1,16 @@ +{{:include file="./_head.html" title=$page.title}} + +{{:include file="./_breadcrumbs.html" parent=$page.path}} + +
    +

    {{$page.title}}

    + +

    Publié le {{$page.published|date_long:true}}

    + + {{$page.html|raw}} + + {{:include file="./gallery.html" parent=$page.path}} + {{:include file="./documents.html" parent=$page.path}} +
    + +{{:include file="./_foot.html"}} \ No newline at end of file diff --git a/src/modules/web/atom.xml b/src/modules/web/atom.xml new file mode 100644 index 0000000..7d10728 --- /dev/null +++ b/src/modules/web/atom.xml @@ -0,0 +1,35 @@ +{{:http type="application/atom+xml"}} + + + + + {{$config.org_name|xml_escape}} + + + {{#articles order="published DESC" limit=1}} + {{$published|atom_date}} + {{/articles}} + + + {{$config.org_name|xml_escape}} + + + {{$root_url|xml_escape}} + Paheko + + {{#articles order="published DESC" limit=20}} + + {{$title|xml_escape}} + + {{$url|xml_escape}} + {{$published|atom_date}} + {{$config.org_name|xml_escape}} + + + + + {{/articles}} + + \ No newline at end of file diff --git a/src/modules/web/category.html b/src/modules/web/category.html new file mode 100644 index 0000000..83e3278 --- /dev/null +++ b/src/modules/web/category.html @@ -0,0 +1,34 @@ +{{:include file="./_head.html" title=$page.title}} + +{{:include file="./_breadcrumbs.html" parent=$page.path}} + +

    {{$page.title}}

    + +
    + {{#categories parent=$page.path order="title"}} + + {{/categories}} +
    + +{{if $page.html || $page.has_attachments}} +
    + {{$page.html|raw}} + + {{:include file="./gallery.html" parent=$page.path}} + {{:include file="./documents.html" parent=$page.path}} +
    +{{/if}} + +
    + {{#articles parent=$page.path order="published DESC"}} +
    +

    {{$title}}

    +
    {{$published|date_long}}
    +

    {{$html|raw|strip_tags|truncate:200}}

    +
    + {{/articles}} +
    + +{{:include file="./_foot.html"}} \ No newline at end of file diff --git a/src/modules/web/config.html b/src/modules/web/config.html new file mode 100644 index 0000000..2ed5572 --- /dev/null +++ b/src/modules/web/config.html @@ -0,0 +1,139 @@ +{{:admin_header title="Configuration du site web"}} + +{{#form on="save"}} + {{:save key="config" + color1=$_POST.color1|trim|or:null + color2=$_POST.color2|trim|or:null + layout=$_POST.layout|trim|or:null + background=$_POST.background|trim|or:null + }} + {{:redirect to="?ok=1"}} +{{/form}} + +{{if $_GET.ok}} +

    Configuration enregistrée.

    +{{/if}} + +{{:form_errors}} + +
    + +{{:assign var="layouts" + 2col="Classique — 2 colonnes" + 1col="Classique — 1 colonne" + wide="Large — 2 colonnes" +}} + +{{:assign var="backgrounds" + gradient="Dégradé entre les deux couleurs" + white_gradient="Dégradé couleur primaire — blanc" + white="Blanc" + gray="Blanc et gris" + dark="Sombre" +}} + + + +{{if $config.site_disabled}} +

    Le site est actuellement désactivé.

    +{{else}} +
    + +
    +{{/if}} + +
    + Configuration du site web +
    + {{:input type="select" name="layout" label="Disposition sur grand écran" required=true default="2col" options=$layouts help="Sur petit écran (mobile), la disposition reste sur une colonne" source=$module.config}} + {{:input type="color" name="color1" default=$config.color1 label="Couleur primaire" required=false source=$module.config}} + {{:input type="color" name="color2" default=$config.color2 label="Couleur secondaire" required=false source=$module.config}} + {{:input type="select" name="background" options=$backgrounds label="Couleur de fond" required=true source=$module.config}} +
    +
    + +

    + {{:button type="submit" name="save" label="Enregistrer" shape="right" class="main"}} +

    + + + +
    + +{{:admin_footer}} \ No newline at end of file diff --git a/src/modules/web/content.css b/src/modules/web/content.css new file mode 100644 index 0000000..bb29f34 --- /dev/null +++ b/src/modules/web/content.css @@ -0,0 +1,534 @@ +/** + * Ce fichier contient les styles CSS qui s'appliquent au contenu des articles et catégorie, + * que ce soit sur le site public ou dans la prévisualisation de l'administration. + * + * Généralement il n'est pas nécessaire de le modifier. + */ + +.protected-contact::before { + content: attr(data-a) "\0040" attr(data-b) "." attr(data-c); +} + +.web-content { + overflow-wrap: break-word; + word-wrap: break-word; + + word-break: break-word; + + -ms-hyphens: auto; + -moz-hyphens: auto; + -webkit-hyphens: auto; + hyphens: auto; +} + +.web-content figure { + margin: 0; + padding: 0; +} + +.web-content p, .web-content h1, .web-content h2, .web-content h3, .web-content h4, .web-content h5, .web-content h6, +.web-content ul, .web-content ol, .web-content table, .web-content blockquote, .web-content pre { + margin-bottom: 1rem; +} + +.web-content ul, .web-content ol, .web-content dd { + margin-left: 2em; +} + +.web-content ul { + list-style-type: disc; +} + +.web-content ol { + list-style-type: decimal; +} + +.web-content blockquote::before { + content: "”"; + color: rgba(0, 0, 0, 0.5); + display: block; + position: absolute; + font-style: italic; + font-size: 3rem; + line-height: 2rem; + margin-left: -3rem; +} + +.web-content blockquote { + font-size: 1.1em; + padding-left: 3rem; +} + +.web-content a.footnote-ref { + vertical-align: super; + font-size: small; +} + +.web-content a.footnote-ref, .web-content .footnotes dt a { + color: blue; +} + +.web-content a.footnote-ref::before, .web-content .footnotes dt a::before { + content: "["; +} + +.web-content a.footnote-ref::after, .web-content .footnotes dt a::after { + content: "]"; +} + +.web-content dl.footnotes { + display: grid; + grid-template-columns: .1fr .9fr; + border-top: 2px solid rgba(0, 0, 0, 0.5); + margin-top: 1rem; + padding-top: 1rem; + color: #333; +} + +.web-content hr { + border: none; + border-top: 2px solid rgba(0, 0, 0, 0.5); + margin: 1rem 0; +} + +.web-content code, .web-content pre { + background: rgba(100, 100, 100, 0.2); + padding: .2rem; +} + +.web-content pre code { + background: unset; + padding: unset; +} + +.web-content pre, .web-content table { + overflow-x: auto; + max-width: 100%; +} + +.web-content dl.footnotes dd { + margin: 0; + margin-bottom: 1rem; +} + +.web-content dl.footnotes dd p { + margin: 0; + margin-bottom: 1rem; +} + +.web-content table { + border-collapse: collapse; +} + +.web-content table th, .web-content table td { + border: 1px solid #999; + padding: .5rem; + text-align: center; +} + +.web-content table thead { + background: #ddd; + font-weight: bold; + border-bottom: 5px solid #999; +} + +.web-content table tbody tr:nth-child(even) { + background: #eee; +} + +.web-content mark { + background: #ffb; + color: #000; + padding: 1px 3px; + display: inline-block; + box-shadow: 0px 0px 3px 1px #990; + border-radius: .2em; +} + +.web-content kbd { + background: #ccc; + color: #000; + padding: 2px 4px; + display: inline-block; + box-shadow: 0px 2px 3px 1px #999; + border: 1px solid #fff; + border-radius: .2em; +} + +.web-content samp { + background: #333; + color: #fff; + padding: 2px 4px; + display: inline-block; + border-radius: .2em; +} + +.web-content del { + color: darkred; + text-decoration: line-through; +} + +.web-content ins { + color: darkgreen; + text-decoration: underline overline; + text-decoration-color: green; +} + +.web-content var { + color: darkblue; + background: #ddd; + border-radius: .2em; + padding: 2px 4px; + display: inline-block; +} + +.web-content aside.toc { + float: right; + margin-left: 2em; + max-width: 20em; +} + +.web-content .toc { + margin: 1rem 0; + border: 1px solid rgba(0, 0, 0, 0.2); + background: rgba(0, 0, 0, 0.1); + padding: .3rem; + display: block; + width: fit-content; +} + +.web-content .toc ol { + list-style: none; + counter-reset: item; + margin: .5rem 0 .5rem .5rem; +} + +.web-content .toc ol li:before { + content: counters(item, ".") " "; + counter-increment: item; + color: #666; +} + + +.web-content figure.file { + display: inline-block; + margin: .8em; +} + +.web-content figure.file > a { + background: #ddd; + display: block; + border-radius: .5rem; + overflow: hidden; + text-align: center; + transition: background-color .2s, color .2s; + text-decoration: none; +} + +.web-content figure.file img { + border-radius: .3rem; + margin-top: .5rem; +} + +.web-content figure.file small { + display: block; + background: no-repeat .5em center; + background-image: url('data:image/svg+xml;utf8,'); + padding-left: 32px; + font-size: .9em; +} + +.web-content figure.file figcaption { + display: block; + text-decoration: none; + padding: .5rem; + font-style: normal; + color: darkblue; +} + +.web-content figure.file a b { + font-size: 1.2em; + text-decoration: underline; +} + +.web-content figure.file a:hover { + background-color: #ccc; +} + +.web-content figure.file a:hover figcaption { + color: darkred; +} + +.web-content iframe { + margin: 1rem auto; + display: block; +} + +.web-content figure.image, .web-content figure.video { + text-align: center; +} + +/** Video preview (youtube) */ +.web-content figure.video a { + position: relative; + display: inline-flex; +} + +.web-content figure.video a img { + transition: opacity .2s; + background: #000; +} + +.web-content figure.video a::after { + position: absolute; + content: " "; + background: no-repeat center center; + background-size: 40%; + background-image: url('data:image/svg+xml;utf8,'); + display: block; + margin: 0 auto; + left: 0; + top: 0; + right: 0; + bottom: 0; +} + +.web-content figure.video a:hover img { + opacity: 0.7; +} + +.web-content video { + margin: .8em auto; + display: block; +} + +.web-content figure.image figcaption { + font-style: italic; + color: #666; + margin-top: 2pt; +} + +.web-content figure.image.img-center { + max-width: 90%; + margin: 0 auto 1rem auto; +} + +.web-content figure.image.img-left { + max-width: 250px; + float: left; + margin: 0 8pt 4pt 0; + clear: left; +} + +.web-content figure.image.img-right { + max-width: 250px; + float: right; + margin: 0 0 4pt 8pt; + clear: right; +} + +.web-content figure.image.img-left figcaption, .web-content figure.image.img-right figcaption { + font-size: .8em; +} + +.web-content a.internal-image { + cursor: zoom-in; +} + +.web-content img, .web-content object { + max-width: 100%; +} + +.web-content .gallery, .web-content .slideshow { + margin: 1em; +} + +.web-content .gallery .images { + display: flex; + flex-wrap: wrap; + grid-gap: 10px; + justify-content: center; +} + +.web-content .gallery figure { + flex: 1 1 auto; + max-height: 180px; +} + +.web-content .gallery img { + object-fit: cover; + width: 100%; + height: 100%; + vertical-align: middle; +} + +.web-content .gallery .images::after { + content: ""; + flex-grow: 1; + width: 1px; + display: block; +} + +.web-content .slideshow { + position: relative; + width: 100%; + margin: 1em auto; +} + +.web-content .slideshow .images { + display: block; + position: relative; + overflow: hidden; + margin: 0 auto; + height: 450px; + width: 100%; + white-space: nowrap; +} + +.web-content .slideshow .index { + display: flex; + align-items: center; + justify-content: center; + flex-wrap: wrap; +} + +.web-content .slideshow button { + border: none; + border-radius: 50%; + background: rgba(0, 0, 0, .2); + color: #000; + font-weight: bold; + font-size: 12pt; + min-width: 3ch; + height: 3ch; + margin: .2em; + cursor: pointer; + text-align: center; +} + +.web-content .slideshow button:hover { + color: darkred; + background: rgba(255, 255, 255, .2); + box-shadow: 0px 0px 5px #000; +} + +.web-content .slideshow button.current { + background: rgba(255, 165, 0, .3); + box-shadow: 0px 0px 5px #999; +} + +.web-content .slideshow .nav { + position: absolute; + top: 0; + left: 0; + right: 0; + height: 450px; + display: flex; + align-items: center; + justify-content: space-between; +} + +.web-content .slideshow figure, .web-content .slideshow a { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; +} + +.web-content .slideshow img { + max-width: 100%; + max-height: 95%; +} + +.imageBrowser { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + z-index: 9999; + background: url("data:image/svg+xml;base64,PCEtLSBCeSBTYW0gSGVyYmVydCAoQHNoZXJiKSwgZm9yIGV2ZXJ5b25lLiBNb3JlIEAgaHR0cDovL2dvby5nbC83QUp6YkwgLS0+Cjxzdmcgd2lkdGg9IjM4IiBoZWlnaHQ9IjM4IiB2aWV3Qm94PSIwIDAgMzggMzgiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgc3Ryb2tlPSIjZmZmIj4KICAgIDxnIGZpbGw9Im5vbmUiIGZpbGwtcnVsZT0iZXZlbm9kZCI+CiAgICAgICAgPGcgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMSAxKSIgc3Ryb2tlLXdpZHRoPSIyIj4KICAgICAgICAgICAgPGNpcmNsZSBzdHJva2Utb3BhY2l0eT0iLjUiIGN4PSIxOCIgY3k9IjE4IiByPSIxOCIvPgogICAgICAgICAgICA8cGF0aCBkPSJNMzYgMThjMC05Ljk0LTguMDYtMTgtMTgtMTgiPgogICAgICAgICAgICAgICAgPGFuaW1hdGVUcmFuc2Zvcm0gYXR0cmlidXRlTmFtZT0idHJhbnNmb3JtIiB0eXBlPSJyb3RhdGUiIGZyb209IjAgMTggMTgiIHRvPSIzNjAgMTggMTgiIGR1cj0iMXMiIHJlcGVhdENvdW50PSJpbmRlZmluaXRlIi8+CiAgICAgICAgICAgIDwvcGF0aD4KICAgICAgICA8L2c+CiAgICA8L2c+Cjwvc3ZnPg==") no-repeat center center; + background-color: rgb(0, 0, 0); + background-color: rgba(0, 0, 0, 0.75); + display: flex; + align-items: center; + justify-content: center; + flex-direction: column; + transition: opacity .5s; + cursor: zoom-out; + margin: 0; + padding: 0; + width: 100%; + height: 100%; +} + +.imageBrowser figure { + max-height: 90%; + max-width: 90%; + display: inline-flex; + align-items: center; + justify-content: center; + transition: opacity .5s; + margin: 0; +} + +.imageBrowser figure img { + max-width: 100%; + max-height: 100%; + border: .5em solid #000; + border-radius: .5em; + cursor: pointer; + background: #fff; + /* For transparent images */ + background-image: + linear-gradient(45deg, #ccc 25%, transparent 25%, transparent 75%, #ccc 75%, #ccc), + linear-gradient(45deg, #ccc 25%, transparent 25%, transparent 75%, #ccc 75%, #ccc); + background-size: 10px 10px; + background-position: 0 0, 5px 5px; +} + +.imageBrowser figure.loading { + opacity: 0; +} + +.imageBrowser figure.loading img { + border: none; +} + +.web-grid { + /* Default grid template: just auto sized columns */; + --grid-template: none / repeat(auto-fit, minmax(100px, 1fr)); + grid-gap: 1rem; + display: grid; + margin-bottom: 1rem; +} + +.web-grid-debug { + grid-gap: 5px; + min-height: 10em; + width: 20em; + grid-template-rows: 6em; + grid-template: var(--grid-template); +} + +.web-grid-debug .web-block { + background: #999; + overflow: hidden; + padding: 5px; + color: #333; +} + +@media handheld, screen and (max-width: 980px) { + .imageBrowser figure { + max-width: 100%; + max-height: 100%; + } +} + +@media screen and (min-width: 800px) { + .web-grid { + /* Get template from variable, which is defined style attribute */ + grid-template: var(--grid-template); + } +} \ No newline at end of file diff --git a/src/modules/web/default.css b/src/modules/web/default.css new file mode 100644 index 0000000..acd57e2 --- /dev/null +++ b/src/modules/web/default.css @@ -0,0 +1,976 @@ +/** + * Ce fichier contient les styles CSS qui s'appliquent au site public. + */ + +/* CSS RESET */ +body, form, p, div, hr, fieldset, dl, dt, dd, ul, ol, li, h1, h2, h3, h4, h5, h6 { + margin: 0; + padding: 0; +} +h1 { font-size: 2em; } +h2 { font-size: 1.5em; } +h3 { font-size: 1.2em; } +h4 { font-size: 1em; } +h5 { font-size: 0.9em; } +h6 { font-size: 0.8em; } +ul, ol { list-style-type: none; } +article, aside, figure, footer, header, hgroup, menu, nav, section { display: block; } + +:root { + --bg-color: #fff; + --text-color: #000; + --link-color: #009; + --visited-link-color: #669; + --hover-link-color: darkred; + --gray-text-color: #666; +} + +/* Modifications du style pour les grands écrans */ +@media screen and (min-width: 981px) { + /* DISPOSITION GÉNÉRALE DE LA PAGE */ + main { + max-width: 950px; + margin: 0 auto; + display: grid; + } + + .layout-1col main { + grid-gap: 1em; + grid-template-columns: 1fr; + grid-template-areas: + "header" + "nav" + "content" + } + + .layout-2col main { + grid-gap: 1em; + grid-template-columns: 1.4fr 0.6fr; + grid-template-areas: + "header header" + "content nav" + } + + .layout-wide main { + max-width: 1100px; + grid-gap: 1em; + grid-template-columns: 18em 1fr; + grid-template-rows: auto 1fr; + grid-template-areas: + "header content" + "nav content"; + align-items: flex-start; + padding: 0 1em; + } + + .layout-wide header.main { + grid-area: header; + display: flex; + grid-gap: 1em; + flex-direction: column; + } + + .layout-wide section.page { + background: var(--bg-color); + border-radius: 0 0 .5em .5em; + padding: 1em; + } + + .layout-1col section.page { + background: var(--bg-color); + border-radius: .5em; + padding: 1em; + } + + header.main h1 a img { + max-height: 300px; + } + + .layout-2col header.main, .layout-1col header.main { + grid-area: header; + display: grid; + grid-gap: 1em; + grid-template-columns: 1.4fr 0.6fr; + } + + nav.main { grid-area: nav; } + section.page { grid-area: content; } + + header.main h1, header.main .contacts, nav.main { + background: var(--bg-color); + padding: 1em; + border-radius: .5em + } + + .layout-2col header.main .contacts { + border-top-left-radius: 0; + border-top-right-radius: 0; + } + + header.main h1 { + border-top-left-radius: 0; + border-top-right-radius: 0; + padding: 0; + } + + .layout-wide header.main h1, .layout-1col header.main h1 { + font-size: 1.2em; + } + + .layout-2col section.page article { + margin-bottom: 1em; + padding: 1em; + background: var(--bg-color); + clear: both; + border-radius: .5em; + } + + .layout-wide .breadcrumbs { + margin: 1em 0; + text-align: left; + } + + .layout-wide section.articles article, .layout-1col section.articles article { + margin: 1em 0; + padding-bottom: 1em; + border-bottom: 2px solid var(--gray-text-color); + } + + .layout-wide section.page > h1 { + text-align: left; + } + + .layout-1col nav.main { + padding: 0; + background: none; + display: flex; + flex-wrap: wrap; + justify-content: center; + } + + .layout-1col nav.main .booking-btn { + margin: 0; + } + + .layout-1col .search-widget p { + max-width: 20em; + margin: .5rem; + } + + .layout-1col nav.main .booking-btn a { + border-radius: .5em; + padding: .2rem .5rem; + } + + .layout-1col nav.main .subcategories { + margin: .5rem; + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: .5rem; + } + + .layout-1col nav.main .subcategories li a, .layout-1col nav.main .subcategories li:first-child a { + border: none; + } + + .layout-1col nav.main .subcategories li a { + background: hsl(var(--first-color), 50%, 95%); + border-radius: .5rem; + } + + .layout-1col nav.main .subcategories li a:hover { + background: var(--bg-color); + } + + .layout-1col nav.main .opening-hours { + display: none; + } +} + + +/* CORPS */ +body { + font-size: 100.01%; + font-family: "Trebuchet MS", Helvetica, Sans-serif; + background: #eee; + background: hsl(var(--first-color), 50%, 90%); + background: linear-gradient(200deg, hsl(var(--first-color), 50%, 90%) 5%, hsl(var(--second-color), 30%, 90%) 30%); + min-height: 100vh; + color: var(--text-color); +} + +body.bg-white { + background: #fff; + --bg-color: hsl(var(--first-color), 50%, 95%); +} + +body.bg-dark { + background: linear-gradient(-200deg, #333 10%, #000 30%, #333 70%); + --bg-color: hsl(var(--first-color), 50%, 10%); + --text-color: #fff; + --link-color: #99f; + --visited-link-color: #c9c; + --hover-link-color: #c99; + --gray-text-color: #999; +} + +body.bg-dark header.main h1 a span { + color: var(--text-color); +} + +body.bg-dark header.main h1 a:hover span { + color: var(--hover-link-color); +} + +body.bg-white_gradient { + background: linear-gradient(-200deg, hsl(var(--first-color), 50%, 90%) 5%, #fff 30%); +} + +body.bg-gray { + background: #fff; + --bg-color: #eee; +} + +a { + color: var(--link-color); +} + +a:visited { + color: var(--visited-link-color); +} + +a:hover { + color: var(--hover-link-color); +} + +/* ENTÊTE AVEC LOGO ET CONTACTS */ +header.main h1 { + padding: 0; + background: none; + border-radius: 0; +} + +header.main h1 a { + display: flex; + padding: 1rem; + background: var(--bg-color); + border-bottom-left-radius: .5rem; + border-bottom-right-radius: .5rem; + text-decoration: none; + height: calc(100% - 2rem); + align-items: center; + justify-content: center; +} + +header.main h1 a img { + max-width: 100%; +} + +header.main h1 a span { + font-size: 1.5em; + font-weight: normal; + color: hsl(var(--second-color), 30%, 30%); + display: block; +} + +header.main h1 a:hover span { + color: hsl(var(--second-color), 50%, 50%); +} + +header.main h1 a:hover img { + opacity: 0.8; +} + +header.main.home h1 a span { + font-size: 2em; +} + +header.main .contacts { + font-size: 1.3em; + display: flex; + justify-content: flex-end; + flex-direction: column; +} + +header.main .contacts .map { + color: inherit; + text-decoration: none; +} + +header.main .contacts .map svg { + fill: hsl(var(--second-color), 60%, 40%); + background: hsl(var(--first-color), 50%, 90%); + padding: .25em; + width: 1.2em; + height: 1.2em; + border-radius: 50%; + float: right; + transition: fill .2s, background .2s; +} + +header.main .contacts .map:hover svg { + fill: hsl(var(--second-color), 50%, 50%); + background: hsl(var(--first-color), 70%, 95%); +} + +/* NAVIGATION EN HAUT DE LA PAGE (ACCUEIL/CONNEXION) */ +header.nav { + background: #ddd; + border-bottom: 1px solid var(--gray-text-color); +} + +header.nav ul { + display: flex; + justify-content: center; + padding-top: .2em; +} + +header.nav li a { + padding: .1em .5em; + color: #666; + text-decoration: none; + font-size: .9em; + margin: 0 1em; +} + +header.nav li.current a { + background: var(--bg-color); + border-radius: .3em .3em 0 0; +} + +header.nav li a:hover { + color: var(--text-color); +} + +/* LISTE DES CATÉGORIES RACINES */ +section.page .subcategories { + text-align: center; + margin: 2em 0; +} + +.subcategories li { + font-size: 1.2em; + margin: .8em 0; + padding: 0; +} + +.subcategories li a { + margin: 0; + padding: .2em .5em; + color: var(--link-color); + text-decoration: underline; + background: #ddd; + background: hsl(var(--first-color), 60%, 80%); + border-radius: .5rem; + display: inline-block; + transition: background-color .2s, color .2s; +} + +nav.main .subcategories { + margin: 0 -1em 1em -1em; +} + +nav.main .subcategories li { + margin: 0; +} + +nav.main .subcategories li a { + padding: .5em 1em; + display: block; + border-radius: 0; + border-bottom: 2px solid hsl(var(--first-color), 50%, 85%); + background: none; +} + +nav.main .subcategories li:first-child a { + border-top: 2px solid hsl(var(--first-color), 50%, 85%); +} + +section.page .subcategories li a { + background: var(--bg-color); +} + +section.page .subcategories li a:hover, nav.main .subcategories li a:hover { + color: var(--hover-link-color); + background-color: #eee; + background-color: hsl(var(--second-color), 50%, 90%); +} + +/* Formulaire de recherche */ +::target-text { + background: #ff0; + padding: .2em; +} + +.search-widget p { + display: flex; + flex-direction: row; + align-items: center; + margin-bottom: 1em; + height: 2em; +} + +.search-widget input { + font-size: 1.2em; + padding: .2em .5em; + border: 1px solid #999; + border-radius: .3rem; + line-height: 1rem; + width: 100%; + height: 100%; +} + +.search-widget input[type=submit] { + color: #fff; + border-radius: 0 .5rem .5rem 0; + border-left: none; + cursor: pointer; + text-indent: -70em; + overflow: hidden; + background: #ccc no-repeat center center; + width: 1.5em; + height: 100%; + margin-left: -.4rem; + background-image: url('data:image/svg+xml;utf8,'); +} + +/* PIED DE PAGE */ +footer.main { + color: var(--grey-text-color); + margin: 1em; + text-align: center; +} + +footer.main a { + text-decoration: none; + font-weight: bold; +} + +footer.main a:hover { + text-decoration: underline; +} + +footer.main a#paheko { + padding-left: 36px; + background: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAWBAMAAABTd4N3AAAAGXRFWHRTb2Z0d2FyZQB3d3cuaW5rc2NhcGUub3Jnm+48GgAAAAlwSFlzAAAAdgAAAHYBTnsmCAAAADBQTFRFBUqmR3BMAwl4KZDEBD2dATCVBy+TJU6mC2y1BCWU/v//m7jbUXC3c53NvtPp3ur1QHGW/AAAAAh0Uk5T/gD6+WqzLO5BK691AAABR0lEQVQY02MQhAKRcIbyECDNAOMrMXR0dLTABcSCJnAABTjLYAIOmkB+swUDO1QglXNG8yogWHcgECwgwjCT23jXqvX/V61hY3BJFHPVnFnAd6N/1QOGU+tZGCJncirNPHmu7wfvqscW3avaGTKBApUPTp1e27HqRUfvqgMMohNqzux4W7VnNcevFx28qxoYRCZVnr59ftbn/x2vXnT0r2dgEFSaW8MzV+dAb8erv7d/rWFnEHTS3HOaW/vl645XQIfcYGUQFFVg+LOvau9q41+/d99gYGEQFNNk2LTmVv1i41UvOGfOnAZ0adLMOTdfn3psu+oFw8yZIKeLTZqj9GDX5b5VDzo4ZzqC/JKkpDTztYUV0GEdrRDfBikpXQBa8qJhgiFEQCxy5kyGu3cvW7DCQkwkknMCR7OxsSMiTFNCmY1NEoEMABmgiBzVXBbdAAAAAElFTkSuQmCC") no-repeat left center; + min-height: 22px; + display: inline-block; +} + +/* CHEMIN VERS L'ARTICLE (BREADCRUMBS), affiche les catégories parentes */ +.breadcrumbs { + margin-bottom: 1em; + text-align: center; +} + +.breadcrumbs ul { + margin: 0; +} + +.breadcrumbs ul li { + display: inline-block; +} + +.breadcrumbs ul li::before { + content: "»"; + color: var(--gray-text-color); + margin: .5em; +} + +.breadcrumbs ul li:nth-child(1)::before { + display: none; +} + +.breadcrumbs a { + color: var(--gray-text-color); +} + + +/* MESSAGES ALERTE ET ERREUR (par exemple : page non trouvée) */ +.error { + border-bottom: .2em solid #c00; + border-radius: .5em; + background: #fcc; + padding: .5em; + margin-bottom: 1em; + font-size: 1.2em; + color: #900; +} + +.alert { + border-bottom: .2em solid #cc0; + border-radius: .5em; + background: #ffc; + padding: .5em; + margin-bottom: 1em; + font-size: 1.2em; + color: #660; +} + +/* AFFICHAGE D'UN ARTICLE */ +section.articles article h3, section.articles article h1 { + margin-bottom: .3em; +} + +section.articles article::after, article.single::after { + content: ""; + display: block; + height: 0px; + clear: both; +} + +section.articles article h1 a { + color: var(--text-color); + text-decoration: none; + font-weight: normal; +} + +section.articles article h3 a { + color: var(--link-color); + font-weight: normal; +} + +section.articles article h3 a:visited { + color: var(--visited-link-color); +} + +section.articles article h5 { + color: #666; + font-weight: normal; + font-size: .8em; + margin-bottom: .3em; +} + +section.page > h1 { + text-align: center; + margin-bottom: 1em; +} + +/* CONTENU DE L'ARTICLE */ +article > h4 { + margin-bottom: 1em; + color: var(--gray-text-color); + font-weight: normal; +} + +article ul, article ol, article blockquote { + margin-left: 2em; +} + +article ul { + list-style-type: disc; +} + +article ol { + list-style-type: decimal; +} + +article dl dd { + margin: .5em 0 .5em 2em; +} + +article img { + max-width: 100%; +} + +article figure { + text-align: center; +} + +article figcaption { + font-style: italic; + color: #666; + margin-top: 2pt; + font-size: .8em; +} + +article > h1 { + margin-bottom: 1rem; +} + +/* GALERIE D'IMAGES EN DESSOUS DE L'ARTICLE */ +section.gallery, section.documents { + display: flex; + flex-wrap: wrap; + justify-content: center; + align-items: stretch; + gap: 1rem; + margin: 1.5rem 0; +} + +section.gallery figure, section.documents figure { + max-width: 12rem; + margin: 0; + padding: 0; +} + +section.gallery figure a, section.documents figure a { + display: flex; + justify-content: center; + align-items: center; + text-align: center; + flex-direction: column; + height: 100%; +} + +section.gallery figure a img, section.documents figure a img { + box-shadow: 0 0 5px 1px #999; + border-radius: .25em; + max-height: 150px; + background: #fff; + margin: .5rem; +} + +section.documents figure a { + text-decoration: none; + border-radius: .5em; + background: hsl(var(--first-color), 50%, 90%); +} + +section.documents figure a figcaption { + background: no-repeat bottom right; + margin: .5rem; + border-radius: .5em; + display: block; + font-size: 1em; + font-style: normal; + color: #666; + transition: background-color .2s, color .2s, box-shadow .2s; +} + +section.documents figure a:hover { + background-color: #eee; + color: #333; + box-shadow: 0px 0px 5px hsl(var(--first-color), 50%, 50%); +} + +section.documents aside a:hover figcaption b { + color: darkred; +} + +section.documents figcaption b { + display: block; + font-size: 1.1em; + text-decoration: underline; + color: darkblue; +} + +section.documents figcaption span { + font-size: .8em; +} + +/* FORMULAIRES */ +fieldset { + border: 2px solid var(--gray-text-color); + border-radius: .5em; + padding: 1em; + margin: 1em; +} + +fieldset legend { + padding: 0 1em; +} + +fieldset input, fieldset textarea, fieldset select { + padding: .5rem; + border: 1px solid #999; + border-radius: .3rem; + font-size: 1.2em; +} + +fieldset input[type=submit] { + background: #999; + color: #fff; + cursor: pointer; +} + +input:focus, textarea:focus, select:focus, button:focus { + outline: none; + box-shadow: 0px 0px 5px 2px orange; +} + +fieldset dl dd { + margin: .5em 1em; +} + +aside.admin { + float: right; + text-align: right; + position: fixed; + right: 1rem; + top: .5rem; +} + +aside.admin a { + display: inline-flex; + align-items: center; + justify-content: center; + text-decoration: none; + background: #700; + color: white; + border-radius: 1rem; + padding: .2rem .8rem; + position: relative; + border: 2px solid #eee; +} + +aside.admin a[data-icon] span { + font-size: 1rem; + margin-left: .5rem; +} + +aside.admin a[data-icon]::before { + content: attr(data-icon); + font-size: 2rem; + line-height: .5em; + display: inline-block; + text-align: center; +} + +aside.admin a[data-icon]:hover span { + text-decoration: underline; +} + + +/** Encart module réservations */ +.booking-btn { + padding: 0; + margin: -1em; + margin-bottom: 1em; +} + +.booking-btn a { + display: flex; + background: hsl(var(--second-color), 35%, 25%); + border-radius: .5rem .5rem 0 0; + color: #fff; + font-size: 1.3em; + padding: .5rem; + justify-content: center; + align-items: center; + transition: box-shadow .25s, color .25s, background .25s; +} + +.booking-btn a b { + font-weight: normal; +} + +.booking-btn a span { + background: no-repeat .5rem center; + background-image: url('data:image/svg+xml;utf8,'); + width: 2em; + height: 2em; + display: block; +} + +.booking-btn a:hover { + box-shadow: 0px 0px 5px 2px orange; + color: #fcc; + background: #966; +} + +/** Encart module horaires d'ouverture */ +.opening-next h3 { + text-align: center; + font-size: 1.3em; +} + +section.page article.opening-next { + background: var(--bg-color); + background: hsl(var(--second-color), 30%, 35%); + border-radius: .3em; + color: #fff; + box-shadow: 0px 0px 5px #000; + margin-bottom: .5em; + padding: .5rem; +} + +section.page article.opening-hours, article.opening-hours { + display: flex; + justify-content: space-between; + background: none; + padding: 0; + gap: .5rem; +} + +article.opening-hours table th, article.opening-hours table td { + padding: .2rem .4rem; +} + +article.opening-hours table { + width: 100%; +} + +section.page article.opening-hours table { + text-align: left; + white-space: nowrap; +} + +article.opening-hours .list { + background: hsl(var(--first-color), 50%, 92%); + padding: .5rem; + border-radius: .5em; + color: #000; +} + +article.opening-hours em { + display: block; +} + +article.opening-hours ul { + margin-left: 1.5rem; +} + +article.opening-hours h4 { + color: #666; +} + +nav.main article.opening-hours { + flex-wrap: wrap; + font-size: .9em; +} + +nav.main article.opening-hours div { + width: 100%; + text-align: left; +} + +nav.main article.opening-hours h4 { + text-align: left; +} + +nav.main article.opening-hours table tr { + display: flex; + flex-direction: column; +} + +/* Modifications du style pour les petits écrans */ +@media handheld, screen and (max-width: 980px) { + body { + padding: 0; + } + + main, header.main { + display: block; + } + + header.nav { + font-size: .9em; + margin: 0; + } + + header.main h1 a, header.main .contacts { + border-radius: 0; + margin: .2em 0; + padding: 0; + } + + header.main { + background: var(--bg-color); + padding: 1em; + } + + + header.main .contacts * { + text-align: center; + font-size: .8em; + } + + header.main h1 a img { + max-height: 200px; + max-width: 100%; + } + + .search-widget p { + display: block; + text-align: center; + } + + .search-widget input { + font-size: 1em; + } + + nav.main ul, .subcategories { + text-align: center; + } + + nav.main ul li, .subcategories li { + font-size: 1.2em; + display: inline-block; + margin: .3em; + } + nav.main ul li a, .subcategories li a { + background: var(--bg-color); + } + + .breadcrumbs { + display: none; + } + + nav.main { + font-size: 1em; + background: none; + } + + section.page article { + border-radius: 0; + } + + section.page h1 { font-size: 1.5em; } + section.page h2 { font-size: 1.3em; } + section.page h3 { font-size: 1.2em; } + section.page h4 { font-size: 1em; } + section.page h5 { font-size: .9em; } + section.page h6 { font-size: .8em; } + + footer.main { + font-size: .8em; + } + + .booking-btn { + margin: 0; + } + + .booking-btn a { + border-radius: 0; + } + + .search-widget { + margin: .5em; + } + + nav.main .subcategories { + margin: 1em 0; + } + + nav.main .subcategories li a { + border: none !important; + background: rgba(255, 255, 255, 0.7); + margin: .2em; + border-radius: .5em; + } + + .search-widget input { + width: 80%; + } + + nav.main .opening-hours { + display: none; + } + + section.page article.opening-next { + border-radius: 0; + } + + section.page article.opening-hours, article.opening-hours { + flex-direction: column; + margin: .8em 0; + } + + .articles article, article.single { + margin: .5rem 0; + background: var(--bg-color); + padding: .5em; + } +} diff --git a/src/modules/web/documents.html b/src/modules/web/documents.html new file mode 100644 index 0000000..c6b491a --- /dev/null +++ b/src/modules/web/documents.html @@ -0,0 +1,15 @@ +
    +{{#documents order="name" parent=$parent except_in_text=true}} +
    + + {{if $thumb_url}} + {{$title}} + {{/if}} +
    + {{$title}} + {{$format}}{{$size|size_in_bytes}} +
    +
    +
    +{{/documents}} +
    diff --git a/src/modules/web/email.html b/src/modules/web/email.html new file mode 100644 index 0000000..1d2a5c1 --- /dev/null +++ b/src/modules/web/email.html @@ -0,0 +1,76 @@ +{{* Ce squelette est utilisé pour l'envoi d'un e-mail au format HTML *}} + + + + + + + +
    + +{{* Le contenu du mail est dans la variable $html, ne pas supprimer sinon le message sera vide ! *}} +{{$html|raw}} + +{{* D'autres variables sont disponibles, permettant de personnaliser le message : + - $recipient contient l'adresse email du destinataire + - $data contient les données disponibles pour le message (par exemple $data.nom contiendra le nom du membre dans un message collectif) + - $context contient le contexte du message (0 = changement de mot de passe, 1 = message privé entre membres, 2 = message collectif) + - $from contient l'expéditeur (si NULL, l'expéditeur sera l'association) +*}} + + + +
    + +{{* Le lien de désinscription sera ajouté automatiquement en bas du message, il n'est pas possible de le modifier ou le supprimer. *}} + + diff --git a/src/modules/web/gallery.html b/src/modules/web/gallery.html new file mode 100644 index 0000000..3fbe1b5 --- /dev/null +++ b/src/modules/web/gallery.html @@ -0,0 +1,7 @@ + diff --git a/src/modules/web/icon.svg b/src/modules/web/icon.svg new file mode 100644 index 0000000..d611933 --- /dev/null +++ b/src/modules/web/icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/modules/web/index.html b/src/modules/web/index.html new file mode 100644 index 0000000..4078e05 --- /dev/null +++ b/src/modules/web/index.html @@ -0,0 +1,33 @@ +{{:include file="./_head.html" home=true}} + +{{#module name="openings"}} +
    + {{:include file="/openings/_next_opening.html"}} +
    +
    + {{:include file="/openings/_all.html"}} +
    +{{/module}} + + +{{#articles order="published DESC" limit=1}} +
    +
    +

    {{ $title }}

    +
    {{ $published|relative_date }}
    +

    {{ $html|raw }}

    +
    +
    +{{/articles}} + +{{#articles order="published DESC" begin=1 limit=9}} +
    +
    +

    {{ $title }}

    +
    {{ $published|relative_date }}
    +

    {{ $html|raw|strip_tags|truncate:200 }}

    +
    +
    +{{/articles}} + +{{:include file="./_foot.html"}} \ No newline at end of file diff --git a/src/modules/web/module.ini b/src/modules/web/module.ini new file mode 100644 index 0000000..93c336f --- /dev/null +++ b/src/modules/web/module.ini @@ -0,0 +1,5 @@ +name="Site web - Thème par défaut" +description="Thème à deux colonnes simple (2022)" +author="Paheko" +author_url="https://paheko.cloud/" +web=true diff --git a/src/modules/web/print.css b/src/modules/web/print.css new file mode 100644 index 0000000..c1dadea --- /dev/null +++ b/src/modules/web/print.css @@ -0,0 +1,19 @@ +body { + font-family: Georgia, Times New Roman, serif; +} + +header.main, footer.main, nav.main, header.nav, .breadcrumbs { + display: none; +} + +article.single > h1 { + font-size: 1.7em; +} + +article.single > h4 { + color: #666; +} + +h1, h2, h3, h4, h5, h6 { + font-family: Verdana, Arial, sans-serif; +} \ No newline at end of file diff --git a/src/modules/web/robots.txt b/src/modules/web/robots.txt new file mode 100644 index 0000000..f0f875e --- /dev/null +++ b/src/modules/web/robots.txt @@ -0,0 +1,7 @@ +User-agent: * +Disallow: /admin/ + +Sitemap: {{$root_url}}sitemap.xml + +User-agent: GPTBot +Disallow: / diff --git a/src/modules/web/search.html b/src/modules/web/search.html new file mode 100644 index 0000000..4370898 --- /dev/null +++ b/src/modules/web/search.html @@ -0,0 +1,25 @@ +{{:include file="./_head.html" title="Recherche"}} + + + +{{:include file="./_foot.html"}} \ No newline at end of file diff --git a/src/modules/web/sitemap.xml b/src/modules/web/sitemap.xml new file mode 100644 index 0000000..47662e3 --- /dev/null +++ b/src/modules/web/sitemap.xml @@ -0,0 +1,16 @@ + + +{{#pages limit=1 order="modified DESC"}} + + {{$root_url}} + {{$modified|atom_date}} + +{{/pages}} + +{{#pages limit=10000}} + + {{$url}} + {{$modified|atom_date}} + +{{/pages}} + diff --git a/src/pubkey.asc b/src/pubkey.asc new file mode 100644 index 0000000..d183bd0 --- /dev/null +++ b/src/pubkey.asc @@ -0,0 +1,51 @@ +-----BEGIN PGP PUBLIC KEY BLOCK----- + +mQINBGPbkSYBEADJ6b2bY8Uva2JSdt/fsjhY0ZD/BEeD9ersNK1OOcZVyqI9Z+hR +J2BIvbEyCiPxmG2+A7/KVCOJrrgt2dmw1soXS4ePepYoiq36Oqjp/Nn2kVMOF6e1 +XeS+O/KVIfLMLZOFItlGFsMOdZRLMzjbI3aUTsKPuIpdJ6A5NEodBZvfGHdXzV03 +sGEw2T9uPi3AqR+ioKxyDmTvcAWiI9NsZXDhN0mRg/IQoAs9pkHhkbUV1BmxNOFi +uuaDbptH3jdYhiQb1E7BJJU5CNhr9Zv1F7PD0Gr4Q5OgoDnk/MZKv4MVsl+zP444 +bHbuiAzfWkm7QbaM+NF+NCcb7ujULJH/Ujgbascatc5dNDF51cA4BDZekjeOUI7Z +DIsg1UtkB8d3VRKlw+J0Lt9ZyH7zwKB7Jzk6Gbn1/YSBnVWq0SZmomqiUVeBWBv7 +gogFsbUkD35mafdBVdkRV4Yce9nrmDwog+5d7jriOKbYQ0MmSwcBeHbHnEGt72kx +Eov8YlzssdqNDTLZUFix5I0LZHAaNT8LmjvkVuyz18J8EDq+x150e4ThP4orhAkB +c/Of3B8OmYlG0fgM3zeawbvE16gnQ1InH2AGNLxBghizjSgHRDOo5gzUDWjXlGN3 +/z2/7+yLSiUEUskz4gCVLuOFhXrCRyGcSpUyBA/nGlfJ/tC3HQTyqHDFrwARAQAB +tBlQYWhla28gPGRldkBwYWhla28uY2xvdWQ+iQJOBBMBCgA4FiEE2gznv2g1PQbF +cEHuJiyrIeahkikFAmPbkSYCGwMFCwkIBwIGFQoJCAsCBBYCAwECHgECF4AACgkQ +JiyrIeahkikITA/+Lt+0Qwu7qIrvJ7Pgt5EgC3QGGSwlNl9lGErSuOQ0jpNExwyB +QhQRHA8lnidrFlRX5FMg6IeyEoyn7NXjY7dzA5lkeNn5mWePl4pkLPz7XXS7Pwqd +gyr5pc7Y9BpTjPsp2izZr5sbBlzYdFDXaMo69DdZHLILTI0LK0qALfBDcqQn4e/Y +/82m5nBqsmoGYup2HnTctBjgAamjhJMyGQ/JXDjBc6PV2O+Ew7n9kp8dwbtY4UpM +Oy8Gs4dzk4Ra/t6O4Zo0MporLZ6zulyWRVgvU4dVbtJFNJEKa4DXMxUOklilFX// +uu665gEAS+F6f/Cb6OFHM7IxdLp4ng6z4A+jVxj4vXDgq5CP1F/L8EUqtl2kcXdU +IQuzba7s8pVDM8ujLwR/s8VbgG1w+xYbZtqkEqpoAIIezvXmx2gnnuCAakw+JaP9 +QSHmmD/5MW/GVXPw3L2+cgmI5axmkPP02A9BSlMcdV82Od5s2Nnat2AAkqPbjK3u +/TTFRAumSTv51ezBTRldSxgYHEoWQBdvv7i+SnZniffYdvxTKklIhK2xuXY1Bzku +5Dk9SR0oQ1DvQA+TFK5XFhaWl1iuuzoTD4TW6r5NLqEHRUdxHpe8QTAkQi4hhKyb +7nY2IgjREqnrLvdfm9KSWjrV26Fe04vcRSYbzcvr6EdDwGmT7PNbOx9gt+a5Ag0E +Y9uRJgEQAKNek9274WllUatGVThIjZvpYtpu5Q53TEZozYTRp4WLJDNtH5W7D0vK +8icr9e7CUILIQKhL6peugJXLj1HXok2vuK0ITWLCseApuXyvdzX8+5Z3QC1gzC6z +AjA0r7Kc6cPg+nUL7SHSKuTY1+LdadC4AYYTFN9hq+QcEBtx+dunTxdjjsyu4aJc +h2g8565mRiccRY1LSgYpmM8Blypj+WjJ1KF7v+JNhvfiAoadPnVvUODMq1pdHW4E +ucIP7Supw+qIVlRmaK+STDPEDU8diy7YMiC+dvxJLV1Yl91qLx9EK5RcvkcUUlvD +w3ESotzEJSiRF1Rdv1bUAtIVi7ZJGYfqr2PFOnGLWJRWYxwFXpv9ADk1Onnu33T2 +tybsVUN+zVHdo3q7vDbSYXbaeeFg4voaJuR9kn3nDKS5Lidh2HQiUWphVz0vScRD +a//nEYwwsu93aTPK/gW5BxAp/LLEjQgjXSH3DK3FNksy3gyf5zdg8WqzPqWocXGR +P6UqChW1ujXQ8dNDRH8arqz5q0kgoFgsY90Hf4aIB0HmCdlsPK8BtTMQUS4oZLNA +G6l/52PnOvkhodYzvWShS4e8Uo6GH8b/mmo0Z7qzlkpuBScYDx02PirHOqrto842 +xUvlk252n4ZCc5zNLa1Zv93afpt+1abihki/i4nPherUtGkoCby5ABEBAAGJAjYE +GAEKACAWIQTaDOe/aDU9BsVwQe4mLKsh5qGSKQUCY9uRJgIbDAAKCRAmLKsh5qGS +KZUPD/9CPZzOvWQCuJlPiDbENZRbLMudShseDlfddkDmAL/9mTprc7j0WWxnMVgC +mObW6t/wiOP4ARw5/KCr3xrZ/O7aO7Fn98WxyTYdEr0gmE8m+nlalHuIDnfktp9O +qyCZ2qjQaGKY0fJLUkyDCJRHa4jOST56LdpH/FxjAcJcP1MTJssy0LxgB2e+FUGy +JdT3+4jUMBO1NBiM84LaV47tygEYdVbO0KP/uRHK3cKGLhGwMT/LdOLc2nmxfoty +ZkM5nP0gxVizQOrXlDOEqRiZ/GyG8TZD91URzReZ8ssALbD+HQiuCllvodxqWiW8 +ZsWi0/6Ht5mb1t4m1+Wy+Gukdh/a6/n/W4/ajWIOpUxN71e0wd+jqEll424DrLD6 +t/M2rtz6BKHum2rSlIu9UOHDXmXKjEpz2XJP8kJsJc5AdOQS9gLn3aiNoyLgDAuf +RlSHkj9nx7XmxNR7m7UKap7Glp9RrHo4PuCczQYbgAWfoeoESBq6bqGS/4vl5c8E +FYB7uQ5YA71prEdDmuDsEq0tI7RkM68d4KqLM4Ag3OWVIDpDGhTtjizCe908o7zl +uhpLMHCE963KKx+zL7ktThmpHz8V8/sqfbDylmSC2suaJhJCi+i5RakThDdw5DaS +5yJfKGPwOjx8m2A6POwV4CGnhAcE9VXzlUCP2XU73JVcMJ3QuA== +=4SMJ +-----END PGP PUBLIC KEY BLOCK----- diff --git a/src/pubkey_old.asc b/src/pubkey_old.asc new file mode 100644 index 0000000..d367665 --- /dev/null +++ b/src/pubkey_old.asc @@ -0,0 +1,140 @@ +-----BEGIN PGP PUBLIC KEY BLOCK----- + +mQINBEzkiQIBEACyW7c1IJXTxln18VSEXZJITqU4GHhS0lqOVxKTqQGmd6BIscu/ +8NyDPdYpNhz3eQ090cCAk2dxxgj5rmLM21Wj8cwYCXLNOsZRGeSufyqgGYWdG5DY +7rJyUFfjwy+xPqoeqcy/W9aIcH1X1k+DfAsgsqhwM97lk6tEg4Zn0eKmWGjcKzla +pJ66Wdv+oRD6T01X7XJ/QQghxQmqupt1u1xbWEOXAaO6tSVEbpUDaODq0YsV3J+9 +owX8gJb2B7UkegXcIaEsuzXsZmlWIL1COyElcTtxh4n+Wtu1dImn0Hv2oprzJ9ub +B3/zEmhNl2+dTjurfrQYt/f4LXuDB32AZlpB2SR8H2dvTwsd+RUuUMsv/nj4lAyl +Vovny1ihilIxZcxYSMadXYvOR7GLmDi2LSsasVJ8fpOQDhYZam7AyOs4lZ0gT4AC +rqVdQGvlEtWrkQGTGmyuMsyi4IUC4aMFJkxmS9f8Dy1Ogu0KN9nAak6PJEs483oQ +o8ip59dTAo55b7OVPUEeX6g68cIiHptMa8pV9RY16sP1RStqKj3XU3+pErEwqUk3 +5HpXNOuO7ALFW29GEq/mjQ12FuTRawtPv6BBPtMRNFakx0V2iX78D5cDDUfze0k9 +iVkcM+ZtXnmA/wbQzyVev/isWrrLG2+/iatxd4bQPSOddh1GDPn+sQIxGwARAQAB +tBpCb2h3YVogPGJvaHdhekBib2h3YXoubmV0PokCWAQTAQoAQgIbAwYLCQgHAwIG +FQgCCQoLBBYCAwECHgECF4ACGQEWIQTEmSR5ui6KXULBggGSydpxuIjqNAUCYAgA +HQUJFuXeGwAKCRCSydpxuIjqNGQTD/wJLpxo+vpOFrKEayvM8LPx6hMBQpbDJPed +Imm8vSE/rviOFy7TLQluJSCAm7LmZvBpt/BjI7x/0XQQ6TLbcFgPxIGAcLKTfjNR +CsYqEUYkQwCcNaeNWaIvtrU3j9N98kHqVWWK5Puvhb7Kn7WGxOpvZsww39u/fLV4 +qlsIGbij2osTEon6C9Wg8vOo66y1YEBE/YCrOAYfwbUL/gmIn+dre1LYRRPExmOb +/iMpkiCAEb5FEaF7dM/c/+J1U1+7KxjTKMdSPhNYzp4DzPHH8QCv40SoxigU9Bx9 +u/HEElXO4iTnlvwFxRra61Aujfs8YtbtbOs0piOnVd2IxzaQpf86vY8/BFhVRJ/V +mFjocXIIb3GEbXz87s4Mg36O67a1hJcw1vh4BYS72nU9x3C4A+sgXF1wI/4/Z5in +NXSkILLSfye1dbO6q7Fz155StWSlbWDQLkZUM8Q0NUYYqj6iWkgxamOisQaXbXDG +XAaSbi577a0H6TB6vKji7xtZesVuDrgke3vdaiiU4JNPVaHLAOHxqJ6xzL8b1yVh +j/m9bU7dl0JIIlliJmGXLnCzlafoZDi6nSprRIoouLux7hksewo/Tc50hLRPXOXv +r3jULYm4ycs99JVzKdwD3VmOHUQVcCUEUoeV/9YgglJelS++IeXn+Ff5HbzaTH6V +NXzVKwRJ6ohGBBARAgAGBQJM5IoSAAoJEIcBgRWB1GUopvMAn3mTVSM68dWRoSdM +h+c30PhNGhUTAJ9A/gcuObRY4PUiqy3YRqtC7s8OGokCQQQTAQIAKwIbAwUJCWYB +gAYLCQgHAwIGFQgCCQoLBBYCAwECHgECF4AFAkzkieYCGQEACgkQksnacbiI6jQs +DA/+K6RXTJ4Ha7n+02su1wOw9NO5FhHcY8kX48PTvjn8ea/AiLLnc3d3NmYzj3dC +hMogGBoEEBfVQ3SbQkW+Lw1OrsBV88wPanvBPepobUVC1Oi9KARMaET3LRZg0GOH +IfWNJIi7IZyIyuBUUdaPjIENjBQTp2HEaLe4ZtHmSlIeZAjTEIlJJnKUFUQ9uNB4 +awmtOP7VPwDU9kLFxFBLQHSCnBJoe3U40F4pv9CBUSyCY+bU5APcYFSvG6UTkD+m +KX6kCMKdADLf75N6DwoRdm+EX9rf3S9YJNNQalfNFBWZy1DG5m4oTmxl/uZAII7C +VN5c68n82GH/99YXQbH42TeU7j5A0HCDsmDM8D836UKpiBZU0E4JRjPhMzoelsMv +IWPnzsPNNrf43llOszT6vhSZ007XH7/p3XxznvsWoU8dzo4d9GFB6E1vDEpa6XFf +ptoFRJzfBXFlSrrvVqtDccG2Lnu+87tR/F9XPoQlRPPG98Akx//6DGNgKVeJTfkG +wSf0Qk6dIkF7bthbHw93hzeDsa0DuqBDIZY+5W8/4nqztzT0e4i0KbrhNNeHSpB3 +pVamb3645JG4ZcQ7Ak6ISC//8A8WyyCXvL5SNlgoSBzeCZyFQB2JFmpohtVFpdrs +MXbipmpBFJ2ZrnM8iksCQPcBmddHMug1HLjXvjVeol74UrWJAkEEEwECACsCGwMG +CwkIBwMCBhUIAgkKCwQWAgMBAh4BAheAAhkBBQJP6l5zBQkMa9bmAAoJEJLJ2nG4 +iOo0edoP/2V3W11XDV7L0j6cxuzu8DR3WZiOD7NY9rIVmwR05CYEUlfx2DwOq1BL +ORri11YXfYaQN8Kd3wYlyvjfanGCmvnovM5fRGAEf/k/ORuIeJG8e79zAtyfhq8W +pCMq6jqzI7eUPhuMlwC52Ysg1/g9Iklkdh7gOpTCwWLENlI9q9eVmxtCDgLNUQC1 +z9xa5urBf/2q5agUsTPUBt0RjXQSy1RbSGxTA7tQ5zB59MHTOSkWDX1cX1VHbBXB +wKqxYhPrUDJdMBjDeSCfbh60xIh5gniNV24MQPe64QGnIe3eb2Qwo1Y0KrjMS83+ +Y8KyMEo58O7rsGfsaInQqtWc35EMrRrOTXNRjqoRZNBeymnb1+3aRxFc+NJwO5jb +QRAXUD3H9bZG5Mj4C7T3NJXuhYyBqA+mDfoPzlBKgPr3vUFL6vRevjcmV49vnXvr +SFFaLAGGtkZLDt8YsvWXVlmfJYJNtQIa8PB+MkV4KYMfHRicFFeR0ms2LMmBJZBX +ClY2u2LHtgjU/z2eYe1fEgJVV3woRj5UvQlzge46/TJOjyWsgqm3U4qtn1e6fXQp +LAv65WVHOE0iapre/VBr2pVBAESTnxk0ioh1r4JQAb8GNuU1M77/14FtB8DsGRq3 +3oIW4EYLHD5DjyOnzC8WUCZcauC7HyoJ9r/FxSfkGytGPA/4n+PkiQJBBBMBAgAr +AhsDBgsJCAcDAgYVCAIJCgsEFgIDAQIeAQIXgAIZAQUCWwXfRQUJE8TwvwAKCRCS +ydpxuIjqNMEYD/9OpGPJ310KzYmD2uN4OQYwQX0kTiflJk5LZU0e6ho88leCZQvV +0e+lUM4wV4BqZkqVLi/+2BOQ8nhW4jw8phxFAp9AC2z0YoYa/MuLGu4Du9h7iy0D +06u0dWyNFbj+h0a0mfwmhbz/fiDQvLK+HIzViLv6kdCI20b8s2OHiTi97ALJAjAx +cWPydGJb/oii3TvFkE2RUPSCRE/ulsa/Ximuwo66Pi8TiDkEJiErnKmqyRGSgEgr +DJDXpiil9KKFltqGGdbxAxkshi8mKuojoOKCkV4oQftycC9lp2kK3Vlix6zL276A +pSI7P6sQt0eCXVAdjGb09F3uTfkynK090PlQTTjZJSGKpNuBC3vKYEHj9ZeUezpH +uNgXnjEPt3H7BOqk978wTi02OUZpu3McNubxf4IbwrsxKXfM1KYT6uNRStIgalMc +WDkzmpu6z/QY+fJxmMVrqbZMMRHAKu62PoOtdnkVjlEWpRHh0oDxQx+mu9MhnO94 +NIXfg6UzYjum7t+vh7vZPjra8/1sBEUGHuxRo4mvK2SDTYgxSra55u5nXefjOvNN +0IVeI512kAWj+02zl74FIfQwv+zsAW8Cyg71ym1VNoDOsKuhJJOF/Ydnv2yrmpp0 +oszNAUWEElssW1EUq7PsFUKVfPxIC8z/rrf8H7HqlXmhgPjIzoo4QWGr/rQXQm9o +d2FaIDxib2h3YXpAa2QyLm9yZz6JAlUEEwEKAD8CGwMGCwkIBwMCBhUIAgkKCwQW +AgMBAh4BAheAFiEExJkkebouil1CwYIBksnacbiI6jQFAmAIACIFCRbl3hsACgkQ +ksnacbiI6jT52g//c98FZhfgtDtyUTFqZDVnLqYnD7XTZ4S1zn9M4KfXQEAkuMD3 +WaZxyGSnwnnaIjdhbUYW6mQlmzsdvZ6Rm0NFK5bewfjpNNKDlL754vMONqMubKZC +u7Wr184myhgwhV0noAb2qPpxcMUYAIP8P2j6LVafNTKmdlrLPymrPdpI++6iOp7S +7lYvcehVmIewbgfavGjFOgRHiKTC1KtLRN5CXxuRx6q1KEKmWc1m4s84qwAePN8/ +nirq4fftPqmR4+MBrWFCTvuz9AkSg+frnzxx4juOkkohIF101lm0r4k4qAa73oZP +MajuZElV718Vew+88J3FpSXEHIoXw6qywHtcxlqn8LJA6SqRkNR1Q3BtIAvwT2L9 +vCrQwo2DAXQ7ciZYWS9QCgWYRuCkxQOO5aBpRQXgE+W4FmFgSLjbR3tCcOAtplZ6 +O/FwXdK9q1q0lARmO4Y/Vr1uWkzt1pR5M79fWRou8Cgodc0ZEVEQTvCNWZTIwAuk +VIMO1funQvitwTlGnSwJzHlb6ZaDcnh5Ri8q1ptjfX3hC53BurLWzCQ15GjK5p+R +m24YL+6Fc0zUCQGwp+iHG7olXYOJ2oGvJdVCXzuCe+xEA5SE2YNieoVEw2uGh0VJ +i53/xRFV8y/AYZvczfWqADDmOrM2Z0aUrfZxySel+EJIH6rnV0284ygsDNKIRgQQ +EQIABgUCTOSKEgAKCRCHAYEVgdRlKBnXAJ92m4YJsfcTBAAvPaF4iZElYqHN2gCf +bYf4UQcYBvRMyJoSTP2rTfDSUryJAj4EEwECACgFAkzkiXoCGwMFCQlmAYAGCwkI +BwMCBhUIAgkKCwQWAgMBAh4BAheAAAoJEJLJ2nG4iOo0hY0P+gNlJb3nBePD/mN4 +6FdZwJKpcNwXbj+VVF67LHqYqIjZLaTy45L54f0ia8efHSdqUwG6AHiNUJFKA8Le +Jgxw9/xcUgFhpkZQlq5jYW+d/QfpNyrDJHmy1YG319RjiWexLO+TZTqQaqnoJO0L +REmmcExYqW0YGCJHQV63htiF9FLZhs0elX94XzePm22CHcsqJKuM4flnEwK1ZqiK +q4ScKs4vXoaUBX7mXJdBUSvFLKJTpq6abaF/G9mCZXaCOF0lcet3XiByh7vvvthr +5g0tiSyS8IKIPW+R6nmO3lqAwHvJt44YW5n+zJ0ZyUabtHM9qGJHAaB0DClzk6Z7 +5J7xOncmJ8Iasx8/u7wEigGrBCbXdadFmKZA+KeEa2xGJuy/HwalzK0VQGk6FMhc +wWXsS41m6zYuSLGphtHKZltTVcHtCW9jIxMlPNybYGgZz5w/69ydUgFA4iyRsfXy +yIrqrvI79ddjA/Fa81EPfqSIj0bMWCMecsehehoT1c0mJOza7edx5F2AZqYfHBn9 +PuGyjvKuS4aQjtw1KvB2iLsYd/HkGFypZDqb271ssu7rK+JojzirPktBikohzmge +7PSJ84DKRIC97ki3T5v/UtU3815aQAqFFcWCdddVABT9DPYU35iQQ/wTrKKulHux +m+E35BwqR+SD4muk4Q5D30MU8a/9iQI+BBMBAgAoAhsDBgsJCAcDAgYVCAIJCgsE +FgIDAQIeAQIXgAUCT+pedwUJDGvW5gAKCRCSydpxuIjqNIPFD/97ZEk6+WqhvrUy +MD2cqJfTCRXXl9ImodT6K1XZgOxsh6Co4qi0zyKHXCLbwXByJ4WZm3XhvBo+uhM4 +qYIBruLrFfGVuk0IlXhaAdSZirOowCniO2PCJ2B7deEfeFmeJelb+KZfmuYe47Ln +jIjtDnVfHdPXo18JJu2OABkJzEcTvvyEkyH4hUpiIYRIvs8WtBzSQffZ48GL1DgY +ea7r7x8CUNfS59bsxJVg/BBt6Un5s63w5JiQF2Dlu49K3nBaEma4jfmrowNO5zbM +8mRMdKod23K1Y68EWu168tPj/bUbszV3TAca42DTarQZYnRn0xiqvC2I47PS2pIy +e/spQH4FbjQouD/543A7++nmd16gkLZoly2/Tg0oTaaFKy1EubiPXPRWnBlF9bit +J85uQNDmjid2Py2YCUGsHRUHBz7BtfZVHX6rnCN1BXNiyUEb1hgCvbaquYfCdjwV +N/c/W/anDtgsarqyvdAgpuDySe8+JEJ2RDggZBGjk1xXKLsPyOJ1McILPQ8Uk/lh +2rnGQ5hy9/RhZ7NvKi8yYban/6yemcYCw9YnK1odHpbeX/BTkQvWmzC2mOGUHN1b +DSmgidNISBxk4ukGhfxj3L3ytTBQMT3ZCRrHo6lOH614RxV1Y9JID7s82szARm3q +sKd1YWmk1fCdkt4b6JO06TologVz0IkCPgQTAQIAKAIbAwYLCQgHAwIGFQgCCQoL +BBYCAwECHgECF4AFAlsF30cFCRPE8L8ACgkQksnacbiI6jR6Vg//QaZ8JXj5BNtI +bp4IbqjGiKFQ0ofr4zKpyu+W5hDgMPoBhW3T1FVXYeBmoaWv1TsGjixwq0ucdBWn +y6+NvL09jBvq5LpKp2+r2KI9vfLjHbLc/cSiZI+fN2/6PJTZMFqf2rWmRCEAW6Gj +vwEVDHlHgMXVMuJiFX1bT8JPvYStZ2v0su2960rBYNA2NxVg0iYE1K8p3Xvugsop +pXuX5jltZNh1I2jPGN9wcYGV69N6Pu8mkSzGm+uiRWrkYKT+w6V2W4jYbuJiNQ7Z +b1lz6+lP07t3o+at3BaafxWjIhIX/rWvtedO32OFF4HO29sa2GWrVNdAa8ViITYW +XpeVHeqmAHPjamEettmKoE4dG3K/5H6sBaF8hJRxff8x4yEbjo0t2jod5hG16+Wx +VohU2JdAazI3rdFRQXgV+vnlRaYv5cb//tur4VnqyTHmpIjq7WRXVmdworCjMyZp +I6xtFZKePQrYm2fAl3aBdTZ9FexV3G3we/gYfD0Gfg2LLYC0TU+ifai0JdPhmuWl +b6IV1BNsnLpYnb+nRS+kY41blo+i/EuUaezL/rN85a4GIWz8YbLkhX/kmyAgks5t +wL7HsLJV+b4SMJnPyX9Yq7wvQICqFwXHEqhwfCV8KCE5VY4RaPVk9ToB84KS4NIF +hNtb8c6GTmpyVZeKp0bavh+ovlL+FhG5Ag0ETOSJAgEQAMQE81j1XU7qUWB7rk6b +UI1f18I1vnFapWGtnagfrEhk5hcP+zO+zIyQx30Vj7gqv1cjHQYn6/BwhL0vlRV8 +iswZHfybIMGx/Zx4c5vkzJY9G88h/xg5GdE3nPAoh99bTXYNA1NcTWrQ3Nnads50 +khhHyHsopaZI7LDDo8X2f+BE5J9DdLFxQo7wtH8JdUCVNmuF4QB+soMnT7luEgjA +Q0Vlud4mYnwdNrYFofu7BL0BlEO3lIGgB6NexBTife4BO7sxZQ9zqilSWh8I5DM9 +W6A8uCwM5FThrByHY4rNf/++5USkXpdd5YfgPuqxktdH5NBF0gKnBfKSqMbZuA6W +NsxxzzTgdNstgd1yZACV5aR7T29/py8HyL9Uw/YyZTYGOgFiPbPstYRAEkvVWM14 +1FNqTWaIXn/PxnKWiFB6IElUcuz9a5z6Jg0RSwV/loEHaJ6idGkMTDwLJ6N+DvXF +Hw7SoUALQnfdu+GU0SZANYzn00hYdzM2Z8E9NKU5fRMT9XERigubGEisn8p+912V +Binnfurhi5kboL/WfYEkYPoKbVkvOV+7kHjARWIDmWSpGpptAVYCfekVqI6jnI6q +THib/qA4xugt3TrFXftRpExJoemg4e7lL6JTLHF/J8BkzjbMef1PctrVI1PYiDQJ +0Shcuyrs814BYSwrT+5CL6ETABEBAAGJAjwEGAEKACYCGwwWIQTEmSR5ui6KXULB +ggGSydpxuIjqNAUCYAgAUgUJFuXeUAAKCRCSydpxuIjqNKWPEACvP9Q7zB4bgiAl +y64sj/jM0OiEMUPhB9u8ZRxzgWw0PqpetKYtiPww3kTF+xk7HCUjt0e12h11p3sX +OWwBfOstLohdb/XJx2hzpFA99SfxbdbDS+WIhHNeRSoTtun1RNGpnmBQAd07A8OM +iUkgP4PJmT9Lx7AcsVR5khAYaCNvAZfrdQrR3Urq6ZIR5r4JQM45/pJQ4Oq19yEq +d89hU3ej+iz2+19Uis4iHY2XpCcP6IZNPawalhlji/gWlKHbOgk454qIptcSvzCx +/gdmifkV6/kzKYWOy691PFpRT40Q9ipRllyajTFpYWe/dNw9tAICjTT6WNOwBMjB +rATutspRxiDDtdl41Kbbca7n8AW/aB4BISkvEZUnMBcxf+go88/fN8ZasyNEtwdT +443UrNnrC3ZVBgqUNSAPfTgEv2XAMlWAeBJOpVsRoZBiXnPsUDYs024toQCXpN2a +3Bn+fFL9BXPor2B0vd1YB/yhAvCWcJ2WhBzcZ01HCAanL1DBa8uvg4p2BwuHSUuW +R5HaZNMj6WJSj0Uk06LFTLaOfCv6ROANhvk37NRx6MX2QI4vEkbycYMBGp14l/g0 +oNKtb3gzOgA4QElT3iu9za3JpYF19qCnS+yMtw7sMy4J33SRO2vJ8RLgbgZWZTqD +LXuQsMKeyq2w2PXDtjLNmCUMNnTR/A== +=6UFY +-----END PGP PUBLIC KEY BLOCK----- diff --git a/src/pubkey_signed.asc b/src/pubkey_signed.asc new file mode 100644 index 0000000..40bba4e --- /dev/null +++ b/src/pubkey_signed.asc @@ -0,0 +1,71 @@ +-----BEGIN PGP MESSAGE----- + +owF9V2vsNNVZBwoVJ9BStAhYIhWKNhOY3dmZnRmBNnO/7s59d2exkLnszs59Zmdn +dmcsGCG0XIoQwEiVUIFg2pRXaANN+9JWWzCNRai2sSi9KGjRIFVSUxoKFv9Fbfzk ++XbOk+fDye/yPL/bTn/LCcCJt//Fs9Vnb3gROfHYaZQHlI2XrLpL3Nr3v3X7RRf/ +9FAsL07P13jtfM2mFJE+X2ad8ylFpeU3ywCQ6eKU4jUvMR2KJRlp7MGeg9utC0tm +sIPWdbxxBksGotgVQ6y29VQeqqq/nHWVSCzBjQFIMCW2HtvRkXbIeBgkMUie0aq0 +3YY7OMj2w7pYmMhKW5VOEVWjsVrFJTTN4WQ2UbnxaggsViaoHvWIa2WiLFVO3KU8 +V0/UYGkokz72xJFrW7WsNWIZSGMSnbJFQC3bNS8Ei342GAE1z+5hi2i0aERWBhgV +8qFjMqv1yXkkEtN6uWA200FmhJCoF2RNlImwSTx7NqSyw1TlIqBpXMYrd8IoDpxN +pHtDFqMkyUbp6WZLLNshh2nMgN8iOqqGBZMn0GQpt8hkVqdgryEIAniC10Rkv54n +GaZ77gSccuCU9j2siW1FEiA7Dr0jVNydjwZThkOHPolQzHKVxCvVFrElwIh1OLR3 +CYUHo5khp3tQGig7YtkJWL+XKUzqkzHv5UPIMal8Nq8G5jIrsiqyZytqTrUYEBYh +V3t2wozQzF0H1CxIjBni+Csi32bMvghBNMDibaTKnqMPJpm596mV4Ak5y+8wODkA +bNHiTtrXdVBNGUtZ2lx0QMWBshRId2rhSha3yazp+iEu4SxTgYchOlgh1kZDiu2G +TCjAh9T1iMLVzEn5wTqcjPqVu/dadjgOc30o5gJM8lPlQIWbqI/NUDAYtUDD3mbm +8SLlpyMA6mEIAzvFjGzWrpMeCemZ0qjcZrGljY73zdLuKBLK+XQtQTt6JOhWVwkM +t92TBqmTFLCjUt2Zb1IXxkONN9Igofb/c28c+NAGcx2MdEmlqAlFhyTCRSwLh33e +wuFQ0z0O8FmhkaJuK67cTRIlHJm9qQua3084ep+I1F7kOb2QaLKmKcqhyT1LCyFL +cwhJ0mGiA/+nWbRICFR24EDfN1glblsJ08Idyob0SOd5c59OUyLl2a3ZqPogLqfs +Yd9RgL7RDYHE0zwKtlxqLFBuEo7FVccWXY5NF7GDBT2JpslqmqPZfKWlSJkoWo8t +Fiam7asACLstWvqYQ1ClFWt1CUf9covWHpX2TsAxC3dSjAkmWAqKqFhH8MqDilTW +FONXeo6sIAeAcDhDc6qqs4J3mhIWcsvfUXFIulm8kSYdr0PSgokpf6zNYBVk91hO +JCUe7L2dg9jlBFA7nK+RoE8Qw4V2YxVZFoNJWWyV5bhv0m5uzMLWRoKZt5O4qcTK +LsIsJgdbTdIo5RYQdKTG8RgNWdIEufEaor2xygkTTDwESonk4bhHSDCeHWKkXTBh +hdLakIMUnLWrXQon/iKwAVFves/FarycMRO8iZW9AdX4zAv54R48ON5yVyVsVRak +KK76dpEd4DDPG5p0kyPZuRoB6KaQZQyETuYQP1to+5ECg36Yiah7yBJNG8AkQZnp +xA9mOKwGaA1Pc3cHk2RSaV4sjxoAsizOIJvMtFp0uOop64iM5iF0BLaY61TQtlgE +mvkyj9ZrJ2gPlpyk4kaGD83CGVJ90gAokxCmMSj0IdPqJGhxMrrgNu48HUZN0xcW +g1jz8RadKhUrGHZwEMoVrltkokfIZiN3HoDlDiyGscFW+VZpg3VGyOY83s7gMbca +IK1vmI7X++12zAbMns8sTJt66oEId6CLkuGABRyiMaSQ1Ul5ukoIGEPmaWq7O35m +bcR42ZbOrmxQHR1Z7LLoHcsokbkiMdOdgM4xZtDKAB75W2KF0baoiLq8Ucblqgml +hRIPhUWRwG0jD0RrrtD1iiybRdcG/QIH0eVIp4dhT497gIzJwRaT/bGvhWBuK5gp +mHJjOUNQCdyARkjHsbgpsalA3Wep3QEMmtw6BHFcdw3iSj6wgUMcHaOZEfm+4QwV +M3TKbIJTaVfG4DyWhjKHtaA03bTriCzcQMtnra0yk2pYBsIcYYHGFzXMbMo9WImz +1MhcGTQtRmMZGw+iDnMmEQ0ewScps6GTEsNKORCsjBp+m/i2nbYMsB+xZrHrWcmM +DG5oBO3Qs8mdOIuwpcQ762oLa5ya88pcMubOYc8typYgmWSoHvFxNLJgYNd59cye +gv1MCIpRhbWMZzoLz12tuBBpC1dqDCLJRzkjm6gSBRtY0CN7Xm5m/aA1fYMBXAjK +WWe/rxti5FqaDIVzlDqQJaQobKyH8cIURow84qZJ3Y3Cbo32QYjPq16r5oW/4A1A +G9sVvZkPm3ih40fDyxBwd1v1aDVIwoILa4cYCGvEFamBkNFBWmsyTu2siW6bSLFU +piTAj1MIhbVcbZNNETh9Ozc3R/sAbhdjXsA9KMuKwRKr+jQpG8r0HeYwgLVoK6jV +dlfgCAwc7DZNYBTOkSXto/1UcYfL9ug363IHDl0vOnJbKEJybbPa2js+KWivQ0mK +pUiSl8jYYQGeZGWSJueibrmMuoJcxiaoerbXV0imyPUGrXhT1m36vykvegxJyrRB +/qwGyEtbYyCC1pa92s51upFSLWI8dro0PGXSBOamXjHpOggSJiMViMiscutj8WA+ +P+STWUgDmerNxztoH6kaQhp7FJLp7eiwXUIq5qoYlxP4/NBZTsBuB2HG4hmYp24q +NCKTr5NdSahA1dFLuIp1l5edwVpS7KRjaMkQXCRWTQsdK0EpQNwhJn3J14YTS6rr +bqAcQgpegZzNd4AUWCMQie0JpQ6nVDTBEcWdIdiuC1nnyIvVgaxBjSHII1/mlc3R +wLMgJVAVH86zw7rYdcAymaC5NggPs6jX1e0iZVS2MqIlxHc8bi0ZYmgbvbFa4nVN +Kh4DHhGxodO0LYJDNY/mOLCs59EAGgs7NPOGOyQbgvMO5Jsk2EDuGMqhOQK58VxU +S/swxYarwT4A44pNUwRGmK3CjIEdNIG3u35MyUKTwVszFRvCVgVmkS3kmC17eCFp +eCLVko+SgaqbRKjkIzeaFp0SMmSzBozUFJKYyA/YkedPDSzDbNktMT4tCWMrFIjW +0H6vO15IztfFqmBNqhp7R/hDSJuiPs4CnENhjY46JDYst0fGmTVMzVaDnYgZyWSM +B4hcKROEDEfqfCYyJcNvrF0c9fSKGOAF1qdAsymViUCzxHgkywewV7BkZ22yUujx +GQ7V1dpjujQzabhuXGkj0REYoYabWBsm2KOMawJoJ61lXtur8QHPYHKsqfsZQvP5 +hvRZYrboU5vW4IWNjaSZP5FGekNefjlwOWJOJODNfZ+dMv9PGLjxpNHJJ5wInHDe +We8++YmPXtgdv+SmD1BfuvZnKeOUk36aK04Afv6M/315DXj7Gx98lH0r/k/PH9ug +3/rM185453s/YX3hbOWJ9Nar3/HImVf9SbKovg5AyHmXqc33f+8/XfuGiHroffY9 +F5782IWv9+e8ev2lymnvOu9ff/N3vnn6n37kb8+485Zb7p9f+aR+4kUP9RcNHn75 +K+2jV9/0/AUZgn3xS+2df/XpC5++m5Xe03/qh8eeqYnqhf2xd5966d3i333+qQvu +jvQrz33xjx8/854P3Ptof9VnmWvf2D193Xc+f95Lp3/xbWdSLx37td/+3NmXG8de +W99y7gC/49rv3faJm7Cbnvvuae+PHzn+0Puf++YrZ93c5b/7k/Gnf/SW1amZ1J39 +qPNHffjh7Onn/n3/Q3WZX3ff8t5XH3rjiotd4rKvvTRZN5c/8Ad3xZ+77VRuoH33 +y4+9+HXntf6Fuz4yfvbWx79z6cG97eLfuHTc32dd8soDXf8f9y+ue9/P3Rid8snk +96+7+dn7r1Ku8kpC+LdP/uUz7/ryY+/snr7vK9/4Begfbzj/xhP++eMPX/mDf7ke +/BD7k2f6Pzzcds/bXn3g+Dkfa6/+s+df/sGvfGE/u+bEX/7V2+86xn3j+z9+9s7h +V/e33/Tko9c8+ev7S/KTTj7+5/Vf/+LbH3no49d9G7/1sgcffOuPmcEr7We6Ry+T +30sj3M1fff34BWDV31o+/J7Tz33ihXMe/NA5w7855Spg8aMrtI8W+seg+B2fuvfG +v3/gmtN+6x/UF+gr9NFVr+tnPN6j3149dR34vRueKl9If+ms48yZV9AfPC4un73j +5TuuJ/4L +=apuZ +-----END PGP MESSAGE----- diff --git a/src/scripts/cron.php b/src/scripts/cron.php new file mode 100644 index 0000000..a1e7779 --- /dev/null +++ b/src/scripts/cron.php @@ -0,0 +1,33 @@ +backup_frequency && $config->backup_limit) { + Backup::auto(); +} + +// Send pending reminders +Reminders::sendPending(); + +if (Files::getVersioningPolicy() !== 'none') { + Files::pruneOldVersions(); +} + +// Make sure we are cleaning the trash +Trash::clean(); + +Plugins::fire('cron'); diff --git a/src/scripts/emails.php b/src/scripts/emails.php new file mode 100644 index 0000000..baeadaa --- /dev/null +++ b/src/scripts/emails.php @@ -0,0 +1,34 @@ + printf("%s: %s\n", $action, $file->path); + +if ($command === 'import') { + Storage::migrate(FILE_STORAGE_BACKEND, 'SQLite', FILE_STORAGE_CONFIG, null, $callback); +} +elseif ($command === 'export') { + Storage::migrate('SQLite', FILE_STORAGE_BACKEND, null, FILE_STORAGE_CONFIG, $callback); +} +elseif ($command === 'truncate') { + Storage::truncate('SQLite', null); + print("Deleted all files contents from database.\n"); +} +elseif ($command === 'scan') { + Storage::sync(null, $callback); +} +else { + printf("Usage: %s COMMAND +COMMAND can be either: + +import + Import files from configured storage to database + +export + Export files from database to configured storage + +truncate + Delete all files contents from database. + (No confirmation asked!) + +scan + Update or rebuild files list in database by listing files + directly from configured storage. + +", $_SERVER['argv'][0]); + exit(1); +} diff --git a/src/scripts/upgrade.php b/src/scripts/upgrade.php new file mode 100644 index 0000000..e8c8333 --- /dev/null +++ b/src/scripts/upgrade.php @@ -0,0 +1,25 @@ +getMessage() . PHP_EOL; + exit(1); +} diff --git a/src/sous-domaine.html b/src/sous-domaine.html new file mode 100644 index 0000000..be9ac31 --- /dev/null +++ b/src/sous-domaine.html @@ -0,0 +1,13 @@ + + +

    Erreur

    +

    Paheko n'est pas installé sur un sous-domaine dédié.

    +

    Ce mode de fonctionnement n'est pas supporté officiellement.

    + +

    Installation conseillée

    +

    Le mode conseillé est de positionner un sous-domaine dédié (virtual host ou vhost) sur le répertoire www/

    +

    Voir la documentation.

    + +

    Installation dans un sous-répertoire, sans virtual host (non conseillé)

    + +

    Voir la documentation dédiée pour configurer Paheko correctement et faire disparaître ce message d'erreur.

    \ No newline at end of file diff --git a/src/templates/_foot.tpl b/src/templates/_foot.tpl new file mode 100644 index 0000000..bd65b77 --- /dev/null +++ b/src/templates/_foot.tpl @@ -0,0 +1,24 @@ +
    + +{if $is_logged} +{* Keep session alive by requesting renewal every before it expires *} + +{/if} + + + diff --git a/src/templates/_head.tpl b/src/templates/_head.tpl new file mode 100644 index 0000000..b5e7ef4 --- /dev/null +++ b/src/templates/_head.tpl @@ -0,0 +1,164 @@ + + + + + + + {$title} + + + + {if isset($custom_js)} + + {foreach from=$custom_js item="js_url"} + + {/foreach} + {/if} + {if isset($custom_css)} + + {foreach from=$custom_css item="css_url"} + + {/foreach} + {/if} + {if isset($plugin_css)} + {foreach from=$plugin_css item="css"} + + {/foreach} + {/if} + {if isset($plugin_js)} + {foreach from=$plugin_js item="js"} + + {/foreach} + {/if} + + {if isset($logged_user) && $logged_user.preferences.force_handheld} + + {else} + + {/if} + + {if isset($config)} + + {/if} + {custom_colors config=$config} + + + + + + +{if ALERT_MESSAGE && !$dialog} +
    +{/if} + +{if !array_key_exists('_dialog', $_GET) && empty($layout)} +
    + + + {if empty($hide_title)} +

    {$title}

    + {/if} +
    +{/if} + +
    \ No newline at end of file diff --git a/src/templates/acc/_table_actions.tpl b/src/templates/acc/_table_actions.tpl new file mode 100644 index 0000000..85b893f --- /dev/null +++ b/src/templates/acc/_table_actions.tpl @@ -0,0 +1,11 @@ + Pour les écritures cochées : + + {csrf_field key="projects_action"} + + diff --git a/src/templates/acc/_year_select.tpl b/src/templates/acc/_year_select.tpl new file mode 100644 index 0000000..2d9c12f --- /dev/null +++ b/src/templates/acc/_year_select.tpl @@ -0,0 +1,5 @@ + diff --git a/src/templates/acc/accounts/_nav.tpl b/src/templates/acc/accounts/_nav.tpl new file mode 100644 index 0000000..5a23964 --- /dev/null +++ b/src/templates/acc/accounts/_nav.tpl @@ -0,0 +1,17 @@ + + \ No newline at end of file diff --git a/src/templates/acc/accounts/all.tpl b/src/templates/acc/accounts/all.tpl new file mode 100644 index 0000000..b32d65b --- /dev/null +++ b/src/templates/acc/accounts/all.tpl @@ -0,0 +1,50 @@ + +{include file="_head.tpl" title="Tous les comptes" current="acc/accounts"} + +{include file="acc/_year_select.tpl"} + +{include file="acc/accounts/_nav.tpl" current="all"} + +{if !empty($balance)} + + + + + + + + + + + + {foreach from=$balance item="account"} + + + + + + + + {/foreach} + +
    NuméroCompteTotal des débitsTotal des créditsSolde
    + {$account.code} + {$account.label}{$account.debit|raw|money:false}{$account.credit|raw|money:false}{if $account.balance !== null}{$account.balance|escape|money:false}{/if}
    +{else} +
    +

    Aucun compte ne comporte d'écriture sur cet exercice.

    +

    + {linkbutton href="!acc/transactions/new.php" label="Saisir une écriture" shape="plus"} +

    +
    +{/if} + +

    + Note : n'apparaissent ici que les comptes qui ont été utilisés dans cet exercice (au moins une écriture).
    + Les lignes grisées correspondent aux comptes soldés.
    + Pour voir la liste complète des comptes, même ceux qui n'ont pas été utilisés, se référer au plan comptable. +

    + +{include file="_foot.tpl"} \ No newline at end of file diff --git a/src/templates/acc/accounts/deposit.tpl b/src/templates/acc/accounts/deposit.tpl new file mode 100644 index 0000000..1bf9726 --- /dev/null +++ b/src/templates/acc/accounts/deposit.tpl @@ -0,0 +1,83 @@ +{include file="_head.tpl" title="Dépôt en banque : %s — %s"|args:$account.code,$account.label current="acc/accounts"} + +{form_errors} + +{if $missing_balance > 0} +

    + Il existe une différence de {$missing_balance|raw|money_currency} entre la liste des écritures à déposer + et le solde du compte.
    + Cette situation est généralement dûe à des écritures de dépôt qui ont été supprimées.
    + {linkbutton shape="plus" label="Faire un virement pour régulariser" href="!acc/transactions/new.php?0=%d&l=Régularisation%%20dépôt&account=%d"|args:$missing_balance,$account.id} +

    +{/if} + +{if !$journal->count()} +

    Il n'y a aucune écriture qui nécessiterait un dépôt. +

    +{else} +

    + Cocher les cases correspondant aux montants à déposer, une nouvelle écriture sera générée. +

    + +
    + {include file="common/dynamic_list_head.tpl" check=true list=$journal} + + {foreach from=$journal->iterate() item="line"} + + + {input type="checkbox" name="deposit[%d]"|args:$line.id_line value="1" data-debit=$line.debit|abs data-credit=$line.credit default=$line.checked} + + #{$line.id} + {$line.date|date_short} + {$line.reference} + {$line.line_reference} + {$line.label} + {$line.debit|raw|money} + {if $line.running_sum > 0}-{/if}{$line.running_sum|abs|raw|money:false} + + {/foreach} + + + +
    + Détails de l'écriture de dépôt +
    + {input type="text" name="label" label="Libellé" required=1 default="Dépôt en banque"} + {input type="date" name="date" default=$date label="Date" required=1} + {input type="money" name="amount" label="Montant" required=1} + {input type="list" target="!acc/charts/accounts/selector.php?chart=%d&targets=%d"|args:$account.id_chart,$target name="account_transfer" label="Compte de dépôt" required=1} + {input type="text" name="reference" label="Numéro de pièce comptable"} + {input type="textarea" name="notes" label="Remarques" rows=4 cols=30} +
    +
    + +

    + {csrf_field key="acc_deposit_%s"|args:$account.id} + {button type="submit" name="save" label="Enregistrer" class="main" shape="check"} +

    +
    + + {literal} + + {/literal} +{/if} + +{include file="_foot.tpl"} \ No newline at end of file diff --git a/src/templates/acc/accounts/index.tpl b/src/templates/acc/accounts/index.tpl new file mode 100644 index 0000000..d018f13 --- /dev/null +++ b/src/templates/acc/accounts/index.tpl @@ -0,0 +1,93 @@ + +{include file="_head.tpl" title="Comptes favoris" current="acc/accounts"} + +{include file="acc/_year_select.tpl"} + +{include file="acc/accounts/_nav.tpl" current="index"} + + +{if isset($_GET['chart_change'])} +

    + L'exercice sélectionné utilise un plan comptable différent, merci de sélectionner un autre compte. +

    +{/if} + +{if $pending_count} + {include file="acc/transactions/_pending_message.tpl"} +{/if} + +{if !empty($grouped_accounts)} + + + + + + + + + + + + {foreach from=$grouped_accounts item="group"} + + + + + {foreach from=$group.accounts item="account"} + + + + + + + + + {/foreach} + + {/foreach} +
    NuméroCompteSolde

    {$group.label}

    +{else} +
    +

    Aucun compte favori ne comporte d'écriture sur cet exercice.

    +

    + {linkbutton href="!acc/transactions/new.php" label="Saisir une écriture" shape="plus"} +

    +
    +{/if} + +

    + Note : n'apparaissent ici que les comptes qui ont été utilisés dans cet exercice (au moins une écriture) de types banque, caisse, tiers, dépenses ou recettes. Les autres comptes n'apparaissent que s'ils ont été utilisés et sont marqués comme favoris.
    + Pour voir le solde de tous les comptes, se référer à la liste de tous comptes de l'exercice.
    + Pour voir la liste complète des comptes, même ceux qui n'ont pas été utilisés, se référer au plan comptable. +

    + +{include file="_foot.tpl"} \ No newline at end of file diff --git a/src/templates/acc/accounts/journal.tpl b/src/templates/acc/accounts/journal.tpl new file mode 100644 index 0000000..9f685f4 --- /dev/null +++ b/src/templates/acc/accounts/journal.tpl @@ -0,0 +1,164 @@ +{include file="_head.tpl" title="Journal : %s - %s"|args:$account.code:$account.label current="acc/accounts" body_id="rapport"} + +{if empty($year)} + {include file="acc/_year_select.tpl"} +{else} + +{/if} + +{if $account.type} + + {if $simple} + {if $account.type == $account::TYPE_THIRD_PARTY} + {if $account->getPosition($year->id) == $account::ASSET && $sum.balance > 0} +

    Ce tiers vous doit {$sum.balance|abs|raw|money_currency}.

    + {elseif $account->getPosition($year->id) == $account::LIABILITY && $sum.balance > 0} +

    Vous devez {$sum.balance|abs|raw|money_currency} à ce tiers.

    + {/if} + {elseif $account.type == $account::TYPE_BANK} + {if $account->getPosition($year->id) == $account::ASSET && $sum.balance > 0} +

    Ce compte est créditeur de {$sum.balance|abs|raw|money_currency} à la banque.

    + {elseif $account->getPosition($year->id) == $account::LIABILITY && $sum.balance > 0} +

    Ce compte est à découvert de {$sum.balance|abs|raw|money_currency} à la banque.

    + {/if} + {elseif $account.type == $account::TYPE_CASH} + {if $account->getPosition($year->id) == $account::ASSET && $sum.balance > 0} +

    Cette caisse est créditrice de {$sum.balance|abs|raw|money_currency}.

    + {elseif $account->getPosition($year->id) == $account::LIABILITY && $sum.balance > 0} +

    Cette caisse est débiteur de {$sum.balance|abs|raw|money_currency}. Est-ce normal ? Une vérification est peut-être nécessaire ?

    + {/if} + {elseif $account.type == $account::TYPE_OUTSTANDING} + {if $sum.balance < 0} +

    Ce compte est débiteur {$sum.balance|abs|raw|money_currency}. Est-ce normal ? Une vérification est peut-être nécessaire ?

    + {elseif $sum.balance > 0} +

    Ce compte d'attente est créditeur de {$sum.balance|abs|raw|money_currency}. {if $sum.balance > 200}Un dépôt à la banque serait peut-être une bonne idée ?{/if}

    + {/if} + {elseif $account.type == $account::TYPE_REVENUE && $sum.balance < 0} +

    Ce compte présente un solde négatif de {$sum.balance|raw|money_currency}. Est-ce normal ? Cette situation ne devrait se produire que si vous avez dû procéder à des remboursements par exemple, et que ceux-ci couvrent des recettes perçues sur un exercice précédent.

    + {elseif $account.type == $account::TYPE_EXPENSE && $sum.balance < 0} +

    Ce compte présente un solde négatif de {$sum.balance|raw|money_currency}. Est-ce normal ? Cette situation ne devrait se produire que si vous avez reçu des remboursements par exemple, et que ceux-ci couvrent des dépenses réglées sur un exercice précédent.

    + {/if} + {/if} + + + +{/if} + + + +
    + +{include file="common/dynamic_list_head.tpl" check=$can_edit} + + {foreach from=$list->iterate() item="line"} + + {if $can_edit} + + {input type="checkbox" name="check[%s]"|args:$line.id_line value=$line.id} + + {/if} + #{$line.id} + {$line.date|date_short} + {if $simple} + {if $line.change > 0}+{else}-{/if}{$line.change|abs|raw|money} + {else} + {$line.debit|raw|money} + {$line.credit|raw|money} + {/if} + {if isset($line->sum)} + {$line.sum|raw|money:false} + {/if} + {$line.reference} + {$line.label}{if $simple && $line.line_label} — {$line.line_label}{/if} + {if !$simple}{$line.line_label}{/if} + {$line.line_reference} + {if $line.id_project}{$line.project_code}{/if} + {if isset($line.locked)} + {if $line.locked}{icon title="Écriture verrouillée" shape="lock"}{/if} + {/if} + {if $line.files}{$line.files}{/if} + {* Deposit status, might be consufing + + {if $account.type == $account::TYPE_OUTSTANDING && $line.debit} + {if !($line.status & Entities\Accounting\Transaction::STATUS_DEPOSIT)} + {icon shape="alert" title="Cette opération n'a pas été déposée"} + {/if} + {/if} + + *} + + {if ($line.status & Entities\Accounting\Transaction::STATUS_WAITING)} + {if $line.type == Entities\Accounting\Transaction::TYPE_DEBT} + {linkbutton shape="check" label="Régler cette dette" href="!acc/transactions/new.php?payoff=%d"|args:$line.id} + {elseif $line.type == Entities\Accounting\Transaction::TYPE_CREDIT} + {linkbutton shape="export" label="Régler cette créance" href="!acc/transactions/new.php?payoff=%d"|args:$line.id} + {/if} + {/if} + + {linkbutton href="!acc/transactions/details.php?id=%d"|args:$line.id label="Détails" shape="search"} + + + {/foreach} + + + + {if $can_edit} + + {/if} + {if !$simple}{/if} + {if null !== $sum} + {if !$simple} + Total + {$sum.debit|raw|money:false} + {$sum.credit|raw|money:false} + {$sum.balance|raw|money:false} + {else} + + Total + {$sum.balance|raw|money:false} + {/if} + {else} + + {/if} + {if !$simple}{/if} + + {if $can_edit} + {include file="acc/_table_actions.tpl"} + {/if} + + + + + +
    + +{include file="_foot.tpl"} \ No newline at end of file diff --git a/src/templates/acc/accounts/reconcile.tpl b/src/templates/acc/accounts/reconcile.tpl new file mode 100644 index 0000000..e304ccb --- /dev/null +++ b/src/templates/acc/accounts/reconcile.tpl @@ -0,0 +1,102 @@ +{include file="_head.tpl" title="Rapprochement : %s — %s"|args:$account.code,$account.label current="acc/accounts"} + +{include file="acc/_year_select.tpl"} + + + +
    + {if $prev || $next} +
    + Rapprochement par mois +

    + {if $prev} + {linkbutton shape="left" href=$prev.url label=$prev.date|date:'F Y'} + {/if} + {if $next} + {linkbutton shape="right" href=$next.url label=$next.date|date:'F Y'} + {/if} +

    +
    + {/if} +
    + Période de rapprochement +

    + Du + {input type="date" name="start" default=$start} + au + {input type="date" name="end" default=$end} +

    +

    + + + +

    +
    +
    + +

    + Les écritures apparaissent ici dans le sens du relevé de banque, à l'inverse des journaux comptables. +

    + +{form_errors} + +
    + + + + + + + + + + + + + + + + + {foreach from=$journal item="line"} + {if isset($line->sum)} + + + + + + + + {else} + + + + + + {* Not a bug! Credit/debit is reversed here to reflect the bank statement *} + + + + + + + {/if} + {/foreach} + +
    DateDébitCréditSolde cumuléSolde rapprochéLibelléRéf. écritureRéf. ligne
    {if $line.sum > 0}-{/if}{$line.sum|abs|raw|money:false}{if $line.reconciled_sum > 0}-{/if}{$line.reconciled_sum|abs|raw|money}Solde au {$line.date|date_short}
    + {input type="checkbox" name="reconcile[%d]"|args:$line.id_line value="1" default=$line.reconciled} + #{$line.id}{$line.date|date_short}{$line.credit|raw|money}{$line.debit|raw|money}{if $line.running_sum > 0}-{/if}{$line.running_sum|abs|raw|money:false}{if $line.reconciled_sum > 0}-{/if}{$line.reconciled_sum|abs|raw|money:false}{$line.label}{$line.reference}{$line.line_reference}
    +

    + {csrf_field key="acc_reconcile_%s"|args:$account.id} + {button type="submit" name="save" label="Enregistrer" class="main" shape="check"} + {if $next} + {button type="submit" name="save_next" label="Enregistrer et aller au mois suivant" class="main minor" shape="right"} + {/if} +

    +
    + +{include file="_foot.tpl"} \ No newline at end of file diff --git a/src/templates/acc/accounts/reconcile_assist.tpl b/src/templates/acc/accounts/reconcile_assist.tpl new file mode 100644 index 0000000..43abdd6 --- /dev/null +++ b/src/templates/acc/accounts/reconcile_assist.tpl @@ -0,0 +1,174 @@ +{include file="_head.tpl" title="Rapprochement : %s — %s"|args:$account.code,$account.label current="acc/accounts"} + +{include file="acc/_year_select.tpl"} + + + +{if $_GET.msg === 'OK'} +

    + Le rapprochement a bien été enregistré. +

    +{/if} + +{form_errors} + +

    + Le rapprochement assisté permet de s'aider d'un relevé de compte pour trouver les écritures manquantes ou erronées.
    + {linkbutton shape="help" href=$help_pattern_url|args:"rapprochement-assiste" target="_dialog" label="Aide détaillée"} +

    +
    + {if !$csv->loaded()} +
    + Relevé de compte +
    + {input type="file" name="file" label="Fichier à importer" accept="csv" required=1} + {include file="common/_csv_help.tpl" more_text="Le fichier doit obligatoirement disposer, soit d'une colonne 'Montant', soit de deux colonnes 'Débit' et 'Crédit'."} +
    +

    + {csrf_field key=$csrf_key} + {button type="submit" name="upload" label="Envoyer le fichier" class="main" shape="upload"} +

    +
    + {elseif !$csv->ready()} + {include file="common/_csv_match_columns.tpl"} +

    + {csrf_field key=$csrf_key} + {button type="submit" name="cancel" value="1" label="Annuler" shape="left"} + {button type="submit" name="assign" label="Continuer" class="main" shape="right"} +

    + {else} +
    + Relevé de compte +
    +
    + Nombre de lignes +
    +
    + {$csv->count()} +
    +
    +

    + {csrf_field key=$csrf_key} + {button type="submit" name="cancel" value="1" label="Annuler le rapprochement" shape="left"} +

    +
    +
    + +
    +
    + Période de rapprochement +
    +
    + Du + {input type="date" name="start" default=$start} + au + {input type="date" name="end" default=$end} +
    +
    +

    + + {button type="submit" label="Modifier" shape="right"} +

    +
    + {/if} +
    + +{if !empty($lines)} +

    + Les écritures apparaissent ici dans le sens du relevé de banque, à l'inverse des journaux comptables. +

    + +
    + + + + + + + + + + + + + + + + + + + + + + + {foreach from=$lines key="line_id" item="line"} + {if isset($line->journal->sum)} + + + + + + + + + {else} + + {if isset($line->journal)} + + + + + + + {else} + + + {/if} + + {if isset($line->csv)} + + + + + {else} + + {/if} + + {/if} + {/foreach} + +
    Journal du compte (compta)Extrait de compte (banque)
    DateMouvementSolde cumuléLibelléLibelléMouvementSolde cumuléDate
    {if $line.journal.sum > 0}-{/if}{$line.journal.sum|abs|raw|money:false}Solde au {$line.journal.date|date_short}
    + {input type="checkbox" name="reconcile[%d]"|args:$line.journal.id_line value="1" default=$line.journal.reconciled} + #{$line.journal.id}{$line.journal.date|date_short} + {if $line.journal.credit} + {* Not a bug! Credit/debit is reversed here to reflect the bank statement *} + -{$line.journal.credit|raw|money} + {else} + {$line.journal.debit|raw|money} + {/if} + {if $line.journal.running_sum > 0}-{/if}{$line.journal.running_sum|abs|raw|money:false}{$line.journal.label} + {if $line.add} + {linkbutton label="Saisir cette écriture" target="_dialog" href="!acc/transactions/new.php?%s"|args:$line.csv.new_params shape="plus"} + {/if} + + {if $line->journal && $line->csv} + == + {else} + {icon shape="alert"} + {/if} + {$line.csv.label} + {$line.csv.amount|raw|money:true:true} + {if $line.csv.balance}{$line.csv.balance|raw|money}{else}{$line.csv.running_sum|raw|money}{/if}{$line.csv.date|date_short}
    +

    + {csrf_field key=$csrf_key} + {button type="submit" name="save" label="Enregistrer" class="main" shape="check"} +

    +
    +{/if} + +{include file="_foot.tpl"} \ No newline at end of file diff --git a/src/templates/acc/accounts/simple.tpl b/src/templates/acc/accounts/simple.tpl new file mode 100644 index 0000000..78afed9 --- /dev/null +++ b/src/templates/acc/accounts/simple.tpl @@ -0,0 +1,113 @@ + +{include file="_head.tpl" title="Suivi : %s"|args:$types[$type] current="acc/simple"} + +{include file="acc/_year_select.tpl"} + + + +{if $pending_count} + {include file="acc/transactions/_pending_message.tpl"} +{/if} + +{if !$list->count()} +

    + Aucune écriture à afficher. +

    +{else} +
    + {assign var="has_debt_or_credit" value=false} + + {include file="common/dynamic_list_head.tpl" check=$can_edit} + + {foreach from=$list->iterate() item="line"} + + {if $can_edit} + + {input type="checkbox" name="check[%s]"|args:$line.id_line value=$line.id default=0} + + {/if} + {if $line.type_label} + {$line.type_label} + {/if} + #{$line.id} + {$line.date|date_short} + {$line.change|abs|raw|money} + {$line.reference} + {$line.label} + {$line.line_reference} + {foreach from=$line.project_code item="code" key="id"}{$code} {/foreach} + {if isset($line.locked)} + {if $line.locked}{icon title="Écriture verrouillée" shape="lock"}{/if} + {/if} + {if $line.files}{$line.files}{/if} + {if property_exists($line, 'status_label')} + + {if $line.status & Entities\Accounting\Transaction::STATUS_WAITING} + {$line.status_label} + {else} + {$line.status_label} + {/if} + + {/if} + + {if $line.type == Transaction::TYPE_DEBT && ($line.status & Transaction::STATUS_WAITING)} + {assign var="has_debt_or_credit" value=true} + {linkbutton shape="check" label="Régler cette dette" href="!acc/transactions/new.php?payoff=%d"|args:$line.id} + {elseif $line.type == Transaction::TYPE_CREDIT && ($line.status & Transaction::STATUS_WAITING)} + {assign var="has_debt_or_credit" value=true} + {linkbutton shape="export" label="Régler cette créance" href="!acc/transactions/new.php?payoff=%d"|args:$line.id} + {/if} + + {linkbutton href="!acc/transactions/details.php?id=%d"|args:$line.id label="Détails" shape="search"} + + + {/foreach} + + {if $can_edit} + + + + + Pour les écritures cochées : + + + {csrf_field key="projects_action"} + + + + + + {/if} + + +
    + + {$list->getHTMLPagination()|raw} +{/if} + +{include file="_foot.tpl"} \ No newline at end of file diff --git a/src/templates/acc/accounts/users.tpl b/src/templates/acc/accounts/users.tpl new file mode 100644 index 0000000..03d8dff --- /dev/null +++ b/src/templates/acc/accounts/users.tpl @@ -0,0 +1,51 @@ +{include file="_head.tpl" title="Comptes de membres" current="acc/accounts"} + +{include file="acc/_year_select.tpl"} + +{include file="acc/accounts/_nav.tpl" current="users"} + + +

    + Ce tableau présente une liste de comptes « virtuels » représentant les membres liés aux écritures. + Seules les écritures liées à des comptes de tiers sont comptabilisées.
    + Les membres qui n'ont aucune écriture associée n'apparaissent pas dans ce tableau. +

    + + +{if !$list->count()} +

    Aucune écriture liée à un membre n'existe sur cet exercice.

    +{else} + {include file="common/dynamic_list_head.tpl"} + + {foreach from=$list->iterate() item="row"} + + {$row.user_number} + {$row.user_identity} + + {if $row.balance < 0}{/if} + {$row.balance|raw|money_currency:false} + {if $row.balance < 0}{/if} + + + + {if $row.balance < 0}Dette + {elseif $row.balance > 0}Créance + {/if} + + + + {linkbutton label="Journal" shape="menu" href="!acc/transactions/user.php?id=%d&year=%d"|args:$row.id,$current_year.id} + + + {/foreach} + + +{/if} + +

    + Dette = l'association doit de l'argent à ce membre
    + Créance = le membre doit de l'argent à l'association +

    + + +{include file="_foot.tpl"} \ No newline at end of file diff --git a/src/templates/acc/charts/_country_input.tpl b/src/templates/acc/charts/_country_input.tpl new file mode 100644 index 0000000..c5e36cf --- /dev/null +++ b/src/templates/acc/charts/_country_input.tpl @@ -0,0 +1,47 @@ + '— Autre']; + +if (!isset($chart)) { + $chart = new Chart; + $chart->country = Config::getInstance()->pays; +} + +$name ??= 'country'; +?> + +{input type="select" name=$name label="Appliquer les règles comptables de ce pays" required=1 options=$country_list default=$chart.country} + +{if !$chart->exists()} +
    Ce choix ne pourra plus être modifié une fois le plan comptable créé.
    +{else} +
    Si un pays est sélectionné, ce choix ne pourra plus être modifié.
    +{/if} + + + +
    + + + +
    \ No newline at end of file diff --git a/src/templates/acc/charts/_nav.tpl b/src/templates/acc/charts/_nav.tpl new file mode 100644 index 0000000..9a261b6 --- /dev/null +++ b/src/templates/acc/charts/_nav.tpl @@ -0,0 +1,9 @@ + diff --git a/src/templates/acc/charts/accounts/_account_form.tpl b/src/templates/acc/charts/accounts/_account_form.tpl new file mode 100644 index 0000000..f53bf2a --- /dev/null +++ b/src/templates/acc/charts/accounts/_account_form.tpl @@ -0,0 +1,64 @@ +{if $create} + +{/if} + +
    + {if $can_edit} + {if $account->canSetPosition()} +
    (obligatoire)
    +
    La position permet d'indiquer dans quelle partie du bilan ou du résultat doit figurer le compte.
    + {input type="radio" label="Ne pas utiliser ce compte au bilan ni au résultat" name="position" value=0 source=$account} + {input type="radio" label="Bilan : actif" name="position" value=Entities\Accounting\Account::ASSET source=$account help="ce que possède l'association : stocks, locaux, soldes bancaires, etc."} + {input type="radio" label="Bilan : passif" name="position" value=Entities\Accounting\Account::LIABILITY source=$account help="ce que l'association doit : dettes, provisions, réserves, etc."} + {input type="radio" label="Bilan : actif ou passif" name="position" value=Entities\Accounting\Account::ASSET_OR_LIABILITY source=$account help="le compte sera placé à l'actif si son solde est débiteur, ou au passif s'il est créditeur"} + {input type="radio" label="Résultat : charge" name="position" value=Entities\Accounting\Account::EXPENSE source=$account help="dépenses"} + {input type="radio" label="Résultat : produit" name="position" value=Entities\Accounting\Account::REVENUE source=$account help="recettes"} + {elseif $account->canSetAssetOrLiabilityPosition()} +
    (obligatoire)
    +
    La position permet d'indiquer dans quelle partie du bilan doit figurer le compte.
    + En cas de doute, sélectionner « Actif ou passif ».
    + {input type="radio" label="Actif ou passif" name="position" value=Entities\Accounting\Account::ASSET_OR_LIABILITY source=$account help="le compte sera automatiquement placé à l'actif si son solde est débiteur, ou au passif si le solde est créditeur"} + {input type="radio" label="Actif" name="position" value=Entities\Accounting\Account::ASSET source=$account help="ce que possède l'association : stocks, locaux, soldes bancaires, etc."} + {input type="radio" label="Passif" name="position" value=Entities\Accounting\Account::LIABILITY source=$account help="ce que l'association doit : dettes, provisions, réserves, etc."} + {elseif $account->exists()} +
    Position du compte
    +
    + {if $account.position == $account::EXPENSE || $account.position == $account::REVENUE}Au compte de résultat{else}Au bilan{/if} + — + {$account->position_name()} +
    + {/if} + + {if $account.type} +
    (obligatoire)
    +
    + {input type="text" readonly=true name="code_base" default=$code_base size=$code_base|strlen} + {input type="text" maxlength="15" size="15" pattern="[A-Z0-9]+" name="code_value" required=true default=$code_value} +
    +
    Le numéro du compte sert à trier le compte dans le plan comptable, et à retrouver le compte plus rapidement.
    + {else} + {input type="text" label="Numéro" maxlength="20" pattern="[A-Z0-9]+" name="code" source=$account required=true help="Le numéro du compte sert à trier le compte dans le plan comptable, attention à choisir un numéro qui correspond au plan comptable."} + {/if} +
    Le numéro ne peut contenir que des chiffres et des lettres majuscules.
    + {input type="text" label="Libellé" name="label" source=$account required=true} + {else} +
    Position du compte
    +
    + {if $account.position == $account::EXPENSE || $account.position == $account::REVENUE}Au compte de résultat{else}Au bilan{/if} + — + {$account->position_name()} +
    +
    Type
    +
    {$account->type_name()}
    +
    Le type est déterminé selon le numéro du compte.
    + {input type="text" disabled=true name="code" source=$account label="Numéro de compte"} + {input type="text" label="Libellé" name="label" source=$account disabled=true} + {/if} + + {input type="textarea" label="Description" name="description" source=$account} + {input type="checkbox" label="Compte favori" name="bookmark" source=$account value=1 help="Si coché, le compte apparaîtra en priorité dans les listes de comptes"} + + {if !$account->exists() && in_array($account.type, [$account::TYPE_BANK, $account::TYPE_CASH, $account::TYPE_OUTSTANDING, $account::TYPE_THIRD_PARTY]) && !empty($current_year)} + {input type="money" name="opening_amount" label="Solde d'ouverture" help="Si renseigné, ce solde sera inscrit dans l'exercice « %s »."|args:$current_year.label} + {/if} +
    diff --git a/src/templates/acc/charts/accounts/_nav.tpl b/src/templates/acc/charts/accounts/_nav.tpl new file mode 100644 index 0000000..25ddccd --- /dev/null +++ b/src/templates/acc/charts/accounts/_nav.tpl @@ -0,0 +1,33 @@ +{if !$dialog || $dialog !== 'manage'} + +{/if} \ No newline at end of file diff --git a/src/templates/acc/charts/accounts/all.tpl b/src/templates/acc/charts/accounts/all.tpl new file mode 100644 index 0000000..aa3e8d1 --- /dev/null +++ b/src/templates/acc/charts/accounts/all.tpl @@ -0,0 +1,63 @@ +{include file="_head.tpl" title=$chart.label current="acc/years"} + +{include file="acc/charts/accounts/_nav.tpl" current="all"} + +
    + + + +

    + Les comptes marqués comme « Ajouté » ont été ajoutés au plan comptable officiel par vous-même. +

    + +{if !$list->count() && $types_names} +

    + Il n'existe aucun compte dans la catégorie « {$types_names} » dans le plan comptable. +

    +{else} + {include file="common/dynamic_list_head.tpl"} + + {foreach from=$list->iterate() item="account"} + + {$account.code} + {$account.label} + {if $account.description} + {$account.description|escape|nl2br} + {/if} + + {$account.position_report} + + {$account.position_name} + + + {if $account.user}Ajouté{/if} + + + bookmark ? 'check' : 'uncheck'; + $title = $account->bookmark ? 'Ôter des favoris' : 'Marquer comme favori'; + ?> + {button shape=$shape name="bookmark[%d]"|args:$account.id value=$account.bookmark label="Favori" title=$title type="submit"} + + + {if $session->canAccess($session::SECTION_ACCOUNTING, $session::ACCESS_ADMIN) && !$chart.archived} + {if $account.user || !$chart.code} + {linkbutton shape="delete" label="Supprimer" href="!acc/charts/accounts/delete.php?id=%d&%s"|args:$account.id,$types_arg target=$dialog_target} + {/if} + {linkbutton shape="edit" label="Modifier" href="!acc/charts/accounts/edit.php?id=%d%s"|args:$account.id,$types_arg target=$dialog_target} + {/if} + + + {/foreach} + + + + +{/if} + + + +{include file="_foot.tpl"} \ No newline at end of file diff --git a/src/templates/acc/charts/accounts/delete.tpl b/src/templates/acc/charts/accounts/delete.tpl new file mode 100644 index 0000000..c64424e --- /dev/null +++ b/src/templates/acc/charts/accounts/delete.tpl @@ -0,0 +1,8 @@ +{include file="_head.tpl" title="Supprimer un compte" current="acc/years"} + +{include file="common/delete_form.tpl" + legend="Supprimer ce plan comptable ?" + warning="Êtes-vous sûr de vouloir supprimer le compte « %s — %s » ?"|args:$account.code,$account.label +} + +{include file="_foot.tpl"} \ No newline at end of file diff --git a/src/templates/acc/charts/accounts/edit.tpl b/src/templates/acc/charts/accounts/edit.tpl new file mode 100644 index 0000000..3f98829 --- /dev/null +++ b/src/templates/acc/charts/accounts/edit.tpl @@ -0,0 +1,28 @@ +{include file="_head.tpl" title="Modifier un compte" current="acc/years"} + +{include file="acc/charts/accounts/_nav.tpl" current="new"} + +{form_errors} + +
    + + {if !$can_edit} +

    + Il n'est pas possible de modifier le libellé, le numéro ou la position de ce compte car il {if $account.user}est utilisé dans des exercices clôturés{else}fait partie du plan comptable officiel{/if}.
    + Pour pouvoir modifier ce compte pour un nouvel exercice, il est conseillé de créer un nouveau plan comptable en y recopiant l'ancien plan comptable. +

    + {/if} + +
    + Modifier un compte + {include file="acc/charts/accounts/_account_form.tpl" create=false} +
    + +

    + {csrf_field key="acc_accounts_edit_%s"|args:$account.id} + {button type="submit" name="edit" label="Enregistrer" shape="right" class="main"} +

    + +
    + +{include file="_foot.tpl"} \ No newline at end of file diff --git a/src/templates/acc/charts/accounts/index.tpl b/src/templates/acc/charts/accounts/index.tpl new file mode 100644 index 0000000..b54a426 --- /dev/null +++ b/src/templates/acc/charts/accounts/index.tpl @@ -0,0 +1,59 @@ +{include file="_head.tpl" title=$chart.label current="acc/years"} + +{include file="acc/charts/accounts/_nav.tpl" current="favorites"} + +
    + + + + +

    + Cette liste regroupe les comptes de banque, caisse, attente, tiers, dépense, recette ou bénévolat qui sont soit marqués comme favori, soit ajoutés manuellement, soit déjà utilisés dans un exercice. +

    + + +{foreach from=$accounts_grouped item="group"} + + + + + + + {foreach from=$group.accounts item="account"} + + + + + + + + {/foreach} + +{/foreach} +

    {$group.label}

    + {if !$chart.archived && $group.type} + {linkbutton label="Ajouter un compte" shape="plus" href="!acc/charts/accounts/new.php?id=%d&type=%d&%s"|args:$chart.id,$group.type,$types_arg target=$dialog_target} + {/if} +
    + + + +
    + +{include file="_foot.tpl"} \ No newline at end of file diff --git a/src/templates/acc/charts/accounts/new.tpl b/src/templates/acc/charts/accounts/new.tpl new file mode 100644 index 0000000..8587727 --- /dev/null +++ b/src/templates/acc/charts/accounts/new.tpl @@ -0,0 +1,140 @@ +{include file="_head.tpl" title="Nouveau compte" current="acc/years"} + +{include file="acc/charts/accounts/_nav.tpl" current="new"} + +{form_errors} + +{if !isset($account->type)} + +
    +
    + Créer un nouveau compte +
    + {foreach from=$types_create item="t" key="v"} + {input type="radio-btn" name="type" value=$v label=$t.label help=$t.help} + {/foreach} +
    +
    +

    + + {button type="submit" label="Continuer" shape="right" class="main"} +

    +
    + +{elseif $ask && $ask->isListedAsFavourite()} + +
    +
    + Créer un sous-compte ? + +
    +

    Vous avez sélectionné le compte suivant :

    + +

    {$ask.code} — {$ask.label}

    +
    + +

    + Ce compte fait déjà partie de la liste des comptes favoris. + Vous pouvez créer un sous-compte pour détailler les écritures, si besoin. +

    + +

    + {csrf_field key=$csrf_key} + {button type="submit" shape="right" name="from" value=$ask.id label="Créer un sous-compte" class="main"} +

    +
    + +
    + +{elseif $ask} + +
    +
    + Marquer comme favori, ou créer un sous-compte ? + +
    +

    Vous avez sélectionné le compte suivant :

    + +

    {$ask.code} — {$ask.label}

    +
    + +

    + Si ce compte vous convient tel quel, vous pouvez l'ajouter à vos comptes favoris, il apparaîtra ainsi toujours dans les listes de comptes.
    + Sinon vous pouvez créer un sous-compte pour plus de détails. +

    + +

    + {csrf_field key=$csrf_key} + {button type="submit" shape="star" name="toggle_bookmark" value=$ask.id label="Ajouter ce compte à mes favoris" class="main"} + — ou — + {button type="submit" shape="right" name="from" value=$ask.id label="Créer un sous-compte" class="main"} +

    +
    + +
    + + +{elseif !empty($missing)} + +
    +
    + Comptes disponibles + +

    {button type="submit" shape="right" name="from" value="" label="Aucun compte ne correspond" class="main"}

    + +

    Est-ce que le compte dont vous avez besoin est dans cette liste ?

    + +

    + Il est important de respecter le plan comptable : + pour cela il faut choisir le compte correspondant au besoin.
    + Si nécessaire, il sera possible de créer un sous-compte plus précis à l'étape suivante. +

    + + + + {foreach from=$missing item="item"} + + + + + + + {/foreach} + +
    +
    + +

    + + + {button type="submit" shape="right" name="from" value="" label="Aucun compte ne correspond" class="main"} +

    +
    + + +{else} + +
    + +
    + Créer un nouveau compte + {include file="acc/charts/accounts/_account_form.tpl" can_edit=true create=true} +
    + +

    + {csrf_field key=$csrf_key} + {if $from} + + {/if} + {button type="submit" name="save" label="Créer" shape="right" class="main"} +

    + +
    + +{/if} + +{include file="_foot.tpl"} \ No newline at end of file diff --git a/src/templates/acc/charts/accounts/selector.tpl b/src/templates/acc/charts/accounts/selector.tpl new file mode 100644 index 0000000..b353ad1 --- /dev/null +++ b/src/templates/acc/charts/accounts/selector.tpl @@ -0,0 +1,95 @@ +{include file="_head.tpl" title="Sélectionner un compte"} + +
    + + + +
    + +
    + +{if empty($grouped_accounts) && empty($accounts)} +

    + Il n'existe aucun compte dans la catégorie « {$targets_names} » dans le plan comptable. +

    + {if $session->canAccess($session::SECTION_ACCOUNTING, $session::ACCESS_ADMIN)} +

    + Il faut modifier le plan comptable pour ajouter un compte de cette catégorie et pouvoir le sélectionner ensuite. +

    + {/if} +

    + +{elseif isset($grouped_accounts)} + + {foreach from=$grouped_accounts item="group"} +

    {$group.label}

    + + {if count($group.accounts)} + + + {foreach from=$group.accounts item="account"} + + + + + + + + + {/foreach} + +
    + {else} +

    Le plan comptable ne comporte aucun compte de ce type.

    + {/if} + {/foreach} + {if $index == 1 && $session->canAccess($session::SECTION_ACCOUNTING, $session::ACCESS_ADMIN)} +

    + Il faut modifier le plan comptable pour ajouter un compte dans une catégorie et pouvoir le sélectionner ensuite. +

    + {/if} + + +{else} + + + + {foreach from=$accounts item="account"} + + + + + + + {/foreach} + +
    + +{/if} + +
    + + + +{include file="_foot.tpl"} \ No newline at end of file diff --git a/src/templates/acc/charts/delete.tpl b/src/templates/acc/charts/delete.tpl new file mode 100644 index 0000000..142d10f --- /dev/null +++ b/src/templates/acc/charts/delete.tpl @@ -0,0 +1,9 @@ +{include file="_head.tpl" title="Supprimer un plan comptable" current="acc/years"} + +{include file="common/delete_form.tpl" + legend="Supprimer ce plan comptable ?" + warning="Êtes-vous sûr de vouloir supprimer le plan comptable « %s » ?"|args:$chart.label + csrf_key="acc_charts_delete_%s"|args:$chart.id +} + +{include file="_foot.tpl"} \ No newline at end of file diff --git a/src/templates/acc/charts/edit.tpl b/src/templates/acc/charts/edit.tpl new file mode 100644 index 0000000..2154132 --- /dev/null +++ b/src/templates/acc/charts/edit.tpl @@ -0,0 +1,23 @@ +{include file="_head.tpl" title="Modifier un plan comptable" current="acc/years"} + +{form_errors} + +
    +
    + Modifier un plan comptable +
    + {input type="text" name="label" label="Libellé" required=1 source=$chart} + {if !$chart.code && !$chart.country} + {include file="./_country_input.tpl"} + {/if} +
    + {input type="checkbox" name="archived" value="1" source=$chart label="Plan comptable archivé" help="Ce plan comptable ne pourra plus être modifié ni utilisé dans un nouvel exercice"} +
    +

    + {csrf_field key="acc_charts_edit_%d"|args:$chart.id} + {button type="submit" name="save" label="Enregistrer" shape="right" class="main"} +

    +
    +
    + +{include file="_foot.tpl"} \ No newline at end of file diff --git a/src/templates/acc/charts/index.tpl b/src/templates/acc/charts/index.tpl new file mode 100644 index 0000000..395ce28 --- /dev/null +++ b/src/templates/acc/charts/index.tpl @@ -0,0 +1,125 @@ +{include file="_head.tpl" title="Gestion des plans comptables" current="acc/years"} + +{if $session->canAccess($session::SECTION_ACCOUNTING, $session::ACCESS_ADMIN)} + {include file="./_nav.tpl" current="charts"} +{/if} + +{if $_GET.msg == 'OPEN'} +

    + Il n'existe aucun exercice ouvert. + {if $session->canAccess($session::SECTION_ACCOUNTING, $session::ACCESS_ADMIN)} + Merci d'en créer un nouveau pour pouvoir saisir des écritures. + {/if} +

    +{/if} + +{form_errors} + +{if count($list)} + + + + + + + + + + {foreach from=$list item="item"} + + + + + + + + {/foreach} + +
    PaysLibelléTypeArchivé
    {if $item.country}{$item.country|get_country_name}{else}-Autre-{/if}{$item.label}{if $item.code}Officiel{else}Personnel{/if}{if $item.archived}Archivé{/if} + {if $item.country} + {linkbutton shape="star" label="Comptes usuels" href="!acc/charts/accounts/?id=%d"|args:$item.id} + {/if} + {linkbutton shape="menu" label="Tous les comptes" href="!acc/charts/accounts/all.php?id=%d"|args:$item.id} + {if $session->canAccess($session::SECTION_ACCOUNTING, $session::ACCESS_ADMIN)} + {linkbutton shape="edit" label="Modifier" href="!acc/charts/edit.php?id=%d"|args:$item.id target="_dialog"} + {if $item->canDelete()} + {linkbutton shape="delete" label="Supprimer" href="!acc/charts/delete.php?id=%d"|args:$item.id target="_dialog"} + {/if} + {/if} + {exportmenu class="menu-btn-right" href="export.php?id=%d"|args:$item.id suffix="format="} +
    +{/if} + +{if $session->canAccess($session::SECTION_ACCOUNTING, $session::ACCESS_ADMIN)} +
    +
    + Créer un nouveau plan comptable +
    + {input type="radio-btn" name="type" value="install" label="Ajouter un autre plan comptable officiel"} + {input type="radio-btn" name="type" value="copy" label="Recopier un plan comptable pour le modifier"} + {input type="radio-btn" name="type" value="import" label="Importer un plan comptable personnel" help="À partir d'un tableau (CSV, Office, etc.)"} +
    +
    + + + + + + + +

    + {csrf_field key=$csrf_key} + {button type="submit" name="new" label="Créer" shape="right" class="main"} +

    +
    + +{/if} + +{include file="_foot.tpl"} \ No newline at end of file diff --git a/src/templates/acc/index.tpl b/src/templates/acc/index.tpl new file mode 100644 index 0000000..a901bc1 --- /dev/null +++ b/src/templates/acc/index.tpl @@ -0,0 +1,91 @@ +{include file="_head.tpl" title="Comptabilité" current="acc"} + +{if !empty($all_years)} +
    +
    + Recherche rapide +

    + + {input type="select" name="year" options=$all_years default=$first_year} + {button type="submit" shape="search" label="Chercher"} +

    +

    + Indiquer un numéro de compte, un numéro d'écriture précédé par le signe hash (#1234), un montant précédé par le signe égal (=62,41) ou une date (JJ/MM/AAAA), sinon la recherche sera effectuée sur le libellé ou la pièce comptable. +

    +
    +
    +{/if} + +{foreach from=$years item="year"} +
    +

    {$year.label} — + Du {$year.start_date|date_short} au {$year.end_date|date_short}

    + + + + {if $year.nb_transactions > 3} +
    + {foreach from=$graphs key="url" item="label"} +
    + +
    {$label}
    +
    + {/foreach} +
    + {else} +

    Il n'y a pas encore suffisamment d'écritures dans cet exercice pour pouvoir afficher les statistiques.

    +

    {linkbutton label="Saisir une nouvelle écriture" shape="plus" href="transactions/new.php?set_year=%d"|args:$year.id}

    + {/if} + + {if $year.nb_transactions} + id]; ?> +

    Dernières écritures

    + {include file="common/dynamic_list_head.tpl" check=false disable_user_ordering=true} + {foreach from=$list->iterate() item="line"} + + {$line.type_label} + #{$line.id} + {$line.date|date_short} + {$line.change|abs|raw|money} + {$line.reference} + {$line.label} + {$line.line_reference} + {foreach from=$line.project_code item="code" key="id"}{$code} {/foreach} + {if isset($line.locked)} + {if $line.locked}{icon title="Écriture verrouillée" shape="lock"}{/if} + {/if} + {if $line.files}{$line.files}{/if} + + {linkbutton href="!acc/transactions/details.php?id=%d"|args:$line.id label="Détails" shape="search"} + + + {/foreach} + + + {/if} +
    + + +{foreachelse} +

    + Il n'y a aucun exercice ouvert en cours.
    + {linkbutton label="Ouvrir un nouvel exercice" shape="plus" href="!acc/years/new.php"} +

    +{/foreach} + +{include file="_foot.tpl"} \ No newline at end of file diff --git a/src/templates/acc/projects/_list.tpl b/src/templates/acc/projects/_list.tpl new file mode 100644 index 0000000..07b2073 --- /dev/null +++ b/src/templates/acc/projects/_list.tpl @@ -0,0 +1,63 @@ + + {if !empty($caption)}{/if} + + + + + + + + + + + + + {foreach from=$list item="parent"} + + + + + {foreach from=$parent.items item="item"} + sum_revenue - $item->sum_expense; ?> + + + + + + + + + + + {/foreach} + + {/foreach} +
    {$caption}
    ProjetChargesProduitsRésultatDébitsCréditsSolde
    +

    {$parent.label}{if $parent.archived} (archivé){/if}

    + {if $parent.description}

    {$parent.description|escape|nl2br}

    {/if} + {if !$table_export && !$by_year && $session->canAccess($session::SECTION_ACCOUNTING, $session::ACCESS_ADMIN)} +

    + {linkbutton shape="edit" label="Modifier" href="edit.php?id=%d"|args:$parent.id target="_dialog"} + {linkbutton shape="delete" label="Supprimer" href="delete.php?id=%d"|args:$parent.id target="_dialog"} +

    + {/if} + {if !$table_export && $by_year} +

    + {linkbutton href="!acc/reports/ledger.php?project=all&year=%d"|args:$parent.id_year label="Grand livre analytique"} +

    + {/if} +
    {$item.label}{if $item.archived} (archivé){/if} + {if !$table_export} + id_project ?: 'all'; + ?> + + Graphiques + | Balance générale + | Journal général + | Grand livre + | Compte de résultat + | Bilan + + {/if} + {$item.sum_expense|raw|money}{$item.sum_revenue|raw|money}{$result|raw|money:true:true}{$item.debit|raw|money:false}{$item.credit|raw|money:false}{$item.sum|raw|money:false}
    diff --git a/src/templates/acc/projects/_nav.tpl b/src/templates/acc/projects/_nav.tpl new file mode 100644 index 0000000..f54917a --- /dev/null +++ b/src/templates/acc/projects/_nav.tpl @@ -0,0 +1,25 @@ +{if !$dialog} + +{/if} \ No newline at end of file diff --git a/src/templates/acc/projects/config.tpl b/src/templates/acc/projects/config.tpl new file mode 100644 index 0000000..317e36f --- /dev/null +++ b/src/templates/acc/projects/config.tpl @@ -0,0 +1,28 @@ +{include file="_head.tpl" title="Projets - configuration" current="acc/years"} + +{include file="./_nav.tpl" current="config" order_code=null} + +{if $_GET.msg == 'SAVED'} +

    + La configuration a été enregistrée. +

    +{/if} + +{form_errors} + +
    +
    + Configuration des projets +
    +
    + {input type="radio" name="analytical_set_all" value="1" label="à tous les comptes" source=$config help="permet de suivre la caisse, banque, comptes de tiers, etc. dans un projet"} + {input type="radio" name="analytical_set_all" value="0" label="seulement aux comptes de charge et de produit" source=$config} +
    +
    +

    + {csrf_field key="save_config"} + {button type="submit" name="save_config" label="Enregistrer" shape="right" class="main"} +

    +
    + +{include file="_foot.tpl"} \ No newline at end of file diff --git a/src/templates/acc/projects/delete.tpl b/src/templates/acc/projects/delete.tpl new file mode 100644 index 0000000..aa4e370 --- /dev/null +++ b/src/templates/acc/projects/delete.tpl @@ -0,0 +1,8 @@ +{include file="_head.tpl" title="Supprimer un projet" current="acc/years"} + +{include file="common/delete_form.tpl" + legend="Supprimer ce projet ?" + warning="Êtes-vous sûr de vouloir supprimer le projet « %s » ?"|args:$project.label + info="Le contenu des écritures comptables ne sera pas modifiées, seule l'affectation à ce projet sera supprimée."} + +{include file="_foot.tpl"} \ No newline at end of file diff --git a/src/templates/acc/projects/edit.tpl b/src/templates/acc/projects/edit.tpl new file mode 100644 index 0000000..125a999 --- /dev/null +++ b/src/templates/acc/projects/edit.tpl @@ -0,0 +1,25 @@ +{include file="_head.tpl" title="Projet" current="acc/years"} + +{include file="./_nav.tpl" current=null} + +{form_errors} + +
    +
    + {if $project->exists()}Modifier un projet{else}Créer un projet{/if} +
    + {input type="text" required=true name="label" label="Libellé du projet" source=$project} + {input type="text" name="code" label="Code du projet" source=$project help="Utile pour retrouver le projet rapidement. Ne peut contenir que des chiffres et des lettres majuscules." pattern="[0-9A-Z_]+"} + {input type="textarea" name="description" label="Description du projet" source=$project} +
    Archivage
    + {input type="checkbox" name="archived" label="Archiver ce projet" value=1 source=$project help="Si coché, ce projet ne sera plus proposé dans la sélection de projets lors de la saisie d'une écriture."} +
    +
    + +

    + {csrf_field key=$csrf_key} + {button type="submit" name="save" label="Enregistrer" shape="right" class="main"} +

    +
    + +{include file="_foot.tpl"} \ No newline at end of file diff --git a/src/templates/acc/projects/index.tpl b/src/templates/acc/projects/index.tpl new file mode 100644 index 0000000..274ab31 --- /dev/null +++ b/src/templates/acc/projects/index.tpl @@ -0,0 +1,38 @@ +{include file="_head.tpl" title="Projets" current="acc/years"} + +{include file="./_nav.tpl" current='index'} + +
    +

    {$config.org_name} — Projets

    + +{if $projects_count} +

    + {if $by_year} + {linkbutton href="./" label="Grouper par projet" shape="left"} + {else} + {linkbutton href="?by_year=1" label="Grouper par exercice" shape="right"} + {/if} + {linkbutton href="!acc/reports/ledger.php?project=all" label="Grand livre analytique — tous les exercices"} +

    + + + + +{/if} +
    + +{if $projects_count} + + {include file="./_list.tpl"} + +{else} +

    Il n'existe pas encore de projet. {linkbutton label="Créer un nouveau projet" href="edit.php" shape="plus" target="_dialog"}

    +

    Les projets (aussi appelés comptabilité analytique) permettent de suivre le budget d'une activité ou d'un projet. {linkbutton shape="help" label="Aide sur les projets" target="_dialog" href=$help_pattern_url|args:"comptabilite-analytique"}

    +{/if} + +{include file="_foot.tpl"} \ No newline at end of file diff --git a/src/templates/acc/reports/_header.tpl b/src/templates/acc/reports/_header.tpl new file mode 100644 index 0000000..d52b2d0 --- /dev/null +++ b/src/templates/acc/reports/_header.tpl @@ -0,0 +1,103 @@ +
    + + + {if !empty($year)} +
    + {if !empty($allow_compare) && !empty($other_years)} +
    + + {if isset($project)} + + {/if} +
    + Comparer avec un autre exercice +

    + {input type="select" name="compare_year" options=$other_years default=$criterias.compare_year} + {button type="submit" label="OK" shape="right"} +

    +
    +
    + {/if} + + {if !empty($allow_filter)} +
    + + {if isset($project)} + + {/if} +
    + Filtrer par date +

    + + {input type="date" name="after" default=$after_default} + + {input type="date" name="before" default=$before_default} + {button type="submit" label="OK" shape="right"} + +

    +
    +
    + {/if} +
    + {/if} + + {if $config.files.logo} + + {/if} + +

    {$config.org_name} — {$title}

    + {if isset($project)} +

    Projet : {if $project.code}{$project.code} — {/if}{$project.label}{if $project.archived} (archivé){/if}

    + {/if} + {if isset($year)} +

    Exercice : {$year.label} ({if $year.closed}clôturé{else}en cours{/if}) + — du {$year.start_date|date_short} + — au {$year.end_date|date_short}
    + Document généré le {$now|date_short} +

    + {/if} + + +
    + + {if !empty($allow_filter) && isset($year) && $criterias.before && $criterias.after} +

    + Attention, seules les écritures du {$criterias.after|date_short} au {$criterias.before|date_short} sont prises en compte. +

    + {/if} diff --git a/src/templates/acc/reports/_journal.tpl b/src/templates/acc/reports/_journal.tpl new file mode 100644 index 0000000..7cbb942 --- /dev/null +++ b/src/templates/acc/reports/_journal.tpl @@ -0,0 +1,46 @@ + + + + + + + + {if !empty($with_linked_users)}{/if} + + + + + + {if isset($criterias) && $criterias.project}{/if} + {if !empty($action)}{/if} + + + {foreach from=$journal item="transaction"} + + + + + + + {if !empty($with_linked_users)}{/if} + {foreach from=$transaction.lines key="k" item="line"} + {if $k > 0}{/if} + + + + + + {if isset($criterias) && $criterias.project} + debit + $line->credit; ?> + + {/if} + {if !empty($action) && $k == 0} + + {/if} + + {/foreach} + + {/foreach} +
    Pièce comptableDateLibelléMembres associésComptesDébitCréditLibellé ligneRéf. ligneCumul
    {if $transaction.id}#{$transaction.id}{/if}{$transaction.reference}{$transaction.date|date_short}{$transaction.label}{$transaction.linked_users}
    {$line.account_code} - {$line.account_label}{$line.debit|raw|money}{$line.credit|raw|money}{$line.label}{$line.reference}{$running_sum|raw|money:false} + {linkbutton href=$action.href|args:$transaction.id shape=$action.shape label=$action.label} +
    \ No newline at end of file diff --git a/src/templates/acc/reports/_journal_diff.tpl b/src/templates/acc/reports/_journal_diff.tpl new file mode 100644 index 0000000..67ed741 --- /dev/null +++ b/src/templates/acc/reports/_journal_diff.tpl @@ -0,0 +1,148 @@ + + + + + + + + {if !empty($with_linked_users)}{/if} + + + + + + + + + {foreach from=$journal item="t"} + getLines()); + ?> + + + + + + + {if !empty($with_linked_users)} + + {/if} + {if $diff.lines_removed || $diff.lines_new || $diff.lines} + {foreach from=$diff.lines_removed item="line"} + + + + + + + + + {/foreach} + {foreach from=$diff.lines_new item="line"} + + + + + + + + + {/foreach} + {foreach from=$diff.lines item="line"} + + + + + + + + + {/foreach} + {else} + {foreach from=$transaction->getLinesWithAccounts() item="line"} + + + + + + + + + {/foreach} + {/if} + + + {/foreach} +
    Pièce comptableDateLibelléMembres associésComptesDébitCréditLibellé ligneRéf. ligneProjet
    {if $transaction.id}#{$transaction.id}{/if} + {if $diff.transaction.reference} + {$diff.transaction.reference[0]}
    + {$diff.transaction.reference[1]} + {else} + {$transaction.reference} + {/if} +
    + {if $diff.transaction.date} + {$diff.transaction.date[0]|date_short}
    + {$diff.transaction.date[1]|date_short} + {else} + {$transaction.date|date_short} + {/if} +
    + {if $diff.transaction.label} + {$diff.transaction.label[0]}
    + {$diff.transaction.label[1]} + {else} + {$transaction.label} + {/if} +
    + {if $diff.linked_users} + {$diff.linked_users[0]}
    + {$diff.linked_users[1]} + {else} + {$t.linked_users} + {/if} +
    {$line.account}{$line.debit|raw|money}{$line.credit|raw|money}{$line.label}{$line.reference}{$line.project}
    {$line.account}{$line.debit|raw|money}{$line.credit|raw|money}{$line.label}{$line.reference}{$line.project}
    + {if $line.diff.account} + {$line.diff.account[0]} + {$line.diff.account[1]} + {else} + {$line.account} + {/if} + + {if $line.diff.debit} + {$line.diff.debit[0]|raw|money:false} + {$line.diff.debit[1]|raw|money:false} + {else} + {$line.debit|raw|money} + {/if} + + {if $line.diff.credit} + {$line.diff.credit[0]|raw|money:false} + {$line.diff.credit[1]|raw|money:false} + {else} + {$line.credit|raw|money} + {/if} + + {if $line.diff.label} + {$line.diff.label[0]} + {$line.diff.label[1]} + {else} + {$line.label} + {/if} + + {if $line.diff.reference} + {$line.diff.reference[0]} + {$line.diff.reference[1]} + {else} + {$line.reference} + {/if} + + {if $line.diff.project} + {$line.diff.project[0]} + {$line.diff.project[1]} + {else} + {$line.project} + {/if} +
    {$line.account_code} - {$line.account_label}{$line.debit|raw|money}{$line.credit|raw|money}{$line.label}{$line.reference}{$line.project}
    \ No newline at end of file diff --git a/src/templates/acc/reports/_statement.tpl b/src/templates/acc/reports/_statement.tpl new file mode 100644 index 0000000..438010c --- /dev/null +++ b/src/templates/acc/reports/_statement.tpl @@ -0,0 +1,108 @@ +body_left), count($statement->body_right)); +?> + + {if !empty($caption)}{/if} + + + + + + + {if !empty($year2)} + + + + + + + + + + + + + + {/if} + + + body_left[$i] ?? null; + $class = $i % 2 == 0 ? 'odd' : 'even'; + ?> + + {if $row} + + + + {if isset($year2)} + + + {/if} + {else} + + {/if} + + body_right[$i] ?? null; ?> + {if $row} + + + + {if isset($year2)} + + + {/if} + {else} + + {/if} + + + + + + + foot_left), count($statement->foot_right)); ?> + foot_left[$i] ?? null; + $class = $i % 2 == 0 ? 'odd' : 'even'; + ?> + + {if $row} + + + {if $row.balance2 || $row.change} + + + {/if} + {else} + + {/if} + + foot_right[$i] ?? null; ?> + {if $row} + + + {if $row.balance2 || $row.change} + + + {/if} + {else} + + {/if} + + + +
    {$caption}
    {$statement.caption_left}{$statement.caption_right}
    {$year->label_years()}{$year2->label_years()}Écart{$year->label_years()}{$year2->label_years()}Écart
    + {if !empty($year) && $row.id} + {link href="!acc/accounts/journal.php?id=%d&year=%d"|args:$row.id,$year.id label=$row.code} + {else} + {$row.code} + {/if} + {$row.label}{$row.balance|raw|money:false}{$row.balance2|raw|money:false}{$row.change|raw|money:false:true} + {if !empty($year) && $row.id} + {link href="!acc/accounts/journal.php?id=%d&year=%d"|args:$row.id,$year.id label=$row.code} + {else} + {$row.code} + {/if} + {$row.label}{$row.balance|raw|money:false}{$row.balance2|raw|money:false}{$row.change|raw|money:false:true}
    {$row.label}{$row.balance|raw|money:false}{$row.balance2|raw|money:false}{$row.change|raw|money:false:true}{$row.label}{$row.balance|raw|money:false}{$row.balance2|raw|money:false}{$row.change|raw|money:false:true}
    diff --git a/src/templates/acc/reports/_statement_table.tpl b/src/templates/acc/reports/_statement_table.tpl new file mode 100644 index 0000000..b918cb9 --- /dev/null +++ b/src/templates/acc/reports/_statement_table.tpl @@ -0,0 +1,35 @@ + + {if !empty($caption)} + + {/if} + {if !empty($year2)} + + + + + + + + + + {/if} + + {foreach from=$accounts item="account"} + + + + {if isset($year2)} + + {/if} + + {if isset($year2)} + + {/if} + + {/foreach} + +

    {$caption}

    {$year2->label_years()}{$year->label_years()}Écart
    + {if !empty($year) && $account.id}{$account.code} + {else}{$account.code} + {/if} + {$account.label}{$account.balance2|raw|money:false}{$account.balance|raw|money:false}{$account.change|raw|money:false:true}
    \ No newline at end of file diff --git a/src/templates/acc/reports/balance_sheet.tpl b/src/templates/acc/reports/balance_sheet.tpl new file mode 100644 index 0000000..c4fa6d0 --- /dev/null +++ b/src/templates/acc/reports/balance_sheet.tpl @@ -0,0 +1,18 @@ +{include file="_head.tpl" title="%sBilan"|args:$project_title current="acc/years"} + +{include file="acc/reports/_header.tpl" current="balance_sheet" title="Bilan" allow_compare=true allow_filter=true} + +

    Le bilan représente une image de votre organisation : l'actif étant ce que l'organisation possède comme ressources (immeubles, comptes en banque, outillage, etc.), et le passif représente comment l'organisation a obtenu ces ressources (dettes, fonds de réserve, résultat…). En gros : à gauche = ce qu'on a, à droite = comment on l'a obtenu.

    + +{if $balance.sums.asset != $balance.sums.liability} +

    + Le bilan n'est pas équilibré !
    + Vérifiez que vous n'avez pas oublié de reporter des soldes depuis le précédent exercice. +

    +{/if} + +{include file="acc/reports/_statement.tpl" statement=$balance} + +

    Toutes les écritures sont libellées en {$config.currency}.

    + +{include file="_foot.tpl"} \ No newline at end of file diff --git a/src/templates/acc/reports/graphs.tpl b/src/templates/acc/reports/graphs.tpl new file mode 100644 index 0000000..fba0ea5 --- /dev/null +++ b/src/templates/acc/reports/graphs.tpl @@ -0,0 +1,24 @@ +{include file="_head.tpl" title="%sGraphiques"|args:$project_title current="acc/years"} + +{include file="acc/reports/_header.tpl" current="graphs" title="Graphiques" allow_filter=false} + +{if $nb_transactions < 3} +

    Il n'y a pas encore suffisamment d'écritures dans cet exercice pour pouvoir afficher les statistiques.

    +{else} +
    +
    + {foreach from=$graphs key="url" item="label"} +
    + +
    {$label}
    +
    + {/foreach} +
    +
    + +

    + En raison des arrondis, la somme des pourcentages peut ne pas être égale à 100%. +

    +{/if} + +{include file="_foot.tpl"} \ No newline at end of file diff --git a/src/templates/acc/reports/journal.tpl b/src/templates/acc/reports/journal.tpl new file mode 100644 index 0000000..9cb8d0c --- /dev/null +++ b/src/templates/acc/reports/journal.tpl @@ -0,0 +1,9 @@ +{include file="_head.tpl" title="%sJournal général"|args:$project_title current="acc/years"} + +{include file="acc/reports/_header.tpl" current="journal" title="Journal général" allow_filter=true} + +{include file="acc/reports/_journal.tpl"} + +

    Toutes les écritures sont libellées en {$config.currency}.

    + +{include file="_foot.tpl"} \ No newline at end of file diff --git a/src/templates/acc/reports/ledger.tpl b/src/templates/acc/reports/ledger.tpl new file mode 100644 index 0000000..1ffa02e --- /dev/null +++ b/src/templates/acc/reports/ledger.tpl @@ -0,0 +1,162 @@ +{if !empty($criterias.projects_only)} + {include file="_head.tpl" title="Grand livre analytique" current="acc/years"} + {include file="acc/reports/_header.tpl" current="analytical_ledger" title="Grand livre analytique" allow_filter=true} +{else} + {include file="_head.tpl" title="%sGrand livre"|args:$project_title current="acc/years"} + {include file="acc/reports/_header.tpl" current="ledger" title="Grand livre" allow_filter=true} +{/if} + +
    + + +
    + +{if $table_export} + + + + + {if !empty($criterias.projects_only)} + + {/if} + + + + + + + + + +{/if} + +{foreach from=$ledger item="account"} + + {if $table_export} + + + + + + {else} +
    +

    + {if !empty($criterias.projects_only)} + id, $account->id_year); ?> + {elseif !$criterias.project} + id, $account->id_year); ?> + {else} + + {/if} + {if $link}{/if} + {if $account.code}{$account.code} — {/if}{$account.label} + {if $link}{/if} +

    + +
    CompteN° pièceRéf. ligneDateIntituléDébitCréditSolde

    {if $account.code}{$account.code} — {/if}{$account.label}

    + + + + {if !empty($criterias.projects_only)} + + {/if} + + + + + + + + + + {/if} + + + + {foreach from=$account.lines item="line"} + + + {if !empty($criterias.projects_only)} + + {/if} + + + + + + + + + {/foreach} + + + + + + + + + + + + {if $table_export && isset($account->all_debit)} + + + + + + + + {/if} + + + + {if !$table_export} +
    CompteN° pièceRéf. ligneDateIntituléDébitCréditSolde
    #{$line.id}{link href="!acc/accounts/journal.php?id=%d&year=%d"|args:$line.id_account,$line.id_year label=$line.account_code}{$line.reference}{$line.line_reference}{$line.date|date_short}{$line.label}{if $line.line_label} ({$line.line_label}){/if}{$line.debit|raw|money}{$line.credit|raw|money}{$line.running_sum|raw|money:false}
    Solde final{$account.debit|raw|money}{$account.credit|raw|money}{$account.sum|raw|money:false}
    Totaux{$account.all_debit|raw|money:false}{$account.all_credit|raw|money:false}
    + + + {/if} + + {if !$table_export && isset($account->all_debit)} + + + + + + + + + + + + + + + +
    Totaux{$account.all_debit|raw|money:false}{$account.all_credit|raw|money:false}
    + {/if} + +{/foreach} + +{if $table_export} + +{/if} + +{literal} + +{/literal} + +

    Toutes les écritures sont libellées en {$config.currency}.

    + +{include file="_foot.tpl"} diff --git a/src/templates/acc/reports/statement.tpl b/src/templates/acc/reports/statement.tpl new file mode 100644 index 0000000..bc2bb20 --- /dev/null +++ b/src/templates/acc/reports/statement.tpl @@ -0,0 +1,16 @@ +{include file="_head.tpl" title="%sCompte de résultat"|args:$project_title current="acc/years"} + +{include file="acc/reports/_header.tpl" current="statement" title="Compte de résultat" allow_compare=true allow_filter=true} + +

    Le compte de résultat indique les recettes (produits) et dépenses (charges), ainsi que le résultat réalisé.

    + +{include file="acc/reports/_statement.tpl" statement=$general} + +{if !empty($volunteering.body_left) || !empty($volunteering.body_right)} +

    Contributions bénévoles en nature

    + {include file="acc/reports/_statement.tpl" statement=$volunteering header=false caption1="Emplois des contributions volontaires en nature" caption2="Contributions volontaires en nature" caption="Contributions bénévoles en nature"} +{/if} + +

    Toutes les écritures sont libellées en {$config.currency}.

    + +{include file="_foot.tpl"} \ No newline at end of file diff --git a/src/templates/acc/reports/trial_balance.tpl b/src/templates/acc/reports/trial_balance.tpl new file mode 100644 index 0000000..9710c70 --- /dev/null +++ b/src/templates/acc/reports/trial_balance.tpl @@ -0,0 +1,44 @@ +{include file="_head.tpl" title="%sBalance générale"|args:$project_title current="acc/years"} + +{include file="acc/reports/_header.tpl" current="trial_balance" title="Balance générale" allow_filter=true} + + + + + + + + + {if !$simple} + + + {else} + + {/if} + + + + {foreach from=$balance item="account"} + + + + + + {if !$simple} + + + {else} + + {/if} + + {/foreach} + +
    NuméroCompteTotal des débitsTotal des créditsSolde débiteurSolde créditeurSolde
    + {if !empty($year) && !$criterias.project}{$account.code} + {else}{$account.code} + {/if} + {$account.label}{$account.debit|raw|money:false}{$account.credit|raw|money:false}{if $account.balance > 0}{$account.balance|abs|escape|money:false}{/if}{if $account.balance < 0}{$account.balance|abs|escape|money:false}{/if}{if $account.balance !== null}{$account.balance|escape|money:false}{/if}
    + +

    Toutes les écritures sont libellées en {$config.currency}. Les lignes grisées correspondent aux comptes soldés.

    + +{include file="_foot.tpl"} \ No newline at end of file diff --git a/src/templates/acc/search.tpl b/src/templates/acc/search.tpl new file mode 100644 index 0000000..fc89f80 --- /dev/null +++ b/src/templates/acc/search.tpl @@ -0,0 +1,188 @@ +{include file="_head.tpl" title="Recherche" current="acc"} + + + +
    + +{include file="common/search/advanced.tpl" legend="Rechercher des écritures…"} + +{if $list !== null} +

    {$list->count()} écritures trouvées pour cette recherche.

    + + {if $list->count() > 0} +

    {exportmenu form=true name="_dl_export" class="menu-btn-right"}

    + {/if} + + {include file="common/dynamic_list_head.tpl" check=$is_admin use_buttons=true} + + + + {foreach from=$list->iterate() item="row"} + + {if $is_admin && $row.id_line && $row.id} + {input type="checkbox" name="check[%s]"|args:$row.id_line value=$row.id} + {/if} + {foreach from=$row key="key" item="value"} + + {if $prev_id == $row.id && !in_array($key, ['debit', 'credit', 'account_code', 'line_label', 'line_reference', 'project_code'])} + + {elseif $key == 'id'} + + {link href="!acc/transactions/details.php?id=%d"|args:$value label="#%d"|args:$value} + + {elseif $key == 'credit' || $key == 'debit'} + + + {$value|raw|money:false} + + {else} + + {if $key === 'date'} + {$value|date_short} + {elseif $column.type === 'boolean'} + {if $value === null} + — + {elseif $value} + Oui + {else} + Non + {/if} + {else} + {$value} + {/if} + + {/if} + {/foreach} + + {if $prev_id != $row.id} + {linkbutton shape="search" label="Détails" href="!acc/transactions/details.php?id=%d"|args:$row.id} + {/if} + + + id; ?> + {/foreach} + + + {if $debit !== null || $credit !== null} + $v) { + if ($key == 'credit' || $key == 'debit') { + break; + } + $span1++; + } + if ($is_admin) { + $span1--; + } + $span2 = count((array)$row) - $span1; + ?> + + + {if $is_admin}{/if} + Totaux de cette page + {foreach from=$row key="key" item="value"} + {if $key == 'credit' || $key == 'debit'} + + + {$total|raw|money:false} + + {/if} + {/foreach} + + {if $is_admin} + {include file="acc/_table_actions.tpl"} + {/if} + + + + {/if} + + + + {$list->getHTMLPagination(true)|raw} + +{elseif $count} + +

    {exportmenu form=true name="_export" class="menu-btn-right"}

    + +

    {$count} résultats trouvés pour cette recherche.

    + + + + + + + {if $is_admin} + + {/if} + {foreach from=$header item="column"} + {$column} + {/foreach} + + + + {foreach from=$results item="row"} + + + + {if $is_admin && $id_column !== false && $id_line_column !== false} + + {elseif $is_admin} + + {/if} + {foreach from=$row key="key" item="value"} + {if $prev_id == $id && $key === $id_column} + + {elseif $id_column === $key} + + {else} + + {/if} + {/foreach} + + + {/foreach} + + {if $is_admin && $id_column !== false && $id_line_column !== false} + + + + + + {/if} +
    {input type="checkbox" name="check[%s]"|args:$id_line value=$id}{link href="!acc/transactions/details.php?id=%d"|args:$value label="#%d"|args:$value}{$value}
    + {include file="acc/_table_actions.tpl"} +
    + +{elseif $count === 0} + +

    Aucun résultat trouvé pour cette recherche.

    + +{/if} + +
    + +{include file="_foot.tpl"} \ No newline at end of file diff --git a/src/templates/acc/transactions/_form.tpl b/src/templates/acc/transactions/_form.tpl new file mode 100644 index 0000000..44d75bc --- /dev/null +++ b/src/templates/acc/transactions/_form.tpl @@ -0,0 +1,135 @@ +type) && !$transaction->exists() && !$transaction->label; +$is_quick = count(array_intersect_key($_GET, array_flip(['a', 'l', 'd', 't', 'account']))) > 0; + +?> +
    + {form_errors} + +
    + Type d'écriture +
    + {if isset($payoff)} + {input type="radio-btn" name="type" value=99 source=$transaction label=$payoff.type_label} + {input type="radio-btn" name="type" value=0 source=$transaction label="Avancé"} + {else} + {foreach from=$types_details item="type"} +
    + {input type="radio" name="type" value=$type.id source=$transaction label=null} + +
    + {/foreach} + {/if} +
    +
    + + {if isset($payoff)} +
    + {if $payoff.type == $transaction::TYPE_DEBT}Règlement de dette{else}Règlement de créance{/if} +
    +
    Écritures d'origine
    + {foreach from=$payoff.transactions item="t"} +
    {link class="num" href="!acc/transactions/details.php?id=%d"|args:$t.id label="#%d"|args:$t.id} — {$t.label} — {$t->sum()|money_currency|raw}
    + {/foreach} + {input type="checkbox" name="mark_paid" value="1" default="1" label="Marquer ces écritures comme réglées"} +
    +
    + {/if} + +
    + + {/foreach} + +
    +

    + {button type="submit" name="run" label="Exécuter" shape="search" class="main"} + + {if $s->exists()} + {button name="save" value=1 type="submit" label="Enregistrer : %s"|args:$s.label|truncate:40:"…":true shape="upload"} + {button name="save_new" value=1 type="submit" label="Enregistrer nouvelle recherche" shape="plus"} + {else} + {button name="save" value=1 type="submit" label="Enregistrer cette recherche" shape="upload"} + {/if} + {if $session->canAccess($session::SECTION_CONFIG, $session::ACCESS_ADMIN)} + {linkbutton href="!config/advanced/sql.php" target="_blank" shape="menu" label="Voir le schéma SQL complet"} + {/if} +

    + {/if} +{else} + {if isset($legend)}{$legend}{else}Rechercher{/if} + +
    +

    + {button name="search" value=1 type="submit" label="Chercher" shape="search" id="send" class="main"} + + + {if $s.id} + {button name="save" value=1 type="submit" label="Enregistrer : %s"|args:$s.label|truncate:40:"…":true shape="upload"} + {button name="save_new" value=1 type="submit" label="Enregistrer nouvelle recherche" shape="plus"} + {else} + {button name="save" value=1 type="submit" label="Enregistrer cette recherche" shape="upload"} + {/if} + {if $is_admin} + {button name="to_sql" value=1 type="submit" label="Recherche SQL" shape="edit"} + {/if} +

    + +{/if} + diff --git a/src/templates/common/search/saved_searches.tpl b/src/templates/common/search/saved_searches.tpl new file mode 100644 index 0000000..d7a343e --- /dev/null +++ b/src/templates/common/search/saved_searches.tpl @@ -0,0 +1,85 @@ +{include file="_head.tpl" title="Recherches enregistrées" current=$target} + +{if $target == 'users'} + {include file="users/_nav.tpl" current="saved_searches"} +{else} + +{/if} + +{if $mode == 'edit'} + {form_errors} + +
    +
    + Modifier une recherche enregistrée +
    + {input type="text" name="label" label="Intitulé" required=1 source=$search} +
    Statut
    + id_user); ?> + {input type="radio" name="public" value="0" default=$public label="Recherche privée" help="Visible seulement par moi-même"} + {if $session->canAccess($access_section, $session::ACCESS_WRITE)} + {input type="radio" name="public" value="1" default=$public label="Recherche publique" help="Visible et exécutable par tous les membres ayant accès à la gestion %s"|args:$target} + {/if} +
    Type
    +
    + {if $search.type == $search::TYPE_JSON} + Avancée + {elseif $search.type == $search::TYPE_SQL_UNPROTECTED} + SQL non protégée + {else} + SQL + {/if} +
    +
    +
    + +

    + {csrf_field key=$csrf_key} + {button type="submit" name="save" label="Enregistrer" shape="right" class="main"} +

    +
    +{elseif $mode == 'delete'} + + {include file="common/delete_form.tpl" + legend="Supprimer cette recherche enregistrée ?" + warning="Êtes-vous sûr de vouloir supprimer la recherche enregistrée « %s » ?"|args:$search.label + csrf_key=$csrf_key + } + +{elseif count($list) == 0} +

    Aucune recherche enregistrée. Faire une nouvelle recherche

    +{else} + + + + + + + + + + + {foreach from=$list item="search"} + + + + + + + {/foreach} + +
    RechercheTypeStatut
    {$search.label}{if $search.type == $search::TYPE_JSON}Avancée{else}SQL{/if}{if !$search.id_user}Publique{else}Privée{/if} + {linkbutton href="%s?id=%d"|args:$search_url,$search.id shape="search" label="Rechercher"} + {if $search.id_user || $session->canAccess($access_section, $session::ACCESS_ADMIN)} + {linkbutton href="?edit=%d"|args:$search.id shape="edit" label="Modifier"} + {linkbutton href="?delete=%d"|args:$search.id shape="delete" label="Supprimer"} + {/if} +
    +{/if} + +{include file="_foot.tpl"} \ No newline at end of file diff --git a/src/templates/config/_menu.tpl b/src/templates/config/_menu.tpl new file mode 100644 index 0000000..c1336ad --- /dev/null +++ b/src/templates/config/_menu.tpl @@ -0,0 +1,37 @@ +{if !$dialog} + +{/if} \ No newline at end of file diff --git a/src/templates/config/advanced/api.tpl b/src/templates/config/advanced/api.tpl new file mode 100644 index 0000000..cf1af16 --- /dev/null +++ b/src/templates/config/advanced/api.tpl @@ -0,0 +1,67 @@ +{include file="_head.tpl" title="API" current="config"} + +{include file="config/_menu.tpl" current="advanced" sub_current="api"} + +{form_errors} + +{if count($list)} +
    + +

    + {csrf_field key=$csrf_key} + {button name="delete" value=1 type="submit" label="Supprimer l'identifiant sélectionné" shape="delete"} +

    + + + + + + + + + + + + + + + + {foreach from=$list item="c"} + + + + + + + + + {/foreach} + +
    DescriptionIdentifiantAccèsCréationDernière utilisation
    + {input type="radio" name="id" value=$c.id} + {$c.label}{$c.key}{$access_levels[$c.access_level]}{$c.created|date_short}{if $c.last_use}{$c.last_use|date}{else}-{/if}
    + +
    +{/if} + +
    +
    + Créer un nouvel identifiant +

    + Cet identifiant vous permettra de faire des requêtes vers l'API, pour modifier ou récupérer les informations de votre association.
    + {linkbutton shape="help" label="Documentation de l'API" href="%swiki?name=API"|args:$website} +

    +
    + {input type="text" name="label" label="Description" required=true} + {input type="text" name="key" label="Identifiant" help="Seules les lettres minuscules, chiffres et tirets bas sont acceptés." pattern="[a-z0-9_]+" required=true default=$default_key} + {input type="text" label="Mot de passe" default=$secret readonly="readonly" help="Ce mot de passe ne sera plus affiché, il est conseillé de le copier/coller et l'enregistrer de votre côté." name="secret" copy=true} + {input type="select" required=true label="Autorisation d'accès" options=$access_levels name="access_level"} +
    +

    + {csrf_field key=$csrf_key} + {button type="submit" name="add" label="Créer" shape="plus" class="main"} +

    +
    +
    + +{include file="_foot.tpl"} \ No newline at end of file diff --git a/src/templates/config/advanced/audit.tpl b/src/templates/config/advanced/audit.tpl new file mode 100644 index 0000000..c103edb --- /dev/null +++ b/src/templates/config/advanced/audit.tpl @@ -0,0 +1,19 @@ +{include file="_head.tpl" title="Journal d'audit — Historique des actions des membres" current="config"} + +{include file="../_menu.tpl" current="advanced" sub_current="audit"} + +

    + Cette page liste les tentatives de connexion, les modifications de mot de passe ou d'identifiant, et toutes les actions de création, suppression ou modification effectuées par tous les membres. +

    + +{if $list->count()} + {include file="users/_log_list.tpl"} +{else} +

    + Aucune activité trouvée. +

    +{/if} + + + +{include file="_foot.tpl"} \ No newline at end of file diff --git a/src/templates/config/advanced/errors.tpl b/src/templates/config/advanced/errors.tpl new file mode 100644 index 0000000..0bf1a6c --- /dev/null +++ b/src/templates/config/advanced/errors.tpl @@ -0,0 +1,86 @@ +{include file="_head.tpl" title="Journaux" current="config"} + +{include file="config/_menu.tpl" current="advanced" sub_current="errors"} + +{if isset($reports) && isset($id)} +
    + {foreach from=$main.errors item="error"} +

    {$error.type}: {$error.message} [Code: {$error.errorCode}]

    + {if !empty($error.backtrace)} + {foreach from=$error.backtrace item=trace} +
    + {if $trace.function} +

    + {$trace.function} + {if !empty($trace.args)} + + {foreach from=$trace.args key=name item=arg} + + + + + {/foreach} +
    {$name}{$arg}
    + {/if} +

    + {/if} + {if $trace.file}
    {$trace.file}:{$trace.line}
    {/if} + {if !empty($trace.code)} +
    {foreach from=$trace.code item=line key=n}{if $n == $trace.line}{/if}{$n} {$line}{if $n == $trace.line}{/if}
    {/foreach}
    + {/if} +
    + {/foreach} + {/if} + {/foreach} + + {foreach from=$reports item=report} +
    +

    Occurence du {$report.context.date|date}

    + + {foreach from=$report.context key="k" item="v"} + + + + + {/foreach} +
    {$k}{if $k == 'date'}{$v|date}{else}{$v}{/if}
    +
    + {/foreach} +
    +{elseif isset($errors)} + {if !count($errors)} +

    Aucune erreur n'a été trouvée dans le journal error.log

    + {else} + + + + + + + + + + + + + {foreach from=$errors item=error key=ref} + + + + + + + + + {/foreach} + +
    Réf.SiteErreurOccurencesDernière fois
    {$ref}{$error.hostname} + {$error.message}
    + {$error.source} +
    {$error.count}{$error.last_seen|date} + {linkbutton shape="menu" label="Voir les détails" href="%s?type=errors&id=%s"|args:$self_url_no_qs,$ref} +
    + {/if} +{/if} + +{include file="_foot.tpl"} \ No newline at end of file diff --git a/src/templates/config/advanced/index.tpl b/src/templates/config/advanced/index.tpl new file mode 100644 index 0000000..a544eff --- /dev/null +++ b/src/templates/config/advanced/index.tpl @@ -0,0 +1,87 @@ +{include file="_head.tpl" title="Fonctions avancées" current="config"} + +{include file="config/_menu.tpl" current="advanced" sub_current=null} + +{form_errors} + +{if $_GET.msg == 'RESET'} +

    + La remise à zéro a été effectuée. Une sauvegarde a également été créée.

    +

    +{else if $_GET.msg == 'REOPEN'} +

    + L'exercice sélectionné a été réouvert. +

    +{/if} + +

    + Ces fonctionnalités sont réservées à un public averti. +

    + +
    +
    Réouvrir un exercice clôturé
    +
    + À utiliser si vous avez clôturé un exercice par erreur. Attention, en comptabilité cette action est normalement exceptionnelle. +
    +
    + {linkbutton shape="reload" href="reopen.php" label="Réouvrir un exercice clôturé"} +
    + +
    Journal d'audit
    +
    + Affiche l'historique des actions (connexion, changement de mot de passe, création de membre, modification comptable, etc.) effectuées par tous les membres. +
    +
    + {linkbutton shape="history" label="Voir le journal d'audit" href="audit.php"} +
    + +
    Accès à l'API
    +
    + Permet de gérer les identifiants d'accès à l'API. Pour interfacer d'autres programmes et scripts avec les données de votre association. +
    +
    + {linkbutton shape="settings" label="Gérer les accès à l'API" href="api.php"} +
    + +
    SQL — Accès à la base de données brute
    +
    + Visualiser le schéma des tables et les données brutes de la base de données, ou y effectuer des requêtes SQL. +
    +
    + {linkbutton shape="code" label="Visualiser la base de données SQL" href="sql.php"} +
    + + {if ENABLE_TECH_DETAILS} +
    Journal des erreurs système
    +
    + Affiche le détail des erreurs système de Paheko et PHP. +
    +
    + {linkbutton shape="menu" label="Voir le journal des erreurs système" href="errors.php"} +
    + + {if SQL_DEBUG} +
    Journal des requêtes SQL
    +
    + Affiche le détail de toutes les requêtes SQL exécutées et leurs performances. +
    +
    + {linkbutton shape="menu" label="Voir le journal des requêtes SQL" href="sql_debug.php"} +
    + {/if} + + {/if} + + {if $logged_user.password} +
    Remise à zéro
    +
    + Efface toutes les données, sauf votre compte de membre. Utile pour revenir à l'état initial après une période d'essai. +
    +
    + {linkbutton shape="delete" href="reset.php" label="Remise à zéro"} +
    + {/if} +
    + + +{include file="_foot.tpl"} \ No newline at end of file diff --git a/src/templates/config/advanced/reopen.tpl b/src/templates/config/advanced/reopen.tpl new file mode 100644 index 0000000..284f416 --- /dev/null +++ b/src/templates/config/advanced/reopen.tpl @@ -0,0 +1,29 @@ +{include file="_head.tpl" title="Réouvrir un exercice clôturé" current="config"} + +{include file="config/_menu.tpl" current="advanced"} + +{form_errors} + + +{if count($closed_years)} +
    +
    + Réouvrir un exercice clôturé +

    + L'exercice sera réouvert, mais une écriture sera ajoutée au journal général indiquant que celui-ci a été réouvert après clôture. Cette écriture ne peut pas être supprimée. +

    +
    + {input type="select" options=$closed_years label="Exercicer à réouvrir" name="year" required=true default_empty="Sélectionner un exercice"} +
    +
    +

    + {csrf_field key="reopen_year"} + {button type="submit" name="reopen_ok" label="Réouvrir l'exercice sélectionné" shape="reset" class="main"} +

    +
    +{else} +

    Il n'y a aucun exercice clôturé. Il est donc impossible d'en réouvrir un :-)

    +{/if} + + +{include file="_foot.tpl"} \ No newline at end of file diff --git a/src/templates/config/advanced/reset.tpl b/src/templates/config/advanced/reset.tpl new file mode 100644 index 0000000..d2e8c7f --- /dev/null +++ b/src/templates/config/advanced/reset.tpl @@ -0,0 +1,36 @@ +{include file="_head.tpl" title="Remise à zéro" current="config"} + +{include file="config/_menu.tpl" current="advanced"} + +{form_errors} +
    + +
    + Remise à zéro +
    +

    Attention : toutes les données seront effacées !

    +
      +
    • Les membres seront supprimés, ainsi que les activités et l'historique d'inscription
    • +
    • Les écritures et exercices comptables seront aussi supprimés, avec toutes les autres données comptables
    • +
    • Le contenu du site web
    • +
    • Les documents, etc.
    • +
    • Bref : tout sera effacé !
    • +
    +

    Seul votre compte membre sera re-créé avec le même email et mot de passe.

    +
    +

    + Une sauvegarde sera automatiquement créée avant de procéder à la remise à zéro. +

    +
    + {input type="password" name="password_check" label="Merci d'entrer ici votre mot de passe" help="Pour valider que vous désirez bien tout effacer !" required=true} +
    +

    + {csrf_field key="reset"} + {button type="submit" name="reset_ok" label="Oui, je veux tout effacer et repartir de zéro" shape="delete" class="main"} +

    +
    + +
    + + +{include file="_foot.tpl"} \ No newline at end of file diff --git a/src/templates/config/advanced/sql.tpl b/src/templates/config/advanced/sql.tpl new file mode 100644 index 0000000..6f55fd4 --- /dev/null +++ b/src/templates/config/advanced/sql.tpl @@ -0,0 +1,185 @@ +{include file="_head.tpl" title="SQL" current="config"} + +{include file="config/_menu.tpl" current="advanced" sub_current="sql"} + + + +{form_errors} + +{if isset($query)} +
    + {if $query !== null} +

    Requête SQL

    + +
    + Faire une requête SQL en lecture +
    + {input type="textarea" cols="70" rows="10" name="query" default=$query class="full-width"} +
    +

    + {button type="submit" name="run" label="Exécuter" shape="search"} +

    +
    + {/if} + + {if !empty($result_count)} + +

    + {exportmenu form=true right=true} +

    +

    {$result_count} résultats trouvés pour cette requête, en {$query_time} ms.

    + + {if $result_header} + + + {foreach from=$result_header item="label"} + + {/foreach} + + + {/if} + + {foreach from=$result item="row"} + + {foreach from=$row key="key" item="value"} + + {/foreach} + + {/foreach} + + + + {elseif isset($result)} + +

    + Aucun résultat trouvé. +

    + + {/if} +
    + +{elseif !empty($table_info)} + +
    +

    Table : {$table_info.name}

    +

    + {linkbutton shape="menu" href="?table=%s"|args:$table_info.name label="Parcourir les données"} +

    + + + {include file="common/_sql_table.tpl" table=$table_info.schema indexes=$table_info.indexes fk_link=true class="center"} + +

    Schéma de la table

    +
    {$table_info.sql}
    +

    Schéma des index

    +
    {if empty($table_info.sql_indexes)}Aucun index{else}{$table_info.sql_indexes}{/if}
    +
    + +{elseif !empty($table)} + +

    Table : {$table}

    +
    +

    + {linkbutton shape="table" href="?table_info=%s"|args:$table label="Voir la structure"} + {exportmenu} +

    +
    + + {$list->getHTMLPagination()|raw} + + {include file="common/dynamic_list_head.tpl"} + + {foreach from=$list->iterate() item="row"} + + {foreach from=$row key="key" item="value"} + + {if null == $value} + NULL + {elseif $is_module && $key === 'document'} +
    {$value|format_json}
    + {else} + {$value} + {/if} + + {/foreach} + + + {/foreach} + + + + + {$list->getHTMLPagination()|raw} + +{else} + +

    Liste des tables

    + + + + + + + + + + + + {foreach from=$tables_list key="name" item="table"} + + + + + + + {/foreach} + +
    NomNombre de lignesTaille
    {$name} + {linkbutton shape="menu" href="?table=%s"|args:$name label="Parcourir"} + {linkbutton shape="table" href="?table_info=%s"|args:$name label="Structure"} + {$table.count}{if $table.size !== null}{$table.size|size_in_bytes}{else}(inconnue){/if}
    + +

    Liste des triggers

    + + + {foreach from=$triggers_list key="name" item="sql"} + + + + + {/foreach} +
    {$name}
    {$sql}
    + +

    Liste des index

    + + + {foreach from=$index_list key="name" item="sql"} + + + + + {/foreach} +
    {$name}{$sql}
    + +{/if} + +{include file="_foot.tpl"} \ No newline at end of file diff --git a/src/templates/config/advanced/sql_debug.tpl b/src/templates/config/advanced/sql_debug.tpl new file mode 100644 index 0000000..c233f71 --- /dev/null +++ b/src/templates/config/advanced/sql_debug.tpl @@ -0,0 +1,74 @@ +{include file="_head.tpl" title="Journal SQL" current="config"} + +{include file="config/_menu.tpl" current="advanced" sub_current="sql_debug"} + +{if isset($debug)} + + + + + + + + + {foreach from=$debug.list item="row"} + + + + + + + + + + + + + + {/foreach} +
    T.DuréeTrace
    time / 1000, 2)?> + duration / 1000, 2); ?> + {if $d > 0.4} +

    {$d}

    + {else} + {$d} + {/if} +
    {$row.trace}

    Query [replay]

    {$row.sql}

    EXPLAIN:

    {$row.explain}
    +{elseif isset($list)} +

    + Liste des pages consultées ayant mené à des requêtes SQL. +

    + + {if !count($list)} +

    Aucune requête n'a été trouvée dans le log

    + {else} + + + + + + + + + + + + + + {foreach from=$list item="row"} + + + + + + + + + + {/foreach} + +
    IDDateScriptMembre connectéDurée totaleDurée SQLNombre de requêtes
    {$row.id}{$row.date|date_format}{$row.script}{$row.user}{if $row.request_time >= 80}{$row.request_time} ms{else}{$row.request_time} ms{/if}{if $row.sql_time >= 20}{$row.sql_time} ms{else}{$row.sql_time} ms{/if}{$row.count}
    + {/if} +{/if} + +{include file="_foot.tpl"} \ No newline at end of file diff --git a/src/templates/config/backup/_menu.tpl b/src/templates/config/backup/_menu.tpl new file mode 100644 index 0000000..f24e95e --- /dev/null +++ b/src/templates/config/backup/_menu.tpl @@ -0,0 +1,8 @@ + \ No newline at end of file diff --git a/src/templates/config/backup/auto.tpl b/src/templates/config/backup/auto.tpl new file mode 100644 index 0000000..0dc5ca4 --- /dev/null +++ b/src/templates/config/backup/auto.tpl @@ -0,0 +1,50 @@ +{include file="_head.tpl" title="Sauvegardes automatiques" current="config"} + +{include file="config/_menu.tpl" current="backup"} + +{include file="config/backup/_menu.tpl" current="auto"} + +{form_errors} + +{if $_GET.msg === 'CONFIG_SAVED'} +

    + La configuration des sauvegardes a bien été enregistrée. +

    +{/if} + +
    + +
    + Configuration de la sauvegarde automatique +

    + En activant cette option une sauvegarde sera automatiquement créée à chaque intervalle donné. + Par exemple en activant une sauvegarde hebdomadaire, une copie des données sera réalisée + une fois par semaine, sauf si aucune modification n'a été effectuée sur les données + ou que personne ne s'est connecté. +

    +
    + {input type="select" name="backup_frequency" source=$config label="Fréquence de sauvegarde" required=true options=$frequencies} + {input type="number" step="1" min="0" max="50" name="backup_limit" source=$config label="Nombre de sauvegardes conservées" required=true options=$frequencies help="Par exemple avec une fréquence mensuelle, en indiquant de conserver 12 sauvegardes, vous pourrez garder un an d'historique de sauvegardes." default=1} +
    + Attention : si vous choisissez un nombre important et un intervalle réduit, + l'espace disque occupé par vos sauvegardes va rapidement augmenter. +
    +
    +

    + {csrf_field key=$csrf_key} + {button type="submit" name="config" label="Enregistrer" shape="right" class="main"} +

    +
    + +
    +

    + Attention, la sauvegarde automatique permet uniquement de revenir à un état antérieur, mais ne prévient pas de la perte des données !
    + Pour cela, il est recommandé de faire des sauvegardes manuelles en téléchargeant une copie des données sur votre ordinateur. +

    + {if FILE_STORAGE_BACKEND !== 'SQLite'} +

    La sauvegarde automatique ne concerne que la base de données, mais pas les documents, fichiers joints aux écritures ou aux membres, ni le contenu du site web.

    + {/if} +
    +
    + +{include file="_foot.tpl"} \ No newline at end of file diff --git a/src/templates/config/backup/documents.tpl b/src/templates/config/backup/documents.tpl new file mode 100644 index 0000000..8e8d7bb --- /dev/null +++ b/src/templates/config/backup/documents.tpl @@ -0,0 +1,43 @@ +{include file="_head.tpl" title="Restaurer les documents et fichiers joints" current="config"} + +{include file="config/_menu.tpl" current="backup"} + +{include file="config/backup/_menu.tpl" current="restore"} + +{if $ok} +

    La restauration a été effectuée.

    +{/if} + +{if $failed} +

    {$failed} fichiers n'ont pas pu être restaurés car ils dépassaient la taille autorisée.

    +{/if} + +{form_errors} + + + + + +{include file="_foot.tpl"} \ No newline at end of file diff --git a/src/templates/config/backup/index.tpl b/src/templates/config/backup/index.tpl new file mode 100644 index 0000000..d5d1941 --- /dev/null +++ b/src/templates/config/backup/index.tpl @@ -0,0 +1,77 @@ +{include file="_head.tpl" title="Sauvegarder" current="config"} + +{include file="config/_menu.tpl" current="backup"} + +{include file="config/backup/_menu.tpl" current="index"} + +{if $_GET.msg == 'BACKUP_CREATED'} +

    + Une nouvelle sauvegarde a été créée. +

    +{/if} + +{if !$config.backup_frequency} +
    +

    Les sauvegardes automatiques sont désactivées. Il est recommandé de les activer pour pouvoir revenir en arrière en cas de problème majeur.

    +

    Attention : cela ne dispense pas de réaliser des sauvegardes régulières sur votre ordinateur.

    +

    {linkbutton shape="settings" href="auto.php" label="Configurer les sauvegardes automatiques"}

    +
    +{/if} + +
    +
    +
    +

    Sauvegarder la base de données sur mon ordinateur

    +

    En cas de problème sur le serveur (plantage, dysfonctionnement du disque dur, incendie, etc.) vous pourriez perdre vos données.

    +

    Il est donc recommandé de réaliser régulièrement des sauvegardes et de les conserver sur votre ordinateur ou sur une clé USB ! +

    +

    + Cliquez sur le bouton ci-dessous pour télécharger une copie de la base de données et enregistrez-la ensuite sur votre ordinateur ou une clé USB : +

    +

    + {csrf_field key=$csrf_key} + {button type="submit" name="download" label="Télécharger la base de données" shape="download"} ({$db_size|size_in_bytes}) +

    +
    + +
    +

    Créer une nouvelle sauvegarde de la base de données sur le serveur

    +

    Cette sauvegarde sera enregistrée sur le serveur et pourra être restaurée plus tard.

    +

    Conseillé par exemple avant de réaliser une opération importante, pour pouvoir revenir en arrière.

    + {if FILE_STORAGE_BACKEND !== 'SQLite'} +

    Attention : seule la base de données est sauvegardées, pas les documents et fichiers joints.

    + {/if} +

    + {csrf_field key=$csrf_key} + {button type="submit" name="create" label="Créer une nouvelle sauvegarde" shape="plus"} +

    +
    + +
    +

    Télécharger les fichiers sur mon ordinateur

    +

    Permet de télécharger un fichier ZIP contenant tous les fichiers (hors base de données et sauvegardes de la base de données) : documents, logo, fichiers joints aux écritures, aux fiches de membres, et aux pages du site web.

    + {if $files_size > 1024*1024*100} +

    + Ce téléchargement de {$files_size|size_in_bytes} peut prendre plusieurs minutes.
    + Veillez à utiliser une bonne connexion internet. +

    + {/if} +

    + {csrf_field key=$csrf_key} + {button type="submit" name="zip" label="Télécharger une archive ZIP de tous les documents" shape="download"} ({$files_size|size_in_bytes}) +

    +
    +
    + +
    +

    Exporter les données comptables

    +

    Il est conseillé d'exporter les informations comptables (bilan, compte de résultat, grand livre et journal) pour archivage après la clôture de chaque exercice, et de les stocker sur un support pérenne (clé USB, carte mémoire, disque dur externe).

    +

    Ils doivent être conservés 10 ans.

    +

    + {linkbutton shape="menu" label="Voir la liste des exercices" href="!acc/years/"} +

    +
    +
    + + +{include file="_foot.tpl"} \ No newline at end of file diff --git a/src/templates/config/backup/restore.tpl b/src/templates/config/backup/restore.tpl new file mode 100644 index 0000000..8bb2a16 --- /dev/null +++ b/src/templates/config/backup/restore.tpl @@ -0,0 +1,126 @@ +{include file="_head.tpl" title="Restaurer" current="config"} + +{include file="config/_menu.tpl" current="backup"} + +{include file="config/backup/_menu.tpl" current="restore"} + +{form_errors} + +{if $code == Backup::INTEGRITY_FAIL && ALLOW_MODIFIED_IMPORT} +

    Pour passer outre, renvoyez le fichier en cochant la case « Ignorer les erreurs ». + Attention, si vous avez effectué des modifications dans la base de données, cela peut créer des bugs !

    +{/if} + +{if $ok} +

    + {if $ok == 'restore'}La restauration a bien été effectuée. + {if $ok_code & Backup::NOT_AN_ADMIN} +

    +

    + Vous n'êtes pas administrateur dans cette sauvegarde. Paheko a donné les droits d'administration à toutes les catégories afin d'empêcher de ne plus pouvoir se connecter. + Merci de corriger les droits des catégories maintenant. + {elseif $ok_code & Backup::CHANGED_USER} +

    +

    + Votre compte membre n'existait pas dans la sauvegarde qui a été restaurée, vous êtes désormais connecté avec le premier compte administrateur. +

    + {/if} + {elseif $ok == 'remove'}La sauvegarde a été supprimée. + {/if} +

    +{/if} + +{if $_GET.from_file} +
    + +
    + +

    + Attention, l'intégralité des données courantes seront effacées et remplacées par celles + contenues dans le fichier fourni. +

    +

    + Une sauvegarde des données courantes sera effectuée avant le remplacement, + en cas de besoin d'annuler cette restauration. +

    +
    + {input type="file" name="file" label="Fichier de sauvegarde à restaurer" required=true} +
    +

    + {csrf_field key="backup_restore"} + {button type="submit" name="restore_file" label="Restaurer depuis le fichier sélectionné" shape="upload" class="main"} +

    + {if $code && ($code == Backup::INTEGRITY_FAIL && ALLOW_MODIFIED_IMPORT)} +

    + {input type="checkbox" name="force_import" value="1" label="Ignorer les erreurs, je sais ce que je fait"} +

    + {/if} +
    + +
    + +{else} + + +

    + {linkbutton shape="reload" label="Restaurer les documents et fichiers joints" href="documents.php"} + {linkbutton shape="upload" label="Restaurer la base de données à partir de mon ordinateur" href="restore.php?from_file=1"} +

    + + + {if !$code && !$ok} +

    + Espace disque occupé par les sauvegardes : {$size|size_in_bytes} +

    + {/if} + +
    + + {if empty($list)} +

    Aucune copie de sauvegarde disponible.

    + {else} + + + + + + + + + + + + + {foreach from=$list item="backup"} + + + + + + + + + {/foreach} + +
    NomTailleDateVersion
    {if $backup.can_restore}{input type="radio" name="selected" value=$backup.filename}{/if}{$backup.size|size_in_bytes}{$backup.date|date_short:true}{if $backup.error} + Sauvegarde corrompue : {$backup.error} + {else} + {$backup.version}{if !$backup.can_restore} — Version trop ancienne pour pouvoir être restaurée{/if} + {/if} + + {linkbutton href="?download=%s"|args:$backup.filename label="Télécharger" shape="download"} +
    +

    + Attention, en cas de restauration, l'intégralité des données courantes seront effacées et remplacées par celles contenues dans la sauvegarde sélectionnée. +

    +

    + {csrf_field key="backup_manage"} + {button type="submit" name="restore" label="Restaurer la sauvegarde sélectionnée" shape="reset" class="main"} + {button type="submit" name="remove" label="Supprimer la sauvegarde sélectionnée" shape="delete"} +

    + {/if} + +
    +{/if} + +{include file="_foot.tpl"} \ No newline at end of file diff --git a/src/templates/config/backup/versions.tpl b/src/templates/config/backup/versions.tpl new file mode 100644 index 0000000..296d12f --- /dev/null +++ b/src/templates/config/backup/versions.tpl @@ -0,0 +1,99 @@ +{include file="_head.tpl" title="Versionnement des fichiers" current="config"} + +{include file="config/_menu.tpl" current="backup"} + +{include file="config/backup/_menu.tpl" current="versions"} + +{form_errors} + + +{if $_GET.msg === 'CONFIG_SAVED'} +

    + La configuration du versionnement a bien été enregistrée. +

    +{elseif $_GET.msg === 'PRUNED'} +

    Les anciennes versions des fichiers qui étaient trop anciennes ont bien été supprimées.

    +{/if} + +{if FILE_VERSIONING_POLICY} +
    + +

    L'administrateur du serveur a défini cette règle de conservation des anciennes versions :

    +

    {$policy.label} — {$policy.help}

    +

    Note : les fichiers de plus de ne seront pas versionnés.

    +
    +{else} +
    + +
    + Conservation des anciennes versions des fichiers +
    +
    Règle de conservation des anciennes versions
    + {foreach from=$versioning_policies key="key" item="policy"} + {input type="radio-btn" name="file_versioning_policy" value=$key default="" source=$config label=$policy.label help=$policy.help} + {/foreach} +
    +
    + {if FILE_VERSIONING_MAX_SIZE} +
    Note : les fichiers de plus de ne seront pas versionnés.
    + {else} + {input type="number" name="file_versioning_max_size" min=1 label="Taille maximale des fichiers à versionner" source=$config required=true help="Les fichiers qui sont plus gros que cette taille ne seront pas versionnés." suffix="Mo" max=100 size=3} + {/if} +
    +
    + +

    + {csrf_field key=$csrf_key} + {button type="submit" name="save" label="Enregistrer" shape="right" class="main"} +

    + +
    + + +{/if} + +
    + +

    Le versionnement des fichiers, c'est quoi ?

    +
    +

    Pour éviter de perdre un travail précieux en cas de maladresse, les anciennes versions des fichiers peuvent être conservées.

    +

    Lorsqu'un fichier est modifié, l'ancienne version est sauvegardée.

    +

    Seuls les fichiers suivants sont versionnés :

    +
      +
    • documents de l'association (menu "Documents") ;
    • +
    • fichiers joints aux fiches des membres ;
    • +
    • fichiers joints aux écritures comptables.
    • +
    +

    {linkbutton shape="help" href="%sversionnement"|args:$help_url label="Plus d'informations sur le versionnement"}

    +
    + + +

    + Espace disque utilisé par les anciennes versions : {$disk_use|size_in_bytes}. + {linkbutton shape="gallery" label="Voir le détail de l'utilisation de l'espace disque" href="!config/disk_usage.php"} +

    + +{if $disk_use} + {if $config.file_versioning_policy === 'none'} +

    + {linkbutton href="?delete_versions=1" shape="delete" label="Supprimer les anciennes versions" target="_dialog"} +

    + {else} +
    +

    + {csrf_field key=$csrf_key} + {button type="submit" name="prune_versions" value=1 shape="reload" label="Nettoyer les anciennes versions"} (pour gagner un peu d'espace disque) +

    +
    + {/if} +{/if} + +{include file="_foot.tpl"} \ No newline at end of file diff --git a/src/templates/config/backup/versions_delete.tpl b/src/templates/config/backup/versions_delete.tpl new file mode 100644 index 0000000..762abaa --- /dev/null +++ b/src/templates/config/backup/versions_delete.tpl @@ -0,0 +1,13 @@ +{include file="_head.tpl" title="Suppression des anciennes versions" current="config"} + +{include file="config/_menu.tpl"} + +{assign var="size_bytes" value=$disk_use|size_in_bytes} + +{include file="common/delete_form.tpl" + legend="Supprimer les anciennes versions ?" + warning="Libérer %s d'espace disque en supprimant toutes les anciennes versions ?"|args:$size_bytes + alert="Après cette action, seule la dernière version de chaque fichier sera conservée." + info="Même les versions nommées seront supprimées."} + +{include file="_foot.tpl"} \ No newline at end of file diff --git a/src/templates/config/categories/delete.tpl b/src/templates/config/categories/delete.tpl new file mode 100644 index 0000000..d4b9636 --- /dev/null +++ b/src/templates/config/categories/delete.tpl @@ -0,0 +1,11 @@ +{include file="_head.tpl" title="Supprimer une catégorie de membre" current="config"} + +{include file="config/_menu.tpl" current="categories"} + +{include file="common/delete_form.tpl" + legend="Supprimer cette catégorie de membres ?" + warning="Êtes-vous sûr de vouloir supprimer la catégorie « %s » ?"|args:$cat.name + alert="Attention, la catégorie ne doit plus contenir de membres pour pouvoir être supprimée." + info="Les écritures comptables liées à l'historique des membres inscrits à cette activité ne seront pas supprimées, et la comptabilité demeurera inchangée."} + +{include file="_foot.tpl"} \ No newline at end of file diff --git a/src/templates/config/categories/edit.tpl b/src/templates/config/categories/edit.tpl new file mode 100644 index 0000000..5e1124b --- /dev/null +++ b/src/templates/config/categories/edit.tpl @@ -0,0 +1,54 @@ +{include file="_head.tpl" title="Modifier une catégorie de membre" current="config"} + +{include file="config/_menu.tpl" current="categories"} + +{form_errors} + +
    + +
    + Informations générales +
    + {input type="text" name="name" label="Nom" required=true source=$cat} +
    Configuration
    + {input type="checkbox" name="hidden" label="Catégorie cachée" source=$cat value=1} +
    + Si coché, les membres de cette catégorie :
    + - ne seront visibles que par les membres ayant le droit d'administration ;
    + - ne recevront pas de messages collectifs ;
    + - ne recevront pas de rappels de cotisation ;
    + - leurs inscriptions aux activités seront cachées.
    + Utile par exemple pour archiver les membres qui n'ont pas renouvelé leur cotisation, avant suppression. +
    +
    +
    + +
    + Droits +
    + {foreach from=$permissions key="type" item="perm"} +
    + {if $perm.disabled} +
    + En tant qu'administrateur, vous ne pouvez pas désactiver ce droit pour votre propre catégorie.
    + Ceci afin d'empêcher que vous ne puissiez plus vous connecter. +
    + {/if} + {foreach from=$perm.options key="level" item="label"} +
    + {'perm_' . $type} == $level}checked="checked"{/if} {if $perm.disabled}disabled="disabled"{/if} /> + +
    + {/foreach} + {/foreach} +
    +
    + +

    + {csrf_field key=$csrf_key} + {button type="submit" name="save" label="Enregistrer" shape="right" class="main"} +

    + +
    + +{include file="_foot.tpl"} \ No newline at end of file diff --git a/src/templates/config/categories/index.tpl b/src/templates/config/categories/index.tpl new file mode 100644 index 0000000..4378d17 --- /dev/null +++ b/src/templates/config/categories/index.tpl @@ -0,0 +1,49 @@ +{include file="_head.tpl" title="Catégories de membres" current="config"} + +{include file="config/_menu.tpl" current="users" sub_current="categories"} + + + + + + + + + + {foreach from=$list item="cat"} + + + + + + + {/foreach} + +
    NomMembresDroits
    {$cat.name}{$cat.count} + {display_permissions permissions=$cat} + + {if $cat.id != $logged_user.id_category} + {linkbutton shape="delete" label="Supprimer" href="delete.php?id=%d"|args:$cat.id target="_dialog"} + {/if} + {linkbutton shape="edit" label="Modifier" href="edit.php?id=%d"|args:$cat.id} + {linkbutton shape="users" label="Liste des membres" href="!users/?cat=%d"|args:$cat.id} +
    + +
    + +
    + Ajouter une catégorie +
    + {input type="text" name="name" label="Nom" required=true} +
    + +

    + {csrf_field key=$csrf_key} + {button type="submit" name="save" label="Ajouter" shape="right" class="main"} +

    +
    + +
    + + +{include file="_foot.tpl"} \ No newline at end of file diff --git a/src/templates/config/custom.tpl b/src/templates/config/custom.tpl new file mode 100644 index 0000000..7fb8908 --- /dev/null +++ b/src/templates/config/custom.tpl @@ -0,0 +1,104 @@ +{include file="_head.tpl" title="Personnalisation" current="config"} + +{include file="config/_menu.tpl" current="custom"} + +{if isset($_GET['ok']) && !$form->hasErrors()} +

    + La configuration a bien été enregistrée. +

    +{/if} + +{form_errors} + +
    + Association et site web +
    +
    Logo
    + {if $url = $config->fileURL('logo', '150px')} +
    + +
    + {/if} +
    + {linkbutton href="!config/edit_file.php?k=%s"|args:'logo' label="Modifier" shape="edit" target="_dialog"} +
    +
    + Ce logo sera affiché en haut du menu de l'administration, sur le site web et sur les documents imprimés. +
    +
    Petite icône
    + {if $url = $config->fileURL('favicon')} +
    + +
    + {/if} +
    + {linkbutton href="!config/edit_file.php?k=%s"|args:'favicon' label="Modifier" shape="edit" target="_dialog"} +
    +
    + Cette image sera affichée dans l'onglet du navigateur (favicon). +
    +
    Grande icône
    + {if $url = $config->fileURL('icon', '150px')} +
    + +
    + +
    {$config.org_name|truncate:12:'…':true}
    +
    +
    + {/if} +
    + {linkbutton href="!config/edit_file.php?k=%s"|args:'icon' label="Modifier" shape="edit" target="_dialog"} +
    +
    + Cette image sera utilisée comme icône de l'application mobile (à installer depuis {link href="!" label="la page d'accueil"} et le bouton « Installer comme application sur l'écran d'accueil »). +
    +
    Signature ou tampon de l'association
    + {if $url = $config->fileURL('signature', '150px')} +
    + +
    + {/if} +
    + {linkbutton href="!config/edit_file.php?k=%s"|args:'signature' label="Modifier" shape="edit" target="_dialog"} +
    +
    + Cette image sera utilisée dans les documents générés pour l'association.
    + Attention : ne pas mettre la vraie signature d'une personne physique, car tous les membres connectés ont accès à cette image. Il est conseillé de créer une signature ou un tampon spécifique à l'association. +
    +
    +
    + +
    +
    + Interface d'administration +
    + {input type="color" pattern="#[a-f0-9]{6}" title="Couleur au format hexadécimal" default=$color1 source=$config name="color1" label="Couleur primaire" placeholder=$color1} + {input type="color" pattern="#[a-f0-9]{6}" title="Couleur au format hexadécimal" default=$color2 source=$config name="color2" label="Couleur secondaire" placeholder=$color2} + {input type="file" label="Image de fond" name="background" help="Il est conseillé d'utiliser une image en noir et blanc avec un fond blanc pour un meilleur rendu. Dimensions recommandées : 380x200" accept="image/*,*.jpeg,*.jpg,*.png,*.gif"} +
    Texte de la page d'accueil
    +
    + {linkbutton href="!config/edit_file.php?k=%s"|args:'admin_homepage' label="Modifier" shape="edit" target="_dialog" data-dialog-height="90%"} +
    +
    + Ce contenu sera affiché à la connexion d'un membre, ou en cliquant sur l'onglet 'Accueil' du menu de gauche. +
    +
    Personnalisation CSS de l'administration
    +
    + {linkbutton href="!config/edit_file.php?k=%s"|args:'admin_css' label="Modifier" shape="edit" target="_dialog" data-dialog-height="90%"} +
    +
    + Permet de rajouter des règles CSS qui modifieront l'apparence de l'interface d'administration. +
    + + +

    + {csrf_field key="config_custom"} + {button type="submit" name="save" label="Enregistrer" shape="right" class="main"} +

    +
    + + +
    + +{include file="_foot.tpl"} \ No newline at end of file diff --git a/src/templates/config/disk_usage.tpl b/src/templates/config/disk_usage.tpl new file mode 100644 index 0000000..a454929 --- /dev/null +++ b/src/templates/config/disk_usage.tpl @@ -0,0 +1,65 @@ +{include file="_head.tpl" title="Utilisation de l'espace disque" current="config"} + +{include file="config/_menu.tpl"} + +

    Base de données

    + +
    +

    + {if FILE_STORAGE_BACKEND == 'SQLite'} + La base de données stocke toutes les informations : membres, activités, rappels, comptabilité, site web, documents, etc. + {else} + La base de données stocke toutes les informations : membres, activités, rappels, comptabilité, site web, etc. sauf les documents. + {/if} +

    +
    + + + + + + + + + + + + + + + +
    Total{$db_total|size_in_bytes}
    Base de données seule{$db|size_in_bytes}{linkbutton shape="download" label="Faire une sauvegarde" href="!config/backup/"} +
    Sauvegardes{$db_backups|size_in_bytes}{linkbutton shape="menu" label="Liste des sauvegardes" href="!config/backup/restore.php"} +
    + +

    Fichiers

    + +
    +

    {$quota_used|size_in_bytes} utilisés sur {$quota_max|size_in_bytes} autorisés ({$quota_left|size_in_bytes} libres)

    +
    + + + + + + + + {foreach from=$contexts item="context" key="ctx"} + + + + + + {/foreach} +
    Total{$quota_used|size_in_bytes}{linkbutton shape="download" label="Télécharger tous les fichiers" href="!config/backup/"} +
    {$context.label}{$context.size|size_in_bytes} + {if $ctx == 'trash'} + {linkbutton shape="trash" label="Voir les fichiers supprimés" href="!docs/trash.php"} + {elseif $ctx == 'versions' && $versioning_policy !== 'none'} + {linkbutton href="!config/backup/versions.php" shape="reload" label="Nettoyer les anciennes versions"} + {elseif $ctx == 'versions' && $versioning_policy === 'none' && $context.size} + {linkbutton href="!config/backup/versions.php?prune_versions=1" shape="delete" label="Supprimer les anciennes versions" target="_dialog"} + {/if} +
    + +{include file="_foot.tpl"} \ No newline at end of file diff --git a/src/templates/config/edit_image.tpl b/src/templates/config/edit_image.tpl new file mode 100644 index 0000000..bd55767 --- /dev/null +++ b/src/templates/config/edit_image.tpl @@ -0,0 +1,19 @@ +{include file="_head.tpl" title="Envoi d'image"} + +{form_errors} + +
    +
    + Téléverser un fichier +
    + {input type="file" name="file" label="Fichier à envoyer" data-enhanced=1} +
    +

    + {csrf_field key=$csrf_key} + {button type="submit" name="upload" label="Envoyer" shape="upload" class="main"} + {button type="submit" name="reset" label="Supprimer" shape="delete"} +

    +
    +
    + +{include file="_foot.tpl"} diff --git a/src/templates/config/ext/_details.tpl b/src/templates/config/ext/_details.tpl new file mode 100644 index 0000000..4f75bbc --- /dev/null +++ b/src/templates/config/ext/_details.tpl @@ -0,0 +1,92 @@ +
    +
    + {if $item.broken_message || $item.missing} +
    +
    +

    {$item.label}

    + {if $item.broken_message} +

    + Extension cassée : installation impossible
    + Erreur : {$item.broken_message} +

    + {elseif $item.missing} +

    + {if ENABLE_TECH_DETAILS} + Le code source de l'extension "{$item.name}" est absent du dossier des plugins + {else} + Cette extension n'est pas installée sur ce serveur. + {/if} +
    + Il n'est pas possible de la supprimer non plus, le code source est nécessaire pour pouvoir la supprimer. +

    + {/if} +
    + {else} +
    + {if $item.icon_url} + + {/if} +
    + +
    +
    +

    {$item.label}

    + {if $item.module && $item.module->canDelete()} +

    + {icon shape="edit"} Modifiée +

    + {elseif $item.module} +

    Modifiable

    + {/if} +
    +

    + {$item.description|escape|nl2br} +

    +

    + {if $item.author && $item.author_url} + Par {link label=$item.author href=$item.author_url target="_blank"} + {elseif $item.author} + Par {$item.author} + {/if} + {if $item.plugin && $item.plugin.version}— Version {$item.plugin.version}{/if} +

    + +

    + {if $item.enabled && $item.url && !$item.web} + {linkbutton shape="right" label="Ouvrir" href=$item.url} + {/if} + {if $item.config_url && $item.enabled} + {linkbutton label="Configurer" href=$item.config_url shape="settings"} + {/if} + {if $item.type === 'module'} + {linkbutton label="Modifier le code" href="edit.php?module=%s"|args:$item.name shape="edit"} + {/if} + {if $item.readme} + {linkbutton label="Documentation" href="details.php?type=%s&name=%s&readme"|args:$item.type:$item.name shape="help"} + {/if} +

    + +
    + +
    + {if $item.enabled && !$item.web} + {button type="submit" label="Désactiver" shape="eye-off" name="disable[%s]"|args:$item.type value=$item.name} + {elseif !$item.enabled} + {button type="submit" label="Activer" shape="eye" name="enable[%s]"|args:$item.type value=$item.name} + {/if} + + {if empty($hide_details)} + {linkbutton shape="menu" label="Détails" href=$item.details_url} + {if $item.restrict_section} +
    +
    Accès limité
    + {display_permissions section=$item.restrict_section level=$item.restrict_level} +
    + {/if} + {/if} + +
    + + {/if} +
    +
    \ No newline at end of file diff --git a/src/templates/config/ext/_nav.tpl b/src/templates/config/ext/_nav.tpl new file mode 100644 index 0000000..f492ebc --- /dev/null +++ b/src/templates/config/ext/_nav.tpl @@ -0,0 +1,43 @@ + \ No newline at end of file diff --git a/src/templates/config/ext/delete.tpl b/src/templates/config/ext/delete.tpl new file mode 100644 index 0000000..4822f2a --- /dev/null +++ b/src/templates/config/ext/delete.tpl @@ -0,0 +1,29 @@ +{include file="_head.tpl" title="Désinstaller une extension" current="config"} + +{if $plugin} + {include file="common/delete_form.tpl" + legend="Supprimer une extension" + confirm="Cocher cette case pour confirmer la suppression de toutes les données liées à cette extension" + warning="Êtes-vous sûr de vouloir supprimer l'extension « %s » ?"|args:$plugin.label + alert="Attention, cela supprimera toutes les données liées à l'extension !"} +{elseif $mode == 'data'} + {include file="common/delete_form.tpl" + legend="Supprimer les données d'une extension" + confirm="Cocher cette case pour confirmer la suppression de toutes les données liées à cette extension" + warning="Êtes-vous sûr de vouloir supprimer les données de l'extension « %s » ?"|args:$module.label + alert="Attention, cela supprimera toutes les données liées à l'extension"} +{elseif $mode == 'reset'} + {include file="common/delete_form.tpl" + legend="Supprimer les modifications d'un module" + confirm="Cocher cette case pour confirmer la suppression des modifications" + warning="Êtes-vous sûr de vouloir supprimer les modifications apportées au module « %s » ?"|args:$module.label + alert="Le module reviendra à son état initial."} +{else} + {include file="common/delete_form.tpl" + legend="Supprimer une extension" + confirm="Cocher cette case pour confirmer la suppression de cette extension" + warning="Êtes-vous sûr de vouloir supprimer l'extension « %s » ?"|args:$module.label + alert="Attention, cela supprimera toutes les données liées à l'extension, ainsi que l'extension elle-même."} +{/if} + +{include file="_foot.tpl"} \ No newline at end of file diff --git a/src/templates/config/ext/details.tpl b/src/templates/config/ext/details.tpl new file mode 100644 index 0000000..f6d59e0 --- /dev/null +++ b/src/templates/config/ext/details.tpl @@ -0,0 +1,129 @@ +{include file="_head.tpl" title="Extension — %s"|args:$ext.label current="config"} + +{include file="config/_menu.tpl" current="ext"} +{include file="./_nav.tpl" current=$mode ext=$ext} + +{if $mode === 'readme'} +
    + {$content|raw|markdown} +
    +{elseif $mode === 'disk' && $module} +
    + getDataSize(); + $code_size = $module->getCodeSize(); + $files_size = $module->getFilesSize(); + $total = $data_size + $code_size + $files_size; + ?> + + + + + + + + + + + + + + + + + + + + + + +
    Total{$total|size_in_bytes}
    Données seules{$data_size|size_in_bytes} + {if $data_size} + {linkbutton href="!config/advanced/sql.php?table=module_data_%s"|args:$ext.name shape="table" label="Voir les données brutes"}
    + {/if} + {if $data_size && $module->canDeleteData()} + {linkbutton shape="delete" label="Supprimer les données" href="delete.php?module=%s&mode=data"|args:$ext.name target="_dialog"} + {/if} +
    Code source{$code_size|size_in_bytes} + {if $code_size && $ext.module->hasDist()} + {linkbutton label="Supprimer toutes les modifications" href="delete.php?module=%s&mode=reset"|args:$ext.name shape="delete" target="_dialog"} + {/if} +
    Fichiers stockés{$files_size|size_in_bytes}
    Utilisation de l'espace disque
    +
    +{else} + {if !$ext.enabled} +

    Cette extension est désactivée.

    + {/if} + +
    + {include file="./_details.tpl" item=$ext hide_details=true} + {csrf_field key=$csrf_key} +
    + +
    + {if $module} + {if !$module.enabled && $module->canDelete()} +

    + {linkbutton label="Supprimer ce module" href="delete.php?module=%s&mode=delete"|args:$module.name shape="delete" target="_dialog"}
    +

    + {/if} + {else} + {if !$plugin.enabled && $plugin->exists()} +

    + {linkbutton label="Supprimer les données" href="delete.php?plugin=%s"|args:$plugin.name shape="delete" target="_dialog"} +

    + {/if} + {/if} +
    + + {if $ext.restrict_section || count($access_details)} +
    +

    Comment accéder à cette extension ?

    + + {if $ext.restrict_section} +

    + {display_permissions section=$ext.restrict_section level=$ext.restrict_level} + Seuls les membres ayant accès à la section + « restrict_section]['label']?> » + en + + {if $ext.restrict_level === Users\Session::ACCESS_READ}lecture + {elseif $ext.restrict_level === Users\Session::ACCESS_WRITE}lecture et écriture + {elseif $ext.restrict_level === Users\Session::ACCESS_ADMIN}administration + {/if} + + pourront accéder à cette extension. +

    + {/if} + +
      + {foreach from=$access_details item="label"} +
    • {$label|raw}
    • + {/foreach} +
    + +
    + {/if} + +
    +

    Comment fonctionne cette extension ?

    + {if $ext.module} +

    Cette extension est un module, elle est donc modifiable.

    +

    Un module est composé de code HTML et Brindille, facile à maîtriser et adapter à ses besoins.

    + {if $ext.module->hasDist()} +

    Vous pouvez à tout moment revenir à la version d'origine en cas de problème.

    + {/if} + {linkbutton shape="help" label="Comment modifier et développer des modules" href="!static/doc/modules.html" target="_dialog"} + {else} +

    Cette extension est un plugin installé sur notre serveur.

    + {if !ENABLE_TECH_DETAILS} +

    Son code n'est pas modifiable par votre organisation pour des raisons de sécurité.

    + {else} +

    Son code PHP peut être modifié si vous avez accès au serveur et des connaissances en programmation.

    +

    {linkbutton shape="help" href=$url_help_plugins label="Documentation des plugins" target="_blank"} + {/if} + {/if} +

    +{/if} + +{include file="_foot.tpl"} \ No newline at end of file diff --git a/src/templates/config/ext/diff.tpl b/src/templates/config/ext/diff.tpl new file mode 100644 index 0000000..99c4ab4 --- /dev/null +++ b/src/templates/config/ext/diff.tpl @@ -0,0 +1,7 @@ +{include file="_head.tpl" title="Différences" current="config" hide_title=true} + +

    Différences avec le fichier d'origine

    + +{diff old=$dist new=$local} + +{include file="_foot.tpl"} \ No newline at end of file diff --git a/src/templates/config/ext/edit.tpl b/src/templates/config/ext/edit.tpl new file mode 100644 index 0000000..b7043ba --- /dev/null +++ b/src/templates/config/ext/edit.tpl @@ -0,0 +1,73 @@ +{include file="_head.tpl" title="Code source — %s"|args:$module.label current="config"} + +{include file="config/_menu.tpl" current="ext"} +{include file="./_nav.tpl" current="edit" ext=$module} + +{form_errors} + + + + {if $path} + + + + + + + + + {/if} + {foreach from=$list item="file"} + + + + + + + + + {/foreach} + +
    {icon shape="left"}{link href="?module=%s"|args:$module.name label="Retour"}
    + {if $file.dir} + {icon shape="folder"} + {/if} + + {if $file.dir} + {link href="?module=%s&p=%s"|args:$module.name:$file.path label=$file.name} + {elseif $file.editable} + {link href=$file.edit_url label=$file.name target="_dialog"} + {else} + {link href=$file.open_url label=$file.name target="_dialog" data-mime=$file.type} + {/if} + + {if $file.local} + {$file.modified|relative_date} + {else} + (non modifié) + {/if} + + {if $file.editable} + {linkbutton label="Éditer" shape="edit" target="_dialog" href=$file.edit_url} + {/if} + + {if $file.local && $file.dist && $file.editable} + {linkbutton label="Différences" href="diff.php?module=%s&p=%s"|args:$module.name,$file.path shape="menu" target="_dialog"} + {/if} + + {if $file.local && $file.dist} + {linkbutton label="Supprimer les modifications" href="%s&trash=no"|args:$file.delete_url shape="delete" target="_dialog"} + {elseif $file.local && $file.dir} + {linkbutton label="Supprimer ce dossier" href=$file.delete_url shape="trash" target="_dialog"} + {elseif $file.local} + {linkbutton label="Supprimer ce fichier" href=$file.delete_url shape="trash" target="_dialog"} + {/if} +
    + +{if $module->hasDist() && $module->hasLocal()} +

    + {linkbutton label="Supprimer toutes les modifications" href="delete.php?module=%s&mode=reset"|args:$module.name shape="delete" target="_dialog"} +

    +{/if} + +{include file="_foot.tpl"} \ No newline at end of file diff --git a/src/templates/config/ext/import.tpl b/src/templates/config/ext/import.tpl new file mode 100644 index 0000000..dca8b13 --- /dev/null +++ b/src/templates/config/ext/import.tpl @@ -0,0 +1,34 @@ +{include file="_head.tpl" title="Importer un module" current="config"} + +{form_errors} + +{if !$_GET.ok} +
    +

    Attention, faites-vous confiance à la personne qui vous a transmis ce module ?

    +

    + Importer un module de source inconnue peut présenter des risques pour les données de votre association.
    + Un module écrit par une personne mal intentionnée pourrait voler les données de votre association, ou modifier ou supprimer des données. +

    +
    +

    + {linkbutton shape="right" href="?ok=1" label="Je comprends les risques, continuer"} +

    +{else} +
    +
    + Importer un module d'extension +
    + {input type="file" required=true label="Fichier ZIP du module" name="zip" accept=".zip,application/zip"} + {if $exists} + {input type="checkbox" name="overwrite" value=1 label="Écraser mes modifications existantes" required=true} + {/if} +
    +

    + {csrf_field key=$csrf_key} + {button type="submit" shape="right" label="Importer ce module" name="import" class="main"} +

    +
    +
    +{/if} + +{include file="_foot.tpl"} \ No newline at end of file diff --git a/src/templates/config/ext/index.tpl b/src/templates/config/ext/index.tpl new file mode 100644 index 0000000..c0d667a --- /dev/null +++ b/src/templates/config/ext/index.tpl @@ -0,0 +1,31 @@ +{include file="_head.tpl" title="Extensions" current="config"} + +{include file="config/_menu.tpl" current="ext"} + +{include file="./_nav.tpl" current=$current ext=null} + +

    Les extensions apportent des fonctionnalités supplémentaires, et peuvent être activées selon vos besoins.

    + +{form_errors} + +
    +
    + {foreach from=$list item="item"} + {include file="./_details.tpl" item=$item} + {/foreach} +
    + {csrf_field key=$csrf_key} +
    + +

    + La mention Modifiable indique que cette extension est un module que vous pouvez modifier. +

    + +

    + {linkbutton shape="help" label="Comment modifier et développer des modules" href="!static/doc/modules.html" target="_dialog"} + {linkbutton shape="plus" label="Créer un module" href="new.php" target="_dialog"} + {linkbutton shape="import" label="Importer un module" href="import.php" target="_dialog"} +

    + + +{include file="_foot.tpl"} \ No newline at end of file diff --git a/src/templates/config/ext/new.tpl b/src/templates/config/ext/new.tpl new file mode 100644 index 0000000..d612668 --- /dev/null +++ b/src/templates/config/ext/new.tpl @@ -0,0 +1,47 @@ +{include file="_head.tpl" title="Créer un nouveau module" current="config"} + +

    + Les modules permettent aux personnes ayant quelques compétences en programmation de rajouter des fonctionnalités.
    + {linkbutton shape="help" label="Comment modifier et développer des modules" href="!static/doc/modules.html" target="_dialog"} +

    + +{form_errors} + +
    +
    + Informations du module +
    + {input type="text" name="label" required="true" label="Nom du module" help="Par exemple « Reçu personnalisé »"} + {input type="text" name="name" required="true" label="Nom unique du module" pattern="[a-z][a-z0-9]*(_[a-z0-9]+)*" help="Ne peut contenir que des lettres minuscules sans accent, des chiffres, et des tirets bas."} + {input type="textarea" cols="50" rows="3" name="description" label="Description"} + {input type="text" name="author" label="Nom de l'auteur⋅e"} + {input type="url" name="author_url" label="Adresse du site de l'auteur⋅e"} +
    +
    + +
    + Configuration +
    + {input type="checkbox" name="menu" label="Afficher dans le menu" value=1} + {input type="checkbox" name="home_button" label="Afficher un bouton sur l'accueil" value=1} + {*input type="select" name="web" label="Type de module" options=$types required=true*} + {input type="select_groups" name="restrict" options=$sections label="Restreindre l'accès aux membres ayant accès à…"} +
    +
    + +

    + {csrf_field key=$csrf_key} + {button type="submit" shape="right" label="Créer ce module" name="create" class="main"} +

    + +
    + + + +{include file="_foot.tpl"} \ No newline at end of file diff --git a/src/templates/config/fields/delete.tpl b/src/templates/config/fields/delete.tpl new file mode 100644 index 0000000..3383f81 --- /dev/null +++ b/src/templates/config/fields/delete.tpl @@ -0,0 +1,10 @@ +{include file="_head.tpl" title="Supprimer un champ" current="config"} + +{include file="common/delete_form.tpl" + legend="Supprimer ce champ ?" + confirm="Cocher cette case pour supprimer le champ, cela effacera de manière permanente cette donnée de toutes les fiches membres." + warning="Êtes-vous sûr de vouloir supprimer le champ « %s » ?"|args:$field.label + alert="Attention, ce champ ainsi que les données qu'il contient seront supprimés de toutes les fiches membres existantes." +} + +{include file="_foot.tpl"} \ No newline at end of file diff --git a/src/templates/config/fields/edit.tpl b/src/templates/config/fields/edit.tpl new file mode 100644 index 0000000..281bca6 --- /dev/null +++ b/src/templates/config/fields/edit.tpl @@ -0,0 +1,102 @@ +exists() ? 'Modifier un champ' : 'Ajouter un champ'; +?> +{include file="_head.tpl" current="config" title=$title} + +{include file="config/_menu.tpl" current="fields"} + +{form_errors} + +
    +
    + {$title} +
    + {if !$field->isPreset() && !$field->exists()} + {input type="select" name="type" options=$field::TYPES source=$field label="Type" default="text" help="Il ne sera plus possible de modifier le type une fois le champ créé." required=true} + {else} +
    Le type et l'identifiant ne sont pas modifiables.
    + {input type="select" name="type" options=$field::TYPES source=$field label="Type" disabled=true} + {/if} + {input type="text" name="label" label="Libellé" required=true source=$field} +
    +
    + +
    + Identifiant unique +

    + Cet identifiant est utilisé dans la base de données de Paheko pour identifier ce champ. +

    +
    + {if !$field->isPreset() && !$field->exists()} + {input type="text" name="name" pattern="[a-z](_?[a-z0-9]+)*" label="Identifiant" required=true source=$field help="Ne peut comporter que des lettres minuscules et des tirets bas. Par exemple pour un champ demandant l'adresse, on peut utiliser 'adresse_postale'. Ce nom ne peut plus être modifié ensuite."} + {else} + {input type="text" name="name" disabled=true label="Identifiant" source=$field} + {/if} +
    + +
    + Préférences +
    + {input type="checkbox" name="list_table" value=1 label="Afficher dans la liste des membres" source=$field} +
    +
    + {input type="checkbox" name="required" value=1 label="Champ obligatoire" help="Si coché, une fiche membre ne pourra pas être enregistrée si ce champ n'est pas renseigné." source=$field} + {input type="text" name="default_value" source=$field label="Valeur par défaut" help="Si renseigné, le champ aura cette valeur par défaut lors de l'ajout d'un nouveau membre"} +
    +
    + {input type="text" name="help" label="Texte d'aide" help="Apparaîtra dans les formulaires de manière identique à ce texte." source=$field} +
    +
    + {input type="textarea" required=true name="sql" class="full-width" rows=3 source=$field label="Code SQL utilisée pour calculer ce champ" disabled=$field->isPreset()} +
    + Les champs calculés utilisent du code SQL. Ils sont des colonnes virtuelles de la vue (VIEW) des membres. +
    +
    +
    + + +
    + Options possibles + +

    Attention renommer ou supprimer une option n'affecte pas ce qui a déjà été enregistré dans les fiches des membres.

    +

    Attention changer l'ordre des options peut avoir des effets indésirables.

    + +
    + {if $field.options} + {foreach from=$field.options item="option"} +
    {input type="text" name="options[]" default=$option}
    + {/foreach} + {/if} +
    {input type="text" name="options[]"}
    +
    +
    + +
    + Accès +
    +
    +{if !$field->isNumber()} +
    Indiquer ici si le membre pourra voir ou modifier cette information dans sa section "Mes infos personnelles".
    + {input type="radio" name="user_access_level" value=$session::ACCESS_WRITE label="Voir et modifier ce champ" source=$field} +{/if} + {input type="radio" name="user_access_level" value=$session::ACCESS_READ label="Seulement voir ce champ" source=$field default=$session::ACCESS_READ} + {input type="radio" name="user_access_level" value=$session::ACCESS_NONE label="Rien, cette information ne doit pas être visible par le membre" source=$field} +
    Attention : conformément à la réglementation (RGPD), quel que soit votre choix, le membre pourra voir le contenu de ce champ en effectuant un export de ses données personnelles (s'il a le droit de se connecter).
    +
    + {input type="radio" name="management_access_level" value=$session::ACCESS_READ label="S'il a accès à la gestion des membres (en lecture, écriture, ou administration)" source=$field default=$session::ACCESS_READ} + {input type="radio" name="management_access_level" value=$session::ACCESS_WRITE label="Seulement s'il a accès en écriture à la gestion des membres" source=$field} + {input type="radio" name="management_access_level" value=$session::ACCESS_ADMIN label="Seulement s'il a accès en administration à la gestion des membres" source=$field} +
    +
    + +

    + {csrf_field key=$csrf_key} + {linkbutton label="Annuler" shape="left" href="./" target="_parent"} + {button type="submit" name="save" label="Enregistrer" shape="right" class="main"} +

    + +
    + + + +{include file="_foot.tpl"} \ No newline at end of file diff --git a/src/templates/config/fields/index.tpl b/src/templates/config/fields/index.tpl new file mode 100644 index 0000000..0edcc05 --- /dev/null +++ b/src/templates/config/fields/index.tpl @@ -0,0 +1,89 @@ +{include file="_head.tpl" current="config" title="Fiche des membres"} + +{include file="config/_menu.tpl" current="users" sub_current="fields"} + +{if $_GET.msg == 'SAVED_ORDER'} +

    + L'ordre a bien été enregistré. +

    +{elseif $_GET.msg == 'SAVED'} +

    + Les modifications ont bien été enregistrées. +

    +{elseif $_GET.msg == 'DELETED'} +

    + Le champ a bien été supprimé. +

    +{/if} + +{form_errors} + +
    + + + + + + + + + + + + + + {foreach from=$fields item="field"} + + + + + + + + + + {/foreach} + +
    OrdreLibelléListe des membresObligatoire ?Accès membreAccès gestion
    + {button shape="menu"} + + {$field.label}{if $field.list_table}Oui{/if}{if $field.required}Obligatoire{/if} + {if $field.user_access_level === $session::ACCESS_NONE} + {icon shape="eye-off" title="Caché"} + {elseif $field.user_access_level === $session::ACCESS_READ} + {icon shape="eye" title="Visible"} + {else} + {icon shape="eye" title="Visible"} + {icon shape="edit" title="Modifiable"} + {/if} + + {display_permissions section="users" level=$field.management_access_level} + {if $field.management_access_level === $session::ACCESS_READ} + Lecture + {elseif $field.management_access_level === $session::ACCESS_WRITE} + Écriture + {elseif $field.management_access_level === $session::ACCESS_ADMIN} + Administration + {/if} + + {if $field->canDelete()} + {linkbutton shape="delete" label="Supprimer" href="delete.php?id=%d"|args:$field.id target="_dialog"} + {/if} + {linkbutton shape="edit" label="Modifier" href="edit.php?id=%d"|args:$field.id target="_dialog"} + {button shape="up" title="Déplacer vers le haut" class="up"} + {button shape="down" title="Déplacer vers le bas" class="down"} +
    + +

    + Cliquer et glisser-déposer sur une ligne pour en changer l'ordre. +

    + +

    + {csrf_field key=$csrf_key} + {button type="submit" name="save" label="Enregistrer l'ordre" shape="right"} +

    +
    + + + +{include file="_foot.tpl"} \ No newline at end of file diff --git a/src/templates/config/fields/new.tpl b/src/templates/config/fields/new.tpl new file mode 100644 index 0000000..99b2d09 --- /dev/null +++ b/src/templates/config/fields/new.tpl @@ -0,0 +1,37 @@ +{include file="_head.tpl" current="config" title="Ajouter un champ aux fiches des membres"} + +{include file="config/_menu.tpl" current="fields"} + +{form_errors} + +

    Avant de demander une information personnelle à vos membres… en avez-vous vraiment besoin ?
    + La loi demande à minimiser au strict minimum les données collectées. Pensez également aux risques de sécurité : si vous demandez la date de naissance complète, cela pourrait être utilisé pour de l'usurpation d'identité, il serait donc plus sage de ne demander que le mois et l'année de naissance, si ces données sont nécessaires afin d'avoir l'âge de la personne. +

    + +
    +
    + Ajouter un nouveau champ + +
    + {input type="radio-btn" name="preset" value="" label="Champ personnalisé" required=true help="Permet de créer n'importe quel type de champ : texte, nombre, choix multiple, case à cocher, fichier, etc." default=""} +
    + +

    Champs prédéfinis :

    +
    + {foreach from=$presets key="key" item="preset"} + {if !$preset.disabled} + {input type="radio-btn" name="preset" value=$key label=$preset.label required=true disabled=$preset.disabled help=$preset.install_help} + {/if} + {/foreach} +
    +
    + +

    + {csrf_field key=$csrf_key} + {linkbutton label="Annuler" shape="left" href="./" target="_parent"} + {button type="submit" name="add" label="Ajouter" shape="right" class="main"} +

    + +
    + +{include file="_foot.tpl"} \ No newline at end of file diff --git a/src/templates/config/index.tpl b/src/templates/config/index.tpl new file mode 100644 index 0000000..74ef28f --- /dev/null +++ b/src/templates/config/index.tpl @@ -0,0 +1,115 @@ +{include file="_head.tpl" title="Configuration" current="config"} + +{include file="config/_menu.tpl" current="index"} + +{if isset($_GET['ok']) && !$form->hasErrors()} +

    + La configuration a bien été enregistrée. +

    +{/if} + +{form_errors} + +
    + +
    + Informations +
    +
    Version installée
    +
    Paheko {$paheko_version}
    +
    + Le développement et le support de Paheko ne sont possibles que grâce à votre soutien !
    + {linkbutton href="https://kd2.org/soutien.html" label="Faire un don pour soutenir le développement" target="_blank" shape="export"} :-) +
    + {if $new_version} +

    + Une nouvelle version {$new_version} est disponible !
    + {if ENABLE_UPGRADES} + {linkbutton shape="export" href="upgrade.php" label="Mettre à jour"} + {else} + {linkbutton shape="export" href=$paheko_website label="Télécharger la mise à jour" target="_blank"} + {/if} +

    + {/if} + {if PDF_COMMAND == 'prince'} +
    + Les PDF sont générés à l'aide du génial logiciel Prince. Merci à eux. +
    + {/if} + {if ENABLE_TECH_DETAILS} +
    Informations système
    +
    + Version PHP : {$php_version}
    + Version SQLite : {$sqlite_version}
    + Heure du serveur : {$server_time|date}
    + Chiffrement GnuPG : {if $has_gpg_support}disponible, module activé{else}non, module PHP gnupg non installé ?{/if}
    + {linkbutton shape="settings" label="Configuration du serveur" href="server/"} +
    + {/if} +
    Espace disque
    +
    + {linkbutton shape="gallery" label="Voir l'espace disque utilisé" href="disk_usage.php"} +
    +
    +
    + +
    + Informations sur l'association +
    + {input type="text" name="org_name" required=true source=$config label="Nom"} + {input type="email" name="org_email" required=true source=$config label="Adresse e-mail de contact" help="Cette adresse est aussi utilisée comme adresse d'expédition des messages collectifs."} + {input type="textarea" name="org_address" source=$config label="Adresse postale"} + {input type="tel" name="org_phone" source=$config label="Numéro de téléphone"} + {input type="textarea" cols="50" rows="2" name="org_infos" required=false source=$config label="Informations diverses" help="Ce champ sera utilisé sur les reçus. Il peut être utile de faire figurer ici le numéro de SIRET par exemple."} +
    +
    + +
    + Site web +

    + Cette option permet d'activer ou désactiver la visibilité publique du site web intégré à Paheko.
    + En désactivant le site public, les visiteurs seront automatiquement redirigés vers la page de connexion.
    + Vous pourrez toujours y publier des informations, mais celles-ci ne seront visibles que pour les membres connectés. +

    +
    + {input type="radio" name="site_disabled" value=0 source=$config label="Activer le site web public"} + {input type="radio" name="site_disabled" value=1 source=$config label="Désactiver le site web"} +
    +
    +

    Si vous avez déjà un site web à une autre adresse, vous pouvez l'indiquer ici :

    +
    + {input type="url" name="org_web" source=$config label="Site web externe"} +
    +
    +
    + +
    + Localisation +
    + {input type="text" name="currency" required=true source=$config label="Monnaie" help="Inscrire ici la devise utilisée : €, CHF, XPF, etc." size="3"} + {input type="select" name="country" required=true source=$config label="Pays" options=$countries} +
    +
    + +

    + {csrf_field key="config"} + {button type="submit" name="save" label="Enregistrer" shape="right" class="main"} +

    + +
    + + + +{include file="_foot.tpl"} \ No newline at end of file diff --git a/src/templates/config/server/index.tpl b/src/templates/config/server/index.tpl new file mode 100644 index 0000000..8180724 --- /dev/null +++ b/src/templates/config/server/index.tpl @@ -0,0 +1,81 @@ +{include file="_head.tpl" title="Configuration du serveur" current="config"} + +{include file="config/_menu.tpl" current=null} + +{if $_GET.msg === 'OK'} +

    L'opération a été réalisée.

    +{/if} + +{form_errors} + +

    Stockage des fichiers

    + +
    +
    +
    Taille des fichiers dans la base de données : {$db_size|size_in_bytes:false}
    +{if FILE_STORAGE_BACKEND === 'SQLite'} +
    Paheko stocke le contenu des fichiers et leurs méta-données dans la base de données.
    +{elseif FILE_STORAGE_BACKEND === 'FileSystem'} +
    Paheko stocke les méta-données des fichiers dans la base de données.
    +
    Paheko stocke le contenu des fichiers dans le répertoire
    +
    Reconstruire les méta-données à partir du système de fichiers
    +
    + {button shape="reload" label="Scanner le répertoire" name="scan" type="submit"} +
    +
    + Permet de mettre à jour les méta-données si vous avez réalisé des modifications externes à Paheko dans les fichiers + (suppression, renommage, déplacement, etc.). +
    +
    + En cliquant sur ce bouton, Paheko va scanner le répertoire de stockage des fichiers, + supprimer les méta-données fichiers qui n'existent plus sur le disque, et ajouter celles des fichiers qui sont apparus. +
    + {if !$db_size} +
    Importer dans la base de données
    +
    + {button shape="import" label="Copier VERS la base de données" name="import" type="submit"} +
    +
    + En cliquant sur ce bouton, le contenu des fichiers sera recopié à l'intérieur de la base de données.
    + Utile pour migrer vers un stockage en base de données. +
    + {else} +
    Exporter vers le répertoire de stockage
    +
    + {button shape="export" label="Copier DEPUIS la base de données" name="export" type="submit"} +
    +
    +

    + Les fichiers seront effacés de la base de données. Tout fichier local existant sera écrasé. +

    +
    +
    + En cliquant sur ce bouton, les fichiers seront créés dans le répertoire de stockage, à partir des informations de la base de données.
    + Utile pour migrer vers un stockage en répertoire local. +
    + {/if} +{/if} + +
    +{csrf_field key=$csrf_key} +
    + +

    Configuration de Paheko

    + + + {foreach from=$constants key="key" item="value"} + + + + + {/foreach} +
    {$key} + {if $value === true}TRUE + {elseif $value === false}TRUE + {elseif $value === null}NULL + {elseif is_array($value)} + + {else}{$value}{/if} +
    + +{include file="_foot.tpl"} \ No newline at end of file diff --git a/src/templates/config/upgrade.tpl b/src/templates/config/upgrade.tpl new file mode 100644 index 0000000..46e76a3 --- /dev/null +++ b/src/templates/config/upgrade.tpl @@ -0,0 +1,89 @@ +{include file="_head.tpl" title="Mise à jour" current="config"} + +{include file="config/_menu.tpl" current="index"} + +{form_errors} + +
    + +{if !count($releases)} +

    Aucune mise à jour n'est disponible.

    +{elseif $downloaded && $verified === false} +

    Le fichier d'installation est corrompu.

    +{elseif $downloaded} +
    + Mise à jour vers {$version} + {if $verified === true} +

    + Le fichier d'installation a été correctement vérifié. +

    + {else} +

    + L'intégrité du fichier d'installation n'a pas pu être vérifié automatiquement. + {if !$can_verify} +
    (Cela est probablement dû au fait que votre installation ne dispose pas du module GnuPG.) + {/if} +

    + {/if} +
    +

    {$diff.delete|count} fichiers seront supprimés

    +
    + {foreach from=$diff.delete key="file" item="path"} +
    {$file}
    + {/foreach} +
    +
    +
    +

    {$diff.create|count} fichiers seront rajoutés

    +
    + {foreach from=$diff.create key="file" item="path"} +
    {$file}
    + {/foreach} +
    +
    +
    +

    {$diff.update|count} fichiers seront modifiés

    +

    + Si vous aviez bidouillé ces fichiers, les modifications seront écrasées. +

    +
    + {foreach from=$diff.update key="file" item="path"} +
    {$file}
    + {/foreach} +
    +
    +
    + {input type="checkbox" name="upgrade" value=$version label="Je confirme vouloir procéder à la mise à jour" help="Cette action peut casser votre installation !"} +
    +
    + +

    N'oubliez pas d'aller {link href="%swiki/?name=Changelog"|args:$website target="_blank" label="lire le journal des changements"} avant d'effectuer la mise à jour !

    +

    + {csrf_field key=$csrf_key} + {button type="submit" name="next" label="Effectuer la mise à jour" shape="right" class="main"} +

    +{else} +
    + Mise à jour +
    + {foreach from=$releases key="version" item="release"} + {input type="radio" name="download" value=$version label=$version} + {if $version == $latest} +
    + Dernière version stable, conseillée. +
    + {/if} + {/foreach} +
    +
    + +

    N'oubliez pas d'aller {link href="%swiki/?name=Changelog"|args:$website target="_blank" label="lire le journal des changements"} avant d'effectuer la mise à jour !

    +

    + {csrf_field key=$csrf_key} + {button type="submit" name="next" label="Télécharger" shape="right" class="main"} +

    +{/if} + +
    + +{include file="_foot.tpl"} \ No newline at end of file diff --git a/src/templates/config/users/field_selector.tpl b/src/templates/config/users/field_selector.tpl new file mode 100644 index 0000000..f50d0a5 --- /dev/null +++ b/src/templates/config/users/field_selector.tpl @@ -0,0 +1,54 @@ +{include file="_head.tpl" title="Sélectionner un champ"} + + + + {foreach from=$list item="label" key="key"} + + + + + {/foreach} + +
    + {$label} + + +
    + +{literal} + +{/literal} + +{include file="_foot.tpl"} \ No newline at end of file diff --git a/src/templates/config/users/index.tpl b/src/templates/config/users/index.tpl new file mode 100644 index 0000000..aa027c9 --- /dev/null +++ b/src/templates/config/users/index.tpl @@ -0,0 +1,64 @@ +{include file="_head.tpl" title="Préférences membres" current="config"} + +{include file="config/_menu.tpl" current="users" sub_current=null} + +{if isset($_GET['ok']) && !$form->hasErrors()} +

    + La configuration a bien été enregistrée. +

    +{/if} + + +{form_errors} + +
    + +
    + Préférences des membres +
    + {input type="select" name="default_category" source=$config options=$users_categories required=true label="Catégorie par défaut des nouveaux membres"} +
    +
    + +
    + Champs spéciaux des fiches de membres +
    + {input type="select" name="login_field" default=$login_field options=$login_fields_list required=true label="Champ utilisé comme identifiant de connexion" help="Ce champ des fiches membres sera utilisé comme identifiant pour se connecter à l'administration de l'association."} +
    Ce champ doit être unique : il ne peut pas y avoir deux membres ayant la même valeur dans ce champ.
    + {input type="list" name="name_fields" required=true label="Champs utilisés pour définir l'identité des membres" help="Ces champs des fiches membres seront utilisés comme identité (nom) du membre dans les emails, les fiches, les pages, etc." target="!config/users/field_selector.php" multiple=true default=$name_fields} +
    Il est possible d'utiliser plusieurs champs, par exemple en choisissant les champs Nom et Prénom, l'identité des membres apparaîtra comme Nom Prénom.
    Dans ce cas l'ordre des champs dans l'identité est déterminé selon l'ordre des champs dans la fiche membre.
    +
    +
    + +
    + Journaux d'activité +

    + Les actions de création, modification ou suppression dans la base de données peuvent être enregistrées pour chaque membre. + Cela permet de garder une trace, pour savoir qui à fait quoi. +

    + +
    + {input type="select" options=$log_retention_options source=$config name="log_retention" required=true label="Durée de conservation des journaux d'activité" help="Après ce délai, les journaux seront supprimés."} +
    +
    + +
    + Sécurité +
    + {input type="select" name="auto_logout" source=$config required=true label="Déconnecter automatiquement les membres inactifs après…" options=$logout_delay_options} +
    + Permet de déconnecter automatiquement un membre s'il garde la gestion de l'association ouverte, sans interagir.
    + Utile par exemple pour éviter de laisser une session ouverte sur un ordinateur partagé.
    Ce réglage ne s'applique pas aux membres ayant coché la case "Rester connecté⋅e". +
    +
    +
    + +

    + {csrf_field key=$csrf_key} + {button type="submit" name="save" label="Enregistrer" shape="right" class="main"} +

    + +
    + + +{include file="_foot.tpl"} \ No newline at end of file diff --git a/src/templates/docs/_nav.tpl b/src/templates/docs/_nav.tpl new file mode 100644 index 0000000..31d24ee --- /dev/null +++ b/src/templates/docs/_nav.tpl @@ -0,0 +1,15 @@ + + \ No newline at end of file diff --git a/src/templates/docs/action_delete.tpl b/src/templates/docs/action_delete.tpl new file mode 100644 index 0000000..31a9ff3 --- /dev/null +++ b/src/templates/docs/action_delete.tpl @@ -0,0 +1,10 @@ +{include file="_head.tpl" title="Supprimer %d fichiers"|args:$count current="docs"} + +{include file="common/delete_form.tpl" + legend="Supprimer ces fichiers ?" + warning="Êtes-vous sûr de vouloir mettre %d fichiers à la corbeille ?"|args:$count + csrf_key=$csrf_key + extra=$extra +} + +{include file="_foot.tpl"} \ No newline at end of file diff --git a/src/templates/docs/action_move.tpl b/src/templates/docs/action_move.tpl new file mode 100644 index 0000000..d24ca91 --- /dev/null +++ b/src/templates/docs/action_move.tpl @@ -0,0 +1,53 @@ +{include file="_head.tpl" title="Ajouter/supprimer des écritures à un projet" current="acc/accounts"} + +{form_errors} + +
    + + + + + {csrf_field key=$csrf_key} + + {if isset($extra)} + {foreach from=$extra key="key" item="value"} + {if is_array($value)} + {foreach from=$value key="subkey" item="subvalue"} + + {/foreach} + {else} + + {/if} + {/foreach} + {/if} + +
    + +{include file="_foot.tpl"} diff --git a/src/templates/docs/action_zip.tpl b/src/templates/docs/action_zip.tpl new file mode 100644 index 0000000..dc7ed6a --- /dev/null +++ b/src/templates/docs/action_zip.tpl @@ -0,0 +1,37 @@ +{include file="_head.tpl" title="Télécharger des fichiers" current="acc/docs"} + +{form_errors} + +
    +
    + Télécharger {$count} fichiers en ZIP… + +

    + Vous allez télécharger un fichier ZIP de {$size|size_in_bytes}. +

    +
    + +

    + {csrf_field key=$csrf_key} + {button type="submit" name="zip" label="Télécharger" shape="right" class="main"} + + {if isset($extra)} + {foreach from=$extra key="key" item="value"} + {if is_array($value)} + {foreach from=$value key="subkey" item="subvalue"} + + {/foreach} + {else} + + {/if} + {/foreach} + {/if} +

    + +
    + + + +{include file="_foot.tpl"} diff --git a/src/templates/docs/index.tpl b/src/templates/docs/index.tpl new file mode 100644 index 0000000..28df9b1 --- /dev/null +++ b/src/templates/docs/index.tpl @@ -0,0 +1,269 @@ +path; +?> +{include file="_head.tpl" title="Documents" current="docs" hide_title=true upload_here=$upload_here} + + + + + + +{if $parent_uri} + +{/if} + +{if $list->count()} +
    + + canAccess($session::SECTION_USERS, $session::ACCESS_ADMIN); + } + else { + $can_check = $session->canAccess($session::SECTION_DOCUMENTS, $session::ACCESS_WRITE); + } + ?> + + {include file="common/dynamic_list_head.tpl" check=$can_check class=$class} + + {foreach from=$list->iterate() item="item"} + {if !$context_ref && $context === File::CONTEXT_TRANSACTION} + + {if $can_check} + + {input type="checkbox" name="check[]" value=$item.path} + + {/if} + #{$item.id} + {$item.label} + {$item.date|date_short} + {$item.reference} + {$item.year} + + {linkbutton href="!docs/?path=%s"|args:$item.path label="Fichiers" shape="menu"} + {linkbutton href="!acc/transactions/details.php?id=%d"|args:$item.id label="Écriture" shape="search"} + + + {elseif !$context_ref && $context === File::CONTEXT_USER} + + {if $can_check} + + {input type="checkbox" name="check[]" value=$item.path} + + {/if} + {$item.number} + {$item.identity} + + {linkbutton href="!docs/?path=%s"|args:$item.path label="Fichiers" shape="menu"} + {linkbutton href="!users/details.php?id=%d"|args:$item.id label="Fiche membre" shape="user"} + + + {else} + {if $item->isDir()} + + {if $can_check && $item->canDelete()} + + {input type="checkbox" name="check[]" value=$item.path} + + {/if} + {icon shape="folder"} + {$item.name} + + {if $dir->canCreateHere() || $item->canDelete()} + {linkmenu label="Modifier…" shape="edit"} + {if $item->canRename()} + {linkbutton href="!common/files/rename.php?p=%s"|args:$item->path_uri() label="Renommer" shape="minus" target="_dialog"} + {/if} + {if $item->canDelete()} + {linkbutton href="!common/files/delete.php?p=%s"|args:$item->path_uri() label="Supprimer" shape="trash" target="_dialog"} + {/if} + {/linkmenu} + {/if} + + + {else} + + {if $item->canDelete()} + + {input type="checkbox" name="check[]" value=$item.path} + + {/if} + {if $gallery && $item->hasThumbnail()} + {$item->link($session, '150px', false)|raw} + {else} + + {$item->link($session, 'icon', false)|raw} + + {/if} + + {$item->link($session, null, false)|raw} + + {$item.size|size_in_bytes} + {$item.modified|relative_date_short:true} + + {linkbutton href=$item->url(true) label="Télécharger" shape="download" title="Télécharger"} + {if $item->canShare()} + {linkbutton href="!common/files/share.php?p=%s"|args:$item->path_uri() label="Partager" shape="export" target="_dialog" title="Partager"} + {/if} + {if $item->canRename() || $item->canDelete() || ($item->canWrite() && $item->editorType())} + {linkmenu label="Modifier…" shape="edit" right=true} + {assign var="can_write" value=$item->canWrite()} + {if $can_write && $item->editorType()} + {linkbutton href="!common/files/edit.php?p=%s"|args:$item->path_uri() label="Éditer" shape="edit" target="_dialog" data-dialog-class="fullscreen"} + {/if} + {if $item->canRename()} + {linkbutton href="!common/files/rename.php?p=%s"|args:$item->path_uri() label="Renommer" shape="reload" target="_dialog"} + {/if} + {if $item->canDelete()} + {linkbutton href="!common/files/delete.php?p=%s"|args:$item->path_uri() label="Supprimer" shape="trash" target="_dialog"} + {/if} + {if !(FILE_VERSIONING_POLICY === 'none' || $config.file_versioning_policy === 'none') && $can_write} + {linkbutton shape="history" href="!common/files/history.php?p=%s"|args:$item->path_uri() label="Historique" target="_dialog"} + {/if} + {/linkmenu} + {/if} + + + {/if} + {/if} + {/foreach} + + + + {if $can_check} + + + + + Pour les fichiers sélectionnés : + + + + + + + {/if} + + + {$list->getHTMLPagination()|raw} + + +{else} +

    Il n'y a aucun fichier dans ce dossier.

    +{/if} + +{if $dir->path == $dir->context()} +
    +

    + Adresse WebDAV : + {copy_button label=$dir->webdav_root_url()} +

    +

    + {linkbutton shape="help" href=HELP_PATTERN_URL|args:"webdav" label="Accéder aux documents avec WebDAV" target="_dialog"} +

    +
    +{/if} + +{include file="_foot.tpl"} \ No newline at end of file diff --git a/src/templates/docs/new_dir.tpl b/src/templates/docs/new_dir.tpl new file mode 100644 index 0000000..fb9bab3 --- /dev/null +++ b/src/templates/docs/new_dir.tpl @@ -0,0 +1,18 @@ +{include file="_head.tpl" title="Créer un dossier"} + +{form_errors} + +
    +
    + Créer un dossier +
    + {input type="text" minlength="1" name="name" required="required" label="Nom du dossier à créer"} +
    +

    + {csrf_field key=$csrf_key} + {button type="submit" name="create" label="Créer le dossier" shape="plus" class="main"} +

    +
    +
    + +{include file="_foot.tpl"} diff --git a/src/templates/docs/new_doc.tpl b/src/templates/docs/new_doc.tpl new file mode 100644 index 0000000..851951c --- /dev/null +++ b/src/templates/docs/new_doc.tpl @@ -0,0 +1,33 @@ +{include file="_head.tpl" title="Créer un document"} + +{form_errors} + +
    +
    + Créer un document +
    + {input type="text" minlength="1" name="name" required="required" label="Nom du document à créer"} +
    +

    + {csrf_field key=$csrf_key} + {button type="submit" name="create" label=$submit_name shape="plus" class="main"} +

    +
    +
    + + + +{include file="_foot.tpl"} diff --git a/src/templates/docs/new_file.tpl b/src/templates/docs/new_file.tpl new file mode 100644 index 0000000..7692060 --- /dev/null +++ b/src/templates/docs/new_file.tpl @@ -0,0 +1,38 @@ +{include file="_head.tpl" title="Créer un fichier texte"} + +{form_errors} + +
    +
    + Créer un fichier texte +
    + {input type="text" minlength="1" name="name" required="required" label="Nom du fichier à créer"} +
    +

    + {csrf_field key=$csrf_key} + {button type="submit" name="create" label="Créer le fichier" shape="plus" class="main"} +

    +
    +
    + + + +{include file="_foot.tpl"} diff --git a/src/templates/docs/search.tpl b/src/templates/docs/search.tpl new file mode 100644 index 0000000..d95aea6 --- /dev/null +++ b/src/templates/docs/search.tpl @@ -0,0 +1,29 @@ +{include file="_head.tpl" title="Rechercher dans les fichiers" current="docs"} + +
    +
    + Rechercher un fichier +

    + + {button type="submit" name="search" label="Chercher" shape="search" class="main"} +

    +
    +
    + +{if $query} +

    + {$results_count} fichiers trouvés pour « {$query} » +

    + +
    + {foreach from=$results item="result"} + + {/foreach} +
    +{/if} + +{include file="_foot.tpl"} \ No newline at end of file diff --git a/src/templates/docs/trash.tpl b/src/templates/docs/trash.tpl new file mode 100644 index 0000000..1bf3e72 --- /dev/null +++ b/src/templates/docs/trash.tpl @@ -0,0 +1,55 @@ +{include file="_head.tpl" title="Fichiers supprimés" current="docs" hide_title=true} + + + +

    + Les fichiers supprimés occupent actuellement {$size|size_in_bytes}.
    + Les fichiers sont supprimés automatiquement après 30 jours. +

    + +{form_errors} + +
    +{if $list->count()} + {include file="common/dynamic_list_head.tpl" check=true} + + {foreach from=$list->iterate() item="item"} + + + {input type="checkbox" name="check[]" value=$item->path} + + + {if $item.type == 2} + {icon shape="folder"} + {/if} + + {$item.name} + {$item.parent} + {$item.trash|date_short:true} + {$item.size|size_in_bytes} + + + + {/foreach} + + + +

    + {csrf_field key=$csrf_key} + {button type="submit" name="restore" label="Restaurer les fichiers sélectionnés" shape="reset"} + {if $session->canAccess($session::SECTION_CONFIG, $session::ACCESS_ADMIN)} + {button type="submit" name="delete" label="Supprimer définitivement les fichiers sélectionnés" shape="delete"} + {/if} +

    + + {$list->getHTMLPagination()|raw} + +{else} + +

    Il n'y a aucun fichier supprimé.

    + +{/if} +
    \ No newline at end of file diff --git a/src/templates/docs/trash_delete.tpl b/src/templates/docs/trash_delete.tpl new file mode 100644 index 0000000..9305431 --- /dev/null +++ b/src/templates/docs/trash_delete.tpl @@ -0,0 +1,11 @@ +{include file="_head.tpl" title="Supprimer %d fichiers"|args:$count current="docs"} + +{include file="common/delete_form.tpl" + legend="Supprimer ces fichiers ?" + warning="Êtes-vous sûr de vouloir supprimer définitivement %d fichiers ?"|args:$count + confirm="Cocher cette case pour confirmer la suppression" + csrf_key=$csrf_key + extra=$extra +} + +{include file="_foot.tpl"} \ No newline at end of file diff --git a/src/templates/emails/login_changed.tpl b/src/templates/emails/login_changed.tpl new file mode 100644 index 0000000..0450661 --- /dev/null +++ b/src/templates/emails/login_changed.tpl @@ -0,0 +1,14 @@ +{assign var="subject" value="Votre identifiant de connexion a été modifié"} + +Vos informations de connexion ont été modifiées. + +Votre nouvel identifiant de connexion est le suivant : + +{$new_login} + +Vous pouvez utiliser cet identifiant pour vous connecter +à votre association à l'adresse suivante : + +{$admin_url} + +Ce message est envoyé automatiquement lorsque votre identifiant est modifié. \ No newline at end of file diff --git a/src/templates/emails/password_changed.tpl b/src/templates/emails/password_changed.tpl new file mode 100644 index 0000000..86c79ff --- /dev/null +++ b/src/templates/emails/password_changed.tpl @@ -0,0 +1,16 @@ +{assign var="subject" value="Mot de passe modifié"} + +Le mot de passe de votre compte a bien été modifié, conformément à votre demande. + +La demande émanait de l'adresse IP : +{$ip} + +Si vous n'avez pas demandé à changer votre mot de passe, merci de nous le signaler. + +Pour rappel, votre identifiant de connexion est : +{$login} + +Pour vous reconnecter, utilisez cette adresse : +{$admin_url} + +Ce message est envoyé automatiquement lorsque votre mot de passe est modifié. \ No newline at end of file diff --git a/src/templates/emails/password_recovery.tpl b/src/templates/emails/password_recovery.tpl new file mode 100644 index 0000000..4333cb8 --- /dev/null +++ b/src/templates/emails/password_recovery.tpl @@ -0,0 +1,9 @@ +{assign var="subject" value="Mot de passe perdu ?"} + +Vous avez oublié votre mot de passe ? Pas de panique ! + +Il vous suffit de cliquer sur le lien ci-dessous pour modifier votre mot de passe : + +{$recovery_url} + +Si vous n'avez pas demandé à recevoir ce message, ignorez-le, votre mot de passe restera inchangé. diff --git a/src/templates/emails/verify_email.tpl b/src/templates/emails/verify_email.tpl new file mode 100644 index 0000000..d9a80f7 --- /dev/null +++ b/src/templates/emails/verify_email.tpl @@ -0,0 +1,6 @@ +{assign var="subject" value="Confirmez votre adresse e-mail"} + +Pour vérifier votre adresse e-mail pour notre association, +merci de bien vouloir cliquer sur le lien ci-dessous : + +{$verify_url} diff --git a/src/templates/error.tpl b/src/templates/error.tpl new file mode 100644 index 0000000..1431bdf --- /dev/null +++ b/src/templates/error.tpl @@ -0,0 +1,91 @@ + + + + + {if empty($title)}Erreur{else}{$title}{/if} + + + + + + +

    {if empty($title)}Erreur{else}{$title}{/if}

    + +

    + {if $html_error} + {$html_error|raw} + {else} + {$error|escape|nl2br} + {/if} +

    + +

    + «Retour +

    + + + \ No newline at end of file diff --git a/src/templates/index.html b/src/templates/index.html new file mode 100644 index 0000000..9a31a28 --- /dev/null +++ b/src/templates/index.html @@ -0,0 +1 @@ +404 Not Found

    Not Found

    The requested URL was not found on this server.

    \ No newline at end of file diff --git a/src/templates/index.tpl b/src/templates/index.tpl new file mode 100644 index 0000000..7abe05d --- /dev/null +++ b/src/templates/index.tpl @@ -0,0 +1,70 @@ +{include file="_head.tpl" title="Bonjour %s !"|args:$logged_user->name() current="home"} + +{$banner|raw} + + + + + +{if !$has_extensions} +
    +

    Besoin d'autres fonctionnalités ?

    +

    Découvrez ces extensions dans le menu Configuration, onglet Extensions :

    + + +
    +{elseif !empty($buttons)} + +{/if} + +{if $homepage} +
    + {$homepage|raw} +
    +{/if} + + + +{include file="_foot.tpl"} \ No newline at end of file diff --git a/src/templates/install.tpl b/src/templates/install.tpl new file mode 100644 index 0000000..f815c80 --- /dev/null +++ b/src/templates/install.tpl @@ -0,0 +1,39 @@ +{include file="_head.tpl" title="Démarrer avec Paheko" menu=false} + +

    + Bienvenue dans Paheko !
    + Veuillez remplir les informations suivantes pour démarrer la gestion de votre association. +

    + +{form_errors} + +
    + +
    + Informations sur l'association +
    + {input type="select" required=true label="Pays (pour la comptabilité)" options=$countries default="FR" help="Ce choix permet de configurer les règles comptables en fonction du pays de l'association." name="country"} + {input type="text" label="Nom de l'association" required=true name="name"} +
    +
    + +{if $require_admin_account} +
    + Création du compte administrateur +
    + {input type="text" label="Nom et prénom" required=true name="user_name"} + {input type="email" label="Adresse E-Mail" required=true name="user_email"} +
    + {include file="users/_password_form.tpl" field="password" required=true} +
    +{/if} + +

    + {csrf_field key=$csrf_key} + {button type="submit" name="save" label="Commencer à gérer mon association" shape="right" class="main"} +

    + +
    + + +{include file="_foot.tpl"} \ No newline at end of file diff --git a/src/templates/legal.tpl b/src/templates/legal.tpl new file mode 100644 index 0000000..3a46b36 --- /dev/null +++ b/src/templates/legal.tpl @@ -0,0 +1,48 @@ +{include file="_head.tpl" title="Mentions légales" layout="public" custom_css="/content.css"} + +
    +

    + {linkbutton shape="left" href=$www_url label="Retour au site"} +

    +

    Mentions légales

    +

    + Nom :
    + {$config.org_name} +

    +

    + Adresse :
    + {if !$config.org_address} + Non renseignée + {else} + {$config.org_address|escape|nl2br} + {/if} +

    +

    + Téléphone :
    + {if $config.org_phone} + {$config.org_phone|protect_contact:'tel'|raw} + {else} + Non renseigné + {/if} +

    +

    + Adresse e-mail :
    + {if $config.org_email} + {$config.org_email|protect_contact|raw} + {else} + Non renseigné + {/if} +

    +

    Toute demande d'accès, modification ou suppression de données personnelles doit être adressée à notre association.

    +

    + Hébergeur :
    + {if LEGAL_HOSTING_DETAILS} + + {else} + {$config.org_name}
    + {$config.org_address|escape|nl2br} + {/if} +

    +
    + +{include file="_foot.tpl"} \ No newline at end of file diff --git a/src/templates/login.tpl b/src/templates/login.tpl new file mode 100644 index 0000000..42e5758 --- /dev/null +++ b/src/templates/login.tpl @@ -0,0 +1,80 @@ +{include file="_head.tpl" title="Connexion" current="login"} + +{form_errors} + +{if $changed} +

    + Votre mot de passe a bien été modifié.
    + Vous pouvez maintenant l'utiliser pour vous reconnecter. +

    +{elseif isset($_GET['logout'])} +

    + Vous avez bien été déconnecté. +

    +{/if} + + + +
    + {if $app_token} +

    Une application tiers demande à accéder aux fichiers de l'association.
    Connectez-vous pour pouvoir confirmer l'accès.

    + {/if} + +
    + + {if $ssl_enabled} + {icon shape="lock"} Connexion sécurisée + {else} + {icon shape="unlock"} Connexion non-sécurisée + {/if} + +
    + {input type=$id_field.type label=$id_field.label required=true name="id"} + {input type="password" name="password" label="Mot de passe" required=true} + {if !$app_token} + {input type="checkbox" name="permanent" value="1" label="Rester connecté⋅e" help="recommandé seulement sur ordinateur personnel"} + {/if} +
    +
    + + {if $captcha} +
    + Vérification de sécurité + +
    +
    +
    {$captcha.spellout}
    +
    {input name="c_answer" type="text" maxlength=4 label=null required=true}
    +
    Cette vérification est demandée après plusieurs tentatives de connexion infructueuses.
    +
    +
    + {/if} + +

    + {csrf_field key="login"} + {button type="submit" name="login" label="Se connecter" shape="right" class="main"} + {if !DISABLE_EMAIL && !$app_token} + {linkbutton href="!password.php" label="Mot de passe perdu ?" shape="help"} + {linkbutton href="!password.php?new" label="Première connexion ?" shape="user"} + {/if} +

    + +

    + Suggestion : mettez cette page dans vos favoris pour la retrouver facilement :-)
    + (Sur ordinateur appuyez sur Ctrl + D. Aide : Firefox, Chrome) +

    + +
    + +{literal} + +{/literal} + +{include file="_foot.tpl"} \ No newline at end of file diff --git a/src/templates/login_app.tpl b/src/templates/login_app.tpl new file mode 100644 index 0000000..e784e9e --- /dev/null +++ b/src/templates/login_app.tpl @@ -0,0 +1,34 @@ +{include file="_head.tpl" title="Accès par une application tiers" layout="public"} + +{if $app_token == 'ok'} +

    L'application a bien été autorisée.

    +
    +

    Vous pourrez fermer cette fenêtre quand l'application aura terminé l'autorisation.

    +{else} +

    Une application tiers demande à accéder aux documents de l'association.

    + {form_errors} +
    +
    + Confirmer l'accès +

    Autoriser l'application à accéder aux documents ?

    +
    +

    L'application pourra :

    +
      + {if $permissions.read}
    • Lire les fichiers
    • {/if} + {if $permissions.write}
    • Modifier les fichiers
    • {/if} + {if $permissions.delete}
    • Supprimer les fichiers
    • {/if} +
    +
    + +

    + {csrf_field key=$csrf_key} + {button type="submit" label="Autoriser l'accès" shape="right" class="main" name="confirm"} +

    +

    + {button type="submit" label="Annuler" shape="left" name="cancel"} +

    +
    +
    +{/if} + +{include file="_foot.tpl"} \ No newline at end of file diff --git a/src/templates/login_otp.tpl b/src/templates/login_otp.tpl new file mode 100644 index 0000000..96702ab --- /dev/null +++ b/src/templates/login_otp.tpl @@ -0,0 +1,21 @@ +{include file="_head.tpl" title="Connexion — double facteur" current="login"} + +{form_errors} + +
    + +
    + Authentification à double facteur +
    + {input type="text" class="otp" minlength=6 maxlength=6 label="Code TOTP" name="code" help="Entrez ici le code donné par l'application d'authentification double facteur." required=true} +
    +
    + +

    + {csrf_field key=$csrf_key} + {button type="submit" name="login" label="Se connecter" shape="right" class="main"} +

    + +
    + +{include file="_foot.tpl"} \ No newline at end of file diff --git a/src/templates/me/_nav.tpl b/src/templates/me/_nav.tpl new file mode 100644 index 0000000..fb90c29 --- /dev/null +++ b/src/templates/me/_nav.tpl @@ -0,0 +1,7 @@ + \ No newline at end of file diff --git a/src/templates/me/edit.tpl b/src/templates/me/edit.tpl new file mode 100644 index 0000000..8791ca3 --- /dev/null +++ b/src/templates/me/edit.tpl @@ -0,0 +1,30 @@ +{include file="_head.tpl" title="Mes informations personnelles" current="me"} + +{include file="./_nav.tpl" current="me"} + +{form_errors} + +
    + +
    + Informations personnelles +
    + {foreach from=$fields item="field"} + {edit_user_field field=$field user=$user context="user_edit"} + {/foreach} +
    +
    + +
    + Changer mon mot de passe +

    {link href="!me/security.php" label="Modifier mon mot de passe ou autres informations de sécurité"}

    +
    + +

    + {csrf_field key=$csrf_key} + {button type="submit" name="save" label="Enregistrer" shape="right" class="main"} +

    + +
    + +{include file="_foot.tpl"} \ No newline at end of file diff --git a/src/templates/me/export.tpl b/src/templates/me/export.tpl new file mode 100644 index 0000000..c782183 --- /dev/null +++ b/src/templates/me/export.tpl @@ -0,0 +1,63 @@ + + + + Données utilisateur + + + + +

    Données utilisateur

    +

    Ce document contient une copie de toutes les données détenues sur vous par {$config.org_name}, conformément à la réglementation.

    + +
    + +

    Profil

    + +{include file="users/_details.tpl" data=$user show_message_button=false context="export"} + +
    + +

    Inscriptions aux activités et cotisations

    + + + + + {foreach from=$services_list->getHeaderColumns() key="key" item="column"} + + {/foreach} + + + + + + {foreach from=$services_list->iterate() item="row"} + + + + + + + + + {/foreach} + + +
    {$column.label}
    {$row.label}{$row.date|date_short}{$row.expiry|date_short}{$row.fee}{if $row.paid}Oui{else}Non{/if}{$row.amount|raw|money_currency}
    + + + + \ No newline at end of file diff --git a/src/templates/me/index.tpl b/src/templates/me/index.tpl new file mode 100644 index 0000000..ea147bc --- /dev/null +++ b/src/templates/me/index.tpl @@ -0,0 +1,40 @@ +{include file="_head.tpl" title="Mes informations personnelles" current="me"} + +{include file="./_nav.tpl" current="me"} + +{if $ok !== null} +

    + Les modifications ont bien été enregistrées. +

    +{/if} + +
    +
    {linkbutton href="!me/edit.php" label="Modifier mes informations" shape="edit"}
    +
    + + +{if $user->isChild() || count($children)} + +{/if} + +{include file="users/_details.tpl" data=$user show_message_button=false context="user"} + +
    +
    {linkbutton href="!me/export.php" label="Télécharger toutes les données détenues sur moi" shape="download"}
    +
    + +{$snippets|raw} + +{include file="_foot.tpl"} \ No newline at end of file diff --git a/src/templates/me/preferences.tpl b/src/templates/me/preferences.tpl new file mode 100644 index 0000000..3c646e9 --- /dev/null +++ b/src/templates/me/preferences.tpl @@ -0,0 +1,37 @@ +{include file="_head.tpl" title="Mes préférences" current="me"} + +{include file="./_nav.tpl" current="preferences"} + +{if $ok !== null} +

    + Les modifications ont bien été enregistrées. +

    +{/if} + + +
    +
    + Mes préférences +
    + {input type="select" name="dark_theme" label="Thème" required=true source=$preferences options=$themes_options default=false} + {input type="select" name="force_handheld" label="Taille d'écran" required=true source=$preferences options=$handheld_options default=false} + {input type="select" name="page_size" label="Nombre d'éléments par page dans les listes" required=true source=$preferences options=$page_size_options default=100 help="Par exemple dans la liste des membres."} + {if $session->canAccess($session::SECTION_DOCUMENTS, $session::ACCESS_READ)} + {input type="select" name="folders_gallery" label="Affichage des listes de documents" required=true source=$preferences options=$folders_options default=true} + {/if} + {if $session->canAccess($session::SECTION_ACCOUNTING, $session::ACCESS_READ)} +
    + {input type="radio-btn" name="accounting_expert" value=0 label="Simplifié" default=0 source=$preferences help="Conseillé. Pour les novices en comptabilité, affiche notamment les comptes de banque tels qu'ils apparaissent sur les relevés bancaires."} + {input type="radio-btn" name="accounting_expert" value=1 label="Expert" source=$preferences help="Si vous avez une bonne expérience de la comptabilité en partie double. Affiche les journaux de compte au sens de la comptabilité en partie double."} + {/if} +
    +
    + +

    + {csrf_field key=$csrf_key} + {button type="submit" name="save" label="Enregistrer" shape="right" class="main"} +

    + +
    + +{include file="_foot.tpl"} \ No newline at end of file diff --git a/src/templates/me/security.tpl b/src/templates/me/security.tpl new file mode 100644 index 0000000..0c1dede --- /dev/null +++ b/src/templates/me/security.tpl @@ -0,0 +1,116 @@ +{include file="_head.tpl" title="Mes informations de connexion et sécurité" current="me"} + +{include file="./_nav.tpl" current="security"} + +{if $ok} +

    + Changements enregistrés. +

    +{/if} + +{form_errors} + +{if $edit} +
    + + {if $edit == 'password'} +
    + Changer mon mot de passe + {include file="users/_password_form.tpl" required=true} +
    + {elseif $edit == 'otp'} +

    + Confirmez l'activation de l'authentification à double facteur TOTP en l'utilisant une première fois. +

    + +

    Pour renforcer la sécurité de votre connexion en cas de vol de votre mot de passe, vous pouvez activer l'authentification à double facteur. Cela nécessite d'installer une application comme Aegis ou Google Authenticator sur votre téléphone.

    + +
    + Confirmer l'activation de l'authentification à double facteur (2FA) + +
    +
    Votre clé secrète est :
    +
    {input name="otp_secret" default=$otp.secret_display type="text" readonly="readonly" copy=true onclick="this.select();"}
    +
    Recopiez la clé secrète ou scannez le QR code pour configurer votre application TOTP, puis utilisez celle-ci pour générer un code d'accès et confirmer l'activation.
    + {input name="otp_code" type="text" class="otp" minlength=6 maxlength=6 label="Code TOTP" help="Entrez ici le code donné par l'application d'authentification double facteur." required=true} +
    +
    + {elseif $edit == 'otp_disable'} +

    + Confirmez la désactivation de l'authentification à double facteur TOTP. +

    + + {elseif $edit == 'pgp_key'} +
    + Chiffrer les e-mails qui me sont envoyés avec PGP/GnuPG +

    En inscrivant ici votre clé publique, tous les e-mails qui vous seront envoyés seront chiffrés (cryptés) avec cette clé : messages collectifs, messages envoyés par les membres, rappels de cotisation, procédure de récupération de mot de passe, etc.

    +
    + {input name="pgp_key" source=$user label="Ma clé publique PGP" type="textarea" cols=90 rows=5 required=true help="Laisser vide pour désactiver le chiffrement."} + {if $pgp_fingerprint}
    L'empreinte de la clé est : {$pgp_fingerprint}
    {/if} +
    +

    + Attention : en inscrivant ici votre clé PGP, les emails de récupération de mot de passe perdu vous seront envoyés chiffrés + et ne pourront donc être lus si vous n'avez pas le le mot de passe protégeant la clé privée correspondante. +

    +
    + {/if} + +
    + Confirmation +
    + {input type="password" name="password_check" label="Mot de passe actuel" help="Entrez votre mot de passe actuel pour confirmer les changements." autocomplete="current-password" required=true} +
    +
    + +

    + {csrf_field key=$csrf_key} + {button type="submit" name="confirm" label="Confirmer" shape="right" class="main"} +

    + +
    +{else} +
    +
    Identifiant de connexion
    +
    {$id_field.label}
    +
    {input type=$id_field.type readonly="readonly" copy=true default=$id name=""}
    +
    Mot de passe
    + {if $can_change_password} +
    {linkbutton href="?edit=password" label="Modifier le mot de passe" shape="edit"}
    + {else} +
    Vous n'avez pas le droit de modifier votre mot de passe. Vous devez contacter un administrateur pour qu'il change votre mot de passe.
    + {/if} +
    Authentification à deux facteurs
    +
    + {if $user.otp_secret} + {icon shape="check"} Activée + {linkbutton href="?edit=otp_disable" label="Désactiver" shape="delete"} + {else} + Désactivée + {linkbutton href="?edit=otp" label="Activer" shape="check"} + {/if} +
    +
    Permet de protéger votre compte en cas de vol de votre mot de passe, en utilisant votre téléphone pour générer un code à usage unique.
    + {if $can_use_pgp} +
    Chiffrer les e-mails qui me sont envoyés avec PGP
    +
    + {if !$user.pgp_key} + Désactivé + {linkbutton href="?edit=pgp_key" label="Configurer" shape="edit"} + {else} + {icon shape="check"} Activé + {linkbutton href="?edit=pgp_key" label="Modifier" shape="edit"} + {/if} +
    +
    Permet de chiffrer les messages qui vous sont envoyés par e-mail, notamment les messages de récupération de mot de passe, pour empêcher un attaquant de prendre contrôle de votre compte si votre adresse e-mail est piratée.
    + {/if} +
    Déconnecter toutes mes sessions
    +
    {{Vous n'avez actuellement qu'une seule session ouverte (celle-ci).}{Vous avez actuellement %n sessions ouvertes (y compris celle-ci).} n=$sessions_count}
    +
    {linkbutton href="!logout.php?all" label="Me déconnecter de toutes les sessions" shape="logout"}
    +
    Journal de connexion
    +
    Permet de voir les tentatives de connexion, les modifications de mot de passe, etc.
    +
    {linkbutton href="!users/log.php" label="Voir mon journal de connexion" shape="menu"}
    +
    +{/if} + + +{include file="_foot.tpl"} \ No newline at end of file diff --git a/src/templates/me/services.tpl b/src/templates/me/services.tpl new file mode 100644 index 0000000..22819a7 --- /dev/null +++ b/src/templates/me/services.tpl @@ -0,0 +1,82 @@ + +{include file="_head.tpl" title="Mes activités & cotisations" current="me/services"} + +
    +
    Mes activités et cotisations
    + {foreach from=$services item="service"} + + {$service.label} + {if $service.archived} (activité passée){/if} + {if $service.status == -1 && $service.end_date} — expirée + {elseif $service.status == -1} — en retard + {elseif $service.status == 1 && $service.end_date} — en cours + {elseif $service.status == 1} — à jour{/if} + {if $service.status.expiry_date} — expire le {$service.expiry_date|date_short}{/if} + {if !$service.paid} — À payer !{/if} + + {foreachelse} +
    + Vous n'êtes inscrit à aucune activité ou cotisation. +
    + {/foreach} +
    + +

    Dettes et créances

    + +{if !count($accounts)} +

    Aucune dette ou créance n'est associée à votre profil.

    +{else} + + + + + + + + + + + {foreach from=$accounts item="account"} + + + + + + {/foreach} + +
    MontantCompte
    {$account.balance|raw|money_currency}{$account.label} + {if $account.position == Account::LIABILITY}Nous vous devons {$account.balance|raw|money_currency}. + {else}Vous nous devez {$account.balance|raw|money_currency}.{/if} +
    +{/if} + +{if $list->count()} + +

    Historique des inscriptions

    + + {include file="common/dynamic_list_head.tpl"} + + {foreach from=$list->iterate() item="row"} + + {$row.label} + {$row.fee} + {$row.date|date_short} + {$row.expiry|date_short} + {if $row.paid}Oui{else}Non{/if} + {$row.amount|raw|money_currency} + + + + {/foreach} + + + + + {$list->getHTMLPagination()|raw} +{/if} + +{$snippets|raw} + +{include file="_foot.tpl"} \ No newline at end of file diff --git a/src/templates/optout.tpl b/src/templates/optout.tpl new file mode 100644 index 0000000..60d1946 --- /dev/null +++ b/src/templates/optout.tpl @@ -0,0 +1,75 @@ +{include file="_head.tpl" title="Désinscription" layout="public"} + +{if $verify === true} +

    + Votre adresse e-mail a bien été vérifiée, merci ! +

    + +

    + {if $config.site_asso || !$config.site_disabled} + site_asso ?? $www_url; ?> + {linkbutton href=$url label="Retour au site" shape="left"} + {else} + {linkbutton href=$admin_url label="Connexion" shape="left"} + {/if} +

    + +{elseif $verify === false} +

    + Erreur de vérification de votre adresse e-mail. +

    +{elseif $ok} +

    + Vous avez été bien désinscrit.
    + Vous ne recevrez plus de messages de notre part. +

    + +

    + Vous pouvez vous réinscrire à tout moment en cliquant à nouveau sur le lien de désinscription présent à la fin de nos e-mails.
    + {linkbutton href="?un=%s"|args:$code label="Me réinscrire" shape="reload"} +

    +{elseif $resub_ok} +

    + Un e-mail vous a été envoyé, merci de cliquer sur le lien dans le message reçu pour confirmer. +

    +{elseif $email.optout} + +

    + Votre adresse e-mail est déjà désinscrite. Pour demander à vous réinscrire, renseignez le formulaire ci-dessous. +

    + + {form_errors} + +
    + +
    +
    + {input type="email" required=true name="email" label="Adresse e-mail"} + {input type="checkbox" name="confirm_resub" value="1" required=true label="Oui, je veux à nouveau recevoir les messages de « %s »"|args:$config.org_name} +
    +
    + +

    + {csrf_field key="optout"} + {button type="submit" name="resub" label="Réinscrire mon adresse e-mail" shape="right" class="main"} +

    +
    +{else} + + {form_errors} + +
    + +

    + En cliquant sur ce bouton vous confirmez ne plus vouloir recevoir aucun message de notre part. +

    + +

    + {csrf_field key="optout"} + {button type="submit" name="optout" label="Me désinscrire" shape="right" class="main"} +

    + +
    +{/if} + +{include file="_foot.tpl"} \ No newline at end of file diff --git a/src/templates/password.tpl b/src/templates/password.tpl new file mode 100644 index 0000000..06d2a71 --- /dev/null +++ b/src/templates/password.tpl @@ -0,0 +1,44 @@ +{include file="_head.tpl" title=$title} + +{if $sent} +

    + {if $new} + Un e-mail vous a été envoyé, cliquez sur le lien dans cet e-mail pour choisir votre mot de passe. + {else} + Un e-mail vous a été envoyé, cliquez sur le lien dans cet e-mail pour modifier votre mot de passe. + {/if} +

    +

    + Si le message n'apparaît pas dans les prochaines minutes, vérifiez le dossier Spam ou Indésirables. +

    + +{else} + + {form_errors} + +
    + +
    + {if $new}Envoyer un e-mail pour choisir son mot de passe{else}Envoyer un e-mail pour modifier son mot de passe{/if} +

    + Inscrivez ici votre identifiant.
    + {if $new} + Vous recevrez un e-mail à l'adresse renseignée dans votre fiche membre, avec un lien vous permettant de créer votre mot de passe. + {else} + Nous vous enverrons un e-mail à l'adresse renseignée dans votre fiche membre, avec un lien vous permettant de modifier votre mot de passe. + {/if} +

    +
    + {input type=$id_field.type label=$id_field.label required=true name="id"} +
    +
    + +

    + {csrf_field key=$csrf_key} + {button type="submit" name="recover" label="Envoyer" shape="right" class="main"} +

    + +
    +{/if} + +{include file="_foot.tpl"} \ No newline at end of file diff --git a/src/templates/password_change.tpl b/src/templates/password_change.tpl new file mode 100644 index 0000000..fbe0a4d --- /dev/null +++ b/src/templates/password_change.tpl @@ -0,0 +1,22 @@ +{include file="_head.tpl" title="Changement de mot de passe"} + + +{form_errors} + +
    + +
    + Choisir un nouveau mot de passe + {include file="users/_password_form.tpl" required=true} +
    + +

    + {csrf_field key=$csrf_key} + {button type="submit" name="change" label="Modifier mon mot de passe" shape="right" class="main"} +

    + + +
    + + +{include file="_foot.tpl"} \ No newline at end of file diff --git a/src/templates/services/_nav.tpl b/src/templates/services/_nav.tpl new file mode 100644 index 0000000..fe6b964 --- /dev/null +++ b/src/templates/services/_nav.tpl @@ -0,0 +1,53 @@ +{if !$dialog} + +{/if} \ No newline at end of file diff --git a/src/templates/services/_service_form.tpl b/src/templates/services/_service_form.tpl new file mode 100644 index 0000000..d7abcb7 --- /dev/null +++ b/src/templates/services/_service_form.tpl @@ -0,0 +1,73 @@ +{form_errors} + +
    + +
    + {$legend} +
    + {input name="label" type="text" required=1 label="Libellé" source=$service} + {input name="description" type="textarea" label="Description" source=$service} + + {if $service && $service->exists()} + {input type="checkbox" name="archived" value=1 label="Archiver cette activité" source=$service} +
    Si coché, les inscrits ne recevront plus de rappels, l'activité ne sera plus visible sur la fiche des membres, il ne sera plus possible d'y inscrire des membres.
    + {/if} + +
    (obligatoire)
    + + {if $service && $service->exists()} +
    Attention, une modification de la durée renseignée ici ne modifie pas la date d'expiration des activités déjà enregistrées.
    + {/if} + + {input name="period" type="radio-btn" value="0" label="Pas de durée (activité ou cotisation ponctuelle)" default=$period help="Pour un événement, un concert, un cours ponctuel, etc."} + {input name="period" type="radio-btn" value="1" label="En nombre de jours" default=$period help="Par exemple une cotisation valide un an à partir de la date d'inscription"} +
    +
    + {input name="duration" type="number" step="1" label="Nombre de jours" size="5" source=$service required=true default=365} +
    +
    + {input name="period" type="radio-btn" value="2" label="Période définie (date à date)" default=$period help="Par exemple pour une cotisation qui serait valable pour l'année civile en cours, quelle que soit la date d'inscription."} +
    +
    + {input type="date" name="start_date" label="Date de début" source=$service required=true} + {input type="date" name="end_date" label="Date de fin" source=$service required=true} +
    +
    +
    +
    + +

    + {csrf_field key=$csrf_key} + {button type="submit" name="save" label="Enregistrer" shape="right" class="main"} +

    + +
    + + \ No newline at end of file diff --git a/src/templates/services/delete.tpl b/src/templates/services/delete.tpl new file mode 100644 index 0000000..da47c03 --- /dev/null +++ b/src/templates/services/delete.tpl @@ -0,0 +1,13 @@ +{include file="_head.tpl" title="Supprimer une activité" current="users/services"} + +{include file="services/_nav.tpl" current="index"} + +{include file="common/delete_form.tpl" + legend="Supprimer cette activité ?" + confirm_label=$confirm_label + confirm_text=$confirm_text + warning="Êtes-vous sûr de vouloir supprimer l'activité « %s » et toutes les inscriptions ?"|args:$service.label + error="Attention, cela supprimera également les tarifs, les inscriptions des membres à cette activité, ainsi que les rappels associés !" + info="Les écritures comptables liées à l'historique des membres inscrits à cette activité ne seront pas supprimées, et la comptabilité demeurera inchangée."} + +{include file="_foot.tpl"} \ No newline at end of file diff --git a/src/templates/services/details.tpl b/src/templates/services/details.tpl new file mode 100644 index 0000000..74c6a32 --- /dev/null +++ b/src/templates/services/details.tpl @@ -0,0 +1,83 @@ +{include file="_head.tpl" title="%s — Liste des membres inscrits"|args:$service.label current="users/services"} + +{include file="services/_nav.tpl" current="index" current_service=$service service_page=$type} + +canAccess($session::SECTION_USERS, $session::ACCESS_ADMIN); +?> + +
    + + + +
    +
    Nombre de membres trouvés
    +
    + {$list->count()} + (N'apparaît ici que l'inscription la plus récente de chaque membre.) + {if $session->canAccess($session::SECTION_USERS, $session::ACCESS_ADMIN)} + {exportmenu} + {/if} +
    + {if $can_action} +
    Membres des catégories cachées
    + {input type="checkbox" label="Afficher aussi les inscriptions des membres appartenant à des catégories cachées" name="hidden" value="1" onchange="this.form.submit()" default=$include_hidden_categories role="button"} + {/if} +
    +
    + +{if $can_action} +
    +{/if} + +{if !$list->count()} +

    Il n'y a aucun résultat.

    +{else} + {include file="common/dynamic_list_head.tpl" check=$can_action} + + {foreach from=$list->iterate() item="row"} + + {if $can_action} + {input type="checkbox" name="selected[]" value=$row.id_user} + {/if} + + {$row.identity} + + {if $row.status == 1 && $row.end_date} + En cours + {elseif $row.status == 1} + À jour + {elseif $row.status == -1 && $row.end_date} + Terminée + {elseif $row.status == -1} + En retard + {else} + Pas d'expiration + {/if} + + {if $row.paid}Oui{else}Non{/if} + {$row.expiry|date_short} + {$row.fee} + {$row.date|date_short} + + {linkbutton shape="user" label="Toutes les activités de ce membre" href="!services/user/?id=%d"|args:$row.id_user} + {linkbutton shape="alert" label="Rappels envoyés" href="!services/reminders/user.php?id=%d"|args:$row.id_user} + + + {/foreach} + + + {if $can_action} + {include file="users/_list_actions.tpl" colspan=7 export=false hide_delete=true} + {/if} + + + + {$list->getHTMLPagination()|raw} +{/if} + +{if $can_action} +
    +{/if} + +{include file="_foot.tpl"} \ No newline at end of file diff --git a/src/templates/services/edit.tpl b/src/templates/services/edit.tpl new file mode 100644 index 0000000..916e71b --- /dev/null +++ b/src/templates/services/edit.tpl @@ -0,0 +1,7 @@ +{include file="_head.tpl" title="Modifier une activité" current="users/services"} + +{include file="services/_nav.tpl" current="index"} + +{include file="services/_service_form.tpl" legend="Modifier une activité"} + +{include file="_foot.tpl"} \ No newline at end of file diff --git a/src/templates/services/fees/_fee_form.tpl b/src/templates/services/fees/_fee_form.tpl new file mode 100644 index 0000000..3d673e3 --- /dev/null +++ b/src/templates/services/fees/_fee_form.tpl @@ -0,0 +1,117 @@ + + +{form_errors} + +
    + +
    + {$legend} +
    + {input name="label" type="text" required=true label="Libellé" source=$fee} + {input name="description" type="textarea" label="Description" source=$fee} + +
    + {input name="amount_type" type="radio" value="0" label="Gratuite ou prix libre" default=$amount_type} + {input name="amount_type" type="radio" value="1" label="Montant fixe ou prix libre conseillé" default=$amount_type} +
    +
    + {input name="amount" type="money" label="Montant" source=$fee required=true} +
    +
    + {input name="amount_type" type="radio" value="2" label="Montant variable" default=$amount_type} +
    +
    + {input name="formula" type="textarea" label="Formule de calcul" source=$fee required=true} +
    + Le résultat doit être un nombre entier incluant les centimes. Exemple : 950 pour représenter 9,50.
    + {linkbutton shape="help" href=$help_pattern_url|args:"formule-calcul-activite" target="_dialog" label="Aide sur les formules de calcul"} +
    +
    +
    +
    Comptabilité
    + {input name="accounting" type="checkbox" value="1" label="Enregistrer en comptabilité" default=$accounting_enabled} +
    Laissez cette case décochée si vous n'utilisez pas Paheko pour la comptabilité. Il ne sera pas possible de suivre le montant des règlements effectués pour ce tarif.
    +
    +
    + +
    + Enregistrer en comptabilité +

    Chaque règlement d'un membre lié à ce tarif sera enregistré dans la comptabilité, permettant de suivre le montant des règlements effectués.

    + {if !count($years)} +

    Il n'y a aucun exercice ouvert dans la comptabilité, il n'est donc pas possible d'enregistrer les activités dans la comptabilité. Merci de commencer par créer un exercice.

    + {else} +
    +
    (obligatoire)
    +
    + +
    + {input type="list" target="!acc/charts/accounts/selector.php?targets=%s&year=%d"|args:$targets,$fee.id_year name="account" label="Compte de recettes à utiliser" default=$account required=true} + {if count($projects) > 0} + {input type="select" options=$projects name="id_project" label="Projet analytique" default=$fee.id_project required=false default_empty="— Aucun —"} + {/if} +
    + {/if} +
    + +

    + {csrf_field key=$csrf_key} + {button type="submit" name="save" label="Enregistrer" shape="right" class="main"} +

    + +
    + + \ No newline at end of file diff --git a/src/templates/services/fees/delete.tpl b/src/templates/services/fees/delete.tpl new file mode 100644 index 0000000..9f2a9f2 --- /dev/null +++ b/src/templates/services/fees/delete.tpl @@ -0,0 +1,13 @@ +{include file="_head.tpl" title="Supprimer un tarif" current="users/services"} + +{include file="services/_nav.tpl" current="index"} + +{include file="common/delete_form.tpl" + legend="Supprimer ce tarif ?" + confirm_label=$confirm_label + confirm_text=$confirm_text + warning="Êtes-vous sûr de vouloir supprimer le tarif « %s » ?"|args:$fee.label + error="Attention, cela supprimera également les inscriptions des membres à ce tarif !" + info="Les écritures comptables liées à l'historique des membres ayant réglé ce tarif ne seront pas supprimées, et la comptabilité demeurera inchangée."} + +{include file="_foot.tpl"} \ No newline at end of file diff --git a/src/templates/services/fees/details.tpl b/src/templates/services/fees/details.tpl new file mode 100644 index 0000000..36c9e17 --- /dev/null +++ b/src/templates/services/fees/details.tpl @@ -0,0 +1,71 @@ +{include file="_head.tpl" title="Tarif : %s — Liste des membres inscrits"|args:$fee.label current="users/services"} + +{include file="services/_nav.tpl" current="index" current_service=$service service_page="index" current_fee=$fee fee_page=$type} + +canAccess($session::SECTION_USERS, $session::ACCESS_ADMIN); +?> + +
    + + + +
    +
    Nombre de membres trouvés
    +
    + {$list->count()} + (N'apparaît ici que l'inscription la plus récente de chaque membre.) + {if $session->canAccess($session::SECTION_USERS, $session::ACCESS_ADMIN)} + {exportmenu} + {/if} +
    + + {if $can_action} +
    Membres des catégories cachées
    + {input type="checkbox" label="Afficher aussi les inscriptions des membres appartenant à des catégories cachées" name="hidden" value="1" onchange="this.form.submit()" default=$include_hidden_categories role="button"} + {/if} +
    +
    + +{if $can_action} +
    +{/if} + +{include file="common/dynamic_list_head.tpl" check=$can_action} + + {foreach from=$list->iterate() item="row"} + + {if $can_action} + {input type="checkbox" name="selected[]" value=$row.id_user} + {/if} + {link href="!users/details.php?id=%d"|args:$row.id_user label=$row.identity} + {if $row.paid}Oui{else}Non{/if} + {if null === $row.paid_amount}{else}{$row.paid_amount|raw|money_currency}{/if} + {$row.date|date_short} + + {linkbutton shape="user" label="Toutes les activités de ce membre" href="!services/user/?id=%d"|args:$row.id_user} + {linkbutton shape="alert" label="Rappels envoyés" href="!services/reminders/user.php?id=%d"|args:$row.id_user} + + + {/foreach} + + + + {if $can_action} + {include file="users/_list_actions.tpl" colspan=5 export=false hide_delete=true} + {/if} + + + +{if $can_action} +
    +{/if} + +{$list->getHTMLPagination()|raw} + +

    + Les lignes indiquant comme montant payé signifient qu'aucune écriture comptable n'a été associée à cette inscription. De ce fait, le montant restant à payer ne peut être calculé. +

    + + +{include file="_foot.tpl"} \ No newline at end of file diff --git a/src/templates/services/fees/edit.tpl b/src/templates/services/fees/edit.tpl new file mode 100644 index 0000000..3c6c60b --- /dev/null +++ b/src/templates/services/fees/edit.tpl @@ -0,0 +1,7 @@ +{include file="_head.tpl" title="%s — Modifier le tarif"|args:$fee.label current="users/services"} + +{include file="services/_nav.tpl" current="index" current_service=$service service_page="index"} + +{include file="services/fees/_fee_form.tpl" legend="Modifier un tarif" submit_label="Enregistrer"} + +{include file="_foot.tpl"} \ No newline at end of file diff --git a/src/templates/services/fees/index.tpl b/src/templates/services/fees/index.tpl new file mode 100644 index 0000000..a75b349 --- /dev/null +++ b/src/templates/services/fees/index.tpl @@ -0,0 +1,46 @@ +{include file="_head.tpl" title="%s — Tarifs"|args:$service.label current="users/services"} + +{include file="services/_nav.tpl" current="index" current_service=$service service_page="index"} + + +{if $list->count()} + {include file="common/dynamic_list_head.tpl"} + {foreach from=$list->iterate() item="row"} + + {$row.label} + + {if $row.formula} + Formule + {elseif $row.amount} + {$row.amount|money_currency|raw} + {else} + - + {/if} + + {$row.nb_users_ok} + {$row.nb_users_expired} + {$row.nb_users_unpaid} + + {linkbutton shape="users" label="Liste des inscrits" href="!services/fees/details.php?id=%d"|args:$row.id} + {if $session->canAccess($session::SECTION_USERS, $session::ACCESS_ADMIN)} + {linkbutton shape="edit" label="Modifier" href="!services/fees/edit.php?id=%d"|args:$row.id} + {linkbutton shape="delete" label="Supprimer" href="!services/fees/delete.php?id=%d"|args:$row.id} + {/if} + + + {/foreach} + + + + {$list->getHTMLPagination()|raw} +{else} +

    + Il n'y a aucun tarif enregistré. Créez un premier tarif pour l'activité « {$service.label} » pour pouvoir y inscrire des membres. +

    +{/if} + +{if $session->canAccess($session::SECTION_USERS, $session::ACCESS_ADMIN)} + {include file="services/fees/_fee_form.tpl" legend="Ajouter un tarif" submit_label="Ajouter" csrf_key="fee_add" fee=null amount_type=0 account=null} +{/if} + +{include file="_foot.tpl"} \ No newline at end of file diff --git a/src/templates/services/import.tpl b/src/templates/services/import.tpl new file mode 100644 index 0000000..e8157d2 --- /dev/null +++ b/src/templates/services/import.tpl @@ -0,0 +1,48 @@ +{include file="_head.tpl" title="Importer des inscriptions" current="users"} + +{include file="services/_nav.tpl" current="import" service=null fee=null} + +{form_errors} + +{if $_GET.msg == 'OK'} +

    + L'import s'est bien déroulé. +

    +{/if} + +
    + +{if $csv->loaded()} + + {include file="common/_csv_match_columns.tpl"} + +

    + {csrf_field key=$csrf_key} + {button type="submit" name="cancel" value="1" label="Annuler" shape="left"} + {button type="submit" name="import" label="Importer" shape="right" class="main"} +

    + +{else} + +

    + Ce formulaire permet d'importer les inscriptions des membres aux activités. +

    + +
    + Importer depuis un fichier +
    + {input type="file" name="file" label="Fichier à importer" required=true accept="csv"} + {include file="common/_csv_help.tpl" csv=$csv} +
    +
    + +

    + {csrf_field key=$csrf_key} + {button type="submit" name="load" label="Charger le fichier" shape="right" class="main"} +

    +{/if} + + +
    + +{include file="_foot.tpl"} \ No newline at end of file diff --git a/src/templates/services/index.tpl b/src/templates/services/index.tpl new file mode 100644 index 0000000..e3601c9 --- /dev/null +++ b/src/templates/services/index.tpl @@ -0,0 +1,48 @@ +{include file="_head.tpl" title="Activités et cotisations" current="users/services"} + +{include file="services/_nav.tpl" current="index" service=null fee=null} + +{if isset($_GET['CREATE'])} +

    Vous devez déjà créer une activité pour pouvoir utiliser cette fonction.

    +{/if} + +{if $list->count()} + {include file="common/dynamic_list_head.tpl"} + {foreach from=$list->iterate() item="row"} + +
    {$row.label} + + {if $row.duration} + {$row.duration} jours + {elseif $row.start_date} + {$row.start_date|date_short} au {$row.end_date|date_short} + {else} + ponctuelle + {/if} + + {$row.nb_users_ok} + {$row.nb_users_expired} + {$row.nb_users_unpaid} + + {linkbutton shape="menu" label="Tarifs" href="!services/fees/?id=%d"|args:$row.id} + {linkbutton shape="users" label="Liste des inscrits" href="!services/details.php?id=%d"|args:$row.id} + {if $session->canAccess($session::SECTION_USERS, $session::ACCESS_ADMIN)} + {linkbutton shape="edit" label="Modifier" href="!services/edit.php?id=%d"|args:$row.id} + {linkbutton shape="delete" label="Supprimer" href="!services/delete.php?id=%d"|args:$row.id} + {/if} + + + {/foreach} + + + + {$list->getHTMLPagination()|raw} +{else} +

    Il n'y a aucune activité enregistrée.

    +{/if} + +{if empty($show_archived_services) && $session->canAccess($session::SECTION_USERS, $session::ACCESS_ADMIN)} + {include file="services/_service_form.tpl" legend="Ajouter une activité" service=null period=0} +{/if} + +{include file="_foot.tpl"} \ No newline at end of file diff --git a/src/templates/services/reminders/_form.tpl b/src/templates/services/reminders/_form.tpl new file mode 100644 index 0000000..f899981 --- /dev/null +++ b/src/templates/services/reminders/_form.tpl @@ -0,0 +1,119 @@ +{form_errors} + +
    + +
    + {$legend} +
    + {input type="select" name="id_service" options=$services_list label="Activité associée au rappel" required=1 source=$reminder} + +
    obligatoire
    + {input type="radio" name="delay_type" value=0 default=$delay_type label="Le jour de l'expiration de l'activité"} +
    + {input type="radio" name="delay_type" value=1 default=$delay_type} + {input type="number" name="delay_before" min=1 max=999 default=$delay_before size=4} + +
    +
    + {input type="radio" name="delay_type" value=2 default=$delay_type} + {input type="number" name="delay_after" min=1 max=999 size=4 default=$delay_after} + +
    + + {if !$reminder->exists()} + not_before_date ?? null) === null; ?> + {input type="radio" name="yes_before" value=1 default=$yes_before prefix_title="Envoyer ce rappel…" prefix_required=true label="À tous les membres" help="Même si leur inscription a expiré il y a longtemps, sauf s'ils ont déjà reçu un rappel pour cette activité"} + {input type="radio" name="yes_before" value=0 default=$yes_before label="Seulement aux membres dont l'inscription n'a pas encore expiré" help="Seuls les inscriptions expirant dans le futur seront concernées"} + {else} +
    Restriction d'envoi
    + {if $reminder.not_before_date} +
    Aucun rappel ne sera envoyé aux inscriptions expirant avant le {$reminder.not_before_date|date_short} + {else} +
    Aucune restriction. Tous les membres recevront ce rappel, selon le délai choisi.
    + {/if} + {/if} + + {input type="text" name="subject" required=1 source=$reminder label="Sujet du message envoyé"} + {input type="textarea" name="body" required=1 source=$reminder label="Texte du message envoyé" cols="90" rows="15"} +
    + Il est possible d'utiliser les mots-clés suivant dans le corps du mail, ils seront remplacés lors de l'envoi : + {literal} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    {{$label}}Nom de l'activité concernée par le rappel
    {{$fee_label}}Nom du tarif utilisé lors de la dernière inscription du membre à cette activité
    {{$id_user}}ID du membre concerné par le rappel
    {{$identity}}Nom du membre
    {{$email}}Adresse e-mail utilisée pour l'envoi du rappel au membre
    {{$nb_days}}Nombre de jours restants avant (ou après) expiration de l'inscription
    {{$reminder_date}}Date d'envoi du rappel
    {{$expiry_date}}Date d'expiration de l'inscription
    {{$user_amount}}Montant dû par le membre pour se réinscrire à cette activité
    {{$delay}}Nombre de jours défini dans le rappel
    {{$config.org_name}}Nom de l'association
    {{$config.org_address}}Adresse postale de l'association
    {{$site_url}}Adresse du site web de l'association
    +

    Note : il est aussi possible d'utiliser les champs de la fiche membre, par exemple {{$nom}} pour le nom du membre.

    + {/literal} +
    +
    +
    + +

    + {csrf_field key=$csrf_key} + {button type="submit" name="save" label="Enregistrer" shape="right" class="main"} +

    + +
    + + \ No newline at end of file diff --git a/src/templates/services/reminders/delete.tpl b/src/templates/services/reminders/delete.tpl new file mode 100644 index 0000000..177b1b3 --- /dev/null +++ b/src/templates/services/reminders/delete.tpl @@ -0,0 +1,10 @@ +{include file="_head.tpl" title="Supprimer un rappel automatique" current="users/services"} + +{include file="services/_nav.tpl" current="reminders"} + +{include file="common/delete_form.tpl" + legend="Supprimer ce rappel automatique ?" + warning="Êtes-vous sûr de vouloir supprimer le rappel « %s » ?"|args:$reminder.subject + confirm="Cocher cette case pour supprimer aussi l'historique des messages envoyés."} + +{include file="_foot.tpl"} \ No newline at end of file diff --git a/src/templates/services/reminders/details.tpl b/src/templates/services/reminders/details.tpl new file mode 100644 index 0000000..59f3e5b --- /dev/null +++ b/src/templates/services/reminders/details.tpl @@ -0,0 +1,63 @@ +{include file="_head.tpl" title="Liste des rappels envoyés" current="users/services"} + +{include file="services/_nav.tpl" current="reminders"} + + + +
    +
    Rappel : {$reminder.subject}
    +
    Activité : {$service.label}
    +
    Délai d'envoi : {if $reminder.delay > 0}{$reminder.delay} jours après l'expiration{elseif $reminder.delay < 0}{$reminder.delay|abs} jours avant l'expiration{else}le jour de l'expiration{/if}
    + {if $current_list === 'sent'} +
    Nombre de rappels envoyés
    +
    + {$list->count()} +
    + {elseif $current_list === 'pending'} +
    Nombre de rappels à envoyer
    +
    + {$list->count()} +
    + {/if} +
    + +{if $list->count()} + {if $current_list === 'pending'} +

    Note : cette liste ne prend pas en compte les membres qui ont une adresse e-mail invalide, ou qui se sont désinscrit des envois de messages.

    + {/if} + + {include file="common/dynamic_list_head.tpl"} + + {foreach from=$list->iterate() item="row"} + + {link href="!users/details.php?id=%d"|args:$row.id_user label=$row.identity} + {if $current_list === 'pending'} + {$row.expiry_date|date_short} + {/if} + {$row.reminder_date|date_short} + + {if $current_list === 'pending'} + {linkbutton href="preview.php?id_user=%d&id_reminder=%d"|args:$row.id_user:$reminder.id shape="eye" label="Prévisualiser" target="_dialog"} + {/if} + + + {/foreach} + + + +{elseif $current_list === 'pending'} +

    Il n'y a aucun message à envoyer pour ce rappel.

    +{else} +

    Il n'y a aucun message envoyé pour ce rappel.

    +{/if} + +{$list->getHTMLPagination()|raw} + + +{include file="_foot.tpl"} \ No newline at end of file diff --git a/src/templates/services/reminders/edit.tpl b/src/templates/services/reminders/edit.tpl new file mode 100644 index 0000000..d8e3cf2 --- /dev/null +++ b/src/templates/services/reminders/edit.tpl @@ -0,0 +1,7 @@ +{include file="_head.tpl" title="Modifier un rappel automatique" current="users/services"} + +{include file="services/_nav.tpl" current="reminders"} + +{include file="services/reminders/_form.tpl" legend="Modifier un rappel automatique" default_subject=null default_body=null} + +{include file="_foot.tpl"} \ No newline at end of file diff --git a/src/templates/services/reminders/index.tpl b/src/templates/services/reminders/index.tpl new file mode 100644 index 0000000..1a19d47 --- /dev/null +++ b/src/templates/services/reminders/index.tpl @@ -0,0 +1,51 @@ +{include file="_head.tpl" title="Gestion des rappels automatiques" current="users/services"} + +{include file="services/_nav.tpl" current="reminders"} + +

    + Les rappels automatiques sont envoyés aux membres disposant d'une adresse e-mail selon le délai défini.
    + Il est possible de définir plusieurs rappels pour une même activité.
    + {if USE_CRON} + Les rappels sont envoyés automatiquement chaque jour. + {/if} +

    + +{if empty($list)} +

    Aucun rappel automatique n'est configuré.

    +{else} + + + + + + + + + {foreach from=$list item="reminder"} + + + + + + + {/foreach} + +
    ActivitéDélai de rappelSujet
    + {$reminder.service_label} + + {if $reminder.delay == 0}le jour de l'expiration + {else} + {$reminder.delay|abs} + {if abs($reminder.delay) > 1}jours{else}jour{/if} + {if $reminder.delay > 0}après{else}avant{/if} + expiration + {/if} + {$reminder.subject} + {linkbutton shape="history" label="Liste des rappels envoyés" href="!services/reminders/details.php?id=%d&list=sent"|args:$reminder.id} + {linkbutton shape="mail" label="Liste des rappels à envoyer" href="!services/reminders/details.php?id=%d&list=pending"|args:$reminder.id}
    + {linkbutton shape="edit" label="Modifier" href="!services/reminders/edit.php?id=%d"|args:$reminder.id} + {linkbutton shape="delete" label="Supprimer" href="!services/reminders/delete.php?id=%d"|args:$reminder.id} +
    +{/if} + +{include file="_foot.tpl"} \ No newline at end of file diff --git a/src/templates/services/reminders/new.tpl b/src/templates/services/reminders/new.tpl new file mode 100644 index 0000000..608c23b --- /dev/null +++ b/src/templates/services/reminders/new.tpl @@ -0,0 +1,8 @@ +{include file="_head.tpl" title="Ajouter un rappel automatique" current="users/services"} + +{include file="services/_nav.tpl" current="reminders"} + +{include file="services/reminders/_form.tpl" legend="Ajouter un rappel automatique" + delay_type=0 delay_before=15 delay_after=5} + +{include file="_foot.tpl"} \ No newline at end of file diff --git a/src/templates/services/reminders/preview.tpl b/src/templates/services/reminders/preview.tpl new file mode 100644 index 0000000..a1a0f6f --- /dev/null +++ b/src/templates/services/reminders/preview.tpl @@ -0,0 +1,7 @@ +{include file="_head.tpl" title="Prévisualisation" current="users/services"} + + +{$body|escape|nl2br} + + +{include file="_foot.tpl"} \ No newline at end of file diff --git a/src/templates/services/reminders/user.tpl b/src/templates/services/reminders/user.tpl new file mode 100644 index 0000000..23d8b9a --- /dev/null +++ b/src/templates/services/reminders/user.tpl @@ -0,0 +1,32 @@ +{include file="_head.tpl" title="Rappels envoyés à un membre" current="users/services"} + +{include file="users/_nav_user.tpl" id=$user_id current="reminders"} + +{if $list->count()} + + {include file="common/dynamic_list_head.tpl"} + + {foreach from=$list->iterate() item="row"} + + {$row.label} + {if $row.delay > 0}{$row.delay} jours après l'expiration{elseif $row.delay < 0}{$row.delay|abs} jours avant l'expiration{else}le jour de l'expiration{/if} + {$row.date|date_short} + + {linkbutton shape="menu" label="Inscriptions après ce rappel" href="!services/user/?id=%d&after=%s"|args:$user_id,$row.date} + + + {/foreach} + + + + + {$list->getHTMLPagination()|raw} + + +{else} + +

    Aucun rappel n'a été envoyé à ce membre.

    + +{/if} + +{include file="_foot.tpl"} \ No newline at end of file diff --git a/src/templates/services/user/_choice_form.tpl b/src/templates/services/user/_choice_form.tpl new file mode 100644 index 0000000..ffa062e --- /dev/null +++ b/src/templates/services/user/_choice_form.tpl @@ -0,0 +1,63 @@ +
    + +
    (obligatoire)
    + + {foreach from=$grouped_services item="service"} +
    + {input type="radio" name="id_service" value=$service.id data-duration=$service.duration data-expiry=$service.expiry_date|date_short label=null} + +
    + {foreachelse} +

    Aucune activité trouvée

    + {/foreach} + +
    + +{foreach from=$grouped_services item="service"} +fees)) { continue; } ?> +
    +
    (obligatoire)
    + {foreach from=$service.fees key="service_id" item="fee"} +
    + {input type="radio" name="id_fee" value=$fee.id data-user-amount=$fee.user_amount data-account=$fee.id_account data-year=$fee.id_year label=null} + +
    + {/foreach} +
    +{/foreach} \ No newline at end of file diff --git a/src/templates/services/user/_service_user_form.tpl b/src/templates/services/user/_service_user_form.tpl new file mode 100644 index 0000000..1871c78 --- /dev/null +++ b/src/templates/services/user/_service_user_form.tpl @@ -0,0 +1,154 @@ + + +
    + +
    + Inscrire à une activité + +
    + {if $create && $users} +
    + Membres à inscrire +
    +
    +
    + {{%n membre sélectionné.}{%n membres sélectionnés.} n=$users|count} + + {foreach from=$users key="id" item="name"} + + + + + {/foreach} +
    + + {if !empty($allow_users_edit)} + {button shape="delete" onclick="this.parentNode.parentNode.remove();" title="Supprimer de la liste"} + {/if} + + {$name} +
    +
    +
    + {elseif $create && $copy_service} +
    Recopier depuis l'activité
    +
    {$copy_service.label}
    +
    {if $copy_only_paid}(seulement les inscriptions marquées comme payées){else}(toutes les inscriptions){/if}
    + {elseif $create && $copy_fee} +
    Recopier depuis le tarif
    +
    {$copy_fee->service()->label} — {$copy_fee.label}
    +
    {if $copy_only_paid}(seulement les inscriptions marquées comme payées){else}(toutes les inscriptions){/if}
    + {/if} + +
    (obligatoire)
    + + {foreach from=$grouped_services item="service"} +
    + {input type="radio" name="id_service" value=$service.id data-duration=$service.duration data-expiry=$service.expiry_date|date_short label=null source=$service_user} + +
    + {foreachelse} +

    Aucune activité trouvée

    + {/foreach} + +
    + + {foreach from=$grouped_services item="service"} + fees)) { continue; } ?> +
    +
    (obligatoire)
    + {foreach from=$service.fees key="service_id" item="fee"} +
    + {input type="radio" name="id_fee" value=$fee.id data-user-amount=$fee.user_amount data-account=$fee.id_account data-year=$fee.id_year label=null data-project=$fee.id_project source=$service_user } + +
    + {/foreach} +
    + {/foreach} + +
    + + + + +
    + Détails +
    + {input type="date" name="date" required=1 default=$today source=$service_user label="Date d'inscription"} + {input type="date" name="expiry_date" source=$service_user label="Date d'expiration de l'inscription"} + {input type="checkbox" name="paid" value="1" source=$service_user default="1" label="Marquer cette inscription comme payée"} +
    Décocher cette case pour pouvoir suivre les règlements de personnes qui payent en plusieurs fois. Il sera possible de cocher cette case lorsque le solde aura été réglé.
    +
    +
    + + {if $create} +
    + {input type="checkbox" name="create_payment" value=1 default=1 label="Enregistrer en comptabilité"} + +
    + {if !empty($users)} +
    Une écriture sera créée pour chaque membre inscrit.
    + {/if} + + {input type="money" name="amount" label="Montant réglé par le membre" required=true help="En cas de règlement en plusieurs fois il sera possible d'ajouter des règlements via la page de suivi des activités de ce membre."} + {input type="list" target="!acc/charts/accounts/selector.php?targets=%s&year=0"|args:$account_targets name="account_selector" label="Compte de règlement" required=true} + {input type="text" name="reference" label="Numéro de pièce comptable" help="Numéro de facture, de reçu, de note de frais, etc."} + {input type="text" name="payment_reference" label="Référence de paiement" help="Numéro de chèque, numéro de transaction CB, etc."} + {input type="textarea" name="notes" label="Remarques"} + {if count($projects) > 0} + {input type="select" options=$projects name="id_project" label="Projet analytique" required=false default_empty="— Aucun —"} + {/if} +
    +
    + {/if} + +

    + {csrf_field key=$csrf_key} + {button type="submit" name="save" label="Enregistrer" shape="right" class="main"} +

    + +
    \ No newline at end of file diff --git a/src/templates/services/user/add.tpl b/src/templates/services/user/add.tpl new file mode 100644 index 0000000..219b225 --- /dev/null +++ b/src/templates/services/user/add.tpl @@ -0,0 +1,62 @@ +{include file="_head.tpl" title="Inscrire à une activité" current="membres/services"} + +{include file="services/_nav.tpl" current="save" fee=null service=null} + +{form_errors} + +
    + +
    + Inscrire à une activité +
    + {input type="radio-btn" name="choice" value="1" label="Sélectionner des membres" default=1} + {input type="radio-btn" name="choice" value="2" label="Recopier depuis une activité" help="Utile si vous avez une cotisation par année civile par exemple : copie les membres inscrits l'année précédente dans la nouvelle année."} + {input type="radio-btn" name="choice" value="3" label="Tous les membres d'une catégorie"} +
    +
    + +
    + Inscrire des membres +
    + {input type="list" name="users" required=true label="Membres à inscrire" target="!users/selector.php" multiple=true} +
    +
    + +
    + Recopier depuis une activité +
    + {input type="select_groups" name="copy" label="Activité à recopier" options=$services required=true default=0} + {input type="checkbox" name="copy_only_paid" value="1" label="Ne recopier que les membres dont l'inscription est payée"} +
    +
    + +
    + Tous les membres d'une catégorie +
    + {input type="select" name="category" label="Catégorie à inscrire" options=$categories required=true} +
    +
    + +

    + + {button type="submit" name="next" label="Continuer" shape="right" class="main"} +

    +
    + + + +{include file="_foot.tpl"} \ No newline at end of file diff --git a/src/templates/services/user/delete.tpl b/src/templates/services/user/delete.tpl new file mode 100644 index 0000000..b8f169a --- /dev/null +++ b/src/templates/services/user/delete.tpl @@ -0,0 +1,9 @@ +{include file="_head.tpl" title="%s : Supprimer une inscription"|args:$user_name current="users/services"} + +{include file="common/delete_form.tpl" + legend="Supprimer l'inscription ?" + warning="Êtes-vous sûr de vouloir supprimer l'inscription ?" + alert="Les écritures comptables liées à cette inscription ne seront pas supprimées, la comptabilité demeurera inchangée." + info="%s – à « %s — %s »"|args:$user_name,$service_name,$fee_name} + +{include file="_foot.tpl"} \ No newline at end of file diff --git a/src/templates/services/user/edit.tpl b/src/templates/services/user/edit.tpl new file mode 100644 index 0000000..9e6cfaf --- /dev/null +++ b/src/templates/services/user/edit.tpl @@ -0,0 +1,7 @@ +{include file="_head.tpl" title="Modifier une inscription" current="users/services"} + +{form_errors} + +{include file="services/user/_service_user_form.tpl" create=false} + +{include file="_foot.tpl"} \ No newline at end of file diff --git a/src/templates/services/user/index.tpl b/src/templates/services/user/index.tpl new file mode 100644 index 0000000..55c050d --- /dev/null +++ b/src/templates/services/user/index.tpl @@ -0,0 +1,99 @@ +{include file="_head.tpl" title="%s — Inscriptions aux activités et cotisations"|args:$user_name current="users/services"} + +{include file="users/_nav_user.tpl" id=$user_id current="services"} + +{form_errors} + +{if !$only} +
    +
    Statut des inscriptions
    + {foreach from=$services item="service"} + + {$service.label} + {if $service.archived} (activité passée){/if} + {if $service.status == -1 && $service.end_date} — expirée + {elseif $service.status == -1} — en retard + {elseif $service.status == 1 && $service.end_date} — en cours + {elseif $service.status == 1} — à jour{/if} + {if $service.status.expiry_date} — expire le {$service.expiry_date|date_short}{/if} + {if !$service.paid} — À payer !{/if} + + {foreachelse} +
    + Ce membre n'est actuellement inscrit à aucune activité ou cotisation. +
    + {/foreach} + {if !$only && !$after} +
    Nombre d'inscriptions pour ce membre
    +
    + {$list->count()} + {if $session->canAccess($session::SECTION_USERS, $session::ACCESS_ADMIN)} + {exportmenu href="?id=%d"|args:$user_id} + {/if} +
    + {/if} +
    +{/if} + +{if $only} +

    Cette liste ne montre qu'une seule inscription, liée à l'activité {$only_service.label}
    + {linkbutton shape="right" href="?id=%d"|args:$user_id label="Voir toutes les inscriptions"} +

    +{/if} + +{include file="common/dynamic_list_head.tpl"} + + {foreach from=$list->iterate() item="row"} + + {$row.label} {if $row.archived}(archivée){/if} + {$row.fee} + {$row.date|date_short} + {$row.expiry|date_short} + {if $row.paid}Oui{else}Non{/if} + {if $row.expected_amount}{$row.amount|raw|money_currency:false} + {if $row.amount}
    (sur {$row.expected_amount|raw|money_currency:false}){/if} + {/if} + + + {if !$row.paid} + {if $session->canAccess($session::SECTION_USERS, $session::ACCESS_WRITE) && $row.id_account} + {linkbutton shape="plus" label="Nouveau règlement" href="payment.php?id=%d"|args:$row.id} + {/if} + + {if $session->canAccess($session::SECTION_ACCOUNTING, $session::ACCESS_WRITE)} + {linkbutton shape="plus" label="Saisir une écriture liée" + href="!acc/transactions/new.php?u[%d]=%d&00=%d&t=1&l=Paiement%%20activité&ar=%s&set_year=%d"|args:$user_id:$row.id:$row.expected_amount:$row.account_code:$row.id_year target="_dialog"} + {/if} +
    + {/if} + + {if $session->canAccess($session::SECTION_ACCOUNTING, $session::ACCESS_READ)} + {linkbutton shape="menu" label="Liste des écritures" href="!acc/transactions/service_user.php?id=%d&user=%d"|args:$row.id,$user_id} + {/if} + + {if $session->canAccess($session::SECTION_USERS, $session::ACCESS_WRITE)} + {if $row.paid} + {linkbutton shape="reset" label="Marquer comme non payé" href="?id=%d&su_id=%d&paid=0"|args:$user_id,$row.id} + {else} + {linkbutton shape="check" label="Marquer comme payé" href="?id=%d&su_id=%d&paid=1"|args:$user_id,$row.id} + {/if} +
    + {linkbutton shape="edit" label="Modifier" href="edit.php?id=%d"|args:$row.id} + {linkbutton shape="delete" label="Supprimer" href="delete.php?id=%d"|args:$row.id} + {/if} + + + + {foreachelse} + + Aucune inscription trouvée. + + {/foreach} + + + + +{$list->getHTMLPagination()|raw} + + +{include file="_foot.tpl"} \ No newline at end of file diff --git a/src/templates/services/user/link.tpl b/src/templates/services/user/link.tpl new file mode 100644 index 0000000..ade6af3 --- /dev/null +++ b/src/templates/services/user/link.tpl @@ -0,0 +1,22 @@ +{include file="_head.tpl" title="Lier une inscription à une écriture" current="acc/accounts"} + +{form_errors} + +
    + +
    + Lier à une écriture + +
    + {input type="number" label="Numéro de l'écriture" name="id_transaction" required=true} +
    +
    + +

    + {csrf_field key=$csrf_key} + {button type="submit" name="save" label="Enregistrer" shape="right" class="main"} +

    + +
    + +{include file="_foot.tpl"} \ No newline at end of file diff --git a/src/templates/services/user/payment.tpl b/src/templates/services/user/payment.tpl new file mode 100644 index 0000000..12e0e08 --- /dev/null +++ b/src/templates/services/user/payment.tpl @@ -0,0 +1,33 @@ +{include file="_head.tpl" title="Enregistrer un règlement" current="users/services"} + +{form_errors} + +
    + +
    + Enregistrer un règlement + +
    +
    Membre sélectionné
    +

    {$user_name}

    +
    Inscription
    + {input type="checkbox" name="paid" value="1" default=$su.paid label="Marquer cette inscription comme payée"} + {input type="date" name="date" label="Date" required=1 source=$su} + {input type="money" name="amount" label="Montant réglé par le membre" required=1} + {input type="list" target="!acc/charts/accounts/selector.php?targets=%s&year=%d"|args:$account_targets,$fee.id_year name="account_selector" label="Compte de règlement" required=1} + {input type="text" name="reference" label="Numéro de pièce comptable" help="Numéro de facture, de reçu, de note de frais, etc."} + {input type="text" name="payment_reference" label="Référence de paiement" help="Numéro de chèque, numéro de transaction CB, etc."} + {if count($projects) > 0} + {input type="select" options=$projects name="id_project" label="Projet analytique" default=$fee.id_project required=false default_empty="— Aucun —"} + {/if} +
    +
    + +

    + {csrf_field key=$csrf_key} + {button type="submit" name="save" label="Enregistrer" shape="right" class="main"} +

    + +
    + +{include file="_foot.tpl"} \ No newline at end of file diff --git a/src/templates/services/user/subscribe.tpl b/src/templates/services/user/subscribe.tpl new file mode 100644 index 0000000..2604e1f --- /dev/null +++ b/src/templates/services/user/subscribe.tpl @@ -0,0 +1,11 @@ +{include file="_head.tpl" title="Inscrire à une activité" current="users/services"} + +{if !$dialog} +{include file="services/_nav.tpl" current="save" fee=null service=null} +{/if} + +{form_errors} + +{include file="services/user/_service_user_form.tpl" create=true} + +{include file="_foot.tpl"} \ No newline at end of file diff --git a/src/templates/static/upgrade_post.html b/src/templates/static/upgrade_post.html new file mode 100644 index 0000000..fc455bc --- /dev/null +++ b/src/templates/static/upgrade_post.html @@ -0,0 +1,21 @@ + + + + Mise à jour en cours + + + + + +

    Une mise à jour est en cours

    +

    Ne fermez pas cette fenêtre !

    +

    Si vous fermez cette fenêtre, les données du formulaire seront perdues.

    +

    Attendez que la mise à jour soit terminée dans la fenêtre ci-dessous pour cliquer sur le bouton Recharger :

    +

    + + + + diff --git a/src/templates/users/_details.tpl b/src/templates/users/_details.tpl new file mode 100644 index 0000000..a61d316 --- /dev/null +++ b/src/templates/users/_details.tpl @@ -0,0 +1,96 @@ +attachmentsDirectory(); + +$id_fields = DF::getNameFields(); +$email_button = 0; +$fields = DF::getInstance()->all(); +?> + +
    + {foreach from=$fields key="key" item="field"} + user_access_level) { + continue; + } + + // Skip files from export + if ($context === 'export' && $field->type === 'file') { + continue; + } + + $value = $user->$key ?? null; + ?> +
    {$field.label}
    + + {* Skip according to management access rules *} + {if $context === 'manage' && !$session->canAccess(Session::SECTION_USERS, $field.management_access_level)} +
    **Caché**
    + + {/if} + +
    + {if $field.type == 'checkbox'} + {if $value} + {icon shape="check"} Oui + {else} + {icon shape="uncheck"} Non + {/if} + {elseif $field.type == 'file'} + user_access_level === Session::ACCESS_WRITE || $context === 'manage'); + ?> + {include file="common/files/_context_list.tpl" path="%s/%s"|args:$user_files_path:$key} + {elseif empty($value)} + (Non renseigné) + {elseif $field.type == 'email'} + {$value} + {if !DISABLE_EMAIL && $show_message_button && !$email_button++} + {linkbutton href="!users/message.php?id=%d"|args:$data.id label="Envoyer un message" shape="mail"} + {/if} + {elseif $field.type == 'multiple'} +
      + {foreach from=$field.options key="b" item="name"} + {if (int)$value & (0x01 << (int)$b)} +
    • {$name}
    • + {/if} + {/foreach} +
    + {else} + {if in_array($key, $id_fields)}{/if} + {user_field field=$field value=$value user_id=$user.id} + {if in_array($key, $id_fields)}{/if} + {/if} +
    + {if $field.type == 'email' && $value} + +
    Statut e-mail
    +
    + {if $email.optout} + {icon shape="alert"} Ne souhaite plus recevoir de messages + {if $session->canAccess($session::SECTION_USERS, $session::ACCESS_WRITE)} + +
    {linkbutton target="_dialog" label="Rétablir les envois à cette adresse" href="!users/mailing/verify.php?address=%s"|args:$value shape="check"} + {/if} + {elseif $email.invalid} + {icon shape="alert"} Adresse invalide + {linkbutton href="!users/mailing/rejected.php?hl=%d#e_%1\$d"|args:$email.id label="Détails de l'erreur" shape="help"} + {elseif $email && $email->hasReachedFailLimit()} + {icon shape="alert"} Trop d'erreurs + {linkbutton href="!users/mailing/rejected.php?hl=%d#e_%1\$d"|args:$email.id label="Détails de l'erreur" shape="help"} + {elseif $email.verified} + {icon shape="check" class="confirm"} Adresse vérifiée + {else} + {* Adresse non vérifiée *} + {if $session->canAccess($session::SECTION_USERS, $session::ACCESS_WRITE)} + {linkbutton target="_dialog" label="Désinscrire de tous les envois" href="!users/mailing/block.php?address=%s"|args:$value shape="delete"} + {/if} + {/if} +
    + {/if} + {/foreach} +
    diff --git a/src/templates/users/_import_list.tpl b/src/templates/users/_import_list.tpl new file mode 100644 index 0000000..b4560e6 --- /dev/null +++ b/src/templates/users/_import_list.tpl @@ -0,0 +1,23 @@ +{foreach from=$list item="user"} + exists() && $user->isModified(); ?> +

    {$user->name()}

    + +
    + {if $user->exists() && $user->isModified()} + {foreach from=$user->diff() key="key" item="diff"} +
    {$csv->getColumnLabel($key)}
    +
    + {user_field name=$key value=$diff[0]} + {user_field name=$key value=$diff[1]} +
    + {/foreach} + {else} + {foreach from=$user->asDetailsArray() key="key" item="value"} +
    {$csv->getColumnLabel($key)}
    +
    + {user_field name=$key value=$value} +
    + {/foreach} + {/if} +
    +{/foreach} diff --git a/src/templates/users/_list_actions.tpl b/src/templates/users/_list_actions.tpl new file mode 100644 index 0000000..a3ced8d --- /dev/null +++ b/src/templates/users/_list_actions.tpl @@ -0,0 +1,30 @@ + + + + + Pour les membres cochés : + {csrf_field key="membres_action"} + + + + + \ No newline at end of file diff --git a/src/templates/users/_log_list.tpl b/src/templates/users/_log_list.tpl new file mode 100644 index 0000000..aa8fccc --- /dev/null +++ b/src/templates/users/_log_list.tpl @@ -0,0 +1,45 @@ +{include file="common/dynamic_list_head.tpl"} + +{foreach from=$list->iterate() item="row"} + + {$row.created|date_short:true} + {if !$row.identity}*{else}{$row.identity}{/if} + + {if $row.type == Log::LOGIN_FAIL || $row.type == Log::LOGIN_PASSWORD_CHANGE} + {icon shape="alert"} + {/if} + + + {$row.type_label} + + + {if $row.type == Log::LOGIN_FAIL && $row.details.otp} + Code OTP erroné
    + {elseif $row.type == Log::LOGIN_SUCCESS && $row.details.otp} + (avec code OTP)
    + {/if} + {if $row.type == Log::LOGIN_FAIL || $row.type == Log::LOGIN_SUCCESS || $row.type == Log::LOGIN_RECOVER} + {$row.details.user_agent} + {elseif $row.type == Log::LOGIN_AS} + "{$row.details.admin}" s'est connecté à la place du membre + {elseif $row.entity_url} + {link href=$row.entity_url label=$row.entity_name} + {elseif $row.entity_name} + {$row.entity_name} + {/if} + {if isset($row.details.id) && $session->canAccess($session::SECTION_CONFIG, $session::ACCESS_ADMIN)} + (ID = {$row.details.id}) + {/if} + + {$row.ip_address} + + + +{/foreach} + + + + +{$list->getHTMLPagination()|raw} + +

    Note : les heures correspondent au fuseau horaire du serveur ().

    \ No newline at end of file diff --git a/src/templates/users/_nav.tpl b/src/templates/users/_nav.tpl new file mode 100644 index 0000000..27de271 --- /dev/null +++ b/src/templates/users/_nav.tpl @@ -0,0 +1,16 @@ + \ No newline at end of file diff --git a/src/templates/users/_nav_user.tpl b/src/templates/users/_nav_user.tpl new file mode 100644 index 0000000..14a0c9d --- /dev/null +++ b/src/templates/users/_nav_user.tpl @@ -0,0 +1,19 @@ + \ No newline at end of file diff --git a/src/templates/users/_password_form.tpl b/src/templates/users/_password_form.tpl new file mode 100644 index 0000000..8b92ef4 --- /dev/null +++ b/src/templates/users/_password_form.tpl @@ -0,0 +1,32 @@ +fieldsBySystemUse('password')); +$password_length = User::MINIMUM_PASSWORD_LENGTH; +$suggestion = Utils::suggestPassword(); +$required = $required ?? $field->required; +?> + +
    +
    + Astuce : un mot de passe de quatre mots choisis au hasard dans le dictionnaire est plus sûr et plus simple à retenir qu'un mot de passe composé de 10 lettres et chiffres. +
    +
    + Pas d'idée ? Voici une suggestion choisie au hasard : + {input type="text" readonly=true title="Cliquer pour utiliser cette suggestion comme mot de passe" default=$suggestion autocomplete="off" copy=true name="suggest"} +
    + + {input type="password" name="password" required=$required label="Mot de passe" help="Minimum %d caractères"|args:$password_length autocomplete="off" minlength=$password_length} + + {input type="password" name="password_confirmed" required=$required label="Encore le mot de passe (vérification)" help="Minimum %d caractères"|args:$password_length autocomplete="off" minlength=$password_length} +
    + + \ No newline at end of file diff --git a/src/templates/users/action.tpl b/src/templates/users/action.tpl new file mode 100644 index 0000000..f5b9929 --- /dev/null +++ b/src/templates/users/action.tpl @@ -0,0 +1,48 @@ +{include file="_head.tpl" title="Action collective sur les membres" current="membres"} + +{form_errors} + +{if $action == 'delete'} + {include file="common/delete_form.tpl" + legend="Supprimer %d membres ?"|args:$count + warning="Êtes-vous sûr de vouloir supprimer ces membres ?" + alert="Cette action est irréversible et effacera toutes les données personnelles et les inscriptions aux activités de ces membres." + extra=$extra + info="Alternativement, il est aussi possible de déplacer les membres qui ne font plus partie de l'association dans une catégorie (par exemple \"Anciens membres\"), plutôt que de les supprimer."} +{elseif $action == 'delete_files'} + {include file="common/delete_form.tpl" + legend="Supprimer les fichiers de %d membres ?"|args:$count + warning="Êtes-vous sûr de vouloir supprimer les fichiers de ces %d membres ?"|args:$count + alert="Cette action est irréversible." + extra=$extra} +{else} +
    + {foreach from=$list item="id"} + + {/foreach} + +

    + {{%n membre sélectionné.}{%n membres sélectionnés} n=$count} +

    + + {if $action == 'move'} + +
    + Changer la catégorie des membres sélectionnés +
    + {input type="select" name="new_category_id" label="Nouvelle catégorie" options=$categories required=true default_empty=""} +
    +
    + +

    + {csrf_field key=$csrf_key} + + {button type="submit" name="confirm" label="Modifier la catégorie" shape="right" class="main"} +

    + + {/if} + +
    +{/if} + +{include file="_foot.tpl"} \ No newline at end of file diff --git a/src/templates/users/delete.tpl b/src/templates/users/delete.tpl new file mode 100644 index 0000000..647e4f8 --- /dev/null +++ b/src/templates/users/delete.tpl @@ -0,0 +1,11 @@ +{include file="_head.tpl" title="Supprimer un membre" current="users"} + +{include file="common/delete_form.tpl" + legend="Supprimer ce membre ?" + warning=$warning + alert="Cette action est irréversible et effacera toutes les données et l'historique de ce membre." + info="Alternativement, il est aussi possible de déplacer le membre dans une catégorie « Anciens membres », plutôt que de le supprimer complètement." + csrf_key=$csrf_key +} + +{include file="_foot.tpl"} \ No newline at end of file diff --git a/src/templates/users/details.tpl b/src/templates/users/details.tpl new file mode 100644 index 0000000..586f779 --- /dev/null +++ b/src/templates/users/details.tpl @@ -0,0 +1,114 @@ +{include file="_head.tpl" title="%s (%s)"|args:$user->name():$category.name current="users"} + +{include file="users/_nav_user.tpl" id=$user.id current="details"} + +
    +
    Activités et cotisations
    + {foreach from=$services item="service"} + + {$service.label} + {if $service.archived} (activité passée){/if} + {if $service.status == -1 && $service.end_date} — terminée + {elseif $service.status == -1} — en retard + {elseif $service.status == 1 && $service.end_date} — en cours + {elseif $service.status == 1} — à jour{/if} + {if $service.status.expiry_date} — expire le {$service.expiry_date|date_short}{/if} + {if !$service.paid} — À payer !{/if} + + {foreachelse} +
    + Ce membre n'est actuellement inscrit à aucune activité ou cotisation. +
    + {/foreach} +
    + {if $session->canAccess($session::SECTION_USERS, $session::ACCESS_WRITE)} + {linkbutton href="!services/user/subscribe.php?user=%d"|args:$user.id label="Inscrire à une activité" shape="plus" target="_dialog" accesskey="V"} + {/if} +
    + {if $session->canAccess($session::SECTION_USERS, $session::ACCESS_READ)} + {if !empty($transactions_linked)} +
    Écritures comptables liées
    +
    {$transactions_linked} écritures comptables liées à ce membre
    + {/if} + {if !empty($transactions_created)} +
    Écritures comptables créées
    +
    {$transactions_created} écritures comptables créées par ce membre
    + {/if} + {/if} + {if $user->isChild()} +
    Membre responsable
    +
    {link href="?id=%d"|args:$user.id_parent label=$parent_name}
    + {if count($siblings)} +
    Autres membres rattachés à {$parent_name}
    + {foreach from=$siblings item="sibling"} +
    {link href="?id=%d"|args:$sibling.id label=$sibling.name}
    + {/foreach} + {/if} + {elseif count($children)} +
    Membres rattachés
    + {foreach from=$children item="child"} +
    {link href="?id=%d"|args:$child.id label=$child.name}
    + {/foreach} + {/if} +
    + + + +{include file="users/_details.tpl" data=$user show_message_button=true context="manage"} + +{$snippets|raw} + +{include file="_foot.tpl"} \ No newline at end of file diff --git a/src/templates/users/edit.tpl b/src/templates/users/edit.tpl new file mode 100644 index 0000000..fca1630 --- /dev/null +++ b/src/templates/users/edit.tpl @@ -0,0 +1,44 @@ +{include file="_head.tpl" title="%s — Modifier le membre"|args:$user->name() current="users"} + + + +{form_errors} + +
    + + +
    + Fiche du membre +
    + {foreach from=$fields item="field"} + {edit_user_field field=$field user=$user context="admin_edit"} + {/foreach} +
    +
    + +

    + {csrf_field key=$csrf_key} + {button type="submit" name="save" label="Enregistrer" shape="right" class="main"} +

    + +
    + +{include file="_foot.tpl"} \ No newline at end of file diff --git a/src/templates/users/edit_security.tpl b/src/templates/users/edit_security.tpl new file mode 100644 index 0000000..8c1f11b --- /dev/null +++ b/src/templates/users/edit_security.tpl @@ -0,0 +1,40 @@ +{include file="_head.tpl" title="%s — Modifier le mot de passe"|args:$user->name() current="users"} + +{form_errors} + +
    + + + + +
    + {if $user.password}Changer le mot de passe{else}Définir un mot de passe{/if} + {include file="users/_password_form.tpl"} +
    + {if $user.password} + {input type="checkbox" name="password_delete" label="Supprimer le mot de passe de ce membre" value=1} + {/if} +
    +
    + + {if $user.otp_secret || $user.pgp_key} +
    + Options de sécurité +
    + {if $user.otp_secret} + {input type="checkbox" name="otp_delete" value="1" label="Désactiver l'authentification à double facteur TOTP"} + {/if} + {if $user.pgp_key} + {input type="checkbox" name="pgp_key" value="" label="Supprimer la clé PGP associée au membre"} + {/if} +
    +
    + {/if} + +

    + {csrf_field key=$csrf_key} + {button type="submit" name="save" label="Enregistrer" shape="right" class="main"} +

    +
    + +{include file="_foot.tpl"} \ No newline at end of file diff --git a/src/templates/users/import.tpl b/src/templates/users/import.tpl new file mode 100644 index 0000000..ca80845 --- /dev/null +++ b/src/templates/users/import.tpl @@ -0,0 +1,122 @@ +{include file="_head.tpl" title="Importer des membres" current="users"} + +{include file="users/_nav.tpl" current="import"} + +{form_errors} + +{if $_GET.msg == 'OK'} +

    + L'import s'est bien déroulé. +

    +{/if} + +
    + +{if $csv->ready()} + {if $report.has_logged_user} +

    + Ce fichier comporte une modification de votre profil de membre. + Celle-ci a été ignorée afin d'empêcher que vous ne puissiez plus vous connecter.
    + Pour modifier vos informations de membre, utilisez la page {linkbutton shape="user" label="Mes informations personnelles" href="!me/"} ou demandez à un autre administrateur de modifier votre fiche. +

    + {/if} + +

    + Aucun problème n'a été détecté.
    + Voici un résumé des changements qui seront apportés par cet import : +

    + + {if count($report.created)} +
    + +

    {{%n membre sera créé}{%n membres seront créés} n=$report.created|count}

    +
    +

    Les membres suivants mentionnés dans le fichier seront ajoutés.

    + {include file="users/_import_list.tpl" list=$report.created} +
    + {/if} + + {if count($report.modified)} +
    + +

    {{%n membre sera modifié}{%n membres seront modifiés} n=$report.modified|count}

    +
    +

    Les membres suivants mentionnés dans le fichier seront modifiés.
    + En rouge ce qui sera supprimé, en vert ce qui sera ajouté.

    + {include file="users/_import_list.tpl" list=$report.modified} +
    + {/if} + + {if count($report.unchanged)} +

    {{%n membre ne sera pas modifié}{%n membres ne seront pas modifiés} n=$report.unchanged|count}

    + {/if} + + {if !count($report.modified) && !count($report.created)} +

    + Aucune modification ne serait apportée par ce fichier à importer. Il n'est donc pas possible de terminer l'import. +

    + {else} +

    + En validant ce formulaire, ces changements seront appliqués. +

    + {/if} + +

    + {csrf_field key=$csrf_key} + {button type="submit" name="cancel" value="1" label="Annuler" shape="left"} + {if count($report.modified) || count($report.created)} + {button type="submit" name="import" label="Importer" class="main" shape="right"} + {/if} +

    +{elseif $csv->loaded()} + + {include file="common/_csv_match_columns.tpl"} + +

    + {csrf_field key=$csrf_key} + {button type="submit" name="cancel" value="1" label="Annuler" shape="left"} + {button type="submit" name="preview" label="Prévisualiser" shape="right" class="main"} +

    + +{else} + +
    + Importer depuis un fichier +
    + {input type="file" name="file" label="Fichier à importer" required=true accept="csv"} + {include file="common/_csv_help.tpl" csv=$csv} +
    +
    + +
    + Configuration de l'import +
    +
    (obligatoire)
    + + {input type="radio" name="mode" value="create" label="Créer tous les membres" required=true} +
    Tous les membres trouvés dans le fichier seront créés.
    Cela peut amener à avoir des membres en doublon si on réalise plusieurs imports du même fichier.
    + + {input type="radio" name="mode" value="update" label="Mettre à jour en utilisant le numéro de membre" required=true} +
    + Les membres présents dans le fichier qui mentionnent un numéro de membre seront mis à jour en utilisant ce numéro.
    + Si une ligne du fichier mentionne un numéro de membre qui n'existe pas ou n'a pas de numéro de membre, l'import échouera. +
    + + {input type="radio" name="mode" value="auto" label="Automatique : créer ou mettre à jour en utilisant le numéro de membre" required=true} +
    + Met à jour la fiche d'un membre si son numéro existe, sinon crée un membre si le numéro de membre indiqué n'existe pas ou n'est pas renseigné. +
    + +
    +
    + +

    + {csrf_field key=$csrf_key} + {button type="submit" name="load" label="Charger le fichier" shape="right" class="main"} +

    +{/if} + + +
    + +{include file="_foot.tpl"} \ No newline at end of file diff --git a/src/templates/users/index.tpl b/src/templates/users/index.tpl new file mode 100644 index 0000000..672e0cf --- /dev/null +++ b/src/templates/users/index.tpl @@ -0,0 +1,111 @@ +{include file="_head.tpl" current="users"} + +{include file="users/_nav.tpl" current="index"} + +{if isset($_GET['sent'])} +

    Le message a bien été envoyé.

    +{/if} + +{if $_GET.msg == 'DELETE'} +

    Le membre a été supprimé.

    +{elseif $_GET.msg == 'DELETE_MULTI'} +

    Les membres sélectionnés ont été supprimés.

    +{elseif $_GET.msg == 'DELETE_FILES'} +

    Les fichiers des membres sélectionnés ont été supprimés.

    +{elseif $_GET.msg == 'CATEGORY_CHANGED'} +

    Les membres sélectionnés ont bien été changés de catégorie.

    +{/if} + +{if !empty($categories)} +
    + Filtrer par catégorie + +
    +{/if} + +
    +
    + Rechercher un membre + + {button type="submit" name="" title="Chercher" shape="search"} +
    +
    + +
    + +{if $list->count()} + {$list->getHTMLPagination()|raw} + + {include file="common/dynamic_list_head.tpl" check=$can_edit} + + {foreach from=$list->iterate() item="row"} + + {if $can_edit} + {input type="checkbox" name="selected[]" value=$row._user_id} + {/if} + {foreach from=$list->getHeaderColumns() key="key" item="value"} + $key; ?> + {if $key == 'number'} + + {link href="details.php?id=%d"|args:$row._user_id label=$value} + + {elseif $key == 'identity'} + {link href="details.php?id=%d"|args:$row._user_id label=$value} + {elseif $key == 'id_parent'} + + {if $value} + {link href="details.php?id=%d"|args:$value label=$row._parent_name} + {/if} + + {elseif $key == 'is_parent'} + + {if $value} + Oui + {/if} + + {else} + + {user_field name=$key value=$value user_id=$row._user_id files_href="details.php?id=%d"|args:$row._user_id} + + {/if} + {/foreach} + + + {linkbutton label="Fiche membre" shape="user" href="details.php?id=%d"|args:$row._user_id} + {if $session->canAccess($session::SECTION_USERS, $session::ACCESS_WRITE)} + {linkbutton label="Modifier" shape="edit" href="edit.php?id=%d"|args:$row._user_id} + {/if} + + + {/foreach} + + + + {if $session->canAccess($session::SECTION_USERS, $session::ACCESS_ADMIN)} + {include file="users/_list_actions.tpl" colspan=count($list->getHeaderColumns())+$can_edit+1} + {/if} + + + + {$list->getHTMLPagination()|raw} +{else} +

    + Aucun membre trouvé. +

    +{/if} + +
    + +{include file="_foot.tpl"} \ No newline at end of file diff --git a/src/templates/users/log.tpl b/src/templates/users/log.tpl new file mode 100644 index 0000000..863e04b --- /dev/null +++ b/src/templates/users/log.tpl @@ -0,0 +1,31 @@ +{if $params.history} + {include file="_head.tpl" title="Historique des modifications de la fiche membre"} +{else} + {include file="_head.tpl" title="Journal d'audit du membre"} +{/if} + +{if $params.id_user} + {include file="users/_nav_user.tpl" id=$params.id_user} +{elseif $params.history} + {include file="users/_nav_user.tpl" id=$params.history} +{else} + {include file="me/_nav.tpl" current="security"} +{/if} + +{if !$params.history} +

    + Cette page liste les tentatives de connexion, les modifications de mot de passe ou d'identifiant, et toutes les actions de création, suppression ou modification effectuées par ce membre. +

    +{/if} + +{if $list->count()} + {include file="users/_log_list.tpl"} +{else} +

    + Aucune activité trouvée. +

    +{/if} + + + +{include file="_foot.tpl"} \ No newline at end of file diff --git a/src/templates/users/mailing/_nav.tpl b/src/templates/users/mailing/_nav.tpl new file mode 100644 index 0000000..0db635d --- /dev/null +++ b/src/templates/users/mailing/_nav.tpl @@ -0,0 +1,17 @@ + diff --git a/src/templates/users/mailing/block.tpl b/src/templates/users/mailing/block.tpl new file mode 100644 index 0000000..de22075 --- /dev/null +++ b/src/templates/users/mailing/block.tpl @@ -0,0 +1,17 @@ +{include file="_head.tpl" title="Désinscription d'adresse" current="users/mailing"} + +
    +
    + Désinscrire une adresse +

    Désinscrire l'adresse {$address} ?

    +

    + Une fois cette adresse désinscrite, elle ne pourra plus recevoir aucun message de votre association (rappels, notifications, messages collectifs, etc.). +

    +

    + {csrf_field key=$csrf_key} + {button type="submit" name="send" label="Désinscrire cette adresse" shape="right" class="main"} +

    +
    +
    + +{include file="_foot.tpl"} \ No newline at end of file diff --git a/src/templates/users/mailing/delete.tpl b/src/templates/users/mailing/delete.tpl new file mode 100644 index 0000000..d038ba5 --- /dev/null +++ b/src/templates/users/mailing/delete.tpl @@ -0,0 +1,8 @@ +{include file="_head.tpl" title="Supprimer un envoi de message collectif" current="users/mailing"} + +{include file="common/delete_form.tpl" + legend="Supprimer ce message collectif ?" + warning="Êtes-vous sûr de vouloir supprimer le message « %s » ?"|args:$mailing.subject + info="La liste des destinataires sera également supprimée."} + +{include file="_foot.tpl"} \ No newline at end of file diff --git a/src/templates/users/mailing/details.tpl b/src/templates/users/mailing/details.tpl new file mode 100644 index 0000000..1f59db6 --- /dev/null +++ b/src/templates/users/mailing/details.tpl @@ -0,0 +1,55 @@ +{include file="_head.tpl" title="Message collectif : %s"|args:$mailing.subject current="users/mailing"} + +{include file="./_nav.tpl" current="details"} + +{if $sent} +

    L'envoi du message a bien commencé. Il peut prendre quelques minutes avant d'avoir été expédié à tous les destinataires.

    +{/if} + +{form_errors} + +
    +
    + {if $mailing.sent} +
    Envoyé le
    +
    {$mailing.sent|date_long:true}
    + {else} +
    Statut
    +
    + Brouillon
    + {linkbutton shape="edit" label="Modifier" href="write.php?id=%d"|args:$mailing.id} + {linkbutton shape="delete" label="Supprimer" href="delete.php?id=%d"|args:$mailing.id} + {if $mailing.body} + {button shape="right" label="Envoyer" class="main" name="send" type="submit"} + {/if} +
    +
    Expéditeur
    +
    + {$mailing->getFrom()}
    +
    + {/if} + {if $mailing.target_type} +
    Cible
    +
    + {$mailing->getTargetTypeLabel()} — {$mailing.target_label} +
    + {/if} +
    Destinataires
    +
    + {{%n destinataire}{%n destinataires} n=$mailing->countRecipients()}
    + {linkbutton shape="users" label="Voir la liste des destinataires" href="recipients.php?id=%d"|args:$mailing.id} +
    +
    Sujet
    +
    {$mailing.subject}
    +
    Message
    +
    {$mailing.body}
    +
    Prévisualisation
    +
    {linkbutton shape="eye" label="Prévisualiser le message" href="?id=%d&preview"|args:$mailing.id target="_dialog"}
    + (Un destinataire sera choisi au hasard.)
    +
    +
    Note : la prévisualisation peut différer du rendu final, selon le logiciel utilisé par vos destinataires pour lire leurs messages.
    +
    + {csrf_field key=$csrf_key} +
    + +{include file="_foot.tpl"} \ No newline at end of file diff --git a/src/templates/users/mailing/index.tpl b/src/templates/users/mailing/index.tpl new file mode 100644 index 0000000..03c7812 --- /dev/null +++ b/src/templates/users/mailing/index.tpl @@ -0,0 +1,46 @@ +{include file="_head.tpl" title="Messages collectifs" current="users/mailing"} + + + +{if $_GET.msg == 'DELETE'} +

    Le message a bien été supprimé.

    +{/if} + +{if !$list->count()} +

    Aucun message collectif n'a été écrit.
    + {linkbutton shape="plus" label="Écrire un nouveau message" href="new.php" target="_dialog"} +

    +{else} + {include file="common/dynamic_list_head.tpl"} + + {foreach from=$list->iterate() item="row"} + + {link href="details.php?id=%d"|args:$row.id label=$row.subject} + {$row.nb_recipients} + {if $row.sent}{$row.sent|relative_date:true}{else}Brouillon{/if} + + {linkbutton shape="eye" label="Ouvrir" href="details.php?id=%d"|args:$row.id} + {if !$row.sent} + {linkbutton shape="edit" label="Modifier" href="write.php?id=%d"|args:$row.id} + {/if} + {linkbutton shape="delete" label="Supprimer" href="delete.php?id=%d"|args:$row.id target="_dialog"} + + + {/foreach} + + + + {$list->getHTMLPagination()|raw} +{/if} + + +{include file="_foot.tpl"} \ No newline at end of file diff --git a/src/templates/users/mailing/new.tpl b/src/templates/users/mailing/new.tpl new file mode 100644 index 0000000..767e95d --- /dev/null +++ b/src/templates/users/mailing/new.tpl @@ -0,0 +1,79 @@ +{include file="_head.tpl" title="Nouveau message collectif" current="users/mailing"} + +{form_errors} + +
    +{if !$target_type} +
    + Sujet du message +
    + {input type="text" required="true" label="Sujet du message" name="subject" class="full-width"} +
    +
    +
    + Qui doit recevoir ce message ? +
    + {input type="radio-btn" name="target_type" value="field" label="Membres correspondant à une case à cocher (sauf ceux appartenant à une catégorie cachée)" required=true help="Par exemple les membres inscrits à la lettre d'information."} + {input type="radio-btn" name="target_type" value="all" label="Tous les membres (sauf ceux appartenant à une catégorie cachée)" required=true} + {input type="radio-btn" name="target_type" value="category" label="Membres d'une seule catégorie" required=true} + {input type="radio-btn" name="target_type" value="service" label="Membres inscrits à une activité, et à jour" required=true help="Les membres dont l'inscription a expiré ne recevront pas de message."} + {input type="radio-btn" name="target_type" value="search" label="Membres renvoyés par une recherche enregistrée" required=true} +
    +
    +

    + {csrf_field key=$csrf_key} + {button type="submit" name="step2" label="Continuer" shape="right" class="main"} +

    +{elseif $target_type == 'field'} +
    + Quel champ de la fiche membre ? +
    + {foreach from=$list item="field"} + {input type="radio" name="target_value" value=$field.name label=$field.label help="%d membres"|args:$field.count} + {input type="hidden" name="labels[%s]"|args:$field.name default=$field.label} + {/foreach} +
    +
    +{elseif $target_type == 'category'} +
    + Quelle catégorie ? +
    + {foreach from=$list item="cat"} + {input type="radio" name="target_value" value=$cat.id label=$cat.name help="%d membres"|args:$cat.count} + {input type="hidden" name="labels[%s]"|args:$cat.id default=$cat.name} + {/foreach} +
    +
    +{elseif $target_type == 'service'} +
    + Quelle activité ? +
    + {foreach from=$list item="service"} + {input type="radio" name="target_value" value=$service.id label=$service.label help="%d membres"|args:$service.nb_users_ok} + {input type="hidden" name="labels[%s]"|args:$service.id default=$service.label} + {/foreach} +
    +
    +{elseif $target_type == 'search'} +
    + Quelle recherche utiliser ? +
    + {foreach from=$list item="search"} + {input type="radio" name="target_value" value=$search.id label=$search.label help="%d membres"|args:$search.count} + {input type="hidden" name="labels[%s]"|args:$search.id default=$search.label} + {/foreach} +
    +
    +{/if} +{if $target_type} +

    Note : le nombre de membres affiché ne prend pas en compte les membres qui ne disposent pas d'adresse e-mail, ou qui se sont désinscrits. Le nombre de destinataires réels sera affiché avant envoi.

    +

    + {input type="hidden" name="subject"} + {input type="hidden" name="target_type" default=$target_type} + {csrf_field key=$csrf_key} + {button type="submit" name="step3" label="Créer" shape="right" class="main"} +

    +{/if} +
    + +{include file="_foot.tpl"} \ No newline at end of file diff --git a/src/templates/users/mailing/optout.tpl b/src/templates/users/mailing/optout.tpl new file mode 100644 index 0000000..290cef8 --- /dev/null +++ b/src/templates/users/mailing/optout.tpl @@ -0,0 +1,40 @@ +{include file="_head.tpl" title="Désinscriptions" current="users/mailing"} + +{include file="./_nav.tpl" current="optout"} + +{if isset($_GET['sent'])} +

    + Un message de demande de confirmation a bien été envoyé. Le destinataire doit désormais cliquer sur le lien dans ce message. +

    +{/if} + +{if !$list->count()} +

    Aucune adresse e-mail n'a demandé à être désinscrite pour le moment.

    +{else} + {include file="common/dynamic_list_head.tpl"} + + {foreach from=$list->iterate() item="row"} + + {link href="!users/details.php?id=%d"|args:$row.user_id label=$row.identity} + {$row.email} + {$row.status} + {$row.sent_count} + {$row.last_sent|date} + + {if $row.email && $row.optout} + {linkbutton target="_dialog" label="Rétablir" href="!users/mailing/verify.php?address=%s"|args:$row.email shape="check"} + {elseif $row.email && $row.target_type} + {linkbutton target="_dialog" label="Supprimer" href="!users/mailing/optout_delete.php?address=%s"|args:$row.email shape="delete"} + {/if} + + + + {/foreach} + + + + {$list->getHTMLPagination()|raw} + +{/if} + +{include file="_foot.tpl"} \ No newline at end of file diff --git a/src/templates/users/mailing/recipient_data.tpl b/src/templates/users/mailing/recipient_data.tpl new file mode 100644 index 0000000..d3e93a1 --- /dev/null +++ b/src/templates/users/mailing/recipient_data.tpl @@ -0,0 +1,23 @@ +{include file="_head.tpl" title="Données du destinataire" current="users/mailing"} + +

    Vous pouvez copier la variable (colonne de gauche) dans le corps du message : + elle sera remplacée dans le message par le contenu (colonne à droite) spécifique à chaque destinataire.

    + + + + + + + + + + {foreach from=$data key="name" item="value"} + + + + + {/foreach} + +
    VariableContenu
    {ldelim}{ldelim}${$name}{rdelim}{rdelim}{$value|escape|nl2br}
    + +{include file="_foot.tpl"} \ No newline at end of file diff --git a/src/templates/users/mailing/recipients.tpl b/src/templates/users/mailing/recipients.tpl new file mode 100644 index 0000000..7a00669 --- /dev/null +++ b/src/templates/users/mailing/recipients.tpl @@ -0,0 +1,54 @@ +{include file="_head.tpl" title="Destinataires du message collectif : %s"|args:$mailing.subject current="users/mailing"} + +{include file="./_nav.tpl" current="details"} + +

    + {linkbutton shape="left" label="Retour au message" href="details.php?id=%d"|args:$mailing.id} + {exportmenu} +

    + +{if $mailing.anonymous} +

    + Les informations personnelles des destinataires ont été supprimées automatiquement après un délai de six mois, conformément au RGPD. +

    +{else} +

    + Les informations personnelles des destinataires seront supprimées automatiquement après un délai de six mois, conformément au RGPD. +

    +{/if} + +{form_errors} +
    + {include file="common/dynamic_list_head.tpl"} + {foreach from=$list->iterate() item="r"} + + {$r.email} + {$r.name} + + {if $r.status} + {$r.status} + {/if} + + + {if $r.has_extra_data} + {linkbutton shape="menu" label="Données" href="recipient_data.php?id=%d&r=%d"|args:$mailing.id:$r.id target="_dialog"} + {/if} + {if $r.id_user} + {linkbutton shape="user" label="Fiche membre" href="!users/details.php?id=%d"|args:$r.id_user} + {/if} + {if !$mailing.sent} + {button shape="delete" label="Supprimer" name="delete" value=$r.id type="submit"} + {/if} + {if !$mailing.anonymous && $r.email} + {linkbutton href="details.php?id=%d&preview=%d"|args:$mailing.id:$r.id label="Prévisualiser" shape="eye" target="_dialog"} + {/if} + + + {/foreach} + + + {csrf_field key=$csrf_key} + {$list->getHTMLPagination()|raw} +
    + +{include file="_foot.tpl"} \ No newline at end of file diff --git a/src/templates/users/mailing/rejected.tpl b/src/templates/users/mailing/rejected.tpl new file mode 100644 index 0000000..d872442 --- /dev/null +++ b/src/templates/users/mailing/rejected.tpl @@ -0,0 +1,74 @@ +{include file="_head.tpl" title="Adresses rejetées" current="users/mailing"} + +{include file="./_nav.tpl" current="rejected"} + +{if isset($_GET['sent'])} +

    + Un message de demande de confirmation a bien été envoyé. Le destinataire doit désormais cliquer sur le lien dans ce message. +

    +{elseif isset($_GET['forced'])} +

    + La file d'attente a été envoyée. +

    +{/if} + +
    +

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

    +
    + +{if !$list->count()} +

    Aucune adresse e-mail n'a été rejetée pour le moment. Cette page présentera les adresses e-mail invalides ou qui ont demandé à se désinscrire.

    +{else} + {include file="common/dynamic_list_head.tpl"} + + {foreach from=$list->iterate() item="row"} + + {link href="!users/details.php?id=%d"|args:$row.user_id label=$row.identity} + {$row.email} + {$row.status} + {$row.sent_count} + {$row.fail_log|escape|nl2br} + {$row.last_sent|date} + + {if $row.email && $row.last_sent < $limit_date} + email); ?> + {linkbutton target="_dialog" label="Rétablir" href="!users/mailing/verify.php?address=%s"|args:$email shape="check"} + {/if} + + + + {/foreach} + + + + {$list->getHTMLPagination()|raw} + +
    +

    Statuts possibles d'une adresse e-mail :

    +
    + {* +
    Vérifiée
    +
    L'adresse a déjà reçu un message et a été vérifiée manuellement par le destinataire.
    + *} +
    Invalide
    +
    L'adresse n'existe pas ou plus. Il n'est pas possible de lui envoyer des messages.
    +
    Trop d'erreurs
    +
    Le service destinataire a renvoyé une erreur temporaire plus de {$max_fail_count} fois.
    Cela arrive par exemple si vos messages sont vus comme du spam trop souvent, ou si la boîte mail destinataire est pleine. Cette adresse ne recevra plus de message.
    +
    +

    + Il est possible de rétablir la réception de messages pour les adresses invalides après un délai d'un mois, et les adresses désinscrites immédiatement, en cliquant sur le bouton "Rétablir" qui enverra un message de validation à la personne. +

    +
    + +{/if} + +{include file="_foot.tpl"} \ No newline at end of file diff --git a/src/templates/users/mailing/verify.tpl b/src/templates/users/mailing/verify.tpl new file mode 100644 index 0000000..a207d01 --- /dev/null +++ b/src/templates/users/mailing/verify.tpl @@ -0,0 +1,28 @@ +{include file="_head.tpl" title="Vérification d'adresse" current="users/mailing"} + +
    +
    + Demander la vérification de l'adresse + {if $email.optout} +

    + Si le membre a cliqué par erreur sur le lien de désinscription, il est possible de rétablir l'envoi des messages.
    + Le membre recevra alors un message contenant un lien pour se réinscrire. +

    + {elseif $email->hasReachedFailLimit()} +

    + Si l'adresse du membre a rencontré trop d'erreurs (boîte mail pleine par exemple), il est possible de rétablir l'envoi des messages.
    + Le membre recevra alors un message contenant un lien pour valider son adresse. +

    + {/if} +

    + Attention, n'utiliser cette procédure qu'à la demande du membre.
    + En cas d'absence de consentement du membre, les messages aux autres membres pourront être bloqués par les serveurs destinataires. +

    +

    + {csrf_field key=$csrf_key} + {button type="submit" name="send" label="Envoyer un message de vérification" shape="right" class="main"} +

    +
    +
    + +{include file="_foot.tpl"} \ No newline at end of file diff --git a/src/templates/users/mailing/write.tpl b/src/templates/users/mailing/write.tpl new file mode 100644 index 0000000..eb7f663 --- /dev/null +++ b/src/templates/users/mailing/write.tpl @@ -0,0 +1,49 @@ +{include file="_head.tpl" title="Message collectif" current="users/mailing" hide_title=true} + +{form_errors} + +
    + +
    + Modifier le message collectif +

    + {input type="text" name="subject" required=true class="full-width" placeholder="Sujet du message…" source=$mailing} +

    +
    +

    + Expéditeur : {$config.org_name} <{$config.org_email}> + {button label="Modifier" shape="edit" id="f_edit_sender"} +

    +
    + {input type="text" required=true name="sender_name" source=$mailing label="Nom de l'expéditeur" placeholder="Nom de l'expéditeur"}   + {input type="email" required=true name="sender_email" source=$mailing label="Adresse e-mail de l'expéditeur" placeholder="Adresse e-mail de l'expéditeur"} +
    +
    +
    + +
    + {input type="textarea" name="content" cols=35 rows=25 required=true class="full-width" + data-attachments=0 data-savebtn=0 data-preview-url="!users/mailing/write.php?id=%s&preview"|local_url|args:$mailing.id data-format="markdown" placeholder="Contenu du message…" default=$mailing.body} +
    + +

    + {csrf_field key=$csrf_key} + {button type="submit" name="save" label="Enregistrer" shape="right" class="main"} +

    + +
    + + + + +{include file="_foot.tpl"} \ No newline at end of file diff --git a/src/templates/users/message.tpl b/src/templates/users/message.tpl new file mode 100644 index 0000000..93d5714 --- /dev/null +++ b/src/templates/users/message.tpl @@ -0,0 +1,27 @@ +{include file="_head.tpl" title="Contacter un membre" current="membres"} + +{form_errors} + +
    +
    + Message +
    +
    Expéditeur
    + {input type="radio" name="sender" value="self" default="self" required=true label="Membre : %s"|args:$self->getNameAndEmail()} + {input type="radio" name="sender" value="org" default="self" required=true label='Association : "%s" <%s>'|args:$config.org_name:$config.org_email} +
    Destinataire
    +
    {$recipient->getNameAndEmail()}
    + {input type="text" name="subject" required=true label="Sujet" class="full-width"} + {input type="textarea" name="message" required=true label="Message" rows=15 class="full-width"} + {input type="checkbox" name="send_copy" value=1 label="Recevoir par e-mail une copie du message envoyé"} +
    +
    + +

    + {csrf_field key=$csrf_key} + {button type="submit" name="send" label="Envoyer" shape="mail" class="main"} +

    +
    + + +{include file="_foot.tpl"} \ No newline at end of file diff --git a/src/templates/users/new.tpl b/src/templates/users/new.tpl new file mode 100644 index 0000000..75b6f74 --- /dev/null +++ b/src/templates/users/new.tpl @@ -0,0 +1,42 @@ +{include file="_head.tpl" title="Ajouter un membre" current="users/new"} + +{form_errors} + +
    + +{if $is_duplicate} +

    + Attention : un membre existe déjà avec ce nom.
    + {linkbutton shape="user" href="details.php?id=%d"|args:$is_duplicate label="Voir la fiche du membre existant" target="_dialog"}
    + {button shape="right" label="Ce n'est pas un doublon, créer ce membre" name="save" value="anyway" type="submit"} +

    +{/if} + + + +
    + Fiche du membre +
    + {foreach from=$fields item="field"} + {edit_user_field context="admin_new" field=$field user=$user} + {/foreach} +
    +
    + +

    + {csrf_field key=$csrf_key} + {button type="submit" name="save" label="Créer ce membre" shape="right" class="main"} +

    + +
    + +{include file="_foot.tpl"} \ No newline at end of file diff --git a/src/templates/users/search.tpl b/src/templates/users/search.tpl new file mode 100644 index 0000000..c921c08 --- /dev/null +++ b/src/templates/users/search.tpl @@ -0,0 +1,126 @@ +{include file="_head.tpl" title="Recherche de membre" current="users"} + +{include file="users/_nav.tpl" current="search"} + +
    + +{include file="common/search/advanced.tpl"} + +{if $list !== null} + {if $list->count() > 0} +

    {exportmenu form=true name="_dl_export" class="menu-btn-right"}

    + {/if} + +

    {$list->count()} membres trouvés pour cette recherche.

    + + {$list->getHTMLPagination(true)|raw} + + {include file="common/dynamic_list_head.tpl" check=$is_admin use_buttons=true} + + {foreach from=$list->iterate() item="row"} + + {if $is_admin} + {input type="checkbox" name="selected[]" value=$row.id} + {/if} + {foreach from=$list->getHeaderColumns() key="name" item="label"} + + {user_field name=$name value=$row->$name link_name_id=$row.id files_href="!users/details.php?id=%d"|args:$row.id} + + {/foreach} + + {linkbutton shape="user" label="Fiche membre" href="!users/details.php?id=%d"|args:$row.id} + {if $session->canAccess($session::SECTION_USERS, $session::ACCESS_WRITE)} + {linkbutton shape="edit" label="Modifier" href="!users/edit.php?id=%d"|args:$row.id} + {/if} + + + {/foreach} + + {if $is_admin} + {include file="users/_list_actions.tpl" colspan=$list->countHeaderColumns()+1} + {/if} + + + {$list->getHTMLPagination(true)|raw} + +
    + +{elseif $count} + +

    {exportmenu form=true name="_export" class="menu-btn-right"}

    + +

    + {$count} résultats trouvés pour cette recherche. +

    + {if !empty($has_limit)} +

    Le nombre de résultats affichés est limité.

    + {/if} + + + +
    + + + + + {if $is_admin && $id_column !== false} + + {/if} + {foreach from=$header item="column"} + + {/foreach} + {if $id_column !== false} + + {/if} + + + + {foreach from=$results item="row"} + + {if $is_admin && $id_column !== false} + + {/if} + {foreach from=$header key="i" item="label"} + + {/foreach} + {if $id_column !== false} + + + {/if} + + {/foreach} + + + {if $is_admin && $id_column !== false} + {include file="users/_list_actions.tpl" colspan=$header_count+2} + {/if} +
    {$column}
    {input type="checkbox" name="selected[]" value=$row[$id_column]} + + {if $id_column !== false} + {user_field name=$name value=$value} + {else} + {$value} + {/if} + + {linkbutton shape="user" href="!users/details.php?id=%d"|args:$id label="Fiche membre"} +
    + +
    + +{elseif $count === 0} + +

    Aucun résultat trouvé pour cette recherche.

    + +{/if} + + + +{include file="_foot.tpl"} \ No newline at end of file diff --git a/src/templates/users/selector.tpl b/src/templates/users/selector.tpl new file mode 100644 index 0000000..b150ef0 --- /dev/null +++ b/src/templates/users/selector.tpl @@ -0,0 +1,63 @@ +{include file="_head.tpl" title="Sélectionner un compte"} + +
    +

    + + +

    +
    + +{if $list} + + + {foreach from=$list->iterate() item="row"} + + + + + {/foreach} + +
    + {$row.identity} + + +
    +{/if} + +{literal} + +{/literal} + +{include file="_foot.tpl"} \ No newline at end of file diff --git a/src/templates/web/_attach.tpl b/src/templates/web/_attach.tpl new file mode 100644 index 0000000..f3eaf2c --- /dev/null +++ b/src/templates/web/_attach.tpl @@ -0,0 +1,83 @@ +{include file="_head.tpl" title="Inclure un fichier"} + +{form_errors} + +
    +
    + Téléverser des fichiers +
    + {input type="file" name="file[]" multiple=true label="Fichiers à envoyer" data-enhanced=1} +
    +

    + {csrf_field key=$csrf_key} + {button type="submit" name="upload" label="Envoyer" shape="upload" class="main"} +

    +
    +
    + + + +{if !empty($images)} +
    +{foreach from=$images item="file"} + +{/foreach} + +{/if} + +{if !empty($files)} + + + {foreach from=$files item="file"} + + + + + + {/foreach} + +
    {$file.name}{$file.mime}, {$file.size|size_in_bytes} +
    + {linkbutton shape="plus" label="Insérer" href=$file->url() data-name=$file.name data-insert="file"} + {linkbutton shape="download" label="Télécharger" href=$file->url() target="_blank"} + {csrf_field key=$csrf_key} + + +
    +
    +{/if} + +{include file="_foot.tpl"} \ No newline at end of file diff --git a/src/templates/web/_history.tpl b/src/templates/web/_history.tpl new file mode 100644 index 0000000..f570b33 --- /dev/null +++ b/src/templates/web/_history.tpl @@ -0,0 +1,70 @@ + +
    +
    +

    {$page.title}

    + {if isset($version)} +

    Version du {$version.date|date_short:true}

    + {else} +

    Historique des modifications

    + {/if} +

    + {if isset($version)} + {linkbutton shape="left" label="Retour" href="?id=%d"|args:$page.id} + {linkbutton shape="history" label="Historique" href="?id=%d&history=list"|args:$page.id} + {else} + {linkbutton shape="left" label="Retour" href="?id=%d"|args:$page.id} + {/if} +

    +
    + + {if isset($versions)} + {if $versions->count()} + {include file="common/dynamic_list_head.tpl" list=$versions} + {foreach from=$versions->iterate() item="version"} + + {$version.date|date_short:true} + {if !$version.author}Membre supprimé{else}{$version.author}{/if} + + {$version.size} caractères + + + {if $version.changes < 0} + {$version.changes} + {else} + +{$version.changes} + {/if} + + + {linkbutton shape="menu" label="Modifications" href="?id=%d&history=%d"|args:$page.id:$version.id} + {linkbutton shape="reload" label="Restaurer" href="!web/edit.php?id=%d&restore=%d"|args:$page.id:$version.id} + + + {/foreach} + + + {else} +

    Aucun historique n'a été trouvé pour cette page.

    + {/if} + {elseif isset($version)} + + + + {if $view === 'render'} +
    + {$page->preview($version.content)|raw} +
    + {elseif $view === 'raw'} +
    +
    {$version.content}
    +
    + {else} + {diff old=$version.previous_content new=$version.content context=5 old_label="Ancienne version" new_label="Nouvelle version"} + {/if} + {/if} +
    \ No newline at end of file diff --git a/src/templates/web/_list.tpl b/src/templates/web/_list.tpl new file mode 100644 index 0000000..0ffc460 --- /dev/null +++ b/src/templates/web/_list.tpl @@ -0,0 +1,26 @@ + + +{include file="common/dynamic_list_head.tpl"} + + {foreach from=$list->iterate() item="p"} + + {link label=$p.title href="?id=%d"|args:$p.id} + {$p.published|relative_date} + {$p.modified|relative_date:true} + + {linkbutton shape="image" label="Lire" href="./?id=%d"|args:$p.id} + {if $session->canAccess($session::SECTION_WEB, $session::ACCESS_WRITE)} + {linkbutton shape="edit" label="Éditer" href="edit.php?id=%d"|args:$p.id} + {if $session->canAccess($session::SECTION_WEB, $session::ACCESS_ADMIN)} + {linkbutton shape="delete" label="Supprimer" target="_dialog" href="delete.php?id=%d"|args:$p.id} + {/if} + {/if} + + + {/foreach} + + + +{$list->getHTMLPagination()|raw} \ No newline at end of file diff --git a/src/templates/web/_page.tpl b/src/templates/web/_page.tpl new file mode 100644 index 0000000..13f72bf --- /dev/null +++ b/src/templates/web/_page.tpl @@ -0,0 +1,76 @@ +{if $excerpt && $page->requiresExcerpt() && !isset($_GET['full'])} + excerpt(); $long = true; ?> +{else} + render(true); $long = false; ?> +{/if} + + +
    +
    +

    {$page.title}

    + {if $can_edit} +

    + {linkbutton href="edit.php?id=%s"|args:$page.id label="Éditer" shape="edit"} + {if $session->canAccess($session::SECTION_WEB, $session::ACCESS_ADMIN)} + {linkbutton shape="delete" label="Supprimer" target="_dialog" href="delete.php?id=%d"|args:$page.id} + {/if} +

    + {/if} + +

    + + {if $page->isCategory()} + {icon shape="folder"} Catégorie + {else} + {icon shape="document"} Page + {/if} + + {if $page.status == $page::STATUS_ONLINE} + {icon shape="eye"} En ligne + {else} + {icon shape="eye-off"} Brouillon + {/if} + Publié : {$page.published|relative_date:true} + Modifié : {$page.modified|relative_date:true} + {if $page->isOnline()} +
    {link href=$page->url() label=$page->url() target="_blank"} + {/if} +

    + + {if $session->canAccess($session::SECTION_WEB, $session::ACCESS_WRITE)} +

    + + {if $session->canAccess($session::SECTION_WEB, $session::ACCESS_ADMIN) && !$page->hasSubPages()} + {if !$page->isCategory()} + {linkbutton href="?id=%d&toggle_type"|args:$page.id label="Transformer en catégorie" shape="reset"} + {else} + {linkbutton href="?id=%s&toggle_type"|args:$page.id label="Transformer en page simple" shape="reset"} + {/if} + {/if} + {linkbutton shape="history" label="Historique" href="?id=%d&history=list"|args:$page.id} + +

    + {/if} +
    + + {if trim($page.content)} +
    + {$text|raw} + {if $excerpt && $long} +

    {linkbutton href="?id=%d&full"|args:$page.id label="Lire la suite" shape="right"}

    + {/if} +
    +
    + {/if} + + {if !($excerpt && $long)} + listAttachments(); ?> + +
    +

    Fichiers joints

    + + {include file="common/files/_context_list.tpl" files=$files edit=$can_edit path=$page->dir_path() use_trash=false} +
    + {/if} + +
    \ No newline at end of file diff --git a/src/templates/web/_selector.tpl b/src/templates/web/_selector.tpl new file mode 100644 index 0000000..df195fa --- /dev/null +++ b/src/templates/web/_selector.tpl @@ -0,0 +1,47 @@ +{include file="_head.tpl" title="Sélectionner la catégorie" current="web"} + +
    + + + + + +{literal} + +{/literal} + +{include file="_foot.tpl"} \ No newline at end of file diff --git a/src/templates/web/all.tpl b/src/templates/web/all.tpl new file mode 100644 index 0000000..9982549 --- /dev/null +++ b/src/templates/web/all.tpl @@ -0,0 +1,35 @@ +{include file="_head.tpl" title="Toutes les pages du site web" current="web"} + + + +{if $list->count()} + +

    {exportmenu name="_dl_export" class="menu-btn-right"}

    + + {include file="common/dynamic_list_head.tpl"} + + {foreach from=$list->iterate() item="p"} + + {link label=$p.title href="./?id=%d"|args:$p.id} + {$p.path} + {if $p.draft}Brouillon{/if} + {$p.published|relative_date} + {$p.modified|relative_date:true} + + {if $can_edit} + {linkbutton shape="edit" label="Éditer" href="edit.php?id=%d"|args:$p.id} + {/if} + + + {/foreach} + + + + {$list->getHTMLPagination()|raw} +{else} +

    Il n'y a aucune page dans le site.

    +{/if} + +{include file="_foot.tpl"} \ No newline at end of file diff --git a/src/templates/web/delete.tpl b/src/templates/web/delete.tpl new file mode 100644 index 0000000..1238600 --- /dev/null +++ b/src/templates/web/delete.tpl @@ -0,0 +1,9 @@ +{include file="_head.tpl" title=$title current="web"} + +{include file="common/delete_form.tpl" + legend=$title + warning="Êtes-vous sûr de vouloir supprimer « %s » ?"|args:$page.title + alert=$alert +} + +{include file="_foot.tpl"} \ No newline at end of file diff --git a/src/templates/web/edit.tpl b/src/templates/web/edit.tpl new file mode 100644 index 0000000..a68cf09 --- /dev/null +++ b/src/templates/web/edit.tpl @@ -0,0 +1,67 @@ +{include file="_head.tpl" title="Édition : %s"|args:$page.title current="web" hide_title=true} + +{form_errors} + +{if $show_diff} + {diff old=$my_content new=$page->content old_label="Votre version" new_label="Version enregistrée"} +{elseif $restored_version} +

    + Attention, le texte a été restauré depuis une version précédente, vous risquez d'écraser des modifications. +

    +{/if} + + +
    + Modification : {$page.title} +

    {input type="text" name="title" source=$page required=true class="full-width" placeholder="Titre" title="Modifier le titre" maxlength=200}

    +
    +
    {input type="list" name="id_parent" label="Catégorie" default=$parent target="!web/_selector.php?id_parent=%d&id_page=%d"|args:$page.id_parent:$page.id required=true}
    +
    {input type="datetime" name="date" label="Date" required=true default=$page.published}
    +
    {input type="select" name="format" required=true options=$formats source=$page label="Format"}
    +
    {input type="checkbox" name="status" value=$page::STATUS_DRAFT label="Brouillon" source=$page}
    + +
    + +
    +
    + {input type="textarea" name="content" cols="70" rows="20" source=$page data-attachments=1 data-savebtn=2 data-preview-url="!common/files/_preview.php?w=%d"|local_url|args:$page.id data-format="#f_format" data-id=$page.id} +
    +
    + +{* +
    + {$page->render()|raw} +
    + +
    +
    +*} + +
    + {* + + +

    + +

    + *} + +
    + {input type="text" label="Identifiant unique de la page" name="uri" default=$page.uri required=true help="Utilisé pour désigner l'adresse de la page sur le site. Ne peut comporter que des lettres, chiffres et tirets." pattern="[A-Za-z0-9_\-]+" class="full-width" maxlength=150} +
    +
    + +

    + {csrf_field key=$csrf_key} + + {button type="submit" name="save" label="Enregistrer et fermer" shape="right" class="main"} +

    + +
    + +{include file="_foot.tpl"} \ No newline at end of file diff --git a/src/templates/web/index.tpl b/src/templates/web/index.tpl new file mode 100644 index 0000000..0c44116 --- /dev/null +++ b/src/templates/web/index.tpl @@ -0,0 +1,152 @@ +{include file="_head.tpl" title=$title current="web" hide_title=true} + + + +{if !$page && $session->canAccess($session::SECTION_WEB, $session::ACCESS_WRITE)} + +{elseif $page} + +{/if} + +{if !$page && $config.site_disabled && $session->canAccess($session::SECTION_CONFIG, $session::ACCESS_ADMIN)} +
    +

    + Le site public est désactivé. + {button shape="right" name="enable" value=1 type="submit" class="main" label="Activer le site"} + {csrf_field key=$csrf_key} +

    +
    +{/if} + +{if $_GET.check && !$page} + {if !empty($links_errors)} +
    + Des pages contiennent des liens qui mènent à des pages qui n'existent pas : +
      + {foreach from=$links_errors item="p"} +
    • {link href="?id=%d"|args:$p.id label=$p.title}
    • + {/foreach} +
    +
    + {else} +

    Aucune erreur n'a été détectée.

    + {/if} +{elseif !empty($links_errors)} +
    +

    Cette page contient des liens qui mènent à des pages internes qui n'existent pas ou ont été renommées :

    + + + + + + + + + {foreach from=$links_errors key="uri" item="link"} + + + + + {/foreach} + +
    Libellé du lienAdresse du lien
    {$link}{$uri}
    +

    Il est conseillé de modifier la page pour corriger les liens.

    +
    +{/if} + +{form_errors} + +{if $page && $_GET.history === 'list'} + {include file="./_history.tpl" versions=$page->listVersions()} +{elseif $page && $_GET.history} + {include file="./_history.tpl" version=$page->getVersion($_GET.history)} +{elseif $page} + {include file="./_page.tpl" excerpt=$page->isCategory()} +{/if} + +{if !$page || (!$_GET.history && $page->isCategory())} +
    + {if $session->canAccess($session::SECTION_WEB, $session::ACCESS_WRITE)} +

    + {if $page} + {assign var="label" value="Nouvelle sous-catégorie"} + {else} + {assign var="label" value="Nouvelle catégorie"} + {/if} + {linkbutton shape="plus" label=$label target="_dialog" href="new.php?type=%d&parent=%d"|args:$type_category:$page.id} +

    + {/if} +

    {if $page}Sous-catégories{else}Catégories{/if}

    +
    + + {if count($categories)} + + {elseif $page} +

    Il n'y a aucune sous-catégorie dans cette catégorie.

    + {else} +

    Il n'y a aucune catégorie.

    + {/if} + + {if $drafts->count()} +

    Brouillons

    + {include file="./_list.tpl" list=$drafts} + {/if} + +
    + {if $session->canAccess($session::SECTION_WEB, $session::ACCESS_WRITE)} +

    + {linkbutton shape="plus" label="Nouvelle page" target="_dialog" href="new.php?type=%d&parent=%d"|args:$type_page,$page.id} +

    + {/if} +

    Pages

    +
    + {if $pages->count()} + {include file="./_list.tpl" list=$pages} + {else} +

    Il n'y a aucune page dans cette catégorie.

    + {/if} +{/if} + + +{include file="_foot.tpl"} \ No newline at end of file diff --git a/src/templates/web/new.tpl b/src/templates/web/new.tpl new file mode 100644 index 0000000..519094f --- /dev/null +++ b/src/templates/web/new.tpl @@ -0,0 +1,22 @@ +{include file="_head.tpl" title=$title current="web"} + +{form_errors} + +
    + +
    + Informations générales +
    + {input type="text" name="title" required=true label="Titre"} +
    +
    + +

    + {csrf_field key=$csrf_key} + {button type="submit" name="create" label="Créer" shape="plus" class="main"} +

    + +
    + + +{include file="_foot.tpl"} \ No newline at end of file diff --git a/src/templates/web/search.tpl b/src/templates/web/search.tpl new file mode 100644 index 0000000..9c42732 --- /dev/null +++ b/src/templates/web/search.tpl @@ -0,0 +1,39 @@ +{include file="_head.tpl" title="Rechercher dans le site web" current="web"} + +
    +
    + Rechercher une page ou catégorie +

    + + {button type="submit" name="search" label="Chercher" shape="search" class="main"} +

    +
    +
    + +{if $query} +

    + {{%n résultat trouvé.}{%n résultats trouvés.} n=$results_count} +

    + +
    + {foreach from=$results item="result"} +
    +

    {$result.title}

    + {* +

    + +

    + *} +

    {$result.snippet|escape|restore_snippet_markup}

    +
    + {/foreach} +
    +{/if} + +{include file="_foot.tpl"} \ No newline at end of file diff --git a/src/www/.htaccess b/src/www/.htaccess new file mode 100644 index 0000000..a4eaaec --- /dev/null +++ b/src/www/.htaccess @@ -0,0 +1,133 @@ +Options -Indexes -Multiviews +FollowSymlinks + +DirectoryIndex index.php index.html + +# Some security + + RedirectMatch 404 _inc\.php + + +# Recommended, if you have xsendfile module +# see https://tn123.org/mod_xsendfile/ +# Also enable X-SendFile in config.local.php +# +# +# +# XSendFile On +# XSendFilePath /home/paheko/ +# +# + +# This is to avoid caching mismatch when using mod_deflate +# see https://github.com/symfony/symfony-docs/issues/12644 + + FileETag None + + +# Allow uploads up to 256 MB where it's required + + + php_value post_max_size 256M + php_value upload_max_filesize 256M + + + + php_value post_max_size 256M + php_value upload_max_filesize 256M + + + + + SetEnv PHP_VALUE "post_max_size=256M" + + # There is no way to pass multiple PHP ini settings via PHP_VALUE :-( + # so we use PHP_ADMIN_VALUE here. It works unless we have more than 2 settings to change. + SetEnv PHP_ADMIN_VALUE "upload_max_filesize=256M" + + + + + + AddDefaultCharset utf-8 + AddCharset utf-8 .html .css .js .txt + + RewriteEngine On + RewriteBase / + + RewriteRule \.cache - [R=404] + RewriteRule \.well-known/assetlinks.json - [R=404] + + # Stop rewrite for /admin URL, except for /admin/p/ (plugins) + RewriteCond %{REQUEST_URI} ^/?admin(?!/p/) + RewriteRule ^ - [END] + + # Skip directly to router if possible + # Do not try cache if method is not GET or HEAD + RewriteCond %{REQUEST_METHOD} !GET|HEAD [OR] + + # Do not try to get from cache if URL is private, or belongs to modules/plugins + RewriteCond %{REQUEST_URI} ^/admin|^/?(?:dav|wopi|p|m|api)/|\.php$ [OR] + + # NextCloud routes + RewriteCond %{REQUEST_URI} ^/?(?:remote\.php|index\.php|ocs|avatars|status\.php)/ [OR] + + # Private files are not part of the cache + RewriteCond %{REQUEST_URI} ^/?(?:documents|user|transaction|ext|attachments|versions)/ + + # Skip, go to router directly + RewriteRule ^ - [skip=8] + + # Store MD5 hashes in environment variables + RewriteCond %{REQUEST_URI} ^(.+)(?:\?|$) + RewriteRule ^ "-" [E=CACHE_URI:%1] + # Extract file extension (required for Apache to serve the correct mimetype) + RewriteCond %{REQUEST_URI} (\.[a-z0-9]+)(?:\?|$) + RewriteRule ^ "-" [E=CACHE_EXT:%1] + # If no extension, default to .html + RewriteCond %{REQUEST_URI} !\.[a-z0-9]+(?:\?|$) + RewriteRule ^ "-" [E=CACHE_EXT:.html] + RewriteCond expr "md5(%{ENV:CACHE_URI}) =~ /^(.+)$/" + RewriteRule ^ "-" [E=CACHE_URI_MD5:%1] + RewriteCond expr "md5(tolower(%{HTTP_HOST})) =~ /^((.{2}).+)$/" + RewriteRule ^ "-" [E=CACHE_HOST_MD5:%1,E=CACHE_HOST2_MD5:%2] + RewriteCond /.cache/%{ENV:CACHE_HOST_MD5}/%{ENV:CACHE_URI_MD5} (.+) + RewriteRule ^ "-" [E=CACHE_PATH:%1] + + # Serve symlinks for files + RewriteCond %{QUERY_STRING} ="" [OR] + RewriteCond %{QUERY_STRING} ^h=[a-f0-9]+$ + RewriteCond %{DOCUMENT_ROOT}%{ENV:CACHE_PATH}%{ENV:CACHE_EXT} -l + RewriteRule ^ %{ENV:CACHE_PATH}%{ENV:CACHE_EXT} [END] + + # Do not try cache for pages if user is logged-in + RewriteCond %{HTTP_COOKIE} !pko= + # Serve static HTML pages + RewriteCond %{QUERY_STRING} ="" + RewriteCond %{DOCUMENT_ROOT}%{ENV:CACHE_PATH}%{ENV:CACHE_EXT} -f + RewriteCond %{DOCUMENT_ROOT}%{ENV:CACHE_PATH}%{ENV:CACHE_EXT} !-l + RewriteRule ^ %{ENV:CACHE_PATH}%{ENV:CACHE_EXT} [END] + + # Redirect to router + RewriteRule ^ /_route.php [E=HTTP_AUTHORIZATION:%{HTTP:Authorization},END,QSA] + +# Sinon le reste marchera, sauf les clients OC/NC + + # FallbackResource has a bug before Apache 2.4.15, requiring to disable DirectoryIndex + # see https://bz.apache.org/bugzilla/show_bug.cgi?id=58292 + # and https://serverfault.com/questions/559067/apache-hangs-for-five-seconds-with-fallbackresource-when-accessing + DirectoryIndex disabled + DirectoryIndex index.php + + # Redirect non-existing URLs to the router + FallbackResource /_route.php + + # FallbackResource does not work for URLs ending with ".php" + # see https://stackoverflow.com/a/66136226 + ErrorDocument 404 /_route.php + + # NextCloud/ownCloud clients cannot work without mod_rewrite + + Redirect 501 /remote.php + Redirect 501 /status.php + + diff --git a/src/www/_route.php b/src/www/_route.php new file mode 100644 index 0000000..9e68ba8 --- /dev/null +++ b/src/www/_route.php @@ -0,0 +1,59 @@ + null, 'v' => null]); + + // RFC 8058 + if (!empty($_POST['Unsubscribe']) && $_POST['Unsubscribe'] == 'Yes') { + $email = Emails::getEmailFromOptout($params['un']); + + if (!$email) { + throw new UserException('Adresse email introuvable.'); + } + + $email->setOptout(); + $email->save(); + http_response_code(200); + echo 'Unsubscribe successful'; + return; + } + + Utils::redirect('!optout.php?' . http_build_query($params)); + return; +} + +// Call router +Router::route(); diff --git a/src/www/admin/_inc.php b/src/www/admin/_inc.php new file mode 100644 index 0000000..ef6457d --- /dev/null +++ b/src/www/admin/_inc.php @@ -0,0 +1,47 @@ +assign_by_ref('form', $form); + +$session = Session::getInstance(); +$config = Config::getInstance(); + +if (!defined('Paheko\LOGIN_PROCESS')) +{ + if (!$session->isLogged()) + { + if ($session->isOTPRequired()) + { + Utils::redirect(ADMIN_URL . 'login_otp.php'); + } + else + { + Utils::redirect(ADMIN_URL . 'login.php'); + } + } + + $tpl->assign('current', ''); + + $tpl->assign('plugins_menu', Extensions::listMenu($session)); +} + +// Make sure we allow frames to work +header('X-Frame-Options: SAMEORIGIN', true); diff --git a/src/www/admin/_serviceworker.js b/src/www/admin/_serviceworker.js new file mode 100644 index 0000000..7ccbdc1 --- /dev/null +++ b/src/www/admin/_serviceworker.js @@ -0,0 +1,4 @@ +self.addEventListener("fetch", function(event) { + // this is doing nothing on purpose + // this is only there to enable "add to home screen" feature in Chrome +}); \ No newline at end of file diff --git a/src/www/admin/acc/_inc.php b/src/www/admin/acc/_inc.php new file mode 100644 index 0000000..2f8178e --- /dev/null +++ b/src/www/admin/acc/_inc.php @@ -0,0 +1,44 @@ +requireAccess($session::SECTION_ACCOUNTING, $session::ACCESS_READ); +} + +$user = Session::getLoggedUser(); +$user_year = $user->getPreference('accounting_year'); + +if (!empty($_GET['set_year'])) { + $user->setPreference('accounting_year', (int)$_GET['set_year']); +} + +$current_year = null; + +// Apply user year +if ($user_year) { + // Check that the selected year is still valid + $current_year = Years::get($user_year); + + if (!$current_year || $current_year->closed) { + $current_year = null; + $user->setPreference('accounting_year', null); + } +} + +// Or just select the first open year +if (!$current_year) { + $current_year = Years::getCurrentOpenYear(); +} + +define('Paheko\CURRENT_YEAR_ID', $current_year ? $current_year->id() : null); + +$tpl->assign('current_year', $current_year); diff --git a/src/www/admin/acc/accounts/all.php b/src/www/admin/acc/accounts/all.php new file mode 100644 index 0000000..a242304 --- /dev/null +++ b/src/www/admin/acc/accounts/all.php @@ -0,0 +1,15 @@ + CURRENT_YEAR_ID]; +$tpl->assign('balance', Reports::getTrialBalance($criterias, true)); + +$tpl->display('acc/accounts/all.tpl'); diff --git a/src/www/admin/acc/accounts/deposit.php b/src/www/admin/acc/accounts/deposit.php new file mode 100644 index 0000000..c19928c --- /dev/null +++ b/src/www/admin/acc/accounts/deposit.php @@ -0,0 +1,67 @@ +requireAccess($session::SECTION_ACCOUNTING, $session::ACCESS_ADMIN); + +if (!CURRENT_YEAR_ID) { + Utils::redirect(ADMIN_URL . 'acc/years/?msg=OPEN'); +} + +$account = Accounts::get((int)qg('id')); + +if (!$account) { + throw new UserException("Le compte demandé n'existe pas."); +} + +$checked = f('deposit') ?: []; + +$journal = $account->getDepositJournal(CURRENT_YEAR_ID, $checked); +$transaction = new Transaction; +$transaction->id_year = CURRENT_YEAR_ID; +$transaction->id_creator = $session->getUser()->id; + +$form->runIf('save', function () use ($checked, $transaction, $journal) { + if (!count($checked)) { + throw new UserException('Aucune ligne n\'a été cochée, impossible de créer un dépôt. Peut-être vouliez-vous saisir un virement ?'); + } + + $transaction->importFromDepositForm(); + Transactions::saveDeposit($transaction, $journal->iterate(), $checked); + + Utils::redirect(ADMIN_URL . 'acc/transactions/details.php?id=' . $transaction->id()); +}, 'acc_deposit_' . $account->id()); + +// Uncheck everything if there was an error +if ($form->hasErrors()) { + $journal = $account->getDepositJournal(CURRENT_YEAR_ID); +} + +$date = new \DateTime; + +if ($date > $current_year->end_date) { + $date = $current_year->end_date; +} + +$target = $account::TYPE_BANK; + +$missing_balance = $account->getDepositMissingBalance(CURRENT_YEAR_ID); + +$journal->loadFromQueryString(); + +$tpl->assign(compact( + 'account', + 'journal', + 'date', + 'target', + 'checked', + 'missing_balance', + 'transaction' +)); + +$tpl->display('acc/accounts/deposit.tpl'); diff --git a/src/www/admin/acc/accounts/index.php b/src/www/admin/acc/accounts/index.php new file mode 100644 index 0000000..81b9b23 --- /dev/null +++ b/src/www/admin/acc/accounts/index.php @@ -0,0 +1,20 @@ +count(); + +$tpl->assign(compact('pending_count')); + +$tpl->assign('chart_id', $current_year->id_chart); +$tpl->assign('grouped_accounts', Reports::getClosingSumsFavoriteAccounts(['year' => CURRENT_YEAR_ID])); + +$tpl->display('acc/accounts/index.tpl'); diff --git a/src/www/admin/acc/accounts/journal.php b/src/www/admin/acc/accounts/journal.php new file mode 100644 index 0000000..ed56dc9 --- /dev/null +++ b/src/www/admin/acc/accounts/journal.php @@ -0,0 +1,70 @@ +assign('year', $year); +} + +// The account has a different chart after changing the current year: +// get back to the list of accounts to select a new account! +if ($account->id_chart != $year->id_chart) { + Utils::redirect(ADMIN_URL . 'acc/accounts/?chart_change'); +} + +// The account has a different chart after changing the current year: +// get back to the list of accounts to select a new account! +if ($account->id_chart != $year->id_chart) { + Utils::redirect(ADMIN_URL . 'acc/accounts/?chart_change'); +} + +$can_edit = $session->canAccess($session::SECTION_ACCOUNTING, $session::ACCESS_ADMIN) && !$year->closed; +$simple = !empty($session->user()->preferences->accounting_expert) ? false : true; + +// Use simplified view for favourite accounts +if (null === $simple) { + $simple = (bool) $account->type; +} + +$filter = new \stdClass; +$filter->start = Utils::get_datetime(qg('start')); +$filter->end = Utils::get_datetime(qg('end')); + +$list = $account->listJournal($year_id, $simple, $filter->start, $filter->end); +$list->setTitle(sprintf('Journal - %s - %s', $account->code, $account->label)); +$list->loadFromQueryString(); + +$sum = null; + +if (!$filter->start && !$filter->end) { + $sum = $account->getSum($year_id, $simple); +} + +$tpl->assign(compact('simple', 'year', 'account', 'list', 'sum', 'can_edit', 'filter')); + +$tpl->display('acc/accounts/journal.tpl'); diff --git a/src/www/admin/acc/accounts/reconcile.php b/src/www/admin/acc/accounts/reconcile.php new file mode 100644 index 0000000..0ef47e2 --- /dev/null +++ b/src/www/admin/acc/accounts/reconcile.php @@ -0,0 +1,109 @@ +requireAccess($session::SECTION_ACCOUNTING, $session::ACCESS_ADMIN); + +if (!CURRENT_YEAR_ID) { + Utils::redirect(ADMIN_URL . 'acc/years/?msg=OPEN'); +} + +$account = Accounts::get((int)qg('id')); + +if (!$account) { + throw new UserException("Le compte demandé n'existe pas."); +} + +// The account has a different chart after changing the current year: +// get back to the list of accounts to select a new account! +if ($account->id_chart != $current_year->id_chart) { + Utils::redirect(ADMIN_URL . 'acc/accounts/?chart_change'); +} + +$start = new \DateTime('first day of this month'); +$end = new \DateTime('last day of this month'); +$only = (bool) qg('only'); + +if (null !== qg('start') && null !== qg('end')) +{ + $start = \DateTime::createFromFormat('!d/m/Y', qg('start')); + $end = \DateTime::createFromFormat('!d/m/Y', qg('end')); + + if (!$start || !$end) { + $form->addError('La date donnée est invalide.'); + } +} + +if ($start < $current_year->start_date || $start > $current_year->end_date) { + $start = clone $current_year->start_date; +} + +if ($end < $current_year->start_date || $end > $current_year->end_date) { + $end = clone $current_year->end_date; +} + +if ($start > $end) { + $end = clone $start; +} + +$journal = $account->getReconcileJournal($current_year->id(), $start, $end, $only); + +// Enregistrement des cases cochées +$form->runIf(f('save') || f('save_next'), function () use ($journal, $start, $account, $only) { + Transactions::saveReconciled($journal, f('reconcile')); + + if (f('save')) { + Utils::redirect(Utils::getSelfURI()); + } + else { + $start->modify('+1 month'); + $url = sprintf('%sacc/accounts/reconcile.php?id=%s&start=%s&end=%s&only=%d', + ADMIN_URL, $account->id(), $start->format('01/m/Y'), $start->format('t/m/Y'), $only); + Utils::redirect($url); + } +}, 'acc_reconcile_' . $account->id()); + +$prev = clone $start; +$next = clone $start; +$prev->modify('-1 month'); +$next->modify('+1 month'); + +if ($next > $current_year->end_date) { + $next = null; +} + +if ($prev < $current_year->start_date) { + $prev = null; +} + +$self_uri = Utils::getSelfURI(false); + +if (null !== $prev) { + $prev = [ + 'date' => $prev, + 'url' => sprintf($self_uri . '?id=%d&start=%s&end=%s&only=%d', $account->id, $prev->format('01/m/Y'), $prev->format('t/m/Y'), $only), + ]; +} + +if (null !== $next) { + $next = [ + 'date' => $next, + 'url' => sprintf($self_uri . '?id=%d&start=%s&end=%s&only=%d', $account->id, $next->format('01/m/Y'), $next->format('t/m/Y'), $only), + ]; +} + +$tpl->assign(compact( + 'account', + 'start', + 'end', + 'prev', + 'next', + 'journal', + 'only' +)); + +$tpl->display('acc/accounts/reconcile.tpl'); diff --git a/src/www/admin/acc/accounts/reconcile_assist.php b/src/www/admin/acc/accounts/reconcile_assist.php new file mode 100644 index 0000000..a4e0ee3 --- /dev/null +++ b/src/www/admin/acc/accounts/reconcile_assist.php @@ -0,0 +1,100 @@ +requireAccess($session::SECTION_ACCOUNTING, $session::ACCESS_ADMIN); + +if (!CURRENT_YEAR_ID) { + Utils::redirect(ADMIN_URL . 'acc/years/?msg=OPEN'); +} + +$account = Accounts::get((int)qg('id')); + +if (!$account) { + throw new UserException("Le compte demandé n'existe pas."); +} + +$csrf_key = 'acc_reconcile_assist_' . $account->id(); + +$ar = new AssistedReconciliation($account); +$csv = $ar->csv(); + +$form->runIf('cancel', function () use ($csv) { + $csv->clear(); +}, $csrf_key, Utils::getSelfURI()); + +$form->runIf(f('upload') && isset($_FILES['file']['name']), function () use ($csv) { + $csv->load($_FILES['file']); +}, $csrf_key, Utils::getSelfURI()); + +$form->runIf('assign', function () use ($ar) { + $ar->setSettings(f('translation_table'), (int)f('skip_first_line')); +}, $csrf_key, Utils::getSelfURI()); + +$start = $end = null; + +if (null !== qg('start') && null !== qg('end')) { + $start = \DateTime::createFromFormat('!d/m/Y', qg('start')); + $end = \DateTime::createFromFormat('!d/m/Y', qg('end')); + + if (!$start || !$end) { + $form->addError('La date donnée est invalide.'); + } +} +else { + try { + extract($ar->getStartAndEndDates()); + } + catch (UserException $e) { + $form->addError($e->getMessage()); + $csv->clear(); + } +} + +$journal = null; + +if ($start && $end) { + if ($start < $current_year->start_date || $start > $current_year->end_date) { + $start = clone $current_year->start_date; + } + + if ($end < $current_year->start_date || $end > $current_year->end_date) { + $end = clone $current_year->end_date; + } + + $journal = $account->getReconcileJournal(CURRENT_YEAR_ID, $start, $end); +} + +// Enregistrement des cases cochées +$form->runIf('save', function () use ($journal, $csv) { + Transactions::saveReconciled($journal, f('reconcile')); + $csv->clear(); +}, $csrf_key, '!acc/accounts/reconcile_assist.php?msg=OK'); + +$lines = null; + +if ($journal && $csv->ready()) { + try { + $lines = $ar->mergeJournal($journal, $start, $end); + } + catch (UserException $e) { + $form->addError($e->getMessage()); + } +} + +$tpl->assign(compact( + 'account', + 'start', + 'end', + 'lines', + 'ar', + 'csv', + 'csrf_key' +)); + +$tpl->display('acc/accounts/reconcile_assist.tpl'); diff --git a/src/www/admin/acc/accounts/simple.php b/src/www/admin/acc/accounts/simple.php new file mode 100644 index 0000000..5b0488a --- /dev/null +++ b/src/www/admin/acc/accounts/simple.php @@ -0,0 +1,45 @@ + 'Toutes', + Transaction::TYPE_REVENUE => 'Recettes', + Transaction::TYPE_EXPENSE => 'Dépenses', + Transaction::TYPE_TRANSFER => 'Virements', + Transaction::TYPE_DEBT => 'Dettes', + Transaction::TYPE_CREDIT => 'Créances', + Transaction::TYPE_ADVANCED => 'Saisies avancées', +]; + +$type = qg('type'); + +if (!array_key_exists($type, $types)) { + $type = key($types); +} + +$list = Transactions::listByType(CURRENT_YEAR_ID, $type == -1 ? null : $type); +$list->setTitle(sprintf('Suivi - %s', $types[$type])); +$list->loadFromQueryString(); + +$can_edit = $session->canAccess($session::SECTION_ACCOUNTING, $session::ACCESS_ADMIN) && !$year->closed; + +$pending_count = null; + +if ($type == Transaction::TYPE_CREDIT || $type == Transaction::TYPE_DEBT) { + $pending_count = Transactions::listPendingCreditAndDebtForOtherYears(CURRENT_YEAR_ID)->count(); +} + +$tpl->assign(compact('type', 'list', 'types', 'can_edit', 'year', 'pending_count')); + +$tpl->display('acc/accounts/simple.tpl'); diff --git a/src/www/admin/acc/accounts/users.php b/src/www/admin/acc/accounts/users.php new file mode 100644 index 0000000..eef7529 --- /dev/null +++ b/src/www/admin/acc/accounts/users.php @@ -0,0 +1,21 @@ +id_chart); + +$list = $accounts->listUserAccounts($current_year->id); +$list->loadFromQueryString(); + +$tpl->assign('chart_id', $current_year->id_chart); + +$tpl->assign(compact('list')); + +$tpl->display('acc/accounts/users.tpl'); diff --git a/src/www/admin/acc/charts/accounts/_inc.php b/src/www/admin/acc/charts/accounts/_inc.php new file mode 100644 index 0000000..092f702 --- /dev/null +++ b/src/www/admin/acc/charts/accounts/_inc.php @@ -0,0 +1,42 @@ +assign(compact('types_arg', 'dialog_target', 'types_names')); + +function chart_reload_or_redirect(string $url) +{ + global $types_arg; + + if (($_GET['_dialog'] ?? null) === 'manage') { + Utils::reloadParentFrame(); + return; + } + + if ($types_arg) { + $url .= '&' . $types_arg; + } + + Utils::redirect($url); +} diff --git a/src/www/admin/acc/charts/accounts/all.php b/src/www/admin/acc/charts/accounts/all.php new file mode 100644 index 0000000..a7e8e70 --- /dev/null +++ b/src/www/admin/acc/charts/accounts/all.php @@ -0,0 +1,46 @@ +chart(); +} + +if (!$chart) { + throw new UserException('Aucun plan comptable spécifié'); +} + +$accounts = $chart->accounts(); + +$form->runIf('bookmark', function () use ($accounts) { + $b = f('bookmark'); + + if (!is_array($b) || empty($b)) { + return; + } + + $id = key($b); + $value = current($b); + $a = $accounts->get($id); + $a->bookmark = (bool) $value; + $a->save(); +}, null, Utils::getSelfURI()); + + +$list = $accounts->list($types); +$list->loadFromQueryString(); + +$target = !isset($_GET['_dialog']) ? '_dialog=manage' : null; + +$tpl->assign(compact('chart', 'list', 'target')); + +$tpl->display('acc/charts/accounts/all.tpl'); diff --git a/src/www/admin/acc/charts/accounts/delete.php b/src/www/admin/acc/charts/accounts/delete.php new file mode 100644 index 0000000..3068b81 --- /dev/null +++ b/src/www/admin/acc/charts/accounts/delete.php @@ -0,0 +1,36 @@ +requireAccess($session::SECTION_ACCOUNTING, $session::ACCESS_ADMIN); + +$account = Accounts::get((int) qg('id')); + +if (!$account) { + throw new UserException("Le compte demandé n'existe pas."); +} + +$chart = $account->chart(); + +if ($chart->archived) { + throw new UserException("Il n'est pas possible de modifier un compte d'un plan comptable archivé."); +} + +if (($chart->code && !$account->user) || !$account->canDelete()) { + throw new UserException("Ce compte ne peut être supprimé car des écritures y sont liées (sur l'exercice courant ou sur un exercice déjà clôturé).\nSi vous souhaitez faire du ménage dans la liste des comptes il est recommandé de créer un nouveau plan comptable. Attention, il n'est pas possible de modifier le plan comptable d'un exercice ouvert."); +} + +$csrf_key = 'acc_accounts_delete_' . $account->id(); + +$form->runIf('delete', function () use ($account) { + $account->delete(); + + chart_reload_or_redirect(sprintf('!acc/charts/accounts/?id=%d', $account->id_chart)); +}, $csrf_key); + +$tpl->assign(compact('account', 'csrf_key')); + +$tpl->display('acc/charts/accounts/delete.tpl'); diff --git a/src/www/admin/acc/charts/accounts/edit.php b/src/www/admin/acc/charts/accounts/edit.php new file mode 100644 index 0000000..3a5fa3f --- /dev/null +++ b/src/www/admin/acc/charts/accounts/edit.php @@ -0,0 +1,51 @@ +requireAccess($session::SECTION_ACCOUNTING, $session::ACCESS_ADMIN); + +$account = Accounts::get((int) qg('id')); + +if (!$account) { + throw new UserException("Le compte demandé n'existe pas."); +} + +$chart = $account->chart(); + +if ($chart->archived) { + throw new UserException("Il n'est pas possible de modifier un compte d'un plan comptable archivé."); +} + +$can_edit = $account->canEdit(); +$csrf_key = 'acc_accounts_edit_' . $account->id(); + +$form->runIf('edit', function () use ($account, $can_edit) { + if (!$can_edit) { + $account->importLimitedForm(); + } + else { + $account->importForm(); + } + + $account->save(); + + $page = ''; + + if (!$account->type) { + $page = 'all.php'; + } + + chart_reload_or_redirect(sprintf('!acc/charts/accounts/%s?id=%d', $page, $account->id_chart)); +}, $csrf_key); + +if ($account->type) { + $tpl->assign('code_base', $account->getNumberBase()); + $tpl->assign('code_value', $account->getNumberUserPart()); +} + +$tpl->assign(compact('account', 'can_edit', 'chart')); + +$tpl->display('acc/charts/accounts/edit.tpl'); diff --git a/src/www/admin/acc/charts/accounts/index.php b/src/www/admin/acc/charts/accounts/index.php new file mode 100644 index 0000000..2e8bcf0 --- /dev/null +++ b/src/www/admin/acc/charts/accounts/index.php @@ -0,0 +1,30 @@ +chart(); +} + +if (!$chart) { + throw new UserException('Aucun plan comptable spécifié'); +} + +if (!$chart->country) { + Utils::redirect('!acc/charts/accounts/all.php?id=' . $chart->id); +} + +$accounts = $chart->accounts(); + +$tpl->assign(compact('chart')); +$tpl->assign('accounts_grouped', $accounts->listCommonGrouped($types, false)); +$tpl->display('acc/charts/accounts/index.tpl'); diff --git a/src/www/admin/acc/charts/accounts/new.php b/src/www/admin/acc/charts/accounts/new.php new file mode 100644 index 0000000..1ad2fe3 --- /dev/null +++ b/src/www/admin/acc/charts/accounts/new.php @@ -0,0 +1,153 @@ +requireAccess($session::SECTION_ACCOUNTING, $session::ACCESS_ADMIN); + +$chart = Charts::get((int)qg('id')); + +if (!$chart) { + throw new UserException('Ce plan comptable n\'existe pas'); +} + +if ($chart->archived) { + throw new UserException("Il n'est pas possible de modifier un plan comptable archivé."); +} + +$accounts = $chart->accounts(); + +$account = new Account; +$account->bookmark = true; +$account->user = true; +$account->code = ''; +$account->id_chart = $chart->id(); + +$type = f('type') ?? qg('type'); + +// Simple creation with pre-determined account type +if ($type !== null) { + $account->type = (int)$type; +} +elseif (isset($types) && is_array($types) && count($types) == 1) { + $account->type = (int)current($types); +} +elseif (!$chart->country) { + $account->type = $account::TYPE_NONE; +} + +$csrf_key = 'account_new'; + +$form->runIf('toggle_bookmark', function () use ($accounts, $chart) { + $a = $accounts->get(f('toggle_bookmark')); + $a->bookmark = true; + $a->save(); + + chart_reload_or_redirect('!acc/charts/accounts/?id=' . $chart->id()); +}, $csrf_key); + +$form->runIf('save', function () use ($account, $accounts, $chart, $current_year) { + $db = DB::getInstance(); + + $db->begin(); + $account->importForm(); + + $account->id_chart = $chart->id(); + $account->user = true; + $account->bookmark = (bool) f('bookmark'); + $account->save(); + + if (!empty(f('opening_amount')) && $current_year) { + $t = $account->createOpeningBalance($current_year, Utils::moneyToInteger(f('opening_amount'))); + $t->id_creator = Session::getUserId(); + $t->save(); + } + + $db->commit(); + + $page = ''; + + if (!$account->type) { + $page = 'all.php'; + } + + chart_reload_or_redirect(sprintf('!acc/charts/accounts/%s?id=%d', $page, $account->id_chart)); +}, $csrf_key); + +$types_create = [ + Account::TYPE_EXPENSE => [ + 'label' => Account::TYPES_NAMES[Account::TYPE_EXPENSE], + 'help' => 'Compte destiné à recevoir les dépenses (charges)', + ], + Account::TYPE_REVENUE => [ + 'label' => Account::TYPES_NAMES[Account::TYPE_REVENUE], + 'help' => 'Compte destiné à recevoir les recettes (produits)', + ], + Account::TYPE_BANK => [ + 'label' => Account::TYPES_NAMES[Account::TYPE_BANK], + 'help' => 'Compte bancaire, livret, ou intermédiaire financier (type HelloAsso, Paypal, Stripe, SumUp, etc.)', + ], + Account::TYPE_CASH => [ + 'label' => Account::TYPES_NAMES[Account::TYPE_CASH], + 'help' => 'Caisse qui sert aux espèces, par exemple la caisse de l\'atelier ou de la boutique.', + ], + Account::TYPE_OUTSTANDING => [ + 'label' => Account::TYPES_NAMES[Account::TYPE_OUTSTANDING], + 'help' => 'Paiements qui ont été reçus mais qui ne sont pas encore déposés sur un compte bancaire (typiquement les chèques reçus, qui seront déposés en banque plus tard).', + ], + Account::TYPE_THIRD_PARTY => [ + 'label' => Account::TYPES_NAMES[Account::TYPE_THIRD_PARTY], + 'help' => 'Fournisseur, membres de l\'association, collectivités ou services de l\'État par exemple.', + ], + Account::TYPE_VOLUNTEERING_REVENUE => [ + 'label' => 'Source du bénévolat', + 'help' => 'Pour indiquer d\'où provient le bénévolat (temps donné, prestation gratuite, etc.)', + ], + Account::TYPE_VOLUNTEERING_EXPENSE => [ + 'label' => 'Utilisation du bénévolat', + 'help' => 'Pour valoriser l\'utilisation du temps de bénévolat, les dons en nature, etc.', + ], + Account::TYPE_NONE => [ + 'label' => 'Autre type de compte', + ], +]; + +$ask = $from = $missing = $code_base = $code_value = null; + +if ($id = (int)f('from')) { + $from = $accounts->get($id); + $code_base = $from->code; + $code_value = $account->getNewNumberAvailable($code_base); +} +elseif ($id = (int)qg('ask')) { + $ask = $accounts->get($id); +} + +if ($account->type && !$from) { + $code_base = $account->getNumberBase() ?? ''; + + if (f('from')) { + $code_value = $account->getNewNumberAvailable($code_base); + } + + if (null === f('from')) { + $missing = $accounts->listMissing($account->type); + } +} + +if ($account->type && $code_base && $code_value) { + $account->code = $code_base . $code_value; + $account->setLocalRules(); +} + +$tpl->assign(compact('types_create', 'account', 'chart', 'ask', 'csrf_key', 'missing', 'code_base', 'code_value', 'from')); + +$tpl->display('acc/charts/accounts/new.tpl'); diff --git a/src/www/admin/acc/charts/accounts/selector.php b/src/www/admin/acc/charts/accounts/selector.php new file mode 100644 index 0000000..e8a4551 --- /dev/null +++ b/src/www/admin/acc/charts/accounts/selector.php @@ -0,0 +1,99 @@ + $targets_str, + 'chart' => $chart_id, + 'year' => $year_id, +]); + +if (qg('_dialog') !== null) { + $this_url .= '&_dialog'; +} + +if (!count($targets)) { + $targets = null; +} + +if (null !== $filter) { + $session->set('account_selector_filter', $filter); + $session->save(); +} + +$filter = $session->get('account_selector_filter') ?? 'usual'; + + +// Cache the page until the charts have changed +$last_change = Config::getInstance()->get('last_chart_change') ?: time(); +$hash = sha1($targets_str . $chart_id . $year_id . $last_change . '=' . $filter); + +// Exit if there's no need to reload +Utils::HTTPCache($hash, null, 10); + +$chart = null; + +if ($chart_id) { + $chart = Charts::get($chart_id); +} +elseif ($year_id) { + $year = Years::get($year_id); + + if ($year) { + $chart = $year->chart(); + } +} +elseif ($current_year) { + $chart = $current_year->chart(); + $year = $current_year; +} + +if (!$chart) { + throw new UserException('Aucun exercice ouvert disponible'); +} + +// Charts with no country don't allow to use types +if (!$chart->country) { + $targets = null; +} + +$accounts = $chart->accounts(); + +$edit_url = sprintf('!acc/charts/accounts/%s?id=%d&types=%s', isset($grouped_accounts) ? '' : 'all.php', $chart->id(), $targets_str); +$new_url = sprintf('!acc/charts/accounts/new.php?id=%d&types=%s', $chart->id(), $targets_str); + +$targets_names = !empty($targets) ? array_intersect_key(Account::TYPES_NAMES, array_flip($targets)) : []; +$targets_names = implode(', ', $targets_names); + +$tpl->assign(compact('chart', 'targets', 'targets_str', 'filter', 'new_url', 'edit_url', 'targets_names', 'this_url')); + +if ($filter == 'all') { + $tpl->assign('accounts', $accounts->listAll($targets)); +} +elseif ($year) { + $tpl->assign('grouped_accounts', $year->listCommonAccountsGrouped($targets)); +} +else { + $tpl->assign('grouped_accounts', $accounts->listCommonGrouped($targets)); +} + +$tpl->display('acc/charts/accounts/selector.tpl'); \ No newline at end of file diff --git a/src/www/admin/acc/charts/delete.php b/src/www/admin/acc/charts/delete.php new file mode 100644 index 0000000..8cd4518 --- /dev/null +++ b/src/www/admin/acc/charts/delete.php @@ -0,0 +1,28 @@ +requireAccess($session::SECTION_ACCOUNTING, $session::ACCESS_ADMIN); + +$chart = Charts::get((int) qg('id')); + +if (!$chart) { + throw new UserException("Le plan comptable demandé n'existe pas."); +} + +if (!$chart->canDelete()) { + throw new UserException("Ce plan comptable ne peut être supprimé car il est lié à des exercices"); +} + +$csrf_key = 'acc_charts_delete_' . $chart->id(); + +$form->runIf('delete', function () use ($chart) { + $chart->delete(); +}, $csrf_key, '!acc/charts/'); + +$tpl->assign(compact('chart')); + +$tpl->display('acc/charts/delete.tpl'); diff --git a/src/www/admin/acc/charts/edit.php b/src/www/admin/acc/charts/edit.php new file mode 100644 index 0000000..8a72aa9 --- /dev/null +++ b/src/www/admin/acc/charts/edit.php @@ -0,0 +1,27 @@ +requireAccess($session::SECTION_ACCOUNTING, $session::ACCESS_ADMIN); + +$chart = Charts::get((int) qg('id')); + +if (!$chart) { + throw new UserException("Le plan comptable demandé n'existe pas."); +} + +$csrf_key = 'acc_charts_edit_' . $chart->id(); + +$form->runIf('save', function() use ($chart) { + $chart->importForm(); + $chart->set('archived', (bool) f('archived')); + $chart->save(); +}, $csrf_key, '!acc/charts/'); + +$tpl->assign(compact('chart')); + +$tpl->display('acc/charts/edit.tpl'); diff --git a/src/www/admin/acc/charts/export.php b/src/www/admin/acc/charts/export.php new file mode 100644 index 0000000..2c482d0 --- /dev/null +++ b/src/www/admin/acc/charts/export.php @@ -0,0 +1,19 @@ +get('org_name'), $chart->label), + $chart->export(), + $chart::COLUMNS +); diff --git a/src/www/admin/acc/charts/index.php b/src/www/admin/acc/charts/index.php new file mode 100644 index 0000000..1ee1887 --- /dev/null +++ b/src/www/admin/acc/charts/index.php @@ -0,0 +1,41 @@ +requireAccess($session::SECTION_ACCOUNTING, $session::ACCESS_READ); + +$tpl->assign('list', Charts::list()); + +if ($session->canAccess($session::SECTION_ACCOUNTING, $session::ACCESS_ADMIN)) { + $csrf_key = 'acc_charts_new'; + + $form->runIf(f('type') == 'copy', function () { + Charts::copyFrom((int) f('copy'), f('label'), f('country')); + }, $csrf_key, '!acc/charts/'); + + $form->runIf(f('type') == 'install', function () { + Charts::install(f('install')); + }, $csrf_key, '!acc/charts/'); + + $form->runIf(f('type') == 'import', function () { + Charts::import('file', f('label'), f('import_country')); + }, $csrf_key, '!acc/charts/'); + + $tpl->assign(compact('csrf_key')); + + $tpl->assign('columns', implode(', ', Chart::COLUMNS)); + $tpl->assign('country_list', Utils::getCountryList()); + + $tpl->assign('from', (int)qg('from')); + $tpl->assign('charts_grouped', Charts::listByCountry()); + $tpl->assign('country_list', Chart::COUNTRY_LIST + ['' => '— Autre']); + + $tpl->assign('install_list', Charts::listInstallable()); +} + +$tpl->display('acc/charts/index.tpl'); diff --git a/src/www/admin/acc/index.php b/src/www/admin/acc/index.php new file mode 100644 index 0000000..e8a3090 --- /dev/null +++ b/src/www/admin/acc/index.php @@ -0,0 +1,23 @@ +requireAccess($session::SECTION_ACCOUNTING, $session::ACCESS_READ); + +if (!Years::count()) { + Utils::redirect('!acc/years/first_setup.php'); +} + +$tpl->assign('graphs', array_slice(Graph::URL_LIST, 0, 3)); + +$years = Years::listOpen(true); +$tpl->assign('years', $years); +$tpl->assign('first_year', count($years) ? current($years)->id : null); +$tpl->assign('all_years', [null => '-- Tous les exercices'] + Years::listAssoc()); +$tpl->assign('last_transactions', Years::listLastTransactions(10, $years)); + +$tpl->display('acc/index.tpl'); diff --git a/src/www/admin/acc/projects/config.php b/src/www/admin/acc/projects/config.php new file mode 100644 index 0000000..83b1b16 --- /dev/null +++ b/src/www/admin/acc/projects/config.php @@ -0,0 +1,15 @@ +requireAccess($session::SECTION_ACCOUNTING, $session::ACCESS_ADMIN); + +$form->runIf('save_config', function () { + $config = Config::getInstance(); + $config->importForm(['analytical_set_all' => f('analytical_set_all')]); + $config->save(); +}, 'save_config', Utils::getSelfURI(['msg' => 'SAVED'])); + + +$tpl->display('acc/projects/config.tpl'); diff --git a/src/www/admin/acc/projects/delete.php b/src/www/admin/acc/projects/delete.php new file mode 100644 index 0000000..f9463f4 --- /dev/null +++ b/src/www/admin/acc/projects/delete.php @@ -0,0 +1,25 @@ +requireAccess($session::SECTION_ACCOUNTING, $session::ACCESS_ADMIN); + +$project = Projects::get((int)qg('id')); + +if (!$project) { + throw new UserException('Projet introuvable'); +} + +$csrf_key = 'project_delete'; + +$form->runIf('delete', function () use ($project) { + $project->delete(); +}, $csrf_key, '!acc/projects/'); + +$tpl->assign(compact('csrf_key', 'project')); + +$tpl->display('acc/projects/delete.tpl'); diff --git a/src/www/admin/acc/projects/edit.php b/src/www/admin/acc/projects/edit.php new file mode 100644 index 0000000..56f331d --- /dev/null +++ b/src/www/admin/acc/projects/edit.php @@ -0,0 +1,27 @@ +requireAccess($session::SECTION_ACCOUNTING, $session::ACCESS_ADMIN); + +if ($id = (int)qg('id')) { + $project = Projects::get($id); +} +else { + $project = new Project; +} + +$csrf_key = 'project_edit'; + +$form->runIf('save', function () use ($project) { + $project->importForm(); + $project->save(); +}, $csrf_key, '!acc/projects/'); + +$tpl->assign(compact('csrf_key', 'project')); + +$tpl->display('acc/projects/edit.tpl'); diff --git a/src/www/admin/acc/projects/index.php b/src/www/admin/acc/projects/index.php new file mode 100644 index 0000000..09fba7c --- /dev/null +++ b/src/www/admin/acc/projects/index.php @@ -0,0 +1,19 @@ +requireAccess($session::SECTION_ACCOUNTING, $session::ACCESS_READ); + +$by_year = (bool)qg('by_year'); + +$list = Projects::getBalances($by_year); + +$tpl->assign(compact('by_year', 'list')); +$tpl->assign('export', false); + +$tpl->assign('projects_count', Projects::count()); + +$tpl->display('acc/projects/index.tpl'); diff --git a/src/www/admin/acc/reports/_inc.php b/src/www/admin/acc/reports/_inc.php new file mode 100644 index 0000000..3fe2483 --- /dev/null +++ b/src/www/admin/acc/reports/_inc.php @@ -0,0 +1,78 @@ +requireAccess($session::SECTION_ACCOUNTING, $session::ACCESS_READ); + +$criterias = []; + +$tpl->assign('project_title', null); + +if (qg('project') === 'all') { + $criterias['projects_only'] = true; +} +elseif (qg('project')) { + $project = Projects::get((int) qg('project')); + + if (!$project) { + throw new UserException('Numéro de projet inconnu.'); + } + + $criterias['project'] = $project->id(); + $tpl->assign('project', $project); + $tpl->assign('project_title', sprintf('%s - ', $project->label)); +} + +if (qg('year')) +{ + $year = Years::get((int) qg('year')); + + if (!$year) { + throw new UserException('Exercice inconnu.'); + } + + if (qg('before') && ($b = Entity::filterUserDateValue(qg('before')))) { + $criterias['before'] = $b; + } + + if (qg('after') && ($a = Entity::filterUserDateValue(qg('after')))) { + $criterias['after'] = $a; + } + + $criterias['year'] = $year->id(); + $tpl->assign('year', $year); + $tpl->assign('before_default', $criterias['before'] ?? $year->end_date); + $tpl->assign('after_default', $criterias['after'] ?? $year->start_date); +} + +if (!count($criterias)) +{ + throw new UserException('Critère de rapport inconnu.'); +} + +if ($y2 = Years::get((int)qg('compare_year'))) { + $tpl->assign('year2', $y2); + $criterias['compare_year'] = $y2->id; +} + +$tpl->assign('criterias', $criterias); +$criterias_query = $criterias; + +foreach ($criterias_query as &$c) { + if ($c instanceof \DateTimeInterface) { + $c = $c->format('Y-m-d'); + } +} + +$tpl->assign('criterias_query', http_build_query($criterias_query)); +unset($criterias_query['compare_year']); +$tpl->assign('criterias_query_no_compare', http_build_query($criterias_query)); + +$tpl->assign('now', new \DateTime); \ No newline at end of file diff --git a/src/www/admin/acc/reports/balance_sheet.php b/src/www/admin/acc/reports/balance_sheet.php new file mode 100644 index 0000000..b814070 --- /dev/null +++ b/src/www/admin/acc/reports/balance_sheet.php @@ -0,0 +1,19 @@ +assign('balance', $balance); + +if (!empty($criterias['year'])) { + $years = Years::listAssocExcept($criterias['year']); + $tpl->assign('other_years', count($years) ? [null => '-- Ne pas comparer'] + $years : $years); +} + +$tpl->display('acc/reports/balance_sheet.tpl'); diff --git a/src/www/admin/acc/reports/graph_pie.php b/src/www/admin/acc/reports/graph_pie.php new file mode 100644 index 0000000..e3243b2 --- /dev/null +++ b/src/www/admin/acc/reports/graph_pie.php @@ -0,0 +1,16 @@ +requireAccess($session::SECTION_ACCOUNTING, $session::ACCESS_READ); + +$year = Years::get((int)qg('year')); + +$tpl->assign('graphs', Graph::URL_LIST); +$tpl->assign('year', $year); + +$tpl->assign('nb_transactions', Reports::countTransactions($criterias)); + +$tpl->display('acc/reports/graphs.tpl'); diff --git a/src/www/admin/acc/reports/journal.php b/src/www/admin/acc/reports/journal.php new file mode 100644 index 0000000..4fbdfbd --- /dev/null +++ b/src/www/admin/acc/reports/journal.php @@ -0,0 +1,13 @@ +assign(compact('journal')); + +$tpl->display('acc/reports/journal.tpl'); diff --git a/src/www/admin/acc/reports/ledger.php b/src/www/admin/acc/reports/ledger.php new file mode 100644 index 0000000..5e523af --- /dev/null +++ b/src/www/admin/acc/reports/ledger.php @@ -0,0 +1,13 @@ +preferences->accounting_expert ?? false; +$tpl->assign('ledger', Reports::getGeneralLedger($criterias, !$expert)); + +$tpl->display('acc/reports/ledger.tpl'); diff --git a/src/www/admin/acc/reports/statement.php b/src/www/admin/acc/reports/statement.php new file mode 100644 index 0000000..917014f --- /dev/null +++ b/src/www/admin/acc/reports/statement.php @@ -0,0 +1,21 @@ + [Account::TYPE_VOLUNTEERING_REVENUE, Account::TYPE_VOLUNTEERING_EXPENSE]]); +$volunteering = Reports::getVolunteeringStatement($criterias, $general); + +$tpl->assign(compact('general', 'volunteering')); + +if (!empty($criterias['year'])) { + $years = Years::listAssocExcept($criterias['year']); + $tpl->assign('other_years', count($years) ? [null => '-- Ne pas comparer'] + $years : $years); +} + +$tpl->display('acc/reports/statement.tpl'); diff --git a/src/www/admin/acc/reports/trial_balance.php b/src/www/admin/acc/reports/trial_balance.php new file mode 100644 index 0000000..8cb0893 --- /dev/null +++ b/src/www/admin/acc/reports/trial_balance.php @@ -0,0 +1,14 @@ +user()->preferences->accounting_expert) ? false : true; +$balance = Reports::getTrialBalance($criterias, (bool) $simple); + +$tpl->assign(compact('simple', 'balance')); + +$tpl->display('acc/reports/trial_balance.tpl'); diff --git a/src/www/admin/acc/saved_searches.php b/src/www/admin/acc/saved_searches.php new file mode 100644 index 0000000..c6492d7 --- /dev/null +++ b/src/www/admin/acc/saved_searches.php @@ -0,0 +1,9 @@ +requireAccess($session::SECTION_ACCOUNTING, $session::ACCESS_ADMIN); + +$check = f('check'); + +if (!$check || !is_array($check)) { + throw new UserException('Aucune écriture n\'a été sélectionnée.'); +} + +$transactions = array_unique(array_values($check)); +$lines = array_keys($check); + +if (f('action') === 'payoff') { + Utils::redirect('!acc/transactions/new.php?payoff=' . implode(',', $transactions)); +} + +$csrf_key = 'acc_actions'; + +// Delete transactions +$form->runIf('delete', function () use ($transactions) { + foreach ($transactions as $id) { + $transaction = Transactions::get((int) $id); + + if (!$transaction) { + throw new UserException('Cette écriture n\'existe pas'); + } + + $transaction->delete(); + } +}, $csrf_key, f('from') ?: ADMIN_URL); + +// Add/remove lines to analytical project +$form->runIf('change_project', function () use ($transactions, $lines) { + $id = f('id_project') ?: null; + + if (f('apply_lines')) { + $lines = null; + } + else { + $transactions = null; + } + + Transactions::setProject($id, $transactions, $lines); +}, $csrf_key, f('from') ?: ADMIN_URL); + +$from = f('from'); +$count = count($check); +$extra = compact('check', 'from'); +$tpl->assign(compact('csrf_key', 'check', 'count', 'extra')); + +if (f('action') == 'delete') +{ + $tpl->display('acc/transactions/actions_delete.tpl'); +} +else +{ + $tpl->assign('projects', Projects::listAssoc()); + + $tpl->display('acc/transactions/action_project.tpl'); +} diff --git a/src/www/admin/acc/transactions/creator.php b/src/www/admin/acc/transactions/creator.php new file mode 100644 index 0000000..8a368b6 --- /dev/null +++ b/src/www/admin/acc/transactions/creator.php @@ -0,0 +1,22 @@ +requireAccess($session::SECTION_ACCOUNTING, $session::ACCESS_READ); + +$u = Users::get((int)qg('id')); + +if (!$u) { + throw new UserException('Ce membre n\'existe pas'); +} + +$criterias = ['creator' => $u->id]; + +$tpl->assign('journal', Reports::getJournal($criterias, true)); +$tpl->assign('transaction_creator', $u); + +$tpl->display('acc/transactions/creator.tpl'); diff --git a/src/www/admin/acc/transactions/delete.php b/src/www/admin/acc/transactions/delete.php new file mode 100644 index 0000000..00c50bb --- /dev/null +++ b/src/www/admin/acc/transactions/delete.php @@ -0,0 +1,27 @@ +requireAccess($session::SECTION_ACCOUNTING, $session::ACCESS_ADMIN); + +$transaction = Transactions::get((int) qg('id')); + +if (!$transaction) { + throw new UserException('Cette écriture n\'existe pas'); +} + +$transaction->assertCanBeModified(); + +$csrf_key = 'acc_delete_' . $transaction->id; + +$form->runIf('delete', function () use ($transaction) { + $transaction->delete(); +}, $csrf_key, '!acc/'); + +$tpl->assign(compact('transaction', 'csrf_key')); + +$tpl->display('acc/transactions/delete.tpl'); diff --git a/src/www/admin/acc/transactions/details.php b/src/www/admin/acc/transactions/details.php new file mode 100644 index 0000000..87129af --- /dev/null +++ b/src/www/admin/acc/transactions/details.php @@ -0,0 +1,42 @@ +id(); + +$form->runIf('mark_paid', function () use ($transaction) { + $transaction->markPaid(); + $transaction->save(); +}, $csrf_key, Utils::getSelfURI()); + +$expert = !empty($session->user()->preferences->accounting_expert); + +$variables = compact('csrf_key', 'transaction') + [ + 'transaction_lines' => $transaction->getLinesWithAccounts(), + 'transaction_year' => $transaction->year(), + 'simple' => isset($_GET['advanced']) ? !$_GET['advanced'] : !$expert, + 'details' => $transaction->getDetails(), + 'files' => $transaction->listFiles(), + 'creator_name' => $transaction->id_creator ? Users::getName($transaction->id_creator) : null, + 'files_edit' => $session->canAccess($session::SECTION_ACCOUNTING, $session::ACCESS_WRITE), + 'file_parent' => $transaction->getAttachementsDirectory(), + 'linked_users' => $transaction->listLinkedUsers(), + 'linked_transactions' => $transaction->listLinkedTransactions(), + 'linked_subscriptions' => $transaction->listSubscriptionLinks(), +]; + +$tpl->assign($variables); +$tpl->assign('snippets', Modules::snippetsAsString(Modules::SNIPPET_TRANSACTION, $variables)); + +$tpl->display('acc/transactions/details.tpl'); diff --git a/src/www/admin/acc/transactions/edit.php b/src/www/admin/acc/transactions/edit.php new file mode 100644 index 0000000..3d7874c --- /dev/null +++ b/src/www/admin/acc/transactions/edit.php @@ -0,0 +1,61 @@ +requireAccess($session::SECTION_ACCOUNTING, $session::ACCESS_ADMIN); + +$transaction = Transactions::get((int) qg('id')); + +if (!$transaction) { + throw new UserException('Cette écriture n\'existe pas'); +} + +$transaction->assertCanBeModified(); + +$year = Years::get($transaction->id_year); +$chart = $year->chart(); +$accounts = $chart->accounts(); + +$csrf_key = 'acc_transaction_edit_' . $transaction->id(); + +$tpl->assign('chart', $chart); + +$form->runIf('save', function() use ($transaction, $session) { + $transaction->importFromNewForm(); + $transaction->save(); + $transaction->saveLinks(); +}, $csrf_key, '!acc/transactions/details.php?id=' . $transaction->id()); + +$types_accounts = []; + +$lines = null; + +$form->runIf(f('lines') !== null, function () use (&$lines) { + $lines = Transaction::getFormLines(); +}); + +if (null === $lines) { + $lines = $transaction->getLinesWithAccounts(); +} + +$amount = $transaction->getLinesCreditSum(); +$types_details = $transaction->getTypesDetails(); +$id_project = $transaction->getProjectId(); +$has_reconciled_lines = $transaction->hasReconciledLines(); + +$tpl->assign(compact('csrf_key', 'transaction', 'lines', 'amount', 'has_reconciled_lines', 'types_details', 'id_project')); + +$tpl->assign('chart_id', $chart->id()); +$tpl->assign('projects', Projects::listAssoc()); +$tpl->assign('linked_users', $transaction->listLinkedUsersAssoc()); +$tpl->assign('linked_transactions', $transaction->listLinkedTransactionsAssoc()); + +$tpl->display('acc/transactions/edit.tpl'); diff --git a/src/www/admin/acc/transactions/lock.php b/src/www/admin/acc/transactions/lock.php new file mode 100644 index 0000000..f37b4c0 --- /dev/null +++ b/src/www/admin/acc/transactions/lock.php @@ -0,0 +1,28 @@ +requireAccess($session::SECTION_ACCOUNTING, $session::ACCESS_ADMIN); + +$transaction = Transactions::get((int) qg('id')); + +if (!$transaction) { + throw new UserException('Cette écriture n\'existe pas'); +} + +$transaction->assertCanBeModified(); + +$csrf_key = 'acc_transaction_lock_' . $transaction->id(); + +$form->runIf('lock', function() use ($transaction) { + $transaction->lock(); +}, $csrf_key, '!acc/transactions/details.php?id=' . $transaction->id()); + +$tpl->assign(compact('csrf_key', 'transaction')); + +$tpl->display('acc/transactions/lock.tpl'); diff --git a/src/www/admin/acc/transactions/new.php b/src/www/admin/acc/transactions/new.php new file mode 100644 index 0000000..1fe5eea --- /dev/null +++ b/src/www/admin/acc/transactions/new.php @@ -0,0 +1,163 @@ +requireAccess($session::SECTION_ACCOUNTING, $session::ACCESS_WRITE); + +if (!CURRENT_YEAR_ID) { + Utils::redirect(ADMIN_URL . 'acc/years/?msg=OPEN'); +} + +$chart = $current_year->chart(); +$accounts = $chart->accounts(); + +$csrf_key = 'acc_transaction_new'; +$transaction = new Transaction; + +$amount = 0; +$id_project = null; +$linked_users = null; +$linked_transactions = null; +$payoff = null; + +$lines = [[], []]; + +// Duplicate transaction +if (qg('copy')) { + $old = Transactions::get((int)qg('copy')); + + if (!$old) { + throw new UserException('Cette écriture n\'existe pas (ou plus).'); + } + + $transaction = $old->duplicate($current_year); + + if (empty($_POST)) { + $lines = $transaction->getLinesWithAccounts(); + $types_details = $transaction->getTypesDetails(); + } + + $id_project = $old->getProjectId(); + $amount = $transaction->getLinesCreditSum(); + $linked_users = $old->listLinkedUsersAssoc(); + + $tpl->assign('duplicate_from', $old->id()); +} +elseif (qg('payoff')) { + $list = explode(',', qg('payoff')); + + // Quick pay-off for debts and credits, directly from a debt/credit details page + $payoff = Transactions::createPayoffFrom($list); + $transaction = $payoff->transaction; + $linked_users = $payoff->linked_users; + $linked_transactions = $payoff->linked_transactions; + $id_project = $payoff->id_project; + + $lines = $transaction->getLinesWithAccounts(); + $amount = $payoff->amount; +} +else { + $defaults = $transaction->setDefaultsFromQueryString($accounts); + + if (null !== $defaults) { + extract($defaults); + } +} + +$form->runIf(f('lines') !== null, function () use (&$lines) { + $lines = Transaction::getFormLines(); +}); + +// Keep this line here, as the transaction can be overwritten by copy +$transaction->id_year = $current_year->id(); +$types_details = $transaction->getTypesDetails(); + +// Set last used date +if (empty($transaction->date) && $session->get('acc_last_date') && $date = Date::createFromFormat('!Y-m-d', $session->get('acc_last_date'))) { + $transaction->date = $date; +} +// Set date of the day if no date was set +elseif (empty($transaction->date)) { + $transaction->date = new Date; +} + +// Make sure the date cannot be outside of the current year +if ($transaction->date < $current_year->start_date || $transaction->date > $current_year->end_date) { + $transaction->date = $current_year->start_date; +} + +// Quick transaction from an account journal page +if ($id = qg('account')) { + $account = $accounts::get($id); + + if (!$account || $account->id_chart != $current_year->id_chart) { + throw new UserException('Ce compte ne correspond pas à l\'exercice comptable ou n\'existe pas'); + } + + $transaction->type = Transaction::getTypeFromAccountType($account->type); + $index = $transaction->type == Transaction::TYPE_DEBT || $transaction->type == Transaction::TYPE_CREDIT ? 1 : 0; + $s = [$account->id => sprintf('%s — %s', $account->code, $account->label)]; + + if ($transaction->type) { + $types_details[$transaction->type]->accounts[$index]->selector_value = $s; + } + else { + $lines = [['account_selector' => $s], []]; + } +} + +$form->runIf('save', function () use ($transaction, $session, $payoff) { + if ($payoff) { + $transaction->importFromPayoffForm($payoff); + } + else { + $transaction->importFromNewForm(); + } + + $transaction->id_creator = $session->getUser()->id; + $transaction->save(); + $transaction->saveLinks(); + + $session->set('acc_last_date', $transaction->date->format('Y-m-d')); + $session->save(); + + if ($payoff) { + $transaction->updateLinkedTransactions(array_keys($payoff->transactions)); + + if (f('mark_paid')) { + foreach ($payoff->transactions as $t) { + $t->markPaid(); + $t->save(); + } + } + } + + if (array_key_exists('_dialog', $_GET)) { + Utils::reloadParentFrame(); + return; + } + + Utils::redirect(sprintf('!acc/transactions/details.php?id=%d&created', $transaction->id())); +}, $csrf_key); + +$projects = Projects::listAssoc(); +$variables = compact('csrf_key', 'transaction', 'amount', 'lines', 'id_project', 'types_details', 'linked_users', 'linked_transactions', 'chart', 'projects', 'payoff'); + +$tpl->assign($variables); + +$tpl->assign('snippets', Modules::snippetsAsString(Modules::SNIPPET_BEFORE_NEW_TRANSACTION, $variables)); + +$tpl->display('acc/transactions/new.tpl'); diff --git a/src/www/admin/acc/transactions/pending.php b/src/www/admin/acc/transactions/pending.php new file mode 100644 index 0000000..2aebfc0 --- /dev/null +++ b/src/www/admin/acc/transactions/pending.php @@ -0,0 +1,14 @@ +loadFromQueryString(); + +$tpl->assign(compact('list')); + +$tpl->display('acc/transactions/pending.tpl'); diff --git a/src/www/admin/acc/transactions/selector.php b/src/www/admin/acc/transactions/selector.php new file mode 100644 index 0000000..d34cfc8 --- /dev/null +++ b/src/www/admin/acc/transactions/selector.php @@ -0,0 +1,21 @@ +assign(compact('query', 'list', 'years')); + +$tpl->display('acc/transactions/selector.tpl'); diff --git a/src/www/admin/acc/transactions/service_user.php b/src/www/admin/acc/transactions/service_user.php new file mode 100644 index 0000000..2cd0e2b --- /dev/null +++ b/src/www/admin/acc/transactions/service_user.php @@ -0,0 +1,30 @@ +requireAccess($session::SECTION_ACCOUNTING, $session::ACCESS_READ); + +$id = (int)qg('id'); +$user = (int)qg('user'); +$self_url = sprintf('!acc/transactions/service_user.php?id=%d&user=%d', $id, $user); + +$form->runIf(qg('unlink') !== null, function () use ($id) { + $t = Transactions::get((int)qg('unlink')); + $t->deleteSubscriptionLink($id); +}, null, $self_url); + +$criterias = ['subscription' => $id]; +$action = ['shape' => 'delete', 'href' => $self_url . '&unlink=%d', 'label' => 'Dé-lier cette écriture']; + +$tpl->assign('balance', Reports::getAccountsBalances($criterias)); +$tpl->assign('journal', Reports::getJournal($criterias)); +$tpl->assign('user_id', $user); +$tpl->assign('service_user_id', $id); +$tpl->assign(compact('action')); + +$tpl->display('acc/transactions/service_user.tpl'); diff --git a/src/www/admin/acc/transactions/user.php b/src/www/admin/acc/transactions/user.php new file mode 100644 index 0000000..cec2dee --- /dev/null +++ b/src/www/admin/acc/transactions/user.php @@ -0,0 +1,27 @@ + $u->id]; + +$tpl->assign('balance', Reports::getAccountsBalances($criterias + ['year' => $year], null, false)); +$tpl->assign('journal', Reports::getJournal($criterias, true)); +$tpl->assign(compact('years', 'year')); +$tpl->assign('transaction_user', $u); + +$tpl->display('acc/transactions/user.tpl'); diff --git a/src/www/admin/acc/years/balance.php b/src/www/admin/acc/years/balance.php new file mode 100644 index 0000000..d704d11 --- /dev/null +++ b/src/www/admin/acc/years/balance.php @@ -0,0 +1,152 @@ +requireAccess($session::SECTION_ACCOUNTING, $session::ACCESS_ADMIN); + +$year = Years::get((int)qg('id')); + +if (!$year) { + throw new UserException('Exercice inconnu.'); +} + +if ($year->closed) { + throw new UserException('Impossible de modifier un exercice clôturé.'); +} + +$csrf_key = 'acc_years_balance_' . $year->id(); +$accounts = $year->accounts(); + +$form->runIf('save', function () use ($year) { + $db = DB::getInstance(); + // Fail everything if appropriation failed + $db->begin(); + + $year->deleteOpeningBalance(); + + $transaction = new Transaction; + $transaction->id_creator = Session::getUserId(); + $transaction->importFromBalanceForm($year); + $transaction->save(); + + if (f('appropriation')) { + // (affectation du résultat) + $t2 = Years::makeAppropriation($year); + + if ($t2) { + $t2->id_creator = $transaction->id_creator; + $t2->save(); + } + } + + $db->commit(); + + if (f('appropriation')) { + Utils::redirect('!acc/reports/journal.php?year=' . $year->id()); + } + + Utils::redirect('!acc/transactions/details.php?id=' . $transaction->id()); +}, $csrf_key); + + +$previous_year = null; +$year_selected = f('from_year') !== null; +$chart_change = false; +$lines = [[]]; +$years = Years::list(true, $year->id); + +// Empty balance +if (!count($years) || f('from_year') === '') { + $previous_year = 0; +} +elseif (null !== f('from_year')) { + $previous_year = (int)f('from_year'); + $previous_year = Years::get($previous_year); + + if (!$previous_year) { + throw new UserException('Année précédente invalide'); + } +} + +$matching_accounts = null; + +if ($previous_year) { + $lines = Reports::getAccountsBalances(['year' => $previous_year->id(), 'exclude_position' => [Account::EXPENSE, Account::REVENUE]]); + + if ($previous_year->id_chart != $year->id_chart) { + $chart_change = true; + $codes = []; + + foreach ($lines as $line) { + $codes[] = $line->code; + } + + $matching_accounts = $accounts->listForCodes($codes); + } + + // Append result + $result = Reports::getResult(['year' => $previous_year->id()]); + + if ($result > 0) { + $account = $accounts->getSingleAccountForType(Account::TYPE_POSITIVE_RESULT); + } + else { + $account = $accounts->getSingleAccountForType(Account::TYPE_NEGATIVE_RESULT); + } + + if (!$account) { + $account = (object) [ + 'id' => null, + 'code' => null, + 'label' => null, + ]; + } + + $lines[] = (object) [ + 'balance' => $result, + 'id' => $account->id, + 'code' => $account->code, + 'label' => $account->label, + 'is_debt' => $result < 0, + ]; + + foreach ($lines as $k => &$line) { + $line->credit = !$line->is_debt ? abs($line->balance) : 0; + $line->debit = $line->is_debt ? abs($line->balance) : 0; + + if ($chart_change) { + if ($matching_accounts && array_key_exists($line->code, $matching_accounts)) { + $acc = $matching_accounts[$line->code]; + $line->account_selector = [$acc->id => sprintf('%s — %s', $acc->code, $acc->label)]; + } + } + else { + $line->account_selector = $line->id ? [$line->id => sprintf('%s — %s', $line->code, $line->label)] : null; + } + + $line = (array) $line; + } + + unset($line); +} + + +if (!empty($_POST['lines']) && is_array($_POST['lines'])) { + $lines = Transaction::getFormLines(); +} + +$appropriation_account = $accounts->getSingleAccountForType(Account::TYPE_APPROPRIATION_RESULT); +$can_appropriate = $accounts->getIdForType(Account::TYPE_NEGATIVE_RESULT) && $accounts->getIdForType(Account::TYPE_POSITIVE_RESULT); +$has_balance = $year->hasOpeningBalance(); + +$tpl->assign(compact('lines', 'years', 'chart_change', 'previous_year', 'year_selected', 'year', 'csrf_key', 'can_appropriate', 'appropriation_account', 'has_balance')); + +$tpl->display('acc/years/balance.tpl'); diff --git a/src/www/admin/acc/years/close.php b/src/www/admin/acc/years/close.php new file mode 100644 index 0000000..0b32ba4 --- /dev/null +++ b/src/www/admin/acc/years/close.php @@ -0,0 +1,38 @@ +requireAccess($session::SECTION_ACCOUNTING, $session::ACCESS_ADMIN); + +$year = Years::get((int)qg('id')); + +if (!$year) { + throw new UserException('Exercice inconnu.'); +} + +if ($year->closed) { + throw new UserException('Impossible de modifier un exercice clôturé.'); +} + +$csrf_key = 'acc_years_close_' . $year->id(); + +$form->runIf('close', function () use ($year, $user, $session) { + $year->close($user->id); + $year->save(); + + $user = Session::getLoggedUser(); + + // Year is closed, remove it from preferences + if ($user->getPreference('accounting_year') == $year->id()) { + $user->setPreference('accounting_year', null); + } + $session->save(); +}, $csrf_key, ADMIN_URL . 'acc/years/new.php?from=' . $year->id()); + +$tpl->assign(compact('year', 'csrf_key')); + +$tpl->display('acc/years/close.tpl'); diff --git a/src/www/admin/acc/years/delete.php b/src/www/admin/acc/years/delete.php new file mode 100644 index 0000000..e11db8a --- /dev/null +++ b/src/www/admin/acc/years/delete.php @@ -0,0 +1,27 @@ +requireAccess($session::SECTION_ACCOUNTING, $session::ACCESS_ADMIN); + +$year = Years::get((int)qg('id')); + +if (!$year) { + throw new UserException('Exercice inconnu.'); +} + +if ($year->closed) { + throw new UserException('Impossible de supprimer un exercice clôturé.'); +} + +$form->runIf(f('delete') && f('confirm_delete'), function () use ($year) { + $year->delete(); +}, 'acc_years_delete_' . $year->id(), ADMIN_URL . 'acc/years/'); + +$tpl->assign('nb_transactions', $year->countTransactions()); +$tpl->assign('year', $year); + +$tpl->display('acc/years/delete.tpl'); diff --git a/src/www/admin/acc/years/edit.php b/src/www/admin/acc/years/edit.php new file mode 100644 index 0000000..8ed988b --- /dev/null +++ b/src/www/admin/acc/years/edit.php @@ -0,0 +1,61 @@ +requireAccess($session::SECTION_ACCOUNTING, $session::ACCESS_ADMIN); + +$year = Years::get((int)qg('id')); + +if (!$year) { + throw new UserException('Exercice inconnu.'); +} + +if ($year->closed) { + throw new UserException('Impossible de modifier un exercice clôturé.'); +} + +$csrf_key = 'acc_years_edit_' . $year->id(); + +$form->runIf('edit', function () use ($year) { + if (f('split')) { + $date = Date::createFromFormat('!d/m/Y', f('end_date')); + + if (!$date) { + throw new UserException('Date de séparation invalide'); + } + + $target = f('split_year'); + + if ($target) { + $target = Years::get($target); + } + else { + $target = new Year; + $new_start = (clone $date)->modify('+1 day'); + $target->label = sprintf('Exercice %d', $date->format('Y')); + $target->start_date = $new_start; + $target->end_date = (clone $new_start)->modify('+1 year'); + $target->id_chart = $year->id_chart; + $target->save(); + } + + if (!$target) { + throw new UserException('Exercice de séparation invalide'); + } + + $year->split($date, $target); + } + + $year->importForm(); + $year->save(); +}, $csrf_key, ADMIN_URL . 'acc/years/'); + +$tpl->assign(compact('year', 'csrf_key')); +$tpl->assign('split_years', ['' => '-- Créer un nouvel exercice'] + Years::listOpenAssocExcept($year->id())); + +$tpl->display('acc/years/edit.tpl'); diff --git a/src/www/admin/acc/years/export.php b/src/www/admin/acc/years/export.php new file mode 100644 index 0000000..e8f699b --- /dev/null +++ b/src/www/admin/acc/years/export.php @@ -0,0 +1,53 @@ + [ + 'label' => 'Complet (comptabilité d\'engagement)', + 'help' => '(Conseillé pour transfert vers un autre logiciel) Chaque ligne reprend toutes les informations de la ligne et de l\'écriture.', + ], + Export::GROUPED => [ + 'label' => 'Complet groupé', + 'help' => 'Les colonnes de l\'écriture ne sont pas répétées pour chaque ligne.', + ], + Export::SIMPLE => [ + 'label' => 'Simplifié (comptabilité de trésorerie)', + 'help' => 'Les écritures avancées ne sont pas inclues dans cet export.', + ], + Export::FEC => [ + 'label' => 'FEC (Fichier des Écritures Comptables)', + 'help' => 'Format standard de l\'administration française.', + ], +]; + +$tpl->assign(compact('year', 'examples', 'types')); + +$tpl->display('acc/years/export.tpl'); diff --git a/src/www/admin/acc/years/first_setup.php b/src/www/admin/acc/years/first_setup.php new file mode 100644 index 0000000..d987046 --- /dev/null +++ b/src/www/admin/acc/years/first_setup.php @@ -0,0 +1,104 @@ +requireAccess($session::SECTION_ACCOUNTING, $session::ACCESS_ADMIN); + +$csrf_key = 'first_setup'; + +$year = new Year; + +$config = Config::getInstance(); +$default_chart = Charts::getFirstForCountry($config->country); +$selected_chart = f('chart'); + +if ($id_chart = (int) f('id_chart')) { + $year->id_chart = $id_chart; +} +elseif ($selected_chart) { + $year->id_chart = Charts::getOrInstall($selected_chart); +} +elseif ($default_chart) { + $year->id_chart = $default_chart->id; +} + +$new_dates = Years::getNewYearDates(); +$year->start_date = $new_dates[0]; +$year->end_date = $new_dates[1]; +$year->label = sprintf('Exercice %s', $year->label_years()); + +$new_accounts = f('accounts'); + +if (is_array($new_accounts)) { + $new_accounts = Utils::array_transpose($new_accounts); + + foreach ($new_accounts as &$line) { + if (isset($line['balance'])) { + $line['balance'] = Utils::moneyToInteger($line['balance']); + } + } + + unset($line); +} +else { + $new_accounts = []; +} + +$appropriation_account = $year->id_chart ? $year->chart()->accounts()->getSingleAccountForType(Account::TYPE_APPROPRIATION_RESULT) : null; + +$form->runIf('save', function () use ($year, $new_accounts, $appropriation_account) { + $db = DB::getInstance(); + + $db->begin(); + $year->importForm(); + $year->save(); + + foreach ($new_accounts as $row) { + if (empty($row['label'])) { + continue; + } + + $account = new Account; + $account->bookmark = true; + $account->user = true; + $account->id_chart = $year->id_chart; + $account->type = $account::TYPE_BANK; + $account->code = $account->getNumberBase() . $account->getNewNumberAvailable(); + $account->import(['label' => $row['label'] ?? '']); + $account->save(); + + if (trim($row['balance'] ?? '')) { + $t = $account->createOpeningBalance($year, $row['balance']); + $t->id_creator = Session::getUserId(); + $t->save(); + } + } + + if (f('result') && $appropriation_account) { + $t = $appropriation_account->createOpeningBalance($year, Utils::moneyToInteger(f('result')) * -1, 'Report du résultat de l\'exercice précédent'); + $t->id_creator = Session::getUserId(); + $t->save(); + } + + $db->commit(); +}, $csrf_key, '!acc/years/?msg=WELCOME'); + +if (!count($new_accounts)) { + $new_accounts[] = ['label' => 'Compte courant', 'balance' => 0]; +} + +$step = (int)f('step'); +$charts_list = Charts::listForCountry($config->country); +$default_chart_code = $default_chart ? $default_chart->country_code() : null; +$tpl->assign(compact('year', 'new_accounts', 'csrf_key', 'appropriation_account', 'charts_list', 'default_chart', 'default_chart_code', 'step')); + +$tpl->display('acc/years/first_setup.tpl'); diff --git a/src/www/admin/acc/years/import.php b/src/www/admin/acc/years/import.php new file mode 100644 index 0000000..c9725d8 --- /dev/null +++ b/src/www/admin/acc/years/import.php @@ -0,0 +1,121 @@ +requireAccess($session::SECTION_ACCOUNTING, $session::ACCESS_ADMIN); +$user = $session->getUser(); + +$year_id = (int) qg('year') ?: CURRENT_YEAR_ID; + +if ($year_id === CURRENT_YEAR_ID) { + $year = $current_year; +} +else { + $year = Years::get($year_id); +} + +if (!$year) { + throw new UserException("L'exercice demandé n'existe pas."); +} + +if ($year->closed) { + throw new UserException('Impossible de modifier un exercice clôturé.'); +} + +$type = qg('type'); +$type_name = Export::NAMES[$type] ?? null; +$csrf_key = 'acc_years_import_' . $year->id(); +$examples = null; +$csv = new CSV_Custom($session, 'acc_import_year'); +$ignore_ids = (bool) (f('ignore_ids') ?? qg('ignore_ids')); +$report = []; + +$params = compact('ignore_ids', 'type') + ['year' => $year->id()]; + +if (f('cancel')) { + $csv->clear(); + unset($params['type']); + Utils::redirect(Utils::getSelfURI($params)); +} + +if ($type && $type_name) { + $columns = Export::COLUMNS[$type]; + + // Remove NULLs + $columns = array_filter($columns); + $columns_table = $columns = array_flip($columns); + + if ($type == Export::FEC) { + // Fill with labels + $columns_table = array_intersect_key(array_flip(Export::COLUMNS_FULL), $columns); + } + + $csv->setColumns($columns_table, $columns); + $csv->setMandatoryColumns(Export::MANDATORY_COLUMNS[$type]); + + $form->runIf(f('load') && isset($_FILES['file']['tmp_name']), function () use ($csv, $params) { + $csv->load($_FILES['file']); + Utils::redirect(Utils::getSelfURI($params)); + }, $csrf_key); + + $form->runIf(f('preview') && $csv->loaded(), function () use (&$csv) { + $csv->skip((int)f('skip_first_line')); + $csv->setTranslationTable(f('translation_table')); + }, $csrf_key); + + if (!f('import') && $csv->ready()) { + try { + $report = Import::import($type, $year, $csv, $user->id, compact('ignore_ids') + ['dry_run' => true, 'return_report' => true]); + } + catch (UserException $e) { + $csv->clear(); + $form->addError($e); + } + } + + $form->runIf(f('import') && $csv->loaded(), function () use ($type, &$csv, $year, $user, $ignore_ids) { + try { + Import::import($type, $year, $csv, $user->id, compact('ignore_ids')); + } + finally { + $csv->clear(); + } + }, $csrf_key, ADMIN_URL . 'acc/years/?msg=IMPORT'); +} +else { + $csv->clear(); + $examples = Export::getExamples($year); +} + +$types = [ + Export::SIMPLE => [ + 'label' => 'Simplifié (comptabilité de trésorerie)', + 'help' => 'Chaque ligne représente une écriture, comme dans un cahier. Les écritures avancées ne peuvent pas être importées dans ce format.', + ], + Export::FULL => [ + 'label' => 'Complet (comptabilité d\'engagement)', + 'help' => 'Permet d\'avoir des écritures avancées. Les écritures sont groupées en utilisant leur numéro.', + ], + Export::GROUPED => [ + 'label' => 'Complet groupé (comptabilité d\'engagement)', + 'help' => 'Permet d\'avoir des écritures avancées. Les 7 premières colonnes de chaque ligne sont vides pour indiquer les lignes suivantes de l\'écriture.', + ], + Export::FEC => [ + 'label' => 'FEC (Fichier des Écritures Comptables)', + 'help' => 'Format standard de l\'administration française.', + ], +]; + +$with_linked_users = ($table = $csv->getTranslationTable()) && in_array('linked_users', $table); + +$tpl->assign(compact('csv', 'year', 'csrf_key', 'examples', 'type', 'type_name', 'ignore_ids', 'types', 'report', 'with_linked_users')); + +$tpl->display('acc/years/import.tpl'); diff --git a/src/www/admin/acc/years/index.php b/src/www/admin/acc/years/index.php new file mode 100644 index 0000000..09f536d --- /dev/null +++ b/src/www/admin/acc/years/index.php @@ -0,0 +1,18 @@ +requireAccess($session::SECTION_ACCOUNTING, $session::ACCESS_READ); + +$list = Years::list(true); + +if (!count($list)) { + Utils::redirect('!acc/years/first_setup.php'); +} + +$tpl->assign('list', $list); + +$tpl->display('acc/years/index.tpl'); diff --git a/src/www/admin/acc/years/new.php b/src/www/admin/acc/years/new.php new file mode 100644 index 0000000..997dd08 --- /dev/null +++ b/src/www/admin/acc/years/new.php @@ -0,0 +1,44 @@ +requireAccess($session::SECTION_ACCOUNTING, $session::ACCESS_ADMIN); + +$year = new Year; + +$form->runIf('new', function () use ($year) { + $year->importForm(); + $year->save(); + + $old_id = qg('from'); + + if ($old_id) { + $old = Years::get((int) $old_id); + $changed = Fees::updateYear($old, $year); + + if (!$changed) { + Utils::redirect(ADMIN_URL . 'acc/years/?msg=UPDATE_FEES'); + } + } + + if (Years::countClosed()) { + Utils::redirect(ADMIN_URL . 'acc/years/balance.php?from=' . $old_id . '&id=' . $year->id()); + } +}, 'acc_years_new', '!acc/years/'); + +$new_dates = Years::getNewYearDates(); +$year->start_date = $new_dates[0]; +$year->end_date = $new_dates[1]; +$year->label = sprintf('Exercice %s', $year->label_years()); + +$tpl->assign(compact('year')); + +$tpl->assign('charts', Charts::listByCountry(true)); + +$tpl->display('acc/years/new.tpl'); diff --git a/src/www/admin/acc/years/select.php b/src/www/admin/acc/years/select.php new file mode 100644 index 0000000..d3fbf34 --- /dev/null +++ b/src/www/admin/acc/years/select.php @@ -0,0 +1,28 @@ +setPreference('accounting_year', $year->id()); + + $session->save(); + Utils::redirect(f('from') ?: ADMIN_URL . 'acc/years/'); +} + +$tpl->assign('list', $years->listOpen()); +$tpl->assign('from', qg('from')); + +$tpl->display('acc/years/select.tpl'); diff --git a/src/www/admin/common/files/_preview.php b/src/www/admin/common/files/_preview.php new file mode 100644 index 0000000..38290c0 --- /dev/null +++ b/src/www/admin/common/files/_preview.php @@ -0,0 +1,47 @@ +canRead()) { + throw new UserException('Vous n\'avez pas le droit de lire ce fichier.'); + } + + $content = Render::render(f('format'), $file->path, f('content'), ADMIN_URL . 'common/files/_preview.php?p='); +} +// Preview single web page +elseif ($web = qg('w')) { + $page = Web::get((int)$web); + + if (!$page || !($file = $page->dir()) || !$file->canRead()) { + throw new UserException('Vous n\'avez pas le droit de lire ce fichier.'); + } + + $content = $page->preview($content); +} +else { + throw new UserException('Fichier inconnu'); +} + +$tpl->assign(compact('file', 'content')); + +$tpl->assign('custom_css', [BASE_URL . 'content.css']); + +$tpl->display('common/files/_preview.tpl'); diff --git a/src/www/admin/common/files/delete.php b/src/www/admin/common/files/delete.php new file mode 100644 index 0000000..b0917c0 --- /dev/null +++ b/src/www/admin/common/files/delete.php @@ -0,0 +1,35 @@ +canDelete()) { + throw new UserException('Vous n\'avez pas le droit de supprimer ce fichier.'); +} + +$trash = qg('trash') !== 'no'; + +$csrf_key = 'file_delete_' . $file->pathHash(); +$parent = $file->parent; + +$form->runIf('delete', function () use ($file, $trash) { + if ($trash) { + $file->moveToTrash(); + } + else { + $file->delete(); + } +}, $csrf_key, '!docs/?path=' . $parent); + +$tpl->assign(compact('file', 'csrf_key', 'trash')); + +$tpl->display('common/files/delete.tpl'); \ No newline at end of file diff --git a/src/www/admin/common/files/edit.php b/src/www/admin/common/files/edit.php new file mode 100644 index 0000000..0e3db3c --- /dev/null +++ b/src/www/admin/common/files/edit.php @@ -0,0 +1,53 @@ +canWrite()) { + throw new UserException('Vous n\'avez pas le droit de modifier ce fichier.'); +} + +$editor = $file->editorType(); +$csrf_key = 'edit_file_' . $file->pathHash(); + +$form->runIf('content', function () use ($file) { + $file->setContent(f('content')); +}, $csrf_key, Utils::getSelfURI()); + +$tpl->assign(compact('csrf_key', 'file')); + +$fallback = qg('fallback'); + +if (!$editor && $fallback) { + $editor = $fallback; +} + +if (!$editor) { + $tpl->display('common/files/upload.tpl'); +} +elseif ($editor == 'wopi') { + echo $file->editorHTML(); +} +else { + $content ??= $file->fetch(); + $path = $file->path; + $format = $file->renderFormat(); + $tpl->assign(compact('csrf_key', 'content', 'path', 'format')); + $tpl->display(sprintf('common/files/edit_%s.tpl', $editor)); +} diff --git a/src/www/admin/common/files/history.php b/src/www/admin/common/files/history.php new file mode 100644 index 0000000..6bc99e4 --- /dev/null +++ b/src/www/admin/common/files/history.php @@ -0,0 +1,50 @@ +canWrite()) { + throw new UserException('Vous n\'avez pas accès à ce fichier.'); +} + +if ($v = (int)qg('download')) { + $file->downloadVersion($v); + return; +} + +$csrf_key = 'file_history_' . $file->pathHash(); + +$form->runIf('restore', function () use ($file) { + $file->restoreVersion((int)f('restore')); +}, $csrf_key, '!common/files/history.php?msg=RESTORED&p=' . $file->path); + +$form->runIf('rename', function () use ($file) { + $file->renameVersion((int)f('rename'), f('new_name')); +}, $csrf_key, '!common/files/history.php?msg=RENAMED&p=' . $file->path); + +$form->runIf('delete', function () use ($file) { + $file->deleteVersion((int)f('delete')); +}, $csrf_key, '!common/files/history.php?msg=DELETED&p=' . $file->path); + +if (qg('rename')) { + $version = $file->getVersion((int)qg('rename')); + $version = $version->getVersionMetadata($version); + $tpl->assign(compact('file', 'csrf_key', 'version')); + $tpl->display('common/files/history_rename.tpl'); + return; +} + +$versions = $file->listVersions(); + +$tpl->assign(compact('versions', 'file', 'csrf_key')); + +$tpl->display('common/files/history.tpl'); \ No newline at end of file diff --git a/src/www/admin/common/files/preview.php b/src/www/admin/common/files/preview.php new file mode 100644 index 0000000..3114ba1 --- /dev/null +++ b/src/www/admin/common/files/preview.php @@ -0,0 +1,30 @@ +canRead()) { + throw new UserException('Vous n\'avez pas le droit de lire ce fichier.'); +} + +if ($file->renderFormat()) { + $tpl->assign('content', $file->render()); + $tpl->assign('file', $file); + $tpl->display('common/files/_preview.tpl'); +} +else if ($html = $file->editorHTML(true)) { + echo $html; +} +else { + // We don't need $session here as read access is already checked above + $file->serve(); +} diff --git a/src/www/admin/common/files/rename.php b/src/www/admin/common/files/rename.php new file mode 100644 index 0000000..32a634f --- /dev/null +++ b/src/www/admin/common/files/rename.php @@ -0,0 +1,29 @@ +parent(); + +if (!$parent->canCreateHere()) { + throw new UserException('Vous n\'avez pas le droit de modifier ce fichier.'); +} + +$csrf_key = 'file_rename_' . $file->pathHash(); + +$form->runIf('rename', function () use ($file) { + $file->changeFileName(f('new_name'), true, true); +}, $csrf_key, '!docs/?path=' . $file->parent); + +$tpl->assign(compact('file', 'csrf_key')); + +$tpl->display('common/files/rename.tpl'); \ No newline at end of file diff --git a/src/www/admin/common/files/share.php b/src/www/admin/common/files/share.php new file mode 100644 index 0000000..9228346 --- /dev/null +++ b/src/www/admin/common/files/share.php @@ -0,0 +1,39 @@ +canWrite()) { + throw new UserException('Vous n\'avez pas le droit de partager ce fichier.'); +} + +$context = $file->context(); + +$csrf_key = 'file_share_' . $file->pathHash(); +$share_url = null; + +$form->runIf('share', function () use ($file, &$share_url) { + $share_url = $file->createShareLink(f('expiry'), f('password')); +}, $csrf_key); + +$expiry_options = [ + 1 => 'Une heure', + 24 => 'Un jour', + 24*31 => 'Un mois', + 24*365 => 'Un an', + 24*365*10 => 'Dix ans', + 24*365*30 => 'Infinie', +]; + +$tpl->assign(compact('file', 'csrf_key', 'share_url', 'expiry_options')); + +$tpl->display('common/files/share.tpl'); \ No newline at end of file diff --git a/src/www/admin/common/files/upload.php b/src/www/admin/common/files/upload.php new file mode 100644 index 0000000..b57c53c --- /dev/null +++ b/src/www/admin/common/files/upload.php @@ -0,0 +1,26 @@ +runIf('upload', function () use ($parent) { + Files::uploadMultiple($parent, 'file'); +}, $csrf_key, '!docs/?path=' . $parent); + +$max = (int) qg('max'); +$multiple = $max > 1; + +$tpl->assign(compact('parent', 'csrf_key', 'multiple')); + +$tpl->display('common/files/upload.tpl'); diff --git a/src/www/admin/common/saved_searches.php b/src/www/admin/common/saved_searches.php new file mode 100644 index 0000000..840c1c0 --- /dev/null +++ b/src/www/admin/common/saved_searches.php @@ -0,0 +1,59 @@ +id_user !== null && $s->id_user != Session::getInstance()->getUser()->id) { + throw new UserException('Recherche privée appartenant à un autre membre.'); + } + + $csrf_key = 'search_' . $s->id; + + $form->runIf('save', function () use ($s) { + $s->importForm(); + $s->set('id_user', f('public') ? null : Session::getUserId()); + $s->save(); + }, $csrf_key, Utils::getSelfURI(false)); + + $form->runIf('delete', function () use ($s) { + $s->delete(); + }, $csrf_key, Utils::getSelfURI(false)); + + $tpl->assign('search', $s); + $tpl->assign('csrf_key', $csrf_key); + + $mode = qg('edit') ? 'edit' : 'delete'; +} +else { + $tpl->assign('list', Search::list(CURRENT_SEARCH_TARGET, Session::getUserId())); + $mode = 'list'; +} + +$target = CURRENT_SEARCH_TARGET; +$tpl->assign(compact('mode', 'target', 'search_url', 'access_section')); + +$tpl->display('common/search/saved_searches.tpl'); diff --git a/src/www/admin/common/search.php b/src/www/admin/common/search.php new file mode 100644 index 0000000..3003c6c --- /dev/null +++ b/src/www/admin/common/search.php @@ -0,0 +1,121 @@ +requireAccess($access_section, Session::ACCESS_READ); + +$id = f('id') ?: qg('id'); + +if ($id) { + $s = Search::get($id); + + if (!$s) { + throw new UserException('Recherche inconnue ou invalide'); + } +} +else { + $s = new SE; + $s->target = CURRENT_SEARCH_TARGET; + $s->created = new \DateTime(); +} + +$text_query = trim((string) qg('qt')); +$sql_query = trim((string) f('sql')); +$json_query = f('q') ? json_decode(f('q'), true) : null; +$default = false; + +if ($text_query !== '') { + $s->content = json_encode($s->getAdvancedSearch()->simple($text_query, true)); + $s->type = SE::TYPE_JSON; +} +elseif ($sql_query !== '') { + // Only admins can run custom queries, others can only run saved queries + $session->requireAccess($access_section, $session::ACCESS_ADMIN); + + if (Session::getInstance()->canAccess($access_section, Session::ACCESS_ADMIN) && f('unprotected')) { + $s->type = SE::TYPE_SQL_UNPROTECTED; + } + else { + $s->type = SE::TYPE_SQL; + } + + $s->content = $sql_query; +} +elseif ($json_query !== null) { + $s->content = json_encode(['groups' => $json_query]); + $s->type = SE::TYPE_JSON; +} +elseif (!$s->content) { + $s->content = json_encode($s->getAdvancedSearch()->defaults()); + $s->type = SE::TYPE_JSON; + $default = true; +} + +if (f('to_sql')) { + $s->transformToSQL(); +} + +$form->runIf(f('save') || f('save_new'), function () use ($s) { + if (f('save_new') || !$s->exists()) { + $s = clone $s; + $label = $s->type != $s::TYPE_JSON ? 'Recherche SQL du ' : 'Recherche avancée du '; + $label .= date('d/m/Y à H:i'); + $s->label = $label; + } + + $s->save(); + + $target = $s->target == $s::TARGET_ACCOUNTING ? 'acc' : 'users'; + Utils::redirect(sprintf('!%s/saved_searches.php?edit=%d', $target, $s->id())); +}); + +$list = $results = $header = $count = null; + +if (!$default) { + try { + if ($s->type == $s::TYPE_JSON) { + $list = $s->getDynamicList(); + $list->loadFromQueryString(); + $count = $list->count(); + } + else { + if (!empty($_POST['_export'])) { + $s->export($_POST['_export']); + exit; + } + + $header = $s->getHeader(); + $count = $s->countResults(false); + $results = $s->iterateResults(); + $tpl->assign('has_limit', $s->hasLimit()); + } + } + catch (UserException $e) { + $form->addError($e->getMessage()); + } +} + +$is_admin = $session->canAccess($access_section, $session::ACCESS_ADMIN); +$schema = $s->schema(); +$columns = $s->getAdvancedSearch()->columns(); +$columns = array_filter($columns, fn($c) => $c['label'] ?? null && $c['type'] ?? null); // remove columns only for dynamiclist + +$tpl->assign(compact('s', 'list', 'header', 'results', 'columns', 'count', 'is_admin', 'schema')); + +if ($s->target == $s::TARGET_ACCOUNTING) { + $tpl->display('acc/search.tpl'); +} +else { + $tpl->display('users/search.tpl'); +} diff --git a/src/www/admin/config/_inc.php b/src/www/admin/config/_inc.php new file mode 100644 index 0000000..b751157 --- /dev/null +++ b/src/www/admin/config/_inc.php @@ -0,0 +1,9 @@ +requireAccess($session::SECTION_CONFIG, $session::ACCESS_ADMIN); + +$tpl->assign('custom_css', ['config.css']); diff --git a/src/www/admin/config/advanced/api.php b/src/www/admin/config/advanced/api.php new file mode 100644 index 0000000..9d5ca0d --- /dev/null +++ b/src/www/admin/config/advanced/api.php @@ -0,0 +1,29 @@ +runIf('add', function () { + API_Credentials::create(); +}, $csrf_key, Utils::getSelfURI()); + +$form->runIf('delete', function () { + API_Credentials::delete((int)f('id')); +}, $csrf_key, Utils::getSelfURI()); + +$list = API_Credentials::list(); +$default_key = API_Credentials::generateKey(); +$secret = API_Credentials::generateSecret(); +$access_levels = API_Entity::ACCESS_LEVELS; + +$tpl->assign('website', WEBSITE); +$tpl->assign(compact('list', 'csrf_key', 'default_key', 'secret', 'access_levels')); + +$tpl->display('config/advanced/api.tpl'); diff --git a/src/www/admin/config/advanced/audit.php b/src/www/admin/config/advanced/audit.php new file mode 100644 index 0000000..57f6070 --- /dev/null +++ b/src/www/admin/config/advanced/audit.php @@ -0,0 +1,14 @@ +loadFromQueryString(); + +$tpl->assign(compact('list')); + +$tpl->display('config/advanced/audit.tpl'); diff --git a/src/www/admin/config/advanced/errors.php b/src/www/admin/config/advanced/errors.php new file mode 100644 index 0000000..f205312 --- /dev/null +++ b/src/www/admin/config/advanced/errors.php @@ -0,0 +1,56 @@ +context->date = strtotime($report->context->date); +} + +unset($report); + +$errors = []; + +if (qg('id')) +{ + if (!count($reports)) { + throw new UserException('Erreur inconnue'); + } + + $tpl->assign('id', qg('id')); + $tpl->assign('main', reset($reports)); + $tpl->assign('reports', $reports); +} +else +{ + foreach ($reports as $report) + { + if (!isset($errors[$report->context->id])) + { + $errors[$report->context->id] = [ + 'message' => $report->errors[0]->message, + 'source' => sprintf('%s:%d', $report->errors[0]->backtrace[0]->file, $report->errors[0]->backtrace[0]->line), + 'count' => 0, + ]; + } + + $errors[$report->context->id]['last_seen'] = $report->context->date; + $errors[$report->context->id]['hostname'] = $report->context->hostname ?? null; + $errors[$report->context->id]['count']++; + } + + $tpl->assign('errors', $errors); +} + +$tpl->display('config/advanced/errors.tpl'); diff --git a/src/www/admin/config/advanced/index.php b/src/www/admin/config/advanced/index.php new file mode 100644 index 0000000..aaff20c --- /dev/null +++ b/src/www/admin/config/advanced/index.php @@ -0,0 +1,6 @@ +display('config/advanced/index.tpl'); diff --git a/src/www/admin/config/advanced/reopen.php b/src/www/admin/config/advanced/reopen.php new file mode 100644 index 0000000..0655948 --- /dev/null +++ b/src/www/admin/config/advanced/reopen.php @@ -0,0 +1,15 @@ +runIf('reopen_ok', function () use ($session) { + $year = Years::get((int) f('year')); + $year->reopen($session->getUser()->id); +}, 'reopen_year', '!config/advanced/?msg=REOPEN'); + +$tpl->assign('closed_years', Years::listClosedAssoc()); + +$tpl->display('config/advanced/reopen.tpl'); diff --git a/src/www/admin/config/advanced/reset.php b/src/www/admin/config/advanced/reset.php new file mode 100644 index 0000000..d88968d --- /dev/null +++ b/src/www/admin/config/advanced/reset.php @@ -0,0 +1,19 @@ +password)) { + throw new UserException('Votre compte ne dispose pas de mot de passe, cette fonctionnalité est désactivée.'); +} + +$form->runIf('reset_ok', function () use ($session) { + Install::reset($session, f('password_check') ?? ''); +}, 'reset'); + +$tpl->display('config/advanced/reset.tpl'); diff --git a/src/www/admin/config/advanced/sql.php b/src/www/admin/config/advanced/sql.php new file mode 100644 index 0000000..6952616 --- /dev/null +++ b/src/www/admin/config/advanced/sql.php @@ -0,0 +1,119 @@ +getGrouped('SELECT name, sql, NULL AS count, NULL AS schema FROM sqlite_master + WHERE type = \'table\' AND name NOT LIKE \'files_search_%\' AND name NOT IN (\'sqlite_stat1\') + ORDER BY name;'); + +if (qg('table') && array_key_exists(qg('table'), $tables_list)) { + $table = qg('table'); + $all_columns = $db->get(sprintf('PRAGMA table_info(%s);', $db->quoteIdentifier($table))); + + if (!$all_columns) { + throw new UserException('This table does not exist'); + } + + $is_module = 0 === strpos($table, 'module_data_'); + + $columns = []; + + foreach ($all_columns as $c) { + $columns[$c->name] = ['label' => $c->name]; + } + + $list = new DynamicList($columns, $table); + $list->orderBy(key($columns), false); + $list->setTitle($table); + $list->loadFromQueryString(); + + $tpl->assign(compact('table', 'list', 'is_module')); +} +elseif (qg('table_info') && array_key_exists(qg('table_info'), $tables_list)) { + $name = qg('table_info'); + $info = $tables_list[$name]; + $info->schema = $db->getTableSchema($name); + $info->indexes = $db->getTableIndexes($name); + + $sql_indexes = []; + + foreach ($info->indexes as $index) { + $sql_indexes[] = $db->firstColumn('SELECT sql FROM sqlite_master WHERE type = \'index\' AND name = ?;', $index['name']); + } + + $info->sql_indexes = implode(";\n", $sql_indexes); + $tpl->assign('table_info', $info); +} +elseif (($pragma = qg('pragma')) || isset($query)) { + try { + $query_time = microtime(true); + + if ($pragma) { + $query = ''; + $result = []; + $result_header = null; + + if ($pragma == 'integrity_check') { + $result = $db->get('PRAGMA integrity_check;'); + } + elseif ($pragma == 'foreign_key_check') { + $result = $db->get('PRAGMA foreign_key_check;') ?: [['no errors']]; + } + elseif (ENABLE_TECH_DETAILS && $pragma == 'vacuum') { + $result[] = ['Size before VACUUM: ' . Backup::getDBSize()]; + $db->exec('VACUUM;'); + $result[] = ['Size after VACUUM: ' . Backup::getDBSize()]; + } + + $result_count = count($result); + } + elseif (!empty($query)) { + $s = Search::fromSQL($query); + + if (f('export')) { + $s->export(f('export'), 'Requête SQL'); + return; + } + + $result = $s->iterateResults(); + $result_header = $s->getHeader(); + $result_count = $s->countResults(); + } + else { + $result = $result_count = $result_header = null; + } + + $query_time = round((microtime(true) - $query_time) * 1000, 3); + + $tpl->assign(compact('result', 'result_header', 'result_count', 'query_time')); + } + catch (UserException $e) { + $form->addError($e->getMessage()); + } +} +else { + foreach ($tables_list as $name => &$data) { + $data->count = $db->count($name); + $data->size = $db->getTableSize($name); + } + + unset($data); + + $tpl->assign('index_list',$db->getAssoc('SELECT name, sql FROM sqlite_master WHERE type = \'index\' AND name NOT LIKE \'sqlite_%\' ORDER BY name;')); + $tpl->assign('triggers_list', $db->getAssoc('SELECT name, sql FROM sqlite_master WHERE type = \'trigger\' ORDER BY name;')); +} + +$tpl->assign(compact('tables_list', 'query', 'list')); + +$tpl->register_modifier('format_json', function (string $str) { + return json_encode(json_decode($str, true), JSON_PRETTY_PRINT); +}); + +$tpl->display('config/advanced/sql.tpl'); diff --git a/src/www/admin/config/advanced/sql_debug.php b/src/www/admin/config/advanced/sql_debug.php new file mode 100644 index 0000000..bb9ad9e --- /dev/null +++ b/src/www/admin/config/advanced/sql_debug.php @@ -0,0 +1,23 @@ +disableLog(); + +if (qg('id')) +{ + $tpl->assign('debug', DB::getDebugSession(qg('id'))); +} +else +{ + $tpl->assign('list', DB::getDebugSessionsList()); +} + +$tpl->display('config/advanced/sql_debug.tpl'); diff --git a/src/www/admin/config/backup/auto.php b/src/www/admin/config/backup/auto.php new file mode 100644 index 0000000..a7f8484 --- /dev/null +++ b/src/www/admin/config/backup/auto.php @@ -0,0 +1,41 @@ +runIf('config', function () { + $frequency = (int) f('backup_frequency'); + + if ($frequency < 0 || $frequency > 365) { + throw new UserException('Fréquence invalide'); + } + + $number = (int) f('backup_limit'); + + if ($number < 0 || $number > 50) { + throw new UserException('Nombre de sauvegardes invalide. Le maximum est de 50 sauvegardes.'); + } + + $config = Config::getInstance(); + $config->set('backup_frequency', $frequency); + $config->set('backup_limit', $number); + $config->save(); +}, $csrf_key, '!config/backup/auto.php?msg=CONFIG_SAVED'); + +$frequencies = [ + 0 => 'Aucun — les sauvegardes automatiques sont désactivées', + 1 => 'Quotidienne, tous les jours', + 7 => 'Hebdomadaire, tous les 7 jours', + 15 => 'Bimensuelle, tous les 15 jours', + 30 => 'Mensuelle', + 90 => 'Trimestrielle', + 365 => 'Annuelle', +]; + +$tpl->assign(compact('frequencies', 'csrf_key')); + +$tpl->display('config/backup/auto.tpl'); diff --git a/src/www/admin/config/backup/documents.php b/src/www/admin/config/backup/documents.php new file mode 100644 index 0000000..79d8031 --- /dev/null +++ b/src/www/admin/config/backup/documents.php @@ -0,0 +1,41 @@ +runIf('restore', function () { + try { + // Decompress (inflate) raw data + if (empty($_FILES['file1']['error']) && !empty($_FILES['file1']['tmp_name']) && f('compressed')) { + $f = $_FILES['file1']['tmp_name']; + file_put_contents($f, gzinflate(file_get_contents($f), 1024*1024*1024)); + } + + Files::upload(Utils::dirname(f('target')), 'file1'); + } + catch (UserException $e) { + die(json_encode(['success' => false, 'error' => f('target') . ': '. $e->getMessage()])); + } + + die(json_encode(['success' => true, 'error' => null])); +}, 'files_restore'); + + +$ok = qg('ok') !== null; +$failed = (int) qg('failed'); + +if ($ok) { + // Reset + $config = Config::getInstance(); + $config->updateFiles(); + $config->save(); + $tpl->assign(compact('config')); +} + +$tpl->assign(compact('failed', 'ok')); + +$tpl->display('config/backup/documents.tpl'); diff --git a/src/www/admin/config/backup/index.php b/src/www/admin/config/backup/index.php new file mode 100644 index 0000000..560f561 --- /dev/null +++ b/src/www/admin/config/backup/index.php @@ -0,0 +1,34 @@ +runIf('download', function () { + Backup::dump(); + exit; +}, $csrf_key); + +// Create local backup +$form->runIf('create', function () { + Backup::create(); +}, $csrf_key, '!config/backup/?msg=BACKUP_CREATED'); + +// Download all files as ZIP +$form->runIf('zip', function () { + Files::zipAll(); + exit; +}, $csrf_key); + +$ok = qg('ok'); // return message +$db_size = Backup::getDBSize(); +$files_size = Files::getUsedQuota(); + +$tpl->assign(compact('ok', 'db_size', 'files_size', 'csrf_key')); + +$tpl->display('config/backup/index.tpl'); diff --git a/src/www/admin/config/backup/restore.php b/src/www/admin/config/backup/restore.php new file mode 100644 index 0000000..8f640a8 --- /dev/null +++ b/src/www/admin/config/backup/restore.php @@ -0,0 +1,65 @@ +refresh()) { + $session->forceLogin(-1); + $ok_code |= Backup::CHANGED_USER; + } +} + +if (qg('download')) { + Backup::dump(qg('download')); + exit; +} + +$form->runIf('restore', function () use ($session) { + if (!f('selected')) { + throw new UserException('Aucune sauvegarde sélectionnée'); + } + + $r = Backup::restoreFromLocal(f('selected'), $session); + Utils::redirect(Utils::getSelfURI(['ok' => 'restore', 'code' => (int)$r])); +}, 'backup_manage'); + +$form->runIf('remove', function () { + if (!f('selected')) { + throw new UserException('Aucune sauvegarde sélectionnée'); + } + + Backup::remove(f('selected')); +}, 'backup_manage', Utils::getSelfURI(['ok' => 'remove'])); + + +$form->runIf('restore_file', function () use (&$code, $session, $form) { + // Ignorer la vérification d'intégrité si autorisé et demandé + $check = (ALLOW_MODIFIED_IMPORT && f('force_import')) ? false : true; + + try { + $r = Backup::restoreFromUpload($_FILES['file'], $session, $check); + Utils::redirect(Utils::getSelfURI(['ok' => 'restore', 'code' => (int)$r])); + } catch (UserException $e) { + $code = $e->getCode(); + if ($code === 0) { + throw $e; + } + $form->addError($e->getMessage()); + } +}, 'backup_restore'); + +$list = Backup::list(); +$size = Backup::getAllBackupsTotalSize(); + +$tpl->assign(compact('code', 'list', 'ok', 'ok_code', 'size')); + +$tpl->display('config/backup/restore.tpl'); diff --git a/src/www/admin/config/backup/versions.php b/src/www/admin/config/backup/versions.php new file mode 100644 index 0000000..1fce9a8 --- /dev/null +++ b/src/www/admin/config/backup/versions.php @@ -0,0 +1,46 @@ +runIf('save', function () { + $config = Config::getInstance(); + $config->importForm(); + $config->save(); +}, $csrf_key, '!config/backup/versions.php?msg=CONFIG_SAVED'); + + +$form->runIf('prune_versions', function() use ($versioning_policy) { + if ('none' === $versioning_policy) { + throw new UserException('Le versionnement des fichiers est désactivé.'); + } + + Files::pruneOldVersions(); +}, $csrf_key, '!config/backup/versions.php?msg=PRUNED'); + +$form->runIf('delete', function() use ($versioning_policy) { + if ('none' !== $versioning_policy) { + throw new UserException('Le versionnement des fichiers n\'est pas désactivé.'); + } + + Files::deleteAllVersions(); +}, $csrf_key, '!config/backup/versions.php?msg=DELETED'); + +$versioning_policies = Config::VERSIONING_POLICIES; +$disk_use = Files::getContextDiskUsage(File::CONTEXT_VERSIONS); + +$tpl->assign(compact('csrf_key', 'versioning_policies', 'disk_use')); + +if (isset($_GET['delete_versions'])) { + $tpl->display('config/backup/versions_delete.tpl'); +} +else { + $tpl->display('config/backup/versions.tpl'); +} diff --git a/src/www/admin/config/categories/delete.php b/src/www/admin/config/categories/delete.php new file mode 100644 index 0000000..b49cd67 --- /dev/null +++ b/src/www/admin/config/categories/delete.php @@ -0,0 +1,28 @@ +getUser(); + +$csrf_key = 'cat_delete_' . $cat->id(); + +if ($cat->id() == $user->id_category) { + throw new UserException("Vous ne pouvez pas supprimer votre catégorie."); +} + +$form->runIf('delete', function () use($cat) { + $cat->delete(); +}, $csrf_key, '!config/categories/'); + +$tpl->assign(compact('cat', 'csrf_key')); + +$tpl->display('config/categories/delete.tpl'); diff --git a/src/www/admin/config/categories/edit.php b/src/www/admin/config/categories/edit.php new file mode 100644 index 0000000..08ad67e --- /dev/null +++ b/src/www/admin/config/categories/edit.php @@ -0,0 +1,54 @@ +getUser(); + +$csrf_key = 'cat_edit_' . $cat->id(); +$admin_safe = $session->isAdmin() && $cat->id == $user->id_category; + +$form->runIf('save', function () use ($cat, $session) { + $user = $session->getUser(); + $cat->importForm(); + $cat->hidden = (bool) f('hidden'); + + // Ne pas permettre de modifier la connexion, l'accès à la config et à la gestion des membres + // pour la catégorie du membre qui édite les catégories, sinon il pourrait s'empêcher + // de se connecter ou n'avoir aucune catégorie avec le droit de modifier les catégories ! + if ($cat->id() == $user->id_category) { + $cat->set('perm_connect', Session::ACCESS_READ); + $cat->set('perm_config', Session::ACCESS_ADMIN); + } + + $cat->save(); + + if ($cat->id() == $user->id_category) { + $session->refresh(); + } +}, $csrf_key, '!config/categories/'); + + +$permissions = Category::PERMISSIONS; + +foreach ($permissions as $key => &$config) { + if ($admin_safe && in_array($key, [Session::SECTION_CONFIG, Session::SECTION_CONNECT])) { + $config['disabled'] = true; + } +} + +unset($config); + +$tpl->assign(compact('csrf_key', 'cat', 'permissions')); + +$tpl->display('config/categories/edit.tpl'); diff --git a/src/www/admin/config/categories/index.php b/src/www/admin/config/categories/index.php new file mode 100644 index 0000000..8a648fc --- /dev/null +++ b/src/www/admin/config/categories/index.php @@ -0,0 +1,28 @@ +runIf('save', function() { + $cat = new Category; + + $cat->importForm([ + 'name' => f('name'), + 'hidden' => 0, + ]); + $cat->setAllPermissions(Session::ACCESS_NONE); + + $cat->save(); +}, $csrf_key, Utils::getSelfURI()); + +$list = Categories::listWithStats(); + +$tpl->assign(compact('list', 'csrf_key')); + +$tpl->display('config/categories/index.tpl'); diff --git a/src/www/admin/config/custom.php b/src/www/admin/config/custom.php new file mode 100644 index 0000000..3abf0b1 --- /dev/null +++ b/src/www/admin/config/custom.php @@ -0,0 +1,34 @@ +runIf('save', function () use ($config) { + $config->importForm(); + + if (f('admin_background') == 'RESET') { + $config->setFile('admin_background', null); + } + elseif (f('admin_background')) { + $config->setFile('admin_background', base64_decode(f('admin_background'))); + } + + $config->save(); +}, 'config_custom', Utils::getSelfURI(['ok' => ''])); + +$tpl->assign([ + 'color1' => ADMIN_COLOR1, + 'color2' => ADMIN_COLOR2, +]); + +$tpl->assign('background_image_current', $config->fileURL('admin_background')); +$tpl->assign('background_image_default', ADMIN_BACKGROUND_IMAGE); + +$tpl->assign('custom_js', ['color_helper.js']); +$tpl->display('config/custom.tpl'); diff --git a/src/www/admin/config/disk_usage.php b/src/www/admin/config/disk_usage.php new file mode 100644 index 0000000..cc81574 --- /dev/null +++ b/src/www/admin/config/disk_usage.php @@ -0,0 +1,26 @@ + Files::getUsedQuota(), + 'quota_max' => Files::getQuota(), + 'quota_left' => Files::getRemainingQuota(), + 'contexts' => Files::getContextsDiskUsage(), + 'db_backups' => Backup::getAllBackupsTotalSize(), + 'db' => Backup::getDBSize(), +]; + +$sizes['db_total'] = $sizes['db'] + $sizes['db_backups']; +$tpl->assign($sizes); + +$tpl->assign(compact('csrf_key', 'versioning_policy')); + +$tpl->display('config/disk_usage.tpl'); diff --git a/src/www/admin/config/donnees/import.php b/src/www/admin/config/donnees/import.php new file mode 100644 index 0000000..adb93ef --- /dev/null +++ b/src/www/admin/config/donnees/import.php @@ -0,0 +1,6 @@ +display('config/donnees/import.tpl'); diff --git a/src/www/admin/config/edit_file.php b/src/www/admin/config/edit_file.php new file mode 100644 index 0000000..bfc7e64 --- /dev/null +++ b/src/www/admin/config/edit_file.php @@ -0,0 +1,54 @@ +file($key); + +$type = Config::FILES_TYPES[$key]; +$csrf_key = 'edit_file_' . $key; + +$form->runIf('upload', function () use ($key, $config) { + $config->setFile($key, 'file', true); + $config->save(); +}, $csrf_key, Utils::getSelfURI()); + +$form->runIf('reset', function () use ($key, $config) { + $config->setFile($key, null); + $config->save(); +}, $csrf_key, Utils::getSelfURI()); + +$form->runIf('save', function () use ($key, $config) { + $content = trim((string) f('content')); + $config->setFile($key, $content === '' ? null : $content); + $config->save(); + + if (qg('js') !== null) { + die('{"success":true}'); + } + +}, $csrf_key, Utils::getSelfURI()); + +$tpl->assign(compact('csrf_key', 'file')); + +if ($type == 'image') { + $tpl->display('config/edit_image.tpl'); +} +else { + $content = $file ? $file->fetch() : ''; + $path = Config::FILES[$key]; + $format = $file ? $file->renderFormat() : 'skriv'; + $tpl->assign(compact('content', 'path', 'format')); + $tpl->display(sprintf('common/files/edit_%s.tpl', $type)); +} diff --git a/src/www/admin/config/ext/delete.php b/src/www/admin/config/ext/delete.php new file mode 100644 index 0000000..a940397 --- /dev/null +++ b/src/www/admin/config/ext/delete.php @@ -0,0 +1,52 @@ +enabled) { + throw new UserException('Impossible de supprimer une extension activée'); + } + + $form->runIf(f('delete') && f('confirm_delete'), function () use ($plugin) { + $plugin->delete(); + }, $csrf_key, '!config/ext/'); +} +else { + $module = Modules::get(qg('module')); + + if ($mode === 'data' && !$module->canDeleteData()) { + throw new UserException('Impossible de supprimer les données de ce module.'); + } + elseif ($mode === 'reset' && !$module->canReset()) { + throw new UserException('Impossible de remettre ce module à son état antérieur.'); + } + elseif ($mode === 'delete' && !$module->canDelete()) { + throw new UserException('Impossible de supprimer ce module.'); + } + + $form->runIf(f('delete') && f('confirm_delete'), function () use ($module, $mode) { + if ($mode === 'data') { + $module->deleteData(); + } + elseif ($mode === 'reset') { + $module->resetChanges(); + } + else { + $module->delete(); + } + }, $csrf_key, '!config/ext/'); +} + +$tpl->assign(compact('plugin', 'module', 'csrf_key', 'mode')); + +$tpl->display('config/ext/delete.tpl'); diff --git a/src/www/admin/config/ext/details.php b/src/www/admin/config/ext/details.php new file mode 100644 index 0000000..3d11558 --- /dev/null +++ b/src/www/admin/config/ext/details.php @@ -0,0 +1,65 @@ +name; +$module = $ext->module ?? null; +$plugin = $ext->plugin ?? null; + +$form->runIf(f('enable') || f('disable'), function () use ($ext) { + $enabled = f('enable') ? true : false; + Extensions::toggle($ext->type, $ext->name, $enabled); + Utils::redirect(sprintf('!config/ext/details.php?type=%s&name=%s&toggle=%d', $ext->type, $ext->name, $enabled)); +}, $csrf_key); + +if (isset($_GET['disk'])) { + $mode = 'disk'; +} +elseif (isset($_GET['readme'])) { + $mode = 'readme'; + $ext_object = $module ?? $plugin; + $tpl->assign('content', $ext_object->fetchFile($ext_object::README_FILE)); + $tpl->assign('custom_css', ['config.css', '/content.css']); +} +else { + $mode = 'details'; + + $snippets = $module ? $module->listSnippets() : []; + $access_details = []; + + if ($ext->config_url) { + $access_details[] = sprintf('Cette extension a une page de configuration', $ext->config_url); + } + + if (!empty($ext->menu)) { + $access_details[] = 'Cette extension ajoute un élément au menu de gauche, en dessous de la page d\'accueil.'; + } + + if (!empty($ext->home_button)) { + $access_details[] = 'Cette extension ajoute un bouton sur la page d\'accueil.'; + } + + foreach ($snippets as $label) { + $access_details[] = sprintf('Cette extension insère un élément : %s', htmlspecialchars($label)); + } + + $tpl->assign(compact('access_details')); +} + +$tpl->assign('url_help_plugins', 'https://fossil.kd2.org/paheko/wiki/?name=Extensions'); + +$tpl->assign(compact('mode', 'csrf_key', 'ext', 'module', 'plugin')); + +$tpl->display('config/ext/details.tpl'); diff --git a/src/www/admin/config/ext/diff.php b/src/www/admin/config/ext/diff.php new file mode 100644 index 0000000..b6f7d4e --- /dev/null +++ b/src/www/admin/config/ext/diff.php @@ -0,0 +1,21 @@ +assign([ + 'local' => $module->fetchLocalFile($path), + 'dist' => $module->fetchDistFile($path), +]); + +$tpl->display('config/ext/diff.tpl'); diff --git a/src/www/admin/config/ext/edit.php b/src/www/admin/config/ext/edit.php new file mode 100644 index 0000000..3b94e60 --- /dev/null +++ b/src/www/admin/config/ext/edit.php @@ -0,0 +1,27 @@ +export(Session::getInstance()); + return; +} + +$path = qg('p'); +$parent_path_uri = rawurlencode($module->path($path)); +$list = $module->listFiles($path); + +$url_help_modules = sprintf(HELP_PATTERN_URL, 'modules'); +$tpl->assign(compact('list', 'url_help_modules', 'module', 'path', 'parent_path_uri')); + +$tpl->display('config/ext/edit.tpl'); diff --git a/src/www/admin/config/ext/import.php b/src/www/admin/config/ext/import.php new file mode 100644 index 0000000..e6dd5b9 --- /dev/null +++ b/src/www/admin/config/ext/import.php @@ -0,0 +1,36 @@ +runIf('import', function () use (&$exists) { + if (empty($_FILES['zip']['tmp_name'])) { + throw new UserException('Aucun fichier reçu.'); + } + + try { + $m = Modules::import($_FILES['zip']['tmp_name'], !empty($_POST['overwrite'])); + } + catch (\InvalidArgumentException $e) { + throw new UserException($e->getMessage(), 0, $e); + } + + if (!$m) { + $exists = true; + throw new UserException('Un module avec ce nom unique existe déjà. Pour écraser ce module, recommencer en cochant la case en bas du formulaire.'); + } + + $i = (int)!$m->enabled; + Utils::redirectDialog(sprintf('!config/ext/?install=%d&focus=%s', $i, $m->name)); +}, $csrf_key); + +$tpl->assign(compact('csrf_key', 'exists')); + +$tpl->display('config/ext/import.tpl'); diff --git a/src/www/admin/config/ext/index.php b/src/www/admin/config/ext/index.php new file mode 100644 index 0000000..eaf656b --- /dev/null +++ b/src/www/admin/config/ext/index.php @@ -0,0 +1,56 @@ +runIf(f('enable') !== null || f('disable') !== null, function () { + $ext = f('enable') ?? f('disable'); + + if (!is_array($ext)) { + throw new UserException('Unknown action.'); + } + + $enabled = f('enable') ? true : false; + $type = key($ext); + $name = current($ext); + + Extensions::toggle($type, $name, $enabled); + Utils::redirect('!config/ext/?focus=' . $name); +}, $csrf_key); + +if (qg('install')) { + foreach (Modules::refresh() as $error) { + // Errors are not used currently + $form->addError('Module ' . $error); + } + + foreach (Plugins::refresh() as $error) { + // Errors are not used currently + $form->addError('Plugin ' . $error); + } + + $list = Extensions::listDisabled(); + $tpl->assign('url_plugins', ENABLE_TECH_DETAILS ? WEBSITE . 'wiki?name=Extensions' : null); + $tpl->assign('installable', true); +} +else { + Modules::refreshEnabledModules(); + $list = Extensions::listEnabled(); + $tpl->assign('installable', false); +} + +$url_help_modules = sprintf(HELP_PATTERN_URL, 'modules'); +$tpl->assign(compact('list', 'csrf_key', 'url_help_modules')); + +$tpl->display('config/ext/index.tpl'); + +flush(); +Plugins::upgradeAllIfRequired(); diff --git a/src/www/admin/config/ext/new.php b/src/www/admin/config/ext/new.php new file mode 100644 index 0000000..d1f7101 --- /dev/null +++ b/src/www/admin/config/ext/new.php @@ -0,0 +1,36 @@ +runIf('create', function () { + $module = new Module; + $module->importForm(); + $module->set('web', false); + $module->save(); + $module->exportToIni(); + + Utils::redirectDialog(sprintf('!config/ext/edit.php?module=%s', $module->name)); +}, $csrf_key); + + +$types = [0 => 'Module normal', 1 => 'Site web']; +$sections = [null => '— Pas de restriction —']; + +foreach (Category::PERMISSIONS as $section => $details) { + $sections[$details['label']] = []; + + foreach ($details['options'] as $l => $label) { + $sections[$details['label']][$section . '_' . $l] = $label; + } +} + +$tpl->assign(compact('csrf_key', 'sections', 'types')); + +$tpl->display('config/ext/new.tpl'); diff --git a/src/www/admin/config/fields/delete.php b/src/www/admin/config/fields/delete.php new file mode 100644 index 0000000..8c84a9f --- /dev/null +++ b/src/www/admin/config/fields/delete.php @@ -0,0 +1,30 @@ +fieldById((int)qg('id')); + +if (!$field) { + throw new UserException('Le champ indiqué n\'existe pas.'); +} + +$form->runIf('delete', function () use ($field, $fields) { + if (!f('confirm_delete')) { + throw new UserException('Merci de bien vouloir cocher la case pour confirmer la suppression.'); + } + + $fields->delete($field->name); + $fields->save(); +}, $csrf_key, '!config/fields/?msg=DELETED'); + +$tpl->assign(compact('csrf_key', 'field')); + +$tpl->display('config/fields/delete.tpl'); diff --git a/src/www/admin/config/fields/edit.php b/src/www/admin/config/fields/edit.php new file mode 100644 index 0000000..2108815 --- /dev/null +++ b/src/www/admin/config/fields/edit.php @@ -0,0 +1,36 @@ +fieldById((int)qg('id')); +} +else { + $field = new DynamicField; +} + +if (!$field) { + throw new UserException('Le champ indiqué n\'existe pas.'); +} + +$form->runIf('save', function () use ($field, $fields) { + $field->importForm(); + + if (!$field->exists()) { + $field->sort_order = $fields->getLastOrderIndex(); + $fields->add($field); + } + + $fields->save(); +}, $csrf_key, '!config/fields/?msg=SAVED'); + +$tpl->assign(compact('csrf_key', 'field')); + +$tpl->display('config/fields/edit.tpl'); diff --git a/src/www/admin/config/fields/index.php b/src/www/admin/config/fields/index.php new file mode 100644 index 0000000..80ba43a --- /dev/null +++ b/src/www/admin/config/fields/index.php @@ -0,0 +1,19 @@ +runIf('save', function () use ($fields) { + $fields->setOrderAll(f('sort_order')); + $fields->save(); +}, $csrf_key, '!config/fields/?msg=SAVED_ORDER'); + +$tpl->assign('fields', $fields->all()); +$tpl->assign(compact('csrf_key')); + +$tpl->display('config/fields/index.tpl'); diff --git a/src/www/admin/config/fields/new.php b/src/www/admin/config/fields/new.php new file mode 100644 index 0000000..8df3582 --- /dev/null +++ b/src/www/admin/config/fields/new.php @@ -0,0 +1,38 @@ +getInstallablePresets(); + +// No presets left to install +if (!count($presets)) { + Utils::redirect('!config/fields/edit.php'); +} + +$form->runIf('add', function () use ($fields) { + $preset = f('preset'); + + if (!$preset) { + Utils::redirect('!config/fields/edit.php'); + } + + $field = $fields->installPreset(f('preset')); + + if (!$field->exists()) { + $field->sort_order = $fields->getLastOrderIndex(); + $fields->add($field); + } + + $fields->save(); +}, $csrf_key, '!config/fields/?msg=SAVED'); + +$tpl->assign(compact('csrf_key', 'presets')); + +$tpl->display('config/fields/new.tpl'); diff --git a/src/www/admin/config/index.php b/src/www/admin/config/index.php new file mode 100644 index 0000000..fc08490 --- /dev/null +++ b/src/www/admin/config/index.php @@ -0,0 +1,41 @@ +runIf('save', function () use ($config) { + $config->importForm(); + $config->save(); +}, 'config', Utils::getSelfURI(['ok' => ''])); + +$latest = Upgrade::getLatestVersion(); + +if (null !== $latest) { + $latest = $latest->version; +} + +$tpl->assign([ + 'paheko_version' => paheko_version() . ' [' . (paheko_manifest() ?: 'release') . ']', + 'new_version' => $latest, + 'php_version' => phpversion(), + 'has_gpg_support' => \KD2\Security::canUseEncryption(), + 'server_time' => time(), + 'sqlite_version' => \SQLite3::version()['versionString'], + 'countries' => Utils::getCountryList(), + 'paheko_website' => WEBSITE, +]); + +$tpl->display('config/index.tpl'); diff --git a/src/www/admin/config/server/index.php b/src/www/admin/config/server/index.php new file mode 100644 index 0000000..71a25bc --- /dev/null +++ b/src/www/admin/config/server/index.php @@ -0,0 +1,55 @@ +runIf('import', function () { + Storage::migrate(FILE_STORAGE_BACKEND, 'SQLite', FILE_STORAGE_CONFIG, null); + }, $csrf_key, '?msg=OK'); + + $form->runIf('export', function () { + Storage::migrate('SQLite', FILE_STORAGE_BACKEND, null, FILE_STORAGE_CONFIG); + Storage::truncate('SQLite', null); + }, $csrf_key, '?msg=OK'); + + $form->runIf('scan', function () { + Storage::sync(null); + }, $csrf_key, '?msg=OK'); +} + +$constants = []; + +foreach (get_defined_constants(false) as $key => $value) { + if (strpos($key, 'Paheko\\') !== 0) { + continue; + } + + $key = str_replace('Paheko\\', '', $key); + + // Hide potentially secret values + if ($key === 'SECRET_KEY') { + $value = '***HIDDEN***'; + } + elseif (is_string($value)) { + $value = preg_replace('!(https?://)([^@]+@)!', '$1***HIDDEN***@', $value); + } + + $constants[$key] = $value; +} + +ksort($constants); + +$db_size = DB::getInstance()->firstColumn('SELECT SUM(LENGTH(content)) FROM files_contents;'); + +$tpl->assign(compact('constants', 'db_size', 'csrf_key')); + +$tpl->display('config/server/index.tpl'); diff --git a/src/www/admin/config/upgrade.php b/src/www/admin/config/upgrade.php new file mode 100644 index 0000000..ec9c06f --- /dev/null +++ b/src/www/admin/config/upgrade.php @@ -0,0 +1,49 @@ +listReleases(); +$v = paheko_version(); + +// Remove releases that are in the past +foreach ($releases as $rv => $release) { + if (!version_compare($rv, $v, '>')) { + unset($releases[$rv]); + } +} + +$latest = $i->latest(); +$tpl->assign('downloaded', false); +$tpl->assign('can_verify', Security::canUseEncryption()); + +$form->runIf('download', function () use ($i, $tpl) { + $i->download(f('download')); + $tpl->assign('downloaded', true); + $tpl->assign('verified', $i->verify(f('download'))); + $tpl->assign('diff', $i->diff(f('download'))); + $tpl->assign('version', f('download')); +}, $csrf_key); + +$form->runIf('upgrade', function () use ($i) { + $i->upgrade(f('upgrade')); + sleep(2); + $url = ADMIN_URL . 'upgrade.php'; + printf('

    Cliquez ici pour terminer la mise a jour :

    ', $url); + exit; +}, $csrf_key); + +$tpl->assign('website', WEBSITE); +$tpl->assign(compact('releases', 'latest', 'csrf_key')); +$tpl->display('config/upgrade.tpl'); diff --git a/src/www/admin/config/users/field_selector.php b/src/www/admin/config/users/field_selector.php new file mode 100644 index 0000000..388c47e --- /dev/null +++ b/src/www/admin/config/users/field_selector.php @@ -0,0 +1,14 @@ +assign([ + 'list' => $df->listEligibleNameFields(), +]); + +$tpl->display('config/users/field_selector.tpl'); diff --git a/src/www/admin/config/users/index.php b/src/www/admin/config/users/index.php new file mode 100644 index 0000000..ae88fbf --- /dev/null +++ b/src/www/admin/config/users/index.php @@ -0,0 +1,59 @@ +runIf('save', function() use ($df, $config) { + $config->importForm(); + $config->save(); + + if (!empty($_POST['login_field'])) { + $df->changeLoginField($_POST['login_field'], Session::getInstance()); + } + + if (!empty($_POST['name_fields'])) { + $df->changeNameFields(array_keys($_POST['name_fields'])); + } +}, $csrf_key, Utils::getSelfURI(['ok' => 1])); + +$names = $df->listAssocNames(); +$name_fields = array_intersect_key($names, array_flip(DynamicFields::getNameFields())); + +$tpl->assign([ + 'users_categories' => Categories::listAssoc(), + 'fields_list' => $names, + 'login_field' => DynamicFields::getLoginField(), + 'login_fields_list' => $df->listEligibleLoginFields(), + 'name_fields' => $name_fields, + 'log_retention_options' => [ + 0 => 'Ne pas enregistrer de journaux', + 7 => 'Une semaine', + 30 => 'Un mois', + 90 => '3 mois', + 180 => '6 mois', + 365 => 'Un an', + 720 => 'Deux ans', + ], + 'logout_delay_options' => [ + 0 => 'Pas de déconnexion automatique', + 1 => '1 minute', + 15 => '15 minutes', + 30 => '30 minutes', + 60 => '1 heure', + 2*60 => '2 heures', + 3*60 => '3 heures', + 6*60 => '6 heures', + ], +]); + +$tpl->assign(compact('csrf_key', 'config')); + +$tpl->display('config/users/index.tpl'); diff --git a/src/www/admin/docs/_inc.php b/src/www/admin/docs/_inc.php new file mode 100644 index 0000000..00c47a6 --- /dev/null +++ b/src/www/admin/docs/_inc.php @@ -0,0 +1,9 @@ +requireAccess(Session::SECTION_DOCUMENTS, Session::ACCESS_READ); diff --git a/src/www/admin/docs/action.php b/src/www/admin/docs/action.php new file mode 100644 index 0000000..68e6145 --- /dev/null +++ b/src/www/admin/docs/action.php @@ -0,0 +1,110 @@ +runIf('zip', function() use ($check, $session) { + Files::zip(null, $check, $session); + exit; +}, $csrf_key); + +$form->runIf('delete', function () use ($check) { + foreach ($check as &$file) { + $file = Files::get($file); + + if (!$file || !$file->canDelete()) { + throw new UserException('Impossible de supprimer un fichier car vous n\'avez pas le droit de le supprimer'); + } + } + + unset($file); + + foreach ($check as $file) { + $file->moveToTrash(); + } +}, $csrf_key, '!docs/?path=' . $parent); + +$form->runIf('move', function () use ($check) { + $target = f('move'); + + foreach ($check as &$file) { + $file = Files::get($file); + + if (!$file || !$file->canMoveTo($target)) { + throw new UserException('Impossible de déplacer un fichier car vous n\'avez pas le droit de le déplacer à cet endroit'); + } + } + + unset($file); + + foreach ($check as $file) { + $file->move($target); + } +}, $csrf_key, '!docs/?path=' . $parent); + +$count = count($check); + +$extra = compact('parent', 'action', 'check'); +$tpl->assign(compact('csrf_key', 'extra', 'action', 'count')); + +if ($action == 'delete') { + $tpl->display('docs/action_delete.tpl'); +} +elseif ($action == 'zip') { + $size = 0; + + foreach ($check as $selected) { + foreach (Files::listRecursive($selected, Session::getInstance(), false) as $file) { + $size += $file->size; + } + } + + $tpl->assign(compact('extra', 'count', 'size')); + $tpl->display('docs/action_zip.tpl'); +} +else { + $current = f('current') ?? f('parent'); + + if (!$current) { + $first_file = Files::get(current($check)); + + if (!$first_file) { + throw new UserException('Fichier introuvable'); + } + + $parent = $first_file->parent; + } + + $directories = Files::list($current); + $directories = array_filter($directories, function (File $file) { + return $file->type == File::TYPE_DIRECTORY; + }); + + $breadcrumbs = Files::getBreadcrumbs($current); + $parent = Utils::dirname($current); + $current_path = $current; + $current_path_name = Utils::basename($current); + + $tpl->assign(compact('directories', 'breadcrumbs', 'parent', 'current_path', 'current_path_name')); + + $tpl->display('docs/action_move.tpl'); +} diff --git a/src/www/admin/docs/index.php b/src/www/admin/docs/index.php new file mode 100644 index 0000000..676967b --- /dev/null +++ b/src/www/admin/docs/index.php @@ -0,0 +1,102 @@ +isDir()) { + throw new UserException('Ce répertoire n\'existe pas.'); +} + +if (!$dir->canRead()) { + throw new UserException('Vous n\'avez pas accès à ce répertoire'); +} + +$context = Files::getContext($path); +$context_ref = Files::getContextRef($path); +$list = null; +$user_name = null; +$context_specific_root = false; + +// Specific lists for some contexts +if ($context == File::CONTEXT_TRANSACTION || $context == File::CONTEXT_USER) { + if (!$context_ref) { + $context_specific_root = true; + + if ($context == File::CONTEXT_TRANSACTION) { + $list = Transactions::list(); + } + elseif ($context == File::CONTEXT_USER) { + $list = Users_Files::list(); + } + } + elseif ($context_ref && $context == File::CONTEXT_USER) { + $user_name = Users::getName($context_ref); + } +} +else { + $context_ref = null; +} + +if (null === $list) { + $list = Files::getDynamicList($path); +} + +$list->loadFromQueryString(); + +$breadcrumbs = Files::getBreadcrumbs($path); + +$pref = Session::getPreference('folders_gallery'); +$gallery = $pref ?? true; + +if (null !== qg('gallery')) { + $gallery = (bool) qg('gallery'); +} + +if ($gallery !== $pref) { + Session::getLoggedUser()->setPreference('folders_gallery', $gallery); +} + +$dir_uri = $dir->path_uri(); +$parent_uri = $dir->parent_uri(); + +$tpl->assign(compact('list', 'dir_uri', 'parent_uri', 'dir', 'context', 'context_ref', + 'breadcrumbs', 'highlight', 'user_name', 'gallery', 'context_specific_root')); + +$quota = [ + 'used' => Files::getUsedQuota(), + 'max' => Files::getQuota(), +]; + +$quota['left'] = Files::getRemainingQuota($quota['used']); + +foreach ($quota as $key => $value) { + $quota[$key . '_bytes'] = Utils::format_bytes($value); +} + +$quota['percent'] = $quota['max'] ? round(($quota['used'] / $quota['max']) * 100) : 100; + +$tpl->assign(compact('quota')); + +$tpl->display('docs/index.tpl'); diff --git a/src/www/admin/docs/new_dir.php b/src/www/admin/docs/new_dir.php new file mode 100644 index 0000000..b91fb1f --- /dev/null +++ b/src/www/admin/docs/new_dir.php @@ -0,0 +1,33 @@ +runIf('create', function () use ($parent) { + $name = trim((string) f('name')); + $f = Files::mkdir($parent . '/' . $name); + + $url = '!docs/?path=' . $f->path; + + if (null !== qg('_dialog')) { + Utils::reloadParentFrame(null === qg('no_redir') ? $url : null); + } + + Utils::redirect($url); +}, $csrf_key); + +$tpl->assign(compact('csrf_key')); + +$tpl->display('docs/new_dir.tpl'); diff --git a/src/www/admin/docs/new_doc.php b/src/www/admin/docs/new_doc.php new file mode 100644 index 0000000..5998b06 --- /dev/null +++ b/src/www/admin/docs/new_doc.php @@ -0,0 +1,42 @@ +runIf('create', function () use ($parent, $ext) { + $name = trim((string) f('name')); + $name = preg_replace('/\.\w+$/', '', $name); + $file = Files::createDocument($parent, $name, $ext); + Utils::redirect('!common/files/edit.php?p=' . rawurlencode($file->path)); +}, $csrf_key); + +if ($ext == 'ods') { + $submit_name = 'Créer le tableau'; +} +elseif ($ext == 'odp') { + $submit_name = 'Créer la présentation'; +} +else { + $submit_name = 'Créer le document'; +} + +$tpl->assign(compact('csrf_key', 'submit_name', 'ext')); + +$tpl->display('docs/new_doc.tpl'); diff --git a/src/www/admin/docs/new_file.php b/src/www/admin/docs/new_file.php new file mode 100644 index 0000000..358b143 --- /dev/null +++ b/src/www/admin/docs/new_file.php @@ -0,0 +1,39 @@ +runIf('create', function () use ($parent, $default_ext) { + $name = trim((string) f('name')); + + if ($default_ext && !strpos($name, '.')) { + $name .= '.' . $default_ext; + } + + $target = $parent . '/' . $name; + + if (Files::exists($target)) { + throw new UserException('Un fichier existe déjà avec ce nom : ' . $name); + } + + $file = Files::createFromString($target, ''); + + Utils::redirect('!common/files/edit.php?fallback=code&p=' . rawurlencode($file->path)); +}, $csrf_key); + +$tpl->assign(compact('csrf_key', 'parent')); + +$tpl->display('docs/new_file.tpl'); diff --git a/src/www/admin/docs/search.php b/src/www/admin/docs/search.php new file mode 100644 index 0000000..d788ea3 --- /dev/null +++ b/src/www/admin/docs/search.php @@ -0,0 +1,19 @@ +assign('query', $q); + +if ($q) { + $r = Files::search($q, File::CONTEXT_DOCUMENTS . '%'); + $tpl->assign('results', $r); + $tpl->assign('results_count', count($r)); +} + +$tpl->display('docs/search.tpl'); diff --git a/src/www/admin/docs/trash.php b/src/www/admin/docs/trash.php new file mode 100644 index 0000000..78d487c --- /dev/null +++ b/src/www/admin/docs/trash.php @@ -0,0 +1,101 @@ +requireAccess($session::SECTION_DOCUMENTS, $session::ACCESS_ADMIN); + + +$csrf_key = 'trash_action'; +$check = f('check'); +$extra = compact('check'); +$count = $check ? count($check) : null; + +$tpl->assign(compact('csrf_key', 'extra', 'count')); + +$form->runIf('confirm_delete', function () use ($check, $session) { + $session->requireAccess($session::SECTION_CONFIG, $session::ACCESS_ADMIN); + + if (empty($check)) { + throw new UserException('Aucun fichier sélectionné'); + } + + foreach ($check as &$file) { + $file = Files::get($file); + + if (!$file) { + continue; + } + + if (!$file->canDelete()) { + throw new UserException('Impossible de supprimer un fichier car vous n\'avez pas le droit de le supprimer'); + } + } + + unset($file); + + $db = DB::getInstance(); + $db->begin(); + + foreach ($check as $file) { + if ($file === null) { + continue; + } + + $file->delete(); + } + + Files::pruneEmptyDirectories(File::CONTEXT_TRASH); + + $db->commit(); +}, $csrf_key, '!docs/trash.php'); + +$form->runIf('restore', function() use ($check) { + if (empty($check)) { + throw new UserException('Aucun fichier sélectionné'); + } + + foreach ($check as &$file) { + $file = Files::get($file); + + if (!$file) { + throw new UserException('Impossible de restaurer un fichier qui n\'existe plus'); + } + } + + unset($file); + + $db = DB::getInstance(); + $db->begin(); + + foreach ($check as $file) { + $file->restoreFromTrash(); + } + + Files::pruneEmptyDirectories(File::CONTEXT_TRASH); + + $db->commit(); + +}, $csrf_key, '!docs/trash.php'); + +if (f('delete')) { + $tpl->display('docs/trash_delete.tpl'); +} +else { + Trash::clean(); + + $size = Trash::getSize(); + $list = Trash::list(); + $list->loadFromQueryString(); + + $tpl->assign(compact('list', 'size')); + + $tpl->display('docs/trash.tpl'); +} diff --git a/src/www/admin/handle_bounce.php b/src/www/admin/handle_bounce.php new file mode 100644 index 0000000..b553b38 --- /dev/null +++ b/src/www/admin/handle_bounce.php @@ -0,0 +1,43 @@ + 'Accepted', + 400 => 'Bad Request', + 401 => 'Unauthorized', + 402 => 'Payment Required', + 403 => 'Forbidden', + 404 => 'Not Found', + 405 => 'Method Not Allowed', + 406 => 'Not Acceptable', + 407 => 'Proxy Authentication Required', + 408 => 'Request Timeout', + 409 => 'Conflict', + ]; + + header(sprintf('%s %d %s', $_SERVER['SERVER_PROTOCOL'], $http_code, $http_statuses[$http_code]), true, $http_code); + echo $message . PHP_EOL; + exit; +} + +if (empty(MAIL_BOUNCE_PASSWORD) || empty($_SERVER['PHP_AUTH_USER']) || empty($_SERVER['PHP_AUTH_PW']) + || $_SERVER['PHP_AUTH_USER'] != 'bounce' || $_SERVER['PHP_AUTH_PW'] != MAIL_BOUNCE_PASSWORD) +{ + error(403, 'Invalid credentials'); +} + +if (empty($_POST['message'])) +{ + error(400, 'Missing or invalid required parameters'); +} + +Emails::handleBounce($_POST['message']); + +error(202, 'OK'); diff --git a/src/www/admin/index.php b/src/www/admin/index.php new file mode 100644 index 0000000..ce833a1 --- /dev/null +++ b/src/www/admin/index.php @@ -0,0 +1,51 @@ + $session->getUser(), 'session' => $session]); + +if ($signal) { + $banner = implode('', $signal->getOut()); +} + +$homepage = Config::getInstance()->file('admin_homepage'); + +if ($homepage) { + $homepage = $homepage->render(); +} +else { + $homepage = null; +} + +$buttons = Extensions::listHomeButtons($session); +$has_extensions = empty($buttons) ? Extensions::isAnyExtensionEnabled() : true; + +if (!$has_extensions && $session->canAccess($session::SECTION_CONFIG, $session::ACCESS_ADMIN)) { + $buttons = Extensions::listAvailableButtons(); +} + +$tpl->assign(compact('homepage', 'banner', 'buttons', 'has_extensions')); + +$tpl->assign('custom_css', [BASE_URL . 'content.css']); + +$tpl->display('index.tpl'); +flush(); + +// If no cron task is used, then the cron is run when visiting the homepage +// this is not the best, but better than nothing +if (!USE_CRON && @filemtime(CACHE_ROOT . '/last_cron_run') < (time() - 24*3600)) { + touch(CACHE_ROOT . '/last_cron_run'); + require_once ROOT . '/scripts/cron.php'; +} diff --git a/src/www/admin/install.php b/src/www/admin/install.php new file mode 100644 index 0000000..d72ff0a --- /dev/null +++ b/src/www/admin/install.php @@ -0,0 +1,52 @@ +assign('admin_url', ADMIN_URL); + +$form = new Form; +$tpl->assign_by_ref('form', $form); +$csrf_key = 'install'; + +$form->runIf('save', function () { + Install::installFromForm(); + Session::getInstance()->forceLogin(1); +}, $csrf_key, ADMIN_URL); + +$tpl->assign('countries', Chart::COUNTRY_LIST); +$tpl->assign('require_admin_account', !is_array(LOCAL_LOGIN)); + +$tpl->assign(compact('csrf_key')); + +$tpl->display('install.tpl'); diff --git a/src/www/admin/legal.php b/src/www/admin/legal.php new file mode 100644 index 0000000..4b6dc6d --- /dev/null +++ b/src/www/admin/legal.php @@ -0,0 +1,15 @@ +display('legal.tpl'); diff --git a/src/www/admin/login.php b/src/www/admin/login.php new file mode 100644 index 0000000..9aa2594 --- /dev/null +++ b/src/www/admin/login.php @@ -0,0 +1,96 @@ +keepAlive(); + + header('Cache-Control: no-cache, must-revalidate'); + header('Expires: Mon, 26 Jul 1997 05:00:00 GMT'); + + header('Content-Type: image/gif'); + echo base64_decode("R0lGODlhAQABAIAAAP///////yH5BAEKAAEALAAAAAABAAEAAAICTAEAOw=="); + + exit; +} + +$args = $app_token ? '?app=' . rawurlencode($app_token) : ''; +$layout = $app_token ? 'public' : null; + +// L'utilisateur est déjà connecté +if ($session->isLogged()) { + if ($app_token) { + Utils::redirect('!login_app.php' . $args); + } + else { + Utils::redirect(ADMIN_URL); + } +} + +$id_field = DynamicFields::get(DynamicFields::getLoginField()); +$id_field_name = $id_field->label; +$lock = Log::isLocked(); + +$form->runIf('login', function () use ($id_field_name, $session, $lock, $args) { + if ($lock == 1) { + throw new UserException(sprintf("Vous avez dépassé la limite de tentatives de connexion.\nMerci d'attendre %d minutes avant de ré-essayer de vous connecter.", Log::LOCKOUT_DELAY/60)); + } + elseif ($lock == -1 && !Security::checkCaptcha(SECRET_KEY, f('c_hash'), f('c_answer'))) { + throw new UserException('Le code de vérification entré n\'est pas correct.'); + } + + $_POST['c_answer'] = null; + + if (!trim((string) f('id'))) { + throw new UserException(sprintf('L\'identifiant (%s) n\'a pas été renseigné.', $id_field_name)); + } + + if (!trim((string) f('password'))) { + throw new UserException('Le mot de passe n\'a pas été renseigné.'); + } + + $ok = $session->login(f('id'), f('password'), (bool) f('permanent')); + + if (!$ok) { + throw new UserException(sprintf("Connexion impossible.\nVérifiez votre identifiant (%s) et votre mot de passe.", $id_field_name)); + } + + if ($session::REQUIRE_OTP === $ok) { + Utils::redirect('!login_otp.php' . $args); + } + elseif ($args) { + Utils::redirect('!login_app.php' . $args); + } +}, 'login', ADMIN_URL); + +$captcha = $lock == -1 ? Security::createCaptcha(SECRET_KEY, 'fr_FR') : null; + +$ssl_enabled = HTTP::getScheme() == 'https'; +$changed = qg('changed') !== null; + +$tpl->assign(compact('id_field', 'ssl_enabled', 'changed', 'app_token', 'layout', 'captcha')); + +$tpl->display('login.tpl'); diff --git a/src/www/admin/login_app.php b/src/www/admin/login_app.php new file mode 100644 index 0000000..06cbd17 --- /dev/null +++ b/src/www/admin/login_app.php @@ -0,0 +1,57 @@ +requireAccess($session::SECTION_DOCUMENTS, $session::ACCESS_READ); + +$app_token = $_GET['app'] ?? null; + +if (!$app_token) { + die("No app token was supplied."); +} + +$csrf_key = 'app_confirm_' . $app_token; + +$form->runIf('cancel', function () use ($app_token, $session) { + $session->logout(); + Utils::redirect('!login.php?app=' . $app_token); +}); + +$form->runIf('confirm', function () use ($app_token, $session) { + $data = null; + + if ($app_token == 'redirect') { + $data = $session->createAppCredentials(); + } + elseif ($app_token == 'test') { + $data = $session->createAppCredentials(); + header('Content-Type: text/plain'); + echo json_encode($data, JSON_PRETTY_PRINT); + exit; + } + elseif (!$session->validateAppToken($app_token)) { + throw new UserException('La demande a expiré ou est invalide, merci de recommencer.'); + } + + if ($data->redirect ?? null) { + http_response_code(303); + header('Location: ' . $data->redirect); + exit; + } + + Utils::redirect('!login_app.php?app=ok'); +}, $csrf_key); + +$permissions = $session->getFilePermissions(File::CONTEXT_DOCUMENTS); + +$tpl->assign(compact('app_token', 'csrf_key', 'permissions')); + +$tpl->display('login_app.tpl'); diff --git a/src/www/admin/login_otp.php b/src/www/admin/login_otp.php new file mode 100644 index 0000000..ae16f41 --- /dev/null +++ b/src/www/admin/login_otp.php @@ -0,0 +1,43 @@ +isOTPRequired()) { + Utils::redirect(ADMIN_URL); +} + +$login = null; +$csrf_key = 'login_otp'; + +$args = $app_token ? '?app=' . rawurlencode($app_token) : ''; +$layout = $app_token ? 'public' : null; + +$form->runIf('login', function () use ($session, $args) { + if (!$session->loginOTP(f('code'))) { + throw new UserException(sprintf('Code incorrect. Vérifiez que votre téléphone est à l\'heure (heure du serveur : %s).', date('d/m/Y H:i:s'))); + } + + if ($args) { + Utils::redirect('!login_app.php' . $args); + } +}, $csrf_key, '!'); + +$tpl->assign(compact('csrf_key', 'layout')); + +$tpl->display('login_otp.tpl'); diff --git a/src/www/admin/logout.php b/src/www/admin/logout.php new file mode 100644 index 0000000..4773f9d --- /dev/null +++ b/src/www/admin/logout.php @@ -0,0 +1,8 @@ +logout(qg('all') !== null); +Utils::redirect('!login.php?logout'); diff --git a/src/www/admin/manifest.php b/src/www/admin/manifest.php new file mode 100644 index 0000000..3fcebf6 --- /dev/null +++ b/src/www/admin/manifest.php @@ -0,0 +1,37 @@ + $config->color2 ?? ADMIN_COLOR2, + 'theme_color' => $config->color1 ?? ADMIN_COLOR1, + 'description' => 'Gestion de l\'association', + 'display' => 'standalone', + 'name' => $config->org_name, + 'start_url' => ADMIN_URL, + 'icons' => [ + [ + 'sizes' => '32x32', + 'src' => $config->fileURL('favicon'), + 'type' => 'image/png', + 'purpose' => 'any maskable', + ], + [ + 'sizes' => '256x256', + 'src' => $config->fileURL('icon', 'crop-256px'), + 'type' => 'image/png', + 'purpose' => 'any maskable', + ], + ], +]; + +$body = json_encode($manifest, JSON_PRETTY_PRINT); + +Utils::HTTPCache(md5($body), max($config->files['icon'], $config->files['favicon'], strtotime('2011-11-11'))); + +header('Content-Type: text/json; charset=utf-8'); +echo $body; diff --git a/src/www/admin/me/_inc.php b/src/www/admin/me/_inc.php new file mode 100644 index 0000000..34d0096 --- /dev/null +++ b/src/www/admin/me/_inc.php @@ -0,0 +1,13 @@ +getUser(); + +if (!$user->exists()) { + throw new UserException('Only existing users can change their info'); +} diff --git a/src/www/admin/me/edit.php b/src/www/admin/me/edit.php new file mode 100644 index 0000000..cb12a82 --- /dev/null +++ b/src/www/admin/me/edit.php @@ -0,0 +1,21 @@ +runIf('save', function () use ($user) { + $user->importForm(); + $user->checkLoginFieldForUserEdit(); + $user->save(); +}, $csrf_key, '!me/?ok'); + +$fields = DynamicFields::getInstance()->all(); + +$tpl->assign(compact('csrf_key', 'user', 'fields')); + +$tpl->display('me/edit.tpl'); diff --git a/src/www/admin/me/export.php b/src/www/admin/me/export.php new file mode 100644 index 0000000..bf7012d --- /dev/null +++ b/src/www/admin/me/export.php @@ -0,0 +1,9 @@ +downloadExport(); diff --git a/src/www/admin/me/index.php b/src/www/admin/me/index.php new file mode 100644 index 0000000..7fa7070 --- /dev/null +++ b/src/www/admin/me/index.php @@ -0,0 +1,18 @@ +getParentName(); +$children = $user->listChildren(); + +$variables = compact('user', 'parent_name', 'children', 'ok'); +$tpl->assign('snippets', Modules::snippetsAsString(Modules::SNIPPET_MY_DETAILS, $variables)); + +$tpl->assign($variables); + +$tpl->display('me/index.tpl'); diff --git a/src/www/admin/me/preferences.php b/src/www/admin/me/preferences.php new file mode 100644 index 0000000..4fa6cca --- /dev/null +++ b/src/www/admin/me/preferences.php @@ -0,0 +1,47 @@ +preferences; +$csrf_key = 'my_preferences'; + +$form->runIf('save', function () use ($user) { + foreach ($user::PREFERENCES as $key => $v) { + $user->setPreference($key, f($key)); + } + $user->save(); +}, $csrf_key, '!me/preferences.php?ok'); + +$folders_options = [ + true => 'En galerie', + false => 'En liste', +]; + +$page_size_options = [ + 25 => 25, + 50 => 50, + 100 => 100, + 200 => 200, + 500 => 500, +]; + +$themes_options = [ + false => 'Thème clair', + true => 'Thème sombre', +]; + +$handheld_options = [ + false => 'S\'adapter automatiquement à la taille de l\'écran', + true => 'Toujours utiliser la disposition pour petit écran', +]; + +$tpl->assign(compact('preferences', 'ok', 'csrf_key', 'folders_options', 'page_size_options', 'themes_options', 'handheld_options')); + +$tpl->display('me/preferences.tpl'); diff --git a/src/www/admin/me/security.php b/src/www/admin/me/security.php new file mode 100644 index 0000000..c1375aa --- /dev/null +++ b/src/www/admin/me/security.php @@ -0,0 +1,38 @@ +password); +$edit = qg('edit'); + +$form->runIf('confirm', function () use ($user, $session) { + $user->importSecurityForm(true, null, $session); + $user->save(false); +}, $csrf_key, '!me/security.php?ok'); + +$otp = null; + +if ($edit == 'otp') { + $otp = $session->getNewOTPSecret(); +} + +$tpl->assign('can_use_pgp', \KD2\Security::canUseEncryption()); +$tpl->assign('pgp_fingerprint', $user->pgp_key ? $session->getPGPFingerprint($user->pgp_key, true) : null); + +$tpl->assign('ok', qg('ok') !== null); +$sessions_count = $session->countActiveSessions(); + +$id_field = current(DynamicFields::getInstance()->fieldsBySystemUse('login')); +$id = $user->{$id_field->name}; +$can_change_password = $user->canChangePassword($session); + +$tpl->assign(compact('id', 'edit', 'id_field', 'user', 'csrf_key', 'sessions_count', 'can_change_password', 'otp')); + +$tpl->display('me/security.tpl'); diff --git a/src/www/admin/me/services.php b/src/www/admin/me/services.php new file mode 100644 index 0000000..666d719 --- /dev/null +++ b/src/www/admin/me/services.php @@ -0,0 +1,25 @@ +assign('membre', $user); + +$list = Services_User::perUserList($user->id); +$list->loadFromQueryString(); + +$tpl->assign(compact('list')); + +$services = Services_User::listDistinctForUser($user->id); +$accounts = Reports::getAccountsBalances(['user' => $user->id, 'type' => Account::TYPE_THIRD_PARTY]); + +$variables = compact('list', 'services', 'accounts'); +$tpl->assign($variables); +$tpl->assign('snippets', Modules::snippetsAsString(Modules::SNIPPET_MY_SERVICES, $variables)); + +$tpl->display('me/services.tpl'); diff --git a/src/www/admin/optout.php b/src/www/admin/optout.php new file mode 100644 index 0000000..63388df --- /dev/null +++ b/src/www/admin/optout.php @@ -0,0 +1,50 @@ +verify($_GET['v'])) { + $email->save(); + $verify = true; + } + else { + $verify = false; + } +} + +$form->runIf('confirm_resub', function () use ($email) { + if (empty($_POST['email'])) { + throw new UserException('Merci de renseigner l\'adresse email'); + } + + $email->sendVerification($_POST['email']); +}, 'optout', '!optout.php?resub_ok&un=' . $code); + +$form->runIf('optout', function () use ($email) { + $email->setOptout(); + $email->save(); +}, 'optout', '!optout.php?ok&un=' . $code); + +$ok = isset($_GET['ok']); +$resub_ok = isset($_GET['resub_ok']); + +$tpl->assign(compact('email', 'ok', 'resub_ok', 'code', 'verify')); + +$tpl->display('optout.tpl'); diff --git a/src/www/admin/password.php b/src/www/admin/password.php new file mode 100644 index 0000000..1fe68c0 --- /dev/null +++ b/src/www/admin/password.php @@ -0,0 +1,45 @@ +runIf(qg('c') !== null, function () use ($session, $form, $tpl) { + if (!$session->checkRecoveryPasswordQuery(qg('c'))) { + throw new UserException('Le lien que vous avez suivi est invalide ou a expiré.'); + } + + + $csrf_key = 'password_change_' . md5(qg('c')); + + $form->runIf('change', function () use ($session) { + $session->recoverPasswordChange(qg('c'), f('password'), f('password_confirmed')); + }, $csrf_key, '!login.php?changed'); + + $tpl->assign(compact('csrf_key')); + $tpl->display('password_change.tpl'); + exit; +}); + +$csrf_key = 'recover_password'; +$new = qg('new') !== null; + +$form->runIf('recover', function () use ($session) { + $session->recoverPasswordSend(f('id')); +}, $csrf_key, '!password.php?sent' . ($new ? '&new' : '')); + +$sent = !$form->hasErrors() && null !== qg('sent'); + +$id_field = DynamicFields::get(DynamicFields::getLoginField()); +$title = $new ? 'Première connexion ?' : 'Mot de passe perdu ?'; + +$tpl->assign(compact('id_field', 'sent', 'csrf_key', 'title', 'new')); + +$tpl->display('password.tpl'); diff --git a/src/www/admin/services/_inc.php b/src/www/admin/services/_inc.php new file mode 100644 index 0000000..37a3719 --- /dev/null +++ b/src/www/admin/services/_inc.php @@ -0,0 +1,7 @@ +requireAccess($session::SECTION_USERS, $session::ACCESS_READ); \ No newline at end of file diff --git a/src/www/admin/services/delete.php b/src/www/admin/services/delete.php new file mode 100644 index 0000000..df9a943 --- /dev/null +++ b/src/www/admin/services/delete.php @@ -0,0 +1,37 @@ +requireAccess($session::SECTION_USERS, $session::ACCESS_ADMIN); + +$service = Services::get((int) qg('id')); + +if (!$service) { + throw new UserException("Cette activité n'existe pas"); +} + +$csrf_key = 'service_delete_' . $service->id(); +$has_subscriptions = $service->hasSubscriptions(); + +$form->runIf('delete', function () use ($service, $has_subscriptions) { + if ($has_subscriptions && 0 !== strnatcasecmp($service->label, trim((string) f('confirm_delete')))) { + throw new UserException('Merci de recopier le nom de l\'activité correctement pour confirmer la suppression.'); + } + + $service->delete(); +}, $csrf_key, ADMIN_URL . 'services/'); + +$confirm_label = null; +$confirm_text = null; + +if ($has_subscriptions) { + $confirm_label = "Entrer ici le nom de l'activité pour confirmer que vous souhaitez désinscrire tous les membres de cette activité"; + $confirm_text = $service->label; +} + +$tpl->assign(compact('service', 'csrf_key', 'confirm_label', 'confirm_text')); + +$tpl->display('services/delete.tpl'); diff --git a/src/www/admin/services/details.php b/src/www/admin/services/details.php new file mode 100644 index 0000000..58f559a --- /dev/null +++ b/src/www/admin/services/details.php @@ -0,0 +1,36 @@ +requireAccess($session::SECTION_USERS, $session::ACCESS_READ); + +$service = Services::get((int) qg('id')); + +if (!$service) { + throw new UserException("Cette activité n'existe pas"); +} + +$type = qg('type'); +$include_hidden_categories = $session->canAccess($session::SECTION_USERS, $session::ACCESS_ADMIN) && qg('hidden'); + +if ('unpaid' == $type) { + $list = $service->unpaidUsersList($include_hidden_categories); +} +elseif ('expired' == $type) { + $list = $service->expiredUsersList($include_hidden_categories); +} +elseif ('active' == $type) { + $list = $service->activeUsersList($include_hidden_categories); +} +else { + $type = 'all'; + $list = $service->allUsersList($include_hidden_categories); +} + +$list->loadFromQueryString(); + +$tpl->assign(compact('list', 'service', 'type', 'include_hidden_categories')); + +$tpl->display('services/details.tpl'); diff --git a/src/www/admin/services/edit.php b/src/www/admin/services/edit.php new file mode 100644 index 0000000..7f0e7ea --- /dev/null +++ b/src/www/admin/services/edit.php @@ -0,0 +1,35 @@ +requireAccess($session::SECTION_USERS, $session::ACCESS_ADMIN); + +$service = Services::get((int) qg('id')); + +if (!$service) { + throw new UserException("Cette activité n'existe pas"); +} + +$csrf_key = 'service_edit_' . $service->id(); + +$form->runIf('save', function () use ($service) { + $service->importForm(); + $service->save(); +}, $csrf_key, ADMIN_URL . 'services/'); + +if ($service->duration) { + $period = 1; +} +elseif ($service->start_date) { + $period = 2; +} +else { + $period = 0; +} + +$tpl->assign(compact('service', 'period', 'csrf_key')); + +$tpl->display('services/edit.tpl'); diff --git a/src/www/admin/services/fees/delete.php b/src/www/admin/services/fees/delete.php new file mode 100644 index 0000000..0e622f9 --- /dev/null +++ b/src/www/admin/services/fees/delete.php @@ -0,0 +1,38 @@ +requireAccess($session::SECTION_USERS, $session::ACCESS_ADMIN); + +$fee = Fees::get((int) qg('id')); + +if (!$fee) { + throw new UserException("Ce tarif n'existe pas"); +} + +$csrf_key = 'fee_delete_' . $fee->id(); +$has_subscriptions = $fee->hasSubscriptions(); + +$form->runIf('delete', function () use ($has_subscriptions, $fee) { + if ($has_subscriptions && 0 !== strnatcasecmp($fee->label, trim((string) f('confirm_delete')))) { + throw new UserException('Merci de recopier le nom du tarif correctement pour confirmer la suppression.'); + } + + $fee->delete(); +}, $csrf_key, ADMIN_URL . 'services/fees/?id=' . $fee->id_service); + + +$confirm_label = null; +$confirm_text = null; + +if ($has_subscriptions) { + $confirm_label = "Entrer ici le nom du tarif pour confirmer que vous souhaitez désinscrire tous les membres de ce tarif"; + $confirm_text = $fee->label; +} + +$tpl->assign(compact('fee', 'csrf_key', 'confirm_label', 'confirm_text')); + +$tpl->display('services/fees/delete.tpl'); diff --git a/src/www/admin/services/fees/details.php b/src/www/admin/services/fees/details.php new file mode 100644 index 0000000..19dbced --- /dev/null +++ b/src/www/admin/services/fees/details.php @@ -0,0 +1,38 @@ +requireAccess($session::SECTION_USERS, $session::ACCESS_READ); + +$fee = Fees::get((int) qg('id')); + +if (!$fee) { + throw new UserException("Ce tarif n'existe pas"); +} + +$type = qg('type'); +$include_hidden_categories = $session->canAccess($session::SECTION_USERS, $session::ACCESS_ADMIN) && qg('hidden'); + +if ('unpaid' == $type) { + $list = $fee->unpaidUsersList($include_hidden_categories); +} +elseif ('expired' == $type) { + $list = $fee->expiredUsersList($include_hidden_categories); +} +elseif ('active' == $type) { + $list = $fee->activeUsersList($include_hidden_categories); +} +else { + $type = 'all'; + $list = $fee->allUsersList($include_hidden_categories); +} + +$list->loadFromQueryString(); + +$service = $fee->service(); + +$tpl->assign(compact('list', 'fee', 'type', 'service', 'include_hidden_categories')); + +$tpl->display('services/fees/details.tpl'); diff --git a/src/www/admin/services/fees/edit.php b/src/www/admin/services/fees/edit.php new file mode 100644 index 0000000..b81bafc --- /dev/null +++ b/src/www/admin/services/fees/edit.php @@ -0,0 +1,46 @@ +requireAccess($session::SECTION_USERS, $session::ACCESS_ADMIN); + +$fee = Fees::get((int) qg('id')); + +if (!$fee) { + throw new UserException("Ce tarif n'existe pas"); +} + +$service = $fee->service(); +$csrf_key = 'fee_edit_' . $fee->id(); + +$form->runIf('save', function () use ($fee) { + $fee->importForm(); + $fee->save(); +}, $csrf_key, ADMIN_URL . 'services/fees/?id=' . $service->id()); + +if ($fee->amount) { + $amount_type = 1; +} +elseif ($fee->formula) { + $amount_type = 2; +} +else { + $amount_type = 0; +} + +$accounting_enabled = (bool) $fee->id_account; + +$years = Years::listOpen(); + +$account = Accounts::getSelector($fee->id_account); +$tpl->assign('projects', Projects::listAssoc()); + +$tpl->assign(compact('service', 'amount_type', 'fee', 'csrf_key', 'account', 'accounting_enabled', 'years')); + +$tpl->display('services/fees/edit.tpl'); diff --git a/src/www/admin/services/fees/index.php b/src/www/admin/services/fees/index.php new file mode 100644 index 0000000..b7dc9f4 --- /dev/null +++ b/src/www/admin/services/fees/index.php @@ -0,0 +1,33 @@ +fees(); + +$form->runIf($session->canAccess($session::SECTION_USERS, $session::ACCESS_ADMIN) && f('save'), function () use ($service) { + $fee = new Fee; + $fee->id_service = $service->id(); + $fee->importForm(); + $fee->save(); +}, 'fee_add', ADMIN_URL . 'services/fees/?id=' . $service->id()); + +$accounting_enabled = false; +$years = Years::listOpen(); + +$tpl->assign(compact('service', 'accounting_enabled', 'years')); +$tpl->assign('list', $fees->listWithStats()); +$tpl->assign('projects', Projects::listAssoc()); + +$tpl->display('services/fees/index.tpl'); diff --git a/src/www/admin/services/import.php b/src/www/admin/services/import.php new file mode 100644 index 0000000..2ef4e04 --- /dev/null +++ b/src/www/admin/services/import.php @@ -0,0 +1,48 @@ +requireAccess($session::SECTION_USERS, $session::ACCESS_ADMIN); + +$csrf_key = 'su_import'; +$csv = new CSV_Custom($session, 'su_import'); + +$csv->setColumns(Services_User::listImportColumns()); +$csv->setMandatoryColumns(Services_User::listMandatoryImportColumns()); + +$form->runIf('cancel', function() use ($csv) { + $csv->clear(); +}, $csrf_key, Utils::getSelfURI()); + +$form->runIf(f('load') && isset($_FILES['file']['tmp_name']), function () use ($csv) { + $csv->load($_FILES['file']); +}, $csrf_key, Utils::getSelfURI()); + +$form->runIf(f('import') && $csv->loaded(), function () use (&$csv) { + $csv->skip((int)f('skip_first_line')); + $csv->setTranslationTable(f('translation_table')); + + try { + if (!$csv->ready()) { + $csv->clear(); + throw new UserException('Erreur dans le chargement du CSV'); + } + + Services_User::import($csv); + } + finally { + $csv->clear(); + } +}, $csrf_key, '!services/import.php?msg=OK'); + +$tpl->assign(compact('csv', 'csrf_key')); + +$tpl->display('services/import.tpl'); diff --git a/src/www/admin/services/index.php b/src/www/admin/services/index.php new file mode 100644 index 0000000..958bd5f --- /dev/null +++ b/src/www/admin/services/index.php @@ -0,0 +1,32 @@ +runIf($session->canAccess($session::SECTION_USERS, $session::ACCESS_ADMIN) && f('save'), function () { + $service = new Service; + $service->importForm(); + $service->save(); + Utils::redirect(ADMIN_URL . 'services/fees/?id=' . $service->id()); +}, $csrf_key); + +$has_archived_services = Services::hasArchivedServices(); +$show_archived_services = $_GET['archived'] ?? false; + +if ($show_archived_services) { + $list = Services::listArchivedWithStats(); +} +else { + $list = Services::listWithStats(); +} + +$list->loadFromQueryString(); + +$tpl->assign(compact('csrf_key', 'has_archived_services', 'show_archived_services', 'list')); + +$tpl->display('services/index.tpl'); diff --git a/src/www/admin/services/reminders/delete.php b/src/www/admin/services/reminders/delete.php new file mode 100644 index 0000000..3de8779 --- /dev/null +++ b/src/www/admin/services/reminders/delete.php @@ -0,0 +1,30 @@ +requireAccess($session::SECTION_USERS, $session::ACCESS_ADMIN); + +$reminder = Reminders::get((int) qg('id')); + +if (!$reminder) { + throw new UserException("Ce rappel n'existe pas"); +} + +$csrf_key = 'reminder_delete_' . $reminder->id(); + +$form->runIf('delete', function () use ($reminder) { + if (f('confirm_delete')) { + $reminder->deleteHistory(); + } + + $reminder->delete(); +}, $csrf_key, ADMIN_URL . 'services/reminders/'); + +$tpl->assign(compact('reminder', 'csrf_key')); + +$tpl->display('services/reminders/delete.tpl'); diff --git a/src/www/admin/services/reminders/details.php b/src/www/admin/services/reminders/details.php new file mode 100644 index 0000000..ec15cf9 --- /dev/null +++ b/src/www/admin/services/reminders/details.php @@ -0,0 +1,32 @@ +requireAccess($session::SECTION_USERS, $session::ACCESS_ADMIN); + +$reminder = Reminders::get((int) qg('id')); + +if (!$reminder) { + throw new UserException("Ce rappel n'existe pas"); +} + +$service = $reminder->service(); + +if (qg('list') === 'pending') { + $current_list = 'pending'; + $list = $reminder->pendingList(); +} +else { + $current_list = 'sent'; + $list = $reminder->sentList(); +} + +$list->loadFromQueryString(); + +$tpl->assign(compact('current_list', 'list', 'reminder', 'service')); + +$tpl->display('services/reminders/details.tpl'); diff --git a/src/www/admin/services/reminders/edit.php b/src/www/admin/services/reminders/edit.php new file mode 100644 index 0000000..39097ce --- /dev/null +++ b/src/www/admin/services/reminders/edit.php @@ -0,0 +1,43 @@ +requireAccess($session::SECTION_USERS, $session::ACCESS_ADMIN); + +$reminder = Reminders::get((int) qg('id')); + +if (!$reminder) { + throw new UserException("Ce rappel n'existe pas"); +} + +$csrf_key = 'reminder_edit_' . $reminder->id(); + +$form->runIf('save', function () use ($reminder) { + $reminder->importForm(); + $reminder->save(); +}, $csrf_key, ADMIN_URL . 'services/reminders/'); + +$delay_before = $delay_after = ''; + +if ($reminder->delay < 0) { + $delay_type = 1; + $delay_before = abs($reminder->delay); +} +elseif ($reminder->delay > 0) { + $delay_type = 2; + $delay_after = abs($reminder->delay); +} +else { + $delay_type = 0; +} + +$services_list = Services::listAssoc(); + +$tpl->assign(compact('delay_type', 'delay_before', 'delay_after', 'reminder', 'csrf_key', 'services_list')); + +$tpl->display('services/reminders/edit.tpl'); diff --git a/src/www/admin/services/reminders/index.php b/src/www/admin/services/reminders/index.php new file mode 100644 index 0000000..f70b280 --- /dev/null +++ b/src/www/admin/services/reminders/index.php @@ -0,0 +1,21 @@ +requireAccess($session::SECTION_USERS, $session::ACCESS_ADMIN); + +$services_list = Services::listAssoc(); + +if (!count($services_list)) { + Utils::redirect(ADMIN_URL . 'services/?CREATE'); +} + +$list = Reminders::list(); + +$tpl->assign(compact('list')); + +$tpl->display('services/reminders/index.tpl'); diff --git a/src/www/admin/services/reminders/new.php b/src/www/admin/services/reminders/new.php new file mode 100644 index 0000000..8c39141 --- /dev/null +++ b/src/www/admin/services/reminders/new.php @@ -0,0 +1,25 @@ +requireAccess($session::SECTION_USERS, $session::ACCESS_ADMIN); + +$csrf_key = 'reminder_add'; +$reminder = new Reminder; +$services_list = Services::listAssoc(); + +$form->runIf('save', function () use ($reminder) { + $reminder->importForm(); + $reminder->save(); +}, $csrf_key, '!services/reminders/'); + +$reminder->subject = $reminder::DEFAULT_SUBJECT; +$reminder->body = $reminder::DEFAULT_BODY; + +$tpl->assign(compact('csrf_key', 'reminder', 'services_list')); + +$tpl->display('services/reminders/new.tpl'); diff --git a/src/www/admin/services/reminders/preview.php b/src/www/admin/services/reminders/preview.php new file mode 100644 index 0000000..e365873 --- /dev/null +++ b/src/www/admin/services/reminders/preview.php @@ -0,0 +1,21 @@ +requireAccess($session::SECTION_USERS, $session::ACCESS_ADMIN); + +$reminder = Reminders::get((int) qg('id_reminder')); + +if (!$reminder) { + throw new UserException("Ce rappel n'existe pas"); +} + +$body = $reminder->getPreview((int) qg('id_user')); + +$tpl->assign(compact('body')); + +$tpl->display('services/reminders/preview.tpl'); diff --git a/src/www/admin/services/reminders/user.php b/src/www/admin/services/reminders/user.php new file mode 100644 index 0000000..8cc6512 --- /dev/null +++ b/src/www/admin/services/reminders/user.php @@ -0,0 +1,15 @@ +assign(compact('list', 'user_id')); + +$tpl->display('services/reminders/user.tpl'); diff --git a/src/www/admin/services/user/_form.php b/src/www/admin/services/user/_form.php new file mode 100644 index 0000000..d7a1331 --- /dev/null +++ b/src/www/admin/services/user/_form.php @@ -0,0 +1,34 @@ +assign([ + 'custom_js' => ['service_form.js'], +]); + +$tpl->assign(compact('form_url', 'today', 'grouped_services', + 'create', 'copy_service', 'copy_service_only_paid')); + +$tpl->assign_by_ref('users', $users); + +$tpl->assign('projects', Projects::listAssoc()); diff --git a/src/www/admin/services/user/add.php b/src/www/admin/services/user/add.php new file mode 100644 index 0000000..3985e16 --- /dev/null +++ b/src/www/admin/services/user/add.php @@ -0,0 +1,27 @@ +requireAccess($session::SECTION_USERS, $session::ACCESS_WRITE); + +// This controller allows to either select a user if none has been provided in the query string +// or subscribe a user to an activity (create a new Service_User entity) +// If $user_id is null then the form is just a select to choose a user + +$count_all = Services::count(); + +if (!$count_all) { + Utils::redirect(ADMIN_URL . 'services/?CREATE'); +} + +$services = Services::listAssocWithFees(); +$categories = Categories::listAssoc(); + +$tpl->assign(compact('services', 'categories')); + +$tpl->display('services/user/add.tpl'); diff --git a/src/www/admin/services/user/delete.php b/src/www/admin/services/user/delete.php new file mode 100644 index 0000000..bd0bdf9 --- /dev/null +++ b/src/www/admin/services/user/delete.php @@ -0,0 +1,31 @@ +requireAccess($session::SECTION_USERS, $session::ACCESS_WRITE); + +$su = Services_User::get((int) qg('id')); + +if (!$su) { + throw new UserException("Cette inscription n'existe pas"); +} + +$csrf_key = 'su_delete_' . $su->id(); +$user_id = $su->id_user; + +$form->runIf('delete', function () use ($su) { + $su->delete(); +}, $csrf_key, ADMIN_URL . 'services/user/?id=' . $user_id); + +$user_name = Users::getName($su->id_user); + +$service_name = $su->service()->label; +$fee_name = $su->id_fee ? $su->fee()->label : null; + +$tpl->assign(compact('csrf_key', 'user_name', 'fee_name', 'service_name')); + +$tpl->display('services/user/delete.tpl'); diff --git a/src/www/admin/services/user/edit.php b/src/www/admin/services/user/edit.php new file mode 100644 index 0000000..3088752 --- /dev/null +++ b/src/www/admin/services/user/edit.php @@ -0,0 +1,35 @@ +requireAccess($session::SECTION_USERS, $session::ACCESS_WRITE); + +$su = Services_User::get((int) qg('id')); + +if (!$su) { + throw new UserException("Cette inscription n'existe pas"); +} + +$csrf_key = 'su_edit_' . $su->id(); +$users = [$su->id_user => Users::getName($su->id_user)]; +$form_url = sprintf('edit.php?id=%d&', $su->id()); +$create = false; + +require __DIR__ . '/_form.php'; + +$form->runIf('save', function () use ($su) { + $su->importForm(); + $su->importForm(['paid' => (bool)f('paid')]); + $su->updateExpectedAmount(); + $su->save(); +}, $csrf_key, ADMIN_URL . 'services/user/?id=' . $su->id_user); + +$service_user = $su; + +$tpl->assign(compact('csrf_key', 'service_user')); + +$tpl->display('services/user/edit.tpl'); diff --git a/src/www/admin/services/user/index.php b/src/www/admin/services/user/index.php new file mode 100644 index 0000000..8b01dd0 --- /dev/null +++ b/src/www/admin/services/user/index.php @@ -0,0 +1,46 @@ +requireAccess($session::SECTION_USERS, $session::ACCESS_READ); + +$user_id = (int) qg('id'); +$user_name = Users::getName($user_id); + +if (!$user_name) { + throw new UserException("Ce membre est introuvable"); +} + +$form->runIf($session->canAccess($session::SECTION_USERS, $session::ACCESS_WRITE) && null !== qg('paid') && qg('su_id'), function () { + $su = Services_User::get((int) qg('su_id')); + + if (!$su) { + throw new UserException("Cette inscription est introuvable"); + } + + $su->paid = (bool)qg('paid'); + $su->save(); +}, null, ADMIN_URL . 'services/user/?id=' . $user_id); + +$only = (int)qg('only') ?: null; + +if ($after = qg('after')) { + $after = \DateTime::createFromFormat('!Y-m-d', $after) ?: null; +} + +$only_service = !$only ? null : Services::get($only); + +$list = Services_User::perUserList($user_id, $only, $after); +$list->setTitle(sprintf('Inscriptions — %s', $user_name)); +$list->loadFromQueryString(); + +$tpl->assign('services', Services_User::listDistinctForUser($user_id)); +$tpl->assign(compact('list', 'user_id', 'user_name', 'only', 'only_service', 'after')); + +$tpl->display('services/user/index.tpl'); diff --git a/src/www/admin/services/user/link.php b/src/www/admin/services/user/link.php new file mode 100644 index 0000000..cafe4dc --- /dev/null +++ b/src/www/admin/services/user/link.php @@ -0,0 +1,33 @@ +requireAccess($session::SECTION_USERS, $session::ACCESS_WRITE); +$session->requireAccess($session::SECTION_ACCOUNTING, $session::ACCESS_READ); + +$su = Services_User::get((int)qg('id')); + +if (!$su) { + throw new UserException("Cette inscription n'existe pas"); +} + +$csrf_key = 'service_link'; + +$form->runIf('save', function () use ($su) { + $id = (int)f('id_transaction'); + $transaction = Transactions::get($id); + + if (!$transaction) { + throw new UserException('Impossible de trouver l\'écriture #' . $id); + } + + $transaction->linkToSubscription($su->id); +}, $csrf_key, '!acc/transactions/service_user.php?id=' . $su->id . '&user=' . $su->id_user); + +$tpl->assign(compact('csrf_key')); + +$tpl->display('services/user/link.tpl'); diff --git a/src/www/admin/services/user/payment.php b/src/www/admin/services/user/payment.php new file mode 100644 index 0000000..5fbc323 --- /dev/null +++ b/src/www/admin/services/user/payment.php @@ -0,0 +1,51 @@ +requireAccess($session::SECTION_USERS, $session::ACCESS_WRITE); + +$su = Services_User::get((int)qg('id')); + +if (!$su) { + throw new UserException("Cette inscription n'existe pas"); +} + +$fee = $su->fee(); + +if (!$fee || !$fee->id_year) { + throw new UserException('Cette inscription n\'est pas liée à un tarif relié à la comptabilité, il n\'est pas possible de saisir un règlement.'); +} + +$user_name = Users::getName($su->id_user); + +$csrf_key = 'service_pay'; + +$form->runIf(f('save') || f('save_and_add_payment'), function () use ($su, $session) { + $su->addPayment($session->getUser()->id); + + if ($su->paid != (bool) f('paid')) { + $su->paid = (bool) f('paid'); + $su->save(); + } +}, $csrf_key, '!services/user/?id=' . $su->id_user); + +$t = new Transaction; +$t->type = $t::TYPE_REVENUE; +$types_details = $t->getTypesDetails(); + +$account_targets = $types_details[Transaction::TYPE_REVENUE]->accounts[1]->targets_string; + +$tpl->assign('projects', Projects::listAssoc()); + +$tpl->assign(compact('csrf_key', 'account_targets', 'user_name', 'su', 'fee')); + +$tpl->display('services/user/payment.tpl'); diff --git a/src/www/admin/services/user/subscribe.php b/src/www/admin/services/user/subscribe.php new file mode 100644 index 0000000..f331445 --- /dev/null +++ b/src/www/admin/services/user/subscribe.php @@ -0,0 +1,112 @@ +requireAccess($session::SECTION_USERS, $session::ACCESS_WRITE); + +// This controller allows to either select a user if none has been provided in the query string +// or subscribe a user to an activity (create a new Service_User entity) +// If $user_id is null then the form is just a select to choose a user + +$count_all = Services::count(); + +if (!$count_all) { + Utils::redirect(ADMIN_URL . 'services/?CREATE'); +} + +$users = null; +$copy_service = null; +$copy_fee = null; +$copy_only_paid = null; +$allow_users_edit = true; +$copy = substr((string) f('copy'), 0, 1); +$copy_id = (int) substr((string) f('copy'), 1); + +if (qg('user') && ($name = Users::getName((int)qg('user')))) { + $users = [(int)qg('user') => $name]; + $allow_users_edit = false; +} +elseif (f('users') && is_array(f('users')) && count(f('users'))) { + $users = f('users'); + $users = array_filter($users, 'intval', \ARRAY_FILTER_USE_KEY); +} +elseif (($copy == 's' && ($copy_service = Services::get($copy_id))) + || ($copy == 'f' && ($copy_fee = Fees::get($copy_id)))) { + $copy_only_paid = (bool) f('copy_only_paid'); +} +elseif (f('category')) { + $category = Categories::get((int)f('category')); + + if (!$category) { + throw new UserException('Catégorie inconnue.'); + } + + $users = iterator_to_array(Users::iterateAssocByCategory($category->id)); +} +elseif (qg('users')) { + $users = explode(',', qg('users')); + $users = array_map('intval', $users); + $users = Users::getNames($users); +} +else { + throw new UserException('Aucun membre n\'a été sélectionné'); +} + +if (null !== $users) { + natcasesort($users); +} + +$form_url = '?'; +$csrf_key = 'service_save'; +$create = true; + +// Only load the form if a user has been selected +require __DIR__ . '/_form.php'; + +$form->runIf('save', function () use ($session, &$users, $copy_service, $copy_fee, $copy_only_paid) { + if ($copy_service) { + $users = $copy_service->getUsers($copy_only_paid); + } + elseif ($copy_fee) { + $users = $copy_fee->getUsers($copy_only_paid); + } + + $su = Service_User::createFromForm($users, $session->getUser()->id, $copy_service ? true : false); + + Utils::reloadParentFrameIfDialog(); + + if (count($users) > 1) { + $url = ADMIN_URL . 'services/details.php?id=' . $su->id_service; + } + else { + $url = ADMIN_URL . 'services/user/?id=' . $su->id_user; + } + + Utils::redirect($url); +}, $csrf_key); + +if (null !== $users && !count($users)) { + throw new ValidationException('Aucun membre sélectionné ne peut être inscrit, car ils sont tous déjà inscrits à cette activité et à la date indiquée.'); +} + +$t = new Transaction; +$t->type = $t::TYPE_REVENUE; +$types_details = $t->getTypesDetails(); +$account_targets = $types_details[Transaction::TYPE_REVENUE]->accounts[1]->targets_string; + +$service_user = null; + +$tpl->assign(compact('csrf_key', 'users', 'account_targets', 'service_user', 'allow_users_edit', 'copy_service', 'copy_fee', 'copy_only_paid')); +$tpl->assign('projects', Projects::listAssoc()); + +$tpl->display('services/user/subscribe.tpl'); diff --git a/src/www/admin/static/admin.css b/src/www/admin/static/admin.css new file mode 100644 index 0000000..54bf5b9 --- /dev/null +++ b/src/www/admin/static/admin.css @@ -0,0 +1,9 @@ +@import url("styles/00-reset.css"); +@import url("styles/01-layout.css"); +@import url("styles/02-common.css"); +@import url("styles/03-forms.css"); +@import url("styles/04-dialogs.css"); +@import url("styles/05-navigation.css"); +@import url("styles/06-tables-export.css"); +@import url("styles/07-tables.css"); +@import url("styles/10-accounting.css"); diff --git a/src/www/admin/static/bg.png b/src/www/admin/static/bg.png new file mode 100644 index 0000000..76539c4 Binary files /dev/null and b/src/www/admin/static/bg.png differ diff --git a/src/www/admin/static/bg_dev.png b/src/www/admin/static/bg_dev.png new file mode 100644 index 0000000..ddcc146 Binary files /dev/null and b/src/www/admin/static/bg_dev.png differ diff --git a/src/www/admin/static/doc/api.html b/src/www/admin/static/doc/api.html new file mode 100644 index 0000000..5d6385e --- /dev/null +++ b/src/www/admin/static/doc/api.html @@ -0,0 +1,350 @@ + + + + /home/bohwaz/fossil/paheko/tools/../doc/admin/api.md + + + + +

    Une API de type REST est disponible dans Paheko.

    +

    Pour accéder à l'API il faut un identifiant et un mot de passe, à créer dans le menu Configuration, onglet Fonctions avancées, puis API.

    +

    L'API peut ensuite recevoir des requêtes REST sur l'URL https://adresse_association/api/{chemin}/.

    +

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

    +

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

    +

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

    Utiliser l'API

    +

    N'importe quel client HTTP capable de gérer TLS (HTTPS) et l'authentification basique fonctionnera.

    +

    En ligne de commande il est possible d'utiliser curl. Exemple pour télécharger la base de données :

    +
    curl https://test:coucou@[identifiant_association].paheko.cloud/api/download -o association.sqlite
    +

    On peut aussi utiliser wget en n'oubliant pas l'option --auth-no-challenge sinon l'authentification ne fonctionnera pas :

    +
    wget https://test:coucou@[identifiant_association].paheko.cloud/api/download  --auth-no-challenge -O association.sqlite
    +

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

    +
    curl -v "http://test:test@[identifiant_association].paheko.cloud/api/accounting/transaction" -F id_year=1 -F label=Test -F "date=01/02/2023" …
    +

    Ou sous forme d'objet JSON :

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

    Authentification

    +

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

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

    Erreurs

    +

    En cas d'erreur un code HTTP 4XX sera fourni, et le contenu sera un objet JSON avec une clé error contenant le message d'erreur.

    +

    Chemins

    +

    sql (POST)

    +

    Permet d'exécuter une requête SQL SELECT (uniquement, pas de requête UPDATE, DELETE, INSERT, etc.) sur la base de données. La requête SQL doit être passée dans le corps de la requête HTTP, ou dans le paramètre sql. Le résultat est retourné dans la clé results de l'objet JSON.

    +

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

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

    ATTENTION : Les requêtes en écriture (INSERT, DELETE, UPDATE, CREATE TABLE, etc.) ne sont pas acceptées, il n'est pas possible de modifier la base de données directement via Paheko, afin d'éviter les soucis de données corrompues.

    +

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

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

    Exemple :

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

    Téléchargements

    +

    download (GET)

    +

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

    +

    Exemple :

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

    download/files (GET)

    +

    (Depuis la version 1.3.4)

    +

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

    +

    Exemple :

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

    Site web

    +

    web/list (GET)

    +

    Renvoie la liste des pages du site web.

    +

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

    +

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

    +

    web/page/{PAGE_URI} (GET)

    +

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

    +

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

    +

    web/html/{PAGE_URI} (GET)

    +

    Renvoie uniquement le contenu de la page au format HTML.

    +

    Membres

    +

    user/import (PUT)

    +

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

    +

    Cette route nécessite une clé d'API ayant les droits d'administration, car importer un fichier peut permettre de modifier l'identifiant de connexion d'un administrateur et donc potentiellement d'obtenir l'accès à l'interface d'administration.

    +

    Paheko s'attend à ce que la première est ligne du tableau contienne le nom des colonnes, et que le nom des colonnes correspond au nom des champs de la fiche membre (ou à leur nom unique). Par exemple si votre fiche membre contient les champs Nom et prénom et Adresse postale, alors le fichier fourni devra ressembler à ceci :

    + + + + + + + + + + + + + +
    Nom et prénomAdresse postale
    Ada Lovelace42 rue du binaire, 21000 DIJON
    +

    Ou à ceci :

    + + + + + + + + + + + + + +
    nom_prenomadresse_postale
    Ada Lovelace42 rue du binaire, 21000 DIJON
    +

    La méthode renvoie un code HTTP 200 OK si l'import s'est bien passé, sinon un code 400 et un message d'erreur JSON dans le corps de la réponse.

    +

    Utilisez la route user/import/preview avant pour vérifier que l'import correspond à ce que vous attendez.

    +

    Exemple pour modifier le nom du membre n°42 :

    +
    echo 'numero,nom' > membres.csv
    +echo '42,"Nouveau nom"' >> membres.csv
    +curl https://test:abcd@monpaheko.tld/api/user/import -T membres.csv
    +

    Paramètres

    +

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

    +

    Depuis la version 1.2.8 il est possible d'utiliser un paramètre supplémentaire mode contenant une de ces options pour spécifier le mode d'import :

    +
      +
    • auto (défaut si le mode n'est pas spécifié) : met à jour la fiche d'un membre si son numéro existe, sinon crée un membre si le numéro de membre indiqué n'existe pas ou n'est pas renseigné
    • +
    • create : ne fait que créer de nouvelles fiches de membre, si le numéro de membre existe déjà une erreur sera produite
    • +
    • update : ne fait que mettre à jour les fiches de membre en utilisant le numéro de membre comme référence, si le numéro de membre n'existe pas une erreur sera produite
    • +
    +

    Depuis la version 1.3.0 il est possible de spécifier :

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

    Exemple :

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

    user/import (POST)

    +

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

    +
    curl https://test:abcd@monpaheko.tld/api/user/import \
    +  -F mode=create \
    +  -F 'column[0]=nom_prenom' \
    +  -F 'column[1]=code_postal' \
    +  -F skip_lines=0 \
    +  -F file=@membres.csv
    +

    user/import/preview (PUT)

    +

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

    +
      +
    • errors : liste des erreurs d'import
    • +
    • created : liste des membres ajoutés, chaque objet contenant tous les champs de la fiche membre qui serait créée
    • +
    • modified : liste des membres modifiés, chaque membre aura une clé id et une clé name, ainsi qu'un objet changed contenant la liste des champs modifiés. Chaque champ modifié aura 2 propriétés old et new, contenant respectivement l'ancienne valeur du champ et la nouvelle.
    • +
    • unchanged : liste des membres mentionnés dans l'import, mais qui ne seront pas affectés. Pour chaque membre une clé name et une clé id indiquant le nom et l'identifiant unique numérique du membre
    • +
    +

    Note : si errors n'est pas vide, alors il sera impossible d'importer le fichier avec user/import.

    +

    Exemple de retour :

    +
    {
    +    "created": [
    +        {
    +            "numero": 3434351,
    +            "nom": "Bla Bli Blu"
    +        }
    +    ],
    +    "modified": [
    +        {
    +            "id": 1,
    +            "name": "Ada Lovelace",
    +            "changed": {
    +                "nom": {
    +                    "old": "Ada Lvelavce",
    +                    "new": "Ada Lovelace"
    +                }
    +            }
    +        }
    +    ],
    +    "unchanged": [
    +        {
    +            "id": 2,
    +            "name": "Paul Muad'Dib"
    +        }
    +    ]
    +}
    +

    user/import/preview (POST)

    +

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

    +

    Activités

    +

    services/subscriptions/import (PUT)

    +

    (Depuis Paheko 1.3.2)

    +

    Permet d'importer les inscriptions des membres aux activités à partir d'un fichier CSV. Les activités et tarifs doivent déjà exister avant l'import.

    +

    Les colonnes suivantes peuvent être utilisées :

    +
      +
    • Numéro de membre**
    • +
    • Activité**
    • +
    • Tarif
    • +
    • Date d'inscription**
    • +
    • Date d'expiration
    • +
    • Montant à régler
    • +
    • Payé ?
    • +
    +

    Les colonnes suivies de deux astérisques (**) sont obligatoires.

    +

    Exemple :

    +
    echo '"Numéro de membre","Activité","Tarif","Date d'inscription","Date d'expiration","Montant à régler","Payé ?"' > /tmp/inscriptions.csv
    +echo '42,"Cours de théâtre","Tarif adulte","01/09/2023","01/07/2023","123,50","Non"' >> /tmp/inscriptions.csv
    +curl https://test:abcd@monpaheko.tld/api/services/subscriptions/import -T /tmp/inscriptions.csv
    +

    Erreurs

    +

    Paheko dispose d'un système dédié à la gestion des erreurs internes, compatible avec les formats des logiciels AirBrake et errbit.

    +

    errors/report (POST)

    +

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

    +

    errors/log (GET)

    +

    Renvoie le log d'erreurs système, au format airbrake/errbit (voir la doc AirBrake pour un exemple du format)

    +

    Comptabilité

    +

    accounting/years (GET)

    +

    Renvoie la liste des exercices.

    +

    accounting/charts (GET)

    +

    Renvoie la liste des plans comptables.

    +

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

    +

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

    +

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

    +

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

    +

    Note : il est possible d'utiliser current comme paramètre pour {ID_YEAR} pour désigner l'exercice ouvert en cours. S'il y a plusieurs exercices ouverts, alors le plus ancien sera choisi.

    +

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

    +

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

    +

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

    +

    Note : il est possible d'utiliser current comme paramètre pour {ID_YEAR} pour désigner l'exercice ouvert en cours. S'il y a plusieurs exercices ouverts, alors le plus ancien sera choisi.

    +

    accounting/transaction/{ID_TRANSACTION} (GET)

    +

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

    +

    accounting/transaction/{ID_TRANSACTION} (POST)

    +

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

    +

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

    +

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

    +

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

    +

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

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

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

    +

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

    +

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

    +

    (Depuis la version 1.3.6)

    +

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

    +

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

    +

    (Depuis la version 1.3.6)

    +

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

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

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

    +

    (Depuis la version 1.3.6)

    +

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

    +

    accounting/transaction (POST)

    +

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

    +

    Structure pour créer / modifier une écriture

    +

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

    +
      +
    • id_year
    • +
    • date (format YYYY-MM-DD)
    • +
    • type peut être un type d'écriture simplifié (2 lignes) : EXPENSE (dépense), REVENUE (recette), TRANSFER (virement), DEBT (dette), CREDIT (créance), ou ADVANCED pour une écriture multi-ligne
    • +
    • amount (uniquement pour les écritures simplifiées) : contient le montant de l'écriture
    • +
    • credit (uniquement pour les écritures simplifiées) : contient le numéro du compte porté au crédit
    • +
    • debit (uniquement pour les écritures simplifiées) : contient le numéro du compte porté au débit
    • +
    • lines (pour les écritures multi-lignes) : un tableau dont chaque ligne doit contenir :
        +
      • account (numéro du compte) ou id_account (ID unique du compte)
      • +
      • credit : montant à inscrire au crédit (doit être zéro ou non renseigné si debit est renseigné, et vice-versa)
      • +
      • debit : montant à inscrire au débit
      • +
      • label (facultatif) : libellé de la ligne
      • +
      • reference (facultatif) : référence de la ligne (aussi appelé référence du paiement pour les écritures simplifiées)
      • +
      • id_project : ID unique du projet à affecter
      • +
      +
    • +
    +

    Champs optionnels :

    +
      +
    • reference : numéro de pièce comptable
    • +
    • notes : remarques (texte multi ligne)
    • +
    • id_project : ID unique du projet à affecter (pour les écritures simplifiées uniquement)
    • +
    • payment_reference (uniquement pour les écritures simplifiées) : référence de paiement
    • +
    • linked_users : Tableau des IDs des membres à lier à l'écriture (depuis 1.3.3)
    • +
    • linked_transactions : Tableau des IDs des écritures à lier à l'écriture (depuis 1.3.5)
    • +
    • linked_subscriptions : Tableau des IDs des inscriptions à lier à l'écriture (depuis 1.3.6)
    • +
    +

    Exemple :

    +
    curl -F 'id_year=12' -F 'label=Test' -F 'date=01/02/2022' -F 'type=EXPENSE' -F 'amount=42' -F 'debit=512A' -F 'credit=601' …
    \ No newline at end of file diff --git a/src/www/admin/static/doc/brindille.html b/src/www/admin/static/doc/brindille.html new file mode 100644 index 0000000..da8c0e8 --- /dev/null +++ b/src/www/admin/static/doc/brindille.html @@ -0,0 +1,502 @@ + + + + Documentation du langage Brindille dans Paheko + + + + +

    Introduction

    +

    La syntaxe utilisée dans les squelettes du site web et des modules s'appelle Brindille.

    +

    Si vous avez déjà fait de la programmation, elle ressemble à un mélange de Mustache, Smarty, Twig et PHP.

    +

    Son but est de permettre une grande flexibilité, sans avoir à utiliser un "vrai" langage de programmation, mais en s'en rapprochant suffisamment quand même.

    +

    Fichiers

    +

    Un fichier texte contenant du code Brindille est appelé un squelette.

    +

    Seuls les fichiers ayant une des extensions .tpl, .html, .htm, .skel ou .xml seront traités par Brindille.
    +De même, les fichiers qui n'ont pas d'extension seront également traités par Brindille.

    +

    Les autres types de fichiers seront renvoyés sans traitement, comme des fichiers "bruts". En d'autres termes, il n'est pas possible de mettre du code Brindille dans des fichiers qui ne sont pas des fichiers textes.

    +

    Syntaxe de base

    +

    Variables

    +

    En programmation, une variable est une référence vers une donnée stockée en mémoire.

    +

    Dans Brindille, une variable commence par le symbole dollar $ et suivi d'un nom.

    +

    Le nom est composé de lettres minuscules (sans accents), de chiffres et de tirets bas ([a-z0-9_]).

    +

    Exemples de variables :

    +
    $config
    +$compte_32
    +$nom_de_variable_long
    +

    Types de variables

    +

    Dans Brindille, une variable peut avoir un des types suivants :

    +
      +
    • null : utilisé pour une variable qui n'est pas définie, ou une variable définie qui n'a pas de valeur
    • +
    • boolean : valeur booléenne, peut seulement avoir true ou false comme valeur
    • +
    • integer : nombre entier (sans virgule). Exemple : 4200.
    • +
    • float : nombre à virgule flottante, exemple 3.14. Leur usage est déconseillé, car les erreurs de calcul sont possibles, les ordinateurs ne sachant pas compter de manière précise avec les nombres à virgule flottante. Exemple : 0.2+0.3 est différent de 0.5.
    • +
    • string : chaîne de texte (aussi appelé chaîne de caractères, car c'est une suite de caractères). Exemple : coucou.
    • +
    • array : tableau.
    • +
    +

    Tableaux

    +

    Les tableaux sont une sorte de dictionnaire, ou pour chaque entrée (appelée "clé") on peut associer une valeur. qui peut être de n'importe lequel des types listés ci-dessus, y compris un autre tableau.

    +

    Les tableaux peuvent être de deux types :

    +
      +
    1. tableau indexé (liste) : dans ce cas les clés ne sont pas choisies, on ajoute simplement des valeurs, et l'ordinateur incrémente le numéro de la clé à chaque nouvelle entrée dans le tableau. La numérotation commence au chiffre zéro.
    2. +
    3. tableau associatif (dictionnaire) : les clés sont des nombres ou des chaînes de texte, et permettent, comme dans un dictionnaire, de choisir la clé.
    4. +
    +

    Exemple de tableau indexé :

    +
    [
    +  0 => 'Texte 1',
    +  1 => 'Texte 2'
    +]
    +

    Exemple de tableau associatif :

    +
    [
    +  'un' => 'Texte 1',
    +  'deux' => 'Texte 2'
    +]
    +

    Affichage de variable

    +

    Une variable est affichée à l'aide de la syntaxe : {{$date}} affichera la valeur brute de la date par exemple : 2020-01-31 16:32:00.

    +

    La variable peut être modifiée à l'aide de filtres de modification, qui sont ajoutés avec le symbole de la barre verticale (pipe |) : {{$date|date_long}} affichera une date au format long : jeudi 7 mars 2021.

    +

    Ces filtres peuvent accepter des paramètres, séparés par deux points :. Exemple : {{$date|date:"d/m/Y"}} affichera 31/01/2020.

    +

    Par défaut la variable sera recherchée dans le contexte actuel de la section, si elle n'est pas trouvée elle sera recherchée dans le contexte parent (section parente), etc. jusqu'à trouver la variable.

    +

    Protection contre le HTML (échappement)

    +

    Par défaut le filtre escape est appliqué à toutes les variables affichées, pour protéger les variables contre les injections de code HTML.

    +

    Ce filtre modifie (on dit qu'il "échappe") les caractères HTML <>&"' présents dans une chaîne de texte en entités HTML, évitant que le HTML éventuellement présent dans la chaîne de texte soit interprété par le navigateur.

    +

    Ce filtre est appliqué en dernier, après les autres filtres. Il est possible de contourner cet automatisme en rajoutant le filtre escape ou raw explicitement. raw désactive tout échappement, alors que escape est utilisé pour changer l'ordre d'échappement. Exemple :

    +
    {{:assign text = "Coucou\nça va ?" }}
    +{{$text|escape|nl2br}}
    +

    Donnera bien Coucou<br />ça va ?. Si on n'avait pas indiqué le filtre escape le résultat serait Coucou&lt;br /&gt;ça va ?.

    +

    Variables de tableaux

    +

    Il est possible de faire référence à une clé d'un tableau avec la notation à points : {{$article.date}} renverra la clé date du tableau stocké dans la variable $article.

    +

    Échappement des caractères spéciaux dans les chaînes de caractère

    +

    Pour inclure un caractère spécial (retour de ligne, guillemets ou apostrophe) dans une chaîne de caractère il suffit d'utiliser un antislash :

    +
    {{:assign text="Retour \n à la ligne"}}
    +{{:assign text="Utiliser des \"apostrophes\"}}
    +

    Ordre de recherche des variables

    +

    Par défaut les variables sont recherchées dans l'ordre inverse, c'est à dire que sont d'abord recherchées les variables avec le nom demandé dans la section courante. Si la variable n'existe pas dans la section courante, alors elle est recherchée dans la section parente, et ainsi de suite jusqu'à ce que la variable soit trouvée, où qu'il n'y ait plus de section parente.

    +

    Prenons cet exemple :

    +
    {{#articles uri="Actualite"}}
    +  <h1>{{$title}}</h1>
    +    {{#images parent=$path limit=1}}
    +      <img src="{{$thumb_url}}" alt="{{$title}}" />
    +    {{/images}}
    +{{/articles}}
    +

    Dans la section articles, $title est une variable de l'article, donc la variable est celle de l'article.

    +

    Dans la section images, les images n'ayant pas de titre, la variable sera celle de l'article de la section parente, alors que $thumb_url sera lié à l'image.

    +

    Conflit de noms de variables

    +

    Imaginons que nous voulions mettre un lien vers l'article sur l'image de l'exemple précédent :

    +
    {{#articles uri="Actualite"}}
    +  <h1>{{$title}}</h1>
    +    {{#images parent=$path limit=1}}
    +
    +    {{/images}}
    +{{/articles}}
    +

    Problème, ici $url fera référence à l'URL de l'image elle-même, et non pas l'URL de l'article.

    +

    La solution est d'ajouter un point au début du nom de variable : {{$.url}}.

    +

    Un point au début d'un nom de variable signifie que la variable est recherchée à partir de la section précédente. Il est possible d'utiliser plusieurs points, chaque point correspond à un niveau à remonter. Ainsi $.url cherchera la variable dans la section parente (et ses sections parentes si elle n'existe pas, etc.). De même, $..url cherchera dans la section parente de la section parente.

    +

    Création manuelle de variable

    +

    Variable simple

    +

    La création d'une variable se fait via l'appel de la fonction {{:assign}}.

    +

    Exemple :

    +
    {{:assign source='wiki'}}
    +{{* est identique à : *}}
    +{{:assign var='source' value='wiki'}}
    +

    Un deuxième appel à {{:assign}} avec le même nom de variable écrase la valeur précédente

    +
    {{:assign var='source' value='wiki'}}
    +{{:assign var='source' value='documentation'}}
    +
    +{{$source}}
    +{{* => Affiche documentation *}}
    +

    Nom de variable dynamique

    +

    Il est possible de créer une variable dont une partie du nom est dynamique.

    +
    {{:assign type='user'}}
    +{{:assign var='allowed_%s'|args:$type value='jeanne'}}
    +{{:assign type='side'}}
    +{{:assign var='allowed_%s'|args:$type value='admin'}}
    +
    +{{$allowed_user}} => jeanne
    +{{$allowed_side}} => admin
    +

    Documentation complète de la fonction {{:assign}}.

    +

    Tableaux (array)

    +

    Pour créer des tableaux, il suffit d'utiliser des points . dans le nom de la variable (ex : colors.yellow). Il n'y a pas besoin d'initialiser le tableau avant de le remplir.

    +
    {{:assign var='colors.admin' value='blue'}}
    +{{:assign var='colors.website' value='grey'}}
    +{{:assign var='colors.debug' value='yellow'}}
    +

    On accède ensuite à la valeur d'un élément du tableau avec la même syntaxe : {{$colors.website}}

    +

    Méthode rapide de création du même tableau :

    +
    {{:assign var='colors' admin='blue' website='grey' debug='yellow'}}
    +

    Pour ajouter un élément à la suite du tableau sans spécifier de clef (push), il suffit de terminer le nom de la variable par un point . sans suffixe.

    +

    Exemple :

    +
    {{* Ajouter les valeurs 17, 43 et 214 dans $processed_ids *}}
    +
    +{{:assign var='processed_ids.' value=17}}
    +{{:assign var='processed_ids.' value=43}}
    +{{:assign var='processed_ids.' value=214}}
    +

    Donnera le tableau suivant :

    +
    [
    +  0 => 17,
    +  1 => 43,
    +  2 => 214
    +]
    +

    Clef dynamique de tableau

    +

    Il est possible d'accéder dynamiquement à un des éléments d'un tableau de la manière suivante :

    +
    {{:assign location='admin'}}
    +{{:assign var='location_color' from='colors.%s'|args:$location}}
    +
    +{{$location_color}} => blue
    +

    Exemple plus complexe :

    +
    {{:assign var='type_whitelist.text' value=1}}
    +{{:assign var='type_whitelist.html' value=1}}
    +
    +{{#foreach from=$documents item='document'}}
    +  {{:assign var='allowed' value='type_whitelist.%s'|args:$document->type}}
    +  {{if $allowed !== null}}
    +    {{:include file='document/'|cat:$type:'.tpl' keep='document'}}
    +  {{/if}}
    +{{/foreach}}
    +

    Il est également possible de créer un membre dynamique d'un tableau en conjuguant les syntaxes précédentes.

    +

    Exemple :

    +
    {{:assign var='type_whitelist.%s'|args:$type value=1}}
    +

    Conditions

    +

    Il est possible d'utiliser des conditions de type "si" (if), "sinon si" (elseif) et "sinon" (else). Celles-ci sont terminées par un block "fin si" (/if).

    +
    {{if $date|date:"%Y" > 2020}}
    +    La date est en 2020
    +{{elseif $article.status == 'draft'}}
    +    La page est un brouillon
    +{{else}}
    +    Autre chose.
    +{{/if}}
    +

    Une condition peut être évaluée comme vraie, dans ce cas la partie qui suit le bloc {{if …}} sera exécutée.

    +

    Si la condition est évaluée comme fausse, alors la partie qui suit le bloc {{if …}} ne sera pas exécutée. Dans ce cas, soit une condition {{elseif …}} suivante est vraie et exécutée, mais sinon c'est le contenu du bloc {{else}} qui est exécuté et affiché.

    +

    Dans une condition on peut utiliser :

    +
      +
    • une variable : $nom_variable
    • +
    • une variable avec des filtres : $nom_variable|filtre1:parametre1
    • +
    • une valeur (nombre, constante) : 42, -42, 42.02 null, true, false
    • +
    • des opérateurs de comparaison
    • +
    • des opérateurs logiques
    • +
    +

    Attention : on ne peut pas grouper les conditions avec des parenthèses.

    +

    Les comparaisons supportées sont les suivantes :

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    Opérateur de comparaisonExplication
    ==égalité faible, ne prend pas en compte le type : 1 == true et 2 == 2.00 serons évalués comme vrais
    ===égalité forte, prend en compte le type : 1 === 1 sera vrai, mais 1 === true sera faux
    !=différent de, en comparaison faible
    !==différent de, en comparaison forte
    >supérieur à
    >=supérieur ou égal à
    <inférieur à
    <=inférieur ou égal à
    +

    Il est aussi possible de précéder une variable de l'opérateur !, c'est un raccourci pour $variable == false.

    +

    Voir les opérateurs de comparaison PHP pour plus de détails.

    +

    Les opérateurs logiques supportés sont :

    + + + + + + + + + + + + + + + + + +
    OpérateurExplication
    &&Vrai si les conditions à gauche et à droite sont vraies
    ||Vrai si une des conditions à gauche ou à droite est vraie
    +

    Exemples :

    +
      +
    • false && true : sera évalué comme faux
    • +
    • false || true : sera évalué comme vrai
    • +
    +

    Tester si une variable existe

    +

    Brindille ne fait pas de différences entre une variable qui n'existe pas, et une variable définie à null.
    +On peut donc tester l'existence d'une variable en la comparant à null comme ceci :

    +
    {{if $session !== null}}
    +  Session en cours pour l'utilisateur/trice {{$session.user.name}}.
    +{{else}}
    +  Session inexistante.
    +{{/if}}
    +

    Fonctions

    +

    Fonctions natives

    +

    Une fonction va répondre à certains paramètres et renvoyer un résultat ou réaliser une action.

    +

    Un bloc de fonction commence par le signe deux points :.

    +
    {{:http code=404}}
    +

    Contrairement aux autres types de blocs, et comme pour les variables, il n'y a pas de bloc fermant (avec un slash /).

    +

    Sections

    +

    Une section est une partie de la page qui sera répétée une fois, plusieurs fois, ou zéro fois, selon ses paramètres et le résultat (c'est une "boucle"). Une section commence par un bloc avec un signe hash (#) et se termine par un bloc avec un slash (/).

    +

    Un exemple simple avec une section qui n'aura qu'une seule répétition :

    +
    {{#categories uri=$_GET.uri}}
    +    <h1>{{$title}}</h1>
    +{{/categories}}
    +

    Il est possible d'utiliser une condition {{else}} avant la fin du bloc pour avoir du contenu alternatif si la section ne se répète pas (dans ce cas si aucune catégorie ne correspond au critère).

    +

    Un exemple de sous-section

    +
    {{#categories uri=$_GET.uri}}
    +    <h1>{{$title}}</h1>
    +
    +    {{#articles parent=$path order="published DESC" limit="10"}}
    +        <h2></h2>
    +        <p>{{$content|truncate:600:"..."}}</p>
    +    {{else}}
    +        <p>Aucun article trouvé.</p>
    +    {{/articles}}
    +
    +{{/categories}}
    +

    Voir la référence des sections pour voir quelles sont les sections possibles et quel est leur comportement.

    +

    Bloc litéral

    +

    Pour qu'une partie du code ne soit pas interprété, pour éviter les conflits avec certaines syntaxes, il est possible d'utiliser un bloc literal :

    +
    {{literal}}
    +<script>
    +// Ceci ne sera pas interprété
    +function test (a) {{
    +}}
    +</script>
    +{{/literal}}
    +

    Commentaires

    +

    Les commentaires sont figurés dans des blocs qui commencent et se terminent par une étoile (*) :

    +
    {{* Ceci est un commentaire
    +Il sera supprimé du résultat final
    +Il peut contenir du code qui ne sera pas interprété :
    +{{if $test}}
    +OK
    +{{/if}}
    +*}}
    +

    Liste des variables définies par défaut

    +

    Ces variables sont définies tout le temps :

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    Nom de la variableValeur
    $_GETTableau contenant tous les paramètres passés dans la chaîne de requêtre de l'URL.
    $_POSTTableau de tous les éléments de formulaire envoyés lors d'une requête POST.
    $root_urlAdresse racine du site web Paheko.
    $request_urlAdresse de la page courante.
    $admin_urlAdresse de la racine de l'administration Paheko.
    $visitor_langLangue préférée du visiteur, sur 2 lettres (exemple : fr, en, etc.).
    $logged_userInformations sur le membre actuellement connecté dans l'administration (vide si non connecté).
    $dialogVaut TRUE si la page est dans un dialogue (iframe sous forme de pop-in dans l'administration).
    $nowContient la date et heure courante.
    $config.org_nameNom de l'association
    $config.org_emailAdresse e-mail de l'association
    $config.org_phoneNuméro de téléphone de l'association
    $config.org_addressAdresse postale de l'association
    $config.org_webAdresse du site web de l'association
    $config.files.logoAdresse du logo de l'association, si définit dans la personnalisation
    $config.files.faviconAdresse de l'icône de favoris de l'association, si défini dans la personnalisation
    $config.files.signatureAdresse de l'image de signature, si défini dans la personnalisation
    +

    À celles-ci s'ajoutent les variables spéciales des modules lorsque le script est chargé dans un module.

    +

    Erreurs

    +

    Si une erreur survient dans un squelette, que ça soit au niveau d'une erreur de syntaxe, ou une erreur dans une fonction, filtre ou section, alors elle sera affichée selon les règles suivantes :

    +
      +
    • si le membre connecté est administrateur, une erreur est affichée avec le code du squelette ;
    • +
    • sinon l'erreur est affichée sans le code.
    • +
    +

    Avertissement sur la sécurité des requêtes SQL

    +

    Attention, en utilisant la section {{#select ...}}, ou une des sections SQL (voir plus bas), avec des paramètres qui ne seraient pas protégés, il est possible qu'une personne mal intentionnée ait accès à des parties de la base de données à laquelle vous ne désirez pas donner accès.

    +

    Pour protéger contre cela il est essentiel d'utiliser les paramètres nommés.

    +

    Exemple de requête dangereuse :

    +
    {{#sql select="*" tables="users" where="id = %s"|args:$_GET.id}}
    +...
    +{{/sql}}
    +

    On se dit que la requête finale sera donc : SELECT * FROM users WHERE id = 42; si le numéro 42 est passé dans le paramètre id de la page.

    +

    Imaginons qu'une personne mal-intentionnée indique dans le paramètre id de la page la chaîne de caractère suivante : 0 OR 1. Dans ce cas la requête exécutée sera SELECT * FROM users WHERE id = 0 OR 1;. Cela aura pour effet de lister tous les membres, au lieu d'un seul.

    +

    Pour protéger contre cela il convient d'utiliser un paramètre nommé :

    +
    {{#sql select="*" tables="users" where="id = :id" :id=$_GET.id}}
    +

    Dans ce cas la requête malveillante générée sera SELECT * FROM users WHERE id = '0 OR 1';. Ce qui aura pour effet de ne lister aucun membre.

    +

    Mesures prises pour la sécurité des données

    +

    Dans Brindille, il n'est pas possible de modifier ou supprimer des éléments dans la base de données avec les requêtes SQL directement. Seules les requêtes SQL en lecture (SELECT) sont permises.

    +

    Cependant certaines fonctions permettent de modifier ou créer des éléments précis (écritures par exemple), ce qui peut avoir un effet de remplir ou modifier des données par une personne mal-intentionnée, donc attention à leur utilisation.

    +

    Les autres mesures prises sont :

    +
      +
    • impossibilité d'accéder à certaines données sensibles (mot de passe, logs de connexion, etc.)
    • +
    • incitation forte à utiliser les paramètres nommés dans la documentation
    • +
    • protection automatique des variables dans la section {{#select}}
    • +
    • fourniture de fonctions pour protéger les chaînes de caractères contre l'injection SQL
    • +
    \ No newline at end of file diff --git a/src/www/admin/static/doc/brindille_functions.html b/src/www/admin/static/doc/brindille_functions.html new file mode 100644 index 0000000..8a2c1ef --- /dev/null +++ b/src/www/admin/static/doc/brindille_functions.html @@ -0,0 +1,1208 @@ + + + + Référence des fonctions Brindille + + + + +

    Fonctions généralistes

    +

    assign

    +

    Permet d'assigner une valeur dans une variable.

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    ParamètreOptionnel / obligatoire ?Fonction
    .optionnelAssigner toutes les variables du contexte (section) actuel
    varoptionnelNom de la variable à créer ou modifier
    valueoptionnelValeur de la variable
    fromoptionnelRecopier la valeur depuis la variable ayant le nom fourni dans ce paramètre.
    +

    Tous les autres paramètres sont considérés comme des variables à assigner.

    +

    Exemple :

    +
    {{:assign blabla="Coucou"}}
    +
    +{{$blabla}}
    +

    Il est possible d'assigner toutes les variables d'une section dans une variable en utilisant le paramètre point . (.="nom_de_variable"). Cela permet de capturer le contenu d'une section pour le réutiliser à un autre endroit.

    +
    {{#pages uri="Informations" limit=1}}
    +{{:assign .="infos"}}
    +{{/pages}}
    +
    +{{$infos.title}}
    +

    Il est aussi possible de remonter dans les sections parentes en utilisant plusieurs points. Ainsi deux points remonteront à la section parente, trois points à la section parente de la section parente, etc.

    +
    {{#foreach from=$infos item="info"}}
    +  {{#foreach from=$info item="sous_info"}}
    +    {{if $sous_info.titre == 'Coucou'}}
    +      {{:assign ..="info_importante"}}
    +    {{/if}}
    +  {{/foreach}}
    +{{/foreach}}
    +
    +{{$info_importante.titre}}
    +

    En utilisant le paramètre spécial var, tous les autres paramètres passés sont ajoutés à la variable donnée en valeur :

    +
    {{:assign var="tableau" label="Coucou" name="Pif le chien"}}
    +{{$tableau.label}}
    +{{$tableau.name}}
    +

    De la même manière on peut écraser une variable avec le paramètre spécial value:

    +
    {{:assign var="tableau" value=$infos}}
    +

    Il est également possible de créer des tableaux avec la syntaxe . dans le nom de la variable :

    +
    {{:assign var="liste.comptes.530" label="Caisse"}}
    +{{:assign var="liste.comptes.512" label="Banque"}}
    +
    +{{#foreach from=$liste.comptes}}
    +{{$key}} = {{$value.label}}
    +{{/foreach}}
    +

    Il est possible de rajouter des éléments à un tableau simplement en utilisant un point seul :

    +
    {{:assign var="liste.comptes." label="530 - Caisse"}}
    +{{:assign var="liste.comptes." label="512 - Banque"}}
    +

    Enfin, il est possible de faire référence à une variable de manière dynamique en utilisant le paramètre spécial from :

    +
    {{:assign var="tableau" a="Coucou" b="Test !"}}
    +{{:assign var="titre" from="tableau.%s"|args:"b"}}
    +{{$titre}} -> Affichera "Test !", soit la valeur de {{$tableau.b}}
    +

    break

    +

    Interrompt une section.

    +

    continue

    +

    Passe à l'itération suivante d'une section. Le code situé entre cette instruction et la fin de la section ne sera pas exécuté.

    +
    {{#foreach from=$list item="event"}}
    +  {{if $event.date == '2023-01-01'}}
    +    {{:continue}}
    +  {{/if}}
    +  {{$event.title}}
    +{{/foreach}}
    +

    Il est possible de passer à l'itération suivante d'une section parente en utilisant un chiffre en paramètre :

    +
    {{#foreach from=$list item="event"}}
    +  {{$event.title}}
    +  {{#foreach from=$event.people item="person"}}
    +    {{if $person.name == 'bohwaz'}}
    +      {{:continue 2}}
    +    {{/if}}
    +    - {{$person.name}}
    +  {{/foreach}}
    +{{/foreach}}
    +

    debug

    +

    Cette fonction permet d'afficher le contenu d'une ou plusieurs variables :

    +
    {{:debug test=$title}}
    +

    Affichera :

    +
    array(1) {
    +  ["test"] => string(6) "coucou"
    +}
    +

    Si aucun paramètre n'est spécifié, alors toutes les variables définies sont renvoyées. Utile pour découvrir quelles sont les variables accessibles dans une section par exemple.

    +

    error

    +

    Affiche un message d'erreur et arrête le traitement à cet endroit.

    + + + + + + + + + + + + + + + +
    ParamètreOptionnel / obligatoire ?Fonction
    messageobligatoireMessage d'erreur à afficher
    +

    Exemple :

    +
    {{if $_POST.nombre != 42}}
    +    {{:error message="Le nombre indiqué n'est pas 42"}}
    +{{/if}}
    +

    form_errors

    +

    Affiche les erreurs du formulaire courant (au format HTML).

    +

    http

    +

    Permet de modifier les entêtes HTTP renvoyés par la page. Cette fonction doit être appelée au tout début du squelette, avant tout autre code ou ligne vide.

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    ParamètreOptionnel / obligatoire ?Fonction
    codeoptionnelModifie le code HTTP renvoyé. Liste des codes HTTP
    redirectoptionnelRediriger vers l'adresse URL indiquée en valeur.
    typeoptionnelModifie le type MIME renvoyé
    downloadoptionnelForce la page à être téléchargée sous le nom indiqué.
    inlineoptionnelForce la page à être affichée, et peut ensuite être téléchargée sous le nom indiqué (utile pour la généraion de PDF : permet d'afficher le PDF dans le navigateur avant de le télécharger).
    +

    Note : si le type application/pdf est indiqué (ou juste pdf), la page sera convertie en PDF à la volée. Il est possible de forcer le téléchargement du fichier en utilisant le paramètre download.

    +

    Exemples :

    +
    {{:http code=404}}
    +{{:http redirect="/Nos-Activites/"}}
    +{{:http redirect="https://mon-site-web.tld/"}}
    +{{:http type="application/svg+xml"}}
    +{{:http type="pdf" download="liste_membres_ca.pdf"}}
    +

    include

    +

    Permet d'inclure un autre squelette.

    +

    Paramètres :

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    ParamètreOptionnel / obligatoire ?Fonction
    fileobligatoireNom du squelette à inclure
    keepoptionnelListe de noms de variables à conserver
    captureoptionnelSi renseigné, au lieu d'afficher le squelette, son contenu sera enregistré dans la variable de ce nom.
    optionnelTout autre paramètre sera utilisé comme variable qui n'existea qu'à l'intérieur du squelette inclus.
    +
    {{* Affiche le contenu du squelette "navigation.html" dans le même répertoire que le squelette d'origine *}}
    +{{:include file="./navigation.html"}}
    +

    Par défaut, les variables du squelette parent sont transmis au squelette inclus, mais les variables définies dans le squelette inclus ne sont pas transmises au squelette parent. Exemple :

    +
    {{* Squelette page.html *}}
    +{{:assign title="Super titre !"}}
    +{{:include file="./_head.html"}}
    +{{$nav}}
    +
    {{* Squelette _head.html *}}
    +<h1>{{$title}}</h1>
    +{{:assign nav="Accueil > %s"|args:$title}}
    +

    Dans ce cas, la dernière ligne du premier squelette ({{$nav}}) n'affichera rien, car la variable définie dans le second squelette n'en sortira pas. Pour indiquer qu'une variable doit être transmise au squelette parent, il faut utiliser le paramètre keep:

    +
    {{:include file="./_head.html" keep="nav"}}
    +

    On peut spécifier plusieurs noms de variables, séparés par des virgules, et utiliser la notation à points :

    +
    {{:include file="./_head.html" keep="nav,article.title,name"}}
    +{{$nav}}
    +{{$article.title}}
    +{{$name}}
    +

    On peut aussi capturer le résultat d'un squelette dans une variable :

    +
    {{:include file="./_test.html" capture="test"}}
    +{{:assign var="test" value=$test|replace:'TITRE':'Ceci est un titre'}}
    +{{$test}}
    +

    Il est possible d'assigner de nouvelles variables au contexte du include en les déclarant comme paramètres tout comme on le ferait avec {{:assign}} :

    +
    {{:include file="./_head.html" title='%s documentation'|args:$doc.label visitor=$user}}
    +

    captcha

    +

    Permet de générer une question qui doit être répondue correctement par l'utilisateur pour valider une action. Utile pour empêcher les robots spammeurs d'effectuer une action.

    +

    L'utilisation simplifiée utilise un de ces deux paramètres :

    + + + + + + + + + + + + + + + + + +
    ParamètreFonction
    htmlSi true, crée un élément de formulaire HTML et le texte demandant à l'utilisateur de répondre à la question
    verifySi true, vérifie que l'utilisateur a correctement répondu à la question
    +

    L'utilisation avancée utilise d'abord ces deux paramètres :

    + + + + + + + + + + + + + + + + + +
    ParamètreFonction
    assign_hashNom de la variable où assigner le hash (à mettre dans un <input type="hidden" />)
    assign_numberNom de la variable où assigner le nombre de la question (à afficher à l'utilisateur)
    +

    Puis on vérifie :

    + + + + + + + + + + + + + + + + + + + + + +
    ParamètreFonction
    verify_hashValeur qui servira comme hash de vérification (valeur du <input type="hidden" />)
    verify_numberValeur qui représente la réponse de l'utilisateur
    assign_errorSi spécifié, le message d'erreur sera placé dans cette variable, sinon il sera affiché directement.
    +

    Exemple :

    +
    {{if $_POST.send}}
    +  {{:captcha verify_hash=$_POST.h verify_number=$_POST.n assign_error="error"}}
    +  {{if $error}}
    +    <p class="alert">Mauvaise réponse</p>
    +  {{else}}
    +    ...
    +  {{/if}}
    +{{/if}}
    +
    +<form method="post" action="">
    +{{:captcha assign_hash="hash" assign_number="number"}}
    +<p>Merci de recopier le nombre suivant en chiffres : <tt>{{$number}}</tt></p>
    +<p>
    +  <input type="text" name="n" placeholder="1234" />
    +  <input type="hidden" name="h" value="{{$hash}}" />
    +  <input type="submit" name="send" />
    +</p>
    +</form>
    +

    mail

    +

    Permet d'envoyer un e-mail à une ou des adresses indiquées (sous forme de tableau).

    +

    Restrictions :

    +
      +
    • le message est toujours envoyé en format texte ;
    • +
    • l'expéditeur est toujours l'adresse de l'association ;
    • +
    • l'envoi est limité à une seule adresse e-mail externe (adresse qui n'est pas celle d'un membre) dans une page ;
    • +
    • l'envoi est limité à maximum 10 adresses e-mails internes (adresses de membres) dans une page ;
    • +
    • un message envoyé à une adresse e-mail externe ne peut pas contenir une adresse web (https://...) autre que celle de l'association.
    • +
    +

    Note : il est également conseillé d'utiliser la fonction captcha pour empêcher l'envoi de spam.

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    ParamètreObligatoire ou optionnel ?Fonction
    toobligatoireAdresse email destinataire (seule l'adresse e-mail elle-même est acceptée, pas de nom)
    subjectobligatoireSujet du message
    bodyobligatoireCorps du message
    block_urlsoptionnel(true ou false) Permet de bloquer l'envoi si le message contient une adresse https://…
    attach_fileoptionnelChemin vers un ou plusieurs documents à joindre au message (situé dans les documents)
    attach_fromoptionnelChemin vers un ou plusieurs squelettes à joindre au message (par exemple pour joindre un document généré)
    +

    Pour le destinataire, il est possible de spécifier un tableau :

    +
    {{:assign var="recipients[]" value="membre1@framasoft.net"}}
    +{{:assign var="recipients[]" value="membre2@chatons.org"}}
    +{{:mail to=$recipients subject="Coucou" body="Contenu du message\nNouvelle ligne"}}
    +

    Exemple de formulaire de contact :

    +
    {{if !$_POST.email|check_email}}
    +  <p class="alert">L'adresse e-mail indiquée est invalide.</p>
    +{{elseif $_POST.message|trim == ''}}
    +  <p class="alert">Le message est vide</p>
    +{{elseif $_POST.send}}
    +  {{:captcha verify=true}}
    +  {{:mail to=$config.org_email subject="Formulaire de contact" body="%s a écrit :\n\n%s"|args:$_POST.email:$_POST.message block_urls=true}}
    +  <p class="ok">Votre message nous a bien été transmis !</p>
    +{{/if}}
    +
    +<form method="post" action="">
    +<dl>
    +  <dt><label>Votre e-mail : <input type="email" required name="email" /></label></dt>
    +  <dt><label>Votre message : <textarea required name="message" cols="50" rows="5"></textarea></label></dt>
    +  <dt>{{:captcha html=true}}</dt>
    +</dl>
    +<p><input type="submit" name="send" value="Envoyer !" /></p>
    +</form>
    +

    redirect

    +

    Redirige vers une nouvelle page.

    +

    Avec le paramètre force, si la page actuelle est ouverte dans une fenêtre modale (grâce à la cible _dialog), alors la fenêtre modale est fermée, et la redirection se passe dans la page parente.

    +

    Avec le paramètre to, si la page actuelle est ouverte dans une fenêtre modal (grâce à la cible _dialog), alors la fenêtre modale est fermée, et la page parente est rechargée. Si la page n'est pas ouvertre dans dans une fenêtre modale, la redirection est effectuée.

    +

    Seules les adresses internes sont acceptées, il n'est pas possible de rediriger vers une adresse extérieure.

    + + + + + + + + + + + + + + + + + + + + +
    ParamètreObligatoire ou optionnel ?Fonction
    forceoptionnelAdresse de redirection forcée
    tooptionnelAdresse de redirection si pas dans une fenêtre modale
    +

    Si to=null est utilisé, alors la fenêtre modale sera fermée. Ou, si la page n'est pas dans une fenêtre modale, la page courante sera rechargée.

    +

    api

    +

    Permet d'appeler l'API de Paheko, que ça soit sur l'instance locale, en cours, ou une autre instance externe.

    +

    Voir la documentation de l'API pour la liste des fonctions disponibles.

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    ParamètreObligatoire ou optionnel ?Fonction
    methodobligatoireMéthode de requête : GET, POST, etc.
    pathobligatoireChemin de la méthode de l'API à appeler.
    failoptionnelBooléen. Si true, alors une erreur sera affichée si la requête échoue. Si false, aucune erreur ne sera affichée. Défaut : true.
    assignoptionnelCapturer le résultat dans cette variable.
    assign_codeoptionnelCapturer le code de retour dans cette variable.
    +

    Par défaut, les requêtes sont réalisées sur la base de données locale, dans ce cas les paramètres suivants sont également disponibles :

    + + + + + + + + + + + + + + + +
    ParamètreObligatoire ou optionnel ?Fonction
    accessoptionnelNiveau d'autorisation de l'API (défaut : admin).
    +
    {{:assign var="users." value=42}}
    +{{:api
    +  method="POST"
    +  path="accounting/transaction"
    +  assign="result"
    +
    +  id_year=1
    +  type="revenue"
    +  date="01/01/2023"
    +  label="Don de Ada Lovelace"
    +  reference="DON-0001"
    +  payment_reference="Credit Mutuel 00042"
    +  amount="51,49"
    +  debit="756"
    +  credit="512A"
    +  linked_users=$users
    +}}
    +
    +L'écriture n°{{$result.id}} a été créée.
    +

    Mais cette fonction permet également d'appeler une API Paheko distante, dans ce cas les paramètres suivants sont nécessaires :

    + + + + + + + + + + + + + + + + + + + + + + + + + +
    ParamètreObligatoire ou optionnel ?Fonction
    urlobligatoireAdresse HTTP de l'instance Paheko distante.
    userobligatoireIdentifiant d'accès à l'API distante.
    passwordobligatoireMot de passe d'accès à l'API distante.
    +
    {{:api
    +  method="POST"
    +  path="sql"
    +  sql="SELECT * FROM users;"
    +  url="https://mon-asso.paheko.cloud/"
    +  user="zmgyfr1qnm"
    +  password="OAqFTLFzujJWr6lLn1Mu7w"
    +  assign="result"
    +  assign_code="code"
    +  fail=false
    +}}
    +
    +{{if $code == 200}}
    +  Il y a {{$result.count}} résultats.
    +{{else}}
    +  La requête a échoué : code {{$code}} — {{$result.error}}
    +{{/if}}
    +
    +

    Fonctions relatives aux Modules

    +

    save

    +

    Enregistre des données, sous la forme d'un document, dans la base de données, pour le module courant.

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    ParamètreObligatoire ou optionnel ?Fonction
    keyoptionnelClé unique du document
    idoptionnelNuméro unique du document
    validate_schemaoptionnelFichier de schéma JSON à utiliser pour valider les données avant enregistrement
    validate_onlyoptionnelListe des paramètres à valider (par exemple pour ne faire qu'une mise à jour partielle), séparés par des virgules.
    assign_new_idoptionnelSi renseigné, le nouveau numéro unique du document sera indiqué dans cette variable.
    optionnelAutres paramètres : traités comme des valeurs à enregistrer dans le document
    +

    Si ni key ni id ne sont indiqués, un nouveau document sera créé avec un nouveau numéro (ID) unique.

    +

    Si le document indiqué existe déjà, il sera mis à jour. Les valeurs nulles (NULL) seront effacées.

    +
    {{:save key="facture_43" nom="Atelier mobile" montant=250}}
    +

    Enregistrera dans la base de données le document suivant sous la clé facture_43 :

    +
    {"nom": "Atelier mobile", "montant": 250}
    +

    Exemple de mise à jour :

    +
    {{:save key="facture_43" montant=300}}
    +

    Exemple de récupération du nouvel ID :

    +
    {{:save titre="Coucou !" assign_new_id="id"}}
    +Le document n°{{$id}} a bien été enregistré.
    +

    Validation avec un schéma JSON

    +
    {{:save titre="Coucou" texte="Très long" validate_schema="./document.schema.json"}}
    +

    Pour ne valider qu'une partie du schéma, par exemple si on veut faire une mise à jour du document :

    +
    {{:save key="test" titre="Coucou" validate_schema="./document.schema.json" validate_only="titre"}}
    +

    delete

    +

    Supprime un document lié au module courant.

    + + + + + + + + + + + + + + + + + + + + +
    ParamètreObligatoire ou optionnel ?Fonction
    keyoptionnelClé unique du document
    idoptionnelNuméro unique du document
    +

    Il est possible de spécifier d'autres paramètres, ou une clause where et des paramètres dont le nom commence par deux points.

    +
      +
    • Supprimer le document avec la clé facture_43 : {{:delete key="facture_43"}}
    • +
    • Supprimer le document avec la clé ABCD et dont la propriété type du document correspond à la valeur facture : {{:delete key="ABCD" type="facture"}}
    • +
    • Supprimer tous les documents : {{:delete}}
    • +
    • Supprimer tous les documents ayant le type facture : {{:delete type="facture"}}
    • +
    • Supprimer tous les documents de type devis ayant une date dans le passé : {{:delete :type="devis" where="$$.type = :type AND $$.date < datetime()"}}
    • +
    +

    read

    +

    Lire un fichier stocké dans les fichiers du code du module.

    + + + + + + + + + + + + + + + + + + + + +
    ParamètreObligatoire ou optionnel ?Fonction
    fileobligatoireChemin du fichier à lire
    assignoptionnelVariable dans laquelle placer le contenu du fichier.
    +

    Si le paramètre assign n'est pas utilisé, le contenu du fichier sera affiché directement.

    +

    Exemple pour lire un fichier JSON :

    +
    {{#read file="baremes.json" assign="baremes"}}
    +{{:assign baremes=$baremes|json_decode}}
    +Barème kilométrique pour une voiture de 3 CV : {{$baremes.voiture.3cv}}
    +

    Exemple pour lire un fichier CSV :

    +
    {{#read file="baremes.csv" assign="baremes"}}
    +{{:assign baremes=$baremes|trim|explode:"\n"}}
    +
    +{{#foreach from=$baremes item="line"}}
    +  {{:assign bareme=$line|str_getcsv}}
    +  Nom du barème : {{$bareme.0}}
    +  Calcul : {{$bareme.1}}
    +{{/foreach}}
    +

    admin_header

    +

    Affiche l'entête de l'administration de l'association.

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    ParamètreObligatoire ou optionnel ?Fonction
    titleoptionnelTitre de la page
    layoutoptionnelAspect de la page. Peut être public pour une page publique simple (sans le menu), ou raw pour une page vierge (sans aucun menu ni autre élément). Défaut : vide (affichage du menu)
    currentoptionnelIndique quel élément dans le menu de gauche doit être marqué comme sélectionné
    custom_cssoptionnelFichier CSS supplémentaire à appeler dans le <head>
    +
    {{:admin_header title="Gestion des dons" current="acc"}}
    +

    Liste des choix possibles pour current :

    +
      +
    • home : menu Accueil
    • +
    • users : menu Membres
    • +
    • users/new : sous-menu "Ajouter" de Membres
    • +
    • users/services : sous-menu "Activités et cotisations" de Membres
    • +
    • users/mailing : sous-menu "Message collectif" de Membres
    • +
    • acc : menu Comptabilité
    • +
    • acc/new : sous-menu "Saisie" de Comptabilité
    • +
    • acc/accounts : sous-menu "Comptes"
    • +
    • acc/simple : sous-menu "Suivi des écritures"
    • +
    • acc/years : sous-menu "Exercices et rapports"
    • +
    • docs : menu Documents
    • +
    • web : menu Site web
    • +
    • config : menu Configuration
    • +
    • me : menu "Mes infos personnelles"
    • +
    • me/services : sous-menu "Mes activités et cotisations"
    • +
    +

    Exemple d'utilisation de custom_css depuis un module :

    +
    {{:admin_header title="Mon module" custom_css="./style.css"}}
    + +

    Affiche le pied de page de l'administration de l'association.

    +
    {{:admin_footer}}
    +

    delete_form

    +

    Affiche un formulaire demandant la confirmation de suppression d'un élément.

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    ParamètreObligatoire ou optionnel ?Fonction
    legendobligatoireLibellé de l'élément <legend> du formulaire
    warningobligatoireLibellé de la question de suppression (en gros en rouge)
    alertoptionnelMessage d'alerte supplémentaire (bloc jaune)
    infooptionnelInformations liées à la suppression (expliquant ce qui va être impacté par la suppression)
    confirmoptionnelLibellé de la case à cocher pour la suppression, si ce paramètre est absent ou NULL, la case à cocher ne sera pas affichée.
    +

    Le formulaire envoie un POST avec le bouton ayant le nom delete. Si le paramètre confirm est renseigné, alors la case à cochée aura le nom confirm_delete.

    +

    Exemple :

    +
    {{#load id=$_GET.id assign="invoice"}}
    +{{else}}
    +  {{:error message="Facture introuvable"}}
    +{{/load}}
    +
    +{{#form on="delete"}}
    +  {{if !$_POST.confirm_delete}}
    +    {{:error message="Merci de cocher la case"}}
    +  {{/if}}
    +  {{:delete id=$invoice.id}}
    +{{/form}}
    +
    +{{:form_errors}}
    +
    +{{:delete_form
    +  legend="Suppression d'une facture"
    +  warning="Supprimer la facture n°%d ?"|args:$invoice.id
    +  info="Le devis lié sera également supprimé"
    +  alert="La facture sera définitivement perdue !"
    +  confirm="Cocher cette case pour confirmer la suppression de la facture"
    +}}
    +

    input

    +

    Crée un champ de formulaire HTML. Cette fonction est une extension à la balise <input> en HTML, mais permet plus de choses.

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    ParamètreObligatoire ou optionnel ?Fonction
    nameobligatoireNom du champ
    typeobligatoireType de champ
    requiredoptionnelMettre à true si le champ est obligatoire
    labeloptionnelLibellé du champ
    helpoptionnelTexte d'aide, affiché sous le champ
    defaultoptionnelValeur du champ par défaut, si le formulaire n'a pas été envoyé, et que la valeur dans source est vide
    sourceoptionnelSource de pré-remplissage du champ. Si le nom du champ est montant, alors la valeur de [source].montant sera affichée si présente.
    +

    Si label ou help sont spécifiés, le champ sera intégré à une balise HTML <dd>, et le libellé sera intégré à une balise <dt>. Dans ce cas il faut donc que le champ soit dans une liste <dl>. Si ces deux paramètres ne sont pas spécifiés, le champ sera le seul tag HTML.

    +
    <dl>
    +    {{:input name="amount" type="money" label="Montant" required=true}}
    +</dl>
    +

    Note : le champ aura comme id la valeur f_[name]. Ainsi un champ avec amount comme name aura id="f_amount".

    +

    Valeur du champ

    +

    La valeur du champ est remplie avec :

    +
      +
    • la valeur dans $_POST qui correspond au name ;
    • +
    • sinon la valeur dans source (tableau) avec le même nom (exemple : $source[name]) ;
    • +
    • sinon la valeur de default est utilisée.
    • +
    +

    Note : le paramètre value n'est pas supporté sauf pour checkbox et radio.

    +

    Types de champs supportés

    +
      +
    • les types classiques de input en HTML : text, search, email, url, file, date, checkbox, radio, password, etc.
        +
      • Note : pour checkbox et radio, il faut utiliser le paramètre value en plus pour spécifier la valeur.
      • +
      +
    • +
    • textarea
    • +
    • money créera un champ qui attend une valeur de monnaie au format décimal
    • +
    • datetime créera un champ date et un champ texte pour entrer l'heure au format HH:MM
    • +
    • radio-btn créera un champ de type radio mais sous la forme d'un gros bouton
    • +
    • select crée un sélecteur de type <select>. Dans ce cas il convient d'indiquer un tableau associatif dans le paramètre options.
    • +
    • select_groups crée un sélecteur de type <select>, mais avec des <optgroup>. Dans ce cas il convient d'indiquer un tableau associatif à deux niveaux dans le paramètre options.
    • +
    • list crée un champ permettant de sélectionner un ou des éléments (selon si le paramètre multiple est true ou false) dans un formulaire externe. Le paramètre can_delete indique si l'utilisateur peut supprimer l'élément déjà sélectionné (si multiple=false). La sélection se fait à partir d'un formulaire dont l'URL doit être spécifiée dans le paramètre target. Les formulaires actuellement supportés sont :
        +
      • !acc/charts/accounts/selector.php?targets=X pour sélectionner un compte du plan comptable, où X est une liste de types de comptes qu'il faut permettre de choisir (séparés par des :)
      • +
      • !users/selector.php pour sélectionner un membre
      • +
      +
    • +
    +

    button

    +

    Affiche un bouton, similaire à <button> en HTML, mais permet d'ajouter une icône par exemple.

    +
    {{:button type="submit" name="save" label="Créer ce membre" shape="plus" class="main"}}
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    ParamètreObligatoire ou optionnel ?Fonction
    typeoptionnelType du bouton
    nameoptionnelNom du bouton
    labeloptionnelLabel du bouton
    shapeoptionnelAffiche une icône en préfixe du label
    classoptionnelClasse CSS
    titleoptionnelAttribut HTML title
    disabledoptionnelDésactive le bouton si true
    + +

    Affiche un lien.

    +
    {{:link href="!users/new.php" label="Créer un nouveau membre"}}
    + + + + + + + + + + + + + + + + + + + + + + + + + +
    ParamètreObligatoire ou optionnel ?Fonction
    hrefobligatoireAdresse du lien
    labelobligatoireLibellé du lien
    targetoptionnelCible du lien, utiliser _dialog pour que le lien s'ouvre dans une fenêtre modale.
    +

    Préfixer l'adresse par "!" donnera une URL absolue en préfixant l'adresse par l'URL de l'administration.
    +Sans "!", l'adresse générée sera relative au contexte d'appel (module/plugin ou squelette site web).

    +

    linkbutton

    +

    Affiche un lien sous forme de faux bouton, avec une icône si le paramètre shape est spécifié.

    +
    {{:linkbutton href="!users/new.php" label="Créer un nouveau membre" shape="plus"}}
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    ParamètreObligatoire ou optionnel ?Fonction
    href*obligatoireAdresse du lien
    labelobligatoireLibellé du bouton
    targetoptionnelCible de l'ouverture du lien
    shapeoptionnelAffiche une icône en préfixe du label
    +

    Si on utilise target="_dialog" alors le lien s'ouvrira dans une fenêtre modale (iframe) par dessus la page actuelle.

    +

    Si on utilise target="_blank" alors le lien s'ouvrira dans un nouvel onglet.

    +

    icon

    +

    Affiche une icône.

    +
    {{:icon shape="print"}}
    + + + + + + + + + + + + + + + +
    ParamètreObligatoire ou optionnel ?Fonction
    shapeobligatoireForme de l'icône.
    +

    Formes d'icônes disponibles

    +

    +

    user_field

    +

    Affiche un champ de la fiche membre.

    + + + + + + + + + + + + + + + + + + + + +
    ParamètreObligatoire ou optionnel ?Fonction
    nameobligatoireNom du champ.
    valueobligatoireValeur du champ.
    +

    edit_user_field

    +

    Afficher un champ de formulaire pour modifier un champ de la fiche membre.

    + + + + + + + + + + + + + + + + + + + + +
    ParamètreObligatoire ou optionnel ?Fonction
    nameobligatoireNom du champ.
    sourceoptionnelSource de pré-remplissage du champ. Si le nom du champ est montant, alors la valeur de [source].montant sera utilisée comme valeur du champ.
    +

    Gestion de fichiers dans les modules

    +

    Les modules peuvent stocker des fichiers, mais seulement dans leur propre contexte. Un module ne peut pas gérer les fichiers du site web, des écritures comptables, des membres, ou des autres modules, il ne peut gérer que ses propres fichiers.

    +

    Quand les données d'un module sont supprimé, les fichiers du module sont aussi supprimés.

    +

    Mais si le module stocke des fichiers liés à un document JSON (par exemple dans un sous-répertoire pour chaque module), c'est au code du module de s'assurer que les fichiers seront supprimés lors de la suppression du document.

    +

    Par défaut, tous les fichiers des modules sont en accès restreint : ils ne peuvent être vus et modifiés que par les membres connectés qui sont au niveau d'accès indiqué dans les paramètres restrict_section et restrict_level du fichier module.ini.

    +

    Pour qu'un fichier soit visible publiquement aux personnes non connectées, il faut le placer dans le sous-répertoire public du module.

    +

    Attention : de par ce fonctionnement, tous les fichiers d'un module sont potentiellement accessibles par tous les membres ayant accès au module et connaissant le nom du fichier.

    +

    Il est donc recommandé de ne pas utiliser ce mécanisme pour stocker des données personnelles ou des données sensibles.

    +

    admin_files

    +

    Affiche (dans le contexte de l'administration) la liste des fichiers dans un sous-répertoire, et éventuellement la possibilité d'en ajouter ou de les supprimer.

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    ParamètreObligatoire ou optionnel ?Fonction
    pathoptionnelChemin du sous-répertoire où sont stockés les fichiers
    uploadoptionnelBooléen. Si true, l'utilisateur pourra ajouter des fichiers. (Défaut : false)
    editoptionnelBooléen. Si true, l'utilisateur pourra modifier ou supprimer les fichiers existants. (Défaut : false)
    use_trashoptionnelBooléen. Si false, le fichier sera supprimé, sans passer par la corbeille. Défaut : true
    +

    Exemple pour afficher la liste des fichiers du sous-répertoire facture43 et permettre de rajouter de nouveaux fichiers :

    +
    {{:admin_files path="facture43" upload=true edit=false}}
    +

    delete_file

    +

    Supprimer un fichier ou un répertoire lié au module courant.

    + + + + + + + + + + + + + + + +
    ParamètreObligatoire ou optionnel ?Fonction
    pathobligatoireChemin du fichier ou répertoire
    +

    Exemple pour supprimer un fichier seul :

    +
    {{:delete_file path="facture43/justificatif.pdf"}}
    +

    Pour supprimer un répertoire et tous les fichiers dedans :

    +
    {{:delete_file path="facture43"}}
    \ No newline at end of file diff --git a/src/www/admin/static/doc/brindille_modifiers.html b/src/www/admin/static/doc/brindille_modifiers.html new file mode 100644 index 0000000..a8c0e9d --- /dev/null +++ b/src/www/admin/static/doc/brindille_modifiers.html @@ -0,0 +1,692 @@ + + + + Référence des filtres Brindille + + + + +

    Filtres PHP

    +

    Ces filtres viennent directement de PHP et utilisent donc les mêmes paramètres. Voir la documentation PHP pour plus de détails.

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    NomDescriptionDocumentation PHP
    htmlentitiesConvertit tous les caractères éligibles en entités HTMLDocumentation PHP
    htmlspecialcharsConvertit les caractères spéciaux en entités HTMLDocumentation PHP
    trimSupprime les espaces et lignes vides au début et à la fin d'un texteDocumentation PHP
    ltrimSupprime les espaces et lignes vides au début d'un texteDocumentation PHP
    rtrimSupprime les espaces et lignes vides à la fin d'un texteDocumentation PHP
    md5Génère un hash MD5 d'un texteDocumentation PHP
    sha1Génère un hash SHA1 d'un texteDocumentation PHP
    strlenNombre de caractères dans une chaîne de texteDocumentation PHP
    strposPosition d'un élément dans une chaîne de texteDocumentation PHP
    strrposPosition d'un dernier élément dans une chaîne de texteDocumentation PHP
    substrDécoupe une chaîne de caractèreDocumentation PHP
    strtotimeTransforme une date en timestamp UNIXDocumentation PHP
    strip_tagsSupprime les tags HTMLDocumentation PHP
    nl2brRemplace les retours à la ligne par des tags HTML <br/>Documentation PHP
    wordwrapAjoute des retours à la ligne tous les 75 caractèresDocumentation PHP
    absRenvoie la valeur absolue d'un nombre (exemple : -42 sera transformé en 42)Documentation PHP
    gettypeRenvoie le type d'une variable
    intvalTransforme une valeur en entier (integer)Documentation PHP
    boolvalTransforme une valeur en booléen (true ou false)Documentation PHP
    floatvalTransforme une valeur en nombre flottant (à virgule)Documentation PHP
    strvalTransforme une valeur en chaîne de texteDocumentation PHP
    arrayvalTransforme une valeur en tableauDocumentation PHP
    json_decodeTransforme une chaîne JSON en valeurDocumentation PHP
    json_encodeTransforme une valeur en chaîne JSONDocumentation PHP
    http_build_queryTransformer un tableau en chaîne query string pour URLDocumentation PHP
    str_getcsvTransformer une chaîne de texte de format CSV en tableauDocumentation PHP
    +

    Filtres utiles pour les e-mails

    +

    check_email

    +

    Permet de vérifier la validité d'une adresse email. Cette fonction vérifie la syntaxe de l'adresse mais aussi que le nom de domaine indiqué possède bien un enregistrement de type MX.

    +

    Renvoie true si l'adresse est valide.

    +
    {{if !$_POST.email|check_email}}
    +<p class="alert">L'adresse e-mail indiquée est invalide.</p>
    +{{/if}}
    +

    protect_contact

    +

    Crée un lien protégé pour une adresse email, pour éviter que l'adresse ne soit recueillie par les robots spammeurs (empêche également le copier-coller et le lien ne fonctionnera pas avec javascript désactivé).

    +

    Filtres de tableaux

    +

    has

    +

    Renvoie vrai si le tableau contient l'élément passé en paramètre.

    +
    {{:assign var="table" a="bleu" b="orange"}}
    +{{if $table|has:"bleu"}}
    +    Oui, il y a du bleu
    +{{/if}}
    +

    in

    +

    Renvoie vrai si l'élément fait partie du tableau passé en paramètre.

    +

    C'est exactement la même chose que has, mais exprimé à l'envers.

    +
    {{:assign var="table" a="bleu" b="orange"}}
    +{{if "bleu"|in:$table}}
    +    Oui, il y a du bleu
    +{{/if}}
    +

    has_key

    +

    Renvoie vrai si le tableau contient la clé passée en paramètre.

    +
    {{:assign var="table" a="bleu" b="orange"}}
    +{{if $table|has_key:"b"}}
    +    Oui, il y a la clé "b"
    +{{/if}}
    +

    key_in

    +

    Renvoie vrai si la clé fait partie du tableau passé en paramètre.

    +

    C'est exactement la même chose que has_key, mais exprimé à l'envers.

    +
    {{:assign var="table" a="bleu" b="orange"}}
    +{{if "b"|key_in:$table}}
    +    Oui, il y a la clé "b"
    +{{/if}}
    +

    keys

    +

    Renvoie les clés du tableau, sous forme de tableau.

    +
    {{:assign var="table" a="bleu" b="orange"}}
    +{{:assign var="cles" value=$table|keys}}
    +{{$cles|implode:","}}
    +

    Donnera :

    +
    a,b
    +

    values

    +

    Renvoie les valeurs du tableau, sous forme de tableau.

    +

    Cela revient en fait à supprimer les clés associatives.

    +
    {{:assign var="table" a="bleu" b="orange"}}
    +{{#foreach from=$table key="cle" item="valeur"}}
    +    {{$cle}} = {{$valeur}}
    +{{/foreach}}
    +--
    +{{:assign var="valeurs" value=$table|values}}
    +{{#foreach from=$valeurs key="cle" item="valeur"}}
    +    {{$cle}} = {{$valeur}}
    +{{/foreach}}
    +

    Donnera :

    +
    a = bleu
    +b = orange
    +--
    +0 = bleu
    +1 = orange
    +

    count

    +

    Compte le nombre d'entrées dans un tableau.

    +
    {{$products|count}}
    += 5
    +

    explode

    +

    Sépare une chaîne de texte en tableau à partir d'une chaîne de séparation.

    +
    {{:assign var="table" value="a,b,c"|explode:","}}
    +- {{$table.0}}
    +- {{$table.1}}
    +- {{$table.2}}
    +

    Affichera :

    +
    - a
    +- b
    +- c
    +

    implode

    +

    Réunit un tableau sous forme de chaîne de texte en utilisant éventuellement une chaîne de liaison entre chaque élément du tableau.

    +
    {{:assign var="table" a="bleu" b="orange"}}
    +{{$table|implode}}
    +{{$table|implode:" - "}}
    +

    Affichera :

    +
    bleuorange
    +bleu - orange
    +

    map

    +

    Applique un filtre sur chaque élément du tableau.

    +

    Le premier paramètre doit être le nom du filtre. Les autres paramètres seront passés au filtre.

    +
    {{:assign var="table" a="01" b="02"}}
    +{{:assign var="table" value=$table|map:intval}}
    +- {{$table.a}}
    +- {{$table.b}}
    +

    Affichera :

    +
    - 1
    +- 2
    +

    ksort, sort

    +

    Trie un tableau par ordre alpha-numérique, sans tenir compte des majuscules/minuscules. ksort trie le tableau en utilisant les clés, et sort trie le tableau en utilisant les valeurs.

    +
    {{:assign var="table" b="3" a="2" c="1"}}
    +{{$table|sort|implode:","}}
    +{{$table|ksort|implode:","}}
    +

    Affichera :

    +
    1,2,3
    +2,3,1
    +

    max, min

    +

    Renvoie respectivement la valeur la plus haute ou la plus basse d'un tableau de valeurs numériques.

    +
    {{:assign var="table" b="3" a="2" c="1"}}
    +{{$table|max}}
    +{{$table|min}}
    +

    Affichera :

    +
    3
    +1
    +

    Filtres de texte

    +

    args

    +

    Remplace des arguments dans le texte selon le schéma utilisé par sprintf.

    +
    {{"Il y a %d résultats dans la recherche sur le terme '%s'."|args:$results_count:$query}}
    += Il y a 5 résultat dans la recherche sur le terme 'test'.
    +

    cat

    +

    Concaténer un texte avec un autre.

    +
    {{"Tangerine"|cat:" Dream"}}
    += Tangerine Dream
    +

    count_words

    +

    Compte le nombre de mots dans un texte.

    +

    escape

    +

    Échappe le contenu pour un usage dans un document HTML. Ce filtre est appliqué par défaut à tout ce qui est affiché (variables, etc.) sauf à utiliser le filtre raw (voir plus bas).

    +

    excerpt

    +

    Produit un extrait d'un texte.

    +

    Supprime les tags HTML, tronque au nombre de caractères indiqué en second argument (si rien n'est indiqué, alors 600 est utilisé), et englobe dans un paragraphe <p>...</p>.

    +

    Équivalent de :

    +
    <p>{{$html|strip_tags|truncate:600|nl2br}}</p>
    +

    extract_leading_number

    +

    Extrait le numéro au début d'une chaîne de texte.

    +

    Exemple :

    +
    {{:assign title="02. Cours sur la physique nucléaire"}}
    +{{$title|extract_leading_number}}
    +

    Affichera :

    +
    02
    +

    format_phone_number

    +

    Formatte un numéro de téléphone selon le format du pays de l'association.

    +

    Seule la France est supportée pour le moment.

    +

    Exemple :

    +
    {{:assign number="0102030405"}}
    +{{$number|format_phone_number}}
    +

    Affichera :

    +
    01 02 03 04 05
    +

    markdown

    +

    Transforme un texte en HTML en utilisant la syntaxe Markdown.

    +

    Il est conseillé de rajouter le filtre |raw pour ne pas échapper le HTML produit, si on veut afficher le texte formatté dans une page HTML.

    +
    {{$texte|markdown|raw}}
    +

    raw

    +

    Passer ce filtre désactive la protection automatique contre le HTML (échappement) dans le texte. À utiliser en connaissance de cause avec les contenus qui contiennent du HTML et sont déjà filtrés !

    +
    {{"<b>Test"}} = &lt;b&gt;Test
    +{{"<b>Test"|raw}} = <b>Test
    +

    replace

    +

    Remplace des parties du texte par une autre partie.

    +
    {{"Tata yoyo"|replace:"yoyo":"yaya"}}
    += Tata yaya
    +

    regexp_replace

    +

    Remplace des valeurs en utilisant une expression rationnelles (regexp) (documentation PHP).

    +
    {{"Tartagueule"|regexp_replace:"/ta/i":"tou"}}
    += tourtougueule
    +

    remove_leading_number

    +

    Supprime le numéro au début d'un titre.

    +

    Cela permet de définir un ordre spécifique aux pages et catégories dans les listes.

    +
    {{"03. Beau titre"|remove_leading_number}}
    +Beau titre
    +

    truncate

    +

    Tronque un texte à une longueur définie.

    + + + + + + + + + + + + + + + + + + + + + + + + + +
    ArgumentFonctionValeur par défaut (si omis)
    1longueur en nombre de caractères80
    2texte à placer à la fin (si tronqué)
    3coupure stricte, si true alors un mot pourra être coupé en deux, si false le texte sera coupé au dernier mot completfalse
    +
    {{:assign texte="Ceci n'est pas un texte."}}
    +{{$texte|truncate:19:"(...)":true}}
    +{{$texte|truncate:19:"":false}}
    +

    Affichera :

    +
    Ceci n'est pas un (...)
    +Ceci n'est pas un t
    +

    typo

    +

    Formatte un texte selon les règles typographiques françaises : ajoute des espaces insécables devant ou derrière les ponctuations françaises (« » ? ! :).

    +

    urlencode

    +

    Encode une chaîne de texte pour utilisation dans une adresse URL (alias de rawurlencode en PHP).

    +

    xml_escape

    +

    Échappe le contenu pour un usage dans un document XML.

    +

    Autres filtres de texte

    +

    Les filtres suivants modifient la casse (majuscule/minuscules) d'un texte et ne fonctionneront correctement que si l'extension mbstring est installée sur le serveur. Sinon les lettres accentuées ne seront pas modifiées.

    +

    Note : il est donc préférable d'utiliser la propriété CSS text-transform pour modifier la casse si l'usage n'est que pour l'affichage, et non pas pour enregistrer les données.

    +
      +
    • tolower : transforme un texte en minuscules
    • +
    • toupper : transforme un texte en majuscules
    • +
    • ucfirst : met la première lettre du texte en majuscule
    • +
    • ucwords : met la première lettre de chaque mot en majuscule
    • +
    • lcfirst : met la première lettre du texte en minuscule
    • +
    +

    Filtres sur les sommes en devises

    +

    money

    +

    Formatte une valeur de monnaie pour l'affichage.

    +

    Une valeur de monnaie doit toujours inclure les cents (exprimée sous forme d'entier). Ainsi 15,02 doit être exprimée sous la forme 1502.

    +

    Paramètres optionnels :

    +
      +
    1. true (défaut) pour ne rien afficher si la valeur est zéro, ou false pour afficher 0,00
    2. +
    3. true pour afficher le signe + si le nombre est positif (- est toujours affiché si le nombre est négatif)
    4. +
    +
    {{* 12 345,67 = 1234567 *}}
    +{{:assign amount=1234567}}
    +{{$amount|money}}
    +12 345,67
    +

    money_currency

    +

    Comme money (même paramètres), formatte une valeur de monnaie (entier) pour affichage, mais en ajoutant la devise.

    +
    {{:assign amount=1502}}
    +{{$amount|money_currency}}
    +15,02 €
    +

    money_html

    +

    Idem que money, mais pour l'affichage en HTML :

    +
    {{* 12 345,67 = 1234567 *}}
    +{{:assign amount=1234567}}
    +{{$amount|money_html}}
    +<span class="money">12&nbsp;345,67</span>
    +

    money_currency_html

    +

    Idem que money_currency, mais pour l'affichage en HTML :

    +
    {{:assign amount=1502}}
    +{{$amount|money_currency_html}}
    +<span class="money">15,02&nbsp;€</span>
    +

    money_raw

    +

    Formatte une valeur de monnaie (entier) de manière brute : les milliers n'auront pas de séparateur.

    +
    {{:assign amount=1234567}}
    +{{$amount|money_raw}}
    +12345,67
    +

    money_int

    +

    Transforme un nombre à partir d'une chaîne de caractère (par exemple 12345,67) en entier (1234567) pour stocker une valeur de monnaie.

    +
    {{:assign montant=$_POST.montant|trim|money_int}}
    +

    Filtres SQL

    +

    quote_sql

    +

    Protège une chaîne contre les attaques SQL, pour l'utilisation dans une condition.

    +

    Note : il est FORTEMENT déconseillé d'intégrer directement des sources extérieures dans les requêtes SQL, il est préférable d'utiliser les paramètres dans la boucle sql et ses dérivées, comme ceci : {{#sql select="id, nom" tables="users" where="lettre_infos = :lettre" :lettre=$_GET.lettre}}.

    +

    Exemple :

    +
    {{:assign nom=$_GET.nom|quote_sql}}
    +{{#sql select="id, nom" tables="users" where="nom = %s"|args:$nom}}
    +

    quote_sql_identifier

    +

    La même chose que quote_sql, mais pour les identifiants (par exemple nom de table ou de colonne).

    +

    Exemple :

    +
    {{:assign colonne=$_GET.colonne|quote_sql_identifier}}
    +{{#sql select="id, %s"|args:$colonne tables="users"}}
    +

    Il est possible d'utiliser un préfixe en argument, utile par exemple quand on a plusieurs tables avec le même nom de colonne :

    +
    {{:assign colonne=$_GET.colonne|quote_sql_identifier:"u1"}}
    +{{#sql select="u1.id, %s"|args:$colonne tables="users AS u1 INNER JOIN users AS u2 ON u2.id_parent = u1.id"}}
    +

    sql_where

    +

    Permet de créer une partie d'une clause SQL WHERE complexe.

    +

    Le premier paramètre est le nom de la colonne (sans préfixe).

    +

    Paramètres :

    +
      +
    1. Comparateur : =, !=, IN, NOT IN, >, >=, <, <=
    2. +
    3. Valeur à comparer (peut être un tableau)
    4. +
    +

    Exemple pour afficher la liste des membres des catégories n°1 et n°2:

    +
    {{:assign var="list." value=1}}
    +{{:assign var="list." value=2}}
    +{{#sql select="nom" tables="users" where="id_category"|sql_where:'IN':$id_list}}
    +    {{$nom}}
    +{{/sql}}
    +

    Le requête SQL générée sera alors SELECT nom FROM users WHERE id_category IN (1, 2).

    +

    sql_user_fields

    +

    Permet de récupérer le contenu de champs de la fiche utilisateur pour une requête SQL.

    +

    C'est particulièrement utile si le module permet de sélectionner dans sa configuration une liste de champs de membre (par exemple pour la carte de membre, ou les reçus fiscaux).

    +

    Si un champ mentionné n'existe plus dans les fiches de membres, il sera ignoré.

    +
      +
    • Le premier paramètre est la liste des champs (tableau ou chaîne de texte)
    • +
    • Le second est le préfixe à utiliser (alias de la table membres), optionnel
    • +
    • Le troisième est la chaîne de texte à utiliser pour coller les champs entre eux
    • +
    +

    Exemple :

    +
    {{:assign var="champs_adresse." value="rue"}}
    +{{:assign var="champs_adresse." value="ville"}}
    +{{:assign var="champs_adresse_sql" value=$champs_adresse|sql_user_fields:"u":" - "}}
    +{{#select !champs_adresse_sql AS adresse FROM users AS u; !champs_adresse_sql=$champs_adresse_sql}}
    +    {{$adresse}}
    +{{/select}}
    +

    Affichera :

    +
    30 rue de Machin Chose — Dijon
    +

    Et la clause SQL générée sera :

    +
    LTRIM(COALESCE(' - ' || u.rue, '') || COALESCE(' - ' || u.ville), ' - ')
    +

    Filtres de date

    +

    date

    +

    Formatte une date selon le format spécifié en premier paramètre.

    +

    Le format est identique au format utilisé par PHP.

    +

    Si aucun format n'est indiqué, le défaut sera d/m/Y à H:i. (en français)

    +

    Exemples :

    +
    {{:assign this_year=$now|date:'Y'}}
    +{{$date|date:'d/m/Y'}}
    +

    strftime

    +

    Formatte une date selon un format spécifié en premier paramètre.

    +

    Le format à utiliser est identique au format utilisé par la fonction strftime de PHP.

    +

    Un format doit obligatoirement être spécifié.

    +

    En passant un code de langue en second paramètre, cette langue sera utilisée. Sont supportés le français (fr) et l'anglais (en). Le défaut est le français si aucune valeur n'est passée en second paramètre .

    +
    {{:assign this_year=$now|date:'%Y'}}
    +{{$date|date:'%d/%m/%Y'}}
    +

    relative_date

    +

    Renvoie une date relative à la date du jour : aujourd'hui, hier, demain, ou sinon mardi 2 janvier (si la date est de l'année en cours) ou 2 janvier 2021 (si la date est d'une autre année).

    +

    En spécifiant true en premier paramètre, l'heure sera ajoutée au format 14h34.

    +

    date_short

    +

    Formatte une date au format court : d/m/Y.

    +

    En spécifiant true en premier paramètre l'heure sera ajoutée : à H\hi.

    +

    date_long

    +

    Formatte une date au format long : lundi 2 janvier 2021.

    +

    En spécifiant true en premier paramètre l'heure sera ajoutée : à 20h42.

    +

    date_hour

    +

    Formatte une date en renvoyant l'heure uniquement : 20h00.

    +

    En passant true en premier paramètre, les minutes seront omises si elles sont égales à zéro : 20h.

    +

    atom_date

    +

    Formatte une date au format ATOM : Y-m-d\TH:i:sP

    +

    parse_date

    +

    Vérifie le format d'une chaîne de texte représentant la date et la transforme en chaîne de date standardisée au format AAAA-MM-JJ.

    +

    Les formats acceptés sont :

    +
      +
    • AAAA-MM-JJ
    • +
    • JJ/MM/AAAA
    • +
    • JJ/MM/AA
    • +
    +

    parse_datetime

    +

    Vérifie le format d'une chaîne de texte représentant la date et l'heure et la transforme en chaîne de date et heure standardisée au format AAAA-MM-JJ HH:mm.

    +

    Les formats acceptés sont :

    +
      +
    • AAAA-MM-JJ HH:mm:ss
    • +
    • AAAA-MM-JJ HH:mm
    • +
    • JJ/MM/AAAA HH:mm
    • +
    +

    parse_time

    +

    Vérifie le format d'une chaîne de texte représentant l'heure et la transforme en chaîne de date standardisée au format HH:MM.

    +

    Les formats acceptés sont :

    +
      +
    • HH:MM
    • +
    • H:M
    • +
    • H:MM
    • +
    • HH:M
    • +
    +

    Le séparateur peut être : ou h.

    +

    Filtres de condition

    +

    Ces filtres sont à utiliser dans les conditions

    +

    match

    +

    Renvoie true si le texte indiqué en premier paramètre est trouvé dans la variable.

    +

    Ce filtre est insensible à la casse.

    +
    {{if $page.path|match:"/aide"}}Bienvenue dans l'aide !{{/if}}
    +

    regexp_match

    +

    Renvoie true si l'expression régulière indiquée en premier paramètre est trouvée dans la variable.

    +

    Exemple pour voir si le texte contient les mots "Bonjour" ou "Au revoir" (insensible à la casse) :

    +
    {{if $texte|regexp_match:"/Bonjour|Au revoir/i"}}
    +    Trouvé !
    +{{else}}
    +    Rien trouvé :-(
    +{{/if}}
    +

    Autres filtres

    +

    math

    +

    Réalise un calcul mathématique. Cette fonction accepte :

    +
      +
    • les nombres: 42, 13,37, 14.05
    • +
    • les signes : + - / * pour additionner, diminuer, diviser ou multiplier
    • +
    • les parenthèses : ( )
    • +
    • les fonctions : round(0.5452, 2) ceil(29,09) floor(0.99) mais aussi : min, max, cos, sin, tan, asin, acos, atan, sinh, cosh, tanh, exp, sqrt, abs, log, log10, et pi.
    • +
    +

    Le résultat est renvoyé sous la forme d'un entier, ou d'un nombre flottant dont les décimales sont séparées par un point.

    +
    {{"1+1"|math}}
    += 2
    +

    Il est possible de donner d'autres arguments, de la même manière qu'avec args pour y inclure des données provenant de variables :

    +
    {{:assign age=42}}
    +{{"1+%d"|math:$age}}
    += 43
    +{{:assign prix=39.99 tva=19.1}}
    +{{"round(%f*%f, 2)"|math:$prix:$tva}}
    += 47.63
    +

    or

    +

    Si la variable passée est évalue comme false (c'est à dire que sa valeur est un texte vide, ou un nombre qui vaut zéro, ou la valeur false), alors le premier paramètre sera utilisé.

    +
    {{:assign texte=""}}
    +{{$texte|or:"Le texte est vide"}}
    +

    Il est possible de chaîner les appels à or :

    +
    {{:assign texte1="" texte2="0"}}
    +{{$texte1|or:$texte2|or:"Aucun texte"}}
    +

    size_in_bytes

    +

    Renvoie une taille en octets, Ko, Mo, ou Go à partir d'une taille en octets.

    +
    {{100|size_in_bytes}} = 100 o
    +{{1500|size_in_bytes}} = 1,50 Ko
    +{{1048576|size_in_bytes}} = 1 Mo
    +

    spell_out_number

    +

    Épelle un nombre en toutes lettres.

    +

    Le premier paramètre peut être utilisé pour spécifier le code de la langue à utiliser (par défaut c'est le français, donc le code fr).

    +
    {{42|spell_out_number}}
    +

    Donnera :

    +
    quarante deux
    +

    uuid

    +

    Renvoie un identifiant unique au format UUIDv4.

    \ No newline at end of file diff --git a/src/www/admin/static/doc/brindille_sections.html b/src/www/admin/static/doc/brindille_sections.html new file mode 100644 index 0000000..37795a4 --- /dev/null +++ b/src/www/admin/static/doc/brindille_sections.html @@ -0,0 +1,1017 @@ + + + + Référence des sections Brindille + + + + +

    Sections généralistes

    +

    foreach

    +

    Permet d'itérer sur un tableau par exemple. Ainsi chaque élément du tableau exécutera une fois le contenu de la section.

    + + + + + + + + + + + + + + + + + + + + + + + + + +
    ParamètreOptionnel / obligatoire ?Fonction
    fromobligatoireVariable sur laquelle effectuer l'itération
    keyoptionnelNom de la variable à utiliser pour la clé de l'élément
    itemoptionnelNom de la variable à utiliser pour la valeur de l'élément
    +

    Considérons ce tableau :

    +
    {{:assign var="tableau" a="bleu" b="orange"}}
    +

    On peut alors itérer pour récupérer les clés (a et b ainsi que les valeurs bleu et orange) :

    +
    {{#foreach from=$tableau key="key" item="value"}}
    +{{$key}} = {{$value}}
    +{{/foreach}}
    +

    Cela affichera :

    +
    a = bleu
    +b = orange
    +

    Si on a un tableau à plusieurs niveaux, les éléments du tableau sont automatiquement transformés en variable :

    +
    {{:assign var="tableau.a" couleur="bleu"}}
    +{{:assign var="tableau.b" couleur="orange"}}
    +
    {{#foreach from=$variable}}
    +{{$couleur}}
    +{{/foreach}}
    +

    Affichera :

    +
    bleu
    +orange
    +

    Itérer sans tableau

    +

    Il est aussi possible de faire X itérations, arbitrairement, sans avoir de tableau en entrée, en utilisant le paramètre count.

    +

    C'est l'équivalent des boucles for dans les autres langages de programmation.

    +

    Exemple :

    +
    {{#foreach count=3 key="i"}}
    +- {{$i}}
    +{{/foreach}}
    +

    Affichera :

    +
    - 0
    +- 1
    +- 2
    +

    restrict

    +

    Permet de limiter (restreindre) une partie de la page aux membres qui sont connectés et/ou qui ont certains droits.

    +

    Deux paramètres optionnels peuvent être utilisés ensemble (il n'est pas possible d'utiliser seulement un des deux) :

    + + + + + + + + + + + + + + + + + + + + + + + + + +
    ParamètreOptionnel / obligatoire ?Fonction
    leveloptionnelNiveau d'accès : read, write, admin
    sectionoptionnelSection où le niveau d'accès doit s'appliquer : users, accounting, web, documents, config
    blockoptionnelSi ce paramètre est présent et vaut true, alors l'accès sera interdit si les conditions d'accès demandées ne sont pas remplies : une page d'erreur sera renvoyée.
    +

    Exemple pour voir si un membre est connecté :

    +
    {{#restrict}}
    +    Un membre est connecté, mais on ne sait pas avec quels droits.
    +{{else}}
    +    Aucun membre n'est connecté.
    +{{/restrict}}
    +

    Exemple pour voir si un membre qui peut administrer les membres est connecté :

    +
    {{#restrict section="users" level="admin"}}
    +    Un membre est connecté, et il a le droit d'administrer les membres.
    +{{else}}
    +    Aucun membre n'est connecté, ou un membre est connecté mais n'est pas administrateur des membres.
    +{{/restrict}}
    +

    Pour bloquer l'accès aux membres non connectés, ou qui n'ont pas accès en écriture à la comptabilité.

    +
    {{#restrict block=true section="accounting" level="write"}}
    +{{/restrict}}
    +

    Le mieux est de mettre ce code au début d'un squelette.

    +

    Requêtes SQL

    +

    select

    +

    Exécute une requête SQL SELECT et effectue une itération pour chaque résultat de la requête.

    +

    Pour une utilisation plus simplifiée des requêtes, voir aussi la section sql.

    +

    Attention : la syntaxe de cette section est différente des autres sections Brindille. En effet après le début ({{#select) doit suivre la suite de la requête, et non pas les paramètres :

    +
    Liste des membres inscrits à la lettre d'informations :
    +{{#select nom, prenom FROM users WHERE lettre_infos = 1;}}
    +    - {{prenom}} {{$nom}}<br />
    +{{else}}
    +    Aucun membre n'est inscrit à la lettre d'information.
    +{{/select}}
    +

    Des paramètres nommés de SQL peuvent être présentés après le point-virgule marquant la fin de la requête SQL :

    +
    {{:assign prenom="Karim"}}
    +{{#select * FROM users WHERE prenom = :prenom;
    +    :prenom=$prenom}}
    +...
    +{{/select}}
    +

    Notez les deux points avant le nom du paramètre. Ces paramètres sont protégés contre les injections SQL (généralement appelés paramètres nommés).

    +

    Pour intégrer des paramètres qui ne sont pas protégés (attention !), il faut utiliser le point d'exclamation :

    +
    {{:assign var="categories." value=1}}
    +{{:assign var="categories." value=2}}
    +{{#select * FROM users WHERE !categories;
    +    !categories='id_category'|sql_where:'IN':$categories}}
    +

    Cela créera la requête suivante : SELECT * FROM users WHERE id_category IN (1, 2);

    +

    Il est aussi possible d'intégrer directement des variables dans la requête, en utilisant la syntaxe {$variable|filtre:argument1:argument2}, comme une variable classique donc, mais au lieu d'utiliser des doubles accolades, on utilise ici des accolades simples. Ces variables seront automatiquement protégées contre les injections SQL.

    +
    {{:assign prenom="Camille"}}
    +{{#select * FROM users WHERE initiale_prenom = {$prenom|substr:0:1};}}
    +

    Cependant, pour plus de lisibilité il est conseillé d'utiliser la syntaxe des paramètres nommés SQL (voir ci-dessus).

    +

    Il est aussi possible d'insérer directement du code SQL (attention aux problèmes de sécurité dans ce cas !), pour cela il faut rajouter un point d'exclamation après l'accolade ouvrante :

    +
    {{:assign var="prenoms." value="Karim"}}
    +{{:assign var="prenoms." value="Camille"}}
    +{{#select * FROM users WHERE {!"prenom"|sql_where:"IN":$prenoms};}}
    +...
    +{{/select}}
    +

    Il est aussi possible d'utiliser les paramètres suivants :

    + + + + + + + + + + + + + + + + + + + + + +
    ParamètreFonction
    debugSi ce paramètre existe, la requête SQL exécutée sera affichée avant le début de la boucle.
    explainSi ce paramètre existe, l'explication de la requête SQL exécutée sera affichée avant le début de la boucle.
    assignSi renseigné, une variable de ce nom sera créée, et le contenu de la ligne y sera assigné.
    +

    Exemple avec debug :

    +
    {{:assign prenom="Karim"}}
    +{{#select * FROM users WHERE prenom = :prenom; :prenom=$prenom debug=true}}
    +...
    +{{/select}}
    +

    Affichera juste au dessus du résultat la requête exécutée :

    +
    SELECT * FROM users WHERE nom = 'Karim'
    +

    Paramètre assign

    +

    Exemple avec assign :

    +
    {{#select * FROM users WHERE prenom = 'Camille' LIMIT 1; assign="membre"}}{{/select}}
    +{{$membre.nom}}
    +

    Il est possible d'utiliser un point final pour que toutes les lignes soient mises dans un tableau :

    +
    {{#select * FROM users WHERE prenom = 'Camille' LIMIT 10; assign="membres."}}{{/select}}
    +
    +{{#foreach from=$membres}}
    +    Nom : {{$nom}}<br />
    +    Adresse : {{$adresse}}
    +{{/foreach}}
    +

    sql

    +

    Effectue une requête SQL de type SELECT dans la base de données, mais de manière simplifiée par rapport à select.

    +
    {{#sql select="*, julianday(date) AS day" tables="membres" where="id_categorie = :id_categorie" :id_categorie=$_GET.id_categorie order="numero DESC" begin=":page*100" limit=100 :page=$_GET.page}}
    +…
    +{{/sql}}
    + + + + + + + + + + + + + + + + + + + + +
    ParamètreOptionnel / obligatoire ?Fonction
    tablesobligatoireListe des tables à utiliser dans la requête (séparées par des virgules).
    selectoptionnelListe des colonnes à sélectionner, si non spécifié, toutes les colonnes (*) seront sélectionnées
    +

    Sections qui héritent de sql

    +

    Certaines sections (voir plus bas) héritent de sql et rajoutent des fonctionnalités. Dans toutes ces sections, il est possible d'utiliser les paramètres facultatifs suivants :

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    ParamètreFonction
    whereCondition de sélection des résultats
    beginDébut des résultats, si vide une valeur de 0 sera utilisée.
    limitLimitation des résultats. Si vide, une valeur de 10000 sera utilisée.
    groupContenu de la clause GROUP BY
    havingContenu de la clause HAVING
    orderOrdre de tri des résultats. Si vide le tri sera fait par ordre d'ajout dans la base de données.
    assignSi renseigné, une variable de ce nom sera créée, et le contenu de la ligne du résultat y sera assigné.
    debugSi ce paramètre existe, la requête SQL exécutée sera affichée avant le début de la boucle.
    explainSi ce paramètre existe, l'explication de la requête SQL exécutée sera affichée avant le début de la boucle.
    countBooléen ou texte. Si ce paramètre est TRUE, le nombre de résultats sera retourné. Si une chaîne de texte est indiquée, elle sera utilisée dans la clause COUNT(<texte>).
    +

    Il est également possible de passer des arguments dans les paramètres à l'aides des arguments nommés qui commencent par deux points : :

    +
    {{#articles where="title = :montitre" :montitre="Actualité"}}
    +

    Exemples d'utilisation du paramètre count :

    +
    {{#articles count=true}}
    +    Il y a {{$count}} articles.
    +{{/articles}}
    +
    +{{#articles count=true assign="result"}}
    +{{/articles}}
    +Il y a {{$result.count}} articles.
    +
    +{{#articles count="DISTINCT title"}}
    +    Il y a {{$count}} articles avec un titre différent.
    +{{/articles}}
    +

    Membres

    +

    users

    +

    Liste les membres.

    +

    Paramètres possibles :

    + + + + + + + + + + + + + + + + + + + + + + + + + +
    ParamètreOptionnel / obligatoire ?Fonction
    idoptionnelIdentifiant unique du membre, ou tableau contenant une liste d'identifiants.
    search_nameoptionnelNe lister que les membres dont le nom correspond au texte passé en paramètre.
    id_parentoptionnelNe lister que les membres rattachés à l'identifiant unique du membre responsable indiqué.
    +

    Chaque itération renverra la fiche du membre, ainsi que ces variables :

    + + + + + + + + + + + + + + + + + + + + + + + + + +
    VariableDescription
    $idIdentifiant unique du membre
    $_nameNom du membre, tel que défini dans la configuration
    $_loginIdentifiant de connexion du membre, tel que défini dans la configuration
    $_numberNuméro du membre, tel que défini dans la configuration
    +

    subscriptions

    +

    Liste les inscriptions à une ou des activités.

    +

    Paramètres possibles :

    + + + + + + + + + + + + + + + + + + + + + + + + + +
    ParamètreFonction
    useroptionnelIdentifiant unique du membre
    activeoptionnelSi TRUE, seules les inscriptions à jour sont listées
    id_serviceoptionnelNe renvoie que les inscriptions à l'activité correspondant à cet ID.
    +

    Comptabilité

    +

    accounts

    +

    Liste les comptes d'un plan comptable.

    + + + + + + + + + + + + + + + + + +
    ParamètreFonction
    codes (optionel)Ne renvoyer que les comptes ayant ces codes (séparer par des virgules).
    id (optionel)Ne renvoyer que le compte ayant cet ID.
    +

    balances

    +

    Renvoie la balance des comptes.

    + + + + + + + + + + + + + + + + + +
    ParamètreFonction
    codes (optionel)Ne renvoyer que les balances des comptes ayant ces codes (séparer par des virgules).
    year (optionel)Ne renvoyer que les balances des comptes utilisés sur l'année (indiquer ici un ID de year).
    +

    transactions

    +

    Renvoie des écritures.

    + + + + + + + + + + + + + + + + + + + + +
    ParamètreFonction
    idoptionnelIndiquer un ID d'écriture pour récupérer ses informations.
    useroptionnelIndiquer ici un ID utilisateur pour lister les écritures liées à un membre.
    +

    years

    +

    Liste les exercices comptables

    + + + + + + + + + + + + + +
    ParamètreFonction
    closed (optionel)Mettre closed=true pour ne lister que les exercices clôturés, ou closed=false pour ne lister que les exercices ouverts.
    +

    Pour le site web

    + +

    Permet de récupérer la liste des pages parentes d'une page afin de constituer un fil d'ariane permettant de remonter dans l'arborescence du site

    +

    Un seul paramètre est possible :

    + + + + + + + + + + + + + + + + + +
    ParamètreFonction
    uri (obligatoire)Adresse unique de la page parente
    ou id_page (obligatoire)Numéro unique (ID) de la page parente
    +

    Chaque itération renverra trois variables :

    + + + + + + + + + + + + + + + + + + + + + + + + + +
    VariableContenu
    $idNuméro unique (ID) de la page ou catégorie
    $titleTitre de la page ou catégorie
    $uriNom unique de la page ou catégorie
    $urlAdresse HTTP de la page ou catégorie
    +

    Exemple

    +
    <ul>
    +{{#breadcrumbs id_page=$page.id}}
    +    <li>{{$title}}</li>
    +{{/breadcrumbs}}
    +</ul>
    +

    pages, articles, categories (sql)

    +

    Note : ces sections héritent de sql (voir plus haut).

    +
      +
    • pages renvoie une liste de pages, qu'elles soient des articles ou des catégories
    • +
    • categories ne renvoie que des catégories
    • +
    • articles ne renvoie que des articles
    • +
    +

    À part cela ces trois types de section se comportent de manière identique.

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    ParamètreFonction
    searchRenseigner ce paramètre avec un terme à rechercher dans le texte ou le titre. Dans ce cas par défaut le tri des résultats se fait sur la pertinence, sauf si le paramètre order est spécifié.
    futureRenseigner ce paramètre à false pour que les articles dont la date est dans le futur n'apparaissent pas, true pour ne renvoyer QUE les articles dans le futur, et null (ou ne pas utiliser ce paramètre) pour que tous les articles, passés et futur, apparaissent.
    uriAdresse unique de la page/catégorie à retourner.
    id_parentNuméro unique (ID) de la catégorie parente. Utiliser null pour n'afficher que les articles ou catégories de la racine du site.
    parentAdresse unique (URI) de la catégorie parente. Exemple pour renvoyer la liste des articles de la sous-catégorie "Événements" de la catégorie "Notre atelier" : evenements. Utiliser null pour n'afficher que les articles ou catégories de la racine du site. Ajouter un point d'exclamation au début de la valeur pour inverser la condition.
    +

    Par exemple lister 5 articles de la catégorie "Actualité", qui ne sont pas dans le futur, triés du plus récent au plus ancien :

    +
    {{#articles future=false parent="actualite" order="published DESC" limit=5}}
    +    <h3>{{$title}}</h3>
    +{{/articles}}
    +

    Chaque élément de ces boucles contiendra les variables suivantes :

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    Nom de la variableDescriptionExemple
    idNuméro unique de la page (ID)1312
    id_parentNuméro unique de la catégorie parente (ID)42
    typeType de page : 1 = catégorie, 2 = article2
    uriAdresse unique de la pagebourse-aux-velos
    urlAdresse HTTP de la pagehttps://site.association.tld/bourse-aux-velos
    pathChemin complet de la pageactualite/atelier/bourse-aux-velos
    parentChemin de la catégorie parenteactualite/atelier
    titleTitre de la pageBourse aux vélos
    contentContenu brut de la page# Titre …
    htmlRendu HTML du contenu de la page<div class="web-content"><h1>Titre</h1>…</div>
    has_attachmentstrue si la page a des fichiers joints, false sinontrue
    publishedDate de publication2023-01-01 01:01:01
    modifiedDate de modification2023-01-01 01:01:01
    +

    Si une recherche a été effectuée, deux autres variables sont fournies :

    + + + + + + + + + + + + + + + + + + + + +
    Nom de la variableDescriptionExemple
    snippetExtrait du contenu contenant le texte recherché (entouré de balises <mark>)L’ONU appelle la France à s’attaquer aux « profonds problèmes » de <mark>racisme</mark> au sein des forces de…
    url_highlightAdresse de la page, où le texte recherché sera mis en évidencehttps://.../onu-racisme#:~:text=racisme%20au%20sein
    +

    attachments, documents, images (sql)

    +

    Note : ces sections héritent de sql (voir plus haut).

    +
      +
    • attachments renvoie une liste de fichiers joints à une page du site web
    • +
    • documents renvoie une liste de fichiers joints qui ne sont pas des images
    • +
    • images renvoie une liste de fichiers joints qui sont des images
    • +
    +

    À part cela ces trois types de section se comportent de manière identique.

    +

    Note : seul les fichiers de la section site web sont accessibles, les fichiers de membres, de comptabilité, etc. ne sont pas disponibles.

    + + + + + + + + + + + + + + + + + + + + + + + + + +
    ParamètreOptionnel / obligatoire ?Fonction
    parentobligatoire si id_parent n'est pas renseignéNom unique (URI) de l'article ou catégorie parente dont ont veut lister les fichiers
    id_parentobligatoire si parent n'est pas renseignéNuméro unique (ID) de l'article ou catégorie parente dont ont veut lister les fichiers
    except_in_textoptionnelpasser true à ce paramètre , et seuls les fichiers qui ne sont pas liés dans le texte de la page seront renvoyés
    +

    Sections relatives aux modules

    +

    form

    +

    Permet de gérer la soumission d'un formulaire (<form method="post"…> en HTML).

    +

    Si l'élément dont le nom spécifié dans le paramètre on a été envoyé en POST, alors le code à l'intérieur de la section est exécuté.

    +

    Toute erreur à l'intérieur de la section arrêtera son exécution, et le message sera ajouté aux erreurs du formulaire.

    +

    Une vérification de sécurité anti-CSRF est également appliquée. Si cette vérification échoue, le message d'erreur "Merci de bien vouloir renvoyer le formulaire." sera renvoyé. Pour que cela marche il faut que le formulaire dispose d'un bouton de type "submit", généré à l'aide de la fonction button. Exemple : {{:button type="submit" name="save" label="Enregistrer"}}.

    +

    En cas d'erreurs, le reste du contenu de la section ne sera pas exécuté. Les messages d'erreurs seront placés dans un tableau dans la variable $form_errors.

    +

    Il est aussi possible de les afficher simplement avec la fonction {{:form_errors}}. Cela revient à faire une boucle sur la variable $form_errors.

    +
    {{#form on="save"}}
    +    {{if $_POST.titre|trim === ''}}
    +        {{:error message="Le titre est vide."}}
    +    {{/if}}
    +    {{* La ligne suivante ne sera pas exécutée si le titre est vide. *}}
    +    {{:save title=$_POST.titre|trim}}
    +{{else}}
    +    {{:form_errors}}
    +{{/form}}
    +

    Il est possible d'utiliser {{:form_errors}} en dehors du bloc {{else}} :

    +
    {{#form on="save"}}
    +    …
    +{{/form}}
    +…
    +{{:form_errors}}

    load (sql)

    +

    Note : cette section hérite de sql (voir plus haut). De ce fait, le nombre de résultats est limité à 10000 par défaut, si le paramètre limit n'est pas renseigné.

    +

    Charge un ou des documents pour le module courant.

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    ParamètreOptionnel / obligatoire ?Fonction
    moduleoptionnelNom unique du module lié (par exemple : recu_don). Si non spécifié, alors le nom du module courant sera utilisé.
    keyoptionnelClé unique du document
    idoptionnelNuméro unique du document
    eachoptionnelTraiter une clé du document comme un tableau
    +

    Il est possible d'utiliser d'autres paramètres : {{#load cle="valeur"}}. Cela va comparer "valeur" avec la valeur de la clé cle dans le document JSON. C'est l'équivalent d'écrire where="json_extract(document, '$.cle') = 'valeur'".

    +

    Pour des conditions plus complexes qu'une simple égalité, il est possible d'utiliser la syntaxe courte $$… dans le paramètre where. Ainsi where="$$.nom LIKE 'Bourse%' est l'équivalent de where="json_extract(document, '$.nom') LIKE 'Bourse%'".

    +

    Voir la documentation de SQLite pour plus de détails sur la syntaxe de json_extract.

    +

    Note : un index SQL dynamique est créé pour chaque requête utilisant une clause json_extract.

    +

    Chaque itération renverra ces deux variables :

    + + + + + + + + + + + + + + + + + +
    VariableValeur
    $keyClé unique du document
    $idNuméro unique du document
    +

    Ainsi que chaque élément du document JSON lui-même.

    +

    Exemples

    +

    Afficher le nom du document dont la clé est facture_43 :

    +
    {{#load key="facture_43"}}
    +{{$nom}}
    +{{/load}}
    +

    Afficher la liste des devis du module invoice depuis un autre module par exemple :

    +
    {{#load module="invoice" type="quote"}}
    +<h1>Titre du devis : {{$subject}}</h1>
    +<h2>Montant : {{$total}}</h2>
    +{{/load}}
    +

    Utilisation du paramètre each

    +

    Le paramètre each est utile pour faire une boucle sur un tableau contenu dans le document. Ce paramètre doit contenir un chemin JSON valide. Par exemple membres[1].noms pour boucler sur le tableau noms, du premier élément du tableau membres. Voir la documentation de la fonction json_each de SQLite pour plus de détails.

    +

    Pour chaque itération de la section, la variable {{$value}} contiendra l'élément recherché dans le critère each.

    +

    Par exemple nous pouvons avoir un élément membres dans notre document JSON qui contient un tableau de noms de membres :

    +
    {{:assign var="membres." value="Greta Thunberg}}
    +{{:assign var="membres." value="Valérie Masson-Delmotte"}}
    +{{:save membres=$membres}}
    +

    Nous pouvons utiliser each pour faire une liste :

    +
    {{#load each="membres"}}
    +- {{$value}}
    +{{/load}}
    +

    Ou pour récupérer les documents qui correspondent à un critère :

    +
    {{#load each="membres" where="value = 'Greta Thunberg'"}}
    +Le document n°{{$id}} est celui qui parle de Greta.
    +{{/load}}
    +

    list

    +

    Attention : cette section n'hérite PAS de sql.

    +

    Un peu comme {{#load}} cette section charge les documents d'un module, mais au sein d'une liste (tableau HTML).

    +

    Cette liste gère automatiquement l'ordre selon les préférences des utilisateurs, ainsi que la pagination.

    +

    Cette section est très puissante et permet de générer des listes simplement, une fois qu'on a saisi la logique de son fonctionnement.

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    ParamètreOptionnel / obligatoire ?Fonction
    schemarequis si select n'est pas fourniChemin vers un fichier de schéma JSON qui représenterait le document
    selectrequis si schema n'est pas fourniListe des colonnes à sélectionner, sous la forme $$.colonne AS "Colonne", chaque colonne étant séparée par un point-virgule.
    moduleoptionnelNom unique du module lié (par exemple : recu_don). Si non spécifié, alors le nom du module courant sera utilisé.
    columnsoptionnelPermet de n'afficher que certaines colonnes du schéma. Indiquer ici le nom des colonnes, séparées par des virgules.
    orderoptionnelColonne utilisée par défaut pour le tri (si l'utilisateur n'a pas choisi le tri sur une autre colonne). Si select est utilisé, il faut alors indiquer ici le numéro de la colonne, et non pas son nom.
    descoptionnelSi ce paramètre est à true, l'ordre de tri sera inversé.
    maxoptionnelNombre d'éléments à afficher dans la liste, sur chaque page.
    whereoptionnelCondition WHERE de la requête SQL.
    debugoptionnelSi ce paramètre existe, la requête SQL exécutée sera affichée avant le début de la boucle.
    explainoptionnelSi ce paramètre existe, l'explication de la requête SQL exécutée sera affichée avant le début de la boucle.
    disable_user_orderingoptionnelBooléen. Si ce paramètre est true, il ne sera pas possible à l'utilisateur d'ordonner les colonnes.
    +

    Pour déterminer quelles colonnes afficher dans le tableau, il faut utiliser soit le paramètre schema pour indiquer un fichier de schéma JSON qui sera utilisé pour donner le libellé des colonnes (via la description indiquée dans le schéma), soit le paramètre select, où il faut alors indiquer le nom et le libellé des colonnes sous la forme $$.colonne1 AS "Libellé"; $$.colonne2 AS "Libellé 2".

    +

    Comme pour load, il est possible d'utiliser des paramètres supplémentaires : cle="valeur". Cela va comparer "valeur" avec la valeur de la clé cle dans le document JSON. C'est l'équivalent d'écrire where="json_extract(document, '$.cle') = 'valeur'".

    +

    Pour des conditions plus complexes qu'une simple égalité, il est possible d'utiliser la syntaxe courte $$… dans le paramètre where. Ainsi where="$$.nom LIKE 'Bourse%' est l'équivalent de where="json_extract(document, '$.nom') LIKE 'Bourse%'".

    +

    Voir la documentation de SQLite pour plus de détails sur la syntaxe de json_extract.

    +

    Note : un index SQL dynamique est créé pour chaque requête utilisant une clause json_extract.

    +

    Chaque itération renverra toujours ces deux variables :

    + + + + + + + + + + + + + + + + + +
    VariableValeur
    $keyClé unique du document
    $idNuméro unique du document
    +

    Ainsi que chaque élément du document JSON lui-même.

    +

    La section ouvre un tableau HTML et le ferme automatiquement, donc le contenu de la section doit être une ligne de tableau HTML (<tr>).

    +

    Dans chaque ligne du tableau il faut respecter l'ordre des colonnes indiqué dans columns ou select. Une dernière colonne est réservée aux boutons d'action : <td class="actions">...</td>.

    +

    Attention : une seule liste peut être utilisée dans une même page. Avoir plusieurs listes provoquera des problèmes au niveau du tri des colonnes.

    +

    Exemples

    +

    Lister le nom, la date et le montant des reçus fiscaux, à partir du schéma JSON suivant :

    +
    {
    +    "$schema": "https://json-schema.org/draft/2020-12/schema",
    +    "type": "object",
    +    "properties": {
    +        "date": {
    +            "description": "Date d'émission",
    +            "type": "string",
    +            "format": "date"
    +        },
    +        "adresse": {
    +            "description": "Adresse du bénéficiaire",
    +            "type": "string"
    +        },
    +        "nom": {
    +            "description": "Nom du bénéficiaire",
    +            "type": "string"
    +        },
    +        "montant": {
    +            "description": "Montant",
    +            "type": "integer",
    +            "minimum": 0
    +        }
    +    }
    +}
    +

    Le code de la section sera alors comme suivant :

    +
    {{#list schema="./recu.schema.json" columns="nom, date, montant"}}
    +    <tr>
    +        <th>{{$nom}}</th>
    +        <td>{{$date|date_short}}</td>
    +        <td>{{$montant|raw|money_currency}}</td>
    +        <td class="actions">
    +            {{:linkbutton shape="eye" label="Ouvrir" href="./voir.html?id=%d"|args:$id target="_dialog"}}
    +        </td>
    +    </tr>
    +{{else}}
    +    <p class="alert block">Aucun reçu n'a été trouvé.</p>
    +{{/list}}
    +

    Si le paramètre columns avait été omis, la colonne adresse aurait également été incluse.

    +

    Il est à noter que si l'utilisation directe du schéma est bien pratique, cela ne permet pas de récupérer des informations plus complexes dans la structure JSON, par exemple une sous-clé ou l'application d'une fonction SQL. Dans ce cas il faut obligatoirement utiliser select. Par exemple ici on veut pouvoir afficher l'année, et trier sur l'année par défaut :

    +
    {{#list select="$$.nom AS 'Nom du donateur' ; strftime('%Y', $$.date) AS 'Année'" order=2}}
    +    <tr>
    +        <th>{{$nom}}</th>
    +        <td>{{$col2}}</td>
    +        <td class="actions">
    +            {{:linkbutton shape="eye" label="Ouvrir" href="./voir.html?id=%d"|args:$id target="_dialog"}}
    +        </td>
    +    </tr>
    +{{else}}
    +    <p class="alert block">Aucun reçu n'a été trouvé.</p>
    +{{/list}}
    +

    On peut utiliser le nom des clés du document JSON, mais sinon pour faire référence à la valeur d'une colonne spécifique dans la boucle, il faut utiliser son numéro d'ordre (qui commence à 1, pas zéro). Ici on veut afficher l'année, donc la seconde colonne, donc $col1.

    +

    Noter aussi l'utilisation du numéro de la colonne de l'année (2) pour le paramètre order, qui avec select doit indiquer le numéro de la colonne à utiliser pour l'ordre.

    \ No newline at end of file diff --git a/src/www/admin/static/doc/keyboard.html b/src/www/admin/static/doc/keyboard.html new file mode 100644 index 0000000..b4eb028 --- /dev/null +++ b/src/www/admin/static/doc/keyboard.html @@ -0,0 +1,145 @@ + + + + Raccourcis claviers dans l'édition de texte — Paheko + + + + +
    +

    Raccourcis clavier

    +

    Depuis l'édition du texte :

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    RaccourciAction
    Ctrl + GMettre en gras
    Ctrl + IMettre en italique
    Ctrl + TMettre en titre
    Ctrl + LTransformer en lien
    Ctrl + Shift + IInsérer une image
    Ctrl + Shift + FInsérer un fichier
    Ctrl + PPrévisualiser
    Ctrl + SEnregistrer
    F11Activer ou désactiver l'édition plein écran
    F1Afficher l'aide
    EchapPrévisualiser (rappuyer pour revenir à l'édition)
    +

    Depuis la prévisualisation :

    + + + + + + + + + + + + + +
    RaccourciAction
    Ctrl + PRetour à l'édition
    +

    Depuis l'aide ou l'insertion de fichier :

    + + + + + + + + + + + + + +
    RaccourciAction
    EchapFermer et revenir à l'édition
    +

    Ajouter un fichier ou une image

    +

    Il est aussi possible de faire glisser et déposer une image ou un fichier sur le champ d'édition du texte pour l'envoyer et l'insérer.

    +

    De même, il est aussi possible d'utiliser le copier/coller dans le texte pour insérer un fichier ou une image.

    \ No newline at end of file diff --git a/src/www/admin/static/doc/markdown.html b/src/www/admin/static/doc/markdown.html new file mode 100644 index 0000000..400b374 --- /dev/null +++ b/src/www/admin/static/doc/markdown.html @@ -0,0 +1,631 @@ + + + + Référence complète MarkDown — Paheko + + + + +

    Syntaxe MarkDown

    +

    Paheko permet d'utiliser la syntaxe MarkDown dans les pages du site web.

    +

    Cette syntaxe est la plus répandue dans les outils d'édition de texte, si vous ne la connaissez pas encore, voici les règles qu'on peut utiliser pour formatter du texte avec MarkDown dans la plupart des outils (dont Paheko), ainsi que les règles spécifiques supportées par Paheko.

    +

    Styles de texte

    +

    Italique

    +

    Pour mettre un texte en italique il faut l'entourer de tirets bas ou d'astérisques :

    +
    Ce texte est en *italique, dingue !*
    +

    Donnera :

    +
    +

    Ce texte est en italique, dingue !

    +
    +

    Gras

    +

    Pour le gras, procéder de la même manière, mais avec deux tirets bas ou deux astérisques :

    +
    Ce texte est **très gras**.
    +
    +

    Ce texte est très gras.

    +
    +

    Gras et italique

    +

    Pour combiner, utiliser trois tirets ou trois astérisques :

    +
    Ce texte est ***gras et italique***.
    +
    +

    Ce texte est gras et italique.

    +
    +

    Barré

    +

    Utiliser un symbole tilde pour barrer un texte :

    +
    Texte ~~complètement barré~~.
    +
    +

    Texte complètement barré.

    +
    +

    Surligné

    +

    Il est possible de marquer une phrase ou un mot comme surligné en l'entourant de deux signes égal :

    +
    Ce texte est ==surligné==.
    +
    +

    Ce texte est surligné.

    +
    +

    Code

    +

    Il est possible d'indiquer du code dans une ligne de texte avec un caractère backtick (accent grave en français, obtenu avec les touches Alt Gr + 7) :

    +
    Le code `<html>` c'est rigolo !
    +
    +

    Le code <html> c'est rigolo !

    +
    +

    Avertissement sur les styles de texte

    +

    Un style de texte ne s'applique que dans un même paragraphe, il n'est pas possible d'appliquer un style sur plusieurs paragraphes :

    +

    Dans l'exemple suivant, les astérisques ne seront pas remplacées par du gras, elles resteront telles quelles :

    +
    Ce texte n'est pas très **gras.
    +
    +Et celui-ci encore moins**.
    +
    +

    Ce texte n'est pas très **gras.

    +

    Et celui-ci encore moins**.

    +
    +

    Liens

    +

    Créez un lien en mettant le texte désiré entre crochets et le lien associé entre parenthèses :

    +
    Je connais un super gestionnaire [d'association](https://paheko.cloud/) !
    +

    Donne :

    +
    +

    Je connais un super gestionnaire d'association !

    +
    +

    Il est possible de faire un lien vers une autre page du site web en utilisant son adresse unique :

    +
    N'oubliez pas de [vous inscrire à notre atelier](atelier-soudure).
    +

    Il est aussi possible de simplement inclure une adresse URL et elle sera automatiquement transformée en lien :

    +
    https://paheko.cloud/
    +

    Blocs

    +

    Paragraphes et retours à la ligne

    +

    Une ligne vide indique un changement de paragraphe :

    +
    Ceci est un paragraphe.
    +
    +Ceci est est un autre.
    +

    Un retour à la ligne simple est traité comme tel :

    +
    Ceci est un
    +paragraphe.
    +
    +

    Ceci est un
    +paragraphe.

    +
    +

    Titres et sous-titres

    +

    Pour faire un titre, vous devez mettre un ou plusieurs caractères hash (#) au début de la ligne.

    +

    Un titre avec un seul caractère est un titre principal (niveau 1), avec deux caractères c'est un sous-titre (niveau 2), etc. jusqu'au niveau 6.

    +
    # Titre principal (niveau 1)
    +## Sous-titre (niveau 2)
    +### Sous-sous-titre (niveau 3)
    +#### Niveau 4
    +##### Niveau 5
    +###### Dernier niveau de sous-titre (6)
    +

    Donnera :

    +
    +

    Titre principal (niveau 1)

    +

    Sous-titre (niveau 2)

    +

    Sous-sous-titre (niveau 3)

    +

    Niveau 4

    +
    Niveau 5
    +
    Dernier niveau de sous-titre (6)
    +
    +

    Listes

    +

    Vous pouvez créer des listes avec les caractères astérisque (*) et tiret - en début de ligne pour des listes non ordonnées :

    +
    * une élément
    +* un autre
    +  - un sous élément
    +  - un autre sous élément
    +* un dernier élément
    +
    +
      +
    • une élément
    • +
    • un autre
        +
      • un sous élément
      • +
      • un autre sous élément
      • +
      +
    • +
    • un dernier élément
    • +
    +
    +

    Ou avec des nombres pour des listes ordonnées :

    +
    1. élément un
    +2. élément deux
    +
    +
      +
    1. élément un
    2. +
    3. élément deux
    4. +
    +
    +

    L'ordre des nombres n'est pas important, seul le premier nombre est utilisé pour déterminer à quel numéro commencer la liste.

    +

    Exemple :

    +
    3. A
    +5. B 
    +4. C
    +
    +
      +
    1. A
    2. +
    3. B
    4. +
    5. C
    6. +
    +
    +

    Il est ainsi possible d'utiliser uniquement le même numéro pour ne pas avoir à numéroter sa liste :

    +
    1. Un
    +1. Deux
    +
    +
      +
    1. Un
    2. +
    3. Deux
    4. +
    +
    +

    Citations

    +

    Les citations se font en ajoutant le signe supérieur à (>) au début de la ligne :

    +
    > Programming is not a science. Programming is a craft.
    +
    +

    Programming is not a science. Programming is a craft.

    +
    +

    Code

    +

    Créez un bloc de code en indentant chaque ligne avec quatre espaces, ou en mettant trois accents graves ` (backtick, obtenu avec Alt Gr + 7) sur la ligne au dessus et en dessous de votre code:

    +
    ```
    +<html>...</html>
    +```
    +

    Résultat :

    +
    <html>...</html>
    +

    Tableaux

    +

    Pour créer un tableau vous devez séparer les colonnes avec des barres verticales (|, obtenu avec les touches AltGr + 6).

    +

    La première ligne contient les noms des colonnes, la seconde ligne contient la ligne de séparation (chaque cellule doit contenir un ou plusieurs tirets), et les lignes suivantes représentent le contenu du tableau.

    +
    | Colonne 1 | Colonne 2 |
    +| - | - | - |
    +| AB | CD |
    + + + + + + + + + + + + + +
    Colonne 1Colonne 2
    ABCD
    +

    Par défaut les colonnes sont centrées. On peut aussi aligner le texte à gauche ou à droite en mettant deux points après le ou les tirets de la ligne suivant l'entête :

    +
    | Aligné à gauche  | Centré          | Aligné à droite |
    +| :--------------- |:---------------:| :--------------:|
    +| Aligné à gauche  | ce texte        | Aligné à droite |
    +| Aligné à gauche  | est             | Aligné à droite |
    +| Aligné à gauche  | centré          | Aligné à droite |
    + + + + + + + + + + + + + + + + + + + + + + + + + +
    Aligné à gaucheCentréAligné à droite
    Aligné à gauchece texteAligné à droite
    Aligné à gaucheestAligné à droite
    Aligné à gauchecentréAligné à droite
    +

    Ligne de séparation

    +

    Il suffit de mettre au moins 3 tirets à la suite sur une ligne séparée pour ajouter une ligne de séparation :

    +
    ---
    +

    Résultat :

    +
    +

    Commentaires

    +

    Pour ajouter un commentaire qui ne sera pas affiché dans le texte, utiliser la syntaxe suivante :

    +
    <!-- Ceci est un commentaire -->
    +

    Notes de bas de page

    +

    Pour créer une note de base de page, il faut mettre entre crochets un signe circonflexe (obtenu en appuyant sur la touche circonflexe, puis sur espace) suivi du numéro ou du nom de la note. Enfin, à la fin du texte il faudra répéter les crochets, le signe circonflexe, suivi de deux points et de la définition.

    +
    Texte très intéressant[^1]. Approuvé par 100% des utilisateurs[^Source].
    +
    +[^1]: Ceci est une note de bas de page
    +[^Source]: Enquête Paheko sur la base de 1 personne interrogée.
    +

    Donnera ceci :

    +
    +

    Texte très intéressant1. Approuvé par 100% des utilisateursSource.

    +
    1
    Ceci est une note de bas de page
    Source
    Enquête Paheko sur la base de 1 personne interrogée.
    +
    +

    Insertion de vidéos depuis un service de vidéo

    +

    Certains services vidéo comme les instances Peertube permettent l'intégration des vidéos.

    +

    Pour cela il faut recopier le code d'intégration donné par le service vidéo. Voici un exemple :

    +
    <iframe title="ENQUÊTE : Brûler la Forêt pour Sauver le Climat ? | EP 3 - Le bois énergie" width="560" height="315" src="https://peertube.stream/videos/embed/12c52265-e3b3-4bad-93f3-f2c1df5bbe4f" frameborder="0" allowfullscreen="" sandbox="allow-same-origin allow-scripts allow-popups"></iframe>
    +

    Résultat :

    + +

    Identifiant et classe CSS sur les titres

    +

    Il est possible de spécifier l'ID et la classe CSS d'un titre en les rajoutant à la fin du titre, entre accolades, comme ceci :

    +
    ## Titre de niveau 2 {#titre2} {.text-center}
    +

    Le code HTML résultant sera comme ceci :

    +
    <h2 id="titre2" class="text-center">Titre de niveau 2</h2>
    +

    Classes CSS

    +

    Il est possible de donner une classe CSS parente à un ensemble d'éléments en les mettant au centre d'un bloc définissant cette classe :

    +
    {{{.custom-quote .custom-block
    +
    +Paragraphe
    +
    +> Citation
    +}}}
    +

    Créera le code HTML suivant :

    +
    <div class="custom-quote custom-block">
    +
    +    <p>Paragraphe</p>
    +
    +    <blockquote><p>Citation</p></blockquote>
    +</div>
    +

    Tags HTML

    +

    Certains tags HTML sont autorisés :

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    TagUtilisationExemple
    <kbd>Touches de clavierCtrl + B
    <samp>Exemple de programme en consolebohwaz@platypus ~ % sudo apt install paheko
    <var>Variable dans un programme informatiqueab + cd = 42
    <del>Texte suppriméTexte supprimé
    <ins>Texte ajoutéTexte ajouté
    <sup>Texte en exposantTexteexposant
    <sub>Texte en indiceTexteindice
    <mark>Texte surlignéTexte surligné
    <audio>Insérer un lecteur audio dans la page<audio src="mon_fichier.mp3">
    <video>Insérer une vidéo dans la page<video src="mon_fichier.webm">
    +

    Mais leurs possibilités sont limitées, notamment sur les attributs autorisés.

    +

    Extensions

    +

    Paheko propose des extensions au langage MarkDown, qui n'existent pas dans les autres logiciels utilisant aussi MarkDown.

    +

    Toutes ces extensions se présentent sous la forme d'un code situé entre deux signes inférieur à (<<) et deux signes supérieur à (>>), à ne pas confondre avec les guillements français (« et »).

    +

    Images jointes

    +

    Il est possible d'intégrer une image jointe à la page web en plaçant le code suivant sur une ligne (sans autre texte) :

    +
    <<image|Nom_fichier.jpg|Alignement|Légende>>
    +
      +
    • Nom_fichier.jpg : remplacer par le nom du fichier de l'image (parmi les images jointes à la page)
    • +
    • Alignement : remplacer par l'alignement :
        +
      • gauche ou left : l'image sera placée à gauche en petit (200 pixels), le texte remplira l'espace laissé sur la droite de l'image ;
      • +
      • droite ou right : l'image sera placée à droite en petit, le texte remplira l'espace laissé sur la gauche de l'image ;
      • +
      • centre ou center : l'image sera placée au centre en taille moyenne (500 pixels), le texte sera placé au dessus et en dessous.
      • +
      +
    • +
    • Légende : indiquer ici une courte description de l'image.
    • +
    +

    Exemple :

    +
    <<image|mon_image.png|center|Ceci est une belle image>>
    +

    Il est aussi possible d'utiliser la syntaxe avec des paramètres nommés :

    +
    <<image file="Nom_fichier.jpg" align="center" caption="Légende">>
    +

    Les images qui ne sont pas mentionnées dans le texte seront affichées après le texte sous forme de galerie.

    +

    Galerie d'images

    +

    Il est possible d'afficher une galerie d'images (sous forme d'images miniatures) avec la balise <<gallery qui contient la liste des images à mettre dans la galerie :

    +
    <<gallery
    +Nom_fichier.jpg
    +Nom_fichier_2.jpg
    +>>
    +

    Si aucun nom de fichier n'est indiqué, alors toutes les images jointes à la page seront affichées :

    +
    <<gallery>>
    +

    Diaporama d'images

    +

    On peut également afficher cette galerie sous forme de diaporama. Dans ce cas une seule image est affichée, et on peut passer de l'une à l'autre.

    +

    La syntaxe est la même, mais on ajoute le mot slideshow après le mot gallery :

    +
    <<gallery slideshow
    +Nom_fichier.jpg
    +Nom_fichier_2.jpg
    +>>
    +

    Fichiers joints

    +

    Pour créer un bouton permettant de voir ou télécharger un fichier joint à la page web, il suffit d'utiliser la syntaxe suivante :

    +
    <<file|Nom_fichier.ext|Libellé>>
    +
      +
    • Nom_fichier.ext : remplacer par le nom du fichier (parmi les fichiers joints à la page)
    • +
    • Libellé : indique le libellé du qui sera affiché sur le bouton, si aucun libellé n'est indiqué alors c'est le nom du fichier qui sera affiché
    • +
    +

    Vidéos

    +

    Pour inclure un lecteur vidéo dans la page web à partir d'un fichier vidéo joint à la page, il faut utiliser le code suivant :

    +
    <<video|Nom_du_fichier.ext>>
    +

    On peut aussi spécifier d'autres paramètres :

    +
      +
    • file : nom du fichier vidéo
    • +
    • poster : nom de fichier d'une image utilisée pour remplacer la vidéo avant qu'elle ne soit lue
    • +
    • subtitles : nom d'un fichier de sous-titres au format VTT ou SRT
    • +
    • width : largeur de la vidéo (en pixels)
    • +
    • height : hauteur de la vidéo (en pixels)
    • +
    +

    Exemple :

    +
    <<video file="Ma_video.webm" poster="Ma_video_poster.jpg" width="640" height="360" subtitles="Ma_video_sous_titres.vtt">>
    +

    Sommaire / table des matières automatique

    +

    Il est possible de placer le code <<toc>> pour générer un sommaire automatiquement à partir des titres et sous-titres :

    +
    <<toc>>
    +

    Affichera un sommaire comme celui-ci :

    Il est possible de limiter les niveaux en utilisant le paramètre level comme ceci :

    +
    <<toc level=1>>
    +

    N'affichera que les titres de niveau 1 (précédés d'un seul signe hash #), comme ceci :

    Enfin il est possible de placer la table des matières sur le côté du texte, en utilisant le paramètre aside :

    +
    <<toc level=1 aside>>
    +

    Note : en plus de la syntaxe <<toc>>, Paheko supporte aussi les syntaxes suivantes par compatibilité avec les autres moteurs de rendu MarkDown : {:toc} [[_TOC_]] [toc].

    +

    Exclure un sous-titre du sommaire

    +

    Il est aussi possible d'indiquer qu'un titre ne doit pas être inclus dans le sommaire en utilisant la classe no_toc comme ceci :

    +
    ## Sous-titre non-inclus {.no_toc}
    +

    Grilles et colonnes

    +

    Pour une mise en page plus avancée, il est possible d'utiliser les grilles, adaptation des grids en CSS. Il faut utiliser la syntaxe <<grid>>...Contenu...<</grid>>.

    +

    Attention, les blocs <<grid>> et <</grid>> doivent obligatoirement être placés sur des lignes qui ne contiennent rien d'autre.

    +

    Note : sur petit écran (mobile ou tablette) les grilles et colonnes sont désactivées, tout sera affiché dans une seule colonne, comme si les grilles n'étaient pas utilisées.

    +

    Pour spécifier le nombre de colonnes on peut utiliser un raccourci qui mime les colonnes, comme ceci :

    +
    <<grid !!>>
    +

    Ce code indique qu'on veut créer une grille de 2 colonnes de largeur identique.

    +

    Dans les raccourcis, le point d'exclamation ! indique une colonne simple, et le hash # indique une colonne qui prend le reste de la place selon le nombre de colonnes total.

    +

    D'autres exemples de raccourcis :

    +
      +
    • !! : deux colonnes de largeur égale
    • +
    • !!! : trois colonnes de largeur égale
    • +
    • !## : deux colonnes, la première occupant un tiers de la largeur, la seconde occupant les deux tiers
    • +
    • !##! : 4 colonnes, la première occupant un quart de la largeur, la seconde occupant la moitié, la dernière occupant le quart
    • +
    +

    Alternativement, pour plus de contrôle, ce bloc accepte les paramètres suivants :

    +
      +
    • short : notation courte décrite ci-dessus
    • +
    • gap : espacement entre les blocs de la grille
    • +
    • template : description CSS complète de la grille (propriété grid-template)
    • +
    +

    Après ce premier bloc <<grid>> qui définit la forme de la grille, on peut entrer le contenu de la première colonne.

    +

    Pour créer la seconde colonne il faut simplement placer un nouveau bloc <<grid>> vide (aucun paramètre) sur une ligne.

    +

    Enfin on termine en fermant la grille avec un block <</grid>>. Voici un exemple complet :

    +
    <<grid !!!>>
    +Col. 1
    +<<grid>>
    +Col. 2
    +<<grid>>
    +Col. 3
    +<</grid>>

    Col. 1

    Col. 2

    Col. 3

    Exemple avec 3 colonnes, dont 2 petites et une large :

    +
    <<grid !##!>>
    +Col. 1
    +<<grid>>
    +Colonne 2 large
    +<<grid>>
    +Col. 3
    +<</grid>>

    Col. 1

    Colonne 2 large

    Col. 3

    Il est possible de créer plus de blocs qu'il n'y a de colonnes, cela créera une nouvelle ligne avec le même motif :

    +
    <<grid !!>>
    +L1 C1
    +<<grid>>
    +L1 C2
    +<<grid>>
    +L2 C1
    +<<grid>>
    +L2 C2
    +<</grid>>

    L1 C1

    L1 C2

    L2 C1

    L2 C2

    Enfin, il est possible d'utiliser la notation CSS grid-row et grid-column pour chaque bloc, permettant de déplacer les blocs, ou de faire en sorte qu'un bloc s'étende sur plusieurs colonnes ou plusieurs lignes. Pour cela il faut utiliser le paramètre row ou column qui précède le bloc :

    +
    <<grid short="#!!" column="span 2">>
    +A
    +<<grid row="span 2">>
    +B
    +<<grid>>
    +C
    +<<grid>>
    +D
    +<</grid>>

    A

    B

    C

    D

    Noter que dans ce cas on doit utiliser la notation short="…" pour pouvoir utiliser les autres paramètres.

    +

    Enfin, il est possible d'aligner un bloc verticalement par rapport aux autres en utilisant le paramètre align (équivalent de la propriété CSS align-self).

    +

    Alignement du texte

    +

    Il suffit de placer sur une ligne seule le code <<center>> pour centrer du texte :

    +
    <<center>>
    +Texte centré
    +<</center>>
    +

    On peut procéder de même avec <<left>> et <<right>> pour aligner à gauche ou à droite.

    +

    Couleurs

    +

    Comme sur les Skyblogs, il est possible de mettre en couleur le texte et le fond, et même de créer des dégradés !

    +

    Utiliser la syntaxe <<color COULEUR>>...texte...<</color>> pour changer la couleur du texte, ou <<bgcolor COULEUR>>...texte...<</bgcolor>> pour la couleur du fond.

    +

    Il est possible d'indiquer plusieurs couleurs, séparées par des espaces, pour créer des dégradés.

    +
    <<color red>>Rouge !<</color>>
    +<<bgcolor yellow>>Fond jaune pétant !<</bgcolor>>
    +<<color cyan green salmon>>Dégradé de texte !<</color>>
    +<<bgcolor chocolate khaki orange>>Dégradé du fond<</bgcolor>>
    +
    +<<bgcolor darkolivegreen darkseagreen >>
    +<<color darkred>>
    +
    +## Il est aussi possible de faire des blocs colorés
    +
    +Avec des paragraphes
    +
    +> Et citations
    +
    +<</color>>
    +<</bgcolor>>
    +
    +

    Rouge !
    +Fond jaune pétant !
    +Dégradé de texte !
    +Dégradé du fond

    Il est aussi possible de faire des blocs colorés

    +

    Avec des paragraphes

    +
    +

    Et citations

    +
    +

    Il est possible d'utiliser les couleurs avec leur nom ou leur code hexadécimal (exemple : #ff0000 pour rouge).

    +

    Attention : cette fonctionnalité est rigolote mais doit être utilisé avec parcimonie, en effet cela risque de rendre le texte illisible, notamment pour les personnes daltoniennes.

    \ No newline at end of file diff --git a/src/www/admin/static/doc/markdown_quickref.html b/src/www/admin/static/doc/markdown_quickref.html new file mode 100644 index 0000000..24cffe9 --- /dev/null +++ b/src/www/admin/static/doc/markdown_quickref.html @@ -0,0 +1,310 @@ + + + + Référence rapide MarkDown — Paheko + + + + +
    +

    Référence rapide MarkDown

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    NomSyntaxeRenduNotes
    Italique*italique*italique
    Gras**gras**gras
    Gras et italique***gras et italique***gras et italique
    Barré~~barré~~barréP
    Surligné==surligné==surlignéP
    Lien[Libellé du lien](adresse)Libellé du lien
    Titre niveau 1# Titre 1

    Titre 1

    Titre niveau 2## Titre 2

    Titre 2

    Titre niveau 3### Titre 3

    Titre 3

    Titre niveau 4#### Titre 4

    Titre 4

    Titre niveau 5##### Titre 5
    Titre 5
    Titre niveau 6###### Titre 6
    Titre 6
    Liste
    * Liste 1
    * Liste 2
    • Liste 1
    • Liste 2
    Liste imbriquée
    * Liste 1
    * Sous-liste 1
    • Liste 1
      • Sous-liste 1
    Liste numérotée
    1. Liste 1
    2. Liste 2
    1. Liste 1
    2. Liste 2
    Code dans du texteVoir ce `code`Voir ce code
    Bloc de code
    ```
    Bloc de code
    ```
    Bloc de code
    Citation
    > Citation
    > Citation
    Citation
    Citation
    Tableau
    | Colonne 1 | Colonne 2 |
    | - | - |
    | A | B |
    Colonne 1Colonne 2
    AB
    Ligne horizontale----
    Référence à une note de bas de page[^1]1P
    Définition d'une note de bas de page
    [^1]: Définition
    1P
    Bloc avec classe CSS
    {{{.boutons
    * Paheko
    }}}
    P
    Sommaire / table des matières<<toc>>(ne peut être montré sur cette page)P
    Image jointe<<image|nom_image.jpg|center|Légende>>(ne peut être montré sur cette page)P
    Fichier joint<<file|nom_fichier.pdf|Libellé>>(ne peut être montré sur cette page)P
    Grille à 2 colonnes
    <<grid !!>>
    Colonne 1

    <<grid>>
    Colonne 2

    <</grid>>
    (ne peut être montré sur cette page)P
    Texte centré<<center>>Centre<</center>>
    Centre
    P
    Texte aligné à droite<<right>>Droite<</right>>
    Droite
    P
    Texte coloré<<color red>>Rouge<</color>>RougeP
    Fond coloré<<bgcolor green>>Vert<</color>>VertP
    Dégradé de texte<<color orange cyan>>Orange à cyan<</color>>Orange à cyanP
    Dégradé de fond<<bgcolor orange cyan>>Orange à cyan<</color>>Orange à cyanP
    Clavier<kbd>Ctrl</kbd> + <kbd>C</kbd>Ctrl + C
    Exemple console<samp>Exemple</samp>Exemple
    Variable maths<var>ab</var> + <var>cd</var> = 42ab + cd = 42
    Texte supprimé<del>supprimé</del>supprimé
    Texte ajouté<ins>ajouté</ins>ajouté
    ExposantTexte<sup>exposant</sup>Texteexposant
    IndiceTexte<sub>indice</sub>Texteindice
    +
    1
    Exemple de note de bas de page
    P
    Indique une syntaxe qui ne fait pas partie du standard Markdown, mais est spécifique à Paheko.
    \ No newline at end of file diff --git a/src/www/admin/static/doc/modules.html b/src/www/admin/static/doc/modules.html new file mode 100644 index 0000000..3490da5 --- /dev/null +++ b/src/www/admin/static/doc/modules.html @@ -0,0 +1,317 @@ + + + + Développer des modules pour Paheko + + + + +

    Introduction

    +

    Depuis la version 1.3, Paheko dispose d'extensions modifiables, nommées Modules.

    +

    Les modules permettent de créer et modifier des formulaires, des modèles de documents simples, à imprimer, mais aussi de créer des "mini-applications" directement dans l'administration de l'association, avec le minimum de code, sans avoir à apprendre à programmer PHP.

    +

    Les modules utilisent le langage Brindille, aussi utilisé pour le site web (qui est lui-même un module). Avec Brindille on parle d'un squelette pour un fichier texte contenant du code Brindille.

    +

    Les modules ne permettent pas d'exécuter du code PHP, ni de modifier la base de données en dehors des données du module, contrairement aux plugins. Grâce à Brindille, les administrateurs de l'association peuvent modifier ou créer de nouveaux modules sans risques pour le serveur, car le code Brindille ne permet pas d'exécuter de fonctions dangereuses. Les plugins eux sont écrits en PHP et ne peuvent pas être modifiés par une association. Du fait des risques de sécurité, seuls les plugins officiels sont proposés sur Paheko.cloud.

    +

    Exemples

    +

    Paheko fournit quelques modules par défaut, qui peuvent être modifiés ou servir d'inspiration pour de nouveaux modules :

    +
      +
    • Reçu de don simple
    • +
    • Reçu de paiement simple
    • +
    • Reçu fiscal
    • +
    • Cartes de membres
    • +
    • Heures d'ouverture
    • +
    • Modèles d'écritures comptables
    • +
    +

    Ces exemples sont développés directement avec Brindille et peuvent être modifiés ou lus depuis le menu Configuration, onglet Extensions.

    +

    Un module fourni dans Paheko peut être modifié, et en cas de problème il peut être remis à son état d'origine.

    +

    D'autres exemples d'utilisation sont imaginables :

    +
      +
    • Auto-remplissage de la déclaration de la liste des dirigeants à la préfecture
    • +
    • Compte de résultat et bilan conforme au modèle du plan comptable
    • +
    • Formulaires partagés entre la partie privée, et le site web (voir par exemple le module "heures d'ouverture")
    • +
    • Gestion de matériel prêté par l'association
    • +
    +

    Pré-requis

    +

    Une connaissance de la programmation informatique est souhaitable pour commencer à modifier ou créer des modules, mais cela n'est pas requis, il est possible d'apprendre progressivement.

    +

    Résumé technique

    +
      +
    • Utilisation de la syntaxe Brindille
    • +
    • Les modules peuvent utiliser toutes les fonctions et boucles de Brindille
    • +
    • Les modules peuvent stocker et récupérer des données dans la base SQLite dans une table clé-valeur spécifique à chaque module
    • +
    • Les données du module sont stockées en JSON, on peut faire des requêtes complètes avec l'extension JSON de SQLite
    • +
    • Les données peuvent être validées avant enregistrement en utilisant JSON Schema
    • +
    • Un module peut également accéder aux données des autres modules
    • +
    • Un module peut aussi accéder à toutes les données de la base de données, sauf certaines données à risque (voir plus bas)
    • +
    • Un module ne peut pas modifier les données de la base de données
    • +
    • Paheko crée automatiquement des index sur les requêtes SQL des modules, permettant de rendre les requêtes rapides
    • +
    +

    Structure des répertoires

    +

    Chaque module a un nom unique (composé uniquement de lettres minuscules, de tirets bas et de chiffres) et dispose d'un sous-répertoire dans le dossier modules. Ainsi le module recu_don serait dans le répertoire modules/recu_don.

    +

    Dans ce répertoire le module peut avoir autant de fichiers qu'il veut, mais certains fichiers ont une fonction spéciale :

    +
      +
    • module.ini : contient les informations sur le module, voir ci-dessous pour les détails
    • +
    • config.html : si ce squelette existe, un bouton "Configurer" apparaîtra dans la liste des modules (Configuration -> Modules) et affichera ce squelette dans un dialogue
    • +
    • icon.svg : icône du module, qui sera utilisée sur la page d'accueil, si le bouton est activé, et dans la liste des modules. Attention l'élément racine du fichier doit porter l'id img pour que l'icône fonctionne (<svg id="img"...>), notamment pour que les couleurs du thème s'appliquent à l'icône.
    • +
    • README.md : si ce fichier existe, son contenu sera affiché dans les détails du module
    • +
    +

    Snippets

    +

    Les modules peuvent également avoir des snippets, ce sont des squelettes qui seront inclus à des endroits précis de l'interface, permettant de rajouter des fonctionnalités, ils sont situés dans le sous-répertoire snippets du module :

    +
      +
    • snippets/transaction_details.html : sera inclus en dessous de la fiche d'une écriture comptable
    • +
    • snippets/transaction_new.html : sera inclus au début du formulaire de saisie d'écriture
    • +
    • snippets/user_details.html : sera inclus en dessous de la fiche d'un membre
    • +
    • snippets/my_details.html : sera inclus en dessous de la page "Mes informations personnelles"
    • +
    • snippets/my_services.html : sera inclus en dessous de la page "Mes inscriptions et cotisations"
    • +
    • snippets/home_button.html : sera inclus dans la liste des boutons de la page d'accueil (ce fichier ne sera pas appelé si home_button est à true dans module.ini, il le remplace)
    • +
    +

    Snippets MarkDown

    +

    Il est également possible, depuis Paheko 1.3.2, d'étendre les fonctionnalités Markdown du site web en créant un snippet dans le répertoire snippets/markdown/, par exemple snippets/markdown/map.html.

    +

    Le snippet sera appelé quand on utilise le tag du même nom dans le contenu du site web. Ici par exemple ça serait <<map>>.

    +

    Le nom du snippet doit commencer par une lettre minuscule et peut être suivi de lettres minuscules, de chiffres, ou de tirets bas. Exemples : map2024 map_openstreetmap, etc.

    +

    Le snippet reçoit ces variables :

    +
      +
    • $params : les paramètres du tag
    • +
    • $block : booléen, TRUE si le tag est seul sur une ligne, ou FALSE s'il se situe à l'intérieur d'un texte
    • +
    • $content : le contenu du bloc, si celui-ci est sur plusieurs lignes
    • +
    +

    Exemple :

    +
    <<map center="Auckland, New Zealand"
    +
    +Ceci est la capitale de Nouvelle-Zélande !
    +>>
    +
    +Voici un marqueur : <<map marker>>
    +

    Dans le premier appel, map.html recevra ces variables :

    +
    $params = ['center' => 'Auckland, New Zealand']
    +$content = "Ceci est la capitale de Nouvelle-Zélande !"
    +$block = TRUE
    +

    Dans le second appel, le snippet recevra celles-ci :

    +
    $params = [0 => 'marker']
    +$content = NULL
    +$block = FALSE
    +

    Fichier module.ini

    +

    Ce fichier décrit le module, au format INI (clé=valeur), en utilisant les clés suivantes :

    +
      +
    • name (obligatoire) : nom du module
    • +
    • description : courte description de la fonctionnalité apportée par le module
    • +
    • author : nom de l'auteur
    • +
    • author_url : adresse web HTTP menant au site de l'auteur
    • +
    • home_button : indique si un bouton pour ce module doit être affiché sur la page d'accueil (true ou false)
    • +
    • menu : indique si ce module doit être listé dans le menu de gauche (true ou false)
    • +
    • restrict_section : indique la section auquel le membre doit avoir accès pour pouvoir voir le menu de ce module, parmi web, documents, users, accounting, connect, config
    • +
    • restrict_level : indique le niveau d'accès que le membre doit avoir dans la section indiquée pour pouvoir voir le menu de ce module, parmi read, write, admin.
    • +
    +

    Attention : les directives restrict_section et restrict_level ne contrôlent que l'affichage du lien vers le module dans le menu et dans les boutons de la page d'accueil, mais pas l'accès aux pages du module.

    +

    Variables spéciales

    +

    Toutes les pages d'un module disposent de la variable $module qui contient l'entité du module en cours :

    +
      +
    • $module.name contient le nom unique (recu_don par exemple)
    • +
    • $module.label le libellé du module
    • +
    • $module.description la description
    • +
    • $module.config la configuration du module
    • +
    • $module.url l'adresse URL du module (https://site-association.tld/m/recu_don/ par exemple)
    • +
    +

    Stockage de données

    +

    Un module peut stocker des données de deux manières : dans sa configuration, ou dans son stockage de documents JSON.

    +

    Configuration

    +

    La première manière est de stocker des informations dans la configuration du module. Pour cela on utilise la fonction save et la clé config :

    +
    {{:save key="config" accounts_list="512A,512B" check_boxes=true}}
    +

    On pourra retrouver ces valeurs dans la variable $module.config :

    +
    {{if $module.config.check_boxes}}
    +  {{$module.config.accounts_list}}
    +{{/if}}
    +

    Stockage de documents JSON

    +

    Chaque module peut stocker ses données dans une base de données clé-document qui stockera les données dans des documents au format JSON dans une table SQLite.

    +

    Grâce aux fonctions JSON de SQLite on pourra ensuite effectuer des recherches sur ces documents.

    +

    Pour enregistrer il suffit d'utiliser la fonction save :

    +
    {{:save key="facture001" type="facture" date="2022-01-01" label="Vente de petits pains au chocolat" total="42"}}
    +

    Si la clé indiquée (dans le paramètre key) n'existe pas, l'enregistrement sera créé, sinon il sera mis à jour avec les valeurs données.

    +

    Validation

    +

    On peut utiliser un schéma JSON pour valider que le document qu'on enregistre est valide :

    +
    {{:save validate_schema="./document.schema.json" type="facture" date="2022-01-01" label="Vente de petits pains au chocolat" total="42"}}
    +

    Le fichier document.schema.json devra être dans le même répertoire que le squelette et devra contenir un schéma valide. Voici un exemple :

    +
    {
    +    "$schema": "https://json-schema.org/draft/2020-12/schema",
    +    "type": "object",
    +    "properties": {
    +        "date": {
    +            "description": "Date d'émission",
    +            "type": "string",
    +            "format": "date"
    +        },
    +        "type": {
    +            "description": "Type de document",
    +            "type": "string",
    +            "enum": ["devis", "facture"]
    +        },
    +        "total": {
    +            "description": "Montant total",
    +            "type": "integer",
    +            "minimum": 0
    +        },
    +        "label": {
    +            "description": "Libellé",
    +            "type": "string"
    +        },
    +        "description": {
    +            "description": "Description",
    +            "type": ["string", "null"]
    +        }
    +    },
    +    "required": [ "type", "date", "total", "label"]
    +}
    +

    Si le document fourni n'est pas conforme au schéma, il ne sera pas enregistré et une erreur sera affichée.

    +

    Propriété non requise

    +

    Si vous souhaitez utiliser dans votre document une propriété non requise, il ne faut pas la fournir en paramètre de la fonction save.

    +

    Si elle est fournie mais vide, il faut aussi autoriser le type null (en minuscules) au type de votre propriété.

    +

    Exemple :

    +
    [...]
    +    "description": {
    +        "description": "Description",
    +        "type": ["string", "null"]
    +    }
    +[...]
    +

    Stockage JSON dans SQLite (pour information)

    +

    Explication du fonctionnement technique derrière la fonction save.

    +

    En pratique chaque enregistrement sera placé dans une table SQL dont le nom commence par module_data_. Ici la table sera donc nommée module_data_factures si le nom unique du module est factures.

    +

    Le schéma de cette table est le suivant :

    +
    CREATE TABLE module_data_factures (
    +  id INTEGER PRIMARY KEY NOT NULL,
    +  key TEXT NULL,
    +  document TEXT NOT NULL
    +);
    +
    +CREATE UNIQUE INDEX module_data_factures_key ON module_data_factures (key);
    +

    Comme on peut le voir, chaque ligne dans la table peut avoir une clé unique (key), et un ID ou juste un ID auto-incrémenté. La clé unique n'est pas obligatoire, mais peut être utile pour différencier certains documents.

    +

    Par exemple le code suivant :

    +
    {{:save key="facture_43" nom="Facture de courses"}}
    +

    Est l'équivalent de la requête SQL suivante :

    +
    INSERT OR REPLACE INTO module_data_factures (key, document) VALUES ('facture_43', '{"nom": "Facture de courses"}');
    +

    Récupération et liste de documents

    +

    Il sera ensuite possible d'utiliser la boucle load pour récupérer les données :

    +
    {{#load id=42}}
    +    Ce document est de type {{$type}} créé le {{$date}}.
    +    <h2>{{$label}}</h2>
    +    À payer : {{$total}} €
    +    {{else}}
    +    Le document numéro 42 n'a pas été trouvé.
    +{{/load}}
    +

    Cette boucle load permet aussi de faire des recherches sur les valeurs du document :

    +
    <ul>
    +{{#load where="$$.type = 'facture'" order="date DESC"}}
    +    <li>{{$label}} ({{$total}} €)</li>
    +{{/load}}
    +</ul>
    +

    La syntaxe $$.type indique d'aller extraire la clé type du document JSON.

    +

    C'est un raccourci pour la syntaxe SQLite json_extract(document, '$.type').

    +

    Export et import de modules

    +

    Il est possible d'exporter un module modifié. Cela créera un fichier ZIP contenant à la fois le code modifié et le code non modifié.

    +

    De la même manière il est possible d'importer un module à partir d'un fichier ZIP d'export. Si vous créez votre fichier ZIP manuellement, attention à respecter le fait que le code du module doit se situer dans le répertoire modules/nom_du_module du fichier ZIP. Tout fichier ou répertoire situé en dehors de cette arborescence provoquera une erreur et l'impossibilité d'importer le module.

    +

    Restrictions

    +
      +
    • Il n'est pas possible de télécharger ou envoyer des données depuis un autre serveur
    • +
    • Il n'est pas possible d'écrire un fichier local
    • +
    +

    Envoi d'e-mail

    +

    Voir la documentation de la fonction {{:mail}}

    +

    Tables et colonnes de la base de données

    +

    Pour des raisons de sécurité, les modules ne peuvent pas accéder à toutes les données de la base de données.

    +

    Les colonnes suivantes de la table users (liste des membres) renverront toujours NULL :

    +
      +
    • password
    • +
    • pgp_key
    • +
    • otp_secret
    • +
    +

    Tenter de lire les données des tables suivantes résultera également en une erreur :

    +
      +
    • emails
    • +
    • emails_queue
    • +
    • compromised_passwords_cache
    • +
    • compromised_passwords_cache_ranges
    • +
    • api_credentials
    • +
    • plugins_signals
    • +
    • config
    • +
    • users_sessions
    • +
    • logs
    • +
    \ No newline at end of file diff --git a/src/www/admin/static/doc/shapes.png b/src/www/admin/static/doc/shapes.png new file mode 100644 index 0000000..1b07c8d Binary files /dev/null and b/src/www/admin/static/doc/shapes.png differ diff --git a/src/www/admin/static/doc/skriv.html b/src/www/admin/static/doc/skriv.html new file mode 100644 index 0000000..33a3088 --- /dev/null +++ b/src/www/admin/static/doc/skriv.html @@ -0,0 +1,251 @@ + + + + Référence rapide SkrivML - Paheko + + + + +

    Syntaxe SkrivML

    +

    Paheko propose la syntaxe SkrivML pour le formatage du texte des pages du site web.

    +

    Styles de texte

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    StyleSyntaxe
    ItaliqueEntourer le texte de ''deux apostrophes''
    GrasEntourer le texte de **deux astérisques**
    Texte SoulignéEntourer le texte de deux __tirets bas__.
    BarréDeux --tirets hauts-- pour barrer.
    Texte ExposantXXI^^ème^^ siècle
    Texte IndiceCO,,2,,
    +

    Attention : ces styles ne fonctionnent que si le code entoure des mots complets, ça ne fonctionne pas au milieu de mots.

    +
    Un **mot** en gras. Mais on ne peut pas cou**per** un mot avec du gras.
    +
    +

    Un mot en gras. Mais on ne peut pas cou**per** un mot avec du gras.

    +
    +

    Titres

    +

    Doivent être précédé d'un ou plusieurs signe égal. Peuvent aussi être suivi du même nombre de signe égal.

    +
    = Titre niveau 1
    +== Titre niveau 2
    +=== Titre niveau 3 ===
    +==== Titre de niveau 4 ====
    +

    Listes

    +

    Listes non ordonnées :

    +
    * Item 1
    +* Item 2
    +** Sous-item 2.1
    +** Sous-item 2.2
    +*** Sous-item 2.2.1
    +

    Listes ordonnées :

    +
    # Item 1
    +# Item 2
    +## Sub-item 2.1
    +## Sub-item 2.2
    +### Sub-item 2.2.1
    +

    Liens

    +

    Lien interne :

    +
    Voir [[cette page|adresse-unique-autre-page]].
    +

    Lien externe :

    +
    [[https://paheko.cloud/]]
    +

    Tableaux

    +
    !! Colonne 1 !! Colonne 2
    +|| Cellule 1 || Cellule 2
    +|| Cellule 3 || Cellule 4
    +

    Autres

    +

    Consulter la documentation complète de SkrivML.

    +

    Extensions

    +

    Toutes les extensions se présentent sous la forme d'un code situé entre deux signes inférieur à (<<) et deux signes supérieur à (>>), à ne pas confondre avec les guillements français (« et »).

    +

    Images jointes

    +

    Il est possible d'intégrer une image jointe à la page web en plaçant le code suivant sur une ligne (sans autre texte) :

    +
    <<image|Nom_fichier.jpg|Alignement|Légende>>
    +
      +
    • Nom_fichier.jpg : remplacer par le nom du fichier de l'image (parmi les images jointes à la page)
    • +
    • Alignement : remplacer par l'alignement :
        +
      • gauche ou left : l'image sera placée à gauche en petit (200 pixels), le texte remplira l'espace laissé sur la droite de l'image ;
      • +
      • droite ou right : l'image sera placée à droite en petit, le texte remplira l'espace laissé sur la gauche de l'image ;
      • +
      • centre ou center : l'image sera placée au centre en taille moyenne (500 pixels), le texte sera placé au dessus et en dessous.
      • +
      +
    • +
    • Légende : indiquer ici une courte description de l'image.
    • +
    +

    Exemple :

    +
    <<image|mon_image.png|center|Ceci est une belle image>>
    +

    Il est aussi possible d'utiliser la syntaxe avec des paramètres nommés :

    +
    <<image file="Nom_fichier.jpg" align="center" caption="Légende">>
    +

    Les images qui ne sont pas mentionnées dans le texte seront affichées après le texte sous forme de galerie.

    +

    Fichiers joints

    +

    Pour créer un bouton permettant de voir ou télécharger un fichier joint à la page web, il suffit d'utiliser la syntaxe suivante :

    +
    <<file|Nom_fichier.ext|Libellé>>
    +
      +
    • Nom_fichier.ext : remplacer par le nom du fichier (parmi les fichiers joints à la page)
    • +
    • Libellé : indique le libellé du qui sera affiché sur le bouton, si aucun libellé n'est indiqué alors c'est le nom du fichier qui sera affiché
    • +
    +

    Raccourcis clavier

    +

    Depuis l'édition du texte :

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    RaccourciAction
    Ctrl + GMettre en gras
    Ctrl + IMettre en italique
    Ctrl + TMettre en titre
    Ctrl + LTransformer en lien
    Ctrl + Shift + IInsérer une image
    Ctrl + Shift + FInsérer un fichier
    Ctrl + PPrévisualiser
    Ctrl + SEnregistrer
    F11Activer ou désactiver l'édition plein écran
    F1Afficher l'aide
    EchapPrévisualiser (rappuyer pour revenir à l'édition)
    +

    Depuis la prévisualisation :

    + + + + + + + + + + + + + +
    RaccourciAction
    Ctrl + PRetour à l'édition
    +

    Depuis l'aide ou l'insertion de fichier :

    + + + + + + + + + + + + + +
    RaccourciAction
    EchapFermer et revenir à l'édition
    \ No newline at end of file diff --git a/src/www/admin/static/doc/web.html b/src/www/admin/static/doc/web.html new file mode 100644 index 0000000..ca5d857 --- /dev/null +++ b/src/www/admin/static/doc/web.html @@ -0,0 +1,136 @@ + + + + Squelettes du site web dans Paheko + + + + +
    +

    Les squelettes du site web

    +

    Les squelettes sont un ensemble de fichiers qui permettent de modéliser l'apparence du site web selon ses préférences et besoins.

    +

    La syntaxe utilisée dans les squelettes s'appelle Brindille. Voir la documentation de Brindille pour son fonctionnement.

    +

    Exemples de sites réalisés avec Paheko

    + +

    Fonctionnement des squelettes

    +

    Par défaut sont fournis plusieurs squelettes qui permettent d'avoir un site web basique mais fonctionnel : page d'accueil, menu avec les catégories de premier niveau, et pour afficher les pages, les catégories, les fichiers joints et images. Il y a également un squelette atom.xml permettant aux visiteurs d'accéder aux dernières pages publiées.

    +

    Les squelettes peuvent être modifiés via l'onglet Configuration de la section Site web du menu principal.

    +

    Une fois un squelette modifié, il apparaît dans la liste comme étant modifié, sinon il apparaît comme défaut. Si vous avez commis une erreur, il est possible de restaurer le squelette d'origine.

    +

    Adresses des pages du site

    +

    Les squelettes sont appelés en fonction des règles suivantes (dans l'ordre) :

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    Squelette appeléCas où le squelette est appelé
    adresseSi l'adresse adresse est appelée, et qu'un squelette du même nom existe
    adresse/index.htmlSi l'adresse adresse/ est appelée, et qu'un squelette index.html dans le répertoire du même nom existe
    category.htmlToute autre adresse se terminant par un slash /, si une catégorie du même nom existe
    article.htmlToute autre adresse, si une page du même nom existe
    404.htmlSi aucune règle précédente n'a fonctionné
    +

    Ainsi l'adresse https://monsite.paheko.cloud/Actualite/ appellera le squelette category.html, mais l'adresse https://monsite.paheko.cloud/Actualite (sans slash à la fin) appellera le squelette article.html si un article avec l'URI Actualite existe. Si un squelette Actualite (sans extension) existe, c'est lui qui sera appelé en priorité et ni category.html ni article.html ne seront appelés.

    +

    Autre exemple : https://monsite.paheko.cloud/atom.xml appellera le squelette atom.xml s'il existe.

    +

    Ceci vous permet de créer de nouvelles pages dynamiques sur le site, par exemple pour notre atelier vélo nous avons une page https://larustine.org/velos qui appelle le squelette velos (sans extension), qui va afficher la liste des vélos actuellement en stock dans notre hangar.

    +

    Le type de fichier étant déterminé selon l'extension (.html, .css, etc.) pour les fichiers traités par Brindille, un fichier sans extension sera considéré comme un fichier texte par le navigateur. Si on veut que le squelette velos (sans extension) s'affiche comme du HTML il faut forcer le type en mettant le code {{:http type="text/html"}} au début du squelette (première ligne).

    +

    Fichier content.css

    +

    Ce fichier est particulier, car il définit le style du contenu des pages et des catégories.

    +

    Ainsi il est également utilisé quand vous éditez un contenu dans l'administration. Donc si vous souhaitez modifier le style d'un élément du texte, il vaux mieux modifier ce fichier, sinon le rendu sera différent entre l'administration et le site public.

    +

    Cache

    +

    Depuis la version 1.3, Paheko dispose d'un cache statique du site web.

    +

    Cela veut dire que les pages du site web sont enregistrées sous la forme de fichiers HTML statiques, et le serveur web renvoie directement ce fichier sans faire appel à Paheko et son code PHP.

    +

    Les fichiers liés aux pages web sont également mis en cache de cette manière, en utilisant des liens symboliques.

    +

    Ce cache permet d'avoir un site web très rapide, même s'il reçoit des millions de visites.

    +

    Désactiver le cache

    +

    Le seul inconvénient c'est qu'une page mise en cache étant statique, si vous utilisez du contenu dynamique (par exemple afficher un texte différent selon la langue du visiteur) dans le squelette Brindille, alors cela ne fonctionnera plus.

    +

    Dans ce cas-là, vous pouvez assigner la variable nocache dans le squelette pour désactiver le cache pour cette page :

    +
    {{:assign nocache=true}}
    +

    Pour permettre des usages du type "affichage en temps presque réel des horaires d'ouverture", le cache d'une page HTML est effacé et remis à jour au bout d'une heure.

    +

    Exceptions

    +

    Il est à noter que le cache n'est pas appelé dans les cas suivants :

    +
      +
    • si la requête vers la page est d'un autre type que GET ou HEAD, ainsi par exemple l'envoi d'un formulaire (POST) ne sera jamais mis en cache ;
    • +
    • si la requête vers la page contient des paramètres dans l'adresse (par exemple velos.html?list=1 : cette page ne sera pas mise en cache) ;
    • +
    • si le visiteur est connecté à l'administration de l'association. Ainsi si vous avez des parties du squelette qui varient en fonction de si la personne est connectée, le cache ne posera pas de problème.
    • +
    +

    Le cache est intégralement effacé à chaque modification du site web.

    +

    Le cache ne concerne que les pages et fichiers du site web public. Il ne concerne pas les modules, les extensions, ou l'administration.

    +

    Attention :

    +
      +
    • avec un serveur sous Windows, le cache est désactivé car Windows ne sait pas gérer les liens symboliques ;
    • +
    • seul Apache sait gérer le cache statique, le cache est désactivé avec les autres serveurs web (nginx, etc.).
    • +
    \ No newline at end of file diff --git a/src/www/admin/static/favicon.png b/src/www/admin/static/favicon.png new file mode 100644 index 0000000..5155267 Binary files /dev/null and b/src/www/admin/static/favicon.png differ diff --git a/src/www/admin/static/font/config.json b/src/www/admin/static/font/config.json new file mode 100644 index 0000000..4a77e4d --- /dev/null +++ b/src/www/admin/static/font/config.json @@ -0,0 +1,602 @@ +{ + "name": "paheko", + "css_prefix_text": "icn-", + "css_use_suffix": false, + "hinting": true, + "units_per_em": 1000, + "ascent": 850, + "glyphs": [ + { + "uid": "e45a3da2ebde8bc8e30a873f3bd51f30", + "css": "eye", + "code": 128065, + "src": "elusive" + }, + { + "uid": "d218294e6f9f7191f6b0b3d1ff6239ff", + "css": "eye-off", + "code": 10539, + "src": "elusive" + }, + { + "uid": "484363b699ea1b5c2f827fc0f62f4dca", + "css": "search", + "code": 128269, + "src": "custom_icons", + "selected": true, + "svg": { + "path": "M643 464Q643 361 569 288T393 214 216 288 143 464 216 641 393 714 569 641 643 464ZM929 929Q929 958 907 979T857 1000Q827 1000 807 979L616 788Q516 857 393 857 313 857 240 826T115 742 31 617 0 464 31 312 115 186 240 102 393 71 545 102 671 186 755 312 786 464Q786 587 717 687L908 878Q929 899 929 929Z", + "width": 928.6 + }, + "search": [ + "search" + ] + }, + { + "uid": "d9d608c26fff5d1d75dc959f185f034d", + "css": "check", + "code": 9745, + "src": "custom_icons", + "selected": true, + "svg": { + "path": "M786 519V696Q786 763 739 810T625 857H161Q94 857 47 810T0 696V232Q0 166 47 119T161 71H625Q660 71 690 85 699 89 700 98 702 108 695 114L668 142Q662 147 655 147 653 147 650 146 637 143 625 143H161Q124 143 98 169T71 232V696Q71 733 98 759T161 786H625Q662 786 688 759T714 696V555Q714 547 719 542L755 507Q761 501 768 501 771 501 775 503 786 507 786 519ZM915 246L460 700Q447 714 429 714T397 700L157 460Q143 447 143 429T157 397L218 335Q232 322 250 322T282 335L429 482 790 121Q803 108 821 108T853 121L915 182Q928 196 928 214T915 246Z", + "width": 928.6 + }, + "search": [ + "check" + ] + }, + { + "uid": "67dabb31f430a26bd27c25db8daa105b", + "css": "user", + "code": 128100, + "src": "custom_icons", + "selected": true, + "svg": { + "path": "M786 784Q786 851 745 890T637 929H149Q82 929 41 890T0 784Q0 754 2 726T10 665 25 605 49 550 83 505 131 475 193 464Q198 464 217 476T258 503 318 530 393 542 467 530 528 503 569 476 593 464Q627 464 655 475T703 505 737 550 761 605 776 665 784 726 786 784ZM607 286Q607 374 544 437T393 500 241 437 179 286 241 134 393 71 544 134 607 286Z", + "width": 785.7 + }, + "search": [ + "user" + ] + }, + { + "uid": "9f9457b7ef8733c515e852b738277774", + "css": "users", + "code": 128106, + "src": "custom_icons", + "selected": true, + "svg": { + "path": "M331 500Q241 503 183 571H108Q63 571 31 549T0 483Q0 286 69 286 73 286 94 297T148 321 214 333Q252 333 289 320 286 341 286 357 286 435 331 500ZM929 856Q929 922 888 961T780 1000H292Q224 1000 184 961T143 856Q143 826 145 798T153 737 167 676 191 622 226 577 274 547 336 536Q342 536 360 548T401 574 460 601 536 613 611 601 671 574 712 548 735 536Q770 536 798 547T845 577 880 622 904 676 919 737 927 798 929 856ZM357 143Q357 202 315 244T214 286 113 244 71 143 113 42 214 0 315 42 357 143ZM750 357Q750 446 687 509T536 571 384 509 321 357 384 206 536 143 687 206 750 357ZM1071 483Q1071 526 1040 549T963 571H888Q831 503 741 500 786 435 786 357 786 341 783 320 820 333 857 333 890 333 924 321T978 297 1002 286Q1071 286 1071 483ZM1000 143Q1000 202 958 244T857 286 756 244 714 143 756 42 857 0 958 42 1000 143Z", + "width": 1071.4 + }, + "search": [ + "users" + ] + }, + { + "uid": "13e9b9898958bd06a926490ec4c78b18", + "css": "delete", + "code": 10008, + "src": "custom_icons", + "selected": true, + "svg": { + "path": "M724 738Q724 760 709 776L633 852Q617 867 595 867T557 852L393 687 229 852Q213 867 191 867T153 852L77 776Q61 760 61 738T77 700L241 536 77 372Q61 356 61 334T77 296L153 220Q169 204 191 204T229 220L393 384 557 220Q573 204 595 204T633 220L709 296Q724 311 724 334T709 372L545 536 709 700Q724 715 724 738Z", + "width": 785.7 + }, + "search": [ + "delete" + ] + }, + { + "uid": "9856e9f63a26ab792b90c46148459ad7", + "css": "plus", + "code": 10133, + "src": "custom_icons", + "selected": true, + "svg": { + "path": "M786 411V518Q786 540 770 556T732 571H500V804Q500 826 484 841T446 857H339Q317 857 301 841T286 804V571H54Q31 571 16 556T0 518V411Q0 388 16 373T54 357H286V125Q286 103 301 87T339 71H446Q469 71 484 87T500 125V357H732Q754 357 770 373T786 411Z", + "width": 785.7 + }, + "search": [ + "plus" + ] + }, + { + "uid": "564500b6ab771051a66101066706e552", + "css": "minus", + "code": 10134, + "src": "custom_icons", + "selected": true, + "svg": { + "path": "M786 411V518Q786 540 770 556T732 571H54Q31 571 16 556T0 518V411Q0 388 16 373T54 357H732Q754 357 770 373T786 411Z", + "width": 785.7 + }, + "search": [ + "minus" + ] + }, + { + "uid": "ef59059d1776e2b5df2cdb61ad02a5c2", + "css": "help", + "code": 10067, + "src": "custom_icons", + "selected": true, + "svg": { + "path": "M393 701V835Q393 844 386 851T371 857H237Q228 857 221 851T214 835V701Q214 692 221 685T237 679H371Q379 679 386 685T393 701ZM569 366Q569 396 561 422T541 465 510 498 478 523 444 542Q421 555 406 579T391 616Q391 626 384 634T368 643H234Q226 643 220 633T214 612V586Q214 540 251 499T330 439Q363 424 377 407T391 365Q391 342 365 324T305 306Q269 306 245 322 225 336 185 386 178 395 168 395 161 395 154 391L63 321Q55 315 54 307T57 291Q146 143 316 143 361 143 406 160T487 206 546 278 569 366Z", + "width": 571.4 + }, + "search": [ + "help" + ] + }, + { + "uid": "e03a3396ad9ce1b09a85c28955799d92", + "css": "home", + "code": 8962, + "src": "custom_icons", + "selected": true, + "svg": { + "path": "M786 554V821Q786 836 775 847T750 857H536V643H393V857H179Q164 857 154 847T143 821V554Q143 553 143 552T143 550L464 286 785 550Q786 551 786 554ZM910 515L876 556Q871 561 864 562H862Q855 562 850 559L464 237 78 559Q71 563 65 563 58 561 53 556L18 515Q14 510 15 502T21 490L422 156Q440 141 464 141T507 156L643 270V161Q643 153 648 148T661 143H768Q776 143 781 148T786 161V388L908 490Q913 495 914 502T910 515Z", + "width": 928.6 + }, + "search": [ + "home" + ] + }, + { + "uid": "496e662a65eb5759e19b1c5d2e58a060", + "css": "attach", + "code": 128206, + "src": "custom_icons", + "selected": true, + "svg": { + "path": "M783 773Q783 838 739 882T630 926Q555 926 499 871L65 437Q2 373 2 286 2 198 64 136T214 74Q302 74 366 137L704 475Q709 480 709 487 709 496 692 513T666 530Q659 530 653 525L315 186Q271 143 214 143 155 143 114 185T74 286Q74 344 116 387L549 820Q584 855 630 855 666 855 689 832T713 773Q713 727 677 692L353 368Q339 354 320 354 304 354 293 365T282 392Q282 410 296 425L525 653Q531 659 531 666 531 675 513 692T487 709Q480 709 475 704L246 475Q211 441 211 392 211 346 243 314T320 282Q369 282 403 318L728 642Q783 696 783 773Z", + "width": 785.7 + }, + "search": [ + "attach" + ] + }, + { + "uid": "e5c7ed71e800e995ddfc3a195924913b", + "css": "lock", + "code": 128274, + "src": "custom_icons", + "selected": true, + "svg": { + "path": "M179 429H464V321Q464 262 422 220T321 179 220 220 179 321V429ZM643 482V804Q643 826 627 841T589 857H54Q31 857 16 841T0 804V482Q0 460 16 444T54 429H71V321Q71 219 145 145T321 71 498 145 571 321V429H589Q612 429 627 444T643 482Z", + "width": 642.9 + }, + "search": [ + "lock" + ] + }, + { + "uid": "f2a84f66f2d360b9c559376a275261ae", + "css": "mail", + "code": 9993, + "src": "custom_icons", + "selected": true, + "svg": { + "path": "M1000 396V839Q1000 876 974 902T911 929H89Q53 929 26 902T0 839V396Q25 424 56 445 258 582 334 637 366 661 385 674T438 701 499 714H501Q529 714 562 701T615 674 666 637Q761 569 944 445 976 423 1000 396ZM1000 232Q1000 276 973 316T905 385Q695 531 643 566 638 570 620 583T590 605 561 623 528 638 501 643H499Q487 643 472 638T439 623 410 605 380 583 357 566Q306 531 210 465T96 385Q61 362 31 321T0 244Q0 201 23 172T89 143H911Q947 143 973 169T1000 232Z", + "width": 1000 + }, + "search": [ + "mail" + ] + }, + { + "uid": "49aab36ecf027cc8608f81597c83d2d4", + "css": "download", + "code": 8659, + "src": "custom_icons", + "selected": true, + "svg": { + "path": "M714 750Q714 735 704 725T679 714 653 725 643 750 653 775 679 786 704 775 714 750ZM857 750Q857 735 847 725T821 714 796 725 786 750 796 775 821 786 847 775 857 750ZM929 625V804Q929 826 913 841T875 857H54Q31 857 16 841T0 804V625Q0 603 16 587T54 571H313L388 647Q421 679 464 679T540 647L616 571H875Q897 571 913 587T929 625ZM747 307Q757 330 739 347L489 597Q479 607 464 607T439 597L189 347Q172 330 181 307 191 286 214 286H357V36Q357 21 368 11T393 0H536Q550 0 561 11T571 36V286H714Q738 286 747 307Z", + "width": 928.6 + }, + "search": [ + "download" + ] + }, + { + "uid": "1ab77fb1c37bad79af7268c79a11b14f", + "css": "edit", + "code": 9998, + "src": "custom_icons", + "selected": true, + "svg": { + "path": "M203 857L253 806 122 675 71 726V786H143V857H203ZM494 339Q494 327 482 327 477 327 473 331L170 633Q166 637 166 643 166 655 179 655 184 655 188 651L491 349Q494 345 494 339ZM464 232L696 464 232 929H0V696ZM845 286Q845 315 825 336L732 429 500 196 593 104Q613 83 643 83 672 83 694 104L825 235Q845 257 845 286Z", + "width": 857.1 + }, + "search": [ + "edit" + ] + }, + { + "uid": "7091435c31f7a593060b9782a234db80", + "css": "menu", + "code": 119650, + "src": "custom_icons", + "selected": true, + "svg": { + "path": "M857 750V821Q857 836 847 846T821 857H36Q21 857 11 846T0 821V750Q0 735 11 725T36 714H821Q836 714 847 725T857 750ZM857 464V536Q857 550 847 561T821 571H36Q21 571 11 561T0 536V464Q0 450 11 439T36 429H821Q836 429 847 439T857 464ZM857 179V250Q857 265 847 275T821 286H36Q21 286 11 275T0 250V179Q0 164 11 153T36 143H821Q836 143 847 153T857 179Z", + "width": 857.1 + }, + "search": [ + "menu" + ] + }, + { + "uid": "08d8ae2e00462d737ce43e77738eee17", + "css": "settings", + "code": 9784, + "src": "custom_icons", + "selected": true, + "svg": { + "path": "M571 500Q571 441 530 399T429 357 328 399 286 500 328 601 429 643 530 601 571 500ZM857 439V563Q857 570 853 576T842 583L738 599Q728 629 717 650 736 677 776 727 782 733 782 740T777 753Q762 774 722 814T669 853Q662 853 655 848L578 788Q553 801 527 809 518 885 511 913 507 929 491 929H367Q359 929 353 924T347 912L331 809Q304 800 281 788L202 848Q196 853 188 853 180 853 174 847 104 783 82 753 78 748 78 740 78 734 83 728 91 716 111 691T141 651Q126 623 118 596L16 581Q9 580 5 574T0 561V437Q0 430 5 424T15 417L119 401Q127 376 141 350 118 318 81 273 75 266 75 259 75 254 80 247 95 227 135 187T188 147Q195 147 203 152L280 212Q304 199 330 191 339 115 347 87 350 72 367 72H491Q498 72 504 76T511 88L526 191Q554 200 576 212L656 152Q661 147 669 147 676 147 683 152 755 219 775 247 779 252 779 260 779 266 775 272 766 284 746 310T716 349Q730 377 739 404L841 419Q848 420 853 426T857 439Z", + "width": 857.1 + }, + "search": [ + "settings" + ] + }, + { + "uid": "b2af04a5076126cde5606aed07cfd647", + "css": "down", + "code": 8595, + "src": "custom_icons", + "selected": true, + "svg": { + "path": "M571 393Q571 407 561 418L311 668Q300 679 286 679T261 668L11 418Q0 407 0 393T11 368 36 357H536Q550 357 561 368T571 393Z", + "width": 571.4 + }, + "search": [ + "down" + ] + }, + { + "uid": "9a8f85e9fc8643b2587509f0e4b6cd1e", + "css": "up", + "code": 8593, + "src": "custom_icons", + "selected": true, + "svg": { + "path": "M571 679Q571 693 561 704T536 714H36Q21 714 11 704T0 679 11 653L261 403Q271 393 286 393T311 403L561 653Q571 664 571 679Z", + "width": 571.4 + }, + "search": [ + "up" + ] + }, + { + "uid": "fe71ca02f0de1b3624d28043ddb30c85", + "css": "unlock", + "code": 128275, + "src": "custom_icons", + "selected": true, + "svg": { + "path": "M929 321V464Q929 479 918 489T893 500H857Q843 500 832 489T821 464V321Q821 262 780 220T679 179 578 220 536 321V429H589Q612 429 627 444T643 482V804Q643 826 627 841T589 857H54Q31 857 16 841T0 804V482Q0 460 16 444T54 429H429V321Q429 218 502 145T679 71 855 145 929 321Z", + "width": 928.6 + }, + "search": [ + "unlock" + ] + }, + { + "uid": "56000699168af06774cd3f1646acbd5f", + "css": "logout", + "code": 10525, + "src": "custom_icons", + "selected": true, + "svg": { + "path": "M857 500Q857 587 823 666T732 803 595 895 429 929 262 895 126 803 34 666 0 500Q0 398 45 309T171 158Q195 140 225 144T271 172Q289 195 285 225T257 272Q203 313 173 373T143 500Q143 558 165 611T227 702 318 763 429 786 539 763 631 702 692 611 714 500Q714 432 684 373T600 272Q576 254 572 225T586 172Q603 148 633 144T686 158Q767 219 812 309T857 500ZM500 71V429Q500 458 479 479T429 500 378 479 357 429V71Q357 42 378 21T429 0 479 21 500 71Z", + "width": 857.1 + }, + "search": [ + "logout" + ] + }, + { + "uid": "474656633f79ea2f1dad59ff63f6bf07", + "css": "star", + "code": 9733, + "src": "fontawesome" + }, + { + "uid": "f8aa663c489bcbd6e68ec8147dca841e", + "css": "folder", + "code": 128448, + "src": "fontawesome" + }, + { + "uid": "178053298e3e5b03551d754d4b9acd8b", + "css": "document", + "code": 128453, + "src": "fontawesome" + }, + { + "uid": "f9c3205df26e7778abac86183aefdc99", + "css": "reset", + "code": 8634, + "src": "fontawesome" + }, + { + "uid": "eeec3208c90b7b48e804919d0d2d4a41", + "css": "upload", + "code": 8657, + "src": "fontawesome" + }, + { + "uid": "390d6d13398cbf8c8c3c5493f7d34088", + "css": "export", + "code": 8631, + "src": "entypo" + }, + { + "uid": "531bc468eecbb8867d822f1c11f1e039", + "css": "calendar", + "code": 128197, + "src": "fontawesome" + }, + { + "uid": "4b900d04e8ab8c82f080c1cfbac5772c", + "css": "uncheck", + "code": 9744, + "src": "fontawesome" + }, + { + "uid": "422e07e5afb80258a9c4ed1706498f8a", + "css": "radio-unchecked", + "code": 9711, + "src": "fontawesome" + }, + { + "uid": "81bb68665e8e595505272a746db07c7a", + "css": "radio-checked", + "code": 11044, + "src": "fontawesome" + }, + { + "uid": "0a431e7db6907fe4d22f4a4069374e4a", + "css": "text", + "code": 84, + "src": "custom_icons", + "selected": true, + "svg": { + "path": "M0 0V250H62.5C62.5 181.3 118.8 125 187.5 125H375V812.5C375 847.5 347.5 875 312.5 875H250V1000H750V875H687.5C652.5 875 625 847.5 625 812.5V125H812.5C881.3 125 937.5 181.3 937.5 250H1000V0Z", + "width": 1000 + }, + "search": [ + "text" + ] + }, + { + "uid": "c5845105a87df2ee1999826d90622f6a", + "css": "paragraph", + "code": 167, + "src": "fontawesome" + }, + { + "uid": "a2a74f5e7b7d9ba054897d8c795a326a", + "css": "list-ul", + "code": 8226, + "src": "fontawesome" + }, + { + "uid": "f6766a8b042c2453a4e153af03294383", + "css": "list-ol", + "code": 49, + "src": "fontawesome" + }, + { + "uid": "0c708edd8fae2376b3370aa56d40cf9e", + "css": "header", + "code": 72, + "src": "fontawesome" + }, + { + "uid": "a8cb1c217f02b073db3670c061cc54d2", + "css": "italic", + "code": 73, + "src": "fontawesome" + }, + { + "uid": "8fb55fd696d9a0f58f3b27c1d8633750", + "css": "table", + "code": 9707, + "src": "fontawesome" + }, + { + "uid": "cc1de8eafc95f6faffcd8683aa8e9aa1", + "css": "bold", + "code": 66, + "src": "elusive" + }, + { + "uid": "381da2c2f7fd51f8de877c044d7f439d", + "css": "image", + "code": 128443, + "src": "fontawesome" + }, + { + "uid": "d870630ff8f81e6de3958ecaeac532f2", + "css": "left", + "code": 8592, + "src": "fontawesome" + }, + { + "uid": "399ef63b1e23ab1b761dfbb5591fa4da", + "css": "right", + "code": 8594, + "src": "fontawesome" + }, + { + "uid": "ae4eb744170ff9a371afde2093bcd733", + "css": "del-col", + "code": 129940, + "src": "custom_icons", + "selected": true, + "svg": { + "path": "M0-35.7L0 961.5 306.8 961.5 306.8-35.7 0-35.7ZM383.5-35.7L383.5 961.4 1071 961.4 1071-35.7 383.5-35.7ZM600.7 219.5L744.3 363.3 888.1 219.5 992.7 324.1 848.9 467.9 992.7 611.6 888.1 716.2 744.3 572.5 600.7 716.2 496.1 611.6 639.7 467.9 496.1 324.1 600.7 219.5Z", + "width": 1074 + }, + "search": [ + "del-col" + ] + }, + { + "uid": "7034e4d22866af82bef811f52fb1ba46", + "css": "code", + "code": 60, + "src": "fontawesome" + }, + { + "uid": "53a4e0148b78afaa94955457773f48c2", + "css": "col", + "code": 9626, + "src": "custom_icons", + "selected": true, + "svg": { + "path": "M0-35.7L0 961.5 306.8 961.5 306.8-35.7 0-35.7ZM383.5-35.7L383.5 961.4 1071 961.4 1071-35.7 383.5-35.7Z", + "width": 1074 + }, + "search": [ + "add-col" + ] + }, + { + "uid": "c3e5dafba1739ef33cc574c7484febf7", + "css": "quote", + "code": 171, + "src": "entypo" + }, + { + "uid": "a73c5deb486c8d66249811642e5d719a", + "css": "reload", + "code": 128472, + "src": "fontawesome" + }, + { + "uid": "47a1f80457068fbeab69fdb83d7d0817", + "css": "video", + "code": 9654, + "src": "fontawesome" + }, + { + "uid": "dd492243d64e21dfe16a92452f7861cb", + "css": "gallery", + "code": 128444, + "src": "fontawesome" + }, + { + "uid": "bf3c88e1a2208a0cf26001e0793ff403", + "css": "print", + "code": 9113, + "src": "custom_icons", + "selected": true, + "svg": { + "path": "M214 857H714V714H214V857ZM214 500H714V286H625Q603 286 587 270T571 232V143H214V500ZM857 536Q857 521 847 511T821 500 796 511 786 536 796 561 821 571 847 561 857 536ZM929 536V768Q929 775 923 780T911 786H786V875Q786 897 770 913T732 929H196Q174 929 159 913T143 875V786H18Q11 786 5 780T0 768V536Q0 492 32 460T107 429H143V125Q143 103 159 87T196 71H571Q594 71 621 83T663 109L748 194Q763 210 775 237T786 286V429H821Q866 429 897 460T929 536Z", + "width": 928.6 + }, + "search": [ + "print" + ] + }, + { + "uid": "4d879892b0e3a0da5d871e3df8d105e2", + "css": "alert", + "code": 9888, + "src": "custom_icons", + "selected": true, + "svg": { + "path": "M571 767V661Q571 653 566 648T554 643H446Q439 643 434 648T429 661V767Q429 775 434 780T446 786H554Q561 786 566 780T571 767ZM570 559L580 302Q580 296 575 292 568 286 561 286H439Q432 286 425 292 420 296 420 304L429 559Q429 564 435 568T448 571H551Q559 571 564 568T570 559ZM563 37L991 823Q1011 858 990 893 980 910 964 919T929 929H71Q53 929 36 919T10 893Q-11 858 9 823L438 37Q447 20 464 10T500 0 536 10 563 37Z", + "width": 1000 + }, + "search": [ + "alert" + ] + }, + { + "uid": "a29e53e4b5a383b4e4591cbba3f3fe1c", + "css": "markdown", + "code": 77, + "src": "custom_icons", + "selected": true, + "svg": { + "path": "M234.4 765.6V234.4H390.6L546.9 429.7 703.1 234.4H859.4V765.6H703.1V460.9L546.9 656.3 390.6 460.9V765.6ZM1210.9 765.6L976.6 507.8H1132.8V234.4H1289.1V507.8H1445.3Z", + "width": 1625 + }, + "search": [ + "markdown-mark" + ] + }, + { + "uid": "i6ej1r6t84xouh0dct7g9zyx3ya9s9eg", + "css": "globe", + "code": 127757, + "src": "modernpics" + }, + { + "uid": "92f93f22074943bf7c53782a46faf86f", + "css": "money", + "code": 8364, + "src": "custom_icons", + "selected": true, + "svg": { + "path": "M831.2 104.2L765.4 238.1Q711.8 183.9 593.7 183.9 422.7 183.9 359.6 378.2H703.7L666.8 472.2H340.2Q338.1 497.1 338.1 521.4 338.1 546.9 340.2 571.2H623.9L587.7 665.2H358.9Q415.9 839.6 581.6 839.6 715.8 839.6 779.5 750.6V921.2Q700.4 983.4 568.9 983.4 411.9 983.4 312 895 219.4 812.8 187.9 665.2H102.7V571.2H174.5Q170.4 518.9 173.8 472.2H102.7V378.2H187.9Q222.8 231.2 321.4 140.3 429.4 40.1 583.6 40.1T831.2 104.2Z", + "width": 1000 + }, + "search": [ + "euro-svgrepo-com-(1)" + ] + }, + { + "uid": "f9cd6e558fac8be365aacaa4c11acc75", + "css": "import", + "code": 8630, + "src": "custom_icons", + "selected": true, + "svg": { + "path": "M129.9 947.8C70.8 939.7 21.5 895.6 4.5 835.8 1.2 823.9 1 808.6 1 544S1.2 264 4.5 252.1C21.6 191.9 69.8 149.1 130.1 140.7 138.9 139.5 189.1 138.7 256 138.7H367.2V246.1H252.8C151.5 246.1 137.8 246.5 132.2 249.3 123.4 253.7 115.6 261.4 110.7 270.5L106.4 278.3 105.9 538.1C105.6 715.9 106.1 801 107.5 807.9 109.9 819.8 119.6 832.1 131.6 838.5L139.6 842.8H670.9L678.7 838.6C683 836.3 688.9 831.9 691.8 828.8 703.4 816.6 703.1 819.5 703.1 691.5V574.2H810.8L810.1 697.8C809.4 817 809.3 821.8 805.2 836.2 791.8 884 758 921.9 713.6 938.7 685.6 949.3 692.4 949.1 403.3 948.9 256.1 948.8 133.1 948.3 129.9 947.8ZM992.5 660.2C982.8 624.3 967.3 589.8 946.5 558.6 927.1 529.3 887.6 490.2 857.4 470.1 806.7 436.6 740.4 413.2 683.5 408.8L668 407.6 667 559 509.3 432.8C422.5 363.4 351.6 306.2 351.6 305.7S422.5 247.9 509.3 178.5L667 52.3 669 203.6 694.4 210C724 217.5 747.2 226.1 774.4 239.8 914 310 1006.4 468.3 999.2 625 998.5 640.6 997.2 656.8 996.3 661.1L994.8 668.9Z", + "width": 1000 + }, + "search": [ + "import" + ] + }, + { + "uid": "3a00327e61b997b58518bd43ed83c3df", + "css": "login", + "code": 8677, + "src": "fontawesome" + }, + { + "uid": "247dd465b159268d39bfff7678033172", + "css": "pdf", + "code": 80, + "src": "custom_icons", + "selected": true, + "svg": { + "path": "M722.5 191.2L558.6 27.3C541 9.8 517.2-0.2 492.4-0.2H93.8C42 0 0 42 0 93.8V906.3C0 958 42 1000 93.8 1000H656.3C708 1000 750 958 750 906.3V257.6C750 232.8 740 208.8 722.5 191.2ZM648.6 250H500V101.4ZM93.7 906.3V93.8H406.2V296.9C406.2 322.9 427.1 343.8 453.1 343.8H656.2V906.3ZM582.4 625.6C558.6 602.1 490.6 608.6 456.6 612.9 423 592.4 400.6 564.1 384.8 522.5 392.4 491 404.5 443.2 395.3 413.1 387.1 361.9 321.5 367 312.1 401.6 303.5 433 311.3 476.8 325.8 532.6 306.2 579.3 277.1 642 256.6 677.9 217.6 698 164.8 729.1 157 768.2 150.6 799 207.8 876 305.7 707.2 349.4 692.8 397.1 675 439.3 668 476.2 687.9 519.3 701.2 548.2 701.2 598 701.2 602.9 646.1 582.4 625.6ZM195.5 777.5C205.5 750.8 243.4 719.9 254.9 709.2 217.8 768.4 195.5 778.9 195.5 777.5ZM354.9 405.3C369.3 405.3 368 468 358.4 485 349.8 457.8 350 405.3 354.9 405.3ZM307.2 672.1C326.2 639.1 342.4 599.8 355.5 565.2 371.7 594.7 392.4 618.4 414.3 634.6 373.6 643 338.3 660.2 307.2 672.1ZM564.3 662.3S554.5 674 491.4 647.1C560 642 571.3 657.6 564.3 662.3Z", + "width": 750 + }, + "search": [ + "pdf-file" + ] + }, + { + "uid": "bbfb51903f40597f0b70fd75bc7b5cac", + "css": "trash", + "code": 128465, + "src": "fontawesome" + }, + { + "uid": "d4816c0845aa43767213d45574b3b145", + "css": "history", + "code": 8986, + "src": "fontawesome" + } + ] +} \ No newline at end of file diff --git a/src/www/admin/static/font/paheko.css b/src/www/admin/static/font/paheko.css new file mode 100644 index 0000000..b49da0e --- /dev/null +++ b/src/www/admin/static/font/paheko.css @@ -0,0 +1,118 @@ +@charset "UTF-8"; +@font-face { + font-family: 'paheko'; + src: url('../font/paheko.eot?71335949'); + src: url('../font/paheko.eot?71335949#iefix') format('embedded-opentype'), + url('../font/paheko.woff2?71335949') format('woff2'), + url('../font/paheko.woff?71335949') format('woff'), + url('../font/paheko.ttf?71335949') format('truetype'), + url('../font/paheko.svg?71335949#paheko') format('svg'); + font-weight: normal; + font-style: normal; +} +/* Chrome hack: SVG is rendered more smooth in Windozze. 100% magic, uncomment if you need it. */ +/* Note, that will break hinting! In other OS-es font will be not as sharp as it could be */ +/* +@media screen and (-webkit-min-device-pixel-ratio:0) { + @font-face { + font-family: 'paheko'; + src: url('../font/paheko.svg?71335949#paheko') format('svg'); + } +} +*/ +[class^="icn-"]:before, [class*=" icn-"]:before { + font-family: "paheko"; + font-style: normal; + font-weight: normal; + speak: never; + + display: inline-block; + text-decoration: inherit; + width: 1em; + margin-right: .2em; + text-align: center; + /* opacity: .8; */ + + /* For safety - reset parent styles, that can break glyph codes*/ + font-variant: normal; + text-transform: none; + + /* fix buttons height, for twitter bootstrap */ + line-height: 1em; + + /* Animation center compensation - margins should be symmetric */ + /* remove if not needed */ + margin-left: .2em; + + /* you can be more comfortable with increased icons size */ + /* font-size: 120%; */ + + /* Font smoothing. That was taken from TWBS */ + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + + /* Uncomment for 3D effect */ + /* text-shadow: 1px 1px 1px rgba(127, 127, 127, 0.3); */ +} + +.icn-list-ol:before { content: '\31'; } /* '1' */ +.icn-code:before { content: '\3c'; } /* '<' */ +.icn-bold:before { content: '\42'; } /* 'B' */ +.icn-header:before { content: '\48'; } /* 'H' */ +.icn-italic:before { content: '\49'; } /* 'I' */ +.icn-markdown:before { content: '\4d'; } /* 'M' */ +.icn-pdf:before { content: '\50'; } /* 'P' */ +.icn-skriv:before { content: '\53'; } /* 'S' */ +.icn-text:before { content: '\54'; } /* 'T' */ +.icn-paragraph:before { content: '\a7'; } /* '§' */ +.icn-quote:before { content: '\ab'; } /* '«' */ +.icn-list-ul:before { content: '\2022'; } /* '•' */ +.icn-euro:before { content: '\20ac'; } /* '€' */ +.icn-left:before { content: '\2190'; } /* '←' */ +.icn-up:before { content: '\2191'; } /* '↑' */ +.icn-right:before { content: '\2192'; } /* '→' */ +.icn-down:before { content: '\2193'; } /* '↓' */ +.icn-import:before { content: '\21b6'; } /* '↶' */ +.icn-export:before { content: '\21b7'; } /* '↷' */ +.icn-reset:before { content: '\21ba'; } /* '↺' */ +.icn-upload:before { content: '\21d1'; } /* '⇑' */ +.icn-download:before { content: '\21d3'; } /* '⇓' */ +.icn-login:before { content: '\21e5'; } /* '⇥' */ +.icn-home:before { content: '\2302'; } /* '⌂' */ +.icn-history:before { content: '\231a'; } /* '⌚' */ +.icn-print:before { content: '\2399'; } /* '⎙' */ +.icn-col:before { content: '\259a'; } /* '▚' */ +.icn-video:before { content: '\25b6'; } /* '▶' */ +.icn-table:before { content: '\25eb'; } /* '◫' */ +.icn-radio-unchecked:before { content: '\25ef'; } /* '◯' */ +.icn-star:before { content: '\2605'; } /* '★' */ +.icn-uncheck:before { content: '\2610'; } /* '☐' */ +.icn-check:before { content: '\2611'; } /* '☑' */ +.icn-settings:before { content: '\2638'; } /* '☸' */ +.icn-alert:before { content: '\26a0'; } /* '⚠' */ +.icn-mail:before { content: '\2709'; } /* '✉' */ +.icn-edit:before { content: '\270e'; } /* '✎' */ +.icn-delete:before { content: '\2718'; } /* '✘' */ +.icn-help:before { content: '\2753'; } /* '❓' */ +.icn-plus:before { content: '\2795'; } /* '➕' */ +.icn-minus:before { content: '\2796'; } /* '➖' */ +.icn-logout:before { content: '\291d'; } /* '⤝' */ +.icn-eye-off:before { content: '\292b'; } /* '⤫' */ +.icn-radio-checked:before { content: '\2b24'; } /* '⬤' */ +.icn-menu:before { content: '𝍢'; } /* '\1d362' */ +.icn-globe:before { content: '🌍'; } /* '\1f30d' */ +.icn-eye:before { content: '👁'; } /* '\1f441' */ +.icn-user:before { content: '👤'; } /* '\1f464' */ +.icn-users:before { content: '👪'; } /* '\1f46a' */ +.icn-calendar:before { content: '📅'; } /* '\1f4c5' */ +.icn-attach:before { content: '📎'; } /* '\1f4ce' */ +.icn-search:before { content: '🔍'; } /* '\1f50d' */ +.icn-lock:before { content: '🔒'; } /* '\1f512' */ +.icn-unlock:before { content: '🔓'; } /* '\1f513' */ +.icn-image:before { content: '🖻'; } /* '\1f5bb' */ +.icn-gallery:before { content: '🖼'; } /* '\1f5bc' */ +.icn-folder:before { content: '🗀'; } /* '\1f5c0' */ +.icn-document:before { content: '🗅'; } /* '\1f5c5' */ +.icn-trash:before { content: '🗑'; } /* '\1f5d1' */ +.icn-reload:before { content: '🗘'; } /* '\1f5d8' */ +.icn-del-col:before { content: '🮔'; } /* '\1fb94' */ diff --git a/src/www/admin/static/font/paheko.eot b/src/www/admin/static/font/paheko.eot new file mode 100644 index 0000000..a785e6b Binary files /dev/null and b/src/www/admin/static/font/paheko.eot differ diff --git a/src/www/admin/static/font/paheko.svg b/src/www/admin/static/font/paheko.svg new file mode 100644 index 0000000..0c191e0 --- /dev/null +++ b/src/www/admin/static/font/paheko.svg @@ -0,0 +1,132 @@ + + + +Copyright (C) 2023 by original authors @ fontello.com + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/www/admin/static/font/paheko.ttf b/src/www/admin/static/font/paheko.ttf new file mode 100644 index 0000000..7a14779 Binary files /dev/null and b/src/www/admin/static/font/paheko.ttf differ diff --git a/src/www/admin/static/font/paheko.woff b/src/www/admin/static/font/paheko.woff new file mode 100644 index 0000000..45afd50 Binary files /dev/null and b/src/www/admin/static/font/paheko.woff differ diff --git a/src/www/admin/static/font/paheko.woff2 b/src/www/admin/static/font/paheko.woff2 new file mode 100644 index 0000000..f907b09 Binary files /dev/null and b/src/www/admin/static/font/paheko.woff2 differ diff --git a/src/www/admin/static/handheld.css b/src/www/admin/static/handheld.css new file mode 100644 index 0000000..8328741 --- /dev/null +++ b/src/www/admin/static/handheld.css @@ -0,0 +1,407 @@ +body { + background-position: -180px 0px; + background-attachment: scroll; + font-size: 11pt; +} + +body main { + padding-bottom: 7em; +} + +input[type=number], input[type=color], +input[type=email], input[type=file], input[type=url], input[type=month], +input[type=password], input[type=range], input[type=search], input[type=tel], +textarea, select, .input-list, .file-selector, input[type=text]:not([data-input]):not([size]) { + min-width: 0; + max-width: calc(100% - 2em); + width: calc(95% - 2em); +} + +input[size] { + width: auto; +} + +img { + max-width: 100% !important; +} + +a.icn-btn, input[type=submit], input[type=button], button, input[type=file], .menu-btn > b { + padding: .4em .6em; +} + +.menu-btn > span { + top: 2.2em; + left: -.2em; +} + +a.icn-btn { + white-space: normal; +} + +nav.breadcrumbs .quota { + float: none; +} + +nav.breadcrumbs ul { + display: block; + margin: .8em 0; +} + +.header h1 { + margin: 1em; + text-align: center; + font-size: 1.2em; +} + +.header .menu { + background: none !important; + position: fixed; + overflow: visible; + bottom: inherit; + z-index: 10000; + margin: 0; + width: 100vw; + padding: 0; + display: block; + bottom: 0; + top: inherit; + left: 0; + right: 0; +} + +.header .menu .logo { + display: none; +} + +.header .menu *, .header .menu a { + margin: 0; + padding: 0; +} + +.header .menu > ul { + background: rgb(var(--gMainColor)); + flex-wrap: nowrap; + grid-template: none / repeat(auto-fit, minmax(20px, 1fr)); + display: grid; + align-items: center; +} + +.header .menu > ul > li > h3 { + display: inline; +} + +.header .menu > ul > li > h3 a b { + display: none; +} + +.header .menu > ul > li > ul { + display: none; +} + +.header .menu h3 a { + text-align: center; + text-decoration: none !important; + font-weight: normal; +} + +.header .menu h3 span[data-icon]::before { + float: none; + display: block; + position: relative; + right: 0; + margin: 0; + padding: 10pt 0; + line-height: 10pt; + color: rgb(var(--gBgColor)); + font-size: 20pt; + top: 0; + text-shadow: none; +} + +.header .menu li.current_parent h3 a, .header .menu li.current h3 a { + background: rgb(var(--gSecondColor)); +} + +.header .menu li.current h3 span[data-icon]::before, +.header .menu > ul > li.current_parent h3 span[data-icon]::before { + color: rgb(var(--gBgColor)); + text-shadow: 0px 0px 5px rgb(var(--gTextColor)), 0px 0px 5px rgb(var(--gTextColor)); +} + +.header .menu > ul > li.current > ul, .header .menu > ul > li.current_parent > ul { + display: flex; + flex-wrap: wrap; + justify-content: center; + position: absolute; + bottom: 30pt; + left: 0; + right: 0; + background: rgb(var(--gSecondColor)); +} + +.header .menu > ul > li.current > ul li, .header .menu > ul > li.current_parent > ul li { + text-align: center; + display: block; + margin: .3rem; +} + +.header .menu > ul > li.current > ul a, .header .menu > ul > li.current_parent > ul a { + background: rgb(var(--gBgColor)); + border-radius: .5em; + color: rgb(var(--gMainColor)); + padding: .3rem .6rem; + font-size: 1em; + font-weight: normal; +} + +.header .menu > ul > li > ul li.current a { + background: rgb(var(--gMainColor)); + color: rgb(var(--gBgColor)); +} + +main { + margin: 0; + padding: .1em; +} + +nav.tabs ul { + border: none; + padding: 0; + margin: .3em 0; + justify-content: center; +} + +nav.tabs ul li a { + padding: .4rem .6rem; +} + +nav.tabs ul li.current a { + font-size: 1em; +} + +nav.tabs li:first-child::before, nav.tabs li:last-child::after { + display: none; +} + +nav.tabs .sub { + margin: 1em 0; + border: none; + border-top: .2rem dashed rgba(var(--gMainColor), .5); +} + +nav.tabs h2 { + text-align: center; +} + +.filterCategory, .searchMember { + width: auto; + float: none; +} + +dl.describe { + margin: 0 .5em; +} + +fieldset { + border-left: none; + border-right: none; +} + +.shortFormRight, .shortFormLeft { + float: none; + width: auto; + margin-left: 0; + margin-right: 0; +} + +nav.tabs aside { + float: none; + text-align: center; + max-width: 100%; +} + +table.list tfoot .actions[colspan] { + text-align: left; +} + +table.list tbody .actions { + text-align: center; + display: flex; + flex-wrap: wrap; + flex-direction: row; + justify-content: flex-end; + border: none; +} + +table.list tbody td.actions > a { + align-self: center; +} + +table.list th { + font-size: 1.2em; +} + +.datepicker tbody td { + font-size: 1.2em; +} + +table.files.gallery tbody { + justify-content: center; +} + +/* Petits écrans (smartphones) */ +@media screen and (max-width:600px) { + .transaction-details { + flex-direction: column; + } + + div.help.flex { + flex-direction: column; + } + + samp.copy { + display: inline-block; + text-overflow: ellipsis; + max-width: 95%; + overflow: hidden; + } + + table.list tr { + display: block; + } + + table.list tbody td.check { + float: left; + } + + table.list tbody td.icon, table.list tbody td.bookmark { + float: right; + } + + table.list thead td.check label, table.list tfoot td.check label { + background: rgba(255, 255, 255, 0.5); + padding: .3em .5em; + border-radius: .5em; + display: inline-block; + } + + table.list thead td.check label::after, table.list tfoot td.check label::after { + content: attr(title); + } + + table.list td, table.list th { + display: block; + text-align: center !important; + width: auto !important; + height: auto !important; + } + + table.list tbody .actions { + justify-content: center; + } + + table.list.gallery td, table.list.gallery th { + border-left: none; + } + + table.files.gallery tbody { + grid-template-columns: repeat(auto-fit, 95%); + } + + colgroup { + /* Hack pour désactiver les largeurs de colonnes */ + display: none; + } + + .radio-btn input { + position: absolute; + right: 1em; + } + + table.list td:first-child, table.list th:first-child { + border-left: none; + } + + .infos_asso { + float: none; + width: auto; + } + + nav.acc-year { + flex-direction: column; + border: none; + border-radius: 0; + background: rgba(var(--gMainColor), 0.2); + font-size: .8em; + } + + .actions, table.list .actions { + text-align: center; + } + + #dialog { + background: #333; + } + + #dialog > iframe { + width: 100%; + height: 100%; + margin: 0; + max-width: 100%; + border-radius: 0; + box-shadow: 0px 0px 5px 10px #fff; + } + + #dialog > button.closeBtn { + color: var(--gLightBackgroundColor); + margin-bottom: .5em; + } + + #dialog.loaded > iframe { + width: 100%; + } + + #dialog > button { + border: none; + background: none; + } + + dl.list { + text-align: center; + } + + table.statement td, table.statement tr { + display: block; + } + + aside.describe { + float: none; + margin: 1em auto; + } + + dl.describe { + display: block; + margin: 1em; + text-align: left; + } + + dl.describe > dt { + text-align: unset; + } + + dl.describe > dd { + font-size: 1.2em; + margin-bottom: .8em; + } + + .datepicker-parent { + position: static; + } + + dialog.datepicker { + margin: auto; + left: 0; + right: 0; + width: 95%; + } +} \ No newline at end of file diff --git a/src/www/admin/static/icon.png b/src/www/admin/static/icon.png new file mode 100644 index 0000000..661048b Binary files /dev/null and b/src/www/admin/static/icon.png differ diff --git a/src/www/admin/static/pics/img_center.png b/src/www/admin/static/pics/img_center.png new file mode 100644 index 0000000..55f9864 Binary files /dev/null and b/src/www/admin/static/pics/img_center.png differ diff --git a/src/www/admin/static/pics/img_flow.png b/src/www/admin/static/pics/img_flow.png new file mode 100644 index 0000000..6801f9b Binary files /dev/null and b/src/www/admin/static/pics/img_flow.png differ diff --git a/src/www/admin/static/pics/img_left.png b/src/www/admin/static/pics/img_left.png new file mode 100644 index 0000000..fbb87a8 Binary files /dev/null and b/src/www/admin/static/pics/img_left.png differ diff --git a/src/www/admin/static/pics/img_right.png b/src/www/admin/static/pics/img_right.png new file mode 100644 index 0000000..de279cb Binary files /dev/null and b/src/www/admin/static/pics/img_right.png differ diff --git a/src/www/admin/static/print.css b/src/www/admin/static/print.css new file mode 100644 index 0000000..a1e9584 --- /dev/null +++ b/src/www/admin/static/print.css @@ -0,0 +1,130 @@ +@page { + size: A4 landscape; + margin: 1cm; +} + +html { + height: auto; +} + +body { + background: #fff; + color: #000; + padding: 0; + margin: 0; + font-size: 10pt; +} + +header.header { + display: none; +} + +main { + margin: 0; +} + +.header h1 { + margin: 0; + text-align: center; +} + +table.list thead { + border-bottom: solid .3rem #000; +} + +table.list thead td, table.list thead th { + background: #ccc !important; + color: #000 !important; + border-right: 1px solid #999; +} + +table.list tfoot { + border-top: double .3rem #000; +} + +table.statement tfoot tr { + color: #000; +} + +table.list tfoot tr td, table.list tfoot th { + background: #fff; + color: #000; +} + +table.list tr { + border: 1px solid #666; +} + +table.list tr:nth-child(even) { + background: #ddd; +} + +table.list.multi tr:nth-child(even) { + background: inherit; +} + +table.list.multi tr:nth-child(4n+1), table.list.multi tr:nth-child(4n+2) { + background: #ddd; +} + +table.list td { + border: 1px solid #999; +} + +#rapport tr { + color: #000 !important; +} + +#rapport table .parent { + border-top: 1px dashed #666; +} + +#rapport table table { + border: 1px solid #666; +} + +#rapport .parent { + background: #ccc; +} + +.noprint { + display: none; +} + +.print-only { + display: block; +} + +td.actions *, nav.tabs, .icn-btn, .pagination, a.icn { + display: none !important; +} + +td.num a { + border: none; + padding: 0; + background: none; +} + +/* Disable hyperlinks */ +a { + color: black !important; + text-decoration: none; + prince-link: none !important; +} + +/* Don't repeat the table footer on every printed page */ +table tfoot{ + display:table-row-group; +} + +details summary::after { + display: none; +} + +.ruler::after, .ruler::before { + display: none; +} + +.transaction-details-container { + max-width: initial; +} \ No newline at end of file diff --git a/src/www/admin/static/scripts/accounting.js b/src/www/admin/static/scripts/accounting.js new file mode 100644 index 0000000..b234d0c --- /dev/null +++ b/src/www/admin/static/scripts/accounting.js @@ -0,0 +1,194 @@ +function initTransactionForm(is_new) { + var form = $('form')[0]; + // Check if an account is listed twice and ask for confirmation + form.addEventListener('submit', (e) => { + var type = document.querySelector('input[name=type]:checked'); + + if (type.value) { + return true; + } + + var accounts = []; + var lines = $('.transaction-lines tbody tr'); + + for (var i = 0; i < lines.length; i++) { + var a = lines[i].querySelector('.input-list input[type="hidden"]'); + + if (!a) { + continue; + } + + if (accounts.includes(a.value)) { + if (!window.confirm(`Attention, cette écriture affecte deux fois le même compte (${a.value}). Confirmer ?`)) { + form.classList.remove('progressing'); + e.preventDefault(); + return false; + } + + break; + } + + accounts.push(a.value); + } + + return true; + }); + + // Advanced transaction: line management + var lines = $('.transaction-lines tbody tr'); + + function initLine(row) { + var removeBtn = row.querySelector('button[name="remove_line"]'); + removeBtn.onclick = () => { + var count = $('.transaction-lines tbody tr').length; + var min = removeBtn.getAttribute('min'); + + if (count <= min) { + alert("Il n'est pas possible d'avoir moins de " + min + " lignes dans une écriture."); + return false; + } + + row.parentNode.removeChild(row); + updateTotals(); + }; + + // To be able to change input just by pressing up/down + var inputs = row.querySelectorAll('input, select, button'); + + inputs.forEach((i, k) => { + i.onkeydown = (e) => { + if (e.key == 'ArrowUp' && (p = row.previousElementSibling)) { + p.querySelectorAll('input, select, button')[k].focus(); + return false; + } + else if (e.key == 'ArrowDown' && (n = row.nextElementSibling)) { + n.querySelectorAll('input, select, button')[k].focus(); + return false; + } + }; + }); + + // Update totals and disable other amount input + var inputs = row.querySelectorAll('input.money'); + + inputs.forEach((i, k) => { + i.onkeyup = (e) => { + var v = i.value.replace(/[^0-9,.]/); + if (v.length && v != 0) { + inputs[+!k].value = '0'; + updateTotals(); + } + }; + + if (+i.value == 0 && +inputs[+!k].value != 0) { + i.value = '0'; + } + }); + } + + lines.forEach(initLine); + + function updateTotals() { + var amounts = $('.transaction-lines tbody input.money'); + var debit = credit = 0; + + amounts.forEach((i) => { + if (!i.value) { + return; + } + + var v = g.getMoneyAsInt(i.value); + + if (i.name.match(/debit/)) { + debit += v; + } + else { + credit += v; + } + }); + + if (m = $('#lines_message')) { + var diff = credit - debit; + m.innerHTML = (!diff) ? '' : 'Écriture non équilibrée (' + g.formatMoney(diff) + ')'; + } + + debit = debit ? debit + '' : '000'; + credit = credit ? credit + '' : '000'; + $('#f_debit_total').value = g.formatMoney(debit); + $('#f_credit_total').value = g.formatMoney(credit); + } + + // Add row "plus" button + $('.transaction-lines tfoot button')[0].onclick = () => { + let lines = $('.transaction-lines tbody tr'); + var line = lines[lines.length - 1]; + var n = line.cloneNode(true); + + // Reset label and reference + n.querySelectorAll('input').forEach((i) => { + if (!i.name.match(/label|reference/)) { + return; + } + + i.value = ''; + }) + + var b = n.querySelector('.input-list button'); + b.onclick = () => { + g.current_list_input = b.parentNode; + let url = b.value + (b.value.indexOf('?') > 0 ? '&' : '?') + '_dialog'; + g.openFrameDialog(url); + return false; + }; + line.parentNode.appendChild(n); + initLine(n); + }; + + updateTotals(); + + // Hide type specific parts of the form + function hideAllTypes() { + g.toggle('[data-types]', false); + } + + // Toggle parts of the form when a type is selected + function selectType(v) { + hideAllTypes(); + g.toggle('[data-types~=t' + v + ']', true); + g.toggle('[data-types=all-but-advanced]', v != 0); + // Disable required form elements, or the form won't be able to be submitted + $('[data-types=all-but-advanced] input[required]').forEach((e) => { + e.disabled = v == 0 ? true : false; + }); + + } + + var radios = $('fieldset input[type=radio][name=type]'); + + radios.forEach((e) => { + e.onchange = () => { + document.querySelectorAll('fieldset').forEach((e, k) => { + if (!is_new || k == 0 || e.dataset.types) return; + g.toggle(e, true); + g.toggle('p.submit', true); + }); + selectType(e.value); + }; + }); + + hideAllTypes(); + + // In case of a pre-filled form: show the correct part of the form + var current = document.querySelector('input[name=type]:checked'); + if (current) { + selectType(current.value); + } + + if (is_new) { + document.querySelectorAll('fieldset').forEach((e, k) => { + if (k == 0) return; + g.toggle(e, false); + g.toggle('p.submit', false); + }); + } +} diff --git a/src/www/admin/static/scripts/accounting_setup.js b/src/www/admin/static/scripts/accounting_setup.js new file mode 100644 index 0000000..cf0ed58 --- /dev/null +++ b/src/www/admin/static/scripts/accounting_setup.js @@ -0,0 +1,33 @@ +function initLine(row) +{ + var removeBtn = row.querySelector('button[name="remove_line"]'); + removeBtn.onclick = () => { + var count = $('tbody tr').length; + + if (count <= 1) { + alert("Il n'est pas possible de supprimer cette ligne."); + return false; + } + + row.parentNode.removeChild(row); + }; +} + +if ($('table').length) { + $('tbody tr').forEach(initLine); + + // Add row "plus" button + $('tfoot button')[0].onclick = () => { + let lines = $('tbody tr'); + var line = lines[lines.length - 1]; + var n = line.cloneNode(true); + + // Reset label and reference + n.querySelectorAll('input').forEach((i) => { + i.value = ''; + }) + + line.parentNode.appendChild(n); + initLine(n); + }; +} \ No newline at end of file diff --git a/src/www/admin/static/scripts/accounts_list.js b/src/www/admin/static/scripts/accounts_list.js new file mode 100644 index 0000000..b1918d1 --- /dev/null +++ b/src/www/admin/static/scripts/accounts_list.js @@ -0,0 +1,57 @@ +$('button[name*=bookmark]').forEach((b) => { + b.onclick = () => { + b.value = parseInt(b.value) ? 0 : 1; + b.setAttribute('data-icon', b.value == 1 ? '☑' : '☐'); + fetch(document.forms[0].action, { + 'method': 'POST', + 'headers': {"Content-Type": "application/x-www-form-urlencoded"}, + 'body': b.name + '=' + b.value + }); + return false; + }; +}); + +RegExp.escape = function(string) { + return string.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&') +}; + +function normalizeString(str) { + return str.normalize('NFD').replace(/[\u0300-\u036f]/g, "") +} + +var q = document.querySelector('.quick-search input[type=text]'); + +if (q) { + var rows = document.querySelectorAll('table tr.account'); + + rows.forEach((e, k) => { + var l = e.querySelector('td.num').innerText + ' ' + e.querySelector('th').innerText; + e.setAttribute('data-search-label', normalizeString(l)); + }); + + q.addEventListener('keyup', (e) => { + filterTableList(); + return true; + }); + document.querySelector('.quick-search button[type=reset]').onclick = () => { + q.value = ''; + q.focus(); + return filterTableList(); + }; + q.focus(); +} + +function filterTableList() { + var query = new RegExp(RegExp.escape(normalizeString(q.value)), 'i'); + + rows.forEach((elm) => { + if (elm.getAttribute('data-search-label').match(query)) { + g.toggle(elm, true); + } + else { + g.toggle(elm, false); + } + }); + + return false; +} \ No newline at end of file diff --git a/src/www/admin/static/scripts/advanced_search.js b/src/www/admin/static/scripts/advanced_search.js new file mode 100644 index 0000000..2f326e5 --- /dev/null +++ b/src/www/admin/static/scripts/advanced_search.js @@ -0,0 +1,86 @@ +g.script('scripts/lib/query_builder.js', () => { + var div = document.getElementById('queryBuilder'); + var columns = JSON.parse(div.dataset.columns); + var groups = JSON.parse(div.dataset.groups); + + var translations = { + "after": "après", + "before": "avant", + "is equal to": "est égal à", + "is equal to one of": "est égal à une des ces options", + "is not equal to one of": "n'est pas égal à une des ces options", + "is not equal to": "n'est pas égal à", + "is greater than": "est supérieur à", + "is greater than or equal to": "est supérieur ou égal à", + "is less than": "est inférieur à", + "is less than or equal to": "est inférieur ou égal à", + "is between": "est situé entre", + "is not between": "n'est pas situé entre", + "is null": "n'est pas renseigné", + "is not null": "est renseigné", + "begins with": "commence par", + "doesn't begin with": "ne commence pas par", + "ends with": "se termine par", + "doesn't end with": "ne se termine pas par", + "contains": "contient", + "doesn't contain": "ne contient pas", + "matches one of": "correspond à", + "doesn't match one of": "ne correspond pas à", + "is true": "oui", + "is false": "non", + "Matches ALL of the following conditions:": "Correspond à TOUS les critères suivants :", + "Matches ANY of the following conditions:": "Correspond à UN des critères suivants :", + "Add a new set of conditions below this one": "— Ajouter un groupe de critères", + "Remove this set of conditions": "— Supprimer ce groupe de critères", + "AND": "ET", + "OR": "OU" + }; + + var q = new SQLQueryBuilder(columns); + q.__ = function (str) { + return translations[str] ?? str; + }; + q.loadDefaultOperators(); + q.default_operator = "LIKE %?%"; + + // Add specific condition just to have the column show up in result + q.operators["1"] = "afficher cette colonne"; + + for (var i in q.types_operators) { + q.types_operators[i]["1"] = q.operators["1"]; + } + + q.buildInput = function (type, label, column) { + if (label == '+') + { + label = '➕'; + } + else if (label == '-') + { + label = '➖'; + } + + if (type == 'button') + { + var i = document.createElement('button'); + i.className = 'icn-btn'; + i.type = 'button'; + i.setAttribute('data-icon', label); + } + else { + var i = document.createElement('input'); + i.type = type == 'integer' ? 'number' : type; + i.value = label; + } + + return i; + }; + + q.init(div); + + $('#queryBuilderForm').onsubmit = function () { + $('#jsonQuery').value = JSON.stringify(q.export()); + }; + + q.import(groups); +}); diff --git a/src/www/admin/static/scripts/auto_logout.js b/src/www/admin/static/scripts/auto_logout.js new file mode 100644 index 0000000..5286425 --- /dev/null +++ b/src/www/admin/static/scripts/auto_logout.js @@ -0,0 +1,71 @@ +(function () { + var last_activity = +new Date; + var t = null; + var dialog = null; + var logout_url = g.admin_url + 'logout.php'; + var tpl = `
    +
    + Inactivité +

    Votre session est inactive. Voulez-vous rester connecté ?

    +

    Vous serez déconnecté dans 60 secondes…

    +

    +

    +
    +
    `; + + function autoLogout() { + window.clearTimeout(t); + + var session_activity = parseInt(sessionStorage.getItem('last_activity') || 0, 10); + + // Just in case activity happened in another tab and not this one + if (session_activity > last_activity) { + last_activity = session_activity; + var expiry = last_activity + g.auto_logout*60*1000; + + if (expiry > Date.now()) { + t = window.setTimeout(autoLogout, g.auto_logout*60*1000); + return; + } + } + + dialog = g.openDialog(tpl, {close: false}); + var timer = document.querySelector('#logout_timer span'); + var title = document.title; + var i = window.setInterval(() => { + var c = parseInt(timer.innerText, 10); + timer.innerText = c - 1; + document.title = (c % 2 == 0) ? '⚠ Déconnexion' : title; + }, 1000); + t = window.setTimeout(() => window.location.href = logout_url, 60*1000); + + document.getElementById('stay_logged_in').onclick = () => { + window.clearInterval(i); + g.closeDialog(); + document.title = title; + dialog = null; + registerActivity(); + }; + } + + function registerActivity() { + if (dialog) { + return; + } + + last_activity = +new Date; + sessionStorage.setItem('last_activity', last_activity); + + if (t) { + window.clearTimeout(t); + } + + t = window.setTimeout(autoLogout, g.auto_logout*60*1000); + } + + window.addEventListener('mousemove', registerActivity); + window.addEventListener('scroll', registerActivity); + window.addEventListener('keydown', registerActivity); + + registerActivity(); +})(); \ No newline at end of file diff --git a/src/www/admin/static/scripts/code_editor.css b/src/www/admin/static/scripts/code_editor.css new file mode 100644 index 0000000..471edeb --- /dev/null +++ b/src/www/admin/static/scripts/code_editor.css @@ -0,0 +1,141 @@ +.codeEditor { + min-height: 600px; + width: 100%; + border: 1px solid var(--gBorderColor); + background: var(--gLightBackgroundColor); + position: relative; + display: block; +} + +.codeEditor .sk_help { + background: var(--gLightBorderColor); + border-top: .2rem solid var(--gBorderColor); + position: absolute; + font-size: .9em; + left: 0; + right: 0; + bottom: 0; + height: 1.2rem; + padding: .3rem 1rem 0; + font-family: "Deja Vu Sans Mono", "Droid Sans Mono", "Courier New", Courier, monospace; +} + +.codeEditor .sk_toolbar { + border-bottom: .2em solid var(--gBorderColor); + display: flex; + flex-direction: row; + justify-content: space-between; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 2.5em; + padding: 0; +} + +.codeEditor .sk_toolbar button:first-child { + font-weight: bold; +} + +.codeEditor .sk_toolbar p { + display: inline; + padding: .3em .5em; + border-radius: .5em; + font-size: .9em; + margin-left: 2em; +} + +.codeEditor .sk_toolbar button { + margin: 4px .5em; +} + +.codeEditor .lineCount, .codeEditor textarea { + font-family: "Deja Vu Sans Mono", "Droid Sans Mono", "Courier New", Courier, monospace; + font-size: 11pt; + line-height: 11pt; +} + +.codeEditor .editor { + position: absolute; + top: calc(2.7em); + height: calc(100% - 4.2em); + bottom: calc(1.6em + .4em); + left: 0; + right: 0; + background: #333; +} + +.codeEditor .container { + position: relative; + display: block; +} + +.codeEditor textarea, .codeEditor .lineCount { + font-family: "Andale Mono", "Monaco", "Lucida", "Courier New", monospace; + font-size: 13pt; + line-height: 16pt; +} + +.codeEditor .lineCount { + position: absolute; + top: 0; + left: 0; + bottom: 0; + width: 46px; + text-align: right; + border-right: 2px solid #666; + overflow: hidden; + color: #999; +} + +.codeEditor .lineCount i { + display: block; + padding-right: 2px; + font-weight: normal; +} + +.codeEditor .lineCount b { + display: block; + padding-right: 2px; + font-weight: normal; +} + +.codeEditor .lineCount b.current { + background: #666; + color: #fff; +} + +.codeEditor .container { + position: absolute; + right: 4px; + top: 0; + bottom: 0; + left: 50px; + margin: 0; + padding: 0; +} + +.codeEditor textarea { + height: 100%; + width: 100%; + padding: 0 0 0 2px; + margin: 0; + background: transparent; + color: #fff; + border-radius: none; + border: none; + overflow: auto; + resize: none; + box-shadow: none; +} + +.codeEditor.fullscreen { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + width: 100%; + height: 100%; + z-index: 100000; +} \ No newline at end of file diff --git a/src/www/admin/static/scripts/code_editor.js b/src/www/admin/static/scripts/code_editor.js new file mode 100644 index 0000000..e77a74b --- /dev/null +++ b/src/www/admin/static/scripts/code_editor.js @@ -0,0 +1,222 @@ +(function (){ + g.style('scripts/code_editor.css'); + g.script('scripts/lib/text_editor.min.js', () => { + g.script('scripts/lib/code_editor.min.js', function () + { + const doc_url = g.admin_url + 'static/doc/'; + var save_btn = document.querySelector('[name=save]'); + var code = new codeEditor('f_content'); + + code.params.lang = { + search: "Texte à chercher ?\n(expression régulière autorisée, pour cela commencer par un slash '/')", + replace: "Texte pour le remplacement ?\n(utiliser $1, $2... pour les captures d'expression régulière)", + search_selection: "Texte à chercher dans la sélection ?\n(expression régulière autorisée, pour cela commencer par un slash '/')", + replace_result: "%d occurences trouvées et remplacées.", + goto: "Aller à la ligne numéro :", + no_search_result: "Aucun résultat trouvé." + }; + + code.origValue = code.textarea.value; + code.saved = true; + + code.onlinechange = function () { + if (!this.textarea.value.match(/\{\{/)) { + return; + } + + if ((p = this.parent.querySelector('nav p')) && this.origValue != code.textarea.value) + { + toolbar.removeChild(p); + } + + var line = this.getLine(this.current_line); + var doc = [{link: 'brindille.html', title: 'Brindille'}]; + + if (match = line.match(/\{\{:(\w+)/)) { + doc.push({link: 'brindille_functions.html', title: 'Fonction'}); + doc.push({link: 'brindille_functions.html#'+match[1], title: match[1]}); + } + else if (match = line.match(/\{\{#(\w+)/)) { + doc.push({link: 'brindille_sections.html', title: 'Section'}); + doc.push({link: 'brindille_sections.html#'+match[1], title: match[1]}); + } + else if (match = line.match(/\{\{(select)/)) { + doc.push({link: 'brindille_sections.html', title: 'Section'}); + doc.push({link: 'brindille_sections.html#'+match[1], title: match[1]}); + } + else if (match = line.match(/\|(\w+)/)) { + doc.push({link: 'brindille_modifiers.html', title: 'Filtre'}); + doc.push({link: 'brindille_modifiers.html#'+match[1], title: match[1]}); + } + + help.innerHTML = 'Documentation'; + + for (var i = 0; i < doc.length; i++) + { + help.innerHTML += ' > '; + + if (doc[i].link) + help.innerHTML += '' + doc[i].title + ''; + else if (doc[i].tag) + help.innerHTML += '<' + tag + '>' + doc[i].title + ''; + else + help.innerHTML += doc[i].title; + } return false; + + }; + + code.saveFile = async function () + { + const data = new URLSearchParams(); + + for (const pair of new FormData(this.textarea.form)) { + data.append(pair[0], pair[1]); + } + + data.append('save', 1); + this.textarea.form.classList.add('progressing'); + + var r = await fetch(this.textarea.form.action, { + 'method': 'post', + 'body': data, + 'headers': { + 'Accept': 'application/json' + } + }); + + if (!r.ok) { + console.log(r); + const data = await r.json(); + console.error(data); + + if (data.message) { + alert(data.message); + } + else if (!data.success) { + throw Error('Invalid response'); + } + + this.textarea.form.querySelector('[type=submit]').click(); + return; + } + + this.textarea.defaultValue = this.textarea.value; + + // Show saved + let c = document.createElement('p'); + c.className = 'block confirm'; + c.id = 'confirm_saved'; + c.innerText = 'Enregistré'; + c.style.left = '-100%'; + c.style.opacity = '1'; + c.onclick = () => c.remove(); + + document.querySelector('.codeEditor').appendChild(c); + + window.setTimeout(() => { + c.style.left = ''; + this.textarea.form.classList.remove('progressing'); + }, 200); + + window.setTimeout(() => { + c.style.opacity = 0; + }, 3000); + + window.setTimeout(() => { + c.remove(); + }, 5000); + + return true; + }; + + code.resetFile = function (e) + { + if (this.textarea.value == this.origValue) return; + if (!window.confirm("Le fichier a été modifié, abandonner les modifications ?")) return; + this.textarea.form.reset(); + }; + + // Warn before closing window if content was changed + var preventClose = (e) => { + if (code.textarea.value == code.textarea.defaultValue) { + return; + } + + e.preventDefault(); + e.returnValue = ''; + return true; + }; + + window.addEventListener('beforeunload', preventClose, { capture: true }); + + code.textarea.form.addEventListener('submit', () => { + window.removeEventListener('beforeunload', preventClose, {capture: true}); + }); + + var help = document.createElement('div'); + help.className = 'sk_help'; + + code.parent.appendChild(help); + + var toolbar = document.createElement('nav'); + toolbar.className = 'sk_toolbar'; + + var appendButton = function (icon, label, title, action) + { + var btn = document.createElement('button'); + btn.type = 'button'; + btn.innerText = label; + btn.title = title; + if (icon) { + btn.setAttribute('data-icon', icon); + } + btn.onclick = () => { action.call(code); return false; }; + + toolbar.appendChild(btn); + }; + + appendButton('→', 'Enregistrer', 'Enregistrer les modifications', code.saveFile); + appendButton('🗘', 'Recharger', 'Recharger le fichier (effacer les modifications)', code.resetFile); + + appendButton('🔍', 'Chercher', 'Chercher', code.search); + appendButton(null, 'Remplacer', 'Chercher et remplacer', code.searchAndReplace); + appendButton(null, 'Aller à la ligne', 'Aller à la ligne', code.goToLine); + + code.parent.insertBefore(toolbar, code.parent.firstChild); + + code.shortcuts.push({ctrl: true, key: 's', callback: code.saveFile}); + + // Cancel Escape to close + if (window.parent && window.parent.g.dialog) { + // Always fullscreen in dialogs + code.toggleFullscreen(); + + // Display error message in editor + if (msg = document.querySelector('p.error, p.confirm')) + { + var m = document.createElement('p'); + m.innerHTML = msg.innerHTML; + m.className = msg.className; + toolbar.appendChild(m); + msg.parentNode.removeChild(msg); + } + + window.parent.g.dialog.preventClose = () => { + if (code.textarea.value == code.textarea.defaultValue) { + return false; + } + + if (window.confirm("Le contenu a été modifié.\nSauvegarder avant de fermer ?")) { + code.saveFile(); + } + + return false; + }; + } + else { + appendButton(null, 'Plein écran', 'Plein écran', code.toggleFullscreen); + } + + g.setParentDialogHeight('90%'); + })}); +}()); diff --git a/src/www/admin/static/scripts/color_helper.js b/src/www/admin/static/scripts/color_helper.js new file mode 100644 index 0000000..c7b8ffc --- /dev/null +++ b/src/www/admin/static/scripts/color_helper.js @@ -0,0 +1,257 @@ +(function () { + if (!document.documentElement.style.setProperty + || !window.CSS || !window.CSS.supports + || !window.CSS.supports('--var', 0)) + { + return; + } + + const logo_limit_x = 170; + const bg_color = getVariable('gBgColor').split(',').map(e => parseInt(e, 10)) || [255, 255, 255]; + const text_color = getVariable('gTextColor').split(',').map(e => parseInt(e, 10)) || [0, 0, 0]; + + function getVariable(var_name) { + return getComputedStyle(document.documentElement).getPropertyValue('--' + var_name); + } + + function colorToRGB(color, type) + { + // Conversion vers décimal RGB + return color.replace(/^#/, '').match(/.{1,2}/g).map(function (el) { + // On limite la luminosité comme ça, c'est pas parfait mais ça marche + return Math.min(parseInt(el, 16), type == 'gMainColor' ? 180 : 220); + }); + } + + function RGBToHex(color) { + // Conversion vers décimal RGB + return '#' + color.split(/,/).map(function (el) { + return ('0' + parseInt(el, 10).toString(16)).substr(-2); + }).join(''); + } + + function changeColor(element, color) + { + let new_color = colorToRGB(color, element); + + let contrast_color = element == 'gMainColor' ? bg_color : text_color; + let sum = contrast_color.reduce((pv, cv) => pv + cv, 0); + let change = sum < (127*3) ? 5 : -5; + + while (!checkContrast(new_color, contrast_color)) { + new_color[0] += change; + new_color[1] += change; + new_color[2] += change; + } + + for (i in new_color) { + new_color[i] = Math.max(new_color[i], 0); + new_color[i] = Math.min(new_color[i], 255); + } + + // Mise à jour variable CSS + document.documentElement.style.setProperty('--' + element, new_color.join(',')); + + applyColors(); + return new_color.join(','); + } + + /** + * Return true if contrast is OK (W3C AA-level), false if not + * @see https://dev.to/alvaromontoro/building-your-own-color-contrast-checker-4j7o + */ + function checkContrast(color1, color2) + { + let l1 = 0.2126 * color1[0] + 0.7152 * color1[1] + 0.0722 * color1[2]; + let l2 = 0.2126 * color2[0] + 0.7152 * color2[1] + 0.0722 * color2[2]; + let ratio = l1 > l2 + ? ((l2 + 0.05) / (l1 + 0.05)) + : ((l1 + 0.05) / (l2 + 0.05)); + + return ratio < 1/3 ? true : false; + } + + function applyColors() + { + let input = $('#f_color2'); + let color = colorToRGB(input.value, 'gSecondColor'); + let color1 = $('#f_color1'), color2 = $('#f_color2'); + let default_colors = color1.value == color1.placeholder && color2.value == color2.placeholder; + + var img = new Image; + img.crossOrigin = "Anonymous"; + + img.onload = function() { + var canvas = document.createElement('canvas'); + var ctx = canvas.getContext('2d'); + canvas.width = img.width; + canvas.height = img.height; + ctx.drawImage(img, 0, 0, img.width, img.height, 0, 0, img.width, img.height); + + var imgData = ctx.getImageData(0, 0, canvas.width, canvas.height); + var data = imgData.data; + + for (var i = 0; i < data.length; i += 4) { + // Re-colorier l'image avec la couleur choisie + data[i] = color[0]; + data[i+1] = color[1]; + data[i+2] = color[2]; + // i+3 = alpha channel, mais on n'y touche pas + } + + ctx.putImageData(imgData, 0, 0); + + var i = canvas.toDataURL('image/png'); + + // Prévisualisation + document.documentElement.style.setProperty('--gBgImage', 'url("' + i + '")'); + $('#f_admin_background').value = i.substr(i.indexOf(',')+1); + + delete canvas2; + delete canvas; + delete ctx; + delete img; + }; + + var bg = $('#f_admin_background'); + + if (bg.value == 'RESET' && default_colors) { + document.documentElement.style.setProperty('--gBgImage', 'url("' + bg.dataset.default + '")'); + } + else if (bg.value == 'RESET') { + img.src = bg.dataset.default; + } + else if (bg.value) { + img.src = 'data:image/png;base64,' + bg.value; + } + else if (bg.dataset.current) { + img.src = bg.dataset.current; + } + else { + img.src = bg.dataset.default; + } + } + + /** + * Imports a new image and makes it black and white + */ + function importBackgroundImage(data, callback) + { + var max_w = 380, max_h = 300; + + var img = new Image; + img.crossOrigin = "Anonymous"; + + img.onload = function() { + var canvas = document.createElement('canvas'); + var ctx = canvas.getContext('2d'); + + var w = img.width, h = img.height; + + if (w > max_w) { + w = max_w; + h = (w / img.width) * img.height; + } + + if (h > max_h) { + h = max_h; + w = (h / img.height) * img.width; + } + + canvas.width = w; + canvas.height = h; + ctx.drawImage(img, 0, 0, img.width, img.height, 0, 0, w, h); + + var imgData = ctx.getImageData(0, 0, w, h); + var data = imgData.data; + + var i = 0; + + for(var y = 0; y < imgData.height; y++) { + for(var x = 0; x < imgData.width; x++) { + var avg = (data[i] * 0.3 + data[i+1] * 0.59 + data[i+2] * 0.11); + var b = avg < 127 && (data[i+3] > 127); + data[i] = b ? avg : 255; // red + data[i+1] = b ? avg : 255; // green + data[i+2] = b ? avg : 255; // blue + data[i+3] = b ? (x >> logo_limit_x ? 50 : 150) : 0; + i += 4; + } + } + + ctx.putImageData(imgData, 0, 0); + + var i = canvas.toDataURL('image/png'); + + $('#f_admin_background').value = i.substr(i.indexOf(',')+1); + + delete canvas2; + delete canvas; + delete ctx; + delete img; + + callback(); + }; + + img.src = data; + } + + garradin.onload(function () { + var colors = {'color1': 'gMainColor', 'color2': 'gSecondColor'}; + + for (var color in colors) + { + if (!colors.hasOwnProperty(color)) continue; + + var input = document.getElementById('f_' + color); + + input.oninput = function () { + var c = changeColor(colors[this.name], this.value); + this.value = RGBToHex(c); + }; + + // Ajout bouton remise à zéro de la couleur + var reset_btn = document.createElement('button'); + reset_btn.className = 'resetButton icn-btn'; + reset_btn.type = 'button'; + reset_btn.innerHTML = 'RàZ'; + + reset_btn.onclick = function() { + var input = this.previousSibling; + input.value = input.getAttribute('placeholder'); + changeColor(colors[input.name], input.value); + return false; + }; + + input.parentNode.insertBefore(reset_btn, input.nextSibling); + } + + var bg = $('#f_background'); + bg.addEventListener('change', () => { + if (!bg.files.length) return; + + var reader = new FileReader; + reader.onload = (e) => { + importBackgroundImage(e.target.result, applyColors); + bg.disabled = true; + bg.value = ''; + }; + reader.readAsDataURL(bg.files[0]); + }); + + var reset_btn = document.createElement('button'); + reset_btn.className = 'resetButton icn-btn'; + reset_btn.type = 'button'; + reset_btn.innerHTML = 'RàZ'; + + reset_btn.onclick = () => { + $('#f_admin_background').dataset.current = ''; + $('#f_admin_background').value = 'RESET'; + bg.disabled = false; + + applyColors(); + }; + + bg.parentNode.insertBefore(reset_btn, bg.nextSibling); + }); +})(); \ No newline at end of file diff --git a/src/www/admin/static/scripts/config_fields.js b/src/www/admin/static/scripts/config_fields.js new file mode 100644 index 0000000..f19e56d --- /dev/null +++ b/src/www/admin/static/scripts/config_fields.js @@ -0,0 +1,98 @@ +function changeType() { + var type = $('#f_type').value; + g.toggle('.type-select, .type-multiple, .type-virtual', false); + g.toggle('.type-' + type, true); + g.toggle('.type-not-virtual', false); + g.toggle('.type-not-password', false); + g.toggle('.type-not-virtual', type !== 'virtual'); + g.toggle('.type-not-password', type !== 'password'); + g.toggle('.type-not-password.type-not-virtual', type !== 'password' && type !== 'virtual'); +} + +$('#f_type').onchange = changeType; + +function normalizeString(str) { + return str.normalize('NFD').replace(/[\u0300-\u036f]/g, "") +} + +var label = $('#f_label'); +label.onkeyup = () => { + var n = $('#f_name'); + if (!n || n.disabled) { + return; + } + + n.value = normalizeString(label.value).toLowerCase().replace(/[^a-z_]+/g, '_'); +}; + +changeType(); + +var addBtn = document.createElement('button'); +addBtn.type = "button"; +addBtn.dataset.icon = "➕"; +addBtn.className = "icn-btn add"; +addBtn.title = "Ajouter une option"; + +var delBtn = document.createElement('button'); +delBtn.type = "button"; +delBtn.dataset.icon = "➖"; +delBtn.className = "icn-btn delete"; +delBtn.title = "Enlever cette option"; + +var options = $('.options dd'); + +options.forEach((o, i) => { + if (i == 0) { + return; + } + + let btn = delBtn.cloneNode(true); + btn.onclick = delOption; + o.appendChild(btn); +}); + +addPlusButton(); + +function addOption(e) { + var options = $('.options dd'); + var target = e.target; + var new_option = target.parentNode.cloneNode(true); + new_option.querySelector('input').value = ''; + + new_option.querySelectorAll('button').forEach((b) => b.remove()); + + var btn = delBtn.cloneNode(); + btn.onclick = delOption; + new_option.appendChild(btn); + + target.parentNode.parentNode.appendChild(new_option); + target.remove(); // Remove add button from previous line + + addPlusButton(); +} + +function delOption(e) { + var options = $('.options dd'); + if (options.length == 1) { + return; + } + + e.target.parentNode.remove(); + addPlusButton(); +} + +function addPlusButton () { + var options = $('.options dd'); + var btn = addBtn.cloneNode(); + btn.onclick = addOption; + + if (options.length < 30) { + let last = options[options.length - 1]; + + if (last.querySelector('.add')) { + return; + } + + last.appendChild(btn); + } +} \ No newline at end of file diff --git a/src/www/admin/static/scripts/dragdrop-table.js b/src/www/admin/static/scripts/dragdrop-table.js new file mode 100644 index 0000000..4e8d1ef --- /dev/null +++ b/src/www/admin/static/scripts/dragdrop-table.js @@ -0,0 +1,110 @@ +window.enableTableDragAndDrop = function (table) { + table.classList.add('drag'); + var items = table.querySelectorAll('tbody tr'); + + items.forEach(function (row) { + row.draggable = true; + addEvents(row); + }); + + function swapNodes(node1, node2) { + const afterNode2 = node2.nextElementSibling; + const parent = node2.parentNode; + node1.replaceWith(node2); + parent.insertBefore(node1, afterNode2); + } + + var dragSrcEl = null; + var dragTargetEl = null; + + function addEvents(row) { + row.querySelector('.up').onclick = () => swapNodes(row.previousElementSibling, row); + row.querySelector('.down').onclick = () => swapNodes(row, row.nextElementSibling); + row.addEventListener('dragstart', handleDragStart, false); + row.addEventListener('dragenter', handleDragEnter, false); + row.addEventListener('dragover', handleDragOver, false); + row.addEventListener('dragleave', handleDragLeave, false); + row.addEventListener('drop', handleDrop, false); + row.addEventListener('dragend', handleDragEnd, false); + } + + function handleDragStart(e) { + this.parentNode.parentNode.classList.add('dragging'); + this.classList.add('dragging'); + + dragTargetEl = null; + dragSrcEl = this; + + e.dataTransfer.effectAllowed = 'move'; + e.dataTransfer.setData('text/html', this.innerHTML); + + // Hide ghost image + var i = new Image; + i.src = 'data:image/gif;base64,R0lGODlhAQABAIAAAP///wAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw=='; + e.dataTransfer.setDragImage(i, 0, 0); + } + + function handleDragOver(e) { + if (e.preventDefault) { + e.preventDefault(); + } + + e.dataTransfer.dropEffect = 'move'; + + return false; + } + + function handleDragEnter(e) { + if (this == dragSrcEl) { + return; + } + + if (!table.contains(this)) { + return; + } + + this.classList.add('placeholder'); + } + + function handleDragLeave(e) { + if (!table.contains(this)) { + return; + } + + this.classList.remove('placeholder'); + + if (this == dragSrcEl) { + return; + } + } + + function handleDrop(e) { + if (e.stopPropagation) { + e.stopPropagation(); // stops the browser from redirecting. + } + + if (dragSrcEl != this) { + dragTargetEl = this; + } + + return false; + } + + function handleDragEnd(e) { + this.classList.remove('dragging'); + this.parentNode.parentNode.classList.remove('dragging'); + + if (dragTargetEl) { + this.parentNode.insertBefore(dragSrcEl, dragTargetEl.nextElementSibling); + } + + // Items list has changed + items = table.querySelectorAll('tbody tr'); + + items.forEach(function (item) { + item.classList.remove('placeholder'); + }); + } +}; + +enableTableDragAndDrop(document.querySelector('table')); diff --git a/src/www/admin/static/scripts/file_drag.js b/src/www/admin/static/scripts/file_drag.js new file mode 100644 index 0000000..c6db87b --- /dev/null +++ b/src/www/admin/static/scripts/file_drag.js @@ -0,0 +1,177 @@ +(function () { + + $('[data-upload-url]').forEach(enableFileDragDrop); + + // Detect directories to dismiss them + // see https://web.dev/patterns/files/drag-and-drop-directories/ + const supportsFileSystemAccessAPI = 'getAsFileSystemHandle' in DataTransferItem.prototype; + const supportsWebkitGetAsEntry = 'webkitGetAsEntry' in DataTransferItem.prototype; + + function isItemFile(item) { + if (item.kind !== 'file') { + return false; + } + else if (supportsFileSystemAccessAPI && item.getAsFileSystemHandle().kind == 'directory') { + return false; + } + else if (supportsWebkitGetAsEntry && (entry = item.webkitGetAsEntry()) && entry.isDirectory) { + return false; + } + else { + return true; + } + } + + function enableFileDragDrop(p) { + var drag_elements = []; + var upload_url = p.dataset.uploadUrl; + var upload_token_name = p.dataset.uploadTokenName; + var upload_token_value = p.dataset.uploadTokenValue; + + var bg = document.createElement('div'); + bg.className = 'overlay'; + var msg = document.createElement('div'); + msg.className = 'message'; + bg.appendChild(msg); + p.appendChild(bg); + + if (p === document.body) { + window.addEventListener('paste', (e) => { + const files = [...e.clipboardData.items] + .filter(isItemFile) + .map(item => item.getAsFile()); + + if (!files.length) { + return; + } + + e.preventDefault(); + document.body.appendChild(bg); + document.body.classList.add('loading'); + + for (var i = 0; i < files.length; i++) { + let f = files[i]; + let name = f.name == 'image.png' ? f.name.replace(/\./, '-' + (+(new Date)) + '.') : f.name; + + msg.innerText = 'Envoi de ' + name + '…'; + + var r = upload(upload_url, upload_token_name, upload_token_value, f, name); + + if (!r) { + break; + } + } + + window.setTimeout(() => { + location.href = location.href; + }, 500); + }); + } + + p.addEventListener('dragover', (e) => { + e.preventDefault(); + e.stopPropagation(); + }); + + p.addEventListener('dragenter', (e) => { + drag_elements.push(e.target); + + e.preventDefault(); + e.stopPropagation(); + + if (drag_elements.length == 1) { + p.classList.add('dropping'); + msg.innerText = 'Déposer des fichiers ici'; + } + }); + + p.addEventListener('dragleave', (e) => { + var idx = drag_elements.indexOf(e.target); + + if (idx === -1) { + return; + } + + drag_elements.splice(idx, 1); + + e.preventDefault(); + e.stopPropagation(); + + if (drag_elements.length === 0) { + p.classList.remove('dropping'); + } + }); + + p.addEventListener('drop', (e) => { + e.preventDefault(); + e.stopPropagation(); + p.classList.remove('dropping'); + + drag_elements = []; + + const files = [...e.dataTransfer.items] + .filter(isItemFile) + .map(item => item.getAsFile()); + + if (!files.length) return; + + document.body.appendChild(bg); + document.body.classList.add('loading'); + + (async () => { + for (var i = 0; i < files.length; i++) { + var f = files[i]; + msg.innerText = 'Envoi de ' + f.name + '…'; + + var r = upload(upload_url, upload_token_name, upload_token_value, f); + + if (!r) { + break; + } + } + + window.setTimeout(() => { + location.href = location.href; + }, 500); + })(); + }); + } + + async function upload(url, token_name, token_value, file, file_name) { + var data = new FormData(); + data.append('file', file, file_name ? file_name : file.name); + data.append(token_name, token_value); + data.append('upload', 'yes'); + + var r = await fetch(url, { + 'method': 'POST', + 'body': data, + 'headers': { + 'Accept': 'application/json' + } + }); + + if (r.ok) { + return true; + } + + console.error(r); + + if (!r.headers.get('content-type').match(/json/)) { + alert('Erreur d\'envoi : ' + r.status + ' ' + r.statusText); + return false; + } + + var r = await r.json(); + console.error(r); + + if (typeof r.message !== 'undefined') { + alert('Erreur : ' + r.message); + } + else { + alert('Erreur inconnue'); + } + + return false; + } +})(); \ No newline at end of file diff --git a/src/www/admin/static/scripts/file_input.js b/src/www/admin/static/scripts/file_input.js new file mode 100644 index 0000000..4d5553b --- /dev/null +++ b/src/www/admin/static/scripts/file_input.js @@ -0,0 +1,217 @@ +(function () { + if (!DataTransfer || !FileReader || !File) { + return; + } + + // This is using DataTransfer API to replace FileList in input, so that files can just be sent using the POST + + const enhanceFileInput = (input) => { + // When a file has been selected + const handleChange = () => { + if (!input.multiple) { + dt.items.clear(); + preview.innerHTML = ''; + } + + if (!input.files.length) { + label.innerHTML = label_unselected; + return; + } + + Array.from(input.files).forEach(addItem); + + updateLabel(); + }; + + const handleUpload = (e) => { + input.files = dt.files; + + let total = 0; + Array.from(input.files).forEach((f) => total += f.size); + + // Check size + if (total >= max_size - 1000) { + alert("Les fichiers sélectionnés dépassent la taille maximale autorisée. Merci de choisir moins de fichiers."); + e.preventDefault(); + e.stopPropagation(); + return false; + } + + input.form.firstElementChild.classList.add('progressing'); + }; + + const updateLabel = () => { + let l; + + if (dt.files.length == 0) { + l = label_unselected; + } + else if (dt.files.length == 1) { + l = label_selected[1]; + } + else { + l = label_selected[0]; + } + + label.innerHTML = '

    ' + l.replace(/%d/, dt.files.length) + '

    '; + + let total = 0; + + Array.from(dt.files).forEach((f) => total += f.size); + + // Let's assume the rest of the form is only 1000 extra bytes + if (!split_upload && total >= max_size - 1000) { + label.innerHTML += '

    Les fichiers sélectionnés dépassent la taille maximale autorisée. Merci de choisir moins de fichiers.

    '; + } + + g.resizeParentDialog(); + }; + + const getByteSize = (size) => { + if (size < 1024) + return (Math.round(size / 1024 * 10) / 10) + ' Ko'; + else if (size < 1024*1024) + return Math.round(size / 1024) + ' Ko'; + else + return (Math.round(size / 1024 / 1024 * 100) / 100) + ' Mo'; + }; + + const addItem = (f) => { + // Skip if duplicate + for (let i = 0; i < dt.files.length; i++) { + let f2 = dt.files[i]; + if (f2.name == f.name && f2.size == f.size) { + return; + } + } + + dt.items.add(f); + + let size_msg = (f.size > max_size) ? '' + '(dépasse la taille autorisée)' + '' : ''; + + let item = document.createElement('tr'); + item.innerHTML = ` + ${f.name} ${size_msg} + ${getByteSize(f.size)} + `; + + if (size_msg) { + item.className = 'disabled'; + } + + item.querySelector('button').onclick = () => { + let idx = [...preview.children].indexOf(item); + dt.items.remove(idx); + item.remove(); + updateLabel(); + }; + + preview.appendChild(item); + + // If image, add preview thumbnail + if (!f.type.startsWith('image/')) { + return; + } + + const img = document.createElement('img'); + img.file = f; + item.querySelector('.img').appendChild(img); + + const reader = new FileReader(); + reader.onload = (e) => { + img.src = e.target.result; + g.resizeParentDialog(); + }; + reader.readAsDataURL(f); + }; + + const max_size = input.form.querySelector('input[type=hidden][name=MAX_FILE_SIZE]').value; + const split_upload = 'splitUpload' in input.form.dataset && input.multiple; + + let label_unselected = (input.multiple ? '…ou glisser-déposer des fichiers ici' : '…ou glisser-déposer un fichier ici'); + let label_button = (input.multiple ? 'Sélectionner des fichiers' : 'Sélectionner un fichier'); + let label_selected = ['%d fichiers sélectionnés', '1 fichier sélectionné']; + + input.form.addEventListener('submit', handleUpload, false); + + // Hide real input + input.style.display = 'none'; + + let container = document.createElement('div'); + container.className = 'file-selector'; + + let btn = document.createElement('button'); + btn.className = 'icn-btn'; + btn.setAttribute('data-icon', '⇑'); + btn.type = 'button'; + btn.innerHTML = label_button; + btn.onclick = () => input.click(); + + let label = document.createElement('label'); + label.setAttribute('for', input.id); + label.innerHTML = label_unselected; + + let preview = document.createElement('table'); + preview.className = 'preview list'; + + container.appendChild(btn); + container.appendChild(label); + container.appendChild(preview); + + let dt = new DataTransfer(); + + const drag = (e) => { + e.stopPropagation(); + e.preventDefault(); + }; + + container.addEventListener('dragenter', drag, false); + container.addEventListener('dragover', drag, false); + + container.addEventListener('drop', (e) => { + e.stopPropagation(); + e.preventDefault(); + + if (!e.dataTransfer.files.length) { + return; + } + + if (!input.multiple) { + dt.items.clear(); + preview.innerHTML = ''; + } + + Array.from(e.dataTransfer.files).forEach(addItem); + updateLabel(); + }, false); + + input.addEventListener('change', handleChange); + input.addItem = addItem; + + input.parentNode.insertBefore(container, input.nextSibling); + + // Support paste events, if there's only one file input in the document + if (document.querySelectorAll('input[type=file][data-enhanced]').length == 1) { + const IMAGE_MIME_REGEX = /^image\/(p?jpeg|gif|png)$/i; + + document.addEventListener('paste', (e) => { + let items = e.clipboardData.items; + + for (var i = 0; i < items.length; i++) { + if (IMAGE_MIME_REGEX.test(items[i].type)) { + let f = items[i].getAsFile(); + let name = f.name.replace(/\./, '-' + (+(new Date)) + '.'); + let f2 = new File([f], name, {type: f.type}); + addItem(f2); + e.preventDefault(); + return; + } + } + }); + } + }; + + document.querySelectorAll('input[type=file][data-enhanced]').forEach((e) => { + enhanceFileInput(e); + }); +}()); \ No newline at end of file diff --git a/src/www/admin/static/scripts/global.js b/src/www/admin/static/scripts/global.js new file mode 100644 index 0000000..8c4c5c3 --- /dev/null +++ b/src/www/admin/static/scripts/global.js @@ -0,0 +1,777 @@ +(function () { + let d = document.documentElement.dataset; + window.g = window.garradin = { + admin_url: d.url, + static_url: d.url + 'static/', + version: d.version, + loaded: {} + }; + + window.$ = function(selector) { + if (!selector.match(/^[.#]?[a-z0-9_-]+$/i)) + { + return document.querySelectorAll(selector); + } + else if (selector.substr(0, 1) == '.') + { + return document.getElementsByClassName(selector.substr(1)); + } + else if (selector.substr(0, 1) == '#') + { + return document.getElementById(selector.substr(1)); + } + else + { + return document.getElementsByTagName(selector); + } + }; + + if (!document.querySelectorAll) + { + return; + } + + g.onload = function(callback, dom) + { + if (typeof dom == 'undefined') + dom = true; + + var eventName = dom ? 'DOMContentLoaded' : 'load'; + + document.addEventListener(eventName, callback, false); + }; + + g.toggle = function(selector, visibility, resize_parent) + { + if (!('classList' in document.documentElement)) + return false; + + if (selector instanceof Array) + { + for (var i = 0; i < selector.length; i++) + { + g.toggle(selector[i], visibility, false); + } + + if (resize_parent !== false) { + g.resizeParentDialog(); + } + + return true; + } + else if (selector instanceof HTMLElement) { + var elements = [selector]; + } + else { + var elements = document.querySelectorAll(selector); + } + + for (var i = 0; i < elements.length; i++) { + elements[i].classList.toggle('hidden', visibility ? false : true); + + elements[i].querySelectorAll('[data-required]').forEach(e => { + e.required = parseInt(e.dataset.required, 10); + }); + + // Make sure hidden elements are not really required + // Avoid Chrome bug "An invalid form control with name='' is not focusable." + elements[i].querySelectorAll('input[required], textarea[required], select[required], button[required]').forEach((e) => { + if (typeof e.dataset.disabled === 'undefined') { + e.dataset.disabled = e.hasAttribute('disabled') ? 1 : 0; + } + + e.disabled = !visibility ? true : parseInt(e.dataset.disabled, 10); + }); + } + + if (resize_parent !== false) { + g.resizeParentDialog(); + } + + return true; + }; + + g.script = function (file, callback) { + if (file in g.loaded) { + callback(); + return; + } + + var script = g.loaded[file] = document.createElement('script'); + script.type = 'text/javascript'; + script.src = this.static_url + file + '?' + g.version; + script.onload = callback; + document.head.appendChild(script); + }; + + g.style = function (file) { + var link = document.createElement('link'); + link.rel = 'stylesheet'; + link.type = 'text/css'; + link.href = this.static_url + file + '?' + g.version; + return document.head.appendChild(link); + }; + + g.dialog = null; + g.focus_before_dialog = null; + g.dialog_on_close = false; + + g.openDialog = function (content, options) { + var close = true, + callback = null, + classname = null; + + if (typeof options === "object" && options !== null) { + callback = options.callback ?? null; + classname = options.classname ?? null; + close = options.close ?? true; + g.dialog_on_close = options.on_close || false; + } + else { + var options = {}; + } + + if (null !== g.dialog) { + g.closeDialog(); + } + + g.focus_before_dialog = document.activeElement; + + g.dialog = document.createElement('dialog'); + g.dialog.id = 'dialog'; + g.dialog.open = true; + g.dialog.className = classname || ''; + + if (close) { + var btn = document.createElement('button'); + btn.className = 'icn-btn closeBtn'; + btn.setAttribute('data-icon', '✘'); + btn.type = 'button'; + btn.innerHTML = 'Fermer'; + btn.onclick = g.closeDialog; + g.dialog.appendChild(btn); + + g.dialog.onclick = (e) => { if (e.target == g.dialog) g.closeDialog(); }; + window.onkeyup = (e) => { if (e.key == 'Escape') g.closeDialog(); }; + } + + if (typeof content == 'string') { + var container = document.createElement('div'); + container.innerHTML = content; + content = container; + } + else if (content instanceof DocumentFragment) { + var container = document.createElement('div'); + container.appendChild(content.cloneNode(true)); + content = container; + } + + g.dialog.appendChild(content); + + g.dialog.style.opacity = 0; + + let tag = content.tagName.toLowerCase(); + + if (tag == 'img' || tag == 'iframe') { + event = 'load'; + } + else if (tag == 'audio' || tag == 'video') { + event = 'canplaythrough'; + } + + if (tag === 'img') { + content.onclick = g.closeDialog; + } + + if (event) { + content.addEventListener(event, () => { if (g.dialog) g.dialog.classList.add('loaded'); }); + + if (event && callback) { + content.addEventListener(event, callback); + } + + if (options.caption ?? null) { + var caption = document.createElement('h4'); + caption.className = 'title'; + caption.innerText = options.caption; + g.dialog.appendChild(caption); + } + } + else { + g.dialog.classList.add('loaded'); + } + + document.body.appendChild(g.dialog); + + // Restore CSS defaults + window.setTimeout(() => { g.dialog.style.opacity = ''; }, 50); + + return content; + } + + g.openFrameDialog = function (url, options) { + options = options ?? {}; + options.height = options.height || 'auto'; + options.callback = options.callback || null; + options.classname = options.classname || null; + + var iframe = document.createElement('iframe'); + iframe.src = url; + iframe.name = 'dialog'; + iframe.id = 'frameDialog'; + iframe.frameborder = '0'; + iframe.scrolling = 'yes'; + iframe.width = iframe.height = 0; + iframe.setAttribute('data-height', options.height); + + iframe.addEventListener('load', () => { + iframe.contentWindow.onkeyup = (e) => { if (e.key == 'Escape') g.closeDialog(); }; + + if (iframe.parentNode.className) { + return; + } + + // We need to wait a bit for the height to be correct, not sure why + window.setTimeout(() => { + iframe.style.height = iframe.dataset.height == 'auto' && iframe.contentWindow.document.body ? iframe.contentWindow.document.body.offsetHeight + 'px' : iframe.dataset.height; + }, 200); + }); + + g.openDialog(iframe, options); + return iframe; + }; + + g.reloadParentDialog = () => { + if (typeof window.parent.g === 'undefined' || !window.parent.g.dialog) { + return; + } + + location.href = window.parent.g.dialog.querySelector('iframe').getAttribute('src'); + }; + + g.setParentDialogHeight = (height) => { + if (typeof window.parent.g === 'undefined' || !window.parent.g.dialog) { + return; + } + + window.parent.g.dialog.querySelector('iframe').setAttribute('data-height', height); + g.resizeParentDialog(height); + }; + + g.toggleDialogFullscreen = () => { + g.dialog.classList.add('fullscreen') + g.dialog.childNodes[1].style.height = null; + }; + + g.resizeParentDialog = (forced_height) => { + if (typeof window.parent.g === 'undefined' || !window.parent.g.dialog) { + return; + } + + let height; + + if (forced_height) { + height = forced_height; + } + else { + let body_height = document.body.offsetHeight; + let parent_height = window.parent.innerHeight; + + if (body_height > parent_height * 0.9) { + height = '90%'; + } + else { + height = body_height + 'px'; + } + } + + window.parent.g.dialog.childNodes[1].style.height = height; + }; + + g.closeDialog = function () { + if (null === g.dialog) { + return; + } + + if (g.dialog.preventClose && g.dialog.preventClose()) { + return false; + } + + if (g.dialog_on_close) { + location.href = g.dialog_on_close == true ? location.href : g.dialog_on_close.replace(/!/, g.admin_url); + return; + } + + var d = g.dialog; + d.style.opacity = 0; + window.onkeyup = g.dialog = null; + + window.setTimeout(() => { d.parentNode.removeChild(d); }, 500); + + if (g.focus_before_dialog) { + g.focus_before_dialog.focus(); + } + }; + + g.openFormInDialog = (form) => { + if (form.target != '_dialog' && form.target != 'dialog') { + return; + } + + let url = form.getAttribute('action'); + url = url + (url.indexOf('?') > 0 ? '&' : '?') + '_dialog'; + form.setAttribute('action', url); + form.target = 'dialog'; + + g.openFrameDialog('about:blank', {'height': form.getAttribute('data-dialog-height') ? 90 : 'auto'}); + form.submit(); + return false; + }; + + g.checkUncheck = function() + { + var checked = this.checked; + this.form.querySelectorAll('tbody input[type=checkbox]').forEach((elm) => { + elm.checked = checked; + elm.dispatchEvent(new Event("change")); + }); + + this.form.querySelectorAll('thead input[type=checkbox], tfoot input[type=checkbox]').forEach((elm) => { + elm.checked = checked; + }); + + return true; + }; + + g.togglePasswordVisibility = (field, repeat, show) => { + if (typeof show == 'undefined') { + show = field.type.toLowerCase() == 'password'; + } + + var btn = field.nextSibling; + + if (!btn) { + throw Error('button not found'); + } + + field.type = show ? 'text' : 'password'; + btn.dataset.icon = !show ? '👁' : '⤫'; + btn.innerHTML = !show ? 'Voir le mot de passe' : 'Cacher le mot de passe'; + field.classList.toggle('clearTextPassword', !show); + + if (repeat) { + repeat.type = field.type; + repeat.classList.toggle('clearTextPassword', !show); + } + }; + + /** + * Adds a "show password" button next to password inputs + */ + g.enhancePasswordField = function (field) + { + if (field.id.indexOf('_confirmed') != -1) { + return; + } + + var show_password = document.createElement('button'); + show_password.type = 'button'; + show_password.className = 'icn-btn'; + + field.parentNode.insertBefore(show_password, field.nextSibling); + + let repeat_field = document.getElementById(field.id + '_confirmed'); + + g.togglePasswordVisibility(field, repeat_field, false); + + show_password.onclick = function (e) { + var pos = field.selectionStart; + + g.togglePasswordVisibility(field, repeat_field); + + // Remettre le focus sur le champ mot de passe + // on ne peut pas vraiment remettre le focus sur le champ + // précis qui était utilisé avant de cliquer sur le bouton + // car il faudrait enregistrer les actions onfocus de tous + // les champs de la page + field.focus(); + field.selectionStart = field.selectionEnd = pos; + }; + }; + + g.enhanceDateField = (input) => { + var span = document.createElement('span'); + span.className = 'datepicker-parent'; + var btn = document.createElement('button'); + var cal = null; + btn.className = 'icn-btn'; + btn.title = 'Cliquer pour ouvrir le calendrier. Utiliser les flèches du clavier pour sélectionner une date, et page précédente suivante pour changer de mois.'; + btn.setAttribute('data-icon', '📅'); + btn.type = 'button'; + btn.onclick = () => { + g.script('scripts/lib/datepicker2.min.js', () => { + if (null == cal) { + btn.onclick = null; + cal = new DatePicker(btn, input, {lang: 'fr', format: 1}); + cal.open(); + } + }); + }; + span.appendChild(btn); + input.parentNode.insertBefore(span, input.nextSibling); + + const getCaretPosition = e => e && e.selectionStart || -1; + + const inputKeyEvent = (e) => { + if (input.value.match(/^\d$|^\d\d?\/\d$/) && e.key.match(/^[0-9]$/)) { + input.value += e.key + '/'; + e.preventDefault(); + return false; + } + + if (e.key == '/' && input.value.slice(-1) == '/') { + e.preventDefault(); + return false; + } + + }; + input.addEventListener('keydown', inputKeyEvent, true); + }; + + g.current_list_input = null; + + g.inputListSelected = function(value, label) { + var i = g.current_list_input; + + if (!i) { + throw Error('Parent input list not found'); + } + + var can_delete = i.firstChild.getAttribute('data-can-delete'); + var multiple = i.firstChild.getAttribute('data-multiple'); + var name = i.firstChild.getAttribute('data-name'); + + var span = document.createElement('span'); + span.className = 'label'; + span.innerHTML = '' + label; + + // Add delete button + if (can_delete == 1) { + var btn = document.createElement('button'); + btn.className = 'icn-btn'; + btn.type = 'button'; + btn.setAttribute('data-icon', '✘'); + btn.onclick = () => span.parentNode.removeChild(span); + span.appendChild(btn); + } + + if (!multiple && (old = i.querySelector('span'))) { + i.removeChild(old); + } + + i.appendChild(span); + g.closeDialog(); + i.firstChild.focus(); + }; + + g.formatMoney = (v) => { + if (!v) { + return '0,00'; + } + + var s = v < 0 ? '-' : ''; + v = '' + Math.abs(v); + return s + (v.substr(0, v.length-2) || '0') + ',' + ('00' + v).substr(-2); + }; + + g.getMoneyAsInt = (v) => { + v = v.replace(/[^0-9.,]/, ''); + if (v.length == 0) return; + + v = v.split(/[,.]/); + var d = v.length == 2 ? v[1] : '0'; + v = v[0] + (d + '00').substr(0, 2); + v = parseInt(v, 10); + return v; + }; + + // Focus on first form input when loading the page + g.onload(() => { + if (!document.activeElement || document.activeElement.tagName.toLowerCase() == 'body') { + let form = document.querySelector('form[data-focus]'); + + if (!form) { + return; + } + + let f = form.dataset.focus; + let n = f.match(/^\d+$/) ? (parseInt(f, 10) - 1) : null; + let i = form.querySelectorAll(n !== null ? '[name]:not([type="hidden"]):not([readonly]):not([type=button])' : f); + + if (n !== null && i[n]) { + i[n].focus(); + } + else if (n === null && i[0]) { + i[0].focus(); + } + } + }); + + // Sélecteurs de listes + g.onload(() => { + var inputs = $('form .input-list > button'); + + inputs.forEach((i) => { + i.onclick = () => { + i.setCustomValidity(''); + g.current_list_input = i.parentNode; + var max = i.getAttribute('data-max'); + + if (max && max <= i.parentNode.querySelectorAll('span').length) { + alert('Il n\'est pas possible de faire plus de ' + max + ' choix.'); + return false; + } + + let url = i.value + (i.value.indexOf('?') > 0 ? '&' : '?') + '_dialog'; + g.openFrameDialog(url); + return false; + }; + }); + + // Set custom error message if required list is not selected + document.querySelectorAll('form').forEach((form) => { + let elements = form.elements; + + // Make sure hidden or disabled form elements are not required + for (var j = 0; j < elements.length; j++) { + var element = elements[j]; + + if (element.required && (element.disabled || !element.offsetParent)) { + element.dataset.required = element.hasAttribute('required') ? 1 : 0; + element.required = false; + } + } + + form.addEventListener('submit', (e) => { + let elements = form.elements; + + // Make sure hidden or disabled form elements are not required + for (var j = 0; j < elements.length; j++) { + var element = elements[j]; + + if (element.disabled || !element.offsetParent) { + element.required = false; + } + } + + let inputs = form.querySelectorAll('.input-list > button[required]'); + + for (var k = 0; k < inputs.length; k++) { + var i2 = inputs[k]; + + // Ignore hidden / disabled form elements + if (i2.disabled || !i2.offsetParent) { + continue; + } + + let v = i2.parentNode.querySelector('input[type="hidden"]:nth-child(1)'); + + if (!v || !v.value) { + // Force button to have error message,