Major architectural changes: - Replace YAML config files with database-stored ScanConfig model - Remove CIDR block support in favor of individual IP addresses per site - Each IP now has its own expected_ping, expected_tcp_ports, expected_udp_ports - AlertRule now uses config_id FK instead of config_file string API changes: - POST /api/scans now requires config_id instead of config_file - Alert rules API uses config_id with validation - All config dropdowns fetch from /api/configs dynamically Template updates: - scans.html, dashboard.html, alert_rules.html load configs via API - Display format: Config Title (X sites) in dropdowns - Removed Jinja2 config_files loops Migrations: - 008: Expand CIDRs to individual IPs with per-IP port configs - 009: Remove CIDR-related columns - 010: Add config_id to alert_rules, remove config_file
210 lines
6.2 KiB
Python
210 lines
6.2 KiB
Python
"""
|
|
Pagination utilities for SneakyScanner web application.
|
|
|
|
Provides helper functions for paginating SQLAlchemy queries.
|
|
"""
|
|
|
|
from typing import Any, Callable, Dict, List, Optional
|
|
from sqlalchemy.orm import Query
|
|
|
|
|
|
class PaginatedResult:
|
|
"""Container for paginated query results."""
|
|
|
|
def __init__(self, items: List[Any], total: int, page: int, per_page: int):
|
|
"""
|
|
Initialize paginated result.
|
|
|
|
Args:
|
|
items: List of items for current page
|
|
total: Total number of items across all pages
|
|
page: Current page number (1-indexed)
|
|
per_page: Number of items per page
|
|
"""
|
|
self.items = items
|
|
self.total = total
|
|
self.page = page
|
|
self.per_page = per_page
|
|
|
|
@property
|
|
def pages(self) -> int:
|
|
"""Calculate total number of pages."""
|
|
if self.per_page == 0:
|
|
return 0
|
|
return (self.total + self.per_page - 1) // self.per_page
|
|
|
|
@property
|
|
def has_prev(self) -> bool:
|
|
"""Check if there is a previous page."""
|
|
return self.page > 1
|
|
|
|
@property
|
|
def has_next(self) -> bool:
|
|
"""Check if there is a next page."""
|
|
return self.page < self.pages
|
|
|
|
@property
|
|
def prev_page(self) -> int:
|
|
"""Get previous page number."""
|
|
return self.page - 1 if self.has_prev else None
|
|
|
|
@property
|
|
def next_page(self) -> int:
|
|
"""Get next page number."""
|
|
return self.page + 1 if self.has_next else None
|
|
|
|
@property
|
|
def prev_num(self) -> int:
|
|
"""Alias for prev_page (Flask-SQLAlchemy compatibility)."""
|
|
return self.prev_page
|
|
|
|
@property
|
|
def next_num(self) -> int:
|
|
"""Alias for next_num (Flask-SQLAlchemy compatibility)."""
|
|
return self.next_page
|
|
|
|
def iter_pages(self, left_edge=1, left_current=1, right_current=2, right_edge=1):
|
|
"""
|
|
Generate page numbers for pagination display.
|
|
|
|
Yields page numbers and None for gaps, compatible with Flask-SQLAlchemy's
|
|
pagination.iter_pages() method.
|
|
|
|
Args:
|
|
left_edge: Number of pages to show on the left edge
|
|
left_current: Number of pages to show left of current page
|
|
right_current: Number of pages to show right of current page
|
|
right_edge: Number of pages to show on the right edge
|
|
|
|
Yields:
|
|
int or None: Page number or None for gaps
|
|
|
|
Example:
|
|
For 100 pages, current page 50:
|
|
Yields: 1, None, 48, 49, 50, 51, 52, None, 100
|
|
"""
|
|
last = 0
|
|
for num in range(1, self.pages + 1):
|
|
if num <= left_edge or \
|
|
(num > self.page - left_current - 1 and num < self.page + right_current) or \
|
|
num > self.pages - right_edge:
|
|
if last + 1 != num:
|
|
yield None
|
|
yield num
|
|
last = num
|
|
|
|
def to_dict(self) -> Dict[str, Any]:
|
|
"""
|
|
Convert to dictionary for API responses.
|
|
|
|
Returns:
|
|
Dictionary with pagination metadata and items
|
|
"""
|
|
return {
|
|
'items': self.items,
|
|
'total': self.total,
|
|
'page': self.page,
|
|
'per_page': self.per_page,
|
|
'pages': self.pages,
|
|
'has_prev': self.has_prev,
|
|
'has_next': self.has_next,
|
|
'prev_page': self.prev_page,
|
|
'next_page': self.next_page,
|
|
}
|
|
|
|
|
|
def paginate(query: Query, page: int = 1, per_page: int = 20,
|
|
transform: Optional[Callable[[Any], Dict[str, Any]]] = None,
|
|
max_per_page: int = 100) -> PaginatedResult:
|
|
"""
|
|
Paginate a SQLAlchemy query.
|
|
|
|
Args:
|
|
query: SQLAlchemy query to paginate
|
|
page: Page number (1-indexed, default: 1)
|
|
per_page: Items per page (default: 20)
|
|
transform: Optional function to transform each item (default: None)
|
|
max_per_page: Maximum items per page (default: 100)
|
|
|
|
Returns:
|
|
PaginatedResult with items and pagination metadata
|
|
|
|
Examples:
|
|
>>> from web.models import Scan
|
|
>>> query = db.query(Scan).order_by(Scan.timestamp.desc())
|
|
>>> result = paginate(query, page=1, per_page=20)
|
|
>>> scans = result.items
|
|
>>> total_pages = result.pages
|
|
|
|
>>> # With transform function
|
|
>>> def scan_to_dict(scan):
|
|
... return {'id': scan.id, 'name': scan.name}
|
|
>>> result = paginate(query, page=1, per_page=20, transform=scan_to_dict)
|
|
"""
|
|
# Validate and sanitize parameters
|
|
page = max(1, page) # Page must be at least 1
|
|
per_page = max(1, min(per_page, max_per_page)) # Clamp per_page
|
|
|
|
# Get total count
|
|
total = query.count()
|
|
|
|
# Calculate offset
|
|
offset = (page - 1) * per_page
|
|
|
|
# Execute query with limit and offset
|
|
items = query.limit(per_page).offset(offset).all()
|
|
|
|
# Apply transform if provided
|
|
if transform is not None:
|
|
items = [transform(item) for item in items]
|
|
|
|
return PaginatedResult(
|
|
items=items,
|
|
total=total,
|
|
page=page,
|
|
per_page=per_page
|
|
)
|
|
|
|
|
|
def validate_page_params(page: Any, per_page: Any,
|
|
max_per_page: int = 100) -> tuple[int, int]:
|
|
"""
|
|
Validate and sanitize pagination parameters.
|
|
|
|
Args:
|
|
page: Page number (any type, will be converted to int)
|
|
per_page: Items per page (any type, will be converted to int)
|
|
max_per_page: Maximum items per page (default: 100)
|
|
|
|
Returns:
|
|
Tuple of (validated_page, validated_per_page)
|
|
|
|
Examples:
|
|
>>> validate_page_params('2', '50')
|
|
(2, 50)
|
|
>>> validate_page_params(-1, 200)
|
|
(1, 100)
|
|
>>> validate_page_params(None, None)
|
|
(1, 20)
|
|
"""
|
|
# Default values
|
|
default_page = 1
|
|
default_per_page = 20
|
|
|
|
# Convert to int, use default if invalid
|
|
try:
|
|
page = int(page) if page is not None else default_page
|
|
except (ValueError, TypeError):
|
|
page = default_page
|
|
|
|
try:
|
|
per_page = int(per_page) if per_page is not None else default_per_page
|
|
except (ValueError, TypeError):
|
|
per_page = default_per_page
|
|
|
|
# Validate ranges
|
|
page = max(1, page)
|
|
per_page = max(1, min(per_page, max_per_page))
|
|
|
|
return page, per_page
|