Compare commits

...

2 Commits

Author SHA1 Message Date
ahmed.mujtaba 3933ba55d6 pkt tiemzone no mateter the briowser
Deploy to S3 / deploy (push) Successful in 39s Details
2026-09-09 20:37:20 +05:00
ahmed.mujtaba 07faee1180 . 2026-09-09 20:26:28 +05:00
7 changed files with 215 additions and 30 deletions

View File

@ -422,6 +422,7 @@ class FormData(SQLModel, table=True):
async def list_by_emails(cls, session: AsyncSession, emails):
"""Sheet applicants for these addresses. Promoted rows are omitted —
those already live on manual_upload_candidate."""
from g_sheet.plugins import form_applied_at_iso
from job.job_post.models import JobPosts
lowers = sorted({(e or "").strip().lower() for e in (emails or []) if (e or "").strip()})
@ -449,10 +450,7 @@ class FormData(SQLModel, table=True):
"job_post_id": str(job_id) if job_id else None,
"job_title": title or rec.position_applied_for or None,
"status": rec.processing_state or None,
"applied_at": (
rec.entry_date.isoformat() if rec.entry_date
else (rec.created_at.isoformat() if rec.created_at else None)
),
"applied_at": form_applied_at_iso(rec),
})
return rows

View File

@ -444,6 +444,9 @@ def stringify_rows(rows):
# -- FormData mapping ------------------------------------------------------
_TIME_RE=re.compile(r"(\d{1,2}:\d{2}\s*(?:[AaPp][Mm])?)")
_APPLIED_HMS_RE=re.compile(
r"(?P<h>\d{1,2}):(?P<m>\d{2})(?::(?P<s>\d{2}))?\s*(?P<ap>[AaPp][Mm])?",
)
_DAY_ORDINAL_RE=re.compile(r"\b(\d+)(st|nd|rd|th)\b",re.I)
_DIGIT_RE=re.compile(r"\d")
_NUMERIC_DATE_RE=re.compile(r"^(\d{1,2})([/\-.])(\d{1,2})\2(\d{2,4})$")
@ -589,6 +592,58 @@ def parse_date_time(value):
return parsed,time_str
def _hms_from_text(text):
if not text:
return 0,0,0
match=_APPLIED_HMS_RE.search(str(text).strip())
if not match:
return 0,0,0
hours=int(match.group("h"))
minutes=int(match.group("m"))
seconds=int(match.group("s") or 0)
ap=(match.group("ap") or "").lower()
if ap=="pm" and hours<12:
hours+=12
if ap=="am" and hours==12:
hours=0
return min(hours,23),minutes,seconds
def form_applied_at_iso(row):
"""Wall-clock apply time for history. Not UTC midnight and not import time.
Google Form Timestamp is the source of truth (M/D/YYYY). entry_date is stored
as timestamptz at 00:00+00:00, so isoformat() would send `T00:00:00+00:00`
and drop entry_time the UI then paints 12:00am or shifts +5h.
"""
raw=getattr(row,"raw_record",None)
ts=None
if isinstance(raw,dict):
for key,val in raw.items():
if str(key).strip().lower()=="timestamp" and val not in (None,""):
ts=str(val).strip()
break
if ts:
parsed,_=parse_date_time(ts)
if parsed is not None:
hours,minutes,seconds=_hms_from_text(ts)
return (
f"{parsed.year:04d}-{parsed.month:02d}-{parsed.day:02d}"
f"T{hours:02d}:{minutes:02d}:{seconds:02d}"
)
entry_date=getattr(row,"entry_date",None)
if entry_date is not None:
hours,minutes,seconds=_hms_from_text(getattr(row,"entry_time",None))
return (
f"{entry_date.year:04d}-{entry_date.month:02d}-{entry_date.day:02d}"
f"T{hours:02d}:{minutes:02d}:{seconds:02d}"
)
created=getattr(row,"created_at",None)
if created is None:
return None
return created.isoformat()
def parse_age(value):
"""(int|None, raw|None) — first digit run if 0 < n < 100, always keep the raw."""
if value is None:

View File

@ -8,12 +8,13 @@ from datetime import datetime,timezone
import redis.asyncio as redis
from dotenv import load_dotenv
from taskiq import TaskiqEvents
from db_setup import session_scope
from inbox.models import MailboxSyncRun
from inbox.views import Email
from taskiq_management.broker_setup import MAX_RETRIES,RETRY_DELAY
from taskiq_management.mailbox_sync_broker_setup import mailbox_sync_broker
from taskiq_management.mailbox_sync_broker_setup import MAILBOX_SYNC_QUEUE_NAME,mailbox_sync_broker
from taskiq_management.middleware import PermanentTaskError
load_dotenv()
@ -22,6 +23,21 @@ logger=logging.getLogger("inbox.mailbox_sync")
REDIS_URL=os.getenv("REDIS_URL","redis://localhost:6379/0")
_LOCK_KEY="inbox:mailbox_sync:lock"
_LOCK_TTL=900
_CONSUMER_GROUP=os.getenv("TASKIQ_CONSUMER_GROUP","taskiq")
# taskiq-redis listen() skips XAUTOCLAIM while this key exists. redis-py Lock has
# no TTL by default, so SIGKILL (compose rebuild) leaves it forever and the Sync
# button stays on a running row nobody will finish.
_AUTOCLAIM_KEY=f"autoclaim:{_CONSUMER_GROUP}:{MAILBOX_SYNC_QUEUE_NAME}"
@mailbox_sync_broker.on_event(TaskiqEvents.WORKER_STARTUP)
async def _drop_stale_autoclaim(_state) -> None:
client=redis.from_url(REDIS_URL,decode_responses=True)
try:
if await client.delete(_AUTOCLAIM_KEY):
logger.warning("dropped stale autoclaim lock %s",_AUTOCLAIM_KEY)
finally:
await client.aclose()
async def _fail(run_id:str,error:str) -> dict:
@ -49,13 +65,25 @@ async def sync_mailbox(run_id:str) -> dict:
try:
acquired=await client.set(_LOCK_KEY,run_id,nx=True,ex=_LOCK_TTL)
if not acquired:
return await _fail(run_id,"another mailbox sync is already running")
holder=await client.get(_LOCK_KEY)
# Crash/restart redelivers the same run_id while the TTL lock is
# still set. Failing that as "another sync" strands the lock until
# expiry and every later click also bounces.
if holder==run_id:
await client.expire(_LOCK_KEY,_LOCK_TTL)
logger.warning("mailbox sync %s reclaimed its own stale lock",run_id)
else:
logger.warning("mailbox sync %s skipped: lock held by %s",run_id,holder)
return await _fail(run_id,"another mailbox sync is already running")
try:
async with session_scope() as session:
row=await MailboxSyncRun.get_by_id(session,run_id)
if not row:
raise PermanentTaskError(f"sync run {run_id} not found")
if row.status in ("completed","failed"):
logger.info("mailbox sync %s already %s, skipping",run_id,row.status)
return {"status":row.status,"error":row.error}
await MailboxSyncRun.update_run(session,run_id,{
"status":"running",
"started_at":datetime.now(timezone.utc),

View File

@ -3,7 +3,7 @@ import os
from shlex import join
import uuid
from datetime import datetime, timezone
from typing import Any, List, Optional
from typing import Any, ClassVar, List, Optional
from dotenv import load_dotenv
from fastapi import HTTPException
@ -2208,6 +2208,11 @@ class MailboxSyncRun(SQLModel, table=True):
started_at: datetime | None = Field(default=None, sa_type=DateTime(timezone=True))
finished_at: datetime | None = Field(default=None, sa_type=DateTime(timezone=True))
# Worker should pick a queued run in seconds. Running past this means the
# consumer died (compose rebuild) and Redis never reclaimed the message.
QUEUED_STALE_SECONDS: ClassVar[int] = 180
RUNNING_STALE_SECONDS: ClassVar[int] = 2700
@staticmethod
def _as_uuid(record_id) -> uuid.UUID | None:
if record_id in (None, ""):
@ -2225,6 +2230,59 @@ class MailboxSyncRun(SQLModel, table=True):
result = await session.execute(select(cls).where(cls.id == uid))
return result.scalars().first()
@staticmethod
def _age_seconds(stamp, now):
if stamp is None:
return None
if getattr(stamp, "tzinfo", None) is None:
stamp = stamp.replace(tzinfo=timezone.utc)
if now.tzinfo is None:
now = now.replace(tzinfo=timezone.utc)
return (now - stamp).total_seconds()
@classmethod
def is_stale(cls, row, now=None) -> bool:
"""True when a queued/running row has outlived a live worker."""
now = now or datetime.now(timezone.utc)
status = getattr(row, "status", None)
if status == "queued":
age = cls._age_seconds(getattr(row, "created_at", None), now)
return age is None or age >= cls.QUEUED_STALE_SECONDS
if status == "running":
age = cls._age_seconds(
getattr(row, "started_at", None) or getattr(row, "created_at", None),
now,
)
return age is None or age >= cls.RUNNING_STALE_SECONDS
return False
@classmethod
async def fail_stale(cls, session: AsyncSession, *, now=None, commit: bool = True):
"""Mark stranded queued/running rows failed so Sync is clickable again."""
now = now or datetime.now(timezone.utc)
result = await session.execute(
select(cls).where(cls.status.in_(("queued", "running")))
)
failed = []
for row in result.scalars().all():
if not cls.is_stale(row, now):
continue
previous = row.status
row.status = "failed"
row.error = (
"Mailbox sync did not start. Click Sync to try again."
if previous == "queued"
else "Mailbox sync was interrupted. Click Sync to try again."
)
row.finished_at = now
session.add(row)
failed.append(row)
if failed and commit:
await session.commit()
for row in failed:
await session.refresh(row)
return failed
@classmethod
async def get_active(cls, session: AsyncSession):
result = await session.execute(

View File

@ -385,7 +385,9 @@ class Email:
"""Enqueue Outlook pull on the mailbox_sync queue; return the run row.
If a queued/running sync already exists, return it instead of stacking another.
A stranded row from a killed worker is failed first so Sync is clickable again.
"""
await MailboxSyncRun.fail_stale(self.session)
active=await MailboxSyncRun.get_active(self.session)
if active:
return serialize_mailbox_sync_run(active)
@ -412,6 +414,7 @@ class Email:
return serialize_mailbox_sync_run(row)
async def get_mailbox_sync(self,run_id=None):
await MailboxSyncRun.fail_stale(self.session)
if run_id:
row=await MailboxSyncRun.get_by_id(self.session,run_id)
if not row:

View File

@ -36,11 +36,14 @@ eq(instant.getTime(), Date.parse(GRAPH), 'toInstant is the UTC instant')
eq(toInstant(GRAPH_NAKED).getTime(), Date.parse(GRAPH), 'Graph without Z is still UTC')
eq(toInstant(GRAPH_OFFSET).getTime(), Date.parse(GRAPH), 'Graph +00:00 is UTC')
eq(fmtTime(wall), '7:52am', 'fmtTime(toDate) prints stored digits')
eq(fmtTime(instant), fmtTime(new Date(GRAPH)), 'fmtTime(toInstant) matches local clock')
eq(fmtTime(instant), '12:52pm', '7:52 UTC Date paints 12:52pm PKT')
eq(fmtTime(GRAPH), '12:52pm', '7:52 UTC string paints 12:52pm PKT')
eq(fmtTime(GRAPH_OFFSET), '12:52pm', '+00:00 paints PKT')
eq(fmtTime('2026-09-09T11:50:24Z'), '4:50pm', 'live Graph sample is 4:50pm PKT')
eq(fmtTime('2026-08-27T13:23:32'), '1:23pm', 'naive sheet ISO is PKT digits')
if (new Date().getTimezoneOffset() !== 0) {
eq(fmtTime(instant) === fmtTime(wall), false, 'non-UTC zone: instant display differs from wall-clock')
if (new Date().getTimezoneOffset() === -300) {
eq(fmtTime(wall), '7:52am', 'on a PKT machine wall-clock Date stays 7:52am')
}
if (failed) {

View File

@ -15,12 +15,9 @@ export function formatRole(name) {
time 7:30pm (12-hour, lowercase, no space)
both 3rd Sept 2026 - 7:30pm
API timestamps are year-month-day, then optional time, e.g.
2026-08-28 03:56:19.685066-07
2026-08-28T03:56:19.685066-07:00
2026-08-28
Display uses those numbers as written: 2026 = year, 08 = month (Aug),
28 = day, 03:56 = 3:56am. The timezone suffix is not applied. */
Painted in Pakistan Standard Time (Asia/Karachi, UTC+5, no DST).
Naive stamps (no Z / offset) are already PKT digits.
UTC instants (Z or +00:00) convert +5 to PKT. */
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sept', 'Oct', 'Nov', 'Dec']
const WEEKDAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']
const WEEKDAYS_SHORT = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
@ -53,14 +50,32 @@ export function toDate(value) {
}
const HAS_OFFSET = /[zZ]|[+-]\d{2}:?\d{2}$/
/** Recruiter clock. PKT has no DST — UTC+5 year-round. */
const DISPLAY_TZ = 'Asia/Karachi'
function pad2(n) {
return String(n).padStart(2, '0')
}
function pktParts(d) {
const out = {}
for (const p of new Intl.DateTimeFormat('en-GB', {
timeZone: DISPLAY_TZ,
year: 'numeric',
month: 'numeric',
day: 'numeric',
hour: 'numeric',
minute: 'numeric',
hourCycle: 'h23',
}).formatToParts(d)) {
if (p.type !== 'literal') out[p.type] = Number(p.value)
}
return out
}
/**
* Parse a UTC instant (Graph receivedDateTime, timestamptz) into a Date
* whose local clock matches the browser the same conversion Outlook does.
*
* toDate() is wall-clock: it prints the stored digits and ignores Z/+00:00.
* Graph always stores UTC, so that path shows 7:52 when Outlook shows 12:52
* in Pakistan (UTC+5). Only call this for fields that are actually UTC.
* Display uses Asia/Karachi (PKT, UTC+5), not the browser zone.
*/
export function toInstant(value) {
if (value == null || value === '') return null
@ -93,11 +108,34 @@ function ordinal(n) {
}
}
/** 3rd Sept 2026 */
/** Date to paint. Naive ISO digits are PKT; Z / +00:00 are UTC → PKT. */
export function toDisplayDate(value) {
if (value instanceof Date) {
return Number.isNaN(value.getTime()) ? null : value
}
if (value == null || value === '') return null
const s = String(value).trim()
if (!s) return null
if (HAS_OFFSET.test(s)) return toInstant(s) || toDate(s)
const m = s.match(API_TS)
if (m) {
const hour = m[4] != null ? Number(m[4]) : 0
const minute = m[5] != null ? Number(m[5]) : 0
const second = m[6] != null ? Number(m[6]) : 0
const iso = `${m[1]}-${m[2]}-${m[3]}T${pad2(hour)}:${pad2(minute)}:${pad2(second)}+05:00`
const d = new Date(iso)
if (!Number.isNaN(d.getTime())) return d
}
return toDate(s) || toInstant(s)
}
/** 3rd Sept 2026 (PKT calendar) */
export function fmtDate(value) {
const d = toDate(value)
const d = toDisplayDate(value)
if (!d) return ''
return `${ordinal(d.getDate())} ${MONTHS[d.getMonth()]} ${d.getFullYear()}`
const p = pktParts(d)
if (p.day == null) return ''
return `${ordinal(p.day)} ${MONTHS[p.month - 1]} ${p.year}`
}
/** Same as fmtDate — the platform uses one date spelling. */
@ -107,19 +145,21 @@ export function fmtShort(value) {
/** 7:30pm */
export function fmtTime(value) {
const d = toDate(value)
const d = toDisplayDate(value)
if (!d) return ''
let hours = d.getHours()
const minutes = d.getMinutes()
const p = pktParts(d)
if (p.hour == null) return ''
let hours = p.hour
const minutes = p.minute
const suffix = hours >= 12 ? 'pm' : 'am'
hours = hours % 12
if (hours === 0) hours = 12
return `${hours}:${String(minutes).padStart(2, '0')}${suffix}`
return `${hours}:${pad2(minutes)}${suffix}`
}
/** 3rd Sept 2026 - 7:30pm */
export function fmtDateTime(value, sep = ' - ') {
const d = toDate(value)
const d = toDisplayDate(value)
if (!d) return ''
return `${fmtDate(d)}${sep}${fmtTime(d)}`
}