Coverage for src/lexigram/auth/models/session.py: 74%

38 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-26 00:58 +0800

1"""Session model for Lexigram Auth.""" 

2 

3from __future__ import annotations 

4 

5from dataclasses import dataclass, field, replace 

6from datetime import UTC, datetime, timedelta 

7from typing import Any 

8 

9 

10@dataclass(frozen=True) 

11class UserSession: 

12 """User session data model. 

13 

14 Tracks a specific device/session for a user. 

15 """ 

16 

17 session_id: str 

18 user_id: str 

19 device_id: str 

20 ip_address: str | None = None 

21 user_agent: str | None = None 

22 geo_location: dict[str, Any] = field(default_factory=dict) 

23 fingerprint: dict[str, Any] = field(default_factory=dict) 

24 is_active: bool = True 

25 expires_at: datetime | None = None 

26 last_active_at: datetime | None = None 

27 mfa_verified_at: datetime | None = None 

28 created_at: datetime | None = None 

29 updated_at: datetime | None = None 

30 token_jti: str | None = None # JTI (JWT ID) of the associated access token 

31 

32 def is_expired(self) -> bool: 

33 """Return ``True`` if this session has passed its expiry time.""" 

34 if not self.expires_at: 

35 return False 

36 

37 # Ensure we compare aware datetimes 

38 now = datetime.now(UTC) if self.expires_at.tzinfo else datetime.now() 

39 return self.expires_at < now 

40 

41 def is_valid(self) -> bool: 

42 """Return ``True`` if the session is active and has not expired. 

43 

44 A session is considered valid when :attr:`is_active` is ``True`` 

45 *and* :meth:`is_expired` returns ``False``. 

46 """ 

47 return self.is_active and not self.is_expired() 

48 

49 def remaining_ttl(self) -> timedelta | None: 

50 """Return the time remaining until this session expires. 

51 

52 Returns: 

53 A :class:`~datetime.timedelta` representing time until expiry, 

54 or ``None`` when no expiry is configured. Returns 

55 ``timedelta(0)`` (zero duration) when the session has already 

56 expired. 

57 """ 

58 if self.expires_at is None: 

59 return None 

60 

61 now = datetime.now(UTC) if self.expires_at.tzinfo else datetime.now() 

62 delta = self.expires_at - now 

63 return delta if delta.total_seconds() > 0 else timedelta(0) 

64 

65 def refresh( 

66 self, 

67 expires_at: datetime, 

68 last_active_at: datetime | None = None, 

69 ) -> UserSession: 

70 """Return a new :class:`UserSession` with an updated expiry time. 

71 

72 Because :class:`UserSession` is a frozen dataclass, this method 

73 returns a **new** instance rather than mutating the current one. 

74 

75 Args: 

76 expires_at: The new expiry timestamp for the refreshed session. 

77 last_active_at: Optional new last-active timestamp. Defaults 

78 to ``datetime.now(UTC)`` when the session uses aware 

79 datetimes, else ``datetime.now()``. 

80 

81 Returns: 

82 A copy of this session with :attr:`expires_at` (and 

83 :attr:`last_active_at`) updated. 

84 """ 

85 if last_active_at is None: 

86 last_active_at = datetime.now(UTC) if expires_at.tzinfo else datetime.now() 

87 return replace(self, expires_at=expires_at, last_active_at=last_active_at) 

88 

89 

90__all__ = [ 

91 "UserSession", 

92]