new file: app.py
new file: pos_database.db new file: static/cache/7801610000571.jpg new file: static/cache/7801620009441.jpg new file: static/cache/9002490100070.jpg new file: static/placeholder.png new file: templates/index.html new file: templates/login.html
This commit is contained in:
154
app.py
Normal file
154
app.py
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
import requests
|
||||||
|
from flask import Flask, render_template, request, jsonify, redirect, url_for, flash, send_from_directory
|
||||||
|
from flask_socketio import SocketIO, emit
|
||||||
|
from flask_login import LoginManager, UserMixin, login_user, login_required, logout_user, current_user
|
||||||
|
from werkzeug.security import generate_password_hash, check_password_hash
|
||||||
|
|
||||||
|
app = Flask(__name__)
|
||||||
|
app.config['SECRET_KEY'] = 'seki_super_secret_key_99' # Change this if you have actual friends
|
||||||
|
socketio = SocketIO(app, cors_allowed_origins="*")
|
||||||
|
|
||||||
|
# Auth Setup
|
||||||
|
login_manager = LoginManager(app)
|
||||||
|
login_manager.login_view = 'login'
|
||||||
|
|
||||||
|
DB_FILE = 'pos_database.db'
|
||||||
|
CACHE_DIR = 'static/cache'
|
||||||
|
os.makedirs(CACHE_DIR, exist_ok=True)
|
||||||
|
|
||||||
|
# --- MODELS ---
|
||||||
|
class User(UserMixin):
|
||||||
|
def __init__(self, id, username):
|
||||||
|
self.id = id
|
||||||
|
self.username = username
|
||||||
|
|
||||||
|
# --- DATABASE LOGIC ---
|
||||||
|
def init_db():
|
||||||
|
with sqlite3.connect(DB_FILE) as conn:
|
||||||
|
conn.execute('''CREATE TABLE IF NOT EXISTS users
|
||||||
|
(id INTEGER PRIMARY KEY, username TEXT UNIQUE, password TEXT)''')
|
||||||
|
conn.execute('''CREATE TABLE IF NOT EXISTS products
|
||||||
|
(barcode TEXT PRIMARY KEY, name TEXT, price REAL, image_url TEXT)''')
|
||||||
|
|
||||||
|
# Default user: admin / Pass: seki123
|
||||||
|
user = conn.execute('SELECT * FROM users WHERE username = ?', ('admin',)).fetchone()
|
||||||
|
if not user:
|
||||||
|
hashed_pw = generate_password_hash('seki123')
|
||||||
|
conn.execute('INSERT INTO users (username, password) VALUES (?, ?)', ('admin', hashed_pw))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
@login_manager.user_loader
|
||||||
|
def load_user(user_id):
|
||||||
|
with sqlite3.connect(DB_FILE) as conn:
|
||||||
|
user = conn.execute('SELECT id, username FROM users WHERE id = ?', (user_id,)).fetchone()
|
||||||
|
return User(user[0], user[1]) if user else None
|
||||||
|
|
||||||
|
# --- HELPERS ---
|
||||||
|
def download_image(url, barcode):
|
||||||
|
if not url or not url.startswith('http'): return url
|
||||||
|
local_filename = f"{barcode}.jpg"
|
||||||
|
local_path = os.path.join(CACHE_DIR, local_filename)
|
||||||
|
if os.path.exists(local_path): return f"/static/cache/{local_filename}"
|
||||||
|
try:
|
||||||
|
headers = {'User-Agent': 'SekiPOS/1.0'}
|
||||||
|
r = requests.get(url, headers=headers, stream=True, timeout=5)
|
||||||
|
if r.status_code == 200:
|
||||||
|
with open(local_path, 'wb') as f:
|
||||||
|
for chunk in r.iter_content(1024): f.write(chunk)
|
||||||
|
return f"/static/cache/{local_filename}"
|
||||||
|
except: pass
|
||||||
|
return url
|
||||||
|
|
||||||
|
def fetch_from_openfoodfacts(barcode):
|
||||||
|
url = f"https://world.openfoodfacts.org/api/v2/product/{barcode}.json"
|
||||||
|
try:
|
||||||
|
headers = {'User-Agent': 'SekiPOS/1.0'}
|
||||||
|
resp = requests.get(url, headers=headers, timeout=5).json()
|
||||||
|
if resp.get('status') == 1:
|
||||||
|
p = resp.get('product', {})
|
||||||
|
name = p.get('product_name_es') or p.get('product_name') or p.get('brands', 'Unknown')
|
||||||
|
imgs = p.get('selected_images', {}).get('front', {}).get('display', {})
|
||||||
|
img_url = imgs.get('es') or imgs.get('en') or p.get('image_url', '')
|
||||||
|
local_img = download_image(img_url, barcode)
|
||||||
|
return {"name": name, "image": local_img}
|
||||||
|
except: pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
# --- ROUTES ---
|
||||||
|
@app.route('/login', methods=['GET', 'POST'])
|
||||||
|
def login():
|
||||||
|
if request.method == 'POST':
|
||||||
|
user_in = request.form.get('username')
|
||||||
|
pass_in = request.form.get('password')
|
||||||
|
with sqlite3.connect(DB_FILE) as conn:
|
||||||
|
user = conn.execute('SELECT * FROM users WHERE username = ?', (user_in,)).fetchone()
|
||||||
|
if user and check_password_hash(user[2], pass_in):
|
||||||
|
login_user(User(user[0], user[1]))
|
||||||
|
return redirect(url_for('index'))
|
||||||
|
flash('Invalid credentials.')
|
||||||
|
return render_template('login.html')
|
||||||
|
|
||||||
|
@app.route('/logout')
|
||||||
|
@login_required
|
||||||
|
def logout():
|
||||||
|
logout_user(); return redirect(url_for('login'))
|
||||||
|
|
||||||
|
@app.route('/')
|
||||||
|
@login_required
|
||||||
|
def index():
|
||||||
|
with sqlite3.connect(DB_FILE) as conn:
|
||||||
|
products = conn.execute('SELECT * FROM products').fetchall()
|
||||||
|
return render_template('index.html', products=products, user=current_user)
|
||||||
|
|
||||||
|
@app.route('/upsert', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def upsert():
|
||||||
|
d = request.form
|
||||||
|
with sqlite3.connect(DB_FILE) as conn:
|
||||||
|
conn.execute('''INSERT INTO products (barcode, name, price, image_url) VALUES (?,?,?,?)
|
||||||
|
ON CONFLICT(barcode) DO UPDATE SET name=excluded.name,
|
||||||
|
price=excluded.price, image_url=excluded.image_url''',
|
||||||
|
(d['barcode'], d['name'], d['price'], d['image_url']))
|
||||||
|
conn.commit()
|
||||||
|
return redirect(url_for('index'))
|
||||||
|
|
||||||
|
@app.route('/delete/<barcode>', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def delete(barcode):
|
||||||
|
with sqlite3.connect(DB_FILE) as conn:
|
||||||
|
conn.execute('DELETE FROM products WHERE barcode = ?', (barcode,))
|
||||||
|
conn.commit()
|
||||||
|
# Clean up cache
|
||||||
|
img_p = os.path.join(CACHE_DIR, f"{barcode}.jpg")
|
||||||
|
if os.path.exists(img_p): os.remove(img_p)
|
||||||
|
socketio.emit('product_deleted', {"barcode": barcode})
|
||||||
|
return redirect(url_for('index'))
|
||||||
|
|
||||||
|
@app.route('/scan', methods=['GET'])
|
||||||
|
def scan():
|
||||||
|
barcode = request.args.get('content', '').replace('{content}', '')
|
||||||
|
if not barcode: return jsonify({"err": "empty"}), 400
|
||||||
|
|
||||||
|
with sqlite3.connect(DB_FILE) as conn:
|
||||||
|
p = conn.execute('SELECT * FROM products WHERE barcode = ?', (barcode,)).fetchone()
|
||||||
|
|
||||||
|
if p:
|
||||||
|
socketio.emit('new_scan', {"barcode": p[0], "name": p[1], "price": int(p[2]), "image": p[3]})
|
||||||
|
return jsonify({"status": "ok"})
|
||||||
|
else:
|
||||||
|
ext = fetch_from_openfoodfacts(barcode)
|
||||||
|
if ext:
|
||||||
|
socketio.emit('scan_error', {"barcode": barcode, "name": ext['name'], "image": ext['image']})
|
||||||
|
else:
|
||||||
|
socketio.emit('scan_error', {"barcode": barcode})
|
||||||
|
return jsonify({"status": "not_found"}), 404
|
||||||
|
|
||||||
|
@app.route('/static/cache/<path:filename>')
|
||||||
|
def serve_cache(filename):
|
||||||
|
return send_from_directory(CACHE_DIR, filename)
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
init_db()
|
||||||
|
socketio.run(app, host='0.0.0.0', port=5000, debug=True)
|
||||||
BIN
pos_database.db
Normal file
BIN
pos_database.db
Normal file
Binary file not shown.
BIN
static/cache/7801610000571.jpg
vendored
Normal file
BIN
static/cache/7801610000571.jpg
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 31 KiB |
BIN
static/cache/7801620009441.jpg
vendored
Normal file
BIN
static/cache/7801620009441.jpg
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
BIN
static/cache/9002490100070.jpg
vendored
Normal file
BIN
static/cache/9002490100070.jpg
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 19 KiB |
BIN
static/placeholder.png
Normal file
BIN
static/placeholder.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 232 KiB |
243
templates/index.html
Normal file
243
templates/index.html
Normal file
@@ -0,0 +1,243 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="es">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>SekiPOS - Inventory Management</title>
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/socket.io/4.0.1/socket.io.js"></script>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 20px;
|
||||||
|
background: #f4f7f6;
|
||||||
|
color: #333;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.header-bar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
background: white;
|
||||||
|
padding: 10px 25px;
|
||||||
|
border-radius: 10px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
box-shadow: 0 2px 10px rgba(0,0,0,0.05);
|
||||||
|
}
|
||||||
|
.main-container { display: flex; gap: 25px; }
|
||||||
|
.column { flex: 1; min-width: 400px; }
|
||||||
|
.card {
|
||||||
|
background: white;
|
||||||
|
padding: 25px;
|
||||||
|
border-radius: 15px;
|
||||||
|
box-shadow: 0 10px 25px rgba(0,0,0,0.05);
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
#display-img {
|
||||||
|
max-width: 250px;
|
||||||
|
max-height: 250px;
|
||||||
|
border-radius: 10px;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
.price-tag { font-size: 3em; color: #2c3e50; font-weight: 800; margin: 10px 0; }
|
||||||
|
input {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
margin: 12px 0;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
button { padding: 10px 15px; cursor: pointer; border-radius: 6px; border: none; transition: 0.2s; }
|
||||||
|
.btn-save { background: #2c3e50; color: white; width: 100%; font-size: 1.1em; }
|
||||||
|
.btn-save:hover { background: #34495e; }
|
||||||
|
.btn-edit { background: #f1c40f; color: #000; }
|
||||||
|
.btn-del { background: #e74c3c; color: white; }
|
||||||
|
|
||||||
|
table { width: 100%; border-collapse: collapse; background: white; border-radius: 10px; overflow: hidden; }
|
||||||
|
th, td { padding: 15px; border-bottom: 1px solid #eee; text-align: left; }
|
||||||
|
th { background: #fafafa; }
|
||||||
|
|
||||||
|
#new-product-prompt {
|
||||||
|
display: none;
|
||||||
|
background: #3498db;
|
||||||
|
color: white;
|
||||||
|
padding: 12px 20px;
|
||||||
|
border-radius: 10px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
animation: slideDown 0.4s ease;
|
||||||
|
|
||||||
|
/* This is what you were missing */
|
||||||
|
display: none; /* JS will change this to flex */
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideDown {
|
||||||
|
from { transform: translateY(-20px); opacity: 0; }
|
||||||
|
to { transform: translateY(0); opacity: 1; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div class="header-bar">
|
||||||
|
<h2 style="margin:0;">SekiPOS v1.0</h2>
|
||||||
|
<div>
|
||||||
|
<span>Usuario: <b>{{ user.username }}</b></span> |
|
||||||
|
<a href="/logout" style="color: #e74c3c; text-decoration: none; font-weight: bold;">Cerrar Sesión</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="main-container">
|
||||||
|
<div class="column">
|
||||||
|
<!-- Notification for New Items -->
|
||||||
|
<div id="new-product-prompt">
|
||||||
|
<span style="flex-grow: 1;">¡Nuevo! Barcode <b><span id="new-barcode-display"></span></b>. ¿Deseas agregarlo?</span>
|
||||||
|
<button onclick="dismissPrompt()" style="background:rgba(255,255,255,0.2); color:white; margin-left: 15px;">Omitir</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Visual Display Card -->
|
||||||
|
<div class="card" id="scan-display">
|
||||||
|
<h2 style="color: #7f8c8d; margin-top:0;">Último Escaneado</h2>
|
||||||
|
<img id="display-img" src="./static/placeholder.png" alt="Producto">
|
||||||
|
<h1 id="display-name" style="margin: 10px 0;">Escanea un producto</h1>
|
||||||
|
<div class="price-tag" id="display-price">$0</div>
|
||||||
|
<p id="display-barcode" style="color: #bdc3c7; font-family: monospace;"></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Form Card -->
|
||||||
|
<div class="card">
|
||||||
|
<h3 id="form-title" style="margin-top:0;">Agregar/Editar Producto</h3>
|
||||||
|
<form action="/upsert" method="POST" id="product-form">
|
||||||
|
<input type="text" name="barcode" id="form-barcode" placeholder="Barcode" required>
|
||||||
|
<input type="text" name="name" id="form-name" placeholder="Nombre del Producto" required>
|
||||||
|
<input type="number" name="price" id="form-price" placeholder="Precio (CLP)" required>
|
||||||
|
<input type="text" name="image_url" id="form-image" placeholder="URL de Imagen">
|
||||||
|
<button type="submit" class="btn-save">Guardar en Inventario</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="column">
|
||||||
|
<div class="card">
|
||||||
|
<h3 style="text-align: left; margin-top:0;">Inventario</h3>
|
||||||
|
<input type="text" id="searchInput" onkeyup="searchTable()" placeholder="Buscar por nombre o código...">
|
||||||
|
|
||||||
|
<table id="inventoryTable">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Código</th>
|
||||||
|
<th>Nombre</th>
|
||||||
|
<th>Precio</th>
|
||||||
|
<th>Acciones</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for p in products %}
|
||||||
|
<tr>
|
||||||
|
<td style="font-family: monospace;">{{ p[0] }}</td>
|
||||||
|
<td>{{ p[1] }}</td>
|
||||||
|
<td class="price-cell" data-value="{{ p[2] }}"></td>
|
||||||
|
<td style="white-space: nowrap;">
|
||||||
|
<button class="btn-edit" onclick="editProduct('{{ p[0] }}', '{{ p[1] }}', '{{ p[2] }}', '{{ p[3] }}')">Editar</button>
|
||||||
|
<form action="/delete/{{ p[0] }}" method="POST" style="display:inline;">
|
||||||
|
<button type="submit" class="btn-del" onclick="return confirm('¿Eliminar producto?')">Borrar</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
var socket = io();
|
||||||
|
|
||||||
|
// Chilean Peso Formatter (e.g., 1500 -> $1.500)
|
||||||
|
const clpFormatter = new Intl.NumberFormat('es-CL', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'CLP',
|
||||||
|
minimumFractionDigits: 0
|
||||||
|
});
|
||||||
|
|
||||||
|
function formatTablePrices() {
|
||||||
|
document.querySelectorAll('.price-cell').forEach(td => {
|
||||||
|
const val = parseFloat(td.getAttribute('data-value'));
|
||||||
|
td.innerText = clpFormatter.format(val);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
formatTablePrices();
|
||||||
|
|
||||||
|
socket.on('new_scan', function(data) {
|
||||||
|
dismissPrompt();
|
||||||
|
document.getElementById('display-name').innerText = data.name;
|
||||||
|
document.getElementById('display-price').innerText = clpFormatter.format(data.price);
|
||||||
|
document.getElementById('display-barcode').innerText = data.barcode;
|
||||||
|
document.getElementById('display-img').src = data.image || './static/placeholder.png';
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('scan_error', function(data) {
|
||||||
|
const prompt = document.getElementById('new-product-prompt');
|
||||||
|
document.getElementById('new-barcode-display').innerText = data.barcode;
|
||||||
|
document.getElementById('display-price').innerText = "$ ???";
|
||||||
|
prompt.style.display = 'flex';
|
||||||
|
|
||||||
|
// Update big display card with internet data (if any)
|
||||||
|
if (data.name) {
|
||||||
|
document.getElementById('display-name').innerText = data.name + " (Nuevo)";
|
||||||
|
document.getElementById('display-price').innerText = "$ ???";
|
||||||
|
document.getElementById('display-img').src = data.image || './static/placeholder.png';
|
||||||
|
} else {
|
||||||
|
document.getElementById('display-name').innerText = "Producto Desconocido";
|
||||||
|
document.getElementById('display-img').src = './static/placeholder.png';
|
||||||
|
}
|
||||||
|
document.getElementById('display-barcode').innerText = data.barcode;
|
||||||
|
|
||||||
|
// Pre-fill form
|
||||||
|
document.getElementById('form-barcode').value = data.barcode;
|
||||||
|
document.getElementById('form-name').value = data.name || '';
|
||||||
|
document.getElementById('form-image').value = data.image || '';
|
||||||
|
document.getElementById('form-price').value = '';
|
||||||
|
document.getElementById('form-title').innerText = "Crear Nuevo: " + (data.name || data.barcode);
|
||||||
|
document.getElementById('form-price').focus();
|
||||||
|
});
|
||||||
|
|
||||||
|
function dismissPrompt() {
|
||||||
|
document.getElementById('new-product-prompt').style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
function editProduct(barcode, name, price, image) {
|
||||||
|
dismissPrompt();
|
||||||
|
document.getElementById('form-barcode').value = barcode;
|
||||||
|
document.getElementById('form-name').value = name;
|
||||||
|
document.getElementById('form-price').value = price;
|
||||||
|
document.getElementById('form-image').value = image;
|
||||||
|
document.getElementById('form-title').innerText = "Editando: " + name;
|
||||||
|
window.scrollTo({top: 0, behavior: 'smooth'});
|
||||||
|
}
|
||||||
|
|
||||||
|
function searchTable() {
|
||||||
|
var input = document.getElementById("searchInput");
|
||||||
|
var filter = input.value.toUpperCase();
|
||||||
|
var tr = document.getElementById("inventoryTable").getElementsByTagName("tr");
|
||||||
|
|
||||||
|
for (var i = 1; i < tr.length; i++) {
|
||||||
|
var content = tr[i].textContent || tr[i].innerText;
|
||||||
|
tr[i].style.display = content.toUpperCase().indexOf(filter) > -1 ? "" : "none";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle instant updates for deletions without full refresh
|
||||||
|
socket.on('product_deleted', function(data) {
|
||||||
|
window.location.reload();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
25
templates/login.html
Normal file
25
templates/login.html
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>SekiPOS Login</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: sans-serif; display: flex; justify-content: center; align-items: center; height: 100vh; background: #2c3e50; }
|
||||||
|
.login-box { background: white; padding: 40px; border-radius: 12px; box-shadow: 0 10px 25px rgba(0,0,0,0.3); text-align: center; }
|
||||||
|
input { display: block; width: 250px; margin: 10px auto; padding: 12px; border: 1px solid #ccc; border-radius: 6px; }
|
||||||
|
button { background: #3498db; color: white; border: none; padding: 12px 24px; border-radius: 6px; cursor: pointer; font-size: 1.1em; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="login-box">
|
||||||
|
<h2>SekiPOS Access</h2>
|
||||||
|
{% with messages = get_flashed_messages() %}
|
||||||
|
{% if messages %}<p style="color: red;">{{ messages[0] }}</p>{% endif %}
|
||||||
|
{% endwith %}
|
||||||
|
<form method="POST">
|
||||||
|
<input type="text" name="username" placeholder="Username" required>
|
||||||
|
<input type="password" name="password" placeholder="Password" required>
|
||||||
|
<button type="submit">Unlock</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user