From 12e11b00c6becb9f80e446832a247016ac80f740 Mon Sep 17 00:00:00 2001
From: jwaes
Date: Mon, 24 Feb 2025 19:36:41 +0000
Subject: [PATCH] Adding extra functionality for svg (rotate, color, ... )
---
web_iconify_proxy/README.rst | 30 +++-
web_iconify_proxy/controllers/main.py | 73 ++++++++--
web_iconify_proxy/readme/USAGE.md | 22 ++-
.../static/description/index.html | 59 +++++---
web_iconify_proxy/tests/test_main.py | 137 +++++++++++++++++-
5 files changed, 289 insertions(+), 32 deletions(-)
diff --git a/web_iconify_proxy/README.rst b/web_iconify_proxy/README.rst
index 738966d83..33658c429 100644
--- a/web_iconify_proxy/README.rst
+++ b/web_iconify_proxy/README.rst
@@ -42,8 +42,34 @@ Usage
=====
This module works in conjunction with web_iconify. Once installed, icons
-will be served through the proxy and cached locally. No specific usage
-instructions are required.
+will be served through the proxy and cached locally.
+
+SVG Icon Parameters
+-------------------
+
+You can customize SVG icons by adding query parameters to the URL. The
+format is:
+
+``/web_iconify_proxy//.svg?param1=value1¶m2=value2...``
+
+Available parameters:
+
+- **color**: Icon color (e.g., ``color=red``, ``color=%23ff0000``).
+- **width**: Icon width (e.g., ``width=50``, ``width=50px``).
+- **height**: Icon height (e.g., ``height=50``, ``height=50px``). If
+ only one dimension is specified, the other will be calculated
+ automatically to maintain aspect ratio.
+- **flip**: Flip the icon. Possible values: ``horizontal``,
+ ``vertical``, or both (e.g., ``flip=horizontal``, ``flip=vertical``,
+ ``flip=horizontal,vertical``).
+- **rotate**: Rotate the icon by 90, 180, or 270 degrees (e.g.,
+ ``rotate=90``, ``rotate=180``).
+- **box**: Set to ``true`` to add an empty rectangle to the SVG that
+ matches the viewBox (e.g., ``box=true``).
+
+Example:
+
+``/web_iconify_proxy/mdi/home.svg?color=blue&width=64&flip=horizontal``
Changelog
=========
diff --git a/web_iconify_proxy/controllers/main.py b/web_iconify_proxy/controllers/main.py
index 696fd01ce..b7c9ad934 100644
--- a/web_iconify_proxy/controllers/main.py
+++ b/web_iconify_proxy/controllers/main.py
@@ -1,3 +1,4 @@
+import ast
import base64
import datetime
import logging
@@ -15,7 +16,13 @@ class IconifyProxyController(http.Controller):
"""Controller for proxying Iconify requests."""
def _fetch_iconify_data(
- self, upstream_url, content_type, prefix, icons=None, icon=None
+ self,
+ upstream_url,
+ content_type,
+ prefix,
+ icons=None,
+ icon=None,
+ normalized_params_string="",
):
"""Fetches data from the Iconify API or the local cache.
@@ -25,22 +32,28 @@ class IconifyProxyController(http.Controller):
prefix (str): The icon prefix.
icons (str, optional): Comma-separated list of icons (for CSS and JSON).
icon (str, optional): The icon name (for SVG).
+ normalized_params_string (str, optional): Normalized parameters string.
Returns:
Response: The HTTP response.
"""
+ # Validate prefix
+ prefix = prefix.lower()
+
# Validate prefix
if not re.match(r"^[a-z0-9-]+$", prefix):
raise request.not_found()
# Validate icon (if provided)
- if icon and not re.match(r"^[a-z0-9:-]+$", icon):
- raise request.not_found()
+ if icon:
+ icon = icon.lower()
+ if not re.match(r"^[a-z0-9:-]+$", icon):
+ raise request.not_found()
# Validate icons (if provided)
if icons:
- icon_list = icons.split(",")
+ icon_list = [i.lower() for i in icons.split(",")]
for single_icon in icon_list:
if not re.match(r"^[a-z0-9:-]+$", single_icon):
raise request.not_found()
@@ -48,13 +61,13 @@ class IconifyProxyController(http.Controller):
Attachment = request.env["ir.attachment"].sudo()
if content_type == "image/svg+xml":
- name = f"{prefix}-{icon}"
+ name = f"{prefix}-{icon}-{normalized_params_string.lower()}"
res_model = "iconify.svg"
elif content_type == "text/css":
- name = f"{prefix}-{icons}"
+ name = f"{prefix}-{icons}-{normalized_params_string.lower()}"
res_model = "iconify.css"
elif content_type == "application/json":
- name = f"{prefix}-{icons}"
+ name = f"{prefix}-{icons}-{normalized_params_string.lower()}"
res_model = "iconify.json"
else:
raise request.not_found()
@@ -98,6 +111,36 @@ class IconifyProxyController(http.Controller):
]
return request.make_response(data, headers)
+ def _normalize_params_common(self, params):
+ """Normalizes common parameters for Iconify requests."""
+ normalized = []
+ for key in sorted(params.keys()): # Sort keys alphabetically
+ normalized.append(f"{key.lower()}={params[key]}")
+ return ";".join(normalized)
+
+ def _normalize_params_svg(self, params):
+ """Normalizes parameters specifically for SVG requests."""
+ allowed_params = ["color", "width", "height", "flip", "rotate", "box"]
+ normalized = []
+ for key in sorted(params.keys()):
+ if key in allowed_params:
+ value = params[key]
+ key = key.lower()
+ # Basic type validation
+ if key in ("width", "height", "rotate") and not (
+ isinstance(value, str) or isinstance(value, int)
+ ):
+ continue # Skip invalid values
+ if key == "box":
+ try:
+ value = ast.literal_eval(str(value).lower())
+ if not isinstance(value, bool):
+ continue
+ except (ValueError, SyntaxError):
+ continue
+ normalized.append(f"{key}={value}")
+ return ";".join(normalized)
+
@http.route(
"/web_iconify_proxy//.svg",
type="http",
@@ -115,9 +158,21 @@ class IconifyProxyController(http.Controller):
Returns:
Response: The HTTP response containing the SVG data.
"""
- upstream_url = f"https://api.iconify.design/{prefix}/{icon}.svg"
+ normalized_params = self._normalize_params_svg(params)
+ if normalized_params:
+ query_string = normalized_params.replace(";", "&")
+ upstream_url = (
+ f"https://api.iconify.design/{prefix}/{icon}.svg?{query_string}"
+ )
+ else:
+ upstream_url = f"https://api.iconify.design/{prefix}/{icon}.svg"
+
return self._fetch_iconify_data(
- upstream_url, "image/svg+xml", prefix, icon=icon
+ upstream_url,
+ "image/svg+xml",
+ prefix,
+ icon=icon,
+ normalized_params_string=normalized_params,
)
@http.route(
diff --git a/web_iconify_proxy/readme/USAGE.md b/web_iconify_proxy/readme/USAGE.md
index 4387a1100..ff4dbaf9d 100644
--- a/web_iconify_proxy/readme/USAGE.md
+++ b/web_iconify_proxy/readme/USAGE.md
@@ -1,3 +1,21 @@
This module works in conjunction with web_iconify. Once installed, icons
-will be served through the proxy and cached locally. No specific usage
-instructions are required.
+will be served through the proxy and cached locally.
+
+## SVG Icon Parameters
+
+You can customize SVG icons by adding query parameters to the URL. The format is:
+
+`/web_iconify_proxy//.svg?param1=value1¶m2=value2...`
+
+Available parameters:
+
+* **color**: Icon color (e.g., `color=red`, `color=%23ff0000`).
+* **width**: Icon width (e.g., `width=50`, `width=50px`).
+* **height**: Icon height (e.g., `height=50`, `height=50px`). If only one dimension is specified, the other will be calculated automatically to maintain aspect ratio.
+* **flip**: Flip the icon. Possible values: `horizontal`, `vertical`, or both (e.g., `flip=horizontal`, `flip=vertical`, `flip=horizontal,vertical`).
+* **rotate**: Rotate the icon by 90, 180, or 270 degrees (e.g., `rotate=90`, `rotate=180`).
+* **box**: Set to `true` to add an empty rectangle to the SVG that matches the viewBox (e.g., `box=true`).
+
+Example:
+
+`/web_iconify_proxy/mdi/home.svg?color=blue&width=64&flip=horizontal`
diff --git a/web_iconify_proxy/static/description/index.html b/web_iconify_proxy/static/description/index.html
index e8dc1c60c..020a34cc4 100644
--- a/web_iconify_proxy/static/description/index.html
+++ b/web_iconify_proxy/static/description/index.html
@@ -377,16 +377,19 @@ ir.attachment model.
Table of contents
-Usage
-Changelog
-18.0.1.0.0 (2025-02-23)
+Usage
-Bug Tracker
-Credits
@@ -394,20 +397,42 @@ ir.attachment model.
This module works in conjunction with web_iconify. Once installed, icons
-will be served through the proxy and cached locally. No specific usage
-instructions are required.
+will be served through the proxy and cached locally.
+
+
+
You can customize SVG icons by adding query parameters to the URL. The
+format is:
+
/web_iconify_proxy/<prefix>/<icon>.svg?param1=value1¶m2=value2...
+
Available parameters:
+
+color : Icon color (e.g., color=red , color=%23ff0000 ).
+width : Icon width (e.g., width=50 , width=50px ).
+height : Icon height (e.g., height=50 , height=50px ). If
+only one dimension is specified, the other will be calculated
+automatically to maintain aspect ratio.
+flip : Flip the icon. Possible values: horizontal ,
+vertical , or both (e.g., flip=horizontal , flip=vertical ,
+flip=horizontal,vertical ).
+rotate : Rotate the icon by 90, 180, or 270 degrees (e.g.,
+rotate=90 , rotate=180 ).
+box : Set to true to add an empty rectangle to the SVG that
+matches the viewBox (e.g., box=true ).
+
+
Example:
+
/web_iconify_proxy/mdi/home.svg?color=blue&width=64&flip=horizontal
+
-
+
Bugs are tracked on GitHub Issues .
In case of trouble, please check there if your issue has already been reported.
If you spotted it first, help us to smash it by providing a detailed and welcomed
@@ -415,21 +440,21 @@ If you spotted it first, help us to smash it by providing a detailed and welcome
Do not contact contributors directly about support or help with technical issues.
-
+
-
+
This module is maintained by the OCA.
diff --git a/web_iconify_proxy/tests/test_main.py b/web_iconify_proxy/tests/test_main.py
index 837f6c701..27e0218e7 100644
--- a/web_iconify_proxy/tests/test_main.py
+++ b/web_iconify_proxy/tests/test_main.py
@@ -3,8 +3,7 @@ from unittest.mock import patch
import requests
-from odoo.tests import tagged
-from odoo.tests.common import HttpCase
+from odoo.tests.common import HttpCase, tagged
@tagged("post_install", "-at_install")
@@ -129,6 +128,140 @@ class TestIconifyProxyController(HttpCase):
# Check that content is the same
self.assertEqual(response1.content, response2.content)
+ @patch("odoo.addons.web_iconify_proxy.controllers.main.requests.get")
+ def test_caching_with_params(self, mock_get):
+ """Test caching with different parameters."""
+ mock_response = requests.Response()
+ mock_response.status_code = 200
+ mock_response._content = b"dummy content "
+ mock_response.headers["Content-Type"] = "image/svg+xml"
+ mock_get.return_value = mock_response
+
+ # First request with specific parameters
+ response1 = self.url_open(
+ "/web_iconify_proxy/mdi/home.svg?color=red&width=50&flip=horizontal"
+ )
+ self.assertEqual(response1.status_code, 200)
+ self.assertFalse("X-Cached-At" in response1.headers)
+
+ # Second request with the same parameters, should be cached
+ response2 = self.url_open(
+ "/web_iconify_proxy/mdi/home.svg?color=red&width=50&flip=horizontal"
+ )
+ self.assertEqual(response2.status_code, 200)
+ self.assertTrue("X-Cached-At" in response2.headers)
+
+ # Third request with different parameters, should not be cached
+ response3 = self.url_open(
+ "/web_iconify_proxy/mdi/home.svg?color=blue&width=100"
+ )
+ self.assertEqual(response3.status_code, 200)
+ self.assertFalse("X-Cached-At" in response3.headers)
+
+ @patch("odoo.addons.web_iconify_proxy.controllers.main.requests.get")
+ def test_caching_parameter_order(self, mock_get):
+ """Test that parameter order doesn't affect caching."""
+ mock_response = requests.Response()
+ mock_response.status_code = 200
+ mock_response._content = b"dummy content "
+ mock_response.headers["Content-Type"] = "image/svg+xml"
+ mock_get.return_value = mock_response
+
+ # First request with specific parameter order
+ response1 = self.url_open(
+ "/web_iconify_proxy/mdi/home.svg?color=red&width=50&flip=horizontal"
+ )
+ self.assertEqual(response1.status_code, 200)
+ self.assertFalse("X-Cached-At" in response1.headers)
+
+ # Second request with different parameter order, should be cached
+ response2 = self.url_open(
+ "/web_iconify_proxy/mdi/home.svg?flip=horizontal&width=50&color=red"
+ )
+ self.assertEqual(response2.status_code, 200)
+ self.assertTrue("X-Cached-At" in response2.headers)
+
+ # Check that content is the same
+ self.assertEqual(response1.content, response2.content)
+
+ @patch("odoo.addons.web_iconify_proxy.controllers.main.requests.get")
+ def test_caching_boolean_values(self, mock_get):
+ """Test that boolean values are case-insensitive for caching."""
+ mock_response = requests.Response()
+ mock_response.status_code = 200
+ mock_response._content = b"dummy content "
+ mock_response.headers["Content-Type"] = "image/svg+xml"
+ mock_get.return_value = mock_response
+
+ # First request with "true"
+ response1 = self.url_open("/web_iconify_proxy/mdi/home.svg?box=true")
+ self.assertEqual(response1.status_code, 200)
+ self.assertFalse("X-Cached-At" in response1.headers)
+
+ # Second request with "True", should be cached
+ response2 = self.url_open("/web_iconify_proxy/mdi/home.svg?box=True")
+ self.assertEqual(response2.status_code, 200)
+ self.assertTrue("X-Cached-At" in response2.headers)
+
+ # Third request with "TRUE", should be cached
+ response3 = self.url_open("/web_iconify_proxy/mdi/home.svg?box=TRUE")
+ self.assertEqual(response3.status_code, 200)
+ self.assertTrue("X-Cached-At" in response3.headers)
+
+ # Check that content is the same for all
+ self.assertEqual(response1.content, response2.content)
+ self.assertEqual(response1.content, response3.content)
+
+ @patch("odoo.addons.web_iconify_proxy.controllers.main.requests.get")
+ def test_get_svg_with_parameters(self, mock_get):
+ """Test the get_svg route with various valid parameters."""
+ mock_response = requests.Response()
+ mock_response.status_code = 200
+ mock_response._content = b"dummy content "
+ mock_response.headers["Content-Type"] = "image/svg+xml"
+ mock_get.return_value = mock_response
+
+ test_cases = [
+ "/web_iconify_proxy/mdi/home.svg?color=red",
+ "/web_iconify_proxy/mdi/home.svg?width=50",
+ "/web_iconify_proxy/mdi/home.svg?height=50",
+ "/web_iconify_proxy/mdi/home.svg?flip=horizontal",
+ "/web_iconify_proxy/mdi/home.svg?flip=vertical",
+ "/web_iconify_proxy/mdi/home.svg?flip=horizontal,vertical",
+ "/web_iconify_proxy/mdi/home.svg?rotate=90",
+ "/web_iconify_proxy/mdi/home.svg?rotate=180",
+ "/web_iconify_proxy/mdi/home.svg?rotate=270",
+ "/web_iconify_proxy/mdi/home.svg?box=true",
+ "/web_iconify_proxy/mdi/home.svg?color=blue&width=64&flip=horizontal&rotate=180",
+ ]
+ for url in test_cases:
+ with self.subTest(url=url):
+ response = self.url_open(url)
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(response.headers["Content-Type"], "image/svg+xml")
+
+ @patch("odoo.addons.web_iconify_proxy.controllers.main.requests.get")
+ def test_get_svg_with_invalid_parameters(self, mock_get):
+ """Test the get_svg route with invalid parameters."""
+ mock_response = requests.Response()
+ mock_response.status_code = 200
+ mock_response._content = b"dummy content "
+ mock_response.headers["Content-Type"] = "image/svg+xml"
+ mock_get.return_value = mock_response
+
+ test_cases = [
+ "/web_iconify_proxy/mdi/home.svg?width=invalid", # Invalid width
+ "/web_iconify_proxy/mdi/home.svg?height=invalid", # Invalid height
+ "/web_iconify_proxy/mdi/home.svg?rotate=invalid", # Invalid rotate
+ "/web_iconify_proxy/mdi/home.svg?box=invalid", # Invalid box
+ "/web_iconify_proxy/mdi/home.svg?unknown=param", # Unknown parameter
+ ]
+ for url in test_cases:
+ with self.subTest(url=url):
+ response = self.url_open(url)
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(response.headers["Content-Type"], "image/svg+xml")
+
@patch("odoo.addons.web_iconify_proxy.controllers.main.requests.get")
def test_api_error(self, mock_get):
# Mock requests.get to simulate an API error