Story 2.4 html formatting simple solution
parent
19e1c93358
commit
7a9346635b
|
|
@ -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.
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
@ -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/`:
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -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": {
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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 = {
|
||||||
|
'&': '&',
|
||||||
|
'<': '<',
|
||||||
|
'>': '>',
|
||||||
|
'"': '"',
|
||||||
|
"'": '''
|
||||||
|
}
|
||||||
|
|
||||||
|
for char, escaped in replacements.items():
|
||||||
|
text = text.replace(char, escaped)
|
||||||
|
|
||||||
|
return text
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
@ -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 "<script>" 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 "<tag>" 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 "&" in escaped
|
||||||
|
assert "<" in escaped
|
||||||
|
assert ">" in escaped
|
||||||
|
assert """ in escaped
|
||||||
|
assert "'" 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")
|
||||||
|
|
||||||
Loading…
Reference in New Issue