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

43 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-04-15 11:04 +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_code: 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 # MyPostcard job ID (returned by place_order) 

82 mypostcard_job_id: Mapped[int | None] = mapped_column(Integer, default=None) 

83 

84 # Code entry attempts 

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

86 

87 # Hybrid properties 

88 @hybrid_property 

89 def is_valid(self) -> bool: 

90 return self.status == PostalVerificationStatus.succeeded 

91 

92 @is_valid.inplace.expression 

93 @classmethod 

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

95 return cls.status == PostalVerificationStatus.succeeded 

96 

97 # Relationships 

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

99 

100 # Constraints 

101 __table_args__ = ( 

102 # Only one active attempt per user at a time 

103 Index( 

104 "ix_postal_verification_one_active_per_user", 

105 user_id, 

106 unique=True, 

107 postgresql_where=( 

108 (status == PostalVerificationStatus.pending_address_confirmation) 

109 | (status == PostalVerificationStatus.in_progress) 

110 | (status == PostalVerificationStatus.awaiting_verification) 

111 ), 

112 ), 

113 # Index for looking up verification status for a user 

114 Index( 

115 "ix_postal_verification_attempts_current", 

116 user_id, 

117 postgresql_where=status == PostalVerificationStatus.succeeded, 

118 ), 

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

120 CheckConstraint( 

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

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

123 name="postal_verification_code_status", 

124 ), 

125 # verified_at must be set when succeeded 

126 CheckConstraint( 

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

128 name="postal_verification_verified_at_status", 

129 ), 

130 # postcard_sent_at must be set when awaiting_verification or succeeded 

131 CheckConstraint( 

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

133 name="postal_verification_postcard_sent_status", 

134 ), 

135 )