commit 793bb6a48880146dff5398eb0d4e81f15100db2b Author: Claude Paroz Date: Mon Jun 3 16:49:01 2024 +0200 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9eb5f12 --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +*.pyc +*.sqlite3 +*.dump +settings/__init__.py +/media/ +/fixtures/ +scripts/* +/static/ +.idea/* +.git/* +info/* +stock +data/* diff --git a/INSTALL b/INSTALL new file mode 100644 index 0000000..2d4c775 --- /dev/null +++ b/INSTALL @@ -0,0 +1,12 @@ +Base de données pour la gestion de CRNE +======================================= + +Installation de la clé de chiffrement/déchiffrement +--------------------------------------------------- + + +Une clé RSA permet de chiffrer les données: elle est générée par la commande suivante: + +ssh-keygen -t rsa -b 4096 -m PEM -C"secretariat@fondation-transit.ch" + +La clé privée est conservée sur une clé USB propriété de la CRNE. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..be3f7b2 --- /dev/null +++ b/LICENSE @@ -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..8fd14b5 --- /dev/null +++ b/README.md @@ -0,0 +1,51 @@ +# AEMO - Fribourg + +## Description + +TODO + +## Dev environment with docker-compose + +Prerequisites: + +- Docker & docker-compose installed on your system +- "docker" group exists on your system (`sudo groupadd docker` will fail anyway if it exists) +- Your user in "docker" group (`sudo usermod -aG docker $USER` + restart your system) + +### Setup + +You first have to create the untracked file "settings/__init__.py" to be able to run the docker environment. +Use command line: `echo "from settings.dev_docker import *" > settings/__init__.py` + +Then, you have to add your POST API credentials in it. +settings/__init__.py should looks like: + +```python +from settings.dev_docker import * + + +POST_API_USER = 'your user' +POST_API_PASSWORD = 'your password' +``` + +### Quick start + +To run a dev environment: `docker-compose up -d` +Then you'll be able to reach your local environment on localhost:8000 + +To collect static: `docker-compose exec web /src/manage.py collectstatic` + +To restart & rebuild your environment: `docker-compose down --remove-orphans && docker-compose up -d --build` + +To use Python debugger (breakpoint): `docker attach $(docker-compose ps -q web)` + +### Clone production data in your dev environment + +There are three fabric tasks you can use to clone & prepare production data in your dev environment: + +- `fab -r docker-dev -H download-remote-data` dumps remote (production) DB, downloads it and synchronizes media +- `fab -r docker-dev import-db-in-dev` overwrites local DB with the production DB +- `fab -r docker-dev create-admin-in-dev` create a superuser with credentials admin/admin + +You can also proceed all tasks in the same command with: +`fab -r docker-dev -H download-remote-data import-db-in-dev create-admin-in-dev` diff --git a/aemo/__init__.py b/aemo/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/aemo/admin.py b/aemo/admin.py new file mode 100644 index 0000000..9395aaf --- /dev/null +++ b/aemo/admin.py @@ -0,0 +1,197 @@ +from django.contrib import admin +from django.contrib.auth.admin import GroupAdmin, UserAdmin +from django.contrib.auth.forms import UserChangeForm +from django.contrib.auth.models import Group, Permission + +from .models import ( + Bilan, CercleScolaire, Contact, Document, Famille, Formation, GroupInfo, + Intervenant, LibellePrestation, Personne, Prestation, Rapport, Region, Role, + Service, Suivi, Utilisateur +) + + +class TypePrestationFilter(admin.SimpleListFilter): + title = 'Prest. famil./générales' + parameter_name = 'prest' + default_value = None + + def lookups(self, request, model_admin): + return ( + ('fam', 'Familiales'), + ('gen', 'Générales'), + ) + + def queryset(self, request, queryset): + value = self.value() + if value == 'fam': + return queryset.filter(famille__isnull=False).order_by('famille__nom') + elif value == 'gen': + return queryset.filter(famille__isnull=True).order_by('-date_prestation') + return queryset + + +class RegionFilter(admin.SimpleListFilter): + title = 'Région' + parameter_name = 'region' + + def lookups(self, request, model_admin): + typ = model_admin.model._meta.app_label + kwargs = {f"{typ}": True} + return [(reg.pk, reg.nom) for reg in Region.objects.filter(**kwargs)] + + def queryset(self, request, queryset): + if self.value(): + return queryset.filter(region_id=self.value()) + return queryset + + +class DocumentInline(admin.TabularInline): + model = Document + extra = 1 + + +class PersonneInLine(admin.StackedInline): + model = Personne + exclude = ('reseaux', 'remarque') + extra = 1 + + +@admin.register(Personne) +class PersonneAdmin(admin.ModelAdmin): + list_display = ('nom_prenom', 'adresse') + search_fields = ('nom', 'prenom') + + +@admin.register(Contact) +class ContactAdmin(admin.ModelAdmin): + list_display = ('nom', 'prenom', 'service', 'roles_display', 'est_actif') + list_filter = ('service', 'est_actif') + search_fields = ('nom', 'prenom', 'service__sigle') + + def roles_display(self, contact): + return contact.roles_str(sep=' / ') + roles_display.short_description = 'Rôles' + + +@admin.register(Famille) +class FamilleAdmin(admin.ModelAdmin): + list_display = ('nom', 'npa', 'localite', 'get_region') + list_filter = (RegionFilter,) + inlines = [PersonneInLine, DocumentInline] + ordering = ('nom',) + search_fields = ('nom', 'localite') + + def get_region(self, obj): + return obj.region.nom if obj.region else None + get_region.short_description = 'Région' + + def formfield_for_foreignkey(self, db_field, request, **kwargs): + if db_field.name == 'region': + app_label = self.model._meta.app_label + param = {f"{app_label}": True} + kwargs['queryset'] = Region.objects.filter(**param) + kwargs['label'] = 'Région' + return super().formfield_for_foreignkey(db_field, request, **kwargs) + + +@admin.register(Formation) +class FormationAdmin(admin.ModelAdmin): + list_display = ('personne', 'statut') + search_fields = ('personne__nom',) + ordering = ('personne__nom',) + + +class UtilisateurChangeForm(UserChangeForm): + class Meta(UserChangeForm.Meta): + model = Utilisateur + + +@admin.register(Utilisateur) +class UtilisateurAdmin(UserAdmin): + form = UtilisateurChangeForm + list_display = [ + 'nom', 'prenom', 'sigle', 'tel_prof', 'tel_prive', 'email', + 'taux_activite', 'is_active', 'last_login' + ] + fieldsets = UserAdmin.fieldsets + ( + (None, {'fields': ( + 'sigle', 'prenom', 'nom', 'rue', 'npa', 'localite', + 'tel_prof', 'tel_prive', 'service', 'taux_activite' + )}), + ) + + +admin.site.unregister(Group) + + +class GroupInfoInline(admin.StackedInline): + model = GroupInfo + + +@admin.register(Group) +class GroupAdmin(GroupAdmin): + inlines = [GroupInfoInline] + + +@admin.register(Region) +class RegionAdmin(admin.ModelAdmin): + list_display = ['nom'] + + +@admin.register(Role) +class RoleAdmin(admin.ModelAdmin): + list_display = ['nom', 'est_famille', 'est_intervenant', 'est_editeur'] + + +@admin.register(Service) +class ServiceAdmin(admin.ModelAdmin): + list_display = ['sigle', 'nom_complet'] + + +@admin.register(LibellePrestation) +class LibellePrestationAdmin(admin.ModelAdmin): + list_display = ['code', 'nom'] + + +@admin.register(Permission) +class PermissionAdmin(admin.ModelAdmin): + search_fields = ['name', 'codename'] + + +@admin.register(Document) +class DocumentAdmin(admin.ModelAdmin): + list_display = ('famille', 'titre') + search_fields = ('famille__nom',) + ordering = ('famille__nom',) + + +@admin.register(Suivi) +class SuiviAdmin(admin.ModelAdmin): + list_display = ('famille', 'equipe', 'etape') + list_filter = ('equipe',) + ordering = ('famille__nom',) + search_fields = ('famille__nom',) + + +@admin.register(Intervenant) +class IntervenantAdmin(admin.ModelAdmin): + list_display = ('intervenant', 'famille', 'role', 'date_debut', 'date_fin', 'fin_suivi') + list_filter = ('role',) + + def famille(self, obj): + return obj.suivi.famille + + def fin_suivi(self, obj): + return obj.suivi.date_fin_suivi + + +@admin.register(Prestation) +class PrestationAdmin(admin.ModelAdmin): + list_display = ('lib_prestation', 'date_prestation', 'duree', 'auteur') + list_filter = (TypePrestationFilter,) + search_fields = ('famille__nom', 'texte') + + +admin.site.register(Bilan) +admin.site.register(Rapport) +admin.site.register(CercleScolaire) diff --git a/aemo/export.py b/aemo/export.py new file mode 100644 index 0000000..b65f1a5 --- /dev/null +++ b/aemo/export.py @@ -0,0 +1,212 @@ +from datetime import timedelta +from tempfile import NamedTemporaryFile + +from django.http import HttpResponse + +from openpyxl import Workbook +from openpyxl.styles import Font +from openpyxl.utils import get_column_letter + +from .utils import format_d_m_Y + +openxml_contenttype = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' + + +class OpenXMLExport: + def __init__(self, sheet_title=None): + self.wb = Workbook() + self.ws = self.wb.active + if sheet_title: + self.ws.title = sheet_title + self.bold = Font(name='Calibri', bold=True) + self.row_idx = 1 + + def write_line(self, values, bold=False, col_widths=()): + # A values item can be an object with a `value` attribute + for col_idx, value in enumerate(values, start=1): + cell = self.ws.cell(row=self.row_idx, column=col_idx) + if isinstance(value, timedelta): + cell.number_format = '[h]:mm;@' + elif hasattr(value, 'number_format'): + cell.number_format = value.number_format + cell.value = getattr(value, 'value', value) + if bold: + cell.font = self.bold + if col_widths and len(col_widths) >= col_idx: + self.ws.column_dimensions[get_column_letter(col_idx)].width = col_widths[col_idx - 1] + self.row_idx += 1 + + def add_sheet(self, title): + self.wb.create_sheet(title) + self.ws = self.wb[title] + self.row_idx = 1 + + def get_http_response(self, filename): + with NamedTemporaryFile() as tmp: + self.wb.save(tmp.name) + tmp.seek(0) + response = HttpResponse(tmp, content_type=openxml_contenttype) + response['Content-Disposition'] = 'attachment; filename="{}"'.format(filename) + return response + + +class ExportReporting(OpenXMLExport): + def __init__(self): + super().__init__('temp') + self.first_sheet = True + # Totaux pour vérification interne dans les tests. + self._total_spe = {'eval': timedelta(0), 'suivi': timedelta(0), 'gen': timedelta(0)} + + def setup_sheet(self, title): + if not self.first_sheet: + self.add_sheet(title) + else: + self.ws.title = title + self.first_sheet = False + + def produce_suivis(self, sheet_title, query, mois): + self.setup_sheet(sheet_title) + SuiviSheet(self, mois).produce(query) + + def produce_nouveaux(self, sheet_title, query): + self.setup_sheet(sheet_title) + NouveauxSheet(self).produce(query) + + def produce_termines(self, sheet_title, query): + self.setup_sheet(sheet_title) + TerminesSheet(self).produce(query) + + def produce_totaux(self, sheet_title): + self.setup_sheet(sheet_title) + self.write_line(['Évaluation', self._total_spe['eval']], col_widths=[25, 10]) + self.write_line(['Accompagnement', self._total_spe['suivi']]) + self.write_line(['Intervention générale', self._total_spe['gen']]) + self.write_line(['Total', self._total_spe['eval'] + self._total_spe['suivi'] + self._total_spe['gen']]) + + +class BaseFamilleSheet: + en_tetes = [ + ('Institution', 12), ('Prestation', 10), ('Nom', 25), ('Prenom', 15), ('Genre', 8), + ('Date de naissance', 17), ('Adresse', 20), ('NPA', 8), ('Localité', 20), ('Canton', 8), + ('OPE', 25), ('Nom mère', 20), ('Prénom mère', 20), ('Nom père', 20), ('Prénom père', 20), + ('Autorité parentale', 18), ('Statut marital', 15), ('Statut financier', 15), + ('Fam. monopar.', 15), ('Nbre enfants', 12), + ('Date demande', 15), ('Provenance', 15), ('Motif demande', 40), ('Début suivi', 15), + ] + + def __init__(self, exp): + self.exp = exp + + def _set_col_dimensions(self): + for col_idx, (_, size) in enumerate(self.en_tetes, start=1): + self.exp.ws.column_dimensions[get_column_letter(col_idx)].width = size + + def produce(self, query): + self._set_col_dimensions() + self.exp.write_line([et[0] for et in self.en_tetes], bold=True) + for famille in query: + self.produce_famille(famille) + self.exp.ws.freeze_panes = self.exp.ws['A2'] + + def produce_famille(self, famille): + membres_suivis = famille.membres_suivis() + if membres_suivis: + for pers in famille.membres_suivis(): + data = self.collect_pers_data(famille, pers) + self.exp.write_line(data) + else: + data = self.collect_pers_data(famille, None) + self.exp.write_line(data) + + def collect_pers_data(self, famille, pers): + parents = famille.parents() + mere = next((par for par in parents if par.role.nom == 'Mère'), None) + pere = next((par for par in parents if par.role.nom == 'Père'), None) + return [ + 'Fondation Transit', + 'AEMO', + pers.nom if pers else famille.nom, + pers.prenom if pers else "-", + pers.genre if pers else "-", + format_d_m_Y(pers.date_naissance) if pers else "-", + pers.rue if pers else famille.rue, + pers.npa if pers else famille.npa, + pers.localite if pers else famille.localite, + 'NE', + famille.suivi.ope_referent.nom_prenom if famille.suivi.ope_referent else '', + mere.nom if mere else '', + mere.prenom if mere else '', + pere.nom if pere else '', + pere.prenom if pere else '', + famille.get_autorite_parentale_display(), + famille.get_statut_marital_display(), + famille.get_statut_financier_display(), + {True: 'OUI', False: 'NON', None: ''}[famille.monoparentale], + len(famille.membres_suivis()) + len(famille.enfants_non_suivis()), + + format_d_m_Y(famille.suivi.date_demande), + famille.get_provenance_display(), + famille.suivi.get_motif_demande_display(), + format_d_m_Y(famille.suivi.date_debut_suivi), + ] + + +class SuiviSheet(BaseFamilleSheet): + en_tetes = BaseFamilleSheet.en_tetes + [('H. Évaluation', 12), ('H. Suivi', 12), ('H. Prest. gén.', 12)] + + def __init__(self, exp, date_debut_mois): + self.date_debut_mois = date_debut_mois + super().__init__(exp) + + def produce_famille(self, famille): + # Prepare some data common to famille + nb_membres_suivis = len(famille.membres_suivis()) or 1 + famille._h_evaluation_par_pers = famille.total_mensuel_evaluation(self.date_debut_mois) // nb_membres_suivis + famille._h_suivi_par_pers = famille.total_mensuel_suivi(self.date_debut_mois) // nb_membres_suivis + super().produce_famille(famille) + + def collect_pers_data(self, famille, pers): + data = super().collect_pers_data(famille, pers) + h_evaluation = famille._h_evaluation_par_pers + h_suivi = famille._h_suivi_par_pers + h_prest_gen = famille.prest_gen # Annotation from the view. + data.extend([h_evaluation, h_suivi, h_prest_gen]) + # Variables des totaux + self.exp._total_spe['eval'] += h_evaluation + self.exp._total_spe['suivi'] += h_suivi + self.exp._total_spe['gen'] += h_prest_gen + return data + + +class NouveauxSheet(BaseFamilleSheet): + pass + + +class TerminesSheet(BaseFamilleSheet): + en_tetes = BaseFamilleSheet.en_tetes + [ + ('Date fin suivi', 15), ('Motif fin suivi', 15), ('Destination', 15), ('Total heures', 12), + ] + + def collect_pers_data(self, famille, pers): + data = super().collect_pers_data(famille, pers) + suivi = famille.suivi + data.extend([ + format_d_m_Y(suivi.date_fin_suivi), + famille.suivi.get_motif_fin_suivi_display(), + famille.get_destination_display(), + famille.temps_total_prestations_reparti(), + ]) + return data + + +class ExportStatistique(OpenXMLExport): + def __init__(self, *args, col_widths=None, **kwargs): + self.col_widths = col_widths + super().__init__(*args, **kwargs) + + def fill_data(self, generator): + for row in generator: + if row and row[0] == 'BOLD': + self.write_line(row[1:], bold=True, col_widths=self.col_widths) + else: + self.write_line(row, col_widths=self.col_widths) diff --git a/aemo/file_array_field.py b/aemo/file_array_field.py new file mode 100644 index 0000000..48a898c --- /dev/null +++ b/aemo/file_array_field.py @@ -0,0 +1,131 @@ +"""Uploaded on https://code.djangoproject.com/ticket/25756 by Riccardo Di Virgilio""" + +from django import forms +from django.contrib.postgres.fields import ArrayField +from django.core.exceptions import ValidationError +from django.db.models.fields.files import FieldFile, File + + +class MultiFileInput(forms.FileInput): + + def render(self, name, value, attrs={}, renderer=None): + attrs['multiple'] = 'multiple' + return super().render(name, None, attrs=attrs) + + def value_from_datadict(self, data, files, name): + if hasattr(files, 'getlist'): + return files.getlist(name) + else: + return [files.get(name)] + + +class MultiFileField(forms.FileField): + widget = MultiFileInput + default_error_messages = { + 'min_num': "Ensure at least %(min_num)s files are uploaded (received %(num_files)s).", + 'max_num': "Ensure at most %(max_num)s files are uploaded (received %(num_files)s).", + 'file_size': "File: %(uploaded_file_name)s, exceeded maximum upload size." + } + + def __init__(self, *args, **kwargs): + self.min_num = kwargs.pop('min_num', 0) + self.max_num = kwargs.pop('max_num', None) + self.maximum_file_size = kwargs.pop('maximum_file_size', None) + super().__init__(*args, **kwargs) + + def to_python(self, data): + ret = [] + for item in data: + ret.append(super().to_python(item)) + return ret + + def validate(self, data): + super().validate(data) + num_files = len(data) + if len(data) and not data[0]: + num_files = 0 + if num_files < self.min_num: + raise ValidationError(self.error_messages['min_num'] % {'min_num': self.min_num, 'num_files': num_files}) + elif self.max_num and num_files > self.max_num: + raise ValidationError(self.error_messages['max_num'] % {'max_num': self.max_num, 'num_files': num_files}) + for uploaded_file in data: + if self.maximum_file_size and uploaded_file.size > self.maximum_file_size: + raise ValidationError(self.error_messages['file_size'] % {'uploaded_file_name': uploaded_file.name}) + + def clean(self, data, initial=None): + value = super().clean(data, initial=initial) + # Do not overwrite, but append to initial + if data and initial: + value = initial + value + return value + + +def to_file_object(field, instance, file): + if isinstance(file, str) or file is None: + return field.attr_class(instance, field, file) + elif isinstance(file, File) and not isinstance(file, FieldFile): + file_copy = field.attr_class(instance, field, file.name) + file_copy.file = file + file_copy._committed = False + return file_copy + elif isinstance(file, FieldFile) and not hasattr(file, 'field'): + file.instance = instance + file.field = field + file.storage = field.storage + return file + else: + return file + + +class ArrayFileDescriptor: + + def __init__(self, field): + self.field = field + + def __get__(self, instance=None, owner=None): + if instance is None: + raise AttributeError( + "The '%s' attribute can only be accessed from %s instances." + % (self.field.name, owner.__name__)) + + return [ + to_file_object(self.field.base_field, instance, file) + for file in (instance.__dict__[self.field.name] or []) + ] + + def __set__(self, instance, value): + instance.__dict__[self.field.name] = value + + +class ArrayFileField(ArrayField): + + descriptor_class = ArrayFileDescriptor + + def set_attributes_from_name(self, name): + super(ArrayField, self).set_attributes_from_name(name) + self.base_field.set_attributes_from_name("%s_array" % name) + + def contribute_to_class(self, cls, name, **kwargs): + super().contribute_to_class(cls, name, **kwargs) + setattr(cls, self.name, self.descriptor_class(self)) + + def pre_save(self, instance, add): + "Returns field's value just before saving." + files = [ + to_file_object(self.base_field, instance, file) + for file in super(ArrayField, self).pre_save(instance, add) + ] + + for file_copy in files: + if file_copy and not file_copy._committed: + file_copy.save(file_copy.name, file_copy, save=False) + + return files + + def formfield(self, **kwargs): + defaults = { + 'form_class': MultiFileField, + 'max_num': self.size + } + defaults.update(kwargs) + return super(ArrayField, self).formfield(**defaults) diff --git a/aemo/forms.py b/aemo/forms.py new file mode 100644 index 0000000..f7034be --- /dev/null +++ b/aemo/forms.py @@ -0,0 +1,969 @@ +from datetime import date, timedelta + +import nh3 +from dal import autocomplete +from dal.widgets import WidgetMixin +from tinymce.widgets import TinyMCE + +from django import forms +from django.contrib import messages +from django.contrib.postgres.search import SearchQuery, SearchVector +from django.core.exceptions import ObjectDoesNotExist +from django.db.models import Count, Q +from django.utils.dates import MONTHS + +from city_ch_autocomplete.forms import CityChField, CityChMixin + +from common.choices import PROVENANCE_DESTINATION_CHOICES +from .models import ( + Bilan, Contact, Document, EquipeChoices, Famille, Formation, Intervenant, + Niveau, Personne, Prestation, Region, Rapport, Role, Service, Suivi, Utilisateur, +) +from .utils import format_nom_prenom, ANTICIPATION_POUR_DEBUT_SUIVI + + +class BootstrapMixin: + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + for ffield in self.fields.values(): + if (isinstance(ffield.widget, (PickSplitDateTimeWidget, PickDateWidget, WidgetMixin)) or + getattr(ffield.widget, '_bs_enabled', False)): + continue + elif isinstance(ffield.widget, (forms.Select, forms.NullBooleanSelect)): + self.add_attr(ffield.widget, 'form-select') + elif isinstance(ffield.widget, (forms.CheckboxInput, forms.RadioSelect)): + self.add_attr(ffield.widget, 'form-check-input') + else: + self.add_attr(ffield.widget, 'form-control') + + @staticmethod + def add_attr(widget, class_name): + if 'class' in widget.attrs: + widget.attrs['class'] += f' {class_name}' + else: + widget.attrs.update({'class': class_name}) + + +class BootstrapChoiceMixin: + """ + Mixin to customize choice widgets to set 'form-check' on container and + 'form-check-input' on sub-options. + """ + _bs_enabled = True + + def get_context(self, *args, **kwargs): + context = super().get_context(*args, **kwargs) + if 'class' in context['widget']['attrs']: + context['widget']['attrs']['class'] += ' form-check' + else: + context['widget']['attrs']['class'] = 'form-check' + return context + + def create_option(self, *args, attrs=None, **kwargs): + attrs = attrs or {} + if 'class' in attrs: + attrs['class'] += ' form-check-input' + else: + attrs.update({'class': 'form-check-input'}) + return super().create_option(*args, attrs=attrs, **kwargs) + + +class BSRadioSelect(BootstrapChoiceMixin, forms.RadioSelect): + pass + + +class ReadOnlyableMixin: + def __init__(self, *args, readonly=False, **kwargs): + self.readonly = readonly + super().__init__(*args, **kwargs) + if self.readonly: + for field in self.fields.values(): + field.disabled = True + + +class BSCheckboxSelectMultiple(forms.CheckboxSelectMultiple): + """ + Custom widget to set 'form-check' on container and 'form-check-input' on sub-options. + """ + _bs_enabled = True + + def get_context(self, *args, **kwargs): + context = super().get_context(*args, **kwargs) + context['widget']['attrs']['class'] = 'form-check' + return context + + def create_option(self, *args, attrs=None, **kwargs): + attrs = attrs.copy() if attrs else {} + if 'class' in attrs: + attrs['class'] += ' form-check-input' + else: + attrs.update({'class': 'form-check-input'}) + return super().create_option(*args, attrs=attrs, **kwargs) + + +class RichTextField(forms.CharField): + widget = TinyMCE + + def __init__(self, *args, **kwargs): + kwargs['widget'] = self.widget + super().__init__(*args, **kwargs) + + def clean(self, value): + value = super().clean(value) + return nh3.clean( + value, tags={'p', 'br', 'b', 'strong', 'u', 'i', 'em', 'ul', 'li'} + ) + + +class HMDurationField(forms.DurationField): + """A duration field taking HH:MM as input.""" + widget = forms.TextInput(attrs={'placeholder': 'hh:mm'}) + + def to_python(self, value): + if value in self.empty_values or isinstance(value, timedelta): + return super().to_python(value) + value += ':00' # Simulate seconds + return super().to_python(value) + + def prepare_value(self, value): + if isinstance(value, timedelta): + seconds = value.days * 24 * 3600 + value.seconds + hours = seconds // 3600 + minutes = seconds % 3600 // 60 + value = '{:02d}:{:02d}'.format(hours, minutes) + return value + + +class PickDateWidget(forms.DateInput): + class Media: + js = [ + 'admin/js/core.js', + 'admin/js/calendar.js', + # Include the Django 3.2 version without today link. + 'js/DateTimeShortcuts.js', + ] + + def __init__(self, attrs=None, **kwargs): + attrs = {'class': 'vDateField vDateField-rounded', 'size': '10', **(attrs or {})} + super().__init__(attrs=attrs, **kwargs) + + +class PickSplitDateTimeWidget(forms.SplitDateTimeWidget): + def __init__(self, attrs=None): + widgets = [PickDateWidget, forms.TimeInput(attrs={'class': 'TimeField'}, format='%H:%M')] + forms.MultiWidget.__init__(self, widgets, attrs) + + +class ContactForm(CityChMixin, BootstrapMixin, forms.ModelForm): + service = forms.ModelChoiceField(queryset=Service.objects.exclude(sigle='CRNE'), required=False) + roles = forms.ModelMultipleChoiceField( + label='Rôles', + queryset=Role.objects.exclude(est_famille=True).order_by('nom'), + widget=BSCheckboxSelectMultiple, + required=False + ) + city_auto = CityChField(required=False) + postal_code_model_field = 'npa' + city_model_field = 'localite' + + class Meta: + model = Contact + fields = [ + 'nom', 'prenom', 'profession', 'service', 'roles', 'rue', 'city_auto', 'npa', 'localite', + 'tel_prof', 'tel_prive', 'email', 'remarque' + ] + + +class RoleForm(BootstrapMixin, forms.ModelForm): + class Meta: + model = Role + fields = '__all__' + + +class ServiceForm(BootstrapMixin, forms.ModelForm): + class Meta: + model = Service + fields = ['sigle', 'nom_complet'] + + def clean_sigle(self): + return self.cleaned_data['sigle'].upper() + + +class FormationForm(BootstrapMixin, ReadOnlyableMixin, forms.ModelForm): + class Meta: + model = Formation + exclude = ('personne',) + + +class GroupSelectMultiple(BSCheckboxSelectMultiple): + option_template_name = "widgets/group_checkbox_option.html" + + def create_option(self, name, value, *args, **kwargs): + try: + help_ = value.instance.groupinfo.description + except ObjectDoesNotExist: + help_= '' + return { + **super().create_option(name, value, *args, **kwargs), + 'help': help_, + } + + +class UtilisateurForm(BootstrapMixin, forms.ModelForm): + roles = forms.ModelMultipleChoiceField( + label="Rôles", + queryset=Role.objects.exclude( + Q(est_famille=True) | Q(nom__in=['Personne significative', 'Référent']) + ).order_by('nom'), + widget=BSCheckboxSelectMultiple, + required=False + ) + + class Meta: + model = Utilisateur + fields = [ + 'nom', 'prenom', 'sigle', 'profession', 'tel_prof', 'tel_prive', 'username', + 'taux_activite', 'decharge', 'email', 'equipe', 'roles', 'groups' + ] + widgets = {'groups': GroupSelectMultiple} + labels = {'profession': 'Titre'} + + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.fields['groups'].queryset = self.fields['groups'].queryset.order_by('name') + + +class PersonneForm(CityChMixin, BootstrapMixin, ReadOnlyableMixin, forms.ModelForm): + role = forms.ModelChoiceField(label="Rôle", queryset=Role.objects.filter(est_famille=True), required=True) + + city_auto = CityChField(required=False) + postal_code_model_field = 'npa' + city_model_field = 'localite' + + class Meta: + model = Personne + fields = ( + 'role', 'nom', 'prenom', 'date_naissance', 'genre', 'filiation', + 'rue', 'npa', 'localite', 'telephone', + 'email', 'profession', 'pays_origine', 'decedee', + 'allergies', 'remarque', 'remarque_privee', + ) + widgets = { + 'date_naissance': PickDateWidget, + } + labels = { + 'remarque_privee': 'Remarque privée (pas imprimée)', + } + + def __init__(self, **kwargs): + self.famille = kwargs.pop('famille', None) + role_id = kwargs.pop('role', None) + + super().__init__(**kwargs) + if self.famille: + self.initial['nom'] = self.famille.nom + self.initial['rue'] = self.famille.rue + self.initial['npa'] = self.famille.npa + self.initial['localite'] = self.famille.localite + self.initial['telephone'] = self.famille.telephone + + if role_id: + if role_id == 'ps': # personne significative + excl = Role.ROLES_PARENTS + ['Enfant suivi', 'Enfant non-suivi'] + self.fields['role'].queryset = Role.objects.filter( + famille=True + ).exclude(nom__in=excl) + elif role_id == 'parent': + self.fields['role'].queryset = Role.objects.filter(nom__in=Role.ROLES_PARENTS) + else: + self.role = Role.objects.get(pk=role_id) + + if self.role.nom in Role.ROLES_PARENTS: + self.fields['role'].queryset = Role.objects.filter(nom=self.role.nom) + self.initial['genre'] = 'M' if self.role.nom == 'Père' else 'F' + elif self.role.nom in ['Enfant suivi', 'Enfant non-suivi']: + self.fields['role'].queryset = Role.objects.filter(nom__in=['Enfant suivi', 'Enfant non-suivi']) + self.fields['profession'].label = 'Profession/École' + self.initial['role'] = self.role.pk + else: + if self.instance.pk: + famille = self.instance.famille + # Cloisonnement des choix pour les rôles en fonction du rôle existant + if self.instance.role.nom in Role.ROLES_PARENTS: + self.fields['role'].queryset = Role.objects.filter(nom__in=Role.ROLES_PARENTS) + elif self.instance.role.nom in ['Enfant suivi', 'Enfant non-suivi']: + self.fields['role'].queryset = Role.objects.filter(nom__in=['Enfant suivi', 'Enfant non-suivi']) + else: + excl = ['Enfant suivi', 'Enfant non-suivi'] + if len(famille.parents()) == 2: + excl.extend(Role.ROLES_PARENTS) + self.fields['role'].queryset = Role.objects.filter( + famille=True + ).exclude(nom__in=excl) + + def clean_nom(self): + return format_nom_prenom(self.cleaned_data['nom']) + + def clean_prenom(self): + return format_nom_prenom(self.cleaned_data['prenom']) + + def clean_localite(self): + localite = self.cleaned_data.get('localite') + if localite and localite[0].islower(): + localite = localite[0].upper() + localite[1:] + return localite + + def clean(self): + cleaned_data = super().clean() + if cleaned_data['decedee'] and cleaned_data['role'] == 'Enfant suivi': + raise forms.ValidationError('Un enfant décédé ne peut pas être «Enfant suivi»') + return cleaned_data + + def save(self, **kwargs): + if self.instance.pk is None: + self.instance.famille = self.famille + pers = Personne.objects.create_personne(**self.instance.__dict__) + else: + pers = super().save(**kwargs) + pers = Personne.objects.add_formation(pers) + return pers + + +class AgendaFormBase(BootstrapMixin, forms.ModelForm): + destination = forms.ChoiceField(choices=PROVENANCE_DESTINATION_CHOICES, required=False) + + class Meta: + fields = ( + 'date_demande', 'date_debut_evaluation', 'date_fin_evaluation', + 'date_debut_suivi', 'date_fin_suivi', 'motif_fin_suivi', 'destination' + ) + widgets = {field: PickDateWidget for field in fields[:-2]} + + def __init__(self, *args, **kwargs): + destination = kwargs.pop('destination', '') + self.request = kwargs.pop('request', None) + super().__init__(*args, **kwargs) + if self.instance.date_debut_suivi is not None or self.instance.motif_fin_suivi: + for fname in ('date_demande', 'date_debut_evaluation', 'date_fin_evaluation'): + self.fields[fname].disabled = True + if self.instance.motif_fin_suivi: + self.fields['date_debut_suivi'].disabled = True + else: + # Choix 'Autres' obsolète (#435), pourrait être supprimé quand plus référencé + self.fields['motif_fin_suivi'].choices = [ + ch for ch in self.fields['motif_fin_suivi'].choices if ch[0] != 'autres' + ] + + self.fields['destination'].choices = [('', '------')] + [ + ch for ch in PROVENANCE_DESTINATION_CHOICES if (ch[0] != 'autre' or destination == 'autre') + ] + self.initial['destination'] = destination + + def clean(self): + cleaned_data = super().clean() + + # Check date chronology + date_preced = None + for field_name in self._meta.fields[:-2]: + dt = cleaned_data.get(field_name) + if not dt: + continue + if date_preced and dt < date_preced: + raise forms.ValidationError( + "La date «{}» ne respecte pas l’ordre chronologique!".format(self.fields[field_name].label) + ) + date_preced = dt + + # Check mandatory dates + workflow = self._meta.model.WORKFLOW + for field, etape in reversed((workflow.items())): + if field == 'archivage': + continue + date_etape = cleaned_data.get(etape.date_nom()) + etape_courante = etape + etape_preced_oblig = workflow[etape.preced_oblig] + date_preced_oblig = cleaned_data.get(etape_preced_oblig.date_nom()) + while True: + etape_preced = workflow[etape_courante.precedente] + date_preced = cleaned_data.get(etape_preced.date_nom()) + if date_preced is None and etape_courante.num > 1: + etape_courante = etape_preced + else: + break + if date_etape and date_preced_oblig is None: + raise forms.ValidationError("La date «{}» est obligatoire".format(etape_preced_oblig.nom)) + + # Check dates out of range + for field_name in self.changed_data: + value = self.cleaned_data.get(field_name) + if isinstance(value, date): + if value > date.today(): + if field_name in ['date_debut_suivi', 'date_debut_evaluation', 'date_fin_evaluation']: + if value > date.today() + timedelta(days=ANTICIPATION_POUR_DEBUT_SUIVI): + self.add_error( + field_name, + forms.ValidationError( + "La saisie de cette date ne peut être " + f"anticipée de plus de {ANTICIPATION_POUR_DEBUT_SUIVI} jours !") + ) + else: + self.add_error(field_name, forms.ValidationError("La saisie anticipée est impossible !")) + elif not Prestation.check_date_allowed(self.request.user, value): + if self.request and self.request.user.has_perm('aemo.change_famille'): + messages.warning( + self.request, + 'Les dates saisies peuvent affecter les statistiques déjà communiquées !' + ) + else: + self.add_error( + field_name, + forms.ValidationError("La saisie de dates pour le mois précédent n’est pas permise !") + ) + + ddebut = cleaned_data.get('date_debut_suivi') + dfin = cleaned_data.get('date_fin_suivi') + motif = cleaned_data.get('motif_fin_suivi') + dest = cleaned_data.get('destination') + + if ddebut and dfin and motif and dest: # dossier terminé + return cleaned_data + elif ddebut and (dfin is None or motif == '' or dest == ''): # suivi en cours + if any([dfin, motif, dest]): + raise forms.ValidationError( + "Les champs «Fin de l'accompagnement», «Motif de fin» et «Destination» " + "sont obligatoires pour fermer le dossier." + ) + elif ddebut is None and dfin is None: # evaluation + if motif != '': # abandon + cleaned_data['date_fin_suivi'] = date.today() + return cleaned_data + + def save(self): + instance = super().save() + if instance.date_fin_suivi: + instance.famille.destination = self.cleaned_data['destination'] + instance.famille.save() + return instance + + +class PrestationRadioSelect(BSRadioSelect): + option_template_name = 'widgets/prestation_radio.html' + + +class PrestationForm(BootstrapMixin, forms.ModelForm): + class Meta: + model = Prestation + fields = ['date_prestation', 'duree', 'texte', 'manque', 'fichier', 'intervenants'] + widgets = { + 'date_prestation': PickDateWidget, + 'intervenants': BSCheckboxSelectMultiple, + 'lib_prestation': PrestationRadioSelect, + } + labels = { + 'lib_prestation': 'Prestation', + } + field_classes = { + 'duree': HMDurationField, + 'texte': RichTextField, + } + + def __init__(self, *args, famille=None, user, **kwargs): + self.user = user + self.famille = famille + super().__init__(*args, **kwargs) + self.fields['intervenants'].queryset = self.defaults_intervenants() + if famille: + intervenants = list(famille.suivi.intervenants.all()) + if len(intervenants): + if self.user not in intervenants: + intervenants.insert(0, self.user) + self.fields['intervenants'].choices = [(i.pk, i.nom_prenom) for i in intervenants] + + def defaults_intervenants(self): + return Utilisateur.intervenants() + + def clean_date_prestation(self): + date_prestation = self.cleaned_data['date_prestation'] + today = date.today() + if date_prestation > today: + raise forms.ValidationError("La saisie anticipée est impossible !") + + if not Prestation.check_date_allowed(self.user, date_prestation): + raise forms.ValidationError( + "La saisie des prestations des mois précédents est close !" + ) + return date_prestation + + def clean_texte(self): + texte = self.cleaned_data['texte'] + for snip in ('

', '


', '

'): + while texte.startswith(snip): + texte = texte[len(snip):].strip() + for snip in ('

', '


', '

'): + while texte.endswith(snip): + texte = texte[:-len(snip)].strip() + return texte + + +class DocumentUploadForm(BootstrapMixin, forms.ModelForm): + class Meta: + model = Document + fields = '__all__' + widgets = {'famille': forms.HiddenInput} + labels = {'fichier': ''} + + +class RapportEditForm(BootstrapMixin, forms.ModelForm): + class Meta: + model = Rapport + exclude = ['famille', 'auteur'] + widgets = { + 'famille': forms.HiddenInput, + 'date': PickDateWidget, + 'pres_interv': BSCheckboxSelectMultiple, + 'sig_interv': BSCheckboxSelectMultiple, + } + field_classes = { + 'situation': RichTextField, + 'projet': RichTextField, + 'observations': RichTextField, + } + field_order = [ + 'date', 'pres_interv', 'situation', 'observations', 'projet', 'sig_interv', + ] + + def __init__(self, user=None, **kwargs): + super().__init__(**kwargs) + if 'sig_interv' in self.fields: + interv_qs = Utilisateur.objects.filter( + pk__in=kwargs['initial']['famille'].suivi.intervenant_set.actifs( + self.instance.date or date.today() + ).values_list('intervenant', flat=True) + ) + self.fields['pres_interv'].queryset = interv_qs + self.fields['sig_interv'].queryset = interv_qs + + +class MonthSelectionForm(BootstrapMixin, forms.Form): + mois = forms.ChoiceField(choices=((mois_idx, MONTHS[mois_idx]) for mois_idx in range(1, 13))) + annee = forms.ChoiceField( + label='Année', + choices=((2019, 2019), + (2020, 2020), + (2021, 2021), + (2022, 2022), + (2023, 2023), + (2024, 2024)) + ) + + +class DateYearForm(forms.Form): + year = forms.ChoiceField(choices=[(str(y), str(y)) for y in range(2020, date.today().year + 1)]) + + def __init__(self, data=None, **kwargs): + if not data: + data = {'year': date.today().year} + super().__init__(data, **kwargs) + + +class ContactExterneAutocompleteForm(forms.ModelForm): + + class Meta: + model = Personne + fields = ('reseaux',) + widgets = {'reseaux': autocomplete.ModelSelect2Multiple(url='contact-externe-autocomplete')} + labels = {'reseaux': 'Contacts'} + + +class ContactFilterForm(forms.Form): + service = forms.ModelChoiceField(label="Service", queryset=Service.objects.all(), required=False) + role = forms.ModelChoiceField(label="Rôle", queryset=Role.objects.exclude(est_famille=True), required=False) + texte = forms.CharField( + widget=forms.TextInput(attrs={'placeholder': 'Recherche…', 'autocomplete': 'off'}), + required=False + ) + sort_by = forms.CharField(widget=forms.HiddenInput(), required=False) + + sort_by_mapping = { + 'nom': ['nom', 'prenom'], + 'service': ['service'], + 'role': ['roles__nom'], + 'activite': ['profession'], + } + + def filter(self, contacts): + if self.cleaned_data['service']: + contacts = contacts.filter(service=self.cleaned_data['service']) + if self.cleaned_data['role']: + contacts = contacts.filter(roles=self.cleaned_data['role']) + if self.cleaned_data['texte']: + contacts = contacts.filter(nom__icontains=self.cleaned_data['texte']) + if self.cleaned_data['sort_by']: + order_desc = self.cleaned_data['sort_by'].startswith('-') + contacts = contacts.order_by(*([ + ('-' if order_desc else '') + key + for key in self.sort_by_mapping.get(self.cleaned_data['sort_by'].strip('-'), []) + ])) + return contacts + + +class FamilleAdresseForm(CityChMixin, BootstrapMixin, forms.ModelForm): + city_auto = CityChField(required=False) + postal_code_model_field = 'npa' + city_model_field = 'localite' + + class Meta: + model = Famille + fields = ('rue', 'npa', 'localite') + + def __init__(self, **kwargs): + self.famille = kwargs.pop('famille', None) + super().__init__(**kwargs) + + membres = [ + (m.pk, f"{m.nom_prenom} ({m.role}), {m.adresse}") + for m in self.famille.membres.all() + ] + self.fields['membres'] = forms.MultipleChoiceField( + widget=BSCheckboxSelectMultiple, + choices=membres, + required=False + ) + + +class FamilleForm(BootstrapMixin, ReadOnlyableMixin, forms.ModelForm): + equipe = forms.ChoiceField(label="Équipe", choices=[('', '------')] + Suivi.EQUIPES_CHOICES[:2]) + + # Concernant 'npa' & 'location', ils sont en lecture seule ici => pas besoin de faire de l'autocomplete + class Meta: + model = Famille + fields = ['nom', 'rue', 'npa', 'localite', 'telephone', 'region', 'autorite_parentale', + 'monoparentale', 'statut_marital', 'connue', 'accueil', 'garde', + 'provenance', 'statut_financier'] + + def __init__(self, **kwargs): + super().__init__(**kwargs) + if self.instance and self.instance.pk: + self.fields['rue'].widget.attrs['readonly'] = True + self.fields['npa'].widget.attrs['readonly'] = True + self.fields['localite'].widget.attrs['readonly'] = True + self.initial['equipe'] = kwargs['instance'].suivi.equipe + self.fields['region'] = forms.ModelChoiceField( + label='Région', + queryset=Region.objects.order_by('nom'), + required=False, + disabled=self.fields['region'].disabled, + widget=forms.Select(attrs={'class': 'form-select'}) + ) + self.fields['provenance'].choices = [ + ch for ch in self.fields['provenance'].choices + if (ch[0] != 'autre' or self.instance.provenance == 'autre') + ] + + def save(self, **kwargs): + famille = super().save(**kwargs) + if famille.suivi.equipe != self.cleaned_data['equipe']: + famille.suivi.equipe = self.cleaned_data['equipe'] + famille.suivi.save() + return famille + + def clean_nom(self): + return format_nom_prenom(self.cleaned_data['nom']) + + def clean_localite(self): + localite = self.cleaned_data.get('localite') + if localite and localite[0].islower(): + localite = localite[0].upper() + localite[1:] + return localite + + +class FamilleCreateForm(CityChMixin, FamilleForm): + motif_detail = forms.CharField( + label="Motif de la demande", widget=forms.Textarea(), required=False + ) + city_auto = CityChField(required=False) + postal_code_model_field = 'npa' + city_model_field = 'localite' + field_order = ['nom', 'rue', 'city_auto'] + + class Meta(FamilleForm.Meta): + exclude = ['typ', 'destination'] + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields['provenance'].choices = [ + ch for ch in self.fields['provenance'].choices if ch[0] != 'autre' + ] + + def save(self, **kwargs): + famille = self._meta.model.objects.create_famille( + equipe=self.cleaned_data['equipe'], **self.instance.__dict__ + ) + famille.suivi.motif_detail = self.cleaned_data['motif_detail'] + famille.suivi.save() + return famille + + +class SuiviForm(ReadOnlyableMixin, forms.ModelForm): + equipe = forms.TypedChoiceField( + choices=[('', '-------')] + Suivi.EQUIPES_CHOICES[:2], required=False + ) + + class Meta: + model = Suivi + fields = [ + 'equipe', 'heure_coord', 'ope_referent', 'ope_referent_2', + 'mandat_ope', 'service_orienteur', 'service_annonceur', 'motif_demande', + 'motif_detail', 'demande_prioritaire', 'demarche', 'collaboration', + 'ressource', 'crise', 'remarque', 'remarque_privee', + ] + widgets = { + 'mandat_ope': BSCheckboxSelectMultiple, + 'motif_demande': BSCheckboxSelectMultiple, + } + + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.fields['ope_referent'].queryset = Contact.membres_ope() + self.fields['ope_referent_2'].queryset = Contact.membres_ope() + + +class IntervenantForm(BootstrapMixin, forms.ModelForm): + intervenant = forms.ModelChoiceField( + queryset=Utilisateur.objects.filter(groups__name__startswith='aemo').distinct().order_by('nom') + ) + date_debut = forms.DateField( + label='Date de début', initial=date.today(), widget=PickDateWidget() + ) + + class Meta: + model = Intervenant + fields = ['intervenant', 'role', 'date_debut'] + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields['role'].queryset = Role.objects.filter(est_intervenant=True) + + +class IntervenantEditForm(BootstrapMixin, forms.ModelForm): + + class Meta: + model = Intervenant + fields = ['intervenant', 'role', 'date_debut', 'date_fin'] + widgets = { + 'date_debut': PickDateWidget(), + 'date_fin': PickDateWidget(), + } + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields['intervenant'].disabled = True + self.fields['role'].disabled = True + + +class DemandeForm(BootstrapMixin, ReadOnlyableMixin, forms.ModelForm): + class Meta: + model = Suivi + fields = ( + 'dates_demande', 'ref_presents', 'pers_famille_presentes', 'autres_pers_presentes', + 'difficultes', 'aides', 'competences', 'disponibilites', 'autres_contacts', 'remarque' + ) + field_classes = { + 'difficultes': RichTextField, + 'aides': RichTextField, + 'disponibilites': RichTextField, + 'competences': RichTextField, + } + widgets = { + 'autres_contacts': forms.Textarea(attrs={'cols': 120, 'rows': 4}), + 'remarque': forms.Textarea(attrs={'cols': 120, 'rows': 4}), + } + + +class AgendaForm(ReadOnlyableMixin, AgendaFormBase): + ope_referent = forms.ModelChoiceField( + queryset=Contact.membres_ope(), required=False + ) + + class Meta(AgendaFormBase.Meta): + model = Suivi + + +class BilanForm(BootstrapMixin, forms.ModelForm): + class Meta: + model = Bilan + exclude = ['famille', 'auteur'] + field_classes = { + 'objectifs': RichTextField, + 'rythme': RichTextField, + } + widgets = { + 'famille': forms.HiddenInput, + 'date': PickDateWidget, + 'sig_interv': BSCheckboxSelectMultiple, + } + labels = {'fichier': ''} + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields['sig_interv'].queryset = Utilisateur.objects.filter( + pk__in=kwargs['initial']['famille'].suivi.intervenant_set.actifs( + self.instance.date or date.today() + ).values_list('intervenant', flat=True) + ) + + +class NiveauForm(BootstrapMixin, forms.ModelForm): + class Meta: + model = Niveau + fields = ['niveau_interv', 'date_debut', 'date_fin'] + widgets = { + 'date_debut': PickDateWidget(), + 'date_fin': PickDateWidget(), + } + + def __init__(self, *args, **kwargs): + self.famille = kwargs.pop('famille') + super().__init__(*args, **kwargs) + if self.instance.pk is None: + self.fields.pop('date_fin') + if self.famille.niveaux.count() > 0: + self.fields['date_debut'].required = True + + def clean_niveau_interv(self): + niv = self.cleaned_data['niveau_interv'] + if self.instance.pk is None: + der_niv = self.famille.niveaux.last() if self.famille.niveaux.count() > 0 else None + if der_niv and der_niv.niveau_interv == niv: + raise forms.ValidationError(f"Le niveau {niv} est déjà actif.") + return niv + + +class EquipeFilterForm(forms.Form): + equipe = forms.ChoiceField( + label="Équipe", choices=[('', 'Toutes')] + EquipeChoices.choices, required=False, + widget=forms.Select(attrs={'class': 'form-select form-select-sm immediate-submit'}), + ) + + def filter(self, familles): + if self.cleaned_data['equipe']: + familles = familles.filter(suivi__equipe=self.cleaned_data['equipe']) + return familles + + +class FamilleFilterForm(EquipeFilterForm): + ressource = forms.ChoiceField( + label='Ressources', + required=False, + widget=forms.Select(attrs={'class': 'form-select form-select-sm immediate-submit'}) + ) + niveau = forms.ChoiceField( + label="Niv. d'interv.", + required=False, + widget=forms.Select(attrs={'class': 'form-select form-select-sm immediate-submit'}) + ) + interv = forms.ChoiceField( + label="Intervenant-e", + required=False, + widget=forms.Select(attrs={'class': 'form-select form-select-sm immediate-submit'}) + ) + nom = forms.CharField( + widget=forms.TextInput( + attrs={'placeholder': 'Nom de famille…', 'autocomplete': 'off', + 'class': 'form-control form-control-sm inline'}), + required=False + ) + duos = forms.ChoiceField( + label="Duos Educ/Psy", + required=False, + widget=forms.Select(attrs={'class': 'form-select form-select-sm immediate-submit'}) + ) + + def __init__(self, **kwargs): + super().__init__(**kwargs) + intervenants = Utilisateur.objects.filter(is_active=True, groups__name='aemo') + self.fields['interv'].choices = [ + ('', 'Tout-e-s'), ('0', 'Aucun')] + [ + (user.id, user.nom_prenom) for user in intervenants + ] + self.fields['niveau'].choices = [ + ('', 'Tous') + ] + [(key, val) for key, val in Niveau.INTERV_CHOICES] + + self.fields['ressource'].choices = [ + ('', 'Toutes') + ] + [(el.pk, el.nom) for el in Role.objects.filter( + nom__in=['ASE', 'IPE', 'Coach APA', 'Assistant-e social-e'] + )] + self.fields['duos'].choices = [ + ('', 'Tous') + ] + [ + (duo, duo) for duo in Famille.objects.exclude( + suivi__date_fin_suivi__isnull=False + ).with_duos().exclude(duo='').values_list('duo', flat=True).distinct().order_by('duo') + ] + + def filter(self, familles): + if self.cleaned_data['interv']: + if self.cleaned_data['interv'] == '0': + familles = familles.annotate( + num_interv=Count('suivi__intervenants', filter=( + Q(suivi__intervenant__date_fin__isnull=True) | + Q(suivi__intervenant__date_fin__gt=date.today()) + )) + ).filter(num_interv=0) + else: + familles = familles.filter( + Q(suivi__intervenant__intervenant=self.cleaned_data['interv']) & ( + Q(suivi__intervenant__date_fin__isnull=True) | + Q(suivi__intervenant__date_fin__gt=date.today()) + ) + ) + + if self.cleaned_data['nom']: + familles = familles.filter(nom__istartswith=self.cleaned_data['nom']) + + familles = super().filter(familles) + + if self.cleaned_data['niveau']: + familles = familles.with_niveau_interv().filter(niveau_interv=self.cleaned_data['niveau']) + + if self.cleaned_data['ressource']: + ress = Intervenant.objects.actifs().filter( + role=self.cleaned_data['ressource'], + ).values_list('intervenant', flat=True) + familles = familles.filter(suivi__intervenants__in=ress).distinct() + + if self.cleaned_data['duos']: + familles = familles.with_duos().filter(duo=self.cleaned_data['duos']) + return familles + + +class JournalAuteurFilterForm(forms.Form): + recherche = forms.CharField( + widget=forms.TextInput(attrs={ + 'class': 'search-form-fields form-control d-inline-block', + 'placeholder': 'recherche', + }), + required=False, + ) + auteur = forms.ModelChoiceField( + queryset=Utilisateur.objects.all(), + widget=forms.Select(attrs={'class': 'search-form-fields form-select d-inline-block immediate-submit'}), + required=False, + ) + + def __init__(self, famille=None, **kwargs): + super().__init__(**kwargs) + self.fields['auteur'].queryset = Utilisateur.objects.filter(prestations__famille=famille).distinct() + + def filter(self, prestations): + if self.cleaned_data['auteur']: + prestations = prestations.filter(auteur=self.cleaned_data['auteur']) + if self.cleaned_data['recherche']: + prestations = prestations.annotate( + search=SearchVector("texte", config="french_unaccent") + ).filter( + search=SearchQuery(self.cleaned_data['recherche'], config="french_unaccent") + ) + return prestations diff --git a/aemo/management/commands/anonymize.py b/aemo/management/commands/anonymize.py new file mode 100644 index 0000000..317d869 --- /dev/null +++ b/aemo/management/commands/anonymize.py @@ -0,0 +1,126 @@ +""" +Anonymiser les données de l'application AEMO +""" +import sys +from datetime import date, timedelta + +from django.core.management.base import BaseCommand + +from django_otp.plugins.otp_static.models import StaticDevice +from django_otp.plugins.otp_totp.models import TOTPDevice + +from aemo.models import Bilan, Contact, Famille, Personne, Prestation, Rapport, Suivi, Utilisateur + +try: + from faker import Faker +except ImportError: + print("La commande anonymize exige la présence du paquet Python Faker (pip install faker)") + sys.exit(1) + + +class Command(BaseCommand): + def handle(self, *args, **options): + fake = Faker('fr_CH') + + StaticDevice.objects.all().delete() + TOTPDevice.objects.all().delete() + + # Contacts et Utilisateurs + Contact.objects.filter(utilisateur__isnull=True, est_actif=False).delete() + for contact in Contact.objects.all().select_related('utilisateur'): + nom, prenom, service = fake.last_name(), fake.first_name(), contact.service + while Contact.objects.filter(nom=nom, prenom=prenom, service=service).exists(): + nom, prenom, service = fake.last_name(), fake.first_name(), contact.service + contact.prenom = prenom + contact.nom = nom + contact.rue = fake.street_address()[:30] + contact.npa = fake.postcode() + contact.localite = fake.city() + if contact.tel_prive: + contact.tel_prive = fake.phone_number() + if contact.tel_prof: + contact.tel_prof = fake.phone_number() + if contact.email: + contact.email = fake.email() + try: + util = contact.utilisateur + util.first_name = contact.prenom + util.last_name = contact.nom + username = f'{util.last_name}{util.first_name[0]}' + while Utilisateur.objects.filter(username=username).exists(): + username += '0' + util.username = username + util.set_password(fake.password()) + util.save() + except Utilisateur.DoesNotExist: + pass + contact.save() + + # Familles et Personnes + Famille.objects.filter(archived_at__date__gt=date.today() - timedelta(days=360)).delete() + for famille in Famille.objects.all(): + famille.nom = fake.last_name() + famille.rue = fake.street_address() + famille.npa = fake.postcode() + famille.localite = fake.city() + famille.telephone = fake.phone_number() + if famille.remarques: + famille.remarques = fake.text(max_nb_chars=100) + famille.save() + + for personne in Personne.objects.all().select_related('famille'): + personne.nom = personne.famille.nom + personne.prenom = fake.first_name() + if personne.date_naissance: + personne.date_naissance = fake.date_between( + personne.date_naissance - timedelta(days=100), + personne.date_naissance + timedelta(days=100), + ) + personne.rue = personne.famille.rue + personne.npa = personne.famille.npa + personne.localite = personne.famille.localite + if personne.telephone: + personne.telephone = fake.phone_number() + if personne.email: + personne.email = fake.email() + if personne.remarque: + personne.remarque = fake.text(max_nb_chars=100) + if personne.remarque_privee: + personne.remarque_privee = fake.text(max_nb_chars=100) + personne.save() + + # Suivi + for suivi in Suivi.objects.all(): + for text_field in [ + 'difficultes', 'aides', 'competences', 'disponibilites', 'collaboration', + 'ressource', 'crise', 'remarque', 'remarque_privee' + ]: + if getattr(suivi, text_field): + setattr(suivi, text_field, fake.text(max_nb_chars=200)) + for pres_field in ['pers_famille_presentes', 'ref_presents', 'autres_pers_presentes']: + if getattr(suivi, pres_field): + mots = getattr(suivi, pres_field).split(" ") + mots_rempl = " ".join([ + fake.first_name() if (len(mot) and mot[0].isupper()) else mot for mot in mots + ]) + setattr(suivi, pres_field, mots_rempl[:100]) + suivi.save() + + # Rapports/Bilans + for rapport in Rapport.objects.all(): + for text_field in [ + 'situation', 'evolutions', 'evaluation', 'projet', + ]: + if getattr(rapport, text_field): + setattr(rapport, text_field, fake.text(max_nb_chars=200)) + rapport.save() + for bilan in Bilan.objects.all(): + for text_field in ['objectifs', 'rythme']: + if getattr(bilan, text_field): + setattr(bilan, text_field, fake.text(max_nb_chars=200)) + bilan.save() + + # Prestations + for prest in Prestation.objects.all(): + prest.texte = fake.text(max_nb_chars=200) + prest.save(update_fields=['texte']) diff --git a/aemo/management/commands/test.py b/aemo/management/commands/test.py new file mode 100644 index 0000000..f85a7fd --- /dev/null +++ b/aemo/management/commands/test.py @@ -0,0 +1,10 @@ +from django.core.management.commands.test import Command as TestCommand +from django.test.utils import override_settings + + +class Command(TestCommand): + def handle(self, *args, **options): + with override_settings( + PASSWORD_HASHERS=['django.contrib.auth.hashers.MD5PasswordHasher'], + ): + return super().handle(*args, **options) diff --git a/aemo/migrations/0001_initial.py b/aemo/migrations/0001_initial.py new file mode 100644 index 0000000..1ced9bc --- /dev/null +++ b/aemo/migrations/0001_initial.py @@ -0,0 +1,382 @@ +# Generated by Django 5.0.4 on 2024-06-03 09:59 + +import common.fields +import django.contrib.auth.models +import django.contrib.auth.validators +import django.core.validators +import django.db.models.deletion +import django.utils.timezone +import django_countries.fields +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.CreateModel( + name='Contact', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('prenom', models.CharField(max_length=30, verbose_name='Prénom')), + ('nom', models.CharField(max_length=30, verbose_name='Nom')), + ('rue', models.CharField(blank=True, max_length=30, verbose_name='Rue')), + ('npa', models.CharField(blank=True, max_length=4, verbose_name='NPA')), + ('localite', models.CharField(blank=True, max_length=30, verbose_name='Localité')), + ('tel_prive', models.CharField(blank=True, max_length=30, verbose_name='Tél. privé')), + ('tel_prof', models.CharField(blank=True, max_length=30, verbose_name='Tél. prof.')), + ('email', models.EmailField(blank=True, max_length=100)), + ('profession', models.CharField(blank=True, max_length=100, verbose_name='Activité/prof.')), + ('remarque', models.TextField(blank=True, verbose_name='Remarque')), + ('est_actif', models.BooleanField(default=True, verbose_name='actif')), + ], + options={ + 'verbose_name': 'Contact', + 'ordering': ('nom', 'prenom'), + }, + ), + migrations.CreateModel( + name='CercleScolaire', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('nom', models.CharField(max_length=50, unique=True)), + ('telephone', models.CharField(blank=True, max_length=35, verbose_name='tél.')), + ], + options={ + 'verbose_name': 'Cercle scolaire', + 'verbose_name_plural': 'Cercles scolaires', + 'ordering': ('nom',), + }, + ), + migrations.CreateModel( + name='Famille', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('archived_at', models.DateTimeField(blank=True, null=True, verbose_name='Archivée le')), + ('nom', models.CharField(max_length=40, verbose_name='Nom de famille')), + ('rue', models.CharField(blank=True, max_length=60, verbose_name='Rue')), + ('npa', models.CharField(blank=True, max_length=4, verbose_name='NPA')), + ('localite', models.CharField(blank=True, max_length=30, verbose_name='Localité')), + ('telephone', models.CharField(blank=True, max_length=60, verbose_name='Tél.')), + ('autorite_parentale', models.CharField(blank=True, choices=[('conjointe', 'Conjointe'), ('pere', 'Père'), ('mere', 'Mère'), ('tutelle', 'Tutelle')], max_length=20, verbose_name='Autorité parentale')), + ('monoparentale', models.BooleanField(blank=True, default=None, null=True, verbose_name='Famille monoparent.')), + ('statut_marital', models.CharField(blank=True, choices=[('celibat', 'Célibataire'), ('mariage', 'Marié'), ('pacs', 'PACS'), ('concubin', 'Concubin'), ('veuf', 'Veuf'), ('separe', 'Séparé'), ('divorce', 'Divorcé')], max_length=20, verbose_name='Statut marital')), + ('connue', models.BooleanField(default=False, verbose_name='famille déjà suivie')), + ('accueil', models.BooleanField(default=False, verbose_name="famille d'accueil")), + ('besoins_part', models.BooleanField(default=False, verbose_name='famille à besoins particuliers')), + ('sap', models.BooleanField(default=False, verbose_name='famille s@p')), + ('garde', models.CharField(blank=True, choices=[('partage', 'garde partagée'), ('droit', 'droit de garde'), ('visite', 'droit de visite')], max_length=20, verbose_name='Type de garde')), + ('provenance', models.CharField(blank=True, choices=[('famille', 'Famille'), ('ies-ne', 'IES-NE'), ('ies-hc', 'IES-HC'), ('aemo', 'SAEMO'), ('fah', "Famille d'accueil"), ('refug', 'Centre d’accueil réfugiés'), ('hopital', 'Hôpital'), ('autre', 'Autre')], max_length=30, verbose_name='Provenance')), + ('destination', models.CharField(blank=True, choices=[('famille', 'Famille'), ('ies-ne', 'IES-NE'), ('ies-hc', 'IES-HC'), ('aemo', 'SAEMO'), ('fah', "Famille d'accueil"), ('refug', 'Centre d’accueil réfugiés'), ('hopital', 'Hôpital'), ('autre', 'Autre')], max_length=30, verbose_name='Destination')), + ('statut_financier', models.CharField(blank=True, choices=[('ai', 'AI PC'), ('gsr', 'GSR'), ('osas', 'OSAS'), ('revenu', 'Revenu')], max_length=30, verbose_name='Statut financier')), + ('remarques', models.TextField(blank=True)), + ], + options={ + 'ordering': ('nom', 'npa'), + 'permissions': ( + ('can_manage_waiting_list', "Gérer la liste d'attente"), + ('can_archive', 'Archiver les dossiers AEMO'), + ('export_stats', 'Exporter les statistiques') + ), + }, + ), + migrations.CreateModel( + name='LibellePrestation', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('code', models.CharField(max_length=6, unique=True, verbose_name='Code')), + ('nom', models.CharField(max_length=30, verbose_name='Nom')), + ('actes', models.TextField(blank=True, verbose_name='Actes à prester')), + ], + options={ + 'ordering': ('code',), + }, + ), + migrations.CreateModel( + name='Region', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('nom', models.CharField(max_length=30, unique=True)), + ('rue', models.CharField(blank=True, max_length=100, verbose_name='Rue')), + ], + ), + migrations.CreateModel( + name='Role', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('nom', models.CharField(max_length=50, unique=True, verbose_name='Nom')), + ('est_famille', models.BooleanField(default=False, verbose_name='Famille')), + ('est_intervenant', models.BooleanField(default=False, verbose_name='Intervenant')), + ('est_editeur', models.BooleanField(default=False, help_text='Un rôle éditeur donne le droit de modification des dossiers familles si la personne est intervenante.', verbose_name='Éditeur')), + ], + options={ + 'ordering': ('nom',), + }, + ), + migrations.CreateModel( + name='Service', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('sigle', models.CharField(max_length=20, unique=True, verbose_name='Sigle')), + ('nom_complet', models.CharField(blank=True, max_length=80, verbose_name='Nom complet')), + ], + options={ + 'ordering': ('sigle',), + }, + ), + migrations.CreateModel( + name='Utilisateur', + fields=[ + ('contact_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='aemo.contact')), + ('password', models.CharField(max_length=128, verbose_name='password')), + ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), + ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')), + ('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')), + ('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')), + ('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')), + ('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')), + ('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')), + ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')), + ('sigle', models.CharField(blank=True, max_length=5)), + ('equipe', models.CharField(blank=True, choices=[('montagnes', 'Montagnes et V-d-T'), ('littoral', 'Littoral et V-d-R')], max_length=10, verbose_name='Équipe')), + ('date_desactivation', models.DateField(blank=True, null=True, verbose_name='Date désactivation')), + ('taux_activite', models.PositiveSmallIntegerField(blank=True, default=0, validators=[django.core.validators.MaxValueValidator(100)], verbose_name='Taux d’activité (en %)')), + ('decharge', models.PositiveSmallIntegerField(blank=True, null=True, verbose_name='Heures de décharge')), + ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')), + ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')), + ], + options={ + 'verbose_name': 'user', + 'verbose_name_plural': 'users', + 'abstract': False, + }, + bases=('aemo.contact', models.Model), + managers=[ + ('objects', django.contrib.auth.models.UserManager()), + ], + ), + migrations.CreateModel( + name='GroupInfo', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('description', models.TextField(blank=True)), + ('group', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to='auth.group')), + ], + ), + migrations.CreateModel( + name='Niveau', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('niveau_interv', models.PositiveSmallIntegerField(choices=[(0, '0'), (1, '1'), (2, '2'), (3, '3')], verbose_name='Niveau d’intervention')), + ('date_debut', models.DateField(blank=True, null=True, verbose_name='Date début')), + ('date_fin', models.DateField(blank=True, null=True, verbose_name='Date fin')), + ('famille', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='niveaux', to='aemo.famille', verbose_name='Famille')), + ], + ), + migrations.CreateModel( + name='Personne', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True, null=True)), + ('nom', models.CharField(max_length=30, verbose_name='Nom')), + ('prenom', models.CharField(blank=True, max_length=30, verbose_name='Prénom')), + ('date_naissance', models.DateField(blank=True, null=True, verbose_name='Date de naissance')), + ('genre', models.CharField(choices=[('M', 'M'), ('F', 'F')], default='M', max_length=1, verbose_name='Genre')), + ('rue', models.CharField(blank=True, max_length=60, verbose_name='Rue')), + ('npa', models.CharField(blank=True, max_length=4, verbose_name='NPA')), + ('localite', models.CharField(blank=True, max_length=30, verbose_name='Localité')), + ('telephone', models.CharField(blank=True, max_length=60, verbose_name='Tél.')), + ('email', models.EmailField(blank=True, max_length=254, verbose_name='Courriel')), + ('pays_origine', django_countries.fields.CountryField(blank=True, max_length=2, verbose_name='Nationalité')), + ('remarque', models.TextField(blank=True)), + ('remarque_privee', models.TextField(blank=True, verbose_name='Remarque privée')), + ('profession', models.CharField(blank=True, max_length=50, verbose_name='Profession')), + ('filiation', models.CharField(blank=True, max_length=80, verbose_name='Filiation')), + ('decedee', models.BooleanField(default=False, verbose_name='Cette personne est décédée')), + ('allergies', models.TextField(blank=True, verbose_name='Allergies')), + ('employeur', models.CharField(blank=True, max_length=50, verbose_name='Adresse empl.')), + ('permis', models.CharField(blank=True, max_length=30, verbose_name='Permis/séjour')), + ('validite', models.DateField(blank=True, null=True, verbose_name='Date validité')), + ('animaux', models.BooleanField(default=None, null=True, verbose_name='Animaux')), + ('famille', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='membres', to='aemo.famille')), + ('reseaux', models.ManyToManyField(blank=True, to='aemo.contact')), + ('role', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='aemo.role')), + ], + options={ + 'verbose_name': 'Personne', + 'ordering': ('nom', 'prenom'), + }, + ), + migrations.CreateModel( + name='Formation', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('statut', models.CharField(blank=True, choices=[('pre_scol', 'Pré-scolaire'), ('cycle1', 'Cycle 1'), ('cycle2', 'Cycle 2'), ('cycle3', 'Cycle 3'), ('apprenti', 'Apprentissage'), ('etudiant', 'Etudiant'), ('en_emploi', 'En emploi'), ('sans_emploi', 'Sans emploi'), ('sans_occupation', 'Sans occupation')], max_length=20, verbose_name='Scolarité')), + ('college', models.CharField(blank=True, max_length=50, verbose_name='Collège')), + ('classe', models.CharField(blank=True, max_length=50, verbose_name='Classe')), + ('enseignant', models.CharField(blank=True, max_length=50, verbose_name='Enseignant')), + ('creche', models.CharField(blank=True, max_length=50, verbose_name='Crèche')), + ('creche_resp', models.CharField(blank=True, max_length=50, verbose_name='Resp.crèche')), + ('entreprise', models.CharField(blank=True, max_length=50, verbose_name='Entreprise')), + ('maitre_apprentissage', models.CharField(blank=True, max_length=50, verbose_name="Maître d'appr.")), + ('remarque', models.TextField(blank=True)), + ('cercle_scolaire', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='aemo.cerclescolaire', verbose_name='Cercle scolaire')), + ('personne', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to='aemo.personne')), + ], + options={ + 'verbose_name': 'Scolarité', + }, + ), + migrations.AddField( + model_name='famille', + name='region', + field=models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, to='aemo.region'), + ), + migrations.AddField( + model_name='contact', + name='roles', + field=models.ManyToManyField(blank=True, related_name='contacts', to='aemo.role', verbose_name='Rôles'), + ), + migrations.AddField( + model_name='contact', + name='service', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='aemo.service'), + ), + migrations.CreateModel( + name='Suivi', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('equipe', models.CharField(choices=[('montagnes', 'Montagnes et V-d-T'), ('littoral', 'Littoral et V-d-R'), ('neuch_ville', 'Neuchâtel-ville (archives)'), ('litt_est', 'Littoral Est (archives)'), ('litt_ouest', 'Littoral Ouest (archives)')], max_length=15, verbose_name='Équipe')), + ('heure_coord', models.BooleanField(default=False, verbose_name='Heure de coordination')), + ('difficultes', models.TextField(blank=True, verbose_name='Difficultés')), + ('aides', models.TextField(blank=True, verbose_name='Aides souhaitées')), + ('competences', models.TextField(blank=True, verbose_name='Ressources/Compétences')), + ('dates_demande', models.CharField(blank=True, max_length=128, verbose_name='Dates')), + ('autres_contacts', models.TextField(blank=True, verbose_name='Autres services contactés')), + ('disponibilites', models.TextField(blank=True, verbose_name='Disponibilités')), + ('remarque', models.TextField(blank=True)), + ('remarque_privee', models.TextField(blank=True, verbose_name='Remarque privée')), + ('service_orienteur', models.CharField(blank=True, choices=[('famille', 'Famille'), ('ope', 'OPE'), ('aemo', 'AEMO'), ('cnpea', 'CNPea'), ('ecole', 'École'), ('res_prim', 'Réseau primaire'), ('res_sec', 'Réseau secondaire'), ('pediatre', 'Pédiatre'), ('autre', 'Autre')], max_length=15, verbose_name='Orienté vers l’AEMO par')), + ('service_annonceur', models.CharField(blank=True, max_length=60, verbose_name='Service annonceur')), + ('motif_demande', common.fields.ChoiceArrayField(base_field=models.CharField(choices=[('accompagnement', 'Accompagnement psycho-éducatif'), ('integration', 'Aide à l’intégration'), ('demande', 'Elaboration d’une demande (contrainte)'), ('crise', 'Travail sur la crise'), ('post-placement', 'Post-placement'), ('pre-placement', 'Pré-placement'), ('violence', 'Violence / maltraitances')], max_length=60), blank=True, null=True, size=None, verbose_name='Motif de la demande')), + ('motif_detail', models.TextField(blank=True, verbose_name='Motif')), + ('mandat_ope', common.fields.ChoiceArrayField(base_field=models.CharField(blank=True, choices=[('volontaire', 'Mandat volontaire'), ('curatelle', 'Curatelle 308'), ('referent', 'Référent'), ('enquete', 'Enquête'), ('tutelle', 'Curatelle de portée générale')], max_length=65), blank=True, null=True, size=None, verbose_name='Mandat OPE')), + ('referent_note', models.TextField(blank=True, verbose_name='Autres contacts')), + ('collaboration', models.TextField(blank=True, verbose_name='Collaboration')), + ('ressource', models.TextField(blank=True, verbose_name='Ressource')), + ('crise', models.TextField(blank=True, verbose_name='Gestion de crise')), + ('date_demande', models.DateField(blank=True, default=None, null=True, verbose_name='Demande déposée le')), + ('date_debut_evaluation', models.DateField(blank=True, default=None, null=True, verbose_name='Début de l’évaluation le')), + ('date_fin_evaluation', models.DateField(blank=True, default=None, null=True, verbose_name='Fin de l’évaluation le')), + ('date_debut_suivi', models.DateField(blank=True, default=None, null=True, verbose_name='Début du suivi le')), + ('date_fin_suivi', models.DateField(blank=True, default=None, null=True, verbose_name='Fin du suivi le')), + ('demande_prioritaire', models.BooleanField(default=False, verbose_name='Demande prioritaire')), + ('demarche', common.fields.ChoiceArrayField(base_field=models.CharField(blank=True, choices=[('volontaire', 'Volontaire'), ('contrainte', 'Contrainte'), ('post_placement', 'Post placement'), ('non_placement', 'Eviter placement')], max_length=60), blank=True, null=True, size=None, verbose_name='Démarche')), + ('pers_famille_presentes', models.CharField(blank=True, max_length=200, verbose_name='Membres famille présents')), + ('ref_presents', models.CharField(blank=True, max_length=250, verbose_name='Intervenants présents')), + ('autres_pers_presentes', models.CharField(blank=True, max_length=100, verbose_name='Autres pers. présentes')), + ('motif_fin_suivi', models.CharField(blank=True, choices=[('desengagement', 'Désengagement'), ('evol_positive', 'Autonomie familiale'), ('relai_amb', 'Relai vers ambulatoire'), ('relai', 'Relai vers autre service'), ('placement', 'Placement'), ('non_aboutie', 'Demande non aboutie'), ('non_dispo', 'Pas de disponibilités/place'), ('erreur', 'Erreur de saisie'), ('autres', 'Autres')], max_length=20, verbose_name='Motif de fin de suivi')), + ('famille', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to='aemo.famille')), + ('ope_referent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='aemo.contact', verbose_name='as. OPE')), + ('ope_referent_2', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='aemo.contact', verbose_name='as. OPE 2')), + ('sse_referent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='aemo.contact', verbose_name='SSE')), + ], + ), + migrations.CreateModel( + name='Intervenant', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('date_debut', models.DateField(default=django.utils.timezone.now, verbose_name='Date début')), + ('date_fin', models.DateField(blank=True, null=True, verbose_name='Date fin')), + ('role', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='aemo.role')), + ('suivi', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='aemo.suivi')), + ('intervenant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.AddField( + model_name='suivi', + name='intervenants', + field=models.ManyToManyField(blank=True, related_name='interventions', through='aemo.Intervenant', to=settings.AUTH_USER_MODEL), + ), + migrations.CreateModel( + name='Rapport', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('date', models.DateField(verbose_name='Date du résumé')), + ('situation', models.TextField(blank=True, verbose_name='Situation / contexte familial')), + ('observations', models.TextField(blank=True, verbose_name='Observations, évolution et hypothèses')), + ('projet', models.TextField(blank=True, verbose_name='Perspectives d’avenir')), + ('famille', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='rapports', to='aemo.famille')), + ('auteur', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)), + ('pres_interv', models.ManyToManyField(blank=True, related_name='+', to=settings.AUTH_USER_MODEL, verbose_name='Intervenants cités dans le résumé')), + ('sig_interv', models.ManyToManyField(blank=True, related_name='+', to=settings.AUTH_USER_MODEL, verbose_name='Signature des intervenants')), + ], + ), + migrations.CreateModel( + name='Prestation', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('date_prestation', models.DateField(verbose_name='date de l’intervention')), + ('duree', models.DurationField(verbose_name='durée')), + ('familles_actives', models.PositiveSmallIntegerField(blank=True, default=0)), + ('texte', models.TextField(blank=True, verbose_name='Contenu')), + ('manque', models.BooleanField(default=False, verbose_name='Rendez-vous manqué')), + ('fichier', models.FileField(blank=True, upload_to='prestations', verbose_name='Fichier/image')), + ('famille', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='prestations', to='aemo.famille', verbose_name='Famille')), + ('lib_prestation', models.ForeignKey(default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='prestations_%(app_label)s', to='aemo.libelleprestation')), + ('auteur', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL, verbose_name='auteur')), + ('intervenants', models.ManyToManyField(related_name='prestations', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ('-date_prestation',), + 'permissions': (('edit_prest_prev_month', 'Modifier prestations du mois précédent'),), + }, + ), + migrations.CreateModel( + name='JournalAcces', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('ordinaire', models.BooleanField(default=True)), + ('quand', models.DateTimeField(auto_now_add=True)), + ('famille', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', to='aemo.famille')), + ('utilisateur', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.CreateModel( + name='Bilan', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('date', models.DateField(verbose_name='Date du bilan')), + ('objectifs', models.TextField(verbose_name='Objectifs')), + ('rythme', models.TextField(verbose_name='Rythme et fréquence')), + ('sig_famille', models.BooleanField(default=True, verbose_name='Apposer signature de la famille')), + ('fichier', models.FileField(blank=True, upload_to='bilans', verbose_name='Fichier/image')), + ('famille', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='bilans', to='aemo.famille')), + ('auteur', models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, related_name='bilans', to=settings.AUTH_USER_MODEL)), + ('sig_interv', models.ManyToManyField(blank=True, to=settings.AUTH_USER_MODEL, verbose_name='Signature des intervenants')), + ], + ), + migrations.CreateModel( + name='Document', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('fichier', models.FileField(upload_to='doc', verbose_name='Nouveau fichier')), + ('titre', models.CharField(max_length=100)), + ('famille', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='documents', to='aemo.famille')), + ], + options={ + 'unique_together': {('famille', 'titre')}, + }, + ), + migrations.AlterUniqueTogether( + name='contact', + unique_together={('nom', 'prenom', 'service')}, + ), + ] diff --git a/aemo/migrations/0002_unaccent_extension.py b/aemo/migrations/0002_unaccent_extension.py new file mode 100644 index 0000000..3d721cb --- /dev/null +++ b/aemo/migrations/0002_unaccent_extension.py @@ -0,0 +1,21 @@ +from django.db import migrations +from django.contrib.postgres.operations import UnaccentExtension + +# ref for this migration: https://stackoverflow.com/questions/47230566 + + +class Migration(migrations.Migration): + + dependencies = [ + ('aemo', '0001_initial'), + ] + + operations = [ + UnaccentExtension(), + migrations.RunSQL("CREATE TEXT SEARCH CONFIGURATION french_unaccent(COPY = french);"), + migrations.RunSQL( + "ALTER TEXT SEARCH CONFIGURATION french_unaccent " + "ALTER MAPPING FOR hword, hword_part, word " + "WITH unaccent, french_stem;" + ), + ] diff --git a/aemo/migrations/__init__.py b/aemo/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/aemo/models.py b/aemo/models.py new file mode 100644 index 0000000..8865da3 --- /dev/null +++ b/aemo/models.py @@ -0,0 +1,1446 @@ +from collections import OrderedDict, namedtuple +from datetime import date, timedelta +from operator import attrgetter +from pathlib import Path + +from django.contrib.auth.models import AbstractUser, Group, UserManager +from django.contrib.postgres.aggregates import StringAgg +from django.core.validators import MaxValueValidator +from django.db import models +from django.db.models import Count, F, OuterRef, Q, Subquery, Sum +from django.db.models.functions import Coalesce, ExtractMonth, ExtractYear +from django.db.utils import IntegrityError +from django.urls import reverse +from django.utils import timezone +from django.utils.functional import cached_property + +from django_countries.fields import CountryField + +from common import choices +from common.fields import ChoiceArrayField + +from .utils import format_adresse, format_contact, format_d_m_Y, random_string_generator + + +class CercleScolaire(models.Model): + """ Les 7 cercles scolaires du canton""" + + nom = models.CharField(max_length=50, unique=True) + telephone = models.CharField('tél.', max_length=35, blank=True) + + class Meta: + verbose_name = 'Cercle scolaire' + verbose_name_plural = 'Cercles scolaires' + ordering = ('nom',) + + def __str__(self): + return self.nom + + +class Service(models.Model): + """ Services sociaux en lien avec la Fondation Transit """ + + sigle = models.CharField('Sigle', max_length=20, unique=True) + nom_complet = models.CharField('Nom complet', max_length=80, blank=True) + + class Meta: + ordering = ('sigle',) + + def __str__(self): + return self.sigle + + def save(self, *args, **kwargs): + self.sigle = self.sigle.upper() + super().save(*args, **kwargs) + + +class RoleManager(models.Manager): + def get_by_natural_key(self, nom): + return self.get(nom=nom) + + +class Role(models.Model): + NON_EDITABLE = ['Père', 'Mère', 'Enfant suivi', 'Enfant non-suivi'] + ROLES_PARENTS = ['Père', 'Mère'] + + nom = models.CharField("Nom", max_length=50, unique=True) + est_famille = models.BooleanField("Famille", default=False) + est_intervenant = models.BooleanField("Intervenant", default=False) + est_editeur = models.BooleanField("Éditeur", default=False, help_text=( + "Un rôle éditeur donne le droit de modification des dossiers familles si " + "la personne est intervenante." + )) + + objects = RoleManager() + + class Meta: + ordering = ('nom',) + + def __str__(self): + return self.nom + + @property + def editable(self): + return self.nom not in self.NON_EDITABLE + + def natural_key(self): + return (self.nom,) + + +class Contact(models.Model): + prenom = models.CharField('Prénom', max_length=30) + nom = models.CharField('Nom', max_length=30) + rue = models.CharField('Rue', max_length=30, blank=True) + npa = models.CharField('NPA', max_length=4, blank=True) + localite = models.CharField('Localité', max_length=30, blank=True) + tel_prive = models.CharField('Tél. privé', max_length=30, blank=True) + tel_prof = models.CharField('Tél. prof.', max_length=30, blank=True) + email = models.EmailField(max_length=100, blank=True) + service = models.ForeignKey(Service, null=True, blank=True, on_delete=models.PROTECT) + roles = models.ManyToManyField(Role, related_name='contacts', blank=True, verbose_name='Rôles') + profession = models.CharField('Activité/prof.', max_length=100, blank=True) + remarque = models.TextField("Remarque", blank=True) + est_actif = models.BooleanField('actif', default=True) + + class Meta: + verbose_name = 'Contact' + ordering = ('nom', 'prenom') + unique_together = ('nom', 'prenom', 'service') + + def __str__(self): + sigle = self.service.sigle if self.service else '' + return '{} ({})'.format(self.nom_prenom, sigle) if self.service else self.nom_prenom + + @property + def nom_prenom(self): + return '{} {}'.format(self.nom, self.prenom) + + @property + def adresse(self): + return format_adresse(self.rue, self.npa, self.localite) + + @cached_property + def all_roles(self): + return self.roles.all() + + def has_role(self, roles=()): + """Return True if user has at least one of the `roles` name.""" + return bool(set(r.nom for r in self.all_roles).intersection(set(roles))) + + def roles_str(self, sep=', '): + return sep.join([role.nom for role in self.all_roles]) + + @property + def contact(self): + return format_contact(self.tel_prof, self.email) + + @classmethod + def membres_ope(cls): + return cls.objects.filter(service__sigle__startswith='OPE', est_actif=True).select_related('service') + + +class EquipeChoices(models.TextChoices): + MONTAGNES = 'montagnes', 'Montagnes et V-d-T' + LITTORAL = 'littoral', 'Littoral et V-d-R' + + +class Utilisateur(Contact, AbstractUser): + sigle = models.CharField(max_length=5, blank=True) + equipe = models.CharField("Équipe", max_length=10, choices=EquipeChoices.choices, blank=True) + date_desactivation = models.DateField('Date désactivation', null=True, blank=True) + taux_activite = models.PositiveSmallIntegerField( + "Taux d’activité (en %)", blank=True, default=0, validators=[MaxValueValidator(100)] + ) + decharge = models.PositiveSmallIntegerField("Heures de décharge", blank=True, null=True) + + objects = UserManager() + + def __str__(self): + return self.nom_prenom + + @property + def groupes(self): + return [item.name for item in self.groups.all()] + + @property + def is_psy_or_educ(self): + return self.has_role({'Psy', 'Educ'}) + + @property + def is_responsable(self): + return self.has_role({'Responsable/coordinateur'}) + + @property + def is_admin_ipe(self): + return 'admin ipe' in self.groupes + + @property + def initiales(self): + return self.sigle if self.sigle else f'{self.prenom[0]}{self.nom[0]}' + + @property + def charge_max(self): + """ + Renvoie la charge maximale d'heures hebodmadaires de charge dossier en + fonction du taux d'activité. + """ + charge_map = { + 100: 32, 90: 28, 80: 24, 75: 22, 70: 20, 60: 16, 0: 0, + } + charge = charge_map.get(self.taux_activite, int(32 * self.taux_activite / 100)) + if self.decharge: + charge -= self.decharge + return charge + + def prestations_gen(self): + """Renvoie les prestations générales de l’utilisateur.""" + return self.prestations.filter(famille__isnull=True) + + def temps_total_prestations(self, unite): + return self.prestations.model.temps_total(self.prestations, tous_utilisateurs=False) + + def total_mensuel(self, unite, month, year): + prestations = self.prestations.filter( + date_prestation__year=year, date_prestation__month=month + ) + return self.prestations.model.temps_total(prestations, tous_utilisateurs=False) + + def totaux_mensuels(self, unite, year): + return [self.total_mensuel(unite, m, year) for m in range(1, 13)] + + def total_annuel(self, unite, year): + prestations = self.prestations.filter(date_prestation__year=year) + return self.prestations.model.temps_total(prestations, tous_utilisateurs=False) + + @classmethod + def _intervenants(cls, groupes=(), annee=None): + if annee is None: + # Utilisateurs actuellement actifs + utils = Utilisateur.objects.filter(date_desactivation__isnull=True) + else: + utils = Utilisateur.objects.filter( + Q(date_desactivation__isnull=True) | Q(date_desactivation__year__gte=annee) + ) + return utils.filter( + groups__name__in=groupes, + is_superuser=False + ).exclude( + groups__name__in=['direction'] + ).distinct() + + @classmethod + def intervenants(cls, annee=None): + return cls._intervenants( + groupes=['aemo'], + annee=annee + ) + + +class GroupInfo(models.Model): + group = models.OneToOneField(Group, on_delete=models.CASCADE) + description = models.TextField(blank=True) + + def __str__(self): + return f"Description of group {self.group.name}" + + +class Region(models.Model): + nom = models.CharField(max_length=30, unique=True) + rue = models.CharField("Rue", max_length=100, blank=True) + + def __str__(self): + return self.nom + + def __lt__(self, other): + return self.nom < (other.nom if other else '') + + +class FamilleQuerySet(models.QuerySet): + def with_duos(self): + """ + Renvoie une liste des groupes Psy/Educ différents, sous forme de chaînes + de sigles séparées par "/". + Peut aussi renvoyer 1 ou 3 intervenants, le cas échéant. + """ + return self.annotate( + duo=StringAgg( + F('suivi__intervenant__intervenant__sigle'), + delimiter='/', + filter=( + Q(suivi__intervenant__date_fin__isnull=True) & + Q(suivi__intervenant__role__nom__in=['Educ', 'Psy']) + ), + ordering='suivi__intervenant__intervenant__sigle', + ) + ) + + def with_niveau_interv(self): + """ + Annote les familles avec le niveau d’intervention le plus récent. + """ + return self.annotate( + niveau_interv=Subquery( + Niveau.objects.filter( + famille=OuterRef('pk') + ).order_by('-date_debut').values('niveau_interv')[:1] + ) + ) + + +class FamilleManager(models.Manager): + def get_queryset(self): + return FamilleQuerySet(self.model, using=self._db).filter( + suivi__isnull=False + ).prefetch_related( + models.Prefetch('membres', queryset=Personne.objects.select_related('role')) + ) + + def create_famille(self, equipe='', **kwargs): + kwargs.pop('_state', None) + famille = self.create(**kwargs) + Suivi.objects.create( + famille=famille, + equipe=equipe + ) + famille.suivi.date_demande = date.today() + famille.suivi.save() + return famille + + +class Famille(models.Model): + created_at = models.DateTimeField(auto_now_add=True, editable=False) + archived_at = models.DateTimeField('Archivée le', blank=True, null=True) + nom = models.CharField('Nom de famille', max_length=40) + rue = models.CharField('Rue', max_length=60, blank=True) + npa = models.CharField('NPA', max_length=4, blank=True) + localite = models.CharField('Localité', max_length=30, blank=True) + region = models.ForeignKey(to=Region, null=True, blank=True, default=None, on_delete=models.SET_NULL) + telephone = models.CharField('Tél.', max_length=60, blank=True) + autorite_parentale = models.CharField("Autorité parentale", max_length=20, + choices=choices.AUTORITE_PARENTALE_CHOICES, blank=True) + monoparentale = models.BooleanField('Famille monoparent.', default=None, blank=True, null=True) + statut_marital = models.CharField("Statut marital", max_length=20, choices=choices.STATUT_MARITAL_CHOICES, + blank=True) + connue = models.BooleanField("famille déjà suivie", default=False) + accueil = models.BooleanField("famille d'accueil", default=False) + # Pour CIPE + besoins_part = models.BooleanField("famille à besoins particuliers", default=False) + sap = models.BooleanField(default=False, verbose_name="famille s@p") + garde = models.CharField( + 'Type de garde', max_length=20, choices=choices.TYPE_GARDE_CHOICES, blank=True + ) + provenance = models.CharField( + 'Provenance', max_length=30, choices=choices.PROVENANCE_DESTINATION_CHOICES, blank=True) + destination = models.CharField( + 'Destination', max_length=30, choices=choices.PROVENANCE_DESTINATION_CHOICES, blank=True) + statut_financier = models.CharField( + 'Statut financier', max_length=30, choices=choices.STATUT_FINANCIER_CHOICES, blank=True) + remarques = models.TextField(blank=True) + + objects = FamilleManager() + + class Meta: + ordering = ('nom', 'npa') + permissions = ( + ("can_manage_waiting_list", "Gérer la liste d'attente"), + ("can_archive", "Archiver les dossiers AEMO"), + ('export_stats', 'Exporter les statistiques'), + ) + + def __str__(self): + return '{} - {}'.format(self.nom, self.adresse) + + @property + def adresse(self): + return format_adresse(self.rue, self.npa, self.localite) + + @property + def suivi(self): + return getattr(self, self.suivi_name) if self.suivi_name else None + + @property + def suivi_url(self): + return reverse('famille-suivi', args=[self.pk]) + + @property + def edit_url(self): + return reverse('famille-edit', args=[self.pk]) + + @property + def add_person_url(self): + return reverse('personne-add', args=[self.pk]) + + def redirect_after_personne_creation(self, personne): + return self.edit_url + + @property + def print_coords_url(self): + return reverse('print-coord-famille', args=[self.pk]) + + @classmethod + def actives(cls, date=None): + qs = Famille.objects.filter(suivi__isnull=False).order_by('nom', 'npa') + if date is not None: + return qs.filter(suivi__date_demande__lte=date).filter( + models.Q(suivi__date_fin_suivi__isnull=True) | + models.Q(suivi__date_fin_suivi__gte=date) + ) + else: + return qs.filter(suivi__date_fin_suivi__isnull=True) + + def interventions_actives(self, dt=None): + dt = dt or date.today() + return self.suivi.intervenant_set.filter( + Q(date_debut__lte=dt) & ( + Q(date_fin__isnull=True) | Q(date_fin__gte=dt) + ) + ).select_related('intervenant', 'role') + + def niveau_actuel(self): + try: + return [ + niv.niveau_interv for niv in sorted( + self.niveaux.all(), key=attrgetter('date_debut') + ) + ][-1] + except IndexError: + return None + + def membres_suivis(self): + if 'membres' in getattr(self, '_prefetched_objects_cache', {}): + return sorted([ + pers for pers in self._prefetched_objects_cache['membres'] if pers.role.nom == "Enfant suivi" + ], key=lambda p: p.date_naissance or date(1950, 1, 1)) + else: + return self.membres.filter(role__nom="Enfant suivi").order_by('date_naissance') + + def enfants_non_suivis(self): + if 'membres' in getattr(self, '_prefetched_objects_cache', {}): + return sorted([ + pers for pers in self._prefetched_objects_cache['membres'] if pers.role.nom == "Enfant non-suivi" + ], key=lambda p: p.date_naissance or date(1950, 1, 1)) + else: + return self.membres.filter(role__nom='Enfant non-suivi').order_by('date_naissance') + + def parents(self): + if 'membres' in getattr(self, '_prefetched_objects_cache', {}): + parents = [ + pers for pers in self._prefetched_objects_cache['membres'] + if pers.role.nom in Role.ROLES_PARENTS + ] + else: + parents = [ + pers for pers in self.membres.filter(role__nom__in=Role.ROLES_PARENTS) + ] + return sorted(parents, key=lambda p: p.role.nom) # Mère avant Père + + def autres_parents(self): + excluded_roles = Role.ROLES_PARENTS + ['Enfant suivi', 'Enfant non-suivi'] + if 'membres' in getattr(self, '_prefetched_objects_cache', {}): + return [ + pers for pers in self._prefetched_objects_cache['membres'] + if pers.role.nom not in excluded_roles + ] + else: + return self.membres.exclude(role__nom__in=excluded_roles) + + def access_ok(self, user): + if user.has_perm('aemo.change_famille') or user.is_responsable: + return True + intervs = self.interventions_actives() + return not intervs or user in [interv.intervenant for interv in intervs] + + def can_view(self, user): + return user.has_perm('aemo.view_famille') + + def can_edit(self, user): + if user.has_perm('aemo.change_famille') or user.is_responsable: + return True + if not self.suivi.date_fin_suivi or self.suivi.date_fin_suivi >= date.today(): + intervs = self.interventions_actives() + if ( + self.can_view(user) and + (not intervs or user in [ + interv.intervenant for interv in intervs if interv.role.est_editeur + ]) + ): + return True + return False + + def can_be_deleted(self, user): + if not user.has_perm('aemo.change_famille'): + return False + if self.suivi.motif_fin_suivi == 'erreur': + return True + return False + + def can_be_archived(self, user): + if self.archived_at: + return False + closed_since = date.today() - self.suivi.date_fin_suivi + return ( + user.has_perm('aemo.can_archive') and + self.suivi.date_fin_suivi is not None and + closed_since.days > 180 and + self.suivi.date_fin_suivi.year < date.today().year and + (date.today().month > 1 or closed_since.days > 400) + ) + + def temps_total_prestations(self, interv=None): + """ + Temps total des prestations liées à la famille, quel que soit le nombre + de membres suivis. + Filtré facultativement par intervenant. + """ + prest = self.prestations if interv is None else self.prestations.filter(intervenant=interv) + return prest.annotate(num_util=Count('intervenants')).aggregate( + total=Sum(F('duree') * F('num_util'), output_field=models.DurationField()) + )['total'] or timedelta() + + def temps_total_prestations_reparti(self): + """ + Temps total des prestations liées à la famille, divisé par le nombre d'enfants suivis. + """ + duree = self.temps_total_prestations() + duree //= (len(self.membres_suivis()) or 1) + return duree + + def total_mensuel(self, date_debut_mois): + """ + Temps total de prestations sur un mois donné + """ + return self.prestations.filter( + date_prestation__month=date_debut_mois.month, + date_prestation__year=date_debut_mois.year + ).aggregate(total=Sum('duree'))['total'] or timedelta() + + def total_mensuel_par_prestation(self, prest_codes, date_debut_mois): + """ + Temps total d'évaluation sur un mois donné par prestation. + """ + return self.prestations.annotate( + num_util=Count('intervenants') + ).filter( + lib_prestation__code__in=prest_codes, + date_prestation__month=date_debut_mois.month, + date_prestation__year=date_debut_mois.year + ).aggregate( + total=Sum(F('duree') * F('num_util'), output_field=models.DurationField()) + )['total'] or timedelta() + + def total_mensuel_evaluation(self, date_debut_mois): + return self.total_mensuel_par_prestation(['aemo01'], date_debut_mois) + + def total_mensuel_suivi(self, date_debut_mois): + return self.total_mensuel_par_prestation(['aemo02', 'aemo04', 'aemo05', 'aemo06', 'aemo07'], date_debut_mois) + + def prestations_historiques(self): + return Prestation.prestations_historiques(self.prestations.all()) + + def prestations_du_mois(self): + """ + Retourne le détail des prestations pour cette famille + à partir du mois courant + """ + date_ref = date(date.today().year, date.today().month, 1) + return self.prestations.filter(date_prestation__gte=date_ref) + + @classmethod + def suivis_en_cours(cls, debut, fin): + base = cls.objects.annotate( + date_deb=Coalesce( + 'suivi__date_demande', + 'suivi__date_debut_evaluation', 'suivi__date_debut_suivi' + ), + ).prefetch_related('membres') + return base.filter( + date_deb__lte=fin + ).filter( + Q(**{'suivi__date_fin_suivi__isnull': True}) | + Q(**{'suivi__date_fin_suivi__gte': debut}) + ).exclude(**{'suivi__motif_fin_suivi': 'erreur'}) + + @classmethod + def suivis_nouveaux(cls, annee, mois): + base = cls.objects.annotate( + date_dem=F('suivi__date_demande') + ).prefetch_related('membres') + return base.filter(**{ + 'date_dem__year': annee, 'date_dem__month': mois, + }).exclude(**{'suivi__motif_fin_suivi': 'erreur'}) + + @classmethod + def suivis_termines(cls, annee, mois): + base = cls.objects.all().prefetch_related('membres') + return base.filter(**{ + 'suivi__date_fin_suivi__year': annee, + 'suivi__date_fin_suivi__month': mois, + }).exclude(**{'suivi__motif_fin_suivi': 'erreur'}) + + def can_be_reactivated(self, user): + return user.has_perm("aemo.change_famille") and self.suivi.date_fin_suivi is not None + + def anonymiser(self): + # Famille + self.nom = random_string_generator() + self.rue = '' + self.telephone = '' + self.remarques = '' + self.archived_at = timezone.now() + self.save() + + # Personne + for personne in self.membres.all().select_related('role'): + if personne.role.nom == 'Enfant suivi': + personne.nom = random_string_generator() + personne.prenom = random_string_generator() + personne.validite = None + personne.contractant = False + fields = ['rue', 'npa', 'localite', 'telephone', 'email', 'remarque', 'remarque_privee', + 'profession', 'filiation', 'allergies', 'employeur', 'permis', 'animaux'] + [setattr(personne, field, '') for field in fields] + personne.save() + personne.reseaux.clear() + if hasattr(personne, 'formation'): + personne.formation.cercle_scolaire = None + fields = ['college', 'classe', 'enseignant', 'creche', 'creche_resp', 'entreprise', + 'maitre_apprentissage', 'remarque'] + [setattr(personne.formation, field, '') for field in fields] + personne.formation.save() + else: + personne.delete() + # Suivi + fields = ['difficultes', 'aides', 'competences', 'autres_contacts', 'disponibilites', 'remarque', + 'remarque_privee', 'motif_detail', 'referent_note', 'collaboration', 'ressources', 'crise', + 'pers_famille_presentes', 'ref_presents', 'autres_pers_presentes'] + [setattr(self.suivi, field, '') for field in fields] + self.suivi.save() + + # Related + for doc in self.documents.all(): + doc.delete() + for bilan in self.bilans.all(): + bilan.delete() + for rapport in self.rapports.all(): + rapport.delete() + self.prestations.all().update(texte='') + for prestation in self.prestations.exclude(fichier=''): + prestation.fichier.delete() + prestation.save() + + +class PersonneManager(models.Manager): + + def create_personne(self, **kwargs): + kwargs.pop('_state', None) + pers = self.create(**kwargs) + return self.add_formation(pers) + + def add_formation(self, pers): + if pers.role.nom == 'Enfant suivi' and not hasattr(pers, 'formation'): + moins_de_4ans = bool(pers.age and pers.age < 4) + Formation.objects.create( + personne=pers, statut='pre_scol' if moins_de_4ans else '' + ) + if moins_de_4ans: + try: + pers.famille.suivi.demande_prioritaire = True + pers.famille.suivi.save() + except AttributeError: + pass + return pers + + +class Personne(models.Model): + """ Classe de base des personnes """ + created_at = models.DateTimeField(auto_now_add=True, editable=False, null=True) + + nom = models.CharField('Nom', max_length=30) + prenom = models.CharField('Prénom', max_length=30, blank=True) + date_naissance = models.DateField('Date de naissance', null=True, blank=True) + genre = models.CharField('Genre', max_length=1, choices=(('M', 'M'), ('F', 'F')), default='M') + rue = models.CharField('Rue', max_length=60, blank=True) + npa = models.CharField('NPA', max_length=4, blank=True) + localite = models.CharField('Localité', max_length=30, blank=True) + telephone = models.CharField('Tél.', max_length=60, blank=True) + email = models.EmailField('Courriel', blank=True) + pays_origine = CountryField('Nationalité', blank=True) + remarque = models.TextField(blank=True) + remarque_privee = models.TextField('Remarque privée', blank=True) + famille = models.ForeignKey(Famille, on_delete=models.CASCADE, related_name='membres') + role = models.ForeignKey('Role', on_delete=models.PROTECT) + profession = models.CharField('Profession', max_length=50, blank=True) + filiation = models.CharField('Filiation', max_length=80, blank=True) + decedee = models.BooleanField('Cette personne est décédée', default=False) + reseaux = models.ManyToManyField(Contact, blank=True) + allergies = models.TextField('Allergies', blank=True) + # Champs spécifiques SIFP + employeur = models.CharField('Adresse empl.', max_length=50, blank=True) + permis = models.CharField('Permis/séjour', max_length=30, blank=True) + validite = models.DateField('Date validité', blank=True, null=True) + animaux = models.BooleanField('Animaux', default=None, null=True) + + objects = PersonneManager() + + class Meta: + verbose_name = 'Personne' + ordering = ('nom', 'prenom') + + def __str__(self): + return '{} {}'.format(self.nom, self.prenom) + + @property + def nom_prenom(self): + return str(self) + + @property + def adresse(self): + return format_adresse(self.rue, self.npa, self.localite) + + @property + def age(self): + return self.age_a(date.today()) + + def age_str(self, date_=None, format_='auto'): + """Renvoie '2 ans, 4 mois', '5 ans', '8 mois'. + Param: + 'auto': application du format + - 'jour' si l'âge < 30 jours + - 'sem_jour' si l'âge < 77 jours (11 semaines) + - 'mois_sem' si l'âge < 690 jours (23 mois) + - 'an_mois': autres cas + 'jour': âge en jours + 'sem_jour': âge en semaines et jours + 'mois_sem': âge en mois et semaines + """ + if not self.date_naissance: + # Pourrait disparaître si date_naissance devient non null + return '' + if format_ not in ['auto', 'jour', 'sem_jour', 'mois_sem', 'an_mois']: + raise ValueError('Paramètre erroné') + age_jours = self.age_jours(date_) + + if (age_jours < 30 and format_ == 'auto') or format_ == 'jour': + age_str = f"{age_jours} jour{'s' if age_jours > 1 else ''}" + elif (age_jours < 77 and format_ == 'auto') or format_ == 'sem_jour': + sem, jours = age_jours // 7, age_jours % 7 + age_str = f"{sem} sem." + age_str += f" {jours} jour{'s' if jours > 1 else ''}" if jours > 0 else '' + elif (age_jours < 690 and format_ == 'auto') or format_ == 'mois_sem': + mois, sem = age_jours // 30, int((age_jours % 30)/7) + age_str = f"{mois} mois" + age_str += f" {sem} sem." if sem > 0 else '' + else: + ans, mois = int(age_jours / 365.25), int((age_jours % 365.25) / 30) + age_str = f"{ans} an{'s' if ans > 1 else ''}" + age_str += " %d mois" % mois if mois > 0 else '' + return age_str + + def age_a(self, date_): + if not self.date_naissance: + # Pourrait disparaître si date_naissance devient non null + return None + age = (date_ - self.date_naissance).days / 365.25 + return int(age * 10) / 10 # 1 décimale arrondi vers le bas + + def age_jours(self, date_=None): + if date_ is None: + date_ = date.today() + return (date_ - self.date_naissance).days + + def age_mois(self, date_=None): + """Âge en mois à la date_""" + if date_ is None: + date_ = date.today() + if not self.date_naissance or date_ < self.date_naissance: + return None + return self.age_jours(date_) / (365 / 12) + + @property + def localite_display(self): + return '{} {}'.format(self.npa, self.localite) + + @property + def edit_url(self): + return reverse('personne-edit', args=[self.famille_id, self.pk]) + + def can_edit(self, user): + return self.famille.can_edit(user) + + def can_be_deleted(self, user): + return self.famille.can_be_deleted(user) + + +class Formation(models.Model): + + FORMATION_CHOICES = ( + ('pre_scol', 'Pré-scolaire'), + ('cycle1', 'Cycle 1'), + ('cycle2', 'Cycle 2'), + ('cycle3', 'Cycle 3'), + ('apprenti', 'Apprentissage'), + ('etudiant', 'Etudiant'), + ('en_emploi', 'En emploi'), + ('sans_emploi', 'Sans emploi'), + ('sans_occupation', 'Sans occupation'), + ) + + personne = models.OneToOneField(Personne, on_delete=models.CASCADE) + + statut = models.CharField('Scolarité', max_length=20, choices=FORMATION_CHOICES, blank=True) + cercle_scolaire = models.ForeignKey( + CercleScolaire, blank=True, null=True, on_delete=models.SET_NULL, + related_name='+', verbose_name='Cercle scolaire' + ) + college = models.CharField('Collège', max_length=50, blank=True) + classe = models.CharField('Classe', max_length=50, blank=True) + enseignant = models.CharField('Enseignant', max_length=50, blank=True) + + creche = models.CharField('Crèche', max_length=50, blank=True) + creche_resp = models.CharField('Resp.crèche', max_length=50, blank=True) + + entreprise = models.CharField('Entreprise', max_length=50, blank=True) + maitre_apprentissage = models.CharField("Maître d'appr.", max_length=50, blank=True) + + remarque = models.TextField(blank=True) + + class Meta: + verbose_name = 'Scolarité' + + def __str__(self): + return 'Scolarité de {}'.format(self.personne) + + def can_edit(self, user): + return self.personne.famille.can_edit(user) + + def delete(self, **kwargs): + if self.personne: + raise IntegrityError + else: + super().delete(**kwargs) + + @property + def sse(self): + return "Champ à définir" + + def pdf_data(self): + if self.personne.formation.statut == 'pre_scol': + data = [ + ['Crèche: {}'.format(self.creche), 'responsable: {}'.format(self.creche_resp)], + ] + elif self.personne.formation.statut in ['cycle1', 'cycle2', 'cycle3', 'etudiant']: + data = [ + ['Cercle: {}'.format(self.cercle_scolaire), 'collège: {}'.format(self.college)], + ['Classe: {}'.format(self.classe), 'Enseignant-e: {}'.format(self.enseignant)] + ] + + elif self.personne.formation.statut == 'apprenti': + data = [ + ['Employeur: {}'.format(self.entreprise), 'resp. apprenti-e: {}'.format(self.maitre_apprentissage)], + ] + else: + data = [self.personne.formation.statut] + return data + + def info_scol(self): + if self.statut == 'pre_scol': + creche = f"Crèche: {self.creche}" if self.creche else '' + resp = f" - resp.: {self.creche_resp}" if self.creche_resp else '' + data = ''.join([creche, resp]) + elif self.statut in ['cycle1', 'cycle2', 'cycle3', 'etudiant']: + college = f"Collège: {self.college}" if self.college else '' + classe = f" - classe: {self.classe}" if self.classe else '' + ens = f" - ens.: {self.enseignant}" if self.enseignant else '' + data = ''.join([college, classe, ens]) + elif self.statut == 'apprenti': + emp = f"Employeur: {self.entreprise}" if self.entreprise else '' + resp = f" - resp.: {self.maitre_apprentissage}" if self.maitre_apprentissage else '' + data = ''.join([emp, resp]) + elif self.statut: + data = self.get_statut_display() if self.statut else '' + else: + data = "Formation: aucune info. saisie" + return data + + +class Document(models.Model): + famille = models.ForeignKey(Famille, related_name='documents', on_delete=models.CASCADE) + fichier = models.FileField("Nouveau fichier", upload_to='doc') + titre = models.CharField(max_length=100) + + class Meta: + unique_together = ('famille', 'titre') + + def __str__(self): + return self.titre + + def delete(self): + Path(self.fichier.path).unlink(missing_ok=True) + return super().delete() + + def can_edit(self, user): + return self.famille.can_edit(user) + + def can_be_deleted(self, user): + return self.famille.can_be_deleted(user) + + +class Bilan(models.Model): + famille = models.ForeignKey(Famille, related_name='bilans', on_delete=models.CASCADE) + date = models.DateField("Date du bilan") + auteur = models.ForeignKey( + Utilisateur, related_name='bilans', on_delete=models.PROTECT, null=True, + ) + objectifs = models.TextField("Objectifs") + rythme = models.TextField("Rythme et fréquence") + sig_famille = models.BooleanField("Apposer signature de la famille", default=True) + sig_interv = models.ManyToManyField(Utilisateur, blank=True, verbose_name="Signature des intervenants") + fichier = models.FileField("Fichier/image", blank=True, upload_to='bilans') + + delete_urlname = 'famille-bilan-delete' + + def __str__(self): + return "Bilan du {} pour la famille {}".format(self.date, self.famille) + + def get_absolute_url(self): + return reverse('famille-bilan-view', args=[self.famille_id, self.pk]) + + def get_print_url(self): + return reverse('print-bilan', args=[self.pk]) + + def edit_url(self): + return reverse('famille-bilan-edit', args=[self.famille_id, self.pk]) + + def can_edit(self, user): + return self.famille.can_edit(user) or user in self.famille.suivi.intervenants.all() + + def title(self): + return f"Bilan du {format_d_m_Y(self.date)}" + + +class Rapport(models.Model): + """ + Rapport est l'appellation historique, Résumé étant l'appellation moderne (dès février 2023). + Les champs «techniques» n'ont pas été renommés. + """ + famille = models.ForeignKey(Famille, related_name='rapports', on_delete=models.CASCADE) + date = models.DateField("Date du résumé") + auteur = models.ForeignKey(Utilisateur, on_delete=models.PROTECT) + pres_interv = models.ManyToManyField( + Utilisateur, blank=True, verbose_name="Intervenants cités dans le résumé", related_name='+' + ) + sig_interv = models.ManyToManyField( + Utilisateur, blank=True, verbose_name="Signature des intervenants", related_name='+' + ) + situation = models.TextField("Situation / contexte familial", blank=True) + observations = models.TextField("Observations, évolution et hypothèses", blank=True) + projet = models.TextField("Perspectives d’avenir", blank=True) + + def __str__(self): + return "Résumé du {} pour la famille {}".format(format_d_m_Y(self.date), self.famille) + + def get_absolute_url(self): + return reverse('famille-rapport-view', args=[self.famille.pk, self.pk]) + + def edit_url(self): + return reverse('famille-rapport-edit', args=[self.famille.pk, self.pk]) + + def get_print_url(self): + return reverse('famille-rapport-print', args=[self.famille.pk, self.pk]) + + def can_edit(self, user): + return self.famille.can_edit(user) or user in self.famille.suivi.intervenants.all() + + def can_delete(self, user): + return self.famille.can_edit(user) + + def title(self): + return f"Résumé du {format_d_m_Y(self.date)}" + + def intervenants(self): + return [i.intervenant for i in self.famille.interventions_actives(self.date)] + + +class Niveau(models.Model): + INTERV_CHOICES = [(0, '0'), (1, '1'), (2, '2'), (3, '3')] + famille = models.ForeignKey( + Famille, related_name='niveaux', on_delete=models.CASCADE, verbose_name="Famille" + ) + niveau_interv = models.PositiveSmallIntegerField("Niveau d’intervention", choices=INTERV_CHOICES) + date_debut = models.DateField('Date début', blank=True, null=True) + date_fin = models.DateField('Date fin', blank=True, null=True) + + def __str__(self): + return ( + f"{self.famille.nom} - {self.niveau_interv} - du {format_d_m_Y(self.date_debut)} au " + f"{format_d_m_Y(self.date_fin)}" + ) + + +class LibellePrestation(models.Model): + code = models.CharField('Code', max_length=6, unique=True) + nom = models.CharField('Nom', max_length=30) + actes = models.TextField('Actes à prester', blank=True) + + class Meta: + ordering = ('code',) + + def __str__(self): + return self.nom + + +class Prestation(models.Model): + famille = models.ForeignKey(Famille, related_name='prestations', null=True, blank=True, + on_delete=models.SET_NULL, verbose_name="Famille") + auteur = models.ForeignKey(Utilisateur, on_delete=models.PROTECT, verbose_name='auteur') + intervenants = models.ManyToManyField(Utilisateur, related_name='prestations') + date_prestation = models.DateField("date de l’intervention") + duree = models.DurationField("durée") + lib_prestation = models.ForeignKey( + LibellePrestation, on_delete=models.SET_NULL, null=True, default=None, + related_name='prestations_%(app_label)s' + ) + # Nombre de familles actives au moment de la prestation, utilisé pour ventiler les heures + # dans les statistiques. + familles_actives = models.PositiveSmallIntegerField(blank=True, default=0) + texte = models.TextField('Contenu', blank=True) + manque = models.BooleanField('Rendez-vous manqué', default=False) + fichier = models.FileField('Fichier/image', upload_to='prestations', blank=True) + + # Nbre de jours maximum après fin d'un mois où il est encore possible de saisir + # des données du mois précédent. + DELAI_SAISIE_SUPPL = 14 + + class Meta: + ordering = ('-date_prestation',) + permissions = ( + ('edit_prest_prev_month', 'Modifier prestations du mois précédent'), + ) + + def __str__(self): + if self.famille: + return f'Prestation pour la famille {self.famille} le {self.date_prestation} : {self.duree}' + return f'Prestation générale le {self.date_prestation} : {self.duree}' + + def can_edit(self, user): + return ( + (user == self.auteur or user in self.intervenants.all() or + user.has_perm('aemo.edit_prest_prev_month') + ) and self.check_date_allowed(user, self.date_prestation) + ) + + def edit_url(self): + return reverse('prestation-edit', args=[self.famille.pk if self.famille else 0, self.pk]) + + def save(self, *args, **kwargs): + if self.famille is None: + self.familles_actives = Famille.actives(self.date_prestation).count() + super().save(*args, **kwargs) + + @classmethod + def check_date_allowed(cls, user, dt): + """Contrôle si la date `dt` est située dans le mois courant + DELAI_SAISIE_SUPPL.""" + today = date.today() + delai = cls.DELAI_SAISIE_SUPPL + if user.has_perm('aemo.edit_prest_prev_month'): + delai += 31 + if today.day <= delai: + return dt >= (today - timedelta(days=delai + 1)).replace(day=1) + else: + return dt.year == today.year and dt.month == today.month + + @classmethod + def prestations_historiques(cls, prestations): + """ + Renvoie un queryset avec toutes les prestations regroupées par annee/mois + et le total correspondant. + """ + return prestations.annotate( + annee=ExtractYear('date_prestation'), + mois=ExtractMonth('date_prestation') + ).values('annee', 'mois').order_by('-annee', '-mois').annotate(total=Sum('duree')) + + @classmethod + def temps_total(cls, prestations, tous_utilisateurs=False): + """ + Renvoie le temps total des `prestations` (QuerySet) sous forme de chaîne '12:30'. + Si tous_utilisateurs = True, multiplie le temps de chaque prestation par son + nombre d'intervenants. + """ + if prestations.count() > 0: + if tous_utilisateurs: + duree = prestations.annotate(num_util=Count('intervenants')).aggregate( + total=Sum(F('duree') * F('num_util'), output_field=models.DurationField()) + )['total'] or timedelta() + else: + duree = prestations.aggregate(total=Sum('duree'))['total'] or timedelta() + else: + duree = timedelta() + return duree + + @classmethod + def temps_total_general(cls, annee, familles=True): + """ + Renvoie le temps total des prestations (familiales ou générales selon + familles) pour l'année civile `annee`. + """ + prest_tot = cls.objects.filter( + famille__isnull=not familles, + date_prestation__year=annee + ).aggregate(total=Sum('duree'))['total'] or timedelta() + return prest_tot + + @classmethod + def temps_total_general_fam_gen(cls, annee): + """ + Renvoie le temps total des prestations familiales ET générales pour l'année civile `annee`. + """ + prest_tot = cls.objects.filter( + date_prestation__year=annee + ).aggregate(total=Sum('duree'))['total'] or timedelta() + return prest_tot + + @classmethod + def temps_totaux_mensuels(cls, annee): + """ + Renvoie une liste du total mensuel de toutes les prestations familiales + (sans prestations générales) pour l'année en cours (de janv. à déc.). + """ + data = [] + for month in range(1, 13): + tot = cls.objects.filter( + famille__isnull=False, + date_prestation__year=annee, date_prestation__month=month, + ).aggregate(total=Sum('duree'))['total'] or timedelta() + data.append(tot) + return data + + +class JournalAcces(models.Model): + """Journalisation des accès aux familles.""" + famille = models.ForeignKey(Famille, on_delete=models.CASCADE, related_name='+') + utilisateur = models.ForeignKey(Utilisateur, on_delete=models.CASCADE, related_name='+') + ordinaire = models.BooleanField(default=True) + quand = models.DateTimeField(auto_now_add=True) + + def __str__(self): + return f"Accès de «{self.utilisateur}» à la famille «{self.famille}», le {self.quand}" + + +class Etape: + """ + 1. Numéro d'ordre pour tri dans la vue + 2. Code + 3. Affichage dans tableau + 4. Nom visible de l'étape + 5. Code de l'étape suivante + 6. Délai jusqu'à l'échéance (en jours) + 7. Code de l'étape précédente + 8. Date obligatoire précédente + 9. Saisie obligatoire ou non (True / False) + """ + + def __init__(self, num, code, abrev, nom, suivante, delai, precedente, preced_oblig, oblig): + self.num = num + self.code = code + self.abrev = abrev + self.nom = nom + self.suivante = suivante + self.delai = delai + self.precedente = precedente + self.preced_oblig = preced_oblig + self.oblig = oblig + + def __str__(self): + return self.nom + + def __repr__(self): + return ''.format(self.num, self.code) + + def date(self, suivi): + return getattr(suivi, 'date_{}'.format(self.code)) + + def delai_depuis(self, suivi, date_base): + """Délai de cette étape à partir de la date date_base (en général date précédente étape).""" + if not date_base: + return None + return date_base + timedelta(days=self.delai) + + def date_nom(self): + return 'date_{}'.format(self.code) + + def etape_suivante(self, suivi): + return Suivi.WORKFLOW[self.suivante] if self.suivante else None + + def etape_precedente(self): + return Suivi.WORKFLOW[self.precedente] if self.precedente else None + + +class EtapeDebutSuivi(Etape): + def date(self, suivi): + return suivi.debut_suivi_selon_niveau + + +class EtapeBilan(Etape): + delai_standard = 3 * 30 # 3 mois + delai_niveau3 = 30 # 1 mois + + def date(self, suivi): + """Date du dernier bilan""" + if self.etape_suivante(suivi).code == 'bilan_suivant': + return None + return suivi.date_dernier_bilan() + + def etape_suivante(self, suivi): + # Soit bilan suivant, soit rapport + if suivi.date_dernier_rapport(): + return Suivi.WORKFLOW['resume'] + date_bilan = suivi.date_dernier_bilan() + prochain_rapport = suivi.date_prochain_rapport() + if date_bilan and prochain_rapport and (prochain_rapport - date_bilan) < timedelta(days=4 * 30): + return Suivi.WORKFLOW['resume'] + else: + return Suivi.WORKFLOW['bilan_suivant'] + + def delai_depuis(self, suivi, date_base): + """Délai de cette étape à partir de la date date_base (en général date précédente étape).""" + if not date_base: + return None + base = suivi.date_dernier_bilan() or date_base + delai = self.delai_niveau3 if suivi.famille.niveau_actuel() == 3 else self.delai_standard + return base + timedelta(days=delai) + + +class EtapeResume(Etape): + def date(self, suivi): + """Date du dernier rapport""" + return suivi.date_dernier_rapport() + + +class EtapeFin(Etape): + delai_standard = 365 + 180 # 18 mois + delai_niveau3 = 270 # 9 mois + + def delai_depuis(self, suivi, *args): + if not suivi.date_debut_suivi: + return None + debut_suivi = suivi.debut_suivi_selon_niveau + delai = self.delai_niveau3 if suivi.famille.niveau_actuel() == 3 else self.delai_standard + return debut_suivi + timedelta(days=delai) + + +Equipe = namedtuple("Equipe", ['code', 'nom', 'perm']) + +EQUIPES = [ + Equipe(code='montagnes', nom='Montagnes et V-d-T', perm=None), + Equipe(code='littoral', nom='Littoral et V-d-R', perm=None), + # Anciennes équipes, conservées pour anciens dossiers + Equipe(code='neuch_ville', nom='Neuchâtel-ville (archives)', perm=None), + Equipe(code='litt_est', nom='Littoral Est (archives)', perm=None), + Equipe(code='litt_ouest', nom='Littoral Ouest (archives)', perm=None), +] + + +class Suivi(models.Model): + WORKFLOW = OrderedDict([ + ('demande', Etape( + 1, 'demande', 'dem', 'Demande déposée', 'debut_evaluation', 0, 'demande', 'demande', True + )), + ('debut_evaluation', Etape( + 2, 'debut_evaluation', 'deb_eva', "Début de l’évaluation", 'fin_evaluation', 40, 'demande', 'demande', True + )), + ('fin_evaluation', Etape( + 3, 'fin_evaluation', "fin_eva", "Fin de l’évaluation", 'debut_suivi', 40, + 'debut_evaluation', 'debut_evaluation', True + )), + ('debut_suivi', EtapeDebutSuivi( + 4, 'debut_suivi', "dsuiv", 'Début du suivi', 'bilan_suivant', 40, 'fin_evaluation', 'fin_evaluation', False + )), + ('bilan_suivant', EtapeBilan( + 5, 'bilan_suivant', "bil", 'Bilan suivant', 'resume', 90, 'debut_suivi', 'fin_evaluation', False + )), + ('resume', EtapeResume( + 6, 'resume', 'rés', 'Résumé', 'fin_suivi', 90, 'bilan_suivant', 'fin_evaluation', False + )), + ('fin_suivi', EtapeFin( + 7, 'fin_suivi', "fsuiv", 'Fin du suivi', 'archivage', 365 + 180, 'resume', 'fin_evaluation', True + )), + ('archivage', Etape( + 8, 'arch_dossier', 'arch', 'Archivage du dossier', None, 0, 'fin_suivi', 'fin_suivi', False + )), + ]) + + EQUIPES_CHOICES = [(equ.code, equ.nom) for equ in EQUIPES] + + famille = models.OneToOneField(Famille, on_delete=models.CASCADE) + equipe = models.CharField('Équipe', max_length=15, choices=EQUIPES_CHOICES) + heure_coord = models.BooleanField("Heure de coordination", default=False) + difficultes = models.TextField("Difficultés", blank=True) + aides = models.TextField("Aides souhaitées", blank=True) + competences = models.TextField('Ressources/Compétences', blank=True) + dates_demande = models.CharField('Dates', max_length=128, blank=True) + autres_contacts = models.TextField("Autres services contactés", blank=True) + disponibilites = models.TextField('Disponibilités', blank=True) + remarque = models.TextField(blank=True) + remarque_privee = models.TextField('Remarque privée', blank=True) + service_orienteur = models.CharField( + "Orienté vers l’AEMO par", max_length=15, choices=choices.SERVICE_ORIENTEUR_CHOICES, blank=True + ) + service_annonceur = models.CharField('Service annonceur', max_length=60, blank=True) + motif_demande = ChoiceArrayField( + models.CharField(max_length=60, choices=choices.MOTIF_DEMANDE_CHOICES), + verbose_name="Motif de la demande", blank=True, null=True) + motif_detail = models.TextField('Motif', blank=True) + + # Référents + intervenants = models.ManyToManyField( + Utilisateur, through='Intervenant', related_name='interventions', blank=True + ) + ope_referent = models.ForeignKey(Contact, blank=True, null=True, related_name='+', + on_delete=models.SET_NULL, verbose_name='as. OPE') + ope_referent_2 = models.ForeignKey( + Contact, blank=True, null=True, related_name='+', on_delete=models.SET_NULL, + verbose_name='as. OPE 2' + ) + mandat_ope = ChoiceArrayField( + models.CharField(max_length=65, choices=choices.MANDATS_OPE_CHOICES, blank=True), + verbose_name="Mandat OPE", blank=True, null=True, + ) + sse_referent = models.ForeignKey(Contact, blank=True, null=True, related_name='+', + on_delete=models.SET_NULL, verbose_name='SSE') + referent_note = models.TextField('Autres contacts', blank=True) + collaboration = models.TextField('Collaboration', blank=True) + ressource = models.TextField('Ressource', blank=True) + crise = models.TextField('Gestion de crise', blank=True) + + date_demande = models.DateField("Demande déposée le", blank=True, null=True, default=None) + date_debut_evaluation = models.DateField("Début de l’évaluation le", blank=True, null=True, default=None) + date_fin_evaluation = models.DateField("Fin de l’évaluation le", blank=True, null=True, default=None) + date_debut_suivi = models.DateField("Début du suivi le", blank=True, null=True, default=None) + date_fin_suivi = models.DateField("Fin du suivi le", blank=True, null=True, default=None) + + demande_prioritaire = models.BooleanField("Demande prioritaire", default=False) + demarche = ChoiceArrayField(models.CharField(max_length=60, choices=choices.DEMARCHE_CHOICES, blank=True), + verbose_name="Démarche", blank=True, null=True) + + pers_famille_presentes = models.CharField('Membres famille présents', max_length=200, blank=True) + ref_presents = models.CharField('Intervenants présents', max_length=250, blank=True) + autres_pers_presentes = models.CharField('Autres pers. présentes', max_length=100, blank=True) + motif_fin_suivi = models.CharField('Motif de fin de suivi', max_length=20, + choices=choices.MOTIFS_FIN_SUIVI_CHOICES, blank=True) + + def __str__(self): + return 'Suivi pour la famille {} '.format(self.famille) + + @property + def date_fin_theorique(self): + if self.date_fin_suivi: + return self.date_fin_suivi + if self.date_debut_suivi is None: + return None + return self.debut_suivi_selon_niveau + timedelta(days=548) # env. 18 mois + + @cached_property + def debut_suivi_selon_niveau(self): + """ + Date de début de suivi tenant compte d'éventuel changement de niveau entre 2 et 3. + """ + debut = self.date_debut_suivi + if debut is None: + return debut + niv_prec = None + for niv in sorted(self.famille.niveaux.all(), key=lambda niv: niv.date_debut): + if ( + (niv_prec in [1, 2] and niv.niveau_interv == 3) or + (niv_prec == 3 and niv.niveau_interv in [1, 2]) + ): + debut = niv.date_debut + niv_prec = niv.niveau_interv + return debut + + def date_prochain_rapport(self): + if self.date_debut_suivi is None or self.date_fin_suivi is not None: + return None + date_dernier_rapport = self.date_dernier_rapport() + niveau = self.famille.niveau_actuel() + if date_dernier_rapport is None: + # Premier à 9 ou 18 mois, selon niveau + delai = 30 * 9 if niveau == 3 else 365 + 182 + return self.date_debut_suivi + timedelta(days=delai) + else: + # Suivants tous les 9 ou 12 mois, selon niveau + delai = 30 * 9 if niveau == 3 else 365 + return date_dernier_rapport + timedelta(days=delai) + + def date_dernier_rapport(self): + # Using rapports.all() to leverage prefetchs + debut_suivi = self.debut_suivi_selon_niveau + return max([ + rapp.date for rapp in self.famille.rapports.all() if rapp.date > debut_suivi + ], default=None) + + def date_dernier_bilan(self): + debut_suivi = self.debut_suivi_selon_niveau + # Using bilans.all() to leverage prefetchs + return max([ + bilan.date for bilan in self.famille.bilans.all() if bilan.date > debut_suivi + ], default=None) + + @property + def ope_referent_display(self): + return self.ope_referent.nom_prenom if self.ope_referent else '-' + + @cached_property + def etape(self): + """L’étape *terminée* du suivi.""" + for key, etape in reversed(self.WORKFLOW.items()): + if key != 'archivage': + if etape.date(self): + return etape + + @cached_property + def etape_suivante(self): + return self.etape.etape_suivante(self) if self.etape else None + + def date_suivante(self): + etape_date = self.etape.date(self) + if not self.etape_suivante or not etape_date: + return None + return self.etape_suivante.delai_depuis(self, etape_date) + + def get_mandat_ope_display(self): + dic = dict(choices.MANDATS_OPE_CHOICES) + return '; '.join([dic[value] for value in self.mandat_ope]) if self.mandat_ope else '-' + + def get_motif_demande_display(self): + dic = dict(choices.MOTIF_DEMANDE_CHOICES) + return '; '.join([dic[value] for value in self.motif_demande]) if self.motif_demande else '' + + @property + def ope_referents(self): + return [ope for ope in [self.ope_referent, self.ope_referent_2] if ope] + + +class IntervenantManager(models.Manager): + def actifs(self, _date=None): + return self.filter(Q(date_fin__isnull=True) | Q(date_fin__gt=_date or date.today())) + + +class Intervenant(models.Model): + """ + Modèle M2M entre Suivi et Utilisateur (utilisé par through de Suivi.intervenants). + """ + suivi = models.ForeignKey(Suivi, on_delete=models.CASCADE) + intervenant = models.ForeignKey(Utilisateur, on_delete=models.CASCADE) + role = models.ForeignKey(Role, on_delete=models.CASCADE) + date_debut = models.DateField('Date début', default=timezone.now) + # date_fin est utilisé pour les interventions s'arrêtant avant la fin du suivi. + date_fin = models.DateField('Date fin', null=True, blank=True) + + objects = IntervenantManager() + + def __str__(self): + return '{}, {} pour {}'.format(self.intervenant, self.role, self.suivi) + + def can_edit(self, user): + return self.suivi.famille.can_edit(user) diff --git a/aemo/pdf.py b/aemo/pdf.py new file mode 100644 index 0000000..31403f5 --- /dev/null +++ b/aemo/pdf.py @@ -0,0 +1,636 @@ +from io import BytesIO + +import extract_msg +import nh3 + +from bs4 import BeautifulSoup +from pypdf import PdfWriter + +from functools import partial +from reportlab.lib.enums import TA_LEFT, TA_CENTER, TA_JUSTIFY +from reportlab.lib.pagesizes import A4 +from reportlab.lib import colors +from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet +from reportlab.lib.units import cm +from reportlab.pdfgen import canvas +from reportlab.platypus import ( + PageBreak, Paragraph, Preformatted, SimpleDocTemplate, Spacer, Table, TableStyle +) + +from django.contrib.staticfiles.finders import find +from django.utils.text import slugify + +from .utils import format_d_m_Y, format_duree, format_Ymd + + +def format_booleen(val): + return '?' if val is None else ('oui' if val else 'non') + + +class PageNumCanvas(canvas.Canvas): + """A special canvas to be able to draw the total page number in the footer.""" + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._pages = [] + + def showPage(self): + self._pages.append(dict(self.__dict__)) + self._startPage() + + def save(self): + page_count = len(self._pages) + for page in self._pages: + self.__dict__.update(page) + self.draw_page_number(page_count) + canvas.Canvas.showPage(self) + super().save() + + def draw_page_number(self, page_count): + self.setFont("Helvetica", 9) + self.drawRightString(self._pagesize[0] - 1.6*cm, 2.3*cm, "p. %s/%s" % (self._pageNumber, page_count)) + + +class CleanParagraph(Paragraph): + """If the (HTML) text cannot be parsed, try to clean it.""" + def __init__(self, text, *args, **kwargs): + if text: + text = text.replace('

', '


') + if '
    ' in text: + text = text.replace('
  • ', '  • ').replace('
','
').replace('', '
') + try: + super().__init__(text, *args, **kwargs) + except ValueError: + text = nh3.clean( + text, tags={'p', 'br', 'b', 'strong', 'u', 'i', 'em', 'ul', 'li'} + ).replace('
', '
') + super().__init__(text, *args, **kwargs) + + +class RawParagraph(Paragraph): + """Raw text, replace new lines by
.""" + def __init__(self, text='', *args, **kwargs): + if text: + text = text.replace('\r\n', '\n').replace('\n', '\n
') + super().__init__(text, *args, **kwargs) + + +class StyleMixin: + FONTSIZE = 9 + MAXLINELENGTH = 120 + + def __init__(self, base_font='Helvetica', **kwargs): + self.styles = getSampleStyleSheet() + self.style_title = ParagraphStyle( + name='title', fontName='Helvetica-Bold', fontSize=self.FONTSIZE + 4, + leading=self.FONTSIZE + 5, alignment=TA_CENTER + ) + self.style_normal = ParagraphStyle( + name='normal', fontName='Helvetica', fontSize=self.FONTSIZE, alignment=TA_LEFT, + leading=self.FONTSIZE + 1, spaceAfter=0 + ) + self.style_justifie = ParagraphStyle( + name='justifie', parent=self.style_normal, alignment=TA_JUSTIFY, spaceAfter=0.2 * cm + ) + self.style_sub_title = ParagraphStyle( + name='sous_titre', fontName='Helvetica-Bold', fontSize=self.FONTSIZE + 2, + alignment=TA_LEFT, spaceBefore=0.5 * cm, spaceAfter=0.1 * cm + ) + self.style_inter_title = ParagraphStyle( + name='inter_titre', fontName='Helvetica-Bold', fontSize=self.FONTSIZE + 1, + alignment=TA_LEFT, spaceBefore=0.3 * cm, spaceAfter=0 + ) + self.style_bold = ParagraphStyle( + name='bold', fontName='Helvetica-Bold', fontSize=self.FONTSIZE, leading=self.FONTSIZE + 1 + ) + self.style_italic = ParagraphStyle( + name='italic', fontName='Helvetica-Oblique', fontSize=self.FONTSIZE - 1, leading=self.FONTSIZE + ) + self.style_indent = ParagraphStyle( + name='indent', fontName='Helvetica', fontSize=self.FONTSIZE, alignment=TA_LEFT, + leftIndent=1 * cm + ) + super().__init__(**kwargs) + + +class HeaderFooterMixin: + LOGO = find('img/logo-cr.png') + EDUQUA = find('img/eduqua.png') + DON = find('img/logo-zewo.png') + + def draw_header(self, canvas, doc): + canvas.saveState() + canvas.drawImage( + self.LOGO, doc.leftMargin + 316, doc.height+60, 7 * cm, 1.6 * cm, preserveAspectRatio=True, mask='auto' + ) + canvas.restoreState() + + def draw_footer(self, canvas, doc): + canvas.saveState() + canvas.drawImage( + self.EDUQUA, doc.leftMargin, doc.height - 670, 1.8 * cm, 0.8 * cm, preserveAspectRatio=True + ) + canvas.drawImage( + self.DON, doc.leftMargin + 60, doc.height - 670, 2.5 * cm, 0.8 * cm, preserveAspectRatio=True, mask='auto' + ) + tab = [220, 365] + line = [658, 667, 676, 685] + + canvas.setFont("Helvetica", 8) + canvas.drawRightString(doc.leftMargin + tab[0], doc.height - line[2], "CCP ?") + canvas.drawRightString(doc.leftMargin + tab[0], doc.height - line[3], "IBAN ?") + canvas.setLineWidth(0.5) + canvas.line(doc.leftMargin + 230, 2.2 * cm, doc.leftMargin + 230, 1.0 * cm) + + canvas.drawRightString(doc.leftMargin + tab[1], doc.height - line[0], "Rte d’Englisberg 3") + canvas.setFont("Helvetica-Bold", 8) + canvas.drawRightString(doc.leftMargin + tab[1], doc.height - line[1], "1763 Granges-Paccot") + canvas.setFont("Helvetica", 8) + canvas.line(doc.leftMargin + 375, 2.2 * cm, doc.leftMargin + 375, 1.0 * cm) + canvas.drawRightString(doc.leftMargin + doc.width, doc.height - line[0], "+41 26 407 70 44") + canvas.drawRightString(doc.leftMargin + doc.width, doc.height - line[1], "secretariat@fondation-transit.ch") + canvas.drawRightString(doc.leftMargin + doc.width, doc.height - line[2], "fondation-transit.ch/aemo") + canvas.restoreState() + + +class BasePDF(HeaderFooterMixin, StyleMixin): + + def __init__(self, tampon, instance, **kwargs): + self.instance = instance + self.kwargs = kwargs + self.doc = SimpleDocTemplate( + tampon, title=self.title, pagesize=A4, + leftMargin=1.5 * cm, rightMargin=1.5 * cm, topMargin=2 * cm, bottomMargin=2.5 * cm + ) + self.story = [] + super().__init__(**kwargs) + + def draw_header_footer(self, canvas, doc): + self.draw_header(canvas, doc) + self.draw_footer(canvas, doc) + + def produce(self): + # subclass should call self.doc.build(self.story, onFirstPage=self.draw_header_footer) + raise NotImplementedError + + def get_filename(self): + raise NotImplementedError + + @staticmethod + def format_note(note, length=StyleMixin.MAXLINELENGTH): + return Preformatted(note.replace('\r\n', '\n'), maxLineLength=length) + + def set_title(self, title=''): + self.story.append(Spacer(0, 1 * cm)) + self.story.append(Paragraph(title, self.style_title)) + self.story.append(Spacer(0, 1.2 * cm)) + + def parent_data(self, person): + """Return parent data ready to be used in a 2-column table.""" + parents = person.parents() + par1 = parents[0] if len(parents) > 0 else None + par2 = parents[1] if len(parents) > 1 else None + data = [ + [('Parent 1 (%s)' % par1.role.nom) if par1 else '-', + ('Parent 2 (%s)' % par2.role.nom) if par2 else '-'], + [par1.contact.nom_prenom if par1 else '', par2.contact.nom_prenom if par2 else ''], + [par1.contact.adresse if par1 else '', par2.contact.adresse if par2 else ''], + [par1.contact.contact if par1 else '', par2.contact.contact if par2 else ''], + + ['Autorité parentale: {}'.format(format_booleen(par1.contact.autorite_parentale)) if par1 else '', + 'Autorité parentale: {}'.format(format_booleen(par2.contact.autorite_parentale)) if par2 else ''], + ] + return data + + def formate_persons(self, parents_list): + labels = ( + ("Nom", "nom"), ("Prénom", "prenom"), ("Adresse", "rue"), + ("Localité", 'localite_display'), ("Profession", 'profession'), ("Tél.", 'telephone'), + ("Courriel", 'email'), ('Remarque', 'remarque'), + ) + P = partial(Paragraph, style=self.style_normal) + Pbold = partial(Paragraph, style=self.style_bold) + data = [] + parents = [parent for parent in parents_list if parent is not None] + if len(parents) == 0: + pass + elif len(parents) == 1: + data.append([Pbold('Rôle:'), Pbold(parents[0].role.nom), '', '']) + for label in labels: + data.append([ + label[0], P(getattr(parents[0], label[1])), '', '' + ]) + elif len(parents) == 2: + data.append([ + Pbold('Rôle:'), Pbold(parents[0].role.nom), + Pbold('Rôle:'), Pbold(parents[1].role.nom) + ]) + for label in labels: + data.append([ + label[0], P(getattr(parents[0], label[1]) if parents[0] else ''), + label[0], P(getattr(parents[1], label[1]) if parents[1] else '') + ]) + return data + + def get_table(self, data, columns, before=0.0, after=0.0, inter=None): + """Prepare a Table instance with data and columns, with a common style.""" + if inter: + inter = inter * cm + cols = [c * cm for c in columns] + + t = Table( + data=data, colWidths=cols, hAlign=TA_LEFT, + spaceBefore=before * cm, spaceAfter=after * cm + ) + t.hAlign = 0 + t.setStyle(tblstyle=TableStyle([ + ('ALIGN', (0, 0), (-1, -1), 'LEFT'), + ('FONTSIZE', (0, 0), (-1, -1), self.FONTSIZE), + ('VALIGN', (0, 0), (-1, -1), "TOP"), + ('LEFTPADDING', (0, 0), (0, -1), 1), + ('LEADING', (0, 0), (-1, -1), 7), + ])) + return t + + def _add_subtitle(self, data): + t = Table( + data=[data], colWidths=[18 * cm / len(data)] * len(data), + hAlign=TA_LEFT, + spaceBefore=0.2 * cm, spaceAfter=0.5 * cm + ) + t.hAlign = 0 + t.setStyle(tblstyle=TableStyle([ + ('FONT', (0, 0), (-1, -1), "Helvetica-Bold"), + ('FONTSIZE', (0, 0), (-1, -1), self.FONTSIZE + 2), + ('LINEBELOW', (0, -1), (-1, -1), 0.25, colors.black), + ('ALIGN', (-1, -1), (-1, -1), 'RIGHT'), + ])) + self.story.append(t) + + def write_paragraph(self, title, text, html=False, justifie=False): + if title: + self.story.append(Paragraph(title, self.style_sub_title)) + style = self.style_justifie if justifie else self.style_normal + if html: + if text.startswith('

') and text.endswith('

'): + soup = BeautifulSoup(text, features="html5lib") + for tag in soup.find_all(['p', 'ul']): + self.story.append(CleanParagraph(str(tag), style)) + else: + self.story.append(CleanParagraph(text, style)) + else: + self.story.append(RawParagraph(text, style)) + + def enfant_data(self, enfant): + labels = [('Tél.', enfant.telephone)] + if hasattr(enfant, 'formation'): + labels.extend([ + ('Statut scol', enfant.formation.get_statut_display()), + ('Centre', enfant.formation.cercle_scolaire), + ('Collège', enfant.formation.college), + ('Classe', enfant.formation.classe), + ('Struct. extra-fam.', enfant.formation.creche), + ('Ens.', enfant.formation.enseignant), + ]) + labels.extend([ + ('Permis de séjour', enfant.permis), + ('Validité', enfant.validite), + ]) + row = [f"{enfant.nom_prenom} (*{format_d_m_Y(enfant.date_naissance)})"] + for label in labels: + if label[1]: + row.append(f"{label[0]}: {label[1]}") + return '; '.join(row) + + +class DemandeAccompagnement(BasePDF): + title = None + + def produce(self): + famille = self.instance + suivi = famille.suivi + self.set_title("Famille {} - {}".format(famille.nom, self.title)) + + self.story.append(Paragraph("Motif(s) de la demande: {}".format( + suivi.get_motif_demande_display()), self.style_normal + )) + self.story.append(Paragraph('_' * 90, self.style_normal)) + + self.write_paragraph("Dates", suivi.dates_demande) + self.write_paragraph( + "Difficultés", + "{}
{}

{}".format( + "Quelles sont les difficultés éducatives que vous rencontrez et depuis combien de " + "temps ?", + "Fonctionnement familial: règles, coucher, lever, repas, jeux, relations " + "parent-enfants, rapport au sein de la fratrie, … (exemple)", + suivi.difficultes + ), + html=True + ) + self.write_paragraph( + "Autres services", + "{}
{}".format( + "Avez-vous fait appel à d'autres services ? Si oui, avec quels vécus ?", + suivi.autres_contacts + ), + html=True + ) + self.write_paragraph("Aides souhaitées", suivi.aides, html=True) + self.write_paragraph("Ressources/Compétences", suivi.competences, html=True) + self.write_paragraph("Disponibilités", suivi.disponibilites, html=True) + self.write_paragraph("Remarques", suivi.remarque) + + self.doc.build(self.story, onFirstPage=self.draw_header_footer) + + +class JournalPdf(BasePDF): + title = "Journal de bord" + + def get_filename(self): + return '{}_journal.pdf'.format(slugify(self.instance.nom)) + + def get_title(self): + return 'Famille {} - {}'.format(self.instance.nom, self.title) + + def produce(self): + famille = self.instance + self.set_title(self.get_title()) + + self.style_bold.spaceAfter = 0.2*cm + self.style_italic.spaceBefore = 0.2 * cm + self.style_italic.spaceAfter = 0.7 * cm + for prest in famille.prestations.all().prefetch_related('intervenants'): + self.story.append(CleanParagraph(prest.texte, self.style_normal)) + self.story.append( + Paragraph('{} - {} ({})'.format( + '/'.join(interv.sigle for interv in prest.intervenants.all()), + format_d_m_Y(prest.date_prestation), + format_duree(prest.duree) + ), self.style_italic) + ) + + self.doc.build( + self.story, + onFirstPage=self.draw_header_footer, onLaterPages=self.draw_footer, + canvasmaker=PageNumCanvas + ) + + +class RapportPdf(BasePDF): + title = None + + def get_filename(self): + return "{}_resume_{}.pdf".format( + slugify(self.instance.famille.nom), + format_Ymd(self.instance.date) + ) + + def produce(self): + rapport = self.instance + self.style_normal.fontSize += 2 + self.style_normal.leading += 2 + self.style_justifie.fontSize += 2 + self.style_justifie.leading += 2 + self.doc.title = 'Résumé "AEMO"' + self.set_title('Famille {} - {}'.format(rapport.famille.nom, self.doc.title)) + self._add_subtitle([f"Date: {format_d_m_Y(rapport.date)}"]) + + data = [ + ("Enfant(s)", '
'.join( + [f"{enfant.nom_prenom} (*{format_d_m_Y(enfant.date_naissance)})" + for enfant in rapport.famille.membres_suivis()]), False), + ("Intervenant-e-s", ', '.join([i.nom_prenom for i in rapport.intervenants()]), False), + ("Début du suivi", format_d_m_Y(rapport.famille.suivi.date_debut_suivi), False), + ("Situation / contexte familial", rapport.situation, True), + ] + data.append(("Observations", rapport.observations, True)) + data.append(("Perspectives d'avenir", rapport.projet, True)) + + for title, text, html in data: + self.write_paragraph(title, text, html=html, justifie=True) + + if hasattr(rapport, 'sig_interv'): + self.story.append(Spacer(0, 0.5 * cm)) + + for idx, interv in enumerate(rapport.sig_interv.all()): + if idx == 0: + self.write_paragraph("Signature des intervenant-e-s :", '') + self.story.append(Spacer(0, 0.2 * cm)) + self.write_paragraph('', interv.nom_prenom + (f', {interv.profession}' if interv.profession else '')) + self.story.append(Spacer(0, 1 * cm)) + + secret_style = ParagraphStyle( + name='italic', fontName='Helvetica-Oblique', fontSize=self.FONTSIZE - 1, leading=self.FONTSIZE + 1, + backColor=colors.Color(0.96, 0.96, 0.96, 1), borderRadius=12, + ) + self.story.append(Paragraph( + "Le présent résumé comporte des éléments couverts par le secret professionnel au sens " + "de la LPSy et du Code pénal. Seuls les propriétaires des données, à savoir les membres " + "de la famille faisant l’objet du résumé, peuvent ensemble lever ce secret ou " + "accepter la divulgation des données. Si cette autorisation n’est pas donnée, l’autorité " + "compétente en matière de levée du secret professionnel doit impérativement être saisie.", + secret_style + )) + + self.doc.build(self.story, onFirstPage=self.draw_header_footer, onLaterPages=self.draw_footer) + + +class MessagePdf(BasePDF): + title = 'Message' + + def get_filename(self): + return '{}_message.pdf'.format(slugify(self.instance.subject)) + + def produce(self): + doc = self.instance + self.set_title('{} - Famille {}'.format(self.title, doc.famille.nom)) + with extract_msg.Message(doc.fichier.path) as msg: + P = partial(Paragraph, style=self.style_normal) + Pbold = partial(Paragraph, style=self.style_bold) + + msg_headers = [ + [Pbold('De:'), P(msg.sender)], + [Pbold('À:'), P(msg.to)], + ] + if msg.cc: + msg_headers.append([Pbold('CC:'), P(msg.cc)]) + if msg.date: + msg_headers.append([Pbold('Date:'), P(msg.date)]) + msg_headers.append([Pbold('Sujet:'), P(msg.subject)]) + self.story.append(self.get_table(msg_headers, [3, 15])) + self.story.append(Pbold('Message:')) + self.story.append(RawParagraph(msg.body, style=self.style_normal)) + return self.doc.build( + self.story, + onFirstPage=self.draw_header_footer, onLaterPages=self.draw_footer, + canvasmaker=PageNumCanvas + ) + + +class EvaluationPdf: + def __init__(self, tampon, famille): + self.tampon = tampon + self.famille = famille + self.merger = PdfWriter() + + def append_pdf(self, PDFClass): + tampon = BytesIO() + pdf = PDFClass(tampon, self.famille) + pdf.produce() + self.merger.append(tampon) + + def produce(self): + + self.append_pdf(CoordonneesPagePdf) + self.append_pdf(DemandeAccompagnementPagePdf) + self.merger.write(self.tampon) + + def get_filename(self): + return '{}_aemo_evaluation.pdf'.format(slugify(self.famille.nom)) + + +class CoordonneesFamillePdf(BasePDF): + title = "Informations" + + def get_filename(self): + return '{}_coordonnees.pdf'.format(slugify(self.instance.nom)) + + def produce(self): + famille = self.instance + suivi = famille.suivi + self.set_title('Famille {} - {}'.format(famille.nom, self.title)) + + # Parents + self.story.append(Paragraph('Parents', self.style_sub_title)) + data = self.formate_persons(famille.parents()) + if data: + self.story.append(self.get_table(data, columns=[2, 7, 2, 7], before=0, after=0)) + else: + self.story.append(Paragraph('-', self.style_normal)) + + # Situation matrimoniale + data = [ + ['Situation matrimoniale: {}'.format(famille.get_statut_marital_display()), + 'Autorité parentale: {}'.format(famille.get_autorite_parentale_display())], + ] + self.story.append(self.get_table(data, columns=[9, 9], before=0, after=0)) + + # Personnes significatives + autres_parents = list(famille.autres_parents()) + if autres_parents: + self.story.append(Paragraph('Personne-s significative-s', self.style_sub_title)) + data = self.formate_persons(autres_parents) + self.story.append(self.get_table(data, columns=[2, 7, 3, 6], before=0, after=0)) + if len(autres_parents) > 2: + self.story.append(PageBreak()) + + # Enfants suivis + self.write_paragraph( + "Enfant(s)", + '
'.join(self.enfant_data(enfant) for enfant in famille.membres_suivis()) + ) + # Réseau + self.story.append(Paragraph("Réseau", self.style_sub_title)) + data = [ + ['AS OPE', '{} - (Mandat: {})'.format( + ', '.join(ope.nom_prenom for ope in suivi.ope_referents), + suivi.get_mandat_ope_display() + )], + ['Interv. CRNE', '{}'.format( + ', '.join('{}'.format(i.nom_prenom) for i in suivi.intervenants.all().distinct()) + )] + ] + for enfant in famille.membres_suivis(): + for contact in enfant.reseaux.all(): + data.append([ + enfant.prenom, + '{} ({})'.format(contact, contact.contact) + ]) + self.story.append(self.get_table(data, columns=[2, 16], before=0, after=0)) + + self.write_paragraph("Motif de la demande", famille.suivi.motif_detail) + self.write_paragraph("Collaborations", famille.suivi.collaboration) + + # Historique + self.story.append(Paragraph('Historique', self.style_sub_title)) + P = partial(Paragraph, style=self.style_normal) + Pbold = partial(Paragraph, style=self.style_bold) + fields = ['date_demande', 'date_debut_evaluation', 'date_fin_evaluation', + 'date_debut_suivi', 'date_fin_suivi'] + data = [] + for field_name in fields: + field = famille.suivi._meta.get_field(field_name) + if getattr(famille.suivi, field_name): + data.append( + [Pbold(f"{field.verbose_name} :"), P(format_d_m_Y(getattr(famille.suivi, field_name)))] + ) + if famille.suivi.motif_fin_suivi: + data.append([Pbold("Motif de fin de suivi :"), famille.suivi.get_motif_fin_suivi_display()]) + if famille.destination: + data.append([Pbold("Destination :"), famille.get_destination_display()]) + if famille.archived_at: + data.append([Pbold("Date d'archivage :"), format_d_m_Y(famille.archived_at)]) + + self.story.append(self.get_table(data, [4, 5])) + self.doc.build(self.story, onFirstPage=self.draw_header_footer) + + +class DemandeAccompagnementPagePdf(DemandeAccompagnement): + title = "Évaluation AEMO" + + +class BilanPdf(BasePDF): + title = "Bilan AEMO" + + def get_filename(self): + return "{}_bilan_{}.pdf".format( + slugify(self.instance.famille.nom), + format_Ymd(self.instance.date) + ) + + def produce(self): + bilan = self.instance + self.style_normal.fontSize += 2 + self.style_normal.leading += 2 + self.style_justifie.fontSize += 2 + self.style_justifie.leading += 2 + self.set_title('Famille {} - {}'.format(bilan.famille.nom, self.title)) + self._add_subtitle([f"Date: {format_d_m_Y(bilan.date)}"]) + + for title, text, html in ( + ("Enfant(s)", '
'.join( + [f"{enfant.nom_prenom} (*{format_d_m_Y(enfant.date_naissance)})" + for enfant in bilan.famille.membres_suivis()]), False), + ("Intervenant-e-s", ', '.join( + [i.intervenant.nom_prenom for i in bilan.famille.interventions_actives(bilan.date)] + ), False), + ("Début du suivi", format_d_m_Y(bilan.famille.suivi.date_debut_suivi), False), + ("Besoins et objectifs", bilan.objectifs, True), + ("Rythme et fréquence", bilan.rythme, True), + ): + self.write_paragraph(title, text, html=html, justifie=True) + + self.story.append(Spacer(0, 0.5 * cm)) + + for idx, interv in enumerate(bilan.sig_interv.all()): + if idx == 0: + self.write_paragraph("Signature des intervenant-e-s AEMO :", '') + self.story.append(Spacer(0, 0.2 * cm)) + self.write_paragraph('', interv.nom_prenom + (f', {interv.profession}' if interv.profession else '')) + self.story.append(Spacer(0, 1 * cm)) + + if bilan.sig_famille: + self.write_paragraph("Signature de la famille :", '') + + self.doc.build(self.story, onFirstPage=self.draw_header_footer, onLaterPages=self.draw_footer) + + +class CoordonneesPagePdf(CoordonneesFamillePdf): + title = "Informations" + + def get_filename(self, famille): + return '{}_aemo_evaluation.pdf'.format(slugify(famille.nom)) + + +def str_or_empty(value): + return '' if not value else str(value) diff --git a/aemo/static/css/autocomplete.min.css b/aemo/static/css/autocomplete.min.css new file mode 100644 index 0000000..89884bd --- /dev/null +++ b/aemo/static/css/autocomplete.min.css @@ -0,0 +1 @@ +.autocomplete{background:#fff;z-index:1000;font:14px/22px "-apple-system",BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;overflow:auto;box-sizing:border-box;border:1px solid rgba(50,50,50,.6)}.autocomplete *{font:inherit}.autocomplete>div{padding:0 4px}.autocomplete .group{background:#eee}.autocomplete>div.selected,.autocomplete>div:hover:not(.group){background:#FFF3F3;cursor:pointer} diff --git a/aemo/static/css/main.css b/aemo/static/css/main.css new file mode 100644 index 0000000..c0d685c --- /dev/null +++ b/aemo/static/css/main.css @@ -0,0 +1,359 @@ +header { background-color: #fff3f3; } + +a { text-decoration: none; } +a:hover:not(.btn) {text-decoration: underline; } + +#logo-cr { display: block; height: 70px; margin: 4px; } +#bandeau-cr { display: block; width: 100%; height: 15px; } +#menu_crne { font-size: 80%; } + +/* This CSS allows the footer to be always at bottom */ +div.top-container { display: flex; min-height: 100vh; flex-direction: column; } +.main-content { flex: 1; } + +.hidden { display: none; } +.red { color: red; } +.green { color: green; } +.orange { color: orange; } +.footer { + font-size: 80%; + background-color: #f8f9fa; + color: #9a1629; + margin-top: 2rem; +} +.spaceabovetr > td { padding-top: 0.7em; } + +#bg_home { + background: url(../img/SRK_DL_Portal.jpg) no-repeat center fixed; + background-size: cover; +} +#home-app-line { position:absolute; bottom:112px; left:5%; } + +.small { font-size: 0.9rem; } +.nowrap { white-space: nowrap; } +.icon-ui { display: inline-block; width: 1rem; height: 1rem; } +img.ficon { height: 1.5em; width: 1.5em; vertical-align: bottom; } +input[readonly] { background-color: #eee; } +#search-button, #reset-button { height: 2rem; min-width: 2.7rem; } +#reset-button img { height: 18px; } + +td.zero { color: #ccc; } +#user-bar { position:absolute; top:40px; right:5vw; font-size:80%; } +#user-bar a, #user-bar button { color: #9c1717; font-weight: bold; } +#user-bar button { font-size: inherit; text-decoration: none; } +#user-bar button:hover { text-decoration: underline; } +#user-tools * { vertical-align: middle; } + +/* For usage inside bs modal */ +.select2-dropdown { z-index: 1100 !important; } + +ul.errorlist, ul.nobullets { list-style-type: none; padding-left: 0; } +ul.errorlist li { + background-color: #f8d7da; + color: #721c24; + font-weight: bold; + padding: .75rem 1.25rem; + margin-bottom: 1rem; + border: 1px solid #ebcccc; + border-radius: .25rem; +} +form.inline { display: inline-block; } +form label { font-weight: bold; } +.custom-file-label::after { content: 'Choisir'; } +form[name=LoginForm] input[type=text], form[name=LoginForm] input[type=password] { width: 20em; } +input[type=text], input[type=email] { width: 100%; } +#id_auth-username { width: auto; } +#id_mois, #id_annee { width: auto; display: inline-block; } +input:disabled + span.datetimeshortcuts { display: none; } +input:disabled + label { color: #999; font-style: italic } +input.card-text { width: 4em; } + +textarea { min-height: 3em; } +textarea#id_note { width: 35em; height: 2em; } +textarea#id_scolarite, textarea#id_loisirs, textarea#id_activite{ width: 100%; height: 4em; } +textarea#id_referent_note, textarea#id_allergies, textarea#id_remarque, textarea#id_remarque_privee, +textarea#id_remarques { + width: 100%; height: 2em; +} +textarea#id_projet, textarea#id_texte { width: 100%; height: 15em; } +select#id_motif_fin_suivi { width: 100%; } +#id_niveau_interv { width: 5em; } + +#id_equipe.immediate-submit { width: auto; display: inline-block; } + +form[name=DemandeForm] th { width: 25%; } +form[name=DemandeForm] input[type=text] { width: 100% } +form[name=DemandeForm] textarea#id_remarque { width: 100%; height: 2em; } +form[name=DemandeForm] textarea#id_autres_contacts { width: 100%; height:2em; } + +form[name=PrestationForm] textarea#id_texte { width: 100%; } +form[name=PrestationForm] th { width: 12em; } + +form[name=Suivi] input#id_service_annonceur { width: 80%; } +form[name=Suivi] textarea { width: 100%; height: 2em;} + +form[name=JournalForm] textarea#id_texte { width: 100%; } +form[name=FamilleForm] textarea#id_motif_detail { width: 100%; height: 5em; } +input.vDateField { width: 7em; display: inline-block; } +input.TimeField { width: 4em; margin-left: 0.5em; } +input#id_duree { width: 6em; } +input#id_username { width: none; } +input#id_npa, input#id_npa_actuelle { width: 4em; } +input#id_sigle { width: 8em; } +#id_groups label, .choicearray label, .filter-form label, #id_lib_prestation label, +#id_membres label, #id_sig_interv label, #id_pres_interv label, #id_roles label { font-weight: normal; } +#id_roles { column-count: 2; } + +tr.decedee td:first-child:before { + content: '† '; +} +tr.decedee td { + font-style: italic; + opacity: 0.8; +} +span.date_theorique { color:#FF8000; font-style: italic; } +span.hoverimage { visibility: hidden; } +span.hoverimage img { cursor: pointer; } +div:hover > span.hoverimage, summary:hover > span.hoverimage { visibility: visible; } + +#benef_table > tbody > tr > td { padding: 2px 0.3em; line-height: 1.2em; } + +table.statut_suivi td { + min-width: 35px; + padding: 0; + font-size: 10px; + background-color: #eee; + border: 1px solid #ccc; + text-align: center; +} +table.statut_suivi { margin-top: 4px;} +table.statut_suivi td.filled { background-color: #AACDAA; } +table.statut_suivi td.current { background-color: #FFDB78; } + +table.statut_suivi td div.filled { background-color: #AACDAA; } +table.statut_suivi td div.next { background-color: #F2F5A9; } +table.statut_suivi td div.urgent { background-color: #FF8000; } +table.statut_suivi td div.depasse { background-color: #FF0000; } + +.stat_table th { padding-top: 1.5em; text-align: right; } +.stat_table th.month { text-align: right; width: 90px; } +.stat_table th.total { text-align: right; width: 90px; } +.stat_table th.left { text-align: left; } +.stat_table .app_line { background-color: #eee; } +.stat_table td.num { text-align: right; } +.stat_table tr.first td { border-top: 1px solid #999; } +.stat_table .subdiv2 td { background-color: beige; font-style: italic; } + +p.app_line { background-color: #eee; padding: 0.2em 0 0.6em 0.2em; } + +#id_mandat_ope, #id_motif_demande, #id_demarche { + display: flex; flex-wrap: wrap; +} +#id_mandat_ope > div, #id_motif_demande > div, #id_demarche > div { + padding-right: 1.5em; +} +#id_mandat_ope label, #id_motif_demande label, #id_demarche label { + font-weight: normal; padding-right: 2em; +} +/* padding needed for bootstrap style */ +#id_motif_demande { padding-left: 2rem; } + +#id_demande_prioritaire label { padding-left: 2rem; } + +table-condensed td{ padding:1px; } + +.btn-mini { + line-height:14px; + font-weight:800; +} +.btn-xs { + padding: .2rem .2rem; + font-size: .750rem; + line-height: 1; + border-radius: .2rem; +} + +.modal-lg input[type=text] { margin-bottom: 1em; } +.modal-lg input[name=theme] { width: 160%; } +.modal-lg textarea { width: 160%; } +th { + vertical-align: top; +} + +a[role=button] { margin-left: 0.4em; } + +.topnav-right {font-size: 95%;} +.selection_form { display: flex; justify-content: flex-end; flex-wrap: wrap; } +.select-container { margin-left: 0.5em; } +select#id_interv { max-width: 12em; } +input#id_letter { width:10em; } + +ul.nav-prestations .nav-link.active { + font-weight: bold; + font-size: 110%; +} + +.left-label { width: 15em; } + +.icon { width:20px; height:20px; } +.icon-xs {width:15px; height:15px; } + +.table-absence { + display: block; + overflow-y: scroll; + max-height:300px; +} + +.table-fixed tbody { + height: 300px; + overflow-y: auto; + width: 100%; +} + + +.table-fixed thead, +.table-fixed tbody, +.table-fixed tr, +.table-fixed td, +.table-fixed th { + display: block; + background-color: white; +} + +.table-fixed tbody td, +.table-fixed tbody th, +.table-fixed thead > tr > th { + float: left; + position: relative; + + &::after { + content: ''; + clear: both; + display: block; + } +} + +.bg-success-2 { background-color: #d3ffd0;} +.bg-warning-2 { background-color: #FAF1B8; } +.bg-primary-2 { background-color: #B8C3FA; } +.bg-danger-2 { background-color: #F7E4E3 } + +.bg-danger-3 { background-color: #fff5f5; } +.bg-success-3 { background-color: #edfced; } + +.calendarbox { z-index:1100; background: white; } +/* Needed because of bootstrap reboot */ +.calendar caption { caption-side: top; background: #fff3f3; } + +.popup textarea#id_texte { width:100%; height:2em; } + +.table-ext-bordered { + border-width:1px; + border-style:solid; + border-color:lightgray; + padding:2px; +} + +.table-wrapper { display: block; position: relative; overflow: auto; } + +.prestation_titre { background-color: #fcf0c6; font-weight: bold; } +table.prestations th { position: sticky; top: 0; } +table.prestations td.total { font-weight: 600; background-color: #eee; text-align: center; } +table.prestations th.mesprest, table.prestations td.mesprest { text-align: right; } +table.prestations th.action, table.prestations td.action { text-align: right; } + +p.secret { + background-color: #eee; + border-radius: 0.5em; + padding: 0.5rem; + font-style: italic; +} + +/* Image modal styles */ +div.modal-image { + display: none; position: fixed; z-index: 100; padding-top: 100px; + left: 0; top: 0; width: 100%; height: 100%; overflow: auto; + background-color: rgba(0,0,0,0.6); +} +img.modal-content { + margin: auto; + display: block; + width: 80%; + max-width: 700px; +} +#modalClose { + position: absolute; + top: 15px; + right: 35px; + color: #f1f1f1; + font-size: 40px; + font-weight: bold; + transition: 0.3s; +} + +#modalClose:hover, #modalClose:focus { + color: #bbb; + text-decoration: none; + cursor: pointer; +} +/* End of Image modal styles */ + +#id_rythme { width: 100%; height:4em; } + +.red {color: red;} + +.table-sortable > thead > tr > th[data-col] { + cursor: pointer; + position: relative; +} + +.table-sortable > thead > tr > th[data-col]:after, +.table-sortable > thead > tr > th[data-col]:after, +.table-sortable > thead > tr > th[data-col]:after { + content: ' '; + position: absolute; + height: 0; + width: 0; + right: 10px; + top: 16px; +} + +.table-sortable > thead > tr > th[data-col]:after { + border-left: 5px solid transparent; + border-right: 5px solid transparent; + border-top: 5px solid #ccc; + border-bottom: 0px solid transparent; +} + +.table-sortable > thead > tr > th[data-col]:hover:after { + border-top: 5px solid #888; +} + +.table-sortable > thead > tr > th.desc:after { + border-left: 5px solid transparent; + border-right: 5px solid transparent; + border-top: 0px solid transparent; + border-bottom: 5px solid #333; +} +.table-sortable > thead > tr > th.desc:hover:after { + border-bottom: 5px solid #888; +} + +.table-sortable > thead > tr > th.asc:after { + border-left: 5px solid transparent; + border-right: 5px solid transparent; + border-top: 5px solid #333; + border-bottom: 5px solid transparent; +} + +.vDateField-rounded { + padding: 0.375rem 0.75rem; + line-height: 1.5; + border-radius: 0.375rem; + border: 1px solid #ced4da; +} + +.search-form-fields { + width: 200px !important; + margin-bottom: 5px; +} diff --git a/aemo/static/css/tablesort.css b/aemo/static/css/tablesort.css new file mode 100644 index 0000000..632d28d --- /dev/null +++ b/aemo/static/css/tablesort.css @@ -0,0 +1,33 @@ +th[role=columnheader]:not(.no-sort) { + cursor: pointer; +} + +th[role=columnheader]:not(.no-sort):after { + content: ''; + float: right; + margin-top: 7px; + border-width: 0 4px 4px; + border-style: solid; + border-color: #404040 transparent; + visibility: hidden; + opacity: 0; + -ms-user-select: none; + -webkit-user-select: none; + -moz-user-select: none; + user-select: none; +} + +th[aria-sort=ascending]:not(.no-sort):after { + border-bottom: none; + border-width: 4px 4px 0; +} + +th[aria-sort]:not(.no-sort):after { + visibility: visible; + opacity: 0.4; +} + +th[role=columnheader]:not(.no-sort):hover:after { + visibility: visible; + opacity: 1; +} diff --git a/aemo/static/docs/sifp_agenda_hebdo.doc b/aemo/static/docs/sifp_agenda_hebdo.doc new file mode 100644 index 0000000..035ad7d Binary files /dev/null and b/aemo/static/docs/sifp_agenda_hebdo.doc differ diff --git a/aemo/static/favicon.png b/aemo/static/favicon.png new file mode 100644 index 0000000..ec205f6 Binary files /dev/null and b/aemo/static/favicon.png differ diff --git a/aemo/static/ficons/README b/aemo/static/ficons/README new file mode 100644 index 0000000..e9cca77 --- /dev/null +++ b/aemo/static/ficons/README @@ -0,0 +1,2 @@ +Many thanks to Daniel M. Hendricks, http://daniel.hn +https://github.com/dmhendricks/file-icon-vectors/ diff --git a/aemo/static/ficons/docx.svg b/aemo/static/ficons/docx.svg new file mode 100644 index 0000000..ac084a0 --- /dev/null +++ b/aemo/static/ficons/docx.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/aemo/static/ficons/image.svg b/aemo/static/ficons/image.svg new file mode 100644 index 0000000..8d4cac8 --- /dev/null +++ b/aemo/static/ficons/image.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/aemo/static/ficons/master.svg b/aemo/static/ficons/master.svg new file mode 100644 index 0000000..2537cbe --- /dev/null +++ b/aemo/static/ficons/master.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/aemo/static/ficons/pdf.svg b/aemo/static/ficons/pdf.svg new file mode 100644 index 0000000..e6472df --- /dev/null +++ b/aemo/static/ficons/pdf.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/aemo/static/ficons/xlsx.svg b/aemo/static/ficons/xlsx.svg new file mode 100644 index 0000000..ddf8038 --- /dev/null +++ b/aemo/static/ficons/xlsx.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/aemo/static/img/SRK_DL_Portal.jpg b/aemo/static/img/SRK_DL_Portal.jpg new file mode 100644 index 0000000..2fd4c9c Binary files /dev/null and b/aemo/static/img/SRK_DL_Portal.jpg differ diff --git a/aemo/static/img/bandeau_rouge.png b/aemo/static/img/bandeau_rouge.png new file mode 100644 index 0000000..1ca70ca Binary files /dev/null and b/aemo/static/img/bandeau_rouge.png differ diff --git a/aemo/static/img/bandeau_vert.png b/aemo/static/img/bandeau_vert.png new file mode 100644 index 0000000..85a8c97 Binary files /dev/null and b/aemo/static/img/bandeau_vert.png differ diff --git a/aemo/static/img/coordonnees.gif b/aemo/static/img/coordonnees.gif new file mode 100644 index 0000000..c82e0d3 Binary files /dev/null and b/aemo/static/img/coordonnees.gif differ diff --git a/aemo/static/img/edit.svg b/aemo/static/img/edit.svg new file mode 100644 index 0000000..7556933 --- /dev/null +++ b/aemo/static/img/edit.svg @@ -0,0 +1,8 @@ + + + + + + + diff --git a/aemo/static/img/eduqua.png b/aemo/static/img/eduqua.png new file mode 100644 index 0000000..b4b9b68 Binary files /dev/null and b/aemo/static/img/eduqua.png differ diff --git a/aemo/static/img/family.png b/aemo/static/img/family.png new file mode 100644 index 0000000..fb34990 Binary files /dev/null and b/aemo/static/img/family.png differ diff --git a/aemo/static/img/filter_off.svg b/aemo/static/img/filter_off.svg new file mode 100644 index 0000000..34bcdf8 --- /dev/null +++ b/aemo/static/img/filter_off.svg @@ -0,0 +1,76 @@ + + + + + + image/svg+xml + + + + + + + + + + + + + + + + diff --git a/aemo/static/img/formation.png b/aemo/static/img/formation.png new file mode 100644 index 0000000..9eb6837 Binary files /dev/null and b/aemo/static/img/formation.png differ diff --git a/aemo/static/img/help.png b/aemo/static/img/help.png new file mode 100644 index 0000000..23c9c73 Binary files /dev/null and b/aemo/static/img/help.png differ diff --git a/aemo/static/img/icon_add.jpeg b/aemo/static/img/icon_add.jpeg new file mode 100644 index 0000000..544eb20 Binary files /dev/null and b/aemo/static/img/icon_add.jpeg differ diff --git a/aemo/static/img/journal.jpg b/aemo/static/img/journal.jpg new file mode 100644 index 0000000..1113192 Binary files /dev/null and b/aemo/static/img/journal.jpg differ diff --git a/aemo/static/img/logo-cr.png b/aemo/static/img/logo-cr.png new file mode 100644 index 0000000..4dee4dd Binary files /dev/null and b/aemo/static/img/logo-cr.png differ diff --git a/aemo/static/img/logo-cr.svg b/aemo/static/img/logo-cr.svg new file mode 100644 index 0000000..b4a25d5 --- /dev/null +++ b/aemo/static/img/logo-cr.svg @@ -0,0 +1,219 @@ + +image/svg+xml \ No newline at end of file diff --git a/aemo/static/img/logo-zewo.png b/aemo/static/img/logo-zewo.png new file mode 100644 index 0000000..9499189 Binary files /dev/null and b/aemo/static/img/logo-zewo.png differ diff --git a/aemo/static/img/logo-zewo.svg b/aemo/static/img/logo-zewo.svg new file mode 100644 index 0000000..3adbfa2 --- /dev/null +++ b/aemo/static/img/logo-zewo.svg @@ -0,0 +1 @@ +ZEWO_Logo_def_4c_claim_rechts_F \ No newline at end of file diff --git a/aemo/static/img/printer.png b/aemo/static/img/printer.png new file mode 100644 index 0000000..0397b7f Binary files /dev/null and b/aemo/static/img/printer.png differ diff --git a/aemo/static/img/reseau.png b/aemo/static/img/reseau.png new file mode 100644 index 0000000..a21d8db Binary files /dev/null and b/aemo/static/img/reseau.png differ diff --git a/aemo/static/img/stat.png b/aemo/static/img/stat.png new file mode 100644 index 0000000..1f1a349 Binary files /dev/null and b/aemo/static/img/stat.png differ diff --git a/aemo/static/img/telephone.png b/aemo/static/img/telephone.png new file mode 100644 index 0000000..c28e4a1 Binary files /dev/null and b/aemo/static/img/telephone.png differ diff --git a/aemo/static/img/warning.svg b/aemo/static/img/warning.svg new file mode 100644 index 0000000..e29e041 --- /dev/null +++ b/aemo/static/img/warning.svg @@ -0,0 +1,3 @@ + + + diff --git a/aemo/static/js/DateTimeShortcuts.js b/aemo/static/js/DateTimeShortcuts.js new file mode 100644 index 0000000..6f4a70a --- /dev/null +++ b/aemo/static/js/DateTimeShortcuts.js @@ -0,0 +1,424 @@ +/*global Calendar, findPosX, findPosY, get_format, gettext, gettext_noop, interpolate, ngettext, quickElement*/ +// Inserts shortcut buttons after all of the following: +// +// +'use strict'; +{ + const DateTimeShortcuts = { + calendars: [], + calendarInputs: [], + clockInputs: [], + clockHours: { + default_: [ + [gettext_noop('Now'), -1], + [gettext_noop('Midnight'), 0], + [gettext_noop('6 a.m.'), 6], + [gettext_noop('Noon'), 12], + [gettext_noop('6 p.m.'), 18] + ] + }, + dismissClockFunc: [], + dismissCalendarFunc: [], + calendarDivName1: 'calendarbox', // name of calendar
that gets toggled + calendarDivName2: 'calendarin', // name of
that contains calendar + calendarLinkName: 'calendarlink', // name of the link that is used to toggle + clockDivName: 'clockbox', // name of clock
that gets toggled + clockLinkName: 'clocklink', // name of the link that is used to toggle + shortCutsClass: 'datetimeshortcuts', // class of the clock and cal shortcuts + timezoneWarningClass: 'timezonewarning', // class of the warning for timezone mismatch + timezoneOffset: 0, + init: function(selector) { + selector = selector || document; + const serverOffset = document.body.dataset.adminUtcOffset; + if (serverOffset) { + const localOffset = new Date().getTimezoneOffset() * -60; + DateTimeShortcuts.timezoneOffset = localOffset - serverOffset; + } + + for (const inp of selector.getElementsByTagName('input')) { + if (inp.type === 'text' && inp.classList.contains('vTimeField')) { + DateTimeShortcuts.addClock(inp); + DateTimeShortcuts.addTimezoneWarning(inp); + } + else if (inp.type === 'text' && inp.classList.contains('vDateField')) { + DateTimeShortcuts.addCalendar(inp); + DateTimeShortcuts.addTimezoneWarning(inp); + } + } + }, + // Return the current time while accounting for the server timezone. + now: function() { + const serverOffset = document.body.dataset.adminUtcOffset; + if (serverOffset) { + const localNow = new Date(); + const localOffset = localNow.getTimezoneOffset() * -60; + localNow.setTime(localNow.getTime() + 1000 * (serverOffset - localOffset)); + return localNow; + } else { + return new Date(); + } + }, + // Add a warning when the time zone in the browser and backend do not match. + addTimezoneWarning: function(inp) { + const warningClass = DateTimeShortcuts.timezoneWarningClass; + let timezoneOffset = DateTimeShortcuts.timezoneOffset / 3600; + + // Only warn if there is a time zone mismatch. + if (!timezoneOffset) { + return; + } + + // Check if warning is already there. + if (inp.parentNode.querySelectorAll('.' + warningClass).length) { + return; + } + + let message; + if (timezoneOffset > 0) { + message = ngettext( + 'Note: You are %s hour ahead of server time.', + 'Note: You are %s hours ahead of server time.', + timezoneOffset + ); + } + else { + timezoneOffset *= -1; + message = ngettext( + 'Note: You are %s hour behind server time.', + 'Note: You are %s hours behind server time.', + timezoneOffset + ); + } + message = interpolate(message, [timezoneOffset]); + + const warning = document.createElement('span'); + warning.className = warningClass; + warning.textContent = message; + inp.parentNode.appendChild(document.createElement('br')); + inp.parentNode.appendChild(warning); + }, + // Add clock widget to a given field + addClock: function(inp) { + const num = DateTimeShortcuts.clockInputs.length; + DateTimeShortcuts.clockInputs[num] = inp; + DateTimeShortcuts.dismissClockFunc[num] = function() { DateTimeShortcuts.dismissClock(num); return true; }; + + // Shortcut links (clock icon and "Now" link) + const shortcuts_span = document.createElement('span'); + shortcuts_span.className = DateTimeShortcuts.shortCutsClass; + inp.parentNode.insertBefore(shortcuts_span, inp.nextSibling); + const now_link = document.createElement('a'); + now_link.href = "#"; + now_link.textContent = gettext('Now'); + now_link.addEventListener('click', function(e) { + e.preventDefault(); + DateTimeShortcuts.handleClockQuicklink(num, -1); + }); + const clock_link = document.createElement('a'); + clock_link.href = '#'; + clock_link.id = DateTimeShortcuts.clockLinkName + num; + clock_link.addEventListener('click', function(e) { + e.preventDefault(); + // avoid triggering the document click handler to dismiss the clock + e.stopPropagation(); + DateTimeShortcuts.openClock(num); + }); + + quickElement( + 'span', clock_link, '', + 'class', 'clock-icon', + 'title', gettext('Choose a Time') + ); + shortcuts_span.appendChild(document.createTextNode('\u00A0')); + shortcuts_span.appendChild(now_link); + shortcuts_span.appendChild(document.createTextNode('\u00A0|\u00A0')); + shortcuts_span.appendChild(clock_link); + + // Create clock link div + // + // Markup looks like: + //
+ //

Choose a time

+ // + //

Cancel

+ //
+ + const clock_box = document.createElement('div'); + clock_box.style.display = 'none'; + clock_box.className = 'clockbox module'; + clock_box.id = DateTimeShortcuts.clockDivName + num; + document.body.appendChild(clock_box); + // cpa: Make it work with bootstrap + clock_box.style.position = clock_box.closest('body').classList.contains('modal-open') ? 'fixed' : 'absolute'; + clock_box.addEventListener('click', function(e) { e.stopPropagation(); }); + + quickElement('h2', clock_box, gettext('Choose a time')); + const time_list = quickElement('ul', clock_box); + time_list.className = 'timelist'; + // The list of choices can be overridden in JavaScript like this: + // DateTimeShortcuts.clockHours.name = [['3 a.m.', 3]]; + // where name is the name attribute of the . + const name = typeof DateTimeShortcuts.clockHours[inp.name] === 'undefined' ? 'default_' : inp.name; + DateTimeShortcuts.clockHours[name].forEach(function(element) { + const time_link = quickElement('a', quickElement('li', time_list), gettext(element[0]), 'href', '#'); + time_link.addEventListener('click', function(e) { + e.preventDefault(); + DateTimeShortcuts.handleClockQuicklink(num, element[1]); + }); + }); + + const cancel_p = quickElement('p', clock_box); + cancel_p.className = 'calendar-cancel'; + const cancel_link = quickElement('a', cancel_p, gettext('Cancel'), 'href', '#'); + cancel_link.addEventListener('click', function(e) { + e.preventDefault(); + DateTimeShortcuts.dismissClock(num); + }); + + document.addEventListener('keyup', function(event) { + if (event.which === 27) { + // ESC key closes popup + DateTimeShortcuts.dismissClock(num); + event.preventDefault(); + } + }); + }, + openClock: function(num) { + const clock_box = document.getElementById(DateTimeShortcuts.clockDivName + num); + const clock_link = document.getElementById(DateTimeShortcuts.clockLinkName + num); + + // Recalculate the clockbox position + // is it left-to-right or right-to-left layout ? + if (window.getComputedStyle(document.body).direction !== 'rtl') { + clock_box.style.left = findPosX(clock_link) + 17 + 'px'; + } + else { + // since style's width is in em, it'd be tough to calculate + // px value of it. let's use an estimated px for now + clock_box.style.left = findPosX(clock_link) - 110 + 'px'; + } + clock_box.style.top = Math.max(0, findPosY(clock_link) - 30) + 'px'; + + // Show the clock box + clock_box.style.display = 'block'; + document.addEventListener('click', DateTimeShortcuts.dismissClockFunc[num]); + }, + dismissClock: function(num) { + document.getElementById(DateTimeShortcuts.clockDivName + num).style.display = 'none'; + document.removeEventListener('click', DateTimeShortcuts.dismissClockFunc[num]); + }, + handleClockQuicklink: function(num, val) { + let d; + if (val === -1) { + d = DateTimeShortcuts.now(); + } + else { + d = new Date(1970, 1, 1, val, 0, 0, 0); + } + DateTimeShortcuts.clockInputs[num].value = d.strftime(get_format('TIME_INPUT_FORMATS')[0]); + DateTimeShortcuts.clockInputs[num].focus(); + DateTimeShortcuts.dismissClock(num); + // Added by cpa: + DateTimeShortcuts.clockInputs[num].dispatchEvent(new Event('change')); + }, + // Add calendar widget to a given field. + addCalendar: function(inp) { + const num = DateTimeShortcuts.calendars.length; + + DateTimeShortcuts.calendarInputs[num] = inp; + DateTimeShortcuts.dismissCalendarFunc[num] = function() { DateTimeShortcuts.dismissCalendar(num); return true; }; + + // Shortcut links (calendar icon and "Today" link) + const shortcuts_span = document.createElement('span'); + shortcuts_span.className = DateTimeShortcuts.shortCutsClass; + inp.parentNode.insertBefore(shortcuts_span, inp.nextSibling); + const today_link = document.createElement('a'); + today_link.href = '#'; + today_link.appendChild(document.createTextNode(gettext('Today'))); + today_link.addEventListener('click', function(e) { + e.preventDefault(); + DateTimeShortcuts.handleCalendarQuickLink(num, 0); + }); + const cal_link = document.createElement('a'); + cal_link.href = '#'; + cal_link.id = DateTimeShortcuts.calendarLinkName + num; + cal_link.addEventListener('click', function(e) { + e.preventDefault(); + // avoid triggering the document click handler to dismiss the calendar + e.stopPropagation(); + DateTimeShortcuts.openCalendar(num); + }); + quickElement( + 'span', cal_link, '', + 'class', 'date-icon', + 'title', gettext('Choose a Date') + ); + shortcuts_span.appendChild(document.createTextNode('\u00A0')); + // CUSTOMIZED: Today link removed. + //shortcuts_span.appendChild(today_link); + //shortcuts_span.appendChild(document.createTextNode('\u00A0|\u00A0')); + shortcuts_span.appendChild(cal_link); + + // Create calendarbox div. + // + // Markup looks like: + // + //
+ //

+ // + // February 2003 + //

+ //
+ // + //
+ //
+ // Yesterday | Today | Tomorrow + //
+ //

Cancel

+ //
+ const cal_box = document.createElement('div'); + cal_box.style.display = 'none'; + cal_box.className = 'calendarbox module'; + cal_box.id = DateTimeShortcuts.calendarDivName1 + num; + document.body.appendChild(cal_box); + // cpa: Make it work with bootstrap + cal_box.style.position = cal_box.closest('body').classList.contains('modal-open') ? 'fixed' : 'absolute'; + cal_box.addEventListener('click', function(e) { e.stopPropagation(); }); + + // next-prev links + const cal_nav = quickElement('div', cal_box); + const cal_nav_prev = quickElement('a', cal_nav, '<', 'href', '#'); + cal_nav_prev.className = 'calendarnav-previous'; + cal_nav_prev.addEventListener('click', function(e) { + e.preventDefault(); + DateTimeShortcuts.drawPrev(num); + }); + + const cal_nav_next = quickElement('a', cal_nav, '>', 'href', '#'); + cal_nav_next.className = 'calendarnav-next'; + cal_nav_next.addEventListener('click', function(e) { + e.preventDefault(); + DateTimeShortcuts.drawNext(num); + }); + + // main box + const cal_main = quickElement('div', cal_box, '', 'id', DateTimeShortcuts.calendarDivName2 + num); + cal_main.className = 'calendar'; + DateTimeShortcuts.calendars[num] = new Calendar(DateTimeShortcuts.calendarDivName2 + num, DateTimeShortcuts.handleCalendarCallback(num)); + DateTimeShortcuts.calendars[num].drawCurrent(); + + // calendar shortcuts + const shortcuts = quickElement('div', cal_box); + shortcuts.className = 'calendar-shortcuts'; + let day_link = quickElement('a', shortcuts, gettext('Yesterday'), 'href', '#'); + day_link.addEventListener('click', function(e) { + e.preventDefault(); + DateTimeShortcuts.handleCalendarQuickLink(num, -1); + }); + shortcuts.appendChild(document.createTextNode('\u00A0|\u00A0')); + day_link = quickElement('a', shortcuts, gettext('Today'), 'href', '#'); + day_link.addEventListener('click', function(e) { + e.preventDefault(); + DateTimeShortcuts.handleCalendarQuickLink(num, 0); + }); + shortcuts.appendChild(document.createTextNode('\u00A0|\u00A0')); + day_link = quickElement('a', shortcuts, gettext('Tomorrow'), 'href', '#'); + day_link.addEventListener('click', function(e) { + e.preventDefault(); + DateTimeShortcuts.handleCalendarQuickLink(num, +1); + }); + + // cancel bar + const cancel_p = quickElement('p', cal_box); + cancel_p.className = 'calendar-cancel'; + const cancel_link = quickElement('a', cancel_p, gettext('Cancel'), 'href', '#'); + cancel_link.addEventListener('click', function(e) { + e.preventDefault(); + DateTimeShortcuts.dismissCalendar(num); + }); + document.addEventListener('keyup', function(event) { + if (event.which === 27) { + // ESC key closes popup + DateTimeShortcuts.dismissCalendar(num); + event.preventDefault(); + } + }); + }, + openCalendar: function(num) { + const cal_box = document.getElementById(DateTimeShortcuts.calendarDivName1 + num); + const cal_link = document.getElementById(DateTimeShortcuts.calendarLinkName + num); + const inp = DateTimeShortcuts.calendarInputs[num]; + + // Determine if the current value in the input has a valid date. + // If so, draw the calendar with that date's year and month. + if (inp.value) { + const format = get_format('DATE_INPUT_FORMATS')[0]; + const selected = inp.value.strptime(format); + const year = selected.getUTCFullYear(); + const month = selected.getUTCMonth() + 1; + const re = /\d{4}/; + if (re.test(year.toString()) && month >= 1 && month <= 12) { + DateTimeShortcuts.calendars[num].drawDate(month, year, selected); + } + } + + // Recalculate the clockbox position + // is it left-to-right or right-to-left layout ? + if (window.getComputedStyle(document.body).direction !== 'rtl') { + cal_box.style.left = findPosX(cal_link) + 17 + 'px'; + } + else { + // since style's width is in em, it'd be tough to calculate + // px value of it. let's use an estimated px for now + cal_box.style.left = findPosX(cal_link) - 180 + 'px'; + } + cal_box.style.top = Math.max(0, findPosY(cal_link) - 75) + 'px'; + + cal_box.style.display = 'block'; + document.addEventListener('click', DateTimeShortcuts.dismissCalendarFunc[num]); + }, + dismissCalendar: function(num) { + document.getElementById(DateTimeShortcuts.calendarDivName1 + num).style.display = 'none'; + document.removeEventListener('click', DateTimeShortcuts.dismissCalendarFunc[num]); + }, + drawPrev: function(num) { + DateTimeShortcuts.calendars[num].drawPreviousMonth(); + }, + drawNext: function(num) { + DateTimeShortcuts.calendars[num].drawNextMonth(); + }, + handleCalendarCallback: function(num) { + let format = get_format('DATE_INPUT_FORMATS')[0]; + // the format needs to be escaped a little + format = format.replace('\\', '\\\\') + .replace('\r', '\\r') + .replace('\n', '\\n') + .replace('\t', '\\t') + .replace("'", "\\'"); + return function(y, m, d) { + DateTimeShortcuts.calendarInputs[num].value = new Date(y, m - 1, d).strftime(format); + DateTimeShortcuts.calendarInputs[num].focus(); + document.getElementById(DateTimeShortcuts.calendarDivName1 + num).style.display = 'none'; + // Added by cpa: + DateTimeShortcuts.calendarInputs[num].dispatchEvent(new Event('change')); + }; + }, + handleCalendarQuickLink: function(num, offset) { + const d = DateTimeShortcuts.now(); + d.setDate(d.getDate() + offset); + DateTimeShortcuts.calendarInputs[num].value = d.strftime(get_format('DATE_INPUT_FORMATS')[0]); + DateTimeShortcuts.calendarInputs[num].focus(); + DateTimeShortcuts.dismissCalendar(num); + } + }; + + // CUSTOMIZED: We call it ourselves. + //window.addEventListener('load', DateTimeShortcuts.init); + window.DateTimeShortcuts = DateTimeShortcuts; +} diff --git a/aemo/static/js/autocomplete.min.js b/aemo/static/js/autocomplete.min.js new file mode 100644 index 0000000..5969978 --- /dev/null +++ b/aemo/static/js/autocomplete.min.js @@ -0,0 +1 @@ +!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?module.exports=t():"function"==typeof define&&define.amd?define(t):(e="undefined"!=typeof globalThis?globalThis:e||self).autocomplete=t()}(this,(function(){"use strict";return function(e){var t,n,o=document,i=o.createElement("div"),r=i.style,f=navigator.userAgent,l=-1!==f.indexOf("Firefox")&&-1!==f.indexOf("Mobile"),u=e.debounceWaitMs||0,a=e.preventSubmit||!1,s=e.disableAutoSelect||!1,d=l?"input":"keyup",c=[],p="",v=2,m=e.showOnFocus,g=0;if(void 0!==e.minLength&&(v=e.minLength),!e.input)throw new Error("input undefined");var h=e.input;function E(){n&&window.clearTimeout(n)}function w(){return!!i.parentNode}function L(){var e;g++,c=[],p="",t=void 0,(e=i.parentNode)&&e.removeChild(i)}function b(){for(;i.firstChild;)i.removeChild(i.firstChild);var n=function(e,t){var n=o.createElement("div");return n.textContent=e.label||"",n};e.render&&(n=e.render);var f=function(e,t){var n=o.createElement("div");return n.textContent=e,n};e.renderGroup&&(f=e.renderGroup);var l=o.createDocumentFragment(),u="#9?$";if(c.forEach((function(o){if(o.group&&o.group!==u){u=o.group;var i=f(o.group,p);i&&(i.className+=" group",l.appendChild(i))}var r=n(o,p);r&&(r.addEventListener("click",(function(t){e.onSelect(o,h),L(),t.preventDefault(),t.stopPropagation()})),o===t&&(r.className+=" selected"),l.appendChild(r))})),i.appendChild(l),c.length<1){if(!e.emptyMsg)return void L();var a=o.createElement("div");a.className="empty",a.textContent=e.emptyMsg,i.appendChild(a)}i.parentNode||o.body.appendChild(i),function(){if(w()){r.height="auto",r.width=h.offsetWidth+"px";var t,n=0;f(),f(),e.customize&&t&&e.customize(h,t,i,n)}function f(){var e=o.documentElement,i=e.clientTop||o.body.clientTop||0,f=e.clientLeft||o.body.clientLeft||0,l=window.pageYOffset||e.scrollTop,u=window.pageXOffset||e.scrollLeft,a=(t=h.getBoundingClientRect()).top+h.offsetHeight+l-i,s=t.left+u-f;r.top=a+"px",r.left=s+"px",(n=window.innerHeight-(t.top+h.offsetHeight))<0&&(n=0),r.top=a+"px",r.bottom="",r.left=s+"px",r.maxHeight=n+"px"}}(),function(){var e=i.getElementsByClassName("selected");if(e.length>0){var t=e[0],n=t.previousElementSibling;if(n&&-1!==n.className.indexOf("group")&&!n.previousElementSibling&&(t=n),t.offsetTopr&&(i.scrollTop+=o-r)}}}()}function y(){w()&&b()}function x(){y()}function C(e){e.target!==i?y():e.preventDefault()}function T(e){for(var t=e.which||e.keyCode||0,n=0,o=[38,13,27,39,37,16,17,18,20,91,9];n=112&&t<=123||40===t&&w()||D(0)}function N(n){var o=n.which||n.keyCode||0;if(38===o||40===o||27===o){var i=w();if(27===o)L();else{if(!i||c.length<1)return;38===o?function(){if(c.length<1)t=void 0;else if(t===c[0])t=c[c.length-1];else for(var e=c.length-1;e>0;e--)if(t===c[e]||1===e){t=c[e-1];break}}():function(){if(c.length<1&&(t=void 0),t&&t!==c[c.length-1]){for(var e=0;e=v||1===o?(E(),n=window.setTimeout((function(){e.fetch(r,(function(e){g===i&&e&&(p=r,t=(c=e).length<1||s?void 0:c[0],b())}),o)}),0===o?u:0)):L()}function H(){setTimeout((function(){o.activeElement!==h&&L()}),200)}return i.className="autocomplete "+(e.className||""),r.position="absolute",i.addEventListener("mousedown",(function(e){e.stopPropagation(),e.preventDefault()})),i.addEventListener("focus",(function(){return h.focus()})),h.addEventListener("keydown",N),h.addEventListener(d,T),h.addEventListener("blur",H),h.addEventListener("focus",k),window.addEventListener("resize",x),o.addEventListener("scroll",C,!0),{destroy:function(){h.removeEventListener("focus",k),h.removeEventListener("keydown",N),h.removeEventListener(d,T),h.removeEventListener("blur",H),window.removeEventListener("resize",x),o.removeEventListener("scroll",C,!0),E(),L()}}}})); diff --git a/aemo/static/js/autosize.min.js b/aemo/static/js/autosize.min.js new file mode 100644 index 0000000..4d9b4e9 --- /dev/null +++ b/aemo/static/js/autosize.min.js @@ -0,0 +1,6 @@ +/*! + autosize 4.0.2 + license: MIT + http://www.jacklmoore.com/autosize +*/ +!function(e,t){if("function"==typeof define&&define.amd)define(["module","exports"],t);else if("undefined"!=typeof exports)t(module,exports);else{var n={exports:{}};t(n,n.exports),e.autosize=n.exports}}(this,function(e,t){"use strict";var n,o,p="function"==typeof Map?new Map:(n=[],o=[],{has:function(e){return-1 { + button.addEventListener('click', ev => { + if (button.dataset.confirm) { + ev.preventDefault(); + if (!confirm(button.dataset.confirm)) { + return false; + } else { + if (button.getAttribute('formaction')) button.form.action = button.formAction; + button.form.submit(); + } + } + }); + }); +} + +function openFormInModal(url) { + const popup = document.querySelector('#popup0'); + + function setupForm() { + DateTimeShortcuts.init(popup); + setConfirmHandlers(popup); + document.querySelectorAll("#popup0 form").forEach((form) => { + form.addEventListener('submit', (ev) => { + ev.preventDefault(); + const form = ev.target; + const formData = new FormData(form); + // GET/POST with fetch + let url = form.action; + let params = {method: form.method}; + if (form.method == 'post') { + params['body'] = formData; + } + fetch(url, params).then(res => { + if (res.redirected) { + window.location.reload(true); + return ''; + } + return res.text(); + }).then(html => { + if (html) { + // Redisplay form with errors or display confirm page + popup.querySelector('.modal-body').innerHTML = html; + setupForm(); + } + }).catch((err) => { + console.log(err); + alert("Désolé, une erreur s'est produite"); + }); + }); + }); + } + + return fetch(url).then(res => res.text()).then(html => { + const modal = new bootstrap.Modal(popup); + popup.querySelector('.modal-body').innerHTML = html; + modal.show(); + setupForm(); + return popup; + }); +} + +function resetForm(ev) { + const form = ev.target.closest('form'); + Array.from(form.elements).forEach(el => { el.value = ''; }); + form.submit(); +} + +function submitFilterForm(form) { + let action = form.action || '.'; + const formData = new FormData(form); + action += '?' + new URLSearchParams(formData).toString(); + fetch(action, { + method: 'get', + headers: {'X-Requested-With': 'Fetch'} + }).then(response => { return response.text(); }).then(output => { + const parser = new DOMParser(); + const doc = parser.parseFromString(output, "text/html"); + const tableBody = doc.querySelector('.table-sortable tbody'); + document.querySelector('.table-sortable tbody').replaceWith(tableBody); + const pagination = doc.querySelector('#pagination'); + document.querySelector('#pagination').replaceWith(pagination); + }); +} + +function sortColumn(ev) { + const header = ev.target; + const form = document.querySelector('.selection_form'); + const desc = header.classList.contains('asc'); + form.elements['sort_by'].value = (desc ? '-' : '') + header.dataset.col; + submitFilterForm(form); + // Reset colums classes + Array.from(header.parentNode.children).forEach(head => { + head.classList.remove('desc'); + head.classList.remove('asc'); + }); + header.classList.add(desc ? 'desc': 'asc'); +} + +document.addEventListener("DOMContentLoaded", () => { + if (typeof DateTimeShortcuts !== 'undefined') { + DateTimeShortcuts.init(); + } + autosize(document.querySelectorAll('textarea')); + $("form").not(".selection_form").not("[method='get']").change(function() { + changed = true; + }); + $("#menu_crne, #aemo_buttons, #aemo_print_buttons").click + (check_changed); + setConfirmHandlers(); + $("table.sortable").each(function(idx) { + new Tablesort(this); + }); + $(".table-sortable th").click(sortColumn); + $('a.read_more').click(toggle_read_more); + + // Attachment images + $('a.image').click(showImage); + $('#modalClose').click(function(ev) {$(this).closest('div').hide(); }); + + $('input[name=dh_debut_1]').change(function(){ + var dateFin = $('input[name=dh_fin_0]'); + if (dateFin.val() == '') { + // Copier date de début vers date de fin + dateFin.val($('input[name=dh_debut_0]').val()); + } + var heureFin = $('input[name=dh_fin_1]'); + if (heureFin.val() == '') { + // Mettre heure de fin 1 heure après heure de début + var dh = $('input[name=dh_debut_1]').val().split(":"); + h = parseInt(dh[0]) + 1; + heureFin.val(h.toString() + ":" + dh[1]); + } + }); + + document.querySelectorAll('.immediate-submit').forEach(immediate => { + //immediate.addEventListener('click', immediateSubmit); + // With screen readers, users don't click but change the radio value + immediate.addEventListener('change', (ev) => { + ev.target.form.submit() + }); + }); + + $(".js-add, .js-edit").click(function(e) { + const url = this.dataset.url || this.href; + e.preventDefault(); + openFormInModal(url); + return false; + }); + + // Activation des tooltips + const tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]')) + tooltipTriggerList.map((el) => new bootstrap.Tooltip(el)); + + $('#reset-button').click(resetForm); +}); + +function debounce(func, timeout=300) { + let timer; + return (...args) => { + if (timeout <= 0) func.apply(this, args); + else { + clearTimeout(timer); + timer = setTimeout(() => { func.apply(this, args); }, timeout); + } + }; +} + +async function archiveFamilies(ev) { + const btn = ev.target; + const archiveUrl = btn.dataset.archiveurl; + const counterSpan = document.querySelector('#archive-counter'); + const totalSpan = document.querySelector('#archive-total'); + + bootstrap.Modal.getInstance(document.getElementById('archiveModal')).hide(); + document.getElementById('archive-message').removeAttribute('hidden'); + const resp = await fetch(btn.dataset.getarchivableurl); + const data = await resp.json(); + let compteur = 0; + totalSpan.textContent = data.length; + const formData = new FormData(); + formData.append('csrfmiddlewaretoken', document.querySelector('[name=csrfmiddlewaretoken]').value); + for (let i = 0; i < data.length; i++) { + const archResp = await fetch( + archiveUrl.replace('999', data[i]), + {method: 'POST', headers: {'X-Requested-With': 'Fetch'}, body: formData} + ); + const jsonResp = await archResp.json(); + compteur += 1; + counterSpan.textContent = compteur; + } + const messageP = document.querySelector("#archive-message p"); + messageP.textContent = `${compteur} dossiers ont été archivés avec succès.`; + messageP.classList.remove('alert-danger'); + messageP.classList.add('alert-success'); +} diff --git a/aemo/static/js/sorts/tablesort.date.min.js b/aemo/static/js/sorts/tablesort.date.min.js new file mode 100644 index 0000000..33fe187 --- /dev/null +++ b/aemo/static/js/sorts/tablesort.date.min.js @@ -0,0 +1,6 @@ +/*! + * tablesort v5.1.0 (2020-01-22) + * http://tristen.ca/tablesort/demo/ + * Copyright (c) 2020 ; Licensed MIT +*/ +!function(){var a=function(a){return a=a.replace(/\-/g,"/"),a=a.replace(/(\d{1,2})[\/\-](\d{1,2})[\/\-](\d{2,4})/,"$3-$2-$1"),new Date(a).getTime()||-1};Tablesort.extend("date",function(b){return(-1!==b.search(/(Mon|Tue|Wed|Thu|Fri|Sat|Sun)\.?\,?\s*/i)||-1!==b.search(/\d{1,2}[\/\-]\d{1,2}[\/\-]\d{2,4}/)||-1!==b.search(/(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)/i))&&!isNaN(a(b))},function(b,c){return b=b.toLowerCase(),c=c.toLowerCase(),a(c)-a(b)})}(); \ No newline at end of file diff --git a/aemo/static/js/sorts/tablesort.number.min.js b/aemo/static/js/sorts/tablesort.number.min.js new file mode 100644 index 0000000..06a376c --- /dev/null +++ b/aemo/static/js/sorts/tablesort.number.min.js @@ -0,0 +1,6 @@ +/*! + * tablesort v5.1.0 (2020-01-22) + * http://tristen.ca/tablesort/demo/ + * Copyright (c) 2020 ; Licensed MIT +*/ +!function(){var a=function(a){return a.replace(/[^\-?0-9.]/g,"")},b=function(a,b){return a=parseFloat(a),b=parseFloat(b),a=isNaN(a)?0:a,b=isNaN(b)?0:b,a-b};Tablesort.extend("number",function(a){return a.match(/^[-+]?[£\x24Û¢´€]?\d+\s*([,\.]\d{0,2})/)||a.match(/^[-+]?\d+\s*([,\.]\d{0,2})?[£\x24Û¢´€]/)||a.match(/^[-+]?(\d)*-?([,\.]){0,1}-?(\d)+([E,e][\-+][\d]+)?%?$/)},function(c,d){return c=a(c),d=a(d),b(d,c)})}(); \ No newline at end of file diff --git a/aemo/static/js/tablesort.min.js b/aemo/static/js/tablesort.min.js new file mode 100644 index 0000000..e9982c6 --- /dev/null +++ b/aemo/static/js/tablesort.min.js @@ -0,0 +1,6 @@ +/*! + * tablesort v5.1.0 (2020-01-22) + * http://tristen.ca/tablesort/demo/ + * Copyright (c) 2020 ; Licensed MIT +*/ +!function(){function a(b,c){if(!(this instanceof a))return new a(b,c);if(!b||"TABLE"!==b.tagName)throw new Error("Element must be a table");this.init(b,c||{})}var b=[],c=function(a){var b;return window.CustomEvent&&"function"==typeof window.CustomEvent?b=new CustomEvent(a):(b=document.createEvent("CustomEvent"),b.initCustomEvent(a,!1,!1,void 0)),b},d=function(a){return a.getAttribute("data-sort")||a.textContent||a.innerText||""},e=function(a,b){return a=a.trim().toLowerCase(),b=b.trim().toLowerCase(),a===b?0:a0)if(a.tHead&&a.tHead.rows.length>0){for(e=0;e0&&n.push(m),o++;if(!n)return}for(o=0;o{% endif %}{% include "django/forms/widgets/input.html" %}{% if widget.wrap_label %} {{ widget.label }} {% if widget.help %}{% help_tooltip widget.help %}{% endif %}{% endif %} diff --git a/aemo/templates/widgets/input_option.html b/aemo/templates/widgets/input_option.html new file mode 100644 index 0000000..8b8cd31 --- /dev/null +++ b/aemo/templates/widgets/input_option.html @@ -0,0 +1,2 @@ +{# input before label (CSSable) #} +{% include "django/forms/widgets/input.html" %} {% if widget.wrap_label %}{% endif %} diff --git a/aemo/templates/widgets/prestation_radio.html b/aemo/templates/widgets/prestation_radio.html new file mode 100644 index 0000000..8574b4b --- /dev/null +++ b/aemo/templates/widgets/prestation_radio.html @@ -0,0 +1,2 @@ +{% load my_tags static %} +{% if widget.wrap_label %}{% endif %}{% include "django/forms/widgets/input.html" %}{% if widget.wrap_label %} {{ widget.label }} {% help_tooltip widget.value.instance.actes %}{% endif %} diff --git a/aemo/templatetags/__init__.py b/aemo/templatetags/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/aemo/templatetags/my_tags.py b/aemo/templatetags/my_tags.py new file mode 100644 index 0000000..2c6d5fc --- /dev/null +++ b/aemo/templatetags/my_tags.py @@ -0,0 +1,351 @@ +import os +from datetime import date, timedelta +from operator import itemgetter + +from django import template +from django.contrib.admin.templatetags.admin_list import _boolean_icon +from django.template.defaultfilters import linebreaksbr +from django.templatetags.static import static +from django.utils.dates import MONTHS +from django.utils.html import escape, format_html_join, format_html +from django.utils.safestring import SafeString, mark_safe +from django.utils.text import Truncator + +from aemo.utils import format_d_m_Y, format_duree as _format_duree, format_adresse + +register = template.Library() + +IMAGE_EXTS = ['.jpg', '.jpeg', '.png', '.tif', '.tiff', '.gif'] + + +@register.simple_tag +def relative_url(value, field_name, urlencode=None): + url = '?{}={}'.format(field_name, value) + if urlencode: + querystring = urlencode.split('&') + filtered_querystring = filter(lambda p: p.split('=')[0] != field_name, querystring) + encoded_querystring = '&'.join(filtered_querystring) + url = '{}&{}'.format(url, encoded_querystring) + return url + + +@register.simple_tag +def get_verbose_field_name(instance, field_name): + """ + Returns verbose_name for a field. + """ + return instance._meta.get_field(field_name).verbose_name.capitalize() + + +@register.simple_tag +def get_field_value(instance, field_name): + value = getattr(instance, field_name) + if isinstance(value, str): + return mark_safe(value) + elif hasattr(value, 'all'): + return mark_safe("
".join([str(v) for v in value.all()])) + return value + + +@register.simple_tag +def help_tooltip(text): + template = ( + '' + ) + return format_html(template, text=linebreaksbr(escape(text)), icon=static("img/help.png")) + + +@register.filter +def boolean_icon(val): + return _boolean_icon(val) + + +@register.filter +def get_item(obj, key): + try: + return obj.get(key) if obj is not None else obj + except Exception: + raise TypeError(f"Unable to get key '{key}' from obj '{obj}'") + + +@register.filter +def get_field(form, field_name): + return form[field_name] + + +@register.filter +def as_field_group(ffield): + # Waiting for Django 5.0 + if (ffield): + return SafeString(" ".join([ffield.label_tag(), ffield.errors.as_ul(), str(ffield)])) + return '' + + +@register.filter +def can_edit(obj, user): + return obj.can_edit(user) + + +@register.filter +def can_delete(obj, user): + return obj.can_delete(user) + + +@register.filter +def sigles_referents(suivi): + """ + Affichage d'abord duo psy/educ, puis autres ressources. par ex: "Psy/Educ - Coach - ASE" + """ + interventions = [{ + 'nom': interv.intervenant.nom_prenom, + 'role': interv.role.nom, + 'sigle': interv.intervenant.sigle or interv.intervenant.nom + } for interv in suivi.intervenant_set.all() + ] + template = '{sigle}' + psyeduc = sorted([i for i in interventions if i['role'] in ['Psy', 'Educ']], key=itemgetter('sigle')) + autres = sorted([i for i in interventions if i['role'] not in ['Psy', 'Educ']], key=itemgetter('sigle')) + return SafeString(" - ".join([res for res in [ + "/".join([format_html(template, **i) for i in psyeduc]), + " - ".join([format_html(template, **i) for i in autres]), + ] if res])) + + +@register.filter +def noms_referents_ope(suivi): + return ' / '.join(ope.nom for ope in suivi.ope_referents) + + +@register.filter +def referents_pk_data(suivi): + return ':'.join([str(ref.pk) for ref in suivi.intervenants.all()]) + + +@register.filter +def join_qs(queryset, sep='/'): + return sep.join([str(q) for q in queryset]) + + +@register.filter +def in_parens(value): + """Enclose value in parentheses only if it's not empty.""" + return '' if value in (None, '', []) else '({})'.format(value) + + +@register.filter +def etape_cellule(suivi, code_etape): + """Produit le contenu d'une cellule du tableau de suivi des étapes.""" + def _css_class(date_suiv, default='next'): + if date_suiv is None: + # Ne devrait pas se produire, mais le cas échéant, éviter un crash. + return default + delta = date_suiv - date.today() + if 19 > delta.days > 0: + return 'urgent' + if delta.days < 0: + return 'depasse' + return default + + etape = suivi.WORKFLOW[code_etape] + etape_suiv = suivi.etape_suivante + date_etape = etape.date(suivi) + date_formatted = '' + css_class = '' + if date_etape: + date_formatted = format_d_m_Y(date_etape) + css_class = 'filled' + elif etape_suiv and code_etape == etape_suiv.code: + date_suiv = suivi.date_suivante() + date_formatted = format_d_m_Y(date_suiv) + css_class = _css_class(date_suiv) + code_etape = etape_suiv.abrev + else: + # Certaines dates sont strictement liées au suivi + date_etape = etape.delai_depuis(suivi, None) + if date_etape: + date_formatted = format_d_m_Y(date_etape) + css_class = _css_class(date_etape, default='') + return format_html( + '
{}
', + code_etape, date_formatted, css_class, etape.abrev + ) + + +@register.filter +def default_if_zero(duree): + if isinstance(duree, timedelta): + duree = _format_duree(duree) + return '' if duree == '00:00' else duree + + +@register.filter +def strip_seconds(duree): + if duree is None: + return '' + if str(duree).count(':') > 1: + return ':'.join(format_duree(duree).split(':')[:2]) + + +@register.filter +def format_duree(duree): + return _format_duree(duree) + + +@register.filter +def strip_zeros(decimal): + return str(decimal).rstrip('0').rstrip('.') + + +@register.filter(is_safe=True) +def strip_colon(txt): + return txt.replace(' :', '') + + +@register.filter +def month_name(month_number): + return MONTHS[month_number] + + +@register.filter(name='has_group') +def has_group(user, group_name): + return user.groups.filter(name=group_name).exists() + + +@register.filter +def info_ope(ope): + if ope: + return format_html( + '{}', ope.tel_prof, ope.nom_prenom, + ) + else: + return '' + + +@register.filter +def age_a(personne, date_): + return personne.age_str(date_) + + +@register.filter +def is_passed(date_): + return date_ < date.today() + + +@register.filter +def nom_prenom_abreg(person): + return '{} {}.'.format(person.nom, person.prenom[0].upper() if person.prenom else '') + + +@register.simple_tag +def mes_totaux_mensuels(user, src, annee): + return user.totaux_mensuels(src, annee) + + +@register.simple_tag +def mon_total_annuel(user, src, annee): + return format_duree(user.total_annuel(src, annee)) + + +@register.filter +def sigles_intervenants(prestation): + return format_html_join( + '/', '{}', ((i.sigle or i.nom,) for i in prestation.intervenants.all()) + ) + + +@register.filter +def sigle_personne(pers): + if pers is None: + return '-' + return format_html( + '{}', pers.nom_prenom, pers.sigle or pers.nom + ) + + +@register.filter +def as_icon(fichier): + if not fichier: + return '' + ext = os.path.splitext(fichier.name)[1].lower() + if ext in IMAGE_EXTS: + icon = 'image' + elif ext in ('.xls', '.xlsx'): + icon = 'xlsx' + elif ext in ('.doc', '.docx'): + icon = 'docx' + elif ext == '.pdf': + icon = 'pdf' + else: + icon = 'master' + return format_html( + 'Télécharger', + klass=icon, url=fichier.url, ficon=static(f"ficons/{icon}.svg") + ) + + +@register.simple_tag(takes_context=True) +def param_replace(context, **kwargs): + d = context['request'].GET.copy() + for k, v in kwargs.items(): + d[k] = v + for k in [k for k, v in d.items() if not v]: + del d[k] + return d.urlencode() + + +@register.filter +def colorier_delai(value): + if value <= 0: + return "bg-danger-3" + return '' + + +@register.filter +def benef_nom_adresse(obj): + if obj: + return '{} - {}'.format(obj.nom_prenom(), + format_adresse(obj.rue_actuelle, obj.npa_actuelle, obj.localite_actuelle)) + return 'Nouveau bénéficiaire' + + +@register.filter +def role_profession(contact): + if contact.profession: + return f"{contact.roles_str()} / {contact.profession}" + return contact.roles_str() + + +@register.filter +def truncate_html_with_more(text, length): + template = ''' + +
{txt_short}
+ {read_more} + ''' + read_more = 'Afficher la suite' + text_trunc = Truncator(text).words(int(length), html=True) + return format_html( + template, + txt_long=mark_safe(text), + txt_short=mark_safe(text_trunc), + read_more=mark_safe(read_more) if text != text_trunc else '', + ) + + +@register.filter +def raw_or_html(value): + if value and '', + '', + '', + '', + ], transform=repr) + p1 = Personne.objects.get(nom='Dupont') + self.assertEqual(p1.famille, famille) + self.assertEqual(p1.localite, 'Bevaix') + with self.assertRaises(Formation.DoesNotExist): + p1.formation + + # Personne < 4 ans + pers = Personne.objects.create_personne( + famille=famille, prenom='Gaston', nom='Lagaffe', + date_naissance=date.today() - timedelta(days=720), + role=Role.objects.get(nom='Enfant suivi') + ) + self.assertEqual(pers.formation.get_statut_display(), 'Pré-scolaire') + self.assertTrue(famille.suivi.demande_prioritaire) + + def test_label_profession_variable(self): + famille = Famille.objects.get(nom='Haddock') + response = self.client.get( + '{}?role={}'.format(reverse('personne-add', args=[famille.pk]), + Role.objects.get(nom='Enfant suivi').pk) + ) + self.assertContains(response, '', html=True) + + response = self.client.get( + '{}?role={}'.format(reverse('personne-add', args=[famille.pk]), + Role.objects.get(nom='Père').pk) + ) + self.assertContains(response, '', html=True) + + def test_get_membres(self): + # Obtention des différents membres avec 2 requêtes: la famille, les membres (prefetch_related) + with self.assertNumQueries(2): + famille = Famille.objects.get(nom='Haddock') + self.assertEqual(famille.membres_suivis()[0].prenom, 'Toto') + self.assertEqual(famille.enfants_non_suivis(), []) + self.assertEqual([p.nom_prenom for p in famille.parents()], ['Haddock Archibald']) + self.assertEqual(famille.autres_parents(), []) + + def test_personne_age(self): + with patch('aemo.models.date') as mock_date: + mock_date.today.return_value = date(2019, 1, 16) + self.assertEqual(Personne(date_naissance=date(1999, 11, 4)).age, 19.2) + self.assertEqual(Personne(date_naissance=date(2000, 1, 1)).age, 19.0) + self.assertEqual(Personne(date_naissance=date(2000, 1, 31)).age, 18.9) + + def test_personne_age_str(self): + pers = Personne(prenom='Toto') + pers.date_naissance = date.today() - timedelta(days=12) + self.assertEqual(pers.age_str(), '12 jours') + pers.date_naissance = date.today() - timedelta(days=33) + self.assertEqual(pers.age_str(), '4 sem. 5 jours') + pers.date_naissance = date.today() - timedelta(days=77) + self.assertEqual(pers.age_str(), '2 mois 2 sem.') + pers.date_naissance = date.today() - timedelta(days=689) + self.assertEqual(pers.age_str(), '22 mois 4 sem.') + pers.date_naissance = date.today() - timedelta(days=690) + self.assertEqual(pers.age_str(), '1 an 10 mois') + pers.date_naissance = date.today() - timedelta(days=691) + self.assertEqual(pers.age_str(format_='jour'), '691 jours') + + def test_personne_edition(self): + famille = Famille.objects.create_famille(nom='Dupont', equipe='montagnes') + pers_data = dict(self.personne_data, famille=famille.pk, role=Role.objects.get(nom='Père').pk) + form = PersonneForm(data=pers_data, famille=famille) + self.assertTrue(form.is_valid(), msg=form.errors) + pers = form.save() + edit_url = reverse('personne-edit', args=[famille.pk, pers.pk]) + new_data = dict(pers_data, rue='Rue du Parc 12') + response = self.client.post(edit_url, data=new_data) + self.assertEqual(response.status_code, 302) + pers.refresh_from_db() + self.assertEqual(pers.rue, 'Rue du Parc 12') + # Edition is refused if user is missing the permission + user = Utilisateur.objects.create_user( + 'joe', 'joe@example.org', 'pwd', first_name='Joe', last_name='Cook', + ) + self.client.force_login(user) + with self.assertLogs('django.request', level='WARNING'): + response = self.client.get(edit_url) + self.assertEqual(response.status_code, 403) + + def test_personne_formation(self): + famille = Famille.objects.get(nom='Haddock') + pers = Personne.objects.create_personne( + famille=famille, prenom='Gaston', nom='Lagaffe', + role=Role.objects.get(nom='Enfant suivi') + ) + self.assertTrue(famille.can_edit(self.user_aemo)) + form_url = reverse('formation', args=[pers.pk]) + response = self.client.get(form_url) + self.assertFalse(response.context['form'].readonly) + self.assertContains(response, "Enregistrer") + response = self.client.post(form_url, data={ + 'statut': 'cycle2', + 'cercle_scolaire': CercleScolaire.objects.first().pk, + 'college': 'École parfaite', + 'classe': '6H', + }) + self.assertRedirects(response, reverse('famille-edit', args=[famille.pk])) + pers.refresh_from_db() + self.assertEqual(pers.formation.classe, '6H') + + def test_delete_personne_formation_impossible(self): + pers = Personne.objects.create_personne( + famille=Famille.objects.first(), prenom='Gaston', nom='Lagaffe', + role=Role.objects.get(nom='Enfant suivi') + ) + self.assertRaises(IntegrityError, pers.formation.delete) + + def test_delete_personne(self): + famille = Famille.objects.first() + pers = Personne.objects.create_personne( + famille=famille, prenom='Gaston', nom='Lagaffe', + role=Role.objects.get(nom='Père') + ) + response = self.client.post( + reverse('personne-delete', args=[famille.pk, pers.pk]), follow=True + ) + self.assertRedirects(response, reverse('famille-edit', args=[famille.pk]), status_code=302) + + def test_famille_creation(self): + response = self.client.get(reverse('famille-add')) + self.assertContains(response, '', html=True) + self.assertContains( + response, + '', + html=True + ) + self.assertContains(response, "id_motif_detail") + response = self.client.post(reverse('famille-add'), data={ + **self.famille_data, 'motif_detail': "Un test parmi d'autres" + }) + famille = Famille.objects.get(nom='Dupont') + self.assertRedirects(response, reverse('famille-edit', args=[famille.pk])) + famille = Famille.objects.get(nom='Dupont') + self.assertEqual(famille.suivi.equipe, 'montagnes') + self.assertEqual(famille.suivi.date_demande, date.today()) + self.assertEqual(famille.suivi.motif_detail, "Un test parmi d'autres") + + def test_famille_edition(self): + famille = Famille.objects.get(nom='Haddock') + edit_url = reverse('famille-edit', args=[famille.pk]) + response = self.client.get(edit_url) + self.assertContains(response, ''.format(format_d_m_Y(date.today())), + html=True + ) + + def test_dates_obligatoires(self): + today = date.today() + form_data = { + field_name: today for field_name in AgendaForm._meta.fields + if field_name not in ['date_fin_suivi', 'motif_fin_suivi', 'destination'] + } + for field, etape in Suivi.WORKFLOW.items(): + data2 = dict(form_data) + if etape.oblig is True: + etape_preced_oblig = Suivi.WORKFLOW[etape.preced_oblig] + data2[etape_preced_oblig.date_nom()] = '' + if etape.code == 'fin_suivi': + data2['date_fin_suivi'] = today + data2['motif_fin_suivi'] = 'placement' + data2['destination'] = 'fah' + form = AgendaForm(data2, request=self.request) + self.assertFalse(form.is_valid()) + self.assertEqual(form.errors, {'__all__': ['La date «{}» est obligatoire'.format(etape_preced_oblig)]}) + else: + form = AgendaForm(data2, request=self.request) + self.assertTrue(form.is_valid()) + + def test_fin_suivi(self): + famille = Famille.objects.create_famille( + nom='Loiseau', rue='Château1', npa=2000, localite='Moulinsart', equipe='montagnes', + ) + today = date.today() + suivi = famille.suivi + form_data = {} + suivi.date_demande = form_data['date_demande'] = today - timedelta(days=360) + suivi.date_debut_evaluation = form_data['date_debut_evaluation'] = today - timedelta(days=290) + suivi.date_fin_evaluation = form_data['date_fin_evaluation'] = today - timedelta(days=250) + suivi.date_debut_suivi = form_data['date_debut_suivi'] = today - timedelta(days=120) + suivi.save() + form = AgendaForm( + data={**form_data, 'date_fin_suivi': today - timedelta(days=5), + 'motif_fin_suivi': 'evol_positive', 'destination': 'fah'}, + instance=suivi, + request=self.request, + ) + self.assertTrue(form.is_valid(), msg=form.errors) + suivi = form.save() + self.assertEqual(suivi.date_fin_suivi, today - timedelta(days=5)) + + def test_motif_fin_suivi_sans_date(self): + data = {'date_demande': date.today(), + 'date_debut_evaluation': date.today(), + 'date_fin_evaluation': date.today(), + 'date_debut_suivi': date.today(), + 'motif_fin_suivi': 'evol_positive'} + form = AgendaForm(data, request=self.request) + self.assertFalse(form.is_valid()) + self.assertEqual( + form.errors, + {'__all__': ["Les champs «Fin de l'accompagnement», «Motif de fin» et «Destination» " + "sont obligatoires pour fermer le dossier."]} + ) + # Test avec date_fin_suivi non valide + data['date_fin_suivi'] = '2019-01-32' + form = AgendaForm(data, request=self.request) + self.assertFalse(form.is_valid()) + self.assertEqual( + form.errors, + {'date_fin_suivi': ['Saisissez une date valide.'], + '__all__': ["Les champs «Fin de l'accompagnement», «Motif de fin» et «Destination» " + "sont obligatoires pour fermer le dossier."] + } + ) + + def test_dates_non_chronologiques(self): + data = { + field_name: '2019-01-15' for field_name in AgendaForm._meta.fields + if field_name not in ['date_fin_suivi', 'motif_fin_suivi', 'destination'] + } + date_field_preced = None + for field_name in AgendaForm._meta.fields: + if field_name in ['date_demande', 'motif_fin_suivi', 'destination']: + date_field_preced = field_name + continue + elif field_name == 'date_fin_suivi': + data = dict( + data, date_fin_suivi='2019-01-15', motif_fin_suivi='placement', destination='famille' + ) + form = AgendaForm(dict(data, **{date_field_preced: '2019-01-16'}), request=self.request) + self.assertFalse(form.is_valid()) + self.assertEqual( + form.errors, + {'__all__': + ["La date «{}» ne respecte pas l’ordre chronologique!".format(form.fields[field_name].label)]} + ) + date_field_preced = field_name + # Test avec trou dans les valeurs de dates + data = { + 'date_demande': '2019-01-15', + 'date_fin_evaluation': '2019-01-01', # Ordre chronologique non respecté + 'date_debut_suivi': '2019-02-01' + } + form = AgendaForm(data, request=self.request) + self.assertFalse(form.is_valid()) + self.assertEqual( + form.errors, + {'__all__': ["La date «Fin de l’évaluation le» ne respecte pas l’ordre chronologique!"]} + ) + + def test_saisie_date_invalide(self): + form = AgendaForm({ + 'date_demande': '20.8', + }, request=self.request) + self.assertFalse(form.is_valid()) + self.assertEqual(form.errors, {'date_demande': ['Saisissez une date valide.']}) + + def test_saisie_par_erreur_OK(self): + data = {'motif_fin_suivi': 'erreur'} + form = AgendaForm(data, request=self.request) + self.assertTrue(form.is_valid()) + self.assertEqual(form.cleaned_data['date_fin_suivi'], date.today()) + + def test_demande_non_aboutie(self): + data = {'motif_fin_suivi': 'non_aboutie'} + form = AgendaForm(data, request=self.request) + self.assertTrue(form.is_valid()) + self.assertEqual(form.cleaned_data['date_fin_suivi'], date.today()) + + def test_abandon_durant_evaluation_1(self): + data = {'date_demande': date.today(), 'motif_fin_suivi': 'placement', + 'destination': 'famille'} + form = AgendaForm(data, request=self.request) + self.assertTrue(form.is_valid(), form.errors) + + def test_abandon_durant_evaluation_2(self): + data = {'date_demande': date.today(), 'motif_fin_suivi': 'placement', 'destination': 'famille'} + form = AgendaForm(data, request=self.request) + self.assertTrue(form.is_valid()) + + def test_dates_tardives(self): + date_passee = date.today() - timedelta(days=31 + Prestation.DELAI_SAISIE_SUPPL) + form_data = {} + suivi = Suivi() + for date_field in AgendaForm._meta.fields: + if not date_field.startswith('date'): + continue + if date_field == 'date_fin_suivi': + form_data['motif_fin_suivi'] = 'placement' + form_data['destination'] = 'famille' + form_data[date_field] = date_passee + form = AgendaForm(data=form_data, instance=suivi, request=self.request) + self.assertFalse(form.is_valid()) + if date_field == 'date_fin_suivi': + self.assertEqual( + form.errors, + {date_field: ["La saisie de dates pour le mois précédent n’est pas permise !"], + '__all__': ["Les champs «Fin de l'accompagnement», «Motif de fin» et «Destination» " + "sont obligatoires pour fermer le dossier."]} + ) + else: + self.assertEqual( + form.errors, + {date_field: ["La saisie de dates pour le mois précédent n’est pas permise !"]} + ) + setattr(suivi, date_field, date.today() - timedelta(days=90)) + form_data[date_field] = date.today() - timedelta(days=90) + + def test_date_demande_anticipee(self): + date_demande = date.today() + timedelta(days=1) + data = {'date_demande': date_demande} + form = AgendaForm(data, request=self.request) + self.assertFalse(form.is_valid()) + self.assertEqual( + form.errors, + {'date_demande': ["La saisie anticipée est impossible !"]} + ) + + def test_sans_destination_erreur(self): + data = {'date_demande': date.today(), + 'date_debut_evaluation': date.today(), + 'date_fin_evaluation': date.today(), + 'date_debut_suivi': date.today(), + 'date_fin_suivi': date.today(), + 'motif_fin_suivi': 'evol_positive'} + form = AgendaForm(data, request=self.request) + self.assertFalse(form.is_valid()) + self.assertEqual( + form.errors, + {'__all__': ["Les champs «Fin de l'accompagnement», «Motif de fin» et «Destination» " + "sont obligatoires pour fermer le dossier."]} + ) + + data['destination'] = '' + form = AgendaForm(data, request=self.request) + self.assertFalse(form.is_valid()) + self.assertEqual( + form.errors, + {'__all__': ["Les champs «Fin de l'accompagnement», «Motif de fin» et «Destination» " + "sont obligatoires pour fermer le dossier."]} + ) + + def test_date_debut_suivi_anticipe_ok(self): + data = {'date_demande': date.today(), + 'date_debut_evaluation': date.today() + timedelta(days=ANTICIPATION_POUR_DEBUT_SUIVI), + 'date_fin_evaluation': date.today() + timedelta(days=ANTICIPATION_POUR_DEBUT_SUIVI), + 'date_debut_suivi': date.today() + timedelta(days=ANTICIPATION_POUR_DEBUT_SUIVI)} + form = AgendaForm(data, request=self.request) + self.assertTrue(form.is_valid()) + + def test_date_debut_suivi_anticipe_error(self): + data = {'date_demande': date.today(), + 'date_debut_evaluation': date.today() + timedelta(days=ANTICIPATION_POUR_DEBUT_SUIVI + 1), + 'date_fin_evaluation': date.today() + timedelta(days=ANTICIPATION_POUR_DEBUT_SUIVI + 1), + 'date_debut_suivi': date.today() + timedelta(days=ANTICIPATION_POUR_DEBUT_SUIVI + 1)} + form = AgendaForm(data, request=self.request) + self.assertFalse(form.is_valid()) + + def test_date_anticipee_pour_groupe_admin(self): + famille = Famille.objects.get(nom='Haddock') + self.client.force_login(self.user_admin) + response = self.client.post( + reverse('famille-agenda', args=[famille.pk]), + data={'date_demande': '2022-09-01'}, + follow=True + ) + self.assertContains( + response, + '
  • Les dates saisies peuvent affecter les statistiques ' + 'déjà communiquées !
  • ', + html=True + ) + self.assertContains( + response, + '
  • Les modifications ont été enregistrées avec succès
  • ', + html=True + ) + + def test_affichage_intervention_temporaire(self): + famille = Famille.objects.get(nom='Haddock') + intervenant = self.user_aemo + role = Role.objects.get(nom='Educ') + date_debut = date.today() + intervention = Intervenant.objects.create( + suivi=famille.suivi, intervenant=intervenant, role=role, date_debut=date_debut, date_fin=date.today() + ) + self.client.force_login(self.user_aemo) + suivi_url = reverse('famille-agenda', args=[famille.pk]) + response = self.client.get(suivi_url) + self.assertContains( + response, + ( + f"
    Aemo Prénom (Educ) ({format_d_m_Y(intervention.date_debut)} - " + f"{format_d_m_Y(intervention.date_fin)})
    " + ), + html=True + ) + + +class JournalisationTests(InitialDataMixin, TestCase): + def test_acces_famille_journalise(self): + famille = Famille.objects.get(nom='Haddock') + Intervenant.objects.create( + suivi=famille.suivi, intervenant=self.user_aemo, + role=Role.objects.get(nom='Educ'), + date_debut=date.today(), + ) + Intervenant.objects.create( + suivi=famille.suivi, intervenant=self.user_aemo2, + role=Role.objects.get(nom='Educ'), + date_debut=date.today() - timedelta(days=3), + date_fin=date.today() - timedelta(days=1), + ) + self.client.force_login(self.user_aemo) + suivi_url = reverse('famille-suivi', args=[famille.pk]) + response = self.client.get(suivi_url) + self.assertTemplateUsed(response, 'aemo/suivi_edit.html') + + self.client.force_login(self.user_aemo2) + response = self.client.get(suivi_url) + self.assertTemplateUsed(response, 'aemo/acces_famille.html') + response = self.client.get(suivi_url + '?confirm=1') + self.assertTemplateUsed(response, 'aemo/suivi_edit.html') + line = JournalAcces.objects.get(utilisateur=self.user_aemo) + self.assertTrue(line.ordinaire) + line = JournalAcces.objects.get(utilisateur=self.user_aemo2) + self.assertFalse(line.ordinaire) + + +@tag('pdf') +class PdfTests(InitialDataMixin, TestCase): + def setUp(self): + super().setUp() + self.client.force_login(self.user_aemo) + + def _test_print_pdf(self, url, filename): + self.client.force_login(self.user_aemo) + response = self.client.get(url) + self.assertEqual( + response['content-disposition'], + 'attachment; filename="{}"'.format(filename) + ) + self.assertEqual(response['content-type'], 'application/pdf') + self.assertGreater(len(response.getvalue()), 200) + + def test_print_evaluation(self): + fam = Famille.objects.get(nom='Haddock') + self._test_print_pdf( + reverse('print-evaluation', args=(fam.pk,)), + 'haddock_aemo_evaluation.pdf' + ) + # Avec genogramme en PDF + with (Path('.').parent / 'aemo' / 'test.pdf').open(mode='rb') as fh: + fam.genogramme = File(fh) + fam.save() + self._test_print_pdf( + reverse('print-evaluation', args=(fam.pk,)), + 'haddock_aemo_evaluation.pdf' + ) + + def test_print_bilan(self): + fam = Famille.objects.get(nom='Haddock') + bilan = Bilan.objects.create( + date=date(2020, 11, 3), + auteur=self.user_aemo, + famille=fam, + objectifs="

    Para 1

    Para 2

    ", + rythme="

    Para 1

    Para 2

    ", + ) + self._test_print_pdf( + reverse('print-bilan', args=(bilan.pk,)), + 'haddock_bilan_20201103.pdf' + ) + + def test_print_coord_famille_un_enfant_un_parent(self): + fam = Famille.objects.get(nom='Haddock') + Personne.objects.create_personne( + famille=fam, prenom='Gaston', nom='Lagaffe', + role=Role.objects.get(nom='Enfant suivi') + ) + self._test_print_pdf( + reverse('print-coord-famille', args=(fam.pk,)), + 'haddock_coordonnees.pdf' + ) + + def test_print_coord_un_enfant_deux_parents(self): + fam = Famille.objects.get(nom='Haddock') + Personne.objects.create_personne( + famille=fam, prenom='Gaston', nom='Lagaffe', + role=Role.objects.get(nom='Enfant suivi') + ) + Personne.objects.create_personne( + famille=fam, prenom='Maude', nom='Zarella', + role=Role.objects.get(nom='Mère') + ) + self._test_print_pdf( + reverse('print-coord-famille', args=(fam.pk,)), + 'haddock_coordonnees.pdf' + ) + + def test_print_coord_pere_mere_beaupere(self): + fam = Famille.objects.get(nom='Haddock') + Personne.objects.create_personne( + famille=fam, prenom='Gaston', nom='Lagaffe', + role=Role.objects.get(nom='Enfant suivi') + ) + Personne.objects.create_personne( + famille=fam, prenom='Maude', nom='Zarella', + role=Role.objects.get(nom='Mère') + ) + Personne.objects.create_personne( + famille=fam, prenom='Aloïs', nom='Silalune', + role=Role.objects.get(nom='Beau-père') + ) + self._test_print_pdf( + reverse('print-coord-famille', args=(fam.pk,)), + 'haddock_coordonnees.pdf' + ) + + def test_print_sans_famille(self): + famille = Famille.objects.create_famille( + nom='Haddock', rue='Château1', npa=2000, localite='Moulinsart', + equipe='montagnes', autorite_parentale='conjointe', statut_marital='divorce', + monoparentale=False + ) + Personne.objects.create_personne( + famille=famille, prenom='Gaston', nom='Lagaffe', + role=Role.objects.get(nom='Enfant suivi') + ) + self._test_print_pdf( + reverse('print-coord-famille', args=(famille.pk,)), + 'haddock_coordonnees.pdf' + ) + + +class PrestationTests(InitialDataMixin, TestCase): + + def setUp(self): + super().setUp() + self.famille = Famille.objects.get(nom='Haddock') + Personne.objects.create_personne( + famille=self.famille, role=Role.objects.get(nom='Enfant suivi'), + nom='Haddock', prenom='Paulet', genre='M', date_naissance=date(1956, 2, 16), + rue='Château1', npa=2000, localite='Moulinsart', + ) + self.famille.suivi.intervenants.add( + self.user_aemo, through_defaults={'role': Role.objects.get(nom='Educ')} + ) + self.prest_fam = LibellePrestation.objects.get(code='aemo01') # Évaluation + self.prest_gen = LibellePrestation.objects.get(code='aemo03') + + def create_prestation_fam(self, texte=""): + prest = Prestation.objects.create( + auteur=self.user_aemo, date_prestation=date.today(), duree=timedelta(hours=8), + famille=self.famille, lib_prestation=self.prest_fam, texte=texte + ) + prest.intervenants.add(self.user_aemo, self.user_aemo2) + return prest + + def create_prestation_gen(self): + data = dict( + auteur=self.user_aemo, date_prestation=date.today(), duree=timedelta(hours=8), + famille=None, lib_prestation=self.prest_gen + ) + prest = Prestation.objects.create(**data) + prest.intervenants.add(self.user_aemo) + return prest + + def test_prestation_texte_clean(self): + """Leading and trailing empty paragraphs are stripped.""" + texte = ( + '

    \n' + '

    Echanges mail fin de suivi, au revoir à la famille

    \n' + '

    ' + ) + data = dict( + duree='3:40', date_prestation=date.today(), lib_prestation=self.prest_fam.pk, + intervenants=[self.user_aemo.pk], texte=texte + ) + form = PrestationForm(famille=self.famille, user=self.user_aemo, data=data) + self.assertTrue(form.is_valid()) + self.assertEqual( + form.cleaned_data['texte'], + '

    Echanges mail fin de suivi, au revoir à la famille

    ' + ) + + def test_prestation_date_prestation_dans_mois_courant(self): + data = dict( + duree='3:40', date_prestation=date.today(), lib_prestation=self.prest_fam.pk, + intervenants=[self.user_aemo.pk] + ) + form = PrestationForm(famille=self.famille, user=self.user_aemo, data=data) + self.assertTrue(form.is_valid(), msg=form.errors) + + def test_prestation_saisie_anticipee(self): + data = dict( + duree='3:40', date_prestation=date.today() + timedelta(days=1), + lib_prestation=self.prest_fam.pk, intervenants=[self.user_aemo.pk] + ) + form = PrestationForm(famille=self.famille, user=self.user_aemo, data=data) + self.assertFalse(form.is_valid()) + self.assertEqual(form.errors, {'date_prestation': ['La saisie anticipée est impossible !']}) + + def test_prestation_saisie_tardive(self): + data = dict( + duree='3:40', date_prestation=date.today() - timedelta(days=31 + Prestation.DELAI_SAISIE_SUPPL), + lib_prestation=self.prest_fam.pk, intervenants=[self.user_aemo.pk] + ) + form = PrestationForm(famille=self.famille, user=self.user_aemo, data=data) + self.assertFalse(form.is_valid()) + self.assertEqual( + form.errors, + {'date_prestation': ['La saisie des prestations des mois précédents est close !']} + ) + + def test_prestation_edit_perm_speciale(self): + prest = self.create_prestation_fam() + prest.date_prestation = date.today() - timedelta(days=31 + Prestation.DELAI_SAISIE_SUPPL) + prest.save() + self.user_admin.user_permissions.add( + Permission.objects.get(codename='edit_prest_prev_month', content_type__app_label='aemo') + ) + self.client.force_login(self.user_admin) + self.client.post(reverse('prestation-edit', args=[self.famille.pk, prest.pk]), data={ + 'date_prestation': prest.date_prestation, + 'duree': '00:45', + 'intervenants': [self.user_aemo.pk], + 'texte': 'foo', + }) + prest.refresh_from_db() + self.assertEqual(prest.texte, 'foo') + + def test_add_prestation_fam(self): + add_url = reverse('prestation-famille-add', args=[self.famille.pk]) + data = dict( + duree='3:40', date_prestation=date.today() - timedelta(days=3), famille=self.famille.pk, + intervenants=[self.user_aemo.pk] + ) + self.client.force_login(self.user_aemo) + response = self.client.post(add_url, data) + if response.status_code == 200: + self.fail(response.context['form'].errors) + self.assertRedirects(response, reverse('journal-list', args=[self.famille.pk])) + prestation = self.famille.prestations.get(duree="3:40") + # Évaluation choisie auto car pas de date de début de suivi + self.assertEqual(prestation.lib_prestation.code, 'aemo01') + + # Le type de prestation dépend de la date exacte de début de suivi. + self.famille.suivi.date_debut_suivi = date.today() - timedelta(days=1) + self.famille.suivi.save() + data.update({'date_prestation': date.today(), 'duree': '1:00'}) + response = self.client.post(add_url, data) + prestation = self.famille.prestations.get(duree="1:00") + self.assertEqual(prestation.lib_prestation.code, 'aemo02') + data.update({'date_prestation': date.today() - timedelta(days=2), 'duree': '1:30'}) + response = self.client.post(add_url, data) + prestation = self.famille.prestations.get(duree="1:30") + self.assertEqual(prestation.lib_prestation.code, 'aemo01') + + def test_affichage_prestation_menu_fam(self): + self.create_prestation_fam() + menu_url = reverse('prestation-menu') + self.client.force_login(self.user_aemo) + response = self.client.get(menu_url) + self.assertContains( + response, + '16:00', + html=True + ) + self.assertContains( + response, + '08:00', + html=True + ) + + def test_add_prestation_gen(self): + add_url = reverse('prestation-gen-add') + data = dict( + duree='3:40', famille='', + date_prestation=date.today() - timedelta(days=360), # Too old! + intervenants=[self.user_aemo.pk] + ) + self.client.force_login(self.user_aemo) + response = self.client.post(add_url, data) + self.assertEqual( + response.context['form'].errors, + {'date_prestation': ['La saisie des prestations des mois précédents est close !']} + ) + data['date_prestation'] = date.today() + response = self.client.post(add_url, data) + self.assertRedirects(response, reverse('prestation-gen-list')) + self.assertEqual(self.user_aemo.prestations.first().lib_prestation.code, 'aemo03') + + def test_update_prestation_fam(self): + prest = self.create_prestation_fam() + url = reverse('prestation-edit', args=[self.famille.pk, prest.pk]) + self.client.force_login(self.user_aemo) + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + data = model_to_dict(prest) + data['fichier'] = '' + data['intervenants'] = [str(interv.pk) for interv in data['intervenants']] + data['duree'] = '12:00' + response = self.client.post(url, data=data) + prest.refresh_from_db() + self.assertEqual(prest.duree, timedelta(hours=12)) + + def test_correction_prestation_debut_suivi_passe(self): + """ + Quand un début de suivi est saisi dans le passé, les prestations depuis + cette date sont passées en accompagnement. + """ + prest = self.create_prestation_fam() + self.assertEqual(prest.lib_prestation.code, 'aemo01') + self.assertIsNone(self.famille.suivi.date_debut_suivi) + self.famille.suivi.intervenants.add( + self.user_aemo, through_defaults={'role': Role.objects.get(nom='Educ')} + ) + self.client.force_login(self.user_aemo) + response = self.client.post( + reverse('famille-agenda', args=[self.famille.pk]), + data={ + 'date_demande': date.today() - timedelta(days=4), + 'date_debut_evaluation': date.today() - timedelta(days=4), + 'date_fin_evaluation': date.today() - timedelta(days=2), + 'date_debut_suivi': date.today() - timedelta(days=2), + }, + ) + self.assertEqual(response.status_code, 302) + self.famille.refresh_from_db() + self.assertIsNotNone(self.famille.suivi.date_debut_suivi) + prest.refresh_from_db() + self.assertEqual(prest.lib_prestation.code, 'aemo02') + + def test_droit_modification_prestation_familiale(self): + prest = Prestation.objects.create( + famille=self.famille, + auteur=self.user_admin, + lib_prestation=LibellePrestation.objects.get(code='aemo01'), + date_prestation=date.today(), + duree='1:10' + ) + prest.intervenants.set([self.user_aemo]) + + self.client.force_login(self.user_aemo2) + with self.assertLogs('django.request', level='WARNING'): + response = self.client.get(reverse('prestation-edit', args=[self.famille.pk, prest.pk])) + self.assertEqual(response.status_code, 403) + self.assertTrue(prest.can_edit(self.user_aemo)) + + self.client.force_login(self.user_admin) + response = self.client.get(reverse('prestation-edit', args=[self.famille.pk, prest.pk])) + self.assertEqual(response.status_code, 200) + + def test_affichage_prestation_personnelle(self): + self.create_prestation_fam() + self.create_prestation_gen() + self.client.force_login(self.user_aemo) + response = self.client.get(reverse('prestation-personnelle')) + self.assertContains( + response, + 'Total prestations aemo0108:00', + html=True + ) + self.assertContains( + response, + 'Total prestations aemo0308:00', + html=True + ) + self.assertContains( + response, + 'Total16:00', + html=True + ) + + def test_affichage_prestation_generale(self): + self.create_prestation_fam() + self.create_prestation_gen() + self.client.force_login(self.user_aemo) + response = self.client.get(reverse('prestation-generale')) + self.assertContains( + response, + '08:00', + html=True, + count=1 + ) + + def test_suppression_prestation_famille(self): + prest_fam = self.create_prestation_fam() + self.client.force_login(self.user_aemo) + response = self.client.post(reverse('prestation-delete', args=[prest_fam.famille_id, prest_fam.pk])) + self.assertRedirects(response, reverse('journal-list', args=[prest_fam.famille_id])) + self.assertFalse(Prestation.objects.filter(pk=prest_fam.pk).exists()) + + def test_recherche_prestations(self): + texte1 = "La famille Lucky va bien" + texte2 = "En été, Luke tire bien plus vite que son ombre!" + prest1 = self.create_prestation_fam(texte1) + prest2 = self.create_prestation_fam(texte2) + self.client.force_login(self.user_aemo) + url = reverse('journal-list', args=[self.famille.pk]) + + # Note: les chaînes à rechercher sont en majuscule pour s'assurer que + # la recherche n'est pas sensible à la casse + + # mot contenu dans les deux enregistrements + response = self.client.get(url, {"recherche": "BIEN"}) + self.assertQuerySetEqual(response.context["object_list"], [prest1, prest2]) + + # mot contenu dans le premier enregistrement + response = self.client.get(url, {"recherche": "FAMILLE"}) + self.assertQuerySetEqual(response.context["object_list"], [prest1]) + + # mot contenu dans le deuxième enregistrement + response = self.client.get(url, {"recherche": "OMBRE"}) + self.assertQuerySetEqual(response.context["object_list"], [prest2]) + + # mot contenu dans aucun enregistrement + response = self.client.get(url, {"recherche": "TINTIN"}) + self.assertQuerySetEqual(response.context["object_list"], []) + + # deux mots contenus chacun dans un enregistrement différent + response = self.client.get(url, {"recherche": "FAMILLE OMBRE"}) + self.assertQuerySetEqual(response.context["object_list"], []) + + # deux mots contenus dans le même enregistrement + response = self.client.get(url, {"recherche": "BIEN OMBRE"}) + self.assertQuerySetEqual(response.context["object_list"], [prest2]) + + # Recherche d'un mot avec accent (mot clé sans accent) + response = self.client.get(url, {"recherche": "ETE"}) + self.assertQuerySetEqual(response.context["object_list"], [prest2]) + + # Recherche d'un mot sans accent (mot clé avec accent) + response = self.client.get(url, {"recherche": "FÂMÌLLÉ"}) + self.assertQuerySetEqual(response.context["object_list"], [prest1]) + + def test_message_recherche_prestations_sans_resultat(self): + pas_de_prestation = "Aucune prestation saisie" + recherche_vide = "Pas de résultat pour votre recherche." + self.client.force_login(self.user_aemo) + + # Message pour contenu vide sans recherche + response = self.client.get(reverse('journal-list', args=[self.famille.pk])) + self.assertContains(response, pas_de_prestation) + self.assertNotContains(response, recherche_vide) + + # Message pour contenu vide lors d'une recherche + response = self.client.get(reverse('journal-list', args=[self.famille.pk]), {"recherche": "VIDE"}) + self.assertContains(response, recherche_vide) + self.assertNotContains(response, pas_de_prestation) + + +class RapportTests(InitialDataMixin, TestCase): + + @classmethod + def setUpTestData(cls): + super().setUpTestData() + cls.famille = Famille.objects.create_famille(nom='Doe') + cls.educ = Utilisateur.objects.create_user('Educ', 'educ@exemple.org', '123') + cls.educ.user_permissions.add(Permission.objects.get(codename='change_famille')) + + def setUp(self) -> None: + self.create_kwargs = { + 'famille': self.famille, + 'auteur': self.educ, + 'date': date.today(), + 'situation': 'Situation initiale', + 'observations': 'Observation' + } + + def test_create_model(self): + rapport = Rapport.objects.create(**self.create_kwargs) + self.assertIsInstance(rapport, Rapport) + self.assertEqual(str(rapport), f"Résumé du {format_d_m_Y(date.today())} pour la famille Doe - ") + + def test_create_view_with_observations(self): + self.client.force_login(self.educ) + response = self.client.get(reverse('famille-rapport-add', args=[self.famille.pk])) + self.assertContains( + response, + '', + html=True + ) + self.assertNotIn('Évaluation / Hypothèses', response) + self.assertNotIn('Évolutions et observations', response) + + def test_display_rapport_with_observations(self): + rapport = Rapport.objects.create(**self.create_kwargs) + self.client.force_login(self.educ) + response = self.client.get(reverse('famille-rapport-view', args=[self.famille.pk, rapport.pk])) + + self.assertContains(response, '

    Observations, évolution et hypothèses

    ', html=True) + + +class NiveauTests(InitialDataMixin, TestCase): + + def setUp(self) -> None: + self.famille = Famille.objects.get(nom='Haddock') + self.client.force_login(self.user_aemo) + + def test_niveau_model(self): + niv = Niveau.objects.create( + famille=self.famille, + niveau_interv=2, + date_debut=date.today() + ) + self.assertEqual(niv.famille, self.famille) + self.assertEqual(niv.niveau_interv, 2) + self.assertEqual(niv.date_debut, date.today()) + self.assertEqual(niv.date_fin, None) + + def test_niveau_add_view(self): + auj = date.today() + demain = auj + timedelta(days=1) + response = self.client.post( + path=reverse('niveau-add', args=[self.famille.pk]), + data={'niveau_interv': 3, 'date_debut': demain}, + follow=True + ) + self.assertContains(response, f"{format_d_m_Y(demain)}---3", html=True) + + def test_niveau_add_form(self): + auj = date.today() + demain = auj + timedelta(days=1) + form = NiveauForm(famille=self.famille, data={'niveau_interv': 3, 'date_debut': demain}) + self.assertTrue(form.is_valid()) + + def test_niveau_add_second_enregistrement(self): + auj = date.today() + demain = auj + timedelta(days=1) + niv = Niveau.objects.create(famille=self.famille, niveau_interv=2, date_debut=auj, date_fin=None) + self.client.post( + path=reverse('niveau-add', args=[self.famille.pk]), + data={'niveau_interv': 3, 'date_debut': demain}, + follow=True + ) + # Mise à jour dernier enreg. + niv.refresh_from_db() + self.assertEqual(niv.date_fin, auj) + + # Test nouvel enreg. + self.assertEqual(self.famille.niveaux.count(), 2) + der_niv = self.famille.niveaux.last() + self.assertEqual(der_niv.famille, self.famille) + self.assertEqual(der_niv.niveau_interv, 3) + self.assertEqual(der_niv.date_debut, demain) + self.assertEqual(der_niv.date_fin, None) + + def test_niveau_edit_view(self): + auj = date.today() + demain = auj + timedelta(days=1) + niv = Niveau.objects.create(famille=self.famille, niveau_interv=2, date_debut=auj, date_fin=None) + self.client.post( + path=reverse('niveau-edit', args=[self.famille.pk, niv.pk]), + data={'niveau_interv': 3, 'date_debut': demain}, + follow=True + ) + niv.refresh_from_db() + self.assertEqual(niv.niveau_interv, 3) + + def test_niveau_edit_form(self): + auj = date.today() + demain = auj + timedelta(days=1) + niv = Niveau.objects.create(famille=self.famille, niveau_interv=2, date_debut=auj, date_fin=None) + form = NiveauForm(famille=self.famille, instance=niv, data={'niveau_interv': 3, 'date_debut': demain}) + self.assertTrue(form.is_valid()) + form.save() + niv.refresh_from_db() + self.assertEqual(niv.niveau_interv, 3) + + def test_niveau_delete(self): + auj = date.today() + niv = Niveau.objects.create(famille=self.famille, niveau_interv=2, date_debut=auj, date_fin=None) + response = self.client.post( + path=reverse('niveau-delete', args=[self.famille.pk, niv.pk]), + follow=True + ) + self.assertEqual(len(response.context['niveaux']), 0) + + def test_niveau_affichage_dans_agenda(self): + auj = date.today() + Niveau.objects.create( + famille=self.famille, niveau_interv=2, date_debut=auj - timedelta(days=10), date_fin=None + ) + agenda_url = reverse('famille-agenda', args=[self.famille.pk]) + response = self.client.get(agenda_url) + self.assertContains(response, 'Niv. d’intervention 2', html=True) + # Fin niveau affiché au plus tard à fin du suivi. + self.famille.suivi.date_fin_suivi = auj + self.famille.suivi.save() + response = self.client.get(agenda_url) + self.assertEqual(response.context['niveaux'][0].date_fin_calc, auj) + + def test_debut_suivi_selon_niveau(self): + """Début du suivi peut varier selon changement de niveau 2/3.""" + auj = date.today() + self.famille.suivi.date_debut_suivi = auj - timedelta(days=20) + self.famille.suivi.save() + Niveau.objects.create( + famille=self.famille, niveau_interv=2, + date_debut=auj - timedelta(days=20), date_fin=auj - timedelta(days=10) + ) + Niveau.objects.create( + famille=self.famille, niveau_interv=3, + date_debut=auj - timedelta(days=10), date_fin=None + ) + self.assertEqual(self.famille.suivi.debut_suivi_selon_niveau, auj - timedelta(days=10)) + + +class UtilisateurTests(InitialDataMixin, TestCase): + + def test_utilisateur_list(self): + self.client.force_login(self.user_admin) + response = self.client.get(reverse('utilisateur-list')) + self.assertEqual(len(response.context['object_list']), Utilisateur.objects.count()) + + def test_create_utilisateur(self): + self.client.force_login(self.user_admin) + response = self.client.post(reverse('utilisateur-add'), data={ + 'nom': 'Muller', + 'prenom': 'Hans', + 'username': 'MullerH', + 'sigle': 'HM', + 'groups': [Group.objects.create(name='grp1').pk, Group.objects.create(name='grp2').pk], + 'taux_activite': 100, + 'decharge': 2, + }) + self.assertEqual(response.status_code, 302) + user = Utilisateur.objects.get(nom='Muller') + self.assertEqual(user.service.sigle, 'CRNE') + self.assertEqual(user.username, 'MullerH') + self.assertEqual(user.groups.count(), 2) + self.assertEqual(user.charge_max, 30) + + def test_taux_activite(self): + data = { + 'nom': 'Muller', + 'prenom': 'Hans', + 'username': 'MullerH', + 'sigle': 'HM', + 'taux_activite': 90, + } + form = UtilisateurForm(data=data) + self.assertTrue(form.is_valid()) + + form = UtilisateurForm(data={**data, 'taux_activite': 110}) + self.assertFalse(form.is_valid()) + self.assertEqual( + form.errors['taux_activite'], + ['Assurez-vous que cette valeur est inférieure ou égale à 100.'] + ) + + def test_delete_utilisateur(self): + user = Utilisateur.objects.create_user(username='user1', nom='Supprimer') + self.client.force_login(self.user_admin) + self.client.post(reverse('utilisateur-delete', args=[user.pk])) + user.refresh_from_db() + self.assertIsNotNone(user.date_desactivation) + self.assertFalse(user.is_active) + self.assertFalse(user.est_actif) + + response = self.client.get(reverse('utilisateur-autocomplete') + '?q=Sup') + self.assertEqual(response.json()['results'], []) + + @override_settings(PASSWORD_HASHERS=PASSWORD_HASHERS) + def test_reinit_password(self): + Utilisateur.objects.create_superuser( + 'somebody', 'somebody@example.org', 'somebodypassword', prenom='Jean', nom='Valjean', + ) + new_user = Utilisateur.objects.create_user(username='user') + self.assertTrue(new_user.password.startswith('!')) # Unusable password + self.client.login(username='somebody', password='somebodypassword') + self.client.post(reverse('utilisateur-password-reinit', args=[new_user.pk])) + new_user.refresh_from_db() + self.assertTrue(new_user.password.startswith('pbkdf2_sha256$')) # Usable password + + def test_reinit_mobile(self): + from django_otp.plugins.otp_totp.models import TOTPDevice + user = Utilisateur.objects.create_user(username='user', nom="Spontz") + TOTPDevice.objects.create(user=user, name='default') + self.client.force_login(self.user_admin) + response = self.client.post(reverse('utilisateur-otp-device-reinit', args=[user.pk]), follow=True) + self.assertEqual(str(list(response.context['messages'])[0]), 'Le mobile de «Spontz » a été réinitialisé.') + response = self.client.post(reverse('utilisateur-otp-device-reinit', args=[user.pk]), follow=True) + self.assertEqual( + str(list(response.context['messages'])[0]), + 'Aucune configuration mobile trouvée pour «Spontz »' + ) + + def test_liste_utilisateurs_actifs_inactifs(self): + user1 = Utilisateur.objects.create_user( + 'User1P', 'user1@exemple.org', 'user1_password', prenom='Paul', nom='User1', + ) + Utilisateur.objects.create_user( + 'User2M', 'user2@exemple.org', 'user2_password', prenom='Max', nom='User2', + ) + self.client.force_login(self.user_admin) + response = self.client.get(reverse('utilisateur-list')) + self.assertContains(response, 'User1 Paul') + self.assertContains(response, 'User2 Max') + self.client.post(reverse('utilisateur-delete', args=[user1.pk])) + response = self.client.get(reverse('utilisateur-list')) + self.assertNotContains(response, 'User1 Paul') + response = self.client.get(reverse('utilisateur-desactive-list')) + self.assertContains(response, 'User1 Paul') + + def test_reactivation_utilisateur(self): + user1 = Utilisateur.objects.create_user( + 'User1P', 'user1@exemple.org', 'user1_password', prenom='Paul', nom='User1', + ) + Utilisateur.objects.create_user( + 'User2M', 'user2@exemple.org', 'user2_password', prenom='Max', nom='User2', + ) + self.client.force_login(self.user_admin) + response = self.client.post(reverse('utilisateur-delete', args=[user1.pk]), follow=True) + self.assertNotContains(response, 'User1 Paul') + user1.refresh_from_db() + self.assertIsNotNone(user1.date_desactivation) + response = self.client.post(reverse('utilisateur-reactiver', args=[user1.pk]), follow=True) + self.assertContains(response, 'User1 Paul') + user1.refresh_from_db() + self.assertIsNone(user1.date_desactivation) + + +class OtherTests(InitialDataMixin, TestCase): + def setUp(self): + self.client.force_login(self.user_aemo) + + def test_contact_list(self): + response = self.client.get(reverse('contact-list')) + self.assertEqual(len(response.context['object_list']), 3) + response = self.client.get(reverse('contact-list') + '?service=%s' % Service.objects.get(sigle='OPEN').pk) + self.assertEqual(len(response.context['object_list']), 1) + response = self.client.get(reverse('contact-list') + '?role=%s' % Role.objects.get(nom='Médecin').pk) + self.assertEqual(len(response.context['object_list']), 1) + response = self.client.get(reverse('contact-list') + '?texte=barn') + self.assertEqual(len(response.context['object_list']), 1) + + def test_contact_autocomplete(self): + medecin = Role.objects.get(nom='Médecin') + ope_service = Service.objects.create(sigle="OPEC") + other_service = Service.objects.get(sigle="SSE") + contact_ope = Contact.objects.create( + nom="Duplain", prenom="Irma", service=ope_service + ) + contact_ope.roles.add(medecin) + contact_other = Contact.objects.create( + nom="Dupont", prenom="Paul", service=other_service + ) + contact_other.roles.add(medecin) + response = self.client.get(reverse('contact-autocomplete') + '?q=Dup') + self.assertEqual( + [res['text'] for res in response.json()['results']], + ['Duplain Irma (OPEC)', 'Dupont Paul (SSE)'] + ) + # The OPE version + response = self.client.get(reverse('contact-ope-autocomplete') + '?q=Dup') + self.assertEqual( + [res['text'] for res in response.json()['results']], + ['Duplain Irma (OPEC)'] + ) + + def test_supression_affichage_du_contact_desactive_dans_contact_autocomplete(self): + medecin = Role.objects.get(nom='Médecin') + ope_service = Service.objects.create(sigle="OPEC") + other_service = Service.objects.get(sigle="SSE") + contact_ope = Contact.objects.create( + nom="Duplain", prenom="Irma", service=ope_service, est_actif=False + ) + contact_ope.roles.add(medecin) + contact_other = Contact.objects.create( + nom="Dupont", prenom="Paul", service=other_service + ) + contact_other.roles.add(medecin) + response = self.client.get(reverse('contact-autocomplete') + '?q=Dup') + self.assertEqual( + [res['text'] for res in response.json()['results']], + ['Dupont Paul (SSE)'] + ) + + def test_suppression_affichage_contact_desactive_dans_list_contact(self): + medecin = Role.objects.get(nom='Médecin') + ope_service = Service.objects.create(sigle="OPEC") + other_service = Service.objects.get(sigle="SSE") + contact_ope = Contact.objects.create( + nom="Duplain", prenom="Irma", service=ope_service, est_actif=False + ) + contact_ope.roles.add(medecin) + contact_other = Contact.objects.create( + nom="Dupont", prenom="Paul", service=other_service + ) + contact_other.roles.add(medecin) + response = self.client.get(reverse('contact-list')) + self.assertNotContains(response, 'Duplain') + + def test_controler_doublon_contact(self): + medecin = Role.objects.get(nom='Médecin') + contact1 = Contact.objects.create( + nom="Duplain", prenom="Irma", est_actif=True + ) + contact1.roles.add(medecin) + doublon_url = reverse('contact-doublon') + response = self.client.post(doublon_url, data={'nom': "Duplain", 'prenom': "Irma"}) + self.assertEqual(response.json(), [{'nom': "Duplain", 'prenom': "Irma"}]) + response = self.client.post(doublon_url, data={'nom': "Nouveau", 'prenom': "Contact"}) + # Réponse vide signifie pas de doublon détecté. + self.assertEqual(response.json(), '') + + def test_service_creation(self): + s1 = Service.objects.create(sigle='lower') # transform code from lower to uppercase + self.assertEqual(s1.sigle, 'LOWER') + # By form + self.client.force_login(self.user_admin) + response = self.client.post( + reverse('service-add'), data={'sigle': 'SERVICE', 'nom_complet': 'Super service'} + ) + self.assertRedirects(response, reverse('service-list')) + self.assertEqual(Service.objects.filter(sigle='SERVICE').count(), 1) + + def test_service_list(self): + response = self.client.get(reverse('service-list')) + self.assertEqual(len(response.context['object_list']), 3) + + def test_raise_unique_constraint_school_center_creation(self): + sc = CercleScolaire(nom='EOREN-MAIL') + self.assertRaises(IntegrityError, sc.save) + + def test_cerclescolaire_list(self): + response = self.client.get(reverse('cercle-list')) + self.assertEqual(len(response.context['object_list']), 2) + + def test_role_create(self): + self.client.force_login(self.user_admin) + response = self.client.post(reverse('role-add'), data={'nom': 'ROLE1', 'famille': False}) + self.assertRedirects(response, reverse('role-list')) + self.assertEqual(Role.objects.filter(nom='ROLE1').count(), 1) + + def test_role_list(self): + response = self.client.get(reverse('role-list')) + self.assertGreater(len(response.context['object_list']), 10) + # Les rôles "protégés" ne sont pas éditables. + self.assertContains(response, 'Enfant suivi') + + def test_delete_used_role(self): + """A role with at least one attached Personne cannot be deleted.""" + role = Role.objects.get(nom="Père") + Personne.objects.create_personne( + famille=Famille.objects.create(nom='Schmurz'), role=role, + nom='Haddock', prenom='Archibald', genre='M', date_naissance=date(1956, 2, 16), + ) + self.assertGreater(role.personne_set.count(), 0) + self.client.force_login(self.user_admin) + response = self.client.post(reverse('role-delete', args=[role.pk]), follow=True) + self.assertContains(response, "Cannot delete") + + def test_truncate_with_more_ttag(self): + from aemo.templatetags.my_tags import truncate_html_with_more + txt = '

    Ceci est un très long texte HTML.
    Seconde ligne

    ' + self.assertHTMLEqual( + truncate_html_with_more(txt, 3), + '' + '

    Ceci est un…

    ' + 'Afficher la suite' + ) + + def test_info_ope_ttag(self): + from aemo.templatetags.my_tags import info_ope + self.assertEqual( + info_ope(Contact.objects.get(nom='Sybarnez')), + 'Sybarnez Tina' + ) + + def test_check_date_allowed(self): + with freeze_time("2021-01-20"): + self.assertFalse(Prestation.check_date_allowed(self.user_aemo, date(2020, 1, 20))) + with freeze_time("2021-01-04"): + self.assertFalse(Prestation.check_date_allowed(self.user_aemo, date(2020, 11, 30))) + self.assertTrue(Prestation.check_date_allowed(self.user_aemo, date(2020, 12, 2))) + self.assertTrue(Prestation.check_date_allowed(self.user_aemo, date(2021, 1, 2))) + + with freeze_time("2020-11-02"): + self.assertFalse(Prestation.check_date_allowed(self.user_aemo, date(2019, 10, 15))) + self.assertTrue(Prestation.check_date_allowed(self.user_aemo, date(2020, 10, 15))) + self.assertTrue(Prestation.check_date_allowed(self.user_aemo, date(2020, 11, 12))) + + def test_duration_field(self): + class SomeForm(Form): + duration = HMDurationField() + + form = SomeForm({'duration': timedelta(days=1, hours=2, minutes=21, seconds=4)}) + self.assertInHTML( + '', + form.as_div() + ) + + +class TestExporter(ExportReporting): + """A fake exporter class that just collect data in lists to be able to assert the contents.""" + def __init__(self): + super().__init__() + self.sheets = {} + self._current_sh = None + + def __call__(self): + # This allows to provide an instance to mock + return self + + def setup_sheet(self, title): + self.sheets[title] = [] + self._current_sh = self.sheets[title] + + def write_line(self, values, **kwargs): + self._current_sh.append(values) + + +class StatTests(TestCase): + @classmethod + def setUpTestData(cls): + group_aemo = Group.objects.create(name='aemo') + cls.user = Utilisateur.objects.create( + username='me', first_name='Jean', last_name='Valjean', + ) + cls.user_admin = Utilisateur.objects.create(username='admin') + cls.user_admin.user_permissions.add(Permission.objects.get(codename='export_stats')) + user_haut = Utilisateur.objects.create(username='ld', prenom='Lise', nom="Duhaut") + user_haut.groups.add(group_aemo) + user_bas = Utilisateur.objects.create(username='jd', prenom='Jean', nom="Dubas") + user_bas.groups.add(group_aemo) + + role_referent = Role.objects.create(nom='Référent', est_famille=False) + cls.enf_suivi = Role.objects.create(nom='Enfant suivi', est_famille=True) + cls.role_pere = Role.objects.create(nom='Père', est_famille=True) + cls.role_mere = Role.objects.create(nom='Mère', est_famille=True) + cls.famille_litt = Famille.objects.create_famille( + equipe='littoral', + nom='Haddock', rue='Château1', npa=2000, localite='Moulinsart', + ) + Personne.objects.create_personne( + famille=cls.famille_litt, role=cls.enf_suivi, + nom='Haddock', prenom='Archibald', genre='M', date_naissance=date(1996, 2, 16) + ) + Personne.objects.create_personne( + famille=cls.famille_litt, role=cls.enf_suivi, + nom='Haddock', prenom='Honorine', genre='F', date_naissance=date(1999, 11, 2) + ) + cls.famille_litt.suivi.date_demande = '2019-01-01' + cls.famille_litt.suivi.intervenants.add(user_bas, through_defaults={'role': role_referent}) + cls.famille_litt.suivi.save() + + cls.famille_mont = Famille.objects.create_famille( + equipe='montagnes', + nom='Tournesol', rue='Château1', npa=2000, localite='Moulinsart', + ) + Personne.objects.create_personne( + famille=cls.famille_mont, role=cls.enf_suivi, + nom='Tournesol', prenom='Tryphon', genre='M', date_naissance=date(1991, 1, 3) + ) + cls.famille_mont.suivi.date_demande = '2019-01-01' + cls.famille_mont.suivi.intervenants.add(user_haut, through_defaults={'role': role_referent}) + cls.famille_mont.suivi.save() + LibellePrestation.objects.bulk_create([ + LibellePrestation(code='aemo01', nom='Évaluation AEMO'), + LibellePrestation( code='aemo04', nom='Activités ASE'), + ]) + + def test_accueil_statistiques(self): + self.client.force_login(self.user_admin) + response = self.client.get(reverse('stats')) + self.assertContains(response, 'Statistiques du 1er') + + @freeze_time("2020-12-04") + def test_accueil_statistiques_decembre(self): + self.client.force_login(self.user_admin) + response = self.client.get(reverse('stats')) + self.assertEqual(response.context['date_form'].data['end_month'], 1) + self.assertEqual(response.context['date_form'].data['end_year'], 2021) + + def test_temps_total_prestations(self): + """ + Test Famille.temps_total_prestations()/temps_total_prestations_reparti(), + Test Utilisateur.temps_total_prestations() + """ + self.assertEqual(self.famille_litt.temps_total_prestations(), timedelta(0)) + auj = date.today() + mois_sui1 = date(auj.year, auj.month, 3) + mois_sui2 = date(auj.year, auj.month, 4) + user2 = Utilisateur.objects.create( + username='you', first_name='Hans', last_name='Zwei', + ) + user2.groups.add(Group.objects.get(name='aemo')) + p1 = Prestation.objects.create( + auteur=self.user, + famille=self.famille_litt, + date_prestation=auj, + duree='0:45' + ) + p1.intervenants.set([self.user]) + p2 = Prestation.objects.create( + auteur=self.user, + famille=self.famille_litt, + date_prestation=mois_sui1, + duree='1:0' + ) + # Chaque intervenant saisit ses propres prestations + p2.intervenants.set([self.user]) + p3 = Prestation.objects.create( + auteur=self.user, + famille=self.famille_litt, + date_prestation=mois_sui2, + duree='1:05' + ) + p3.intervenants.set([user2]) + + # Avec ce processus, la même prestation peut avoir des durées différentes!!! + + # p1 (00:45) + 2 interv. x p2 (1:00) >> p1(00:45) + p2(1:00) + p3(1:05) = 2:50 + self.assertEqual(self.famille_litt.temps_total_prestations(), timedelta(hours=2, minutes=50)) + self.assertEqual(self.famille_litt.temps_total_prestations_reparti(), timedelta(hours=1, minutes=25)) + # self.user = p1 (00:45) + p2 (1:00) = 1:45 + # user2 = p3(1:05) + self.assertEqual(format_duree(self.user.temps_total_prestations('aemo')), '01:45') + self.assertEqual(format_duree(user2.temps_total_prestations('aemo')), '01:05') + + def test_stats(self): + auj = date.today() + mois_sui1 = date(auj.year, auj.month, 3) + user_bas = Utilisateur.objects.get(nom='Dubas') + p = Prestation.objects.create( + auteur=self.user, + famille=self.famille_litt, + date_prestation=auj, + duree='0:45' + ) + p.intervenants.set([user_bas]) + p = Prestation.objects.create( + auteur=self.user, + famille=self.famille_litt, + date_prestation=mois_sui1, + duree='1:00' + ) + p.intervenants.set([user_bas]) + p = Prestation.objects.create( + auteur=self.user, + famille=self.famille_litt, + date_prestation=auj, + duree='0:00', manque=True, + ) + # Cette famille est du littoral, mais n'a pas Lise Dubas comme référente + Famille.objects.create_famille( + equipe='littoral', + nom='Tintin', rue='Château1', npa=2000, localite='Moulinsart', + ) + months = [Month(date.today().year, date.today().month)] + stats = StatistiquesView( + date_start=date.today().replace(day=1), + date_end=(date.today().replace(day=1) + timedelta(days=35)).replace(day=1), + ).get_stats(months) + self.assertEqual(stats['familles']['familles_evaluees']['total'], 3) + self.assertEqual(stats['familles']['enfants_evalues']['total'], 3) + self.assertEqual(stats['familles']['rdv_manques']['total'], 1) + + def test_total_mensuel(self): + """Test Famille.total_mensuel() method.""" + famille = Famille.objects.get(nom='Haddock') + auj = date.today() + mois_suiv = auj + timedelta(days=31) + self.assertEqual(famille.total_mensuel(auj), timedelta(0)) + Prestation.objects.bulk_create([ + Prestation( + auteur=self.user, famille=famille, date_prestation=date(auj.year, auj.month, 1), duree='1:30' + ), + Prestation( + auteur=self.user, famille=famille, date_prestation=date(auj.year, auj.month, 25), duree='0:15' + ), + # Not included in this month + Prestation( + auteur=self.user, famille=famille, date_prestation=date(mois_suiv.year, mois_suiv.month, 1), + duree='0:15' + ), + ]) + self.assertEqual(famille.total_mensuel(auj), timedelta(minutes=105)) + + def test_total_prestations(self): + famille = Famille.objects.get(nom='Haddock') + user = Utilisateur.objects.get(nom='Duhaut') + my_group = Group.objects.get(name='aemo') + my_group.user_set.add(user) + prestation_data = { + 'auteur': self.user, 'famille': famille, 'date_prestation': date(2019, 3, 1), + 'lib_prestation': LibellePrestation.objects.get(code='aemo01'), + } + Prestation.objects.bulk_create([ + Prestation(**{**prestation_data, 'duree': '3:40'}), + Prestation(**{**prestation_data, 'duree': '6:40'}), + # Not included in this month + Prestation(**{**prestation_data, 'date_prestation': date(2019, 4, 1), 'duree': '2:00'}), + ]) + for p in Prestation.objects.filter(famille=famille): + p.intervenants.set([user]) + + self.assertEqual(format_duree(user.total_mensuel('aemo', 3, 2019)), '10:20') + self.assertEqual(format_duree(user.totaux_mensuels('aemo', 2019)[3]), '02:00') + self.assertEqual(format_duree(user.total_annuel('aemo', 2019)), '12:20') + totaux_2019 = Prestation.temps_totaux_mensuels(2019) + self.assertEqual(totaux_2019[0], timedelta()) + self.assertEqual(format_duree(totaux_2019[2]), '10:20') + self.assertEqual(format_duree(totaux_2019[3]), '02:00') + self.assertEqual(format_duree(Prestation.temps_total_general(2019)), '12:20') + + user.user_permissions.add(Permission.objects.get(codename='export_stats')) + self.client.force_login(user) + exp = TestExporter() + with patch('aemo.views.ExportReporting', new=exp): + self.client.post(reverse('export-prestation'), {'mois': '3', 'annee': '2019'}) + self.assertEqual(format_duree(exp._total_spe['eval']), '10:20') + + def test_export_prestations_decembre(self): + self.client.force_login(self.user_admin) + response = self.client.post(reverse('export-prestation'), {'mois': '12', 'annee': '2019'}) + self.assertEqual(response.status_code, 200) + + @freeze_time("2020-12-14") + def test_affichage_prestation(self): + famille = Famille.objects.get(nom='Tournesol') + auj = date.today() + self.assertEqual(famille.total_mensuel(auj), timedelta(0)) + user_haut = Utilisateur.objects.get(nom='Duhaut') + user_haut.taux_activite = 100.0 + user_haut.save() + Prestation.objects.bulk_create([ + Prestation(auteur=user_haut, famille=famille, + date_prestation=date(auj.year, 3, 1), duree='1:45'), + Prestation(auteur=user_haut, famille=famille, + date_prestation=date(auj.year, 5, 25), duree='0:15'), + # Not included in this month + Prestation(auteur=user_haut, famille=famille, + date_prestation=date(auj.year, 7, 1), duree='0:15'), + ]) + for p in Prestation.objects.filter(famille=famille): + p.intervenants.set([user_haut]) + + self.user.user_permissions.add(Permission.objects.get(codename='export_stats')) + self.client.force_login(self.user) + response = self.client.get(reverse('stats-prestations')) + data = response.context['intervenants'][user_haut] + self.assertEqual(format_duree(data['heures_prestees'][2]), '01:45') + self.assertEqual(format_duree(data['heures_prestees'][4]), '00:15') + self.assertEqual(format_duree(data['heures_prestees'][6]), '00:15') + + self.assertEqual(format_duree(data['tot_prestees']), '02:15') + self.assertEqual(format_duree(response.context['totaux_prest_mensuels'][Month(auj.year, 3)]['total']), '01:45') + self.assertEqual(format_duree(response.context['total_gen']), '02:15') + + @skip("This statistic is currently deactivated") + def test_stats_interv(self): + last_year = date.today().year - 1 + famille = Famille.objects.get(nom='Haddock') + famille.suivi.date_debut_suivi = date(last_year, 2, 1) + famille.suivi.save() + user_bas = Utilisateur.objects.get(nom='Dubas') + self.client.force_login(self.user_admin) + params = '&'.join( + f'{key}={val}' for key, val in { + 'start_year': last_year, 'start_month': '3', 'end_year': last_year, 'end_month': '5' + }.items() + ) + response = self.client.get(reverse('stats-interv') + '?' + params) + self.assertEqual(response.context['interv_spe'][user_bas]['num_familles']['total'], 1) + + def test_stats_par_niveau(self): + auj = date.today() + Niveau.objects.create( + famille=self.famille_litt, niveau_interv=2, date_debut=auj - timedelta(days=1) + ) + # Niveau obsolète + Niveau.objects.create( + famille=self.famille_litt, niveau_interv=1, + date_debut=auj - timedelta(days=360), date_fin=auj - timedelta(days=100) + ) + Prestation.objects.create( + famille=self.famille_litt, auteur=self.user, date_prestation=auj, + lib_prestation=LibellePrestation.objects.get(code='aemo04'), + duree=timedelta(minutes=90) + ) + + ilya2mois = auj - timedelta(days=60) + params = '&'.join( + f'{key}={val}' for key, val in { + 'start_year': ilya2mois.year, 'start_month': ilya2mois.month, + 'end_year': auj.year, 'end_month': auj.month, + }.items() + ) + + self.client.force_login(self.user_admin) + response = self.client.get(reverse('stats-niveaux') + '?' + params) + self.assertEqual(response.status_code, 200) + self.assertEqual( + response.context['stats'][2]['aemo04'][Month.from_date(auj)], timedelta(minutes=90) + ) + self.assertEqual( + response.context['stats'][2]['aemo04']['total'], timedelta(minutes=90) + ) + + +class ExportTests(InitialDataMixin, TestCase): + + def setUp(self): + self.client.force_login(self.user_admin) + self.cfg_date = date(2019, 10, 3) + self.cfg_sheet = 'En_cours_{}.{}'.format(self.cfg_date.month, self.cfg_date.year) + self.cfg_export_month = {'mois': self.cfg_date.month, 'annee': self.cfg_date.year} + + self.entetes_communs = [ + 'Institution', 'Prestation', 'Nom', 'Prenom', 'Genre', 'Date de naissance', 'Adresse', 'NPA', 'Localité', + 'Canton', 'OPE', 'Nom mère', 'Prénom mère', 'Nom père', 'Prénom père', 'Autorité parentale', + 'Statut marital', 'Statut financier', 'Fam. monopar.', 'Nbre enfants', + 'Date demande', 'Provenance', 'Motif demande', 'Début suivi' + ] + + self.entetes_nouveaux = self.entetes_communs + self.entetes_en_cours = self.entetes_communs + ['H. Évaluation', 'H. Suivi', 'H. Prest. gén.'] + self.entetes_termines = (self.entetes_communs + + ['Date fin suivi', 'Motif fin suivi', 'Destination', 'Total heures']) + + self.enf_suivi = Role.objects.get(nom='Enfant suivi', est_famille=True) + self.role_pere = Role.objects.get(nom='Père', est_famille=True) + self.role_mere = Role.objects.get(nom='Mère', est_famille=True) + + def test_entete_nouveux_suivis(self): + exp = TestExporter() + with patch('aemo.views.ExportReporting', new=exp): + self.client.post(reverse('export-prestation'), {'mois': '10', 'annee': '2019'}) + sheet_name = 'Nouveaux_10.2019' + self.assertEqual(len(exp.sheets[sheet_name]), 1) + self.assertEqual(exp.sheets[sheet_name][0], self.entetes_nouveaux) + + def test_entete_suivis_en_cours(self): + exp = TestExporter() + with patch('aemo.views.ExportReporting', new=exp): + self.client.post(reverse('export-prestation'), {'mois': '10', 'annee': '2019'}) + sheet_name = 'En_cours_10.2019' + self.assertEqual(len(exp.sheets[sheet_name]), 1) + self.assertEqual(exp.sheets[sheet_name][0], self.entetes_en_cours) + + def test_entete_suivis_termines(self): + exp = TestExporter() + with patch('aemo.views.ExportReporting', new=exp): + self.client.post(reverse('export-prestation'), {'mois': '10', 'annee': '2019'}) + sheet_name = 'Terminés_10.2019' + self.assertEqual(len(exp.sheets[sheet_name]), 1) + self.assertEqual(exp.sheets[sheet_name][0], self.entetes_termines) + + def test_export_prestations_decembre(self): + self.client.force_login(self.user_admin) + response = self.client.post(reverse('export-prestation'), {'mois': '12', 'annee': '2019'}) + self.assertEqual(response.status_code, 200) + + def test_date_nouveau_suivi(self): + fam1 = self.create_famille(name='Haddock') + fam1.suivi.date_demande = '2019-09-20' + fam1.suivi.save() + + fam2 = self.create_famille(name='Tournesol') + fam2.suivi.date_demande = '2019-10-20' + fam2.suivi.save() + + fam3 = self.create_famille(name='Castafiore') + fam3.suivi.date_demande = '2019-11-20' + fam3.suivi.save() + + exp = TestExporter() + with patch('aemo.views.ExportReporting', new=exp): + self.client.post(reverse('export-prestation'), {'mois': '10', 'annee': '2019'}) + sheet_name = 'Nouveaux_10.2019' + self.assertEqual(len(exp.sheets[sheet_name]), 2) + self.assertEqual([line[2] for line in exp.sheets[sheet_name]], ['Nom', 'Tournesol']) + + def test_date_suivi_en_cours(self): + fam1 = self.create_famille(name='Haddock') + fam1.suivi.date_demande = '2019-09-20' + fam1.suivi.save() + + fam2 = self.create_famille(name='Tournesol') + fam2.suivi.date_demande = '2019-10-20' + fam2.suivi.save() + + fam3 = self.create_famille(name='Castafiore') + fam3.suivi.date_demande = '2019-11-20' + fam3.suivi.save() + + self.benef1 = dict(nom='Haddock', date_admission='2019-10-02') + self.benef2 = dict(nom='Tournesol', date_admission='2019-09-30') + + def test_date_suivi_termine(self): + fam1 = self.create_famille(name='Haddock') + fam1.suivi.date_demande = '2019-09-20' + fam1.suivi.date_fin_suivi = '2019-10-31' + fam1.suivi.save() + + fam2 = self.create_famille(name='Tournesol') + fam2.suivi.date_demande = '2019-10-20' + fam2.suivi.save() + + fam3 = self.create_famille(name='Castafiore') + fam3.suivi.date_demande = '2019-11-20' + fam3.suivi.date_fin_suivi = '2019-12-31' + fam3.suivi.save() + + exp = TestExporter() + with patch('aemo.views.ExportReporting', new=exp): + self.client.post(reverse('export-prestation'), {'mois': '10', 'annee': '2019'}) + sheet_name = 'Terminés_10.2019' + self.assertEqual(len(exp.sheets[sheet_name]), 2) + self.assertEqual([line[2] for line in exp.sheets[sheet_name]], ['Nom', 'Haddock']) + + def test_un_enfant_par_ligne(self): + fam = self.create_famille() + fam.suivi.date_demande = '2019-10-20' + fam.suivi.save() + Personne.objects.create_personne( + famille=fam, role=Role.objects.get(nom='Enfant suivi'), + nom='Haddock', prenom='Ursule', genre='M', date_naissance=date(2008, 11, 6), + ) + + exp = TestExporter() + with patch('aemo.views.ExportReporting', new=exp): + self.client.post(reverse('export-prestation'), {'mois': '10', 'annee': '2019'}) + sheet_name = 'Nouveaux_10.2019' + self.assertEqual(len(exp.sheets[sheet_name]), 3) + self.assertEqual( + [(line[2], line[3]) for line in exp.sheets['Nouveaux_10.2019']], + [('Nom', 'Prenom'), ('Haddock', 'Ursule'), ('Haddock', 'Toto')] + ) + + def test_repartition_temps_total_prestation_mensuel_entre_enfants(self): + fam = self.create_famille() + fam.suivi.date_demande = '2019-10-20' + fam.suivi.save() + Personne.objects.create_personne( + famille=fam, role=Role.objects.get(nom='Enfant suivi'), + nom='Haddock', prenom='Ursule', genre='M', date_naissance=date(2008, 11, 6), + ) + aemo01 = LibellePrestation.objects.get(code='aemo01') + Prestation.objects.bulk_create([ + Prestation( + auteur=self.user_aemo, famille=fam, date_prestation=date(2019, 10, 21), lib_prestation=aemo01, duree='3:40' + ), + Prestation( + auteur=self.user_aemo, famille=fam, date_prestation=date(2019, 10, 22), lib_prestation=aemo01, duree='6:40' + ), + Prestation( + auteur=self.user_aemo, famille=fam, date_prestation=date(2019, 10, 30), lib_prestation=aemo01, duree='2:00' + ), + ]) + for prest in Prestation.objects.all(): + prest.intervenants.add(self.user_aemo) + + exp = TestExporter() + with patch('aemo.views.ExportReporting', new=exp): + self.client.post(reverse('export-prestation'), {'mois': '10', 'annee': '2019'}) + sheet_name = 'En_cours_10.2019' + self.assertEqual(len(exp.sheets[sheet_name]), 3) + self.assertEqual( + [(line[2], line[3], line[24]) for line in exp.sheets[sheet_name]], + [('Nom', 'Prenom', 'H. Évaluation'), + ('Haddock', 'Ursule', timedelta(hours=6, minutes=10)), + ('Haddock', 'Toto', timedelta(hours=6, minutes=10)), + ] + ) + + def test_export_nouvelle_famille(self): + fam = self.create_famille() + fam.suivi.date_demande = '2019-10-20' + fam.suivi.date_debut_suivi = '2019-10-21' + fam.suivi.motif_demande = ['integration'] + fam.suivi.save() + Prestation.objects.bulk_create([ + Prestation(auteur=self.user_aemo, famille=fam, date_prestation=date(2019, 10, 21), duree='3:40'), + Prestation(auteur=self.user_aemo, famille=fam, date_prestation=date(2019, 10, 22), duree='6:40'), + # Not included in this month + Prestation(auteur=self.user_aemo, famille=fam, date_prestation=date(2019, 11, 1), duree='2:00'), + ]) + self.assertEqual(Prestation.objects.filter(famille=fam).count(), 3) + for p in Prestation.objects.filter(famille=fam): + p.intervenants.set([self.user_aemo]) + + exp = TestExporter() + with patch('aemo.views.ExportReporting', new=exp): + self.client.post(reverse('export-prestation'), {'mois': '10', 'annee': '2019'}) + sheet_name = 'Nouveaux_10.2019' + self.assertEqual(len(exp.sheets[sheet_name]), 2) + self.assertEqual( + exp.sheets[sheet_name][1], + ['Fondation Transit', 'AEMO', 'Haddock', 'Toto', 'M', '16.02.2010', 'Château1', '2000', 'Moulinsart', 'NE', + 'Sybarnez Tina', '', '', 'Haddock', 'Archibald', 'Conjointe', 'Divorcé', '', 'NON', 1, + '20.10.2019', '', 'Aide à l’intégration', '21.10.2019'] + ) + + def test_export_total_prestation_mois_courant(self): + fam = self.create_famille() + fam.suivi.date_demande = '2019-10-20' + fam.suivi.save() + aemo01 = LibellePrestation.objects.get(code='aemo01') + Prestation.objects.bulk_create([ + Prestation( + auteur=self.user_aemo, famille=fam, date_prestation=date(2019, 10, 21), lib_prestation=aemo01, duree='3:40' + ), + Prestation( + auteur=self.user_aemo, famille=fam, date_prestation=date(2019, 10, 22), lib_prestation=aemo01, duree='6:40' + ), + # Not included in this month + Prestation( + auteur=self.user_aemo, famille=fam, date_prestation=date(2019, 11, 1), lib_prestation=aemo01, duree='2:00' + ), + ]) + for p in Prestation.objects.filter(famille=fam): + p.intervenants.set([self.user_aemo]) + + exp = TestExporter() + with patch('aemo.views.ExportReporting', new=exp): + self.client.post(reverse('export-prestation'), {'mois': '10', 'annee': '2019'}) + self.assertEqual(len(exp.sheets['En_cours_10.2019']), 2) + self.assertEqual(exp.sheets['En_cours_10.2019'][1][24], timedelta(hours=10, minutes=20)) + + def test_export_prest_gen(self): + familles = [self.create_famille('Fam_{}'.format(i)) for i in range(5)] + for famille in familles: + famille.suivi.date_demande = date(2019, 10, 1) + famille.suivi.save() + prestation_data = { + 'auteur': self.user_aemo, 'famille': None, 'date_prestation': date(2019, 10, 21), + 'lib_prestation': LibellePrestation.objects.get(code='aemo03') + } + Prestation.objects.create(**{**prestation_data, 'duree': '6:50'}) + Prestation.objects.create(**{**prestation_data, 'duree': '3:50'}) + + for prest in Prestation.objects.all(): + prest.intervenants.add(self.user_aemo) + + exp = TestExporter() + with patch('aemo.views.ExportReporting', new=exp): + self.client.post(reverse('export-prestation'), self.cfg_export_month) + self.assertEqual(len(exp.sheets[self.cfg_sheet]), 6) + self.assertEqual(format_duree(exp._total_spe['gen']), '10:40') + # Les 10h40 sont divisées par le nombre d'enfants suivis (= 5 * 2h08): + self.assertEqual(exp.sheets[self.cfg_sheet][1][26], timedelta(hours=2, minutes=8)) + self.assertEqual(exp.sheets[self.cfg_sheet][2][26], timedelta(hours=2, minutes=8)) + self.assertEqual(exp.sheets[self.cfg_sheet][3][26], timedelta(hours=2, minutes=8)) + self.assertEqual(exp.sheets[self.cfg_sheet][4][26], timedelta(hours=2, minutes=8)) + self.assertEqual(exp.sheets[self.cfg_sheet][5][26], timedelta(hours=2, minutes=8)) + + def test_export_evaluation(self): + familles = [self.create_famille('Fam_{}'.format(i)) for i in range(3)] + for famille in familles: + famille.suivi.date_demande = date(2019, 10, 1) + famille.suivi.save() + prestation_data = { + 'auteur': self.user_aemo, 'date_prestation': date(2019, 10, 21), + 'lib_prestation': LibellePrestation.objects.get(code='aemo01'), + } + Prestation.objects.bulk_create([ + Prestation(**{**prestation_data, 'famille': familles[0], 'duree': '6:50'}), + Prestation(**{**prestation_data, 'famille': familles[0], 'duree': '1:00'}), + Prestation(**{**prestation_data, 'famille': familles[1], 'duree': '1:25'}), + Prestation(**{**prestation_data, 'famille': familles[2], 'duree': '4:40'}), + ]) + for prest in Prestation.objects.all(): + prest.intervenants.add(self.user_aemo) + exp = TestExporter() + with patch('aemo.views.ExportReporting', new=exp): + self.client.post(reverse('export-prestation'), self.cfg_export_month) + sheet_name = exp.sheets[self.cfg_sheet] + self.assertEqual(len(sheet_name), 4) + self.assertEqual(sheet_name[1][24], timedelta(hours=7, minutes=50)) + self.assertEqual(sheet_name[2][24], timedelta(hours=1, minutes=25)) + self.assertEqual(sheet_name[3][24], timedelta(hours=4, minutes=40)) + self.assertEqual(format_duree(exp._total_spe['eval']), '13:55') + + def test_export_suivi(self): + familles = [self.create_famille('Fam_{}'.format(i)) for i in range(3)] + for famille in familles: + famille.suivi.date_demande = date(2019, 10, 1) + famille.suivi.save() + prestation_data = dict( + {'auteur': self.user_aemo, 'date_prestation': date(2019, 10, 21), + 'lib_prestation': LibellePrestation.objects.get(code='aemo02')} + ) + Prestation.objects.bulk_create([ + Prestation(**{**prestation_data, 'famille': familles[0], 'duree': '1:10'}), + Prestation(**{**prestation_data, 'famille': familles[0], 'duree': '2:20'}), + Prestation(**{**prestation_data, 'famille': familles[1], 'duree': '3:30'}), + Prestation(**{**prestation_data, 'famille': familles[2], 'duree': '4:45'}), + ]) + for prest in Prestation.objects.all(): + prest.intervenants.add(self.user_aemo) + exp = TestExporter() + with patch('aemo.views.ExportReporting', new=exp): + self.client.post(reverse('export-prestation'), self.cfg_export_month) + sheet_name = exp.sheets[self.cfg_sheet] + self.assertEqual(len(sheet_name), 4) + self.assertEqual(sheet_name[1][25], timedelta(hours=3, minutes=30)) + self.assertEqual(sheet_name[2][25], timedelta(hours=3, minutes=30)) + self.assertEqual(sheet_name[3][25], timedelta(hours=4, minutes=45)) + self.assertEqual(format_duree(exp._total_spe['suivi']), '11:45') + + def test_export_authorization(self): + self.client.force_login(self.user_externe) + # Test denied access without export_stats permission + with self.assertLogs('django.request', level='WARNING'): + response = self.client.get(reverse('export-prestation')) + self.assertEqual(response.status_code, 403) + + self.user_externe.user_permissions.add(Permission.objects.get(codename='export_stats')) + # Test default selected options in export form + with patch('aemo.views.date') as mock_date: + mock_date.today.return_value = date(2019, 7, 3) + response = self.client.get(reverse('export-prestation')) + self.assertContains( + response, + '' + ) + self.assertContains( + response, + '' + ) + + mock_date.today.return_value = date(2020, 1, 30) + response = self.client.get(reverse('export-prestation')) + self.assertContains( + response, + '' + ) + self.assertContains( + response, + '' + ) + + response = self.client.post(reverse('export-prestation'), data={'mois': '2', 'annee': '2019'}) + self.assertEqual(response['Content-Type'], openxml_contenttype) + self.assertEqual( + response['Content-Disposition'], + 'attachment; filename="aemo_reporting_02_2019.xlsx"' + ) + + def test_openxml_export_sheets(self): + # "Nouvelle" famille + famille = Famille.objects.create_famille( + equipe='littoral', + nom='NouvelleFamille', rue='Château 4', npa=2000, localite='Moulinsart', + ) + famille.suivi.date_demande = '2019-02-10' + # famille.suivi.date_fin_suivi = '2019-05-30' + famille.suivi.save() + Personne.objects.create_personne( + famille=famille, role=Role.objects.get(nom='Enfant suivi'), + nom='NouvelleFamille', prenom='Hégésipe', genre='M', date_naissance=date(1996, 2, 16) + ) + + # Famille "en cours" + famille = Famille.objects.create_famille( + equipe='littoral', + nom='FamilleEnCours', rue='Château 5', npa=2000, localite='Moulinsart', + autorite_parentale='conjointe', statut_marital='divorce', monoparentale=False, + ) + famille.suivi.date_demande = '2019-01-01' + famille.suivi.date_debut_suivi = '2019-01-28' + ope_service = Service.objects.create(sigle="OPEC") + famille.suivi.ope_referent = Contact.objects.create( + nom="Duplain", prenom="Irma", service=ope_service + ) + famille.suivi.save() + Personne.objects.create_personne( + famille=famille, role=Role.objects.get(nom='Enfant suivi'), + nom='FamilleEnCours', prenom='Alain', genre='M', date_naissance=date(2003, 4, 23), + localite='Moulinsart', + ) + Personne.objects.create_personne( + famille=famille, role=self.role_mere, + nom='FamilleEnCours', prenom='Judith', genre='F', date_naissance=date(1974, 11, 2) + ) + Personne.objects.create_personne( + famille=famille, role=self.role_pere, + nom='NomDuPere', prenom='Hans', genre='M', date_naissance=date(1968, 3, 13) + ) + + # Famille "terminée" + famille = Famille.objects.create_famille( + nom='FamilleTerminée', rue='Château 6', npa=2000, localite='Moulinsart', + ) + famille.suivi.date_demande = '2018-10-02' + famille.suivi.date_debut_suivi = '2018-11-11' + famille.suivi.date_fin_suivi = '2019-02-20' + famille.suivi.motif_fin_suivi = 'relai' + famille.suivi.save() + Personne.objects.create_personne( + famille=famille, role=self.enf_suivi, + nom='FamilleTerminée', prenom='Jeanne', genre='F', date_naissance=date(1998, 12, 14) + ) + + self.client.force_login(self.user_admin) + exp = TestExporter() + with patch('aemo.views.ExportReporting', new=exp): + self.client.post(reverse('export-prestation'), {'mois': '2', 'annee': '2019'}) + + self.assertEqual(len(exp.sheets['En_cours_02.2019']), 4) + self.assertEqual(exp.sheets['En_cours_02.2019'][0], self.entetes_en_cours) + self.assertEqual( + exp.sheets['En_cours_02.2019'][1], + ['Fondation Transit', 'AEMO', 'FamilleEnCours', 'Alain', 'M', '23.04.2003', '', '', 'Moulinsart', 'NE', + 'Duplain Irma', 'FamilleEnCours', 'Judith', 'NomDuPere', 'Hans', 'Conjointe', 'Divorcé', '', 'NON', 1, + '01.01.2019', '', '', '28.01.2019', + timedelta(0), timedelta(0), timedelta(0)] + ) + self.assertEqual( + [(line[2], line[3]) for line in exp.sheets['En_cours_02.2019'][1:]], + [('FamilleEnCours', 'Alain'), ('FamilleTerminée', 'Jeanne'), ('NouvelleFamille', 'Hégésipe')] + ) + + self.assertEqual(len(exp.sheets['Nouveaux_02.2019']), 2) + self.assertEqual(exp.sheets['Nouveaux_02.2019'][0], self.entetes_nouveaux) + self.assertEqual( + exp.sheets['Nouveaux_02.2019'][1], + ['Fondation Transit', 'AEMO', 'NouvelleFamille', 'Hégésipe', 'M', '16.02.1996', '', '', '', 'NE', + '', '', '', '', '', '', '', '', '', 1, + '10.02.2019', '', '', '' + ] + ) + self.assertEqual(len(exp.sheets['Terminés_02.2019']), 2) + self.assertEqual(exp.sheets['Terminés_02.2019'][0], self.entetes_termines) + self.assertEqual( + exp.sheets['Terminés_02.2019'][1], + ['Fondation Transit', 'AEMO', 'FamilleTerminée', 'Jeanne', 'F', '14.12.1998', '', '', '', 'NE', + '', '', '', '', '', '', '', '', '', 1, + '02.10.2018', '', '', '11.11.2018', + '20.02.2019', 'Relai vers autre service', '', timedelta(0) + ] + ) + + famille = Famille.objects.get(nom='NouvelleFamille') + famille.suivi.motif_fin_suivi = 'erreur' + famille.suivi.date_fin_suivi = date(2019, 2, 17) + famille.suivi.save() + + famille = Famille.objects.get(nom='FamilleTerminée') + famille.suivi.motif_fin_suivi = 'erreur' + famille.suivi.date_fin_suivi = date(2019, 2, 17) + famille.suivi.save() + + exp = TestExporter() + with patch('aemo.views.ExportReporting', new=exp): + self.client.post(reverse('export-prestation'), {'mois': '2', 'annee': '2019'}) + # Plus de famille, puisque les motifs de fin de suivi "erreur" ne sont pas pris en compte + self.assertEqual(len(exp.sheets['Nouveaux_02.2019']), 1) + self.assertEqual(len(exp.sheets['Terminés_02.2019']), 1) + # 'NouvelleFamille' plus exportée + self.assertEqual(len(exp.sheets['En_cours_02.2019']), 2) diff --git a/aemo/urls.py b/aemo/urls.py new file mode 100644 index 0000000..c053201 --- /dev/null +++ b/aemo/urls.py @@ -0,0 +1,165 @@ +from django.apps import apps +from django.urls import include, path +from aemo import views, views_stats + +urlpatterns = [ + path('contact/add/', views.ContactCreateView.as_view(), name='contact-add'), + path('contact/list/', views.ContactListView.as_view(), name='contact-list'), + path('contact//edit/', views.ContactUpdateView.as_view(), name='contact-edit'), + path('contact//delete/', views.ContactDeleteView.as_view(), name='contact-delete'), + path('contact/dal/', views.ContactAutocompleteView.as_view(), name='contact-autocomplete'), + path('contact/dal-ope/', views.ContactAutocompleteView.as_view(ope=True), + name='contact-ope-autocomplete'), + path('contact/dal-externe/', views.ContactExterneAutocompleteView.as_view(), + name='contact-externe-autocomplete'), + path('contact/test/doublon/', views.ContactTestDoublon.as_view(), name='contact-doublon'), + + path('service/add/', views.ServiceCreateView.as_view(), name='service-add'), + path('service/list/', views.ServiceListView.as_view(), name='service-list'), + path('service//edit/', views.ServiceUpdateView.as_view(), name='service-edit'), + path('service//delete/', views.ServiceDeleteView.as_view(), name='service-delete'), + + path('famille/dal/', views.FamilleAutoCompleteView.as_view(), name="famille-autocomplete"), + # Famille + path('famille/list/', views.FamilleListView.as_view(), name='famille-list'), + path('famille/attente/', views.FamilleListView.as_view(mode='attente'), name='famille-attente'), + path('famille/add/', views.FamilleCreateView.as_view(), name='famille-add'), + path('famille//edit/', views.FamilleUpdateView.as_view(), name='famille-edit'), + path('famille//niveau/add/', views.NiveauCreateUpdateView.as_view(), name='niveau-add'), + path('famille//niveau//edit/', views.NiveauCreateUpdateView.as_view(), + name='niveau-edit' + ), + path('famille//niveau//delete/', views.NiveauDeleteView.as_view(), + name='niveau-delete'), + + # Personne + path('famille//personne/add/', views.PersonneCreateView.as_view(), + name="personne-add"), + path('famille//personne//edit/', + views.PersonneUpdateView.as_view(), name="personne-edit"), + path('famille//personne//delete/', + views.PersonneDeleteView.as_view(), name='personne-delete'), + + path('personne//formation/', views.FormationView.as_view(), name='formation'), + path('personne//contacts/', views.PersonneReseauView.as_view(), + name='personne-reseau-list'), + path('personne//contact/add/', views.PersonneReseauAdd.as_view(), + name='personne-reseau-add'), + path('personne//contact//remove/', views.PersonneReseauRemove.as_view(), + name='personne-reseau-remove'), + + # Prestations + path('prestation/menu/', views.PrestationMenu.as_view(), name='prestation-menu'), + path('famille//prestation/list/', views.PrestationListView.as_view(), name='journal-list'), + path('famille//prestation/add/', views.PrestationCreateView.as_view(), name='prestation-famille-add'), + path('famille//prestation//edit/', views.PrestationUpdateView.as_view(), + name='prestation-edit'), + path('famille//prestation//delete/', views.PrestationDeleteView.as_view(), + name='prestation-delete'), + path('prestation_gen/list/', views.PrestationListView.as_view(), name='prestation-gen-list'), + path('prestation_gen/add/', views.PrestationCreateView.as_view(), name='prestation-gen-add'), + + path('famille//upload/', views.DocumentUploadView.as_view(), + name='famille-doc-upload'), + path('famille//doc//delete/', views.DocumentDeleteView.as_view(), + name='famille-doc-delete'), + path('famille//reactivation/', views.FamilleReactivationView.as_view(), + name='famille-reactivation'), + + # Doc. à imprimer + path('famille//print-evaluation/', views.EvaluationPDFView.as_view(), name='print-evaluation'), + path('famille//print-info/', views.CoordonneesPDFView.as_view(), name='print-coord-famille'), + path('famille//print-journal', views.JournalPDFView.as_view(), name='print-journal'), + path('bilan//print/', views.BilanPDFView.as_view(), name='print-bilan'), + + # Rapport + path('famille//rapport/add/', views.RapportCreateView.as_view(), + name='famille-rapport-add'), + path('famille//rapport//', views.RapportDisplayView.as_view(), + name='famille-rapport-view'), + path('famille//rapport//edit/', views.RapportUpdateView.as_view(), + name='famille-rapport-edit'), + path('famille//rapport//delete/', views.RapportDeleteView.as_view(), + name='famille-rapport-delete'), + path('famille//rapport//print/', views.RapportPDFView.as_view(), + name='famille-rapport-print'), + path('famille//adresse/change/', views.FamilleAdresseChangeView.as_view(), + name='famille-adresse-change'), + + # Demande, suivi, agenda, suivis terminés + path('famille//demande/', views.DemandeView.as_view(), name='demande'), + path('famille//suivi/', views.SuiviView.as_view(), name='famille-suivi'), + path('famille//intervenant/add/', views.SuiviIntervenantCreate.as_view(), name='intervenant-add'), + path('famille//intervenant//edit/', views.SuiviIntervenantUpdateView.as_view(), + name='intervenant-edit'), + path('famille//agenda/', views.AgendaSuiviView.as_view(), name='famille-agenda'), + path('famille//bilan/add/', views.BilanEditView.as_view(is_create=True), + name='famille-bilan-add'), + path('famille//bilan//', views.BilanDetailView.as_view(), + name='famille-bilan-view'), + path('famille//bilan//edit/', views.BilanEditView.as_view(), + name='famille-bilan-edit'), + path('famille//bilan//delete/', views.BilanDeleteView.as_view(), + name='famille-bilan-delete'), + path('famille/archivable/list/', views.FamilleArchivableListe.as_view(), name='famille-archivable'), + + path('suivis_termines/', views.SuivisTerminesListView.as_view(), name='suivis-termines'), + + path('charge_utilisateurs/', views.UtilisateurChargeDossierView.as_view(), name='charge-utilisateurs'), + + path('utilisateur/', views.UtilisateurListView.as_view(), + name='utilisateur-list'), + path('utilisateur//edit/', views.UtilisateurUpdateView.as_view(), + name='utilisateur-edit'), + path('utilisateur/add/', views.UtilisateurCreateView.as_view(), + name='utilisateur-add'), + path('utilisateur//delete/', views.UtilisateurDeleteView.as_view(), + name='utilisateur-delete'), + path('utilisateur//password_reinit/', views.UtilisateurPasswordReinitView.as_view(), + name='utilisateur-password-reinit'), + path('utilisateur//otp_device/reinit/', views.UtilisateurOtpDeviceReinitView.as_view(), + name='utilisateur-otp-device-reinit'), + path('utilisateur//journalacces/', views.UtilisateurJournalAccesView.as_view(), + name='utilisateur-journalacces'), + path('utilisateur/dal/', views.UtilisateurAutocompleteView.as_view(), + name='utilisateur-autocomplete'), + path('utilisateur/desactive/list/', views.UtilisateurListView.as_view(is_active=False), + name='utilisateur-desactive-list'), + path('utilisateur//reactiver/', views.UtilisateurReactivateView.as_view(), + name='utilisateur-reactiver'), + + path('utilisateur/prestation/', views.PrestationPersonnelleListView.as_view(), + name='prestation-personnelle'), + path('prestation/generale/', views.PrestationGeneraleListView.as_view(), + name='prestation-generale'), + + path('cerclescolaire/', views.CercleScolaireListView.as_view(), + name='cercle-list'), + path('cerclescolaire//edit/', views.CercleScolaireUpdateView.as_view(), + name='cercle-edit'), + path('cerclescolaire/add/', views.CercleScolaireCreateView.as_view(), + name='cercle-add'), + path('cerclescolaire//delete/', views.CercleScolaireDeleteView.as_view(), + name='cercle-delete'), + + path('role/', views.RoleListView.as_view(), name='role-list'), + path('role//edit/', views.RoleUpdateView.as_view(), name='role-edit'), + path('role/add/', views.RoleCreateView.as_view(), name='role-add'), + path('role//delete/', views.RoleDeleteView.as_view(), name='role-delete'), + + path('permissions/', views.PermissionOverview.as_view(), name='permissions'), + + path('export/prestation/', views.ExportPrestationView.as_view(), name='export-prestation'), + + path('statistiques/', views_stats.StatistiquesView.as_view(), name='stats'), + path('statistiques/localite/', views_stats.StatistiquesParLocaliteView.as_view(), name='stats-localite'), + path('statistiques/region/', views_stats.StatistiquesParRegionView.as_view(), name='stats-region'), + path('statistiques/duree/', views_stats.StatistiquesParDureeView.as_view(), name='stats-duree'), + path('statistiques/age/', views_stats.StatistiquesParAgeView.as_view(), name='stats-age'), + path('statistiques/motifs/', views_stats.StatistiquesMotifsView.as_view(), name='stats-motifs'), + path('statistiques/prestations/', views_stats.StatistiquesPrestationView.as_view(), name='stats-prestations'), + path('statistiques/niveaux/', views_stats.StatistiquesNiveauxView.as_view(), name='stats-niveaux'), +] + +if apps.is_installed('debug_toolbar'): + urlpatterns.append(path('__debug__/', include('debug_toolbar.urls'))) diff --git a/aemo/utils.py b/aemo/utils.py new file mode 100644 index 0000000..b6957e2 --- /dev/null +++ b/aemo/utils.py @@ -0,0 +1,207 @@ +import random +import string +import unicodedata + +import requests + +from django.conf import settings +from django.core.mail import EmailMessage, mail_admins +from django.utils.dateformat import format as django_format +from django.utils.html import Urlizer + +ANTICIPATION_POUR_DEBUT_SUIVI = 90 + + +def format_d_m_Y(date): + return django_format(date, 'd.m.Y') if date else '' + + +def format_d_m_Y_HM(date): + return django_format(date, 'l d.m.Y - G:i') if date else '' + + +def format_duree(duree, centiemes=False): + if duree is None: + return '00:00' + elif isinstance(duree, str): + return duree + secondes = duree.total_seconds() + heures = secondes // 3600 + if centiemes: + # Arrondi, cf. #455 + minutes = round((secondes % 3600) / 36) + return '{:02}.{:02}'.format(int(heures), minutes) + else: + minutes = (secondes % 3600) // 60 + return '{:02}:{:02}'.format(int(heures), int(minutes)) + + +def format_Ymd(date): + return django_format(date, 'Ymd') if date else '' + + +def format_adresse(rue, npa, localite): + """ Formate the complete adress """ + + if rue and npa and localite: + return f"{rue}, {npa} {localite}" + if npa and localite: + return f"{npa} {localite}" + return f"{localite}" + + +def format_contact(telephone, email): + """ Formate the contact data (phone and email) """ + return '{} {} {}'.format(telephone, '-' if telephone != '' and email != '' else '', email) + + +def unaccent(text): + if isinstance(text, list): + text = ' '.join(text) + text = text.replace('-', ' ') + text = text.lower() + t = unicodedata.normalize('NFD', text).encode('ascii', 'ignore') + return t.decode('utf-8') + + +def is_ajax(request): + return request.META.get('HTTP_X_REQUESTED_WITH') in ['XMLHttpRequest', 'Fetch'] + + +def format_nom_prenom(nom): + if nom is None or nom == '': + return '' + if nom.startswith('de ') and len(nom) > 3: + return f"de {nom[3].upper()}{nom[4:]}" + return f"{nom[0].upper()}{nom[1:]}" + + +def is_valid_for_sms(phone): + return phone and phone[:2] == '07' + + +class SMSUrlizer(Urlizer): + """ + Replace links with short links: "<-short->" -> take 19 chars + See https://doc.smsup.ch/en/api/sms/send/short-url + """ + + url_template = '<-short->' + + def __init__(self): + self.links = [] + + def trim_url(self, url, **kwargs): + self.links.append(url) + return super().trim_url(url, **kwargs) + + +def send_sms(text, to, err): + # Try to remain below 160 chars + # https://pypi.org/project/smsutil/ if we want to check against GSM-7 + url = settings.SMSUP_SEND_URL + urlizer = SMSUrlizer() + if 'https://' in text: + text = urlizer(text) + + headers = { + "Authorization": f"Bearer {settings.SMSUP_API_TOKEN}", + "Accept": "application/json", + } + if urlizer.links: + # Presence of links requires usage of the POST API. + params = { + 'sms': { + 'message': { + 'text': text, + 'pushtype': 'alert', + 'sender': 'F.Transit', # Max 11 chars + 'links': urlizer.links, + }, + 'recipients': { + 'gsm': [{'gsmsmsid': '', 'value': to.replace(' ', '')}], + }, + }, + } + response = requests.post(url, json=params, headers=headers) + else: + params = { + 'text': text, + 'to': to.replace(' ', ''), + 'sender': 'F.Transit', # Max 11 chars + } + response = requests.get(url, params=params, headers=headers) + + result = response.json() + if not result or result.get('message') != 'OK': + mail_admins( + "[AEMO-FR] Erreur SMS", + f"{err}\n\nParams: {params}\n\n{result}", + ) + return False + return True + + +def send_email(subject, message, to): + email = EmailMessage( + subject, + message, + to=[to], + reply_to=['secretariat@fondation-transit.ch'], + ) + try: + email.send() + return True + except OSError: + return False + + +CONTINENTS = { + 'NA': [ + 'AI', 'AG', 'AW', 'BS', 'BB', 'BQ', 'BZ', 'BM', 'CA', 'KY', 'CR', 'CU', + 'CW', 'DM', 'DO', 'SV', 'GL', 'GD', 'GP', 'GT', 'HT', 'HN', 'JM', 'MQ', + 'MX', 'PM', 'MS', 'CW', 'KN', 'NI', 'PA', 'PR', 'KN', 'LC', 'PM', + 'SX', 'TT', 'TC', 'VI', 'US', 'VC', 'VG', + ], + 'SA': [ + 'AR', 'BO', 'BR', 'CL', 'CO', 'EC', 'FK', 'GF', 'GY', 'PY', 'PE', 'SR', + 'UY', 'VE', + ], + 'EU': [ + 'AL', 'AD', 'AT', 'BY', 'BE', 'BA', 'BG', 'HR', 'CY', 'CZ', 'DK', 'EE', + 'FO', 'FI', 'FR', 'DE', 'GI', 'GR', 'HU', 'IS', 'IE', 'IT', 'LV', 'LI', + 'LT', 'LU', 'MK', 'MT', 'MD', 'MC', 'NL', 'NO', 'PL', 'PT', 'RO', 'RU', + 'SM', 'RS', 'SK', 'SI', 'ES', 'SE', 'CH', 'UA', 'GB', 'VA', 'RS', 'IM', + 'RS', 'ME', + ], + 'AF': [ + 'AO', 'DZ', 'BJ', 'BW', 'BF', 'BI', 'CD', 'CI', 'CM', 'CV', 'CF', 'KM', + 'CG', 'DJ', 'EG', 'GQ', 'ER', 'ET', 'GA', 'GH', 'GM', 'GW', 'GN', 'KE', + 'LS', 'LR', 'LY', 'MG', 'MW', 'ML', 'MR', 'MU', 'YT', 'MA', 'MZ', 'NA', + 'NE', 'NG', 'ST', 'RE', 'RW', 'SH', 'ST', 'SN', 'SC', 'SL', 'SO', 'SH', + 'SD', 'SZ', 'TD', 'TG', 'TN', 'TZ', 'UG', 'ZM', 'TZ', 'ZW', 'SS', 'ZA', + ], + 'AS': [ + 'AF', 'AM', 'AZ', 'BH', 'BD', 'BT', 'BN', 'KH', 'CN', 'CX', 'CC', 'IO', + 'GE', 'HK', 'IN', 'ID', 'IR', 'IQ', 'IL', 'JP', 'JO', 'KZ', 'KP', 'KR', + 'KW', 'KG', 'LA', 'LB', 'MO', 'MY', 'MV', 'MN', 'MM', 'NP', 'OM', 'PK', + 'PH', 'QA', 'SA', 'SG', 'LK', 'SY', 'TW', 'TJ', 'TH', 'TR', 'TM', 'AE', + 'UZ', 'VN', 'YE', 'PS', + ], + 'OC': [ + 'AS', 'AU', 'NZ', 'CK', 'FJ', 'PF', 'GU', 'KI', 'MP', 'MH', 'FM', 'UM', + 'NR', 'NC', 'NZ', 'NU', 'NF', 'PW', 'PG', 'MP', 'SB', 'TK', 'TO', 'TV', + 'VU', 'UM', 'WF', 'WS', 'TL', + ], + 'AN': ['AQ'], +} + + +def continent_from_country_code(code): + for cont_code, country_codes in CONTINENTS.items(): + if code in country_codes: + return cont_code + + +def random_string_generator(size=10, chars=string.ascii_lowercase + string.digits): + return ''.join(random.choice(chars) for _ in range(size)) diff --git a/aemo/views.py b/aemo/views.py new file mode 100644 index 0000000..a15a531 --- /dev/null +++ b/aemo/views.py @@ -0,0 +1,1673 @@ +import io +import calendar +import logging +from collections import OrderedDict +from datetime import date, timedelta +from operator import attrgetter + +from dal import autocomplete +from two_factor.views import SetupView as TwoFactSetupView + +from django.contrib import messages +from django.contrib.auth.mixins import PermissionRequiredMixin +from django.contrib.auth.models import Group, Permission +from django.contrib.auth.views import PasswordChangeView as AuthPasswordChangeView +from django.contrib.messages.views import SuccessMessageMixin +from django.contrib.postgres.aggregates import ArrayAgg +from django.core.exceptions import PermissionDenied +from django.db.models import ( + Case, Count, DurationField, F, OuterRef, Prefetch, Q, Subquery, Sum, Value, When +) +from django.db.models.deletion import ProtectedError +from django.db.models.functions import Coalesce +from django.forms import HiddenInput, modelform_factory +from django.http import FileResponse, HttpResponseRedirect, JsonResponse +from django.shortcuts import get_object_or_404, render +from django.urls import reverse, reverse_lazy +from django.utils.crypto import get_random_string +from django.views.defaults import page_not_found as django_page_not_found +from django.views.generic import ( + CreateView, DeleteView as DjangoDeleteView, DetailView, FormView, ListView, TemplateView, + UpdateView, View +) + +from .export import ExportReporting +from . import forms +from .models import ( + Bilan, CercleScolaire, Contact, Document, Famille, Formation, Intervenant, JournalAcces, + LibellePrestation, Niveau, Personne, Prestation, Rapport, Role, Service, Suivi, + Utilisateur +) +from .pdf import BilanPdf, CoordonneesFamillePdf, EvaluationPdf, JournalPdf, RapportPdf +from .utils import format_d_m_Y, is_ajax + +logger = logging.getLogger('django') + +MSG_READ_ONLY = "Vous n'avez pas les droits nécessaires pour modifier cette page" +MSG_ACCESS_DENIED = "Vous n’avez pas la permission d’accéder à cette page." + + +class CreateUpdateView(UpdateView): + """Mix generic Create and Update views.""" + is_create = False + + def get_object(self): + return None if self.is_create else super().get_object() + + +class DeleteView(DjangoDeleteView): + """ + Nous ne suivons pas la méthode Django d'afficher une page de confirmation + avant de supprimer un objet, mais nous avertissons avec un message JS avant + de POSTer directement la suppression. Pour cela, nous autorisons uniquement + la méthode POST. + """ + http_method_names = ['post'] + + +class JournalAccesMixin: + """ + Classe Mixin pour journaliser les accès aux familles. + """ + def get(self, *args, **kwargs): + acces_ordinaire = self.famille.access_ok(self.request.user) + if not acces_ordinaire and not self.request.GET.get('confirm') == '1': + return render( + self.request, 'aemo/acces_famille.html', + {'url': self.request.path, 'famille': self.famille} + ) + JournalAcces.objects.create( + famille=self.famille, + utilisateur=self.request.user, + ordinaire=acces_ordinaire + ) + return super().get(*args, **kwargs) + + +class BasePDFView(View): + obj_class = None + pdf_class = None + produce_kwargs = {} + + def get_object(self): + return get_object_or_404(self.obj_class, pk=self.kwargs[getattr(self, 'pk_url_kwarg', 'pk')]) + + def get(self, request, *args, **kwargs): + instance = self.get_object() + temp = io.BytesIO() + pdf = self.pdf_class(temp, instance, **self.produce_kwargs) + pdf.produce() + filename = pdf.get_filename() + temp.seek(0) + return FileResponse(temp, as_attachment=True, filename=filename) + + +class EquipeRequiredMixin: + def dispatch(self, request, *args, **kwargs): + self.famille = get_object_or_404(Famille, pk=kwargs['pk']) + self.check_access(request) + return super().dispatch(request, *args, **kwargs) + + def check_access(self, request): + if not self.famille.can_view(request.user): + raise PermissionDenied(MSG_ACCESS_DENIED) + self.readonly = not self.famille.can_edit(request.user) + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + if self.readonly: + messages.info(self.request, MSG_READ_ONLY) + context['can_edit'] = not self.readonly + context['famille'] = self.famille + return context + + +class CheckCanEditMixin: + def dispatch(self, request, *args, **kwargs): + if not self.get_object().can_edit(request.user): + raise PermissionDenied(MSG_ACCESS_DENIED) + return super().dispatch(request, *args, **kwargs) + + +class HomeView(TemplateView): + template_name = 'index.html' + + +class SetupView(TwoFactSetupView): + def get(self, request, *args, **kwargs): + # The original view is short-circuiting to complete is a device exists. + # We want to allow adding a second device if needed. + return super(TwoFactSetupView, self).get(request, *args, **kwargs) + + +class PasswordChangeView(AuthPasswordChangeView): + success_url = reverse_lazy('home') + + def form_valid(self, form): + response = super().form_valid(form) + messages.success(self.request, "Votre mot de passe a bien été modifié.") + return response + + +class ContactCreateView(CreateView): + template_name = 'aemo/contact_edit.html' + model = Contact + form_class = forms.ContactForm + success_url = reverse_lazy('contact-list') + action = 'Création' + + def form_valid(self, form): + contact = form.save() + for_pers = self.request.GET.get('forpers') + if for_pers: + pers = get_object_or_404(Personne, pk=for_pers) + pers.reseaux.add(contact) + return HttpResponseRedirect(reverse('personne-reseau-list', args=[pers.pk])) + return HttpResponseRedirect(self.success_url) + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context.update({ + 'role_form': forms.RoleForm(), + 'service_form': forms.ServiceForm(), + }) + return context + + +class ContactListView(ListView): + template_name = 'aemo/contact_list.html' + model = Contact + paginate_by = 20 + + def get(self, request, *args, **kwargs): + self.filter_form = forms.ContactFilterForm(data=self.request.GET or None) + return super().get(request, *args, **kwargs) + + def get_queryset(self): + contacts = Contact.objects.filter(est_actif=True).exclude(utilisateur__isnull=False).prefetch_related('roles') + + if self.filter_form.is_bound and self.filter_form.is_valid(): + contacts = self.filter_form.filter(contacts) + return contacts + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context.update({ + 'form': self.filter_form + }) + return context + + +class ContactUpdateView(PermissionRequiredMixin, SuccessMessageMixin, UpdateView): + permission_required = 'aemo.change_contact' + template_name = 'aemo/contact_edit.html' + model = Contact + form_class = forms.ContactForm + success_url = reverse_lazy('contact-list') + success_message = 'Le contact %(nom)s %(prenom)s a bien été modifié' + action = 'Modification' + + def delete_url(self): + return reverse('contact-delete', args=[self.object.pk]) + + +class ContactDeleteView(PermissionRequiredMixin, DeleteView): + permission_required = 'aemo.delete_contact' + model = Contact + success_url = reverse_lazy('contact-list') + + def form_valid(self, form): + self.object.est_actif = False + self.object.save() + messages.success(self.request, "Le contact %s a bien été archivé." % self.object) + return HttpResponseRedirect(self.success_url) + + +class ContactAutocompleteView(autocomplete.Select2QuerySetView): + ope = False + + def get_queryset(self): + qs = Contact.membres_ope() if self.ope else Contact.objects.filter(est_actif=True) + if self.q: + qs = qs.filter(nom__istartswith=self.q) + return qs + + +class ContactExterneAutocompleteView(autocomplete.Select2QuerySetView): + + def get_queryset(self): + qs = Contact.objects.\ + filter(est_actif=True).\ + exclude(service__sigle__startswith='OPE').\ + exclude(service__sigle='CRNE') + if self.q: + qs = qs.filter(nom__istartswith=self.q) + return qs + + +class ContactTestDoublon(View): + def post(self, request, *args, **kwargs): + nom = request.POST.get('nom') + prenom = request.POST.get('prenom') + contacts = Contact.objects.filter(nom=nom, prenom=prenom) + data = '' + if contacts.exists(): + data = [{'nom': c.nom, 'prenom': c.prenom} for c in contacts] + return JsonResponse(data, safe=False) + + +class ServiceCreateView(PermissionRequiredMixin, CreateView): + permission_required = 'aemo.add_service' + template_name = 'aemo/service_edit.html' + model = Service + form_class = forms.ServiceForm + success_url = reverse_lazy('service-list') + action = 'Création' + + def form_valid(self, form): + if is_ajax(self.request): + service = form.save() + return JsonResponse({'pk': service.pk, 'sigle': service.sigle}) + return super().form_valid(form) + + def form_invalid(self, form): + if is_ajax(self.request): + return JsonResponse({'error': form.errors.as_text()}) + return super().form_invalid(form) + + +class ServiceListView(ListView): + template_name = 'aemo/service_list.html' + model = Service + + def get_queryset(self): + return Service.objects.exclude(sigle='FAMILLE') + + +class ServiceUpdateView(PermissionRequiredMixin, UpdateView): + permission_required = 'aemo.change_service' + template_name = 'aemo/service_edit.html' + model = Service + form_class = forms.ServiceForm + success_url = reverse_lazy('service-list') + action = 'Modification' + + def delete_url(self): + return reverse('service-delete', args=[self.object.pk]) + + +class ServiceDeleteView(PermissionRequiredMixin, DeleteView): + permission_required = 'aemo.delete_service' + model = Service + success_url = reverse_lazy('service-list') + + +class FormationView(SuccessMessageMixin, UpdateView): + template_name = 'aemo/formation_edit.html' + model = Formation + form_class = forms.FormationForm + success_message = 'Les modifications ont été enregistrées avec succès' + + def get_object(self, queryset=None): + personne = get_object_or_404(Personne, pk=self.kwargs['pk']) + self.famille = get_object_or_404(Famille, pk=personne.famille_id) + return personne.formation + + def get_form_kwargs(self): + return { + **super().get_form_kwargs(), + 'readonly': not self.famille.can_edit(self.request.user) + } + + def get_success_url(self): + return self.famille.edit_url + + def get_context_data(self, **kwargs): + return {**super().get_context_data(**kwargs), 'famille': self.famille} + + +class PersonneBaseMixin: + template_name = 'aemo/personne_edit.html' + model = Personne + form_class = forms.PersonneForm + require_edit = False + + def dispatch(self, request, *args, **kwargs): + self.famille = get_object_or_404(Famille, pk=kwargs['pk']) + if not self.famille.can_view(request.user): + raise PermissionDenied("Vous n’avez pas la permission d’accéder à cette page.") + elif self.require_edit and not self.famille.can_edit(request.user): + raise PermissionDenied("Vous n’avez pas la permission d’accéder à cette page.") + return super().dispatch(request, *args, **kwargs) + + def get_context_data(self, **kwargs): + context = {**super().get_context_data(**kwargs), 'famille': self.famille} + if self.object and self.object.role.nom == 'Enfant suivi': + context['enfant'] = self.object + return context + + def get_success_url(self): + return self.famille.edit_url + + +class PersonneCreateView(PersonneBaseMixin, CreateView): + action = 'Membre de la famille ' + require_edit = True + + def get_form_kwargs(self): + kwargs = super().get_form_kwargs() + kwargs['famille'] = self.famille + kwargs['role'] = self.request.GET.get('role', None) + return kwargs + + def form_valid(self, form): + response = super().form_valid(form) + if self.object.famille.archived_at: + self.object.famille.archived_at = None + self.object.famille.save() + messages.info(self.request, "La famille a été sortie des archives") + return response + + def get_success_url(self): + return self.famille.redirect_after_personne_creation(self.object) + + +class PersonneUpdateView(SuccessMessageMixin, PersonneBaseMixin, UpdateView): + pk_url_kwarg = 'obj_pk' + action = 'Modification' + success_message = 'Les modifications ont été enregistrées avec succès' + + def get_form_kwargs(self): + return {**super().get_form_kwargs(), 'readonly': not self.famille.can_edit(self.request.user)} + + def delete_url(self): + return reverse('personne-delete', args=[self.famille.pk, self.object.pk]) + + +class PersonneDeleteView(PersonneBaseMixin, DeleteView): + require_edit = True + form_class = DeleteView.form_class + + def get_object(self, *args, **kwargs): + pers = get_object_or_404(Personne, pk=self.kwargs['obj_pk']) + if pers.role.nom == "Enfant suivi": + raise PermissionDenied( + "Un enfant suivi ne peut pas être directement supprimé. Si c’est " + "vraiment ce que vous voulez, mettez-le d’abord comme Enfant non suivi." + ) + return pers + + +class PersonneReseauView(DetailView): + template_name = 'aemo/personne_reseau_list.html' + model = Personne + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + famille = get_object_or_404(Famille, pk=self.object.famille_id) + context.update({ + 'famille': famille, + 'form': forms.ContactExterneAutocompleteForm(), + 'reseau': list(self.object.reseaux.all().order_by('nom')), + }) + try: + pediatre = self.object.suivienfant.pediatre + except AttributeError: + pass + else: + if pediatre: + context['reseau'].append(pediatre) + return context + + +class PersonneReseauAdd(View): + def post(self, request, *args, **kwargs): + pers = get_object_or_404(Personne, pk=kwargs['pk']) + obj_pk = request.POST.getlist('contacts[]') + for obj in obj_pk: + contact = get_object_or_404(Contact, pk=obj) + pers.reseaux.add(contact) + return JsonResponse({'is_valid': True}) + + +class PersonneReseauRemove(View): + def post(self, request, *args, **kwargs): + pers = get_object_or_404(Personne, pk=kwargs['pk']) + contact = get_object_or_404(Contact, pk=kwargs['obj_pk']) + pers.reseaux.remove(contact) + return HttpResponseRedirect(reverse('personne-reseau-list', args=[pers.pk])) + + +class UtilisateurListView(ListView): + template_name = 'aemo/utilisateur_list.html' + model = Utilisateur + is_active = True + paginate_by = 50 + + def get_queryset(self): + return Utilisateur.objects.filter(date_desactivation__isnull=self.is_active) + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context.update({ + 'active_users': self.is_active, + }) + return context + + +class UtilisateurReactivateView(PermissionRequiredMixin, View): + permission_required = 'aemo.delete_utilisateur' + + def post(self, request, *args, **kwargs): + utilisateur = get_object_or_404(Utilisateur, pk=self.kwargs['pk']) + utilisateur.is_active = True + utilisateur.est_actif = True + utilisateur.date_desactivation = None + utilisateur.save() + return HttpResponseRedirect(reverse('utilisateur-list')) + + +class UtilisateurCreateView(SuccessMessageMixin, PermissionRequiredMixin, CreateView): + permission_required = 'aemo.add_utilisateur' + + template_name = 'aemo/utilisateur_edit.html' + model = Utilisateur + form_class = forms.UtilisateurForm + success_url = reverse_lazy('utilisateur-list') + success_message = "L’utilisateur «%(username)s» a été créé avec le mot de passe «%(password)s»." + + def form_valid(self, form): + form.instance.first_name = form.cleaned_data['prenom'] + form.instance.last_name = form.cleaned_data['nom'] + form.instance.service, _ = Service.objects.get_or_create(sigle='CRNE', defaults={'sigle': 'CRNE'}) + pwd = get_random_string(length=10) + form.instance.set_password(pwd) + form.cleaned_data['password'] = pwd # for success message + return super().form_valid(form) + + +class UtilisateurUpdateView(PermissionRequiredMixin, UpdateView): + permission_required = 'aemo.change_utilisateur' + + template_name = 'aemo/utilisateur_edit.html' + model = Utilisateur + form_class = forms.UtilisateurForm + success_url = reverse_lazy('utilisateur-list') + + def delete_url(self): + return reverse('utilisateur-delete', args=[self.object.pk]) + + +class UtilisateurPasswordReinitView(PermissionRequiredMixin, View): + permission_required = 'aemo.change_utilisateur' + + def post(self, request, *args, **kwargs): + utilisateur = get_object_or_404(Utilisateur, pk=self.kwargs['pk']) + pwd = get_random_string(length=10) + utilisateur.set_password(pwd) + utilisateur.save() + messages.success(request, 'Le nouveau mot de passe de «%s» est «%s».' % (utilisateur, pwd)) + return HttpResponseRedirect(reverse('utilisateur-edit', kwargs=self.kwargs)) + + +class UtilisateurOtpDeviceReinitView(PermissionRequiredMixin, View): + permission_required = 'aemo.change_utilisateur' + + def post(self, request, *args, **kwargs): + utilisateur = get_object_or_404(Utilisateur, pk=self.kwargs['pk']) + if utilisateur.totpdevice_set.exists(): + utilisateur.totpdevice_set.all().delete() + messages.success(request, 'Le mobile de «%s» a été réinitialisé.' % utilisateur) + else: + messages.error(request, 'Aucune configuration mobile trouvée pour «%s»' % utilisateur) + return HttpResponseRedirect(reverse('utilisateur-edit', kwargs=self.kwargs)) + + +class UtilisateurJournalAccesView(PermissionRequiredMixin, ListView): + permission_required = 'aemo.change_utilisateur' + template_name = 'aemo/utilisateur_journal.html' + paginate_by = 50 + model = JournalAcces + + def get_queryset(self): + return self.model.objects.filter(utilisateur_id=self.kwargs['pk']).order_by('-quand') + + def get_context_data(self, **kwargs): + return { + **super().get_context_data(**kwargs), + 'utilisateur': get_object_or_404(Utilisateur, pk=self.kwargs['pk']), + } + + +class UtilisateurChargeDossierView(PermissionRequiredMixin, TemplateView): + template_name = 'aemo/charge_utilisateurs.html' + permission_required = 'aemo.change_utilisateur' + + def get_queryset(self): + self.filter_form = forms.EquipeFilterForm(data=self.request.GET or None) + + qs_utilisateurs = Utilisateur.objects.filter( + roles__nom__in=['Educ', 'Psy'], date_desactivation__isnull=True + ).exclude( + roles__nom__in=['Responsable/coordinateur'] + ).order_by('nom', 'prenom').distinct() + if self.filter_form.is_bound and self.filter_form.is_valid(): + qs_utilisateurs = qs_utilisateurs.filter(equipe=self.filter_form.cleaned_data['equipe']) + + # cf requete similaire dans FamilleListView + intervs = Intervenant.objects.filter( + Q(suivi__date_fin_suivi__isnull=True) & ( + Q(date_fin__isnull=True) | Q(date_fin__gt=date.today()) + ) + ).values('intervenant').annotate( + familles=ArrayAgg( + "suivi__famille_id", + distinct=True + ), + nbre_coord=Count( + 'id', filter=Q(suivi__heure_coord=True), + distinct=True + ), + nbre_eval=Count('id', filter=( + Q(suivi__date_debut_suivi__isnull=True) + ), distinct=True), + nbre_suivi=Count('id', filter=( + Q(suivi__date_debut_suivi__isnull=False) + ), distinct=True), + ).order_by('intervenant__id').values( + 'intervenant__id', 'familles', 'nbre_coord', 'nbre_eval', 'nbre_suivi' + ) + intervs_dict = {line['intervenant__id']: line for line in intervs} + + familles = dict(Famille.objects.filter( + suivi__date_fin_suivi__isnull=True + ).with_niveau_interv().values_list('pk', 'niveau_interv')) + + utilisateurs = [] + charge_map = {None: 0, 0: 0, 1: 1, 2: 2, 3: 5} + for util in qs_utilisateurs: + for key in 'familles', 'nbre_coord', 'nbre_eval', 'nbre_suivi': + setattr(util, key, intervs_dict.get(util.pk, {}).get(key, [] if key == 'familles' else 0)) + util.charge = sum([charge_map[familles[fam_pk]] for fam_pk in util.familles]) + if util.taux_activite == 0 and util.charge is None: + continue + util.heures = util.charge + util.nbre_coord if util.charge is not None else 0 + util.charge_diff = util.charge_max - util.heures + utilisateurs.append(util) + return utilisateurs + + def get_context_data(self, **kwargs): + return { + **super().get_context_data(**kwargs), + 'utilisateurs': self.get_queryset(), + 'filter_form': self.filter_form, + } + + +class UtilisateurDeleteView(PermissionRequiredMixin, DeleteView): + """Archive, ne supprime pas réellement.""" + permission_required = 'aemo.change_utilisateur' + + model = Utilisateur + success_url = reverse_lazy('utilisateur-list') + + def form_valid(self, form): + self.object.is_active = False # C'est ce flag qui empêche la connexion au système + self.object.est_actif = False + self.object.date_desactivation = date.today() + self.object.save() + return HttpResponseRedirect(self.success_url) + + +class UtilisateurAutocompleteView(autocomplete.Select2QuerySetView): + def get_queryset(self): + qs = Utilisateur.objects.filter(service__sigle='CRNE', date_desactivation__isnull=True) + + if self.q: + qs = qs.filter(nom__istartswith=self.q) + return qs + + +class CercleScolaireListView(ListView): + template_name = 'aemo/cercle_scolaire_list.html' + model = CercleScolaire + + +class CercleScolaireUpdateView(PermissionRequiredMixin, UpdateView): + permission_required = 'aemo.change_cerclescolaire' + template_name = 'aemo/cercle_scolaire_edit.html' + model = CercleScolaire + fields = '__all__' + success_url = reverse_lazy('cercle-list') + + def delete_url(self): + return reverse('cercle-delete', args=[self.object.pk]) + + +class CercleScolaireCreateView(PermissionRequiredMixin, CreateView): + permission_required = 'aemo.add_cerclescolaire' + template_name = 'aemo/cercle_scolaire_edit.html' + model = CercleScolaire + fields = '__all__' + success_url = reverse_lazy('cercle-list') + + +class CercleScolaireDeleteView(PermissionRequiredMixin, DeleteView): + permission_required = 'aemo.delete_cerclescolaire' + model = CercleScolaire + success_url = reverse_lazy('cercle-list') + + +class RoleListView(ListView): + template_name = 'aemo/role_list.html' + model = Role + + def get_context_data(self, **kwargs): + return { + **super().get_context_data(**kwargs), + 'editeur_help': self.model._meta.get_field('est_editeur').help_text, + } + + +class RoleUpdateView(PermissionRequiredMixin, UpdateView): + permission_required = 'aemo.change_role' + template_name = 'aemo/role_edit.html' + model = Role + fields = '__all__' + success_url = reverse_lazy('role-list') + + def delete_url(self): + if self.object.personne_set.count() == 0: + return reverse('role-delete', args=[self.object.pk]) + + +class RoleCreateView(PermissionRequiredMixin, CreateView): + permission_required = 'aemo.add_role' + template_name = 'aemo/role_edit.html' + model = Role + fields = '__all__' + success_url = reverse_lazy('role-list') + + def form_valid(self, form): + if is_ajax(self.request): + role = form.save() + return JsonResponse({'pk': role.pk, 'nom': role.nom}) + return super().form_valid(form) + + def form_invalid(self, form): + if is_ajax(self.request): + return JsonResponse({'error': form.errors.as_text()}) + return super().form_invalid(form) + + +class RoleDeleteView(PermissionRequiredMixin, DeleteView): + permission_required = 'aemo.delete_role' + model = Role + success_url = reverse_lazy('role-list') + + def form_valid(self, form): + try: + return super().form_valid(form) + except ProtectedError as e: + # TODO: Il y a certainement mieux... + messages.add_message(self.request, messages.ERROR, str(e), "Suppression impossible") + return HttpResponseRedirect(reverse('role-list')) + + +class FamilleCreateView(CreateView): + template_name = 'aemo/famille_edit.html' + model = Famille + form_class = forms.FamilleCreateForm + action = 'Nouvelle famille' + + def dispatch(self, request, *args, **kwargs): + if not request.user.has_perm('aemo.add_famille'): + raise PermissionDenied(MSG_ACCESS_DENIED) + return super().dispatch(request, *args, **kwargs) + + def form_valid(self, form): + famille = form.save() + return HttpResponseRedirect(reverse('famille-edit', args=[famille.pk])) + + +class FamilleUpdateView(EquipeRequiredMixin, SuccessMessageMixin, UpdateView): + template_name = 'aemo/famille_edit.html' + model = Famille + famille_model = Famille + form_class = forms.FamilleForm + context_object_name = 'famille' + title = 'Modification' + archive_url = None + success_message = 'La famille %(nom)s a bien été modifiée.' + + def get_success_url(self): + return self.success_url or reverse('famille-edit', args=[self.object.pk]) + + def get_form_kwargs(self): + return {**super().get_form_kwargs(), 'readonly': self.readonly} + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context.update({ + 'enfant_suivi': Role.objects.get(nom='Enfant suivi'), + 'enfant_non_suivi': Role.objects.get(nom='Enfant non-suivi'), + }) + return context + + +class FamilleListView(ListView): + template_name = 'aemo/famille_list.html' + model = Famille + paginate_by = 20 + mode = 'normal' + normal_cols = [ + ('Nom', 'nom'), ('Adresse', 'localite'), ('Réf. AEMO', 'referents'), + ('Réf. OPE', 'referents_ope'), ('Statut', 'suivi'), ('Prior.', 'prioritaire'), + ('Niveau', 'niveau_interv'), + ] + attente_cols = [ + ('Nom', 'nom'), ('Adresse', 'localite'), ('Région', 'region'), + ('Réf. AEMO', 'referents'), ('Réf. OPE', 'referents_ope'), ('Prior.', 'prioritaire'), + ('Demande', 'date_demande'), ('Évaluation', 'evaluation'), + ] + + def get(self, request, *args, **kwargs): + if self.mode == 'attente': + self.paginate_by = None + form_class = forms.EquipeFilterForm + else: + form_class = forms.FamilleFilterForm + self.filter_form = form_class(data=self.request.GET or None) + return super().get(request, *args, **kwargs) + + def get_queryset(self): + familles = super().get_queryset().with_niveau_interv().filter( + suivi__isnull=False, suivi__date_fin_suivi__isnull=True + ).select_related( + 'suivi__ope_referent', 'suivi__ope_referent_2' + ).prefetch_related( + Prefetch( + 'suivi__intervenant_set', + queryset=Intervenant.objects.actifs().select_related('intervenant', 'role') + ), + Prefetch( + 'niveaux', queryset=Niveau.objects.order_by('-date_debut') + ), + 'suivi__intervenants', 'bilans', 'rapports' + ).order_by('nom', 'npa') + + if self.mode == 'attente': + familles = familles.filter( + suivi__date_debut_suivi__isnull=True + ).order_by( + '-suivi__demande_prioritaire', 'suivi__date_demande', + ) + if self.filter_form.is_bound and self.filter_form.is_valid(): + familles = self.filter_form.filter(familles) + return familles + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + if self.mode == 'attente': + cols = self.attente_cols + else: + cols = self.normal_cols + # cf requete similaire dans UtilisateurChargeDossierView + current_user = self.request.user + ma_charge = current_user.interventions.filter( + Q(intervenant__date_fin__isnull=True) | Q(intervenant__date_fin__gt=date.today()) + ).annotate( + niveau_interv=Subquery( + Niveau.objects.filter( + famille=OuterRef('famille_id') + ).order_by('-date_debut').values('niveau_interv')[:1] + ), + ).aggregate( + charge=Sum(Case( + When(niveau_interv__lt=3, then='niveau_interv'), + When(niveau_interv=3, then=5), + ), filter=Q(date_fin_suivi__isnull=True)), + nbre_coord=Count('heure_coord', filter=Q(heure_coord=True, date_fin_suivi__isnull=True)), + nbre_eval=Count('id', filter=( + Q(date_fin_suivi__isnull=True) & Q(date_debut_suivi__isnull=True) + )), + nbre_suivi=Count('id', filter=( + Q(date_fin_suivi__isnull=True) & Q(date_debut_suivi__isnull=False) + )), + ) + + context.update({ + 'labels': [c[0] for c in cols], + 'col_keys': [c[1] for c in cols], + 'form': self.filter_form, + 'ma_charge': { + 'heures': ma_charge['charge'] + ma_charge['nbre_coord'], + 'nbre_eval': ma_charge['nbre_eval'], + 'nbre_suivi': ma_charge['nbre_suivi'], + 'charge_diff': current_user.charge_max - (ma_charge['charge'] + ma_charge['nbre_coord']), + } if ma_charge['charge'] is not None else '', + }) + return context + + +class FamilleArchivableListe(View): + """Return all family ids which are archivable by the current user.""" + + def get(self, request, *args, **kwargs): + data = [ + famille.pk for famille in Famille.objects.filter( + archived_at__isnull=True, suivi__date_fin_suivi__isnull=False + ) + if famille.can_be_archived(request.user) + ] + return JsonResponse(data, safe=False) + + +class SuiviView(EquipeRequiredMixin, SuccessMessageMixin, JournalAccesMixin, UpdateView): + template_name = 'aemo/suivi_edit.html' + model = Suivi + famille_model = Famille + form_class = forms.SuiviForm + success_message = 'Les modifications ont été enregistrées avec succès' + + def get_object(self, queryset=None): + return get_object_or_404(self.model, famille__pk=self.kwargs['pk']) + + def get_form_kwargs(self): + return {**super().get_form_kwargs(), 'readonly': self.readonly} + + def get_success_url(self): + return self.object.famille.suivi_url + + def get_context_data(self, **kwargs): + return { + **super().get_context_data(**kwargs), + 'famille': self.object.famille, + 'intervenants': self.object.famille.interventions_actives().order_by('role'), + 'niveaux': self.object.famille.niveaux.all().annotate( + date_fin_calc=Coalesce('date_fin', self.object.date_fin_suivi) + ).order_by('pk'), + } + + +class SuiviIntervenantCreate(EquipeRequiredMixin, CreateView): + model = Intervenant + form_class = forms.IntervenantForm + template_name = 'aemo/form_in_popup.html' + titre_page = "Ajout d’un intervenant" + titre_formulaire = "Intervenant" + + def form_valid(self, form): + form.instance.suivi = Suivi.objects.get(famille__pk=self.kwargs['pk']) + return super().form_valid(form) + + def get_success_url(self): + return reverse('famille-suivi', args=[self.kwargs['pk']]) + + +class SuiviIntervenantUpdateView(EquipeRequiredMixin, UpdateView): + model = Intervenant + form_class = forms.IntervenantEditForm + template_name = 'aemo/form_in_popup.html' + titre_page = "Modification d’une intervention" + titre_formulaire = "Intervenant" + + pk_url_kwarg = 'obj_pk' + + def get_success_url(self): + return reverse('famille-suivi', args=[self.kwargs['pk']]) + + +class SuivisTerminesListView(FamilleListView): + template_name = 'aemo/suivis_termines_list.html' + + def get_queryset(self): + familles = self.model.objects.filter(**{ + 'suivi__date_fin_suivi__isnull': False, + 'archived_at__isnull': True + }).select_related('suivi') + + if self.filter_form.is_bound and self.filter_form.is_valid(): + familles = self.filter_form.filter(familles) + return familles + + +class AgendaSuiviView(EquipeRequiredMixin, SuccessMessageMixin, UpdateView): + template_name = 'aemo/agenda_suivi.html' + model = Suivi + famille_model = Famille + form_class = forms.AgendaForm + success_message = 'Les modifications ont été enregistrées avec succès' + + def get_object(self, queryset=None): + return get_object_or_404(self.model, famille__pk=self.kwargs['pk']) + + def get_form_kwargs(self): + return {**super().get_form_kwargs(), + 'readonly': self.readonly, + 'destination': self.get_object().famille.destination, + 'request': self.request} + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + dfin = self.object.date_fin_suivi + ddebut = self.object.date_debut_suivi + context.update({ + 'bilans_et_rapports': sorted( + list(self.famille.bilans.all()) + list(self.famille.rapports.all()), + key=attrgetter('date'), + ), + 'mode': 'reactivation' if dfin else ('evaluation' if ddebut is None else 'suivi'), + 'interv_temporaires': self.object.intervenant_set.filter(date_fin__isnull=False), + 'niveaux': self.object.famille.niveaux.all().annotate( + date_fin_calc=Coalesce('date_fin', self.object.date_fin_suivi) + ).order_by('pk'), + }) + return context + + def form_valid(self, form): + response = super().form_valid(form) + if ( + 'date_debut_suivi' in form.changed_data and form.cleaned_data['date_debut_suivi'] and + form.cleaned_data['date_debut_suivi'] < date.today() + ): + # Contrôle attribution des prestations (accompagnement) depuis début suivi. + self.famille.prestations.filter( + date_prestation__gte=form.cleaned_data['date_debut_suivi'], lib_prestation__code='aemo01' + ).update(lib_prestation=LibellePrestation.objects.get(code='aemo02')) + return response + + def get_success_url(self): + if self.object.date_fin_suivi: + return reverse('famille-list') + return reverse('famille-agenda', args=[self.object.famille.pk]) + + +class DemandeView(EquipeRequiredMixin, SuccessMessageMixin, UpdateView): + template_name = 'aemo/demande_edit.html' + model = Suivi + famille_model = Famille + form_class = forms.DemandeForm + success_message = 'Les modifications ont été enregistrées avec succès' + + def get_form_kwargs(self): + return {**super().get_form_kwargs(), 'readonly': self.readonly} + + def get_object(self, queryset=None): + suivi = self.famille.suivi + items = [ + "Selon le professionnel", + "Selon les parents", + "Selon l'enfant", + ] + if suivi.difficultes == '': + suivi.difficultes = ''.join( + ['

    {}

    '.format(item) for item in items if suivi.difficultes == ''] + ) + if suivi.aides == '': + suivi.aides = ''.join( + ['{}

    '.format(item) for item in items if suivi.aides == ''] + ) + if suivi.disponibilites == '': + suivi.disponibilites = ''.join( + ['{}

    '.format(item) for item in ['Parents', 'Enfants'] + if suivi.disponibilites == ''] + ) + return suivi + + def get_success_url(self): + return reverse('demande', args=[self.object.famille.pk]) + + +class EvaluationPDFView(BasePDFView): + obj_class = Famille + pdf_class = EvaluationPdf + + +class CoordonneesPDFView(BasePDFView): + obj_class = Famille + pdf_class = CoordonneesFamillePdf + + +class JournalPDFView(BasePDFView): + obj_class = Famille + pdf_class = JournalPdf + + +class BilanPDFView(BasePDFView): + obj_class = Bilan + pdf_class = BilanPdf + + +class PrestationMenu(ListView): + template_name = 'aemo/prestation_menu.html' + model = Prestation + paginate_by = 15 + context_object_name = 'familles' + + def get_queryset(self): + user = self.request.user + rdv_manques = dict( + Famille.actives().annotate( + rdv_manques=ArrayAgg( + 'prestations__date_prestation', + filter=Q(prestations__manque=True), + ordering='prestations__date_prestation', + default=Value([]) + ) + ).values_list('pk', 'rdv_manques') + ) + familles = Famille.actives( + ).annotate( + user_prest=Sum('prestations__duree', + filter=Q(**{'prestations__intervenants': user})) + ).annotate( + aemo1=Sum('prestations__duree', + filter=(Q(**{'prestations__lib_prestation__code': 'aemo01'})) + ) or None + ).annotate( + aemo2=Sum('prestations__duree', + filter=(Q(**{'prestations__lib_prestation__code': 'aemo02'})) + ) or None + ) + for famille in familles: + famille.rdv_manques = [format_d_m_Y(rdv) for rdv in rdv_manques[famille.pk]] + return familles + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context.update({ + 'annee': date.today().year + }) + return context + + +class PrestationBaseView: + model = Prestation + famille_model = Famille + form_class = forms.PrestationForm + template_name = 'aemo/prestation_edit.html' + + def dispatch(self, *args, **kwargs): + self.famille = get_object_or_404(self.famille_model, pk=self.kwargs['pk']) if self.kwargs.get('pk') else None + return super().dispatch(*args, **kwargs) + + def get_context_data(self, **kwargs): + return {**super().get_context_data(**kwargs), 'famille': self.famille} + + def get_success_url(self): + if self.famille: + return reverse('journal-list', args=[self.famille.pk]) + return reverse('prestation-gen-list') + + +class PrestationListView(PrestationBaseView, ListView): + template_name = 'aemo/prestation_list.html' + model = Prestation + paginate_by = 15 + context_object_name = 'prestations' + + def get(self, request, **kwargs): + self.filter_form = forms.JournalAuteurFilterForm(famille=self.famille, data=request.GET or None) + return super().get(request, **kwargs) + + def get_queryset(self): + if self.famille: + prestations = self.famille.prestations.all().order_by('-date_prestation') + if self.filter_form.is_bound and self.filter_form.is_valid(): + prestations = self.filter_form.filter(prestations) + return prestations + return self.request.user.prestations.filter(famille=None) + + def get_context_data(self, **kwargs): + return super().get_context_data(**kwargs, filter_form=self.filter_form) + + +class PrestationCreateView(PrestationBaseView, CreateView): + def dispatch(self, request, *args, **kwargs): + if not request.user.has_perm('aemo.add_prestation'): + raise PermissionDenied("Vous n'avez pas les droits pour ajouter une prestation.") + return super().dispatch(request, *args, **kwargs) + + def get_initial(self): + initial_text = ( + "

    Observations:\n" + "(relations et interactions dans la famille / disponibilité des parents / " + "réponses données aux besoins des enfants / …)

    " + "

    Discussion (thème-s abordé-s) / activités:

    " + "

    Particularités en termes de ressources et/ou de limites:

    " + "

    Ressentis de l’intervenant-e:

    " + "

    Objectif-s traité-s:

    " + "

    Objectif-s à suivre:

    " + ) + initial = { + **super().get_initial(), + 'intervenants': [self.request.user.pk], + 'texte': initial_text if self.famille else '', + } + return initial + + def get_form_kwargs(self): + return {**super().get_form_kwargs(), 'famille': self.famille, 'user': self.request.user} + + def get_lib_prestation(self, date_prest): + """ + Renvoie la prestation en fonction de la famille et du rôle de l'utilisateur connecté. + """ + if self.famille is None: + code = 'aemo03' # Prestation générale + elif self.famille.suivi.date_debut_suivi and date_prest >= self.famille.suivi.date_debut_suivi: + code = 'aemo02' # Accompagnement + else: + code = 'aemo01' # Évaluation + return LibellePrestation.objects.get(code=code) + + def form_valid(self, form): + if self.famille: + form.instance.famille = self.famille + form.instance.auteur = self.request.user + form.instance.lib_prestation = self.get_lib_prestation(form.cleaned_data['date_prestation']) + if 'duree' not in form.cleaned_data: + form.instance.duree = timedelta() + return super().form_valid(form) + + +class PrestationUpdateView(CheckCanEditMixin, PrestationBaseView, UpdateView): + pk_url_kwarg = 'obj_pk' + + def get_form_kwargs(self): + return {**super().get_form_kwargs(), 'famille': self.famille, 'user': self.request.user} + + def delete_url(self): + fam_id = self.famille.pk if self.famille else 0 + return reverse('prestation-delete', args=[fam_id, self.object.pk]) + + +class PrestationDeleteView(PrestationBaseView, DeleteView): + template_name = 'aemo/object_confirm_delete.html' + pk_url_kwarg = 'obj_pk' + form_class = DeleteView.form_class + + +class NiveauCreateUpdateView(CreateUpdateView): + model = Niveau + form_class = forms.NiveauForm + template_name = 'aemo/form_in_popup.html' + + def dispatch(self, request, *args, **kwargs): + self.is_create = 'pk' not in kwargs + self.famille = get_object_or_404(Famille, pk=kwargs['obj_pk']) + self.titre_page = f"Famille: {self.famille.nom}" + self.titre_formulaire = "Nouveau niveau d'intervention" if self.is_create \ + else "Modification de l'enregistrement" + return super().dispatch(request, *args, **kwargs) + + def get_form_kwargs(self): + kwargs = super().get_form_kwargs() + kwargs['famille'] = self.famille + return kwargs + + def form_valid(self, form): + if self.is_create: + der_niv = self.famille.niveaux.last() if self.famille.niveaux.count() > 0 else None + if der_niv: + der_niv.date_fin = form.cleaned_data['date_debut'] - timedelta(days=1) + der_niv.save() + + form.instance.famille = self.famille + form.instance.date_fin = None + return super().form_valid(form) + + def get_success_url(self): + return reverse('famille-suivi', args=[self.famille.pk]) + + def delete_url(self): + return reverse('niveau-delete', args=[self.famille.pk, self.object.pk]) + + +class NiveauDeleteView(DeleteView): + template_name = 'aemo/object_confirm_delete.html' + form_class = DeleteView.form_class + model = Niveau + + def get_success_url(self): + return reverse('famille-suivi', args=[self.kwargs['obj_pk']]) + + +class DocumentBaseView: + template_name = 'aemo/document_upload.html' + titre_page = '' + titre_formulaire = '' + + def dispatch(self, request, *args, **kwargs): + self.famille = get_object_or_404(Famille, pk=kwargs['pk']) + self.titre_page = self.titre_page.format(famille=self.famille.nom) + return super().dispatch(request, *args, **kwargs) + + def get_success_url(self): + return self.famille.suivi_url + + +class DocumentUploadView(DocumentBaseView, CreateView): + form_class = forms.DocumentUploadForm + titre_page = 'Documents externes de la famille {famille}' + titre_formulaire = 'Nouveau document' + + def get_initial(self): + initial = super().get_initial() + initial['famille'] = self.famille + return initial + + def form_valid(self, form): + form.instance.famille = self.famille + form.instance.auteur = self.request.user + return super().form_valid(form) + + +class DocumentDeleteView(DeleteView): + model = Document + pk_url_kwarg = 'doc_pk' + + def form_valid(self, form): + response = super().form_valid(form) + messages.success(self.request, "Le document a été supprimé avec succès") + return response + + def get_success_url(self): + return self.request.META.get('HTTP_REFERER') or self.object.famille.suivi_url + + + +class BilanEditView(DocumentUploadView, CreateUpdateView): + pk_url_kwarg = 'obj_pk' + titre_page = 'Bilan pour la famille {famille}' + titre_formulaire = 'Nouveau bilan' + model = Bilan + form_class = forms.BilanForm + + def get_form_class(self): + if self.form_class: + return self.form_class + return modelform_factory( + model=self.model, + exclude=['famille', 'auteur'], + field_classes={ + 'objectifs': forms.RichTextField, + 'rythme': forms.RichTextField, + }, + widgets={ + 'famille': HiddenInput, + 'date': forms.PickDateWidget, + }, + labels={'fichier': ''}, + ) + + def get_initial(self): + initial = super().get_initial() + if self.is_create: + initial['objectifs'] = ( + "

    Besoin de l’enfant

    \n" + "

    Objectifs de la famille

    \n" + "

    Moyens

    \n" + "

    Critères

    " + ) + return initial + + def get_success_url(self): + return reverse('famille-agenda', args=[self.famille.pk]) + + +class BilanDetailView(DetailView): + model = Bilan + pk_url_kwarg = 'obj_pk' + template_name = 'aemo/bilan.html' + + def get_context_data(self, **kwargs): + meta = self.model._meta + data_fields = [ + f.name for f in meta.get_fields() + if f.name not in ['id', 'date', 'famille', 'auteur', 'fichier', 'phase', 'sig_famille', 'sig_interv'] + ] + return { + **super().get_context_data(**kwargs), + 'famille': self.object.famille, + 'data': [(meta.get_field(f).verbose_name, getattr(self.object, f)) for f in data_fields] + } + + +class BilanDeleteView(DeleteView): + model = Bilan + pk_url_kwarg = 'obj_pk' + + def dispatch(self, request, *args, **kwargs): + self.famille = get_object_or_404(Famille, pk=kwargs['pk']) + if not self.get_object().can_edit(request.user): + raise PermissionDenied("Vous n'avez pas les droits de supprimer ce bilan.") + return super().dispatch(request, *args, **kwargs) + + def get_success_url(self): + return reverse('famille-agenda', args=[self.famille.pk]) + + +class BaseRapportView: + model = Rapport + + def dispatch(self, request, *args, **kwargs): + self.famille = get_object_or_404(Famille, pk=kwargs['pk']) + return super().dispatch(request, *args, **kwargs) + + +class RapportCreateView(DocumentUploadView, BaseRapportView, CreateView): + form_class = forms.RapportEditForm + template_name = 'aemo/rapport_edit.html' + + def get_form_class(self): + return modelform_factory(self.model, form=self.form_class) + + def get_success_url(self): + return reverse('famille-agenda', args=[self.famille.pk]) + + +class RapportDisplayView(BaseRapportView, DetailView): + pk_url_kwarg = 'obj_pk' + template_name = 'aemo/rapport.html' + + def get_context_data(self, **kwargs): + meta = self.model._meta + fields = ['situation', 'observations', 'projet'] + return { + **super().get_context_data(**kwargs), + 'famille': self.object.famille, + 'rapport': self.object, + 'intervenants': ', '.join([i.nom_prenom for i in self.object.intervenants()]), + 'enfants': '\n'.join( + [f"{enfant.nom_prenom} (*{format_d_m_Y(enfant.date_naissance)})" + for enfant in self.object.famille.membres_suivis()] + ), + 'data': [ + (meta.get_field(f).verbose_name, getattr(self.object, f)) for f in fields + if getattr(self.object, f) or f in ['situation', 'projet'] + ] + } + + +class RapportUpdateView(DocumentUploadView, BaseRapportView, CreateUpdateView): + pk_url_kwarg = 'obj_pk' + form_class = forms.RapportEditForm + template_name = 'aemo/rapport_edit.html' + + def get_form_class(self): + return modelform_factory(self.model, form=self.form_class) + + def get_form_kwargs(self): + return { + **super().get_form_kwargs(), + 'user': self.request.user, + } + + def get_success_url(self): + return reverse('famille-agenda', args=[self.famille.pk]) + + +class RapportDeleteView(BaseRapportView, DeleteView): + model = Rapport + pk_url_kwarg = 'obj_pk' + + def form_valid(self, form): + if not self.object.can_edit(self.request.user): + raise PermissionDenied("Vous n'avez pas le droit de supprimer ce résumé.") + return super().form_valid(form) + + def get_success_url(self): + return reverse('famille-agenda', args=[self.famille.pk]) + + +class RapportPDFView(BaseRapportView, BasePDFView): + pdf_class = RapportPdf + pk_url_kwarg = 'obj_pk' + + @property + def obj_class(self): + return self.model + + +class FamilleAdresseChangeView(UpdateView): + template_name = 'aemo/famille_adresse.html' + form_class = forms.FamilleAdresseForm + context_object_name = 'famille' + + def dispatch(self, request, *args, **kwargs): + if not self.get_object().can_edit(request.user): + raise PermissionDenied("Vous n’avez pas la permission d’accéder à cette page.") + return super().dispatch(request, *args, **kwargs) + + def get_object(self): + return get_object_or_404(Famille, pk=self.kwargs['pk']) + + def get_form_kwargs(self): + kwargs = super().get_form_kwargs() + kwargs['famille'] = self.get_object() + return kwargs + + def form_valid(self, form): + famille = form.save() + for membre_id in form.cleaned_data['membres']: + membre = get_object_or_404(Personne, pk=membre_id) + membre.rue = famille.rue + membre.npa = famille.npa + membre.localite = famille.localite + membre.save() + return HttpResponseRedirect(famille.edit_url) + + +class FamilleAutoCompleteView(autocomplete.Select2QuerySetView): + def get_queryset(self): + qs = Famille.objects.filter(archived_at__isnull=True) + if self.q: + qs = qs.filter(nom__icontains=self.q) + return qs.prefetch_related('membres') + + def get_result_label(self, result): + enfants = ", ".join(enf.prenom for enf in result.membres_suivis()) + label = f'{result.nom}, {result.npa} {result.localite}' + if enfants: + label += f" ({enfants})" + return label + + +class PermissionOverview(TemplateView): + template_name = 'aemo/permissions.html' + perm_map = {'add': 'création', 'change': 'modification', 'delete': 'suppression', 'view': 'affichage'} + + def get_context_data(self, **kwargs): + def verbose(perm): + return self.perm_map.get(perm.codename.split('_')[0], perm.codename) + + context = super().get_context_data(**kwargs) + groups = Group.objects.all().prefetch_related('permissions') + grp_perms = {gr.name: [perm.codename for perm in gr.permissions.all()] for gr in groups} + objects = [CercleScolaire, Contact, Role, Service, Utilisateur] + all_perms = Permission.objects.filter( + content_type__app_label__in=['aemo'], + content_type__model__in=[o.__name__.lower() for o in objects] + ) + + def perms_for_model(model): + return [ + (perm.codename, verbose(perm)) for perm in all_perms + if perm.content_type.model == model.__name__.lower() + ] + + perm_groups = OrderedDict() + # {'Contact': [('view_contact', 'affichage), ('change_contact', 'modification'), ...]} + for obj in objects: + perm_groups[obj._meta.verbose_name] = perms_for_model(obj) + perm_groups['AEMO'] = list(Suivi._meta.permissions) + perm_groups['AEMO'].extend([('delete_famille', 'Supprimer une famille')]) + context.update({ + 'groups': groups, + 'grp_perms': grp_perms, + 'perms_by_categ': perm_groups, + }) + return context + + +class ExportPrestationView(PermissionRequiredMixin, FormView): + permission_required = 'aemo.export_stats' + template_name = 'aemo/export.html' + form_class = forms.MonthSelectionForm + filename = 'aemo_reporting_{}'.format(date.strftime(date.today(), '%Y-%m-%d')) + + def _prepare_query(self, query, prest_gen): + """Return a list of families, sorted by prest and name.""" + return query.annotate( + prest_gen=Value(prest_gen, output_field=DurationField()) + ).order_by('nom') + + def get_initial(self): + initial = super().get_initial() + initial.update({ + 'mois': date.today().month - 1 if date.today().month > 1 else 12, + 'annee': date.today().year if date.today().month > 1 else date.today().year - 1, + }) + return initial + + def form_valid(self, form): + mois = int(form.cleaned_data['mois']) + annee = int(form.cleaned_data['annee']) + debut_mois = date(annee, mois, 1) + fin_mois = date(annee, mois, calendar.monthrange(annee, mois)[1]) + + export = ExportReporting() + familles_en_cours = Famille.suivis_en_cours(debut_mois, fin_mois) + # Considérer au min. 1 enfant par famille, car les familles sans enfant + # suivi occupent aussi une ligne de la stat des familles en cours. + num_enfants = sum( + [len(fam.membres_suivis()) or 1 for fam in familles_en_cours] + ) + # Calcul des prestations générales réparties sur chaque enfant suivi + # Renvoie une durée (timedelta()) par enfant suivi + prest_gen = Prestation.objects.filter( + date_prestation__month=mois, + date_prestation__year=annee, + famille=None + ).annotate( + num_util=Count('intervenants') + ).aggregate( + total=Sum(F('duree') * F('num_util'), output_field=DurationField()) + )['total'] or timedelta() + logger.info("Total heures prest. gén. du mois: %s", prest_gen.total_seconds() / 3600) + prest_gen_par_enfant = prest_gen / (num_enfants or 1) + + # Demandes en cours + export.produce_suivis( + 'En_cours_{}'.format(debut_mois.strftime("%m.%Y")), + self._prepare_query(familles_en_cours, prest_gen_par_enfant), + debut_mois + ) + + # Nouvelles demandes + familles_nouvelles = Famille.suivis_nouveaux(annee, mois) + export.produce_nouveaux( + "Nouveaux_{}".format(debut_mois.strftime("%m.%Y")), + self._prepare_query(familles_nouvelles, prest_gen_par_enfant), + ) + + # Fins de suivis + familles_terminees = Famille.suivis_termines(annee, mois) + export.produce_termines( + "Terminés_{}".format(debut_mois.strftime("%m.%Y")), + self._prepare_query(familles_terminees, prest_gen_par_enfant), + ) + export.produce_totaux("Totaux heures du mois") + + return export.get_http_response( + 'aemo_reporting_{}.xlsx'.format(date.strftime(debut_mois, '%m_%Y')) + ) + + +class BasePrestationGeneraleEtPersonnelle(ListView): + + def dispatch(self, request, *args, **kwargs): + str_curdate = request.GET.get('date', None) + if str_curdate: + cur_year = int(str_curdate[-4:]) + cur_month = int(str_curdate[:2]) + else: + today = date.today() + cur_year = today.year + cur_month = today.month + self.dfrom = date(cur_year, cur_month, 1) + self.dto = self.dfrom + timedelta(days=-1 + calendar.monthrange(self.dfrom.year, self.dfrom.month)[1]) + return super().dispatch(request, *args, **kwargs) + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + prev_month = self.dfrom - timedelta(days=2) + next_month = self.dfrom + timedelta(days=33) + + context.update({ + 'current_date': self.dfrom, + 'prev_month': prev_month if prev_month.year > 2019 else None, + 'next_month': next_month if next_month.month <= date.today().month else None, + }) + return context + + +class PrestationPersonnelleListView(BasePrestationGeneraleEtPersonnelle): + template_name = 'aemo/prestation_personnelle.html' + + def get_queryset(self): + return self.request.user.prestations.filter( + date_prestation__gte=self.dfrom, date_prestation__lte=self.dto + ) + + def get_context_data(self, *args, **kwargs): + context = super().get_context_data(*args, **kwargs) + context.update({ + 'totaux': [(pp.code, self.get_queryset().filter( + lib_prestation__code=f"{pp.code}" + ).aggregate( + tot=Sum('duree') + )['tot']) for pp in LibellePrestation.objects.all()], + 'total_final': self.get_queryset().aggregate(tot=Sum('duree'))['tot'], + }) + return context + + +class PrestationGeneraleListView(BasePrestationGeneraleEtPersonnelle): + model = Prestation + template_name = 'aemo/prestation_generale.html' + + def get_queryset(self): + return super().get_queryset().filter( + famille=None, + date_prestation__gte=self.dfrom, + date_prestation__lte=self.dto + ) + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context.update({ + 'prestations': self.get_queryset(), + }) + return context + + +class FamilleReactivationView(View): + + def post(self, request, *args, **kwargs): + famille = get_object_or_404(Famille, pk=kwargs['pk']) + if famille.can_be_reactivated(request.user): + famille.suivi.date_fin_suivi = None + famille.suivi.motif_fin_suivi = "" + famille.suivi.save() + famille.destination = '' + famille.save() + return HttpResponseRedirect(reverse('famille-list')) + + +def page_not_found(request, *args, **kwargs): + if not request.user.is_authenticated: + kwargs['template_name'] = '404-public.html' + return django_page_not_found(request, *args, **kwargs) diff --git a/aemo/views_stats.py b/aemo/views_stats.py new file mode 100644 index 0000000..0eb2fee --- /dev/null +++ b/aemo/views_stats.py @@ -0,0 +1,909 @@ +import calendar +from dataclasses import dataclass +from datetime import date, timedelta +from operator import attrgetter + +from django import forms +from django.contrib.auth.mixins import PermissionRequiredMixin +from django.db.models import ( + Case, Count, DurationField, ExpressionWrapper, F, IntegerField, OuterRef, Q, + Subquery, Sum, When +) +from django.db.models.functions import Coalesce, TruncMonth +from django.utils.dates import MONTHS +from django.views.generic import TemplateView + +from .forms import DateYearForm +from .models import Intervenant, LibellePrestation, Niveau, Personne, Prestation, Suivi, Utilisateur +from .utils import format_d_m_Y, format_duree +from common.choices import ( + MOTIF_DEMANDE_CHOICES, MOTIFS_FIN_SUIVI_CHOICES, PROVENANCE_DESTINATION_CHOICES, + SERVICE_ORIENTEUR_CHOICES, +) +from .export import ExportStatistique + + +class DateLimitForm(forms.Form): + YEAR_CHOICES = tuple( + (str(y), str(y)) + for y in range(2020, date.today().year + (1 if date.today().month < 12 else 2)) + ) + start_month = forms.ChoiceField(choices=[(str(m), MONTHS[m]) for m in range(1, 13)]) + start_year = forms.ChoiceField(choices=YEAR_CHOICES) + end_month = forms.ChoiceField(choices=[(str(m), MONTHS[m]) for m in range(1, 13)]) + end_year = forms.ChoiceField(choices=YEAR_CHOICES) + + def __init__(self, data, **kwargs): + if not data: + today = date.today() + data = { + 'start_year': today.year, 'start_month': today.month, + 'end_year': today.year if today.month < 12 else today.year + 1, + 'end_month': (today.month + 1) if today.month < 12 else 1, + } + super().__init__(data, **kwargs) + + def clean(self): + cleaned_data = super().clean() + if not self.errors and self.start > self.end: + raise forms.ValidationError("Les dates ne sont pas dans l’ordre.") + return cleaned_data + + @property + def start(self): + return date(int(self.cleaned_data['start_year']), int(self.cleaned_data['start_month']), 1) + + @property + def end(self): + return date( + int(self.cleaned_data['end_year']), + int(self.cleaned_data['end_month']), + calendar.monthrange(int(self.cleaned_data['end_year']), int(self.cleaned_data['end_month']))[1] + ) + + +@dataclass +class Month: + year: int + month: int + + def __str__(self): + return f'{self.month:0>2}.{self.year}' + + def __lt__(self, other): + return (self.year, self.month) < (other.year, other.month) + + def __hash__(self): + return hash((self.year, self.month)) + + def next(self): + if self.month == 12: + return Month(self.year + 1, 1) + else: + return Month(self.year, self.month + 1) + + @classmethod + def from_date(cls, dt): + return Month(dt.year, dt.month) + + def is_future(self): + return date(self.year, self.month, 1) > date.today() + + +class StatsMixin: + permission_required = 'aemo.export_stats' + + def get_months(self): + """Return a list of tuples [(year, month), ...] from date_start to date_end.""" + months = [Month(self.date_start.year, self.date_start.month)] + while True: + next_m = months[-1].next() + if next_m > Month(self.date_end.year, self.date_end.month): + break + months.append(next_m) + return months + + def month_limits(self, month): + """From (2020, 4), return (date(2020, 4, 1), date(2020, 5, 1)).""" + next_m = month.next() + return ( + date(month.year, month.month, 1), + date(next_m.year, next_m.month, 1) + ) + + @staticmethod + def init_counters(names, months, default=0, total=0): + """ + Create stat counters: + {: {(2020, 3): 0, (2020, 4): 0, …, 'total': 0}, …} + """ + counters = {} + for count_name in names: + counters[count_name] = {} + for month in months: + counters[count_name][month] = '-' if month.is_future() else default + counters[count_name]['total'] = total + return counters + + def get_stats(self, months): + # Here subclasses produce stats to be merged in view context + return {} + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + get_params = self.request.GET.copy() + self.export_flag = get_params.pop('export', None) + date_form = DateLimitForm(get_params) + context['date_form'] = date_form + if not date_form.is_valid(): + return context + self.date_start = date_form.start + self.date_end = date_form.end + months = self.get_months() + context.update({ + 'date_form': date_form, + 'months': months, + }) + context.update(self.get_stats(months)) + return context + + def export_as_openxml(self, context): + export = ExportStatistique(col_widths=[50]) + export.fill_data(self.export_lines(context)) + return export.get_http_response(self.__class__.__name__.replace('View', '') + '.xlsx') + + def render_to_response(self, context, **response_kwargs): + if self.export_flag: + return self.export_as_openxml(context) + else: + return super().render_to_response(context, **response_kwargs) + + +class StatistiquesView(StatsMixin, PermissionRequiredMixin, TemplateView): + template_name = 'statistiques/statistiques.html' + labels = { + 'familles_total': 'Total familles (évaluation et/ou accomp.)', + 'enfants_total': 'Total enfants suivis (évaluation et/ou accomp.)', + 'familles_evaluees': 'Familles évaluées', + 'enfants_evalues': 'Enfants évalués', + 'enfants_evalues_non_suivis': 'Enfants non suivis de familles évaluées', + 'familles_eval_sans_suivi': 'Familles évaluées sans aboutir à un suivi', + 'familles_suivies': 'Familles suivies', + 'enfants_suivis': 'Enfants suivis', + 'enfants_suivis_non_suivis': 'Enfants non suivis de familles suivies', + 'familles_accueil': 'dont Familles d’accueil', + 'familles_connues': 'dont Familles déjà suivies', + 'prioritaires': 'Demandes prioritaires', + 'rdv_manques': 'Rendez-vous manqués', + 'duree_attente': 'Durée moyenne entre demande et début de suivi', + } + + def suivi_stats(self, model, months): + suivis_base = model.objects.annotate( + date_fin=Coalesce('date_fin_suivi', date.today()), + date_debut=Coalesce('date_demande', 'date_debut_evaluation', 'date_debut_suivi') + ).filter( + date_debut__lte=self.date_end, + date_fin__gte=self.date_start + ).exclude(motif_fin_suivi='erreur') + + # Annotations pour Count("Enfant suivi"), Count("Enfant non-suivi"), duree_attente + suivis = suivis_base.annotate(enf_suivis=Count(Case( + When(famille__membres__role__nom="Enfant suivi", then=1), + output_field=IntegerField(), + )), enf_nonsuivis=Count(Case( + When(famille__membres__role__nom="Enfant non-suivi", then=1), + output_field=IntegerField(), + )), duree_attente=F('date_debut_suivi') - F('date_demande'), + ).select_related('famille') + + count_keys = [ + 'familles_total', 'enfants_total', + 'familles_evaluees', 'enfants_evalues', 'enfants_evalues_non_suivis', + 'familles_eval_sans_suivi', 'familles_suivies', 'enfants_suivis', + 'enfants_suivis_non_suivis', 'familles_accueil', 'familles_connues', + 'prioritaires', 'rdv_manques', + ] + if not getattr(model, 'demande_prioritaire', False): + count_keys.remove('prioritaires') + self.counters = self.init_counters(count_keys, months) + self.counters['duree_attente'] = {'familles': 0, 'total': timedelta(), 'moyenne': timedelta()} + count_keys.append('duree_attente') + for suivi in suivis: + self.update_counters(suivi, months) + if self.counters['duree_attente']['familles'] > 0: + self.counters['duree_attente']['moyenne'] = ( + self.counters['duree_attente']['total'] / self.counters['duree_attente']['familles'] + ) + + # Rendez-vous manqués + rdv_manques = suivis_base.annotate( + month=TruncMonth('famille__prestations__date_prestation'), + ).values('month').annotate( + rdv_manques=Count( + 'famille__prestations', + filter=Q(**{'famille__prestations__manque': True}) + ) + ) + for line in rdv_manques: + if not line['month']: + continue + month = Month.from_date(line['month']) + if month not in self.counters['rdv_manques']: + continue + self.counters['rdv_manques'][month] = line['rdv_manques'] + self.counters['rdv_manques']['total'] += line['rdv_manques'] + data = {key: self.counters[key] for key in count_keys} + data['total_familles'] = len(suivis) + return data + + def update_counters(self, suivi, months): + def suivi_entre(date_deb, date_fin): + return ( + suivi.date_debut_suivi and suivi.date_debut_suivi < date_fin and + (not suivi.date_fin_suivi or suivi.date_fin_suivi > date_deb) + ) + + def evaluation_entre(date_deb, date_fin): + # Dès la demande éventuelle, on considère la famille en cours d'évaluation + # pour les stats, même sans date de debut d'évaluation + debut_eval = suivi.date_demande or suivi.date_debut_evaluation + fin_eval = suivi.date_fin_evaluation or suivi.date_fin_suivi + return ( + debut_eval and debut_eval < date_fin + and (not fin_eval or fin_eval > date_deb) + ) + + # Pour chaque mois: + for month in months: + if month.is_future(): + continue + month_start, month_end = self.month_limits(month) + month_evalue = evaluation_entre(month_start, month_end) + month_suivi = suivi_entre(month_start, month_end) + if month_evalue: + self.counters['familles_evaluees'][month] += 1 + self.counters['enfants_evalues'][month] += suivi.enf_suivis + self.counters['enfants_evalues_non_suivis'][month] += suivi.enf_nonsuivis + if suivi.famille.accueil: + self.counters['familles_accueil'][month] += 1 + if suivi.famille.connue: + self.counters['familles_connues'][month] += 1 + if month_suivi: + self.counters['familles_suivies'][month] += 1 + self.counters['enfants_suivis'][month] += suivi.enf_suivis + self.counters['enfants_suivis_non_suivis'][month] += suivi.enf_nonsuivis + if suivi.famille.accueil: + self.counters['familles_accueil'][month] += 1 + if suivi.famille.connue: + self.counters['familles_connues'][month] += 1 + if month_evalue or month_suivi: + if getattr(suivi, 'demande_prioritaire', False): + self.counters['prioritaires'][month] += 1 + self.counters['prioritaires']['total'] += 1 + self.counters['familles_total'][month] += 1 + self.counters['enfants_total'][month] += suivi.enf_suivis + + if not suivi.date_debut_suivi and suivi.motif_fin_suivi and ( + suivi.date_fin_suivi >= month_start and suivi.date_fin_suivi < month_end + ): + self.counters['familles_eval_sans_suivi'][month] += 1 + self.counters['familles_eval_sans_suivi']['total'] += 1 + + # Au total: + suivi_evalue = evaluation_entre(self.date_start, self.date_end) + suivi_acc = suivi_entre(self.date_start, self.date_end) + if suivi_evalue: + self.counters['familles_evaluees']['total'] += 1 + self.counters['enfants_evalues']['total'] += suivi.enf_suivis + self.counters['enfants_evalues_non_suivis']['total'] += suivi.enf_nonsuivis + if suivi.famille.accueil: + self.counters['familles_accueil']['total'] += 1 + if suivi.famille.connue: + self.counters['familles_connues']['total'] += 1 + if suivi_acc: + self.counters['familles_suivies']['total'] += 1 + self.counters['enfants_suivis']['total'] += suivi.enf_suivis + self.counters['enfants_suivis_non_suivis']['total'] += suivi.enf_nonsuivis + if suivi.famille.accueil: + self.counters['familles_accueil']['total'] += 1 + if suivi.famille.connue: + self.counters['familles_connues']['total'] += 1 + if suivi_evalue or suivi_acc: + self.counters['familles_total']['total'] += 1 + self.counters['enfants_total']['total'] += suivi.enf_suivis + if suivi.duree_attente is not None and ( + suivi.date_debut_suivi >= self.date_start and suivi.date_debut_suivi <= self.date_end + ): + self.counters['duree_attente']['familles'] += 1 + self.counters['duree_attente']['total'] += suivi.duree_attente + + def get_stats(self, months): + return { + 'familles': self.suivi_stats(Suivi, months), + } + + def export_lines(self, context): + months = context['months'] + + def stats_for(values): + for key, vals in values.items(): + if key in ['duree_attente', 'total_familles']: + continue + yield [self.labels[key]] + [vals[month] for month in months] + [vals['total']] + yield ( + [self.labels['duree_attente'] + ' (jours)'] + + (len(months)) * [''] + + [values['duree_attente']['moyenne'].days] + ) + + yield ['BOLD', 'Familles AEMO'] + [str(month) for month in months] + ['Total'] + yield from stats_for(context['familles']) + + +class StatistiquesParIntervView(StatsMixin, PermissionRequiredMixin, TemplateView): + """This view is currently unused (#344). It might be deleted in the future.""" + template_name = 'statistiques/stats-interv.html' + + def interv_stats(self, model, months): + intervs = model.objects.annotate( + date_fin=Coalesce('suivi__date_fin_suivi', date.today()), + date_debut=Coalesce('suivi__date_debut_evaluation', 'suivi__date_debut_suivi') + ).filter( + date_debut__lte=self.date_end, + date_fin__gte=self.date_start + ).exclude( + suivi__motif_fin_suivi='erreur' + ).annotate( + enf_suivis=Count(Case( + When(suivi__famille__membres__role__nom="Enfant suivi", then=1), + output_field=IntegerField(), + )) + ).select_related('suivi', 'intervenant') + + counters = {} + for interv in intervs: + if interv.intervenant not in counters: + counters[interv.intervenant] = self.init_counters(['num_familles', 'num_enfants'], months) + counters[interv.intervenant]['num_familles']['total'] += 1 + counters[interv.intervenant]['num_enfants']['total'] += interv.enf_suivis + for month in months: + month_start, month_end = self.month_limits(month) + if interv.date_debut <= month_end and interv.date_fin >= month_start: + counters[interv.intervenant]['num_familles'][month] += 1 + counters[interv.intervenant]['num_enfants'][month] += interv.enf_suivis + return {key: counters[key] for key in sorted(counters.keys(), key=attrgetter('nom'))} + + def get_stats(self, months): + return { + 'intervs': self.interv_stats(Intervenant, months), + } + + def export_lines(self, context): + months = context['months'] + + def stats_for(values): + for key, vals in values.items(): + yield [key.nom_prenom, 'Familles'] + [vals['num_familles'][month] for month in months] + yield [key.nom_prenom, 'Enfants'] + [vals['num_enfants'][month] for month in months] + + yield ['BOLD', 'AEMO', ''] + [str(month) for month in months] + yield from stats_for(context['intervs']) + + +class StatistiquesParLocaliteView(StatsMixin, PermissionRequiredMixin, TemplateView): + template_name = 'statistiques/stats-localite.html' + + def localite_query(self, model): + return model.objects.annotate( + date_fin=Coalesce('date_fin_suivi', date.today()), + date_debut=Coalesce('date_demande', 'date_debut_evaluation', 'date_debut_suivi') + ).filter( + date_debut__lte=self.date_end, + date_fin__gte=self.date_start + ).exclude( + motif_fin_suivi='erreur' + ).annotate( + enf_suivis=Count(Case( + When(famille__membres__role__nom="Enfant suivi", then=1), + output_field=IntegerField(), + )) + ).values('date_debut', 'date_fin', 'famille__npa', 'famille__localite', 'enf_suivis') + + def localite_stats(self, model, months): + suivis = self.localite_query(model) + counters = self.init_counters(['totals'], months) + for suivi in suivis: + loc_key = f"{suivi['famille__npa']} {suivi['famille__localite']}" + if loc_key not in counters: + counters.update(self.init_counters([loc_key], months)) + counters[loc_key]['total'] += suivi['enf_suivis'] + counters['totals']['total'] += suivi['enf_suivis'] + for month in months: + month_start, month_end = self.month_limits(month) + if suivi['date_debut'] <= month_end and suivi['date_fin'] >= month_start: + counters[loc_key][month] += suivi['enf_suivis'] + counters['totals'][month] += suivi['enf_suivis'] + return {localite: counters[localite] for localite in sorted(counters.keys())} + + def get_stats(self, months): + return { + 'localites': self.localite_stats(Suivi, months), + } + + def export_lines(self, context): + months = context['months'] + + def stats_for(values): + for key, vals in values.items(): + yield ['Totaux' if key == 'totals' else key] + [vals[month] for month in months] + [vals['total']] + + yield ['BOLD', 'AEMO'] + [str(month) for month in months] + ['Total'] + yield from stats_for(context['localites']) + + +class StatistiquesParRegionView(StatsMixin, PermissionRequiredMixin, TemplateView): + template_name = 'statistiques/stats-region.html' + + def region_query(self, model, region_key): + return model.objects.annotate( + date_fin=Coalesce('date_fin_suivi', date.today()), + date_debut=Coalesce('date_demande', 'date_debut_evaluation', 'date_debut_suivi') + ).filter( + date_debut__lte=self.date_end, + date_fin__gte=self.date_start + ).exclude( + motif_fin_suivi='erreur' + ).annotate( + enf_suivis=Count(Case( + When(famille__membres__role__nom="Enfant suivi", then=1), + output_field=IntegerField(), + )) + ).values('date_debut', 'date_fin', region_key, 'enf_suivis') + + def region_stats(self, model, months, region_key): + suivis = self.region_query(model, region_key) + counters = self.init_counters(['totals'], months) + for suivi in suivis: + loc_key = suivi[region_key] or '?' + if loc_key not in counters: + counters.update(self.init_counters([loc_key], months)) + counters[loc_key]['total'] += suivi['enf_suivis'] + counters['totals']['total'] += suivi['enf_suivis'] + for month in months: + month_start, month_end = self.month_limits(month) + if suivi['date_debut'] <= month_end and suivi['date_fin'] >= month_start: + counters[loc_key][month] += suivi['enf_suivis'] + counters['totals'][month] += suivi['enf_suivis'] + return {region: counters[region] for region in sorted(counters.keys())} + + def get_stats(self, months): + return { + 'regions': self.region_stats(Suivi, months, 'famille__region__nom'), + } + + def export_lines(self, context): + months = context['months'] + + def stats_for(values): + for key, vals in values.items(): + yield ( + ['Totaux' if key == 'totals' else key] + + [vals[month] for month in months] + + [vals['total']] + ) + + yield ['BOLD', 'AEMO'] + [str(month) for month in months] + ['Total'] + yield from stats_for(context['regions']) + + +class StatistiquesParAgeView(StatsMixin, PermissionRequiredMixin, TemplateView): + template_name = 'statistiques/stats-age.html' + + def age_stats(self, model, months): + enfants_suivis = Personne.objects.filter(role__nom='Enfant suivi') + enfants_suivis = enfants_suivis.exclude( + famille__suivi__motif_fin_suivi='erreur' + ).annotate( + date_fin=Coalesce( + 'famille__suivi__date_fin_suivi', date.today() + ), + date_debut=Coalesce( + 'famille__suivi__date_demande', + 'famille__suivi__date_debut_evaluation', 'famille__suivi__date_debut_suivi' + ) + ) + enfants_suivis = enfants_suivis.filter( + date_debut__lte=self.date_end, + date_fin__gte=self.date_start + ) + + counters = {} + means = {'total': [], **{m: [] for m in months}} + for enfant in enfants_suivis: + age = enfant.age_a(enfant.date_debut + ((enfant.date_fin - enfant.date_debut) / 2)) + if age is None: + continue + age = age_real = int(age) + if age > 18: + age = 18 + if age not in counters: + counters.update(self.init_counters([age], months)) + counters[age]['total'] += 1 + means['total'].append(age_real) + for month in months: + month_start, month_end = self.month_limits(month) + if enfant.date_debut <= month_end and enfant.date_fin >= month_start: + counters[age][month] += 1 + means[month].append(age_real) + stats = {str(age): counters[age] for age in sorted(counters.keys())} + if '18' in stats: + stats['18 et plus'] = stats['18'] + del stats['18'] + # Calcul des moyennes à 1 décimale + means['total'] = int(sum(means['total']) / max(len(means['total']), 1) * 10) / 10 + for month in months: + if month.is_future(): + means[month] = '-' + else: + means[month] = int(sum(means[month]) / max(len(means[month]), 1) * 10) / 10 + return stats, means + + def get_stats(self, months): + stats, means = self.age_stats(Suivi, months) + return { + 'ages': stats, + 'means': means, + } + + def export_lines(self, context): + months = context['months'] + + def stats_for(values): + for key, vals in values.items(): + yield ['Totaux' if key == 'totals' else key] + [vals[month] for month in months] + [vals['total']] + + yield ['BOLD', 'AEMO'] + [str(month) for month in months] + ['Total'] + yield from stats_for(context['ages']) + yield ['Âge moyen'] + [context['means'][month] for month in months] + [context['means']['total']] + + +class StatistiquesParDureeView(StatsMixin, PermissionRequiredMixin, TemplateView): + template_name = 'statistiques/stats-duree.html' + + slices = { + Suivi: [ + {'label': '0 à 3 mois', 'start': 0, 'stop': 120}, + {'label': '4 à 6 mois', 'start': 121, 'stop': 210}, + {'label': '7 à 9 mois', 'start': 211, 'stop': 300}, + {'label': '10 à 12 mois', 'start': 301, 'stop': 394}, + {'label': '13 à 15 mois', 'start': 395, 'stop': 484}, + {'label': '16 à 18 mois', 'start': 485, 'stop': 574}, + {'label': '19 à 24 mois', 'start': 575, 'stop': 760}, + {'label': '25 à 36 mois', 'start': 761, 'stop': 1125}, + {'label': '37 à 48 mois', 'start': 1126, 'stop': 1490}, + {'label': '49 mois et plus', 'start': 1491, 'stop': 100000}, + ], + } + + def duree_stats(self, model, months): + suivis = model.objects.filter( + date_debut_suivi__isnull=False, + date_fin_suivi__lte=self.date_end, + date_fin_suivi__gte=self.date_start + ).exclude( + motif_fin_suivi='erreur' + ).annotate( + duree_suivi=F('date_fin_suivi') - F('date_debut_suivi') + ) + counters = {sl['label']: 0 for sl in self.slices[model]} + for suivi in suivis: + duree_days = suivi.duree_suivi.days + for sl in self.slices[model]: + if sl['stop'] > duree_days > sl['start']: + counters[sl['label']] += 1 + break + return counters + + def get_stats(self, months): + return { + 'slices': self.slices[Suivi], + 'durees': self.duree_stats(Suivi, months), + } + + def export_lines(self, context): + def stats_for(values, slices): + for _slice in slices: + yield [_slice['label'], values[_slice['label']]] + + yield ['BOLD', f'AEMO, du {format_d_m_Y(self.date_start)} au {format_d_m_Y(self.date_end)}'] + yield ['BOLD', 'Durée', 'Nombre de suivis'] + yield from stats_for(context['durees'], self.slices[Suivi]) + + +class StatistiquesMotifsView(StatsMixin, PermissionRequiredMixin, TemplateView): + template_name = 'statistiques/stats-motifs.html' + labels = { + 'ann': 'Motif d’annonce', + 'orient': 'Service annonceur', + 'fin_preeval': 'Abandon avant évaluation', + 'fin_posteval': 'Abandon après évaluation', + 'fin_postacc': 'Fin de l’accompagnement', + 'fin_total': 'Total', + 'prov': 'Provenance', + 'dest': 'Destination', + } + + def motifs_stats(self, model, months): + suivis = model.objects.annotate( + date_fin=Coalesce('date_fin_suivi', date.today()), + date_debut=Coalesce('date_demande', 'date_debut_evaluation', 'date_debut_suivi') + ).filter( + date_debut__lte=self.date_end, + date_fin__gte=self.date_start + ).exclude( + motif_fin_suivi='erreur' + ).values( + 'famille_id', 'date_debut', 'date_fin', 'date_debut_evaluation', 'date_debut_suivi', + 'motif_demande', 'motif_fin_suivi', + 'service_orienteur', 'famille__provenance', 'famille__destination', + ) + + # Initiate all counters to 0 for each month. + counters = { + 'dem': {}, 'orient': {}, 'prov': {}, 'dest': {}, + 'fin': {'preeval': {}, 'posteval': {}, 'postacc': {}, 'total': {}} + } + for key, choices in ( + ('dem', MOTIF_DEMANDE_CHOICES), ('fin', MOTIFS_FIN_SUIVI_CHOICES), + ('orient', SERVICE_ORIENTEUR_CHOICES), ('prov', PROVENANCE_DESTINATION_CHOICES), + ('dest', PROVENANCE_DESTINATION_CHOICES)): + for ch in choices: + if key == 'fin': + if ch[0] == 'erreur': + continue + for subkey in counters['fin'].keys(): + counters[key][subkey].update(self.init_counters([ch[0]], months)) + else: + counters[key].update(self.init_counters([ch[0]], months)) + counters['orient'].update(self.init_counters(['undefined'], months)) + + def suivi_in_month(suivi, month): + month_start, month_end = self.month_limits(month) + return suivi['date_debut'] <= month_end and suivi['date_fin'] >= month_start + + for suivi in suivis: + # Stats motif demande + for motif in (suivi['motif_demande'] or []): + counters['dem'][motif]['total'] += 1 + for month in months: + if suivi_in_month(suivi, month): + for motif in (suivi['motif_demande'] or []): + counters['dem'][motif][month] += 1 + # Stats service annonceur + counters['orient'][suivi['service_orienteur'] or 'undefined']['total'] += 1 + for month in months: + if suivi_in_month(suivi, month): + counters['orient'][suivi['service_orienteur'] or 'undefined'][month] += 1 + # Stats motif fin de suivi + if suivi['motif_fin_suivi'] and suivi['motif_fin_suivi'] != 'erreur': + counters['fin']['total'][suivi['motif_fin_suivi']]['total'] += 1 + if not suivi['date_debut_evaluation'] and not suivi['date_debut_suivi']: + counters['fin']['preeval'][suivi['motif_fin_suivi']]['total'] += 1 + elif not suivi['date_debut_suivi']: + counters['fin']['posteval'][suivi['motif_fin_suivi']]['total'] += 1 + else: + counters['fin']['postacc'][suivi['motif_fin_suivi']]['total'] += 1 + for month in months: + if suivi_in_month(suivi, month): + counters['fin']['total'][suivi['motif_fin_suivi']][month] += 1 + if not suivi['date_debut_evaluation'] and not suivi['date_debut_suivi']: + counters['fin']['preeval'][suivi['motif_fin_suivi']][month] += 1 + elif not suivi['date_debut_suivi']: + counters['fin']['posteval'][suivi['motif_fin_suivi']][month] += 1 + else: + counters['fin']['postacc'][suivi['motif_fin_suivi']][month] += 1 + # Stats provenance + if suivi['famille__provenance']: + counters['prov'][suivi['famille__provenance']]['total'] += 1 + for month in months: + if suivi_in_month(suivi, month): + counters['prov'][suivi['famille__provenance']][month] += 1 + # Stats destination + if suivi['famille__destination']: + counters['dest'][suivi['famille__destination']]['total'] += 1 + for month in months: + if suivi_in_month(suivi, month): + counters['dest'][suivi['famille__destination']][month] += 1 + return counters + + def get_stats(self, months): + motif_ann_dict = dict(MOTIF_DEMANDE_CHOICES) + annonceur_dict = dict(SERVICE_ORIENTEUR_CHOICES) + motif_fin_dict = dict(MOTIFS_FIN_SUIVI_CHOICES) + provdest_dict = dict(PROVENANCE_DESTINATION_CHOICES) + stats = self.motifs_stats(Suivi, months) + return { + 'data': { + 'ann': {motif_ann_dict[key]: data for key, data in stats['dem'].items()}, + 'orient': {annonceur_dict.get(key, 'Non défini'): data for key, data in stats['orient'].items()}, + 'fin_preeval': {motif_fin_dict[key]: data for key, data in stats['fin']['preeval'].items()}, + 'fin_posteval': {motif_fin_dict[key]: data for key, data in stats['fin']['posteval'].items()}, + 'fin_postacc': {motif_fin_dict[key]: data for key, data in stats['fin']['postacc'].items()}, + 'fin_total': {motif_fin_dict[key]: data for key, data in stats['fin']['total'].items()}, + 'prov': {provdest_dict[key]: data for key, data in stats['prov'].items()}, + 'dest': {provdest_dict[key]: data for key, data in stats['dest'].items()}, + } + } + + def export_lines(self, context): + months = context['months'] + + def stats_for(values): + for key1, vals in values.items(): + yield ['BOLD', self.labels[key1]] + for key2, subvals in vals.items(): + yield [key2] + [subvals[month] for month in months] + [subvals['total']] + + yield ['BOLD', 'AEMO'] + [str(month) for month in months] + ['Total'] + yield from stats_for(context['data']) + + +class StatistiquesPrestationView(PermissionRequiredMixin, TemplateView): + permission_required = 'aemo.export_stats' + template_name = 'statistiques/stats-prestations.html' + + def _sum_list(self, liste): + tot = timedelta() + for data in liste: + if data != '-': + tot += data + return tot + + @staticmethod + def temps_totaux_mensuels_fam_gen(prest_model, annee): + """ + Renvoie un dictionnaire avec les totaux mensuels de toutes les prestations familiales + et générales pour l'année en cours (de janv. à déc.). + """ + query = prest_model.objects.filter( + date_prestation__year=annee + ).annotate( + month=TruncMonth('date_prestation'), + duree_tot=ExpressionWrapper(Count('intervenants') * F('duree'), output_field=DurationField()) + ).values('month', 'lib_prestation__nom', 'duree_tot') + # La somme est calculée en Python, car Django ne sait pas faire la somme de duree_tot. + months = [Month(annee, num_month) for num_month in range(1, 13)] + data = {month: {'total': '-' if month.is_future() else timedelta(0)} for month in months} + for result in query: + month = Month.from_date(result['month']) + if result['lib_prestation__nom'] not in data[month]: + data[month][result['lib_prestation__nom']] = '-' if month.is_future() else timedelta(0) + data[month][result['lib_prestation__nom']] += result['duree_tot'] + data[month]['total'] += result['duree_tot'] + return data + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + get_params = self.request.GET.copy() + self.export_flag = get_params.pop('export', None) + date_form = DateYearForm(get_params) + context['date_form'] = date_form + if not date_form.is_valid(): + return context + if date_form.is_valid(): + annee = int(date_form.cleaned_data['year']) + counters = {} + tot_dus_mensuels = [timedelta()] * 12 + tot_ecarts_mensuels = [timedelta()] * 12 + + intervenants = Utilisateur.objects.annotate( + num_prest=Count('prestations', filter=Q(prestations__date_prestation__year=annee)) + ).filter(num_prest__gt=0).order_by('nom', 'prenom') + for interv in intervenants: + h_prestees = interv.totaux_mensuels('aemo', annee) + h_prestees = [('-' if Month(annee, idx).is_future() else h) for idx, h in enumerate(h_prestees, start=1)] + + tot_prestees = self._sum_list(h_prestees) + counters[interv] = { + 'heures_prestees': h_prestees, + 'tot_prestees': tot_prestees, + } + + tot_prest_mensuels = self.temps_totaux_mensuels_fam_gen(Prestation, annee) + + tot_dus_mensuels.append(self._sum_list(tot_dus_mensuels)) + tot_ecarts_mensuels.append(self._sum_list(tot_ecarts_mensuels)) + + totaux_par_prest = {} + for month, data in tot_prest_mensuels.items(): + for label, duration in data.items(): + if label not in totaux_par_prest: + totaux_par_prest[label] = timedelta(0) + if duration != '-': + totaux_par_prest[label] += duration + + context.update({ + 'annee': annee, + 'titre': 'Prestations AEMO', + 'intervenants': counters, + 'months': [date(annee, m, 1) for m in range(1, 13)], + 'libelles_prest': LibellePrestation.objects.all().order_by('code'), + 'totaux_prest_mensuels': tot_prest_mensuels, + 'totaux_par_prest': totaux_par_prest, + 'total_gen': totaux_par_prest['total'], + }) + return context + + def render_to_response(self, context, **response_kwargs): + if self.export_flag: + export = ExportStatistique(col_widths=[30]) + export.fill_data(self.export_lines(context)) + return export.get_http_response(self.__class__.__name__.replace('View', '') + '.xlsx') + else: + return super().render_to_response(context, **response_kwargs) + + def export_lines(self, context): + months = context['months'] + yield ['BOLD', 'AEMO'] + [str(month) for month in months] + ['Total'] + for user, vals in context['intervenants'].items(): + yield ([user.nom_prenom] + [format_duree(val) for val in vals['heures_prestees']] + + [format_duree(vals['tot_prestees'])]) + yield ['BOLD', 'Par type d’intervention'] + for prest in context['libelles_prest']: + yield ([prest.nom] + [ + format_duree(context['totaux_prest_mensuels'][month].get(prest.nom, + '-' if month.is_future() else timedelta(0))) + for month in [Month.from_date(m) for m in months] + ] + [format_duree(context['totaux_par_prest'].get(prest.nom, timedelta(0)))]) + + +class StatistiquesNiveauxView(StatsMixin, PermissionRequiredMixin, TemplateView): + permission_required = 'aemo.export_stats' + template_name = 'statistiques/stats-niveaux.html' + + def get_stats(self, months): + prest_list = ['aemo04', 'aemo05', 'aemo06', 'aemo07'] + prest_query = Prestation.objects.filter( + famille__isnull=False, + date_prestation__range=(self.date_start, self.date_end), + lib_prestation__code__in=prest_list, + ).annotate( + month=TruncMonth('date_prestation'), + niveau_interv=Subquery( + Niveau.objects.filter( + Q(famille=OuterRef('famille_id')) & + Q(date_debut__lt=OuterRef('date_prestation')) & ( + Q(date_fin__isnull=True) | Q(date_fin__gt=OuterRef('date_prestation')) + ) + ).order_by('-date_debut').values('niveau_interv')[:1] + ), + ).values( + 'month', 'niveau_interv', 'lib_prestation__code', + ).annotate( + sum_hours=Sum('duree'), + ) + niveaux = { + niv: { + p: { + **{m: timedelta(0) for m in months}, 'total': timedelta(0) + } for p in prest_list + } for niv in [0, 1, 2, 3] + } + for line in prest_query: + if line['niveau_interv'] is None: + continue + niveaux[line['niveau_interv']][line['lib_prestation__code']][Month.from_date(line['month'])] = line['sum_hours'] + niveaux[line['niveau_interv']][line['lib_prestation__code']]['total'] += line['sum_hours'] + return { + 'stats': niveaux, + 'prest_map': LibellePrestation.objects.in_bulk(prest_list, field_name='code'), + } + + def export_lines(self, context): + months = context['months'] + yield ['BOLD', 'Ressources par niveau'] + [str(month) for month in months] + ['Total'] + for niv, prests in context['stats'].items(): + yield ['BOLD', niv] + for prest, numbers in prests.items(): + yield [context['prest_map'][prest].nom] + [numbers[m] for m in months] + [numbers['total']] diff --git a/archive/__init__.py b/archive/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/archive/admin.py b/archive/admin.py new file mode 100644 index 0000000..3863f25 --- /dev/null +++ b/archive/admin.py @@ -0,0 +1,9 @@ +from django.contrib import admin + +from .models import Archive + + +@admin.register(Archive) +class ArchiveAdmin(admin.ModelAdmin): + list_display = ['nom', 'unite', 'date_debut', 'date_fin'] + search_fields = ['nom'] diff --git a/archive/forms.py b/archive/forms.py new file mode 100644 index 0000000..60c37da --- /dev/null +++ b/archive/forms.py @@ -0,0 +1,31 @@ +from django import forms + +from aemo.forms import BootstrapMixin + + +class ArchiveKeyUploadForm(BootstrapMixin, forms.Form): + file = forms.FileField(label='Clé de déchiffrement des archives', required=True) + + +class ArchiveFilterForm(BootstrapMixin, forms.Form): + + search_famille = forms.CharField( + label='Recherche par nom de famille', + max_length=30, + required=False + ) + + search_intervenant = forms.CharField( + label='Recherche par interv. CRNE', + max_length=30, + required=False + ) + + def filter(self, archives): + if not self.cleaned_data['search_famille'] and not self.cleaned_data['search_intervenant']: + return archives.none() + if self.cleaned_data['search_famille']: + archives = archives.filter(nom__icontains=self.cleaned_data['search_famille']) + if self.cleaned_data['search_intervenant']: + archives = archives.filter(intervenant__icontains=self.cleaned_data['search_intervenant']) + return archives diff --git a/archive/migrations/0001_Add_archive_model.py b/archive/migrations/0001_Add_archive_model.py new file mode 100644 index 0000000..21d7455 --- /dev/null +++ b/archive/migrations/0001_Add_archive_model.py @@ -0,0 +1,30 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='Archive', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('nom', models.CharField(max_length=40)), + ('unite', models.CharField(max_length=10)), + ('intervenant', models.CharField(blank=True, max_length=50)), + ('ope', models.CharField(blank=True, max_length=50)), + ('motif_fin', models.CharField(blank=True, max_length=30)), + ('date_debut', models.DateField(null=True)), + ('date_fin', models.DateField()), + ('key', models.TextField()), + ('pdf', models.FileField(null=True, upload_to='archives/')), + ], + options={ + 'ordering': ('nom',), + }, + ), + ] diff --git a/archive/migrations/0002_longer_intervenant.py b/archive/migrations/0002_longer_intervenant.py new file mode 100644 index 0000000..008517f --- /dev/null +++ b/archive/migrations/0002_longer_intervenant.py @@ -0,0 +1,16 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('archive', '0001_Add_archive_model'), + ] + + operations = [ + migrations.AlterField( + model_name='archive', + name='intervenant', + field=models.CharField(blank=True, max_length=120), + ), + ] diff --git a/archive/migrations/__init__.py b/archive/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/archive/models.py b/archive/models.py new file mode 100644 index 0000000..06cbffb --- /dev/null +++ b/archive/models.py @@ -0,0 +1,19 @@ +from django.db import models + + +class Archive(models.Model): + nom = models.CharField(max_length=40) + unite = models.CharField(max_length=10) + intervenant = models.CharField(max_length=120, blank=True) + ope = models.CharField(max_length=50, blank=True) + motif_fin = models.CharField(max_length=30, blank=True) + date_debut = models.DateField(null=True) + date_fin = models.DateField() + key = models.TextField() + pdf = models.FileField(upload_to='archives/', null=True) + + class Meta: + ordering = ('nom',) + + def __str__(self): + return f"{self.unite}: {self.nom}" diff --git a/archive/pdf.py b/archive/pdf.py new file mode 100644 index 0000000..591a409 --- /dev/null +++ b/archive/pdf.py @@ -0,0 +1,64 @@ +import subprocess +import tempfile + +from io import BytesIO +from pathlib import Path +from pypdf import PdfWriter, PdfReader + +from django.utils.text import slugify +from aemo.pdf import BasePDF, BilanPdf, EvaluationPdf, MessagePdf, RapportPdf, JournalPdf + + +class ArchiveBase(BasePDF): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.merger = PdfWriter() + + def get_filename(self): + return f"{slugify(self.instance.nom)}-{self.instance.pk}.pdf" + + def append_pdf(self, PDFClass, obj): + temp = BytesIO() + pdf = PDFClass(temp, obj) + pdf.produce() + self.merger.append(temp) + + def append_other_docs(self, documents): + msg = [] + for doc in documents: + doc_path = Path(doc.fichier.path) + if not doc_path.exists(): + msg.append(f"Le fichier «{doc.titre}» n'existe pas!") + continue + if doc_path.suffix.lower() == '.pdf': + self.merger.append(PdfReader(str(doc_path))) + elif doc_path.suffix.lower() in ['.doc', '.docx']: + with tempfile.TemporaryDirectory() as tmpdir: + cmd = ['libreoffice', '--headless', '--convert-to', 'pdf', '--outdir', tmpdir, doc_path] + subprocess.run(cmd, capture_output=True) + converted_path = Path(tmpdir) / f'{doc_path.stem}.pdf' + if converted_path.exists(): + self.merger.append(PdfReader(str(converted_path))) + else: + msg.append(f"La conversion du fichier «{doc.titre}» a échoué") + elif doc_path.suffix.lower() == '.msg': + self.append_pdf(MessagePdf, doc) + else: + msg.append(f"Le format du fichier «{doc.titre}» ne peut pas être intégré.") + return msg + + +class ArchivePdf(ArchiveBase): + title = "Archive" + + def produce(self): + famille = self.instance + self.append_pdf(EvaluationPdf, famille) + self.append_pdf(JournalPdf, famille) + for bilan in famille.bilans.all(): + self.append_pdf(BilanPdf, bilan) + for rapport in famille.rapports.all(): + self.append_pdf(RapportPdf, rapport) + msg = self.append_other_docs(famille.documents.all()) + self.merger.write(self.doc.filename) + return msg diff --git a/archive/templates/archive/key_upload.html b/archive/templates/archive/key_upload.html new file mode 100644 index 0000000..52cec61 --- /dev/null +++ b/archive/templates/archive/key_upload.html @@ -0,0 +1,18 @@ +{% extends 'base.html' %} + +{% block content %} +
    +
    +
    + {% csrf_token %} + {{ form.as_div }} +
    +
    + Annuler + +
    +
    +
    +
    +
    +{% endblock %} diff --git a/archive/templates/archive/list.html b/archive/templates/archive/list.html new file mode 100644 index 0000000..58f8c25 --- /dev/null +++ b/archive/templates/archive/list.html @@ -0,0 +1,37 @@ +{% extends 'base.html' %} +{% block extra_javascript %} + +{% endblock %} + +{% block content %} +
    +
    +

    Archives {{ unite|upper }}

    +
    +
    +
    +
    +
    {{ form.search_famille.label_tag }} {{ form.search_famille }}
    +
    {{form.search_intervenant.label_tag }} {{ form.search_intervenant }}
    +
    +
    +
    +
    +
    +
    +
    Utilisez la recherche par nom de famille et/ou par intervenant.
    +
    +
    +{% endblock %} diff --git a/archive/templates/archive/list_partial.html b/archive/templates/archive/list_partial.html new file mode 100644 index 0000000..93651dd --- /dev/null +++ b/archive/templates/archive/list_partial.html @@ -0,0 +1,28 @@ + + + + + {% if can_download %} + + {% endif %} + + {% for archive in archives %} + + + + + + + + {% if can_download %} + + {% endif %} + + {% empty %} + + + + {% endfor %} +
    NomRéf. {{ unite|upper }}Réf. OPEDate de débutDate de finMotif de finDéchiffrement
    {{ archive.nom }}{{ archive.intervenant }}{{ archive.ope }}{{ archive.date_debut|default:'' }}{{ archive.date_fin }}{{ archive.motif_fin }} + Déchiffrer +
    Aucune famille pour cette recherche.
    diff --git a/archive/tests/__init__.py b/archive/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/archive/tests/crne_rsa b/archive/tests/crne_rsa new file mode 100644 index 0000000..cd71cd4 --- /dev/null +++ b/archive/tests/crne_rsa @@ -0,0 +1,51 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIJKAIBAAKCAgEArBihBJdaIiYBOaP/AjwvqQQFO1inVEIVrKukPJmbxMjFceBr +Hd1YDMQ2J5K9LcqlZnFJLn1XwGVfGSMosRA8VOKaof/EA9npdQ/ncNpTc8Gugq2z +0UmsYjr2OXloZu3bkEdzhE7Nf5wE/s5qHQ6IFn6NyHwSbg6iUVl1d+C+UZNVXZPa +yhAHxsqIMQb7a/FCqusWV0g8HmP4xSq7Z8gAl7Bpg/eGqnIKVm2i8U0dOuAIcrof +45QAn07e2imX/2GLSURrHnjZcelUUWJflEDdNrsFi/3z1PYPhNOHvwR7SLzwcnXZ +8GElpcGniBR0vU2urCDtC1txNaGABjoWqPHPmdxsqhujoNd8eVxu20Do0fyKrSnF +qGzU/nFye77QJM7HUKSCILRSJvXhjV4Rn7qvU5+Cpc8wJd5T/cvkaXIZ0cXGSU6r +JX5QdLRT05MI+W4gyGzb3MTna9/EMjtpR8no6SYBYscTY/jw9K3hNYou1MjJF1y3 +pUSmcB0aXrUqjc1bshzq7SVvkdoDGz2j0bHyOkc94G1yMNJuK6znqHqZzBjE9MPd +E0RdKIajQAqxcf+3S8hS+udvYMfA5gZ7Vi1FiGJBif6PRdSMM5ad7N3tClr7JLTH +8mnYDGyELaJal2lUu66SLOLiQWY0dWGtvjGPyMhWWzSTPp8t2mMRjLx3FIUCAwEA +AQKCAgAK0GYMf07odsHnSNZajMSj7BRAv+031AqU9e4qlAPKmvZz6u8K3+CgthL/ +Fpnw+YW+M7UuXi2i8dvGx8Oj/goEfX7Lr4zg1Gh2n4ANbTKxmJnqLUHE5MptG8O4 +Jdhjy2OGI+9EK51/J3iAOHaeNSO5DM0aVtg49o4dlYUA+kGLUmTwAz1MKt4KOv98 +MTJK/KHnUpbDTPngy6sTXKriYRokbgSbXcpRBecqD4FIhMN8R8f6yZZSZcz04G8M +Khvv2MXOCKYV3UXbV2xMduWG+ofMC3bUQ/92l4WvH4mtUk9FlAB5+MzamDLWQR58 +DxOs3OedJQPa4wKbT6GCAqlvhqO4WX/EUKRi+eoVyIHuVmkYoHRQZhOdl7X+ortr +pU2FRRMVFMI/rCHGrPGUlIz0+03+Yudg0oIawDwlLKj5gO/xXy8mGbSYbhQE9TjH +n8pAu7UJOglK0JRMhow+qsB0FQ8l1t1QmDaO6zbjIk4J2liCqmhojqNmxl2Wx7vm +vVGtkEpRwH5cE+uNWZ5h/QTsJogadOS58gF5Wos5Pe8UnWZYtLOe0KbF+iZPeQiN +jOJUYFSktytAIixSLrcp816qctfZs93IocBWHlrsV8iF8rs9CrMZiX/QK+l6CrAu +CNGWO9Wm8NytkISQbBIl7T7oGiaLr0Kl0KkmPwDovnwH2CCx4QKCAQEA05ZTz93C +k91ixDsaioGevdnNT8JBknWu1odcmLzOpzYZCTs9j9du3N3OAImZT9j0tA4NiLbO +2GCR93+/glNJJG1iJJc2x39qPZLwbXHr/Dx5YrH5PP25sjbm2xxAxaztJzXEIJgR +R6+dMJk5qD1qeOMxWxXOStFQGRtyy7umF1jAJUVxgTZt5+hYn5TtU9fTB8o8Qeto ++V2gjyhJISiRQDJYzHoRrKy45cwhBc5njdDrr4TpwwE/WyOtkrQthaaUuCrhpRZE +fOBblO7YWoYdM3IdxjtM45DlZLdIpuycdczJ5zZE3yQ5dSKlU9HQR9fN5pyZ3DV8 +eOeMO8P+tzUkdQKCAQEA0Dg+9Y9qeeLtcmS0tD3r1iVTiNOmTT7EerQv4KjYhsJe +Ws2ZnELtzcKT75pkdF7kD06xKyMNFvLeC+/+SagzCziPa05uK3WV7FopQ1Ultj6i +9L9BpOpc6M2MQSObDu7QiCWIEqWwjYXy3Z0Yxm8ME8KyAO4j/YeD3ycPZMAL1yGZ +fTf6NbrOpYvEV+bZIRLkgfc7HAg0IBo5IiQgURVtrA27yZ5VV49Y2DyhDGqH5twF +s7VWkCRMsBeHA7sxeJimVG2NU67eIrc1GBTbzeS9JbP3p3bbpQeCm584siQ9mxxF +NjXDXgwx8gyp5G1UPbPtzEoixxzlgC9CHwH8H4Tt0QKCAQAAqW63rrzmE4I0lO6/ +Uip5841122izGZUjbKb4f1ayJTQs2DeYFJdvL25uh/+nxUj2qziVneTFvn+WY5ro +wHPxHjp5XNO6Cgb+DFCeNwYC8vl6Oo5KB40mJo/QTaVSOPlA7yUe6Prc24rFVSVe +Blsn56YG3+mWSFNU0MYqJvsdBZUMSMxTGCV93TcxwJiBc6JgWtyXZDIe3ZEcAYdB +CEx0A/RNJ3CYtq2ZYmsUBpJCWk3ybZsBliZplZH8bH3b9ipu7QtppckvDtCahai9 +l7/NomS/cv4JlDFzgDNE+mZ+49YZ2AydGhLn7+TOf1CEeQNW3lSI4M3z3t2Mbk+E +qTDlAoIBAQCqZ83G4/dtBzXyr85f0GlpGaUyzpxEjYD5NuwT/bsvFnVn9OmpQ/Eg +uwSdTAq4Xkxg5rMCLa5xwJPOyzueBmS34zMky8xIDvSCuQsaCt5RNxPgH4JWuGMP +N+F4Ee69mt7Y/XZOZIGIYT5w9jendow4w9cwAbU8sSJQh8QGXVGTX/Eg1KYWQOsL ++sXWdpvugGq4nqAmgeQ+/ZcShORZ16Ko85hjGgyYGz3Hwl6/LZRJcHnOKDNOxhZo +6uhZOmLzYmKFqB7IhM1RNgTiz3dQGspdx9p/mDuL5QiT2gvpZtVwUwOlqPxZxLs/ +b/O+eWc/FDkiPu4VbGW6sXJ4tAQlu4FxAoIBAGAVm1D+/+fZpASL/XW6RRsjtEnh +FUgV1LvOKpZjqv7rZAWZOQD+eeJVIM+5/TW0G7dycvQl6oGUMvihYW8FYFyk3MrI +vNOi6+aoB4AQsweHFesonivSZ9e5g3Z0ChxK8lzmCQ0fKg1I6aVdFCuXYxVnNseW +v/fdhI1OC2fwzdihsUjlEwhaSJwnJhLDVPmzOb4jg3KFbFHOHr29hQ2Nw4jHTQ83 +J39HvFRI/nZ9LuQ2EXMHMQy47ikV3P7jEXshpxXydLzW19CGn5Qo6bKrTT8iHZ1T +3FsFeSW2Js+KKOAqh9EhkvRJRWHIq/3qgPB7dkQEw9LP/3abYllMrefmVoU= +-----END RSA PRIVATE KEY----- diff --git a/archive/tests/crne_rsa.pub b/archive/tests/crne_rsa.pub new file mode 100644 index 0000000..3325ced --- /dev/null +++ b/archive/tests/crne_rsa.pub @@ -0,0 +1 @@ +ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQCsGKEEl1oiJgE5o/8CPC+pBAU7WKdUQhWsq6Q8mZvEyMVx4Gsd3VgMxDYnkr0tyqVmcUkufVfAZV8ZIyixEDxU4pqh/8QD2el1D+dw2lNzwa6CrbPRSaxiOvY5eWhm7duQR3OETs1/nAT+zmodDogWfo3IfBJuDqJRWXV34L5Rk1Vdk9rKEAfGyogxBvtr8UKq6xZXSDweY/jFKrtnyACXsGmD94aqcgpWbaLxTR064Ahyuh/jlACfTt7aKZf/YYtJRGseeNlx6VRRYl+UQN02uwWL/fPU9g+E04e/BHtIvPByddnwYSWlwaeIFHS9Ta6sIO0LW3E1oYAGOhao8c+Z3GyqG6Og13x5XG7bQOjR/IqtKcWobNT+cXJ7vtAkzsdQpIIgtFIm9eGNXhGfuq9Tn4KlzzAl3lP9y+RpchnRxcZJTqslflB0tFPTkwj5biDIbNvcxOdr38QyO2lHyejpJgFixxNj+PD0reE1ii7UyMkXXLelRKZwHRpetSqNzVuyHOrtJW+R2gMbPaPRsfI6Rz3gbXIw0m4rrOeoepnMGMT0w90TRF0ohqNACrFx/7dLyFL6529gx8DmBntWLUWIYkGJ/o9F1Iwzlp3s3e0KWvsktMfyadgMbIQtolqXaVS7rpIs4uJBZjR1Ya2+MY/IyFZbNJM+ny3aYxGMvHcUhQ== transit@example.org diff --git a/archive/tests/sample-2.msg b/archive/tests/sample-2.msg new file mode 100644 index 0000000..ded03fd Binary files /dev/null and b/archive/tests/sample-2.msg differ diff --git a/archive/tests/sample.doc b/archive/tests/sample.doc new file mode 100644 index 0000000..0c2d5fd Binary files /dev/null and b/archive/tests/sample.doc differ diff --git a/archive/tests/sample.docx b/archive/tests/sample.docx new file mode 100644 index 0000000..0d13360 Binary files /dev/null and b/archive/tests/sample.docx differ diff --git a/archive/tests/sample.msg b/archive/tests/sample.msg new file mode 100644 index 0000000..4c0e77b Binary files /dev/null and b/archive/tests/sample.msg differ diff --git a/archive/tests/sample.pdf b/archive/tests/sample.pdf new file mode 100644 index 0000000..70cbd4b Binary files /dev/null and b/archive/tests/sample.pdf differ diff --git a/archive/tests/tests.py b/archive/tests/tests.py new file mode 100644 index 0000000..c959744 --- /dev/null +++ b/archive/tests/tests.py @@ -0,0 +1,204 @@ +import os.path +import subprocess +import tempfile + +from datetime import date, timedelta +from pathlib import Path + +from django.conf import settings +from django.contrib.auth.models import Group, Permission +from django.core.files import File +from django.test import TestCase, override_settings +from django.urls import reverse +from django.utils.text import slugify + +from aemo.models import ( + Bilan, Document, Famille, LibellePrestation, Personne, Role, Prestation, + Rapport, Utilisateur +) +from aemo.tests import InitialDataMixin, TempMediaRootMixin +from aemo.utils import format_d_m_Y +from ..models import Archive + +public_key_path = os.path.join(settings.BASE_DIR, 'archive/tests/crne_rsa.pub') + + +@override_settings(CRNE_RSA_PUBLIC_KEY=public_key_path) +class ArchiveTests(InitialDataMixin, TempMediaRootMixin, TestCase): + + def setUp(self) -> None: + self.user = Utilisateur.objects.create_user('user', 'user@example.org', sigle='XX') + self.create_kwargs = { + 'nom': 'John Doe', + 'unite': 'aemo', + 'date_debut': date(2021, 1, 1), + 'date_fin': date(2021, 12, 31), + 'motif_fin': 'Autre', + 'key': '', + 'pdf': 'encrypted_data' + } + + def _create_archive(self): + fam = Famille.objects.create_famille(nom='Haddock', equipe='aemo') + fam.suivi.date_fin_suivi = date(2019, 1, 1) + fam.suivi.motif_fin_suivi = 'autres' + fam.suivi.save() + for idx, doc_name in enumerate(['sample.docx', 'sample.doc', 'sample.pdf', 'sample.msg']): + doc = Document(famille=fam, titre=f"Test {idx}") + with (Path(__file__).parent / doc_name).open(mode='rb') as fh: + doc.fichier = File(fh, name=doc_name) + doc.save() + + self.user.user_permissions.add(Permission.objects.get(codename='can_archive')) + self.client.force_login(self.user) + self.client.post(reverse('archive-add', args=['aemo', fam.pk])) + return Archive.objects.get(nom='Haddock', unite='aemo') + + def test_model_creation(self): + arch = Archive.objects.create(**self.create_kwargs) + self.assertEqual(arch.nom, 'John Doe') + self.assertEqual(arch.unite, 'aemo') + self.assertEqual(arch.date_debut, date(2021, 1, 1)) + self.assertEqual(arch.date_fin, date(2021, 12, 31)) + self.assertEqual(arch.pdf, 'encrypted_data') + + def test_sans_permission_d_archiver(self): + fam = Famille.objects.create_famille(nom='Haddock', equipe='aemo') + fam.suivi.date_fin_suivi = date(2019, 1, 1) + fam.suivi.motif_fin_suivi = 'autre' + fam.suivi.save() + self.assertEqual(fam.can_be_archived(self.user), False) + + def test_avec_permission_d_archiver(self): + fam = Famille.objects.create_famille(nom='Haddock', equipe='aemo') + fam.suivi.date_fin_suivi = date(2019, 1, 1) + fam.suivi.motif_fin_suivi = 'autre' + fam.suivi.save() + self.user.user_permissions.add(Permission.objects.get(codename='can_archive')) + self.assertEqual(fam.can_be_archived(self.user), True) + + def test_archivage_aemo(self): + famille = Famille.objects.create_famille( + nom='Doe', equipe='aemo', rue="Rue du lac", npa='2000', localite='Paris', telephone='012 345 67 89') + famille.suivi.date_fin_suivi = date.today() - timedelta(days=700) + famille.suivi.motif_fin_suivi = 'autre' + famille.suivi.save() + file_paths = [] + for idx, doc_name in enumerate(['sample.docx', 'sample.doc', 'sample.pdf', 'sample.msg', 'sample-2.msg']): + doc = Document(famille=famille, titre=f"Test {idx}") + with (Path(__file__).parent / doc_name).open(mode='rb') as fh: + doc.fichier = File(fh, name=doc_name) + doc.save() + file_paths.append(doc.fichier.path) + Personne.objects.create_personne( + famille=famille, prenom='Archibald', nom='Doe', role=Role.objects.get(nom='Père') + ) + enfant = Personne.objects.create_personne( + famille=famille, prenom='Gaston', nom='Doe', date_naissance=date.today() - timedelta(days=720), + role=Role.objects.get(nom='Enfant suivi') + ) + enfant.formation.creche = 'Les Schtroumpfs' + enfant.formation.save() + Bilan.objects.create(famille=famille, date=date.today()) + Rapport.objects.create(famille=famille, date=date.today(), auteur=self.user) + prest = Prestation.objects.create( + famille=famille, auteur=self.user, date_prestation=date.today(), duree=timedelta(hours=2), + lib_prestation=LibellePrestation.objects.first()) + prest.intervenants.add(self.user, through_defaults={'role': Role.objects.get(nom='Référent')}) + with (Path(__file__).parent / 'sample.pdf').open(mode='rb') as fh: + prest.fichier = File(fh, name=doc_name) + prest.save() + file_paths.append(prest.fichier.path) + + grp = Group.objects.get(name='aemo') + self.user.groups.add(grp) + self.user.user_permissions.add( + *list(Permission.objects.filter(codename__in=['view_famille', 'change_famille', 'can_archive'])) + ) + self.assertTrue(famille.can_be_archived(self.user)) + self.client.force_login(self.user) + response = self.client.post(reverse('archive-add', args=['aemo', famille.pk])) + self.assertRedirects(response, reverse('suivis-termines')) + + famille.refresh_from_db() + self.assertNotEqual(famille.nom, 'Doe') + self.assertEqual(len(famille.nom), 10) + self.assertEqual(famille.rue, '') + self.assertEqual(famille.npa, '2000') + self.assertEqual(famille.localite, 'Paris'), + self.assertEqual(famille.telephone, '') + self.assertEqual(famille.parents(), []) + + enfant.refresh_from_db() + self.assertNotEqual(enfant.nom, 'Doe') + self.assertEqual(len(famille.nom), 10) + self.assertNotEqual(enfant.prenom, 'Gaston') + self.assertEqual(len(famille.nom), 10) + self.assertEqual(enfant.formation.creche, '') + + self.assertEqual(famille.documents.count(), 0) + self.assertEqual(famille.bilans.count(), 0) + self.assertEqual(famille.rapports.count(), 0) + for prest in famille.prestations.all(): + self.assertEqual(prest.texte, '') + self.assertEqual(prest.duree, timedelta(hours=2)) + self.assertEqual(bool(prest.fichier), False) + + for path in file_paths: + self.assertFalse(Path(path).exists()) + + arch = Archive.objects.get(nom='Doe', unite='aemo') + self.assertTrue(os.path.exists(arch.pdf.path)) + self.assertIn(f"aemo/doe-{famille.pk}", arch.pdf.name) + self.assertTrue(famille.prestations.exists()) + + # Cannot be archived a second time: + response = self.client.post(reverse('archive-add', args=['aemo', famille.pk])) + self.assertEqual(response.status_code, 404) + + def test_decryptage_access(self): + arch = self._create_archive() + private_key = os.path.join(settings.BASE_DIR, 'archive/tests/crne_rsa') + anonymous = Utilisateur.objects.create_user('anonymous', email='anonymous@example.org') + self.client.force_login(anonymous) + with open(private_key, 'rb') as f: + response = self.client.post(reverse('archive-decrypt', args=[arch.pk]), data={'file': f}) + self.assertEqual(response.status_code, 403) + + anonymous.user_permissions.add(Permission.objects.get(codename='can_archive')) + self.client.force_login(anonymous) + with open(private_key, 'rb') as f: + response = self.client.post(reverse('archive-decrypt', args=[arch.pk]), data={'file': f}) + self.assertEqual(response.status_code, 200) + + def test_decryptage_aemo(self): + arch = self._create_archive() + private_key = os.path.join(settings.BASE_DIR, 'archive/tests/crne_rsa') + + self.client.force_login(self.user) + response = self.client.post(reverse('archive-decrypt', args=[arch.pk]), data={'file': ''}) + self.assertEqual(response.context['form'].errors, {'file': ['Ce champ est obligatoire.']}) + + with open(private_key, 'rb') as f: + response = self.client.post(reverse('archive-decrypt', args=[arch.pk]), data={'file': f}) + self.assertEqual( + response.get('Content-Disposition'), + f"attachment; filename={slugify(arch.nom)}.pdf" + ) + + with tempfile.NamedTemporaryFile(delete=True, mode='wb') as fh: + fh.write(response.content) + subprocess.run(['pdftotext', fh.name], stdout=subprocess.PIPE, stderr=subprocess.PIPE) + with open(f'{fh.name}.txt', 'r') as f: + content = f.read() + + self.assertIn('Historique', content) + self.assertIn('Fin du suivi le :\n\n01.01.2019', content) + self.assertIn('Motif de fin de suivi :\n\nAutres', content) + self.assertIn(f"Date d'archivage :\n\n{format_d_m_Y(date.today())}", content) + self.assertIn('Famille Haddock', content) + self.assertIn('Informations', content) + self.assertIn("Fichier docx d’exemple", content) + self.assertIn("Exemple de fichier doc", content) + self.assertIn("Fichier pdf d’exemple", content) + self.assertIn("Kind regards", content) diff --git a/archive/urls.py b/archive/urls.py new file mode 100644 index 0000000..4c3e1d2 --- /dev/null +++ b/archive/urls.py @@ -0,0 +1,9 @@ +from django.urls import path + +from archive import views + +urlpatterns = [ + path('/famille//add/', views.ArchiveCreateView.as_view(), name='archive-add'), + path('/list/', views.ArchiveListView.as_view(), name='archive-list'), + path('/decrypt/', views.ArchiveDecryptView.as_view(), name='archive-decrypt'), +] diff --git a/archive/views.py b/archive/views.py new file mode 100644 index 0000000..598e22e --- /dev/null +++ b/archive/views.py @@ -0,0 +1,153 @@ +import base64 +from io import BytesIO + +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.asymmetric import padding +from cryptography.hazmat.primitives import serialization +from cryptography.fernet import Fernet, InvalidToken + +from django.conf import settings +from django.contrib import messages +from django.contrib.auth.mixins import PermissionRequiredMixin +from django.core.exceptions import PermissionDenied +from django.core.files.base import ContentFile +from django.http import HttpResponse, HttpResponseRedirect, JsonResponse +from django.shortcuts import get_object_or_404, reverse +from django.template.loader import render_to_string +from django.utils import timezone +from django.utils.text import slugify +from django.views.generic import FormView, TemplateView, View + +from aemo.models import Famille +from aemo.utils import is_ajax +from .models import Archive +from .pdf import ArchivePdf + +from .forms import ArchiveFilterForm, ArchiveKeyUploadForm + + +class ArchiveCreateView(View): + + def post(self, request, *args, **kwargs): + unite = 'aemo' + temp = BytesIO() + + famille = get_object_or_404(Famille, pk=kwargs['pk'], archived_at__isnull=True) + intervenants_list = '/'.join([f"{interv.nom} {interv.prenom[0].upper()}." + for interv in famille.suivi.intervenants.all()]) + ope_list = famille.suivi.ope_referents + motif_fin = famille.suivi.get_motif_fin_suivi_display() + pdf = ArchivePdf(temp, famille) + + if not famille.can_be_archived(self.request.user): + raise PermissionDenied("Vous n'avez pas les droits nécessaires pour accéder à cette page.") + + famille.archived_at = timezone.now() + pdf.produce() + filename = f"{unite}/{pdf.get_filename()}" + temp.seek(0) + pdf = temp.read() + + # Create a symmetric Fernet key to encrypt the PDF, and encrypt that key with asymmetric RSA key. + key = Fernet.generate_key() + fernet = Fernet(key) + pdf_crypted = fernet.encrypt(pdf) + + with open(settings.CRNE_RSA_PUBLIC_KEY, "rb") as key_file: + public_key = serialization.load_ssh_public_key(key_file.read()) + padd = padding.OAEP(mgf=padding.MGF1(algorithm=hashes.SHA1()), algorithm=hashes.SHA1(), label=None) + fernet_crypted = public_key.encrypt(key, padding=padd) + + nom_famille = famille.nom + arch = Archive.objects.create( + nom=nom_famille, + unite=unite, + intervenant=intervenants_list, + ope=", ".join([ope.nom_prenom for ope in ope_list]), + motif_fin=motif_fin, + date_debut=famille.suivi.date_debut_suivi, + date_fin=famille.suivi.date_fin_suivi, + key=base64.b64encode(fernet_crypted).decode(), + pdf=None + ) + arch.pdf.save(filename, ContentFile(pdf_crypted)) + famille.archived_at = timezone.now() + famille.save() + + famille.anonymiser() + + if is_ajax(request): + return JsonResponse({'id': famille.pk}, safe=True) + + messages.success(request, f"La famille «{nom_famille}» a bien été archivée.") + return HttpResponseRedirect(reverse("suivis-termines")) + + +class ArchiveListView(TemplateView): + template_name = 'archive/list.html' + model = Archive + + def dispatch(self, request, *args, **kwargs): + self.unite = self.kwargs['unite'] + self.filter_form = ArchiveFilterForm(data=request.GET or None) + return super().dispatch(request, *args, **kwargs) + + def get(self, request, *args, **kwargs): + if is_ajax(request) and self.unite == 'aemo': + if self.filter_form.is_bound and self.filter_form.is_valid(): + archives = self.filter_form.filter(Archive.objects.filter(unite=self.unite)) + else: + archives = Archive.objects.none() + response = render_to_string( + template_name='archive/list_partial.html', + context={ + 'archives': archives, + 'can_download': request.user.has_perm('aemo.can_archive'), + } + ) + return JsonResponse(response, safe=False) + return super().get(request, *args, **kwargs) + + def get_context_data(self, *args, **kwargs): + return { + **super().get_context_data(*args, **kwargs), + 'unite': self.unite, + 'form': self.filter_form, + 'archives': Archive.objects.filter(unite=self.unite), + 'can_download': self.request.user.has_perm('aemo.can_archive'), + } + + +class ArchiveDecryptView(PermissionRequiredMixin, FormView): + form_class = ArchiveKeyUploadForm + template_name = 'archive/key_upload.html' + permission_required = 'aemo.can_archive' + + def form_valid(self, form): + arch = get_object_or_404(Archive, pk=self.kwargs['pk']) + try: + with open(arch.pdf.path, "rb") as fh: + pdf_crypted = fh.read() + except OSError as err: + messages.error(self.request, f"Erreur lors de la lecture du document ({str(err)})") + return HttpResponseRedirect(reverse('archive-list', args=[arch.unite])) + + try: + private_key_file = self.request.FILES['file'].read() + private_key = serialization.load_pem_private_key(private_key_file, password=None) + padd = padding.OAEP(mgf=padding.MGF1(algorithm=hashes.SHA1()), algorithm=hashes.SHA1(), label=None) + sim_key = private_key.decrypt(base64.b64decode(arch.key), padding=padd) + + fernet = Fernet(sim_key) + pdf_content = fernet.decrypt(pdf_crypted) + except ValueError as err: + messages.error(self.request, f"Erreur lors de la lecture de la clé ({str(err)})") + return HttpResponseRedirect(reverse('archive-list', args=[arch.unite])) + except InvalidToken: + messages.error(self.request, "Erreur lors du déchiffrement") + return HttpResponseRedirect(reverse('archive-list', args=[arch.unite])) + + filename = f"{slugify(arch.nom)}.pdf" + response = HttpResponse(pdf_content, content_type='application/pdf') + response['Content-Disposition'] = "attachment; filename=%s" % filename + return response diff --git a/common/__init__.py b/common/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/common/choices.py b/common/choices.py new file mode 100644 index 0000000..d6ce3d7 --- /dev/null +++ b/common/choices.py @@ -0,0 +1,99 @@ + + +AUTORITE_PARENTALE_CHOICES = ( + ('conjointe', 'Conjointe'), + ('pere', 'Père'), + ('mere', 'Mère'), + ('tutelle', 'Tutelle') +) + + +DEMARCHE_CHOICES = ( + ('volontaire', 'Volontaire'), + ('contrainte', 'Contrainte'), + ('post_placement', 'Post placement'), + ('non_placement', 'Eviter placement') +) + + +MANDATS_OPE_CHOICES = ( + ('volontaire', 'Mandat volontaire'), + ('curatelle', 'Curatelle 308'), + ('referent', 'Référent'), + ('enquete', 'Enquête'), + ('tutelle', 'Curatelle de portée générale'), +) + + +MOTIF_DEMANDE_CHOICES = ( + ('accompagnement', 'Accompagnement psycho-éducatif'), + ('integration', 'Aide à l’intégration'), + ('demande', 'Elaboration d’une demande (contrainte)'), + ('crise', 'Travail sur la crise'), + ('post-placement', 'Post-placement'), + ('pre-placement', 'Pré-placement'), + ('violence', 'Violence / maltraitances'), +) + + +MOTIFS_FIN_SUIVI_CHOICES = ( + ('desengagement', 'Désengagement'), + ('evol_positive', 'Autonomie familiale'), + ('relai_amb', 'Relai vers ambulatoire'), + ('relai', 'Relai vers autre service'), + ('placement', 'Placement'), + ('non_aboutie', 'Demande non aboutie'), + ('non_dispo', 'Pas de disponibilités/place'), + ('erreur', 'Erreur de saisie'), + ('autres', 'Autres'), # Obsolète (#435) +) + + +PROVENANCE_DESTINATION_CHOICES = ( + ('famille', 'Famille'), + ('ies-ne', 'IES-NE'), + ('ies-hc', 'IES-HC'), + ('aemo', 'SAEMO'), + ('fah', "Famille d'accueil"), + ('refug', "Centre d’accueil réfugiés"), + ('hopital', "Hôpital"), + ('autre', 'Autre'), # Obsolète (#435) +) + + +SERVICE_ORIENTEUR_CHOICES = ( + ('famille', 'Famille'), + ('ope', 'OPE'), + ('aemo', 'AEMO'), + ('cnpea', 'CNPea'), + ('ecole', 'École'), + ('res_prim', 'Réseau primaire'), + ('res_sec', 'Réseau secondaire'), + ('pediatre', 'Pédiatre'), + ('autre', 'Autre'), +) + + +TYPE_GARDE_CHOICES = ( + ('partage', 'garde partagée'), + ('droit', 'droit de garde'), + ('visite', 'droit de visite'), +) + +STATUT_FINANCIER_CHOICES = ( + ('ai', 'AI PC'), + ('gsr', 'GSR'), + ('osas', 'OSAS'), + ('revenu', 'Revenu'), +) + + +STATUT_MARITAL_CHOICES = ( + ('celibat', 'Célibataire'), + ('mariage', 'Marié'), + ('pacs', 'PACS'), + ('concubin', 'Concubin'), + ('veuf', 'Veuf'), + ('separe', 'Séparé'), + ('divorce', 'Divorcé') +) diff --git a/common/fields.py b/common/fields.py new file mode 100644 index 0000000..7b06260 --- /dev/null +++ b/common/fields.py @@ -0,0 +1,26 @@ +from django import forms +from django.contrib.postgres.fields import ArrayField + + +class ChoiceArrayField(ArrayField): + """ + From https://blogs.gnome.org/danni/2016/03/08/multiple-choice-using-djangos-postgres-arrayfield/ + A field that allows us to store an array of choices. + + Uses Django's postgres ArrayField and a MultipleChoiceField for its formfield. + See also https://code.djangoproject.com/ticket/27704 + """ + widget = forms.CheckboxSelectMultiple + + def formfield(self, **kwargs): + defaults = { + 'form_class': forms.TypedMultipleChoiceField, + 'coerce': self.base_field.to_python, + 'choices': self.base_field.choices, + 'widget': self.widget(attrs={'class': 'choicearray'}), + } + defaults.update(kwargs) + # Skip our parent's formfield implementation completely as we don't + # care for it. + # pylint:disable=bad-super-call + return super(ArrayField, self).formfield(**defaults) diff --git a/common/formats/fr/formats.py b/common/formats/fr/formats.py new file mode 100644 index 0000000..21c4204 --- /dev/null +++ b/common/formats/fr/formats.py @@ -0,0 +1,31 @@ +# Mix between Django fr and de_CH formats. +# see https://docs.djangoproject.com/en/dev/ref/templates/builtins/#date +DATE_FORMAT = 'j F Y' +TIME_FORMAT = 'H:i' +DATETIME_FORMAT = 'j F Y H:i' +YEAR_MONTH_FORMAT = 'F Y' +MONTH_DAY_FORMAT = 'j F' +SHORT_DATE_FORMAT = 'j N Y' +SHORT_DATETIME_FORMAT = 'j N Y H:i' +FIRST_DAY_OF_WEEK = 1 # Monday + +# The *_INPUT_FORMATS strings use the Python strftime format syntax, +# see https://docs.python.org/library/datetime.html#strftime-strptime-behavior +DATE_INPUT_FORMATS = [ + '%d.%m.%Y', '%d.%m.%y', # Swiss [fr_CH), '25.10.2006', '25.10.06' + '%d/%m/%Y', '%d/%m/%y', # '25/10/2006', '25/10/06' +] +DATETIME_INPUT_FORMATS = [ + '%d.%m.%Y %H:%M:%S', # '25.10.2006 14:30:59' + '%d.%m.%Y %H:%M:%S.%f', # '25.10.2006 14:30:59.000200' + '%d.%m.%Y %H:%M', # '25.10.2006 14:30' + '%d.%m.%Y', # '25.10.2006' + '%d/%m/%Y %H:%M:%S', # '25/10/2006 14:30:59' + '%d/%m/%Y %H:%M:%S.%f', # '25/10/2006 14:30:59.000200' + '%d/%m/%Y %H:%M', # '25/10/2006 14:30' + '%d/%m/%Y', # '25/10/2006' +] + +DECIMAL_SEPARATOR = ',' +THOUSAND_SEPARATOR = '\xa0' # non-breaking space +NUMBER_GROUPING = 3 diff --git a/common/middleware.py b/common/middleware.py new file mode 100644 index 0000000..7aee4a1 --- /dev/null +++ b/common/middleware.py @@ -0,0 +1,35 @@ +import re +from ipaddress import ip_address + +from django.conf import settings +from django.urls import reverse +from django.http import HttpResponseRedirect + +EXEMPT_URLS = [ + re.compile(r'^account/*'), + re.compile(r'^agenda/rendez-vous*'), + re.compile(r'^login/$'), + re.compile(r'^logout/$'), + re.compile(r'^jsi18n/$'), + re.compile(r'^favicon.ico$'), +] + + +class LoginRequiredMiddleware: + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + path = request.path_info.lstrip('/') + ip = ip_address(request.META.get('REMOTE_ADDR')) + # is_verified checks 2FA auth + if not request.user.is_verified() and not any(m.match(path) for m in EXEMPT_URLS): + ip_exempted = any(ip in net for net in settings.EXEMPT_2FA_NETWORKS) + if request.user.is_authenticated: + if ip_exempted or request.user.username in settings.EXEMPT_2FA_USERS: + return self.get_response(request) + return HttpResponseRedirect(reverse('two_factor:setup')) + else: + login_view = reverse('login') if ip_exempted else reverse('two_factor:login') + return HttpResponseRedirect("{}?next={}".format(login_view, request.path)) + return self.get_response(request) diff --git a/common/urls.py b/common/urls.py new file mode 100644 index 0000000..66e8fee --- /dev/null +++ b/common/urls.py @@ -0,0 +1,42 @@ +from django.conf import settings +from django.contrib import admin +from django.contrib.auth.urls import urlpatterns as auth_patterns +from django.contrib.auth.views import LoginView, LogoutView +from django.urls import path, include +from django.views.decorators.cache import cache_page +from django.views.i18n import JavaScriptCatalog +from django.views.static import serve + +from two_factor.urls import urlpatterns as tf_urls + +from aemo import views + +handler404 = 'aemo.views.page_not_found' + +urlpatterns = [ + path('', views.HomeView.as_view(), name='home'), + path('account/password_change/', views.PasswordChangeView.as_view(), name='password_change'), + # Overriden 2FA path: + path('account/two_factor/setup/', views.SetupView.as_view(), name='setup'), + path('', include(tf_urls)), + # Standard login is still permitted depending on IP origin + path('login/', LoginView.as_view(template_name='login.html'), name='login'), + path('logout/', LogoutView.as_view(next_page='/account/login'), name='logout'), + path('jsi18n/', cache_page(86400, key_prefix='jsi18n-1')(JavaScriptCatalog.as_view()), + name='javascript-catalog'), + path("", include("city_ch_autocomplete.urls")), + + path('tinymce/', include('tinymce.urls')), + path('admin/', admin.site.urls), + path('', include('aemo.urls')), + path('archive/', include('archive.urls')), + + path('media/', serve, + {'document_root': settings.MEDIA_ROOT, 'show_indexes': False}), +] + +# Include contrib.auth password reset URLs under the /account prefix +for pat in auth_patterns: + if pat.name.startswith('password') and pat.name != 'password_change': + pat.pattern._route = f'account/{pat.pattern._route}' + urlpatterns.append(pat) diff --git a/common/wsgi.py b/common/wsgi.py new file mode 100644 index 0000000..2d8777e --- /dev/null +++ b/common/wsgi.py @@ -0,0 +1,33 @@ +""" +WSGI config for aemo project. +""" + +import os + +from django.core.wsgi import get_wsgi_application + +UPGRADING = False + + +def upgrade_in_progress(environ, start_response): + response_headers = [('Content-type', 'text/html')] + response = """ + +

    This site is in maintenance mode, please come back in some minutes.

    +

    Ce site est actuellement en maintenance, merci de revenir dans quelques minutes.

    + + """ + if environ['REQUEST_METHOD'] == 'GET': + status = '200 OK' + else: + status = '403 Forbidden' + start_response(status, response_headers) + return [response.encode('utf-8')] + + +if UPGRADING: + application = upgrade_in_progress +else: + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "settings") + + application = get_wsgi_application() diff --git a/docker-compose.override.yml b/docker-compose.override.yml new file mode 100644 index 0000000..125dcbe --- /dev/null +++ b/docker-compose.override.yml @@ -0,0 +1,25 @@ +--- +version: '3.4' + +services: + db: + env_file: + - docker-dev/.env-db + volumes: + - db-data:/var/lib/postgresql/data + - ./data/:/data + + web: + build: + target: aemo-fr-dev + env_file: + - docker-dev/.env-web + volumes: + - .:/src + ports: + - "127.0.0.1:8000:80" + stdin_open: true + tty: true + +volumes: + db-data: diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..8fe0018 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,18 @@ +--- +version: '3.4' + +services: + db: + image: "postgres:15.6-alpine" + + web: + build: + dockerfile: docker-dev/Dockerfile + context: . + target: aemo-fr + command: python manage.py runserver 0.0.0.0:80 + depends_on: + - db + +volumes: + db-data: diff --git a/docker-dev/.env-db b/docker-dev/.env-db new file mode 100644 index 0000000..e98c5b7 --- /dev/null +++ b/docker-dev/.env-db @@ -0,0 +1,8 @@ +# PG* variables are used by psql client +PGDATABASE=main +PGPASSWORD=db +PGUSER=db +# POSTGRES_* variables are used to create the superuser when docker container is created +POSTGRES_DB=main +POSTGRES_PASSWORD=db +POSTGRES_USER=db diff --git a/docker-dev/.env-web b/docker-dev/.env-web new file mode 100644 index 0000000..b52640d --- /dev/null +++ b/docker-dev/.env-web @@ -0,0 +1,3 @@ +ALLOWED_HOSTS=127.0.0.1,localhost +DATABASE_URL=postgresql://db:db@db/main +DJANGO_SETTINGS_MODULE=settings diff --git a/docker-dev/Dockerfile b/docker-dev/Dockerfile new file mode 100644 index 0000000..ed4326b --- /dev/null +++ b/docker-dev/Dockerfile @@ -0,0 +1,26 @@ +FROM python:3.11.8-bookworm AS aemo-fr + +USER root + +RUN mkdir -p /src +WORKDIR /src + +RUN apt-get update && apt-get install -y --no-install-recommends \ + poppler-utils \ + libreoffice-writer \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt /usr/local/pip-requirements/ +RUN pip3 install \ + --quiet \ + --no-binary :none: \ + -r /usr/local/pip-requirements/requirements.txt + + +FROM aemo-fr AS aemo-fr-dev + +COPY requirements_dev.txt /usr/local/pip-requirements/ +RUN pip3 install \ + --quiet \ + --no-binary :none: \ + -r /usr/local/pip-requirements/requirements_dev.txt diff --git a/docker-dev/fabfile.py b/docker-dev/fabfile.py new file mode 100644 index 0000000..a336ad8 --- /dev/null +++ b/docker-dev/fabfile.py @@ -0,0 +1,76 @@ +import getpass +from fabric import task +from invoke import Context + +DEV_COMPOSE_CMD = "docker-compose exec -T {container} /bin/bash -c '{command}'" + +ENV_NAME = "prod" +REMOTE_DB_NAME = "aemo_fr" + + +@task +def download_remote_data(conn): + """ + Dump remote (production) data, download it and sync media files + + Don't forget to set host in command line `fab -H download-remote-data` + """ + local = Context() + + conn.config["sudo"]["password"] = getpass.getpass( + "Enter the sudo password (on the server):" + ) + + local.run('echo "Dump DB: Start"') + conn.run(f"touch {REMOTE_DB_NAME}.backup && chmod o+rw {REMOTE_DB_NAME}.backup") + conn.sudo( + f'pg_dump --no-owner -Fc -b -f "{REMOTE_DB_NAME}.backup" {REMOTE_DB_NAME}', + user="postgres", + ) + local.run('echo "Dump DB: End"') + + local.run('echo "Download DB: Start"') + conn.get(f"{REMOTE_DB_NAME}.backup", f"./data/{ENV_NAME}/") + local.run('echo "Download DB: End"') + + +@task +def import_db_in_dev(conn): + local = Context() + + local.run('echo "Import DB in dev: Start"') + local.run( + DEV_COMPOSE_CMD.format( + container="db", + command=( + "dropdb -U ${POSTGRES_USER} --if-exists --force ${POSTGRES_DB} && " + "createdb -U ${POSTGRES_USER} -O ${POSTGRES_USER} ${POSTGRES_DB}" + ), + ) + ) + local.run( + DEV_COMPOSE_CMD.format( + container="db", + command=f'pg_restore -U ${{POSTGRES_USER}} -d ${{POSTGRES_DB}} --no-owner "/data/{ENV_NAME}/{REMOTE_DB_NAME}.backup"', + ) + ) + local.run('echo "Import DB in dev: End\n\n"') + + +@task +def create_admin_in_dev(conn): + local = Context() + + local.run('echo "Create superuser: Start"') + local.run( + DEV_COMPOSE_CMD.format( + container="web", + command=( + "DJANGO_SUPERUSER_USERNAME=admin " + "DJANGO_SUPERUSER_PASSWORD=admin " + "DJANGO_SUPERUSER_EMAIL=admin@dev.dev " + "python3 ./manage.py createsuperuser --no-input" + ), + ) + ) + local.run('echo "Create superuser: End\n\n"') diff --git a/manage.py b/manage.py new file mode 100755 index 0000000..6d3a047 --- /dev/null +++ b/manage.py @@ -0,0 +1,15 @@ +#!/usr/bin/env python +import os +import sys + +if __name__ == '__main__': + os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'settings') + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) from exc + execute_from_command_line(sys.argv) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..0075421 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,20 @@ +city-ch-autocomplete>=0.3 +Django>=5.0,<5.1 +django-autocomplete-light==3.9.4 +psycopg +reportlab +openpyxl==3.1.2 +django-countries==7.6 +django-tinymce==3.7.1 +django-otp>=1.0.2 +phonenumberslite +django-two-factor-auth==1.15.4 +django-formtools # required for public agenda form +workalendar==17.0.0 +requests==2.31.0 +nh3 +html5lib +beautifulsoup4 +pypdf==3.17.4 +freezegun # for tests +extract_msg==0.41.2 diff --git a/requirements_dev.txt b/requirements_dev.txt new file mode 100644 index 0000000..c3c5ebb --- /dev/null +++ b/requirements_dev.txt @@ -0,0 +1,3 @@ +-r requirements.txt +ruff +dj-database-url diff --git a/ruff.toml b/ruff.toml new file mode 100644 index 0000000..44c7aaa --- /dev/null +++ b/ruff.toml @@ -0,0 +1,4 @@ +line-length = 119 + +[lint.per-file-ignores] +"*/migrations/*" = ["E501"] diff --git a/settings/README b/settings/README new file mode 100644 index 0000000..bda73d0 --- /dev/null +++ b/settings/README @@ -0,0 +1,6 @@ +This settings system works on two levels: +- aemo.py : global project settings +- __init__.py : instance specific settings (passwords, keys, etc.) + +__init__.py should import aemo.py. +__init__.py should *NOT* be committed in the vcs (it is in .gitignore). diff --git a/settings/aemo.py b/settings/aemo.py new file mode 100644 index 0000000..c8bb834 --- /dev/null +++ b/settings/aemo.py @@ -0,0 +1,189 @@ +from ipaddress import IPv4Network +from pathlib import Path +from django.contrib.messages import constants as messages + +BASE_DIR = Path(__file__).resolve().parent.parent + +DEBUG = False + +ALLOWED_HOSTS = ['localhost'] + +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.postgresql', + 'NAME': 'aemo_fr', + } +} +DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' + +INSTALLED_APPS = [ + 'dal', # dal: django-auto-complete + 'dal_select2', + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', + 'django.contrib.postgres', + + 'city_ch_autocomplete', + 'django_countries', + 'django_otp', + 'django_otp.plugins.otp_totp', + 'django_otp.plugins.otp_static', + 'two_factor', + 'tinymce', + + 'aemo', + 'archive', +] + +MIDDLEWARE = [ + 'django.middleware.security.SecurityMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.middleware.http.ConditionalGetMiddleware', + 'django.middleware.common.CommonMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django_otp.middleware.OTPMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', + 'django.middleware.clickjacking.XFrameOptionsMiddleware', + 'common.middleware.LoginRequiredMiddleware', +] + +ROOT_URLCONF = 'common.urls' + +TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [ + BASE_DIR / 'templates', + ], + 'APP_DIRS': True, + 'OPTIONS': { + 'context_processors': [ + 'django.contrib.auth.context_processors.auth', + 'django.template.context_processors.media', + 'django.template.context_processors.static', + 'django.contrib.messages.context_processors.messages', + 'django.template.context_processors.request', + ], + 'libraries': { + 'my_tags': 'aemo.templatetags.my_tags', + }, + 'builtins': [ + 'aemo.templatetags.my_tags', 'django.templatetags.static', + ], + }, + + }, + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'NAME': 'django-text', + 'APP_DIRS': True, + 'OPTIONS': { + 'autoescape': False, + }, + }, +] + +SESSION_ENGINE = 'django.contrib.sessions.backends.signed_cookies' + +WSGI_APPLICATION = 'common.wsgi.application' + + +# Password validation +# https://docs.djangoproject.com/en/2.1/ref/settings/#auth-password-validators + +AUTH_PASSWORD_VALIDATORS = [ + { + 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', + }, +] + +AUTH_USER_MODEL = 'aemo.Utilisateur' + +LOGIN_URL = 'two_factor:login' +LOGIN_REDIRECT_URL = '/' +EXEMPT_2FA_NETWORKS = [IPv4Network('127.0.0.0/30')] +# These users should not have TOTP devices configured. +EXEMPT_2FA_USERS = [] + +# Internationalization +# https://docs.djangoproject.com/en/2.1/topics/i18n/ + +LANGUAGE_CODE = 'fr' +LANGUAGES = [('fr', 'Français')] + +TIME_ZONE = 'Europe/Zurich' + +USE_I18N = True + +USE_TZ = True + +FORMAT_MODULE_PATH = ['common.formats'] + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/2.1/howto/static-files/ + +STATIC_URL = '/static/' +STATIC_ROOT = BASE_DIR / 'static' + +MEDIA_URL = '/media/' +MEDIA_ROOT = BASE_DIR / 'media' + +SESSION_EXPIRE_AT_BROWSER_CLOSE = True + +DEFAULT_FROM_EMAIL = 'secretariat@fondation-transit.ch' + +# For django-countries +COUNTRIES_FIRST = ['CH'] +# https://github.com/SmileyChris/django-countries/issues/314 +COUNTRIES_OVERRIDE = { + "XK": {"name": "Kosovo", "ioc_code": "KOS"}, +} + +TINYMCE_DEFAULT_CONFIG = { + 'autosave_interval': '10s', + 'autosave_retention': '240m', + 'height': 360, + 'width': '100%', + 'cleanup_on_startup': True, + 'entity_encoding': 'raw', + 'custom_undo_redo_levels': 20, + 'browser_spellcheck': True, + 'theme': 'silver', + 'plugins': 'autosave,lists', + 'toolbar1': 'preview bold italic underline bullist | undo redo restoredraft', + 'menubar': False, + 'statusbar': True, + # Modern way is to use text-decoration: underline, however it is harder to clean + # with sanitizer than simpler . + 'formats': { + 'underline': {'inline': 'u', 'exact': True} + }, +} + +MESSAGE_TAGS = { + messages.DEBUG: 'alert-info', + messages.INFO: 'alert-info', + messages.SUCCESS: 'alert-success', + messages.WARNING: 'alert-warning', + messages.ERROR: 'alert-danger', +} + +CRNE_RSA_PUBLIC_KEY = '' + +# Les valeurs "POST_API_*" doivent être renseignées dans settings/__init__.py (non suivi dans le dépôt de code) +POST_API_USER = "" +POST_API_PASSWORD = "" diff --git a/settings/aemo_tests.py b/settings/aemo_tests.py new file mode 100644 index 0000000..288c064 --- /dev/null +++ b/settings/aemo_tests.py @@ -0,0 +1,14 @@ +from settings.aemo import * # noqa + +SECRET_KEY = 'ForAutomatedTestsC9g' + +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.postgresql', + 'NAME': 'ci_test', + 'USER': 'runner', + 'PASSWORD': '', + 'HOST': 'postgres', + 'PORT': '5432', + } +} diff --git a/settings/dev_docker.py b/settings/dev_docker.py new file mode 100644 index 0000000..7e1d949 --- /dev/null +++ b/settings/dev_docker.py @@ -0,0 +1,14 @@ +import dj_database_url +import os + +from ipaddress import IPv4Network + +from settings.aemo import * # NOQA + + +ALLOWED_HOSTS = os.environ["ALLOWED_HOSTS"].split(",") +DATABASES = {"default": dj_database_url.parse(os.environ["DATABASE_URL"])} +DEBUG = True +EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend' +EXEMPT_2FA_NETWORKS = [IPv4Network('0.0.0.0/0')] +SECRET_KEY = "no secret here" diff --git a/templates/403.html b/templates/403.html new file mode 100644 index 0000000..9820139 --- /dev/null +++ b/templates/403.html @@ -0,0 +1,19 @@ +{% extends "base.html" %} + +{% block title %}Accès non autorisé{% endblock %} + +{% block content %} +
    +

    Oups !!!

    + {% if exception %} +
    {{ exception }}
    + {% else %} +

    Vous n'avez pas la permission d'accéder au contenu de cette page.

    + {% endif %} +

    +

    + +

    +
    +{% endblock %} + diff --git a/templates/404-public.html b/templates/404-public.html new file mode 100644 index 0000000..d47daef --- /dev/null +++ b/templates/404-public.html @@ -0,0 +1,8 @@ +{% extends "base-public.html" %} + +{% block title %}Page introuvable{% endblock %} + +{% block content %} +

    Page introuvable

    +

    Nous sommes désolés, la page demandée n'existe pas ou plus.

    +{% endblock %} diff --git a/templates/404.html b/templates/404.html new file mode 100644 index 0000000..a8299f8 --- /dev/null +++ b/templates/404.html @@ -0,0 +1,8 @@ +{% extends "base.html" %} + +{% block title %}Page introuvable{% endblock %} + +{% block content %} +

    Page introuvable

    +

    Nous sommes désolés, la page demandée n'existe pas ou plus.

    +{% endblock %} diff --git a/templates/500.html b/templates/500.html new file mode 100644 index 0000000..49fdcc4 --- /dev/null +++ b/templates/500.html @@ -0,0 +1,8 @@ +{% extends "base.html" %} + +{% block title %}Erreur de serveur{% endblock %} + +{% block content %} +

    Erreur de serveur

    +

    Nous sommes désolés, une erreur de serveur s'est produite. L'administrateur a été informé et fera tout son possible pour la résoudre rapidement.

    +{% endblock %} diff --git a/templates/actions.html b/templates/actions.html new file mode 100644 index 0000000..374e101 --- /dev/null +++ b/templates/actions.html @@ -0,0 +1,16 @@ +
    + {% if form.instance.pk %} +
    + {% if view.delete_url %} + + {% endif %} +
    + {% endif %} +
    + Annuler + +
    +
    diff --git a/templates/admin/menu_principal.html b/templates/admin/menu_principal.html new file mode 100644 index 0000000..193b223 --- /dev/null +++ b/templates/admin/menu_principal.html @@ -0,0 +1,22 @@ + +{% if perms.aemo.export_stats %} + + +{% endif %} diff --git a/templates/aemo/acces_famille.html b/templates/aemo/acces_famille.html new file mode 100644 index 0000000..8b0aad0 --- /dev/null +++ b/templates/aemo/acces_famille.html @@ -0,0 +1,12 @@ +{% extends "base.html" %} + +{% block content %} + +

    Confirmation d’accès

    + +

    Veuillez confirmer l'accès à la famille {{ famille.nom }}. Comme ce n’est pas un accès habituel, il sera journalisé comme tel.

    + +

    + Continuer vers la famille {{ famille.nom }} +

    +{% endblock %} diff --git a/templates/aemo/agenda_suivi.html b/templates/aemo/agenda_suivi.html new file mode 100644 index 0000000..ca6d6c8 --- /dev/null +++ b/templates/aemo/agenda_suivi.html @@ -0,0 +1,127 @@ +{% extends "base.html" %} + +{% block extrastyle %} + +{% endblock %} + +{% block boutons_droite %} + {% include './famille_boutons.html' with active='agenda' %} +{% endblock %} + +{% block content %} +
    +
    Famille {{ famille.nom }} - {{ famille.adresse }}
    +
    Dossier de suivi
    +
    + +
    {% csrf_token %} + + {% for error in form.non_field_errors %} +
    + {{ error }} +
    + {% endfor %} +
    +
    ÉVALUATION
    +
    ACCOMPAGNEMENT
    +
    CLÔTURE DU DOSSIER
    +
    + +
    +
    +
    Demande déposée le
    +

    {{ form.date_demande.errors }}{{ form.date_demande }}

    +
    Début de l’évaluation le
    +

    {{ form.date_debut_evaluation.errors }}{{ form.date_debut_evaluation }}

    +
    Fin de l’évaluation le
    +

    {{ form.date_fin_evaluation.errors }}{{ form.date_fin_evaluation }}

    + {% if mode == 'evaluation' %} +
    +
    Si abandon du dossier:
    +
    Motif de fin
    +

    {{ form.motif_fin_suivi.errors }}{{ form.motif_fin_suivi }}

    +
    + {% endif %} +

    + Évaluation PDF +

    +
    + +
    +
    +
    + Début de l'accompagnement le {{ form.date_debut_suivi.errors }}{{ form.date_debut_suivi }} + {% if famille.suivi.date_debut_suivi and not famille.suivi.date_fin_suivi %} +
    Depuis {{ famille.suivi.date_debut_suivi|timesince }}
    + {% endif %} +
    +
    +
    +
    + {% for niveau in niveaux %} +
    Niv. d’intervention {{niveau.niveau_interv }} + du {{ niveau.date_debut|date:"d.m.Y" }} au {{ niveau.date_fin_calc|date:"d.m.Y"|default:'--'}} +
    + {% endfor %} +
    +
    +
    Accompagnement(s) temporaire(s)
    + {% for intervention in interv_temporaires %} +
    {{ intervention.intervenant.nom_prenom }} ({{ intervention.role }}) + ({{ intervention.date_debut|date:"d.m.Y" }} - {{intervention.date_fin|date:"d.m.Y" }}) +
    + {% empty %} - + {% endfor %} +
    +
    + +
    +
    +
    BILANS ET RÉSUMÉS
    +

    + {% include "partial_show_docs.html" with docs=bilans_et_rapports %} +

    + {% if can_edit and mode == 'suivi' %} +

    + Ajouter un bilan + Ajouter un résumé +

    + {% endif %} +
    +
    +
    +
    + {% if mode == 'suivi' %} +
    + {% include 'partial_agenda_cloture.html' %} +
    + {% endif %} + {% if mode == 'reactivation' %} +
    Fin de l’accompagnement le
    +

    {{ object.date_fin_suivi }}

    +
    Motif de fin
    +

    {{ object.get_motif_fin_suivi_display }}

    +
    Destination
    +

    {{ object.famille.get_destination_display }}

    + {% if famille|can_be_reactivated:user %} + + {% endif %} + {% endif %} +
    +
    +
    +
    + {% if famille|can_edit:user %} + {% include "actions.html" %} + {% endif %} +
    +
    +
    +{% endblock %} diff --git a/templates/aemo/bilan.html b/templates/aemo/bilan.html new file mode 100644 index 0000000..36b0fa6 --- /dev/null +++ b/templates/aemo/bilan.html @@ -0,0 +1,42 @@ +{% extends "base.html" %} + +{% block boutons_droite %} + {% include 'aemo/famille_boutons.html' with active='agenda' %} +{% endblock %} + +{% block content %} +
    +
    Famille {{ famille.nom }} - {{ famille.adresse }}
    +
    + Bilan du {{ bilan.date }}{% if bilan.phase %} - Phase {{ bilan.phase }}{% endif %} +
    +
    + +
    +
    + {% for label, value in data %} +

    {{ label }}

    + {{ value|default:'-'|raw_or_html }} + {% endfor %} + {% if bilan.fichier and bilan.famille|can_edit:user %} +

    Document annexé

    + {{ bilan.fichier }} + {% endif %} +
    +
    + +
    +
    + Retour + {% if bilan|can_edit:user %} + Modifier +
    {% csrf_token %} + +
    + {% endif %} +
    +
    +{% endblock %} diff --git a/templates/aemo/cercle_scolaire_edit.html b/templates/aemo/cercle_scolaire_edit.html new file mode 100644 index 0000000..a6de52d --- /dev/null +++ b/templates/aemo/cercle_scolaire_edit.html @@ -0,0 +1,22 @@ +{% extends "base.html" %} + +{% block content %} +
    +

    Centre scolaire

    +
    +
    +
    +
    + {% csrf_token %} + + + {% csrf_token %} + + {{ form.as_table }} +
    + {% include "actions.html" %} +
    + +
    +
    +{% endblock %} diff --git a/templates/aemo/cercle_scolaire_list.html b/templates/aemo/cercle_scolaire_list.html new file mode 100644 index 0000000..04b6f0f --- /dev/null +++ b/templates/aemo/cercle_scolaire_list.html @@ -0,0 +1,34 @@ +{% extends "base.html" %} + +{% block content %} +
    +

    Centres scolaires

    + {% if perms.aemo.add_cerclescolaire %} +
    + Nouveau +
    + {% endif %} +
    +
    +
    + + + + + + + + + {% with can_edit=perms.aemo.change_cerclescolaire %} + {% for item in object_list %} + + + + + {% endfor %} + {% endwith %} + +
    NomTéléphone
    {% if can_edit %}{% endif %}{{ item.nom }}{% if can_edit %}{% endif %}{{ item.telephone }}
    +
    +
    +{% endblock %} diff --git a/templates/aemo/charge_utilisateurs.html b/templates/aemo/charge_utilisateurs.html new file mode 100644 index 0000000..35fb9e1 --- /dev/null +++ b/templates/aemo/charge_utilisateurs.html @@ -0,0 +1,39 @@ +{% extends "base.html" %} + +{% block content %} +
    +

    Charge des dossiers en cours par utilisateur (rôles psy et éduc)

    +
    +
    {{ filter_form }}
    + Utilisateurs +
    +
    + +
    +
    + + + + + + + + + + + + + {% for util in utilisateurs %} + + + + + + + + {% endfor %} + +
    Taux act.Familles éval.Familles suiviesCharge hebdoCharge max.Différence
    {{ util }}{{ util.taux_activite }}%{{ util.nbre_eval }}{{ util.nbre_suivi }}{{ util.heures }}{{ util.charge_max }}{{ util.charge_diff }}
    +
    +
    +{% endblock %} diff --git a/templates/aemo/contact_edit.html b/templates/aemo/contact_edit.html new file mode 100644 index 0000000..b3c0e22 --- /dev/null +++ b/templates/aemo/contact_edit.html @@ -0,0 +1,45 @@ +{% extends "base.html" %} + +{% block extra_javascript %} + +{% endblock %} + +{% block content %} +
    +

    Contact professionnel

    +

    {{ view.action }}

    +
    + +
    +
    +
    {% csrf_token %} + + {{ form.as_table }} +
    + {% include "actions.html" %} +
    +
    +
    +{% endblock %} diff --git a/templates/aemo/contact_list.html b/templates/aemo/contact_list.html new file mode 100644 index 0000000..6a6179d --- /dev/null +++ b/templates/aemo/contact_list.html @@ -0,0 +1,79 @@ +{% extends "base.html" %} + +{% block extra_javascript %} + +{% endblock %} + +{% block extrastyle %} + +{% endblock %} + +{% block boutons_droite %} +
    +
    +
    + {{ form.role.label_tag }} {{ form.role }} +
    +
    + {{ form.service.label_tag }} {{ form.service }} +
    +
    + {{ form.texte }} + + + {{ form.sort_by }} +
    +
    +
    +{% endblock %} + +{% block content %} +
    +

    Contacts

    + {% if perms.aemo.add_contact %} + + {% endif %} +
    + +
    +
    + + + + + + + + + + + + + + {% with can_edit=perms.aemo.change_contact %} + {% for contact in object_list %} + + + + + + + + + + {% endfor %} + {% endwith %} + +
    Nom, prénomTéléphone (prof.)Téléphone (privé)EmailServiceRôlesActivité/prof.
    {% if can_edit %}{% endif %}{{ contact.nom_prenom }}{% if can_edit %}{% endif %}{{ contact.tel_prof }}{{ contact.tel_prive }}{{ contact.email }}{{ contact.service.sigle }}{{ contact.roles_str }}{{ contact.profession }}
    +
    +
    +{% endblock %} + diff --git a/templates/aemo/demande_edit.html b/templates/aemo/demande_edit.html new file mode 100644 index 0000000..a732b7c --- /dev/null +++ b/templates/aemo/demande_edit.html @@ -0,0 +1,47 @@ +{% extends "base.html" %} + +{% block boutons_droite %} + {% if form.instance.pk %} + {% include 'aemo/famille_boutons.html' with active='demande' %} + {% endif %} +{% endblock %} + +{% block content %} +
    +
    Famille {{ famille.nom }} - {{ famille.adresse }}
    +
    Analyse de la demande
    +
    +
    +
    +
    {% csrf_token %} + + + {{ form.as_table }} + + + + +
    Réseau prof. + + + + + + {% for enfant in famille.membres_suivis %} + {% for ct in enfant.reseaux.all %} + + + + + {% endfor %} + {% endfor %} +
    OPE:{{ famille.suivi|noms_referents_ope }} {{ famille.suivi.mandat_ope|join:', '|in_parens }}
    {{ enfant.prenom }}:{{ ct }} {{ ct.contact }}
    +
    + {% if famille|can_edit:user %} + {% include "actions.html" %} + {% endif %} +
    +
    +
    + +{% endblock %} diff --git a/templates/aemo/document_upload.html b/templates/aemo/document_upload.html new file mode 100644 index 0000000..454b675 --- /dev/null +++ b/templates/aemo/document_upload.html @@ -0,0 +1,24 @@ +{% extends "base.html" %} + +{% block content %} +
    +

    {{ view.titre_page }}

    +
    +
    +
    +
    + {% csrf_token %} + {{ form.non_field_errors }} + + + + + + {{ form.as_table }} + +
    {{ view.titre_formulaire }}
    + {% include 'actions.html' %} +
    +
    +
    +{% endblock %} diff --git a/templates/aemo/export.html b/templates/aemo/export.html new file mode 100644 index 0000000..61f8762 --- /dev/null +++ b/templates/aemo/export.html @@ -0,0 +1,22 @@ +{% extends "base.html" %} + +{% block content %} + {% include "statistiques/stat-tabs.html" with active="export" %} + +
    +

    Exportation mensuelle

    +
    +
    +
    +

    Exportation des statistiques mensuelles pour le SPAJ

    +
    {% csrf_token %} + + {{ form.as_table }} + + + +
    +
    +
    +
    +{% endblock %} diff --git a/templates/aemo/famille_adresse.html b/templates/aemo/famille_adresse.html new file mode 100644 index 0000000..4e6a686 --- /dev/null +++ b/templates/aemo/famille_adresse.html @@ -0,0 +1,35 @@ +{% extends "base.html" %} + +{% block content %} +
    +
    +

    Famille: {{ famille }}

    +
    +
    +
    +
    +

    Changement d’adresse

    +
    {% csrf_token %} + + + + + + + + + + + + + + + +
    {{ form.rue.label_tag }}{{ form.rue.errors }}{{ form.rue }}
    {{ form.city_auto.label_tag }}{{ form.city_auto.errors }}{{ form.city_auto }}
    +
    Appliquer le changement aux personnes suivantes
    +
    {{ form.membres.errors }}{{ form.membres }}
    + {% include "actions.html" %} +
    +
    +
    +{% endblock %} diff --git a/templates/aemo/famille_boutons.html b/templates/aemo/famille_boutons.html new file mode 100644 index 0000000..0cd9312 --- /dev/null +++ b/templates/aemo/famille_boutons.html @@ -0,0 +1,8 @@ + diff --git a/templates/aemo/famille_edit.html b/templates/aemo/famille_edit.html new file mode 100644 index 0000000..99a7965 --- /dev/null +++ b/templates/aemo/famille_edit.html @@ -0,0 +1,165 @@ +{% extends "base.html" %} + +{% block boutons_droite %} + {% if form.instance.pk %} + {% include 'aemo/famille_boutons.html' with active='coordonnees' %} + {% endif %} +{% endblock %} + +{% block content %} +
    + {% if form.instance.pk %} +
    Famille {{ famille.nom }} - {{ famille.adresse }}
    +
    + Informations générales + +
    + {% else %} +
    Nouvelle famille
    + {% endif %} +
    + +
    {% csrf_token %} +
    +
    + {% for hidden in subform.hidden_fields %}{{ hidden }}{% endfor %} + + {% for field in form.visible_fields %} + + + + + {% if form.instance.pk and not form.readonly and field.name == "localite" %} + + + + {% endif %} + {% endfor %} +
    {{ field.label_tag }}{{ field.errors }}{{ field }}
    + + Changer l’adresse + +
    +
    + {% if form.instance.pk %} +
    +

    Parents et personnes significatives + {% if famille|can_edit:user and form.instance.pk %} + Ajouter pers. sign. + + {% endif %} +

    + {% with parents=famille.parents %} + + {% for parent in parents %} + + + + {% endfor %} + {% if parents|length < 2 %} + + + + {% endif %} +
    {{ parent.role }}:{{ parent.nom_prenom }} - {{ parent.adresse }} {% if parent.telephone %}/ {{ parent.telephone}}{% endif %}
    + Ajouter parent +
    + {% endwith %} + + {% for person in famille.autres_parents %} + + + + + {% endfor %} + +
    {{ person.role }}:{{ person.nom_prenom }} + {% if person.adresse %}- {{ person.adresse }}{% endif %} + {% if person.telephone %} / {{ person.telephone }}{% endif %} +
    + + +

    Enfants non-suivis + {% if famille|can_edit:user %} + {% if form.instance.pk %} + Ajouter + + {% endif %} + {% endif %} +

    + + + {% for membre in famille.enfants_non_suivis %} + + + + + + {% endfor %} +
    {{ membre.nom_prenom }}
    + {{ membre.age_str }}
    {{ membre.adresse }}{{ membre.formation.get_statut_display }}
    + +

    Enfants suivis + {% if famille|can_edit:user %} + {% if form.instance.pk %} + Ajouter + {% endif %} + {% endif %} +

    +
    + + {% for membre in famille.membres_suivis %} + + + + + {% endfor %} +
    +
    + {{ membre.nom_prenom }} + {% if membre.formation %} + + + + + + + {% endif %} +
    + {{ membre.age_str }} +
    +
    {{ membre.adresse }}
    +
    +
    + {% endif %} +
    +
    + {% if view.archive_url %} +
    + +
    + {% elif view.unarchive_url %} +
    + +
    + {% endif %} +
    + {% if not form.instance.pk or not form.readonly %} + {% include "actions.html" %} + {% endif %} +
    +
    +
    +{% endblock %} diff --git a/templates/aemo/famille_list.html b/templates/aemo/famille_list.html new file mode 100644 index 0000000..0c99342 --- /dev/null +++ b/templates/aemo/famille_list.html @@ -0,0 +1,156 @@ +{% extends "base.html" %} + +{% block extra_javascript %} + +{% endblock %} + +{% block boutons_droite %} +
    +
    +
    + {{ form.duos }}{{ form.duos.label_tag }} +
    +
    + {{ form.ressource }}{{ form.ressource.label_tag }} +
    +
    + {{ form.equipe }}{{ form.equipe.label_tag }} +
    +
    + {{ form.interv }}{{ form.interv.label_tag }} +
    +
    + {{ form.niveau }}{{ form.niveau.label_tag }} +
    +
    + {{ form.nom }} + + +
    +
    +
    +{% endblock %} + +{% block content %} +
    +

    Familles {% if view.mode == 'attente' %}- Liste d’attente{% endif %}

    +
    + {% if view.mode == 'attente' %} + Liste + {% else %} + Liste d’attente + {% endif %} + {% if perms.aemo.add_famille %} + Nouvelle famille + {% endif %} +
    +
    +
    +
    + + + + {% for label in labels %} + + {% endfor %} + + + + {% for famille in object_list %} + {% with suivi=famille.suivi %} + + {% for col_key in col_keys %} + {% if col_key == 'nom' %} + + {% elif col_key == 'localite' %} + + {% elif col_key == 'referents' %} + + {% elif col_key == 'referents_ope' %} + + {% elif col_key == 'suivi' %} + + {% elif col_key == 'prioritaire' %} + + {% elif col_key == 'niveau_interv' %} + + {% elif col_key == 'date_demande' %} + + {% elif col_key == 'region' %} + + {% elif col_key == 'evaluation' %} + {% if suivi.date_fin_evaluation %} + {% elif suivi.date_debut_evaluation %} + {% else %}{% endif %} + {% endif %} + {% endfor %} + + {% endwith %} + {% empty %} + + {% endfor %} + +
    {{ label }}
    + + {{ famille.npa}} {{ famille.localite }}{{ suivi|sigles_referents }} + {{ suivi.ope_referent|info_ope }} + + + + + + + + + + +
    {{ suivi|etape_cellule:"demande" }}{{ suivi|etape_cellule:"debut_evaluation" }}{{ suivi|etape_cellule:"fin_evaluation" }}{{ suivi|etape_cellule:"debut_suivi" }}{{ suivi|etape_cellule:"bilan_suivant" }}{{ suivi|etape_cellule:"resume" }}{{ suivi|etape_cellule:"fin_suivi" }}
    +
    {{ suivi.demande_prioritaire|yesno:'Oui,' }}{{ famille.niveau_interv|default_if_none:'-' }} + {{ suivi.date_demande|date:"d.m.Y" }} + {{ famille.region.nom }}Terminée le {{ suivi.date_fin_evaluation }}En coursÀ faire
    + Aucune famille ne correspond à votre recherche. +
    +
    +
    + {% if can_view_rdvs %} +
    + {% if duo_rdv_passes %} +
    Derniers RdV: + {% for rdv in duo_rdv_passes %}{% if not forloop.first %}, {% endif %}{{ rdv.rendez_vous|date:'d.m.Y' }}{% endfor %} +
    + {% endif %} + +
    + {% endif %} + + {% if ma_charge %} +
    + Ma charge actuelle: {{ ma_charge.nbre_eval }} familles en éval., {{ ma_charge.nbre_suivi }} familles suivies, {{ ma_charge.heures|default_if_none:0 }}h. hebdo (sur max. {{ user.charge_max}}h) +
    + {% endif %} +{% endblock %} + diff --git a/templates/aemo/form_in_popup.html b/templates/aemo/form_in_popup.html new file mode 100644 index 0000000..9866a69 --- /dev/null +++ b/templates/aemo/form_in_popup.html @@ -0,0 +1,36 @@ +
    +

    {{ view.titre_page }}

    +
    +
    +
    +
    + {% csrf_token %} + {{ form.non_field_errors }} + + + + + + {{ form.as_table }} + +
    {{ view.titre_formulaire }}
    +
    + {% if form.instance.pk %} +
    + {% if view.delete_url %} + + {% endif %} +
    + {% endif %} +
    + + +
    +
    +
    +
    +
    + diff --git a/templates/aemo/formation_edit.html b/templates/aemo/formation_edit.html new file mode 100644 index 0000000..204c1de --- /dev/null +++ b/templates/aemo/formation_edit.html @@ -0,0 +1,67 @@ +{% extends "base.html" %} + +{% block extra_javascript %} + +{% endblock %} + +{% block content %} +
    +
    +

    Famille {{ form.instance.personne.famille.nom }} - {{ form.instance.personne.famille.adresse }}

    +
    +
    +

    Formation de {{ form.instance.personne.nom_prenom }}

    +
    +
    +
    +
    +
    {% csrf_token %} + + + + + + + + + + + + + + + + + + + + + + + +
    Niveau de formation:{{ form.statut }} +
    École:{{ form.cercle_scolaire }}Collège:{{ form.college }}
    Classe:{{ form.classe }}Enseignant:{{ form.enseignant }}
    Accueil extra-fam.:{{ form.creche }}Responsable:{{ form.creche_resp }}
    Entreprise:{{ form.entreprise }}Maître d'apprent.:{{ form.maitre_apprentissage }}
    Remarque:{{ form.remarque }}
    + {% if form.instance|can_edit:user %} + {% include "actions.html" %} + {% endif %} +
    +
    +
    +{% endblock %} diff --git a/templates/aemo/menu_principal.html b/templates/aemo/menu_principal.html new file mode 100644 index 0000000..9761de5 --- /dev/null +++ b/templates/aemo/menu_principal.html @@ -0,0 +1,15 @@ + + + + + diff --git a/templates/aemo/niveau_change.html b/templates/aemo/niveau_change.html new file mode 100644 index 0000000..221df73 --- /dev/null +++ b/templates/aemo/niveau_change.html @@ -0,0 +1,11 @@ +
    +
    +
    Famille: {{ famille.nom }}
    +
    {% csrf_token %} + + {{ form.as_table }} +
    + {% include "actions.html" %} +
    +
    +
    diff --git a/templates/aemo/object_confirm_delete.html b/templates/aemo/object_confirm_delete.html new file mode 100644 index 0000000..7de0f18 --- /dev/null +++ b/templates/aemo/object_confirm_delete.html @@ -0,0 +1,22 @@ +{% extends "base.html" %} + +{% block content %} +
    +
    +
    Suppression de données
    +
    Voulez-vous vraiment supprimer l'information suivante ?
    +
    {{ object }} ?
    +
    Cette opération est définitive !
    + +
    {% csrf_token %} +
    +
    +
    + Annuler +
    +
    +
    + +
    +
    +{% endblock %} diff --git a/templates/aemo/permissions.html b/templates/aemo/permissions.html new file mode 100644 index 0000000..8fe50e4 --- /dev/null +++ b/templates/aemo/permissions.html @@ -0,0 +1,29 @@ +{% extends "base.html" %} + +{% block extrastyle %} + +{% endblock %} + +{% block content %} + +{% for grp in groups %}{% endfor %} + + {% for label, plist in perms_by_categ.items %} + + {% for pcode, pverb in plist %} + + {% for grp, pcodes in grp_perms.items %} + + {% endfor %} + + {% endfor %} + {% endfor %} + +
    {{ grp.name }}
    {{ label }}
    {{ pverb }}{% if pcode in pcodes %}{% endif %}
    +{% endblock %} diff --git a/templates/aemo/personne_edit.html b/templates/aemo/personne_edit.html new file mode 100644 index 0000000..9e9e519 --- /dev/null +++ b/templates/aemo/personne_edit.html @@ -0,0 +1,125 @@ +{% extends "base.html" %} + +{% block extrastyle %} +{{ block.super }} + +{% endblock %} + +{% block boutons_droite %} + {% include 'aemo/famille_boutons.html' with active='coordonnees' %} +{% endblock %} + +{% block content %} +
    +
    Famille {{ famille.nom }} - {{ famille.adresse }}
    +
    {% if personne.pk %}{{ personne.nom_prenom }}{% else %}Nouveau membre{% endif %}
    +
    +
    +
    +
    {% csrf_token %} + +
    +
    + {{ form.nom.label_tag }}{{ form.nom.errors }}{{ form.nom }} +
    +
    + {{ form.prenom.label_tag }}{{ form.prenom.errors }}{{ form.prenom }} +
    +
    +
    +
    + {{ form.date_naissance.label_tag }}{% help_tooltip 'Si l’âge est connu, mais pas la date de naissance exacte, saisir le 1er janvier de l’année supposée' %} +
    {{ form.date_naissance.errors }}{{ form.date_naissance }} +
    +
    + {{ form.genre.label_tag }}{{ form.genre.errors }}{{ form.genre }} +
    +
    + {{ form.role.label_tag }}{{ form.role.errors }}{{ form.role }} +
    +
    +
    +
    + {{ form.filiation.label_tag }}{% help_tooltip 'Précision sur le lien familial (père de…, etc.)' %}{{ form.filiation.errors }}{{ form.filiation }} +
    + {% if form.prescripteur %}{# CIPE #} +
    + {{ form.prescripteur.label_tag }}{{ form.prescripteur.errors }}{{ form.prescripteur }} +
    + {% endif %} +
    +
    +
    + {{ form.rue.label_tag }}{{ form.rue.errors }}{{ form.rue }} +
    +
    + {{ form.city_auto.label_tag }}{{ form.city_auto.errors }}{{ form.city_auto }} +
    +
    +
    +
    + {{ form.telephone.label_tag }}{{ form.telephone.errors }}{{ form.telephone }} +
    +
    + {{ form.email.label_tag }}{{ form.email.errors }}{{ form.email }} +
    +
    +
    +
    + {{ form.profession.label_tag }}{{ form.profession.errors }}{{ form.profession }} +
    +
    + {{ form.pays_origine.label_tag }}{{ form.pays_origine.errors }}{{ form.pays_origine }} +
    +
    +
    +
    + {{ form.allergies.label_tag }}{{ form.allergies.errors }}{{ form.allergies }} +
    +
    +
    +
    + {{ form.remarque.label_tag }}{{ form.remarque.errors }}{{ form.remarque }} +
    +
    + {{ form.remarque_privee.label_tag }}{{ form.remarque_privee.errors }}{{ form.remarque_privee }} +
    +
    + {% with gestite=personne.gestite %} + {% if gestite %} +
    +
    Gestité : {{ gestite }}
    +
    + {% endif %} + {% endwith %} +
    +
    +
    + {{ form.decedee.errors }}{{ form.decedee }} {{ form.decedee.label_tag|strip_colon }} +
    +
    +
    + + {% if personne.pk %} +
    + {% if form.instance.formation %} +
    + Formation +
    + {% endif %} + +
    + {% endif %} + + {% if famille|can_edit:user %} + {% include "actions.html" %} + {% endif %} +
    +
    +
    + +{% endblock %} diff --git a/templates/aemo/personne_reseau_list.html b/templates/aemo/personne_reseau_list.html new file mode 100644 index 0000000..ae305ec --- /dev/null +++ b/templates/aemo/personne_reseau_list.html @@ -0,0 +1,88 @@ +{% extends "base.html" %} + +{% block extra_javascript %} + +{% endblock %} + +{% block content %} +
    +
    Famille {{ personne.famille.nom }} - {{ personne.famille.adresse }}
    +
    Réseau professionnel de {{ personne.nom_prenom }}
    +
    +
    +
    + {% if personne.famille|can_edit:user %} + + {% endif %} +
    +
    +
    +
    +
    {% csrf_token %} + + + + + + + + + + {% for contact in reseau %} + + + + + + + + {% endfor %} + +
    Nom, prénomRôle/Act./prof.TéléphoneEmailAction
    {{ contact.nom_prenom }}{{ contact|role_profession }}{{ contact.tel_prof }}{{ contact.email }} + +
    +
    +
    +
    + +
    + +
    +{% endblock %} diff --git a/templates/aemo/prestation_edit.html b/templates/aemo/prestation_edit.html new file mode 100644 index 0000000..8314d3e --- /dev/null +++ b/templates/aemo/prestation_edit.html @@ -0,0 +1,48 @@ +{% extends "base.html" %} + +{% block extrastyle %} + +{% endblock %} + +{% block boutons_droite %} + {% if famille %} + {% include './famille_boutons.html' with active='prestation' %} + {% endif %} +{% endblock %} + +{% block content %} +
    +
    +

    + {% if famille %} + Famille {{ famille.nom }} - {{ famille.adresse }} + {% else %} + Mes prestations générales + {% endif %}

    +
    +
    + +
    + {% csrf_token %} +
    +
    {{ form.date_prestation|as_field_group }}
    +
    {{ form.duree|as_field_group }}
    +
    +
    +
    {{ form.texte|as_field_group }}
    +
    +
    +
    {{ form.manque }} {{ form.manque.label_tag|strip_colon }}
    +
    +
    +
    {{ form.fichier|as_field_group }}
    +
    +
    +
    {{ form.intervenants|as_field_group }}
    +
    + {% include "actions.html" %} +
    +{% endblock %} diff --git a/templates/aemo/prestation_generale.html b/templates/aemo/prestation_generale.html new file mode 100644 index 0000000..073c6c1 --- /dev/null +++ b/templates/aemo/prestation_generale.html @@ -0,0 +1,67 @@ +{% extends "base.html" %} + +{% block content %} + +
    +
    +

    + {% if prev_month %} + + {% endif %} + {{ current_date.month|month_name }} {{ current_date.year }} + {% if next_month %} + + {% endif %} +

    +
    + +
    +
    +
    + + + + + + + + + + + + + {% for prest in prestations %} + + + + + + + + + {% endfor %} + +
    DateDuréeParticipantsTexteAuteurAction
    {{ prest.date_prestation }}{{ prest.duree|format_duree }}{{ prest|sigles_intervenants }}{{ prest.texte|striptags }}{{ prest.auteur|sigle_personne }} + {% if prest|can_edit:user %} + + + + {% endif %} +
    +
    +
    +{% endblock %} diff --git a/templates/aemo/prestation_list.html b/templates/aemo/prestation_list.html new file mode 100644 index 0000000..82a545d --- /dev/null +++ b/templates/aemo/prestation_list.html @@ -0,0 +1,74 @@ +{% extends "base.html" %} + +{% block boutons_droite %} + {% if famille %} + {% include './famille_boutons.html' with active='prestation' %} + {% endif %} +{% endblock %} + +{% block content %} +
    + {% if famille %} +
    Famille {{ famille.nom }} - {{ famille.adresse }}
    +
    + + Ajouter + + {% if filter_form %}
    {{ filter_form.as_div }}
    {% endif %} +
    + {% else %} +
    Mes prestations générales
    + + {% endif %} +
    +
    +
    + + + + + + + + + + + + + + {% for prestation in prestations %} + + + + + + + + + + {% empty %} + + {% endfor %} + +
    DateDuréeInterv.ContenuAuteurPrest.Actions
    {{ prestation.date_prestation|date:"d.m.Y" }}{{ prestation.duree|strip_seconds }}{{ prestation|sigles_intervenants }}{{ prestation.texte|truncate_html_with_more:40 }} {{ prestation.fichier|as_icon }}{{ prestation.auteur|sigle_personne }}{{ prestation.lib_prestation.nom }} + {% with fam_pk=famille.pk|default:0 %} + {% if prestation|can_edit:user %} + + + + {% endif %} + {% endwith %} +
    + {% if filter_form.cleaned_data.recherche or filter_form.cleaned_data.famille %} + Pas de résultat pour votre recherche. + {% else %} + Aucune prestation saisie + {% endif %} +
    +
    +
    +{% endblock %} diff --git a/templates/aemo/prestation_menu.html b/templates/aemo/prestation_menu.html new file mode 100644 index 0000000..ada9c59 --- /dev/null +++ b/templates/aemo/prestation_menu.html @@ -0,0 +1,59 @@ +{% extends "base.html" %} + +{% block content %} + +
    +
    + + + + + + + + + + + + + {% for famille in familles %} + + + +
    {{ famille.rdv_manques|length }}
    + + {{ famille.aemo1|format_duree }} + {{ famille.aemo2|format_duree }} + {% with temps_total=famille.temps_total_prestations %} +
    + {% endwith %} + + + {% endfor %} + +
    FamillesRDV manquésEval.Accomp.TotalMes prestations
    + {{ famille.nom }} + - {{ famille.adresse }} + + {{ temps_total|format_duree }} + + {{ famille.user_prest|format_duree }} +
    +

    + Les prestations générales sont réparties chaque mois entre toutes les familles en fonction des familles actives durant le mois donné. Elles ne figurent pas sur cette page. +

    +
    +
    +{% endblock %} diff --git a/templates/aemo/prestation_personnelle.html b/templates/aemo/prestation_personnelle.html new file mode 100644 index 0000000..51ed1ed --- /dev/null +++ b/templates/aemo/prestation_personnelle.html @@ -0,0 +1,92 @@ +{% extends "base.html" %} +{% load static my_tags %} + +{% block content %} + +
    +
    +

    + {% if prev_month %} + + {% endif %} + {{ current_date.month|month_name }} {{ current_date.year }} + {% if next_month %} + + {% endif %} +

    +
    +
    +
    +
    + + + + + + + + + + + + + + + {% for prest in object_list %} + + + + + + + + + + + {% empty %} + + {% endfor %} + +
    DateDuréeFamilleIntervenant-e-sContenuAuteurPrestationActions
    {{ prest.date_prestation|date:"d.m.Y" }}{{ prest.duree|strip_seconds }}{{ prest.famille.nom }}{{ prest|sigles_intervenants }}{{ prest.texte|truncate_html_with_more:20 }}{{ prest.auteur|sigle_personne }}{{ prest.lib_prestation.nom }} + {% if prest|can_edit:user %} + + + + {% endif %} +
    Aucune prestation saisie
    +
    +
    +
    +
    +
    +
    + RÉCAPITULATIF pour {{ current_date.month|month_name }} {{ current_date.year }} +
    +
    + + {% for code, total in totaux %} + + + + + {% endfor %} + + + + +
    Total prestations {{ code }}{{ total|strip_seconds|default:'00:00' }}
    Total{{ total_final|strip_seconds|default:'00:00' }}
    +
    +
    +
    +
    +{% endblock %} diff --git a/templates/aemo/rapport.html b/templates/aemo/rapport.html new file mode 100644 index 0000000..0c4a2d8 --- /dev/null +++ b/templates/aemo/rapport.html @@ -0,0 +1,48 @@ +{% extends "base.html" %} + +{% block boutons_droite %} + {% include 'aemo/famille_boutons.html' with active='agenda' %} +{% endblock %} + +{% block content %} +
    +
    Famille {{ famille.nom }} - {{ famille.adresse }}
    +
    Dossier de suivi
    +
    + +
    + + + +
    +

    Résumé du {{ rapport.date }}

    + +

    Enfants:
    {{ enfants|linebreaksbr }}

    +

    Intervenant-e-s: {{ intervenants }}

    +

    Début du suivi: {{ rapport.famille.suivi.date_debut_suivi }}

    + + {% for label, value in data %} +

    {{ label }}

    + {{ value|default:'-'|raw_or_html }} + {% endfor %} + +

    + Le présent résumé comporte des éléments couverts par le secret professionnel au sens de la LPSy et du Code pénal. Seuls les propriétaires des données, à savoir les membres de la famille faisant l’objet du résumé, peuvent ensemble lever ce secret ou accepter la divulgation des données. Si cette autorisation n’est pas donnée, l’autorité compétente en matière de levée du secret professionnel doit impérativement être saisie. +

    + +
    + Retour + {% if rapport|can_edit:user %} + Modifier + {% endif %} + {% if rapport|can_delete:user %} +
    {% csrf_token %} + +
    + {% endif %} +
    +{% endblock %} diff --git a/templates/aemo/rapport_edit.html b/templates/aemo/rapport_edit.html new file mode 100644 index 0000000..e880e18 --- /dev/null +++ b/templates/aemo/rapport_edit.html @@ -0,0 +1,25 @@ +{% extends "base.html" %} +{% load my_tags %} + +{% block content %} +
    +

    Édition de résumé pour la famille {{ form.instance.famille }}

    +
    +
    +
    +
    + {% csrf_token %} + {{ form.non_field_errors }} + {% for field in form %} +
    + {{ field.label_tag }} {{ field.errors }} + {% if field.field.disabled %}{% get_field_value form.instance field.name|safe %} + {% else %}{{ field }}{% endif %} +
    + {% endfor %} + + {% include 'actions.html' %} +
    +
    +
    +{% endblock %} diff --git a/templates/aemo/role_edit.html b/templates/aemo/role_edit.html new file mode 100644 index 0000000..0fb1eff --- /dev/null +++ b/templates/aemo/role_edit.html @@ -0,0 +1,22 @@ +{% extends "base.html" %} + +{% block content %} +
    +

    Role

    +
    +
    +
    +
    + {% csrf_token %} + + + {% csrf_token %} + + {{ form.as_table }} +
    + {% include "actions.html" %} +
    + +
    +
    +{% endblock %} diff --git a/templates/aemo/role_list.html b/templates/aemo/role_list.html new file mode 100644 index 0000000..77ada97 --- /dev/null +++ b/templates/aemo/role_list.html @@ -0,0 +1,40 @@ +{% extends "base.html" %} + +{% block title %}Rôles{% endblock %} + +{% block content %} +
    +

    Rôles

    + {% if perms.aemo.add_role %} +
    + Nouveau +
    + {% endif %} +
    +
    +
    + + + + + + + + + + + {% with can_edit=perms.aemo.change_role %} + {% for role in object_list %} + + + + + + + {% endfor %} + {% endwith %} + +
    NomRôle familialRôle intervenantÉditeur ? {% help_tooltip editeur_help %}
    {% if can_edit and role.editable %}{% endif %}{{ role.nom }}{% if can_edit and role.editable %}{% endif %}{{ role.est_famille|boolean_icon }}{{ role.est_intervenant|boolean_icon }}{{ role.est_editeur|boolean_icon }}
    +
    +
    +{% endblock %} diff --git a/templates/aemo/service_edit.html b/templates/aemo/service_edit.html new file mode 100644 index 0000000..c5b96cc --- /dev/null +++ b/templates/aemo/service_edit.html @@ -0,0 +1,19 @@ +{% extends "base.html" %} + +{% block content %} +
    +

    Services partenaires

    +
    {{ view.action }}
    +
    + +
    +
    +
    {% csrf_token %} + + {{ form.as_table }} +
    + {% include "actions.html" %} +
    +
    +
    +{% endblock %} diff --git a/templates/aemo/service_list.html b/templates/aemo/service_list.html new file mode 100644 index 0000000..dba0424 --- /dev/null +++ b/templates/aemo/service_list.html @@ -0,0 +1,36 @@ +{% extends "base.html" %} + +{% block title %}Services{% endblock %} + +{% block content %} + +
    +

    Services partenaires

    + {% if perms.aemo.add_service %} + + {% endif %} +
    + +
    +
    + + + + + + + + {% with can_edit=perms.aemo.change_service %} + {% for service in object_list %} + + + + + {% endfor %} + {% endwith %} + +
    SigleNom
    {% if can_edit %}{% endif %}{{ service.sigle }}{% if can_edit %}{% endif %}{{ service.nom_complet }}
    +
    +
    +{% endblock %} + diff --git a/templates/aemo/suivi_edit.html b/templates/aemo/suivi_edit.html new file mode 100644 index 0000000..e5706fe --- /dev/null +++ b/templates/aemo/suivi_edit.html @@ -0,0 +1,175 @@ +{% extends "base.html" %} + +{% block boutons_droite %} + {% include './famille_boutons.html' with active='suivi' %} +{% endblock %} + +{% block content %} +
    +
    Famille {{ famille.nom }} - {{ famille.adresse }}
    +
    Dossier de suivi
    +
    + +
    {% csrf_token %} + +
    +
    + + + + + +
    Enfant(s) suivi(s) + + {% for enfant in famille.membres_suivis %} + + + + + + {% endfor %} +
    {{ enfant.nom_prenom }}{{ enfant.age|floatformat }} ans{{ enfant.formation.info_scol }}
    +
    +
    +
    +
    +
    {{ form.equipe }}
    +
    {{ form.service_annonceur }}
    +
    {{ form.service_orienteur }}
    +
    + +
    +
    + + {% if can_edit %} + + + + + {% endif %} + {% for interv in intervenants %} + {% if not interv.date_fin %} +
    {{ interv.role }}: {{ interv.intervenant.nom_prenom }} + {% if can_edit %} + + + + + + {% endif %} +
    + {% else %} +
    {{ interv.role }}: {{ interv.intervenant.nom_prenom }}
    + {% endif %} + {% endfor %} +
    +
    +
    +
    + {{ form.ope_referent }} +
    +
    + {{ form.ope_referent_2 }} +
    +
    + {{ form.mandat_ope }} +
    + +
    +
    +
    +
    +
    + + {% if can_edit %} + + + + + {% endif %} + + + + + {% for niv in niveaux %} + + + + + + + {% endfor %} +
    DuAuNiveau d'interv.
    {{ niv.date_debut|date:"d.m.Y" }}{{ niv.date_fin_calc|date:"d.m.Y"|default:"---" }}{{ niv.niveau_interv }} + + +
    + +
    +
    +
    + {{ form.demande_prioritaire }} +
    +
    +
    + {{ form.heure_coord }} {{ form.heure_coord.label_tag|strip_colon }} +
    + +
    +
    +
    Motif standardisé:
    +
    {{ form.motif_demande.errors }}{{ form.motif_demande }}
    +
    +
    +
    Motif de la demande:
    +
    {{ form.motif_detail.errors }}{{ form.motif_detail }}
    +
    +
    +
    Collaborations:
    +
    {{ form.collaboration.errors }}{{ form.collaboration }}
    +
    +
    +
    Ressources:
    +
    {{ form.ressource.errors }}{{ form.ressource }}
    +
    +
    +
    Gestion de crise:
    +
    {{ form.crise.errors }}{{ form.crise }}
    +
    +
    +
    Remarque:
    +
    {{ form.remarque.errors }}{{ form.remarque }}
    +
    + + {% include 'partial_table_documents.html' %} + + + + {% if can_edit %} +
    +
    + {% include "actions.html" %} +
    +
    + {% endif %} +
    +{% endblock %} diff --git a/templates/aemo/suivis_termines_list.html b/templates/aemo/suivis_termines_list.html new file mode 100644 index 0000000..8b58625 --- /dev/null +++ b/templates/aemo/suivis_termines_list.html @@ -0,0 +1,130 @@ +{% extends "base.html" %} + +{% block extra_javascript %} + +{% endblock %} + +{% block boutons_droite %} +
    +
    + {% if request.user|has_group:'direction' %} + Exporter + + {% endif %} +
    +
    + {{ form.nom }} + + +
    +
    +
    +
    +{% endblock %} + +{% block content %} +
    +
    Suivis terminés
    + {% if perms.aemo.can_archive %} +
    + +
    + {% endif %} +
    + +
    +
    + + + + + + + + + + + + + + + + {% for famille in object_list %} + + + + + + + + + + + + + + {% empty %} + + {% endfor %} + +
    NomAdresseRéf. AEMORéf. OPEDébut du suiviFin du suiviTotal heuresMotif de finArchiver {% help_tooltip "Le bouton d’archivage n’apparaît que si vous avez la permission d’archivage, que le suivi est terminé depuis plus de 180 jours et que le suivi s’est terminé l’année précédente." %} +
    + + {{ famille.npa}} {{ famille.localite }}{{ famille.suivi|sigles_referents }}{{ famille.suivi.ope_referent.nom_prenom|default_if_none:'' }} + {{ famille.suivi.mandat_ope|join:', '|in_parens }} + {{ famille.suivi.date_debut_suivi|date:'d.m.Y' }}{{ famille.suivi.date_fin_suivi|date:'d.m.Y' }}{{ famille.temps_total_prestations|format_duree }}{{ famille.suivi.get_motif_fin_suivi_display }} + {% if famille|archivable:user %} +
    {% csrf_token %} + +
    + {% endif %} +
    Cette liste est actuellement vide.
    +
    +
    + + + +{% endblock %} + diff --git a/templates/aemo/utilisateur_edit.html b/templates/aemo/utilisateur_edit.html new file mode 100644 index 0000000..33ee72d --- /dev/null +++ b/templates/aemo/utilisateur_edit.html @@ -0,0 +1,48 @@ +{% extends "base.html" %} + +{% block extrastyle %} + +{% endblock %} + +{% block content %} +
    +

    Utilisateur

    + {% if form.instance.pk %} +
    +
    {% csrf_token %} + +
    +
    {% csrf_token %} + +
    + Journal d’accès +
    + {% endif %} +
    +
    +
    +
    + {% csrf_token %} + + {{ form.as_table }} +
    +
    + {% if form.instance.pk %} +
    + {% if view.delete_url %} + + {% endif %} +
    + {% endif %} +
    + Annuler + +
    +
    +
    +
    +
    +{% endblock %} diff --git a/templates/aemo/utilisateur_journal.html b/templates/aemo/utilisateur_journal.html new file mode 100644 index 0000000..b07575c --- /dev/null +++ b/templates/aemo/utilisateur_journal.html @@ -0,0 +1,24 @@ +{% extends "base.html" %} + +{% block content %} +
    +

    Accès aux familles par {{ utilisateur }}

    +
    + + + + + + + + + + {% for line in object_list %} + + + + + {% endfor %} + +
    Date/heureFamilleAccès ordinaire
    {{ line.quand|date:'d.m.Y H:i' }}{{ line.famille }}{{ line.ordinaire|boolean_icon }}
    +{% endblock %} diff --git a/templates/aemo/utilisateur_list.html b/templates/aemo/utilisateur_list.html new file mode 100644 index 0000000..71a0aa7 --- /dev/null +++ b/templates/aemo/utilisateur_list.html @@ -0,0 +1,65 @@ +{% extends "base.html" %} + +{% block content %} +
    +

    Utilisateurs {% if active_users %}activés{% else %}désactivés{% endif %}

    + {% if perms.aemo.add_utilisateur %} +
    + {% if active_users %} + Charge dossiers + Utilisateurs désactivés + {% else %} + Utilisateurs actifs + {% endif %} + Nouveau +
    + {% endif %} +
    +
    +
    + + + + + + + + + + + + {% if not active_users %}{% endif %} + + + + {% with can_edit=perms.aemo.change_utilisateur %} + {% for util in object_list %} + + + + + + + + + + {% if not active_users %} + + {% endif %} + + {% endfor %} + {% endwith %} + +
    Nom, prénomSigleCourrielTaux act.TitreÉquipeRôlesGroupesAction
    {% if can_edit %}{% endif %} + {{ util.nom_prenom }}{% if can_edit %}{% endif %}{{ util.sigle }}{{ util.email }} + {{ util.taux_activite }}% + {% if util.decharge %}
    D. {{ util.decharge }}h{% endif %} +
    {{ util.profession }}{{ util.get_equipe_display }}{{ util.roles_str }}{{ util.groupes|join:", " }} +
    + {% csrf_token %} + +
    +
    +
    +
    +{% endblock %} diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..dccf529 --- /dev/null +++ b/templates/base.html @@ -0,0 +1,119 @@ + + + + AEMO Fribourg {% block title %}{% endblock %} + + + {% include 'bootstrap_headers.html' %} + + + + + + + {% block extrastyle %}{% endblock %} + + + + + + + + + + {% block extra_javascript %}{% endblock %} + {{ form.media }} + + +
    + {% block header %} +
    +
    + + +
    +
    + {% include 'user_bar.html' %} + {% endblock %} + {% block top-navigation %} + + {% endblock %} + + {% block messages %} + {% if messages %} +
    +
    +
      + {% for message in messages %} +
    • {{ message|linebreaksbr|capfirst }}
    • + {% endfor %} +
    +
    +
    + {% endif %} + {% endblock messages %} + + {% block above-content %}{% endblock %} +
    +
    +
    + {% block content %}{% endblock %} + {% block pagination %} + {% include 'pagination.html' %} + {% endblock %} +
    +
    +
    + +
    + +
    +
    + + + + + + + + diff --git a/templates/bootstrap_headers.html b/templates/bootstrap_headers.html new file mode 100644 index 0000000..99fafd8 --- /dev/null +++ b/templates/bootstrap_headers.html @@ -0,0 +1,7 @@ + + + + + + + diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..6b9ae47 --- /dev/null +++ b/templates/index.html @@ -0,0 +1,31 @@ + + + + AEMO FribourgE + + + {% include 'bootstrap_headers.html' %} + + + +
    +
    + {% include 'user_bar.html' %} +
    +
    + AEMO +
    + +
    +
    + +
    + +
    +
    + + diff --git a/templates/login.html b/templates/login.html new file mode 100644 index 0000000..9ad462b --- /dev/null +++ b/templates/login.html @@ -0,0 +1,32 @@ +{% extends "base.html" %} + +{% block content %} +
    +
    + {% csrf_token %} + + {% if form.non_field_errors %} + {% for error in form.non_field_errors %} +

    {{ error }}

    + {% endfor %} + {% endif %} +
    + {{ form.username.label_tag }} +
    + {{ form.username.errors }}{{ form.username }} +
    +
    +
    + {{ form.password.label_tag }} +
    + {{ form.password.errors }}{{ form.password }} +
    +
    +
    +
    + +
    +
    +
    +
    +{% endblock %} diff --git a/templates/pagination.html b/templates/pagination.html new file mode 100644 index 0000000..a7a3fd8 --- /dev/null +++ b/templates/pagination.html @@ -0,0 +1,13 @@ +{% if is_paginated %} + +{% endif %} diff --git a/templates/partial_agenda_cloture.html b/templates/partial_agenda_cloture.html new file mode 100644 index 0000000..89516ea --- /dev/null +++ b/templates/partial_agenda_cloture.html @@ -0,0 +1,6 @@ +
    Fin de l’accompagnement le
    +

    {{ form.date_fin_suivi.errors }}{{ form.date_fin_suivi }}

    +
    Motif de fin
    +

    {{ form.motif_fin_suivi.errors }}{{ form.motif_fin_suivi }}

    +
    Destination
    +

    {{ form.destination.errors }}{{ form.destination }}

    \ No newline at end of file diff --git a/templates/partial_show_docs.html b/templates/partial_show_docs.html new file mode 100644 index 0000000..710d492 --- /dev/null +++ b/templates/partial_show_docs.html @@ -0,0 +1,8 @@ +{% for doc in docs %} + +{% endfor %} diff --git a/templates/partial_table_documents.html b/templates/partial_table_documents.html new file mode 100644 index 0000000..8954776 --- /dev/null +++ b/templates/partial_table_documents.html @@ -0,0 +1,31 @@ + + + + + +
    Documents + {% if famille|can_edit:user %} + + + + + {% endif %} + +
    + {% for doc in famille.documents.all %} + {{ doc.titre }} + {% if famille|can_edit:user %} + + + + {% endif %} + {% endfor %} +
    +
    diff --git a/templates/registration/password_change_form.html b/templates/registration/password_change_form.html new file mode 100644 index 0000000..c5543fd --- /dev/null +++ b/templates/registration/password_change_form.html @@ -0,0 +1,27 @@ +{% extends "base.html" %} + +{% block extrastyle %} + +{% endblock %} + +{% block content %} +

    Modification du mot de passe

    + +

    Pour des raisons de sécurité, saisissez votre ancien mot de passe puis votre nouveau mot de passe à deux reprises afin de vérifier qu'il est correctement saisi.

    + +
    + {% csrf_token %} + {% for field in form %} + + {{ field }} + {{ field.help_text }} + {% endfor %} +
    + +
    +
    +{% endblock %} diff --git a/templates/registration/password_reset_complete.html b/templates/registration/password_reset_complete.html new file mode 100644 index 0000000..45f8b24 --- /dev/null +++ b/templates/registration/password_reset_complete.html @@ -0,0 +1,10 @@ +{% extends "base.html" %} +{% load i18n %} + +{% block content %} + +

    {% translate "Your password has been set. You may go ahead and log in now." %}

    + +

    {% translate 'Log in' %}

    + +{% endblock %} diff --git a/templates/registration/password_reset_confirm.html b/templates/registration/password_reset_confirm.html new file mode 100644 index 0000000..9bea5d6 --- /dev/null +++ b/templates/registration/password_reset_confirm.html @@ -0,0 +1,35 @@ +{% extends "base.html" %} +{% load i18n static %} + +{% block extrastyle %}{{ block.super }}{% endblock %} + +{% block content %} + +{% if validlink %} + +

    {% translate "Please enter your new password twice so we can verify you typed it in correctly." %}

    + +
    {% csrf_token %} +
    + +
    + {{ form.new_password1.errors }} + + {{ form.new_password1 }} +
    +
    + {{ form.new_password2.errors }} + + {{ form.new_password2 }} +
    + +
    +
    + +{% else %} + +

    {% translate "The password reset link was invalid, possibly because it has already been used. Please request a new password reset." %}

    + +{% endif %} + +{% endblock %} diff --git a/templates/registration/password_reset_done.html b/templates/registration/password_reset_done.html new file mode 100644 index 0000000..be4f202 --- /dev/null +++ b/templates/registration/password_reset_done.html @@ -0,0 +1,12 @@ +{% extends "base.html" %} +{% load i18n %} + +{% block content %} + +

    {% translate 'Password reset sent' %}

    + +

    {% translate 'We’ve emailed you instructions for setting your password, if an account exists with the email you entered. You should receive them shortly.' %}

    + +

    {% translate 'If you don’t receive an email, please make sure you’ve entered the address you registered with, and check your spam folder.' %}

    + +{% endblock %} diff --git a/templates/registration/password_reset_form.html b/templates/registration/password_reset_form.html new file mode 100644 index 0000000..cf8ccca --- /dev/null +++ b/templates/registration/password_reset_form.html @@ -0,0 +1,14 @@ +{% extends 'base.html' %} + +{% block title %}Mot de passe oublié ?{% endblock %} + +{% block content %} +

    Mot de passe oublié ?

    +

    Saisissez votre adresse électronique ci-dessous et nous vous enverrons les instructions pour en créer un nouveau.

    + +
    + {% csrf_token %} + {{ form.as_p }} + +
    +{% endblock %} diff --git a/templates/statistiques/stat-form.html b/templates/statistiques/stat-form.html new file mode 100644 index 0000000..b559617 --- /dev/null +++ b/templates/statistiques/stat-form.html @@ -0,0 +1,11 @@ +
    +
    +
    + {{ date_form.non_field_errors }} +

    + Statistiques du 1er {{ date_form.start_month }} {{ date_form.start_year }} à fin {{ date_form.end_month }} {{ date_form.end_year }} + +

    +
    +
    +
    \ No newline at end of file diff --git a/templates/statistiques/stat-tabs.html b/templates/statistiques/stat-tabs.html new file mode 100644 index 0000000..958ba58 --- /dev/null +++ b/templates/statistiques/stat-tabs.html @@ -0,0 +1,41 @@ + diff --git a/templates/statistiques/statistiques.html b/templates/statistiques/statistiques.html new file mode 100644 index 0000000..1aa3b74 --- /dev/null +++ b/templates/statistiques/statistiques.html @@ -0,0 +1,98 @@ +{% extends "base.html" %} + +{% block title %}Statistiques{% endblock %} + +{% block content %} + {% include "statistiques/stat-form.html" %} + {% include "statistiques/stat-tabs.html" with active="general" %} + +
    +
    + + + {% for month in months %}{% endfor %} + + + + + + + + {% for month in months %}{% endfor %} + + + + + {% for month in months %}{% endfor %} + + + + + {% for month in months %}{% endfor %} + + + + + {% for month in months %}{% endfor %} + + + + + {% for month in months %}{% endfor %} + + + + + {% for month in months %}{% endfor %} + + + + + {% for month in months %}{% endfor %} + + + + + {% for month in months %}{% endfor %} + + + + + {% for month in months %}{% endfor %} + + + + + {% for month in months %}{% endfor %} + + + + + {% for month in months %}{% endfor %} + + + + + {% for month in months %}{% endfor %} + + + + + {% for month in months %}{% endfor %} + + + + + + + +
    {{ month }}Total
    +

    Familles

    + {{ familles.total_familles }} familles prise en compte sur la période choisie +
    Total familles (évaluation et/ou accomp.){{ familles.familles_total|get_item:month }}{{ familles.familles_total.total }}
    Total enfants suivis (évaluation et/ou accomp.){{ familles.enfants_total|get_item:month }}{{ familles.enfants_total.total }}
    Familles évaluées{{ familles.familles_evaluees|get_item:month }}{{ familles.familles_evaluees.total }}
    Enfants évalués{{ familles.enfants_evalues|get_item:month }}{{ familles.enfants_evalues.total }}
    Enfants non suivis de familles évaluées{{ familles.enfants_evalues_non_suivis|get_item:month }}{{ familles.enfants_evalues_non_suivis.total }}
    Familles évaluées sans aboutir à un suivi{{ familles.familles_eval_sans_suivi|get_item:month }}{{ familles.familles_eval_sans_suivi.total }}
    Familles suivies{{ familles.familles_suivies|get_item:month }}{{ familles.familles_suivies.total }}
    Enfants suivis{{ familles.enfants_suivis|get_item:month }}{{ familles.enfants_suivis.total }}
    Enfants non suivis de familles suivies{{ familles.enfants_suivis_non_suivis|get_item:month }}{{ familles.enfants_suivis_non_suivis.total }}
    dont Familles d’accueil{{ familles.familles_accueil|get_item:month }}{{ familles.familles_accueil.total }}
    dont Familles déjà suivies{{ familles.familles_connues|get_item:month }}{{ familles.familles_connues.total }}
    Demandes prioritaires{{ familles.prioritaires|get_item:month }}{{ familles.prioritaires.total }}
    Rendez-vous manqués{{ familles.rdv_manques|get_item:month }}{{ familles.rdv_manques.total }}
    Durée moyenne entre demande et début de suivi{{ familles.duree_attente.moyenne.days }} jours
    +
    + +
    +
    +
    +{% endblock %} diff --git a/templates/statistiques/stats-age.html b/templates/statistiques/stats-age.html new file mode 100644 index 0000000..f667e36 --- /dev/null +++ b/templates/statistiques/stats-age.html @@ -0,0 +1,43 @@ +{% extends "base.html" %} + +{% block content %} + {% include "statistiques/stat-form.html" %} + {% include "statistiques/stat-tabs.html" with active="age" %} + +
    +
    +

    + Répartition des enfants suivis (dès demande jusqu'à la fin d’accompagnement) par âge. L’âge est calculé à la date médiane entre la demande et la fin du suivi. +

    + + + + + {% for month in months %}{% endfor %} + + + + + + {% for age, stats in ages.items %} + + + {% for month in months %}{% endfor %} + + + {% endfor %} + + + {% for month in months %}{% endfor %} + + + +
    Âge{{ month }}Total
    +

    Âges

    +
    {{ age }}{{ stats|get_item:month }}{{ stats.total }}
    Âge moyen{{ means|get_item:month }}{{ means.total }}
    +
    + +
    +
    +
    +{% endblock %} diff --git a/templates/statistiques/stats-duree.html b/templates/statistiques/stats-duree.html new file mode 100644 index 0000000..80d55dd --- /dev/null +++ b/templates/statistiques/stats-duree.html @@ -0,0 +1,33 @@ +{% extends "base.html" %} + +{% block content %} + {% include "statistiques/stat-form.html" %} + {% include "statistiques/stat-tabs.html" with active="duree" %} + +
    +
    +

    + Répartition des suivis par durée d’accompagnement. Les suivis pris en compte sont ceux dont la date de fin de suivi se situe entre les bornes temporelles sélectionnées. +

    + + + + + + + + + {% for slice in slices %} + + + + + {% endfor %} + +
    DuréeNb de suivis
    {{ slice.label }}{{ durees|get_item:slice.label }}
    +
    + +
    +
    +
    +{% endblock %} diff --git a/templates/statistiques/stats-interv.html b/templates/statistiques/stats-interv.html new file mode 100644 index 0000000..6decab1 --- /dev/null +++ b/templates/statistiques/stats-interv.html @@ -0,0 +1,45 @@ +{% extends "base.html" %} + +{% block extrastyle %} + +{% endblock %} + +{% block content %} + {% include "statistiques/stat-form.html" %} + {% include "statistiques/stat-tabs.html" with active="interv" %} + +
    +
    + + + + {% for month in months %}{% endfor %} + + + + + + {% for interv, counters in intervs.items %} + + + {% for month in months %}{% endfor %} + + + + + {% for month in months %}{% endfor %} + + + {% endfor %} + +
    Intervenant-e{{ month }}Total
    +

    AEMO

    +
    {{ interv.nom_prenom }}Familles{{ counters.num_familles|get_item:month }}{{ counters.num_familles.total }}
    Enfants{{ counters.num_enfants|get_item:month }}{{ counters.num_enfants.total }}
    +
    + +
    +
    +
    +{% endblock %} diff --git a/templates/statistiques/stats-localite.html b/templates/statistiques/stats-localite.html new file mode 100644 index 0000000..2b14915 --- /dev/null +++ b/templates/statistiques/stats-localite.html @@ -0,0 +1,45 @@ +{% extends "base.html" %} + +{% block content %} + {% include "statistiques/stat-form.html" %} + {% include "statistiques/stat-tabs.html" with active="localite" %} + +
    +
    +

    + Nombre d’enfants suivis (dès demande jusqu'à la fin d’accompagnement) par localité. +

    + + + + + {% for month in months %}{% endfor %} + + + + + + {% for localite, stats in localites.items %} + {% if localite != 'totals' %} + + + {% for month in months %}{% endfor %} + + + {% endif %} + {% endfor %} + + + {% for month in months %}{% endfor %} + + + +
    Localité{{ month }}Total
    +

    Localités

    +
    {{ localite }}{{ stats|get_item:month }}{{ stats.total }}
    Totaux{{ localites.totals|get_item:month }}{{ localites.totals.totals }}
    +
    + +
    +
    +
    +{% endblock %} diff --git a/templates/statistiques/stats-motifs.html b/templates/statistiques/stats-motifs.html new file mode 100644 index 0000000..ca1459a --- /dev/null +++ b/templates/statistiques/stats-motifs.html @@ -0,0 +1,120 @@ +{% extends "base.html" %} + +{% block content %} + {% include "statistiques/stat-form.html" %} + {% include "statistiques/stat-tabs.html" with active="motifs" %} + +
    +
    + + + + {% for month in months %}{% endfor %} + + + + + {% for motif, stats in data.ann.items %} + + + {% for month in months %}{% endfor %} + + + {% endfor %} + + + + + {% for month in months %}{% endfor %} + + + + + {% for service, stats in data.orient.items %} + + + {% for month in months %}{% endfor %} + + + {% endfor %} + + + + + {% for month in months %}{% endfor %} + + + + + + {% for motif, stats in data.fin_preeval.items %} + + + {% for month in months %}{% endfor %} + + + {% endfor %} + + {% for motif, stats in data.fin_posteval.items %} + + + {% for month in months %}{% endfor %} + + + {% endfor %} + + {% for motif, stats in data.fin_postacc.items %} + + + {% for month in months %}{% endfor %} + + + {% endfor %} + + {% for motif, stats in data.fin_total.items %} + + + {% for month in months %}{% endfor %} + + + {% endfor %} + + + + + {% for month in months %}{% endfor %} + + + + + {% for prov, stats in data.prov.items %} + + + {% for month in months %}{% endfor %} + + + {% endfor %} + + + + + {% for month in months %}{% endfor %} + + + + + {% for dest, stats in data.dest.items %} + + + {% for month in months %}{% endfor %} + + + {% endfor %} + +
    Motif d’annonce{{ month }}Total
    {{ motif }}{{ stats|get_item:month }}{{ stats.total }}
    Service annonceur{{ month }}Total
    {{ service }}{{ stats|get_item:month }}{{ stats.total }}
    Motif de fin d’évaluation ou d’accompagnement{{ month }}Total
    Abandon avant évaluation
    {{ motif }}{{ stats|get_item:month }}{{ stats.total }}
    Abandon après évaluation
    {{ motif }}{{ stats|get_item:month }}{{ stats.total }}
    Fin de l'accompagnement
    {{ motif }}{{ stats|get_item:month }}{{ stats.total }}
    Total
    {{ motif }}{{ stats|get_item:month }}{{ stats.total }}
    Provenance{{ month }}Total
    {{ prov }}{{ stats|get_item:month }}{{ stats.total }}
    Destination{{ month }}Total
    {{ dest }}{{ stats|get_item:month }}{{ stats.total }}
    +
    + +
    +
    +
    +{% endblock %} diff --git a/templates/statistiques/stats-niveaux.html b/templates/statistiques/stats-niveaux.html new file mode 100644 index 0000000..e75fec1 --- /dev/null +++ b/templates/statistiques/stats-niveaux.html @@ -0,0 +1,37 @@ +{% extends "base.html" %} + +{% block content %} + {% include "statistiques/stat-form.html" %} + {% include "statistiques/stat-tabs.html" with active="niveau" %} + +
    +
    + + + + + {% for month in months %}{% endfor %} + + + + + {% for niveau, prest_list in stats.items %} + + + + {% for prest, prest_data in prest_list.items %} + + + {% for month in months %}{% endfor %} + + + {% endfor %} + {% endfor %} + +
    Ressources par niveau{{ month }}Total
    Niveau {{ niveau }}
    {{ prest_map|get_item:prest }}{{ prest_data|get_item:month|format_duree }}{{ prest_data|get_item:'total'|format_duree }}
    +
    + +
    +
    +
    +{% endblock %} diff --git a/templates/statistiques/stats-prestations.html b/templates/statistiques/stats-prestations.html new file mode 100644 index 0000000..d2a75fa --- /dev/null +++ b/templates/statistiques/stats-prestations.html @@ -0,0 +1,78 @@ +{% extends "base.html" %} + +{% block content %} +
    +
    +
    + {{ date_form.non_field_errors }} +

    + Statistiques de l'année {{ date_form.year }} + +

    +
    +
    +
    + {% include "statistiques/stat-tabs.html" with active="prestations" %} + +
    +
    + + + + + {% for month in months %} + + {% endfor %} + + + + + + + {% for user, detail in intervenants.items %} + + + {% for h in detail.heures_prestees %} + + {% endfor %} + + + {% endfor %} + + + + + + {% for month in months %} + + {% endfor %} + + + + + + {% for libelle in libelles_prest %} + + + {% for month, data in totaux_prest_mensuels.items %} + + {% endfor %} + + + {% endfor %} + + + {% for month, data in totaux_prest_mensuels.items %} + + {% endfor %} + + + +
    {{ month|date:"N" }}Total
    +

    Intervenant·e·s {{ unite|upper }}

    Heures prestées
    {{ user|nom_prenom_abreg }}{{ h|format_duree|default_if_zero }}{{ detail.tot_prestees|format_duree }}
    {{ month|date:"N" }}Total
    {{ libelle.nom }}{% if month.is_future %}-{% else %}{{ data|get_item:libelle.nom|format_duree }}{% endif %}{{ totaux_par_prest|get_item:libelle.nom|format_duree }}
    Totaux{{ data.total|format_duree }}{{ total_gen|format_duree }}
    +
    + +
    +
    +
    +{% endblock %} diff --git a/templates/statistiques/stats-region.html b/templates/statistiques/stats-region.html new file mode 100644 index 0000000..d93f794 --- /dev/null +++ b/templates/statistiques/stats-region.html @@ -0,0 +1,45 @@ +{% extends "base.html" %} + +{% block content %} + {% include "statistiques/stat-form.html" %} + {% include "statistiques/stat-tabs.html" with active="region" %} + +
    +
    +

    + Nombre d’enfants suivis (dès demande jusqu'à la fin d’accompagnement) par région. +

    + + + + + {% for month in months %}{% endfor %} + + + + + + {% for region, stats in regions.items %} + {% if region != 'totals' %} + + + {% for month in months %}{% endfor %} + + + {% endif %} + {% endfor %} + + + {% for month in months %}{% endfor %} + + + +
    Région{{ month }}Total
    +

    Régions

    +
    {{ region }}{{ stats|get_item:month }}{{ stats.total }}
    Totaux{{ regions.totals|get_item:month }}{{ regions.totals.total }}
    +
    + +
    +
    +
    +{% endblock %} diff --git a/templates/two_factor/_base.html b/templates/two_factor/_base.html new file mode 100644 index 0000000..94d9808 --- /dev/null +++ b/templates/two_factor/_base.html @@ -0,0 +1 @@ +{% extends "base.html" %} diff --git a/templates/two_factor/_wizard_forms.html b/templates/two_factor/_wizard_forms.html new file mode 100644 index 0000000..d4f3715 --- /dev/null +++ b/templates/two_factor/_wizard_forms.html @@ -0,0 +1,13 @@ +{{ wizard.management_form }} + + {% for field in form.visible_fields %} + + + + {% endfor %} +
    {{ field.label_tag }}{{ field.errors }}{{ field }} + {% if field.name == 'password' %} Mot de passe oublié ?{% endif %} +
    +{% for hidden in form.hidden_fields %} +{{ hidden }} +{% endfor %} diff --git a/templates/two_factor/profile/profile.html b/templates/two_factor/profile/profile.html new file mode 100644 index 0000000..03b42c2 --- /dev/null +++ b/templates/two_factor/profile/profile.html @@ -0,0 +1,41 @@ +{% extends "two_factor/_base.html" %} +{% load i18n %} + +{% block content %} +

    {% block title %}{% trans "Account Security" %}{% endblock %}

    + + {% if default_device %} + + Modifier mon mot de passe + +

    {% trans "Tokens will be generated by your token generator." %}

    + +

    {% trans "Backup Tokens" %}

    +

    + {% blocktrans trimmed %}If you don't have any device with you, you can access + your account using backup tokens.{% endblocktrans %} + {% blocktrans trimmed count counter=backup_tokens %} + You have only one backup token remaining. + {% plural %} + You have {{ counter }} backup tokens remaining. + {% endblocktrans %} +

    +

    {% trans "Show Codes" %}

    + +

    Ajouter un appareil supplémentaire pour l’authentification à deux facteurs

    +

    Ajouter un autre appareil

    + +

    {% trans "Disable Two-Factor Authentication" %}

    +

    Si vous désactivez l’authentification à deux facteurs, vous devrez ensuite la réactiver pour pouvoir vous connecter. Cela peut être utile si vous souhaitez changer d’appareil.

    +

    + {% trans "Disable Two-Factor Authentication" %}

    + {% else %} +

    {% blocktrans trimmed %}Two-factor authentication is not enabled for your + account. Enable two-factor authentication for enhanced account + security.{% endblocktrans %}

    +

    + {% trans "Enable Two-Factor Authentication" %} +

    + {% endif %} +{% endblock %} diff --git a/templates/user_bar.html b/templates/user_bar.html new file mode 100644 index 0000000..d3ce4b7 --- /dev/null +++ b/templates/user_bar.html @@ -0,0 +1,13 @@ +
    +
    + {% if user.is_authenticated %} +
    + +
    {% csrf_token %} + +
    +
    + {% endif %} +
    +
    +