695401f237
- 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>
495 lines
22 KiB
Python
495 lines
22 KiB
Python
"""
|
|
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)
|