diff --git a/.env.example b/.env.example index b7772ec..f2e1547 100644 --- a/.env.example +++ b/.env.example @@ -10,3 +10,5 @@ SYSTEM_PASSWORD=admin # Domyślny admin (login i hasło) DEFAULT_ADMIN_USERNAME=admin DEFAULT_ADMIN_PASSWORD=admin123 + +UPLOAD_FOLDER=uploads \ No newline at end of file diff --git a/.gitignore b/.gitignore index 1363273..9b8d872 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,5 @@ venv env *.db __pycache__ -instance \ No newline at end of file +instance/ +uploads/ \ No newline at end of file diff --git a/alters.txt b/alters.txt index f6e273f..92ca219 100644 --- a/alters.txt +++ b/alters.txt @@ -2,4 +2,8 @@ CREATE TABLE IF NOT EXISTS suggested_product ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT UNIQUE NOT NULL -); \ No newline at end of file +); + +# NOTATKI +ALTER TABLE item +ADD COLUMN note TEXT; \ No newline at end of file diff --git a/app.py b/app.py index 2e2e5c4..d16e2c0 100644 --- a/app.py +++ b/app.py @@ -3,17 +3,27 @@ import secrets import time from datetime import datetime, timedelta from flask import Flask, render_template, redirect, url_for, request, flash, Blueprint, send_from_directory +from markupsafe import Markup + from flask_sqlalchemy import SQLAlchemy from flask_login import LoginManager, UserMixin, login_user, login_required, logout_user, current_user from flask_socketio import SocketIO, emit, join_room from werkzeug.security import generate_password_hash, check_password_hash from config import Config +from PIL import Image +from werkzeug.utils import secure_filename +from werkzeug.middleware.proxy_fix import ProxyFix app = Flask(__name__) app.config.from_object(Config) +app.wsgi_app = ProxyFix(app.wsgi_app, x_proto=1, x_host=1) SYSTEM_PASSWORD = app.config.get('SYSTEM_PASSWORD', 'changeme') DEFAULT_ADMIN_USERNAME = app.config.get('DEFAULT_ADMIN_USERNAME', 'admin') DEFAULT_ADMIN_PASSWORD = app.config.get('DEFAULT_ADMIN_PASSWORD', 'admin123') +UPLOAD_FOLDER = app.config.get('UPLOAD_FOLDER', 'uploads') +ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'webp'} + +os.makedirs(UPLOAD_FOLDER, exist_ok=True) db = SQLAlchemy(app) socketio = SocketIO(app) @@ -22,19 +32,6 @@ login_manager.login_view = 'login' static_bp = Blueprint('static_bp', __name__) -@static_bp.route('/static/js/live.js') -def serve_live_js(): - response = send_from_directory('static/js', 'live.js') - response.cache_control.no_cache = True - response.cache_control.no_store = True - response.cache_control.must_revalidate = True - response.expires = 0 - response.pragma = 'no-cache' - return response - -app.register_blueprint(static_bp) - - class User(UserMixin, db.Model): id = db.Column(db.Integer, primary_key=True) username = db.Column(db.String(150), unique=True, nullable=False) @@ -59,11 +56,27 @@ class Item(db.Model): added_by = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=True) purchased = db.Column(db.Boolean, default=False) purchased_at = db.Column(db.DateTime, nullable=True) + note = db.Column(db.Text, nullable=True) class SuggestedProduct(db.Model): id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(150), unique=True, nullable=False) +@static_bp.route('/static/js/live.js') +def serve_live_js(): + response = send_from_directory('static/js', 'live.js') + response.cache_control.no_cache = True + response.cache_control.no_store = True + response.cache_control.must_revalidate = True + response.expires = 0 + response.pragma = 'no-cache' + return response + +app.register_blueprint(static_bp) + +def allowed_file(filename): + return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS + @login_manager.user_loader def load_user(user_id): return User.query.get(int(user_id)) @@ -78,12 +91,39 @@ def require_system_password(): and request.endpoint != 'system_auth' \ and not request.endpoint.startswith('static') \ and not request.endpoint.startswith('login'): - return redirect(url_for('system_auth', next=request.url)) + # Jeśli wchodzi na '/', nie dodawaj next + if request.path == '/': + return redirect(url_for('system_auth')) + else: + # W innym przypadku poprawiamy URL jak wcześniej + from urllib.parse import urlparse, urlunparse + parsed = urlparse(request.url) + fixed_url = urlunparse(parsed._replace(netloc=request.host)) + return redirect(url_for('system_auth', next=fixed_url)) + +@app.template_filter('filemtime') +def file_mtime_filter(path): + try: + t = os.path.getmtime(path) + return datetime.fromtimestamp(t) + except Exception: + return datetime.utcnow() + +@app.template_filter('filesizeformat') +def filesizeformat_filter(path): + try: + size = os.path.getsize(path) + # Jeśli chcesz dokładniejszy format, np. KB, MB + for unit in ['B', 'KB', 'MB', 'GB']: + if size < 1024.0: + return f"{size:.1f} {unit}" + size /= 1024.0 + return f"{size:.1f} TB" + except Exception: + return "N/A" @app.route('/system-auth', methods=['GET', 'POST']) def system_auth(): - DEFAULT_ADMIN_USERNAME = app.config.get('DEFAULT_ADMIN_USERNAME', 'admin') - DEFAULT_ADMIN_PASSWORD = app.config.get('DEFAULT_ADMIN_PASSWORD', 'admin123') next_page = request.args.get('next') or url_for('index_guest') @@ -102,7 +142,7 @@ def system_auth(): resp = redirect(next_page) resp.set_cookie('authorized', 'true') return resp - flash('Nieprawidłowe hasło do systemu') + flash('Nieprawidłowe hasło do systemu','danger') return render_template('system_auth.html') @app.route('/') @@ -154,19 +194,38 @@ def create_list(): def view_list(list_id): shopping_list = ShoppingList.query.get_or_404(list_id) items = Item.query.filter_by(list_id=list_id).all() - return render_template('list.html', list=shopping_list, items=items) + + receipt_pattern = f"list_{list_id}" + all_files = os.listdir(app.config['UPLOAD_FOLDER']) + + receipt_files = [f for f in all_files if receipt_pattern in f] + + return render_template('list.html', list=shopping_list, items=items, receipt_files=receipt_files) @app.route('/share/') def share_list(token): shopping_list = ShoppingList.query.filter_by(share_token=token).first_or_404() items = Item.query.filter_by(list_id=shopping_list.id).all() - return render_template('list_guest.html', list=shopping_list, items=items) + + receipt_pattern = f"list_{shopping_list.id}" + all_files = os.listdir(app.config['UPLOAD_FOLDER']) + receipt_files = [f for f in all_files if receipt_pattern in f] + + return render_template('list_guest.html', list=shopping_list, items=items, receipt_files=receipt_files) + @app.route('/guest-list/') def guest_list(list_id): shopping_list = ShoppingList.query.get_or_404(list_id) items = Item.query.filter_by(list_id=list_id).all() - return render_template('list_guest.html', list=shopping_list, items=items) + + receipt_pattern = f"list_{list_id}" + all_files = os.listdir(app.config['UPLOAD_FOLDER']) + + receipt_files = [f for f in all_files if receipt_pattern in f] + + return render_template('list_guest.html', list=shopping_list, items=items, receipt_files=receipt_files) + @app.route('/copy/') @login_required @@ -192,6 +251,47 @@ def suggest_products(): suggestions = SuggestedProduct.query.filter(SuggestedProduct.name.ilike(f'%{query}%')).limit(5).all() return {'suggestions': [s.name for s in suggestions]} +@app.route('/upload_receipt/', methods=['POST']) +def upload_receipt(list_id): + if 'receipt' not in request.files: + flash('Brak pliku', 'danger') + return redirect(request.referrer) + + file = request.files['receipt'] + + if file.filename == '': + flash('Nie wybrano pliku', 'danger') + return redirect(request.referrer) + + if file and allowed_file(file.filename): + filename = secure_filename(file.filename) + file_path = os.path.join(app.config['UPLOAD_FOLDER'], f"list_{list_id}_{filename}") + + img = Image.open(file) + img.thumbnail((800, 800)) + img.save(file_path) + + flash('Wgrano paragon', 'success') + return redirect(request.referrer) + + flash('Niedozwolony format pliku', 'danger') + return redirect(request.referrer) + +@app.route('/uploads/') +def uploaded_file(filename): + response = send_from_directory(app.config['UPLOAD_FOLDER'], filename) + response.headers['Cache-Control'] = 'public, max-age=2592000, immutable' + response.headers.pop('Pragma', None) + return response + +@app.route('/update-note/', methods=['POST']) +def update_note(item_id): + item = Item.query.get_or_404(item_id) + note = request.form.get('note') + item.note = note + db.session.commit() + return {'success': True} + @app.route('/admin') @login_required def admin_panel(): @@ -278,6 +378,35 @@ def delete_user(user_id): flash('Użytkownik usunięty', 'success') return redirect(url_for('list_users')) + +@app.route('/admin/receipts') +@login_required +def admin_receipts(): + if not current_user.is_admin: + return redirect(url_for('index_guest')) + all_files = os.listdir(app.config['UPLOAD_FOLDER']) + image_files = [f for f in all_files if allowed_file(f)] + return render_template( + 'admin/receipts.html', + image_files=image_files, + upload_folder=app.config['UPLOAD_FOLDER'] + ) + + +@app.route('/admin/delete_receipt/') +@login_required +def delete_receipt(filename): + if not current_user.is_admin: + return redirect(url_for('index_guest')) + file_path = os.path.join(app.config['UPLOAD_FOLDER'], filename) + if os.path.exists(file_path): + os.remove(file_path) + flash('Plik usunięty', 'success') + else: + flash('Plik nie istnieje', 'danger') + return redirect(url_for('admin_receipts')) + + @socketio.on('delete_item') def handle_delete_item(data): item = Item.query.get(data['item_id']) @@ -295,7 +424,6 @@ def handle_edit_item(data): db.session.commit() emit('item_edited', {'item_id': item.id, 'new_name': item.name}, to=str(item.list_id)) - @socketio.on('join_list') def handle_join(data): room = str(data['room']) @@ -343,6 +471,15 @@ def handle_uncheck_item(data): db.session.commit() emit('item_unchecked', {'item_id': item.id}, to=str(item.list_id)) +@socketio.on('update_note') +def handle_update_note(data): + item_id = data['item_id'] + note = data['note'] + item = Item.query.get(item_id) + if item: + item.note = note + db.session.commit() + emit('note_updated', {'item_id': item_id, 'note': note}, to=str(item.list_id)) @app.cli.command('create_db') def create_db(): diff --git a/config.py b/config.py index 70f248e..b05f22f 100644 --- a/config.py +++ b/config.py @@ -7,3 +7,4 @@ class Config: SYSTEM_PASSWORD = os.environ.get('SYSTEM_PASSWORD', 'admin') DEFAULT_ADMIN_USERNAME = os.environ.get('DEFAULT_ADMIN_USERNAME', 'admin') DEFAULT_ADMIN_PASSWORD = os.environ.get('DEFAULT_ADMIN_PASSWORD', 'admin123') + UPLOAD_FOLDER = os.environ.get('UPLOAD_FOLDER', 'uploads') diff --git a/requirements.txt b/requirements.txt index 0b8f317..b9dfb26 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,3 +4,4 @@ Flask-Login Flask-SocketIO eventlet Werkzeug +Pillow \ No newline at end of file diff --git a/static/js/list_guest.js b/static/js/list_guest.js new file mode 100644 index 0000000..40d569b --- /dev/null +++ b/static/js/list_guest.js @@ -0,0 +1,56 @@ +let currentItemId = null; + +function openNoteModal(event, itemId) { + event.stopPropagation(); + currentItemId = itemId; + // Pobierz notatkę z HTML-a, jeśli chcesz pokazywać aktualną (opcjonalnie) + const noteEl = document.querySelector(`#item-${itemId} small`); + document.getElementById('noteText').value = noteEl ? noteEl.innerText : ""; + const modal = new bootstrap.Modal(document.getElementById('noteModal')); + modal.show(); +} + +function submitNote(e) { + e.preventDefault(); + const text = document.getElementById('noteText').value; + + if (currentItemId !== null) { + socket.emit('update_note', { item_id: currentItemId, note: text }); + + const modal = bootstrap.Modal.getInstance(document.getElementById('noteModal')); + modal.hide(); + } +} + +document.addEventListener("DOMContentLoaded", () => { + document.querySelectorAll('.clickable-item').forEach(item => { + item.addEventListener('click', function(e) { + // Jeśli klik w button (np. Notatka), nie zaznaczaj + if (!e.target.closest('button') && e.target.tagName.toLowerCase() !== 'input') { + const checkbox = this.querySelector('input[type="checkbox"]'); + + if (checkbox.disabled) { + return; + } + + if (checkbox.checked) { + socket.emit('uncheck_item', { item_id: parseInt(this.id.replace('item-', ''), 10) }); + } else { + socket.emit('check_item', { item_id: parseInt(this.id.replace('item-', ''), 10) }); + } + + checkbox.disabled = true; + this.classList.add('opacity-50'); + + let existingSpinner = this.querySelector('.spinner-border'); + if (!existingSpinner) { + const spinner = document.createElement('span'); + spinner.className = 'spinner-border spinner-border-sm ms-2'; + spinner.setAttribute('role', 'status'); + spinner.setAttribute('aria-hidden', 'true'); + checkbox.parentElement.appendChild(spinner); + } + } + }); + }); +}); diff --git a/static/js/live.js b/static/js/live.js index 3e3658e..f01aa7b 100644 --- a/static/js/live.js +++ b/static/js/live.js @@ -36,7 +36,7 @@ function setupList(listId, username) { item.onclick = () => { newItemInput.value = s; suggestionsBox.innerHTML = ''; - newItemInput.focus(); + //newItemInput.focus(); }; suggestionsBox.appendChild(item); }); @@ -119,6 +119,31 @@ function setupList(listId, username) { updateProgressBar(); }); + socket.on('note_updated', data => { + const itemEl = document.getElementById(`item-${data.item_id}`); + if (itemEl) { + // Szukamy w całym elemencie + let noteEl = itemEl.querySelector('small'); + if (noteEl) { + noteEl.innerHTML = `[ Notatka: ${data.note} ]`; + } else { + const newNote = document.createElement('small'); + newNote.className = 'text-danger ms-4'; + newNote.innerHTML = `[ Notatka: ${data.note} ]`; + + // Znajdź wrapper flex-column + const flexColumn = itemEl.querySelector('.d-flex.flex-column'); + if (flexColumn) { + flexColumn.appendChild(newNote); + } else { + // fallback: dodaj do elementu + itemEl.appendChild(newNote); + } + } + } + showToast('Notatka zaktualizowana!'); + }); + socket.on('item_edited', data => { const nameSpan = document.getElementById(`name-${data.item_id}`); if (nameSpan) { @@ -208,3 +233,4 @@ function showToast(message) { toastContainer.appendChild(toast); setTimeout(() => { toast.remove(); }, 1750); } + diff --git a/templates/admin/admin_panel.html b/templates/admin/admin_panel.html index 962c3c9..04f0fa5 100644 --- a/templates/admin/admin_panel.html +++ b/templates/admin/admin_panel.html @@ -18,6 +18,7 @@ diff --git a/templates/admin/receipts.html b/templates/admin/receipts.html new file mode 100644 index 0000000..fc7644d --- /dev/null +++ b/templates/admin/receipts.html @@ -0,0 +1,39 @@ +{% extends 'base.html' %} +{% block title %}Wszystkie paragony{% endblock %} +{% block content %} + +
+

📸 Wszystkie paragony

+ ← Powrót do panelu +
+ +
+ {% for img in image_files %} + {% set list_id = img.split('_')[1] if '_' in img else None %} + {% set file_path = (upload_folder ~ '/' ~ img) %} + {% set file_size = (file_path | filesizeformat) %} + {% set upload_time = (file_path | filemtime) %} +
+
+ + + +
+

{{ img }}

+

Rozmiar: {{ file_size }}

+

Wgrano: {{ upload_time.strftime('%Y-%m-%d %H:%M') }}

+ {% if list_id %} + 🔗 Lista #{{ list_id }} + {% endif %} + 🗑️ Usuń +
+
+
+ {% endfor %} +
+ +{% if not image_files %} +

Brak wgranych zdjęć.

+{% endif %} + +{% endblock %} diff --git a/templates/base.html b/templates/base.html index 8c8f5ef..a430d1f 100644 --- a/templates/base.html +++ b/templates/base.html @@ -5,6 +5,8 @@ {% block title %}Live Lista Zakupów{% endblock %} + + @@ -45,7 +47,9 @@ {% endwith %} }); - + + + diff --git a/templates/list.html b/templates/list.html index 368ca3c..48d680e 100644 --- a/templates/list.html +++ b/templates/list.html @@ -26,24 +26,47 @@
    {% for item in items %} -
  • -
    - - {{ item.name }} -
    -
    - - -
    -
  • +
  • +
    + + {{ item.name }} + {% if item.note %} + [ Notatka: {{ item.note }} ] + {% endif %} +
    +
    + + +
    +
  • {% endfor %}
+
+{% set receipt_pattern = 'list_' ~ list.id %} +{% if receipt_files %} +
+
📸 Paragony dodane do tej listy
+
+ {% for file in receipt_files %} +
+ + + +
+ {% endfor %} +
+{% else %} +
+

Brak wgranych paragonów do tej listy.

+{% endif %} + +
+ +