Coverage for src/local_deep_research/news/folder_manager.py: 100%

83 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-22 21:20 +0000

1""" 

2Simple folder manager for subscription organization. 

3Handles folder CRUD operations for the API routes. 

4""" 

5 

6from typing import List, Optional, Dict, Any 

7from datetime import datetime, timezone 

8from sqlalchemy.orm import Session 

9from ..database.models import ( 

10 SubscriptionFolder, 

11 NewsSubscription as BaseSubscription, 

12) 

13from .constants import ( 

14 normalize_compact_subscription_update, 

15 normalize_folder_update, 

16) 

17 

18 

19class FolderManager: 

20 """Manages subscription folders and organization.""" 

21 

22 def __init__(self, session: Session): 

23 self.session = session 

24 

25 def get_user_folders(self, user_id: str) -> List[SubscriptionFolder]: 

26 """Get all folders for a user (user_id parameter kept for API compatibility).""" 

27 # In per-user databases, all folders belong to the current user 

28 return ( 

29 self.session.query(SubscriptionFolder) 

30 .order_by(SubscriptionFolder.name) 

31 .all() 

32 ) 

33 

34 def create_folder( 

35 self, name: str, description: Optional[str] = None 

36 ) -> SubscriptionFolder: 

37 """Create a new folder.""" 

38 import uuid 

39 

40 folder = SubscriptionFolder( 

41 id=str(uuid.uuid4()), 

42 name=name, 

43 description=description, 

44 ) 

45 self.session.add(folder) 

46 self.session.commit() 

47 return folder 

48 

49 def update_folder( 

50 self, folder_id: int, **kwargs 

51 ) -> Optional[SubscriptionFolder]: 

52 """Update a folder. 

53 

54 ``kwargs`` is validated against :data:`FOLDER_UPDATABLE_FIELDS` via 

55 :func:`normalize_folder_update` before anything else runs -- the 

56 blind ``hasattr``-gated ``setattr`` loop this replaced accepted any 

57 attribute name the SQLAlchemy model instance had, including 

58 ORM-internal ones (``_sa_instance_state``) and the bound 

59 ``to_dict`` method, either of which corrupts the instance or the 

60 route's subsequent ``folder.to_dict()`` call. Raises ``ValueError`` 

61 naming the offending field(s) for anything outside the allowlist. 

62 """ 

63 update_data = normalize_folder_update(kwargs) 

64 

65 folder = self.session.get(SubscriptionFolder, folder_id) 

66 if not folder: 

67 return None 

68 

69 for key, value in update_data.items(): 

70 setattr(folder, key, value) 

71 

72 folder.updated_at = datetime.now(timezone.utc) # type: ignore[assignment] 

73 self.session.commit() 

74 return folder 

75 

76 def delete_folder( 

77 self, folder_id: int, move_to: Optional[str] = None 

78 ) -> bool: 

79 """Delete a folder, optionally moving subscriptions to another folder.""" 

80 folder = self.session.get(SubscriptionFolder, folder_id) 

81 if not folder: 

82 return False 

83 

84 # Move subscriptions if specified 

85 if move_to: 

86 self.session.query(BaseSubscription).filter_by( 

87 folder=folder.name 

88 ).update({"folder": move_to}) 

89 else: 

90 # Set to None if no target folder 

91 self.session.query(BaseSubscription).filter_by( 

92 folder=folder.name 

93 ).update({"folder": None}) 

94 

95 self.session.delete(folder) 

96 self.session.commit() 

97 return True 

98 

99 def get_subscriptions_by_folder(self, user_id: str) -> Dict[str, Any]: 

100 """Get subscriptions organized by folder.""" 

101 folders = self.get_user_folders(user_id) 

102 

103 result: Dict[str, Any] = {"folders": [], "uncategorized": []} 

104 

105 # Get subscriptions for each folder 

106 for folder in folders: 

107 subs = ( 

108 self.session.query(BaseSubscription) 

109 .filter_by(folder=folder.name, status="active") 

110 .all() 

111 ) 

112 

113 result["folders"].append( 

114 { 

115 "folder": folder.to_dict(), 

116 "subscriptions": [self._sub_to_dict(s) for s in subs], 

117 } 

118 ) 

119 

120 # Get uncategorized subscriptions 

121 uncategorized = ( 

122 self.session.query(BaseSubscription) 

123 .filter_by(folder=None, status="active") 

124 .all() 

125 ) 

126 

127 result["uncategorized"] = [self._sub_to_dict(s) for s in uncategorized] 

128 

129 return result 

130 

131 def update_subscription( 

132 self, subscription_id: str, **kwargs 

133 ) -> Optional[BaseSubscription]: 

134 """Update a subscription.""" 

135 update_data = normalize_compact_subscription_update(kwargs) 

136 sub = ( 

137 self.session.query(BaseSubscription) 

138 .filter_by(id=subscription_id) 

139 .first() 

140 ) 

141 if not sub: 

142 return None 

143 

144 for key, value in update_data.items(): 

145 setattr(sub, key, value) 

146 

147 # Recalculate next_refresh if refresh_interval_minutes changed 

148 if "refresh_interval_minutes" in update_data: 

149 from datetime import timedelta 

150 from loguru import logger 

151 

152 old_next = sub.next_refresh 

153 new_minutes = update_data["refresh_interval_minutes"] 

154 

155 if sub.last_refresh: 

156 sub.next_refresh = sub.last_refresh + timedelta( # type: ignore[assignment] 

157 minutes=new_minutes 

158 ) 

159 logger.info( 

160 f"Updated subscription {sub.id} next_refresh based on last_refresh: {sub.last_refresh} + {new_minutes}min = {sub.next_refresh}" 

161 ) 

162 else: 

163 # If no last_refresh, calculate from now 

164 sub.next_refresh = datetime.now(timezone.utc) + timedelta( # type: ignore[assignment] 

165 minutes=new_minutes 

166 ) 

167 logger.info( 

168 f"Updated subscription {sub.id} next_refresh from now: {sub.next_refresh}" 

169 ) 

170 

171 logger.info( 

172 f"Subscription {sub.id} next_refresh changed from {old_next} to {sub.next_refresh}" 

173 ) 

174 

175 sub.updated_at = datetime.now(timezone.utc) # type: ignore[assignment] 

176 self.session.commit() 

177 return sub 

178 

179 def delete_subscription(self, subscription_id: str) -> bool: 

180 """Delete a subscription.""" 

181 sub = ( 

182 self.session.query(BaseSubscription) 

183 .filter_by(id=subscription_id) 

184 .first() 

185 ) 

186 if not sub: 

187 return False 

188 

189 self.session.delete(sub) 

190 self.session.commit() 

191 return True 

192 

193 def get_subscription_stats(self, user_id: str) -> Dict[str, Any]: 

194 """Get subscription statistics for a user (user_id kept for API compatibility).""" 

195 # In per-user databases, all data belongs to the current user 

196 total = self.session.query(BaseSubscription).count() 

197 

198 active = ( 

199 self.session.query(BaseSubscription) 

200 .filter_by(status="active") 

201 .count() 

202 ) 

203 

204 by_type = {} 

205 for sub_type in ["search", "topic"]: 

206 count = ( 

207 self.session.query(BaseSubscription) 

208 .filter_by(subscription_type=sub_type, status="active") 

209 .count() 

210 ) 

211 by_type[sub_type] = count 

212 

213 return { 

214 "total": total, 

215 "active": active, 

216 "by_type": by_type, 

217 "folders": len(self.get_user_folders(user_id)), 

218 } 

219 

220 def _sub_to_dict(self, sub: BaseSubscription) -> Dict[str, Any]: 

221 """Convert subscription to dictionary.""" 

222 return { 

223 "id": sub.id, 

224 "type": sub.subscription_type, 

225 "query_or_topic": sub.query_or_topic, 

226 "created_at": sub.created_at.isoformat() 

227 if sub.created_at 

228 else None, 

229 "last_refresh": sub.last_refresh.isoformat() 

230 if sub.last_refresh 

231 else None, 

232 "next_refresh": sub.next_refresh.isoformat() 

233 if sub.next_refresh 

234 else None, 

235 "refresh_interval_minutes": sub.refresh_interval_minutes, 

236 "status": sub.status, 

237 }