Initial
This commit is contained in:
commit
f78c32db85
278
.gitignore
vendored
Executable file
278
.gitignore
vendored
Executable file
|
@ -0,0 +1,278 @@
|
|||
# Created by https://www.gitignore.io/api/git,python,django,pycharm+all
|
||||
# Edit at https://www.gitignore.io/?templates=git,python,django,pycharm+all
|
||||
|
||||
### Django ###
|
||||
*.log
|
||||
*.pot
|
||||
*.pyc
|
||||
__pycache__/
|
||||
local_settings.py
|
||||
db.sqlite3
|
||||
media
|
||||
|
||||
# If your build process includes running collectstatic, then you probably don't need or want to include staticfiles/
|
||||
# in your Git repository. Update and uncomment the following line accordingly.
|
||||
drakul/static/
|
||||
|
||||
### Django.Python Stack ###
|
||||
# Byte-compiled / optimized / DLL files
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
# C extensions
|
||||
*.so
|
||||
|
||||
# Distribution / packaging
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
share/python-wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
MANIFEST
|
||||
|
||||
# PyInstaller
|
||||
# Usually these files are written by a python script from a template
|
||||
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||
*.manifest
|
||||
*.spec
|
||||
|
||||
# Installer logs
|
||||
pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
|
||||
# Unit test / coverage reports
|
||||
htmlcov/
|
||||
.tox/
|
||||
.nox/
|
||||
.coverage
|
||||
.coverage.*
|
||||
.cache
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
*.cover
|
||||
.hypothesis/
|
||||
.pytest_cache/
|
||||
|
||||
# Translations
|
||||
*.mo
|
||||
|
||||
# Django stuff:
|
||||
|
||||
# Flask stuff:
|
||||
instance/
|
||||
.webassets-cache
|
||||
|
||||
# Scrapy stuff:
|
||||
.scrapy
|
||||
|
||||
# Sphinx documentation
|
||||
docs/_build/
|
||||
|
||||
# PyBuilder
|
||||
target/
|
||||
|
||||
# Jupyter Notebook
|
||||
.ipynb_checkpoints
|
||||
|
||||
# IPython
|
||||
profile_default/
|
||||
ipython_config.py
|
||||
|
||||
# pyenv
|
||||
.python-version
|
||||
|
||||
# celery beat schedule file
|
||||
celerybeat-schedule
|
||||
|
||||
# SageMath parsed files
|
||||
*.sage.py
|
||||
|
||||
# Environments
|
||||
.env
|
||||
.venv
|
||||
env/
|
||||
venv/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
|
||||
# Spyder project settings
|
||||
.spyderproject
|
||||
.spyproject
|
||||
|
||||
# Rope project settings
|
||||
.ropeproject
|
||||
|
||||
# mkdocs documentation
|
||||
/site
|
||||
|
||||
# mypy
|
||||
.mypy_cache/
|
||||
.dmypy.json
|
||||
dmypy.json
|
||||
|
||||
# Pyre type checker
|
||||
.pyre/
|
||||
|
||||
### Git ###
|
||||
# Created by git for backups. To disable backups in Git:
|
||||
# $ git config --global mergetool.keepBackup false
|
||||
*.orig
|
||||
|
||||
# Created by git when using merge tools for conflicts
|
||||
*.BACKUP.*
|
||||
*.BASE.*
|
||||
*.LOCAL.*
|
||||
*.REMOTE.*
|
||||
*_BACKUP_*.txt
|
||||
*_BASE_*.txt
|
||||
*_LOCAL_*.txt
|
||||
*_REMOTE_*.txt
|
||||
|
||||
### PyCharm+all ###
|
||||
# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and WebStorm
|
||||
# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
|
||||
|
||||
# User-specific stuff
|
||||
.idea/**/workspace.xml
|
||||
.idea/**/tasks.xml
|
||||
.idea/**/usage.statistics.xml
|
||||
.idea/**/dictionaries
|
||||
.idea/**/shelf
|
||||
|
||||
# Generated files
|
||||
.idea/**/contentModel.xml
|
||||
|
||||
# Sensitive or high-churn files
|
||||
.idea/**/dataSources/
|
||||
.idea/**/dataSources.ids
|
||||
.idea/**/dataSources.local.xml
|
||||
.idea/**/sqlDataSources.xml
|
||||
.idea/**/dynamic.xml
|
||||
.idea/**/uiDesigner.xml
|
||||
.idea/**/dbnavigator.xml
|
||||
|
||||
# Gradle
|
||||
.idea/**/gradle.xml
|
||||
.idea/**/libraries
|
||||
|
||||
# Gradle and Maven with auto-import
|
||||
# When using Gradle or Maven with auto-import, you should exclude module files,
|
||||
# since they will be recreated, and may cause churn. Uncomment if using
|
||||
# auto-import.
|
||||
# .idea/modules.xml
|
||||
# .idea/*.iml
|
||||
# .idea/modules
|
||||
|
||||
# CMake
|
||||
cmake-build-*/
|
||||
|
||||
# Mongo Explorer plugin
|
||||
.idea/**/mongoSettings.xml
|
||||
|
||||
# File-based project format
|
||||
*.iws
|
||||
|
||||
# IntelliJ
|
||||
out/
|
||||
|
||||
# mpeltonen/sbt-idea plugin
|
||||
.idea_modules/
|
||||
|
||||
# JIRA plugin
|
||||
atlassian-ide-plugin.xml
|
||||
|
||||
# Cursive Clojure plugin
|
||||
.idea/replstate.xml
|
||||
|
||||
# Crashlytics plugin (for Android Studio and IntelliJ)
|
||||
com_crashlytics_export_strings.xml
|
||||
crashlytics.properties
|
||||
crashlytics-build.properties
|
||||
fabric.properties
|
||||
|
||||
# Editor-based Rest Client
|
||||
.idea/httpRequests
|
||||
|
||||
# Android studio 3.1+ serialized cache file
|
||||
.idea/caches/build_file_checksums.ser
|
||||
|
||||
### PyCharm+all Patch ###
|
||||
# Ignores the whole .idea folder and all .iml files
|
||||
# See https://github.com/joeblau/gitignore.io/issues/186 and https://github.com/joeblau/gitignore.io/issues/360
|
||||
|
||||
.idea/*
|
||||
#!/.idea/runConfigurations
|
||||
|
||||
# Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-249601023
|
||||
|
||||
*.iml
|
||||
modules.xml
|
||||
.idea/misc.xml
|
||||
*.ipr
|
||||
|
||||
### Python ###
|
||||
# Byte-compiled / optimized / DLL files
|
||||
|
||||
# C extensions
|
||||
|
||||
# Distribution / packaging
|
||||
|
||||
# PyInstaller
|
||||
# Usually these files are written by a python script from a template
|
||||
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||
|
||||
# Installer logs
|
||||
|
||||
# Unit test / coverage reports
|
||||
|
||||
# Translations
|
||||
|
||||
# Django stuff:
|
||||
|
||||
# Flask stuff:
|
||||
|
||||
# Scrapy stuff:
|
||||
|
||||
# Sphinx documentation
|
||||
|
||||
# PyBuilder
|
||||
|
||||
# Jupyter Notebook
|
||||
|
||||
# IPython
|
||||
|
||||
# pyenv
|
||||
|
||||
# celery beat schedule file
|
||||
|
||||
# SageMath parsed files
|
||||
|
||||
# Environments
|
||||
|
||||
# Spyder project settings
|
||||
|
||||
# Rope project settings
|
||||
|
||||
# mkdocs documentation
|
||||
|
||||
# mypy
|
||||
|
||||
# Pyre type checker
|
||||
|
||||
### Python Patch ###
|
||||
.venv/
|
||||
|
||||
# End of https://www.gitignore.io/api/git,python,django,pycharm+all
|
661
LICENSE
Executable file
661
LICENSE
Executable file
|
@ -0,0 +1,661 @@
|
|||
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||
Version 3, 19 November 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU Affero General Public License is a free, copyleft license for
|
||||
software and other kinds of works, specifically designed to ensure
|
||||
cooperation with the community in the case of network server software.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
our General Public Licenses are intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
Developers that use our General Public Licenses protect your rights
|
||||
with two steps: (1) assert copyright on the software, and (2) offer
|
||||
you this License which gives you legal permission to copy, distribute
|
||||
and/or modify the software.
|
||||
|
||||
A secondary benefit of defending all users' freedom is that
|
||||
improvements made in alternate versions of the program, if they
|
||||
receive widespread use, become available for other developers to
|
||||
incorporate. Many developers of free software are heartened and
|
||||
encouraged by the resulting cooperation. However, in the case of
|
||||
software used on network servers, this result may fail to come about.
|
||||
The GNU General Public License permits making a modified version and
|
||||
letting the public access it on a server without ever releasing its
|
||||
source code to the public.
|
||||
|
||||
The GNU Affero General Public License is designed specifically to
|
||||
ensure that, in such cases, the modified source code becomes available
|
||||
to the community. It requires the operator of a network server to
|
||||
provide the source code of the modified version running there to the
|
||||
users of that server. Therefore, public use of a modified version, on
|
||||
a publicly accessible server, gives the public access to the source
|
||||
code of the modified version.
|
||||
|
||||
An older license, called the Affero General Public License and
|
||||
published by Affero, was designed to accomplish similar goals. This is
|
||||
a different license, not a version of the Affero GPL, but Affero has
|
||||
released a new version of the Affero GPL which permits relicensing under
|
||||
this license.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, if you modify the
|
||||
Program, your modified version must prominently offer all users
|
||||
interacting with it remotely through a computer network (if your version
|
||||
supports such interaction) an opportunity to receive the Corresponding
|
||||
Source of your version by providing access to the Corresponding Source
|
||||
from a network server at no charge, through some standard or customary
|
||||
means of facilitating copying of software. This Corresponding Source
|
||||
shall include the Corresponding Source for any work covered by version 3
|
||||
of the GNU General Public License that is incorporated pursuant to the
|
||||
following paragraph.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the work with which it is combined will remain governed by version
|
||||
3 of the GNU General Public License.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU Affero General Public License from time to time. Such new versions
|
||||
will be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU Affero General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU Affero General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU Affero General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published
|
||||
by the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If your software can interact with users remotely through a computer
|
||||
network, you should also make sure that it provides a way for users to
|
||||
get its source. For example, if your program is a web application, its
|
||||
interface could display a "Source" link that leads users to an archive
|
||||
of the code. There are many ways you could offer source, and different
|
||||
solutions will be better for different programs; see section 13 for the
|
||||
specific requirements.
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
113
README.md
Normal file
113
README.md
Normal file
|
@ -0,0 +1,113 @@
|
|||
# Drakul
|
||||
Drakul is an online system for organising and managing the raids and members of a World of Warcraft guild. Thanks for
|
||||
checking it out.
|
||||
|
||||
|
||||
## Setup
|
||||
The following steps installs the application in `/opt/drakul/` and configures nginx to reverse proxy it using uwsgi.
|
||||
While the instructions are focused on Debian 10 Buster, it might work on other distributions as well.
|
||||
|
||||
Start by installing the required packages:
|
||||
```bash
|
||||
apt install git python3 python3-venv nginx uwsgi uwsgi-plugin-python3
|
||||
```
|
||||
Create a system user for running the application:
|
||||
```bash
|
||||
adduser \
|
||||
--system \
|
||||
--shell /bin/bash \
|
||||
--group \
|
||||
--disabled-password \
|
||||
--home /opt/drakul \
|
||||
drakul
|
||||
```
|
||||
Clone the code:
|
||||
```bash
|
||||
su - drakul
|
||||
git clone https://git.caspervk.net/caspervk/drakul /opt/drakul
|
||||
```
|
||||
Setup virtual environment and install dependencies:
|
||||
```bash
|
||||
python3 -m venv venv
|
||||
source venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
deactivate
|
||||
```
|
||||
Add the uwsgi configuration file, `/etc/uwsgi/apps-available/drakul.ini`:
|
||||
```ini
|
||||
[uwsgi]
|
||||
workers = 2
|
||||
|
||||
chdir = /opt/drakul/
|
||||
venv = /opt/drakul/venv
|
||||
module = drakul.base.wsgi:application
|
||||
|
||||
socket = /run/uwsgi/app/drakul/socket
|
||||
chown-socket = www-data
|
||||
chmod-socket = 660
|
||||
vacuum = true
|
||||
|
||||
daemonize = /var/log/uwsgi/app/drakul.log
|
||||
|
||||
uid = drakul
|
||||
gid = drakul
|
||||
|
||||
plugins = python3
|
||||
|
||||
exec-pre-app = venv/bin/python manage.py migrate --no-input
|
||||
exec-pre-app = venv/bin/python manage.py collectstatic --no-input
|
||||
|
||||
; Change these as needed
|
||||
env = DJANGO_SETTINGS_MODULE=drakul.base.settings.production
|
||||
env = SECRET_KEY=hunter2 ; the result of 'head -c 56 /dev/random | base64' is a good idea
|
||||
env = ALLOWED_HOSTS=drakul.example.com
|
||||
```
|
||||
Enable the application in uwsgi:
|
||||
```bash
|
||||
ln -s /etc/uwsgi/apps-available/drakul.ini /etc/uwsgi/apps-enabled
|
||||
service uwsgi restart
|
||||
```
|
||||
Add the following to your `/etc/nginx/sites-available/drakul.example.com`:
|
||||
```text
|
||||
location / {
|
||||
include uwsgi_params;
|
||||
uwsgi_pass unix:/run/uwsgi/app/drakul/socket;
|
||||
}
|
||||
|
||||
location /static/ {
|
||||
alias /opt/drakul/drakul/static/;
|
||||
}
|
||||
```
|
||||
|
||||
That's it! After restarting nginx everything should work.
|
||||
|
||||
|
||||
### Environment variables
|
||||
Defaults are bolded.
|
||||
|
||||
Django
|
||||
* `DJANGO_SETTINGS_MODULE`: **`drakul.base.settings.production`**.
|
||||
* `SECRET_KEY`: The result of running `head -c 56 /dev/random | base64` is a good idea.
|
||||
* `ALLOWED_HOSTS`: A comma-separated list of the domain names Django is allowed to serve. Security measure to prevent
|
||||
HTTP Host header attacks. Only used and required in production.
|
||||
|
||||
Database ([docs](https://docs.djangoproject.com/en/dev/ref/settings/#databases))
|
||||
* `DB_ENGINE`: **django.db.backends.sqlite3**.
|
||||
* `DB_DB_NAME`: **db.sqlite3**.
|
||||
* `DB_HOSTNAME`: **localhost**.
|
||||
* `DB_PORT`: **5432**.
|
||||
* `DB_USERNAME`: **root**.
|
||||
* `DB_PASSWORD`: **root**.
|
||||
|
||||
|
||||
## Development
|
||||
```bash
|
||||
# Configure environment
|
||||
source activate.sh
|
||||
|
||||
# Create admin user
|
||||
m createsuperuser
|
||||
|
||||
# Start the server
|
||||
m runserver 8000
|
||||
```
|
37
activate.sh
Executable file
37
activate.sh
Executable file
|
@ -0,0 +1,37 @@
|
|||
# Create and enter virtual environment
|
||||
if [ ! -d venv ]; then
|
||||
echo Creating virtual environment
|
||||
python3 -m venv venv
|
||||
fi
|
||||
source venv/bin/activate
|
||||
|
||||
# Install required python packages
|
||||
echo Installing required Python packages
|
||||
pip install -r requirements.txt
|
||||
|
||||
# Create and load environment variables from .env
|
||||
if [ ! -f .env ]; then
|
||||
echo Creating .env
|
||||
echo 'DJANGO_SETTINGS_MODULE=drakul.base.settings.development' > .env
|
||||
fi
|
||||
set -a
|
||||
source .env
|
||||
set +a
|
||||
|
||||
|
||||
# Set up neat shortcuts
|
||||
alias manage='python manage.py'
|
||||
alias m=manage
|
||||
|
||||
function reset() {
|
||||
# Delete database
|
||||
rm db.sqlite3
|
||||
# Delete all migrations
|
||||
find . -path "*/migrations/*.py" -not -name "__init__.py" -not -path "*/venv/*" -delete
|
||||
find . -path "*/migrations/*.pyc" -not -path "*/venv/*" -delete
|
||||
# Make and apply migrations
|
||||
m makemigrations
|
||||
m migrate
|
||||
# Create test users
|
||||
m shell -c "from django.contrib.auth import get_user_model; User = get_user_model(); User.objects.create_superuser('admin', 'admin@localhost', 'admin')"
|
||||
}
|
4
drakul/__init__.py
Executable file
4
drakul/__init__.py
Executable file
|
@ -0,0 +1,4 @@
|
|||
__version__ = "0.0.1"
|
||||
__author__ = "Casper V. Kristensen"
|
||||
__licence__ = "AGPLv3"
|
||||
__url__ = "https://git.caspervk.net/caspervk/drakul"
|
0
drakul/authentication/__init__.py
Executable file
0
drakul/authentication/__init__.py
Executable file
5
drakul/authentication/apps.py
Executable file
5
drakul/authentication/apps.py
Executable file
|
@ -0,0 +1,5 @@
|
|||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class AuthenticationConfig(AppConfig):
|
||||
name = "drakul.authentication"
|
9
drakul/authentication/forms.py
Normal file
9
drakul/authentication/forms.py
Normal file
|
@ -0,0 +1,9 @@
|
|||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.auth.forms import UserCreationForm as DjangoUserCreationForm
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class UserCreationForm(DjangoUserCreationForm):
|
||||
class Meta(DjangoUserCreationForm.Meta):
|
||||
model = User
|
0
drakul/authentication/migrations/__init__.py
Normal file
0
drakul/authentication/migrations/__init__.py
Normal file
18
drakul/authentication/templates/registration/login.html
Normal file
18
drakul/authentication/templates/registration/login.html
Normal file
|
@ -0,0 +1,18 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% load crispy_forms_tags %}
|
||||
|
||||
{% block title %}Login{% endblock %}
|
||||
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-center align-items-center pt-5">
|
||||
<form class="m-auto" method="post" action="{% url 'login' %}">
|
||||
{% csrf_token %}
|
||||
{{ form | crispy }}
|
||||
<button type="submit" class="btn btn-primary btn-block">Log In</button>
|
||||
<input type="hidden" name="next" value="{{ next }}">
|
||||
<p class="text-center mt-2"><a href="{% url 'signup' %}">Sign Up</a></p>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock content %}
|
|
@ -0,0 +1,18 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% load crispy_forms_tags %}
|
||||
|
||||
{% block title %}Change Password{% endblock %}
|
||||
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-center align-items-center mt-5">
|
||||
<form class="m-auto" method="post" action="{% url 'password_change' %}">
|
||||
<h2>Change Password</h2>
|
||||
<hr>
|
||||
{% csrf_token %}
|
||||
{{ form | crispy }}
|
||||
<button type="submit" class="btn btn-primary btn-block">Change Password</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock content %}
|
24
drakul/authentication/templates/registration/signup.html
Normal file
24
drakul/authentication/templates/registration/signup.html
Normal file
|
@ -0,0 +1,24 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% load crispy_forms_tags %}
|
||||
|
||||
{% block title %}Sign Up{% endblock %}
|
||||
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-center align-items-center pt-5">
|
||||
<form class="m-auto" method="post" action="{% url 'signup' %}">
|
||||
<h2>Sign Up</h2>
|
||||
<hr>
|
||||
{% csrf_token %}
|
||||
{{ user_form | crispy }}
|
||||
<h5 class="mt-5">Character</h5>
|
||||
<hr>
|
||||
{{ character_form | crispy }}
|
||||
<hr>
|
||||
<button type="submit" class="btn btn-primary btn-block">Sign Up</button>
|
||||
<input type="hidden" name="next" value="{{ next }}">
|
||||
<p class="text-center mt-2"><a href="{% url 'login' %}">Log In</a></p>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock content %}
|
14
drakul/authentication/urls.py
Normal file
14
drakul/authentication/urls.py
Normal file
|
@ -0,0 +1,14 @@
|
|||
from django.contrib.auth import views as auth_views
|
||||
from django.urls import path
|
||||
|
||||
from . import views
|
||||
|
||||
urlpatterns = [
|
||||
# From django.contrib.auth.urls
|
||||
# https://docs.djangoproject.com/en/2.2/topics/auth/default/#module-django.contrib.auth.views
|
||||
path("login/", auth_views.LoginView.as_view(), name="login"),
|
||||
path("logout/", auth_views.LogoutView.as_view(), name="logout"),
|
||||
path("password_change/", auth_views.PasswordChangeView.as_view(success_url="/"), name="password_change"),
|
||||
|
||||
path("signup/", views.SignupView.as_view(), name="signup"),
|
||||
]
|
32
drakul/authentication/views.py
Normal file
32
drakul/authentication/views.py
Normal file
|
@ -0,0 +1,32 @@
|
|||
from django.contrib.auth import login
|
||||
from django.contrib.auth.mixins import UserPassesTestMixin
|
||||
from django.db import transaction
|
||||
from django.urls import reverse_lazy
|
||||
|
||||
from drakul.base.views import MultiModelFormView
|
||||
from drakul.users.forms import CharacterForm
|
||||
from .forms import UserCreationForm
|
||||
|
||||
|
||||
class SignupView(UserPassesTestMixin, MultiModelFormView):
|
||||
template_name = "registration/signup.html"
|
||||
form_classes = {
|
||||
"user_form": UserCreationForm,
|
||||
"character_form": CharacterForm
|
||||
}
|
||||
success_url = reverse_lazy("index")
|
||||
require_all_valid = True
|
||||
|
||||
def test_func(self):
|
||||
return not self.request.user.is_authenticated
|
||||
|
||||
@transaction.atomic
|
||||
def forms_valid(self, user_form, character_form):
|
||||
character = character_form.save()
|
||||
user = user_form.save(commit=False)
|
||||
user.main = character
|
||||
user.save()
|
||||
character.user = user
|
||||
character.save()
|
||||
login(self.request, user_form.instance)
|
||||
return super().forms_valid()
|
0
drakul/base/__init__.py
Executable file
0
drakul/base/__init__.py
Executable file
5
drakul/base/apps.py
Executable file
5
drakul/base/apps.py
Executable file
|
@ -0,0 +1,5 @@
|
|||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class BaseConfig(AppConfig):
|
||||
name = "drakul.base"
|
0
drakul/base/settings/__init__.py
Executable file
0
drakul/base/settings/__init__.py
Executable file
157
drakul/base/settings/common.py
Executable file
157
drakul/base/settings/common.py
Executable file
|
@ -0,0 +1,157 @@
|
|||
"""
|
||||
Base settings for drakul project.
|
||||
For more information on this file, see
|
||||
https://docs.djangoproject.com/en/2.2/topics/settings/
|
||||
For the full list of settings and their values, see
|
||||
https://docs.djangoproject.com/en/2.2/ref/settings/
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
# Build paths inside the project like this: os.path.join(BASE_DIR, ...)
|
||||
BASE_DIR = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
# Application definition
|
||||
# The application listed first has precedence. List django apps last to allow overriding.
|
||||
INSTALLED_APPS = (
|
||||
# Local apps
|
||||
"drakul.authentication.apps.AuthenticationConfig",
|
||||
"drakul.base.apps.BaseConfig",
|
||||
"drakul.raids.apps.RaidsConfig",
|
||||
"drakul.users.apps.UsersConfig",
|
||||
|
||||
# Third party apps
|
||||
"crispy_forms",
|
||||
|
||||
# Django apps
|
||||
"django.contrib.admin",
|
||||
"django.contrib.auth",
|
||||
"django.contrib.contenttypes",
|
||||
"django.contrib.sessions",
|
||||
"django.contrib.messages",
|
||||
"django.contrib.staticfiles",
|
||||
"django.contrib.sites",
|
||||
)
|
||||
|
||||
MIDDLEWARE = [
|
||||
"django.middleware.security.SecurityMiddleware",
|
||||
"django.contrib.sessions.middleware.SessionMiddleware",
|
||||
"django.middleware.common.CommonMiddleware",
|
||||
"django.middleware.csrf.CsrfViewMiddleware",
|
||||
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
||||
"django.contrib.messages.middleware.MessageMiddleware",
|
||||
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
||||
"django.contrib.sites.middleware.CurrentSiteMiddleware",
|
||||
]
|
||||
|
||||
ROOT_URLCONF = "drakul.base.urls"
|
||||
|
||||
WSGI_APPLICATION = "drakul.base.wsgi.application"
|
||||
|
||||
|
||||
# Security
|
||||
# https://docs.djangoproject.com/en/2.2/ref/clickjacking/
|
||||
# https://docs.djangoproject.com/en/2.2/ref/middleware/#module-django.middleware.security
|
||||
X_FRAME_OPTIONS = "DENY"
|
||||
SECURE_CONTENT_TYPE_NOSNIFF = True
|
||||
SECURE_BROWSER_XSS_FILTER = True
|
||||
|
||||
# Templates
|
||||
# https://docs.djangoproject.com/en/2.2/ref/settings/#templates
|
||||
TEMPLATES = [
|
||||
{
|
||||
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
||||
"DIRS": [os.path.join(BASE_DIR, "templates")],
|
||||
"APP_DIRS": True,
|
||||
"OPTIONS": {
|
||||
"context_processors": [
|
||||
"django.template.context_processors.debug",
|
||||
"django.template.context_processors.request",
|
||||
"django.contrib.auth.context_processors.auth",
|
||||
"django.contrib.messages.context_processors.messages",
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
# Database
|
||||
# https://docs.djangoproject.com/en/2.2/ref/settings/#databases
|
||||
DATABASES = {
|
||||
"default": {
|
||||
"ENGINE": os.getenv("DB_ENGINE", "django.db.backends.sqlite3"),
|
||||
"NAME": os.getenv("DB_DB_NAME", "db.sqlite3"),
|
||||
"USER": os.getenv("DB_USERNAME", "root"),
|
||||
"PASSWORD": os.getenv("DB_PASSWORD", "root"),
|
||||
"HOST": os.getenv("DB_HOSTNAME", "localhost"),
|
||||
"PORT": os.getenv("DB_PORT", "5432"),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
# Auth
|
||||
# https://docs.djangoproject.com/en/2.2/ref/settings/#auth
|
||||
AUTH_USER_MODEL = "users.User" # https://docs.djangoproject.com/en/2.2/topics/auth/customizing/#auth-custom-user
|
||||
LOGIN_REDIRECT_URL = "index"
|
||||
LOGIN_URL = "login"
|
||||
LOGOUT_REDIRECT_URL = "index"
|
||||
|
||||
|
||||
# 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.MinimumLengthValidator",
|
||||
},
|
||||
{
|
||||
"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator",
|
||||
},
|
||||
{
|
||||
"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
# Internationalization
|
||||
# https://docs.djangoproject.com/en/2.2/topics/i18n/
|
||||
# https://docs.djangoproject.com/en/2.2/ref/settings/#globalization-i18n-l10n
|
||||
LANGUAGE_CODE = "en"
|
||||
|
||||
TIME_ZONE = "Europe/Copenhagen" # European server time
|
||||
|
||||
# If you set this to False, Django will make some optimizations so as not
|
||||
# to load the internationalization machinery.
|
||||
USE_I18N = True
|
||||
|
||||
# If you set this to False, Django will not format dates, numbers and
|
||||
# calendars according to the current locale
|
||||
USE_L10N = False
|
||||
# We use the following (actually sane) defaults instead:
|
||||
DATE_FORMAT = "j N Y" # 4 Feb 2003
|
||||
DATETIME_FORMAT = "j N Y, H:i" # 4 Feb. 2003, 04:00
|
||||
FIRST_DAY_OF_WEEK = 1 # Monday
|
||||
MONTH_DAY_FORMAT = "j F" # 4 February
|
||||
SHORT_DATE_FORMAT = "d/m/Y" # 31/12/2003
|
||||
SHORT_DATETIME_FORMAT = "d/m/Y H:i" # 31/12/2003 04:00
|
||||
TIME_FORMAT = "H:i" # 04:00
|
||||
YEAR_MONTH_FORMAT = "F Y" # February 2003
|
||||
|
||||
USE_TZ = False
|
||||
|
||||
|
||||
# Static files (CSS, JavaScript, Images)
|
||||
# https://docs.djangoproject.com/en/2.2/howto/static-files/
|
||||
STATIC_URL = "/static/"
|
||||
STATIC_ROOT = os.path.join(BASE_DIR, "static/")
|
||||
|
||||
|
||||
# Site
|
||||
# https://docs.djangoproject.com/en/2.2/ref/contrib/sites/
|
||||
SITE_ID = 1
|
||||
|
||||
|
||||
# Crispy Forms
|
||||
CRISPY_TEMPLATE_PACK = "bootstrap4"
|
13
drakul/base/settings/development.py
Executable file
13
drakul/base/settings/development.py
Executable file
|
@ -0,0 +1,13 @@
|
|||
"""
|
||||
Development settings for drakul project.
|
||||
"""
|
||||
|
||||
from .common import * # noqa
|
||||
|
||||
DEBUG = True
|
||||
|
||||
# Security
|
||||
SECRET_KEY = "zm77s+S5LieNrLqjujVi1/0LmxHXYpA3ONh8uTaWTi7YiKYJwwpdZAec9gXRg+dNJmdFS0Omg4w="
|
||||
|
||||
# Allow Django to serve all hosts/domains.
|
||||
ALLOWED_HOSTS = ["*"]
|
30
drakul/base/settings/production.py
Executable file
30
drakul/base/settings/production.py
Executable file
|
@ -0,0 +1,30 @@
|
|||
"""
|
||||
Production settings for drakul project.
|
||||
"""
|
||||
|
||||
from .common import * # noqa
|
||||
import os
|
||||
|
||||
DEBUG = False
|
||||
|
||||
# Security
|
||||
SECRET_KEY = os.environ["SECRET_KEY"]
|
||||
|
||||
# A list of strings representing the host/domain names that this Django site can serve.
|
||||
# This is a security measure to prevent HTTP Host header attacks. A value beginning with
|
||||
# a period can be used as a subdomain wildcard: '.example.com' will match example.com, www.example.com,
|
||||
# and any other subdomain of example.com.
|
||||
ALLOWED_HOSTS = os.environ["ALLOWED_HOSTS"].split(",")
|
||||
|
||||
# Use a secure cookie for the session/CSRF cookies, marking them as “secure,” which means
|
||||
# browsers may ensure that the cookies are only sent over an HTTPS connection.
|
||||
# Leaving these settings off isn’t a good idea because an attacker could capture an
|
||||
# unencrypted cookie with a packet sniffer and use the cookie to hijack the user’s session.
|
||||
SESSION_COOKIE_SECURE = True
|
||||
CSRF_COOKIE_SECURE = True
|
||||
|
||||
|
||||
# Database
|
||||
# Enable persistent database connections for performance.
|
||||
# https://docs.djangoproject.com/en/2.2/ref/databases/#persistent-database-connections
|
||||
CONN_MAX_AGE = None
|
7
drakul/base/static/css/bootstrap.min.css
vendored
Normal file
7
drakul/base/static/css/bootstrap.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
95
drakul/base/static/css/main.css
Normal file
95
drakul/base/static/css/main.css
Normal file
|
@ -0,0 +1,95 @@
|
|||
/* https://django-crispy-forms.readthedocs.io/en/latest/crispy_tag_forms.html#change-required-fields */
|
||||
.asteriskField {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.w-14 {
|
||||
width: 14.285% !important; /* Like Bootstrap's .w-{25,50,75,100}, but for 1/7th of the width */
|
||||
}
|
||||
|
||||
.o-50 {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.class-druid {color: #ff7d0a;}
|
||||
.class-hunter {color: #abd473;}
|
||||
.class-mage {color: #69ccf0;}
|
||||
.class-paladin {color: #f58cba;}
|
||||
.class-priest {color: #ffffff;}
|
||||
.class-rogue {color: #fff569;}
|
||||
.class-shaman {color: #0070de;}
|
||||
.class-warlock {color: #9482c9;}
|
||||
.class-warrior {color: #c79c6e;}
|
||||
|
||||
.class-druid-bg {
|
||||
color: #fff;
|
||||
background-color: #ff7d0a;
|
||||
}
|
||||
.class-hunter-bg {
|
||||
color: #fff;
|
||||
background-color: #abd473;
|
||||
}
|
||||
.class-mage-bg {
|
||||
color: #fff;
|
||||
background-color: #69ccf0;
|
||||
}
|
||||
.class-paladin-bg {
|
||||
color: #fff;
|
||||
background-color: #f58cba;
|
||||
}
|
||||
.class-priest-bg {
|
||||
color: #343a40;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
.class-rogue-bg {
|
||||
color: #343a40;
|
||||
background-color: #fff569;
|
||||
}
|
||||
.class-shaman-bg {
|
||||
color: #fff;
|
||||
background-color: #0070de;
|
||||
}
|
||||
.class-warlock-bg {
|
||||
color: #fff;
|
||||
background-color: #9482c9;
|
||||
}
|
||||
.class-warrior-bg {
|
||||
color: #fff;
|
||||
background-color: #c79c6e;
|
||||
}
|
||||
|
||||
|
||||
/* Response bg and text colors from https://getbootstrap.com/docs/4.3/utilities/colors/ */
|
||||
.response-status-signed-off-bg, .response-status-1-bg {
|
||||
color: #fff;
|
||||
background-color: #dc3545;
|
||||
}
|
||||
.response-status-signed-up-bg, .response-status-2-bg {
|
||||
color: #fff;
|
||||
background-color: #17a2b8;
|
||||
}
|
||||
.response-status-stand-by-bg, .response-status-3-bg {
|
||||
color: #343a40;
|
||||
background-color: #ffc107;
|
||||
}
|
||||
.response-status-confirmed-bg, .response-status-4-bg {
|
||||
color: #fff;
|
||||
background-color: #28a745;
|
||||
}
|
||||
.response-status-no-response-bg, .response-status-0-bg {
|
||||
color: #fff;
|
||||
background-color: #6c757d;
|
||||
}
|
||||
|
||||
.response-character-button {
|
||||
width: 16ch;
|
||||
}
|
||||
|
||||
.raid-response-form .form-group {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.raid-calendar td {
|
||||
height: 8rem; /* height works like min-height for td's */
|
||||
padding: .5rem;
|
||||
}
|
7
drakul/base/static/js/bootstrap.bundle.min.js
vendored
Normal file
7
drakul/base/static/js/bootstrap.bundle.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
2
drakul/base/static/js/jquery.slim.min.js
vendored
Normal file
2
drakul/base/static/js/jquery.slim.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
62
drakul/base/templates/base.html
Normal file
62
drakul/base/templates/base.html
Normal file
|
@ -0,0 +1,62 @@
|
|||
{% load static %}
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html class="h-100" lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||
|
||||
<title>{% block title %}Base{% endblock %} | {{ request.site.name }}</title>
|
||||
|
||||
<link rel="stylesheet" href="{% static 'css/bootstrap.min.css' %}" type="text/css">
|
||||
<link rel="stylesheet" href="{% static 'css/main.css' %}" type="text/css">
|
||||
</head>
|
||||
<body class="d-flex flex-column h-100 bg-light">
|
||||
<header class="mb-3">
|
||||
<nav class="navbar navbar-expand-md navbar-dark bg-dark shadow-sm">
|
||||
<div class="container">
|
||||
<a class="navbar-brand" href="{% url 'index' %}">{{ request.site.name }}</a>
|
||||
<button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbar-collapse" aria-controls="navbar-collapse" aria-expanded="false" aria-label="Toggle navigation">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
|
||||
<div class="collapse navbar-collapse" id="navbar-collapse">
|
||||
<div class="navbar-nav mr-auto">
|
||||
<a class="nav-item nav-link" href="{% url 'raid_calendar' %}">Raids</a>
|
||||
<!--<a class="nav-item nav-link" href="{% url 'user_list' %}">Users</a>-->
|
||||
<!--<a class="nav-link disabled" href="#" tabindex="-1" aria-disabled="true">DKP</a>-->
|
||||
</div>
|
||||
{% if user.is_authenticated %}
|
||||
<span class="navbar-text">Logged in as <strong>{{ user.username }}</strong></span>
|
||||
<a class="btn btn-outline-danger ml-3" role="button" href="{% url 'logout' %}">Log Out</a>
|
||||
{% else %}
|
||||
<a class="btn btn-outline-info mr-1" role="button" href="{% url 'signup' %}">Sign Up</a>
|
||||
<a class="btn btn-success" role="button" href="{% url 'login' %}">Log In</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main class="flex-shrink-0" role="main">,
|
||||
<div class="container">
|
||||
{% block content %}
|
||||
{% endblock content %}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer class="footer mt-auto">
|
||||
<div class="mt-4 bg-white">
|
||||
<div class="container d-flex justify-content-between py-4">
|
||||
<span class="text-muted">© {% now "Y" %} {{ request.site.name }} </span>
|
||||
<a class="text-muted" href="https://git.caspervk.net/caspervk/drakul">Powered by Drakul</a>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<!-- We're only really using JavaScript to control the navbar expansion for mobile devices.. -->
|
||||
<script src="{% static 'js/jquery.slim.min.js' %}"></script>
|
||||
<script src="{% static 'js/bootstrap.bundle.min.js' %}"></script>
|
||||
{% block scripts %}{% endblock scripts %}
|
||||
</body>
|
||||
</html>
|
30
drakul/base/urls.py
Executable file
30
drakul/base/urls.py
Executable file
|
@ -0,0 +1,30 @@
|
|||
"""drakul URL Configuration
|
||||
The `urlpatterns` list routes URLs to views. For more information please see:
|
||||
https://docs.djangoproject.com/en/2.2/topics/http/urls/
|
||||
Examples:
|
||||
Function views
|
||||
1. Add an import: from my_app import views
|
||||
2. Add a URL to urlpatterns: path('', views.home, name='home')
|
||||
Class-based views
|
||||
1. Add an import: from other_app.views import Home
|
||||
2. Add a URL to urlpatterns: path('', Home.as_view(), name='home')
|
||||
Including another URLconf
|
||||
1. Import the include() function: from django.urls import include, path
|
||||
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
|
||||
"""
|
||||
from django.contrib import admin
|
||||
from django.urls import path, include
|
||||
from django.views.generic import RedirectView
|
||||
|
||||
urlpatterns = [
|
||||
# Root
|
||||
path("", RedirectView.as_view(pattern_name="raid_calendar"), name="index"), # redirect root path to raid_calendar
|
||||
|
||||
# Admin
|
||||
path("admin/", admin.site.urls, name="admin"),
|
||||
|
||||
# Local Apps
|
||||
path("auth/", include("drakul.authentication.urls")),
|
||||
path("", include("drakul.raids.urls")),
|
||||
path("", include("drakul.users.urls")),
|
||||
]
|
257
drakul/base/views.py
Normal file
257
drakul/base/views.py
Normal file
|
@ -0,0 +1,257 @@
|
|||
from django import forms as django_forms
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
from django.http import HttpResponseRedirect
|
||||
from django.views.generic.base import ContextMixin, TemplateResponseMixin
|
||||
from django.views.generic.edit import ProcessFormView
|
||||
|
||||
|
||||
class MultiFormMixin(ContextMixin):
|
||||
"""
|
||||
Provide a way to show and handle multiple forms in a request.
|
||||
Based on Django's FormMixin.
|
||||
Inspired by https://github.com/TimBest/django-multi-form-view & https://github.com/kennethlove/django-shapeshifter.
|
||||
|
||||
TODO: Description.
|
||||
"""
|
||||
initials = {}
|
||||
form_classes = {}
|
||||
success_url = None
|
||||
success_urls = {}
|
||||
prefixes = {}
|
||||
form_id_field_prefix = "x-MFFID-"
|
||||
require_all_valid = False
|
||||
|
||||
def get_initials(self, form_names):
|
||||
"""
|
||||
Return the initial data to use for each form class on this view.
|
||||
"""
|
||||
initials = {}
|
||||
for form_name in form_names:
|
||||
try:
|
||||
initials[form_name] = getattr(self, f"get_{form_name}_initial")()
|
||||
except AttributeError:
|
||||
initials[form_name] = self.initials.get(form_name) or {}
|
||||
return initials
|
||||
|
||||
def get_prefixes(self, form_names):
|
||||
"""
|
||||
Return the prefix to use for each form class on this view.
|
||||
"""
|
||||
prefixes = {}
|
||||
for form_name in form_names:
|
||||
try:
|
||||
prefixes[form_name] = getattr(self, f"get_{form_name}_prefix")()
|
||||
except AttributeError:
|
||||
prefixes[form_name] = self.prefixes.get(form_name)
|
||||
return prefixes
|
||||
|
||||
def get_form_classes(self):
|
||||
"""
|
||||
Return the form classes to use on this view.
|
||||
"""
|
||||
return self.form_classes
|
||||
|
||||
def get_forms(self, form_classes=None):
|
||||
"""
|
||||
Return instances of the forms to be used in this view.
|
||||
Adds a hidden field to enable identifying the posted form(s) on submit.
|
||||
"""
|
||||
if form_classes is None:
|
||||
form_classes = self.get_form_classes()
|
||||
|
||||
kwargs = self.get_forms_kwargs(form_classes.keys())
|
||||
forms = {}
|
||||
for form_name, form_class in form_classes.items():
|
||||
forms[form_name] = form_class(**kwargs[form_name])
|
||||
|
||||
self.add_form_id_fields(forms)
|
||||
return forms
|
||||
|
||||
def get_forms_kwargs(self, form_names):
|
||||
"""
|
||||
Return the keyword arguments for instantiating forms on this view.
|
||||
"""
|
||||
initials = self.get_initials(form_names)
|
||||
prefixes = self.get_prefixes(form_names)
|
||||
forms_kwargs = {}
|
||||
for form_name in form_names:
|
||||
kwargs = {
|
||||
"initial": initials[form_name],
|
||||
"prefix": prefixes[form_name],
|
||||
}
|
||||
|
||||
if self.request.method in ("POST", "PUT") \
|
||||
and (self.require_all_valid or form_name in self.posted_form_names):
|
||||
kwargs.update({
|
||||
"data": self.request.POST,
|
||||
"files": self.request.FILES
|
||||
})
|
||||
|
||||
# Allow extending (or overwriting) the kwargs
|
||||
try:
|
||||
kwargs = getattr(self, f"get_{form_name}_kwargs")(kwargs)
|
||||
except AttributeError:
|
||||
pass
|
||||
forms_kwargs[form_name] = kwargs
|
||||
return forms_kwargs
|
||||
|
||||
def get_form_id_field_name(self, form_name):
|
||||
return f"{self.form_id_field_prefix}{form_name}"
|
||||
|
||||
def add_form_id_fields(self, forms):
|
||||
for form_name, form in forms.items():
|
||||
try:
|
||||
fields = form.fields
|
||||
except AttributeError:
|
||||
fields = form.management_form.fields # formset
|
||||
fields[self.get_form_id_field_name(form_name)] = django_forms.CharField(
|
||||
widget=django_forms.HiddenInput(),
|
||||
required=False
|
||||
)
|
||||
|
||||
def remove_form_id_fields(self, forms):
|
||||
for form_name, form in forms.items():
|
||||
try:
|
||||
fields = form.fields
|
||||
except AttributeError:
|
||||
fields = form.management_form.fields # formset
|
||||
del fields[self.get_form_id_field_name(form_name)]
|
||||
|
||||
def get_success_url(self):
|
||||
"""
|
||||
Return the URL to redirect to after processing a valid form.
|
||||
"""
|
||||
if self.success_url:
|
||||
return str(self.success_url) # success_url may be lazy
|
||||
|
||||
if len(self.posted_form_names) > 1:
|
||||
raise ImproperlyConfigured(
|
||||
"No URL to redirect to: Multiple forms posted, but common success_url not defined."
|
||||
)
|
||||
|
||||
form_name = self.posted_form_names[0]
|
||||
try:
|
||||
return getattr(self, f"get_{form_name}_success_url")()
|
||||
except AttributeError:
|
||||
try:
|
||||
return self.success_urls[form_name]
|
||||
except KeyError:
|
||||
raise ImproperlyConfigured(
|
||||
"No URL to redirect to. Provide a common success_url for all forms, or "
|
||||
"either define a 'get_{form_name}_success_url' method or a 'success_urls' dict."
|
||||
)
|
||||
|
||||
def forms_valid(self, **forms):
|
||||
"""
|
||||
If the forms are valid, redirect to the success URL.
|
||||
"""
|
||||
for form_name, form in forms.items():
|
||||
try:
|
||||
getattr(self, f"{form_name}_valid")(form)
|
||||
except AttributeError:
|
||||
pass
|
||||
return HttpResponseRedirect(self.get_success_url())
|
||||
|
||||
def forms_invalid(self, **forms):
|
||||
"""
|
||||
If the forms are invalid, render the invalid forms.
|
||||
"""
|
||||
for form_name, form in forms.items():
|
||||
try:
|
||||
getattr(self, f"{form_name}_invalid")(form)
|
||||
except AttributeError:
|
||||
pass
|
||||
return self.render_to_response(self.get_context_data(**forms))
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
"""
|
||||
Insert the forms into the context dict.
|
||||
"""
|
||||
for form_name, form in self.get_forms().items():
|
||||
if form_name not in kwargs:
|
||||
kwargs[form_name] = form
|
||||
return super().get_context_data(**kwargs)
|
||||
|
||||
|
||||
class ProcessMultiFormView(ProcessFormView):
|
||||
"""
|
||||
Render multiple forms on GET and process the submitted one(s) on POST.
|
||||
"""
|
||||
def get_posted_form_names(self):
|
||||
form_names = []
|
||||
for key in self.request.POST:
|
||||
# The form id prefix might not be in the beginning of the key, e.g. if the form has a prefix configured
|
||||
_, _, form_name = key.partition(self.form_id_field_prefix)
|
||||
if form_name:
|
||||
form_names.append(form_name)
|
||||
return form_names
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
"""
|
||||
Handle POST requests: instantiate form instances with the passed
|
||||
POST variables and then check if they're valid.
|
||||
"""
|
||||
self.posted_form_names = self.get_posted_form_names()
|
||||
forms = self.get_forms()
|
||||
if not self.require_all_valid:
|
||||
forms = {form_name: forms[form_name] for form_name in self.posted_form_names}
|
||||
if all([form.is_valid() for form in forms.values()]): # [] list wrap ensures non-short-circuit
|
||||
self.remove_form_id_fields(forms)
|
||||
return self.forms_valid(**forms)
|
||||
else:
|
||||
return self.forms_invalid(**forms)
|
||||
|
||||
|
||||
class BaseMultiFormView(MultiFormMixin, ProcessMultiFormView):
|
||||
"""
|
||||
A base view for displaying multiple forms.
|
||||
"""
|
||||
|
||||
|
||||
class MultiFormView(TemplateResponseMixin, BaseMultiFormView):
|
||||
"""
|
||||
A view for displaying multiple forms and rendering a template response.
|
||||
The class inheritance setup is based on Django's FormView.
|
||||
"""
|
||||
|
||||
|
||||
class MultiModelFormMixin(MultiFormMixin):
|
||||
"""
|
||||
Extends the MultiFormMixin with ideas from Django's ModelFormMixin.
|
||||
"""
|
||||
def get_instances(self, form_names):
|
||||
instances = {}
|
||||
for form_name in form_names:
|
||||
try:
|
||||
instances[form_name] = getattr(self, f"get_{form_name}_instance")()
|
||||
except AttributeError:
|
||||
instances[form_name] = None
|
||||
return instances
|
||||
|
||||
def get_forms_kwargs(self, form_names):
|
||||
form_kwargs = super().get_forms_kwargs(form_names)
|
||||
instances = self.get_instances(form_names)
|
||||
for form_name, kwargs in form_kwargs.items():
|
||||
kwargs["instance"] = instances[form_name]
|
||||
return form_kwargs
|
||||
|
||||
def forms_valid(self, **forms):
|
||||
for form_name, form in forms.items():
|
||||
try:
|
||||
getattr(self, f"{form_name}_valid")(form)
|
||||
except AttributeError:
|
||||
form.save()
|
||||
return HttpResponseRedirect(self.get_success_url())
|
||||
|
||||
|
||||
class BaseMultiModelFormView(MultiModelFormMixin, ProcessMultiFormView):
|
||||
"""
|
||||
A base view for displaying multiple model forms.
|
||||
"""
|
||||
|
||||
|
||||
class MultiModelFormView(TemplateResponseMixin, BaseMultiModelFormView):
|
||||
"""
|
||||
A view for displaying multiple model forms and rendering a template response.
|
||||
The class inheritance setup is based on Django's FormView.
|
||||
"""
|
14
drakul/base/wsgi.py
Executable file
14
drakul/base/wsgi.py
Executable file
|
@ -0,0 +1,14 @@
|
|||
"""
|
||||
WSGI config for drakul project.
|
||||
It exposes the WSGI callable as a module-level variable named ``application``.
|
||||
For more information on this file, see
|
||||
https://docs.djangoproject.com/en/2.2/howto/deployment/wsgi/
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from django.core.wsgi import get_wsgi_application
|
||||
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "drakul.base.settings.production")
|
||||
|
||||
application = get_wsgi_application()
|
0
drakul/raids/__init__.py
Executable file
0
drakul/raids/__init__.py
Executable file
28
drakul/raids/admin.py
Executable file
28
drakul/raids/admin.py
Executable file
|
@ -0,0 +1,28 @@
|
|||
from django.contrib import admin
|
||||
|
||||
from .models import Raid, RaidResponse, RaidComment, InstanceReset
|
||||
|
||||
|
||||
class RaidResponseInline(admin.TabularInline):
|
||||
model = RaidResponse
|
||||
fields = ["character", "role", "status", "attendance", "note"]
|
||||
extra = 0
|
||||
|
||||
|
||||
class RaidCommentInline(admin.TabularInline):
|
||||
model = RaidComment
|
||||
fields = ["user", "body", "date_created"]
|
||||
readonly_fields = ["date_created"]
|
||||
extra = 1
|
||||
|
||||
|
||||
@admin.register(Raid)
|
||||
class RaidAdmin(admin.ModelAdmin):
|
||||
list_display = ["title", "date", "signup_deadline"]
|
||||
search_fields = ["title"]
|
||||
inlines = [RaidResponseInline, RaidCommentInline]
|
||||
|
||||
|
||||
@admin.register(InstanceReset)
|
||||
class InstanceResetAdmin(admin.ModelAdmin):
|
||||
list_display = ["name", "date", "time", "duration"]
|
5
drakul/raids/apps.py
Executable file
5
drakul/raids/apps.py
Executable file
|
@ -0,0 +1,5 @@
|
|||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class RaidsConfig(AppConfig):
|
||||
name = "drakul.raids"
|
124
drakul/raids/forms.py
Normal file
124
drakul/raids/forms.py
Normal file
|
@ -0,0 +1,124 @@
|
|||
from crispy_forms.bootstrap import StrictButton
|
||||
from crispy_forms.helper import FormHelper
|
||||
from crispy_forms.layout import Submit, Layout, Column, Row, Field
|
||||
from django.forms import ModelForm, modelformset_factory, inlineformset_factory
|
||||
|
||||
from .models import RaidResponse, RaidComment, Raid
|
||||
|
||||
|
||||
class RaidResponseForm(ModelForm):
|
||||
class Meta:
|
||||
model = RaidResponse
|
||||
fields = ["character", "role", "status"]
|
||||
|
||||
def __init__(self, user, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields["character"].queryset = user.characters
|
||||
self.fields["character"].initial = user.main
|
||||
self.fields["role"].initial = user.main.role
|
||||
self.fields["status"].choices = RaidResponse.USER_STATUS_CHOICES
|
||||
|
||||
self.helper = FormHelper()
|
||||
|
||||
if self.instance.pk is None or self.instance.status == RaidResponse.SIGNED_OFF:
|
||||
signup_button = StrictButton(
|
||||
"Sign Up",
|
||||
type="submit",
|
||||
name="status",
|
||||
value=RaidResponse.SIGNED_UP,
|
||||
css_class="btn-success btn-block"
|
||||
)
|
||||
else:
|
||||
signup_button = StrictButton(
|
||||
"Change",
|
||||
type="submit",
|
||||
name="status",
|
||||
value=RaidResponse.SIGNED_UP,
|
||||
css_class="btn-primary btn-block"
|
||||
)
|
||||
self.helper.layout = Layout(
|
||||
Row(
|
||||
Column("character", css_class="col-md-3"),
|
||||
Column("role", css_class="col-md-3"),
|
||||
Column(signup_button, css_class="col-md-3"),
|
||||
Column(
|
||||
StrictButton(
|
||||
"Sign Off",
|
||||
type="submit",
|
||||
name="status",
|
||||
value=RaidResponse.SIGNED_OFF,
|
||||
css_class="btn-danger btn-block",
|
||||
disabled=self.instance.pk and self.instance.status == RaidResponse.SIGNED_OFF
|
||||
),
|
||||
css_class="col-md-3"
|
||||
),
|
||||
css_class="form-row"
|
||||
)
|
||||
)
|
||||
self.helper.render_hidden_fields = True # Include MultiFormMixin's hidden form_id_field_prefix
|
||||
self.helper.form_show_labels = False
|
||||
self.helper.form_tag = False
|
||||
|
||||
|
||||
class RaidForm(ModelForm):
|
||||
class Meta:
|
||||
model = Raid
|
||||
fields = ["title", "description", "date", "signup_deadline"]
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.helper = FormHelper()
|
||||
|
||||
if self.instance.pk is None:
|
||||
submit_button = Submit("submit", "Create", css_class="btn btn-success")
|
||||
else:
|
||||
submit_button = Submit("submit", "Update", css_class="btn btn-primary")
|
||||
self.helper.layout = Layout(
|
||||
Field("title"),
|
||||
Field("description"),
|
||||
Row(
|
||||
Column("date", css_class="form-group col-md-6"),
|
||||
Column("signup_deadline", css_class="form-group col-md-6"),
|
||||
),
|
||||
submit_button
|
||||
)
|
||||
self.helper.render_hidden_fields = True
|
||||
|
||||
|
||||
class RaidCommentForm(ModelForm):
|
||||
helper = FormHelper()
|
||||
helper.layout = Layout(
|
||||
Field("body", label="lol"),
|
||||
Submit("submit", "Submit"),
|
||||
)
|
||||
helper.render_hidden_fields = True
|
||||
helper.form_show_labels = False
|
||||
|
||||
class Meta:
|
||||
model = RaidComment
|
||||
fields = ["body"]
|
||||
|
||||
|
||||
RaidResponseFormSet = inlineformset_factory(
|
||||
Raid, # parent model
|
||||
RaidResponse,
|
||||
fields=["character", "role", "status", "note", "attendance"],
|
||||
extra=1
|
||||
)
|
||||
|
||||
|
||||
class RaidResponseFormSetHelper(FormHelper):
|
||||
template = "bootstrap4/table_inline_formset.html"
|
||||
|
||||
def __init__(self, form=None):
|
||||
super().__init__(form)
|
||||
self.form_class = "responses_form"
|
||||
self.layout = Layout(
|
||||
Field("character", css_class="character-select"),
|
||||
Field("role", css_class="role-select"),
|
||||
Field("status", css_class="status-select"),
|
||||
Field("note"),
|
||||
Field("attendance", css_class="attendance-input"),
|
||||
)
|
||||
self.form_tag = False
|
||||
|
64
drakul/raids/migrations/0001_initial.py
Normal file
64
drakul/raids/migrations/0001_initial.py
Normal file
|
@ -0,0 +1,64 @@
|
|||
# Generated by Django 2.2.6 on 2019-10-25 01:58
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='InstanceReset',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=100, unique=True)),
|
||||
('date', models.DateField(help_text='Some date where the instance was reset in the past.')),
|
||||
('time', models.TimeField()),
|
||||
('duration', models.DurationField(help_text='Resets are calculated from the given date in intervals of this duration.', verbose_name='lockout duration')),
|
||||
],
|
||||
options={
|
||||
'ordering': ['id'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Raid',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('title', models.CharField(max_length=40)),
|
||||
('description', models.TextField(blank=True, null=True)),
|
||||
('date', models.DateTimeField()),
|
||||
('signup_deadline', models.DateTimeField(blank=True, help_text='Defaults to date and time of raid if not set.')),
|
||||
],
|
||||
options={
|
||||
'ordering': ['-date'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='RaidComment',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('body', models.TextField(max_length=5000)),
|
||||
('date_created', models.DateTimeField(auto_now_add=True)),
|
||||
],
|
||||
options={
|
||||
'ordering': ['date_created'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='RaidResponse',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('role', models.PositiveSmallIntegerField(blank=True, choices=[(1, 'Tank'), (2, 'Healer'), (3, 'Damage')], null=True)),
|
||||
('status', models.PositiveSmallIntegerField(choices=[(1, 'Signed Off'), (2, 'Signed Up'), (3, 'Stand By'), (4, 'Confirmed')], default=2)),
|
||||
('note', models.CharField(blank=True, max_length=100, null=True)),
|
||||
('attendance', models.DecimalField(blank=True, decimal_places=2, max_digits=3, null=True)),
|
||||
],
|
||||
options={
|
||||
'ordering': ['-status', 'role', 'character__klass', 'character__name'],
|
||||
},
|
||||
),
|
||||
]
|
43
drakul/raids/migrations/0002_auto_20191025_0158.py
Normal file
43
drakul/raids/migrations/0002_auto_20191025_0158.py
Normal file
|
@ -0,0 +1,43 @@
|
|||
# Generated by Django 2.2.6 on 2019-10-25 01:58
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
('users', '0001_initial'),
|
||||
('raids', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='raidresponse',
|
||||
name='character',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='raid_responses', to='users.Character'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='raidresponse',
|
||||
name='raid',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='responses', to='raids.Raid'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='raidcomment',
|
||||
name='raid',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='comments', to='raids.Raid'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='raidcomment',
|
||||
name='user',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='raid_comments', to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='raidresponse',
|
||||
constraint=models.UniqueConstraint(fields=('raid', 'character'), name='unique_character_raid_signup'),
|
||||
),
|
||||
]
|
0
drakul/raids/migrations/__init__.py
Normal file
0
drakul/raids/migrations/__init__.py
Normal file
154
drakul/raids/models.py
Executable file
154
drakul/raids/models.py
Executable file
|
@ -0,0 +1,154 @@
|
|||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import models
|
||||
|
||||
from drakul.users.models import Character
|
||||
|
||||
|
||||
class Raid(models.Model):
|
||||
title = models.CharField(
|
||||
max_length=40,
|
||||
)
|
||||
|
||||
description = models.TextField(
|
||||
blank=True,
|
||||
null=True
|
||||
)
|
||||
|
||||
date = models.DateTimeField()
|
||||
signup_deadline = models.DateTimeField(
|
||||
blank=True,
|
||||
help_text="Defaults to date and time of raid if not set."
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ["-date"]
|
||||
|
||||
def clean(self):
|
||||
# Set the signup deadline to the date/time of the raid if it hasn't been set already
|
||||
if self.signup_deadline is None:
|
||||
self.signup_deadline = self.date
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.title} on {self.date}"
|
||||
|
||||
|
||||
class RaidResponse(models.Model):
|
||||
raid = models.ForeignKey(
|
||||
Raid,
|
||||
related_name="responses",
|
||||
on_delete=models.CASCADE,
|
||||
db_index=True
|
||||
)
|
||||
|
||||
character = models.ForeignKey(
|
||||
Character,
|
||||
related_name="raid_responses",
|
||||
on_delete=models.CASCADE,
|
||||
db_index=True
|
||||
)
|
||||
|
||||
role = models.PositiveSmallIntegerField(
|
||||
choices=Character.ROLE_CHOICES,
|
||||
blank=True,
|
||||
null=True
|
||||
)
|
||||
|
||||
SIGNED_OFF = 1
|
||||
SIGNED_UP = 2
|
||||
STANDBY = 3
|
||||
CONFIRMED = 4
|
||||
STATUS_CHOICES = [
|
||||
(SIGNED_OFF, "Signed Off"),
|
||||
(SIGNED_UP, "Signed Up"),
|
||||
(STANDBY, "Stand By"),
|
||||
(CONFIRMED, "Confirmed"),
|
||||
]
|
||||
USER_STATUS_CHOICES = [
|
||||
(SIGNED_OFF, "Signed Off"),
|
||||
(SIGNED_UP, "Signed Up"),
|
||||
]
|
||||
status = models.PositiveSmallIntegerField(
|
||||
choices=STATUS_CHOICES,
|
||||
default=SIGNED_UP
|
||||
)
|
||||
|
||||
note = models.CharField(
|
||||
max_length=100,
|
||||
blank=True,
|
||||
null=True
|
||||
)
|
||||
|
||||
attendance = models.DecimalField(
|
||||
max_digits=3,
|
||||
decimal_places=2,
|
||||
blank=True,
|
||||
null=True
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ["-status", "role", "character__klass", "character__name"]
|
||||
constraints = [
|
||||
models.UniqueConstraint(fields=["raid", "character"], name="unique_character_raid_signup")
|
||||
]
|
||||
|
||||
def clean(self):
|
||||
# Make sure sign-offs are role-agnostic, but all other responses are not
|
||||
if self.status == RaidResponse.SIGNED_OFF:
|
||||
self.role = None
|
||||
elif self.role is None:
|
||||
raise ValidationError({"role": "This field is required."})
|
||||
|
||||
def __str__(self):
|
||||
return super().__str__() # TODO?
|
||||
|
||||
|
||||
class RaidComment(models.Model):
|
||||
raid = models.ForeignKey(
|
||||
Raid,
|
||||
related_name="comments",
|
||||
on_delete=models.CASCADE,
|
||||
db_index=True
|
||||
)
|
||||
|
||||
user = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
related_name="raid_comments",
|
||||
on_delete=models.PROTECT,
|
||||
)
|
||||
|
||||
body = models.TextField(
|
||||
max_length=5000,
|
||||
)
|
||||
|
||||
date_created = models.DateTimeField(
|
||||
auto_now_add=True
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ["date_created"]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.user}'s comment on '{self.raid}'"
|
||||
|
||||
|
||||
class InstanceReset(models.Model):
|
||||
name = models.CharField(
|
||||
max_length=100,
|
||||
unique=True
|
||||
)
|
||||
|
||||
date = models.DateField(
|
||||
help_text="Some date where the instance was reset in the past."
|
||||
)
|
||||
time = models.TimeField()
|
||||
duration = models.DurationField(
|
||||
"lockout duration",
|
||||
help_text="Resets are calculated from the given date in intervals of this duration."
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ["id"]
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
55
drakul/raids/templates/raids/raid_calendar.html
Normal file
55
drakul/raids/templates/raids/raid_calendar.html
Normal file
|
@ -0,0 +1,55 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Raids{% endblock %}
|
||||
|
||||
|
||||
{% block content %}
|
||||
<h2>Raids</h2>
|
||||
<div class="d-flex justify-content-center mb-1">
|
||||
<div class="btn-group" role="group" aria-label="Basic example">
|
||||
<a class="btn btn-secondary" role="button" href="{% url 'raid_calendar' previous_month.year previous_month.month %}"><</a>
|
||||
<a class="btn btn-secondary" role="button" href="{% url 'raid_calendar' %}">{{ month | date:"YEAR_MONTH_FORMAT" }}</a>
|
||||
<a class="btn btn-secondary" role="button" href="{% url 'raid_calendar' next_month.year next_month.month %}">></a>
|
||||
</div>
|
||||
</div>
|
||||
<table class="raid-calendar table table-bordered bg-white mb-1">
|
||||
<thead class="thead-light text-center">
|
||||
<tr>
|
||||
<th scope="col" class="w-14">Monday</th>
|
||||
<th scope="col" class="w-14">Tuesday</th>
|
||||
<th scope="col" class="w-14">Wednesday</th>
|
||||
<th scope="col" class="w-14">Thursday</th>
|
||||
<th scope="col" class="w-14">Friday</th>
|
||||
<th scope="col" class="w-14">Saturday</th>
|
||||
<th scope="col" class="w-14">Sunday</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for week in calendar %}
|
||||
<tr>
|
||||
{% for day in week %}
|
||||
<td class="{% if day.date.month != month.month %}o-50{% endif %} {% if day.date == today %}table-warning{% endif %}">
|
||||
<p class="mb-2"><small>{{ day.date | date:"j" }}</small></p>
|
||||
<div class="d-flex flex-column">
|
||||
{% for instance_reset in day.instance_resets %}
|
||||
<span class="mb-1 badge badge-light text-left text-wrap font-weight-normal">
|
||||
<span class="text-monospace">{{ instance_reset.time }}</span> <span class="text-muted">{{ instance_reset.name }} Resets</span>
|
||||
</span>
|
||||
{% endfor %}
|
||||
{% for raid in day.raids %}
|
||||
<a class="mb-1 badge badge-secondary response-status-{{ raid.max_status | default:0 }}-bg text-left text-wrap font-weight-normal" href="{% url 'raid_detail' raid.id %}">
|
||||
<span class="text-monospace">{{ raid.date.time }}</span> <strong>{{ raid.title }}</strong>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</td>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="d-flex justify-content-end">
|
||||
{% if perms.raids.add_raid %}<a class="btn btn-success" role="button" href="{% url 'raid_create' %}">Add New</a>{% endif %}
|
||||
</div>
|
||||
{% endblock content %}
|
168
drakul/raids/templates/raids/raid_change.html
Normal file
168
drakul/raids/templates/raids/raid_change.html
Normal file
|
@ -0,0 +1,168 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% load crispy_forms_tags %}
|
||||
|
||||
{% block title %}Change {{ raid.title }}{% endblock %}
|
||||
|
||||
|
||||
{% block content %}
|
||||
<div class="card mb-3">
|
||||
<h5 class="card-header">Raid Details</h5>
|
||||
<div class="card-body">
|
||||
{% crispy raid_form %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mb-3">
|
||||
<h5 class="card-header">Raid Responses</h5>
|
||||
<div class="card-body">
|
||||
<form class="responses-form table-borderless" method="post" action="{% url 'raid_change' raid.id %}">
|
||||
{% crispy raid_response_formset raid_response_formset_helper %}
|
||||
<hr>
|
||||
|
||||
<div class="row">
|
||||
<div class="form-group col-md-6">
|
||||
<label>Change Status</label>
|
||||
<div class="input-group">
|
||||
<div class="input-group-prepend">
|
||||
<label class="input-group-text" for="change-status-from-status-select">Set all</label>
|
||||
</div>
|
||||
<select class="custom-select" id="change-status-from-status-select">
|
||||
<option value="1">Signed Off</option>
|
||||
<option selected value="2">Signed Up</option>
|
||||
<option value="3">Stand By</option>
|
||||
<option value="4">Confirmed</option>
|
||||
</select>
|
||||
<div class="input-group-append input-group-prepend">
|
||||
<label class="input-group-text" for="change-status-to-status-select">to</label>
|
||||
</div>
|
||||
<select class="custom-select" id="change-status-to-status-select">
|
||||
<option value="1">Signed Off</option>
|
||||
<option value="2">Signed Up</option>
|
||||
<option value="3">Stand By</option>
|
||||
<option selected value="4">Confirmed</option>
|
||||
</select>
|
||||
<div class="input-group-append">
|
||||
<button class="btn btn-outline-secondary" type="button"
|
||||
onclick="changeSignupStatus(
|
||||
from=document.querySelector('#change-status-from-status-select').value,
|
||||
to=document.querySelector('#change-status-to-status-select').value
|
||||
);"
|
||||
>Go</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group col-md-6">
|
||||
<label>Give Attendance</label>
|
||||
<div class="input-group">
|
||||
<div class="input-group-prepend">
|
||||
<label class="input-group-text" for="set-attendance-status-select">Give all</label>
|
||||
</div>
|
||||
<select class="custom-select" id="set-attendance-status-select">
|
||||
<option value="1">Signed Off</option>
|
||||
<option value="2">Signed Up</option>
|
||||
<option value="3">Stand By</option>
|
||||
<option selected value="4">Confirmed</option>
|
||||
</select>
|
||||
<input type="number" class="form-control" id="set-attendance-value-input" aria-label="Attendance Input" value="1.0">
|
||||
<div class="input-group-append">
|
||||
<button class="btn btn-outline-secondary" type="button"
|
||||
onclick="setAttendanceForStatus(
|
||||
status=document.querySelector('#set-attendance-status-select').value,
|
||||
value=document.querySelector('#set-attendance-value-input').value
|
||||
)"
|
||||
>Go</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<hr>
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="alert alert-primary d-flex justify-content-around" role="alert">
|
||||
<div><span id="total-confirmed" class="font-weight-bold">?</span> Confirmed</div>
|
||||
<div><span id="total-stand-by" class="font-weight-bold">?</span> Stand By</div>
|
||||
<div><span id="total-signed-up" class="font-weight-bold">?</span> Signed Up</div>
|
||||
<div><span id="total-signed-off" class="font-weight-bold">?</span> Signed Off</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<input type="submit" name="submit" value="Save" class="btn btn-primary btn-lg btn-block">
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock content %}
|
||||
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
const SIGNED_OFF = 1;
|
||||
const SIGNED_UP = 2;
|
||||
const STAND_BY = 3;
|
||||
const CONFIRMED = 4;
|
||||
|
||||
const responseForm = document.querySelector(".responses-form");
|
||||
const responseFormSelects = responseForm.querySelectorAll("select");
|
||||
|
||||
function updateSelectTotals() {
|
||||
let statusTotals = {
|
||||
[SIGNED_OFF]: 0,
|
||||
[SIGNED_UP]: 0,
|
||||
[STAND_BY]: 0,
|
||||
[CONFIRMED]: 0
|
||||
};
|
||||
responseFormSelects.forEach((select) => {
|
||||
if (!select.value) {
|
||||
return;
|
||||
}
|
||||
if (select.classList.contains("status-select")) {
|
||||
statusTotals[select.value] += 1;
|
||||
} else if (select.classList.contains("role-select")) {
|
||||
// TODO ?
|
||||
}
|
||||
});
|
||||
document.querySelector("#total-signed-off").innerHTML = statusTotals[SIGNED_OFF].toString();
|
||||
document.querySelector("#total-signed-up").innerHTML = statusTotals[SIGNED_UP].toString();
|
||||
document.querySelector("#total-stand-by").innerHTML = statusTotals[STAND_BY].toString();
|
||||
document.querySelector("#total-confirmed").innerHTML = statusTotals[CONFIRMED].toString();
|
||||
}
|
||||
|
||||
updateSelectTotals();
|
||||
responseFormSelects.forEach((select) => {
|
||||
select.addEventListener("change", updateSelectTotals);
|
||||
});
|
||||
|
||||
function trIsExtra(tr) {
|
||||
let characterSelect = tr.querySelector(".character-select");
|
||||
return characterSelect == null || characterSelect.value === ""; // the "--------" character has value=""
|
||||
}
|
||||
|
||||
function changeSignupStatus(from, to) {
|
||||
responseForm.querySelectorAll("tr").forEach((tr) => {
|
||||
if (trIsExtra(tr)) {
|
||||
return;
|
||||
}
|
||||
let statusSelect = tr.querySelector(".status-select");
|
||||
if (statusSelect.value == from) {
|
||||
statusSelect.value = to;
|
||||
}
|
||||
});
|
||||
updateSelectTotals();
|
||||
}
|
||||
|
||||
function setAttendanceForStatus(status, value) {
|
||||
responseForm.querySelectorAll("tr").forEach((tr) => {
|
||||
if (trIsExtra(tr)) {
|
||||
return;
|
||||
}
|
||||
let statusSelect = tr.querySelector(".status-select");
|
||||
if (statusSelect != null && statusSelect.value == status) {
|
||||
tr.querySelector(".attendance-input").value = value;
|
||||
}
|
||||
});
|
||||
updateSelectTotals();
|
||||
}
|
||||
</script>
|
||||
{% endblock scripts %}
|
14
drakul/raids/templates/raids/raid_confirm_delete.html
Normal file
14
drakul/raids/templates/raids/raid_confirm_delete.html
Normal file
|
@ -0,0 +1,14 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% load crispy_forms_tags %}
|
||||
|
||||
{% block title %}Delete {{ raid.title }}{% endblock %}
|
||||
|
||||
|
||||
{% block content %}
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
<p>Are you sure you want to delete "{{ object }}"?</p>
|
||||
<button type="submit" class="btn btn-danger">Confirm</button>
|
||||
</form>
|
||||
{% endblock content %}
|
10
drakul/raids/templates/raids/raid_create_form.html
Normal file
10
drakul/raids/templates/raids/raid_create_form.html
Normal file
|
@ -0,0 +1,10 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% load crispy_forms_tags %}
|
||||
|
||||
{% block title %}Create Raid{% endblock %}
|
||||
|
||||
|
||||
{% block content %}
|
||||
{% crispy form %}
|
||||
{% endblock content %}
|
90
drakul/raids/templates/raids/raid_detail.html
Normal file
90
drakul/raids/templates/raids/raid_detail.html
Normal file
|
@ -0,0 +1,90 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% load crispy_forms_tags %}
|
||||
|
||||
{% block title %}{{ raid.title }} - {{ raid.date | date:"MONTH_DAY_FORMAT" }}{% endblock %}
|
||||
|
||||
|
||||
{% block content %}
|
||||
<div class="card mb-1">
|
||||
|
||||
<h4 class="card-header d-flex">
|
||||
<span class="mr-auto">{{ raid.title }} <small class="text-muted">{{ raid.date }}</small></span>
|
||||
{% if perms.raids.change_raid %}<a class="btn btn-outline-primary btn-sm ml-2" role="button" href="{% url 'raid_change' raid.id %}">Edit</a>{% endif %}
|
||||
{% if perms.raids.delete_raid %}<a class="btn text-danger btn-link btn-sm ml-2" role="button" href="{% url 'raid_delete' raid.id %}">Delete</a>{% endif %}
|
||||
</h4>
|
||||
<div class="card-body">
|
||||
<p class="card-text">{{ raid.description | linebreaksbr | default:"<em>No description</em>" }}</p>
|
||||
<p class="card-text"><small class="text-muted">Sign up deadline: {{ raid.signup_deadline }}</small></p>
|
||||
</div>
|
||||
</div>
|
||||
{% if response_form %}
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<form class="raid-response-form" method="post" action="{% url 'raid_detail' raid.id %}">
|
||||
{% crispy response_form %}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="mb-4"></div>
|
||||
|
||||
{% regroup responses by get_status_display as status_responses_list %}
|
||||
{% for status, status_responses in status_responses_list %}
|
||||
{% with status=status|default_if_none:"No Response" %}
|
||||
<div class="card mb-2">
|
||||
<h6 class="card-header response-status-{{ status | slugify }}-bg">{{ status }} ({{ status_responses | length }})</h6>
|
||||
<div class="card-body">
|
||||
{% regroup status_responses by get_role_display as role_responses_list %}
|
||||
{% for role, role_responses in role_responses_list %}
|
||||
{% if role is not None %}
|
||||
<h6 class="card-title">{{ role }} ({{ role_responses | length }})</h6>
|
||||
<hr>
|
||||
{% endif %}
|
||||
<div class="d-flex flex-wrap">
|
||||
{% regroup role_responses by character.klass as class_responses_list %}
|
||||
{% for class, class_responses in class_responses_list %}
|
||||
<div class="d-flex flex-column mr-3 mb-3">
|
||||
{% for response in class_responses %}
|
||||
<a class="btn btn-secondary response-character-button mb-1 class-{{ response.character.get_klass_display | lower }}-bg" role="button" href="#">
|
||||
{{ response.character.name }}
|
||||
{% if response.note is not None %}🗩{% endif %}
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endwith %}
|
||||
{% endfor %}
|
||||
|
||||
<div id="comments" class="card mt-4 mb-4">
|
||||
<h5 class="card-header">Comments</h5>
|
||||
<div class="card-body">
|
||||
{% for comment in raid.comments.all %}
|
||||
<div class="card mb-2">
|
||||
<div class="card-body">
|
||||
<h6 class="card-title">
|
||||
<strong>{{ comment.user.main.name }}</strong> <small class="text-muted">· {{ comment.date_created }}</small>
|
||||
</h6>
|
||||
<p class="card-text">
|
||||
{{ comment.body }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{% empty %}
|
||||
<em>No comments.</em>
|
||||
{% endfor %}
|
||||
|
||||
{% if comment_form %}
|
||||
<hr class="my-4">
|
||||
<h6>Add Comment</h6>
|
||||
<form class="form" method="post" action="{% url 'raid_detail' raid.id %}">
|
||||
{% crispy comment_form %}
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock content %}
|
14
drakul/raids/templates/raids/raid_list.html
Normal file
14
drakul/raids/templates/raids/raid_list.html
Normal file
|
@ -0,0 +1,14 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Raids{% endblock %}
|
||||
|
||||
|
||||
{% block content %}
|
||||
<h2>Raids</h2>
|
||||
{% if perms.raids.add_raid %}<a class="btn btn-success" role="button" href="{% url 'raid_create' %}">Add New</a>{% endif %}
|
||||
<ul>
|
||||
{% for raid in raid_list %}
|
||||
<li><a href="{% url 'raid_detail' raid.id %}">{{ raid.title }}</a> ({{ raid.date }})</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endblock content %}
|
12
drakul/raids/urls.py
Normal file
12
drakul/raids/urls.py
Normal file
|
@ -0,0 +1,12 @@
|
|||
from django.urls import path
|
||||
|
||||
from . import views
|
||||
|
||||
urlpatterns = [
|
||||
path("calendar/", views.RaidCalendar.as_view(), name="raid_calendar"),
|
||||
path("calendar/<int:year>/<int:month>/", views.RaidCalendar.as_view(month_format="%m"), name="raid_calendar"),
|
||||
path("raids/create/", views.RaidCreateView.as_view(), name="raid_create"),
|
||||
path("raids/<int:pk>/", views.RaidDetailView.as_view(), name="raid_detail"),
|
||||
path("raids/<int:pk>/change/", views.RaidChangeView.as_view(), name="raid_change"),
|
||||
path("raids/<int:pk>/delete/", views.RaidDeleteView.as_view(), name="raid_delete"),
|
||||
]
|
185
drakul/raids/views.py
Normal file
185
drakul/raids/views.py
Normal file
|
@ -0,0 +1,185 @@
|
|||
import itertools
|
||||
from calendar import Calendar
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.auth.mixins import PermissionRequiredMixin
|
||||
from django.db.models import Q, Max
|
||||
from django.http import Http404
|
||||
from django.urls import reverse, reverse_lazy
|
||||
from django.utils import timezone
|
||||
from django.views.generic import DeleteView, CreateView, MonthArchiveView
|
||||
from django.views.generic.detail import SingleObjectMixin
|
||||
|
||||
from drakul.base.views import MultiModelFormView
|
||||
from .forms import RaidResponseForm, RaidCommentForm, RaidForm, RaidResponseFormSetHelper, RaidResponseFormSet
|
||||
from .models import Raid, RaidResponse, InstanceReset
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class RaidCalendar(MonthArchiveView):
|
||||
allow_empty = True
|
||||
allow_future = True
|
||||
date_field = "date"
|
||||
ordering = "date"
|
||||
template_name_suffix = "_calendar"
|
||||
|
||||
def get_queryset(self):
|
||||
if not self.request.user.is_authenticated:
|
||||
return Raid.objects.all()
|
||||
return Raid.objects.annotate(
|
||||
max_status=Max("responses__status", filter=Q(responses__character__user=self.request.user))
|
||||
)
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
date = context["month"]
|
||||
raids_for_date = {k: list(g) for k, g in itertools.groupby(context["raid_list"], key=lambda r: r.date.date())}
|
||||
instance_resets = InstanceReset.objects.all()
|
||||
|
||||
def get_day(day):
|
||||
return {
|
||||
"date": day,
|
||||
"raids": raids_for_date.get(day),
|
||||
"instance_resets": [reset
|
||||
for reset in instance_resets
|
||||
if not (day - reset.date) % reset.duration]
|
||||
}
|
||||
|
||||
context["calendar"] = [[get_day(day)
|
||||
for day in week]
|
||||
for week in Calendar().monthdatescalendar(date.year, date.month)]
|
||||
context["today"] = timezone.now().date()
|
||||
return context
|
||||
|
||||
def get_year(self):
|
||||
try:
|
||||
return super().get_year()
|
||||
except Http404:
|
||||
return timezone.now().strftime(self.get_year_format())
|
||||
|
||||
def get_month(self):
|
||||
try:
|
||||
return super().get_month()
|
||||
except Http404:
|
||||
return timezone.now().strftime(self.get_month_format()) # TODO: Could get date of next raid instead
|
||||
|
||||
def get_ordering(self):
|
||||
return super().get_ordering()
|
||||
|
||||
|
||||
class RaidDetailView(SingleObjectMixin, MultiModelFormView):
|
||||
template_name = "raids/raid_detail.html"
|
||||
|
||||
def get_queryset(self):
|
||||
return Raid.objects.prefetch_related(
|
||||
"responses", "responses__character", "comments", "comments__user__main"
|
||||
).all()
|
||||
|
||||
def get_form_classes(self):
|
||||
if not self.request.user.is_authenticated:
|
||||
return {}
|
||||
|
||||
classes = {
|
||||
"comment_form": RaidCommentForm
|
||||
}
|
||||
if self.object.signup_deadline > timezone.now():
|
||||
classes["response_form"] = RaidResponseForm
|
||||
return classes
|
||||
|
||||
def get_response_form_instance(self):
|
||||
try:
|
||||
return RaidResponse.objects.get(raid=self.object, character__user=self.request.user)
|
||||
except RaidResponse.DoesNotExist:
|
||||
return None
|
||||
|
||||
def response_form_valid(self, form):
|
||||
form.instance.raid = self.object
|
||||
form.save()
|
||||
|
||||
def comment_form_valid(self, form):
|
||||
form.instance.raid = self.object
|
||||
form.instance.user = self.request.user
|
||||
form.save()
|
||||
|
||||
def get_response_form_kwargs(self, kwargs):
|
||||
kwargs["user"] = self.request.user
|
||||
return kwargs
|
||||
|
||||
def get_comment_form_success_url(self):
|
||||
return reverse("raid_detail", kwargs={"pk": self.object.pk}) + "#comments"
|
||||
|
||||
def get_response_form_success_url(self):
|
||||
return reverse("raid_detail", kwargs={"pk": self.object.pk})
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
raid = context["raid"] = self.get_object()
|
||||
|
||||
# Create temporary pseudo-responses for users who haven't responded
|
||||
no_response_users = User.objects \
|
||||
.exclude(Q(date_joined__gt=raid.date) | Q(characters__raid_responses__raid=raid) | Q(is_active=False)) \
|
||||
.select_related("main") \
|
||||
.order_by("main__klass")
|
||||
pseudo_no_responses = [RaidResponse(character=user.main, status=None)
|
||||
for user in no_response_users]
|
||||
context["responses"] = list(raid.responses.all()) + pseudo_no_responses
|
||||
|
||||
return context
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
self.object = self.get_object()
|
||||
return super().get(request, *args, **kwargs)
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
self.object = self.get_object()
|
||||
return super().post(request, *args, **kwargs)
|
||||
|
||||
|
||||
class RaidCreateView(PermissionRequiredMixin, CreateView):
|
||||
permission_required = "raids.add_raid"
|
||||
model = Raid
|
||||
form_class = RaidForm
|
||||
template_name_suffix = "_create_form"
|
||||
|
||||
def get_success_url(self):
|
||||
return reverse("raid_detail", kwargs={"pk": self.object.pk})
|
||||
|
||||
|
||||
class RaidChangeView(PermissionRequiredMixin, SingleObjectMixin, MultiModelFormView):
|
||||
permission_required = "raids.change_raid"
|
||||
template_name = "raids/raid_change.html"
|
||||
form_classes = {
|
||||
"raid_form": RaidForm,
|
||||
"raid_response_formset": RaidResponseFormSet
|
||||
}
|
||||
|
||||
def get_queryset(self):
|
||||
return Raid.objects.prefetch_related("responses", "responses__character").all()
|
||||
|
||||
def get_raid_form_instance(self):
|
||||
return self.object
|
||||
|
||||
def get_raid_response_formset_instance(self):
|
||||
return self.object
|
||||
|
||||
def get_success_url(self):
|
||||
return reverse("raid_detail", kwargs={"pk": self.object.pk})
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
kwargs["raid_response_formset_helper"] = RaidResponseFormSetHelper()
|
||||
return super().get_context_data(**kwargs)
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
self.object = self.get_object()
|
||||
return super().get(request, *args, **kwargs)
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
self.object = self.get_object()
|
||||
return super().post(request, *args, **kwargs)
|
||||
|
||||
|
||||
class RaidDeleteView(PermissionRequiredMixin, DeleteView):
|
||||
permission_required = "raids.delete_raid"
|
||||
queryset = Raid.objects.all()
|
||||
success_url = reverse_lazy("raid_calendar")
|
0
drakul/users/__init__.py
Executable file
0
drakul/users/__init__.py
Executable file
40
drakul/users/admin.py
Executable file
40
drakul/users/admin.py
Executable file
|
@ -0,0 +1,40 @@
|
|||
from django.contrib import admin
|
||||
from django.contrib.auth.admin import UserAdmin as DjangoUserAdmin
|
||||
|
||||
from .models import User, Character
|
||||
|
||||
|
||||
class CharacterInline(admin.TabularInline):
|
||||
model = Character
|
||||
fields = ["name", "klass", "role"]
|
||||
extra = 0
|
||||
|
||||
|
||||
@admin.register(User)
|
||||
class UserAdmin(DjangoUserAdmin):
|
||||
fieldsets = (
|
||||
(None, {
|
||||
"fields": ("username", "password")
|
||||
}),
|
||||
("Permissions", {
|
||||
"fields": ("is_active", "is_staff", "is_superuser", "groups", "user_permissions"),
|
||||
}),
|
||||
("Important dates", {
|
||||
"fields": ("last_login", "date_joined")
|
||||
}),
|
||||
("Character", {
|
||||
"fields": ("main",)
|
||||
}),
|
||||
)
|
||||
inlines = [CharacterInline]
|
||||
ordering = None # use default model ordering
|
||||
list_display = ["username", "main", "avg_attendance", "is_active", "is_staff", "is_superuser"]
|
||||
list_filter = ["is_active", "is_staff", "is_superuser"]
|
||||
search_fields = ["username", "main__name"]
|
||||
|
||||
|
||||
@admin.register(Character)
|
||||
class CharacterAdmin(admin.ModelAdmin):
|
||||
list_display = ["name", "user", "klass", "role"]
|
||||
list_filter = ["klass", "role"]
|
||||
search_fields = ["user", "name", "klass", "role"]
|
5
drakul/users/apps.py
Executable file
5
drakul/users/apps.py
Executable file
|
@ -0,0 +1,5 @@
|
|||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class UsersConfig(AppConfig):
|
||||
name = "drakul.users"
|
12
drakul/users/forms.py
Normal file
12
drakul/users/forms.py
Normal file
|
@ -0,0 +1,12 @@
|
|||
from django.forms import ModelForm
|
||||
|
||||
from .models import Character
|
||||
|
||||
|
||||
class CharacterForm(ModelForm):
|
||||
class Meta:
|
||||
model = Character
|
||||
fields = ["name", "klass", "role"]
|
||||
|
||||
def clean_name(self):
|
||||
return self.cleaned_data["name"].capitalize()
|
65
drakul/users/migrations/0001_initial.py
Normal file
65
drakul/users/migrations/0001_initial.py
Normal file
|
@ -0,0 +1,65 @@
|
|||
# Generated by Django 2.2.6 on 2019-10-25 01:58
|
||||
|
||||
from django.conf import settings
|
||||
import django.contrib.auth.validators
|
||||
import django.core.validators
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import django.utils.timezone
|
||||
import drakul.users.models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('auth', '0011_update_proxy_permissions'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='User',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('password', models.CharField(max_length=128, verbose_name='password')),
|
||||
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
|
||||
('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
|
||||
('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')),
|
||||
('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')),
|
||||
('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')),
|
||||
('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')),
|
||||
('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')),
|
||||
('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.Group', verbose_name='groups')),
|
||||
],
|
||||
options={
|
||||
'ordering': ['-is_superuser', '-is_staff', 'username'],
|
||||
},
|
||||
managers=[
|
||||
('objects', drakul.users.models.UserManager()),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Character',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=12, unique=True, validators=[django.core.validators.MinLengthValidator(2), django.core.validators.RegexValidator('^[a-zA-Z]+$')])),
|
||||
('klass', models.PositiveSmallIntegerField(choices=[(1, 'Druid'), (2, 'Hunter'), (3, 'Mage'), (4, 'Paladin'), (5, 'Priest'), (6, 'Rogue'), (7, 'Shaman'), (8, 'Warlock'), (9, 'Warrior')], verbose_name='class')),
|
||||
('role', models.PositiveSmallIntegerField(choices=[(1, 'Tank'), (2, 'Healer'), (3, 'Damage')])),
|
||||
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='characters', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'ordering': ['name'],
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='user',
|
||||
name='main',
|
||||
field=models.OneToOneField(on_delete=django.db.models.deletion.PROTECT, related_name='+', to='users.Character'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='user',
|
||||
name='user_permissions',
|
||||
field=models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.Permission', verbose_name='user permissions'),
|
||||
),
|
||||
]
|
0
drakul/users/migrations/__init__.py
Normal file
0
drakul/users/migrations/__init__.py
Normal file
111
drakul/users/models.py
Normal file
111
drakul/users/models.py
Normal file
|
@ -0,0 +1,111 @@
|
|||
from django.contrib.auth.models import AbstractUser, UserManager as DjangoUserManager
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.validators import RegexValidator, MinLengthValidator
|
||||
from django.db import models, transaction
|
||||
from django.db.models import Avg
|
||||
|
||||
|
||||
class UserManager(DjangoUserManager):
|
||||
def get_queryset(self):
|
||||
qs = super().get_queryset()
|
||||
return qs.annotate(
|
||||
avg_attendance=Avg("characters__raid_responses__attendance")
|
||||
)
|
||||
|
||||
@transaction.atomic
|
||||
def _create_user(self, username, email, password, **extra_fields):
|
||||
if "main" not in extra_fields:
|
||||
main = Character.objects.create(
|
||||
user=None,
|
||||
name=username,
|
||||
klass=Character.WARRIOR,
|
||||
role=Character.DAMAGE
|
||||
)
|
||||
main.save()
|
||||
extra_fields["main"] = main
|
||||
user = super()._create_user(username, email, password, **extra_fields)
|
||||
extra_fields["main"].user = user
|
||||
extra_fields["main"].save()
|
||||
return user
|
||||
|
||||
|
||||
class User(AbstractUser):
|
||||
objects = UserManager()
|
||||
|
||||
first_name = None
|
||||
last_name = None
|
||||
|
||||
main = models.OneToOneField(
|
||||
"Character",
|
||||
related_name="+",
|
||||
on_delete=models.PROTECT
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ["-is_superuser", "-is_staff", "username"]
|
||||
|
||||
def clean(self):
|
||||
if hasattr(self, "main") and self.main.user != self:
|
||||
raise ValidationError({"main": "Main character must be owned by user."})
|
||||
|
||||
def avg_attendance(self):
|
||||
return self.avg_attendance
|
||||
|
||||
|
||||
class Character(models.Model):
|
||||
user = models.ForeignKey(
|
||||
User,
|
||||
related_name="characters",
|
||||
on_delete=models.CASCADE,
|
||||
null=True, # User and Character are mutually dependent, so we allow non-owned characters to enable signup
|
||||
blank=True
|
||||
)
|
||||
|
||||
name = models.CharField(
|
||||
max_length=12, # Blizzard limits character names to 2-12 ascii letters
|
||||
validators=[MinLengthValidator(2), RegexValidator("^[a-zA-Z]+$")],
|
||||
unique=True
|
||||
)
|
||||
|
||||
DRUID = 1
|
||||
HUNTER = 2
|
||||
MAGE = 3
|
||||
PALADIN = 4
|
||||
PRIEST = 5
|
||||
ROGUE = 6
|
||||
SHAMAN = 7
|
||||
WARLOCK = 8
|
||||
WARRIOR = 9
|
||||
CLASS_CHOICES = [
|
||||
(DRUID, "Druid"),
|
||||
(HUNTER, "Hunter"),
|
||||
(MAGE, "Mage"),
|
||||
(PALADIN, "Paladin"),
|
||||
(PRIEST, "Priest"),
|
||||
(ROGUE, "Rogue"),
|
||||
(SHAMAN, "Shaman"),
|
||||
(WARLOCK, "Warlock"),
|
||||
(WARRIOR, "Warrior"),
|
||||
]
|
||||
klass = models.PositiveSmallIntegerField(
|
||||
"class",
|
||||
choices=CLASS_CHOICES
|
||||
)
|
||||
|
||||
TANK = 1
|
||||
HEALER = 2
|
||||
DAMAGE = 3
|
||||
ROLE_CHOICES = [
|
||||
(TANK, "Tank"),
|
||||
(HEALER, "Healer"),
|
||||
(DAMAGE, "Damage"),
|
||||
]
|
||||
role = models.PositiveSmallIntegerField(
|
||||
choices=ROLE_CHOICES
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ["name"]
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
15
drakul/users/templates/users/user_list.html
Normal file
15
drakul/users/templates/users/user_list.html
Normal file
|
@ -0,0 +1,15 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<h2>Users</h2>
|
||||
<ul>
|
||||
{% for user in user_list %}
|
||||
<li>{{ user.username }} {{ user.avg_attendance | floatformat:2 }}</li>
|
||||
<ul>
|
||||
{% for character in user.characters.all %}
|
||||
<li>{{ character.name }} {{ character.get_klass_display }} {{ character.get_role_display }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endblock %}
|
7
drakul/users/urls.py
Normal file
7
drakul/users/urls.py
Normal file
|
@ -0,0 +1,7 @@
|
|||
from django.urls import path
|
||||
|
||||
from . import views
|
||||
|
||||
urlpatterns = [
|
||||
path("users/", views.UserListView.as_view(), name="user_list"),
|
||||
]
|
13
drakul/users/views.py
Normal file
13
drakul/users/views.py
Normal file
|
@ -0,0 +1,13 @@
|
|||
from django.views.generic import ListView
|
||||
|
||||
from .models import User
|
||||
|
||||
|
||||
class UserListView(ListView):
|
||||
def get_queryset(self):
|
||||
return User.objects.prefetch_related("characters").all()
|
||||
|
||||
|
||||
# CharacterDetailView:
|
||||
#slug_field = "title"
|
||||
#slug_url_kwarg = "title"
|
15
manage.py
Executable file
15
manage.py
Executable file
|
@ -0,0 +1,15 @@
|
|||
#!/usr/bin/env python
|
||||
import os
|
||||
import sys
|
||||
|
||||
if __name__ == "__main__":
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "drakul.base.settings.production")
|
||||
try:
|
||||
from django.core.management import execute_from_command_line
|
||||
except ImportError as exc:
|
||||
raise ImportError(
|
||||
"Couldn't import Django. Are you sure it's installed and "
|
||||
"available on your PYTHONPATH environment variable? Did you "
|
||||
"forget to activate a virtual environment?"
|
||||
) from exc
|
||||
execute_from_command_line(sys.argv)
|
3
requirements.txt
Normal file
3
requirements.txt
Normal file
|
@ -0,0 +1,3 @@
|
|||
Django==2.2.6
|
||||
psycopg2-binary>=2.5.4 # required by Django for PostgreSQL support: https://docs.djangoproject.com/en/2.2/ref/databases/#postgresql-notes
|
||||
django-crispy-forms==1.7.2
|
Loading…
Reference in a new issue