From 64079c1724c9daee14f1646b73f4519aed58a1ec Mon Sep 17 00:00:00 2001 From: orohi Date: Sun, 26 Jul 2026 05:15:37 +0300 Subject: [PATCH] Fix naive/aware datetime comparison for user expiration. Co-authored-by: Cursor --- app.py | 16 ++++++++------ managers/user_expiration.py | 43 ++++++++++++++++++++++++++++++------- 2 files changed, 44 insertions(+), 15 deletions(-) diff --git a/app.py b/app.py index 897999b..fa78f39 100644 --- a/app.py +++ b/app.py @@ -1855,16 +1855,18 @@ async def periodic_background_tasks(): if uid not in to_disable_uids: to_disable_uids.append(uid) - # Check expiration date - exp_str = u.get('expiration_date') - if exp_str and u.get('enabled', True): + # Check expiration date (naive/aware-safe) + if u.get('enabled', True): try: - exp_date = datetime.fromisoformat(exp_str) - if now > exp_date: - logger.info(f"Subscription expired for user {u['username']} (expired at {exp_str})") + from managers.user_expiration import user_is_expired + if user_is_expired(u, now=now): + logger.info( + f"Subscription expired for user {u['username']} " + f"(expired at {u.get('expiration_date')})" + ) if uid not in to_disable_uids: to_disable_uids.append(uid) - except: + except Exception: pass save_data(curr_data) diff --git a/managers/user_expiration.py b/managers/user_expiration.py index cd25a9a..276199c 100644 --- a/managers/user_expiration.py +++ b/managers/user_expiration.py @@ -2,20 +2,44 @@ from __future__ import annotations -from datetime import datetime, timedelta -from typing import Optional +from datetime import datetime, timedelta, timezone +from typing import Optional, Union + + +def _parse_dt(value: Union[str, datetime, None]) -> Optional[datetime]: + if value is None or value == '': + return None + if isinstance(value, datetime): + return value + text = str(value).strip() + if not text: + return None + if text.endswith('Z'): + text = text[:-1] + '+00:00' + try: + return datetime.fromisoformat(text) + except Exception: + return None + + +def _as_utc(dt: datetime) -> datetime: + """Normalize naive/aware datetimes for safe comparison.""" + if dt.tzinfo is None: + # Treat naive wall time as local, then convert to UTC. + return dt.astimezone(timezone.utc) + return dt.astimezone(timezone.utc) def user_is_expired(user: dict, *, now: Optional[datetime] = None) -> bool: """True when absolute expiration_date is in the past.""" - exp_str = user.get('expiration_date') - if not exp_str: + exp_date = _parse_dt(user.get('expiration_date')) + if not exp_date: return False + stamp = now if now is not None else datetime.now().astimezone() try: - exp_date = datetime.fromisoformat(str(exp_str)) + return _as_utc(stamp) > _as_utc(exp_date) except Exception: return False - return (now or datetime.now()) > exp_date def maybe_start_user_expiration(data: dict, user_id: str, *, now: Optional[datetime] = None) -> Optional[str]: @@ -35,8 +59,11 @@ def maybe_start_user_expiration(data: dict, user_id: str, *, now: Optional[datet days = int(user.get('expiration_days') or 0) if days <= 0: return None - stamp = now or datetime.now() + stamp = now if now is not None else datetime.now() expires = stamp + timedelta(days=days) - iso = expires.isoformat() + # Store without tz so UI/local comparisons stay consistent with manual dates + if expires.tzinfo is not None: + expires = expires.replace(tzinfo=None) + iso = expires.isoformat(timespec='seconds') user['expiration_date'] = iso return iso