Initial commit
13
.gitignore
vendored
Normal file
|
@ -0,0 +1,13 @@
|
|||
*.pyc
|
||||
*.sqlite3
|
||||
*.dump
|
||||
settings/__init__.py
|
||||
/media/
|
||||
/fixtures/
|
||||
scripts/*
|
||||
/static/
|
||||
.idea/*
|
||||
.git/*
|
||||
info/*
|
||||
stock
|
||||
data/*
|
12
INSTALL
Normal file
|
@ -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.
|
661
LICENSE
Normal file
|
@ -0,0 +1,661 @@
|
|||
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||
Version 3, 19 November 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
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.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
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
|
||||
<https://www.gnu.org/licenses/>.
|
51
README.md
Normal file
|
@ -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 <HOST> 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 <HOST> download-remote-data import-db-in-dev create-admin-in-dev`
|
0
aemo/__init__.py
Normal file
197
aemo/admin.py
Normal file
|
@ -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)
|
212
aemo/export.py
Normal file
|
@ -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)
|
131
aemo/file_array_field.py
Normal file
|
@ -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)
|
969
aemo/forms.py
Normal file
|
@ -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 ('<p></p>', '<p><br></p>', '<p> </p>'):
|
||||
while texte.startswith(snip):
|
||||
texte = texte[len(snip):].strip()
|
||||
for snip in ('<p></p>', '<p><br></p>', '<p> </p>'):
|
||||
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
|
126
aemo/management/commands/anonymize.py
Normal file
|
@ -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'])
|
10
aemo/management/commands/test.py
Normal file
|
@ -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)
|
382
aemo/migrations/0001_initial.py
Normal file
|
@ -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')},
|
||||
),
|
||||
]
|
21
aemo/migrations/0002_unaccent_extension.py
Normal file
|
@ -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;"
|
||||
),
|
||||
]
|
0
aemo/migrations/__init__.py
Normal file
1446
aemo/models.py
Normal file
636
aemo/pdf.py
Normal file
|
@ -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('</p>', '</p><br/>')
|
||||
if '<ul>' in text:
|
||||
text = text.replace('<li>', ' • ').replace('</ul>','<br>').replace('</li>', '<br>')
|
||||
try:
|
||||
super().__init__(text, *args, **kwargs)
|
||||
except ValueError:
|
||||
text = nh3.clean(
|
||||
text, tags={'p', 'br', 'b', 'strong', 'u', 'i', 'em', 'ul', 'li'}
|
||||
).replace('<br>', '<br/>')
|
||||
super().__init__(text, *args, **kwargs)
|
||||
|
||||
|
||||
class RawParagraph(Paragraph):
|
||||
"""Raw text, replace new lines by <br/>."""
|
||||
def __init__(self, text='', *args, **kwargs):
|
||||
if text:
|
||||
text = text.replace('\r\n', '\n').replace('\n', '\n<br/>')
|
||||
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('<p>') and text.endswith('</p>'):
|
||||
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("<strong>Motif(s) de la demande:</strong> {}".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",
|
||||
"{}<br/>{}<br/><br/>{}".format(
|
||||
"<em>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)</em>",
|
||||
suivi.difficultes
|
||||
),
|
||||
html=True
|
||||
)
|
||||
self.write_paragraph(
|
||||
"Autres services",
|
||||
"<em>{}</em><br/>{}".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)", '<br/>'.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 <b>couverts par le secret professionnel au sens "
|
||||
"de la LPSy et du Code pénal</b>. Seuls les propriétaires des données, à savoir les membres "
|
||||
"de la famille faisant l’objet du résumé, peuvent <b>ensemble</b> 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)",
|
||||
'<br/>'.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)", '<br/>'.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)
|
1
aemo/static/css/autocomplete.min.css
vendored
Normal file
|
@ -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}
|
359
aemo/static/css/main.css
Normal file
|
@ -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;
|
||||
}
|
33
aemo/static/css/tablesort.css
Normal file
|
@ -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;
|
||||
}
|
BIN
aemo/static/docs/sifp_agenda_hebdo.doc
Normal file
BIN
aemo/static/favicon.png
Normal file
After Width: | Height: | Size: 592 B |
2
aemo/static/ficons/README
Normal file
|
@ -0,0 +1,2 @@
|
|||
Many thanks to Daniel M. Hendricks, http://daniel.hn
|
||||
https://github.com/dmhendricks/file-icon-vectors/
|
1
aemo/static/ficons/docx.svg
Normal file
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><style>.st1{fill:#2372ba}</style><path fill="#fff" d="M0 0h100v100H0z"/><path class="st1" d="M100 100H0V0h100v100zM9.7 90h80.7V10H9.7"/><path class="st1" d="M27.6 27l7.9 29.7L45.2 27h9.5l9.8 29.7L72.4 27H85L71.2 73H59l-9-26.7L41 73H28.8L15 27h12.6z"/></svg>
|
After Width: | Height: | Size: 319 B |
1
aemo/static/ficons/image.svg
Normal file
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><style>.st1{fill:#5b2d8d}</style><path fill="#fff" d="M0 0h100v100H0z"/><path class="st1" d="M100 100H0V0h100v100zM9.7 90h80.7V10H9.7"/><circle class="st1" cx="32.4" cy="35" r="8"/><path class="st1" d="M78.9 47.3l-9.7-9.6L50 57l-9.6-9.7-19.3 19.3V73h57.8z"/></svg>
|
After Width: | Height: | Size: 326 B |
1
aemo/static/ficons/master.svg
Normal file
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><path fill="#fff" d="M0 0h100v100H0z"/><path d="M100 100H0V0h100v100zM9.7 90h80.7V10H9.7" fill="#bababa"/><path d="M71 36.3L57.8 23.1c-.4-.4-.9-.6-1.4-.6h-26c-1.1 0-2 .9-2 2v51.1c0 1.1.9 2 2 2h39.3c1.1 0 2-.9 2-2V37.7c-.1-.5-.3-1-.7-1.4zm-3.9 2.3H55.5V27l11.6 11.6zm.1 34.5H32.8V26.9h18.5v13.3c0 1.4 1.2 2.6 2.6 2.6h13.3v30.3z" fill="#bababa" stroke="#bababa" stroke-miterlimit="10"/></svg>
|
After Width: | Height: | Size: 452 B |
1
aemo/static/ficons/pdf.svg
Normal file
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><path fill="#fff" d="M0 0h100v100H0z"/><path d="M100 100H0V0h100v100zM9.7 90h80.7V10H9.7" fill="#c11e07"/><path d="M46.2 21.8c-3.5 0-6.3 2.9-6.3 6.3 0 4.3 2.4 9.6 4.9 14.7-2 6.1-4.1 12.7-7 18.2-5.8 2.3-11 4-14 6.6l-.2.2c-1.1 1.2-1.8 2.7-1.8 4.4 0 3.5 2.9 6.3 6.3 6.3 1.7 0 3.4-.6 4.4-1.8 0 0 .2 0 .2-.2 2.3-2.7 5-7.8 7.5-12.2 5.5-2.1 11.5-4.4 16.9-5.8 4.1 3.4 10.1 5.5 15 5.5 3.5 0 6.3-2.9 6.3-6.3 0-3.5-2.9-6.3-6.3-6.3-4 0-9.6 1.4-13.9 2.9-3.5-3.4-6.7-7.5-9.2-11.9C50.6 37 52.6 32 52.6 28c-.2-3.5-2.9-6.2-6.4-6.2zm0 3.6c1.4 0 2.4 1.1 2.4 2.4 0 1.8-1.1 5.3-2.1 9-1.5-3.7-2.9-7.2-2.9-9 .1-1.2 1.2-2.4 2.6-2.4zm1.1 21.5c1.8 3.1 4.1 5.8 6.6 8.2-3.7 1.1-7.3 2.3-11 3.7 1.8-3.8 3.1-7.9 4.4-11.9zM72 55c1.4 0 2.4 1.1 2.4 2.4 0 1.4-1.1 2.4-2.4 2.4-2.9 0-6.9-1.2-10.1-3.1C65.6 56 69.7 55 72 55zM34.6 66.2c-1.8 3.2-3.5 6.1-4.7 7.6-.5.5-.9.6-1.7.6-1.4 0-2.4-1.1-2.4-2.4 0-.6.3-1.4.6-1.7 1.3-1.2 4.5-2.6 8.2-4.1z" fill="#c11e07" stroke="#c11e07" stroke-width="1.25" stroke-miterlimit="10"/></svg>
|
After Width: | Height: | Size: 1 KiB |
1
aemo/static/ficons/xlsx.svg
Normal file
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><style>.st1{fill:#30723f}</style><path fill="#fff" d="M0 0h100v100H0z"/><path class="st1" d="M100 100H0V0h100v100zM9.7 90h80.7V10H9.7"/><path class="st1" d="M41.8 48.5L23.9 27h18.5l7.8 11.9L59.8 27h18.5l-19 21.5L80.7 73H62.4L50.9 58 37.4 73H19.3l22.5-24.5z"/></svg>
|
After Width: | Height: | Size: 327 B |
BIN
aemo/static/img/SRK_DL_Portal.jpg
Normal file
After Width: | Height: | Size: 135 KiB |
BIN
aemo/static/img/bandeau_rouge.png
Normal file
After Width: | Height: | Size: 466 B |
BIN
aemo/static/img/bandeau_vert.png
Normal file
After Width: | Height: | Size: 255 B |
BIN
aemo/static/img/coordonnees.gif
Normal file
After Width: | Height: | Size: 3.5 KiB |
8
aemo/static/img/edit.svg
Normal file
|
@ -0,0 +1,8 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generator: Adobe Illustrator 22.1.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<svg version="1.1" id="Layer" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
viewBox="0 0 512 512" style="enable-background:new 0 0 512 512;" xml:space="preserve">
|
||||
<polygon points="128,448 32,448 32,352 "/>
|
||||
<rect x="45.7" y="140.1" transform="matrix(0.7071 -0.7071 0.7071 0.7071 -67.4113 253.2548)" width="452.5" height="135.8"/>
|
||||
<path d="M32,512v-32h352v32H32z"/>
|
||||
</svg>
|
After Width: | Height: | Size: 555 B |
BIN
aemo/static/img/eduqua.png
Normal file
After Width: | Height: | Size: 3.1 KiB |
BIN
aemo/static/img/family.png
Normal file
After Width: | Height: | Size: 23 KiB |
76
aemo/static/img/filter_off.svg
Normal file
|
@ -0,0 +1,76 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||
xmlns:cc="http://creativecommons.org/ns#"
|
||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
enable-background="new 0 0 24 24"
|
||||
height="24px"
|
||||
viewBox="0 0 24 24"
|
||||
width="24px"
|
||||
fill="#000000"
|
||||
version="1.1"
|
||||
id="svg14"
|
||||
sodipodi:docname="filter_off.svg"
|
||||
inkscape:version="1.0.2 (e86c870879, 2021-01-15)">
|
||||
<metadata
|
||||
id="metadata20">
|
||||
<rdf:RDF>
|
||||
<cc:Work
|
||||
rdf:about="">
|
||||
<dc:format>image/svg+xml</dc:format>
|
||||
<dc:type
|
||||
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
|
||||
</cc:Work>
|
||||
</rdf:RDF>
|
||||
</metadata>
|
||||
<defs
|
||||
id="defs18" />
|
||||
<sodipodi:namedview
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#666666"
|
||||
borderopacity="1"
|
||||
objecttolerance="10"
|
||||
gridtolerance="10"
|
||||
guidetolerance="10"
|
||||
inkscape:pageopacity="0"
|
||||
inkscape:pageshadow="2"
|
||||
inkscape:window-width="1296"
|
||||
inkscape:window-height="894"
|
||||
id="namedview16"
|
||||
showgrid="false"
|
||||
inkscape:zoom="30.625"
|
||||
inkscape:cx="12"
|
||||
inkscape:cy="12"
|
||||
inkscape:window-x="26"
|
||||
inkscape:window-y="23"
|
||||
inkscape:window-maximized="0"
|
||||
inkscape:current-layer="svg14" />
|
||||
<g
|
||||
id="g4">
|
||||
<rect
|
||||
fill="none"
|
||||
height="24"
|
||||
width="24"
|
||||
id="rect2" />
|
||||
</g>
|
||||
<g
|
||||
id="g12"
|
||||
style="fill:#666666">
|
||||
<g
|
||||
id="g10"
|
||||
style="fill:#666666">
|
||||
<path
|
||||
d="M19.79,5.61C20.3,4.95,19.83,4,19,4H6.83l7.97,7.97L19.79,5.61z"
|
||||
id="path6"
|
||||
style="fill:#666666" />
|
||||
<path
|
||||
d="M2.81,2.81L1.39,4.22L10,13v6c0,0.55,0.45,1,1,1h2c0.55,0,1-0.45,1-1v-2.17l5.78,5.78l1.41-1.41L2.81,2.81z"
|
||||
id="path8"
|
||||
style="fill:#666666" />
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 2 KiB |
BIN
aemo/static/img/formation.png
Normal file
After Width: | Height: | Size: 5.7 KiB |
BIN
aemo/static/img/help.png
Normal file
After Width: | Height: | Size: 543 B |
BIN
aemo/static/img/icon_add.jpeg
Normal file
After Width: | Height: | Size: 6.1 KiB |
BIN
aemo/static/img/journal.jpg
Normal file
After Width: | Height: | Size: 85 KiB |
BIN
aemo/static/img/logo-cr.png
Normal file
After Width: | Height: | Size: 41 KiB |
219
aemo/static/img/logo-cr.svg
Normal file
|
@ -0,0 +1,219 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||
xmlns:cc="http://creativecommons.org/ns#"
|
||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 1238.1333 262.01334"
|
||||
height="262.01334"
|
||||
width="1238.1333"
|
||||
xml:space="preserve"
|
||||
id="svg2"
|
||||
version="1.1"><metadata
|
||||
id="metadata8"><rdf:RDF><cc:Work
|
||||
rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type
|
||||
rdf:resource="http://purl.org/dc/dcmitype/StillImage" /><dc:title></dc:title></cc:Work></rdf:RDF></metadata><defs
|
||||
id="defs6" /><g
|
||||
transform="matrix(1.3333333,0,0,-1.3333333,0,262.01333)"
|
||||
id="g10"><g
|
||||
transform="scale(0.1)"
|
||||
id="g12"><path
|
||||
id="path14"
|
||||
style="fill:#d92838;fill-opacity:1;fill-rule:nonzero;stroke:none"
|
||||
d="m 9286.04,1310.05 h -655.02 v 655.02 H 7976 V 1310.05 H 7320.97 V 655.031 H 7976 V 0 h 655.02 v 655.031 h 655.02 v 655.019" /><path
|
||||
id="path16"
|
||||
style="fill:#231f20;fill-opacity:1;fill-rule:nonzero;stroke:none"
|
||||
d="m 282.129,1211.91 c -13.301,3.5 -25.902,5.6 -39.203,5.6 -91.707,0 -131.617,-83.3 -131.617,-164.51 0,-78.41 39.91,-175.02 130.914,-175.02 13.304,0 26.605,3.489 39.906,7.7 v -98.711 c -15.399,-6.309 -31.5,-9.098 -48.305,-9.098 C 83.3086,777.871 0,913.68 0,1052.29 c 0,130.92 86.8086,265.34 228.922,265.34 18.203,0 35.703,-3.51 53.207,-9.81 v -95.91" /><path
|
||||
id="path18"
|
||||
style="fill:#231f20;fill-opacity:1;fill-rule:nonzero;stroke:none"
|
||||
d="m 424.207,1137 v -42.7 h 1.398 c 18.2,30.81 42.004,52.5 80.508,56 v -95.91 c -4.902,0.71 -9.797,1.41 -15.398,1.41 -65.813,0 -66.508,-43.41 -66.508,-95.921 V 783.469 H 325.496 V 1137 h 98.711" /><path
|
||||
id="path20"
|
||||
style="fill:#231f20;fill-opacity:1;fill-rule:nonzero;stroke:none"
|
||||
d="m 627.203,959.879 c 0,-20.289 1.399,-95.199 35.703,-95.199 34.305,0 35.703,74.91 35.703,95.199 0,20.305 -1.398,95.221 -35.703,95.221 -34.304,0 -35.703,-74.916 -35.703,-95.221 z m 170.117,0 c 0,-90.297 -28.707,-183.418 -134.414,-183.418 -105.711,0 -134.414,93.121 -134.414,183.418 0,90.321 28.703,184.121 134.414,184.121 105.707,0 134.414,-93.8 134.414,-184.121" /><path
|
||||
id="path22"
|
||||
style="fill:#231f20;fill-opacity:1;fill-rule:nonzero;stroke:none"
|
||||
d="M 940.793,1137 V 783.469 H 842.082 V 1137 Z m -112.012,105.71 c 0,35.71 28.699,62.31 64.403,62.31 34.304,0 60.91,-30.1 60.91,-63.7 0,-33.61 -28.707,-62.31 -63.008,-62.31 -34.301,0 -62.305,28.7 -62.305,63.7" /><path
|
||||
id="path24"
|
||||
style="fill:#231f20;fill-opacity:1;fill-rule:nonzero;stroke:none"
|
||||
d="m 1098.99,1137 28.01,-53.91 10.5,-21.7 39.9,75.61 h 102.21 L 1189.3,973.887 1293.61,783.469 H 1189.3 l -51.8,97.312 -52.51,-97.312 H 983.484 L 1084.99,973.887 996.785,1137 h 102.205" /><path
|
||||
id="path26"
|
||||
style="fill:#231f20;fill-opacity:1;fill-rule:nonzero;stroke:none"
|
||||
d="m 1511.3,1041.39 v -93.101 h -175.02 v 93.101 h 175.02" /><path
|
||||
id="path28"
|
||||
style="fill:#231f20;fill-opacity:1;fill-rule:nonzero;stroke:none"
|
||||
d="m 1693.29,1084.5 18.2,-0.7 c 47.61,0 66.51,32.2 66.51,75.6 0,37.81 -18.2,68.62 -70.71,65.81 h -14 z M 1588.98,783.469 v 527.861 h 105.01 c 66.51,0 185.52,-7.01 185.52,-151.22 0,-58.11 -23.1,-107.82 -74.91,-136.52 l 91.72,-240.121 H 1786.4 l -91.71,243.621 h -1.4 V 783.469 h -104.31" /><path
|
||||
id="path30"
|
||||
style="fill:#231f20;fill-opacity:1;fill-rule:nonzero;stroke:none"
|
||||
d="m 2014.59,959.879 c 0,-20.289 1.4,-95.199 35.7,-95.199 34.31,0 35.71,74.91 35.71,95.199 0,20.305 -1.4,95.221 -35.71,95.221 -34.3,0 -35.7,-74.916 -35.7,-95.221 z m 170.12,0 c 0,-90.297 -28.71,-183.418 -134.42,-183.418 -105.71,0 -134.41,93.121 -134.41,183.418 0,90.321 28.7,184.121 134.41,184.121 105.71,0 134.42,-93.8 134.42,-184.121" /><path
|
||||
id="path32"
|
||||
style="fill:#231f20;fill-opacity:1;fill-rule:nonzero;stroke:none"
|
||||
d="M 2342.88,1137 V 943.082 c 0,-19.602 -7.7,-74.203 25.2,-74.203 26.61,0 24.51,35 24.51,52.512 l 0.7,215.609 h 98.01 V 911.582 c 0,-81.902 -38.51,-135.121 -126.02,-135.121 -112.01,0 -121.11,84.019 -121.11,148.418 V 1137 h 98.71" /><path
|
||||
id="path34"
|
||||
style="fill:#231f20;fill-opacity:1;fill-rule:nonzero;stroke:none"
|
||||
d="m 2706.18,961.988 c 0,19.602 -1.4,94.502 -35,94.502 -33.61,0 -35.7,-74.205 -35.7,-94.502 0,-22.398 1.39,-95.207 37.09,-95.207 31.51,0 33.61,76.301 33.61,95.207 z m -78.41,-225.429 c 1.4,-22.399 7.71,-50.399 35.71,-50.399 44.1,0 37.8,66.5 37.8,96.61 v 44.812 h -1.4 c -6.3,-12.613 -14,-25.91 -23.8,-35.012 -9.8,-9.8 -22.41,-16.109 -39.91,-16.109 -36.4,0 -60.9,28.711 -76.31,58.809 -17.5,35 -23.1,80.511 -23.1,119.718 0,61.602 11.21,189.012 95.21,189.012 32.9,0 56.71,-21.7 67.91,-49.7 h 1.4 v 42.7 h 98.71 V 752.66 c 0,-103.609 -46.2,-144.91 -132.31,-144.91 -84.01,0 -122.52,48.301 -127.41,128.809 h 87.5" /><path
|
||||
id="path36"
|
||||
style="fill:#231f20;fill-opacity:1;fill-rule:nonzero;stroke:none"
|
||||
d="m 3027.48,1003.29 c 0,23.1 -4.9,65.11 -35.7,65.11 -30.1,0 -36.41,-40.61 -36.41,-63.01 v -10.499 h 72.11 z m 83.31,-69.31 h -156.12 c 0.7,-25.898 -0.7,-81.898 37.11,-81.898 25.2,0 31.5,19.598 31.5,40.59 h 84.01 c -4.2,-32.192 -14,-61.602 -32.21,-82.602 -17.5,-21 -43.4,-33.609 -79.8,-33.609 -100.11,0 -135.82,88.918 -135.82,176.43 0,84.709 30.81,191.109 133.72,191.109 65.1,0 125.31,-58.1 119.71,-169.41 l -2.1,-40.61" /><path
|
||||
id="path38"
|
||||
style="fill:#231f20;fill-opacity:1;fill-rule:nonzero;stroke:none"
|
||||
d="m 3431.35,1082.4 h 1.41 c 13.3,33.6 43.4,61.6 81.9,61.6 72.82,0 66.52,-81.2 66.52,-132.31 V 783.469 h -98.71 v 193.922 c 0,18.203 7,67.209 -24.5,67.209 -22.41,0 -26.62,-23.11 -26.62,-40.61 V 783.469 h -98.7 V 1137 h 98.7 v -54.6" /><path
|
||||
id="path40"
|
||||
style="fill:#231f20;fill-opacity:1;fill-rule:nonzero;stroke:none"
|
||||
d="m 3793.96,1003.29 c 0,23.1 -4.9,65.11 -35.7,65.11 -30.11,0 -36.41,-40.61 -36.41,-63.01 v -10.499 h 72.11 z m 83.31,-69.31 h -156.12 c 0.7,-25.898 -0.69,-81.898 37.11,-81.898 25.2,0 31.5,19.598 31.5,40.59 h 84.01 c -4.2,-32.192 -14,-61.602 -32.2,-82.602 -17.51,-21 -43.41,-33.609 -79.82,-33.609 -100.1,0 -135.81,88.918 -135.81,176.43 0,84.709 30.8,191.109 133.71,191.109 65.11,0 125.32,-58.1 119.72,-169.41 l -2.1,-40.61" /><path
|
||||
id="path42"
|
||||
style="fill:#231f20;fill-opacity:1;fill-rule:nonzero;stroke:none"
|
||||
d="M 4023.55,1137 V 943.082 c 0,-19.602 -7.7,-74.203 25.21,-74.203 26.6,0 24.5,35 24.5,52.512 l 0.69,215.609 h 98.02 V 911.582 c 0,-81.902 -38.51,-135.121 -126.02,-135.121 -112.01,0 -121.11,84.019 -121.11,148.418 V 1137 h 98.71" /><path
|
||||
id="path44"
|
||||
style="fill:#231f20;fill-opacity:1;fill-rule:nonzero;stroke:none"
|
||||
d="m 4403.65,789.07 c -18.2,-8.41 -36.41,-12.609 -56.71,-12.609 -95.91,0 -129.51,100.809 -129.51,181.32 0,88.919 35,186.219 137.22,186.219 17.5,0 32.9,-3.49 49,-10.5 v -86.8 c -9.1,8.4 -16.81,11.2 -28.7,11.2 -48.31,0 -58.81,-58.107 -58.81,-94.509 0,-37.809 7.69,-94.512 57.4,-94.512 11.2,0 21.01,4.191 30.11,10.492 V 789.07" /><path
|
||||
id="path46"
|
||||
style="fill:#231f20;fill-opacity:1;fill-rule:nonzero;stroke:none"
|
||||
d="M 4545.73,1348.43 V 1082.4 h 1.4 c 13.3,33.6 43.4,61.6 81.91,61.6 72.8,0 66.5,-81.2 66.5,-132.31 V 783.469 h -98.71 v 193.922 c 0,18.203 7,67.209 -24.5,67.209 -22.4,0 -26.6,-23.11 -26.6,-40.61 V 783.469 h -98.71 v 564.961 h 98.71" /><path
|
||||
id="path48"
|
||||
style="fill:#231f20;fill-opacity:1;fill-rule:nonzero;stroke:none"
|
||||
d="m 4909.73,961.988 c 0,19.602 -2.1,94.502 -35.7,94.502 -32.9,0 -35,-74.205 -35,-94.502 0,-22.398 1.4,-95.207 36.41,-95.207 31.5,0 34.29,76.301 34.29,95.207 z m 72.81,260.422 -42.7,-43.4 -59.51,49.01 -61.6,-49.01 -38.51,49.01 100.11,79.1 z m -79.81,-399.039 h -1.4 c -13.3,-26.601 -33.6,-46.91 -65.1,-46.91 -79.81,0 -95.91,127.418 -95.91,184.121 0,60.908 11.9,183.418 94.51,183.418 32.9,0 55.3,-21.7 66.5,-50.4 h 1.4 v 43.4 h 98.71 V 783.469 h -98.71 v 39.902" /><path
|
||||
id="path50"
|
||||
style="fill:#231f20;fill-opacity:1;fill-rule:nonzero;stroke:none"
|
||||
d="M 5182.02,1235.02 V 1137 h 40.61 v -81.2 h -40.61 V 783.469 h -98.71 V 1055.8 h -34.29 v 81.2 h 34.29 v 98.02 h 98.71" /><path
|
||||
id="path52"
|
||||
style="fill:#231f20;fill-opacity:1;fill-rule:nonzero;stroke:none"
|
||||
d="m 5415.57,1003.29 c 0,23.1 -4.9,65.11 -35.7,65.11 -30.11,0 -36.41,-40.61 -36.41,-63.01 v -10.499 h 72.11 z m 83.31,-69.31 h -156.12 c 0.7,-25.898 -0.69,-81.898 37.11,-81.898 25.2,0 31.5,19.598 31.5,40.59 h 84.01 c -4.2,-32.192 -14.01,-61.602 -32.2,-82.602 -17.51,-21 -43.41,-33.609 -79.81,-33.609 -100.11,0 -135.81,88.918 -135.81,176.43 0,84.709 30.8,191.109 133.71,191.109 65.1,0 125.31,-58.1 119.71,-169.41 l -2.1,-40.61" /><path
|
||||
id="path54"
|
||||
style="fill:#231f20;fill-opacity:1;fill-rule:nonzero;stroke:none"
|
||||
d="M 5645.16,1348.43 V 783.469 h -98.71 v 564.961 h 98.71" /><path
|
||||
id="path56"
|
||||
style="fill:#231f20;fill-opacity:1;fill-rule:nonzero;stroke:none"
|
||||
d="m 5788.66,959.879 c 0,-20.289 1.39,-95.199 35.7,-95.199 34.31,0 35.7,74.91 35.7,95.199 0,20.305 -1.39,95.221 -35.7,95.221 -34.31,0 -35.7,-74.916 -35.7,-95.221 z m 170.12,0 c 0,-90.297 -28.71,-183.418 -134.42,-183.418 -105.71,0 -134.41,93.121 -134.41,183.418 0,90.321 28.7,184.121 134.41,184.121 105.71,0 134.42,-93.8 134.42,-184.121" /><path
|
||||
id="path58"
|
||||
style="fill:#231f20;fill-opacity:1;fill-rule:nonzero;stroke:none"
|
||||
d="M 6102.25,1137 V 783.469 h -98.71 V 1137 Z m -112.01,105.71 c 0,35.71 28.71,62.31 64.41,62.31 34.3,0 60.9,-30.1 60.9,-63.7 0,-33.61 -28.7,-62.31 -63,-62.31 -34.3,0 -62.31,28.7 -62.31,63.7" /><path
|
||||
id="path60"
|
||||
style="fill:#231f20;fill-opacity:1;fill-rule:nonzero;stroke:none"
|
||||
d="m 6331.16,1041.8 c -14.01,12.59 -27.31,23.1 -47.61,23.1 -17.5,0 -26.6,-10.51 -26.6,-25.91 1.4,-41.295 112.7,-32.9 112.7,-138.611 0,-70.008 -40.59,-123.918 -113.4,-123.918 -37.81,0 -77.01,16.109 -106.41,40.609 l 43.4,70.012 c 14.71,-12.602 29.4,-23.113 49,-23.113 15.4,0 28.71,11.203 28.71,29.41 1.4,46.902 -112.71,35.012 -112.71,147.021 0,67.21 56,103.6 118.31,103.6 35.7,0 65.81,-9.1 94.51,-30.09 l -39.9,-72.11" /><path
|
||||
id="path62"
|
||||
style="fill:#231f20;fill-opacity:1;fill-rule:nonzero;stroke:none"
|
||||
d="m 6581.05,1003.29 c 0,23.1 -4.9,65.11 -35.7,65.11 -30.11,0 -36.41,-40.61 -36.41,-63.01 v -10.499 h 72.11 z m 83.31,-69.31 h -156.12 c 0.7,-25.898 -0.69,-81.898 37.11,-81.898 25.2,0 31.5,19.598 31.5,40.59 h 84.01 c -4.2,-32.192 -14,-61.602 -32.2,-82.602 -17.51,-21 -43.41,-33.609 -79.82,-33.609 -100.1,0 -135.81,88.918 -135.81,176.43 0,84.709 30.8,191.109 133.71,191.109 65.11,0 125.32,-58.1 119.72,-169.41 l -2.1,-40.61" /><path
|
||||
id="path64"
|
||||
style="fill:#231f20;fill-opacity:1;fill-rule:nonzero;stroke:none"
|
||||
d="m 1120.11,225.961 -21.5,124.258 c -3.87,21.929 -5.59,44.293 -8.17,66.222 h -1.72 c -3.01,-21.929 -5.16,-44.293 -8.6,-66.222 L 1061.2,225.961 Z m 49.02,-92.891 h -32.25 l -12.04,66.66 h -68.8 l -11.6,-66.66 h -32.26 l 63.64,324.219 h 28.38 l 64.93,-324.219" /><path
|
||||
id="path66"
|
||||
style="fill:#231f20;fill-opacity:1;fill-rule:nonzero;stroke:none"
|
||||
d="m 1272.76,308.512 h -0.86 c -8.17,11.617 -21.07,22.359 -36.55,22.359 -16.77,0 -27.52,-12.902 -27.52,-29.242 0,-21.07 18.06,-33.969 35.69,-48.578 18.06,-15.051 35.69,-31.403 35.69,-60.211 0,-35.25 -23.65,-63.199 -60.2,-63.199 -13.76,0 -29.24,6.441 -39.99,15.05 v 32.668 c 10.75,-11.179 22.36,-21.488 39.13,-21.488 19.35,0 32.25,15.051 32.25,33.527 0,21.942 -17.63,36.563 -35.69,52.461 -17.63,15.481 -35.69,32.68 -35.69,58.911 0,33.121 21.93,56.332 55.04,56.332 14.19,0 27.52,-5.59 38.7,-14.192 v -34.398" /><path
|
||||
id="path68"
|
||||
style="fill:#231f20;fill-opacity:1;fill-rule:nonzero;stroke:none"
|
||||
d="m 1392.31,308.512 h -0.87 c -8.17,11.617 -21.07,22.359 -36.55,22.359 -16.77,0 -27.52,-12.902 -27.52,-29.242 0,-21.07 18.06,-33.969 35.69,-48.578 18.06,-15.051 35.69,-31.403 35.69,-60.211 0,-35.25 -23.65,-63.199 -60.2,-63.199 -13.76,0 -29.24,6.441 -39.99,15.05 v 32.668 c 10.75,-11.179 22.36,-21.488 39.13,-21.488 19.35,0 32.25,15.051 32.25,33.527 0,21.942 -17.63,36.563 -35.69,52.461 -17.63,15.481 -35.69,32.68 -35.69,58.911 0,33.121 21.93,56.332 55.04,56.332 14.19,0 27.52,-5.59 38.71,-14.192 v -34.398" /><path
|
||||
id="path70"
|
||||
style="fill:#231f20;fill-opacity:1;fill-rule:nonzero;stroke:none"
|
||||
d="m 1449.92,243.582 c 0,-21.5 0.86,-87.711 34.41,-87.711 33.53,0 34.39,66.211 34.39,87.711 0,21.078 -0.86,87.289 -34.39,87.289 -33.55,0 -34.41,-66.211 -34.41,-87.289 z m 97.61,0 c 0,-41.711 -5.16,-113.941 -63.2,-113.941 -58.06,0 -63.22,72.23 -63.22,113.941 0,41.277 5.16,113.52 63.22,113.52 58.04,0 63.2,-72.243 63.2,-113.52" /><path
|
||||
id="path72"
|
||||
style="fill:#231f20;fill-opacity:1;fill-rule:nonzero;stroke:none"
|
||||
d="m 1668.79,316.25 c -6.88,7.309 -15.91,14.621 -26.66,14.621 -36.12,0 -39.56,-62.781 -39.56,-88.152 0,-21.93 4.73,-86.848 37.84,-86.848 12.9,0 21.07,9.879 27.52,19.77 h 0.86 V 140.82 c -8.17,-6.89 -21.07,-11.179 -31.82,-11.179 -50.74,0 -63.21,71.371 -63.21,109.218 0,41.7 8.6,118.243 64.5,118.243 10.32,0 21.5,-3.012 30.53,-8.602 v -32.25" /><path
|
||||
id="path74"
|
||||
style="fill:#231f20;fill-opacity:1;fill-rule:nonzero;stroke:none"
|
||||
d="m 1727.7,133.07 h -27.95 v 220.59 h 27.95 z m -13.76,264.879 c -11.6,0 -21.07,9.461 -21.07,21.071 0,11.179 9.47,20.64 21.07,20.64 11.18,0 20.64,-9.461 20.64,-20.64 0,-11.61 -9.46,-21.071 -20.64,-21.071" /><path
|
||||
id="path76"
|
||||
style="fill:#231f20;fill-opacity:1;fill-rule:nonzero;stroke:none"
|
||||
d="m 1821.44,334.309 c -30.96,0 -32.68,-49.02 -32.68,-86.43 0,-91.149 15.05,-95.457 31.39,-95.457 27.09,0 32.25,25.367 32.25,94.598 0,47.73 -3.44,87.289 -30.96,87.289 z m 56.33,-201.239 h -27.95 v 17.2 h -0.86 c -8.17,-12.469 -20.64,-20.629 -36.55,-20.629 -35.26,0 -52.46,19.769 -52.46,116.089 0,49.45 1.72,111.372 55.04,111.372 15.48,0 25.37,-6.454 33.97,-18.493 h 0.86 v 15.051 h 27.95 V 133.07" /><path
|
||||
id="path78"
|
||||
style="fill:#231f20;fill-opacity:1;fill-rule:nonzero;stroke:none"
|
||||
d="m 1992.58,327.859 h -36.55 V 133.07 h -27.95 v 194.789 h -23.64 v 25.801 h 23.64 v 60.199 h 27.95 V 353.66 h 36.55 v -25.801" /><path
|
||||
id="path80"
|
||||
style="fill:#231f20;fill-opacity:1;fill-rule:nonzero;stroke:none"
|
||||
d="m 2034.71,133.07 h -27.95 v 220.59 h 27.95 z m -13.76,264.879 c -11.6,0 -21.07,9.461 -21.07,21.071 0,11.179 9.47,20.64 21.07,20.64 11.18,0 20.64,-9.461 20.64,-20.64 0,-11.61 -9.46,-21.071 -20.64,-21.071" /><path
|
||||
id="path82"
|
||||
style="fill:#231f20;fill-opacity:1;fill-rule:nonzero;stroke:none"
|
||||
d="m 2095.77,243.582 c 0,-21.5 0.86,-87.711 34.41,-87.711 33.53,0 34.39,66.211 34.39,87.711 0,21.078 -0.86,87.289 -34.39,87.289 -33.55,0 -34.41,-66.211 -34.41,-87.289 z m 97.61,0 c 0,-41.711 -5.16,-113.941 -63.2,-113.941 -58.06,0 -63.22,72.23 -63.22,113.941 0,41.277 5.16,113.52 63.22,113.52 58.04,0 63.2,-72.243 63.2,-113.52" /><path
|
||||
id="path84"
|
||||
style="fill:#231f20;fill-opacity:1;fill-rule:nonzero;stroke:none"
|
||||
d="m 2254.01,336.031 c 11.18,12.899 26.23,21.071 43.43,21.071 39.12,0 42.57,-36.122 42.57,-66.223 V 133.07 h -27.95 v 154.801 c 0,26.231 -1.72,46.438 -24.94,46.438 -31.82,0 -33.11,-34.829 -33.11,-58.047 V 133.07 h -27.95 v 220.59 h 27.95 v -17.629" /><path
|
||||
id="path86"
|
||||
style="fill:#231f20;fill-opacity:1;fill-rule:nonzero;stroke:none"
|
||||
d="m 2556.72,316.25 c -6.88,7.309 -15.91,14.621 -26.66,14.621 -36.12,0 -39.56,-62.781 -39.56,-88.152 0,-21.93 4.73,-86.848 37.84,-86.848 12.9,0 21.07,9.879 27.52,19.77 h 0.86 V 140.82 c -8.17,-6.89 -21.07,-11.179 -31.82,-11.179 -50.74,0 -63.21,71.371 -63.21,109.218 0,41.7 8.6,118.243 64.5,118.243 10.32,0 21.5,-3.012 30.53,-8.602 v -32.25" /><path
|
||||
id="path88"
|
||||
style="fill:#231f20;fill-opacity:1;fill-rule:nonzero;stroke:none"
|
||||
d="m 2642.72,334.309 c -30.96,0 -32.68,-49.02 -32.68,-86.43 0,-91.149 15.05,-95.457 31.39,-95.457 27.09,0 32.25,25.367 32.25,94.598 0,47.73 -3.44,87.289 -30.96,87.289 z m 56.32,-201.239 h -27.94 v 17.2 h -0.86 c -8.17,-12.469 -20.64,-20.629 -36.55,-20.629 -35.26,0 -52.46,19.769 -52.46,116.089 0,49.45 1.72,111.372 55.04,111.372 15.48,0 25.37,-6.454 33.97,-18.493 h 0.86 v 15.051 h 27.94 V 133.07" /><path
|
||||
id="path90"
|
||||
style="fill:#231f20;fill-opacity:1;fill-rule:nonzero;stroke:none"
|
||||
d="m 2765.69,336.031 c 11.19,12.899 26.24,21.071 43.44,21.071 39.12,0 42.57,-36.122 42.57,-66.223 V 133.07 h -27.95 v 154.801 c 0,26.231 -1.72,46.438 -24.94,46.438 -31.82,0 -33.12,-34.829 -33.12,-58.047 V 133.07 h -27.94 v 220.59 h 27.94 v -17.629" /><path
|
||||
id="path92"
|
||||
style="fill:#231f20;fill-opacity:1;fill-rule:nonzero;stroke:none"
|
||||
d="m 2966.51,327.859 h -36.56 V 133.07 H 2902 v 194.789 h -23.64 V 353.66 H 2902 v 60.199 h 27.95 V 353.66 h 36.56 v -25.801" /><path
|
||||
id="path94"
|
||||
style="fill:#231f20;fill-opacity:1;fill-rule:nonzero;stroke:none"
|
||||
d="m 3003.05,243.582 c 0,-21.5 0.86,-87.711 34.4,-87.711 33.54,0 34.4,66.211 34.4,87.711 0,21.078 -0.86,87.289 -34.4,87.289 -33.54,0 -34.4,-66.211 -34.4,-87.289 z m 97.61,0 c 0,-41.711 -5.16,-113.941 -63.21,-113.941 -58.05,0 -63.21,72.23 -63.21,113.941 0,41.277 5.16,113.52 63.21,113.52 58.05,0 63.21,-72.243 63.21,-113.52" /><path
|
||||
id="path96"
|
||||
style="fill:#231f20;fill-opacity:1;fill-rule:nonzero;stroke:none"
|
||||
d="m 3161.28,336.031 c 11.18,12.899 26.23,21.071 43.44,21.071 39.12,0 42.57,-36.122 42.57,-66.223 V 133.07 h -27.95 v 154.801 c 0,26.231 -1.72,46.438 -24.94,46.438 -31.82,0 -33.12,-34.829 -33.12,-58.047 V 133.07 h -27.95 v 220.59 h 27.95 v -17.629" /><path
|
||||
id="path98"
|
||||
style="fill:#231f20;fill-opacity:1;fill-rule:nonzero;stroke:none"
|
||||
d="m 3341.02,334.309 c -30.96,0 -32.68,-49.02 -32.68,-86.43 0,-91.149 15.05,-95.457 31.39,-95.457 27.09,0 32.25,25.367 32.25,94.598 0,47.73 -3.44,87.289 -30.96,87.289 z m 56.33,-201.239 h -27.95 v 17.2 h -0.86 c -8.17,-12.469 -20.64,-20.629 -36.55,-20.629 -35.26,0 -52.46,19.769 -52.46,116.089 0,49.45 1.72,111.372 55.04,111.372 15.48,0 25.37,-6.454 33.97,-18.493 h 0.86 v 15.051 h 27.95 V 133.07" /><path
|
||||
id="path100"
|
||||
style="fill:#231f20;fill-opacity:1;fill-rule:nonzero;stroke:none"
|
||||
d="m 3464,133.07 h -27.95 v 344 H 3464 v -344" /><path
|
||||
id="path102"
|
||||
style="fill:#231f20;fill-opacity:1;fill-rule:nonzero;stroke:none"
|
||||
d="m 3589.56,261.211 -0.43,3.879 c -0.86,21.058 -1.72,65.781 -30.96,65.781 -26.66,0 -32.68,-49.883 -32.68,-69.66 z m -64.5,-23.641 v -6.461 c 0,-21.918 3.87,-75.238 34.4,-75.238 24.51,0 27.95,34.391 27.95,52.02 h 28.81 c -0.43,-33.539 -13.33,-78.25 -54.18,-78.25 -58.05,0 -65.79,68.8 -65.79,113.078 0,39.562 8.61,114.383 61.92,114.383 51.6,0 59.77,-67.512 59.77,-106.204 V 237.57 h -92.88" /><path
|
||||
id="path104"
|
||||
style="fill:#231f20;fill-opacity:1;fill-rule:nonzero;stroke:none"
|
||||
d="m 3794.65,334.309 c -31.39,0 -32.68,-55.028 -32.68,-86.43 0,-20.641 -4.29,-95.457 31.39,-95.457 29.67,0 32.26,36.547 32.26,94.598 0,50.312 -5.17,87.289 -30.97,87.289 z m 28.38,-184.039 h -0.86 c -8.16,-12.469 -20.64,-20.629 -36.54,-20.629 -46.02,0 -52.46,55.898 -52.46,116.089 0,60.211 6.01,111.372 55.03,111.372 15.48,0 25.38,-6.454 33.97,-18.493 h 0.86 V 477.07 h 27.95 v -344 h -27.95 v 17.2" /><path
|
||||
id="path106"
|
||||
style="fill:#231f20;fill-opacity:1;fill-rule:nonzero;stroke:none"
|
||||
d="m 3976.54,261.211 -0.43,3.879 c -0.85,21.058 -1.71,65.781 -30.96,65.781 -26.65,0 -32.68,-49.883 -32.68,-69.66 z m -64.5,-23.641 v -6.461 c 0,-21.918 3.87,-75.238 34.41,-75.238 24.5,0 27.95,34.391 27.95,52.02 h 28.8 c -0.43,-33.539 -13.33,-78.25 -54.18,-78.25 -58.04,0 -65.79,68.8 -65.79,113.078 0,39.562 8.61,114.383 61.92,114.383 51.6,0 59.77,-67.512 59.77,-106.204 V 237.57 h -92.88" /><path
|
||||
id="path108"
|
||||
style="fill:#231f20;fill-opacity:1;fill-rule:nonzero;stroke:none"
|
||||
d="m 4154.54,133.07 h -27.95 v 344 h 27.95 v -344" /><path
|
||||
id="path110"
|
||||
style="fill:#231f20;fill-opacity:1;fill-rule:nonzero;stroke:none"
|
||||
d="m 4248.28,334.309 c -30.95,0 -32.67,-49.02 -32.67,-86.43 0,-91.149 15.05,-95.457 31.38,-95.457 27.09,0 32.26,25.367 32.26,94.598 0,47.73 -3.45,87.289 -30.97,87.289 z m 56.33,-201.239 h -27.95 v 17.2 h -0.86 c -8.16,-12.469 -20.63,-20.629 -36.54,-20.629 -35.26,0 -52.46,19.769 -52.46,116.089 0,49.45 1.72,111.372 55.04,111.372 15.48,0 25.37,-6.454 33.96,-18.493 h 0.86 v 15.051 h 27.95 V 133.07" /><path
|
||||
id="path112"
|
||||
style="fill:#231f20;fill-opacity:1;fill-rule:nonzero;stroke:none"
|
||||
d="m 4558.31,142.109 c -11.18,-7.75 -27.09,-13.328 -41.28,-13.328 -76.11,0 -87.29,99.321 -87.29,154.789 0,52.461 1.72,178.02 90.3,178.02 12.04,0 28.38,-3.008 38.27,-10.32 v -33.11 c -12.04,8.172 -22.79,14.192 -37.84,14.192 -52.46,0 -59.78,-76.114 -59.78,-136.313 0,-37.84 0,-138.019 57.63,-138.019 14.62,0 28.38,6.441 39.99,14.621 v -30.532" /><path
|
||||
id="path114"
|
||||
style="fill:#231f20;fill-opacity:1;fill-rule:nonzero;stroke:none"
|
||||
d="m 4618.5,324.422 h 0.86 c 6.02,15.476 18.06,33.109 36.55,32.68 v -32.68 l -5.59,0.43 c -28.8,0 -31.82,-27.954 -31.82,-63.211 V 133.07 h -27.95 v 220.59 h 27.95 v -29.238" /><path
|
||||
id="path116"
|
||||
style="fill:#231f20;fill-opacity:1;fill-rule:nonzero;stroke:none"
|
||||
d="m 4701.06,243.582 c 0,-21.5 0.86,-87.711 34.4,-87.711 33.54,0 34.4,66.211 34.4,87.711 0,21.078 -0.86,87.289 -34.4,87.289 -33.54,0 -34.4,-66.211 -34.4,-87.289 z m 97.6,0 c 0,-41.711 -5.15,-113.941 -63.2,-113.941 -58.06,0 -63.21,72.23 -63.21,113.941 0,41.277 5.15,113.52 63.21,113.52 58.05,0 63.2,-72.243 63.2,-113.52" /><path
|
||||
id="path118"
|
||||
style="fill:#231f20;fill-opacity:1;fill-rule:nonzero;stroke:none"
|
||||
d="m 4859.29,133.07 h -27.95 v 220.59 h 27.95 z m -13.76,264.879 c -11.6,0 -21.07,9.461 -21.07,21.071 0,11.179 9.47,20.64 21.07,20.64 11.18,0 20.64,-9.461 20.64,-20.64 0,-11.61 -9.46,-21.071 -20.64,-21.071" /><path
|
||||
id="path120"
|
||||
style="fill:#231f20;fill-opacity:1;fill-rule:nonzero;stroke:none"
|
||||
d="m 4966.79,245.73 49.45,-112.66 h -30.96 l -22.79,58.051 c -4.72,12.039 -9.46,24.078 -11.61,36.981 h -1.72 c -1.72,-12.903 -6.01,-25.372 -10.75,-37.411 l -21.92,-57.621 h -31.4 l 50.74,112.66 -45.57,107.93 h 30.95 l 18.06,-49.019 c 5.17,-14.621 9.89,-29.243 12.9,-44.719 h 1.72 c 3.88,15.476 6.88,30.957 12.9,46.019 l 18.49,47.719 h 30.96 l -49.45,-107.93" /><path
|
||||
id="path122"
|
||||
style="fill:#231f20;fill-opacity:1;fill-rule:nonzero;stroke:none"
|
||||
d="m 5022.69,287.441 h 67.08 V 253.48 h -67.08 v 33.961" /><path
|
||||
id="path124"
|
||||
style="fill:#231f20;fill-opacity:1;fill-rule:nonzero;stroke:none"
|
||||
d="m 5145.67,316.25 h 10.75 c 42.14,0 49.01,14.621 49.01,57.191 0,51.168 -15.48,56.329 -53.75,56.329 h -6.01 z m -30.1,-183.18 v 324.219 h 32.24 c 45.16,0 88.59,-3.437 88.59,-82.559 0,-41.269 -9.04,-81.691 -61.07,-83.421 l 71.81,-158.239 h -31.82 l -68.8,158.68 h -0.85 V 133.07 h -30.1" /><path
|
||||
id="path126"
|
||||
style="fill:#231f20;fill-opacity:1;fill-rule:nonzero;stroke:none"
|
||||
d="m 5295.3,243.582 c 0,-21.5 0.86,-87.711 34.41,-87.711 33.54,0 34.4,66.211 34.4,87.711 0,21.078 -0.86,87.289 -34.4,87.289 -33.55,0 -34.41,-66.211 -34.41,-87.289 z m 97.62,0 c 0,-41.711 -5.16,-113.941 -63.21,-113.941 -58.06,0 -63.22,72.23 -63.22,113.941 0,41.277 5.16,113.52 63.22,113.52 58.05,0 63.21,-72.243 63.21,-113.52" /><path
|
||||
id="path128"
|
||||
style="fill:#231f20;fill-opacity:1;fill-rule:nonzero;stroke:none"
|
||||
d="M 5451.82,353.66 V 215.199 c 0,-30.09 1.72,-59.328 29.24,-59.328 28.38,0 29.67,29.238 30.1,59.328 V 353.66 h 27.95 V 191.551 c 0,-36.969 -18.07,-61.91 -57.62,-61.91 -39.12,0 -57.62,24.507 -57.62,61.91 V 353.66 h 27.95" /><path
|
||||
id="path130"
|
||||
style="fill:#231f20;fill-opacity:1;fill-rule:nonzero;stroke:none"
|
||||
d="m 5633.29,155.871 c 29.67,0 30.95,43.43 30.95,91.578 0,40.422 -2.57,86.86 -33.12,86.86 -20.2,0 -32.68,-13.75 -32.68,-94.59 0,-24.09 -0.43,-83.848 34.85,-83.848 z m 56.33,-30.973 c 0,-41.2769 -1.72,-79.5386 -57.64,-79.5386 -38.69,0 -56.76,23.2109 -56.76,61.0506 v 8.18 h 27.52 v -3.879 c 0,-20.629 5.61,-39.1212 29.69,-39.1212 31.38,0 28.81,27.5196 28.81,51.1602 v 25.801 c -9.48,-10.75 -21.08,-15.481 -35.71,-15.481 -55.04,0 -55.89,76.121 -55.89,115.239 0,36.98 4.29,108.793 55.46,108.793 15.06,0 27.52,-6.883 35.71,-18.493 h 0.86 v 15.051 h 27.95 V 124.898" /><path
|
||||
id="path132"
|
||||
style="fill:#231f20;fill-opacity:1;fill-rule:nonzero;stroke:none"
|
||||
d="m 5815.16,261.211 -0.43,3.879 c -0.86,21.058 -1.72,65.781 -30.95,65.781 -26.66,0 -32.68,-49.883 -32.68,-69.66 z m -64.49,-23.641 v -6.461 c 0,-21.918 3.87,-75.238 34.39,-75.238 24.52,0 27.95,34.391 27.95,52.02 h 28.81 c -0.43,-33.539 -13.32,-78.25 -54.18,-78.25 -58.04,0 -65.78,68.8 -65.78,113.078 0,39.562 8.6,114.383 61.92,114.383 51.6,0 59.76,-67.512 59.76,-106.204 V 237.57 h -92.87" /><path
|
||||
id="path134"
|
||||
style="fill:#231f20;fill-opacity:1;fill-rule:nonzero;stroke:none"
|
||||
d="m 6049.5,308.512 h -0.86 c -8.17,11.617 -21.08,22.359 -36.54,22.359 -16.78,0 -27.52,-12.902 -27.52,-29.242 0,-21.07 18.04,-33.969 35.68,-48.578 18.07,-15.051 35.68,-31.403 35.68,-60.211 0,-35.25 -23.65,-63.199 -60.19,-63.199 -13.75,0 -29.24,6.441 -39.98,15.05 v 32.668 c 10.74,-11.179 22.34,-21.488 39.12,-21.488 19.35,0 32.25,15.051 32.25,33.527 0,21.942 -17.62,36.563 -35.69,52.461 -17.64,15.481 -35.68,32.68 -35.68,58.911 0,33.121 21.91,56.332 55.04,56.332 14.18,0 27.52,-5.59 38.69,-14.192 v -34.398" /><path
|
||||
id="path136"
|
||||
style="fill:#231f20;fill-opacity:1;fill-rule:nonzero;stroke:none"
|
||||
d="M 6110.98,353.66 V 215.199 c 0,-30.09 1.72,-59.328 29.24,-59.328 28.4,0 29.69,29.238 30.12,59.328 V 353.66 h 27.95 V 191.551 c 0,-36.969 -18.07,-61.91 -57.64,-61.91 -39.12,0 -57.62,24.507 -57.62,61.91 V 353.66 h 27.95" /><path
|
||||
id="path138"
|
||||
style="fill:#231f20;fill-opacity:1;fill-rule:nonzero;stroke:none"
|
||||
d="m 6263.21,133.07 h -27.95 v 220.59 h 27.95 z m -13.77,264.879 c -11.6,0 -21.05,9.461 -21.05,21.071 0,11.179 9.45,20.64 21.05,20.64 11.19,0 20.64,-9.461 20.64,-20.64 0,-11.61 -9.45,-21.071 -20.64,-21.071" /><path
|
||||
id="path140"
|
||||
style="fill:#231f20;fill-opacity:1;fill-rule:nonzero;stroke:none"
|
||||
d="m 6386.2,308.512 h -0.88 c -8.17,11.617 -21.06,22.359 -36.54,22.359 -16.76,0 -27.52,-12.902 -27.52,-29.242 0,-21.07 18.06,-33.969 35.68,-48.578 18.07,-15.051 35.7,-31.403 35.7,-60.211 0,-35.25 -23.65,-63.199 -60.19,-63.199 -13.77,0 -29.24,6.441 -40,15.05 v 32.668 c 10.76,-11.179 22.36,-21.488 39.14,-21.488 19.33,0 32.24,15.051 32.24,33.527 0,21.942 -17.63,36.563 -35.7,52.461 -17.62,15.481 -35.68,32.68 -35.68,58.911 0,33.121 21.93,56.332 55.04,56.332 14.2,0 27.52,-5.59 38.71,-14.192 v -34.398" /><path
|
||||
id="path142"
|
||||
style="fill:#231f20;fill-opacity:1;fill-rule:nonzero;stroke:none"
|
||||
d="m 6505.73,308.512 h -0.86 c -8.16,11.617 -21.07,22.359 -36.54,22.359 -16.78,0 -27.52,-12.902 -27.52,-29.242 0,-21.07 18.04,-33.969 35.68,-48.578 18.07,-15.051 35.68,-31.403 35.68,-60.211 0,-35.25 -23.65,-63.199 -60.19,-63.199 -13.75,0 -29.24,6.441 -39.98,15.05 v 32.668 c 10.74,-11.179 22.34,-21.488 39.12,-21.488 19.35,0 32.25,15.051 32.25,33.527 0,21.942 -17.62,36.563 -35.69,52.461 -17.63,15.481 -35.68,32.68 -35.68,58.911 0,33.121 21.91,56.332 55.04,56.332 14.18,0 27.52,-5.59 38.69,-14.192 v -34.398" /><path
|
||||
id="path144"
|
||||
style="fill:#231f20;fill-opacity:1;fill-rule:nonzero;stroke:none"
|
||||
d="m 6627.86,261.211 -0.43,3.879 c -0.86,21.058 -1.72,65.781 -30.98,65.781 -26.64,0 -32.67,-49.883 -32.67,-69.66 z m -64.51,-23.641 v -6.461 c 0,-21.918 3.86,-75.238 34.41,-75.238 24.49,0 27.95,34.391 27.95,52.02 h 28.81 c -0.43,-33.539 -13.34,-78.25 -54.18,-78.25 -58.05,0 -65.8,68.8 -65.8,113.078 0,39.562 8.61,114.383 61.91,114.383 51.6,0 59.79,-67.512 59.79,-106.204 V 237.57 h -92.89" /></g></g></svg>
|
After Width: | Height: | Size: 28 KiB |
BIN
aemo/static/img/logo-zewo.png
Normal file
After Width: | Height: | Size: 22 KiB |
1
aemo/static/img/logo-zewo.svg
Normal file
After Width: | Height: | Size: 8.9 KiB |
BIN
aemo/static/img/printer.png
Normal file
After Width: | Height: | Size: 10 KiB |
BIN
aemo/static/img/reseau.png
Normal file
After Width: | Height: | Size: 3.8 KiB |
BIN
aemo/static/img/stat.png
Normal file
After Width: | Height: | Size: 2.2 KiB |
BIN
aemo/static/img/telephone.png
Normal file
After Width: | Height: | Size: 117 KiB |
3
aemo/static/img/warning.svg
Normal file
|
@ -0,0 +1,3 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" class="bi bi-exclamation-triangle-fill flex-shrink-0 me-2" viewBox="0 0 16 16" role="img" aria-label="Warning:">
|
||||
<path d="M8.982 1.566a1.13 1.13 0 0 0-1.96 0L.165 13.233c-.457.778.091 1.767.98 1.767h13.713c.889 0 1.438-.99.98-1.767L8.982 1.566zM8 5c.535 0 .954.462.9.995l-.35 3.507a.552.552 0 0 1-1.1 0L7.1 5.995A.905.905 0 0 1 8 5zm.002 6a1 1 0 1 1 0 2 1 1 0 0 1 0-2z"/>
|
||||
</svg>
|
After Width: | Height: | Size: 466 B |
424
aemo/static/js/DateTimeShortcuts.js
Normal file
|
@ -0,0 +1,424 @@
|
|||
/*global Calendar, findPosX, findPosY, get_format, gettext, gettext_noop, interpolate, ngettext, quickElement*/
|
||||
// Inserts shortcut buttons after all of the following:
|
||||
// <input type="text" class="vDateField">
|
||||
// <input type="text" class="vTimeField">
|
||||
'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 <div> that gets toggled
|
||||
calendarDivName2: 'calendarin', // name of <div> that contains calendar
|
||||
calendarLinkName: 'calendarlink', // name of the link that is used to toggle
|
||||
clockDivName: 'clockbox', // name of clock <div> 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:
|
||||
// <div id="clockbox1" class="clockbox module">
|
||||
// <h2>Choose a time</h2>
|
||||
// <ul class="timelist">
|
||||
// <li><a href="#">Now</a></li>
|
||||
// <li><a href="#">Midnight</a></li>
|
||||
// <li><a href="#">6 a.m.</a></li>
|
||||
// <li><a href="#">Noon</a></li>
|
||||
// <li><a href="#">6 p.m.</a></li>
|
||||
// </ul>
|
||||
// <p class="calendar-cancel"><a href="#">Cancel</a></p>
|
||||
// </div>
|
||||
|
||||
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 <input>.
|
||||
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:
|
||||
//
|
||||
// <div id="calendarbox3" class="calendarbox module">
|
||||
// <h2>
|
||||
// <a href="#" class="link-previous">‹</a>
|
||||
// <a href="#" class="link-next">›</a> February 2003
|
||||
// </h2>
|
||||
// <div class="calendar" id="calendarin3">
|
||||
// <!-- (cal) -->
|
||||
// </div>
|
||||
// <div class="calendar-shortcuts">
|
||||
// <a href="#">Yesterday</a> | <a href="#">Today</a> | <a href="#">Tomorrow</a>
|
||||
// </div>
|
||||
// <p class="calendar-cancel"><a href="#">Cancel</a></p>
|
||||
// </div>
|
||||
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;
|
||||
}
|
1
aemo/static/js/autocomplete.min.js
vendored
Normal file
|
@ -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.offsetTop<i.scrollTop)i.scrollTop=t.offsetTop;else{var o=t.offsetTop+t.offsetHeight,r=i.scrollTop+i.offsetHeight;o>r&&(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<o.length;n++){if(t===o[n])return}t>=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<c.length-1;e++)if(t===c[e]){t=c[e+1];break}}else t=c[0]}(),b()}return n.preventDefault(),void(i&&n.stopPropagation())}13===o&&(t&&(e.onSelect(t,h),L()),a&&n.preventDefault())}function k(){m&&D(1)}function D(o){var i=++g,r=h.value;r.length>=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()}}}}));
|
6
aemo/static/js/autosize.min.js
vendored
Normal file
|
@ -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<n.indexOf(e)},get:function(e){return o[n.indexOf(e)]},set:function(e,t){-1===n.indexOf(e)&&(n.push(e),o.push(t))},delete:function(e){var t=n.indexOf(e);-1<t&&(n.splice(t,1),o.splice(t,1))}}),c=function(e){return new Event(e,{bubbles:!0})};try{new Event("test")}catch(e){c=function(e){var t=document.createEvent("Event");return t.initEvent(e,!0,!1),t}}function r(r){if(r&&r.nodeName&&"TEXTAREA"===r.nodeName&&!p.has(r)){var e,n=null,o=null,i=null,d=function(){r.clientWidth!==o&&a()},l=function(t){window.removeEventListener("resize",d,!1),r.removeEventListener("input",a,!1),r.removeEventListener("keyup",a,!1),r.removeEventListener("autosize:destroy",l,!1),r.removeEventListener("autosize:update",a,!1),Object.keys(t).forEach(function(e){r.style[e]=t[e]}),p.delete(r)}.bind(r,{height:r.style.height,resize:r.style.resize,overflowY:r.style.overflowY,overflowX:r.style.overflowX,wordWrap:r.style.wordWrap});r.addEventListener("autosize:destroy",l,!1),"onpropertychange"in r&&"oninput"in r&&r.addEventListener("keyup",a,!1),window.addEventListener("resize",d,!1),r.addEventListener("input",a,!1),r.addEventListener("autosize:update",a,!1),r.style.overflowX="hidden",r.style.wordWrap="break-word",p.set(r,{destroy:l,update:a}),"vertical"===(e=window.getComputedStyle(r,null)).resize?r.style.resize="none":"both"===e.resize&&(r.style.resize="horizontal"),n="content-box"===e.boxSizing?-(parseFloat(e.paddingTop)+parseFloat(e.paddingBottom)):parseFloat(e.borderTopWidth)+parseFloat(e.borderBottomWidth),isNaN(n)&&(n=0),a()}function s(e){var t=r.style.width;r.style.width="0px",r.offsetWidth,r.style.width=t,r.style.overflowY=e}function u(){if(0!==r.scrollHeight){var e=function(e){for(var t=[];e&&e.parentNode&&e.parentNode instanceof Element;)e.parentNode.scrollTop&&t.push({node:e.parentNode,scrollTop:e.parentNode.scrollTop}),e=e.parentNode;return t}(r),t=document.documentElement&&document.documentElement.scrollTop;r.style.height="",r.style.height=r.scrollHeight+n+"px",o=r.clientWidth,e.forEach(function(e){e.node.scrollTop=e.scrollTop}),t&&(document.documentElement.scrollTop=t)}}function a(){u();var e=Math.round(parseFloat(r.style.height)),t=window.getComputedStyle(r,null),n="content-box"===t.boxSizing?Math.round(parseFloat(t.height)):r.offsetHeight;if(n<e?"hidden"===t.overflowY&&(s("scroll"),u(),n="content-box"===t.boxSizing?Math.round(parseFloat(window.getComputedStyle(r,null).height)):r.offsetHeight):"hidden"!==t.overflowY&&(s("hidden"),u(),n="content-box"===t.boxSizing?Math.round(parseFloat(window.getComputedStyle(r,null).height)):r.offsetHeight),i!==n){i=n;var o=c("autosize:resized");try{r.dispatchEvent(o)}catch(e){}}}}function i(e){var t=p.get(e);t&&t.destroy()}function d(e){var t=p.get(e);t&&t.update()}var l=null;"undefined"==typeof window||"function"!=typeof window.getComputedStyle?((l=function(e){return e}).destroy=function(e){return e},l.update=function(e){return e}):((l=function(e,t){return e&&Array.prototype.forEach.call(e.length?e:[e],function(e){return r(e)}),e}).destroy=function(e){return e&&Array.prototype.forEach.call(e.length?e:[e],i),e},l.update=function(e){return e&&Array.prototype.forEach.call(e.length?e:[e],d),e}),t.default=l,e.exports=t.default});
|
241
aemo/static/js/main.js
Normal file
|
@ -0,0 +1,241 @@
|
|||
function htmlToElem(html) {
|
||||
let temp = document.createElement('template');
|
||||
html = html.trim(); // Never return a space text node as a result
|
||||
temp.innerHTML = html;
|
||||
return temp.content.firstChild;
|
||||
}
|
||||
|
||||
function dateFormat(input) {
|
||||
if (input) {
|
||||
var dt = new Date(input);
|
||||
return dt.toLocaleDateString("fr-CH");
|
||||
}
|
||||
return '-';
|
||||
}
|
||||
|
||||
var changed = false;
|
||||
|
||||
function check_changed(ev) {
|
||||
if (changed) {
|
||||
alert("Vos données n'ont pas été sauvegardées !");
|
||||
ev.preventDefault();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function toggle_read_more(ev) {
|
||||
ev.preventDefault();
|
||||
const link = ev.target;
|
||||
link.innerHTML = (link.innerHTML == 'Afficher la suite') ? 'Réduire' : 'Afficher la suite';
|
||||
link.parentNode.querySelector('.long').classList.toggle('hidden');
|
||||
link.parentNode.querySelector('.short').classList.toggle('hidden');
|
||||
}
|
||||
|
||||
function showImage(ev) {
|
||||
var modal = document.getElementById('imgModal'); /* Present in base.html */
|
||||
var imgTag = document.getElementById("img01");
|
||||
var captionText = document.getElementById("caption");
|
||||
ev.preventDefault();
|
||||
modal.style.display = "block";
|
||||
imgTag.src = this.href;
|
||||
captionText.innerHTML = this.textContent;
|
||||
}
|
||||
|
||||
function setConfirmHandlers(section) {
|
||||
if (typeof section === 'undefined') section = document;
|
||||
const selector = section.querySelectorAll(".btn-danger, .confirm");
|
||||
selector.forEach(button => {
|
||||
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');
|
||||
}
|
6
aemo/static/js/sorts/tablesort.date.min.js
vendored
Normal file
|
@ -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)})}();
|
6
aemo/static/js/sorts/tablesort.number.min.js
vendored
Normal file
|
@ -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)})}();
|
6
aemo/static/js/tablesort.min.js
vendored
Normal file
|
@ -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:a<b?1:-1},f=function(a,b){return[].slice.call(a).find(function(a){return a.getAttribute("data-sort-column-key")===b})},g=function(a,b){return function(c,d){var e=a(c.td,d.td);return 0===e?b?d.index-c.index:c.index-d.index:e}};a.extend=function(a,c,d){if("function"!=typeof c||"function"!=typeof d)throw new Error("Pattern and sort must be a function");b.push({name:a,pattern:c,sort:d})},a.prototype={init:function(a,b){var c,d,e,f,g=this;if(g.table=a,g.thead=!1,g.options=b,a.rows&&a.rows.length>0)if(a.tHead&&a.tHead.rows.length>0){for(e=0;e<a.tHead.rows.length;e++)if("thead"===a.tHead.rows[e].getAttribute("data-sort-method")){c=a.tHead.rows[e];break}c||(c=a.tHead.rows[a.tHead.rows.length-1]),g.thead=!0}else c=a.rows[0];if(c){var h=function(){g.current&&g.current!==this&&g.current.removeAttribute("aria-sort"),g.current=this,g.sortTable(this)};for(e=0;e<c.cells.length;e++)f=c.cells[e],f.setAttribute("role","columnheader"),"none"!==f.getAttribute("data-sort-method")&&(f.tabindex=0,f.addEventListener("click",h,!1),null!==f.getAttribute("data-sort-default")&&(d=f));d&&(g.current=d,g.sortTable(d))}},sortTable:function(a,h){var i=this,j=a.getAttribute("data-sort-column-key"),k=a.cellIndex,l=e,m="",n=[],o=i.thead?0:1,p=a.getAttribute("data-sort-method"),q=a.getAttribute("aria-sort");if(i.table.dispatchEvent(c("beforeSort")),h||(q="ascending"===q?"descending":"descending"===q?"ascending":i.options.descending?"descending":"ascending",a.setAttribute("aria-sort",q)),!(i.table.rows.length<2)){if(!p){for(var r;n.length<3&&o<i.table.tBodies[0].rows.length;)r=j?f(i.table.tBodies[0].rows[o].cells,j):i.table.tBodies[0].rows[o].cells[k],m=r?d(r):"",m=m.trim(),m.length>0&&n.push(m),o++;if(!n)return}for(o=0;o<b.length;o++)if(m=b[o],p){if(m.name===p){l=m.sort;break}}else if(n.every(m.pattern)){l=m.sort;break}for(i.col=k,o=0;o<i.table.tBodies.length;o++){var s,t=[],u={},v=0,w=0;if(!(i.table.tBodies[o].rows.length<2)){for(s=0;s<i.table.tBodies[o].rows.length;s++){var r;m=i.table.tBodies[o].rows[s],"none"===m.getAttribute("data-sort-method")?u[v]=m:(r=j?f(m.cells,j):m.cells[i.col],t.push({tr:m,td:r?d(r):"",index:v})),v++}for("descending"===q?t.sort(g(l,!0)):(t.sort(g(l,!1)),t.reverse()),s=0;s<v;s++)u[s]?(m=u[s],w++):m=t[s-w].tr,i.table.tBodies[o].appendChild(m)}}i.table.dispatchEvent(c("afterSort"))}},refresh:function(){void 0!==this.current&&this.sortTable(this.current,!0)}},"undefined"!=typeof module&&module.exports?module.exports=a:window.Tablesort=a}();
|
1
aemo/templates/widgets/group_checkbox_option.html
Normal file
|
@ -0,0 +1 @@
|
|||
{% load my_tags %}{% if widget.wrap_label %}<label{% if widget.attrs.id %} for="{{ widget.attrs.id }}"{% endif %}>{% endif %}{% include "django/forms/widgets/input.html" %}{% if widget.wrap_label %} {{ widget.label }} {% if widget.help %}{% help_tooltip widget.help %}{% endif %}</label>{% endif %}
|
2
aemo/templates/widgets/input_option.html
Normal file
|
@ -0,0 +1,2 @@
|
|||
{# input before label (CSSable) #}
|
||||
{% include "django/forms/widgets/input.html" %} {% if widget.wrap_label %}<label {% if widget.attrs.id %} for="{{ widget.attrs.id }}"{% endif %}>{{ widget.label }}</label>{% endif %}
|
2
aemo/templates/widgets/prestation_radio.html
Normal file
|
@ -0,0 +1,2 @@
|
|||
{% load my_tags static %}
|
||||
{% if widget.wrap_label %}<label{% if widget.attrs.id %} for="{{ widget.attrs.id }}"{% endif %}>{% endif %}{% include "django/forms/widgets/input.html" %}{% if widget.wrap_label %} {{ widget.label }} {% help_tooltip widget.value.instance.actes %}</label>{% endif %}
|
0
aemo/templatetags/__init__.py
Normal file
351
aemo/templatetags/my_tags.py
Normal file
|
@ -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("<br>".join([str(v) for v in value.all()]))
|
||||
return value
|
||||
|
||||
|
||||
@register.simple_tag
|
||||
def help_tooltip(text):
|
||||
template = (
|
||||
'<span class="help" data-bs-toggle="tooltip" data-bs-html="true" data-bs-placement="bottom" '
|
||||
'title="{text}"><img src="{icon}"></span>'
|
||||
)
|
||||
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 = '<span title="{nom}, {role}">{sigle}</span>'
|
||||
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(
|
||||
'<div title="{}:{}" class="{}" data-bs-toggle="tooltip">{}</div>',
|
||||
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(
|
||||
'<span title="{}">{}</span>', 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(
|
||||
'<span title="{}">{}</span>', 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(
|
||||
'<a class="{klass}" href="{url}"><img class="ficon" src="{ficon}" alt="Télécharger"></a>',
|
||||
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 = '''
|
||||
<div class="long hidden">{txt_long}</div>
|
||||
<div class="short">{txt_short}</div>
|
||||
{read_more}
|
||||
'''
|
||||
read_more = '<a class="read_more" href=".">Afficher la suite</a>'
|
||||
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 '</' in value: # Considered as HTML
|
||||
return mark_safe(value)
|
||||
else:
|
||||
return linebreaksbr(escape(value))
|
||||
|
||||
|
||||
@register.filter
|
||||
def can_be_reactivated(obj, user):
|
||||
return obj.can_be_reactivated(user)
|
||||
|
||||
|
||||
@register.filter
|
||||
def archivable(famille, user):
|
||||
return famille.can_be_archived(user)
|
BIN
aemo/test.pdf
Normal file
2910
aemo/tests.py
Normal file
165
aemo/urls.py
Normal file
|
@ -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/<int:pk>/edit/', views.ContactUpdateView.as_view(), name='contact-edit'),
|
||||
path('contact/<int:pk>/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/<int:pk>/edit/', views.ServiceUpdateView.as_view(), name='service-edit'),
|
||||
path('service/<int:pk>/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/<int:pk>/edit/', views.FamilleUpdateView.as_view(), name='famille-edit'),
|
||||
path('famille/<int:obj_pk>/niveau/add/', views.NiveauCreateUpdateView.as_view(), name='niveau-add'),
|
||||
path('famille/<int:obj_pk>/niveau/<int:pk>/edit/', views.NiveauCreateUpdateView.as_view(),
|
||||
name='niveau-edit'
|
||||
),
|
||||
path('famille/<int:obj_pk>/niveau/<int:pk>/delete/', views.NiveauDeleteView.as_view(),
|
||||
name='niveau-delete'),
|
||||
|
||||
# Personne
|
||||
path('famille/<int:pk>/personne/add/', views.PersonneCreateView.as_view(),
|
||||
name="personne-add"),
|
||||
path('famille/<int:pk>/personne/<int:obj_pk>/edit/',
|
||||
views.PersonneUpdateView.as_view(), name="personne-edit"),
|
||||
path('famille/<int:pk>/personne/<int:obj_pk>/delete/',
|
||||
views.PersonneDeleteView.as_view(), name='personne-delete'),
|
||||
|
||||
path('personne/<int:pk>/formation/', views.FormationView.as_view(), name='formation'),
|
||||
path('personne/<int:pk>/contacts/', views.PersonneReseauView.as_view(),
|
||||
name='personne-reseau-list'),
|
||||
path('personne/<int:pk>/contact/add/', views.PersonneReseauAdd.as_view(),
|
||||
name='personne-reseau-add'),
|
||||
path('personne/<int:pk>/contact/<int:obj_pk>/remove/', views.PersonneReseauRemove.as_view(),
|
||||
name='personne-reseau-remove'),
|
||||
|
||||
# Prestations
|
||||
path('prestation/menu/', views.PrestationMenu.as_view(), name='prestation-menu'),
|
||||
path('famille/<int:pk>/prestation/list/', views.PrestationListView.as_view(), name='journal-list'),
|
||||
path('famille/<int:pk>/prestation/add/', views.PrestationCreateView.as_view(), name='prestation-famille-add'),
|
||||
path('famille/<int:pk>/prestation/<int:obj_pk>/edit/', views.PrestationUpdateView.as_view(),
|
||||
name='prestation-edit'),
|
||||
path('famille/<int:pk>/prestation/<int:obj_pk>/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/<int:pk>/upload/', views.DocumentUploadView.as_view(),
|
||||
name='famille-doc-upload'),
|
||||
path('famille/<int:pk>/doc/<int:doc_pk>/delete/', views.DocumentDeleteView.as_view(),
|
||||
name='famille-doc-delete'),
|
||||
path('famille/<int:pk>/reactivation/', views.FamilleReactivationView.as_view(),
|
||||
name='famille-reactivation'),
|
||||
|
||||
# Doc. à imprimer
|
||||
path('famille/<int:pk>/print-evaluation/', views.EvaluationPDFView.as_view(), name='print-evaluation'),
|
||||
path('famille/<int:pk>/print-info/', views.CoordonneesPDFView.as_view(), name='print-coord-famille'),
|
||||
path('famille/<int:pk>/print-journal', views.JournalPDFView.as_view(), name='print-journal'),
|
||||
path('bilan/<int:pk>/print/', views.BilanPDFView.as_view(), name='print-bilan'),
|
||||
|
||||
# Rapport
|
||||
path('famille/<int:pk>/rapport/add/', views.RapportCreateView.as_view(),
|
||||
name='famille-rapport-add'),
|
||||
path('famille/<int:pk>/rapport/<int:obj_pk>/', views.RapportDisplayView.as_view(),
|
||||
name='famille-rapport-view'),
|
||||
path('famille/<int:pk>/rapport/<int:obj_pk>/edit/', views.RapportUpdateView.as_view(),
|
||||
name='famille-rapport-edit'),
|
||||
path('famille/<int:pk>/rapport/<int:obj_pk>/delete/', views.RapportDeleteView.as_view(),
|
||||
name='famille-rapport-delete'),
|
||||
path('famille/<int:pk>/rapport/<int:obj_pk>/print/', views.RapportPDFView.as_view(),
|
||||
name='famille-rapport-print'),
|
||||
path('famille/<int:pk>/adresse/change/', views.FamilleAdresseChangeView.as_view(),
|
||||
name='famille-adresse-change'),
|
||||
|
||||
# Demande, suivi, agenda, suivis terminés
|
||||
path('famille/<int:pk>/demande/', views.DemandeView.as_view(), name='demande'),
|
||||
path('famille/<int:pk>/suivi/', views.SuiviView.as_view(), name='famille-suivi'),
|
||||
path('famille/<int:pk>/intervenant/add/', views.SuiviIntervenantCreate.as_view(), name='intervenant-add'),
|
||||
path('famille/<int:pk>/intervenant/<int:obj_pk>/edit/', views.SuiviIntervenantUpdateView.as_view(),
|
||||
name='intervenant-edit'),
|
||||
path('famille/<int:pk>/agenda/', views.AgendaSuiviView.as_view(), name='famille-agenda'),
|
||||
path('famille/<int:pk>/bilan/add/', views.BilanEditView.as_view(is_create=True),
|
||||
name='famille-bilan-add'),
|
||||
path('famille/<int:pk>/bilan/<int:obj_pk>/', views.BilanDetailView.as_view(),
|
||||
name='famille-bilan-view'),
|
||||
path('famille/<int:pk>/bilan/<int:obj_pk>/edit/', views.BilanEditView.as_view(),
|
||||
name='famille-bilan-edit'),
|
||||
path('famille/<int:pk>/bilan/<int:obj_pk>/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/<int:pk>/edit/', views.UtilisateurUpdateView.as_view(),
|
||||
name='utilisateur-edit'),
|
||||
path('utilisateur/add/', views.UtilisateurCreateView.as_view(),
|
||||
name='utilisateur-add'),
|
||||
path('utilisateur/<int:pk>/delete/', views.UtilisateurDeleteView.as_view(),
|
||||
name='utilisateur-delete'),
|
||||
path('utilisateur/<int:pk>/password_reinit/', views.UtilisateurPasswordReinitView.as_view(),
|
||||
name='utilisateur-password-reinit'),
|
||||
path('utilisateur/<int:pk>/otp_device/reinit/', views.UtilisateurOtpDeviceReinitView.as_view(),
|
||||
name='utilisateur-otp-device-reinit'),
|
||||
path('utilisateur/<int:pk>/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/<int:pk>/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/<int:pk>/edit/', views.CercleScolaireUpdateView.as_view(),
|
||||
name='cercle-edit'),
|
||||
path('cerclescolaire/add/', views.CercleScolaireCreateView.as_view(),
|
||||
name='cercle-add'),
|
||||
path('cerclescolaire/<int:pk>/delete/', views.CercleScolaireDeleteView.as_view(),
|
||||
name='cercle-delete'),
|
||||
|
||||
path('role/', views.RoleListView.as_view(), name='role-list'),
|
||||
path('role/<int:pk>/edit/', views.RoleUpdateView.as_view(), name='role-edit'),
|
||||
path('role/add/', views.RoleCreateView.as_view(), name='role-add'),
|
||||
path('role/<int:pk>/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')))
|
207
aemo/utils.py
Normal file
|
@ -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))
|
1673
aemo/views.py
Normal file
909
aemo/views_stats.py
Normal file
|
@ -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:
|
||||
{<counter_name>: {(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']]
|
0
archive/__init__.py
Normal file
9
archive/admin.py
Normal file
|
@ -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']
|
31
archive/forms.py
Normal file
|
@ -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
|
30
archive/migrations/0001_Add_archive_model.py
Normal file
|
@ -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',),
|
||||
},
|
||||
),
|
||||
]
|
16
archive/migrations/0002_longer_intervenant.py
Normal file
|
@ -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),
|
||||
),
|
||||
]
|
0
archive/migrations/__init__.py
Normal file
19
archive/models.py
Normal file
|
@ -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}"
|
64
archive/pdf.py
Normal file
|
@ -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
|
18
archive/templates/archive/key_upload.html
Normal file
|
@ -0,0 +1,18 @@
|
|||
{% extends 'base.html' %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-4 mt-5 p-5 border">
|
||||
<form action="." method="post" enctype="multipart/form-data">
|
||||
{% csrf_token %}
|
||||
{{ form.as_div }}
|
||||
<div id="actions" class="row border-top mt-4">
|
||||
<div class="col mt-3 text-end">
|
||||
<a class="btn btn-sm btn-secondary" href="javascript: history.go(-1)">Annuler</a>
|
||||
<button class="btn btn-sm btn-success" name="save" type="submit">Envoyer et déchiffrer</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
37
archive/templates/archive/list.html
Normal file
|
@ -0,0 +1,37 @@
|
|||
{% extends 'base.html' %}
|
||||
{% block extra_javascript %}
|
||||
<script type="text/javascript">
|
||||
$(document).ready(function() {
|
||||
$("#id_search_famille, #id_search_intervenant").keyup(debounce((ev) => {
|
||||
const form = ev.target.form;
|
||||
$.getJSON({
|
||||
url: form.action,
|
||||
data: $(form).serialize()
|
||||
}).done(function(response) {
|
||||
$("#archive_table").html(response);
|
||||
});
|
||||
}));
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row mb-5 border-bottom">
|
||||
<div class="col-2 pt-5">
|
||||
<h4>Archives {{ unite|upper }}</h4>
|
||||
</div>
|
||||
<div class="col">
|
||||
<form method="get" action="{% url 'archive-list' unite %}">
|
||||
<div class="row justify-content-end mb-3">
|
||||
<div class="col-auto">{{ form.search_famille.label_tag }} {{ form.search_famille }}</div>
|
||||
<div class="col-auto">{{form.search_intervenant.label_tag }} {{ form.search_intervenant }}</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div id="archive_table">Utilisez la recherche par nom de famille et/ou par intervenant.</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
28
archive/templates/archive/list_partial.html
Normal file
|
@ -0,0 +1,28 @@
|
|||
<table class="table table-sm table-hover">
|
||||
<tr>
|
||||
<th>Nom</th><th>Réf. {{ unite|upper }}</th><th>Réf. OPE</th><th>Date de début</th>
|
||||
<th>Date de fin</th><th>Motif de fin</th>
|
||||
{% if can_download %}
|
||||
<th width="15rem">Déchiffrement</th>
|
||||
{% endif %}
|
||||
</tr>
|
||||
{% for archive in archives %}
|
||||
<tr>
|
||||
<td>{{ archive.nom }}</td>
|
||||
<td>{{ archive.intervenant }}</td>
|
||||
<td>{{ archive.ope }}</td>
|
||||
<td>{{ archive.date_debut|default:'' }}</td>
|
||||
<td>{{ archive.date_fin }}</td>
|
||||
<td>{{ archive.motif_fin }}</td>
|
||||
{% if can_download %}
|
||||
<td align="center">
|
||||
<a class="btn btn-sm bg-success" href="{%url 'archive-decrypt' archive.pk %}">Déchiffrer</a>
|
||||
</td>
|
||||
{% endif %}
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr>
|
||||
<td colspan="7">Aucune famille pour cette recherche.</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
0
archive/tests/__init__.py
Normal file
51
archive/tests/crne_rsa
Normal file
|
@ -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-----
|
1
archive/tests/crne_rsa.pub
Normal file
|
@ -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
|
BIN
archive/tests/sample-2.msg
Normal file
BIN
archive/tests/sample.doc
Normal file
BIN
archive/tests/sample.docx
Normal file
BIN
archive/tests/sample.msg
Normal file
BIN
archive/tests/sample.pdf
Normal file
204
archive/tests/tests.py
Normal file
|
@ -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)
|
9
archive/urls.py
Normal file
|
@ -0,0 +1,9 @@
|
|||
from django.urls import path
|
||||
|
||||
from archive import views
|
||||
|
||||
urlpatterns = [
|
||||
path('<str:unite>/famille/<int:pk>/add/', views.ArchiveCreateView.as_view(), name='archive-add'),
|
||||
path('<str:unite>/list/', views.ArchiveListView.as_view(), name='archive-list'),
|
||||
path('<int:pk>/decrypt/', views.ArchiveDecryptView.as_view(), name='archive-decrypt'),
|
||||
]
|
153
archive/views.py
Normal file
|
@ -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
|
0
common/__init__.py
Normal file
99
common/choices.py
Normal file
|
@ -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é')
|
||||
)
|
26
common/fields.py
Normal file
|
@ -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)
|
31
common/formats/fr/formats.py
Normal file
|
@ -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
|
35
common/middleware.py
Normal file
|
@ -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)
|
42
common/urls.py
Normal file
|
@ -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/<path:path>', 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)
|
33
common/wsgi.py
Normal file
|
@ -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 = """
|
||||
<body>
|
||||
<h1>This site is in maintenance mode, please come back in some minutes.</h1>
|
||||
<h1>Ce site est actuellement en maintenance, merci de revenir dans quelques minutes.</h1>
|
||||
</body>
|
||||
"""
|
||||
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()
|
25
docker-compose.override.yml
Normal file
|
@ -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:
|
18
docker-compose.yml
Normal file
|
@ -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:
|
8
docker-dev/.env-db
Normal file
|
@ -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
|
3
docker-dev/.env-web
Normal file
|
@ -0,0 +1,3 @@
|
|||
ALLOWED_HOSTS=127.0.0.1,localhost
|
||||
DATABASE_URL=postgresql://db:db@db/main
|
||||
DJANGO_SETTINGS_MODULE=settings
|
26
docker-dev/Dockerfile
Normal file
|
@ -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
|