recieved time implemented

pull/39/head
ahmed.mujtaba 2026-08-31 15:51:52 +05:00
parent e7907a194e
commit 8380be0d8a
15 changed files with 192 additions and 18 deletions

View File

@ -13,6 +13,7 @@
**/.env **/.env
**/.env.* **/.env.*
!**/.env.example !**/.env.example
backend/credentials/*.json
**/__pycache__/ **/__pycache__/
**/*.py[cod] **/*.py[cod]

3
.gitignore vendored
View File

@ -60,6 +60,9 @@ frontend/dist/
# Uploaded content — user data, never in git # Uploaded content — user data, never in git
backend/uploads/ backend/uploads/
# Google OAuth ADC / Desktop client secrets — never commit
backend/credentials/*.json
**.pdf **.pdf
# Per-machine alembic autogen revisions only — the old bare `**_**_**.py` # Per-machine alembic autogen revisions only — the old bare `**_**_**.py`
# also swallowed any module with two underscores (e.g. test_talent_plugins.py). # also swallowed any module with two underscores (e.g. test_talent_plugins.py).

View File

View File

@ -1,8 +1,11 @@
{ {
"account": "", "type": "authorized_user",
"client_id": "679334897177-ufal3rogbg8cgm3qcren6pv20phqd7nl.apps.googleusercontent.com", "client_id": "679334897177-ufal3rogbg8cgm3qcren6pv20phqd7nl.apps.googleusercontent.com",
"client_secret": "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", "client_secret": "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",
"refresh_token": "1//03C8LMkk-9mSyCgYIARAAGAMSNwF-L9IrAcPhShsp3cDeprSlqI-P6lArpmbyzu-PcKiLfQ5gC3H_MEk930IaKfewy3cxP3T0Oo8", "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"
} }

View File

@ -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 Auth reuses the credentials already on disk (authorized_user ADC + a valid refresh
token). Nothing here launches a browser, runs InstalledAppFlow, or reads stdin. 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 from __future__ import annotations
import json
import logging import logging
import os import os
import random import random
@ -31,12 +34,11 @@ from g_sheet.enums import (
MonthNormalisation, MonthNormalisation,
) )
load_dotenv()
logger=logging.getLogger("g_sheet.plugins") logger=logging.getLogger("g_sheet.plugins")
# backend/ — GOOGLE_APPLICATION_CREDENTIALS is stored relative to it ("credentials/..."). # backend/ — GOOGLE_APPLICATION_CREDENTIALS is stored relative to it ("credentials/...").
ROOT=Path(__file__).resolve().parent.parent ROOT=Path(__file__).resolve().parent.parent
load_dotenv(ROOT/".env")
SCOPES=[ SCOPES=[
"https://www.googleapis.com/auth/spreadsheets", "https://www.googleapis.com/auth/spreadsheets",
@ -47,6 +49,9 @@ SPREADSHEET_ID=os.getenv("SPREADSHEET_ID")
SPREADSHEET_NAME=os.getenv("SPREADSHEET_NAME") SPREADSHEET_NAME=os.getenv("SPREADSHEET_NAME")
SPREADSHEET_URL=os.getenv("SPREADSHEET_URL") SPREADSHEET_URL=os.getenv("SPREADSHEET_URL")
GOOGLE_APPLICATION_CREDENTIALS=os.getenv("GOOGLE_APPLICATION_CREDENTIALS") 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. # 429 and 5xx are transient; every other 4xx is a bad request that a retry repeats.
RETRY_ATTEMPTS=3 RETRY_ATTEMPTS=3
@ -94,6 +99,73 @@ def resolve_credentials_path(credentials_path=None):
return path 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): def load_credentials(credentials_path=None,scopes=None):
"""Build scoped ADC credentials and refresh them once. Never prompts.""" """Build scoped ADC credentials and refresh them once. Never prompts."""
path=resolve_credentials_path(credentials_path) path=resolve_credentials_path(credentials_path)
@ -108,10 +180,11 @@ def load_credentials(credentials_path=None,scopes=None):
raise raise
except Exception as e: except Exception as e:
raise SheetsAuthError(f"Google credential refresh failed: {e}") raise SheetsAuthError(f"Google credential refresh failed: {e}")
store_authorized_session(credentials,credentials_path)
return credentials 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.""" """Refresh only when the token has actually gone stale — not on every call."""
if credentials is None: if credentials is None:
raise SheetsAuthError("Google credentials are not initialised") raise SheetsAuthError("Google credentials are not initialised")
@ -121,6 +194,7 @@ def ensure_fresh(credentials):
credentials.refresh(Request()) credentials.refresh(Request())
except Exception as e: except Exception as e:
raise SheetsAuthError(f"Google credential refresh failed: {e}") raise SheetsAuthError(f"Google credential refresh failed: {e}")
store_authorized_session(credentials,credentials_path)
return credentials return credentials

View File

@ -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())

View File

@ -85,13 +85,13 @@ class SheetClient(Sheet):
their own client. their own client.
""" """
if self.client is not None: 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: with self._lock:
if self.client is None: if self.client is None:
self.credentials=load_credentials(self.credentials_path,self.scopes) self.credentials=load_credentials(self.credentials_path,self.scopes)
self.client=build_sheets_client(self.credentials) self.client=build_sheets_client(self.credentials)
else: else:
ensure_fresh(self.credentials) ensure_fresh(self.credentials,self.credentials_path)
return self.client return self.client
async def _values(self): async def _values(self):

View File

@ -820,7 +820,7 @@ class Inbox_Messages(SQLModel, table=True):
): ):
statement = cls._apply_filters( 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, search, isread, application_status, assigned, is_duplicate,
no_suggestions, processing_state, no_suggestions, processing_state,
) )

View File

@ -864,7 +864,10 @@ class Interviews(SQLModel, table=True):
def _with_inbox_message(cls): def _with_inbox_message(cls):
from inbox.models import Inbox from inbox.models import Inbox
return selectinload(cls.inbox).selectinload(Inbox.messages) return selectinload(cls.inbox).options(
selectinload(Inbox.messages),
selectinload(Inbox.user),
)
@classmethod @classmethod
async def get_interview_by_id(cls, session: AsyncSession, record_id): async def get_interview_by_id(cls, session: AsyncSession, record_id):

View File

@ -1,6 +1,7 @@
def serialize_interview(row, *, job_title=None) -> dict: def serialize_interview(row, *, job_title=None) -> dict:
inbox=getattr(row,"inbox",None) inbox=getattr(row,"inbox",None)
user=getattr(inbox,"user",None) if inbox else None user=getattr(inbox,"user",None) if inbox else None
uid=getattr(user,"id",None) or getattr(row,"user_id",None)
return { return {
"id": str(row.id), "id": str(row.id),
"inbox_id": row.inbox_id, "inbox_id": row.inbox_id,
@ -9,6 +10,7 @@ def serialize_interview(row, *, job_title=None) -> dict:
"interview_type": row.interview_type, "interview_type": row.interview_type,
"interview_status": row.interview_status, "interview_status": row.interview_status,
"candidate_name": user.name if user else None, "candidate_name": user.name if user else None,
"user_id": str(uid) if uid else None,
"job_title": job_title or None, "job_title": job_title or None,
"graph_event_id": row.graph_event_id or None, "graph_event_id": row.graph_event_id or None,
"web_link": row.web_link or None, "web_link": row.web_link or None,

View File

@ -50,6 +50,7 @@ openpyxl==3.1.5
google-api-python-client==2.198.0 # Sheets v4 client in g_sheet/plugins.py 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==2.56.3 # ADC + refresh in g_sheet/plugins.py
google-auth-httplib2==0.4.1 # transport used by googleapiclient 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/) ---------------------------------------------------------- # --- AWS S3 (s3/) ----------------------------------------------------------
boto3==1.40.49 # S3 PutObject / DeleteObject in s3/plugins.py boto3==1.40.49 # S3 PutObject / DeleteObject in s3/plugins.py

View File

@ -51,13 +51,18 @@ x-backend-env: &backend-env
# Shared CV storage. Named volume so API + workers see the same files. # Shared CV storage. Named volume so API + workers see the same files.
# Optional docker-compose.dev.yml remounts ./backend/inbox/decoded_attachments. # 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 x-attachments: &attachments
- attachments-data:/app/inbox/decoded_attachments - attachments-data:/app/inbox/decoded_attachments
- ./backend/credentials:/app/credentials
x-backend-service: &backend-service x-backend-service: &backend-service
build: *backend-build build: *backend-build
image: hrms-backend:local image: hrms-backend:local
working_dir: /app working_dir: /app
volumes:
- ./backend/credentials:/app/credentials
env_file: env_file:
# backend/.env is the source of truth (plain DB_* + PROD_ENV). # backend/.env is the source of truth (plain DB_* + PROD_ENV).
- ./backend/.env - ./backend/.env

View File

@ -95,10 +95,12 @@ export function update(interviewId, { instant, type, status } = {}) {
* render. * render.
* *
* serialize_interview returns the interview columns plus optional calendar sync * serialize_interview returns the interview columns plus optional calendar sync
* fields (`graph_event_id`, `web_link`) and `job_title`. Meeting mode, duration, * fields (`graph_event_id`, `web_link`), `job_title`, and `user_id` (inbox.user
* interviewer list and feedback verdict still have no source they stay absent * id, falling back to the denorm column). Meeting mode, duration, interviewer
* rather than defaulted. Screens that already hydrate `jobTitle` from the * list and feedback verdict still have no source they stay absent rather than
* application row keep doing so as a fallback. * 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) { export function toInterviewView(row) {
const whenRaw = row.interview_date || row.interview_time const whenRaw = row.interview_date || row.interview_time
@ -111,6 +113,7 @@ export function toInterviewView(row) {
status: row.interview_status || 'Scheduled', status: row.interview_status || 'Scheduled',
when: when && !Number.isNaN(when.getTime()) ? when : null, when: when && !Number.isNaN(when.getTime()) ? when : null,
jobTitle: row.job_title || null, jobTitle: row.job_title || null,
userId: row.user_id ?? null,
graphEventId: row.graph_event_id || null, graphEventId: row.graph_event_id || null,
webLink: row.web_link || null, webLink: row.web_link || null,
} }

View File

@ -71,8 +71,9 @@ export default function Calendar() {
}, },
}) })
/* Job title and the candidate's user id are not on the interview row; the /* Job title is hydrated from the pipeline board; user id prefers the
application supplies both. One extra request for the whole screen. */ interview row so unassigned applications (absent from the board) still
open a profile. One extra request for the whole screen. */
const appsQuery = useApplications() const appsQuery = useApplications()
const appByInbox = useMemo(() => byInboxId(appsQuery.data), [appsQuery.data]) const appByInbox = useMemo(() => byInboxId(appsQuery.data), [appsQuery.data])
@ -84,7 +85,7 @@ export default function Calendar() {
return { return {
...iv, ...iv,
jobTitle: iv.jobTitle || app?.jobTitle || null, jobTitle: iv.jobTitle || app?.jobTitle || null,
userId: app?.userId ?? null, userId: iv.userId ?? app?.userId ?? null,
} }
}), }),
[monthQuery.data, appByInbox], [monthQuery.data, appByInbox],

View File

@ -103,7 +103,7 @@ export default function Interviews() {
return { return {
...iv, ...iv,
jobTitle: iv.jobTitle || app?.jobTitle || null, jobTitle: iv.jobTitle || app?.jobTitle || null,
userId: app?.userId ?? null, userId: iv.userId ?? app?.userId ?? null,
} }
}, },
[appByInbox], [appByInbox],