first push message

This commit is contained in:
2026-07-01 14:41:49 +07:00
parent 6667dec2bf
commit 58b5f46cc4
2951 changed files with 316619 additions and 0 deletions
+562
View File
@@ -0,0 +1,562 @@
# xf_doc_approval_custom — Developer Guide
## Table of Contents
1. [Project Overview](#1-project-overview)
2. [Folder Structure](#2-folder-structure)
3. [How Odoo Inheritance Works](#3-how-odoo-inheritance-works)
4. [Inheriting a Model (Python)](#4-inheriting-a-model-python)
5. [Inheriting a View (XML)](#5-inheriting-a-view-xml)
6. [Inheriting CSS / Styles](#6-inheriting-css--styles)
7. [Inheriting Fonts (Google Fonts)](#7-inheriting-fonts-google-fonts)
8. [Key Techniques Used in This Module](#8-key-techniques-used-in-this-module)
9. [Docker Commands](#9-docker-commands)
10. [Rules — What NOT to Touch](#10-rules--what-not-to-touch)
---
## 1. Project Overview
| Item | Value |
|------|-------|
| Base module | `xf_doc_approval` (located at `d:\project_v19s\xf_doc_approval`) |
| Custom module | `xf_doc_approval_custom` (located at `d:\project_v19s\odoo19-dev\addons\xf_doc_approval_custom`) |
| Odoo version | 19.0 |
| Purpose | Extend the base module with Khmer language labels, new fields, redesigned form, QR code, and role-based comment visibility — **without modifying the original module** |
### What we changed vs the base module
| Feature | What we did |
|---------|-------------|
| Navbar color | Changed to `#0a5e98` via CSS |
| Font | Added Khmer font (Battambang) for Khmer text only |
| State labels | Overrode all state labels to Khmer |
| Section ក (Approvers) | Renamed to Khmer, added sent_date, round, Khmer column headers, document source row |
| Section ខ (Content) | Completely new section with 8 custom fields in a styled table |
| New fields | `document_type`, `decision_requester_id`, `reference_note`, `reference_file`, `doc_number`, `qr_code`, `document_source`, `description` (Html override) |
| QR Code | Auto-generated when document is approved |
| Auto numbering | `ir.sequence` → format `DOC/YYYY/MM/0001` |
| Comment visibility | Step-based: each approver sees only notes from their step and earlier |
| Button label | "Send for Approval" → "បញ្ជូន" |
---
## 2. Folder Structure
```
xf_doc_approval_custom/
├── __manifest__.py ← Module declaration (name, depends, data files)
├── __init__.py ← Python package init (imports models/)
├── models/
│ ├── __init__.py ← Imports all model files
│ ├── document_package.py ← Inherits xf.doc.approval.document.package
│ └── document_approver.py ← Inherits xf.doc.approval.document.approver
├── views/
│ ├── assets.xml ← Registers CSS + Google Fonts injection
│ └── document_package_form.xml ← Inherits and modifies the form view
├── data/
│ └── sequences.xml ← Creates ir.sequence for document numbering
└── static/
└── src/
└── css/
└── navbar.css ← All custom CSS (navbar, table, section ខ)
```
---
## 3. How Odoo Inheritance Works
Odoo has **3 layers** you can extend without touching the original code:
```
Original Module Your Custom Module
───────────────── ────────────────────────────────────
models/ models/ ← _inherit = 'original.model.name'
views/ views/ ← inherit_id = ref('original.view.id')
static/css/ static/ ← registered via ir.asset record
```
The **golden rule**: never edit the original module. All changes live in your custom module.
---
## 4. Inheriting a Model (Python)
### Basic pattern
```python
# models/my_model.py
from odoo import api, fields, models
class MyCustomExtension(models.Model):
_inherit = 'original.model.name' # ← this is the key line
# Add new fields
my_new_field = fields.Char(string='My Field')
# Override an existing field (change labels, default, etc.)
state = fields.Selection(
selection=[('draft', 'New Label'), ('done', 'Finished')],
# Only list what you want to CHANGE. Odoo merges the rest.
)
# Override an existing method
def action_confirm(self):
# Do something before
result = super().action_confirm() # ← always call super()
# Do something after
return result
```
### How we used it — `document_package.py`
```python
class DocApprovalDocumentPackageCustom(models.Model):
_inherit = 'xf.doc.approval.document.package'
# 1. New fields added to existing model
document_type = fields.Selection(...)
doc_number = fields.Char(default='New')
qr_code = fields.Image(max_width=0, max_height=0)
# 2. Override existing field labels only
state = fields.Selection(
selection=[('draft', 'បង្កើតលិខិតឯកសារ'), ...]
)
# 3. Override existing field type (Text → Html)
description = fields.Html(translate=True, sanitize=False)
# 4. Override a method — inject QR generation after approval
def action_finish_approval(self):
res = super().action_finish_approval() # run original logic first
approved = self.filtered(lambda r: r.state == 'approved')
approved._generate_qr_code()
return res
```
### Field types reference
| Field | Use case |
|-------|----------|
| `fields.Char` | Short text (one line) |
| `fields.Text` | Long plain text |
| `fields.Html` | Rich text with editor toolbar |
| `fields.Integer` | Whole number |
| `fields.Float` | Decimal number |
| `fields.Boolean` | True/False checkbox |
| `fields.Date` | Date only |
| `fields.Datetime` | Date + time |
| `fields.Selection` | Dropdown list |
| `fields.Many2one` | Link to one other record |
| `fields.One2many` | List of related records |
| `fields.Many2many` | Multiple related records |
| `fields.Binary` | File / raw bytes |
| `fields.Image` | Image file (served via /web/image/) |
### Important: `__init__.py` must import every model file
```python
# models/__init__.py
from . import document_package
from . import document_approver
```
---
## 5. Inheriting a View (XML)
### Basic pattern
```xml
<record id="my_custom_view" model="ir.ui.view">
<field name="name">my.custom.view.name</field>
<field name="model">original.model.name</field>
<!-- Point to the original view's XML id -->
<field name="inherit_id" ref="original_module.original_view_id"/>
<field name="arch" type="xml">
<!-- Each change is one <xpath> block -->
<xpath expr="//SELECTOR" position="POSITION">
<!-- your content here -->
</xpath>
</field>
</record>
```
### Finding the original view's `ref`
1. Go to **Settings → Technical → Views**
2. Search for the model name
3. The **External ID** column shows the ref, e.g. `xf_doc_approval.xf_doc_approval_document_package_form`
### xpath `expr` — how to select elements
| Selector | Example | Selects |
|----------|---------|---------|
| By element name | `//form` | The `<form>` element |
| By name attribute | `//group[@name='approvers']` | `<group name="approvers">` |
| By field name | `//field[@name='user_id']` | `<field name="user_id">` |
| By button name | `//button[@name='action_confirm']` | `<button name="action_confirm">` |
| Nested | `//field[@name='line_ids']//list//field[@name='qty']` | `qty` field inside list inside `line_ids` |
### xpath `position` — where to insert
| Position | What it does |
|----------|-------------|
| `before` | Insert before the selected element |
| `after` | Insert after the selected element |
| `inside` | Insert as child at the end |
| `replace` | Replace the selected element entirely |
| `attributes` | Only change attributes of the element |
### Examples used in this module
```xml
<!-- 1. Change a button label -->
<xpath expr="//button[@name='action_send_for_approval']" position="attributes">
<attribute name="string">បញ្ជូន</attribute>
</xpath>
<!-- 2. Hide an element -->
<xpath expr="//div[@class='oe_title']" position="attributes">
<attribute name="invisible">1</attribute>
</xpath>
<!-- 3. Rename a group header -->
<xpath expr="//group[@name='approvers']" position="attributes">
<attribute name="string">ក. ឋានានុក្រមលំហូរឯកសារ</attribute>
</xpath>
<!-- 4. Add a new element BEFORE another -->
<xpath expr="//group[@name='approvers']/field[@name='approver_ids']" position="before">
<div class="my-class">
<field name="my_new_field" nolabel="1"/>
</div>
</xpath>
<!-- 5. Add a column to a list -->
<xpath expr="//field[@name='approver_ids']//list//field[@name='notes']" position="after">
<field name="sent_date" string="កាលបរិច្ឆេទ"/>
</xpath>
<!-- 6. Replace a field with another -->
<xpath expr="//field[@name='approver_ids']//list//field[@name='notes']" position="replace">
<field name="notes_display" string="មតិយោបល់" readonly="1"/>
</xpath>
<!-- 7. Add a whole new group after an existing one -->
<xpath expr="//group[@name='approvers']" position="after">
<group string="ខ. ខ្លឹមសារ" name="section_b">
<field name="document_type" nolabel="1"/>
</group>
</xpath>
```
### Conditional visibility
```xml
<!-- hide when state is not draft -->
<field name="my_field" readonly="state != 'draft'"/>
<!-- hide column in list -->
<field name="method" column_invisible="True"/>
<!-- hide entire group conditionally -->
<group invisible="state != 'approved' or not qr_code">
...
</group>
```
---
## 6. Inheriting CSS / Styles
### Step 1 — Create the CSS file
```
static/src/css/navbar.css
```
### Step 2 — Register it in `assets.xml`
```xml
<record id="my_module_css" model="ir.asset">
<field name="bundle">web.assets_backend</field>
<field name="path">my_module/static/src/css/navbar.css</field>
</record>
```
The bundle `web.assets_backend` means it loads in the Odoo backend (not the website/portal).
### CSS selectors used in this module
```css
/* Target by field name (most reliable) */
[name="approver_ids"] .o_list_table thead th { ... }
/* Target Odoo's built-in classes */
.o_main_navbar { background-color: #0a5e98 !important; }
/* Target a custom class added via view */
.o_xf_section_b { ... }
.o_xf_row { ... }
.o_xf_cell_label { ... }
/* Target buttons */
button.oe_highlight { background-color: #0a5e98 !important; }
```
### Important CSS rules for Odoo
```css
/* Always use !important to override Odoo's built-in Bootstrap styles */
.my-class { color: red !important; }
/* Use specific font list — Khmer font as FALLBACK, not first */
body, p, span, td {
font-family: 'Segoe UI', Roboto, Arial, 'Battambang', sans-serif;
}
/* If you put Battambang first, ALL text (including icons) uses Khmer font */
/* Hide checkbox column in list without breaking layout */
[name="approver_ids"] .o_list_record_selector {
width: 0 !important;
padding: 0 !important;
overflow: hidden !important;
}
```
---
## 7. Inheriting Fonts (Google Fonts)
CSS `@import url(...)` does **not** work inside Odoo's asset bundle (Docker blocks external HTTP at compile time). Instead, inject a `<link>` tag directly into the HTML `<head>` by inheriting `web.layout`:
```xml
<!-- In assets.xml -->
<template id="my_font_template" inherit_id="web.layout" name="My Font">
<xpath expr="//head" position="inside">
<link rel="preconnect" href="https://fonts.googleapis.com"/>
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin=""/>
<link rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Battambang:wght@400;700&amp;display=swap"/>
</xpath>
</template>
```
Note: `&amp;` must be used instead of `&` inside XML attribute values.
---
## 8. Key Techniques Used in This Module
### 8.1 Auto document numbering (ir.sequence)
```xml
<!-- data/sequences.xml -->
<record id="seq_xf_doc_approval_document_package" model="ir.sequence">
<field name="code">xf.doc.approval.document.package</field>
<field name="prefix">DOC/%(year)s/%(month)s/</field>
<field name="padding">4</field>
</record>
```
```python
# In model method:
record.doc_number = self.env['ir.sequence'].next_by_code(
'xf.doc.approval.document.package'
) or 'New'
# Result: DOC/2026/04/0001
```
### 8.2 Override a method and call super()
Always call `super()` unless you deliberately want to block the original behavior.
```python
def action_send_for_approval(self):
# Run YOUR code first
for record in self:
if not record.doc_number or record.doc_number == 'New':
record.doc_number = self.env['ir.sequence'].next_by_code(...)
# Then run the ORIGINAL method
return super().action_send_for_approval()
```
### 8.3 Computed field (non-stored, user-dependent)
Used for `notes_display` — each user sees different notes based on their step:
```python
notes_display = fields.Text(
compute='_compute_notes_display',
# No store=True → recomputes every time, never saved to DB
)
@api.depends('notes', 'step', 'document_package_id.approver_ids.step')
def _compute_notes_display(self):
current_uid = self.env.uid
for record in self:
my_step = max(
record.document_package_id.approver_ids
.filtered(lambda a: a.user_id.id == current_uid)
.mapped('step') or ['00']
)
record.notes_display = record.notes if record.step <= my_step else False
```
### 8.4 QR Code generation
```python
# fields.Image serves via /web/image/ — do NOT use fields.Binary with attachment=True
qr_code = fields.Image(max_width=0, max_height=0)
def _generate_qr_code(self):
import qrcode, base64, io
for record in self:
qr = qrcode.QRCode(version=None,
error_correction=qrcode.constants.ERROR_CORRECT_L,
box_size=15, border=4)
qr.add_data(record.doc_number) # Keep content SHORT and ASCII-only
qr.make(fit=True)
img = qr.make_image(fill_color='black', back_color='white')
buf = io.BytesIO()
img.save(buf, format='PNG')
record.write({'qr_code': base64.b64encode(buf.getvalue())})
# Trigger after approval
def action_finish_approval(self):
res = super().action_finish_approval()
self.filtered(lambda r: r.state == 'approved')._generate_qr_code()
return res
```
**Key lesson**: Keep QR content to ASCII-only short text.
Khmer Unicode text forces QR version 17+ (810×810 px) → unreadable when scaled down.
### 8.5 Override field type (Text → Html)
The base model had `description = fields.Text(...)`.
We override it to get a rich-text editor:
```python
# Custom module — overrides the type for this model
description = fields.Html(translate=True, sanitize=False)
```
`sanitize=False` is needed to allow file attachments embedded by the HTML editor.
### 8.6 One2many field in view — show only file column
```xml
<field name="document_ids" nolabel="1" readonly="state != 'draft'">
<list editable="bottom">
<field name="name" column_invisible="True"/> <!-- hidden but still present -->
<field name="file" widget="binary" filename="file_name" string="ឯកសារ"/>
<field name="file_name" column_invisible="True"/>
</list>
</field>
```
`column_invisible` hides the column in list view but keeps the field in the record.
### 8.7 Radio widget inline (horizontal)
```xml
<field name="document_source" widget="radio" nolabel="1" readonly="state != 'draft'"/>
```
```css
/* Force horizontal layout */
[name="document_source"] .o_field_radio {
display: flex !important;
flex-direction: row !important;
gap: 16px !important;
}
```
---
## 9. Docker Commands
### Start the containers
```bash
cd d:/project_v19s/odoo19-dev
docker compose up -d
```
### Stop the containers
```bash
cd d:/project_v19s/odoo19-dev
docker compose down
```
### Update the custom module (after code/view changes)
```bash
docker exec odoo19_app odoo -u xf_doc_approval_custom -d demo_db --stop-after-init
docker restart odoo19_app
```
### First-time install of a new module
```bash
docker exec odoo19_app odoo -i xf_doc_approval_custom -d demo_db --stop-after-init
docker restart odoo19_app
```
### Open Python shell (for debugging / manual fixes)
```bash
docker exec -i odoo19_app odoo shell -d demo_db --no-http
```
Inside the shell:
```python
# Browse a record
r = env['xf.doc.approval.document.package'].browse(4)
print(r.state, r.doc_number)
# Run a method manually
r._generate_qr_code()
env.cr.commit() # save changes
```
### View live logs
```bash
docker logs odoo19_app -f
```
---
## 10. Rules — What NOT to Touch
| Rule | Reason |
|------|--------|
| Never edit `d:\project_v19s\xf_doc_approval\` | It is the original base module. Any change there will be overwritten by updates and breaks the separation of concerns. |
| Never use `@import url()` in CSS files | Odoo's Docker environment cannot fetch external URLs at CSS compile time. Use `web.layout` template injection instead. |
| Never put `font-family: Battambang` as the FIRST font | It will replace Font Awesome icons with broken characters. Always put it as a fallback. |
| Never use `fields.Binary(attachment=True)` for images shown with `widget="image"` | The `/web/image/` controller does not serve `attachment=True` Binary fields for custom models. Use `fields.Image` instead. |
| Never put `<field>` inside a plain `<div>` when replacing the entire `<sheet>` | OWL (Odoo 19's frontend framework) requires fields to be inside proper Odoo form elements. Use targeted `<xpath>` instead of full sheet replacement. |
| Always call `super()` when overriding methods | Without `super()`, the original module's logic is skipped entirely (approvals won't work, emails won't send, etc.). |
---
## Quick Inheritance Checklist
When you want to add something new:
```
[ ] 1. Find the model name in the original module (grep for _name = '...')
[ ] 2. Find the view XML ID in Settings → Technical → Views
[ ] 3. Add _inherit = 'model.name' in a new Python file under models/
[ ] 4. Import the new file in models/__init__.py
[ ] 5. Add inherit_id = ref('module.view_id') in your XML view file
[ ] 6. Write xpath to target the exact element you want to change
[ ] 7. Add CSS in static/src/css/ and register it in assets.xml
[ ] 8. Run: docker exec odoo19_app odoo -u your_module -d demo_db --stop-after-init
[ ] 9. Run: docker restart odoo19_app
[ ] 10. Hard refresh browser (Ctrl+Shift+R)
```
Binary file not shown.
+2
View File
@@ -0,0 +1,2 @@
from . import models
from . import controllers
+45
View File
@@ -0,0 +1,45 @@
# {
# 'name': 'xf_doc_approval_custom',
# 'version': '1.0.1',
# 'author': 'My Company',
# 'license': 'LGPL-3',
# 'category': 'Customization',
# 'depends': ['xf_doc_approval', 'web'],
# 'data': [
# 'data/sequences.xml',
# 'views/assets.xml',
# 'views/document_package_form.xml',
# 'views/approver_wizard.xml',
# 'views/menuitems.xml',
# ],
# 'installable': True,
# 'auto_install': False,
# 'application': False,
# }
{
'name': 'Document Approval Custom',
'version': '1.0.2', # Increment version
'category': 'Productivity',
'summary': 'Custom Document Approval with QR Security',
'description': """
Custom Document Approval Package
- QR Code generation with security
- Authorized user access control
- Token-based authentication
""",
'depends': [
'base',
'xf_doc_approval', # Your base module
],
'data': [
'data/sequences.xml',
'views/document_package_form.xml',
'views/approver_wizard.xml',
'views/menuitems.xml',
'views/assets.xml',
],
'installable': True,
'application': False,
'auto_install': False,
}
@@ -0,0 +1 @@
from . import document_access
@@ -0,0 +1,94 @@
from odoo import http, fields, _
from odoo.http import request
from odoo.exceptions import AccessError, UserError
import logging
_logger = logging.getLogger(__name__)
class DocumentAccessController(http.Controller):
@http.route('/document/access/<int:package_id>/<string:token>',
type='http', auth='user', website=True)
def access_document(self, package_id, token, **kwargs):
"""Handle QR code document access with authorization check."""
try:
# Fetch the document package
package = request.env['xf.doc.approval.document.package'].sudo().browse(package_id)
if not package.exists():
return self._render_error('404', 'Document not found',
'The requested document does not exist.')
# Get current user
current_user = request.env.user
# Check access permission
has_access = package.check_qr_access(token, current_user)
if not has_access:
# Build detailed error message
reasons = []
if token != package.qr_access_token:
reasons.append('Invalid or incorrect QR code token')
if package.qr_token_expiry and fields.Datetime.now() > package.qr_token_expiry:
reasons.append(f'QR code expired on {package.qr_token_expiry}')
if package.authorized_user_ids and current_user not in package.authorized_user_ids:
authorized_names = ', '.join(package.authorized_user_ids.mapped('name'))
reasons.append(f'You are not in the authorized users list')
reasons.append(f'Authorized users: {authorized_names}')
reason_html = '<br/>• '.join(reasons) if reasons else 'Access denied'
message = f'''
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 20px auto;">
<h2 style="color: #dc3545;">🚫 Access Denied</h2>
<div style="background: #f8f9fa; padding: 15px; border-radius: 5px; margin: 20px 0;">
<p><strong>Current User:</strong> {current_user.name} ({current_user.login})</p>
<p><strong>Document:</strong> {package.doc_number}</p>
</div>
<div style="background: #fff3cd; padding: 15px; border-radius: 5px; border-left: 4px solid #ffc107;">
<p><strong>Reason(s):</strong></p>
<p>• {reason_html}</p>
</div>
<p style="margin-top: 20px;">
<small>Please contact the document administrator if you believe you should have access to this document.</small>
</p>
</div>
'''
return self._render_error('403', 'Access Denied', message)
# Access granted - get document
first_doc = package.document_ids.filtered(lambda d: not d.is_reference)[:1]
if not first_doc:
return self._render_error('404', 'No Document File',
'No document file found in this package.')
# Log successful access
_logger.info(
'✓ QR Access GRANTED: User "%s" (ID: %s) accessed document package "%s" (ID: %s)',
current_user.name, current_user.id, package.doc_number, package.id
)
# Redirect to document file
file_name = first_doc.file_name or first_doc.name or ''
return request.redirect(
f'/web/content/xf.doc.approval.document/{first_doc.id}/file/{file_name}'
)
except Exception as e:
_logger.exception('Error in QR document access')
return self._render_error('500', 'Server Error', str(e))
def _render_error(self, error_code, title, message):
"""Render error page."""
return request.render('http_routing.http_error', {
'status_code': error_code,
'status_message': title,
'message': message,
})
+13
View File
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<record id="seq_xf_doc_approval_document_package" model="ir.sequence">
<field name="name">Document Approval Number</field>
<field name="code">xf.doc.approval.document.package</field>
<field name="prefix">DOC/%(year)s/%(month)s/</field>
<field name="padding">4</field>
<field name="number_next">1</field>
<field name="number_increment">1</field>
</record>
</data>
</odoo>
@@ -0,0 +1,3 @@
from . import document_approver
from . import document_package
from . import document
+10
View File
@@ -0,0 +1,10 @@
from odoo import fields, models
class DocApprovalDocumentCustom(models.Model):
_inherit = 'xf.doc.approval.document'
is_reference = fields.Boolean(
string='Is Reference',
default=False,
)
@@ -0,0 +1,93 @@
from odoo import api, fields, models
_KH_DIGITS = str.maketrans('0123456789', '០១២៣៤៥៦៧៨៩')
_KH_MONTHS = [
'មករា', 'កុម្ភៈ', 'មីនា', 'មេសា', 'ឧសភា', 'មិថុនា',
'កក្កដា', 'សីហា', 'កញ្ញា', 'តុលា', 'វិច្ឆិកា', 'ធ្នូ',
]
class DocApprovalDocumentApproverCustom(models.Model):
_inherit = 'xf.doc.approval.document.approver'
role = fields.Selection(
string='តួនាទី',
selection=[
('reviewer', 'អ្នកពិនិត្យ និងផ្តល់យោបល់'),
('approver', 'អ្នកអនុម័ត'),
],
default='approver',
)
state = fields.Selection(
selection=[
('to approve', 'មិនទាន់ពិនិត្យ'),
('pending', 'រង់ចាំ'),
('approved', 'បានអនុម័ត'),
('rejected', 'បានបដិសេធ'),
],
)
sent_date = fields.Datetime(
string='Review Date',
readonly=True,
)
sent_date_kh = fields.Char(
string='កាលបរិច្ឆេទ',
compute='_compute_sent_date_kh',
)
@api.depends('sent_date')
def _compute_sent_date_kh(self):
for rec in self:
if rec.sent_date:
dt = fields.Datetime.context_timestamp(rec, rec.sent_date)
day = str(dt.day).translate(_KH_DIGITS)
month = _KH_MONTHS[dt.month - 1]
year = str(dt.year).translate(_KH_DIGITS)
hour = str(dt.hour).translate(_KH_DIGITS)
minute = str(dt.minute).zfill(2).translate(_KH_DIGITS)
rec.sent_date_kh = f'{day} {month} {year} {hour}:{minute}'
else:
rec.sent_date_kh = False
round = fields.Integer(
string='Round',
default=1,
readonly=True,
)
notes_display = fields.Text(
string='Notes (Visible)',
compute='_compute_notes_display',
)
@api.depends('notes', 'step', 'document_package_id.approver_ids.step',
'document_package_id.approver_ids.user_id')
def _compute_notes_display(self):
current_uid = self.env.uid
is_admin = self.env.user._is_admin()
for record in self:
if is_admin:
record.notes_display = record.notes
continue
package = record.document_package_id
# Find the current user's highest step in this document
my_approvers = package.approver_ids.filtered(
lambda a: a.user_id.id == current_uid
)
if not my_approvers:
# Initiator or non-approver: see all notes
record.notes_display = record.notes
else:
my_max_step = max(my_approvers.mapped('step'))
# Show this row's note only if its step <= my step
if record.step <= my_max_step:
record.notes_display = record.notes
else:
record.notes_display = False
def action_approve(self):
self.filtered(lambda a: not a.sent_date).write({'sent_date': fields.Datetime.now()})
return super().action_approve()
def action_reject(self):
self.filtered(lambda a: not a.sent_date).write({'sent_date': fields.Datetime.now()})
return super().action_reject()
@@ -0,0 +1,330 @@
import base64
import io
import logging
import secrets
from datetime import timedelta
from odoo import api, fields, models
from odoo.http import request
_logger = logging.getLogger(__name__)
class DocApprovalDocumentPackageCustom(models.Model):
_inherit = 'xf.doc.approval.document.package'
document_type = fields.Selection(
string='Document Type',
selection=[
('request', 'សំណើ'),
('report', 'របាយការណ៍'),
('letter', 'លិខិត'),
('other', 'ផ្សេងៗ'),
],
)
decision_requester_id = fields.Many2one(
string='Request Decision From',
comodel_name='res.users',
)
reference_note = fields.Text(
string='Reference Note',
)
state = fields.Selection(
selection=[
('draft', 'បង្កើតលិខិតឯកសារ'),
('approval', 'ការពិនិត្យនិងផ្ដល់យោបល់'),
('approved', 'ការអនុម័ត'),
('cancelled', 'បានលុបចោល'),
('rejected', 'បានបដិសេធ'),
],
)
document_source = fields.Selection(
string='Document Source',
selection=[
('create', 'បង្កើតលំហូរឯកសារ'),
('template', 'ជ្រើសរើសគំរូលំហូរឯកសារ'),
],
default='create',
)
doc_number = fields.Char(
string='Document Number',
copy=False,
)
upload_file_ids = fields.Many2many(
'ir.attachment',
'xf_doc_package_upload_rel',
'package_id',
'attachment_id',
string='ឯកសារ',
)
reference_file_ids = fields.Many2many(
'ir.attachment',
'xf_doc_package_ref_att_rel',
'package_id',
'attachment_id',
string='ឯកសារយោង',
)
description = fields.Html(
string='Description',
translate=True,
sanitize=False,
)
qr_code = fields.Image(
string='QR Code',
max_width=0,
max_height=0,
)
# New fields for secure QR access
qr_access_token = fields.Char(
string='QR Access Token',
copy=False,
readonly=True,
help='Secure token for QR code access'
)
qr_token_expiry = fields.Datetime(
string='QR Token Expiry',
copy=False,
readonly=True,
help='Token expiration date (optional, leave empty for no expiry)'
)
authorized_user_ids = fields.Many2many(
'res.users',
'xf_doc_package_authorized_users_rel',
'package_id',
'user_id',
string='Authorized Users',
help='Users who can access this document via QR code'
)
document_files_html = fields.Html(compute='_compute_document_files_html', sanitize=False)
reference_files_html = fields.Html(compute='_compute_reference_files_html', sanitize=False)
@api.depends('document_ids.file_name', 'document_ids.is_reference')
def _compute_document_files_html(self):
for rec in self:
docs = rec.document_ids.filtered(lambda d: not d.is_reference)
rec.document_files_html = self._build_file_html(docs)
@api.depends('document_ids.file_name', 'document_ids.is_reference', 'reference_file_ids')
def _compute_reference_files_html(self):
for rec in self:
# Use document_ids (is_reference=True) — works for all users
docs = rec.document_ids.filtered(lambda d: d.is_reference)
if docs:
rec.reference_files_html = self._build_file_html(docs)
else:
# Fallback for old documents: read reference_file_ids via sudo
atts = rec.sudo().reference_file_ids
if not atts:
rec.reference_files_html = False
continue
rows = ''.join(
'<div class="o_xf_file_item">'
'<i class="fa fa-file-o o_xf_file_icon"></i>'
f'<a href="/web/content/{att.id}?download=true" target="_blank">'
f'{att.name}'
'</a>'
'</div>'
for att in atts
)
rec.reference_files_html = f'<div class="o_xf_files_list">{rows}</div>'
@staticmethod
def _build_file_html(docs):
if not docs:
return False
rows = ''.join(
'<div class="o_xf_file_item">'
'<i class="fa fa-file-o o_xf_file_icon"></i>'
f'<a href="/web/content/xf.doc.approval.document/{d.id}/file/{d.file_name or d.name or ""}" target="_blank">'
f'{d.file_name or d.name}'
'</a>'
'</div>'
for d in docs
)
return f'<div class="o_xf_files_list">{rows}</div>'
@api.onchange('upload_file_ids')
def _onchange_document_ids_name(self):
if self.upload_file_ids and not self.doc_number:
first = self.upload_file_ids[0]
name = first.name or ''
# Strip extension for a cleaner document number
if '.' in name:
name = name.rsplit('.', 1)[0]
self.doc_number = name
def _generate_access_token(self):
"""Generate a secure random access token."""
return secrets.token_urlsafe(32)
def _generate_qr_code(self):
"""Generate QR code with secure access token."""
import qrcode
for record in self:
if record.state == 'approved' and record.doc_number:
try:
# Generate or reuse access token
if not record.qr_access_token:
record.qr_access_token = record._generate_access_token()
# Build secure URL with token
base_url = self.env['ir.config_parameter'].sudo().get_param(
'web.base.url', 'http://localhost:8069'
)
# URL format: /document/access/<package_id>/<token>
content = f"{base_url}/document/access/{record.id}/{record.qr_access_token}"
qr = qrcode.QRCode(
version=None,
error_correction=qrcode.constants.ERROR_CORRECT_L,
box_size=15,
border=4,
)
qr.add_data(content)
qr.make(fit=True)
img = qr.make_image(fill_color='black', back_color='white')
buffer = io.BytesIO()
img.save(buffer, format='PNG')
record.write({'qr_code': base64.b64encode(buffer.getvalue())})
_logger.info(
'Secure QR code generated for record %s (package_id: %s)',
record.id, record.id
)
except Exception:
_logger.exception('QR code generation failed for record %s', record.id)
record.write({'qr_code': False})
else:
record.write({'qr_code': False})
def check_qr_access(self, token, user=None):
"""
Check if a user is authorized to access this document via QR code.
:param token: The access token from QR code
:param user: User record (defaults to current user)
:return: Boolean indicating access permission
"""
self.ensure_one()
if not user:
user = self.env.user
# Validate token
if not token or token != self.qr_access_token:
_logger.warning(
'Invalid QR token attempt for package %s by user %s',
self.id, user.id
)
return False
# Check token expiry if set
if self.qr_token_expiry:
if fields.Datetime.now() > self.qr_token_expiry:
_logger.warning(
'Expired QR token for package %s (expired: %s)',
self.id, self.qr_token_expiry
)
return False
# Admin always has access
if user.has_group('base.group_system'):
return True
# Check if user is in authorized list
if self.authorized_user_ids and user not in self.authorized_user_ids:
_logger.warning(
'Unauthorized QR access attempt for package %s by user %s (%s)',
self.id, user.id, user.name
)
return False
# If authorized_user_ids is empty, consider document as public
# Change this logic if you want to restrict by default
if not self.authorized_user_ids:
_logger.info(
'Public document access via QR for package %s by user %s',
self.id, user.id
)
# Optionally restrict: return False
return True
def action_finish_approval(self):
res = super().action_finish_approval()
approved = self.filtered(lambda r: r.state == 'approved')
if approved:
approved._generate_qr_code()
return res
def action_regenerate_qr_token(self):
"""Regenerate QR access token (useful if token is compromised)."""
for record in self:
record.qr_access_token = record._generate_access_token()
record._generate_qr_code()
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': 'Success',
'message': 'QR access token regenerated successfully',
'type': 'success',
'sticky': False,
}
}
def action_send_for_approval(self):
for record in self:
# Auto document number
if not record.doc_number:
record.doc_number = (
self.env['ir.sequence'].next_by_code('xf.doc.approval.document.package') or ''
)
# Sync upload_file_ids → document_ids (is_reference=False)
existing_names = set(
record.document_ids.filtered(lambda d: not d.is_reference).mapped('file_name')
)
new_docs = []
for att in record.upload_file_ids:
if att.name not in existing_names:
new_docs.append((0, 0, {
'name': att.name,
'file': att.datas,
'file_name': att.name,
'is_reference': False,
}))
# Sync reference_file_ids → document_ids (is_reference=True)
existing_ref_names = set(
record.document_ids.filtered(lambda d: d.is_reference).mapped('file_name')
)
for att in record.reference_file_ids:
if att.name not in existing_ref_names:
new_docs.append((0, 0, {
'name': att.name,
'file': att.datas,
'file_name': att.name,
'is_reference': True,
}))
if new_docs:
record.sudo().write({'document_ids': new_docs})
# Ensure reference attachments are accessible to all users
if record.reference_file_ids:
record.reference_file_ids.sudo().write({
'res_model': self._name,
'res_id': record.id,
})
return super().action_send_for_approval()
def action_draft(self):
res = super().action_draft()
# Bump round for all approvers on re-submission
for record in self:
if record.approver_ids:
current_round = max(record.approver_ids.mapped('round') or [1])
record.approver_ids.write({'round': current_round + 1, 'sent_date': False})
return res
@@ -0,0 +1,553 @@
/* Section header - orange/brown like design */
.o_xf_section_header {
font-weight: bold;
font-size: 1.1rem;
margin: 16px 0 8px 0;
}
/* Approvers group title font size */
.o_group.o_inner_group .o_horizontal_separator,
.o_horizontal_separator {
font-size: 1.2rem !important;
font-weight: bold !important;
}
/* Make source container full width matching the table */
.o_xf_source_container {
width: 100% !important;
box-sizing: border-box !important;
margin: 0 !important;
}
.o_xf_source_label {
white-space: nowrap !important;
flex-shrink: 0 !important;
min-width: 180px !important;
display: flex !important;
align-items: center !important;
justify-content: center !important;
}
.o_xf_source_container {
align-items: stretch !important;
}
.o_xf_source_container > div {
display: flex !important;
align-items: center !important;
}
/* Inline radio widget using field name selector */
[name="document_source"] {
display: flex !important;
flex-direction: row !important;
flex-wrap: nowrap !important;
align-items: center !important;
}
[name="document_source"] .o_radio_item {
display: inline-flex !important;
align-items: center !important;
white-space: nowrap !important;
flex-shrink: 0 !important;
margin: 0 !important;
}
[name="document_source"] .o_radio_item:not(:last-child)::after {
content: '|' !important;
margin: 0 10px !important;
color: #999 !important;
}
[name="document_source"] input[type="radio"] {
display: none !important;
}
[name="document_source"] label {
cursor: pointer !important;
color: #0a5e98 !important;
margin: 0 !important;
padding: 0 !important;
font-weight: normal !important;
background: none !important;
border: none !important;
white-space: nowrap !important;
}
[name="document_source"] .o_radio_item.o_checked label {
font-weight: bold !important;
color: #000 !important;
}
/* ── Confirmation dialog — Khmer ── */
/* Title: replace "Confirmation" */
.o_dialog .modal-title {
font-size: 0 !important;
color: transparent !important;
}
.o_dialog .modal-title::after {
content: 'ការបញ្ជាក់' !important;
font-size: 1.1rem !important;
font-weight: 600 !important;
}
/* OK button: target buttons with no [name] attr (confirmation dialog only) */
.o_dialog .modal-footer button.btn-primary:not([name]) {
font-size: 0 !important;
color: transparent !important;
}
/* Wizard Cancel button (special="cancel") */
.o_dialog .modal-footer button[special="cancel"] {
font-size: 0 !important;
color: transparent !important;
}
/* ── Chatter buttons — Khmer ── */
.o_chatter_button_new_message {
font-size: 0 !important;
color: transparent !important;
}
.o_chatter_button_new_message::after {
content: 'ផ្ញើសារ' !important;
font-size: 0.875rem !important;
}
.o_chatter_button_log_note {
font-size: 0 !important;
color: transparent !important;
}
.o_chatter_button_log_note::after {
content: 'កត់សម្គាល់' !important;
font-size: 0.875rem !important;
}
/* Replace "Add a line" with Khmer in approver table */
.o_field_one2many[name="approver_ids"] a[role="button"] {
font-size: 0 !important;
color: transparent !important;
}
.o_field_one2many[name="approver_ids"] a[role="button"]::after {
content: 'បន្ថែម' !important;
font-size: 0.875rem !important;
}
/* Remove background from sort icon in approver table header */
.o_field_one2many[name="approver_ids"] .o_list_sortable_icon {
background: none !important;
background-color: transparent !important;
}
/* Approver table - header */
.o_form_view .o_field_one2many .o_list_renderer thead th {
background-color: #2e75b6 !important;
text-align: center !important;
padding: 8px 10px !important;
font-weight: bold !important;
border: 1px solid #1a5a96 !important;
white-space: nowrap;
}
/* Approver table - rows */
.o_form_view .o_field_one2many .o_list_renderer tbody td {
padding: 6px 10px !important;
border: 1px solid #d0d0d0 !important;
vertical-align: middle !important;
text-align: center !important;
}
/* Approver table - alternating rows */
.o_form_view .o_field_one2many .o_list_renderer tbody tr:nth-child(odd) td {
background-color: #ffffff !important;
}
.o_form_view .o_field_one2many .o_list_renderer tbody tr:nth-child(even) td {
background-color: #e8f0f9 !important;
}
/* Hover row highlight */
.o_form_view .o_field_one2many .o_list_renderer tbody tr:hover td {
background-color: #cce0f5 !important;
}
/* Remove default Odoo row borders */
.o_form_view .o_field_one2many .o_list_renderer table {
border-collapse: collapse !important;
width: 100% !important;
}
/* Hide selection checkbox in approver_ids list */
.o_field_one2many[name="approver_ids"] .o_list_record_selector,
.o_field_one2many[name="approver_ids"] .o_list_record_selector * {
display: none !important;
width: 0 !important;
min-width: 0 !important;
padding: 0 !important;
margin: 0 !important;
border: none !important;
}
.o_field_one2many[name="approver_ids"] input[type="checkbox"] {
display: none !important;
}
/* ===================================================
Section ខ — Modern Card Design
=================================================== */
/* Section title */
.o_xf_section_b .o_horizontal_separator {
font-size: 1.05rem !important;
font-weight: 700 !important;
color: #0a5e98 !important;
padding: 6px 0 10px 2px !important;
border-bottom: 2px solid #0a5e98 !important;
margin-bottom: 12px !important;
}
/* Card wrapper */
.o_xf_table {
width: 100% !important;
background: #ffffff !important;
border-radius: 10px !important;
box-shadow: 0 2px 8px rgba(10, 94, 152, 0.10),
0 0 0 1px #dde8f5 !important;
overflow: hidden !important;
}
/* ── Row ── */
.o_xf_row {
display: flex !important;
min-height: 50px !important;
border-bottom: 1px solid #eef3fa !important;
}
.o_xf_row:last-child { border-bottom: none !important; }
.o_xf_row_tall {
align-items: stretch !important;
min-height: 80px !important;
}
/* ── Label cell ── */
.o_xf_cell_label {
flex: 0 0 200px !important;
width: 200px !important;
background: #f4f7fb !important;
border-right: 3px solid #0a5e98 !important;
padding: 0 16px !important;
display: flex !important;
align-items: center !important;
font-size: 0.875rem !important;
font-weight: 600 !important;
color: #234d73 !important;
white-space: nowrap !important;
}
/* ── Value cell ── */
.o_xf_cell_value {
flex: 1 !important;
padding: 0 16px !important;
display: flex !important;
align-items: center !important;
background: #ffffff !important;
min-width: 0 !important;
}
.o_xf_cell_value > .o_field_widget {
width: 100% !important;
min-width: 0 !important;
}
/* Input fields inside value cell — clean underline style */
.o_xf_cell_value .o_field_widget input.o_input,
.o_xf_cell_value .o_field_widget textarea.o_input {
border: none !important;
border-bottom: 1.5px solid #d0dcea !important;
border-radius: 0 !important;
background: transparent !important;
padding: 4px 2px !important;
font-size: 0.9rem !important;
color: #1a3550 !important;
width: 100% !important;
transition: border-color 0.15s !important;
}
.o_xf_cell_value .o_field_widget input.o_input:focus,
.o_xf_cell_value .o_field_widget textarea.o_input:focus {
border-bottom-color: #0a5e98 !important;
outline: none !important;
box-shadow: none !important;
}
/* Many2one field styling */
.o_xf_cell_value .o_field_many2one .o_input_dropdown input {
border: none !important;
border-bottom: 1.5px solid #d0dcea !important;
border-radius: 0 !important;
background: transparent !important;
font-size: 0.9rem !important;
color: #1a3550 !important;
}
.o_xf_cell_value .o_field_many2one .o_input_dropdown input:focus {
border-bottom-color: #0a5e98 !important;
box-shadow: none !important;
}
/* Selection (dropdown) field */
.o_xf_cell_value .o_field_selection select,
.o_xf_cell_value .o_field_widget select {
border: none !important;
border-bottom: 1.5px solid #d0dcea !important;
border-radius: 0 !important;
background: transparent !important;
font-size: 0.9rem !important;
color: #1a3550 !important;
padding: 4px 2px !important;
width: 100% !important;
}
/* Read-only values */
.o_xf_cell_value .o_field_widget.o_readonly,
.o_xf_cell_value .o_field_char.o_field_readonly,
.o_form_readonly .o_xf_cell_value .o_field_widget {
font-size: 0.9rem !important;
color: #1a3550 !important;
font-weight: 500 !important;
}
/* Placeholder text */
.o_xf_cell_value input::placeholder,
.o_xf_cell_value textarea::placeholder {
color: #b0bec5 !important;
font-style: italic !important;
font-size: 0.85rem !important;
}
/* ── Tall value cell (documents / description) ── */
.o_xf_cell_tall {
align-items: flex-start !important;
padding-top: 10px !important;
padding-bottom: 10px !important;
}
/* ── Split cell: file | note ── */
.o_xf_cell_split {
padding: 0 !important;
align-items: stretch !important;
}
.o_xf_split_left,
.o_xf_split_right {
flex: 1 !important;
display: flex !important;
flex-direction: column !important;
justify-content: center !important;
padding: 8px 16px !important;
gap: 5px !important;
}
.o_xf_split_divider {
width: 1px !important;
background: #dde8f5 !important;
flex-shrink: 0 !important;
}
.o_xf_split_hint {
font-size: 0.7rem !important;
color: #90aac4 !important;
font-weight: 700 !important;
text-transform: uppercase !important;
letter-spacing: 0.07em !important;
}
.o_xf_split_left .o_field_widget,
.o_xf_split_right .o_field_widget {
width: 100% !important;
}
/* ── Upload zone (ឯកសារ row) ── */
.o_xf_upload_zone {
flex-direction: column !important;
align-items: flex-start !important;
gap: 8px !important;
padding: 12px 16px !important;
}
.o_xf_upload_zone > .o_field_widget {
width: 100% !important;
}
/* Wrapper for the whole many2many_binary widget */
.o_xf_upload_zone .o_field_many2many_binary {
display: flex !important;
flex-direction: column !important;
gap: 6px !important;
width: 100% !important;
}
/* ── Style the raw file input ── */
.o_xf_upload_zone input[type="file"] {
width: 100% !important;
font-size: 0.84rem !important;
color: #555 !important;
cursor: pointer !important;
border: 1.5px dashed #a0bfdf !important;
border-radius: 8px !important;
padding: 8px 12px !important;
background: #f7fafd !important;
transition: border-color 0.15s, background 0.15s !important;
}
.o_xf_upload_zone input[type="file"]:hover {
border-color: #0a5e98 !important;
background: #edf4ff !important;
}
/* "Choose Files" browser button part */
.o_xf_upload_zone input[type="file"]::file-selector-button {
padding: 5px 16px !important;
border: none !important;
border-radius: 5px !important;
background: #0a5e98 !important;
color: #ffffff !important;
font-size: 0.83rem !important;
font-weight: 600 !important;
cursor: pointer !important;
margin-right: 12px !important;
transition: background 0.15s !important;
font-family: 'Segoe UI', Roboto, Arial, 'Battambang', sans-serif !important;
}
.o_xf_upload_zone input[type="file"]::file-selector-button:hover {
background: #084e82 !important;
}
/* ── Uploaded file rows ── */
.o_xf_upload_zone .o_field_many2many_binary .o_attachments,
.o_xf_upload_zone .o_field_many2many_binary ul {
width: 100% !important;
display: flex !important;
flex-direction: column !important;
gap: 5px !important;
list-style: none !important;
padding: 0 !important;
margin: 0 !important;
}
.o_xf_upload_zone .o_field_many2many_binary .o_attachment,
.o_xf_upload_zone .o_field_many2many_binary li {
display: flex !important;
align-items: center !important;
gap: 8px !important;
padding: 6px 12px !important;
background: #edf4ff !important;
border: 1px solid #c5daef !important;
border-radius: 6px !important;
font-size: 0.84rem !important;
color: #1a3d5c !important;
}
.o_xf_upload_zone .o_field_many2many_binary .o_attachment a,
.o_xf_upload_zone .o_field_many2many_binary li a {
color: #0a5e98 !important;
text-decoration: none !important;
font-weight: 500 !important;
flex: 1 !important;
}
.o_xf_upload_zone .o_field_many2many_binary .o_attachment a:hover,
.o_xf_upload_zone .o_field_many2many_binary li a:hover {
text-decoration: underline !important;
}
/* Delete button */
.o_xf_upload_zone .o_field_many2many_binary .o_delete,
.o_xf_upload_zone .o_field_many2many_binary .delete {
color: #c0392b !important;
opacity: 0.55 !important;
cursor: pointer !important;
font-size: 0.9rem !important;
margin-left: auto !important;
}
.o_xf_upload_zone .o_field_many2many_binary .o_delete:hover {
opacity: 1 !important;
}
/* ── document_ids list in non-draft (ឯកសារ row) ── */
.o_xf_doc_list .o_list_renderer {
border: none !important;
box-shadow: none !important;
}
.o_xf_doc_list .o_list_renderer thead { display: none !important; }
.o_xf_doc_list .o_list_renderer tbody td {
border: none !important;
padding: 3px 6px !important;
background: transparent !important;
}
.o_xf_doc_list .o_list_renderer tbody tr {
display: flex !important;
align-items: center !important;
gap: 8px !important;
padding: 4px 8px !important;
background: #edf4ff !important;
border: 1px solid #c5daef !important;
border-radius: 6px !important;
margin-bottom: 4px !important;
}
/* ── File download list (non-draft state) ── */
.o_xf_files_list {
display: flex !important;
flex-direction: column !important;
gap: 5px !important;
width: 100% !important;
padding: 4px 0 !important;
}
.o_xf_file_item {
display: flex !important;
align-items: center !important;
gap: 8px !important;
padding: 6px 12px !important;
background: #edf4ff !important;
border: 1px solid #c5daef !important;
border-radius: 6px !important;
font-size: 0.84rem !important;
}
.o_xf_file_item a {
color: #0a5e98 !important;
text-decoration: none !important;
font-weight: 500 !important;
flex: 1 !important;
}
.o_xf_file_item a:hover { text-decoration: underline !important; }
.o_xf_file_icon { color: #5a9fd4 !important; font-size: 0.85rem !important; }
/* ── Readonly many2many_binary: ensure files are always visible ── */
.o_xf_upload_zone .o_field_many2many_binary {
display: flex !important;
flex-direction: column !important;
gap: 6px !important;
width: 100% !important;
}
/* Odoo 19 OWL: readonly renders files as <a> links directly */
.o_xf_upload_zone .o_field_many2many_binary a,
.o_xf_upload_zone .o_field_many2many_binary .o_form_uri,
.o_xf_upload_zone .o_field_many2many_binary span[title] {
display: inline-flex !important;
align-items: center !important;
gap: 6px !important;
padding: 6px 12px !important;
background: #edf4ff !important;
border: 1px solid #c5daef !important;
border-radius: 6px !important;
font-size: 0.84rem !important;
color: #0a5e98 !important;
text-decoration: none !important;
font-weight: 500 !important;
}
.o_xf_upload_zone .o_field_many2many_binary a:hover {
text-decoration: underline !important;
background: #dceeff !important;
}
/* ── QR Code section ── */
.o_xf_qr_group {
margin-top: 16px !important;
padding: 24px 20px !important;
background: #f7fafd !important;
border-radius: 10px !important;
border: 1px dashed #b0cce8 !important;
display: flex !important;
flex-direction: column !important;
align-items: center !important;
gap: 10px !important;
}
.o_xf_qr_label {
font-size: 0.78rem !important;
color: #7a9ec0 !important;
font-weight: 700 !important;
text-transform: uppercase !important;
letter-spacing: 0.09em !important;
}
.o_xf_qr_group .o_field_image img {
border-radius: 8px !important;
box-shadow: 0 2px 12px rgba(0,0,0,0.13) !important;
border: 4px solid #ffffff !important;
}
@@ -0,0 +1,48 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data>
<!-- Approve wizard — Khmer labels -->
<record id="xf_doc_approval_custom_approve_wizard" model="ir.ui.view">
<field name="name">xf_doc_approval_custom_approve_wizard</field>
<field name="model">xf.doc.approval.document.approver</field>
<field name="inherit_id" ref="xf_doc_approval.action_approve_wizard"/>
<field name="arch" type="xml">
<xpath expr="//form" position="attributes">
<attribute name="string">អនុម័តឯកសារ</attribute>
</xpath>
<xpath expr="//field[@name='notes']" position="attributes">
<attribute name="string">មតិយោបល់</attribute>
<attribute name="placeholder">បញ្ចូលមតិយោបល់...</attribute>
</xpath>
<xpath expr="//button[@name='action_approve']" position="attributes">
<attribute name="string">អនុម័ត</attribute>
</xpath>
<xpath expr="//button[@special='cancel'][1]" position="attributes">
<attribute name="string">បោះបង់</attribute>
</xpath>
</field>
</record>
<!-- Reject wizard — Khmer labels -->
<record id="xf_doc_approval_custom_reject_wizard" model="ir.ui.view">
<field name="name">xf_doc_approval_custom_reject_wizard</field>
<field name="model">xf.doc.approval.document.approver</field>
<field name="inherit_id" ref="xf_doc_approval.action_reject_wizard"/>
<field name="arch" type="xml">
<xpath expr="//form" position="attributes">
<attribute name="string">បដិសេធឯកសារ</attribute>
</xpath>
<xpath expr="//field[@name='notes']" position="attributes">
<attribute name="string">មតិយោបល់</attribute>
<attribute name="placeholder">បញ្ចូលហេតុផលបដិសេធ...</attribute>
</xpath>
<xpath expr="//button[@name='action_reject']" position="attributes">
<attribute name="string">បដិសេធ</attribute>
</xpath>
<xpath expr="//button[@special='cancel'][1]" position="attributes">
<attribute name="string">បោះបង់</attribute>
</xpath>
</field>
</record>
</data>
</odoo>
+18
View File
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data>
<record id="xf_doc_approval_custom_navbar_css" model="ir.asset">
<field name="name">xf_doc_approval_custom - Navbar CSS</field>
<field name="bundle">web.assets_backend</field>
<field name="path">xf_doc_approval_custom/static/src/css/navbar.css</field>
</record>
<template id="xf_doc_approval_custom_fonts" inherit_id="web.layout" name="Battambang Font">
<xpath expr="//head" position="inside">
<link rel="preconnect" href="https://fonts.googleapis.com"/>
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin=""/>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Battambang:wght@400;700&amp;display=swap"/>
</xpath>
</template>
</data>
</odoo>
@@ -0,0 +1,205 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data>
<record id="xf_doc_approval_custom_document_package_form" model="ir.ui.view">
<field name="name">xf_doc_approval_custom_document_package_form</field>
<field name="model">xf.doc.approval.document.package</field>
<field name="inherit_id" ref="xf_doc_approval.xf_doc_approval_document_package_form"/>
<field name="arch" type="xml">
<!-- ===== HEADER ===== -->
<!-- Rename buttons -->
<xpath expr="//button[@name='action_send_for_approval']" position="attributes">
<attribute name="string">បញ្ជូន</attribute>
<attribute name="confirm">តើអ្នកពិតជាចង់បញ្ជូនឯកសារសម្រាប់ការអនុម័តមែនទេ?</attribute>
</xpath>
<xpath expr="//button[@name='action_reject_wizard']" position="attributes">
<attribute name="string">បដិសេធ</attribute>
</xpath>
<xpath expr="//button[@name='action_approve_wizard']" position="attributes">
<attribute name="string">អនុម័ត</attribute>
</xpath>
<xpath expr="//button[@name='action_draft']" position="attributes">
<attribute name="string">កែប្រែ</attribute>
</xpath>
<xpath expr="//button[@name='action_cancel']" position="attributes">
<attribute name="string">លុបចោល</attribute>
</xpath>
<!-- ===== SHEET ===== -->
<!-- Hide oe_title -->
<xpath expr="//div[@class='oe_title']" position="attributes">
<attribute name="invisible">1</attribute>
</xpath>
<!-- Rename section header -->
<xpath expr="//group[@name='approvers']" position="attributes">
<attribute name="string">ក. ឋានានុក្រមលំហូរឯកសារ</attribute>
<attribute name="class">o_xf_section_b</attribute>
</xpath>
<!-- Approver table customization -->
<xpath expr="//field[@name='approver_ids']//list" position="attributes">
<attribute name="no_open">1</attribute>
<attribute name="create_text">បន្ថែម</attribute>
</xpath>
<xpath expr="//field[@name='approver_ids']//list//field[@name='method']" position="attributes">
<attribute name="column_invisible">True</attribute>
</xpath>
<xpath expr="//field[@name='approver_ids']//list//field[@name='step']" position="attributes">
<attribute name="string">ដំណាក់កាល</attribute>
</xpath>
<xpath expr="//field[@name='approver_ids']//list//field[@name='step']" position="after">
<field name="sent_date_kh" string="កាលបរិច្ឆេទ" optional="show"/>
</xpath>
<xpath expr="//field[@name='approver_ids']//list//field[@name='user_id']" position="attributes">
<attribute name="string">អ្នកពិនិត្យ និងអនុម័ត</attribute>
</xpath>
<xpath expr="//field[@name='approver_ids']//list//field[@name='role']" position="attributes">
<attribute name="string">តួនាទី</attribute>
</xpath>
<xpath expr="//field[@name='approver_ids']//list//field[@name='state']" position="attributes">
<attribute name="string">ស្ថានភាព</attribute>
</xpath>
<xpath expr="//field[@name='approver_ids']//list//field[@name='notes']" position="replace">
<field name="notes_display" string="មតិយោបល់" readonly="1"/>
<field name="round" string="ជំនាន់" optional="show"/>
</xpath>
<!-- Hide old groups -->
<xpath expr="//group[group[@name='visibility']]" position="attributes">
<attribute name="invisible">1</attribute>
</xpath>
<xpath expr="//group[@name='documents']" position="attributes">
<attribute name="invisible">1</attribute>
</xpath>
<xpath expr="//group[field[@name='initiator_user_id']]" position="attributes">
<attribute name="invisible">1</attribute>
</xpath>
<xpath expr="//group[@name='description']" position="attributes">
<attribute name="invisible">1</attribute>
</xpath>
<!-- Section ខ: Content -->
<xpath expr="//group[@name='approvers']" position="after">
<group string="ខ. ខ្លឹមសារ" name="section_b" class="o_xf_section_b">
<div class="o_xf_table" colspan="2">
<!-- Document Type -->
<div class="o_xf_row">
<div class="o_xf_cell_label">
<span>ប្រភេទឯកសារ</span>
</div>
<div class="o_xf_cell_value">
<field name="document_type" nolabel="1" readonly="state != 'draft'"/>
</div>
</div>
<!-- Decision Requester -->
<div class="o_xf_row">
<div class="o_xf_cell_label">
<span>ស្នើសុំការសម្រេចពី</span>
</div>
<div class="o_xf_cell_value">
<field name="decision_requester_id" nolabel="1" readonly="state != 'draft'"/>
</div>
</div>
<!-- Reference -->
<div class="o_xf_row o_xf_row_tall">
<div class="o_xf_cell_label">
<span>យោង</span>
</div>
<div class="o_xf_cell_value o_xf_cell_split">
<div class="o_xf_split_left o_xf_upload_zone">
<span class="o_xf_split_hint" invisible="state != 'draft'">Upload ឯកសារ · អាចបន្ថែមច្រើន</span>
<field name="reference_file_ids" widget="many2many_binary" nolabel="1" invisible="state != 'draft'"/>
<field name="reference_files_html" widget="html" nolabel="1" readonly="1" invisible="state == 'draft'"/>
</div>
<div class="o_xf_split_divider"/>
<div class="o_xf_split_right">
<span class="o_xf_split_hint">មតិណែនាំ</span>
<field name="reference_note" nolabel="1" readonly="state != 'draft'" placeholder="បញ្ចូលមតិណែនាំ..."/>
</div>
</div>
</div>
<!-- Subject -->
<div class="o_xf_row">
<div class="o_xf_cell_label">
<span>កម្មវត្ថុ</span>
</div>
<div class="o_xf_cell_value">
<field name="name" nolabel="1" readonly="state != 'draft'" placeholder="បញ្ចូលកម្មវត្ថុ..."/>
</div>
</div>
<!-- Documents -->
<div class="o_xf_row o_xf_row_tall">
<div class="o_xf_cell_label">
<span>ឯកសារ</span>
</div>
<div class="o_xf_cell_value o_xf_cell_tall o_xf_upload_zone">
<span class="o_xf_split_hint" invisible="state != 'draft'">Upload ឯកសារ · អាចបន្ថែមច្រើន</span>
<field name="upload_file_ids" widget="many2many_binary" nolabel="1" invisible="state != 'draft'"/>
<field name="document_files_html" widget="html" nolabel="1" readonly="1" invisible="state == 'draft'"/>
</div>
</div>
<!-- Document Number -->
<div class="o_xf_row">
<div class="o_xf_cell_label">
<span>ឈ្មោះឯកសារ</span>
</div>
<div class="o_xf_cell_value">
<field name="doc_number" nolabel="1" readonly="state != 'draft'" placeholder="ចាប់ឈ្មោះស្វ័យប្រវត្តិ..."/>
</div>
</div>
<!-- Description -->
<div class="o_xf_row o_xf_row_tall">
<div class="o_xf_cell_label">
<span>បរិយាយ</span>
</div>
<div class="o_xf_cell_value o_xf_cell_tall">
<field name="description" nolabel="1" readonly="state != 'draft'" placeholder="សរសេរបរិយាយ..."/>
</div>
</div>
<!-- Initiator -->
<div class="o_xf_row">
<div class="o_xf_cell_label">
<span>អ្នកស្នើសុំ</span>
</div>
<div class="o_xf_cell_value">
<field name="initiator_user_id" nolabel="1" readonly="state != 'draft'"/>
</div>
</div>
</div>
</group>
<!-- QR Security Section -->
<group string="គ. ការកំណត់សុវត្ថិភាព QR" name="section_qr_security" class="o_xf_section_b" invisible="state == 'draft'">
<field name="authorized_user_ids" widget="many2many_tags" placeholder="ជ្រើសរើសអ្នកប្រើប្រាស់..."/>
<field name="qr_token_expiry" placeholder="ជ្រើសរើសថ្ងៃផុតកំណត់..."/>
<field name="qr_access_token" password="True" groups="base.group_system"/>
</group>
<!-- QR Code Display -->
<div class="o_xf_qr_group" colspan="2" invisible="state != 'approved' or not qr_code">
<span class="o_xf_qr_label">
<i class="fa fa-qrcode"/> លេខ QR ឯកសារ
</span>
<field name="qr_code" widget="image" nolabel="1" options="{'size': [260, 260]}"/>
</div>
</xpath>
</field>
</record>
</data>
</odoo>
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data>
<record id="menu_xf_doc_approval_root_kh" model="ir.ui.menu">
<field name="id" ref="xf_doc_approval.menu_xf_doc_approval_root"/>
<field name="name">សំណើអនុម័ត</field>
</record>
<record id="menu_xf_doc_approval_documents_kh" model="ir.ui.menu">
<field name="id" ref="xf_doc_approval.menu_xf_doc_approval_document_package"/>
<field name="name">ឯកសារ</field>
</record>
</data>
</odoo>