diff --git a/app.py b/app.py index ae84c49..1aace7c 100644 --- a/app.py +++ b/app.py @@ -216,7 +216,7 @@ class ShoppingList(db.Model): expires_at = db.Column(db.DateTime(timezone=True), nullable=True) owner = db.relationship("User", backref="lists", lazy=True) is_archived = db.Column(db.Boolean, default=False) - is_public = db.Column(db.Boolean, default=True) + is_public = db.Column(db.Boolean, default=False) # Relacje items = db.relationship("Item", back_populates="shopping_list", lazy="select") @@ -290,6 +290,31 @@ class Receipt(db.Model): uploaded_by_user = db.relationship("User", backref="uploaded_receipts") +class ListPermission(db.Model): + __tablename__ = "list_permission" + id = db.Column(db.Integer, primary_key=True) + list_id = db.Column( + db.Integer, + db.ForeignKey("shopping_list.id", ondelete="CASCADE"), + nullable=False, + ) + user_id = db.Column( + db.Integer, + db.ForeignKey("user.id", ondelete="CASCADE"), + nullable=False, + ) + created_at = db.Column(db.DateTime, default=datetime.utcnow) + __table_args__ = (db.UniqueConstraint("list_id", "user_id", name="uq_list_user"),) + + +ShoppingList.permitted_users = db.relationship( + "User", + secondary="list_permission", + backref=db.backref("permitted_lists", lazy="dynamic"), + lazy="dynamic", +) + + def hash_password(password): pepper = app.config["BCRYPT_PEPPER"] peppered = (password + pepper).encode("utf-8") @@ -443,7 +468,7 @@ def get_total_expense_for_list(list_id, start_date=None, end_date=None): def update_list_categories_from_form(shopping_list, form): - raw_vals = form.getlist("categories") + raw_vals = form.getlist("categories") candidate_ids = set() for v in raw_vals: @@ -539,6 +564,79 @@ def redirect_with_flash( return redirect(url_for(endpoint)) +def can_view_list(sl: ShoppingList) -> bool: + if current_user.is_authenticated: + if sl.owner_id == current_user.id: + return True + if sl.is_public: + return True + return ( + db.session.query(ListPermission.id) + .filter_by(list_id=sl.id, user_id=current_user.id) + .first() + is not None + ) + return bool(sl.is_public) + + +def db_bucket(col, kind: str = "month"): + name = db.engine.name # 'sqlite', 'mysql', 'mariadb', 'postgresql', ... + kind = (kind or "month").lower() + + if kind == "day": + if name == "sqlite": + return func.strftime("%Y-%m-%d", col) + elif name in ("mysql", "mariadb"): + return func.date_format(col, "%Y-%m-%d") + else: + return func.to_char(col, "YYYY-MM-DD") + + if kind == "week": + if name == "sqlite": + return func.printf( + "%s-W%s", func.strftime("%Y", col), func.strftime("%W", col) + ) + elif name in ("mysql", "mariadb"): + return func.date_format(col, "%x-W%v") + else: + return func.to_char(col, 'IYYY-"W"IW') + + if name == "sqlite": + return func.strftime("%Y-%m", col) + elif name in ("mysql", "mariadb"): + return func.date_format(col, "%Y-%m") + else: + return func.to_char(col, "YYYY-MM") + + +def visible_lists_clause_for_expenses(user_id: int, include_shared: bool, now_dt): + perm_subq = user_permission_subq(user_id) + + base = [ + ShoppingList.is_archived == False, + ((ShoppingList.expires_at == None) | (ShoppingList.expires_at > now_dt)), + ] + + if include_shared: + base.append( + or_( + ShoppingList.owner_id == user_id, + ShoppingList.is_public == True, + ShoppingList.id.in_(perm_subq), + ) + ) + else: + base.append(ShoppingList.owner_id == user_id) + + return base + + +def user_permission_subq(user_id): + return db.session.query(ListPermission.list_id).filter( + ListPermission.user_id == user_id + ) + + def admin_required(f): @wraps(f) def decorated_function(*args, **kwargs): @@ -650,113 +748,6 @@ def handle_crop_receipt(receipt_id, file): return {"success": False, "error": str(e)} -def get_total_expenses_grouped_by_list_created_at( - user_only=False, - admin=False, - show_all=False, - range_type="monthly", - start_date=None, - end_date=None, - user_id=None, - category_id=None, -): - lists_query = ShoppingList.query - - if admin: - pass - elif 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 category_id: - if str(category_id) == "none": - lists_query = lists_query.filter(~ShoppingList.categories.any()) - else: - try: - cat_id_int = int(category_id) - except ValueError: - return {"labels": [], "expenses": []} - lists_query = lists_query.join( - shopping_list_category, - shopping_list_category.c.shopping_list_id == ShoppingList.id, - ).filter(shopping_list_category.c.category_id == cat_id_int) - - today = datetime.now(timezone.utc).date() - - if range_type == "last30days": - dt_start = today - timedelta(days=29) - dt_end = today + timedelta(days=1) - start_date, end_date = dt_start.strftime("%Y-%m-%d"), dt_end.strftime( - "%Y-%m-%d" - ) - - elif range_type == "currentmonth": - dt_start = today.replace(day=1) - dt_end = today + timedelta(days=1) - start_date, end_date = dt_start.strftime("%Y-%m-%d"), dt_end.strftime( - "%Y-%m-%d" - ) - - 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") - if dt_end.tzinfo is None: - dt_end = dt_end.replace(tzinfo=timezone.utc) - dt_end += timedelta(days=1) - except Exception: - return {"error": "Błędne daty", "labels": [], "expenses": []} - - lists_query = lists_query.filter( - ShoppingList.created_at >= dt_start, ShoppingList.created_at < dt_end - ) - - lists = lists_query.all() - if not lists: - return {"labels": [], "expenses": []} - - list_ids = [l.id for l in lists] - - total_expenses = ( - db.session.query( - Expense.list_id, func.sum(Expense.amount).label("total_amount") - ) - .filter(Expense.list_id.in_(list_ids)) - .group_by(Expense.list_id) - .all() - ) - - expense_map = {lid: amt for lid, amt in total_expenses} - - grouped = defaultdict(float) - for sl in lists: - if sl.id in expense_map: - ts = sl.created_at or datetime.now(timezone.utc) - if range_type in ("last30days", "currentmonth"): - key = ts.strftime("%Y-%m-%d") # dzienny widok - elif range_type == "monthly": - key = ts.strftime("%Y-%m") - elif range_type == "quarterly": - key = f"{ts.year}-Q{((ts.month - 1) // 3 + 1)}" - elif range_type == "halfyearly": - key = f"{ts.year}-H{1 if ts.month <= 6 else 2}" - elif range_type == "yearly": - key = str(ts.year) - else: - key = ts.strftime("%Y-%m-%d") - grouped[key] += expense_map[sl.id] - - labels = sorted(grouped) - expenses = [round(grouped[l], 2) for l in labels] - return {"labels": labels, "expenses": expenses} - - def recalculate_filesizes(receipt_id: int = None): updated = 0 not_found = 0 @@ -871,84 +862,87 @@ def category_to_color(name): def get_total_expenses_grouped_by_category( show_all, range_type, start_date, end_date, user_id, category_id=None ): - lists_query = ShoppingList.query + now = datetime.now(timezone.utc) + lists_q = ShoppingList.query.filter( + ShoppingList.is_archived == False, + ((ShoppingList.expires_at == None) | (ShoppingList.expires_at > now)), + ) if show_all: - lists_query = lists_query.filter( - or_(ShoppingList.owner_id == user_id, ShoppingList.is_public == True) + perm_subq = user_permission_subq(user_id) + lists_q = lists_q.filter( + or_( + ShoppingList.owner_id == user_id, + ShoppingList.is_public == True, + ShoppingList.id.in_(perm_subq), + ) ) else: - lists_query = lists_query.filter(ShoppingList.owner_id == user_id) + lists_q = lists_q.filter(ShoppingList.owner_id == user_id) if category_id: if str(category_id) == "none": - lists_query = lists_query.filter(~ShoppingList.categories.any()) + lists_q = lists_q.filter(~ShoppingList.categories.any()) else: try: - cat_id_int = int(category_id) - except ValueError: - return {"labels": [], "datasets": []} - lists_query = lists_query.join( - shopping_list_category, - shopping_list_category.c.shopping_list_id == ShoppingList.id, - ).filter(shopping_list_category.c.category_id == cat_id_int) - - if not start_date and not end_date: - today = datetime.now(timezone.utc).date() - if range_type == "last30days": - dt_start = today - timedelta(days=29) - dt_end = today + timedelta(days=1) - start_date = dt_start.strftime("%Y-%m-%d") - end_date = dt_end.strftime("%Y-%m-%d") - elif range_type == "currentmonth": - dt_start = today.replace(day=1) - dt_end = today + timedelta(days=1) - start_date = dt_start.strftime("%Y-%m-%d") - end_date = dt_end.strftime("%Y-%m-%d") + cid = int(category_id) + lists_q = lists_q.join( + shopping_list_category, + shopping_list_category.c.shopping_list_id == ShoppingList.id, + ).filter(shopping_list_category.c.category_id == cid) + except (TypeError, ValueError): + pass 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) + lists_q = lists_q.filter( + ShoppingList.created_at >= dt_start, ShoppingList.created_at < dt_end + ) 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() + lists = lists_q.options(joinedload(ShoppingList.categories)).all() if not lists: return {"labels": [], "datasets": []} + list_ids = [l.id for l in lists] + totals = ( + db.session.query( + Expense.list_id, + func.coalesce(func.sum(Expense.amount), 0).label("total_amount"), + ) + .filter(Expense.list_id.in_(list_ids)) + .group_by(Expense.list_id) + .all() + ) + expense_map = {lid: float(total or 0) for lid, total in totals} + + def bucket_from_dt(ts: datetime) -> str: + if range_type == "daily": + return ts.strftime("%Y-%m-%d") + elif range_type == "weekly": + return f"{ts.isocalendar().year}-W{ts.isocalendar().week:02d}" + elif range_type == "quarterly": + return f"{ts.year}-Q{((ts.month - 1)//3 + 1)}" + elif range_type == "halfyearly": + return f"{ts.year}-H{1 if ts.month <= 6 else 2}" + elif range_type == "yearly": + return str(ts.year) + else: + return ts.strftime("%Y-%m") + 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") - + key = bucket_from_dt(l.created_at) all_labels.add(key) + total_expense = expense_map.get(l.id, 0.0) if str(category_id) == "none": - if not l.categories: - data_map[key]["Bez kategorii"] += total_expense + data_map[key]["Bez kategorii"] += total_expense continue if not l.categories: @@ -960,27 +954,151 @@ def get_total_expenses_grouped_by_category( data_map[key][c.name] += total_expense labels = sorted(all_labels) + cats = sorted({cat for b in data_map.values() for cat, v in b.items() if v > 0}) - categories_with_expenses = sorted( + datasets = [ { - cat - for cat_data in data_map.values() - for cat, value in cat_data.items() - if value > 0 + "label": cat, + "data": [round(data_map[label].get(cat, 0.0), 2) for label in labels], + "backgroundColor": category_to_color(cat), } + for cat in cats + ] + return {"labels": labels, "datasets": datasets} + + +def get_total_expenses_grouped_by_list_created_at( + user_only=False, + admin=False, + show_all=False, + range_type="monthly", + start_date=None, + end_date=None, + user_id=None, + category_id=None, +): + now = datetime.now(timezone.utc) + lists_q = ShoppingList.query.filter( + ShoppingList.is_archived == False, + ((ShoppingList.expires_at == None) | (ShoppingList.expires_at > now)), ) - 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), - } + if admin: + pass + elif user_only: + lists_q = lists_q.filter(ShoppingList.owner_id == user_id) + elif show_all: + perm_subq = user_permission_subq(user_id) + lists_q = lists_q.filter( + or_( + ShoppingList.owner_id == user_id, + ShoppingList.is_public == True, + ShoppingList.id.in_(perm_subq), + ) ) + else: + lists_q = lists_q.filter(ShoppingList.owner_id == user_id) - return {"labels": labels, "datasets": datasets} + # kategorie (bez ucinania „none”) + if category_id: + if str(category_id) == "none": + lists_q = lists_q.filter(~ShoppingList.categories.any()) + else: + try: + cid = int(category_id) + lists_q = lists_q.join( + shopping_list_category, + shopping_list_category.c.shopping_list_id == ShoppingList.id, + ).filter(shopping_list_category.c.category_id == cid) + except (TypeError, ValueError): + pass + + 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) + lists_q = lists_q.filter( + ShoppingList.created_at >= dt_start, ShoppingList.created_at < dt_end + ) + except Exception: + return {"error": "Błędne daty"} + + lists = lists_q.options(joinedload(ShoppingList.categories)).all() + if not lists: + return {"labels": [], "expenses": []} + + list_ids = [l.id for l in lists] + totals = ( + db.session.query( + Expense.list_id, + func.coalesce(func.sum(Expense.amount), 0).label("total_amount"), + ) + .filter(Expense.list_id.in_(list_ids)) + .group_by(Expense.list_id) + .all() + ) + expense_map = {lid: float(total or 0) for lid, total in totals} + + def bucket_from_dt(ts: datetime) -> str: + if range_type == "daily": + return ts.strftime("%Y-%m-%d") + elif range_type == "weekly": + return f"{ts.isocalendar().year}-W{ts.isocalendar().week:02d}" + elif range_type == "quarterly": + return f"{ts.year}-Q{((ts.month - 1)//3 + 1)}" + elif range_type == "halfyearly": + return f"{ts.year}-H{1 if ts.month <= 6 else 2}" + elif range_type == "yearly": + return str(ts.year) + else: + return ts.strftime("%Y-%m") + + grouped = defaultdict(float) + for sl in lists: + grouped[bucket_from_dt(sl.created_at)] += expense_map.get(sl.id, 0.0) + + labels = sorted(grouped.keys()) + expenses = [round(grouped[l], 2) for l in labels] + return {"labels": labels, "expenses": expenses} + + +def resolve_range(range_type: str): + now = datetime.now(timezone.utc) + sd = ed = None + bucket = "monthly" + + rt = (range_type or "").lower() + if rt in ("last7days", "last_7_days"): + sd = (now - timedelta(days=7)).date().strftime("%Y-%m-%d") + ed = now.date().strftime("%Y-%m-%d") + bucket = "daily" + elif rt in ("last30days", "last_30_days"): + sd = (now - timedelta(days=30)).date().strftime("%Y-%m-%d") + ed = now.date().strftime("%Y-%m-%d") + bucket = "monthly" + elif rt in ("last90days", "last_90_days"): + sd = (now - timedelta(days=90)).date().strftime("%Y-%m-%d") + ed = now.date().strftime("%Y-%m-%d") + bucket = "monthly" + elif rt in ("thismonth", "this_month"): + first = datetime(now.year, now.month, 1, tzinfo=timezone.utc) + sd = first.date().strftime("%Y-%m-%d") + ed = now.date().strftime("%Y-%m-%d") + bucket = "monthly" + elif rt in ( + "currentmonth", + "thismonth", + "this_month", + "monthtodate", + "month_to_date", + "mtd", + ): + first = datetime(now.year, now.month, 1, tzinfo=timezone.utc) + sd = first.date().strftime("%Y-%m-%d") + ed = now.date().strftime("%Y-%m-%d") + bucket = "monthly" + + return sd, ed, bucket def save_pdf_as_webp(file, path): @@ -1392,21 +1510,23 @@ def favicon(): @app.route("/") def main_page(): + perm_subq = ( + user_permission_subq(current_user.id) if current_user.is_authenticated else None + ) + now = datetime.now(timezone.utc) month_param = request.args.get("m", None) start = end = None if month_param in (None, ""): - # brak wyboru -> domyślnie aktualny miesiąc + # domyślnie: bieżący miesiąc month_str = now.strftime("%Y-%m") start = datetime(now.year, now.month, 1, tzinfo=timezone.utc) end = (start + timedelta(days=31)).replace(day=1) - elif month_param == "all": month_str = "all" start = end = None - else: month_str = month_param try: @@ -1414,16 +1534,10 @@ def main_page(): start = datetime(year, month, 1, tzinfo=timezone.utc) end = (start + timedelta(days=31)).replace(day=1) except ValueError: + # jeśli m ma zły format – pokaż wszystko + month_str = "all" start = end = None - # dalej normalnie używasz date_filter: - def date_filter(query): - if start and end: - query = query.filter( - ShoppingList.created_at >= start, ShoppingList.created_at < end - ) - return query - def date_filter(query): if start and end: query = query.filter( @@ -1434,10 +1548,10 @@ def main_page(): if current_user.is_authenticated: user_lists = ( date_filter( - ShoppingList.query.filter_by( - owner_id=current_user.id, is_archived=False - ).filter( - (ShoppingList.expires_at == None) | (ShoppingList.expires_at > now) + ShoppingList.query.filter( + ShoppingList.owner_id == current_user.id, + ShoppingList.is_archived == False, + (ShoppingList.expires_at == None) | (ShoppingList.expires_at > now), ) ) .order_by(ShoppingList.created_at.desc()) @@ -1450,21 +1564,23 @@ def main_page(): .all() ) + # publiczne cudze + udzielone mi (po list_permission) public_lists = ( date_filter( ShoppingList.query.filter( - ShoppingList.is_public == True, ShoppingList.owner_id != current_user.id, - ( - (ShoppingList.expires_at == None) - | (ShoppingList.expires_at > now) - ), ShoppingList.is_archived == False, + (ShoppingList.expires_at == None) | (ShoppingList.expires_at > now), + or_( + ShoppingList.is_public == True, + ShoppingList.id.in_(perm_subq), + ), ) ) .order_by(ShoppingList.created_at.desc()) .all() ) + accessible_lists = public_lists # alias do szablonu: publiczne + udostępnione else: user_lists = [] archived_lists = [] @@ -1472,31 +1588,31 @@ def main_page(): date_filter( ShoppingList.query.filter( ShoppingList.is_public == True, - ( - (ShoppingList.expires_at == None) - | (ShoppingList.expires_at > now) - ), + (ShoppingList.expires_at == None) | (ShoppingList.expires_at > now), ShoppingList.is_archived == False, ) ) .order_by(ShoppingList.created_at.desc()) .all() ) + accessible_lists = public_lists # dla gościa = tylko publiczne - # Definiujemy widoczny zakres list dla tego użytkownika + # Zakres miesięcy do selektora if current_user.is_authenticated: visible_lists_query = ShoppingList.query.filter( or_( - ShoppingList.owner_id == current_user.id, ShoppingList.is_public == True + ShoppingList.owner_id == current_user.id, + ShoppingList.is_public == True, + ShoppingList.id.in_(perm_subq), ) ) else: visible_lists_query = ShoppingList.query.filter(ShoppingList.is_public == True) - # Teraz możemy bezpiecznie pobrać miesiące month_options = get_active_months_query(visible_lists_query) - all_lists = user_lists + public_lists + archived_lists + # Statystyki dla wszystkich widocznych sekcji + all_lists = user_lists + accessible_lists + archived_lists all_ids = [l.id for l in all_lists] if all_ids: @@ -1515,7 +1631,6 @@ def main_page(): .group_by(Item.list_id) .all() ) - stats_map = { s.list_id: ( s.total_count or 0, @@ -1542,7 +1657,6 @@ def main_page(): l.purchased_count = purchased_count l.not_purchased_count = not_purchased_count l.total_expense = latest_expenses_map.get(l.id, 0) - l.category_badges = [ {"name": c.name, "color": category_to_color(c.name)} for c in l.categories @@ -1559,6 +1673,7 @@ def main_page(): "main.html", user_lists=user_lists, public_lists=public_lists, + accessible_lists=accessible_lists, archived_lists=archived_lists, now=now, timedelta=timedelta, @@ -1627,13 +1742,52 @@ def edit_my_list(list_id): next_page = request.args.get("next") or request.referrer if request.method == "POST": + grant_username = (request.form.get("grant_username") or "").strip().lower() + revoke_user_id = request.form.get("revoke_user_id") + + if grant_username: + u = User.query.filter(func.lower(User.username) == grant_username).first() + if not u: + flash("Użytkownik nie istnieje.", "danger") + return redirect(next_page or request.url) + if u.id == current_user.id: + flash("Jesteś właścicielem tej listy.", "info") + return redirect(next_page or request.url) + + exists = ( + db.session.query(ListPermission.id) + .filter( + ListPermission.list_id == l.id, + ListPermission.user_id == u.id, + ) + .first() + ) + if not exists: + db.session.add(ListPermission(list_id=l.id, user_id=u.id)) + db.session.commit() + flash(f"Nadano dostęp użytkownikowi „{u.username}”.", "success") + else: + flash("Ten użytkownik już ma dostęp.", "info") + return redirect(next_page or request.url) + + if revoke_user_id: + try: + uid = int(revoke_user_id) + except ValueError: + flash("Błędny identyfikator użytkownika.", "danger") + return redirect(next_page or request.url) + + ListPermission.query.filter_by(list_id=l.id, user_id=uid).delete() + db.session.commit() + flash("Odebrano dostęp użytkownikowi.", "success") + return redirect(next_page or request.url) + if "unarchive" in request.form: l.is_archived = False db.session.commit() flash(f"Lista „{l.title}” została przywrócona.", "success") - return redirect(next_page or url_for("main_page")) + return redirect(next_page or request.url) - # Pełna edycja formularza move_to_month = request.form.get("move_to_month") if move_to_month: try: @@ -1645,12 +1799,12 @@ def edit_my_list(list_id): f"Zmieniono datę utworzenia listy na {new_created_at.strftime('%Y-%m-%d')}", "success", ) - return redirect(next_page or url_for("main_page")) + return redirect(next_page or request.url) except ValueError: flash("Nieprawidłowy format miesiąca", "danger") - return redirect(next_page or url_for("main_page")) + return redirect(next_page or request.url) - new_title = request.form.get("title", "").strip() + new_title = (request.form.get("title") or "").strip() is_public = "is_public" in request.form is_temporary = "is_temporary" in request.form is_archived = "is_archived" in request.form @@ -1659,7 +1813,7 @@ def edit_my_list(list_id): if not new_title: flash("Podaj poprawny tytuł", "danger") - return redirect(next_page or url_for("main_page")) + return redirect(next_page or request.url) l.title = new_title l.is_public = is_public @@ -1673,14 +1827,22 @@ def edit_my_list(list_id): l.expires_at = expires_dt.replace(tzinfo=timezone.utc) except ValueError: flash("Błędna data lub godzina wygasania", "danger") - return redirect(next_page or url_for("main_page")) + return redirect(next_page or request.url) else: l.expires_at = None update_list_categories_from_form(l, request.form) db.session.commit() flash("Zaktualizowano dane listy", "success") - return redirect(next_page or url_for("main_page")) + return redirect(next_page or request.url) + + permitted_users = ( + db.session.query(User) + .join(ListPermission, ListPermission.user_id == User.id) + .where(ListPermission.list_id == l.id) + .order_by(User.username.asc()) + .all() + ) return render_template( "edit_my_list.html", @@ -1688,6 +1850,7 @@ def edit_my_list(list_id): receipts=receipts, categories=categories, selected_categories=selected_categories_ids, + permitted_users=permitted_users, ) @@ -1846,9 +2009,73 @@ def view_list(list_id): def expenses(): start_date_str = request.args.get("start_date") end_date_str = request.args.get("end_date") - category_id = request.args.get("category_id", type=int) + category_id = request.args.get("category_id", type=str) show_all = request.args.get("show_all", "true").lower() == "true" + now = datetime.now(timezone.utc) + + visible_clause = visible_lists_clause_for_expenses( + user_id=current_user.id, include_shared=show_all, now_dt=now + ) + + lists_q = ShoppingList.query.filter(*visible_clause) + + if start_date_str and end_date_str: + try: + start = datetime.strptime(start_date_str, "%Y-%m-%d") + end = datetime.strptime(end_date_str, "%Y-%m-%d") + timedelta(days=1) + lists_q = lists_q.filter( + ShoppingList.created_at >= start, + ShoppingList.created_at < end, + ) + except ValueError: + flash("Błędny zakres dat", "danger") + + if category_id: + if category_id == "none": + lists_q = lists_q.filter(~ShoppingList.categories.any()) + else: + try: + cid = int(category_id) + lists_q = lists_q.join( + shopping_list_category, + shopping_list_category.c.shopping_list_id == ShoppingList.id, + ).filter(shopping_list_category.c.category_id == cid) + except (TypeError, ValueError): + pass + + lists_filtered = ( + lists_q.options( + joinedload(ShoppingList.owner), joinedload(ShoppingList.categories) + ) + .order_by(ShoppingList.created_at.desc()) + .all() + ) + list_ids = [l.id for l in lists_filtered] or [-1] + + expenses = ( + Expense.query.options( + joinedload(Expense.shopping_list).joinedload(ShoppingList.owner), + joinedload(Expense.shopping_list).joinedload(ShoppingList.categories), + ) + .filter(Expense.list_id.in_(list_ids)) + .order_by(Expense.added_at.desc()) + .all() + ) + + totals_rows = ( + db.session.query( + ShoppingList.id.label("lid"), + func.coalesce(func.sum(Expense.amount), 0).label("total_expense"), + ) + .select_from(ShoppingList) + .filter(ShoppingList.id.in_(list_ids)) + .outerjoin(Expense, Expense.list_id == ShoppingList.id) + .group_by(ShoppingList.id) + .all() + ) + totals_map = {row.lid: float(row.total_expense or 0) for row in totals_rows} + categories = ( Category.query.join( shopping_list_category, shopping_list_category.c.category_id == Category.id @@ -1856,79 +2083,16 @@ def expenses(): .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 - ), - ) - ) + .filter(ShoppingList.id.in_(list_ids)) .distinct() .order_by(Category.name.asc()) .all() ) - categories.append(SimpleNamespace(id="none", name="Bez kategorii")) - start = None - end = None - - expenses_query = Expense.query.options( - joinedload(Expense.shopping_list).joinedload(ShoppingList.owner), - joinedload(Expense.shopping_list).joinedload(ShoppingList.expenses), - joinedload(Expense.shopping_list).joinedload(ShoppingList.categories), - ).join(ShoppingList, Expense.list_id == ShoppingList.id) - - if not show_all: - expenses_query = expenses_query.filter(ShoppingList.owner_id == current_user.id) - else: - expenses_query = expenses_query.filter( - or_( - ShoppingList.owner_id == current_user.id, ShoppingList.is_public == True - ) - ) - - if category_id: - if str(category_id) == "none": # Bez kategorii - lists_query = lists_query.filter(~ShoppingList.categories.any()) - else: - lists_query = lists_query.join( - shopping_list_category, - 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: - try: - start = datetime.strptime(start_date_str, "%Y-%m-%d") - end = datetime.strptime(end_date_str, "%Y-%m-%d") + timedelta(days=1) - expenses_query = expenses_query.filter( - Expense.added_at >= start, Expense.added_at < end - ) - except ValueError: - flash("Błędny zakres dat", "danger") - - expenses = expenses_query.order_by(Expense.added_at.desc()).all() - - list_ids = {e.list_id for e in expenses} - totals_map = {} - if list_ids: - totals = ( - db.session.query( - Expense.list_id, func.sum(Expense.amount).label("total_expense") - ) - .filter(Expense.list_id.in_(list_ids)) - .group_by(Expense.list_id) - .all() - ) - totals_map = {t.list_id: t.total_expense or 0 for t in totals} - expense_table = [ { - "title": e.shopping_list.title if e.shopping_list else "Nieznana", + "title": (e.shopping_list.title if e.shopping_list else "Nieznana"), "amount": e.amount, "added_at": e.added_at, } @@ -1940,11 +2104,11 @@ def expenses(): "id": l.id, "title": l.title, "created_at": l.created_at, - "total_expense": totals_map.get(l.id, 0), + "total_expense": totals_map.get(l.id, 0.0), "owner_username": l.owner.username if l.owner else "?", "categories": [c.id for c in l.categories], } - for l in {e.shopping_list for e in expenses if e.shopping_list} + for l in lists_filtered ] return render_template( @@ -1967,6 +2131,13 @@ def expenses_data(): category_id = request.args.get("category_id") by_category = request.args.get("by_category", "false").lower() == "true" + if not start_date or not end_date: + sd, ed, bucket = resolve_range(range_type) + if sd and ed: + start_date = sd + end_date = ed + range_type = bucket + if by_category: result = get_total_expenses_grouped_by_category( show_all=show_all, @@ -1978,7 +2149,7 @@ def expenses_data(): ) else: result = get_total_expenses_grouped_by_list_created_at( - user_only=True, + user_only=False, admin=False, show_all=show_all, range_type=range_type, @@ -1994,16 +2165,45 @@ def expenses_data(): @app.route("/share/") -@app.route("/guest-list/") +# @app.route("/guest-list/") +@app.route("/shared/") def shared_list(token=None, list_id=None): + now = datetime.now(timezone.utc) + if token: shopping_list = ShoppingList.query.filter_by(share_token=token).first_or_404() - if not check_list_public(shopping_list): + # jeśli lista wygasła – zablokuj (spójne z resztą aplikacji) + if ( + shopping_list.is_temporary + and shopping_list.expires_at + and shopping_list.expires_at <= now + ): + flash("Link wygasł.", "warning") return redirect(url_for("main_page")) + # >>> KLUCZOWE: pozwól wejść nawet, gdy niepubliczna (bez check_list_public) list_id = shopping_list.id + # >>> Jeśli zalogowany i nie jest właścicielem — auto-przypisz stałe uprawnienie + if current_user.is_authenticated and current_user.id != shopping_list.owner_id: + # dodaj wpis tylko jeśli go nie ma + exists = ( + db.session.query(ListPermission.id) + .filter( + ListPermission.list_id == shopping_list.id, + ListPermission.user_id == current_user.id, + ) + .first() + ) + if not exists: + db.session.add( + ListPermission(list_id=shopping_list.id, user_id=current_user.id) + ) + db.session.commit() + else: + shopping_list = ShoppingList.query.get_or_404(list_id) + total_expense = get_total_expense_for_list(list_id) shopping_list, items, receipts, expenses, total_expense = get_list_details(list_id) @@ -2647,14 +2847,27 @@ def delete_user(user_id): return redirect(url_for("list_users")) -@app.route("/admin/receipts/") +@app.route("/admin/receipts", methods=["GET"]) +@app.route("/admin/receipts/", methods=["GET"]) @login_required @admin_required -def admin_receipts(id): +def admin_receipts(list_id=None): try: page, per_page = get_page_args(default_per_page=24, max_per_page=200) - if id == "all": + if list_id is not None: + all_receipts = ( + Receipt.query.options(joinedload(Receipt.uploaded_by_user)) + .filter_by(list_id=list_id) + .order_by(Receipt.uploaded_at.desc()) + .all() + ) + receipts_paginated, total_items, total_pages = paginate_items( + all_receipts, page, per_page + ) + orphan_files = [] + id = list_id + else: all_filenames = { r.filename for r in Receipt.query.with_entities(Receipt.filename).all() } @@ -2667,6 +2880,7 @@ def admin_receipts(id): receipts_paginated = pagination.items total_pages = pagination.pages + id = "all" upload_folder = app.config["UPLOAD_FOLDER"] files_on_disk = set(os.listdir(upload_folder)) @@ -2677,25 +2891,12 @@ def admin_receipts(id): and f not in all_filenames and f.startswith("list_") ] - else: - list_id = int(id) - all_receipts = ( - Receipt.query.options(joinedload(Receipt.uploaded_by_user)) - .filter_by(list_id=list_id) - .order_by(Receipt.uploaded_at.desc()) - .all() - ) - receipts_paginated, total_items, total_pages = paginate_items( - all_receipts, page, per_page - ) - orphan_files = [] except ValueError: flash("Nieprawidłowe ID listy.", "danger") return redirect(url_for("admin_panel")) total_filesize = db.session.query(func.sum(Receipt.filesize)).scalar() or 0 - page_filesize = sum(r.filesize or 0 for r in receipts_paginated) query_string = urlencode({k: v for k, v in request.args.items() if k != "page"}) @@ -2833,7 +3034,7 @@ def admin_delete_list(): db.session.delete(lst) db.session.commit() - flash(f"Usunięto {len(ids)} list(y)", "success") + flash(f"Usunięto {len(ids)} list(e/y)", "success") return redirect(request.referrer or url_for("admin_panel")) @@ -2852,6 +3053,13 @@ def edit_list(list_id): joinedload(ShoppingList.categories), ], ) + permitted_users = ( + db.session.query(User) + .join(ListPermission, ListPermission.user_id == User.id) + .filter(ListPermission.list_id == shopping_list.id) + .order_by(User.username.asc()) + .all() + ) if shopping_list is None: abort(404) @@ -3029,6 +3237,7 @@ def edit_list(list_id): receipts=receipts, categories=categories, selected_categories=selected_categories_ids, + permitted_users=permitted_users, ) @@ -3309,6 +3518,118 @@ def add_suggestion(): return redirect(url_for("list_products")) +@app.route("/admin/lists-access", methods=["GET", "POST"]) +@app.route("/admin/lists-access/", methods=["GET", "POST"]) +@login_required +@admin_required +def admin_lists_access(list_id=None): + try: + page = int(request.args.get("page", 1)) + except ValueError: + page = 1 + try: + per_page = int(request.args.get("per_page", 25)) + except ValueError: + per_page = 25 + per_page = max(1, min(100, per_page)) + + q = ShoppingList.query.options(db.joinedload(ShoppingList.owner)).order_by( + ShoppingList.created_at.desc() + ) + + if list_id is not None: + target_list = db.session.get(ShoppingList, list_id) + if not target_list: + flash("Lista nie istnieje.", "danger") + return redirect(url_for("admin_lists_access")) + lists = [target_list] + list_ids = [list_id] + pagination = None + else: + pagination = q.paginate(page=page, per_page=per_page, error_out=False) + lists = pagination.items + list_ids = [l.id for l in lists] + + if request.method == "POST": + action = request.form.get("action") + target_list_id = request.form.get("target_list_id", type=int) + + if action == "grant" and target_list_id: + login = (request.form.get("grant_username") or "").strip().lower() + l = db.session.get(ShoppingList, target_list_id) + if not l: + flash("Lista nie istnieje.", "danger") + return redirect(request.url) + u = User.query.filter(func.lower(User.username) == login).first() + if not u: + flash("Użytkownik nie istnieje.", "danger") + return redirect(request.url) + exists = ( + db.session.query(ListPermission.id) + .filter(ListPermission.list_id == l.id, ListPermission.user_id == u.id) + .first() + ) + if not exists: + db.session.add(ListPermission(list_id=l.id, user_id=u.id)) + db.session.commit() + flash(f"Nadano dostęp „{u.username}” do listy #{l.id}.", "success") + else: + flash("Ten użytkownik już ma dostęp.", "info") + return redirect(request.url) + + if action == "revoke" and target_list_id: + uid = request.form.get("revoke_user_id", type=int) + if uid: + ListPermission.query.filter_by( + list_id=target_list_id, user_id=uid + ).delete() + db.session.commit() + flash("Odebrano dostęp użytkownikowi.", "success") + return redirect(request.url) + + if action == "save_changes": + ids = request.form.getlist("visible_ids", type=int) + if ids: + lists_edit = ShoppingList.query.filter(ShoppingList.id.in_(ids)).all() + posted = request.form + for l in lists_edit: + l.is_public = posted.get(f"is_public_{l.id}") is not None + l.is_temporary = posted.get(f"is_temporary_{l.id}") is not None + l.is_archived = posted.get(f"is_archived_{l.id}") is not None + db.session.commit() + flash("Zapisano zmiany statusów.", "success") + return redirect(request.url) + + perms = ( + db.session.query( + ListPermission.list_id, + User.id.label("uid"), + User.username.label("uname"), + ) + .join(User, User.id == ListPermission.user_id) + .filter(ListPermission.list_id.in_(list_ids)) + .order_by(User.username.asc()) + .all() + ) + + permitted_by_list = {lid: [] for lid in list_ids} + for lid, uid, uname in perms: + permitted_by_list[lid].append({"id": uid, "username": uname}) + + query_string = f"per_page={per_page}" + + return render_template( + "admin/admin_lists_access.html", + lists=lists, + permitted_by_list=permitted_by_list, + page=page, + per_page=per_page, + total_pages=pagination.pages if pagination else 1, + query_string=query_string, + list_id=list_id, + ) + + @app.route("/healthcheck") def healthcheck(): header_token = request.headers.get("X-Internal-Check") diff --git a/static/js/functions.js b/static/js/functions.js index 244499e..ef89d4b 100644 --- a/static/js/functions.js +++ b/static/js/functions.js @@ -224,17 +224,17 @@ function toggleVisibility(listId) { const copyBtn = document.getElementById('copyBtn'); const toggleBtn = document.getElementById('toggleVisibilityBtn'); + // URL zawsze widoczny i aktywny + shareUrlSpan.style.display = 'inline'; + shareUrlSpan.textContent = data.share_url; + copyBtn.disabled = false; + if (data.is_public) { - shareHeader.textContent = '🔗 Udostępnij link:'; - shareUrlSpan.style.display = 'inline'; - shareUrlSpan.textContent = data.share_url; - copyBtn.disabled = false; + shareHeader.textContent = '🔗 Udostępnij link (lista publiczna)'; toggleBtn.innerHTML = '🙈 Ukryj listę'; } else { - shareHeader.textContent = '🙈 Lista jest ukryta. Link udostępniania nie zadziała!'; - shareUrlSpan.style.display = 'none'; - copyBtn.disabled = true; - toggleBtn.innerHTML = '👁️ Udostępnij ponownie'; + shareHeader.textContent = '🔗 Udostępnij link (widoczna tylko przez link / uprawnienia)'; + toggleBtn.innerHTML = '🐵 Uczyń publiczną'; } }); } diff --git a/templates/admin/admin_lists_access.html b/templates/admin/admin_lists_access.html new file mode 100644 index 0000000..89fb241 --- /dev/null +++ b/templates/admin/admin_lists_access.html @@ -0,0 +1,179 @@ +{% extends 'base.html' %} +{% block title %}Zarządzanie dostępem do list{% endblock %} + +{% block content %} +
+

🔐{% if list_id %} Zarządzanie dostępem listy #{{ list_id }}{% else %} Zarządzanie dostępem do list {% endif %}

+
+ {% if list_id %} + Powrót do wszystkich list + {% endif %} + ← Powrót do panelu +
+
+ +
+
+
+ + +
+ + + + + + + + + + + + + + {% for l in lists %} + + + + + + + + + + + + + + + + {% endfor %} + {% if lists|length == 0 %} + + + + {% endif %} + +
IDNazwa listyWłaścicielUtworzonoStatusyUdostępnianieUprawnienia
+ {{ l.id }} + + + {{ l.title }} + + {% if l.owner %} + 👤 {{ l.owner.username }} ({{ l.owner.id }}) + {% else %}-{% endif %} + {{ l.created_at.strftime('%Y-%m-%d %H:%M') if l.created_at else '-' }} +
+ + +
+
+ + +
+
+ + +
+
+ {% if l.share_token %} + {% set share_url = url_for('shared_list', token=l.share_token, _external=True) %} +
+
+ {{ share_url }} +
+
+
+ {% if l.is_public %}Lista widoczna publicznie{% else %}Lista dostępna przez + link/uprawnienia{% + endif %} +
+ {% else %} +
Brak tokenu
+ {% endif %} +
+
    + {% for u in permitted_by_list.get(l.id, []) %} +
  • +
    + @{{ u.username }} +
    + + + + + + +
  • + {% endfor %} + {% if permitted_by_list.get(l.id, [])|length == 0 %} +
  • +
    Brak dodanych uprawnień.
    +
  • + {% endif %} +
+ + +
+ + +
+ + +
+
+ +
Brak list do wyświetlenia
+
+ +
+ +
+ +
+
+ +{% if not list_id %} +
+
+
+ + + +
+ + +
+{% endif %} + +{% endblock %} \ No newline at end of file diff --git a/templates/admin/admin_panel.html b/templates/admin/admin_panel.html index 30791e7..d41f051 100644 --- a/templates/admin/admin_panel.html +++ b/templates/admin/admin_panel.html @@ -11,9 +11,10 @@ @@ -217,7 +218,7 @@ — {{ month_str|replace('-', ' / ') }} {% endif %} -
+
@@ -299,11 +300,6 @@ title="Podgląd produktów"> 👁️ - - - - diff --git a/templates/admin/edit_list.html b/templates/admin/edit_list.html index d4d63b1..48cb8c6 100644 --- a/templates/admin/edit_list.html +++ b/templates/admin/edit_list.html @@ -117,6 +117,32 @@ value="{{ request.url_root }}share/{{ list.share_token }}"> + + +
+
🔐 Użytkownicy z dostępem
+ + + ⚙️ Edytuj uprawnienia + + + {% if permitted_users %} +
    + {% for u in permitted_users %} +
  • +
    + @{{ u.username }} +
    +
  • + {% endfor %} +
+ {% else %} +
Brak dodatkowych uprawnień.
+ {% endif %} +
+ diff --git a/templates/admin/receipts.html b/templates/admin/receipts.html index 481c34f..a90f165 100644 --- a/templates/admin/receipts.html +++ b/templates/admin/receipts.html @@ -20,21 +20,28 @@ {{ (page_filesize / 1024) | round(1) }} kB {% endif %} - | - Łącznie: - - {% if total_filesize >= 1024*1024 %} - {{ (total_filesize / 1024 / 1024) | round(2) }} MB - {% else %} - {{ (total_filesize / 1024) | round(1) }} kB + {% if not (id != 'all' and (id|string).isdigit()) %} + | Łącznie: + + {% if total_filesize >= 1024*1024 %} + {{ (total_filesize / 1024 / 1024) | round(2) }} MB + {% else %} + {{ (total_filesize / 1024) | round(1) }} kB + {% endif %} + {% endif %} -

- - Przelicz rozmiary plików - + {% if id is string and id.isdigit() and id|int > 0 %} + + Pokaż wszystkie paragony + + {% else %} + + Przelicz rozmiary plików + + {% endif %} ← Powrót do panelu
@@ -118,8 +125,8 @@ +{% if id == 'all' %}
-
@@ -149,7 +156,7 @@
- +{% endif %} {% if orphan_files and request.path.endswith('/all') %}
diff --git a/templates/edit_my_list.html b/templates/edit_my_list.html index 88525c6..e08aabd 100644 --- a/templates/edit_my_list.html +++ b/templates/edit_my_list.html @@ -24,13 +24,13 @@
- +
- +
@@ -85,17 +85,81 @@ {% endfor %}
-
❌ Anuluj
- + +
+
+
🔐 Dostęp do listy
+ + +
+ + + {% if list.share_token %} +
+ + Otwórz + +
+ {% else %} +
Brak tokenu udostępniania.
+ {% endif %} +
Ustawienie „🌐 Publiczna” nie jest wymagane dla dostępu z linku.
+
+ +
+
+
+ + +
+
+ +
+ + + + +
+ + + +
+ + {% if permitted_users and permitted_users|length > 0 %} +
    + {% for u in permitted_users %} +
  • +
    + @{{ u.username }} +
    +
    + + + +
  • + {% endfor %} +
+ {% else %}
+
Brak dodanych uprawnień.
+ {% endif %} +
+
+
+ {% if receipts %}
Paragony przypisane do tej listy
diff --git a/templates/expenses.html b/templates/expenses.html index 1de5e43..c5e5cd1 100644 --- a/templates/expenses.html +++ b/templates/expenses.html @@ -13,7 +13,7 @@
diff --git a/templates/list.html b/templates/list.html index 216475c..6e80d0f 100644 --- a/templates/list.html +++ b/templates/list.html @@ -35,30 +35,33 @@
{% if list.is_public %} - 🔗 Udostępnij link: + 🔗 Udostępnij link (lista publiczna) {% else %} - 🙈 Lista jest ukryta przed gośćmi + 🔗 Udostępnij link (widoczna przez link / uprawnienia) {% endif %} - + {{ request.url_root }}share/{{ list.share_token }}
+ + + ➕ Nadaj dostęp +
diff --git a/templates/main.html b/templates/main.html index f8b126b..1c7b1e2 100644 --- a/templates/main.html +++ b/templates/main.html @@ -63,7 +63,7 @@ Twoje listy {% if user_lists %} @@ -87,17 +87,17 @@ @@ -135,21 +135,21 @@ {% endif %} {% endif %} -

Publiczne listy innych użytkowników

-{% if public_lists %} +

Udostępnione i publiczne listy innych użytkowników

+{% set lists_to_show = accessible_lists %} +{% if lists_to_show %}
    - {% for l in public_lists %} + {% for l in lists_to_show %} {% set purchased_count = l.purchased_count %} {% set total_count = l.total_count %} {% set percent = (purchased_count / total_count * 100) if total_count > 0 else 0 %}
  • - {{ l.title }} (Autor: {{ l.owner.username }}) + {{ l.title }} (Autor: {{ l.owner.username if l.owner else '—' }}) {% for cat in l.category_badges %} + font-size: 0.56rem; opacity: 0.85;"> {{ cat.name }} {% endfor %} @@ -158,37 +158,31 @@ ✏️ Odznaczaj
    +
    - {# Kupione #}
    - {# Niekupione #} {% set not_purchased_count = l.not_purchased_count if l.total_count else 0 %}
    - {# Pozostałe #}
    - + Produkty: {{ purchased_count }}/{{ total_count }} ({{ percent|round(0) }}%) - {% if l.total_expense > 0 %} - — 💸 {{ '%.2f'|format(l.total_expense) }} PLN - {% endif %} + {% if l.total_expense > 0 %} — 💸 {{ '%.2f'|format(l.total_expense) }} PLN{% endif %}
    -
  • {% endfor %}
{% else %} -

Brak dostępnych list publicznych do wyświetlenia

+

Brak list do wyświetlenia

{% endif %}