Lifted JWT-cookie auth pattern from the AI QC sibling project: core/auth/middleware.py validates Azure AD JWTs and stores them in an httpOnly cookie (hm_aiqc_auth_token). Tenant membership is enforced by JWTValidator's tid check, which is sufficient for the tenant-wide access policy chosen for this project. templates/login.html now drives an MSAL.js popup that POSTs the ID token to /auth/login. base.html exposes Azure config to all pages so the logout button can also clear the MSAL session. app.py's @before_request now checks the JWT cookie and exposes g.user; modules read user identity via core.auth.current_user_email so usage logs and created_by columns now record the signed-in user's email rather than a session value. Legacy username/password code removed: top-level auth_middleware.py, jwt_validator.py, deploy/generate_password.py.
337 lines
12 KiB
Python
337 lines
12 KiB
Python
"""
|
|
Campaign Management Routes.
|
|
|
|
Handles CRUD operations for campaign presentations and pricing references
|
|
(independent, selectable per QC run — not tied to a specific campaign_id).
|
|
"""
|
|
import os
|
|
import shutil
|
|
import logging
|
|
import threading
|
|
from flask import (
|
|
render_template, request, jsonify,
|
|
current_app, send_from_directory
|
|
)
|
|
from werkzeug.utils import secure_filename
|
|
|
|
from core.auth import current_user_email
|
|
from .blueprint import campaigns_bp
|
|
from core.models.database import db
|
|
from core.models.campaign_presentation import CampaignPresentation
|
|
from core.models.pricing_reference import PricingReference
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
ALLOWED_EXTENSIONS = {'pdf', 'xlsx', 'xls'}
|
|
PRICING_ALLOWED_EXTENSIONS = {'pdf', 'xlsx', 'xls'}
|
|
|
|
|
|
def allowed_file(filename):
|
|
"""Check if file extension is allowed."""
|
|
return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS
|
|
|
|
|
|
@campaigns_bp.route('/')
|
|
def index():
|
|
"""List all campaign presentations and pricing references."""
|
|
presentations = CampaignPresentation.query.order_by(
|
|
CampaignPresentation.created_at.desc()
|
|
).all()
|
|
|
|
pricing_references = PricingReference.query.order_by(
|
|
PricingReference.created_at.desc()
|
|
).all()
|
|
|
|
return render_template(
|
|
'campaigns/index.html',
|
|
active_tab='campaigns',
|
|
presentations=presentations,
|
|
pricing_references=pricing_references
|
|
)
|
|
|
|
|
|
@campaigns_bp.route('/upload', methods=['POST'])
|
|
def upload():
|
|
"""Upload a campaign presentation PDF."""
|
|
# Validate file
|
|
if 'file' not in request.files:
|
|
return jsonify({'error': 'No file provided'}), 400
|
|
|
|
file = request.files['file']
|
|
if not file or file.filename == '':
|
|
return jsonify({'error': 'No file selected'}), 400
|
|
|
|
if not allowed_file(file.filename):
|
|
return jsonify({'error': 'Only PDF and Excel (.xlsx) files are allowed'}), 400
|
|
|
|
# Get form data
|
|
campaign_id = request.form.get('campaign_id', '').strip()
|
|
campaign_name = request.form.get('campaign_name', '').strip()
|
|
has_pricing = request.form.get('has_pricing', 'false').lower() == 'true'
|
|
|
|
if not campaign_id:
|
|
return jsonify({'error': 'Campaign ID is required'}), 400
|
|
|
|
try:
|
|
# Create storage directory
|
|
storage_path = current_app.config.get('CAMPAIGN_STORAGE_PATH', 'storage/campaigns')
|
|
campaign_dir = os.path.join(storage_path, campaign_id)
|
|
os.makedirs(campaign_dir, exist_ok=True)
|
|
|
|
# Save file
|
|
filename = secure_filename(file.filename)
|
|
pdf_path = os.path.join(campaign_dir, filename)
|
|
file.save(pdf_path)
|
|
|
|
# Create database record
|
|
presentation = CampaignPresentation(
|
|
campaign_id=campaign_id,
|
|
campaign_name=campaign_name or None,
|
|
pdf_filename=filename,
|
|
pdf_path=pdf_path,
|
|
has_pricing=has_pricing,
|
|
status='pending',
|
|
created_by=current_user_email() or 'unknown'
|
|
)
|
|
db.session.add(presentation)
|
|
db.session.commit()
|
|
|
|
# Start async parsing in background thread
|
|
# Must capture app reference before request context ends
|
|
from .services import parse_campaign_pdf, parse_campaign_excel
|
|
app = current_app._get_current_object()
|
|
is_excel = filename.lower().endswith(('.xlsx', '.xls'))
|
|
|
|
def run_parse():
|
|
with app.app_context():
|
|
if is_excel:
|
|
parse_campaign_excel(presentation.id)
|
|
else:
|
|
parse_campaign_pdf(presentation.id, app)
|
|
|
|
thread = threading.Thread(target=run_parse, daemon=True)
|
|
thread.start()
|
|
|
|
file_type = 'Excel' if is_excel else 'PDF'
|
|
logger.info(f"Campaign {file_type} uploaded: {campaign_id}/{filename} (id={presentation.id})")
|
|
|
|
return jsonify({
|
|
'success': True,
|
|
'id': presentation.id,
|
|
'campaign_id': campaign_id,
|
|
'message': f'PDF uploaded. Parsing in background...'
|
|
})
|
|
|
|
except Exception as e:
|
|
logger.error(f"Upload failed: {e}", exc_info=True)
|
|
return jsonify({'error': f'Upload failed: {str(e)}'}), 500
|
|
|
|
|
|
@campaigns_bp.route('/<int:presentation_id>')
|
|
def view(presentation_id):
|
|
"""View a single campaign presentation."""
|
|
presentation = CampaignPresentation.query.get_or_404(presentation_id)
|
|
|
|
# Get page images if available
|
|
page_images = []
|
|
if presentation.page_images_dir and os.path.isdir(presentation.page_images_dir):
|
|
from .services import get_page_image_paths
|
|
page_images = get_page_image_paths(presentation.page_images_dir)
|
|
|
|
return render_template(
|
|
'campaigns/view.html',
|
|
active_tab='campaigns',
|
|
presentation=presentation,
|
|
page_images=page_images
|
|
)
|
|
|
|
|
|
@campaigns_bp.route('/<int:presentation_id>', methods=['DELETE'])
|
|
def delete(presentation_id):
|
|
"""Delete a campaign presentation."""
|
|
presentation = CampaignPresentation.query.get_or_404(presentation_id)
|
|
|
|
try:
|
|
# Remove files
|
|
if presentation.pdf_path and os.path.exists(presentation.pdf_path):
|
|
campaign_dir = os.path.dirname(presentation.pdf_path)
|
|
# Remove the entire campaign directory if it only contains this presentation's files
|
|
if os.path.isdir(campaign_dir):
|
|
remaining = CampaignPresentation.query.filter(
|
|
CampaignPresentation.campaign_id == presentation.campaign_id,
|
|
CampaignPresentation.id != presentation.id
|
|
).count()
|
|
if remaining == 0:
|
|
shutil.rmtree(campaign_dir, ignore_errors=True)
|
|
else:
|
|
# Just remove this file and its pages
|
|
if os.path.exists(presentation.pdf_path):
|
|
os.remove(presentation.pdf_path)
|
|
if presentation.page_images_dir and os.path.isdir(presentation.page_images_dir):
|
|
shutil.rmtree(presentation.page_images_dir, ignore_errors=True)
|
|
|
|
# Remove database record
|
|
db.session.delete(presentation)
|
|
db.session.commit()
|
|
|
|
logger.info(f"Deleted campaign presentation {presentation_id}")
|
|
return jsonify({'success': True})
|
|
|
|
except Exception as e:
|
|
logger.error(f"Delete failed: {e}", exc_info=True)
|
|
return jsonify({'error': str(e)}), 500
|
|
|
|
|
|
@campaigns_bp.route('/api/list')
|
|
def api_list():
|
|
"""JSON API: List all campaign presentations for dropdowns."""
|
|
presentations = CampaignPresentation.query.filter_by(
|
|
status='ready'
|
|
).order_by(CampaignPresentation.created_at.desc()).all()
|
|
|
|
return jsonify([p.to_dict() for p in presentations])
|
|
|
|
|
|
@campaigns_bp.route('/api/<campaign_id>')
|
|
def api_get_campaign(campaign_id):
|
|
"""JSON API: Get parsed content for a specific campaign."""
|
|
presentation = CampaignPresentation.get_ready_by_campaign_id(campaign_id)
|
|
if not presentation:
|
|
return jsonify({'error': 'No ready presentation found for this campaign'}), 404
|
|
|
|
return jsonify({
|
|
'campaign_id': presentation.campaign_id,
|
|
'campaign_name': presentation.campaign_name,
|
|
'parsed_content': presentation.parsed_content,
|
|
'has_pricing': presentation.has_pricing,
|
|
'page_images_dir': presentation.page_images_dir
|
|
})
|
|
|
|
|
|
@campaigns_bp.route('/api/status/<int:presentation_id>')
|
|
def api_status(presentation_id):
|
|
"""JSON API: Check parsing status of a presentation."""
|
|
presentation = CampaignPresentation.query.get_or_404(presentation_id)
|
|
return jsonify({
|
|
'id': presentation.id,
|
|
'status': presentation.status,
|
|
'error_message': presentation.error_message
|
|
})
|
|
|
|
|
|
# --- Pricing Reference Routes (standalone, per-run selectable) ---
|
|
|
|
def _pricing_allowed(filename):
|
|
return '.' in filename and filename.rsplit('.', 1)[1].lower() in PRICING_ALLOWED_EXTENSIONS
|
|
|
|
|
|
@campaigns_bp.route('/pricing/upload', methods=['POST'])
|
|
def upload_pricing():
|
|
"""Upload a new pricing reference PDF. Creates a PricingReference row."""
|
|
if 'file' not in request.files:
|
|
return jsonify({'error': 'No file provided'}), 400
|
|
|
|
file = request.files['file']
|
|
if not file or file.filename == '':
|
|
return jsonify({'error': 'No file selected'}), 400
|
|
|
|
if not _pricing_allowed(file.filename):
|
|
return jsonify({'error': 'Only PDF and Excel (.xlsx) files are allowed for pricing references'}), 400
|
|
|
|
name = request.form.get('name', '').strip()
|
|
if not name:
|
|
name = os.path.splitext(secure_filename(file.filename))[0]
|
|
|
|
try:
|
|
# Insert row first to get an id for the storage path
|
|
ref = PricingReference(
|
|
name=name,
|
|
pdf_filename=secure_filename(file.filename),
|
|
pdf_path='', # set below once id is known
|
|
status='pending',
|
|
created_by=current_user_email() or 'unknown'
|
|
)
|
|
db.session.add(ref)
|
|
db.session.flush() # populate ref.id without committing
|
|
|
|
storage_root = current_app.config.get('PRICING_REF_STORAGE_PATH', 'storage/pricing_references')
|
|
ref_dir = os.path.join(storage_root, str(ref.id))
|
|
os.makedirs(ref_dir, exist_ok=True)
|
|
|
|
pdf_path = os.path.join(ref_dir, ref.pdf_filename)
|
|
file.save(pdf_path)
|
|
ref.pdf_path = pdf_path
|
|
db.session.commit()
|
|
|
|
# Parse in background
|
|
app = current_app._get_current_object()
|
|
ref_id = ref.id
|
|
|
|
def run_parse():
|
|
with app.app_context():
|
|
from .pricing_parser import parse_pricing_reference
|
|
parse_pricing_reference(ref_id)
|
|
|
|
thread = threading.Thread(target=run_parse, daemon=True)
|
|
thread.start()
|
|
|
|
logger.info(f"Pricing reference uploaded: {name} (id={ref.id})")
|
|
return jsonify({
|
|
'success': True,
|
|
'id': ref.id,
|
|
'name': name,
|
|
'message': 'Pricing reference uploaded. Parsing in background...'
|
|
})
|
|
|
|
except Exception as e:
|
|
logger.error(f"Pricing upload failed: {e}", exc_info=True)
|
|
db.session.rollback()
|
|
return jsonify({'error': str(e)}), 500
|
|
|
|
|
|
@campaigns_bp.route('/pricing/<int:pricing_id>', methods=['DELETE'])
|
|
def delete_pricing(pricing_id):
|
|
"""Delete a pricing reference (row + stored file)."""
|
|
ref = PricingReference.query.get_or_404(pricing_id)
|
|
try:
|
|
if ref.pdf_path and os.path.exists(ref.pdf_path):
|
|
ref_dir = os.path.dirname(ref.pdf_path)
|
|
if os.path.isdir(ref_dir):
|
|
shutil.rmtree(ref_dir, ignore_errors=True)
|
|
|
|
db.session.delete(ref)
|
|
db.session.commit()
|
|
logger.info(f"Deleted pricing reference {pricing_id}")
|
|
return jsonify({'success': True})
|
|
except Exception as e:
|
|
db.session.rollback()
|
|
logger.error(f"Pricing delete failed: {e}", exc_info=True)
|
|
return jsonify({'error': str(e)}), 500
|
|
|
|
|
|
@campaigns_bp.route('/api/pricing/list')
|
|
def api_pricing_list():
|
|
"""JSON API: list ready pricing references for the configure dropdown."""
|
|
refs = PricingReference.get_ready()
|
|
return jsonify([r.to_dict() for r in refs])
|
|
|
|
|
|
@campaigns_bp.route('/api/pricing/status/<int:pricing_id>')
|
|
def api_pricing_status(pricing_id):
|
|
"""JSON API: check parsing status of a pricing reference."""
|
|
ref = PricingReference.query.get_or_404(pricing_id)
|
|
return jsonify({
|
|
'id': ref.id,
|
|
'status': ref.status,
|
|
'error_message': ref.error_message,
|
|
'entry_count': len(ref.get_lookup()) if ref.status == 'ready' else 0
|
|
})
|
|
|
|
|
|
@campaigns_bp.route('/page-image/<int:presentation_id>/<path:filename>')
|
|
def serve_page_image(presentation_id, filename):
|
|
"""Serve a page image from a campaign presentation."""
|
|
presentation = CampaignPresentation.query.get_or_404(presentation_id)
|
|
if not presentation.page_images_dir:
|
|
return jsonify({'error': 'No page images available'}), 404
|
|
return send_from_directory(presentation.page_images_dir, filename)
|