Coverage for src/couchers/models/host_requests.py: 97%

72 statements  

« prev     ^ index     » next       coverage.py v7.11.0, created at 2025-12-25 10:58 +0000

1import enum 

2from datetime import date, datetime 

3from typing import TYPE_CHECKING, Any 

4 

5from geoalchemy2 import Geometry 

6from sqlalchemy import BigInteger, Boolean, Date, DateTime, Enum, Float, ForeignKey, Index, String, func, text 

7from sqlalchemy.ext.hybrid import hybrid_property 

8from sqlalchemy.orm import Mapped, column_property, mapped_column, relationship 

9from sqlalchemy.sql import expression 

10 

11from couchers.models.base import Base, Geom 

12from couchers.utils import date_in_timezone, now 

13 

14if TYPE_CHECKING: 

15 from couchers.models.conversations import Conversation 

16 from couchers.models.moderation import ModerationState 

17 from couchers.models.users import User 

18 

19 

20class HostRequestStatus(enum.Enum): 

21 pending = enum.auto() 

22 accepted = enum.auto() 

23 rejected = enum.auto() 

24 confirmed = enum.auto() 

25 cancelled = enum.auto() 

26 

27 

28class HostRequestQuality(enum.Enum): 

29 high_quality = enum.auto() 

30 okay_quality = enum.auto() 

31 low_quality = enum.auto() 

32 

33 

34class HostRequest(Base): 

35 """ 

36 A request to stay with a host 

37 """ 

38 

39 __tablename__ = "host_requests" 

40 __moderation_author_column__ = "surfer_user_id" 

41 

42 conversation_id: Mapped[int] = mapped_column("id", ForeignKey("conversations.id"), primary_key=True) 

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

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

45 

46 # Unified Moderation System 

47 moderation_state_id: Mapped[int] = mapped_column(ForeignKey("moderation_states.id"), index=True) 

48 

49 hosting_city: Mapped[str] = mapped_column(String) 

50 hosting_location: Mapped[Geom] = mapped_column(Geometry("POINT", srid=4326)) 

51 hosting_radius: Mapped[float] = mapped_column(Float) 

52 

53 # TODO: proper timezone handling 

54 timezone = "Etc/UTC" 

55 

56 # dates in the timezone above 

57 from_date: Mapped[date] = mapped_column(Date) 

58 to_date: Mapped[date] = mapped_column(Date) 

59 

60 # timezone-aware start and end times of the request, can be compared to now() 

61 start_time = column_property(date_in_timezone(from_date, timezone)) # type: ignore[arg-type] 

62 end_time = column_property(date_in_timezone(to_date, timezone) + text("interval '1 days'")) # type: ignore[arg-type] 

63 start_time_to_write_reference = column_property(date_in_timezone(to_date, timezone)) # type: ignore[arg-type] 

64 # notice 1 day for midnight at the *end of the day*, then 14 days to write a ref 

65 end_time_to_write_reference = column_property(date_in_timezone(to_date, timezone) + text("interval '15 days'")) # type: ignore[arg-type] 

66 

67 status: Mapped[HostRequestStatus] = mapped_column(Enum(HostRequestStatus)) 

68 is_host_archived: Mapped[bool] = mapped_column(Boolean, default=False, server_default=expression.false()) 

69 is_surfer_archived: Mapped[bool] = mapped_column(Boolean, default=False, server_default=expression.false()) 

70 

71 host_last_seen_message_id: Mapped[int] = mapped_column(BigInteger, default=0) 

72 surfer_last_seen_message_id: Mapped[int] = mapped_column(BigInteger, default=0) 

73 

74 # number of reference reminders sent out 

75 host_sent_reference_reminders: Mapped[int] = mapped_column(BigInteger, server_default=text("0")) 

76 surfer_sent_reference_reminders: Mapped[int] = mapped_column(BigInteger, server_default=text("0")) 

77 host_sent_request_reminders: Mapped[int] = mapped_column(BigInteger, server_default=text("0")) 

78 last_sent_request_reminder_time: Mapped[datetime] = mapped_column( 

79 DateTime(timezone=True), server_default=func.now() 

80 ) 

81 

82 # reason why the host/surfer marked that they didn't meet up 

83 # if null then they haven't marked it such 

84 host_reason_didnt_meetup: Mapped[str | None] = mapped_column(String, nullable=True) 

85 surfer_reason_didnt_meetup: Mapped[str | None] = mapped_column(String, nullable=True) 

86 

87 surfer: Mapped["User"] = relationship( 

88 "User", backref="host_requests_sent", foreign_keys="HostRequest.surfer_user_id" 

89 ) 

90 host: Mapped["User"] = relationship( 

91 "User", backref="host_requests_received", foreign_keys="HostRequest.host_user_id" 

92 ) 

93 conversation: Mapped["Conversation"] = relationship("Conversation") 

94 moderation_state: Mapped["ModerationState"] = relationship("ModerationState") 

95 

96 __table_args__ = ( 

97 # allows fast lookup as to whether they didn't meet up 

98 Index( 

99 "ix_host_requests_host_didnt_meetup", 

100 host_reason_didnt_meetup != None, 

101 ), 

102 Index( 

103 "ix_host_requests_surfer_didnt_meetup", 

104 surfer_reason_didnt_meetup != None, 

105 ), 

106 # Used for figuring out who needs a reminder to respond 

107 Index( 

108 "ix_host_requests_status_reminder_counts", 

109 status, 

110 host_sent_request_reminders, 

111 last_sent_request_reminder_time, 

112 from_date, 

113 ), 

114 ) 

115 

116 @hybrid_property 

117 def can_write_reference(self) -> Any: 

118 return ( 

119 ((self.status == HostRequestStatus.confirmed) | (self.status == HostRequestStatus.accepted)) 

120 & (now() >= self.start_time_to_write_reference) 

121 & (now() <= self.end_time_to_write_reference) 

122 ) 

123 

124 @can_write_reference.expression 

125 def can_write_reference_expr(cls) -> Any: # noqa: ARG003,D102 

126 return ( 

127 ((cls.status == HostRequestStatus.confirmed) | (cls.status == HostRequestStatus.accepted)) 

128 & (func.now() >= cls.start_time_to_write_reference) 

129 & (func.now() <= cls.end_time_to_write_reference) 

130 ) 

131 

132 def __repr__(self) -> str: 

133 return f"HostRequest(id={self.conversation_id}, surfer_user_id={self.surfer_user_id}, host_user_id={self.host_user_id}...)" 

134 

135 

136class HostRequestFeedback(Base): 

137 """ 

138 Private feedback from a host about a host request 

139 """ 

140 

141 __tablename__ = "host_request_feedbacks" 

142 

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

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

145 host_request_id: Mapped[int] = mapped_column(ForeignKey("host_requests.id")) 

146 

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

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

149 

150 request_quality: Mapped[HostRequestQuality | None] = mapped_column(Enum(HostRequestQuality), nullable=True) 

151 decline_reason: Mapped[str | None] = mapped_column(String, nullable=True) # plain text 

152 

153 host_request: Mapped["HostRequest"] = relationship("HostRequest") 

154 

155 __table_args__ = ( 

156 # Each user can leave at most one friend reference to another user 

157 Index( 

158 "ix_unique_host_req_feedback", 

159 from_user_id, 

160 to_user_id, 

161 host_request_id, 

162 unique=True, 

163 ), 

164 )