Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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'
15 changes: 6 additions & 9 deletions goodmap/core_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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")

Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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"])
Expand Down
37 changes: 9 additions & 28 deletions goodmap/formatter.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
"""Formatters for translating and preparing location data for display."""

import base64
import logging

from flask_babel import gettext, lazy_gettext
Expand All @@ -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": "<name>", ...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
Expand Down
10 changes: 1 addition & 9 deletions goodmap/goodmap.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -164,26 +159,23 @@ 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(
allowed_mime_types=frozenset({"image/jpeg"}),
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,
languages_dict(config.languages),
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)

Expand Down
8 changes: 4 additions & 4 deletions poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 6 additions & 2 deletions tests/unit_tests/test_core_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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={
Expand Down Expand Up @@ -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={
Expand Down
44 changes: 29 additions & 15 deletions tests/unit_tests/test_formatter.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from platzky.shortcodes.shortcode import Shortcode, ShortcodeAttrs

from goodmap.formatter import prepare_pin

test_place = {
Expand All @@ -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 = {
Expand Down
Loading