[ADD] migration: browse the drifted COW copies full screen
The text report puts a thousand lines between the two things that decide: what the copy holds that the module view does not — all a reset gives up — and which child no longer finds its anchor, which is why anything breaks. Space switches between them, « c » copies the reset command. Offered as [4] on the error prompt, next to check and reset, and run on the real terminal: a full screen started through the capturing executor falls back to the text report with nothing to tell the two apart. The diff is rendered in one place, shared with the command line. Two renderings would drift without anything saying so. --- FR --- [ADD] migration : parcourir les copies COW dérivées en plein écran Le rapport texte met mille lignes entre les deux choses qui décident : ce que la copie porte et que la vue module n'a pas — tout ce qu'une réinitialisation abandonne — et quel enfant ne trouve plus son ancrage, la raison pour laquelle quoi que ce soit casse. L'espace bascule, « c » copie la commande de réparation. Proposé en [4] à l'invite d'erreur, à côté de vérifier et réinitialiser, et lancé sur le vrai terminal : un plein écran passé par l'exécuteur qui capture retombe sur le rapport texte, sans rien qui les distingue. Le diff est rendu à un seul endroit, partagé avec la ligne de commande. Deux rendus dériveraient sans que rien ne le signale. Assisted-by: Claude Opus 5
This commit is contained in:
parent
2eac9e9848
commit
95ffbe6f13
5 changed files with 475 additions and 4 deletions
198
script/odoo/migration/reset_stale_cow_tui.py
Executable file
198
script/odoo/migration/reset_stale_cow_tui.py
Executable file
|
|
@ -0,0 +1,198 @@
|
|||
#!/usr/bin/env python3
|
||||
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
|
||||
|
||||
"""Full-screen browsing of the COW copies that drifted from their module view.
|
||||
|
||||
Two things decide whether to reset a copy, and the text report puts them a
|
||||
thousand lines apart: what the copy holds that the module view does not — the
|
||||
only thing a reset gives up — and which child no longer finds its anchor,
|
||||
which is why anything breaks at all. Space switches between them.
|
||||
|
||||
The reset command is on « c »: it takes a key, and copying a key by hand out
|
||||
of a scrolled diff is where a character goes missing. A key matching no copy
|
||||
is not an error for the tool — it runs and does nothing.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
sys.path.append(
|
||||
os.path.normpath(os.path.join(os.path.dirname(__file__), "..", "..", ".."))
|
||||
)
|
||||
|
||||
from reset_stale_cow_views import render_broken, render_diff # noqa: E402
|
||||
|
||||
try:
|
||||
from script.todo.todo_i18n import t
|
||||
except Exception: # pragma: no cover - repli si i18n indisponible
|
||||
|
||||
def t(key: str) -> str:
|
||||
return key
|
||||
|
||||
|
||||
CSS = """
|
||||
Screen { layout: vertical; }
|
||||
#head { height: 3; padding: 0 1; background: $panel; color: $text; }
|
||||
#body { height: 1fr; }
|
||||
#copies { width: 44; border-right: solid $accent; }
|
||||
#pane { width: 1fr; padding: 0 1; }
|
||||
"""
|
||||
|
||||
|
||||
def weight(cow_view, module_view):
|
||||
"""Combien la copie s'écarte, pour trier d'un coup d'œil."""
|
||||
if not module_view:
|
||||
return "—"
|
||||
import difflib
|
||||
|
||||
diff = list(
|
||||
difflib.unified_diff(
|
||||
module_view["arch"].splitlines(),
|
||||
cow_view["arch"].splitlines(),
|
||||
lineterm="",
|
||||
n=0,
|
||||
)
|
||||
)
|
||||
plus = sum(1 for x in diff if x[:1] == "+" and not x.startswith("+++"))
|
||||
minus = sum(1 for x in diff if x[:1] == "-" and not x.startswith("---"))
|
||||
return f"+{plus}/-{minus}"
|
||||
|
||||
|
||||
def build_app(lst_finding, database):
|
||||
"""Build the application. Textual is imported here, not at module level.
|
||||
|
||||
The module stays importable — and therefore testable — on a machine
|
||||
without Textual, which is also what lets the caller fall back to the text
|
||||
report rather than fail.
|
||||
"""
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.containers import Horizontal, VerticalScroll
|
||||
from textual.widgets import DataTable, Footer, Header, Static
|
||||
|
||||
class StaleCowApp(App):
|
||||
CSS = globals()["CSS"]
|
||||
BINDINGS = [
|
||||
("q,escape", "quit", "Quit"),
|
||||
("space,tab", "toggle", "Diff / why"),
|
||||
("c", "copy", "Copy reset command"),
|
||||
]
|
||||
|
||||
def __init__(self, lst_finding, database):
|
||||
super().__init__()
|
||||
self.lst_finding = lst_finding
|
||||
self.database = database
|
||||
self.why = False
|
||||
self.index = 0
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Header()
|
||||
yield Static("", id="head")
|
||||
with Horizontal(id="body"):
|
||||
yield DataTable(id="copies", cursor_type="row")
|
||||
with VerticalScroll(id="pane"):
|
||||
yield Static("", id="content")
|
||||
yield Footer()
|
||||
|
||||
def on_mount(self):
|
||||
self.title = "COW copies drifted from their module view"
|
||||
table = self.query_one("#copies", DataTable)
|
||||
table.add_columns("copy", "+/-", "broken")
|
||||
for cow_view, module_view, broken in self.lst_finding:
|
||||
table.add_row(
|
||||
(cow_view["key"] or str(cow_view["id"]))[:30],
|
||||
weight(cow_view, module_view),
|
||||
str(len(broken)),
|
||||
key=str(cow_view["id"]),
|
||||
)
|
||||
self._show()
|
||||
|
||||
def _show(self):
|
||||
if not self.lst_finding:
|
||||
return
|
||||
cow_view, module_view, broken = self.lst_finding[self.index]
|
||||
which = "why it breaks" if self.why else "what the copy holds"
|
||||
self.query_one("#head", Static).update(
|
||||
f"{cow_view['key']} · id={cow_view['id']}"
|
||||
f" · website={cow_view.get('website_id')}\n"
|
||||
f"[{which}] — space to switch, c to copy the reset command"
|
||||
)
|
||||
if self.why:
|
||||
text = render_broken(cow_view, module_view, broken)
|
||||
elif module_view:
|
||||
text = render_diff(module_view, cow_view, indent=" ")
|
||||
else:
|
||||
text = (
|
||||
f" {t('No module view carries this key: nothing to reset')}"
|
||||
f" {t('onto.')}"
|
||||
)
|
||||
self.query_one("#content", Static).update(text)
|
||||
|
||||
def on_data_table_row_highlighted(self, event):
|
||||
if event.data_table.id == "copies" and self.lst_finding:
|
||||
self.index = event.cursor_row
|
||||
self._show()
|
||||
|
||||
def action_toggle(self):
|
||||
self.why = not self.why
|
||||
self._show()
|
||||
|
||||
def action_copy(self):
|
||||
"""Mettre la commande de réinitialisation dans le presse-papier.
|
||||
|
||||
Elle prend une CLÉ, et recopier une clé à la main depuis un diff
|
||||
défilé est l'endroit où un caractère se perd — l'outil ne dirait
|
||||
rien, il ne trouverait simplement aucune copie.
|
||||
"""
|
||||
cow_view = self.lst_finding[self.index][0]
|
||||
command = (
|
||||
"./script/odoo/migration/reset_stale_cow_views.py"
|
||||
f" -d {self.database} --reset {cow_view['key']} --apply"
|
||||
)
|
||||
try:
|
||||
self.copy_to_clipboard(command)
|
||||
self.notify(t("Reset command copied."))
|
||||
except Exception:
|
||||
self.notify(command, timeout=20)
|
||||
|
||||
return StaleCowApp(lst_finding, database)
|
||||
|
||||
|
||||
def run_tui(lst_finding, database, run_app=True):
|
||||
"""Ouvrir l'écran. False si on n'a pas pu — et alors on DIT pourquoi.
|
||||
|
||||
Trois refus, trois raisons, aucune n'est une panne : rien à montrer, pas
|
||||
de terminal, ou Textual absent. Se taire ferait réafficher le rapport
|
||||
texte à la place de l'écran demandé, sans rien qui distingue les deux.
|
||||
"""
|
||||
if not lst_finding:
|
||||
return False
|
||||
if not sys.stdout.isatty():
|
||||
print(f"ℹ️ {t('Not a terminal: showing the text report instead.')}")
|
||||
return False
|
||||
try:
|
||||
from script.todo import textual_setup
|
||||
except Exception:
|
||||
textual_setup = None
|
||||
if textual_setup and not textual_setup.available():
|
||||
if not textual_setup.in_venv():
|
||||
print(
|
||||
f"ℹ️ {t('Textual is missing from this interpreter:')}"
|
||||
f" {sys.executable}"
|
||||
)
|
||||
print(f" {t('Run it with')} .venv.erplibre/bin/python3")
|
||||
if textual_setup and not textual_setup.ensure():
|
||||
return False
|
||||
try:
|
||||
app = build_app(lst_finding, database)
|
||||
except ImportError:
|
||||
print(
|
||||
f"ℹ️ {t('Textual is missing from this interpreter:')}"
|
||||
f" {sys.executable}"
|
||||
)
|
||||
return False
|
||||
if not run_app:
|
||||
return app
|
||||
app.run()
|
||||
return True
|
||||
|
|
@ -248,8 +248,13 @@ def find_copy_by_key(views, key):
|
|||
return copy, twin
|
||||
|
||||
|
||||
def show_diff(module_view, cow_view):
|
||||
"""Module arch vs copy: what the copy would gain and lose on a reset."""
|
||||
def render_diff(module_view, cow_view, indent=" "):
|
||||
"""Module arch vs copy : ce qu'une réinitialisation rend et abandonne.
|
||||
|
||||
Rendu en texte plutôt qu'imprimé : la TUI montre exactement le même
|
||||
diff que la ligne de commande. Deux rendus séparés dériveraient sans
|
||||
que rien ne le signale.
|
||||
"""
|
||||
diff = difflib.unified_diff(
|
||||
module_view["arch"].splitlines(),
|
||||
cow_view["arch"].splitlines(),
|
||||
|
|
@ -257,8 +262,44 @@ def show_diff(module_view, cow_view):
|
|||
tofile=f"cow id={cow_view['id']}",
|
||||
lineterm="",
|
||||
)
|
||||
for line in diff:
|
||||
print(f" {line}")
|
||||
return "\n".join(f"{indent}{line}" for line in diff)
|
||||
|
||||
|
||||
def render_broken(cow_view, module_view, broken):
|
||||
"""Pourquoi ça casse : les enfants dont l'xpath ne trouve plus son point."""
|
||||
lines = [
|
||||
f"── id={cow_view['id']} {cow_view['key']}"
|
||||
f" (website={cow_view.get('website_id')}) ──",
|
||||
"",
|
||||
]
|
||||
if not broken:
|
||||
lines.append(
|
||||
f" {t('No child fails on this copy: it drifted without')}"
|
||||
f" {t('breaking anything yet.')}"
|
||||
)
|
||||
else:
|
||||
lines.append(
|
||||
f" {t('These children no longer find their anchor in the copy')}"
|
||||
" :"
|
||||
)
|
||||
for child_id, expr in broken:
|
||||
lines.append(f" {t('child')} {child_id} : {expr}")
|
||||
lines += [
|
||||
"",
|
||||
f" {t('The anchor exists in the module view')}"
|
||||
f" (id={module_view['id'] if module_view else '?'})"
|
||||
f" {t('but not in this copy, frozen on an older version.')}",
|
||||
"",
|
||||
f" {t('Resetting restores the module arch; the customization the')}",
|
||||
f" {t('copy carried is saved first, and is yours to re-apply as an')}",
|
||||
f" {t('INHERITING view with its own key.')}",
|
||||
]
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def show_diff(module_view, cow_view):
|
||||
"""Imprimer le diff, pour la ligne de commande."""
|
||||
print(render_diff(module_view, cow_view))
|
||||
|
||||
|
||||
def backup(database, cow_view, directory):
|
||||
|
|
@ -304,6 +345,11 @@ def main():
|
|||
)
|
||||
)
|
||||
parser.add_argument("-d", "--database", required=True)
|
||||
parser.add_argument(
|
||||
"--tui",
|
||||
action="store_true",
|
||||
help="browse the differences full screen",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--list-keys",
|
||||
action="store_true",
|
||||
|
|
@ -381,6 +427,13 @@ def main():
|
|||
)
|
||||
return 1
|
||||
|
||||
if config.tui and findings:
|
||||
from reset_stale_cow_tui import run_tui
|
||||
|
||||
# False n'est pas un échec : l'écran a dit pourquoi, et le rapport
|
||||
# texte ci-dessus porte déjà la même information.
|
||||
run_tui(findings, config.database)
|
||||
|
||||
wanted = set(config.reset)
|
||||
directory = config.backup_dir or os.path.join(
|
||||
"private", "odoo", "migration", config.database, "cow_reset"
|
||||
|
|
|
|||
|
|
@ -5094,6 +5094,50 @@ TRANSLATIONS = {
|
|||
"fr": "après — et ce ne sera pas la migration.",
|
||||
"en": "after — and that is not the migration.",
|
||||
},
|
||||
"No child fails on this copy: it drifted without": {
|
||||
"fr": "Aucun enfant n'échoue sur cette copie : elle a dérivé sans",
|
||||
"en": "No child fails on this copy: it drifted without",
|
||||
},
|
||||
"breaking anything yet.": {
|
||||
"fr": "encore rien casser.",
|
||||
"en": "breaking anything yet.",
|
||||
},
|
||||
"These children no longer find their anchor in the copy": {
|
||||
"fr": "Ces enfants ne trouvent plus leur point d'ancrage dans la copie",
|
||||
"en": "These children no longer find their anchor in the copy",
|
||||
},
|
||||
"The anchor exists in the module view": {
|
||||
"fr": "L'ancrage existe dans la vue module",
|
||||
"en": "The anchor exists in the module view",
|
||||
},
|
||||
"but not in this copy, frozen on an older version.": {
|
||||
"fr": "mais pas dans cette copie, figée sur une version antérieure.",
|
||||
"en": "but not in this copy, frozen on an older version.",
|
||||
},
|
||||
"Resetting restores the module arch; the customization the": {
|
||||
"fr": "Réinitialiser rend l'arch du module ; la personnalisation que",
|
||||
"en": "Resetting restores the module arch; the customization the",
|
||||
},
|
||||
"copy carried is saved first, and is yours to re-apply as an": {
|
||||
"fr": "la copie portait est sauvegardée d'abord, à vous de la réappliquer en",
|
||||
"en": "copy carried is saved first, and is yours to re-apply as an",
|
||||
},
|
||||
"INHERITING view with its own key.": {
|
||||
"fr": "vue HÉRITANTE avec sa propre clé.",
|
||||
"en": "INHERITING view with its own key.",
|
||||
},
|
||||
"No module view carries this key: nothing to reset": {
|
||||
"fr": "Aucune vue module ne porte cette clé : rien sur quoi",
|
||||
"en": "No module view carries this key: nothing to reset",
|
||||
},
|
||||
"onto.": {
|
||||
"fr": "réinitialiser.",
|
||||
"en": "onto.",
|
||||
},
|
||||
"Browse the differences full screen": {
|
||||
"fr": "Parcourir les différences en plein écran",
|
||||
"en": "Browse the differences full screen",
|
||||
},
|
||||
"Nothing to decide yet": {
|
||||
"fr": "Rien à décider pour l'instant",
|
||||
"en": "Nothing to decide yet",
|
||||
|
|
|
|||
|
|
@ -3412,6 +3412,7 @@ class TodoUpgrade:
|
|||
f" ({database_name})"
|
||||
)
|
||||
print(f"[3] {t('Reset one of them onto its module view')}")
|
||||
print(f"[4] {t('Browse the differences full screen')}")
|
||||
wait_status = (
|
||||
input(
|
||||
f"💬 {t('Error detected, press enter to continue or')}"
|
||||
|
|
@ -3434,6 +3435,18 @@ class TodoUpgrade:
|
|||
if wait_status == "3" and database_name:
|
||||
self.prompt_reset_stale_cow_views(database_name)
|
||||
continue
|
||||
if wait_status == "4" and database_name:
|
||||
# Sur le VRAI terminal : un plein écran refuse de
|
||||
# s'ouvrir sur un stdout capturé, et retomberait sur le
|
||||
# rapport texte sans que rien ne distingue les deux.
|
||||
self.run_on_terminal(
|
||||
f"{PYTHON_BIN} ./"
|
||||
+ os.path.join(
|
||||
PATH_MIGRATION_GLOBAL, "reset_stale_cow_views.py"
|
||||
)
|
||||
+ f" -d {database_name} --tui"
|
||||
)
|
||||
continue
|
||||
break
|
||||
|
||||
if wait_status == "1":
|
||||
|
|
|
|||
163
test/test_reset_stale_cow_tui.py
Executable file
163
test/test_reset_stale_cow_tui.py
Executable file
|
|
@ -0,0 +1,163 @@
|
|||
#!/usr/bin/env python3
|
||||
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
|
||||
|
||||
"""Lire une copie COW avant d'accepter de la perdre, en plein écran.
|
||||
|
||||
Le rapport texte met à mille lignes d'écart les deux choses qui décident :
|
||||
ce que la copie porte en propre — la seule chose qu'une réinitialisation
|
||||
abandonne — et quel enfant ne trouve plus son ancrage, qui est la raison
|
||||
pour laquelle quoi que ce soit casse. L'espace bascule entre les deux.
|
||||
|
||||
La commande de réparation prend une CLÉ. La recopier à la main depuis un
|
||||
diff défilé est l'endroit où un caractère se perd, et une clé sans
|
||||
correspondance n'est pas une erreur pour l'outil : il tourne et ne fait
|
||||
rien. D'où « c ».
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
sys.path.insert(0, os.path.join(REPO, "script", "odoo", "migration"))
|
||||
|
||||
import reset_stale_cow_tui as tui # noqa: E402
|
||||
import reset_stale_cow_views as reset # noqa: E402
|
||||
|
||||
|
||||
def finding(**override):
|
||||
cow = {
|
||||
"id": 2841,
|
||||
"key": "website_blog.blog_post_complete",
|
||||
"website_id": 1,
|
||||
"arch": "<t>\n <div/>\n <span>vieux</span>\n</t>",
|
||||
}
|
||||
module = {
|
||||
"id": 1826,
|
||||
"key": "website_blog.blog_post_complete",
|
||||
"website_id": None,
|
||||
"arch": "<t>\n <div/>\n <section id='o_wblog_post_footer'/>\n</t>",
|
||||
}
|
||||
broken = [(3288, "//section[@id='o_wblog_post_footer']")]
|
||||
if "cow" in override:
|
||||
cow.update(override["cow"])
|
||||
return cow, override.get("module", module), override.get("broken", broken)
|
||||
|
||||
|
||||
class TestBothScreens(unittest.TestCase):
|
||||
def setUp(self):
|
||||
from script.todo import todo_i18n
|
||||
|
||||
self.addCleanup(
|
||||
setattr, todo_i18n, "_current_lang", todo_i18n._current_lang
|
||||
)
|
||||
todo_i18n._current_lang = "en"
|
||||
|
||||
def test_the_diff_shows_what_the_copy_holds(self):
|
||||
cow, module, _broken = finding()
|
||||
text = reset.render_diff(module, cow, indent=" ")
|
||||
self.assertIn("vieux", text)
|
||||
self.assertIn("o_wblog_post_footer", text)
|
||||
|
||||
def test_the_why_screen_names_the_failing_child(self):
|
||||
# Sans lui, on lit un diff sans savoir pourquoi il compte.
|
||||
text = tui.render_broken(*finding())
|
||||
self.assertIn("3288", text)
|
||||
self.assertIn("//section[@id='o_wblog_post_footer']", text)
|
||||
|
||||
def test_the_why_screen_says_what_a_reset_costs(self):
|
||||
text = tui.render_broken(*finding())
|
||||
self.assertIn("INHERITING", text)
|
||||
|
||||
def test_a_copy_without_broken_child_is_said_so(self):
|
||||
# Elle a dérivé sans rien casser ENCORE : le taire ferait croire à
|
||||
# une erreur de détection.
|
||||
cow, module, _ = finding()
|
||||
text = tui.render_broken(cow, module, [])
|
||||
self.assertIn("without", text)
|
||||
|
||||
def test_the_two_screens_use_the_same_diff_as_the_cli(self):
|
||||
# Deux rendus séparés dériveraient sans que rien ne le signale.
|
||||
import inspect
|
||||
|
||||
source = inspect.getsource(tui)
|
||||
self.assertIn("from reset_stale_cow_views import", source)
|
||||
self.assertIn("render_diff", source)
|
||||
|
||||
|
||||
class TestTheTriageColumn(unittest.TestCase):
|
||||
def test_the_weight_counts_both_directions(self):
|
||||
cow, module, _ = finding()
|
||||
self.assertEqual(tui.weight(cow, module), "+1/-1")
|
||||
|
||||
def test_no_module_twin_has_no_weight(self):
|
||||
cow, _module, _ = finding()
|
||||
self.assertEqual(tui.weight(cow, None), "—")
|
||||
|
||||
|
||||
class TestTheRefusalIsNeverSilent(unittest.TestCase):
|
||||
"""Un refus muet ferait réafficher le rapport texte sans le dire."""
|
||||
|
||||
def test_a_pipe_is_explained(self):
|
||||
import io
|
||||
from contextlib import redirect_stdout
|
||||
|
||||
out = io.StringIO()
|
||||
with redirect_stdout(out):
|
||||
result = tui.run_tui([finding()], "db")
|
||||
self.assertFalse(result)
|
||||
self.assertTrue(out.getvalue().strip(), "refus muet")
|
||||
|
||||
def test_nothing_to_show_stays_silent(self):
|
||||
import io
|
||||
from contextlib import redirect_stdout
|
||||
|
||||
out = io.StringIO()
|
||||
with redirect_stdout(out):
|
||||
self.assertFalse(tui.run_tui([], "db"))
|
||||
self.assertEqual(out.getvalue(), "")
|
||||
|
||||
|
||||
class TestTheToolAndTheMigrationOfferIt(unittest.TestCase):
|
||||
def test_the_tool_has_a_tui_flag(self):
|
||||
import subprocess
|
||||
|
||||
done = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
"./script/odoo/migration/reset_stale_cow_views.py",
|
||||
"--help",
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=REPO,
|
||||
)
|
||||
self.assertEqual(done.returncode, 0)
|
||||
self.assertIn("--tui", done.stdout)
|
||||
|
||||
def test_the_error_prompt_offers_it_as_option_four(self):
|
||||
import inspect
|
||||
|
||||
from script.todo.todo_upgrade import TodoUpgrade
|
||||
|
||||
source = inspect.getsource(TodoUpgrade.todo_upgrade_execute)
|
||||
self.assertIn("Browse the differences full screen", source)
|
||||
self.assertIn('wait_status == "4"', source)
|
||||
|
||||
def test_it_gets_a_real_terminal(self):
|
||||
# Un plein écran lancé par l'exécuteur qui capture retomberait sur
|
||||
# le rapport texte, sans que rien ne distingue les deux.
|
||||
import inspect
|
||||
|
||||
from script.todo.todo_upgrade import TodoUpgrade
|
||||
|
||||
source = inspect.getsource(TodoUpgrade.todo_upgrade_execute)
|
||||
start = source.index('wait_status == "4"')
|
||||
window = source[start : start + 620]
|
||||
self.assertIn("run_on_terminal", window)
|
||||
self.assertIn("--tui", window)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Loading…
Reference in a new issue