feat: Initial commit for web-app
This commit is contained in:
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
.env
|
||||
data/
|
||||
35
Dockerfile
Normal file
35
Dockerfile
Normal file
@@ -0,0 +1,35 @@
|
||||
|
||||
# 1. Basis-Image
|
||||
FROM python:3.11-slim
|
||||
|
||||
# Install su-exec, a lightweight tool for dropping privileges
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends gosu && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# 2. Arbeitsverzeichnis im Container setzen
|
||||
WORKDIR /app
|
||||
|
||||
# 3. Abhängigkeiten installieren
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# 4. Anwendungs-Code kopieren
|
||||
COPY . .
|
||||
|
||||
# Create the data directory that will be used by the volume
|
||||
# The entrypoint script will set the correct permissions on this
|
||||
RUN mkdir -p /app/data
|
||||
|
||||
# Copy and set permissions for the entrypoint script
|
||||
COPY entrypoint.sh .
|
||||
RUN chmod +x entrypoint.sh
|
||||
|
||||
# 5. Port freigeben, auf dem die App läuft
|
||||
EXPOSE 8000
|
||||
|
||||
# 6. Umgebungsvariable für die Gotify-URL (kann beim Start überschrieben werden)
|
||||
ENV GOTIFY_URL="https://gotify.ilunix.de/message?token=ADhlhgJ2Z14HK70"
|
||||
|
||||
# 7. Entrypoint und Befehl zum Starten der Anwendung
|
||||
# The entrypoint script will handle user permissions and then run the CMD
|
||||
ENTRYPOINT ["/app/entrypoint.sh"]
|
||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
52
README.md
Normal file
52
README.md
Normal file
@@ -0,0 +1,52 @@
|
||||
# Shared Shopping List Web App
|
||||
|
||||
## About The Project
|
||||
|
||||
This project provides a central, self-hosted web application for a family or group to manage a shared shopping list. Any member can easily add items they need, creating a single, always-up-to-date list.
|
||||
|
||||
This web service is the final development step for the accompanying **Android app**. The goal is to allow the app to automatically sync with this web service.
|
||||
|
||||
**Current Status & Workaround:** While direct API integration in the app is still to be completed, the system is already fully functional using notifications. The text sent in the Gotify notification can be copied and pasted 1:1 into the Android app to instantly populate the shopping list.
|
||||
|
||||
## How to Use
|
||||
|
||||
Here is an example `docker-compose.yml` file:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
shopping-list:
|
||||
image: lxtools/noteshop-webapp:latest
|
||||
container_name: shopping-list
|
||||
restart: always
|
||||
ports:
|
||||
- "8080:8000" # Host-Port:Container-Port
|
||||
volumes:
|
||||
# For local development, use a relative path:
|
||||
- ./data:/app/data
|
||||
|
||||
# For production, you might use an absolute path like this (choose one):
|
||||
# - /opt/containers/sharelist/data:/app/data
|
||||
|
||||
environment:
|
||||
# Set the User and Group ID for file permissions.
|
||||
# Defaults to 1000 if not specified in your environment.
|
||||
- PUID=${PUID:-1000}
|
||||
- PGID=${PGID:-1000}
|
||||
# Your Gotify URL for notifications. Should be set in a .env file.
|
||||
- GOTIFY_URL=${GOTIFY_URL}
|
||||
```
|
||||
|
||||
### First Login
|
||||
|
||||
After starting the container for the first time, a default administrator account is created so you can log in.
|
||||
|
||||
* **Username:** `admin`
|
||||
* **Password:** `admin`
|
||||
|
||||
**Important:** For security reasons, please log in and change the default password immediately using the admin panel.
|
||||
|
||||
### Important Notes
|
||||
|
||||
* **User/Group:** Set `PUID` and `PGID` to your user's ID on the host system. You can find them by running the commands `id -u` and `id -g`.
|
||||
* **Data Directory:** Before the first run, create a directory for the database: `mkdir data`.
|
||||
* **Running:** Start the container with: `PUID=$(id -u) PGID=$(id -g) docker compose up -d`
|
||||
22
docker-compose.yml
Normal file
22
docker-compose.yml
Normal file
@@ -0,0 +1,22 @@
|
||||
services:
|
||||
shopping-list:
|
||||
build: .
|
||||
container_name: shopping-list
|
||||
restart: always
|
||||
ports:
|
||||
- "82:8000" # Host-Port:Container-Port
|
||||
volumes:
|
||||
# For local development, use a relative path:
|
||||
- ./data:/app/data
|
||||
|
||||
# For production, you might use an absolute path like this (choose one):
|
||||
# - /opt/containers/sharelist/data:/app/data
|
||||
|
||||
environment:
|
||||
# Set the User and Group ID for file permissions.
|
||||
# Defaults to 1000 if not specified in your environment.
|
||||
- PUID=${PUID:-1000}
|
||||
- PGID=${PGID:-1000}
|
||||
# Your Gotify URL for notifications. Should be set in a .env file.
|
||||
- GOTIFY_URL=${GOTIFY_URL}
|
||||
|
||||
15
entrypoint.sh
Normal file
15
entrypoint.sh
Normal file
@@ -0,0 +1,15 @@
|
||||
#!/bin/sh
|
||||
# Exit on error
|
||||
set -e
|
||||
|
||||
# Use PUID/PGID from environment variables, or default to 1000
|
||||
PUID=${PUID:-1000}
|
||||
PGID=${PGID:-1000}
|
||||
|
||||
# Set ownership of the data directory
|
||||
# This ensures that the user running the container can write to the volume
|
||||
chown -R "$PUID:$PGID" /app/data
|
||||
|
||||
# Drop root privileges and execute the main command (CMD)
|
||||
# The command (e.g., uvicorn) is passed as arguments to this script ("$@")
|
||||
exec gosu "$PUID:$PGID" "$@"
|
||||
556
main.py
Normal file
556
main.py
Normal file
@@ -0,0 +1,556 @@
|
||||
|
||||
from fastapi import FastAPI, HTTPException, Depends, status, WebSocket, WebSocketDisconnect, Request
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional, List
|
||||
from datetime import datetime, timedelta
|
||||
from jose import JWTError, jwt
|
||||
import sqlite3
|
||||
import uvicorn
|
||||
import requests
|
||||
import os
|
||||
from passlib.context import CryptContext
|
||||
from collections import defaultdict
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
# --- Configuration ---
|
||||
SECRET_KEY = "09d25e094faa6ca2556c818166b7a9563b93f7099f6f0f4caa6cf63b88e8d3e7"
|
||||
ALGORITHM = "HS256"
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES = 43200 # 30 days
|
||||
DB_FILE = "data/shoppinglist.db"
|
||||
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
|
||||
|
||||
# --- Brute-Force Protection ---
|
||||
MAX_FAILED_ATTEMPTS_USER = 5
|
||||
MAX_FAILED_ATTEMPTS_ADMIN = 3
|
||||
LOCKOUT_PERIOD_MINUTES = 30
|
||||
failed_login_attempts_ip = defaultdict(lambda: {'count': 0, 'timestamp': datetime.utcnow()})
|
||||
|
||||
|
||||
# --- Pydantic Models ---
|
||||
class Token(BaseModel):
|
||||
access_token: str
|
||||
token_type: str
|
||||
|
||||
|
||||
class TokenData(BaseModel):
|
||||
username: Optional[str] = None
|
||||
|
||||
|
||||
class User(BaseModel):
|
||||
username: str
|
||||
is_admin: bool
|
||||
|
||||
|
||||
class UserInDB(User):
|
||||
id: int
|
||||
password_hash: str
|
||||
failed_login_attempts: int
|
||||
is_locked: bool
|
||||
locked_at: Optional[datetime]
|
||||
|
||||
|
||||
class Item(BaseModel):
|
||||
names: List[str]
|
||||
|
||||
|
||||
class NewUser(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
is_admin: bool = False
|
||||
|
||||
|
||||
# --- WebSocket Connection Manager ---
|
||||
class ConnectionManager:
|
||||
def __init__(self):
|
||||
self.active_connections: List[WebSocket] = []
|
||||
self.user_map: dict[WebSocket, User] = {}
|
||||
|
||||
async def connect(self, websocket: WebSocket, user: User):
|
||||
await websocket.accept()
|
||||
self.active_connections.append(websocket)
|
||||
self.user_map[websocket] = user
|
||||
await self.broadcast_user_list()
|
||||
|
||||
def disconnect(self, websocket: WebSocket):
|
||||
if websocket in self.active_connections:
|
||||
self.active_connections.remove(websocket)
|
||||
if websocket in self.user_map:
|
||||
del self.user_map[websocket]
|
||||
|
||||
async def broadcast_user_list(self):
|
||||
user_list = sorted([user.username for user in self.user_map.values()])
|
||||
for connection in self.active_connections:
|
||||
await connection.send_json({"type": "user_list", "users": user_list})
|
||||
|
||||
|
||||
manager = ConnectionManager()
|
||||
|
||||
|
||||
# --- Auth Helper Functions ---
|
||||
def get_user(username: str):
|
||||
conn = sqlite3.connect(DB_FILE)
|
||||
conn.row_factory = sqlite3.Row
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT * FROM users WHERE username = ?", (username,))
|
||||
user_data = cursor.fetchone()
|
||||
conn.close()
|
||||
if user_data:
|
||||
user_dict = dict(user_data)
|
||||
if user_dict.get('locked_at'):
|
||||
try:
|
||||
user_dict['locked_at'] = datetime.fromisoformat(user_dict['locked_at'])
|
||||
except (TypeError, ValueError):
|
||||
user_dict['locked_at'] = None
|
||||
return UserInDB(**user_dict)
|
||||
|
||||
|
||||
def verify_password(plain_password, hashed_password):
|
||||
return pwd_context.verify(plain_password, hashed_password)
|
||||
|
||||
|
||||
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None):
|
||||
to_encode = data.copy()
|
||||
if expires_delta:
|
||||
expire = datetime.utcnow() + expires_delta
|
||||
else:
|
||||
expire = datetime.utcnow() + timedelta(minutes=15)
|
||||
to_encode.update({"exp": expire})
|
||||
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
|
||||
return encoded_jwt
|
||||
|
||||
|
||||
async def get_current_active_user(token: str = Depends(oauth2_scheme)):
|
||||
credentials_exception = HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Could not validate credentials",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
try:
|
||||
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
||||
username: str = payload.get("sub")
|
||||
if username is None:
|
||||
raise credentials_exception
|
||||
token_data = TokenData(username=username)
|
||||
except JWTError:
|
||||
raise credentials_exception
|
||||
user = get_user(username=token_data.username)
|
||||
if user is None:
|
||||
raise credentials_exception
|
||||
return user
|
||||
|
||||
|
||||
# --- Gotify Konfiguration ---
|
||||
GOTIFY_URL = os.getenv("GOTIFY_URL")
|
||||
|
||||
|
||||
def send_gotify_notification(title: str, message: str) -> str:
|
||||
if not GOTIFY_URL:
|
||||
return "GOTIFY_URL is not set. Skipping notification."
|
||||
try:
|
||||
payload = {"title": title, "message": message}
|
||||
response = requests.post(GOTIFY_URL, json=payload)
|
||||
response.raise_for_status()
|
||||
return "Gotify notification sent successfully."
|
||||
except requests.exceptions.RequestException as e:
|
||||
print(f"Error sending Gotify notification: {e}")
|
||||
return "Error sending Gotify notification."
|
||||
|
||||
|
||||
from translations import get_standard_items, get_all_translations
|
||||
|
||||
|
||||
# --- Datenbank-Setup ---
|
||||
def column_exists(cursor, table_name, column_name):
|
||||
cursor.execute(f"PRAGMA table_info({table_name})")
|
||||
columns = [info[1] for info in cursor.fetchall()]
|
||||
return column_name in columns
|
||||
|
||||
|
||||
def init_db():
|
||||
conn = sqlite3.connect(DB_FILE)
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
is_admin BOOLEAN NOT NULL DEFAULT 0
|
||||
)
|
||||
""")
|
||||
|
||||
if not column_exists(cursor, "users", "failed_login_attempts"):
|
||||
cursor.execute("ALTER TABLE users ADD COLUMN failed_login_attempts INTEGER NOT NULL DEFAULT 0")
|
||||
if not column_exists(cursor, "users", "is_locked"):
|
||||
cursor.execute("ALTER TABLE users ADD COLUMN is_locked BOOLEAN NOT NULL DEFAULT 0")
|
||||
if not column_exists(cursor, "users", "locked_at"):
|
||||
cursor.execute("ALTER TABLE users ADD COLUMN locked_at TIMESTAMP")
|
||||
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS items (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
is_standard BOOLEAN NOT NULL DEFAULT 0
|
||||
)
|
||||
""")
|
||||
|
||||
if not column_exists(cursor, "items", "created_by_user_id"):
|
||||
cursor.execute("ALTER TABLE items ADD COLUMN created_by_user_id INTEGER REFERENCES users(id)")
|
||||
|
||||
cursor.execute("SELECT COUNT(*) FROM users")
|
||||
if cursor.fetchone()[0] == 0:
|
||||
admin_password = "admin"
|
||||
hashed_password = pwd_context.hash(admin_password)
|
||||
cursor.execute(
|
||||
"INSERT INTO users (username, password_hash, is_admin) VALUES (?, ?, ?)",
|
||||
("admin", hashed_password, True)
|
||||
)
|
||||
print("Default admin user 'admin' with password 'admin' created.")
|
||||
print("Please change this password in a production environment.")
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
print("Datenbank initialisiert.")
|
||||
|
||||
|
||||
@app.get("/api/standard-items/{lang}")
|
||||
async def get_standard_items_route(lang: str):
|
||||
return get_standard_items(lang)
|
||||
|
||||
|
||||
@app.get("/api/translations/{lang}")
|
||||
async def get_translations_route(lang: str):
|
||||
return get_all_translations(lang)
|
||||
|
||||
|
||||
# --- API Endpunkte ---
|
||||
@app.post("/token", response_model=Token)
|
||||
async def login_for_access_token(request: Request, form_data: OAuth2PasswordRequestForm = Depends()):
|
||||
client_ip = request.client.host
|
||||
now = datetime.utcnow()
|
||||
|
||||
ip_failures = failed_login_attempts_ip[client_ip]
|
||||
if ip_failures['count'] >= 15 and (now - ip_failures['timestamp']) < timedelta(minutes=LOCKOUT_PERIOD_MINUTES):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail="Too many failed login attempts from this IP. Please try again later."
|
||||
)
|
||||
|
||||
user = get_user(form_data.username)
|
||||
|
||||
if user and user.is_locked:
|
||||
lockout_expiry = user.locked_at + timedelta(minutes=LOCKOUT_PERIOD_MINUTES) if user.locked_at else now
|
||||
if now < lockout_expiry:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Account is locked due to too many failed login attempts."
|
||||
)
|
||||
else:
|
||||
conn = sqlite3.connect(DB_FILE)
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("UPDATE users SET is_locked = 0, failed_login_attempts = 0, locked_at = NULL WHERE username = ?",
|
||||
(user.username,))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
user = get_user(form_data.username)
|
||||
|
||||
if not user or not verify_password(form_data.password, user.password_hash):
|
||||
if user:
|
||||
conn = sqlite3.connect(DB_FILE)
|
||||
cursor = conn.cursor()
|
||||
new_attempts = user.failed_login_attempts + 1
|
||||
max_attempts = MAX_FAILED_ATTEMPTS_ADMIN if user.is_admin else MAX_FAILED_ATTEMPTS_USER
|
||||
|
||||
if new_attempts >= max_attempts:
|
||||
cursor.execute("UPDATE users SET failed_login_attempts = ?, is_locked = 1, locked_at = ? WHERE username = ?",
|
||||
(new_attempts, now, user.username))
|
||||
else:
|
||||
cursor.execute("UPDATE users SET failed_login_attempts = ? WHERE username = ?", (new_attempts, user.username))
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
ip_failures['count'] += 1
|
||||
ip_failures['timestamp'] = now
|
||||
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Incorrect username or password",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
if user.failed_login_attempts > 0 or user.is_locked:
|
||||
conn = sqlite3.connect(DB_FILE)
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("UPDATE users SET failed_login_attempts = 0, is_locked = 0, locked_at = NULL WHERE username = ?",
|
||||
(user.username,))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
failed_login_attempts_ip[client_ip] = {'count': 0, 'timestamp': now}
|
||||
|
||||
access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
access_token = create_access_token(
|
||||
data={"sub": user.username}, expires_delta=access_token_expires
|
||||
)
|
||||
return {"access_token": access_token, "token_type": "bearer"}
|
||||
|
||||
|
||||
@app.get("/api/users/me", response_model=User)
|
||||
async def read_users_me(current_user: User = Depends(get_current_active_user)):
|
||||
return current_user
|
||||
|
||||
|
||||
@app.post("/api/users", status_code=status.HTTP_201_CREATED)
|
||||
async def create_user(user: NewUser, current_user: User = Depends(get_current_active_user)):
|
||||
if not current_user.is_admin:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Only admins can create users")
|
||||
|
||||
conn = sqlite3.connect(DB_FILE)
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute("SELECT id FROM users WHERE username = ?", (user.username,))
|
||||
if cursor.fetchone():
|
||||
conn.close()
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Username already registered")
|
||||
|
||||
hashed_password = pwd_context.hash(user.password)
|
||||
cursor.execute(
|
||||
"INSERT INTO users (username, password_hash, is_admin) VALUES (?, ?, ?)",
|
||||
(user.username, hashed_password, user.is_admin)
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return {"status": f"User {user.username} created"}
|
||||
|
||||
|
||||
class PasswordChangeRequest(BaseModel):
|
||||
current_password: str
|
||||
new_password: str
|
||||
|
||||
|
||||
@app.put("/api/users/me/password", status_code=status.HTTP_200_OK)
|
||||
async def change_password(
|
||||
request: PasswordChangeRequest,
|
||||
current_user: UserInDB = Depends(get_current_active_user)
|
||||
):
|
||||
if not verify_password(request.current_password, current_user.password_hash):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Incorrect current password",
|
||||
)
|
||||
|
||||
new_hashed_password = pwd_context.hash(request.new_password)
|
||||
|
||||
conn = sqlite3.connect(DB_FILE)
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"UPDATE users SET password_hash = ? WHERE id = ?",
|
||||
(new_hashed_password, current_user.id)
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
return {"status": "Password updated successfully"}
|
||||
|
||||
|
||||
class UserListUser(BaseModel):
|
||||
id: int
|
||||
username: str
|
||||
is_admin: bool
|
||||
is_locked: bool
|
||||
|
||||
|
||||
@app.get("/api/users", response_model=List[UserListUser])
|
||||
async def get_users(current_user: UserInDB = Depends(get_current_active_user)):
|
||||
if not current_user.is_admin:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Only admins can access the user list")
|
||||
|
||||
conn = sqlite3.connect(DB_FILE)
|
||||
conn.row_factory = sqlite3.Row
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT id, username, is_admin, is_locked FROM users")
|
||||
users = cursor.fetchall()
|
||||
conn.close()
|
||||
return [dict(row) for row in users]
|
||||
|
||||
|
||||
@app.delete("/api/users/{user_id}", status_code=status.HTTP_200_OK)
|
||||
async def delete_user(
|
||||
user_id: int,
|
||||
current_user: UserInDB = Depends(get_current_active_user)
|
||||
):
|
||||
if not current_user.is_admin:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Only admins can delete users")
|
||||
|
||||
if current_user.id == user_id:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Admins cannot delete themselves")
|
||||
|
||||
conn = sqlite3.connect(DB_FILE)
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute("SELECT id FROM users WHERE id = ?", (user_id,))
|
||||
if cursor.fetchone() is None:
|
||||
conn.close()
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
|
||||
|
||||
cursor.execute("DELETE FROM users WHERE id = ?", (user_id,))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
return {"status": "User deleted successfully"}
|
||||
|
||||
|
||||
@app.get("/api/users/locked", response_model=List[UserListUser])
|
||||
async def get_locked_users(current_user: UserInDB = Depends(get_current_active_user)):
|
||||
if not current_user.is_admin:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Only admins can access this resource")
|
||||
|
||||
conn = sqlite3.connect(DB_FILE)
|
||||
conn.row_factory = sqlite3.Row
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT id, username, is_admin, is_locked FROM users WHERE is_locked = 1")
|
||||
locked_users = cursor.fetchall()
|
||||
conn.close()
|
||||
return [dict(row) for row in locked_users]
|
||||
|
||||
|
||||
@app.put("/api/users/{user_id}/unlock", status_code=status.HTTP_200_OK)
|
||||
async def unlock_user(user_id: int, current_user: UserInDB = Depends(get_current_active_user)):
|
||||
if not current_user.is_admin:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Only admins can perform this action")
|
||||
|
||||
conn = sqlite3.connect(DB_FILE)
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("UPDATE users SET is_locked = 0, failed_login_attempts = 0, locked_at = NULL WHERE id = ?", (user_id,))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
return {"status": "User unlocked successfully"}
|
||||
|
||||
|
||||
@app.get("/api/items")
|
||||
async def get_items(current_user: User = Depends(get_current_active_user)):
|
||||
conn = sqlite3.connect(DB_FILE)
|
||||
conn.row_factory = sqlite3.Row
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT id, name, is_standard, created_by_user_id FROM items ORDER BY name")
|
||||
items = cursor.fetchall()
|
||||
conn.close()
|
||||
return {"items": [dict(row) for row in items]}
|
||||
|
||||
|
||||
@app.post("/api/items")
|
||||
async def add_item(item: Item, current_user: UserInDB = Depends(get_current_active_user)):
|
||||
conn = sqlite3.connect(DB_FILE)
|
||||
cursor = conn.cursor()
|
||||
|
||||
added_count = 0
|
||||
for name in item.names:
|
||||
cursor.execute("SELECT * FROM items WHERE name = ?", (name,))
|
||||
if cursor.fetchone():
|
||||
continue
|
||||
|
||||
standard_items_de = get_standard_items('de')
|
||||
standard_items_en = get_standard_items('en')
|
||||
is_standard = name in standard_items_de or name in standard_items_en
|
||||
|
||||
try:
|
||||
cursor.execute("INSERT INTO items (name, is_standard, created_by_user_id) VALUES (?, ?, ?)",
|
||||
(name, is_standard, current_user.id))
|
||||
added_count += 1
|
||||
except sqlite3.IntegrityError:
|
||||
continue
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
if added_count > 0:
|
||||
return {"status": "ok", "added": added_count}
|
||||
else:
|
||||
return {"status": "exists"}
|
||||
|
||||
|
||||
@app.delete("/api/items/{item_id}")
|
||||
async def delete_item(item_id: int, current_user: User = Depends(get_current_active_user)):
|
||||
if not current_user.is_admin:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Only admins can delete items")
|
||||
|
||||
conn = sqlite3.connect(DB_FILE)
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("DELETE FROM items WHERE id = ?", (item_id,))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.post("/api/notify")
|
||||
async def trigger_notification(lang: str = 'en', current_user: User = Depends(get_current_active_user)):
|
||||
conn = sqlite3.connect(DB_FILE)
|
||||
conn.row_factory = sqlite3.Row
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT * FROM items ORDER BY name")
|
||||
items = cursor.fetchall()
|
||||
conn.close()
|
||||
|
||||
item_names = [dict(row)["name"] for row in items]
|
||||
translations = get_all_translations(lang)
|
||||
|
||||
if not item_names:
|
||||
message = translations.get("empty_list_message", "The shopping list is empty.")
|
||||
else:
|
||||
message = ", ".join(item_names)
|
||||
|
||||
status = send_gotify_notification(
|
||||
translations.get("notification_title", "Shopping List Updated"),
|
||||
message
|
||||
)
|
||||
return {"status": status}
|
||||
|
||||
|
||||
@app.get("/api/db-status")
|
||||
async def get_db_status(current_user: User = Depends(get_current_active_user)):
|
||||
try:
|
||||
conn = sqlite3.connect(DB_FILE)
|
||||
version = sqlite3.sqlite_version
|
||||
conn.close()
|
||||
return {"version": version, "error": None}
|
||||
except sqlite3.Error as e:
|
||||
return {"version": None, "error": str(e)}
|
||||
|
||||
|
||||
@app.websocket("/ws/{token}")
|
||||
async def websocket_endpoint(websocket: WebSocket, token: str):
|
||||
try:
|
||||
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
||||
username: str = payload.get("sub")
|
||||
if username is None:
|
||||
await websocket.close(code=status.WS_1008_POLICY_VIOLATION)
|
||||
return
|
||||
user = get_user(username=username)
|
||||
if user is None:
|
||||
await websocket.close(code=status.WS_1008_POLICY_VIOLATION)
|
||||
return
|
||||
except JWTError:
|
||||
await websocket.close(code=status.WS_1008_POLICY_VIOLATION)
|
||||
return
|
||||
|
||||
await manager.connect(websocket, user)
|
||||
try:
|
||||
while True:
|
||||
await websocket.receive_text()
|
||||
except WebSocketDisconnect:
|
||||
manager.disconnect(websocket)
|
||||
await manager.broadcast_user_list()
|
||||
|
||||
|
||||
# --- Statische Dateien (Frontend) ---
|
||||
# Create data directory if it doesn't exist
|
||||
os.makedirs(os.path.dirname(DB_FILE), exist_ok=True)
|
||||
init_db()
|
||||
app.mount("/", StaticFiles(directory="static", html=True), name="static")
|
||||
7
requirements.txt
Normal file
7
requirements.txt
Normal file
@@ -0,0 +1,7 @@
|
||||
fastapi
|
||||
uvicorn[standard]
|
||||
requests
|
||||
passlib[bcrypt]==1.7.4
|
||||
bcrypt==3.2.0
|
||||
python-jose[cryptography]
|
||||
python-multipart
|
||||
BIN
static/favicon.png
Normal file
BIN
static/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
806
static/index.html
Normal file
806
static/index.html
Normal file
@@ -0,0 +1,806 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Geteilte Einkaufsliste</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link rel="icon" type="image/png" href="/favicon.png">
|
||||
<style>
|
||||
body {
|
||||
background: linear-gradient(to right, #ece9e6, #ffffff);
|
||||
}
|
||||
.container {
|
||||
max-width: 800px;
|
||||
}
|
||||
.input-card-sticky {
|
||||
position: -webkit-sticky;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1020;
|
||||
}
|
||||
#page-title {
|
||||
font-weight: 300;
|
||||
}
|
||||
.list-group-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.delete-btn {
|
||||
cursor: pointer;
|
||||
color: #dc3545;
|
||||
font-weight: bold;
|
||||
}
|
||||
#status-bar {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
background-color: #f8f9fa;
|
||||
padding: 5px;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
border-top: 1px solid #dee2e6;
|
||||
}
|
||||
#suggestion-info {
|
||||
font-size: 0.9rem;
|
||||
color: #6c757d;
|
||||
margin-top: 10px;
|
||||
}
|
||||
#suggestion-box {
|
||||
margin-top: 5px;
|
||||
}
|
||||
.suggestion-btn {
|
||||
margin-right: 5px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
#login-view, #app-view {
|
||||
display: none; /* Hidden by default */
|
||||
}
|
||||
#login-view, #app-view {
|
||||
display: none; /* Hidden by default */
|
||||
}
|
||||
</style> </style>
|
||||
<body>
|
||||
|
||||
<!-- Login View -->
|
||||
<div id="login-view" class="container mt-5">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-6">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-body">
|
||||
<h3 id="login-title" class="card-title text-center"></h3>
|
||||
<form id="login-form">
|
||||
<div class="mb-3">
|
||||
<label id="login-username-label" for="username" class="form-label"></label>
|
||||
<input type="text" class="form-control" id="username" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label id="login-password-label" for="password" class="form-label"></label>
|
||||
<input type="password" class="form-control" id="password" required>
|
||||
</div>
|
||||
<div class="d-grid">
|
||||
<button id="login-button" type="submit" class="btn btn-primary"></button>
|
||||
</div>
|
||||
<div id="login-error" class="text-danger mt-2"></div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main App View -->
|
||||
<div id="app-view" class="container mt-5">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<h1 id="page-title" class="text-center mb-4"></h1>
|
||||
<button id="logout-btn" class="btn btn-sm btn-outline-secondary">Logout</button>
|
||||
</div>
|
||||
|
||||
<div id="online-users-container" class="mb-3" style="display: none;">
|
||||
<strong>Online:</strong> <span id="online-users-list"></span>
|
||||
</div>
|
||||
|
||||
<div id="user-view-content">
|
||||
<div class="card shadow-sm mb-4 input-card-sticky">
|
||||
<div class="card-body">
|
||||
<form id="add-item-form" class="input-group">
|
||||
<input type="text" id="item-name" class="form-control" autocomplete="off" required>
|
||||
<button type="submit" id="add-button" class="btn btn-primary"></button>
|
||||
<button type="button" id="add-one-button" class="btn btn-secondary"></button>
|
||||
</form>
|
||||
<div id="suggestion-info">
|
||||
<div class="d-flex align-items-center mb-1">
|
||||
<button id="example-button-existing" type="button" class="btn btn-primary btn-sm me-2" disabled></button>
|
||||
<span id="info-text-existing"></span>
|
||||
</div>
|
||||
<div class="d-flex align-items-center">
|
||||
<button id="example-button-new" type="button" class="btn btn-outline-primary btn-sm me-2" disabled></button>
|
||||
<span id="info-text-new"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div id="suggestion-box"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-grid gap-2 mb-3">
|
||||
<button id="notify-btn" class="btn btn-success"></button>
|
||||
</div>
|
||||
|
||||
<ul id="item-list" class="list-group shadow-sm"></ul>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<!-- Admin Section -->
|
||||
<div id="admin-section" class="card shadow-sm mt-5" style="display: none;">
|
||||
<div id="admin-panel-title" class="card-header"></div>
|
||||
<div class="card-body">
|
||||
|
||||
<h5 id="create-user-title" class="card-title"></h5>
|
||||
<form id="create-user-form">
|
||||
<div class="row">
|
||||
<div class="col-md-4 mb-3">
|
||||
<label id="username-label" for="new-username" class="form-label"></label>
|
||||
<input type="text" id="new-username" class="form-control" required>
|
||||
</div>
|
||||
<div class="col-md-4 mb-3">
|
||||
<label id="password-label" for="new-password" class="form-label"></label>
|
||||
<input type="password" id="new-password" class="form-control" required>
|
||||
</div>
|
||||
<div class="col-md-4 mb-3 d-flex align-items-end">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="new-user-is-admin">
|
||||
<label id="is-admin-label" class="form-check-label" for="new-user-is-admin"></label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button id="create-user-button" type="submit" class="btn btn-success"></button>
|
||||
<div id="create-user-status" class="mt-2"></div>
|
||||
</form>
|
||||
|
||||
<hr class="my-4">
|
||||
|
||||
<h5 id="change-password-title" class="card-title"></h5>
|
||||
<form id="change-password-form">
|
||||
<div class="row">
|
||||
<div class="col-md-4 mb-3">
|
||||
<label id="current-password-label" for="current-password" class="form-label"></label>
|
||||
<input type="password" id="current-password" class="form-control" required>
|
||||
</div>
|
||||
<div class="col-md-4 mb-3">
|
||||
<label id="new-password-label" for="new-password-change" class="form-label"></label>
|
||||
<input type="password" id="new-password-change" class="form-control" required>
|
||||
</div>
|
||||
<div class="col-md-4 mb-3">
|
||||
<label id="confirm-password-label" for="confirm-password" class="form-label"></label>
|
||||
<input type="password" id="confirm-password" class="form-control" required>
|
||||
</div>
|
||||
</div>
|
||||
<button id="change-password-button" type="submit" class="btn btn-primary"></button>
|
||||
<div id="change-password-status" class="mt-2"></div>
|
||||
</form>
|
||||
|
||||
<hr class="my-4">
|
||||
|
||||
<h5 id="manage-users-title" class="card-title"></h5>
|
||||
<ul id="user-list" class="list-group mb-3">
|
||||
<!-- Users will be populated by JavaScript -->
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
<div id="status-bar"></div>
|
||||
|
||||
<script>
|
||||
// Views
|
||||
const loginView = document.getElementById('login-view');
|
||||
const appView = document.getElementById('app-view');
|
||||
const userViewContent = document.getElementById('user-view-content');
|
||||
|
||||
// Login elements
|
||||
const loginForm = document.getElementById('login-form');
|
||||
const usernameInput = document.getElementById('username');
|
||||
const passwordInput = document.getElementById('password');
|
||||
const loginError = document.getElementById('login-error');
|
||||
const logoutBtn = document.getElementById('logout-btn');
|
||||
|
||||
const adminSection = document.getElementById('admin-section');
|
||||
const createUserForm = document.getElementById('create-user-form');
|
||||
const newUsernameInput = document.getElementById('new-username');
|
||||
const newPasswordInput = document.getElementById('new-password');
|
||||
const newUserIsAdminCheckbox = document.getElementById('new-user-is-admin');
|
||||
const createUserStatus = document.getElementById('create-user-status');
|
||||
|
||||
// App elements
|
||||
const pageTitle = document.getElementById('page-title');
|
||||
const itemList = document.getElementById('item-list');
|
||||
const addItemForm = document.getElementById('add-item-form');
|
||||
const itemNameInput = document.getElementById('item-name');
|
||||
const addButton = document.getElementById('add-button');
|
||||
const addOneButton = document.getElementById('add-one-button');
|
||||
const suggestionInfo = document.getElementById('suggestion-info');
|
||||
const suggestionBox = document.getElementById('suggestion-box');
|
||||
const notifyBtn = document.getElementById('notify-btn');
|
||||
const exampleButtonExisting = document.getElementById('example-button-existing');
|
||||
const infoTextExisting = document.getElementById('info-text-existing');
|
||||
const exampleButtonNew = document.getElementById('example-button-new');
|
||||
const infoTextNew = document.getElementById('info-text-new');
|
||||
const statusBar = document.getElementById('status-bar');
|
||||
|
||||
const onlineUsersContainer = document.getElementById('online-users-container');
|
||||
const onlineUsersList = document.getElementById('online-users-list');
|
||||
|
||||
let standardItems = [];
|
||||
let translations = {};
|
||||
let currentShoppingList = [];
|
||||
let currentUser = {};
|
||||
let socket;
|
||||
|
||||
// --- WebSocket Functions ---
|
||||
function connectWebSocket() {
|
||||
const token = localStorage.getItem('access_token');
|
||||
if (!token) return;
|
||||
|
||||
// Close existing socket if any
|
||||
if (socket && socket.readyState === WebSocket.OPEN) {
|
||||
socket.close();
|
||||
}
|
||||
|
||||
const wsProtocol = window.location.protocol === 'https:' ? 'wss' : 'ws';
|
||||
socket = new WebSocket(`${wsProtocol}://${window.location.host}/ws/${token}`);
|
||||
|
||||
socket.onopen = () => {
|
||||
console.log("WebSocket connection established.");
|
||||
};
|
||||
|
||||
socket.onmessage = (event) => {
|
||||
const data = JSON.parse(event.data);
|
||||
if (data.type === 'user_list') {
|
||||
if (data.users && data.users.length > 1) {
|
||||
onlineUsersList.textContent = data.users.join(', ');
|
||||
onlineUsersContainer.style.display = 'block';
|
||||
} else {
|
||||
onlineUsersContainer.style.display = 'none';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
socket.onclose = () => {
|
||||
console.log("WebSocket connection closed. Attempting to reconnect...");
|
||||
setTimeout(connectWebSocket, 5000); // Reconnect after 5 seconds
|
||||
};
|
||||
|
||||
socket.onerror = (error) => {
|
||||
console.error("WebSocket error:", error);
|
||||
socket.close();
|
||||
};
|
||||
}
|
||||
|
||||
// --- Auth Functions ---
|
||||
|
||||
function getAuthHeaders() {
|
||||
const token = localStorage.getItem('access_token');
|
||||
return {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': 'Bearer ' + token
|
||||
};
|
||||
}
|
||||
|
||||
async function handleLogin(event) {
|
||||
event.preventDefault();
|
||||
loginError.textContent = '';
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('username', usernameInput.value);
|
||||
formData.append('password', passwordInput.value);
|
||||
|
||||
try {
|
||||
const response = await fetch('/token', {
|
||||
method: 'POST',
|
||||
body: new URLSearchParams(formData)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
localStorage.setItem('access_token', data.access_token);
|
||||
await showAppView();
|
||||
} else {
|
||||
loginError.textContent = translations.login_error_incorrect || 'Incorrect username or password.';
|
||||
}
|
||||
} catch (error) {
|
||||
loginError.textContent = translations.login_error_generic || 'An error occurred. Please try again.';
|
||||
}
|
||||
}
|
||||
|
||||
function handleLogout() {
|
||||
if (socket && socket.readyState === WebSocket.OPEN) {
|
||||
socket.close();
|
||||
}
|
||||
localStorage.removeItem('access_token');
|
||||
location.reload();
|
||||
}
|
||||
|
||||
async function handleCreateUser(event) {
|
||||
event.preventDefault();
|
||||
createUserStatus.textContent = '';
|
||||
|
||||
const newUser = {
|
||||
username: newUsernameInput.value,
|
||||
password: newPasswordInput.value,
|
||||
is_admin: newUserIsAdminCheckbox.checked
|
||||
};
|
||||
|
||||
const response = await fetch('/api/users', {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify(newUser)
|
||||
});
|
||||
|
||||
if (response.status === 401) return handleLogout();
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
const successMessage = (translations.user_created_success || "User '{username}' created successfully.").replace('{username}', newUser.username);
|
||||
createUserStatus.textContent = successMessage;
|
||||
createUserStatus.className = 'mt-2 text-success';
|
||||
createUserForm.reset();
|
||||
await fetchAndRenderUsers(); // Refresh the user list
|
||||
} else {
|
||||
createUserStatus.textContent = `Error: ${data.detail}`;
|
||||
createUserStatus.className = 'mt-2 text-danger';
|
||||
}
|
||||
}
|
||||
|
||||
// --- View Management ---
|
||||
|
||||
function showLoginView() {
|
||||
appView.style.display = 'none';
|
||||
loginView.style.display = 'block';
|
||||
adminSection.style.display = 'none';
|
||||
if (userViewContent) userViewContent.style.display = 'none';
|
||||
}
|
||||
|
||||
async function showAppView() {
|
||||
loginView.style.display = 'none';
|
||||
appView.style.display = 'block';
|
||||
adminSection.style.display = 'none';
|
||||
if (userViewContent) userViewContent.style.display = 'none';
|
||||
connectWebSocket();
|
||||
await initApp();
|
||||
}
|
||||
|
||||
// --- App Functions ---
|
||||
async function fetchCurrentUser() {
|
||||
const response = await fetch(`/api/users/me?_=${new Date().getTime()}`, { headers: getAuthHeaders() });
|
||||
if (response.status === 401) return handleLogout();
|
||||
currentUser = await response.json();
|
||||
|
||||
if (currentUser.is_admin) {
|
||||
adminSection.style.display = 'block';
|
||||
userViewContent.style.display = 'none';
|
||||
pageTitle.textContent = 'Admin Panel';
|
||||
} else {
|
||||
adminSection.style.display = 'none';
|
||||
userViewContent.style.display = 'block';
|
||||
// Title will be set by fetchTranslations for regular users
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchTranslations() {
|
||||
const lang = navigator.language.startsWith('de') ? 'de' : 'en';
|
||||
const response = await fetch(`/api/translations/${lang}?_=${new Date().getTime()}`);
|
||||
translations = await response.json();
|
||||
updateUIWithTranslations();
|
||||
}
|
||||
|
||||
|
||||
function updateUIWithTranslations() {
|
||||
document.title = translations.title;
|
||||
|
||||
// Login View
|
||||
if(document.getElementById('login-title')) {
|
||||
document.getElementById('login-title').textContent = translations.login_title;
|
||||
document.getElementById('login-username-label').textContent = translations.login_username_label;
|
||||
document.getElementById('login-password-label').textContent = translations.login_password_label;
|
||||
document.getElementById('login-button').textContent = translations.login_button;
|
||||
}
|
||||
|
||||
// User View
|
||||
pageTitle.textContent = translations.title;
|
||||
itemNameInput.placeholder = translations.placeholder;
|
||||
addButton.textContent = translations.add_button;
|
||||
addOneButton.textContent = translations.add_one_button;
|
||||
notifyBtn.textContent = translations.notify_button;
|
||||
exampleButtonExisting.textContent = translations.example_button_text || 'Example';
|
||||
exampleButtonNew.textContent = translations.example_button_text || 'Example';
|
||||
infoTextExisting.textContent = translations.info_text_existing || '';
|
||||
infoTextNew.textContent = translations.info_text_new || '';
|
||||
|
||||
// Admin View
|
||||
if (document.getElementById('admin-panel-title')) {
|
||||
document.getElementById('admin-panel-title').textContent = translations.admin_panel_title;
|
||||
document.getElementById('create-user-title').textContent = translations.create_user_title;
|
||||
document.getElementById('username-label').textContent = translations.username_label;
|
||||
document.getElementById('password-label').textContent = translations.password_label;
|
||||
document.getElementById('is-admin-label').textContent = translations.is_admin_label;
|
||||
document.getElementById('create-user-button').textContent = translations.create_user_button;
|
||||
document.getElementById('change-password-title').textContent = translations.change_password_title;
|
||||
document.getElementById('current-password-label').textContent = translations.current_password_label;
|
||||
document.getElementById('new-password-label').textContent = translations.new_password_label;
|
||||
document.getElementById('confirm-password-label').textContent = translations.confirm_password_label;
|
||||
document.getElementById('change-password-button').textContent = translations.change_password_button;
|
||||
document.getElementById('manage-users-title').textContent = translations.manage_users_title;
|
||||
}
|
||||
|
||||
// Common elements
|
||||
if (document.getElementById('logout-btn')) {
|
||||
document.getElementById('logout-btn').textContent = translations.logout_button;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchStandardItems() {
|
||||
const lang = navigator.language.startsWith('de') ? 'de' : 'en';
|
||||
const response = await fetch(`/api/standard-items/${lang}`);
|
||||
standardItems = await response.json();
|
||||
}
|
||||
|
||||
async function fetchItems() {
|
||||
const response = await fetch('/api/items', { headers: getAuthHeaders() });
|
||||
if (response.status === 401) return handleLogout();
|
||||
const data = await response.json();
|
||||
currentShoppingList = data.items;
|
||||
itemList.innerHTML = '';
|
||||
data.items.forEach(item => {
|
||||
const li = document.createElement('li');
|
||||
li.className = 'list-group-item';
|
||||
li.textContent = item.name;
|
||||
|
||||
if (currentUser.is_admin) {
|
||||
const deleteBtn = document.createElement('span');
|
||||
deleteBtn.textContent = 'X';
|
||||
deleteBtn.className = 'delete-btn';
|
||||
deleteBtn.onclick = () => deleteItem(item.id);
|
||||
li.appendChild(deleteBtn);
|
||||
}
|
||||
|
||||
itemList.appendChild(li);
|
||||
});
|
||||
handleInputChange();
|
||||
}
|
||||
|
||||
async function addItemsFromInput(inputString, split) {
|
||||
if (!inputString.trim()) return;
|
||||
|
||||
let itemsToAdd = [];
|
||||
if (split) {
|
||||
itemsToAdd = inputString.split(/[ ,]+/).map(item => item.trim()).filter(item => item);
|
||||
} else {
|
||||
itemsToAdd.push(inputString.trim());
|
||||
}
|
||||
|
||||
if (itemsToAdd.length === 0) return;
|
||||
|
||||
const response = await fetch('/api/items', {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify({ names: itemsToAdd })
|
||||
});
|
||||
|
||||
if (response.status === 401) return handleLogout();
|
||||
|
||||
const result = await response.json();
|
||||
if (result.status === 'exists' && result.added === 0) {
|
||||
console.log('All items already exist.');
|
||||
}
|
||||
|
||||
await fetchItems();
|
||||
handleInputChange();
|
||||
}
|
||||
|
||||
async function deleteItem(id) {
|
||||
const response = await fetch(`/api/items/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: getAuthHeaders()
|
||||
});
|
||||
if (response.status === 401) return handleLogout();
|
||||
if (response.status === 403) {
|
||||
alert("Only admins can delete items."); // Simple feedback for non-admins
|
||||
return;
|
||||
}
|
||||
await fetchItems();
|
||||
}
|
||||
|
||||
function handleInputChange() {
|
||||
const value = itemNameInput.value.trim().toLowerCase();
|
||||
const currentShoppingListNames = currentShoppingList.map(item => item.name.toLowerCase());
|
||||
|
||||
if (value === '') {
|
||||
suggestionBox.innerHTML = '';
|
||||
suggestionInfo.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
if (suggestionInfo) suggestionInfo.style.display = 'block';
|
||||
|
||||
const searchTerms = value.split(/[ ,]+/).map(term => term.trim()).filter(term => term);
|
||||
const lastTerm = searchTerms[searchTerms.length - 1];
|
||||
|
||||
if (!lastTerm) {
|
||||
suggestionBox.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
const newSuggestions = standardItems.filter(item =>
|
||||
item.toLowerCase().includes(lastTerm) &&
|
||||
!currentShoppingListNames.includes(item.toLowerCase())
|
||||
);
|
||||
|
||||
const existingItems = currentShoppingList
|
||||
.filter(item => item.name.toLowerCase().includes(lastTerm))
|
||||
.map(item => item.name);
|
||||
|
||||
const existingStandardSuggestions = standardItems.filter(item =>
|
||||
item.toLowerCase().includes(lastTerm) &&
|
||||
currentShoppingListNames.includes(item.toLowerCase())
|
||||
);
|
||||
|
||||
const allExistingItems = [...new Set([...existingItems, ...existingStandardSuggestions])];
|
||||
|
||||
displaySuggestions(allExistingItems, newSuggestions);
|
||||
}
|
||||
|
||||
function displaySuggestions(existing, news) {
|
||||
suggestionBox.innerHTML = '';
|
||||
|
||||
existing.slice(0, 10).forEach(suggestion => {
|
||||
const btn = createSuggestionButton(suggestion, true);
|
||||
suggestionBox.appendChild(btn);
|
||||
});
|
||||
|
||||
news.slice(0, 10).forEach(suggestion => {
|
||||
const btn = createSuggestionButton(suggestion, false);
|
||||
suggestionBox.appendChild(btn);
|
||||
});
|
||||
}
|
||||
|
||||
function createSuggestionButton(suggestion, isExisting) {
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = isExisting
|
||||
? 'btn btn-primary btn-sm suggestion-btn'
|
||||
: 'btn btn-outline-primary btn-sm suggestion-btn';
|
||||
btn.textContent = suggestion;
|
||||
btn.disabled = isExisting;
|
||||
if (!isExisting) {
|
||||
btn.onclick = () => onSuggestionClick(suggestion);
|
||||
}
|
||||
return btn;
|
||||
}
|
||||
|
||||
async function onSuggestionClick(suggestion) {
|
||||
const value = itemNameInput.value;
|
||||
await addItemsFromInput(suggestion, false);
|
||||
const lastSeparatorIndex = Math.max(value.lastIndexOf(','), value.lastIndexOf(' '));
|
||||
let newValue = '';
|
||||
if (lastSeparatorIndex !== -1) {
|
||||
newValue = value.substring(0, lastSeparatorIndex + 1);
|
||||
}
|
||||
itemNameInput.value = newValue;
|
||||
suggestionBox.innerHTML = '';
|
||||
itemNameInput.focus();
|
||||
}
|
||||
|
||||
async function sendNotification() {
|
||||
const lang = navigator.language.startsWith('de') ? 'de' : 'en';
|
||||
notifyBtn.disabled = true;
|
||||
notifyBtn.textContent = translations.sending_notification || 'Sending...';
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/notify?lang=${lang}`, {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders()
|
||||
});
|
||||
if (response.status === 401) return handleLogout();
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok && data.status.includes("success")) {
|
||||
notifyBtn.textContent = translations.notification_sent || 'Notification sent!';
|
||||
} else {
|
||||
notifyBtn.textContent = translations.generic_notification_error || 'Error sending notification.';
|
||||
}
|
||||
} catch (error) {
|
||||
notifyBtn.textContent = translations.generic_notification_error || 'Error sending notification.';
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
notifyBtn.disabled = false;
|
||||
notifyBtn.textContent = translations.notify_button || 'Finalize Changes & Notify';
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
async function fetchDbStatus() {
|
||||
try {
|
||||
const response = await fetch('/api/db-status', { headers: getAuthHeaders() });
|
||||
if (response.status === 401) return handleLogout();
|
||||
const data = await response.json();
|
||||
if (data.error) {
|
||||
statusBar.textContent = (translations.db_error || "Database error: {error}").replace("{error}", data.error);
|
||||
} else {
|
||||
statusBar.textContent = (translations.db_version || "Database version: {version}").replace("{version}", data.version);
|
||||
}
|
||||
} catch (error) {
|
||||
statusBar.textContent = (translations.db_error || "Database error: {error}").replace("{error}", "Connection failed");
|
||||
}
|
||||
}
|
||||
|
||||
// --- Event Listeners ---
|
||||
loginForm.addEventListener('submit', handleLogin);
|
||||
logoutBtn.addEventListener('click', handleLogout);
|
||||
createUserForm.addEventListener('submit', handleCreateUser);
|
||||
addItemForm.addEventListener('submit', async (event) => {
|
||||
event.preventDefault();
|
||||
await addItemsFromInput(itemNameInput.value, true);
|
||||
itemNameInput.value = '';
|
||||
suggestionBox.innerHTML = '';
|
||||
});
|
||||
|
||||
addOneButton.addEventListener('click', async () => {
|
||||
await addItemsFromInput(itemNameInput.value, false);
|
||||
itemNameInput.value = '';
|
||||
suggestionBox.innerHTML = '';
|
||||
});
|
||||
|
||||
itemNameInput.addEventListener('input', handleInputChange);
|
||||
|
||||
notifyBtn.addEventListener('click', sendNotification);
|
||||
|
||||
// --- Initialisierung ---
|
||||
async function initApp() {
|
||||
// NOTE: Translations are now fetched before this function is called.
|
||||
const promises = [
|
||||
fetchCurrentUser(),
|
||||
fetchStandardItems(),
|
||||
fetchItems(),
|
||||
fetchAndRenderUsers(),
|
||||
fetchDbStatus()
|
||||
];
|
||||
const results = await Promise.allSettled(promises);
|
||||
results.forEach((result, i) => {
|
||||
if (result.status === 'rejected') {
|
||||
console.error(`Error in initApp data fetch step ${i}:`, result.reason);
|
||||
}
|
||||
});
|
||||
|
||||
if (suggestionInfo) suggestionInfo.style.display = 'none';
|
||||
}
|
||||
|
||||
|
||||
// --- User Management Logic ---
|
||||
const userList = document.getElementById('user-list');
|
||||
|
||||
async function unlockUser(userId, username) {
|
||||
// TODO: Add translation for the confirmation message
|
||||
if (!confirm(`Are you sure you want to unlock the user "${username}"?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/users/${userId}/unlock`, {
|
||||
method: 'PUT',
|
||||
headers: getAuthHeaders()
|
||||
});
|
||||
|
||||
if (response.status === 401) return handleLogout();
|
||||
|
||||
if (response.ok) {
|
||||
await fetchAndRenderUsers();
|
||||
} else {
|
||||
const data = await response.json();
|
||||
alert(`Error: ${data.detail}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteUser(userId, username) {
|
||||
if (!confirm(`Are you sure you want to delete the user "${username}"?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/users/${userId}`, {
|
||||
method: 'DELETE',
|
||||
headers: getAuthHeaders()
|
||||
});
|
||||
|
||||
if (response.status === 401) return handleLogout();
|
||||
|
||||
if (response.ok) {
|
||||
await fetchAndRenderUsers();
|
||||
} else {
|
||||
const data = await response.json();
|
||||
alert(`Error: ${data.detail}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchAndRenderUsers() {
|
||||
if (!userList) return;
|
||||
|
||||
const response = await fetch('/api/users', { headers: getAuthHeaders() });
|
||||
if (response.status === 401) return handleLogout();
|
||||
|
||||
const users = await response.json();
|
||||
userList.innerHTML = '';
|
||||
|
||||
users.forEach(user => {
|
||||
const li = document.createElement('li');
|
||||
li.className = 'list-group-item d-flex justify-content-between align-items-center';
|
||||
|
||||
const userInfo = document.createElement('span');
|
||||
userInfo.textContent = `${user.username}`;
|
||||
|
||||
if (user.is_admin) {
|
||||
const adminBadge = document.createElement('span');
|
||||
adminBadge.className = 'badge bg-primary rounded-pill ms-2';
|
||||
adminBadge.textContent = 'Admin';
|
||||
userInfo.appendChild(adminBadge);
|
||||
}
|
||||
|
||||
if (user.is_locked) {
|
||||
const lockedBadge = document.createElement('span');
|
||||
// TODO: Add translation for "Locked"
|
||||
lockedBadge.className = 'badge bg-warning text-dark rounded-pill ms-2';
|
||||
lockedBadge.textContent = 'Locked';
|
||||
userInfo.appendChild(lockedBadge);
|
||||
}
|
||||
|
||||
li.appendChild(userInfo);
|
||||
|
||||
const buttonContainer = document.createElement('div');
|
||||
|
||||
if (currentUser && currentUser.id !== user.id) {
|
||||
if (user.is_locked) {
|
||||
const unlockBtn = document.createElement('button');
|
||||
unlockBtn.className = 'btn btn-success btn-sm';
|
||||
// TODO: Add translation for "Unlock"
|
||||
unlockBtn.textContent = 'Unlock';
|
||||
unlockBtn.onclick = () => unlockUser(user.id, user.username);
|
||||
buttonContainer.appendChild(unlockBtn);
|
||||
} else {
|
||||
const deleteBtn = document.createElement('button');
|
||||
deleteBtn.className = 'btn btn-danger btn-sm ms-2';
|
||||
deleteBtn.textContent = translations.delete_button || 'Delete';
|
||||
deleteBtn.onclick = () => deleteUser(user.id, user.username);
|
||||
buttonContainer.appendChild(deleteBtn);
|
||||
}
|
||||
}
|
||||
|
||||
li.appendChild(buttonContainer);
|
||||
userList.appendChild(li);
|
||||
});
|
||||
}
|
||||
|
||||
// --- Change Password Logic ---
|
||||
// --- Main Application Entry Point ---
|
||||
async function main() {
|
||||
// Always fetch translations first, so all UI is translated before showing.
|
||||
await fetchTranslations();
|
||||
|
||||
// Now decide which view to show
|
||||
if (localStorage.getItem('access_token')) {
|
||||
showAppView();
|
||||
} else {
|
||||
showLoginView();
|
||||
}
|
||||
}
|
||||
|
||||
main(); // Run the app
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
</body>
|
||||
</html>
|
||||
107
translations.py
Normal file
107
translations.py
Normal file
@@ -0,0 +1,107 @@
|
||||
|
||||
import xml.etree.ElementTree as ET
|
||||
from typing import List, Dict
|
||||
|
||||
def get_standard_items(lang: str) -> List[str]:
|
||||
"""
|
||||
Parses the strings.xml file for the given language and returns the standard shopping list items.
|
||||
"""
|
||||
if lang == "de":
|
||||
filepath = "translations/de/strings.xml"
|
||||
else:
|
||||
filepath = "translations/en/strings.xml"
|
||||
|
||||
try:
|
||||
tree = ET.parse(filepath)
|
||||
root = tree.getroot()
|
||||
for string_array in root.findall('string-array'):
|
||||
if string_array.get('name') == 'standard_list_items':
|
||||
return [item.text for item in string_array.findall('item')]
|
||||
except (ET.ParseError, FileNotFoundError):
|
||||
return []
|
||||
|
||||
return []
|
||||
|
||||
def get_all_translations(lang: str) -> Dict[str, str]:
|
||||
"""
|
||||
Returns a dictionary with all UI and notification translations for the web app.
|
||||
"""
|
||||
if lang == "de":
|
||||
return {
|
||||
"title": "Geteilte Einkaufsliste",
|
||||
"placeholder": "Artikel eingeben...",
|
||||
"add_button": "Hinzufügen",
|
||||
"add_one_button": "+1",
|
||||
"notify_button": "Änderungen abschließen & Benachrichtigen",
|
||||
"sending_notification": "Sende...",
|
||||
"notification_sent": "Benachrichtigung gesendet!",
|
||||
"notification_title": "Einkaufsliste aktualisiert",
|
||||
"empty_list_message": "Die Einkaufsliste ist leer.",
|
||||
"db_version": "Datenbankversion: {version}",
|
||||
"db_error": "Datenbankfehler: {error}",
|
||||
"generic_notification_error": "Fehler beim Senden der Benachrichtigung.",
|
||||
"example_button_text": "Beispiel",
|
||||
"info_text_existing": "= ist schon in der Liste",
|
||||
"info_text_new": "= noch nicht in der Liste",
|
||||
"admin_panel_title": "Admin Panel",
|
||||
"create_user_title": "Neuen Benutzer erstellen",
|
||||
"username_label": "Benutzername",
|
||||
"password_label": "Passwort",
|
||||
"is_admin_label": "Ist Admin?",
|
||||
"create_user_button": "Benutzer erstellen",
|
||||
"change_password_title": "Passwort ändern",
|
||||
"current_password_label": "Aktuelles Passwort",
|
||||
"new_password_label": "Neues Passwort",
|
||||
"confirm_password_label": "Neues Passwort bestätigen",
|
||||
"change_password_button": "Passwort ändern",
|
||||
"manage_users_title": "Benutzer verwalten",
|
||||
"delete_button": "Löschen",
|
||||
"logout_button": "Abmelden",
|
||||
"login_title": "Anmelden",
|
||||
"login_username_label": "Benutzername",
|
||||
"login_password_label": "Passwort",
|
||||
"login_button": "Anmelden",
|
||||
"user_created_success": "Benutzer '{username}' erfolgreich erstellt.",
|
||||
"login_error_incorrect": "Benutzername oder Passwort inkorrekt.",
|
||||
"login_error_generic": "Ein Fehler ist aufgetreten. Bitte erneut versuchen."
|
||||
}
|
||||
else: # Fallback to English
|
||||
return {
|
||||
"title": "Shared Shopping List",
|
||||
"placeholder": "Enter item...",
|
||||
"add_button": "Add",
|
||||
"add_one_button": "+1",
|
||||
"notify_button": "Finalize Changes & Notify",
|
||||
"sending_notification": "Sending...",
|
||||
"notification_sent": "Notification sent!",
|
||||
"notification_title": "Shopping List Updated",
|
||||
"empty_list_message": "The shopping list is empty.",
|
||||
"db_version": "Database version: {version}",
|
||||
"db_error": "Database error: {error}",
|
||||
"generic_notification_error": "Error sending notification.",
|
||||
"example_button_text": "Example",
|
||||
"info_text_existing": "= is already on the list",
|
||||
"info_text_new": "= not yet on the list",
|
||||
"admin_panel_title": "Admin Panel",
|
||||
"create_user_title": "Create New User",
|
||||
"username_label": "Username",
|
||||
"password_label": "Password",
|
||||
"is_admin_label": "Is Admin?",
|
||||
"create_user_button": "Create User",
|
||||
"change_password_title": "Change Your Password",
|
||||
"current_password_label": "Current Password",
|
||||
"new_password_label": "New Password",
|
||||
"confirm_password_label": "Confirm New Password",
|
||||
"change_password_button": "Change Password",
|
||||
"manage_users_title": "Manage Users",
|
||||
"delete_button": "Delete",
|
||||
"logout_button": "Logout",
|
||||
"login_title": "Login",
|
||||
"login_username_label": "Username",
|
||||
"login_password_label": "Password",
|
||||
"login_button": "Login",
|
||||
"user_created_success": "User '{username}' created successfully.",
|
||||
"login_error_incorrect": "Incorrect username or password.",
|
||||
"login_error_generic": "An error occurred. Please try again."
|
||||
}
|
||||
|
||||
111
translations/de/strings.xml
Normal file
111
translations/de/strings.xml
Normal file
@@ -0,0 +1,111 @@
|
||||
<resources>
|
||||
<string-array name="standard_list_items">
|
||||
<item>Äpfel</item>
|
||||
<item>Antikalk</item>
|
||||
<item>Apfelmus</item>
|
||||
<item>Asia Nudeln</item>
|
||||
<item>Backofen Fisch</item>
|
||||
<item>Backpapier</item>
|
||||
<item>Backpulver</item>
|
||||
<item>Baguette</item>
|
||||
<item>Bananen</item>
|
||||
<item>Batterien</item>
|
||||
<item>Bierschinken</item>
|
||||
<item>Bockwurst</item>
|
||||
<item>Bohnen</item>
|
||||
<item>Bolognese Fix</item>
|
||||
<item>Brokkoli</item>
|
||||
<item>Brot</item>
|
||||
<item>Brötchen</item>
|
||||
<item>Butter</item>
|
||||
<item>Cashewkerne</item>
|
||||
<item>Chicken Wings</item>
|
||||
<item>Chips</item>
|
||||
<item>Cola</item>
|
||||
<item>Cola Zero</item>
|
||||
<item>Cornflakes</item>
|
||||
<item>Curry Fix</item>
|
||||
<item>Desinfektionsmittel</item>
|
||||
<item>Eier</item>
|
||||
<item>Eis</item>
|
||||
<item>Eisberg Salat</item>
|
||||
<item>Erbsen</item>
|
||||
<item>Erbsen Wurzel Gemüse</item>
|
||||
<item>Fanta</item>
|
||||
<item>Fisch</item>
|
||||
<item>Flips</item>
|
||||
<item>Frituesen Fett</item>
|
||||
<item>Ganze Mandeln</item>
|
||||
<item>Gemüse</item>
|
||||
<item>Gewürzgurken</item>
|
||||
<item>Gyros</item>
|
||||
<item>Haferflocken</item>
|
||||
<item>Hafermilch</item>
|
||||
<item>Hamburger</item>
|
||||
<item>Hamburger Brötchen</item>
|
||||
<item>Hamburger Fleisch</item>
|
||||
<item>Haribo</item>
|
||||
<item>Haselnüsse</item>
|
||||
<item>Honig</item>
|
||||
<item>Kaba</item>
|
||||
<item>Kaffee</item>
|
||||
<item>Karotten</item>
|
||||
<item>Kartoffeln</item>
|
||||
<item>Käse</item>
|
||||
<item>Ketchup</item>
|
||||
<item>Kidney Bohnen</item>
|
||||
<item>Kirschen</item>
|
||||
<item>Kiwi</item>
|
||||
<item>Kochschinken</item>
|
||||
<item>Kräutersalz</item>
|
||||
<item>Küchen Tücher</item>
|
||||
<item>Lactosefreie Milch</item>
|
||||
<item>Lasangne Platten</item>
|
||||
<item>Leberwurst</item>
|
||||
<item>Lorbeer Blätter</item>
|
||||
<item>Mayonnaise</item>
|
||||
<item>Margarine</item>
|
||||
<item>Mehl</item>
|
||||
<item>Milch</item>
|
||||
<item>Milchreis</item>
|
||||
<item>Müllbeutel 60l</item>
|
||||
<item>Nudeln</item>
|
||||
<item>Nuggets</item>
|
||||
<item>Olivenöl</item>
|
||||
<item>Pfeffer</item>
|
||||
<item>Pizza Baguette</item>
|
||||
<item>Pommes</item>
|
||||
<item>Pommes Salz</item>
|
||||
<item>Putzmittel</item>
|
||||
<item>Rapsöl</item>
|
||||
<item>Red Bull</item>
|
||||
<item>Reibekäse</item>
|
||||
<item>Reis</item>
|
||||
<item>Remoulade</item>
|
||||
<item>Rotkohl</item>
|
||||
<item>Sahnesteif</item>
|
||||
<item>Salat</item>
|
||||
<item>Salz</item>
|
||||
<item>Schlagsahne</item>
|
||||
<item>Schweinebraten</item>
|
||||
<item>Schweinemett</item>
|
||||
<item>Spezi</item>
|
||||
<item>Sprite</item>
|
||||
<item>Sprudel</item>
|
||||
<item>Sprühsahne</item>
|
||||
<item>Tafel Essig</item>
|
||||
<item>Taschen Tücher</item>
|
||||
<item>Vanillin Zucker</item>
|
||||
<item>Walnüsse</item>
|
||||
<item>Wattestäbchen</item>
|
||||
<item>Wienerle</item>
|
||||
<item>Wings</item>
|
||||
<item>Wurst</item>
|
||||
<item>Wurstsalat</item>
|
||||
<item>Zatziki</item>
|
||||
<item>Zitronentee</item>
|
||||
<item>Zucker Eier</item>
|
||||
<item>Zwiebelmett</item>
|
||||
<item>Zwiebeln</item>
|
||||
</string-array>
|
||||
</resources>
|
||||
111
translations/en/strings.xml
Normal file
111
translations/en/strings.xml
Normal file
@@ -0,0 +1,111 @@
|
||||
<resources>
|
||||
<string-array name="standard_list_items">
|
||||
<item>Apples</item>
|
||||
<item>Apple sauce</item>
|
||||
<item>Asian noodles</item>
|
||||
<item>Baguette</item>
|
||||
<item>Baking paper</item>
|
||||
<item>Baking powder</item>
|
||||
<item>Bananas</item>
|
||||
<item>Batteries</item>
|
||||
<item>Bay leaves</item>
|
||||
<item>Beans</item>
|
||||
<item>Beer ham</item>
|
||||
<item>Bockwurst</item>
|
||||
<item>Bolognese seasoning mix</item>
|
||||
<item>Bread</item>
|
||||
<item>Bread rolls</item>
|
||||
<item>Broccoli</item>
|
||||
<item>Butter</item>
|
||||
<item>Canned whipped cream</item>
|
||||
<item>Canola oil</item>
|
||||
<item>Carrots</item>
|
||||
<item>Cashew nuts</item>
|
||||
<item>Cheese</item>
|
||||
<item>Cherries</item>
|
||||
<item>Chicken wings</item>
|
||||
<item>Chip salt</item>
|
||||
<item>Chips</item>
|
||||
<item>Chocolate milk powder</item>
|
||||
<item>Cleaning agent</item>
|
||||
<item>Cola</item>
|
||||
<item>Cola Zero</item>
|
||||
<item>Cooked ham</item>
|
||||
<item>Cornflakes</item>
|
||||
<item>Cotton swabs</item>
|
||||
<item>Cream stabilizer</item>
|
||||
<item>Curry seasoning mix</item>
|
||||
<item>Deep-frying fat</item>
|
||||
<item>Descaler</item>
|
||||
<item>Disinfectant</item>
|
||||
<item>Eggs</item>
|
||||
<item>Fanta</item>
|
||||
<item>Fish</item>
|
||||
<item>Flour</item>
|
||||
<item>French fries</item>
|
||||
<item>Frying fat</item>
|
||||
<item>Garbage bags (60l)</item>
|
||||
<item>Grated cheese</item>
|
||||
<item>Gyros</item>
|
||||
<item>Haribo</item>
|
||||
<item>Hazelnuts</item>
|
||||
<item>Herbal salt</item>
|
||||
<item>Honey</item>
|
||||
<item>Ice cream</item>
|
||||
<item>Iceberg lettuce</item>
|
||||
<item>Ketchup</item>
|
||||
<item>Kidney beans</item>
|
||||
<item>Kitchen towels</item>
|
||||
<item>Kiwi</item>
|
||||
<item>Lactose-free milk</item>
|
||||
<item>Lasagna sheets</item>
|
||||
<item>Lemon tea</item>
|
||||
<item>Liver sausage</item>
|
||||
<item>Mayonnaise</item>
|
||||
<item>Milk</item>
|
||||
<item>Minced pork (seasoned)</item>
|
||||
<item>Noodles</item>
|
||||
<item>Nuggets</item>
|
||||
<item>Oat flakes</item>
|
||||
<item>Oat milk</item>
|
||||
<item>Olive oil</item>
|
||||
<item>Onions</item>
|
||||
<item>Oven fish</item>
|
||||
<item>Paper towels</item>
|
||||
<item>Pasta</item>
|
||||
<item>Peanut flips</item>
|
||||
<item>Peas</item>
|
||||
<item>Peas and root vegetables</item>
|
||||
<item>Pepper</item>
|
||||
<item>Pickled gherkins</item>
|
||||
<item>Pizza baguette</item>
|
||||
<item>Pork roast</item>
|
||||
<item>Potatoes</item>
|
||||
<item>Rapeseed oil</item>
|
||||
<item>Red Bull</item>
|
||||
<item>Red cabbage</item>
|
||||
<item>Remoulade sauce</item>
|
||||
<item>Rice</item>
|
||||
<item>Rice pudding</item>
|
||||
<item>Rolled oats</item>
|
||||
<item>Rolls</item>
|
||||
<item>Salad</item>
|
||||
<item>Salt</item>
|
||||
<item>Sausage</item>
|
||||
<item>Sausage salad</item>
|
||||
<item>Seasoned minced pork with onions</item>
|
||||
<item>Spezi</item>
|
||||
<item>Sprite</item>
|
||||
<item>Sparkling water</item>
|
||||
<item>Sugar eggs</item>
|
||||
<item>Table vinegar</item>
|
||||
<item>Tissues</item>
|
||||
<item>Tzatziki</item>
|
||||
<item>Vanillin sugar</item>
|
||||
<item>Vegetables</item>
|
||||
<item>Vienna sausage</item>
|
||||
<item>Walnuts</item>
|
||||
<item>Whipped cream (liquid)</item>
|
||||
<item>Whole almonds</item>
|
||||
</string-array>
|
||||
</resources>
|
||||
Reference in New Issue
Block a user