[UPD] initial commit
This commit is contained in:
3
gmn_freshservice/services/__init__.py
Normal file
3
gmn_freshservice/services/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Package services: berisi client untuk koneksi ke Freshservice API."""
|
||||
from . import gmn_freshservice_client_v01
|
||||
BIN
gmn_freshservice/services/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
gmn_freshservice/services/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
gmn_freshservice/services/__pycache__/__init__.cpython-314.pyc
Normal file
BIN
gmn_freshservice/services/__pycache__/__init__.cpython-314.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
652
gmn_freshservice/services/gmn_freshservice_client_v01.py
Normal file
652
gmn_freshservice/services/gmn_freshservice_client_v01.py
Normal file
@@ -0,0 +1,652 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Client untuk koneksi ke Freshservice REST API v2.
|
||||
Bisa jalan dalam 2 mode: Live (panggil API beneran) atau Mock (data dummy untuk testing).
|
||||
"""
|
||||
import datetime
|
||||
import logging
|
||||
import requests
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
class FreshserviceClient:
|
||||
"""
|
||||
Client adapter untuk integrasi Freshservice API v2.
|
||||
Mendukung dua mode:
|
||||
1. Mock Mode (Format data 100% identik dengan dokumentasi resmi Freshservice API)
|
||||
2. Live API Mode (Direct HTTP call ke endpoint perusahaan)
|
||||
"""
|
||||
|
||||
STATUS_MAP = {
|
||||
2: 'open',
|
||||
3: 'pending',
|
||||
4: 'resolved',
|
||||
5: 'closed',
|
||||
}
|
||||
|
||||
PRIORITY_MAP = {
|
||||
1: 'low',
|
||||
2: 'medium',
|
||||
3: 'high',
|
||||
4: 'urgent',
|
||||
}
|
||||
|
||||
_agent_cache = {}
|
||||
_dept_cache = {}
|
||||
|
||||
def __init__(self, base_url="https://servicedesk.lyman.co.id", api_key="", use_mock=False):
|
||||
"""Inisialisasi koneksi ke Freshservice.
|
||||
- base_url: URL instance Freshservice (misal: https://company.freshservice.com)
|
||||
- api_key: token API untuk otentikasi
|
||||
- use_mock: kalau True, pakai data dummy untuk testing tanpa koneksi internet
|
||||
"""
|
||||
self.base_url = (base_url or "").rstrip("/")
|
||||
self.api_key = api_key or ""
|
||||
self.use_mock = False
|
||||
|
||||
def get_agent_name(self, agent_id):
|
||||
"""Ambil nama agen IT Freshservice berdasarkan ID (dengan in-memory cache)"""
|
||||
if not agent_id or self.use_mock:
|
||||
return ""
|
||||
if agent_id in self._agent_cache:
|
||||
return self._agent_cache[agent_id]
|
||||
|
||||
try:
|
||||
auth = (self.api_key, "X")
|
||||
headers = {"Content-Type": "application/json", "Accept": "application/json"}
|
||||
url = f"{self.base_url}/api/v2/agents/{agent_id}"
|
||||
res = requests.get(url, auth=auth, headers=headers, timeout=10)
|
||||
if res.status_code == 200:
|
||||
agent = res.json().get("agent", {})
|
||||
first_name = agent.get("first_name") or ""
|
||||
last_name = agent.get("last_name") or ""
|
||||
full_name = f"{first_name} {last_name}".strip() or agent.get("email") or str(agent_id)
|
||||
self._agent_cache[agent_id] = full_name
|
||||
return full_name
|
||||
except Exception as e:
|
||||
_logger.warning("Gagal fetch agent %s dari Freshservice: %s", agent_id, e)
|
||||
|
||||
self._agent_cache[agent_id] = ""
|
||||
return ""
|
||||
|
||||
def get_department_name(self, dept_id):
|
||||
"""Ambil nama departemen Freshservice berdasarkan ID (dengan in-memory cache)"""
|
||||
if not dept_id or self.use_mock:
|
||||
return ""
|
||||
if dept_id in self._dept_cache:
|
||||
return self._dept_cache[dept_id]
|
||||
|
||||
try:
|
||||
auth = (self.api_key, "X")
|
||||
headers = {"Content-Type": "application/json", "Accept": "application/json"}
|
||||
url = f"{self.base_url}/api/v2/departments/{dept_id}"
|
||||
res = requests.get(url, auth=auth, headers=headers, timeout=10)
|
||||
if res.status_code == 200:
|
||||
dept = res.json().get("department", {})
|
||||
name = dept.get("name") or ""
|
||||
self._dept_cache[dept_id] = name
|
||||
return name
|
||||
except Exception as e:
|
||||
_logger.warning("Gagal fetch department %s dari Freshservice: %s", dept_id, e)
|
||||
|
||||
self._dept_cache[dept_id] = ""
|
||||
return ""
|
||||
|
||||
def fetch_ticket_full_data(self, ticket_id):
|
||||
"""
|
||||
Mengambil paket lengkap data tiket:
|
||||
- Ticket Details + SLA Stats
|
||||
- Audit Trail Activities
|
||||
- Conversations (Replies & Notes)
|
||||
"""
|
||||
if self.use_mock:
|
||||
_logger.info("Mengambil data tiket Freshservice via MOCK ENGINE untuk ID: %s", ticket_id)
|
||||
return self._get_mock_payload(ticket_id)
|
||||
else:
|
||||
_logger.info("Menghubungi LIVE Freshservice API: %s untuk ID: %s", self.base_url, ticket_id)
|
||||
return self._get_live_payload(ticket_id)
|
||||
|
||||
def fetch_recent_tickets(self, limit=200, tiket_id=None):
|
||||
"""
|
||||
Mengambil daftar tiket terbaru dari Freshservice API (default 200 tiket terbaru).
|
||||
Freshservice API v2 membatasi per_page maksimum 100 tiket per request,
|
||||
sehingga dilakukan paginasi (page 1, page 2, dst) secara cepat hingga mencapai limit.
|
||||
Hanya mengambil ringkasan tiket dan status (tanpa membebani API dengan detail activities).
|
||||
"""
|
||||
if self.use_mock:
|
||||
return self._get_mock_recent_tickets(limit=limit)
|
||||
|
||||
if not self.base_url or not self.api_key:
|
||||
raise UserError("Base URL dan API Key Freshservice wajib dikonfigurasi di Settings!")
|
||||
|
||||
auth = (self.api_key, "X")
|
||||
headers = {"Content-Type": "application/json", "Accept": "application/json"}
|
||||
|
||||
tickets = []
|
||||
page = 1
|
||||
per_page = 100
|
||||
while len(tickets) < limit:
|
||||
fetch_count = min(per_page, limit - len(tickets))
|
||||
url = f"{self.base_url}/api/v2/tickets?order_by=created_at&order_type=desc&per_page={fetch_count}&page={page}&include=requester,stats"
|
||||
try:
|
||||
res = requests.get(url, auth=auth, headers=headers, timeout=25)
|
||||
if res.status_code == 200:
|
||||
batch = res.json().get("tickets", [])
|
||||
if not batch:
|
||||
break
|
||||
|
||||
# Resolusi info pemohon, agen responder, dan departemen
|
||||
for t in batch:
|
||||
req = t.get('requester') or {}
|
||||
t['requester_name'] = req.get('name') or t.get('requester_name') or ''
|
||||
t['requester_email'] = req.get('email') or t.get('requester_email') or t.get('email') or ''
|
||||
|
||||
resp_id = t.get('responder_id')
|
||||
if resp_id:
|
||||
t['responder_name'] = self.get_agent_name(resp_id)
|
||||
|
||||
dept_id = t.get('department_id')
|
||||
if dept_id:
|
||||
t['department_name'] = self.get_department_name(dept_id)
|
||||
elif t.get('custom_fields', {}).get('company_information_for_owner_of_ticket'):
|
||||
t['department_name'] = t['custom_fields']['company_information_for_owner_of_ticket']
|
||||
|
||||
tickets.extend(batch)
|
||||
if len(batch) < fetch_count:
|
||||
# Data di Freshservice sudah habis
|
||||
break
|
||||
page += 1
|
||||
else:
|
||||
_logger.warning("Gagal fetch recent tickets halaman %s (HTTP %s): %s", page, res.status_code, res.text)
|
||||
break
|
||||
except Exception as e:
|
||||
_logger.error("Error fetching recent tickets from Freshservice: %s", e)
|
||||
break
|
||||
|
||||
return tickets[:limit]
|
||||
|
||||
def _get_mock_recent_tickets(self, limit=200):
|
||||
"""Mock data ringkasan tiket untuk pengujian cepat tanpa membebani network"""
|
||||
samples = [
|
||||
{
|
||||
"id": 129,
|
||||
"subject": "Kendala Koneksi Jaringan & SAP Client - Dept Finance [Mock #129]",
|
||||
"status": 4,
|
||||
"priority": 3,
|
||||
"requester_name": "Ahmad Dani (User)",
|
||||
"responder_name": "Budi Santoso (IT Support)",
|
||||
"department_name": "Finance & Accounting",
|
||||
"created_at": "2026-10-10T08:30:00Z",
|
||||
"due_by": "2026-10-12T17:00:00Z",
|
||||
"stats": {
|
||||
"first_responded_at": "2026-10-10T08:52:00Z",
|
||||
"resolved_at": "2026-10-11T16:30:00Z",
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 9991,
|
||||
"subject": "User Akses untuk modul report Account Receivables",
|
||||
"status": 2,
|
||||
"priority": 1,
|
||||
"requester_name": "Henny Gunawan",
|
||||
"responder_name": "Jonathan Tan",
|
||||
"department_name": "Accounting",
|
||||
"created_at": "2026-10-12T02:45:00Z",
|
||||
"due_by": "2026-10-14T17:00:00Z",
|
||||
"stats": {
|
||||
"first_responded_at": "2026-10-12T03:15:00Z",
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 9966,
|
||||
"subject": "Email gom@parahyangangolf.com - Your Certificate has Expired",
|
||||
"status": 4,
|
||||
"priority": 1,
|
||||
"requester_name": "Rohmat tulloh",
|
||||
"responder_name": "Prabowo Bowo",
|
||||
"department_name": "IT Infrastructure",
|
||||
"created_at": "2026-10-09T06:27:00Z",
|
||||
"due_by": "2026-10-11T17:00:00Z",
|
||||
"stats": {
|
||||
"first_responded_at": "2026-10-09T07:10:00Z",
|
||||
"resolved_at": "2026-10-09T09:00:00Z",
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 9950,
|
||||
"subject": "Permintaan Install Ulang Windows Laptop Sales",
|
||||
"status": 3,
|
||||
"priority": 2,
|
||||
"requester_name": "Siti Nurhaliza",
|
||||
"responder_name": "Budi Santoso (IT Support)",
|
||||
"department_name": "Sales & Marketing",
|
||||
"created_at": "2026-10-11T09:15:00Z",
|
||||
"due_by": "2026-10-13T17:00:00Z",
|
||||
"stats": {
|
||||
"first_responded_at": "2026-10-11T10:00:00Z",
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 9942,
|
||||
"subject": "Reset Password Akun VPN Kantor Pusat",
|
||||
"status": 5,
|
||||
"priority": 2,
|
||||
"requester_name": "Rudi Hermawan",
|
||||
"responder_name": "Prabowo Bowo",
|
||||
"department_name": "Human Resources",
|
||||
"created_at": "2026-10-08T11:00:00Z",
|
||||
"due_by": "2026-10-09T17:00:00Z",
|
||||
"stats": {
|
||||
"first_responded_at": "2026-10-08T11:15:00Z",
|
||||
"resolved_at": "2026-10-08T11:45:00Z",
|
||||
}
|
||||
},
|
||||
]
|
||||
return samples[:limit]
|
||||
|
||||
# def _get_live_payload(self, ticket_id):
|
||||
# if not self.base_url or not self.api_key:
|
||||
# raise UserError("Base URL dan API Key Freshservice wajib dikonfigurasi di Settings!")
|
||||
|
||||
# auth = (self.api_key, "X")
|
||||
# headers = {"Content-Type": "application/json", "Accept": "application/json"}
|
||||
|
||||
# # 1. Ticket details & stats with requester
|
||||
# url_ticket = f"{self.base_url}/api/v2/tickets/{ticket_id}?include=requester,stats"
|
||||
# res_ticket = requests.get(url_ticket, auth=auth, headers=headers, timeout=20)
|
||||
# if res_ticket.status_code != 200:
|
||||
# raise UserError(f"Gagal mengambil tiket dari Freshservice (HTTP {res_ticket.status_code}): {res_ticket.text}")
|
||||
# ticket_json = res_ticket.json().get("ticket", {})
|
||||
|
||||
# # Resolusi data pemohon (requester), agen (responder), dan departemen
|
||||
# req = ticket_json.get('requester') or {}
|
||||
# ticket_json['requester_name'] = req.get('name') or ticket_json.get('requester_name') or ''
|
||||
# ticket_json['requester_email'] = req.get('email') or ticket_json.get('requester_email') or ticket_json.get('email') or ''
|
||||
|
||||
# resp_id = ticket_json.get('responder_id')
|
||||
# if resp_id:
|
||||
# ticket_json['responder_name'] = self.get_agent_name(resp_id)
|
||||
|
||||
# dept_id = ticket_json.get('department_id')
|
||||
# if dept_id:
|
||||
# ticket_json['department_name'] = self.get_department_name(dept_id)
|
||||
# elif ticket_json.get('custom_fields', {}).get('company_information_for_owner_of_ticket'):
|
||||
# ticket_json['department_name'] = ticket_json['custom_fields']['company_information_for_owner_of_ticket']
|
||||
|
||||
# # 2. Activities (Audit Trail) with pagination support
|
||||
# url_activities = f"{self.base_url}/api/v2/tickets/{ticket_id}/activities"
|
||||
# activities_json = []
|
||||
# page_count = 0
|
||||
# while url_activities and page_count < 10:
|
||||
# page_count += 1
|
||||
# try:
|
||||
# res_act = requests.get(url_activities, auth=auth, headers=headers, timeout=20)
|
||||
# if res_act.status_code == 200:
|
||||
# act_data = res_act.json()
|
||||
# activities_json.extend(act_data.get("activities", []))
|
||||
# url_activities = act_data.get("next_page_url")
|
||||
# else:
|
||||
# _logger.warning("Gagal fetch activities Freshservice (HTTP %s): %s", res_act.status_code, res_act.text)
|
||||
# break
|
||||
# except Exception as e:
|
||||
# _logger.warning("Exception saat fetch activities halaman %s: %s", page_count, e)
|
||||
# break
|
||||
|
||||
# # 3. Conversations
|
||||
# url_conv = f"{self.base_url}/api/v2/tickets/{ticket_id}/conversations"
|
||||
# res_conv = requests.get(url_conv, auth=auth, headers=headers, timeout=20)
|
||||
# conversations_json = res_conv.json().get("conversations", []) if res_conv.status_code == 200 else []
|
||||
|
||||
# return {
|
||||
# "ticket": ticket_json,
|
||||
# "activities": activities_json,
|
||||
# "conversations": conversations_json,
|
||||
# }
|
||||
def _get_live_payload(self, ticket_id):
|
||||
"""Ambil data lengkap tiket dari server live Freshservice via REST API.
|
||||
Memanggil beberapa endpoint:
|
||||
1. /api/v2/tickets/{id}?include=stats,requester - info tiket & metrik
|
||||
2. /api/v2/tickets/{id}/activities - audit trail/riwayat perubahan
|
||||
3. /api/v2/tickets/{id}/conversations - riwayat pesan/chat
|
||||
Mengembalikan dictionary berisi ketiga data di atas.
|
||||
"""
|
||||
if not self.base_url or not self.api_key:
|
||||
raise UserError(
|
||||
"Base URL dan API Key Freshservice wajib dikonfigurasi di Settings!"
|
||||
)
|
||||
|
||||
auth = (self.api_key, "X")
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
}
|
||||
|
||||
# 1. Ticket details & stats with requester
|
||||
url_ticket = (
|
||||
f"{self.base_url}/api/v2/tickets/"
|
||||
f"{ticket_id}?include=requester,stats"
|
||||
)
|
||||
|
||||
res_ticket = requests.get(
|
||||
url_ticket,
|
||||
auth=auth,
|
||||
headers=headers,
|
||||
timeout=20,
|
||||
)
|
||||
|
||||
if res_ticket.status_code != 200:
|
||||
raise UserError(
|
||||
f"Gagal mengambil tiket dari Freshservice "
|
||||
f"(HTTP {res_ticket.status_code}): {res_ticket.text}"
|
||||
)
|
||||
|
||||
ticket_json = res_ticket.json().get("ticket", {})
|
||||
|
||||
# Resolusi data pemohon (requester), agen (responder), dan departemen
|
||||
req = ticket_json.get("requester") or {}
|
||||
|
||||
ticket_json["requester_name"] = (
|
||||
req.get("name")
|
||||
or ticket_json.get("requester_name")
|
||||
or ""
|
||||
)
|
||||
|
||||
ticket_json["requester_email"] = (
|
||||
req.get("email")
|
||||
or ticket_json.get("requester_email")
|
||||
or ticket_json.get("email")
|
||||
or ""
|
||||
)
|
||||
|
||||
resp_id = ticket_json.get("responder_id")
|
||||
if resp_id:
|
||||
ticket_json["responder_name"] = self.get_agent_name(resp_id)
|
||||
|
||||
dept_id = ticket_json.get("department_id")
|
||||
if dept_id:
|
||||
ticket_json["department_name"] = self.get_department_name(dept_id)
|
||||
elif ticket_json.get("custom_fields", {}).get(
|
||||
"company_information_for_owner_of_ticket"
|
||||
):
|
||||
ticket_json["department_name"] = ticket_json["custom_fields"][
|
||||
"company_information_for_owner_of_ticket"
|
||||
]
|
||||
|
||||
# 2. Activities (Audit Trail)
|
||||
# Fetch seluruh halaman sampai next_page_url kosong
|
||||
url_activities = (
|
||||
f"{self.base_url}/api/v2/tickets/"
|
||||
f"{ticket_id}/activities"
|
||||
)
|
||||
|
||||
activities_json = []
|
||||
visited_urls = set()
|
||||
|
||||
while url_activities:
|
||||
# Mencegah infinite loop jika URL pagination berulang
|
||||
if url_activities in visited_urls:
|
||||
_logger.warning(
|
||||
"Pagination activities dihentikan karena URL berulang: %s",
|
||||
url_activities,
|
||||
)
|
||||
break
|
||||
|
||||
visited_urls.add(url_activities)
|
||||
|
||||
try:
|
||||
res_act = requests.get(
|
||||
url_activities,
|
||||
auth=auth,
|
||||
headers=headers,
|
||||
timeout=20,
|
||||
)
|
||||
|
||||
if res_act.status_code != 200:
|
||||
_logger.warning(
|
||||
"Gagal fetch activities Freshservice (HTTP %s): %s",
|
||||
res_act.status_code,
|
||||
res_act.text,
|
||||
)
|
||||
break
|
||||
|
||||
act_data = res_act.json()
|
||||
|
||||
# Tambahkan activities dari halaman saat ini
|
||||
activities_json.extend(
|
||||
act_data.get("activities", [])
|
||||
)
|
||||
|
||||
# Lanjut sampai halaman terakhir
|
||||
url_activities = act_data.get("next_page_url")
|
||||
|
||||
except Exception as e:
|
||||
_logger.warning(
|
||||
"Exception saat fetch activities Freshservice: %s",
|
||||
e,
|
||||
)
|
||||
break
|
||||
|
||||
_logger.info(
|
||||
"Freshservice ticket %s: berhasil mengambil %s activities",
|
||||
ticket_id,
|
||||
len(activities_json),
|
||||
)
|
||||
|
||||
# 3. Conversations
|
||||
url_conv = (
|
||||
f"{self.base_url}/api/v2/tickets/"
|
||||
f"{ticket_id}/conversations"
|
||||
)
|
||||
|
||||
conversations_json = []
|
||||
|
||||
try:
|
||||
res_conv = requests.get(
|
||||
url_conv,
|
||||
auth=auth,
|
||||
headers=headers,
|
||||
timeout=20,
|
||||
)
|
||||
|
||||
if res_conv.status_code == 200:
|
||||
conversations_json = res_conv.json().get(
|
||||
"conversations", []
|
||||
)
|
||||
else:
|
||||
_logger.warning(
|
||||
"Gagal fetch conversations Freshservice "
|
||||
"(HTTP %s): %s",
|
||||
res_conv.status_code,
|
||||
res_conv.text,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
_logger.warning(
|
||||
"Exception saat fetch conversations Freshservice: %s",
|
||||
e,
|
||||
)
|
||||
|
||||
return {
|
||||
"ticket": ticket_json,
|
||||
"activities": activities_json,
|
||||
"conversations": conversations_json,
|
||||
}
|
||||
|
||||
def _get_mock_payload(self, ticket_id):
|
||||
"""
|
||||
Data Mock standar enterprise sesuai dokumentasi Freshservice API v2.
|
||||
Merefleksikan siklus tiket riil:
|
||||
- Open (08:30)
|
||||
- First Response oleh Agent (08:52) -> jeda 22 menit
|
||||
- Status Pending User (09:30) -> menunggu klarifikasi user
|
||||
- User Balas (Keesokan hari 14:00) -> 28.5 jam di user
|
||||
- Status In Progress (14:00) -> 2.5 jam di internal
|
||||
- Status Resolved (16:30)
|
||||
"""
|
||||
str_id = str(ticket_id or "129")
|
||||
|
||||
return {
|
||||
"ticket": {
|
||||
"id": int(str_id) if str_id.isdigit() else 129,
|
||||
"subject": f"Kendala Koneksi Jaringan & SAP Client - Dept Finance [Mock #{str_id}]",
|
||||
"description_text": "Koneksi ke server SAP sering terputus dan lambat sejak pukul 08:00 pagi. Mohon dicek.",
|
||||
"status": 4, # 4 = Resolved
|
||||
"priority": 3, # 3 = High
|
||||
"source": 2, # Portal
|
||||
"requester_id": 19000123456,
|
||||
"requester_name": "Ahmad Dani (User)",
|
||||
"requester_email": "ahmad.dani@lyman.co.id",
|
||||
"responder_id": 19000234567,
|
||||
"responder_name": "Budi Santoso (IT Support)",
|
||||
"responder_email": "budi.santoso@lyman.co.id",
|
||||
"department_name": "Finance & Accounting",
|
||||
"created_at": "2026-10-10T08:30:00Z",
|
||||
"updated_at": "2026-10-11T16:30:00Z",
|
||||
"due_by": "2026-10-12T17:00:00Z",
|
||||
"stats": {
|
||||
"first_responded_at": "2026-10-10T08:52:00Z",
|
||||
"resolved_at": "2026-10-11T16:30:00Z",
|
||||
"closed_at": None,
|
||||
"pending_since": "2026-10-10T09:30:00Z",
|
||||
}
|
||||
},
|
||||
"activities": [
|
||||
{
|
||||
"id": 101,
|
||||
"actor": {
|
||||
"id": 19000123456,
|
||||
"name": "Ahmad Dani (User)",
|
||||
"is_agent": False
|
||||
},
|
||||
"actor_id": 19000123456,
|
||||
"actor_type": "Requester",
|
||||
"actor_name": "Ahmad Dani (User)",
|
||||
"content": "Tiket dibuat via Web Portal dengan status Open",
|
||||
"stage_name": "Open",
|
||||
"responsibility": "internal",
|
||||
"created_at": "2026-10-10T08:30:00Z"
|
||||
},
|
||||
{
|
||||
"id": 102,
|
||||
"actor": {
|
||||
"id": 19000234567,
|
||||
"name": "Budi Santoso (IT Support)",
|
||||
"is_agent": True
|
||||
},
|
||||
"actor_id": 19000234567,
|
||||
"actor_type": "Agent",
|
||||
"actor_name": "Budi Santoso (IT Support)",
|
||||
"content": "First Response: Tiket diterima, sedang dilakukan pengecekan switch jaringan.",
|
||||
"stage_name": "In Progress",
|
||||
"responsibility": "internal",
|
||||
"created_at": "2026-10-10T08:52:00Z"
|
||||
},
|
||||
{
|
||||
"id": 103,
|
||||
"actor": {
|
||||
"id": 19000234567,
|
||||
"name": "Budi Santoso (IT Support)",
|
||||
"is_agent": True
|
||||
},
|
||||
"actor_id": 19000234567,
|
||||
"actor_type": "Agent",
|
||||
"actor_name": "Budi Santoso (IT Support)",
|
||||
"content": "Status diubah ke Pending: Menunggu user mengirimkan IP Address dan pesan error lengkap.",
|
||||
"stage_name": "Pending User",
|
||||
"responsibility": "customer",
|
||||
"created_at": "2026-10-10T09:30:00Z"
|
||||
},
|
||||
{
|
||||
"id": 104,
|
||||
"actor": {
|
||||
"id": 19000123456,
|
||||
"name": "Ahmad Dani (User)",
|
||||
"is_agent": False
|
||||
},
|
||||
"actor_id": 19000123456,
|
||||
"actor_type": "Requester",
|
||||
"actor_name": "Ahmad Dani (User)",
|
||||
"content": "User membalas: IP komputer 192.168.10.45 dan switch port lampu kuning.",
|
||||
"stage_name": "In Progress",
|
||||
"responsibility": "internal",
|
||||
"created_at": "2026-10-11T14:00:00Z"
|
||||
},
|
||||
{
|
||||
"id": 105,
|
||||
"actor": {
|
||||
"id": 19000234567,
|
||||
"name": "Budi Santoso (IT Support)",
|
||||
"is_agent": True
|
||||
},
|
||||
"actor_id": 19000234567,
|
||||
"actor_type": "Agent",
|
||||
"actor_name": "Budi Santoso (IT Support)",
|
||||
"content": "Tiket Diselesaikan: Port switch diganti dan koneksi SAP telah kembali normal.",
|
||||
"stage_name": "Resolved",
|
||||
"responsibility": "internal",
|
||||
"created_at": "2026-10-11T16:30:00Z"
|
||||
}
|
||||
],
|
||||
"conversations": [
|
||||
{
|
||||
"id": 501,
|
||||
"body_text": "Halo Pak Dani, tiket sudah kami terima. Kami sedang memeriksa port router dan switch lantai 3.",
|
||||
"incoming": False,
|
||||
"user_id": 19000234567,
|
||||
"user_name": "Budi Santoso (IT Support)",
|
||||
"created_at": "2026-10-10T08:52:00Z"
|
||||
},
|
||||
{
|
||||
"id": 502,
|
||||
"body_text": "Mohon informasikan IP Address komputer Bapak dan apakah lampu indikator LAN di CPU berkedip oranye?",
|
||||
"incoming": False,
|
||||
"user_id": 19000234567,
|
||||
"user_name": "Budi Santoso (IT Support)",
|
||||
"created_at": "2026-10-10T09:30:00Z"
|
||||
},
|
||||
{
|
||||
"id": 503,
|
||||
"body_text": "Siang Mas Budi, IP saya 192.168.10.45. Lampunya kedip oranye terus.",
|
||||
"incoming": True,
|
||||
"user_id": 19000123456,
|
||||
"user_name": "Ahmad Dani (User)",
|
||||
"created_at": "2026-10-11T14:00:00Z"
|
||||
},
|
||||
{
|
||||
"id": 504,
|
||||
"body_text": "Kabel patch cord dan port switch sudah kami ganti baru. Sekarang SAP sudah lancar kembali.",
|
||||
"incoming": False,
|
||||
"user_id": 19000234567,
|
||||
"user_name": "Budi Santoso (IT Support)",
|
||||
"created_at": "2026-10-11T16:30:00Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def parse_datetime(iso_str):
|
||||
"""Konversi timestamp API ke string datetime UTC yang diterima Odoo.
|
||||
|
||||
API dapat mengirim ISO string maupun objek ``datetime``. Keduanya
|
||||
dinormalisasi ke format yang sama agar tidak terjadi perbandingan
|
||||
``str`` dengan ``datetime`` saat audit trail direkonstruksi.
|
||||
"""
|
||||
if not iso_str:
|
||||
return False
|
||||
try:
|
||||
if isinstance(iso_str, datetime.datetime):
|
||||
dt = iso_str
|
||||
elif isinstance(iso_str, str):
|
||||
clean = iso_str.strip().replace("Z", "+00:00")
|
||||
dt = datetime.datetime.fromisoformat(clean)
|
||||
else:
|
||||
_logger.warning("Tipe datetime tidak didukung: %r", type(iso_str))
|
||||
return False
|
||||
|
||||
# Konversi ke naive UTC untuk Odoo.
|
||||
if dt.tzinfo is not None:
|
||||
dt = dt.astimezone(datetime.timezone.utc).replace(tzinfo=None)
|
||||
return dt.strftime("%Y-%m-%d %H:%M:%S")
|
||||
except Exception as e:
|
||||
_logger.warning("Gagal parse datetime '%s': %s", iso_str, e)
|
||||
return False
|
||||
Reference in New Issue
Block a user