mirror of
https://github.com/Southampton-RSG/breccia-mapper.git
synced 2026-03-03 19:37:06 +00:00
Compare commits
275 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
51ac084c52 | ||
|
|
d4bb874236 | ||
|
|
3887815bbc | ||
|
|
9a90b1a432 | ||
|
|
c50bdca3eb | ||
|
|
183c8706b6 | ||
|
|
632c199ffb | ||
|
|
a4b2e085d8 | ||
|
|
93487e3541 | ||
|
|
fe5b68488f | ||
|
|
8b8a05597c | ||
|
|
055e1080dd | ||
|
|
ba6701ee67 | ||
|
|
b9b153d654 | ||
|
|
a66d7d407c | ||
|
|
a5a954ee7d | ||
|
|
40c5aed15c | ||
|
|
4a46466f1e | ||
|
|
4019749930 | ||
|
|
d2093f849a | ||
|
|
40c138de00 | ||
|
|
3ea4ea88a7 | ||
|
|
9d14cf4b38 | ||
|
|
48cce12c32 | ||
|
|
264c353b1d | ||
|
|
f37d7c77c4 | ||
|
|
479ef038d4 | ||
|
|
8f7767fa5c | ||
|
|
8ca1ba3d95 | ||
|
|
e7a113c4ee | ||
|
|
b28d714e9e | ||
|
|
1ea2c5448b | ||
|
|
c57392e83c | ||
|
|
4f1dfe16cd | ||
|
|
9ead2ab05f | ||
|
|
bc28c238ea | ||
|
|
e7542e70a5 | ||
|
|
7f01bff993 | ||
|
|
4e87176c17 | ||
|
|
6a5addc917 | ||
|
|
0e08feee01 | ||
|
|
311a90fe09 | ||
|
|
fa6a4339d8 | ||
|
|
d5f85c84c8 | ||
|
|
123a123050 | ||
|
|
adc9021002 | ||
|
|
7681e78a50 | ||
|
|
7d14fed90f | ||
|
|
bd13bb29e8 | ||
|
|
0f4e39fcaf | ||
|
|
7d1c05cfd8 | ||
|
|
cc25a154ac | ||
|
|
20812dfc40 | ||
|
|
5a2890ece1 | ||
|
|
4635e79520 | ||
|
|
78e35b70e0 | ||
|
|
9f5253834f | ||
|
|
78056c7752 | ||
|
|
2664ff6e83 | ||
|
|
9f067249de | ||
|
|
27b16c2212 | ||
|
|
81598ea624 | ||
|
|
74fffb0cac | ||
|
|
42d95beb5a | ||
|
|
8093b23870 | ||
|
|
c20b2b5a0a | ||
|
|
2c68877cc8 | ||
|
|
1de4f741b8 | ||
|
|
98e9148998 | ||
|
|
583a49fdd3 | ||
|
|
87e5e6cbf3 | ||
|
|
3ad8d4a5c9 | ||
|
|
667a51d1c7 | ||
|
|
75bc12de57 | ||
|
|
6f72244331 | ||
|
|
df13bcf46d | ||
|
|
8697d726b6 | ||
|
|
7ce47fc0ef | ||
|
|
b00ca4c1a2 | ||
|
|
af31971565 | ||
|
|
e3e8a2ada4 | ||
|
|
5d1caf98ba | ||
|
|
97473a46e2 | ||
|
|
989c8141b3 | ||
|
|
6670a87a52 | ||
|
|
e457086c50 | ||
|
|
7d74a99518 | ||
|
|
7e9a6e992a | ||
|
|
ba4f8d8ffd | ||
|
|
85c13033e8 | ||
|
|
d1dc505b37 | ||
|
|
12bc9f886f | ||
|
|
8d68e1b0c3 | ||
|
|
02b0132f9c | ||
|
|
bf2422c6dd | ||
|
|
936a375992 | ||
|
|
6d5188af72 | ||
|
|
db76d57971 | ||
|
|
b73e2dcb2d | ||
|
|
550d7cede0 | ||
|
|
71e5352b6b | ||
|
|
c8a68d542a | ||
|
|
adf12442a4 | ||
|
|
25cc33e2c1 | ||
|
|
9be49f4faf | ||
|
|
a6e8f06441 | ||
|
|
7021f05b67 | ||
|
|
0b1f303d62 | ||
|
|
8a94a46cd5 | ||
|
|
0c92554561 | ||
|
|
a24f5157cd | ||
|
|
c102cbf091 | ||
|
|
a141b93644 | ||
|
|
9164ea8a05 | ||
|
|
7783aee192 | ||
|
|
f4bd9a0cef | ||
|
|
25a28755c6 | ||
|
|
afae0fd943 | ||
|
|
4bbe4eac3a | ||
|
|
4d4d7ab70b | ||
|
|
9b98ce73f0 | ||
|
|
94b2ee9d70 | ||
|
|
6dc4bd770f | ||
|
|
3dd97b6457 | ||
|
|
f2e945c67f | ||
|
|
4cfee6362d | ||
|
|
f94627e4c8 | ||
|
|
59f717829a | ||
|
|
9db870bcb0 | ||
|
|
2d85ab4370 | ||
|
|
95fda6a3d5 | ||
|
|
90c40b2e2e | ||
|
|
20342ef671 | ||
|
|
cbc70ab85a | ||
|
|
e54c717ada | ||
|
|
efecfa8e43 | ||
|
|
2998a7e3fb | ||
|
|
b2cd5f4940 | ||
|
|
1a27774177 | ||
|
|
9e6d1a495e | ||
|
|
8e52f779ee | ||
|
|
82a235e6ff | ||
|
|
c2961b911f | ||
|
|
76fcb7ceb2 | ||
|
|
80491623de | ||
|
|
7e8dba4806 | ||
|
|
e045b084d0 | ||
|
|
6bb4f09454 | ||
|
|
2027d9d3ab | ||
|
|
0288b0320d | ||
|
|
91a47b4fdc | ||
|
|
d6e42cc18d | ||
|
|
f95e06aa18 | ||
|
|
5035b121a6 | ||
|
|
a94db2713e | ||
|
|
6435ec69a1 | ||
|
|
dde5ec9d7f | ||
|
|
a1f9510a8c | ||
|
|
8689c1a204 | ||
|
|
387cebd0d4 | ||
|
|
0e4234cb35 | ||
|
|
aafb6c0a21 | ||
|
|
b84076ec3b | ||
|
|
57349a6007 | ||
|
|
ab37139429 | ||
|
|
26bf55b4b4 | ||
|
|
9a84f77ec4 | ||
|
|
62d7a1e48c | ||
|
|
a1dc5721e6 | ||
|
|
0cceb604dd | ||
|
|
c6eda514ca | ||
|
|
8bc82b2a15 | ||
|
|
c364db4f16 | ||
|
|
82fbdd2ca1 | ||
|
|
567322c0af | ||
|
|
a39cf0e7ca | ||
|
|
b99aa77d7b | ||
|
|
efee146044 | ||
|
|
a382d93b00 | ||
|
|
aaafdef7e1 | ||
|
|
17e6255d62 | ||
|
|
1ca3c4e6c4 | ||
|
|
5dcfbb6052 | ||
|
|
d6763a760e | ||
|
|
3b3cec02be | ||
|
|
719b11e79e | ||
|
|
bf472a69fd | ||
|
|
75fc169630 | ||
|
|
19735e9771 | ||
|
|
2b0ba8d12e | ||
|
|
fa07b13fbd | ||
|
|
57c29bf01d | ||
|
|
4a1e33064c | ||
|
|
224a92f42e | ||
|
|
63e06d812d | ||
|
|
eea4342455 | ||
|
|
c7a6c72219 | ||
|
|
fef6e9d459 | ||
|
|
216e6b06fa | ||
|
|
dc69c51ac9 | ||
|
|
ada72c24f8 | ||
|
|
8f494e1be5 | ||
|
|
e5bb530214 | ||
|
|
6418c8c96a | ||
|
|
db96e65485 | ||
|
|
15aab2eb33 | ||
|
|
7d1aac2021 | ||
|
|
d02f865952 | ||
|
|
76270c4572 | ||
|
|
76ae447cc6 | ||
|
|
04ae8cb4f6 | ||
|
|
80f7fb0857 | ||
|
|
834fb3c644 | ||
|
|
1bc45b1106 | ||
|
|
74d3c1b091 | ||
|
|
da57108e3e | ||
|
|
2cdc7675c7 | ||
|
|
7c75f9d7f4 | ||
|
|
74aab162e1 | ||
|
|
af77cb39f8 | ||
|
|
416f2fbf6c | ||
|
|
9f493a53e4 | ||
|
|
d83f4bda49 | ||
|
|
8c698e821d | ||
|
|
440de19c56 | ||
|
|
09b7fc334b | ||
|
|
512590d615 | ||
|
|
6e00c42302 | ||
|
|
b2fd9eeaf9 | ||
|
|
9b3b759254 | ||
|
|
1a9ba731cf | ||
|
|
474f3a4be3 | ||
|
|
118ce6228f | ||
|
|
40afbc1908 | ||
|
|
7784ab3e09 | ||
|
|
e3ef4ed90c | ||
|
|
7ee7ed3ff0 | ||
|
|
56568093ae | ||
|
|
e410d9bcb1 | ||
|
|
097a6b0152 | ||
|
|
a7f34bbb54 | ||
|
|
0d2f1a79b2 | ||
|
|
512c02a198 | ||
|
|
507e2a43fa | ||
|
|
7891ab72da | ||
|
|
6d12202c8a | ||
|
|
e47ee453bd | ||
|
|
39127cf972 | ||
|
|
d733b6db63 | ||
|
|
224bce9853 | ||
|
|
5e6065951f | ||
|
|
9210636475 | ||
|
|
abcfd67f77 | ||
|
|
6d2737b1a6 | ||
|
|
e4a50dbfa4 | ||
|
|
5a1b043862 | ||
|
|
e1df999108 | ||
|
|
65f46a66f1 | ||
|
|
0d5d04902e | ||
|
|
db57d3c08f | ||
|
|
85a3ac082b | ||
|
|
d57c4769ae | ||
|
|
e530ddc8ec | ||
|
|
e10a6c4521 | ||
|
|
e8416bc779 | ||
|
|
4b2abd5d6c | ||
|
|
026fc10999 | ||
|
|
bc713a9fe7 | ||
|
|
3090ecda9b | ||
|
|
b7ba0d1749 | ||
|
|
701252ee4a | ||
|
|
db9859d9af | ||
|
|
6b426d2b39 | ||
|
|
1dcdd57ea1 | ||
|
|
141759479b |
18
.dockerignore
Normal file
18
.dockerignore
Normal file
@@ -0,0 +1,18 @@
|
||||
.dbbackup/
|
||||
.idea/
|
||||
.mypy_cache/
|
||||
.vagrant/
|
||||
.venv/
|
||||
venv/
|
||||
.vscode/
|
||||
|
||||
Caddyfile
|
||||
docker-compose.yml
|
||||
.env
|
||||
settings.ini
|
||||
mail.log/
|
||||
/roles/
|
||||
/static/
|
||||
*.sqlite3*
|
||||
*.log*
|
||||
deployment*
|
||||
17
.gitignore
vendored
17
.gitignore
vendored
@@ -1,12 +1,27 @@
|
||||
# IDE files
|
||||
# IDEs
|
||||
.idea/
|
||||
.vscode/
|
||||
|
||||
# Tools
|
||||
.mypy_cache/
|
||||
|
||||
# Runtime
|
||||
/static/
|
||||
mail.log/
|
||||
venv/
|
||||
|
||||
*.pyc
|
||||
db.sqlite3
|
||||
debug.log*
|
||||
|
||||
# Configuration
|
||||
.env
|
||||
settings.ini
|
||||
deployment-key*
|
||||
|
||||
# Deployment
|
||||
/.dbbackup/
|
||||
.vagrant/
|
||||
/custom
|
||||
staging.yml
|
||||
production.yml
|
||||
|
||||
4
.style.yapf
Normal file
4
.style.yapf
Normal file
@@ -0,0 +1,4 @@
|
||||
[style]
|
||||
allow_split_before_dict_value=false
|
||||
column_limit=100
|
||||
dedent_closing_brackets=true
|
||||
15
Caddyfile
Normal file
15
Caddyfile
Normal file
@@ -0,0 +1,15 @@
|
||||
:80 :443 {
|
||||
root * /srv
|
||||
file_server
|
||||
|
||||
@proxy_paths {
|
||||
not path /static/*
|
||||
}
|
||||
|
||||
reverse_proxy @proxy_paths http://web:8000
|
||||
|
||||
log {
|
||||
output stderr
|
||||
format single_field common_log
|
||||
}
|
||||
}
|
||||
17
Dockerfile
Normal file
17
Dockerfile
Normal file
@@ -0,0 +1,17 @@
|
||||
FROM python:3.8-slim
|
||||
|
||||
RUN groupadd -r mapper && useradd --no-log-init -r -g mapper mapper
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt ./
|
||||
|
||||
RUN pip install --no-cache-dir --upgrade pip \
|
||||
&& pip install --no-cache-dir -r /app/requirements.txt gunicorn
|
||||
|
||||
COPY . ./
|
||||
|
||||
# USER mapper
|
||||
|
||||
ENTRYPOINT [ "/app/entrypoint.sh" ]
|
||||
CMD [ "gunicorn", "-w", "2", "-b", "0.0.0.0:8000", "breccia_mapper.wsgi" ]
|
||||
674
LICENSE
Normal file
674
LICENSE
Normal file
@@ -0,0 +1,674 @@
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 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 General Public License is a free, copyleft license for
|
||||
software and other kinds of works.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
the GNU General Public License is 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. We, the Free Software Foundation, use the
|
||||
GNU General Public License for most of our software; it applies also to
|
||||
any other work released this way by its authors. You can apply it to
|
||||
your programs, too.
|
||||
|
||||
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.
|
||||
|
||||
To protect your rights, we need to prevent others from denying you
|
||||
these rights or asking you to surrender the rights. Therefore, you have
|
||||
certain responsibilities if you distribute copies of the software, or if
|
||||
you modify it: responsibilities to respect the freedom of others.
|
||||
|
||||
For example, if you distribute copies of such a program, whether
|
||||
gratis or for a fee, you must pass on to the recipients the same
|
||||
freedoms that you received. You must make sure that they, too, receive
|
||||
or can get the source code. And you must show them these terms so they
|
||||
know their rights.
|
||||
|
||||
Developers that use the GNU GPL protect your rights with two steps:
|
||||
(1) assert copyright on the software, and (2) offer you this License
|
||||
giving you legal permission to copy, distribute and/or modify it.
|
||||
|
||||
For the developers' and authors' protection, the GPL clearly explains
|
||||
that there is no warranty for this free software. For both users' and
|
||||
authors' sake, the GPL requires that modified versions be marked as
|
||||
changed, so that their problems will not be attributed erroneously to
|
||||
authors of previous versions.
|
||||
|
||||
Some devices are designed to deny users access to install or run
|
||||
modified versions of the software inside them, although the manufacturer
|
||||
can do so. This is fundamentally incompatible with the aim of
|
||||
protecting users' freedom to change the software. The systematic
|
||||
pattern of such abuse occurs in the area of products for individuals to
|
||||
use, which is precisely where it is most unacceptable. Therefore, we
|
||||
have designed this version of the GPL to prohibit the practice for those
|
||||
products. If such problems arise substantially in other domains, we
|
||||
stand ready to extend this provision to those domains in future versions
|
||||
of the GPL, as needed to protect the freedom of users.
|
||||
|
||||
Finally, every program is threatened constantly by software patents.
|
||||
States should not allow patents to restrict development and use of
|
||||
software on general-purpose computers, but in those that do, we wish to
|
||||
avoid the special danger that patents applied to a free program could
|
||||
make it effectively proprietary. To prevent this, the GPL assures that
|
||||
patents cannot be used to render the program non-free.
|
||||
|
||||
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 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. Use with the GNU Affero General Public License.
|
||||
|
||||
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 Affero 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 special requirements of the GNU Affero General Public License,
|
||||
section 13, concerning interaction through a network will apply to the
|
||||
combination as such.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU 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 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 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 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 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 General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU 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 the program does terminal interaction, make it output a short
|
||||
notice like this when it starts in an interactive mode:
|
||||
|
||||
<program> Copyright (C) <year> <name of author>
|
||||
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||
This is free software, and you are welcome to redistribute it
|
||||
under certain conditions; type `show c' for details.
|
||||
|
||||
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||
parts of the General Public License. Of course, your program's commands
|
||||
might be different; for a GUI interface, you would use an "about box".
|
||||
|
||||
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 GPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
|
||||
The GNU General Public License does not permit incorporating your program
|
||||
into proprietary programs. If your program is a subroutine library, you
|
||||
may consider it more useful to permit linking proprietary applications with
|
||||
the library. If this is what you want to do, use the GNU Lesser General
|
||||
Public License instead of this License. But first, please read
|
||||
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
||||
16
Makefile
Normal file
16
Makefile
Normal file
@@ -0,0 +1,16 @@
|
||||
.PHONY: docs
|
||||
docs:
|
||||
cd docs; make clean; make html; cd ..
|
||||
yes 'yes' | env/bin/python manage.py collectstatic
|
||||
|
||||
.PHONY: lint
|
||||
lint:
|
||||
prospector
|
||||
|
||||
.PHONY: staging
|
||||
staging:
|
||||
env ANSIBLE_STDOUT_CALLBACK=debug ansible-playbook -v -i staging.yml playbook.yml -u jag1e17 -K
|
||||
|
||||
.PHONY: production
|
||||
production:
|
||||
env ANSIBLE_STDOUT_CALLBACK=debug ansible-playbook -v -i production.yml playbook.yml -u jag1e17 -K
|
||||
28
README.md
Normal file
28
README.md
Normal file
@@ -0,0 +1,28 @@
|
||||
# BRECcIA Mapper
|
||||
|
||||
BRECcIA Mapper is a web app to collect and explore data about the relationships between researchers and their stakeholders on large-scale, multi-site research projects.
|
||||
This allows researchers to visually represent the relationships between project staff and stakeholders involved in the their project at different points in time.
|
||||
Through this it is possible to explore the extent of networks and change over time, and identify where new relationships can be developed or existing ones strengthened.
|
||||
|
||||
This work was funded through the "Building REsearch Capacity for sustainable water and food security In drylands of sub-saharan Africa" (BRECcIA) project which is supported by UK Research and Innovation as part of the Global Challenges Research Fund, grant number NE/P021093/1.
|
||||
|
||||
## Deployment
|
||||
|
||||
This project is written in Python using the popular [Django](https://www.djangoproject.com/) framework.
|
||||
Deployment is managed using [Ansible](https://www.ansible.com/) and Docker (https://www.docker.com/), see the `deploy/README.md` for details.
|
||||
|
||||
## Contributors
|
||||
|
||||
- James Graham (@jag1g13) - developer
|
||||
- Genevieve Agaba
|
||||
- Sebastian Reichel
|
||||
- Claire Bedelian
|
||||
- Eunice Shame
|
||||
- Fiona Ngarachu
|
||||
- Gertrude Domfeh
|
||||
- Henry Hunga
|
||||
- Julie Reeves
|
||||
|
||||
## License
|
||||
|
||||
GPL-3.0 © University of Southampton
|
||||
0
activities/__init__.py
Normal file
0
activities/__init__.py
Normal file
27
activities/admin.py
Normal file
27
activities/admin.py
Normal file
@@ -0,0 +1,27 @@
|
||||
"""
|
||||
Admin site panels for models in the Activities app.
|
||||
"""
|
||||
|
||||
from django.contrib import admin
|
||||
|
||||
from . import models
|
||||
|
||||
|
||||
@admin.register(models.ActivityType)
|
||||
class ActivityTypeAdmin(admin.ModelAdmin):
|
||||
pass
|
||||
|
||||
|
||||
@admin.register(models.ActivityMedium)
|
||||
class ActivityMediumAdmin(admin.ModelAdmin):
|
||||
pass
|
||||
|
||||
|
||||
@admin.register(models.ActivitySeries)
|
||||
class ActivitySeriesAdmin(admin.ModelAdmin):
|
||||
pass
|
||||
|
||||
|
||||
@admin.register(models.Activity)
|
||||
class ActivityAdmin(admin.ModelAdmin):
|
||||
pass
|
||||
5
activities/apps.py
Normal file
5
activities/apps.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class ActivitiesConfig(AppConfig):
|
||||
name = 'activities'
|
||||
14
activities/forms.py
Normal file
14
activities/forms.py
Normal file
@@ -0,0 +1,14 @@
|
||||
from django import forms
|
||||
|
||||
from . import models
|
||||
|
||||
|
||||
class ActivityForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = models.Activity
|
||||
fields = [
|
||||
'name',
|
||||
'series',
|
||||
'type',
|
||||
'medium',
|
||||
]
|
||||
36
activities/migrations/0001_initial.py
Normal file
36
activities/migrations/0001_initial.py
Normal file
@@ -0,0 +1,36 @@
|
||||
# Generated by Django 2.2.9 on 2020-02-14 09:47
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='ActivitySeries',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=255)),
|
||||
],
|
||||
options={
|
||||
'verbose_name_plural': 'activity series',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Activity',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=255)),
|
||||
('series', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='instances', to='activities.ActivitySeries')),
|
||||
],
|
||||
options={
|
||||
'verbose_name_plural': 'activities',
|
||||
},
|
||||
),
|
||||
]
|
||||
52
activities/migrations/0002_activity_type_medium.py
Normal file
52
activities/migrations/0002_activity_type_medium.py
Normal file
@@ -0,0 +1,52 @@
|
||||
# Generated by Django 2.2.10 on 2020-02-19 13:01
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('activities', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='ActivityMedium',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=255, unique=True)),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ActivityType',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=255, unique=True)),
|
||||
],
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='activity',
|
||||
name='medium',
|
||||
field=models.ForeignKey(default=None, on_delete=django.db.models.deletion.PROTECT, to='activities.ActivityMedium'),
|
||||
preserve_default=False,
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='activity',
|
||||
name='type',
|
||||
field=models.ForeignKey(default=None, on_delete=django.db.models.deletion.PROTECT, to='activities.ActivityType'),
|
||||
preserve_default=False,
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='activityseries',
|
||||
name='medium',
|
||||
field=models.ForeignKey(default=None, on_delete=django.db.models.deletion.PROTECT, to='activities.ActivityMedium'),
|
||||
preserve_default=False,
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='activityseries',
|
||||
name='type',
|
||||
field=models.ForeignKey(default=None, on_delete=django.db.models.deletion.PROTECT, to='activities.ActivityType'),
|
||||
preserve_default=False,
|
||||
),
|
||||
]
|
||||
19
activities/migrations/0003_rename_activity_series_fk.py
Normal file
19
activities/migrations/0003_rename_activity_series_fk.py
Normal file
@@ -0,0 +1,19 @@
|
||||
# Generated by Django 2.2.10 on 2020-02-19 13:32
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('activities', '0002_activity_type_medium'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='activity',
|
||||
name='series',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='activities', to='activities.ActivitySeries'),
|
||||
),
|
||||
]
|
||||
19
activities/migrations/0004_activity_attendance_list.py
Normal file
19
activities/migrations/0004_activity_attendance_list.py
Normal file
@@ -0,0 +1,19 @@
|
||||
# Generated by Django 2.2.10 on 2020-02-19 15:20
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('people', '0005_user_one_person'),
|
||||
('activities', '0003_rename_activity_series_fk'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='activity',
|
||||
name='attendance_list',
|
||||
field=models.ManyToManyField(related_name='activities', to='people.Person'),
|
||||
),
|
||||
]
|
||||
23
activities/migrations/0005_shrink_name_fields_to_255.py
Normal file
23
activities/migrations/0005_shrink_name_fields_to_255.py
Normal file
@@ -0,0 +1,23 @@
|
||||
# Generated by Django 2.2.10 on 2020-02-28 15:03
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('activities', '0004_activity_attendance_list'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='activity',
|
||||
name='name',
|
||||
field=models.CharField(max_length=255),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='activityseries',
|
||||
name='name',
|
||||
field=models.CharField(max_length=255),
|
||||
),
|
||||
]
|
||||
18
activities/migrations/0006_activity_attendance_optional.py
Normal file
18
activities/migrations/0006_activity_attendance_optional.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 2.2.10 on 2020-04-02 15:57
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('activities', '0005_shrink_name_fields_to_255'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='activity',
|
||||
name='attendance_list',
|
||||
field=models.ManyToManyField(blank=True, related_name='activities', to='people.Person'),
|
||||
),
|
||||
]
|
||||
0
activities/migrations/__init__.py
Normal file
0
activities/migrations/__init__.py
Normal file
99
activities/models.py
Normal file
99
activities/models.py
Normal file
@@ -0,0 +1,99 @@
|
||||
from django.db import models
|
||||
from django.urls import reverse
|
||||
|
||||
|
||||
from people import models as people_models
|
||||
|
||||
|
||||
class ActivityType(models.Model):
|
||||
"""
|
||||
Representation of the type of activity being conducted.
|
||||
|
||||
Examples may include: 'research activity' or 'stakeholder engagement'.
|
||||
"""
|
||||
#: Name of this type of activity
|
||||
name = models.CharField(max_length=255,
|
||||
unique=True,
|
||||
blank=False, null=False)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.name
|
||||
|
||||
|
||||
class ActivityMedium(models.Model):
|
||||
"""
|
||||
Representation of the medium via which the activity is conducted.
|
||||
|
||||
Examples may include: 'face to face' or 'virtual'.
|
||||
"""
|
||||
#: Name of this medium
|
||||
name = models.CharField(max_length=255,
|
||||
unique=True,
|
||||
blank=False, null=False)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.name
|
||||
|
||||
|
||||
class ActivitySeries(models.Model):
|
||||
"""
|
||||
A series of related :class:`Activity`s.
|
||||
"""
|
||||
class Meta:
|
||||
verbose_name_plural = 'activity series'
|
||||
|
||||
#: Name of activity series
|
||||
name = models.CharField(max_length=255,
|
||||
blank=False, null=False)
|
||||
|
||||
#: What type of activity does this series represent?
|
||||
type = models.ForeignKey(ActivityType,
|
||||
on_delete=models.PROTECT,
|
||||
blank=False, null=False)
|
||||
|
||||
#: How are activities in this series typically conducted?
|
||||
medium = models.ForeignKey(ActivityMedium,
|
||||
on_delete=models.PROTECT,
|
||||
blank=False, null=False)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.name
|
||||
|
||||
|
||||
class Activity(models.Model):
|
||||
"""
|
||||
An instance of an activity - e.g. a workshop.
|
||||
"""
|
||||
class Meta:
|
||||
verbose_name_plural = 'activities'
|
||||
|
||||
#: Name of activity
|
||||
name = models.CharField(max_length=255,
|
||||
blank=False, null=False)
|
||||
|
||||
#: Optional :class:`ActivitySeries` to which this activity belongs
|
||||
series = models.ForeignKey(ActivitySeries,
|
||||
related_name='activities',
|
||||
on_delete=models.PROTECT,
|
||||
blank=True, null=True)
|
||||
|
||||
#: What type of activity is this?
|
||||
type = models.ForeignKey(ActivityType,
|
||||
on_delete=models.PROTECT,
|
||||
blank=False, null=False)
|
||||
|
||||
#: How was this activity conducted?
|
||||
medium = models.ForeignKey(ActivityMedium,
|
||||
on_delete=models.PROTECT,
|
||||
blank=False, null=False)
|
||||
|
||||
#: Who attended this activity?
|
||||
attendance_list = models.ManyToManyField(people_models.Person,
|
||||
related_name='activities',
|
||||
blank=True)
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('activities:activity.detail', kwargs={'pk': self.pk})
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.name
|
||||
29
activities/templates/activities/activity/create.html
Normal file
29
activities/templates/activities/activity/create.html
Normal file
@@ -0,0 +1,29 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block content %}
|
||||
<nav aria-label="breadcrumb">
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item">
|
||||
<a href="{% url 'activities:activity.list' %}">Activities</a>
|
||||
</li>
|
||||
<li class="breadcrumb-item active" aria-current="page">Create</li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<h1>New Activity</h1>
|
||||
|
||||
<hr>
|
||||
|
||||
<form class="form"
|
||||
method="POST">
|
||||
{% csrf_token %}
|
||||
|
||||
{% load bootstrap4 %}
|
||||
{% bootstrap_form form %}
|
||||
|
||||
{% buttons %}
|
||||
<button class="btn btn-success" type="submit">Submit</button>
|
||||
{% endbuttons %}
|
||||
</form>
|
||||
|
||||
{% endblock %}
|
||||
131
activities/templates/activities/activity/detail.html
Normal file
131
activities/templates/activities/activity/detail.html
Normal file
@@ -0,0 +1,131 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block content %}
|
||||
<nav aria-label="breadcrumb">
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item">
|
||||
<a href="{% url 'activities:activity.list' %}">Activities</a>
|
||||
</li>
|
||||
<li class="breadcrumb-item active" aria-current="page">{{ object }}</li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<h1>{{ activity.name }}</h1>
|
||||
|
||||
{% if user_is_attending %}
|
||||
<button class="btn btn-danger"
|
||||
onclick="clickCancelAttend();">
|
||||
Cancel Attendance
|
||||
</button>
|
||||
|
||||
{% else %}
|
||||
<button class="btn btn-success"
|
||||
onclick="clickAttend();">
|
||||
Attend
|
||||
</button>
|
||||
|
||||
{% endif %}
|
||||
|
||||
<hr>
|
||||
|
||||
<dl>
|
||||
<dt>Series</dt>
|
||||
<dl>{{ activity.series|default_if_none:'Standalone Activity' }}</dl>
|
||||
|
||||
<dt>Type</dt>
|
||||
<dd>{{ activity.type }}</dd>
|
||||
|
||||
<dt>Medium</dt>
|
||||
<dd>{{ activity.medium }}</dd>
|
||||
</dl>
|
||||
|
||||
<hr>
|
||||
|
||||
<h2>Attendance</h2>
|
||||
|
||||
<table class="table table-borderless">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
{% for person in activity.attendance_list.all %}
|
||||
<tr>
|
||||
<td>{{ person }}</td>
|
||||
<td>
|
||||
<a class="btn btn-sm btn-info"
|
||||
href="{% url 'people:person.detail' pk=person.pk %}">Profile</a>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
{% empty %}
|
||||
<tr>
|
||||
<td>No records</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
|
||||
{% endblock %}
|
||||
|
||||
|
||||
{% block extra_script %}
|
||||
<script type="application/javascript">
|
||||
/**
|
||||
* Get the value of a named cookie.
|
||||
*/
|
||||
function getCookie(name) {
|
||||
for (const cookie of document.cookie.split(';')) {
|
||||
const tokens = cookie.split('=');
|
||||
if (tokens[0].trim() === name) {
|
||||
return tokens[1].trim();
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit that user is attending this activity.
|
||||
*/
|
||||
function clickAttend() {
|
||||
$.ajax({
|
||||
url: '{% url "activities:activity.attendance" pk=activity.pk %}',
|
||||
type: 'POST',
|
||||
contentType: 'application/json',
|
||||
headers: {
|
||||
'X-CSRFToken': getCookie('csrftoken')
|
||||
},
|
||||
data: JSON.stringify({
|
||||
pk: {{ request.user.person.pk }}
|
||||
}),
|
||||
success: function() {
|
||||
location.reload()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit that user is not attending this activity.
|
||||
*/
|
||||
function clickCancelAttend() {
|
||||
$.ajax({
|
||||
url: '{% url "activities:activity.attendance" pk=activity.pk %}',
|
||||
type: 'DELETE',
|
||||
contentType: 'application/json',
|
||||
headers: {
|
||||
'X-CSRFToken': getCookie('csrftoken')
|
||||
},
|
||||
data: JSON.stringify({
|
||||
pk: {{ request.user.person.pk }}
|
||||
}),
|
||||
success: function() {
|
||||
location.reload()
|
||||
}
|
||||
})
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
42
activities/templates/activities/activity/list.html
Normal file
42
activities/templates/activities/activity/list.html
Normal file
@@ -0,0 +1,42 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block content %}
|
||||
<nav aria-label="breadcrumb">
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item active" aria-current="page">Activities</li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<h1>Activities</h1>
|
||||
|
||||
<hr>
|
||||
|
||||
<a class="btn btn-success"
|
||||
href="{% url 'activities:activity.create' %}">New Activity</a>
|
||||
|
||||
<table class="table table-borderless">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
{% for activity in activity_list.all %}
|
||||
<tr>
|
||||
<td>{{ activity }}</td>
|
||||
<td>
|
||||
<a class="btn btn-sm btn-info"
|
||||
href="{% url 'activities:activity.detail' pk=activity.pk %}">Details</a>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
{% empty %}
|
||||
<tr>
|
||||
<td>No records</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{% endblock %}
|
||||
52
activities/templates/activities/activity_series/detail.html
Normal file
52
activities/templates/activities/activity_series/detail.html
Normal file
@@ -0,0 +1,52 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block content %}
|
||||
<nav aria-label="breadcrumb">
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item">
|
||||
<a href="{% url 'activities:activity-series.list' %}">Activity Series</a>
|
||||
</li>
|
||||
<li class="breadcrumb-item active" aria-current="page">{{ object }}</li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<h1>{{ activity_series.name }}</h1>
|
||||
|
||||
<hr>
|
||||
|
||||
<dl>
|
||||
<dt>Type</dt>
|
||||
<dd>{{ activity_series.type }}</dd>
|
||||
|
||||
<dt>Medium</dt>
|
||||
<dd>{{ activity_series.medium }}</dd>
|
||||
</dl>
|
||||
|
||||
<hr>
|
||||
|
||||
<table class="table table-borderless">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
{% for activity in activity_series.activities.all %}
|
||||
<tr>
|
||||
<td>{{ activity }}</td>
|
||||
<td>
|
||||
<a class="btn btn-sm btn-info"
|
||||
href="{% url 'activities:activity.detail' pk=activity.pk %}">Details</a>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
{% empty %}
|
||||
<tr>
|
||||
<td>No records</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{% endblock %}
|
||||
39
activities/templates/activities/activity_series/list.html
Normal file
39
activities/templates/activities/activity_series/list.html
Normal file
@@ -0,0 +1,39 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block content %}
|
||||
<nav aria-label="breadcrumb">
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item active" aria-current="page">Activity Series</li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<h1>Activity Series</h1>
|
||||
|
||||
<hr>
|
||||
|
||||
<table class="table table-borderless">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
{% for activity_series in activity_series_list.all %}
|
||||
<tr>
|
||||
<td>{{ activity_series }}</td>
|
||||
<td>
|
||||
<a class="btn btn-sm btn-info"
|
||||
href="{% url 'activities:activity-series.detail' pk=activity_series.pk %}">Details</a>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
{% empty %}
|
||||
<tr>
|
||||
<td>No records</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{% endblock %}
|
||||
32
activities/urls.py
Normal file
32
activities/urls.py
Normal file
@@ -0,0 +1,32 @@
|
||||
from django.urls import path
|
||||
|
||||
from . import views
|
||||
|
||||
|
||||
app_name = 'activities'
|
||||
|
||||
urlpatterns = [
|
||||
path('activity-series',
|
||||
views.ActivitySeriesListView.as_view(),
|
||||
name='activity-series.list'),
|
||||
|
||||
path('activity-series/<int:pk>',
|
||||
views.ActivitySeriesDetailView.as_view(),
|
||||
name='activity-series.detail'),
|
||||
|
||||
path('activities/create',
|
||||
views.ActivityCreateView.as_view(),
|
||||
name='activity.create'),
|
||||
|
||||
path('activities',
|
||||
views.ActivityListView.as_view(),
|
||||
name='activity.list'),
|
||||
|
||||
path('activities/<int:pk>',
|
||||
views.ActivityDetailView.as_view(),
|
||||
name='activity.detail'),
|
||||
|
||||
path('activities/<int:pk>/attendance',
|
||||
views.ActivityAttendanceView.as_view(),
|
||||
name='activity.attendance'),
|
||||
]
|
||||
97
activities/views.py
Normal file
97
activities/views.py
Normal file
@@ -0,0 +1,97 @@
|
||||
"""
|
||||
Views for displaying / manipulating models within the Activities app.
|
||||
"""
|
||||
import json
|
||||
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
from django.http import HttpResponse
|
||||
from django.views.generic import CreateView, DetailView, ListView, View
|
||||
from django.views.generic.detail import SingleObjectMixin
|
||||
|
||||
from people import models as people_models
|
||||
from people import permissions
|
||||
from . import forms, models
|
||||
|
||||
|
||||
class ActivitySeriesListView(LoginRequiredMixin, ListView):
|
||||
"""
|
||||
View displaying a list of :class:`ActivitySeries`.
|
||||
"""
|
||||
model = models.ActivitySeries
|
||||
template_name = 'activities/activity_series/list.html'
|
||||
context_object_name = 'activity_series_list'
|
||||
|
||||
|
||||
class ActivitySeriesDetailView(LoginRequiredMixin, DetailView):
|
||||
"""
|
||||
View displaying details of a single :class:`ActivitySeries`.
|
||||
"""
|
||||
model = models.ActivitySeries
|
||||
template_name = 'activities/activity_series/detail.html'
|
||||
context_object_name = 'activity_series'
|
||||
|
||||
|
||||
class ActivityCreateView(LoginRequiredMixin, CreateView):
|
||||
"""
|
||||
View to create a new instance of :class:`Activity`.
|
||||
"""
|
||||
model = models.Activity
|
||||
template_name = 'activities/activity/create.html'
|
||||
form_class = forms.ActivityForm
|
||||
|
||||
|
||||
class ActivityListView(LoginRequiredMixin, ListView):
|
||||
"""
|
||||
View displaying a list of :class:`Activity`.
|
||||
"""
|
||||
model = models.Activity
|
||||
template_name = 'activities/activity/list.html'
|
||||
|
||||
|
||||
class ActivityDetailView(LoginRequiredMixin, DetailView):
|
||||
"""
|
||||
View displaying details of a single :class:`Activity`.
|
||||
"""
|
||||
model = models.Activity
|
||||
template_name = 'activities/activity/detail.html'
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
|
||||
context['user_is_attending'] = self.object.attendance_list.filter(
|
||||
pk=self.request.user.person.pk
|
||||
).exists()
|
||||
|
||||
return context
|
||||
|
||||
|
||||
class ActivityAttendanceView(permissions.UserIsLinkedPersonMixin, SingleObjectMixin, View):
|
||||
"""
|
||||
View to add or delete attendance of an activity.
|
||||
"""
|
||||
model = models.Activity
|
||||
|
||||
def get_test_person(self) -> people_models.Person:
|
||||
data = json.loads(self.request.body)
|
||||
|
||||
return people_models.Person.objects.get(pk=data['pk'])
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
self.object = self.get_object()
|
||||
|
||||
if request.is_ajax():
|
||||
self.object.attendance_list.add(self.get_test_person())
|
||||
|
||||
return HttpResponse(status=204)
|
||||
|
||||
return HttpResponse("URL does not support non-AJAX requests", status=400)
|
||||
|
||||
def delete(self, request, *args, **kwargs):
|
||||
self.object = self.get_object()
|
||||
|
||||
if request.is_ajax():
|
||||
self.object.attendance_list.remove(self.get_test_person())
|
||||
|
||||
return HttpResponse(status=204)
|
||||
|
||||
return HttpResponse("URL does not support non-AJAX requests", status=400)
|
||||
6
backports/__init__.py
Normal file
6
backports/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""
|
||||
These files are backported functionality from future versions of Django.
|
||||
|
||||
- models.db.enums - copied from Django 3.0.3
|
||||
See https://github.com/django/django/blob/3.0.3/django/db/models/enums.py
|
||||
"""
|
||||
0
backports/db/__init__.py
Normal file
0
backports/db/__init__.py
Normal file
0
backports/db/models/__init__.py
Normal file
0
backports/db/models/__init__.py
Normal file
82
backports/db/models/enums.py
Normal file
82
backports/db/models/enums.py
Normal file
@@ -0,0 +1,82 @@
|
||||
import enum
|
||||
|
||||
from django.utils.functional import Promise
|
||||
|
||||
__all__ = ['Choices', 'IntegerChoices', 'TextChoices']
|
||||
|
||||
|
||||
class ChoicesMeta(enum.EnumMeta):
|
||||
"""A metaclass for creating a enum choices."""
|
||||
|
||||
def __new__(metacls, classname, bases, classdict):
|
||||
labels = []
|
||||
for key in classdict._member_names:
|
||||
value = classdict[key]
|
||||
if (
|
||||
isinstance(value, (list, tuple)) and
|
||||
len(value) > 1 and
|
||||
isinstance(value[-1], (Promise, str))
|
||||
):
|
||||
*value, label = value
|
||||
value = tuple(value)
|
||||
else:
|
||||
label = key.replace('_', ' ').title()
|
||||
labels.append(label)
|
||||
# Use dict.__setitem__() to suppress defenses against double
|
||||
# assignment in enum's classdict.
|
||||
dict.__setitem__(classdict, key, value)
|
||||
cls = super().__new__(metacls, classname, bases, classdict)
|
||||
cls._value2label_map_ = dict(zip(cls._value2member_map_, labels))
|
||||
# Add a label property to instances of enum which uses the enum member
|
||||
# that is passed in as "self" as the value to use when looking up the
|
||||
# label in the choices.
|
||||
cls.label = property(lambda self: cls._value2label_map_.get(self.value))
|
||||
cls.do_not_call_in_templates = True
|
||||
return enum.unique(cls)
|
||||
|
||||
def __contains__(cls, member):
|
||||
if not isinstance(member, enum.Enum):
|
||||
# Allow non-enums to match against member values.
|
||||
return member in {x.value for x in cls}
|
||||
return super().__contains__(member)
|
||||
|
||||
@property
|
||||
def names(cls):
|
||||
empty = ['__empty__'] if hasattr(cls, '__empty__') else []
|
||||
return empty + [member.name for member in cls]
|
||||
|
||||
@property
|
||||
def choices(cls):
|
||||
empty = [(None, cls.__empty__)] if hasattr(cls, '__empty__') else []
|
||||
return empty + [(member.value, member.label) for member in cls]
|
||||
|
||||
@property
|
||||
def labels(cls):
|
||||
return [label for _, label in cls.choices]
|
||||
|
||||
@property
|
||||
def values(cls):
|
||||
return [value for value, _ in cls.choices]
|
||||
|
||||
|
||||
class Choices(enum.Enum, metaclass=ChoicesMeta):
|
||||
"""Class for creating enumerated choices."""
|
||||
|
||||
def __str__(self):
|
||||
"""
|
||||
Use value when cast to str, so that Choices set as model instance
|
||||
attributes are rendered as expected in templates and similar contexts.
|
||||
"""
|
||||
return str(self.value)
|
||||
|
||||
|
||||
class IntegerChoices(int, Choices):
|
||||
"""Class for creating enumerated integer choices."""
|
||||
pass
|
||||
|
||||
|
||||
class TextChoices(str, Choices):
|
||||
"""Class for creating enumerated string choices."""
|
||||
|
||||
def _generate_next_value_(name, start, count, last_values):
|
||||
return name
|
||||
15
breccia_mapper/forms.py
Normal file
15
breccia_mapper/forms.py
Normal file
@@ -0,0 +1,15 @@
|
||||
from django import forms
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
User = get_user_model() # pylint: disable=invalid-name
|
||||
|
||||
|
||||
class ConsentForm(forms.ModelForm):
|
||||
"""Form used to collect user consent for data collection / processing."""
|
||||
class Meta:
|
||||
model = User
|
||||
fields = ['consent_given']
|
||||
labels = {
|
||||
'consent_given':
|
||||
'I have read and understood this information and consent to my data being used in this way',
|
||||
}
|
||||
@@ -11,33 +11,127 @@ https://docs.djangoproject.com/en/2.2/ref/settings/
|
||||
|
||||
Before production deployment, see
|
||||
https://docs.djangoproject.com/en/2.2/howto/deployment/checklist/
|
||||
|
||||
|
||||
Many configuration settings are input from `settings.ini`.
|
||||
The most likely required settings are: SECRET_KEY, DEBUG, ALLOWED_HOSTS, DATABASE_URL, PROJECT_*_NAME, EMAIL_*
|
||||
|
||||
- PARENT_PROJECT_NAME
|
||||
default: Parent Project Name
|
||||
Displayed in templates where the name of the parent project should be used
|
||||
|
||||
- PROJECT_LONG_NAME
|
||||
default: Project Long Name
|
||||
Displayed in templates where the full name of the project should be used
|
||||
|
||||
- PROJECT_SHORT_NAME
|
||||
default: shortname
|
||||
Displayed in templates where a short identifier for the project should be used
|
||||
|
||||
- SECRET_KEY (REQUIRED)
|
||||
Used to generate CSRF tokens - must never be made public
|
||||
|
||||
- DEBUG
|
||||
default: False
|
||||
Should the server run in debug mode? Provides information to users which is unsafe in production
|
||||
|
||||
- ALLOWED_HOSTS
|
||||
default: * if DEBUG else localhost
|
||||
Accepted values for server header in request - protects against CSRF and CSS attacks
|
||||
|
||||
- DATABASE_URL
|
||||
default: sqlite://db.sqlite3
|
||||
URL to database - uses format described at https://github.com/jacobian/dj-database-url
|
||||
|
||||
- DBBACKUP_STORAGE_LOCATION
|
||||
default: .dbbackup
|
||||
Directory where database backups should be stored
|
||||
|
||||
- LANGUAGE_CODE
|
||||
default: en-gb
|
||||
Default language - used for translation - has not been enabled
|
||||
|
||||
- TIME_ZONE
|
||||
default: UTC
|
||||
Default timezone
|
||||
|
||||
- LOG_LEVEL
|
||||
default: INFO
|
||||
Level of messages written to log file
|
||||
|
||||
- LOG_FILENAME
|
||||
default: debug.log
|
||||
Path to logfile
|
||||
|
||||
- LOG_DAYS
|
||||
default: 14
|
||||
Number of days of logs to keep - logfile is rotated out at the end of each day
|
||||
|
||||
- EMAIL_HOST
|
||||
default: None
|
||||
Hostname of SMTP server
|
||||
|
||||
- DEFAULT_FROM_EMAIL
|
||||
default: None
|
||||
Email address from which messages are sent
|
||||
|
||||
- EMAIL_FILE_PATH (debug only)
|
||||
default: mail.log
|
||||
Directory where emails will be stored if not using an SMTP server
|
||||
|
||||
- EMAIL_HOST_USER
|
||||
default: None
|
||||
Username to authenticate with SMTP server
|
||||
|
||||
- EMAIL_HOST_PASSWORD
|
||||
default: None
|
||||
Password to authenticate with SMTP server
|
||||
|
||||
- EMAIL_PORT
|
||||
default: 25
|
||||
Port to access on SMTP server
|
||||
|
||||
- EMAIL_USE_TLS
|
||||
default: True if EMAIL_PORT == 587 else False
|
||||
Use TLS to communicate with SMTP server? Usually on port 587
|
||||
|
||||
- EMAIL_USE_SSL
|
||||
default: True if EMAIL_PORT == 465 else False
|
||||
Use SSL to communicate with SMTP server? Usually on port 465
|
||||
|
||||
- GOOGLE_MAPS_API_KEY
|
||||
default: None
|
||||
Google Maps API key to display maps of people's locations
|
||||
"""
|
||||
|
||||
import collections
|
||||
import logging
|
||||
import logging.config
|
||||
import pathlib
|
||||
|
||||
from django.urls import reverse_lazy
|
||||
|
||||
from decouple import config, Csv
|
||||
import dj_database_url
|
||||
|
||||
|
||||
# Settings exported to templates
|
||||
# https://github.com/jakubroztocil/django-settings-export
|
||||
|
||||
SETTINGS_EXPORT = [
|
||||
'DEBUG',
|
||||
'PARENT_PROJECT_NAME',
|
||||
'PROJECT_LONG_NAME',
|
||||
'PROJECT_SHORT_NAME',
|
||||
'GOOGLE_MAPS_API_KEY',
|
||||
]
|
||||
|
||||
|
||||
PARENT_PROJECT_NAME = config('PARENT_PROJECT_NAME',
|
||||
default='Parent Project Name')
|
||||
PROJECT_LONG_NAME = config('PROJECT_LONG_NAME', default='Project Long Name')
|
||||
PROJECT_SHORT_NAME = config('PROJECT_SHORT_NAME', default='shortname')
|
||||
|
||||
|
||||
# Build paths inside the project like this: BASE_DIR.joinpath(...)
|
||||
BASE_DIR = pathlib.Path(__file__).parent.parent
|
||||
|
||||
|
||||
# SECURITY WARNING: keep the secret key used in production secret!
|
||||
SECRET_KEY = config('SECRET_KEY')
|
||||
|
||||
@@ -47,9 +141,7 @@ DEBUG = config('DEBUG', default=False, cast=bool)
|
||||
ALLOWED_HOSTS = config(
|
||||
'ALLOWED_HOSTS',
|
||||
default='*' if DEBUG else '127.0.0.1,localhost,localhost.localdomain',
|
||||
cast=Csv()
|
||||
)
|
||||
|
||||
cast=Csv())
|
||||
|
||||
# Application definition
|
||||
|
||||
@@ -66,10 +158,20 @@ THIRD_PARTY_APPS = [
|
||||
'bootstrap4',
|
||||
'constance',
|
||||
'constance.backends.database',
|
||||
'dbbackup',
|
||||
'django_countries',
|
||||
'django_select2',
|
||||
'rest_framework',
|
||||
'post_office',
|
||||
'bootstrap_datepicker_plus',
|
||||
'hijack',
|
||||
'compat',
|
||||
]
|
||||
|
||||
FIRST_PARTY_APPS = [
|
||||
'people',
|
||||
'activities',
|
||||
'export',
|
||||
]
|
||||
|
||||
INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + FIRST_PARTY_APPS
|
||||
@@ -106,16 +208,14 @@ TEMPLATES = [
|
||||
|
||||
WSGI_APPLICATION = 'breccia_mapper.wsgi.application'
|
||||
|
||||
|
||||
# Database
|
||||
# https://docs.djangoproject.com/en/2.2/ref/settings/#databases
|
||||
|
||||
DATABASES = {
|
||||
'default': config(
|
||||
'DATABASE_URL',
|
||||
default='sqlite:///' + str(BASE_DIR.joinpath('db.sqlite3')),
|
||||
cast=dj_database_url.parse
|
||||
)
|
||||
'default':
|
||||
config('DATABASE_URL',
|
||||
default='sqlite:///' + str(BASE_DIR.joinpath('db.sqlite3')),
|
||||
cast=dj_database_url.parse)
|
||||
}
|
||||
|
||||
# Django DBBackup
|
||||
@@ -123,25 +223,44 @@ DATABASES = {
|
||||
|
||||
DBBACKUP_STORAGE = 'django.core.files.storage.FileSystemStorage'
|
||||
DBBACKUP_STORAGE_OPTIONS = {
|
||||
'location': config('DBBACKUP_STORAGE_LOCATION', default=BASE_DIR.joinpath('.dbbackup')),
|
||||
'location':
|
||||
config('DBBACKUP_STORAGE_LOCATION',
|
||||
default=BASE_DIR.joinpath('.dbbackup')),
|
||||
}
|
||||
|
||||
# Django REST Framework
|
||||
# https://www.django-rest-framework.org/
|
||||
|
||||
REST_FRAMEWORK = {
|
||||
'DEFAULT_PERMISSION_CLASSES': [
|
||||
'rest_framework.permissions.IsAuthenticated',
|
||||
],
|
||||
'DEFAULT_RENDERER_CLASSES': [
|
||||
'rest_framework.renderers.JSONRenderer',
|
||||
'rest_framework_csv.renderers.CSVRenderer',
|
||||
'rest_framework.renderers.BrowsableAPIRenderer',
|
||||
],
|
||||
}
|
||||
|
||||
# Password validation
|
||||
# https://docs.djangoproject.com/en/2.2/ref/settings/#auth-password-validators
|
||||
|
||||
AUTH_PASSWORD_VALIDATORS = [
|
||||
{
|
||||
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
|
||||
'NAME':
|
||||
'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
|
||||
},
|
||||
{
|
||||
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
|
||||
'NAME':
|
||||
'django.contrib.auth.password_validation.MinimumLengthValidator',
|
||||
},
|
||||
{
|
||||
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
|
||||
'NAME':
|
||||
'django.contrib.auth.password_validation.CommonPasswordValidator',
|
||||
},
|
||||
{
|
||||
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
|
||||
'NAME':
|
||||
'django.contrib.auth.password_validation.NumericPasswordValidator',
|
||||
},
|
||||
]
|
||||
|
||||
@@ -150,13 +269,18 @@ AUTH_PASSWORD_VALIDATORS = [
|
||||
|
||||
AUTH_USER_MODEL = 'people.User'
|
||||
|
||||
# Login flow
|
||||
|
||||
LOGIN_URL = reverse_lazy('login')
|
||||
|
||||
LOGIN_REDIRECT_URL = reverse_lazy('people:person.profile')
|
||||
|
||||
# Internationalization
|
||||
# https://docs.djangoproject.com/en/2.2/topics/i18n/
|
||||
|
||||
LANGUAGE_CODE = 'en-us'
|
||||
LANGUAGE_CODE = config('LANGUAGE_CODE', default='en-gb')
|
||||
|
||||
TIME_ZONE = 'UTC'
|
||||
TIME_ZONE = config('TIME_ZONE', default='UTC')
|
||||
|
||||
USE_I18N = True
|
||||
|
||||
@@ -164,7 +288,6 @@ USE_L10N = True
|
||||
|
||||
USE_TZ = True
|
||||
|
||||
|
||||
# Static files (CSS, JavaScript, Images)
|
||||
# https://docs.djangoproject.com/en/2.2/howto/static-files/
|
||||
|
||||
@@ -172,10 +295,7 @@ STATIC_URL = '/static/'
|
||||
|
||||
STATIC_ROOT = BASE_DIR.joinpath('static')
|
||||
|
||||
STATICFILES_DIRS = [
|
||||
BASE_DIR.joinpath('breccia_mapper', 'static')
|
||||
]
|
||||
|
||||
STATICFILES_DIRS = [BASE_DIR.joinpath('breccia_mapper', 'static')]
|
||||
|
||||
# Logging - NB the logger name is empty to capture all output
|
||||
|
||||
@@ -212,16 +332,116 @@ LOGGING = {
|
||||
}
|
||||
}
|
||||
|
||||
# Initialise logger now so we can use it in this file
|
||||
|
||||
LOGGING_CONFIG = None
|
||||
logging.config.dictConfig(LOGGING)
|
||||
logger = logging.getLogger(__name__) # pylint: disable=invalid-name
|
||||
|
||||
# Admin panel variables
|
||||
|
||||
CONSTANCE_CONFIG = collections.OrderedDict([
|
||||
('NOTICE_TEXT', ('', 'Text to be displayed in a notice banner at the top of every page.')),
|
||||
('NOTICE_CLASS', ('alert-warning', 'CSS class to use for background of notice banner.')),
|
||||
])
|
||||
CONSTANCE_CONFIG = {
|
||||
'NOTICE_TEXT': (
|
||||
'',
|
||||
'Text to be displayed in a notice banner at the top of every page.'),
|
||||
'NOTICE_CLASS': (
|
||||
'alert-warning',
|
||||
'CSS class to use for background of notice banner.'),
|
||||
'CONSENT_TEXT': (
|
||||
'This is template consent text and should have been replaced. Please contact an admin.',
|
||||
'Text to be displayed to ask for consent for data collection.'),
|
||||
'PERSON_LIST_HELP': (
|
||||
'',
|
||||
'Help text to display at the top of the people list.'),
|
||||
'ORGANISATION_LIST_HELP': (
|
||||
'',
|
||||
'Help text to display at the top of the organisaton list.'),
|
||||
'RELATIONSHIP_FORM_HELP': (
|
||||
'',
|
||||
'Help text to display at the top of relationship forms.'),
|
||||
} # yapf: disable
|
||||
|
||||
CONSTANCE_CONFIG_FIELDSETS = {
|
||||
'Notice Banner': ('NOTICE_TEXT', 'NOTICE_CLASS'),
|
||||
}
|
||||
'Notice Banner': (
|
||||
'NOTICE_TEXT',
|
||||
'NOTICE_CLASS',
|
||||
),
|
||||
'Data Collection': (
|
||||
'CONSENT_TEXT',
|
||||
),
|
||||
'Help Text': (
|
||||
'PERSON_LIST_HELP',
|
||||
'ORGANISATION_LIST_HELP',
|
||||
'RELATIONSHIP_FORM_HELP',
|
||||
),
|
||||
} # yapf: disable
|
||||
|
||||
CONSTANCE_BACKEND = 'constance.backends.database.DatabaseBackend'
|
||||
|
||||
# Django Hijack settings
|
||||
# See https://django-hijack.readthedocs.io/en/stable/
|
||||
|
||||
HIJACK_USE_BOOTSTRAP = True
|
||||
|
||||
# Bootstrap settings
|
||||
# See https://django-bootstrap4.readthedocs.io/en/latest/settings.html
|
||||
|
||||
BOOTSTRAP4 = {
|
||||
'include_jquery': 'full',
|
||||
}
|
||||
|
||||
# Email backend settings
|
||||
# See https://docs.djangoproject.com/en/3.0/topics/email
|
||||
|
||||
EMAIL_HOST = config('EMAIL_HOST', default=None)
|
||||
DEFAULT_FROM_EMAIL = config(
|
||||
'DEFAULT_FROM_EMAIL',
|
||||
default=f'{PROJECT_SHORT_NAME}@localhost.localdomain')
|
||||
SERVER_EMAIL = DEFAULT_FROM_EMAIL
|
||||
|
||||
if EMAIL_HOST is None:
|
||||
EMAIL_BACKEND = 'django.core.mail.backends.filebased.EmailBackend'
|
||||
EMAIL_FILE_PATH = config('EMAIL_FILE_PATH',
|
||||
default=str(BASE_DIR.joinpath('mail.log')))
|
||||
|
||||
else:
|
||||
EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend'
|
||||
EMAIL_HOST_USER = config('EMAIL_HOST_USER', default=None)
|
||||
EMAIL_HOST_PASSWORD = config('EMAIL_HOST_PASSWORD', default=None)
|
||||
|
||||
EMAIL_PORT = config('EMAIL_PORT', default=25, cast=int)
|
||||
EMAIL_USE_TLS = config('EMAIL_USE_TLS',
|
||||
default=(EMAIL_PORT == 587),
|
||||
cast=bool)
|
||||
EMAIL_USE_SSL = config('EMAIL_USE_SSL',
|
||||
default=(EMAIL_PORT == 465),
|
||||
cast=bool)
|
||||
|
||||
# Upstream API keys
|
||||
|
||||
GOOGLE_MAPS_API_KEY = config('GOOGLE_MAPS_API_KEY', default=None)
|
||||
|
||||
# Import customisation app settings if present
|
||||
|
||||
try:
|
||||
from custom.settings import (
|
||||
CUSTOMISATION_NAME,
|
||||
TEMPLATE_NAME_INDEX,
|
||||
TEMPLATE_WELCOME_EMAIL_NAME,
|
||||
CONSTANCE_CONFIG as constance_config_custom,
|
||||
CONSTANCE_CONFIG_FIELDSETS as constance_config_fieldsets_custom
|
||||
) # yapf: disable
|
||||
|
||||
CONSTANCE_CONFIG.update(constance_config_custom)
|
||||
CONSTANCE_CONFIG_FIELDSETS.update(constance_config_fieldsets_custom)
|
||||
|
||||
INSTALLED_APPS.append('custom')
|
||||
logger.info("Loaded customisation app: %s", CUSTOMISATION_NAME)
|
||||
|
||||
except ImportError as exc:
|
||||
logger.info("No customisation app loaded: %s", exc)
|
||||
|
||||
# Set default values if no customisations loaded
|
||||
CUSTOMISATION_NAME = None
|
||||
TEMPLATE_NAME_INDEX = 'index.html'
|
||||
TEMPLATE_WELCOME_EMAIL_NAME = 'welcome-email'
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
header.masthead {
|
||||
position: relative;
|
||||
background: #343a40 no-repeat center;
|
||||
-webkit-background-size: cover;
|
||||
-moz-background-size: cover;
|
||||
-o-background-size: cover;
|
||||
background-size: cover;
|
||||
-webkit-background-size: contain;
|
||||
-moz-background-size: contain;
|
||||
-o-background-size: contain;
|
||||
background-size: contain;
|
||||
padding-top: 8rem;
|
||||
padding-bottom: 8rem;
|
||||
min-height: 200px;
|
||||
height: 60vh;
|
||||
min-height: 400px;
|
||||
height: 40vh;
|
||||
z-index: -2;
|
||||
}
|
||||
|
||||
|
||||
7
breccia_mapper/templates/403.html
Normal file
7
breccia_mapper/templates/403.html
Normal file
@@ -0,0 +1,7 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block content %}
|
||||
<h2>Error 403 at {{ request.path }}</h2>
|
||||
|
||||
<p>{{ exception }}</p>
|
||||
{% endblock content %}
|
||||
@@ -7,10 +7,10 @@
|
||||
<html lang="{{ LANGUAGE_CODE|default:'en_us' }}">
|
||||
|
||||
<head>
|
||||
|
||||
<!-- Required meta tags -->
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||
<title>{{ settings.PROJECT_LONG_NAME }}</title>
|
||||
|
||||
<!-- Bootstrap CSS -->
|
||||
{% bootstrap_css %}
|
||||
@@ -26,8 +26,11 @@
|
||||
crossorigin="anonymous" />
|
||||
|
||||
{% load staticfiles %}
|
||||
<link rel="stylesheet" href="{% static 'css/global.css' %}">
|
||||
|
||||
<link rel="stylesheet"
|
||||
href="{% static 'css/global.css' %}">
|
||||
type="text/css"
|
||||
href="{% static 'hijack/hijack-styles.css' %}" />
|
||||
|
||||
{% if 'javascript_in_head'|bootstrap_setting %}
|
||||
{% if 'include_jquery'|bootstrap_setting %}
|
||||
@@ -39,111 +42,172 @@
|
||||
{% bootstrap_javascript %}
|
||||
{% endif %}
|
||||
|
||||
{% if form %}
|
||||
{{ form.media.css }}
|
||||
{% endif %}
|
||||
|
||||
{% block extra_head %}{% endblock %}
|
||||
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="content" style="display: flex; flex-direction: column">
|
||||
{% block navbar %}
|
||||
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
|
||||
<div class="container">
|
||||
<a href="{% url 'index' %}" class="navbar-brand">
|
||||
{{ settings.PROJECT_SHORT_NAME }}
|
||||
</a>
|
||||
<div class="content" style="display: flex; flex-direction: column">
|
||||
{% block navbar %}
|
||||
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
|
||||
<div class="container">
|
||||
<a href="{% url 'index' %}" class="navbar-brand">
|
||||
{{ settings.PROJECT_SHORT_NAME }}
|
||||
</a>
|
||||
|
||||
<button type="button" class="navbar-toggler"
|
||||
data-toggle="collapse" data-target="#navbarCollapse"
|
||||
aria-controls="navbar-collapse" aria-expanded="false" aria-label="Toggle navbar">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<button type="button" class="navbar-toggler"
|
||||
data-toggle="collapse" data-target="#navbarCollapse"
|
||||
aria-controls="navbar-collapse" aria-expanded="false" aria-label="Toggle navbar">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
|
||||
<div class="navbar-collapse collapse" id="navbarCollapse">
|
||||
<ul class="navbar-nav mt-2 mt-lg-0">
|
||||
{% if request.user.is_superuser %}
|
||||
<div class="navbar-collapse collapse" id="navbarCollapse">
|
||||
<ul class="navbar-nav mt-2 mt-lg-0">
|
||||
<li class="nav-item">
|
||||
<a href="{% url 'admin:index' %}" class="nav-link">Admin</a>
|
||||
<a href="{% url 'people:person.list' %}" class="nav-link">People</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
|
||||
<ul class="navbar-nav mt-2 mt-lg-0 ml-auto">
|
||||
{% if request.user.is_authenticated %}
|
||||
<li class="nav-item">
|
||||
{% if request.user.person %}
|
||||
<a href="{{ request.user.get_absolute_url }}" class="nav-link">
|
||||
<i class="fas fa-user-circle"></i>
|
||||
{{ request.user }}
|
||||
<a href="{% url 'people:organisation.list' %}" class="nav-link">Organisations</a>
|
||||
</li>
|
||||
|
||||
<li class="nav-item">
|
||||
<a href="{% url 'activities:activity-series.list' %}" class="nav-link">Activity Series</a>
|
||||
</li>
|
||||
|
||||
<li class="nav-item">
|
||||
<a href="{% url 'activities:activity.list' %}" class="nav-link">Activities</a>
|
||||
</li>
|
||||
|
||||
<li class="nav-item">
|
||||
<a href="{% url 'people:map' %}" class="nav-link">Map</a>
|
||||
</li>
|
||||
|
||||
<li class="nav-item">
|
||||
<a href="{% url 'people:network' %}" class="nav-link">Network</a>
|
||||
</li>
|
||||
|
||||
{% if request.user.is_superuser %}
|
||||
<li class="nav-item">
|
||||
<a href="{% url 'export:index' %}" class="nav-link">Export</a>
|
||||
</li>
|
||||
|
||||
<li class="nav-item">
|
||||
<a href="{% url 'admin:index' %}" class="nav-link">Admin</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
|
||||
<ul class="navbar-nav mt-2 mt-lg-0 ml-auto">
|
||||
{% if request.user.is_authenticated %}
|
||||
<li class="nav-item">
|
||||
{% if request.user.person %}
|
||||
<a href="{% url 'people:person.profile' %}" class="nav-link">
|
||||
<i class="fas fa-user-circle"></i>
|
||||
{{ request.user }}
|
||||
</a>
|
||||
|
||||
{% else %}
|
||||
<a href="{% url 'people:person.create' %}?user" class="nav-link">
|
||||
<i class="fas fa-user-circle"></i>
|
||||
{{ request.user }}
|
||||
</a>
|
||||
|
||||
{% endif %}
|
||||
</li>
|
||||
|
||||
<li class="nav-item">
|
||||
<a href="{% url 'logout' %}" class="nav-link">
|
||||
<i class="fas fa-sign-out-alt"></i>
|
||||
Log Out
|
||||
</a>
|
||||
</li>
|
||||
|
||||
{% else %}
|
||||
<span class="navbar-text">
|
||||
<i class="fas fa-user-circle"></i>
|
||||
{{ request.user }}
|
||||
</span>
|
||||
{% else %}
|
||||
<li class="nav-item">
|
||||
<a href="{% url 'login' %}" class="nav-link">
|
||||
<i class="fas fa-sign-in-alt"></i>
|
||||
Log In
|
||||
</a>
|
||||
</li>
|
||||
|
||||
{% endif %}
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a href="{% url 'logout' %}" class="nav-link">
|
||||
<i class="fas fa-sign-out-alt"></i>
|
||||
Log Out
|
||||
</a>
|
||||
</li>
|
||||
|
||||
{% else %}
|
||||
<li class="nav-item">
|
||||
<a href="{% url 'login' %}" class="nav-link">
|
||||
<i class="fas fa-sign-in-alt"></i>
|
||||
Log In
|
||||
</a>
|
||||
</li>
|
||||
|
||||
{% endif %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
{% endblock %}
|
||||
|
||||
{# Global banner if config.NOTICE_TEXT is set using Constance #}
|
||||
{% if config.NOTICE_TEXT %}
|
||||
<div class="alert {{ config.NOTICE_CLASS }} rounded-0 mb-3" role="alert">
|
||||
<h4 class="alert-heading text-center mb-0">{{ config.NOTICE_TEXT }}</h4>
|
||||
</div>
|
||||
</nav>
|
||||
{% endblock %}
|
||||
{% endif %}
|
||||
|
||||
{# Global banner if config.NOTICE_TEXT is set using Constance #}
|
||||
{% if config.NOTICE_TEXT %}
|
||||
<div class="alert {{ config.NOTICE_CLASS }} rounded-0 mb-0" role="alert">
|
||||
<h4 class="alert-heading text-center mb-0">{{ config.NOTICE_TEXT }}</h4>
|
||||
{% load hijack_tags %}
|
||||
{% hijack_notification %}
|
||||
|
||||
{% if request.user.is_authenticated and not request.user.has_person %}
|
||||
<div class="alert alert-info rounded-0" role="alert">
|
||||
<p class="text-center mb-0">
|
||||
Your profile is currently blank.
|
||||
Please fill in your details so you can be part of the network.
|
||||
|
||||
<a class="btn btn-success"
|
||||
href="{% url 'people:person.create' %}?user">Profile</a>
|
||||
</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if request.user.is_authenticated and not request.user.consent_given %}
|
||||
<div class="alert alert-warning rounded-0" role="alert">
|
||||
<p class="text-center mb-0">
|
||||
You have not yet given consent for your data to be collected and processed.
|
||||
Please read and accept the <a href="{% url 'consent' %}">consent text</a>.
|
||||
</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% block before_content %}{% endblock %}
|
||||
|
||||
<main class="{{ full_width_page|yesno:'container-fluid,container' }}">
|
||||
{# Display Django messages as Bootstrap alerts #}
|
||||
{% bootstrap_messages %}
|
||||
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
|
||||
<div class="container">
|
||||
{% block after_content %}{% endblock %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="footer bg-light">
|
||||
<div class="container">
|
||||
<span class="text-muted">{{ settings.PROJECT_LONG_NAME }}</span>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
{% if not 'javascript_in_head'|bootstrap_setting %}
|
||||
{% if 'include_jquery'|bootstrap_setting %}
|
||||
{# jQuery JavaScript if it is in body #}
|
||||
{% bootstrap_jquery jquery='include_jquery'|bootstrap_setting %}
|
||||
{% endif %}
|
||||
|
||||
{# Bootstrap JavaScript if it is in body #}
|
||||
{% bootstrap_javascript %}
|
||||
{% endif %}
|
||||
|
||||
{% block before_content %}{% endblock %}
|
||||
|
||||
<main class="container">
|
||||
{# Display Django messages as Bootstrap alerts #}
|
||||
{% bootstrap_messages %}
|
||||
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
|
||||
<div class="container">
|
||||
{% block after_content %}{% endblock %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="footer bg-light">
|
||||
<div class="container">
|
||||
<span class="text-muted">{{ settings.PROJECT_LONG_NAME }}</span>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
{% if not 'javascript_in_head'|bootstrap_setting %}
|
||||
{% if 'include_jquery'|bootstrap_setting %}
|
||||
{# jQuery JavaScript if it is in body #}
|
||||
{% bootstrap_jquery jquery='include_jquery'|bootstrap_setting %}
|
||||
{% if form %}
|
||||
{{ form.media.js }}
|
||||
{% endif %}
|
||||
|
||||
{# Bootstrap JavaScript if it is in body #}
|
||||
{% bootstrap_javascript %}
|
||||
{% endif %}
|
||||
|
||||
{% block extra_script %}{% endblock %}
|
||||
{% block extra_script %}{% endblock %}
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
||||
21
breccia_mapper/templates/consent.html
Normal file
21
breccia_mapper/templates/consent.html
Normal file
@@ -0,0 +1,21 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block content %}
|
||||
<h2>Consent</h2>
|
||||
|
||||
<p>
|
||||
{{ config.CONSENT_TEXT|linebreaks }}
|
||||
</p>
|
||||
|
||||
<form class="form"
|
||||
method="POST">
|
||||
{% csrf_token %}
|
||||
|
||||
{% load bootstrap4 %}
|
||||
{% bootstrap_form form %}
|
||||
|
||||
{% buttons %}
|
||||
<button class="btn btn-success" type="submit">Submit</button>
|
||||
{% endbuttons %}
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -63,7 +63,7 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row align-items-center">
|
||||
<div class="row align-items-center" style="min-height: 400px;">
|
||||
<div class="col-sm-8">
|
||||
<h2 class="pb-2">About {{ settings.PROJECT_LONG_NAME }}</h2>
|
||||
|
||||
|
||||
10
breccia_mapper/templates/registration/logged_out.html
Normal file
10
breccia_mapper/templates/registration/logged_out.html
Normal file
@@ -0,0 +1,10 @@
|
||||
{% extends 'base.html' %}
|
||||
{% load bootstrap4 %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Logged Out</h1>
|
||||
|
||||
<p>
|
||||
You have logged out
|
||||
</p>
|
||||
{% endblock %}
|
||||
15
breccia_mapper/templates/registration/login.html
Normal file
15
breccia_mapper/templates/registration/login.html
Normal file
@@ -0,0 +1,15 @@
|
||||
{% extends 'base.html' %}
|
||||
{% load bootstrap4 %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Login</h1>
|
||||
|
||||
<form action="" method="post">
|
||||
{% csrf_token %}
|
||||
{% bootstrap_form form %}
|
||||
{% buttons %}
|
||||
<input type="hidden" name="next" value="{{ next }}">
|
||||
<input type="submit" class="btn btn-info" value="Login">
|
||||
{% endbuttons %}
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -19,7 +19,14 @@ from django.urls import include, path
|
||||
from . import views
|
||||
|
||||
urlpatterns = [
|
||||
path('admin/', admin.site.urls),
|
||||
path('admin/',
|
||||
admin.site.urls),
|
||||
|
||||
path('select2/',
|
||||
include('django_select2.urls')),
|
||||
|
||||
path('hijack/',
|
||||
include('hijack.urls', namespace='hijack')),
|
||||
|
||||
path('',
|
||||
include('django.contrib.auth.urls')),
|
||||
@@ -27,4 +34,17 @@ urlpatterns = [
|
||||
path('',
|
||||
views.IndexView.as_view(),
|
||||
name='index'),
|
||||
]
|
||||
|
||||
path('consent',
|
||||
views.ConsentTextView.as_view(),
|
||||
name='consent'),
|
||||
|
||||
path('',
|
||||
include('export.urls')),
|
||||
|
||||
path('',
|
||||
include('people.urls')),
|
||||
|
||||
path('',
|
||||
include('activities.urls')),
|
||||
] # yapf: disable
|
||||
|
||||
@@ -1,5 +1,37 @@
|
||||
"""Views belonging to the core of the project.
|
||||
|
||||
These views don't represent any of the models in the apps.
|
||||
"""
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
from django.urls import reverse
|
||||
from django.views.generic import TemplateView
|
||||
from django.views.generic.edit import UpdateView
|
||||
|
||||
from . import forms
|
||||
|
||||
User = get_user_model() # pylint: disable=invalid-name
|
||||
|
||||
|
||||
class IndexView(TemplateView):
|
||||
template_name = 'index.html'
|
||||
# Template set in Django settings file - may be customised by a customisation app
|
||||
template_name = settings.TEMPLATE_NAME_INDEX
|
||||
|
||||
|
||||
class ConsentTextView(LoginRequiredMixin, UpdateView):
|
||||
"""View with consent text and form for users to indicate consent."""
|
||||
model = User
|
||||
form_class = forms.ConsentForm
|
||||
template_name = 'consent.html'
|
||||
|
||||
def get_success_url(self) -> str:
|
||||
try:
|
||||
return reverse('people:person.detail', kwargs={'pk': self.request.user.person.pk})
|
||||
|
||||
except AttributeError:
|
||||
return reverse('index')
|
||||
|
||||
def get_object(self, *args, **kwargs) -> User:
|
||||
return self.request.user
|
||||
|
||||
54
deploy/README.md
Normal file
54
deploy/README.md
Normal file
@@ -0,0 +1,54 @@
|
||||
# BRECcIA Mapper Deployment
|
||||
|
||||
BRECcIA Mapper is intended to be deployed using Ansible and Docker.
|
||||
It has been tested on RHEL7 and RHEL8, though with minor modification to the Ansible playbook it is expected to deploy correctly on other Linux variants (e.g. Ubuntu).
|
||||
|
||||
## Development Deployment
|
||||
|
||||
Prerequisites:
|
||||
|
||||
- [Vagrant](https://www.vagrantup.com/)
|
||||
- [Ansible](https://www.ansible.com/)
|
||||
|
||||
Using Vagrant, we can create a virtual machine and deploy BRECcIA Mapper using the same provisioning scripts as a production deployment.
|
||||
To deploy a local development version of BRECcIA Mapper inside a virtual machine, use:
|
||||
|
||||
```
|
||||
vagrant up
|
||||
```
|
||||
|
||||
Once this virtual machine has been created, to redeploy use:
|
||||
|
||||
```
|
||||
vagrant provision
|
||||
```
|
||||
|
||||
And to stop the virtual machine use:
|
||||
|
||||
```
|
||||
vagrant halt
|
||||
```
|
||||
|
||||
For further commands see the [Vagrant documentation](https://www.vagrantup.com/docs/cli).
|
||||
|
||||
## Production Deployment
|
||||
|
||||
Prerequisites:
|
||||
|
||||
- [Ansible](https://www.ansible.com/)
|
||||
|
||||
To perform a production deployment of BRECcIA Mapper:
|
||||
|
||||
1. Copy the `inventory.example.yml` to `inventory.yml`
|
||||
2. Edit this file:
|
||||
- Use your server's hostname instead of `example.com`
|
||||
- Disable debugging
|
||||
- Replace the secret key with some text known only to you
|
||||
3. Run the Ansible playbook with this inventory file using:
|
||||
|
||||
```
|
||||
ansible-playbook playbook.yml -i inventory.yml -K -k -u <SSH username>
|
||||
```
|
||||
|
||||
This will ask for your SSH and sudo passwords for the server, before deploying.
|
||||
To redeploy updates, the same command can be run again - it's safe to redeploy on top of an existing deployment.
|
||||
30
deploy/Vagrantfile
vendored
Normal file
30
deploy/Vagrantfile
vendored
Normal file
@@ -0,0 +1,30 @@
|
||||
# -*- mode: ruby -*-
|
||||
# vi: set ft=ruby :
|
||||
|
||||
# All Vagrant configuration is done below. The "2" in Vagrant.configure
|
||||
# configures the configuration version (we support older styles for
|
||||
# backwards compatibility). Please don't change it unless you know what
|
||||
# you're doing.
|
||||
Vagrant.configure("2") do |config|
|
||||
# Every Vagrant development environment requires a box. You can search for
|
||||
# boxes at https://vagrantcloud.com/search.
|
||||
config.vm.box = "generic/rocky8"
|
||||
|
||||
# Create a forwarded port mapping which allows access to a specific port
|
||||
# within the machine from a port on the host machine and only allow access
|
||||
# via 127.0.0.1 to disable public access
|
||||
config.vm.network "forwarded_port", guest: 80, host: 8080, host_ip: "127.0.0.1"
|
||||
|
||||
# Provision VM using Ansible playbook
|
||||
config.vm.provision "ansible" do |ansible|
|
||||
ansible.verbose = "v"
|
||||
ansible.playbook = "playbook.yml"
|
||||
ansible.host_vars = {
|
||||
"default" => {
|
||||
"deploy_environment" => "vagrant",
|
||||
"django_debug" => 1,
|
||||
"django_secret_key" => "debug_only_g62WlORMbo8iAcV7vKCKBQ=="
|
||||
}
|
||||
}
|
||||
end
|
||||
end
|
||||
5
deploy/inventory.example.yml
Normal file
5
deploy/inventory.example.yml
Normal file
@@ -0,0 +1,5 @@
|
||||
all:
|
||||
hosts:
|
||||
example.com:
|
||||
django_debug: 1
|
||||
django_secret_key: debug_only_g62WlORMbo8iAcV7vKCKBQ==
|
||||
86
deploy/playbook.yml
Normal file
86
deploy/playbook.yml
Normal file
@@ -0,0 +1,86 @@
|
||||
---
|
||||
- hosts: all
|
||||
become_user: root
|
||||
become_method: sudo
|
||||
become: yes
|
||||
|
||||
pre_tasks:
|
||||
- name: Check if running under Vagrant
|
||||
stat:
|
||||
path: /vagrant
|
||||
register: vagrant_dir
|
||||
|
||||
vars:
|
||||
project_name: mapper
|
||||
project_dir: /srv/{{ project_name }}
|
||||
project_src_dir: "{{ project_dir }}/src"
|
||||
|
||||
tasks:
|
||||
- name: Vagrant specific tasks
|
||||
block:
|
||||
- name: Add Docker repository
|
||||
get_url:
|
||||
url: https://download.docker.com/linux/centos/docker-ce.repo
|
||||
dest: '/etc/yum.repos.d/docker-ce.repo'
|
||||
when: deploy_environment is defined and deploy_environment == "vagrant"
|
||||
|
||||
- name: Install system dependencies
|
||||
ansible.builtin.yum:
|
||||
name:
|
||||
- git
|
||||
- docker-ce
|
||||
- docker-ce-cli
|
||||
- containerd.io
|
||||
- docker-compose-plugin
|
||||
state: present
|
||||
|
||||
# - name: Update system packages
|
||||
# ansible.builtin.yum:
|
||||
# name: '*'
|
||||
# state: latest
|
||||
|
||||
- name: Clone / update from source repos
|
||||
ansible.builtin.git:
|
||||
repo: 'https://github.com/Southampton-RSG/breccia-mapper.git'
|
||||
dest: '{{ project_src_dir }}'
|
||||
version: docker
|
||||
accept_hostkey: yes
|
||||
|
||||
- name: Copy template files
|
||||
ansible.builtin.template:
|
||||
src: '{{ item }}.j2'
|
||||
dest: '{{ project_dir }}/{{ item }}'
|
||||
mode: 0600
|
||||
loop:
|
||||
- Caddyfile
|
||||
- docker-compose.yml
|
||||
|
||||
- name: Create database file
|
||||
ansible.builtin.file:
|
||||
path: "{{ project_dir }}/db.sqlite3"
|
||||
state: touch
|
||||
|
||||
- name: Start Docker
|
||||
ansible.builtin.systemd:
|
||||
name: docker
|
||||
state: started
|
||||
enabled: yes
|
||||
|
||||
- name: Pull latest docker images
|
||||
ansible.builtin.command:
|
||||
chdir: "{{ project_dir }}"
|
||||
cmd: docker compose pull {{ item }}
|
||||
loop:
|
||||
- caddy
|
||||
|
||||
- name: Build custom images
|
||||
ansible.builtin.command:
|
||||
chdir: "{{ project_dir }}"
|
||||
cmd: docker compose build {{ item }}
|
||||
loop:
|
||||
- web
|
||||
|
||||
- name: Start containers
|
||||
ansible.builtin.command:
|
||||
chdir: "{{ project_dir }}"
|
||||
cmd: docker compose up -d
|
||||
15
deploy/templates/Caddyfile.j2
Normal file
15
deploy/templates/Caddyfile.j2
Normal file
@@ -0,0 +1,15 @@
|
||||
http://* {
|
||||
root * /srv
|
||||
file_server
|
||||
|
||||
@proxy_paths {
|
||||
not path /static/*
|
||||
}
|
||||
|
||||
reverse_proxy @proxy_paths http://web:8000
|
||||
|
||||
log {
|
||||
output stderr
|
||||
format single_field common_log
|
||||
}
|
||||
}
|
||||
35
deploy/templates/docker-compose.yml.j2
Normal file
35
deploy/templates/docker-compose.yml.j2
Normal file
@@ -0,0 +1,35 @@
|
||||
version: '3.1'
|
||||
|
||||
services:
|
||||
web:
|
||||
image: breccia-mapper
|
||||
build: {{ project_src_dir }}
|
||||
ports:
|
||||
- 8000:8000
|
||||
environment:
|
||||
DEBUG: {{ django_debug }}
|
||||
DATABASE_URL: sqlite:////app/db.sqlite3
|
||||
SECRET_KEY: {{ django_secret_key }}
|
||||
volumes:
|
||||
- {{ project_dir }}/db.sqlite3:/app/db.sqlite3:z
|
||||
- static_files:/app/static
|
||||
|
||||
caddy:
|
||||
image: caddy:2
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- 80:80
|
||||
- 443:443
|
||||
volumes:
|
||||
- ./Caddyfile:/etc/caddy/Caddyfile:z
|
||||
# Caddy serves static files collected by Django
|
||||
- static_files:/srv/static:ro
|
||||
- caddy_data:/data
|
||||
- caddy_config:/config
|
||||
depends_on:
|
||||
- web
|
||||
|
||||
volumes:
|
||||
caddy_data:
|
||||
caddy_config:
|
||||
static_files:
|
||||
35
docker-compose.yml
Normal file
35
docker-compose.yml
Normal file
@@ -0,0 +1,35 @@
|
||||
version: '3.1'
|
||||
|
||||
services:
|
||||
web:
|
||||
image: breccia-mapper
|
||||
build: .
|
||||
ports:
|
||||
- 8000:8000
|
||||
environment:
|
||||
DEBUG: ${DJANGO_DEBUG}
|
||||
DATABASE_URL: sqlite:////app/db.sqlite3
|
||||
SECRET_KEY: ${DJANGO_SECRET_KEY}
|
||||
volumes:
|
||||
- ./db.sqlite3:/app/db.sqlite3:z
|
||||
- static_files:/app/static
|
||||
|
||||
caddy:
|
||||
image: caddy:2
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- 80:80
|
||||
- 443:443
|
||||
volumes:
|
||||
- ./Caddyfile:/etc/caddy/Caddyfile:z
|
||||
# Caddy serves static files collected by Django
|
||||
- static_files:/srv/static:ro
|
||||
- caddy_data:/data
|
||||
- caddy_config:/config
|
||||
depends_on:
|
||||
- web
|
||||
|
||||
volumes:
|
||||
caddy_data:
|
||||
caddy_config:
|
||||
static_files:
|
||||
8
entrypoint.sh
Executable file
8
entrypoint.sh
Executable file
@@ -0,0 +1,8 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -eo pipefail
|
||||
|
||||
python manage.py migrate
|
||||
python manage.py collectstatic --no-input
|
||||
|
||||
exec "$@"
|
||||
0
export/__init__.py
Normal file
0
export/__init__.py
Normal file
5
export/apps.py
Normal file
5
export/apps.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class ExportConfig(AppConfig):
|
||||
name = 'export'
|
||||
10
export/serializers/__init__.py
Normal file
10
export/serializers/__init__.py
Normal file
@@ -0,0 +1,10 @@
|
||||
from . import (
|
||||
activities,
|
||||
people
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
'activities',
|
||||
'people',
|
||||
]
|
||||
76
export/serializers/activities.py
Normal file
76
export/serializers/activities.py
Normal file
@@ -0,0 +1,76 @@
|
||||
from rest_framework import serializers
|
||||
|
||||
from activities import models
|
||||
|
||||
from . import base
|
||||
from . import people as people_serializers
|
||||
|
||||
|
||||
class ActivityTypeSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = models.ActivityType
|
||||
fields = [
|
||||
'pk',
|
||||
'name',
|
||||
]
|
||||
|
||||
|
||||
class ActivityMediumSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = models.ActivityMedium
|
||||
fields = [
|
||||
'pk',
|
||||
'name',
|
||||
]
|
||||
|
||||
|
||||
class ActivitySeriesSerializer(serializers.ModelSerializer):
|
||||
type = ActivityTypeSerializer()
|
||||
medium = ActivityMediumSerializer()
|
||||
|
||||
class Meta:
|
||||
model = models.ActivitySeries
|
||||
fields = [
|
||||
'pk',
|
||||
'name',
|
||||
'type',
|
||||
'medium',
|
||||
]
|
||||
|
||||
|
||||
class ActivitySerializer(base.FlattenedModelSerializer):
|
||||
series = ActivitySeriesSerializer()
|
||||
type = ActivityTypeSerializer()
|
||||
medium = ActivityMediumSerializer()
|
||||
|
||||
class Meta:
|
||||
model = models.Activity
|
||||
fields = [
|
||||
'pk',
|
||||
'name',
|
||||
'series',
|
||||
'type',
|
||||
'medium',
|
||||
]
|
||||
|
||||
|
||||
class SimpleActivitySerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = models.Activity
|
||||
fields = [
|
||||
'pk',
|
||||
'name',
|
||||
]
|
||||
|
||||
|
||||
class ActivityAttendanceSerializer(base.FlattenedModelSerializer):
|
||||
activity = SimpleActivitySerializer()
|
||||
person = people_serializers.PersonSerializer()
|
||||
|
||||
class Meta:
|
||||
model = models.Activity.attendance_list.through
|
||||
fields = [
|
||||
'pk',
|
||||
'activity',
|
||||
'person',
|
||||
]
|
||||
65
export/serializers/base.py
Normal file
65
export/serializers/base.py
Normal file
@@ -0,0 +1,65 @@
|
||||
"""
|
||||
Serialize models to and deserialize from JSON.
|
||||
"""
|
||||
|
||||
from collections import OrderedDict
|
||||
import typing
|
||||
|
||||
from rest_framework import serializers
|
||||
|
||||
|
||||
class FlattenedModelSerializer(serializers.ModelSerializer):
|
||||
@classmethod
|
||||
def flatten_data(cls, data,
|
||||
sub_type: typing.Type = dict,
|
||||
sub_value_accessor: typing.Callable = lambda x: x.items()) -> OrderedDict:
|
||||
"""
|
||||
Flatten a dictionary so that subdictionaries become a series of `key[.subkey[.subsubkey ...]]` entries
|
||||
in the top level dictionary.
|
||||
|
||||
Works for other data structures (e.g. DRF Serializers) by providing suitable values for the
|
||||
`sub_type` and `sub_value_accessor` parameters.
|
||||
|
||||
:param data: Dictionary or other data structure to flatten
|
||||
:param sub_type: Type to recursively flatten
|
||||
:param sub_value_accessor: Function to access keys and values contained within sub_type.
|
||||
"""
|
||||
data_out = OrderedDict()
|
||||
|
||||
for key, value in sub_value_accessor(data):
|
||||
if isinstance(value, sub_type):
|
||||
# Recursively flatten nested structures of type `sub_type`
|
||||
sub_flattened = cls.flatten_data(value,
|
||||
sub_type=sub_type,
|
||||
sub_value_accessor=sub_value_accessor).items()
|
||||
|
||||
# Enter recursively flattened values into result dictionary
|
||||
for sub_key, sub_value in sub_flattened:
|
||||
# Keys in result dictionary are of format `key[.subkey[.subsubkey ...]]`
|
||||
data_out[f'{key}.{sub_key}'] = sub_value
|
||||
|
||||
elif value is not None:
|
||||
data_out[key] = value
|
||||
|
||||
return data_out
|
||||
|
||||
@property
|
||||
def column_headers(self) -> typing.List[str]:
|
||||
"""
|
||||
Get all column headers that will be output by this serializer.
|
||||
"""
|
||||
fields = self.flatten_data(self.fields,
|
||||
sub_type=serializers.BaseSerializer,
|
||||
sub_value_accessor=lambda x: x.fields.items())
|
||||
|
||||
return list(fields)
|
||||
|
||||
def to_representation(self, instance) -> OrderedDict:
|
||||
"""
|
||||
|
||||
"""
|
||||
rep = super().to_representation(instance)
|
||||
|
||||
rep = self.flatten_data(rep)
|
||||
|
||||
return rep
|
||||
143
export/serializers/people.py
Normal file
143
export/serializers/people.py
Normal file
@@ -0,0 +1,143 @@
|
||||
import typing
|
||||
|
||||
from people import models
|
||||
|
||||
from . import base
|
||||
|
||||
|
||||
def underscore(slug: str) -> str:
|
||||
"""Replace hyphens with underscores in text."""
|
||||
return slug.replace('-', '_')
|
||||
|
||||
|
||||
def underscore_dict_keys(dict_: typing.Mapping[str, typing.Any]):
|
||||
return {underscore(key): value for key, value in dict_.items()}
|
||||
|
||||
|
||||
class AnswerSetSerializer(base.FlattenedModelSerializer):
|
||||
question_model = None
|
||||
|
||||
@property
|
||||
def column_headers(self) -> typing.List[str]:
|
||||
headers = super().column_headers
|
||||
|
||||
# Add relationship questions to columns
|
||||
for question in self.question_model.objects.all():
|
||||
headers.append(underscore(question.slug))
|
||||
|
||||
return headers
|
||||
|
||||
def to_representation(self, instance: models.question.AnswerSet):
|
||||
rep = super().to_representation(instance)
|
||||
|
||||
rep.update(
|
||||
underscore_dict_keys(instance.build_question_answers(use_slugs=True, show_all=True))
|
||||
)
|
||||
|
||||
return rep
|
||||
|
||||
|
||||
class PersonSerializer(base.FlattenedModelSerializer):
|
||||
class Meta:
|
||||
model = models.Person
|
||||
fields = [
|
||||
'id',
|
||||
'name',
|
||||
'organisation',
|
||||
'country_of_residence',
|
||||
]
|
||||
|
||||
|
||||
class PersonAnswerSetSerializer(AnswerSetSerializer):
|
||||
question_model = models.PersonQuestion
|
||||
person = PersonSerializer()
|
||||
|
||||
class Meta:
|
||||
model = models.PersonAnswerSet
|
||||
fields = [
|
||||
'id',
|
||||
'person',
|
||||
'timestamp',
|
||||
'replaced_timestamp',
|
||||
'latitude',
|
||||
'longitude',
|
||||
]
|
||||
|
||||
|
||||
class RelationshipSerializer(base.FlattenedModelSerializer):
|
||||
source = PersonSerializer()
|
||||
target = PersonSerializer()
|
||||
|
||||
class Meta:
|
||||
model = models.Relationship
|
||||
fields = [
|
||||
'id',
|
||||
'source',
|
||||
'target',
|
||||
]
|
||||
|
||||
|
||||
class RelationshipAnswerSetSerializer(AnswerSetSerializer):
|
||||
question_model = models.RelationshipQuestion
|
||||
relationship = RelationshipSerializer()
|
||||
|
||||
class Meta:
|
||||
model = models.RelationshipAnswerSet
|
||||
fields = [
|
||||
'id',
|
||||
'relationship',
|
||||
'timestamp',
|
||||
'replaced_timestamp',
|
||||
]
|
||||
|
||||
|
||||
class OrganisationSerializer(base.FlattenedModelSerializer):
|
||||
class Meta:
|
||||
model = models.Organisation
|
||||
fields = [
|
||||
'id',
|
||||
'name',
|
||||
]
|
||||
|
||||
|
||||
class OrganisationAnswerSetSerializer(AnswerSetSerializer):
|
||||
question_model = models.OrganisationQuestion
|
||||
organisation = OrganisationSerializer()
|
||||
|
||||
class Meta:
|
||||
model = models.OrganisationAnswerSet
|
||||
fields = [
|
||||
'id',
|
||||
'organisation',
|
||||
'timestamp',
|
||||
'replaced_timestamp',
|
||||
'latitude',
|
||||
'longitude',
|
||||
]
|
||||
|
||||
|
||||
class OrganisationRelationshipSerializer(base.FlattenedModelSerializer):
|
||||
source = OrganisationSerializer()
|
||||
target = OrganisationSerializer()
|
||||
|
||||
class Meta:
|
||||
model = models.OrganisationRelationship
|
||||
fields = [
|
||||
'id',
|
||||
'source',
|
||||
'target',
|
||||
]
|
||||
|
||||
|
||||
class OrganisationRelationshipAnswerSetSerializer(AnswerSetSerializer):
|
||||
question_model = models.OrganisationRelationshipQuestion
|
||||
relationship = OrganisationRelationshipSerializer()
|
||||
|
||||
class Meta:
|
||||
model = models.OrganisationRelationshipAnswerSet
|
||||
fields = [
|
||||
'id',
|
||||
'relationship',
|
||||
'timestamp',
|
||||
'replaced_timestamp',
|
||||
]
|
||||
117
export/templates/export/export.html
Normal file
117
export/templates/export/export.html
Normal file
@@ -0,0 +1,117 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block content %}
|
||||
<nav aria-label="breadcrumb">
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item active" aria-current="page">Export Data</li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<h1>Export Data</h1>
|
||||
|
||||
<hr>
|
||||
|
||||
<table class="table table-borderless">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Type</th>
|
||||
<th>Records</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>People</td>
|
||||
<td></td>
|
||||
<td>
|
||||
<a class="btn btn-info"
|
||||
href="{% url 'export:person' %}">Export</a>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>Person Answer Sets</td>
|
||||
<td></td>
|
||||
<td>
|
||||
<a class="btn btn-info"
|
||||
href="{% url 'export:person-answer-set' %}">Export</a>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>Relationships</td>
|
||||
<td></td>
|
||||
<td>
|
||||
<a class="btn btn-info"
|
||||
href="{% url 'export:relationship' %}">Export</a>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>Relationship Answer Sets</td>
|
||||
<td></td>
|
||||
<td>
|
||||
<a class="btn btn-info"
|
||||
href="{% url 'export:relationship-answer-set' %}">Export</a>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>Organisation</td>
|
||||
<td></td>
|
||||
<td>
|
||||
<a class="btn btn-info"
|
||||
href="{% url 'export:organisation' %}">Export</a>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>Organisation Answer Sets</td>
|
||||
<td></td>
|
||||
<td>
|
||||
<a class="btn btn-info"
|
||||
href="{% url 'export:organisation-answer-set' %}">Export</a>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>Organisation Relationships</td>
|
||||
<td></td>
|
||||
<td>
|
||||
<a class="btn btn-info"
|
||||
href="{% url 'export:organisation-relationship' %}">Export</a>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>Organisation Relationship Answer Sets</td>
|
||||
<td></td>
|
||||
<td>
|
||||
<a class="btn btn-info"
|
||||
href="{% url 'export:organisation-relationship-answer-set' %}">Export</a>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>Activities</td>
|
||||
<td></td>
|
||||
<td>
|
||||
<a class="btn btn-info"
|
||||
href="{% url 'export:activity' %}">Export</a>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>Activity Attendance</td>
|
||||
<td></td>
|
||||
<td>
|
||||
<a class="btn btn-info"
|
||||
href="{% url 'export:activity-attendance' %}">Export</a>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
|
||||
{% endblock %}
|
||||
52
export/urls.py
Normal file
52
export/urls.py
Normal file
@@ -0,0 +1,52 @@
|
||||
from django.urls import path
|
||||
|
||||
from . import views
|
||||
|
||||
|
||||
app_name = 'export'
|
||||
|
||||
urlpatterns = [
|
||||
path('export',
|
||||
views.ExportListView.as_view(),
|
||||
name='index'),
|
||||
|
||||
path('export/people',
|
||||
views.people.PersonExportView.as_view(),
|
||||
name='person'),
|
||||
|
||||
path('export/person-answer-sets',
|
||||
views.people.PersonAnswerSetExportView.as_view(),
|
||||
name='person-answer-set'),
|
||||
|
||||
path('export/relationships',
|
||||
views.people.RelationshipExportView.as_view(),
|
||||
name='relationship'),
|
||||
|
||||
path('export/relationship-answer-sets',
|
||||
views.people.RelationshipAnswerSetExportView.as_view(),
|
||||
name='relationship-answer-set'),
|
||||
|
||||
path('export/organisation',
|
||||
views.people.OrganisationExportView.as_view(),
|
||||
name='organisation'),
|
||||
|
||||
path('export/organisation-answer-sets',
|
||||
views.people.OrganisationAnswerSetExportView.as_view(),
|
||||
name='organisation-answer-set'),
|
||||
|
||||
path('export/organisation-relationships',
|
||||
views.people.OrganisationRelationshipExportView.as_view(),
|
||||
name='organisation-relationship'),
|
||||
|
||||
path('export/organisation-relationship-answer-sets',
|
||||
views.people.OrganisationRelationshipAnswerSetExportView.as_view(),
|
||||
name='organisation-relationship-answer-set'),
|
||||
|
||||
path('export/activities',
|
||||
views.activities.ActivityExportView.as_view(),
|
||||
name='activity'),
|
||||
|
||||
path('export/activity-attendance',
|
||||
views.activities.ActivityAttendanceExportView.as_view(),
|
||||
name='activity-attendance'),
|
||||
]
|
||||
13
export/views/__init__.py
Normal file
13
export/views/__init__.py
Normal file
@@ -0,0 +1,13 @@
|
||||
from .base import ExportListView
|
||||
|
||||
from . import (
|
||||
activities,
|
||||
people
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
'activities',
|
||||
'people',
|
||||
'ExportListView',
|
||||
]
|
||||
14
export/views/activities.py
Normal file
14
export/views/activities.py
Normal file
@@ -0,0 +1,14 @@
|
||||
from . import base
|
||||
from .. import serializers
|
||||
|
||||
from activities import models
|
||||
|
||||
|
||||
class ActivityExportView(base.CsvExportView):
|
||||
model = models.Activity
|
||||
serializer_class = serializers.activities.ActivitySerializer
|
||||
|
||||
|
||||
class ActivityAttendanceExportView(base.CsvExportView):
|
||||
model = models.Activity.attendance_list.through
|
||||
serializer_class = serializers.activities.ActivityAttendanceSerializer
|
||||
38
export/views/base.py
Normal file
38
export/views/base.py
Normal file
@@ -0,0 +1,38 @@
|
||||
import csv
|
||||
import typing
|
||||
|
||||
from django.contrib.auth.mixins import UserPassesTestMixin
|
||||
from django.http import HttpResponse
|
||||
from django.views.generic import TemplateView
|
||||
from django.views.generic.list import BaseListView
|
||||
|
||||
|
||||
class QuotedCsv(csv.excel):
|
||||
quoting = csv.QUOTE_NONNUMERIC
|
||||
|
||||
|
||||
class UserIsStaffMixin(UserPassesTestMixin):
|
||||
def test_func(self) -> typing.Optional[bool]:
|
||||
return self.request.user.is_staff
|
||||
|
||||
|
||||
class CsvExportView(UserIsStaffMixin, BaseListView):
|
||||
model = None
|
||||
serializer_class = None
|
||||
|
||||
def render_to_response(self, context: typing.Dict) -> HttpResponse:
|
||||
response = HttpResponse(content_type='text/csv')
|
||||
response['Content-Disposition'] = f'attachment; filename="{self.get_context_object_name(self.object_list)}.csv"'
|
||||
|
||||
# Force ordering by PK - though this should be default anyway
|
||||
serializer = self.serializer_class(self.get_queryset().order_by('pk'), many=True)
|
||||
|
||||
writer = csv.DictWriter(response, dialect=QuotedCsv, fieldnames=serializer.child.column_headers)
|
||||
writer.writeheader()
|
||||
writer.writerows(serializer.data)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
class ExportListView(UserIsStaffMixin, TemplateView):
|
||||
template_name = 'export/export.html'
|
||||
44
export/views/people.py
Normal file
44
export/views/people.py
Normal file
@@ -0,0 +1,44 @@
|
||||
from . import base
|
||||
from .. import serializers
|
||||
|
||||
from people import models
|
||||
|
||||
|
||||
class PersonExportView(base.CsvExportView):
|
||||
model = models.person.Person
|
||||
serializer_class = serializers.people.PersonSerializer
|
||||
|
||||
|
||||
class PersonAnswerSetExportView(base.CsvExportView):
|
||||
model = models.person.PersonAnswerSet
|
||||
serializer_class = serializers.people.PersonAnswerSetSerializer
|
||||
|
||||
|
||||
class RelationshipExportView(base.CsvExportView):
|
||||
model = models.relationship.Relationship
|
||||
serializer_class = serializers.people.RelationshipSerializer
|
||||
|
||||
|
||||
class RelationshipAnswerSetExportView(base.CsvExportView):
|
||||
model = models.relationship.RelationshipAnswerSet
|
||||
serializer_class = serializers.people.RelationshipAnswerSetSerializer
|
||||
|
||||
|
||||
class OrganisationExportView(base.CsvExportView):
|
||||
model = models.person.Organisation
|
||||
serializer_class = serializers.people.OrganisationSerializer
|
||||
|
||||
|
||||
class OrganisationAnswerSetExportView(base.CsvExportView):
|
||||
model = models.organisation.OrganisationAnswerSet
|
||||
serializer_class = serializers.people.OrganisationAnswerSetSerializer
|
||||
|
||||
|
||||
class OrganisationRelationshipExportView(base.CsvExportView):
|
||||
model = models.relationship.OrganisationRelationship
|
||||
serializer_class = serializers.people.OrganisationRelationshipSerializer
|
||||
|
||||
|
||||
class OrganisationRelationshipAnswerSetExportView(base.CsvExportView):
|
||||
model = models.relationship.OrganisationRelationshipAnswerSet
|
||||
serializer_class = serializers.people.OrganisationRelationshipAnswerSetSerializer
|
||||
@@ -7,6 +7,7 @@ import sys
|
||||
def main():
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'breccia_mapper.settings')
|
||||
try:
|
||||
# pylint: disable=import-outside-toplevel
|
||||
from django.core.management import execute_from_command_line
|
||||
except ImportError as exc:
|
||||
raise ImportError(
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
default_app_config = 'people.apps.PeopleConfig'
|
||||
|
||||
@@ -1,7 +1,98 @@
|
||||
"""
|
||||
Admin site panels for models in the People app.
|
||||
"""
|
||||
|
||||
from django.contrib import admin
|
||||
from django.contrib.auth.admin import UserAdmin
|
||||
|
||||
from . import models
|
||||
|
||||
|
||||
admin.site.register(models.User, UserAdmin)
|
||||
@admin.register(models.User)
|
||||
class CustomUserAdmin(UserAdmin):
|
||||
"""Add email address field to new user form."""
|
||||
add_fieldsets = UserAdmin.add_fieldsets + (
|
||||
('Details', {'fields': ('email', )}),
|
||||
) # yapf: disable
|
||||
|
||||
|
||||
class OrganisationQuestionChoiceInline(admin.TabularInline):
|
||||
model = models.OrganisationQuestionChoice
|
||||
|
||||
|
||||
@admin.register(models.OrganisationQuestion)
|
||||
class OrganisationQuestionAdmin(admin.ModelAdmin):
|
||||
inlines = [
|
||||
OrganisationQuestionChoiceInline,
|
||||
]
|
||||
|
||||
|
||||
class OrganisationAnswerSetInline(admin.TabularInline):
|
||||
model = models.OrganisationAnswerSet
|
||||
readonly_fields = [
|
||||
'question_answers',
|
||||
]
|
||||
|
||||
|
||||
@admin.register(models.Organisation)
|
||||
class OrganisationAdmin(admin.ModelAdmin):
|
||||
inlines = [
|
||||
OrganisationAnswerSetInline,
|
||||
]
|
||||
|
||||
|
||||
class PersonQuestionChoiceInline(admin.TabularInline):
|
||||
model = models.PersonQuestionChoice
|
||||
|
||||
|
||||
@admin.register(models.PersonQuestion)
|
||||
class PersonQuestionAdmin(admin.ModelAdmin):
|
||||
inlines = [
|
||||
PersonQuestionChoiceInline,
|
||||
]
|
||||
|
||||
|
||||
class PersonAnswerSetInline(admin.TabularInline):
|
||||
model = models.PersonAnswerSet
|
||||
readonly_fields = [
|
||||
'question_answers',
|
||||
]
|
||||
|
||||
|
||||
@admin.register(models.Person)
|
||||
class PersonAdmin(admin.ModelAdmin):
|
||||
inlines = [
|
||||
PersonAnswerSetInline,
|
||||
]
|
||||
|
||||
|
||||
class RelationshipQuestionChoiceInline(admin.TabularInline):
|
||||
model = models.RelationshipQuestionChoice
|
||||
|
||||
|
||||
@admin.register(models.RelationshipQuestion)
|
||||
class RelationshipQuestionAdmin(admin.ModelAdmin):
|
||||
inlines = [
|
||||
RelationshipQuestionChoiceInline,
|
||||
]
|
||||
|
||||
|
||||
@admin.register(models.Relationship)
|
||||
class RelationshipAdmin(admin.ModelAdmin):
|
||||
ordering = ['source__name', 'target__name']
|
||||
|
||||
|
||||
class OrganisationRelationshipQuestionChoiceInline(admin.TabularInline):
|
||||
model = models.OrganisationRelationshipQuestionChoice
|
||||
|
||||
|
||||
@admin.register(models.OrganisationRelationshipQuestion)
|
||||
class OrganisationRelationshipQuestionAdmin(admin.ModelAdmin):
|
||||
inlines = [
|
||||
OrganisationRelationshipQuestionChoiceInline,
|
||||
]
|
||||
|
||||
|
||||
@admin.register(models.OrganisationRelationship)
|
||||
class OrganisationRelationshipAdmin(admin.ModelAdmin):
|
||||
ordering = ['source__name', 'target__name']
|
||||
|
||||
@@ -1,5 +1,69 @@
|
||||
import logging
|
||||
|
||||
from django.apps import AppConfig
|
||||
from django.conf import settings
|
||||
from django.core import serializers
|
||||
from django.db.models.signals import post_save
|
||||
|
||||
logger = logging.getLogger(__name__) # pylint: disable=invalid-name
|
||||
|
||||
|
||||
def load_welcome_template_fixture(fixture_path) -> bool:
|
||||
"""Load welcome email template from a JSON fixture."""
|
||||
try:
|
||||
with open(fixture_path) as f:
|
||||
for deserialized in serializers.deserialize('json', f):
|
||||
if deserialized.object.name == settings.TEMPLATE_WELCOME_EMAIL_NAME:
|
||||
deserialized.save()
|
||||
logger.warning('Welcome email template \'%s\' loaded',
|
||||
deserialized.object.name)
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
except FileNotFoundError:
|
||||
logger.warning('Email template fixture not found.')
|
||||
return False
|
||||
|
||||
|
||||
def send_welcome_email(sender, instance, created, **kwargs):
|
||||
from post_office import models
|
||||
|
||||
if not created:
|
||||
# If user already exists, don't send welcome message
|
||||
return
|
||||
|
||||
try:
|
||||
instance.send_welcome_email()
|
||||
|
||||
except models.EmailTemplate.DoesNotExist:
|
||||
logger.warning(
|
||||
'Welcome email template \'%s\' not found - attempting to load from fixtures',
|
||||
settings.TEMPLATE_WELCOME_EMAIL_NAME)
|
||||
|
||||
is_loaded = False
|
||||
if settings.CUSTOMISATION_NAME:
|
||||
# Customisation app present - try here first
|
||||
is_loaded |= load_welcome_template_fixture(
|
||||
settings.BASE_DIR.joinpath('custom', 'fixtures',
|
||||
'email_templates.json'))
|
||||
|
||||
# |= operator shortcuts - only try here if we don't already have it
|
||||
is_loaded |= load_welcome_template_fixture(
|
||||
settings.BASE_DIR.joinpath('people', 'fixtures',
|
||||
'email_templates.json'))
|
||||
|
||||
if is_loaded:
|
||||
instance.send_welcome_email()
|
||||
|
||||
else:
|
||||
logger.error('Welcome email template \'%s\' not found',
|
||||
settings.TEMPLATE_WELCOME_EMAIL_NAME)
|
||||
|
||||
|
||||
class PeopleConfig(AppConfig):
|
||||
name = 'people'
|
||||
|
||||
def ready(self) -> None:
|
||||
# Activate signal handlers
|
||||
post_save.connect(send_welcome_email, sender='people.user')
|
||||
|
||||
16
people/fixtures/email_templates.json
Normal file
16
people/fixtures/email_templates.json
Normal file
@@ -0,0 +1,16 @@
|
||||
[
|
||||
{
|
||||
"model": "post_office.emailtemplate",
|
||||
"fields": {
|
||||
"name": "welcome-email",
|
||||
"description": "Default welcome email template",
|
||||
"created": "2020-04-27T12:13:30.448Z",
|
||||
"last_updated": "2020-04-27T14:45:27.152Z",
|
||||
"subject": "Welcome to {{settings.PROJECT_LONG_NAME}}",
|
||||
"content": "Dear {{ user.get_full_name }},\r\n\r\nWelcome to {{ settings.PROJECT_LONG_NAME }}.\r\n\r\nThanks,\r\n\r\nThe {{ settings.PROJECT_LONG_NAME }} team",
|
||||
"html_content": "<h1>{{ settings.PROJECT_LONG_NAME }}</h1>\r\n\r\nDear {{ user.get_full_name }},\r\n\r\nWelcome to {{ settings.PROJECT_LONG_NAME }}.\r\n\r\nThanks,\r\n\r\nThe {{ settings.PROJECT_LONG_NAME }} team",
|
||||
"language": "",
|
||||
"default_template": null
|
||||
}
|
||||
}
|
||||
]
|
||||
359
people/forms.py
Normal file
359
people/forms.py
Normal file
@@ -0,0 +1,359 @@
|
||||
"""Forms for creating / updating models belonging to the 'people' app."""
|
||||
|
||||
import typing
|
||||
|
||||
from django import forms
|
||||
from django.conf import settings
|
||||
|
||||
from bootstrap_datepicker_plus import DatePickerInput
|
||||
from django_select2.forms import ModelSelect2Widget, Select2Widget, Select2MultipleWidget
|
||||
|
||||
from . import models
|
||||
|
||||
|
||||
class OrganisationForm(forms.ModelForm):
|
||||
"""Form for creating / updating an instance of :class:`Organisation`."""
|
||||
class Meta:
|
||||
model = models.Organisation
|
||||
fields = [
|
||||
'name',
|
||||
]
|
||||
|
||||
|
||||
class PersonForm(forms.ModelForm):
|
||||
"""Form for creating / updating an instance of :class:`Person`."""
|
||||
class Meta:
|
||||
model = models.Person
|
||||
fields = [
|
||||
'name',
|
||||
]
|
||||
|
||||
|
||||
class RelationshipForm(forms.Form):
|
||||
target = forms.ModelChoiceField(
|
||||
models.Person.objects.all(),
|
||||
widget=ModelSelect2Widget(search_fields=['name__icontains']))
|
||||
|
||||
|
||||
class DynamicAnswerSetBase(forms.Form):
|
||||
field_class = forms.ModelChoiceField
|
||||
field_required = True
|
||||
field_widget: typing.Optional[typing.Type[forms.Widget]] = None
|
||||
question_model: typing.Type[models.Question]
|
||||
answer_model: typing.Type[models.QuestionChoice]
|
||||
question_prefix: str = ''
|
||||
as_filters: bool = False
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.negative_responses = {}
|
||||
field_order = []
|
||||
|
||||
for question in self.question_model.objects.all():
|
||||
if self.as_filters and not question.answer_is_public:
|
||||
continue
|
||||
|
||||
# Is a placeholder question just for sorting hardcoded questions?
|
||||
if (
|
||||
question.is_hardcoded
|
||||
and (self.as_filters or (question.hardcoded_field in self.Meta.fields))
|
||||
):
|
||||
field_order.append(question.hardcoded_field)
|
||||
continue
|
||||
|
||||
field_class = self.field_class
|
||||
field_widget = self.field_widget
|
||||
|
||||
if question.is_multiple_choice:
|
||||
field_class = forms.ModelMultipleChoiceField
|
||||
field_widget = Select2MultipleWidget
|
||||
|
||||
field_name = f'{self.question_prefix}question_{question.pk}'
|
||||
|
||||
# If being used as a filter - do we have alternate text?
|
||||
field_label = question.text
|
||||
if self.as_filters and question.filter_text:
|
||||
field_label = question.filter_text
|
||||
|
||||
field = field_class(
|
||||
label=field_label,
|
||||
queryset=question.answers,
|
||||
widget=field_widget,
|
||||
required=(self.field_required
|
||||
and not question.allow_free_text),
|
||||
initial=self.initial.get(field_name, None),
|
||||
help_text=question.help_text if not self.as_filters else '')
|
||||
self.fields[field_name] = field
|
||||
field_order.append(field_name)
|
||||
|
||||
try:
|
||||
negative_response = question.answers.get(is_negative_response=True)
|
||||
self.negative_responses[field_name] = negative_response.id
|
||||
|
||||
except (self.answer_model.DoesNotExist, self.answer_model.MultipleObjectsReturned):
|
||||
pass
|
||||
|
||||
if question.allow_free_text and not self.as_filters:
|
||||
free_field = forms.CharField(label=f'{question} free text',
|
||||
required=False)
|
||||
self.fields[f'{field_name}_free'] = free_field
|
||||
field_order.append(f'{field_name}_free')
|
||||
|
||||
self.order_fields(field_order)
|
||||
|
||||
|
||||
class OrganisationAnswerSetForm(forms.ModelForm, DynamicAnswerSetBase):
|
||||
"""Form for variable organisation attributes.
|
||||
|
||||
Dynamic fields inspired by https://jacobian.org/2010/feb/28/dynamic-form-generation/
|
||||
"""
|
||||
class Meta:
|
||||
model = models.OrganisationAnswerSet
|
||||
fields = [
|
||||
'name',
|
||||
'website',
|
||||
'countries',
|
||||
'hq_country',
|
||||
'is_partner_organisation',
|
||||
'latitude',
|
||||
'longitude',
|
||||
]
|
||||
labels = {
|
||||
'is_partner_organisation':
|
||||
f'Is this organisation a {settings.PARENT_PROJECT_NAME} partner organisation?'
|
||||
}
|
||||
widgets = {
|
||||
'countries': Select2MultipleWidget(),
|
||||
'hq_country': Select2Widget(),
|
||||
'latitude': forms.HiddenInput,
|
||||
'longitude': forms.HiddenInput,
|
||||
}
|
||||
|
||||
question_model = models.OrganisationQuestion
|
||||
answer_model = models.OrganisationQuestionChoice
|
||||
|
||||
def save(self, commit=True) -> models.OrganisationAnswerSet:
|
||||
# Save model
|
||||
self.instance = super().save(commit=False)
|
||||
self.instance.organisation_id = self.initial['organisation_id']
|
||||
if commit:
|
||||
self.instance.save()
|
||||
# Need to call same_m2m manually since we use commit=False above
|
||||
self.save_m2m()
|
||||
|
||||
if commit:
|
||||
# Save answers to questions
|
||||
for key, value in self.cleaned_data.items():
|
||||
if key.startswith('question_') and value:
|
||||
if key.endswith('_free'):
|
||||
# Create new answer from free text
|
||||
value, _ = self.answer_model.objects.get_or_create(
|
||||
text=value,
|
||||
question=self.question_model.objects.get(
|
||||
pk=key.split('_')[1]))
|
||||
|
||||
try:
|
||||
self.instance.question_answers.add(value)
|
||||
|
||||
except TypeError:
|
||||
# Value is a QuerySet - multiple choice question
|
||||
self.instance.question_answers.add(*value.all())
|
||||
|
||||
return self.instance
|
||||
|
||||
|
||||
class PersonAnswerSetForm(forms.ModelForm, DynamicAnswerSetBase):
|
||||
"""Form for variable person attributes.
|
||||
|
||||
Dynamic fields inspired by https://jacobian.org/2010/feb/28/dynamic-form-generation/
|
||||
"""
|
||||
class Meta:
|
||||
model = models.PersonAnswerSet
|
||||
fields = [
|
||||
'nationality',
|
||||
'country_of_residence',
|
||||
'organisation',
|
||||
'organisation_started_date',
|
||||
'project_started_date',
|
||||
'job_title',
|
||||
'disciplinary_background',
|
||||
'external_organisations',
|
||||
'latitude',
|
||||
'longitude',
|
||||
]
|
||||
widgets = {
|
||||
'nationality': Select2MultipleWidget(),
|
||||
'country_of_residence': Select2Widget(),
|
||||
'organisation_started_date': DatePickerInput(format='%Y-%m-%d'),
|
||||
'project_started_date': DatePickerInput(format='%Y-%m-%d'),
|
||||
'latitude': forms.HiddenInput,
|
||||
'longitude': forms.HiddenInput,
|
||||
}
|
||||
labels = {
|
||||
'project_started_date':
|
||||
f'Date started on the {settings.PARENT_PROJECT_NAME} project',
|
||||
'external_organisations':
|
||||
'Please list the main organisations external to BRECcIA work that you have been working with since 1st January 2019 that are involved in food/water security in African dryland regions'
|
||||
}
|
||||
help_texts = {
|
||||
'organisation_started_date':
|
||||
'If you don\'t know the exact date, an approximate date is okay.',
|
||||
'project_started_date':
|
||||
'If you don\'t know the exact date, an approximate date is okay.',
|
||||
}
|
||||
|
||||
question_model = models.PersonQuestion
|
||||
answer_model = models.PersonQuestionChoice
|
||||
|
||||
def save(self, commit=True) -> models.PersonAnswerSet:
|
||||
# Save model
|
||||
self.instance = super().save(commit=False)
|
||||
self.instance.person_id = self.initial['person_id']
|
||||
if commit:
|
||||
self.instance.save()
|
||||
# Need to call same_m2m manually since we use commit=False above
|
||||
self.save_m2m()
|
||||
|
||||
if commit:
|
||||
# Save answers to questions
|
||||
for key, value in self.cleaned_data.items():
|
||||
if key.startswith('question_') and value:
|
||||
if key.endswith('_free'):
|
||||
# Create new answer from free text
|
||||
value, _ = self.answer_model.objects.get_or_create(
|
||||
text=value,
|
||||
question=self.question_model.objects.get(
|
||||
pk=key.split('_')[1]))
|
||||
|
||||
try:
|
||||
self.instance.question_answers.add(value)
|
||||
|
||||
except TypeError:
|
||||
# Value is a QuerySet - multiple choice question
|
||||
self.instance.question_answers.add(*value.all())
|
||||
|
||||
return self.instance
|
||||
|
||||
|
||||
class RelationshipAnswerSetForm(forms.ModelForm, DynamicAnswerSetBase):
|
||||
"""
|
||||
Form to allow users to describe a relationship.
|
||||
|
||||
Dynamic fields inspired by https://jacobian.org/2010/feb/28/dynamic-form-generation/
|
||||
"""
|
||||
class Meta:
|
||||
model = models.RelationshipAnswerSet
|
||||
fields = [
|
||||
'relationship',
|
||||
]
|
||||
widgets = {
|
||||
'relationship': forms.HiddenInput,
|
||||
}
|
||||
|
||||
question_model = models.RelationshipQuestion
|
||||
answer_model = models.RelationshipQuestionChoice
|
||||
|
||||
def save(self, commit=True) -> models.RelationshipAnswerSet:
|
||||
# Save model
|
||||
self.instance = super().save(commit=commit)
|
||||
|
||||
if commit:
|
||||
# Save answers to questions
|
||||
for key, value in self.cleaned_data.items():
|
||||
if key.startswith('question_') and value:
|
||||
if key.endswith('_free'):
|
||||
# Create new answer from free text
|
||||
value, _ = self.answer_model.objects.get_or_create(
|
||||
text=value,
|
||||
question=self.question_model.objects.get(
|
||||
pk=key.split('_')[1]))
|
||||
|
||||
try:
|
||||
self.instance.question_answers.add(value)
|
||||
|
||||
except TypeError:
|
||||
# Value is a QuerySet - multiple choice question
|
||||
self.instance.question_answers.add(*value.all())
|
||||
|
||||
return self.instance
|
||||
|
||||
|
||||
class OrganisationRelationshipAnswerSetForm(forms.ModelForm,
|
||||
DynamicAnswerSetBase):
|
||||
"""Form to allow users to describe a relationship with an organisation.
|
||||
|
||||
Dynamic fields inspired by https://jacobian.org/2010/feb/28/dynamic-form-generation/
|
||||
"""
|
||||
class Meta:
|
||||
model = models.OrganisationRelationshipAnswerSet
|
||||
fields = [
|
||||
'relationship',
|
||||
]
|
||||
widgets = {
|
||||
'relationship': forms.HiddenInput,
|
||||
}
|
||||
|
||||
question_model = models.OrganisationRelationshipQuestion
|
||||
answer_model = models.OrganisationRelationshipQuestionChoice
|
||||
|
||||
def save(self, commit=True) -> models.OrganisationRelationshipAnswerSet:
|
||||
# Save model
|
||||
self.instance = super().save(commit=commit)
|
||||
|
||||
if commit:
|
||||
# Save answers to questions
|
||||
for key, value in self.cleaned_data.items():
|
||||
if key.startswith('question_') and value:
|
||||
if key.endswith('_free'):
|
||||
# Create new answer from free text
|
||||
value, _ = self.answer_model.objects.get_or_create(
|
||||
text=value,
|
||||
question=self.question_model.objects.get(
|
||||
pk=key.split('_')[1]))
|
||||
|
||||
try:
|
||||
self.instance.question_answers.add(value)
|
||||
|
||||
except TypeError:
|
||||
# Value is a QuerySet - multiple choice question
|
||||
self.instance.question_answers.add(*value.all())
|
||||
|
||||
return self.instance
|
||||
|
||||
|
||||
class DateForm(forms.Form):
|
||||
date = forms.DateField(
|
||||
required=False,
|
||||
widget=DatePickerInput(format='%Y-%m-%d'),
|
||||
help_text='Show relationships as they were on this date'
|
||||
)
|
||||
|
||||
|
||||
class FilterForm(DynamicAnswerSetBase):
|
||||
"""Filter objects by answerset responses."""
|
||||
field_class = forms.ModelMultipleChoiceField
|
||||
field_widget = Select2MultipleWidget
|
||||
field_required = False
|
||||
as_filters = True
|
||||
|
||||
|
||||
class NetworkRelationshipFilterForm(FilterForm):
|
||||
"""Filer relationships by answerset responses."""
|
||||
question_model = models.RelationshipQuestion
|
||||
answer_model = models.RelationshipQuestionChoice
|
||||
question_prefix = 'relationship_'
|
||||
|
||||
|
||||
class NetworkPersonFilterForm(FilterForm):
|
||||
"""Filer people by answerset responses."""
|
||||
question_model = models.PersonQuestion
|
||||
answer_model = models.PersonQuestionChoice
|
||||
question_prefix = 'person_'
|
||||
|
||||
|
||||
class NetworkOrganisationFilterForm(FilterForm):
|
||||
"""Filer organisations by answerset responses."""
|
||||
question_model = models.OrganisationQuestion
|
||||
answer_model = models.OrganisationQuestionChoice
|
||||
question_prefix = 'organisation_'
|
||||
59
people/migrations/0002_add_relationship_models.py
Normal file
59
people/migrations/0002_add_relationship_models.py
Normal file
@@ -0,0 +1,59 @@
|
||||
# Generated by Django 2.2.9 on 2020-01-30 15:02
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('people', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Person',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=255)),
|
||||
('core_member', models.BooleanField(default=False)),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='RelationshipQuestion',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('version', models.PositiveSmallIntegerField(default=1)),
|
||||
('text', models.CharField(max_length=255)),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='RelationshipQuestionChoice',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('text', models.CharField(max_length=255)),
|
||||
('question', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='answers', to='people.RelationshipQuestion')),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Relationship',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('source', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='relationships_as_source', to='people.Person')),
|
||||
('target', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='relationships_as_target', to='people.Person')),
|
||||
],
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='person',
|
||||
name='relationship_targets',
|
||||
field=models.ManyToManyField(related_name='relationship_sources', through='people.Relationship', to='people.Person'),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='relationshipquestionchoice',
|
||||
constraint=models.UniqueConstraint(fields=('question', 'text'), name='unique_question_answer'),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='relationship',
|
||||
constraint=models.UniqueConstraint(fields=('source', 'target'), name='unique_relationship'),
|
||||
),
|
||||
]
|
||||
17
people/migrations/0003_fix_people_plural.py
Normal file
17
people/migrations/0003_fix_people_plural.py
Normal file
@@ -0,0 +1,17 @@
|
||||
# Generated by Django 2.2.9 on 2020-02-14 09:45
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('people', '0002_add_relationship_models'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name='person',
|
||||
options={'verbose_name_plural': 'people'},
|
||||
),
|
||||
]
|
||||
20
people/migrations/0004_person_user.py
Normal file
20
people/migrations/0004_person_user.py
Normal file
@@ -0,0 +1,20 @@
|
||||
# Generated by Django 2.2.10 on 2020-02-18 15:44
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('people', '0003_fix_people_plural'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='person',
|
||||
name='user',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='person', to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
]
|
||||
20
people/migrations/0005_user_one_person.py
Normal file
20
people/migrations/0005_user_one_person.py
Normal file
@@ -0,0 +1,20 @@
|
||||
# Generated by Django 2.2.10 on 2020-02-19 08:09
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('people', '0004_person_user'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='person',
|
||||
name='user',
|
||||
field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='person', to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
]
|
||||
31
people/migrations/0006_relationship_questions_order.py
Normal file
31
people/migrations/0006_relationship_questions_order.py
Normal file
@@ -0,0 +1,31 @@
|
||||
# Generated by Django 2.2.10 on 2020-02-20 10:08
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('people', '0005_user_one_person'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name='relationshipquestion',
|
||||
options={'ordering': ['order', 'text']},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='relationshipquestionchoice',
|
||||
options={'ordering': ['order', 'text']},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='relationshipquestion',
|
||||
name='order',
|
||||
field=models.SmallIntegerField(default=0),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='relationshipquestionchoice',
|
||||
name='order',
|
||||
field=models.SmallIntegerField(default=0),
|
||||
),
|
||||
]
|
||||
18
people/migrations/0007_relationship_question_answers.py
Normal file
18
people/migrations/0007_relationship_question_answers.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 2.2.10 on 2020-02-20 13:47
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('people', '0006_relationship_questions_order'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='relationship',
|
||||
name='question_answers',
|
||||
field=models.ManyToManyField(to='people.RelationshipQuestionChoice'),
|
||||
),
|
||||
]
|
||||
17
people/migrations/0008_order_answer_by_question.py
Normal file
17
people/migrations/0008_order_answer_by_question.py
Normal file
@@ -0,0 +1,17 @@
|
||||
# Generated by Django 2.2.10 on 2020-02-20 15:23
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('people', '0007_relationship_question_answers'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name='relationshipquestionchoice',
|
||||
options={'ordering': ['question__order', 'order', 'text']},
|
||||
),
|
||||
]
|
||||
23
people/migrations/0009_add_first_person_fields.py
Normal file
23
people/migrations/0009_add_first_person_fields.py
Normal file
@@ -0,0 +1,23 @@
|
||||
# Generated by Django 2.2.10 on 2020-02-21 16:49
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('people', '0008_order_answer_by_question'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='person',
|
||||
name='age_group',
|
||||
field=models.CharField(blank=True, choices=[('<=25', '25 or under'), ('26-30', '26-30'), ('31-35', '31-35'), ('36-40', '36-40'), ('41-45', '41-45'), ('46-50', '46-50'), ('51-55', '51-55'), ('56-60', '56-60'), ('>=61', '61 or older'), ('N', 'Prefer not to say')], max_length=5),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='person',
|
||||
name='gender',
|
||||
field=models.CharField(blank=True, choices=[('M', 'Male'), ('F', 'Female'), ('O', 'Other'), ('N', 'Prefer not to say')], max_length=1),
|
||||
),
|
||||
]
|
||||
24
people/migrations/0010_add_country_fields.py
Normal file
24
people/migrations/0010_add_country_fields.py
Normal file
@@ -0,0 +1,24 @@
|
||||
# Generated by Django 2.2.10 on 2020-02-24 08:16
|
||||
|
||||
from django.db import migrations
|
||||
import django_countries.fields
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('people', '0009_add_first_person_fields'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='person',
|
||||
name='country_of_residence',
|
||||
field=django_countries.fields.CountryField(blank=True, max_length=2, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='person',
|
||||
name='nationality',
|
||||
field=django_countries.fields.CountryField(blank=True, max_length=2, null=True),
|
||||
),
|
||||
]
|
||||
26
people/migrations/0011_person_add_organisation.py
Normal file
26
people/migrations/0011_person_add_organisation.py
Normal file
@@ -0,0 +1,26 @@
|
||||
# Generated by Django 2.2.10 on 2020-02-25 08:49
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('people', '0010_add_country_fields'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Organisation',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=255)),
|
||||
],
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='person',
|
||||
name='organisation',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='members', to='people.Organisation'),
|
||||
),
|
||||
]
|
||||
18
people/migrations/0012_person_job_title.py
Normal file
18
people/migrations/0012_person_job_title.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 2.2.10 on 2020-02-25 09:01
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('people', '0011_person_add_organisation'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='person',
|
||||
name='job_title',
|
||||
field=models.CharField(blank=True, max_length=255),
|
||||
),
|
||||
]
|
||||
26
people/migrations/0013_person_role.py
Normal file
26
people/migrations/0013_person_role.py
Normal file
@@ -0,0 +1,26 @@
|
||||
# Generated by Django 2.2.10 on 2020-02-25 09:10
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('people', '0012_person_job_title'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Role',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=255)),
|
||||
],
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='person',
|
||||
name='role',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='holders', to='people.Role'),
|
||||
),
|
||||
]
|
||||
39
people/migrations/0014_person_role_themes.py
Normal file
39
people/migrations/0014_person_role_themes.py
Normal file
@@ -0,0 +1,39 @@
|
||||
# Generated by Django 2.2.10 on 2020-02-25 11:12
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('people', '0013_person_role'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Discipline',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=255)),
|
||||
('code', models.CharField(blank=True, max_length=15)),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Theme',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=255)),
|
||||
],
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='person',
|
||||
name='discipline',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='people', to='people.Discipline'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='person',
|
||||
name='themes',
|
||||
field=models.ManyToManyField(blank=True, related_name='people', to='people.Theme'),
|
||||
),
|
||||
]
|
||||
23
people/migrations/0015_shrink_name_fields_to_255.py
Normal file
23
people/migrations/0015_shrink_name_fields_to_255.py
Normal file
@@ -0,0 +1,23 @@
|
||||
# Generated by Django 2.2.10 on 2020-02-28 15:03
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('people', '0014_person_role_themes'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='relationshipquestion',
|
||||
name='text',
|
||||
field=models.CharField(max_length=255),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='relationshipquestionchoice',
|
||||
name='text',
|
||||
field=models.CharField(max_length=255),
|
||||
),
|
||||
]
|
||||
78
people/migrations/0016_add_answer_set.py
Normal file
78
people/migrations/0016_add_answer_set.py
Normal file
@@ -0,0 +1,78 @@
|
||||
# Generated by Django 2.2.10 on 2020-03-04 12:09
|
||||
import logging
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import django.utils.timezone
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def forward_migration(apps, schema_editor):
|
||||
"""
|
||||
Move existing data forward into answer sets from the relationship.
|
||||
"""
|
||||
Relationship = apps.get_model('people', 'Relationship')
|
||||
|
||||
for relationship in Relationship.objects.all():
|
||||
answer_set = relationship.answer_sets.first()
|
||||
if answer_set is None:
|
||||
answer_set = relationship.answer_sets.create()
|
||||
|
||||
for answer in relationship.question_answers.all():
|
||||
answer_set.question_answers.add(answer)
|
||||
|
||||
|
||||
def backward_migration(apps, schema_editor):
|
||||
"""
|
||||
Move data backward from answer sets onto the relationship.
|
||||
"""
|
||||
Relationship = apps.get_model('people', 'Relationship')
|
||||
|
||||
for relationship in Relationship.objects.all():
|
||||
answer_set = relationship.answer_sets.last()
|
||||
|
||||
try:
|
||||
for answer in answer_set.question_answers.all():
|
||||
relationship.question_answers.add(answer)
|
||||
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('people', '0015_shrink_name_fields_to_255'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='relationship',
|
||||
name='created',
|
||||
field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now),
|
||||
preserve_default=False,
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='relationship',
|
||||
name='expired',
|
||||
field=models.DateTimeField(blank=True, null=True),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='RelationshipAnswerSet',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('timestamp', models.DateTimeField(auto_now_add=True)),
|
||||
('question_answers', models.ManyToManyField(to='people.RelationshipQuestionChoice')),
|
||||
('relationship', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='answer_sets', to='people.Relationship')),
|
||||
],
|
||||
options={
|
||||
'ordering': ['timestamp'],
|
||||
},
|
||||
),
|
||||
migrations.RunPython(forward_migration, backward_migration),
|
||||
migrations.RemoveField(
|
||||
model_name='relationship',
|
||||
name='question_answers',
|
||||
),
|
||||
]
|
||||
18
people/migrations/0017_answerset_replaced_timestamp.py
Normal file
18
people/migrations/0017_answerset_replaced_timestamp.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 2.2.10 on 2020-03-09 15:23
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('people', '0016_add_answer_set'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='relationshipanswerset',
|
||||
name='replaced_timestamp',
|
||||
field=models.DateTimeField(blank=True, editable=False, null=True),
|
||||
),
|
||||
]
|
||||
18
people/migrations/0018_require_user_email.py
Normal file
18
people/migrations/0018_require_user_email.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 2.2.10 on 2020-05-28 15:17
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('people', '0017_answerset_replaced_timestamp'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='user',
|
||||
name='email',
|
||||
field=models.EmailField(max_length=254, verbose_name='email address'),
|
||||
),
|
||||
]
|
||||
17
people/migrations/0019_remove_person_core_member.py
Normal file
17
people/migrations/0019_remove_person_core_member.py
Normal file
@@ -0,0 +1,17 @@
|
||||
# Generated by Django 2.2.10 on 2020-06-24 11:14
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('people', '0018_require_user_email'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='person',
|
||||
name='core_member',
|
||||
),
|
||||
]
|
||||
18
people/migrations/0020_person_organisation_started_date.py
Normal file
18
people/migrations/0020_person_organisation_started_date.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 2.2.10 on 2020-06-24 12:52
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('people', '0019_remove_person_core_member'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='person',
|
||||
name='organisation_started_date',
|
||||
field=models.DateField(null=True, verbose_name='Date started at this organisation'),
|
||||
),
|
||||
]
|
||||
61
people/migrations/0021_refactor_person_disciplines.py
Normal file
61
people/migrations/0021_refactor_person_disciplines.py
Normal file
@@ -0,0 +1,61 @@
|
||||
# Generated by Django 2.2.10 on 2020-06-24 14:19
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def migrate_forward(apps, schema_editor):
|
||||
Person = apps.get_model('people', 'Person')
|
||||
|
||||
for person in Person.objects.all():
|
||||
try:
|
||||
person.disciplines = person.discipline.name
|
||||
person.save()
|
||||
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
|
||||
def migrate_backward(apps, schema_editor):
|
||||
Person = apps.get_model('people', 'Person')
|
||||
Discipline = apps.get_model('people', 'Discipline')
|
||||
|
||||
for person in Person.objects.all():
|
||||
try:
|
||||
discipline_str = person.disciplines.split(',')[0]
|
||||
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
else:
|
||||
# Returns None if not found - doesn't raise exception
|
||||
discipline = Discipline.objects.filter(name=discipline_str).first()
|
||||
|
||||
if not discipline:
|
||||
discipline = Discipline.objects.create(
|
||||
name=discipline_str,
|
||||
code=discipline_str
|
||||
if len(discipline_str) < 15 else discipline_str[:15])
|
||||
|
||||
person.discipline = discipline
|
||||
person.save()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('people', '0020_person_organisation_started_date'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='person',
|
||||
name='disciplines',
|
||||
field=models.CharField(blank=True, max_length=255, null=True),
|
||||
),
|
||||
migrations.RunPython(migrate_forward, migrate_backward),
|
||||
migrations.RemoveField(
|
||||
model_name='person',
|
||||
name='discipline',
|
||||
),
|
||||
migrations.DeleteModel(name='Discipline', ),
|
||||
]
|
||||
55
people/migrations/0022_refactor_person_questions.py
Normal file
55
people/migrations/0022_refactor_person_questions.py
Normal file
@@ -0,0 +1,55 @@
|
||||
# Generated by Django 2.2.10 on 2020-11-23 14:29
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('people', '0021_refactor_person_disciplines'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='PersonQuestion',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('version', models.PositiveSmallIntegerField(default=1)),
|
||||
('text', models.CharField(max_length=255)),
|
||||
('order', models.SmallIntegerField(default=0)),
|
||||
],
|
||||
options={
|
||||
'ordering': ['order', 'text'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='PersonQuestionChoice',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('text', models.CharField(max_length=255)),
|
||||
('order', models.SmallIntegerField(default=0)),
|
||||
('question', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='answers', to='people.PersonQuestion')),
|
||||
],
|
||||
options={
|
||||
'ordering': ['question__order', 'order', 'text'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='PersonAnswerSet',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('timestamp', models.DateTimeField(auto_now_add=True)),
|
||||
('replaced_timestamp', models.DateTimeField(blank=True, editable=False, null=True)),
|
||||
('person', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='answer_sets', to='people.Person')),
|
||||
('question_answers', models.ManyToManyField(to='people.PersonQuestionChoice')),
|
||||
],
|
||||
options={
|
||||
'ordering': ['timestamp'],
|
||||
},
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='personquestionchoice',
|
||||
constraint=models.UniqueConstraint(fields=('question', 'text'), name='unique_question_answer'),
|
||||
),
|
||||
]
|
||||
65
people/migrations/0023_remove_person_role.py
Normal file
65
people/migrations/0023_remove_person_role.py
Normal file
@@ -0,0 +1,65 @@
|
||||
# Generated by Django 2.2.10 on 2020-11-25 15:50
|
||||
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
from django.db import migrations
|
||||
from django.utils import timezone
|
||||
|
||||
from .utils.question_sets import port_question
|
||||
|
||||
|
||||
def migrate_forward(apps, schema_editor):
|
||||
Person = apps.get_model('people', 'Person')
|
||||
Role = apps.get_model('people', 'Role')
|
||||
|
||||
role_question = port_question(apps, 'Role',
|
||||
Role.objects.values_list('name', flat=True))
|
||||
|
||||
for person in Person.objects.all():
|
||||
try:
|
||||
prev_set = person.answer_sets.latest('timestamp')
|
||||
|
||||
except ObjectDoesNotExist:
|
||||
prev_set = None
|
||||
|
||||
try:
|
||||
|
||||
answer_set = person.answer_sets.create()
|
||||
answer_set.question_answers.add(
|
||||
role_question.answers.get(text=person.role.name))
|
||||
|
||||
prev_set.replaced_timestamp = timezone.datetime.now()
|
||||
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
|
||||
def migrate_backward(apps, schema_editor):
|
||||
Person = apps.get_model('people', 'Person')
|
||||
Role = apps.get_model('people', 'Role')
|
||||
|
||||
for person in Person.objects.all():
|
||||
try:
|
||||
current_answers = person.answer_sets.latest('timestamp')
|
||||
role_answer = current_answers.question_answers.get(
|
||||
question__text='Role')
|
||||
person.role, _ = Role.objects.get_or_create(name=role_answer.text)
|
||||
person.save()
|
||||
|
||||
except ObjectDoesNotExist:
|
||||
pass
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('people', '0022_refactor_person_questions'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(migrate_forward, migrate_backward),
|
||||
migrations.RemoveField(
|
||||
model_name='person',
|
||||
name='role',
|
||||
),
|
||||
migrations.DeleteModel('Role'),
|
||||
]
|
||||
120
people/migrations/0024_remove_age_gender.py
Normal file
120
people/migrations/0024_remove_age_gender.py
Normal file
@@ -0,0 +1,120 @@
|
||||
# Generated by Django 2.2.10 on 2020-11-26 13:03
|
||||
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
from django.db import migrations
|
||||
|
||||
from backports.db.models.enums import TextChoices
|
||||
from .utils.question_sets import port_question
|
||||
|
||||
|
||||
class GenderChoices(TextChoices):
|
||||
MALE = 'M', 'Male'
|
||||
FEMALE = 'F', 'Female'
|
||||
OTHER = 'O', 'Other'
|
||||
PREFER_NOT_TO_SAY = 'N', 'Prefer not to say'
|
||||
|
||||
|
||||
class AgeGroupChoices(TextChoices):
|
||||
LTE_25 = '<=25', '25 or under'
|
||||
BETWEEN_26_30 = '26-30', '26-30'
|
||||
BETWEEN_31_35 = '31-35', '31-35'
|
||||
BETWEEN_36_40 = '36-40', '36-40'
|
||||
BETWEEN_41_45 = '41-45', '41-45'
|
||||
BETWEEN_46_50 = '46-50', '46-50'
|
||||
BETWEEN_51_55 = '51-55', '51-55'
|
||||
BETWEEN_56_60 = '56-60', '56-60'
|
||||
GTE_61 = '>=61', '61 or older'
|
||||
PREFER_NOT_TO_SAY = 'N', 'Prefer not to say'
|
||||
|
||||
|
||||
def migrate_forward(apps, schema_editor):
|
||||
Person = apps.get_model('people', 'Person')
|
||||
|
||||
gender_question = port_question(apps, 'Gender', GenderChoices.labels)
|
||||
age_question = port_question(apps, 'Age', AgeGroupChoices.labels)
|
||||
|
||||
for person in Person.objects.all():
|
||||
try:
|
||||
answer_set = person.answer_sets.latest('timestamp')
|
||||
|
||||
except ObjectDoesNotExist:
|
||||
answer_set = person.answer_sets.create()
|
||||
|
||||
try:
|
||||
gender = [
|
||||
item for item in GenderChoices if item.value == person.gender
|
||||
][0]
|
||||
answer_set.question_answers.filter(
|
||||
question__text=gender_question.text).delete()
|
||||
answer_set.question_answers.add(
|
||||
gender_question.answers.get(text__iexact=gender.label))
|
||||
|
||||
except (AttributeError, IndexError):
|
||||
pass
|
||||
|
||||
try:
|
||||
age = [
|
||||
item for item in AgeGroupChoices
|
||||
if item.value == person.age_group
|
||||
][0]
|
||||
answer_set.question_answers.filter(
|
||||
question__text=age_question.text).delete()
|
||||
answer_set.question_answers.add(
|
||||
age_question.answers.get(text__iexact=age.label))
|
||||
|
||||
except (AttributeError, IndexError):
|
||||
pass
|
||||
|
||||
|
||||
def migrate_backward(apps, schema_editor):
|
||||
Person = apps.get_model('people', 'Person')
|
||||
|
||||
for person in Person.objects.all():
|
||||
try:
|
||||
current_answers = person.answer_sets.latest('timestamp')
|
||||
age_answer = current_answers.question_answers.get(
|
||||
question__text='Age')
|
||||
|
||||
person.age_group = [
|
||||
item for item in AgeGroupChoices
|
||||
if item.label == age_answer.text
|
||||
][0].value
|
||||
|
||||
person.save()
|
||||
|
||||
except ObjectDoesNotExist:
|
||||
pass
|
||||
|
||||
try:
|
||||
current_answers = person.answer_sets.latest('timestamp')
|
||||
gender_answer = current_answers.question_answers.get(
|
||||
question__text='Gender')
|
||||
person.gender = [
|
||||
|
||||
item for item in GenderChoices
|
||||
if item.label == gender_answer.text
|
||||
][0].value
|
||||
|
||||
person.save()
|
||||
|
||||
except ObjectDoesNotExist:
|
||||
pass
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('people', '0023_remove_person_role'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(migrate_forward, migrate_backward),
|
||||
migrations.RemoveField(
|
||||
model_name='person',
|
||||
name='age_group',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='person',
|
||||
name='gender',
|
||||
),
|
||||
]
|
||||
12
people/migrations/0025_rename_relationship_target.py
Normal file
12
people/migrations/0025_rename_relationship_target.py
Normal file
@@ -0,0 +1,12 @@
|
||||
# Generated by Django 2.2.10 on 2020-11-27 08:49
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('people', '0024_remove_age_gender'),
|
||||
]
|
||||
|
||||
operations = []
|
||||
148
people/migrations/0026_move_static_person_questions.py
Normal file
148
people/migrations/0026_move_static_person_questions.py
Normal file
@@ -0,0 +1,148 @@
|
||||
# Generated by Django 2.2.10 on 2020-12-02 13:31
|
||||
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import django_countries.fields
|
||||
|
||||
|
||||
def migrate_forward(apps, schema_editor):
|
||||
Person = apps.get_model('people', 'Person')
|
||||
PersonAnswerset = apps.get_model('people', 'PersonAnswerSet')
|
||||
|
||||
fields = {
|
||||
'country_of_residence',
|
||||
'disciplines',
|
||||
'job_title',
|
||||
'nationality',
|
||||
'organisation',
|
||||
'organisation_started_date',
|
||||
'themes',
|
||||
}
|
||||
|
||||
for person in Person.objects.all():
|
||||
try:
|
||||
answer_set = person.answer_sets.last()
|
||||
|
||||
except ObjectDoesNotExist:
|
||||
answer_set = person.answer_sets.create()
|
||||
|
||||
for field in fields:
|
||||
value = getattr(person, field)
|
||||
try:
|
||||
setattr(answer_set, field, value)
|
||||
|
||||
except TypeError:
|
||||
# Cannot directly set an m2m field
|
||||
m2m = getattr(answer_set, field)
|
||||
m2m.set(value.all())
|
||||
|
||||
answer_set.save()
|
||||
|
||||
|
||||
def migrate_backward(apps, schema_editor):
|
||||
Person = apps.get_model('people', 'Person')
|
||||
PersonAnswerset = apps.get_model('people', 'PersonAnswerSet')
|
||||
|
||||
fields = {
|
||||
'country_of_residence',
|
||||
'disciplines',
|
||||
'job_title',
|
||||
'nationality',
|
||||
'organisation',
|
||||
'organisation_started_date',
|
||||
'themes',
|
||||
}
|
||||
|
||||
for person in Person.objects.all():
|
||||
try:
|
||||
answer_set = person.answer_sets.last()
|
||||
|
||||
for field in fields:
|
||||
value = getattr(answer_set, field)
|
||||
try:
|
||||
setattr(person, field, value)
|
||||
|
||||
except TypeError:
|
||||
# Cannot directly set an m2m field
|
||||
m2m = getattr(person, field)
|
||||
m2m.set(value.all())
|
||||
|
||||
person.save()
|
||||
|
||||
except ObjectDoesNotExist:
|
||||
pass
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('people', '0025_rename_relationship_target'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='personanswerset',
|
||||
name='country_of_residence',
|
||||
field=django_countries.fields.CountryField(blank=True, max_length=2, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='personanswerset',
|
||||
name='disciplines',
|
||||
field=models.CharField(blank=True, max_length=255, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='personanswerset',
|
||||
name='job_title',
|
||||
field=models.CharField(blank=True, max_length=255),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='personanswerset',
|
||||
name='nationality',
|
||||
field=django_countries.fields.CountryField(blank=True, max_length=2, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='personanswerset',
|
||||
name='organisation',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='members', to='people.Organisation'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='personanswerset',
|
||||
name='organisation_started_date',
|
||||
field=models.DateField(null=True, verbose_name='Date started at this organisation'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='personanswerset',
|
||||
name='themes',
|
||||
field=models.ManyToManyField(blank=True, related_name='people', to='people.Theme'),
|
||||
),
|
||||
migrations.RunPython(migrate_forward, migrate_backward),
|
||||
migrations.RemoveField(
|
||||
model_name='person',
|
||||
name='country_of_residence',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='person',
|
||||
name='disciplines',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='person',
|
||||
name='job_title',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='person',
|
||||
name='nationality',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='person',
|
||||
name='organisation',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='person',
|
||||
name='organisation_started_date',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='person',
|
||||
name='themes',
|
||||
),
|
||||
]
|
||||
23
people/migrations/0027_multiple_choice_questions.py
Normal file
23
people/migrations/0027_multiple_choice_questions.py
Normal file
@@ -0,0 +1,23 @@
|
||||
# Generated by Django 2.2.10 on 2020-12-07 16:39
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('people', '0026_move_static_person_questions'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='personquestion',
|
||||
name='is_multiple_choice',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='relationshipquestion',
|
||||
name='is_multiple_choice',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
]
|
||||
23
people/migrations/0028_person_location_fields.py
Normal file
23
people/migrations/0028_person_location_fields.py
Normal file
@@ -0,0 +1,23 @@
|
||||
# Generated by Django 2.2.10 on 2020-12-15 13:42
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('people', '0027_multiple_choice_questions'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='personanswerset',
|
||||
name='latitude',
|
||||
field=models.FloatField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='personanswerset',
|
||||
name='longitude',
|
||||
field=models.FloatField(blank=True, null=True),
|
||||
),
|
||||
]
|
||||
23
people/migrations/0029_organisation_location_fields.py
Normal file
23
people/migrations/0029_organisation_location_fields.py
Normal file
@@ -0,0 +1,23 @@
|
||||
# Generated by Django 2.2.10 on 2021-01-15 13:38
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('people', '0028_person_location_fields'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='organisation',
|
||||
name='latitude',
|
||||
field=models.FloatField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='organisation',
|
||||
name='longitude',
|
||||
field=models.FloatField(blank=True, null=True),
|
||||
),
|
||||
]
|
||||
18
people/migrations/0030_user_consent_given.py
Normal file
18
people/migrations/0030_user_consent_given.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 2.2.10 on 2021-01-20 11:25
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('people', '0029_organisation_location_fields'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='user',
|
||||
name='consent_given',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
]
|
||||
23
people/migrations/0031_question_allow_free_text.py
Normal file
23
people/migrations/0031_question_allow_free_text.py
Normal file
@@ -0,0 +1,23 @@
|
||||
# Generated by Django 2.2.10 on 2021-01-20 13:31
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('people', '0030_user_consent_given'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='personquestion',
|
||||
name='allow_free_text',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='relationshipquestion',
|
||||
name='allow_free_text',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
]
|
||||
18
people/migrations/0032_personquestion_answer_is_public.py
Normal file
18
people/migrations/0032_personquestion_answer_is_public.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 2.2.10 on 2021-02-08 13:32
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('people', '0031_question_allow_free_text'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='personquestion',
|
||||
name='answer_is_public',
|
||||
field=models.BooleanField(default=True, help_text='Should answers to this question be displayed on profiles?'),
|
||||
),
|
||||
]
|
||||
17
people/migrations/0033_person_sort_by_name.py
Normal file
17
people/migrations/0033_person_sort_by_name.py
Normal file
@@ -0,0 +1,17 @@
|
||||
# Generated by Django 2.2.10 on 2021-02-08 15:01
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('people', '0032_personquestion_answer_is_public'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name='person',
|
||||
options={'ordering': ['name'], 'verbose_name_plural': 'people'},
|
||||
),
|
||||
]
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user