poprawki i optymalizacje kodu
This commit is contained in:
255
app.py
255
app.py
@@ -29,6 +29,7 @@ from flask import (
|
||||
abort,
|
||||
session,
|
||||
jsonify,
|
||||
g,
|
||||
)
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
from flask_login import (
|
||||
@@ -44,7 +45,7 @@ from flask_socketio import SocketIO, emit, join_room
|
||||
from config import Config
|
||||
from PIL import Image, ExifTags, ImageFilter, ImageOps
|
||||
from werkzeug.middleware.proxy_fix import ProxyFix
|
||||
from sqlalchemy import func, extract, inspect, or_, case, text
|
||||
from sqlalchemy import func, extract, inspect, or_, case, text, and_
|
||||
from sqlalchemy.orm import joinedload, load_only, aliased
|
||||
from collections import defaultdict, deque
|
||||
from functools import wraps
|
||||
@@ -219,10 +220,13 @@ class ShoppingList(db.Model):
|
||||
|
||||
# Relacje
|
||||
items = db.relationship("Item", back_populates="shopping_list", lazy="select")
|
||||
receipts = db.relationship("Receipt", back_populates="shopping_list", lazy="select")
|
||||
receipts = db.relationship(
|
||||
"Receipt",
|
||||
back_populates="shopping_list",
|
||||
cascade="all, delete-orphan",
|
||||
lazy="select",
|
||||
)
|
||||
expenses = db.relationship("Expense", back_populates="shopping_list", lazy="select")
|
||||
|
||||
# Nowa relacja wiele-do-wielu
|
||||
categories = db.relationship(
|
||||
"Category",
|
||||
secondary=shopping_list_category,
|
||||
@@ -270,7 +274,11 @@ class Expense(db.Model):
|
||||
|
||||
class Receipt(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
list_id = db.Column(db.Integer, db.ForeignKey("shopping_list.id"), nullable=False)
|
||||
list_id = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey("shopping_list.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
)
|
||||
filename = db.Column(db.String(255), nullable=False)
|
||||
uploaded_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
filesize = db.Column(db.Integer, nullable=True)
|
||||
@@ -763,55 +771,70 @@ def get_admin_expense_summary():
|
||||
current_year = now.year
|
||||
current_month = now.month
|
||||
|
||||
def calc_sum(base_query):
|
||||
total = base_query.scalar() or 0
|
||||
def calc_summary(expense_query, list_query):
|
||||
total = expense_query.scalar() or 0
|
||||
year_total = (
|
||||
base_query.filter(
|
||||
expense_query.filter(
|
||||
extract("year", ShoppingList.created_at) == current_year
|
||||
).scalar()
|
||||
or 0
|
||||
)
|
||||
month_total = (
|
||||
base_query.filter(extract("year", ShoppingList.created_at) == current_year)
|
||||
.filter(extract("month", ShoppingList.created_at) == current_month)
|
||||
.scalar()
|
||||
expense_query.filter(
|
||||
extract("year", ShoppingList.created_at) == current_year,
|
||||
extract("month", ShoppingList.created_at) == current_month,
|
||||
).scalar()
|
||||
or 0
|
||||
)
|
||||
return {"total": total, "year": year_total, "month": month_total}
|
||||
list_count = list_query.count()
|
||||
avg = round(total / list_count, 2) if list_count else 0
|
||||
return {
|
||||
"total": total,
|
||||
"year": year_total,
|
||||
"month": month_total,
|
||||
"count": list_count,
|
||||
"avg": avg,
|
||||
}
|
||||
|
||||
base = db.session.query(func.sum(Expense.amount)).join(
|
||||
expense_base = db.session.query(func.sum(Expense.amount)).join(
|
||||
ShoppingList, ShoppingList.id == Expense.list_id
|
||||
)
|
||||
list_base = ShoppingList.query
|
||||
|
||||
all_lists = calc_sum(base)
|
||||
all = calc_summary(expense_base, list_base)
|
||||
|
||||
active_lists = calc_sum(
|
||||
base.filter(
|
||||
ShoppingList.is_archived == False,
|
||||
~(
|
||||
(ShoppingList.is_temporary == True)
|
||||
& (ShoppingList.expires_at != None)
|
||||
& (ShoppingList.expires_at <= now)
|
||||
),
|
||||
)
|
||||
active_condition = and_(
|
||||
ShoppingList.is_archived == False,
|
||||
~(
|
||||
(ShoppingList.is_temporary == True)
|
||||
& (ShoppingList.expires_at != None)
|
||||
& (ShoppingList.expires_at <= now)
|
||||
),
|
||||
)
|
||||
active = calc_summary(
|
||||
expense_base.filter(active_condition), list_base.filter(active_condition)
|
||||
)
|
||||
|
||||
archived_lists = calc_sum(base.filter(ShoppingList.is_archived == True))
|
||||
archived_condition = ShoppingList.is_archived == True
|
||||
archived = calc_summary(
|
||||
expense_base.filter(archived_condition), list_base.filter(archived_condition)
|
||||
)
|
||||
|
||||
expired_lists = calc_sum(
|
||||
base.filter(
|
||||
ShoppingList.is_archived == False,
|
||||
(ShoppingList.is_temporary == True),
|
||||
(ShoppingList.expires_at != None),
|
||||
(ShoppingList.expires_at <= now),
|
||||
)
|
||||
expired_condition = and_(
|
||||
ShoppingList.is_archived == False,
|
||||
ShoppingList.is_temporary == True,
|
||||
ShoppingList.expires_at != None,
|
||||
ShoppingList.expires_at <= now,
|
||||
)
|
||||
expired = calc_summary(
|
||||
expense_base.filter(expired_condition), list_base.filter(expired_condition)
|
||||
)
|
||||
|
||||
return {
|
||||
"all": all_lists,
|
||||
"active": active_lists,
|
||||
"archived": archived_lists,
|
||||
"expired": expired_lists,
|
||||
"all": all,
|
||||
"active": active,
|
||||
"archived": archived,
|
||||
"expired": expired,
|
||||
}
|
||||
|
||||
|
||||
@@ -1205,7 +1228,7 @@ def require_system_password():
|
||||
|
||||
@app.before_request
|
||||
def start_timer():
|
||||
request._start_time = time.time()
|
||||
g.start_time = time.time()
|
||||
|
||||
|
||||
@app.after_request
|
||||
@@ -1218,9 +1241,10 @@ def log_request(response):
|
||||
path = request.path
|
||||
status = response.status_code
|
||||
length = response.content_length or "-"
|
||||
start = getattr(request, "_start_time", None)
|
||||
start = getattr(g, "start_time", None)
|
||||
duration = round((time.time() - start) * 1000, 2) if start else "-"
|
||||
agent = request.headers.get("User-Agent", "-")
|
||||
|
||||
if status == 304:
|
||||
app.logger.info(
|
||||
f'REVALIDATED: {ip} - "{method} {path}" {status} {length} {duration}ms "{agent}"'
|
||||
@@ -1229,6 +1253,7 @@ def log_request(response):
|
||||
app.logger.info(
|
||||
f'{ip} - "{method} {path}" {status} {length} {duration}ms "{agent}"'
|
||||
)
|
||||
|
||||
app.logger.debug(f"Request headers: {dict(request.headers)}")
|
||||
app.logger.debug(f"Response headers: {dict(response.headers)}")
|
||||
return response
|
||||
@@ -2014,14 +2039,31 @@ def all_products():
|
||||
base_query = base_query.order_by("normalized_name")
|
||||
|
||||
results = base_query.offset(offset).limit(limit).all()
|
||||
|
||||
total_count = (
|
||||
db.session.query(func.count()).select_from(base_query.subquery()).scalar()
|
||||
)
|
||||
|
||||
products = [{"name": row.original_name, "count": row.count} for row in results]
|
||||
used_names = set(row.original_name.strip().lower() for row in results)
|
||||
extra_suggestions = (
|
||||
db.session.query(SuggestedProduct.name)
|
||||
.filter(~func.lower(func.trim(SuggestedProduct.name)).in_(used_names))
|
||||
.all()
|
||||
)
|
||||
|
||||
return jsonify({"products": products, "total_count": total_count})
|
||||
suggested_fallbacks = [
|
||||
{"name": row.name.strip(), "count": 0} for row in extra_suggestions
|
||||
]
|
||||
|
||||
if sort == "alphabetical":
|
||||
products += suggested_fallbacks
|
||||
products.sort(key=lambda x: x["name"].lower())
|
||||
else:
|
||||
products += suggested_fallbacks
|
||||
|
||||
return jsonify(
|
||||
{"products": products, "total_count": total_count + len(suggested_fallbacks)}
|
||||
)
|
||||
|
||||
|
||||
@app.route("/upload_receipt/<int:list_id>", methods=["POST"])
|
||||
@@ -2251,7 +2293,6 @@ def admin_panel():
|
||||
now = datetime.now(timezone.utc)
|
||||
start = end = None
|
||||
|
||||
# Liczniki globalne
|
||||
user_count = User.query.count()
|
||||
list_count = ShoppingList.query.count()
|
||||
item_count = Item.query.count()
|
||||
@@ -2270,17 +2311,12 @@ def admin_panel():
|
||||
)
|
||||
|
||||
all_lists = base_query.all()
|
||||
|
||||
# tylko listy z danych miesięcy
|
||||
month_options = get_active_months_query()
|
||||
|
||||
all_ids = [l.id for l in all_lists]
|
||||
|
||||
stats_map = {}
|
||||
latest_expenses_map = {}
|
||||
|
||||
if all_ids:
|
||||
# Statystyki produktów
|
||||
stats = (
|
||||
db.session.query(
|
||||
Item.list_id,
|
||||
@@ -2336,17 +2372,57 @@ def admin_panel():
|
||||
}
|
||||
)
|
||||
|
||||
purchased_items_count = Item.query.filter_by(purchased=True).count()
|
||||
not_purchased_count = Item.query.filter_by(not_purchased=True).count()
|
||||
items_with_notes = Item.query.filter(Item.note.isnot(None), Item.note != "").count()
|
||||
|
||||
total_expense = db.session.query(func.sum(Expense.amount)).scalar() or 0
|
||||
avg_list_expense = round(total_expense / list_count, 2) if list_count else 0
|
||||
|
||||
time_to_purchase = (
|
||||
db.session.query(
|
||||
func.avg(
|
||||
func.strftime("%s", Item.purchased_at)
|
||||
- func.strftime("%s", Item.added_at)
|
||||
)
|
||||
)
|
||||
.filter(
|
||||
Item.purchased == True,
|
||||
Item.purchased_at.isnot(None),
|
||||
Item.added_at.isnot(None),
|
||||
)
|
||||
.scalar()
|
||||
)
|
||||
avg_hours_to_purchase = round(time_to_purchase / 3600, 2) if time_to_purchase else 0
|
||||
|
||||
first_list = db.session.query(func.min(ShoppingList.created_at)).scalar()
|
||||
last_list = db.session.query(func.max(ShoppingList.created_at)).scalar()
|
||||
now_dt = datetime.now(timezone.utc)
|
||||
|
||||
if first_list and first_list.tzinfo is None:
|
||||
first_list = first_list.replace(tzinfo=timezone.utc)
|
||||
|
||||
if last_list and last_list.tzinfo is None:
|
||||
last_list = last_list.replace(tzinfo=timezone.utc)
|
||||
|
||||
if first_list and last_list:
|
||||
days_span = max((now_dt - first_list).days, 1)
|
||||
avg_per_day = list_count / days_span
|
||||
avg_per_week = round(avg_per_day * 7, 2)
|
||||
avg_per_month = round(avg_per_day * 30.44, 2)
|
||||
avg_per_year = round(avg_per_day * 365, 2)
|
||||
else:
|
||||
avg_per_week = avg_per_month = avg_per_year = 0
|
||||
|
||||
top_products = (
|
||||
db.session.query(Item.name, func.count(Item.id).label("count"))
|
||||
.filter(Item.purchased.is_(True))
|
||||
.group_by(Item.name)
|
||||
.order_by(func.count(Item.id).desc())
|
||||
.limit(5)
|
||||
.limit(7)
|
||||
.all()
|
||||
)
|
||||
|
||||
purchased_items_count = Item.query.filter_by(purchased=True).count()
|
||||
|
||||
expense_summary = get_admin_expense_summary()
|
||||
process = psutil.Process(os.getpid())
|
||||
app_mem = process.memory_info().rss // (1024 * 1024)
|
||||
@@ -2365,12 +2441,21 @@ def admin_panel():
|
||||
(datetime.now(timezone.utc) - app_start_time).total_seconds() // 60
|
||||
)
|
||||
|
||||
month_options = get_active_months_query()
|
||||
|
||||
return render_template(
|
||||
"admin/admin_panel.html",
|
||||
user_count=user_count,
|
||||
list_count=list_count,
|
||||
item_count=item_count,
|
||||
purchased_items_count=purchased_items_count,
|
||||
not_purchased_count=not_purchased_count,
|
||||
items_with_notes=items_with_notes,
|
||||
avg_hours_to_purchase=avg_hours_to_purchase,
|
||||
avg_list_expense=avg_list_expense,
|
||||
avg_per_week=avg_per_week,
|
||||
avg_per_month=avg_per_month,
|
||||
avg_per_year=avg_per_year,
|
||||
enriched_lists=enriched_lists,
|
||||
top_products=top_products,
|
||||
expense_summary=expense_summary,
|
||||
@@ -2389,21 +2474,6 @@ def admin_panel():
|
||||
)
|
||||
|
||||
|
||||
@app.route("/admin/delete_list/<int:list_id>")
|
||||
@login_required
|
||||
@admin_required
|
||||
def delete_list(list_id):
|
||||
|
||||
delete_receipts_for_list(list_id)
|
||||
list_to_delete = ShoppingList.query.get_or_404(list_id)
|
||||
Item.query.filter_by(list_id=list_to_delete.id).delete()
|
||||
Expense.query.filter_by(list_id=list_to_delete.id).delete()
|
||||
db.session.delete(list_to_delete)
|
||||
db.session.commit()
|
||||
flash(f"Usunięto listę: {list_to_delete.title}", "success")
|
||||
return redirect(url_for("admin_panel"))
|
||||
|
||||
|
||||
@app.route("/admin/add_user", methods=["POST"])
|
||||
@login_required
|
||||
@admin_required
|
||||
@@ -2481,20 +2551,41 @@ def delete_user(user_id):
|
||||
user = User.query.get_or_404(user_id)
|
||||
|
||||
if user.is_admin:
|
||||
admin_count = User.query.filter_by(is_admin=True).count()
|
||||
if admin_count <= 1:
|
||||
flash("Nie można usunąć ostatniego administratora.", "danger")
|
||||
return redirect(url_for("list_users"))
|
||||
flash("Nie można usunąć konta administratora.", "warning")
|
||||
return redirect(url_for("list_users"))
|
||||
|
||||
admin_user = User.query.filter_by(is_admin=True).first()
|
||||
if not admin_user:
|
||||
flash("Brak konta administratora do przeniesienia zawartości.", "danger")
|
||||
return redirect(url_for("list_users"))
|
||||
|
||||
lists_owned = ShoppingList.query.filter_by(owner_id=user.id).count()
|
||||
|
||||
if lists_owned > 0:
|
||||
ShoppingList.query.filter_by(owner_id=user.id).update(
|
||||
{"owner_id": admin_user.id}
|
||||
)
|
||||
Receipt.query.filter_by(uploaded_by=user.id).update(
|
||||
{"uploaded_by": admin_user.id}
|
||||
)
|
||||
Item.query.filter_by(added_by=user.id).update({"added_by": admin_user.id})
|
||||
db.session.commit()
|
||||
flash(
|
||||
f"Użytkownik '{user.username}' został usunięty, a jego zawartość przeniesiona na administratora.",
|
||||
"success",
|
||||
)
|
||||
else:
|
||||
flash(
|
||||
f"Użytkownik '{user.username}' został usunięty. Nie posiadał żadnych list zakupowych.",
|
||||
"info",
|
||||
)
|
||||
|
||||
db.session.delete(user)
|
||||
db.session.commit()
|
||||
flash("Użytkownik usunięty", "success")
|
||||
|
||||
return redirect(url_for("list_users"))
|
||||
|
||||
|
||||
from sqlalchemy.orm import joinedload
|
||||
|
||||
|
||||
@app.route("/admin/receipts/<id>")
|
||||
@login_required
|
||||
@admin_required
|
||||
@@ -2656,23 +2747,27 @@ def generate_receipt_hash(receipt_id):
|
||||
return redirect(request.referrer)
|
||||
|
||||
|
||||
@app.route("/admin/delete_selected_lists", methods=["POST"])
|
||||
@app.route("/admin/delete_list", methods=["POST"])
|
||||
@login_required
|
||||
@admin_required
|
||||
def delete_selected_lists():
|
||||
def admin_delete_list():
|
||||
ids = request.form.getlist("list_ids")
|
||||
single_id = request.form.get("single_list_id")
|
||||
if single_id:
|
||||
ids.append(single_id)
|
||||
|
||||
for list_id in ids:
|
||||
|
||||
lst = db.session.get(ShoppingList, int(list_id))
|
||||
|
||||
if lst:
|
||||
delete_receipts_for_list(lst.id)
|
||||
Receipt.query.filter_by(list_id=lst.id).delete()
|
||||
Item.query.filter_by(list_id=lst.id).delete()
|
||||
Expense.query.filter_by(list_id=lst.id).delete()
|
||||
db.session.delete(lst)
|
||||
|
||||
db.session.commit()
|
||||
flash("Usunięto wybrane listy", "success")
|
||||
return redirect(url_for("admin_panel"))
|
||||
flash(f"Usunięto {len(ids)} list(y)", "success")
|
||||
return redirect(request.referrer or url_for("admin_panel"))
|
||||
|
||||
|
||||
@app.route("/admin/edit_list/<int:list_id>", methods=["GET", "POST"])
|
||||
@@ -2735,6 +2830,12 @@ def edit_list(list_id):
|
||||
user_obj = db.session.get(User, new_owner_id_int)
|
||||
if user_obj:
|
||||
shopping_list.owner_id = new_owner_id_int
|
||||
Item.query.filter_by(list_id=list_id).update(
|
||||
{"added_by": new_owner_id_int}
|
||||
)
|
||||
Receipt.query.filter_by(list_id=list_id).update(
|
||||
{"uploaded_by": new_owner_id_int}
|
||||
)
|
||||
else:
|
||||
flash("Wybrany użytkownik nie istnieje", "danger")
|
||||
return redirect(url_for("edit_list", list_id=list_id))
|
||||
|
@@ -42,12 +42,29 @@
|
||||
<td>✅ Zakupione</td>
|
||||
<td class="text-end fw-bold">{{ purchased_items_count }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>🚫 Nieoznaczone jako kupione</td>
|
||||
<td class="text-end fw-bold">{{ not_purchased_count }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>✍️ Produkty z notatkami</td>
|
||||
<td class="text-end fw-bold">{{ items_with_notes }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>🕓 Śr. czas do zakupu (h)</td>
|
||||
<td class="text-end fw-bold">{{ avg_hours_to_purchase }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>💸 Średnia kwota na listę</td>
|
||||
<td class="text-end fw-bold">{{ avg_list_expense }} zł</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Najczęściej kupowane -->
|
||||
<div class="col-md-4">
|
||||
<div class="card bg-dark text-white h-100">
|
||||
@@ -79,17 +96,18 @@
|
||||
|
||||
<!-- Podsumowanie wydatków -->
|
||||
<div class="col-md-4">
|
||||
<div class="card bg-dark text-white h-100">
|
||||
<div class="card bg-dark text-white h-100 shadow-sm">
|
||||
<div class="card-body">
|
||||
<h5>💸 Podsumowanie wydatków:</h5>
|
||||
<h5 class="mb-3">💸 Podsumowanie wydatków</h5>
|
||||
|
||||
<table class="table table-dark table-sm mb-3">
|
||||
<thead>
|
||||
<table class="table table-dark table-sm mb-3 align-middle">
|
||||
<thead class="text-muted small">
|
||||
<tr>
|
||||
<th>Typ listy</th>
|
||||
<th>Miesiąc</th>
|
||||
<th>Rok</th>
|
||||
<th>Całkowite</th>
|
||||
<th title="Rodzaj listy zakupowej">Typ listy</th>
|
||||
<th title="Wydatki w bieżącym miesiącu">Miesiąc</th>
|
||||
<th title="Wydatki w bieżącym roku">Rok</th>
|
||||
<th title="Wydatki łączne">Całkowite</th>
|
||||
<th title="Średnia kwota na 1 listę">Średnia</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -98,225 +116,247 @@
|
||||
<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>
|
||||
<td>{{ '%.2f'|format(expense_summary.all.avg) }} 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>
|
||||
<td>{{ '%.2f'|format(expense_summary.active.avg) }} 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>
|
||||
<td>{{ '%.2f'|format(expense_summary.archived.avg) }} 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>
|
||||
<td>{{ '%.2f'|format(expense_summary.expired.avg) }} PLN</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="mb-3">
|
||||
<div class="small text-uppercase mb-1">📈 Średnie tempo tworzenia list:</div>
|
||||
<ul class="list-unstyled small mb-0">
|
||||
<li>📆 Tygodniowo: <strong>{{ avg_per_week }}</strong></li>
|
||||
<li>🗓️ Miesięcznie: <strong>{{ avg_per_month }}</strong></li>
|
||||
<li>📅 Rocznie: <strong>{{ avg_per_year }}</strong></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<a href="{{ url_for('expenses') }}#chartTab" class="btn btn-outline-light w-100">
|
||||
📊 Pokaż wykres wydatków
|
||||
</a>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# panel wyboru miesiąca zawsze widoczny #}
|
||||
<div class="d-flex justify-content-between align-items-center mb-3 flex-wrap gap-2">
|
||||
|
||||
{# LEWA STRONA — przyciski ← → TYLKO gdy nie show_all #}
|
||||
<div class="d-flex gap-2">
|
||||
{% if not show_all %}
|
||||
{% set current_date = now.replace(day=1) %}
|
||||
{% set prev_month = (current_date - timedelta(days=1)).strftime('%Y-%m') %}
|
||||
{% set next_month = (current_date + timedelta(days=31)).replace(day=1).strftime('%Y-%m') %}
|
||||
{# panel wyboru miesiąca zawsze widoczny #}
|
||||
<div class="d-flex justify-content-between align-items-center mb-3 flex-wrap gap-2">
|
||||
|
||||
{% if prev_month in month_options %}
|
||||
<a href="{{ url_for('admin_panel', m=prev_month) }}" class="btn btn-outline-light btn-sm">
|
||||
← {{ prev_month }}
|
||||
</a>
|
||||
{% else %}
|
||||
<button class="btn btn-outline-light btn-sm opacity-50" disabled>← {{ prev_month }}</button>
|
||||
{% endif %}
|
||||
{# LEWA STRONA — przyciski ← → TYLKO gdy nie show_all #}
|
||||
<div class="d-flex gap-2">
|
||||
{% if not show_all %}
|
||||
{% set current_date = now.replace(day=1) %}
|
||||
{% set prev_month = (current_date - timedelta(days=1)).strftime('%Y-%m') %}
|
||||
{% set next_month = (current_date + timedelta(days=31)).replace(day=1).strftime('%Y-%m') %}
|
||||
|
||||
{% if next_month in month_options %}
|
||||
<a href="{{ url_for('admin_panel', m=next_month) }}" class="btn btn-outline-light btn-sm">
|
||||
{{ next_month }} →
|
||||
</a>
|
||||
{% else %}
|
||||
<button class="btn btn-outline-light btn-sm opacity-50" disabled>{{ next_month }} →</button>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
{# Tryb wszystkie miesiące — możemy pokazać skrót do bieżącego miesiąca #}
|
||||
<a href="{{ url_for('admin_panel', m=now.strftime('%Y-%m')) }}" class="btn btn-outline-light btn-sm">
|
||||
📅 Przejdź do bieżącego miesiąca
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# PRAWA STRONA — picker miesięcy zawsze widoczny #}
|
||||
<form method="get" class="m-0">
|
||||
<div class="input-group input-group-sm">
|
||||
<span class="input-group-text bg-secondary text-white">📅</span>
|
||||
<select name="m" class="form-select bg-dark text-white border-secondary" onchange="this.form.submit()">
|
||||
<option value="all" {% if show_all %}selected{% endif %}>Wszystkie miesiące</option>
|
||||
{% for val in month_options %}
|
||||
{% set date_obj = (val ~ '-01') | todatetime %}
|
||||
<option value="{{ val }}" {% if month_str==val %}selected{% endif %}>
|
||||
{{ date_obj.strftime('%B %Y')|capitalize }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card bg-dark text-white mb-5">
|
||||
<div class="card-body">
|
||||
<h3 class="mt-4">
|
||||
📄 Listy zakupowe
|
||||
{% if show_all %}
|
||||
— <strong>wszystkie miesiące</strong>
|
||||
{% if prev_month in month_options %}
|
||||
<a href="{{ url_for('admin_panel', m=prev_month) }}" class="btn btn-outline-light btn-sm">
|
||||
← {{ prev_month }}
|
||||
</a>
|
||||
{% else %}
|
||||
— <strong>{{ month_str|replace('-', ' / ') }}</strong>
|
||||
<button class="btn btn-outline-light btn-sm opacity-50" disabled>← {{ prev_month }}</button>
|
||||
{% endif %}
|
||||
</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>Progress</th>
|
||||
<th>Koment.</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>
|
||||
|
||||
<td>
|
||||
{% if l.is_archived %}
|
||||
<span class="badge rounded-pill bg-secondary">Archiwalna</span>
|
||||
{% elif e.expired %}
|
||||
<span class="badge rounded-pill bg-warning text-dark">Wygasła</span>
|
||||
{% else %}
|
||||
<span class="badge rounded-pill 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 bg-transparent" style=" height: 14px;">
|
||||
<div class="progress-bar fw-bold text-black text-cente
|
||||
{% 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 rounded-pill bg-primary">{{ e.comments_count }}</span></td>
|
||||
<td><span class="badge rounded-pill 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>
|
||||
<div class="btn-group btn-group-sm" role="group">
|
||||
<a href="{{ url_for('edit_list', list_id=l.id) }}" class="btn btn-sm btn-outline-light"
|
||||
title="Edytuj">✏️</a>
|
||||
<button type="button" class="btn btn-sm btn-outline-light preview-btn" data-list-id="{{ l.id }}"
|
||||
title="Podgląd produktów">
|
||||
👁️
|
||||
</button>
|
||||
<a href="{{ url_for('delete_list', list_id=l.id) }}" class="btn btn-sm btn-outline-light"
|
||||
onclick="return confirm('Na pewno usunąć tę listę?')" title="Usuń">🗑️</a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="d-flex justify-content-end mt-2">
|
||||
<button type="submit" class="btn btn-outline-light btn-sm">🗑️ Usuń zaznaczone listy</button>
|
||||
{% if next_month in month_options %}
|
||||
<a href="{{ url_for('admin_panel', m=next_month) }}" class="btn btn-outline-light btn-sm">
|
||||
{{ next_month }} →
|
||||
</a>
|
||||
{% else %}
|
||||
<button class="btn btn-outline-light btn-sm opacity-50" disabled>{{ next_month }} →</button>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
{# Tryb wszystkie miesiące — możemy pokazać skrót do bieżącego miesiąca #}
|
||||
<a href="{{ url_for('admin_panel', m=now.strftime('%Y-%m')) }}" class="btn btn-outline-light btn-sm">
|
||||
📅 Przejdź do bieżącego miesiąca
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# PRAWA STRONA — picker miesięcy zawsze widoczny #}
|
||||
<form method="get" class="m-0">
|
||||
<div class="input-group input-group-sm">
|
||||
<span class="input-group-text bg-secondary text-white">📅</span>
|
||||
<select name="m" class="form-select bg-dark text-white border-secondary" onchange="this.form.submit()">
|
||||
<option value="all" {% if show_all %}selected{% endif %}>Wszystkie miesiące</option>
|
||||
{% for val in month_options %}
|
||||
{% set date_obj = (val ~ '-01') | todatetime %}
|
||||
<option value="{{ val }}" {% if month_str==val %}selected{% endif %}>
|
||||
{{ date_obj.strftime('%B %Y')|capitalize }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<div class="card bg-dark text-white mb-5">
|
||||
<div class="card-body">
|
||||
<h3 class="mt-4">
|
||||
📄 Listy zakupowe
|
||||
{% if show_all %}
|
||||
— <strong>wszystkie miesiące</strong>
|
||||
{% else %}
|
||||
— <strong>{{ month_str|replace('-', ' / ') }}</strong>
|
||||
{% endif %}
|
||||
</h3>
|
||||
<form method="post" action="{{ url_for('admin_delete_list') }}">
|
||||
<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>Progress</th>
|
||||
<th>Koment.</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>
|
||||
|
||||
<!-- Modal podglądu produktów -->
|
||||
<div class="modal fade" id="productPreviewModal" tabindex="-1" aria-labelledby="previewModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-lg modal-dialog-scrollable">
|
||||
<div class="modal-content bg-dark text-white">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="previewModalLabel">Podgląd produktów</h5>
|
||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Zamknij"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<ul id="product-list" class="list-group list-group-flush"></ul>
|
||||
<td>
|
||||
{% if l.is_archived %}
|
||||
<span class="badge rounded-pill bg-secondary">Archiwalna</span>
|
||||
{% elif e.expired %}
|
||||
<span class="badge rounded-pill bg-warning text-dark">Wygasła</span>
|
||||
{% else %}
|
||||
<span class="badge rounded-pill 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 bg-transparent" style=" height: 14px;">
|
||||
<div class="progress-bar fw-bold text-black text-cente
|
||||
{% 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 rounded-pill bg-primary">{{ e.comments_count }}</span></td>
|
||||
<td><span class="badge rounded-pill 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>
|
||||
<div class="btn-group btn-group-sm" role="group">
|
||||
<a href="{{ url_for('edit_list', list_id=l.id) }}" class="btn btn-sm btn-outline-light"
|
||||
title="Edytuj">✏️</a>
|
||||
<button type="button" class="btn btn-sm btn-outline-light preview-btn" data-list-id="{{ l.id }}"
|
||||
title="Podgląd produktów">
|
||||
👁️
|
||||
</button>
|
||||
<form method="post" action="{{ url_for('admin_delete_list') }}"
|
||||
onsubmit="return confirm('Na pewno usunąć tę listę?')" class="d-inline">
|
||||
<input type="hidden" name="single_list_id" value="{{ l.id }}">
|
||||
<button type="submit" class="btn btn-sm btn-outline-light" title="Usuń">🗑️</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% if enriched_lists|length == 0 %}
|
||||
<tr>
|
||||
<td colspan="12" class="text-center py-4">
|
||||
Brak list zakupowych do wyświetlenia
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="d-flex justify-content-end mt-2">
|
||||
<button type="submit" class="btn btn-outline-light btn-sm">🗑️ Usuń zaznaczone listy</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
|
||||
<!-- Modal podglądu produktów -->
|
||||
<div class="modal fade" id="productPreviewModal" tabindex="-1" aria-labelledby="previewModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-lg modal-dialog-scrollable">
|
||||
<div class="modal-content bg-dark text-white">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="previewModalLabel">Podgląd produktów</h5>
|
||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Zamknij"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<ul id="product-list" class="list-group list-group-flush"></ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% block scripts %}
|
||||
<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='preview_list_modal.js') }}"></script>
|
||||
{% endblock %}
|
||||
{% block scripts %}
|
||||
<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='preview_list_modal.js') }}"></script>
|
||||
{% endblock %}
|
||||
|
||||
{% endblock %}
|
||||
{% endblock %}
|
@@ -266,7 +266,7 @@
|
||||
|
||||
{% if not receipts %}
|
||||
<div class="alert alert-info text-center mt-3" role="alert">
|
||||
Brak paragonów.
|
||||
Brak paragonów
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
@@ -53,9 +53,12 @@
|
||||
{% endfor %}
|
||||
{% if items|length == 0 %}
|
||||
<tr>
|
||||
<td colspan="5" class="text-center">Pusta lista produktów.</td>
|
||||
<td colspan="12" class="text-center py-4">
|
||||
Pusta lista produktów
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
@@ -73,6 +73,14 @@
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% if l|length == 0 %}
|
||||
<tr>
|
||||
<td colspan="12" class="text-center py-4">
|
||||
Brak list zakupowych do wyświetlenia
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
@@ -65,7 +65,7 @@
|
||||
|
||||
{% if not receipts %}
|
||||
<div class="alert alert-info text-center mt-4" role="alert">
|
||||
Nie wgrano żadnych paragonów.
|
||||
Nie wgrano żadnego paragonu
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
@@ -70,7 +70,10 @@
|
||||
{% else %}
|
||||
<a href="/admin/demote_user/{{ user.id }}" class="btn btn-sm btn-outline-light">⬇️ Usuń admina</a>
|
||||
{% endif %}
|
||||
<a href="/admin/delete_user/{{ user.id }}" class="btn btn-sm btn-outline-light me-1">🗑️ Usuń</a>
|
||||
<a href="/admin/delete_user/{{ user.id }}" class="btn btn-sm btn-outline-light me-1"
|
||||
onclick="return confirm('Czy na pewno chcesz usunąć użytkownika {{ user.username }}?\n\nWszystkie jego listy zostaną przeniesione na administratora.')">
|
||||
🗑️ Usuń
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
|
@@ -88,8 +88,8 @@
|
||||
|
||||
<!-- Przyciski -->
|
||||
<div class="btn-group mt-4" role="group">
|
||||
<button type="submit" class="btn btn-outline-light">💾 Zapisz</button>
|
||||
<a href="{{ url_for('main_page') }}" class="btn btn-outline-light">❌ Anuluj</a>
|
||||
<button type="submit" class="btn btn-sm btn-outline-light">💾 Zapisz</button>
|
||||
<a href="{{ url_for('main_page') }}" class="btn btn-sm btn-outline-light">❌ Anuluj</a>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
@@ -121,14 +121,14 @@
|
||||
{% endif %}
|
||||
|
||||
<a href="{{ url_for('rotate_receipt_user', receipt_id=r.id) }}"
|
||||
class="btn btn-sm btn-outline-warning w-100 mb-2">🔄 Obróć o 90°</a>
|
||||
class="btn btn-sm btn-outline-light w-100 mb-2">🔄 Obróć o 90°</a>
|
||||
|
||||
<a href="#" class="btn btn-sm btn-outline-secondary w-100 mb-2" data-bs-toggle="modal"
|
||||
<a href="#" class="btn btn-sm btn-outline-light w-100 mb-2" data-bs-toggle="modal"
|
||||
data-bs-target="#userCropModal" data-img-src="{{ url_for('uploaded_file', filename=r.filename) }}"
|
||||
data-receipt-id="{{ r.id }}" data-crop-endpoint="{{ url_for('crop_receipt_user') }}">
|
||||
✂️ Przytnij
|
||||
</a>
|
||||
<a href="{{ url_for('delete_receipt_user', receipt_id=r.id) }}" class="btn btn-sm btn-outline-danger w-100"
|
||||
<a href="{{ url_for('delete_receipt_user', receipt_id=r.id) }}" class="btn btn-sm btn-outline-light w-100"
|
||||
onclick="return confirm('Na pewno usunąć ten paragon?')">🗑️ Usuń</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -140,7 +140,7 @@
|
||||
<hr class="my-3">
|
||||
<!-- Trigger przycisk -->
|
||||
<div class="btn-group mt-4" role="group">
|
||||
<button type="button" class="btn btn-outline-danger" data-bs-toggle="modal" data-bs-target="#deleteModal">
|
||||
<button type="button" class="btn btn-sm btn-outline-danger" data-bs-toggle="modal" data-bs-target="#deleteModal">
|
||||
🗑️ Usuń tę listę
|
||||
</button>
|
||||
</div>
|
||||
|
@@ -123,6 +123,15 @@
|
||||
<td>{{ '%.2f'|format(list.total_expense) }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
|
||||
{% if list|length == 0 %}
|
||||
<tr>
|
||||
<td colspan="12" class="text-center py-4">
|
||||
Brak list zakupowych do wyświetlenia
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
Reference in New Issue
Block a user