Compare commits
12
Commits
ff9056fece
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3175067af5 | ||
|
|
66afdccfee | ||
|
|
7edf8b45cf | ||
|
|
0093d9b20d | ||
|
|
5aec0a2c2c | ||
|
|
2dcc7ebbf9 | ||
|
|
0c03348c88 | ||
|
|
768ccab09f | ||
|
|
becd73c810 | ||
|
|
d2d89c36e0 | ||
|
|
a739218fb0 | ||
|
|
7e64c5b4cf |
@@ -1,9 +1,400 @@
|
||||
# lapp
|
||||
# L@pp
|
||||
|
||||
list app
|
||||
L@pp is a small, mobile-friendly shared-list application built with Flask. It is
|
||||
designed primarily for grocery lists, but works just as well for chores, packing
|
||||
lists, or any other lightweight checklist.
|
||||
|
||||
install
|
||||
1. clone repo
|
||||
2. create subfolder "instance" to store database file
|
||||
3. for an initial instance: run python3 initialze_data.py, with the python from the virtual-env
|
||||
4. add the below config to your apache2 enabled site
|
||||
Users can create and share lists, add items with quantities and categories, and
|
||||
check items off from a phone-friendly interface. The included web app can also
|
||||
be installed as a Progressive Web App (PWA).
|
||||
|
||||
## Features
|
||||
|
||||
- Multiple lists per user
|
||||
- List sharing between registered users
|
||||
- Item quantities and units, including input such as `Milk 2 cartons`
|
||||
- Grocery categories with icons
|
||||
- Reusable suggestions for previously completed items
|
||||
- Bulk item entry
|
||||
- Responsive, installable PWA interface
|
||||
- User registration with group invitation keys
|
||||
- Administrator approval for new accounts
|
||||
- Session-based JSON API for companion clients
|
||||
- SQLite by default, with other SQLAlchemy database URLs supported
|
||||
|
||||
## Requirements
|
||||
|
||||
- Python 3.9 or newer
|
||||
- `pip` and Python virtual-environment support
|
||||
|
||||
## Quick start
|
||||
|
||||
Clone the repository and enter its directory:
|
||||
|
||||
```bash
|
||||
git clone <repository-url>
|
||||
cd lapp
|
||||
```
|
||||
|
||||
Create a virtual environment and install the dependencies:
|
||||
|
||||
```bash
|
||||
python3 -m venv .venv
|
||||
source .venv/bin/activate
|
||||
python -m pip install --upgrade pip
|
||||
python -m pip install -r requirement.txt
|
||||
```
|
||||
|
||||
Initialize the database with an administrator, a regular user, two groups, and
|
||||
some example lists:
|
||||
|
||||
```bash
|
||||
python initialize_data.py
|
||||
```
|
||||
|
||||
The command creates `instance/secrets.yaml` and `instance/app.db`. It does not
|
||||
print the generated passwords or registration keys; open
|
||||
`instance/secrets.yaml` locally to retrieve them. The seeded usernames are
|
||||
`admin` and `ignace`.
|
||||
|
||||
Start the development server:
|
||||
|
||||
```bash
|
||||
python lapp.py
|
||||
```
|
||||
|
||||
Then open <http://127.0.0.1:5001>. The built-in server runs in debug mode and is
|
||||
intended for local development only.
|
||||
|
||||
Running the application without `initialize_data.py` creates an empty database
|
||||
schema, but no users or registration groups.
|
||||
|
||||
## Configuration and secrets
|
||||
|
||||
Private configuration lives in `instance/secrets.yaml`. On first use, missing
|
||||
values are generated automatically and the file is restricted to mode `0600`.
|
||||
A generated file has this structure:
|
||||
|
||||
```yaml
|
||||
runtime:
|
||||
secret_key: <Flask session secret>
|
||||
application_key: <key used by /api/validate_key>
|
||||
database_uri: sqlite:///app.db
|
||||
initial_data:
|
||||
admin_password: <initial admin password>
|
||||
admin_group: <initial admin registration key>
|
||||
user_password: <initial user password>
|
||||
user_group: <initial user registration key>
|
||||
fernet:
|
||||
key: <Fernet key used by test.py>
|
||||
```
|
||||
|
||||
For SQLite, relative database paths are resolved inside Flask's `instance/`
|
||||
directory. To use another database, set `runtime.database_uri` to a compatible
|
||||
SQLAlchemy URL and install the corresponding database driver.
|
||||
|
||||
Keep `runtime.secret_key` stable. Replacing it invalidates active sessions and
|
||||
the seven-day auto-login cookie. The `runtime.application_key` is checked by
|
||||
`POST /api/validate_key`.
|
||||
|
||||
The complete `instance/` directory is ignored by Git. Back it up securely and
|
||||
never commit or share its contents. In particular, back up both `app.db` and
|
||||
`secrets.yaml` together when using the default SQLite setup.
|
||||
|
||||
### Customizing the initial accounts
|
||||
|
||||
To choose credentials instead of using generated values:
|
||||
|
||||
1. Generate the configuration and empty schema without seeding accounts:
|
||||
|
||||
```bash
|
||||
python -c 'from lapp import create_app; create_app()'
|
||||
```
|
||||
|
||||
2. Edit the values under `initial_data` in `instance/secrets.yaml`.
|
||||
3. Run `python initialize_data.py`.
|
||||
|
||||
Initialization is deliberately idempotent: if the database already contains a
|
||||
user, no seed data is added. It also refuses to initialize a partially populated
|
||||
database.
|
||||
|
||||
## How accounts and sharing work
|
||||
|
||||
Registration requires a group key. The two keys created during initialization
|
||||
are stored as `initial_data.admin_group` and `initial_data.user_group`. A newly
|
||||
registered account remains pending until an administrator approves it at
|
||||
`/admin/`.
|
||||
|
||||
After signing in, a user can create, activate, rename, share, or delete their own
|
||||
lists. A shared list is visible and editable by every selected user. Only the
|
||||
owner can change the list itself or its sharing settings.
|
||||
|
||||
Administrators can create additional registration groups from `/groups`.
|
||||
|
||||
## Item input
|
||||
|
||||
L@pp extracts simple quantities and units from item text. For example:
|
||||
|
||||
| Input | Label | Quantity | Unit |
|
||||
| --- | --- | ---: | --- |
|
||||
| `Apples 4` | Apples | 4 | `x` |
|
||||
| `Milk 2 cartons` | Milk | 2 | `cartons` |
|
||||
| `3 kg potatoes` | Potatoes | 3 | `kg` |
|
||||
|
||||
Cleaning a list moves checked items into its suggestion history. Those items can
|
||||
then be added again quickly from the add-items screen.
|
||||
|
||||
## API overview
|
||||
|
||||
API requests use form-encoded fields and return JSON. Authentication is stored
|
||||
in the Flask session cookie, so API clients must retain cookies after login.
|
||||
|
||||
| Method | Endpoint | Purpose |
|
||||
| --- | --- | --- |
|
||||
| `POST` | `/api/validate_key` | Validate the configured application key (`key`) |
|
||||
| `POST` | `/api/login` | Sign in with `user` and `password` |
|
||||
| `POST` | `/api/register` | Register with `user` and a group key in `password` |
|
||||
| `POST` | `/api/load_lists` | Load lists available to the signed-in user |
|
||||
| `POST` | `/api/load_items` | Load active items for `listid` |
|
||||
|
||||
Responses use a numeric `status` field: `1` means success, `0` means failure,
|
||||
and registration uses `2` to indicate that approval is pending.
|
||||
|
||||
Example login with a cookie jar:
|
||||
|
||||
```bash
|
||||
curl -c cookies.txt \
|
||||
-X POST \
|
||||
-d 'user=ignace' \
|
||||
-d 'password=YOUR_PASSWORD' \
|
||||
http://127.0.0.1:5001/api/login
|
||||
|
||||
curl -b cookies.txt \
|
||||
-X POST \
|
||||
http://127.0.0.1:5001/api/load_lists
|
||||
```
|
||||
|
||||
## Production deployment with Nginx and Gunicorn
|
||||
|
||||
The following example targets Debian or Ubuntu, installs L@pp in `/srv/lapp`,
|
||||
and serves it from `lists.example.com`. Substitute your own installation path
|
||||
and domain where necessary.
|
||||
|
||||
Gunicorn loads the existing Flask application factory as
|
||||
`lapp:create_app()`. The repository's `lapp.wsgi` file is only needed for a
|
||||
mod_wsgi deployment and is not used here.
|
||||
|
||||
### 1. Install the system packages
|
||||
|
||||
```bash
|
||||
sudo apt update
|
||||
sudo apt install nginx python3 python3-venv
|
||||
```
|
||||
|
||||
Create a dedicated, unprivileged service account:
|
||||
|
||||
```bash
|
||||
sudo useradd --system \
|
||||
--home /srv/lapp \
|
||||
--shell /usr/sbin/nologin \
|
||||
lapp
|
||||
```
|
||||
|
||||
Place or clone the repository at `/srv/lapp`, then install the application and
|
||||
Gunicorn:
|
||||
|
||||
```bash
|
||||
cd /srv/lapp
|
||||
|
||||
sudo python3 -m venv .venv
|
||||
sudo .venv/bin/pip install --upgrade pip
|
||||
sudo .venv/bin/pip install -r requirement.txt
|
||||
sudo .venv/bin/pip install 'gunicorn>=23,<24'
|
||||
```
|
||||
|
||||
Gunicorn should run as the service account, not as `root`, and should only be
|
||||
reachable through Nginx.
|
||||
|
||||
### 2. Initialize the application
|
||||
|
||||
Create the private instance directory with permissions that allow the service
|
||||
account to maintain the SQLite database:
|
||||
|
||||
```bash
|
||||
sudo install -d \
|
||||
-o lapp \
|
||||
-g www-data \
|
||||
-m 0750 \
|
||||
/srv/lapp/instance
|
||||
|
||||
sudo -u lapp /srv/lapp/.venv/bin/python \
|
||||
/srv/lapp/initialize_data.py
|
||||
```
|
||||
|
||||
View the generated passwords and registration keys locally:
|
||||
|
||||
```bash
|
||||
sudo -u lapp sed -n '1,120p' \
|
||||
/srv/lapp/instance/secrets.yaml
|
||||
```
|
||||
|
||||
Back up `instance/app.db` and `instance/secrets.yaml` together. Do not expose
|
||||
the `instance/` directory through Nginx.
|
||||
|
||||
### 3. Run Gunicorn with systemd
|
||||
|
||||
Create `/etc/systemd/system/lapp.service`:
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Description=L@pp service
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=lapp
|
||||
Group=www-data
|
||||
WorkingDirectory=/srv/lapp
|
||||
|
||||
RuntimeDirectory=lapp
|
||||
RuntimeDirectoryMode=0750
|
||||
UMask=0007
|
||||
|
||||
ExecStart=/srv/lapp/.venv/bin/gunicorn \
|
||||
--workers 2 \
|
||||
--bind unix:/run/lapp/lapp.sock \
|
||||
--access-logfile - \
|
||||
--error-logfile - \
|
||||
lapp:create_app()
|
||||
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
PrivateTmp=true
|
||||
NoNewPrivileges=true
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
One or two workers is a sensible starting point for this small SQLite
|
||||
application. Adding many workers can increase SQLite write contention.
|
||||
|
||||
Enable and start the service:
|
||||
|
||||
```bash
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now lapp
|
||||
sudo systemctl status lapp
|
||||
```
|
||||
|
||||
Follow its logs or verify that its socket exists:
|
||||
|
||||
```bash
|
||||
sudo journalctl -u lapp -f
|
||||
sudo ls -l /run/lapp/lapp.sock
|
||||
```
|
||||
|
||||
### 4. Configure Nginx
|
||||
|
||||
Create `/etc/nginx/sites-available/lapp`:
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
|
||||
server_name lapp.suy.nl;
|
||||
|
||||
client_max_body_size 2m;
|
||||
|
||||
location /static/ {
|
||||
alias /srv/lapp/static/;
|
||||
expires 7d;
|
||||
add_header Cache-Control "public";
|
||||
}
|
||||
|
||||
location / {
|
||||
proxy_pass http://unix:/run/lapp/lapp.sock;
|
||||
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
proxy_read_timeout 60s;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Enable the site and validate the configuration before reloading Nginx:
|
||||
|
||||
```bash
|
||||
sudo ln -s /etc/nginx/sites-available/lapp \
|
||||
/etc/nginx/sites-enabled/lapp
|
||||
|
||||
sudo nginx -t
|
||||
sudo systemctl reload nginx
|
||||
```
|
||||
|
||||
The application should now be available at `http://lists.example.com`.
|
||||
|
||||
### 5. Enable HTTPS
|
||||
|
||||
Once the domain's DNS records point to the server, obtain a TLS certificate
|
||||
with your preferred ACME client. For example, using Certbot:
|
||||
|
||||
```bash
|
||||
sudo apt install certbot python3-certbot-nginx
|
||||
sudo certbot --nginx -d lists.example.com
|
||||
```
|
||||
|
||||
Do not expose L@pp publicly over plain HTTP: it handles passwords and persistent
|
||||
login cookies.
|
||||
|
||||
### Updating a deployment
|
||||
|
||||
Back up the database and secrets first. Then update the source and dependencies
|
||||
and restart Gunicorn:
|
||||
|
||||
```bash
|
||||
cd /srv/lapp
|
||||
sudo .venv/bin/pip install -r requirement.txt
|
||||
sudo systemctl restart lapp
|
||||
sudo systemctl status lapp
|
||||
```
|
||||
|
||||
See the official [Flask Gunicorn deployment guide](https://flask.palletsprojects.com/en/stable/deploying/gunicorn/),
|
||||
[Gunicorn documentation](https://docs.gunicorn.org/en/stable/run.html), and
|
||||
[Nginx proxy module documentation](https://nginx.org/en/docs/http/ngx_http_proxy_module.html)
|
||||
for additional configuration options.
|
||||
|
||||
## Utility scripts
|
||||
|
||||
- `initialize_data.py` safely seeds a new database.
|
||||
- `show_db.py` prints all database records for local diagnostics. Its output
|
||||
includes password hashes and group secrets, so treat it as sensitive.
|
||||
- `update_db.py` contains historical, manual migration snippets. Review and
|
||||
adapt it before running it against any database.
|
||||
- `test.py` is a Fernet encryption example, not an automated test suite.
|
||||
|
||||
## Project structure
|
||||
|
||||
```text
|
||||
lapp.py Application factory and blueprint registration
|
||||
auth.py Login, registration, logout, and password changes
|
||||
admin.py Account approval
|
||||
groups.py Registration-group management
|
||||
lists.py List creation, ownership, and sharing
|
||||
items.py Item entry, categories, quantities, and completion
|
||||
api.py Session-based JSON API
|
||||
models.py SQLAlchemy models
|
||||
templates/ Jinja templates
|
||||
static/ CSS, JavaScript, icons, and PWA manifest
|
||||
instance/ Local secrets and database (generated, ignored by Git)
|
||||
```
|
||||
|
||||
## Development notes
|
||||
|
||||
There is currently no automated test suite or migration framework. Before
|
||||
upgrading an existing installation, back up the database and secrets, then test
|
||||
the change on a copy of the data.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from flask import Blueprint, render_template, redirect, url_for, request
|
||||
from flask import Blueprint, render_template, redirect, url_for, request, current_app
|
||||
from flask_login import login_required, current_user, login_user
|
||||
from models import db
|
||||
from models import User, Item
|
||||
@@ -14,7 +14,7 @@ def validate_key():
|
||||
result['data'] = ''
|
||||
if "key" in request.form:
|
||||
key = request.form["key"]
|
||||
if key == 'NogNietNodigHier':
|
||||
if key == current_app.config["APPLICATION_KEY"]:
|
||||
result['status'] = 1
|
||||
else:
|
||||
result['message'] = 'Unknown Application-Key'
|
||||
|
||||
@@ -1,64 +1,35 @@
|
||||
from flask import Blueprint, render_template, request, redirect, url_for, flash, make_response
|
||||
from flask import Blueprint, render_template, request, redirect, url_for, flash, make_response, current_app
|
||||
from flask_login import login_user, logout_user, login_required, current_user
|
||||
from models import db
|
||||
from models import User
|
||||
from log import Log
|
||||
import pickle
|
||||
from cryptography.fernet import Fernet
|
||||
import datetime
|
||||
from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer
|
||||
|
||||
|
||||
auth_bp = Blueprint("auth", __name__)
|
||||
|
||||
# =====================================================
|
||||
# SECRET KEY (only for this program)
|
||||
# =====================================================
|
||||
# Generate once using: Fernet.generate_key()
|
||||
_SECRET_KEY = b'YFK7QCyTzhyLO4vqrnRxvDAI5uu8mXEYrInEjbRoQgs='
|
||||
fernet = Fernet(_SECRET_KEY)
|
||||
AUTOLOGIN_MAX_AGE_SECONDS = 7 * 24 * 60 * 60
|
||||
AUTOLOGIN_SALT = "lapp-autologin"
|
||||
|
||||
# =====================================================
|
||||
# ENCRYPT
|
||||
# =====================================================
|
||||
def encrypt_object(obj) -> bytes:
|
||||
"""
|
||||
Encrypt any Python object and return encrypted bytes.
|
||||
"""
|
||||
serialized = pickle.dumps(obj)
|
||||
encrypted = fernet.encrypt(serialized)
|
||||
return encrypted
|
||||
|
||||
# =====================================================
|
||||
# DECRYPT
|
||||
# =====================================================
|
||||
def decrypt_object(encrypted_data: bytes):
|
||||
"""
|
||||
Decrypt bytes back into the original Python object.
|
||||
"""
|
||||
decrypted = fernet.decrypt(encrypted_data)
|
||||
obj = pickle.loads(decrypted)
|
||||
return obj
|
||||
def autologin_serializer():
|
||||
return URLSafeTimedSerializer(current_app.config["SECRET_KEY"], salt=AUTOLOGIN_SALT)
|
||||
|
||||
def create_cookie(userid):
|
||||
cookie_data = {
|
||||
"user": userid,
|
||||
"datetime": datetime.datetime.now()
|
||||
"user": userid
|
||||
}
|
||||
return encrypt_object(cookie_data).decode('utf-8')
|
||||
return autologin_serializer().dumps(cookie_data)
|
||||
|
||||
def is_cookie_ok_to_autologin(cookie_content):
|
||||
user = 0
|
||||
autologin = False
|
||||
if not cookie_content:
|
||||
return 0
|
||||
|
||||
try:
|
||||
cookie_data = decrypt_object(cookie_content)
|
||||
user = cookie_data["user"]
|
||||
dt = cookie_data["datetime"]
|
||||
lastweek = datetime.datetime.now() - datetime.timedelta(days=7)
|
||||
autologin = dt > lastweek
|
||||
except:
|
||||
cookie_data = autologin_serializer().loads(cookie_content,
|
||||
max_age=AUTOLOGIN_MAX_AGE_SECONDS)
|
||||
return int(cookie_data["user"])
|
||||
except (BadSignature, SignatureExpired, KeyError, TypeError, ValueError):
|
||||
Log.info("Cookie error")
|
||||
if autologin:
|
||||
return user
|
||||
return 0
|
||||
|
||||
#. @auth_bp.route("/", methods=["GET", "POST"])
|
||||
@@ -81,11 +52,16 @@ def login():
|
||||
if user.group_id>0:
|
||||
login_user(user)
|
||||
return redirect(url_for("lists.home"))
|
||||
flash("Invalid key credentials.")
|
||||
# flash("Invalid key credentials.")
|
||||
Log.info(f"Authorization failure for user '{request.form["name"]}' ")
|
||||
|
||||
|
||||
resp = make_response(render_template("login.html"))
|
||||
login_failed = request.method == "POST"
|
||||
resp = make_response(render_template(
|
||||
"login.html",
|
||||
name_placeholder="Sorry" if login_failed else "Name",
|
||||
password_placeholder="Try again" if login_failed else "Password",
|
||||
))
|
||||
resp.set_cookie('username', '')
|
||||
return resp
|
||||
|
||||
@@ -129,14 +105,16 @@ def change_pwd():
|
||||
pwd_old = request.form["password_old"]
|
||||
pwd_new1 = request.form["password_new1"]
|
||||
pwd_new2 = request.form["password_new2"]
|
||||
if current_user.check_password(pwd_old):
|
||||
if pwd_new1 == pwd_new2:
|
||||
if not current_user.check_password(pwd_old):
|
||||
flash("Invalid old credentials")
|
||||
Log.info(f"Authorization failure for user '{current_user.name}' ")
|
||||
elif pwd_new1 != pwd_new2:
|
||||
flash("New Passwords are not identical")
|
||||
else:
|
||||
current_user.set_password(pwd_new2)
|
||||
db.session.commit()
|
||||
return redirect(url_for("lists.home"))
|
||||
flash("New Passwords are not identical")
|
||||
flash("Invalid credentials")
|
||||
Log.info(f"Authorization failure for user '{request.form["name"]}' ")
|
||||
|
||||
return render_template(url_for("lists.home"))
|
||||
return redirect(url_for("auth.change_pwd"))
|
||||
|
||||
return render_template("change_pwd.html")
|
||||
|
||||
+100
-35
@@ -1,38 +1,103 @@
|
||||
from lapp import create_app
|
||||
import sqlalchemy as sa
|
||||
from models import db, User, ListOfItems, Shared, Item, Group
|
||||
|
||||
from lapp import create_app
|
||||
from models import Group, Item, ListOfItems, Shared, User, db
|
||||
from secrets_config import load_secrets
|
||||
|
||||
|
||||
def has_rows(model):
|
||||
primary_key = next(iter(model.__table__.primary_key.columns))
|
||||
return db.session.scalar(sa.select(primary_key).limit(1)) is not None
|
||||
|
||||
|
||||
def initialize(app):
|
||||
initial_data = load_secrets(app.instance_path)["initial_data"]
|
||||
|
||||
with app.app_context():
|
||||
with db.session.begin():
|
||||
if has_rows(User):
|
||||
return False
|
||||
|
||||
partially_populated = [
|
||||
model.__tablename__
|
||||
for model in (Group, ListOfItems, Item, Shared)
|
||||
if has_rows(model)
|
||||
]
|
||||
if partially_populated:
|
||||
tables = ", ".join(partially_populated)
|
||||
raise RuntimeError(
|
||||
"Refusing to initialize a partially populated database. "
|
||||
f"These tables contain data while user is empty: {tables}"
|
||||
)
|
||||
|
||||
admin_group = Group(secret=initial_data["admin_group"])
|
||||
user_group = Group(secret=initial_data["user_group"])
|
||||
db.session.add_all([admin_group, user_group])
|
||||
db.session.flush()
|
||||
|
||||
admin = User(
|
||||
name="admin",
|
||||
group_id=admin_group.id,
|
||||
is_admin=True,
|
||||
is_approved=True,
|
||||
is_private=True,
|
||||
)
|
||||
admin.set_password(initial_data["admin_password"])
|
||||
|
||||
user = User(
|
||||
name="ignace",
|
||||
group_id=user_group.id,
|
||||
is_admin=False,
|
||||
is_approved=True,
|
||||
)
|
||||
user.set_password(initial_data["user_password"])
|
||||
db.session.add_all([admin, user])
|
||||
db.session.flush()
|
||||
|
||||
groceries = ListOfItems(
|
||||
owner_user_id=user.id,
|
||||
name="Supermarkt",
|
||||
is_active=True,
|
||||
)
|
||||
chores = ListOfItems(
|
||||
owner_user_id=user.id,
|
||||
name="Klusjes",
|
||||
is_active=True,
|
||||
)
|
||||
db.session.add_all([groceries, chores])
|
||||
db.session.flush()
|
||||
|
||||
db.session.add_all([
|
||||
Item(listofitems_id=groceries.id, label="Stokbrood"),
|
||||
Item(
|
||||
listofitems_id=groceries.id,
|
||||
label="Speltbroodje",
|
||||
category="Brood",
|
||||
),
|
||||
Item(
|
||||
listofitems_id=groceries.id,
|
||||
label="Boter",
|
||||
category="Zuivel",
|
||||
unit="x",
|
||||
quantity=1,
|
||||
),
|
||||
Item(
|
||||
listofitems_id=groceries.id,
|
||||
label="Notenbroodjes",
|
||||
is_checked=True,
|
||||
),
|
||||
])
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def main():
|
||||
app = create_app()
|
||||
if initialize(app):
|
||||
print("Created initial credentials from instance/secrets.yaml")
|
||||
else:
|
||||
print("Database already contains users; no initial data was added")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app = create_app()
|
||||
app.app_context().push()
|
||||
query = sa.select(User)
|
||||
users = db.session.scalars(query).all()
|
||||
if len(users) == 0:
|
||||
g = Group(secret="This is the group just for the admin")
|
||||
db.session.add(g)
|
||||
g = Group(secret="GroupForIgnaceAndLoversThatDoGroceries")
|
||||
db.session.add(g)
|
||||
admin = User(name="admin", group_id=1, is_admin=True, is_approved=True, is_private=True)
|
||||
admin.set_password("suy2025")
|
||||
db.session.add(admin)
|
||||
user1 = User(name="ignace", group_id=2, is_admin=False, is_approved=True)
|
||||
user1.set_password("xanderj2")
|
||||
db.session.add(user1)
|
||||
db.session.commit()
|
||||
|
||||
lol1 = ListOfItems(owner_user_id = user1.id, name="Supermarkt", is_active=True)
|
||||
db.session.add(lol1)
|
||||
lol2 = ListOfItems(owner_user_id = user1.id, name="Klusjes", is_active=True)
|
||||
db.session.add(lol2)
|
||||
db.session.commit()
|
||||
|
||||
item1 = Item(listofitems_id=lol1.id, label="Stokbrood")
|
||||
item2 = Item(listofitems_id=lol1.id, label="Speltbroodje", category="Brood")
|
||||
item3 = Item(listofitems_id=lol1.id, label="Boter", category="Zuivel", unit='x', quantity=1)
|
||||
item4 = Item(listofitems_id=lol1.id, label="Notenbroodjes", is_checked=True)
|
||||
db.session.add(item1)
|
||||
db.session.add(item2)
|
||||
db.session.add(item3)
|
||||
db.session.add(item4)
|
||||
|
||||
db.session.commit()
|
||||
main()
|
||||
|
||||
@@ -3,6 +3,7 @@ from flask_login import login_required, current_user
|
||||
from models import db
|
||||
from models import Item, ListOfItems
|
||||
from sqlalchemy import desc, text
|
||||
from datetime import datetime
|
||||
import os
|
||||
from log import Log
|
||||
from auth import create_cookie
|
||||
@@ -23,6 +24,28 @@ def require_item_access(itemid):
|
||||
abort(403)
|
||||
return item
|
||||
|
||||
def client_update_time():
|
||||
client_updated_at = request.args.get("client_updated_at")
|
||||
if not client_updated_at:
|
||||
return None
|
||||
|
||||
try:
|
||||
return datetime.fromtimestamp(int(client_updated_at) / 1000)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
def item_update_is_current(item):
|
||||
updated_at = client_update_time()
|
||||
if updated_at is None:
|
||||
return True
|
||||
|
||||
return item.updated_at is None or updated_at >= item.updated_at
|
||||
|
||||
def stamp_item_update(item):
|
||||
updated_at = client_update_time()
|
||||
if updated_at is not None:
|
||||
item.updated_at = updated_at
|
||||
|
||||
# home screen for items
|
||||
@items_bp.route("/items/<listid>")
|
||||
@login_required
|
||||
@@ -42,7 +65,10 @@ def items(listid):
|
||||
@login_required
|
||||
def item_update(itemid, itemchecked):
|
||||
i = require_item_access(itemid)
|
||||
if not item_update_is_current(i):
|
||||
return '', 204
|
||||
i.is_checked = True if itemchecked == 'true' else False
|
||||
stamp_item_update(i)
|
||||
db.session.commit()
|
||||
return '', 204
|
||||
|
||||
@@ -170,6 +196,8 @@ def items_multiappend(listid):
|
||||
@login_required
|
||||
def item_delete(itemid):
|
||||
i = require_item_access(itemid)
|
||||
if not item_update_is_current(i):
|
||||
return redirect(url_for("items.items_append", listid=i.listofitems_id))
|
||||
listid = i.listofitems_id
|
||||
db.session.delete(i)
|
||||
db.session.commit()
|
||||
@@ -195,6 +223,12 @@ def items_clean(listid):
|
||||
@login_required
|
||||
def item_addone(itemid):
|
||||
i = require_item_access(itemid)
|
||||
if not item_update_is_current(i):
|
||||
resp = make_response(render_template("items_append.html", user=current_user,
|
||||
items=Item.query.filter_by(listofitems_id=i.listofitems_id).order_by(Item.label).all(),
|
||||
logo_url=url_for("items.items", listid=i.listofitems_id)))
|
||||
resp.set_cookie('username', create_cookie(current_user.id))
|
||||
return resp
|
||||
listid = i.listofitems_id
|
||||
# if it is checked or not on the list yet, set quantity to one
|
||||
if i.is_suggestion or i.is_checked:
|
||||
@@ -208,6 +242,7 @@ def item_addone(itemid):
|
||||
# if no unit given, make it 'x'
|
||||
if not i.unit:
|
||||
i.unit = 'x'
|
||||
stamp_item_update(i)
|
||||
db.session.commit()
|
||||
|
||||
resp = make_response(render_template("items_append.html", user=current_user,
|
||||
@@ -221,6 +256,12 @@ def item_addone(itemid):
|
||||
@login_required
|
||||
def item_addquantity(itemid, quantity):
|
||||
i = require_item_access(itemid)
|
||||
if not item_update_is_current(i):
|
||||
resp = make_response(render_template("items_append.html", user=current_user,
|
||||
items=Item.query.filter_by(listofitems_id=i.listofitems_id).order_by(Item.label).all(),
|
||||
logo_url=url_for("items.items", listid=i.listofitems_id)))
|
||||
resp.set_cookie('username', create_cookie(current_user.id))
|
||||
return resp
|
||||
listid = i.listofitems_id
|
||||
# always add the item to the active todo
|
||||
if i.is_suggestion or i.is_checked:
|
||||
@@ -229,6 +270,7 @@ def item_addquantity(itemid, quantity):
|
||||
i.is_checked = False
|
||||
else:
|
||||
i.quantity += int(quantity)
|
||||
stamp_item_update(i)
|
||||
db.session.commit()
|
||||
|
||||
resp = make_response(render_template("items_append.html", user=current_user,
|
||||
@@ -242,6 +284,9 @@ def item_addquantity(itemid, quantity):
|
||||
@login_required
|
||||
def item_upitem_update_categorydate(itemid, category):
|
||||
i = require_item_access(itemid)
|
||||
if not item_update_is_current(i):
|
||||
return '', 204
|
||||
i.category = category
|
||||
stamp_item_update(i)
|
||||
db.session.commit()
|
||||
return '', 204
|
||||
|
||||
@@ -8,13 +8,16 @@ from lists import lists_bp
|
||||
from items import items_bp
|
||||
from api import api_bp
|
||||
from groups import groups_bp
|
||||
from secrets_config import load_secrets
|
||||
|
||||
login_manager = LoginManager()
|
||||
|
||||
def create_app():
|
||||
app = Flask(__name__)
|
||||
app.config["SECRET_KEY"] = "9823740934riurehoiuwerf873487qe78feri"
|
||||
app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///app.db"
|
||||
app = Flask(__name__, instance_relative_config=True)
|
||||
secret_values = load_secrets(app.instance_path)
|
||||
app.config["SECRET_KEY"] = secret_values["runtime"]["secret_key"]
|
||||
app.config["APPLICATION_KEY"] = secret_values["runtime"]["application_key"]
|
||||
app.config["SQLALCHEMY_DATABASE_URI"] = secret_values["runtime"]["database_uri"]
|
||||
|
||||
db.init_app(app)
|
||||
login_manager.init_app(app)
|
||||
|
||||
@@ -37,12 +37,7 @@ def edit():
|
||||
abort(403)
|
||||
|
||||
newname = request.form["name"].strip()
|
||||
double = False
|
||||
for d in ListOfItems.query.filter_by(owner_user_id=current_user.id, name=newname).all():
|
||||
if not d.id == ilist_id:
|
||||
# there is a duplicate name
|
||||
double = True
|
||||
if double:
|
||||
if not newname or ListOfItems.name_exists_for_owner(current_user.id, newname, exclude_id=ilist_id):
|
||||
return render_template("lists_edit.html", user=current_user,
|
||||
ilists=current_user.my_owned_lists(with_inactive=True),
|
||||
users=not_current_user(current_user.id),
|
||||
@@ -76,8 +71,7 @@ def edit():
|
||||
# must be a new list
|
||||
newlist = ListOfItems(name=request.form["name"].strip(), owner_user_id=current_user.id)
|
||||
# check if name exists
|
||||
duplicate = ListOfItems.query.filter_by(owner_user_id=current_user.id, name=newlist.name).first()
|
||||
if duplicate:
|
||||
if not newlist.name or ListOfItems.name_exists_for_owner(current_user.id, newlist.name):
|
||||
# there is a duplicate name
|
||||
return render_template("lists_edit.html", user=current_user,
|
||||
ilists=current_user.my_owned_lists(with_inactive=True),
|
||||
|
||||
@@ -2,12 +2,13 @@ from flask_sqlalchemy import SQLAlchemy
|
||||
from flask_login import UserMixin
|
||||
from werkzeug.security import generate_password_hash, check_password_hash
|
||||
from datetime import datetime
|
||||
from sqlalchemy import func
|
||||
|
||||
db = SQLAlchemy() # Create the extension object
|
||||
|
||||
class User(UserMixin, db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
updated_at = db.Column(db.TIMESTAMP, default=datetime.now(), onupdate=datetime.now(), nullable=False)
|
||||
updated_at = db.Column(db.TIMESTAMP, default=datetime.now, onupdate=datetime.now, nullable=False)
|
||||
group_id = db.Column(db.Integer, default=0, nullable=False)
|
||||
name = db.Column(db.String(40), unique=True, nullable=False)
|
||||
password_hash = db.Column(db.String(256))
|
||||
@@ -72,12 +73,26 @@ class User(UserMixin, db.Model):
|
||||
return 0
|
||||
|
||||
class ListOfItems(db.Model):
|
||||
__table_args__ = (
|
||||
db.UniqueConstraint("owner_user_id", "name", name="uq_list_owner_name"),
|
||||
)
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
updated_at = db.Column(db.TIMESTAMP, default=datetime.now(), onupdate=datetime.now(), nullable=False)
|
||||
updated_at = db.Column(db.TIMESTAMP, default=datetime.now, onupdate=datetime.now, nullable=False)
|
||||
owner_user_id = db.Column(db.Integer, nullable=False)
|
||||
name = db.Column(db.String(40), unique=True, nullable=False)
|
||||
name = db.Column(db.String(40), nullable=False)
|
||||
is_active = db.Column(db.Boolean, default=False)
|
||||
|
||||
@classmethod
|
||||
def name_exists_for_owner(cls, owner_user_id, name, exclude_id=None):
|
||||
query = cls.query.filter(
|
||||
cls.owner_user_id == owner_user_id,
|
||||
func.lower(cls.name) == name.strip().lower(),
|
||||
)
|
||||
if exclude_id is not None:
|
||||
query = query.filter(cls.id != exclude_id)
|
||||
return query.first() is not None
|
||||
|
||||
def as_dict(self):
|
||||
result = {}
|
||||
for p in ['id','name','owner_user_id','is_active']:
|
||||
@@ -94,7 +109,7 @@ class ListOfItems(db.Model):
|
||||
|
||||
class Item(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
updated_at = db.Column(db.TIMESTAMP, default=datetime.now(), onupdate=datetime.now(), nullable=False)
|
||||
updated_at = db.Column(db.TIMESTAMP, default=datetime.now, onupdate=datetime.now, nullable=False)
|
||||
listofitems_id = db.Column(db.Integer, nullable=False)
|
||||
label = db.Column(db.String(40), nullable=False)
|
||||
quantity = db.Column(db.Integer, default=0)
|
||||
@@ -117,5 +132,5 @@ class Shared(db.Model):
|
||||
|
||||
class Group(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
updated_at = db.Column(db.TIMESTAMP, default=datetime.now(), onupdate=datetime.now(), nullable=False)
|
||||
updated_at = db.Column(db.TIMESTAMP, default=datetime.now, onupdate=datetime.now, nullable=False)
|
||||
secret = db.Column(db.String(128))
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
Flask>=3.0,<4.0
|
||||
Flask-Login>=0.6,<1.0
|
||||
Flask-SQLAlchemy>=3.1,<4.0
|
||||
cryptography>=42,<46
|
||||
PyYAML>=6.0,<7.0
|
||||
gunicorn
|
||||
@@ -0,0 +1,85 @@
|
||||
import base64
|
||||
import secrets
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
def _fernet_key():
|
||||
return base64.urlsafe_b64encode(secrets.token_bytes(32)).decode("ascii")
|
||||
|
||||
|
||||
def _default_secrets():
|
||||
return {
|
||||
"runtime": {
|
||||
"secret_key": secrets.token_urlsafe(48),
|
||||
"application_key": secrets.token_urlsafe(48),
|
||||
"database_uri": "sqlite:///app.db",
|
||||
},
|
||||
"initial_data": {
|
||||
"admin_password": secrets.token_urlsafe(18),
|
||||
"admin_group": secrets.token_urlsafe(18),
|
||||
"user_password": secrets.token_urlsafe(18),
|
||||
"user_group": secrets.token_urlsafe(18),
|
||||
},
|
||||
"fernet": {
|
||||
"key": _fernet_key(),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _merge_missing(target, defaults):
|
||||
changed = False
|
||||
for key, value in defaults.items():
|
||||
if isinstance(value, dict):
|
||||
current = target.get(key)
|
||||
if not isinstance(current, dict):
|
||||
target[key] = {}
|
||||
current = target[key]
|
||||
changed = True
|
||||
if _merge_missing(current, value):
|
||||
changed = True
|
||||
elif not target.get(key):
|
||||
target[key] = value
|
||||
changed = True
|
||||
return changed
|
||||
|
||||
|
||||
def load_secrets(instance_path):
|
||||
instance_dir = Path(instance_path)
|
||||
instance_dir.mkdir(parents=True, exist_ok=True)
|
||||
secrets_path = instance_dir / "secrets.yaml"
|
||||
existed = secrets_path.exists()
|
||||
if existed:
|
||||
secrets_path.chmod(0o600)
|
||||
else:
|
||||
secrets_path.touch(mode=0o600)
|
||||
|
||||
if existed:
|
||||
loaded = yaml.safe_load(secrets_path.read_text(encoding="utf-8")) or {}
|
||||
if not isinstance(loaded, dict):
|
||||
raise ValueError(f"{secrets_path} must contain a YAML mapping")
|
||||
else:
|
||||
loaded = {}
|
||||
|
||||
defaults = _default_secrets()
|
||||
legacy_files = {
|
||||
"secret_key": instance_dir / "secret_key",
|
||||
"application_key": instance_dir / "application_key",
|
||||
}
|
||||
migrated_paths = []
|
||||
for key, legacy_path in legacy_files.items():
|
||||
if legacy_path.exists() and not loaded.get("runtime", {}).get(key):
|
||||
defaults["runtime"][key] = legacy_path.read_text(encoding="utf-8").strip()
|
||||
migrated_paths.append(legacy_path)
|
||||
|
||||
changed = _merge_missing(loaded, defaults)
|
||||
if changed or not existed:
|
||||
secrets_path.write_text(
|
||||
yaml.safe_dump(loaded, sort_keys=False),
|
||||
encoding="utf-8",
|
||||
)
|
||||
secrets_path.chmod(0o600)
|
||||
for legacy_path in migrated_paths:
|
||||
legacy_path.unlink()
|
||||
return loaded
|
||||
Executable
+48
@@ -0,0 +1,48 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Set a L@pp user's password from the command line."""
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from lapp import create_app
|
||||
from models import User, db
|
||||
|
||||
|
||||
def parse_args():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Set a new password for an existing L@pp user.",
|
||||
usage="%(prog)s <username> <new-pwd>",
|
||||
)
|
||||
parser.add_argument("username", help="name of the user to update")
|
||||
parser.add_argument("new_password", metavar="new-pwd", help="new password")
|
||||
return parser, parser.parse_args()
|
||||
|
||||
|
||||
def main():
|
||||
parser, args = parse_args()
|
||||
|
||||
if not args.username:
|
||||
parser.error("username must not be empty")
|
||||
if not args.new_password:
|
||||
parser.error("new-pwd must not be empty")
|
||||
|
||||
app = create_app()
|
||||
with app.app_context():
|
||||
user = db.session.scalar(
|
||||
sa.select(User).where(User.name == args.username)
|
||||
)
|
||||
if user is None:
|
||||
print(f"User {args.username!r} does not exist.", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
user.set_password(args.new_password)
|
||||
db.session.commit()
|
||||
|
||||
print(f"Password updated for user {args.username!r}.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 50 KiB |
@@ -0,0 +1,117 @@
|
||||
(function () {
|
||||
const STORAGE_KEY = "lapp.pendingItemUpdates";
|
||||
|
||||
function readQueue() {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(STORAGE_KEY) || "[]");
|
||||
} catch (error) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function writeQueue(queue) {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(queue));
|
||||
}
|
||||
|
||||
function queueRequest(request) {
|
||||
const queue = readQueue();
|
||||
const queuedRequest = {
|
||||
url: request.url,
|
||||
method: request.method || "GET",
|
||||
coalesceKey: request.coalesceKey || null,
|
||||
clientUpdatedAt: request.clientUpdatedAt || Date.now()
|
||||
};
|
||||
|
||||
if (queuedRequest.coalesceKey) {
|
||||
const existingIndex = queue.findIndex(function (item) {
|
||||
return item.coalesceKey === queuedRequest.coalesceKey;
|
||||
});
|
||||
if (existingIndex >= 0) {
|
||||
queue[existingIndex] = queuedRequest;
|
||||
} else {
|
||||
queue.push(queuedRequest);
|
||||
}
|
||||
} else {
|
||||
queue.push(queuedRequest);
|
||||
}
|
||||
|
||||
writeQueue(queue);
|
||||
}
|
||||
|
||||
async function sendRequest(request) {
|
||||
const url = new URL(request.url, window.location.origin);
|
||||
if (request.clientUpdatedAt) {
|
||||
url.searchParams.set("client_updated_at", request.clientUpdatedAt);
|
||||
}
|
||||
|
||||
const response = await fetch(url.toString(), {
|
||||
method: request.method || "GET",
|
||||
cache: "no-store",
|
||||
credentials: "same-origin"
|
||||
});
|
||||
|
||||
if (response.redirected && new URL(response.url).pathname === "/login") {
|
||||
const error = new Error("Login required");
|
||||
error.retryable = true;
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const error = new Error("Request failed: " + response.status);
|
||||
error.retryable = response.status >= 500 || response.status === 408 || response.status === 429;
|
||||
throw error;
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
async function queueableItemRequest(url, options) {
|
||||
const request = Object.assign({}, options || {}, {
|
||||
url: url,
|
||||
clientUpdatedAt: Date.now()
|
||||
});
|
||||
try {
|
||||
await sendRequest(request);
|
||||
flushItemUpdateQueue();
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (error.retryable !== false) {
|
||||
queueRequest(request);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function flushItemUpdateQueue() {
|
||||
if (flushItemUpdateQueue.running) {
|
||||
return;
|
||||
}
|
||||
flushItemUpdateQueue.running = true;
|
||||
|
||||
const queue = readQueue();
|
||||
const remaining = [];
|
||||
|
||||
for (const request of queue) {
|
||||
try {
|
||||
await sendRequest(request);
|
||||
} catch (error) {
|
||||
if (error.retryable !== false) {
|
||||
remaining.push(request);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
writeQueue(remaining);
|
||||
flushItemUpdateQueue.running = false;
|
||||
}
|
||||
|
||||
window.lappQueueItemRequest = queueableItemRequest;
|
||||
window.lappFlushItemUpdateQueue = flushItemUpdateQueue;
|
||||
|
||||
window.addEventListener("online", flushItemUpdateQueue);
|
||||
document.addEventListener("visibilitychange", function () {
|
||||
if (!document.hidden) {
|
||||
flushItemUpdateQueue();
|
||||
}
|
||||
});
|
||||
document.addEventListener("DOMContentLoaded", flushItemUpdateQueue);
|
||||
})();
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
@@ -17,6 +17,7 @@
|
||||
<link href="/static/bootstrap.min.css" rel="stylesheet">
|
||||
<script src="/static/jquery.min.js"></script>
|
||||
<script src="/static/bootstrap.min.js"></script>
|
||||
<script src="/static/item_update_queue.js"></script>
|
||||
|
||||
<style>
|
||||
body {
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<h3 class="text-center mb-4"><img style="width: 3em; height: 3em" src="/static/login.svg"></h3>
|
||||
{% with messages = get_flashed_messages() %}
|
||||
{% for message in messages %}
|
||||
<div class="alert alert-danger" role="alert">{{ message }}</div>
|
||||
{% endfor %}
|
||||
{% endwith %}
|
||||
<form method="post">
|
||||
|
||||
<input class="form-control mb-3" name="password_old" type="password" placeholder="Old Password" required>
|
||||
|
||||
@@ -6,17 +6,29 @@
|
||||
|
||||
// handle Delete
|
||||
function handleDelete(button) {
|
||||
window.location.href = '/item_delete/'+button.name ;
|
||||
lappQueueItemRequest('/item_delete/'+button.name, {
|
||||
coalesceKey: 'item:' + button.name
|
||||
}).then(function (sent) {
|
||||
if (sent) {
|
||||
window.location.href = '/items_append/{{ listid }}';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// handle AddOne
|
||||
function handleAddOne(button) {
|
||||
const response = fetch('/item_addone/'+button.name);
|
||||
lappQueueItemRequest('/item_addone/'+button.name, {
|
||||
coalesceKey: 'item:' + button.name
|
||||
});
|
||||
clearSearch();
|
||||
}
|
||||
|
||||
// handle Add with quantity
|
||||
function handleAddWithQuantity(button, q) {
|
||||
const response = fetch('/item_addquantity/'+button.name+'/'+q);
|
||||
lappQueueItemRequest('/item_addquantity/'+button.name+'/'+q, {
|
||||
coalesceKey: 'item:' + button.name
|
||||
});
|
||||
clearSearch();
|
||||
}
|
||||
|
||||
//trigger change category button
|
||||
@@ -27,7 +39,9 @@
|
||||
// selected the category
|
||||
function clickedUpdateCategory4Item(icon) {
|
||||
image_to_update.src="{{ iconpath }}" + icon.name + ".svg"
|
||||
const response = fetch('/item_update_category/'+image_to_update.name+'/'+icon.name);
|
||||
lappQueueItemRequest('/item_update_category/'+image_to_update.name+'/'+icon.name, {
|
||||
coalesceKey: 'category:' + image_to_update.name
|
||||
});
|
||||
$('#staticBackdrop').modal('hide');
|
||||
}
|
||||
|
||||
@@ -40,6 +54,11 @@ $(document).ready(function(){
|
||||
});
|
||||
});
|
||||
|
||||
function clearSearch() {
|
||||
$("#myInput").val("");
|
||||
$("#myList tbody tr").show();
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<form method="post" action="/items_append/{{ listid }}">
|
||||
|
||||
@@ -35,7 +35,9 @@
|
||||
|
||||
// handle Checboc clicks
|
||||
function handleCBClick(cb) {
|
||||
const response = fetch('/item_update/'+cb.name+'/'+cb.checked);
|
||||
lappQueueItemRequest('/item_update/'+cb.name+'/'+cb.checked, {
|
||||
coalesceKey: 'checked:' + cb.name
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
+15
-4
@@ -1,9 +1,20 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<h3 class="text-center mb-4"><img style="width: 3em; height: 3em" src="/static/login.svg"></h3>
|
||||
<form method="post">
|
||||
<input class="form-control mb-3" name="name" placeholder="Name" required>
|
||||
<input class="form-control mb-3" name="password" type="password" placeholder="Password" required>
|
||||
<div class="text-center mb-4">
|
||||
<div
|
||||
class="d-inline-block"
|
||||
style="width: 6em; height: 6em; background: url('{{ url_for('static', filename='wwwsuynl.png') }}') center / cover no-repeat;"
|
||||
>
|
||||
<img
|
||||
src="{{ url_for('static', filename='login.svg') }}"
|
||||
alt="Login"
|
||||
style="width: 6em; height: 6em;"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<form method="post" autocomplete="off">
|
||||
<input class="form-control mb-3" name="name" placeholder="{{ name_placeholder }}" autocomplete="off" required>
|
||||
<input class="form-control mb-3" name="password" type="password" placeholder="{{ password_placeholder }}" autocomplete="new-password" required>
|
||||
<input
|
||||
type="image"
|
||||
src="/static/save.svg"
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import pickle
|
||||
from pathlib import Path
|
||||
|
||||
from cryptography.fernet import Fernet
|
||||
from secrets_config import load_secrets
|
||||
|
||||
# =====================================================
|
||||
# SECRET KEY (only your program has this)
|
||||
# =====================================================
|
||||
# Generate once using: Fernet.generate_key()
|
||||
_SECRET_KEY = b'YFK7QCyTzhyLO4vqrnRxvDAI5uu8mXEYrInEjbRoQgs='
|
||||
# The key is generated once and stored outside Git in instance/secrets.yaml.
|
||||
_SECRET_KEY = load_secrets(Path(__file__).parent / "instance")["fernet"]["key"].encode("ascii")
|
||||
|
||||
fernet = Fernet(_SECRET_KEY)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user