diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 0efe10c5..fbf77a91 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -87,6 +87,7 @@ jobs: - name: Run tests with coverage run: make coverage - name: Coveralls + if: matrix.python-version == '3.14' uses: coverallsapp/github-action@5cbfd81b66ca5d10c19b062c04de0199c215fb6e # v2.3.7 with: path-to-lcov: "./coverage.lcov" @@ -98,7 +99,7 @@ jobs: contents: read actions: read pull-requests: write - uses: problematy/goodmap-e2e-tests/.github/workflows/e2e-tests.yml@b2b85b731205989fc162841df6fee397f716d80b + uses: problematy/goodmap-e2e-tests/.github/workflows/e2e-tests.yml@cac4085419b27dfa74062cab5f2f2fcc43ffa230 with: goodmap-version: ${{ github.sha }} goodmap-frontend-version: 'main' diff --git a/goodmap/core_api.py b/goodmap/core_api.py index 8388118d..e862261e 100644 --- a/goodmap/core_api.py +++ b/goodmap/core_api.py @@ -8,8 +8,9 @@ from flask import Blueprint, jsonify, make_response, request from flask_babel import gettext from platzky import FeatureFlagSet -from platzky.attachment import AttachmentProtocol +from platzky.attachment import create_attachment from platzky.config import AttachmentConfig, LanguagesMapping +from platzky.shortcodes import Shortcode from spectree import Response, SpecTree from goodmap.api_models import ( @@ -98,10 +99,9 @@ def core_pages( notifier_function, csrf_generator, location_model, - photo_attachment_class: type[AttachmentProtocol], photo_attachment_config: AttachmentConfig, feature_flags: FeatureFlagSet, - field_renderers: dict[str, str], + shortcodes: dict[str, Shortcode], ) -> Blueprint: core_api_blueprint = Blueprint("api", __name__, url_prefix="/api") @@ -180,10 +180,9 @@ def suggest_new_point(): photo_content = photo_file.read() photo_mime = photo_file.content_type or "application/octet-stream" - # Validate using configured Attachment class try: - photo_attachment = photo_attachment_class( - photo_file.filename, photo_content, photo_mime + photo_attachment = create_attachment( + photo_file.filename, photo_content, photo_mime, photo_attachment_config ) except ValueError as e: logger.warning( @@ -373,9 +372,7 @@ def get_location(location_id): visible_data = database.get_visible_data() meta_data = database.get_meta_data() - formatted_data = prepare_pin( - location.model_dump(), visible_data, meta_data, field_renderers - ) + formatted_data = prepare_pin(location.model_dump(), visible_data, meta_data, shortcodes) return jsonify(formatted_data) @core_api_blueprint.route("/version", methods=["GET"]) diff --git a/goodmap/formatter.py b/goodmap/formatter.py index bc8ddae5..d292ec6f 100644 --- a/goodmap/formatter.py +++ b/goodmap/formatter.py @@ -1,6 +1,5 @@ """Formatters for translating and preparing location data for display.""" -import base64 import logging from flask_babel import gettext, lazy_gettext @@ -25,47 +24,29 @@ def safe_gettext(text): return gettext(text) -def _apply_field_plugin(value, field, field_plugins): - """Wrap a dict field value with its plugin scope if a handler is registered. - - Returns: - The wrapped dict with scope if registered, None if the value is an - unconfigured plugin field, or the original value otherwise. - """ - if isinstance(value, dict): - if field in field_plugins: - result = {**value, "scope": field_plugins[field]} - if isinstance(result.get("code"), str): - result["code"] = base64.b64encode(result["code"].encode()).decode() - return result - if "code" in value and "type" not in value and "scope" not in value: - logger.debug("Dropping field '%s': unconfigured plugin data %s", field, value) - return None - return value - - -def prepare_pin(place, visible_fields, meta_data, field_plugins=None): +def prepare_pin(place, visible_fields, meta_data, shortcodes=None): """Prepare location data for map pin display with translations. Args: place: Location data dictionary visible_fields: List of field names to display in pin meta_data: List of metadata field names - field_plugins: Optional mapping of field name → plugin scope. Dict-valued - fields listed here are wrapped with ``{"scope": "", ...original_fields}`` - so the frontend can route them to the correct plugin component via ``PluginSlot``. + shortcodes: Optional mapping of field name → Shortcode instance. + When a field name matches a shortcode, its value is transformed via + ``shortcode.transform_field_value()`` before display. Returns: dict: Formatted pin data with title, subtitle, position, metadata, and translated fields """ - plugins = field_plugins or {} + plugins = shortcodes or {} data = [] for field in visible_fields: if field not in place: continue - processed = _apply_field_plugin(safe_gettext(place[field]), field, plugins) - if processed is not None: - data.append([gettext(field), processed]) + value = safe_gettext(place[field]) + if field in plugins: + value = plugins[field].transform_field_value(value) + data.append([gettext(field), value]) pin_data = { "title": place["name"], "subtitle": lazy_gettext(place["type_of_place"]), # TODO this should not be obligatory diff --git a/goodmap/goodmap.py b/goodmap/goodmap.py index 77fbc19e..0da28a6d 100644 --- a/goodmap/goodmap.py +++ b/goodmap/goodmap.py @@ -10,7 +10,6 @@ from flask_babel import gettext from flask_wtf.csrf import CSRFProtect, generate_csrf from platzky import platzky -from platzky.attachment import create_attachment_class from platzky.config import AttachmentConfig, languages_dict from platzky.models import CmsModule from pydantic import BaseModel @@ -149,10 +148,6 @@ def create_app_from_config(config: GoodmapConfig) -> platzky.Engine: app.extensions["goodmap"] = {"location_obligatory_fields": location_obligatory_fields} - field_renderers: dict[str, str] = {} - for sc_name in app.shortcodes: - field_renderers.setdefault(sc_name, sc_name) - plugin_manifest = [] for ep in importlib.metadata.entry_points(group=_PLUGIN_ENTRY_POINT_GROUP): bp, entry = _register_plugin_static_resources(ep) @@ -164,7 +159,6 @@ def create_app_from_config(config: GoodmapConfig) -> platzky.Engine: CSRFProtect(app) - # Create Attachment class for photo uploads # JPEG-only: universal browser/device support, good compression for location photos, # no transparency needed. PNG/WebP can be added if user demand warrants it. photo_attachment_config = AttachmentConfig( @@ -172,7 +166,6 @@ def create_app_from_config(config: GoodmapConfig) -> platzky.Engine: allowed_extensions=frozenset({"jpg", "jpeg"}), max_size=5 * 1024 * 1024, # 5MB - reasonable for location photos ) - PhotoAttachment = create_attachment_class(photo_attachment_config) cp = core_pages( app.db, @@ -180,10 +173,9 @@ def create_app_from_config(config: GoodmapConfig) -> platzky.Engine: app.notify, generate_csrf, location_model, - photo_attachment_class=PhotoAttachment, photo_attachment_config=photo_attachment_config, feature_flags=config.feature_flags, - field_renderers=field_renderers, + shortcodes=app.shortcodes, ) app.register_blueprint(cp) diff --git a/poetry.lock b/poetry.lock index 25207f62..777cabb5 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1999,14 +1999,14 @@ type = ["mypy (>=1.14.1)"] [[package]] name = "platzky" -version = "2.0.0a0" +version = "2.0.0a2" description = "Not only blog engine" optional = false python-versions = "<4.0,>=3.10" groups = ["main"] files = [ - {file = "platzky-2.0.0a0-py3-none-any.whl", hash = "sha256:b91f6df7be68dc766a73cc4125312a52561acf8e765461500d2007c8884c0bc7"}, - {file = "platzky-2.0.0a0.tar.gz", hash = "sha256:8d5cc9906941d9aa18fb22d5645d078490c6374666958735758555709e52f7d4"}, + {file = "platzky-2.0.0a2-py3-none-any.whl", hash = "sha256:39da542c56466152fb202cacc564b45a4daf66de5d6b0c44f0b4060fb2484a96"}, + {file = "platzky-2.0.0a2.tar.gz", hash = "sha256:5a09a424cf74f7678f3de7382cc09ffeaf98ce7a0bdd4e949a78f025ba1f2da8"}, ] [package.dependencies] @@ -4013,4 +4013,4 @@ docs = ["myst-parser", "sphinx", "sphinx-rtd-theme"] [metadata] lock-version = "2.1" python-versions = "^3.10" -content-hash = "3ec325f7f0366c95bda33620438e421520f395295ad81fbd0710da681187ad48" +content-hash = "18cd461f3f07235dac94fff3e8e282335289b4751f5ea4aa9766cce7859f6cfb" diff --git a/pyproject.toml b/pyproject.toml index 55e0a656..cbf80c40 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,7 +19,7 @@ Flask-WTF = "^1.2.1" gql = "^3.4.0" aiohttp = "^3.8.4" pydantic = "^2.12.0" -platzky = "2.0.0a0" +platzky = "2.0.0a2" deprecation = "^2.1.0" numpy = "^2.2.0" # Using fork because official PyPI version (0.7.7) has outdated numpy setup hack diff --git a/tests/unit_tests/test_core_api.py b/tests/unit_tests/test_core_api.py index dcc4601c..bcc9b99b 100644 --- a/tests/unit_tests/test_core_api.py +++ b/tests/unit_tests/test_core_api.py @@ -426,7 +426,9 @@ def test_suggest_new_location_with_multipart_form_data(test_app): def test_suggest_location_with_valid_jpeg_photo(test_app): """Valid JPEG photo upload should succeed.""" - with mock.patch("platzky.attachment.core.validate_content_mime_type", return_value=None): + with mock.patch( + "platzky.attachment.mime_validation.validate_content_mime_type", return_value=None + ): response = test_app.post( "/api/suggest-new-point", data={ @@ -521,7 +523,9 @@ def test_suggest_location_with_photo_stores_suggestion(test_app): db = test_app.application.db initial_count = len(db.get_suggestions({})) - with mock.patch("platzky.attachment.core.validate_content_mime_type", return_value=None): + with mock.patch( + "platzky.attachment.mime_validation.validate_content_mime_type", return_value=None + ): response = test_app.post( "/api/suggest-new-point", data={ diff --git a/tests/unit_tests/test_formatter.py b/tests/unit_tests/test_formatter.py index 0f4379bb..48d127d2 100644 --- a/tests/unit_tests/test_formatter.py +++ b/tests/unit_tests/test_formatter.py @@ -1,3 +1,5 @@ +from platzky.shortcodes.shortcode import Shortcode, ShortcodeAttrs + from goodmap.formatter import prepare_pin test_place = { @@ -12,28 +14,40 @@ } -def test_field_plugin_wraps_dict_value_with_scope(): - place = {**test_place, "promo_code": {"code": "SUMMER24", "text": "Get it", "color": "#f00"}} - result = prepare_pin(place, ["promo_code"], [], field_plugins={"promo_code": "promocode"}) +class _FakeShortcode(Shortcode): + """Minimal shortcode stub for formatter tests.""" + + name = "promo_code" + description = "test" + + def __init__(self, defaults=None): + self._defaults = defaults or {} + + def transform_field_value(self, value: object) -> dict[str, object]: + return {**self._defaults, "value": value, "scope": self.name} + + def render(self, attrs: ShortcodeAttrs, content: str) -> str: + return content + + +def test_field_plugin_transforms_value(): + place = {**test_place, "promo_code": "SAVE20"} + result = prepare_pin(place, ["promo_code"], [], shortcodes={"promo_code": _FakeShortcode()}) + assert result["data"] == [["promo_code", {"scope": "promo_code", "value": "SAVE20"}]] + + +def test_field_plugin_merges_defaults(): + place = {**test_place, "promo_code": "SAVE20"} + sc = _FakeShortcode(defaults={"color": "#4caf50", "text": "Reveal"}) + result = prepare_pin(place, ["promo_code"], [], shortcodes={"promo_code": sc}) assert result["data"] == [ [ "promo_code", - {"scope": "promocode", "code": "U1VNTUVSMjQ=", "text": "Get it", "color": "#f00"}, + {"scope": "promo_code", "value": "SAVE20", "color": "#4caf50", "text": "Reveal"}, ] ] -def test_field_plugin_ignores_non_dict_values(): - result = prepare_pin(test_place, ["plain_text"], [], field_plugins={"plain_text": "someplugin"}) - assert result["data"] == [["plain_text", "text"]] - - -def test_field_plugin_drops_unconfigured_dict_with_code(): - place = {**test_place, "promo_code": {"code": "HIDDEN"}} - result = prepare_pin(place, ["promo_code"], [], field_plugins={}) - assert result["data"] == [] - - def test_formatting_when_missing_visible_field(): visible_fields = ["types", "gender", "visible_without_data", "dict_data", "plain_text"] expected_data = {