Skip to content
Open
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
2 changes: 1 addition & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,7 @@ repos:
- --settings=.
exclude: /__init__\.py$
- repo: https://github.com/acsone/setuptools-odoo
rev: 3.1.8
rev: 3.3.2
hooks:
- id: setuptools-odoo-make-default
- id: setuptools-odoo-get-requirements
Expand Down
2 changes: 1 addition & 1 deletion currency_rate_update_xe/__manifest__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

{
"name": "Currency Rate Update: XE.com",
"version": "14.0.1.0.0",
"version": "14.0.1.0.1",
"category": "Financial Management/Configuration",
"summary": "Update exchange rates using XE.com",
"author": "Tecnativa, Odoo Community Association (OCA)",
Expand Down
20 changes: 18 additions & 2 deletions currency_rate_update_xe/models/res_currency_rate_provider_XE.py
Original file line number Diff line number Diff line change
Expand Up @@ -241,8 +241,16 @@ def _get_historical_rate(
"""Get all the exchange rates from 'date_from' to 'date_to'"""
content = {}
current_date = date_from
today = date.today()
while current_date <= date_to:
url = f"{base_url}/?from={base_currency}&date={current_date.strftime('%Y-%m-%d')}"
if current_date >= today:
# XE.com returns HTTP 404 for ``?date=YYYY-MM-DD`` when the
# requested date is today or in the future; fall back to the
# latest endpoint (same URL as ``_get_latest_rate``).
url = f"{base_url}/?from={base_currency}"
else:
day = current_date.strftime("%Y-%m-%d")
url = f"{base_url}/?from={base_currency}&date={day}"
data = self._request_data(url)
content[current_date] = self._parse_data(data, currencies)
current_date += timedelta(days=1)
Expand All @@ -253,7 +261,15 @@ def _request_data(
url,
):
try:
return requests.request("GET", url)
# XE.com returns HTTP 403 to requests without a User-Agent header.
response = requests.request(
"GET",
url,
timeout=10,
headers={"User-Agent": "Mozilla/5.0"},
)
response.raise_for_status()
return response
except Exception as e:
raise UserError(
_("Couldn't fetch data. Please contact your administrator.")
Expand Down
80 changes: 78 additions & 2 deletions currency_rate_update_xe/tests/test_currency_rate_update_xe.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,44 @@
# Copyright 2023 Tecnativa - Ernesto Tejeda
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html).

from unittest import mock

import requests
from dateutil.relativedelta import relativedelta

from odoo import fields
from odoo.exceptions import UserError
from odoo.tests import common

from ..models import res_currency_rate_provider_XE as xe_module

_PROVIDER = (
"odoo.addons.currency_rate_update_xe.models"
".res_currency_rate_provider_XE.ResCurrencyRateProviderXE"
)
_REQUEST_DATA = _PROVIDER + "._request_data"

# Minimal XE.com currency-table HTML matching the provider's xpath
# (//div[@id='table-section']//tbody/tr ; th = currency code ; td[2] = rate).
_XE_HTML = b"""<html><body>
<div id="table-section"><table><tbody>
<tr><th>USD</th><td>US Dollar</td><td>0.920000</td></tr>
<tr><th>GBP</th><td>British Pound</td><td>1.170000</td></tr>
</tbody></table></div>
</body></html>"""


class _FakeResponse:
"""Minimal stand-in for a ``requests`` response (no real network call)."""

def __init__(self, content=_XE_HTML, status_code=200):
self.content = content
self.status_code = status_code

def raise_for_status(self):
if self.status_code >= 400:
raise requests.exceptions.HTTPError(self.status_code)


class TestResCurrencyRateProviderXE(common.SavepointCase):
@classmethod
Expand Down Expand Up @@ -35,7 +69,12 @@ def setUpClass(cls):
cls.CurrencyRate.search([]).unlink()

def test_cron(self):
self.xe_provider._scheduled_update()
# Pretend the provider already ran yesterday so the scheduled update
# only fetches today's rate (via the latest endpoint). Hermetic: the
# HTTP layer is mocked, no real call to xe.com.
self.xe_provider.last_successful_run = self.today - relativedelta(days=1)
with mock.patch(_REQUEST_DATA, return_value=_FakeResponse()):
self.xe_provider._scheduled_update()
rates = self.CurrencyRate.search([])
self.assertEqual(len(rates), 1)
self.assertEqual(rates.currency_id, self.usd_currency)
Expand All @@ -46,7 +85,44 @@ def test_wizard(self):
.with_context(default_provider_ids=[(6, False, self.xe_provider.ids)])
.create({})
)
wizard.action_update()
with mock.patch(_REQUEST_DATA, return_value=_FakeResponse()):
wizard.action_update()
rates = self.CurrencyRate.search([])
self.assertEqual(len(rates), 1)
self.assertEqual(rates.currency_id, self.usd_currency)

def test_parse_data(self):
# The HTML table is parsed into a {currency: rate} mapping, filtered
# to the requested currencies only.
rates = self.xe_provider._parse_data(_FakeResponse(), ["USD"])
self.assertEqual(rates, {"USD": 0.92})

def test_today_falls_back_to_latest_endpoint(self):
# XE.com returns HTTP 404 for ``?date=<today>``; over a range that ends
# today, the provider must use the dated endpoint for past days and
# fall back to the latest endpoint (no ``date=``) for today.
captured = []

def _capture(url):
captured.append(url)
return _FakeResponse()

yesterday = self.today - relativedelta(days=1)
with mock.patch(_REQUEST_DATA, side_effect=_capture):
self.xe_provider._obtain_rates("EUR", ["USD"], yesterday, self.today)
self.assertEqual(len(captured), 2)
self.assertIn("date=", captured[0]) # past day -> dated endpoint
self.assertNotIn("date=", captured[1]) # today -> latest fallback

def test_http_error_raises_user_error(self):
# An HTTP error status (e.g. 403/404) surfaces as a clean UserError
# instead of a raw traceback.
with mock.patch.object(
xe_module.requests,
"request",
return_value=_FakeResponse(status_code=404),
):
with self.assertRaises(UserError):
self.xe_provider._request_data(
"http://www.xe.com/currencytables/?from=EUR"
)
Loading