""" 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/') def css(filename): return send_from_directory('css', filename) @app.route('/js/') 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)