diff --git a/.gitignore b/.gitignore index 280fb6b..4f97b84 100644 --- a/.gitignore +++ b/.gitignore @@ -1,22 +1,17 @@ -/data/* -/data/logs/* -/data/tls/* -/data/django_static/* -/data/profilepictures/* -/data/archive/* -!/data/logs/ -!/data/logs/.gitkeep -!/data/tls/ -!/data/tls/.gitkeep -!/data/profilepictures/ -!/data/profilepictures/default.svg -!/data/archive/ -!/data/archive/.gitkeep -!/data/Caddyfile -!/data/*.example.* - -/venv - +/config/* +/static/admin +/application/**/migrations/* +/archive/* +/logs/* +/packages/* +/profilepictures/* +/temp +/tmp __pycache__ .vscode *.pem +!/config/config.sample.sh +!/config/Caddyfile +!/config/tls/ +!/profilepictures/default.svg +!.gitkeep diff --git a/LICENSE b/LICENSE index 58eccf7..f910113 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2021 Julian Müller (ChaoticByte) +Copyright (c) 2021 Julian Müller (W13R) Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal @@ -18,4 +18,4 @@ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. \ No newline at end of file +SOFTWARE. diff --git a/README.md b/README.md index 74a7248..27df4cf 100644 --- a/README.md +++ b/README.md @@ -1,420 +1,31 @@ -# Drinks Manager +# Drinks Manager (season 2) Note: This software is tailored to my own needs. I probably won't accept feature requests, and don't recommend you to use this software if this isn't exactly what you are looking for. Can't keep track of the number of drinks your guests drink? -Now you have a web interface that *really tries* to make things -less complicated- for you and your guests. +Now you have a web interface that *really tries* to make things less complicated- for +you and your guests. -This (exaggeration intended) most incredible piece of software is -written in Python, HTML, CSS, JS, Bash and uses Django and PostgreSQL. +This (exaggeration intended) most incredible piece of software is written in Python, +HTML, CSS, JS, Bash and uses Django and PostgreSQL. You have to bring your own PostgreSQL Database though. -# Getting started -## System Requirements +## Setup, Installation, Updating and Dependencies -Beneath a `PostgreSQL` DBMS, you need the following things: +You can find the latest releases [here](https://gitlab.com/W13R/drinks-manager/-/releases), but you should consider using Git to easily switch between versions. +For more information see [Setup](docs/Setup.md). -- `pg_config` (Ubuntu: `libpq-dev`, RHEL: `libpq-devel`) -- `Caddy` 2.4.3+ (HTTP Reverse Proxy & Static File Server) -- `gcc` -- `gettext` (for development only) -- `Python` 3.9+ - - `venv` - - `pip` -- `Python` header files (RHEL: `python3-devel`, Ubuntu: `python3-dev`) - -## Database - -This project is using PostgreSQL. After creating a -user and database for this application, make shure to -```sql -revoke all on schema public from PUBLIC; -``` -and revoke/grant other privileges accordingly to secure the -database against public access. ## Configuration -Create the configuration file by copying `./data/config.example.yml` -to `./data/config.yml`, and modify it for your needs. +see [Configuration](docs/Configuration.md) -## Create Environment & Install dependencies -Run the following from the main directory: -``` -./scripts/setup-env.sh -``` +## Usage -## Create admin account -``` -./scripts/create-admin.sh -``` -This also runs all necessary migrations. - -# Activate venv - -**On every new session**, before running commands with -manage.py, running special scripts, or developing, -you have to activate the virtual environment: -``` -source ./venv/bin/activate -``` -If you see `(venv)` before your command prompt, it worked! - -# Usage - -To start the Application and Webserver, run -``` -./start.sh -``` -or -``` -./start.sh --devel -``` - -# Third-Party Licenses - -This software contains third-party software and resources. -These are listed here with their respective licenses. - -## Simple Keyboard - -Source: https://github.com/hodgef/simple-keyboard - -``` -MIT License - -Copyright (c) 2019 Francisco Hodge and project contributors. - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. -``` - -## Inter (Font) - -Source: https://github.com/rsms/inter/ - -``` -Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter) - -This Font Software is licensed under the SIL Open Font License, Version 1.1. -This license is copied below, and is also available with a FAQ at: -http://scripts.sil.org/OFL - ------------------------------------------------------------ -SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 ------------------------------------------------------------ - -PREAMBLE -The goals of the Open Font License (OFL) are to stimulate worldwide -development of collaborative font projects, to support the font creation -efforts of academic and linguistic communities, and to provide a free and -open framework in which fonts may be shared and improved in partnership -with others. - -The OFL allows the licensed fonts to be used, studied, modified and -redistributed freely as long as they are not sold by themselves. The -fonts, including any derivative works, can be bundled, embedded, -redistributed and/or sold with any software provided that any reserved -names are not used by derivative works. The fonts and derivatives, -however, cannot be released under any other type of license. The -requirement for fonts to remain under this license does not apply -to any document created using the fonts or their derivatives. - -DEFINITIONS -"Font Software" refers to the set of files released by the Copyright -Holder(s) under this license and clearly marked as such. This may -include source files, build scripts and documentation. - -"Reserved Font Name" refers to any names specified as such after the -copyright statement(s). - -"Original Version" refers to the collection of Font Software components as -distributed by the Copyright Holder(s). - -"Modified Version" refers to any derivative made by adding to, deleting, -or substituting -- in part or in whole -- any of the components of the -Original Version, by changing formats or by porting the Font Software to a -new environment. - -"Author" refers to any designer, engineer, programmer, technical -writer or other person who contributed to the Font Software. - -PERMISSION AND CONDITIONS -Permission is hereby granted, free of charge, to any person obtaining -a copy of the Font Software, to use, study, copy, merge, embed, modify, -redistribute, and sell modified and unmodified copies of the Font -Software, subject to the following conditions: - -1) Neither the Font Software nor any of its individual components, -in Original or Modified Versions, may be sold by itself. - -2) Original or Modified Versions of the Font Software may be bundled, -redistributed and/or sold with any software, provided that each copy -contains the above copyright notice and this license. These can be -included either as stand-alone text files, human-readable headers or -in the appropriate machine-readable metadata fields within text or -binary files as long as those fields can be easily viewed by the user. - -3) No Modified Version of the Font Software may use the Reserved Font -Name(s) unless explicit written permission is granted by the corresponding -Copyright Holder. This restriction only applies to the primary font name as -presented to the users. - -4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font -Software shall not be used to promote, endorse or advertise any -Modified Version, except to acknowledge the contribution(s) of the -Copyright Holder(s) and the Author(s) or with their explicit written -permission. - -5) The Font Software, modified or unmodified, in part or in whole, -must be distributed entirely under this license, and must not be -distributed under any other license. The requirement for fonts to -remain under this license does not apply to any document created -using the Font Software. - -TERMINATION -This license becomes null and void if any of the above conditions are -not met. - -DISCLAIMER -THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT -OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE -COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL -DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM -OTHER DEALINGS IN THE FONT SOFTWARE. -``` - -## Material Design Icons - -Source: https://github.com/google/material-design-icons -Files: -- `./app/static/material-icons/*` -- `./data/profilepictures/default.svg` - -``` - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - -TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - -1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - -2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - -3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - -4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - -5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - -6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - -7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - -8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - -9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - -END OF TERMS AND CONDITIONS - -APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - -Copyright [yyyy] [name of copyright owner] - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -``` +After setup, run ```./run.sh help``` to see a help text. +Start the production server with ```./run.sh server```. You can ignore the error message about the "lifespan error". +For more commands, see [Commands](docs/Commands.md). diff --git a/app/apps.py b/app/apps.py deleted file mode 100644 index bcfe39b..0000000 --- a/app/apps.py +++ /dev/null @@ -1,6 +0,0 @@ -from django.apps import AppConfig - - -class AppConfig(AppConfig): - default_auto_field = "django.db.models.BigAutoField" - name = "app" diff --git a/app/db_queries.py b/app/db_queries.py deleted file mode 100644 index dd79bbe..0000000 --- a/app/db_queries.py +++ /dev/null @@ -1,180 +0,0 @@ -#from datetime import datetime - -from django.conf import settings -from django.db import connection -from django.utils.translation import gettext -from calendar import day_name - - -COMBINE_ALPHABET = "abcdefghijklmnopqrstuvwxyz" - - -def _db_select(sql_select:str): - result = None - with connection.cursor() as cursor: - cursor.execute(sql_select) - result = cursor.fetchall() - return result - -def _combine_results(results:list) -> dict: - ''' - e.g. - input: [ - [("x", 12), ("y", 13)], - [("y", 10), ("z", 42)] - ] - output: { - "x": {"a": 12}, - "y": {"a": 13, "b": 10}, - "z": {"b": 42} - } - ''' - result = {} - for i, d in enumerate(results): - a = COMBINE_ALPHABET[i] - for r in d: - r_0 = r[0] - if r_0 not in result: - result[r_0] = {} - result[r_0][a] = r[1] - return result - - -def select_history(user, language_code="en") -> list: - # select order history and deposits - user_id = user.pk - result = _db_select(f""" - select - price_sum as "sum", - concat( - product_name, - ' (', - content_litres::real, -- converting to real removes trailing zeros - 'l) x ', amount - ) as "text", - datetime - from app_order - where user_id = {user_id} - - union - - select - transaction_sum as "sum", - '{gettext("Deposit")}' as "text", - datetime - from app_userdeposits_view - where user_id = {user_id} - - union - - select - transaction_sum as "sum", - comment as "text", - datetime - from app_registertransaction - where user_id = {user_id} and is_transfer = true - - order by datetime desc - fetch first 30 rows only; - """) - result = [list(row) for row in result] - return result - -def select_orders_per_month(user) -> dict: - # number of orders per month (last 12 months) - result_user = _db_select(f""" - select - to_char(date_trunc('month', datetime), 'YYYY-MM') as "month", - sum(amount) as "count" - from app_order - where user_id = {user.pk} - and date_trunc('month', datetime) > date_trunc('month', now() - '12 months'::interval) - group by "month" - order by "month" desc; - """) - result_all = _db_select(f""" - select - to_char(date_trunc('month', datetime), 'YYYY-MM') as "month", - sum(amount) as "count" - from app_order - where date_trunc('month', datetime) > date_trunc('month', now() - '12 months'::interval) - group by "month" - order by "month" desc; - """) - return _combine_results([result_all, result_user]) - -def select_orders_per_weekday(user) -> list: - # number of orders per weekday (all time) - result = _db_select(f""" - with q_all as ( - select - extract(isodow from datetime) as "d", - sum(amount) as "c" - from app_order - group by d - ), q_user as ( - select - extract(isodow from datetime) as "d", - sum(amount) as "c" - from app_order - where user_id = {user.pk} - group by d - ) - select q_all.d as "day", q_all.c, q_user.c from q_all full join q_user on q_all.d = q_user.d - group by day, q_all.c, q_user.c - order by day asc; - """) - for i in range(len(result)): - day_, all_, user_ = result[i] - result[i] = (day_name[int(day_)-1], all_, user_) - return result - -def select_orders_per_drink(user) -> dict: - # number of orders per drink (all time) - result_user = _db_select(f""" - select - d.product_name as "label", - sum(o.amount) as "data" - from app_drink d - join app_order o on (d.id = o.drink_id) - where o.user_id = {user.pk} - group by d.product_name - order by "data" desc; - """) - result_all = _db_select(f""" - select - d.product_name as "label", - sum(o.amount) as "data" - from app_drink d - join app_order o on (d.id = o.drink_id) - group by d.product_name - order by "data" desc; - """) - return _combine_results([result_all, result_user]) - -def select_order_sum_per_user_all_users() -> list: - # sum of all orders per user, for all users - result = _db_select(f""" - select - app_user.username as user, - sum(app_order.price_sum) as sum - from app_user - left outer join app_order on (app_user.id = app_order.user_id) - group by app_user.id - order by app_user asc; - """) - return result - -def select_deposit_sum_per_user_all_users() -> list: - # sum of all orders per user, for all users - result = _db_select(f""" - select - app_user.username as user, - sum(rt.transaction_sum) as sum - from app_user - left outer join app_registertransaction rt on (app_user.id = rt.user_id) - where rt.is_user_deposit is true or rt.is_user_deposit is null - group by app_user.id - order by app_user asc; - """) - return result diff --git a/app/locales/de/LC_MESSAGES/django.mo b/app/locales/de/LC_MESSAGES/django.mo deleted file mode 100644 index 5d60623..0000000 Binary files a/app/locales/de/LC_MESSAGES/django.mo and /dev/null differ diff --git a/app/locales/de/LC_MESSAGES/django.po b/app/locales/de/LC_MESSAGES/django.po deleted file mode 100644 index 278ff86..0000000 --- a/app/locales/de/LC_MESSAGES/django.po +++ /dev/null @@ -1,276 +0,0 @@ -# SOME DESCRIPTIVE TITLE. -# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER -# This file is distributed under the same license as the PACKAGE package. -# FIRST AUTHOR , YEAR. -# -#, fuzzy -msgid "" -msgstr "" -"Project-Id-Version: PACKAGE VERSION\n" -"Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2023-11-01 19:29+0100\n" -"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" -"Last-Translator: Julian Müller (ChaoticByte)\n" -"Language: DE\n" -"MIME-Version: 1.0\n" -"Content-Type: text/plain; charset=UTF-8\n" -"Content-Transfer-Encoding: 8bit\n" -"Plural-Forms: nplurals=2; plural=(n != 1);\n" - -#: app/templates/admin/base_site.html:7 -msgid "Django site admin" -msgstr "Django Administrator" - -#: app/templates/admin/base_site.html:15 -msgid "Django administration" -msgstr "Django Administration" - -#: app/templates/baselayout.html:26 -msgid "An error occured. Please log out and log in again." -msgstr "Ein Fehler ist aufgetreten. Bitte ab- und wieder anmelden." - -#: app/templates/deposit.html:6 -msgid "Drinks - Deposit" -msgstr "Getränke - Einzahlen" - -#: app/templates/deposit.html:17 app/templates/userpanel.html:18 -msgid "Deposit" -msgstr "Einzahlen" - -#: app/templates/deposit.html:19 app/templates/transfer.html:43 -msgid "Amount" -msgstr "Summe" - -#: app/templates/deposit.html:30 app/templates/order.html:54 -#: app/templates/registration/login.html:28 app/templates/supply.html:29 -#: app/templates/transfer.html:54 -msgid "cancel" -msgstr "Abbrechen" - -#: app/templates/deposit.html:31 app/templates/transfer.html:55 -msgid "confirm" -msgstr "Bestätigen" - -#: app/templates/history.html:6 -msgid "Drinks - History" -msgstr "Getränke - Verlauf" - -#: app/templates/history.html:10 app/templates/userpanel.html:23 -msgid "History" -msgstr "Verlauf" - -#: app/templates/history.html:14 -msgid "last 30 actions" -msgstr "letzte 30 Vorgänge" - -#: app/templates/history.html:25 -msgid "No history." -msgstr "Kein Verlauf verfügbar." - -#: app/templates/index.html:6 -msgid "Drinks - Home" -msgstr "Getränke - Home" - -#: app/templates/index.html:10 -msgid "Available Drinks" -msgstr "Verfügbare Getränke" - -#: app/templates/index.html:18 app/templates/index.html:25 -msgid "available" -msgstr "verfügbar" - -#: app/templates/index.html:32 -msgid "No drinks available." -msgstr "Es sind gerade keine Getränke verfügbar." - -#: app/templates/order.html:7 -msgid "Drinks - Order" -msgstr "Getränke - Bestellen" - -#: app/templates/order.html:16 -msgid "Order" -msgstr "Bestellung" - -#: app/templates/order.html:18 -msgid "Drink" -msgstr "Getränk" - -#: app/templates/order.html:22 -msgid "Price per Item" -msgstr "Preis pro Getränk" - -#: app/templates/order.html:29 -msgid "Available" -msgstr "Verfügbar" - -#: app/templates/order.html:34 -msgid "Sum" -msgstr "Summe" - -#: app/templates/order.html:38 -msgid "Count" -msgstr "Anzahl" - -#: app/templates/order.html:55 -msgid "order" -msgstr "Bestellen" - -#: app/templates/order.html:62 -msgid "Your balance is too low to order a drink." -msgstr "Dein Saldo ist zu niedrig um Getränke zu bestellen." - -#: app/templates/order.html:63 app/templates/order.html:69 -#: app/templates/supply.html:38 -msgid "back" -msgstr "zurück" - -#: app/templates/order.html:68 -msgid "This drink is not available." -msgstr "Dieses Getränk ist gerade nicht verfügbar." - -#: app/templates/registration/logged_out.html:6 -msgid "Drinks - Logged Out" -msgstr "Getränke - Abgemeldet" - -#: app/templates/registration/logged_out.html:15 -msgid "Logged out! You will be redirected shortly." -msgstr "Du wurdest abgemeldet und wirst in Kürze weitergeleitet." - -#: app/templates/registration/logged_out.html:16 -msgid "Click here if automatic redirection does not work." -msgstr "" -"Bitte klicke hier, wenn die automatische Weiterleitung nicht funktioniert." - -#: app/templates/registration/login.html:8 -msgid "Drinks - Login" -msgstr "Getränke - Anmeldung" - -#: app/templates/registration/login.html:22 -msgid "Log in" -msgstr "Anmelden" - -#: app/templates/registration/login.html:26 -msgid "Password/PIN" -msgstr "Passwort/PIN" - -#: app/templates/registration/login.html:29 -msgid "login" -msgstr "Anmelden" - -#: app/templates/registration/login.html:40 -msgid "Choose your account" -msgstr "Wähle deinen Account" - -#: app/templates/statistics.html:6 -msgid "Drinks - Statistics" -msgstr "Getränke - Statistiken" - -#: app/templates/statistics.html:10 app/templates/userpanel.html:24 -msgid "Statistics" -msgstr "Statistiken" - -#: app/templates/statistics.html:13 -msgid "orders / drink" -msgstr "Bestellungen / Getränk" - -#: app/templates/statistics.html:16 -msgid "drink" -msgstr "Getränk" - -#: app/templates/statistics.html:17 app/templates/statistics.html:36 -#: app/templates/statistics.html:53 -msgid "all" -msgstr "Alle" - -#: app/templates/statistics.html:18 app/templates/statistics.html:37 -#: app/templates/statistics.html:54 -msgid "you" -msgstr "Du" - -#: app/templates/statistics.html:32 -msgid "orders / month" -msgstr "Bestellungen / Monat" - -#: app/templates/statistics.html:35 -msgid "month" -msgstr "Monat" - -#: app/templates/statistics.html:49 -msgid "orders / weekday" -msgstr "Bestellungen / Wochentag" - -#: app/templates/statistics.html:52 -msgid "day" -msgstr "Tag" - -#: app/templates/statistics.html:69 -msgid "order sum" -msgstr "Bestellungen" - -#: app/templates/statistics.html:72 app/templates/statistics.html:89 -msgid "user" -msgstr "Benutzer" - -#: app/templates/statistics.html:73 app/templates/statistics.html:90 -msgid "sum" -msgstr "Summe" - -#: app/templates/statistics.html:86 -msgid "deposit sum" -msgstr "Einzahlungen" - -#: app/templates/supply.html:7 -msgid "Drinks - Supply" -msgstr "Getränke - Beschaffung" - -#: app/templates/supply.html:14 app/templates/userpanel.html:30 -msgid "Supply" -msgstr "Beschaffung" - -#: app/templates/supply.html:16 -msgid "Description" -msgstr "Beschreibung" - -#: app/templates/supply.html:22 -msgid "Price" -msgstr "Preis" - -#: app/templates/supply.html:30 -msgid "submit" -msgstr "Senden" - -#: app/templates/supply.html:37 -msgid "You are not allowed to view this site." -msgstr "Dir fehlt die Berechtigung, diese Seite anzuzeigen." - -#: app/templates/transfer.html:6 -msgid "Drinks - Transfer" -msgstr "Getränke - Geld senden" - -#: app/templates/transfer.html:17 -msgid "Transfer Money" -msgstr "Geld senden" - -#: app/templates/transfer.html:19 -msgid "Recipient" -msgstr "Empfänger" - -#: app/templates/userpanel.html:10 app/templates/userpanel.html:12 -msgid "Balance" -msgstr "Saldo" - -#: app/templates/userpanel.html:19 -msgid "Logout" -msgstr "Abmelden" - -#: app/templates/userpanel.html:28 -msgid "Transfer" -msgstr "Geld senden" - -#: app/templates/userpanel.html:32 -msgid "Change Password" -msgstr "Passwort ändern" - -#: app/views.py:42 -msgid "Invalid username or password." -msgstr "Benutzername oder Passwort ungültig." diff --git a/app/migrations/0001_initial.py b/app/migrations/0001_initial.py deleted file mode 100644 index 71ead05..0000000 --- a/app/migrations/0001_initial.py +++ /dev/null @@ -1,267 +0,0 @@ -# Generated by Django 4.1.6 on 2023-02-11 15:24 - -from django.conf import settings -import django.contrib.auth.models -import django.contrib.auth.validators -from django.db import migrations, models -import django.db.models.deletion -import django.utils.timezone - - -class Migration(migrations.Migration): - initial = True - - dependencies = [ - ("auth", "0012_alter_user_first_name_max_length"), - ] - - operations = [ - migrations.CreateModel( - name="User", - fields=[ - ( - "id", - models.BigAutoField( - 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", - ), - ), - ( - "first_name", - models.CharField( - blank=True, max_length=150, verbose_name="first name" - ), - ), - ( - "last_name", - models.CharField( - blank=True, max_length=150, verbose_name="last name" - ), - ), - ( - "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" - ), - ), - ( - "balance", - models.DecimalField(decimal_places=2, default=0.0, max_digits=8), - ), - ( - "allow_order_with_negative_balance", - models.BooleanField(default=False), - ), - ( - "profile_picture_filename", - models.CharField(default="default.svg", max_length=25), - ), - ("allowed_to_supply", models.BooleanField(default=False)), - ( - "groups", - models.ManyToManyField( - blank=True, - help_text="The groups this user belongs to. A user will get all permissions granted to each of their groups.", - related_name="user_set", - related_query_name="user", - to="auth.group", - verbose_name="groups", - ), - ), - ( - "user_permissions", - models.ManyToManyField( - blank=True, - help_text="Specific permissions for this user.", - related_name="user_set", - related_query_name="user", - to="auth.permission", - verbose_name="user permissions", - ), - ), - ], - options={ - "verbose_name": "user", - "verbose_name_plural": "users", - "abstract": False, - }, - managers=[ - ("objects", django.contrib.auth.models.UserManager()), - ], - ), - migrations.CreateModel( - name="Drink", - fields=[ - ( - "id", - models.BigAutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ("product_name", models.CharField(max_length=64)), - ( - "content_litres", - models.DecimalField(decimal_places=3, default=0.5, max_digits=6), - ), - ( - "price", - models.DecimalField(decimal_places=2, default=0.0, max_digits=6), - ), - ("available", models.PositiveIntegerField(default=0)), - ("deleted", models.BooleanField(default=False)), - ("do_not_count", models.BooleanField(default=False)), - ], - ), - migrations.CreateModel( - name="Global", - fields=[ - ( - "name", - models.CharField( - max_length=42, primary_key=True, serialize=False, unique=True - ), - ), - ("comment", models.TextField()), - ("value_float", models.FloatField(default=0.0)), - ("value_string", models.TextField()), - ], - ), - migrations.CreateModel( - name="RegisterTransaction", - fields=[ - ( - "id", - models.BigAutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ( - "transaction_sum", - models.DecimalField(decimal_places=2, default=0.0, max_digits=6), - ), - ( - "old_transaction_sum", - models.DecimalField(decimal_places=2, default=0.0, max_digits=6), - ), - ("datetime", models.DateTimeField(default=django.utils.timezone.now)), - ("is_user_deposit", models.BooleanField(default=False)), - ("comment", models.TextField(default=" ")), - ( - "user", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - to=settings.AUTH_USER_MODEL, - ), - ), - ], - options={ - "verbose_name": "transaction", - "verbose_name_plural": "transactions", - }, - ), - migrations.CreateModel( - name="Order", - fields=[ - ( - "id", - models.BigAutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ("datetime", models.DateTimeField(default=django.utils.timezone.now)), - ("amount", models.PositiveIntegerField(default=1, editable=False)), - ("product_name", models.CharField(editable=False, max_length=64)), - ( - "price_sum", - models.DecimalField( - decimal_places=2, default=0, editable=False, max_digits=6 - ), - ), - ( - "content_litres", - models.DecimalField( - decimal_places=3, default=0, editable=False, max_digits=6 - ), - ), - ( - "drink", - models.ForeignKey( - limit_choices_to=models.Q(("available__gt", 0)), - null=True, - on_delete=django.db.models.deletion.SET_NULL, - to="app.drink", - ), - ), - ( - "user", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - to=settings.AUTH_USER_MODEL, - ), - ), - ], - ), - ] diff --git a/app/migrations/0002_setup.py b/app/migrations/0002_setup.py deleted file mode 100644 index fdb8962..0000000 --- a/app/migrations/0002_setup.py +++ /dev/null @@ -1,34 +0,0 @@ -# GlobalValues Data migration #1 - -from django.db import migrations - - -def create_globals(apps, schema_editor): - Global = apps.get_model("app", "Global") - Global( - name="global_message", - comment="Here you can set a global message that will be shown to every user", - value_float=0.0, - value_string="").save() - Global( - name="admin_info", - comment="Here you can set am infotext that will be displayed on the admin panel", - value_float=0.0, - value_string="").save() - - -class Migration(migrations.Migration): - - dependencies = [ - ('app', '0001_initial'), - ] - - operations = [ - # create globals - migrations.RunPython(create_globals), - # create view for userdeposits - migrations.RunSQL(""" - create or replace view app_userdeposits_view as - select * from app_registertransaction - where is_user_deposit = true;""") - ] diff --git a/app/migrations/0003_user_hide_from_userlist.py b/app/migrations/0003_user_hide_from_userlist.py deleted file mode 100644 index 7ffa0e8..0000000 --- a/app/migrations/0003_user_hide_from_userlist.py +++ /dev/null @@ -1,17 +0,0 @@ -# Generated by Django 4.1.6 on 2023-04-13 19:12 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("app", "0002_setup"), - ] - - operations = [ - migrations.AddField( - model_name="user", - name="hide_from_userlist", - field=models.BooleanField(default=False), - ), - ] diff --git a/app/migrations/0004_registertransaction_is_transfer.py b/app/migrations/0004_registertransaction_is_transfer.py deleted file mode 100644 index 550b353..0000000 --- a/app/migrations/0004_registertransaction_is_transfer.py +++ /dev/null @@ -1,17 +0,0 @@ -# Generated by Django 4.1.6 on 2023-04-14 20:01 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("app", "0003_user_hide_from_userlist"), - ] - - operations = [ - migrations.AddField( - model_name="registertransaction", - name="is_transfer", - field=models.BooleanField(default=False), - ), - ] diff --git a/app/templates/deposit.html b/app/templates/deposit.html deleted file mode 100644 index cf3dbc1..0000000 --- a/app/templates/deposit.html +++ /dev/null @@ -1,33 +0,0 @@ -{% extends "baselayout.html" %} - -{% load i18n %} - -{% block title %} -{% translate "Drinks - Deposit" %} -{% endblock %} - -{% block headAdditional %} - - -{% endblock %} - -{% block content %} -

{% translate "Deposit" %}

-
- {% csrf_token %} -
- -
- -
- - - -
-
- - -{% endblock %} \ No newline at end of file diff --git a/app/templates/footer.html b/app/templates/footer.html deleted file mode 100644 index 63b51ec..0000000 --- a/app/templates/footer.html +++ /dev/null @@ -1,7 +0,0 @@ -{% load i18n %} - \ No newline at end of file diff --git a/app/templates/globalmessage.html b/app/templates/globalmessage.html deleted file mode 100644 index 4ec0067..0000000 --- a/app/templates/globalmessage.html +++ /dev/null @@ -1,5 +0,0 @@ -{% if global_message != "" %} -
-
{{ global_message }}
-
-{% endif %} \ No newline at end of file diff --git a/app/templates/history.html b/app/templates/history.html deleted file mode 100644 index c94f17e..0000000 --- a/app/templates/history.html +++ /dev/null @@ -1,28 +0,0 @@ -{% extends "baselayout.html" %} - -{% load i18n %} - -{% block title %} -{% translate "Drinks - History" %} -{% endblock %} - -{% block content %} -

{% translate "History" %}

-{% if history %} - - - - - {% for h in history %} - - - - - - {% endfor %} -
{% translate "last 30 actions" %}
{{ h.0 }} {{ currency_suffix }}{{ h.1 }}{{ h.2 }}
-{% else %} -{% translate "No history." %} -{% endif %} - -{% endblock %} \ No newline at end of file diff --git a/app/templates/index.html b/app/templates/index.html deleted file mode 100644 index 076af03..0000000 --- a/app/templates/index.html +++ /dev/null @@ -1,35 +0,0 @@ -{% extends "baselayout.html" %} - -{% load i18n %} - -{% block title %} -{% translate "Drinks - Home" %} -{% endblock %} - -{% block content %} -

{% translate "Available Drinks" %}

-{% if available_drinks %} - -{% else %} -{% translate "No drinks available." %} -{% endif %} - -{% endblock %} \ No newline at end of file diff --git a/app/templates/order.html b/app/templates/order.html deleted file mode 100644 index 2e5286f..0000000 --- a/app/templates/order.html +++ /dev/null @@ -1,74 +0,0 @@ -{% extends "baselayout.html" %} - -{% load i18n %} -{% load l10n %} - -{% block title %} -{% translate "Drinks - Order" %} -{% endblock %} - -{% block content %} -
-{% if drink and drink.available > 0 and not drink.deleted %} -{% if user.balance > 0 or user.allow_order_with_negative_balance %} -

{% translate "Order" %}

-
- {% csrf_token %} -
- {% translate "Drink" %} - {{ drink.product_name }} -
-
- {% translate "Price per Item" %} ({{ currency_suffix }}) - - {{ drink.price }} - -
- {% if not drink.do_not_count %} -
- {% translate "Available" %} - {{ drink.available }} -
- {% endif %} -
- {% translate "Sum" %} ({{ currency_suffix }}) - {{ drink.price }} -
-
- {% translate "Count" %} - - - {% if drink.do_not_count %} - - {% else %} - - {% endif %} - - -
- - -
-
- - -{% else %} -
-

{% translate "Your balance is too low to order a drink." %}

- {% translate "back" %} -
-{% endif %} -{% else %} -
-

{% translate "This drink is not available." %}

- {% translate "back" %} -
-{% endif %} - -
-{% endblock %} \ No newline at end of file diff --git a/app/templates/registration/logged_out.html b/app/templates/registration/logged_out.html deleted file mode 100644 index c4d69fc..0000000 --- a/app/templates/registration/logged_out.html +++ /dev/null @@ -1,19 +0,0 @@ -{% extends "baselayout.html" %} - -{% load i18n %} - -{% block title %} -{% translate "Drinks - Logged Out" %} -{% endblock %} - -{% block headAdditional %} - -{% endblock %} - -{% block content %} -
- {% translate "Logged out! You will be redirected shortly." %} - {% translate "Click here if automatic redirection does not work." %} -
- -{% endblock %} \ No newline at end of file diff --git a/app/templates/registration/login.html b/app/templates/registration/login.html deleted file mode 100644 index 657c713..0000000 --- a/app/templates/registration/login.html +++ /dev/null @@ -1,62 +0,0 @@ - -{% extends "baselayout.html" %} - -{% load i18n %} -{% load static %} - -{% block title %} -{% translate "Drinks - Login" %} -{% endblock %} - -{% block headAdditional %} - - -{% endblock %} - -{% block content %} -{% if error_message %} -

{{ error_message }}

-{% endif %} -
-
-

{% translate "Log in" %}

-
- {% csrf_token %} - - -
- - -
-
-
- - {% get_current_language as LANGUAGE_CODE %} -
- - -
-
-

{% translate "Choose your account" %}

- -
- -{% endblock %} \ No newline at end of file diff --git a/app/templates/statistics.html b/app/templates/statistics.html deleted file mode 100644 index 93f3a9e..0000000 --- a/app/templates/statistics.html +++ /dev/null @@ -1,103 +0,0 @@ -{% extends "baselayout.html" %} - -{% load i18n %} - -{% block title %} -{% translate "Drinks - Statistics" %} -{% endblock %} - -{% block content %} -

{% translate "Statistics" %}

-
-
-

{% translate "orders / drink" %}

- - - - - - - {% for key, values in orders_per_drink.items %} - - - - - - {% endfor %} -
{% translate "drink" %}{% translate "all" %}{% translate "you" %}
{{ key }}{{ values.a|default:0 }}{{ values.b|default:0 }}
-
-
-
-
-

{% translate "orders / month" %}

- - - - - - - {% for key, values in orders_per_month.items %} - - - - - - {% endfor %} -
{% translate "month" %}{% translate "all" %}{% translate "you" %}
{{ key }}{{ values.a|default:0 }}{{ values.b|default:0 }}
-
-
-

{% translate "orders / weekday" %}

- - - - - - - {% for values in orders_per_weekday %} - - - - - - {% endfor %} -
{% translate "day" %}{% translate "all" %}{% translate "you" %}
{{ values.0 }}{{ values.1|default:0 }}{{ values.2|default:0 }}
-
-
-
- {% if user.is_superuser or perms.app.view_order %} -
-

{% translate "order sum" %}

- - - - - - {% for values in order_sum_per_user %} - - - - - {% endfor %} -
{% translate "user" %}{% translate "sum" %}
{{ values.0 }}{{ values.1|default:0.0 }} {{ currency_suffix }}
-
- {% endif %} - {% if user.is_superuser or perms.app.view_registertransaction %} -
-

{% translate "deposit sum" %}

- - - - - - {% for values in deposit_sum_per_user %} - - - - - {% endfor %} -
{% translate "user" %}{% translate "sum" %}
{{ values.0 }}{{ values.1|default:0.0 }} {{ currency_suffix }}
-
- {% endif %} -
- -{% endblock %} \ No newline at end of file diff --git a/app/templates/supply.html b/app/templates/supply.html deleted file mode 100644 index bcbc3d0..0000000 --- a/app/templates/supply.html +++ /dev/null @@ -1,36 +0,0 @@ -{% extends "baselayout.html" %} - -{% load i18n %} -{% load l10n %} - -{% block title %} -{% translate "Drinks - Supply" %} -{% endblock %} - -{% block content %} -{% if user.is_superuser or user.allowed_to_supply %} -

{% translate "Supply" %}

-
- {% csrf_token %} -
- -
-
- -
- -
-
- - -{% else %} -
-

{% translate "You are not allowed to view this site." %}

- {% translate "back" %} -
-{% endif %} - -{% endblock %} \ No newline at end of file diff --git a/app/templates/transfer.html b/app/templates/transfer.html deleted file mode 100644 index bf0524b..0000000 --- a/app/templates/transfer.html +++ /dev/null @@ -1,54 +0,0 @@ -{% extends "baselayout.html" %} - -{% load i18n %} - -{% block title %} -{% translate "Drinks - Transfer" %} -{% endblock %} - -{% block headAdditional %} - - -{% endblock %} - -{% block content %} -

{% translate "Transfer Money" %}

-
- {% csrf_token %} -
- -
-
- -
- -
- - - -
-
- - -{% endblock %} \ No newline at end of file diff --git a/app/templates/userpanel.html b/app/templates/userpanel.html deleted file mode 100644 index 966f86e..0000000 --- a/app/templates/userpanel.html +++ /dev/null @@ -1,37 +0,0 @@ -{% load i18n %} -{% load static %} - -
-
- {% if user.first_name != "" %} - {{ user.first_name }} {{ user.last_name }} ({{ user.username }}){% else %}{{ user.username }}{% endif %} -  -  - {% if user.balance < 0.01 %} - {% translate "Balance" %}: {{ user.balance }} {{ currency_suffix }} - {% else %} - {% translate "Balance" %}: {{ user.balance }} {{ currency_suffix }} - {% endif %} - -
-
- Home - {% translate "Deposit" %} - {% translate "Logout" %} - -
-
diff --git a/app/__init__.py b/application/app/__init__.py similarity index 100% rename from app/__init__.py rename to application/app/__init__.py diff --git a/app/admin.py b/application/app/admin.py similarity index 93% rename from app/admin.py rename to application/app/admin.py index 4513ff0..4234240 100644 --- a/app/admin.py +++ b/application/app/admin.py @@ -2,7 +2,6 @@ from django.contrib import admin from django.contrib.auth.admin import UserAdmin -from django.utils.decorators import method_decorator from django.views.decorators.cache import never_cache from .models import User @@ -16,7 +15,6 @@ from .forms import CustomDrinkForm from .forms import CustomGlobalForm from .forms import CustomRegisterTransactionForm - # Admin Site class CustomAdminSite(admin.AdminSite): @@ -24,8 +22,9 @@ class CustomAdminSite(admin.AdminSite): site_header = "Drinks Administration" site_title = "Drinks Administration" - @method_decorator(never_cache) + @never_cache def index(self, request, extra_context=None): + return super().index(request, extra_context={ "admin_info": Global.objects.get(name="admin_info").value_string, **(extra_context or {}) @@ -43,24 +42,20 @@ class CustomUserAdmin(UserAdmin): fieldsets_ = list((*UserAdmin.fieldsets,)) fieldsets_.insert(1, ( - "Visibility", - {"fields": ("hide_from_userlist",)}, - )) - fieldsets_.insert(2, ( "Balance", {"fields": ("balance", "allow_order_with_negative_balance")}, )) - fieldsets_.insert(3, ( + fieldsets_.insert(2, ( "Supply", {"fields": ("allowed_to_supply",)}, )) - fieldsets_.insert(4, ( + fieldsets_.insert(3, ( "Profile Picture", {"fields": ("profile_picture_filename",)}, )) fieldsets = tuple(fieldsets_) - list_display = ["username", "balance", "allow_order_with_negative_balance", "is_active", "hide_from_userlist"] + list_display = ["username", "balance", "is_active", "allow_order_with_negative_balance"] def get_actions(self, request): # remove the "delete_selected" action because it breaks some functionality actions = super().get_actions(request) @@ -95,7 +90,9 @@ class CustomRegisterAdmin(admin.ModelAdmin): return actions def delete_selected_new(self, request, queryset): + #print(queryset) for supply in queryset: + #print(order) supply.delete() if queryset.count() < 2: self.message_user(request, f"Revoked {queryset.count()} supply.") @@ -103,6 +100,7 @@ class CustomRegisterAdmin(admin.ModelAdmin): self.message_user(request, f"Revoked {queryset.count()} supplies.") delete_selected_new.short_description = "Revoke selected transactions" + adminSite.register(Register, CustomRegisterAdmin) @@ -119,7 +117,9 @@ class CustomOrderAdmin(admin.ModelAdmin): return actions def delete_selected_new(self, request, queryset): + #print(queryset) for order in queryset: + #print(order) order.delete() self.message_user(request, f"Revoked {queryset.count()} order(s).") delete_selected_new.short_description = "Revoke selected orders" diff --git a/application/app/apps.py b/application/app/apps.py new file mode 100644 index 0000000..e61ab8c --- /dev/null +++ b/application/app/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig +from django.contrib.admin.apps import AdminConfig + + +class DAppConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'app' diff --git a/app/context_processors.py b/application/app/context_processors.py similarity index 100% rename from app/context_processors.py rename to application/app/context_processors.py index 68a9e69..f3e345f 100644 --- a/app/context_processors.py +++ b/application/app/context_processors.py @@ -2,8 +2,8 @@ from django.conf import settings from .models import Global - def app_version(request): + try: global_message = Global.objects.get(pk="global_message").value_string except Global.DoesNotExist: diff --git a/app/forms.py b/application/app/forms.py similarity index 100% rename from app/forms.py rename to application/app/forms.py diff --git a/application/app/middleware.py b/application/app/middleware.py new file mode 100644 index 0000000..43acf6c --- /dev/null +++ b/application/app/middleware.py @@ -0,0 +1,11 @@ + +# Define CSP middleware: + +def csp_middleware(get_response): + + def middleware(request): + response = get_response(request) + response["content-security-policy"] = "default-src 'self'" + return response + + return middleware diff --git a/app/models.py b/application/app/models.py similarity index 83% rename from app/models.py rename to application/app/models.py index 717a1cf..a3bb11c 100644 --- a/app/models.py +++ b/application/app/models.py @@ -2,10 +2,12 @@ from django.db import models from django.conf import settings from django.contrib.auth.models import AbstractUser +from django_currentuser.db.models import CurrentUserField from django.forms import ValidationError from django.utils import timezone + # Custom user model class User(AbstractUser): @@ -13,7 +15,6 @@ class User(AbstractUser): allow_order_with_negative_balance = models.BooleanField(default=False) profile_picture_filename = models.CharField(default="default.svg", max_length=25) allowed_to_supply = models.BooleanField(default=False) - hide_from_userlist = models.BooleanField(default=False) def delete(self, *args, **kwargs): self.balance = 0 @@ -24,6 +25,8 @@ class User(AbstractUser): self.email = "" super().save() +# + class Drink(models.Model): @@ -40,12 +43,10 @@ class Drink(models.Model): do_not_count = models.BooleanField(default=False) def delete(self, *args, **kwargs): - # we flag the field as deleted. self.deleted = True super().save() - def __str__(self): - return f"{self.product_name} ({float(self.content_litres):.2f}l) - {self.price} {settings.CURRENCY_SUFFIX}" + def __str__(self): return f"{self.product_name} ({float(self.content_litres):.2f}l) - {self.price}{settings.CURRENCY_SUFFIX}" class RegisterTransaction(models.Model): @@ -60,34 +61,35 @@ class RegisterTransaction(models.Model): old_transaction_sum = models.DecimalField(max_digits=6, decimal_places=2, default=0.00) datetime = models.DateTimeField(default=timezone.now) is_user_deposit = models.BooleanField(default=False) - is_transfer = models.BooleanField(default=False) comment = models.TextField(default=" ") - user = models.ForeignKey(User, on_delete=models.CASCADE) + user = CurrentUserField() def save(self, *args, **kwargs): if self._state.adding: - if self.is_user_deposit or self.is_transfer: # update user balance + if self.is_user_deposit == True: # update user balance self.user.balance += self.transaction_sum self.user.save() self.old_transaction_sum = self.transaction_sum super().save(*args, **kwargs) else: - # update user balance - if self.is_user_deposit or self.is_transfer: - self.user.balance += self.transaction_sum - self.old_transaction_sum - self.user.save() # update register transaction + sum_diff = self.transaction_sum - self.old_transaction_sum + # update user balance + if self.is_user_deposit == True: + ub_sum_diff = self.transaction_sum - self.old_transaction_sum + self.user.balance += ub_sum_diff + self.user.save() self.old_transaction_sum = self.transaction_sum super().save(*args, **kwargs) def delete(self, *args, **kwargs): # update user deposit - if self.is_user_deposit or self.is_transfer: + if self.is_user_deposit: self.user.balance -= self.transaction_sum self.user.save() super().delete(*args, kwargs) - def __str__(self): return f"{self.transaction_sum} {settings.CURRENCY_SUFFIX} by {self.user}" + def __str__(self): return f"{self.transaction_sum}{settings.CURRENCY_SUFFIX} by {self.user}" class Order(models.Model): @@ -98,7 +100,7 @@ class Order(models.Model): null=True, limit_choices_to=models.Q(available__gt=0) # Query only those drinks with a availability greater than (gt) 0 ) - user = models.ForeignKey(User, on_delete=models.CASCADE) + user = CurrentUserField() datetime = models.DateTimeField(default=timezone.now) amount = models.PositiveIntegerField(default=1, editable=False) @@ -108,9 +110,10 @@ class Order(models.Model): price_sum = models.DecimalField(max_digits=6, decimal_places=2, default=0, editable=False) content_litres = models.DecimalField(max_digits=6, decimal_places=3, default=0, editable=False) + # TODO: Add more comments on how and why the save & delete functions are implemented + # address this in a refactoring issue. + def save(self, *args, **kwargs): - # saving this may affect other fields - # so we reimplement the save function drink = Drink.objects.get(pk=self.drink.pk) if self._state.adding and drink.available > 0: if not drink.do_not_count: @@ -126,7 +129,6 @@ class Order(models.Model): raise ValidationError("This entry can't be changed.") def delete(self, *args, **kwargs): - # when deleting, we affect other fields as well. self.user.balance += self.price_sum self.user.save() drink = Drink.objects.get(pk=self.drink.pk) @@ -135,7 +137,7 @@ class Order(models.Model): drink.save() super().delete(*args, **kwargs) - def __str__(self): return f"{self.drink.product_name} ({float(self.drink.content_litres):.2f}l) x {self.amount} - {self.price_sum} {settings.CURRENCY_SUFFIX}" + def __str__(self): return f"{self.drink.product_name} ({float(self.drink.content_litres):.2f}l) x {self.amount} - {self.price_sum}{settings.CURRENCY_SUFFIX}" class Global(models.Model): diff --git a/application/app/sql_queries.py b/application/app/sql_queries.py new file mode 100644 index 0000000..0448708 --- /dev/null +++ b/application/app/sql_queries.py @@ -0,0 +1,137 @@ +#from datetime import datetime + +from django.conf import settings +from django.db import connection + + +def _select_from_db(sql_select:str): + result = None + with connection.cursor() as cursor: + cursor.execute(sql_select) + result = cursor.fetchall() + return result + + +def select_history(user, language_code="en") -> list: + # select order history and deposits + user_id = user.pk + result = _select_from_db(f""" + select + concat( + product_name, ' (', + content_litres::real, -- converting to real removes trailing zeros + 'l) x ', amount, ' - ', price_sum, '{settings.CURRENCY_SUFFIX}') as "text", + datetime + from app_order + where user_id = {user_id} + + union + + select + concat('Deposit: +', transaction_sum, '{settings.CURRENCY_SUFFIX}') as "text", + datetime + from app_userdeposits_view + where user_id = {user_id} + + order by datetime desc + fetch first 30 rows only; + """) + result = [list(row) for row in result] + if language_code == "de": # reformat for german translation + for row in result: + row[0] = row[0].replace(".", ",") + return result + + +def select_yopml12m(user) -> list: + # number of orders per month (last 12 months) + # only for the specified user + user_id = user.pk + result = _select_from_db(f""" + -- select the count of the orders per month (last 12 days) + select + to_char(date_trunc('month', datetime), 'YYYY-MM') as "month", + sum(amount) as "count" + from app_order + where user_id = {user_id} + and date_trunc('month', datetime) > date_trunc('month', now() - '12 months'::interval) + group by "month" + order by "month" desc; + """) + return [list(row) for row in result] + +def select_aopml12m() -> list: + # number of orders per month (last 12 months) + result = _select_from_db(f""" + -- select the count of the orders per month (last 12 days) + select + to_char(date_trunc('month', datetime), 'YYYY-MM') as "month", + sum(amount) as "count" + from app_order + where date_trunc('month', datetime) > date_trunc('month', now() - '12 months'::interval) + group by "month" + order by "month" desc; + """) + return [list(row) for row in result] + + +def select_yopwd(user) -> list: + # number of orders per weekday (all time) + # only for the specified user + user_id = user.pk + result = _select_from_db(f""" + -- select the count of the orders per weekday (all time) + select + to_char(datetime, 'Day') as "day", + sum(amount) as "count" + from app_order + where user_id = {user_id} + group by "day" + order by "count" desc; + """) + return [list(row) for row in result] + return [] + +def select_aopwd() -> list: + # number of orders per weekday (all time) + result = _select_from_db(f""" + -- select the count of the orders per weekday (all time) + select + to_char(datetime, 'Day') as "day", + sum(amount) as "count" + from app_order + group by "day" + order by "count" desc; + """) + return [list(row) for row in result] + return [] + + +def select_noyopd(user) -> list: + # number of orders per drink (all time) + # only for specified user + user_id = user.pk + result = _select_from_db(f""" + select + d.product_name as "label", + sum(o.amount) as "data" + from app_drink d + join app_order o on (d.id = o.drink_id) + where o.user_id = {user_id} + group by d.product_name + order by "data" desc; + """) + return [list(row) for row in result] + +def select_noaopd() -> list: + # number of orders per drink (all time) + result = _select_from_db(f""" + select + d.product_name as "label", + sum(o.amount) as "data" + from app_drink d + join app_order o on (d.id = o.drink_id) + group by d.product_name + order by "data" desc; + """) + return [list(row) for row in result] \ No newline at end of file diff --git a/app/templates/admin/base_site.html b/application/app/templates/admin/base_site.html similarity index 100% rename from app/templates/admin/base_site.html rename to application/app/templates/admin/base_site.html diff --git a/app/templates/admin/index.html b/application/app/templates/admin/index.html similarity index 100% rename from app/templates/admin/index.html rename to application/app/templates/admin/index.html diff --git a/app/templates/baselayout.html b/application/app/templates/baselayout.html similarity index 67% rename from app/templates/baselayout.html rename to application/app/templates/baselayout.html index 97ac907..7d0a5e3 100644 --- a/app/templates/baselayout.html +++ b/application/app/templates/baselayout.html @@ -1,6 +1,9 @@ + {% load i18n %} + + @@ -10,27 +13,44 @@ {% block title %}{% endblock %} {% block headAdditional %}{% endblock %} + -
+ +
+ {% include "globalmessage.html" %} + {% if user.is_authenticated %} + {% include "userpanel.html" %} + {% endif %} -
+ +
+ {% if user.is_authenticated or "accounts/login/" in request.path or "accounts/logout/" in request.path or "admin/logout/" in request.path %} -
- {% block content %}{% endblock %} -
+ +
+ {% block content %}{% endblock %} +
+ {% else %} -
- {% translate "An error occured. Please log out and log in again." %} -
- log out -
+ +
+ {% translate "An error occured. Please log out and log in again." %} +
+ log out +
+ {% endif %} +
+ {% include "footer.html" %} +
+ + \ No newline at end of file diff --git a/application/app/templates/deposit.html b/application/app/templates/deposit.html new file mode 100644 index 0000000..cec4ef2 --- /dev/null +++ b/application/app/templates/deposit.html @@ -0,0 +1,40 @@ +{% extends "baselayout.html" %} + +{% load i18n %} + +{% block title %} + {% translate "Drinks - Deposit" %} +{% endblock %} + +{% block headAdditional %} + +{% endblock %} + + +{% block content %} + +
+ {% csrf_token %} + +

{% translate "Deposit" %}

+ +
+ {% translate "Amount" %} {{ currency_suffix }}: + + + +
+ +
+ + + +
+ + + + +{% endblock %} diff --git a/application/app/templates/footer.html b/application/app/templates/footer.html new file mode 100644 index 0000000..fbfe674 --- /dev/null +++ b/application/app/templates/footer.html @@ -0,0 +1,6 @@ +{% load i18n %} + + diff --git a/application/app/templates/globalmessage.html b/application/app/templates/globalmessage.html new file mode 100644 index 0000000..83fd733 --- /dev/null +++ b/application/app/templates/globalmessage.html @@ -0,0 +1,5 @@ +{% if global_message != "" %} +
+
{{ global_message }}
+
+{% endif %} \ No newline at end of file diff --git a/application/app/templates/history.html b/application/app/templates/history.html new file mode 100644 index 0000000..7abd1f3 --- /dev/null +++ b/application/app/templates/history.html @@ -0,0 +1,37 @@ +{% extends "baselayout.html" %} + +{% load i18n %} + +{% block title %} + {% translate "Drinks - History" %} +{% endblock %} + +{% block headAdditional %} + +{% endblock %} + + +{% block content %} + +

{% translate "History" %}

+ + {% if history %} + + + + + + {% for h in history %} + + + + + {% endfor %} +
{% translate "last 30 actions" %}
{{ h.0 }}{{ h.1 }}
+ {% else %} + {% translate "No history." %} + {% endif %} + + + +{% endblock %} diff --git a/application/app/templates/index.html b/application/app/templates/index.html new file mode 100644 index 0000000..3756b0b --- /dev/null +++ b/application/app/templates/index.html @@ -0,0 +1,47 @@ +{% extends "baselayout.html" %} + +{% load i18n %} + +{% block title %} + {% translate "Drinks - Home" %} +{% endblock %} + +{% block headAdditional %} + +{% endblock %} + +{% block content %} + +

{% translate "Available Drinks" %}

+ + {% if available_drinks %} + + + + {% else %} + + {% translate "No drinks available." %} + + {% endif %} + + + +{% endblock %} diff --git a/application/app/templates/order.html b/application/app/templates/order.html new file mode 100644 index 0000000..d720197 --- /dev/null +++ b/application/app/templates/order.html @@ -0,0 +1,100 @@ +{% extends "baselayout.html" %} + +{% load i18n %} +{% load l10n %} + +{% block title %} +{% translate "Drinks - Order" %} +{% endblock %} + +{% block headAdditional %} + + +{% endblock %} + + +{% block content %} + + {% if drink and drink.available > 0 and not drink.deleted %} + + {% if user.balance > 0 or user.allow_order_with_negative_balance %} + +
+ {% csrf_token %} + +

{% translate "Order" %}

+ +
+ {% translate "Drink" %}: + {{ drink.product_name }} +
+
+ {% translate "Price per Item" %} ({{ currency_suffix }}): + + {{ drink.price }} + +
+ + {% if not drink.do_not_count %} +
+ {% translate "Available" %}: + {{ drink.available }} +
+ {% endif %} + +
+ {% translate "Sum" %} ({{ currency_suffix }}): + {{ drink.price }} +
+ +
+ {% translate "Count" %}: + + + {% if drink.do_not_count %} + + {% else %} + + {% endif %} + + +
+ +
+ + + + + +
+ + + + + + {% else %} + +
+

{% translate "Your balance is too low to order a drink." %}

+ {% translate "back" %} +
+ + {% endif %} + + {% else %} + +
+

{% translate "This drink is not available." %}

+ {% translate "back" %} +
+ + {% endif %} + + + +{% endblock %} diff --git a/application/app/templates/registration/logged_out.html b/application/app/templates/registration/logged_out.html new file mode 100644 index 0000000..38bb024 --- /dev/null +++ b/application/app/templates/registration/logged_out.html @@ -0,0 +1,24 @@ + +{% extends "baselayout.html" %} + +{% load i18n %} + +{% block title %} + {% translate "Drinks - Logged Out" %} +{% endblock %} + +{% block headAdditional %} + +{% endblock %} + +{% block content %} + +
+ {% translate "Logged out! You will be redirected shortly." %} +

+ {% translate "Click here if automatic redirection does not work." %} +
+ + + +{% endblock %} diff --git a/application/app/templates/registration/login.html b/application/app/templates/registration/login.html new file mode 100644 index 0000000..4323c9e --- /dev/null +++ b/application/app/templates/registration/login.html @@ -0,0 +1,93 @@ + +{% extends "baselayout.html" %} + +{% load i18n %} +{% load static %} + +{% block title %} + {% translate "Drinks - Login" %} +{% endblock %} + +{% block headAdditional %} + +{% endblock %} + +{% block content %} + + {% if error_message %} +

{{ error_message }}

+ {% endif %} + +
+ +
+ +
+ {% csrf_token %} +

{% translate "Log in" %}

+ + + +
+ + + + + + + + + + + + + + + + + + + + + +
+
+ +
+ + +
+ +
+ +
+
+ +

{% translate "Choose your account" %}

+ +
+
    + {% for user_ in user_list %} +
  • + +
    + {% if user_.first_name %} + + {% if user_.last_name %} + {{ user_.last_name }}, + {% endif %} + + {{ user_.first_name }} + + {% else %} + {{ user_.username }} + {% endif %} +
    +
  • + {% endfor %} +
+
+ + + +{% endblock %} diff --git a/application/app/templates/statistics.html b/application/app/templates/statistics.html new file mode 100644 index 0000000..662e7d4 --- /dev/null +++ b/application/app/templates/statistics.html @@ -0,0 +1,148 @@ +{% extends "baselayout.html" %} + +{% load i18n %} + +{% block title %} + {% translate "Drinks - Statistics" %} +{% endblock %} + +{% block headAdditional %} + +{% endblock %} + + +{% block content %} + +

{% translate "Statistics" %}

+ +
+ +
+ +
+

{% translate "Your orders per drink" %}

+ {% if noyopd %} + + + + + + {% for row in noyopd %} + + + + + {% endfor %} +
{% translate "drink" %}{% translate "count" %}
{{ row.0 }}{{ row.1 }}
+ {% else %} +
{% translate "No history." %}
+ {% endif %} +
+ +
+

{% translate "All orders per drink" %}

+ {% if noaopd %} + + + + + + {% for row in noaopd %} + + + + + {% endfor %} +
{% translate "drink" %}{% translate "count" %}
{{ row.0 }}{{ row.1 }}
+ {% else %} +
{% translate "No history." %}
+ {% endif %} +
+ +
+

{% translate "Your orders per month (last 12 months)" %}

+ {% if yopml12m %} + + + + + + {% for row in yopml12m %} + + + + + {% endfor %} +
{% translate "month" %}{% translate "count" %}
{{ row.0 }}{{ row.1 }}
+ {% else %} +
{% translate "No history." %}
+ {% endif %} +
+ +
+

{% translate "All orders per month (last 12 months)" %}

+ {% if aopml12m %} + + + + + + {% for row in aopml12m %} + + + + + {% endfor %} +
{% translate "month" %}{% translate "count" %}
{{ row.0 }}{{ row.1 }}
+ {% else %} +
{% translate "No history." %}
+ {% endif %} +
+ +
+

{% translate "Your orders per weekday" %}

+ {% if yopwd %} + + + + + + {% for row in yopwd %} + + + + + {% endfor %} +
{% translate "day" %}{% translate "count" %}
{{ row.0 }}{{ row.1 }}
+ {% else %} +
{% translate "No history." %}
+ {% endif %} +
+ +
+

{% translate "All orders per weekday" %}

+ {% if aopwd %} + + + + + + {% for row in aopwd %} + + + + + {% endfor %} +
{% translate "day" %}{% translate "count" %}
{{ row.0 }}{{ row.1 }}
+ {% else %} +
{% translate "No history." %}
+ {% endif %} +
+ +
+ +
+ + + +{% endblock %} diff --git a/application/app/templates/supply.html b/application/app/templates/supply.html new file mode 100644 index 0000000..fabb224 --- /dev/null +++ b/application/app/templates/supply.html @@ -0,0 +1,62 @@ +{% extends "baselayout.html" %} + +{% load i18n %} +{% load l10n %} + +{% block title %} +{% translate "Drinks - Supply" %} +{% endblock %} + +{% block headAdditional %} + + +{% endblock %} + + +{% block content %} + + {% if user.is_superuser or user.allowed_to_supply %} + +
+ {% csrf_token %} + +

{% translate "Supply" %}

+ +
+ {% translate "Description" %}: + + + +
+ +
+ {% translate "Price" %} ({{ currency_suffix }}): + + + +
+ +
+ + + +
+ + + + + {% else %} + +
+

{% translate "You are not allowed to view this site." %}

+ {% translate "back" %} +
+ + {% endif %} + + + +{% endblock %} diff --git a/application/app/templates/userpanel.html b/application/app/templates/userpanel.html new file mode 100644 index 0000000..7a15a36 --- /dev/null +++ b/application/app/templates/userpanel.html @@ -0,0 +1,42 @@ +{% load i18n %} +{% load static %} + +
+
+ + + {% if user.first_name != "" %} + {% translate "User" %}: {{ user.first_name }} {{ user.last_name }} ({{ user.username }}) + {% else %} + {% translate "User" %}: {{ user.username }} + {% endif %} +  -  + {% if user.balance < 0.01 %} + {% translate "Balance" %}: {{ user.balance }}{{ currency_suffix }} + {% else %} + {% translate "Balance" %}: {{ user.balance }}{{ currency_suffix }} + {% endif %} + +
+
+ Home + {% translate "Deposit" %} + {% translate "Logout" %} + +
+
diff --git a/application/app/tests.py b/application/app/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/application/app/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/app/urls.py b/application/app/urls.py similarity index 91% rename from app/urls.py rename to application/app/urls.py index 1fa379d..70feadd 100644 --- a/app/urls.py +++ b/application/app/urls.py @@ -10,16 +10,16 @@ urlpatterns = [ path('history/', views.history), path('deposit/', views.deposit), path('statistics/', views.statistics), - path('transfer/', views.transfer), path('supply/', views.supply), path('accounts/login/', views.login_page, name="login"), path('accounts/logout/', auth_views.LogoutView.as_view(), name='logout'), path('accounts/password_change/', auth_views.PasswordChangeView.as_view(), name='password_change'), path('accounts/password_change_done/', views.redirect_home, name='password_change_done'), path('admin/', adminSite.urls), + # custom-handled resources + path('profilepictures', views.profile_pictures), # API # path('api/order-drink', views.api_order_drink), path('api/deposit', views.api_deposit), - path('api/transfer', views.api_transfer), path('api/supply', views.api_supply) -] +] \ No newline at end of file diff --git a/app/views.py b/application/app/views.py similarity index 67% rename from app/views.py rename to application/app/views.py index ba864a4..33e4b49 100644 --- a/app/views.py +++ b/application/app/views.py @@ -15,22 +15,34 @@ from django.http.response import HttpResponse from django.shortcuts import render from django.utils.translation import gettext as _ + from django.utils.formats import decimal -from . import db_queries +from . import sql_queries from .models import Drink from .models import Order from .models import RegisterTransaction +# + +profile_pictures_path = Path(settings.PROFILE_PICTURES).resolve() + +# login view + def login_page(request): - userlist = get_user_model().objects.filter(hide_from_userlist=False).filter(is_active=True).order_by("username") + + userlist = get_user_model().objects.filter(is_superuser=False).filter(is_active=True).order_by("username") + if request.method == "POST": + form = AuthenticationForm(request.POST) username = request.POST['username'] password = request.POST['password'] + user = authenticate(username=username,password=password) + if user: if user.is_active: login(request, user) @@ -41,15 +53,22 @@ def login_page(request): "user_list": userlist, "error_message": _("Invalid username or password.") }) + else: + if request.user.is_authenticated: return HttpResponseRedirect("/") + form = AuthenticationForm() + return render(request,'registration/login.html', { "form": form, "user_list": userlist }) + +# actual application + @login_required def index(request): context = { @@ -60,7 +79,7 @@ def index(request): @login_required def history(request): context = { - "history": db_queries.select_history(request.user, language_code=request.LANGUAGE_CODE), + "history": sql_queries.select_history(request.user, language_code=request.LANGUAGE_CODE), } return render(request, "history.html", context) @@ -68,7 +87,9 @@ def history(request): def order(request, drinkid): try: drink_ = Drink.objects.get(pk=drinkid) - context = {"drink": drink_} + context = { + "drink": drink_ + } return render(request, "order.html", context) except Drink.DoesNotExist: return HttpResponseRedirect("/") @@ -77,27 +98,18 @@ def order(request, drinkid): def deposit(request): return render(request, "deposit.html", {}) - @login_required def statistics(request): - user = request.user context = { - "orders_per_month": db_queries.select_orders_per_month(user), - "orders_per_weekday": db_queries.select_orders_per_weekday(user), - "orders_per_drink": db_queries.select_orders_per_drink(user), + "yopml12m": sql_queries.select_yopml12m(request.user), + "aopml12m": sql_queries.select_aopml12m(), + "yopwd": sql_queries.select_yopwd(request.user), + "aopwd": sql_queries.select_aopwd(), + "noyopd": sql_queries.select_noyopd(request.user), + "noaopd": sql_queries.select_noaopd() } - # Advanced statistics - if user.has_perm("app.view_order") or user.is_superuser: - context["order_sum_per_user"] = db_queries.select_order_sum_per_user_all_users() - if user.has_perm("app.view_registertransaction") or user.is_superuser: - context["deposit_sum_per_user"] = db_queries.select_deposit_sum_per_user_all_users() return render(request, "statistics.html", context) -@login_required -def transfer(request): - userlist = get_user_model().objects.filter(hide_from_userlist=False).filter(is_active=True).order_by("username") - return render(request, "transfer.html", {"user_list": userlist}) - @login_required def supply(request): return render(request, "supply.html") @@ -106,80 +118,93 @@ def supply(request): def redirect_home(request): return HttpResponseRedirect("/") + +# Custom-Handled Resources + +def profile_pictures(request): + if not "name" in request.GET: + return HttpResponse(b"", status=400) + ppic_filepath = Path(profile_pictures_path / request.GET["name"]).resolve() + try: + ppic_filepath.relative_to(profile_pictures_path) + except: + return HttpResponse("No.", status=403) + if ppic_filepath.is_file(): + return FileResponse(ppic_filepath.open('rb')) + else: + return FileResponse(b"", status=404) + + # API for XHR requests # @login_required def api_order_drink(request): + # check request -> make order + user = request.user + try: + if user.allow_order_with_negative_balance or user.balance > 0: + drinkid = int(request.POST["drinkid"]) amount = int(request.POST["numberofdrinks"]) + drink = Drink.objects.get(pk=drinkid) + if ((drink.do_not_count and drink.available > 0) or (drink.available >= amount)) and not drink.deleted: Order.objects.create(drink=drink, user=user, amount=amount) return HttpResponse("success", status=200) else: return HttpResponse("notAvailable", status=400) + else: raise Exception("Unexpected input or missing privileges.") + except Exception as e: print(f"An exception occured while processing an order: User: {user.username} - Exception: {e}", file=sys.stderr) return HttpResponse(b"", status=500) + @login_required def api_deposit(request): + # check request -> deposit + user = request.user + try: + amount = decimal.Decimal(request.POST["depositamount"]) + if 0.00 < amount < 9999.99: # create transaction RegisterTransaction.objects.create( transaction_sum=amount, comment=f"User deposit by user {user.username}", is_user_deposit=True, - user=user) + user=user + ) + # return HttpResponse("success", status=200) else: raise Exception("Deposit amount too big or small.") + except Exception as e: - print(f"An exception occured while processing a deposit transaction: User: {user.username} - Exception: {e}", file=sys.stderr) - return HttpResponse(b"", status=500) - -@login_required -def api_transfer(request): - # check request -> transfer - user = request.user - try: - recipient = get_user_model().objects.get(id=int(request.POST["recipientuser"])) - if recipient.id == user.id: - raise Exception(f"User {user.username} tried to transfer to themself.") - amount = decimal.Decimal(request.POST["transferamount"]) - if 0.00 < amount <= user.balance: - # create transaction - RegisterTransaction.objects.create( - transaction_sum=-amount, - comment=f"Transfer to {recipient.username}", - is_transfer=True, - user=user) - RegisterTransaction.objects.create( - transaction_sum=amount, - comment=f"Transfer from {user.username}", - is_transfer=True, - user=recipient) - return HttpResponse("success", status=200) - else: raise Exception("Transfer amount too big or small.") - except Exception as e: - print(f"An exception occured while processing a transfer transaction: User: {user.username} - Exception: {e}", file=sys.stderr) + print(f"An exception occured while processing a transaction: User: {user.username} - Exception: {e}", file=sys.stderr) return HttpResponse(b"", status=500) @login_required def api_supply(request): + # check request -> supply + user = request.user + try: + price = decimal.Decimal(request.POST["supplyprice"]) description = str(request.POST["supplydescription"]) + if 0.00 < price < 9999.99 and (user.allowed_to_supply or user.is_superuser): # create transaction RegisterTransaction.objects.create( @@ -188,8 +213,10 @@ def api_supply(request): is_user_deposit=False, user=user ) + # return HttpResponse("success", status=200) else: raise Exception("Unexpected input or missing privileges.") + except Exception as e: print(f"An exception occured while processing a supply transaction: User: {user.username} - Exception: {e}", file=sys.stderr) return HttpResponse(b"", status=500) diff --git a/app/migrations/__init__.py b/application/drinks_manager/__init__.py similarity index 100% rename from app/migrations/__init__.py rename to application/drinks_manager/__init__.py diff --git a/project/asgi.py b/application/drinks_manager/asgi.py similarity index 56% rename from project/asgi.py rename to application/drinks_manager/asgi.py index 486c89b..dcfe5ee 100644 --- a/project/asgi.py +++ b/application/drinks_manager/asgi.py @@ -1,16 +1,16 @@ """ -ASGI config for project project. +ASGI config for drinks_manager project. It exposes the ASGI callable as a module-level variable named ``application``. For more information on this file, see -https://docs.djangoproject.com/en/4.1/howto/deployment/asgi/ +https://docs.djangoproject.com/en/3.2/howto/deployment/asgi/ """ import os from django.core.asgi import get_asgi_application -os.environ.setdefault("DJANGO_SETTINGS_MODULE", "project.settings") +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'drinks_manager.settings') application = get_asgi_application() diff --git a/application/drinks_manager/settings.py b/application/drinks_manager/settings.py new file mode 100644 index 0000000..d01503f --- /dev/null +++ b/application/drinks_manager/settings.py @@ -0,0 +1,179 @@ +""" +Django settings for drinks_manager project. + +Generated by 'django-admin startproject' using Django 3.2.5. + +For more information on this file, see +https://docs.djangoproject.com/en/3.2/topics/settings/ + +For the full list of settings and their values, see +https://docs.djangoproject.com/en/3.2/ref/settings/ +""" + +import os + +from pathlib import Path + + +# Build paths inside the project like this: BASE_DIR / 'subdir'. +BASE_DIR = Path(__file__).resolve().parent.parent + + +# Quick-start development settings - unsuitable for production +# See https://docs.djangoproject.com/en/3.2/howto/deployment/checklist/ + +# SECURITY WARNING: keep the secret key secret! +django_secret_key_absolute_fp = os.environ["DJANGO_SK_ABS_FP"] +with open(django_secret_key_absolute_fp) as secret_key_file: + SECRET_KEY = secret_key_file.read().strip() + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = (os.environ["DJANGO_DEBUG"].lower() == "true") + + +ALLOWED_HOSTS = [ + "*" +] + + +### ----------------- ### + + +# Application definition + +INSTALLED_APPS = [ + "app.apps.DAppConfig", + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', +] + +MIDDLEWARE = [ + 'django.middleware.security.SecurityMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + "django.middleware.locale.LocaleMiddleware", + 'django.middleware.common.CommonMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', + 'django.middleware.clickjacking.XFrameOptionsMiddleware', + "django_currentuser.middleware.ThreadLocalUserMiddleware", + "app.middleware.csp_middleware" +] + +ROOT_URLCONF = 'drinks_manager.urls' + +TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [], + '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', + "app.context_processors.app_version" + ], + }, + }, +] + +WSGI_APPLICATION = 'drinks_manager.wsgi.application' + + +# Database +# https://docs.djangoproject.com/en/3.2/ref/settings/#databases + +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.postgresql_psycopg2', + 'NAME': os.environ["PGDB_DB"], + 'USER': os.environ["PGDB_USER"], + 'PASSWORD': os.environ["PGDB_PASSWORD"], + 'HOST': os.environ["PGDB_HOST"], + 'PORT': str(os.environ["PGDB_PORT"]) + } +} + +CONN_MAX_AGE = 20 # keep database connections alive for n seconds + + +# Password validation +# https://docs.djangoproject.com/en/3.2/ref/settings/#auth-password-validators + +if os.environ["DJANGO_ENABLE_PASSWORD_VALIDATION"].lower() == "true": + 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', + }, + ] +else: + AUTH_PASSWORD_VALIDATORS = [] + + +AUTH_USER_MODEL = "app.User" + +# user will be logged out after x seconds +SESSION_COOKIE_AGE = int(os.environ["DJANGO_SESSION_COOKIE_AGE"]) + + +# more security settings + +CSRF_COOKIE_SECURE = True +SESSION_COOKIE_SECURE = True + + +# Internationalization +# https://docs.djangoproject.com/en/3.2/topics/i18n/ + +LANGUAGE_CODE = os.environ["DJANGO_LANGUAGE_CODE"] # this is the default and fallback language (currently only de-de and en-us supported) + +TIME_ZONE = os.environ["DJANGO_TIME_ZONE"] + +USE_I18N = True + +USE_L10N = True + +USE_TZ = True + +LOCALE_PATHS = [ + BASE_DIR / "locale" +] + + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/3.2/howto/static-files/ + +STATIC_URL = '/static/' +STATIC_ROOT = os.environ["STATIC_FILES"] + + +# Default primary key field type +# https://docs.djangoproject.com/en/3.2/ref/settings/#default-auto-field + +DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' + +# + +APP_VERSION = os.environ["APP_VERSION"] + +try: + CURRENCY_SUFFIX = os.environ["CURRENCY_SUFFIX"] +except KeyError: + CURRENCY_SUFFIX = "$" + +PROFILE_PICTURES = os.environ["PROFILE_PICTURES"] diff --git a/project/urls.py b/application/drinks_manager/urls.py similarity index 86% rename from project/urls.py rename to application/drinks_manager/urls.py index 77d62fb..5bf5958 100644 --- a/project/urls.py +++ b/application/drinks_manager/urls.py @@ -1,7 +1,7 @@ -"""project URL Configuration +"""drinks_manager URL Configuration The `urlpatterns` list routes URLs to views. For more information please see: - https://docs.djangoproject.com/en/4.1/topics/http/urls/ + https://docs.djangoproject.com/en/3.2/topics/http/urls/ Examples: Function views 1. Add an import: from my_app import views @@ -13,8 +13,9 @@ 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.urls import path, include urlpatterns = [ path('', include("app.urls")) -] +] \ No newline at end of file diff --git a/project/wsgi.py b/application/drinks_manager/wsgi.py similarity index 56% rename from project/wsgi.py rename to application/drinks_manager/wsgi.py index b5da491..b42c9aa 100644 --- a/project/wsgi.py +++ b/application/drinks_manager/wsgi.py @@ -1,16 +1,16 @@ """ -WSGI config for project project. +WSGI config for drinks_manager 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/4.1/howto/deployment/wsgi/ +https://docs.djangoproject.com/en/3.2/howto/deployment/wsgi/ """ import os from django.core.wsgi import get_wsgi_application -os.environ.setdefault("DJANGO_SETTINGS_MODULE", "project.settings") +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'drinks_manager.settings') application = get_wsgi_application() diff --git a/application/locale/de/LC_MESSAGES/django.mo b/application/locale/de/LC_MESSAGES/django.mo new file mode 100644 index 0000000..1f4683e Binary files /dev/null and b/application/locale/de/LC_MESSAGES/django.mo differ diff --git a/application/locale/de/LC_MESSAGES/django.po b/application/locale/de/LC_MESSAGES/django.po new file mode 100644 index 0000000..efc8657 --- /dev/null +++ b/application/locale/de/LC_MESSAGES/django.po @@ -0,0 +1,282 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2022-10-15 19:20+0200\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: Julian Müller (W13R)\n" +"Language: DE\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#: application/app/templates/admin/base_site.html:7 +msgid "Django site admin" +msgstr "Django Administrator" + +#: application/app/templates/admin/base_site.html:15 +msgid "Django administration" +msgstr "Django Administration" + +#: application/app/templates/baselayout.html:41 +msgid "An error occured. Please log out and log in again." +msgstr "Ein Fehler ist aufgetreten. Bitte ab- und wieder anmelden." + +#: application/app/templates/deposit.html:6 +msgid "Drinks - Deposit" +msgstr "Getränke - Einzahlen" + +#: application/app/templates/deposit.html:14 +#: application/app/templates/userpanel.html:23 +msgid "Deposit" +msgstr "Einzahlen" + +#: application/app/templates/deposit.html:23 +msgid "Amount" +msgstr "Summe" + +#: application/app/templates/deposit.html:31 +#: application/app/templates/order.html:72 +#: application/app/templates/registration/login.html:57 +#: application/app/templates/supply.html:41 +msgid "cancel" +msgstr "Abbrechen" + +#: application/app/templates/deposit.html:32 +msgid "confirm" +msgstr "Bestätigen" + +#: application/app/templates/history.html:6 +msgid "Drinks - History" +msgstr "Getränke - Verlauf" + +#: application/app/templates/history.html:14 +#: application/app/templates/userpanel.html:30 +msgid "History" +msgstr "Verlauf" + +#: application/app/templates/history.html:22 +msgid "last 30 actions" +msgstr "letzte 30 Vorgänge" + +#: application/app/templates/history.html:33 +#: application/app/templates/statistics.html:41 +#: application/app/templates/statistics.html:61 +#: application/app/templates/statistics.html:81 +#: application/app/templates/statistics.html:101 +#: application/app/templates/statistics.html:121 +#: application/app/templates/statistics.html:141 +msgid "No history." +msgstr "Kein Verlauf verfügbar." + +#: application/app/templates/index.html:6 +msgid "Drinks - Home" +msgstr "Getränke - Home" + +#: application/app/templates/index.html:14 +msgid "Available Drinks" +msgstr "Verfügbare Getränke" + +#: application/app/templates/index.html:27 +#: application/app/templates/index.html:34 +msgid "available" +msgstr "verfügbar" + +#: application/app/templates/index.html:43 +msgid "No drinks available." +msgstr "Es sind gerade keine Getränke verfügbar." + +#: application/app/templates/order.html:7 +msgid "Drinks - Order" +msgstr "Getränke - Bestellen" + +#: application/app/templates/order.html:16 +#: packages/django/forms/formsets.py:405 packages/django/forms/formsets.py:412 +msgid "Order" +msgstr "Bestellung" + +#: application/app/templates/order.html:29 +msgid "Drink" +msgstr "Getränk" + +#: application/app/templates/order.html:34 +msgid "Price per Item" +msgstr "Preis pro Getränk" + +#: application/app/templates/order.html:40 +msgid "Available" +msgstr "Verfügbar" + +#: application/app/templates/order.html:46 +msgid "Count" +msgstr "Anzahl" + +#: application/app/templates/order.html:63 +msgid "Sum" +msgstr "Summe" + +#: application/app/templates/order.html:73 +msgid "order" +msgstr "Bestellen" + +#: application/app/templates/order.html:85 +msgid "Your balance is too low to order a drink." +msgstr "Dein Saldo ist zu niedrig um Getränke zu bestellen." + +#: application/app/templates/order.html:86 +#: application/app/templates/order.html:95 +#: application/app/templates/supply.html:54 +msgid "back" +msgstr "zurück" + +#: application/app/templates/order.html:94 +msgid "This drink is not available." +msgstr "Dieses Getränk ist gerade nicht verfügbar." + +#: application/app/templates/registration/logged_out.html:7 +msgid "Drinks - Logged Out" +msgstr "Getränke - Abgemeldet" + +#: application/app/templates/registration/logged_out.html:17 +msgid "Logged out! You will be redirected shortly." +msgstr "Du wurdest abgemeldet und wirst in Kürze weitergeleitet." + +#: application/app/templates/registration/logged_out.html:19 +msgid "Click here if automatic redirection does not work." +msgstr "" +"Bitte klicke hier, wenn die automatische Weiterleitung nicht funktioniert." + +#: application/app/templates/registration/login.html:8 +msgid "Drinks - Login" +msgstr "Getränke - Anmeldung" + +#: application/app/templates/registration/login.html:27 +msgid "Log in" +msgstr "Anmelden" + +#: application/app/templates/registration/login.html:29 +msgid "Password/PIN" +msgstr "Passwort/PIN" + +#: application/app/templates/registration/login.html:58 +msgid "login" +msgstr "Anmelden" + +#: application/app/templates/registration/login.html:66 +msgid "Choose your account" +msgstr "Wähle deinen Account" + +#: application/app/templates/statistics.html:6 +msgid "Drinks - Statistics" +msgstr "Getränke - Statistiken" + +#: application/app/templates/statistics.html:15 +#: application/app/templates/userpanel.html:31 +msgid "Statistics" +msgstr "Statistiken" + +#: application/app/templates/statistics.html:26 +msgid "Your orders per drink" +msgstr "Deine Bestellungen pro Getränk" + +#: application/app/templates/statistics.html:30 +#: application/app/templates/statistics.html:50 +msgid "drink" +msgstr "Getränk" + +#: application/app/templates/statistics.html:31 +#: application/app/templates/statistics.html:51 +#: application/app/templates/statistics.html:71 +#: application/app/templates/statistics.html:91 +#: application/app/templates/statistics.html:111 +#: application/app/templates/statistics.html:131 +msgid "count" +msgstr "Anzahl" + +#: application/app/templates/statistics.html:46 +msgid "All orders per drink" +msgstr "Alle Bestellungen pro Getränk" + +#: application/app/templates/statistics.html:66 +msgid "Your orders per month (last 12 months)" +msgstr "Deine Bestellungen pro Monat (letzte 12 Monate)" + +#: application/app/templates/statistics.html:70 +#: application/app/templates/statistics.html:90 +msgid "month" +msgstr "Monat" + +#: application/app/templates/statistics.html:86 +msgid "All orders per month (last 12 months)" +msgstr "Alle Bestellungen pro Monat (letzte 12 Monate)" + +#: application/app/templates/statistics.html:106 +msgid "Your orders per weekday" +msgstr "Deine Bestellungen pro Wochentag" + +#: application/app/templates/statistics.html:110 +#: application/app/templates/statistics.html:130 +msgid "day" +msgstr "Tag" + +#: application/app/templates/statistics.html:126 +msgid "All orders per weekday" +msgstr "Alle Bestellungen pro Wochentag" + +#: application/app/templates/supply.html:7 +msgid "Drinks - Supply" +msgstr "Getränke - Beschaffung" + +#: application/app/templates/supply.html:16 +#: application/app/templates/userpanel.html:36 +msgid "Supply" +msgstr "Beschaffung" + +#: application/app/templates/supply.html:27 +msgid "Description" +msgstr "Beschreibung" + +#: application/app/templates/supply.html:32 +msgid "Price" +msgstr "Preis" + +#: application/app/templates/supply.html:42 +msgid "submit" +msgstr "Senden" + +#: application/app/templates/supply.html:53 +msgid "You are not allowed to view this site." +msgstr "Dir fehlt die Berechtigung, diese Seite anzuzeigen." + +#: application/app/templates/userpanel.html:9 +#: application/app/templates/userpanel.html:11 +msgid "User" +msgstr "Benutzer" + +#: application/app/templates/userpanel.html:15 +#: application/app/templates/userpanel.html:17 +msgid "Balance" +msgstr "Saldo" + +#: application/app/templates/userpanel.html:24 +msgid "Logout" +msgstr "Abmelden" + +#: application/app/templates/userpanel.html:27 +msgid "Account" +msgstr "Account" + +#: application/app/templates/userpanel.html:38 +msgid "Change Password" +msgstr "Passwort ändern" + +#: application/app/views.py:47 +msgid "Invalid username or password." +msgstr "Benutzername oder Passwort ungültig." diff --git a/manage.py b/application/manage.py similarity index 80% rename from manage.py rename to application/manage.py index 5481674..dce56eb 100755 --- a/manage.py +++ b/application/manage.py @@ -1,4 +1,4 @@ -#!./venv/bin/python3 +#!/usr/bin/env python3 """Django's command-line utility for administrative tasks.""" import os import sys @@ -6,7 +6,7 @@ import sys def main(): """Run administrative tasks.""" - os.environ.setdefault("DJANGO_SETTINGS_MODULE", "project.settings") + os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'drinks_manager.settings') try: from django.core.management import execute_from_command_line except ImportError as exc: @@ -18,5 +18,5 @@ def main(): execute_from_command_line(sys.argv) -if __name__ == "__main__": +if __name__ == '__main__': main() diff --git a/data/archive/.gitkeep b/archive/.gitkeep similarity index 100% rename from data/archive/.gitkeep rename to archive/.gitkeep diff --git a/config/Caddyfile b/config/Caddyfile new file mode 100644 index 0000000..e5fe817 --- /dev/null +++ b/config/Caddyfile @@ -0,0 +1,39 @@ +{ + # disable admin backend + admin off + # define the ports by the environment variables + http_port {$HTTP_PORT} + https_port {$HTTPS_PORT} +} + +https:// { + # the tls certificates + tls ./config/tls/server.pem ./config/tls/server-key.pem + route { + # static files + file_server /static/* { + root {$STATIC_FILES}/.. + } + # favicon + redir /favicon.ico /static/favicon.ico + # reverse proxy to the (django) application + reverse_proxy localhost:{$DJANGO_PORT} + } + # use compression + encode gzip + # logging + log { + output file {$CADDY_ACCESS_LOG} + format filter { + wrap console + fields { + common_log delete + request>headers delete + request>tls delete + user_id delete + resp_headers delete + } + } + level INFO + } +} diff --git a/config/config.sample.sh b/config/config.sample.sh new file mode 100644 index 0000000..51aa8f0 --- /dev/null +++ b/config/config.sample.sh @@ -0,0 +1,31 @@ +# environment variables + +export HTTP_PORT=80 # required by caddy, will be redirected to https +export HTTPS_PORT=443 # actual port for the webinterface + +export DJANGO_PORT=8001 # caddy's http port (should be blocked by the firewall) + +export DJANGO_SESSION_COOKIE_AGE=600 # auto-logout, in seconds +export SESSION_CLEAR_INTERVAL=120 # interval for automatic session clearing, in minutes + +export DJANGO_LANGUAGE_CODE="en" # the default and fallback language, currently only de and en are supported. +export DJANGO_TIME_ZONE="CET" # your timezone + +export CURRENCY_SUFFIX="$" # if you have another currency symbol, you can specify it here + +# Do you want to enable password validation? +# (numeric PINs as Password will not be seen as valid) +export DJANGO_ENABLE_PASSWORD_VALIDATION="true" + +# database connection (postgresql) +export PGDB_DB="" # The name of the database +export PGDB_USER="" # The database user +export PGDB_PASSWORD='' # The password for the database user +export PGDB_HOST="127.0.0.1" # The hostname of your database +export PGDB_PORT=5432 # The port your database is listening on + +# log files +# only change if you know what you are doing +export CADDY_ACCESS_LOG="$(pwd)/logs/http-access.log" +export CADDY_LOG="$(pwd)/logs/caddy.log" +export APPLICATION_LOG="$(pwd)/logs/application.log" diff --git a/data/logs/.gitkeep b/config/tls/.gitkeep similarity index 100% rename from data/logs/.gitkeep rename to config/tls/.gitkeep diff --git a/data/Caddyfile b/data/Caddyfile deleted file mode 100644 index d790c4e..0000000 --- a/data/Caddyfile +++ /dev/null @@ -1,54 +0,0 @@ -{ - # disable unwanted stuff - admin off - skip_install_trust - # define the ports by the environment variables - http_port {$HTTP_PORT} - https_port {$HTTPS_PORT} -} - -{$CADDY_HOSTS} { - # the tls certificates - # tls {$DATADIR}/tls/server.pem {$DATADIR}/tls/server-key.pem - tls internal - route { - # profile pictures - file_server /profilepictures/* { - root {$DATADIR}/profilepictures/.. - } - # static files - file_server /static/* { - root {$ROOTDIR} - } - # django static files - file_server /django_static/* { - root {$DATADIR}/django_static/.. - } - # favicon - redir /favicon.ico /static/favicon.ico - # reverse proxy to the (django) application - reverse_proxy localhost:{$APPLICATION_PORT} - # set additional security headers - header Content-Security-Policy "default-src 'self'" - } - # use compression - encode gzip - # logging - log { - output file {$ACCESS_LOG} - format filter { - wrap json { - time_format rfc3339 - } - fields { - request>headers delete - request>tls delete - request>remote_ip hash - request>remote_port delete - user_id delete - resp_headers delete - } - } - level INFO - } -} diff --git a/data/config.example.yml b/data/config.example.yml deleted file mode 100644 index 0586bcf..0000000 --- a/data/config.example.yml +++ /dev/null @@ -1,40 +0,0 @@ ---- -app: - # The secret key, used for security protections - # This MUST be a secret, very long (40+ characters), random string - secret_key: "!!!insert random data!!!" - # The port for the asgi application - # This should be blocked by the firewall - application_port: 8001 - # Used for auto-logout, in seconds - session_cookie_age: 600 - # Interval for automatic session clearing, in minutes - session_clear_interval: 120 - # The default and fallback language, currently only de and en are supported. - language_code: "en" - # Your timezone - timezone: "CET" - # Specify the suffix for your currency - currency_suffix: "$" - # Enable/Disable password validation - # (numeric PINs are NOT valid when this is set to true) - password_validation: true -db: - # Database configuration - database: "drinks" - user: "drinks" - password: "insert password" - host: "127.0.0.1" - port: 5432 -caddy: - # Webserver settings - hosts: - - "localhost" - - "127.0.0.1" - http_port: 80 - https_port: 443 -logs: - # Logfile paths - caddy: "./data/logs/caddy.log" - http_access: "./data/logs/http-access.log" - application: "./data/logs/application.log" diff --git a/data/profilepictures/default.svg b/data/profilepictures/default.svg deleted file mode 100644 index edc6946..0000000 --- a/data/profilepictures/default.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/docs/Commands.md b/docs/Commands.md new file mode 100644 index 0000000..b47bbc3 --- /dev/null +++ b/docs/Commands.md @@ -0,0 +1,81 @@ +# Commands + +You run a command with + +``` +./run.sh +``` + +## Available Commands + +--- + +### `server` +This starts the application (a caddy instance, uvicorn with the Django application and a scheduler that automatically removes expired session data). +Log files will be written. + +--- + +### `setup` +This sets up some database tables, views, and more, generates a secret key for the application and lets you create an admin user. + +--- + +### `create-admin` +Lets you create an admin user + +--- + +### `generate-secret-key` +Generate a new random secret key for Django. +This will overwrite the old one. +Warning: After running this, current sessions will be invalid, and the users have to relogin. Don't run this command while the server is running. + +--- + +### `clear-sessions` +manually remove all expired sessions from the database + +--- + +### `force-db-upgrade` +force a database migration and -upgrade. +This is mainly used in development. + +--- + +### `archive-tables` +archive (copy & delete) all entries in app_order and app_registertransaction. +Use this to archive old orders or transactions (e.g. when the database gets too big). + +--- + +### `development-server` +This starts a caddy instance, the Django development server with DEBUGGING enabled and the session-clear-scheduler. +Only the HTTP-Access-Log will be written to its logfile, other logs will be written to the console. + +--- + +### `shell` + +Start a Django shell. + +--- + +### `help` +Show a help text + +--- + + +## Examples + +Run the production server: +``` +./run.sh server +``` + +Create a new admin: +``` +./run.sh create-admin +``` \ No newline at end of file diff --git a/docs/Configuration.md b/docs/Configuration.md new file mode 100644 index 0000000..00d5933 --- /dev/null +++ b/docs/Configuration.md @@ -0,0 +1,14 @@ +# Configuration + +## Main Configuration + +`config/config.sh` + +There is a sample configuration with explanations: [/config/config.sample.sh](/config/config.sample.sh) + + +## Caddy (Reverse Proxy & Static File Server) + +[config/Caddyfile](/config/Caddyfile) + +The default configuration should work out of the box, don't edit this file unless you know what you're doing. diff --git a/docs/Setup.md b/docs/Setup.md new file mode 100644 index 0000000..bf3d0c4 --- /dev/null +++ b/docs/Setup.md @@ -0,0 +1,110 @@ +# Setup + +## I. Dependencies + +Before the actual setup, you have to satisfy the following dependencies: + + +### System + +- `pg_config` + - Ubuntu: `libpq-dev` + - Fedora/RHEL: `libpq-devel` +- `Caddy` 2.4.3+ (HTTP Reverse Proxy & Static File Server) +- `gcc`, `gettext` +- `Python` 3.9+ with pip + - `Python` header files + - Fedora/RHEL: `python3-devel` + - Ubuntu: `python3-dev` + + +### Python Packages (pip) + +All required python packages are listed in [requirements.txt](/requirements.txt) + +You can install the required python packages with +```bash +./install-pip-dependencies.sh +``` + +## II.A Installation + +You can get the latest version with git: + +``` +git clone --branch release-x https://gitlab.com/W13R/drinks-manager.git +``` +(replace x with the latest version) + +Alternatively, you can download the [latest release](https://gitlab.com/W13R/drinks-manager/-/releases) and extract the files to your prefered destination. + +**Warning:** + +Make shure that you set the correct file permissions, especially for the config files !! + +The following should be sufficient: + +```bash +chmod -R u+rw,g+r,g-w,o-rwx +``` + + +## II.B Update + +If you installed the application with git, you can run the following in the drinks-manager directory to update to the new version: + +``` +git fetch +git checkout x +``` +(replace x with the new version) + +If you downloaded the application from the releases page, you can download the new release in the same manner, and overwrite the old files with the new ones. + +You have to restart the application server to apply the changes. +WARNING: The auto-upgrade mechanism may expect you to input information. Therefore, you should start the application from the command-line the first time after an update. + +Further upgrading-instructions may be provided in the Release Notes on the Releases Page of this Project (Deployments -> Releases). + + +## III. Database + +This project is using PostgreSQL. You have to set up a database: + +```sql +create user drinksmanager password ''; +create database drinksmgr owner drinksmanager; +``` + +After creating the user, you have to edit your `pg_hba.conf` (see https://www.postgresql.org/docs/current/auth-pg-hba-conf.html). +Add the following line: +``` +host drinksmgr drinksmanager 127.0.0.1/32 md5 +``` + +Now you can configure your database connection in `config/config.sh`. + + +## IV. HTTPS & TLS Certificates + +A TLS/SSL certificate and key is required. +Filepaths: + +- `config/tls/server.pem` for the certificate +- `config/tls/server-key.pem` for the key + +You can set another filepath for those files in your caddy configuration at [config/Caddyfile](/config/Caddyfile). + + +## V. Configuration + +see [Configuration](Configuration.md) + + +## VI. Run Setup Command + +run `./run.sh setup` + +This will automatically set up database tables, views and entries, set up Django and let you create a admin user. + +After this, start the server with `./run.sh server` and navigate to `https://your.ip.add.ress:port/admin/`. diff --git a/install-pip-dependencies.sh b/install-pip-dependencies.sh new file mode 100755 index 0000000..6c02848 --- /dev/null +++ b/install-pip-dependencies.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash + +# install the required python packages + +wd=$(dirname $0) + +pip3 install -r "$wd/requirements.txt" -t "$wd/packages" diff --git a/lib/activate-devel-env.sh b/lib/activate-devel-env.sh new file mode 100755 index 0000000..3467721 --- /dev/null +++ b/lib/activate-devel-env.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash + +source ./lib/env.sh +source ./config/config.sh +export DJANGO_DEBUG=true +export PYTHONPATH="./packages" diff --git a/lib/archive-tables.py b/lib/archive-tables.py new file mode 100644 index 0000000..e698e73 --- /dev/null +++ b/lib/archive-tables.py @@ -0,0 +1,75 @@ +#!/usr/bin/env python3 + +import os, sys + +from datetime import datetime +from pathlib import Path + +from psycopg2 import connect + + +# archive (copy & delete) all entries in app_order and app_registertransaction + +timestamp = datetime.now().strftime("%Y-%m-%d-%H%M%S") + +archive_folder = Path("./archive") +orders_archive_path = archive_folder / ("orders-archive-" + timestamp + ".csv") +transactions_archive_path = archive_folder / ("transactions-archive-" + timestamp + ".csv") + + +if __name__ == "__main__": + + exit_code = 0 + + try: + + print(f"Starting archiving to {orders_archive_path.__str__()} and {transactions_archive_path.__str__()}...") + + connection = connect( + user = os.environ["PGDB_USER"], + password = os.environ["PGDB_PASSWORD"], + host = os.environ["PGDB_HOST"], + port = os.environ["PGDB_PORT"], + database = os.environ["PGDB_DB"] + ) + + cur = connection.cursor() + + + # # # # # + + # copy + + with orders_archive_path.open("w") as of: + cur.copy_expert( + "copy (select * from app_order) to STDOUT with csv delimiter ';'", + of + ) + + with transactions_archive_path.open("w") as tf: + cur.copy_expert( + "copy (select * from app_registertransaction) to STDOUT with csv delimiter ';'", + tf + ) + + # delete + + cur.execute("delete from app_order;") + cur.execute("delete from app_registertransaction;") + connection.commit() + + # # # # # + + print("done.") + + except (Error, Exception) as err: + + connection.rollback() + print(f"An error occured while upgrading the database at {os.environ['PGDB_HOST']}:\n{err}") + exit_code = 1 + + finally: + + cur.close() + connection.close() + exit(exit_code) diff --git a/lib/auto-upgrade-db.sh b/lib/auto-upgrade-db.sh new file mode 100644 index 0000000..beadb59 --- /dev/null +++ b/lib/auto-upgrade-db.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash + + +echo -e "Checking if database needs an upgrade..." + +if python3 $(pwd)/lib/verify-db-app-version.py; then + + echo -e "No database upgrade needed." + +else + + echo -e "Starting automatic database upgrade..." + source "$(pwd)/lib/db-migrations.sh" + python3 $(pwd)/lib/upgrade-db.py + +fi diff --git a/lib/bootstrap.py b/lib/bootstrap.py new file mode 100644 index 0000000..23f0d34 --- /dev/null +++ b/lib/bootstrap.py @@ -0,0 +1,124 @@ +#!/usr/bin/env python3 + + +from os import environ +from os import getcwd +from shlex import quote +from signal import SIGINT +from subprocess import run +from subprocess import Popen +from sys import argv +from sys import stdout +from sys import stderr + + +# some vars +devel = False +caddy_process = None +scs_process = None +app_process = None + + +def stop(): + print("\n\nStopping services.\n\n") + caddy_process.send_signal(SIGINT) + scs_process.send_signal(SIGINT) + app_process.send_signal(SIGINT) + print(f"Caddy stopped with exit code {caddy_process.wait()}.") + print(f"session-clear-scheduler stopped with exit code {scs_process.wait()}.") + if devel: + print(f"Django stopped with exit code {app_process.wait()}.") + else: + print(f"Django/Uvicorn stopped with exit code {app_process.wait()}.") + if caddy_process.returncode != 0 or scs_process.returncode != 0 or app_process.returncode !=0: + exit(1) + else: + exit(0) + + +if __name__ == "__main__": + # development or production environment? + try: + if argv[1] == "devel": + devel = True + except IndexError: + pass + # vars + pwd = getcwd() + APPLICATION_LOG = environ["APPLICATION_LOG"] + CADDY_ACCESS_LOG = environ["CADDY_ACCESS_LOG"] + CADDY_LOG = environ["CADDY_LOG"] + DJANGO_PORT = environ["DJANGO_PORT"] + HTTPS_PORT = environ["HTTPS_PORT"] + if devel: + environ["DJANGO_DEBUG"] = "true" + else: + environ["DJANGO_DEBUG"] = "false" + # info + print(f"\n\nStarting server on port {HTTPS_PORT}...\nYou should be able to access the application locally at https://127.0.0.1:{HTTPS_PORT}/\n\nPress Ctrl+C to stop all services.\n\n") + if not devel: + print(f"All further messages will be written to {APPLICATION_LOG} and {CADDY_LOG}") + print(f"HTTP Access Log will be written to {CADDY_ACCESS_LOG}") + try: + # start django/uvicorn + if devel: + run( + ["python3", f"{pwd}/application/manage.py", "collectstatic", "--noinput"], + stdout=stdout, + stderr=stderr, + env=environ + ) + app_process = Popen( + ["python3", f"{pwd}/application/manage.py", "runserver", f"127.0.0.1:{DJANGO_PORT}"], + stdout=stdout, + stderr=stderr, + env=environ + ) + else: + application_log_file = open(APPLICATION_LOG, "a") + run( + ["python3", f"{pwd}/application/manage.py", "collectstatic", "--noinput"], + stdout=application_log_file, + stderr=application_log_file, + env=environ + ) + app_process = Popen( + [ + "python3", "-m", "uvicorn", + "--host", "127.0.0.1", + "--port", quote(DJANGO_PORT), + "drinks_manager.asgi:application" + ], + stdout=application_log_file, + stderr=application_log_file, + cwd=f"{pwd}/application/", + env=environ + ) + # start caddy + if devel: + caddy_log_file = stdout + caddy_log_file_stderr = stderr + else: + caddy_log_file = caddy_log_file_stderr = open(CADDY_LOG, "a") + caddy_process = Popen( + ["caddy", "run", "--config", f"{pwd}/config/Caddyfile"], + stdout=caddy_log_file, + stderr=caddy_log_file_stderr, + env=environ + ) + # start session-clear-scheduler + if devel: + clear_sched_log_file = stdout + clear_sched_log_file_stderr = stderr + else: + clear_sched_log_file = clear_sched_log_file_stderr = open(APPLICATION_LOG, "a") + scs_process = Popen( + ["python3", f"{pwd}/lib/session-clear-scheduler.py"], + stdout=clear_sched_log_file, + stderr=clear_sched_log_file_stderr + ) + caddy_process.wait() + scs_process.wait() + app_process.wait() + except KeyboardInterrupt: + stop() diff --git a/lib/clear-expired-sessions.sh b/lib/clear-expired-sessions.sh new file mode 100644 index 0000000..c10b5ce --- /dev/null +++ b/lib/clear-expired-sessions.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash + +# enable debugging for this command +export DJANGO_DEBUG="true" + +# make migrations & migrate +python3 $(pwd)/application/manage.py clearsessions \ No newline at end of file diff --git a/lib/create-admin.sh b/lib/create-admin.sh new file mode 100644 index 0000000..ee46fd4 --- /dev/null +++ b/lib/create-admin.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash + + +# enable debugging for this command +export DJANGO_DEBUG="true" + +# make migrations & migrate +python3 $(pwd)/application/manage.py createsuperuser + +echo -e "done." \ No newline at end of file diff --git a/lib/db-migrations.sh b/lib/db-migrations.sh new file mode 100644 index 0000000..7c17843 --- /dev/null +++ b/lib/db-migrations.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash + + +# enable debugging for this command +export DJANGO_DEBUG="true" + +# make migrations & migrate +python3 $(pwd)/application/manage.py makemigrations +python3 $(pwd)/application/manage.py makemigrations app +python3 $(pwd)/application/manage.py migrate + +echo -e "done with db migration." \ No newline at end of file diff --git a/lib/env.sh b/lib/env.sh new file mode 100644 index 0000000..5863525 --- /dev/null +++ b/lib/env.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash + +export DJANGO_SK_ABS_FP="$(pwd)/config/secret_key.txt" +export PROFILE_PICTURES="$(pwd)/profilepictures/" +export STATIC_FILES="$(pwd)/static/" +export APP_VERSION="13" +export PYTHONPATH="$(pwd)/packages/" diff --git a/lib/generate-secret-key.py b/lib/generate-secret-key.py new file mode 100644 index 0000000..7f98866 --- /dev/null +++ b/lib/generate-secret-key.py @@ -0,0 +1,30 @@ +#!/usr/bin/env python3 + +import sys + +from pathlib import Path +from secrets import token_bytes +from base64 import b85encode + +# + +override = False +if len(sys.argv) > 1: + if sys.argv[1] == "--override": + override = True + +random_token_length = 128 + +secret_key_fp = Path("config/secret_key.txt") + +# + +if secret_key_fp.exists() and not override: + print(f"Warning: secret_key.txt already exists in directory {secret_key_fp.absolute()}. Won't override.", file=sys.stderr) + exit(1) +else: + print("Generating random secret key...") + random_key = b85encode(token_bytes(random_token_length)) + with secret_key_fp.open("wb") as secret_key_f: + secret_key_f.write(random_key) + print("done.") \ No newline at end of file diff --git a/lib/session-clear-scheduler.py b/lib/session-clear-scheduler.py new file mode 100644 index 0000000..6096d72 --- /dev/null +++ b/lib/session-clear-scheduler.py @@ -0,0 +1,47 @@ +#!/usr/bin/env python3 + +# This script clears expired sessions in a regular interval +# The interval is defined (in minutes) by config.sh (SESSION_CLEAR_INTERVAL) + +import os + +from pathlib import Path +from subprocess import run +from time import sleep +from datetime import datetime + +try: + + exiting = False + clear_running = False + + print("[session-clear-scheduler] Starting session-clear-scheduler.") + + session_clear_script_fp = Path("lib/clear-expired-sessions.sh") + clear_interval_seconds = int(os.environ["SESSION_CLEAR_INTERVAL"]) * 60 + + sleep(10) # wait some seconds before the first session clean-up + + while True: + + clear_running = True + run(["/bin/sh", session_clear_script_fp.absolute()]) + clear_running = False + + print(f"[session-clear-scheduler: {datetime.now()}] Cleared expired sessions.") + + if exiting: + break + + sleep(clear_interval_seconds) + +except KeyboardInterrupt: + + exiting = True + + if clear_running: + print(f"[session-clear-scheduler: {datetime.now()}] Received SIGINT. Waiting for current clear process to finish.") + sleep(20) # wait some time + + print(f"[session-clear-scheduler: {datetime.now()}] Exiting") + exit(0) diff --git a/lib/setup-application.sh b/lib/setup-application.sh new file mode 100644 index 0000000..56ae66d --- /dev/null +++ b/lib/setup-application.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash + + +# enable debugging for this command +export DJANGO_DEBUG="true" + +python3 "$(pwd)/lib/generate-secret-key.py" + +source "$(pwd)/lib/db-migrations.sh" + +python3 $(pwd)/lib/upgrade-db.py + +echo -e "\nCreate admin account. Email is optional.\n" +source "$(pwd)/lib/create-admin.sh" + +python3 $(pwd)/application/manage.py collectstatic --noinput diff --git a/lib/start-django-shell.sh b/lib/start-django-shell.sh new file mode 100644 index 0000000..a696310 --- /dev/null +++ b/lib/start-django-shell.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash + +# start a django shell + +export DJANGO_DEBUG="true" + +oldcwd="$(pwd)" +echo "Starting a django shell..." +echo -e "--------------------------------------------------------------------------------\n" +"$(pwd)/application/manage.py" shell +echo -e "\n--------------------------------------------------------------------------------" +cd "$oldcwd" \ No newline at end of file diff --git a/lib/upgrade-db.py b/lib/upgrade-db.py new file mode 100644 index 0000000..2bc239c --- /dev/null +++ b/lib/upgrade-db.py @@ -0,0 +1,157 @@ +#!/usr/bin/env python3 + +import os, sys + +from pathlib import Path + +from psycopg2 import connect +from psycopg2._psycopg import cursor as _cursor +from psycopg2._psycopg import connection as _connection +from psycopg2 import Error +from psycopg2 import IntegrityError +from psycopg2 import errorcodes + + +# setup or upgrade the database + + +def log(s, error=False): + if error: + print(f"{s}", file=sys.stderr) + else: + print(f"{s}", file=sys.stdout) + + +def execute_sql_statement(cursor:_cursor, connection:_connection, sql_statement): + try: + cursor.execute(sql_statement) + connection.commit() + except IntegrityError as ie: + if ie.pgcode == errorcodes.UNIQUE_VIOLATION: + log("Skipping one row that already exists.") + connection.rollback() + else: + log(f"An integrity error occured:\n{ie}\nRolling back...", error=True) + connection.rollback() + except Error as e: + log(f"An SQL statement failed while upgrading the database at {os.environ['PGDB_HOST']}:\n{e}", error=True) + connection.rollback() + + +if __name__ == "__main__": + + exit_code = 0 + + try: + + log("\nSetting up/upgrading database...") + + conn = connect( + user = os.environ["PGDB_USER"], + password = os.environ["PGDB_PASSWORD"], + host = os.environ["PGDB_HOST"], + port = os.environ["PGDB_PORT"], + database = os.environ["PGDB_DB"] + ) + + cur = conn.cursor() + + + # # # # # + + log("Not deleting register_balance. You can delete it via the Admin Panel (Globals -> register_balance), as it is no more used.") + + execute_sql_statement(cur, conn, """ + insert into app_global + values ('global_message', 'Here you can set a global message that will be shown to every user.', 0.0, ''); + """) + + execute_sql_statement(cur, conn, """ + insert into app_global + values ('admin_info', 'Here you can set am infotext that will be displayed on the admin panel.', 0.0, ''); + """) + + execute_sql_statement(cur, conn, """ + create or replace view app_userdeposits_view as + select * from app_registertransaction + where is_user_deposit = true; + """) + + # # # # # + + + # set app_version in file and database + + # database + + try: + + cur.execute(""" + select value from application_info + where key = 'app_version'; + """) + + result = cur.fetchone() + + if result == None: + + cur.execute(f""" + insert into application_info values ('app_version', '{os.environ['APP_VERSION']}'); + """) + + conn.commit() + + else: + + cur.execute(f""" + update application_info set value = '{os.environ['APP_VERSION']}' where key = 'app_version'; + """) + + conn.commit() + + except Error as err: + + if err.pgcode == errorcodes.UNDEFINED_TABLE: + + try: + + conn.rollback() + + cur.execute(""" + create table application_info ( + key varchar(32) primary key, + value text + ); + """) + + cur.execute(f""" + insert into application_info values ('app_version', '{os.environ['APP_VERSION']}'); + """) + + conn.commit() + + except Error as err2: + + log(f"An error occurred while setting app_version in table application_info: {err}", error=True) + exit_code = 1 + + else: + + log(f"An error occurred while setting app_version in table application_info: {err}", error=True) + exit_code = 1 + + # file + + Path("./config/db_app_version.txt").write_text(os.environ["APP_VERSION"]) + log("done with db setup/upgrade.") + + except (Error, Exception) as err: + + log(f"An error occured while upgrading the database at {os.environ['PGDB_HOST']}:\n{err}", error=True) + exit_code = 1 + + finally: + + cur.close() + conn.close() + exit(exit_code) diff --git a/lib/verify-db-app-version.py b/lib/verify-db-app-version.py new file mode 100644 index 0000000..92a7627 --- /dev/null +++ b/lib/verify-db-app-version.py @@ -0,0 +1,104 @@ +#!/usr/bin/env python3 + +from os import environ +from pathlib import Path + +from psycopg2 import connect +from psycopg2._psycopg import cursor +from psycopg2 import Error +from psycopg2 import errorcodes + + +# verify if the installation +# exit code 0 -> no database update is necessary +# exit code 1 -> database update is necessary + + +def check_file(): + + db_app_version_file = Path("./config/db_app_version.txt") + + if not db_app_version_file.exists(): + exit(1) + if not db_app_version_file.is_file(): + exit(1) + if not db_app_version_file.read_text().strip(" ").strip("\n") == environ["APP_VERSION"]: + exit(1) + + +def check_database(): + + try: + + connection = connect( + user = environ["PGDB_USER"], + password = environ["PGDB_PASSWORD"], + host = environ["PGDB_HOST"], + port = environ["PGDB_PORT"], + database = environ["PGDB_DB"] + ) + + cur = connection.cursor() + + # check application version in db + + cur.execute(""" + select value from application_info + where key = 'app_version'; + """) + + appinfo_result = list(cur.fetchone())[0] + + if appinfo_result == None: + cur.close() + connection.close() + exit(1) + + if appinfo_result != environ["APP_VERSION"]: + cur.close() + connection.close() + exit(1) + + # check rows in app_global + + required_rows = [ + "global_message", + "admin_info" + ] + + cur.execute(""" + select name from app_global; + """) + + table_global_result = list(cur.fetchall()) + + cur.close() + connection.close() + + existing_rows = [list(row)[0] for row in table_global_result] + + for r in required_rows: + if not r in existing_rows: + exit(1) + + except Error: + + cur.close() + connection.close() + exit(1) + + except Exception as e: + + print(f"An exception occured: {e}") + cur.close() + connection.close() + exit(1) + + + +if __name__ == "__main__": + + check_file() + check_database() + + exit(0) diff --git a/data/tls/.gitkeep b/logs/.gitkeep similarity index 100% rename from data/tls/.gitkeep rename to logs/.gitkeep diff --git a/misc/drinks-manager.service b/misc/drinks-manager.service index e5d23e8..9dbc463 100644 --- a/misc/drinks-manager.service +++ b/misc/drinks-manager.service @@ -6,15 +6,15 @@ Requires=network-online.target Description=Drinks Manager [Service] -User=drinks -Group=drinks +User=drinks-manager +Group=drinks-manager WorkingDirectory=/srv/drinks-manager/ # start the server: -ExecStart=/usr/bin/bash -c "/srv/drinks-manager/start.sh" +ExecStart=/usr/bin/bash -c "/srv/drinks-manager/run.sh server" # stop the process with a SIGINT: -ExecStop=/usr/bin/bash -c "/bin/kill -2 $MAINPID;" +ExecStop=/usr/bin/bash -c "/bin/kill -2 $MAINPID; /usr/bin/sleep 10" Restart=on-failure -TimeoutStopSec=15s +TimeoutStopSec=40s LimitNPROC=512 LimitNOFILE=1048576 AmbientCapabilities=CAP_NET_BIND_SERVICE @@ -22,5 +22,4 @@ PrivateTmp=true ProtectSystem=full [Install] -WantedBy=multi-user.target - +WantedBy=multi-user.target \ No newline at end of file diff --git a/misc/icons/drinksmanager-icon.src.svg b/misc/icons/drinksmanager-icon.src.svg index f18b107..f231677 100644 --- a/misc/icons/drinksmanager-icon.src.svg +++ b/misc/icons/drinksmanager-icon.src.svg @@ -104,7 +104,7 @@ rdf:about=""> - Julian Müller (ChaoticByte) + Julian Müller (W13R) diff --git a/project/__init__.py b/packages/.gitkeep similarity index 100% rename from project/__init__.py rename to packages/.gitkeep diff --git a/profilepictures/default.svg b/profilepictures/default.svg new file mode 100644 index 0000000..7138ef3 --- /dev/null +++ b/profilepictures/default.svg @@ -0,0 +1,28 @@ + + + + + + + + + + diff --git a/project/settings.py b/project/settings.py deleted file mode 100644 index 56ff5fb..0000000 --- a/project/settings.py +++ /dev/null @@ -1,167 +0,0 @@ -""" -Django settings for project project. - -Generated by 'django-admin startproject' using Django 4.1.6. - -For more information on this file, see -https://docs.djangoproject.com/en/4.1/topics/settings/ - -For the full list of settings and their values, see -https://docs.djangoproject.com/en/4.1/ref/settings/ -""" - -import os -from pathlib import Path -from yaml import safe_load - -# Build paths inside the project like this: BASE_DIR / 'subdir'. -BASE_DIR = Path(__file__).resolve().parent.parent - -# Load configuration file -with Path(BASE_DIR / "data" / "config.yml").open("r") as f: - config = safe_load(f) - -# Quick-start development settings - unsuitable for production -# See https://docs.djangoproject.com/en/4.1/howto/deployment/checklist/ - -# SECURITY WARNING: keep the secret key used in production secret! -SECRET_KEY = config["app"]["secret_key"] -if SECRET_KEY == "!!!replace this with random data!!!" or len(SECRET_KEY) < 40: - print( - "WARNING: You didn't provide a secure secret_key in the configuration file!", - "This is a security risk!!!") - -# SECURITY WARNING: don't run with debug turned on in production! -DEBUG = True -if "APP_PROD" in os.environ: - DEBUG = not os.environ["APP_PROD"] - -# ALLOWED_HOSTS can be wildcarded, -# because caddy already handles requests -ALLOWED_HOSTS = ["*"] - -# Application definition - -INSTALLED_APPS = [ - "app.apps.AppConfig", - "django.contrib.admin", - "django.contrib.auth", - "django.contrib.contenttypes", - "django.contrib.sessions", - "django.contrib.messages", - "django.contrib.staticfiles", -] - -MIDDLEWARE = [ - "django.middleware.security.SecurityMiddleware", - "django.contrib.sessions.middleware.SessionMiddleware", - "django.middleware.locale.LocaleMiddleware", - "django.middleware.common.CommonMiddleware", - "django.middleware.csrf.CsrfViewMiddleware", - "django.contrib.auth.middleware.AuthenticationMiddleware", - "django.contrib.messages.middleware.MessageMiddleware", - "django.middleware.clickjacking.XFrameOptionsMiddleware", -] - -ROOT_URLCONF = "project.urls" - -TEMPLATES = [ - { - "BACKEND": "django.template.backends.django.DjangoTemplates", - "DIRS": [], - "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", - "app.context_processors.app_version" - ], - }, - }, -] - -WSGI_APPLICATION = "project.wsgi.application" - -# Database -# https://docs.djangoproject.com/en/4.1/ref/settings/#databases - -DATABASES = { - "default": { - "ENGINE": 'django.db.backends.postgresql', - "NAME": config["db"]["database"], - "USER": config["db"]["user"], - "PASSWORD": config["db"]["password"], - "HOST": config["db"]["host"], - "PORT": str(config["db"]["port"]), - } -} - -# Password validation -# https://docs.djangoproject.com/en/4.1/ref/settings/#auth-password-validators - -if config["app"]["password_validation"]: - 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", - }, - ] -else: - AUTH_PASSWORD_VALIDATORS = [] - -# Security settings - -AUTH_USER_MODEL = "app.User" -SESSION_COOKIE_AGE = int(config["app"]["session_cookie_age"]) -CSRF_COOKIE_SECURE = True -SESSION_COOKIE_SECURE = True -CSRF_TRUSTED_ORIGINS = [] - -for host in config['caddy']['hosts']: - CSRF_TRUSTED_ORIGINS.append(f"http://{host}") - CSRF_TRUSTED_ORIGINS.append(f"https://{host}") - CSRF_TRUSTED_ORIGINS.append(f"http://{host}:{config['caddy']['https_port']}") - CSRF_TRUSTED_ORIGINS.append(f"https://{host}:{config['caddy']['https_port']}") - -# Internationalization -# https://docs.djangoproject.com/en/4.1/topics/i18n/ - -LANGUAGE_CODE = config["app"]["language_code"] -TIME_ZONE = config["app"]["timezone"] -USE_I18N = True -USE_L10N = True -USE_TZ = True - -LOCALE_PATHS = [ - BASE_DIR / "app" / "locales" -] - -# Static files (CSS, JavaScript, Images) -# https://docs.djangoproject.com/en/4.1/howto/static-files/ - -STATIC_URL = "django_static/" -STATIC_ROOT = BASE_DIR / "data" / "django_static" - -# Default primary key field type -# https://docs.djangoproject.com/en/4.1/ref/settings/#default-auto-field - -DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" - -# Additional settings - -if "APP_VERSION" in os.environ: - APP_VERSION = os.environ["APP_VERSION"] -else: - APP_VERSION = "unknown" - -CURRENCY_SUFFIX = config["app"]["currency_suffix"] diff --git a/requirements.txt b/requirements.txt index ceb35b8..da50c9d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ -Django~=4.2 -psycopg2~=2.9 -uvicorn[standard]~=0.27 -PyYAML~=6.0 +django~=3.2.7 +django-currentuser==0.5.3 +psycopg2~=2.9.1 +uvicorn~=0.17.6 diff --git a/run.sh b/run.sh new file mode 100755 index 0000000..a1664c5 --- /dev/null +++ b/run.sh @@ -0,0 +1,94 @@ +#!/usr/bin/env bash + + +function show_dm_help { # $1 = exit code + + echo -e "Usage:\t./run.sh \n" + echo -e "\nCommands:\n" + echo -e " server\t\tstart server" + echo -e " setup\t\t\tset up the application" + echo -e " create-admin\t\tcreate an admin account" + echo -e " generate-secret-key\tgenerate a new random secret key for Django" + echo -e " clear-sessions\tmanually remove all expired sessions from the database" + echo -e " force-db-upgrade\tforce a database migration & upgrade" + echo -e " archive-tables\tarchive (copy & delete) all entries in app_order and app_registertransaction" + echo -e " development-server\tstart Django development server and enable debugging" + echo -e " shell\t\t\tstart a Django shell" + echo -e " help\t\t\tShow this help text\n" + echo -e "\nExamples:\n" + echo -e " ./run.sh server" + echo -e " ./run.sh create-admin" + echo "" + + exit $1 + +} + +# set current working directory +cd $(dirname "$0") + +source "$(pwd)/lib/env.sh" + +echo -e "\n## Drinks Manager" +echo -e "## version $APP_VERSION\n" + + +if [ -z $1 ]; then + + show_dm_help 1 + +else + + source "$(pwd)/config/config.sh" + + if [ $1 = 'server' ]; then + + source "$(pwd)/lib/auto-upgrade-db.sh" + python3 "$(pwd)/lib/bootstrap.py" + + elif [ $1 = 'development-server' ]; then + + source "$(pwd)/lib/auto-upgrade-db.sh" + python3 "$(pwd)/lib/bootstrap.py" devel + + elif [ $1 = 'setup' ]; then + + source "$(pwd)/lib/setup-application.sh" + + elif [ $1 = 'generate-secret-key' ]; then + + python3 "$(pwd)/lib/generate-secret-key.py" --override + + elif [ $1 = 'force-db-upgrade' ]; then + + source "$(pwd)/lib/db-migrations.sh" + python3 "$(pwd)/lib/upgrade-db.py" + + elif [ $1 = 'create-admin' ]; then + + source "$(pwd)/lib/create-admin.sh" + + elif [ $1 = 'clear-sessions' ]; then + + source "$(pwd)/lib/clear-expired-sessions.sh" + echo -e "done." + + elif [ $1 = 'archive-tables' ]; then + + python3 "$(pwd)/lib/archive-tables.py" + + elif [ $1 = 'shell' ]; then + + source "$(pwd)/lib/start-django-shell.sh" + + elif [ $1 = 'help' ]; then + + show_dm_help 0 + + else + + show_dm_help 1 + + fi + +fi \ No newline at end of file diff --git a/scripts/_bootstrap.py b/scripts/_bootstrap.py deleted file mode 100755 index c162c03..0000000 --- a/scripts/_bootstrap.py +++ /dev/null @@ -1,190 +0,0 @@ -#!./venv/bin/python3 -# Copyright 2023 Julian Müller (ChaoticByte) - -import os - -from argparse import ArgumentParser -from atexit import register as register_exithandler -from pathlib import Path -from signal import SIGINT -from subprocess import Popen -from sys import stdout, stderr -from time import sleep - -from yaml import safe_load - - -banner = r""" ___ _ _ -| \ _ _ (_) _ _ | |__ ___ ___ -| |) || '_|| || ' \ | / /(_-< |___| -|___/ |_| |_||_||_||_\_\/__/ - __ __ Version {version} -| \/ | __ _ _ _ __ _ __ _ ___ _ _ -| |\/| |/ _` || ' \ / _` |/ _` |/ -_)| '_| -|_| |_|\__,_||_||_|\__,_|\__, |\___||_| - |___/ -""" - -base_directory = Path(__file__).parent.parent -data_directory = base_directory / "data" -logfile_directory = data_directory / "logs" -configuration_file = data_directory / "config.yml" -caddyfile = data_directory / "Caddyfile" -logfile_caddy = logfile_directory / "caddy.log" -logfile_app = logfile_directory / "app.log" -logfile_sessioncleanup = logfile_directory / "session-cleanup.log" - - -class MonitoredSubprocess: - def __init__( - self, - name: str, - commandline: list, - logfile: Path, - environment: dict = os.environ, - max_tries: int = 5, - ): - self.name = name - self.commandline = commandline - self.logfile = logfile - self.environment = environment - self.max_tries = max_tries - self.s = None # the subprocess object - self._tries = 0 - self._stopped = False - - def try_start(self): - if self._tries < self.max_tries: - self._tries += 1 - print(f"Starting {self.name}...") - if self.logfile is None: - self.s = Popen( - self.commandline, - stdout=stdout.buffer, - stderr=stderr.buffer, - env=self.environment) - else: - with self.logfile.open("ab") as l: - self.s = Popen( - self.commandline, - stdout=l, - stderr=l, - env=self.environment) - return True - else: - print(f"Max. tries exceeded ({self.name})!") - # the process must already be stopped at this - # point, so we can set the variable accordingly - self._stopped = True - return False - - def stop(self): - if not self._stopped: - print(f"Stopping {self.name}...") - self.s.terminate() - self._stopped = True - - -def cleanup_procs(processes): - for p in processes: - p.stop() - - -def start_and_monitor(monitored_subprocesses: list): - # display banner - print(banner.format(version=os.environ["APP_VERSION"])) - # start processes - for p in monitored_subprocesses: - p.try_start() - register_exithandler(cleanup_procs, monitored_subprocesses) - # monitor processes - try: - while True: - sleep(1) - for p in monitored_subprocesses: - returncode = p.s.poll() - if returncode is None: - continue - else: - print(f"{p.name} stopped with exit code {returncode}.") - if p.try_start() is False: - # stop everything if the process - # has exceeded max. tries - exit() - except KeyboardInterrupt: - print("Received KeyboardInterrupt, exiting...") - exit() - - -if __name__ == "__main__": - argp = ArgumentParser() - argp.add_argument("--devel", help="Start development server", action="store_true") - args = argp.parse_args() - # Load configuration - with configuration_file.open("r") as f: - config = safe_load(f) - # Prepare - os.chdir(str(base_directory)) - Popen( - ["./venv/bin/python3", "./manage.py", "collectstatic", "--noinput"], env=os.environ).wait() - Popen( - ["./venv/bin/python3", "./manage.py", "migrate", "--noinput"], env=os.environ).wait() - # Caddy configuration via env - environment_caddy = os.environ - environment_caddy["ROOTDIR"] = str(base_directory.absolute()) - environment_caddy["DATADIR"] = str(data_directory.absolute()) - environment_caddy["CADDY_HOSTS"] = ", ".join(config["caddy"]["hosts"]) - environment_caddy["HTTP_PORT"] = str(config["caddy"]["http_port"]) - environment_caddy["HTTPS_PORT"] = str(config["caddy"]["https_port"]) - environment_caddy["APPLICATION_PORT"] = str(config["app"]["application_port"]) - environment_caddy["ACCESS_LOG"] = config["logs"]["http_access"] - # Start - if args.devel: - procs = [ - MonitoredSubprocess( - "Caddy Webserver", - ["caddy", "run", "--config", str(caddyfile)], - None, - environment=environment_caddy), - MonitoredSubprocess( - "Django Development Server", - ["./venv/bin/python3", "./manage.py", "runserver", str(config["app"]["application_port"])], - None), - MonitoredSubprocess( - "Session Autocleaner", - ["./scripts/_session-autocleaner.py", str(config["app"]["session_clear_interval"])], - None) - ] - start_and_monitor(procs) - else: - # Application configuration via env - environment_app = os.environ - environment_app["APP_PROD"] = "1" - print("\nRunning in production mode.\n") - # define processes - procs = [ - MonitoredSubprocess( - "Caddy Webserver", - ["caddy", "run", "--config", str(caddyfile)], - logfile_caddy, - environment=environment_caddy), - MonitoredSubprocess( - "Drinks-Manager", - [ - "./venv/bin/python3", - "-m", - "uvicorn", - "--host", - "127.0.0.1", - "--port", - str(config["app"]["application_port"]), - "project.asgi:application", - ], - logfile_app, - environment=environment_app), - MonitoredSubprocess( - "Session Autocleaner", - ["./scripts/_session-autocleaner.py", str(config["app"]["session_clear_interval"])], - logfile_sessioncleanup) - ] - start_and_monitor(procs) diff --git a/scripts/_session-autocleaner.py b/scripts/_session-autocleaner.py deleted file mode 100755 index bb81089..0000000 --- a/scripts/_session-autocleaner.py +++ /dev/null @@ -1,55 +0,0 @@ -#!./venv/bin/python3 - -# This script clears expired sessions in a regular interval - -import os - -from argparse import ArgumentParser -from atexit import register as register_exithandler -from pathlib import Path -from subprocess import Popen -from time import sleep -from datetime import datetime - - -current_proc = None - - -def exithandler(): - if current_proc is not None: - seconds_waited = 0 - while current_proc.poll() is None: - # wait for 10 seconds to quit session cleaner - if seconds_waited >= 10: - current_proc.terminate() - break - # is still running - sleep(1) - seconds_waited += 1 - print("Stopped session-autocleaner.") - - -if __name__ == "__main__": - try: - argp = ArgumentParser() - argp.add_argument("interval", help="The interval in minutes", type=int) - args = argp.parse_args() - os.chdir(str(Path(__file__).parent.parent)) - print(f"Started session-autocleaner with an interval of {args.interval} minute(s)") - interval = args.interval * 60 - # register exithandler that cleans up stuff - register_exithandler(exithandler) - # main loop - while True: - if current_proc is not None: - # wait for last iteration - while current_proc.poll() is None: - # is still running - print("Last cleanup is still running, waiting before clearing sessions...") - sleep(1) - print(f"Clearing expired sessions at {datetime.now()}...") - current_proc = Popen( - ["./manage.py", "clearsessions"]) - sleep(interval) - except KeyboardInterrupt: - exit() diff --git a/scripts/archive.py b/scripts/archive.py deleted file mode 100755 index 7128fb8..0000000 --- a/scripts/archive.py +++ /dev/null @@ -1,57 +0,0 @@ -#!/usr/bin/env python3 - -from datetime import datetime -from pathlib import Path - -from psycopg2 import connect -from yaml import safe_load - - -base_directory = Path(__file__).parent.parent -data_directory = base_directory / "data" -configuration_file = data_directory / "config.yml" -archive_directory = data_directory / "archive" - - -if __name__ == "__main__": - exit_code = 0 - try: - # read config - with configuration_file.open("r") as f: - config = safe_load(f) - # connect to database - connection = connect( - user = config["db"]["user"], - password = config["db"]["password"], - host = config["db"]["host"], - port = config["db"]["port"], - database = config["db"]["database"] - ) - cur = connection.cursor() - # copy data from database - timestamp = datetime.now().strftime("%Y-%m-%d-%H%M%S") - orders_archive_path = archive_directory / f"orders-archive-{timestamp}.csv" - transactions_archive_path = archive_directory / f"transactions-archive-{timestamp}.csv" - print(f"Copying data...") - with orders_archive_path.open("w") as of: - cur.copy_expert( - "copy (select * from app_order) to STDOUT with csv delimiter ';'", of) - print(str(orders_archive_path)) - with transactions_archive_path.open("w") as tf: - cur.copy_expert( - "copy (select * from app_registertransaction) to STDOUT with csv delimiter ';'", tf) - print(str(transactions_archive_path)) - # delete data from database - print("Deleting data from database...") - cur.execute("delete from app_order;") - cur.execute("delete from app_registertransaction;") - connection.commit() - print("done.") - except (Error, Exception) as err: - connection.rollback() - print(f"An error occured while upgrading the database at {os.environ['PGDB_HOST']}:\n{err}") - exit_code = 1 - finally: - cur.close() - connection.close() - exit(exit_code) diff --git a/scripts/create-admin.sh b/scripts/create-admin.sh deleted file mode 100755 index 783e509..0000000 --- a/scripts/create-admin.sh +++ /dev/null @@ -1,19 +0,0 @@ -#!/usr/bin/env bash -# Copyright 2023 Julian Müller (ChaoticByte) - -# change to correct directory, if necessary -script_absolute=$(realpath "$0") -script_directory=$(dirname "$script_absolute") -desired_directory=$(realpath "$script_directory"/..) -if [ "$PWD" != "$desired_directory" ]; then - echo "Changing to project directory..." - cd "$desired_directory" -fi - -echo "Activating venv..." -source ./venv/bin/activate - -echo "Applying migrations..." -./manage.py migrate - -./manage.py createsuperuser diff --git a/scripts/setup-env.sh b/scripts/setup-env.sh deleted file mode 100755 index f700819..0000000 --- a/scripts/setup-env.sh +++ /dev/null @@ -1,20 +0,0 @@ -#!/usr/bin/env bash -# Copyright 2023 Julian Müller (ChaoticByte) - -# change to correct directory, if necessary -script_absolute=$(realpath "$0") -script_directory=$(dirname "$script_absolute") -desired_directory=$(realpath "$script_directory"/..) -if [ "$PWD" != "$desired_directory" ]; then - echo "Changing to project directory..." - cd "$desired_directory" -fi - -echo "Creating venv..." -python3 -m venv ./venv - -echo "Activating venv..." -source ./venv/bin/activate - -echo "Installing dependencies..." -python3 -m pip install -r requirements.txt diff --git a/start.sh b/start.sh deleted file mode 100755 index cd50964..0000000 --- a/start.sh +++ /dev/null @@ -1,16 +0,0 @@ -#!/usr/bin/env bash - -basedir=$(dirname "$0") -basedir=$(realpath $basedir) -cd "$basedir" - -# Set file permissions -chmod -c -R g-w,o-rwx * -chmod -c -R g-w,o-rwx .git/ -chmod -c -R g-w,o-rwx .gitignore - -export PYTHONPATH="$basedir" -export DJANGO_SETTINGS_MODULE="project.settings" -export APP_VERSION="22" - -exec ./scripts/_bootstrap.py "$@" diff --git a/static/css/appform.css b/static/css/appform.css new file mode 100644 index 0000000..5c727b5 --- /dev/null +++ b/static/css/appform.css @@ -0,0 +1,61 @@ +.appform { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: max-content; + font-size: 1.1rem; +} +.appform > .forminfo { + width: 100%; + text-align: left; + margin: .4rem 0; +} +.forminfo > span:first-child { + margin-right: 1rem; +} +.forminfo > span:last-child { + float: right; +} +.appform > .forminput { + width: 100%; + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; + margin: .8rem 0; + gap: 1rem; +} +.appform > .statusinfo { + margin-top: .5rem; +} +.appform > .formbuttons { + border-top: 1px solid var(--glass-border-color); + padding-top: 1rem; + margin-top: 1rem; + width: 100%; + display: flex; + flex-direction: row; + justify-content: center; + align-items: center; + gap: 1rem; +} +.formbuttons button, .formbuttons .button { + box-sizing: content-box; + font-size: 1rem; + width: fit-content; +} +.formheading { + text-align: left; + width: 100%; + margin-top: 0; +} +@media only screen and (max-width: 700px) { + .appform > .forminput { + flex-direction: column; + gap: .5rem; + } + .formheading { + text-align: center; + } +} \ No newline at end of file diff --git a/static/css/custom_number_input.css b/static/css/custom_number_input.css new file mode 100644 index 0000000..375f0de --- /dev/null +++ b/static/css/custom_number_input.css @@ -0,0 +1,39 @@ +/* custom number input */ +.customnumberinput { + display: flex; + flex-direction: row; + height: 2.2rem; +} +.customnumberinput button { + min-width: 2.5rem !important; + width: 2.5rem !important; + padding: 0; + margin: 0; + height: 100%; +} +.customnumberinput-minus { + border-bottom-right-radius: 0; + border-top-right-radius: 0; + z-index: 10; +} +.customnumberinput-plus { + border-bottom-left-radius: 0; + border-top-left-radius: 0; + z-index: 10; +} +.customnumberinput input[type="number"] { + max-height: 100%; + width: 4rem; + padding: 0; + margin: 0; + font-size: .9rem; + color: var(--color); + text-align: center; + background: var(--glass-bg-color2); + outline: none; + border: none; + border-radius: 0 !important; + -webkit-appearance: textfield; + -moz-appearance: textfield; + appearance: textfield; +} \ No newline at end of file diff --git a/static/css/history.css b/static/css/history.css new file mode 100644 index 0000000..6c87d89 --- /dev/null +++ b/static/css/history.css @@ -0,0 +1,23 @@ +.history { + margin: 0; + padding: 0; + width: 40%; + min-width: 30rem; +} +.history td { + padding-top: .4rem !important; + padding-bottom: .4rem !important; + font-size: .95rem; +} +.history .historydate { + margin-left: auto; + text-align: right; + font-size: .8rem !important; +} +/* mobile devices */ +@media only screen and (max-width: 700px) { + .history { + width: 90%; + min-width: 90%; + } +} \ No newline at end of file diff --git a/static/css/index.css b/static/css/index.css new file mode 100644 index 0000000..a89df5d --- /dev/null +++ b/static/css/index.css @@ -0,0 +1,46 @@ +.availabledrinkslist { + width: 50%; + max-width: 45rem; + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + justify-content: start; + align-items: center; +} +.availabledrinkslist li { + display: flex; + width: 100%; + height: fit-content; + margin-bottom: .6rem; +} +.availabledrinkslist li a { + display: flex; + width: 100%; + align-items: center; + justify-content: start; + color: var(--color); + padding: .8rem 1.1rem; + text-decoration: none; + font-size: 1rem; +} +.availabledrinkslist li a span:first-child { + margin-right: 1rem !important; + text-align: left; +} +.availabledrinkslist li a span:last-child { + margin-left: auto; + text-align: right; + font-size: 1rem; +} +/* mobile devices */ +@media only screen and (max-width: 700px) { + .availabledrinkslist { + width: 95%; + } + .availabledrinkslist li a { + width: calc(100vw - (2 * .8rem)) !important; + padding: .8rem !important; + } +} \ No newline at end of file diff --git a/static/css/login.css b/static/css/login.css new file mode 100644 index 0000000..13776e9 --- /dev/null +++ b/static/css/login.css @@ -0,0 +1,131 @@ +/* login page */ +main { + margin-top: 2vh; +} +main > h1 { + display: none; +} +.userlistcontainer { + display: flex; + flex-direction: column; + align-items: center; + justify-content: start; +} +.userlist { + width: 50vw; + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; +} +.userlist > li { + display: flex; + width: 100%; + align-items: center; + justify-content: center; + margin-bottom: .5rem; + padding: 0 .5rem; +} +.userlist > li > img { + margin-right: auto; + margin-left: 0; + height: 2rem; + width: 2rem; +} +.userlist > li > div { + display: flex; + flex-grow: 1; + align-items: center; + justify-content: center; + text-align: center; + padding: .8rem 1.1rem; +} +.userlistbutton { + font-size: 1.1rem; +} +.passwordoverlaycontainer { + position: absolute; + top: 0; + width: 100vw; + height: 100vh; + display: flex; + flex-direction: column; + align-items: center; + justify-content: start; + background: var(--page-background); + z-index: 40; +} +.passwordoverlay { + display: flex; + flex-direction: column; + justify-content: start; + align-items: center; +} +.passwordoverlay > form { + min-width: unset; + width: fit-content; +} +.passwordoverlay > form > h1 { + margin-top: 2rem; + margin-bottom: 2rem; +} +/* loginform */ +.loginform { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; +} +.loginform input[type="password"], form input[type="text"] { + width: 94%; + padding-top: .5rem; + padding-bottom: .5rem; + font-size: 1rem; + margin: .1rem 0; +} +.loginform .horizontalbuttonlist { + margin-top: 1.5rem; +} +.horizontalbuttonlist .button, .horizontalbuttonlist button { + font-size: 1rem; +} +/***/ +.pinpad { + margin-top: 1.5rem; + margin-bottom: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + max-width: 30vw; +} +.pinpad table { + box-shadow: none !important; +} +.pinpad table tr, .pinpad td { + padding: unset; + background: unset; +} +.pinpad tr td button { + height: 4.0rem; + width: 4.1rem; + font-size: 1.16rem; + margin: .2rem !important; +} +@media only screen and (max-width: 700px) { + .userlistcontainer { + width: 95vw; + } + .userlist { + width: 100%; + } + .pinpad table tr td button { + height: 4.2rem; + width: 4.2rem; + font-size: 1.16rem; + margin: .2rem; + } +} \ No newline at end of file diff --git a/static/css/main.css b/static/css/main.css index ea44432..8b999b7 100644 --- a/static/css/main.css +++ b/static/css/main.css @@ -1,642 +1,341 @@ -/* Fonts */ - -@font-face { - font-family: "Inter"; - src: url('/static/fonts/Inter-Regular.ttf'); - font-weight: normal; - font-style: normal; -} -@font-face { - font-family: "Inter"; - src: url('/static/fonts/Inter-Bold.ttf'); - font-weight: bold; - font-style: normal; -} - -/* Variables */ - +/* VARIABLES */ :root { - --font-family: "Inter"; + /** FONT **/ + --font-family: 'Liberation Sans', sans-serif; + /** colors **/ --color: #fafafa; - --color-disabled: #ffffff50; - --color-error: #ff817c; - --bg-page: linear-gradient( - -10deg, - #071c29 10%, - #4a8897 - ); - --bg-color: #ffffff35; - --bg-color2: #ffffff25; - --bg-hover-color: #ffffff50; - --border-color: #ffffff50; - --bg-globalmessage: #161616; - --border-radius: .6rem; - --element-padding: .6rem .8rem; + --color-error: rgb(255, 70, 70); + /** glass **/ + --glass-bg-dropdown: #3a3b44ef; + --glass-bg-dropdown-hover: #55565efa; + --glass-bg-color1: #ffffff31; + --glass-bg-color2: #ffffff1a; + --glass-bg-hover-color1: #ffffff46; + --glass-bg-hover-color2: #ffffff1a; + --glass-blur: none; + --glass-border-color: #ffffff77; + --glass-bg: linear-gradient(var(--glass-bg-color1), var(--glass-bg-color2)); + --glass-bg-hover: linear-gradient(var(--glass-bg-hover-color1), var(--glass-bg-hover-color2)); + --glass-corner-radius: .5rem; + /** page background **/ + --page-background-color1: #131d25; + --page-background-color2: #311d30; + --page-background: linear-gradient(-190deg, var(--page-background-color1), var(--page-background-color2)); + /** global message banner **/ + --bg-globalmessage: linear-gradient(135deg, #4b351c, #411d52, #1c404b); } - -/* General */ - -body, -input, -select, -button, .button -{ - font-family: var(--font-family); +@supports(backdrop-filter: blur(10px)) { + :root { + --glass-bg-dropdown: #ffffff1a; + --glass-bg-dropdown-hover: #ffffff46; + --glass-blur: blur(18px); + } } - +/* BASE LAYOUT */ body { margin: 0; padding: 0; width: 100vw; min-height: 100vh; - font-size: 17px; - background: var(--bg-page); + font-family: var(--font-family); + background: var(--page-background); color: var(--color); overflow-x: hidden; } - -a { - color: var(--color); -} - -h1 { - font-size: 28px; -} - -h1, h2, h3, h4 { - text-align: center; -} - -input[type="number"] { - width: 8rem; - -webkit-appearance: textfield; - -moz-appearance: textfield; - appearance: textfield; -} - -input[type="number"]::-webkit-inner-spin-button { - display: none; -} - -input[type="text"], -input[type="password"], -input[type="number"], -select { - padding: var(--element-padding); - text-align: center !important; - font-size: 16px; - color: var(--color); - border: none; - outline: none; - border: 1px solid var(--border-color); - border-radius: var(--border-radius); - background: var(--bg-color); -} - -input[type="text"]::placeholder, -input[type="password"]::placeholder, -input[type="number"]::placeholder, -select > option:disabled { - color: var(--color-disabled); -} - -select { - appearance: none; - -webkit-appearance: none; - -moz-appearance: none; - background-image: url("/static/material-icons/arrow-drop-down.svg"); - background-repeat: no-repeat; - background-position: right; - background-size: 1.5rem; -} - -table { - border-collapse: collapse; - border-spacing: 0; - text-align: left; - border-radius: var(--border-radius); -} - -tr > th, -tr > td { - background: var(--bg-color); -} - -tr:nth-child(2n+2) > td { - background: var(--bg-color2); -} - -table tr:first-child th:first-child { - border-top-left-radius: var(--border-radius); -} - -table tr:first-child th:last-child { - border-top-right-radius: var(--border-radius); -} - -table tr:last-child td:first-child { - border-bottom-left-radius: var(--border-radius); -} - -table tr:last-child td:last-child { - border-bottom-right-radius: var(--border-radius); -} - -td, th { - padding: .5rem .8rem; -} - -th { - text-align: left; - border-bottom: 1px solid var(--border-color); -} - -/* Basic Layout */ - .baselayout { + display: flex; + flex-direction: column; justify-content: start; align-items: center; min-height: 100vh; width: 100vw; max-width: 100vw; } - -.globalmessage { - width: 100vw; - z-index: 999; - background: var(--bg-globalmessage); - padding: .3rem 0; +main { + display: flex; + flex-direction: column; + justify-content: flex-start; + align-items: center; + flex-grow: 1; + width: 100%; + margin-top: 5vh; } - -.globalmessage > div { - width: 96%; - word-break: keep-all; - word-wrap: break-word; - box-sizing: border-box; -} - .userpanel { + display: flex; flex-direction: row; + justify-content: center; + align-items: center; margin-top: 1rem; + font-size: 1rem; width: 94%; - gap: 2rem; } - .userinfo > span { + font-size: 1.1rem; vertical-align: middle; } - .userinfo > img { vertical-align: middle; width: 1.8rem; height: 1.8rem; margin: .5rem; } - -.userpanel-buttons { - gap: .5rem; +.userpanel > .horizontalbuttonlist { + margin-left: auto; + margin-right: 0; } - -.userpanel-buttons > .button, .userpanel-buttons button { - height: 1.2rem; -} - .userbalancewarn { color: var(--color-error); font-weight: bold; } - -main { - justify-content: flex-start; - align-items: center; - flex-grow: 1; - width: 100%; -} - .content { + display: flex; + flex-direction: column; justify-content: start; align-items: center; - flex-grow: 1; - padding: 2rem 0; -} - -.footer-container { - z-index: 900; - margin-top: auto; - pointer-events: none; -} - -.footer { - margin-top: 1.5rem; - padding-bottom: .3rem; - text-align: center; - pointer-events: initial; -} - -.footer > div { - font-size: 16px; - margin-top: .15rem; - margin-bottom: .15rem; -} - -.footer > div::after { - margin-left: .5rem; - content: "-"; - margin-right: .5rem; -} - -.footer > div:last-child::after { - content: none; - margin-left: 0; - margin-right: 0; -} - -/* Common */ - -.flex { - display: flex; -} - -.flex-row { - flex-direction: row; -} - -.flex-column { - flex-direction: column; -} - -.flex-center { - justify-content: center; - align-items: center; -} - -.flex-wrap { - flex-wrap: wrap; -} - -.text-align-right { - text-align: right; -} - -.text-align-center { - text-align: center; -} - -.gap-1rem { - gap: 1rem; -} - -.buttons { - display: flex; - flex-direction: row; - align-items: center; - justify-content: end; - gap: 1rem; -} - -.button, button { - display: flex; - align-items: center; - justify-content: center; - outline: none; - border: 1px solid var(--border-color); - border-radius: var(--border-radius); - width: fit-content; -} - -.button, button, .dropdownchoice { - padding: var(--element-padding); - font-size: 16px; - text-align: center !important; - text-decoration: none; - color: var(--color); - box-sizing: content-box; - cursor: pointer; - user-select: none; - background: var(--bg-color); -} - -.button:hover, button:hover, -.button:active, button:active { - background: var(--bg-hover-color); -} - -.button:disabled, button:disabled { - opacity: 40%; -} - -.formheading { - margin-bottom: 2rem; -} - -.forminfo { - width: fit-content; - min-width: 16rem; - text-align: left; - display: flex; - flex-direction: row; - justify-content: space-between; - gap: 2rem; - padding-bottom: .15rem; - border-bottom: 1px solid #ffffff20; -} - -.forminfo > span:last-child { - float: right; -} - -.appform, .appform > * { - max-width: 90vw; -} - -.appform > .forminput { width: 100%; - flex-direction: row; - justify-content: space-between; + flex-grow: 1; +} +.globalmessage { + width: 100vw; + z-index: 999; + display: flex; + justify-content: center; align-items: center; - flex-wrap: wrap; - gap: 1rem; + background: var(--bg-globalmessage); + padding: .3rem 0; } - -.forminput > input, .forminput > select { - width: 100% !important; +.globalmessage div { + width: 96%; + text-align: center; + word-break: keep-all; + word-wrap: break-word; + box-sizing: border-box; } - -.forminput > .keyboard-input, #transfer-recipient { - /* the keyboard has a 5px padding */ - margin-left: 5px !important; - margin-right: 5px !important; -} - -.appform > .buttons { - margin-top: 1rem; -} - -#statusinfo { - margin-top: 1rem; -} - +/* DROP DOWN MENUS */ .dropdownmenu { display: flex; flex-direction: column; justify-content: flex-start; align-items: center; - border-radius: var(--border-radius); + border-radius: var(--glass-corner-radius); } - -#dropdownnope { - position: fixed; - top: 0; - left: 0; - width: 100vw; - height: 100vh; - margin: 0; - padding: 0; -} - .dropdownbutton { + width: fit-content; z-index: 190; + box-shadow: none; + text-align: center; + justify-content: center; } - -.dropdownbutton > img { - width: auto; - height: 100%; +.dropdownbutton, .dropdownchoice { + font-size: 1rem; } - .dropdownlist { - margin-top: 3rem; position: absolute; display: flex; flex-direction: column; - border-radius: var(--border-radius); - box-shadow: 0 0 .5rem #00000025; -} - -.dropdownlist, #dropdownnope { - visibility: hidden; - opacity: 0%; pointer-events: none; + border-radius: var(--glass-corner-radius) !important; + backdrop-filter: var(--glass-blur); + z-index: 200; + margin-top: 3.2rem; + opacity: 0%; + transition: opacity 100ms; } - -.dropdownvisible .dropdownlist, -.dropdownvisible #dropdownnope { +.dropdownchoice { + box-shadow: none; + border-radius: 0 !important; + margin: 0; + margin-top: -1px; + text-align: center; + justify-content: center; + background: var(--glass-bg-dropdown) !important; + backdrop-filter: none !important; +} +.dropdownchoice:hover { + background: var(--glass-bg-dropdown-hover) !important; +} +.dropdownlist :first-child { + border-top-left-radius: var(--glass-corner-radius) !important; + border-top-right-radius: var(--glass-corner-radius) !important; +} +.dropdownlist :last-child { + border-bottom-left-radius: var(--glass-corner-radius) !important; + border-bottom-right-radius: var(--glass-corner-radius) !important; +} +.dropdownvisible .dropdownlist { opacity: 100%; - background: #00000020; visibility: visible; pointer-events: visible; - z-index: 100; } - -.dropdownchoice { - z-index: 200; - margin: 0; - text-decoration: none; - width: initial; - min-width: max-content; - border-bottom: 1px solid var(--border-color); - border-left: 1px solid var(--border-color); - border-right: 1px solid var(--border-color); -} - -.dropdownchoice:first-child { - border-top: 1px solid var(--border-color); - border-top-left-radius: var(--border-radius); - border-top-right-radius: var(--border-radius); -} - -.dropdownchoice:last-child { - border-bottom: 1px solid var(--border-color); - border-bottom-left-radius: var(--border-radius); - border-bottom-right-radius: var(--border-radius); -} - -.dropdownchoice:hover { - background: var(--bg-hover-color); -} - -.customnumberinput { - height: 2.2rem; +/* FOOTER */ +.footer { + z-index: 900; display: flex; flex-direction: row; + justify-content: center; align-items: center; - gap: .25rem; + flex-wrap: wrap; + margin-top: auto; + padding-top: 3rem; + padding-bottom: .3rem; + text-align: center; } - -.customnumberinput button { - width: 2.2rem !important; - height: 2.2rem !important; - padding: 0; - margin: 0; +.footer div { + font-size: .95rem; + margin-top: .15rem; + margin-bottom: .15rem; } - -.customnumberinput input[type="number"] { - height: 100%; - width: 4rem; - padding: 0; - margin: 0; - background: var(--bg-color2); +.footer div::after { + margin-left: .5rem; + content: "-"; + margin-right: .5rem; +} +.footer div:last-child::after { + content: none; + margin-left: 0; + margin-right: 0; +} +/* TABLES */ +table { + border-collapse: collapse; + border-spacing: 0; + text-align: left; + border-radius: var(--glass-corner-radius); + backdrop-filter: var(--glass-blur); +} +tr { + background: var(--glass-bg-color1); +} +tr:nth-child(2n+2) { + background: var(--glass-bg-color2); +} +/* +Rounded corners on table cells apparently don't work with +Firefox, so Firefox users won't have rounded corners +on tables. Can't fix that by myself. +*/ +table tr:first-child th:first-child { + border-top-left-radius: var(--glass-corner-radius); +} +table tr:first-child th:last-child { + border-top-right-radius: var(--glass-corner-radius); +} +table tr:last-child td:first-child { + border-bottom-left-radius: var(--glass-corner-radius); +} +table tr:last-child td:last-child { + border-bottom-right-radius: var(--glass-corner-radius); +} +/* - */ +td, th { + padding: .5rem .8rem; +} +th { + text-align: left; + border-bottom: 1px solid var(--color); +} +/* BUTTONS & OTHER INPUT ELEMENTS */ +.button, button { + display: flex; + align-items: center; + justify-content: center; + font-family: var(--font-family); + text-decoration: none; + text-align: center !important; + background: var(--glass-bg); + color: var(--color); + padding: .6rem .8rem; + outline: none; + border: 1px solid var(--glass-border-color); + border-radius: var(--glass-corner-radius); + /*backdrop-filter: var(--glass-blur); disabled for performance reasons*/ + cursor: pointer; + user-select: none; +} +.button:hover, button:hover, .button:active, button:active { + background: var(--glass-bg-hover); +} +.button:disabled, button:disabled { + opacity: 40%; +} +a { + color: var(--color); +} +input[type="number"] { -webkit-appearance: textfield; -moz-appearance: textfield; appearance: textfield; } - +input[type="number"]::-webkit-inner-spin-button { + display: none; +} +input[type="text"], input[type="password"], input[type="number"] { + background: var(--glass-bg-color2); + outline: none; + padding: .4rem .6rem; + font-size: .9rem; + color: var(--color); + text-align: center; + border: none; + border-radius: var(--glass-corner-radius); +} +/**** OTHER CLASSES ****/ +.centeringflex { + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + text-align: center; + padding: 2rem 1rem; +} +.horizontalbuttonlist { + display: flex; + flex-direction: row; + align-items: flex-end; + justify-content: space-between; +} +.horizontalbuttonlist > .button, .horizontalbuttonlist > button, .horizontalbuttonlist > div { + margin: 0 .5rem; +} .errortext { - font-weight: bold; + margin-top: 1rem; color: var(--color-error); } - .nodisplay { display: none !important; } - -/* Login */ - -.userlist-container { - flex-grow: 1; - padding-bottom: 10vh; -} - -.userlist { - width: 60%; - list-style: none; - margin: 0; - padding: 1rem; - gap: 1rem; -} - -.userlist > li { - padding: .1rem .6rem; -} - -.userlist > li > img { - margin-right: auto; - margin-left: 0; - height: 2rem; - width: 2rem; -} - -.userlist > li > div { - flex-grow: 1; - text-align: center; - padding: .7rem 1.1rem; -} - -.loginform { - gap: 1rem; - flex-direction: row; -} - -.loginform > .buttons { +.heading { margin-top: 0; } - -#passwordoverlay-container { - position: fixed; - width: 100vw; - height: 100vh; - top: 0; - right: 0; - background: var(--bg-page); - align-items: center; - padding-top: 10vh; - z-index: 200; +/* MISC / GENERAL */ +h1 { + text-align: center; + font-size: 1.8rem; } - -/* Drinks List */ - -.drinks-list { - justify-content: center; - align-items: start; - padding: 0; - width: 60%; -} - -.drinks-list > li { - flex-grow: 1; -} - -.drinks-list > li > .button { - width: 100%; - justify-content: space-between; - padding: .7rem 1.1rem; -} - -/* Statistics */ - -.statistics-container { - display: flex; - flex-direction: row; - align-items: flex-start; - justify-content: center; - flex-wrap: wrap; - max-width: 90vw; - gap: 1rem; -} - -.statistics-container > div { - height: 100%; - width: 16rem; -} - -/* Blur */ - -@supports (backdrop-filter: blur()) { - .dropdownvisible #dropdownnope { - backdrop-filter: blur(16px); +/* MOBILE OPTIMIZATIONS */ +@media only screen and (max-width: 700px) { + main { + margin-top: 2rem; } - #passwordoverlay-container { - background: #00000020; - backdrop-filter: blur(64px); /* fallback */ - backdrop-filter: blur(128px); - } -} - -/* Responsive */ - -@media only screen and (max-width: 1200px) { - .userlist { - width: 75%; - } - .drinks-list { - width: 70%; - } -} - -@media only screen and (max-width: 1000px) { - .userlist { + .globalmessage span { width: 90%; } - .drinks-list { - width: 80%; - } -} - -@media only screen and (max-width: 860px) { .userpanel { flex-direction: column; - gap: 1rem; + justify-content: start; + align-items: center; } - .userlist { - gap: 0.25rem; + .userpanel > .horizontalbuttonlist { + margin-right: 0; + margin-left: 0; + margin-top: .5rem; + justify-content: center; + flex-wrap: wrap; } - .userlist > li { - width: 100%; + .userpanel > .horizontalbuttonlist > .button, + .userpanel > .horizontalbuttonlist > .dropdownmenu { + margin: 0.25rem; } - .userlist > li > div { - margin-right: 2rem; - } - .loginform { - flex-direction: column; - } - .drinks-list { - width: 90%; - } - .dropdownlist { - width: 14rem; - right: calc(50vw - 7rem); /* regard width */ - left: auto; - } - #keyboard { - display: none !important; - } -} +} \ No newline at end of file diff --git a/static/css/simple-keyboard.css b/static/css/simple-keyboard.css deleted file mode 100644 index 7b9e413..0000000 --- a/static/css/simple-keyboard.css +++ /dev/null @@ -1,11 +0,0 @@ -/*! - * - * simple-keyboard v3.5.22 - * https://github.com/hodgef/simple-keyboard - * - * Copyright (c) Francisco Hodge (https://github.com/hodgef) and project contributors. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - * - */.hg-theme-default{background-color:#ececec;border-radius:5px;box-sizing:border-box;font-family:HelveticaNeue-Light,Helvetica Neue Light,Helvetica Neue,Helvetica,Arial,Lucida Grande,sans-serif;overflow:hidden;padding:5px;touch-action:manipulation;-webkit-user-select:none;-moz-user-select:none;user-select:none;width:100%}.hg-theme-default .hg-button span{pointer-events:none}.hg-theme-default button.hg-button{border-width:0;font-size:inherit;outline:0}.hg-theme-default .hg-button{display:inline-block;flex-grow:1}.hg-theme-default .hg-row{display:flex}.hg-theme-default .hg-row:not(:last-child){margin-bottom:5px}.hg-theme-default .hg-row .hg-button-container,.hg-theme-default .hg-row .hg-button:not(:last-child){margin-right:5px}.hg-theme-default .hg-row>div:last-child{margin-right:0}.hg-theme-default .hg-row .hg-button-container{display:flex}.hg-theme-default .hg-button{-webkit-tap-highlight-color:rgba(0,0,0,0);align-items:center;background:#fff;border-bottom:1px solid #b5b5b5;border-radius:5px;box-shadow:0 0 3px -1px rgba(0,0,0,.3);box-sizing:border-box;cursor:pointer;display:flex;height:40px;justify-content:center;padding:5px}.hg-theme-default .hg-button.hg-standardBtn{width:20px}.hg-theme-default .hg-button.hg-activeButton{background:#efefef}.hg-theme-default.hg-layout-numeric .hg-button{align-items:center;display:flex;height:60px;justify-content:center;width:33.3%}.hg-theme-default .hg-button.hg-button-numpadadd,.hg-theme-default .hg-button.hg-button-numpadenter{height:85px}.hg-theme-default .hg-button.hg-button-numpad0{width:105px}.hg-theme-default .hg-button.hg-button-com{max-width:85px}.hg-theme-default .hg-button.hg-standardBtn.hg-button-at{max-width:45px}.hg-theme-default .hg-button.hg-selectedButton{background:rgba(5,25,70,.53);color:#fff}.hg-theme-default .hg-button.hg-standardBtn[data-skbtn=".com"]{max-width:82px}.hg-theme-default .hg-button.hg-standardBtn[data-skbtn="@"]{max-width:60px}.hg-candidate-box{background:#ececec;border-bottom:2px solid #b5b5b5;border-radius:5px;display:inline-flex;margin-top:-10px;max-width:272px;position:absolute;transform:translateY(-100%);-webkit-user-select:none;-moz-user-select:none;user-select:none}ul.hg-candidate-box-list{display:flex;flex:1;list-style:none;margin:0;padding:0}li.hg-candidate-box-list-item{align-items:center;display:flex;height:40px;justify-content:center;width:40px}li.hg-candidate-box-list-item:hover{background:rgba(0,0,0,.03);cursor:pointer}li.hg-candidate-box-list-item:active{background:rgba(0,0,0,.1)}.hg-candidate-box-prev:before{content:"◄"}.hg-candidate-box-next:before{content:"►"}.hg-candidate-box-next,.hg-candidate-box-prev{align-items:center;background:#d0d0d0;color:#969696;cursor:pointer;display:flex;padding:0 10px}.hg-candidate-box-next{border-bottom-right-radius:5px;border-top-right-radius:5px}.hg-candidate-box-prev{border-bottom-left-radius:5px;border-top-left-radius:5px}.hg-candidate-box-btn-active{color:#444} \ No newline at end of file diff --git a/static/css/simple-keyboard_custom.css b/static/css/simple-keyboard_custom.css deleted file mode 100644 index 39da287..0000000 --- a/static/css/simple-keyboard_custom.css +++ /dev/null @@ -1,25 +0,0 @@ -.simple-keyboard.darkTheme.numeric { - width: 13rem; -} -.simple-keyboard.darkTheme { - width: 50rem; - max-width: 100%; - background: transparent; - font-family: "Inter"; - font-size: 16px; -} -.simple-keyboard.darkTheme .hg-button { - height: 50px; - display: flex; - justify-content: center; - align-items: center; - background: var(--bg-color); - color: white; - border: 1px solid var(--border-color); - border-radius: var(--border-radius); -} -.simple-keyboard.darkTheme .hg-button:active, -.simple-keyboard.darkTheme .hg-button:hover { - color: white; - background: var(--bg-hover-color); -} diff --git a/static/css/statistics.css b/static/css/statistics.css new file mode 100644 index 0000000..5abc145 --- /dev/null +++ b/static/css/statistics.css @@ -0,0 +1,53 @@ +.maincontainer { + min-width: 70vw; + display: flex; + flex-direction: column; + align-items: center; + justify-content: start; +} +.tablescontainer { + display: flex; + flex-direction: column; + align-items: center; + justify-content: start; + width: 95%; + margin-top: 2rem; +} +.statisticstable { + margin-bottom: 2rem; + padding-bottom: 1rem; + display: flex; + flex-direction: column; + justify-content: start; + align-items: center; + text-align: center; +} +.statisticstable h1 { + margin-top: 0; + font-size: 1.2rem; + text-align: left; + min-width: 10rem; + text-align: center; +} +.statisticstable table { + min-width: 20vw; + width: fit-content; +} +.statisticstable th:last-child { + text-align: right; +} +.statisticstable td:last-child { + text-align: right; +} +@media only screen and (max-width: 700px) { + .statisticstable h1 { + min-width: 90vw; + } + .statisticstable table { + min-width: 80vw; + } + .statisticstable { + margin-bottom: 2rem; + padding-bottom: 1rem; + } +} \ No newline at end of file diff --git a/static/fonts/Inter-Bold.ttf b/static/fonts/Inter-Bold.ttf deleted file mode 100644 index 8e82c70..0000000 Binary files a/static/fonts/Inter-Bold.ttf and /dev/null differ diff --git a/static/fonts/Inter-Regular.ttf b/static/fonts/Inter-Regular.ttf deleted file mode 100644 index 8d4eebf..0000000 Binary files a/static/fonts/Inter-Regular.ttf and /dev/null differ diff --git a/static/js/autoreload.js b/static/js/autoreload.js index a86e6ff..0c30078 100644 --- a/static/js/autoreload.js +++ b/static/js/autoreload.js @@ -1,3 +1,3 @@ setInterval(() => { location.reload(); -}, 1000*60*2); // reload after 2 minutes +}, 1000*60*2); // reload after 2 minutes \ No newline at end of file diff --git a/static/js/custom_number_input.js b/static/js/custom_number_input.js index f39d100..299e02b 100644 --- a/static/js/custom_number_input.js +++ b/static/js/custom_number_input.js @@ -1,4 +1,5 @@ -(() => { +{ + document.addEventListener("DOMContentLoaded", () => { // get all customnumberinput Elements let customNumberInputElements = document.getElementsByClassName("customnumberinput"); @@ -7,11 +8,16 @@ // number input let numberFieldElement = element.getElementsByClassName("customnumberinput-field")[0]; // minus button - element.getElementsByClassName("customnumberinput-minus")[0].addEventListener("click", () => alterCustomNumberField(numberFieldElement, -1)); + element.getElementsByClassName("customnumberinput-minus")[0].addEventListener("click", () => { + alterCustomNumberField(numberFieldElement, -1) + }); // plus button - element.getElementsByClassName("customnumberinput-plus")[0].addEventListener("click", () => alterCustomNumberField(numberFieldElement, +1)); + element.getElementsByClassName("customnumberinput-plus")[0].addEventListener("click", () => { + alterCustomNumberField(numberFieldElement, +1) + }); }) }) + function alterCustomNumberField(numberFieldElement, n) { numberFieldElement.value = Math.min( Math.max( @@ -20,4 +26,5 @@ numberFieldElement.max || Number.MAX_VALUE ); } -})(); + +} \ No newline at end of file diff --git a/static/js/custom_form.js b/static/js/deposit.js similarity index 77% rename from static/js/custom_form.js rename to static/js/deposit.js index a29f07b..4e8905d 100644 --- a/static/js/custom_form.js +++ b/static/js/deposit.js @@ -1,18 +1,28 @@ document.addEventListener("DOMContentLoaded", () => { + // elements - let customForm = document.getElementById("customform"); + + let depositForm = document.getElementById("depositform"); let statusInfo = document.getElementById("statusinfo"); - let submitButton = document.getElementById("submitbtn"); + let depositSubmitButton = document.getElementById("depositsubmitbtn"); + // event listener for deposit form // this implements a custom submit method - customForm.addEventListener("submit", (event) => { - submitButton.disabled = true; + + depositForm.addEventListener("submit", (event) => { + + depositSubmitButton.disabled = true; + event.preventDefault(); // Don't do the default submit action! + let xhr = new XMLHttpRequest(); - let formData = new FormData(customForm); + let formData = new FormData(depositForm); + xhr.addEventListener("load", (event) => { + status_ = event.target.status; response_ = event.target.responseText; + if (status_ == 200 && response_ == "success") { statusInfo.innerText = "Success. Redirecting soon."; window.location.replace("/"); @@ -22,13 +32,18 @@ document.addEventListener("DOMContentLoaded", () => { statusInfo.innerText = "An error occured. Redirecting in 5 seconds..."; window.setTimeout(() => { window.location.replace("/") }, 5000); } + }) + xhr.addEventListener("error", (event) => { statusInfo.classList.add("errortext"); statusInfo.innerText = "An error occured. Redirecting in 5 seconds..."; window.setTimeout(() => { window.location.replace("/") }, 5000); }) - xhr.open("POST", customForm.action); + + xhr.open("POST", "/api/deposit"); xhr.send(formData); + }); -}); + +}) \ No newline at end of file diff --git a/static/js/login.js b/static/js/login.js index 6f62216..47c2b9f 100644 --- a/static/js/login.js +++ b/static/js/login.js @@ -1,48 +1,87 @@ (() => { + // Define variables + let usernameInputElement; let passwordInputElement; let submitButton; let passwordOverlayElement; let pwOverlayCancelButton; let userlistButtons; + let pinpadButtons; let userlistContainerElement; + + // Add event listeners after DOM Content loaded + document.addEventListener("DOMContentLoaded", () => { + // elements + usernameInputElement = document.getElementById("id_username"); passwordInputElement = document.getElementById("id_password"); submitButton = document.getElementById("submit_login"); - passwordOverlayElement = document.getElementById("passwordoverlay-container"); + passwordOverlayElement = document.getElementById("passwordoverlaycontainer"); pwOverlayCancelButton = document.getElementById("pwocancel"); userlistContainerElement = document.getElementById("userlistcontainer"); + userlistButtons = document.getElementsByClassName("userlistbutton"); + pinpadButtons = document.getElementsByClassName("pinpadbtn"); + // event listeners + // [...] converts an html collection to an array + [...userlistButtons].forEach(element => { element.addEventListener("click", () => { set_username(element.dataset.username); show_password_overlay(); }) }); + + [...pinpadButtons].forEach(element => { + element.addEventListener("click", () => { + pinpad_press(element.dataset.btn); + }) + }) + pwOverlayCancelButton.addEventListener("click", () => { hide_password_overlay(); }); + }) + + function set_username(username) { usernameInputElement.value = username; } + function show_password_overlay() { + window.scrollTo(0, 0); passwordOverlayElement.classList.remove("nodisplay"); - passwordInputElement.focus() + userlistContainerElement.classList.add("nodisplay"); + } + function hide_password_overlay() { + passwordOverlayElement.classList.add("nodisplay"); + userlistContainerElement.classList.remove("nodisplay"); passwordInputElement.value = ""; - // Dispatch an Input Event to the input element to trigger the on- - // screen keyboard to update its buffer. This fixes a security - // issue on the login page. - passwordInputElement.dispatchEvent(new Event("input", {bubbles: true})); + } -})(); + + function pinpad_press(key) { + if (key == "enter") { + submitButton.click(); + } + else if (key == "x") { + passwordInputElement.value = ""; + } + else { + passwordInputElement.value += key; + } + } + +})() \ No newline at end of file diff --git a/static/js/main.js b/static/js/main.js index 5fab451..58b2373 100644 --- a/static/js/main.js +++ b/static/js/main.js @@ -1,21 +1,21 @@ document.addEventListener("DOMContentLoaded", () => { + let dropdownmenuElement = document.getElementById("dropdownmenu"); let dropdownmenuButtonElement = document.getElementById("dropdownmenu-button"); - let dropdownmenuNopeElement = document.getElementById("dropdownnope"); - function toggleDropDown() { - if (dropdownmenuElement.classList.contains("dropdownvisible")) { - dropdownmenuElement.classList.remove("dropdownvisible"); - dropdownmenuNopeElement.classList.remove("dropdownvisible"); - } else { - dropdownmenuElement.classList.add("dropdownvisible"); - dropdownmenuNopeElement.classList.add("dropdownvisible"); - } - } + if (dropdownmenuButtonElement != null) { - dropdownmenuButtonElement.addEventListener("click", toggleDropDown); - dropdownmenuNopeElement.addEventListener("click", () => { - dropdownmenuElement.classList.remove("dropdownvisible"); - dropdownmenuNopeElement.classList.remove("dropdownvisible"); + + dropdownmenuButtonElement.addEventListener("click", () => { + + if (dropdownmenuElement.classList.contains("dropdownvisible")) { + dropdownmenuElement.classList.remove("dropdownvisible"); + } + else { + dropdownmenuElement.classList.add("dropdownvisible"); + } + }) + } -}); + +}) \ No newline at end of file diff --git a/static/js/order.js b/static/js/order.js index 801e167..46f88b6 100644 --- a/static/js/order.js +++ b/static/js/order.js @@ -1,39 +1,61 @@ document.addEventListener("DOMContentLoaded", () => { + // elements + let orderNumberofdrinksInput = document.getElementById("numberofdrinks"); let orderNumberofdrinksBtnA = document.getElementById("numberofdrinks-btn-minus"); let orderNumberofdrinksBtnB = document.getElementById("numberofdrinks-btn-plus"); let orderSumElement = document.getElementById("ordercalculatedsum"); + let orderFormElement = document.getElementById("orderform"); let statusInfoElement = document.getElementById("statusinfo"); let orderSubmitButton = document.getElementById("ordersubmitbtn"); + + // calculate & display sum + let orderPricePerDrink = parseFloat(document.getElementById("priceperdrink").dataset.drinkPrice); + function calculateAndDisplaySum() { + setTimeout(() => { + let numberOfDrinks = parseFloat(orderNumberofdrinksInput.value); if (isNaN(numberOfDrinks)) { numberOfDrinks = 1; } let calculated_sum = orderPricePerDrink * numberOfDrinks; orderSumElement.innerText = new Intl.NumberFormat(undefined, {minimumFractionDigits: 2}).format(calculated_sum); + }, 25); + } + orderNumberofdrinksInput.addEventListener("input", calculateAndDisplaySum); orderNumberofdrinksBtnA.addEventListener("click", calculateAndDisplaySum); orderNumberofdrinksBtnB.addEventListener("click", calculateAndDisplaySum); + + // custom submit method + orderFormElement.addEventListener("submit", (event) => { + orderSubmitButton.disabled = true; + event.preventDefault(); // Don't do the default submit action! + if (isNaN(parseFloat(orderNumberofdrinksInput.value))) { orderNumberofdrinksInput.value = 1; } + let xhr = new XMLHttpRequest(); let formData = new FormData(orderFormElement); + xhr.addEventListener("load", (event) => { + status_ = event.target.status; response_ = event.target.responseText; + if (status_ == 200 && response_ == "success") { statusInfoElement.innerText = "Success."; window.location.replace("/"); @@ -43,13 +65,18 @@ document.addEventListener("DOMContentLoaded", () => { statusInfoElement.innerText = "An error occured."; window.setTimeout(() => { window.location.reload() }, 5000); } + }) + xhr.addEventListener("error", (event) => { statusInfoElement.classList.add("errortext"); statusInfoElement.innerText = "An error occured."; window.setTimeout(() => { window.location.reload() }, 5000); }) + xhr.open("POST", "/api/order-drink"); xhr.send(formData); + }); -}); + +}) \ No newline at end of file diff --git a/static/js/simple-keyboard.js b/static/js/simple-keyboard.js deleted file mode 100644 index da44a98..0000000 --- a/static/js/simple-keyboard.js +++ /dev/null @@ -1,12 +0,0 @@ -/*! - * - * simple-keyboard v3.5.22 - * https://github.com/hodgef/simple-keyboard - * - * Copyright (c) Francisco Hodge (https://github.com/hodgef) and project contributors. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - * - */ -!function(t,e){"object"==typeof exports&&"object"==typeof module?module.exports=e():"function"==typeof define&&define.amd?define([],e):"object"==typeof exports?exports.SimpleKeyboard=e():t.SimpleKeyboard=e()}(this,(function(){return function(){var t={9662:function(t,e,n){var r=n(614),o=n(6330),i=TypeError;t.exports=function(t){if(r(t))return t;throw i(o(t)+" is not a function")}},9483:function(t,e,n){var r=n(4411),o=n(6330),i=TypeError;t.exports=function(t){if(r(t))return t;throw i(o(t)+" is not a constructor")}},6077:function(t,e,n){var r=n(614),o=String,i=TypeError;t.exports=function(t){if("object"==typeof t||r(t))return t;throw i("Can't set "+o(t)+" as a prototype")}},1223:function(t,e,n){var r=n(5112),o=n(30),i=n(3070).f,a=r("unscopables"),s=Array.prototype;null==s[a]&&i(s,a,{configurable:!0,value:o(null)}),t.exports=function(t){s[a][t]=!0}},1530:function(t,e,n){"use strict";var r=n(8710).charAt;t.exports=function(t,e,n){return e+(n?r(t,e).length:1)}},9670:function(t,e,n){var r=n(111),o=String,i=TypeError;t.exports=function(t){if(r(t))return t;throw i(o(t)+" is not an object")}},8533:function(t,e,n){"use strict";var r=n(2092).forEach,o=n(9341)("forEach");t.exports=o?[].forEach:function(t){return r(this,t,arguments.length>1?arguments[1]:void 0)}},8457:function(t,e,n){"use strict";var r=n(9974),o=n(6916),i=n(7908),a=n(3411),s=n(7659),u=n(4411),c=n(6244),l=n(6135),f=n(4121),d=n(1246),p=Array;t.exports=function(t){var e=i(t),n=u(this),h=arguments.length,v=h>1?arguments[1]:void 0,y=void 0!==v;y&&(v=r(v,h>2?arguments[2]:void 0));var g,m,b,x,w,E,S=d(e),O=0;if(!S||this===p&&s(S))for(g=c(e),m=n?new this(g):p(g);g>O;O++)E=y?v(e[O],O):e[O],l(m,O,E);else for(w=(x=f(e,S)).next,m=n?new this:[];!(b=o(w,x)).done;O++)E=y?a(x,v,[b.value,O],!0):b.value,l(m,O,E);return m.length=O,m}},1318:function(t,e,n){var r=n(5656),o=n(1400),i=n(6244),a=function(t){return function(e,n,a){var s,u=r(e),c=i(u),l=o(a,c);if(t&&n!=n){for(;c>l;)if((s=u[l++])!=s)return!0}else for(;c>l;l++)if((t||l in u)&&u[l]===n)return t||l||0;return!t&&-1}};t.exports={includes:a(!0),indexOf:a(!1)}},2092:function(t,e,n){var r=n(9974),o=n(1702),i=n(8361),a=n(7908),s=n(6244),u=n(5417),c=o([].push),l=function(t){var e=1==t,n=2==t,o=3==t,l=4==t,f=6==t,d=7==t,p=5==t||f;return function(h,v,y,g){for(var m,b,x=a(h),w=i(x),E=r(v,y),S=s(w),O=0,k=g||u,I=e?k(h,S):n||d?k(h,0):void 0;S>O;O++)if((p||O in w)&&(b=E(m=w[O],O,x),t))if(e)I[O]=b;else if(b)switch(t){case 3:return!0;case 5:return m;case 6:return O;case 2:c(I,m)}else switch(t){case 4:return!1;case 7:c(I,m)}return f?-1:o||l?l:I}};t.exports={forEach:l(0),map:l(1),filter:l(2),some:l(3),every:l(4),find:l(5),findIndex:l(6),filterReject:l(7)}},1194:function(t,e,n){var r=n(7293),o=n(5112),i=n(7392),a=o("species");t.exports=function(t){return i>=51||!r((function(){var e=[];return(e.constructor={})[a]=function(){return{foo:1}},1!==e[t](Boolean).foo}))}},9341:function(t,e,n){"use strict";var r=n(7293);t.exports=function(t,e){var n=[][t];return!!n&&r((function(){n.call(null,e||function(){return 1},1)}))}},3671:function(t,e,n){var r=n(9662),o=n(7908),i=n(8361),a=n(6244),s=TypeError,u=function(t){return function(e,n,u,c){r(n);var l=o(e),f=i(l),d=a(l),p=t?d-1:0,h=t?-1:1;if(u<2)for(;;){if(p in f){c=f[p],p+=h;break}if(p+=h,t?p<0:d<=p)throw s("Reduce of empty array with no initial value")}for(;t?p>=0:d>p;p+=h)p in f&&(c=n(c,f[p],p,l));return c}};t.exports={left:u(!1),right:u(!0)}},3658:function(t,e,n){"use strict";var r=n(9781),o=n(3157),i=TypeError,a=Object.getOwnPropertyDescriptor,s=r&&!function(){if(void 0!==this)return!0;try{Object.defineProperty([],"length",{writable:!1}).length=1}catch(t){return t instanceof TypeError}}();t.exports=s?function(t,e){if(o(t)&&!a(t,"length").writable)throw i("Cannot set read only .length");return t.length=e}:function(t,e){return t.length=e}},1589:function(t,e,n){var r=n(1400),o=n(6244),i=n(6135),a=Array,s=Math.max;t.exports=function(t,e,n){for(var u=o(t),c=r(e,u),l=r(void 0===n?u:n,u),f=a(s(l-c,0)),d=0;c0;)t[r]=t[--r];r!==i++&&(t[r]=n)}return t},s=function(t,e,n,r){for(var o=e.length,i=n.length,a=0,s=0;a9007199254740991)throw e("Maximum allowed index exceeded");return t}},8324:function(t){t.exports={CSSRuleList:0,CSSStyleDeclaration:0,CSSValueList:0,ClientRectList:0,DOMRectList:0,DOMStringList:0,DOMTokenList:1,DataTransferItemList:0,FileList:0,HTMLAllCollection:0,HTMLCollection:0,HTMLFormElement:0,HTMLSelectElement:0,MediaList:0,MimeTypeArray:0,NamedNodeMap:0,NodeList:1,PaintRequestList:0,Plugin:0,PluginArray:0,SVGLengthList:0,SVGNumberList:0,SVGPathSegList:0,SVGPointList:0,SVGStringList:0,SVGTransformList:0,SourceBufferList:0,StyleSheetList:0,TextTrackCueList:0,TextTrackList:0,TouchList:0}},8509:function(t,e,n){var r=n(317)("span").classList,o=r&&r.constructor&&r.constructor.prototype;t.exports=o===Object.prototype?void 0:o},8886:function(t,e,n){var r=n(8113).match(/firefox\/(\d+)/i);t.exports=!!r&&+r[1]},256:function(t,e,n){var r=n(8113);t.exports=/MSIE|Trident/.test(r)},5268:function(t,e,n){var r=n(4326);t.exports="undefined"!=typeof process&&"process"==r(process)},8113:function(t){t.exports="undefined"!=typeof navigator&&String(navigator.userAgent)||""},7392:function(t,e,n){var r,o,i=n(7854),a=n(8113),s=i.process,u=i.Deno,c=s&&s.versions||u&&u.version,l=c&&c.v8;l&&(o=(r=l.split("."))[0]>0&&r[0]<4?1:+(r[0]+r[1])),!o&&a&&(!(r=a.match(/Edge\/(\d+)/))||r[1]>=74)&&(r=a.match(/Chrome\/(\d+)/))&&(o=+r[1]),t.exports=o},8008:function(t,e,n){var r=n(8113).match(/AppleWebKit\/(\d+)\./);t.exports=!!r&&+r[1]},748:function(t){t.exports=["constructor","hasOwnProperty","isPrototypeOf","propertyIsEnumerable","toLocaleString","toString","valueOf"]},2109:function(t,e,n){var r=n(7854),o=n(1236).f,i=n(8880),a=n(8052),s=n(3072),u=n(9920),c=n(4705);t.exports=function(t,e){var n,l,f,d,p,h=t.target,v=t.global,y=t.stat;if(n=v?r:y?r[h]||s(h,{}):(r[h]||{}).prototype)for(l in e){if(d=e[l],f=t.dontCallGetSet?(p=o(n,l))&&p.value:n[l],!c(v?l:h+(y?".":"#")+l,t.forced)&&void 0!==f){if(typeof d==typeof f)continue;u(d,f)}(t.sham||f&&f.sham)&&i(d,"sham",!0),a(n,l,d,t)}}},7293:function(t){t.exports=function(t){try{return!!t()}catch(t){return!0}}},7007:function(t,e,n){"use strict";n(4916);var r=n(1470),o=n(8052),i=n(2261),a=n(7293),s=n(5112),u=n(8880),c=s("species"),l=RegExp.prototype;t.exports=function(t,e,n,f){var d=s(t),p=!a((function(){var e={};return e[d]=function(){return 7},7!=""[t](e)})),h=p&&!a((function(){var e=!1,n=/a/;return"split"===t&&((n={}).constructor={},n.constructor[c]=function(){return n},n.flags="",n[d]=/./[d]),n.exec=function(){return e=!0,null},n[d](""),!e}));if(!p||!h||n){var v=r(/./[d]),y=e(d,""[t],(function(t,e,n,o,a){var s=r(t),u=e.exec;return u===i||u===l.exec?p&&!a?{done:!0,value:v(e,n,o)}:{done:!0,value:s(n,e,o)}:{done:!1}}));o(String.prototype,t,y[0]),o(l,d,y[1])}f&&u(l[d],"sham",!0)}},2104:function(t,e,n){var r=n(4374),o=Function.prototype,i=o.apply,a=o.call;t.exports="object"==typeof Reflect&&Reflect.apply||(r?a.bind(i):function(){return a.apply(i,arguments)})},9974:function(t,e,n){var r=n(1470),o=n(9662),i=n(4374),a=r(r.bind);t.exports=function(t,e){return o(t),void 0===e?t:i?a(t,e):function(){return t.apply(e,arguments)}}},4374:function(t,e,n){var r=n(7293);t.exports=!r((function(){var t=function(){}.bind();return"function"!=typeof t||t.hasOwnProperty("prototype")}))},6916:function(t,e,n){var r=n(4374),o=Function.prototype.call;t.exports=r?o.bind(o):function(){return o.apply(o,arguments)}},6530:function(t,e,n){var r=n(9781),o=n(2597),i=Function.prototype,a=r&&Object.getOwnPropertyDescriptor,s=o(i,"name"),u=s&&"something"===function(){}.name,c=s&&(!r||r&&a(i,"name").configurable);t.exports={EXISTS:s,PROPER:u,CONFIGURABLE:c}},1470:function(t,e,n){var r=n(4326),o=n(1702);t.exports=function(t){if("Function"===r(t))return o(t)}},1702:function(t,e,n){var r=n(4374),o=Function.prototype,i=o.call,a=r&&o.bind.bind(i,i);t.exports=r?a:function(t){return function(){return i.apply(t,arguments)}}},5005:function(t,e,n){var r=n(7854),o=n(614),i=function(t){return o(t)?t:void 0};t.exports=function(t,e){return arguments.length<2?i(r[t]):r[t]&&r[t][e]}},1246:function(t,e,n){var r=n(648),o=n(8173),i=n(8554),a=n(7497),s=n(5112)("iterator");t.exports=function(t){if(!i(t))return o(t,s)||o(t,"@@iterator")||a[r(t)]}},4121:function(t,e,n){var r=n(6916),o=n(9662),i=n(9670),a=n(6330),s=n(1246),u=TypeError;t.exports=function(t,e){var n=arguments.length<2?s(t):e;if(o(n))return i(r(n,t));throw u(a(t)+" is not iterable")}},8173:function(t,e,n){var r=n(9662),o=n(8554);t.exports=function(t,e){var n=t[e];return o(n)?void 0:r(n)}},647:function(t,e,n){var r=n(1702),o=n(7908),i=Math.floor,a=r("".charAt),s=r("".replace),u=r("".slice),c=/\$([$&'`]|\d{1,2}|<[^>]*>)/g,l=/\$([$&'`]|\d{1,2})/g;t.exports=function(t,e,n,r,f,d){var p=n+t.length,h=r.length,v=l;return void 0!==f&&(f=o(f),v=c),s(d,v,(function(o,s){var c;switch(a(s,0)){case"$":return"$";case"&":return t;case"`":return u(e,0,n);case"'":return u(e,p);case"<":c=f[u(s,1,-1)];break;default:var l=+s;if(0===l)return o;if(l>h){var d=i(l/10);return 0===d?o:d<=h?void 0===r[d-1]?a(s,1):r[d-1]+a(s,1):o}c=r[l-1]}return void 0===c?"":c}))}},7854:function(t,e,n){var r=function(t){return t&&t.Math==Math&&t};t.exports=r("object"==typeof globalThis&&globalThis)||r("object"==typeof window&&window)||r("object"==typeof self&&self)||r("object"==typeof n.g&&n.g)||function(){return this}()||Function("return this")()},2597:function(t,e,n){var r=n(1702),o=n(7908),i=r({}.hasOwnProperty);t.exports=Object.hasOwn||function(t,e){return i(o(t),e)}},3501:function(t){t.exports={}},490:function(t,e,n){var r=n(5005);t.exports=r("document","documentElement")},4664:function(t,e,n){var r=n(9781),o=n(7293),i=n(317);t.exports=!r&&!o((function(){return 7!=Object.defineProperty(i("div"),"a",{get:function(){return 7}}).a}))},8361:function(t,e,n){var r=n(1702),o=n(7293),i=n(4326),a=Object,s=r("".split);t.exports=o((function(){return!a("z").propertyIsEnumerable(0)}))?function(t){return"String"==i(t)?s(t,""):a(t)}:a},9587:function(t,e,n){var r=n(614),o=n(111),i=n(7674);t.exports=function(t,e,n){var a,s;return i&&r(a=e.constructor)&&a!==n&&o(s=a.prototype)&&s!==n.prototype&&i(t,s),t}},2788:function(t,e,n){var r=n(1702),o=n(614),i=n(5465),a=r(Function.toString);o(i.inspectSource)||(i.inspectSource=function(t){return a(t)}),t.exports=i.inspectSource},9909:function(t,e,n){var r,o,i,a=n(4811),s=n(7854),u=n(111),c=n(8880),l=n(2597),f=n(5465),d=n(6200),p=n(3501),h="Object already initialized",v=s.TypeError,y=s.WeakMap;if(a||f.state){var g=f.state||(f.state=new y);g.get=g.get,g.has=g.has,g.set=g.set,r=function(t,e){if(g.has(t))throw v(h);return e.facade=t,g.set(t,e),e},o=function(t){return g.get(t)||{}},i=function(t){return g.has(t)}}else{var m=d("state");p[m]=!0,r=function(t,e){if(l(t,m))throw v(h);return e.facade=t,c(t,m,e),e},o=function(t){return l(t,m)?t[m]:{}},i=function(t){return l(t,m)}}t.exports={set:r,get:o,has:i,enforce:function(t){return i(t)?o(t):r(t,{})},getterFor:function(t){return function(e){var n;if(!u(e)||(n=o(e)).type!==t)throw v("Incompatible receiver, "+t+" required");return n}}}},7659:function(t,e,n){var r=n(5112),o=n(7497),i=r("iterator"),a=Array.prototype;t.exports=function(t){return void 0!==t&&(o.Array===t||a[i]===t)}},3157:function(t,e,n){var r=n(4326);t.exports=Array.isArray||function(t){return"Array"==r(t)}},614:function(t,e,n){var r=n(4154),o=r.all;t.exports=r.IS_HTMLDDA?function(t){return"function"==typeof t||t===o}:function(t){return"function"==typeof t}},4411:function(t,e,n){var r=n(1702),o=n(7293),i=n(614),a=n(648),s=n(5005),u=n(2788),c=function(){},l=[],f=s("Reflect","construct"),d=/^\s*(?:class|function)\b/,p=r(d.exec),h=!d.exec(c),v=function(t){if(!i(t))return!1;try{return f(c,l,t),!0}catch(t){return!1}},y=function(t){if(!i(t))return!1;switch(a(t)){case"AsyncFunction":case"GeneratorFunction":case"AsyncGeneratorFunction":return!1}try{return h||!!p(d,u(t))}catch(t){return!0}};y.sham=!0,t.exports=!f||o((function(){var t;return v(v.call)||!v(Object)||!v((function(){t=!0}))||t}))?y:v},4705:function(t,e,n){var r=n(7293),o=n(614),i=/#|\.prototype\./,a=function(t,e){var n=u[s(t)];return n==l||n!=c&&(o(e)?r(e):!!e)},s=a.normalize=function(t){return String(t).replace(i,".").toLowerCase()},u=a.data={},c=a.NATIVE="N",l=a.POLYFILL="P";t.exports=a},5988:function(t,e,n){var r=n(111),o=Math.floor;t.exports=Number.isInteger||function(t){return!r(t)&&isFinite(t)&&o(t)===t}},8554:function(t){t.exports=function(t){return null==t}},111:function(t,e,n){var r=n(614),o=n(4154),i=o.all;t.exports=o.IS_HTMLDDA?function(t){return"object"==typeof t?null!==t:r(t)||t===i}:function(t){return"object"==typeof t?null!==t:r(t)}},1913:function(t){t.exports=!1},7850:function(t,e,n){var r=n(111),o=n(4326),i=n(5112)("match");t.exports=function(t){var e;return r(t)&&(void 0!==(e=t[i])?!!e:"RegExp"==o(t))}},2190:function(t,e,n){var r=n(5005),o=n(614),i=n(7976),a=n(3307),s=Object;t.exports=a?function(t){return"symbol"==typeof t}:function(t){var e=r("Symbol");return o(e)&&i(e.prototype,s(t))}},9212:function(t,e,n){var r=n(6916),o=n(9670),i=n(8173);t.exports=function(t,e,n){var a,s;o(t);try{if(!(a=i(t,"return"))){if("throw"===e)throw n;return n}a=r(a,t)}catch(t){s=!0,a=t}if("throw"===e)throw n;if(s)throw a;return o(a),n}},3061:function(t,e,n){"use strict";var r=n(3383).IteratorPrototype,o=n(30),i=n(9114),a=n(8003),s=n(7497),u=function(){return this};t.exports=function(t,e,n,c){var l=e+" Iterator";return t.prototype=o(r,{next:i(+!c,n)}),a(t,l,!1,!0),s[l]=u,t}},1656:function(t,e,n){"use strict";var r=n(2109),o=n(6916),i=n(1913),a=n(6530),s=n(614),u=n(3061),c=n(9518),l=n(7674),f=n(8003),d=n(8880),p=n(8052),h=n(5112),v=n(7497),y=n(3383),g=a.PROPER,m=a.CONFIGURABLE,b=y.IteratorPrototype,x=y.BUGGY_SAFARI_ITERATORS,w=h("iterator"),E="keys",S="values",O="entries",k=function(){return this};t.exports=function(t,e,n,a,h,y,I){u(n,e,a);var P,C,A,M=function(t){if(t===h&&R)return R;if(!x&&t in j)return j[t];switch(t){case E:case S:case O:return function(){return new n(this,t)}}return function(){return new n(this)}},T=e+" Iterator",D=!1,j=t.prototype,N=j[w]||j["@@iterator"]||h&&j[h],R=!x&&N||M(h),L="Array"==e&&j.entries||N;if(L&&(P=c(L.call(new t)))!==Object.prototype&&P.next&&(i||c(P)===b||(l?l(P,b):s(P[w])||p(P,w,k)),f(P,T,!0,!0),i&&(v[T]=k)),g&&h==S&&N&&N.name!==S&&(!i&&m?d(j,"name",S):(D=!0,R=function(){return o(N,this)})),h)if(C={values:M(S),keys:y?R:M(E),entries:M(O)},I)for(A in C)(x||D||!(A in j))&&p(j,A,C[A]);else r({target:e,proto:!0,forced:x||D},C);return i&&!I||j[w]===R||p(j,w,R,{name:h}),v[e]=R,C}},3383:function(t,e,n){"use strict";var r,o,i,a=n(7293),s=n(614),u=n(111),c=n(30),l=n(9518),f=n(8052),d=n(5112),p=n(1913),h=d("iterator"),v=!1;[].keys&&("next"in(i=[].keys())?(o=l(l(i)))!==Object.prototype&&(r=o):v=!0),!u(r)||a((function(){var t={};return r[h].call(t)!==t}))?r={}:p&&(r=c(r)),s(r[h])||f(r,h,(function(){return this})),t.exports={IteratorPrototype:r,BUGGY_SAFARI_ITERATORS:v}},7497:function(t){t.exports={}},6244:function(t,e,n){var r=n(7466);t.exports=function(t){return r(t.length)}},6339:function(t,e,n){var r=n(1702),o=n(7293),i=n(614),a=n(2597),s=n(9781),u=n(6530).CONFIGURABLE,c=n(2788),l=n(9909),f=l.enforce,d=l.get,p=String,h=Object.defineProperty,v=r("".slice),y=r("".replace),g=r([].join),m=s&&!o((function(){return 8!==h((function(){}),"length",{value:8}).length})),b=String(String).split("String"),x=t.exports=function(t,e,n){"Symbol("===v(p(e),0,7)&&(e="["+y(p(e),/^Symbol\(([^)]*)\)/,"$1")+"]"),n&&n.getter&&(e="get "+e),n&&n.setter&&(e="set "+e),(!a(t,"name")||u&&t.name!==e)&&(s?h(t,"name",{value:e,configurable:!0}):t.name=e),m&&n&&a(n,"arity")&&t.length!==n.arity&&h(t,"length",{value:n.arity});try{n&&a(n,"constructor")&&n.constructor?s&&h(t,"prototype",{writable:!1}):t.prototype&&(t.prototype=void 0)}catch(t){}var r=f(t);return a(r,"source")||(r.source=g(b,"string"==typeof e?e:"")),t};Function.prototype.toString=x((function(){return i(this)&&d(this).source||c(this)}),"toString")},4758:function(t){var e=Math.ceil,n=Math.floor;t.exports=Math.trunc||function(t){var r=+t;return(r>0?n:e)(r)}},3929:function(t,e,n){var r=n(7850),o=TypeError;t.exports=function(t){if(r(t))throw o("The method doesn't accept regular expressions");return t}},1574:function(t,e,n){"use strict";var r=n(9781),o=n(1702),i=n(6916),a=n(7293),s=n(1956),u=n(5181),c=n(5296),l=n(7908),f=n(8361),d=Object.assign,p=Object.defineProperty,h=o([].concat);t.exports=!d||a((function(){if(r&&1!==d({b:1},d(p({},"a",{enumerable:!0,get:function(){p(this,"b",{value:3,enumerable:!1})}}),{b:2})).b)return!0;var t={},e={},n=Symbol(),o="abcdefghijklmnopqrst";return t[n]=7,o.split("").forEach((function(t){e[t]=t})),7!=d({},t)[n]||s(d({},e)).join("")!=o}))?function(t,e){for(var n=l(t),o=arguments.length,a=1,d=u.f,p=c.f;o>a;)for(var v,y=f(arguments[a++]),g=d?h(s(y),d(y)):s(y),m=g.length,b=0;m>b;)v=g[b++],r&&!i(p,y,v)||(n[v]=y[v]);return n}:d},30:function(t,e,n){var r,o=n(9670),i=n(6048),a=n(748),s=n(3501),u=n(490),c=n(317),l=n(6200),f=l("IE_PROTO"),d=function(){},p=function(t){return"