refactor: Migrate ZOPK and Users API routes to admin blueprint
Major refactoring to reduce app.py size by ~22%: - Move all ZOPK routes (47 endpoints) to 4 blueprint files: - routes_zopk_dashboard.py - main dashboard - routes_zopk_news.py - news management, scraping, AI evaluation - routes_zopk_knowledge.py - knowledge base, embeddings, graph - routes_zopk_timeline.py - milestones management - Move Users API routes to routes_users_api.py: - /admin/users-api/ai-parse - AI-powered user parsing - /admin/users-api/bulk-create - bulk user creation - Move notify-release to routes.py app.py reduced from 11518 to 8916 lines (-22.6%) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
351c8fba75
commit
49830855a2
@ -18,3 +18,9 @@ from . import routes_announcements # noqa: E402, F401
|
||||
from . import routes_insights # noqa: E402, F401
|
||||
from . import routes_analytics # noqa: E402, F401
|
||||
from . import routes_model_comparison # noqa: E402, F401
|
||||
from . import routes_ai_usage # noqa: E402, F401
|
||||
from . import routes_zopk_dashboard # noqa: E402, F401
|
||||
from . import routes_zopk_news # noqa: E402, F401
|
||||
from . import routes_zopk_knowledge # noqa: E402, F401
|
||||
from . import routes_zopk_timeline # noqa: E402, F401
|
||||
from . import routes_users_api # noqa: E402, F401
|
||||
|
||||
@ -826,3 +826,33 @@ def admin_calendar_delete(event_id):
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
# ============================================================
|
||||
# RELEASE NOTIFICATIONS
|
||||
# ============================================================
|
||||
|
||||
@bp.route('/notify-release', methods=['POST'])
|
||||
@login_required
|
||||
def admin_notify_release():
|
||||
"""
|
||||
Send notifications to all users about a new release.
|
||||
Called manually by admin after deploying a new version.
|
||||
"""
|
||||
if not current_user.is_admin:
|
||||
return jsonify({'success': False, 'error': 'Brak uprawnień'}), 403
|
||||
|
||||
data = request.get_json() or {}
|
||||
version = data.get('version')
|
||||
highlights = data.get('highlights', [])
|
||||
|
||||
if not version:
|
||||
return jsonify({'success': False, 'error': 'Brak wersji'}), 400
|
||||
|
||||
from utils.notifications import notify_all_users_release
|
||||
count = notify_all_users_release(version=version, highlights=highlights)
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': f'Wysłano {count} powiadomień o wersji {version}'
|
||||
})
|
||||
|
||||
307
blueprints/admin/routes_users_api.py
Normal file
307
blueprints/admin/routes_users_api.py
Normal file
@ -0,0 +1,307 @@
|
||||
"""
|
||||
Admin Users API Routes - Admin blueprint
|
||||
|
||||
Migrated from app.py as part of the blueprint refactoring.
|
||||
Contains API routes for AI-powered user parsing and bulk user creation.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import string
|
||||
import tempfile
|
||||
|
||||
from flask import jsonify, request
|
||||
from flask_login import current_user, login_required
|
||||
from werkzeug.security import generate_password_hash
|
||||
|
||||
from database import SessionLocal, User, Company
|
||||
import gemini_service
|
||||
from . import bp
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# AI PROMPTS FOR USER PARSING
|
||||
# ============================================================
|
||||
|
||||
AI_USER_PARSE_PROMPT = """Jesteś asystentem systemu NordaBiz pomagającym administratorowi tworzyć konta użytkowników.
|
||||
|
||||
ZADANIE:
|
||||
Przeanalizuj podany tekst i wyodrębnij informacje o użytkownikach.
|
||||
|
||||
DANE WEJŚCIOWE:
|
||||
```
|
||||
{input_text}
|
||||
```
|
||||
|
||||
DOSTĘPNE FIRMY W SYSTEMIE (id: nazwa):
|
||||
{companies_json}
|
||||
|
||||
INSTRUKCJE:
|
||||
1. Wyodrębnij każdą osobę/użytkownika z tekstu
|
||||
2. Dla każdego użytkownika zidentyfikuj:
|
||||
- email (WYMAGANY - jeśli brak prawidłowego emaila, pomiń użytkownika)
|
||||
- imię i nazwisko (jeśli dostępne)
|
||||
- firma (dopasuj do listy dostępnych firm po nazwie, nawet częściowej)
|
||||
- rola: jeśli tekst zawiera słowa "admin", "administrator", "zarząd" przy danej osobie - ustaw is_admin na true
|
||||
3. Jeśli email jest niepoprawny (brak @), dodaj ostrzeżenie
|
||||
4. Jeśli firma nie pasuje do żadnej z listy, ustaw company_id na null
|
||||
|
||||
ZWRÓĆ TYLKO CZYSTY JSON w dokładnie takim formacie (bez żadnego tekstu przed ani po):
|
||||
{{
|
||||
"analysis": "Krótki opis znalezionych danych (1-2 zdania po polsku)",
|
||||
"users": [
|
||||
{{
|
||||
"email": "adres@email.pl",
|
||||
"name": "Imię Nazwisko lub null",
|
||||
"company_id": 123,
|
||||
"company_name": "Nazwa dopasowanej firmy lub null",
|
||||
"is_admin": false,
|
||||
"warnings": []
|
||||
}}
|
||||
]
|
||||
}}"""
|
||||
|
||||
AI_USER_IMAGE_PROMPT = """Jesteś asystentem systemu NordaBiz pomagającym administratorowi tworzyć konta użytkowników.
|
||||
|
||||
ZADANIE:
|
||||
Przeanalizuj ten obraz (screenshot) i wyodrębnij informacje o użytkownikach.
|
||||
Szukaj: adresów email, imion i nazwisk, nazw firm, ról (admin/user).
|
||||
|
||||
DOSTĘPNE FIRMY W SYSTEMIE (id: nazwa):
|
||||
{companies_json}
|
||||
|
||||
INSTRUKCJE:
|
||||
1. Przeczytaj cały tekst widoczny na obrazie
|
||||
2. Wyodrębnij każdą osobę/użytkownika
|
||||
3. Dla każdego użytkownika zidentyfikuj:
|
||||
- email (WYMAGANY - jeśli brak, pomiń)
|
||||
- imię i nazwisko
|
||||
- firma (dopasuj do listy)
|
||||
- rola: admin lub zwykły użytkownik
|
||||
4. Jeśli email jest nieczytelny lub niepoprawny, dodaj ostrzeżenie
|
||||
|
||||
ZWRÓĆ TYLKO CZYSTY JSON w dokładnie takim formacie (bez żadnego tekstu przed ani po):
|
||||
{{
|
||||
"analysis": "Krótki opis co widzisz na obrazie (1-2 zdania po polsku)",
|
||||
"users": [
|
||||
{{
|
||||
"email": "adres@email.pl",
|
||||
"name": "Imię Nazwisko lub null",
|
||||
"company_id": 123,
|
||||
"company_name": "Nazwa dopasowanej firmy lub null",
|
||||
"is_admin": false,
|
||||
"warnings": []
|
||||
}}
|
||||
]
|
||||
}}"""
|
||||
|
||||
|
||||
# ============================================================
|
||||
# API ROUTES
|
||||
# ============================================================
|
||||
|
||||
@bp.route('/users-api/ai-parse', methods=['POST'])
|
||||
@login_required
|
||||
def admin_users_ai_parse():
|
||||
"""Parse text or image with AI to extract user data."""
|
||||
if not current_user.is_admin:
|
||||
return jsonify({'success': False, 'error': 'Brak uprawnień'}), 403
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
# Get list of companies for AI context
|
||||
companies = db.query(Company).order_by(Company.name).all()
|
||||
companies_json = "\n".join([f"{c.id}: {c.name}" for c in companies])
|
||||
|
||||
# Check input type
|
||||
input_type = request.form.get('input_type') or (request.get_json() or {}).get('input_type', 'text')
|
||||
|
||||
if input_type == 'image':
|
||||
# Handle image upload
|
||||
if 'file' not in request.files:
|
||||
return jsonify({'success': False, 'error': 'Brak pliku obrazu'}), 400
|
||||
|
||||
file = request.files['file']
|
||||
if file.filename == '':
|
||||
return jsonify({'success': False, 'error': 'Nie wybrano pliku'}), 400
|
||||
|
||||
# Validate file type
|
||||
allowed_extensions = {'png', 'jpg', 'jpeg', 'gif', 'webp'}
|
||||
ext = file.filename.rsplit('.', 1)[-1].lower() if '.' in file.filename else ''
|
||||
if ext not in allowed_extensions:
|
||||
return jsonify({'success': False, 'error': 'Dozwolone formaty: PNG, JPG, JPEG, GIF, WEBP'}), 400
|
||||
|
||||
# Save temp file
|
||||
with tempfile.NamedTemporaryFile(delete=False, suffix=f'.{ext}') as tmp:
|
||||
file.save(tmp.name)
|
||||
temp_path = tmp.name
|
||||
|
||||
try:
|
||||
# Get Gemini service and analyze image
|
||||
service = gemini_service.get_gemini_service()
|
||||
prompt = AI_USER_IMAGE_PROMPT.format(companies_json=companies_json)
|
||||
ai_response = service.analyze_image(temp_path, prompt)
|
||||
finally:
|
||||
# Clean up temp file
|
||||
if os.path.exists(temp_path):
|
||||
os.unlink(temp_path)
|
||||
|
||||
else:
|
||||
# Handle text input
|
||||
data = request.get_json() or {}
|
||||
content = data.get('content', '').strip()
|
||||
|
||||
if not content:
|
||||
return jsonify({'success': False, 'error': 'Brak treści do analizy'}), 400
|
||||
|
||||
# Get Gemini service and analyze text
|
||||
service = gemini_service.get_gemini_service()
|
||||
prompt = AI_USER_PARSE_PROMPT.format(
|
||||
input_text=content,
|
||||
companies_json=companies_json
|
||||
)
|
||||
ai_response = service.generate_text(
|
||||
prompt=prompt,
|
||||
feature='ai_user_parse',
|
||||
user_id=current_user.id,
|
||||
temperature=0.3 # Lower temperature for more consistent JSON output
|
||||
)
|
||||
|
||||
# Parse AI response as JSON
|
||||
# Try to extract JSON from response (handle potential markdown code blocks)
|
||||
json_match = re.search(r'\{[\s\S]*\}', ai_response)
|
||||
if not json_match:
|
||||
logger.error(f"AI response not valid JSON: {ai_response[:500]}")
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'AI nie zwróciło prawidłowej odpowiedzi. Spróbuj ponownie.'
|
||||
}), 500
|
||||
|
||||
try:
|
||||
parsed = json.loads(json_match.group())
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error(f"JSON parse error: {e}, response: {ai_response[:500]}")
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'Błąd parsowania odpowiedzi AI. Spróbuj ponownie.'
|
||||
}), 500
|
||||
|
||||
# Check for duplicate emails in database
|
||||
proposed_users = parsed.get('users', [])
|
||||
existing_emails = []
|
||||
|
||||
for user in proposed_users:
|
||||
email = user.get('email', '').strip().lower()
|
||||
if email:
|
||||
existing = db.query(User).filter(User.email == email).first()
|
||||
if existing:
|
||||
existing_emails.append(email)
|
||||
user['warnings'] = user.get('warnings', []) + [f'Email już istnieje w systemie']
|
||||
|
||||
logger.info(f"Admin {current_user.email} used AI to parse users: {len(proposed_users)} found")
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'ai_response': parsed.get('analysis', 'Analiza zakończona'),
|
||||
'proposed_users': proposed_users,
|
||||
'duplicate_emails': existing_emails
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in AI user parse: {e}")
|
||||
return jsonify({'success': False, 'error': f'Błąd: {str(e)}'}), 500
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@bp.route('/users-api/bulk-create', methods=['POST'])
|
||||
@login_required
|
||||
def admin_users_bulk_create():
|
||||
"""Create multiple users from confirmed proposals."""
|
||||
if not current_user.is_admin:
|
||||
return jsonify({'success': False, 'error': 'Brak uprawnień'}), 403
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
data = request.get_json() or {}
|
||||
users_to_create = data.get('users', [])
|
||||
|
||||
if not users_to_create:
|
||||
return jsonify({'success': False, 'error': 'Brak użytkowników do utworzenia'}), 400
|
||||
|
||||
created = []
|
||||
failed = []
|
||||
|
||||
password_chars = string.ascii_letters + string.digits + "!@#$%^&*"
|
||||
|
||||
for user_data in users_to_create:
|
||||
email = user_data.get('email', '').strip().lower()
|
||||
|
||||
if not email:
|
||||
failed.append({'email': email or 'brak', 'error': 'Brak adresu email'})
|
||||
continue
|
||||
|
||||
# Check if email already exists
|
||||
existing = db.query(User).filter(User.email == email).first()
|
||||
if existing:
|
||||
failed.append({'email': email, 'error': 'Email już istnieje'})
|
||||
continue
|
||||
|
||||
# Validate company_id if provided
|
||||
company_id = user_data.get('company_id')
|
||||
if company_id:
|
||||
company = db.query(Company).filter(Company.id == company_id).first()
|
||||
if not company:
|
||||
company_id = None # Reset if company doesn't exist
|
||||
|
||||
# Generate password
|
||||
generated_password = ''.join(secrets.choice(password_chars) for _ in range(16))
|
||||
password_hash = generate_password_hash(generated_password, method='pbkdf2:sha256')
|
||||
|
||||
# Create user
|
||||
try:
|
||||
new_user = User(
|
||||
email=email,
|
||||
password_hash=password_hash,
|
||||
name=user_data.get('name', '').strip() or None,
|
||||
company_id=company_id,
|
||||
is_admin=user_data.get('is_admin', False),
|
||||
is_verified=True,
|
||||
is_active=True
|
||||
)
|
||||
db.add(new_user)
|
||||
db.flush() # Get the ID
|
||||
|
||||
created.append({
|
||||
'email': email,
|
||||
'user_id': new_user.id,
|
||||
'name': new_user.name,
|
||||
'generated_password': generated_password
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
failed.append({'email': email, 'error': str(e)})
|
||||
|
||||
# Commit all successful creates
|
||||
if created:
|
||||
db.commit()
|
||||
logger.info(f"Admin {current_user.email} bulk created {len(created)} users via AI")
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'created': created,
|
||||
'failed': failed,
|
||||
'message': f'Utworzono {len(created)} użytkowników' + (f', {len(failed)} błędów' if failed else '')
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(f"Error in bulk user create: {e}")
|
||||
return jsonify({'success': False, 'error': f'Błąd: {str(e)}'}), 500
|
||||
finally:
|
||||
db.close()
|
||||
133
blueprints/admin/routes_zopk_dashboard.py
Normal file
133
blueprints/admin/routes_zopk_dashboard.py
Normal file
@ -0,0 +1,133 @@
|
||||
"""
|
||||
ZOPK Dashboard Routes - Admin blueprint
|
||||
|
||||
Migrated from app.py as part of the blueprint refactoring.
|
||||
Contains the main ZOPK dashboard route.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from flask import flash, redirect, render_template, request, url_for
|
||||
from flask_login import current_user, login_required
|
||||
|
||||
from database import (
|
||||
SessionLocal,
|
||||
ZOPKProject,
|
||||
ZOPKStakeholder,
|
||||
ZOPKNews,
|
||||
ZOPKResource,
|
||||
ZOPKNewsFetchJob
|
||||
)
|
||||
from . import bp
|
||||
|
||||
|
||||
@bp.route('/zopk')
|
||||
@login_required
|
||||
def admin_zopk():
|
||||
"""Admin dashboard for ZOPK management"""
|
||||
if not current_user.is_admin:
|
||||
flash('Brak uprawnień do tej strony.', 'error')
|
||||
return redirect(url_for('dashboard'))
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
# Pagination and filtering parameters
|
||||
page = request.args.get('page', 1, type=int)
|
||||
per_page = 20
|
||||
status_filter = request.args.get('status', 'pending') # pending, approved, rejected, all
|
||||
min_year = request.args.get('min_year', 2024, type=int) # ZOPK started in 2024
|
||||
show_old = request.args.get('show_old', 'false') == 'true'
|
||||
|
||||
# ZOPK project started in 2024 - news from before this year are likely irrelevant
|
||||
min_date = datetime(min_year, 1, 1) if not show_old else None
|
||||
|
||||
# Stats
|
||||
stats = {
|
||||
'total_projects': db.query(ZOPKProject).count(),
|
||||
'total_stakeholders': db.query(ZOPKStakeholder).count(),
|
||||
'total_news': db.query(ZOPKNews).count(),
|
||||
'pending_news': db.query(ZOPKNews).filter(ZOPKNews.status == 'pending').count(),
|
||||
'approved_news': db.query(ZOPKNews).filter(ZOPKNews.status.in_(['approved', 'auto_approved'])).count(),
|
||||
'rejected_news': db.query(ZOPKNews).filter(ZOPKNews.status == 'rejected').count(),
|
||||
'total_resources': db.query(ZOPKResource).count(),
|
||||
# Count old news (before min_year) - likely irrelevant
|
||||
'old_news': db.query(ZOPKNews).filter(
|
||||
ZOPKNews.status == 'pending',
|
||||
ZOPKNews.published_at < datetime(min_year, 1, 1)
|
||||
).count() if not show_old else 0,
|
||||
# AI evaluation stats
|
||||
'ai_relevant': db.query(ZOPKNews).filter(ZOPKNews.ai_relevant == True).count(),
|
||||
'ai_not_relevant': db.query(ZOPKNews).filter(ZOPKNews.ai_relevant == False).count(),
|
||||
'ai_not_evaluated': db.query(ZOPKNews).filter(
|
||||
ZOPKNews.status == 'pending',
|
||||
ZOPKNews.ai_relevant.is_(None)
|
||||
).count(),
|
||||
# Items with ai_relevant but missing score (need upgrade to 1-5 stars)
|
||||
'ai_missing_score': db.query(ZOPKNews).filter(
|
||||
ZOPKNews.ai_relevant.isnot(None),
|
||||
ZOPKNews.ai_relevance_score.is_(None)
|
||||
).count()
|
||||
}
|
||||
|
||||
# Build news query with filters
|
||||
news_query = db.query(ZOPKNews)
|
||||
|
||||
# Status filter (including AI-based filters)
|
||||
if status_filter == 'pending':
|
||||
news_query = news_query.filter(ZOPKNews.status == 'pending')
|
||||
elif status_filter == 'approved':
|
||||
news_query = news_query.filter(ZOPKNews.status.in_(['approved', 'auto_approved']))
|
||||
elif status_filter == 'rejected':
|
||||
news_query = news_query.filter(ZOPKNews.status == 'rejected')
|
||||
elif status_filter == 'ai_relevant':
|
||||
# AI evaluated as relevant (regardless of status)
|
||||
news_query = news_query.filter(ZOPKNews.ai_relevant == True)
|
||||
elif status_filter == 'ai_not_relevant':
|
||||
# AI evaluated as NOT relevant
|
||||
news_query = news_query.filter(ZOPKNews.ai_relevant == False)
|
||||
elif status_filter == 'ai_not_evaluated':
|
||||
# Not yet evaluated by AI
|
||||
news_query = news_query.filter(ZOPKNews.ai_relevant.is_(None))
|
||||
# 'all' - no status filter
|
||||
|
||||
# Date filter - exclude old news by default
|
||||
if min_date and not show_old:
|
||||
news_query = news_query.filter(
|
||||
(ZOPKNews.published_at >= min_date) | (ZOPKNews.published_at.is_(None))
|
||||
)
|
||||
|
||||
# Order and count
|
||||
total_news_filtered = news_query.count()
|
||||
total_pages = (total_news_filtered + per_page - 1) // per_page
|
||||
|
||||
# Paginate
|
||||
news_items = news_query.order_by(
|
||||
ZOPKNews.published_at.desc().nullslast(),
|
||||
ZOPKNews.created_at.desc()
|
||||
).offset((page - 1) * per_page).limit(per_page).all()
|
||||
|
||||
# All projects
|
||||
projects = db.query(ZOPKProject).order_by(ZOPKProject.sort_order).all()
|
||||
|
||||
# Recent fetch jobs
|
||||
fetch_jobs = db.query(ZOPKNewsFetchJob).order_by(
|
||||
ZOPKNewsFetchJob.created_at.desc()
|
||||
).limit(5).all()
|
||||
|
||||
return render_template('admin/zopk_dashboard.html',
|
||||
stats=stats,
|
||||
news_items=news_items,
|
||||
projects=projects,
|
||||
fetch_jobs=fetch_jobs,
|
||||
# Pagination
|
||||
current_page=page,
|
||||
total_pages=total_pages,
|
||||
total_news_filtered=total_news_filtered,
|
||||
per_page=per_page,
|
||||
# Filters
|
||||
status_filter=status_filter,
|
||||
min_year=min_year,
|
||||
show_old=show_old
|
||||
)
|
||||
|
||||
finally:
|
||||
db.close()
|
||||
1085
blueprints/admin/routes_zopk_knowledge.py
Normal file
1085
blueprints/admin/routes_zopk_knowledge.py
Normal file
File diff suppressed because it is too large
Load Diff
786
blueprints/admin/routes_zopk_news.py
Normal file
786
blueprints/admin/routes_zopk_news.py
Normal file
@ -0,0 +1,786 @@
|
||||
"""
|
||||
ZOPK News Routes - Admin blueprint
|
||||
|
||||
Migrated from app.py as part of the blueprint refactoring.
|
||||
Contains routes for ZOPK news management, scraping, and AI evaluation.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from flask import flash, jsonify, redirect, render_template, request, url_for, Response, stream_with_context
|
||||
from flask_login import current_user, login_required
|
||||
from sqlalchemy import desc, asc, func, or_
|
||||
from sqlalchemy.sql import nullslast
|
||||
|
||||
from database import (
|
||||
SessionLocal,
|
||||
ZOPKProject,
|
||||
ZOPKNews,
|
||||
ZOPKNewsFetchJob
|
||||
)
|
||||
from . import bp
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@bp.route('/zopk/news')
|
||||
@login_required
|
||||
def admin_zopk_news():
|
||||
"""Admin news management for ZOPK"""
|
||||
if not current_user.is_admin:
|
||||
flash('Brak uprawnień do tej strony.', 'error')
|
||||
return redirect(url_for('dashboard'))
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
page = request.args.get('page', 1, type=int)
|
||||
status = request.args.get('status', 'all')
|
||||
stars = request.args.get('stars', 'all') # 'all', '1'-'5', 'none'
|
||||
sort_by = request.args.get('sort', 'date') # 'date', 'score', 'title'
|
||||
sort_dir = request.args.get('dir', 'desc') # 'asc', 'desc'
|
||||
per_page = 50
|
||||
|
||||
query = db.query(ZOPKNews)
|
||||
if status != 'all':
|
||||
query = query.filter(ZOPKNews.status == status)
|
||||
|
||||
# Filter by star rating
|
||||
if stars == 'none':
|
||||
query = query.filter(ZOPKNews.ai_relevance_score.is_(None))
|
||||
elif stars in ['1', '2', '3', '4', '5']:
|
||||
query = query.filter(ZOPKNews.ai_relevance_score == int(stars))
|
||||
# 'all' - no filter
|
||||
|
||||
# Apply sorting
|
||||
sort_func = desc if sort_dir == 'desc' else asc
|
||||
if sort_by == 'score':
|
||||
# Sort by AI score (nulls last so evaluated items come first)
|
||||
query = query.order_by(nullslast(sort_func(ZOPKNews.ai_relevance_score)))
|
||||
elif sort_by == 'title':
|
||||
query = query.order_by(sort_func(ZOPKNews.title))
|
||||
else: # default: date
|
||||
query = query.order_by(sort_func(ZOPKNews.published_at))
|
||||
|
||||
total = query.count()
|
||||
news_items = query.offset((page - 1) * per_page).limit(per_page).all()
|
||||
|
||||
total_pages = (total + per_page - 1) // per_page
|
||||
|
||||
projects = db.query(ZOPKProject).order_by(ZOPKProject.sort_order).all()
|
||||
|
||||
return render_template('admin/zopk_news.html',
|
||||
news_items=news_items,
|
||||
projects=projects,
|
||||
page=page,
|
||||
total_pages=total_pages,
|
||||
total=total,
|
||||
current_status=status,
|
||||
current_stars=stars,
|
||||
current_sort=sort_by,
|
||||
current_dir=sort_dir
|
||||
)
|
||||
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@bp.route('/zopk/news/<int:news_id>/approve', methods=['POST'])
|
||||
@login_required
|
||||
def admin_zopk_news_approve(news_id):
|
||||
"""Approve a ZOPK news item"""
|
||||
if not current_user.is_admin:
|
||||
return jsonify({'success': False, 'error': 'Brak uprawnień'}), 403
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
news = db.query(ZOPKNews).filter(ZOPKNews.id == news_id).first()
|
||||
if not news:
|
||||
return jsonify({'success': False, 'error': 'Nie znaleziono newsa'}), 404
|
||||
|
||||
news.status = 'approved'
|
||||
news.moderated_by = current_user.id
|
||||
news.moderated_at = datetime.now()
|
||||
db.commit()
|
||||
|
||||
return jsonify({'success': True, 'message': 'News został zatwierdzony'})
|
||||
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(f"Error approving ZOPK news {news_id}: {e}")
|
||||
return jsonify({'success': False, 'error': 'Wystąpił błąd podczas zatwierdzania'}), 500
|
||||
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@bp.route('/zopk/news/<int:news_id>/reject', methods=['POST'])
|
||||
@login_required
|
||||
def admin_zopk_news_reject(news_id):
|
||||
"""Reject a ZOPK news item"""
|
||||
if not current_user.is_admin:
|
||||
return jsonify({'success': False, 'error': 'Brak uprawnień'}), 403
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
data = request.get_json() or {}
|
||||
reason = data.get('reason', '')
|
||||
|
||||
news = db.query(ZOPKNews).filter(ZOPKNews.id == news_id).first()
|
||||
if not news:
|
||||
return jsonify({'success': False, 'error': 'Nie znaleziono newsa'}), 404
|
||||
|
||||
news.status = 'rejected'
|
||||
news.moderated_by = current_user.id
|
||||
news.moderated_at = datetime.now()
|
||||
news.rejection_reason = reason
|
||||
db.commit()
|
||||
|
||||
return jsonify({'success': True, 'message': 'News został odrzucony'})
|
||||
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(f"Error rejecting ZOPK news {news_id}: {e}")
|
||||
return jsonify({'success': False, 'error': 'Wystąpił błąd podczas odrzucania'}), 500
|
||||
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@bp.route('/zopk/news/add', methods=['POST'])
|
||||
@login_required
|
||||
def admin_zopk_news_add():
|
||||
"""Manually add a ZOPK news item"""
|
||||
if not current_user.is_admin:
|
||||
return jsonify({'success': False, 'error': 'Brak uprawnień'}), 403
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
data = request.get_json() or {}
|
||||
|
||||
title = data.get('title', '').strip()
|
||||
url = data.get('url', '').strip()
|
||||
description = data.get('description', '').strip()
|
||||
source_name = data.get('source_name', '').strip()
|
||||
project_id = data.get('project_id')
|
||||
|
||||
if not title or not url:
|
||||
return jsonify({'success': False, 'error': 'Tytuł i URL są wymagane'}), 400
|
||||
|
||||
# SECURITY: Validate URL protocol (block javascript:, data:, etc.)
|
||||
parsed = urlparse(url)
|
||||
allowed_protocols = ('http', 'https')
|
||||
if parsed.scheme.lower() not in allowed_protocols:
|
||||
return jsonify({'success': False, 'error': 'Nieprawidłowy protokół URL. Dozwolone: http, https'}), 400
|
||||
|
||||
# SECURITY: Validate project_id if provided
|
||||
if project_id:
|
||||
try:
|
||||
project_id = int(project_id)
|
||||
project = db.query(ZOPKProject).filter(ZOPKProject.id == project_id).first()
|
||||
if not project:
|
||||
return jsonify({'success': False, 'error': 'Nieprawidłowy ID projektu'}), 400
|
||||
except (ValueError, TypeError):
|
||||
return jsonify({'success': False, 'error': 'ID projektu musi być liczbą'}), 400
|
||||
else:
|
||||
project_id = None
|
||||
|
||||
# Generate URL hash for deduplication
|
||||
url_hash = hashlib.sha256(url.encode()).hexdigest()
|
||||
|
||||
# Check if URL already exists
|
||||
existing = db.query(ZOPKNews).filter(ZOPKNews.url_hash == url_hash).first()
|
||||
if existing:
|
||||
return jsonify({'success': False, 'error': 'Ten artykuł już istnieje w bazie'}), 400
|
||||
|
||||
# Extract domain from URL
|
||||
source_domain = parsed.netloc.replace('www.', '')
|
||||
|
||||
news = ZOPKNews(
|
||||
title=title,
|
||||
url=url,
|
||||
url_hash=url_hash,
|
||||
description=description,
|
||||
source_name=source_name or source_domain,
|
||||
source_domain=source_domain,
|
||||
source_type='manual',
|
||||
status='approved', # Manual entries are auto-approved
|
||||
moderated_by=current_user.id,
|
||||
moderated_at=datetime.now(),
|
||||
published_at=datetime.now(),
|
||||
project_id=project_id
|
||||
)
|
||||
db.add(news)
|
||||
db.commit()
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': 'News został dodany',
|
||||
'news_id': news.id
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(f"Error adding ZOPK news: {e}")
|
||||
return jsonify({'success': False, 'error': 'Wystąpił błąd podczas dodawania newsa'}), 500
|
||||
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@bp.route('/zopk/news/reject-old', methods=['POST'])
|
||||
@login_required
|
||||
def admin_zopk_reject_old_news():
|
||||
"""Reject all news from before a certain year (ZOPK didn't exist then)"""
|
||||
if not current_user.is_admin:
|
||||
return jsonify({'success': False, 'error': 'Brak uprawnień'}), 403
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
data = request.get_json() or {}
|
||||
min_year = data.get('min_year', 2024)
|
||||
|
||||
# Find all pending news from before min_year
|
||||
min_date = datetime(min_year, 1, 1)
|
||||
old_news = db.query(ZOPKNews).filter(
|
||||
ZOPKNews.status == 'pending',
|
||||
ZOPKNews.published_at < min_date
|
||||
).all()
|
||||
|
||||
count = len(old_news)
|
||||
|
||||
# Reject them all
|
||||
for news in old_news:
|
||||
news.status = 'rejected'
|
||||
news.moderated_by = current_user.id
|
||||
news.moderated_at = datetime.now()
|
||||
news.rejection_reason = f'Automatycznie odrzucony - artykuł sprzed {min_year} roku (ZOP Kaszubia powstał w 2024)'
|
||||
|
||||
db.commit()
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': f'Odrzucono {count} newsów sprzed {min_year} roku',
|
||||
'count': count
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(f"Error rejecting old ZOPK news: {e}")
|
||||
return jsonify({'success': False, 'error': 'Wystąpił błąd podczas odrzucania starych newsów'}), 500
|
||||
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@bp.route('/zopk/news/star-counts')
|
||||
@login_required
|
||||
def admin_zopk_news_star_counts():
|
||||
"""Get counts of pending news items grouped by star rating"""
|
||||
if not current_user.is_admin:
|
||||
return jsonify({'success': False, 'error': 'Brak uprawnień'}), 403
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
# Count pending news for each star rating (1-5 and NULL)
|
||||
counts = {}
|
||||
|
||||
# Count for each star 1-5
|
||||
for star in range(1, 6):
|
||||
count = db.query(func.count(ZOPKNews.id)).filter(
|
||||
ZOPKNews.status == 'pending',
|
||||
ZOPKNews.ai_relevance_score == star
|
||||
).scalar()
|
||||
counts[star] = count
|
||||
|
||||
# Count for NULL (no AI evaluation)
|
||||
count_null = db.query(func.count(ZOPKNews.id)).filter(
|
||||
ZOPKNews.status == 'pending',
|
||||
ZOPKNews.ai_relevance_score.is_(None)
|
||||
).scalar()
|
||||
counts[0] = count_null
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'counts': counts
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting ZOPK news star counts: {e}")
|
||||
return jsonify({'success': False, 'error': 'Wystąpił błąd'}), 500
|
||||
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@bp.route('/zopk/news/reject-by-stars', methods=['POST'])
|
||||
@login_required
|
||||
def admin_zopk_reject_by_stars():
|
||||
"""Reject all pending news items with specified star ratings"""
|
||||
if not current_user.is_admin:
|
||||
return jsonify({'success': False, 'error': 'Brak uprawnień'}), 403
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
data = request.get_json() or {}
|
||||
stars = data.get('stars', []) # List of star ratings to reject (0 = no rating)
|
||||
reason = data.get('reason', '')
|
||||
|
||||
if not stars:
|
||||
return jsonify({'success': False, 'error': 'Nie wybrano ocen do odrzucenia'}), 400
|
||||
|
||||
# Validate stars input
|
||||
valid_stars = [s for s in stars if s in [0, 1, 2, 3, 4, 5]]
|
||||
if not valid_stars:
|
||||
return jsonify({'success': False, 'error': 'Nieprawidłowe oceny gwiazdkowe'}), 400
|
||||
|
||||
# Build query for pending news with specified stars
|
||||
conditions = []
|
||||
for star in valid_stars:
|
||||
if star == 0:
|
||||
conditions.append(ZOPKNews.ai_relevance_score.is_(None))
|
||||
else:
|
||||
conditions.append(ZOPKNews.ai_relevance_score == star)
|
||||
|
||||
news_to_reject = db.query(ZOPKNews).filter(
|
||||
ZOPKNews.status == 'pending',
|
||||
or_(*conditions)
|
||||
).all()
|
||||
|
||||
count = len(news_to_reject)
|
||||
|
||||
# Reject them all
|
||||
default_reason = f"Masowo odrzucone - oceny: {', '.join(str(s) + '★' if s > 0 else 'brak oceny' for s in valid_stars)}"
|
||||
final_reason = reason if reason else default_reason
|
||||
|
||||
for news in news_to_reject:
|
||||
news.status = 'rejected'
|
||||
news.moderated_by = current_user.id
|
||||
news.moderated_at = datetime.now()
|
||||
news.rejection_reason = final_reason
|
||||
|
||||
db.commit()
|
||||
|
||||
logger.info(f"Admin {current_user.email} rejected {count} ZOPK news with stars {valid_stars}")
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': f'Odrzucono {count} artykułów',
|
||||
'count': count
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(f"Error rejecting ZOPK news by stars: {e}")
|
||||
return jsonify({'success': False, 'error': 'Wystąpił błąd podczas odrzucania'}), 500
|
||||
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@bp.route('/zopk/news/evaluate-ai', methods=['POST'])
|
||||
@login_required
|
||||
def admin_zopk_evaluate_ai():
|
||||
"""Evaluate pending news for ZOPK relevance using Gemini AI"""
|
||||
if not current_user.is_admin:
|
||||
return jsonify({'success': False, 'error': 'Brak uprawnień'}), 403
|
||||
|
||||
from zopk_news_service import evaluate_pending_news
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
data = request.get_json() or {}
|
||||
limit = data.get('limit', 50) # Max 50 to avoid API limits
|
||||
|
||||
# Run AI evaluation
|
||||
result = evaluate_pending_news(db, limit=limit, user_id=current_user.id)
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'total_evaluated': result.get('total_evaluated', 0),
|
||||
'relevant_count': result.get('relevant_count', 0),
|
||||
'not_relevant_count': result.get('not_relevant_count', 0),
|
||||
'errors': result.get('errors', 0),
|
||||
'message': result.get('message', '')
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(f"Error evaluating ZOPK news with AI: {e}")
|
||||
return jsonify({'success': False, 'error': 'Wystąpił błąd podczas oceny AI'}), 500
|
||||
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@bp.route('/zopk/news/reevaluate-scores', methods=['POST'])
|
||||
@login_required
|
||||
def admin_zopk_reevaluate_scores():
|
||||
"""Re-evaluate news items that have ai_relevant but no ai_relevance_score (1-5 stars)"""
|
||||
if not current_user.is_admin:
|
||||
return jsonify({'success': False, 'error': 'Brak uprawnień'}), 403
|
||||
|
||||
from zopk_news_service import reevaluate_news_without_score
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
data = request.get_json() or {}
|
||||
limit = data.get('limit', 50) # Max 50 to avoid API limits
|
||||
|
||||
# Run AI re-evaluation for items missing scores
|
||||
result = reevaluate_news_without_score(db, limit=limit, user_id=current_user.id)
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'total_evaluated': result.get('total_evaluated', 0),
|
||||
'relevant_count': result.get('relevant_count', 0),
|
||||
'not_relevant_count': result.get('not_relevant_count', 0),
|
||||
'errors': result.get('errors', 0),
|
||||
'message': result.get('message', '')
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(f"Error reevaluating ZOPK news scores: {e}")
|
||||
return jsonify({'success': False, 'error': 'Wystąpił błąd podczas ponownej oceny'}), 500
|
||||
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@bp.route('/zopk/news/reevaluate-low-scores', methods=['POST'])
|
||||
@login_required
|
||||
def admin_zopk_reevaluate_low_scores():
|
||||
"""
|
||||
Re-evaluate news with low AI scores (1-2★) that contain key ZOPK topics.
|
||||
|
||||
Useful after updating AI prompt to include new topics like Via Pomerania, S6, NORDA.
|
||||
Old articles scored low before these topics were recognized will be re-evaluated
|
||||
and potentially upgraded.
|
||||
"""
|
||||
if not current_user.is_admin:
|
||||
return jsonify({'success': False, 'error': 'Brak uprawnień'}), 403
|
||||
|
||||
from zopk_news_service import reevaluate_low_score_news
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
data = request.get_json() or {}
|
||||
limit = data.get('limit', 50) # Max 50 to avoid API limits
|
||||
|
||||
# Run AI re-evaluation for low-score items with key topics
|
||||
result = reevaluate_low_score_news(db, limit=limit, user_id=current_user.id)
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'total_evaluated': result.get('total_evaluated', 0),
|
||||
'upgraded': result.get('upgraded', 0),
|
||||
'downgraded': result.get('downgraded', 0),
|
||||
'unchanged': result.get('unchanged', 0),
|
||||
'errors': result.get('errors', 0),
|
||||
'message': result.get('message', ''),
|
||||
'details': result.get('details', [])
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(f"Error reevaluating low-score ZOPK news: {e}")
|
||||
return jsonify({'success': False, 'error': 'Wystąpił błąd podczas ponownej oceny'}), 500
|
||||
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@bp.route('/zopk-api/search-news', methods=['POST'])
|
||||
@login_required
|
||||
def api_zopk_search_news():
|
||||
"""
|
||||
Search for ZOPK news using multiple sources with cross-verification.
|
||||
|
||||
Sources:
|
||||
- Brave Search API
|
||||
- Google News RSS
|
||||
- Local media RSS (trojmiasto.pl, dziennikbaltycki.pl)
|
||||
|
||||
Cross-verification:
|
||||
- 1 source → pending (manual review)
|
||||
- 3+ sources → auto_approved
|
||||
"""
|
||||
if not current_user.is_admin:
|
||||
return jsonify({'success': False, 'error': 'Brak uprawnień'}), 403
|
||||
|
||||
from zopk_news_service import ZOPKNewsService
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
data = request.get_json() or {}
|
||||
query = data.get('query', 'Zielony Okręg Przemysłowy Kaszubia')
|
||||
|
||||
# Create fetch job record
|
||||
job_id = str(uuid.uuid4())[:8]
|
||||
fetch_job = ZOPKNewsFetchJob(
|
||||
job_id=job_id,
|
||||
search_query=query,
|
||||
search_api='multi_source', # Brave + RSS
|
||||
triggered_by='admin',
|
||||
triggered_by_user=current_user.id,
|
||||
status='running',
|
||||
started_at=datetime.now()
|
||||
)
|
||||
db.add(fetch_job)
|
||||
db.commit()
|
||||
|
||||
# Use multi-source service
|
||||
service = ZOPKNewsService(db)
|
||||
results = service.search_all_sources(query)
|
||||
|
||||
# Update fetch job
|
||||
fetch_job.results_found = results['total_found']
|
||||
fetch_job.results_new = results['saved_new']
|
||||
fetch_job.results_approved = results['auto_approved']
|
||||
fetch_job.status = 'completed'
|
||||
fetch_job.completed_at = datetime.now()
|
||||
db.commit()
|
||||
|
||||
# Build detailed message
|
||||
source_info = ', '.join(f"{k}: {v}" for k, v in results['source_stats'].items() if v > 0)
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': f"Znaleziono {results['total_found']} wyników z {len(results['source_stats'])} źródeł. "
|
||||
f"Dodano {results['saved_new']} nowych, zaktualizowano {results['updated_existing']}. "
|
||||
f"Auto-zatwierdzono: {results['auto_approved']}",
|
||||
'job_id': job_id,
|
||||
'total_found': results['total_found'],
|
||||
'unique_items': results['unique_items'],
|
||||
'saved_new': results['saved_new'],
|
||||
'updated_existing': results['updated_existing'],
|
||||
'auto_approved': results['auto_approved'],
|
||||
'ai_approved': results.get('ai_approved', 0),
|
||||
'ai_rejected': results.get('ai_rejected', 0),
|
||||
'blacklisted': results.get('blacklisted', 0),
|
||||
'keyword_filtered': results.get('keyword_filtered', 0),
|
||||
'sent_to_ai': results.get('sent_to_ai', 0),
|
||||
'duplicates': results.get('duplicates', 0),
|
||||
'processing_time': results.get('processing_time', 0),
|
||||
'knowledge_entities_created': results.get('knowledge_entities_created', 0),
|
||||
'source_stats': results['source_stats'],
|
||||
'process_log': results.get('process_log', []),
|
||||
'auto_approved_articles': results.get('auto_approved_articles', []),
|
||||
'ai_rejected_articles': results.get('ai_rejected_articles', [])
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(f"ZOPK news search error: {e}")
|
||||
|
||||
# Update job status on error
|
||||
try:
|
||||
fetch_job.status = 'failed'
|
||||
fetch_job.error_message = str(e) # Keep internal log
|
||||
fetch_job.completed_at = datetime.now()
|
||||
db.commit()
|
||||
except:
|
||||
pass
|
||||
|
||||
return jsonify({'success': False, 'error': 'Wystąpił błąd podczas wyszukiwania newsów'}), 500
|
||||
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@bp.route('/zopk/news/scrape-stats')
|
||||
@login_required
|
||||
def admin_zopk_scrape_stats():
|
||||
"""
|
||||
Get content scraping statistics.
|
||||
|
||||
Returns JSON with:
|
||||
- total_approved: Total approved/auto_approved articles
|
||||
- scraped: Successfully scraped articles
|
||||
- pending: Articles waiting to be scraped
|
||||
- failed: Failed scraping attempts
|
||||
- skipped: Skipped (social media, paywalls)
|
||||
- ready_for_extraction: Scraped but not yet processed for knowledge
|
||||
"""
|
||||
if not current_user.is_admin:
|
||||
return jsonify({'success': False, 'error': 'Brak uprawnień'}), 403
|
||||
|
||||
from zopk_content_scraper import get_scrape_stats
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
stats = get_scrape_stats(db)
|
||||
return jsonify({
|
||||
'success': True,
|
||||
**stats
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting scrape stats: {e}")
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@bp.route('/zopk/news/scrape-content', methods=['POST'])
|
||||
@login_required
|
||||
def admin_zopk_scrape_content():
|
||||
"""
|
||||
Batch scrape article content from source URLs.
|
||||
|
||||
Request JSON:
|
||||
- limit: int (default 50) - max articles to scrape
|
||||
- force: bool (default false) - re-scrape already scraped
|
||||
|
||||
Response:
|
||||
- scraped: number of successfully scraped
|
||||
- failed: number of failures
|
||||
- skipped: number of skipped (social media, etc.)
|
||||
- errors: list of error details
|
||||
- scraped_articles: list of scraped article info
|
||||
"""
|
||||
if not current_user.is_admin:
|
||||
return jsonify({'success': False, 'error': 'Brak uprawnień'}), 403
|
||||
|
||||
from zopk_content_scraper import ZOPKContentScraper
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
data = request.get_json() or {}
|
||||
limit = min(data.get('limit', 50), 100) # Max 100 at once
|
||||
force = data.get('force', False)
|
||||
|
||||
scraper = ZOPKContentScraper(db, user_id=current_user.id)
|
||||
result = scraper.batch_scrape(limit=limit, force=force)
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': f"Scraping zakończony: {result['scraped']} pobrano, "
|
||||
f"{result['failed']} błędów, {result['skipped']} pominięto",
|
||||
**result
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(f"Error in batch scrape: {e}")
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@bp.route('/zopk/news/<int:news_id>/scrape', methods=['POST'])
|
||||
@login_required
|
||||
def admin_zopk_scrape_single(news_id):
|
||||
"""
|
||||
Scrape content for a single article.
|
||||
"""
|
||||
if not current_user.is_admin:
|
||||
return jsonify({'success': False, 'error': 'Brak uprawnień'}), 403
|
||||
|
||||
from zopk_content_scraper import ZOPKContentScraper
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
scraper = ZOPKContentScraper(db, user_id=current_user.id)
|
||||
result = scraper.scrape_article(news_id)
|
||||
|
||||
if result.success:
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': f"Pobrano treść: {result.word_count} słów",
|
||||
'word_count': result.word_count,
|
||||
'status': result.status
|
||||
})
|
||||
else:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': result.error,
|
||||
'status': result.status
|
||||
}), 400
|
||||
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(f"Error scraping article {news_id}: {e}")
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@bp.route('/zopk/news/scrape-content/stream', methods=['GET'])
|
||||
@login_required
|
||||
def admin_zopk_news_scrape_stream():
|
||||
"""
|
||||
Stream scraping progress using Server-Sent Events.
|
||||
|
||||
Query params:
|
||||
- limit: int (default 50)
|
||||
- force: bool (default false)
|
||||
"""
|
||||
if not current_user.is_admin:
|
||||
return jsonify({'success': False, 'error': 'Brak uprawnień'}), 403
|
||||
|
||||
from zopk_content_scraper import ZOPKContentScraper, MAX_RETRY_ATTEMPTS
|
||||
|
||||
limit = request.args.get('limit', 50, type=int)
|
||||
force = request.args.get('force', 'false').lower() == 'true'
|
||||
|
||||
def generate():
|
||||
import json
|
||||
db = SessionLocal()
|
||||
try:
|
||||
scraper = ZOPKContentScraper(db, user_id=current_user.id)
|
||||
|
||||
# Get articles to scrape
|
||||
query = db.query(ZOPKNews).filter(
|
||||
ZOPKNews.status.in_(['approved', 'auto_approved'])
|
||||
)
|
||||
|
||||
if not force:
|
||||
query = query.filter(
|
||||
ZOPKNews.content_scraped == False,
|
||||
ZOPKNews.scrape_attempts < MAX_RETRY_ATTEMPTS
|
||||
)
|
||||
|
||||
articles = query.order_by(ZOPKNews.published_at.desc()).limit(limit).all()
|
||||
total = len(articles)
|
||||
|
||||
yield f"data: {json.dumps({'type': 'start', 'total': total})}\n\n"
|
||||
|
||||
scraped = 0
|
||||
failed = 0
|
||||
skipped = 0
|
||||
|
||||
for i, article in enumerate(articles):
|
||||
result = scraper.scrape_article(article.id)
|
||||
|
||||
if result.success:
|
||||
scraped += 1
|
||||
status = 'success'
|
||||
elif result.status == 'skipped':
|
||||
skipped += 1
|
||||
status = 'skipped'
|
||||
else:
|
||||
failed += 1
|
||||
status = 'failed'
|
||||
|
||||
yield f"data: {json.dumps({'type': 'progress', 'current': i + 1, 'total': total, 'article': article.title[:50], 'status': status, 'scraped': scraped, 'failed': failed, 'skipped': skipped})}\n\n"
|
||||
|
||||
yield f"data: {json.dumps({'type': 'complete', 'scraped': scraped, 'failed': failed, 'skipped': skipped})}\n\n"
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in scrape stream: {e}")
|
||||
yield f"data: {json.dumps({'type': 'error', 'error': str(e)})}\n\n"
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
return Response(
|
||||
stream_with_context(generate()),
|
||||
mimetype='text/event-stream',
|
||||
headers={
|
||||
'Cache-Control': 'no-cache',
|
||||
'X-Accel-Buffering': 'no'
|
||||
}
|
||||
)
|
||||
212
blueprints/admin/routes_zopk_timeline.py
Normal file
212
blueprints/admin/routes_zopk_timeline.py
Normal file
@ -0,0 +1,212 @@
|
||||
"""
|
||||
ZOPK Timeline Routes - Admin blueprint
|
||||
|
||||
Migrated from app.py as part of the blueprint refactoring.
|
||||
Contains routes for ZOPK timeline and milestones management.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
from flask import flash, jsonify, redirect, render_template, request, url_for
|
||||
from flask_login import current_user, login_required
|
||||
|
||||
from database import (
|
||||
SessionLocal,
|
||||
ZOPKMilestone
|
||||
)
|
||||
from . import bp
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@bp.route('/zopk/timeline')
|
||||
@login_required
|
||||
def admin_zopk_timeline():
|
||||
"""Panel Timeline ZOPK."""
|
||||
if not current_user.is_admin:
|
||||
flash('Brak uprawnień.', 'error')
|
||||
return redirect(url_for('dashboard'))
|
||||
return render_template('admin/zopk_timeline.html')
|
||||
|
||||
|
||||
@bp.route('/zopk-api/milestones')
|
||||
@login_required
|
||||
def api_zopk_milestones():
|
||||
"""API - lista kamieni milowych ZOPK."""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
milestones = db.query(ZOPKMilestone).order_by(ZOPKMilestone.target_date).all()
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'milestones': [{
|
||||
'id': m.id,
|
||||
'title': m.title,
|
||||
'description': m.description,
|
||||
'category': m.category,
|
||||
'target_date': m.target_date.isoformat() if m.target_date else None,
|
||||
'actual_date': m.actual_date.isoformat() if m.actual_date else None,
|
||||
'status': m.status,
|
||||
'source_url': m.source_url
|
||||
} for m in milestones]
|
||||
})
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@bp.route('/zopk-api/milestones', methods=['POST'])
|
||||
@login_required
|
||||
def api_zopk_milestone_create():
|
||||
"""API - utworzenie kamienia milowego."""
|
||||
if not current_user.is_admin:
|
||||
return jsonify({'error': 'Forbidden'}), 403
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
data = request.get_json()
|
||||
milestone = ZOPKMilestone(
|
||||
title=data['title'],
|
||||
description=data.get('description'),
|
||||
category=data.get('category', 'other'),
|
||||
target_date=datetime.strptime(data['target_date'], '%Y-%m-%d').date() if data.get('target_date') else None,
|
||||
actual_date=datetime.strptime(data['actual_date'], '%Y-%m-%d').date() if data.get('actual_date') else None,
|
||||
status=data.get('status', 'planned'),
|
||||
source_url=data.get('source_url'),
|
||||
source_news_id=data.get('source_news_id')
|
||||
)
|
||||
db.add(milestone)
|
||||
db.commit()
|
||||
return jsonify({'success': True, 'id': milestone.id})
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@bp.route('/zopk-api/milestones/<int:milestone_id>', methods=['PUT'])
|
||||
@login_required
|
||||
def api_zopk_milestone_update(milestone_id):
|
||||
"""API - aktualizacja kamienia milowego."""
|
||||
if not current_user.is_admin:
|
||||
return jsonify({'error': 'Forbidden'}), 403
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
milestone = db.query(ZOPKMilestone).get(milestone_id)
|
||||
if not milestone:
|
||||
return jsonify({'error': 'Not found'}), 404
|
||||
|
||||
data = request.get_json()
|
||||
if 'title' in data:
|
||||
milestone.title = data['title']
|
||||
if 'description' in data:
|
||||
milestone.description = data['description']
|
||||
if 'category' in data:
|
||||
milestone.category = data['category']
|
||||
if 'target_date' in data:
|
||||
milestone.target_date = datetime.strptime(data['target_date'], '%Y-%m-%d').date() if data['target_date'] else None
|
||||
if 'actual_date' in data:
|
||||
milestone.actual_date = datetime.strptime(data['actual_date'], '%Y-%m-%d').date() if data['actual_date'] else None
|
||||
if 'status' in data:
|
||||
milestone.status = data['status']
|
||||
if 'source_url' in data:
|
||||
milestone.source_url = data['source_url']
|
||||
|
||||
db.commit()
|
||||
return jsonify({'success': True})
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@bp.route('/zopk-api/milestones/<int:milestone_id>', methods=['DELETE'])
|
||||
@login_required
|
||||
def api_zopk_milestone_delete(milestone_id):
|
||||
"""API - usunięcie kamienia milowego."""
|
||||
if not current_user.is_admin:
|
||||
return jsonify({'error': 'Forbidden'}), 403
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
milestone = db.query(ZOPKMilestone).get(milestone_id)
|
||||
if not milestone:
|
||||
return jsonify({'error': 'Not found'}), 404
|
||||
|
||||
db.delete(milestone)
|
||||
db.commit()
|
||||
return jsonify({'success': True})
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@bp.route('/zopk-api/timeline/suggestions')
|
||||
@login_required
|
||||
def api_zopk_timeline_suggestions():
|
||||
"""API - sugestie kamieni milowych z bazy wiedzy."""
|
||||
if not current_user.is_admin:
|
||||
return jsonify({'error': 'Forbidden'}), 403
|
||||
|
||||
from zopk_knowledge_service import get_timeline_suggestions
|
||||
|
||||
limit = request.args.get('limit', 30, type=int)
|
||||
only_verified = request.args.get('only_verified', 'false').lower() == 'true'
|
||||
use_ai = request.args.get('use_ai', 'false').lower() == 'true'
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
result = get_timeline_suggestions(db, limit=limit, only_verified=only_verified)
|
||||
|
||||
if result['success'] and use_ai and result.get('suggestions'):
|
||||
from zopk_knowledge_service import categorize_milestones_with_ai
|
||||
result['suggestions'] = categorize_milestones_with_ai(db, result['suggestions'])
|
||||
|
||||
return jsonify(result)
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@bp.route('/zopk-api/timeline/suggestions/approve', methods=['POST'])
|
||||
@login_required
|
||||
def api_zopk_timeline_suggestion_approve():
|
||||
"""API - zatwierdzenie sugestii i utworzenie kamienia milowego."""
|
||||
if not current_user.is_admin:
|
||||
return jsonify({'error': 'Forbidden'}), 403
|
||||
|
||||
from zopk_knowledge_service import create_milestone_from_suggestion
|
||||
|
||||
data = request.get_json()
|
||||
if not data:
|
||||
return jsonify({'error': 'No data provided'}), 400
|
||||
|
||||
fact_id = data.get('fact_id')
|
||||
if not fact_id:
|
||||
return jsonify({'error': 'fact_id is required'}), 400
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
result = create_milestone_from_suggestion(
|
||||
db_session=db,
|
||||
fact_id=fact_id,
|
||||
title=data.get('title', 'Kamień milowy'),
|
||||
description=data.get('description'),
|
||||
category=data.get('category', 'other'),
|
||||
target_date=data.get('target_date'),
|
||||
status=data.get('status', 'planned'),
|
||||
source_url=data.get('source_url')
|
||||
)
|
||||
return jsonify(result)
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
finally:
|
||||
db.close()
|
||||
Loading…
Reference in New Issue
Block a user