diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml index 8257b78..ba505b0 100644 --- a/.github/workflows/python-app.yml +++ b/.github/workflows/python-app.yml @@ -13,7 +13,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.9", "3.10", "3.11", "3.12"] + python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] steps: - uses: actions/checkout@v3 - name: Set up Python ${{ matrix.python-version }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 99565bc..3c5aac0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,16 @@ All notable changes to ExaFS will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.1.8] - 2025-10-20 + +### Fixed +- check all for group edit / delete + +### Added +- pagination for Expired and All Dashboard cards +- search form quick clear by button or ESC key +- improved search to work with paginated data + ## [1.1.7] - 2025-10-16 ### Fixed diff --git a/config.example.py b/config.example.py index d5994e2..6831062 100644 --- a/config.example.py +++ b/config.example.py @@ -3,6 +3,8 @@ class Config: Default config options """ + # Locale for Babel + BABEL_DEFAULT_LOCALE = "en_US_POSIX" # Limits FLOWSPEC4_MAX_RULES = 9000 FLOWSPEC6_MAX_RULES = 9000 diff --git a/flowapp/__about__.py b/flowapp/__about__.py index b12417c..23581c2 100755 --- a/flowapp/__about__.py +++ b/flowapp/__about__.py @@ -1,4 +1,4 @@ -__version__ = "1.1.7" +__version__ = "1.1.8" __title__ = "ExaFS" __description__ = "Tool for creation, validation, and execution of ExaBGP messages." __author__ = "CESNET / Jiri Vrany, Petr Adamec, Josef Verich, Jakub Man" diff --git a/flowapp/models/utils.py b/flowapp/models/utils.py index a5e38aa..8270173 100644 --- a/flowapp/models/utils.py +++ b/flowapp/models/utils.py @@ -290,69 +290,135 @@ def get_existing_community(name=None): return community.id if hasattr(community, "id") else None -def get_ip_rules(rule_type, rule_state, sort="expires", order="desc"): - """ - Returns list of rules sorted by sort column ordered asc or desc - :param sort: sorting column - :param order: asc or desc - :return: list - """ +def _get_flowspec4_rules(rule_state, sort="expires", order="desc", page=1, per_page=50, paginate=False): + """Get Flowspec4 rules with optional pagination""" today = datetime.now() comp_func = utils.get_comp_func(rule_state) - if rule_type == "ipv4": - sorter_ip4 = getattr(Flowspec4, sort, Flowspec4.id) - sorting_ip4 = getattr(sorter_ip4, order) - if comp_func: - rules4 = ( - db.session.query(Flowspec4).filter(comp_func(Flowspec4.expires, today)).order_by(sorting_ip4()).all() - ) - else: - rules4 = db.session.query(Flowspec4).order_by(sorting_ip4()).all() + sorter = getattr(Flowspec4, sort, Flowspec4.id) + sorting = getattr(sorter, order) - return rules4 + query = db.session.query(Flowspec4) - if rule_type == "ipv6": - sorter_ip6 = getattr(Flowspec6, sort, Flowspec6.id) - sorting_ip6 = getattr(sorter_ip6, order) - if comp_func: - rules6 = ( - db.session.query(Flowspec6).filter(comp_func(Flowspec6.expires, today)).order_by(sorting_ip6()).all() - ) - else: - rules6 = db.session.query(Flowspec6).order_by(sorting_ip6()).all() + if comp_func: + query = query.filter(comp_func(Flowspec4.expires, today)) - return rules6 + query = query.order_by(sorting()) - if rule_type == "rtbh": - sorter_rtbh = getattr(RTBH, sort, RTBH.id) - sorting_rtbh = getattr(sorter_rtbh, order) + if paginate: + pagination = query.paginate(page=page, per_page=per_page, error_out=False, max_per_page=500) + return pagination.items, pagination + else: + return query.all() - if comp_func: - rules_rtbh = db.session.query(RTBH).filter(comp_func(RTBH.expires, today)).order_by(sorting_rtbh()).all() - else: - rules_rtbh = db.session.query(RTBH).order_by(sorting_rtbh()).all() +def _get_flowspec6_rules(rule_state, sort="expires", order="desc", page=1, per_page=50, paginate=False): + """Get Flowspec6 rules with optional pagination""" + + today = datetime.now() + comp_func = utils.get_comp_func(rule_state) - return rules_rtbh + sorter = getattr(Flowspec6, sort, Flowspec6.id) + sorting = getattr(sorter, order) - if rule_type == "whitelist": - sorter_whitelist = getattr(Whitelist, sort, Whitelist.id) - sorting_whitelist = getattr(sorter_whitelist, order) + query = db.session.query(Flowspec6) - if comp_func: - rules_whitelist = ( - db.session.query(Whitelist) - .filter(comp_func(Whitelist.expires, today)) - .order_by(sorting_whitelist()) - .all() - ) + if comp_func: + query = query.filter(comp_func(Flowspec6.expires, today)) - else: - rules_whitelist = db.session.query(Whitelist).order_by(sorting_whitelist()).all() + query = query.order_by(sorting()) + + if paginate: + pagination = query.paginate(page=page, per_page=per_page, error_out=False, max_per_page=500) + return pagination.items, pagination + else: + return query.all() + + +def _get_rtbh_rules(rule_state, sort="expires", order="desc", page=1, per_page=50, paginate=False): + """Get RTBH rules with optional pagination""" + + today = datetime.now() + comp_func = utils.get_comp_func(rule_state) + + sorter = getattr(RTBH, sort, RTBH.id) + sorting = getattr(sorter, order) - return rules_whitelist + query = db.session.query(RTBH) + + if comp_func: + query = query.filter(comp_func(RTBH.expires, today)) + + query = query.order_by(sorting()) + + if paginate: + pagination = query.paginate(page=page, per_page=per_page, error_out=False, max_per_page=500) + return pagination.items, pagination + else: + return query.all() + + +def _get_whitelist_rules(rule_state, sort="expires", order="desc", page=1, per_page=50, paginate=False): + """Get Whitelist rules with optional pagination""" + + today = datetime.now() + comp_func = utils.get_comp_func(rule_state) + + sorter = getattr(Whitelist, sort, Whitelist.id) + sorting = getattr(sorter, order) + + query = db.session.query(Whitelist) + + if comp_func: + query = query.filter(comp_func(Whitelist.expires, today)) + + query = query.order_by(sorting()) + + if paginate: + pagination = query.paginate(page=page, per_page=per_page, error_out=False, max_per_page=500) + return pagination.items, pagination + else: + return query.all() + + +# Facade function - keeps backward compatibility and config-based routing +def get_ip_rules(rule_type, rule_state, sort="expires", order="desc", page=1, per_page=50, paginate=False): + """ + Returns list of rules sorted by sort column ordered asc or desc, with optional pagination. + This is a facade function that delegates to type-specific handlers. + + Args: + rule_type: Type of rule ('ipv4', 'ipv6', 'rtbh', 'whitelist') + rule_state: State filter ('active', 'expired', 'all') + sort: Column to sort by (default: 'expires') + order: Sort order 'asc' or 'desc' (default: 'desc') + page: Page number (1-indexed, default: 1) + per_page: Number of items per page (default: 50) + paginate: If True, return (items, pagination) tuple; if False, return all items + + Returns: + If paginate=True: tuple of (list of rules, pagination object) + If paginate=False: list of all rules + """ + # Dispatch to appropriate handler + handlers = { + "ipv4": _get_flowspec4_rules, + "ipv6": _get_flowspec6_rules, + "rtbh": _get_rtbh_rules, + "whitelist": _get_whitelist_rules, + } + + handler = handlers.get(rule_type) + + if handler: + return handler(rule_state, sort, order, page, per_page, paginate) + else: + # Unknown rule type + if paginate: + return [], None + else: + return [] def get_user_rules_ids(user_id, rule_type): diff --git a/flowapp/static/js/check_all.js b/flowapp/static/js/check_all.js new file mode 100644 index 0000000..5df58fc --- /dev/null +++ b/flowapp/static/js/check_all.js @@ -0,0 +1,15 @@ +document.getElementById("check-all").addEventListener("click", function(event){ + /** + * find all checkboxes in current dashboard and toggle checked all / none + */ + const inputs = document.querySelectorAll("input[type='checkbox']"); + if (this.checked) { + for(let minput of inputs) { + minput.checked = true; + } + } else { + for(let minput of inputs) { + minput.checked = false; + } + } +}); \ No newline at end of file diff --git a/flowapp/static/js/search_helpers.js b/flowapp/static/js/search_helpers.js new file mode 100644 index 0000000..0712db3 --- /dev/null +++ b/flowapp/static/js/search_helpers.js @@ -0,0 +1,18 @@ +document.addEventListener('DOMContentLoaded', function() { + document.querySelectorAll('.btn-close[data-clear-url]').forEach(btn => { + btn.addEventListener('click', function() { + window.location.href = this.dataset.clearUrl; + }); + }); + + const searchInput = document.querySelector('input[name="squery"]'); + if (searchInput) { + const clearUrl = searchInput.dataset.clearUrl; + + searchInput.addEventListener('keydown', function(e) { + if (e.key === 'Escape' && this.value && clearUrl) { + window.location.href = clearUrl; + } + }); + } +}); diff --git a/flowapp/templates/layouts/default.html b/flowapp/templates/layouts/default.html index 0463591..4ecbecd 100644 --- a/flowapp/templates/layouts/default.html +++ b/flowapp/templates/layouts/default.html @@ -113,5 +113,7 @@ + + diff --git a/flowapp/templates/pages/dashboard_admin.html b/flowapp/templates/pages/dashboard_admin.html index df14052..1850c36 100644 --- a/flowapp/templates/pages/dashboard_admin.html +++ b/flowapp/templates/pages/dashboard_admin.html @@ -1,4 +1,5 @@ {% extends 'layouts/default.html' %} +{% from 'pagination_macro.html' import render_pagination %} {% block title %}Flowspec{% endblock %} @@ -18,8 +19,12 @@ + + {# Pagination controls #} + {{ render_pagination(pagination, rtype, rstate, sort_key, sort_order, search_query, per_page, per_page_options) }} + {% else %}

There are no {{ rstate|capitalize }} {{ table_title }}.

{% endif %} -{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/flowapp/templates/pages/dashboard_search_form.html b/flowapp/templates/pages/dashboard_search_form.html new file mode 100644 index 0000000..ce4698a --- /dev/null +++ b/flowapp/templates/pages/dashboard_search_form.html @@ -0,0 +1,19 @@ +{% block dashboard_search_form %} + +{% endblock %} \ No newline at end of file diff --git a/flowapp/templates/pages/dashboard_view.html b/flowapp/templates/pages/dashboard_view.html index ae14146..5fb1c00 100644 --- a/flowapp/templates/pages/dashboard_view.html +++ b/flowapp/templates/pages/dashboard_view.html @@ -1,5 +1,6 @@ {% extends 'layouts/default.html' %} {% from 'macros.html' import build_ip_tbody, build_rtbh_tbody, build_rules_thead %} +{% from 'pagination_macro.html' import render_pagination %} {% block title %}Flowspec{% endblock %} @@ -15,8 +16,11 @@

{{ rstate|capitalize }} {{ table_title }}

{{ dashboard_table_body }} + + {# Pagination controls #} + {{ render_pagination(pagination, rtype, rstate, sort_key, sort_order, search_query, per_page, per_page_options) }} {% else %}

There are no {{ rstate|capitalize }} {{ table_title }}.

{% endif %} -{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/flowapp/templates/pages/submenu_dashboard.html b/flowapp/templates/pages/submenu_dashboard.html index ff00634..8e8ece5 100644 --- a/flowapp/templates/pages/submenu_dashboard.html +++ b/flowapp/templates/pages/submenu_dashboard.html @@ -1,4 +1,3 @@ - {% block submenu_dashboard %}
@@ -21,34 +20,7 @@

{{ rstate|capitalize }} {{ table_title }}

+ + {% if search_query %} +
+
+ +
+
+ {% endif %} +
{% endblock %} \ No newline at end of file diff --git a/flowapp/templates/pages/submenu_dashboard_view.html b/flowapp/templates/pages/submenu_dashboard_view.html index 96fef1a..d5462d6 100644 --- a/flowapp/templates/pages/submenu_dashboard_view.html +++ b/flowapp/templates/pages/submenu_dashboard_view.html @@ -21,34 +21,17 @@

{{ rstate|capitalize }} {{ table_title }}

diff --git a/flowapp/templates/pagination_macro.html b/flowapp/templates/pagination_macro.html new file mode 100644 index 0000000..92a2f3c --- /dev/null +++ b/flowapp/templates/pagination_macro.html @@ -0,0 +1,105 @@ +{# +Pagination macro for dashboard tables +Usage: {{ render_pagination(pagination, rtype, rstate, sort_key, sort_order, search_query, per_page, per_page_options) }} +Parameters: + pagination - Pagination object containing page info (required) + rtype - Rule type filter (string, required) + rstate - Rule state filter (string, required) + sort_key - Key to sort by (string, required) + sort_order - Sort order, e.g. 'asc' or 'desc' (string, required) + search_query - Search query string (string, optional, default: '') + per_page - Number of items per page (integer, optional, default: 50) + per_page_options - List of selectable items per page (list of integers, optional, default: [25, 50, 100, 200]) +#} +{% macro render_pagination(pagination, rtype, rstate, sort_key, sort_order, search_query='', per_page=50, per_page_options=[25, 50, 100, 200]) %} + {% if pagination %} +
+
+ +
+ +
+ {# Pagination info #} +

+ Showing {{ pagination.first }} - {{ pagination.last }} of {{ pagination.total }} rules +

+
+ +
+ {# Items per page selector #} +
+ + + + + {% if search_query %} + + {% endif %} +
+ + +
+
+
+
+ {% endif %} +{% endmacro %} diff --git a/flowapp/utils/app_factory.py b/flowapp/utils/app_factory.py index 10a4be6..6a4cae3 100644 --- a/flowapp/utils/app_factory.py +++ b/flowapp/utils/app_factory.py @@ -125,7 +125,8 @@ def format_datetime(value): return app.config.get("MISSING_DATETIME_MESSAGE", "Never") format = "y/MM/dd HH:mm" - return babel.dates.format_datetime(value, format) + locale = app.config.get("BABEL_DEFAULT_LOCALE", "en_US_POSIX") + return babel.dates.format_datetime(value, format, locale=locale) @app.template_filter("unlimited") def unlimited_filter(value): diff --git a/flowapp/views/dashboard.py b/flowapp/views/dashboard.py index b4cd176..c013f67 100644 --- a/flowapp/views/dashboard.py +++ b/flowapp/views/dashboard.py @@ -1,5 +1,6 @@ import subprocess from datetime import datetime +from dataclasses import dataclass from flask import ( Blueprint, @@ -10,7 +11,10 @@ session, make_response, abort, + redirect, + url_for, ) + from markupsafe import Markup from flowapp import models, validators, flowspec from flowapp.auth import auth_required @@ -30,6 +34,12 @@ dashboard = Blueprint("dashboard", __name__, template_folder="templates") +# Pagination constants +PAGE_ARG = "page" +PER_PAGE_DEFAULT = 50 # Default number of items per page +PER_PAGE_OPTIONS = [25, 50, 100, 200] # Options for items per page +PER_PAGE_ARG = "per_page" + @dashboard.route("/whois/", methods=["GET"]) @auth_required @@ -48,7 +58,7 @@ def index(rtype=None, rstate="active"): """ dispatcher object for the dashboard :param rtype: ipv4, ipv6, rtbh, whitelist - :param rstate: + :param rstate: active, expired, all :return: view from view factory """ @@ -73,7 +83,6 @@ def index(rtype=None, rstate="active"): view_factory = create_view_response # get the macros for the current rule type from config - # warning no checks here, if the config is set to non existing macro the app will crash macro_file = current_app.config["DASHBOARD"].get(rtype).get("macro_file", "macros.html") macro_tbody = current_app.config["DASHBOARD"].get(rtype).get("macro_tbody", "build_ip_tbody") macro_thead = current_app.config["DASHBOARD"].get(rtype).get("macro_thead", "build_rules_thead") @@ -81,6 +90,15 @@ def index(rtype=None, rstate="active"): data_handler_module = current_app.config["DASHBOARD"].get(rtype).get("data_handler", models) data_handler_method = current_app.config["DASHBOARD"].get(rtype).get("data_handler_method", "get_ip_rules") + + # Get pagination parameters + page = request.args.get(PAGE_ARG, 1, type=int) + per_page = request.args.get(PER_PAGE_ARG, PER_PAGE_DEFAULT, type=int) + + # Validate per_page + if per_page not in PER_PAGE_OPTIONS: + per_page = PER_PAGE_DEFAULT + # get search query, sort order and sort key from request or session get_search_query = request.args.get(SEARCH_ARG, session.get(SEARCH_ARG, "")) get_sort_key = request.args.get(SORT_ARG, session.get(SORT_ARG, DEFAULT_SORT)) @@ -104,24 +122,56 @@ def index(rtype=None, rstate="active"): # get the handler and the data handler = getattr(data_handler_module, data_handler_method) - rules = handler(rtype, rstate, get_sort_key, get_sort_order) - # Enrich rules with whitelist information - rules, whitelist_rule_ids = enrich_rules_with_whitelist_info(rules, rtype) + # Determine if we're searching + is_searching = bool(get_search_query) - # search rules - if get_search_query: - count_match = current_app.config["COUNT_MATCH"] + # Always fetch ALL rules first (no pagination at DB level when searching) + if is_searching: + # Get all rules for search + rules = handler(rtype, rstate, get_sort_key, get_sort_order, paginate=False) + + # Perform search on all rules rules = filter_rules(rules, get_search_query) - # extended search in for all rule types - count_match[rtype] = len(rules) + + # Now paginate the search results in memory + pagination = paginate_list(rules, page, per_page) + # Get the slice of rules for current page + start_idx = (page - 1) * per_page + end_idx = start_idx + per_page + rules = rules[start_idx:end_idx] + + # Get counts for other rule types + count_match = current_app.config["COUNT_MATCH"] + count_match[rtype] = pagination.total for other_rtype in other_rtypes(rtype): - other_rules = handler(other_rtype, rstate) + other_rules = handler(other_rtype, rstate, get_sort_key, get_sort_order, paginate=False) other_rules = filter_rules(other_rules, get_search_query) count_match[other_rtype] = len(other_rules) else: + # No search - use normal pagination or fetch all + use_pagination = rstate in ["expired", "all"] + + if use_pagination: + # Use paginated version from DB + rules_data = handler( + rtype, rstate, get_sort_key, get_sort_order, page=page, per_page=per_page, paginate=True + ) + if isinstance(rules_data, tuple): + rules, pagination = rules_data + else: + rules = rules_data + pagination = None + else: + # Fetch all rules for 'active' state + rules = handler(rtype, rstate, get_sort_key, get_sort_order, paginate=False) + pagination = None + count_match = "" + # Enrich rules with whitelist information + rules, whitelist_rule_ids = enrich_rules_with_whitelist_info(rules, rtype) + allowed_communities = current_app.config["ALLOWED_COMMUNITIES"] return view_factory( @@ -141,9 +191,90 @@ def index(rtype=None, rstate="active"): macro_tfoot=macro_tfoot, whitelist_rule_ids=whitelist_rule_ids, allowed_communities=allowed_communities, + pagination=pagination, + per_page=per_page, + per_page_options=PER_PAGE_OPTIONS, ) +# Add this route to your dashboard.py Blueprint + + +@dashboard.route("/clear-search") +@auth_required +def clear_search(): + """ + Clear the search query from session and redirect back to the current view. + """ + # Get current rtype and rstate before clearing + rtype = session.get(TYPE_ARG, next(iter(current_app.config["DASHBOARD"].keys()))) + rstate = session.get(RULE_ARG, "active") + sort_key = session.get(SORT_ARG, DEFAULT_SORT) + sort_order = session.get(ORDER_ARG, DEFAULT_ORDER) + + # Clear the search query from session + session[SEARCH_ARG] = "" + + # Redirect back to dashboard with current settings but no search + return redirect(url_for("dashboard.index", rtype=rtype, rstate=rstate, sort=sort_key, order=sort_order)) + + +# Helper functions + + +@dataclass +class Pagination: + page: int + per_page: int + total: int + pages: int + has_prev: bool + has_next: bool + prev_num: int = None + next_num: int = None + first: int = 0 + last: int = 0 + + +def paginate_list(items, page, per_page): + """ + Create a pagination object from a list of items. + This mimics SQLAlchemy's pagination for in-memory lists. + + :param items: List of items to paginate + :param page: Current page number (1-indexed) + :param per_page: Number of items per page + :return: Pagination-like object + """ + total = len(items) + pages = (total + per_page - 1) // per_page # Ceiling division + + has_prev = page > 1 + has_next = page < pages + + prev_num = page - 1 if has_prev else None + next_num = page + 1 if has_next else None + + # Calculate first and last item numbers for display + first = (page - 1) * per_page + 1 if total > 0 else 0 + last = min(page * per_page, total) + + pagination = Pagination( + page=page, + per_page=per_page, + total=total, + pages=pages, + has_prev=has_prev, + has_next=has_next, + prev_num=prev_num, + next_num=next_num, + first=first, + last=last, + ) + + return pagination + + def create_dashboard_table_body( rules, rtype, @@ -260,15 +391,15 @@ def create_admin_response( macro_tfoot="build_group_buttons_tfoot", whitelist_rule_ids=None, allowed_communities=None, + pagination=None, + per_page=PER_PAGE_DEFAULT, + per_page_options=PER_PAGE_OPTIONS, ): """ Admin can see and edit any rules - :param rtype: - :param rstate: - :param rules: - :param all_actions: - :param sort_order: - :return: + :param pagination: SQLAlchemy pagination object (optional) + :param per_page: Number of items per page + :param per_page_options: Available options for items per page """ group_op = True if rtype != "whitelist" else False @@ -318,6 +449,9 @@ def create_admin_response( sort_key=sort_key, sort_order=sort_order, search_query=search_query, + pagination=pagination, + per_page=per_page, + per_page_options=per_page_options, ) ) @@ -341,12 +475,15 @@ def create_user_response( macro_tfoot="build_rules_tfoot", whitelist_rule_ids=None, allowed_communities=None, + pagination=None, + per_page=PER_PAGE_DEFAULT, + per_page_options=PER_PAGE_OPTIONS, ): """ Filter out the rules for normal users - :param rules: - :param rstate: - :return: + :param pagination: SQLAlchemy pagination object (optional) + :param per_page: Number of items per page + :param per_page_options: Available options for items per page """ net_ranges = models.get_user_nets(session["user_id"]) @@ -440,6 +577,9 @@ def create_user_response( sort_order=sort_order, search_query=search_query, count_match=count_match, + pagination=pagination, + per_page=per_page, + per_page_options=per_page_options, ) ) @@ -463,12 +603,15 @@ def create_view_response( macro_tfoot="build_rules_tfoot", whitelist_rule_ids=None, allowed_communities=None, + pagination=None, + per_page=PER_PAGE_DEFAULT, + per_page_options=PER_PAGE_OPTIONS, ): """ Filter out the rules for normal users - :param rules: - :param rstate: - :return: + :param pagination: SQLAlchemy pagination object (optional) + :param per_page: Number of items per page + :param per_page_options: Available options for items per page """ dashboard_table_body = create_dashboard_table_body( rules, @@ -513,6 +656,9 @@ def create_view_response( dashboard_table_body=Markup(dashboard_table_body), dashboard_table_head=Markup(dashboard_table_head), dashboard_table_foot=Markup(dashboard_table_foot), + pagination=pagination, + per_page=per_page, + per_page_options=per_page_options, ) ) @@ -520,11 +666,25 @@ def create_view_response( def filter_rules(rules, get_search_query): + """ + Filter rules based on search query. + Performs full-text search across all rule fields. + + :param rules: List of rule objects + :param get_search_query: Search string + :return: Filtered list of rules + """ + if not get_search_query: + return rules + rules_serialized = [rule.dict() for rule in rules] result = [] + search_lower = get_search_query.lower() + for idx, rule in enumerate(rules_serialized): - full_text = " ".join("{}".format(c) for c in rule.values()) - if get_search_query.lower() in full_text.lower(): + # Create a full text string from all values + full_text = " ".join(str(val) for val in rule.values()) + if search_lower in full_text.lower(): result.append(rules[idx]) return result diff --git a/pyproject.toml b/pyproject.toml index 5e2d3d8..b5635c4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,10 +6,10 @@ build-backend = "setuptools.build_meta" name = "exafs" dynamic = ["version"] authors = [ - {name = "Jiri Vrany", email = "jiri.vrany@cesnet.cz"}, - {name = "Petr Adamec", email = "petr.adamec@cesnet.cz"}, + {name = "Jiri Vrany"}, + {name = "Petr Adamec"}, {name = "Josef Verich"}, - {name = "Jakub Man", email = "jakub.man@cesnet.cz"}, + {name = "Jakub Man"}, ] maintainers = [ {name = "Jiri Vrany", email = "jiri.vrany@cesnet.cz"}