fix: wrap AsyncSession in context manager across all API blueprints

Replace all bare `session = AsyncSession(bind=conn)` assignments with
`async with AsyncSession(bind=conn) as session:` in downloads.py,
credentials.py, settings.py, sources.py, and subscriptions.py to
ensure sessions are properly closed on all code paths.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-18 23:38:58 -04:00
parent dc146f4a16
commit 080a1254c7
5 changed files with 771 additions and 800 deletions
+149 -155
View File
@@ -26,44 +26,43 @@ async def list_sources():
per_page = min(int(request.args.get("per_page", 50)), 100)
async with current_app.db_engine.connect() as conn:
session = AsyncSession(bind=conn)
async with AsyncSession(bind=conn) as session:
# Build query with subscription eager loading
query = select(Source).options(selectinload(Source.subscription))
# Build query with subscription eager loading
query = select(Source).options(selectinload(Source.subscription))
if platform:
query = query.where(Source.platform == platform)
if subscription_id:
query = query.where(Source.subscription_id == int(subscription_id))
if enabled is not None:
query = query.where(Source.enabled == (enabled.lower() == "true"))
if platform:
query = query.where(Source.platform == platform)
if subscription_id:
query = query.where(Source.subscription_id == int(subscription_id))
if enabled is not None:
query = query.where(Source.enabled == (enabled.lower() == "true"))
# Get total count
count_query = select(func.count()).select_from(Source)
if platform:
count_query = count_query.where(Source.platform == platform)
if subscription_id:
count_query = count_query.where(Source.subscription_id == int(subscription_id))
if enabled is not None:
count_query = count_query.where(Source.enabled == (enabled.lower() == "true"))
# Get total count
count_query = select(func.count()).select_from(Source)
if platform:
count_query = count_query.where(Source.platform == platform)
if subscription_id:
count_query = count_query.where(Source.subscription_id == int(subscription_id))
if enabled is not None:
count_query = count_query.where(Source.enabled == (enabled.lower() == "true"))
total_result = await session.execute(count_query)
total = total_result.scalar()
total_result = await session.execute(count_query)
total = total_result.scalar()
# Apply pagination - order by subscription name, then platform
query = query.join(Subscription).order_by(Subscription.name, Source.platform)
query = query.offset((page - 1) * per_page).limit(per_page)
# Apply pagination - order by subscription name, then platform
query = query.join(Subscription).order_by(Subscription.name, Source.platform)
query = query.offset((page - 1) * per_page).limit(per_page)
result = await session.execute(query)
sources = result.scalars().all()
result = await session.execute(query)
sources = result.scalars().all()
return jsonify({
"items": [s.to_dict() for s in sources],
"total": total,
"page": page,
"per_page": per_page,
"pages": (total + per_page - 1) // per_page if total > 0 else 0,
})
return jsonify({
"items": [s.to_dict() for s in sources],
"total": total,
"page": page,
"per_page": per_page,
"pages": (total + per_page - 1) // per_page if total > 0 else 0,
})
@bp.route("", methods=["POST"])
@@ -83,65 +82,63 @@ async def create_source():
return jsonify({"error": f"Invalid platform. Must be one of: {', '.join(valid_platforms)}"}), 400
async with current_app.db_engine.connect() as conn:
session = AsyncSession(bind=conn)
# Check subscription exists
sub_result = await session.execute(
select(Subscription).where(Subscription.id == data["subscription_id"])
)
subscription = sub_result.scalar()
if not subscription:
return jsonify({"error": "Subscription not found"}), 404
# Check for duplicate platform+url
existing = await session.execute(
select(Source).where(
Source.platform == data["platform"],
Source.url == data["url"]
async with AsyncSession(bind=conn) as session:
# Check subscription exists
sub_result = await session.execute(
select(Subscription).where(Subscription.id == data["subscription_id"])
)
)
if existing.scalar():
return jsonify({"error": "Source with this platform and URL already exists"}), 409
subscription = sub_result.scalar()
if not subscription:
return jsonify({"error": "Subscription not found"}), 404
# Get default check interval from database settings
default_interval = await get_setting_value(
session, "download.schedule_interval", 28800
)
# Check for duplicate platform+url
existing = await session.execute(
select(Source).where(
Source.platform == data["platform"],
Source.url == data["url"]
)
)
if existing.scalar():
return jsonify({"error": "Source with this platform and URL already exists"}), 409
source = Source(
subscription_id=data["subscription_id"],
platform=data["platform"],
url=data["url"],
enabled=data.get("enabled", True),
check_interval=data.get("check_interval", default_interval),
metadata_=data.get("metadata", {}),
)
# Get default check interval from database settings
default_interval = await get_setting_value(
session, "download.schedule_interval", 28800
)
session.add(source)
await session.commit()
await session.refresh(source)
source = Source(
subscription_id=data["subscription_id"],
platform=data["platform"],
url=data["url"],
enabled=data.get("enabled", True),
check_interval=data.get("check_interval", default_interval),
metadata_=data.get("metadata", {}),
)
current_app.logger.info(f"Created source: {subscription.name}/{source.platform}")
return jsonify(source.to_dict()), 201
session.add(source)
await session.commit()
await session.refresh(source)
current_app.logger.info(f"Created source: {subscription.name}/{source.platform}")
return jsonify(source.to_dict()), 201
@bp.route("/<int:source_id>", methods=["GET"])
async def get_source(source_id: int):
"""Get a single source by ID."""
async with current_app.db_engine.connect() as conn:
session = AsyncSession(bind=conn)
async with AsyncSession(bind=conn) as session:
result = await session.execute(
select(Source)
.options(selectinload(Source.subscription))
.where(Source.id == source_id)
)
source = result.scalar()
result = await session.execute(
select(Source)
.options(selectinload(Source.subscription))
.where(Source.id == source_id)
)
source = result.scalar()
if not source:
return jsonify({"error": "Source not found"}), 404
if not source:
return jsonify({"error": "Source not found"}), 404
return jsonify(source.to_dict())
return jsonify(source.to_dict())
@bp.route("/<int:source_id>", methods=["PATCH"])
@@ -150,103 +147,100 @@ async def update_source(source_id: int):
data = await request.get_json()
async with current_app.db_engine.connect() as conn:
session = AsyncSession(bind=conn)
async with AsyncSession(bind=conn) as session:
result = await session.execute(
select(Source)
.options(selectinload(Source.subscription))
.where(Source.id == source_id)
)
source = result.scalar()
result = await session.execute(
select(Source)
.options(selectinload(Source.subscription))
.where(Source.id == source_id)
)
source = result.scalar()
if not source:
return jsonify({"error": "Source not found"}), 404
if not source:
return jsonify({"error": "Source not found"}), 404
# Update allowed fields
allowed_fields = ["enabled", "check_interval", "metadata"]
for field in allowed_fields:
if field in data:
if field == "metadata":
source.metadata_ = data[field]
else:
setattr(source, field, data[field])
# Update allowed fields
allowed_fields = ["enabled", "check_interval", "metadata"]
for field in allowed_fields:
if field in data:
if field == "metadata":
source.metadata_ = data[field]
else:
setattr(source, field, data[field])
await session.commit()
await session.refresh(source)
await session.commit()
await session.refresh(source)
current_app.logger.info(f"Updated source: {source.subscription.name}/{source.platform}")
return jsonify(source.to_dict())
current_app.logger.info(f"Updated source: {source.subscription.name}/{source.platform}")
return jsonify(source.to_dict())
@bp.route("/<int:source_id>", methods=["DELETE"])
async def delete_source(source_id: int):
"""Delete a source."""
async with current_app.db_engine.connect() as conn:
session = AsyncSession(bind=conn)
async with AsyncSession(bind=conn) as session:
result = await session.execute(
select(Source)
.options(selectinload(Source.subscription))
.where(Source.id == source_id)
)
source = result.scalar()
result = await session.execute(
select(Source)
.options(selectinload(Source.subscription))
.where(Source.id == source_id)
)
source = result.scalar()
if not source:
return jsonify({"error": "Source not found"}), 404
if not source:
return jsonify({"error": "Source not found"}), 404
info = f"{source.subscription.name}/{source.platform}"
await session.delete(source)
await session.commit()
info = f"{source.subscription.name}/{source.platform}"
await session.delete(source)
await session.commit()
current_app.logger.info(f"Deleted source: {info}")
return "", 204
current_app.logger.info(f"Deleted source: {info}")
return "", 204
@bp.route("/<int:source_id>/check", methods=["POST"])
async def trigger_check(source_id: int):
"""Trigger an immediate download check for a source."""
async with current_app.db_engine.connect() as conn:
session = AsyncSession(bind=conn)
result = await session.execute(
select(Source)
.options(selectinload(Source.subscription))
.where(Source.id == source_id)
)
source = result.scalar()
if not source:
return jsonify({"error": "Source not found"}), 404
# Check for existing queued or running download for this source
existing = await session.execute(
select(Download).where(
Download.source_id == source.id,
Download.status.in_([DownloadStatus.QUEUED, DownloadStatus.RUNNING])
async with AsyncSession(bind=conn) as session:
result = await session.execute(
select(Source)
.options(selectinload(Source.subscription))
.where(Source.id == source_id)
)
)
if existing.scalar():
return jsonify({"error": "A download is already queued or running for this source"}), 409
source = result.scalar()
# Create download record in QUEUED state for visibility
download = Download(
source_id=source.id,
url=source.url,
status=DownloadStatus.QUEUED,
)
session.add(download)
await session.commit()
await session.refresh(download)
if not source:
return jsonify({"error": "Source not found"}), 404
# Queue Celery task for immediate download
task = download_source.delay(source_id, download_id=download.id)
# Check for existing queued or running download for this source
existing = await session.execute(
select(Download).where(
Download.source_id == source.id,
Download.status.in_([DownloadStatus.QUEUED, DownloadStatus.RUNNING])
)
)
if existing.scalar():
return jsonify({"error": "A download is already queued or running for this source"}), 409
current_app.logger.info(f"Triggered check for source: {source.subscription.name}/{source.platform}")
return jsonify({
"message": "Check queued",
"source_id": source_id,
"download_id": download.id,
"subscription_name": source.subscription.name,
"platform": source.platform,
"task_id": task.id,
}), 202
# Create download record in QUEUED state for visibility
download = Download(
source_id=source.id,
url=source.url,
status=DownloadStatus.QUEUED,
)
session.add(download)
await session.commit()
await session.refresh(download)
# Queue Celery task for immediate download
task = download_source.delay(source_id, download_id=download.id)
current_app.logger.info(f"Triggered check for source: {source.subscription.name}/{source.platform}")
return jsonify({
"message": "Check queued",
"source_id": source_id,
"download_id": download.id,
"subscription_name": source.subscription.name,
"platform": source.platform,
"task_id": task.id,
}), 202