Añade login con Google y CRUD de proyectos/tareas

Restringe el acceso a colaboradores conocidos en Airtable, añade una
vista de tareas propias filtrable por fecha, y permite crear, editar
y borrar proyectos y tareas desde el dashboard.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Jose Manuel 2026-07-05 20:39:32 +02:00
parent 663f058c00
commit 7f0b08d3dd
4 changed files with 376 additions and 22 deletions

View File

@ -0,0 +1,15 @@
# Copia este archivo a .streamlit/secrets.toml (ya está en .gitignore) y
# rellena los valores reales de tu OAuth Client de Google Cloud Console.
#
# Pasos en Google Cloud Console:
# 1. Crea/configura la pantalla de consentimiento OAuth ("Google Auth Platform").
# 2. Crea credenciales -> OAuth client ID -> tipo "Web application".
# 3. En "Authorized redirect URIs" añade exactamente la redirect_uri de abajo
# (debe terminar en /oauth2callback). Para producción, usa el dominio real.
[auth]
redirect_uri = "http://localhost:8501/oauth2callback"
cookie_secret = "cambia-esto-por-una-cadena-aleatoria-larga"
client_id = "tu-client-id.apps.googleusercontent.com"
client_secret = "tu-client-secret"
server_metadata_url = "https://accounts.google.com/.well-known/openid-configuration"

View File

@ -2,16 +2,20 @@ from pyairtable import Api
import config import config
def _collaborator_name(value): def _collaborator(value):
if isinstance(value, dict): if isinstance(value, dict):
return value.get("name") or value.get("email") or "" return {
return "" "id": value.get("id", ""),
"name": value.get("name") or value.get("email") or "",
"email": value.get("email", ""),
}
return None
def _collaborator_names(values): def _collaborators(values):
if not values: if not values:
return [] return []
return [_collaborator_name(v) for v in values] return [c for c in (_collaborator(v) for v in values) if c]
class AirtableClient: class AirtableClient:
@ -32,14 +36,18 @@ class AirtableClient:
result = [] result = []
for r in records: for r in records:
f = r["fields"] f = r["fields"]
project_lead = _collaborator(f.get("Project lead"))
working_team = _collaborators(f.get("Working team"))
result.append({ result.append({
"id": r["id"], "id": r["id"],
"project": f.get("Project", ""), "project": f.get("Project", ""),
"tags": f.get("Tags", []), "tags": f.get("Tags", []),
"status": f.get("Status", ""), "status": f.get("Status", ""),
"client_ids": f.get("Client", []), "client_ids": f.get("Client", []),
"project_lead": _collaborator_name(f.get("Project lead")), "project_lead": project_lead,
"working_team": _collaborator_names(f.get("Working team")), "project_lead_name": project_lead["name"] if project_lead else "",
"working_team": working_team,
"working_team_names": [c["name"] for c in working_team],
"kickoff_date": f.get("Kickoff date"), "kickoff_date": f.get("Kickoff date"),
"due_date": f.get("Due date"), "due_date": f.get("Due date"),
"budget": f.get("Budget", 0), "budget": f.get("Budget", 0),
@ -59,13 +67,15 @@ class AirtableClient:
result = [] result = []
for r in records: for r in records:
f = r["fields"] f = r["fields"]
assigned_to = _collaborators(f.get("Assigned to"))
result.append({ result.append({
"id": r["id"], "id": r["id"],
"task": f.get("Task", ""), "task": f.get("Task", ""),
"project_ids": f.get("Project", []), "project_ids": f.get("Project", []),
"area_ids": f.get("Area", []), "area_ids": f.get("Area", []),
"status": f.get("Status Task", ""), "status": f.get("Status Task", ""),
"assigned_to": _collaborator_names(f.get("Assigned to")), "assigned_to": assigned_to,
"assigned_to_names": [c["name"] for c in assigned_to],
"kick_off": f.get("Kick off"), "kick_off": f.get("Kick off"),
"due_date": f.get("Due date"), "due_date": f.get("Due date"),
"days_to_complete": f.get("Days to complete"), "days_to_complete": f.get("Days to complete"),
@ -103,3 +113,40 @@ class AirtableClient:
"task_ids": f.get("Tasks", []), "task_ids": f.get("Tasks", []),
}) })
return result return result
@staticmethod
def get_known_collaborators(projects: list[dict], tasks: list[dict]) -> dict:
"""Colaboradores conocidos por haber aparecido en algún proyecto o tarea, indexados por email."""
known = {}
for p in projects:
candidates = list(p["working_team"])
if p["project_lead"]:
candidates.append(p["project_lead"])
for c in candidates:
if c["email"]:
known[c["email"]] = c
for t in tasks:
for c in t["assigned_to"]:
if c["email"]:
known[c["email"]] = c
return known
# ── Escritura ─────────────────────────────────────────────────────────── #
def create_project(self, fields: dict) -> dict:
return self.projects.create(fields, typecast=True)
def update_project(self, record_id: str, fields: dict) -> dict:
return self.projects.update(record_id, fields, typecast=True)
def delete_project(self, record_id: str) -> None:
self.projects.delete(record_id)
def create_task(self, fields: dict) -> dict:
return self.tasks.create(fields, typecast=True)
def update_task(self, record_id: str, fields: dict) -> dict:
return self.tasks.update(record_id, fields, typecast=True)
def delete_task(self, record_id: str) -> None:
self.tasks.delete(record_id)

View File

@ -14,10 +14,38 @@ st.set_page_config(
initial_sidebar_state="expanded", initial_sidebar_state="expanded",
) )
# ── Autenticación ─────────────────────────────────────────────────────────── #
try:
_auth_configured = "auth" in st.secrets
except Exception:
_auth_configured = False
if not _auth_configured:
st.title("📊 PM Dashboard")
st.error(
"Falta configurar el login. Copia `.streamlit/secrets.toml.example` a "
"`.streamlit/secrets.toml` y rellena las credenciales de tu OAuth Client de Google "
"(ver instrucciones dentro del archivo)."
)
st.stop()
if not st.user.is_logged_in:
st.title("📊 PM Dashboard")
st.button("Iniciar sesión con Google", on_click=st.login)
st.stop()
@st.cache_resource
def _get_client():
return AirtableClient()
at = _get_client()
@st.cache_data(ttl=300, show_spinner="Cargando datos de Airtable...") @st.cache_data(ttl=300, show_spinner="Cargando datos de Airtable...")
def _load_data(): def _load_data():
at = AirtableClient()
return { return {
"projects": at.get_projects(), "projects": at.get_projects(),
"tasks": at.get_tasks(), "tasks": at.get_tasks(),
@ -27,17 +55,99 @@ def _load_data():
data = _load_data() data = _load_data()
known_collaborators = AirtableClient.get_known_collaborators(data["projects"], data["tasks"])
if st.user.email not in known_collaborators:
st.error(
f"Tu email ({st.user.email}) no está registrado como colaborador en Airtable. "
"Pide a un administrador que te asigne a un proyecto o tarea primero."
)
st.button("Cerrar sesión", on_click=st.logout)
st.stop()
st.sidebar.write(f"👤 {st.user.name or st.user.email}")
st.sidebar.button("Cerrar sesión", on_click=st.logout)
client_name_by_id = {c["id"]: (c["display_name"] or c["company_name"]) for c in data["clients"]} client_name_by_id = {c["id"]: (c["display_name"] or c["company_name"]) for c in data["clients"]}
client_id_by_name = {v: k for k, v in client_name_by_id.items()}
project_name_by_id = {p["id"]: p["project"] for p in data["projects"]} project_name_by_id = {p["id"]: p["project"] for p in data["projects"]}
project_id_by_name = {v: k for k, v in project_name_by_id.items()}
area_name_by_id = {a["id"]: a["area"] for a in data["areas"]} area_name_by_id = {a["id"]: a["area"] for a in data["areas"]}
area_id_by_name = {v: k for k, v in area_name_by_id.items()}
collaborator_labels = {
f"{c['name']} <{email}>": email for email, c in sorted(known_collaborators.items())
}
NONE_OPTION = "(ninguno)"
def _collaborator_fields(emails):
return [{"email": e} for e in emails]
def _parse_date(value):
if not value:
return None
try:
return pd.to_datetime(value).date()
except (ValueError, TypeError):
return None
def _confirm_delete(key_prefix, label, on_confirm):
confirmed = st.checkbox(f"Confirmar borrado de «{label}»", key=f"{key_prefix}_confirm")
if st.button("🗑️ Borrar", key=f"{key_prefix}_delete_btn", disabled=not confirmed):
on_confirm()
st.cache_data.clear()
st.success("Borrado.")
st.rerun()
st.title("📊 PM Dashboard") st.title("📊 PM Dashboard")
tab_proyectos, tab_tareas, tab_clientes, tab_areas = st.tabs( tab_mis_tareas, tab_proyectos, tab_tareas, tab_clientes, tab_areas = st.tabs(
["📁 Proyectos", "✅ Tareas", "👥 Clientes", "🗂️ Áreas"] ["🙋 Mis tareas", "📁 Proyectos", "✅ Tareas", "👥 Clientes", "🗂️ Áreas"]
) )
# ── Mis tareas ────────────────────────────────────────────────────────────── #
with tab_mis_tareas:
df = pd.DataFrame(data["tasks"])
if df.empty:
st.info("No hay tareas.")
else:
mine = df[
df["assigned_to"].apply(lambda people: any(p["email"] == st.user.email for p in people))
].copy()
if mine.empty:
st.info("No tienes tareas asignadas.")
else:
mine["project"] = mine["project_ids"].apply(
lambda ids: ", ".join(project_name_by_id.get(i, "") for i in ids)
)
mine["area"] = mine["area_ids"].apply(
lambda ids: ", ".join(area_name_by_id.get(i, "") for i in ids)
)
mine["due_date_parsed"] = mine["due_date"].apply(_parse_date)
dated = mine[mine["due_date_parsed"].notna()]
if not dated.empty:
min_d, max_d = dated["due_date_parsed"].min(), dated["due_date_parsed"].max()
date_range = st.date_input("Rango de vencimiento (Due date)", value=(min_d, max_d))
if isinstance(date_range, tuple) and len(date_range) == 2:
start, end = date_range
mine = mine[
mine["due_date_parsed"].isna()
| ((mine["due_date_parsed"] >= start) & (mine["due_date_parsed"] <= end))
]
st.dataframe(
mine[["task", "project", "area", "status", "prioridad", "due_date", "horas_estimadas"]],
use_container_width=True,
hide_index=True,
)
st.caption(f"{len(mine)} tareas asignadas a ti")
# ── Proyectos ─────────────────────────────────────────────────────────────── # # ── Proyectos ─────────────────────────────────────────────────────────────── #
with tab_proyectos: with tab_proyectos:
@ -50,21 +160,106 @@ with tab_proyectos:
) )
col1, col2 = st.columns(2) col1, col2 = st.columns(2)
status_filter = col1.multiselect("Status", sorted(df["status"].dropna().unique())) status_options = sorted(df["status"].dropna().unique())
client_filter = col2.multiselect("Cliente", sorted(client_name_by_id.values())) status_filter = col1.multiselect("Status", status_options, key="proj_status_filter")
client_filter = col2.multiselect("Cliente", sorted(client_name_by_id.values()), key="proj_client_filter")
view = df.copy() view = df.copy()
if status_filter: if status_filter:
view = view[view["status"].isin(status_filter)] view = view[view["status"].isin(status_filter)]
if client_filter: if client_filter:
view = view[view["client"].apply(lambda c: any(cf in c for cf in client_filter))] view = view[view["client"].apply(lambda c: any(cf in c for cf in client_filter))]
view = view.reset_index(drop=True)
st.dataframe( selection = st.dataframe(
view[["project", "status", "client", "project_lead", "kickoff_date", "due_date", "budget", "internal"]], view[["project", "status", "client", "project_lead_name", "kickoff_date", "due_date", "budget", "internal"]],
use_container_width=True, use_container_width=True,
hide_index=True, hide_index=True,
on_select="rerun",
selection_mode="single-row",
key="proj_table",
) )
st.caption(f"{len(view)} de {len(df)} proyectos") st.caption(f"{len(view)} de {len(df)} proyectos — selecciona una fila para editarla o borrarla")
with st.expander(" Nuevo proyecto"):
with st.form("new_project_form", clear_on_submit=True):
name = st.text_input("Project")
status = st.selectbox("Status", options=status_options + ["(otro)"])
if status == "(otro)":
status = st.text_input("Nuevo status")
client_sel = st.multiselect("Cliente", options=sorted(client_id_by_name))
lead_sel = st.selectbox("Project lead", options=[NONE_OPTION] + sorted(collaborator_labels))
team_sel = st.multiselect("Working team", options=sorted(collaborator_labels))
kickoff = st.date_input("Kickoff date", value=None)
due = st.date_input("Due date", value=None)
budget = st.number_input("Budget", min_value=0.0, step=100.0)
internal = st.checkbox("Internal")
code = st.text_input("Code")
submitted = st.form_submit_button("Crear proyecto")
if submitted:
if not name:
st.error("El nombre del proyecto es obligatorio.")
else:
fields = {"Project": name, "Status": status, "Internal": internal, "Code": code}
if client_sel:
fields["Client"] = [client_id_by_name[c] for c in client_sel]
if lead_sel != NONE_OPTION:
fields["Project lead"] = {"email": collaborator_labels[lead_sel]}
if team_sel:
fields["Working team"] = _collaborator_fields([collaborator_labels[t] for t in team_sel])
if kickoff:
fields["Kickoff date"] = kickoff.isoformat()
if due:
fields["Due date"] = due.isoformat()
if budget:
fields["Budget"] = budget
at.create_project(fields)
st.cache_data.clear()
st.success("Proyecto creado.")
st.rerun()
selected_rows = selection.selection.rows if selection and selection.selection else []
if selected_rows:
sel = view.iloc[selected_rows[0]]
st.markdown(f"#### ✏️ Editar «{sel['project']}»")
with st.form(f"edit_project_{sel['id']}"):
name = st.text_input("Project", value=sel["project"])
status_idx = status_options.index(sel["status"]) if sel["status"] in status_options else 0
status = st.selectbox("Status", options=status_options, index=status_idx)
current_client_names = [client_name_by_id.get(i) for i in sel["client_ids"] if client_name_by_id.get(i)]
client_sel = st.multiselect("Cliente", options=sorted(client_id_by_name), default=current_client_names)
lead_email = sel["project_lead"]["email"] if sel["project_lead"] else None
lead_options = [NONE_OPTION] + sorted(collaborator_labels)
current_lead_label = next((lbl for lbl, e in collaborator_labels.items() if e == lead_email), NONE_OPTION)
lead_sel = st.selectbox("Project lead", options=lead_options, index=lead_options.index(current_lead_label))
team_emails = {c["email"] for c in sel["working_team"]}
current_team_labels = [lbl for lbl, e in collaborator_labels.items() if e in team_emails]
team_sel = st.multiselect("Working team", options=sorted(collaborator_labels), default=current_team_labels)
kickoff = st.date_input("Kickoff date", value=_parse_date(sel["kickoff_date"]))
due = st.date_input("Due date", value=_parse_date(sel["due_date"]))
budget = st.number_input("Budget", min_value=0.0, step=100.0, value=float(sel["budget"] or 0))
internal = st.checkbox("Internal", value=bool(sel["internal"]))
code = st.text_input("Code", value=sel["code"])
save = st.form_submit_button("💾 Guardar cambios")
if save:
fields = {
"Project": name,
"Status": status,
"Client": [client_id_by_name[c] for c in client_sel],
"Project lead": {"email": collaborator_labels[lead_sel]} if lead_sel != NONE_OPTION else [],
"Working team": _collaborator_fields([collaborator_labels[t] for t in team_sel]),
"Kickoff date": kickoff.isoformat() if kickoff else None,
"Due date": due.isoformat() if due else None,
"Budget": budget,
"Internal": internal,
"Code": code,
}
at.update_project(sel["id"], fields)
st.cache_data.clear()
st.success("Proyecto actualizado.")
st.rerun()
_confirm_delete(f"proj_{sel['id']}", sel["project"], lambda rid=sel["id"]: at.delete_project(rid))
# ── Tareas ────────────────────────────────────────────────────────────────── # # ── Tareas ────────────────────────────────────────────────────────────────── #
@ -79,13 +274,15 @@ with tab_tareas:
df["area"] = df["area_ids"].apply( df["area"] = df["area_ids"].apply(
lambda ids: ", ".join(area_name_by_id.get(i, "") for i in ids) lambda ids: ", ".join(area_name_by_id.get(i, "") for i in ids)
) )
df["assigned"] = df["assigned_to"].apply(lambda names: ", ".join(names)) df["assigned"] = df["assigned_to_names"].apply(lambda names: ", ".join(names))
col1, col2, col3 = st.columns(3) col1, col2, col3 = st.columns(3)
status_filter = col1.multiselect("Status", sorted(df["status"].dropna().unique())) status_options = sorted(df["status"].dropna().unique())
prioridad_filter = col2.multiselect("Prioridad", sorted(df["prioridad"].dropna().unique())) prioridad_options = sorted(df["prioridad"].dropna().unique())
status_filter = col1.multiselect("Status", status_options, key="task_status_filter")
prioridad_filter = col2.multiselect("Prioridad", prioridad_options, key="task_prioridad_filter")
assigned_filter = col3.multiselect( assigned_filter = col3.multiselect(
"Asignado a", sorted({n for names in df["assigned_to"] for n in names}) "Asignado a", sorted({n for names in df["assigned_to_names"] for n in names}), key="task_assigned_filter"
) )
view = df.copy() view = df.copy()
@ -95,13 +292,106 @@ with tab_tareas:
view = view[view["prioridad"].isin(prioridad_filter)] view = view[view["prioridad"].isin(prioridad_filter)]
if assigned_filter: if assigned_filter:
view = view[view["assigned"].apply(lambda a: any(af in a for af in assigned_filter))] view = view[view["assigned"].apply(lambda a: any(af in a for af in assigned_filter))]
view = view.reset_index(drop=True)
st.dataframe( selection = st.dataframe(
view[["task", "project", "area", "status", "prioridad", "assigned", "due_date", "horas_estimadas"]], view[["task", "project", "area", "status", "prioridad", "assigned", "due_date", "horas_estimadas"]],
use_container_width=True, use_container_width=True,
hide_index=True, hide_index=True,
on_select="rerun",
selection_mode="single-row",
key="task_table",
) )
st.caption(f"{len(view)} de {len(df)} tareas") st.caption(f"{len(view)} de {len(df)} tareas — selecciona una fila para editarla o borrarla")
with st.expander(" Nueva tarea"):
with st.form("new_task_form", clear_on_submit=True):
task_name = st.text_input("Task")
project_sel = st.multiselect("Project", options=sorted(project_id_by_name))
area_sel = st.multiselect("Area", options=sorted(area_id_by_name))
status = st.selectbox("Status Task", options=status_options + ["(otro)"])
if status == "(otro)":
status = st.text_input("Nuevo status")
prioridad = st.selectbox("Prioridad", options=prioridad_options + ["(otro)"])
if prioridad == "(otro)":
prioridad = st.text_input("Nueva prioridad")
assigned_sel = st.multiselect("Assigned to", options=sorted(collaborator_labels))
kick_off = st.date_input("Kick off", value=None)
due = st.date_input("Due date", value=None)
horas = st.number_input("Horas estimadas", min_value=0.0, step=1.0)
interno_externo = st.text_input("INTERNO/EXTERNO")
recurrencia = st.text_input("Recurrencia")
submitted = st.form_submit_button("Crear tarea")
if submitted:
if not task_name:
st.error("El nombre de la tarea es obligatorio.")
else:
fields = {
"Task": task_name,
"Status Task": status,
"Prioridad": prioridad,
"Horas estimadas": horas,
"INTERNO/EXTERNO": interno_externo,
"Recurrencia": recurrencia,
}
if project_sel:
fields["Project"] = [project_id_by_name[p] for p in project_sel]
if area_sel:
fields["Area"] = [area_id_by_name[a] for a in area_sel]
if assigned_sel:
fields["Assigned to"] = _collaborator_fields([collaborator_labels[a] for a in assigned_sel])
if kick_off:
fields["Kick off"] = kick_off.isoformat()
if due:
fields["Due date"] = due.isoformat()
at.create_task(fields)
st.cache_data.clear()
st.success("Tarea creada.")
st.rerun()
selected_rows = selection.selection.rows if selection and selection.selection else []
if selected_rows:
sel = view.iloc[selected_rows[0]]
st.markdown(f"#### ✏️ Editar «{sel['task']}»")
with st.form(f"edit_task_{sel['id']}"):
task_name = st.text_input("Task", value=sel["task"])
current_project_names = [project_name_by_id.get(i) for i in sel["project_ids"] if project_name_by_id.get(i)]
project_sel = st.multiselect("Project", options=sorted(project_id_by_name), default=current_project_names)
current_area_names = [area_name_by_id.get(i) for i in sel["area_ids"] if area_name_by_id.get(i)]
area_sel = st.multiselect("Area", options=sorted(area_id_by_name), default=current_area_names)
status_idx = status_options.index(sel["status"]) if sel["status"] in status_options else 0
status = st.selectbox("Status Task", options=status_options, index=status_idx)
prioridad_idx = prioridad_options.index(sel["prioridad"]) if sel["prioridad"] in prioridad_options else 0
prioridad = st.selectbox("Prioridad", options=prioridad_options, index=prioridad_idx)
assigned_emails = {c["email"] for c in sel["assigned_to"]}
current_assigned_labels = [lbl for lbl, e in collaborator_labels.items() if e in assigned_emails]
assigned_sel = st.multiselect("Assigned to", options=sorted(collaborator_labels), default=current_assigned_labels)
kick_off = st.date_input("Kick off", value=_parse_date(sel["kick_off"]))
due = st.date_input("Due date", value=_parse_date(sel["due_date"]))
horas = st.number_input("Horas estimadas", min_value=0.0, step=1.0, value=float(sel["horas_estimadas"] or 0))
interno_externo = st.text_input("INTERNO/EXTERNO", value=sel["interno_externo"])
recurrencia = st.text_input("Recurrencia", value=sel["recurrencia"])
save = st.form_submit_button("💾 Guardar cambios")
if save:
fields = {
"Task": task_name,
"Project": [project_id_by_name[p] for p in project_sel],
"Area": [area_id_by_name[a] for a in area_sel],
"Status Task": status,
"Prioridad": prioridad,
"Assigned to": _collaborator_fields([collaborator_labels[a] for a in assigned_sel]),
"Kick off": kick_off.isoformat() if kick_off else None,
"Due date": due.isoformat() if due else None,
"Horas estimadas": horas,
"INTERNO/EXTERNO": interno_externo,
"Recurrencia": recurrencia,
}
at.update_task(sel["id"], fields)
st.cache_data.clear()
st.success("Tarea actualizada.")
st.rerun()
_confirm_delete(f"task_{sel['id']}", sel["task"], lambda rid=sel["id"]: at.delete_task(rid))
# ── Clientes ──────────────────────────────────────────────────────────────── # # ── Clientes ──────────────────────────────────────────────────────────────── #

View File

@ -2,3 +2,5 @@ pyairtable==3.3.0
python-dotenv==1.2.2 python-dotenv==1.2.2
streamlit>=1.35.0 streamlit>=1.35.0
pandas>=2.0.0 pandas>=2.0.0
Authlib>=1.3.2
httpx