Added POST /api/seo/audit endpoint to trigger SEO audits for companies: - Admin-only access with current_user.is_admin check - Rate limited to 10 requests per hour per user - Accepts company_id or slug in JSON body - Runs full SEO audit (PageSpeed, on-page, technical) - Saves results to database and returns audit data - Comprehensive error handling and logging - Uses existing _build_seo_audit_response helper for response format 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
3873 lines
128 KiB
Python
3873 lines
128 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Norda Biznes Hub - Flask Application
|
|
====================================
|
|
|
|
Main Flask application for Norda Biznes company directory with AI chat.
|
|
|
|
Features:
|
|
- User authentication with email confirmation
|
|
- Company directory with advanced search
|
|
- AI chat assistant powered by Google Gemini
|
|
- PostgreSQL database integration
|
|
- Analytics dashboard for chat insights
|
|
|
|
Author: Norda Biznes Development Team
|
|
Created: 2025-11-23
|
|
"""
|
|
|
|
import os
|
|
import logging
|
|
import secrets
|
|
import re
|
|
import json
|
|
from collections import deque
|
|
from datetime import datetime, timedelta
|
|
from flask import Flask, render_template, request, jsonify, redirect, url_for, flash, session, Response
|
|
from flask_login import LoginManager, login_user, logout_user, login_required, current_user
|
|
from flask_wtf.csrf import CSRFProtect
|
|
from flask_limiter import Limiter
|
|
from flask_limiter.util import get_remote_address
|
|
from werkzeug.security import generate_password_hash, check_password_hash
|
|
from dotenv import load_dotenv
|
|
|
|
# Load environment variables (override any existing env vars)
|
|
# Try .env first, then nordabiz_config.txt for production flexibility
|
|
import os
|
|
if os.path.exists('.env'):
|
|
load_dotenv('.env', override=True)
|
|
elif os.path.exists('nordabiz_config.txt'):
|
|
load_dotenv('nordabiz_config.txt', override=True)
|
|
else:
|
|
load_dotenv(override=True)
|
|
|
|
# Configure logging with in-memory buffer for debug panel
|
|
class DebugLogHandler(logging.Handler):
|
|
"""Custom handler that stores logs in memory for real-time viewing"""
|
|
def __init__(self, max_logs=500):
|
|
super().__init__()
|
|
self.logs = deque(maxlen=max_logs)
|
|
|
|
def emit(self, record):
|
|
log_entry = {
|
|
'timestamp': datetime.now().isoformat(),
|
|
'level': record.levelname,
|
|
'logger': record.name,
|
|
'message': self.format(record),
|
|
'module': record.module,
|
|
'funcName': record.funcName,
|
|
'lineno': record.lineno
|
|
}
|
|
self.logs.append(log_entry)
|
|
|
|
# Create debug handler
|
|
debug_handler = DebugLogHandler(max_logs=500)
|
|
debug_handler.setFormatter(logging.Formatter('%(message)s'))
|
|
|
|
logging.basicConfig(
|
|
level=logging.INFO,
|
|
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
|
)
|
|
|
|
# Add debug handler to root logger
|
|
logging.getLogger().addHandler(debug_handler)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Import database models
|
|
from database import (
|
|
init_db,
|
|
SessionLocal,
|
|
User,
|
|
Company,
|
|
Category,
|
|
Service,
|
|
Competency,
|
|
CompanyDigitalMaturity,
|
|
CompanyWebsiteAnalysis,
|
|
CompanyQualityTracking,
|
|
CompanyWebsiteContent,
|
|
CompanyAIInsights,
|
|
CompanyEvent,
|
|
CompanySocialMedia,
|
|
CompanyContact,
|
|
AIChatConversation,
|
|
AIChatMessage,
|
|
AIChatFeedback,
|
|
AIAPICostLog,
|
|
ForumTopic,
|
|
ForumReply,
|
|
NordaEvent,
|
|
EventAttendee,
|
|
PrivateMessage,
|
|
Classified,
|
|
UserNotification,
|
|
MembershipFee,
|
|
MembershipFeeConfig,
|
|
Announcement
|
|
)
|
|
|
|
# Import services
|
|
import gemini_service
|
|
from nordabiz_chat import NordaBizChatEngine
|
|
from search_service import search_companies
|
|
import krs_api_service
|
|
|
|
# News service for fetching company news
|
|
try:
|
|
from news_service import NewsService, get_news_service, init_news_service
|
|
NEWS_SERVICE_AVAILABLE = True
|
|
except ImportError:
|
|
NEWS_SERVICE_AVAILABLE = False
|
|
logger.warning("News service not available")
|
|
|
|
# SEO audit components for triggering audits via API
|
|
import sys
|
|
_scripts_path = os.path.join(os.path.dirname(__file__), 'scripts')
|
|
if _scripts_path not in sys.path:
|
|
sys.path.insert(0, _scripts_path)
|
|
|
|
try:
|
|
from seo_audit import SEOAuditor, SEO_AUDIT_VERSION
|
|
SEO_AUDIT_AVAILABLE = True
|
|
except ImportError as e:
|
|
SEO_AUDIT_AVAILABLE = False
|
|
logger.warning(f"SEO audit service not available: {e}")
|
|
|
|
# Initialize Flask app
|
|
app = Flask(__name__)
|
|
app.config['SECRET_KEY'] = os.getenv('SECRET_KEY', 'dev-secret-key-change-in-production')
|
|
app.config['PERMANENT_SESSION_LIFETIME'] = timedelta(days=7)
|
|
|
|
# Security configurations
|
|
app.config['WTF_CSRF_ENABLED'] = True
|
|
app.config['WTF_CSRF_TIME_LIMIT'] = None # No time limit for CSRF tokens
|
|
app.config['SESSION_COOKIE_SECURE'] = os.getenv('FLASK_ENV') != 'development' # HTTPS only in production
|
|
app.config['SESSION_COOKIE_HTTPONLY'] = True
|
|
app.config['SESSION_COOKIE_SAMESITE'] = 'Lax'
|
|
|
|
# Template filters
|
|
@app.template_filter('ensure_url')
|
|
def ensure_url_filter(url):
|
|
"""Ensure URL has http:// or https:// scheme"""
|
|
if url and not url.startswith(('http://', 'https://')):
|
|
return f'https://{url}'
|
|
return url
|
|
|
|
# Initialize CSRF protection
|
|
csrf = CSRFProtect(app)
|
|
|
|
# Initialize rate limiter
|
|
limiter = Limiter(
|
|
app=app,
|
|
key_func=get_remote_address,
|
|
default_limits=["200 per day", "50 per hour"],
|
|
storage_uri="memory://"
|
|
)
|
|
|
|
# Initialize database
|
|
init_db()
|
|
|
|
# Initialize Login Manager
|
|
login_manager = LoginManager()
|
|
login_manager.init_app(app)
|
|
login_manager.login_view = 'login'
|
|
login_manager.login_message = 'Zaloguj się, aby uzyskać dostęp do tej strony.'
|
|
|
|
# Initialize Gemini service
|
|
try:
|
|
gemini_service.init_gemini_service(model='flash-2.0') # Gemini 2.0 Flash (DARMOWY w preview)
|
|
logger.info("Gemini service initialized successfully")
|
|
except Exception as e:
|
|
logger.error(f"Failed to initialize Gemini service: {e}")
|
|
|
|
@login_manager.user_loader
|
|
def load_user(user_id):
|
|
"""Load user from database"""
|
|
db = SessionLocal()
|
|
try:
|
|
return db.query(User).filter_by(id=int(user_id)).first()
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
# ============================================================
|
|
# TEMPLATE CONTEXT PROCESSORS
|
|
# ============================================================
|
|
|
|
@app.context_processor
|
|
def inject_globals():
|
|
"""Inject global variables into all templates"""
|
|
return {
|
|
'current_year': datetime.now().year
|
|
}
|
|
|
|
|
|
@app.context_processor
|
|
def inject_notifications():
|
|
"""Inject unread notifications count into all templates"""
|
|
if current_user.is_authenticated:
|
|
db = SessionLocal()
|
|
try:
|
|
unread_count = db.query(UserNotification).filter(
|
|
UserNotification.user_id == current_user.id,
|
|
UserNotification.is_read == False
|
|
).count()
|
|
return {'unread_notifications_count': unread_count}
|
|
finally:
|
|
db.close()
|
|
return {'unread_notifications_count': 0}
|
|
|
|
|
|
# ============================================================
|
|
# NOTIFICATION HELPERS
|
|
# ============================================================
|
|
|
|
def create_notification(user_id, title, message, notification_type='info',
|
|
related_type=None, related_id=None, action_url=None):
|
|
"""
|
|
Create a notification for a user.
|
|
|
|
Args:
|
|
user_id: ID of the user to notify
|
|
title: Notification title
|
|
message: Notification message/body
|
|
notification_type: Type of notification (news, system, message, event, alert)
|
|
related_type: Type of related entity (company_news, event, message, etc.)
|
|
related_id: ID of the related entity
|
|
action_url: URL to navigate when notification is clicked
|
|
|
|
Returns:
|
|
UserNotification object or None on error
|
|
"""
|
|
db = SessionLocal()
|
|
try:
|
|
notification = UserNotification(
|
|
user_id=user_id,
|
|
title=title,
|
|
message=message,
|
|
notification_type=notification_type,
|
|
related_type=related_type,
|
|
related_id=related_id,
|
|
action_url=action_url
|
|
)
|
|
db.add(notification)
|
|
db.commit()
|
|
db.refresh(notification)
|
|
logger.info(f"Created notification for user {user_id}: {title}")
|
|
return notification
|
|
except Exception as e:
|
|
logger.error(f"Error creating notification: {e}")
|
|
db.rollback()
|
|
return None
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
def create_news_notification(company_id, news_id, news_title):
|
|
"""
|
|
Create notification for company owner when their news is approved.
|
|
|
|
Args:
|
|
company_id: ID of the company
|
|
news_id: ID of the approved news
|
|
news_title: Title of the news
|
|
"""
|
|
db = SessionLocal()
|
|
try:
|
|
# Find users associated with this company
|
|
users = db.query(User).filter(
|
|
User.company_id == company_id,
|
|
User.is_active == True
|
|
).all()
|
|
|
|
for user in users:
|
|
create_notification(
|
|
user_id=user.id,
|
|
title="Nowa aktualnosc o Twojej firmie",
|
|
message=f"Aktualnosc '{news_title}' zostala zatwierdzona i jest widoczna na profilu firmy.",
|
|
notification_type='news',
|
|
related_type='company_news',
|
|
related_id=news_id,
|
|
action_url=f"/company/{company_id}"
|
|
)
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
# ============================================================
|
|
# SECURITY MIDDLEWARE & HELPERS
|
|
# ============================================================
|
|
|
|
@app.after_request
|
|
def set_security_headers(response):
|
|
"""Add security headers to all responses"""
|
|
response.headers['X-Content-Type-Options'] = 'nosniff'
|
|
response.headers['X-Frame-Options'] = 'SAMEORIGIN'
|
|
response.headers['X-XSS-Protection'] = '1; mode=block'
|
|
response.headers['Strict-Transport-Security'] = 'max-age=31536000; includeSubDomains'
|
|
response.headers['Referrer-Policy'] = 'strict-origin-when-cross-origin'
|
|
|
|
# Content Security Policy
|
|
csp = (
|
|
"default-src 'self'; "
|
|
"script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; "
|
|
"style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://fonts.googleapis.com; "
|
|
"img-src 'self' data: https:; "
|
|
"font-src 'self' https://cdn.jsdelivr.net https://fonts.gstatic.com; "
|
|
"connect-src 'self'"
|
|
)
|
|
response.headers['Content-Security-Policy'] = csp
|
|
|
|
return response
|
|
|
|
|
|
def validate_email(email):
|
|
"""Validate email format"""
|
|
if not email or len(email) > 255:
|
|
return False
|
|
|
|
# RFC 5322 compliant email regex (simplified)
|
|
pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
|
|
return re.match(pattern, email) is not None
|
|
|
|
|
|
def validate_password(password):
|
|
"""
|
|
Validate password strength
|
|
|
|
Requirements:
|
|
- Minimum 8 characters
|
|
- At least one uppercase letter
|
|
- At least one lowercase letter
|
|
- At least one digit
|
|
"""
|
|
if not password or len(password) < 8:
|
|
return False, "Hasło musi mieć minimum 8 znaków"
|
|
|
|
if not re.search(r'[A-Z]', password):
|
|
return False, "Hasło musi zawierać przynajmniej jedną wielką literę"
|
|
|
|
if not re.search(r'[a-z]', password):
|
|
return False, "Hasło musi zawierać przynajmniej jedną małą literę"
|
|
|
|
if not re.search(r'\d', password):
|
|
return False, "Hasło musi zawierać przynajmniej jedną cyfrę"
|
|
|
|
return True, "OK"
|
|
|
|
|
|
def sanitize_input(text, max_length=1000):
|
|
"""Sanitize user input - remove potentially dangerous characters"""
|
|
if not text:
|
|
return ""
|
|
|
|
# Remove null bytes
|
|
text = text.replace('\x00', '')
|
|
|
|
# Trim to max length
|
|
text = text[:max_length]
|
|
|
|
# Strip whitespace
|
|
text = text.strip()
|
|
|
|
return text
|
|
|
|
|
|
def get_free_tier_usage():
|
|
"""
|
|
Get today's Gemini API usage for free tier tracking.
|
|
|
|
Returns:
|
|
Dict with requests_today and tokens_today
|
|
"""
|
|
from datetime import date
|
|
from sqlalchemy import func
|
|
|
|
db = SessionLocal()
|
|
try:
|
|
today = date.today()
|
|
result = db.query(
|
|
func.count(AIAPICostLog.id).label('requests'),
|
|
func.coalesce(func.sum(AIAPICostLog.total_tokens), 0).label('tokens')
|
|
).filter(
|
|
func.date(AIAPICostLog.timestamp) == today,
|
|
AIAPICostLog.api_provider == 'gemini'
|
|
).first()
|
|
|
|
return {
|
|
'requests_today': result.requests or 0,
|
|
'tokens_today': int(result.tokens or 0)
|
|
}
|
|
except Exception as e:
|
|
logger.warning(f"Failed to get free tier usage: {e}")
|
|
return {'requests_today': 0, 'tokens_today': 0}
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
def get_brave_api_usage():
|
|
"""
|
|
Get Brave Search API usage for current month.
|
|
|
|
Brave free tier: 2000 requests/month
|
|
|
|
Returns:
|
|
Dict with usage stats and limits
|
|
"""
|
|
from datetime import date
|
|
from sqlalchemy import func, extract
|
|
|
|
db = SessionLocal()
|
|
try:
|
|
today = date.today()
|
|
current_month = today.month
|
|
current_year = today.year
|
|
|
|
# Monthly usage
|
|
monthly_result = db.query(
|
|
func.count(AIAPICostLog.id).label('requests')
|
|
).filter(
|
|
extract('month', AIAPICostLog.timestamp) == current_month,
|
|
extract('year', AIAPICostLog.timestamp) == current_year,
|
|
AIAPICostLog.api_provider == 'brave'
|
|
).first()
|
|
|
|
# Today's usage
|
|
daily_result = db.query(
|
|
func.count(AIAPICostLog.id).label('requests')
|
|
).filter(
|
|
func.date(AIAPICostLog.timestamp) == today,
|
|
AIAPICostLog.api_provider == 'brave'
|
|
).first()
|
|
|
|
monthly_used = monthly_result.requests or 0
|
|
daily_used = daily_result.requests or 0
|
|
monthly_limit = 2000 # Brave free tier
|
|
|
|
return {
|
|
'requests_today': daily_used,
|
|
'requests_this_month': monthly_used,
|
|
'monthly_limit': monthly_limit,
|
|
'remaining': max(0, monthly_limit - monthly_used),
|
|
'usage_percent': round((monthly_used / monthly_limit) * 100, 1) if monthly_limit > 0 else 0,
|
|
'tier': 'free',
|
|
'is_limit_reached': monthly_used >= monthly_limit
|
|
}
|
|
except Exception as e:
|
|
logger.warning(f"Failed to get Brave API usage: {e}")
|
|
return {
|
|
'requests_today': 0,
|
|
'requests_this_month': 0,
|
|
'monthly_limit': 2000,
|
|
'remaining': 2000,
|
|
'usage_percent': 0,
|
|
'tier': 'free',
|
|
'is_limit_reached': False
|
|
}
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
def log_brave_api_call(user_id=None, feature='news_search', company_name=None):
|
|
"""
|
|
Log a Brave API call for usage tracking.
|
|
|
|
Args:
|
|
user_id: User who triggered the call (optional)
|
|
feature: Feature name (news_search, etc.)
|
|
company_name: Company being searched (for reference)
|
|
"""
|
|
db = SessionLocal()
|
|
try:
|
|
log_entry = AIAPICostLog(
|
|
api_provider='brave',
|
|
model_name='search_api',
|
|
feature=feature,
|
|
user_id=user_id,
|
|
input_tokens=0,
|
|
output_tokens=0,
|
|
total_tokens=0
|
|
)
|
|
db.add(log_entry)
|
|
db.commit()
|
|
logger.debug(f"Logged Brave API call: {feature} for {company_name}")
|
|
except Exception as e:
|
|
logger.error(f"Failed to log Brave API call: {e}")
|
|
db.rollback()
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
# ============================================================
|
|
# HEALTH CHECK
|
|
# ============================================================
|
|
|
|
@app.route('/health')
|
|
def health():
|
|
"""Health check endpoint for monitoring"""
|
|
return {'status': 'ok'}, 200
|
|
|
|
|
|
# ============================================================
|
|
# PUBLIC ROUTES
|
|
# ============================================================
|
|
|
|
@app.route('/')
|
|
def index():
|
|
"""Homepage - landing page for guests, company directory for logged in users"""
|
|
if not current_user.is_authenticated:
|
|
# Landing page for guests
|
|
db = SessionLocal()
|
|
try:
|
|
total_companies = db.query(Company).filter_by(status='active').count()
|
|
total_categories = db.query(Category).count()
|
|
return render_template(
|
|
'landing.html',
|
|
total_companies=total_companies,
|
|
total_categories=total_categories
|
|
)
|
|
finally:
|
|
db.close()
|
|
|
|
# Company directory for logged in users
|
|
db = SessionLocal()
|
|
try:
|
|
companies = db.query(Company).filter_by(status='active').order_by(Company.name).all()
|
|
categories = db.query(Category).order_by(Category.sort_order).all()
|
|
|
|
total_companies = len(companies)
|
|
total_categories = len([c for c in categories if db.query(Company).filter_by(category_id=c.id).count() > 0])
|
|
|
|
return render_template(
|
|
'index.html',
|
|
companies=companies,
|
|
categories=categories,
|
|
total_companies=total_companies,
|
|
total_categories=total_categories
|
|
)
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
@app.route('/company/<int:company_id>')
|
|
@login_required
|
|
def company_detail(company_id):
|
|
"""Company detail page - requires login"""
|
|
db = SessionLocal()
|
|
try:
|
|
company = db.query(Company).filter_by(id=company_id).first()
|
|
if not company:
|
|
flash('Firma nie znaleziona.', 'error')
|
|
return redirect(url_for('index'))
|
|
|
|
# Load digital maturity data if available
|
|
maturity_data = db.query(CompanyDigitalMaturity).filter_by(company_id=company_id).first()
|
|
website_analysis = db.query(CompanyWebsiteAnalysis).filter_by(company_id=company_id).first()
|
|
|
|
# Load quality tracking data
|
|
quality_data = db.query(CompanyQualityTracking).filter_by(company_id=company_id).first()
|
|
|
|
# Load company events (latest 10)
|
|
events = db.query(CompanyEvent).filter_by(company_id=company_id).order_by(
|
|
CompanyEvent.event_date.desc(),
|
|
CompanyEvent.created_at.desc()
|
|
).limit(10).all()
|
|
|
|
# Load website scraping data (most recent)
|
|
website_content = db.query(CompanyWebsiteContent).filter_by(company_id=company_id).order_by(
|
|
CompanyWebsiteContent.scraped_at.desc()
|
|
).first()
|
|
|
|
# Load AI insights
|
|
ai_insights = db.query(CompanyAIInsights).filter_by(company_id=company_id).first()
|
|
|
|
# Load social media profiles
|
|
social_media = db.query(CompanySocialMedia).filter_by(company_id=company_id).all()
|
|
|
|
# Load company contacts (phones, emails with sources)
|
|
contacts = db.query(CompanyContact).filter_by(company_id=company_id).order_by(
|
|
CompanyContact.contact_type,
|
|
CompanyContact.is_primary.desc()
|
|
).all()
|
|
|
|
return render_template('company_detail.html',
|
|
company=company,
|
|
maturity_data=maturity_data,
|
|
website_analysis=website_analysis,
|
|
quality_data=quality_data,
|
|
events=events,
|
|
website_content=website_content,
|
|
ai_insights=ai_insights,
|
|
social_media=social_media,
|
|
contacts=contacts
|
|
)
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
@app.route('/company/<slug>')
|
|
@login_required
|
|
def company_detail_by_slug(slug):
|
|
"""Company detail page by slug - requires login"""
|
|
db = SessionLocal()
|
|
try:
|
|
company = db.query(Company).filter_by(slug=slug).first()
|
|
if not company:
|
|
flash('Firma nie znaleziona.', 'error')
|
|
return redirect(url_for('index'))
|
|
# Redirect to canonical int ID route
|
|
return redirect(url_for('company_detail', company_id=company.id))
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
@app.route('/search')
|
|
@login_required
|
|
def search():
|
|
"""Search companies with advanced matching - requires login"""
|
|
query = request.args.get('q', '')
|
|
category_id = request.args.get('category', type=int)
|
|
|
|
db = SessionLocal()
|
|
try:
|
|
# Use new SearchService with synonym expansion, NIP/REGON lookup, and fuzzy matching
|
|
results = search_companies(db, query, category_id, limit=50)
|
|
|
|
# Extract companies from SearchResult objects
|
|
companies = [r.company for r in results]
|
|
|
|
# For debugging/analytics - log search stats
|
|
if query:
|
|
match_types = {}
|
|
for r in results:
|
|
match_types[r.match_type] = match_types.get(r.match_type, 0) + 1
|
|
logger.info(f"Search '{query}': {len(companies)} results, types: {match_types}")
|
|
|
|
return render_template(
|
|
'search_results.html',
|
|
companies=companies,
|
|
query=query,
|
|
category_id=category_id,
|
|
result_count=len(companies)
|
|
)
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
# DISABLED: Aktualności section removed
|
|
# @app.route('/aktualnosci')
|
|
# @login_required
|
|
# def events():
|
|
# """Company events and news - latest updates from member companies"""
|
|
# pass
|
|
|
|
|
|
# ============================================================
|
|
# FORUM ROUTES
|
|
# ============================================================
|
|
|
|
@app.route('/forum')
|
|
@login_required
|
|
def forum_index():
|
|
"""Forum - list of topics"""
|
|
page = request.args.get('page', 1, type=int)
|
|
per_page = 20
|
|
|
|
db = SessionLocal()
|
|
try:
|
|
# Get topics ordered by pinned first, then by last activity
|
|
query = db.query(ForumTopic).order_by(
|
|
ForumTopic.is_pinned.desc(),
|
|
ForumTopic.updated_at.desc()
|
|
)
|
|
|
|
total_topics = query.count()
|
|
topics = query.limit(per_page).offset((page - 1) * per_page).all()
|
|
|
|
return render_template(
|
|
'forum/index.html',
|
|
topics=topics,
|
|
page=page,
|
|
per_page=per_page,
|
|
total_topics=total_topics,
|
|
total_pages=(total_topics + per_page - 1) // per_page
|
|
)
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
@app.route('/forum/nowy', methods=['GET', 'POST'])
|
|
@login_required
|
|
def forum_new_topic():
|
|
"""Create new forum topic"""
|
|
if request.method == 'POST':
|
|
title = sanitize_input(request.form.get('title', ''), 255)
|
|
content = request.form.get('content', '').strip()
|
|
|
|
if not title or len(title) < 5:
|
|
flash('Tytuł musi mieć co najmniej 5 znaków.', 'error')
|
|
return render_template('forum/new_topic.html')
|
|
|
|
if not content or len(content) < 10:
|
|
flash('Treść musi mieć co najmniej 10 znaków.', 'error')
|
|
return render_template('forum/new_topic.html')
|
|
|
|
db = SessionLocal()
|
|
try:
|
|
topic = ForumTopic(
|
|
title=title,
|
|
content=content,
|
|
author_id=current_user.id
|
|
)
|
|
db.add(topic)
|
|
db.commit()
|
|
db.refresh(topic)
|
|
|
|
flash('Temat został utworzony.', 'success')
|
|
return redirect(url_for('forum_topic', topic_id=topic.id))
|
|
finally:
|
|
db.close()
|
|
|
|
return render_template('forum/new_topic.html')
|
|
|
|
|
|
@app.route('/forum/<int:topic_id>')
|
|
@login_required
|
|
def forum_topic(topic_id):
|
|
"""View forum topic with replies"""
|
|
db = SessionLocal()
|
|
try:
|
|
topic = db.query(ForumTopic).filter(ForumTopic.id == topic_id).first()
|
|
|
|
if not topic:
|
|
flash('Temat nie istnieje.', 'error')
|
|
return redirect(url_for('forum_index'))
|
|
|
|
# Increment view count
|
|
topic.views_count += 1
|
|
db.commit()
|
|
|
|
return render_template('forum/topic.html', topic=topic)
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
@app.route('/forum/<int:topic_id>/odpowiedz', methods=['POST'])
|
|
@login_required
|
|
def forum_reply(topic_id):
|
|
"""Add reply to forum topic"""
|
|
content = request.form.get('content', '').strip()
|
|
|
|
if not content or len(content) < 3:
|
|
flash('Odpowiedź musi mieć co najmniej 3 znaki.', 'error')
|
|
return redirect(url_for('forum_topic', topic_id=topic_id))
|
|
|
|
db = SessionLocal()
|
|
try:
|
|
topic = db.query(ForumTopic).filter(ForumTopic.id == topic_id).first()
|
|
|
|
if not topic:
|
|
flash('Temat nie istnieje.', 'error')
|
|
return redirect(url_for('forum_index'))
|
|
|
|
if topic.is_locked:
|
|
flash('Ten temat jest zamknięty.', 'error')
|
|
return redirect(url_for('forum_topic', topic_id=topic_id))
|
|
|
|
reply = ForumReply(
|
|
topic_id=topic_id,
|
|
author_id=current_user.id,
|
|
content=content
|
|
)
|
|
db.add(reply)
|
|
|
|
# Update topic updated_at
|
|
topic.updated_at = datetime.now()
|
|
db.commit()
|
|
|
|
flash('Odpowiedź dodana.', 'success')
|
|
return redirect(url_for('forum_topic', topic_id=topic_id))
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
# ============================================================
|
|
# FORUM ADMIN ROUTES
|
|
# ============================================================
|
|
|
|
@app.route('/admin/forum')
|
|
@login_required
|
|
def admin_forum():
|
|
"""Admin panel for forum moderation"""
|
|
if not current_user.is_admin:
|
|
flash('Brak uprawnień do tej strony.', 'error')
|
|
return redirect(url_for('forum_index'))
|
|
|
|
db = SessionLocal()
|
|
try:
|
|
# Get all topics with stats
|
|
topics = db.query(ForumTopic).order_by(
|
|
ForumTopic.created_at.desc()
|
|
).all()
|
|
|
|
# Get recent replies
|
|
recent_replies = db.query(ForumReply).order_by(
|
|
ForumReply.created_at.desc()
|
|
).limit(50).all()
|
|
|
|
# Stats
|
|
total_topics = len(topics)
|
|
total_replies = db.query(ForumReply).count()
|
|
pinned_count = sum(1 for t in topics if t.is_pinned)
|
|
locked_count = sum(1 for t in topics if t.is_locked)
|
|
|
|
return render_template(
|
|
'admin/forum.html',
|
|
topics=topics,
|
|
recent_replies=recent_replies,
|
|
total_topics=total_topics,
|
|
total_replies=total_replies,
|
|
pinned_count=pinned_count,
|
|
locked_count=locked_count
|
|
)
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
@app.route('/admin/forum/topic/<int:topic_id>/pin', methods=['POST'])
|
|
@login_required
|
|
def admin_forum_pin(topic_id):
|
|
"""Toggle topic pin status"""
|
|
if not current_user.is_admin:
|
|
return jsonify({'success': False, 'error': 'Brak uprawnień'}), 403
|
|
|
|
db = SessionLocal()
|
|
try:
|
|
topic = db.query(ForumTopic).filter(ForumTopic.id == topic_id).first()
|
|
if not topic:
|
|
return jsonify({'success': False, 'error': 'Temat nie istnieje'}), 404
|
|
|
|
topic.is_pinned = not topic.is_pinned
|
|
db.commit()
|
|
|
|
logger.info(f"Admin {current_user.email} {'pinned' if topic.is_pinned else 'unpinned'} topic #{topic_id}")
|
|
return jsonify({
|
|
'success': True,
|
|
'is_pinned': topic.is_pinned,
|
|
'message': f"Temat {'przypięty' if topic.is_pinned else 'odpięty'}"
|
|
})
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
@app.route('/admin/forum/topic/<int:topic_id>/lock', methods=['POST'])
|
|
@login_required
|
|
def admin_forum_lock(topic_id):
|
|
"""Toggle topic lock status"""
|
|
if not current_user.is_admin:
|
|
return jsonify({'success': False, 'error': 'Brak uprawnień'}), 403
|
|
|
|
db = SessionLocal()
|
|
try:
|
|
topic = db.query(ForumTopic).filter(ForumTopic.id == topic_id).first()
|
|
if not topic:
|
|
return jsonify({'success': False, 'error': 'Temat nie istnieje'}), 404
|
|
|
|
topic.is_locked = not topic.is_locked
|
|
db.commit()
|
|
|
|
logger.info(f"Admin {current_user.email} {'locked' if topic.is_locked else 'unlocked'} topic #{topic_id}")
|
|
return jsonify({
|
|
'success': True,
|
|
'is_locked': topic.is_locked,
|
|
'message': f"Temat {'zamknięty' if topic.is_locked else 'otwarty'}"
|
|
})
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
@app.route('/admin/forum/topic/<int:topic_id>/delete', methods=['POST'])
|
|
@login_required
|
|
def admin_forum_delete_topic(topic_id):
|
|
"""Delete topic and all its replies"""
|
|
if not current_user.is_admin:
|
|
return jsonify({'success': False, 'error': 'Brak uprawnień'}), 403
|
|
|
|
db = SessionLocal()
|
|
try:
|
|
topic = db.query(ForumTopic).filter(ForumTopic.id == topic_id).first()
|
|
if not topic:
|
|
return jsonify({'success': False, 'error': 'Temat nie istnieje'}), 404
|
|
|
|
topic_title = topic.title
|
|
db.delete(topic) # Cascade deletes replies
|
|
db.commit()
|
|
|
|
logger.info(f"Admin {current_user.email} deleted topic #{topic_id}: {topic_title}")
|
|
return jsonify({
|
|
'success': True,
|
|
'message': 'Temat usunięty'
|
|
})
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
@app.route('/admin/forum/reply/<int:reply_id>/delete', methods=['POST'])
|
|
@login_required
|
|
def admin_forum_delete_reply(reply_id):
|
|
"""Delete a reply"""
|
|
if not current_user.is_admin:
|
|
return jsonify({'success': False, 'error': 'Brak uprawnień'}), 403
|
|
|
|
db = SessionLocal()
|
|
try:
|
|
reply = db.query(ForumReply).filter(ForumReply.id == reply_id).first()
|
|
if not reply:
|
|
return jsonify({'success': False, 'error': 'Odpowiedź nie istnieje'}), 404
|
|
|
|
topic_id = reply.topic_id
|
|
db.delete(reply)
|
|
db.commit()
|
|
|
|
logger.info(f"Admin {current_user.email} deleted reply #{reply_id} from topic #{topic_id}")
|
|
return jsonify({
|
|
'success': True,
|
|
'message': 'Odpowiedź usunięta'
|
|
})
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
# ============================================================
|
|
# CALENDAR ROUTES
|
|
# ============================================================
|
|
|
|
@app.route('/kalendarz')
|
|
@login_required
|
|
def calendar_index():
|
|
"""Kalendarz wydarzeń Norda Biznes"""
|
|
from datetime import date
|
|
|
|
db = SessionLocal()
|
|
try:
|
|
today = date.today()
|
|
|
|
# Nadchodzące wydarzenia
|
|
upcoming = db.query(NordaEvent).filter(
|
|
NordaEvent.event_date >= today
|
|
).order_by(NordaEvent.event_date.asc()).all()
|
|
|
|
# Przeszłe wydarzenia (ostatnie 5)
|
|
past = db.query(NordaEvent).filter(
|
|
NordaEvent.event_date < today
|
|
).order_by(NordaEvent.event_date.desc()).limit(5).all()
|
|
|
|
return render_template('calendar/index.html',
|
|
upcoming_events=upcoming,
|
|
past_events=past,
|
|
today=today
|
|
)
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
@app.route('/kalendarz/<int:event_id>')
|
|
@login_required
|
|
def calendar_event(event_id):
|
|
"""Szczegóły wydarzenia"""
|
|
db = SessionLocal()
|
|
try:
|
|
event = db.query(NordaEvent).filter(NordaEvent.id == event_id).first()
|
|
if not event:
|
|
flash('Wydarzenie nie istnieje.', 'error')
|
|
return redirect(url_for('calendar_index'))
|
|
|
|
# Sprawdź czy użytkownik jest zapisany
|
|
user_attending = db.query(EventAttendee).filter(
|
|
EventAttendee.event_id == event_id,
|
|
EventAttendee.user_id == current_user.id
|
|
).first()
|
|
|
|
return render_template('calendar/event.html',
|
|
event=event,
|
|
user_attending=user_attending
|
|
)
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
@app.route('/kalendarz/<int:event_id>/rsvp', methods=['POST'])
|
|
@login_required
|
|
def calendar_rsvp(event_id):
|
|
"""Zapisz się / wypisz z wydarzenia"""
|
|
db = SessionLocal()
|
|
try:
|
|
event = db.query(NordaEvent).filter(NordaEvent.id == event_id).first()
|
|
if not event:
|
|
return jsonify({'success': False, 'error': 'Wydarzenie nie istnieje'}), 404
|
|
|
|
# Sprawdź czy już zapisany
|
|
existing = db.query(EventAttendee).filter(
|
|
EventAttendee.event_id == event_id,
|
|
EventAttendee.user_id == current_user.id
|
|
).first()
|
|
|
|
if existing:
|
|
# Wypisz
|
|
db.delete(existing)
|
|
db.commit()
|
|
return jsonify({
|
|
'success': True,
|
|
'action': 'removed',
|
|
'message': 'Wypisano z wydarzenia',
|
|
'attendee_count': event.attendee_count
|
|
})
|
|
else:
|
|
# Zapisz
|
|
if event.max_attendees and event.attendee_count >= event.max_attendees:
|
|
return jsonify({'success': False, 'error': 'Brak wolnych miejsc'}), 400
|
|
|
|
attendee = EventAttendee(
|
|
event_id=event_id,
|
|
user_id=current_user.id,
|
|
status='confirmed'
|
|
)
|
|
db.add(attendee)
|
|
db.commit()
|
|
return jsonify({
|
|
'success': True,
|
|
'action': 'added',
|
|
'message': 'Zapisano na wydarzenie',
|
|
'attendee_count': event.attendee_count
|
|
})
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
@app.route('/admin/kalendarz')
|
|
@login_required
|
|
def admin_calendar():
|
|
"""Panel admin - zarządzanie wydarzeniami"""
|
|
if not current_user.is_admin:
|
|
flash('Brak uprawnień.', 'error')
|
|
return redirect(url_for('calendar_index'))
|
|
|
|
db = SessionLocal()
|
|
try:
|
|
events = db.query(NordaEvent).order_by(NordaEvent.event_date.desc()).all()
|
|
return render_template('calendar/admin.html', events=events)
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
@app.route('/admin/kalendarz/nowy', methods=['GET', 'POST'])
|
|
@login_required
|
|
def admin_calendar_new():
|
|
"""Dodaj nowe wydarzenie"""
|
|
if not current_user.is_admin:
|
|
flash('Brak uprawnień.', 'error')
|
|
return redirect(url_for('calendar_index'))
|
|
|
|
if request.method == 'POST':
|
|
from datetime import datetime as dt
|
|
|
|
title = sanitize_input(request.form.get('title', ''), 255)
|
|
description = request.form.get('description', '').strip()
|
|
event_type = request.form.get('event_type', 'meeting')
|
|
event_date_str = request.form.get('event_date', '')
|
|
time_start_str = request.form.get('time_start', '')
|
|
time_end_str = request.form.get('time_end', '')
|
|
location = sanitize_input(request.form.get('location', ''), 500)
|
|
location_url = request.form.get('location_url', '').strip()
|
|
speaker_name = sanitize_input(request.form.get('speaker_name', ''), 255)
|
|
max_attendees = request.form.get('max_attendees', type=int)
|
|
|
|
if not title or not event_date_str:
|
|
flash('Tytuł i data są wymagane.', 'error')
|
|
return render_template('calendar/admin_new.html')
|
|
|
|
db = SessionLocal()
|
|
try:
|
|
event = NordaEvent(
|
|
title=title,
|
|
description=description,
|
|
event_type=event_type,
|
|
event_date=dt.strptime(event_date_str, '%Y-%m-%d').date(),
|
|
time_start=dt.strptime(time_start_str, '%H:%M').time() if time_start_str else None,
|
|
time_end=dt.strptime(time_end_str, '%H:%M').time() if time_end_str else None,
|
|
location=location,
|
|
location_url=location_url,
|
|
speaker_name=speaker_name,
|
|
max_attendees=max_attendees,
|
|
created_by=current_user.id
|
|
)
|
|
db.add(event)
|
|
db.commit()
|
|
|
|
flash('Wydarzenie utworzone.', 'success')
|
|
return redirect(url_for('admin_calendar'))
|
|
finally:
|
|
db.close()
|
|
|
|
return render_template('calendar/admin_new.html')
|
|
|
|
|
|
@app.route('/admin/kalendarz/<int:event_id>/delete', methods=['POST'])
|
|
@login_required
|
|
def admin_calendar_delete(event_id):
|
|
"""Usuń wydarzenie"""
|
|
if not current_user.is_admin:
|
|
return jsonify({'success': False, 'error': 'Brak uprawnień'}), 403
|
|
|
|
db = SessionLocal()
|
|
try:
|
|
event = db.query(NordaEvent).filter(NordaEvent.id == event_id).first()
|
|
if not event:
|
|
return jsonify({'success': False, 'error': 'Wydarzenie nie istnieje'}), 404
|
|
|
|
db.delete(event)
|
|
db.commit()
|
|
return jsonify({'success': True, 'message': 'Wydarzenie usunięte'})
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
# ============================================================
|
|
# PRIVATE MESSAGES ROUTES
|
|
# ============================================================
|
|
|
|
@app.route('/wiadomosci')
|
|
@login_required
|
|
def messages_inbox():
|
|
"""Skrzynka odbiorcza"""
|
|
page = request.args.get('page', 1, type=int)
|
|
per_page = 20
|
|
|
|
db = SessionLocal()
|
|
try:
|
|
query = db.query(PrivateMessage).filter(
|
|
PrivateMessage.recipient_id == current_user.id
|
|
).order_by(PrivateMessage.created_at.desc())
|
|
|
|
total = query.count()
|
|
messages = query.limit(per_page).offset((page - 1) * per_page).all()
|
|
|
|
unread_count = db.query(PrivateMessage).filter(
|
|
PrivateMessage.recipient_id == current_user.id,
|
|
PrivateMessage.is_read == False
|
|
).count()
|
|
|
|
return render_template('messages/inbox.html',
|
|
messages=messages,
|
|
page=page,
|
|
total_pages=(total + per_page - 1) // per_page,
|
|
unread_count=unread_count
|
|
)
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
@app.route('/wiadomosci/wyslane')
|
|
@login_required
|
|
def messages_sent():
|
|
"""Wysłane wiadomości"""
|
|
page = request.args.get('page', 1, type=int)
|
|
per_page = 20
|
|
|
|
db = SessionLocal()
|
|
try:
|
|
query = db.query(PrivateMessage).filter(
|
|
PrivateMessage.sender_id == current_user.id
|
|
).order_by(PrivateMessage.created_at.desc())
|
|
|
|
total = query.count()
|
|
messages = query.limit(per_page).offset((page - 1) * per_page).all()
|
|
|
|
return render_template('messages/sent.html',
|
|
messages=messages,
|
|
page=page,
|
|
total_pages=(total + per_page - 1) // per_page
|
|
)
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
@app.route('/wiadomosci/nowa')
|
|
@login_required
|
|
def messages_new():
|
|
"""Formularz nowej wiadomości"""
|
|
recipient_id = request.args.get('to', type=int)
|
|
|
|
db = SessionLocal()
|
|
try:
|
|
# Lista użytkowników do wyboru
|
|
users = db.query(User).filter(
|
|
User.is_active == True,
|
|
User.is_verified == True,
|
|
User.id != current_user.id
|
|
).order_by(User.name).all()
|
|
|
|
recipient = None
|
|
if recipient_id:
|
|
recipient = db.query(User).filter(User.id == recipient_id).first()
|
|
|
|
return render_template('messages/compose.html',
|
|
users=users,
|
|
recipient=recipient
|
|
)
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
@app.route('/wiadomosci/wyslij', methods=['POST'])
|
|
@login_required
|
|
def messages_send():
|
|
"""Wyślij wiadomość"""
|
|
recipient_id = request.form.get('recipient_id', type=int)
|
|
subject = sanitize_input(request.form.get('subject', ''), 255)
|
|
content = request.form.get('content', '').strip()
|
|
|
|
if not recipient_id or not content:
|
|
flash('Odbiorca i treść są wymagane.', 'error')
|
|
return redirect(url_for('messages_new'))
|
|
|
|
db = SessionLocal()
|
|
try:
|
|
recipient = db.query(User).filter(User.id == recipient_id).first()
|
|
if not recipient:
|
|
flash('Odbiorca nie istnieje.', 'error')
|
|
return redirect(url_for('messages_new'))
|
|
|
|
message = PrivateMessage(
|
|
sender_id=current_user.id,
|
|
recipient_id=recipient_id,
|
|
subject=subject,
|
|
content=content
|
|
)
|
|
db.add(message)
|
|
db.commit()
|
|
|
|
flash('Wiadomość wysłana.', 'success')
|
|
return redirect(url_for('messages_sent'))
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
@app.route('/wiadomosci/<int:message_id>')
|
|
@login_required
|
|
def messages_view(message_id):
|
|
"""Czytaj wiadomość"""
|
|
db = SessionLocal()
|
|
try:
|
|
message = db.query(PrivateMessage).filter(
|
|
PrivateMessage.id == message_id
|
|
).first()
|
|
|
|
if not message:
|
|
flash('Wiadomość nie istnieje.', 'error')
|
|
return redirect(url_for('messages_inbox'))
|
|
|
|
# Sprawdź dostęp
|
|
if message.recipient_id != current_user.id and message.sender_id != current_user.id:
|
|
flash('Brak dostępu do tej wiadomości.', 'error')
|
|
return redirect(url_for('messages_inbox'))
|
|
|
|
# Oznacz jako przeczytaną
|
|
if message.recipient_id == current_user.id and not message.is_read:
|
|
message.is_read = True
|
|
message.read_at = datetime.now()
|
|
db.commit()
|
|
|
|
return render_template('messages/view.html', message=message)
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
@app.route('/wiadomosci/<int:message_id>/odpowiedz', methods=['POST'])
|
|
@login_required
|
|
def messages_reply(message_id):
|
|
"""Odpowiedz na wiadomość"""
|
|
content = request.form.get('content', '').strip()
|
|
|
|
if not content:
|
|
flash('Treść jest wymagana.', 'error')
|
|
return redirect(url_for('messages_view', message_id=message_id))
|
|
|
|
db = SessionLocal()
|
|
try:
|
|
original = db.query(PrivateMessage).filter(
|
|
PrivateMessage.id == message_id
|
|
).first()
|
|
|
|
if not original:
|
|
flash('Wiadomość nie istnieje.', 'error')
|
|
return redirect(url_for('messages_inbox'))
|
|
|
|
# Odpowiedz do nadawcy oryginalnej wiadomości
|
|
recipient_id = original.sender_id if original.sender_id != current_user.id else original.recipient_id
|
|
|
|
reply = PrivateMessage(
|
|
sender_id=current_user.id,
|
|
recipient_id=recipient_id,
|
|
subject=f"Re: {original.subject}" if original.subject else None,
|
|
content=content,
|
|
parent_id=message_id
|
|
)
|
|
db.add(reply)
|
|
db.commit()
|
|
|
|
flash('Odpowiedź wysłana.', 'success')
|
|
return redirect(url_for('messages_view', message_id=message_id))
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
@app.route('/api/messages/unread-count')
|
|
@login_required
|
|
def api_unread_count():
|
|
"""API: Liczba nieprzeczytanych wiadomości"""
|
|
db = SessionLocal()
|
|
try:
|
|
count = db.query(PrivateMessage).filter(
|
|
PrivateMessage.recipient_id == current_user.id,
|
|
PrivateMessage.is_read == False
|
|
).count()
|
|
return jsonify({'count': count})
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
# ============================================================
|
|
# NOTIFICATIONS API ROUTES
|
|
# ============================================================
|
|
|
|
@app.route('/api/notifications')
|
|
@login_required
|
|
def api_notifications():
|
|
"""API: Get user notifications"""
|
|
limit = request.args.get('limit', 20, type=int)
|
|
offset = request.args.get('offset', 0, type=int)
|
|
unread_only = request.args.get('unread_only', 'false').lower() == 'true'
|
|
|
|
db = SessionLocal()
|
|
try:
|
|
query = db.query(UserNotification).filter(
|
|
UserNotification.user_id == current_user.id
|
|
)
|
|
|
|
if unread_only:
|
|
query = query.filter(UserNotification.is_read == False)
|
|
|
|
# Order by most recent first
|
|
query = query.order_by(UserNotification.created_at.desc())
|
|
|
|
total = query.count()
|
|
notifications = query.limit(limit).offset(offset).all()
|
|
|
|
return jsonify({
|
|
'success': True,
|
|
'notifications': [
|
|
{
|
|
'id': n.id,
|
|
'title': n.title,
|
|
'message': n.message,
|
|
'notification_type': n.notification_type,
|
|
'related_type': n.related_type,
|
|
'related_id': n.related_id,
|
|
'action_url': n.action_url,
|
|
'is_read': n.is_read,
|
|
'created_at': n.created_at.isoformat() if n.created_at else None
|
|
}
|
|
for n in notifications
|
|
],
|
|
'total': total,
|
|
'unread_count': db.query(UserNotification).filter(
|
|
UserNotification.user_id == current_user.id,
|
|
UserNotification.is_read == False
|
|
).count()
|
|
})
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
@app.route('/api/notifications/<int:notification_id>/read', methods=['POST'])
|
|
@login_required
|
|
def api_notification_mark_read(notification_id):
|
|
"""API: Mark notification as read"""
|
|
db = SessionLocal()
|
|
try:
|
|
notification = db.query(UserNotification).filter(
|
|
UserNotification.id == notification_id,
|
|
UserNotification.user_id == current_user.id
|
|
).first()
|
|
|
|
if not notification:
|
|
return jsonify({'success': False, 'error': 'Powiadomienie nie znalezione'}), 404
|
|
|
|
notification.mark_as_read()
|
|
db.commit()
|
|
|
|
return jsonify({
|
|
'success': True,
|
|
'message': 'Oznaczono jako przeczytane'
|
|
})
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
@app.route('/api/notifications/read-all', methods=['POST'])
|
|
@login_required
|
|
def api_notifications_mark_all_read():
|
|
"""API: Mark all notifications as read"""
|
|
db = SessionLocal()
|
|
try:
|
|
updated = db.query(UserNotification).filter(
|
|
UserNotification.user_id == current_user.id,
|
|
UserNotification.is_read == False
|
|
).update({
|
|
UserNotification.is_read: True,
|
|
UserNotification.read_at: datetime.now()
|
|
})
|
|
db.commit()
|
|
|
|
return jsonify({
|
|
'success': True,
|
|
'message': f'Oznaczono {updated} powiadomien jako przeczytane',
|
|
'count': updated
|
|
})
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
@app.route('/api/notifications/unread-count')
|
|
@login_required
|
|
def api_notifications_unread_count():
|
|
"""API: Get unread notifications count"""
|
|
db = SessionLocal()
|
|
try:
|
|
count = db.query(UserNotification).filter(
|
|
UserNotification.user_id == current_user.id,
|
|
UserNotification.is_read == False
|
|
).count()
|
|
return jsonify({'count': count})
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
# ============================================================
|
|
# B2B CLASSIFIEDS ROUTES
|
|
# ============================================================
|
|
|
|
@app.route('/tablica')
|
|
@login_required
|
|
def classifieds_index():
|
|
"""Tablica ogłoszeń B2B"""
|
|
listing_type = request.args.get('type', '')
|
|
category = request.args.get('category', '')
|
|
page = request.args.get('page', 1, type=int)
|
|
per_page = 20
|
|
|
|
db = SessionLocal()
|
|
try:
|
|
query = db.query(Classified).filter(
|
|
Classified.is_active == True
|
|
)
|
|
|
|
# Filtry
|
|
if listing_type:
|
|
query = query.filter(Classified.listing_type == listing_type)
|
|
if category:
|
|
query = query.filter(Classified.category == category)
|
|
|
|
# Sortowanie - najnowsze pierwsze
|
|
query = query.order_by(Classified.created_at.desc())
|
|
|
|
total = query.count()
|
|
classifieds = query.limit(per_page).offset((page - 1) * per_page).all()
|
|
|
|
# Kategorie do filtrów
|
|
categories = [
|
|
('uslugi', 'Usługi'),
|
|
('produkty', 'Produkty'),
|
|
('wspolpraca', 'Współpraca'),
|
|
('praca', 'Praca'),
|
|
('inne', 'Inne')
|
|
]
|
|
|
|
return render_template('classifieds/index.html',
|
|
classifieds=classifieds,
|
|
categories=categories,
|
|
listing_type=listing_type,
|
|
category_filter=category,
|
|
page=page,
|
|
total_pages=(total + per_page - 1) // per_page
|
|
)
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
@app.route('/tablica/nowe', methods=['GET', 'POST'])
|
|
@login_required
|
|
def classifieds_new():
|
|
"""Dodaj nowe ogłoszenie"""
|
|
if request.method == 'POST':
|
|
listing_type = request.form.get('listing_type', '')
|
|
category = request.form.get('category', '')
|
|
title = sanitize_input(request.form.get('title', ''), 255)
|
|
description = request.form.get('description', '').strip()
|
|
budget_info = sanitize_input(request.form.get('budget_info', ''), 255)
|
|
location_info = sanitize_input(request.form.get('location_info', ''), 255)
|
|
|
|
if not listing_type or not category or not title or not description:
|
|
flash('Wszystkie wymagane pola muszą być wypełnione.', 'error')
|
|
return render_template('classifieds/new.html')
|
|
|
|
db = SessionLocal()
|
|
try:
|
|
# Automatyczne wygaśnięcie po 30 dniach
|
|
expires = datetime.now() + timedelta(days=30)
|
|
|
|
classified = Classified(
|
|
author_id=current_user.id,
|
|
company_id=current_user.company_id,
|
|
listing_type=listing_type,
|
|
category=category,
|
|
title=title,
|
|
description=description,
|
|
budget_info=budget_info,
|
|
location_info=location_info,
|
|
expires_at=expires
|
|
)
|
|
db.add(classified)
|
|
db.commit()
|
|
|
|
flash('Ogłoszenie dodane.', 'success')
|
|
return redirect(url_for('classifieds_index'))
|
|
finally:
|
|
db.close()
|
|
|
|
return render_template('classifieds/new.html')
|
|
|
|
|
|
@app.route('/tablica/<int:classified_id>')
|
|
@login_required
|
|
def classifieds_view(classified_id):
|
|
"""Szczegóły ogłoszenia"""
|
|
db = SessionLocal()
|
|
try:
|
|
classified = db.query(Classified).filter(
|
|
Classified.id == classified_id
|
|
).first()
|
|
|
|
if not classified:
|
|
flash('Ogłoszenie nie istnieje.', 'error')
|
|
return redirect(url_for('classifieds_index'))
|
|
|
|
# Zwiększ licznik wyświetleń
|
|
classified.views_count += 1
|
|
db.commit()
|
|
|
|
return render_template('classifieds/view.html', classified=classified)
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
@app.route('/tablica/<int:classified_id>/zakoncz', methods=['POST'])
|
|
@login_required
|
|
def classifieds_close(classified_id):
|
|
"""Zamknij ogłoszenie"""
|
|
db = SessionLocal()
|
|
try:
|
|
classified = db.query(Classified).filter(
|
|
Classified.id == classified_id,
|
|
Classified.author_id == current_user.id
|
|
).first()
|
|
|
|
if not classified:
|
|
return jsonify({'success': False, 'error': 'Ogłoszenie nie istnieje lub brak uprawnień'}), 404
|
|
|
|
classified.is_active = False
|
|
db.commit()
|
|
|
|
return jsonify({'success': True, 'message': 'Ogłoszenie zamknięte'})
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
# ============================================================
|
|
# NEW MEMBERS ROUTE
|
|
# ============================================================
|
|
|
|
@app.route('/nowi-czlonkowie')
|
|
@login_required
|
|
def new_members():
|
|
"""Lista nowych firm członkowskich"""
|
|
days = request.args.get('days', 90, type=int)
|
|
|
|
db = SessionLocal()
|
|
try:
|
|
cutoff_date = datetime.now() - timedelta(days=days)
|
|
|
|
new_companies = db.query(Company).filter(
|
|
Company.status == 'active',
|
|
Company.created_at >= cutoff_date
|
|
).order_by(Company.created_at.desc()).all()
|
|
|
|
return render_template('new_members.html',
|
|
companies=new_companies,
|
|
days=days,
|
|
total=len(new_companies)
|
|
)
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
# ============================================================
|
|
# AUTHENTICATION ROUTES
|
|
# ============================================================
|
|
|
|
@app.route('/register', methods=['GET', 'POST'])
|
|
@limiter.limit("5 per hour") # Limit registration attempts
|
|
def register():
|
|
"""User registration"""
|
|
if current_user.is_authenticated:
|
|
return redirect(url_for('index'))
|
|
|
|
if request.method == 'POST':
|
|
email = sanitize_input(request.form.get('email', ''), 255)
|
|
password = request.form.get('password', '')
|
|
name = sanitize_input(request.form.get('name', ''), 255)
|
|
company_nip = sanitize_input(request.form.get('company_nip', ''), 10)
|
|
|
|
# Validate email
|
|
if not validate_email(email):
|
|
flash('Nieprawidłowy format adresu email.', 'error')
|
|
return render_template('auth/register.html')
|
|
|
|
# Validate password
|
|
password_valid, password_message = validate_password(password)
|
|
if not password_valid:
|
|
flash(password_message, 'error')
|
|
return render_template('auth/register.html')
|
|
|
|
# Validate required fields
|
|
if not name or not email or not company_nip:
|
|
flash('Imię, email i NIP firmy są wymagane.', 'error')
|
|
return render_template('auth/register.html')
|
|
|
|
# Validate NIP format
|
|
if not re.match(r'^\d{10}$', company_nip):
|
|
flash('NIP musi składać się z 10 cyfr.', 'error')
|
|
return render_template('auth/register.html')
|
|
|
|
db = SessionLocal()
|
|
try:
|
|
# Check if user exists
|
|
if db.query(User).filter_by(email=email).first():
|
|
flash('Email już jest zarejestrowany.', 'error')
|
|
return render_template('auth/register.html')
|
|
|
|
# Check if company is NORDA member
|
|
is_norda_member = False
|
|
company_id = None
|
|
if company_nip and re.match(r'^\d{10}$', company_nip):
|
|
company = db.query(Company).filter_by(nip=company_nip, status='active').first()
|
|
if company:
|
|
is_norda_member = True
|
|
company_id = company.id
|
|
|
|
# Generate verification token
|
|
verification_token = secrets.token_urlsafe(32)
|
|
verification_expires = datetime.now() + timedelta(hours=24)
|
|
|
|
# Create user
|
|
user = User(
|
|
email=email,
|
|
password_hash=generate_password_hash(password, method='pbkdf2:sha256'),
|
|
name=name,
|
|
company_nip=company_nip,
|
|
company_id=company_id,
|
|
is_norda_member=is_norda_member,
|
|
created_at=datetime.now(),
|
|
is_active=True,
|
|
is_verified=False, # Requires email verification
|
|
verification_token=verification_token,
|
|
verification_token_expires=verification_expires
|
|
)
|
|
|
|
db.add(user)
|
|
db.commit()
|
|
|
|
# Build verification URL
|
|
base_url = os.getenv('APP_URL', 'https://nordabiznes.pl')
|
|
verification_url = f"{base_url}/verify-email/{verification_token}"
|
|
|
|
# Try to send verification email
|
|
try:
|
|
import email_service
|
|
if email_service.is_configured():
|
|
success = email_service.send_welcome_email(email, name, verification_url)
|
|
if success:
|
|
logger.info(f"Verification email sent to {email}")
|
|
else:
|
|
logger.warning(f"Failed to send verification email to {email}")
|
|
logger.info(f"Verification URL (email failed): {verification_url}")
|
|
else:
|
|
logger.warning("Email service not configured")
|
|
logger.info(f"Verification URL (no email service): {verification_url}")
|
|
except Exception as e:
|
|
logger.error(f"Error sending verification email: {e}")
|
|
logger.info(f"Verification URL (exception): {verification_url}")
|
|
|
|
logger.info(f"New user registered: {email}")
|
|
flash('Rejestracja udana! Sprawdz email i kliknij link weryfikacyjny.', 'success')
|
|
return redirect(url_for('login'))
|
|
|
|
except Exception as e:
|
|
logger.error(f"Registration error: {e}")
|
|
flash('Wystąpił błąd podczas rejestracji. Spróbuj ponownie.', 'error')
|
|
return render_template('auth/register.html')
|
|
finally:
|
|
db.close()
|
|
|
|
return render_template('auth/register.html')
|
|
|
|
|
|
@app.route('/login', methods=['GET', 'POST'])
|
|
@limiter.limit("100 per hour") # Increased for testing
|
|
def login():
|
|
"""User login"""
|
|
if current_user.is_authenticated:
|
|
return redirect(url_for('index'))
|
|
|
|
if request.method == 'POST':
|
|
email = sanitize_input(request.form.get('email', ''), 255)
|
|
password = request.form.get('password', '')
|
|
remember = request.form.get('remember', False) == 'on'
|
|
|
|
# Basic validation
|
|
if not email or not password:
|
|
flash('Email i hasło są wymagane.', 'error')
|
|
return render_template('auth/login.html')
|
|
|
|
db = SessionLocal()
|
|
try:
|
|
user = db.query(User).filter_by(email=email).first()
|
|
|
|
if not user or not check_password_hash(user.password_hash, password):
|
|
logger.warning(f"Failed login attempt for: {email}")
|
|
flash('Nieprawidłowy email lub hasło.', 'error')
|
|
return render_template('auth/login.html')
|
|
|
|
if not user.is_active:
|
|
flash('Konto zostało dezaktywowane.', 'error')
|
|
return render_template('auth/login.html')
|
|
|
|
# Require email verification
|
|
if not user.is_verified:
|
|
flash('Musisz potwierdzic adres email przed zalogowaniem. Sprawdz skrzynke.', 'error')
|
|
return render_template('auth/login.html')
|
|
|
|
login_user(user, remember=remember)
|
|
user.last_login = datetime.now()
|
|
db.commit()
|
|
|
|
logger.info(f"User logged in: {email}")
|
|
|
|
next_page = request.args.get('next')
|
|
# Prevent open redirect vulnerability
|
|
if next_page and not next_page.startswith('/'):
|
|
next_page = None
|
|
|
|
return redirect(next_page or url_for('index'))
|
|
|
|
except Exception as e:
|
|
logger.error(f"Login error: {e}")
|
|
flash('Wystąpił błąd podczas logowania. Spróbuj ponownie.', 'error')
|
|
return render_template('auth/login.html')
|
|
finally:
|
|
db.close()
|
|
|
|
return render_template('auth/login.html')
|
|
|
|
|
|
@app.route('/logout')
|
|
@login_required
|
|
def logout():
|
|
"""User logout"""
|
|
logout_user()
|
|
flash('Wylogowano pomyślnie.', 'success')
|
|
return redirect(url_for('index'))
|
|
|
|
|
|
@app.route('/forgot-password', methods=['GET', 'POST'])
|
|
@limiter.limit("5 per hour")
|
|
def forgot_password():
|
|
"""Request password reset"""
|
|
if current_user.is_authenticated:
|
|
return redirect(url_for('index'))
|
|
|
|
if request.method == 'POST':
|
|
email = sanitize_input(request.form.get('email', ''), 255)
|
|
|
|
if not validate_email(email):
|
|
flash('Nieprawidłowy format adresu email.', 'error')
|
|
return render_template('auth/forgot_password.html')
|
|
|
|
db = SessionLocal()
|
|
try:
|
|
user = db.query(User).filter_by(email=email, is_active=True).first()
|
|
|
|
if user:
|
|
# Generate reset token
|
|
reset_token = secrets.token_urlsafe(32)
|
|
reset_expires = datetime.now() + timedelta(hours=1)
|
|
|
|
# Save token to database
|
|
user.reset_token = reset_token
|
|
user.reset_token_expires = reset_expires
|
|
db.commit()
|
|
|
|
# Build reset URL
|
|
base_url = os.getenv('APP_URL', 'https://nordabiznes.pl')
|
|
reset_url = f"{base_url}/reset-password/{reset_token}"
|
|
|
|
# Try to send email
|
|
try:
|
|
import email_service
|
|
if email_service.is_configured():
|
|
success = email_service.send_password_reset_email(email, reset_url)
|
|
if success:
|
|
logger.info(f"Password reset email sent to {email}")
|
|
else:
|
|
logger.warning(f"Failed to send password reset email to {email}")
|
|
# Log URL for manual recovery
|
|
logger.info(f"Reset URL (email failed): {reset_url}")
|
|
else:
|
|
logger.warning("Email service not configured")
|
|
logger.info(f"Reset URL (no email service): {reset_url}")
|
|
except Exception as e:
|
|
logger.error(f"Error sending reset email: {e}")
|
|
logger.info(f"Reset URL (exception): {reset_url}")
|
|
|
|
# Always show same message to prevent email enumeration
|
|
flash('Jeśli email istnieje w systemie, instrukcje resetowania hasła zostały wysłane.', 'info')
|
|
return redirect(url_for('login'))
|
|
|
|
except Exception as e:
|
|
logger.error(f"Password reset error: {e}")
|
|
flash('Wystąpił błąd. Spróbuj ponownie.', 'error')
|
|
finally:
|
|
db.close()
|
|
|
|
return render_template('auth/forgot_password.html')
|
|
|
|
|
|
@app.route('/reset-password/<token>', methods=['GET', 'POST'])
|
|
@limiter.limit("10 per hour")
|
|
def reset_password(token):
|
|
"""Reset password with token"""
|
|
if current_user.is_authenticated:
|
|
return redirect(url_for('index'))
|
|
|
|
db = SessionLocal()
|
|
try:
|
|
# Find user with valid token
|
|
user = db.query(User).filter(
|
|
User.reset_token == token,
|
|
User.reset_token_expires > datetime.now(),
|
|
User.is_active == True
|
|
).first()
|
|
|
|
if not user:
|
|
flash('Link resetowania hasła jest nieprawidłowy lub wygasł.', 'error')
|
|
return redirect(url_for('forgot_password'))
|
|
|
|
if request.method == 'POST':
|
|
password = request.form.get('password', '')
|
|
password_confirm = request.form.get('password_confirm', '')
|
|
|
|
# Validate passwords match
|
|
if password != password_confirm:
|
|
flash('Hasła nie są identyczne.', 'error')
|
|
return render_template('auth/reset_password.html', token=token)
|
|
|
|
# Validate password strength
|
|
password_valid, password_message = validate_password(password)
|
|
if not password_valid:
|
|
flash(password_message, 'error')
|
|
return render_template('auth/reset_password.html', token=token)
|
|
|
|
# Update password and clear reset token
|
|
user.password_hash = generate_password_hash(password, method='pbkdf2:sha256')
|
|
user.reset_token = None
|
|
user.reset_token_expires = None
|
|
db.commit()
|
|
|
|
logger.info(f"Password reset successful for {user.email}")
|
|
flash('Hasło zostało zmienione. Możesz się teraz zalogować.', 'success')
|
|
return redirect(url_for('login'))
|
|
|
|
return render_template('auth/reset_password.html', token=token)
|
|
|
|
except Exception as e:
|
|
logger.error(f"Reset password error: {e}")
|
|
flash('Wystąpił błąd. Spróbuj ponownie.', 'error')
|
|
return redirect(url_for('forgot_password'))
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
@app.route('/verify-email/<token>')
|
|
def verify_email(token):
|
|
"""Verify email address with token"""
|
|
db = SessionLocal()
|
|
try:
|
|
user = db.query(User).filter(
|
|
User.verification_token == token,
|
|
User.verification_token_expires > datetime.now(),
|
|
User.is_active == True
|
|
).first()
|
|
|
|
if not user:
|
|
flash('Link weryfikacyjny jest nieprawidłowy lub wygasł.', 'error')
|
|
return redirect(url_for('login'))
|
|
|
|
if user.is_verified:
|
|
flash('Email został już zweryfikowany.', 'info')
|
|
return redirect(url_for('login'))
|
|
|
|
# Verify user
|
|
user.is_verified = True
|
|
user.verified_at = datetime.now()
|
|
user.verification_token = None
|
|
user.verification_token_expires = None
|
|
db.commit()
|
|
|
|
logger.info(f"Email verified for {user.email}")
|
|
flash('Email został zweryfikowany! Możesz się teraz zalogować.', 'success')
|
|
return redirect(url_for('login'))
|
|
|
|
except Exception as e:
|
|
logger.error(f"Email verification error: {e}")
|
|
flash('Wystąpił błąd podczas weryfikacji.', 'error')
|
|
return redirect(url_for('login'))
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
@app.route('/resend-verification', methods=['GET', 'POST'])
|
|
@limiter.limit("5 per hour")
|
|
def resend_verification():
|
|
"""Resend email verification link"""
|
|
if current_user.is_authenticated:
|
|
return redirect(url_for('index'))
|
|
|
|
if request.method == 'POST':
|
|
email = sanitize_input(request.form.get('email', ''), 255)
|
|
|
|
if not validate_email(email):
|
|
flash('Nieprawidłowy format adresu email.', 'error')
|
|
return render_template('auth/resend_verification.html')
|
|
|
|
db = SessionLocal()
|
|
try:
|
|
user = db.query(User).filter_by(email=email, is_active=True).first()
|
|
|
|
if user and not user.is_verified:
|
|
# Generate new verification token
|
|
verification_token = secrets.token_urlsafe(32)
|
|
verification_expires = datetime.now() + timedelta(hours=24)
|
|
|
|
# Update user token
|
|
user.verification_token = verification_token
|
|
user.verification_token_expires = verification_expires
|
|
db.commit()
|
|
|
|
# Build verification URL
|
|
base_url = os.getenv('APP_URL', 'https://nordabiznes.pl')
|
|
verification_url = f"{base_url}/verify-email/{verification_token}"
|
|
|
|
# Try to send email
|
|
try:
|
|
import email_service
|
|
if email_service.is_configured():
|
|
success = email_service.send_welcome_email(email, user.name, verification_url)
|
|
if success:
|
|
logger.info(f"Verification email resent to {email}")
|
|
else:
|
|
logger.warning(f"Failed to resend verification email to {email}")
|
|
logger.info(f"Verification URL (email failed): {verification_url}")
|
|
else:
|
|
logger.warning("Email service not configured")
|
|
logger.info(f"Verification URL (no email service): {verification_url}")
|
|
except Exception as e:
|
|
logger.error(f"Error resending verification email: {e}")
|
|
logger.info(f"Verification URL (exception): {verification_url}")
|
|
|
|
# Always show same message to prevent email enumeration
|
|
flash('Jesli konto istnieje i nie zostalo zweryfikowane, email weryfikacyjny zostal wyslany.', 'info')
|
|
return redirect(url_for('login'))
|
|
|
|
except Exception as e:
|
|
logger.error(f"Resend verification error: {e}")
|
|
flash('Wystapil blad. Sprobuj ponownie.', 'error')
|
|
finally:
|
|
db.close()
|
|
|
|
return render_template('auth/resend_verification.html')
|
|
|
|
|
|
# ============================================================
|
|
# USER DASHBOARD
|
|
# ============================================================
|
|
|
|
@app.route('/dashboard')
|
|
@login_required
|
|
def dashboard():
|
|
"""User dashboard"""
|
|
db = SessionLocal()
|
|
try:
|
|
# Get user's conversations
|
|
conversations = db.query(AIChatConversation).filter_by(
|
|
user_id=current_user.id
|
|
).order_by(AIChatConversation.updated_at.desc()).limit(10).all()
|
|
|
|
# Stats
|
|
total_conversations = db.query(AIChatConversation).filter_by(user_id=current_user.id).count()
|
|
total_messages = db.query(AIChatMessage).join(AIChatConversation).filter(
|
|
AIChatConversation.user_id == current_user.id
|
|
).count()
|
|
|
|
return render_template(
|
|
'dashboard.html',
|
|
conversations=conversations,
|
|
total_conversations=total_conversations,
|
|
total_messages=total_messages
|
|
)
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
# ============================================================
|
|
# AI CHAT ROUTES
|
|
# ============================================================
|
|
|
|
@app.route('/chat')
|
|
@login_required
|
|
def chat():
|
|
"""AI Chat interface"""
|
|
return render_template('chat.html')
|
|
|
|
|
|
@app.route('/api/chat/start', methods=['POST'])
|
|
@login_required
|
|
def chat_start():
|
|
"""Start new chat conversation"""
|
|
try:
|
|
data = request.get_json()
|
|
title = data.get('title', f"Rozmowa - {datetime.now().strftime('%Y-%m-%d %H:%M')}")
|
|
|
|
chat_engine = NordaBizChatEngine()
|
|
conversation = chat_engine.start_conversation(
|
|
user_id=current_user.id,
|
|
title=title
|
|
)
|
|
|
|
return jsonify({
|
|
'success': True,
|
|
'conversation_id': conversation.id,
|
|
'title': conversation.title
|
|
})
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error starting chat: {e}")
|
|
return jsonify({'success': False, 'error': str(e)}), 500
|
|
|
|
|
|
@app.route('/api/chat/<int:conversation_id>/message', methods=['POST'])
|
|
@login_required
|
|
def chat_send_message(conversation_id):
|
|
"""Send message to AI chat"""
|
|
try:
|
|
data = request.get_json()
|
|
message = data.get('message', '').strip()
|
|
|
|
if not message:
|
|
return jsonify({'success': False, 'error': 'Wiadomość nie może być pusta'}), 400
|
|
|
|
# Verify conversation belongs to user
|
|
db = SessionLocal()
|
|
try:
|
|
conversation = db.query(AIChatConversation).filter_by(
|
|
id=conversation_id,
|
|
user_id=current_user.id
|
|
).first()
|
|
|
|
if not conversation:
|
|
return jsonify({'success': False, 'error': 'Conversation not found'}), 404
|
|
finally:
|
|
db.close()
|
|
|
|
chat_engine = NordaBizChatEngine()
|
|
response = chat_engine.send_message(
|
|
conversation_id=conversation_id,
|
|
user_message=message,
|
|
user_id=current_user.id
|
|
)
|
|
|
|
# Get free tier usage stats for today
|
|
free_tier_stats = get_free_tier_usage()
|
|
|
|
# Calculate theoretical cost (Gemini 2.0 Flash pricing)
|
|
tokens_in = response.tokens_input or 0
|
|
tokens_out = response.tokens_output or 0
|
|
theoretical_cost = (tokens_in / 1_000_000) * 0.075 + (tokens_out / 1_000_000) * 0.30
|
|
|
|
return jsonify({
|
|
'success': True,
|
|
'message': response.content,
|
|
'message_id': response.id,
|
|
'created_at': response.created_at.isoformat(),
|
|
# Technical metadata
|
|
'tech_info': {
|
|
'model': 'gemini-2.0-flash',
|
|
'data_source': 'PostgreSQL (80 firm Norda Biznes)',
|
|
'architecture': 'Full DB Context (wszystkie firmy w kontekście AI)',
|
|
'tokens_input': tokens_in,
|
|
'tokens_output': tokens_out,
|
|
'tokens_total': tokens_in + tokens_out,
|
|
'latency_ms': response.latency_ms or 0,
|
|
'theoretical_cost_usd': round(theoretical_cost, 6),
|
|
'actual_cost_usd': 0.0, # Free tier
|
|
'free_tier': {
|
|
'is_free': True,
|
|
'daily_limit': 1500, # Gemini free tier: 1500 req/day
|
|
'requests_today': free_tier_stats['requests_today'],
|
|
'tokens_today': free_tier_stats['tokens_today'],
|
|
'remaining': max(0, 1500 - free_tier_stats['requests_today'])
|
|
}
|
|
}
|
|
})
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error sending message: {e}")
|
|
return jsonify({'success': False, 'error': str(e)}), 500
|
|
|
|
|
|
@app.route('/api/chat/<int:conversation_id>/history', methods=['GET'])
|
|
@login_required
|
|
def chat_get_history(conversation_id):
|
|
"""Get conversation history"""
|
|
try:
|
|
# Verify conversation belongs to user
|
|
db = SessionLocal()
|
|
try:
|
|
conversation = db.query(AIChatConversation).filter_by(
|
|
id=conversation_id,
|
|
user_id=current_user.id
|
|
).first()
|
|
|
|
if not conversation:
|
|
return jsonify({'success': False, 'error': 'Conversation not found'}), 404
|
|
finally:
|
|
db.close()
|
|
|
|
chat_engine = NordaBizChatEngine()
|
|
history = chat_engine.get_conversation_history(conversation_id)
|
|
|
|
return jsonify({
|
|
'success': True,
|
|
'messages': history
|
|
})
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error getting history: {e}")
|
|
return jsonify({'success': False, 'error': str(e)}), 500
|
|
|
|
|
|
# ============================================================
|
|
# API ROUTES (for frontend)
|
|
# ============================================================
|
|
|
|
@app.route('/api/companies')
|
|
def api_companies():
|
|
"""API: Get all companies"""
|
|
db = SessionLocal()
|
|
try:
|
|
companies = db.query(Company).filter_by(status='active').all()
|
|
return jsonify({
|
|
'success': True,
|
|
'companies': [
|
|
{
|
|
'id': c.id,
|
|
'name': c.name,
|
|
'category': c.category.name if c.category else None,
|
|
'description': c.description_short,
|
|
'website': c.website,
|
|
'phone': c.phone,
|
|
'email': c.email
|
|
}
|
|
for c in companies
|
|
]
|
|
})
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
def _build_seo_audit_response(company, analysis):
|
|
"""
|
|
Helper function to build SEO audit response JSON.
|
|
Used by both /api/seo/audit and /api/seo/audit/<slug> endpoints.
|
|
"""
|
|
# Build issues list from various checks
|
|
issues = []
|
|
|
|
# Check for images without alt
|
|
if analysis.images_without_alt and analysis.images_without_alt > 0:
|
|
issues.append({
|
|
'severity': 'warning',
|
|
'message': f'{analysis.images_without_alt} obrazów nie ma atrybutu alt',
|
|
'category': 'accessibility'
|
|
})
|
|
|
|
# Check for missing meta description
|
|
if not analysis.meta_description:
|
|
issues.append({
|
|
'severity': 'warning',
|
|
'message': 'Brak meta description',
|
|
'category': 'on_page'
|
|
})
|
|
|
|
# Check H1 count (should be exactly 1)
|
|
if analysis.h1_count is not None:
|
|
if analysis.h1_count == 0:
|
|
issues.append({
|
|
'severity': 'error',
|
|
'message': 'Brak nagłówka H1 na stronie',
|
|
'category': 'on_page'
|
|
})
|
|
elif analysis.h1_count > 1:
|
|
issues.append({
|
|
'severity': 'warning',
|
|
'message': f'Strona zawiera {analysis.h1_count} nagłówków H1 (zalecany: 1)',
|
|
'category': 'on_page'
|
|
})
|
|
|
|
# Check SSL
|
|
if analysis.has_ssl is False:
|
|
issues.append({
|
|
'severity': 'error',
|
|
'message': 'Strona nie używa HTTPS (brak certyfikatu SSL)',
|
|
'category': 'security'
|
|
})
|
|
|
|
# Check robots.txt
|
|
if analysis.has_robots_txt is False:
|
|
issues.append({
|
|
'severity': 'info',
|
|
'message': 'Brak pliku robots.txt',
|
|
'category': 'technical'
|
|
})
|
|
|
|
# Check sitemap
|
|
if analysis.has_sitemap is False:
|
|
issues.append({
|
|
'severity': 'info',
|
|
'message': 'Brak pliku sitemap.xml',
|
|
'category': 'technical'
|
|
})
|
|
|
|
# Check indexability
|
|
if analysis.is_indexable is False:
|
|
issues.append({
|
|
'severity': 'error',
|
|
'message': f'Strona nie jest indeksowalna: {analysis.noindex_reason or "nieznana przyczyna"}',
|
|
'category': 'technical'
|
|
})
|
|
|
|
# Check structured data
|
|
if analysis.has_structured_data is False:
|
|
issues.append({
|
|
'severity': 'info',
|
|
'message': 'Brak danych strukturalnych (Schema.org)',
|
|
'category': 'on_page'
|
|
})
|
|
|
|
# Check Open Graph tags
|
|
if analysis.has_og_tags is False:
|
|
issues.append({
|
|
'severity': 'info',
|
|
'message': 'Brak tagów Open Graph (ważne dla udostępniania w social media)',
|
|
'category': 'social'
|
|
})
|
|
|
|
# Check mobile-friendliness
|
|
if analysis.is_mobile_friendly is False:
|
|
issues.append({
|
|
'severity': 'warning',
|
|
'message': 'Strona nie jest przyjazna dla urządzeń mobilnych',
|
|
'category': 'technical'
|
|
})
|
|
|
|
# Add issues from seo_issues JSONB field if available
|
|
if analysis.seo_issues:
|
|
stored_issues = analysis.seo_issues if isinstance(analysis.seo_issues, list) else []
|
|
for issue in stored_issues:
|
|
if isinstance(issue, dict):
|
|
issues.append(issue)
|
|
|
|
# Build response
|
|
return {
|
|
'success': True,
|
|
'company_id': company.id,
|
|
'company_name': company.name,
|
|
'website': company.website,
|
|
'seo_audit': {
|
|
'audited_at': analysis.seo_audited_at.isoformat() if analysis.seo_audited_at else None,
|
|
'audit_version': analysis.seo_audit_version,
|
|
'overall_score': analysis.seo_overall_score,
|
|
'pagespeed': {
|
|
'seo_score': analysis.pagespeed_seo_score,
|
|
'performance_score': analysis.pagespeed_performance_score,
|
|
'accessibility_score': analysis.pagespeed_accessibility_score,
|
|
'best_practices_score': analysis.pagespeed_best_practices_score
|
|
},
|
|
'on_page': {
|
|
'meta_title': analysis.meta_title,
|
|
'meta_description': analysis.meta_description,
|
|
'h1_count': analysis.h1_count,
|
|
'h1_text': analysis.h1_text,
|
|
'h2_count': analysis.h2_count,
|
|
'h3_count': analysis.h3_count,
|
|
'total_images': analysis.total_images,
|
|
'images_without_alt': analysis.images_without_alt,
|
|
'images_with_alt': analysis.images_with_alt,
|
|
'internal_links_count': analysis.internal_links_count,
|
|
'external_links_count': analysis.external_links_count,
|
|
'has_structured_data': analysis.has_structured_data,
|
|
'structured_data_types': analysis.structured_data_types
|
|
},
|
|
'technical': {
|
|
'has_ssl': analysis.has_ssl,
|
|
'ssl_issuer': analysis.ssl_issuer,
|
|
'ssl_expires_at': analysis.ssl_expires_at.isoformat() if analysis.ssl_expires_at else None,
|
|
'has_sitemap': analysis.has_sitemap,
|
|
'has_robots_txt': analysis.has_robots_txt,
|
|
'has_canonical': analysis.has_canonical,
|
|
'canonical_url': analysis.canonical_url,
|
|
'is_indexable': analysis.is_indexable,
|
|
'noindex_reason': analysis.noindex_reason,
|
|
'is_mobile_friendly': analysis.is_mobile_friendly,
|
|
'viewport_configured': analysis.viewport_configured,
|
|
'load_time_ms': analysis.load_time_ms,
|
|
'http_status_code': analysis.http_status_code
|
|
},
|
|
'core_web_vitals': {
|
|
'largest_contentful_paint_ms': analysis.largest_contentful_paint_ms,
|
|
'first_input_delay_ms': analysis.first_input_delay_ms,
|
|
'cumulative_layout_shift': float(analysis.cumulative_layout_shift) if analysis.cumulative_layout_shift else None
|
|
},
|
|
'social': {
|
|
'has_og_tags': analysis.has_og_tags,
|
|
'og_title': analysis.og_title,
|
|
'og_description': analysis.og_description,
|
|
'og_image': analysis.og_image,
|
|
'has_twitter_cards': analysis.has_twitter_cards
|
|
},
|
|
'language': {
|
|
'html_lang': analysis.html_lang,
|
|
'has_hreflang': analysis.has_hreflang
|
|
},
|
|
'issues': issues
|
|
}
|
|
}
|
|
|
|
|
|
def _get_seo_audit_for_company(db, company):
|
|
"""
|
|
Helper function to get SEO audit data for a company.
|
|
Returns tuple of (response_dict, status_code) or (None, None) if audit exists.
|
|
"""
|
|
# Get latest SEO audit for this company
|
|
analysis = db.query(CompanyWebsiteAnalysis).filter_by(
|
|
company_id=company.id
|
|
).order_by(CompanyWebsiteAnalysis.analyzed_at.desc()).first()
|
|
|
|
if not analysis:
|
|
return {
|
|
'success': True,
|
|
'company_id': company.id,
|
|
'company_name': company.name,
|
|
'website': company.website,
|
|
'seo_audit': None,
|
|
'message': 'Brak danych SEO dla tej firmy. Audyt nie został jeszcze przeprowadzony.'
|
|
}, 200
|
|
|
|
# Check if SEO audit was performed (seo_audited_at is set)
|
|
if not analysis.seo_audited_at:
|
|
return {
|
|
'success': True,
|
|
'company_id': company.id,
|
|
'company_name': company.name,
|
|
'website': company.website,
|
|
'seo_audit': None,
|
|
'message': 'Audyt SEO nie został jeszcze przeprowadzony dla tej firmy.'
|
|
}, 200
|
|
|
|
# Build full response
|
|
return _build_seo_audit_response(company, analysis), 200
|
|
|
|
|
|
@app.route('/api/seo/audit')
|
|
def api_seo_audit():
|
|
"""
|
|
API: Get SEO audit results for a company.
|
|
|
|
Query parameters:
|
|
- company_id: Company ID (integer)
|
|
- slug: Company slug (string)
|
|
|
|
At least one of company_id or slug must be provided.
|
|
|
|
Returns JSON with:
|
|
- pagespeed scores (seo, performance, accessibility, best_practices)
|
|
- on_page metrics (meta tags, headings, images, links, structured data)
|
|
- technical checks (ssl, sitemap, robots.txt, mobile-friendly)
|
|
- issues list with severity levels
|
|
"""
|
|
company_id = request.args.get('company_id', type=int)
|
|
slug = request.args.get('slug', type=str)
|
|
|
|
if not company_id and not slug:
|
|
return jsonify({
|
|
'success': False,
|
|
'error': 'Podaj company_id lub slug firmy'
|
|
}), 400
|
|
|
|
db = SessionLocal()
|
|
try:
|
|
# Find company by ID or slug
|
|
if company_id:
|
|
company = db.query(Company).filter_by(id=company_id, status='active').first()
|
|
else:
|
|
company = db.query(Company).filter_by(slug=slug, status='active').first()
|
|
|
|
if not company:
|
|
return jsonify({
|
|
'success': False,
|
|
'error': 'Firma nie znaleziona'
|
|
}), 404
|
|
|
|
response, status_code = _get_seo_audit_for_company(db, company)
|
|
return jsonify(response), status_code
|
|
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
@app.route('/api/seo/audit/<slug>')
|
|
def api_seo_audit_by_slug(slug):
|
|
"""
|
|
API: Get SEO audit results for a company by slug.
|
|
Convenience endpoint that uses slug from URL path.
|
|
|
|
Example: GET /api/seo/audit/pixlab-sp-z-o-o
|
|
"""
|
|
db = SessionLocal()
|
|
try:
|
|
# Find company by slug
|
|
company = db.query(Company).filter_by(slug=slug, status='active').first()
|
|
|
|
if not company:
|
|
return jsonify({
|
|
'success': False,
|
|
'error': 'Firma nie znaleziona'
|
|
}), 404
|
|
|
|
response, status_code = _get_seo_audit_for_company(db, company)
|
|
return jsonify(response), status_code
|
|
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
@app.route('/api/seo/audit', methods=['POST'])
|
|
@login_required
|
|
@limiter.limit("10 per hour")
|
|
def api_seo_audit_trigger():
|
|
"""
|
|
API: Trigger SEO audit for a company (admin-only).
|
|
|
|
This endpoint runs a full SEO audit including:
|
|
- Google PageSpeed Insights analysis
|
|
- On-page SEO analysis (meta tags, headings, images, links)
|
|
- Technical SEO checks (robots.txt, sitemap, canonical URLs)
|
|
|
|
Request JSON body:
|
|
- company_id: Company ID (integer) OR
|
|
- slug: Company slug (string)
|
|
|
|
Returns:
|
|
- Success: Full SEO audit results saved to database
|
|
- Error: Error message with status code
|
|
|
|
Rate limited to 10 requests per hour per user to prevent API abuse.
|
|
"""
|
|
# Admin-only check
|
|
if not current_user.is_admin:
|
|
return jsonify({
|
|
'success': False,
|
|
'error': 'Brak uprawnień. Tylko administrator może uruchamiać audyty SEO.'
|
|
}), 403
|
|
|
|
# Check if SEO audit service is available
|
|
if not SEO_AUDIT_AVAILABLE:
|
|
return jsonify({
|
|
'success': False,
|
|
'error': 'Usługa audytu SEO jest niedostępna. Sprawdź konfigurację serwera.'
|
|
}), 503
|
|
|
|
# Parse request data
|
|
data = request.get_json()
|
|
if not data:
|
|
return jsonify({
|
|
'success': False,
|
|
'error': 'Brak danych w żądaniu. Podaj company_id lub slug.'
|
|
}), 400
|
|
|
|
company_id = data.get('company_id')
|
|
slug = data.get('slug')
|
|
|
|
if not company_id and not slug:
|
|
return jsonify({
|
|
'success': False,
|
|
'error': 'Podaj company_id lub slug firmy do audytu.'
|
|
}), 400
|
|
|
|
db = SessionLocal()
|
|
try:
|
|
# Find company by ID or slug
|
|
if company_id:
|
|
company = db.query(Company).filter_by(id=company_id, status='active').first()
|
|
else:
|
|
company = db.query(Company).filter_by(slug=slug, status='active').first()
|
|
|
|
if not company:
|
|
return jsonify({
|
|
'success': False,
|
|
'error': 'Firma nie znaleziona lub nieaktywna.'
|
|
}), 404
|
|
|
|
# Check if company has a website
|
|
if not company.website:
|
|
return jsonify({
|
|
'success': False,
|
|
'error': f'Firma "{company.name}" nie ma zdefiniowanej strony internetowej.',
|
|
'company_id': company.id,
|
|
'company_name': company.name
|
|
}), 400
|
|
|
|
logger.info(f"SEO audit triggered by admin {current_user.email} for company: {company.name} (ID: {company.id})")
|
|
|
|
# Initialize SEO auditor and run audit
|
|
try:
|
|
auditor = SEOAuditor()
|
|
|
|
# Prepare company dict for auditor
|
|
company_dict = {
|
|
'id': company.id,
|
|
'name': company.name,
|
|
'slug': company.slug,
|
|
'website': company.website,
|
|
'address_city': company.address_city
|
|
}
|
|
|
|
# Run the audit
|
|
audit_result = auditor.audit_company(company_dict)
|
|
|
|
# Check for errors
|
|
if audit_result.get('errors') and not audit_result.get('onpage') and not audit_result.get('pagespeed'):
|
|
return jsonify({
|
|
'success': False,
|
|
'error': f'Audyt nie powiódł się: {", ".join(audit_result["errors"])}',
|
|
'company_id': company.id,
|
|
'company_name': company.name,
|
|
'website': company.website
|
|
}), 422
|
|
|
|
# Save result to database
|
|
saved = auditor.save_audit_result(audit_result)
|
|
|
|
if not saved:
|
|
return jsonify({
|
|
'success': False,
|
|
'error': 'Audyt został wykonany, ale nie udało się zapisać wyników do bazy danych.',
|
|
'company_id': company.id,
|
|
'company_name': company.name
|
|
}), 500
|
|
|
|
# Get the updated analysis record to return
|
|
db.expire_all() # Refresh the session to get updated data
|
|
analysis = db.query(CompanyWebsiteAnalysis).filter_by(
|
|
company_id=company.id
|
|
).order_by(CompanyWebsiteAnalysis.analyzed_at.desc()).first()
|
|
|
|
# Build response using the existing helper function
|
|
response = _build_seo_audit_response(company, analysis)
|
|
|
|
return jsonify({
|
|
'success': True,
|
|
'message': f'Audyt SEO dla firmy "{company.name}" został zakończony pomyślnie.',
|
|
'audit_version': SEO_AUDIT_VERSION,
|
|
'triggered_by': current_user.email,
|
|
'triggered_at': datetime.now().isoformat(),
|
|
**response
|
|
}), 200
|
|
|
|
except Exception as e:
|
|
logger.error(f"SEO audit error for company {company.id}: {e}")
|
|
return jsonify({
|
|
'success': False,
|
|
'error': f'Błąd podczas wykonywania audytu: {str(e)}',
|
|
'company_id': company.id,
|
|
'company_name': company.name
|
|
}), 500
|
|
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
@app.route('/api/check-email', methods=['POST'])
|
|
def api_check_email():
|
|
"""API: Check if email is available"""
|
|
data = request.get_json()
|
|
email = data.get('email', '').strip().lower()
|
|
|
|
# Validate email format
|
|
if not email or not validate_email(email):
|
|
return jsonify({
|
|
'available': False,
|
|
'error': 'Nieprawidłowy format email'
|
|
}), 400
|
|
|
|
db = SessionLocal()
|
|
try:
|
|
# Check if email exists
|
|
existing_user = db.query(User).filter_by(email=email).first()
|
|
|
|
return jsonify({
|
|
'available': existing_user is None,
|
|
'email': email
|
|
})
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
@app.route('/api/verify-nip', methods=['POST'])
|
|
def api_verify_nip():
|
|
"""API: Verify NIP and check if company is NORDA member"""
|
|
data = request.get_json()
|
|
nip = data.get('nip', '').strip()
|
|
|
|
# Validate NIP format
|
|
if not nip or not re.match(r'^\d{10}$', nip):
|
|
return jsonify({
|
|
'success': False,
|
|
'error': 'Nieprawidłowy format NIP'
|
|
}), 400
|
|
|
|
db = SessionLocal()
|
|
try:
|
|
# Check if NIP exists in companies database
|
|
company = db.query(Company).filter_by(nip=nip, status='active').first()
|
|
|
|
if company:
|
|
return jsonify({
|
|
'success': True,
|
|
'is_member': True,
|
|
'company_name': company.name,
|
|
'company_id': company.id
|
|
})
|
|
else:
|
|
return jsonify({
|
|
'success': True,
|
|
'is_member': False,
|
|
'company_name': None,
|
|
'company_id': None
|
|
})
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
@app.route('/api/verify-krs', methods=['GET', 'POST'])
|
|
def api_verify_krs():
|
|
"""
|
|
API: Verify company data from KRS Open API (prs.ms.gov.pl).
|
|
|
|
GET /api/verify-krs?krs=0000817317
|
|
POST /api/verify-krs with JSON body: {"krs": "0000817317"}
|
|
|
|
Returns official KRS data including:
|
|
- Company name, NIP, REGON
|
|
- Address
|
|
- Capital
|
|
- Registration date
|
|
- Management board (anonymized in Open API)
|
|
- Shareholders (anonymized in Open API)
|
|
"""
|
|
# Get KRS from query params (GET) or JSON body (POST)
|
|
if request.method == 'GET':
|
|
krs = request.args.get('krs', '').strip()
|
|
else:
|
|
data = request.get_json(silent=True) or {}
|
|
krs = data.get('krs', '').strip()
|
|
|
|
# Validate KRS format (7-10 digits)
|
|
if not krs or not re.match(r'^\d{7,10}$', krs):
|
|
return jsonify({
|
|
'success': False,
|
|
'error': 'Nieprawidłowy format KRS (wymagane 7-10 cyfr)'
|
|
}), 400
|
|
|
|
# Normalize to 10 digits
|
|
krs_normalized = krs.zfill(10)
|
|
|
|
try:
|
|
# Fetch data from KRS Open API
|
|
krs_data = krs_api_service.get_company_from_krs(krs_normalized)
|
|
|
|
if krs_data is None:
|
|
return jsonify({
|
|
'success': False,
|
|
'error': f'Nie znaleziono podmiotu o KRS {krs_normalized} w rejestrze',
|
|
'krs': krs_normalized
|
|
}), 404
|
|
|
|
# Check if company exists in our database
|
|
db = SessionLocal()
|
|
try:
|
|
our_company = db.query(Company).filter_by(krs=krs_normalized).first()
|
|
is_member = our_company is not None
|
|
company_id = our_company.id if our_company else None
|
|
finally:
|
|
db.close()
|
|
|
|
return jsonify({
|
|
'success': True,
|
|
'krs': krs_normalized,
|
|
'is_norda_member': is_member,
|
|
'company_id': company_id,
|
|
'data': krs_data.to_dict(),
|
|
'formatted_address': krs_api_service.format_address(krs_data),
|
|
'source': 'KRS Open API (prs.ms.gov.pl)',
|
|
'note': 'Dane osobowe (imiona, nazwiska) są zanonimizowane w Open API'
|
|
})
|
|
|
|
except Exception as e:
|
|
return jsonify({
|
|
'success': False,
|
|
'error': f'Błąd podczas pobierania danych z KRS: {str(e)}'
|
|
}), 500
|
|
|
|
|
|
@app.route('/api/company/<int:company_id>/refresh-krs', methods=['POST'])
|
|
@login_required
|
|
def api_refresh_company_krs(company_id):
|
|
"""
|
|
API: Refresh company data from KRS Open API.
|
|
Updates company record with official KRS data.
|
|
Requires login.
|
|
"""
|
|
db = SessionLocal()
|
|
try:
|
|
company = db.query(Company).filter_by(id=company_id).first()
|
|
|
|
if not company:
|
|
return jsonify({
|
|
'success': False,
|
|
'error': 'Firma nie znaleziona'
|
|
}), 404
|
|
|
|
if not company.krs:
|
|
return jsonify({
|
|
'success': False,
|
|
'error': 'Firma nie ma numeru KRS'
|
|
}), 400
|
|
|
|
# Fetch data from KRS
|
|
krs_data = krs_api_service.get_company_from_krs(company.krs)
|
|
|
|
if krs_data is None:
|
|
return jsonify({
|
|
'success': False,
|
|
'error': f'Nie znaleziono podmiotu o KRS {company.krs} w rejestrze'
|
|
}), 404
|
|
|
|
# Update company data (only non-personal data)
|
|
updates = {}
|
|
|
|
if krs_data.nip and krs_data.nip != company.nip:
|
|
updates['nip'] = krs_data.nip
|
|
company.nip = krs_data.nip
|
|
|
|
if krs_data.regon:
|
|
regon_9 = krs_data.regon[:9]
|
|
if regon_9 != company.regon:
|
|
updates['regon'] = regon_9
|
|
company.regon = regon_9
|
|
|
|
# Update address if significantly different
|
|
new_address = krs_api_service.format_address(krs_data)
|
|
if new_address and new_address != company.address:
|
|
updates['address'] = new_address
|
|
company.address = new_address
|
|
|
|
if krs_data.miejscowosc and krs_data.miejscowosc != company.city:
|
|
updates['city'] = krs_data.miejscowosc
|
|
company.city = krs_data.miejscowosc
|
|
|
|
if krs_data.kapital_zakladowy:
|
|
updates['kapital_zakladowy'] = krs_data.kapital_zakladowy
|
|
# Note: Might need to add this field to Company model
|
|
|
|
# Update verification timestamp
|
|
company.krs_verified_at = datetime.utcnow()
|
|
|
|
db.commit()
|
|
|
|
return jsonify({
|
|
'success': True,
|
|
'company_id': company_id,
|
|
'updates': updates,
|
|
'krs_data': krs_data.to_dict(),
|
|
'message': f'Zaktualizowano {len(updates)} pól' if updates else 'Dane są aktualne'
|
|
})
|
|
|
|
except Exception as e:
|
|
db.rollback()
|
|
return jsonify({
|
|
'success': False,
|
|
'error': f'Błąd podczas aktualizacji: {str(e)}'
|
|
}), 500
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
@app.route('/api/model-info', methods=['GET'])
|
|
def api_model_info():
|
|
"""API: Get current AI model information"""
|
|
service = gemini_service.get_gemini_service()
|
|
if service:
|
|
return jsonify({
|
|
'success': True,
|
|
'model': service.model_name,
|
|
'provider': 'Google Gemini'
|
|
})
|
|
else:
|
|
return jsonify({
|
|
'success': False,
|
|
'error': 'AI service not initialized'
|
|
}), 500
|
|
|
|
|
|
# ============================================================
|
|
# AI CHAT FEEDBACK & ANALYTICS
|
|
# ============================================================
|
|
|
|
@app.route('/api/chat/feedback', methods=['POST'])
|
|
@login_required
|
|
def chat_feedback():
|
|
"""API: Submit feedback for AI response"""
|
|
try:
|
|
data = request.get_json()
|
|
message_id = data.get('message_id')
|
|
rating = data.get('rating') # 1 = thumbs down, 2 = thumbs up
|
|
|
|
if not message_id or rating not in [1, 2]:
|
|
return jsonify({'success': False, 'error': 'Invalid data'}), 400
|
|
|
|
db = SessionLocal()
|
|
try:
|
|
# Verify message exists and belongs to user's conversation
|
|
message = db.query(AIChatMessage).filter_by(id=message_id).first()
|
|
if not message:
|
|
return jsonify({'success': False, 'error': 'Message not found'}), 404
|
|
|
|
conversation = db.query(AIChatConversation).filter_by(
|
|
id=message.conversation_id,
|
|
user_id=current_user.id
|
|
).first()
|
|
if not conversation:
|
|
return jsonify({'success': False, 'error': 'Not authorized'}), 403
|
|
|
|
# Update message feedback
|
|
message.feedback_rating = rating
|
|
message.feedback_at = datetime.now()
|
|
message.feedback_comment = data.get('comment', '')
|
|
|
|
# Create detailed feedback record if provided
|
|
if data.get('is_helpful') is not None or data.get('comment'):
|
|
existing_feedback = db.query(AIChatFeedback).filter_by(message_id=message_id).first()
|
|
if existing_feedback:
|
|
existing_feedback.rating = rating
|
|
existing_feedback.is_helpful = data.get('is_helpful')
|
|
existing_feedback.is_accurate = data.get('is_accurate')
|
|
existing_feedback.found_company = data.get('found_company')
|
|
existing_feedback.comment = data.get('comment')
|
|
else:
|
|
feedback = AIChatFeedback(
|
|
message_id=message_id,
|
|
user_id=current_user.id,
|
|
rating=rating,
|
|
is_helpful=data.get('is_helpful'),
|
|
is_accurate=data.get('is_accurate'),
|
|
found_company=data.get('found_company'),
|
|
comment=data.get('comment'),
|
|
original_query=data.get('original_query'),
|
|
expected_companies=data.get('expected_companies')
|
|
)
|
|
db.add(feedback)
|
|
|
|
db.commit()
|
|
logger.info(f"Feedback received: message_id={message_id}, rating={rating}")
|
|
|
|
return jsonify({'success': True})
|
|
|
|
finally:
|
|
db.close()
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error saving feedback: {e}")
|
|
return jsonify({'success': False, 'error': str(e)}), 500
|
|
|
|
|
|
@app.route('/admin/chat-analytics')
|
|
@login_required
|
|
def chat_analytics():
|
|
"""Admin dashboard for chat analytics"""
|
|
# Only admins can access
|
|
if not current_user.is_admin:
|
|
flash('Brak uprawnień do tej strony.', 'error')
|
|
return redirect(url_for('dashboard'))
|
|
|
|
db = SessionLocal()
|
|
try:
|
|
from sqlalchemy import func, desc
|
|
|
|
# Basic stats
|
|
total_conversations = db.query(AIChatConversation).count()
|
|
total_messages = db.query(AIChatMessage).count()
|
|
total_user_messages = db.query(AIChatMessage).filter_by(role='user').count()
|
|
|
|
# Feedback stats
|
|
feedback_count = db.query(AIChatMessage).filter(AIChatMessage.feedback_rating.isnot(None)).count()
|
|
positive_feedback = db.query(AIChatMessage).filter_by(feedback_rating=2).count()
|
|
negative_feedback = db.query(AIChatMessage).filter_by(feedback_rating=1).count()
|
|
|
|
# Recent conversations with feedback
|
|
recent_feedback = db.query(AIChatMessage).filter(
|
|
AIChatMessage.feedback_rating.isnot(None)
|
|
).order_by(desc(AIChatMessage.feedback_at)).limit(20).all()
|
|
|
|
# Popular queries (user messages)
|
|
recent_queries = db.query(AIChatMessage).filter_by(role='user').order_by(
|
|
desc(AIChatMessage.created_at)
|
|
).limit(50).all()
|
|
|
|
# Calculate satisfaction rate
|
|
satisfaction_rate = (positive_feedback / feedback_count * 100) if feedback_count > 0 else 0
|
|
|
|
return render_template(
|
|
'admin/chat_analytics.html',
|
|
total_conversations=total_conversations,
|
|
total_messages=total_messages,
|
|
total_user_messages=total_user_messages,
|
|
feedback_count=feedback_count,
|
|
positive_feedback=positive_feedback,
|
|
negative_feedback=negative_feedback,
|
|
satisfaction_rate=round(satisfaction_rate, 1),
|
|
recent_feedback=recent_feedback,
|
|
recent_queries=recent_queries
|
|
)
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
@app.route('/api/admin/chat-stats')
|
|
@login_required
|
|
def api_chat_stats():
|
|
"""API: Get chat statistics for dashboard"""
|
|
if not current_user.is_admin:
|
|
return jsonify({'success': False, 'error': 'Not authorized'}), 403
|
|
|
|
db = SessionLocal()
|
|
try:
|
|
from sqlalchemy import func, desc
|
|
from datetime import timedelta
|
|
|
|
# Stats for last 7 days
|
|
week_ago = datetime.now() - timedelta(days=7)
|
|
|
|
daily_stats = db.query(
|
|
func.date(AIChatMessage.created_at).label('date'),
|
|
func.count(AIChatMessage.id).label('count')
|
|
).filter(
|
|
AIChatMessage.created_at >= week_ago,
|
|
AIChatMessage.role == 'user'
|
|
).group_by(
|
|
func.date(AIChatMessage.created_at)
|
|
).order_by('date').all()
|
|
|
|
return jsonify({
|
|
'success': True,
|
|
'daily_queries': [{'date': str(d.date), 'count': d.count} for d in daily_stats]
|
|
})
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
# ============================================================
|
|
# DEBUG PANEL (Admin only)
|
|
# ============================================================
|
|
|
|
@app.route('/admin/debug')
|
|
@login_required
|
|
def debug_panel():
|
|
"""Real-time debug panel for monitoring app activity"""
|
|
if not current_user.is_admin:
|
|
flash('Brak uprawnień do tej strony.', 'error')
|
|
return redirect(url_for('dashboard'))
|
|
return render_template('admin/debug.html')
|
|
|
|
|
|
@app.route('/api/admin/logs')
|
|
@login_required
|
|
def api_get_logs():
|
|
"""API: Get recent logs"""
|
|
if not current_user.is_admin:
|
|
return jsonify({'success': False, 'error': 'Not authorized'}), 403
|
|
|
|
# Get optional filters
|
|
level = request.args.get('level', '') # DEBUG, INFO, WARNING, ERROR
|
|
since = request.args.get('since', '') # ISO timestamp
|
|
limit = min(int(request.args.get('limit', 100)), 500)
|
|
|
|
logs = list(debug_handler.logs)
|
|
|
|
# Filter by level
|
|
if level:
|
|
logs = [l for l in logs if l['level'] == level.upper()]
|
|
|
|
# Filter by timestamp
|
|
if since:
|
|
logs = [l for l in logs if l['timestamp'] > since]
|
|
|
|
# Return most recent
|
|
logs = logs[-limit:]
|
|
|
|
return jsonify({
|
|
'success': True,
|
|
'logs': logs,
|
|
'total': len(debug_handler.logs)
|
|
})
|
|
|
|
|
|
@app.route('/api/admin/logs/stream')
|
|
@login_required
|
|
def api_logs_stream():
|
|
"""SSE endpoint for real-time log streaming"""
|
|
if not current_user.is_admin:
|
|
return jsonify({'success': False, 'error': 'Not authorized'}), 403
|
|
|
|
def generate():
|
|
last_count = 0
|
|
while True:
|
|
current_count = len(debug_handler.logs)
|
|
if current_count > last_count:
|
|
# Send new logs
|
|
new_logs = list(debug_handler.logs)[last_count:]
|
|
for log in new_logs:
|
|
yield f"data: {json.dumps(log)}\n\n"
|
|
last_count = current_count
|
|
import time
|
|
time.sleep(0.5)
|
|
|
|
return Response(generate(), mimetype='text/event-stream')
|
|
|
|
|
|
@app.route('/api/admin/logs/clear', methods=['POST'])
|
|
@login_required
|
|
def api_clear_logs():
|
|
"""API: Clear log buffer"""
|
|
if not current_user.is_admin:
|
|
return jsonify({'success': False, 'error': 'Not authorized'}), 403
|
|
|
|
debug_handler.logs.clear()
|
|
logger.info("Log buffer cleared by admin")
|
|
return jsonify({'success': True})
|
|
|
|
|
|
@app.route('/api/admin/test-log', methods=['POST'])
|
|
@login_required
|
|
def api_test_log():
|
|
"""API: Generate test log entries"""
|
|
if not current_user.is_admin:
|
|
return jsonify({'success': False, 'error': 'Not authorized'}), 403
|
|
|
|
logger.debug("Test DEBUG message")
|
|
logger.info("Test INFO message")
|
|
logger.warning("Test WARNING message")
|
|
logger.error("Test ERROR message")
|
|
return jsonify({'success': True, 'message': 'Test logs generated'})
|
|
|
|
|
|
@app.route('/admin/digital-maturity')
|
|
@login_required
|
|
def digital_maturity_dashboard():
|
|
"""Admin dashboard for digital maturity assessment results"""
|
|
if not current_user.is_admin:
|
|
flash('Brak uprawnień do tej strony.', 'error')
|
|
return redirect(url_for('dashboard'))
|
|
|
|
db = SessionLocal()
|
|
try:
|
|
from sqlalchemy import func, desc
|
|
|
|
# Get all companies with maturity data
|
|
companies_query = db.query(
|
|
Company.id,
|
|
Company.name,
|
|
Company.slug,
|
|
Company.website,
|
|
CompanyDigitalMaturity.overall_score,
|
|
CompanyDigitalMaturity.online_presence_score,
|
|
CompanyDigitalMaturity.sales_readiness,
|
|
CompanyDigitalMaturity.total_opportunity_value,
|
|
CompanyWebsiteAnalysis.opportunity_score,
|
|
CompanyWebsiteAnalysis.has_blog,
|
|
CompanyWebsiteAnalysis.has_portfolio,
|
|
CompanyWebsiteAnalysis.has_contact_form,
|
|
CompanyWebsiteAnalysis.content_richness_score,
|
|
CompanyDigitalMaturity.critical_gaps,
|
|
CompanyWebsiteAnalysis.missing_features
|
|
).join(
|
|
CompanyDigitalMaturity, Company.id == CompanyDigitalMaturity.company_id
|
|
).join(
|
|
CompanyWebsiteAnalysis, Company.id == CompanyWebsiteAnalysis.company_id
|
|
).filter(
|
|
CompanyDigitalMaturity.overall_score > 0
|
|
).order_by(
|
|
desc(CompanyDigitalMaturity.overall_score)
|
|
).all()
|
|
|
|
# Calculate stats
|
|
total_analyzed = len(companies_query)
|
|
avg_score = round(sum(c.overall_score for c in companies_query) / total_analyzed, 1) if total_analyzed else 0
|
|
total_opportunity = sum(float(c.total_opportunity_value or 0) for c in companies_query)
|
|
|
|
warm_leads = [c for c in companies_query if c.sales_readiness == 'warm']
|
|
cold_leads = [c for c in companies_query if c.sales_readiness == 'cold']
|
|
|
|
# Top 10 and bottom 10
|
|
top_performers = companies_query[:10]
|
|
bottom_performers = sorted(companies_query, key=lambda c: c.overall_score)[:10]
|
|
|
|
# Top opportunities
|
|
top_opportunities = sorted(
|
|
companies_query,
|
|
key=lambda c: float(c.total_opportunity_value or 0),
|
|
reverse=True
|
|
)[:10]
|
|
|
|
return render_template('admin/digital_maturity.html',
|
|
total_analyzed=total_analyzed,
|
|
avg_score=avg_score,
|
|
total_opportunity=total_opportunity,
|
|
warm_leads_count=len(warm_leads),
|
|
cold_leads_count=len(cold_leads),
|
|
top_performers=top_performers,
|
|
bottom_performers=bottom_performers,
|
|
top_opportunities=top_opportunities,
|
|
all_companies=companies_query
|
|
)
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
@app.route('/admin/social-media')
|
|
@login_required
|
|
def admin_social_media():
|
|
"""Admin dashboard for social media analytics"""
|
|
if not current_user.is_admin:
|
|
flash('Brak uprawnień do tej strony.', 'error')
|
|
return redirect(url_for('dashboard'))
|
|
|
|
db = SessionLocal()
|
|
try:
|
|
from sqlalchemy import func, case, distinct
|
|
from database import CompanySocialMedia
|
|
|
|
# Total counts per platform
|
|
platform_stats = db.query(
|
|
CompanySocialMedia.platform,
|
|
func.count(CompanySocialMedia.id).label('count'),
|
|
func.count(distinct(CompanySocialMedia.company_id)).label('companies')
|
|
).filter(
|
|
CompanySocialMedia.is_valid == True
|
|
).group_by(CompanySocialMedia.platform).all()
|
|
|
|
# Companies with each platform combination
|
|
company_platforms = db.query(
|
|
Company.id,
|
|
Company.name,
|
|
Company.slug,
|
|
func.array_agg(distinct(CompanySocialMedia.platform)).label('platforms')
|
|
).outerjoin(
|
|
CompanySocialMedia,
|
|
(Company.id == CompanySocialMedia.company_id) & (CompanySocialMedia.is_valid == True)
|
|
).group_by(Company.id, Company.name, Company.slug).all()
|
|
|
|
# Analysis
|
|
total_companies = len(company_platforms)
|
|
companies_with_sm = [c for c in company_platforms if c.platforms and c.platforms[0] is not None]
|
|
companies_without_sm = [c for c in company_platforms if not c.platforms or c.platforms[0] is None]
|
|
|
|
# Platform combinations
|
|
platform_combos_raw = {}
|
|
for c in companies_with_sm:
|
|
platforms = sorted([p for p in c.platforms if p]) if c.platforms else []
|
|
key = ', '.join(platforms) if platforms else 'Brak'
|
|
if key not in platform_combos_raw:
|
|
platform_combos_raw[key] = []
|
|
platform_combos_raw[key].append({'id': c.id, 'name': c.name, 'slug': c.slug})
|
|
|
|
# Sort by number of companies (descending)
|
|
platform_combos = dict(sorted(platform_combos_raw.items(), key=lambda x: len(x[1]), reverse=True))
|
|
|
|
# Only Facebook
|
|
only_facebook = [c for c in companies_with_sm if set(c.platforms) == {'facebook'}]
|
|
# Only LinkedIn
|
|
only_linkedin = [c for c in companies_with_sm if set(c.platforms) == {'linkedin'}]
|
|
# Only Instagram
|
|
only_instagram = [c for c in companies_with_sm if set(c.platforms) == {'instagram'}]
|
|
# Has all major (FB + LI + IG)
|
|
has_all_major = [c for c in companies_with_sm if {'facebook', 'linkedin', 'instagram'}.issubset(set(c.platforms or []))]
|
|
|
|
# Get all social media entries with company info for detailed view
|
|
all_entries = db.query(
|
|
CompanySocialMedia,
|
|
Company.name.label('company_name'),
|
|
Company.slug.label('company_slug')
|
|
).join(Company).order_by(
|
|
Company.name, CompanySocialMedia.platform
|
|
).all()
|
|
|
|
# Freshness analysis
|
|
from datetime import datetime, timedelta
|
|
now = datetime.now()
|
|
fresh_30d = db.query(func.count(CompanySocialMedia.id)).filter(
|
|
CompanySocialMedia.verified_at >= now - timedelta(days=30)
|
|
).scalar()
|
|
stale_90d = db.query(func.count(CompanySocialMedia.id)).filter(
|
|
CompanySocialMedia.verified_at < now - timedelta(days=90)
|
|
).scalar()
|
|
|
|
return render_template('admin/social_media.html',
|
|
platform_stats=platform_stats,
|
|
total_companies=total_companies,
|
|
companies_with_sm=len(companies_with_sm),
|
|
companies_without_sm=companies_without_sm,
|
|
platform_combos=platform_combos,
|
|
only_facebook=only_facebook,
|
|
only_linkedin=only_linkedin,
|
|
only_instagram=only_instagram,
|
|
has_all_major=has_all_major,
|
|
all_entries=all_entries,
|
|
fresh_30d=fresh_30d,
|
|
stale_90d=stale_90d,
|
|
now=now
|
|
)
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
# ============================================================
|
|
# MEMBERSHIP FEES ADMIN
|
|
# ============================================================
|
|
|
|
MONTHS_PL = [
|
|
(1, 'Styczen'), (2, 'Luty'), (3, 'Marzec'), (4, 'Kwiecien'),
|
|
(5, 'Maj'), (6, 'Czerwiec'), (7, 'Lipiec'), (8, 'Sierpien'),
|
|
(9, 'Wrzesien'), (10, 'Pazdziernik'), (11, 'Listopad'), (12, 'Grudzien')
|
|
]
|
|
|
|
|
|
@app.route('/admin/fees')
|
|
@login_required
|
|
def admin_fees():
|
|
"""Admin panel for membership fee management"""
|
|
if not current_user.is_admin:
|
|
flash('Brak uprawnien do tej strony.', 'error')
|
|
return redirect(url_for('index'))
|
|
|
|
db = SessionLocal()
|
|
try:
|
|
from sqlalchemy import func, case
|
|
from decimal import Decimal
|
|
|
|
# Get filter parameters
|
|
year = request.args.get('year', datetime.now().year, type=int)
|
|
month = request.args.get('month', type=int)
|
|
status_filter = request.args.get('status', '')
|
|
|
|
# Get all active companies
|
|
companies = db.query(Company).filter(Company.status == 'active').order_by(Company.name).all()
|
|
|
|
# Get fees for selected period
|
|
fee_query = db.query(MembershipFee).filter(MembershipFee.fee_year == year)
|
|
if month:
|
|
fee_query = fee_query.filter(MembershipFee.fee_month == month)
|
|
|
|
fees = {(f.company_id, f.fee_month): f for f in fee_query.all()}
|
|
|
|
# Build company list with fee status
|
|
companies_fees = []
|
|
for company in companies:
|
|
if month:
|
|
fee = fees.get((company.id, month))
|
|
companies_fees.append({
|
|
'company': company,
|
|
'fee': fee,
|
|
'status': fee.status if fee else 'brak'
|
|
})
|
|
else:
|
|
# Show all months
|
|
company_data = {'company': company, 'months': {}}
|
|
for m in range(1, 13):
|
|
fee = fees.get((company.id, m))
|
|
company_data['months'][m] = fee
|
|
companies_fees.append(company_data)
|
|
|
|
# Apply status filter
|
|
if status_filter and month:
|
|
if status_filter == 'paid':
|
|
companies_fees = [cf for cf in companies_fees if cf.get('status') == 'paid']
|
|
elif status_filter == 'pending':
|
|
companies_fees = [cf for cf in companies_fees if cf.get('status') in ('pending', 'brak')]
|
|
elif status_filter == 'overdue':
|
|
companies_fees = [cf for cf in companies_fees if cf.get('status') == 'overdue']
|
|
|
|
# Calculate stats
|
|
total_companies = len(companies)
|
|
if month:
|
|
month_fees = [cf.get('fee') for cf in companies_fees if cf.get('fee')]
|
|
paid_count = sum(1 for f in month_fees if f and f.status == 'paid')
|
|
pending_count = total_companies - paid_count
|
|
total_due = sum(float(f.amount) for f in month_fees if f) if month_fees else Decimal(0)
|
|
total_paid = sum(float(f.amount_paid or 0) for f in month_fees if f) if month_fees else Decimal(0)
|
|
else:
|
|
all_fees = list(fees.values())
|
|
paid_count = sum(1 for f in all_fees if f.status == 'paid')
|
|
pending_count = len(all_fees) - paid_count
|
|
total_due = sum(float(f.amount) for f in all_fees) if all_fees else Decimal(0)
|
|
total_paid = sum(float(f.amount_paid or 0) for f in all_fees) if all_fees else Decimal(0)
|
|
|
|
# Get default fee amount
|
|
fee_config = db.query(MembershipFeeConfig).filter(
|
|
MembershipFeeConfig.scope == 'global',
|
|
MembershipFeeConfig.valid_until == None
|
|
).first()
|
|
default_fee = float(fee_config.monthly_amount) if fee_config else 100.00
|
|
|
|
return render_template(
|
|
'admin/fees.html',
|
|
companies_fees=companies_fees,
|
|
year=year,
|
|
month=month,
|
|
status_filter=status_filter,
|
|
total_companies=total_companies,
|
|
paid_count=paid_count,
|
|
pending_count=pending_count,
|
|
total_due=total_due,
|
|
total_paid=total_paid,
|
|
default_fee=default_fee,
|
|
years=list(range(2024, datetime.now().year + 2)),
|
|
months=MONTHS_PL
|
|
)
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
@app.route('/admin/fees/generate', methods=['POST'])
|
|
@login_required
|
|
def admin_fees_generate():
|
|
"""Generate fee records for all companies for a given month"""
|
|
if not current_user.is_admin:
|
|
return jsonify({'success': False, 'error': 'Brak uprawnien'}), 403
|
|
|
|
db = SessionLocal()
|
|
try:
|
|
year = request.form.get('year', type=int)
|
|
month = request.form.get('month', type=int)
|
|
|
|
if not year or not month:
|
|
return jsonify({'success': False, 'error': 'Brak roku lub miesiaca'}), 400
|
|
|
|
# Get default fee amount
|
|
fee_config = db.query(MembershipFeeConfig).filter(
|
|
MembershipFeeConfig.scope == 'global',
|
|
MembershipFeeConfig.valid_until == None
|
|
).first()
|
|
default_fee = fee_config.monthly_amount if fee_config else 100.00
|
|
|
|
# Get all active companies
|
|
companies = db.query(Company).filter(Company.status == 'active').all()
|
|
|
|
created = 0
|
|
for company in companies:
|
|
# Check if record already exists
|
|
existing = db.query(MembershipFee).filter(
|
|
MembershipFee.company_id == company.id,
|
|
MembershipFee.fee_year == year,
|
|
MembershipFee.fee_month == month
|
|
).first()
|
|
|
|
if not existing:
|
|
fee = MembershipFee(
|
|
company_id=company.id,
|
|
fee_year=year,
|
|
fee_month=month,
|
|
amount=default_fee,
|
|
status='pending'
|
|
)
|
|
db.add(fee)
|
|
created += 1
|
|
|
|
db.commit()
|
|
|
|
return jsonify({
|
|
'success': True,
|
|
'message': f'Utworzono {created} rekordow skladek'
|
|
})
|
|
except Exception as e:
|
|
db.rollback()
|
|
logger.error(f"Error generating fees: {e}")
|
|
return jsonify({'success': False, 'error': str(e)}), 500
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
@app.route('/admin/fees/<int:fee_id>/mark-paid', methods=['POST'])
|
|
@login_required
|
|
def admin_fees_mark_paid(fee_id):
|
|
"""Mark a fee as paid"""
|
|
if not current_user.is_admin:
|
|
return jsonify({'success': False, 'error': 'Brak uprawnien'}), 403
|
|
|
|
db = SessionLocal()
|
|
try:
|
|
fee = db.query(MembershipFee).filter(MembershipFee.id == fee_id).first()
|
|
if not fee:
|
|
return jsonify({'success': False, 'error': 'Nie znaleziono skladki'}), 404
|
|
|
|
# Get data from request
|
|
amount_paid = request.form.get('amount_paid', type=float)
|
|
payment_date = request.form.get('payment_date')
|
|
payment_method = request.form.get('payment_method', 'transfer')
|
|
payment_reference = request.form.get('payment_reference', '')
|
|
notes = request.form.get('notes', '')
|
|
|
|
# Update fee record
|
|
fee.amount_paid = amount_paid or float(fee.amount)
|
|
fee.payment_date = datetime.strptime(payment_date, '%Y-%m-%d').date() if payment_date else datetime.now().date()
|
|
fee.payment_method = payment_method
|
|
fee.payment_reference = payment_reference
|
|
fee.notes = notes
|
|
fee.recorded_by = current_user.id
|
|
fee.recorded_at = datetime.now()
|
|
|
|
# Set status based on payment amount
|
|
if fee.amount_paid >= float(fee.amount):
|
|
fee.status = 'paid'
|
|
elif fee.amount_paid > 0:
|
|
fee.status = 'partial'
|
|
|
|
db.commit()
|
|
|
|
return jsonify({
|
|
'success': True,
|
|
'message': 'Skladka zostala zarejestrowana'
|
|
})
|
|
except Exception as e:
|
|
db.rollback()
|
|
logger.error(f"Error marking fee as paid: {e}")
|
|
return jsonify({'success': False, 'error': str(e)}), 500
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
@app.route('/admin/fees/bulk-mark-paid', methods=['POST'])
|
|
@login_required
|
|
def admin_fees_bulk_mark_paid():
|
|
"""Bulk mark fees as paid"""
|
|
if not current_user.is_admin:
|
|
return jsonify({'success': False, 'error': 'Brak uprawnien'}), 403
|
|
|
|
db = SessionLocal()
|
|
try:
|
|
fee_ids = request.form.getlist('fee_ids[]', type=int)
|
|
|
|
if not fee_ids:
|
|
return jsonify({'success': False, 'error': 'Brak wybranych skladek'}), 400
|
|
|
|
updated = 0
|
|
for fee_id in fee_ids:
|
|
fee = db.query(MembershipFee).filter(MembershipFee.id == fee_id).first()
|
|
if fee and fee.status != 'paid':
|
|
fee.status = 'paid'
|
|
fee.amount_paid = fee.amount
|
|
fee.payment_date = datetime.now().date()
|
|
fee.recorded_by = current_user.id
|
|
fee.recorded_at = datetime.now()
|
|
updated += 1
|
|
|
|
db.commit()
|
|
|
|
return jsonify({
|
|
'success': True,
|
|
'message': f'Zaktualizowano {updated} rekordow'
|
|
})
|
|
except Exception as e:
|
|
db.rollback()
|
|
logger.error(f"Error in bulk action: {e}")
|
|
return jsonify({'success': False, 'error': str(e)}), 500
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
@app.route('/admin/fees/export')
|
|
@login_required
|
|
def admin_fees_export():
|
|
"""Export fees to CSV"""
|
|
if not current_user.is_admin:
|
|
flash('Brak uprawnien.', 'error')
|
|
return redirect(url_for('admin_fees'))
|
|
|
|
import csv
|
|
from io import StringIO
|
|
|
|
db = SessionLocal()
|
|
try:
|
|
year = request.args.get('year', datetime.now().year, type=int)
|
|
month = request.args.get('month', type=int)
|
|
|
|
query = db.query(MembershipFee).join(Company).filter(
|
|
MembershipFee.fee_year == year
|
|
)
|
|
|
|
if month:
|
|
query = query.filter(MembershipFee.fee_month == month)
|
|
|
|
fees = query.order_by(Company.name, MembershipFee.fee_month).all()
|
|
|
|
# Generate CSV
|
|
output = StringIO()
|
|
writer = csv.writer(output)
|
|
writer.writerow([
|
|
'Firma', 'NIP', 'Rok', 'Miesiac', 'Kwota', 'Zaplacono',
|
|
'Status', 'Data platnosci', 'Metoda', 'Referencja', 'Notatki'
|
|
])
|
|
|
|
for fee in fees:
|
|
writer.writerow([
|
|
fee.company.name,
|
|
fee.company.nip,
|
|
fee.fee_year,
|
|
fee.fee_month,
|
|
fee.amount,
|
|
fee.amount_paid,
|
|
fee.status,
|
|
fee.payment_date,
|
|
fee.payment_method,
|
|
fee.payment_reference,
|
|
fee.notes
|
|
])
|
|
|
|
output.seek(0)
|
|
|
|
return Response(
|
|
output.getvalue(),
|
|
mimetype='text/csv',
|
|
headers={
|
|
'Content-Disposition': f'attachment; filename=skladki_{year}_{month or "all"}.csv'
|
|
}
|
|
)
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
# ============================================================
|
|
# ANNOUNCEMENTS
|
|
# ============================================================
|
|
|
|
@app.route('/announcements')
|
|
@login_required
|
|
def announcements_list():
|
|
"""View published announcements"""
|
|
db = SessionLocal()
|
|
try:
|
|
now = datetime.now()
|
|
|
|
announcements = db.query(Announcement).filter(
|
|
Announcement.is_published == True,
|
|
(Announcement.publish_date <= now) | (Announcement.publish_date == None),
|
|
(Announcement.expire_date >= now) | (Announcement.expire_date == None)
|
|
).order_by(
|
|
Announcement.is_pinned.desc(),
|
|
Announcement.created_at.desc()
|
|
).all()
|
|
|
|
return render_template('announcements/list.html', announcements=announcements)
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
@app.route('/admin/announcements')
|
|
@login_required
|
|
def admin_announcements():
|
|
"""Admin panel for announcements"""
|
|
if not current_user.is_admin:
|
|
flash('Brak uprawnien do tej strony.', 'error')
|
|
return redirect(url_for('index'))
|
|
|
|
db = SessionLocal()
|
|
try:
|
|
announcements = db.query(Announcement).order_by(
|
|
Announcement.created_at.desc()
|
|
).all()
|
|
|
|
return render_template('admin/announcements.html', announcements=announcements)
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
@app.route('/admin/announcements/new', methods=['GET', 'POST'])
|
|
@login_required
|
|
def admin_announcements_new():
|
|
"""Create new announcement"""
|
|
if not current_user.is_admin:
|
|
flash('Brak uprawnien.', 'error')
|
|
return redirect(url_for('index'))
|
|
|
|
if request.method == 'POST':
|
|
db = SessionLocal()
|
|
try:
|
|
announcement = Announcement(
|
|
title=request.form.get('title'),
|
|
content=request.form.get('content'),
|
|
announcement_type=request.form.get('type', 'general'),
|
|
is_published=request.form.get('is_published') == 'on',
|
|
is_pinned=request.form.get('is_pinned') == 'on',
|
|
author_id=current_user.id
|
|
)
|
|
|
|
# Handle dates
|
|
publish_date = request.form.get('publish_date')
|
|
if publish_date:
|
|
announcement.publish_date = datetime.strptime(publish_date, '%Y-%m-%dT%H:%M')
|
|
|
|
expire_date = request.form.get('expire_date')
|
|
if expire_date:
|
|
announcement.expire_date = datetime.strptime(expire_date, '%Y-%m-%dT%H:%M')
|
|
|
|
db.add(announcement)
|
|
db.commit()
|
|
|
|
flash('Ogloszenie zostalo utworzone.', 'success')
|
|
return redirect(url_for('admin_announcements'))
|
|
except Exception as e:
|
|
db.rollback()
|
|
flash(f'Blad: {e}', 'error')
|
|
finally:
|
|
db.close()
|
|
|
|
return render_template('admin/announcements_form.html', announcement=None)
|
|
|
|
|
|
@app.route('/admin/announcements/<int:id>/edit', methods=['GET', 'POST'])
|
|
@login_required
|
|
def admin_announcements_edit(id):
|
|
"""Edit announcement"""
|
|
if not current_user.is_admin:
|
|
flash('Brak uprawnien.', 'error')
|
|
return redirect(url_for('index'))
|
|
|
|
db = SessionLocal()
|
|
try:
|
|
announcement = db.query(Announcement).filter(Announcement.id == id).first()
|
|
if not announcement:
|
|
flash('Nie znaleziono ogloszenia.', 'error')
|
|
return redirect(url_for('admin_announcements'))
|
|
|
|
if request.method == 'POST':
|
|
announcement.title = request.form.get('title')
|
|
announcement.content = request.form.get('content')
|
|
announcement.announcement_type = request.form.get('type', 'general')
|
|
announcement.is_published = request.form.get('is_published') == 'on'
|
|
announcement.is_pinned = request.form.get('is_pinned') == 'on'
|
|
|
|
# Handle dates
|
|
publish_date = request.form.get('publish_date')
|
|
announcement.publish_date = datetime.strptime(publish_date, '%Y-%m-%dT%H:%M') if publish_date else None
|
|
|
|
expire_date = request.form.get('expire_date')
|
|
announcement.expire_date = datetime.strptime(expire_date, '%Y-%m-%dT%H:%M') if expire_date else None
|
|
|
|
db.commit()
|
|
|
|
flash('Ogloszenie zostalo zaktualizowane.', 'success')
|
|
return redirect(url_for('admin_announcements'))
|
|
|
|
return render_template('admin/announcements_form.html', announcement=announcement)
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
@app.route('/admin/announcements/<int:id>/delete', methods=['POST'])
|
|
@login_required
|
|
def admin_announcements_delete(id):
|
|
"""Delete announcement"""
|
|
if not current_user.is_admin:
|
|
return jsonify({'success': False, 'error': 'Brak uprawnien'}), 403
|
|
|
|
db = SessionLocal()
|
|
try:
|
|
announcement = db.query(Announcement).filter(Announcement.id == id).first()
|
|
if announcement:
|
|
db.delete(announcement)
|
|
db.commit()
|
|
return jsonify({'success': True})
|
|
return jsonify({'success': False, 'error': 'Nie znaleziono'}), 404
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
# ============================================================
|
|
# RELEASE NOTES
|
|
# ============================================================
|
|
|
|
@app.route('/release-notes')
|
|
def release_notes():
|
|
"""Historia zmian platformy"""
|
|
releases = [
|
|
{
|
|
'version': 'v1.5.0',
|
|
'date': '4 stycznia 2026',
|
|
'badges': ['new', 'improve', 'fix'],
|
|
'new': [
|
|
'System skladek czlonkowskich - panel admina do sledzenia platnosci (/admin/fees)',
|
|
'Ogloszenia organizacyjne - komunikaty zarzadu dla czlonkow (/announcements)',
|
|
],
|
|
'improve': [
|
|
'Po zalogowaniu uzytkownik trafia na katalog firm zamiast dashboardu',
|
|
'Uproszczone menu - usuniety zduplikowany link "Szukaj" (wyszukiwanie dostepne na stronie glownej)',
|
|
],
|
|
'fix': [
|
|
'Menu uzytkownika (Panel) dziala poprawnie na wszystkich stronach',
|
|
],
|
|
},
|
|
{
|
|
'version': 'v1.4.0',
|
|
'date': '4 stycznia 2026',
|
|
'badges': ['new', 'improve'],
|
|
'new': [
|
|
'Autouzupełnianie firm w formularzu rejestracji - wpisuj nazwę firmy zamiast NIP',
|
|
'Strona Historia zmian (ta strona) - śledź rozwój platformy',
|
|
],
|
|
'improve': [
|
|
'Lepsze UX formularza rejestracji dla nowych użytkowników',
|
|
'API /api/companies zwraca teraz NIP i miasto firmy',
|
|
],
|
|
},
|
|
{
|
|
'version': 'v1.3.0',
|
|
'date': '2 stycznia 2026',
|
|
'badges': ['fix'],
|
|
'fix': [
|
|
'Naprawiony problem z dostępem do portalu z zewnątrz (ERR_TOO_MANY_REDIRECTS)',
|
|
'Poprawiona konfiguracja reverse proxy (NPM)',
|
|
],
|
|
},
|
|
{
|
|
'version': 'v1.2.0',
|
|
'date': '29 grudnia 2025',
|
|
'badges': ['new', 'beta'],
|
|
'new': [
|
|
'Monitoring wzmianek o firmach w mediach (News Monitoring)',
|
|
'Panel moderacji newsów dla administratorów',
|
|
'System powiadomień o nowych wzmiankach',
|
|
],
|
|
'beta': [
|
|
'Integracja z Brave Search API do wyszukiwania newsów',
|
|
'AI filtering przez Google Gemini (ocena relevance)',
|
|
],
|
|
},
|
|
{
|
|
'version': 'v1.1.0',
|
|
'date': '26 grudnia 2025',
|
|
'badges': ['new', 'improve'],
|
|
'new': [
|
|
'Profile Social Media dla firm (Facebook, Instagram, LinkedIn, YouTube, TikTok, Twitter)',
|
|
'Sekcja Social Media na profilach firm',
|
|
'Analiza kompletności danych Social Media',
|
|
],
|
|
'improve': [
|
|
'Rozbudowane profile firm o dane z Social Media',
|
|
'Lepsza prezentacja informacji kontaktowych',
|
|
],
|
|
},
|
|
{
|
|
'version': 'v1.0.0',
|
|
'date': '23 listopada 2025',
|
|
'badges': ['new'],
|
|
'new': [
|
|
'Oficjalne uruchomienie platformy Norda Biznes Hub',
|
|
'Katalog 80 firm członkowskich Norda Biznes',
|
|
'Wyszukiwarka firm po nazwie, usługach, słowach kluczowych',
|
|
'Chat AI z asystentem Norda Biznes (Google Gemini)',
|
|
'System rejestracji i logowania użytkowników',
|
|
'Profile firm z danymi kontaktowymi i opisami',
|
|
'16 kategorii branżowych',
|
|
],
|
|
},
|
|
]
|
|
return render_template('release_notes.html', releases=releases)
|
|
|
|
|
|
# ============================================================
|
|
# ERROR HANDLERS
|
|
# ============================================================
|
|
|
|
@app.errorhandler(404)
|
|
def not_found(error):
|
|
return render_template('errors/404.html'), 404
|
|
|
|
|
|
@app.errorhandler(500)
|
|
def internal_error(error):
|
|
return render_template('errors/500.html'), 500
|
|
|
|
|
|
# ============================================================
|
|
# MAIN
|
|
# ============================================================
|
|
|
|
if __name__ == '__main__':
|
|
port = int(os.getenv('PORT', 5000))
|
|
debug = os.getenv('FLASK_ENV') == 'development'
|
|
|
|
logger.info(f"Starting Norda Biznes Hub on port {port}")
|
|
app.run(host='0.0.0.0', port=port, debug=debug)
|