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

41 statements  

« prev     ^ index     » next       coverage.py v7.11.0, created at 2025-12-20 11:53 +0000

1import enum 

2from datetime import datetime 

3from typing import TYPE_CHECKING, Any 

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 

19 

20from couchers.models.base import Base 

21 

22if TYPE_CHECKING: 

23 from couchers.models.users import User 

24 

25 

26class PostalVerificationStatus(enum.Enum): 

27 # User has initiated, awaiting address confirmation 

28 pending_address_confirmation = enum.auto() 

29 # Address confirmed, postcard being sent 

30 in_progress = enum.auto() 

31 # Postcard sent, awaiting user to enter code 

32 awaiting_verification = enum.auto() 

33 # Successfully verified 

34 succeeded = enum.auto() 

35 # Failed (too many wrong attempts or expired) 

36 failed = enum.auto() 

37 # Cancelled by user 

38 cancelled = enum.auto() 

39 

40 

41class PostalVerificationAttempt(Base): 

42 """ 

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

44 """ 

45 

46 __tablename__ = "postal_verification_attempts" 

47 

48 id: Mapped[int] = mapped_column(BigInteger, primary_key=True) 

49 

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

51 

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

53 

54 status: Mapped[PostalVerificationStatus] = mapped_column( 

55 Enum(PostalVerificationStatus), 

56 default=PostalVerificationStatus.pending_address_confirmation, 

57 ) 

58 

59 # Address fields (normalized/validated) 

60 # Required: address_line_1, city, country 

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

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

63 address_line_2: Mapped[str | None] = mapped_column(String, nullable=True) 

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

65 state: Mapped[str | None] = mapped_column(String, nullable=True) 

66 postal_code: Mapped[str | None] = mapped_column(String, nullable=True) 

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

68 

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

70 original_address_json: Mapped[str | None] = mapped_column(String, nullable=True) 

71 

72 # Verification code (6 chars, uppercase alphanumeric) 

73 verification_code: Mapped[str | None] = mapped_column(String, nullable=True) 

74 

75 # Timestamps 

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

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

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

79 

80 # Code entry attempts 

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

82 

83 # Hybrid properties 

84 @hybrid_property 

85 def is_valid(self) -> bool: 

86 return self.status == PostalVerificationStatus.succeeded 

87 

88 @is_valid.expression 

89 @classmethod 

90 def is_valid(cls) -> Any: 

91 return cls.status == PostalVerificationStatus.succeeded 

92 

93 # Relationships 

94 user: Mapped["User"] = relationship("User") 

95 

96 # Constraints 

97 __table_args__ = ( 

98 # Only one active attempt per user at a time 

99 Index( 

100 "ix_postal_verification_one_active_per_user", 

101 user_id, 

102 unique=True, 

103 postgresql_where=( 

104 (status == PostalVerificationStatus.pending_address_confirmation) 

105 | (status == PostalVerificationStatus.in_progress) 

106 | (status == PostalVerificationStatus.awaiting_verification) 

107 ), 

108 ), 

109 # Index for looking up verification status for a user 

110 Index( 

111 "ix_postal_verification_attempts_current", 

112 user_id, 

113 postgresql_where=status == PostalVerificationStatus.succeeded, 

114 ), 

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

116 CheckConstraint( 

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

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

119 name="postal_verification_code_status", 

120 ), 

121 # verified_at must be set when succeeded 

122 CheckConstraint( 

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

124 name="postal_verification_verified_at_status", 

125 ), 

126 # postcard_sent_at must be set when awaiting_verification or succeeded 

127 CheckConstraint( 

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

129 name="postal_verification_postcard_sent_status", 

130 ), 

131 )