8 Commits

Author SHA1 Message Date
gru
7f68b1647e Merge pull request 'pgsql_mysql_docker' (#4) from pgsql_mysql_docker into master
Reviewed-on: #4
2025-07-24 10:03:32 +02:00
Mateusz Gruszczyński
6f7d0069cc poprawki w compose i kodzie 2025-07-24 09:56:30 +02:00
Mateusz Gruszczyński
a68aa031bb poprawki w compose i kodzie 2025-07-24 09:54:18 +02:00
root
730330cba9 remove firebird 2025-07-23 23:50:06 +02:00
Mateusz Gruszczyński
5a898c5b7a usprawnienia w panelu 2025-07-23 13:50:22 +02:00
Mateusz Gruszczyński
74ae7642e5 usprawnienia w panelu 2025-07-23 13:46:57 +02:00
root
111a63d3af wsparie dla mysql/pgsql/firebird/sqlite 2025-07-23 10:57:13 +02:00
Mateusz Gruszczyński
57a3866ec8 inne bazy z opcjach 2025-07-23 09:30:27 +02:00
18 changed files with 287 additions and 39 deletions

BIN
.app.py.swp Normal file

Binary file not shown.

View File

@@ -23,4 +23,31 @@ AUTH_COOKIE_MAX_AGE=86400
HEALTHCHECK_TOKEN=alamapsaikota123
# sesja zalogowanego usera (domyślnie 7 dni)
SESSION_TIMEOUT_MINUTES=10080
SESSION_TIMEOUT_MINUTES=10080
# Rodzaj bazy: sqlite, pgsql, mysql
# Mozliwe wartosci: sqlite / pgsql / mysql
DB_ENGINE=sqlite
# --- Konfiguracja dla sqlite ---
# Plik bazy bedzie utworzony automatycznie w katalogu ./instance
# Pozostale zmienne sa ignorowane przy DB_ENGINE=sqlite
# --- Konfiguracja dla pgsql ---
# Ustaw DB_ENGINE=pgsql
# Domyslny port PostgreSQL to 5432
# Wymaga dzialajacego serwera PostgreSQL (np. kontener `postgres`)
# Przyklad URI: postgresql://user:pass@db:5432/myapp
# --- Konfiguracja dla mysql ---
# Ustaw DB_ENGINE=mysql
# Domyslny port MySQL to 3306
# Wymaga kontenera z MySQL i uzytkownika z dostepem do bazy
# Przyklad URI: mysql+pymysql://user:pass@db:3306/myapp
# Wspolne zmienne (dla pgsql, mysql)
DB_HOST=db
DB_PORT=5432
DB_NAME=myapp
DB_USER=user
DB_PASSWORD=pass

4
.gitignore vendored
View File

@@ -5,4 +5,6 @@ env
__pycache__
instance/
uploads/
.DS_Store
.DS_Store
db/*
*.swp

38
_tools/db/migrate.txt Normal file
View File

@@ -0,0 +1,38 @@
python3 -m venv venv_migrate
source venv_migrate/bin/activate
pip install sqlalchemy psycopg2-binary dotenv
docker compose --profile pgsql up -d --build
PYTHONPATH=. python3 _tools/db/migrate_sqlite_to_pgsql.py
rm -rf venv_migrate
# reset wszystkich sekwencji w pgsql
docker exec -it pgsql-db psql -U lista -d lista
DO $$
DECLARE
r RECORD;
BEGIN
FOR r IN
SELECT
c.relname AS seq_name,
t.relname AS table_name,
a.attname AS column_name
FROM
pg_class c
JOIN
pg_depend d ON d.objid = c.oid
JOIN
pg_class t ON d.refobjid = t.oid
JOIN
pg_attribute a ON a.attrelid = t.oid AND a.attnum = d.refobjsubid
WHERE
c.relkind = 'S'
AND d.deptype = 'a'
LOOP
EXECUTE format(
'SELECT setval(%L, COALESCE((SELECT MAX(%I) FROM %I), 1), true)',
r.seq_name, r.column_name, r.table_name
);
END LOOP;
END$$;

View File

@@ -0,0 +1,61 @@
import sys
import os
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../../")))
from sqlalchemy import create_engine, MetaData
from sqlalchemy.orm import sessionmaker
from config import Config
from dotenv import load_dotenv
load_dotenv()
# Źródło: SQLite
sqlite_engine = create_engine("sqlite:///instance/shopping.db")
sqlite_meta = MetaData()
sqlite_meta.reflect(bind=sqlite_engine)
# Cel: PostgreSQL
pg_engine = create_engine(Config.SQLALCHEMY_DATABASE_URI)
pg_meta = MetaData()
pg_meta.reflect(bind=pg_engine)
# Sesje
SQLiteSession = sessionmaker(bind=sqlite_engine)
PGSession = sessionmaker(bind=pg_engine)
sqlite_session = SQLiteSession()
pg_session = PGSession()
def migrate_table(table_name):
print("➡️ Używana baza docelowa:", Config.SQLALCHEMY_DATABASE_URI)
print(f"\n➡️ Migruję tabelę: {table_name}")
source_table = sqlite_meta.tables.get(table_name)
target_table = pg_meta.tables.get(table_name)
if source_table is None or target_table is None:
print(f"⚠️ Pominięto: {table_name} (brak w jednej z baz)")
return
rows = sqlite_session.execute(source_table.select()).fetchall()
if not rows:
print(" Brak danych do migracji.")
return
insert_data = [dict(row._mapping) for row in rows]
try:
with pg_engine.begin() as conn:
conn.execute(target_table.delete())
conn.execute(target_table.insert(), insert_data)
print(f"✅ Przeniesiono: {len(rows)} rekordów")
except Exception as e:
print(f"❌ Błąd przy migracji {table_name}: {e}")
def main():
tables = ["user", "shopping_list", "item", "expense", "receipt", "suggested_product"]
for table in tables:
migrate_table(table)
print("\n🎉 Migracja zakończona pomyślnie.")
if __name__ == "__main__":
main()

61
app.py
View File

@@ -45,7 +45,7 @@ from config import Config
from PIL import Image, ExifTags, ImageFilter, ImageOps
from werkzeug.utils import secure_filename
from werkzeug.middleware.proxy_fix import ProxyFix
from sqlalchemy import func, extract
from sqlalchemy import func, extract, inspect
from collections import defaultdict, deque
from functools import wraps
@@ -100,10 +100,13 @@ def utcnow():
return datetime.now(timezone.utc)
app_start_time = utcnow()
class User(UserMixin, db.Model):
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(150), unique=True, nullable=False)
password_hash = db.Column(db.String(150), nullable=False)
password_hash = db.Column(db.String(512), nullable=False)
is_admin = db.Column(db.Boolean, default=False)
@@ -1088,9 +1091,11 @@ def all_products():
top_products_query = top_products_query.filter(
SuggestedProduct.name.ilike(f"%{query}%")
)
top_products = (
top_products_query.order_by(
SuggestedProduct.usage_count.desc(), SuggestedProduct.name.asc()
SuggestedProduct.name.asc(), # musi być pierwsze
SuggestedProduct.usage_count.desc(),
)
.distinct(SuggestedProduct.name)
.limit(20)
@@ -1125,8 +1130,9 @@ def all_products():
def upload_receipt(list_id):
l = db.session.get(ShoppingList, list_id)
if l is None or l.owner_id != current_user.id:
return _receipt_error("Nie masz uprawnień do tej listy.")
# if l is None or l.owner_id != current_user.id:
# return _receipt_error("Nie masz uprawnień do tej listy.")
if "receipt" not in request.files:
return _receipt_error("Brak pliku")
@@ -1382,6 +1388,32 @@ def admin_panel():
process = psutil.Process(os.getpid())
app_mem = process.memory_info().rss // (1024 * 1024) # MB
# Engine info
db_engine = db.engine
db_info = {
"engine": db_engine.name,
"version": getattr(db_engine.dialect, "server_version_info", None),
"url": str(db_engine.url).split("?")[0],
}
# Tabele
inspector = inspect(db_engine)
table_count = len(inspector.get_table_names())
# Rekordy (szybkie zliczenie)
record_total = (
db.session.query(func.count(User.id)).scalar()
+ db.session.query(func.count(ShoppingList.id)).scalar()
+ db.session.query(func.count(Item.id)).scalar()
+ db.session.query(func.count(Receipt.id)).scalar()
+ db.session.query(func.count(Expense.id)).scalar()
)
# Uptime
uptime_minutes = int(
(datetime.now(timezone.utc) - app_start_time).total_seconds() // 60
)
return render_template(
"admin/admin_panel.html",
user_count=user_count,
@@ -1397,6 +1429,10 @@ def admin_panel():
python_version=sys.version,
system_info=platform.platform(),
app_memory=f"{app_mem} MB",
db_info=db_info,
table_count=table_count,
record_total=record_total,
uptime_minutes=uptime_minutes,
)
@@ -1774,6 +1810,21 @@ def edit_list(list_id):
flash("Nie znaleziono produktu", "danger")
return redirect(url_for("edit_list", list_id=list_id))
elif action == "edit_quantity":
item = db.session.get(Item, request.form.get("item_id"))
if item and item.list_id == list_id:
try:
new_quantity = int(request.form.get("quantity"))
if new_quantity > 0:
item.quantity = new_quantity
db.session.commit()
flash("Zmieniono ilość produktu", "success")
except ValueError:
flash("Nieprawidłowa ilość", "danger")
else:
flash("Nie znaleziono produktu", "danger")
return redirect(url_for("edit_list", list_id=list_id))
return render_template(
"admin/edit_list.html",
list=l,

View File

@@ -1,9 +1,19 @@
import os
basedir = os.path.abspath(os.path.dirname(__file__))
class Config:
SECRET_KEY = os.environ.get("SECRET_KEY", "D8pceNZ8q%YR7^7F&9wAC2")
SQLALCHEMY_DATABASE_URI = os.environ.get("DATABASE_URL", "sqlite:///shopping.db")
DB_ENGINE = os.environ.get("DB_ENGINE", "sqlite").lower()
if DB_ENGINE == "sqlite":
SQLALCHEMY_DATABASE_URI = f"sqlite:///{os.path.join(basedir, 'instance', 'shopping.db')}"
elif DB_ENGINE == "pgsql":
SQLALCHEMY_DATABASE_URI = f"postgresql://{os.environ['DB_USER']}:{os.environ['DB_PASSWORD']}@{os.environ['DB_HOST']}:{os.environ.get('DB_PORT', 5432)}/{os.environ['DB_NAME']}"
elif DB_ENGINE == "mysql":
SQLALCHEMY_DATABASE_URI = f"mysql+pymysql://{os.environ['DB_USER']}:{os.environ['DB_PASSWORD']}@{os.environ['DB_HOST']}:{os.environ.get('DB_PORT', 3306)}/{os.environ['DB_NAME']}"
else:
raise ValueError("Nieobsługiwany typ bazy danych.")
SQLALCHEMY_TRACK_MODIFICATIONS = False
SYSTEM_PASSWORD = os.environ.get("SYSTEM_PASSWORD", "admin")
DEFAULT_ADMIN_USERNAME = os.environ.get("DEFAULT_ADMIN_USERNAME", "admin")

View File

@@ -1,13 +1,28 @@
#!/bin/bash
set -e
echo "Zatrzymuję i usuwam stare kontenery..."
docker compose down --rmi all
PROFILE=$1
if [[ -z "$PROFILE" ]]; then
echo "Uzycie: $0 {pgsql|mysql|sqlite}"
exit 1
fi
echo "Zatrzymuje kontenery aplikacji i bazy..."
if [[ "$PROFILE" == "sqlite" ]]; then
docker compose stop
else
docker compose --profile "$PROFILE" stop
fi
echo "Pobieram najnowszy kod z repozytorium..."
git pull
echo "Buduję obrazy i uruchamiam kontenery..."
docker compose up -d --build
echo "Buduje i uruchamiam kontenery..."
if [[ "$PROFILE" == "sqlite" ]]; then
docker compose up -d --build
else
DB_ENGINE="$PROFILE" docker compose --profile "$PROFILE" up -d --build
fi
echo "Gotowe!"

View File

@@ -10,18 +10,41 @@ services:
timeout: 10s
retries: 3
start_period: 10s
environment:
- FLASK_APP=app.py
- FLASK_ENV=production
- SECRET_KEY=${SECRET_KEY}
- SYSTEM_PASSWORD=${SYSTEM_PASSWORD}
- DEFAULT_ADMIN_USERNAME=${DEFAULT_ADMIN_USERNAME}
- DEFAULT_ADMIN_PASSWORD=${DEFAULT_ADMIN_PASSWORD}
- UPLOAD_FOLDER=${UPLOAD_FOLDER}
- AUTHORIZED_COOKIE_VALUE=${AUTHORIZED_COOKIE_VALUE}
- AUTH_COOKIE_MAX_AGE=${AUTH_COOKIE_MAX_AGE}
- HEALTHCHECK_TOKEN=${HEALTHCHECK_TOKEN}
- SESSION_TIMEOUT_MINUTES=${SESSION_TIMEOUT_MINUTES}
env_file:
- .env
volumes:
- .:/app
restart: unless-stopped
- ./uploads:/app/uploads
- ./instance:/app/instance
restart: unless-stopped
pgsql:
image: postgres:17
container_name: pgsql-db
environment:
POSTGRES_DB: ${DB_NAME}
POSTGRES_USER: ${DB_USER}
POSTGRES_PASSWORD: ${DB_PASSWORD}
volumes:
- ./db/pgsql:/var/lib/postgresql/data
#ports:
# - ":5432:5432"
restart: unless-stopped
hostname: db
profiles: ["pgsql"]
mysql:
image: mysql:8
container_name: mysql-db
environment:
MYSQL_DATABASE: ${DB_NAME}
MYSQL_USER: ${DB_USER}
MYSQL_PASSWORD: ${DB_PASSWORD}
MYSQL_ROOT_PASSWORD: 89o38kUX5T4C
volumes:
- ./db/mysql:/var/lib/mysql
#ports:
# - "3306:3306"
restart: unless-stopped
hostname: db
profiles: ["mysql"]

View File

@@ -10,4 +10,7 @@ psutil
pillow-heif
pytesseract
opencv-python-headless
opencv-python-headless
psycopg2-binary # pgsql
pymysql # mysql
cryptography

View File

@@ -123,7 +123,7 @@ function copyLink(link) {
if (navigator.share) {
navigator.share({
title: 'Udostępnij link',
text: 'Link do listy::',
text: 'Udostępniam link do listy:',
url: link
}).then(() => {
showToast('Link udostępniony!');

View File

@@ -198,6 +198,9 @@
{% endblock %}
<div class="info-bar-fixed">
Python: {{ python_version.split()[0] }} | {{ system_info }} | RAM app: {{ app_memory }}
Python: {{ python_version.split()[0] }} | {{ system_info }} | RAM app: {{ app_memory }} |
DB: {{ db_info.engine|upper }}{% if db_info.version %} v{{ db_info.version[0] }}{% endif %} |
Tabele: {{ table_count }} | Rekordy: {{ record_total }} |
Uptime: {{ uptime_minutes }} min
</div>
{% endblock %}

View File

@@ -65,8 +65,6 @@
</div>
</div>
<div class="mb-4">
<label class="form-label">Link do udostępnienia</label>
<input type="text" class="form-control bg-dark text-white border-secondary rounded" readonly
@@ -111,7 +109,30 @@
<tr>
<td>
<strong>{{ item.name }}</strong>
<small class="text-muted">(x{{ item.quantity }})</small>
<small class="text-small text-success">(x{{ item.quantity }})</small>
{% if item.note %}
<div class="text-info small mt-1">
<strong>Notatka:</strong> {{ item.note }}
</div>
{% endif %}
{% if item.not_purchased_reason %}
<div class="text-warning small mt-1">
<strong>Powód:</strong> {{ item.not_purchased_reason }}
</div>
{% endif %}
<form method="post" action="{{ url_for('edit_list', list_id=list.id) }}" class="mt-2">
<input type="hidden" name="action" value="edit_quantity">
<input type="hidden" name="item_id" value="{{ item.id }}">
<div class="input-group input-group-sm ">
<input type="number" name="quantity"
class="form-control bg-dark text-white border-secondary rounded-left" min="1"
value="{{ item.quantity }}">
<button type="submit" class="btn btn-outline-light">💾</button>
</div>
</form>
</td>
<td>
{% if item.purchased %}
@@ -152,12 +173,6 @@
<input type="hidden" name="item_id" value="{{ item.id }}">
<button type="submit" class="btn btn-outline-success btn-sm">✅ Przywróć na liste</button>
</form>
{% if item.not_purchased_reason %}
<div class="mt-2 text-warning small border-top pt-2">
<strong>Powód:</strong> {{ item.not_purchased_reason }}
</div>
{% endif %}
{% endif %}
</td>

View File

@@ -79,7 +79,7 @@
</ul>
{% else %}
<p><span class="badge rounded-pill bg-secondary opacity-75">Nie masz jeszcze żadnych list. Utwórz pierwszą, korzystając
z formularza powyżej!</span></p>
z formularza powyżej</span></p>
{% endif %}
{% endif %}