From 14448408af1df23ebbaf5701020a560130c723be Mon Sep 17 00:00:00 2001 From: rob Date: Tue, 13 Jan 2026 06:13:37 -0400 Subject: [PATCH] Add enhanced search and filtering (M2 feature) - Add /api/v1/tags endpoint for listing available tags - Enhance search API with tag filtering (AND logic), multi-category filtering (OR logic), owner filter, download range, and date range - Add faceted response support (category/tag/owner counts) - Update registry client with new search parameters and get_tags method - Add CLI search options: -t/--tag, -o/--owner, --min-downloads, --popular, --new, --since, --before, --json, --show-facets - Add new 'registry tags' CLI subcommand - Add web UI filter sidebar with checkboxes, dropdowns, and active filter chips with URL-based state management - Display clickable tags on tool cards Co-Authored-By: Claude Opus 4.5 --- src/cmdforge/cli/__init__.py | 18 + src/cmdforge/cli/registry_commands.py | 95 ++++- src/cmdforge/registry/app.py | 221 +++++++++- src/cmdforge/registry/db.py | 1 + src/cmdforge/registry_client.py | 73 +++- src/cmdforge/web/routes.py | 47 ++- .../web/templates/components/tool_card.html | 22 +- src/cmdforge/web/templates/pages/search.html | 386 ++++++++++++++---- 8 files changed, 762 insertions(+), 101 deletions(-) diff --git a/src/cmdforge/cli/__init__.py b/src/cmdforge/cli/__init__.py index 4b55e2e..2c9fc40 100644 --- a/src/cmdforge/cli/__init__.py +++ b/src/cmdforge/cli/__init__.py @@ -135,9 +135,27 @@ def main(): p_reg_search = registry_sub.add_parser("search", help="Search for tools") p_reg_search.add_argument("query", help="Search query") p_reg_search.add_argument("-c", "--category", help="Filter by category") + p_reg_search.add_argument("-t", "--tag", action="append", dest="tags", help="Filter by tag (repeatable, AND logic)") + p_reg_search.add_argument("-o", "--owner", help="Filter by publisher/owner") + p_reg_search.add_argument("--min-downloads", type=int, help="Minimum downloads") + p_reg_search.add_argument("--popular", action="store_true", help="Shortcut for --min-downloads 100") + p_reg_search.add_argument("--new", action="store_true", help="Shortcut for --max-downloads 10") + p_reg_search.add_argument("--since", help="Published after date (YYYY-MM-DD)") + p_reg_search.add_argument("--before", help="Published before date (YYYY-MM-DD)") + p_reg_search.add_argument("-s", "--sort", choices=["relevance", "downloads", "published_at", "name"], default="relevance", help="Sort by field") p_reg_search.add_argument("-l", "--limit", type=int, help="Max results (default: 20)") + p_reg_search.add_argument("--json", action="store_true", help="Output as JSON") + p_reg_search.add_argument("--show-facets", action="store_true", help="Show category/tag counts") + p_reg_search.add_argument("--deprecated", action="store_true", help="Include deprecated tools") p_reg_search.set_defaults(func=cmd_registry) + # registry tags + p_reg_tags = registry_sub.add_parser("tags", help="List available tags") + p_reg_tags.add_argument("-c", "--category", help="Filter tags by category") + p_reg_tags.add_argument("-l", "--limit", type=int, default=50, help="Max tags to show (default: 50)") + p_reg_tags.add_argument("--json", action="store_true", help="Output as JSON") + p_reg_tags.set_defaults(func=cmd_registry) + # registry install p_reg_install = registry_sub.add_parser("install", help="Install a tool from registry") p_reg_install.add_argument("tool", help="Tool to install (owner/name or name)") diff --git a/src/cmdforge/cli/registry_commands.py b/src/cmdforge/cli/registry_commands.py index 9a2209b..5feae45 100644 --- a/src/cmdforge/cli/registry_commands.py +++ b/src/cmdforge/cli/registry_commands.py @@ -1,5 +1,6 @@ """Registry commands.""" +import json import sys from pathlib import Path @@ -15,6 +16,8 @@ def cmd_registry(args): if args.registry_cmd == "search": return _cmd_registry_search(args) + elif args.registry_cmd == "tags": + return _cmd_registry_tags(args) elif args.registry_cmd == "install": return _cmd_registry_install(args) elif args.registry_cmd == "uninstall": @@ -33,6 +36,7 @@ def cmd_registry(args): # Default: show registry help print("Registry commands:") print(" search Search for tools") + print(" tags List available tags") print(" install Install a tool") print(" uninstall Uninstall a tool") print(" info Show tool information") @@ -49,27 +53,73 @@ def _cmd_registry_search(args): try: client = get_client() + + # Handle shortcut flags + min_downloads = getattr(args, 'min_downloads', None) + max_downloads = None + if getattr(args, 'popular', False): + min_downloads = 100 + if getattr(args, 'new', False): + max_downloads = 10 + results = client.search_tools( query=args.query, - category=args.category, - per_page=args.limit or 20 + category=getattr(args, 'category', None), + tags=getattr(args, 'tags', None), + owner=getattr(args, 'owner', None), + min_downloads=min_downloads, + max_downloads=max_downloads, + published_after=getattr(args, 'since', None), + published_before=getattr(args, 'before', None), + include_deprecated=getattr(args, 'deprecated', False), + include_facets=getattr(args, 'show_facets', False), + per_page=args.limit or 20, + sort=getattr(args, 'sort', 'relevance') ) + # JSON output + if getattr(args, 'json', False): + output = { + "query": args.query, + "total": results.total, + "results": results.data + } + if results.facets: + output["facets"] = results.facets + print(json.dumps(output, indent=2)) + return 0 + if not results.data: print(f"No tools found matching '{args.query}'") return 0 - print(f"Found {results.total} tools:\n") + print(f"Found {results.total} tools matching \"{args.query}\":") + + # Show facets summary if requested + if results.facets: + cats = results.facets.get("categories", [])[:5] + tags = results.facets.get("tags", [])[:5] + if cats: + cat_str = ", ".join(f"{c['name']} ({c['count']})" for c in cats) + print(f"\nCategories: {cat_str}") + if tags: + tag_str = ", ".join(f"{t['name']} ({t['count']})" for t in tags) + print(f"Top Tags: {tag_str}") + + print() for tool in results.data: owner = tool.get("owner", "") name = tool.get("name", "") version = tool.get("version", "") desc = tool.get("description", "") downloads = tool.get("downloads", 0) + tags = tool.get("tags", []) print(f" {owner}/{name} v{version}") print(f" {desc[:60]}{'...' if len(desc) > 60 else ''}") - print(f" Downloads: {downloads}") + if tags: + print(f" Tags: {', '.join(tags[:5])}") + print(f" Downloads: {downloads:,}") print() if results.total_pages > 1: @@ -92,6 +142,43 @@ def _cmd_registry_search(args): return 0 +def _cmd_registry_tags(args): + """List available tags.""" + from ..registry_client import RegistryError, get_client + + try: + client = get_client() + tags = client.get_tags( + category=getattr(args, 'category', None), + limit=getattr(args, 'limit', 50) + ) + + # JSON output + if getattr(args, 'json', False): + print(json.dumps({"tags": tags}, indent=2)) + return 0 + + if not tags: + print("No tags found") + return 0 + + print(f"Available tags ({len(tags)}):\n") + for tag in tags: + print(f" {tag['name']:20} ({tag['count']} tools)") + + except RegistryError as e: + if e.code == "CONNECTION_ERROR": + print("Could not connect to the registry.", file=sys.stderr) + else: + print(f"Error: {e.message}", file=sys.stderr) + return 1 + except Exception as e: + print(f"Error: {e}", file=sys.stderr) + return 1 + + return 0 + + def _cmd_registry_install(args): """Install a tool from the registry.""" from ..registry_client import RegistryError diff --git a/src/cmdforge/registry/app.py b/src/cmdforge/registry/app.py index abaf77b..637ad1e 100644 --- a/src/cmdforge/registry/app.py +++ b/src/cmdforge/registry/app.py @@ -482,14 +482,77 @@ def create_app() -> Flask: page, per_page, sort, order, error = parse_pagination("/tools/search", "downloads") if error: return error - category = request.args.get("category") offset = (page - 1) * per_page - where_clause = "WHERE tools_fts MATCH ?" + # Parse filter parameters + category = request.args.get("category") # Single category (backward compat) + categories_param = request.args.get("categories", "") # Multi-category (OR logic) + tags_param = request.args.get("tags", "") # Tags (AND logic) + owner_filter = request.args.get("owner") + min_downloads = request.args.get("min_downloads", type=int) + max_downloads = request.args.get("max_downloads", type=int) + published_after = request.args.get("published_after") + published_before = request.args.get("published_before") + include_deprecated = request.args.get("deprecated", "false").lower() == "true" + include_facets = request.args.get("include_facets", "false").lower() == "true" + + # Parse multi-value params + categories = [c.strip() for c in categories_param.split(",") if c.strip()] + if category and category not in categories: + categories.append(category) + tags = [t.strip() for t in tags_param.split(",") if t.strip()] + + # Build WHERE clause + where_clauses = ["tools_fts MATCH ?"] params: List[Any] = [query_text] - if category: - where_clause += " AND tools.category = ?" - params.append(category) + + if categories: + placeholders = ",".join(["?" for _ in categories]) + where_clauses.append(f"tools.category IN ({placeholders})") + params.extend(categories) + + if owner_filter: + where_clauses.append("tools.owner = ?") + params.append(owner_filter) + + if min_downloads is not None: + where_clauses.append("tools.downloads >= ?") + params.append(min_downloads) + + if max_downloads is not None: + where_clauses.append("tools.downloads <= ?") + params.append(max_downloads) + + if published_after: + where_clauses.append("tools.published_at >= ?") + params.append(published_after) + + if published_before: + where_clauses.append("tools.published_at <= ?") + params.append(published_before) + + if not include_deprecated: + where_clauses.append("tools.deprecated = 0") + + where_clause = "WHERE " + " AND ".join(where_clauses) + + # Tag filtering CTE (AND logic - must have ALL specified tags) + tag_cte = "" + tag_join = "" + if tags: + tag_placeholders = ",".join(["?" for _ in tags]) + tag_cte = f""" + tag_matches AS ( + SELECT tools.id + FROM tools, json_each(tools.tags) AS tag + WHERE tag.value IN ({tag_placeholders}) + GROUP BY tools.id + HAVING COUNT(DISTINCT tag.value) = ? + ), + """ + tag_join = "JOIN tag_matches tm ON m.id = tm.id" + # Prepend tag params + params = tags + [len(tags)] + params order_dir = "DESC" if order == "desc" else "ASC" if sort == "relevance": @@ -500,46 +563,57 @@ def create_app() -> Flask: rows = query_all( g.db, f""" - WITH matches AS ( + WITH {tag_cte} + matches AS ( SELECT tools.*, bm25(tools_fts) AS rank FROM tools_fts JOIN tools ON tools_fts.rowid = tools.id {where_clause} ), + filtered AS ( + SELECT m.* FROM matches m + {tag_join} + ), latest_any AS ( SELECT owner, name, MAX(id) AS max_id - FROM matches + FROM filtered GROUP BY owner, name ), latest_stable AS ( SELECT owner, name, MAX(id) AS max_id - FROM matches + FROM filtered WHERE version NOT LIKE '%-%' GROUP BY owner, name ) - SELECT m.* FROM matches m + SELECT f.* FROM filtered f JOIN ( SELECT a.owner, a.name, COALESCE(s.max_id, a.max_id) AS max_id FROM latest_any a LEFT JOIN latest_stable s ON s.owner = a.owner AND s.name = a.name ) latest - ON m.owner = latest.owner AND m.name = latest.name AND m.id = latest.max_id + ON f.owner = latest.owner AND f.name = latest.name AND f.id = latest.max_id ORDER BY {order_sql} LIMIT ? OFFSET ? """, params + [per_page, offset], ) + # Count query (reuse same params without pagination) count_row = query_one( g.db, f""" - WITH matches AS ( + WITH {tag_cte} + matches AS ( SELECT tools.* FROM tools_fts JOIN tools ON tools_fts.rowid = tools.id {where_clause} + ), + filtered AS ( + SELECT m.* FROM matches m + {tag_join} ) - SELECT COUNT(DISTINCT owner || '/' || name) AS total FROM matches + SELECT COUNT(DISTINCT owner || '/' || name) AS total FROM filtered """, params, ) @@ -560,7 +634,90 @@ def create_app() -> Flask: "score": score, }) - return jsonify({"data": data, "meta": paginate(page, per_page, total)}) + result: dict = {"data": data, "meta": paginate(page, per_page, total)} + + # Compute facets if requested + if include_facets: + # Category facets + cat_rows = query_all( + g.db, + f""" + WITH {tag_cte} + matches AS ( + SELECT tools.* + FROM tools_fts + JOIN tools ON tools_fts.rowid = tools.id + {where_clause} + ), + filtered AS ( + SELECT m.* FROM matches m + {tag_join} + ) + SELECT category, COUNT(DISTINCT owner || '/' || name) AS count + FROM filtered + WHERE category IS NOT NULL + GROUP BY category + ORDER BY count DESC + LIMIT 20 + """, + params, + ) + + # Tag facets + tag_rows = query_all( + g.db, + f""" + WITH {tag_cte} + matches AS ( + SELECT tools.* + FROM tools_fts + JOIN tools ON tools_fts.rowid = tools.id + {where_clause} + ), + filtered AS ( + SELECT m.* FROM matches m + {tag_join} + ) + SELECT tag.value AS name, COUNT(DISTINCT filtered.owner || '/' || filtered.name) AS count + FROM filtered, json_each(filtered.tags) AS tag + GROUP BY tag.value + ORDER BY count DESC + LIMIT 30 + """, + params, + ) + + # Owner facets + owner_rows = query_all( + g.db, + f""" + WITH {tag_cte} + matches AS ( + SELECT tools.* + FROM tools_fts + JOIN tools ON tools_fts.rowid = tools.id + {where_clause} + ), + filtered AS ( + SELECT m.* FROM matches m + {tag_join} + ) + SELECT owner, COUNT(DISTINCT owner || '/' || name) AS count + FROM filtered + GROUP BY owner + ORDER BY count DESC + LIMIT 20 + """, + params, + ) + + result["facets"] = { + "categories": [{"name": r["category"], "count": r["count"]} for r in cat_rows], + "tags": [{"name": r["name"], "count": r["count"]} for r in tag_rows], + "owners": [{"name": r["owner"], "count": r["count"]} for r in owner_rows], + } + + return jsonify(result) @app.route("/api/v1/tools//", methods=["GET"]) def get_tool(owner: str, name: str) -> Response: @@ -726,6 +883,44 @@ def create_app() -> Flask: response.headers["Cache-Control"] = "max-age=3600" return response + @app.route("/api/v1/tags", methods=["GET"]) + def list_tags() -> Response: + """List all tags with usage counts.""" + category = request.args.get("category") + limit = min(int(request.args.get("limit", 100)), 500) + + # Build query - extract tags from JSON array and count occurrences + if category: + rows = query_all( + g.db, + """ + SELECT tag.value AS name, COUNT(DISTINCT tools.owner || '/' || tools.name) AS count + FROM tools, json_each(tools.tags) AS tag + WHERE tools.category = ? + GROUP BY tag.value + ORDER BY count DESC + LIMIT ? + """, + (category, limit), + ) + else: + rows = query_all( + g.db, + """ + SELECT tag.value AS name, COUNT(DISTINCT tools.owner || '/' || tools.name) AS count + FROM tools, json_each(tools.tags) AS tag + GROUP BY tag.value + ORDER BY count DESC + LIMIT ? + """, + (limit,), + ) + + data = [{"name": row["name"], "count": row["count"]} for row in rows] + response = jsonify({"data": data, "meta": {"total": len(data)}}) + response.headers["Cache-Control"] = "max-age=3600" + return response + # ─── Collections ───────────────────────────────────────────────────────────── @app.route("/api/v1/collections", methods=["GET"]) diff --git a/src/cmdforge/registry/db.py b/src/cmdforge/registry/db.py index f4d1b0e..d0d8c11 100644 --- a/src/cmdforge/registry/db.py +++ b/src/cmdforge/registry/db.py @@ -120,6 +120,7 @@ CREATE TABLE IF NOT EXISTS web_sessions ( ); CREATE INDEX IF NOT EXISTS idx_tools_owner_name ON tools(owner, name); +CREATE INDEX IF NOT EXISTS idx_tools_owner ON tools(owner); CREATE INDEX IF NOT EXISTS idx_tools_category ON tools(category); CREATE INDEX IF NOT EXISTS idx_tools_published_at ON tools(published_at DESC); CREATE INDEX IF NOT EXISTS idx_tools_downloads ON tools(downloads DESC); diff --git a/src/cmdforge/registry_client.py b/src/cmdforge/registry_client.py index fadcc91..80aa9e1 100644 --- a/src/cmdforge/registry_client.py +++ b/src/cmdforge/registry_client.py @@ -63,6 +63,7 @@ class PaginatedResponse: per_page: int = 20 total: int = 0 total_pages: int = 0 + facets: Optional[Dict] = None # Category/tag/owner counts when requested @dataclass @@ -329,6 +330,15 @@ class RegistryClient: self, query: str, category: Optional[str] = None, + categories: Optional[List[str]] = None, + tags: Optional[List[str]] = None, + owner: Optional[str] = None, + min_downloads: Optional[int] = None, + max_downloads: Optional[int] = None, + published_after: Optional[str] = None, + published_before: Optional[str] = None, + include_deprecated: bool = False, + include_facets: bool = False, page: int = 1, per_page: int = 20, sort: str = "relevance" @@ -338,15 +348,24 @@ class RegistryClient: Args: query: Search query - category: Filter by category + category: Filter by single category (backward compat) + categories: Filter by multiple categories (OR logic) + tags: Filter by tags (AND logic - must have all) + owner: Filter by publisher/owner + min_downloads: Minimum download count + max_downloads: Maximum download count + published_after: Published after date (ISO format) + published_before: Published before date (ISO format) + include_deprecated: Include deprecated tools + include_facets: Include category/tag/owner counts in response page: Page number per_page: Items per page sort: Sort field (relevance, downloads, published_at) Returns: - PaginatedResponse with matching tools + PaginatedResponse with matching tools (and facets if requested) """ - params = { + params: Dict[str, Any] = { "q": query, "page": page, "per_page": min(per_page, 100), @@ -354,6 +373,24 @@ class RegistryClient: } if category: params["category"] = category + if categories: + params["categories"] = ",".join(categories) + if tags: + params["tags"] = ",".join(tags) + if owner: + params["owner"] = owner + if min_downloads is not None: + params["min_downloads"] = min_downloads + if max_downloads is not None: + params["max_downloads"] = max_downloads + if published_after: + params["published_after"] = published_after + if published_before: + params["published_before"] = published_before + if include_deprecated: + params["deprecated"] = "true" + if include_facets: + params["include_facets"] = "true" response = self._request("GET", "/tools/search", params=params) @@ -368,9 +405,37 @@ class RegistryClient: page=meta.get("page", page), per_page=meta.get("per_page", per_page), total=meta.get("total", 0), - total_pages=meta.get("total_pages", 0) + total_pages=meta.get("total_pages", 0), + facets=data.get("facets") ) + def get_tags( + self, + category: Optional[str] = None, + limit: int = 100 + ) -> List[Dict]: + """ + Get all tags with usage counts. + + Args: + category: Filter tags by category + limit: Maximum tags to return + + Returns: + List of tag objects with name and count + """ + params: Dict[str, Any] = {"limit": min(limit, 500)} + if category: + params["category"] = category + + response = self._request("GET", "/tags", params=params) + + if response.status_code != 200: + self._handle_error_response(response) + + data = response.json() + return data.get("data", []) + def get_tool(self, owner: str, name: str) -> ToolInfo: """ Get detailed information about a tool. diff --git a/src/cmdforge/web/routes.py b/src/cmdforge/web/routes.py index ddfef69..0c4f7d4 100644 --- a/src/cmdforge/web/routes.py +++ b/src/cmdforge/web/routes.py @@ -267,22 +267,57 @@ def category(name: str): def search(): query = request.args.get("q", "").strip() page = request.args.get("page", 1) + + # Parse filter parameters category = request.args.get("category") + categories_param = request.args.get("categories", "") + tags_param = request.args.get("tags", "") + owner = request.args.get("owner") + min_downloads = request.args.get("min_downloads") + sort = request.args.get("sort", "relevance") + + # Parse multi-value params + active_categories = [c.strip() for c in categories_param.split(",") if c.strip()] + if category and category not in active_categories: + active_categories.append(category) + active_tags = [t.strip() for t in tags_param.split(",") if t.strip()] if query: - params = {"q": query, "page": page, "per_page": 12} - if category: - params["category"] = category + params = { + "q": query, + "page": page, + "per_page": 12, + "sort": sort, + "include_facets": "true" # Always get facets for the UI + } + if active_categories: + params["categories"] = ",".join(active_categories) + if active_tags: + params["tags"] = ",".join(active_tags) + if owner: + params["owner"] = owner + if min_downloads: + params["min_downloads"] = min_downloads + status, payload = _api_get("/api/v1/tools/search", params=params) if status != 200: return render_template("errors/500.html"), 500 + meta = payload.get("meta", {}) + facets = payload.get("facets", {}) + return render_template( "pages/search.html", query=query, results=payload.get("data", []), pagination=_build_pagination(meta), popular_categories=[], + facets=facets, + active_categories=active_categories, + active_tags=active_tags, + active_owner=owner, + active_sort=sort, + active_min_downloads=min_downloads, ) categories = sorted(_load_categories(), key=lambda c: c.count, reverse=True)[:8] @@ -292,6 +327,12 @@ def search(): results=[], pagination=None, popular_categories=categories, + facets={}, + active_categories=[], + active_tags=[], + active_owner=None, + active_sort="relevance", + active_min_downloads=None, ) diff --git a/src/cmdforge/web/templates/components/tool_card.html b/src/cmdforge/web/templates/components/tool_card.html index 7ed32d6..cfe9c67 100644 --- a/src/cmdforge/web/templates/components/tool_card.html +++ b/src/cmdforge/web/templates/components/tool_card.html @@ -1,5 +1,5 @@ {# Tool card macro #} -{% macro tool_card(tool=none, owner=none, name=none, description=none, category=none, downloads=none, version=none) %} +{% macro tool_card(tool=none, owner=none, name=none, description=none, category=none, downloads=none, version=none, tags=none) %} {% if tool is none %} {% set tool = { "owner": owner, @@ -7,7 +7,8 @@ "description": description, "category": category, "downloads": downloads, - "version": version + "version": version, + "tags": tags } %} {% endif %}
@@ -39,6 +40,23 @@ {{ tool.description or 'No description available.' }}

+ + {% if tool.tags %} +
+ {% for tag in tool.tags[:5] %} + + {{ tag }} + + {% endfor %} + {% if tool.tags|length > 5 %} + + +{{ tool.tags|length - 5 }} more + + {% endif %} +
+ {% endif %} +
diff --git a/src/cmdforge/web/templates/pages/search.html b/src/cmdforge/web/templates/pages/search.html index 49704ff..b5e0489 100644 --- a/src/cmdforge/web/templates/pages/search.html +++ b/src/cmdforge/web/templates/pages/search.html @@ -9,7 +9,7 @@
-
+
-
+
{% if query %} - -
-

- {% if results|length == 1 %} - 1 result for "{{ query }}" +
+ + + + +
+ +
+ + + + + +
+ +
+

+ {{ pagination.total if pagination else results|length }} results for "{{ query }}" +

+
+ + {% if results %} + +
+ {% for tool in results %} + {{ tool_card( + owner=tool.owner, + name=tool.name, + description=tool.description, + category=tool.category, + downloads=tool.downloads, + version=tool.version, + tags=tool.tags + ) }} + {% endfor %} +
+ + + {% if pagination and pagination.pages > 1 %} + + {% endif %} + {% else %} - {{ results|length }} results for "{{ query }}" - {% endif %} -

-
+ +
+ + + +

No results found

+

+ We couldn't find any tools matching "{{ query }}"{% if active_categories or active_tags %} with the current filters{% endif %}. +

- {% if results %} - -
- {% for tool in results %} - {{ tool_card( - owner=tool.owner, - name=tool.name, - description=tool.description, - category=tool.category, - downloads=tool.downloads, - version=tool.version - ) }} - {% endfor %} -
+ {% if active_categories or active_tags or active_owner %} + + {% endif %} - - {% if pagination and pagination.pages > 1 %} -
- - {% endif %} - - {% else %} - -
- - - -

No results found

-

- We couldn't find any tools matching "{{ query }}". -

- -
-

Suggestions:

-
    -
  • Check your spelling
  • -
  • Try more general keywords
  • -
  • Use fewer keywords
  • -
-
- -
- {% endif %} {% else %} @@ -140,4 +319,61 @@ {% endif %}
+ + {% endblock %}