Coverage for src / idx_api / routers / suggestions.py: 39%

141 statements  

« prev     ^ index     » next       coverage.py v7.13.1, created at 2025-12-28 11:09 -0700

1"""Suggestions API for suggest-edit workflow.""" 

2 

3from datetime import datetime 

4from typing import Any 

5 

6from fastapi import APIRouter, Depends, HTTPException, Query 

7from pydantic import BaseModel 

8from sqlalchemy import func, select 

9from sqlalchemy.orm import Session 

10 

11from idx_api.auth import AdminUser, BrokerUser, RequiredUser 

12from idx_api.database import get_db 

13from idx_api.models.suggestion import Suggestion 

14 

15router = APIRouter() 

16 

17 

18# ===== Request/Response Models ===== 

19 

20 

21class SuggestionCreate(BaseModel): 

22 """Create suggestion request.""" 

23 

24 target_table: str 

25 target_id: int 

26 suggestion_type: str 

27 title: str 

28 description: str | None = None 

29 proposed_changes: dict[str, Any] 

30 reviewer_type: str # "admin" or "broker" 

31 

32 

33class SuggestionReject(BaseModel): 

34 """Reject suggestion request.""" 

35 

36 notes: str 

37 

38 

39class SuggestionResponse(BaseModel): 

40 """Suggestion response model.""" 

41 

42 id: int 

43 target_table: str 

44 target_id: int 

45 suggestion_type: str 

46 title: str 

47 description: str | None 

48 proposed_changes: dict[str, Any] 

49 reviewer_type: str 

50 status: str 

51 submitted_by_id: int | None 

52 reviewed_by_id: int | None 

53 review_notes: str | None 

54 created_at: datetime 

55 reviewed_at: datetime | None 

56 

57 class Config: 

58 from_attributes = True 

59 

60 

61class PaginatedSuggestions(BaseModel): 

62 """Paginated suggestions response.""" 

63 

64 items: list[SuggestionResponse] 

65 total: int 

66 page: int 

67 page_size: int 

68 total_pages: int 

69 

70 

71# ===== Endpoints ===== 

72 

73 

74@router.get("/suggestions", response_model=PaginatedSuggestions) 

75async def list_suggestions( 

76 user: RequiredUser, 

77 db: Session = Depends(get_db), 

78 page: int = Query(1, ge=1), 

79 page_size: int = Query(20, ge=1, le=100), 

80 status: str | None = None, 

81 target_table: str | None = None, 

82): 

83 """ 

84 List suggestions with filtering. 

85 

86 Filters results based on user role: 

87 - Admins: See all suggestions 

88 - Brokers: See suggestions for their brokerage 

89 - Agents: See suggestions they submitted 

90 """ 

91 # Build query 

92 query = select(Suggestion) 

93 

94 # Apply filters 

95 if status: 

96 query = query.where(Suggestion.status == status) 

97 if target_table: 

98 query = query.where(Suggestion.target_table == target_table) 

99 

100 # Role-based filtering 

101 if user.role == "broker" and user.broker_id: 

102 # Brokers only see suggestions for their brokerage resources 

103 query = query.where( 

104 (Suggestion.target_table == "broker") & (Suggestion.target_id == user.broker_id) 

105 | (Suggestion.target_table == "agent") # All agent suggestions for their brokerage 

106 ) 

107 elif user.role == "agent": 

108 # Agents only see their own suggestions 

109 query = query.where(Suggestion.submitted_by_id == user.id) 

110 

111 # Count total 

112 count_query = select(func.count()).select_from(Suggestion) 

113 if status: 

114 count_query = count_query.where(Suggestion.status == status) 

115 if target_table: 

116 count_query = count_query.where(Suggestion.target_table == target_table) 

117 total = db.scalar(count_query) 

118 

119 # Get paginated results 

120 offset = (page - 1) * page_size 

121 suggestions = db.scalars( 

122 query.order_by(Suggestion.created_at.desc()).offset(offset).limit(page_size) 

123 ).all() 

124 

125 total_pages = (total + page_size - 1) // page_size 

126 

127 return PaginatedSuggestions( 

128 items=[SuggestionResponse.model_validate(s) for s in suggestions], 

129 total=total, 

130 page=page, 

131 page_size=page_size, 

132 total_pages=total_pages, 

133 ) 

134 

135 

136@router.post("/suggestions", response_model=SuggestionResponse) 

137async def create_suggestion( 

138 data: SuggestionCreate, 

139 user: RequiredUser, 

140 db: Session = Depends(get_db), 

141): 

142 """ 

143 Submit a new suggestion. 

144 

145 Any authenticated user can submit suggestions. 

146 """ 

147 suggestion = Suggestion( 

148 target_table=data.target_table, 

149 target_id=data.target_id, 

150 suggestion_type=data.suggestion_type, 

151 title=data.title, 

152 description=data.description, 

153 proposed_changes=data.proposed_changes, 

154 reviewer_type=data.reviewer_type, 

155 status="pending", 

156 submitted_by_id=user.id, 

157 created_at=datetime.utcnow(), 

158 ) 

159 

160 db.add(suggestion) 

161 db.commit() 

162 db.refresh(suggestion) 

163 

164 return SuggestionResponse.model_validate(suggestion) 

165 

166 

167@router.get("/suggestions/{suggestion_id}", response_model=SuggestionResponse) 

168async def get_suggestion( 

169 suggestion_id: int, 

170 user: RequiredUser, 

171 db: Session = Depends(get_db), 

172): 

173 """Get a specific suggestion.""" 

174 suggestion = db.get(Suggestion, suggestion_id) 

175 if not suggestion: 

176 raise HTTPException(status_code=404, detail="Suggestion not found") 

177 

178 # Check permissions 

179 if user.role == "broker" and user.broker_id: 

180 # Brokers can only see suggestions for their resources 

181 if suggestion.target_table == "broker" and suggestion.target_id != user.broker_id: 

182 raise HTTPException(status_code=403, detail="Not authorized to view this suggestion") 

183 elif user.role == "agent": 

184 # Agents can only see their own suggestions 

185 if suggestion.submitted_by_id != user.id: 

186 raise HTTPException(status_code=403, detail="Not authorized to view this suggestion") 

187 

188 return SuggestionResponse.model_validate(suggestion) 

189 

190 

191@router.post("/suggestions/{suggestion_id}/approve", response_model=SuggestionResponse) 

192async def approve_suggestion( 

193 suggestion_id: int, 

194 user: RequiredUser, 

195 db: Session = Depends(get_db), 

196): 

197 """ 

198 Approve and apply a suggestion. 

199 

200 Requires admin role or broker role (for agent suggestions in their brokerage). 

201 """ 

202 suggestion = db.get(Suggestion, suggestion_id) 

203 if not suggestion: 

204 raise HTTPException(status_code=404, detail="Suggestion not found") 

205 

206 if suggestion.status != "pending": 

207 raise HTTPException(status_code=400, detail="Suggestion already reviewed") 

208 

209 # Check permissions 

210 if suggestion.reviewer_type == "admin": 

211 # Only admins can approve admin-level suggestions 

212 if user.role != "admin": 

213 raise HTTPException(status_code=403, detail="Admin approval required") 

214 elif suggestion.reviewer_type == "broker": 

215 # Brokers can approve suggestions for their brokerage 

216 if user.role == "broker" and user.broker_id: 

217 # Verify the suggestion is for their brokerage 

218 if suggestion.target_table == "agent": 

219 # Need to check if the agent belongs to their brokerage 

220 from idx_api.models.agent import Agent 

221 agent = db.get(Agent, suggestion.target_id) 

222 if not agent or agent.broker_id != user.broker_id: 

223 raise HTTPException(status_code=403, detail="Not authorized") 

224 elif user.role != "admin": 

225 raise HTTPException(status_code=403, detail="Broker or admin approval required") 

226 

227 # Apply the proposed changes 

228 # NOTE: This is a simplified implementation - production would need to: 

229 # 1. Validate the proposed changes 

230 # 2. Apply them to the target table 

231 # 3. Handle field-specific validation 

232 # For now, we'll just mark as approved - actual application would be in Phase 4 

233 

234 suggestion.status = "approved" 

235 suggestion.reviewed_by_id = user.id 

236 suggestion.reviewed_at = datetime.utcnow() 

237 

238 db.commit() 

239 db.refresh(suggestion) 

240 

241 return SuggestionResponse.model_validate(suggestion) 

242 

243 

244@router.post("/suggestions/{suggestion_id}/reject", response_model=SuggestionResponse) 

245async def reject_suggestion( 

246 suggestion_id: int, 

247 data: SuggestionReject, 

248 user: RequiredUser, 

249 db: Session = Depends(get_db), 

250): 

251 """ 

252 Reject a suggestion. 

253 

254 Requires admin role or broker role (for agent suggestions). 

255 """ 

256 suggestion = db.get(Suggestion, suggestion_id) 

257 if not suggestion: 

258 raise HTTPException(status_code=404, detail="Suggestion not found") 

259 

260 if suggestion.status != "pending": 

261 raise HTTPException(status_code=400, detail="Suggestion already reviewed") 

262 

263 # Check permissions (same logic as approve) 

264 if suggestion.reviewer_type == "admin" and user.role != "admin": 

265 raise HTTPException(status_code=403, detail="Admin approval required") 

266 elif suggestion.reviewer_type == "broker": 

267 if user.role == "broker" and user.broker_id: 

268 from idx_api.models.agent import Agent 

269 if suggestion.target_table == "agent": 

270 agent = db.get(Agent, suggestion.target_id) 

271 if not agent or agent.broker_id != user.broker_id: 

272 raise HTTPException(status_code=403, detail="Not authorized") 

273 elif user.role != "admin": 

274 raise HTTPException(status_code=403, detail="Broker or admin approval required") 

275 

276 suggestion.status = "rejected" 

277 suggestion.reviewed_by_id = user.id 

278 suggestion.review_notes = data.notes 

279 suggestion.reviewed_at = datetime.utcnow() 

280 

281 db.commit() 

282 db.refresh(suggestion) 

283 

284 return SuggestionResponse.model_validate(suggestion) 

285 

286 

287@router.delete("/suggestions/{suggestion_id}") 

288async def delete_suggestion( 

289 suggestion_id: int, 

290 user: AdminUser, 

291 db: Session = Depends(get_db), 

292): 

293 """ 

294 Delete a suggestion. 

295 

296 Admin only. 

297 """ 

298 suggestion = db.get(Suggestion, suggestion_id) 

299 if not suggestion: 

300 raise HTTPException(status_code=404, detail="Suggestion not found") 

301 

302 db.delete(suggestion) 

303 db.commit() 

304 

305 return {"message": "Suggestion deleted successfully"}