pgsql_mysql_docker #4
BIN
.app.py.swp
Normal file
BIN
.app.py.swp
Normal file
Binary file not shown.
29
.env.example
29
.env.example
@@ -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
4
.gitignore
vendored
@@ -5,4 +5,6 @@ env
|
||||
__pycache__
|
||||
instance/
|
||||
uploads/
|
||||
.DS_Store
|
||||
.DS_Store
|
||||
db/*
|
||||
*.swp
|
38
_tools/db/migrate.txt
Normal file
38
_tools/db/migrate.txt
Normal 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$$;
|
61
_tools/db/migrate_sqlite_to_pgsql.py
Normal file
61
_tools/db/migrate_sqlite_to_pgsql.py
Normal 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
61
app.py
@@ -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,
|
||||
|
14
config.py
14
config.py
@@ -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")
|
||||
|
@@ -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!"
|
||||
|
@@ -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"]
|
||||
|
@@ -10,4 +10,7 @@ psutil
|
||||
pillow-heif
|
||||
|
||||
pytesseract
|
||||
opencv-python-headless
|
||||
opencv-python-headless
|
||||
psycopg2-binary # pgsql
|
||||
pymysql # mysql
|
||||
cryptography
|
||||
|
@@ -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!');
|
||||
|
@@ -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 %}
|
@@ -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>
|
||||
|
||||
|
@@ -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 %}
|
||||
|
||||
|
Reference in New Issue
Block a user