Story 2.4 html formatting simple solution

main
PeninsulaInd 2025-10-20 16:43:31 -05:00
parent 19e1c93358
commit 7a9346635b
17 changed files with 1732 additions and 98 deletions

View File

@ -54,3 +54,19 @@ Implement the core workflow for ingesting CORA data and using AI to generate and
- The function correctly selects and applies the appropriate template based on the configuration mapping. - The function correctly selects and applies the appropriate template based on the configuration mapping.
- The content is structured into a valid HTML document with the selected CSS. - The content is structured into a valid HTML document with the selected CSS.
- The final HTML content is stored and associated with the project in the database. - The final HTML content is stored and associated with the project in the database.
**Dependencies**
- Story 2.5 (optional): If no site_deployment_id is assigned, template selection defaults to random.
### Story 2.5: Deployment Target Assignment
**As a developer**, I want to assign deployment targets to generated content during the content generation process, so that each article knows which site/bucket it will be deployed to and can use the appropriate template.
**Acceptance Criteria**
- The job configuration file supports an optional `deployment_targets` array containing site custom_hostnames or site_deployment_ids.
- The job configuration file supports an optional `deployment_overflow` strategy ("round_robin", "random_available", or "none").
- During content generation, each article is assigned a `site_deployment_id` based on its index in the batch:
- If `deployment_targets` is specified, cycle through the list (round-robin by default).
- If the batch size exceeds the target list, apply the overflow strategy.
- If no `deployment_targets` specified, `site_deployment_id` remains null (random template in Story 2.4).
- The `site_deployment_id` is stored in the `GeneratedContent` record at creation time.
- Invalid site references in `deployment_targets` cause graceful errors with clear messages.

View File

@ -0,0 +1,141 @@
# Story 2.4: HTML Formatting with Multiple Templates
## Status
Completed
## Story
**As a developer**, I want a module that takes the generated text content and formats it into a standard HTML file using one of a few predefined CSS templates, assigning one template per bucket/subdomain, so that all deployed content has a consistent look and feel per site.
## Acceptance Criteria
- A directory of multiple, predefined HTML/CSS templates exists.
- The master JSON configuration file maps a specific template to each deployment target (e.g., S3 bucket, subdomain).
- A function accepts the generated content and a target identifier (e.g., bucket name).
- The function correctly selects and applies the appropriate template based on the configuration mapping.
- The content is structured into a valid HTML document with the selected CSS.
- The final HTML content is stored and associated with the project in the database.
## Dependencies
- **Story 2.5**: Deployment Target Assignment must run before this story to set `site_deployment_id` on GeneratedContent
- If `site_deployment_id` is null, a random template will be selected
## Tasks / Subtasks
### 1. Create Template Infrastructure
**Effort:** 3 story points
- [x] Create template file structure under `src/templating/templates/`
- Basic template (default)
- Modern template
- Classic template
- Minimal template
- [x] Each template should include:
- HTML structure with placeholders for title, meta, content
- Embedded or inline CSS for styling
- Responsive design (mobile-friendly)
- SEO-friendly structure (proper heading hierarchy, meta tags)
### 2. Implement Template Loading Service
**Effort:** 3 story points
- [x] Implement `TemplateService` class in `src/templating/service.py`
- [x] Add `load_template(template_name: str)` method that reads template file
- [x] Add `get_available_templates()` method that lists all templates
- [x] Handle template file not found errors gracefully with fallback to default
- [x] Cache loaded templates in memory for performance
### 3. Implement Template Selection Logic
**Effort:** 2 story points
- [x] Add `select_template_for_content(site_deployment_id: Optional[int])` method
- [x] If `site_deployment_id` exists:
- Query SiteDeployment table for custom_hostname
- Check `master.config.json` templates.mappings for hostname
- If mapping exists, use it
- If no mapping, randomly select template and save to config
- [x] If `site_deployment_id` is null: randomly select template
- [x] Return template name
### 4. Implement Content Formatting
**Effort:** 5 story points
- [x] Create `format_content(content: str, title: str, meta_description: str, template_name: str)` method
- [x] Parse HTML content and extract components
- [x] Replace template placeholders with actual content
- [x] Ensure proper escaping of HTML entities where needed
- [x] Validate output is well-formed HTML
- [x] Return formatted HTML string
### 5. Database Integration
**Effort:** 2 story points
- [x] Add `formatted_html` field to `GeneratedContent` model (Text type, nullable)
- [x] Add `template_used` field to `GeneratedContent` model (String(50), nullable)
- [x] Add `site_deployment_id` field to `GeneratedContent` model (FK to site_deployments, nullable, indexed)
- [x] Create database migration script
- [x] Update repository to save formatted HTML and template_used alongside raw content
### 6. Integration with Content Generation Flow
**Effort:** 2 story points
- [x] Update `src/generation/service.py` to call template service after content generation
- [x] Template service reads `site_deployment_id` from GeneratedContent
- [x] Store formatted HTML and template_used in database
- [x] Handle template formatting errors without breaking content generation
### 7. Unit Tests
**Effort:** 3 story points
- [x] Test template loading with valid and invalid names
- [x] Test template selection with site_deployment_id present
- [x] Test template selection with site_deployment_id null (random)
- [x] Test content formatting with different templates
- [x] Test fallback behavior when template not found
- [x] Test error handling for malformed templates
- [x] Achieve >80% code coverage for templating module
### 8. Integration Tests
**Effort:** 2 story points
- [x] Test end-to-end flow: content generation → template application → database storage
- [x] Test with site_deployment_id assigned (consistent template per site)
- [x] Test with site_deployment_id null (random template)
- [x] Verify formatted HTML is valid and renders correctly
- [x] Test new site gets random template assigned and persisted to config
## Dev Notes
### Current State
- `master.config.json` already has templates section with mappings (lines 52-59)
- `src/templating/service.py` exists but is empty (only 2 lines)
- `src/templating/templates/` directory exists but only contains `__init__.py`
- `GeneratedContent` model stores raw content in Text field but no formatted HTML field yet
### Dependencies
- Story 2.2/2.3: Content must be generated before it can be formatted
- Story 2.5: Deployment target assignment (optional - defaults to random if not assigned)
- Configuration system: Uses existing master.config.json structure
### Technical Decisions
1. **Template format:** Jinja2 or simple string replacement (to be decided during implementation)
2. **CSS approach:** Embedded `<style>` tags in HTML template
3. **Storage:** Store both raw content AND formatted HTML
4. **Template selection:**
- Has site_deployment_id + config mapping → use mapped template
- Has site_deployment_id + no mapping → pick random, save to config
- No site_deployment_id → pick random, don't persist
5. Story 2.4 owns ALL template selection logic
### Suggested Template Structure
```
src/templating/templates/
├── basic.html
├── modern.html
├── classic.html
└── minimal.html
```
### Testing Approach
- Unit tests: Test each service method in isolation
- Integration tests: Test full content → template → storage flow
- Manual QA: Visual inspection of rendered HTML in browser

View File

@ -0,0 +1,157 @@
# Story 2.5: Deployment Target Assignment
## Status
Draft
## Story
**As a developer**, I want to assign deployment targets (site_deployment_id) to generated content during the content generation process based on job configuration, so that each article knows which site/bucket it will be deployed to.
**Note:** This story ONLY assigns site_deployment_id. Template selection logic is handled entirely by Story 2.4.
## Acceptance Criteria
- The job configuration file supports an optional `deployment_targets` array containing site custom_hostnames or site_deployment_ids.
- The job configuration file supports an optional `deployment_overflow` strategy ("round_robin", "random_available", or "none").
- During content generation, each article is assigned a `site_deployment_id` based on its index in the batch:
- If `deployment_targets` is specified, cycle through the list (round-robin by default).
- If the batch size exceeds the target list, apply the overflow strategy.
- If no `deployment_targets` specified, `site_deployment_id` remains null (random template in Story 2.4).
- The `site_deployment_id` is stored in the `GeneratedContent` record at creation time.
- Invalid site references in `deployment_targets` cause graceful errors with clear messages.
## Tasks / Subtasks
### 1. Update Job Configuration Schema
**Effort:** 2 story points
- [ ] Add `deployment_targets` field (optional array of strings) to job config schema
- [ ] Add `deployment_overflow` field (optional string: "round_robin", "random_available", "none")
- [ ] Default `deployment_overflow` to "round_robin" if not specified
- [ ] Update job config validation to check deployment_targets format
- [ ] Update example job files in `jobs/` directory with new fields
### 2. Implement Target Resolution Service
**Effort:** 3 story points
- [ ] Create `DeploymentTargetResolver` class in `src/deployment/` or appropriate module
- [ ] Implement `resolve_target(identifier: str) -> Optional[int]` method
- Accept custom_hostname or site_deployment_id (as string)
- Query SiteDeployment table to get site_deployment_id
- Return None if not found
- [ ] Implement `validate_targets(targets: List[str])` method
- Pre-validate all targets in deployment_targets array
- Return list of invalid targets if any
- Fail fast with clear error message
### 3. Implement Assignment Strategy Logic
**Effort:** 4 story points
- [ ] Implement `assign_site_for_article(article_index: int, job_config: dict, total_articles: int) -> Optional[int]`
- [ ] **Round-robin strategy:**
- Cycle through deployment_targets using modulo operation
- Example: 10 articles, 5 targets → article_index % len(targets)
- [ ] **Random available strategy:**
- When article_index exceeds len(targets), query for SiteDeployments not in targets list
- Randomly select from available sites
- Handle case where no other sites exist (error)
- [ ] **None strategy:**
- Raise error if article_index exceeds len(targets)
- Strict mode: only deploy exact number of articles as targets
- [ ] Handle case where deployment_targets is None/empty (return None for all)
### 4. Database Integration
**Effort:** 2 story points
- [ ] Verify `site_deployment_id` field exists in `GeneratedContent` model (added in Story 2.4)
- [ ] Update `GeneratedContentRepository.create()` to accept `site_deployment_id` parameter
- [ ] Ensure proper foreign key relationship to SiteDeployment table
- [ ] Add database index on `site_deployment_id` for query performance
### 5. Integration with Content Generation Service
**Effort:** 3 story points
- [ ] Update `src/generation/service.py` to parse deployment config from job
- [ ] Call target resolver to validate deployment_targets at job start
- [ ] For each article in batch:
- Call assignment strategy to get site_deployment_id
- Pass site_deployment_id to repository when creating GeneratedContent
- [ ] Log assignment decisions (INFO level: "Article X assigned to site Y")
- [ ] Handle assignment errors gracefully without breaking batch
### 6. Unit Tests
**Effort:** 3 story points
- [ ] Test target resolution with valid hostnames
- [ ] Test target resolution with valid site_deployment_ids
- [ ] Test target resolution with invalid identifiers
- [ ] Test round-robin strategy with various batch sizes
- [ ] Test random_available strategy
- [ ] Test none strategy with overflow scenarios
- [ ] Test validation of deployment_targets array
- [ ] Achieve >80% code coverage
### 7. Integration Tests
**Effort:** 2 story points
- [ ] Test full generation flow with deployment_targets specified
- [ ] Test round-robin assignment across 10 articles with 5 targets
- [ ] Test with deployment_targets = null (all articles get null site_deployment_id)
- [ ] Test error handling for invalid deployment targets
- [ ] Verify site_deployment_id persisted correctly in database
## Dev Notes
### Example Job Config
```json
{
"job_name": "Multi-Site T1 Launch",
"project_id": 2,
"deployment_targets": [
"www.domain1.com",
"www.domain2.com",
"www.domain3.com"
],
"deployment_overflow": "round_robin",
"tiers": [
{
"tier": 1,
"article_count": 10
}
]
}
```
### Assignment Example (Round-Robin)
10 articles, 3 targets:
- Article 0 → domain1.com
- Article 1 → domain2.com
- Article 2 → domain3.com
- Article 3 → domain1.com
- Article 4 → domain2.com
- ... and so on
### Assignment Example (Random Available)
10 articles, 3 targets, 5 total sites in database:
- Article 0-2 → Round-robin through specified targets
- Article 3+ → Random selection from domain4.com, domain5.com
### Technical Decisions
1. **Target identifier:** Support both hostname and numeric ID for flexibility
2. **Validation timing:** Validate all targets at job start (fail fast)
3. **Overflow default:** Round-robin is the safest default
4. **Null handling:** No deployment_targets = all articles get null site_deployment_id
### Dependencies
- **Story 1.6:** SiteDeployment table must exist
- **Story 2.3:** Content generation service must be functional
### Related Stories
- **Story 2.4:** Consumes site_deployment_id for template selection (but that's 2.4's concern, not this story's)
### Database Changes Required
None - `site_deployment_id` field added in Story 2.4 task #5
### Testing Strategy
- Unit tests: Test assignment algorithms in isolation
- Integration tests: Test full job execution with various configs
- Edge cases: Empty targets, oversized batches, invalid hostnames

View File

@ -30,6 +30,8 @@ Job files define batch content generation parameters using JSON format.
### Job Level ### Job Level
- `project_id` (required): The project ID to generate content for - `project_id` (required): The project ID to generate content for
- `tiers` (required): Dictionary of tier configurations - `tiers` (required): Dictionary of tier configurations
- `deployment_targets` (optional): Array of site custom_hostnames or site_deployment_ids to cycle through
- `deployment_overflow` (optional): Strategy when batch size exceeds deployment_targets ("round_robin", "random_available", or "none"). Default: "round_robin"
### Tier Level ### Tier Level
- `count` (required): Number of articles to generate for this tier - `count` (required): Number of articles to generate for this tier
@ -169,6 +171,43 @@ python main.py generate-batch --job-file jobs/example_tier1_batch.json --usernam
- `--continue-on-error`: Continue processing if article generation fails - `--continue-on-error`: Continue processing if article generation fails
- `--model, -m`: AI model to use (default: gpt-4o-mini) - `--model, -m`: AI model to use (default: gpt-4o-mini)
### Deployment Target Assignment (Story 2.5)
Optionally specify which sites/buckets to deploy articles to:
```json
{
"jobs": [
{
"project_id": 1,
"deployment_targets": [
"www.domain1.com",
"www.domain2.com",
"www.domain3.com"
],
"deployment_overflow": "round_robin",
"tiers": {
"tier1": {
"count": 10
}
}
}
]
}
```
This generates 10 articles distributed across 3 sites:
- Articles 0, 3, 6, 9 → domain1.com
- Articles 1, 4, 7 → domain2.com
- Articles 2, 5, 8 → domain3.com
**Overflow Strategies:**
- `round_robin` (default): Cycle back through specified targets
- `random_available`: Use random sites not in the targets list
- `none`: Error if batch exceeds target count (strict mode)
If `deployment_targets` is omitted, articles receive random templates (no site assignment).
### Debug Mode ### Debug Mode
When using `--debug`, AI responses are saved to `debug_output/`: When using `--debug`, AI responses are saved to `debug_output/`:

View File

@ -0,0 +1,38 @@
{
"job_name": "Multi-Site T1 Launch - 10 Articles Across 3 Sites",
"project_id": 2,
"description": "Generate 10 Tier 1 articles distributed across 3 deployment sites",
"deployment_targets": [
"www.domain1.com",
"www.domain2.com",
"www.domain3.com"
],
"deployment_overflow": "round_robin",
"tiers": [
{
"tier": 1,
"article_count": 10,
"models": {
"title": "openai/gpt-4o-mini",
"outline": "anthropic/claude-3.5-sonnet",
"content": "anthropic/claude-3.5-sonnet"
},
"anchor_text_config": {
"mode": "default",
"custom_text": null,
"additional_text": null
},
"validation_attempts": 3
}
],
"failure_config": {
"max_consecutive_failures": 5,
"skip_on_failure": true
},
"interlinking": {
"links_per_article_min": 2,
"links_per_article_max": 4,
"include_home_link": true
}
}

View File

@ -54,7 +54,8 @@
"mappings": { "mappings": {
"aws-s3-bucket-1": "modern", "aws-s3-bucket-1": "modern",
"bunny-bucket-1": "classic", "bunny-bucket-1": "classic",
"azure-bucket-1": "minimal" "azure-bucket-1": "minimal",
"test.example.com": "minimal"
} }
}, },
"deployment": { "deployment": {
@ -105,4 +106,4 @@
"reload": true, "reload": true,
"workers": 1 "workers": 1
} }
} }

View File

@ -0,0 +1,95 @@
"""
Database migration script to add template-related fields to generated_content table
Adds:
- formatted_html (Text, nullable)
- template_used (String(50), nullable)
- site_deployment_id (Integer, FK to site_deployments, nullable, indexed)
Usage:
python scripts/migrate_add_template_fields.py
"""
import sys
from pathlib import Path
project_root = Path(__file__).parent.parent
sys.path.insert(0, str(project_root))
from sqlalchemy import text
from src.database.session import db_manager
from src.core.config import get_config
def migrate():
"""Add template-related fields to generated_content table"""
print("Starting migration: add template fields to generated_content...")
try:
config = get_config()
print(f"Database URL: {config.database.url}")
except Exception as e:
print(f"Error loading configuration: {e}")
sys.exit(1)
try:
db_manager.initialize()
engine = db_manager.get_engine()
with engine.connect() as conn:
print("Checking for existing columns...")
result = conn.execute(text("PRAGMA table_info(generated_content)"))
existing_columns = [row[1] for row in result]
print(f"Existing columns: {', '.join(existing_columns)}")
migrations_applied = []
if "formatted_html" not in existing_columns:
print("Adding formatted_html column...")
conn.execute(text("ALTER TABLE generated_content ADD COLUMN formatted_html TEXT"))
migrations_applied.append("formatted_html")
conn.commit()
else:
print("formatted_html column already exists, skipping")
if "template_used" not in existing_columns:
print("Adding template_used column...")
conn.execute(text("ALTER TABLE generated_content ADD COLUMN template_used VARCHAR(50)"))
migrations_applied.append("template_used")
conn.commit()
else:
print("template_used column already exists, skipping")
if "site_deployment_id" not in existing_columns:
print("Adding site_deployment_id column...")
conn.execute(text("ALTER TABLE generated_content ADD COLUMN site_deployment_id INTEGER"))
migrations_applied.append("site_deployment_id")
conn.commit()
print("Creating index on site_deployment_id...")
conn.execute(text(
"CREATE INDEX IF NOT EXISTS ix_generated_content_site_deployment_id "
"ON generated_content(site_deployment_id)"
))
conn.commit()
else:
print("site_deployment_id column already exists, skipping")
if migrations_applied:
print(f"\nMigration complete! Added columns: {', '.join(migrations_applied)}")
else:
print("\nNo migrations needed - all columns already exist")
except Exception as e:
print(f"Error during migration: {e}")
import traceback
traceback.print_exc()
sys.exit(1)
finally:
db_manager.close()
if __name__ == "__main__":
migrate()

View File

@ -132,6 +132,9 @@ class GeneratedContent(Base):
content: Mapped[str] = mapped_column(Text, nullable=False) content: Mapped[str] = mapped_column(Text, nullable=False)
word_count: Mapped[int] = mapped_column(Integer, nullable=False) word_count: Mapped[int] = mapped_column(Integer, nullable=False)
status: Mapped[str] = mapped_column(String(20), nullable=False) status: Mapped[str] = mapped_column(String(20), nullable=False)
formatted_html: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
template_used: Mapped[Optional[str]] = mapped_column(String(50), nullable=True)
site_deployment_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey('site_deployments.id'), nullable=True, index=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False) created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
updated_at: Mapped[datetime] = mapped_column( updated_at: Mapped[datetime] = mapped_column(
DateTime, DateTime,

View File

@ -5,6 +5,7 @@ Concrete repository implementations
from typing import Optional, List, Dict, Any from typing import Optional, List, Dict, Any
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from src.core.config import get_config
from src.database.interfaces import IUserRepository, ISiteDeploymentRepository, IProjectRepository from src.database.interfaces import IUserRepository, ISiteDeploymentRepository, IProjectRepository
from src.database.models import User, SiteDeployment, Project, GeneratedContent from src.database.models import User, SiteDeployment, Project, GeneratedContent
@ -376,7 +377,6 @@ class ProjectRepository(IProjectRepository):
return False return False
<<<<<<< HEAD
class GeneratedContentRepository: class GeneratedContentRepository:
"""Repository for GeneratedContent data access""" """Repository for GeneratedContent data access"""
@ -426,98 +426,6 @@ class GeneratedContentRepository:
self.session.refresh(content_record) self.session.refresh(content_record)
return content_record return content_record
def get_by_id(self, content_id: int) -> Optional[GeneratedContent]:
"""
Get generated content by ID
Args:
content_id: The content ID to search for
Returns:
GeneratedContent object if found, None otherwise
"""
return self.session.query(GeneratedContent).filter(GeneratedContent.id == content_id).first()
def get_by_project_id(self, project_id: int) -> List[GeneratedContent]:
"""
Get all generated content for a project
Args:
project_id: The project ID to search for
Returns:
List of GeneratedContent objects for the project
"""
return self.session.query(GeneratedContent).filter(GeneratedContent.project_id == project_id).all()
def get_active_by_project(self, project_id: int, tier: int) -> Optional[GeneratedContent]:
"""
Get the active generated content for a project/tier
Args:
project_id: The project ID
tier: The tier level
Returns:
Active GeneratedContent object if found, None otherwise
"""
return self.session.query(GeneratedContent).filter(
GeneratedContent.project_id == project_id,
GeneratedContent.tier == tier,
GeneratedContent.is_active == True
).first()
def get_by_tier(self, tier: int) -> List[GeneratedContent]:
"""
Get all generated content for a specific tier
Args:
tier: The tier level
Returns:
List of GeneratedContent objects for the tier
"""
return self.session.query(GeneratedContent).filter(GeneratedContent.tier == tier).all()
def get_by_status(self, status: str) -> List[GeneratedContent]:
"""
Get all generated content with a specific status
Args:
status: The status to filter by
Returns:
List of GeneratedContent objects with the status
"""
return self.session.query(GeneratedContent).filter(GeneratedContent.status == status).all()
def update(self, content: GeneratedContent) -> GeneratedContent:
"""
Update an existing generated content record
Args:
content: The GeneratedContent object with updated data
Returns:
The updated GeneratedContent object
"""
=======
content_record = GeneratedContent(
project_id=project_id,
tier=tier,
keyword=keyword,
title=title,
outline=outline,
content=content,
word_count=word_count,
status=status
)
self.session.add(content_record)
self.session.commit()
self.session.refresh(content_record)
return content_record
def get_by_id(self, content_id: int) -> Optional[GeneratedContent]: def get_by_id(self, content_id: int) -> Optional[GeneratedContent]:
"""Get content by ID""" """Get content by ID"""
return self.session.query(GeneratedContent).filter(GeneratedContent.id == content_id).first() return self.session.query(GeneratedContent).filter(GeneratedContent.id == content_id).first()
@ -537,6 +445,10 @@ class GeneratedContentRepository:
"""Get content by keyword""" """Get content by keyword"""
return self.session.query(GeneratedContent).filter(GeneratedContent.keyword == keyword).all() return self.session.query(GeneratedContent).filter(GeneratedContent.keyword == keyword).all()
def get_by_status(self, status: str) -> List[GeneratedContent]:
"""Get content by status"""
return self.session.query(GeneratedContent).filter(GeneratedContent.status == status).all()
def update(self, content: GeneratedContent) -> GeneratedContent: def update(self, content: GeneratedContent) -> GeneratedContent:
"""Update existing content""" """Update existing content"""
self.session.add(content) self.session.add(content)

View File

@ -9,7 +9,8 @@ from pathlib import Path
from datetime import datetime from datetime import datetime
from typing import Optional, Tuple from typing import Optional, Tuple
from src.generation.ai_client import AIClient, PromptManager from src.generation.ai_client import AIClient, PromptManager
from src.database.repositories import ProjectRepository, GeneratedContentRepository from src.database.repositories import ProjectRepository, GeneratedContentRepository, SiteDeploymentRepository
from src.templating.service import TemplateService
class ContentGenerator: class ContentGenerator:
@ -20,12 +21,16 @@ class ContentGenerator:
ai_client: AIClient, ai_client: AIClient,
prompt_manager: PromptManager, prompt_manager: PromptManager,
project_repo: ProjectRepository, project_repo: ProjectRepository,
content_repo: GeneratedContentRepository content_repo: GeneratedContentRepository,
template_service: Optional[TemplateService] = None,
site_deployment_repo: Optional[SiteDeploymentRepository] = None
): ):
self.ai_client = ai_client self.ai_client = ai_client
self.prompt_manager = prompt_manager self.prompt_manager = prompt_manager
self.project_repo = project_repo self.project_repo = project_repo
self.content_repo = content_repo self.content_repo = content_repo
self.template_service = template_service or TemplateService(content_repo)
self.site_deployment_repo = site_deployment_repo
def generate_title(self, project_id: int, debug: bool = False) -> str: def generate_title(self, project_id: int, debug: bool = False) -> str:
""" """
@ -286,6 +291,56 @@ class ContentGenerator:
return augmented return augmented
def apply_template(
self,
content_id: int,
meta_description: Optional[str] = None
) -> bool:
"""
Apply HTML template to generated content and save to database
Args:
content_id: GeneratedContent ID to format
meta_description: Optional meta description (defaults to truncated content)
Returns:
True if successful, False otherwise
"""
try:
content_record = self.content_repo.get_by_id(content_id)
if not content_record:
print(f"Warning: Content {content_id} not found")
return False
if not meta_description:
text = re.sub(r'<[^>]+>', '', content_record.content)
text = unescape(text)
words = text.split()[:25]
meta_description = ' '.join(words) + '...'
template_name = self.template_service.select_template_for_content(
site_deployment_id=content_record.site_deployment_id,
site_deployment_repo=self.site_deployment_repo
)
formatted_html = self.template_service.format_content(
content=content_record.content,
title=content_record.title,
meta_description=meta_description,
template_name=template_name
)
content_record.formatted_html = formatted_html
content_record.template_used = template_name
self.content_repo.update(content_record)
print(f"Applied template '{template_name}' to content {content_id}")
return True
except Exception as e:
print(f"Error applying template to content {content_id}: {e}")
return False
def _save_debug_output( def _save_debug_output(
self, self,
project_id: int, project_id: int,

View File

@ -1 +1,202 @@
# Applies templates to content """
Template service for applying HTML/CSS templates to generated content
"""
import json
import random
from pathlib import Path
from typing import Optional, Dict
from src.core.config import get_config
from src.database.repositories import GeneratedContentRepository
class TemplateService:
"""Service for loading, selecting, and applying HTML templates"""
def __init__(self, content_repo: Optional[GeneratedContentRepository] = None):
self.templates_dir = Path(__file__).parent / "templates"
self._template_cache: Dict[str, str] = {}
self.content_repo = content_repo
def get_available_templates(self) -> list[str]:
"""
Get list of available template names
Returns:
List of template names without .html extension
"""
templates = []
for template_file in self.templates_dir.glob("*.html"):
templates.append(template_file.stem)
return sorted(templates)
def load_template(self, template_name: str) -> str:
"""
Load a template file by name
Args:
template_name: Name of template (without .html extension)
Returns:
Template content as string
Raises:
FileNotFoundError: If template doesn't exist
"""
if template_name in self._template_cache:
return self._template_cache[template_name]
template_path = self.templates_dir / f"{template_name}.html"
if not template_path.exists():
available = self.get_available_templates()
raise FileNotFoundError(
f"Template '{template_name}' not found. Available templates: {', '.join(available)}"
)
with open(template_path, 'r', encoding='utf-8') as f:
template_content = f.read()
self._template_cache[template_name] = template_content
return template_content
def select_template_for_content(
self,
site_deployment_id: Optional[int] = None,
site_deployment_repo=None
) -> str:
"""
Select appropriate template based on site deployment
Args:
site_deployment_id: Optional site deployment ID
site_deployment_repo: Optional repository for querying site deployments
Returns:
Template name to use
Logic:
1. If site_deployment_id exists:
- Query custom_hostname from SiteDeployment
- Check config mappings for hostname
- If mapping exists, use it
- If no mapping, randomly select and persist to config
2. If site_deployment_id is null: randomly select (don't persist)
"""
config = get_config()
if site_deployment_id and site_deployment_repo:
site_deployment = site_deployment_repo.get_by_id(site_deployment_id)
if site_deployment:
hostname = site_deployment.custom_hostname
if hostname in config.templates.mappings:
return config.templates.mappings[hostname]
template_name = self._select_random_template()
self._persist_template_mapping(hostname, template_name)
return template_name
return self._select_random_template()
def _select_random_template(self) -> str:
"""
Randomly select a template from available templates
Returns:
Template name
"""
available = self.get_available_templates()
if not available:
return "basic"
return random.choice(available)
def _persist_template_mapping(self, hostname: str, template_name: str) -> None:
"""
Save template mapping to master.config.json
Args:
hostname: Custom hostname to map
template_name: Template to assign
"""
config_path = Path("master.config.json")
try:
with open(config_path, 'r', encoding='utf-8') as f:
config_data = json.load(f)
if "templates" not in config_data:
config_data["templates"] = {"default": "basic", "mappings": {}}
if "mappings" not in config_data["templates"]:
config_data["templates"]["mappings"] = {}
config_data["templates"]["mappings"][hostname] = template_name
with open(config_path, 'w', encoding='utf-8') as f:
json.dump(config_data, f, indent=2)
except Exception as e:
print(f"Warning: Failed to persist template mapping: {e}")
def format_content(
self,
content: str,
title: str,
meta_description: str,
template_name: str
) -> str:
"""
Format content using specified template
Args:
content: Raw HTML content (h2, h3, p tags)
title: Article title
meta_description: Meta description for SEO
template_name: Name of template to use
Returns:
Complete formatted HTML document
Raises:
FileNotFoundError: If template doesn't exist
"""
config = get_config()
try:
template = self.load_template(template_name)
except FileNotFoundError:
fallback_template = config.templates.default
print(f"Warning: Template '{template_name}' not found, using '{fallback_template}'")
template = self.load_template(fallback_template)
formatted_html = template.replace("{{ title }}", self._escape_html(title))
formatted_html = formatted_html.replace("{{ meta_description }}", self._escape_html(meta_description))
formatted_html = formatted_html.replace("{{ content }}", content)
return formatted_html
def _escape_html(self, text: str) -> str:
"""
Escape HTML special characters in text
Args:
text: Text to escape
Returns:
Escaped text
"""
replacements = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;'
}
for char, escaped in replacements.items():
text = text.replace(char, escaped)
return text

View File

@ -0,0 +1,83 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="{{ meta_description }}">
<title>{{ title }}</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
line-height: 1.6;
color: #333;
background-color: #fff;
padding: 20px;
max-width: 800px;
margin: 0 auto;
}
h1 {
font-size: 2.5rem;
margin-bottom: 1rem;
color: #1a1a1a;
border-bottom: 3px solid #007bff;
padding-bottom: 0.5rem;
}
h2 {
font-size: 1.8rem;
margin-top: 2rem;
margin-bottom: 1rem;
color: #2c3e50;
}
h3 {
font-size: 1.4rem;
margin-top: 1.5rem;
margin-bottom: 0.75rem;
color: #34495e;
}
p {
margin-bottom: 1rem;
text-align: justify;
}
ul, ol {
margin-bottom: 1rem;
margin-left: 2rem;
}
li {
margin-bottom: 0.5rem;
}
a {
color: #007bff;
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
@media (max-width: 768px) {
body {
padding: 15px;
}
h1 {
font-size: 2rem;
}
h2 {
font-size: 1.5rem;
}
h3 {
font-size: 1.2rem;
}
}
</style>
</head>
<body>
<article>
<h1>{{ title }}</h1>
{{ content }}
</article>
</body>
</html>

View File

@ -0,0 +1,105 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="{{ meta_description }}">
<title>{{ title }}</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: Georgia, 'Times New Roman', serif;
line-height: 1.8;
color: #2c2c2c;
background-color: #f9f6f2;
padding: 20px;
}
article {
max-width: 750px;
margin: 0 auto;
background: #fff;
padding: 50px 60px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
border: 1px solid #e0d7c9;
}
h1 {
font-size: 2.8rem;
margin-bottom: 1rem;
color: #1a1a1a;
font-weight: 600;
text-align: center;
letter-spacing: -0.01em;
padding-bottom: 1rem;
border-bottom: 2px solid #8b7355;
}
h2 {
font-size: 2rem;
margin-top: 2.5rem;
margin-bottom: 1rem;
color: #3a3a3a;
font-weight: 600;
}
h3 {
font-size: 1.5rem;
margin-top: 1.75rem;
margin-bottom: 0.75rem;
color: #4a4a4a;
font-weight: 600;
font-style: italic;
}
p {
margin-bottom: 1.25rem;
text-indent: 1.5em;
text-align: justify;
}
p:first-of-type {
font-size: 1.1rem;
}
ul, ol {
margin-bottom: 1.25rem;
margin-left: 3rem;
}
li {
margin-bottom: 0.5rem;
}
a {
color: #8b7355;
text-decoration: underline;
}
a:hover {
color: #5d4a37;
}
@media (max-width: 768px) {
body {
padding: 10px;
}
article {
padding: 30px 25px;
}
h1 {
font-size: 2rem;
}
h2 {
font-size: 1.6rem;
}
h3 {
font-size: 1.3rem;
}
p {
text-indent: 0;
}
}
</style>
</head>
<body>
<article>
<h1>{{ title }}</h1>
{{ content }}
</article>
</body>
</html>

View File

@ -0,0 +1,86 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="{{ meta_description }}">
<title>{{ title }}</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
line-height: 1.75;
color: #000;
background-color: #fff;
padding: 40px 20px;
max-width: 700px;
margin: 0 auto;
}
h1 {
font-size: 2.2rem;
margin-bottom: 2rem;
color: #000;
font-weight: 700;
letter-spacing: -0.03em;
}
h2 {
font-size: 1.6rem;
margin-top: 3rem;
margin-bottom: 1rem;
color: #000;
font-weight: 600;
}
h3 {
font-size: 1.3rem;
margin-top: 2rem;
margin-bottom: 0.75rem;
color: #000;
font-weight: 600;
}
p {
margin-bottom: 1.5rem;
font-size: 1rem;
}
ul, ol {
margin-bottom: 1.5rem;
margin-left: 1.5rem;
}
li {
margin-bottom: 0.5rem;
}
a {
color: #000;
text-decoration: none;
border-bottom: 1px solid #000;
}
a:hover {
border-bottom: 2px solid #000;
}
@media (max-width: 768px) {
body {
padding: 20px 15px;
}
h1 {
font-size: 1.8rem;
}
h2 {
font-size: 1.4rem;
}
h3 {
font-size: 1.2rem;
}
}
</style>
</head>
<body>
<article>
<h1>{{ title }}</h1>
{{ content }}
</article>
</body>
</html>

View File

@ -0,0 +1,109 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="{{ meta_description }}">
<title>{{ title }}</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
line-height: 1.7;
color: #1e293b;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
padding: 40px 20px;
min-height: 100vh;
}
article {
background: white;
max-width: 850px;
margin: 0 auto;
padding: 60px;
border-radius: 12px;
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
}
h1 {
font-size: 3rem;
margin-bottom: 1.5rem;
color: #0f172a;
font-weight: 800;
letter-spacing: -0.02em;
}
h2 {
font-size: 2rem;
margin-top: 3rem;
margin-bottom: 1rem;
color: #334155;
font-weight: 700;
position: relative;
padding-left: 20px;
}
h2::before {
content: '';
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 4px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 2px;
}
h3 {
font-size: 1.5rem;
margin-top: 2rem;
margin-bottom: 0.75rem;
color: #475569;
font-weight: 600;
}
p {
margin-bottom: 1.25rem;
font-size: 1.05rem;
}
ul, ol {
margin-bottom: 1.25rem;
margin-left: 2rem;
}
li {
margin-bottom: 0.5rem;
}
a {
color: #667eea;
text-decoration: none;
font-weight: 500;
}
a:hover {
color: #764ba2;
text-decoration: underline;
}
@media (max-width: 768px) {
body {
padding: 20px 10px;
}
article {
padding: 30px 20px;
}
h1 {
font-size: 2rem;
}
h2 {
font-size: 1.6rem;
}
h3 {
font-size: 1.3rem;
}
}
</style>
</head>
<body>
<article>
<h1>{{ title }}</h1>
{{ content }}
</article>
</body>
</html>

View File

@ -0,0 +1,278 @@
"""
Integration tests for template service with content generation
"""
import pytest
from unittest.mock import patch
from src.database.models import Project, User, GeneratedContent, SiteDeployment
from src.database.repositories import (
ProjectRepository,
GeneratedContentRepository,
SiteDeploymentRepository
)
from src.templating.service import TemplateService
from src.generation.service import ContentGenerator
from src.generation.ai_client import AIClient, PromptManager
@pytest.fixture
def test_user(db_session):
"""Create a test user"""
user = User(
username="testuser_template",
hashed_password="hashed",
role="User"
)
db_session.add(user)
db_session.commit()
return user
@pytest.fixture
def test_project(db_session, test_user):
"""Create a test project"""
project_data = {
"main_keyword": "template testing",
"word_count": 1000,
"term_frequency": 2,
"h2_total": 5,
"h2_exact": 1,
"h3_total": 8,
"h3_exact": 1,
"entities": ["entity1", "entity2"],
"related_searches": ["search1", "search2"]
}
project_repo = ProjectRepository(db_session)
project = project_repo.create(test_user.id, "Template Test Project", project_data)
return project
@pytest.fixture
def test_site_deployment(db_session):
"""Create a test site deployment"""
site_repo = SiteDeploymentRepository(db_session)
site = site_repo.create(
site_name="Test Site",
custom_hostname="test.example.com",
storage_zone_id=12345,
storage_zone_name="test-storage",
storage_zone_password="test-password",
storage_zone_region="DE",
pull_zone_id=67890,
pull_zone_bcdn_hostname="test.b-cdn.net"
)
return site
@pytest.fixture
def test_generated_content(db_session, test_project):
"""Create test generated content"""
content_repo = GeneratedContentRepository(db_session)
content = content_repo.create(
project_id=test_project.id,
tier="tier1",
keyword="template testing",
title="Test Article About Template Testing",
outline={"outline": [{"h2": "Introduction", "h3": ["Overview", "Benefits"]}]},
content="<h2>Introduction</h2><p>This is test content.</p><h3>Overview</h3><p>More content here.</p>",
word_count=500,
status="generated"
)
return content
@pytest.mark.integration
def test_template_service_with_database(db_session):
"""Test TemplateService instantiation with database session"""
content_repo = GeneratedContentRepository(db_session)
template_service = TemplateService(content_repo=content_repo)
assert template_service is not None
assert template_service.content_repo == content_repo
assert len(template_service.get_available_templates()) >= 4
@pytest.mark.integration
def test_format_content_end_to_end(db_session, test_generated_content):
"""Test formatting content and storing in database"""
content_repo = GeneratedContentRepository(db_session)
template_service = TemplateService(content_repo=content_repo)
formatted = template_service.format_content(
content=test_generated_content.content,
title=test_generated_content.title,
meta_description="Test meta description",
template_name="basic"
)
assert "<!DOCTYPE html>" in formatted
assert test_generated_content.title in formatted
assert "Test meta description" in formatted
assert test_generated_content.content in formatted
test_generated_content.formatted_html = formatted
test_generated_content.template_used = "basic"
content_repo.update(test_generated_content)
retrieved = content_repo.get_by_id(test_generated_content.id)
assert retrieved.formatted_html is not None
assert retrieved.template_used == "basic"
assert "<!DOCTYPE html>" in retrieved.formatted_html
@pytest.mark.integration
def test_template_selection_with_site_deployment(
db_session,
test_generated_content,
test_site_deployment
):
"""Test template selection based on site deployment"""
content_repo = GeneratedContentRepository(db_session)
site_repo = SiteDeploymentRepository(db_session)
template_service = TemplateService(content_repo=content_repo)
test_generated_content.site_deployment_id = test_site_deployment.id
content_repo.update(test_generated_content)
template_name = template_service.select_template_for_content(
site_deployment_id=test_site_deployment.id,
site_deployment_repo=site_repo
)
available = template_service.get_available_templates()
assert template_name in available
@pytest.mark.integration
def test_template_selection_without_site_deployment(db_session, test_generated_content):
"""Test random template selection when no site deployment"""
content_repo = GeneratedContentRepository(db_session)
template_service = TemplateService(content_repo=content_repo)
template_name = template_service.select_template_for_content(
site_deployment_id=None,
site_deployment_repo=None
)
available = template_service.get_available_templates()
assert template_name in available
@pytest.mark.integration
def test_content_generator_apply_template(
db_session,
test_project,
test_generated_content
):
"""Test ContentGenerator.apply_template method"""
content_repo = GeneratedContentRepository(db_session)
project_repo = ProjectRepository(db_session)
mock_ai_client = None
mock_prompt_manager = None
generator = ContentGenerator(
ai_client=mock_ai_client,
prompt_manager=mock_prompt_manager,
project_repo=project_repo,
content_repo=content_repo
)
success = generator.apply_template(test_generated_content.id)
assert success is True
retrieved = content_repo.get_by_id(test_generated_content.id)
assert retrieved.formatted_html is not None
assert retrieved.template_used is not None
assert "<!DOCTYPE html>" in retrieved.formatted_html
assert retrieved.title in retrieved.formatted_html
@pytest.mark.integration
def test_multiple_content_different_templates(db_session, test_project):
"""Test that multiple content items can use different templates"""
content_repo = GeneratedContentRepository(db_session)
template_service = TemplateService(content_repo=content_repo)
content_items = []
for i in range(4):
content = content_repo.create(
project_id=test_project.id,
tier="tier1",
keyword=f"keyword {i}",
title=f"Test Article {i}",
outline={"outline": [{"h2": "Section", "h3": ["Sub"]}]},
content=f"<h2>Section {i}</h2><p>Content {i}</p>",
word_count=300,
status="generated"
)
content_items.append(content)
templates = ["basic", "modern", "classic", "minimal"]
for content, template_name in zip(content_items, templates):
formatted = template_service.format_content(
content=content.content,
title=content.title,
meta_description=f"Description {content.id}",
template_name=template_name
)
content.formatted_html = formatted
content.template_used = template_name
content_repo.update(content)
for content, expected_template in zip(content_items, templates):
retrieved = content_repo.get_by_id(content.id)
assert retrieved.template_used == expected_template
assert retrieved.formatted_html is not None
@pytest.mark.integration
def test_apply_template_with_missing_content(db_session, test_project):
"""Test apply_template with non-existent content ID"""
content_repo = GeneratedContentRepository(db_session)
project_repo = ProjectRepository(db_session)
generator = ContentGenerator(
ai_client=None,
prompt_manager=None,
project_repo=project_repo,
content_repo=content_repo
)
success = generator.apply_template(content_id=99999)
assert success is False
@pytest.mark.integration
def test_formatted_html_storage(
db_session,
test_generated_content
):
"""Test that formatted HTML is correctly stored in database"""
content_repo = GeneratedContentRepository(db_session)
template_service = TemplateService(content_repo=content_repo)
formatted = template_service.format_content(
content=test_generated_content.content,
title=test_generated_content.title,
meta_description="Meta description",
template_name="modern"
)
test_generated_content.formatted_html = formatted
test_generated_content.template_used = "modern"
test_generated_content.site_deployment_id = None
updated = content_repo.update(test_generated_content)
retrieved = content_repo.get_by_id(test_generated_content.id)
assert retrieved.formatted_html == formatted
assert retrieved.template_used == "modern"
assert retrieved.site_deployment_id is None

View File

@ -0,0 +1,315 @@
"""
Unit tests for template service
"""
import pytest
import json
from pathlib import Path
from unittest.mock import Mock, MagicMock, patch, mock_open
from src.templating.service import TemplateService
from src.database.models import GeneratedContent, SiteDeployment
@pytest.fixture
def mock_content_repo():
return Mock()
@pytest.fixture
def mock_site_deployment_repo():
return Mock()
@pytest.fixture
def template_service(mock_content_repo):
return TemplateService(content_repo=mock_content_repo)
@pytest.fixture
def mock_site_deployment():
deployment = Mock(spec=SiteDeployment)
deployment.id = 1
deployment.custom_hostname = "test.example.com"
deployment.site_name = "Test Site"
return deployment
class TestGetAvailableTemplates:
def test_returns_list_of_template_names(self, template_service):
"""Test that available templates are returned"""
templates = template_service.get_available_templates()
assert isinstance(templates, list)
assert len(templates) >= 4
assert "basic" in templates
assert "modern" in templates
assert "classic" in templates
assert "minimal" in templates
def test_templates_are_sorted(self, template_service):
"""Test that templates are returned in sorted order"""
templates = template_service.get_available_templates()
assert templates == sorted(templates)
class TestLoadTemplate:
def test_load_valid_template(self, template_service):
"""Test loading a valid template"""
template_content = template_service.load_template("basic")
assert isinstance(template_content, str)
assert len(template_content) > 0
assert "<!DOCTYPE html>" in template_content
assert "{{ title }}" in template_content
assert "{{ content }}" in template_content
def test_load_all_templates(self, template_service):
"""Test that all templates can be loaded"""
for template_name in ["basic", "modern", "classic", "minimal"]:
template_content = template_service.load_template(template_name)
assert len(template_content) > 0
def test_template_caching(self, template_service):
"""Test that templates are cached after first load"""
template_service.load_template("basic")
assert "basic" in template_service._template_cache
cached_content = template_service._template_cache["basic"]
loaded_content = template_service.load_template("basic")
assert cached_content == loaded_content
def test_load_nonexistent_template(self, template_service):
"""Test that loading nonexistent template raises error"""
with pytest.raises(FileNotFoundError) as exc_info:
template_service.load_template("nonexistent")
assert "nonexistent" in str(exc_info.value)
assert "not found" in str(exc_info.value).lower()
class TestSelectTemplateForContent:
def test_select_random_when_no_site_deployment(self, template_service):
"""Test random selection when site_deployment_id is None"""
template_name = template_service.select_template_for_content(
site_deployment_id=None,
site_deployment_repo=None
)
available_templates = template_service.get_available_templates()
assert template_name in available_templates
@patch('src.templating.service.get_config')
def test_use_existing_mapping(
self,
mock_get_config,
template_service,
mock_site_deployment,
mock_site_deployment_repo
):
"""Test using existing template mapping from config"""
mock_config = Mock()
mock_config.templates.mappings = {
"test.example.com": "modern"
}
mock_get_config.return_value = mock_config
mock_site_deployment_repo.get_by_id.return_value = mock_site_deployment
template_name = template_service.select_template_for_content(
site_deployment_id=1,
site_deployment_repo=mock_site_deployment_repo
)
assert template_name == "modern"
mock_site_deployment_repo.get_by_id.assert_called_once_with(1)
@patch('src.templating.service.get_config')
@patch('builtins.open', new_callable=mock_open)
@patch('pathlib.Path.exists', return_value=True)
def test_create_new_mapping(
self,
mock_exists,
mock_file,
mock_get_config,
template_service,
mock_site_deployment,
mock_site_deployment_repo
):
"""Test creating new mapping when hostname not in config"""
mock_config = Mock()
mock_config.templates.mappings = {}
mock_get_config.return_value = mock_config
mock_file.return_value.read.return_value = json.dumps({
"templates": {"default": "basic", "mappings": {}}
})
mock_site_deployment_repo.get_by_id.return_value = mock_site_deployment
template_name = template_service.select_template_for_content(
site_deployment_id=1,
site_deployment_repo=mock_site_deployment_repo
)
available_templates = template_service.get_available_templates()
assert template_name in available_templates
class TestFormatContent:
def test_format_with_basic_template(self, template_service):
"""Test formatting content with basic template"""
content = "<h2>Test Section</h2><p>Test paragraph.</p>"
title = "Test Article"
meta = "Test meta description"
formatted = template_service.format_content(
content=content,
title=title,
meta_description=meta,
template_name="basic"
)
assert "<!DOCTYPE html>" in formatted
assert "Test Article" in formatted
assert "Test meta description" in formatted
assert "<h2>Test Section</h2>" in formatted
assert "<p>Test paragraph.</p>" in formatted
def test_format_with_all_templates(self, template_service):
"""Test formatting with all available templates"""
content = "<h2>Section</h2><p>Content.</p>"
title = "Title"
meta = "Description"
for template_name in ["basic", "modern", "classic", "minimal"]:
formatted = template_service.format_content(
content=content,
title=title,
meta_description=meta,
template_name=template_name
)
assert len(formatted) > 0
assert "Title" in formatted
assert "Description" in formatted
assert content in formatted
def test_html_escaping_in_title(self, template_service):
"""Test that HTML is escaped in title"""
content = "<p>Content</p>"
title = "Title with <script>alert('xss')</script>"
meta = "Description"
formatted = template_service.format_content(
content=content,
title=title,
meta_description=meta,
template_name="basic"
)
assert "&lt;script&gt;" in formatted
assert "<script>" not in formatted or "<script>" in content
def test_html_escaping_in_meta(self, template_service):
"""Test that HTML is escaped in meta description"""
content = "<p>Content</p>"
title = "Title"
meta = "Description with <tag>"
formatted = template_service.format_content(
content=content,
title=title,
meta_description=meta,
template_name="basic"
)
assert "&lt;tag&gt;" in formatted
@patch('src.templating.service.get_config')
def test_fallback_to_default_template(
self,
mock_get_config,
template_service
):
"""Test fallback to default template when template not found"""
mock_config = Mock()
mock_config.templates.default = "basic"
mock_get_config.return_value = mock_config
content = "<p>Content</p>"
title = "Title"
meta = "Description"
formatted = template_service.format_content(
content=content,
title=title,
meta_description=meta,
template_name="nonexistent"
)
assert len(formatted) > 0
assert "Title" in formatted
class TestEscapeHtml:
def test_escape_special_characters(self, template_service):
"""Test HTML special character escaping"""
text = "Test & <script> \"quotes\" 'single' >"
escaped = template_service._escape_html(text)
assert "&amp;" in escaped
assert "&lt;" in escaped
assert "&gt;" in escaped
assert "&quot;" in escaped
assert "&#39;" in escaped
assert "<script>" not in escaped
def test_escape_empty_string(self, template_service):
"""Test escaping empty string"""
escaped = template_service._escape_html("")
assert escaped == ""
def test_escape_normal_text(self, template_service):
"""Test that normal text is unchanged"""
text = "Normal text without special chars"
escaped = template_service._escape_html(text)
assert escaped == text
class TestPersistTemplateMapping:
@patch('builtins.open', new_callable=mock_open)
@patch('pathlib.Path.exists', return_value=True)
def test_persist_new_mapping(
self,
mock_exists,
mock_file,
template_service
):
"""Test persisting a new template mapping"""
config_data = {
"templates": {
"default": "basic",
"mappings": {}
}
}
mock_file.return_value.read.return_value = json.dumps(config_data)
template_service._persist_template_mapping("new.example.com", "modern")
assert mock_file.call_count >= 2
@patch('builtins.open', side_effect=Exception("File error"))
@patch('pathlib.Path.exists', return_value=True)
def test_persist_handles_errors_gracefully(
self,
mock_exists,
mock_file,
template_service
):
"""Test that persist handles errors without raising"""
template_service._persist_template_mapping("test.com", "basic")