Migración a Python/Flask y adaptación para proyecto Infra
- Reemplaza Netlify Functions con servidor Python/Flask (server.py) - Adapta dashboard para contar tickets en lugar de Story Points - Conecta al proyecto IO (Infra) de Jira - Elimina secciones de QA y Code Review - Agrega métricas de solicitudes semanales y reporters - Excluye devs inactivos y unifica nombres duplicados Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,494 @@
|
||||
"""
|
||||
server.py — Servidor Flask para Dashboard LABS/Infra — Story Points
|
||||
Reemplaza las Netlify Functions. Sirve el frontend y el endpoint /api/data
|
||||
"""
|
||||
|
||||
from flask import Flask, request, jsonify, send_from_directory
|
||||
import requests
|
||||
import base64
|
||||
import os
|
||||
import time
|
||||
import math
|
||||
from datetime import datetime, timezone
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# Cargar variables de entorno desde .env
|
||||
load_dotenv()
|
||||
|
||||
app = Flask(__name__, static_folder='.')
|
||||
|
||||
# ─── Configuración ─────────────────────────────────────────────────────────
|
||||
USE_TICKET_COUNT = os.getenv('USE_TICKET_COUNT', 'false').lower() == 'true'
|
||||
|
||||
config = {
|
||||
'jira': {
|
||||
'host': os.getenv('JIRA_HOST', 'invera.atlassian.net'),
|
||||
'project': os.getenv('JIRA_PROJECT', 'IO'),
|
||||
'email': os.getenv('JIRA_EMAIL'),
|
||||
'token': os.getenv('JIRA_TOKEN'),
|
||||
},
|
||||
'fields': {
|
||||
'spEstimate': os.getenv('JIRA_FIELD_SP_ESTIMATE', 'customfield_10016'),
|
||||
'spFinal': os.getenv('JIRA_FIELD_SP_FINAL', 'customfield_11019'),
|
||||
'developer': os.getenv('JIRA_FIELD_DEVELOPER', 'customfield_10622'),
|
||||
'qaAssignee': 'customfield_10632',
|
||||
'qaEntryRtqa': 'customfield_10821',
|
||||
'qaTotalTimeRtqa': 'customfield_10822',
|
||||
'qaLastDurRtqa': 'customfield_10823',
|
||||
'qaEntryQa': 'customfield_10824',
|
||||
'qaTotalTimeQa': 'customfield_10825',
|
||||
'qaLastDurQa': 'customfield_10826',
|
||||
'timesReturned': 'customfield_11217',
|
||||
'spFinalReal': 'customfield_11216',
|
||||
'crEntry': 'customfield_10818',
|
||||
'crTotalTime': 'customfield_10819',
|
||||
'enCursoEntry': 'customfield_10815',
|
||||
'enCursoTotalTime':'customfield_10816',
|
||||
},
|
||||
'cacheMsec': int(os.getenv('CACHE_MS', 3600000)),
|
||||
}
|
||||
|
||||
# ─── Cache en memoria ──────────────────────────────────────────────────────
|
||||
_cache = {}
|
||||
_cache_time = {}
|
||||
|
||||
# ─── Helpers ───────────────────────────────────────────────────────────────
|
||||
def mins_to_hrs(mins):
|
||||
if not mins:
|
||||
return 0
|
||||
return round((mins / 60) * 10) / 10
|
||||
|
||||
def get_percentile(sorted_arr, p):
|
||||
if not sorted_arr:
|
||||
return 0
|
||||
if p <= 0:
|
||||
return sorted_arr[0]
|
||||
if p >= 1:
|
||||
return sorted_arr[-1]
|
||||
index = (len(sorted_arr) - 1) * p
|
||||
lower = math.floor(index)
|
||||
upper = lower + 1
|
||||
weight = index % 1
|
||||
if upper >= len(sorted_arr):
|
||||
return sorted_arr[lower]
|
||||
return sorted_arr[lower] * (1 - weight) + sorted_arr[upper] * weight
|
||||
|
||||
def build_fields_list():
|
||||
f = config['fields']
|
||||
return ','.join([
|
||||
'summary', 'status', 'assignee', 'reporter', 'resolutiondate', 'created', 'updated', 'duedate',
|
||||
f['spEstimate'], f['spFinal'], f['developer'],
|
||||
f['qaAssignee'], f['qaEntryRtqa'], f['qaTotalTimeRtqa'], f['qaLastDurRtqa'],
|
||||
f['qaEntryQa'], f['qaTotalTimeQa'], f['qaLastDurQa'],
|
||||
f['timesReturned'], f['spFinalReal'], f['crEntry'], f['crTotalTime'],
|
||||
f['enCursoEntry'], f['enCursoTotalTime'],
|
||||
])
|
||||
|
||||
# ─── Fetch de Jira ─────────────────────────────────────────────────────────
|
||||
def fetch_jira_issues(period):
|
||||
email = config['jira']['email']
|
||||
token = config['jira']['token']
|
||||
host = config['jira']['host']
|
||||
project = config['jira']['project']
|
||||
|
||||
credentials = base64.b64encode(f"{email}:{token}".encode()).decode()
|
||||
headers = {
|
||||
'Authorization': f'Basic {credentials}',
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
|
||||
max_results_wanted = 1500
|
||||
all_issues = []
|
||||
|
||||
from datetime import timedelta
|
||||
jql = f'project = {project} ORDER BY updated DESC'
|
||||
|
||||
print(f" → Email: {email}")
|
||||
print(f" → Token: {token[:20] if token else 'NO TOKEN'}...")
|
||||
print(f" → JQL: {jql}")
|
||||
|
||||
print(f"📡 Consultando JIRA: {project} (periodo: {period})")
|
||||
|
||||
fields_param = build_fields_list()
|
||||
next_page_token = None
|
||||
|
||||
while len(all_issues) < max_results_wanted:
|
||||
url = f"https://{host}/rest/api/3/search/jql?jql={requests.utils.quote(jql)}&maxResults=100&fields=summary,status,assignee,reporter,resolutiondate,created,updated,duedate"
|
||||
if next_page_token:
|
||||
url += f"&nextPageToken={requests.utils.quote(next_page_token)}"
|
||||
print(f" → URL: {url[:200]}")
|
||||
res = requests.get(url, headers=headers)
|
||||
|
||||
print(f" → HTTP {res.status_code} — respuesta: {res.text[:200]}")
|
||||
|
||||
if not res.ok:
|
||||
raise Exception(f"JIRA API {res.status_code}: {res.text[:200]}")
|
||||
|
||||
data = res.json()
|
||||
items = data.get('issues', [])
|
||||
all_issues.extend(items)
|
||||
next_page_token = data.get('nextPageToken')
|
||||
|
||||
print(f" → {len(items)} tickets en esta página | Total acumulado: {len(all_issues)}")
|
||||
|
||||
if not items or not next_page_token:
|
||||
break
|
||||
|
||||
print(f"✅ Total tickets obtenidos de Jira: {len(all_issues)}")
|
||||
return {'issues': all_issues, 'isTruncated': bool(next_page_token)}
|
||||
|
||||
# ─── Procesamiento de Issues ───────────────────────────────────────────────
|
||||
def process_issues(issues, period='90d'):
|
||||
f = config['fields']
|
||||
assignee_data = {}
|
||||
monthly_done = {}
|
||||
issues_list = []
|
||||
warnings = []
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
cutoff_date = None
|
||||
if period != 'all':
|
||||
period_days = 1 if period == '1d' else int(period.replace('d', ''))
|
||||
if period == '1d':
|
||||
cutoff_date = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
else:
|
||||
cutoff_date = datetime.fromtimestamp(time.time() - period_days * 86400, tz=timezone.utc)
|
||||
|
||||
qa_data = {
|
||||
'tickets': 0, 'ticketsReturned': 0, 'totalTimeQa': 0, 'totalTimeRtqa': 0,
|
||||
'monthlyTotalQa': {}, 'monthlyCountQa': {},
|
||||
'monthlyTotalRtqa': {}, 'monthlyCountRtqa': {},
|
||||
'issues': [], 'timesQa': [], 'timesRtqa': []
|
||||
}
|
||||
|
||||
tickets_created = 0 # Contador de tickets creados en el período
|
||||
reporter_data = {} # Tickets solicitados por persona
|
||||
|
||||
delivery_data = {
|
||||
'totalLeadTimeDays': 0, 'totalCycleTimeDays': 0,
|
||||
'doneTicketsLead': 0, 'doneTicketsCycle': 0,
|
||||
'doneSpOriginal': 0, 'doneSpFinal': 0
|
||||
}
|
||||
|
||||
cr_data = {
|
||||
'tickets': 0, 'totalTimeCr': 0, 'issues': [],
|
||||
'monthlyTotalCr': {}, 'monthlyCountCr': {},
|
||||
'stalledIssues': [], 'times': []
|
||||
}
|
||||
|
||||
current_states = {
|
||||
'codeReview': {'count': 0, 'sp': 0},
|
||||
'readyToQa': {'count': 0, 'sp': 0},
|
||||
'inQa': {'count': 0, 'sp': 0},
|
||||
}
|
||||
|
||||
INACTIVE_DEVS = ['Felipe Esposito', 'heiner', 'Ciro Bruno', 'Josefina Vera Ocampo', 'Isidro Emir Pelli']
|
||||
|
||||
# Normalizar nombres de usuario a nombre real
|
||||
NAME_MAP = {
|
||||
'facundo.devoto': 'Devoto Facundo',
|
||||
}
|
||||
|
||||
for issue in issues:
|
||||
try:
|
||||
fields = issue['fields']
|
||||
|
||||
# Devs
|
||||
devs = []
|
||||
if fields.get(f['developer']):
|
||||
devs = [d['displayName'] for d in fields[f['developer']]]
|
||||
elif fields.get('assignee'):
|
||||
devs = [fields['assignee']['displayName']]
|
||||
else:
|
||||
devs = ['Sin asignar']
|
||||
|
||||
devs = [NAME_MAP.get(d, d) for d in devs]
|
||||
devs = [d for d in devs if d not in INACTIVE_DEVS]
|
||||
if not devs:
|
||||
continue
|
||||
|
||||
if USE_TICKET_COUNT:
|
||||
sp = 1
|
||||
else:
|
||||
sp = (fields.get(f['spFinal']) if fields.get(f['spFinal']) is not None
|
||||
else fields.get(f['spEstimate'])) or 0
|
||||
|
||||
status_key = (fields.get('status') or {}).get('statusCategory', {}).get('key', 'new')
|
||||
status_name = ((fields.get('status') or {}).get('name') or '').lower()
|
||||
is_done = status_key == 'done'
|
||||
is_in_progress = status_key == 'indeterminate'
|
||||
|
||||
# Contar tickets creados dentro del período + reporter
|
||||
if fields.get('created'):
|
||||
created_dt = datetime.fromisoformat(fields['created'].replace('Z', '+00:00'))
|
||||
if cutoff_date is None or created_dt >= cutoff_date:
|
||||
tickets_created += 1
|
||||
reporter = (fields.get('reporter') or {}).get('displayName', 'Sin asignar')
|
||||
reporter_data[reporter] = reporter_data.get(reporter, 0) + 1
|
||||
|
||||
# Filtrar done tickets fuera del período
|
||||
if is_done and fields.get('resolutiondate') and cutoff_date:
|
||||
res_date = datetime.fromisoformat(fields['resolutiondate'].replace('Z', '+00:00'))
|
||||
if res_date < cutoff_date:
|
||||
continue
|
||||
|
||||
# Estado actual (colas)
|
||||
if not is_done:
|
||||
if 'code review' in status_name or status_name == 'cr':
|
||||
current_states['codeReview']['count'] += 1
|
||||
current_states['codeReview']['sp'] += sp
|
||||
elif 'ready to qa' in status_name or status_name == 'listo para qa':
|
||||
current_states['readyToQa']['count'] += 1
|
||||
current_states['readyToQa']['sp'] += sp
|
||||
elif status_name == 'qa' or 'in qa' in status_name or status_name == 'en qa':
|
||||
current_states['inQa']['count'] += 1
|
||||
current_states['inQa']['sp'] += sp
|
||||
|
||||
res_month = fields['resolutiondate'][:7] if fields.get('resolutiondate') else None
|
||||
cre_month = fields['created'][:7] if fields.get('created') else None
|
||||
|
||||
for dev in devs:
|
||||
if dev not in assignee_data:
|
||||
assignee_data[dev] = {'done': 0, 'in_progress': 0, 'todo': 0, 'pending_count': 0}
|
||||
if is_done:
|
||||
assignee_data[dev]['done'] += sp
|
||||
elif is_in_progress:
|
||||
assignee_data[dev]['in_progress'] += sp
|
||||
assignee_data[dev]['pending_count'] += 1
|
||||
else:
|
||||
assignee_data[dev]['todo'] += sp
|
||||
assignee_data[dev]['pending_count'] += 1
|
||||
|
||||
if is_done:
|
||||
month = res_month or cre_month
|
||||
if month:
|
||||
if month not in monthly_done:
|
||||
monthly_done[month] = {}
|
||||
for dev in devs:
|
||||
monthly_done[month][dev] = monthly_done[month].get(dev, 0) + sp
|
||||
|
||||
# Delivery metrics
|
||||
created_date = datetime.fromisoformat(fields['created'].replace('Z', '+00:00')) if fields.get('created') else None
|
||||
resolved_date = datetime.fromisoformat(fields['resolutiondate'].replace('Z', '+00:00')) if fields.get('resolutiondate') else None
|
||||
en_curso_date = datetime.fromisoformat(fields[f['enCursoEntry']].replace('Z', '+00:00')) if fields.get(f['enCursoEntry']) else None
|
||||
sp_original = fields.get(f['spEstimate']) or 0
|
||||
sp_real = fields.get(f['spFinalReal']) if fields.get(f['spFinalReal']) is not None else sp_original
|
||||
|
||||
if resolved_date:
|
||||
if created_date:
|
||||
delivery_data['totalLeadTimeDays'] += (resolved_date - created_date).total_seconds() / 86400
|
||||
delivery_data['doneTicketsLead'] += 1
|
||||
if en_curso_date:
|
||||
delivery_data['totalCycleTimeDays'] += (resolved_date - en_curso_date).total_seconds() / 86400
|
||||
delivery_data['doneTicketsCycle'] += 1
|
||||
delivery_data['doneSpOriginal'] += sp_original
|
||||
delivery_data['doneSpFinal'] += sp_real
|
||||
|
||||
# Warnings
|
||||
issue_warnings = []
|
||||
|
||||
if is_in_progress and fields.get('updated'):
|
||||
updated = datetime.fromisoformat(fields['updated'].replace('Z', '+00:00'))
|
||||
days = (now - updated).total_seconds() / 86400
|
||||
if days > 14:
|
||||
w = {'type': 'stalled', 'key': issue['key'], 'summary': fields['summary'], 'devs': devs, 'sp': sp, 'days': round(days)}
|
||||
warnings.append(w)
|
||||
issue_warnings.append(w)
|
||||
|
||||
if not is_done and fields.get('duedate'):
|
||||
due_date = datetime.fromisoformat(fields['duedate'] + 'T00:00:00+00:00')
|
||||
if due_date < now:
|
||||
days_overdue = (now - due_date).days
|
||||
w = {'type': 'expired', 'key': issue['key'], 'summary': fields['summary'], 'devs': devs, 'sp': sp, 'days': days_overdue}
|
||||
warnings.append(w)
|
||||
issue_warnings.append(w)
|
||||
|
||||
if is_done and sp == 0:
|
||||
w = {'type': 'done_no_sp', 'key': issue['key'], 'summary': fields['summary'], 'devs': devs, 'sp': 0}
|
||||
warnings.append(w)
|
||||
issue_warnings.append(w)
|
||||
|
||||
if not is_done and 'Sin asignar' in devs and sp > 0:
|
||||
w = {'type': 'unassigned_sp', 'key': issue['key'], 'summary': fields['summary'], 'devs': devs, 'sp': sp}
|
||||
warnings.append(w)
|
||||
issue_warnings.append(w)
|
||||
|
||||
issues_list.append({
|
||||
'id': issue['id'], 'key': issue['key'],
|
||||
'summary': fields['summary'], 'status': status_key,
|
||||
'sp': sp, 'devs': devs, 'creMonth': cre_month,
|
||||
'warnings': issue_warnings
|
||||
})
|
||||
|
||||
# QA & CR
|
||||
time_qa_min = fields.get(f['qaTotalTimeQa']) or 0
|
||||
time_rtqa_min = fields.get(f['qaTotalTimeRtqa']) or 0
|
||||
time_cr_min = fields.get(f['crTotalTime']) or 0
|
||||
time_dev_min = fields.get(f['enCursoTotalTime']) or 0
|
||||
times_ret = fields.get(f['timesReturned']) or 0
|
||||
qa_month = res_month or cre_month
|
||||
|
||||
# Fix datos sucios anteriores al 7-Apr-2026
|
||||
automation_fix_date = datetime(2026, 4, 7, tzinfo=timezone.utc)
|
||||
issue_created_dt = datetime.fromisoformat(fields['created'].replace('Z', '+00:00')) if fields.get('created') else None
|
||||
if issue_created_dt and issue_created_dt < automation_fix_date:
|
||||
times_ret = 1
|
||||
|
||||
if time_qa_min > 0:
|
||||
qa_data['tickets'] += 1
|
||||
qa_data['totalTimeQa'] += time_qa_min
|
||||
qa_data['timesQa'].append(time_qa_min)
|
||||
if times_ret > 1:
|
||||
qa_data['ticketsReturned'] += 1
|
||||
if qa_month:
|
||||
qa_data['monthlyTotalQa'][qa_month] = qa_data['monthlyTotalQa'].get(qa_month, 0) + time_qa_min
|
||||
qa_data['monthlyCountQa'][qa_month] = qa_data['monthlyCountQa'].get(qa_month, 0) + 1
|
||||
qa_data['issues'].append({
|
||||
'key': issue['key'], 'summary': fields['summary'],
|
||||
'timeQa': mins_to_hrs(time_qa_min), 'timeRtqa': mins_to_hrs(time_rtqa_min),
|
||||
'timesReturned': times_ret, 'status': status_key, 'resMonth': qa_month,
|
||||
'timeCr': mins_to_hrs(time_cr_min), 'timeDev': mins_to_hrs(time_dev_min)
|
||||
})
|
||||
|
||||
if time_rtqa_min > 0:
|
||||
qa_data['timesRtqa'].append(time_rtqa_min)
|
||||
if qa_month:
|
||||
qa_data['totalTimeRtqa'] += time_rtqa_min
|
||||
qa_data['monthlyTotalRtqa'][qa_month] = qa_data['monthlyTotalRtqa'].get(qa_month, 0) + time_rtqa_min
|
||||
qa_data['monthlyCountRtqa'][qa_month] = qa_data['monthlyCountRtqa'].get(qa_month, 0) + 1
|
||||
|
||||
if time_cr_min > 0:
|
||||
cr_data['tickets'] += 1
|
||||
cr_data['totalTimeCr'] += time_cr_min
|
||||
cr_data['times'].append(time_cr_min)
|
||||
if qa_month:
|
||||
cr_data['monthlyTotalCr'][qa_month] = cr_data['monthlyTotalCr'].get(qa_month, 0) + time_cr_min
|
||||
cr_data['monthlyCountCr'][qa_month] = cr_data['monthlyCountCr'].get(qa_month, 0) + 1
|
||||
cr_data['issues'].append({'key': issue['key'], 'summary': fields['summary'], 'timeCr': mins_to_hrs(time_cr_min), 'status': status_key})
|
||||
|
||||
cr_entry_date = fields.get(f['crEntry'])
|
||||
if cr_entry_date and not is_done and not fields.get(f['qaEntryRtqa']):
|
||||
cr_entry_dt = datetime.fromisoformat(cr_entry_date.replace('Z', '+00:00'))
|
||||
days_in_cr = (now - cr_entry_dt).total_seconds() / 86400
|
||||
if days_in_cr > 2:
|
||||
cr_data['stalledIssues'].append({'key': issue['key'], 'summary': fields['summary'], 'days': round(days_in_cr * 10) / 10})
|
||||
|
||||
except Exception as e:
|
||||
print(f"⚠️ Error procesando {issue.get('key', '?')}: {e}")
|
||||
|
||||
# Alertas de devs libres
|
||||
EXCLUDED_FROM_ALERTS = ['Sin asignar', 'Juan Manuel Villablanca']
|
||||
for dev, ad in assignee_data.items():
|
||||
if dev not in EXCLUDED_FROM_ALERTS and ad['pending_count'] == 0:
|
||||
warnings.append({'type': 'free_dev', 'summary': f'{dev} no tiene tickets pendientes ni en curso', 'devs': [dev]})
|
||||
|
||||
# Promedios mensuales QA/RTQA/CR
|
||||
monthly_avg_qa = {m: mins_to_hrs(qa_data['monthlyTotalQa'][m] / qa_data['monthlyCountQa'][m]) for m in qa_data['monthlyTotalQa']}
|
||||
monthly_avg_rtqa = {m: mins_to_hrs(qa_data['monthlyTotalRtqa'][m] / qa_data['monthlyCountRtqa'][m]) for m in qa_data['monthlyTotalRtqa']}
|
||||
monthly_avg_cr = {m: mins_to_hrs(cr_data['monthlyTotalCr'][m] / cr_data['monthlyCountCr'][m]) for m in cr_data['monthlyTotalCr']}
|
||||
|
||||
# Percentiles
|
||||
cr_sorted = sorted(cr_data['times'])
|
||||
qa_sorted = sorted(qa_data['timesQa'])
|
||||
rtqa_sorted = sorted(qa_data['timesRtqa'])
|
||||
|
||||
avg_lead = round(delivery_data['totalLeadTimeDays'] / delivery_data['doneTicketsLead']) if delivery_data['doneTicketsLead'] else 0
|
||||
avg_cycle = round(delivery_data['totalCycleTimeDays'] / delivery_data['doneTicketsCycle']) if delivery_data['doneTicketsCycle'] else 0
|
||||
sp_orig = delivery_data['doneSpOriginal']
|
||||
sp_fin = delivery_data['doneSpFinal']
|
||||
sp_dev_pct = round((sp_fin - sp_orig) / sp_orig * 100, 1) if sp_orig else 0
|
||||
|
||||
return {
|
||||
'lastUpdated': datetime.now(timezone.utc).isoformat(),
|
||||
'assigneeData': assignee_data,
|
||||
'monthlyDone': monthly_done,
|
||||
'issuesList': issues_list,
|
||||
'warnings': warnings,
|
||||
'currentStates': current_states,
|
||||
'ticketsCreated': tickets_created,
|
||||
'reporterData': reporter_data,
|
||||
'deliveryData': {
|
||||
'avgLeadTime': avg_lead,
|
||||
'avgCycleTime': avg_cycle,
|
||||
'spOriginal': sp_orig,
|
||||
'spFinal': sp_fin,
|
||||
'spDeviationPct': sp_dev_pct,
|
||||
},
|
||||
'crData': {
|
||||
**cr_data,
|
||||
'monthlyAvgCr': monthly_avg_cr,
|
||||
'p50Cr': mins_to_hrs(get_percentile(cr_sorted, 0.5)),
|
||||
'p90Cr': mins_to_hrs(get_percentile(cr_sorted, 0.9)),
|
||||
'p99Cr': mins_to_hrs(get_percentile(cr_sorted, 0.99)),
|
||||
'avgTimeCr': mins_to_hrs(cr_data['totalTimeCr'] / cr_data['tickets']) if cr_data['tickets'] else 0,
|
||||
},
|
||||
'qaData': {
|
||||
**qa_data,
|
||||
'monthlyAvgQa': monthly_avg_qa,
|
||||
'monthlyAvgRtqa': monthly_avg_rtqa,
|
||||
'p50Qa': mins_to_hrs(get_percentile(qa_sorted, 0.5)),
|
||||
'p90Qa': mins_to_hrs(get_percentile(qa_sorted, 0.9)),
|
||||
'p99Qa': mins_to_hrs(get_percentile(qa_sorted, 0.99)),
|
||||
'p50Rtqa': mins_to_hrs(get_percentile(rtqa_sorted, 0.5)),
|
||||
'p90Rtqa': mins_to_hrs(get_percentile(rtqa_sorted, 0.9)),
|
||||
'p99Rtqa': mins_to_hrs(get_percentile(rtqa_sorted, 0.99)),
|
||||
'avgTimeQa': mins_to_hrs(qa_data['totalTimeQa'] / qa_data['tickets']) if qa_data['tickets'] else 0,
|
||||
'avgTimeRtqa': mins_to_hrs(qa_data['totalTimeRtqa'] / qa_data['tickets']) if qa_data['tickets'] else 0,
|
||||
},
|
||||
'config': {'host': config['jira']['host'], 'useTicketCount': USE_TICKET_COUNT},
|
||||
}
|
||||
|
||||
# ─── Rutas Flask ───────────────────────────────────────────────────────────
|
||||
|
||||
@app.route('/api/data')
|
||||
def api_data():
|
||||
try:
|
||||
period = request.args.get('period', '90d')
|
||||
force_refresh = request.args.get('refresh') == '1'
|
||||
|
||||
cache_valid = (
|
||||
not force_refresh
|
||||
and period in _cache
|
||||
and (time.time() * 1000 - _cache_time.get(period, 0)) < config['cacheMsec']
|
||||
)
|
||||
|
||||
if cache_valid:
|
||||
return jsonify({**_cache[period], 'fromCache': True})
|
||||
|
||||
if not config['jira']['email'] or not config['jira']['token']:
|
||||
return jsonify({'error': 'Faltan credenciales JIRA en el archivo .env'}), 500
|
||||
|
||||
jira_result = fetch_jira_issues(period)
|
||||
data = process_issues(jira_result['issues'], period)
|
||||
|
||||
data['isTruncated'] = jira_result['isTruncated']
|
||||
data['jiraTotal'] = '1500+' if jira_result['isTruncated'] else len(jira_result['issues'])
|
||||
data['period'] = period
|
||||
data['fromCache'] = False
|
||||
|
||||
_cache[period] = data
|
||||
_cache_time[period] = time.time() * 1000
|
||||
|
||||
return jsonify(data)
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
# Servir archivos estáticos (CSS, JS)
|
||||
@app.route('/css/<path:filename>')
|
||||
def css(filename):
|
||||
return send_from_directory('css', filename)
|
||||
|
||||
@app.route('/js/<path:filename>')
|
||||
def js(filename):
|
||||
return send_from_directory('js', filename)
|
||||
|
||||
@app.route('/')
|
||||
def index():
|
||||
return send_from_directory('.', 'index.html')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
print("🚀 Dashboard corriendo en http://localhost:8888")
|
||||
app.run(host='0.0.0.0', port=8888, debug=True)
|
||||
Reference in New Issue
Block a user