recieved time implemented
parent
e7907a194e
commit
8380be0d8a
|
|
@ -13,6 +13,7 @@
|
|||
**/.env
|
||||
**/.env.*
|
||||
!**/.env.example
|
||||
backend/credentials/*.json
|
||||
|
||||
**/__pycache__/
|
||||
**/*.py[cod]
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
|
|||
Loading…
Reference in New Issue