Implements the parallel.md workstreams (Agents A-E) toward REFACTOR_GOAL.md. Phase 1 — app factory + blueprints + /api/v1: - app.py -> create_app() factory (no module-level app); entrypoints updated - routes/ (auth, pages, settings, admin, assets) + blueprints/api.py at /api/v1 - config.py / extensions.py / security.py extracted; services/ layer added - endpoint names preserved so template url_for() calls keep resolving (static check: all 27 template url_for endpoints are defined routes) Phase 2 — one pluggable filter system: - filter_pipeline/registry.py: @register_stage / @register_plugin + discover_modules - engine._init_stages() instantiates registered stages (no hardcoded dict); process_batch is AI-aware: only short-circuits to the AI-disabled path when a filterset's stages declare requires_ai, so offline filtersets run with AI off - BaseFilterPlugin gets a consumer (stages/plugins.py); Keyword/Quality re-enabled via filter_config.json plugins config - comment tree modes ported to stages/comment_filter.py + shared rules.py; wired into /api/v1/posts/<uuid> and /api/v1/comments/<uuid> via FilterEngine.filter_comments() (fails open) - offline quality_filter filterset exercises plugins+ranker without AI - legacy filter_lib / comment_lib / html_generation_lib / generate_html / active_html path deleted Phase 3 prep — pluggable fetchers + Postgres models: - Post / Comment SQLAlchemy models added to models.py - migrate_content_to_db.py backfill (idempotent by uuid, batched, --dry-run) - platforms/ fetcher registry (extension point) - live reads/writes still go through PostService (disk JSON); cutover deferred Phase 6 — test harness: - pytest.ini + tests/ (conftest with in-memory SQLite fixture, no Postgres; stubbed polling/filter singletons) - test_app_factory.py (route registration, no module-level app), test_api_contracts.py (posts/post_detail/comments/filters shape with monkeypatched post_service + get_filter_engine), test_filter_pipeline.py + test_plugin_contract.py (Flask-free; validated locally 12/12 incl. drop-in stage/plugin discovered with zero core edits) Other: .gitignore added (__pycache__, data/, secrets); pytest in requirements. Verification: py_compile clean across the project; the Flask-free filter-pipeline and plugin-contract tests pass locally. App-factory / API-contract tests need deps+docker to run; runtime flask routes / Auth0-repeated-create_app also gated on docker. Co-Authored-By: Claude <noreply@anthropic.com>
93 lines
3.0 KiB
Python
93 lines
3.0 KiB
Python
"""User settings validation and defaults."""
|
|
|
|
import json
|
|
import logging
|
|
import re
|
|
|
|
from config import MAX_COMMUNITY_NAME_LENGTH
|
|
from security import is_safe_filterset
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class SettingsService:
|
|
"""Parse and sanitize user settings JSON shared by page and API routes."""
|
|
|
|
EXPERIENCE_DEFAULTS = {
|
|
"infinite_scroll": False,
|
|
"auto_refresh": False,
|
|
"push_notifications": False,
|
|
"dark_patterns_opt_in": False,
|
|
"time_filter_enabled": False,
|
|
"time_filter_days": 7,
|
|
}
|
|
|
|
EXPERIENCE_BOOL_FIELDS = {
|
|
"infinite_scroll",
|
|
"auto_refresh",
|
|
"push_notifications",
|
|
"dark_patterns_opt_in",
|
|
"time_filter_enabled",
|
|
}
|
|
|
|
@classmethod
|
|
def parse(cls, settings_str):
|
|
"""Return settings JSON as a dict, or an empty dict if invalid."""
|
|
if not settings_str:
|
|
return {}
|
|
try:
|
|
settings = json.loads(settings_str)
|
|
except json.JSONDecodeError as e:
|
|
logger.warning(f"Invalid user settings JSON: {e}")
|
|
return {}
|
|
if not isinstance(settings, dict):
|
|
logger.warning("User settings must be a JSON object")
|
|
return {}
|
|
return settings
|
|
|
|
@classmethod
|
|
def validate(cls, settings_str):
|
|
"""Validate and sanitize persisted user settings JSON."""
|
|
settings = cls.parse(settings_str)
|
|
validated = {}
|
|
|
|
filter_set = settings.get("filter_set")
|
|
if isinstance(filter_set, str) and is_safe_filterset(filter_set):
|
|
validated["filter_set"] = filter_set
|
|
|
|
communities = settings.get("communities")
|
|
if isinstance(communities, list):
|
|
safe_communities = []
|
|
for community in communities:
|
|
if (
|
|
isinstance(community, str)
|
|
and len(community) <= MAX_COMMUNITY_NAME_LENGTH
|
|
and re.match(r"^[a-zA-Z0-9_-]+$", community)
|
|
):
|
|
safe_communities.append(community)
|
|
validated["communities"] = safe_communities
|
|
|
|
experience = settings.get("experience")
|
|
if isinstance(experience, dict):
|
|
safe_experience = {}
|
|
for field in cls.EXPERIENCE_BOOL_FIELDS:
|
|
if field in experience and isinstance(experience[field], bool):
|
|
safe_experience[field] = experience[field]
|
|
|
|
time_filter_days = experience.get("time_filter_days")
|
|
if isinstance(time_filter_days, int) and time_filter_days > 0:
|
|
safe_experience["time_filter_days"] = time_filter_days
|
|
|
|
validated["experience"] = safe_experience
|
|
|
|
return validated
|
|
|
|
@classmethod
|
|
def experience_settings(cls, settings_str):
|
|
"""Return experience settings with defaults filled in."""
|
|
settings = cls.parse(settings_str)
|
|
experience = settings.get("experience", {})
|
|
if not isinstance(experience, dict):
|
|
experience = {}
|
|
return {**cls.EXPERIENCE_DEFAULTS, **experience}
|