update
This commit is contained in:
@@ -224,6 +224,43 @@ def send_newsletter():
|
|||||||
}), 500
|
}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@admin_bp.route('/api/admin/recent-articles', methods=['GET'])
|
||||||
|
def get_recent_articles():
|
||||||
|
"""Get recently summarized articles"""
|
||||||
|
try:
|
||||||
|
from database import articles_collection
|
||||||
|
|
||||||
|
# Get last 10 articles with summaries, sorted by when they were summarized
|
||||||
|
articles = list(articles_collection.find(
|
||||||
|
{'summary': {'$exists': True, '$ne': None}},
|
||||||
|
{
|
||||||
|
'title': 1,
|
||||||
|
'title_en': 1,
|
||||||
|
'source': 1,
|
||||||
|
'category': 1,
|
||||||
|
'summarized_at': 1,
|
||||||
|
'created_at': 1,
|
||||||
|
'summary_word_count': 1,
|
||||||
|
'_id': 0
|
||||||
|
}
|
||||||
|
).sort('summarized_at', -1).limit(10))
|
||||||
|
|
||||||
|
# Convert datetime to ISO format
|
||||||
|
for article in articles:
|
||||||
|
if 'summarized_at' in article and article['summarized_at']:
|
||||||
|
article['summarized_at'] = article['summarized_at'].isoformat()
|
||||||
|
if 'created_at' in article and article['created_at']:
|
||||||
|
article['created_at'] = article['created_at'].isoformat()
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'articles': articles,
|
||||||
|
'total': len(articles)
|
||||||
|
}), 200
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({'error': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
@admin_bp.route('/api/admin/stats', methods=['GET'])
|
@admin_bp.route('/api/admin/stats', methods=['GET'])
|
||||||
def get_stats():
|
def get_stats():
|
||||||
"""Get system statistics"""
|
"""Get system statistics"""
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
|
"""
|
||||||
|
RSS Feed management routes
|
||||||
|
"""
|
||||||
from flask import Blueprint, request, jsonify
|
from flask import Blueprint, request, jsonify
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pymongo.errors import DuplicateKeyError
|
|
||||||
from bson.objectid import ObjectId
|
|
||||||
import feedparser
|
|
||||||
from database import rss_feeds_collection
|
from database import rss_feeds_collection
|
||||||
|
|
||||||
rss_bp = Blueprint('rss', __name__)
|
rss_bp = Blueprint('rss', __name__)
|
||||||
@@ -10,145 +10,208 @@ rss_bp = Blueprint('rss', __name__)
|
|||||||
|
|
||||||
@rss_bp.route('/api/rss-feeds', methods=['GET'])
|
@rss_bp.route('/api/rss-feeds', methods=['GET'])
|
||||||
def get_rss_feeds():
|
def get_rss_feeds():
|
||||||
"""Get all RSS feeds, optionally filtered by category"""
|
"""Get all RSS feeds"""
|
||||||
try:
|
try:
|
||||||
# Get optional category filter
|
feeds = list(rss_feeds_collection.find(
|
||||||
category = request.args.get('category')
|
{},
|
||||||
|
{'_id': 0}
|
||||||
|
).sort('name', 1))
|
||||||
|
|
||||||
# Build query
|
# Convert datetime to ISO format
|
||||||
query = {}
|
for feed in feeds:
|
||||||
if category:
|
if 'created_at' in feed:
|
||||||
query['category'] = category
|
feed['created_at'] = feed['created_at'].isoformat()
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'feeds': feeds,
|
||||||
|
'total': len(feeds)
|
||||||
|
}), 200
|
||||||
|
|
||||||
cursor = rss_feeds_collection.find(query).sort('created_at', -1)
|
|
||||||
feeds = []
|
|
||||||
for feed in cursor:
|
|
||||||
feeds.append({
|
|
||||||
'id': str(feed['_id']),
|
|
||||||
'name': feed.get('name', ''),
|
|
||||||
'url': feed.get('url', ''),
|
|
||||||
'category': feed.get('category', 'general'),
|
|
||||||
'active': feed.get('active', True),
|
|
||||||
'created_at': feed.get('created_at', '').isoformat() if feed.get('created_at') else ''
|
|
||||||
})
|
|
||||||
return jsonify({'feeds': feeds}), 200
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({'error': str(e)}), 500
|
return jsonify({'error': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
@rss_bp.route('/api/rss-feeds', methods=['POST'])
|
@rss_bp.route('/api/rss-feeds', methods=['POST'])
|
||||||
def add_rss_feed():
|
def add_rss_feed():
|
||||||
"""Add a new RSS feed with optional category"""
|
"""Add a new RSS feed"""
|
||||||
data = request.json
|
|
||||||
name = data.get('name', '').strip()
|
|
||||||
url = data.get('url', '').strip()
|
|
||||||
category = data.get('category', 'general').strip().lower()
|
|
||||||
|
|
||||||
if not name or not url:
|
|
||||||
return jsonify({'error': 'Name and URL are required'}), 400
|
|
||||||
|
|
||||||
if not url.startswith('http://') and not url.startswith('https://'):
|
|
||||||
return jsonify({'error': 'URL must start with http:// or https://'}), 400
|
|
||||||
|
|
||||||
# Validate category (optional, but if provided should be reasonable)
|
|
||||||
valid_categories = ['general', 'local', 'politics', 'sports', 'culture', 'business', 'technology', 'entertainment']
|
|
||||||
if category and category not in valid_categories:
|
|
||||||
# Allow custom categories but warn
|
|
||||||
pass
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Test if the RSS feed is valid
|
data = request.get_json()
|
||||||
try:
|
|
||||||
feed = feedparser.parse(url)
|
|
||||||
if not feed.entries:
|
|
||||||
return jsonify({'error': 'Invalid RSS feed or no entries found'}), 400
|
|
||||||
except Exception as e:
|
|
||||||
return jsonify({'error': f'Failed to parse RSS feed: {str(e)}'}), 400
|
|
||||||
|
|
||||||
|
# Validate required fields
|
||||||
|
if not data.get('name'):
|
||||||
|
return jsonify({'error': 'Feed name is required'}), 400
|
||||||
|
if not data.get('url'):
|
||||||
|
return jsonify({'error': 'Feed URL is required'}), 400
|
||||||
|
if not data.get('category'):
|
||||||
|
return jsonify({'error': 'Category is required'}), 400
|
||||||
|
|
||||||
|
# Category validation - accept any string, but recommend common ones
|
||||||
|
category = data['category'].strip().lower()
|
||||||
|
if not category:
|
||||||
|
return jsonify({'error': 'Category cannot be empty'}), 400
|
||||||
|
|
||||||
|
data['category'] = category # Normalize to lowercase
|
||||||
|
|
||||||
|
# Check if feed already exists
|
||||||
|
existing = rss_feeds_collection.find_one({'url': data['url']})
|
||||||
|
if existing:
|
||||||
|
return jsonify({'error': 'Feed URL already exists'}), 400
|
||||||
|
|
||||||
|
# Create feed document
|
||||||
feed_doc = {
|
feed_doc = {
|
||||||
'name': name,
|
'name': data['name'].strip(),
|
||||||
'url': url,
|
'url': data['url'].strip(),
|
||||||
'category': category,
|
'category': data['category'],
|
||||||
'active': True,
|
'active': data.get('active', True),
|
||||||
'created_at': datetime.utcnow()
|
'created_at': datetime.utcnow()
|
||||||
}
|
}
|
||||||
|
|
||||||
try:
|
rss_feeds_collection.insert_one(feed_doc)
|
||||||
result = rss_feeds_collection.insert_one(feed_doc)
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'message': 'RSS feed added successfully',
|
'message': 'RSS feed added successfully',
|
||||||
'id': str(result.inserted_id),
|
'feed': {
|
||||||
'category': category
|
'name': feed_doc['name'],
|
||||||
}), 201
|
'url': feed_doc['url'],
|
||||||
except DuplicateKeyError:
|
'category': feed_doc['category'],
|
||||||
return jsonify({'error': 'RSS feed URL already exists'}), 409
|
'active': feed_doc['active']
|
||||||
|
}
|
||||||
|
}), 201
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({'error': str(e)}), 500
|
return jsonify({'error': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
@rss_bp.route('/api/rss-feeds/<feed_id>', methods=['DELETE'])
|
@rss_bp.route('/api/rss-feeds/<feed_name>', methods=['PUT'])
|
||||||
def remove_rss_feed(feed_id):
|
def update_rss_feed(feed_name):
|
||||||
"""Remove an RSS feed"""
|
"""Update an RSS feed"""
|
||||||
try:
|
try:
|
||||||
# Validate ObjectId
|
data = request.get_json()
|
||||||
try:
|
|
||||||
obj_id = ObjectId(feed_id)
|
|
||||||
except Exception:
|
|
||||||
return jsonify({'error': 'Invalid feed ID'}), 400
|
|
||||||
|
|
||||||
result = rss_feeds_collection.delete_one({'_id': obj_id})
|
# Build update document
|
||||||
|
update_doc = {}
|
||||||
|
if 'url' in data:
|
||||||
|
update_doc['url'] = data['url'].strip()
|
||||||
|
if 'category' in data:
|
||||||
|
category = data['category'].strip().lower()
|
||||||
|
if not category:
|
||||||
|
return jsonify({'error': 'Category cannot be empty'}), 400
|
||||||
|
update_doc['category'] = category
|
||||||
|
if 'active' in data:
|
||||||
|
update_doc['active'] = bool(data['active'])
|
||||||
|
|
||||||
if result.deleted_count > 0:
|
if not update_doc:
|
||||||
return jsonify({'message': 'RSS feed removed successfully'}), 200
|
return jsonify({'error': 'No fields to update'}), 400
|
||||||
else:
|
|
||||||
return jsonify({'error': 'RSS feed not found'}), 404
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
return jsonify({'error': str(e)}), 500
|
|
||||||
|
|
||||||
|
|
||||||
@rss_bp.route('/api/rss-feeds/<feed_id>/toggle', methods=['PATCH'])
|
|
||||||
def toggle_rss_feed(feed_id):
|
|
||||||
"""Toggle RSS feed active status"""
|
|
||||||
try:
|
|
||||||
# Validate ObjectId
|
|
||||||
try:
|
|
||||||
obj_id = ObjectId(feed_id)
|
|
||||||
except Exception:
|
|
||||||
return jsonify({'error': 'Invalid feed ID'}), 400
|
|
||||||
|
|
||||||
# Get current status
|
|
||||||
feed = rss_feeds_collection.find_one({'_id': obj_id})
|
|
||||||
if not feed:
|
|
||||||
return jsonify({'error': 'RSS feed not found'}), 404
|
|
||||||
|
|
||||||
# Toggle status
|
|
||||||
new_status = not feed.get('active', True)
|
|
||||||
result = rss_feeds_collection.update_one(
|
result = rss_feeds_collection.update_one(
|
||||||
{'_id': obj_id},
|
{'name': feed_name},
|
||||||
{'$set': {'active': new_status}}
|
{'$set': update_doc}
|
||||||
)
|
)
|
||||||
|
|
||||||
if result.modified_count > 0:
|
if result.matched_count > 0:
|
||||||
return jsonify({
|
return jsonify({'message': 'RSS feed updated successfully'}), 200
|
||||||
'message': f'RSS feed {"activated" if new_status else "deactivated"} successfully',
|
|
||||||
'active': new_status
|
|
||||||
}), 200
|
|
||||||
else:
|
else:
|
||||||
return jsonify({'error': 'Failed to update RSS feed'}), 500
|
return jsonify({'error': 'Feed not found'}), 404
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({'error': str(e)}), 500
|
return jsonify({'error': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
@rss_bp.route('/api/rss-feeds/categories', methods=['GET'])
|
@rss_bp.route('/api/rss-feeds/<feed_name>', methods=['DELETE'])
|
||||||
def get_categories():
|
def delete_rss_feed(feed_name):
|
||||||
"""Get all unique categories from RSS feeds"""
|
"""Delete an RSS feed"""
|
||||||
try:
|
try:
|
||||||
categories = rss_feeds_collection.distinct('category')
|
result = rss_feeds_collection.delete_one({'name': feed_name})
|
||||||
# Filter out None/empty and sort
|
|
||||||
categories = sorted([c for c in categories if c])
|
if result.deleted_count > 0:
|
||||||
return jsonify({'categories': categories}), 200
|
return jsonify({'message': f'RSS feed "{feed_name}" deleted successfully'}), 200
|
||||||
|
else:
|
||||||
|
return jsonify({'error': 'Feed not found'}), 404
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({'error': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@rss_bp.route('/api/rss-feeds/export', methods=['GET'])
|
||||||
|
def export_rss_feeds():
|
||||||
|
"""Export all RSS feeds as JSON"""
|
||||||
|
try:
|
||||||
|
feeds = list(rss_feeds_collection.find(
|
||||||
|
{},
|
||||||
|
{'_id': 0}
|
||||||
|
).sort('name', 1))
|
||||||
|
|
||||||
|
# Convert datetime to ISO format
|
||||||
|
for feed in feeds:
|
||||||
|
if 'created_at' in feed:
|
||||||
|
feed['created_at'] = feed['created_at'].isoformat()
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'feeds': feeds,
|
||||||
|
'total': len(feeds),
|
||||||
|
'exported_at': datetime.utcnow().isoformat()
|
||||||
|
}), 200
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({'error': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@rss_bp.route('/api/rss-feeds/import', methods=['POST'])
|
||||||
|
def import_rss_feeds():
|
||||||
|
"""Import RSS feeds from JSON"""
|
||||||
|
try:
|
||||||
|
data = request.get_json()
|
||||||
|
|
||||||
|
if not data or 'feeds' not in data:
|
||||||
|
return jsonify({'error': 'Invalid import data. Expected {feeds: [...]}'}), 400
|
||||||
|
|
||||||
|
feeds = data['feeds']
|
||||||
|
|
||||||
|
if not isinstance(feeds, list):
|
||||||
|
return jsonify({'error': 'feeds must be an array'}), 400
|
||||||
|
|
||||||
|
imported = 0
|
||||||
|
skipped = 0
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
for feed in feeds:
|
||||||
|
try:
|
||||||
|
# Validate required fields
|
||||||
|
if not feed.get('name') or not feed.get('url') or not feed.get('category'):
|
||||||
|
errors.append(f"Skipped invalid feed: {feed.get('name', 'unknown')}")
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check if feed already exists
|
||||||
|
existing = rss_feeds_collection.find_one({'url': feed['url']})
|
||||||
|
if existing:
|
||||||
|
errors.append(f"Skipped duplicate: {feed['name']}")
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Create feed document
|
||||||
|
feed_doc = {
|
||||||
|
'name': feed['name'].strip(),
|
||||||
|
'url': feed['url'].strip(),
|
||||||
|
'category': feed['category'].strip().lower(),
|
||||||
|
'active': feed.get('active', True),
|
||||||
|
'created_at': datetime.utcnow()
|
||||||
|
}
|
||||||
|
|
||||||
|
rss_feeds_collection.insert_one(feed_doc)
|
||||||
|
imported += 1
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
errors.append(f"Error importing {feed.get('name', 'unknown')}: {str(e)}")
|
||||||
|
skipped += 1
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'message': f'Import complete: {imported} imported, {skipped} skipped',
|
||||||
|
'imported': imported,
|
||||||
|
'skipped': skipped,
|
||||||
|
'errors': errors if errors else None
|
||||||
|
}), 200
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({'error': str(e)}), 500
|
return jsonify({'error': str(e)}), 500
|
||||||
|
|||||||
@@ -8,36 +8,69 @@ subscription_bp = Blueprint('subscription', __name__)
|
|||||||
|
|
||||||
@subscription_bp.route('/api/subscribe', methods=['POST'])
|
@subscription_bp.route('/api/subscribe', methods=['POST'])
|
||||||
def subscribe():
|
def subscribe():
|
||||||
"""Subscribe a user to the newsletter"""
|
"""Subscribe a user to the newsletter with category preferences"""
|
||||||
data = request.json
|
data = request.json
|
||||||
email = data.get('email', '').strip().lower()
|
email = data.get('email', '').strip().lower()
|
||||||
|
# Get valid categories from RSS feeds
|
||||||
|
from database import rss_feeds_collection
|
||||||
|
valid_categories = rss_feeds_collection.distinct('category')
|
||||||
|
|
||||||
|
categories = data.get('categories', valid_categories) # Default: all categories
|
||||||
|
|
||||||
if not email or '@' not in email:
|
if not email or '@' not in email:
|
||||||
return jsonify({'error': 'Invalid email address'}), 400
|
return jsonify({'error': 'Invalid email address'}), 400
|
||||||
|
|
||||||
|
# Validate categories
|
||||||
|
if not isinstance(categories, list) or not categories:
|
||||||
|
categories = valid_categories # Default to all if invalid
|
||||||
|
else:
|
||||||
|
# Filter to only valid categories (categories that exist in RSS feeds)
|
||||||
|
categories = [c for c in categories if c in valid_categories]
|
||||||
|
if not categories:
|
||||||
|
categories = valid_categories # Default to all if none valid
|
||||||
|
|
||||||
try:
|
try:
|
||||||
subscriber_doc = {
|
subscriber_doc = {
|
||||||
'email': email,
|
'email': email,
|
||||||
'subscribed_at': datetime.utcnow(),
|
'subscribed_at': datetime.utcnow(),
|
||||||
'status': 'active'
|
'status': 'active',
|
||||||
|
'categories': categories
|
||||||
}
|
}
|
||||||
|
|
||||||
# Try to insert, if duplicate key error, subscriber already exists
|
# Try to insert, if duplicate key error, subscriber already exists
|
||||||
try:
|
try:
|
||||||
subscribers_collection.insert_one(subscriber_doc)
|
subscribers_collection.insert_one(subscriber_doc)
|
||||||
return jsonify({'message': 'Successfully subscribed!'}), 201
|
return jsonify({
|
||||||
|
'message': 'Successfully subscribed!',
|
||||||
|
'categories': categories
|
||||||
|
}), 201
|
||||||
except DuplicateKeyError:
|
except DuplicateKeyError:
|
||||||
# Check if subscriber is active
|
# Check if subscriber is active
|
||||||
existing = subscribers_collection.find_one({'email': email})
|
existing = subscribers_collection.find_one({'email': email})
|
||||||
if existing and existing.get('status') == 'active':
|
if existing and existing.get('status') == 'active':
|
||||||
return jsonify({'message': 'Email already subscribed'}), 200
|
# Update categories even if already subscribed
|
||||||
|
subscribers_collection.update_one(
|
||||||
|
{'email': email},
|
||||||
|
{'$set': {'categories': categories}}
|
||||||
|
)
|
||||||
|
return jsonify({
|
||||||
|
'message': 'Email already subscribed. Preferences updated!',
|
||||||
|
'categories': categories
|
||||||
|
}), 200
|
||||||
else:
|
else:
|
||||||
# Reactivate if previously unsubscribed
|
# Reactivate if previously unsubscribed
|
||||||
subscribers_collection.update_one(
|
subscribers_collection.update_one(
|
||||||
{'email': email},
|
{'email': email},
|
||||||
{'$set': {'status': 'active', 'subscribed_at': datetime.utcnow()}}
|
{'$set': {
|
||||||
|
'status': 'active',
|
||||||
|
'subscribed_at': datetime.utcnow(),
|
||||||
|
'categories': categories
|
||||||
|
}}
|
||||||
)
|
)
|
||||||
return jsonify({'message': 'Successfully re-subscribed!'}), 200
|
return jsonify({
|
||||||
|
'message': 'Successfully re-subscribed!',
|
||||||
|
'categories': categories
|
||||||
|
}), 200
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({'error': str(e)}), 500
|
return jsonify({'error': str(e)}), 500
|
||||||
@@ -95,6 +128,34 @@ def list_subscribers():
|
|||||||
return jsonify({'error': str(e)}), 500
|
return jsonify({'error': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@subscription_bp.route('/api/subscribers/<email>', methods=['GET'])
|
||||||
|
def get_subscriber(email):
|
||||||
|
"""Get a single subscriber's information"""
|
||||||
|
try:
|
||||||
|
email = email.strip().lower()
|
||||||
|
|
||||||
|
subscriber = subscribers_collection.find_one(
|
||||||
|
{'email': email},
|
||||||
|
{'_id': 0, 'email': 1, 'categories': 1, 'status': 1, 'subscribed_at': 1}
|
||||||
|
)
|
||||||
|
|
||||||
|
if subscriber:
|
||||||
|
# Convert datetime to ISO format
|
||||||
|
if 'subscribed_at' in subscriber:
|
||||||
|
subscriber['subscribed_at'] = subscriber['subscribed_at'].isoformat()
|
||||||
|
|
||||||
|
# Ensure categories field exists
|
||||||
|
if 'categories' not in subscriber:
|
||||||
|
subscriber['categories'] = ['general', 'local', 'sports']
|
||||||
|
|
||||||
|
return jsonify(subscriber), 200
|
||||||
|
else:
|
||||||
|
return jsonify({'error': 'Email not found in subscribers'}), 404
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({'error': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
@subscription_bp.route('/api/subscribers/<email>', methods=['DELETE'])
|
@subscription_bp.route('/api/subscribers/<email>', methods=['DELETE'])
|
||||||
def remove_subscriber(email):
|
def remove_subscriber(email):
|
||||||
"""Permanently remove a subscriber from the database"""
|
"""Permanently remove a subscriber from the database"""
|
||||||
@@ -110,3 +171,68 @@ def remove_subscriber(email):
|
|||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({'error': str(e)}), 500
|
return jsonify({'error': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@subscription_bp.route('/api/admin/subscribers/delete-all', methods=['DELETE'])
|
||||||
|
def delete_all_subscribers():
|
||||||
|
"""Delete all subscribers (admin only)"""
|
||||||
|
try:
|
||||||
|
result = subscribers_collection.delete_many({})
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'message': f'Successfully deleted {result.deleted_count} subscribers',
|
||||||
|
'deleted_count': result.deleted_count
|
||||||
|
}), 200
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({'error': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@subscription_bp.route('/api/categories', methods=['GET'])
|
||||||
|
def get_categories():
|
||||||
|
"""Get available newsletter categories from RSS feeds"""
|
||||||
|
from database import rss_feeds_collection
|
||||||
|
|
||||||
|
# Get unique categories from RSS feeds
|
||||||
|
categories_from_db = rss_feeds_collection.distinct('category')
|
||||||
|
|
||||||
|
# Category metadata
|
||||||
|
category_info = {
|
||||||
|
'general': {
|
||||||
|
'name': 'Top Trending',
|
||||||
|
'description': 'Top trending news and updates',
|
||||||
|
'icon': '🔥'
|
||||||
|
},
|
||||||
|
'local': {
|
||||||
|
'name': 'Local Events',
|
||||||
|
'description': 'Local events, culture, and community news',
|
||||||
|
'icon': '🏛️'
|
||||||
|
},
|
||||||
|
'sports': {
|
||||||
|
'name': 'Sports',
|
||||||
|
'description': 'Sports news and updates',
|
||||||
|
'icon': '⚽'
|
||||||
|
},
|
||||||
|
'science': {
|
||||||
|
'name': 'Science & Tech',
|
||||||
|
'description': 'Science and technology news',
|
||||||
|
'icon': '🔬'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Build category list
|
||||||
|
categories = []
|
||||||
|
for cat_id in sorted(categories_from_db):
|
||||||
|
info = category_info.get(cat_id, {
|
||||||
|
'name': cat_id.title(),
|
||||||
|
'description': f'{cat_id.title()} news',
|
||||||
|
'icon': '📄'
|
||||||
|
})
|
||||||
|
categories.append({
|
||||||
|
'id': cat_id,
|
||||||
|
'name': info['name'],
|
||||||
|
'description': info['description'],
|
||||||
|
'icon': info['icon']
|
||||||
|
})
|
||||||
|
|
||||||
|
return jsonify({'categories': categories}), 200
|
||||||
|
|||||||
56
fix-categories.js
Normal file
56
fix-categories.js
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
// MongoDB script to fix article categories
|
||||||
|
// Run with: docker exec -i munich-news-mongodb mongosh -u admin -p changeme --authenticationDatabase admin < fix-categories.js
|
||||||
|
|
||||||
|
use munich_news
|
||||||
|
|
||||||
|
print("=== RSS Feeds and their categories ===");
|
||||||
|
db.rss_feeds.find({}, {name: 1, category: 1, _id: 0}).forEach(feed => {
|
||||||
|
print(`${feed.name}: ${feed.category || 'NO CATEGORY'}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
print("\n=== Current article category distribution ===");
|
||||||
|
db.articles.aggregate([
|
||||||
|
{$group: {_id: "$category", count: {$sum: 1}}},
|
||||||
|
{$sort: {count: -1}}
|
||||||
|
]).forEach(result => {
|
||||||
|
print(`${result._id || 'null'}: ${result.count} articles`);
|
||||||
|
});
|
||||||
|
|
||||||
|
print("\n=== Fixing null categories ===");
|
||||||
|
|
||||||
|
// Update articles based on their RSS feed source
|
||||||
|
var feedsUpdated = 0;
|
||||||
|
db.rss_feeds.find().forEach(function(feed) {
|
||||||
|
if (feed.category) {
|
||||||
|
var result = db.articles.updateMany(
|
||||||
|
{source: feed.name, category: null},
|
||||||
|
{$set: {category: feed.category}}
|
||||||
|
);
|
||||||
|
if (result.modifiedCount > 0) {
|
||||||
|
print(`Updated ${result.modifiedCount} articles from ${feed.name} to category: ${feed.category}`);
|
||||||
|
feedsUpdated += result.modifiedCount;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set remaining null categories to 'general'
|
||||||
|
var remainingNull = db.articles.updateMany(
|
||||||
|
{category: null},
|
||||||
|
{$set: {category: "general"}}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (remainingNull.modifiedCount > 0) {
|
||||||
|
print(`Set ${remainingNull.modifiedCount} remaining null articles to 'general'`);
|
||||||
|
}
|
||||||
|
|
||||||
|
print(`\nTotal articles updated: ${feedsUpdated + remainingNull.modifiedCount}`);
|
||||||
|
|
||||||
|
print("\n=== Updated article category distribution ===");
|
||||||
|
db.articles.aggregate([
|
||||||
|
{$group: {_id: "$category", count: {$sum: 1}}},
|
||||||
|
{$sort: {count: -1}}
|
||||||
|
]).forEach(result => {
|
||||||
|
print(`${result._id || 'null'}: ${result.count} articles`);
|
||||||
|
});
|
||||||
|
|
||||||
|
print("\n✓ Done!");
|
||||||
@@ -226,6 +226,54 @@
|
|||||||
<h3>🔗 AI Clustering & Aggregation</h3>
|
<h3>🔗 AI Clustering & Aggregation</h3>
|
||||||
<div id="clusteringStats" class="loading">Loading...</div>
|
<div id="clusteringStats" class="loading">Loading...</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Subscriber Management -->
|
||||||
|
<div class="card">
|
||||||
|
<h3>👥 Subscriber Management</h3>
|
||||||
|
<div style="margin-bottom: 20px;">
|
||||||
|
<button onclick="viewSubscribers()" class="btn btn-primary" style="margin-right: 10px;">
|
||||||
|
📋 View All Subscribers
|
||||||
|
</button>
|
||||||
|
<button onclick="deleteAllSubscribers()" class="btn btn-danger">
|
||||||
|
🗑️ Delete All Subscribers
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="subscriberList"></div>
|
||||||
|
<div id="subscriberMessage"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- RSS Feed Management -->
|
||||||
|
<div class="card">
|
||||||
|
<h3>📡 RSS Feed Management</h3>
|
||||||
|
<div style="margin-bottom: 20px;">
|
||||||
|
<button onclick="exportRSSFeeds()" class="btn btn-primary" style="margin-right: 10px;">
|
||||||
|
📥 Export RSS Feeds
|
||||||
|
</button>
|
||||||
|
<label class="btn btn-primary" style="margin-right: 10px; cursor: pointer;">
|
||||||
|
📤 Import RSS Feeds
|
||||||
|
<input type="file" id="rssImportFile" accept=".json" style="display: none;" onchange="importRSSFeeds(event)">
|
||||||
|
</label>
|
||||||
|
<button onclick="viewRSSFeeds()" class="btn btn-secondary">
|
||||||
|
📋 View All Feeds
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="rssFeedList"></div>
|
||||||
|
<div id="rssFeedMessage"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Recent Summarization Activity -->
|
||||||
|
<div class="card">
|
||||||
|
<h3>🤖 Recent AI Summarization Activity</h3>
|
||||||
|
<div style="margin-bottom: 15px;">
|
||||||
|
<button onclick="loadRecentArticles()" class="btn btn-primary">
|
||||||
|
🔄 Refresh
|
||||||
|
</button>
|
||||||
|
<label style="margin-left: 15px;">
|
||||||
|
<input type="checkbox" id="autoRefresh" onchange="toggleAutoRefresh()"> Auto-refresh (10s)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div id="recentArticles" class="loading">Loading...</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="admin.js"></script>
|
<script src="admin.js"></script>
|
||||||
|
|||||||
@@ -313,3 +313,262 @@ async function loadClusteringStats() {
|
|||||||
document.getElementById('clusteringStats').innerHTML = `<div class="error">Error: ${error.message}</div>`;
|
document.getElementById('clusteringStats').innerHTML = `<div class="error">Error: ${error.message}</div>`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Subscriber Management
|
||||||
|
async function viewSubscribers() {
|
||||||
|
const listDiv = document.getElementById('subscriberList');
|
||||||
|
const messageDiv = document.getElementById('subscriberMessage');
|
||||||
|
|
||||||
|
listDiv.innerHTML = '<p class="loading">Loading subscribers...</p>';
|
||||||
|
messageDiv.innerHTML = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/subscribers');
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.subscribers && data.subscribers.length > 0) {
|
||||||
|
let html = '<div style="max-height: 400px; overflow-y: auto; margin-top: 15px;">';
|
||||||
|
html += '<table style="width: 100%; border-collapse: collapse;">';
|
||||||
|
html += '<thead><tr style="background: #f5f5f5; position: sticky; top: 0;"><th style="padding: 10px; text-align: left;">Email</th><th style="padding: 10px; text-align: left;">Categories</th><th style="padding: 10px; text-align: left;">Status</th></tr></thead>';
|
||||||
|
html += '<tbody>';
|
||||||
|
|
||||||
|
data.subscribers.forEach(sub => {
|
||||||
|
const categories = sub.categories ? sub.categories.join(', ') : 'All';
|
||||||
|
html += `<tr style="border-bottom: 1px solid #eee;">
|
||||||
|
<td style="padding: 10px;">${sub.email}</td>
|
||||||
|
<td style="padding: 10px;">${categories}</td>
|
||||||
|
<td style="padding: 10px;"><span style="color: ${sub.status === 'active' ? 'green' : 'red'};">${sub.status}</span></td>
|
||||||
|
</tr>`;
|
||||||
|
});
|
||||||
|
|
||||||
|
html += '</tbody></table></div>';
|
||||||
|
html += `<p style="margin-top: 10px; color: #666;">Total: ${data.total} subscribers</p>`;
|
||||||
|
listDiv.innerHTML = html;
|
||||||
|
} else {
|
||||||
|
listDiv.innerHTML = '<p style="color: #666;">No subscribers found.</p>';
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
listDiv.innerHTML = '<p style="color: red;">Failed to load subscribers</p>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteAllSubscribers() {
|
||||||
|
const messageDiv = document.getElementById('subscriberMessage');
|
||||||
|
|
||||||
|
if (!confirm('⚠️ Are you sure you want to delete ALL subscribers? This cannot be undone!')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!confirm('⚠️ FINAL WARNING: This will permanently delete all subscriber data. Continue?')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
messageDiv.innerHTML = '<p class="loading">Deleting all subscribers...</p>';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/admin/subscribers/delete-all', {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
messageDiv.innerHTML = `<p style="color: green;">✓ ${data.message}</p>`;
|
||||||
|
document.getElementById('subscriberList').innerHTML = '';
|
||||||
|
// Refresh stats
|
||||||
|
loadStats();
|
||||||
|
} else {
|
||||||
|
messageDiv.innerHTML = `<p style="color: red;">✗ ${data.error || 'Failed to delete subscribers'}</p>`;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
messageDiv.innerHTML = '<p style="color: red;">✗ Network error</p>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// RSS Feed Management
|
||||||
|
async function exportRSSFeeds() {
|
||||||
|
const messageDiv = document.getElementById('rssFeedMessage');
|
||||||
|
messageDiv.innerHTML = '<p class="loading">Exporting RSS feeds...</p>';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/rss-feeds/export');
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
// Create download
|
||||||
|
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `rss-feeds-${new Date().toISOString().split('T')[0]}.json`;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
|
||||||
|
messageDiv.innerHTML = `<p style="color: green;">✓ Exported ${data.total} RSS feeds</p>`;
|
||||||
|
} else {
|
||||||
|
messageDiv.innerHTML = `<p style="color: red;">✗ ${data.error || 'Export failed'}</p>`;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
messageDiv.innerHTML = '<p style="color: red;">✗ Network error</p>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function importRSSFeeds(event) {
|
||||||
|
const messageDiv = document.getElementById('rssFeedMessage');
|
||||||
|
const file = event.target.files[0];
|
||||||
|
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
messageDiv.innerHTML = '<p class="loading">Importing RSS feeds...</p>';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const text = await file.text();
|
||||||
|
const data = JSON.parse(text);
|
||||||
|
|
||||||
|
const response = await fetch('/api/rss-feeds/import', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
let html = `<p style="color: green;">✓ ${result.message}</p>`;
|
||||||
|
if (result.errors && result.errors.length > 0) {
|
||||||
|
html += '<details style="margin-top: 10px;"><summary>Show details</summary><ul>';
|
||||||
|
result.errors.forEach(err => {
|
||||||
|
html += `<li style="color: orange;">${err}</li>`;
|
||||||
|
});
|
||||||
|
html += '</ul></details>';
|
||||||
|
}
|
||||||
|
messageDiv.innerHTML = html;
|
||||||
|
|
||||||
|
// Refresh feed list if visible
|
||||||
|
if (document.getElementById('rssFeedList').innerHTML) {
|
||||||
|
viewRSSFeeds();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
messageDiv.innerHTML = `<p style="color: red;">✗ ${result.error || 'Import failed'}</p>`;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
messageDiv.innerHTML = `<p style="color: red;">✗ Error: ${error.message}</p>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset file input
|
||||||
|
event.target.value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function viewRSSFeeds() {
|
||||||
|
const listDiv = document.getElementById('rssFeedList');
|
||||||
|
const messageDiv = document.getElementById('rssFeedMessage');
|
||||||
|
|
||||||
|
listDiv.innerHTML = '<p class="loading">Loading RSS feeds...</p>';
|
||||||
|
messageDiv.innerHTML = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/rss-feeds');
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.feeds && data.feeds.length > 0) {
|
||||||
|
let html = '<div style="max-height: 400px; overflow-y: auto; margin-top: 15px;">';
|
||||||
|
html += '<table style="width: 100%; border-collapse: collapse;">';
|
||||||
|
html += '<thead><tr style="background: #f5f5f5; position: sticky; top: 0;"><th style="padding: 10px; text-align: left;">Name</th><th style="padding: 10px; text-align: left;">Category</th><th style="padding: 10px; text-align: left;">URL</th><th style="padding: 10px; text-align: left;">Status</th></tr></thead>';
|
||||||
|
html += '<tbody>';
|
||||||
|
|
||||||
|
data.feeds.forEach(feed => {
|
||||||
|
const statusColor = feed.active ? 'green' : 'gray';
|
||||||
|
const statusText = feed.active ? 'Active' : 'Inactive';
|
||||||
|
html += `<tr style="border-bottom: 1px solid #eee;">
|
||||||
|
<td style="padding: 10px;">${feed.name}</td>
|
||||||
|
<td style="padding: 10px;">${feed.category}</td>
|
||||||
|
<td style="padding: 10px; font-size: 12px; max-width: 300px; overflow: hidden; text-overflow: ellipsis;">${feed.url}</td>
|
||||||
|
<td style="padding: 10px;"><span style="color: ${statusColor};">${statusText}</span></td>
|
||||||
|
</tr>`;
|
||||||
|
});
|
||||||
|
|
||||||
|
html += '</tbody></table></div>';
|
||||||
|
html += `<p style="margin-top: 10px; color: #666;">Total: ${data.total} feeds</p>`;
|
||||||
|
listDiv.innerHTML = html;
|
||||||
|
} else {
|
||||||
|
listDiv.innerHTML = '<p style="color: #666;">No RSS feeds found.</p>';
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
listDiv.innerHTML = '<p style="color: red;">Failed to load RSS feeds</p>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Recent Summarization Activity
|
||||||
|
let autoRefreshInterval = null;
|
||||||
|
|
||||||
|
async function loadRecentArticles() {
|
||||||
|
const container = document.getElementById('recentArticles');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/admin/recent-articles');
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.articles && data.articles.length > 0) {
|
||||||
|
let html = '<div style="max-height: 500px; overflow-y: auto;">';
|
||||||
|
html += '<table style="width: 100%; border-collapse: collapse; font-size: 14px;">';
|
||||||
|
html += '<thead><tr style="background: #f5f5f5; position: sticky; top: 0;"><th style="padding: 8px; text-align: left;">Time</th><th style="padding: 8px; text-align: left;">Title</th><th style="padding: 8px; text-align: left;">Source</th><th style="padding: 8px; text-align: left;">Category</th><th style="padding: 8px; text-align: right;">Words</th></tr></thead>';
|
||||||
|
html += '<tbody>';
|
||||||
|
|
||||||
|
data.articles.forEach(article => {
|
||||||
|
const time = article.summarized_at ? new Date(article.summarized_at).toLocaleTimeString() : 'N/A';
|
||||||
|
const title = article.title_en || article.title;
|
||||||
|
const categoryColors = {
|
||||||
|
'general': '#667eea',
|
||||||
|
'local': '#f59e0b',
|
||||||
|
'sports': '#10b981',
|
||||||
|
'science': '#8b5cf6'
|
||||||
|
};
|
||||||
|
const categoryColor = categoryColors[article.category] || '#6b7280';
|
||||||
|
|
||||||
|
html += `<tr style="border-bottom: 1px solid #eee;">
|
||||||
|
<td style="padding: 8px; white-space: nowrap; color: #666;">${time}</td>
|
||||||
|
<td style="padding: 8px; max-width: 400px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" title="${title}">${title}</td>
|
||||||
|
<td style="padding: 8px;">${article.source}</td>
|
||||||
|
<td style="padding: 8px;"><span style="background: ${categoryColor}; color: white; padding: 2px 8px; border-radius: 4px; font-size: 12px;">${article.category || 'N/A'}</span></td>
|
||||||
|
<td style="padding: 8px; text-align: right;">${article.summary_word_count || 'N/A'}</td>
|
||||||
|
</tr>`;
|
||||||
|
});
|
||||||
|
|
||||||
|
html += '</tbody></table></div>';
|
||||||
|
html += `<p style="margin-top: 10px; color: #666; font-size: 12px;">Last updated: ${new Date().toLocaleTimeString()}</p>`;
|
||||||
|
container.innerHTML = html;
|
||||||
|
} else {
|
||||||
|
container.innerHTML = '<p style="color: #666;">No summarized articles found.</p>';
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
container.innerHTML = '<p style="color: red;">Failed to load recent articles</p>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleAutoRefresh() {
|
||||||
|
const checkbox = document.getElementById('autoRefresh');
|
||||||
|
|
||||||
|
if (checkbox.checked) {
|
||||||
|
// Start auto-refresh every 10 seconds
|
||||||
|
loadRecentArticles();
|
||||||
|
autoRefreshInterval = setInterval(loadRecentArticles, 10000);
|
||||||
|
} else {
|
||||||
|
// Stop auto-refresh
|
||||||
|
if (autoRefreshInterval) {
|
||||||
|
clearInterval(autoRefreshInterval);
|
||||||
|
autoRefreshInterval = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load recent articles on page load
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
loadRecentArticles();
|
||||||
|
});
|
||||||
|
|||||||
@@ -11,8 +11,32 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
loadNews();
|
loadNews();
|
||||||
loadStats();
|
loadStats();
|
||||||
setupInfiniteScroll();
|
setupInfiniteScroll();
|
||||||
|
loadCategories();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
async function loadCategories() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/categories');
|
||||||
|
const data = await response.json();
|
||||||
|
const categories = data.categories || [];
|
||||||
|
|
||||||
|
const container = document.getElementById('categoryCheckboxes');
|
||||||
|
container.innerHTML = '';
|
||||||
|
|
||||||
|
categories.forEach(category => {
|
||||||
|
const label = document.createElement('label');
|
||||||
|
label.className = 'flex items-center space-x-3 cursor-pointer';
|
||||||
|
label.innerHTML = `
|
||||||
|
<input type="checkbox" value="${category.id}" checked class="w-5 h-5 rounded border-2 border-white/30 bg-white/20 checked:bg-white checked:border-white focus:ring-2 focus:ring-white/50">
|
||||||
|
<span class="text-white text-sm">${category.icon} ${category.name}</span>
|
||||||
|
`;
|
||||||
|
container.appendChild(label);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load categories:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function loadNews() {
|
async function loadNews() {
|
||||||
const newsGrid = document.getElementById('newsGrid');
|
const newsGrid = document.getElementById('newsGrid');
|
||||||
newsGrid.innerHTML = '<div class="text-center py-10 text-gray-500">Loading news...</div>';
|
newsGrid.innerHTML = '<div class="text-center py-10 text-gray-500">Loading news...</div>';
|
||||||
@@ -291,6 +315,16 @@ async function subscribe() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get selected categories
|
||||||
|
const checkboxes = document.querySelectorAll('#categoryCheckboxes input[type="checkbox"]:checked');
|
||||||
|
const categories = Array.from(checkboxes).map(cb => cb.value);
|
||||||
|
|
||||||
|
if (categories.length === 0) {
|
||||||
|
formMessage.textContent = 'Please select at least one category';
|
||||||
|
formMessage.className = 'text-red-200 font-medium';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
subscribeBtn.disabled = true;
|
subscribeBtn.disabled = true;
|
||||||
subscribeBtn.textContent = 'Subscribing...';
|
subscribeBtn.textContent = 'Subscribing...';
|
||||||
subscribeBtn.classList.add('opacity-75', 'cursor-not-allowed');
|
subscribeBtn.classList.add('opacity-75', 'cursor-not-allowed');
|
||||||
@@ -302,7 +336,10 @@ async function subscribe() {
|
|||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json'
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ email: email })
|
body: JSON.stringify({
|
||||||
|
email: email,
|
||||||
|
categories: categories
|
||||||
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|||||||
@@ -41,6 +41,15 @@
|
|||||||
class="w-full px-6 py-4 rounded-lg text-gray-800 text-lg focus:outline-none focus:ring-4 focus:ring-white/30 shadow-lg"
|
class="w-full px-6 py-4 rounded-lg text-gray-800 text-lg focus:outline-none focus:ring-4 focus:ring-white/30 shadow-lg"
|
||||||
required
|
required
|
||||||
>
|
>
|
||||||
|
|
||||||
|
<!-- Category Selection -->
|
||||||
|
<div class="bg-white/10 backdrop-blur-sm rounded-lg p-4">
|
||||||
|
<p class="text-white font-semibold mb-3 text-sm">Choose your interests:</p>
|
||||||
|
<div class="space-y-2" id="categoryCheckboxes">
|
||||||
|
<!-- Categories will be loaded dynamically -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
id="subscribeBtn"
|
id="subscribeBtn"
|
||||||
onclick="subscribe()"
|
onclick="subscribe()"
|
||||||
|
|||||||
257
frontend/public/preferences.html
Normal file
257
frontend/public/preferences.html
Normal file
@@ -0,0 +1,257 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Manage Preferences - Munich News Daily</title>
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<script>
|
||||||
|
tailwind.config = {
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
primary: '#667eea',
|
||||||
|
secondary: '#764ba2',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</head>
|
||||||
|
<body class="bg-gradient-to-br from-primary to-secondary min-h-screen">
|
||||||
|
<div class="max-w-2xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
|
<!-- Header -->
|
||||||
|
<header class="text-center text-white py-12">
|
||||||
|
<h1 class="text-4xl sm:text-5xl font-extrabold mb-4">📰 Munich News Daily</h1>
|
||||||
|
<p class="text-xl opacity-95">Manage Your Preferences</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Preferences Form -->
|
||||||
|
<div class="bg-white rounded-2xl shadow-2xl p-8 mb-8">
|
||||||
|
<div id="loadingState" class="text-center py-10">
|
||||||
|
<div class="inline-block animate-spin rounded-full h-12 w-12 border-b-2 border-primary"></div>
|
||||||
|
<p class="mt-4 text-gray-600">Loading your preferences...</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="emailForm" class="hidden">
|
||||||
|
<h2 class="text-2xl font-bold text-gray-800 mb-4">Enter Your Email</h2>
|
||||||
|
<p class="text-gray-600 mb-6">Enter your email address to manage your newsletter preferences.</p>
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
id="emailInput"
|
||||||
|
placeholder="your@email.com"
|
||||||
|
class="w-full px-4 py-3 border-2 border-gray-300 rounded-lg focus:outline-none focus:border-primary mb-4"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onclick="loadPreferences()"
|
||||||
|
class="w-full px-6 py-3 bg-primary hover:bg-secondary text-white font-semibold rounded-lg transition"
|
||||||
|
>
|
||||||
|
Load Preferences
|
||||||
|
</button>
|
||||||
|
<p id="emailError" class="mt-4 text-sm text-red-600 hidden"></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="preferencesForm" class="hidden">
|
||||||
|
<h2 class="text-2xl font-bold text-gray-800 mb-2">Your Preferences</h2>
|
||||||
|
<p class="text-gray-600 mb-6">Email: <strong id="userEmail"></strong></p>
|
||||||
|
|
||||||
|
<div class="mb-6">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-800 mb-3">Choose your interests:</h3>
|
||||||
|
<div class="space-y-3" id="categoryCheckboxes">
|
||||||
|
<!-- Categories will be loaded dynamically -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onclick="savePreferences()"
|
||||||
|
class="w-full px-6 py-3 bg-primary hover:bg-secondary text-white font-semibold rounded-lg transition mb-3"
|
||||||
|
>
|
||||||
|
Save Preferences
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onclick="showEmailForm()"
|
||||||
|
class="w-full px-6 py-3 bg-gray-200 hover:bg-gray-300 text-gray-800 font-semibold rounded-lg transition"
|
||||||
|
>
|
||||||
|
Change Email
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<p id="saveMessage" class="mt-4 text-sm min-h-[20px]"></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Back to Home -->
|
||||||
|
<div class="text-center">
|
||||||
|
<a href="/" class="text-white underline hover:opacity-80">← Back to Home</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
let currentEmail = '';
|
||||||
|
let availableCategories = [];
|
||||||
|
|
||||||
|
// Load categories from API
|
||||||
|
async function loadCategories() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/categories');
|
||||||
|
const data = await response.json();
|
||||||
|
availableCategories = data.categories || [];
|
||||||
|
renderCategories();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load categories:', error);
|
||||||
|
// Fallback to default categories
|
||||||
|
availableCategories = [
|
||||||
|
{ id: 'general', name: 'Top Trending', description: 'Top trending news and updates', icon: '🔥' },
|
||||||
|
{ id: 'local', name: 'Local Events', description: 'Local events and community news', icon: '🏛️' },
|
||||||
|
{ id: 'sports', name: 'Sports', description: 'Sports news', icon: '⚽' }
|
||||||
|
];
|
||||||
|
renderCategories();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCategories(selectedCategories = []) {
|
||||||
|
const container = document.getElementById('categoryCheckboxes');
|
||||||
|
container.innerHTML = '';
|
||||||
|
|
||||||
|
availableCategories.forEach(category => {
|
||||||
|
const isChecked = selectedCategories.length === 0 || selectedCategories.includes(category.id);
|
||||||
|
|
||||||
|
const label = document.createElement('label');
|
||||||
|
label.className = 'flex items-center space-x-3 p-3 border-2 border-gray-200 rounded-lg cursor-pointer hover:border-primary transition';
|
||||||
|
label.innerHTML = `
|
||||||
|
<input type="checkbox" value="${category.id}" ${isChecked ? 'checked' : ''} class="w-5 h-5 text-primary rounded focus:ring-2 focus:ring-primary">
|
||||||
|
<div>
|
||||||
|
<div class="font-semibold text-gray-800">${category.icon} ${category.name}</div>
|
||||||
|
<div class="text-sm text-gray-500">${category.description}</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
container.appendChild(label);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if email is in URL parameter
|
||||||
|
window.addEventListener('DOMContentLoaded', async () => {
|
||||||
|
// Load categories first
|
||||||
|
await loadCategories();
|
||||||
|
|
||||||
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
|
const email = urlParams.get('email');
|
||||||
|
|
||||||
|
if (email) {
|
||||||
|
document.getElementById('emailInput').value = email;
|
||||||
|
loadPreferences();
|
||||||
|
} else {
|
||||||
|
showEmailForm();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function showEmailForm() {
|
||||||
|
document.getElementById('loadingState').classList.add('hidden');
|
||||||
|
document.getElementById('emailForm').classList.remove('hidden');
|
||||||
|
document.getElementById('preferencesForm').classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadPreferences() {
|
||||||
|
const emailInput = document.getElementById('emailInput');
|
||||||
|
const email = emailInput.value.trim().toLowerCase();
|
||||||
|
const emailError = document.getElementById('emailError');
|
||||||
|
|
||||||
|
if (!email || !email.includes('@')) {
|
||||||
|
emailError.textContent = 'Please enter a valid email address';
|
||||||
|
emailError.classList.remove('hidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
emailError.classList.add('hidden');
|
||||||
|
currentEmail = email;
|
||||||
|
|
||||||
|
// Show loading
|
||||||
|
document.getElementById('emailForm').classList.add('hidden');
|
||||||
|
document.getElementById('loadingState').classList.remove('hidden');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Try to get subscriber info (we'll need to add this endpoint)
|
||||||
|
const response = await fetch(`/api/subscribers/${encodeURIComponent(email)}`);
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
const categories = data.categories || [];
|
||||||
|
|
||||||
|
// Update UI
|
||||||
|
document.getElementById('userEmail').textContent = email;
|
||||||
|
|
||||||
|
// Render categories with user's selections
|
||||||
|
renderCategories(categories);
|
||||||
|
|
||||||
|
// Show preferences form
|
||||||
|
document.getElementById('loadingState').classList.add('hidden');
|
||||||
|
document.getElementById('preferencesForm').classList.remove('hidden');
|
||||||
|
} else if (response.status === 404) {
|
||||||
|
// Email not found - show preferences form with all categories checked (new subscription)
|
||||||
|
document.getElementById('userEmail').textContent = email;
|
||||||
|
|
||||||
|
// Render all categories checked by default
|
||||||
|
renderCategories([]);
|
||||||
|
|
||||||
|
// Show preferences form
|
||||||
|
document.getElementById('loadingState').classList.add('hidden');
|
||||||
|
document.getElementById('preferencesForm').classList.remove('hidden');
|
||||||
|
|
||||||
|
// Show info message
|
||||||
|
const saveMessage = document.getElementById('saveMessage');
|
||||||
|
saveMessage.textContent = 'New subscriber - select your preferences and save to subscribe!';
|
||||||
|
saveMessage.className = 'mt-4 text-sm text-blue-600';
|
||||||
|
} else {
|
||||||
|
throw new Error('Failed to load preferences');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
emailError.textContent = 'Error loading preferences. Please try again.';
|
||||||
|
emailError.classList.remove('hidden');
|
||||||
|
showEmailForm();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function savePreferences() {
|
||||||
|
const saveMessage = document.getElementById('saveMessage');
|
||||||
|
const checkboxes = document.querySelectorAll('#preferencesForm input[type="checkbox"]:checked');
|
||||||
|
const categories = Array.from(checkboxes).map(cb => cb.value);
|
||||||
|
|
||||||
|
if (categories.length === 0) {
|
||||||
|
saveMessage.textContent = 'Please select at least one category';
|
||||||
|
saveMessage.className = 'mt-4 text-sm text-red-600';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
saveMessage.textContent = 'Saving...';
|
||||||
|
saveMessage.className = 'mt-4 text-sm text-gray-600';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/subscribe', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
email: currentEmail,
|
||||||
|
categories: categories
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
saveMessage.textContent = '✓ Preferences saved successfully!';
|
||||||
|
saveMessage.className = 'mt-4 text-sm text-green-600 font-semibold';
|
||||||
|
} else {
|
||||||
|
saveMessage.textContent = data.error || 'Failed to save preferences';
|
||||||
|
saveMessage.className = 'mt-4 text-sm text-red-600';
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
saveMessage.textContent = 'Network error. Please try again.';
|
||||||
|
saveMessage.className = 'mt-4 text-sm text-red-600';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -17,6 +17,15 @@ app.get('/admin', (req, res) => {
|
|||||||
res.sendFile(path.join(__dirname, 'public', 'admin.html'));
|
res.sendFile(path.join(__dirname, 'public', 'admin.html'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Serve preferences page
|
||||||
|
app.get('/preferences.html', (req, res) => {
|
||||||
|
res.sendFile(path.join(__dirname, 'public', 'preferences.html'));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/preferences', (req, res) => {
|
||||||
|
res.sendFile(path.join(__dirname, 'public', 'preferences.html'));
|
||||||
|
});
|
||||||
|
|
||||||
// Serve static files
|
// Serve static files
|
||||||
app.use(express.static('public'));
|
app.use(express.static('public'));
|
||||||
|
|
||||||
@@ -61,6 +70,83 @@ app.post('/api/unsubscribe', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.get('/api/subscribers/:email', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const response = await axios.get(`${API_URL}/api/subscribers/${req.params.email}`);
|
||||||
|
res.json(response.data);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(error.response?.status || 500).json(
|
||||||
|
error.response?.data || { error: 'Failed to get subscriber' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/categories', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const response = await axios.get(`${API_URL}/api/categories`);
|
||||||
|
res.json(response.data);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(error.response?.status || 500).json(
|
||||||
|
error.response?.data || { error: 'Failed to get categories' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/api/admin/subscribers/delete-all', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const response = await axios.delete(`${API_URL}/api/admin/subscribers/delete-all`);
|
||||||
|
res.json(response.data);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(error.response?.status || 500).json(
|
||||||
|
error.response?.data || { error: 'Failed to delete subscribers' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/rss-feeds', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const response = await axios.get(`${API_URL}/api/rss-feeds`);
|
||||||
|
res.json(response.data);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(error.response?.status || 500).json(
|
||||||
|
error.response?.data || { error: 'Failed to get RSS feeds' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/rss-feeds/export', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const response = await axios.get(`${API_URL}/api/rss-feeds/export`);
|
||||||
|
res.json(response.data);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(error.response?.status || 500).json(
|
||||||
|
error.response?.data || { error: 'Failed to export RSS feeds' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/rss-feeds/import', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const response = await axios.post(`${API_URL}/api/rss-feeds/import`, req.body);
|
||||||
|
res.json(response.data);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(error.response?.status || 500).json(
|
||||||
|
error.response?.data || { error: 'Failed to import RSS feeds' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/admin/recent-articles', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const response = await axios.get(`${API_URL}/api/admin/recent-articles`);
|
||||||
|
res.json(response.data);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(error.response?.status || 500).json(
|
||||||
|
error.response?.data || { error: 'Failed to get recent articles' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Ollama API proxy endpoints for admin dashboard
|
// Ollama API proxy endpoints for admin dashboard
|
||||||
app.get('/api/ollama/ping', async (req, res) => {
|
app.get('/api/ollama/ping', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -83,18 +83,18 @@
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
<!-- Top Trending Section -->
|
<!-- Category Sections -->
|
||||||
{% if trending_articles %}
|
{% for section in category_sections %}
|
||||||
<tr>
|
<tr>
|
||||||
<td style="padding: 30px 40px 15px 40px;">
|
<td style="padding: 30px 40px 15px 40px;">
|
||||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
<h2 style="margin: 0; font-size: 22px; font-weight: 700; color: #1a1a1a; display: flex; align-items: center;">
|
<h2 style="margin: 0; font-size: 22px; font-weight: 700; color: #1a1a1a;">
|
||||||
🔥 Top Trending in Munich
|
{{ section.icon }} {{ section.name }}
|
||||||
</h2>
|
</h2>
|
||||||
<p style="margin: 8px 0 0 0; font-size: 13px; color: #666666;">
|
<p style="margin: 8px 0 0 0; font-size: 13px; color: #666666;">
|
||||||
The most talked-about stories today
|
Top stories in {{ section.name.lower() }}
|
||||||
</p>
|
</p>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -102,8 +102,8 @@
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
<!-- Trending Articles -->
|
<!-- Category Articles -->
|
||||||
{% for article in trending_articles %}
|
{% for article in section.articles %}
|
||||||
<tr>
|
<tr>
|
||||||
<td style="padding: 25px 40px;">
|
<td style="padding: 25px 40px;">
|
||||||
<!-- Article Number Badge -->
|
<!-- Article Number Badge -->
|
||||||
@@ -177,110 +177,16 @@
|
|||||||
</tr>
|
</tr>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<!-- Other Articles Section -->
|
<!-- Category Section Divider -->
|
||||||
{% if other_articles %}
|
{% if not loop.last %}
|
||||||
<!-- Section Divider -->
|
|
||||||
<tr>
|
<tr>
|
||||||
<td style="padding: 25px 40px;">
|
<td style="padding: 25px 40px;">
|
||||||
<div style="height: 2px; background-color: #e0e0e0;"></div>
|
<div style="height: 2px; background-color: #e0e0e0;"></div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
<tr>
|
|
||||||
<td style="padding: 30px 40px 15px 40px;">
|
|
||||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
|
|
||||||
<tr>
|
|
||||||
<td>
|
|
||||||
<h2 style="margin: 0; font-size: 22px; font-weight: 700; color: #1a1a1a;">
|
|
||||||
📰 More Stories
|
|
||||||
</h2>
|
|
||||||
<p style="margin: 8px 0 0 0; font-size: 13px; color: #666666;">
|
|
||||||
Additional news from around Munich
|
|
||||||
</p>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
|
|
||||||
<!-- Other Articles -->
|
|
||||||
{% for article in other_articles %}
|
|
||||||
<tr>
|
|
||||||
<td style="padding: 25px 40px;">
|
|
||||||
<!-- Article Number Badge -->
|
|
||||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
|
|
||||||
<tr>
|
|
||||||
<td>
|
|
||||||
<span style="display: inline-block; background-color: #666666; color: #ffffff; width: 24px; height: 24px; line-height: 24px; text-align: center; border-radius: 50%; font-size: 12px; font-weight: 600;">
|
|
||||||
{{ loop.index + trending_articles|length }}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<!-- Article Title -->
|
|
||||||
<h2 style="margin: 12px 0 8px 0; font-size: 19px; font-weight: 700; line-height: 1.3; color: #1a1a1a;">
|
|
||||||
{{ article.title_en if article.title_en else article.title }}
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<!-- Original German Title (subtitle) -->
|
|
||||||
{% if article.title_en and article.title_en != article.title %}
|
|
||||||
<p style="margin: 0 0 12px 0; font-size: 13px; color: #999999; font-style: italic;">
|
|
||||||
Original: {{ article.title }}
|
|
||||||
</p>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<!-- Article Meta -->
|
|
||||||
<p style="margin: 0 0 12px 0; font-size: 13px; color: #999999;">
|
|
||||||
{% if article.is_clustered %}
|
|
||||||
<span style="color: #000000; font-weight: 600;">Multiple sources</span>
|
|
||||||
{% else %}
|
|
||||||
<span style="color: #000000; font-weight: 600;">{{ article.source }}</span>
|
|
||||||
{% if article.author %}
|
|
||||||
<span> • {{ article.author }}</span>
|
|
||||||
{% endif %}
|
|
||||||
{% endif %}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<!-- Article Summary -->
|
|
||||||
<p style="margin: 0 0 15px 0; font-size: 15px; line-height: 1.6; color: #333333; text-align: justify;">
|
|
||||||
{{ article.summary }}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<!-- Read More Links -->
|
|
||||||
{% if article.is_clustered and article.sources %}
|
|
||||||
<!-- Multiple sources -->
|
|
||||||
<p style="margin: 0 0 8px 0; font-size: 13px; color: #666666;">
|
|
||||||
📰 Covered by {{ article.article_count }} sources:
|
|
||||||
</p>
|
|
||||||
<div style="margin: 0;">
|
|
||||||
{% for source in article.sources %}
|
|
||||||
<a href="{{ source.link }}" style="display: inline-block; color: #000000; text-decoration: none; font-size: 13px; font-weight: 600; border-bottom: 2px solid #000000; padding-bottom: 2px; margin-right: 15px; margin-bottom: 8px;">
|
|
||||||
{{ source.name }} →
|
|
||||||
</a>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
{% else %}
|
|
||||||
<!-- Single source -->
|
|
||||||
<a href="{{ article.link }}" style="display: inline-block; color: #000000; text-decoration: none; font-size: 14px; font-weight: 600; border-bottom: 2px solid #000000; padding-bottom: 2px;">
|
|
||||||
Read more →
|
|
||||||
</a>
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
|
|
||||||
<!-- Article Divider -->
|
|
||||||
{% if not loop.last %}
|
|
||||||
<tr>
|
|
||||||
<td style="padding: 0 40px;">
|
|
||||||
<div style="height: 1px; background-color: #f0f0f0;"></div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<!-- Bottom Divider -->
|
<!-- Bottom Divider -->
|
||||||
<tr>
|
<tr>
|
||||||
@@ -299,8 +205,8 @@
|
|||||||
Here's everything in one sentence each:
|
Here's everything in one sentence each:
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{% set all_articles = (trending_articles or []) + (other_articles or []) %}
|
{% for section in category_sections %}
|
||||||
{% for article in all_articles %}
|
{% for article in section.articles %}
|
||||||
<div style="margin-bottom: 12px; padding-left: 20px; position: relative;">
|
<div style="margin-bottom: 12px; padding-left: 20px; position: relative;">
|
||||||
<span style="position: absolute; left: 0; top: 0; color: #667eea; font-weight: 700;">{{ loop.index }}.</span>
|
<span style="position: absolute; left: 0; top: 0; color: #667eea; font-weight: 700;">{{ loop.index }}.</span>
|
||||||
<p style="margin: 0; font-size: 14px; line-height: 1.5; color: #333333;">
|
<p style="margin: 0; font-size: 14px; line-height: 1.5; color: #333333;">
|
||||||
@@ -309,6 +215,7 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
{% endfor %}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
@@ -355,6 +262,8 @@
|
|||||||
<p style="margin: 0; font-size: 12px; color: #666666;">
|
<p style="margin: 0; font-size: 12px; color: #666666;">
|
||||||
<a href="{{ website_link }}" style="color: #999999; text-decoration: none;">Visit Website</a>
|
<a href="{{ website_link }}" style="color: #999999; text-decoration: none;">Visit Website</a>
|
||||||
<span style="color: #444444;"> • </span>
|
<span style="color: #444444;"> • </span>
|
||||||
|
<a href="{{ preferences_link }}" style="color: #999999; text-decoration: none;">Manage Preferences</a>
|
||||||
|
<span style="color: #444444;"> • </span>
|
||||||
<a href="{{ unsubscribe_link }}" style="color: #999999; text-decoration: none;">Unsubscribe</a>
|
<a href="{{ unsubscribe_link }}" style="color: #999999; text-decoration: none;">Unsubscribe</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
|||||||
@@ -162,6 +162,7 @@ def get_latest_articles(max_articles=10, hours=24):
|
|||||||
'link': doc.get('link', ''),
|
'link': doc.get('link', ''),
|
||||||
'summary': cluster.get('neutral_summary', doc.get('summary', '')),
|
'summary': cluster.get('neutral_summary', doc.get('summary', '')),
|
||||||
'source': doc.get('source', ''),
|
'source': doc.get('source', ''),
|
||||||
|
'category': doc.get('category', 'general'),
|
||||||
'published_at': doc.get('published_at', ''),
|
'published_at': doc.get('published_at', ''),
|
||||||
'is_clustered': True,
|
'is_clustered': True,
|
||||||
'sources': sources,
|
'sources': sources,
|
||||||
@@ -177,6 +178,7 @@ def get_latest_articles(max_articles=10, hours=24):
|
|||||||
'link': doc.get('link', ''),
|
'link': doc.get('link', ''),
|
||||||
'summary': doc.get('summary', ''),
|
'summary': doc.get('summary', ''),
|
||||||
'source': doc.get('source', ''),
|
'source': doc.get('source', ''),
|
||||||
|
'category': doc.get('category', 'general'),
|
||||||
'published_at': doc.get('published_at', ''),
|
'published_at': doc.get('published_at', ''),
|
||||||
'is_clustered': False
|
'is_clustered': False
|
||||||
})
|
})
|
||||||
@@ -190,6 +192,7 @@ def get_latest_articles(max_articles=10, hours=24):
|
|||||||
'link': doc.get('link', ''),
|
'link': doc.get('link', ''),
|
||||||
'summary': doc.get('summary', ''),
|
'summary': doc.get('summary', ''),
|
||||||
'source': doc.get('source', ''),
|
'source': doc.get('source', ''),
|
||||||
|
'category': doc.get('category', 'general'),
|
||||||
'published_at': doc.get('published_at', ''),
|
'published_at': doc.get('published_at', ''),
|
||||||
'is_clustered': False
|
'is_clustered': False
|
||||||
})
|
})
|
||||||
@@ -206,22 +209,29 @@ def get_latest_articles(max_articles=10, hours=24):
|
|||||||
|
|
||||||
def get_active_subscribers():
|
def get_active_subscribers():
|
||||||
"""
|
"""
|
||||||
Get all active subscribers from database
|
Get all active subscribers from database with their category preferences
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
list: Email addresses of active subscribers
|
list: Subscriber dictionaries with email and categories
|
||||||
"""
|
"""
|
||||||
cursor = subscribers_collection.find({'status': 'active'})
|
cursor = subscribers_collection.find({'status': 'active'})
|
||||||
return [doc['email'] for doc in cursor]
|
subscribers = []
|
||||||
|
for doc in cursor:
|
||||||
|
subscribers.append({
|
||||||
|
'email': doc['email'],
|
||||||
|
'categories': doc.get('categories', None) # None means all categories
|
||||||
|
})
|
||||||
|
return subscribers
|
||||||
|
|
||||||
|
|
||||||
def render_newsletter_html(articles, tracking_enabled=False, pixel_tracking_id=None,
|
def render_newsletter_html(articles, subscriber_categories=None, tracking_enabled=False,
|
||||||
link_tracking_map=None, api_url=None):
|
pixel_tracking_id=None, link_tracking_map=None, api_url=None):
|
||||||
"""
|
"""
|
||||||
Render newsletter HTML from template with optional tracking integration
|
Render newsletter HTML from template with optional tracking integration
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
articles: List of article dictionaries
|
articles: List of article dictionaries
|
||||||
|
subscriber_categories: List of categories the subscriber wants (None = all)
|
||||||
tracking_enabled: Whether to inject tracking pixel and replace links
|
tracking_enabled: Whether to inject tracking pixel and replace links
|
||||||
pixel_tracking_id: Tracking ID for the email open pixel
|
pixel_tracking_id: Tracking ID for the email open pixel
|
||||||
link_tracking_map: Dictionary mapping original URLs to tracking IDs
|
link_tracking_map: Dictionary mapping original URLs to tracking IDs
|
||||||
@@ -237,10 +247,39 @@ def render_newsletter_html(articles, tracking_enabled=False, pixel_tracking_id=N
|
|||||||
|
|
||||||
template = Template(template_content)
|
template = Template(template_content)
|
||||||
|
|
||||||
# Split articles into sections
|
# Filter articles by subscriber's category preferences
|
||||||
# Top 3 are "trending", rest are "other articles"
|
if subscriber_categories:
|
||||||
trending_articles = articles[:3] if len(articles) >= 3 else articles
|
filtered_articles = [a for a in articles if a.get('category', 'general') in subscriber_categories]
|
||||||
other_articles = articles[3:] if len(articles) > 3 else []
|
else:
|
||||||
|
filtered_articles = articles
|
||||||
|
|
||||||
|
# Group articles by category (max 3 per category)
|
||||||
|
from collections import defaultdict
|
||||||
|
articles_by_category = defaultdict(list)
|
||||||
|
|
||||||
|
for article in filtered_articles:
|
||||||
|
category = article.get('category', 'general')
|
||||||
|
if len(articles_by_category[category]) < 3:
|
||||||
|
articles_by_category[category].append(article)
|
||||||
|
|
||||||
|
# Convert to list of category sections
|
||||||
|
category_sections = []
|
||||||
|
category_names = {
|
||||||
|
'general': {'name': 'Top Trending', 'icon': '🔥'},
|
||||||
|
'local': {'name': 'Local Events', 'icon': '🏛️'},
|
||||||
|
'sports': {'name': 'Sports', 'icon': '⚽'},
|
||||||
|
'science': {'name': 'Science & Tech', 'icon': '🔬'}
|
||||||
|
}
|
||||||
|
|
||||||
|
for category, category_articles in sorted(articles_by_category.items()):
|
||||||
|
if category_articles:
|
||||||
|
cat_info = category_names.get(category, {'name': category.title(), 'icon': '📄'})
|
||||||
|
category_sections.append({
|
||||||
|
'id': category,
|
||||||
|
'name': cat_info['name'],
|
||||||
|
'icon': cat_info['icon'],
|
||||||
|
'articles': category_articles
|
||||||
|
})
|
||||||
|
|
||||||
# Get weather data
|
# Get weather data
|
||||||
from weather_service import get_munich_weather
|
from weather_service import get_munich_weather
|
||||||
@@ -248,13 +287,14 @@ def render_newsletter_html(articles, tracking_enabled=False, pixel_tracking_id=N
|
|||||||
|
|
||||||
# Prepare template data
|
# Prepare template data
|
||||||
now = datetime.now()
|
now = datetime.now()
|
||||||
|
total_articles = sum(len(section['articles']) for section in category_sections)
|
||||||
template_data = {
|
template_data = {
|
||||||
'date': now.strftime('%A, %B %d, %Y'),
|
'date': now.strftime('%A, %B %d, %Y'),
|
||||||
'year': now.year,
|
'year': now.year,
|
||||||
'article_count': len(articles),
|
'article_count': total_articles,
|
||||||
'trending_articles': trending_articles,
|
'category_sections': category_sections,
|
||||||
'other_articles': other_articles,
|
|
||||||
'unsubscribe_link': f'{Config.WEBSITE_URL}/unsubscribe',
|
'unsubscribe_link': f'{Config.WEBSITE_URL}/unsubscribe',
|
||||||
|
'preferences_link': f'{Config.WEBSITE_URL}/preferences.html',
|
||||||
'website_link': Config.WEBSITE_URL,
|
'website_link': Config.WEBSITE_URL,
|
||||||
'tracking_enabled': tracking_enabled,
|
'tracking_enabled': tracking_enabled,
|
||||||
'weather': weather
|
'weather': weather
|
||||||
@@ -358,7 +398,8 @@ def send_newsletter(max_articles=None, test_email=None):
|
|||||||
|
|
||||||
# Get subscribers
|
# Get subscribers
|
||||||
if test_email:
|
if test_email:
|
||||||
subscribers = [test_email]
|
# For test mode, send with all categories
|
||||||
|
subscribers = [{'email': test_email, 'categories': None}]
|
||||||
print(f"\n🧪 Test mode: Sending to {test_email} only")
|
print(f"\n🧪 Test mode: Sending to {test_email} only")
|
||||||
else:
|
else:
|
||||||
print("\nFetching active subscribers...")
|
print("\nFetching active subscribers...")
|
||||||
@@ -386,7 +427,10 @@ def send_newsletter(max_articles=None, test_email=None):
|
|||||||
failed_count = 0
|
failed_count = 0
|
||||||
errors = []
|
errors = []
|
||||||
|
|
||||||
for i, email in enumerate(subscribers, 1):
|
for i, subscriber in enumerate(subscribers, 1):
|
||||||
|
email = subscriber['email']
|
||||||
|
categories = subscriber['categories']
|
||||||
|
|
||||||
print(f"[{i}/{len(subscribers)}] Sending to {email}...", end=' ')
|
print(f"[{i}/{len(subscribers)}] Sending to {email}...", end=' ')
|
||||||
|
|
||||||
# Generate tracking data for this subscriber if tracking is enabled
|
# Generate tracking data for this subscriber if tracking is enabled
|
||||||
@@ -399,9 +443,10 @@ def send_newsletter(max_articles=None, test_email=None):
|
|||||||
tracking_service=tracking_service
|
tracking_service=tracking_service
|
||||||
)
|
)
|
||||||
|
|
||||||
# Render newsletter with tracking
|
# Render newsletter with tracking and subscriber's category preferences
|
||||||
html_content = render_newsletter_html(
|
html_content = render_newsletter_html(
|
||||||
articles=articles,
|
articles=articles,
|
||||||
|
subscriber_categories=categories,
|
||||||
tracking_enabled=True,
|
tracking_enabled=True,
|
||||||
pixel_tracking_id=tracking_data['pixel_tracking_id'],
|
pixel_tracking_id=tracking_data['pixel_tracking_id'],
|
||||||
link_tracking_map=tracking_data['link_tracking_map'],
|
link_tracking_map=tracking_data['link_tracking_map'],
|
||||||
@@ -410,10 +455,10 @@ def send_newsletter(max_articles=None, test_email=None):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"⚠ Tracking error: {e}, sending without tracking...", end=' ')
|
print(f"⚠ Tracking error: {e}, sending without tracking...", end=' ')
|
||||||
# Fallback: send without tracking
|
# Fallback: send without tracking
|
||||||
html_content = render_newsletter_html(articles)
|
html_content = render_newsletter_html(articles, subscriber_categories=categories)
|
||||||
else:
|
else:
|
||||||
# Render newsletter without tracking
|
# Render newsletter without tracking but with subscriber's preferences
|
||||||
html_content = render_newsletter_html(articles)
|
html_content = render_newsletter_html(articles, subscriber_categories=categories)
|
||||||
|
|
||||||
# Send email
|
# Send email
|
||||||
success, error = send_email(email, subject, html_content)
|
success, error = send_email(email, subject, html_content)
|
||||||
|
|||||||
Reference in New Issue
Block a user