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
@@ -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