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
« 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"""
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)
19class FolderManager:
20 """Manages subscription folders and organization."""
22 def __init__(self, session: Session):
23 self.session = session
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 )
34 def create_folder(
35 self, name: str, description: Optional[str] = None
36 ) -> SubscriptionFolder:
37 """Create a new folder."""
38 import uuid
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
49 def update_folder(
50 self, folder_id: int, **kwargs
51 ) -> Optional[SubscriptionFolder]:
52 """Update a folder.
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)
65 folder = self.session.get(SubscriptionFolder, folder_id)
66 if not folder:
67 return None
69 for key, value in update_data.items():
70 setattr(folder, key, value)
72 folder.updated_at = datetime.now(timezone.utc) # type: ignore[assignment]
73 self.session.commit()
74 return folder
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
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})
95 self.session.delete(folder)
96 self.session.commit()
97 return True
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)
103 result: Dict[str, Any] = {"folders": [], "uncategorized": []}
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 )
113 result["folders"].append(
114 {
115 "folder": folder.to_dict(),
116 "subscriptions": [self._sub_to_dict(s) for s in subs],
117 }
118 )
120 # Get uncategorized subscriptions
121 uncategorized = (
122 self.session.query(BaseSubscription)
123 .filter_by(folder=None, status="active")
124 .all()
125 )
127 result["uncategorized"] = [self._sub_to_dict(s) for s in uncategorized]
129 return result
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
144 for key, value in update_data.items():
145 setattr(sub, key, value)
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
152 old_next = sub.next_refresh
153 new_minutes = update_data["refresh_interval_minutes"]
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 )
171 logger.info(
172 f"Subscription {sub.id} next_refresh changed from {old_next} to {sub.next_refresh}"
173 )
175 sub.updated_at = datetime.now(timezone.utc) # type: ignore[assignment]
176 self.session.commit()
177 return sub
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
189 self.session.delete(sub)
190 self.session.commit()
191 return True
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()
198 active = (
199 self.session.query(BaseSubscription)
200 .filter_by(status="active")
201 .count()
202 )
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
213 return {
214 "total": total,
215 "active": active,
216 "by_type": by_type,
217 "folders": len(self.get_user_folders(user_id)),
218 }
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 }