first push message
This commit is contained in:
@@ -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&display=swap"/>
|
||||
</xpath>
|
||||
</template>
|
||||
```
|
||||
|
||||
Note: `&` 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.
@@ -0,0 +1,2 @@
|
||||
from . import models
|
||||
from . import controllers
|
||||
@@ -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
|
||||
Binary file not shown.
Binary file not shown.
@@ -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,
|
||||
})
|
||||
@@ -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
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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>
|
||||
@@ -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&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>
|
||||
Reference in New Issue
Block a user