update
This commit is contained in:
1
backend/routes/__init__.py
Normal file
1
backend/routes/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Routes package
|
||||
123
backend/routes/news_routes.py
Normal file
123
backend/routes/news_routes.py
Normal file
@@ -0,0 +1,123 @@
|
||||
from flask import Blueprint, jsonify
|
||||
from database import articles_collection
|
||||
from services.news_service import fetch_munich_news, save_articles_to_db
|
||||
|
||||
news_bp = Blueprint('news', __name__)
|
||||
|
||||
|
||||
@news_bp.route('/api/news', methods=['GET'])
|
||||
def get_news():
|
||||
"""Get latest Munich news"""
|
||||
try:
|
||||
# Fetch fresh news and save to database
|
||||
articles = fetch_munich_news()
|
||||
save_articles_to_db(articles)
|
||||
|
||||
# Get articles from MongoDB, sorted by created_at (newest first)
|
||||
cursor = articles_collection.find().sort('created_at', -1).limit(20)
|
||||
|
||||
db_articles = []
|
||||
for doc in cursor:
|
||||
article = {
|
||||
'title': doc.get('title', ''),
|
||||
'author': doc.get('author'),
|
||||
'link': doc.get('link', ''),
|
||||
'source': doc.get('source', ''),
|
||||
'published': doc.get('published_at', ''),
|
||||
'word_count': doc.get('word_count'),
|
||||
'has_full_content': bool(doc.get('content')),
|
||||
'has_summary': bool(doc.get('summary'))
|
||||
}
|
||||
|
||||
# Include AI summary if available
|
||||
if doc.get('summary'):
|
||||
article['summary'] = doc.get('summary', '')
|
||||
article['summary_word_count'] = doc.get('summary_word_count')
|
||||
article['summarized_at'] = doc.get('summarized_at', '').isoformat() if doc.get('summarized_at') else None
|
||||
# Fallback: Include preview of content if no summary (first 200 chars)
|
||||
elif doc.get('content'):
|
||||
article['preview'] = doc.get('content', '')[:200] + '...'
|
||||
|
||||
db_articles.append(article)
|
||||
|
||||
# Combine fresh articles with database articles and deduplicate
|
||||
seen_links = set()
|
||||
combined = []
|
||||
|
||||
# Add fresh articles first (they're more recent)
|
||||
for article in articles:
|
||||
link = article.get('link', '')
|
||||
if link and link not in seen_links:
|
||||
seen_links.add(link)
|
||||
combined.append(article)
|
||||
|
||||
# Add database articles
|
||||
for article in db_articles:
|
||||
link = article.get('link', '')
|
||||
if link and link not in seen_links:
|
||||
seen_links.add(link)
|
||||
combined.append(article)
|
||||
|
||||
return jsonify({'articles': combined[:20]}), 200
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@news_bp.route('/api/news/<path:article_url>', methods=['GET'])
|
||||
def get_article_by_url(article_url):
|
||||
"""Get full article content by URL"""
|
||||
try:
|
||||
# Decode URL
|
||||
from urllib.parse import unquote
|
||||
decoded_url = unquote(article_url)
|
||||
|
||||
# Find article by link
|
||||
article = articles_collection.find_one({'link': decoded_url})
|
||||
|
||||
if not article:
|
||||
return jsonify({'error': 'Article not found'}), 404
|
||||
|
||||
return jsonify({
|
||||
'title': article.get('title', ''),
|
||||
'author': article.get('author'),
|
||||
'link': article.get('link', ''),
|
||||
'content': article.get('content', ''),
|
||||
'summary': article.get('summary'),
|
||||
'word_count': article.get('word_count', 0),
|
||||
'summary_word_count': article.get('summary_word_count'),
|
||||
'source': article.get('source', ''),
|
||||
'published_at': article.get('published_at', ''),
|
||||
'crawled_at': article.get('crawled_at', '').isoformat() if article.get('crawled_at') else None,
|
||||
'summarized_at': article.get('summarized_at', '').isoformat() if article.get('summarized_at') else None,
|
||||
'created_at': article.get('created_at', '').isoformat() if article.get('created_at') else None
|
||||
}), 200
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@news_bp.route('/api/stats', methods=['GET'])
|
||||
def get_stats():
|
||||
"""Get subscription statistics"""
|
||||
try:
|
||||
from database import subscribers_collection
|
||||
|
||||
# Count only active subscribers
|
||||
subscriber_count = subscribers_collection.count_documents({'status': 'active'})
|
||||
|
||||
# Also get total article count
|
||||
article_count = articles_collection.count_documents({})
|
||||
|
||||
# Count crawled articles
|
||||
crawled_count = articles_collection.count_documents({'content': {'$exists': True, '$ne': ''}})
|
||||
|
||||
# Count summarized articles
|
||||
summarized_count = articles_collection.count_documents({'summary': {'$exists': True, '$ne': ''}})
|
||||
|
||||
return jsonify({
|
||||
'subscribers': subscriber_count,
|
||||
'articles': article_count,
|
||||
'crawled_articles': crawled_count,
|
||||
'summarized_articles': summarized_count
|
||||
}), 200
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
62
backend/routes/newsletter_routes.py
Normal file
62
backend/routes/newsletter_routes.py
Normal file
@@ -0,0 +1,62 @@
|
||||
from flask import Blueprint, Response
|
||||
from pathlib import Path
|
||||
from jinja2 import Template
|
||||
from datetime import datetime
|
||||
from database import articles_collection
|
||||
|
||||
newsletter_bp = Blueprint('newsletter', __name__)
|
||||
|
||||
|
||||
@newsletter_bp.route('/api/newsletter/preview', methods=['GET'])
|
||||
def preview_newsletter():
|
||||
"""Preview the newsletter HTML (for testing)"""
|
||||
try:
|
||||
# Get latest articles with AI summaries
|
||||
cursor = articles_collection.find(
|
||||
{'summary': {'$exists': True, '$ne': None}}
|
||||
).sort('created_at', -1).limit(10)
|
||||
|
||||
articles = []
|
||||
for doc in cursor:
|
||||
articles.append({
|
||||
'title': doc.get('title', ''),
|
||||
'author': doc.get('author'),
|
||||
'link': doc.get('link', ''),
|
||||
'summary': doc.get('summary', ''),
|
||||
'source': doc.get('source', ''),
|
||||
'published_at': doc.get('published_at', '')
|
||||
})
|
||||
|
||||
if not articles:
|
||||
return Response(
|
||||
"<h1>No articles with summaries found</h1><p>Run the crawler with Ollama enabled first.</p>",
|
||||
mimetype='text/html'
|
||||
)
|
||||
|
||||
# Load template
|
||||
template_path = Path(__file__).parent.parent / 'templates' / 'newsletter_template.html'
|
||||
with open(template_path, 'r', encoding='utf-8') as f:
|
||||
template_content = f.read()
|
||||
|
||||
template = Template(template_content)
|
||||
|
||||
# Prepare data
|
||||
now = datetime.now()
|
||||
template_data = {
|
||||
'date': now.strftime('%A, %B %d, %Y'),
|
||||
'year': now.year,
|
||||
'article_count': len(articles),
|
||||
'articles': articles,
|
||||
'unsubscribe_link': 'http://localhost:3000/unsubscribe',
|
||||
'website_link': 'http://localhost:3000'
|
||||
}
|
||||
|
||||
# Render and return HTML
|
||||
html_content = template.render(**template_data)
|
||||
return Response(html_content, mimetype='text/html')
|
||||
|
||||
except Exception as e:
|
||||
return Response(
|
||||
f"<h1>Error</h1><p>{str(e)}</p>",
|
||||
mimetype='text/html'
|
||||
), 500
|
||||
158
backend/routes/ollama_routes.py
Normal file
158
backend/routes/ollama_routes.py
Normal file
@@ -0,0 +1,158 @@
|
||||
from flask import Blueprint, jsonify
|
||||
from config import Config
|
||||
from services.ollama_service import call_ollama, list_ollama_models
|
||||
import os
|
||||
|
||||
ollama_bp = Blueprint('ollama', __name__)
|
||||
|
||||
|
||||
@ollama_bp.route('/api/ollama/ping', methods=['GET', 'POST'])
|
||||
def ping_ollama():
|
||||
"""Test connection to Ollama server"""
|
||||
try:
|
||||
# Check if Ollama is enabled
|
||||
if not Config.OLLAMA_ENABLED:
|
||||
return jsonify({
|
||||
'status': 'disabled',
|
||||
'message': 'Ollama is not enabled. Set OLLAMA_ENABLED=true in your .env file.',
|
||||
'ollama_config': {
|
||||
'base_url': Config.OLLAMA_BASE_URL,
|
||||
'model': Config.OLLAMA_MODEL,
|
||||
'enabled': False
|
||||
}
|
||||
}), 200
|
||||
|
||||
# Send a simple test prompt
|
||||
test_prompt = "Say 'Hello! I am connected and working.' in one sentence."
|
||||
system_prompt = "You are a helpful assistant. Respond briefly and concisely."
|
||||
|
||||
response_text, error_message = call_ollama(test_prompt, system_prompt)
|
||||
|
||||
if response_text:
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'message': 'Successfully connected to Ollama',
|
||||
'response': response_text,
|
||||
'ollama_config': {
|
||||
'base_url': Config.OLLAMA_BASE_URL,
|
||||
'model': Config.OLLAMA_MODEL,
|
||||
'enabled': True
|
||||
}
|
||||
}), 200
|
||||
else:
|
||||
# Try to get available models for better error message
|
||||
available_models, _ = list_ollama_models()
|
||||
|
||||
troubleshooting = {
|
||||
'check_server': f'Verify Ollama is running at {Config.OLLAMA_BASE_URL}',
|
||||
'check_model': f'Verify model "{Config.OLLAMA_MODEL}" is available (run: ollama list)',
|
||||
'test_connection': f'Test manually: curl {Config.OLLAMA_BASE_URL}/api/generate -d \'{{"model":"{Config.OLLAMA_MODEL}","prompt":"test"}}\''
|
||||
}
|
||||
|
||||
if available_models:
|
||||
troubleshooting['available_models'] = available_models
|
||||
troubleshooting['suggestion'] = f'Try setting OLLAMA_MODEL to one of: {", ".join(available_models[:5])}'
|
||||
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': error_message or 'Failed to get response from Ollama',
|
||||
'error_details': error_message,
|
||||
'ollama_config': {
|
||||
'base_url': Config.OLLAMA_BASE_URL,
|
||||
'model': Config.OLLAMA_MODEL,
|
||||
'enabled': True
|
||||
},
|
||||
'troubleshooting': troubleshooting
|
||||
}), 500
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'Error connecting to Ollama: {str(e)}',
|
||||
'ollama_config': {
|
||||
'base_url': Config.OLLAMA_BASE_URL,
|
||||
'model': Config.OLLAMA_MODEL,
|
||||
'enabled': Config.OLLAMA_ENABLED
|
||||
}
|
||||
}), 500
|
||||
|
||||
|
||||
@ollama_bp.route('/api/ollama/config', methods=['GET'])
|
||||
def get_ollama_config():
|
||||
"""Get current Ollama configuration (for debugging)"""
|
||||
try:
|
||||
from pathlib import Path
|
||||
backend_dir = Path(__file__).parent.parent
|
||||
env_path = backend_dir / '.env'
|
||||
|
||||
return jsonify({
|
||||
'ollama_config': {
|
||||
'base_url': Config.OLLAMA_BASE_URL,
|
||||
'model': Config.OLLAMA_MODEL,
|
||||
'enabled': Config.OLLAMA_ENABLED,
|
||||
'has_api_key': bool(Config.OLLAMA_API_KEY)
|
||||
},
|
||||
'env_file_path': str(env_path),
|
||||
'env_file_exists': env_path.exists(),
|
||||
'current_working_directory': os.getcwd()
|
||||
}), 200
|
||||
except Exception as e:
|
||||
return jsonify({
|
||||
'error': str(e),
|
||||
'ollama_config': {
|
||||
'base_url': Config.OLLAMA_BASE_URL,
|
||||
'model': Config.OLLAMA_MODEL,
|
||||
'enabled': Config.OLLAMA_ENABLED
|
||||
}
|
||||
}), 500
|
||||
|
||||
|
||||
@ollama_bp.route('/api/ollama/models', methods=['GET'])
|
||||
def get_ollama_models():
|
||||
"""List available models on Ollama server"""
|
||||
try:
|
||||
if not Config.OLLAMA_ENABLED:
|
||||
return jsonify({
|
||||
'status': 'disabled',
|
||||
'message': 'Ollama is not enabled. Set OLLAMA_ENABLED=true in your .env file.',
|
||||
'ollama_config': {
|
||||
'base_url': Config.OLLAMA_BASE_URL,
|
||||
'model': Config.OLLAMA_MODEL,
|
||||
'enabled': False
|
||||
}
|
||||
}), 200
|
||||
|
||||
models, error_message = list_ollama_models()
|
||||
|
||||
if models is not None:
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'models': models,
|
||||
'current_model': Config.OLLAMA_MODEL,
|
||||
'ollama_config': {
|
||||
'base_url': Config.OLLAMA_BASE_URL,
|
||||
'model': Config.OLLAMA_MODEL,
|
||||
'enabled': True
|
||||
}
|
||||
}), 200
|
||||
else:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': error_message or 'Failed to list models',
|
||||
'ollama_config': {
|
||||
'base_url': Config.OLLAMA_BASE_URL,
|
||||
'model': Config.OLLAMA_MODEL,
|
||||
'enabled': True
|
||||
}
|
||||
}), 500
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'Error listing models: {str(e)}',
|
||||
'ollama_config': {
|
||||
'base_url': Config.OLLAMA_BASE_URL,
|
||||
'model': Config.OLLAMA_MODEL,
|
||||
'enabled': Config.OLLAMA_ENABLED
|
||||
}
|
||||
}), 500
|
||||
124
backend/routes/rss_routes.py
Normal file
124
backend/routes/rss_routes.py
Normal file
@@ -0,0 +1,124 @@
|
||||
from flask import Blueprint, request, jsonify
|
||||
from datetime import datetime
|
||||
from pymongo.errors import DuplicateKeyError
|
||||
from bson.objectid import ObjectId
|
||||
import feedparser
|
||||
from database import rss_feeds_collection
|
||||
|
||||
rss_bp = Blueprint('rss', __name__)
|
||||
|
||||
|
||||
@rss_bp.route('/api/rss-feeds', methods=['GET'])
|
||||
def get_rss_feeds():
|
||||
"""Get all RSS feeds"""
|
||||
try:
|
||||
cursor = rss_feeds_collection.find().sort('created_at', -1)
|
||||
feeds = []
|
||||
for feed in cursor:
|
||||
feeds.append({
|
||||
'id': str(feed['_id']),
|
||||
'name': feed.get('name', ''),
|
||||
'url': feed.get('url', ''),
|
||||
'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:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@rss_bp.route('/api/rss-feeds', methods=['POST'])
|
||||
def add_rss_feed():
|
||||
"""Add a new RSS feed"""
|
||||
data = request.json
|
||||
name = data.get('name', '').strip()
|
||||
url = data.get('url', '').strip()
|
||||
|
||||
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
|
||||
|
||||
try:
|
||||
# Test if the RSS feed is valid
|
||||
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
|
||||
|
||||
feed_doc = {
|
||||
'name': name,
|
||||
'url': url,
|
||||
'active': True,
|
||||
'created_at': datetime.utcnow()
|
||||
}
|
||||
|
||||
try:
|
||||
result = rss_feeds_collection.insert_one(feed_doc)
|
||||
return jsonify({
|
||||
'message': 'RSS feed added successfully',
|
||||
'id': str(result.inserted_id)
|
||||
}), 201
|
||||
except DuplicateKeyError:
|
||||
return jsonify({'error': 'RSS feed URL already exists'}), 409
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@rss_bp.route('/api/rss-feeds/<feed_id>', methods=['DELETE'])
|
||||
def remove_rss_feed(feed_id):
|
||||
"""Remove an RSS feed"""
|
||||
try:
|
||||
# Validate ObjectId
|
||||
try:
|
||||
obj_id = ObjectId(feed_id)
|
||||
except Exception:
|
||||
return jsonify({'error': 'Invalid feed ID'}), 400
|
||||
|
||||
result = rss_feeds_collection.delete_one({'_id': obj_id})
|
||||
|
||||
if result.deleted_count > 0:
|
||||
return jsonify({'message': 'RSS feed removed successfully'}), 200
|
||||
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(
|
||||
{'_id': obj_id},
|
||||
{'$set': {'active': new_status}}
|
||||
)
|
||||
|
||||
if result.modified_count > 0:
|
||||
return jsonify({
|
||||
'message': f'RSS feed {"activated" if new_status else "deactivated"} successfully',
|
||||
'active': new_status
|
||||
}), 200
|
||||
else:
|
||||
return jsonify({'error': 'Failed to update RSS feed'}), 500
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
63
backend/routes/subscription_routes.py
Normal file
63
backend/routes/subscription_routes.py
Normal file
@@ -0,0 +1,63 @@
|
||||
from flask import Blueprint, request, jsonify
|
||||
from datetime import datetime
|
||||
from pymongo.errors import DuplicateKeyError
|
||||
from database import subscribers_collection
|
||||
|
||||
subscription_bp = Blueprint('subscription', __name__)
|
||||
|
||||
|
||||
@subscription_bp.route('/api/subscribe', methods=['POST'])
|
||||
def subscribe():
|
||||
"""Subscribe a user to the newsletter"""
|
||||
data = request.json
|
||||
email = data.get('email', '').strip().lower()
|
||||
|
||||
if not email or '@' not in email:
|
||||
return jsonify({'error': 'Invalid email address'}), 400
|
||||
|
||||
try:
|
||||
subscriber_doc = {
|
||||
'email': email,
|
||||
'subscribed_at': datetime.utcnow(),
|
||||
'status': 'active'
|
||||
}
|
||||
|
||||
# Try to insert, if duplicate key error, subscriber already exists
|
||||
try:
|
||||
subscribers_collection.insert_one(subscriber_doc)
|
||||
return jsonify({'message': 'Successfully subscribed!'}), 201
|
||||
except DuplicateKeyError:
|
||||
# Check if subscriber is active
|
||||
existing = subscribers_collection.find_one({'email': email})
|
||||
if existing and existing.get('status') == 'active':
|
||||
return jsonify({'message': 'Email already subscribed'}), 200
|
||||
else:
|
||||
# Reactivate if previously unsubscribed
|
||||
subscribers_collection.update_one(
|
||||
{'email': email},
|
||||
{'$set': {'status': 'active', 'subscribed_at': datetime.utcnow()}}
|
||||
)
|
||||
return jsonify({'message': 'Successfully re-subscribed!'}), 200
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@subscription_bp.route('/api/unsubscribe', methods=['POST'])
|
||||
def unsubscribe():
|
||||
"""Unsubscribe a user from the newsletter"""
|
||||
data = request.json
|
||||
email = data.get('email', '').strip().lower()
|
||||
|
||||
try:
|
||||
result = subscribers_collection.update_one(
|
||||
{'email': email},
|
||||
{'$set': {'status': 'inactive'}}
|
||||
)
|
||||
|
||||
if result.matched_count > 0:
|
||||
return jsonify({'message': 'Successfully unsubscribed'}), 200
|
||||
else:
|
||||
return jsonify({'error': 'Email not found in subscribers'}), 404
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
Reference in New Issue
Block a user