diff --git a/.env.example b/.env.example index 0613853..25ada18 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,5 @@ DB_SERVER=localhost\MSSQLSERVER2025 DB_DATABASE=job_postings_mvp -DB_USER=sa -DB_PASSWORD=tu_password +DB_USER=your_sql_user +DB_PASSWORD=your_sql_password +OPENAI_API_KEY=your_openai_api_key diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d76f479..6ad4946 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,7 +27,7 @@ jobs: - name: Validate Python syntax run: | - python -m compileall app modules tests api.py app.py services.py run_tests.py + python -m compileall app modules tests api.py app.py run_tests.py lint: runs-on: windows-latest @@ -48,7 +48,7 @@ jobs: - name: Run Ruff run: | - ruff check app modules tests api.py app.py services.py run_tests.py + ruff check app modules tests api.py app.py run_tests.py test: runs-on: windows-latest diff --git a/README.md b/README.md index e4e5169..1efdb8c 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,9 @@ Aplicacion para registrar vacantes, analizarlas contra un perfil profesional y g - registrar vacantes - listar, archivar, reactivar y eliminar vacantes - analizar vacantes contra el perfil activo +- guardar vacantes desde la extension de Chrome +- mostrar progreso de guardado y analisis en la extension +- recuperar analisis historicos en la extension al reabrir una vacante ya registrada por `link` - gestionar aplicaciones y su seguimiento - gestionar perfil profesional, skills, experiencia, proyectos, educacion, cursos y certificaciones - renderizar una vista CV consolidada desde la web @@ -32,7 +35,7 @@ CVs-Optimizator/ | |-- infrastructure/ | `-- interfaces/ | `-- web/ -|-- modules/ +|-- chrome-extension/ |-- tests/ |-- sql_queries/ |-- run_tests.py @@ -50,16 +53,25 @@ CVs-Optimizator/ 1. Crea `.env` a partir de `.env.example`. 2. Ajusta credenciales de SQL Server. +3. Agrega `OPENAI_API_KEY` si vas a ejecutar analisis reales de vacantes. Variables usadas: ```env DB_SERVER=localhost\MSSQLSERVER2025 DB_DATABASE=job_postings_mvp -DB_USER=sa -DB_PASSWORD=tu_password +DB_USER=your_sql_user +DB_PASSWORD=your_sql_password +OPENAI_API_KEY=your_openai_api_key ``` +Notas de configuracion: + +- `DB_USER` y `DB_PASSWORD` son obligatorias para conectarse a SQL Server. +- `DB_SERVER` y `DB_DATABASE` mantienen defaults locales de desarrollo si no se definen. +- `OPENAI_API_KEY` es obligatoria para ejecutar analisis reales con OpenAI. +- La app puede arrancar sin BD, pero los flujos que persisten o consultan datos devolveran errores controlados o vistas vacias. + ## Instalacion ```bash @@ -73,13 +85,13 @@ pip install -r requirements.txt Interfaz principal web: ```bash -uvicorn api:app --reload +uvicorn api:app --reload --port 8001 ``` Luego abre: ```text -http://127.0.0.1:8000/app +http://127.0.0.1:8001/app ``` Prueba rapida de BD: @@ -106,6 +118,27 @@ La interfaz web ya cubre el flujo principal del producto: Las vistas largas de `Inbox` y `Seguimiento` ya incluyen paginacion y tamano de pagina configurable para evitar listas demasiado pesadas. +## Extension de Chrome + +La extension usa `side panel` en lugar de popup efimero. + +Flujo actual: + +1. extrae la vacante desde LinkedIn +2. guarda la vacante por API local +3. inicia el analisis en segundo plano +4. muestra estados intermedios en el panel lateral +5. si la vacante ya existia y tenia analisis, lo recupera desde la BD usando el `link` + +Endpoints usados por la extension: + +- `GET /health` +- `POST /vacantes` +- `POST /vacantes/async` +- `GET /vacantes/tasks/{task_id}` +- `GET /vacantes/{vacancy_id}/analysis` +- `GET /vacantes/by-link?link=...` + ## Tests Entrada unica recomendada: @@ -121,6 +154,7 @@ Estado actual de la suite principal: - tests de API - tests de rutas web - tests de flujos integrados con mocks +- tests del flujo async y de recuperacion de analisis por `link` ## CI @@ -140,7 +174,7 @@ Resumen corto: - `app/application`: casos de uso y servicios - `app/infrastructure`: conexion y repositorios - `app/interfaces/web`: interfaz HTML principal -- `modules`: codigo residual compartido, como `analizar_vacante.py` +- `chrome-extension`: integracion local con LinkedIn y la API Detalle adicional en [ARCHITECTURE.md](/C:/Users/josem/PycharmProjects/CVs-Optimizator/docs/ARCHITECTURE.md). @@ -148,5 +182,6 @@ Detalle adicional en [ARCHITECTURE.md](/C:/Users/josem/PycharmProjects/CVs-Optim - la web es ahora la interfaz recomendada - `app.py` en la raiz queda solo como acceso rapido informativo -- la suite principal corre localmente con `53` tests cubriendo la interfaz web principal +- la extension de Chrome ya opera con panel lateral y recuperacion de analisis historico por `link` +- la suite principal corre localmente con `68` tests - el pipeline de CI ya esta preparado para validar cambios automaticamente diff --git a/api.py b/api.py index 430cefe..70eb710 100644 --- a/api.py +++ b/api.py @@ -9,7 +9,7 @@ from typing import Optional from uuid import uuid4 -from fastapi import BackgroundTasks, FastAPI, HTTPException +from fastapi import BackgroundTasks, FastAPI, HTTPException, Query from fastapi.middleware.cors import CORSMiddleware from fastapi.staticfiles import StaticFiles from pydantic import BaseModel @@ -38,8 +38,8 @@ profile_repository=profile_repository, analysis_repository=analysis_repository, ) -analysis_jobs: dict[str, dict] = {} -analysis_jobs_lock = threading.Lock() +analysis_tasks: dict[str, dict] = {} +analysis_tasks_lock = threading.Lock() app = FastAPI( @@ -78,12 +78,12 @@ class VacanteResponse(BaseModel): class AsyncVacanteResponse(VacanteResponse): - job_id: str + task_id: str status: str -class AnalysisJobResponse(BaseModel): - job_id: str +class AnalysisTaskResponse(BaseModel): + task_id: str vacancy_id: int status: str step: str @@ -98,24 +98,30 @@ class VacancyAnalysisResponse(BaseModel): analysis: dict +class VacancyByLinkResponse(BaseModel): + found: bool + vacancy: Optional[dict] = None + analysis: Optional[dict] = None + + def _utc_now() -> str: return datetime.utcnow().isoformat(timespec="seconds") + "Z" -def _set_job_state(job_id: str, **updates) -> dict: - with analysis_jobs_lock: - current = analysis_jobs.get(job_id, {}).copy() +def _set_task_state(task_id: str, **updates) -> dict: + with analysis_tasks_lock: + current = analysis_tasks.get(task_id, {}).copy() current.update(updates) current["updated_at"] = _utc_now() - analysis_jobs[job_id] = current + analysis_tasks[task_id] = current return current.copy() -def _create_analysis_job(vacancy_id: int) -> dict: - job_id = str(uuid4()) +def _create_analysis_task(vacancy_id: int) -> dict: + task_id = str(uuid4()) now = _utc_now() - job = { - "job_id": job_id, + task = { + "task_id": task_id, "vacancy_id": vacancy_id, "status": "queued", "step": "saved", @@ -124,14 +130,14 @@ def _create_analysis_job(vacancy_id: int) -> dict: "created_at": now, "updated_at": now, } - with analysis_jobs_lock: - analysis_jobs[job_id] = job - return job.copy() + with analysis_tasks_lock: + analysis_tasks[task_id] = task + return task.copy() -def _run_analysis_job(job_id: str, vacancy_id: int) -> None: - _set_job_state( - job_id, +def _run_analysis_task(task_id: str, vacancy_id: int) -> None: + _set_task_state( + task_id, status="running", step="analyzing", message="Analizando vacante contra el perfil activo.", @@ -140,16 +146,16 @@ def _run_analysis_job(job_id: str, vacancy_id: int) -> None: try: result = analyze_vacancy_use_case.execute(vacancy_id) if result["omitido"]: - _set_job_state( - job_id, + _set_task_state( + task_id, status="completed", step="completed", message="Vacante guardada. Analisis omitido porque no hay perfil activo.", error=None, ) return - _set_job_state( - job_id, + _set_task_state( + task_id, status="completed", step="completed", message="Vacante guardada y analizada correctamente.", @@ -157,8 +163,8 @@ def _run_analysis_job(job_id: str, vacancy_id: int) -> None: ) except AnalysisError as exc: logger.warning("La vacante %s se guardo, pero el analisis automatico fallo: %s", vacancy_id, exc) - _set_job_state( - job_id, + _set_task_state( + task_id, status="failed", step="failed", message="La vacante se guardo, pero el analisis fallo.", @@ -257,25 +263,30 @@ def crear_vacante_async(payload: VacantePayload, background_tasks: BackgroundTas if not resultado["success"] or not resultado.get("id"): raise HTTPException(status_code=500, detail=resultado["message"]) - job = _create_analysis_job(resultado["id"]) - background_tasks.add_task(_run_analysis_job, job["job_id"], resultado["id"]) + task = _create_analysis_task(resultado["id"]) + background_tasks.add_task(_run_analysis_task, task["task_id"], resultado["id"]) return AsyncVacanteResponse( success=True, message="Vacante guardada. Analisis en segundo plano iniciado.", id=resultado["id"], - job_id=job["job_id"], - status=job["status"], + task_id=task["task_id"], + status=task["status"], ) -@app.get("/vacantes/jobs/{job_id}", response_model=AnalysisJobResponse) -def get_analysis_job(job_id: str): - with analysis_jobs_lock: - job = analysis_jobs.get(job_id) - if not job: - raise HTTPException(status_code=404, detail="Job no encontrado") - return AnalysisJobResponse(**job) +@app.get("/vacantes/tasks/{task_id}", response_model=AnalysisTaskResponse) +def get_analysis_task(task_id: str): + with analysis_tasks_lock: + task = analysis_tasks.get(task_id) + if not task: + raise HTTPException(status_code=404, detail="Tarea no encontrada") + return AnalysisTaskResponse(**task) + + +@app.get("/vacantes/jobs/{task_id}", response_model=AnalysisTaskResponse) +def get_analysis_job_legacy(task_id: str): + return get_analysis_task(task_id) @app.get("/vacantes/{vacancy_id}/analysis", response_model=VacancyAnalysisResponse) @@ -284,3 +295,13 @@ def get_vacancy_analysis(vacancy_id: int): if not analysis: raise HTTPException(status_code=404, detail="Analisis no encontrado") return VacancyAnalysisResponse(vacancy_id=vacancy_id, analysis=analysis) + + +@app.get("/vacantes/by-link", response_model=VacancyByLinkResponse) +def get_vacancy_by_link(link: str = Query(..., min_length=1)): + vacancy = vacancy_repository.get_by_link(link) + if not vacancy: + return VacancyByLinkResponse(found=False, vacancy=None, analysis=None) + + analysis = analysis_repository.get_by_vacancy_id(vacancy["id"]) + return VacancyByLinkResponse(found=True, vacancy=vacancy, analysis=analysis) diff --git a/app.py b/app.py index 8f0d9dd..c364e86 100644 --- a/app.py +++ b/app.py @@ -4,8 +4,8 @@ def main() -> int: - print("La interfaz principal ahora es la web en http://127.0.0.1:8000/app") - print("Ejecuta: uvicorn api:app --reload") + print("La interfaz principal ahora es la web en http://127.0.0.1:8001/app") + print("Ejecuta: uvicorn api:app --reload --port 8001") return 0 diff --git a/app/config/settings.py b/app/config/settings.py index 504a661..48ac2df 100644 --- a/app/config/settings.py +++ b/app/config/settings.py @@ -10,12 +10,20 @@ load_dotenv() +def _optional_env(key: str) -> str | None: + value = os.getenv(key) + if value is None: + return None + value = value.strip() + return value or None + + @dataclass(frozen=True) class Settings: db_server: str = os.getenv("DB_SERVER", "localhost\\MSSQLSERVER2025") db_database: str = os.getenv("DB_DATABASE", "job_postings_mvp") - db_user: str = os.getenv("DB_USER", "sa") - db_password: str = os.getenv("DB_PASSWORD", "Micontraseña") + db_user: str | None = _optional_env("DB_USER") + db_password: str | None = _optional_env("DB_PASSWORD") settings = Settings() diff --git a/app/infrastructure/persistence/connection.py b/app/infrastructure/persistence/connection.py index 2bee7a8..55e8b65 100644 --- a/app/infrastructure/persistence/connection.py +++ b/app/infrastructure/persistence/connection.py @@ -11,6 +11,10 @@ from app.config.settings import settings +class DatabaseConfigurationError(ValueError): + """Raised when database credentials are missing or incomplete.""" + + def get_odbc_driver() -> str: """Return the preferred SQL Server ODBC driver available in the machine.""" available_drivers = pyodbc.drivers() @@ -23,7 +27,27 @@ def get_odbc_driver() -> str: return "ODBC Driver for SQL Server" +def missing_database_settings() -> list[str]: + missing = [] + if not settings.db_user: + missing.append("DB_USER") + if not settings.db_password: + missing.append("DB_PASSWORD") + return missing + + +def validate_database_settings() -> None: + missing = missing_database_settings() + if missing: + joined = ", ".join(missing) + raise DatabaseConfigurationError( + f"Faltan variables de configuracion de BD: {joined}. " + "Define estas variables en .env o en el entorno antes de conectar a SQL Server." + ) + + def build_connection_string() -> str: + validate_database_settings() driver = get_odbc_driver() return ( f"Driver={{{driver}}};" @@ -34,6 +58,19 @@ def build_connection_string() -> str: ) +def build_safe_connection_summary() -> str: + driver = get_odbc_driver() + user_state = "configured" if settings.db_user else "missing" + password_state = "configured" if settings.db_password else "missing" + return ( + f"Driver={{{driver}}};" + f"Server={settings.db_server};" + f"Database={settings.db_database};" + f"UID=<{user_state}>;" + f"PWD=<{password_state}>;" + ) + + def get_connection(): """Create a pyodbc connection using centralized settings.""" return pyodbc.connect(build_connection_string()) @@ -48,7 +85,7 @@ def test_connection() -> bool: cursor = conn.cursor() cursor.execute("SELECT 1") return True - except pyodbc.Error: + except (pyodbc.Error, DatabaseConfigurationError): return False finally: if cursor is not None: diff --git a/app/infrastructure/persistence/repositories/vacancy_repository.py b/app/infrastructure/persistence/repositories/vacancy_repository.py index c2dc503..1ba05a2 100644 --- a/app/infrastructure/persistence/repositories/vacancy_repository.py +++ b/app/infrastructure/persistence/repositories/vacancy_repository.py @@ -15,6 +15,16 @@ class VacancyRepository: """Persistence access for vacancies.""" + @staticmethod + def normalize_link(link: Optional[str]) -> Optional[str]: + if not link: + return None + normalized = link.strip() + if not normalized: + return None + normalized = normalized.split("?", 1)[0].rstrip("/") + return normalized or None + @staticmethod def _close(cursor=None, conn=None) -> None: if cursor is not None: @@ -71,7 +81,7 @@ def create( empresa, cargo, modalidad, - link.strip() if link and link.strip() else None, + self.normalize_link(link), descripcion, ), ) @@ -145,6 +155,37 @@ def get_by_id(self, vacante_id: int) -> Optional[dict]: finally: self._close(cursor, conn) + def get_by_link(self, link: Optional[str]) -> Optional[dict]: + normalized_link = self.normalize_link(link) + if not normalized_link: + return None + + conn = None + cursor = None + try: + conn = get_connection() + cursor = conn.cursor() + cursor.execute( + """ + SELECT TOP 1 id, empresa, cargo, modalidad, link, descripcion, fecha_registro, motivo_archivo + FROM vacantes + WHERE link = ? OR link = ? OR link = ? + ORDER BY fecha_registro DESC + """, + ( + normalized_link, + f"{normalized_link}/", + normalized_link.split("?", 1)[0], + ), + ) + row = cursor.fetchone() + return self._row_to_vacancy(row) if row else None + except Exception: + logger.exception("Error obteniendo vacante por link %s", normalized_link) + return None + finally: + self._close(cursor, conn) + def delete(self, vacante_id: int) -> dict: conn = None cursor = None diff --git a/app/interfaces/web/routes/vacancies.py b/app/interfaces/web/routes/vacancies.py index 510ef88..fe1151c 100644 --- a/app/interfaces/web/routes/vacancies.py +++ b/app/interfaces/web/routes/vacancies.py @@ -39,9 +39,79 @@ STATUS_META = { "En seguimiento": {"tone": "blue", "label": "En seguimiento"}, "Analizada": {"tone": "green", "label": "Analizada"}, - "Registrada": {"tone": "gray", "label": "Registrada"}, + "Sin analizar": {"tone": "gray", "label": "Sin analizar"}, } INBOX_VIEWS = ["Todas", "Recientes", "Analizadas", "En seguimiento", "Sin analizar"] +ANALYSIS_TONE_DEFAULT = {"tone": "gray", "label": "Sin analisis"} + + +def _safe_score(value) -> float | None: + try: + return float(value) if value is not None else None + except (TypeError, ValueError): + return None + + +def _score_meta(analysis: dict | None) -> dict: + if not analysis: + return {**ANALYSIS_TONE_DEFAULT, "value": None} + + score = _safe_score(analysis.get("score_total")) + if score is None: + return {**ANALYSIS_TONE_DEFAULT, "value": None} + if score >= 80: + tone = "green" + elif score >= 60: + tone = "amber" + else: + tone = "red" + return {"tone": tone, "label": "Score", "value": round(score)} + + +def _keyword_tone(value: str | None, mapping: dict[str, str], default_label: str) -> dict: + if not value: + return {"tone": "gray", "label": "-", "raw": None} + + normalized = value.strip() + lowered = normalized.lower() + for keyword, tone in mapping.items(): + if keyword in lowered: + return {"tone": tone, "label": normalized, "raw": normalized} + return {"tone": "gray", "label": normalized or default_label, "raw": normalized} + + +def _affinity_meta(analysis: dict | None) -> dict: + if not analysis: + return {"tone": "gray", "label": "-", "raw": None} + return _keyword_tone( + analysis.get("afinidad_general"), + { + "alta": "green", + "media": "amber", + "baja": "red", + }, + "-", + ) + + +def _decision_meta(analysis: dict | None) -> dict: + if not analysis: + return {"tone": "gray", "label": "-", "raw": None} + + decision = analysis.get("decision_aplicacion") + if not decision: + return {"tone": "gray", "label": "-", "raw": None} + + lowered = decision.strip().lower() + if "no aplicar" in lowered or "descartar" in lowered or "rechazar" in lowered: + tone = "red" + elif "revis" in lowered or "evalu" in lowered or "consider" in lowered: + tone = "amber" + elif "aplicar" in lowered or "prior" in lowered or "avanz" in lowered: + tone = "green" + else: + tone = "gray" + return {"tone": tone, "label": decision.strip(), "raw": decision.strip()} def _application_ids_with_tracking() -> set[int]: @@ -50,6 +120,7 @@ def _application_ids_with_tracking() -> set[int]: def _build_vacancy_items(limit: int | None = None) -> list[dict]: vacancies = vacancy_repository.list_all() + vacancies = [item for item in vacancies if not item.get("motivo_archivo")] vacancies = sorted(vacancies, key=lambda item: item.get("fecha_registro") or "", reverse=True) visible_vacancies = vacancies[:limit] if limit else vacancies vacancy_ids = [item["id"] for item in visible_vacancies] @@ -59,14 +130,20 @@ def _build_vacancy_items(limit: int | None = None) -> list[dict]: for vacancy in visible_vacancies: analysis = analyses_by_vacancy.get(vacancy["id"]) has_application = vacancy["id"] in tracked_vacancy_ids - status_label = "En seguimiento" if has_application else ("Analizada" if analysis else "Registrada") + status_label = "En seguimiento" if has_application else ("Analizada" if analysis else "Sin analizar") + score_meta = _score_meta(analysis) + affinity_meta = _affinity_meta(analysis) + decision_meta = _decision_meta(analysis) items.append( { **vacancy, "analisis": analysis, "status_label": status_label, "status_meta": STATUS_META[status_label], - "score_label": f"{analysis.get('score_total', 0):.0f}" if analysis else "Sin score", + "score_label": f"{score_meta['value']:.0f}" if score_meta["value"] is not None else "Sin analisis", + "score_meta": score_meta, + "affinity_meta": affinity_meta, + "decision_meta": decision_meta, "has_application": has_application, } ) @@ -134,6 +211,41 @@ def _selected_vacancy(selected_id: int | None) -> dict | None: return next((item for item in items if item["id"] == selected_id), items[0]) +def _next_visible_vacancy_id(current_id: int, *, q: str | None, view: str) -> int | None: + items = _filter_vacancy_items(_build_vacancy_items(limit=None), q=q, view=view) + if not items: + return None + for item in items: + if item["id"] != current_id: + return item["id"] + return None + + +def _build_inbox_url( + *, + selected: int | None = None, + flash: str | None = None, + q: str | None = None, + view: str = "Todas", + page: int = 1, + page_size: int = DEFAULT_PAGE_SIZE, +) -> str: + params: list[str] = [] + if selected is not None: + params.append(f"selected={selected}") + if flash: + params.append(f"flash={flash}") + if q: + params.append(f"q={q}") + if view and view != "Todas": + params.append(f"view={view}") + if page != 1: + params.append(f"page={page}") + if page_size != DEFAULT_PAGE_SIZE: + params.append(f"page_size={page_size}") + return "/app/vacancies" + (f"?{'&'.join(params)}" if params else "") + + def _flash_message(flash: str | None) -> tuple[str, str] | None: if flash == "vacancy_created": return ("success", "Vacante registrada y analizada. Revisa el resultado en Inbox.") @@ -143,6 +255,10 @@ def _flash_message(flash: str | None) -> tuple[str, str] | None: return ("warning", "Vacante registrada, pero el analisis no pudo completarse.") if flash == "interest_error": return ("warning", "No se pudo enviar la vacante a Seguimiento.") + if flash == "vacancy_discarded": + return ("info", "Vacante descartada y removida del Inbox.") + if flash == "vacancy_discard_error": + return ("warning", "No se pudo descartar la vacante.") return None @@ -160,7 +276,7 @@ def _build_inbox_context( filtered_items = _filter_vacancy_items(all_items, q=q, view=normalized_view) resolved_page = _resolve_page_for_selected(filtered_items, selected, normalized_page_size, page) items, pagination = _paginate_items(filtered_items, resolved_page, normalized_page_size) - selected_vacancy = next((item for item in items if item["id"] == selected), None) if selected else (items[0] if items else None) + selected_vacancy = next((item for item in items if item["id"] == selected), None) if selected else None return { "vacancies": items, "selected_vacancy": selected_vacancy, @@ -188,6 +304,8 @@ def vacancies_index( page_size: int = DEFAULT_PAGE_SIZE, ): context = _build_inbox_context(selected=selected, flash=flash, q=q, view=view, page=page, page_size=page_size) + metrics = _build_metrics() + metric_lookup = {item["label"]: item["value"] for item in metrics} return templates.TemplateResponse( request=request, name="vacancies/index.html", @@ -195,7 +313,15 @@ def vacancies_index( "page_title": "Inbox de Vacantes", "active_nav": "vacancies", "nav_items": _build_nav("vacancies"), - "metrics": _build_metrics(), + "metrics": metrics, + "hide_global_metrics": True, + "context_bar": [ + {"label": "Vacantes visibles", "value": context["summary"]["total"]}, + {"label": "Analizadas", "value": context["summary"]["analizadas"]}, + {"label": "En seguimiento", "value": context["summary"]["seguimiento"]}, + {"label": "Aplicaciones", "value": metric_lookup.get("Aplicaciones", 0)}, + {"label": "Rechazadas", "value": metric_lookup.get("Rechazadas", 0)}, + ], **context, }, ) @@ -264,7 +390,16 @@ def vacancy_detail_partial(request: Request, vacancy_id: int): return templates.TemplateResponse( request=request, name="vacancies/_detail.html", - context={"request": request, "selected_vacancy": vacancy}, + context={ + "request": request, + "selected_vacancy": vacancy, + "query": request.query_params.get("q", ""), + "current_view": request.query_params.get("view", "Todas"), + "pagination": { + "page": int(request.query_params.get("page", "1")), + "page_size": int(request.query_params.get("page_size", str(DEFAULT_PAGE_SIZE))), + }, + }, ) @@ -311,3 +446,39 @@ def mark_vacancy_as_interesting(vacancy_id: int): ) return RedirectResponse(url="/app/vacancies?flash=interest_error", status_code=303) + + +@router.post("/app/vacancies/{vacancy_id}/discard") +def discard_vacancy( + vacancy_id: int, + q: str | None = Form(default=None), + view: str = Form(default="Todas"), + page: int = Form(default=1), + page_size: int = Form(default=DEFAULT_PAGE_SIZE), +): + result = vacancy_repository.archive(vacancy_id, "Otro") + if not result["success"]: + return RedirectResponse( + url=_build_inbox_url( + selected=vacancy_id, + flash="vacancy_discard_error", + q=q, + view=view, + page=page, + page_size=page_size, + ), + status_code=303, + ) + + next_selected = _next_visible_vacancy_id(vacancy_id, q=q, view=view) + return RedirectResponse( + url=_build_inbox_url( + selected=next_selected, + flash="vacancy_discarded", + q=q, + view=view, + page=page, + page_size=page_size, + ), + status_code=303, + ) diff --git a/app/interfaces/web/static/css/app.css b/app/interfaces/web/static/css/app.css index 2819183..4e637a8 100644 --- a/app/interfaces/web/static/css/app.css +++ b/app/interfaces/web/static/css/app.css @@ -37,9 +37,10 @@ a { .shell-header { display: flex; justify-content: space-between; - align-items: end; + align-items: center; gap: 24px; - padding: 28px 0 16px; + padding: 18px 0 14px; + border-bottom: 1px solid var(--border); } .shell-brand { @@ -57,13 +58,21 @@ a { } .shell-title { - font-size: 1.5rem; + font-size: 1.25rem; font-weight: 700; } .shell-subtitle { color: var(--muted); margin-top: 4px; + font-size: 0.88rem; +} + +.shell-nav-group { + display: flex; + align-items: center; + gap: 14px; + flex-wrap: wrap; } .shell-nav { @@ -96,6 +105,25 @@ a { font-weight: 700; } +.shell-header-action { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 40px; + padding: 0 16px; + border-radius: 12px; + background: var(--brand); + color: #fff; + text-decoration: none; + font-weight: 700; + box-shadow: 0 8px 16px rgba(29, 78, 216, 0.16); +} + +.shell-header-action.is-active, +.shell-header-action:hover { + background: var(--brand-strong); +} + .kpi-strip { display: grid; grid-template-columns: repeat(5, 1fr); @@ -128,7 +156,7 @@ a { } .page-shell { - padding-bottom: 32px; + padding: 20px 0 32px; } .layout-two-columns { @@ -144,14 +172,18 @@ a { .page-header { display: flex; justify-content: space-between; - align-items: end; + align-items: start; gap: 16px; - margin-bottom: 14px; + margin-bottom: 10px; +} + +.page-header > * { + min-width: 0; } .page-header h1 { margin: 0; - font-size: 1.55rem; + font-size: 1.28rem; } .page-header p, @@ -161,6 +193,7 @@ a { .panel { padding: 18px 20px; + overflow: hidden; } .panel.narrow { @@ -168,7 +201,17 @@ a { } .filter-panel { - margin-bottom: 16px; + margin-bottom: 12px; +} + +.page-header-copy { + display: grid; + gap: 4px; +} + +.page-header-copy p { + margin: 0; + font-size: 0.92rem; } .primary-link, @@ -232,6 +275,35 @@ a { font-weight: 700; } +.context-strip { + display: flex; + flex-wrap: wrap; + gap: 10px; + padding: 0 0 14px; + margin-bottom: 10px; + border-bottom: 1px solid var(--border); +} + +.context-item { + display: inline-flex; + align-items: baseline; + gap: 6px; + padding: 8px 12px; + border-radius: 999px; + background: #fff; + border: 1px solid var(--border); +} + +.context-value { + font-size: 1rem; + font-weight: 800; +} + +.context-label { + font-size: 0.82rem; + color: var(--muted); +} + .filter-form { display: grid; grid-template-columns: minmax(0, 2fr) minmax(220px, 1fr) auto; @@ -239,11 +311,28 @@ a { align-items: end; } +.filter-form-compact { + grid-template-columns: minmax(0, 2.2fr) minmax(180px, 0.8fr) minmax(120px, 0.6fr) auto; + gap: 10px; +} + .filter-form label { display: grid; gap: 6px; } +.filter-form label span { + font-size: 0.8rem; + color: var(--muted); +} + +.filter-submit { + min-height: 44px; + border: 1px solid var(--border); + background: #fff; + color: var(--muted); +} + .primary-link:hover, .stack-form button:hover { background: var(--brand-strong); @@ -317,47 +406,73 @@ a { .simple-table { display: grid; gap: 8px; + min-width: 0; + --vacancy-table-columns: 92px minmax(180px, 1.05fr) minmax(220px, 1.35fr) 112px 148px 124px; } .table-head, .table-row { display: grid; - grid-template-columns: 90px 1.2fr 1.3fr 1fr 1fr; - gap: 12px; + grid-template-columns: var(--vacancy-table-columns); + gap: 14px; align-items: center; } +.table-head > div, +.table-row > div, +.detail-card, +.summary-card, +.panel { + min-width: 0; +} + .table-head { font-size: 0.82rem; font-weight: 700; color: var(--muted); text-transform: uppercase; letter-spacing: 0.04em; - padding: 0 8px; + padding: 0 12px; +} + +.table-head > div:nth-child(4), +.table-head > div:nth-child(5), +.table-head > div:nth-child(6) { + text-align: center; } .table-row { - padding: 12px 8px; + padding: 12px 92px 12px 12px; + transition: 0.2s ease; +} + +.table-row-shell { + position: relative; + display: block; + padding: 0; border: 1px solid var(--border); - border-radius: 12px; + border-radius: 14px; background: #fbfcfe; transition: 0.2s ease; + overflow: hidden; } .row-link { color: inherit; text-decoration: none; + min-width: 0; } -.row-link:hover { +.table-row-shell:hover { border-color: #bfdbfe; background: #f8fbff; transform: translateY(-1px); } -.row-link.is-selected { +.table-row-shell.is-selected { border-color: #b3c9ff; background: #eef4ff; + box-shadow: inset 4px 0 0 #1d4ed8; } .mono-cell { @@ -365,14 +480,90 @@ a { font-weight: 700; } +.score-cell { + display: flex; + align-items: center; +} + +.company-cell, +.role-cell { + min-width: 0; +} + +.date-cell { + display: flex; + justify-content: center; +} + +.date-chip { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 34px; + padding: 0 10px; + border-radius: 999px; + background: #f8fafc; + border: 1px solid var(--border); + color: var(--muted); + font-size: 0.8rem; + font-weight: 700; + white-space: nowrap; +} + +.row-quick-actions { + display: flex; + align-items: center; + justify-content: flex-end; + position: absolute; + top: 50%; + right: 12px; + transform: translateY(-50%); + opacity: 0; + transition: opacity 0.2s ease; + pointer-events: none; +} + +.table-row-shell:hover .row-quick-actions, +.table-row-shell.is-selected .row-quick-actions { + opacity: 1; + pointer-events: auto; +} + +.row-quick-actions form { + margin: 0; +} + +.quick-action { + min-height: 34px; + padding: 0 12px; + border-radius: 10px; + font-size: 0.78rem; +} + .primary-cell { font-weight: 600; + overflow-wrap: anywhere; +} + +.truncate-cell { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .secondary-cell { margin-top: 3px; color: var(--muted); font-size: 0.84rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.decision-cell, +.status-cell { + display: flex; + justify-content: center; } .detail-grid { @@ -389,10 +580,15 @@ a { background: #fbfcfe; } -#vacancy-detail .panel, -.layout-two-columns > .panel:last-child { +#vacancy-detail { position: sticky; - top: 20px; + top: 18px; + align-self: start; +} + +.detail-sheet { + max-height: calc(100vh - 110px); + overflow-y: auto; } .detail-label { @@ -406,6 +602,7 @@ a { margin-top: 6px; font-size: 1rem; font-weight: 700; + overflow-wrap: anywhere; } .soft-badge, @@ -416,7 +613,75 @@ a { padding: 6px 10px; font-size: 0.82rem; font-weight: 700; - white-space: nowrap; + white-space: normal; + text-align: center; + max-width: 100%; +} + +.analysis-pill { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 42px; + max-width: 100%; + padding: 6px 10px; + border-radius: 999px; + font-size: 0.82rem; + font-weight: 800; + white-space: normal; + text-align: center; + border: 1px solid transparent; +} + +.analysis-pill-score { + min-width: 62px; + font-size: 0.92rem; +} + +.analysis-pill-large { + min-width: 104px; + padding: 10px 14px; + font-size: 1rem; +} + +.table-row .analysis-pill, +.table-row .status-badge { + justify-self: center; +} + +.tone-gray { + background: #f3f4f6; + border-color: #e5e7eb; + color: #4b5563; +} + +.tone-green { + background: #dcfce7; + border-color: #86efac; + color: #166534; +} + +.tone-amber { + background: #fef3c7; + border-color: #fcd34d; + color: #b45309; +} + +.tone-red { + background: #fee2e2; + border-color: #fca5a5; + color: #b91c1c; +} + +.detail-header-badges { + display: flex; + gap: 8px; + flex-wrap: wrap; + align-items: center; +} + +.analysis-detail-grid .detail-card { + background: #ffffff; } .soft-badge { @@ -471,6 +736,11 @@ a { border: 1px solid #dbeafe; } +.analysis-summary-empty { + background: #fbfcfe; + border-color: var(--border); +} + .empty-panel { padding: 20px 12px; text-align: center; @@ -480,6 +750,340 @@ a { display: flex; gap: 10px; flex-wrap: wrap; + min-width: 0; +} + +.description-disclosure { + border: 1px solid var(--border); + border-radius: 14px; + background: #fbfcfe; + padding: 0; + overflow: hidden; +} + +.description-disclosure > summary { + cursor: pointer; + list-style: none; + padding: 14px 16px; + font-weight: 700; + background: #f8fbff; +} + +.description-disclosure > summary::-webkit-details-marker { + display: none; +} + +.description-disclosure[open] > summary { + border-bottom: 1px solid var(--border); +} + +.description-disclosure > p { + margin: 0; + padding: 16px; +} + +.inbox-workspace { + align-items: start; +} + +.workspace-panel { + min-height: 0; +} + +.inbox-list-panel { + padding: 16px 18px 18px; +} + +.inline-detail-row { + margin: 8px 0 16px; + transform-origin: top center; + animation: inline-detail-enter 0.22s ease; +} + +.detail-sheet-inline { + max-height: none; + overflow: visible; + border-radius: 16px; + border-color: #cfe0ff; + background: linear-gradient(180deg, #ffffff 0%, #f8fbff 100%); +} + +.detail-sheet-inline .page-header { + margin-bottom: 16px; +} + +@keyframes inline-detail-enter { + from { + opacity: 0; + transform: translateY(-8px) scaleY(0.98); + } + to { + opacity: 1; + transform: translateY(0) scaleY(1); + } +} + +.vacancy-card-inline { + padding: 0; + overflow: hidden; +} + +.vacancy-card-header { + display: flex; + justify-content: space-between; + align-items: start; + gap: 12px; + padding: 18px 22px 16px; +} + +.vacancy-card-copy { + min-width: 0; +} + +.vacancy-card-title { + margin: 0 0 6px; + display: flex; + align-items: center; + gap: 8px; + font-size: 1rem; + font-weight: 600; + line-height: 1.35; +} + +.vacancy-link-icon { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 24px; + padding: 0 8px; + border-radius: 999px; + border: 1px solid var(--border); + color: var(--muted); + text-decoration: none; + font-size: 0.76rem; + font-weight: 700; +} + +.vacancy-link-icon:hover { + border-color: #bfdbfe; + color: var(--brand); + background: #f8fbff; +} + +.vacancy-card-meta { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 10px 14px; + font-size: 0.78rem; + color: var(--muted); +} + +.vacancy-card-actions { + display: flex; + gap: 8px; + align-items: center; + flex-shrink: 0; +} + +.vacancy-card-actions form { + margin: 0; +} + +.vacancy-action-button { + min-height: 34px; + padding: 0 12px; + border-radius: 10px; + font-size: 0.78rem; + box-shadow: none; +} + +.vacancy-close-button { + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border-radius: 10px; + border: 1px solid var(--border); + color: var(--muted); + text-decoration: none; + background: transparent; + font-size: 1rem; + line-height: 1; +} + +.vacancy-close-button:hover { + background: #fee2e2; + border-color: #fecaca; + color: #b91c1c; +} + +.vacancy-card-divider { + height: 1px; + background: var(--border); +} + +.vacancy-score-strip { + display: flex; + align-items: center; + gap: 18px; + padding: 16px 22px; + background: #f8fbff; +} + +.vacancy-score-value { + flex-shrink: 0; +} + +.vacancy-score-label { + margin-bottom: 4px; + font-size: 0.7rem; + color: var(--muted); + text-transform: uppercase; + letter-spacing: 0.08em; +} + +.vacancy-score-number { + font-size: 1.9rem; + font-weight: 700; + line-height: 1; +} + +.vacancy-score-number.tone-green, +.vacancy-score-fill.tone-green, +.vacancy-verdict.tone-green { + color: #166534; +} + +.vacancy-score-number.tone-amber, +.vacancy-score-fill.tone-amber, +.vacancy-verdict.tone-amber { + color: #b45309; +} + +.vacancy-score-number.tone-red, +.vacancy-score-fill.tone-red, +.vacancy-verdict.tone-red { + color: #b91c1c; +} + +.vacancy-score-number.tone-gray, +.vacancy-score-fill.tone-gray, +.vacancy-verdict.tone-gray { + color: #4b5563; +} + +.vacancy-score-gauge-block { + flex: 1; + min-width: 0; +} + +.vacancy-score-scale { + display: flex; + justify-content: space-between; + margin-bottom: 6px; + font-size: 0.72rem; + color: var(--muted); +} + +.vacancy-score-gauge { + height: 6px; + background: #dbe3f0; + border-radius: 999px; + overflow: hidden; +} + +.vacancy-score-fill { + height: 100%; + border-radius: 999px; + background: currentColor; +} + +.vacancy-verdict { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 6px 10px; + border-radius: 10px; + font-size: 0.78rem; + font-weight: 700; + background: currentColor; + color: #fff; + white-space: nowrap; +} + +.vacancy-verdict.tone-green { + background: #dcfce7; + color: #166534; +} + +.vacancy-verdict.tone-amber { + background: #fef3c7; + color: #b45309; +} + +.vacancy-verdict.tone-red { + background: #fee2e2; + color: #b91c1c; +} + +.vacancy-verdict.tone-gray { + background: #f3f4f6; + color: #4b5563; +} + +.vacancy-card-body { + padding: 18px 22px; +} + +.vacancy-skills { + display: flex; + gap: 6px; + flex-wrap: wrap; + margin-bottom: 12px; +} + +.vacancy-skill { + display: inline-flex; + align-items: center; + min-height: 24px; + padding: 0 10px; + border-radius: 999px; + border: 1px solid var(--border); + color: var(--muted); + background: #fff; + font-size: 0.74rem; + font-weight: 700; +} + +.vacancy-analysis-text { + margin: 0; + font-size: 0.88rem; + line-height: 1.7; + color: #4b5563; +} + +.vacancy-description { + margin-top: 0; + border: 0; + border-top: 1px solid var(--border); + border-radius: 0; + background: transparent; +} + +.vacancy-description > summary { + padding: 14px 22px; + background: transparent; + font-size: 0.8rem; +} + +.vacancy-description > .vacancy-description-body { + padding: 0 22px 18px; +} + +.vacancy-description > .vacancy-description-body p { + margin: 0; } .pill-group { @@ -865,12 +1469,11 @@ a { } .summary-strip, + .context-strip, .profile-hint-grid, .filter-form, .layout-two-columns, .detail-grid, - .table-head, - .table-row, .profile-grid, .grid-two, .grid-three, @@ -878,11 +1481,52 @@ a { grid-template-columns: 1fr; } - #vacancy-detail .panel, - .layout-two-columns > .panel:last-child { + .table-head, + .table-row { + grid-template-columns: 76px minmax(0, 1fr) minmax(0, 1fr) 92px; + } + + .table-head > div:nth-child(4), + .table-head > div:nth-child(5), + .table-head > div:nth-child(6), + .table-row > div:nth-child(4), + .table-row > div:nth-child(5), + .table-row > div:nth-child(6), + .table-row-shell > .row-quick-actions { + display: none; + } + + #vacancy-detail { position: static; } + .detail-sheet { + max-height: none; + } + + .inbox-list-panel { + padding: 14px; + } + + .table-row { + padding-right: 12px; + } + + .vacancy-card-header, + .vacancy-score-strip { + flex-direction: column; + align-items: start; + } + + .vacancy-card-actions { + width: 100%; + flex-wrap: wrap; + } + + .vacancy-score-gauge-block { + width: 100%; + } + .record-header, .section-title { flex-direction: column; diff --git a/app/interfaces/web/templates.py b/app/interfaces/web/templates.py index 0d25de5..5786641 100644 --- a/app/interfaces/web/templates.py +++ b/app/interfaces/web/templates.py @@ -8,4 +8,16 @@ TEMPLATES_DIR = Path(__file__).resolve().parent / "templates" +STATIC_DIR = TEMPLATES_DIR.parent / "static" templates = Jinja2Templates(directory=str(TEMPLATES_DIR)) + + +def _static_version(path: str) -> int: + target = STATIC_DIR / path + try: + return int(target.stat().st_mtime) + except OSError: + return 0 + + +templates.env.globals["static_version"] = _static_version diff --git a/app/interfaces/web/templates/base.html b/app/interfaces/web/templates/base.html index d96c6a7..6fffdd5 100644 --- a/app/interfaces/web/templates/base.html +++ b/app/interfaces/web/templates/base.html @@ -4,7 +4,7 @@ {{ page_title }} | CVs Optimizator - + @@ -13,21 +13,28 @@
CVs Optimizator
Vacantes, analisis y seguimiento
- + -
- {% for metric in metrics %} -
-
{{ metric.label }}
-
{{ metric.value }}
-
- {% endfor %} -
+ {% if not hide_global_metrics %} +
+ {% for metric in metrics %} +
+
{{ metric.label }}
+
{{ metric.value }}
+
+ {% endfor %} +
+ {% endif %}
{% block content %}{% endblock %} diff --git a/app/interfaces/web/templates/vacancies/_detail.html b/app/interfaces/web/templates/vacancies/_detail.html index 0e52e80..f79e39a 100644 --- a/app/interfaces/web/templates/vacancies/_detail.html +++ b/app/interfaces/web/templates/vacancies/_detail.html @@ -1,87 +1,114 @@ {% if selected_vacancy %} -
-