[UPD] add dokumentasi fucntion
This commit is contained in:
@@ -1,4 +1,7 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Modul utama GMN Freshservice.
|
||||
Import semua sub-package: models, services, dan wizard.
|
||||
"""
|
||||
from . import models
|
||||
from . import services
|
||||
from . import wizard
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Package models: semua model Odoo untuk modul Freshservice.
|
||||
Urutan import penting karena ticket bergantung pada stage_log dan conversation.
|
||||
"""
|
||||
from . import gmn_res_config_settings_v01
|
||||
from . import gmn_freshservice_ticket_stage_log_v01
|
||||
from . import gmn_freshservice_ticket_conversation_v01
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Model untuk menyimpan riwayat percakapan (chat/pesan/reply) dari tiket Freshservice.
|
||||
Setiap baris adalah satu pesan, baik dari user maupun dari agen IT.
|
||||
"""
|
||||
from odoo import models, fields
|
||||
|
||||
|
||||
class FreshserviceTicketConversation(models.Model):
|
||||
_name = 'freshservice.ticket.conversation'
|
||||
"""Menyimpan pesan/balasan pada tiket.
|
||||
Dipakai untuk menampilkan isi chat di tab percakapan dan di file Excel.
|
||||
"""
|
||||
_description = 'Percakapan & Balasan Tiket Freshservice'
|
||||
_order = 'created_at asc, id asc'
|
||||
|
||||
@@ -32,5 +38,9 @@ class FreshserviceTicketConversation(models.Model):
|
||||
created_at = fields.Datetime(string='Waktu Kirim')
|
||||
|
||||
def _compute_sender_type(self):
|
||||
"""Tentukan siapa yang kirim pesan:
|
||||
- 'customer' jika incoming=True (pesan dari user)
|
||||
- 'agent' jika incoming=False (balasan dari agen IT)
|
||||
"""
|
||||
for rec in self:
|
||||
rec.sender_type = 'customer' if rec.incoming else 'agent'
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Model Stage Log: menyimpan riwayat perubahan status tiket beserta durasi.
|
||||
Termasuk helper function untuk hitung jam kerja, hari kerja, dan format tampilan.
|
||||
"""
|
||||
from datetime import timedelta, datetime, time
|
||||
from odoo import models, fields, api
|
||||
|
||||
@@ -240,6 +243,11 @@ def format_duration_human(hours_float):
|
||||
return f"{days} Hari"
|
||||
|
||||
class FreshserviceTicketStageLog(models.Model):
|
||||
"""Satu baris = satu fase/status di tiket.
|
||||
Contoh: Open → In Progress → Pending User → Resolved.
|
||||
Setiap baris punya waktu mulai, waktu selesai, siapa pelakunya,
|
||||
dan siapa yang bertanggung jawab (Agent/User/Third Party).
|
||||
"""
|
||||
_name = 'freshservice.ticket.stage.log'
|
||||
_description = 'Audit Trail Riwayat Status & Durasi Tiket'
|
||||
_order = 'start_date asc, id asc'
|
||||
@@ -345,11 +353,15 @@ class FreshserviceTicketStageLog(models.Model):
|
||||
|
||||
@api.depends('end_date')
|
||||
def _compute_is_active(self):
|
||||
"""Stage dianggap masih aktif kalau belum ada end_date (belum selesai)."""
|
||||
for rec in self:
|
||||
rec.is_active = not bool(rec.end_date)
|
||||
|
||||
@api.depends('start_date', 'end_date', 'is_active')
|
||||
def _compute_working_metrics(self):
|
||||
"""Hitung jam kerja efektif (08:30-17:30 WIB, Senin-Jumat)
|
||||
dan konversi ke hari kerja (1 hari = 8 jam).
|
||||
"""
|
||||
for rec in self:
|
||||
if rec.start_date:
|
||||
wh = compute_working_hours(self.env, rec.start_date, rec.end_date)
|
||||
@@ -363,6 +375,9 @@ class FreshserviceTicketStageLog(models.Model):
|
||||
|
||||
@api.depends('start_date', 'end_date')
|
||||
def _compute_duration(self):
|
||||
"""Hitung durasi dalam hari kalender dan jam total.
|
||||
Kalau stage masih aktif, hitung sampai waktu sekarang.
|
||||
"""
|
||||
for rec in self:
|
||||
if rec.start_date:
|
||||
days = compute_calendar_days(self.env, rec.start_date, rec.end_date)
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Model utama Freshservice Ticket.
|
||||
Menyimpan data tiket, metrik SLA, dan logika sinkronisasi dari Freshservice API.
|
||||
"""
|
||||
import re
|
||||
import logging
|
||||
from datetime import datetime, timedelta, timezone
|
||||
@@ -10,6 +13,11 @@ from .gmn_freshservice_ticket_stage_log_v01 import format_duration_human, format
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
class FreshserviceTicket(models.Model):
|
||||
"""Model tiket Freshservice di Odoo.
|
||||
Menyimpan info tiket (judul, status, prioritas, pemohon, agen),
|
||||
metrik SLA (first response, resolution time, breakdown per tanggung jawab),
|
||||
dan relasi ke stage log (audit trail) serta percakapan.
|
||||
"""
|
||||
_name = 'freshservice.ticket'
|
||||
_description = 'Freshservice Ticket with Audit Trail'
|
||||
_inherit = ['mail.thread', 'mail.activity.mixin']
|
||||
@@ -212,6 +220,12 @@ class FreshserviceTicket(models.Model):
|
||||
'stage_log_ids.duration_days', 'stage_log_ids.duration_hours', 'stage_log_ids.working_hours', 'stage_log_ids.is_active'
|
||||
)
|
||||
def _compute_time_metrics(self):
|
||||
"""Hitung semua metrik waktu tiket:
|
||||
- First Response Time (berapa lama sampai agen pertama kali merespon)
|
||||
- Breakdown jam kerja per tanggung jawab (Agent/User/Meeting/Third Party)
|
||||
- Resolution Time (total waktu sampai tiket selesai)
|
||||
- Status tanggung jawab saat ini (siapa yang harus action)
|
||||
"""
|
||||
for rec in self:
|
||||
# 1. Hitung First Response
|
||||
if rec.ticket_created_at and rec.first_responded_at:
|
||||
@@ -302,7 +316,9 @@ class FreshserviceTicket(models.Model):
|
||||
}
|
||||
|
||||
def action_assign_to_me(self):
|
||||
"""Menugaskan tiket ini ke user Odoo yang sedang login"""
|
||||
"""Tombol 'Assign ke Saya': tugaskan tiket ini ke user yang sedang login.
|
||||
Otomatis catat di chatter siapa yang ambil alih tiket.
|
||||
"""
|
||||
self.ensure_one()
|
||||
current_user = self.env.user
|
||||
vals = {'user_id': current_user.id}
|
||||
@@ -348,10 +364,12 @@ class FreshserviceTicket(models.Model):
|
||||
return False, ""
|
||||
|
||||
def action_sync_from_freshservice(self):
|
||||
"""
|
||||
Sinkronisasi data dari Freshservice (Mock Engine atau Live API sesuai konfigurasi).
|
||||
Memetakan payload JSON ke Model Tiket, Audit Trail Logs, dan Percakapan.
|
||||
Mendukung concurrency row-lock untuk keamanan multi-user.
|
||||
"""Tarik data lengkap tiket dari Freshservice API.
|
||||
Proses:
|
||||
1. Ambil detail tiket (subject, status, requester, dll)
|
||||
2. Ambil audit trail activities lalu rekonstruksi jadi stage log
|
||||
3. Ambil percakapan (reply/note)
|
||||
Pakai row-lock supaya aman kalau 2 user sync bersamaan.
|
||||
"""
|
||||
self.ensure_one()
|
||||
|
||||
@@ -959,7 +977,9 @@ class FreshserviceTicket(models.Model):
|
||||
|
||||
@api.model
|
||||
def _extract_ticket_ids_from_domain(self, domain):
|
||||
"""Ambil angka ticket ID dari domain pencarian agar bisa fetch on-demand."""
|
||||
"""Cari angka-angka ticket ID dari domain pencarian Odoo.
|
||||
Contoh: domain [('external_ticket_id', '=', '992')] → return ['992']
|
||||
"""
|
||||
ticket_ids = []
|
||||
if not domain:
|
||||
return ticket_ids
|
||||
@@ -982,7 +1002,9 @@ class FreshserviceTicket(models.Model):
|
||||
|
||||
@api.model
|
||||
def _fetch_and_create_ticket_by_external_id(self, ticket_id):
|
||||
"""Ambil tiket tertentu dari Freshservice jika belum ada di Odoo."""
|
||||
"""Cari tiket di Odoo berdasarkan ID Freshservice.
|
||||
Kalau belum ada, buat record baru lalu langsung sync dari API.
|
||||
"""
|
||||
if ticket_id in (None, ''):
|
||||
return self.browse()
|
||||
|
||||
@@ -1016,10 +1038,8 @@ class FreshserviceTicket(models.Model):
|
||||
|
||||
@api.model
|
||||
def action_open_and_fetch_tickets(self, tiket_id = None):
|
||||
"""
|
||||
Action menu: Otomatis get tiket terbaru dari Freshservice saat membuka menu 'Daftar Tiket',
|
||||
lalu langsung membuka tampilan list tiket secara real-time!
|
||||
Batas jumlah tiket disesuaikan dengan konfigurasi di Settings.
|
||||
"""Dipanggil saat user klik menu 'Daftar Tiket'.
|
||||
Otomatis tarik tiket terbaru dari Freshservice, lalu buka halaman list.
|
||||
"""
|
||||
try:
|
||||
self._auto_sync_recent_tickets(limit=None, raise_if_empty=False, tiket_id=tiket_id)
|
||||
@@ -1038,6 +1058,9 @@ class FreshserviceTicket(models.Model):
|
||||
|
||||
@api.model
|
||||
def name_search(self, name='', args=None, operator='ilike', limit=100):
|
||||
"""Override name_search: kalau user ketik nomor tiket yang belum ada di Odoo,
|
||||
otomatis fetch dari Freshservice dulu baru tampilkan.
|
||||
"""
|
||||
if name and isinstance(name, str):
|
||||
ticket_ids = re.findall(r'\d+', name.strip())
|
||||
for ticket_id in ticket_ids:
|
||||
@@ -1047,9 +1070,9 @@ class FreshserviceTicket(models.Model):
|
||||
|
||||
@api.model
|
||||
def web_search_read(self, domain, specification, offset=0, limit=None, order=None, count_limit=None):
|
||||
"""
|
||||
Otomatis fetch tiket terbaru dari Freshservice saat pengguna membuka / me-refresh (F5) halaman list tiket.
|
||||
Batas jumlah tiket disesuaikan dengan konfigurasi di Settings.
|
||||
"""Override web_search_read: otomatis sync tiket terbaru dari Freshservice
|
||||
setiap kali user buka atau refresh halaman list tiket.
|
||||
Juga cek kalau ada nomor tiket di pencarian yang belum ada di Odoo.
|
||||
"""
|
||||
if offset == 0 and not self.env.context.get('skip_auto_fetch'):
|
||||
try:
|
||||
@@ -1067,9 +1090,8 @@ class FreshserviceTicket(models.Model):
|
||||
|
||||
@api.readonly
|
||||
def web_read(self, specification: dict[str, dict]) -> list[dict]:
|
||||
"""
|
||||
Ketika pengguna membuka form view (detail tiket), jika detail audit trail
|
||||
belum di-fetch, otomatis fetch detail tiket tersebut saja secara on-demand.
|
||||
"""Override web_read: saat user buka detail tiket (form view),
|
||||
kalau audit trail belum pernah di-sync, otomatis fetch dari Freshservice.
|
||||
"""
|
||||
if len(self) == 1 and not self.env.context.get('skip_auto_detail_fetch'):
|
||||
# Form view meminta detail fields seperti stage_log_ids, conversation_ids, atau audit trail
|
||||
@@ -1090,8 +1112,8 @@ class FreshserviceTicket(models.Model):
|
||||
return super().web_read(specification)
|
||||
|
||||
def action_sync_recent_tickets(self):
|
||||
"""
|
||||
Tombol manual 'Tarik Tiket Terbaru Freshservice' di header tabel jika user ingin refresh paksa.
|
||||
"""Tombol manual 'Tarik Tiket Terbaru' di halaman list.
|
||||
Dipanggil kalau user mau refresh paksa daftar tiket dari Freshservice.
|
||||
"""
|
||||
synced_count = self._auto_sync_recent_tickets(limit=None, raise_if_empty=True)
|
||||
return {
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Konfigurasi Settings untuk koneksi Freshservice.
|
||||
User bisa set URL, API Key, dan limit sync tiket dari menu Settings Odoo.
|
||||
"""
|
||||
from odoo import models, fields, api
|
||||
from odoo.exceptions import UserError
|
||||
import requests
|
||||
|
||||
|
||||
class ResConfigSettings(models.TransientModel):
|
||||
"""Halaman Settings untuk mengatur koneksi ke Freshservice API.
|
||||
Bisa diakses dari menu Settings > Freshservice.
|
||||
"""
|
||||
_inherit = 'res.config.settings'
|
||||
|
||||
freshservice_url = fields.Char(
|
||||
@@ -30,7 +37,9 @@ class ResConfigSettings(models.TransientModel):
|
||||
)
|
||||
|
||||
def action_test_freshservice_connection(self):
|
||||
"""Uji konektivitas ke server Freshservice"""
|
||||
"""Tombol 'Test Connection' di halaman Settings.
|
||||
Coba panggil API Freshservice untuk cek apakah URL dan API Key valid.
|
||||
"""
|
||||
self.ensure_one()
|
||||
url = (self.freshservice_url or "").rstrip("/")
|
||||
if not url:
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Package services: berisi client untuk koneksi ke Freshservice API."""
|
||||
from . import gmn_freshservice_client_v01
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
# -*- 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
|
||||
@@ -32,6 +35,11 @@ class FreshserviceClient:
|
||||
_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
|
||||
@@ -295,6 +303,13 @@ class FreshserviceClient:
|
||||
# "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!"
|
||||
|
||||
3
gmn_freshservice/tests/__init__.py
Normal file
3
gmn_freshservice/tests/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Package test untuk modul GMN Freshservice."""
|
||||
from . import test_responsibility_rules
|
||||
BIN
gmn_freshservice/tests/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
gmn_freshservice/tests/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
Binary file not shown.
78
gmn_freshservice/tests/test_responsibility_rules.py
Normal file
78
gmn_freshservice/tests/test_responsibility_rules.py
Normal file
@@ -0,0 +1,78 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Test untuk memastikan pembagian tanggung jawab (responsibility bucket)
|
||||
dan laporan tiket berjalan dengan benar.
|
||||
|
||||
Data expected value diambil dari tiket #9658 yang sudah disetujui.
|
||||
Test ini hanya jalan kalau fixture tiket #9658 sudah di-install.
|
||||
"""
|
||||
from odoo.tests.common import TransactionCase
|
||||
|
||||
|
||||
class TestResponsibilityRules(TransactionCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
"""Siapkan model Ticket supaya bisa dipakai di semua test."""
|
||||
super().setUpClass()
|
||||
cls.Ticket = cls.env['freshservice.ticket']
|
||||
|
||||
def _ticket_9658(self):
|
||||
"""Cari tiket #9658 di database. Return kosong kalau belum ada."""
|
||||
return self.Ticket.search([('external_ticket_id', '=', '9658')], limit=1)
|
||||
|
||||
def test_acceptance_1_eight_buckets_sum_to_total(self):
|
||||
"""Cek 8 bucket tanggung jawab. Jumlah jam masing-masing bucket
|
||||
harus cocok dengan nilai expected, dan total semua bucket
|
||||
harus sama dengan grand total.
|
||||
"""
|
||||
ticket = self._ticket_9658()
|
||||
if not ticket:
|
||||
self.skipTest('Approved Freshservice fixture for ticket #9658 is not installed.')
|
||||
values = {
|
||||
'internal': 30.70,
|
||||
'it_hold': 10.74,
|
||||
'customer': 31.34,
|
||||
'user_after_auto_resolve': 29.89,
|
||||
'needs_verification': 36.72,
|
||||
'scheduled_meeting': 13.26,
|
||||
'third_party': 27.22,
|
||||
'resolved_idle': 0.0,
|
||||
}
|
||||
actual = {key: 0.0 for key in values}
|
||||
for log in ticket.stage_log_ids:
|
||||
key = log.override_responsibility or log.responsibility
|
||||
if key in actual:
|
||||
actual[key] += log.duration_hours or 0.0
|
||||
for key, expected in values.items():
|
||||
self.assertAlmostEqual(actual[key], expected, places=2, msg=key)
|
||||
self.assertAlmostEqual(sum(actual.values()), sum(values.values()), places=2)
|
||||
|
||||
def test_acceptance_2_as_of_and_overrides(self):
|
||||
"""Pastikan tiket punya field first_ack_at dan
|
||||
first_meaningful_response_at (waktu respon pertama).
|
||||
"""
|
||||
ticket = self._ticket_9658()
|
||||
if not ticket:
|
||||
self.skipTest('Approved Freshservice fixture for ticket #9658 is not installed.')
|
||||
self.assertTrue(all(hasattr(ticket, field) for field in (
|
||||
'first_ack_at', 'first_meaningful_response_at',
|
||||
)))
|
||||
|
||||
def test_acceptance_3_bucket_invariant(self):
|
||||
"""Cek invariant: total jam dari semua bucket harus sama
|
||||
dengan total jam dari semua stage log. Tidak boleh ada
|
||||
jam yang hilang atau dobel.
|
||||
"""
|
||||
ticket = self._ticket_9658()
|
||||
if not ticket:
|
||||
self.skipTest('Approved Freshservice fixture for ticket #9658 is not installed.')
|
||||
bucket_names = {
|
||||
'internal', 'it_hold', 'customer', 'user_after_auto_resolve',
|
||||
'needs_verification', 'scheduled_meeting', 'third_party',
|
||||
'resolved_idle',
|
||||
}
|
||||
bucket_total = sum(
|
||||
log.duration_hours or 0.0
|
||||
for log in ticket.stage_log_ids
|
||||
if (log.override_responsibility or log.responsibility) in bucket_names
|
||||
)
|
||||
self.assertAlmostEqual(bucket_total, sum(log.duration_hours or 0.0 for log in ticket.stage_log_ids), places=2)
|
||||
@@ -1,2 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Package wizard untuk laporan bottleneck tiket Freshservice."""
|
||||
from . import gmn_freshservice_ticket_bottleneck_wizard_v01
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Wizard untuk generate laporan Excel analisis bottleneck tiket Freshservice.
|
||||
Bisa bikin 2 jenis laporan:
|
||||
1. Detail bottleneck satu tiket (breakdown per stage siapa yang lama)
|
||||
2. Ranking multi-tiket (perbandingan banyak tiket sekaligus)
|
||||
"""
|
||||
import io
|
||||
import re
|
||||
import base64
|
||||
@@ -11,7 +16,12 @@ from ..models.gmn_freshservice_ticket_stage_log_v01 import compute_calendar_days
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FreshserviceTicketBottleneckWizard(models.TransientModel):
|
||||
"""Wizard popup untuk bikin laporan Excel bottleneck.
|
||||
User pilih tiket, filter tanggal/status, lalu klik Generate.
|
||||
Hasilnya file Excel yang bisa langsung diunduh.
|
||||
"""
|
||||
_name = 'freshservice.ticket.bottleneck.wizard'
|
||||
_description = 'Wizard Laporan Analisis Bottleneck & Ranking Tiket Freshservice (Excel)'
|
||||
|
||||
@@ -74,6 +84,7 @@ class FreshserviceTicketBottleneckWizard(models.TransientModel):
|
||||
|
||||
@api.onchange('ticket_id')
|
||||
def _onchange_ticket_id(self):
|
||||
"""Kalau user pilih tiket dari dropdown, otomatis isi nomor ID Freshservice-nya."""
|
||||
if self.ticket_id and self.ticket_id.external_ticket_id:
|
||||
self.external_ticket_id = self.ticket_id.external_ticket_id
|
||||
|
||||
@@ -86,11 +97,15 @@ class FreshserviceTicketBottleneckWizard(models.TransientModel):
|
||||
return clean
|
||||
|
||||
def _effective_responsibility(self, log):
|
||||
"""Return the reviewed classification when present, otherwise the automatic one."""
|
||||
"""Ambil tanggung jawab yang berlaku: pakai override kalau sudah di-review,
|
||||
kalau belum pakai hasil deteksi otomatis.
|
||||
"""
|
||||
return log.override_responsibility or log.responsibility
|
||||
|
||||
def _clipped_stage_interval(self, log, report_as_of):
|
||||
"""Return the reportable (start, end) interval, clipped at the report cut-off."""
|
||||
"""Potong interval stage log sesuai cut-off laporan.
|
||||
Kalau ada as_of, durasi dihitung hanya sampai tanggal cut-off itu.
|
||||
"""
|
||||
start = log.start_date
|
||||
if not start:
|
||||
return False, False
|
||||
@@ -132,6 +147,11 @@ class FreshserviceTicketBottleneckWizard(models.TransientModel):
|
||||
target_ticket.action_sync_from_freshservice()
|
||||
except Exception as e:
|
||||
raise UserError(_("Gagal mengambil data tiket #%s dari Freshservice: %s") % (clean_ext_id, str(e)))
|
||||
else :
|
||||
try:
|
||||
target_ticket.action_sync_from_freshservice()
|
||||
except Exception as e:
|
||||
raise UserError(_("Gagal mengambil data tiket #%s dari Freshservice: %s") % (clean_ext_id, str(e)))
|
||||
|
||||
if not target_ticket:
|
||||
raise UserError(_("Silakan pilih tiket atau masukkan Nomor ID Freshservice (contoh: 992)."))
|
||||
|
||||
Reference in New Issue
Block a user