From 7f0b08d3dd9964f6519cd51b362faaa57168637b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Manuel=20G=C3=B3mez?= Date: Sun, 5 Jul 2026 20:39:32 +0200 Subject: [PATCH] =?UTF-8?q?A=C3=B1ade=20login=20con=20Google=20y=20CRUD=20?= =?UTF-8?q?de=20proyectos/tareas?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .streamlit/secrets.toml.example | 15 ++ airtable_client.py | 63 ++++++- dashboard.py | 318 ++++++++++++++++++++++++++++++-- requirements.txt | 2 + 4 files changed, 376 insertions(+), 22 deletions(-) create mode 100644 .streamlit/secrets.toml.example diff --git a/.streamlit/secrets.toml.example b/.streamlit/secrets.toml.example new file mode 100644 index 0000000..1561547 --- /dev/null +++ b/.streamlit/secrets.toml.example @@ -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" diff --git a/airtable_client.py b/airtable_client.py index 37469ba..b5677f5 100644 --- a/airtable_client.py +++ b/airtable_client.py @@ -2,16 +2,20 @@ from pyairtable import Api import config -def _collaborator_name(value): +def _collaborator(value): 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: return [] - return [_collaborator_name(v) for v in values] + return [c for c in (_collaborator(v) for v in values) if c] class AirtableClient: @@ -32,14 +36,18 @@ class AirtableClient: result = [] for r in records: f = r["fields"] + project_lead = _collaborator(f.get("Project lead")) + working_team = _collaborators(f.get("Working team")) result.append({ "id": r["id"], "project": f.get("Project", ""), "tags": f.get("Tags", []), "status": f.get("Status", ""), "client_ids": f.get("Client", []), - "project_lead": _collaborator_name(f.get("Project lead")), - "working_team": _collaborator_names(f.get("Working team")), + "project_lead": project_lead, + "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"), "due_date": f.get("Due date"), "budget": f.get("Budget", 0), @@ -59,13 +67,15 @@ class AirtableClient: result = [] for r in records: f = r["fields"] + assigned_to = _collaborators(f.get("Assigned to")) result.append({ "id": r["id"], "task": f.get("Task", ""), "project_ids": f.get("Project", []), "area_ids": f.get("Area", []), "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"), "due_date": f.get("Due date"), "days_to_complete": f.get("Days to complete"), @@ -103,3 +113,40 @@ class AirtableClient: "task_ids": f.get("Tasks", []), }) 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) diff --git a/dashboard.py b/dashboard.py index 8d4f8e0..59aeb9c 100644 --- a/dashboard.py +++ b/dashboard.py @@ -14,10 +14,38 @@ st.set_page_config( 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...") def _load_data(): - at = AirtableClient() return { "projects": at.get_projects(), "tasks": at.get_tasks(), @@ -27,17 +55,99 @@ def _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_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_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_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") -tab_proyectos, tab_tareas, tab_clientes, tab_areas = st.tabs( - ["📁 Proyectos", "✅ Tareas", "👥 Clientes", "🗂️ Áreas"] +tab_mis_tareas, tab_proyectos, tab_tareas, tab_clientes, tab_areas = st.tabs( + ["🙋 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 ─────────────────────────────────────────────────────────────── # with tab_proyectos: @@ -50,21 +160,106 @@ with tab_proyectos: ) col1, col2 = st.columns(2) - status_filter = col1.multiselect("Status", sorted(df["status"].dropna().unique())) - client_filter = col2.multiselect("Cliente", sorted(client_name_by_id.values())) + status_options = sorted(df["status"].dropna().unique()) + 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() if status_filter: view = view[view["status"].isin(status_filter)] if 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( - view[["project", "status", "client", "project_lead", "kickoff_date", "due_date", "budget", "internal"]], + selection = st.dataframe( + view[["project", "status", "client", "project_lead_name", "kickoff_date", "due_date", "budget", "internal"]], use_container_width=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 ────────────────────────────────────────────────────────────────── # @@ -79,13 +274,15 @@ with tab_tareas: df["area"] = df["area_ids"].apply( 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) - status_filter = col1.multiselect("Status", sorted(df["status"].dropna().unique())) - prioridad_filter = col2.multiselect("Prioridad", sorted(df["prioridad"].dropna().unique())) + status_options = sorted(df["status"].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( - "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() @@ -95,13 +292,106 @@ with tab_tareas: view = view[view["prioridad"].isin(prioridad_filter)] if 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"]], use_container_width=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 ──────────────────────────────────────────────────────────────── # diff --git a/requirements.txt b/requirements.txt index 43381dd..d32062a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,3 +2,5 @@ pyairtable==3.3.0 python-dotenv==1.2.2 streamlit>=1.35.0 pandas>=2.0.0 +Authlib>=1.3.2 +httpx