Compare commits
297 Commits
havari_lic
...
18.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 72f76d8bd7 | |||
| 6138670817 | |||
| b5e2d5b9b3 | |||
| 043d30e16e | |||
| 92a74356f7 | |||
| d4be3eed12 | |||
| c5ff83ec82 | |||
| 2eebf70d5c | |||
|
|
906e5ebd6d | ||
|
|
a103d8129b | ||
|
|
13a0f0faa1 | ||
| 888f87d8ec | |||
| 63c62699f5 | |||
| c412640ca2 | |||
| fd62a75b51 | |||
| e50acbac83 | |||
| ed5f0d6535 | |||
| bf36bd383a | |||
| ee7e3fb398 | |||
| c83da26305 | |||
| 5880120a84 | |||
| 207a122e37 | |||
| e40caa55e8 | |||
| d4789aaed6 | |||
|
|
7048450ad5 | ||
|
|
a3b7a9a521 | ||
| 686c06f52c | |||
| 99dd5ad688 | |||
| 2d88df12c0 | |||
| bb3dd53fbd | |||
| d2c8473122 | |||
| f726994409 | |||
| 5915c32ba7 | |||
| 2d76c220f0 | |||
| 005da073e8 | |||
| 7a4a89cef9 | |||
| 9295668143 | |||
| c53a0b6b30 | |||
| a498653c26 | |||
| 730cb8ddde | |||
| 33b1eeedf8 | |||
| 4915aaa882 | |||
| ffd1dd0b18 | |||
| c670be57f6 | |||
| 4ac0b04bca | |||
| c5a4899c9f | |||
| bbb6d4c35c | |||
| 0a9a96ae77 | |||
| 7a4b7d8e8f | |||
| 285e2e807a | |||
| b3c96f6416 | |||
| 30f5ab37d2 | |||
| de21d5cb55 | |||
| d4dd28c402 | |||
| 45eaa252bd | |||
| 45222f6bb7 | |||
| d9715fff07 | |||
| 5227767283 | |||
| f76d79a606 | |||
| 14fd3e2f1e | |||
| e3ce2be1f4 | |||
| 0329122548 | |||
| f31284a113 | |||
| dcccf8b034 | |||
| 9751faf173 | |||
| 0d94303107 | |||
| fcd0cfb26f | |||
| 48863fc6d5 | |||
| 8ba35217bf | |||
| afe168723d | |||
| 72273b580f | |||
| 095ed48ded | |||
| 3a18336cc4 | |||
| 33c3ae3585 | |||
| aa7c94b0dc | |||
| e30a37898b | |||
| 88e1fa7965 | |||
| 9f19b53414 | |||
| 26fa9e2871 | |||
| 825423b3af | |||
| 06a1e15a4f | |||
| 23c6cf64cf | |||
| ae9617dedd | |||
| 2884546072 | |||
| d5ae939266 | |||
| 89047fe79c | |||
| d323267a28 | |||
| edf915943b | |||
| d7c9aee436 | |||
| e497c2b5d0 | |||
| 582b9e1b9b | |||
| 2b1c7afdf1 | |||
| 882ba9247a | |||
| 0671d0b2e9 | |||
| 509006cce4 | |||
| 44c55e900a | |||
| db83188b88 | |||
| aa4d44164c | |||
| ea9d3ff61f | |||
| f63dd818aa | |||
| 27f8b2541e | |||
| 558a815535 | |||
| 6d6add697b | |||
| bb235faf39 | |||
| 3951faca64 | |||
| 3db314cedb | |||
| 3e4faf0dc7 | |||
| a70964ad21 | |||
| d0a79ac5a6 | |||
| 04c6e4d523 | |||
| 3d2cbfcb74 | |||
| 320f8e5bbf | |||
| 4d495e5cc5 | |||
| dbb7322fce | |||
| ad78baae06 | |||
| b3f9c9f989 | |||
| 773476029f | |||
| bdf313b39e | |||
| 0b3fafc478 | |||
| ebe63f69ab | |||
| a59bf97a36 | |||
| a64c72a5d4 | |||
| 8b0f9d6361 | |||
| aa5439325d | |||
| b1dbab9942 | |||
| 43ae1490f5 | |||
| 2a07cab00b | |||
| 08fe99a3dd | |||
| 1dae9452af | |||
| 0c15c42d4f | |||
| 7a0ffe51cd | |||
| effc02f01b | |||
| e585583bf5 | |||
| eab73a9964 | |||
| fe62898189 | |||
| dd454b6269 | |||
| 3a469c333d | |||
| 3fe3d5944a | |||
| 42ecceac62 | |||
| f5a379f683 | |||
| 834b292f73 | |||
| d29c58ac6c | |||
| 1095317b23 | |||
| 80ff9d095b | |||
| e110187874 | |||
| 0786f81d63 | |||
| dcb6ca59fc | |||
| 3eb6ee6758 | |||
| 97753affb8 | |||
| e19d374f2b | |||
| 30a33e939f | |||
| d2e38977c2 | |||
| fa3cb047c8 | |||
| 8441d1956d | |||
| 633253de00 | |||
| 9dfa1d4d03 | |||
| 4350577ab7 | |||
| 63becde73e | |||
| 65dc01b15e | |||
| d0e0bb7b0c | |||
| 26ce32efb8 | |||
| aa9004b2ef | |||
| cd8b9c7975 | |||
| 500026c640 | |||
| bd2ee6d072 | |||
| da58849aae | |||
| 570140673c | |||
| 0e56aa652c | |||
| f3b0ba4632 | |||
| 2462220921 | |||
| 17efcd0fbc | |||
| 5d3db75b14 | |||
| ea03f85024 | |||
| be600989ba | |||
| 362fe7126a | |||
| 439ebf4356 | |||
| 9b50ec37a6 | |||
| 829a0fe36f | |||
| 6fc36fd5c5 | |||
| cbc31eaf55 | |||
| d63a402aaf | |||
| 31047a2670 | |||
| 24adc03ab3 | |||
| 4e051d6c52 | |||
| 2ebb0a73c2 | |||
| 906671a8b5 | |||
| a401dc3abd | |||
| 124377f7c5 | |||
| 90836e2f2a | |||
| 90d9a2b202 | |||
| c62d637f56 | |||
| ce90945990 | |||
| bb42154f35 | |||
| 3fd4eb215e | |||
| 543f423825 | |||
| 3d2ad55cfa | |||
| 50cf4b4107 | |||
| 8b6528cb8b | |||
| a55dafa6e0 | |||
| 24c89d97e5 | |||
| a7c36be076 | |||
| 2ef98a3897 | |||
| e21d4e66a9 | |||
| 38c91ef94c | |||
| 454e6936fb | |||
| 6d49162c09 | |||
| 5bd5c7b906 | |||
| a4de8697da | |||
| ce8f7f8711 | |||
| ca0ca65f14 | |||
| 30f2cf9b0e | |||
| f68e2d23e7 | |||
| 05c33d06c3 | |||
| 46fca55d81 | |||
| 4db469f273 | |||
| 72a652a5c6 | |||
| 5523995594 | |||
| 7b7bcf73e6 | |||
| acb3564750 | |||
| bb3359a309 | |||
| 8b1544647a | |||
| f727f3ab67 | |||
| a371ac9bdc | |||
| f84c891e96 | |||
| c31947bfab | |||
| d783d0d08d | |||
| 7a9e3c56fe | |||
| 11770d4950 | |||
| 0d150fa92e | |||
| 2933122464 | |||
| 5ab0886edf | |||
| e2045b21e2 | |||
| 596bd0a761 | |||
| 2a19267d2f | |||
| 50105c5ba6 | |||
| 434b1d46ab | |||
| d4945d20ee | |||
| 6e05ee4c57 | |||
| 33ba2de5dd | |||
| bb075fe036 | |||
| 60687c611a | |||
| 776bcc1f7f | |||
| 61061bdee9 | |||
| 77d467de70 | |||
| e4a63ec0e4 | |||
| 77dc1762c0 | |||
| e2bf6c22f9 | |||
| f9b4e1582a | |||
| 53a05d2240 | |||
| 8512a1d196 | |||
| 41a130c1d6 | |||
| 13b8322e3c | |||
| d18265433c | |||
| 16e661f8e8 | |||
| 8a40edf6c6 | |||
| 831cbe1449 | |||
| aea1722933 | |||
| 51a39b2e6e | |||
| 21e6a6f3f6 | |||
| dbca7e34e1 | |||
| ac8400512b | |||
| 817b05fe06 | |||
| cb1c9a1ffa | |||
| bc7dbb494a | |||
| 7827e2f224 | |||
| c4edb6b3af | |||
| bdc66120f3 | |||
| 636938581d | |||
| 2c3cfc3978 | |||
| 0236ef0809 | |||
| de9fd7b71d | |||
| eaeb978c19 | |||
| 2e55efb312 | |||
| b786492d5e | |||
| 6086717e66 | |||
| 375466beea | |||
| 8dfe9e9874 | |||
| 385ef060ed | |||
| 6c8b3cbd74 | |||
| 90d1e47e03 | |||
| 9700c192d0 | |||
| 25596b4149 | |||
| 0885b59bb3 | |||
| fcbe9a17db | |||
| 860fedb7e0 | |||
| 85ada8e9b7 | |||
| 6b98bf8be1 | |||
| 55fb716490 | |||
|
|
ab214ac9dd | ||
|
|
304da43eb8 | ||
| 48b0b7a283 | |||
| 40c3e1d471 | |||
| c1ecf1289d | |||
| 0741834b31 | |||
| 7a2debb3d7 | |||
| 6ef9d029eb | |||
|
|
96edc0c694 |
393
.gitea/qualify-addon.py
Normal file
393
.gitea/qualify-addon.py
Normal file
@@ -0,0 +1,393 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
qualify-addon.py — Pillar 1 of the addon qualification gate.
|
||||
|
||||
Static checks against an Odoo addon source tree:
|
||||
manifest __manifest__.py parses, has 'name', 'version' starts with '<digit>.0.'
|
||||
pip-deps every non-stdlib import is declared in external_dependencies['python']
|
||||
app-name every <app> element in any view XML has a name= attribute
|
||||
menu-icon top-level <menuitem web_icon=> is set OR the addon ships
|
||||
static/description/icon.png
|
||||
hoot-import no JS file under static/src/ imports from '@odoo/hoot' or '@odoo/hoot-dom'
|
||||
webpack-name no JS file under static/lib/ uses self.webpackChunk_<unprefixed-name> —
|
||||
chunk array names must be addon-namespaced (e.g. webpackChunk_am5_<addon>)
|
||||
|
||||
Usage:
|
||||
python3 scripts/qualify-addon.py <addon-dir> [<addon-dir> ...]
|
||||
python3 scripts/qualify-addon.py --json <addon-dir>
|
||||
|
||||
Exit codes:
|
||||
0 all checks passed for every addon
|
||||
1 at least one addon failed at least one check
|
||||
2 bad usage / I/O error
|
||||
|
||||
Each finding is (severity, check, message). Severity:
|
||||
ERROR — the addon is broken-by-construction; refuse to admit to catalog
|
||||
WARN — likely problem but could be intentional; admit-with-warning posture
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from dataclasses import dataclass, asdict
|
||||
from pathlib import Path
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
|
||||
# Python stdlib modules. Conservative — anything imported NOT in here AND not in
|
||||
# ODOO_BUILTINS is flagged as needing declaration. Better to false-positive (fixable
|
||||
# by adding to external_dependencies) than miss a real missing dep.
|
||||
STDLIB = frozenset({
|
||||
'abc', 'argparse', 'ast', 'asyncio', 'base64', 'binascii', 'bisect', 'calendar',
|
||||
'collections', 'configparser', 'contextlib', 'contextvars', 'copy', 'csv', 'ctypes',
|
||||
'dataclasses', 'datetime', 'decimal', 'difflib', 'dis', 'email', 'enum', 'errno',
|
||||
'fcntl', 'fnmatch', 'functools', 'gc', 'getpass', 'gettext', 'glob', 'gzip', 'hashlib',
|
||||
'heapq', 'hmac', 'html', 'http', 'imaplib', 'importlib', 'inspect', 'io', 'ipaddress',
|
||||
'itertools', 'json', 'keyword', 'locale', 'logging', 'math', 'mimetypes',
|
||||
'multiprocessing', 'numbers', 'operator', 'os', 'pathlib', 'pickle', 'pkgutil',
|
||||
'platform', 'pprint', 'queue', 'random', 're', 'select', 'selectors', 'shlex',
|
||||
'shutil', 'signal', 'smtplib', 'socket', 'sqlite3', 'ssl', 'stat', 'string', 'struct',
|
||||
'subprocess', 'sys', 'tempfile', 'textwrap', 'threading', 'time', 'timeit', 'token',
|
||||
'tokenize', 'traceback', 'types', 'typing', 'unicodedata', 'unittest', 'urllib',
|
||||
'uuid', 'warnings', 'weakref', 'xml', 'xmlrpc', 'zipfile', 'zlib', 'zoneinfo',
|
||||
'__future__',
|
||||
})
|
||||
|
||||
# Modules shipped by Odoo's base image. Never need declaration.
|
||||
ODOO_BUILTINS = frozenset({
|
||||
'odoo', 'psycopg2', 'lxml', 'PIL', 'requests', 'dateutil', 'pytz', 'passlib',
|
||||
'werkzeug', 'jinja2', 'markupsafe', 'docutils', 'reportlab', 'babel', 'xlsxwriter',
|
||||
'xlrd', 'xlwt', 'qrcode', 'vobject', 'polib', 'PyPDF2', 'cryptography', 'pyOpenSSL',
|
||||
'OpenSSL', 'suds', 'num2words', 'pyldap', 'ldap', 'xmltodict', 'zeep', 'gevent',
|
||||
'greenlet', 'libsass', 'idna', 'pyusb', 'serial', 'qrcode', 'mock', 'freezegun',
|
||||
'phonenumbers',
|
||||
})
|
||||
|
||||
# Bare-name webpackChunk arrays we know collide. Detector flags any
|
||||
# `self.webpackChunk_<name>` where <name> doesn't have an addon-derived suffix.
|
||||
# We allow the canonical chunk array names listed here ONLY if the addon's
|
||||
# directory name matches — i.e. we suggest namespacing.
|
||||
WEBPACK_CHUNK_RE = re.compile(r'self\.(webpackChunk[a-zA-Z0-9_]+)\b')
|
||||
|
||||
# JS imports of the hoot test framework that should never appear in production code.
|
||||
HOOT_IMPORT_RE = re.compile(
|
||||
r'''(?:from\s+['"]@odoo/hoot[a-z\-]*['"]|require\s*\(\s*['"]@odoo/hoot[a-z\-]*['"]\s*\))'''
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Finding:
|
||||
severity: str # 'ERROR' | 'WARN'
|
||||
check: str # short check id
|
||||
message: str # human-readable
|
||||
file: str | None = None # relative path, if applicable
|
||||
line: int | None = None # 1-indexed, if applicable
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------- #
|
||||
# Check 1 — manifest parses + has required keys
|
||||
# ---------------------------------------------------------------------------- #
|
||||
def check_manifest(addon_dir: Path) -> tuple[list[Finding], dict | None]:
|
||||
findings: list[Finding] = []
|
||||
mf_path = addon_dir / '__manifest__.py'
|
||||
if not mf_path.exists():
|
||||
findings.append(Finding('ERROR', 'manifest', 'no __manifest__.py'))
|
||||
return findings, None
|
||||
try:
|
||||
manifest = ast.literal_eval(mf_path.read_text())
|
||||
except (SyntaxError, ValueError) as e:
|
||||
findings.append(Finding('ERROR', 'manifest',
|
||||
f'__manifest__.py does not parse as Python literal: {e}',
|
||||
file='__manifest__.py'))
|
||||
return findings, None
|
||||
if not isinstance(manifest, dict):
|
||||
findings.append(Finding('ERROR', 'manifest',
|
||||
'__manifest__.py top-level is not a dict',
|
||||
file='__manifest__.py'))
|
||||
return findings, None
|
||||
if not manifest.get('name'):
|
||||
findings.append(Finding('ERROR', 'manifest',
|
||||
"missing 'name' key (Odoo refuses install)",
|
||||
file='__manifest__.py'))
|
||||
version = manifest.get('version', '')
|
||||
if not re.match(r'^\d+\.0\.\d+\.\d+\.\d+$', version):
|
||||
findings.append(Finding('WARN', 'manifest',
|
||||
f"version {version!r} is not in '<odoo_major>.0.x.y.z' form — "
|
||||
"Odoo will prepend the running Odoo major and may refuse install "
|
||||
"on a different major (incident #9)",
|
||||
file='__manifest__.py'))
|
||||
return findings, manifest
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------- #
|
||||
# Check 2 — pip deps: every non-stdlib import is in external_dependencies
|
||||
# ---------------------------------------------------------------------------- #
|
||||
def check_pip_deps(addon_dir: Path, manifest: dict) -> list[Finding]:
|
||||
findings: list[Finding] = []
|
||||
declared = set(manifest.get('external_dependencies', {}).get('python', []))
|
||||
addon_name = addon_dir.name
|
||||
|
||||
# Pre-scan: collect this addon's submodule names so we don't flag intra-addon imports.
|
||||
own_submodules = {p.stem for p in addon_dir.rglob('*.py') if p.stem != '__init__'}
|
||||
own_submodules.add(addon_name)
|
||||
|
||||
seen_imports: set[tuple[str, str, int]] = set() # (toplevel, file, line)
|
||||
|
||||
for py_file in addon_dir.rglob('*.py'):
|
||||
if any(part.startswith('.') for part in py_file.parts):
|
||||
continue
|
||||
try:
|
||||
tree = ast.parse(py_file.read_text())
|
||||
except (SyntaxError, UnicodeDecodeError):
|
||||
continue
|
||||
rel = py_file.relative_to(addon_dir).as_posix()
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.Import):
|
||||
for alias in node.names:
|
||||
seen_imports.add((alias.name.split('.')[0], rel, node.lineno))
|
||||
elif isinstance(node, ast.ImportFrom):
|
||||
if node.level: # relative import — intra-addon, skip
|
||||
continue
|
||||
if node.module:
|
||||
seen_imports.add((node.module.split('.')[0], rel, node.lineno))
|
||||
|
||||
for top, rel, lineno in sorted(seen_imports):
|
||||
if top in STDLIB or top in ODOO_BUILTINS or top in own_submodules:
|
||||
continue
|
||||
if top in declared:
|
||||
continue
|
||||
# PEP 8 names that are clearly local helpers (e.g. utils, models) — skip if
|
||||
# they look like a sibling module we missed in own_submodules.
|
||||
if (addon_dir / top).is_dir() or (addon_dir / f'{top}.py').exists():
|
||||
continue
|
||||
findings.append(Finding(
|
||||
'ERROR', 'pip-deps',
|
||||
f"imports '{top}' but it is not in external_dependencies['python'] "
|
||||
"(install will fail with ModuleNotFoundError — incident #5)",
|
||||
file=rel, line=lineno,
|
||||
))
|
||||
return findings
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------- #
|
||||
# Check 3 — every <app> element has a name= attribute
|
||||
# ---------------------------------------------------------------------------- #
|
||||
def check_app_name(addon_dir: Path) -> list[Finding]:
|
||||
findings: list[Finding] = []
|
||||
# XML files in views/ + data/ may contain res_config_settings <app> elements.
|
||||
for xml_file in list(addon_dir.rglob('views/*.xml')) + list(addon_dir.rglob('data/*.xml')):
|
||||
try:
|
||||
text = xml_file.read_text()
|
||||
except UnicodeDecodeError:
|
||||
continue
|
||||
rel = xml_file.relative_to(addon_dir).as_posix()
|
||||
# Multi-line tolerant regex: <app ... > with everything between.
|
||||
for m in re.finditer(r'<app\b[^>]*?>', text, re.DOTALL):
|
||||
tag = m.group()
|
||||
if 'name=' in tag:
|
||||
continue
|
||||
line = text[:m.start()].count('\n') + 1
|
||||
findings.append(Finding(
|
||||
'ERROR', 'app-name',
|
||||
"<app> element missing name= attribute. Odoo 18 SettingsFormCompiler "
|
||||
"calls toStringExpression(null) and crashes the entire Settings page "
|
||||
"(incident #7)",
|
||||
file=rel, line=line,
|
||||
))
|
||||
return findings
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------- #
|
||||
# Check 4 — top-level menus declare web_icon OR addon ships static/description/icon.png
|
||||
# ---------------------------------------------------------------------------- #
|
||||
def check_menu_icon(addon_dir: Path) -> list[Finding]:
|
||||
findings: list[Finding] = []
|
||||
has_default_icon = (addon_dir / 'static' / 'description' / 'icon.png').exists()
|
||||
for xml_file in addon_dir.rglob('*.xml'):
|
||||
try:
|
||||
text = xml_file.read_text()
|
||||
except UnicodeDecodeError:
|
||||
continue
|
||||
rel = xml_file.relative_to(addon_dir).as_posix()
|
||||
# Find <menuitem ... > whose XML has no parent= attribute (top-level menu).
|
||||
for m in re.finditer(r'<menuitem\b[^>]*?/?>', text, re.DOTALL):
|
||||
tag = m.group()
|
||||
if 'parent=' in tag:
|
||||
continue
|
||||
if 'web_icon=' in tag:
|
||||
continue
|
||||
if has_default_icon:
|
||||
# Odoo 18's auto-fallback path. Soft warning since it works for top-level
|
||||
# menus that get web_icon auto-populated from the module's icon.png.
|
||||
# But our incident #6 showed even with icon.png present, web_icon often
|
||||
# ends up empty in DB. So WARN, not ERROR.
|
||||
line = text[:m.start()].count('\n') + 1
|
||||
findings.append(Finding(
|
||||
'WARN', 'menu-icon',
|
||||
"top-level <menuitem> has no web_icon=. Will fall back to "
|
||||
"static/description/icon.png IF Odoo's auto-populate fires; "
|
||||
"if not, menu shows blank (incident #6). Set web_icon explicitly.",
|
||||
file=rel, line=line,
|
||||
))
|
||||
else:
|
||||
line = text[:m.start()].count('\n') + 1
|
||||
findings.append(Finding(
|
||||
'ERROR', 'menu-icon',
|
||||
"top-level <menuitem> has no web_icon= AND addon ships no "
|
||||
"static/description/icon.png — menu will render blank.",
|
||||
file=rel, line=line,
|
||||
))
|
||||
return findings
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------- #
|
||||
# Check 5 — no @odoo/hoot* imports in static/src/
|
||||
# ---------------------------------------------------------------------------- #
|
||||
def check_hoot_import(addon_dir: Path) -> list[Finding]:
|
||||
findings: list[Finding] = []
|
||||
src_dir = addon_dir / 'static' / 'src'
|
||||
if not src_dir.exists():
|
||||
return findings
|
||||
for js_file in src_dir.rglob('*.js'):
|
||||
try:
|
||||
text = js_file.read_text()
|
||||
except UnicodeDecodeError:
|
||||
continue
|
||||
rel = js_file.relative_to(addon_dir).as_posix()
|
||||
for m in HOOT_IMPORT_RE.finditer(text):
|
||||
line = text[:m.start()].count('\n') + 1
|
||||
findings.append(Finding(
|
||||
'ERROR', 'hoot-import',
|
||||
"imports from @odoo/hoot* in production code (static/src/). "
|
||||
"@odoo/hoot is the test framework; the production bundle does not "
|
||||
"register it. Page will white-screen (incident #3 class)",
|
||||
file=rel, line=line,
|
||||
))
|
||||
return findings
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------- #
|
||||
# Check 6 — webpack chunk arrays in static/lib/ must be addon-namespaced
|
||||
# ---------------------------------------------------------------------------- #
|
||||
def check_webpack_chunk(addon_dir: Path) -> list[Finding]:
|
||||
findings: list[Finding] = []
|
||||
lib_dir = addon_dir / 'static' / 'lib'
|
||||
if not lib_dir.exists():
|
||||
return findings
|
||||
addon_name = addon_dir.name
|
||||
seen: set[str] = set()
|
||||
for js_file in lib_dir.rglob('*.js'):
|
||||
try:
|
||||
text = js_file.read_text()
|
||||
except UnicodeDecodeError:
|
||||
continue
|
||||
rel = js_file.relative_to(addon_dir).as_posix()
|
||||
for m in WEBPACK_CHUNK_RE.finditer(text):
|
||||
chunk_name = m.group(1)
|
||||
if chunk_name in seen:
|
||||
continue
|
||||
seen.add(chunk_name)
|
||||
# Acceptable if chunk name contains: full addon name OR any 4+ char
|
||||
# sub-token of the addon name (e.g. 'ksdn' for 'ks_dashboard_ninja')
|
||||
# OR a known-namespaced suffix (anything past the standard library
|
||||
# prefix). We just need confidence the chunk array is unique-per-addon.
|
||||
addon_lower = addon_name.lower()
|
||||
chunk_lower = chunk_name.lower()
|
||||
tokens = [addon_lower.replace('_', '')] + [
|
||||
t for t in addon_lower.split('_') if len(t) >= 4
|
||||
]
|
||||
# Also accept any short 4+ char abbrev derived from initials of
|
||||
# underscore-separated parts (ks_dashboard_ninja -> ksdn)
|
||||
initials = ''.join(t[0] for t in addon_lower.split('_') if t)
|
||||
if len(initials) >= 3:
|
||||
tokens.append(initials)
|
||||
if any(t in chunk_lower for t in tokens):
|
||||
continue
|
||||
line = text[:m.start()].count('\n') + 1
|
||||
findings.append(Finding(
|
||||
'ERROR', 'webpack-chunk',
|
||||
f"uses bare webpack chunk array '{chunk_name}'. Two addons that ship "
|
||||
f"the same library (e.g. amCharts) collide on this global → bundle "
|
||||
f"execution aborts (incident #4). Rename to '{chunk_name}_{addon_name}' "
|
||||
"or similar.",
|
||||
file=rel, line=line,
|
||||
))
|
||||
return findings
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------- #
|
||||
# Runner
|
||||
# ---------------------------------------------------------------------------- #
|
||||
def qualify_addon(addon_dir: Path) -> dict:
|
||||
findings: list[Finding] = []
|
||||
|
||||
manifest_findings, manifest = check_manifest(addon_dir)
|
||||
findings.extend(manifest_findings)
|
||||
|
||||
if manifest is not None:
|
||||
findings.extend(check_pip_deps(addon_dir, manifest))
|
||||
|
||||
findings.extend(check_app_name(addon_dir))
|
||||
findings.extend(check_menu_icon(addon_dir))
|
||||
findings.extend(check_hoot_import(addon_dir))
|
||||
findings.extend(check_webpack_chunk(addon_dir))
|
||||
|
||||
errors = sum(1 for f in findings if f.severity == 'ERROR')
|
||||
warns = sum(1 for f in findings if f.severity == 'WARN')
|
||||
return {
|
||||
'addon': addon_dir.name,
|
||||
'path': str(addon_dir),
|
||||
'qualified': errors == 0,
|
||||
'errors': errors,
|
||||
'warns': warns,
|
||||
'findings': [asdict(f) for f in findings],
|
||||
}
|
||||
|
||||
|
||||
def main(argv: list[str]) -> int:
|
||||
json_out = False
|
||||
args: list[str] = []
|
||||
for a in argv[1:]:
|
||||
if a == '--json':
|
||||
json_out = True
|
||||
elif a in ('-h', '--help'):
|
||||
print(__doc__)
|
||||
return 0
|
||||
else:
|
||||
args.append(a)
|
||||
if not args:
|
||||
print(__doc__, file=sys.stderr)
|
||||
return 2
|
||||
|
||||
results = []
|
||||
for path_str in args:
|
||||
path = Path(path_str).resolve()
|
||||
if not path.is_dir() or not (path / '__manifest__.py').exists():
|
||||
print(f'ERROR: {path} is not an Odoo addon directory '
|
||||
'(missing __manifest__.py)', file=sys.stderr)
|
||||
return 2
|
||||
results.append(qualify_addon(path))
|
||||
|
||||
if json_out:
|
||||
print(json.dumps(results, indent=2))
|
||||
else:
|
||||
for r in results:
|
||||
badge = '\033[32mQUALIFIED\033[0m' if r['qualified'] else '\033[31mFAILED\033[0m'
|
||||
print(f"\n{badge} {r['addon']} ({r['errors']} error(s), {r['warns']} warning(s))")
|
||||
if not r['findings']:
|
||||
continue
|
||||
for f in r['findings']:
|
||||
tag = '\033[31m' if f['severity'] == 'ERROR' else '\033[33m'
|
||||
loc = ''
|
||||
if f['file']:
|
||||
loc = f" [{f['file']}" + (f":{f['line']}" if f['line'] else '') + ']'
|
||||
print(f" {tag}{f['severity']:5}\033[0m {f['check']:<14} {f['message']}{loc}")
|
||||
|
||||
any_failed = any(not r['qualified'] for r in results)
|
||||
return 1 if any_failed else 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main(sys.argv))
|
||||
40
.gitea/workflows/addon-qualify.yml
Normal file
40
.gitea/workflows/addon-qualify.yml
Normal file
@@ -0,0 +1,40 @@
|
||||
# Pillar 1 of the addon-qualification proposal — runs on every push to any
|
||||
# branch and on every PR. Runs the vendored qualify-addon.py against every
|
||||
# addon directory in this repo.
|
||||
#
|
||||
# admit-with-warning posture: lint findings are reported but do NOT fail
|
||||
# the build (matches Pillar 3 informed-consent posture).
|
||||
#
|
||||
# To update the qualifier itself, edit scripts/qualify-addon.py in
|
||||
# odoo-tower/odooskyv3 then sync it here.
|
||||
name: addon-qualify
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
qualify:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout addons repo
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Run qualifier on every addon
|
||||
run: |
|
||||
set +e
|
||||
ADDONS=()
|
||||
for d in addons/*/; do
|
||||
[ -f "$d/__manifest__.py" ] && ADDONS+=("${d%/}")
|
||||
done
|
||||
if [ ${#ADDONS[@]} -eq 0 ]; then
|
||||
echo "No addons under addons/ — nothing to qualify"
|
||||
exit 0
|
||||
fi
|
||||
echo "Qualifying ${#ADDONS[@]} addons..."
|
||||
python3 .gitea/qualify-addon.py "${ADDONS[@]}"
|
||||
QUAL_RC=$?
|
||||
echo
|
||||
echo "::notice ::qualifier exit code $QUAL_RC (admit-with-warning — not failing build)"
|
||||
exit 0
|
||||
123
addons/cx_web_refresh_from_backend/README.rst
Normal file
123
addons/cx_web_refresh_from_backend/README.rst
Normal file
@@ -0,0 +1,123 @@
|
||||
========================
|
||||
Web Refresh From Backend
|
||||
========================
|
||||
|
||||
..
|
||||
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
|
||||
!! This file is generated by oca-gen-addon-readme !!
|
||||
!! changes will be overwritten. !!
|
||||
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
|
||||
!! source digest: sha256:199e0da56a7d94568d062706d1f34ac6b38310034c25f5840e2631722e9d9f65
|
||||
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
|
||||
|
||||
.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png
|
||||
:target: https://odoo-community.org/page/development-status
|
||||
:alt: Beta
|
||||
.. |badge2| image:: https://img.shields.io/badge/license-LGPL--3-blue.png
|
||||
:target: http://www.gnu.org/licenses/lgpl-3.0-standalone.html
|
||||
:alt: License: LGPL-3
|
||||
.. |badge3| image:: https://img.shields.io/badge/github-cetmix%2Fcetmix--tower-lightgray.png?logo=github
|
||||
:target: https://github.com/cetmix/cetmix-tower/tree/18.0/cx_web_refresh_from_backend
|
||||
:alt: cetmix/cetmix-tower
|
||||
|
||||
|badge1| |badge2| |badge3|
|
||||
|
||||
Refresh UI views from backend
|
||||
=============================
|
||||
|
||||
This is a **technical module** that allows triggering a **UI reload**
|
||||
from the backend. It enables triggering the reload action for selected
|
||||
users and record IDs.
|
||||
|
||||
--------------
|
||||
|
||||
🔧 Helper Function: ``reload_views``
|
||||
------------------------------------
|
||||
|
||||
A special helper function ``reload_views`` is added to the ``res.users``
|
||||
model.
|
||||
|
||||
**Arguments**
|
||||
~~~~~~~~~~~~~
|
||||
|
||||
+----------------+--------------------------+--------------------------+
|
||||
| Argument | Type | Description |
|
||||
+================+==========================+==========================+
|
||||
| **model** | ``Char`` | Model name, e.g. |
|
||||
| | | ``'res.partner'`` |
|
||||
+----------------+--------------------------+--------------------------+
|
||||
| **view_types** | ``List of Char`` | View types to reload, |
|
||||
| | *(optional)* | e.g. |
|
||||
| | | ``["form", "kanban"]``. |
|
||||
| | | Leave blank to reload |
|
||||
| | | all views. |
|
||||
+----------------+--------------------------+--------------------------+
|
||||
| **rec_ids** | ``List of Integer`` | The view will be |
|
||||
| | *(optional)* | reloaded only if a |
|
||||
| | | record with an ID from |
|
||||
| | | this list is present in |
|
||||
| | | the view. |
|
||||
+----------------+--------------------------+--------------------------+
|
||||
|
||||
--------------
|
||||
|
||||
⚠️ Important Notes
|
||||
------------------
|
||||
|
||||
Use this function **wisely**.
|
||||
|
||||
When reloading **form views**, be aware that if a user is currently
|
||||
editing a record, **their unsaved updates may be lost** when the form
|
||||
reloads from the server (no confirmation dialog is shown).
|
||||
|
||||
**Table of contents**
|
||||
|
||||
.. contents::
|
||||
:local:
|
||||
|
||||
Usage
|
||||
=====
|
||||
|
||||
🧩 Example Usage
|
||||
----------------
|
||||
|
||||
Below is a code snippet showing how to use the ``reload_views`` helper
|
||||
function.
|
||||
|
||||
.. code:: python
|
||||
|
||||
# Reload the kanban and form views for all salespeople when an opportunity is won
|
||||
# Will reload views only if the current opportunity is being displayed
|
||||
|
||||
group_id = self.env.ref("sales_team.group_sale_salesman").id
|
||||
users_to_reload = self.env["res.users"].search([("groups_id", "in", [group_id])])
|
||||
users_to_reload.reload_views(
|
||||
model="crm.lead",
|
||||
view_types=["kanban", "form"],
|
||||
rec_ids=[self.ids],
|
||||
)
|
||||
|
||||
Bug Tracker
|
||||
===========
|
||||
|
||||
Bugs are tracked on `GitHub Issues <https://github.com/cetmix/cetmix-tower/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
|
||||
`feedback <https://github.com/cetmix/cetmix-tower/issues/new?body=module:%20cx_web_refresh_from_backend%0Aversion:%2018.0%0A%0A**Steps%20to%20reproduce**%0A-%20...%0A%0A**Current%20behavior**%0A%0A**Expected%20behavior**>`_.
|
||||
|
||||
Do not contact contributors directly about support or help with technical issues.
|
||||
|
||||
Credits
|
||||
=======
|
||||
|
||||
Authors
|
||||
-------
|
||||
|
||||
* Cetmix
|
||||
|
||||
Maintainers
|
||||
-----------
|
||||
|
||||
This module is part of the `cetmix/cetmix-tower <https://github.com/cetmix/cetmix-tower/tree/18.0/cx_web_refresh_from_backend>`_ project on GitHub.
|
||||
|
||||
You are welcome to contribute.
|
||||
4
addons/cx_web_refresh_from_backend/__init__.py
Normal file
4
addons/cx_web_refresh_from_backend/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
# Copyright 2025 Cetmix OÜ
|
||||
# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl).
|
||||
|
||||
from . import models
|
||||
30
addons/cx_web_refresh_from_backend/__manifest__.py
Normal file
30
addons/cx_web_refresh_from_backend/__manifest__.py
Normal file
@@ -0,0 +1,30 @@
|
||||
# Copyright 2025 Cetmix OÜ
|
||||
# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl).
|
||||
|
||||
# Mail is required: its ir.websocket override subscribes the partner channel to the
|
||||
# bus, so users receive web.refresh_view notifications.
|
||||
|
||||
{
|
||||
"name": "Web Refresh From Backend",
|
||||
"summary": "Refresh frontend views from backend",
|
||||
"version": "18.0.1.0.0",
|
||||
"category": "Web",
|
||||
"license": "LGPL-3",
|
||||
"author": "Cetmix",
|
||||
"website": "https://tower.cetmix.com",
|
||||
"images": ["static/description/banner.png"],
|
||||
"depends": ["mail"],
|
||||
"assets": {
|
||||
"web.assets_backend": [
|
||||
"cx_web_refresh_from_backend/static/src/views/utils/get_loaded_record_ids.esm.js",
|
||||
"cx_web_refresh_from_backend/static/src/views/list/list_controller_patch.esm.js",
|
||||
"cx_web_refresh_from_backend/static/src/views/kanban/kanban_controller_patch.esm.js",
|
||||
"cx_web_refresh_from_backend/static/src/views/form/form_controller_patch.esm.js",
|
||||
],
|
||||
"web.qunit_suite_tests": [
|
||||
"cx_web_refresh_from_backend/static/tests/refresh_from_backend_tests.esm.js",
|
||||
],
|
||||
},
|
||||
"installable": True,
|
||||
"auto_install": False,
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
# Translation of Odoo Server.
|
||||
# This file contains the translation of the following modules:
|
||||
# * cx_web_refresh_from_backend
|
||||
#
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: Odoo Server 18.0\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: \n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: \n"
|
||||
"Plural-Forms: \n"
|
||||
|
||||
#. module: cx_web_refresh_from_backend
|
||||
#. odoo-javascript
|
||||
#: code:addons/cx_web_refresh_from_backend/static/src/views/list/list_controller_patch.esm.js:0
|
||||
msgid "Cancel"
|
||||
msgstr ""
|
||||
|
||||
#. module: cx_web_refresh_from_backend
|
||||
#. odoo-javascript
|
||||
#: code:addons/cx_web_refresh_from_backend/static/src/views/form/form_controller_patch.esm.js:0
|
||||
msgid "Could not reload form. %(message)s"
|
||||
msgstr ""
|
||||
|
||||
#. module: cx_web_refresh_from_backend
|
||||
#. odoo-javascript
|
||||
#: code:addons/cx_web_refresh_from_backend/static/src/views/kanban/kanban_controller_patch.esm.js:0
|
||||
msgid "Could not reload kanban. %(message)s"
|
||||
msgstr ""
|
||||
|
||||
#. module: cx_web_refresh_from_backend
|
||||
#. odoo-javascript
|
||||
#: code:addons/cx_web_refresh_from_backend/static/src/views/list/list_controller_patch.esm.js:0
|
||||
msgid "Could not reload list. %(message)s"
|
||||
msgstr ""
|
||||
|
||||
#. module: cx_web_refresh_from_backend
|
||||
#. odoo-javascript
|
||||
#: code:addons/cx_web_refresh_from_backend/static/src/views/list/list_controller_patch.esm.js:0
|
||||
msgid "Could not save record. %(message)s"
|
||||
msgstr ""
|
||||
|
||||
#. module: cx_web_refresh_from_backend
|
||||
#. odoo-javascript
|
||||
#: code:addons/cx_web_refresh_from_backend/static/src/views/list/list_controller_patch.esm.js:0
|
||||
msgid "List is being refreshed from backend"
|
||||
msgstr ""
|
||||
|
||||
#. module: cx_web_refresh_from_backend
|
||||
#. odoo-javascript
|
||||
#: code:addons/cx_web_refresh_from_backend/static/src/views/list/list_controller_patch.esm.js:0
|
||||
msgid "Save & Refresh"
|
||||
msgstr ""
|
||||
|
||||
#. module: cx_web_refresh_from_backend
|
||||
#: model:ir.model,name:cx_web_refresh_from_backend.model_res_users
|
||||
msgid "User"
|
||||
msgstr ""
|
||||
|
||||
#. module: cx_web_refresh_from_backend
|
||||
#. odoo-javascript
|
||||
#: code:addons/cx_web_refresh_from_backend/static/src/views/list/list_controller_patch.esm.js:0
|
||||
msgid "You have unsaved edits. Save them before refreshing?"
|
||||
msgstr ""
|
||||
4
addons/cx_web_refresh_from_backend/models/__init__.py
Normal file
4
addons/cx_web_refresh_from_backend/models/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
# Copyright 2025 Cetmix OÜ
|
||||
# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl).
|
||||
|
||||
from . import res_users
|
||||
50
addons/cx_web_refresh_from_backend/models/res_users.py
Normal file
50
addons/cx_web_refresh_from_backend/models/res_users.py
Normal file
@@ -0,0 +1,50 @@
|
||||
# Copyright 2025 Cetmix OÜ
|
||||
# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl).
|
||||
|
||||
from odoo import models
|
||||
|
||||
|
||||
class ResUsers(models.Model):
|
||||
_inherit = "res.users"
|
||||
|
||||
def reload_views(self, model, view_types=None, rec_ids=None):
|
||||
"""
|
||||
Trigger UI reload for selected users and record IDs.
|
||||
|
||||
This method allows to reload specific views from the backend.
|
||||
Be aware that when reloading form views, if a user is currently
|
||||
doing some updates, those updates may be lost when the form reloads
|
||||
(no confirmation dialog on the client).
|
||||
|
||||
:param model: str, Model name (e.g., 'res.partner')
|
||||
:param view_types: list of str, optional, View types to reload
|
||||
(e.g., ['form', 'kanban']). Leave blank to reload all views.
|
||||
:param rec_ids: list of int, optional, View will be reloaded only if a record
|
||||
with id from the list is present in the view.
|
||||
|
||||
Example usage:
|
||||
# Reload the kanban and form views for all salespeople
|
||||
# when an opportunity is won.
|
||||
# Will reload views only if the current opportunity is being displayed
|
||||
group_id = self.env.ref("sales_team.group_sale_salesman").id
|
||||
users_to_reload = self.env["res.users"].search(
|
||||
[("groups_id", "in", [group_id])]
|
||||
)
|
||||
users_to_reload.reload_views(
|
||||
model="crm.lead",
|
||||
view_types=["kanban", "form"],
|
||||
rec_ids=[self.ids]
|
||||
)
|
||||
"""
|
||||
|
||||
# Prepare the message payload
|
||||
bus_message = {
|
||||
"model": model,
|
||||
"view_types": view_types or [],
|
||||
"rec_ids": rec_ids or [],
|
||||
}
|
||||
|
||||
# Send one notification per user's partner in deterministic order.
|
||||
bus_bus = self.env["bus.bus"]
|
||||
for user in self.sorted("id"):
|
||||
bus_bus._sendone(user.partner_id, "web.refresh_view", bus_message)
|
||||
3
addons/cx_web_refresh_from_backend/pyproject.toml
Normal file
3
addons/cx_web_refresh_from_backend/pyproject.toml
Normal file
@@ -0,0 +1,3 @@
|
||||
[build-system]
|
||||
requires = ["whool"]
|
||||
build-backend = "whool.buildapi"
|
||||
28
addons/cx_web_refresh_from_backend/readme/DESCRIPTION.md
Normal file
28
addons/cx_web_refresh_from_backend/readme/DESCRIPTION.md
Normal file
@@ -0,0 +1,28 @@
|
||||
# Refresh UI views from backend
|
||||
|
||||
This is a **technical module** that allows triggering a **UI reload** from the backend.
|
||||
It enables triggering the reload action for selected users and record IDs.
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Helper Function: `reload_views`
|
||||
|
||||
A special helper function `reload_views` is added to the `res.users` model.
|
||||
|
||||
### **Arguments**
|
||||
|
||||
| Argument | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| **model** | `Char` | Model name, e.g. `'res.partner'` |
|
||||
| **view_types** | `List of Char` *(optional)* | View types to reload, e.g. `["form", "kanban"]`. Leave blank to reload all views. |
|
||||
| **rec_ids** | `List of Integer` *(optional)* | The view will be reloaded only if a record with an ID from this list is present in the view. |
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ Important Notes
|
||||
|
||||
Use this function **wisely**.
|
||||
|
||||
When reloading **form views**, be aware that if a user is currently editing a record,
|
||||
**their unsaved updates may be lost** when the form reloads from the server (no confirmation
|
||||
dialog is shown).
|
||||
16
addons/cx_web_refresh_from_backend/readme/USAGE.md
Normal file
16
addons/cx_web_refresh_from_backend/readme/USAGE.md
Normal file
@@ -0,0 +1,16 @@
|
||||
## 🧩 Example Usage
|
||||
|
||||
Below is a code snippet showing how to use the `reload_views` helper function.
|
||||
|
||||
```python
|
||||
# Reload the kanban and form views for all salespeople when an opportunity is won
|
||||
# Will reload views only if the current opportunity is being displayed
|
||||
|
||||
group_id = self.env.ref("sales_team.group_sale_salesman").id
|
||||
users_to_reload = self.env["res.users"].search([("groups_id", "in", [group_id])])
|
||||
users_to_reload.reload_views(
|
||||
model="crm.lead",
|
||||
view_types=["kanban", "form"],
|
||||
rec_ids=[self.ids],
|
||||
)
|
||||
```
|
||||
BIN
addons/cx_web_refresh_from_backend/static/description/banner.png
Normal file
BIN
addons/cx_web_refresh_from_backend/static/description/banner.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 204 KiB |
BIN
addons/cx_web_refresh_from_backend/static/description/icon.png
Normal file
BIN
addons/cx_web_refresh_from_backend/static/description/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.4 KiB |
479
addons/cx_web_refresh_from_backend/static/description/index.html
Normal file
479
addons/cx_web_refresh_from_backend/static/description/index.html
Normal file
@@ -0,0 +1,479 @@
|
||||
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
|
||||
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
|
||||
<head>
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
|
||||
<meta name="generator" content="Docutils: https://docutils.sourceforge.io/" />
|
||||
<title>Web Refresh From Backend</title>
|
||||
<style type="text/css">
|
||||
|
||||
/*
|
||||
:Author: David Goodger (goodger@python.org)
|
||||
:Id: $Id: html4css1.css 9511 2024-01-13 09:50:07Z milde $
|
||||
:Copyright: This stylesheet has been placed in the public domain.
|
||||
|
||||
Default cascading style sheet for the HTML output of Docutils.
|
||||
Despite the name, some widely supported CSS2 features are used.
|
||||
|
||||
See https://docutils.sourceforge.io/docs/howto/html-stylesheets.html for how to
|
||||
customize this style sheet.
|
||||
*/
|
||||
|
||||
/* used to remove borders from tables and images */
|
||||
.borderless, table.borderless td, table.borderless th {
|
||||
border: 0 }
|
||||
|
||||
table.borderless td, table.borderless th {
|
||||
/* Override padding for "table.docutils td" with "! important".
|
||||
The right padding separates the table cells. */
|
||||
padding: 0 0.5em 0 0 ! important }
|
||||
|
||||
.first {
|
||||
/* Override more specific margin styles with "! important". */
|
||||
margin-top: 0 ! important }
|
||||
|
||||
.last, .with-subtitle {
|
||||
margin-bottom: 0 ! important }
|
||||
|
||||
.hidden {
|
||||
display: none }
|
||||
|
||||
.subscript {
|
||||
vertical-align: sub;
|
||||
font-size: smaller }
|
||||
|
||||
.superscript {
|
||||
vertical-align: super;
|
||||
font-size: smaller }
|
||||
|
||||
a.toc-backref {
|
||||
text-decoration: none ;
|
||||
color: black }
|
||||
|
||||
blockquote.epigraph {
|
||||
margin: 2em 5em ; }
|
||||
|
||||
dl.docutils dd {
|
||||
margin-bottom: 0.5em }
|
||||
|
||||
object[type="image/svg+xml"], object[type="application/x-shockwave-flash"] {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Uncomment (and remove this text!) to get bold-faced definition list terms
|
||||
dl.docutils dt {
|
||||
font-weight: bold }
|
||||
*/
|
||||
|
||||
div.abstract {
|
||||
margin: 2em 5em }
|
||||
|
||||
div.abstract p.topic-title {
|
||||
font-weight: bold ;
|
||||
text-align: center }
|
||||
|
||||
div.admonition, div.attention, div.caution, div.danger, div.error,
|
||||
div.hint, div.important, div.note, div.tip, div.warning {
|
||||
margin: 2em ;
|
||||
border: medium outset ;
|
||||
padding: 1em }
|
||||
|
||||
div.admonition p.admonition-title, div.hint p.admonition-title,
|
||||
div.important p.admonition-title, div.note p.admonition-title,
|
||||
div.tip p.admonition-title {
|
||||
font-weight: bold ;
|
||||
font-family: sans-serif }
|
||||
|
||||
div.attention p.admonition-title, div.caution p.admonition-title,
|
||||
div.danger p.admonition-title, div.error p.admonition-title,
|
||||
div.warning p.admonition-title, .code .error {
|
||||
color: red ;
|
||||
font-weight: bold ;
|
||||
font-family: sans-serif }
|
||||
|
||||
/* Uncomment (and remove this text!) to get reduced vertical space in
|
||||
compound paragraphs.
|
||||
div.compound .compound-first, div.compound .compound-middle {
|
||||
margin-bottom: 0.5em }
|
||||
|
||||
div.compound .compound-last, div.compound .compound-middle {
|
||||
margin-top: 0.5em }
|
||||
*/
|
||||
|
||||
div.dedication {
|
||||
margin: 2em 5em ;
|
||||
text-align: center ;
|
||||
font-style: italic }
|
||||
|
||||
div.dedication p.topic-title {
|
||||
font-weight: bold ;
|
||||
font-style: normal }
|
||||
|
||||
div.figure {
|
||||
margin-left: 2em ;
|
||||
margin-right: 2em }
|
||||
|
||||
div.footer, div.header {
|
||||
clear: both;
|
||||
font-size: smaller }
|
||||
|
||||
div.line-block {
|
||||
display: block ;
|
||||
margin-top: 1em ;
|
||||
margin-bottom: 1em }
|
||||
|
||||
div.line-block div.line-block {
|
||||
margin-top: 0 ;
|
||||
margin-bottom: 0 ;
|
||||
margin-left: 1.5em }
|
||||
|
||||
div.sidebar {
|
||||
margin: 0 0 0.5em 1em ;
|
||||
border: medium outset ;
|
||||
padding: 1em ;
|
||||
background-color: #ffffee ;
|
||||
width: 40% ;
|
||||
float: right ;
|
||||
clear: right }
|
||||
|
||||
div.sidebar p.rubric {
|
||||
font-family: sans-serif ;
|
||||
font-size: medium }
|
||||
|
||||
div.system-messages {
|
||||
margin: 5em }
|
||||
|
||||
div.system-messages h1 {
|
||||
color: red }
|
||||
|
||||
div.system-message {
|
||||
border: medium outset ;
|
||||
padding: 1em }
|
||||
|
||||
div.system-message p.system-message-title {
|
||||
color: red ;
|
||||
font-weight: bold }
|
||||
|
||||
div.topic {
|
||||
margin: 2em }
|
||||
|
||||
h1.section-subtitle, h2.section-subtitle, h3.section-subtitle,
|
||||
h4.section-subtitle, h5.section-subtitle, h6.section-subtitle {
|
||||
margin-top: 0.4em }
|
||||
|
||||
h1.title {
|
||||
text-align: center }
|
||||
|
||||
h2.subtitle {
|
||||
text-align: center }
|
||||
|
||||
hr.docutils {
|
||||
width: 75% }
|
||||
|
||||
img.align-left, .figure.align-left, object.align-left, table.align-left {
|
||||
clear: left ;
|
||||
float: left ;
|
||||
margin-right: 1em }
|
||||
|
||||
img.align-right, .figure.align-right, object.align-right, table.align-right {
|
||||
clear: right ;
|
||||
float: right ;
|
||||
margin-left: 1em }
|
||||
|
||||
img.align-center, .figure.align-center, object.align-center {
|
||||
display: block;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
table.align-center {
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.align-left {
|
||||
text-align: left }
|
||||
|
||||
.align-center {
|
||||
clear: both ;
|
||||
text-align: center }
|
||||
|
||||
.align-right {
|
||||
text-align: right }
|
||||
|
||||
/* reset inner alignment in figures */
|
||||
div.align-right {
|
||||
text-align: inherit }
|
||||
|
||||
/* div.align-center * { */
|
||||
/* text-align: left } */
|
||||
|
||||
.align-top {
|
||||
vertical-align: top }
|
||||
|
||||
.align-middle {
|
||||
vertical-align: middle }
|
||||
|
||||
.align-bottom {
|
||||
vertical-align: bottom }
|
||||
|
||||
ol.simple, ul.simple {
|
||||
margin-bottom: 1em }
|
||||
|
||||
ol.arabic {
|
||||
list-style: decimal }
|
||||
|
||||
ol.loweralpha {
|
||||
list-style: lower-alpha }
|
||||
|
||||
ol.upperalpha {
|
||||
list-style: upper-alpha }
|
||||
|
||||
ol.lowerroman {
|
||||
list-style: lower-roman }
|
||||
|
||||
ol.upperroman {
|
||||
list-style: upper-roman }
|
||||
|
||||
p.attribution {
|
||||
text-align: right ;
|
||||
margin-left: 50% }
|
||||
|
||||
p.caption {
|
||||
font-style: italic }
|
||||
|
||||
p.credits {
|
||||
font-style: italic ;
|
||||
font-size: smaller }
|
||||
|
||||
p.label {
|
||||
white-space: nowrap }
|
||||
|
||||
p.rubric {
|
||||
font-weight: bold ;
|
||||
font-size: larger ;
|
||||
color: maroon ;
|
||||
text-align: center }
|
||||
|
||||
p.sidebar-title {
|
||||
font-family: sans-serif ;
|
||||
font-weight: bold ;
|
||||
font-size: larger }
|
||||
|
||||
p.sidebar-subtitle {
|
||||
font-family: sans-serif ;
|
||||
font-weight: bold }
|
||||
|
||||
p.topic-title {
|
||||
font-weight: bold }
|
||||
|
||||
pre.address {
|
||||
margin-bottom: 0 ;
|
||||
margin-top: 0 ;
|
||||
font: inherit }
|
||||
|
||||
pre.literal-block, pre.doctest-block, pre.math, pre.code {
|
||||
margin-left: 2em ;
|
||||
margin-right: 2em }
|
||||
|
||||
pre.code .ln { color: gray; } /* line numbers */
|
||||
pre.code, code { background-color: #eeeeee }
|
||||
pre.code .comment, code .comment { color: #5C6576 }
|
||||
pre.code .keyword, code .keyword { color: #3B0D06; font-weight: bold }
|
||||
pre.code .literal.string, code .literal.string { color: #0C5404 }
|
||||
pre.code .name.builtin, code .name.builtin { color: #352B84 }
|
||||
pre.code .deleted, code .deleted { background-color: #DEB0A1}
|
||||
pre.code .inserted, code .inserted { background-color: #A3D289}
|
||||
|
||||
span.classifier {
|
||||
font-family: sans-serif ;
|
||||
font-style: oblique }
|
||||
|
||||
span.classifier-delimiter {
|
||||
font-family: sans-serif ;
|
||||
font-weight: bold }
|
||||
|
||||
span.interpreted {
|
||||
font-family: sans-serif }
|
||||
|
||||
span.option {
|
||||
white-space: nowrap }
|
||||
|
||||
span.pre {
|
||||
white-space: pre }
|
||||
|
||||
span.problematic, pre.problematic {
|
||||
color: red }
|
||||
|
||||
span.section-subtitle {
|
||||
/* font-size relative to parent (h1..h6 element) */
|
||||
font-size: 80% }
|
||||
|
||||
table.citation {
|
||||
border-left: solid 1px gray;
|
||||
margin-left: 1px }
|
||||
|
||||
table.docinfo {
|
||||
margin: 2em 4em }
|
||||
|
||||
table.docutils {
|
||||
margin-top: 0.5em ;
|
||||
margin-bottom: 0.5em }
|
||||
|
||||
table.footnote {
|
||||
border-left: solid 1px black;
|
||||
margin-left: 1px }
|
||||
|
||||
table.docutils td, table.docutils th,
|
||||
table.docinfo td, table.docinfo th {
|
||||
padding-left: 0.5em ;
|
||||
padding-right: 0.5em ;
|
||||
vertical-align: top }
|
||||
|
||||
table.docutils th.field-name, table.docinfo th.docinfo-name {
|
||||
font-weight: bold ;
|
||||
text-align: left ;
|
||||
white-space: nowrap ;
|
||||
padding-left: 0 }
|
||||
|
||||
/* "booktabs" style (no vertical lines) */
|
||||
table.docutils.booktabs {
|
||||
border: 0px;
|
||||
border-top: 2px solid;
|
||||
border-bottom: 2px solid;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
table.docutils.booktabs * {
|
||||
border: 0px;
|
||||
}
|
||||
table.docutils.booktabs th {
|
||||
border-bottom: thin solid;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
h1 tt.docutils, h2 tt.docutils, h3 tt.docutils,
|
||||
h4 tt.docutils, h5 tt.docutils, h6 tt.docutils {
|
||||
font-size: 100% }
|
||||
|
||||
ul.auto-toc {
|
||||
list-style-type: none }
|
||||
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="document" id="web-refresh-from-backend">
|
||||
<h1 class="title">Web Refresh From Backend</h1>
|
||||
|
||||
<!-- !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
|
||||
!! This file is generated by oca-gen-addon-readme !!
|
||||
!! changes will be overwritten. !!
|
||||
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
|
||||
!! source digest: sha256:199e0da56a7d94568d062706d1f34ac6b38310034c25f5840e2631722e9d9f65
|
||||
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -->
|
||||
<p><a class="reference external image-reference" href="https://odoo-community.org/page/development-status"><img alt="Beta" src="https://img.shields.io/badge/maturity-Beta-yellow.png" /></a> <a class="reference external image-reference" href="http://www.gnu.org/licenses/lgpl-3.0-standalone.html"><img alt="License: LGPL-3" src="https://img.shields.io/badge/license-LGPL--3-blue.png" /></a> <a class="reference external image-reference" href="https://github.com/cetmix/cetmix-tower/tree/18.0/cx_web_refresh_from_backend"><img alt="cetmix/cetmix-tower" src="https://img.shields.io/badge/github-cetmix%2Fcetmix--tower-lightgray.png?logo=github" /></a></p>
|
||||
<div class="section" id="refresh-ui-views-from-backend">
|
||||
<h1>Refresh UI views from backend</h1>
|
||||
<p>This is a <strong>technical module</strong> that allows triggering a <strong>UI reload</strong>
|
||||
from the backend. It enables triggering the reload action for selected
|
||||
users and record IDs.</p>
|
||||
<hr class="docutils" />
|
||||
<div class="section" id="helper-function-reload-views">
|
||||
<h2>🔧 Helper Function: <tt class="docutils literal">reload_views</tt></h2>
|
||||
<p>A special helper function <tt class="docutils literal">reload_views</tt> is added to the <tt class="docutils literal">res.users</tt>
|
||||
model.</p>
|
||||
<div class="section" id="arguments">
|
||||
<h3><strong>Arguments</strong></h3>
|
||||
<table border="1" class="docutils">
|
||||
<colgroup>
|
||||
<col width="24%" />
|
||||
<col width="38%" />
|
||||
<col width="38%" />
|
||||
</colgroup>
|
||||
<thead valign="bottom">
|
||||
<tr><th class="head">Argument</th>
|
||||
<th class="head">Type</th>
|
||||
<th class="head">Description</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody valign="top">
|
||||
<tr><td><strong>model</strong></td>
|
||||
<td><tt class="docutils literal">Char</tt></td>
|
||||
<td>Model name, e.g.
|
||||
<tt class="docutils literal">'res.partner'</tt></td>
|
||||
</tr>
|
||||
<tr><td><strong>view_types</strong></td>
|
||||
<td><tt class="docutils literal">List of Char</tt>
|
||||
<em>(optional)</em></td>
|
||||
<td>View types to reload,
|
||||
e.g.
|
||||
<tt class="docutils literal">["form", "kanban"]</tt>.
|
||||
Leave blank to reload
|
||||
all views.</td>
|
||||
</tr>
|
||||
<tr><td><strong>rec_ids</strong></td>
|
||||
<td><tt class="docutils literal">List of Integer</tt>
|
||||
<em>(optional)</em></td>
|
||||
<td>The view will be
|
||||
reloaded only if a
|
||||
record with an ID from
|
||||
this list is present in
|
||||
the view.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<hr class="docutils" />
|
||||
<div class="section" id="important-notes">
|
||||
<h2>⚠️ Important Notes</h2>
|
||||
<p>Use this function <strong>wisely</strong>.</p>
|
||||
<p>When reloading <strong>form views</strong>, be aware that if a user is currently
|
||||
editing a record, <strong>their unsaved updates may be lost</strong> when the form
|
||||
reloads from the server (no confirmation dialog is shown).</p>
|
||||
<p><strong>Table of contents</strong></p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="section" id="usage">
|
||||
<h1>Usage</h1>
|
||||
<div class="section" id="example-usage">
|
||||
<h2>🧩 Example Usage</h2>
|
||||
<p>Below is a code snippet showing how to use the <tt class="docutils literal">reload_views</tt> helper
|
||||
function.</p>
|
||||
<pre class="code python literal-block">
|
||||
<span class="c1"># Reload the kanban and form views for all salespeople when an opportunity is won</span><span class="w">
|
||||
</span><span class="c1"># Will reload views only if the current opportunity is being displayed</span><span class="w">
|
||||
|
||||
</span><span class="n">group_id</span> <span class="o">=</span> <span class="bp">self</span><span class="o">.</span><span class="n">env</span><span class="o">.</span><span class="n">ref</span><span class="p">(</span><span class="s2">"sales_team.group_sale_salesman"</span><span class="p">)</span><span class="o">.</span><span class="n">id</span><span class="w">
|
||||
</span><span class="n">users_to_reload</span> <span class="o">=</span> <span class="bp">self</span><span class="o">.</span><span class="n">env</span><span class="p">[</span><span class="s2">"res.users"</span><span class="p">]</span><span class="o">.</span><span class="n">search</span><span class="p">([(</span><span class="s2">"groups_id"</span><span class="p">,</span> <span class="s2">"in"</span><span class="p">,</span> <span class="p">[</span><span class="n">group_id</span><span class="p">])])</span><span class="w">
|
||||
</span><span class="n">users_to_reload</span><span class="o">.</span><span class="n">reload_views</span><span class="p">(</span><span class="w">
|
||||
</span> <span class="n">model</span><span class="o">=</span><span class="s2">"crm.lead"</span><span class="p">,</span><span class="w">
|
||||
</span> <span class="n">view_types</span><span class="o">=</span><span class="p">[</span><span class="s2">"kanban"</span><span class="p">,</span> <span class="s2">"form"</span><span class="p">],</span><span class="w">
|
||||
</span> <span class="n">rec_ids</span><span class="o">=</span><span class="p">[</span><span class="bp">self</span><span class="o">.</span><span class="n">ids</span><span class="p">],</span><span class="w">
|
||||
</span><span class="p">)</span>
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
<div class="section" id="bug-tracker">
|
||||
<h1>Bug Tracker</h1>
|
||||
<p>Bugs are tracked on <a class="reference external" href="https://github.com/cetmix/cetmix-tower/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
|
||||
<a class="reference external" href="https://github.com/cetmix/cetmix-tower/issues/new?body=module:%20cx_web_refresh_from_backend%0Aversion:%2018.0%0A%0A**Steps%20to%20reproduce**%0A-%20...%0A%0A**Current%20behavior**%0A%0A**Expected%20behavior**">feedback</a>.</p>
|
||||
<p>Do not contact contributors directly about support or help with technical issues.</p>
|
||||
</div>
|
||||
<div class="section" id="credits">
|
||||
<h1>Credits</h1>
|
||||
<div class="section" id="authors">
|
||||
<h2>Authors</h2>
|
||||
<ul class="simple">
|
||||
<li>Cetmix</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="section" id="maintainers">
|
||||
<h2>Maintainers</h2>
|
||||
<p>This module is part of the <a class="reference external" href="https://github.com/cetmix/cetmix-tower/tree/18.0/cx_web_refresh_from_backend">cetmix/cetmix-tower</a> project on GitHub.</p>
|
||||
<p>You are welcome to contribute.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,161 @@
|
||||
/** @odoo-module */
|
||||
|
||||
import {FormController} from "@web/views/form/form_controller";
|
||||
import {isResIdInRecIds} from "../utils/get_loaded_record_ids.esm";
|
||||
import {onWillUnmount} from "@odoo/owl";
|
||||
import {patch} from "@web/core/utils/patch";
|
||||
import {useService} from "@web/core/utils/hooks";
|
||||
import {_t} from "@web/core/l10n/translation";
|
||||
|
||||
patch(FormController.prototype, {
|
||||
setup() {
|
||||
super.setup(...arguments);
|
||||
|
||||
// Bus_service is async; useService("bus_service") breaks (SERVICES_METADATA).
|
||||
this.busService = this.env.services.bus_service;
|
||||
this.notificationService = useService("notification");
|
||||
|
||||
this._lastLocalSave = null;
|
||||
this._isRefreshInFlight = false;
|
||||
this._hasRefreshQueued = false;
|
||||
|
||||
this._boundBusHandler = this._onWebRefreshNotification.bind(this);
|
||||
this.busService.subscribe("web.refresh_view", this._boundBusHandler);
|
||||
|
||||
onWillUnmount(() => {
|
||||
if (this.busService && this._boundBusHandler) {
|
||||
this.busService.unsubscribe("web.refresh_view", this._boundBusHandler);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Handle a web.refresh_view bus notification for this form.
|
||||
* Called once per notification; coalesces concurrent refreshes via _queueRefresh.
|
||||
*
|
||||
* @param {Object} payload - Notification payload {model, view_types, rec_ids}
|
||||
*/
|
||||
async _onWebRefreshNotification(payload) {
|
||||
if (!this.model || !this.model.root) {
|
||||
return;
|
||||
}
|
||||
if (this._shouldRefreshView(payload)) {
|
||||
await this._queueRefresh("refreshForm");
|
||||
}
|
||||
},
|
||||
|
||||
async _queueRefresh(methodName) {
|
||||
if (this._isRefreshInFlight) {
|
||||
this._hasRefreshQueued = true;
|
||||
return;
|
||||
}
|
||||
this._isRefreshInFlight = true;
|
||||
try {
|
||||
do {
|
||||
this._hasRefreshQueued = false;
|
||||
await this[methodName]();
|
||||
} while (this._hasRefreshQueued);
|
||||
} finally {
|
||||
this._isRefreshInFlight = false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Check whether a refresh notification is relevant to this form.
|
||||
*
|
||||
* Returns true when all of the following hold:
|
||||
* - model matches current form model
|
||||
* - requested view types include "form" (or none specified)
|
||||
* - record id matches current record (or none specified)
|
||||
* - form is not inside a dialog / wizard
|
||||
*
|
||||
* @param {Object} payload - Notification payload
|
||||
* @returns {Boolean}
|
||||
*/
|
||||
_shouldRefreshView(payload) {
|
||||
const {model, view_types = [], rec_ids = []} = payload;
|
||||
|
||||
if (this.props.resModel !== model) {
|
||||
return false;
|
||||
}
|
||||
if (view_types.length > 0 && !view_types.includes("form")) {
|
||||
return false;
|
||||
}
|
||||
const currentResId = this.model && this.model.root && this.model.root.resId;
|
||||
if (rec_ids.length > 0 && !isResIdInRecIds(currentResId, rec_ids)) {
|
||||
return false;
|
||||
}
|
||||
// Skip refresh when form is in a dialog or when a wizard is on top
|
||||
// of the stack. Refreshing in that context can leave wizard/confirmation
|
||||
// dialogs stuck open (e.g. confirm="..." in wizard view).
|
||||
if (this.env.inDialog) {
|
||||
return false;
|
||||
}
|
||||
const currentController = this.actionService.currentController;
|
||||
const currentAction = currentController && currentController.action;
|
||||
if (currentAction && currentAction.target === "new") {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
|
||||
/**
|
||||
* Refresh the form with actual data from server.
|
||||
*
|
||||
* Reloads without confirmation even when the record is dirty (client changes
|
||||
* may be discarded). Dialog / wizard forms are filtered out in
|
||||
* _shouldRefreshView().
|
||||
*
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async refreshForm() {
|
||||
if (this._lastLocalSave && Date.now() - this._lastLocalSave < 2500) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.model || !this.model.root) {
|
||||
return;
|
||||
}
|
||||
|
||||
const record = this.model.root;
|
||||
|
||||
try {
|
||||
await record.load();
|
||||
} catch (error) {
|
||||
this.notificationService.add(this._getRefreshErrorMessage(error), {
|
||||
type: "danger",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.model && this.model.root) {
|
||||
this.render(true);
|
||||
}
|
||||
},
|
||||
|
||||
_getRefreshErrorMessage(error) {
|
||||
const message =
|
||||
(error && error.data && error.data.message) ||
|
||||
(error && error.message) ||
|
||||
String(error);
|
||||
return _t("Could not reload form. %(message)s", {message});
|
||||
},
|
||||
|
||||
/**
|
||||
* Override of save button handler.
|
||||
*
|
||||
* After a successful save, stores a timestamp to avoid immediate auto-refresh
|
||||
* triggered by our own write (bus notification). Failed saves leave the
|
||||
* timestamp unchanged so refresh suppression does not apply incorrectly.
|
||||
*
|
||||
* @param {Object} params - Save options
|
||||
* @returns {Promise<Boolean|undefined>} Result of the core save (truthy when save succeeded)
|
||||
*/
|
||||
async saveButtonClicked(params) {
|
||||
const result = await super.saveButtonClicked(params);
|
||||
if (result) {
|
||||
this._lastLocalSave = Date.now();
|
||||
}
|
||||
return result;
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
/** @odoo-module */
|
||||
|
||||
import {
|
||||
getLoadedRecordIds,
|
||||
hasAnyLoadedIdInRecIds,
|
||||
} from "../utils/get_loaded_record_ids.esm";
|
||||
import {KanbanController} from "@web/views/kanban/kanban_controller";
|
||||
import {onWillUnmount} from "@odoo/owl";
|
||||
import {patch} from "@web/core/utils/patch";
|
||||
import {useService} from "@web/core/utils/hooks";
|
||||
import {_t} from "@web/core/l10n/translation";
|
||||
|
||||
patch(KanbanController.prototype, {
|
||||
setup() {
|
||||
super.setup(...arguments);
|
||||
// Bus_service is async; useService("bus_service") breaks (SERVICES_METADATA).
|
||||
this.busService = this.env.services.bus_service;
|
||||
this.notificationService = useService("notification");
|
||||
this._isRefreshInFlight = false;
|
||||
this._hasRefreshQueued = false;
|
||||
|
||||
this._boundBusHandler = this._onWebRefreshNotification.bind(this);
|
||||
this.busService.subscribe("web.refresh_view", this._boundBusHandler);
|
||||
|
||||
onWillUnmount(() => {
|
||||
if (this.busService && this._boundBusHandler) {
|
||||
this.busService.unsubscribe("web.refresh_view", this._boundBusHandler);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Handle a web.refresh_view bus notification for this kanban.
|
||||
* Called once per notification; coalesces concurrent refreshes via _queueRefresh.
|
||||
*
|
||||
* @param {Object} payload - Notification payload {model, view_types, rec_ids}
|
||||
*/
|
||||
async _onWebRefreshNotification(payload) {
|
||||
if (!this.model || !this.model.root) {
|
||||
return;
|
||||
}
|
||||
if (this._shouldRefreshView(payload)) {
|
||||
await this._queueRefresh("refreshList");
|
||||
}
|
||||
},
|
||||
|
||||
async _queueRefresh(methodName) {
|
||||
if (this._isRefreshInFlight) {
|
||||
this._hasRefreshQueued = true;
|
||||
return;
|
||||
}
|
||||
this._isRefreshInFlight = true;
|
||||
try {
|
||||
do {
|
||||
this._hasRefreshQueued = false;
|
||||
await this[methodName]();
|
||||
} while (this._hasRefreshQueued);
|
||||
} finally {
|
||||
this._isRefreshInFlight = false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Check whether a refresh notification is relevant to this kanban.
|
||||
*
|
||||
* Returns true when all of the following hold:
|
||||
* - model matches current kanban model
|
||||
* - requested view types include "kanban" (or none specified)
|
||||
* - at least one loaded record id is in rec_ids (or none specified)
|
||||
*
|
||||
* @param {Object} payload - Notification payload
|
||||
* @returns {Boolean}
|
||||
*/
|
||||
_shouldRefreshView(payload) {
|
||||
const {model, view_types = [], rec_ids = []} = payload;
|
||||
|
||||
if (this.props.resModel !== model) {
|
||||
return false;
|
||||
}
|
||||
if (view_types.length > 0 && !view_types.includes("kanban")) {
|
||||
return false;
|
||||
}
|
||||
if (rec_ids.length > 0) {
|
||||
const loadedIds = getLoadedRecordIds(this.model.root);
|
||||
if (!hasAnyLoadedIdInRecIds(loadedIds, rec_ids)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
},
|
||||
|
||||
/**
|
||||
* Refresh the kanban with actual data from server.
|
||||
*
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async refreshList() {
|
||||
if (!this.model || !this.model.root) {
|
||||
return;
|
||||
}
|
||||
|
||||
const list = this.model.root;
|
||||
|
||||
try {
|
||||
await list.load();
|
||||
} catch (error) {
|
||||
this.notificationService.add(this._getRefreshErrorMessage(error), {
|
||||
type: "danger",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.model && this.model.root) {
|
||||
this.render(true);
|
||||
}
|
||||
},
|
||||
|
||||
_getRefreshErrorMessage(error) {
|
||||
const message =
|
||||
(error && error.data && error.data.message) ||
|
||||
(error && error.message) ||
|
||||
String(error);
|
||||
return _t("Could not reload kanban. %(message)s", {message});
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,170 @@
|
||||
/** @odoo-module */
|
||||
|
||||
import {
|
||||
getLoadedRecordIds,
|
||||
hasAnyLoadedIdInRecIds,
|
||||
} from "../utils/get_loaded_record_ids.esm";
|
||||
import {ConfirmationDialog} from "@web/core/confirmation_dialog/confirmation_dialog";
|
||||
import {ListController} from "@web/views/list/list_controller";
|
||||
import {onWillUnmount} from "@odoo/owl";
|
||||
import {patch} from "@web/core/utils/patch";
|
||||
import {useService} from "@web/core/utils/hooks";
|
||||
import {_t} from "@web/core/l10n/translation";
|
||||
|
||||
patch(ListController.prototype, {
|
||||
setup() {
|
||||
super.setup(...arguments);
|
||||
// Bus_service is async; useService("bus_service") breaks (SERVICES_METADATA).
|
||||
this.busService = this.env.services.bus_service;
|
||||
this.notificationService = useService("notification");
|
||||
this._isRefreshInFlight = false;
|
||||
this._hasRefreshQueued = false;
|
||||
|
||||
this._boundBusHandler = this._onWebRefreshNotification.bind(this);
|
||||
this.busService.subscribe("web.refresh_view", this._boundBusHandler);
|
||||
|
||||
onWillUnmount(() => {
|
||||
if (this.busService && this._boundBusHandler) {
|
||||
this.busService.unsubscribe("web.refresh_view", this._boundBusHandler);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Handle a web.refresh_view bus notification for this list.
|
||||
* Called once per notification; coalesces concurrent refreshes via _queueRefresh.
|
||||
*
|
||||
* @param {Object} payload - Notification payload {model, view_types, rec_ids}
|
||||
*/
|
||||
async _onWebRefreshNotification(payload) {
|
||||
if (!this.model || !this.model.root) {
|
||||
return;
|
||||
}
|
||||
if (this._shouldRefreshView(payload)) {
|
||||
await this._queueRefresh("refreshList");
|
||||
}
|
||||
},
|
||||
|
||||
async _queueRefresh(methodName) {
|
||||
if (this._isRefreshInFlight) {
|
||||
this._hasRefreshQueued = true;
|
||||
return;
|
||||
}
|
||||
this._isRefreshInFlight = true;
|
||||
try {
|
||||
do {
|
||||
this._hasRefreshQueued = false;
|
||||
await this[methodName]();
|
||||
} while (this._hasRefreshQueued);
|
||||
} finally {
|
||||
this._isRefreshInFlight = false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Check whether a refresh notification is relevant to this list.
|
||||
*
|
||||
* Returns true when all of the following hold:
|
||||
* - model matches current list model
|
||||
* - requested view types include "list" or "tree" (or none specified)
|
||||
* - at least one loaded record id is in rec_ids (or none specified)
|
||||
*
|
||||
* @param {Object} payload - Notification payload
|
||||
* @returns {Boolean}
|
||||
*/
|
||||
_shouldRefreshView(payload) {
|
||||
const {model, view_types = [], rec_ids = []} = payload;
|
||||
|
||||
if (this.props.resModel !== model) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
view_types.length > 0 &&
|
||||
!view_types.includes("list") &&
|
||||
!view_types.includes("tree")
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (rec_ids.length > 0) {
|
||||
const loadedIds = getLoadedRecordIds(this.model.root);
|
||||
if (!hasAnyLoadedIdInRecIds(loadedIds, rec_ids)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
},
|
||||
|
||||
/**
|
||||
* Refresh the list with actual data from server.
|
||||
* If there is an edited record, asks the user to save or cancel.
|
||||
*
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async refreshList() {
|
||||
if (!this.model || !this.model.root) {
|
||||
return;
|
||||
}
|
||||
|
||||
const list = this.model.root;
|
||||
|
||||
if (list.editedRecord) {
|
||||
const confirmed = await this._confirmListRefresh();
|
||||
|
||||
if (!confirmed) {
|
||||
// User declined: drop coalesced refreshes queued during the dialog.
|
||||
this._hasRefreshQueued = false;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await list.editedRecord.save();
|
||||
} catch (error) {
|
||||
this.notificationService.add(this._getSaveErrorMessage(error), {
|
||||
type: "danger",
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await list.load();
|
||||
} catch (error) {
|
||||
this.notificationService.add(this._getReloadErrorMessage(error), {
|
||||
type: "danger",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.model && this.model.root) {
|
||||
this.render(true);
|
||||
}
|
||||
},
|
||||
|
||||
async _confirmListRefresh() {
|
||||
return await new Promise((resolve) => {
|
||||
this.dialogService.add(ConfirmationDialog, {
|
||||
title: _t("List is being refreshed from backend"),
|
||||
body: _t("You have unsaved edits. Save them before refreshing?"),
|
||||
confirm: () => resolve(true),
|
||||
cancel: () => resolve(false),
|
||||
confirmLabel: _t("Save & Refresh"),
|
||||
cancelLabel: _t("Cancel"),
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
_getSaveErrorMessage(error) {
|
||||
const message =
|
||||
(error && error.data && error.data.message) ||
|
||||
(error && error.message) ||
|
||||
String(error);
|
||||
return _t("Could not save record. %(message)s", {message});
|
||||
},
|
||||
|
||||
_getReloadErrorMessage(error) {
|
||||
const message =
|
||||
(error && error.data && error.data.message) ||
|
||||
(error && error.message) ||
|
||||
String(error);
|
||||
return _t("Could not reload list. %(message)s", {message});
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
/** @odoo-module */
|
||||
|
||||
/**
|
||||
* Get IDs of records currently loaded in list-like root models.
|
||||
* Supports both plain and grouped datasets.
|
||||
*
|
||||
* @param {Object} root - View root model (list/kanban)
|
||||
* @returns {Array<Number>}
|
||||
*/
|
||||
export function getLoadedRecordIds(root) {
|
||||
if (root.isGrouped) {
|
||||
const recordIds = [];
|
||||
const collectIds = (groups) => {
|
||||
for (const group of groups) {
|
||||
if (group.list && group.list.records) {
|
||||
recordIds.push(...group.list.records.map((record) => record.resId));
|
||||
}
|
||||
if (group.groups) {
|
||||
collectIds(group.groups);
|
||||
}
|
||||
}
|
||||
};
|
||||
collectIds(root.groups);
|
||||
return recordIds;
|
||||
}
|
||||
return root.records.map((record) => record.resId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether any loaded record id is present in the notification id list.
|
||||
* Uses a Set for O(n + m) membership checks instead of O(n * m) with includes.
|
||||
*
|
||||
* @param {Number[]} loadedIds - IDs currently visible in the view
|
||||
* @param {Number[]} rec_ids - IDs from the bus payload
|
||||
* @returns {Boolean}
|
||||
*/
|
||||
export function hasAnyLoadedIdInRecIds(loadedIds, rec_ids) {
|
||||
const recIdSet = new Set(rec_ids);
|
||||
return loadedIds.some((id) => recIdSet.has(id));
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a single record id is in the notification id list.
|
||||
* Uses a Set for O(m) build + O(1) lookup vs repeated includes.
|
||||
*
|
||||
* @param {Number|undefined|false} resId - Current record id (e.g. form root)
|
||||
* @param {Number[]} rec_ids - IDs from the bus payload
|
||||
* @returns {Boolean}
|
||||
*/
|
||||
export function isResIdInRecIds(resId, rec_ids) {
|
||||
if (!resId) {
|
||||
return false;
|
||||
}
|
||||
return new Set(rec_ids).has(resId);
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
/** @odoo-module */
|
||||
/* global QUnit */
|
||||
|
||||
import "cx_web_refresh_from_backend/static/src/views/form/form_controller_patch.esm";
|
||||
import "cx_web_refresh_from_backend/static/src/views/kanban/kanban_controller_patch.esm";
|
||||
import "cx_web_refresh_from_backend/static/src/views/list/list_controller_patch.esm";
|
||||
|
||||
import {
|
||||
editInput,
|
||||
getFixture,
|
||||
makeDeferred,
|
||||
nextTick,
|
||||
} from "@web/../tests/helpers/utils";
|
||||
import {
|
||||
makeView,
|
||||
makeViewInDialog,
|
||||
setupViewRegistries,
|
||||
} from "@web/../tests/views/helpers";
|
||||
|
||||
let serverData = null;
|
||||
let target = null;
|
||||
|
||||
/**
|
||||
* Simulate a web.refresh_view notification on the patched controller.
|
||||
*
|
||||
* The unit tests exercise the controller filtering and refresh logic, so they
|
||||
* can call the public notification handler directly instead of reproducing the
|
||||
* bus service internals.
|
||||
*
|
||||
* @param {Object} controller - Patched view controller instance
|
||||
* @param {Object} payload - {model, view_types, rec_ids}
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
function triggerRefresh(controller, payload) {
|
||||
return controller._onWebRefreshNotification(payload);
|
||||
}
|
||||
|
||||
QUnit.module("cx_web_refresh_from_backend", (hooks) => {
|
||||
hooks.beforeEach(() => {
|
||||
serverData = {
|
||||
models: {
|
||||
"res.partner": {
|
||||
fields: {
|
||||
name: {string: "Name", type: "char"},
|
||||
},
|
||||
records: [
|
||||
{id: 1, name: "Partner 1"},
|
||||
{id: 2, name: "Partner 2"},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
setupViewRegistries();
|
||||
target = getFixture();
|
||||
});
|
||||
|
||||
QUnit.test(
|
||||
"form: refresh runs only for matching notifications",
|
||||
async function (assert) {
|
||||
const form = await makeView({
|
||||
type: "form",
|
||||
resModel: "res.partner",
|
||||
serverData,
|
||||
resId: 1,
|
||||
arch: '<form><field name="name"/></form>',
|
||||
});
|
||||
|
||||
let refreshCalls = 0;
|
||||
form.refreshForm = async () => {
|
||||
refreshCalls++;
|
||||
};
|
||||
|
||||
triggerRefresh(form, {
|
||||
model: "res.users",
|
||||
view_types: ["form"],
|
||||
rec_ids: [1],
|
||||
});
|
||||
triggerRefresh(form, {
|
||||
model: "res.partner",
|
||||
view_types: ["list"],
|
||||
rec_ids: [1],
|
||||
});
|
||||
triggerRefresh(form, {
|
||||
model: "res.partner",
|
||||
view_types: ["form"],
|
||||
rec_ids: [2],
|
||||
});
|
||||
triggerRefresh(form, {
|
||||
model: "res.partner",
|
||||
view_types: ["form"],
|
||||
rec_ids: [1],
|
||||
});
|
||||
await nextTick();
|
||||
|
||||
assert.strictEqual(refreshCalls, 1);
|
||||
}
|
||||
);
|
||||
|
||||
QUnit.test(
|
||||
"form in dialog: matching notification is ignored",
|
||||
async function (assert) {
|
||||
const form = await makeViewInDialog({
|
||||
type: "form",
|
||||
resModel: "res.partner",
|
||||
serverData,
|
||||
resId: 1,
|
||||
arch: '<form><field name="name"/></form>',
|
||||
});
|
||||
|
||||
let refreshCalls = 0;
|
||||
form.refreshForm = async () => {
|
||||
refreshCalls++;
|
||||
};
|
||||
|
||||
triggerRefresh(form, {
|
||||
model: "res.partner",
|
||||
view_types: ["form"],
|
||||
rec_ids: [1],
|
||||
});
|
||||
await nextTick();
|
||||
|
||||
assert.strictEqual(refreshCalls, 0);
|
||||
}
|
||||
);
|
||||
|
||||
QUnit.test(
|
||||
"form: dirty form reloads from backend without confirmation dialog",
|
||||
async function (assert) {
|
||||
const form = await makeView({
|
||||
type: "form",
|
||||
resModel: "res.partner",
|
||||
serverData,
|
||||
resId: 1,
|
||||
arch: '<form><field name="name"/></form>',
|
||||
});
|
||||
|
||||
await form.model.root.switchMode("edit");
|
||||
await editInput(
|
||||
target,
|
||||
".o_field_widget[name='name'] input",
|
||||
"Changed Name"
|
||||
);
|
||||
|
||||
triggerRefresh(form, {
|
||||
model: "res.partner",
|
||||
view_types: ["form"],
|
||||
rec_ids: [1],
|
||||
});
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
|
||||
assert.containsNone(
|
||||
target,
|
||||
".modal",
|
||||
"backend refresh must not open a confirmation dialog"
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
QUnit.test("list: burst notifications are coalesced", async function (assert) {
|
||||
const list = await makeView({
|
||||
type: "list",
|
||||
resModel: "res.partner",
|
||||
serverData,
|
||||
arch: '<list><field name="name"/></list>',
|
||||
});
|
||||
|
||||
const deferred = makeDeferred();
|
||||
let refreshCalls = 0;
|
||||
list.refreshList = async () => {
|
||||
refreshCalls++;
|
||||
if (refreshCalls === 1) {
|
||||
await deferred;
|
||||
}
|
||||
};
|
||||
|
||||
const payload = {model: "res.partner", view_types: ["list"], rec_ids: [1]};
|
||||
triggerRefresh(list, payload);
|
||||
triggerRefresh(list, payload);
|
||||
triggerRefresh(list, payload);
|
||||
await nextTick();
|
||||
|
||||
assert.strictEqual(
|
||||
refreshCalls,
|
||||
1,
|
||||
"only one refresh should run while in flight"
|
||||
);
|
||||
|
||||
deferred.resolve();
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
|
||||
assert.strictEqual(
|
||||
refreshCalls,
|
||||
2,
|
||||
"one additional refresh should run after in-flight refresh finishes"
|
||||
);
|
||||
});
|
||||
|
||||
QUnit.test("kanban: burst notifications are coalesced", async function (assert) {
|
||||
const kanban = await makeView({
|
||||
type: "kanban",
|
||||
resModel: "res.partner",
|
||||
serverData,
|
||||
arch: '<kanban><templates><t t-name="card"><div><field name="name"/></div></t></templates></kanban>',
|
||||
});
|
||||
|
||||
const deferred = makeDeferred();
|
||||
let refreshCalls = 0;
|
||||
kanban.refreshList = async () => {
|
||||
refreshCalls++;
|
||||
if (refreshCalls === 1) {
|
||||
await deferred;
|
||||
}
|
||||
};
|
||||
|
||||
const payload = {model: "res.partner", view_types: ["kanban"], rec_ids: [1]};
|
||||
triggerRefresh(kanban, payload);
|
||||
triggerRefresh(kanban, payload);
|
||||
triggerRefresh(kanban, payload);
|
||||
await nextTick();
|
||||
|
||||
assert.strictEqual(
|
||||
refreshCalls,
|
||||
1,
|
||||
"only one refresh should run while in flight"
|
||||
);
|
||||
|
||||
deferred.resolve();
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
|
||||
assert.strictEqual(
|
||||
refreshCalls,
|
||||
2,
|
||||
"one additional refresh should run after in-flight refresh finishes"
|
||||
);
|
||||
});
|
||||
});
|
||||
4
addons/cx_web_refresh_from_backend/tests/__init__.py
Normal file
4
addons/cx_web_refresh_from_backend/tests/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
# Copyright 2025 Cetmix OÜ
|
||||
# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl).
|
||||
|
||||
from . import test_reload_views
|
||||
@@ -0,0 +1,78 @@
|
||||
# Copyright 2025 Cetmix OÜ
|
||||
# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl).
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
from odoo.tests import tagged
|
||||
|
||||
from odoo.addons.base.tests.common import BaseCommon
|
||||
|
||||
|
||||
@tagged("post_install", "-at_install")
|
||||
class TestReloadViews(BaseCommon):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
cls.user_admin = cls.env.ref("base.user_admin")
|
||||
cls.user_demo = cls.env["res.users"].create(
|
||||
{
|
||||
"name": "Test User",
|
||||
"login": "test_refresh_user",
|
||||
"email": "test_refresh@example.com",
|
||||
}
|
||||
)
|
||||
|
||||
def test_reload_views_basic(self):
|
||||
"""Test basic reload_views call without parameters"""
|
||||
with patch.object(type(self.env["bus.bus"]), "_sendone") as mock_sendone:
|
||||
self.user_admin.reload_views(model="res.partner")
|
||||
|
||||
mock_sendone.assert_called_once()
|
||||
partner, channel, message = mock_sendone.call_args[0]
|
||||
self.assertEqual(partner, self.user_admin.partner_id)
|
||||
self.assertEqual(channel, "web.refresh_view")
|
||||
self.assertEqual(message["model"], "res.partner")
|
||||
self.assertEqual(message["view_types"], [])
|
||||
self.assertEqual(message["rec_ids"], [])
|
||||
|
||||
def test_reload_views_with_params(self):
|
||||
"""Test reload_views with view_types and rec_ids parameters"""
|
||||
with patch.object(type(self.env["bus.bus"]), "_sendone") as mock_sendone:
|
||||
self.user_admin.reload_views(
|
||||
model="res.partner",
|
||||
view_types=["form", "kanban"],
|
||||
rec_ids=[self.partner.id],
|
||||
)
|
||||
|
||||
mock_sendone.assert_called_once()
|
||||
message = mock_sendone.call_args[0][2]
|
||||
self.assertEqual(message["view_types"], ["form", "kanban"])
|
||||
self.assertEqual(message["rec_ids"], [self.partner.id])
|
||||
|
||||
def test_reload_views_recordset(self):
|
||||
"""Test reload_views on a multi-record user recordset.
|
||||
|
||||
Ensures that calling reload_views on a recordset sends one notification
|
||||
per user through _sendone.
|
||||
"""
|
||||
users = self.user_admin | self.user_demo
|
||||
|
||||
with patch.object(type(self.env["bus.bus"]), "_sendone") as mock_sendone:
|
||||
users.reload_views(model="res.partner")
|
||||
|
||||
self.assertEqual(mock_sendone.call_count, 2)
|
||||
|
||||
# Verify both users' partners are notified and payload is correct.
|
||||
notified_partners = set()
|
||||
for call in mock_sendone.call_args_list:
|
||||
partner, channel, message = call[0]
|
||||
notified_partners.add(partner)
|
||||
self.assertEqual(channel, "web.refresh_view")
|
||||
self.assertEqual(message["model"], "res.partner")
|
||||
self.assertEqual(message["view_types"], [])
|
||||
self.assertEqual(message["rec_ids"], [])
|
||||
self.assertEqual(len(notified_partners), 2)
|
||||
self.assertEqual(
|
||||
notified_partners,
|
||||
{self.user_admin.partner_id, self.user_demo.partner_id},
|
||||
)
|
||||
@@ -1,3 +0,0 @@
|
||||
# Pyarmor 9.2.3 (basic), 009742, subscription_packages, 2026-03-02T02:54:00.548137
|
||||
from .pyarmor_runtime_009742 import __pyarmor__
|
||||
__pyarmor__(__name__, __file__, b'PY009742\x00\x03\t\x00a\r\r\n\x80\x00\x01\x00\x08\x00\x00\x00\x04\x00\x00\x00@\x00\x00\x00_\x04\x00\x00\x12\t\x05\x00\xe2\xf5bdff\x8b\xfd@x\xee\xd0\xcf\x91\x97E\x00\x00\x00\x00\x00\x00\x00\x007\t{\xa7Q\xfb#\x11\x9f\x7f\xd1\x7f\\\x9d\x84\xbd\xa4\x1bS\xed\xc1S\x12E\xce\xd4 \x0c\xbf\xf0P\xca\xdf\x88O<\xb3\x19\x16)\xaba\xce\xdc\xed\xc8\x9e\xf8\xd6\xb4T\xdb^\x02\xe9\x13\xd9\x05p\x86\xccr0#\x04\xb9\xc8 \'\xe9V\xe7\x03\xda\xec\xf2ol\x99\xb37pc\xc8\xaeip\xb8\xb0U\x03\x02\xb6+H\xda\xff\xb3j\xcf\xd6\x1e\xf7VU\x19\x90\xd9\x07e\xa9\xcc\xd6\x15T`\x88\x90B\xd3/\x10\xe4\xe6b\xa0\x19\x0c\x14\xf9\x1b\xd9]\xd6=\xb9\xc9U\xe1\xe4\xd6b-\x9f\xd7\x12\x13\xdd\xc6\xda\xa4\xd9\xa9\x8f3\x02\xda\xcd\xf0E*Z\xfd\xe2\xba\xd6T\x0eQ\xfd\'XM\xc2#i \xb8O!4\xab\xfd\xacS\xda^\x08\xcf\xb1\x9c\xa4\xcb\x00\x06\x89\xafI\xcd>F\x8e\x91.\xeb\x94+E\x89<w\x15\x86]p\x86rS;\xdc:\xf9\xdainE\x80"\x9fA3\xca\xfa\x96\xdev`\x06C\t\xf8\xa5.\x98\x04%\xd5\x8d\xc59\x892\x0c\xc9a\xbfqa\xc4\xac\x80\xe0\xab\xa6\xf9fHd\xa0\xbd\xfe?)\x0eQ\xca\xeeol\xa2\xf8\xd5\xb11\x95_\xda[]\x19\x82\xb4\xe0Iv\xb2\x95\xdcx\xb2\x11\x94\x9bL\x7f\xce\x9e\xb5O\xd4n\xc5?\xfaRJ\x06\xa5\xf5\x95\xe7\x85\xb3gg0\x80\x8b\x8b\x93\xbb\xdb\xed]\xcdc:\xeb\xaa,#?\xd0n\x86.\xd2\x17\x0b\x89\x89vGB\xf9\xdb\xe4\xe44\xa2\xc7&\xdf\xc0f\x140|s\xfe\x95\x1aT\x83\xb9\xe9\x8f\xbd\x94G\x07\xb0\xb3X[\x9d\xb1\x80\xb8\xbd\xa7\x8dGiH\xf9\x92\xaa\xed\xa1\x12\x06Y\xb1\xa4\x83\x07\x8c\xe8E\x87^\xaca[\xf0\xf8\xc4I\xeb\x1dN8mW\xb0\x011\n/X\x8e9\xd8\x8c\x11\\\x15"\xb9%-\xb7\x84\xcd\xa6\x1d\xc1\xc3]\x1b\xaf\xa9\xba\xab\xfd\x99\xf1`\xf3-\x07\x8f\x07,\x89\x9b\x11\x8e\r\xfc\xa8\x8bG3\xd9\xfaF\xf5-6\xfa\xdd(\xcf\xc4J\x0f\x9e6m\xb2\'\x9e]8u\xaf+o~\xdeS`M\xf5\xd4\\\x98\x88\x02\x9d\x87U\xa1\xc3q\xab\r\xd5\x81h\xc1v*\x18\xaf\xdb\xc8v\x83\x19G6\x08\x1fA\x7f\xccK\x0b\xd0\x89G\xc7\xe3\xfb\xa9\x93\xb6\xc1D\xb2\xc9\x9d6\n|\xbd\x00\x0e\xec\xe2\xcf\x7f\x86\xdd\xd2\x82\x99\xa1\x00<~\xe1_\x1c8k\xcf\xef\xa3d\xdd\xef-IK\x10\x17\xc0\xc9C?\x02x\x8ey 2u\xda\x8eDAh\xa2Ah\x9a\xfc\xfb\\w"_\t\x12\xae\xfcZG\x01\xcc\xe1\x1c\xab\x98k?\x8e8\xe3\x94+\x1b\n\xf9D\xec\xdd\xf0\x12\xeb\x0b\xe4=}\x88\x97\x8c\xf3\xa7\xc8\x18\x99\xd2>\t\xf9e\xea\xa1FxD+c\xe4V\x14\xe74\xd2\x8e\xf9\',\xe1\xfck,\x14:0\xba?q\x10\xdc\xee;Y\xa7\x1e\x14\x93h\xc5xg]F\x06J\xae\xfa\xa5c\x91n\t\x0c#\x1f+>\xa9\xd8\x03kB\xe6\xb3\x8cyc\xdc!\x92\xfa\x1da\xf1K\x85t$\xd2\xf6\xb2\x8b\x0bH_\xacSq\xbe\xb4\xb3yVq\xe3\xbb\x8c.)\\\xf2\xc6\xb2\x1d>y\xbf\xea-b\xe6l\r\x9cPnTu`\x13!\x14\x8bj\x94N\xf4\x94\x16\x19\x8bih\xca.G\x19Ys\x01\xb1\x15\xce\xfbk\xd0\xe6\xd6\xa8n\x8eK+\x9f\x85\xc7\x02\xc4"T\xd7\x98s\x08tw:\xfa\x08\xcd\xc9F\xa1Hr\x03\xf6u\x8f\xcdE\xdbdb\xe6\xfd\x8a[VI1I\x8e\x9f\xf7\xc3\x1a5\xeb\xa0\xa5a0\xb1\xf5je\x0e\xf3\xebe\xf0\xb0\x83b{S/\xebO\xf3\xd2p\xb5<\xe1\xbb\xd5\xf4\xcds\xbdwO \xc9\xbfA\xa1\xfbT`\xf4<\x10\x02\xb1-\x88\xe9H\xcd\x19d\xbdEP\x17\xde\x1a\xe4/\xd0\xb7\xbf\xf1m\x12U\xb7zD\x03\xbf\xd5\xf1\x80/\xa1\x1d\xa0\n,\xdb>vk\x1e\xd3\xb3\xe9\xe8b\x01\xf9U\xad\xad4\xbd%\xeb}\x83\xb5\x1e\xdfo\x0e~6\xa9\xbcE\xab\xc9\xd9\x93@5\xe5T\x05\x82\x03\xcf\xa4@\xa0\xe9\xbc\xc0\x9b\x04\xf3\x90K\xe9]6\x96\xa4\x8c\xff9m35\x03\x16\xdc\x91} c\x89r\xed0\xea\xc6\x1f\x06#\xbf\x8e\xd9\xb8\x9b9l\x1a8\xb8\xb5+\x1a\xdd@x3\xb0*\xe8\xc0F[bG\x9d\x86\xd0n\x0b.\x06\x8a\xd3-U\xdd>\xb7\xc23D\n\\\x7fY\xc1\x80\xbe7\xd4\x85\xf9\xdf\xf5\xa3\xd7\xeb\x92\xdd:\xbc\xf7e %BK4\x95\x9c\t\xe5\x01N\xfa\x7f\x16\x82"_\xb0\x0c$\xf2f1h\xd6\xb4\xd8@\x0f\x8e\xa8')
|
||||
@@ -1,58 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
{
|
||||
'name': 'Odoo Arabic Fonts',
|
||||
'version': '16.0.2.0.0',
|
||||
'summary': 'إدارة موحدة للخطوط العربية - 6 خطوط',
|
||||
'description': '''
|
||||
Odoo Arabic Fonts - إضافة لتحسين الخطوط العربية في أودوو
|
||||
========================================================
|
||||
|
||||
المميزات:
|
||||
---------
|
||||
* 6 خطوط عربية احترافية:
|
||||
- دبي (Dubai) - عصري
|
||||
- الجزيرة (Al Jazeera) - إخباري
|
||||
- القاهرة (Cairo) - احترافي
|
||||
- تجوال (Tajawal) - أنيق
|
||||
- أميري (Amiri) - نسخي تقليدي
|
||||
- المراعي (Almarai) - سعودي احترافي
|
||||
* إعدادات مرنة لأحجام الخطوط
|
||||
* ثلاثة أنماط جاهزة (مدمج - متوازن - مريح)
|
||||
* دعم خطوط الطباعة والتقارير
|
||||
* تحسين واجهة المستخدم للغة العربية
|
||||
* توحيد إدارة الخطوط للباك إند والتقارير
|
||||
''',
|
||||
'category': 'Tools',
|
||||
'author': 'Mostafa Elhavari',
|
||||
'website': 'https://havari.me',
|
||||
'maintainer': 'Mostafa Elhavari <m@havari.me>',
|
||||
'support': 'm@havari.me',
|
||||
'license': 'LGPL-3',
|
||||
# Developer Contact: +90 543 774 3103 (WhatsApp)
|
||||
'depends': ['base', 'web', 'havari_license_client'],
|
||||
'data': [
|
||||
'security/ir.model.access.csv',
|
||||
'views/assets.xml',
|
||||
'views/res_config_settings_views.xml',
|
||||
'views/base_document_layout_views.xml',
|
||||
'report/report_templates.xml',
|
||||
],
|
||||
'assets': {
|
||||
'web.assets_backend': [
|
||||
'havari_arabic_fonts/static/src/scss/fonts.scss',
|
||||
'havari_arabic_fonts/static/src/scss/variables.scss',
|
||||
'havari_arabic_fonts/static/src/scss/backend.scss',
|
||||
'havari_arabic_fonts/static/src/scss/presets/balanced.scss',
|
||||
'havari_arabic_fonts/static/src/js/font_settings.js',
|
||||
'havari_arabic_fonts/static/src/js/font_preview_simple.js',
|
||||
],
|
||||
'web.report_assets_common': [
|
||||
'havari_arabic_fonts/static/src/scss/fonts.scss',
|
||||
'havari_arabic_fonts/static/src/scss/report.scss',
|
||||
],
|
||||
},
|
||||
'installable': True,
|
||||
'auto_install': False,
|
||||
'application': True,
|
||||
'post_init_hook': '_register_license',
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
# Pyarmor 9.2.3 (basic), 009742, subscription_packages, 2026-03-02T02:54:00.569512
|
||||
from ..pyarmor_runtime_009742 import __pyarmor__
|
||||
__pyarmor__(__name__, __file__, b'PY009742\x00\x03\t\x00a\r\r\n\x80\x00\x01\x00\x08\x00\x00\x00\x04\x00\x00\x00@\x00\x00\x00\xee\x01\x00\x00\x12\t\x05\x00\x10;\xe8\x1eTQ\xc6\xb2\xaf!\xa74\xdf\xcfb\xed\x00\x00\x00\x00\x00\x00\x00\x00\xe7Z+\xff}\xef\xcc\x08\x82\\h\xeei\xc2\x08C\xba<\x94\x0fW\xd4\xc3\xf7\xf2,\x17E\xe7\xee\x89\x9fW\xc3H\xba\xb7\x98\xe5\xef~I\xb1H\xf0\xd6s\xeb\x1e!I\xcd\xd1{\x16\x14\xeb\xf7@XW\xe9p\x1eXu\xed\xe7\xbb\x1d\xa9\xb5B\x1c\xf2\xa0\x19\xc1s9\xf4\xce\x119\xd3\x0c\xb4\x13P\x85z\xd9\x8b\xdc\xf1\x8c\xa4\x83U{\xad\xf8a@H\xdb}!|z\x80^\x8dy\'A\xf1\xaf\xf8\x81\x08\x03\xf6\xa4\x9c\xb8h\xbb\x17\x8c\xf9\xe7\xc0\xc1\x9cuCJX~\x10\xcam1F~kb\x12\xb5\xedf\x80\xd8s\xc8C\x13o\xbbx\xecG\xcc\xc8\x035\xf6\xd9\xd9\xd7\x80\x8ad\x87y\x86k\xef\xe0\xbc\xde\x08\xbe!\xaeV\x7f\xd4\xa3\xcf\x1fi(\r\x92\xc0\x9bW\xfc%\\;50\x85\xc7\xd6D\xa4\x96&\xec\x88\xbb\xefu4O\xb4w;\xaav\xe6\xc61\x13>q\x8c,Lu\xefu\xdf\xd9\xd9\x11bZ\xb6f\xf5\x0c\xc3\xbc\xf4\x9dE\x1cI\xd2&\xfb\xd9\x8d\x03\x05!a\xb1\xbe\x10A\x8e\xb5\x81\x0e\xe6\xe2\r)\xd5\xf3\x12\x99\x07\xb0\x8f\xcb=:\x9f\xec\xa0B\x19\xe2\x1fJ\\yq\xb5\xfa\r\xd3\xe1\'W\xfaI\x16Ij\xaa*\xd2\x9c\xeb\xf4\x81\xedJ\x80\t+\x89\xa8\xb5\x9e-\xbe\xbf\xde)\xf2W\xeb\xd7\xf4UF\xfe\x1d\x7fX\xb9[^\x14\x8a;\xf1\xacsr\xe0\xd5\x92dy\xf8T\xd9s\xad2Z\'\xa9h\xb5\xb6\x81\xeb] \xfe\x88\x8d\xc5\x99\xcd\xb5C\xdc;l\xe5\xf8x?\xa6A\xc85\xa4\xa1\xe3\xc3n\x12\x14Q1\n\xc1|\xf7\x87VS5\xb9\x00S\xd1\xe6a\xc0\xd9+\xca1\x92(\x1d\x93\x7fdD\x0c\x80\x93W\xe3\xec\x94\xcaI\xda\x19}\x7fVT\xf2\xa5\xd5\x96\xd6\x1e#\x03b\xa2\xc1\x85"\x86\xd6_\x02\xca[\x16\xcf\x1c\x92tN\xf4Dd\x8ej4s\xe5\xd0\x856h<\x1a\x83\xfc\x85\xb3^\x99\xa3\x92P\x96\x9bQ\x8d\x9f\\$')
|
||||
@@ -1,3 +0,0 @@
|
||||
# Pyarmor 9.2.3 (basic), 009742, subscription_packages, 2026-03-02T02:54:00.562657
|
||||
from ..pyarmor_runtime_009742 import __pyarmor__
|
||||
__pyarmor__(__name__, __file__, b'PY009742\x00\x03\t\x00a\r\r\n\x80\x00\x01\x00\x08\x00\x00\x00\x04\x00\x00\x00@\x00\x00\x00\xbd\x03\x00\x00\x12\t\x05\x00NR}\xb1\xe1\xf4Q\xf5\xa18\xbf%\xd8>\xabq\x00\x00\x00\x00\x00\x00\x00\x00\xe3\x1d5J_\'\xd8-\x14\x12M5&X[\xa0\r(\x9c2\nD\x0f\x12\x03\x17\xcb,2\xcdHl\xfcj\xba\xb5\x8dE\x13\xaepw\x1bN\x8b\xd2\x8dW6s\xe5\xd5\xa2\x11fs\xdfx\x8c\'\x83B\xb8\x93cQ\xdaQ\x05[\xafl\x81]I\xd9^t\xe7\xb8\xfb^\x04Z\xf6\xd5\xbdo\x8f\xdfuI\xf6k\xe6\x1fJ\xde\xbc\xd7\xdf\xf1{a\xcbI\x91\xfd\xfd\x904\xb6\x85:nQ\xde\x13\xd3o\xbaqi\xc4^0Q\xc2J\x1e\x0fo\x83(d\xba--\x97A\x1b\x14q\xc6\x05\xa4\x06\xda}\x12\x00\xa7q\xf3\x7f\x07\xf3\xba\x8dM\xbekd-\x1d\xf1\xa7\xd5\x03-T\xc8\x1b"\x0f\x10$\xfb\x08\x98\xa7\x10\xbeLy\xa915\x07\x1c\xd5\x91OSU\x80\xc1\xba\xcd\x00\x8a\xe9 \xcc;\xfa\x9bIQ<\xca{\n\xf2\xa2J\x0f\xd4\xa5\x8e\xbbV\xf5\xa2H\x9eeL?\x0c\xfa\xa4Sj\x00\xffF!\x0f\x90\xb5T\xe3\x17\x02\x057\x13\x00\xe2\x95\xff\xaf=\x10\xa9\x17\xad%u\xcb\x7f8\x88(\xedk\xfe\x12\xf4C\x9cR_\xa7\xb4\xe2N\x0cNCD\xcb>P\xc4Fv\x03\'\xd3\x14@\x893\x94\x1f\xc8\xf4U\x99e\xcb\x9a\xae((\xebR\x08;E\xbd\x91u\xd8!m\xeb\x8f2\xb0\xda\xad\xe9\x83[Z\x944Hc\x87\xf8\x9csK#[\'m\xd4m\xac\x88\x9c\xea\xfbmu\x83`\x01\x81\xdf\xc9m\xa7\xec\xf1\x87r\xfbj\xcd_\xed\xa2\x08\xf4\xcd\x06\xb9\x08\xd5B\x89\xa6g\xf8Rz\xe4\x1d\xab?cw\x11\xea\xadl6\x92\x0e*\xb6\xdc4\xd0U\xd6X\xbf&R}\x08_\x13\xb9\xa3:[lH^\x83\xa9\x01J\xab\x8aB:F2\xd9\xcd\xf7\x82\xec\x82\xd0\xdbx[\x95\x99\xa9\xfb=\x0c\r\x90k\xd6\x07\xd5v\xd0\x97\xed\xe5Z\x1dJ\xc6\xa8z?\x08J?\x15\x98\x80\xd8 \x8dq\\\xb8\'\xfa\xe6e(\x99\x13b\x95\xee\xf9\xfbc\x05\xf1\xba\x8dD\xd6\xbf\xad\x1a\xd8X\xdf/\xae\xf8v\x1b7\x8c\x1d\xd8\xf5\x92$db/\x03wx\x98)\xdee\xb5\r\x19cf\xe0lM\x89+xl{\x81O\xe1\'\xa4\x8d\xab\x1c,k\x89\xe7\x16\xa2\x1b\x00\xb42\x950\xe4+e\x16\x9c\xaa%\xa2\x0bj{D\x0cm\x10\x8fq\xd2\xa9\xe6ax\xff^!&F\x1b\xc1Y\xba\xbcB\xc2\xe3qm\xf4+\xe8m\xf26l\x01\xd73\x91\x10&\xf4\xab\x0e\x99>\x1c\x88\xcdG_F\xacA]~b\x02F\xd1\xea[\x87\x84E\x1c\xdb\x18\xb47b6\x8ehv\x022d\x02\xf6b\xc4\xd8vU\xa6^\x1d\xb7\xff\xd1\x87\xd3R\x8b\xa9\xabvx\x81\xe6\xb8:%2{]p\xc0y*\x90\xfd\xf5)\x1b\xfdE\x1d\xfd\xd2~\rRC,\xb2\xe7%\x1d\x83\'\xe5\xbf\x83\x82\x11\xf3\xb6z\x80u\xe5\x12\\\x07\xa1\x91\x11\xd1\x1dj\xd2[x\x91\x01\xc4\xb67\xff\x99\xc6\n^7\\\xf4\xee\x05\xe1~R\x8d_1\xb0\xd2\xe9\x04g\x05\x85\x90\xd9\x1d\x7f\xd5]\xd1\x7f\x8fJ\xb3P\x00H\xf8\xdb\x10\xf6\xa3\xe0\xa2\x13a\xdf\xe39\\\x15\x97\xfa\xbd`U\xb7\x88\x87\xef\x00\xc1\x84\xb3\x87\nH#\xc8\xbf\xd1\x10\xa1\xca\xe4\xddm@\xd5\xb4D\x98GRN\xb9\xf1x=&D\xd1vY\xf1F{\xf1\xe7\xf4j+\x19Aj\xb1\xc5\xcdZ]:\xdd\xeb\\h\x87\xa9\xcey0\xc1\x18\x03\x82\xbd\r[\x04(o\xdd\xf0\xf2\x98\xaf\x11\x8et\xc7*\xbb\xf3;s\x86\r\xa9-,%\xc1\x7f.B;\xe1B\x80\xf5\xfai$S\xefe\x9c\xefU\xd7\x9a\xe2\xdd.\x10\xd8\x88\x18`\xb2\xf2)\xfd\xeb\x08^\x87S\x8c\xa23\x1eM\xf8/m]h\x8aP\xe5\x88\x94A\x12V\xf7S\xc8\x13q2+\xda!n6\xf0\xb3x\xdb\xc5\x90;\x16\xd9\x93\xbc\xf5\xe9.!\x8f\x96\xae@')
|
||||
File diff suppressed because one or more lines are too long
@@ -1,3 +0,0 @@
|
||||
# Pyarmor 9.2.3 (basic), 009742, subscription_packages, 2026-03-02T02:54:00.576354
|
||||
from ..pyarmor_runtime_009742 import __pyarmor__
|
||||
__pyarmor__(__name__, __file__, b'PY009742\x00\x03\t\x00a\r\r\n\x80\x00\x01\x00\x08\x00\x00\x00\x04\x00\x00\x00@\x00\x00\x00\x0c\x03\x00\x00\x12\t\x05\x00\xd0\xdb\xb0v\xde\x07j}[\xe6\xb3\xd4\x9d\x0c\xad4\x00\x00\x00\x00\x00\x00\x00\x00\xb9\x8f\xde#\x94\xc5\xce<\xb7\x0c50\xcax\xefC\xe1[\xb6.<~\xb8M\xd3\x0b{e\x96\x9e\x87\xc1{H\x01Vo\xd6\x8f\xdd\xe6\xd8\xe4\xe3\x9e?X\xa2\xe9\xa3$Q\xfa\xab\xdeg\\d1%\xb8j\xb5C\xc0^\x8f\xddV\xe9a\xa8K\x19\xe3+\x94[\x8e\xbd\xe7\xb7\xa2\xc3\xf6\xab\x01f7\x18\xaau\x91\xd2\xb4&\x95\x1c:\x19i^\xf9Z\xe6!\x8e6\t\x01\x0e\xa7\x9a\x1a:\x7fqqOj\x06\xad)g\x11\xbc\x00\xa3\xc4O\x06\x82\x089\x96`\x1e\x00\xcfh\xe5S\x91\x83\xf1+\xe1\xacmOk\xdd0\xc5n\x1b\x92\xca\xc4\xfb\xd5Z\x00d\x0f\x0fj\x81\xff6\xcf\xf7\xcf\xa8i\xb1\xebg\xa3\xf0\xd2|\xc6\x83\xda\x94moW\x96NK\x86%\xcf_\n$|\x057=3\x8bK\xf0cS \xf8%k\xcd\xb5\xb8\xf5\x97\xa3\xa3\x15\x008\x87\x99%\x10af`\xe7\xad\xcb\x08\x01\x7f\xcca\xd7\x14mh\xa4\x03\xfd\xa2\xfbM`\x95\x97\x89\x9d\x8fY\x87\xd5h`\x07\x18\xc9\xe3\x0e\xfe\xdf\xeb\xf4s\xeb\xcc\x02@\xc5I$qU)2U\xd7]\x8ef*N\x9dR\xd5^d+\x93\xba\xff\x91C\x1f\x8bX\xd9y\xb0\xbd\xb2\xfc\x00SN\xa8cM\xe7\x05M\xc6\xa6\x996k\x9c\xb0`\x10#Af!\xdb`o\xf1\xec\x12\xb8\x10-\xf7\xf6\x14R\xdb\x84\x85\xdb\xbb\x03\xab\xdb\x98\xef\xcd\xc1c\xf7\xcd\xe3_\x17\xe2\x83\xca\xc3`n2\\\xc0w\xd1\x8a!|L\xc8U\xd7\x9d\x11\xac\xa0\xc7\xc6\xe5\x17\xff\x9bw6\xad\xb9n\x84&\xf9\xdc4x\x02eWa\xb1\xb3\x13\xae7\nj\xd8J\x86\'\x99s\x91\x0f\xde8!\xd1\xa7\n%@\x19\xf5[=\no\x03sBT?=\xd6\x0c\xb6+\x9c\xe6\x0f6\x9e\xe3 \xd8\x12D\xe8\xe6\ry\x08\xb0\xe8<E\x80\xf1OQ\xd4H\x1f\x08\x0f\xa1\xe0\x9e\x91\x04=\xca\xeb\xbc42P#\xfb\x0b\xdfi\x85\x08g\xe1Z\x8dZJ\xe3f\x96\xcd\xbf\x88[q\x8c\xa3A\xd4N\xe18\xd4\'\xec\x03\xea\x92\xba\x9089%\xdf\x0b\x9b\x07\xb3\xa9\x17\xe0\'U\x13i\x7fm\xd6\rV\xff\xcbR\xdf\xe0\xdaYpu\xd0\xd8\x05h%%\x07\x84"\xe81\xf6\xc0\xee\x08ETD\x99g\xaeZ\xe7\x99\xba\xb4\x84\xe0\x8f\x83\xf8\x8d\xc8\x83\xb6\xdf\xf6\x84\xfc\x00\t8\xc7\xff\x90\xe9Sk\xf7\xd1\xe4\xaf\xfd\xe5\xb4O\xa3\x08\xbd\xcc\x16H\xddl\xab\xa2TB(@\xe4\x8e\xe7\x9f\'D\x19A}\xa5\x95<\xfd\x1d\xdbu\xda5I\xcb\xa7u\xc6\xde\xa5\xe8\x16a\x13\xa40Td*_\xf7\x00 \x8e\xa4\x0b\xd0\x1f\x13\xa0\x0c\x7f!\x18x\xb8!\xd8H\xda\xeej\xdf$\xc2\xfd.xi\xec\x12\x08\xa6\xbfkk]\n\xdf\xa7\xda\x19O\x18\x02\xd0\xcf\x02\xe8\xb6\x0b\x97\xa8\x8a\xd8\xbb\xf2\xc7H\xb4\xfdv8\x87\xc0;\xc4\xd42"(\x93&+s\xa4O\x9b\x89\xe9\x9cQ\x136b\x11\xe0P^9?!\xb9\x9b\xee\x08\xa3\x88\xd5\xa2\xb6c\xdd\xb7\xc76\xcd\xe3\xffW\x9a\xb2h\x92\xfb\xb5\x8fVO\xf9G0@\xe3\xdc\xefio\xa8')
|
||||
File diff suppressed because one or more lines are too long
@@ -1,2 +0,0 @@
|
||||
# Pyarmor 9.2.3 (basic), 009742, 2026-03-02T02:54:00.531077
|
||||
from .pyarmor_runtime import __pyarmor__
|
||||
Binary file not shown.
@@ -1,311 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<!--
|
||||
Font Fourth Arabic - Report Templates
|
||||
قوالب التقارير والطباعة مع دعم RTL
|
||||
18 خط عربي محلي - لا يتطلب اتصال بالإنترنت
|
||||
|
||||
Note: Header/Footer functionality moved to havari_odoo_printx module
|
||||
-->
|
||||
|
||||
<!-- وراثة تخطيط التقارير الأساسي -->
|
||||
<template id="report_layout_custom_fonts" inherit_id="web.report_layout">
|
||||
<xpath expr="//head" position="inside">
|
||||
<style>
|
||||
/* ======================================
|
||||
تحميل جميع الخطوط العربية للتقارير
|
||||
====================================== */
|
||||
|
||||
/* خط دبي */
|
||||
@font-face {
|
||||
font-family: 'Dubai';
|
||||
src: url('/havari_arabic_fonts/static/fonts/dubai/Dubai-Light.ttf') format('truetype');
|
||||
font-weight: 300;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Dubai';
|
||||
src: url('/havari_arabic_fonts/static/fonts/dubai/Dubai-Regular.ttf') format('truetype');
|
||||
font-weight: 400;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Dubai';
|
||||
src: url('/havari_arabic_fonts/static/fonts/dubai/Dubai-Medium.ttf') format('truetype');
|
||||
font-weight: 500;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Dubai';
|
||||
src: url('/havari_arabic_fonts/static/fonts/dubai/Dubai-Bold.ttf') format('truetype');
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* خط الجزيرة */
|
||||
@font-face {
|
||||
font-family: 'Al Jazeera';
|
||||
src: url('/havari_arabic_fonts/static/fonts/aljazeera/AlJazeera-Light.ttf') format('truetype');
|
||||
font-weight: 300;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Al Jazeera';
|
||||
src: url('/havari_arabic_fonts/static/fonts/aljazeera/AlJazeera-Regular.ttf') format('truetype');
|
||||
font-weight: 400;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Al Jazeera';
|
||||
src: url('/havari_arabic_fonts/static/fonts/aljazeera/AlJazeera-Medium.ttf') format('truetype');
|
||||
font-weight: 500;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Al Jazeera';
|
||||
src: url('/havari_arabic_fonts/static/fonts/aljazeera/AlJazeera-Bold.ttf') format('truetype');
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* خط القاهرة */
|
||||
@font-face {
|
||||
font-family: 'Cairo';
|
||||
src: url('/havari_arabic_fonts/static/fonts/cairo/Cairo-Light.ttf') format('truetype');
|
||||
font-weight: 300;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Cairo';
|
||||
src: url('/havari_arabic_fonts/static/fonts/cairo/Cairo-Regular.ttf') format('truetype');
|
||||
font-weight: 400;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Cairo';
|
||||
src: url('/havari_arabic_fonts/static/fonts/cairo/Cairo-Medium.ttf') format('truetype');
|
||||
font-weight: 500;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Cairo';
|
||||
src: url('/havari_arabic_fonts/static/fonts/cairo/Cairo-Bold.ttf') format('truetype');
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* خط تجوال */
|
||||
@font-face {
|
||||
font-family: 'Tajawal';
|
||||
src: url('/havari_arabic_fonts/static/fonts/tajawal/Tajawal-Light.ttf') format('truetype');
|
||||
font-weight: 300;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Tajawal';
|
||||
src: url('/havari_arabic_fonts/static/fonts/tajawal/Tajawal-Regular.ttf') format('truetype');
|
||||
font-weight: 400;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Tajawal';
|
||||
src: url('/havari_arabic_fonts/static/fonts/tajawal/Tajawal-Medium.ttf') format('truetype');
|
||||
font-weight: 500;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Tajawal';
|
||||
src: url('/havari_arabic_fonts/static/fonts/tajawal/Tajawal-Bold.ttf') format('truetype');
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* خط أميري */
|
||||
@font-face {
|
||||
font-family: 'Amiri';
|
||||
src: url('/havari_arabic_fonts/static/fonts/amiri/Amiri-Regular.ttf') format('truetype');
|
||||
font-weight: 400;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Amiri';
|
||||
src: url('/havari_arabic_fonts/static/fonts/amiri/Amiri-Bold.ttf') format('truetype');
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* خط المراعي */
|
||||
@font-face {
|
||||
font-family: 'Almarai';
|
||||
src: url('/havari_arabic_fonts/static/fonts/almarai/Almarai-Light.ttf') format('truetype');
|
||||
font-weight: 300;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Almarai';
|
||||
src: url('/havari_arabic_fonts/static/fonts/almarai/Almarai-Regular.ttf') format('truetype');
|
||||
font-weight: 400;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Almarai';
|
||||
src: url('/havari_arabic_fonts/static/fonts/almarai/Almarai-Bold.ttf') format('truetype');
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* ====================================== */
|
||||
/* الخطوط الجديدة - 12 خط إضافي */
|
||||
/* ====================================== */
|
||||
|
||||
/* خط IBM Plex Sans Arabic */
|
||||
@font-face {
|
||||
font-family: 'IBM Plex Sans Arabic';
|
||||
src: url('/havari_arabic_fonts/static/fonts/ibmplexsansarabic/IBMPlexSansArabic-Light.ttf') format('truetype');
|
||||
font-weight: 300;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'IBM Plex Sans Arabic';
|
||||
src: url('/havari_arabic_fonts/static/fonts/ibmplexsansarabic/IBMPlexSansArabic-Regular.ttf') format('truetype');
|
||||
font-weight: 400;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'IBM Plex Sans Arabic';
|
||||
src: url('/havari_arabic_fonts/static/fonts/ibmplexsansarabic/IBMPlexSansArabic-Medium.ttf') format('truetype');
|
||||
font-weight: 500;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'IBM Plex Sans Arabic';
|
||||
src: url('/havari_arabic_fonts/static/fonts/ibmplexsansarabic/IBMPlexSansArabic-Bold.ttf') format('truetype');
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* خط Noto Sans Arabic */
|
||||
@font-face {
|
||||
font-family: 'Noto Sans Arabic';
|
||||
src: url('/havari_arabic_fonts/static/fonts/noto-sans-arabic/NotoSansArabic-Light.ttf') format('truetype');
|
||||
font-weight: 300;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Noto Sans Arabic';
|
||||
src: url('/havari_arabic_fonts/static/fonts/noto-sans-arabic/NotoSansArabic-Regular.ttf') format('truetype');
|
||||
font-weight: 400;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Noto Sans Arabic';
|
||||
src: url('/havari_arabic_fonts/static/fonts/noto-sans-arabic/NotoSansArabic-Medium.ttf') format('truetype');
|
||||
font-weight: 500;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Noto Sans Arabic';
|
||||
src: url('/havari_arabic_fonts/static/fonts/noto-sans-arabic/NotoSansArabic-Bold.ttf') format('truetype');
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* خط Noto Naskh Arabic */
|
||||
@font-face {
|
||||
font-family: 'Noto Naskh Arabic';
|
||||
src: url('/havari_arabic_fonts/static/fonts/noto-naskh-arabic/NotoNaskhArabic-Regular.ttf') format('truetype');
|
||||
font-weight: 400;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Noto Naskh Arabic';
|
||||
src: url('/havari_arabic_fonts/static/fonts/noto-naskh-arabic/NotoNaskhArabic-Bold.ttf') format('truetype');
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* خط Noto Kufi Arabic */
|
||||
@font-face {
|
||||
font-family: 'Noto Kufi Arabic';
|
||||
src: url('/havari_arabic_fonts/static/fonts/noto-kufi-arabic/NotoKufiArabic-Regular.ttf') format('truetype');
|
||||
font-weight: 400;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Noto Kufi Arabic';
|
||||
src: url('/havari_arabic_fonts/static/fonts/noto-kufi-arabic/NotoKufiArabic-Bold.ttf') format('truetype');
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* خط Readex Pro */
|
||||
@font-face {
|
||||
font-family: 'Readex Pro';
|
||||
src: url('/havari_arabic_fonts/static/fonts/readex-pro/ReadexPro-Regular.ttf') format('truetype');
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
/* خط Scheherazade */
|
||||
@font-face {
|
||||
font-family: 'Scheherazade';
|
||||
src: url('/havari_arabic_fonts/static/fonts/scheherazade/Scheherazade-Regular.ttf') format('truetype');
|
||||
font-weight: 400;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Scheherazade';
|
||||
src: url('/havari_arabic_fonts/static/fonts/scheherazade/Scheherazade-Bold.ttf') format('truetype');
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* خط Reem Kufi */
|
||||
@font-face {
|
||||
font-family: 'Reem Kufi';
|
||||
src: url('/havari_arabic_fonts/static/fonts/reem-kufi/ReemKufi-Regular.ttf') format('truetype');
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
/* خط El Messiri */
|
||||
@font-face {
|
||||
font-family: 'El Messiri';
|
||||
src: url('/havari_arabic_fonts/static/fonts/el-messiri/ElMessiri-Regular.ttf') format('truetype');
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
/* خط Markazi Text */
|
||||
@font-face {
|
||||
font-family: 'Markazi Text';
|
||||
src: url('/havari_arabic_fonts/static/fonts/markazi-text/MarkaziText-Regular.ttf') format('truetype');
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
/* خط Mada */
|
||||
@font-face {
|
||||
font-family: 'Mada';
|
||||
src: url('/havari_arabic_fonts/static/fonts/mada/Mada-Regular.ttf') format('truetype');
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
/* خط Changa */
|
||||
@font-face {
|
||||
font-family: 'Changa';
|
||||
src: url('/havari_arabic_fonts/static/fonts/changa/Changa-Regular.ttf') format('truetype');
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
/* خط Aref Ruqaa */
|
||||
@font-face {
|
||||
font-family: 'Aref Ruqaa';
|
||||
src: url('/havari_arabic_fonts/static/fonts/aref-ruqaa/ArefRuqaa-Regular.ttf') format('truetype');
|
||||
font-weight: 400;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Aref Ruqaa';
|
||||
src: url('/havari_arabic_fonts/static/fonts/aref-ruqaa/ArefRuqaa-Bold.ttf') format('truetype');
|
||||
font-weight: 700;
|
||||
}
|
||||
</style>
|
||||
</xpath>
|
||||
</template>
|
||||
|
||||
<!-- تنسيقات RTL المحسنة للتقارير - تعمل فقط عندما يكون الاتجاه RTL -->
|
||||
<template id="report_rtl_styles" inherit_id="web.report_layout">
|
||||
<xpath expr="//head" position="inside">
|
||||
<style>
|
||||
/* === تنسيقات RTL للغات العربية والعبرية === */
|
||||
/* تعمل فقط عندما يحدد Odoo dir="rtl" تلقائيًا */
|
||||
body[dir="rtl"] .page {
|
||||
direction: rtl;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* تنسيقات جداول RTL */
|
||||
body[dir="rtl"] table,
|
||||
body[dir="rtl"] thead,
|
||||
body[dir="rtl"] tbody,
|
||||
body[dir="rtl"] tr {
|
||||
direction: rtl;
|
||||
}
|
||||
|
||||
body[dir="rtl"] th,
|
||||
body[dir="rtl"] td {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* استثناء الأرقام والعملات - دائما LTR */
|
||||
.o_price_total,
|
||||
.text-end,
|
||||
.amount-col {
|
||||
direction: ltr;
|
||||
}
|
||||
</style>
|
||||
</xpath>
|
||||
</template>
|
||||
|
||||
</odoo>
|
||||
@@ -1 +0,0 @@
|
||||
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||
|
BIN
addons/havari_arabic_fonts/static/.DS_Store
vendored
BIN
addons/havari_arabic_fonts/static/.DS_Store
vendored
Binary file not shown.
Binary file not shown.
|
Before Width: | Height: | Size: 53 KiB |
@@ -1,55 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" width="256" height="256">
|
||||
<defs>
|
||||
<linearGradient id="bgGrad3" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#1a1a2e"/>
|
||||
<stop offset="50%" style="stop-color:#16213e"/>
|
||||
<stop offset="100%" style="stop-color:#0f3460"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="goldGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#f7d794"/>
|
||||
<stop offset="50%" style="stop-color:#f5cd79"/>
|
||||
<stop offset="100%" style="stop-color:#e1a83b"/>
|
||||
</linearGradient>
|
||||
<filter id="glow" x="-50%" y="-50%" width="200%" height="200%">
|
||||
<feGaussianBlur stdDeviation="3" result="coloredBlur"/>
|
||||
<feMerge>
|
||||
<feMergeNode in="coloredBlur"/>
|
||||
<feMergeNode in="SourceGraphic"/>
|
||||
</feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<!-- Background rounded square - dark elegant -->
|
||||
<rect x="8" y="8" width="240" height="240" rx="48" ry="48" fill="url(#bgGrad3)"/>
|
||||
|
||||
<!-- Decorative Islamic geometric pattern border -->
|
||||
<rect x="24" y="24" width="208" height="208" rx="36" ry="36" fill="none" stroke="url(#goldGrad)" stroke-width="2" opacity="0.4"/>
|
||||
|
||||
<!-- Arabic letter "ع" (Ain) - represents Arabic beautifully -->
|
||||
<g filter="url(#glow)">
|
||||
<path d="M165 85
|
||||
Q175 85 180 95
|
||||
Q185 110 175 125
|
||||
Q165 140 145 145
|
||||
L120 150
|
||||
Q95 155 85 175
|
||||
Q80 190 90 200
|
||||
Q100 210 120 205
|
||||
L140 198"
|
||||
fill="none" stroke="url(#goldGrad)" stroke-width="14" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
|
||||
<!-- Stylized Latin "F" for Fonts -->
|
||||
<g transform="translate(55, 70)" opacity="0.9">
|
||||
<path d="M30 0 L30 90 M30 0 L70 0 M30 40 L60 40"
|
||||
fill="none" stroke="white" stroke-width="10" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
|
||||
<!-- Three dots decoration (Arabic style) -->
|
||||
<circle cx="188" cy="70" r="6" fill="url(#goldGrad)"/>
|
||||
<circle cx="205" cy="85" r="5" fill="url(#goldGrad)" opacity="0.7"/>
|
||||
<circle cx="198" cy="55" r="4" fill="url(#goldGrad)" opacity="0.5"/>
|
||||
|
||||
<!-- Bottom decorative line -->
|
||||
<line x1="60" y1="225" x2="196" y2="225" stroke="url(#goldGrad)" stroke-width="3" stroke-linecap="round" opacity="0.6"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 2.3 KiB |
@@ -1,215 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html dir="rtl" lang="ar">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Font Fourth Arabic</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: 'Segoe UI', Tahoma, sans-serif;
|
||||
direction: rtl;
|
||||
text-align: right;
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
.header {
|
||||
background: linear-gradient(135deg, #714B67, #875A7B);
|
||||
color: white;
|
||||
padding: 40px;
|
||||
border-radius: 10px;
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
.header h1 {
|
||||
margin: 0 0 10px 0;
|
||||
font-size: 2.5em;
|
||||
}
|
||||
.header p {
|
||||
margin: 0;
|
||||
opacity: 0.9;
|
||||
font-size: 1.2em;
|
||||
}
|
||||
.section {
|
||||
background: white;
|
||||
padding: 30px;
|
||||
border-radius: 10px;
|
||||
margin-bottom: 20px;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||
}
|
||||
.section h2 {
|
||||
color: #714B67;
|
||||
border-bottom: 2px solid #714B67;
|
||||
padding-bottom: 10px;
|
||||
margin-top: 0;
|
||||
}
|
||||
.features {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
.feature {
|
||||
background: #f9f9f9;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
border-right: 4px solid #714B67;
|
||||
}
|
||||
.feature h3 {
|
||||
margin: 0 0 10px 0;
|
||||
color: #333;
|
||||
}
|
||||
.feature p {
|
||||
margin: 0;
|
||||
color: #666;
|
||||
}
|
||||
.font-preview {
|
||||
background: #f0f0f0;
|
||||
padding: 30px;
|
||||
border-radius: 8px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
.font-preview h3 {
|
||||
font-size: 24px;
|
||||
margin: 10px 0;
|
||||
}
|
||||
.font-preview p {
|
||||
font-size: 16px;
|
||||
color: #555;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 20px 0;
|
||||
}
|
||||
th, td {
|
||||
padding: 12px;
|
||||
text-align: right;
|
||||
border-bottom: 1px solid #ddd;
|
||||
}
|
||||
th {
|
||||
background: #714B67;
|
||||
color: white;
|
||||
}
|
||||
tr:hover {
|
||||
background: #f5f5f5;
|
||||
}
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 5px 10px;
|
||||
background: #28a745;
|
||||
color: white;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
margin-left: 10px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="header">
|
||||
<h1>Font Fourth Arabic</h1>
|
||||
<p>تحسين الخطوط العربية في أودوو</p>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>نظرة عامة</h2>
|
||||
<p>
|
||||
إضافة Font Fourth Arabic توفر تحسيناً شاملاً للخطوط العربية في نظام أودوو،
|
||||
مع دعم كامل لخط دبي وخط الجزيرة، وإمكانية التحكم الكامل في الأحجام والأوزان.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>المميزات</h2>
|
||||
<div class="features">
|
||||
<div class="feature">
|
||||
<h3>خطوط احترافية</h3>
|
||||
<p>دعم خط دبي وخط الجزيرة بجميع الأوزان</p>
|
||||
</div>
|
||||
<div class="feature">
|
||||
<h3>تحكم كامل</h3>
|
||||
<p>إعدادات مرنة لأحجام وأوزان الخطوط</p>
|
||||
</div>
|
||||
<div class="feature">
|
||||
<h3>ثلاثة أنماط</h3>
|
||||
<p>أنماط جاهزة: مدمج، متوازن، مريح</p>
|
||||
</div>
|
||||
<div class="feature">
|
||||
<h3>دعم التقارير</h3>
|
||||
<p>خطوط محسنة للطباعة والتقارير</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>الخطوط المدعومة</h2>
|
||||
<table>
|
||||
<tr>
|
||||
<th>الخط</th>
|
||||
<th>الوصف</th>
|
||||
<th>الاستخدام المقترح</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>دبي</strong></td>
|
||||
<td>خط عصري وأنيق من حكومة دبي</td>
|
||||
<td>النصوص والقوائم</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>الجزيرة</strong></td>
|
||||
<td>خط احترافي وواضح</td>
|
||||
<td>العناوين والأقسام</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>الإعدادات</h2>
|
||||
<p>يمكنك الوصول للإعدادات من:</p>
|
||||
<p><strong>الإعدادات ← الخطوط العربية</strong></p>
|
||||
<table>
|
||||
<tr>
|
||||
<th>الإعداد</th>
|
||||
<th>الوصف</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>النمط العام</td>
|
||||
<td>مدمج / متوازن / مريح</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>خط العناوين</td>
|
||||
<td>اختيار الخط للعناوين</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>خط النصوص</td>
|
||||
<td>اختيار الخط للنصوص</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>الأوزان</td>
|
||||
<td>من خفيف إلى عريض</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>حجم الخط</td>
|
||||
<td>من 12px إلى 15px</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>التثبيت</h2>
|
||||
<ol>
|
||||
<li>انسخ المجلد إلى مسار الإضافات</li>
|
||||
<li>قم بتحديث قائمة التطبيقات</li>
|
||||
<li>ثبت إضافة "Fourth Arabic Fonts"</li>
|
||||
<li>أضف ملفات الخطوط (دبي والجزيرة) إلى مجلد static/fonts</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>الدعم</h2>
|
||||
<p>للدعم والاستفسارات، تواصل معنا عبر:</p>
|
||||
<p><strong>Fourth</strong></p>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user