optymalizacje_kodu #8

Merged
gru merged 23 commits from optymalizacje_kodu into master 2025-07-31 10:55:39 +02:00
6 changed files with 621 additions and 292 deletions
Showing only changes of commit 0a44753eb2 - Show all commits

View File

@@ -158,4 +158,14 @@ LIB_CSS_CACHE_CONTROL="public, max-age=604800"
# UPLOADS_CACHE_CONTROL:
# Nagłówki Cache-Control dla wgrywanych plików (/uploads/)
# Domyślnie: "public, max-age=2592000, immutable"
UPLOADS_CACHE_CONTROL="public, max-age=2592000, immutable"
UPLOADS_CACHE_CONTROL="public, max-age=2592000, immutable"
# DEFAULT_CATEGORIES:
# Lista domyślnych kategorii tworzonych automatycznie przy starcie aplikacji,
# jeśli nie istnieją w bazie danych.
# Podaj w formacie CSV (oddzielone przecinkami) kolejność zostanie zachowana.
# Możesz dodać własne kategorie
# UWAGA: Wielkość liter w nazwach jest zachowywana, ale porównywanie odbywa się
# bez rozróżniania wielkości liter (case-insensitive).
# Domyślnie: poniższa lista
DEFAULT_CATEGORIES="Spożywcze,Budowlane,Zabawki,Chemia,Inne,Elektronika,Odzież i obuwie,Artykuły biurowe,Kosmetyki i higiena,Motoryzacja,Ogród i rośliny,Zwierzęta,Sprzęt sportowy,Książki i prasa,Narzędzia i majsterkowanie,RTV / AGD,Apteka i suplementy,Artykuły dekoracyjne,Gry i hobby,Usługi,Pieczywo"

356
app.py
View File

@@ -89,9 +89,9 @@ referrer_policy = app.config.get("REFERRER_POLICY")
if referrer_policy:
talisman_kwargs["referrer_policy"] = referrer_policy
talisman = Talisman(app,
session_cookie_secure=app.config["SESSION_COOKIE_SECURE"],
**talisman_kwargs)
talisman = Talisman(
app, session_cookie_secure=app.config["SESSION_COOKIE_SECURE"], **talisman_kwargs
)
register_heif_opener() # pillow_heif dla HEIC
SQLALCHEMY_ECHO = True
@@ -109,7 +109,7 @@ SESSION_COOKIE_SECURE = app.config.get("SESSION_COOKIE_SECURE")
app.config["COMPRESS_ALGORITHM"] = ["zstd", "br", "gzip", "deflate"]
app.config["PERMANENT_SESSION_LIFETIME"] = timedelta(minutes=SESSION_TIMEOUT_MINUTES)
#app.config["SESSION_COOKIE_SECURE"] = True if app.config.get("SESSION_COOKIE_SECURE") is True else False
# app.config["SESSION_COOKIE_SECURE"] = True if app.config.get("SESSION_COOKIE_SECURE") is True else False
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1)
DEBUG_MODE = app.config.get("DEBUG_MODE", False)
@@ -166,14 +166,23 @@ class User(UserMixin, db.Model):
# Tabela pośrednia
shopping_list_category = db.Table(
"shopping_list_category",
db.Column("shopping_list_id", db.Integer, db.ForeignKey("shopping_list.id"), primary_key=True),
db.Column("category_id", db.Integer, db.ForeignKey("category.id"), primary_key=True)
db.Column(
"shopping_list_id",
db.Integer,
db.ForeignKey("shopping_list.id"),
primary_key=True,
),
db.Column(
"category_id", db.Integer, db.ForeignKey("category.id"), primary_key=True
),
)
class Category(db.Model):
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(100), unique=True, nullable=False)
class ShoppingList(db.Model):
id = db.Column(db.Integer, primary_key=True)
title = db.Column(db.String(150), nullable=False)
@@ -198,9 +207,10 @@ class ShoppingList(db.Model):
categories = db.relationship(
"Category",
secondary=shopping_list_category,
backref=db.backref("shopping_lists", lazy="dynamic")
backref=db.backref("shopping_lists", lazy="dynamic"),
)
class Item(db.Model):
id = db.Column(db.Integer, primary_key=True)
list_id = db.Column(db.Integer, db.ForeignKey("shopping_list.id"))
@@ -255,9 +265,14 @@ def handle_db_error(e):
app.logger.error(f"[Błąd DB] {e}")
if request.accept_mimetypes.best == "application/json":
return jsonify({
"error": "Baza danych jest obecnie niedostępna. Spróbuj ponownie później."
}), 503
return (
jsonify(
{
"error": "Baza danych jest obecnie niedostępna. Spróbuj ponownie później."
}
),
503,
)
return (
render_template(
@@ -288,6 +303,7 @@ def check_password(stored_hash, password_input):
return False
return False
def set_authorized_cookie(response):
secure_flag = app.config["SESSION_COOKIE_SECURE"] # wartość z config.py
@@ -301,7 +317,7 @@ def set_authorized_cookie(response):
secure=secure_flag,
httponly=True,
samesite="Lax",
path="/"
path="/",
)
return response
@@ -331,32 +347,25 @@ with app.app_context():
print(f"[INFO] Zmieniono hasło admina '{admin_username}' z konfiguracji.")
db.session.commit()
else:
db.session.add(User(
username=admin_username,
password_hash=password_hash,
is_admin=True
))
db.session.add(
User(username=admin_username, password_hash=password_hash, is_admin=True)
)
db.session.commit()
# --- Predefiniowane kategorie ---
default_categories = [
"Spożywcze", "Budowlane", "Zabawki", "Chemia", "Inne",
"Elektronika", "Odzież i obuwie", "Artykuły biurowe",
"Kosmetyki i higiena", "Motoryzacja", "Ogród i rośliny",
"Zwierzęta", "Sprzęt sportowy", "Książki i prasa",
"Narzędzia i majsterkowanie", "RTV / AGD", "Apteka i suplementy",
"Artykuły dekoracyjne", "Gry i hobby", "Usługi", "Pieczywo"
]
default_categories = app.config["DEFAULT_CATEGORIES"]
# Pobierz istniejące nazwy z bazy, ignorując puste/niewłaściwe rekordy
existing_names = {
c.name for c in Category.query.filter(Category.name.isnot(None)).all()
}
# Znajdź brakujące
missing = [cat for cat in default_categories if cat not in existing_names]
# ignorujemy wielkość liter przy porównaniu
existing_names_lower = {name.lower() for name in existing_names}
missing = [
cat for cat in default_categories if cat.lower() not in existing_names_lower
]
# Dodaj tylko brakujące
if missing:
db.session.add_all(Category(name=cat) for cat in missing)
db.session.commit()
@@ -656,7 +665,7 @@ def get_total_expenses_grouped_by_list_created_at(
if category_id:
lists_query = lists_query.join(
shopping_list_category,
shopping_list_category.c.shopping_list_id == ShoppingList.id
shopping_list_category.c.shopping_list_id == ShoppingList.id,
).filter(shopping_list_category.c.category_id == category_id)
if start_date and end_date:
@@ -737,6 +746,158 @@ def recalculate_filesizes(receipt_id: int = None):
return updated, unchanged, not_found
def get_admin_expense_summary():
now = datetime.now(timezone.utc)
current_year = now.year
current_month = now.month
def calc_sum(base_query):
total = base_query.scalar() or 0
year_total = (
base_query.filter(
extract("year", Expense.added_at) == current_year
).scalar()
or 0
)
month_total = (
base_query.filter(extract("year", Expense.added_at) == current_year)
.filter(extract("month", Expense.added_at) == current_month)
.scalar()
or 0
)
return {"total": total, "year": year_total, "month": month_total}
# baza wspólna
base = db.session.query(func.sum(Expense.amount)).join(
ShoppingList, ShoppingList.id == Expense.list_id
)
# wszystkie listy
all_lists = calc_sum(base)
# aktywne listy
active_lists = calc_sum(
base.filter(
ShoppingList.is_archived == False,
or_(ShoppingList.expires_at == None, ShoppingList.expires_at > now),
)
)
# archiwalne
archived_lists = calc_sum(base.filter(ShoppingList.is_archived == True))
# wygasłe
expired_lists = calc_sum(
base.filter(
ShoppingList.is_archived == False,
ShoppingList.expires_at != None,
ShoppingList.expires_at <= now,
)
)
return {
"all": all_lists,
"active": active_lists,
"archived": archived_lists,
"expired": expired_lists,
}
def category_to_color(name):
"""Generuje powtarzalny pastelowy kolor HEX na podstawie nazwy kategorii."""
hash_val = int(hashlib.md5(name.encode("utf-8")).hexdigest(), 16)
r = (hash_val & 0xFF0000) >> 16
g = (hash_val & 0x00FF00) >> 8
b = hash_val & 0x0000FF
# Rozjaśnienie (pastel)
r = (r + 255) // 2
g = (g + 255) // 2
b = (b + 255) // 2
return f"#{r:02x}{g:02x}{b:02x}"
def get_total_expenses_grouped_by_category(
show_all, range_type, start_date, end_date, user_id
):
lists_query = ShoppingList.query
if show_all:
lists_query = lists_query.filter(
or_(ShoppingList.owner_id == user_id, ShoppingList.is_public == True)
)
else:
lists_query = lists_query.filter(ShoppingList.owner_id == user_id)
if start_date and end_date:
try:
dt_start = datetime.strptime(start_date, "%Y-%m-%d")
dt_end = datetime.strptime(end_date, "%Y-%m-%d") + timedelta(days=1)
except Exception:
return {"error": "Błędne daty"}
lists_query = lists_query.filter(
ShoppingList.created_at >= dt_start, ShoppingList.created_at < dt_end
)
lists = lists_query.options(joinedload(ShoppingList.categories)).all()
if not lists:
return {"labels": [], "datasets": []}
data_map = defaultdict(lambda: defaultdict(float))
all_labels = set()
for l in lists:
total_expense = (
db.session.query(func.sum(Expense.amount))
.filter(Expense.list_id == l.id)
.scalar()
) or 0
if total_expense <= 0:
continue
if range_type == "monthly":
key = l.created_at.strftime("%Y-%m")
elif range_type == "quarterly":
key = f"{l.created_at.year}-Q{((l.created_at.month - 1) // 3 + 1)}"
elif range_type == "halfyearly":
key = f"{l.created_at.year}-H{1 if l.created_at.month <= 6 else 2}"
elif range_type == "yearly":
key = str(l.created_at.year)
else:
key = l.created_at.strftime("%Y-%m-%d")
all_labels.add(key)
if not l.categories:
data_map[key]["Inne"] += total_expense
else:
for c in l.categories:
data_map[key][c.name] += total_expense
labels = sorted(all_labels)
categories_with_expenses = sorted(
{
cat
for cat_data in data_map.values()
for cat, value in cat_data.items()
if value > 0
}
)
datasets = []
for cat in categories_with_expenses:
datasets.append(
{
"label": cat,
"data": [round(data_map[label].get(cat, 0), 2) for label in labels],
"backgroundColor": category_to_color(cat),
}
)
return {"labels": labels, "datasets": datasets}
############# OCR ###########################
@@ -1143,8 +1304,7 @@ def main_page():
# ostatnia kwota (w tym przypadku max = suma z ostatniego zapisu)
latest_expenses_map = dict(
db.session.query(
Expense.list_id,
func.coalesce(func.sum(Expense.amount), 0)
Expense.list_id, func.coalesce(func.sum(Expense.amount), 0)
)
.filter(Expense.list_id.in_(all_ids))
.group_by(Expense.list_id)
@@ -1185,7 +1345,10 @@ def system_auth():
next_page = request.args.get("next") or url_for("main_page")
if is_ip_blocked(ip):
flash("Przekroczono limit prób logowania. Dostęp zablokowany na 1 godzinę.", "danger")
flash(
"Przekroczono limit prób logowania. Dostęp zablokowany na 1 godzinę.",
"danger",
)
return render_template("system_auth.html"), 403
if request.method == "POST":
@@ -1196,7 +1359,10 @@ def system_auth():
else:
register_failed_attempt(ip)
if is_ip_blocked(ip):
flash("Przekroczono limit prób logowania. Dostęp zablokowany na 1 godzinę.", "danger")
flash(
"Przekroczono limit prób logowania. Dostęp zablokowany na 1 godzinę.",
"danger",
)
return render_template("system_auth.html"), 403
remaining = attempts_remaining(ip)
flash(f"Nieprawidłowe hasło. Pozostało {remaining} prób.", "warning")
@@ -1306,11 +1472,10 @@ def edit_my_list(list_id):
list=l,
receipts=receipts,
categories=categories,
selected_categories=selected_categories_ids
selected_categories=selected_categories_ids,
)
@app.route("/delete_user_list/<int:list_id>", methods=["POST"])
@login_required
def delete_user_list(list_id):
@@ -1452,7 +1617,28 @@ def user_expenses():
category_id = request.args.get("category_id", type=int)
show_all = request.args.get("show_all", "true").lower() == "true"
categories = Category.query.order_by(Category.name.asc()).all()
categories = (
Category.query.join(
shopping_list_category, shopping_list_category.c.category_id == Category.id
)
.join(
ShoppingList, ShoppingList.id == shopping_list_category.c.shopping_list_id
)
.join(Expense, Expense.list_id == ShoppingList.id)
.filter(
or_(
ShoppingList.owner_id == current_user.id,
(
ShoppingList.is_public == True
if show_all
else ShoppingList.owner_id == current_user.id
),
)
)
.distinct()
.order_by(Category.name.asc())
.all()
)
start = None
end = None
@@ -1468,15 +1654,14 @@ def user_expenses():
else:
expenses_query = expenses_query.filter(
or_(
ShoppingList.owner_id == current_user.id,
ShoppingList.is_public == True
ShoppingList.owner_id == current_user.id, ShoppingList.is_public == True
)
)
if category_id:
expenses_query = expenses_query.join(
shopping_list_category,
shopping_list_category.c.shopping_list_id == ShoppingList.id
shopping_list_category.c.shopping_list_id == ShoppingList.id,
).filter(shopping_list_category.c.category_id == category_id)
if start_date_str and end_date_str:
@@ -1522,7 +1707,7 @@ def user_expenses():
"created_at": l.created_at,
"total_expense": totals_map.get(l.id, 0),
"owner_username": l.owner.username if l.owner else "?",
"categories": [c.id for c in l.categories]
"categories": [c.id for c in l.categories],
}
for l in {e.shopping_list for e in expenses if e.shopping_list}
]
@@ -1545,17 +1730,27 @@ def user_expenses_data():
end_date = request.args.get("end_date")
show_all = request.args.get("show_all", "true").lower() == "true"
category_id = request.args.get("category_id", type=int)
by_category = request.args.get("by_category", "false").lower() == "true"
result = get_total_expenses_grouped_by_list_created_at(
user_only=True,
admin=False,
show_all=show_all,
range_type=range_type,
start_date=start_date,
end_date=end_date,
user_id=current_user.id,
category_id=category_id
)
if by_category:
result = get_total_expenses_grouped_by_category(
show_all=show_all,
range_type=range_type,
start_date=start_date,
end_date=end_date,
user_id=current_user.id,
)
else:
result = get_total_expenses_grouped_by_list_created_at(
user_only=True,
admin=False,
show_all=show_all,
range_type=range_type,
start_date=start_date,
end_date=end_date,
user_id=current_user.id,
category_id=category_id,
)
if "error" in result:
return jsonify({"error": result["error"]}), 400
@@ -1640,12 +1835,14 @@ def all_products():
)
top_products = (
top_products_query.order_by(
SuggestedProduct.name.asc(), # musi być pierwsze
SuggestedProduct.usage_count.desc(),
db.session.query(
func.lower(Item.name).label("name"), func.sum(Item.quantity).label("count")
)
.distinct(SuggestedProduct.name)
.limit(20)
.join(ShoppingList, ShoppingList.id == Item.list_id)
.filter(Item.purchased.is_(True))
.group_by(func.lower(Item.name))
.order_by(func.sum(Item.quantity).desc())
.limit(5)
.all()
)
@@ -1887,6 +2084,7 @@ def admin_panel():
joinedload(ShoppingList.items),
joinedload(ShoppingList.receipts),
joinedload(ShoppingList.expenses),
joinedload(ShoppingList.categories),
).all()
all_ids = [l.id for l in all_lists]
@@ -1914,15 +2112,13 @@ def admin_panel():
latest_expenses_map = dict(
db.session.query(
Expense.list_id,
func.coalesce(func.sum(Expense.amount), 0)
Expense.list_id, func.coalesce(func.sum(Expense.amount), 0)
)
.filter(Expense.list_id.in_(all_ids))
.group_by(Expense.list_id)
.all()
)
enriched_lists = []
for l in all_lists:
total_count, purchased_count = stats_map.get(l.id, (0, 0))
@@ -1949,6 +2145,7 @@ def admin_panel():
"receipts_count": receipts_count,
"total_expense": total_expense,
"expired": is_expired,
"categories": l.categories,
}
)
@@ -1964,24 +2161,8 @@ def admin_panel():
purchased_items_count = Item.query.filter_by(purchased=True).count()
# Podsumowania wydatków globalnych
total_expense_sum = db.session.query(func.sum(Expense.amount)).scalar() or 0
current_time = datetime.now(timezone.utc)
current_year = current_time.year
current_month = current_time.month
year_expense_sum = (
db.session.query(func.sum(Expense.amount))
.filter(extract("year", Expense.added_at) == current_year)
.scalar()
) or 0
month_expense_sum = (
db.session.query(func.sum(Expense.amount))
.filter(extract("year", Expense.added_at) == current_year)
.filter(extract("month", Expense.added_at) == current_month)
.scalar()
) or 0
# Nowe podsumowanie wydatków
expense_summary = get_admin_expense_summary()
# Statystyki systemowe
process = psutil.Process(os.getpid())
@@ -1996,9 +2177,7 @@ def admin_panel():
inspector = inspect(db_engine)
table_count = len(inspector.get_table_names())
record_total = get_total_records()
uptime_minutes = int(
(datetime.now(timezone.utc) - app_start_time).total_seconds() // 60
)
@@ -2011,9 +2190,7 @@ def admin_panel():
purchased_items_count=purchased_items_count,
enriched_lists=enriched_lists,
top_products=top_products,
total_expense_sum=total_expense_sum,
year_expense_sum=year_expense_sum,
month_expense_sum=month_expense_sum,
expense_summary=expense_summary,
now=now,
python_version=sys.version,
system_info=platform.platform(),
@@ -2025,7 +2202,6 @@ def admin_panel():
)
@app.route("/admin/delete_list/<int:list_id>")
@login_required
@admin_required
@@ -2291,7 +2467,7 @@ def edit_list(list_id):
joinedload(ShoppingList.owner),
joinedload(ShoppingList.items),
joinedload(ShoppingList.categories),
]
],
)
if l is None:
@@ -2378,7 +2554,7 @@ def edit_list(list_id):
db.session.commit()
flash("Zapisano zmiany listy", "success")
return redirect(url_for("edit_list", list_id=list_id))
elif action == "add_item":
item_name = request.form.get("item_name", "").strip()
quantity_str = request.form.get("quantity", "1")
@@ -2487,7 +2663,7 @@ def edit_list(list_id):
items=items,
receipts=receipts,
categories=categories,
selected_categories=selected_categories_ids
selected_categories=selected_categories_ids,
)
@@ -2640,7 +2816,11 @@ def recalculate_filesizes_all():
@login_required
@admin_required
def admin_mass_edit_categories():
lists = ShoppingList.query.options(joinedload(ShoppingList.categories)).order_by(ShoppingList.created_at.desc()).all()
lists = (
ShoppingList.query.options(joinedload(ShoppingList.categories))
.order_by(ShoppingList.created_at.desc())
.all()
)
categories = Category.query.order_by(Category.name.asc()).all()
if request.method == "POST":
@@ -2654,7 +2834,9 @@ def admin_mass_edit_categories():
flash("Zaktualizowano kategorie dla wybranych list", "success")
return redirect(url_for("admin_mass_edit_categories"))
return render_template("admin/mass_edit_categories.html", lists=lists, categories=categories)
return render_template(
"admin/mass_edit_categories.html", lists=lists, categories=categories
)
@app.route("/healthcheck")

View File

@@ -71,3 +71,13 @@ class Config:
UPLOADS_CACHE_CONTROL = os.environ.get(
"UPLOADS_CACHE_CONTROL", "public, max-age=2592000, immutable"
)
DEFAULT_CATEGORIES = [
c.strip() for c in os.environ.get(
"DEFAULT_CATEGORIES",
"Spożywcze,Budowlane,Zabawki,Chemia,Inne,Elektronika,Odzież i obuwie,"
"Artykuły biurowe,Kosmetyki i higiena,Motoryzacja,Ogród i rośliny,"
"Zwierzęta,Sprzęt sportowy,Książki i prasa,Narzędzia i majsterkowanie,"
"RTV / AGD,Apteka i suplementy,Artykuły dekoracyjne,Gry i hobby,Usługi,Pieczywo"
).split(",") if c.strip()
]

View File

@@ -1,6 +1,7 @@
document.addEventListener("DOMContentLoaded", function () {
let expensesChart = null;
let selectedCategoryId = "";
let categorySplit = false; // <-- nowy tryb
const rangeLabel = document.getElementById("chartRangeLabel");
function loadExpenses(range = "monthly", startDate = null, endDate = null) {
@@ -15,6 +16,9 @@ document.addEventListener("DOMContentLoaded", function () {
if (selectedCategoryId) {
url += `&category_id=${selectedCategoryId}`;
}
if (categorySplit) {
url += '&by_category=true';
}
fetch(url, { cache: "no-store" })
.then(response => response.json())
@@ -25,24 +29,44 @@ document.addEventListener("DOMContentLoaded", function () {
expensesChart.destroy();
}
expensesChart = new Chart(ctx, {
type: 'bar',
data: {
labels: data.labels,
datasets: [{
label: 'Suma wydatków [PLN]',
data: data.expenses,
backgroundColor: '#0d6efd'
}]
},
options: {
scales: {
y: {
beginAtZero: true
if (categorySplit) {
// Tryb z podziałem na kategorie
expensesChart = new Chart(ctx, {
type: 'bar',
data: {
labels: data.labels,
datasets: data.datasets // <-- gotowe z backendu
},
options: {
responsive: true,
plugins: {
tooltip: { mode: 'index', intersect: false },
legend: { position: 'top' }
},
scales: {
x: { stacked: true },
y: { stacked: true, beginAtZero: true }
}
}
}
});
});
} else {
// Tryb zwykły
expensesChart = new Chart(ctx, {
type: 'bar',
data: {
labels: data.labels,
datasets: [{
label: 'Suma wydatków [PLN]',
data: data.expenses,
backgroundColor: '#0d6efd'
}]
},
options: {
responsive: true,
scales: { y: { beginAtZero: true } }
}
});
}
if (startDate && endDate) {
rangeLabel.textContent = `Widok: własny zakres (${startDate}${endDate})`;
@@ -54,13 +78,28 @@ document.addEventListener("DOMContentLoaded", function () {
else if (range === "yearly") labelText = "Widok: roczne";
rangeLabel.textContent = labelText;
}
})
.catch(error => {
console.error("Błąd pobierania danych:", error);
});
}
// Obsługa przycisku przełączania trybu
document.getElementById("toggleCategorySplit").addEventListener("click", function () {
categorySplit = !categorySplit;
if (categorySplit) {
this.textContent = "🔵 Pokaż całościowo";
this.classList.remove("btn-outline-warning");
this.classList.add("btn-outline-info");
} else {
this.textContent = "🎨 Pokaż podział na kategorie";
this.classList.remove("btn-outline-info");
this.classList.add("btn-outline-warning");
}
loadExpenses(); // przeładuj wykres
});
// Reszta Twojego kodu bez zmian...
const startDateInput = document.getElementById("startDate");
const endDateInput = document.getElementById("endDate");
const today = new Date();
@@ -97,7 +136,7 @@ document.addEventListener("DOMContentLoaded", function () {
document.querySelectorAll('.category-filter').forEach(b => b.classList.remove('active'));
this.classList.add('active');
selectedCategoryId = this.dataset.categoryId || "";
loadExpenses(); // odśwież wykres z nowym filtrem
loadExpenses();
});
});
});

View File

@@ -7,70 +7,117 @@
<a href="/" class="btn btn-outline-secondary">← Powrót do strony głównej</a>
</div>
<nav class="navbar navbar-expand-lg navbar-dark bg-dark rounded mb-4">
<div class="container-fluid p-0">
<a class="navbar-brand" href="#">Funkcje:</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#adminNavbar"
aria-controls="adminNavbar" aria-expanded="false" aria-label="Przełącz nawigację">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="adminNavbar">
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
<li class="nav-item">
<a class="nav-link" href="{{ url_for('list_users') }}">👥 Zarządzanie użytkownikami</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{{ url_for('admin_receipts', id='all') }}">📸 Wszystkie paragony</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{{ url_for('list_products') }}">🛍️ Produkty i sugestie</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{{ url_for('admin_mass_edit_categories') }}">🗂 Masowa edycja kategorii</a>
</li>
</ul>
<div class="card bg-dark text-white mb-4">
<div class="card-body p-2">
<div class="d-flex flex-wrap gap-2">
<a href="{{ url_for('list_users') }}" class="btn btn-outline-light btn-sm">👥 Użytkownicy</a>
<a href="{{ url_for('admin_receipts', id='all') }}" class="btn btn-outline-light btn-sm">📸 Paragony</a>
<a href="{{ url_for('list_products') }}" class="btn btn-outline-light btn-sm">🛍️ Produkty</a>
<a href="{{ url_for('admin_mass_edit_categories') }}" class="btn btn-outline-light btn-sm">🗂 Kategorie</a>
</div>
</div>
</nav>
</div>
<div class="row g-3 mb-4">
<!-- Statystyki liczbowe -->
<div class="col-md-4">
<div class="card bg-dark text-white h-100">
<div class="card-body">
<p><strong>👤 Liczba użytkowników:</strong> {{ user_count }}</p>
<p><strong>📝 Liczba list zakupowych:</strong> {{ list_count }}</p>
<p><strong>🛒 Liczba produktów:</strong> {{ item_count }}</p>
<p><strong>✅ Zakupionych produktów:</strong> {{ purchased_items_count }}</p>
<h5 class="mb-3">📊 Statystyki ogólne</h5>
<table class="table table-dark table-sm mb-0">
<tbody>
<tr>
<td>👤 Użytkownicy</td>
<td class="text-end fw-bold">{{ user_count }}</td>
</tr>
<tr>
<td>📝 Listy zakupowe</td>
<td class="text-end fw-bold">{{ list_count }}</td>
</tr>
<tr>
<td>🛒 Produkty</td>
<td class="text-end fw-bold">{{ item_count }}</td>
</tr>
<tr>
<td>✅ Zakupione</td>
<td class="text-end fw-bold">{{ purchased_items_count }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
{% if top_products %}
<!-- Najczęściej kupowane -->
<div class="col-md-4">
<div class="card bg-dark text-white h-100">
<div class="card-body">
<h5>🔥 Najczęściej kupowane produkty:</h5>
<ul class="mb-0">
{% for name, count in top_products %}
<li>{{ name }} — {{ count }}×</li>
{% endfor %}
</ul>
<h5 class="mb-3">🔥 Najczęściej kupowane produkty</h5>
{% if top_products %}
{% set max_count = top_products[0][1] %}
{% for name, count in top_products %}
<div class="mb-2">
<div class="d-flex justify-content-between">
<span>{{ name }}</span>
<span class="text-muted">{{ count }}×</span>
</div>
<div class="progress" style="height: 6px;">
<div class="progress-bar bg-success" role="progressbar" style="width: {{ (count / max_count) * 100 }}%"
aria-valuenow="{{ count }}" aria-valuemin="0" aria-valuemax="{{ max_count }}">
</div>
</div>
</div>
{% endfor %}
{% else %}
<p class="text-muted mb-0">Brak danych</p>
{% endif %}
</div>
</div>
</div>
{% endif %}
<!-- Podsumowanie wydatków -->
<div class="col-md-4">
<div class="card bg-dark text-white h-100">
<div class="card-body">
<h5>💸 Podsumowanie wydatków:</h5>
<ul class="mb-3">
<li><strong>Obecny miesiąc:</strong> {{ '%.2f'|format(month_expense_sum) }} PLN</li>
<li><strong>Obecny rok:</strong> {{ '%.2f'|format(year_expense_sum) }} PLN</li>
<li><strong>Całkowite:</strong> {{ '%.2f'|format(total_expense_sum) }} PLN</li>
</ul>
<table class="table table-dark table-sm mb-3">
<thead>
<tr>
<th>Typ listy</th>
<th>Miesiąc</th>
<th>Rok</th>
<th>Całkowite</th>
</tr>
</thead>
<tbody>
<tr>
<td>Wszystkie</td>
<td>{{ '%.2f'|format(expense_summary.all.month) }} PLN</td>
<td>{{ '%.2f'|format(expense_summary.all.year) }} PLN</td>
<td>{{ '%.2f'|format(expense_summary.all.total) }} PLN</td>
</tr>
<tr>
<td>Aktywne</td>
<td>{{ '%.2f'|format(expense_summary.active.month) }} PLN</td>
<td>{{ '%.2f'|format(expense_summary.active.year) }} PLN</td>
<td>{{ '%.2f'|format(expense_summary.active.total) }} PLN</td>
</tr>
<tr>
<td>Archiwalne</td>
<td>{{ '%.2f'|format(expense_summary.archived.month) }} PLN</td>
<td>{{ '%.2f'|format(expense_summary.archived.year) }} PLN</td>
<td>{{ '%.2f'|format(expense_summary.archived.total) }} PLN</td>
</tr>
<tr>
<td>Wygasłe</td>
<td>{{ '%.2f'|format(expense_summary.expired.month) }} PLN</td>
<td>{{ '%.2f'|format(expense_summary.expired.year) }} PLN</td>
<td>{{ '%.2f'|format(expense_summary.expired.total) }} PLN</td>
</tr>
</tbody>
</table>
<button type="button" class="btn btn-outline-primary w-100 mt-3" data-bs-toggle="modal"
data-bs-target="#expensesChartModal" id="loadExpensesBtn">
📊 Pokaż wykres wydatków
@@ -78,134 +125,152 @@
</div>
</div>
</div>
</div>
<div class="card bg-dark text-white mb-5">
<div class="card-body">
<h3 class="mt-4">📄 Wszystkie listy zakupowe</h3>
<form method="post" action="{{ url_for('delete_selected_lists') }}">
<div class="table-responsive">
<table class="table table-dark table-striped align-middle sortable">
<thead>
<tr>
<th><input type="checkbox" id="select-all"></th>
<th>ID</th>
<th>Tytuł</th>
<th>Status</th>
<th>Utworzono</th>
<th>Właściciel</th>
<th>Produkty</th>
<th>Wypełnienie</th>
<th>Komentarze</th>
<th>Paragony</th>
<th>Wydatki</th>
<th>Akcje</th>
</tr>
</thead>
<tbody>
{% for e in enriched_lists %}
{% set l = e.list %}
<tr>
<td><input type="checkbox" name="list_ids" value="{{ l.id }}"></td>
<td>{{ l.id }}</td>
<td class="fw-bold">
<a href="{{ url_for('view_list', list_id=l.id) }}" class="text-white">{{ l.title }}</a>
</td>
<td>
{% if l.is_archived %}
<span class="badge bg-secondary">Archiwalna</span>
{% elif e.expired %}
<span class="badge bg-warning text-dark">Wygasła</span>
{% else %}
<span class="badge bg-success">Aktywna</span>
{% endif %}
</td>
<td>{{ l.created_at.strftime('%Y-%m-%d %H:%M') if l.created_at else '-' }}</td>
<td>
{% if l.owner_id %}
{{ l.owner_id }} / {{ l.owner.username if l.owner else 'Brak użytkownika' }}
{% else %}
-
{% endif %}
</td>
<td>{{ e.total_count }}</td>
<td>{{ e.purchased_count }}/{{ e.total_count }} ({{ e.percent }}%)</td>
<td>{{ e.comments_count }}</td>
<td>{{ e.receipts_count }}</td>
<td>
{% if e.total_expense > 0 %}
{{ '%.2f'|format(e.total_expense) }} PLN
{% else %}
-
{% endif %}
</td>
<td class="d-flex flex-wrap gap-1">
<a href="{{ url_for('edit_list', list_id=l.id) }}" class="btn btn-sm btn-outline-primary">✏️
Edytuj</a>
<a href="{{ url_for('delete_list', list_id=l.id) }}" class="btn btn-sm btn-outline-danger">🗑️
Usuń</a>
</td>
</tr>
{% endfor %}
</tbody>
<div class="card bg-dark text-white mb-5">
<div class="card-body">
<h3 class="mt-4">📄 Wszystkie listy zakupowe</h3>
<form method="post" action="{{ url_for('delete_selected_lists') }}">
<div class="table-responsive">
<table class="table table-dark table-striped align-middle sortable">
<thead>
<tr>
<th><input type="checkbox" id="select-all"></th>
<th>ID</th>
<th>Tytuł</th>
<th>Status</th>
<th>Utworzono</th>
<th>Właściciel</th>
<th>Produkty</th>
<th>Wypełnienie</th>
<th>Komentarze</th>
<th>Paragony</th>
<th>Wydatki</th>
<th>Akcje</th>
</tr>
</thead>
<tbody>
{% for e in enriched_lists %}
{% set l = e.list %}
<tr>
<td><input type="checkbox" name="list_ids" value="{{ l.id }}"></td>
<td>{{ l.id }}</td>
<td class="fw-bold align-middle">
<a href="{{ url_for('view_list', list_id=l.id) }}" class="text-white">{{ l.title }}</a>
{% if l.categories %}
<span class="ms-1 text-info" data-bs-toggle="tooltip"
title="{{ l.categories | map(attribute='name') | join(', ') }}">
🏷
</span>
{% endif %}
</td>
</table>
</div>
<button type="submit" class="btn btn-danger mt-2">🗑️ Usuń zaznaczone listy</button>
</form>
</div>
<td>
{% if l.is_archived %}
<span class="badge bg-secondary">Archiwalna</span>
{% elif e.expired %}
<span class="badge bg-warning text-dark">Wygasła</span>
{% else %}
<span class="badge bg-success">Aktywna</span>
{% endif %}
</td>
<td>{{ l.created_at.strftime('%Y-%m-%d %H:%M') if l.created_at else '-' }}</td>
<td>
{% if l.owner %}
👤 {{ l.owner.username }} ({{ l.owner.id }})
{% else %}
-
{% endif %}
</td>
<td>{{ e.total_count }}</td>
<td>
<div class="progress" style="height: 14px;">
<div class="progress-bar
{% if e.percent >= 80 %}bg-success
{% elif e.percent >= 40 %}bg-warning
{% else %}bg-danger{% endif %}" role="progressbar" style="width: {{ e.percent }}%">
{{ e.purchased_count }}/{{ e.total_count }}
</div>
</div>
</td>
<td><span class="badge bg-primary">{{ e.comments_count }}</span></td>
<td><span class="badge bg-secondary">{{ e.receipts_count }}</span></td>
<td class="fw-bold
{% if e.total_expense >= 500 %}text-danger
{% elif e.total_expense > 0 %}text-success{% endif %}">
{% if e.total_expense > 0 %}
{{ '%.2f'|format(e.total_expense) }} PLN
{% else %}
-
{% endif %}
</td>
<td class="d-flex flex-wrap gap-1">
<a href="{{ url_for('edit_list', list_id=l.id) }}" class="btn btn-sm btn-outline-primary">✏️ Edytuj</a>
<a href="{{ url_for('delete_list', list_id=l.id) }}" class="btn btn-sm btn-outline-danger"
onclick="return confirm('Na pewno usunąć tę listę?')">🗑️ Usuń</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<button type="submit" class="btn btn-danger mt-2">🗑️ Usuń zaznaczone listy</button>
</form>
</div>
</div>
<div class="modal fade" id="expensesChartModal" tabindex="-1" aria-labelledby="expensesChartModalLabel"
aria-hidden="true">
<div class="modal-dialog modal-xl modal-dialog-centered">
<div class="modal-content bg-dark text-white rounded">
<div class="modal-header border-0">
<div>
<h5 class="modal-title m-0" id="expensesChartModalLabel">📊 Wydatki</h5>
<small id="chartRangeLabel" class="text-muted">Widok: miesięczne</small>
</div>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Zamknij"></button>
<div class="modal fade" id="expensesChartModal" tabindex="-1" aria-labelledby="expensesChartModalLabel"
aria-hidden="true">
<div class="modal-dialog modal-xl modal-dialog-centered">
<div class="modal-content bg-dark text-white rounded">
<div class="modal-header border-0">
<div>
<h5 class="modal-title m-0" id="expensesChartModalLabel">📊 Wydatki</h5>
<small id="chartRangeLabel" class="text-muted">Widok: miesięczne</small>
</div>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Zamknij"></button>
</div>
<div class="modal-body pt-0">
<div class="d-flex flex-wrap gap-2 mb-3">
<button class="btn btn-outline-light btn-sm range-btn active" data-range="monthly">📅 Miesięczne</button>
<button class="btn btn-outline-light btn-sm range-btn" data-range="quarterly">📊 Kwartalne</button>
<button class="btn btn-outline-light btn-sm range-btn" data-range="halfyearly">🗓️ Półroczne</button>
<button class="btn btn-outline-light btn-sm range-btn" data-range="yearly">📆 Roczne</button>
</div>
<div class="modal-body pt-0">
<div class="d-flex flex-wrap gap-2 mb-3">
<button class="btn btn-outline-light btn-sm range-btn active" data-range="monthly">📅 Miesięczne</button>
<button class="btn btn-outline-light btn-sm range-btn" data-range="quarterly">📊 Kwartalne</button>
<button class="btn btn-outline-light btn-sm range-btn" data-range="halfyearly">🗓️ Półroczne</button>
<button class="btn btn-outline-light btn-sm range-btn" data-range="yearly">📆 Roczne</button>
</div>
<div class="input-group input-group-sm mb-3 w-100" style="max-width: 570px;">
<span class="input-group-text bg-secondary text-white border-secondary">Od</span>
<input type="date" class="form-control bg-dark text-white border-secondary flex-grow-1" id="startDate">
<span class="input-group-text bg-secondary text-white border-secondary">Do</span>
<input type="date" class="form-control bg-dark text-white border-secondary flex-grow-1" id="endDate">
<button class="btn btn-outline-success" id="customRangeBtn">Pokaż dane z zakresu 📅</button>
</div>
<div class="input-group input-group-sm mb-3 w-100" style="max-width: 570px;">
<span class="input-group-text bg-secondary text-white border-secondary">Od</span>
<input type="date" class="form-control bg-dark text-white border-secondary flex-grow-1" id="startDate">
<span class="input-group-text bg-secondary text-white border-secondary">Do</span>
<input type="date" class="form-control bg-dark text-white border-secondary flex-grow-1" id="endDate">
<button class="btn btn-outline-success" id="customRangeBtn">Pokaż dane z zakresu 📅</button>
</div>
<div class="bg-dark rounded p-2">
<canvas id="expensesChart" height="100"></canvas>
</div>
<div class="bg-dark rounded p-2">
<canvas id="expensesChart" height="100"></canvas>
</div>
</div>
</div>
</div>
</div>
{% block scripts %}
<script src="{{ url_for('static_bp.serve_js_lib', filename='chart.js') }}"></script>
<script>
document.getElementById('select-all').addEventListener('click', function () {
const checkboxes = document.querySelectorAll('input[name="list_ids"]');
checkboxes.forEach(cb => cb.checked = this.checked);
});
</script>
<script src="{{ url_for('static_bp.serve_js', filename='expenses.js') }}"></script>
{% block scripts %}
<script src="{{ url_for('static_bp.serve_js_lib', filename='chart.js') }}"></script>
<script>
document.getElementById('select-all').addEventListener('click', function () {
const checkboxes = document.querySelectorAll('input[name="list_ids"]');
checkboxes.forEach(cb => cb.checked = this.checked);
});
</script>
<script src="{{ url_for('static_bp.serve_js', filename='expenses.js') }}"></script>
{% endblock %}
{% endblock %}
<div class="info-bar-fixed">
Python: {{ python_version.split()[0] }} | {{ system_info }} | RAM app: {{ app_memory }} |
DB: {{ db_info.engine|upper }}{% if db_info.version %} v{{ db_info.version[0] }}{% endif %} |
Tabele: {{ table_count }} | Rekordy: {{ record_total }} |
Uptime: {{ uptime_minutes }} min
</div>
{% endblock %}
<div class="info-bar-fixed">
Python: {{ python_version.split()[0] }} | {{ system_info }} | RAM app: {{ app_memory }} |
DB: {{ db_info.engine|upper }}{% if db_info.version %} v{{ db_info.version[0] }}{% endif %} |
Tabele: {{ table_count }} | Rekordy: {{ record_total }} |
Uptime: {{ uptime_minutes }} min
</div>
{% endblock %}

View File

@@ -6,15 +6,22 @@
<h2 class="mb-2">Statystyki wydatków</h2>
<a href="{{ url_for('main_page') }}" class="btn btn-outline-secondary">← Powrót</a>
</div>
<div class="form-check form-switch mb-3">
<input class="form-check-input" type="checkbox" id="showAllLists" {% if show_all %}checked{% endif %}>
<label class="form-check-label ms-2 text-white" for="showAllLists">Pokaż wszystkie publiczne listy
innych</label>
<div class="d-flex justify-content-center mb-3">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="showAllLists" {% if show_all %}checked{% endif %}>
<label class="form-check-label ms-2 text-white" for="showAllLists">
Pokaż wszystkie publiczne listy innych
</label>
</div>
</div>
<div class="d-flex flex-wrap gap-2 mb-3">
<a href="{{ url_for('user_expenses', show_all='true') }}"
class="btn btn-sm {% if not selected_category %}btn-success{% else %}btn-outline-light{% endif %}">🌐 Wszystkie</a>
<div class="d-flex flex-wrap gap-2 mb-3 justify-content-center">
<a href="{{ url_for('user_expenses') }}"
class="btn btn-sm {% if not selected_category %}btn-success{% else %}btn-outline-light{% endif %}">
🌐 Wszystkie
</a>
{% for cat in categories %}
<a href="{{ url_for('user_expenses', category_id=cat.id) }}"
class="btn btn-sm {% if selected_category == cat.id %}btn-success{% else %}btn-outline-light{% endif %}">
@@ -23,6 +30,7 @@
{% endfor %}
</div>
<div class="card bg-dark text-white mb-5">
<div class="card-body">
<ul class="nav nav-tabs mb-3" id="expenseTabs" role="tablist">
@@ -49,7 +57,7 @@
<div class="card bg-dark text-white mb-4">
<div class="card-body">
<div class="d-flex flex-wrap gap-2 mb-3">
<div class="d-flex flex-wrap gap-2 mb-3 justify-content-center">
<button class="btn btn-outline-light btn-sm range-btn" data-range="day">🗓️ Dzień</button>
<button class="btn btn-outline-light btn-sm range-btn" data-range="week">📆 Tydzień</button>
<button class="btn btn-outline-light btn-sm range-btn active" data-range="month">📅 Miesiąc</button>
@@ -57,19 +65,25 @@
<button class="btn btn-outline-light btn-sm range-btn" data-range="all">🌐 Wszystko</button>
</div>
<div class="form-check form-switch mb-3">
<input class="form-check-input" type="checkbox" id="onlyWithExpenses">
<label class="form-check-label ms-2 text-white" for="onlyWithExpenses">Pokaż tylko listy z
wydatkami</label>
<div class="d-flex justify-content-center mb-3">
<div class="input-group input-group-sm w-100" style="max-width: 570px;">
<span class="input-group-text bg-secondary text-white border-secondary">Od</span>
<input type="date" class="form-control bg-dark text-white border-secondary flex-grow-1"
id="customStart">
<span class="input-group-text bg-secondary text-white border-secondary">Do</span>
<input type="date" class="form-control bg-dark text-white border-secondary flex-grow-1" id="customEnd">
<button class="btn btn-outline-success" id="applyCustomRange">📊 Zastosuj zakres</button>
</div>
</div>
<div class="input-group input-group-sm mb-3 w-100" style="max-width: 570px;">
<span class="input-group-text bg-secondary text-white border-secondary">Od</span>
<input type="date" class="form-control bg-dark text-white border-secondary flex-grow-1" id="customStart">
<span class="input-group-text bg-secondary text-white border-secondary">Do</span>
<input type="date" class="form-control bg-dark text-white border-secondary flex-grow-1" id="customEnd">
<button class="btn btn-outline-success" id="applyCustomRange">📊 Zastosuj zakres</button>
<div class="d-flex justify-content-center mb-3">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="onlyWithExpenses">
<label class="form-check-label ms-2 text-white" for="onlyWithExpenses">
Pokaż tylko listy z wydatkami
</label>
</div>
</div>
<div class="d-flex justify-content-end mb-2">
@@ -123,13 +137,19 @@
<div class="tab-pane fade" id="chartTab" role="tabpanel">
<div class="card bg-dark text-white mb-4">
<div class="card-body">
<button class="btn btn-outline-light w-100 py-2 mb-2 d-flex align-items-center justify-content-center gap-2"
id="toggleCategorySplit">
🎨 Pokaż podział na kategorie
</button>
<p id="chartRangeLabel" class="fw-bold mb-3">Widok: miesięczne</p>
<canvas id="expensesChart" height="120"></canvas>
</div>
</div>
<div class="d-flex flex-wrap gap-2 mb-3">
<div class="d-flex flex-wrap gap-2 mb-3 justify-content-center">
<button class="btn btn-outline-light btn-sm range-btn active" data-range="monthly">📅 Miesięczne</button>
<button class="btn btn-outline-light btn-sm range-btn" data-range="quarterly">📊 Kwartalne</button>
<button class="btn btn-outline-light btn-sm range-btn" data-range="halfyearly">🗓️ Półroczne</button>
@@ -137,14 +157,17 @@
</div>
<!-- Picker daty w formie input-group -->
<div class="input-group input-group-sm mb-4 w-100" style="max-width: 570px;">
<span class="input-group-text bg-secondary text-white border-secondary">Od</span>
<input type="date" class="form-control bg-dark text-white border-secondary flex-grow-1" id="startDate">
<span class="input-group-text bg-secondary text-white border-secondary">Do</span>
<input type="date" class="form-control bg-dark text-white border-secondary flex-grow-1" id="endDate">
<button class="btn btn-outline-success" id="customRangeBtn">📊 Pokaż dane z zakresu</button>
<div class="d-flex justify-content-center mb-4">
<div class="input-group input-group-sm w-100" style="max-width: 570px;">
<span class="input-group-text bg-secondary text-white border-secondary">Od</span>
<input type="date" class="form-control bg-dark text-white border-secondary flex-grow-1" id="startDate">
<span class="input-group-text bg-secondary text-white border-secondary">Do</span>
<input type="date" class="form-control bg-dark text-white border-secondary flex-grow-1" id="endDate">
<button class="btn btn-outline-success" id="customRangeBtn">📊 Pokaż dane z zakresu</button>
</div>
</div>
</div>
</div>