Update app.py
Browse files
app.py
CHANGED
|
@@ -9,7 +9,7 @@ from flask import Flask, render_template, request, Response, url_for
|
|
| 9 |
from flask_sqlalchemy import SQLAlchemy
|
| 10 |
from flask_admin import Admin, AdminIndexView, expose
|
| 11 |
from flask_admin.contrib.sqla import ModelView
|
| 12 |
-
|
| 13 |
from sqlalchemy import func
|
| 14 |
|
| 15 |
# --- KONFIGURASI APLIKASI ---
|
|
@@ -17,36 +17,28 @@ app = Flask(__name__)
|
|
| 17 |
app.config['SECRET_KEY'] = os.environ.get('SECRET_KEY', 'default-super-secret-key-for-local-dev')
|
| 18 |
|
| 19 |
# --- KONFIGURASI DATABASE ---
|
| 20 |
-
# Gunakan direktori /tmp yang dijamin bisa ditulisi di semua lingkungan container.
|
| 21 |
DATA_DIR = '/tmp'
|
| 22 |
-
# Pastikan direktori ada, buat jika belum ada.
|
| 23 |
os.makedirs(DATA_DIR, exist_ok=True)
|
| 24 |
-
|
| 25 |
-
# Tentukan path lengkap ke file database di dalam direktori /tmp
|
| 26 |
db_path = os.path.join(DATA_DIR, 'database.db')
|
| 27 |
app.config['SQLALCHEMY_DATABASE_URI'] = f'sqlite:///{db_path}'
|
| 28 |
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
|
| 29 |
db = SQLAlchemy(app)
|
| 30 |
|
| 31 |
-
|
| 32 |
# --- MODEL DATABASE (STRUKTUR TABEL) ---
|
|
|
|
| 33 |
class Product(db.Model):
|
| 34 |
id = db.Column(db.Integer, primary_key=True)
|
| 35 |
name = db.Column(db.String(100), unique=True, nullable=False)
|
| 36 |
description = db.Column(db.Text, nullable=False)
|
| 37 |
price = db.Column(db.String(50), nullable=False)
|
| 38 |
image = db.Column(db.String(100), nullable=True, default='default.jpg')
|
| 39 |
-
|
| 40 |
-
def __repr__(self):
|
| 41 |
-
return f'<Product {self.name}>'
|
| 42 |
|
| 43 |
class Setting(db.Model):
|
| 44 |
id = db.Column(db.Integer, primary_key=True)
|
| 45 |
key = db.Column(db.String(50), unique=True, nullable=False)
|
| 46 |
value = db.Column(db.Text, nullable=True)
|
| 47 |
-
|
| 48 |
-
def __repr__(self):
|
| 49 |
-
return f'<Setting {self.key}>'
|
| 50 |
|
| 51 |
class Sale(db.Model):
|
| 52 |
id = db.Column(db.Integer, primary_key=True)
|
|
@@ -54,137 +46,82 @@ class Sale(db.Model):
|
|
| 54 |
quantity = db.Column(db.Integer, nullable=False)
|
| 55 |
total_price = db.Column(db.Float, nullable=False)
|
| 56 |
sale_date = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
|
| 57 |
-
|
| 58 |
-
def __repr__(self):
|
| 59 |
-
return f'<Sale of {self.product_name}>'
|
| 60 |
-
|
| 61 |
|
| 62 |
# --- INISIALISASI DATABASE OTOMATIS ---
|
| 63 |
with app.app_context():
|
| 64 |
db.create_all()
|
| 65 |
|
| 66 |
-
|
| 67 |
-
# --- CONTEXT PROCESSOR UNTUK ADMIN TEMPLATE ---
|
| 68 |
@app.context_processor
|
| 69 |
def inject_admin_data():
|
| 70 |
-
"""Menyediakan data global ke semua template admin."""
|
| 71 |
menu_icons = {
|
| 72 |
-
'Dashboard': 'fa-solid fa-chart-pie',
|
| 73 |
-
'
|
| 74 |
-
'Sale': 'fa-solid fa-cash-register',
|
| 75 |
-
'Setting': 'fa-solid fa-sliders'
|
| 76 |
}
|
| 77 |
-
|
| 78 |
def get_setting_from_db(key, default=''):
|
| 79 |
setting = Setting.query.filter_by(key=key).first()
|
| 80 |
return setting.value if setting else default
|
| 81 |
-
|
| 82 |
return dict(menu_icons=menu_icons, get_setting=get_setting_from_db)
|
| 83 |
|
| 84 |
-
|
| 85 |
-
# --- FUNGSI KEAMANAN UNTUK ADMIN PANEL ---
|
| 86 |
def check_auth(username, password):
|
| 87 |
ADMIN_USER = os.environ.get('ADMIN_USER', 'admin')
|
| 88 |
ADMIN_PASS = os.environ.get('ADMIN_PASS', 'password')
|
| 89 |
return username == ADMIN_USER and password == ADMIN_PASS
|
| 90 |
|
| 91 |
def authenticate():
|
| 92 |
-
return Response(
|
| 93 |
-
'Could not verify your access level for that URL.\n'
|
| 94 |
-
'You have to login with proper credentials', 401,
|
| 95 |
-
{'WWW-Authenticate': 'Basic realm="Login Required"'})
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
# --- PENGATURAN ADMIN PANEL (DENGAN IMAGE UPLOAD) ---
|
| 99 |
-
|
| 100 |
-
# Tentukan path untuk menyimpan gambar yang di-upload
|
| 101 |
-
basedir = os.path.abspath(os.path.dirname(__file__))
|
| 102 |
-
upload_path = os.path.join(basedir, 'static/img/')
|
| 103 |
-
# Buat folder jika belum ada
|
| 104 |
-
os.makedirs(upload_path, exist_ok=True)
|
| 105 |
-
|
| 106 |
|
|
|
|
| 107 |
class AuthMixin:
|
| 108 |
-
"""Mixin untuk menambahkan autentikasi ke view admin."""
|
| 109 |
def is_accessible(self):
|
| 110 |
auth = request.authorization
|
| 111 |
return auth and check_auth(auth.username, auth.password)
|
| 112 |
-
|
| 113 |
def inaccessible_callback(self, name, **kwargs):
|
| 114 |
return authenticate()
|
| 115 |
|
| 116 |
-
# Buat view khusus untuk Produk agar bisa handle upload
|
| 117 |
-
class ProductAdminView(AuthMixin, ModelView):
|
| 118 |
-
# Gunakan form_extra_fields untuk menambahkan field upload
|
| 119 |
-
form_extra_fields = {
|
| 120 |
-
'image': ImageUploadField(
|
| 121 |
-
'Image',
|
| 122 |
-
base_path=upload_path,
|
| 123 |
-
url_relative_path='img/',
|
| 124 |
-
thumbnail_size=(100, 100, True) # Menampilkan thumbnail
|
| 125 |
-
)
|
| 126 |
-
}
|
| 127 |
-
|
| 128 |
class SecureModelView(AuthMixin, ModelView):
|
| 129 |
pass
|
| 130 |
|
| 131 |
class DashboardView(AuthMixin, AdminIndexView):
|
|
|
|
| 132 |
def get_sales_data(self):
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
seven_days_ago = today - timedelta(days=6)
|
| 136 |
-
sales = db.session.query(
|
| 137 |
-
func.date(Sale.sale_date).label('date'),
|
| 138 |
-
func.sum(Sale.total_price).label('total')
|
| 139 |
-
).filter(Sale.sale_date >= seven_days_ago).group_by(func.date(Sale.sale_date)).order_by(func.date(Sale.sale_date)).all()
|
| 140 |
sales_dict = {s.date.strftime('%Y-%m-%d'): s.total for s in sales}
|
| 141 |
labels = [(today - timedelta(days=i)).strftime('%a, %d') for i in range(6, -1, -1)]
|
| 142 |
data = [sales_dict.get((today - timedelta(days=i)).strftime('%Y-%m-%d'), 0) for i in range(6, -1, -1)]
|
| 143 |
return labels, data
|
| 144 |
-
|
| 145 |
def get_stats(self):
|
| 146 |
-
|
| 147 |
-
stats = {}
|
| 148 |
-
total_revenue = db.session.query(func.sum(Sale.total_price)).scalar()
|
| 149 |
stats['total_revenue'] = total_revenue or 0
|
| 150 |
stats['total_products'] = Product.query.count()
|
| 151 |
-
today_sales = Sale.query.filter(func.date(Sale.sale_date) == date.today()).count()
|
| 152 |
-
stats['today_sales'] = today_sales
|
| 153 |
return stats
|
| 154 |
-
|
| 155 |
@expose('/')
|
| 156 |
def index(self):
|
| 157 |
-
chart_labels, chart_data = self.get_sales_data()
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
chart_labels=chart_labels,
|
| 161 |
-
chart_data=chart_data,
|
| 162 |
-
stats=stats)
|
| 163 |
-
|
| 164 |
-
# Inisialisasi admin dengan struktur yang sudah diperbaiki
|
| 165 |
admin = Admin(app, name='Bit & Bean Dashboard', template_mode='bootstrap4',
|
| 166 |
base_template='admin/custom_master.html',
|
| 167 |
index_view=DashboardView(name='Dashboard', url='/admin'))
|
| 168 |
|
| 169 |
-
#
|
| 170 |
-
admin.add_view(
|
| 171 |
admin.add_view(SecureModelView(Sale, db.session, category='Laporan'))
|
| 172 |
admin.add_view(SecureModelView(Setting, db.session, category='Pengaturan'))
|
| 173 |
|
| 174 |
-
|
| 175 |
-
# --- FUNGSI QR CODE ---
|
| 176 |
def generate_qr_code(data):
|
| 177 |
qr = qrcode.QRCode(version=1, box_size=10, border=4)
|
| 178 |
-
qr.add_data(data)
|
| 179 |
-
qr.make(fit=True)
|
| 180 |
img = qr.make_image(fill_color="black", back_color="white")
|
| 181 |
-
buffered = io.BytesIO()
|
| 182 |
-
img.save(buffered, format="PNG")
|
| 183 |
img_str = base64.b64encode(buffered.getvalue()).decode("utf-8")
|
| 184 |
return img_str
|
| 185 |
|
| 186 |
-
|
| 187 |
-
# --- ROUTE UNTUK HALAMAN UTAMA ---
|
| 188 |
@app.route('/')
|
| 189 |
def home():
|
| 190 |
products_from_db = Product.query.all()
|
|
@@ -192,11 +129,8 @@ def home():
|
|
| 192 |
for product in products_from_db:
|
| 193 |
product_url = f"https://BitBean-company.hf.space/product/{product.id}"
|
| 194 |
product_data = {
|
| 195 |
-
"id": product.id,
|
| 196 |
-
"
|
| 197 |
-
"description": product.description,
|
| 198 |
-
"price": product.price,
|
| 199 |
-
"image": product.image,
|
| 200 |
"qr_code": generate_qr_code(product_url)
|
| 201 |
}
|
| 202 |
products_with_qr.append(product_data)
|
|
|
|
| 9 |
from flask_sqlalchemy import SQLAlchemy
|
| 10 |
from flask_admin import Admin, AdminIndexView, expose
|
| 11 |
from flask_admin.contrib.sqla import ModelView
|
| 12 |
+
# ImageUploadField tidak lagi digunakan
|
| 13 |
from sqlalchemy import func
|
| 14 |
|
| 15 |
# --- KONFIGURASI APLIKASI ---
|
|
|
|
| 17 |
app.config['SECRET_KEY'] = os.environ.get('SECRET_KEY', 'default-super-secret-key-for-local-dev')
|
| 18 |
|
| 19 |
# --- KONFIGURASI DATABASE ---
|
|
|
|
| 20 |
DATA_DIR = '/tmp'
|
|
|
|
| 21 |
os.makedirs(DATA_DIR, exist_ok=True)
|
|
|
|
|
|
|
| 22 |
db_path = os.path.join(DATA_DIR, 'database.db')
|
| 23 |
app.config['SQLALCHEMY_DATABASE_URI'] = f'sqlite:///{db_path}'
|
| 24 |
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
|
| 25 |
db = SQLAlchemy(app)
|
| 26 |
|
|
|
|
| 27 |
# --- MODEL DATABASE (STRUKTUR TABEL) ---
|
| 28 |
+
# ... (Model tidak berubah) ...
|
| 29 |
class Product(db.Model):
|
| 30 |
id = db.Column(db.Integer, primary_key=True)
|
| 31 |
name = db.Column(db.String(100), unique=True, nullable=False)
|
| 32 |
description = db.Column(db.Text, nullable=False)
|
| 33 |
price = db.Column(db.String(50), nullable=False)
|
| 34 |
image = db.Column(db.String(100), nullable=True, default='default.jpg')
|
| 35 |
+
def __repr__(self): return f'<Product {self.name}>'
|
|
|
|
|
|
|
| 36 |
|
| 37 |
class Setting(db.Model):
|
| 38 |
id = db.Column(db.Integer, primary_key=True)
|
| 39 |
key = db.Column(db.String(50), unique=True, nullable=False)
|
| 40 |
value = db.Column(db.Text, nullable=True)
|
| 41 |
+
def __repr__(self): return f'<Setting {self.key}>'
|
|
|
|
|
|
|
| 42 |
|
| 43 |
class Sale(db.Model):
|
| 44 |
id = db.Column(db.Integer, primary_key=True)
|
|
|
|
| 46 |
quantity = db.Column(db.Integer, nullable=False)
|
| 47 |
total_price = db.Column(db.Float, nullable=False)
|
| 48 |
sale_date = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
|
| 49 |
+
def __repr__(self): return f'<Sale of {self.product_name}>'
|
|
|
|
|
|
|
|
|
|
| 50 |
|
| 51 |
# --- INISIALISASI DATABASE OTOMATIS ---
|
| 52 |
with app.app_context():
|
| 53 |
db.create_all()
|
| 54 |
|
| 55 |
+
# --- CONTEXT PROCESSOR ---
|
|
|
|
| 56 |
@app.context_processor
|
| 57 |
def inject_admin_data():
|
|
|
|
| 58 |
menu_icons = {
|
| 59 |
+
'Dashboard': 'fa-solid fa-chart-pie', 'Product': 'fa-solid fa-mug-hot',
|
| 60 |
+
'Sale': 'fa-solid fa-cash-register', 'Setting': 'fa-solid fa-sliders'
|
|
|
|
|
|
|
| 61 |
}
|
|
|
|
| 62 |
def get_setting_from_db(key, default=''):
|
| 63 |
setting = Setting.query.filter_by(key=key).first()
|
| 64 |
return setting.value if setting else default
|
|
|
|
| 65 |
return dict(menu_icons=menu_icons, get_setting=get_setting_from_db)
|
| 66 |
|
| 67 |
+
# --- FUNGSI KEAMANAN ---
|
|
|
|
| 68 |
def check_auth(username, password):
|
| 69 |
ADMIN_USER = os.environ.get('ADMIN_USER', 'admin')
|
| 70 |
ADMIN_PASS = os.environ.get('ADMIN_PASS', 'password')
|
| 71 |
return username == ADMIN_USER and password == ADMIN_PASS
|
| 72 |
|
| 73 |
def authenticate():
|
| 74 |
+
return Response('Login Required', 401, {'WWW-Authenticate': 'Basic realm="Login Required"'})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 75 |
|
| 76 |
+
# --- PENGATURAN ADMIN PANEL (TANPA UPLOAD) ---
|
| 77 |
class AuthMixin:
|
|
|
|
| 78 |
def is_accessible(self):
|
| 79 |
auth = request.authorization
|
| 80 |
return auth and check_auth(auth.username, auth.password)
|
|
|
|
| 81 |
def inaccessible_callback(self, name, **kwargs):
|
| 82 |
return authenticate()
|
| 83 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 84 |
class SecureModelView(AuthMixin, ModelView):
|
| 85 |
pass
|
| 86 |
|
| 87 |
class DashboardView(AuthMixin, AdminIndexView):
|
| 88 |
+
# ... (Isi kelas DashboardView tidak berubah) ...
|
| 89 |
def get_sales_data(self):
|
| 90 |
+
today = date.today(); seven_days_ago = today - timedelta(days=6)
|
| 91 |
+
sales = db.session.query(func.date(Sale.sale_date).label('date'), func.sum(Sale.total_price).label('total')).filter(Sale.sale_date >= seven_days_ago).group_by(func.date(Sale.sale_date)).order_by(func.date(Sale.sale_date)).all()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 92 |
sales_dict = {s.date.strftime('%Y-%m-%d'): s.total for s in sales}
|
| 93 |
labels = [(today - timedelta(days=i)).strftime('%a, %d') for i in range(6, -1, -1)]
|
| 94 |
data = [sales_dict.get((today - timedelta(days=i)).strftime('%Y-%m-%d'), 0) for i in range(6, -1, -1)]
|
| 95 |
return labels, data
|
|
|
|
| 96 |
def get_stats(self):
|
| 97 |
+
stats = {}; total_revenue = db.session.query(func.sum(Sale.total_price)).scalar()
|
|
|
|
|
|
|
| 98 |
stats['total_revenue'] = total_revenue or 0
|
| 99 |
stats['total_products'] = Product.query.count()
|
| 100 |
+
stats['today_sales'] = Sale.query.filter(func.date(Sale.sale_date) == date.today()).count()
|
|
|
|
| 101 |
return stats
|
|
|
|
| 102 |
@expose('/')
|
| 103 |
def index(self):
|
| 104 |
+
chart_labels, chart_data = self.get_sales_data(); stats = self.get_stats()
|
| 105 |
+
return self.render('admin/dashboard.html', chart_labels=chart_labels, chart_data=chart_data, stats=stats)
|
| 106 |
+
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 107 |
admin = Admin(app, name='Bit & Bean Dashboard', template_mode='bootstrap4',
|
| 108 |
base_template='admin/custom_master.html',
|
| 109 |
index_view=DashboardView(name='Dashboard', url='/admin'))
|
| 110 |
|
| 111 |
+
# Menggunakan SecureModelView standar untuk semua
|
| 112 |
+
admin.add_view(SecureModelView(Product, db.session))
|
| 113 |
admin.add_view(SecureModelView(Sale, db.session, category='Laporan'))
|
| 114 |
admin.add_view(SecureModelView(Setting, db.session, category='Pengaturan'))
|
| 115 |
|
| 116 |
+
# ... (Sisa kode, fungsi QR code dan route home, tidak berubah) ...
|
|
|
|
| 117 |
def generate_qr_code(data):
|
| 118 |
qr = qrcode.QRCode(version=1, box_size=10, border=4)
|
| 119 |
+
qr.add_data(data); qr.make(fit=True)
|
|
|
|
| 120 |
img = qr.make_image(fill_color="black", back_color="white")
|
| 121 |
+
buffered = io.BytesIO(); img.save(buffered, format="PNG")
|
|
|
|
| 122 |
img_str = base64.b64encode(buffered.getvalue()).decode("utf-8")
|
| 123 |
return img_str
|
| 124 |
|
|
|
|
|
|
|
| 125 |
@app.route('/')
|
| 126 |
def home():
|
| 127 |
products_from_db = Product.query.all()
|
|
|
|
| 129 |
for product in products_from_db:
|
| 130 |
product_url = f"https://BitBean-company.hf.space/product/{product.id}"
|
| 131 |
product_data = {
|
| 132 |
+
"id": product.id, "name": product.name, "description": product.description,
|
| 133 |
+
"price": product.price, "image": product.image,
|
|
|
|
|
|
|
|
|
|
| 134 |
"qr_code": generate_qr_code(product_url)
|
| 135 |
}
|
| 136 |
products_with_qr.append(product_data)
|