Coverage for app / backend / src / couchers / models / postal_verification.py: 98%

42 statements  

« prev     ^ index     » next       coverage.py v7.13.2, created at 2026-02-03 06:18 +0000

1import enum 

2from datetime import datetime 

3from typing import TYPE_CHECKING 

4 

5from sqlalchemy import ( 

6 BigInteger, 

7 CheckConstraint, 

8 DateTime, 

9 Enum, 

10 ForeignKey, 

11 Index, 

12 Integer, 

13 String, 

14 func, 

15 text, 

16) 

17from sqlalchemy.ext.hybrid import hybrid_property 

18from sqlalchemy.orm import Mapped, mapped_column, relationship 

19from sqlalchemy.sql.elements import ColumnElement 

20 

21from couchers.models.base import Base 

22 

23if TYPE_CHECKING: 

24 from couchers.models.users import User 

25 

26 

27class PostalVerificationStatus(enum.Enum): 

28 # User has initiated, awaiting address confirmation 

29 pending_address_confirmation = enum.auto() 

30 # Address confirmed, postcard being sent 

31 in_progress = enum.auto() 

32 # Postcard sent, awaiting user to enter code 

33 awaiting_verification = enum.auto() 

34 # Successfully verified 

35 succeeded = enum.auto() 

36 # Failed (too many wrong attempts or expired) 

37 failed = enum.auto() 

38 # Cancelled by user 

39 cancelled = enum.auto() 

40 

41 

42class PostalVerificationAttempt(Base, kw_only=True): 

43 """ 

44 An attempt to perform postal verification by sending a postcard with a code. 

45 """ 

46 

47 __tablename__ = "postal_verification_attempts" 

48 

49 id: Mapped[int] = mapped_column(BigInteger, primary_key=True, init=False) 

50 

51 user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True) 

52 

53 created: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), init=False) 

54 

55 status: Mapped[PostalVerificationStatus] = mapped_column( 

56 Enum(PostalVerificationStatus), 

57 default=PostalVerificationStatus.pending_address_confirmation, 

58 ) 

59 

60 # Address fields (normalized/validated) 

61 # Required: address_line_1, city, country 

62 # Optional: address_line_2, state, postal_code (varies by country) 

63 address_line_1: Mapped[str] = mapped_column(String) 

64 address_line_2: Mapped[str | None] = mapped_column(String, default=None) 

65 city: Mapped[str] = mapped_column(String) 

66 state: Mapped[str | None] = mapped_column(String, default=None) 

67 postal_code: Mapped[str | None] = mapped_column(String, default=None) 

68 country: Mapped[str] = mapped_column(String) # ISO 3166-1 alpha-2 

69 

70 # The original address as entered by user (for audit), stored as JSON 

71 original_address_json: Mapped[str | None] = mapped_column(String, default=None) 

72 

73 # Verification code (6 chars, uppercase alphanumeric) 

74 verification_code: Mapped[str | None] = mapped_column(String, default=None) 

75 

76 # Timestamps 

77 address_confirmed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), default=None) 

78 postcard_sent_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), default=None) 

79 verified_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), default=None) 

80 

81 # Code entry attempts 

82 code_attempts: Mapped[int] = mapped_column(Integer, server_default=text("0"), init=False) 

83 

84 # Hybrid properties 

85 @hybrid_property 

86 def is_valid(self) -> bool: 

87 return self.status == PostalVerificationStatus.succeeded 

88 

89 @is_valid.inplace.expression 

90 @classmethod 

91 def _is_valid_expression(cls) -> ColumnElement[bool]: 

92 return cls.status == PostalVerificationStatus.succeeded 

93 

94 # Relationships 

95 user: Mapped[User] = relationship(init=False) 

96 

97 # Constraints 

98 __table_args__ = ( 

99 # Only one active attempt per user at a time 

100 Index( 

101 "ix_postal_verification_one_active_per_user", 

102 user_id, 

103 unique=True, 

104 postgresql_where=( 

105 (status == PostalVerificationStatus.pending_address_confirmation) 

106 | (status == PostalVerificationStatus.in_progress) 

107 | (status == PostalVerificationStatus.awaiting_verification) 

108 ), 

109 ), 

110 # Index for looking up verification status for a user 

111 Index( 

112 "ix_postal_verification_attempts_current", 

113 user_id, 

114 postgresql_where=status == PostalVerificationStatus.succeeded, 

115 ), 

116 # Code must be set when in_progress or later (except cancelled) 

117 CheckConstraint( 

118 "(status IN ('pending_address_confirmation', 'cancelled') AND verification_code IS NULL) OR " 

119 "(status IN ('in_progress', 'awaiting_verification', 'succeeded', 'failed') AND verification_code IS NOT NULL)", 

120 name="postal_verification_code_status", 

121 ), 

122 # verified_at must be set when succeeded 

123 CheckConstraint( 

124 "(status != 'succeeded') OR (verified_at IS NOT NULL)", 

125 name="postal_verification_verified_at_status", 

126 ), 

127 # postcard_sent_at must be set when awaiting_verification or succeeded 

128 CheckConstraint( 

129 "(status NOT IN ('awaiting_verification', 'succeeded')) OR (postcard_sent_at IS NOT NULL)", 

130 name="postal_verification_postcard_sent_status", 

131 ), 

132 )