summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorMqdd <ahmadmiqdad27@gmail.com>2026-02-27 09:54:01 +0700
committerMqdd <ahmadmiqdad27@gmail.com>2026-02-27 09:54:01 +0700
commit3e08828c8e0238478a9b2575aa0705ab89124050 (patch)
tree441834e5b1b19dda0b58d2d67936686aa3771cf8
parent687e462aaf3112d3b8a7dcecfc8c50582c531798 (diff)
parent523491c16ccac66b2464654bdd1969b444459466 (diff)
Merge branch 'odoo-backup' of https://bitbucket.org/altafixco/indoteknik-addons into odoo-backup
-rwxr-xr-xindoteknik_custom/__manifest__.py3
-rwxr-xr-xindoteknik_custom/models/__init__.py2
-rw-r--r--indoteknik_custom/models/automatic_purchase.py53
-rwxr-xr-xindoteknik_custom/models/purchase_order.py307
-rwxr-xr-xindoteknik_custom/models/purchase_order_line.py17
-rw-r--r--indoteknik_custom/models/stock_picking.py1
-rw-r--r--indoteknik_custom/models/token_log.py17
-rwxr-xr-xindoteknik_custom/security/ir.model.access.csv1
-rwxr-xr-xindoteknik_custom/views/purchase_order.xml22
-rw-r--r--indoteknik_custom/views/stock_picking.xml1
-rw-r--r--indoteknik_custom/views/token_log.xml33
11 files changed, 449 insertions, 8 deletions
diff --git a/indoteknik_custom/__manifest__.py b/indoteknik_custom/__manifest__.py
index 583b59a4..8c427579 100755
--- a/indoteknik_custom/__manifest__.py
+++ b/indoteknik_custom/__manifest__.py
@@ -191,8 +191,9 @@
'views/uom_uom.xml',
'views/update_depreciation_move_wizard_view.xml',
'views/commission_internal.xml',
- 'views/gudang_service.xml',
'views/keywords.xml',
+ 'views/token_log.xml',
+ 'views/gudang_service.xml',
'views/kartu_stock.xml',
],
'demo': [],
diff --git a/indoteknik_custom/models/__init__.py b/indoteknik_custom/models/__init__.py
index 1fa640c4..31ee5108 100755
--- a/indoteknik_custom/models/__init__.py
+++ b/indoteknik_custom/models/__init__.py
@@ -167,6 +167,6 @@ from . import uom_uom
from . import commission_internal
from . import gudang_service
from . import update_depreciation_move_wizard
-
from . import keywords
+from . import token_log
from . import kartu_stock
diff --git a/indoteknik_custom/models/automatic_purchase.py b/indoteknik_custom/models/automatic_purchase.py
index 0b2f7d1b..f4ecdcd6 100644
--- a/indoteknik_custom/models/automatic_purchase.py
+++ b/indoteknik_custom/models/automatic_purchase.py
@@ -315,6 +315,53 @@ class AutomaticPurchase(models.Model):
sale_ids_set = set()
sale_ids_name = set()
for sale_order in matches_so:
+ exist = self.env['purchase.order.sales.match'].search([
+ ('product_id', '=', sale_order.product_id.id),
+ ('sale_line_id', '=', sale_order.sale_line_id.id),
+ ('sale_id', '=', sale_order.sale_id.id),
+ ('purchase_order_id.state', '!=', 'cancel'),
+ ])
+
+ skip_line = False
+
+ for existing in exist:
+ if existing.purchase_order_id.state in ['done', 'purchase']:
+ # if existing.purchase_line_id.qty_received != existing.purchase_line_id.product_qty:
+ # break
+
+ incoming = self.env['stock.move'].search([
+ ('reference', 'ilike', 'BU/INPUT'),
+ ('state', 'not in', ['done','cancel']),
+ ('product_id', '=', existing.product_id.id),
+ ('purchase_line_id', '=', existing.purchase_line_id.id),
+ ], limit=1)
+
+ if incoming:
+ skip_line = True
+ break
+
+ retur = self.env['stock.move'].search([
+ ('reference', 'ilike', 'BU/INPUT'),
+ ('state', 'in', ['done']),
+ ('product_id', '=', existing.product_id.id),
+ ('purchase_line_id', '=', existing.purchase_line_id.id),
+ ], limit=1)
+
+ if retur and existing.purchase_line_id.qty_received == existing.purchase_line_id.product_qty:
+ skip_line = True
+ break
+
+ if skip_line:
+ continue
+
+ stock_move = self.env['stock.move'].search([
+ ('reference', 'ilike', 'BU/PICK'),
+ ('state', 'in', ['confirmed','waiting','partially_available']),
+ ('product_id', '=', sale_order.product_id.id),
+ ('sale_line_id', '=', sale_order.sale_line_id.id),
+ ])
+ if not stock_move:
+ continue
# @stephan skip so line yang sudah pernah ada di purchase order sales match sebelumnya
salesperson_name = sale_order.sale_id.user_id.name
@@ -740,11 +787,5 @@ class SaleNotInMatchPO(models.Model):
apsm.create_date, apsm.write_uid, apsm.write_date, apsm.purchase_price,
apsm.purchase_tax_id, apsm.note_procurement
from automatic_purchase_sales_match apsm
- where apsm.sale_line_id not in (
- select distinct coalesce(posm.sale_line_id,0)
- from purchase_order_sales_match posm
- join purchase_order po on po.id = posm.purchase_order_id
- where po.state not in ('cancel')
- )
)
""" % self._table) \ No newline at end of file
diff --git a/indoteknik_custom/models/purchase_order.py b/indoteknik_custom/models/purchase_order.py
index 6e09f3e9..a345b96b 100755
--- a/indoteknik_custom/models/purchase_order.py
+++ b/indoteknik_custom/models/purchase_order.py
@@ -8,6 +8,8 @@ import io
import base64
from odoo.tools import lazy_property
import socket
+import requests
+import json
try:
from odoo.tools.misc import xlsxwriter
@@ -125,7 +127,308 @@ class PurchaseOrder(models.Model):
)
overseas_po = fields.Boolean(string='PO Luar Negeri?', tracking=3, help='Centang jika PO untuk pembelian luar negeri')
+ order_altama_id = fields.Integer('Req Order Altama', copy=False)
+ soo_number = fields.Char('SOO Number', copy=False)
+ soo_price = fields.Float('SOO Price', copy=False)
+ soo_discount = fields.Float('SOO Discount', copy=False)
+ soo_tax = fields.Float('SOO Tax', copy=False)
+
+ def _get_altama_token(self, source='auto'):
+ ICP = self.env['ir.config_parameter'].sudo()
+ TokenLog = self.env['token.log'].sudo()
+
+ token_url = ICP.get_param('token.adempiere.altama')
+ client_id = ICP.get_param('client.adempiere.altama')
+ client_secret = ICP.get_param('secret_key.adempiere.altama')
+
+ active_token = TokenLog.search([
+ ('is_active', '=', True),
+ ('token_from', '=', 'Adempiere Altama'),
+ ('expires_at', '>', datetime.utcnow()),
+ ], limit=1, order='id desc')
+
+ if active_token:
+ return active_token.token
+
+ headers = {
+ "Authorization": "Basic " + base64.b64encode(f"{client_id}:{client_secret}".encode()).decode(),
+ "Content-Type": "application/x-www-form-urlencoded",
+ }
+ data = {"grant_type": "client_credentials"}
+
+ response = requests.post(token_url, data=data, headers=headers, timeout=15)
+ if response.status_code == 200:
+ result = response.json()
+ token = result.get("access_token")
+ expires_in = result.get("expires_in", 3600)
+ expiry_time = datetime.utcnow() + timedelta(seconds=expires_in - 60)
+
+ TokenLog.search([
+ ('token_from', '=', 'Adempiere Altama'),
+ ('is_active', '=', True),
+ ]).write({'is_active': False})
+
+ TokenLog.create({
+ 'token': token,
+ 'expires_at': expiry_time,
+ 'is_active': True,
+ 'created_by': self.env.user.id if self.env.user else None,
+ 'source': source,
+ 'token_from': 'Adempiere Altama',
+ })
+
+ return token
+
+ else:
+ raise Exception(f"Gagal ambil token: {response.status_code} - {response.text}")
+
+ def action_create_order_altama(self):
+ ICP = self.env['ir.config_parameter'].sudo()
+ for order in self:
+ try:
+ token = self._get_altama_token(source='manual')
+ url = ICP.get_param('endpoint.create.order.adempiere.altama')
+ headers = {
+ "Authorization": f"Bearer {token}",
+ "Content-Type": "application/json",
+ }
+
+ payload = {
+ "date_po": order.date_approve.strftime("%Y%m%d%H%M%S"),
+ "no_po": order.name,
+ "details": [
+ {
+ "item_code": line.product_id.default_code or "",
+ "price": line.price_unit,
+ "qty": line.product_qty,
+ }
+ for line in order.order_line
+ ],
+ }
+
+ response = requests.post(url, json=payload, headers=headers, timeout=20)
+
+ try:
+ result = response.json()
+ except json.JSONDecodeError:
+ raise Exception(f"Response bukan JSON valid: {response.text}")
+
+ if response.status_code == 200 and result.get("code") == "00":
+ contents = result.get("contents", {})
+ if isinstance(contents, dict):
+ order.order_altama_id = contents.get("req_id")
+ else:
+ order.order_altama_id = contents.get("req_id")
+
+ elif response.status_code == 404:
+ raise Exception("URL endpoint gak ditemukan (404). Pastikan path-nya benar di Altama API.")
+ elif response.status_code == 401:
+ token = self._get_altama_token(source='auto')
+ headers["Authorization"] = f"Bearer {token}"
+ response = requests.post(url, json=payload, headers=headers, timeout=20)
+ elif response.status_code not in (200, 201):
+ raise Exception(f"Gagal kirim ke Altama: {response.status_code} - {response.text}")
+
+ self.message_post(body=f"✅ PO berhasil dikirim ke Altama!\nResponse: {json.dumps(result, indent=2)}")
+
+ except Exception as e:
+ self.message_post(body=f"❌ Gagal kirim ke Altama:<br/><pre>{str(e)}</pre>")
+
+
+ def action_get_order_altama(self):
+ ICP = self.env['ir.config_parameter'].sudo()
+
+ for order in self:
+ try:
+ # ============================
+ # Get Token
+ # ============================
+ token = self._get_altama_token(source='manual')
+
+ url = ICP.get_param('endpoint.get.order.adempiere.altama')
+ if not url:
+ raise Exception("Parameter 'endpoint.adempiere.altama' belum diset di System Parameters.")
+
+ headers = {
+ "Authorization": f"Bearer {token}",
+ "Content-Type": "application/json",
+ }
+
+ params = {
+ "orderreq_id": order.order_altama_id or 0,
+ }
+
+ # ============================
+ # Request ke API
+ # ============================
+ response = requests.get(url, headers=headers, params=params, timeout=20)
+
+ if response.status_code == 401:
+ token = self._get_altama_token(source='auto')
+ headers["Authorization"] = f"Bearer {token}"
+ response = requests.get(url, headers=headers, params=params, timeout=20)
+
+ if response.status_code not in (200, 201):
+ raise Exception(f"Gagal ambil data dari Altama: {response.status_code} - {response.text}")
+
+ data = response.json()
+
+ # ============================
+ # Extract Data
+ # ============================
+ contents_root = data.get("contents", {})
+ contents_list = contents_root.get("contents", [])
+
+ if not isinstance(contents_list, list):
+ raise Exception("Format data contents dari Altama tidak sesuai (expected list).")
+
+ order.message_post(
+ body=f"✅ Berhasil ambil data dari Altama. Ditemukan {len(contents_list)} record."
+ )
+
+ # =====================================================
+ # LOOP MAIN DATA
+ # =====================================================
+ for item in contents_list:
+
+ req_id = item.get("req_id")
+ no_po = item.get("no_po")
+ list_item_po = item.get("list_Item_po", [])
+ list_soo = item.get("list_soo", [])
+
+ # ============================
+ # Isi Data SOO Ke Order
+ # ============================
+ soo_numbers = [s.get("no_soo") for s in list_soo if s.get("no_soo")]
+ unique_soo = list(set(soo_numbers))
+ if len(unique_soo) == 1:
+ order.soo_number = unique_soo[0]
+ if not order.picking_ids.number_soo:
+ # order.picking_ids[0].number_soo = unique_soo[0]
+ for picking in order.picking_ids:
+ picking.number_soo = unique_soo[0]
+ elif len(unique_soo) > 1:
+ order.soo_number = ", ".join(unique_soo)
+ if not order.picking_ids.number_soo:
+ # order.picking_ids[0].number_soo = ", ".join(unique_soo)
+ for picking in order.picking_ids:
+ picking.number_soo = ", ".join(unique_soo)
+ else:
+ order.soo_number = False
+
+ if list_soo:
+ first_soo = list_soo[0]
+ order.soo_price = first_soo.get("totalprice")
+ order.soo_discount = first_soo.get("diskon")
+ order.soo_tax = first_soo.get("ppn")
+
+ order.order_altama_id = req_id
+
+ # ============================
+ # Update Order Lines
+ # ============================
+ for item_line in list_item_po:
+
+ line = order.order_line.filtered(
+ lambda l: l.product_id.default_code == item_line.get("item_code")
+ )
+
+ if line:
+ line.write({
+ "description": item_line.get("description", ""),
+ "altama_ordered": item_line.get("qtyordered", 0),
+ "altama_delivered": item_line.get("qtydelivered", 0),
+ "altama_invoiced": item_line.get("qtyinvoiced", 0),
+ "docstatus_altama": item_line.get("docstatus", ""),
+ })
+
+ # =====================================================
+ # BUILD HTML TABLES FOR CHATTER
+ # =====================================================
+
+ # ---- SOO TABLE ----
+ soo_rows = ""
+ for s in list_soo:
+ soo_rows += f"""
+ <tr>
+ <td>{s.get('no_soo')}</td>
+ <td>{s.get('totalprice')}</td>
+ <td>{s.get('diskon')}</td>
+ <td>{s.get('ppn')}</td>
+ </tr>
+ """
+
+ soo_table = f"""
+ <table style="width:100%; border-collapse: collapse; margin-top: 10px;">
+ <thead>
+ <tr style="background:#f1f1f1;">
+ <th style="border:1px solid #ccc; padding:6px;">SOO Number</th>
+ <th style="border:1px solid #ccc; padding:6px;">Total Price</th>
+ <th style="border:1px solid #ccc; padding:6px;">Diskon</th>
+ <th style="border:1px solid #ccc; padding:6px;">PPN</th>
+ </tr>
+ </thead>
+ <tbody>
+ {soo_rows or '<tr><td colspan="4">Tidak ada data SOO</td></tr>'}
+ </tbody>
+ </table>
+ """
+
+ # ---- ITEM PO TABLE ----
+ po_rows = ""
+ for l in list_item_po:
+
+ desc = l.get("description") or ""
+
+ # Flag: row error kalau description tidak mulai dengan SOO/
+ is_error = desc and not desc.startswith("SOO/")
+
+ # Style row merah
+ row_style = "color:red; font-weight:bold;" if is_error else ""
+
+ po_rows += f"""
+ <tr style="{row_style}">
+ <td>{l.get('item_code')}</td>
+ <td>{desc}</td>
+ <td>{l.get('qtyordered')}</td>
+ <td>{l.get('qtydelivered')}</td>
+ <td>{l.get('qtyinvoiced')}</td>
+ <td>{l.get('docstatus')}</td>
+ </tr>
+ """
+
+
+ po_table = f"""
+ <table style="width:100%; border-collapse: collapse; margin-top: 10px;">
+ <thead>
+ <tr style="background:#f1f1f1;">
+ <th style="border:1px solid #ccc; padding:6px;">Item Code</th>
+ <th style="border:1px solid #ccc; padding:6px;">Description</th>
+ <th style="border:1px solid #ccc; padding:6px;">Ordered</th>
+ <th style="border:1px solid #ccc; padding:6px;">Delivered</th>
+ <th style="border:1px solid #ccc; padding:6px;">Invoiced</th>
+ <th style="border:1px solid #ccc; padding:6px;">Status</th>
+ </tr>
+ </thead>
+ <tbody>
+ {po_rows or '<tr><td colspan="6">Tidak ada item PO</td></tr>'}
+ </tbody>
+ </table>
+ """
+ # ---- POST TO CHATTER ----
+ order.message_post(
+ body=f"""
+ <b>📦 Data SOO</b><br/>{soo_table}
+ <br/><br/>
+ <b>📦 Data Item PO</b><br/>{po_table}
+ """
+ )
+
+ except Exception as e:
+ order.message_post(
+ body=f"❌ Gagal ambil data dari Altama:<br/><pre>{str(e)}</pre>"
+ )
@staticmethod
def is_local_env():
hostname = socket.gethostname().lower()
@@ -1143,6 +1446,9 @@ class PurchaseOrder(models.Model):
send_email = True
break
+ if self.partner_id.id == 5571 and not self.revisi_po:
+ self.action_create_order_altama()
+
if send_email:
if self.is_local_env():
_logger.warning("📪 Local environment detected — skip sending email reminders.")
@@ -1160,6 +1466,7 @@ class PurchaseOrder(models.Model):
self.calculate_line_no()
self.approve_by = self.env.user.id
+
# override date planned added with two days
# leadtime = self.partner_id.leadtime
# delta_time = current_time + timedelta(days=leadtime)
diff --git a/indoteknik_custom/models/purchase_order_line.py b/indoteknik_custom/models/purchase_order_line.py
index 76dcc09e..c6a49481 100755
--- a/indoteknik_custom/models/purchase_order_line.py
+++ b/indoteknik_custom/models/purchase_order_line.py
@@ -55,6 +55,23 @@ class PurchaseOrderLine(models.Model):
ending_price = fields.Float(string='Ending Price', compute='_compute_doc_delivery_amt')
show_description = fields.Boolean(string='Show Description', help="Show Description when print po", default=True)
price_unit_before = fields.Float(string='Unit Price Before', help="Harga awal yang sebelumnya telah diinputkan")
+ altama_ordered = fields.Float(
+ string='Altama Ordered',
+ default=0.0,
+ copy=False
+ )
+ altama_delivered = fields.Float(
+ string='Altama Delivered',
+ default=0.0,
+ copy=False
+ )
+ altama_invoiced = fields.Float(
+ string='Altama Invoiced',
+ default=0.0,
+ copy=False
+ )
+ description = fields.Text(string='Description', readonly=True, copy=False)
+ docstatus_altama = fields.Text(string='Status Altama', readonly=True, copy=False)
@api.onchange('price_unit')
def _onchange_price_unit_before(self):
diff --git a/indoteknik_custom/models/stock_picking.py b/indoteknik_custom/models/stock_picking.py
index 065b1484..ab366fd6 100644
--- a/indoteknik_custom/models/stock_picking.py
+++ b/indoteknik_custom/models/stock_picking.py
@@ -459,6 +459,7 @@ class StockPicking(models.Model):
('urgent', 'Urgent Delivery'),
], string='Reason Change Date Planned', tracking=True)
delivery_date = fields.Datetime(string='Delivery Date', copy=False)
+ number_soo = fields.Char(string='Number SOO Altama')
def _get_kgx_awb_number(self):
"""Menggabungkan name dan origin untuk membuat AWB Number"""
diff --git a/indoteknik_custom/models/token_log.py b/indoteknik_custom/models/token_log.py
new file mode 100644
index 00000000..fdc0c03e
--- /dev/null
+++ b/indoteknik_custom/models/token_log.py
@@ -0,0 +1,17 @@
+from odoo import models, fields, api
+
+class FixcoTokenLog(models.Model):
+ _name = 'token.log'
+ _description = 'Log Token Fixco'
+ _order = 'create_date desc'
+
+ token = fields.Text(string="Access Token", readonly=True)
+ expires_at = fields.Datetime(string="Expires At", readonly=True)
+ created_at = fields.Datetime(string="Created At", default=lambda self: fields.Datetime.now(), readonly=True)
+ created_by = fields.Many2one('res.users', string="Generated By", readonly=True)
+ source = fields.Selection([
+ ('manual', 'Manual Request'),
+ ('auto', 'Auto Refresh'),
+ ], string="Token Source", default='auto', readonly=True)
+ token_from = fields.Char(string="From", readonly=True)
+ is_active = fields.Boolean("Active", default=True) \ No newline at end of file
diff --git a/indoteknik_custom/security/ir.model.access.csv b/indoteknik_custom/security/ir.model.access.csv
index 87798115..42c68e80 100755
--- a/indoteknik_custom/security/ir.model.access.csv
+++ b/indoteknik_custom/security/ir.model.access.csv
@@ -221,3 +221,4 @@ access_gudang_service_line,gudang.service.line,model_gudang_service_line,base.gr
access_update_depreciation_move_wizard,access.update.depreciation.move.wizard,model_update_depreciation_move_wizard,,1,1,1,1
access_keywords,keywords,model_keywords,base.group_user,1,1,1,1
+access_token_log,access.token.log,model_token_log,,1,1,1,1
diff --git a/indoteknik_custom/views/purchase_order.xml b/indoteknik_custom/views/purchase_order.xml
index 59e317d2..9651cdd6 100755
--- a/indoteknik_custom/views/purchase_order.xml
+++ b/indoteknik_custom/views/purchase_order.xml
@@ -46,6 +46,19 @@
</button>
</xpath>
<button id="draft_confirm" position="after">
+ <button name="action_create_order_altama"
+ type="object"
+ string="Create Order Altama"
+ class="oe_highlight"
+ icon="fa-cloud-upload"
+ attrs="{'invisible': [('partner_id', '!=', 5571)]}"
+ />
+ <button name="action_get_order_altama"
+ type="object"
+ string="Get Order Altama"
+ class="oe_highlight"
+ attrs="{'invisible': [('partner_id', '!=', 5571)]}"
+ icon="fa-cloud-download"/>
<button name="po_approve"
string="Ask Approval"
type="object"
@@ -76,6 +89,10 @@
</field>
<field name="approval_status" position="after">
<field name="revisi_po"/>
+ <field name="soo_number" attrs="{'invisible': [('partner_id', '!=', 5571)]}"/>
+ <field name="soo_price" attrs="{'invisible': [('partner_id', '!=', 5571)]}"/>
+ <field name="soo_discount" attrs="{'invisible': [('partner_id', '!=', 5571)]}"/>
+ <field name="soo_tax" attrs="{'invisible': [('partner_id', '!=', 5571)]}"/>
<field name="not_update_purchasepricelist"/>
</field>
<field name="approval_status" position="after">
@@ -153,8 +170,13 @@
</field>
<field name="price_unit" position="after">
<field name="price_vendor" attrs="{'readonly': 1}" optional="hide"/>
+ <field name="description" optional="hide"/>
+ <field name="docstatus_altama" optional="hide"/>
</field>
<field name="price_subtotal" position="after">
+ <field name="altama_ordered" optional="hide" readonly="1"/>
+ <field name="altama_delivered" optional="hide" readonly="1"/>
+ <field name="altama_invoiced" optional="hide" readonly="1"/>
<field name="so_item_margin" attrs="{'readonly': 1}" optional="hide"/>
<field name="so_item_percent_margin" attrs="{'readonly': 1}" optional="hide"/>
<field name="item_margin" attrs="{'readonly': 1}" optional="hide"/>
diff --git a/indoteknik_custom/views/stock_picking.xml b/indoteknik_custom/views/stock_picking.xml
index 9cd63e25..29a95626 100644
--- a/indoteknik_custom/views/stock_picking.xml
+++ b/indoteknik_custom/views/stock_picking.xml
@@ -133,6 +133,7 @@
<field name="total_mapping_koli" attrs="{'invisible': [('location_id', '!=', 60)]}"/>
<field name="total_koli_display" readonly="1" attrs="{'invisible': [('location_id', '!=', 60)]}"/>
<field name="linked_out_picking_id" readonly="1" attrs="{'invisible': [('location_id', '=', 60)]}"/>
+ <field name="number_soo" attrs="{'invisible': [('picking_type_id', 'in', [29,30])]}"/>
</field>
<field name="weight_uom_name" position="after">
<group>
diff --git a/indoteknik_custom/views/token_log.xml b/indoteknik_custom/views/token_log.xml
new file mode 100644
index 00000000..77e6dd48
--- /dev/null
+++ b/indoteknik_custom/views/token_log.xml
@@ -0,0 +1,33 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<odoo>
+ <record id="token_log_tree" model="ir.ui.view">
+ <field name="name">token.log.tree</field>
+ <field name="model">token.log</field>
+ <field name="arch" type="xml">
+ <tree editable="top" default_order="create_date desc">
+ <field name="token"/>
+ <field name="expires_at"/>
+ <field name="token_from"/>
+ <field name="created_at"/>
+ <field name="created_by"/>
+ <field name="source"/>
+ <field name="is_active" widget="boolean_toggle"/>
+ </tree>
+ </field>
+ </record>
+
+ <record id="token_log_action" model="ir.actions.act_window">
+ <field name="name">Token Log</field>
+ <field name="type">ir.actions.act_window</field>
+ <field name="res_model">token.log</field>
+ <field name="view_mode">tree,form</field>
+ </record>
+
+ <menuitem
+ action="token_log_action"
+ id="token_log"
+ parent="base.menu_users"
+ name="Token Log"
+ sequence="1"
+ />
+</odoo>