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:
juan.beldomenico
2026-04-27 15:09:04 -03:00
parent 76edc176f9
commit 695401f237
7 changed files with 711 additions and 427 deletions
+494
View File
@@ -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)