[ADD] iitial commit gmn fresh service

This commit is contained in:
Madanil
2026-10-19 08:07:51 +07:00
commit ebdc2ee304
38 changed files with 4111 additions and 0 deletions

View File

@@ -0,0 +1,2 @@
# -*- coding: utf-8 -*-
from . import gmn_freshservice_client_v01

View File

@@ -0,0 +1,637 @@
# -*- coding: utf-8 -*-
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=True):
self.base_url = (base_url or "").rstrip("/")
self.api_key = api_key or ""
self.use_mock = use_mock
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):
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