diff --git a/.env.example b/.env.example index ca8c477..f8da826 100644 --- a/.env.example +++ b/.env.example @@ -17,4 +17,7 @@ UPLOAD_FOLDER=uploads AUTHORIZED_COOKIE_VALUE=twoj_wlasny_hash # czas zycia cookie -AUTH_COOKIE_MAX_AGE=86400 \ No newline at end of file +AUTH_COOKIE_MAX_AGE=86400 + +# dla compose +HEALTHCHECK_TOKEN=alamapsaikota123 \ No newline at end of file diff --git a/app.py b/app.py index 2e02625..2403f1a 100644 --- a/app.py +++ b/app.py @@ -35,6 +35,7 @@ UPLOAD_FOLDER = app.config.get('UPLOAD_FOLDER', 'uploads') ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'webp'} AUTHORIZED_COOKIE_VALUE = app.config.get('AUTHORIZED_COOKIE_VALUE', '80d31cdfe63539c9') AUTH_COOKIE_MAX_AGE = app.config.get('AUTH_COOKIE_MAX_AGE', 86400) +HEALTHCHECK_TOKEN = app.config.get('HEALTHCHECK_TOKEN', 'alamapsaikota1234') os.makedirs(UPLOAD_FOLDER, exist_ok=True) @@ -271,7 +272,7 @@ def require_system_password(): if endpoint is None: return - if endpoint == 'system_auth': + if endpoint in ('system_auth', 'healthcheck'): return if 'authorized' not in request.cookies and not endpoint.startswith('login') and endpoint != 'favicon': @@ -839,15 +840,27 @@ def delete_user(user_id): flash('Użytkownik usunięty', 'success') return redirect(url_for('list_users')) -@app.route('/admin/receipts') +@app.route('/admin/receipts/') @login_required @admin_required -def admin_receipts(): +def admin_receipts(id): all_files = os.listdir(app.config['UPLOAD_FOLDER']) image_files = [f for f in all_files if allowed_file(f)] + + if id == "all": + filtered_files = image_files + else: + try: + list_id = int(id) + receipt_prefix = f"list_{list_id}_" + filtered_files = [f for f in image_files if f.startswith(receipt_prefix)] + except ValueError: + flash("Nieprawidłowe ID listy.", "danger") + return redirect(url_for('admin_panel')) + return render_template( 'admin/receipts.html', - image_files=image_files, + image_files=filtered_files, upload_folder=app.config['UPLOAD_FOLDER'] ) @@ -861,6 +874,10 @@ def delete_receipt(filename): flash('Plik usunięty', 'success') else: flash('Plik nie istnieje', 'danger') + + next_url = request.args.get('next') + if next_url: + return redirect(next_url) return redirect(url_for('admin_receipts')) @app.route('/admin/delete_selected_lists', methods=['POST']) @@ -879,16 +896,6 @@ def delete_selected_lists(): flash('Usunięto wybrane listy', 'success') return redirect(url_for('admin_panel')) -@app.route('/admin/archive_list/') -@login_required -@admin_required -def archive_list(list_id): - l = ShoppingList.query.get_or_404(list_id) - l.is_archived = True - db.session.commit() - flash('Lista oznaczona jako archiwalna', 'success') - return redirect(url_for('admin_panel')) - @app.route('/admin/delete_all_items') @login_required @admin_required @@ -905,55 +912,115 @@ def edit_list(list_id): l = ShoppingList.query.get_or_404(list_id) expenses = Expense.query.filter_by(list_id=list_id).all() total_expense = sum(e.amount for e in expenses) - users = User.query.all() + items = Item.query.filter_by(list_id=list_id).order_by(Item.id.desc()).all() + + # Pobranie listy plików paragonów + receipt_pattern = f"list_{list_id}_" + all_files = os.listdir(app.config['UPLOAD_FOLDER']) + receipts = [f for f in all_files if f.startswith(receipt_pattern)] if request.method == 'POST': - new_title = request.form.get('title') - new_amount_str = request.form.get('amount') - is_archived = 'archived' in request.form - new_owner_id = request.form.get('owner_id') + action = request.form.get('action') - if new_title and new_title.strip(): - l.title = new_title.strip() + if action == 'save': + new_title = request.form.get('title', '').strip() + new_amount_str = request.form.get('amount') + is_archived = 'archived' in request.form + is_public = 'public' in request.form + new_owner_id = request.form.get('owner_id') - l.is_archived = is_archived + if new_title: + l.title = new_title - if new_owner_id: - try: - new_owner_id_int = int(new_owner_id) - if User.query.get(new_owner_id_int): - l.owner_id = new_owner_id_int - else: - flash('Wybrany użytkownik nie istnieje', 'danger') + l.is_archived = is_archived + l.is_public = is_public + + if new_owner_id: + try: + new_owner_id_int = int(new_owner_id) + if User.query.get(new_owner_id_int): + l.owner_id = new_owner_id_int + else: + flash('Wybrany użytkownik nie istnieje', 'danger') + return redirect(url_for('edit_list', list_id=list_id)) + except ValueError: + flash('Niepoprawny ID użytkownika', 'danger') return redirect(url_for('edit_list', list_id=list_id)) - except ValueError: - flash('Niepoprawny ID użytkownika', 'danger') - return redirect(url_for('edit_list', list_id=list_id)) - if new_amount_str: - try: - new_amount = float(new_amount_str) - - if expenses: + if new_amount_str: + try: + new_amount = float(new_amount_str) for expense in expenses: db.session.delete(expense) db.session.commit() + new_expense = Expense(list_id=list_id, amount=new_amount) + db.session.add(new_expense) + db.session.commit() + except ValueError: + flash('Niepoprawna kwota', 'danger') + return redirect(url_for('edit_list', list_id=list_id)) - new_expense = Expense(list_id=list_id, amount=new_amount) - db.session.add(new_expense) - db.session.commit() - flash('Zaktualizowano tytuł, właściciela, archiwizację i/lub kwotę wydatku', 'success') - except ValueError: - flash('Niepoprawna kwota', 'danger') - return redirect(url_for('edit_list', list_id=list_id)) - else: db.session.commit() - flash('Zaktualizowano tytuł, właściciela i/lub archiwizację', 'success') + flash('Zapisano zmiany listy', 'success') + return redirect(url_for('edit_list', list_id=list_id)) - return redirect(url_for('admin_panel')) + elif action == 'add_item': + item_name = request.form.get('item_name', '').strip() + quantity_str = request.form.get('quantity', '1') + if not item_name: + flash('Podaj nazwę produktu', 'danger') + return redirect(url_for('edit_list', list_id=list_id)) - return render_template('admin/edit_list.html', list=l, total_expense=total_expense, users=users) + try: + quantity = int(quantity_str) + if quantity < 1: + quantity = 1 + except ValueError: + quantity = 1 + + new_item = Item(list_id=list_id, name=item_name, quantity=quantity, added_by=current_user.id) + db.session.add(new_item) + + if not SuggestedProduct.query.filter(func.lower(SuggestedProduct.name) == item_name.lower()).first(): + db.session.add(SuggestedProduct(name=item_name)) + + db.session.commit() + flash('Dodano produkt', 'success') + return redirect(url_for('edit_list', list_id=list_id)) + + elif action == 'delete_item': + item_id = request.form.get('item_id') + item = Item.query.get(item_id) + if item and item.list_id == list_id: + db.session.delete(item) + db.session.commit() + flash('Usunięto produkt', 'success') + else: + flash('Nie znaleziono produktu', 'danger') + return redirect(url_for('edit_list', list_id=list_id)) + + elif action == 'toggle_purchased': + item_id = request.form.get('item_id') + item = Item.query.get(item_id) + if item and item.list_id == list_id: + item.purchased = not item.purchased + db.session.commit() + flash('Zmieniono status oznaczenia produktu', 'success') + else: + flash('Nie znaleziono produktu', 'danger') + return redirect(url_for('edit_list', list_id=list_id)) + + # Przekazanie receipts do szablonu + return render_template( + 'admin/edit_list.html', + list=l, + total_expense=total_expense, + users=users, + items=items, + receipts=receipts, + upload_folder=app.config['UPLOAD_FOLDER'] + ) @app.route('/admin/products') @login_required @@ -974,7 +1041,6 @@ def list_products(): suggestions_dict=suggestions_dict ) - @app.route('/admin/sync_suggestion/', methods=['POST']) @login_required def sync_suggestion_ajax(item_id): @@ -1136,6 +1202,15 @@ def demote_user(user_id): flash(f'Użytkownik {user.username} został zdegradowany.', 'success') return redirect(url_for('list_users')) +@app.route('/healthcheck') +def healthcheck(): + header_token = request.headers.get("X-Internal-Check") + correct_token = app.config.get('HEALTHCHECK_TOKEN') + + if header_token != correct_token: + abort(404) + return 'OK', 200 + # ========================================================================================= # SOCKET.IO # ========================================================================================= diff --git a/config.py b/config.py index e846822..68666d5 100644 --- a/config.py +++ b/config.py @@ -9,4 +9,5 @@ class Config: DEFAULT_ADMIN_PASSWORD = os.environ.get('DEFAULT_ADMIN_PASSWORD', 'admin123') UPLOAD_FOLDER = os.environ.get('UPLOAD_FOLDER', 'uploads') AUTHORIZED_COOKIE_VALUE = os.environ.get('AUTHORIZED_COOKIE_VALUE', 'cookievalue') - AUTH_COOKIE_MAX_AGE = int(os.environ.get('AUTH_COOKIE_MAX_AGE', 86400)) \ No newline at end of file + AUTH_COOKIE_MAX_AGE = int(os.environ.get('AUTH_COOKIE_MAX_AGE', 86400)) + HEALTHCHECK_TOKEN = os.environ.get('HEALTHCHECK_TOKEN', 'alamapsaikota1234') \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 3941886..ce6e0ac 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,6 +4,12 @@ services: container_name: live-lista-zakupow ports: - "${APP_PORT:-8000}:8000" + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request; import sys; req = urllib.request.Request('http://localhost:8000/healthcheck', headers={'X-Internal-Check': '${HEALTHCHECK_TOKEN}'}); sys.exit(0) if urllib.request.urlopen(req).read() == b'OK' else sys.exit(1)"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 10s environment: - FLASK_APP=app.py - FLASK_ENV=production @@ -14,5 +20,6 @@ services: - UPLOAD_FOLDER=${UPLOAD_FOLDER} - AUTHORIZED_COOKIE_VALUE=${AUTHORIZED_COOKIE_VALUE} - AUTH_COOKIE_MAX_AGE=${AUTH_COOKIE_MAX_AGE} + - HEALTHCHECK_TOKEN=${HEALTHCHECK_TOKEN} volumes: - .:/app diff --git a/static/css/style.css b/static/css/style.css index fb1088c..878f7e0 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -186,11 +186,12 @@ input.form-control { box-sizing: border-box; } -@media (max-width: 600px) { +@media (max-width: 768px) { .info-bar-fixed { + position: static; font-size: 0.85rem; padding: 8px 4px; - border-radius: 10px 10px 0 0; + border-radius: 0; } } @@ -264,4 +265,18 @@ input.form-control { #empty-placeholder { font-style: italic; pointer-events: none; +} + +#items li.hide-purchased { + display: none !important; +} + +.list-group-item:first-child, +.list-group-item:last-child { + border-radius: 0 !important; +} + +.fade-out { + opacity: 0; + transition: opacity 0.5s ease; } \ No newline at end of file diff --git a/static/js/functions.js b/static/js/functions.js index 0bd07a3..45f6811 100644 --- a/static/js/functions.js +++ b/static/js/functions.js @@ -16,9 +16,10 @@ function updateItemState(itemId, isChecked) { if (sp) sp.remove(); } updateProgressBar(); + applyHidePurchased(); } -function updateProgressBar() { +/* function updateProgressBar() { const items = document.querySelectorAll('#items li'); const total = items.length; const purchased = Array.from(items).filter(li => li.classList.contains('bg-success')).length; @@ -30,6 +31,48 @@ function updateProgressBar() { progressBar.setAttribute('aria-valuenow', percent); progressBar.textContent = `${percent}%`; } +} */ + +function updateProgressBar() { + const items = document.querySelectorAll('#items li'); + const total = items.length; + const purchased = Array.from(items).filter(li => li.classList.contains('bg-success')).length; + const percent = total > 0 ? Math.round((purchased / total) * 100) : 0; + + // Pasek postępu + const progressBar = document.getElementById('progress-bar'); + if (progressBar) { + progressBar.style.width = `${percent}%`; + progressBar.setAttribute('aria-valuenow', percent); + progressBar.textContent = percent > 0 ? `${percent}%` : ''; // opcjonalnie + } + + // Label na pasku postępu + const progressLabel = document.getElementById('progress-label'); + if (progressLabel) { + progressLabel.textContent = `${percent}%`; + if (percent === 0) { + progressLabel.style.display = 'inline'; + } else { + progressLabel.style.display = 'none'; + } + // Kolor tekstu labela + if (percent < 50) { + progressLabel.classList.remove('text-dark'); + progressLabel.classList.add('text-white'); + } else { + progressLabel.classList.remove('text-white'); + progressLabel.classList.add('text-dark'); + } + } + + // Nagłówek + const purchasedCount = document.getElementById('purchased-count'); + if (purchasedCount) purchasedCount.textContent = purchased; + const totalCount = document.getElementById('total-count'); + if (totalCount) totalCount.textContent = total; + const percentValue = document.getElementById('percent-value'); + if (percentValue) percentValue.textContent = percent; } function addItem(listId) { @@ -91,6 +134,22 @@ function submitExpense(listId) { } function copyLink(link) { + if (navigator.share) { + navigator.share({ + title: 'Udostępnij link', + text: 'Link do listy::', + url: link + }).then(() => { + showToast('Link udostępniony!'); + }).catch((err) => { + tryClipboard(link); + }); + return; + } + tryClipboard(link); +} + +function tryClipboard(link) { if (navigator.clipboard && window.isSecureContext) { navigator.clipboard.writeText(link).then(() => { showToast('Link skopiowany do schowka!'); @@ -103,33 +162,6 @@ function copyLink(link) { } } -/* function shareLink(link) { - if (navigator.share) { - navigator.share({ - title: 'Udostępnij moją listę', - text: 'Zobacz tę listę!', - url: link - }) - .catch((error) => { - console.error('Błąd podczas udostępniania', error); - alert('Nie udało się udostępnić linka'); - }); - } else { - copyLink(link); - } -} - -function fallbackCopy(link) { - navigator.clipboard.writeText(link).then(() => { - alert('Link skopiowany do schowka!'); - }); -} - */ - -function openList(link) { - window.open(link, '_blank'); -} - function fallbackCopyText(text) { const textarea = document.createElement('textarea'); textarea.value = text; @@ -156,6 +188,48 @@ function fallbackCopyText(text) { document.body.removeChild(textarea); } +function openList(link) { + window.open(link, '_blank'); +} + +function applyHidePurchased(isInit = false) { + console.log("applyHidePurchased: wywołana, isInit =", isInit); + const toggle = document.getElementById('hidePurchasedToggle'); + if (!toggle) return; + const hide = toggle.checked; + + const items = document.querySelectorAll('#items li'); + + items.forEach(li => { + const isPurchased = li.classList.contains('bg-success'); + + if (isPurchased) { + if (hide) { + if (isInit) { + // Jeśli inicjalizacja: od razu ukryj + li.classList.add('hide-purchased'); + li.classList.remove('fade-out'); + } else { + // Z animacją + li.classList.add('fade-out'); + setTimeout(() => { + li.classList.add('hide-purchased'); + }, 700); + } + } else { + // Odsłanianie + li.classList.remove('hide-purchased'); + setTimeout(() => { + li.classList.remove('fade-out'); + }, 10); + } + } else { + // Element niekupiony — zawsze pokazany + li.classList.remove('hide-purchased', 'fade-out'); + } + }); +} + function toggleVisibility(listId) { fetch('/toggle_visibility/' + listId, {method: 'POST'}) .then(response => response.json()) @@ -282,6 +356,7 @@ function updateListSmoothly(newItems) { updateProgressBar(); toggleEmptyPlaceholder(); + applyHidePurchased(); } document.addEventListener("DOMContentLoaded", function() { @@ -302,3 +377,16 @@ document.addEventListener("DOMContentLoaded", function() { localStorage.setItem("receiptSectionOpen", "false"); }); }); + +document.addEventListener("DOMContentLoaded", function() { + const toggle = document.getElementById('hidePurchasedToggle'); + if (!toggle) return; + + const savedState = localStorage.getItem('hidePurchasedToggle'); + toggle.checked = savedState === 'true'; + applyHidePurchased(true); + toggle.addEventListener('change', function() { + localStorage.setItem('hidePurchasedToggle', toggle.checked ? 'true' : 'false'); + applyHidePurchased(); + }); +}); \ No newline at end of file diff --git a/templates/admin/admin_panel.html b/templates/admin/admin_panel.html index 474f604..44624a7 100644 --- a/templates/admin/admin_panel.html +++ b/templates/admin/admin_panel.html @@ -20,7 +20,7 @@ 👥 Zarządzanie użytkownikami