Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 73 additions & 2 deletions backend/controllers/settings_controller.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
"""Settings Controller - handles application settings endpoints"""

import logging
import re
import shutil
import tempfile
from math import gcd
from pathlib import Path
from datetime import datetime, timezone
from contextlib import contextmanager
Expand All @@ -23,6 +25,71 @@
"settings", __name__, url_prefix="/api/settings"
)

# Accept custom ratios but guard against obviously invalid input.
# Prefer common ratios used by common image models.
_COMMON_IMAGE_ASPECT_RATIOS = {
"1:1",
"3:4",
"4:3",
"16:9",
"9:16",
"21:9",
"3:2",
"2:3",
"4:5",
"5:4",
}

_ASPECT_RATIO_PATTERN = re.compile(r"^\d+:\d+$")
_ASPECT_RATIO_MIN = 0.2
_ASPECT_RATIO_MAX = 5.0


def _normalize_image_aspect_ratio(raw_value):
"""
Normalize and validate aspect ratio input.

- Accepts "W:H" where W/H are positive integers.
- Reduces by gcd (e.g., "1920:1080" -> "16:9").
- Rejects obviously invalid or extreme ratios.
"""
if raw_value is None:
raise ValueError("Image aspect ratio is required")

value = str(raw_value).strip()
if value == "":
raise ValueError("Image aspect ratio is required")

if not _ASPECT_RATIO_PATTERN.fullmatch(value):
raise ValueError(
"Image aspect ratio must match \\d+:\\d+ (e.g., 16:9, 4:3, 1:1)"
)

width, height = (int(part) for part in value.split(":", 1))
if width <= 0 or height <= 0:
raise ValueError("Image aspect ratio must be positive integers (e.g., 16:9)")

divisor = gcd(width, height)
width //= divisor
height //= divisor

ratio_value = width / height
if ratio_value < _ASPECT_RATIO_MIN or ratio_value > _ASPECT_RATIO_MAX:
raise ValueError(
f"Image aspect ratio must be between {_ASPECT_RATIO_MIN:.1f} and {_ASPECT_RATIO_MAX:.1f} (e.g., 16:9)"
)

normalized = f"{width}:{height}"
if len(normalized) > 10:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The check len(normalized) > 10 seems arbitrary and could lead to valid, simplified aspect ratios being rejected. For example, a ratio like 10000:1 would simplify to 10000:1 (length 7) but 100000:1 would simplify to 100000:1 (length 8). If a user inputs 1000000000:1, it would simplify to 1000000000:1 (length 10), which is fine. However, if a ratio like 1234567890:1 is simplified to 1234567890:1 (length 11), it would be rejected. Given that custom ratios are allowed, this length check might be too restrictive and not directly related to the validity or extremity of the ratio itself. Consider removing this check or basing it on a more logical constraint if there's a specific reason for it.

raise ValueError("Image aspect ratio is too long")

if normalized not in _COMMON_IMAGE_ASPECT_RATIOS:
logger.info(
f"Non-standard image_aspect_ratio provided: {normalized} (allowed, but may not be supported by all providers)"
)

return normalized


@contextmanager
def temporary_settings_override(settings_override: dict):
Expand Down Expand Up @@ -180,8 +247,12 @@ def update_settings():
settings.image_resolution = resolution

if "image_aspect_ratio" in data:
aspect_ratio = data["image_aspect_ratio"]
settings.image_aspect_ratio = aspect_ratio
try:
settings.image_aspect_ratio = _normalize_image_aspect_ratio(
data["image_aspect_ratio"]
)
except ValueError as e:
return bad_request(str(e))

# Update worker configuration
if "max_description_workers" in data:
Expand Down
2 changes: 1 addition & 1 deletion backend/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@

# 设置测试环境变量 - 必须在导入app之前设置
os.environ['TESTING'] = 'true'
os.environ.setdefault('SKIP_SERVICE_TESTS', 'true')
os.environ['USE_MOCK_AI'] = 'true' # 标记使用mock AI服务
os.environ['GOOGLE_API_KEY'] = os.environ.get('GOOGLE_API_KEY', 'mock-api-key-for-testing')
os.environ['FLASK_ENV'] = 'testing'
Expand Down Expand Up @@ -178,4 +179,3 @@ def assert_error_response(response, expected_status=None):
assert data is not None
assert data.get('success') is False or 'error' in data
return data

7 changes: 3 additions & 4 deletions backend/tests/integration/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ uv run pytest tests/integration/test_full_workflow.py -v
### 2. Real Service 测试(需要运行服务)
**文件**: `test_api_full_flow.py`

这些测试使用 `requests` 库直接调用 HTTP 端点,需要真实的后端服务运行。
这些测试使用 `httpx` 库直接调用 HTTP 端点,需要真实的后端服务运行。

**特点**:
- ⏱️ 较慢(需要真实 HTTP 请求)
Expand Down Expand Up @@ -90,7 +90,7 @@ GOOGLE_API_KEY: <real-api-key-from-secrets>
| `unit` | 单元测试 | 测试单个函数或方法 |
| `integration` | 集成测试 | 测试多个组件交互 |
| `slow` | 慢速测试 | 需要 AI API 调用的测试 |
| `requires_service` | 需要运行服务 | 使用 requests 调用 HTTP 端点 |
| `requires_service` | 需要运行服务 | 使用 httpx 调用 HTTP 端点 |
| `mock` | 使用 mock | 不调用真实外部服务 |
| `docker` | Docker 环境测试 | 需要 Docker 环境 |

Expand Down Expand Up @@ -163,7 +163,7 @@ pytest tests/integration -v -m "not requires_service"
@pytest.mark.integration
@pytest.mark.requires_service
def test_real_api_call(self):
response = requests.post('http://localhost:5000/api/projects', ...)
response = httpx.post('http://localhost:5000/api/projects', ...)
```

3. **环境检查**:
Expand All @@ -174,4 +174,3 @@ pytest tests/integration -v -m "not requires_service"

**更新日期**: 2025-12-22
**维护者**: Banana Slides Team

39 changes: 19 additions & 20 deletions backend/tests/integration/test_api_full_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
"""

import pytest
import requests
import httpx
import time
import os
import io
Expand Down Expand Up @@ -45,9 +45,9 @@ def wait_for_project_status(project_id: str, expected_status: str, timeout: int

while time.time() - start_time < timeout:
try:
response = requests.get(f"{BASE_URL}/api/projects/{project_id}", timeout=10)
response = httpx.get(f"{BASE_URL}/api/projects/{project_id}", timeout=10)

if not response.ok:
if not response.is_success:
consecutive_errors += 1
if consecutive_errors >= max_consecutive_errors:
raise Exception(f"Failed to get project status after {max_consecutive_errors} consecutive errors")
Expand Down Expand Up @@ -97,12 +97,12 @@ def wait_for_task_completion(project_id: str, task_id: str, timeout: int = 120):

while time.time() - start_time < timeout:
try:
response = requests.get(
response = httpx.get(
f"{BASE_URL}/api/projects/{project_id}/tasks/{task_id}",
timeout=10
)

if not response.ok:
if not response.is_success:
consecutive_errors += 1
if consecutive_errors >= max_consecutive_errors:
raise Exception(f"Failed to get task status after {max_consecutive_errors} consecutive errors")
Expand Down Expand Up @@ -155,7 +155,7 @@ def register_project(pid):
# Cleanup
for pid in created_project_ids:
try:
requests.delete(f"{BASE_URL}/api/projects/{pid}", timeout=10)
httpx.delete(f"{BASE_URL}/api/projects/{pid}", timeout=10)
print(f"✓ Cleaned up project: {pid}")
except Exception as e:
print(f"Failed to cleanup project {pid}: {e}")
Expand Down Expand Up @@ -183,7 +183,7 @@ def test_api_full_flow_create_to_export(self, project_id):

# Step 1: Create project
print('📝 Step 1: Creating project...')
response = requests.post(
response = httpx.post(
f"{BASE_URL}/api/projects",
json={
'creation_type': 'idea',
Expand All @@ -209,7 +209,7 @@ def test_api_full_flow_create_to_export(self, project_id):
template_img.save(img_bytes, format='PNG')
img_bytes.seek(0)

response = requests.post(
response = httpx.post(
f"{BASE_URL}/api/projects/{pid}/template",
files={'template_image': ('template.png', img_bytes, 'image/png')},
timeout=30
Expand All @@ -222,7 +222,7 @@ def test_api_full_flow_create_to_export(self, project_id):

# Step 2: Generate outline
print('📋 Step 2: Triggering outline generation...')
response = requests.post(
response = httpx.post(
f"{BASE_URL}/api/projects/{pid}/generate/outline",
json={},
timeout=30
Expand All @@ -238,7 +238,7 @@ def test_api_full_flow_create_to_export(self, project_id):
wait_for_project_status(pid, 'OUTLINE_GENERATED', timeout=API_TIMEOUT)

# Verify pages were created
response = requests.get(f"{BASE_URL}/api/projects/{pid}", timeout=10)
response = httpx.get(f"{BASE_URL}/api/projects/{pid}", timeout=10)
data = response.json()
pages = data['data']['pages']

Expand All @@ -248,7 +248,7 @@ def test_api_full_flow_create_to_export(self, project_id):

# Step 4: Generate descriptions
print('✍️ Step 4: Starting to generate page descriptions...')
response = requests.post(
response = httpx.post(
f"{BASE_URL}/api/projects/{pid}/generate/descriptions",
json={},
timeout=30
Expand All @@ -268,7 +268,7 @@ def test_api_full_flow_create_to_export(self, project_id):

# Step 5: Generate images
print('🎨 Step 5: Starting to generate page images...')
response = requests.post(
response = httpx.post(
f"{BASE_URL}/api/projects/{pid}/generate/images",
json={
'use_template': True, # Use the uploaded template
Expand All @@ -291,7 +291,7 @@ def test_api_full_flow_create_to_export(self, project_id):
print('✓ All page images generated\n')

# Verify all pages have images
response = requests.get(f"{BASE_URL}/api/projects/{pid}", timeout=10)
response = httpx.get(f"{BASE_URL}/api/projects/{pid}", timeout=10)
data = response.json()
pages = data['data'].get('pages', [])

Expand All @@ -305,7 +305,7 @@ def test_api_full_flow_create_to_export(self, project_id):

# Step 6: Export PPT
print('📦 Step 6: Exporting PPT file...')
response = requests.get(
response = httpx.get(
f"{BASE_URL}/api/projects/{pid}/export/pptx?filename=integration-test.pptx",
timeout=60
)
Expand All @@ -321,7 +321,7 @@ def test_api_full_flow_create_to_export(self, project_id):
# Step 7: Verify PPT can be downloaded
print('📥 Step 7: Verifying PPT file can be downloaded...')
download_url = data['data']['download_url']
response = requests.get(f"{BASE_URL}{download_url}", timeout=30)
response = httpx.get(f"{BASE_URL}{download_url}", timeout=30)

assert response.status_code == 200

Expand Down Expand Up @@ -355,7 +355,7 @@ def test_quick_api_flow_no_ai(self):
print('\n🏃 Quick API flow test (skip AI generation)\n')

# Create project
response = requests.post(
response = httpx.post(
f"{BASE_URL}/api/projects",
json={
'creation_type': 'idea',
Expand All @@ -370,19 +370,18 @@ def test_quick_api_flow_no_ai(self):
print(f"✓ Project created: {pid}")

# Get project info
response = requests.get(f"{BASE_URL}/api/projects/{pid}", timeout=10)
response = httpx.get(f"{BASE_URL}/api/projects/{pid}", timeout=10)
assert response.status_code == 200
print('✓ Project query successful')

# List all projects
response = requests.get(f"{BASE_URL}/api/projects", timeout=10)
response = httpx.get(f"{BASE_URL}/api/projects", timeout=10)
assert response.status_code == 200
data = response.json()
assert 'projects' in data['data']
print(f"✓ Project list query successful, total {len(data['data']['projects'])} projects")

# Delete project
response = requests.delete(f"{BASE_URL}/api/projects/{pid}", timeout=10)
response = httpx.delete(f"{BASE_URL}/api/projects/{pid}", timeout=10)
assert response.status_code == 200
print('✓ Project deleted successfully\n')

49 changes: 49 additions & 0 deletions backend/tests/unit/test_api_settings.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
"""
Settings API unit tests
"""

import pytest

from conftest import assert_success_response, assert_error_response


class TestSettingsAspectRatio:
"""Image aspect ratio settings tests"""

def test_get_settings_includes_image_aspect_ratio(self, client):
response = client.get("/api/settings")
data = assert_success_response(response)
assert data["data"]["image_aspect_ratio"] == "16:9"

def test_update_settings_persists_image_aspect_ratio(self, client):
response = client.put("/api/settings", json={"image_aspect_ratio": "4:3"})
data = assert_success_response(response)
assert data["data"]["image_aspect_ratio"] == "4:3"
assert client.application.config["DEFAULT_ASPECT_RATIO"] == "4:3"

response = client.get("/api/settings")
data = assert_success_response(response)
assert data["data"]["image_aspect_ratio"] == "4:3"

def test_update_settings_normalizes_image_aspect_ratio(self, client):
response = client.put("/api/settings", json={"image_aspect_ratio": "1920:1080"})
data = assert_success_response(response)
assert data["data"]["image_aspect_ratio"] == "16:9"

@pytest.mark.parametrize(
"value",
[
"",
None,
"16x9",
"16:0",
"0:16",
"abc",
"1:1000",
"1000:1",
],
)
def test_update_settings_rejects_invalid_image_aspect_ratio(self, client, value):
response = client.put("/api/settings", json={"image_aspect_ratio": value})
assert_error_response(response, expected_status=400)

18 changes: 18 additions & 0 deletions frontend/src/pages/Settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,24 @@ const settingsSections: SectionConfig[] = [
{ value: '4K', label: '4K (4096px)' },
],
},
{
key: 'image_aspect_ratio',
label: '画面比例',
type: 'select',
description: '生成图片时使用的宽高比(影响构图与画布尺寸)',
options: [
{ value: '16:9', label: '16:9(横屏)' },
{ value: '4:3', label: '4:3(传统横屏)' },
{ value: '21:9', label: '21:9(超宽屏)' },
{ value: '1:1', label: '1:1(方形)' },
{ value: '3:4', label: '3:4(竖屏)' },
{ value: '9:16', label: '9:16(竖屏)' },
{ value: '3:2', label: '3:2(横屏)' },
{ value: '2:3', label: '2:3(竖屏)' },
{ value: '5:4', label: '5:4(横屏)' },
{ value: '4:5', label: '4:5(竖屏)' },
],
},
],
},
{
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ dependencies = [
"python-pptx>=1.0.0",
"python-dotenv>=1.0.1",
"reportlab>=4.1.0",
"requests>=2.0.0",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The integration tests (backend/tests/integration/test_api_full_flow.py) have been updated to use httpx instead of requests. If requests is no longer used anywhere else in the backend, it should be removed from the dependencies list to keep the project dependencies lean and avoid unnecessary packages.

"werkzeug>=3.0.1",
"markitdown[all]",
"tenacity>=9.0.0",
Expand Down
2 changes: 2 additions & 0 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading