Files
queue_analyzer/app.py
T
2026-07-27 19:22:30 +03:30

675 lines
28 KiB
Python

from flask import Flask, render_template, request, jsonify, Response
import sqlite3, pandas as pd, os, io, csv, hashlib, json, re
from datetime import datetime
from zoneinfo import ZoneInfo
from werkzeug.utils import secure_filename
app = Flask(__name__)
app.config['UPLOAD_FOLDER'] = 'uploads'
DB_PATH = 'queue_stats.db'
IRAN_TZ = ZoneInfo('Asia/Tehran')
SHORT_ABANDON = 5
os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True)
# ─────────────────────── DB ───────────────────────
def init_db():
conn = sqlite3.connect(DB_PATH)
conn.execute('''
CREATE TABLE IF NOT EXISTS queue_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp INTEGER, callid TEXT, queuename TEXT,
agent TEXT, event TEXT, data1 TEXT, data2 TEXT,
data3 TEXT, file_source TEXT, imported_at TEXT
)
''')
conn.execute('''
CREATE TABLE IF NOT EXISTS stats_cache (
cache_key TEXT PRIMARY KEY,
result TEXT,
created_at TEXT
)
''')
for idx, col in [('ts','timestamp'),('queue','queuename'),
('agent','agent'),('event','event'),('callid','callid')]:
conn.execute(f'CREATE INDEX IF NOT EXISTS idx_{idx} ON queue_events({col})')
conn.commit()
conn.close()
# ─────────────────────── Cache ───────────────────────
def get_cache(key):
conn = sqlite3.connect(DB_PATH)
row = conn.execute("SELECT result FROM stats_cache WHERE cache_key=?", (key,)).fetchone()
conn.close()
return json.loads(row[0]) if row else None
def set_cache(key, data):
conn = sqlite3.connect(DB_PATH)
conn.execute(
"INSERT OR REPLACE INTO stats_cache (cache_key, result, created_at) VALUES (?,?,?)",
(key, json.dumps(data, ensure_ascii=False), datetime.now().isoformat())
)
conn.commit()
conn.close()
def clear_cache():
conn = sqlite3.connect(DB_PATH)
conn.execute("DELETE FROM stats_cache")
conn.commit()
conn.close()
def make_cache_key(*args):
return hashlib.md5('|'.join(str(a) for a in args).encode()).hexdigest()
# ─────────────────────── Phone ───────────────────────
def normalize_phone(number):
if not number or not isinstance(number, str):
return None
n = re.sub(r'\D', '', number.strip())
if not n or len(n) < 7:
return None
if n in ('0', '00'):
return None
if n.startswith('0098'):
n = n[4:]
elif n.startswith('98') and len(n) == 12:
n = n[2:]
elif n.startswith('0') and len(n) == 11:
n = n[1:]
return n if len(n) >= 7 else None
# ─────────────────────── Parse / Import ───────────────────────
def parse_line(line):
parts = line.strip().split('|')
if len(parts) < 5:
return None
try:
return {
'timestamp': int(parts[0]),
'callid' : parts[1],
'queuename': parts[2],
'agent' : parts[3],
'event' : parts[4],
'data1' : parts[5] if len(parts) > 5 else None,
'data2' : parts[6] if len(parts) > 6 else None,
'data3' : parts[7] if len(parts) > 7 else None,
}
except:
return None
def process_file(filepath, filename):
conn = sqlite3.connect(DB_PATH)
existing = conn.execute(
"SELECT COUNT(*) FROM queue_events WHERE file_source=?", (filename,)
).fetchone()[0]
if existing > 0:
conn.close()
return -1
rows = []
with open(filepath, 'r', errors='ignore') as f:
for line in f:
r = parse_line(line)
if r:
rows.append((
r['timestamp'], r['callid'], r['queuename'],
r['agent'], r['event'], r['data1'],
r['data2'], r['data3'], filename,
datetime.now().isoformat()
))
conn.executemany('''
INSERT INTO queue_events
(timestamp,callid,queuename,agent,event,data1,data2,data3,file_source,imported_at)
VALUES (?,?,?,?,?,?,?,?,?,?)
''', rows)
conn.commit()
conn.close()
return len(rows)
# ─────────────────────── Build Calls ───────────────────────
def ts_to_dt(t):
try:
return datetime.fromtimestamp(int(t), tz=IRAN_TZ)
except:
return None
def build_calls(df):
enter = df[df['event'] == 'ENTERQUEUE'].groupby('callid').first()
# ✅ آخرین CONNECT (در ringall چندتا داریم، آخری که جواب داده مهمه)
connect = df[df['event'] == 'CONNECT'].groupby('callid').last()
# ✅ COMPLETE = منبع اصلی — answered فقط با COMPLETE تعیین میشه
comp = df[df['event'].isin(['COMPLETEAGENT', 'COMPLETECALLER'])]\
.groupby('callid').first()
abandon = df[df['event'] == 'ABANDON'].groupby('callid').first()
timeout = df[df['event'] == 'EXITWITHTIMEOUT'].groupby('callid').first()
rna_cnt = df[df['event'] == 'RINGNOANSWER'].groupby('callid').size().rename('rna_count')
all_ids = pd.DataFrame(index=df['callid'].unique())
# ✅ outcome: answered فقط اگه COMPLETEAGENT یا COMPLETECALLER داشته باشه
# نه صرفاً CONNECT — چون در ringall چند CONNECT داریم
all_ids['outcome'] = 'other'
all_ids.loc[all_ids.index.isin(abandon.index), 'outcome'] = 'abandoned'
all_ids.loc[all_ids.index.isin(timeout.index), 'outcome'] = 'timeout'
all_ids.loc[all_ids.index.isin(comp.index), 'outcome'] = 'answered'
# زمان‌ها: اول از COMPLETE (دقیق‌ترین)، بعد از آخرین CONNECT
all_ids['wait_time'] = pd.to_numeric(comp['data1'], errors='coerce').reindex(all_ids.index)
all_ids['talk_time'] = pd.to_numeric(comp['data2'], errors='coerce').reindex(all_ids.index)
all_ids['position'] = pd.to_numeric(comp['data3'], errors='coerce').reindex(all_ids.index)
connect_wait = pd.to_numeric(connect['data1'], errors='coerce').reindex(all_ids.index)
all_ids['wait_time'] = all_ids['wait_time'].fillna(connect_wait)
enter_pos = pd.to_numeric(enter['data3'], errors='coerce').reindex(all_ids.index)
all_ids['position'] = all_ids['position'].fillna(enter_pos)
all_ids['abandon_wait'] = pd.to_numeric(abandon['data3'], errors='coerce').reindex(all_ids.index)
all_ids['timeout_wait'] = pd.to_numeric(timeout['data3'], errors='coerce').reindex(all_ids.index)
all_ids['is_short_abandon'] = (
(all_ids['outcome'] == 'abandoned') &
(all_ids['abandon_wait'].notna()) &
(all_ids['abandon_wait'] < SHORT_ABANDON)
)
all_ids['aht'] = all_ids['wait_time'] + all_ids['talk_time']
# agent: اول از COMPLETE، بعد از آخرین CONNECT
comp_agent = comp['agent'].reindex(all_ids.index)
connect_agent = connect['agent'].reindex(all_ids.index)
all_ids['agent'] = comp_agent.where(
comp_agent.notna() & ~comp_agent.isin(['NONE', 'none', '']),
connect_agent
)
all_ids['agent'] = all_ids['agent'].where(
all_ids['agent'].notna() & ~all_ids['agent'].isin(['NONE', 'none', '']),
None
)
raw_caller = enter['data2'].reindex(all_ids.index)
raw_caller = raw_caller.where(
raw_caller.notna() & ~raw_caller.isin(['NONE', 'none', '', '<unknown>', 'anonymous']),
None
)
all_ids['caller_id'] = raw_caller.apply(normalize_phone)
all_ids['queue'] = enter['queuename'].reindex(all_ids.index).fillna(
df.groupby('callid')['queuename'].first()
)
ts = df.groupby('callid')['timestamp'].min()
all_ids['timestamp'] = ts.reindex(all_ids.index)
def apply_dt(t, attr):
dt = ts_to_dt(t)
if dt is None:
return None
return {'hour': dt.hour, 'time': dt.strftime('%H:%M'), 'date': dt.strftime('%Y-%m-%d')}[attr]
all_ids['hour'] = all_ids['timestamp'].apply(lambda t: apply_dt(t, 'hour'))
all_ids['time'] = all_ids['timestamp'].apply(lambda t: apply_dt(t, 'time'))
all_ids['date'] = all_ids['timestamp'].apply(lambda t: apply_dt(t, 'date'))
comp_event = df[df['event'].isin(['COMPLETEAGENT', 'COMPLETECALLER'])]\
.groupby('callid')['event'].first()
all_ids['complete_type'] = comp_event.map(
{'COMPLETEAGENT': 'agent', 'COMPLETECALLER': 'caller'}
).reindex(all_ids.index)
all_ids['rna_count'] = rna_cnt.reindex(all_ids.index).fillna(0).astype(int)
all_ids.index.name = 'callid'
return all_ids.reset_index()
# ─────────────────────── Shared calls cache ───────────────────────
def get_calls_cached(queue, agent_filter, file_filter):
cache_key = make_cache_key('calls_df', queue, agent_filter, file_filter)
cached = get_cache(cache_key)
if cached is not None:
if not cached:
return pd.DataFrame()
df = pd.DataFrame(cached)
df['is_short_abandon'] = df['is_short_abandon'].astype(bool)
for col in ['wait_time', 'talk_time', 'aht', 'abandon_wait',
'timeout_wait', 'position', 'hour', 'rna_count']:
if col in df.columns:
df[col] = pd.to_numeric(df[col], errors='coerce')
return df
raw = _build_stats_df(queue, file_filter)
if raw.empty:
set_cache(cache_key, [])
return pd.DataFrame()
calls = build_calls(raw)
if calls.empty:
set_cache(cache_key, [])
return pd.DataFrame()
if agent_filter:
agent_callids = calls[
calls['agent'].notna() &
calls['agent'].str.contains(agent_filter, case=False, na=False)
]['callid']
calls = calls[calls['callid'].isin(agent_callids)]
calls_save = calls.copy()
calls_save['timestamp'] = calls_save['timestamp'].astype(str)
calls_save['is_short_abandon'] = calls_save['is_short_abandon'].astype(bool)
set_cache(cache_key, calls_save.where(pd.notnull(calls_save), None).to_dict('records'))
return calls
# ─────────────────────── Stats ───────────────────────
def _build_stats_df(queue='', file_filter=''):
conn = sqlite3.connect(DB_PATH)
q = "SELECT * FROM queue_events WHERE 1=1"
params = []
if queue:
q += " AND queuename=?"
params.append(queue)
if file_filter:
q += " AND file_source=?"
params.append(file_filter)
df = pd.read_sql_query(q, conn, params=params)
conn.close()
return df
def _calculate_stats(calls):
answered = calls[calls['outcome'] == 'answered']
abandoned_all = calls[calls['outcome'] == 'abandoned']
short_abandons= abandoned_all[abandoned_all['is_short_abandon'] == True]
real_abandons = abandoned_all[abandoned_all['is_short_abandon'] == False]
timeouts = calls[calls['outcome'] == 'timeout']
total_calls = len(calls)
total_ans = len(answered)
total_abn_real = len(real_abandons)
total_abn_short = len(short_abandons)
total_to = len(timeouts)
wait_times = answered['wait_time'].dropna()
abn_waits = real_abandons['abandon_wait'].dropna()
to_waits = timeouts['timeout_wait'].dropna()
talk_times = answered['talk_time'].dropna()
aht_values = answered['aht'].dropna()
effective_total = total_calls - total_abn_short if total_calls else 0
answer_rate = round(total_ans / effective_total * 100, 1) if effective_total else 0
abandon_rate = round(total_abn_real / effective_total * 100, 1) if effective_total else 0
timeout_rate = round(total_to / effective_total * 100, 1) if effective_total else 0
pos_answered = answered['position'].dropna()
pos_abandoned = real_abandons['position'].dropna()
avg_pos_ans = round(float(pos_answered.mean()), 1) if len(pos_answered) > 0 else 0
avg_pos_abn = round(float(pos_abandoned.mean()), 1) if len(pos_abandoned) > 0 else 0
avg_to_wait = round(float(to_waits.mean()), 1) if len(to_waits) > 0 else 0
med_to_wait = round(float(to_waits.median()), 1) if len(to_waits) > 0 else 0
timeout_hourly = [
{'hour': h, 'count': int(len(timeouts[timeouts['hour'] == h]))}
for h in range(24)
]
fcr_data = calls[calls['caller_id'].notna() & (calls['caller_id'] != '')]
caller_counts = fcr_data.groupby('caller_id')['callid'].nunique() if not fcr_data.empty else pd.Series(dtype=int)
unique_callers = int(len(caller_counts))
repeat_callers = int((caller_counts > 1).sum()) if unique_callers > 0 else 0
repeat_rate = round(repeat_callers / unique_callers * 100, 1) if unique_callers > 0 else 0
repeat_calls = int(caller_counts[caller_counts > 1].sum()) - repeat_callers if unique_callers > 0 else 0
repeat_calls_rate = round(repeat_calls / total_calls * 100, 1) if total_calls > 0 else 0
hourly = []
for h in range(24):
hc = calls[calls['hour'] == h]
ha = hc[hc['outcome'] == 'answered']
habn = hc[(hc['outcome'] == 'abandoned') & (hc['is_short_abandon'] == False)]
hto = hc[hc['outcome'] == 'timeout']
hw = ha['wait_time'].dropna()
eff_h= len(hc) - int(hc['is_short_abandon'].sum())
hourly.append({
'hour' : h,
'total' : int(len(hc)),
'answered' : int(len(ha)),
'abandoned' : int(len(habn)),
'timeout' : int(len(hto)),
'abandon_rate': round(len(habn) / eff_h * 100, 1) if eff_h > 0 else 0,
'avg_wait' : round(float(hw.mean()), 1) if len(hw) > 0 else 0,
'avg_aht' : round(float(ha['aht'].dropna().mean()), 1) if len(ha) > 0 else 0,
})
team_avg_aht = round(float(aht_values.mean()), 1) if len(aht_values) > 0 else 0
team_med_wait = round(float(wait_times.median()), 1) if len(wait_times) > 0 else 0
agent_stats = []
agent_calls = answered[answered['agent'].notna() & (answered['agent'] != '')]
for ag, ag_calls in agent_calls.groupby('agent'):
ag_waits = ag_calls['wait_time'].dropna()
ag_talks = ag_calls['talk_time'].dropna()
ag_aht = ag_calls['aht'].dropna()
ag_ans = len(ag_calls)
share_of_team = round(ag_ans / total_ans * 100, 1) if total_ans > 0 else 0
comp_agent_cnt = int((ag_calls['complete_type'] == 'agent').sum())
comp_caller_cnt = int((ag_calls['complete_type'] == 'caller').sum())
ag_callers = ag_calls[ag_calls['caller_id'].notna()]
ag_caller_ids = set(ag_callers['caller_id'].values)
ag_repeat = sum(1 for cid in ag_caller_ids
if caller_counts.get(cid, 1) > 1) if unique_callers > 0 else 0
ag_repeat_rate= round(ag_repeat / len(ag_caller_ids) * 100, 1) if len(ag_caller_ids) > 0 else 0
ag_pos = ag_calls['position'].dropna()
avg_ag_pos = round(float(ag_pos.mean()), 1) if len(ag_pos) > 0 else 0
aht_vs_team = round(
(float(ag_aht.mean()) - team_avg_aht) / team_avg_aht * 100, 1
) if (len(ag_aht) > 0 and team_avg_aht > 0) else 0
agent_stats.append({
'agent' : ag,
'answered' : ag_ans,
'share_of_team' : share_of_team,
'complete_agent' : comp_agent_cnt,
'complete_caller': comp_caller_cnt,
'avg_wait' : round(float(ag_waits.mean()), 1) if len(ag_waits) > 0 else 0,
'median_wait' : round(float(ag_waits.median()), 1) if len(ag_waits) > 0 else 0,
'avg_talk' : round(float(ag_talks.mean()), 1) if len(ag_talks) > 0 else 0,
'median_talk' : round(float(ag_talks.median()), 1) if len(ag_talks) > 0 else 0,
'avg_aht' : round(float(ag_aht.mean()), 1) if len(ag_aht) > 0 else 0,
'aht_vs_team' : aht_vs_team,
'repeat_rate' : ag_repeat_rate,
'avg_position' : avg_ag_pos,
})
agent_stats.sort(key=lambda x: x['answered'], reverse=True)
histogram = {
'0-20s' : int((wait_times < 20).sum()),
'20-40s' : int(((wait_times >= 20) & (wait_times < 40)).sum()),
'40-60s' : int(((wait_times >= 40) & (wait_times < 60)).sum()),
'60-120s': int(((wait_times >= 60) & (wait_times < 120)).sum()),
'120s+' : int((wait_times >= 120).sum()),
}
return {
'total_calls' : int(total_calls),
'total_answered' : int(total_ans),
'total_abandoned' : int(total_abn_real),
'total_short_abandon': int(total_abn_short),
'total_timeout' : int(total_to),
'answer_rate' : answer_rate,
'abandon_rate' : abandon_rate,
'timeout_rate' : timeout_rate,
'avg_wait' : round(float(wait_times.mean()), 1) if len(wait_times) > 0 else 0,
'median_wait' : round(float(wait_times.median()), 1) if len(wait_times) > 0 else 0,
'avg_abandon_wait' : round(float(abn_waits.mean()), 1) if len(abn_waits) > 0 else 0,
'median_abandon_wait': round(float(abn_waits.median()), 1) if len(abn_waits) > 0 else 0,
'avg_timeout_wait' : avg_to_wait,
'median_timeout_wait': med_to_wait,
'avg_talk' : round(float(talk_times.mean()), 1) if len(talk_times) > 0 else 0,
'median_talk' : round(float(talk_times.median()), 1) if len(talk_times) > 0 else 0,
'avg_aht' : round(float(aht_values.mean()), 1) if len(aht_values) > 0 else 0,
'median_aht' : round(float(aht_values.median()), 1) if len(aht_values) > 0 else 0,
'team_avg_aht' : team_avg_aht,
'team_med_wait' : team_med_wait,
'avg_pos_answered' : avg_pos_ans,
'avg_pos_abandoned' : avg_pos_abn,
'unique_callers' : unique_callers,
'repeat_callers' : repeat_callers,
'repeat_rate' : repeat_rate,
'repeat_calls_rate' : repeat_calls_rate,
'timeout_hourly' : timeout_hourly,
'hourly' : hourly,
'histogram' : histogram,
'agents' : agent_stats,
}
# ─────────────────────── Routes ───────────────────────
@app.route('/queue/')
@app.route('/queue')
def index():
return render_template('index.html')
@app.route('/queue/upload', methods=['POST'])
def upload():
f = request.files.get('file')
if not f:
return jsonify({'error': 'فایل پیدا نشد'})
filename = secure_filename(f.filename)
filepath = os.path.join(app.config['UPLOAD_FOLDER'], filename)
f.save(filepath)
count = process_file(filepath, filename)
if count == -1:
return jsonify({'warning': True, 'message': f'فایل {filename} قبلاً وارد شده!'})
clear_cache()
return jsonify({'success': True, 'message': f'{count} رکورد از {filename} وارد شد'})
@app.route('/queue/files')
def get_files():
conn = sqlite3.connect(DB_PATH)
files = pd.read_sql_query(
"SELECT file_source, COUNT(*) as count, MIN(imported_at) as imported "
"FROM queue_events GROUP BY file_source ORDER BY imported DESC", conn
)
conn.close()
return jsonify(files.to_dict('records'))
@app.route('/queue/queues')
def get_queues():
conn = sqlite3.connect(DB_PATH)
queues = pd.read_sql_query(
"SELECT DISTINCT queuename FROM queue_events ORDER BY queuename", conn
)
conn.close()
return jsonify(queues['queuename'].tolist())
@app.route('/queue/agents')
def get_agents():
queue = request.args.get('queue', '')
conn = sqlite3.connect(DB_PATH)
q = "SELECT DISTINCT agent FROM queue_events WHERE agent NOT IN ('NONE','none','') AND agent IS NOT NULL"
params = []
if queue:
q += " AND queuename=?"
params.append(queue)
q += " ORDER BY agent"
agents = pd.read_sql_query(q, conn, params=params)
conn.close()
return jsonify(agents['agent'].tolist())
@app.route('/queue/delete_file', methods=['POST'])
def delete_file():
filename = request.json.get('filename')
if not filename:
return jsonify({'error': 'نام فایل وارد نشد'})
conn = sqlite3.connect(DB_PATH)
conn.execute("DELETE FROM queue_events WHERE file_source=?", (filename,))
conn.commit()
conn.close()
fp = os.path.join(app.config['UPLOAD_FOLDER'], filename)
if os.path.exists(fp):
os.remove(fp)
clear_cache()
return jsonify({'success': True, 'message': f'فایل {filename} حذف شد'})
@app.route('/queue/clear_cache', methods=['POST'])
def api_clear_cache():
clear_cache()
return jsonify({'success': True, 'message': 'cache پاک شد'})
@app.route('/queue/stats')
def stats():
queue = request.args.get('queue', '')
agent_filter = request.args.get('agent', '')
file_filter = request.args.get('file_filter', '')
cache_key = make_cache_key('stats', queue, agent_filter, file_filter)
cached = get_cache(cache_key)
if cached:
cached['from_cache'] = True
return jsonify(cached)
calls = get_calls_cached(queue, agent_filter, file_filter)
if calls.empty:
return jsonify({'error': 'تماسی پیدا نشد'})
result = _calculate_stats(calls)
set_cache(cache_key, result)
result['from_cache'] = False
return jsonify(result)
@app.route('/queue/call_details')
def call_details():
queue = request.args.get('queue', '')
agent_filter = request.args.get('agent', '')
file_filter = request.args.get('file_filter', '')
page = int(request.args.get('page', 1))
per_page = int(request.args.get('per_page', 50))
search = request.args.get('search', '').strip()
cache_key = make_cache_key('details', queue, agent_filter, file_filter)
cached_calls = get_cache(cache_key)
if cached_calls is None:
calls = get_calls_cached(queue, agent_filter, file_filter)
if calls.empty:
return jsonify({'error': 'تماسی پیدا نشد', 'calls': [], 'total': 0})
calls = calls.sort_values('timestamp', ascending=False)
all_calls = []
for _, row in calls.iterrows():
all_calls.append({
'callid' : str(row.get('callid', '')),
'caller_id': str(row.get('caller_id', '') or '—'),
'agent' : str(row.get('agent', '') or '—'),
'queue' : str(row.get('queue', '')),
'outcome' : str(row.get('outcome', '')),
'wait_time': round(float(row['wait_time']), 1) if pd.notna(row.get('wait_time')) else '—',
'talk_time': round(float(row['talk_time']), 1) if pd.notna(row.get('talk_time')) else '—',
'aht' : round(float(row['aht']), 1) if pd.notna(row.get('aht')) else '—',
'date' : str(row.get('date', '')),
'time' : str(row.get('time', '') or '—'),
'position' : int(row['position']) if pd.notna(row.get('position')) else '—',
})
set_cache(cache_key, all_calls)
cached_calls = all_calls
filtered = [c for c in cached_calls if search in c.get('caller_id', '')] if search else cached_calls
total = len(filtered)
start = (page - 1) * per_page
page_calls = filtered[start: start + per_page]
return jsonify({
'calls' : page_calls,
'total' : total,
'page' : page,
'per_page': per_page,
'pages' : (total + per_page - 1) // per_page,
})
@app.route('/queue/repeat_callers')
def repeat_callers():
queue = request.args.get('queue', '')
agent_filter = request.args.get('agent', '')
file_filter = request.args.get('file_filter', '')
page = int(request.args.get('page', 1))
per_page = int(request.args.get('per_page', 50))
search = request.args.get('search', '').strip()
cache_key = make_cache_key('repeats', queue, agent_filter, file_filter)
cached_list = get_cache(cache_key)
if cached_list is None:
calls = get_calls_cached(queue, agent_filter, file_filter)
if calls.empty:
return jsonify({'callers': [], 'total': 0, 'pages': 0})
calls_with_id = calls[calls['caller_id'].notna() & (calls['caller_id'] != '')]
result = []
for cid, grp in calls_with_id.groupby('caller_id'):
if len(grp) < 2:
continue
answered = grp[grp['outcome'] == 'answered']
abandoned = grp[(grp['outcome'] == 'abandoned') & (grp['is_short_abandon'] == False)]
dates = sorted(grp['date'].dropna().unique().tolist())
agents_used = [a for a in grp['agent'].dropna().unique().tolist()
if a not in ('NONE', 'none', '')]
max_daily = int(grp.groupby('date')['callid'].count().max())
result.append({
'caller_id' : str(cid),
'call_count': len(grp),
'answered' : int(len(answered)),
'abandoned' : int(len(abandoned)),
'first_call': dates[0] if dates else '—',
'last_call' : dates[-1] if dates else '—',
'days_span' : len(dates),
'agents' : ', '.join(agents_used[:3]) + (
f' +{len(agents_used)-3}' if len(agents_used) > 3 else ''),
'avg_wait' : round(float(answered['wait_time'].dropna().mean()), 1)
if len(answered) > 0 and answered['wait_time'].notna().any() else '—',
'max_daily' : max_daily,
'is_shared' : max_daily >= 5,
})
result.sort(key=lambda x: x['call_count'], reverse=True)
set_cache(cache_key, result)
cached_list = result
filtered = [r for r in cached_list if search in r['caller_id']] if search else cached_list
total = len(filtered)
start = (page - 1) * per_page
paged = filtered[start: start + per_page]
return jsonify({
'callers' : paged,
'total' : total,
'page' : page,
'per_page': per_page,
'pages' : (total + per_page - 1) // per_page,
})
@app.route('/queue/export_csv')
def export_csv():
queue = request.args.get('queue', '')
file_filter = request.args.get('file_filter', '')
calls = get_calls_cached(queue, '', file_filter)
if calls.empty:
return jsonify({'error': 'داده‌ای پیدا نشد'})
output = io.StringIO()
writer = csv.writer(output)
writer.writerow(['callid', 'queue', 'agent', 'caller_id', 'outcome',
'wait_time', 'talk_time', 'aht', 'abandon_wait', 'position', 'date', 'time'])
for _, row in calls.iterrows():
writer.writerow([
row.get('callid', ''), row.get('queue', ''), row.get('agent', ''),
row.get('caller_id', ''), row.get('outcome', ''),
row.get('wait_time', ''), row.get('talk_time', ''), row.get('aht', ''),
row.get('abandon_wait', ''), row.get('position', ''),
row.get('date', ''), row.get('time', ''),
])
output.seek(0)
return Response(
'\ufeff' + output.getvalue(),
mimetype='text/csv; charset=utf-8',
headers={'Content-Disposition': 'attachment; filename=queue_report.csv'}
)
if __name__ == '__main__':
init_db()
app.run(host='0.0.0.0', port=5000)