This commit is contained in:
2025-11-10 19:13:33 +01:00
commit ac5738c29d
64 changed files with 9445 additions and 0 deletions

View File

@@ -0,0 +1 @@
# Routes package

View 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

View 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

View 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

View 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

View 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