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:
Maciej Pienczyn 2026-01-31 17:05:36 +01:00
parent 351c8fba75
commit 49830855a2
8 changed files with 2568 additions and 2588 deletions

2597
app.py

File diff suppressed because it is too large Load Diff

View File

@ -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

View File

@ -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}'
})

View 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()

View 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()

File diff suppressed because it is too large Load Diff

View 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'
}
)

View 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()