diff --git a/.app.py.swp b/.app.py.swp new file mode 100644 index 0000000..5d0c051 Binary files /dev/null and b/.app.py.swp differ diff --git a/.env.example b/.env.example index ba00eff..b6961b1 100644 --- a/.env.example +++ b/.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 \ No newline at end of file +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 diff --git a/.gitignore b/.gitignore index aa9d9a9..62bde8f 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,6 @@ env __pycache__ instance/ uploads/ -.DS_Store \ No newline at end of file +.DS_Store +db/* +*.swp \ No newline at end of file diff --git a/add_products.py b/_tools/add_products.py similarity index 100% rename from add_products.py rename to _tools/add_products.py diff --git a/add_receipt_to_list.py b/_tools/add_receipt_to_list.py similarity index 100% rename from add_receipt_to_list.py rename to _tools/add_receipt_to_list.py diff --git a/_tools/db/migrate.txt b/_tools/db/migrate.txt new file mode 100644 index 0000000..eea8aa2 --- /dev/null +++ b/_tools/db/migrate.txt @@ -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$$; diff --git a/_tools/db/migrate_sqlite_to_pgsql.py b/_tools/db/migrate_sqlite_to_pgsql.py new file mode 100644 index 0000000..946a507 --- /dev/null +++ b/_tools/db/migrate_sqlite_to_pgsql.py @@ -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() diff --git a/migrate_to_webp.py b/_tools/migrate_to_webp.py similarity index 100% rename from migrate_to_webp.py rename to _tools/migrate_to_webp.py diff --git a/update_missing_image_data.py b/_tools/update_missing_image_data.py similarity index 100% rename from update_missing_image_data.py rename to _tools/update_missing_image_data.py diff --git a/app.py b/app.py index ee97ac9..d3e57a6 100644 --- a/app.py +++ b/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, diff --git a/config.py b/config.py index feb098e..7fdf671 100644 --- a/config.py +++ b/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") diff --git a/deploy_docker.sh b/deploy_docker.sh index 98c64b8..38047d3 100644 --- a/deploy_docker.sh +++ b/deploy_docker.sh @@ -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!" diff --git a/docker-compose.yml b/docker-compose.yml index 68eeafa..19ec0ed 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 \ No newline at end of file + - ./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"] diff --git a/requirements.txt b/requirements.txt index 5957702..c27062c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,4 +10,7 @@ psutil pillow-heif pytesseract -opencv-python-headless \ No newline at end of file +opencv-python-headless +psycopg2-binary # pgsql +pymysql # mysql +cryptography diff --git a/static/js/functions.js b/static/js/functions.js index 522222e..6df9da8 100644 --- a/static/js/functions.js +++ b/static/js/functions.js @@ -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!'); diff --git a/templates/admin/admin_panel.html b/templates/admin/admin_panel.html index 6c3d3f6..18c1065 100644 --- a/templates/admin/admin_panel.html +++ b/templates/admin/admin_panel.html @@ -198,6 +198,9 @@ {% endblock %}
{% endblock %} \ No newline at end of file diff --git a/templates/admin/edit_list.html b/templates/admin/edit_list.html index c8c9b85..9666185 100644 --- a/templates/admin/edit_list.html +++ b/templates/admin/edit_list.html @@ -65,8 +65,6 @@ - -Nie masz jeszcze żadnych list. Utwórz pierwszą, korzystając - z formularza powyżej!
+ z formularza powyżej {% endif %} {% endif %}