diff --git a/.dockerignore b/.dockerignore index 2ba274f..45b7c83 100644 --- a/.dockerignore +++ b/.dockerignore @@ -13,6 +13,7 @@ **/.env **/.env.* !**/.env.example +backend/credentials/*.json **/__pycache__/ **/*.py[cod] diff --git a/.gitignore b/.gitignore index 115e33b..5f319a3 100644 --- a/.gitignore +++ b/.gitignore @@ -60,6 +60,9 @@ frontend/dist/ # Uploaded content — user data, never in git backend/uploads/ +# Google OAuth ADC / Desktop client secrets — never commit +backend/credentials/*.json + **.pdf # Per-machine alembic autogen revisions only — the old bare `**_**_**.py` # also swallowed any module with two underscores (e.g. test_talent_plugins.py). diff --git a/backend/credentials/.gitkeep b/backend/credentials/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/backend/credentials/application_default_credentials.json b/backend/credentials/application_default_credentials.json index 776c982..dfb1d51 100644 --- a/backend/credentials/application_default_credentials.json +++ b/backend/credentials/application_default_credentials.json @@ -1,8 +1,11 @@ { - "account": "", + "type": "authorized_user", "client_id": "679334897177-ufal3rogbg8cgm3qcren6pv20phqd7nl.apps.googleusercontent.com", "client_secret": "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", "refresh_token": "1//03C8LMkk-9mSyCgYIARAAGAMSNwF-L9IrAcPhShsp3cDeprSlqI-P6lArpmbyzu-PcKiLfQ5gC3H_MEk930IaKfewy3cxP3T0Oo8", - "type": "authorized_user", - "universe_domain": "googleapis.com" + "universe_domain": "googleapis.com", + "account": "ahmed.mujtaba@utopiabrands.com", + "token": "ya29.a0AdMD6Eg_6meQs84gTmiyhzZp7C-JlZeJU6-ECm6twwAcMqvfvRvyvs5LQGbAhHarHzZF-jiU-sicebJmxXIN4l6hNDXoaHcrojuhq--hj2oSBWojiEKaGIgLKPM8frdspz_wVANrwkFwpIKhN3RpWID9mJCt7N6IFaNrZtgStakdF0sVCKKVttE7qWK0vIvJT3HZHpVbaCgYKAX8SARASFQHGX2MiQvJqWStYQDgYFK4E6GJ9Zw0207", + "expiry": "2026-08-31T09:52:09Z", + "quota_project_id": "hrms-ats-portal" } diff --git a/backend/g_sheet/plugins.py b/backend/g_sheet/plugins.py index 04fb1c2..d0cee32 100644 --- a/backend/g_sheet/plugins.py +++ b/backend/g_sheet/plugins.py @@ -5,10 +5,13 @@ family and lets g_sheet/views.py translate that into HTTPException. Auth reuses the credentials already on disk (authorized_user ADC + a valid refresh token). Nothing here launches a browser, runs InstalledAppFlow, or reads stdin. +After a successful refresh, store_authorized_session writes the ADC JSON back so +the session can be copied to Linux prod. Re-auth lives in g_sheet/store_session.py. """ from __future__ import annotations +import json import logging import os import random @@ -31,12 +34,11 @@ from g_sheet.enums import ( MonthNormalisation, ) -load_dotenv() - logger=logging.getLogger("g_sheet.plugins") # backend/ — GOOGLE_APPLICATION_CREDENTIALS is stored relative to it ("credentials/..."). ROOT=Path(__file__).resolve().parent.parent +load_dotenv(ROOT/".env") SCOPES=[ "https://www.googleapis.com/auth/spreadsheets", @@ -47,6 +49,9 @@ SPREADSHEET_ID=os.getenv("SPREADSHEET_ID") SPREADSHEET_NAME=os.getenv("SPREADSHEET_NAME") SPREADSHEET_URL=os.getenv("SPREADSHEET_URL") GOOGLE_APPLICATION_CREDENTIALS=os.getenv("GOOGLE_APPLICATION_CREDENTIALS") +GOOGLE_OAUTH_CLIENT_ID_FILE=os.getenv("GOOGLE_OAUTH_CLIENT_ID_FILE") +GOOGLE_CLOUD_PROJECT=os.getenv("GOOGLE_CLOUD_PROJECT") +GOOGLE_ACCOUNT=os.getenv("GOOGLE_ACCOUNT") # 429 and 5xx are transient; every other 4xx is a bad request that a retry repeats. RETRY_ATTEMPTS=3 @@ -94,6 +99,73 @@ def resolve_credentials_path(credentials_path=None): return path +def resolve_client_secret_path(client_secret_path=None): + """Absolute path to the Desktop OAuth client json (credentials/client_secret.json).""" + raw=client_secret_path or GOOGLE_OAUTH_CLIENT_ID_FILE + if not raw: + return None + path=Path(raw) + if not path.is_absolute(): + path=ROOT/path + return path + + +def _expiry_iso(expiry): + if expiry is None: + return None + if expiry.tzinfo is None: + return expiry.strftime("%Y-%m-%dT%H:%M:%SZ") + return expiry.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + + +def _authorized_user_adc(credentials): + """gcloud-compatible authorized_user payload. google.auth.default() requires type.""" + payload={ + "type":"authorized_user", + "client_id":credentials.client_id, + "client_secret":credentials.client_secret, + "refresh_token":credentials.refresh_token, + "universe_domain":getattr(credentials,"universe_domain",None) or "googleapis.com", + "account":getattr(credentials,"account",None) or GOOGLE_ACCOUNT or "", + } + token=getattr(credentials,"token",None) + if token: + payload["token"]=token + expiry=_expiry_iso(getattr(credentials,"expiry",None)) + if expiry: + payload["expiry"]=expiry + if GOOGLE_CLOUD_PROJECT: + payload["quota_project_id"]=GOOGLE_CLOUD_PROJECT + return payload + + +def store_authorized_session(credentials,credentials_path=None): + """Persist an authorized_user session to GOOGLE_APPLICATION_CREDENTIALS. + + Service-account key files are left untouched (no refresh_token to rotate). + A persist failure is logged, never raised — the in-memory token still works. + """ + path=resolve_credentials_path(credentials_path) + if path is None: + logger.warning("GOOGLE_APPLICATION_CREDENTIALS is not configured; session not stored") + return None + if not getattr(credentials,"refresh_token",None) or not getattr(credentials,"client_id",None): + return None + try: + path.parent.mkdir(parents=True,exist_ok=True) + tmp=path.with_name(path.name+".tmp") + tmp.write_text(json.dumps(_authorized_user_adc(credentials),indent=2)+"\n",encoding="utf-8") + tmp.replace(path) + try: + os.chmod(path,0o600) + except OSError: + pass + except OSError as e: + logger.warning("could not persist Google authorized session: %s",e) + return None + return path + + def load_credentials(credentials_path=None,scopes=None): """Build scoped ADC credentials and refresh them once. Never prompts.""" path=resolve_credentials_path(credentials_path) @@ -108,10 +180,11 @@ def load_credentials(credentials_path=None,scopes=None): raise except Exception as e: raise SheetsAuthError(f"Google credential refresh failed: {e}") + store_authorized_session(credentials,credentials_path) return credentials -def ensure_fresh(credentials): +def ensure_fresh(credentials,credentials_path=None): """Refresh only when the token has actually gone stale — not on every call.""" if credentials is None: raise SheetsAuthError("Google credentials are not initialised") @@ -121,6 +194,7 @@ def ensure_fresh(credentials): credentials.refresh(Request()) except Exception as e: raise SheetsAuthError(f"Google credential refresh failed: {e}") + store_authorized_session(credentials,credentials_path) return credentials diff --git a/backend/g_sheet/store_session.py b/backend/g_sheet/store_session.py new file mode 100644 index 0000000..67672b5 --- /dev/null +++ b/backend/g_sheet/store_session.py @@ -0,0 +1,78 @@ +"""Capture a Google authorized_user session into credentials/. + +Run on a machine with a browser (Windows/macOS). Copy the resulting JSON to +Linux prod — the API never opens a browser. + + cd backend + python g_sheet/store_session.py + python g_sheet/store_session.py --force # re-consent, mint a new refresh token +""" + +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + +# `python g_sheet/store_session.py` puts this file's dir on sys.path, not backend/. +_BACKEND=Path(__file__).resolve().parent.parent +if str(_BACKEND) not in sys.path: + sys.path.insert(0,str(_BACKEND)) + +from g_sheet.plugins import ( + SCOPES, + SheetsAuthError, + load_credentials, + resolve_client_secret_path, + resolve_credentials_path, + store_authorized_session, +) + + +def _authorize_browser(client_secret_path): + try: + from google_auth_oauthlib.flow import InstalledAppFlow + except ImportError as e: + raise SystemExit( + "google-auth-oauthlib is required for browser login. " + "pip install google-auth-oauthlib==1.4.0" + ) from e + if client_secret_path is None or not client_secret_path.exists(): + raise SystemExit( + "OAuth client file not found. Set GOOGLE_OAUTH_CLIENT_ID_FILE " + "(credentials/client_secret.json)." + ) + flow=InstalledAppFlow.from_client_secrets_file(str(client_secret_path),SCOPES) + return flow.run_local_server(port=0,prompt="consent",access_type="offline") + + +def main(argv=None): + parser=argparse.ArgumentParser(description="Store a Google authorized_user session on disk.") + parser.add_argument( + "--force", + action="store_true", + help="Ignore the existing ADC file and open a browser consent screen.", + ) + args=parser.parse_args(argv) + path=resolve_credentials_path() + if path is None: + raise SystemExit("GOOGLE_APPLICATION_CREDENTIALS is not set.") + credentials=None + if not args.force: + try: + credentials=load_credentials() + except SheetsAuthError as e: + print(f"existing session unusable ({e}); opening browser…",file=sys.stderr) + if credentials is None: + credentials=_authorize_browser(resolve_client_secret_path()) + stored=store_authorized_session(credentials) + else: + stored=path + if stored is None: + raise SystemExit("failed to write the authorized session file") + print(f"stored authorized session: {stored}") + return 0 + + +if __name__=="__main__": + raise SystemExit(main()) diff --git a/backend/g_sheet/views.py b/backend/g_sheet/views.py index 80e1fe7..88df88e 100644 --- a/backend/g_sheet/views.py +++ b/backend/g_sheet/views.py @@ -85,13 +85,13 @@ class SheetClient(Sheet): their own client. """ if self.client is not None: - return ensure_fresh(self.credentials) and self.client + return ensure_fresh(self.credentials,self.credentials_path) and self.client with self._lock: if self.client is None: self.credentials=load_credentials(self.credentials_path,self.scopes) self.client=build_sheets_client(self.credentials) else: - ensure_fresh(self.credentials) + ensure_fresh(self.credentials,self.credentials_path) return self.client async def _values(self): diff --git a/backend/inbox/models.py b/backend/inbox/models.py index 15ab605..5fec8ce 100644 --- a/backend/inbox/models.py +++ b/backend/inbox/models.py @@ -820,7 +820,7 @@ class Inbox_Messages(SQLModel, table=True): ): statement = cls._apply_filters( - select(cls).order_by(cls.created_at.desc(),cls.id.desc()), + select(cls).order_by(cls.message_received_time.desc(),cls.id.desc()), search, isread, application_status, assigned, is_duplicate, no_suggestions, processing_state, ) diff --git a/backend/job/candidate/models.py b/backend/job/candidate/models.py index 62ac1c6..4f03884 100644 --- a/backend/job/candidate/models.py +++ b/backend/job/candidate/models.py @@ -864,7 +864,10 @@ class Interviews(SQLModel, table=True): def _with_inbox_message(cls): from inbox.models import Inbox - return selectinload(cls.inbox).selectinload(Inbox.messages) + return selectinload(cls.inbox).options( + selectinload(Inbox.messages), + selectinload(Inbox.user), + ) @classmethod async def get_interview_by_id(cls, session: AsyncSession, record_id): diff --git a/backend/job/interviews/serializers.py b/backend/job/interviews/serializers.py index 7a2bebd..afb4d0d 100644 --- a/backend/job/interviews/serializers.py +++ b/backend/job/interviews/serializers.py @@ -1,6 +1,7 @@ def serialize_interview(row, *, job_title=None) -> dict: inbox=getattr(row,"inbox",None) user=getattr(inbox,"user",None) if inbox else None + uid=getattr(user,"id",None) or getattr(row,"user_id",None) return { "id": str(row.id), "inbox_id": row.inbox_id, @@ -9,6 +10,7 @@ def serialize_interview(row, *, job_title=None) -> dict: "interview_type": row.interview_type, "interview_status": row.interview_status, "candidate_name": user.name if user else None, + "user_id": str(uid) if uid else None, "job_title": job_title or None, "graph_event_id": row.graph_event_id or None, "web_link": row.web_link or None, diff --git a/backend/requirements.txt b/backend/requirements.txt index 39032c9..44436be 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -50,6 +50,7 @@ openpyxl==3.1.5 google-api-python-client==2.198.0 # Sheets v4 client in g_sheet/plugins.py google-auth==2.56.3 # ADC + refresh in g_sheet/plugins.py google-auth-httplib2==0.4.1 # transport used by googleapiclient +google-auth-oauthlib==1.4.0 # InstalledAppFlow in g_sheet/store_session.py only # --- AWS S3 (s3/) ---------------------------------------------------------- boto3==1.40.49 # S3 PutObject / DeleteObject in s3/plugins.py diff --git a/docker-compose.yml b/docker-compose.yml index d1d34fc..bd40d62 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -51,13 +51,18 @@ x-backend-env: &backend-env # Shared CV storage. Named volume so API + workers see the same files. # Optional docker-compose.dev.yml remounts ./backend/inbox/decoded_attachments. +# Google authorized_user ADC is bind-mounted so a refresh-token rotation on +# disk survives image rebuilds. Copy the JSON onto the host; do not bake it. x-attachments: &attachments - attachments-data:/app/inbox/decoded_attachments + - ./backend/credentials:/app/credentials x-backend-service: &backend-service build: *backend-build image: hrms-backend:local working_dir: /app + volumes: + - ./backend/credentials:/app/credentials env_file: # backend/.env is the source of truth (plain DB_* + PROD_ENV). - ./backend/.env diff --git a/frontend/src/api/interviews.js b/frontend/src/api/interviews.js index 119a76d..cea80ee 100644 --- a/frontend/src/api/interviews.js +++ b/frontend/src/api/interviews.js @@ -95,10 +95,12 @@ export function update(interviewId, { instant, type, status } = {}) { * render. * * serialize_interview returns the interview columns plus optional calendar sync - * fields (`graph_event_id`, `web_link`) and `job_title`. Meeting mode, duration, - * interviewer list and feedback verdict still have no source — they stay absent - * rather than defaulted. Screens that already hydrate `jobTitle` from the - * application row keep doing so as a fallback. + * fields (`graph_event_id`, `web_link`), `job_title`, and `user_id` (inbox.user + * id, falling back to the denorm column). Meeting mode, duration, interviewer + * list and feedback verdict still have no source — they stay absent rather than + * defaulted. Screens that already hydrate `jobTitle` from the application row + * keep doing so as a fallback; `userId` prefers the interview row so unassigned + * applications still open a profile. */ export function toInterviewView(row) { const whenRaw = row.interview_date || row.interview_time @@ -111,6 +113,7 @@ export function toInterviewView(row) { status: row.interview_status || 'Scheduled', when: when && !Number.isNaN(when.getTime()) ? when : null, jobTitle: row.job_title || null, + userId: row.user_id ?? null, graphEventId: row.graph_event_id || null, webLink: row.web_link || null, } diff --git a/frontend/src/screens/Calendar.jsx b/frontend/src/screens/Calendar.jsx index 0f0c1d8..51286f4 100644 --- a/frontend/src/screens/Calendar.jsx +++ b/frontend/src/screens/Calendar.jsx @@ -71,8 +71,9 @@ export default function Calendar() { }, }) - /* Job title and the candidate's user id are not on the interview row; the - application supplies both. One extra request for the whole screen. */ + /* Job title is hydrated from the pipeline board; user id prefers the + interview row so unassigned applications (absent from the board) still + open a profile. One extra request for the whole screen. */ const appsQuery = useApplications() const appByInbox = useMemo(() => byInboxId(appsQuery.data), [appsQuery.data]) @@ -84,7 +85,7 @@ export default function Calendar() { return { ...iv, jobTitle: iv.jobTitle || app?.jobTitle || null, - userId: app?.userId ?? null, + userId: iv.userId ?? app?.userId ?? null, } }), [monthQuery.data, appByInbox], diff --git a/frontend/src/screens/Interviews.jsx b/frontend/src/screens/Interviews.jsx index eb86c3c..056978a 100644 --- a/frontend/src/screens/Interviews.jsx +++ b/frontend/src/screens/Interviews.jsx @@ -103,7 +103,7 @@ export default function Interviews() { return { ...iv, jobTitle: iv.jobTitle || app?.jobTitle || null, - userId: app?.userId ?? null, + userId: iv.userId ?? app?.userId ?? null, } }, [appByInbox],