Adding extra functionality for svg (rotate, color, ... )

pull/3103/head
jwaes 2025-02-24 19:36:41 +00:00
parent be5d3f3c26
commit 12e11b00c6
5 changed files with 289 additions and 32 deletions

View File

@ -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/<prefix>/<icon>.svg?param1=value1&param2=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
=========

View File

@ -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):
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/<string:prefix>/<string:icon>.svg",
type="http",
@ -115,9 +158,21 @@ class IconifyProxyController(http.Controller):
Returns:
Response: The HTTP response containing the SVG data.
"""
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(

View File

@ -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/<prefix>/<icon>.svg?param1=value1&param2=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`

View File

@ -377,16 +377,19 @@ ir.attachment model.</p>
<p><strong>Table of contents</strong></p>
<div class="contents local topic" id="contents">
<ul class="simple">
<li><a class="reference internal" href="#usage" id="toc-entry-1">Usage</a></li>
<li><a class="reference internal" href="#changelog" id="toc-entry-2">Changelog</a><ul>
<li><a class="reference internal" href="#section-1" id="toc-entry-3">18.0.1.0.0 (2025-02-23)</a></li>
<li><a class="reference internal" href="#usage" id="toc-entry-1">Usage</a><ul>
<li><a class="reference internal" href="#svg-icon-parameters" id="toc-entry-2">SVG Icon Parameters</a></li>
</ul>
</li>
<li><a class="reference internal" href="#bug-tracker" id="toc-entry-4">Bug Tracker</a></li>
<li><a class="reference internal" href="#credits" id="toc-entry-5">Credits</a><ul>
<li><a class="reference internal" href="#authors" id="toc-entry-6">Authors</a></li>
<li><a class="reference internal" href="#contributors" id="toc-entry-7">Contributors</a></li>
<li><a class="reference internal" href="#maintainers" id="toc-entry-8">Maintainers</a></li>
<li><a class="reference internal" href="#changelog" id="toc-entry-3">Changelog</a><ul>
<li><a class="reference internal" href="#section-1" id="toc-entry-4">18.0.1.0.0 (2025-02-23)</a></li>
</ul>
</li>
<li><a class="reference internal" href="#bug-tracker" id="toc-entry-5">Bug Tracker</a></li>
<li><a class="reference internal" href="#credits" id="toc-entry-6">Credits</a><ul>
<li><a class="reference internal" href="#authors" id="toc-entry-7">Authors</a></li>
<li><a class="reference internal" href="#contributors" id="toc-entry-8">Contributors</a></li>
<li><a class="reference internal" href="#maintainers" id="toc-entry-9">Maintainers</a></li>
</ul>
</li>
</ul>
@ -394,20 +397,42 @@ ir.attachment model.</p>
<div class="section" id="usage">
<h1><a class="toc-backref" href="#toc-entry-1">Usage</a></h1>
<p>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.</p>
will be served through the proxy and cached locally.</p>
<div class="section" id="svg-icon-parameters">
<h2><a class="toc-backref" href="#toc-entry-2">SVG Icon Parameters</a></h2>
<p>You can customize SVG icons by adding query parameters to the URL. The
format is:</p>
<p><tt class="docutils literal"><span class="pre">/web_iconify_proxy/&lt;prefix&gt;/&lt;icon&gt;.svg?param1=value1&amp;param2=value2...</span></tt></p>
<p>Available parameters:</p>
<ul class="simple">
<li><strong>color</strong>: Icon color (e.g., <tt class="docutils literal">color=red</tt>, <tt class="docutils literal"><span class="pre">color=%23ff0000</span></tt>).</li>
<li><strong>width</strong>: Icon width (e.g., <tt class="docutils literal">width=50</tt>, <tt class="docutils literal">width=50px</tt>).</li>
<li><strong>height</strong>: Icon height (e.g., <tt class="docutils literal">height=50</tt>, <tt class="docutils literal">height=50px</tt>). If
only one dimension is specified, the other will be calculated
automatically to maintain aspect ratio.</li>
<li><strong>flip</strong>: Flip the icon. Possible values: <tt class="docutils literal">horizontal</tt>,
<tt class="docutils literal">vertical</tt>, or both (e.g., <tt class="docutils literal">flip=horizontal</tt>, <tt class="docutils literal">flip=vertical</tt>,
<tt class="docutils literal">flip=horizontal,vertical</tt>).</li>
<li><strong>rotate</strong>: Rotate the icon by 90, 180, or 270 degrees (e.g.,
<tt class="docutils literal">rotate=90</tt>, <tt class="docutils literal">rotate=180</tt>).</li>
<li><strong>box</strong>: Set to <tt class="docutils literal">true</tt> to add an empty rectangle to the SVG that
matches the viewBox (e.g., <tt class="docutils literal">box=true</tt>).</li>
</ul>
<p>Example:</p>
<p><tt class="docutils literal"><span class="pre">/web_iconify_proxy/mdi/home.svg?color=blue&amp;width=64&amp;flip=horizontal</span></tt></p>
</div>
</div>
<div class="section" id="changelog">
<h1><a class="toc-backref" href="#toc-entry-2">Changelog</a></h1>
<h1><a class="toc-backref" href="#toc-entry-3">Changelog</a></h1>
<div class="section" id="section-1">
<h2><a class="toc-backref" href="#toc-entry-3">18.0.1.0.0 (2025-02-23)</a></h2>
<h2><a class="toc-backref" href="#toc-entry-4">18.0.1.0.0 (2025-02-23)</a></h2>
<ul class="simple">
<li>Initial version.</li>
</ul>
</div>
</div>
<div class="section" id="bug-tracker">
<h1><a class="toc-backref" href="#toc-entry-4">Bug Tracker</a></h1>
<h1><a class="toc-backref" href="#toc-entry-5">Bug Tracker</a></h1>
<p>Bugs are tracked on <a class="reference external" href="https://github.com/OCA/web/issues">GitHub Issues</a>.
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
<p>Do not contact contributors directly about support or help with technical issues.</p>
</div>
<div class="section" id="credits">
<h1><a class="toc-backref" href="#toc-entry-5">Credits</a></h1>
<h1><a class="toc-backref" href="#toc-entry-6">Credits</a></h1>
<div class="section" id="authors">
<h2><a class="toc-backref" href="#toc-entry-6">Authors</a></h2>
<h2><a class="toc-backref" href="#toc-entry-7">Authors</a></h2>
<ul class="simple">
<li>jaco.tech</li>
</ul>
</div>
<div class="section" id="contributors">
<h2><a class="toc-backref" href="#toc-entry-7">Contributors</a></h2>
<h2><a class="toc-backref" href="#toc-entry-8">Contributors</a></h2>
<ul class="simple">
<li>Jaco Waes &lt;<a class="reference external" href="mailto:jaco&#64;jaco.tech">jaco&#64;jaco.tech</a>&gt;</li>
</ul>
</div>
<div class="section" id="maintainers">
<h2><a class="toc-backref" href="#toc-entry-8">Maintainers</a></h2>
<h2><a class="toc-backref" href="#toc-entry-9">Maintainers</a></h2>
<p>This module is maintained by the OCA.</p>
<a class="reference external image-reference" href="https://odoo-community.org">
<img alt="Odoo Community Association" src="https://odoo-community.org/logo.png" />

View File

@ -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"<svg>dummy content</svg>"
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"<svg>dummy content</svg>"
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"<svg>dummy content</svg>"
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"<svg>dummy content</svg>"
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"<svg>dummy content</svg>"
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