diff --git a/app.py b/app.py index 70f0cf0..47c9a96 100644 --- a/app.py +++ b/app.py @@ -26,10 +26,8 @@ from collections import deque from pathlib import Path from datetime import datetime, timedelta, date from flask import Flask, render_template, request, jsonify, redirect, url_for, flash, session, Response, send_file -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 flask_login import login_user, logout_user, login_required, current_user +# Note: CSRFProtect, Limiter, LoginManager are imported from extensions.py (line ~250) from werkzeug.security import generate_password_hash, check_password_hash from dotenv import load_dotenv from user_agents import parse as parse_user_agent @@ -246,8 +244,10 @@ def ensure_url_filter(url): return f'https://{url}' return url -# Initialize CSRF protection -csrf = CSRFProtect(app) +# Initialize extensions from centralized extensions.py +from extensions import csrf, limiter, login_manager + +csrf.init_app(app) # Initialize rate limiter with Redis storage (persistent across restarts) # Falls back to memory if Redis unavailable @@ -261,12 +261,13 @@ try: except Exception: logger.warning("Redis unavailable, rate limiter using memory storage") -limiter = Limiter( - app=app, - key_func=get_remote_address, - default_limits=["1000 per day", "200 per hour"], - storage_uri="redis://localhost:6379/0" if _redis_available else "memory://" -) +# Note: default_limits are set in extensions.py +# Here we only configure storage +if _redis_available: + limiter._storage_uri = "redis://localhost:6379/0" +else: + limiter._storage_uri = "memory://" +limiter.init_app(app) @limiter.request_filter def is_admin_exempt(): @@ -280,10 +281,9 @@ def is_admin_exempt(): # Initialize database init_db() -# Initialize Login Manager -login_manager = LoginManager() +# Initialize Login Manager (imported from extensions.py) login_manager.init_app(app) -login_manager.login_view = 'login' +login_manager.login_view = 'login' # Will change to 'auth.login' after full migration login_manager.login_message = 'Zaloguj się, aby uzyskać dostęp do tej strony.' # Initialize Gemini service @@ -1083,11 +1083,14 @@ def health_full(): # ============================================================ -# PUBLIC ROUTES +# PUBLIC ROUTES - MOVED TO blueprints/public/routes.py # ============================================================ +# The routes below have been migrated to the public blueprint. +# They are commented out but preserved for reference. +# See: blueprints/public/routes.py -@app.route('/') -def index(): +# @app.route('/') # MOVED TO public.index +def _old_index(): """Homepage - landing page for guests, company directory for logged in users""" if not current_user.is_authenticated: # Landing page for guests @@ -1147,9 +1150,9 @@ def index(): db.close() -@app.route('/company/') +# @app.route('/company/') # MOVED TO public.company_detail # @login_required # Public access -def company_detail(company_id): +def _old_company_detail(company_id): """Company detail page - requires login""" db = SessionLocal() try: @@ -1249,9 +1252,9 @@ def company_detail(company_id): db.close() -@app.route('/company/') +# @app.route('/company/') # MOVED TO public.company_detail_by_slug # @login_required # Disabled - public access -def company_detail_by_slug(slug): +def _old_company_detail_by_slug(slug): """Company detail page by slug - requires login""" db = SessionLocal() try: @@ -1265,8 +1268,8 @@ def company_detail_by_slug(slug): db.close() -@app.route('/osoba/') -def person_detail(person_id): +# @app.route('/osoba/') # MOVED TO public.person_detail +def _old_person_detail(person_id): """Person detail page - shows registry data and portal data if available""" db = SessionLocal() try: @@ -1314,9 +1317,9 @@ def person_detail(person_id): db.close() -@app.route('/company//recommend', methods=['GET', 'POST']) +# @app.route('/company//recommend', methods=['GET', 'POST']) # MOVED TO public.company_recommend # @login_required # Disabled - public access -def company_recommend(slug): +def _old_company_recommend(slug): """Create recommendation for a company - requires login""" db = SessionLocal() try: @@ -1377,9 +1380,9 @@ def company_recommend(slug): db.close() -@app.route('/search') -@login_required -def search(): +# @app.route('/search') # MOVED TO public.search +# @login_required +def _old_search(): """Search companies and people with advanced matching - requires login""" query = request.args.get('q', '') category_id = request.args.get('category', type=int) @@ -1459,9 +1462,9 @@ def search(): db.close() -@app.route('/aktualnosci') -@login_required -def events(): +# @app.route('/aktualnosci') # MOVED TO public.events +# @login_required +def _old_events(): """Company events and news - latest updates from member companies""" from sqlalchemy import func @@ -3967,12 +3970,12 @@ def api_delete_recommendation(rec_id): # Routes: /tablica, /tablica/nowe, /tablica/, /tablica//zakoncz # ============================================================ -# NEW MEMBERS ROUTE +# NEW MEMBERS ROUTE - MOVED TO blueprints/public/routes.py # ============================================================ -@app.route('/nowi-czlonkowie') -@login_required -def new_members(): +# @app.route('/nowi-czlonkowie') # MOVED TO public.new_members +# @login_required +def _old_new_members(): """Lista nowych firm członkowskich""" days = request.args.get('days', 90, type=int) @@ -3995,12 +3998,15 @@ def new_members(): # ============================================================ -# AUTHENTICATION ROUTES +# AUTHENTICATION ROUTES - MOVED TO blueprints/auth/routes.py # ============================================================ +# The routes below have been migrated to the auth blueprint. +# They are commented out but preserved for reference. +# See: blueprints/auth/routes.py -@app.route('/register', methods=['GET', 'POST']) -@limiter.limit("50 per hour;200 per day") # Increased limits for better UX -def register(): +# @app.route('/register', methods=['GET', 'POST']) # MOVED TO auth.register +# @limiter.limit("50 per hour;200 per day") +def _old_register(): """User registration""" if current_user.is_authenticated: return redirect(url_for('index')) @@ -4119,9 +4125,9 @@ def register(): return render_template('auth/register.html') -@app.route('/login', methods=['GET', 'POST']) -@limiter.limit("1000 per hour" if os.getenv('FLASK_ENV') == 'development' else "60 per minute;500 per hour") -def login(): +# @app.route('/login', methods=['GET', 'POST']) # MOVED TO auth.login +# @limiter.limit("1000 per hour" if os.getenv('FLASK_ENV') == 'development' else "60 per minute;500 per hour") +def _old_login(): """User login""" if current_user.is_authenticated: return redirect(url_for('index')) @@ -4220,9 +4226,9 @@ def login(): return render_template('auth/login.html') -@app.route('/logout') -@login_required -def logout(): +# @app.route('/logout') # MOVED TO auth.logout +# @login_required +def _old_logout(): """User logout""" # Clear 2FA session flag session.pop('2fa_verified', None) @@ -4232,12 +4238,12 @@ def logout(): # ============================================================ -# TWO-FACTOR AUTHENTICATION +# TWO-FACTOR AUTHENTICATION - MOVED TO blueprints/auth/routes.py # ============================================================ -@app.route('/verify-2fa', methods=['GET', 'POST']) -@limiter.limit("10 per minute") -def verify_2fa(): +# @app.route('/verify-2fa', methods=['GET', 'POST']) # MOVED TO auth.verify_2fa +# @limiter.limit("10 per minute") +def _old_verify_2fa(): """Verify 2FA code during login""" # Check if there's a pending 2FA login pending_user_id = session.get('2fa_pending_user_id') @@ -4301,9 +4307,9 @@ def verify_2fa(): return render_template('auth/verify_2fa.html') -@app.route('/settings/2fa', methods=['GET', 'POST']) -@login_required -def settings_2fa(): +# @app.route('/settings/2fa', methods=['GET', 'POST']) # MOVED TO auth.settings_2fa +# @login_required +def _old_settings_2fa(): """2FA settings - enable/disable""" db = SessionLocal() try: @@ -4396,9 +4402,9 @@ def settings_2fa(): db.close() -@app.route('/settings/privacy', methods=['GET', 'POST']) -@login_required -def settings_privacy(): +# @app.route('/settings/privacy', methods=['GET', 'POST']) # MOVED TO auth.settings_privacy +# @login_required +def _old_settings_privacy(): """Privacy settings - control visibility of phone and email""" db = SessionLocal() try: @@ -4434,9 +4440,9 @@ def settings_privacy(): db.close() -@app.route('/settings/blocks', methods=['GET']) -@login_required -def settings_blocks(): +# @app.route('/settings/blocks', methods=['GET']) # MOVED TO auth.settings_blocks +# @login_required +def _old_settings_blocks(): """Manage blocked users""" db = SessionLocal() try: @@ -4461,9 +4467,9 @@ def settings_blocks(): db.close() -@app.route('/settings/blocks/add', methods=['POST']) -@login_required -def settings_blocks_add(): +# @app.route('/settings/blocks/add', methods=['POST']) # MOVED TO auth.settings_blocks_add +# @login_required +def _old_settings_blocks_add(): """Block a user""" user_id = request.form.get('user_id', type=int) reason = request.form.get('reason', '').strip() @@ -4503,9 +4509,9 @@ def settings_blocks_add(): return redirect(url_for('settings_blocks')) -@app.route('/settings/blocks/remove/', methods=['POST']) -@login_required -def settings_blocks_remove(block_id): +# @app.route('/settings/blocks/remove/', methods=['POST']) # MOVED TO auth.settings_blocks_remove +# @login_required +def _old_settings_blocks_remove(block_id): """Unblock a user""" db = SessionLocal() try: @@ -4534,19 +4540,19 @@ def settings_blocks_remove(block_id): # ============================================================ -# MOJE KONTO - User Account Settings (new unified section) +# MOJE KONTO - MOVED TO blueprints/auth/routes.py # ============================================================ -@app.route('/konto') -@login_required -def konto_dane(): +# @app.route('/konto') # MOVED TO auth.konto_dane +# @login_required +def _old_konto_dane(): """User profile - edit personal data""" return render_template('konto/dane.html') -@app.route('/konto', methods=['POST']) -@login_required -def konto_dane_post(): +# @app.route('/konto', methods=['POST']) # MOVED TO auth.konto_dane_post +# @login_required +def _old_konto_dane_post(): """Save user profile changes""" db = SessionLocal() try: @@ -4574,9 +4580,9 @@ def konto_dane_post(): return redirect(url_for('konto_dane')) -@app.route('/konto/prywatnosc', methods=['GET', 'POST']) -@login_required -def konto_prywatnosc(): +# @app.route('/konto/prywatnosc', methods=['GET', 'POST']) # MOVED TO auth.konto_prywatnosc +# @login_required +def _old_konto_prywatnosc(): """Privacy settings - control visibility of phone and email""" db = SessionLocal() try: @@ -4606,16 +4612,16 @@ def konto_prywatnosc(): db.close() -@app.route('/konto/bezpieczenstwo') -@login_required -def konto_bezpieczenstwo(): +# @app.route('/konto/bezpieczenstwo') # MOVED TO auth.konto_bezpieczenstwo +# @login_required +def _old_konto_bezpieczenstwo(): """Security settings - 2FA, password""" return render_template('konto/bezpieczenstwo.html') -@app.route('/konto/blokady') -@login_required -def konto_blokady(): +# @app.route('/konto/blokady') # MOVED TO auth.konto_blokady +# @login_required +def _old_konto_blokady(): """User blocks management""" db = SessionLocal() try: @@ -4639,9 +4645,9 @@ def konto_blokady(): db.close() -@app.route('/konto/blokady/dodaj', methods=['POST']) -@login_required -def konto_blokady_dodaj(): +# @app.route('/konto/blokady/dodaj', methods=['POST']) # MOVED TO auth.konto_blokady_dodaj +# @login_required +def _old_konto_blokady_dodaj(): """Block a user""" db = SessionLocal() try: @@ -4674,9 +4680,9 @@ def konto_blokady_dodaj(): return redirect(url_for('konto_blokady')) -@app.route('/konto/blokady/usun/', methods=['POST']) -@login_required -def konto_blokady_usun(block_id): +# @app.route('/konto/blokady/usun/', methods=['POST']) # MOVED TO auth.konto_blokady_usun +# @login_required +def _old_konto_blokady_usun(block_id): """Unblock a user""" db = SessionLocal() try: @@ -4704,9 +4710,9 @@ def konto_blokady_usun(block_id): return redirect(url_for('konto_blokady')) -@app.route('/forgot-password', methods=['GET', 'POST']) -@limiter.limit("20 per hour") -def forgot_password(): +# @app.route('/forgot-password', methods=['GET', 'POST']) # MOVED TO auth.forgot_password +# @limiter.limit("20 per hour") +def _old_forgot_password(): """Request password reset""" if current_user.is_authenticated: return redirect(url_for('index')) @@ -4767,9 +4773,9 @@ def forgot_password(): return render_template('auth/forgot_password.html') -@app.route('/reset-password/', methods=['GET', 'POST']) -@limiter.limit("30 per hour") -def reset_password(token): +# @app.route('/reset-password/', methods=['GET', 'POST']) # MOVED TO auth.reset_password +# @limiter.limit("30 per hour") +def _old_reset_password(token): """Reset password with token""" if current_user.is_authenticated: return redirect(url_for('index')) @@ -4831,8 +4837,8 @@ def reset_password(token): db.close() -@app.route('/verify-email/') -def verify_email(token): +# @app.route('/verify-email/') # MOVED TO auth.verify_email +def _old_verify_email(token): """Verify email address with token""" db = SessionLocal() try: @@ -4869,9 +4875,9 @@ def verify_email(token): db.close() -@app.route('/resend-verification', methods=['GET', 'POST']) -@limiter.limit("15 per hour") -def resend_verification(): +# @app.route('/resend-verification', methods=['GET', 'POST']) # MOVED TO auth.resend_verification +# @limiter.limit("15 per hour") +def _old_resend_verification(): """Resend email verification link""" if current_user.is_authenticated: return redirect(url_for('index')) @@ -4932,12 +4938,12 @@ def resend_verification(): # ============================================================ -# USER DASHBOARD +# USER DASHBOARD - MOVED TO blueprints/public/routes.py # ============================================================ -@app.route('/dashboard') -@login_required -def dashboard(): +# @app.route('/dashboard') # MOVED TO public.dashboard +# @login_required +def _old_dashboard(): """User dashboard""" db = SessionLocal() try: @@ -5355,8 +5361,8 @@ def api_connections(): db.close() -@app.route('/mapa-polaczen') -def connections_map(): +# @app.route('/mapa-polaczen') # MOVED TO public.connections_map +def _old_connections_map(): """Company-person connections visualization page""" return render_template('connections_map.html') @@ -10905,11 +10911,11 @@ def api_it_audit_export(): # ============================================================ -# RELEASE NOTES +# RELEASE NOTES - MOVED TO blueprints/public/routes.py # ============================================================ -@app.route('/release-notes') -def release_notes(): +# @app.route('/release-notes') # MOVED TO public.release_notes +def _old_release_notes(): """Historia zmian platformy.""" releases = [ { diff --git a/blueprints/__init__.py b/blueprints/__init__.py index 952c25a..6235168 100644 --- a/blueprints/__init__.py +++ b/blueprints/__init__.py @@ -58,4 +58,98 @@ def register_blueprints(app): except ImportError as e: logger.debug(f"Blueprint education not yet available: {e}") - # Phase 2-7: Future blueprints will be added here + # Phase 2: Auth + Public blueprints (with backward-compatible aliases) + try: + from blueprints.auth import bp as auth_bp + app.register_blueprint(auth_bp) + logger.info("Registered blueprint: auth") + + # Create aliases for backward compatibility + # Old url_for('login') will still work alongside url_for('auth.login') + _create_endpoint_aliases(app, auth_bp, { + 'register': 'auth.register', + 'login': 'auth.login', + 'logout': 'auth.logout', + 'verify_2fa': 'auth.verify_2fa', + 'settings_2fa': 'auth.settings_2fa', + 'settings_privacy': 'auth.settings_privacy', + 'settings_blocks': 'auth.settings_blocks', + 'settings_blocks_add': 'auth.settings_blocks_add', + 'settings_blocks_remove': 'auth.settings_blocks_remove', + 'forgot_password': 'auth.forgot_password', + 'reset_password': 'auth.reset_password', + 'verify_email': 'auth.verify_email', + 'resend_verification': 'auth.resend_verification', + # Account routes (konto) + 'konto_dane': 'auth.konto_dane', + 'konto_dane_save': 'auth.konto_dane_post', + 'konto_prywatnosc': 'auth.konto_prywatnosc', + 'konto_bezpieczenstwo': 'auth.konto_bezpieczenstwo', + 'konto_blokady': 'auth.konto_blokady', + 'konto_blokady_dodaj': 'auth.konto_blokady_dodaj', + 'konto_blokady_usun': 'auth.konto_blokady_usun', + }) + logger.info("Created auth endpoint aliases") + except ImportError as e: + logger.debug(f"Blueprint auth not yet available: {e}") + except Exception as e: + logger.error(f"Error registering auth blueprint: {e}") + + try: + from blueprints.public import bp as public_bp + app.register_blueprint(public_bp) + logger.info("Registered blueprint: public") + + # Create aliases for backward compatibility + _create_endpoint_aliases(app, public_bp, { + 'index': 'public.index', + 'company_detail': 'public.company_detail', + 'company_detail_by_slug': 'public.company_detail_by_slug', + 'person_detail': 'public.person_detail', + 'company_recommend': 'public.company_recommend', + 'search': 'public.search', + 'events': 'public.events', + 'new_members': 'public.new_members', + 'connections_map': 'public.connections_map', + 'dashboard': 'public.dashboard', + 'release_notes': 'public.release_notes', + }) + logger.info("Created public endpoint aliases") + except ImportError as e: + logger.debug(f"Blueprint public not yet available: {e}") + except Exception as e: + logger.error(f"Error registering public blueprint: {e}") + + # Phase 3-10: Future blueprints will be added here + + +def _create_endpoint_aliases(app, blueprint, aliases): + """ + Create backward-compatible endpoint aliases. + + This allows old code using url_for('login') to work alongside + new code using url_for('auth.login'). + + Args: + app: Flask application instance + blueprint: The blueprint that was just registered + aliases: Dict mapping old_name -> new_name (blueprint.endpoint) + """ + for old_name, new_name in aliases.items(): + if new_name in app.view_functions: + # Find the URL rule for the new endpoint + for rule in app.url_map.iter_rules(): + if rule.endpoint == new_name: + try: + # Register the same view function under the old name + app.add_url_rule( + rule.rule, + old_name, + app.view_functions[new_name], + methods=list(rule.methods - {'OPTIONS', 'HEAD'}) + ) + logger.debug(f"Created alias: {old_name} -> {new_name}") + except AssertionError: + # Endpoint already exists (e.g., still in app.py) + logger.debug(f"Alias {old_name} already exists, skipping") + break diff --git a/blueprints/auth/__init__.py b/blueprints/auth/__init__.py new file mode 100644 index 0000000..3b26254 --- /dev/null +++ b/blueprints/auth/__init__.py @@ -0,0 +1,12 @@ +""" +Auth Blueprint +============== + +Authentication routes: login, logout, register, password reset, email verification, 2FA. +""" + +from flask import Blueprint + +bp = Blueprint('auth', __name__) + +from . import routes # noqa: E402, F401 diff --git a/blueprints/auth/routes.py b/blueprints/auth/routes.py new file mode 100644 index 0000000..e8ace6c --- /dev/null +++ b/blueprints/auth/routes.py @@ -0,0 +1,1040 @@ +""" +Auth Routes +============ + +Authentication routes: login, logout, register, password reset, email verification, 2FA. +""" + +import os +import re +import logging +import secrets +from datetime import datetime, timedelta + +from flask import render_template, request, redirect, url_for, flash, session, current_app +from flask_login import login_required, login_user, logout_user, current_user +from werkzeug.security import generate_password_hash, check_password_hash + +from . import bp +from database import SessionLocal, User, Company, UserBlock +from utils.helpers import sanitize_input, validate_email, validate_password +from extensions import limiter + +# Logger +logger = logging.getLogger(__name__) + +# Security logger (same name as in app.py for fail2ban integration) +security_logger = logging.getLogger('security') + +# Security service availability check +try: + from security_service import ( + generate_totp_secret, get_totp_uri, verify_totp, + generate_backup_codes, verify_backup_code, log_audit + ) + SECURITY_SERVICE_AVAILABLE = True +except ImportError: + SECURITY_SERVICE_AVAILABLE = False + logger.warning("Security service not available in auth blueprint") + + +def _send_registration_notification(user_info): + """Send email notification when a new user registers""" + try: + from email_service import send_email, is_configured + + if not is_configured(): + logger.warning("Email service not configured - skipping registration notification") + return + + notify_email = os.getenv('ERROR_NOTIFY_EMAIL', 'maciej.pienczyn@inpi.pl') + if not notify_email: + return + + reg_time = datetime.now().strftime('%Y-%m-%d %H:%M:%S') + is_member = "TAK" if user_info.get('is_norda_member') else "NIE" + company_name = user_info.get('company_name', 'Brak przypisanej firmy') + + subject = f"NordaBiz: Nowa rejestracja - {user_info.get('name', 'Nieznany')}" + + body_text = f"""NOWA REJESTRACJA NA NORDABIZNES.PL +{'='*50} + +Czas: {reg_time} +Imie: {user_info.get('name', 'N/A')} +Email: {user_info.get('email', 'N/A')} +NIP: {user_info.get('company_nip', 'N/A')} +Firma: {company_name} +Czlonek NORDA: {is_member} + +{'='*50} +""" + + send_email( + to_email=notify_email, + subject=subject, + body_text=body_text + ) + logger.info(f"Registration notification sent for {user_info.get('email')}") + except Exception as e: + logger.error(f"Failed to send registration notification: {e}") + + +# ============================================================ +# REGISTRATION +# ============================================================ + +@bp.route('/register', methods=['GET', 'POST']) +@limiter.limit("50 per hour;200 per day") +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('Nieprawidlowy 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('Imie, email i NIP firmy sa wymagane.', 'error') + return render_template('auth/register.html') + + # Validate NIP format + if not re.match(r'^\d{10}$', company_nip): + flash('NIP musi skladac sie 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 juz 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, + 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 token (email failed) for {email}: {verification_token[:8]}...") + else: + logger.warning("Email service not configured") + logger.info(f"Verification token (no email) for {email}: {verification_token[:8]}...") + except Exception as e: + logger.error(f"Error sending verification email: {e}") + logger.info(f"Verification token (exception) for {email}: {verification_token[:8]}...") + + logger.info(f"New user registered: {email}") + + # Send notification to admin about new registration + try: + company_name = company.name if company_id and company else None + _send_registration_notification({ + 'name': name, + 'email': email, + 'company_nip': company_nip, + 'company_name': company_name, + 'is_norda_member': is_norda_member + }) + except Exception as e: + logger.error(f"Failed to send registration notification: {e}") + + 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('Wystapil blad podczas rejestracji. Sprobuj ponownie.', 'error') + return render_template('auth/register.html') + finally: + db.close() + + return render_template('auth/register.html') + + +# ============================================================ +# LOGIN +# ============================================================ + +@bp.route('/login', methods=['GET', 'POST']) +@limiter.limit("1000 per hour" if os.getenv('FLASK_ENV') == 'development' else "60 per minute;500 per hour") +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 haslo sa wymagane.', 'error') + return render_template('auth/login.html') + + db = SessionLocal() + try: + user = db.query(User).filter_by(email=email).first() + + # Get client IP for logging + client_ip = request.headers.get('X-Forwarded-For', request.remote_addr) + if client_ip and ',' in client_ip: + client_ip = client_ip.split(',')[0].strip() + + # Check if account is locked + if user and user.locked_until and user.locked_until > datetime.now(): + remaining = (user.locked_until - datetime.now()).seconds // 60 + 1 + security_logger.warning(f"LOCKED_ACCOUNT ip={client_ip} email={email}") + flash(f'Konto tymczasowo zablokowane. Sprobuj za {remaining} minut.', 'error') + return render_template('auth/login.html') + + if not user or not check_password_hash(user.password_hash, password): + logger.warning(f"Failed login attempt for: {email}") + security_logger.warning(f"FAILED_LOGIN ip={client_ip} email={email}") + + # Increment failed attempts if user exists + if user: + user.failed_login_attempts = (user.failed_login_attempts or 0) + 1 + # Lock account after 5 failed attempts (30 min lockout) + if user.failed_login_attempts >= 5: + user.locked_until = datetime.now() + timedelta(minutes=30) + security_logger.warning(f"ACCOUNT_LOCKED ip={client_ip} email={email} attempts={user.failed_login_attempts}") + db.commit() + flash('Zbyt wiele nieudanych prob. Konto zablokowane na 30 minut.', 'error') + return render_template('auth/login.html') + db.commit() + + flash('Nieprawidlowy email lub haslo.', 'error') + return render_template('auth/login.html') + + if not user.is_active: + flash('Konto zostalo 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') + + # Reset failed attempts on successful login + user.failed_login_attempts = 0 + user.locked_until = None + + # Check if user has 2FA enabled + if user.totp_enabled and SECURITY_SERVICE_AVAILABLE: + # Store pending login in session for 2FA verification + session['2fa_pending_user_id'] = user.id + session['2fa_remember'] = remember + next_page = request.args.get('next') + if next_page and next_page.startswith('/'): + session['2fa_next'] = next_page + db.commit() + logger.info(f"2FA required for user: {email}") + return redirect(url_for('auth.verify_2fa')) + + # No 2FA - login directly + 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('dashboard')) + + except Exception as e: + logger.error(f"Login error: {e}") + flash('Wystapil blad podczas logowania. Sprobuj ponownie.', 'error') + return render_template('auth/login.html') + finally: + db.close() + + return render_template('auth/login.html') + + +# ============================================================ +# LOGOUT +# ============================================================ + +@bp.route('/logout') +@login_required +def logout(): + """User logout""" + # Clear 2FA session flag + session.pop('2fa_verified', None) + logout_user() + flash('Wylogowano pomyslnie.', 'success') + return redirect(url_for('index')) + + +# ============================================================ +# TWO-FACTOR AUTHENTICATION +# ============================================================ + +@bp.route('/verify-2fa', methods=['GET', 'POST']) +@limiter.limit("10 per minute") +def verify_2fa(): + """Verify 2FA code during login""" + # Check if there's a pending 2FA login + pending_user_id = session.get('2fa_pending_user_id') + if not pending_user_id: + flash('Sesja wygasla. Zaloguj sie ponownie.', 'error') + return redirect(url_for('login')) + + if request.method == 'POST': + code = request.form.get('code', '').strip() + use_backup = request.form.get('use_backup', False) + + if not code: + flash('Wprowadz kod weryfikacyjny.', 'error') + return render_template('auth/verify_2fa.html') + + db = SessionLocal() + try: + user = db.query(User).get(pending_user_id) + if not user: + session.pop('2fa_pending_user_id', None) + flash('Uzytkownik nie istnieje.', 'error') + return redirect(url_for('login')) + + # Verify code + if SECURITY_SERVICE_AVAILABLE: + if use_backup: + valid = verify_backup_code(user, code, db) + else: + valid = verify_totp(user, code) + else: + valid = False + + if valid: + # Clear pending login and log in + session.pop('2fa_pending_user_id', None) + remember = session.pop('2fa_remember', False) + next_page = session.pop('2fa_next', None) + + login_user(user, remember=remember) + session['2fa_verified'] = True + user.last_login = datetime.now() + db.commit() + + logger.info(f"User logged in with 2FA: {user.email}") + flash('Zalogowano pomyslnie.', 'success') + + return redirect(next_page or url_for('dashboard')) + else: + client_ip = request.headers.get('X-Forwarded-For', request.remote_addr) + if client_ip and ',' in client_ip: + client_ip = client_ip.split(',')[0].strip() + security_logger.warning(f"INVALID_2FA ip={client_ip} user_id={pending_user_id}") + flash('Nieprawidlowy kod weryfikacyjny.', 'error') + + except Exception as e: + logger.error(f"2FA verification error: {e}") + flash('Wystapil blad. Sprobuj ponownie.', 'error') + finally: + db.close() + + return render_template('auth/verify_2fa.html') + + +@bp.route('/settings/2fa', methods=['GET', 'POST']) +@login_required +def settings_2fa(): + """2FA settings - enable/disable""" + db = SessionLocal() + try: + user = db.query(User).get(current_user.id) + + if request.method == 'POST': + action = request.form.get('action') + + if action == 'setup': + # Generate new secret + if SECURITY_SERVICE_AVAILABLE: + secret = generate_totp_secret() + if secret: + user.totp_secret = secret + user.totp_enabled = False # Not enabled until verified + db.commit() + qr_uri = get_totp_uri(user) + return render_template('auth/2fa_setup.html', + qr_uri=qr_uri, + secret=secret) + flash('Blad konfiguracji 2FA.', 'error') + + elif action == 'verify_setup': + # Verify the setup code and enable 2FA + code = request.form.get('code', '').strip() + if SECURITY_SERVICE_AVAILABLE and verify_totp(user, code): + user.totp_enabled = True + # Generate backup codes + backup_codes = generate_backup_codes(8) + user.totp_backup_codes = backup_codes + db.commit() + + # Log audit + if SECURITY_SERVICE_AVAILABLE: + log_audit(db, '2fa.enabled', 'user', user.id, user.email) + db.commit() + + logger.info(f"2FA enabled for user: {user.email}") + return render_template('auth/2fa_backup_codes.html', + backup_codes=backup_codes) + else: + flash('Nieprawidlowy kod. Sprobuj ponownie.', 'error') + qr_uri = get_totp_uri(user) + return render_template('auth/2fa_setup.html', + qr_uri=qr_uri, + secret=user.totp_secret) + + elif action == 'disable': + # Require current code to disable + code = request.form.get('code', '').strip() + if SECURITY_SERVICE_AVAILABLE and verify_totp(user, code): + user.totp_enabled = False + user.totp_secret = None + user.totp_backup_codes = None + db.commit() + + # Log audit + if SECURITY_SERVICE_AVAILABLE: + log_audit(db, '2fa.disabled', 'user', user.id, user.email) + db.commit() + + logger.info(f"2FA disabled for user: {user.email}") + flash('Uwierzytelnianie dwuskladnikowe zostalo wylaczone.', 'success') + else: + flash('Nieprawidlowy kod. Nie mozna wylaczyc 2FA.', 'error') + + elif action == 'regenerate_backup': + # Require current code to regenerate backup codes + code = request.form.get('code', '').strip() + if SECURITY_SERVICE_AVAILABLE and verify_totp(user, code): + backup_codes = generate_backup_codes(8) + user.totp_backup_codes = backup_codes + db.commit() + + logger.info(f"Backup codes regenerated for user: {user.email}") + return render_template('auth/2fa_backup_codes.html', + backup_codes=backup_codes) + else: + flash('Nieprawidlowy kod. Nie mozna wygenerowac kodow.', 'error') + + return render_template('auth/2fa_settings.html', + totp_enabled=user.totp_enabled, + backup_codes_count=len(user.totp_backup_codes) if user.totp_backup_codes else 0) + + except Exception as e: + logger.error(f"2FA settings error: {e}") + flash('Wystapil blad.', 'error') + return redirect(url_for('dashboard')) + finally: + db.close() + + +# ============================================================ +# PRIVACY SETTINGS +# ============================================================ + +@bp.route('/settings/privacy', methods=['GET', 'POST']) +@login_required +def settings_privacy(): + """Privacy settings - control visibility of phone and email""" + db = SessionLocal() + try: + user = db.query(User).get(current_user.id) + + if request.method == 'POST': + # Update privacy settings + user.privacy_show_phone = request.form.get('show_phone') == 'on' + user.privacy_show_email = request.form.get('show_email') == 'on' + + # Update contact preferences + user.contact_prefer_email = request.form.get('prefer_email') == 'on' + user.contact_prefer_phone = request.form.get('prefer_phone') == 'on' + user.contact_prefer_portal = request.form.get('prefer_portal') == 'on' + user.contact_note = request.form.get('contact_note', '').strip() or None + + db.commit() + + logger.info(f"Privacy settings updated for user: {user.email}") + flash('Ustawienia prywatnosci zostaly zapisane.', 'success') + return redirect(url_for('auth.settings_privacy')) + + return render_template('settings/privacy.html', + user=user, + show_phone=user.privacy_show_phone if user.privacy_show_phone is not None else True, + show_email=user.privacy_show_email if user.privacy_show_email is not None else True) + + except Exception as e: + logger.error(f"Privacy settings error: {e}") + flash('Wystapil blad.', 'error') + return redirect(url_for('dashboard')) + finally: + db.close() + + +# ============================================================ +# BLOCK SETTINGS +# ============================================================ + +@bp.route('/settings/blocks', methods=['GET']) +@login_required +def settings_blocks(): + """Manage blocked users""" + db = SessionLocal() + try: + # Get list of blocked users + blocks = db.query(UserBlock).filter( + UserBlock.user_id == current_user.id + ).all() + + # Get all users for blocking (exclude self and already blocked) + blocked_ids = [b.blocked_user_id for b in blocks] + blocked_ids.append(current_user.id) + + available_users = db.query(User).filter( + User.id.notin_(blocked_ids), + User.is_active == True + ).order_by(User.name).all() + + return render_template('settings/blocks.html', + blocks=blocks, + available_users=available_users) + finally: + db.close() + + +@bp.route('/settings/blocks/add', methods=['POST']) +@login_required +def settings_blocks_add(): + """Block a user""" + user_id = request.form.get('user_id', type=int) + reason = request.form.get('reason', '').strip() + + if not user_id or user_id == current_user.id: + flash('Nieprawidlowy uzytkownik.', 'error') + return redirect(url_for('auth.settings_blocks')) + + db = SessionLocal() + try: + # Check if already blocked + existing = db.query(UserBlock).filter( + UserBlock.user_id == current_user.id, + UserBlock.blocked_user_id == user_id + ).first() + + if existing: + flash('Ten uzytkownik jest juz zablokowany.', 'warning') + return redirect(url_for('auth.settings_blocks')) + + block = UserBlock( + user_id=current_user.id, + blocked_user_id=user_id, + reason=reason if reason else None + ) + db.add(block) + db.commit() + + logger.info(f"User {current_user.id} blocked user {user_id}") + flash('Uzytkownik zostal zablokowany.', 'success') + except Exception as e: + logger.error(f"Error blocking user: {e}") + flash('Wystapil blad.', 'error') + finally: + db.close() + + return redirect(url_for('auth.settings_blocks')) + + +@bp.route('/settings/blocks/remove/', methods=['POST']) +@login_required +def settings_blocks_remove(block_id): + """Unblock a user""" + db = SessionLocal() + try: + block = db.query(UserBlock).filter( + UserBlock.id == block_id, + UserBlock.user_id == current_user.id + ).first() + + if not block: + flash('Blokada nie istnieje.', 'error') + return redirect(url_for('auth.settings_blocks')) + + blocked_user_id = block.blocked_user_id + db.delete(block) + db.commit() + + logger.info(f"User {current_user.id} unblocked user {blocked_user_id}") + flash('Uzytkownik zostal odblokowany.', 'success') + except Exception as e: + logger.error(f"Error unblocking user: {e}") + flash('Wystapil blad.', 'error') + finally: + db.close() + + return redirect(url_for('auth.settings_blocks')) + + +# ============================================================ +# MOJE KONTO - User Account Settings +# ============================================================ + +@bp.route('/konto') +@login_required +def konto_dane(): + """User profile - edit personal data""" + return render_template('konto/dane.html') + + +@bp.route('/konto', methods=['POST']) +@login_required +def konto_dane_post(): + """Save user profile changes""" + db = SessionLocal() + try: + user = db.query(User).filter_by(id=current_user.id).first() + if user: + name = sanitize_input(request.form.get('name', ''), 255) + phone = sanitize_input(request.form.get('phone', ''), 50) + + user.name = name if name else None + user.phone = phone if phone else None + db.commit() + + # Update current_user session + current_user.name = user.name + current_user.phone = user.phone + + logger.info(f"Profile updated for user: {user.email}") + flash('Dane zostaly zapisane.', 'success') + except Exception as e: + logger.error(f"Profile update error: {e}") + flash('Wystapil blad podczas zapisywania.', 'error') + finally: + db.close() + + return redirect(url_for('auth.konto_dane')) + + +@bp.route('/konto/prywatnosc', methods=['GET', 'POST']) +@login_required +def konto_prywatnosc(): + """Privacy settings - control visibility of phone and email""" + db = SessionLocal() + try: + user = db.query(User).filter_by(id=current_user.id).first() + + if request.method == 'POST': + user.privacy_show_phone = request.form.get('show_phone') == 'on' + user.privacy_show_email = request.form.get('show_email') == 'on' + user.contact_prefer_email = request.form.get('prefer_email') == 'on' + user.contact_prefer_phone = request.form.get('prefer_phone') == 'on' + user.contact_prefer_portal = request.form.get('prefer_portal') == 'on' + db.commit() + + logger.info(f"Privacy settings updated for user: {user.email}") + flash('Ustawienia prywatnosci zostaly zapisane.', 'success') + return redirect(url_for('auth.konto_prywatnosc')) + + return render_template('konto/prywatnosc.html', + user=user, + show_phone=user.privacy_show_phone if user.privacy_show_phone is not None else True, + show_email=user.privacy_show_email if user.privacy_show_email is not None else True) + except Exception as e: + logger.error(f"Privacy settings error: {e}") + flash('Wystapil blad.', 'error') + return redirect(url_for('auth.konto_dane')) + finally: + db.close() + + +@bp.route('/konto/bezpieczenstwo') +@login_required +def konto_bezpieczenstwo(): + """Security settings - 2FA, password""" + return render_template('konto/bezpieczenstwo.html') + + +@bp.route('/konto/blokady') +@login_required +def konto_blokady(): + """User blocks management""" + db = SessionLocal() + try: + blocks = db.query(UserBlock).filter_by(user_id=current_user.id).all() + blocked_ids = [b.blocked_user_id for b in blocks] + blocked_ids.append(current_user.id) + + available_users = db.query(User).filter( + User.id.notin_(blocked_ids), + User.is_active == True + ).order_by(User.name).all() + + return render_template('konto/blokady.html', + blocks=blocks, + available_users=available_users) + except Exception as e: + logger.error(f"Blocks page error: {e}") + flash('Wystapil blad.', 'error') + return redirect(url_for('auth.konto_dane')) + finally: + db.close() + + +@bp.route('/konto/blokady/dodaj', methods=['POST']) +@login_required +def konto_blokady_dodaj(): + """Block a user""" + db = SessionLocal() + try: + user_id = request.form.get('user_id', type=int) + if not user_id or user_id == current_user.id: + flash('Nieprawidlowy uzytkownik.', 'error') + return redirect(url_for('auth.konto_blokady')) + + existing = db.query(UserBlock).filter_by( + user_id=current_user.id, + blocked_user_id=user_id + ).first() + + if existing: + flash('Ten uzytkownik jest juz zablokowany.', 'info') + return redirect(url_for('auth.konto_blokady')) + + block = UserBlock(user_id=current_user.id, blocked_user_id=user_id) + db.add(block) + db.commit() + + logger.info(f"User {current_user.id} blocked user {user_id}") + flash('Uzytkownik zostal zablokowany.', 'success') + except Exception as e: + logger.error(f"Error blocking user: {e}") + flash('Wystapil blad.', 'error') + finally: + db.close() + + return redirect(url_for('auth.konto_blokady')) + + +@bp.route('/konto/blokady/usun/', methods=['POST']) +@login_required +def konto_blokady_usun(block_id): + """Unblock a user""" + db = SessionLocal() + try: + block = db.query(UserBlock).filter( + UserBlock.id == block_id, + UserBlock.user_id == current_user.id + ).first() + + if not block: + flash('Blokada nie istnieje.', 'error') + return redirect(url_for('auth.konto_blokady')) + + blocked_user_id = block.blocked_user_id + db.delete(block) + db.commit() + + logger.info(f"User {current_user.id} unblocked user {blocked_user_id}") + flash('Uzytkownik zostal odblokowany.', 'success') + except Exception as e: + logger.error(f"Error unblocking user: {e}") + flash('Wystapil blad.', 'error') + finally: + db.close() + + return redirect(url_for('auth.konto_blokady')) + + +# ============================================================ +# PASSWORD RESET +# ============================================================ + +@bp.route('/forgot-password', methods=['GET', 'POST']) +@limiter.limit("20 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('Nieprawidlowy 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}") + logger.info(f"Reset token (email failed) for {email}: {reset_token[:8]}...") + else: + logger.warning("Email service not configured") + logger.info(f"Reset token (no email) for {email}: {reset_token[:8]}...") + except Exception as e: + logger.error(f"Error sending reset email: {e}") + logger.info(f"Reset token (exception) for {email}: {reset_token[:8]}...") + + # Always show same message to prevent email enumeration + flash('Jesli email istnieje w systemie, instrukcje resetowania hasla zostaly wyslane.', 'info') + return redirect(url_for('login')) + + except Exception as e: + logger.error(f"Password reset error: {e}") + flash('Wystapil blad. Sprobuj ponownie.', 'error') + finally: + db.close() + + return render_template('auth/forgot_password.html') + + +@bp.route('/reset-password/', methods=['GET', 'POST']) +@limiter.limit("30 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 hasla jest nieprawidlowy lub wygasl.', 'error') + return redirect(url_for('auth.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('Hasla nie sa 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 + + # Auto-verify email - user proved inbox access by using reset link + if not user.is_verified: + user.is_verified = True + user.verified_at = datetime.now() + user.verification_token = None + user.verification_token_expires = None + logger.info(f"Email auto-verified via password reset for {user.email}") + + db.commit() + + logger.info(f"Password reset successful for {user.email}") + flash('Haslo zostalo zmienione. Mozesz sie teraz zalogowac.', '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('Wystapil blad. Sprobuj ponownie.', 'error') + return redirect(url_for('auth.forgot_password')) + finally: + db.close() + + +# ============================================================ +# EMAIL VERIFICATION +# ============================================================ + +@bp.route('/verify-email/') +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 nieprawidlowy lub wygasl.', 'error') + return redirect(url_for('login')) + + if user.is_verified: + flash('Email zostal juz 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 zostal zweryfikowany! Mozesz sie teraz zalogowac.', 'success') + return redirect(url_for('login')) + + except Exception as e: + logger.error(f"Email verification error: {e}") + flash('Wystapil blad podczas weryfikacji.', 'error') + return redirect(url_for('login')) + finally: + db.close() + + +@bp.route('/resend-verification', methods=['GET', 'POST']) +@limiter.limit("15 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('Nieprawidlowy 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"Resend verification token (email failed) for {email}: {verification_token[:8]}...") + else: + logger.warning("Email service not configured") + logger.info(f"Resend verification token (no email) for {email}: {verification_token[:8]}...") + except Exception as e: + logger.error(f"Error resending verification email: {e}") + logger.info(f"Resend verification token (exception) for {email}: {verification_token[:8]}...") + + # 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') diff --git a/blueprints/public/__init__.py b/blueprints/public/__init__.py new file mode 100644 index 0000000..f956de1 --- /dev/null +++ b/blueprints/public/__init__.py @@ -0,0 +1,12 @@ +""" +Public Blueprint +================ + +Public-facing routes: index, company profiles, search, events. +""" + +from flask import Blueprint + +bp = Blueprint('public', __name__) + +from . import routes # noqa: E402, F401 diff --git a/blueprints/public/routes.py b/blueprints/public/routes.py new file mode 100644 index 0000000..3b64de9 --- /dev/null +++ b/blueprints/public/routes.py @@ -0,0 +1,862 @@ +""" +Public Routes +============= + +Public-facing routes: index, company profiles, search, events, new members, +connections map, release notes, dashboard. +""" + +import logging +from datetime import datetime, timedelta + +from flask import render_template, request, redirect, url_for, flash, session +from flask_login import login_required, current_user +from sqlalchemy import or_, func + +from . import bp +from database import ( + SessionLocal, + Company, + Category, + User, + CompanyRecommendation, + CompanyEvent, + CompanyDigitalMaturity, + CompanyWebsiteAnalysis, + CompanyQualityTracking, + CompanyWebsiteContent, + CompanyAIInsights, + CompanySocialMedia, + CompanyContact, + Person, + CompanyPerson, + GBPAudit, + ITAudit, + CompanyPKD, + NordaEvent, + EventAttendee, + AIChatConversation, + AIChatMessage, + UserSession, + SearchQuery, +) +from utils.helpers import sanitize_input +from extensions import limiter +from search_service import search_companies + +# Logger +logger = logging.getLogger(__name__) + +# Global constant (same as in app.py) +COMPANY_COUNT_MARKETING = 150 + + +@bp.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: + from datetime import date + companies = db.query(Company).filter_by(status='active').order_by(Company.name).all() + + # Get hierarchical categories (main categories with subcategories) + main_categories = db.query(Category).filter( + Category.parent_id.is_(None) + ).order_by(Category.display_order, Category.name).all() + + # All categories for backwards compatibility + 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]) + + # Najbliższe wydarzenie (dla bannera "Kto weźmie udział?") + next_event = db.query(NordaEvent).filter( + NordaEvent.event_date >= date.today() + ).order_by(NordaEvent.event_date.asc()).first() + + # Sprawdź czy użytkownik jest zapisany na to wydarzenie + user_registered = False + if next_event: + user_registered = db.query(EventAttendee).filter( + EventAttendee.event_id == next_event.id, + EventAttendee.user_id == current_user.id + ).first() is not None + + return render_template( + 'index.html', + companies=companies, + categories=categories, + main_categories=main_categories, + total_companies=total_companies, + total_categories=total_categories, + next_event=next_event, + user_registered=user_registered + ) + finally: + db.close() + + +@bp.route('/company/') +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() + # Get latest website analysis sorted by audit date (consistent with seo_audit_dashboard) + website_analysis = db.query(CompanyWebsiteAnalysis).filter_by( + company_id=company_id + ).order_by(CompanyWebsiteAnalysis.seo_audited_at.desc()).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() + + # Load recommendations (approved only, with recommender details) + recommendations = db.query(CompanyRecommendation).filter_by( + company_id=company_id, + status='approved' + ).join(User, CompanyRecommendation.user_id == User.id).order_by( + CompanyRecommendation.created_at.desc() + ).all() + + # Load people connected to company (zarząd, wspólnicy, prokurenci) + people = db.query(CompanyPerson).filter_by( + company_id=company_id + ).join(Person, CompanyPerson.person_id == Person.id).order_by( + CompanyPerson.role_category, + Person.nazwisko + ).all() + + # Load GBP audit (most recent) + gbp_audit = db.query(GBPAudit).filter_by( + company_id=company_id + ).order_by(GBPAudit.audit_date.desc()).first() + + # Load IT audit (most recent) + it_audit = db.query(ITAudit).filter_by( + company_id=company_id + ).order_by(ITAudit.audit_date.desc()).first() + + # Load PKD codes (all - primary first) + pkd_codes = db.query(CompanyPKD).filter_by( + company_id=company_id + ).order_by(CompanyPKD.is_primary.desc(), CompanyPKD.pkd_code).all() + + # Check if current user can enrich company data (admin or company owner) + can_enrich = False + if current_user.is_authenticated: + can_enrich = current_user.is_admin or (current_user.company_id == company.id) + + return render_template('company_detail.html', + company=company, + company_id=company.id, # For analytics conversion tracking + 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, + recommendations=recommendations, + people=people, + gbp_audit=gbp_audit, + it_audit=it_audit, + pkd_codes=pkd_codes, + can_enrich=can_enrich + ) + finally: + db.close() + + +@bp.route('/company/') +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() + + +@bp.route('/osoba/') +def person_detail(person_id): + """Person detail page - shows registry data and portal data if available""" + db = SessionLocal() + try: + # Get person with their company relationships + person = db.query(Person).filter_by(id=person_id).first() + if not person: + flash('Osoba nie znaleziona.', 'error') + return redirect(url_for('index')) + + # Get company roles with company details (only active companies) + company_roles = db.query(CompanyPerson).filter_by( + person_id=person_id + ).join(Company, CompanyPerson.company_id == Company.id).filter( + Company.status == 'active' + ).order_by( + CompanyPerson.role_category, + Company.name + ).all() + + # Try to find matching user account by name (for portal data) + # This is a simple match - in production might need more sophisticated matching + portal_user = None + name_parts = person.full_name().upper().split() + if len(name_parts) >= 2: + # Try to find user where first/last name matches + potential_users = db.query(User).filter( + User.name.isnot(None) + ).all() + for u in potential_users: + if u.name: + user_name_parts = u.name.upper().split() + # Check if at least first and last name match + if len(user_name_parts) >= 2: + if (user_name_parts[-1] in name_parts and # Last name match + any(part in user_name_parts for part in name_parts[:-1])): # First name match + portal_user = u + break + + return render_template('person_detail.html', + person=person, + company_roles=company_roles, + portal_user=portal_user + ) + finally: + db.close() + + +@bp.route('/company//recommend', methods=['GET', 'POST']) +def company_recommend(slug): + """Create recommendation for a company - requires login""" + db = SessionLocal() + try: + # Get company + company = db.query(Company).filter_by(slug=slug).first() + if not company: + flash('Firma nie znaleziona.', 'error') + return redirect(url_for('index')) + + # Handle POST (form submission) + if request.method == 'POST': + recommendation_text = request.form.get('recommendation_text', '').strip() + service_category = sanitize_input(request.form.get('service_category', ''), 200) + show_contact = request.form.get('show_contact') == '1' + + # Validation + if not recommendation_text or len(recommendation_text) < 50: + flash('Rekomendacja musi mieć co najmniej 50 znaków.', 'error') + return render_template('company/recommend.html', company=company) + + if len(recommendation_text) > 2000: + flash('Rekomendacja może mieć maksymalnie 2000 znaków.', 'error') + return render_template('company/recommend.html', company=company) + + # Prevent self-recommendation + if current_user.company_id == company.id: + flash('Nie możesz polecać własnej firmy.', 'error') + return redirect(url_for('company_detail', company_id=company.id)) + + # Check for duplicate (user already recommended this company) + existing = db.query(CompanyRecommendation).filter_by( + user_id=current_user.id, + company_id=company.id + ).first() + + if existing: + flash('Już poleciłeś tę firmę. Możesz edytować swoją wcześniejszą rekomendację.', 'error') + return redirect(url_for('company_detail', company_id=company.id)) + + # Create recommendation + recommendation = CompanyRecommendation( + company_id=company.id, + user_id=current_user.id, + recommendation_text=recommendation_text, + service_category=service_category if service_category else None, + show_contact=show_contact, + status='pending' + ) + db.add(recommendation) + db.commit() + + flash('Dziękujemy! Twoja rekomendacja została przesłana i oczekuje na moderację.', 'success') + return redirect(url_for('company_detail', company_id=company.id)) + + # Handle GET (show form) + return render_template('company/recommend.html', company=company) + finally: + db.close() + + +@bp.route('/search') +@login_required +def search(): + """Search companies and people 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] + + # Log search to analytics (SearchQuery table) + if query: + try: + analytics_session_id = session.get('analytics_session_id') + session_db_id = None + if analytics_session_id: + user_session = db.query(UserSession).filter_by(session_id=analytics_session_id).first() + if user_session: + session_db_id = user_session.id + + search_query = SearchQuery( + session_id=session_db_id, + user_id=current_user.id if current_user.is_authenticated else None, + query=query[:500], + query_normalized=query.lower().strip()[:500], + results_count=len(companies), + has_results=len(companies) > 0, + search_type='main', + filters_used={'category_id': category_id} if category_id else None + ) + db.add(search_query) + db.commit() + except Exception as e: + logger.error(f"Search logging error: {e}") + db.rollback() + + # 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}") + + # Search people by name (partial match) + people_results = [] + if query and len(query) >= 2: + q = f"%{query}%" + people_results = db.query(Person).filter( + or_( + Person.imiona.ilike(q), + Person.nazwisko.ilike(q), + func.concat(Person.imiona, ' ', Person.nazwisko).ilike(q) + ) + ).limit(20).all() + + # For each person, get their company connections count + for person in people_results: + person.company_count = len(set( + r.company_id for r in person.company_roles + if r.company and r.company.status == 'active' + )) + + logger.info(f"Search '{query}': {len(people_results)} people found") + + return render_template( + 'search_results.html', + companies=companies, + people=people_results, + query=query, + category_id=category_id, + result_count=len(companies) + ) + finally: + db.close() + + +@bp.route('/aktualnosci') +@login_required +def events(): + """Company events and news - latest updates from member companies""" + event_type_filter = request.args.get('type', '') + company_id = request.args.get('company', type=int) + page = request.args.get('page', 1, type=int) + per_page = 20 + + db = SessionLocal() + try: + # Build query + query = db.query(CompanyEvent).join(Company) + + # Apply filters + if event_type_filter: + query = query.filter(CompanyEvent.event_type == event_type_filter) + if company_id: + query = query.filter(CompanyEvent.company_id == company_id) + + # Order by date (newest first) + query = query.order_by( + CompanyEvent.event_date.desc(), + CompanyEvent.created_at.desc() + ) + + # Pagination + total_events = query.count() + events = query.limit(per_page).offset((page - 1) * per_page).all() + + # Get companies with events for filter dropdown + companies_with_events = db.query(Company).join(CompanyEvent).distinct().order_by(Company.name).all() + + # Event type statistics + event_types = db.query( + CompanyEvent.event_type, + func.count(CompanyEvent.id) + ).group_by(CompanyEvent.event_type).all() + + return render_template( + 'events.html', + events=events, + companies_with_events=companies_with_events, + event_types=event_types, + event_type_filter=event_type_filter, + company_id=company_id, + page=page, + per_page=per_page, + total_events=total_events, + total_pages=(total_events + per_page - 1) // per_page + ) + finally: + db.close() + + +@bp.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() + + +@bp.route('/mapa-polaczen') +def connections_map(): + """Company-person connections visualization page""" + return render_template('connections_map.html') + + +@bp.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() + + +@bp.route('/release-notes') +def release_notes(): + """Historia zmian platformy.""" + releases = [ + { + 'version': 'v1.21.0', + 'date': '30 stycznia 2026', + 'badges': ['new', 'improve', 'fix'], + 'new': [ + # MEGA WAŻNE - Konto użytkownika + 'Moje konto: Nowa sekcja ustawień - edycja danych, prywatność, bezpieczeństwo, blokady', + 'Forum: Panel moderacji dla admina - usuwanie wątków i odpowiedzi, przypinanie, blokowanie', + 'Tablica B2B: Panel moderacji dla admina - usuwanie i dezaktywacja ogłoszeń', + # UX + 'Formularze: Ikonka oka przy polach hasła (podgląd wpisywanego hasła)', + 'Forum: Ładny modal potwierdzenia zamiast systemowego okna', + 'Tablica B2B: Ładny modal potwierdzenia przy moderacji', + # Feedback + 'Forum: Wątek "Zgłoszenia i sugestie użytkowników" do zbierania feedbacku', + ], + 'improve': [ + 'Strona rejestracji: Poprawna nazwa "Norda Biznes Partner"', + 'Strona maintenance: Przyjazna strona podczas aktualizacji (502/503/504)', + ], + 'fix': [ + 'Reset hasła: Automatyczna weryfikacja emaila - użytkownik nie musi ponownie weryfikować', + 'Akademia: Usunięto placeholder video "Jak korzystać z NordaGPT"', + ], + }, + { + 'version': 'v1.20.0', + 'date': '29 stycznia 2026', + 'badges': ['new', 'improve', 'fix'], + 'new': [ + # MEGA WAŻNE - AI + 'NordaGPT: Upgrade do Gemini 3 Flash Preview - najnowszy model Google AI', + 'NordaGPT: Dwa modele do wyboru - Flash (darmowy) i Pro (płatny, lepszy)', + 'NordaGPT: 7x lepsze rozumowanie, thinking mode, 78% na SWE-bench', + 'NordaGPT: Osobne klucze API dla Free tier i Paid tier', + 'NordaGPT: Wyświetlanie szacowanego kosztu miesięcznego', + # MEGA WAŻNE - PWA + 'PWA: Aplikacja mobilna - możliwość instalacji na telefonie (iOS/Android)', + 'PWA: Web Manifest z ikonami 192px i 512px', + 'PWA: Apple Touch Icon dla urządzeń iOS', + # Aktualności + 'Aktualności: Obsługa wielu kategorii dla jednego ogłoszenia', + 'Aktualności: Nowe kategorie - Wewnętrzne, Zewnętrzne, Wydarzenie, Okazja biznesowa, Partnerstwo', + # Edukacja + 'Edukacja: Integracja wideo z portalem (modal player)', + 'Edukacja: Wideo "Wprowadzenie do Norda Biznes Partner"', + # Admin + 'Admin: Powiadomienia email o nowych rejestracjach - mail przy każdej rejestracji', + ], + 'improve': [ + 'Strona główna: Nowa ikona NordaGPT', + 'Stopka: Usunięcie nieaktywnych linków', + ], + 'fix': [ + 'Tablica B2B: Naprawiono błąd 500 przy dodawaniu ogłoszeń', + 'Kalendarz: Naprawiono błąd 500 przy dodawaniu wydarzeń', + 'Kontakty: Naprawiono nawigację w module', + ], + }, + { + 'version': 'v1.19.0', + 'date': '28 stycznia 2026', + 'badges': ['new', 'improve', 'security'], + 'new': [ + # MEGA WAŻNE - Prywatność + 'Prywatność: Ukrywanie telefonu i emaila w profilu (Ustawienia → Prywatność)', + 'Blokowanie użytkowników - możliwość blokowania kontaktów (Ustawienia → Blokady)', + 'Prywatność: Preferencje kanałów kontaktu (email, telefon, portal)', + 'Blokowanie: Bidirectional - zablokowany nie może wysłać wiadomości', + # MEGA WAŻNE - Kategorie + 'Kategorie: Hierarchiczna struktura - 4 główne grupy branżowe', + 'Katalog: Żółta kategoria "Do uzupełnienia" dla 27 firm', + 'Kategorie: Nowe podkategorie (Budownictwo ogólne, Produkcja ogólna, Usługi finansowe)', + # Nowe sekcje + 'Edukacja: Nowa sekcja Platforma Edukacyjna w menu', + 'Insights: Panel dla adminów do zbierania feedbacku', + 'Health: Monitorowanie nowych endpointów', + ], + 'improve': [ + 'Katalog: Tylko aktywna kategoria podświetlona', + 'Kategorie: Sortowanie malejąco po liczbie firm', + ], + 'security': [ + 'RODO: Automatyczne maskowanie danych wrażliwych w czacie (PESEL, karty, IBAN)', + 'Chat: Izolacja sesji - użytkownicy nie widzą pytań innych', + 'Admin: Anonimizacja zapytań w panelu analityki', + ], + }, + { + 'version': 'v1.17.0', + 'date': '26 stycznia 2026', + 'badges': ['new'], + 'new': [ + 'Aktualności: Nowa sekcja dla członków (Społeczność → Aktualności)', + 'Aktualności: Panel administracyjny do zarządzania ogłoszeniami', + 'Aktualności: Kategorie, statusy publikacji, przypinanie', + 'Aktualności: Linki zewnętrzne i załączniki PDF', + 'Pierwsze ogłoszenia: Baza noclegowa ARP, Konkurs Tytani Przedsiębiorczości', + ], + }, + { + 'version': 'v1.16.0', + 'date': '14 stycznia 2026', + 'badges': ['new', 'improve', 'fix'], + 'new': [ + # MEGA WAŻNE - Bezpieczeństwo + 'GeoIP Blocking - blokowanie krajów wysokiego ryzyka (RU, CN, KP, IR, BY)', + 'Email: Własna domena - wysyłka z noreply@nordabiznes.pl (DKIM, SPF, DMARC)', + # Raporty + 'Raporty: Nowa sekcja - staż członkostwa, Social Media, struktura branżowa', + 'Profil firmy: Data przystąpienia do Izby NORDA z kartą stażu', + 'Integracja: API CEIDG do pobierania danych JDG', + 'Bezpieczeństwo: Panel z oceną wszystkich mechanizmów ochrony', + ], + 'improve': [ + 'Dane firm: Rok założenia uzupełniony dla 71 z 111 firm (64%)', + 'Import dat przystąpienia: 57 firm z historią od 1997 roku', + ], + 'fix': [ + 'Analityka: Polskie znaki i pełne nazwy użytkowników', + ], + }, + { + 'version': 'v1.15.0', + 'date': '13 stycznia 2026', + 'badges': ['new', 'improve', 'fix'], + 'new': [ + # MEGA WAŻNE - NordaGPT + 'NordaGPT: Rozszerzony kontekst AI - rekomendacje, kalendarz, B2B, forum, KRS', + 'NordaGPT: Klikalne linki URL i email w odpowiedziach AI', + 'NordaGPT: Banner na stronie głównej z szybkim dostępem do chatu', + # Kalendarz + 'Kalendarz: Widok siatki miesięcznej z Quick RSVP', + 'Kalendarz: Banner wydarzenia na stronie głównej z uczestnikami', + # AI i Audyty + 'AI Enrichment - wzbogacanie danych firm przez AI z web search', + 'KRS Audit - parsowanie dokumentów PDF, progress bar', + 'Analityka: Panel /admin/analytics - śledzenie sesji użytkowników', + # Profile + 'Profil firmy: Wszystkie kody PKD, dane właściciela CEIDG', + 'Profil firmy: Zielone badge dla osób zweryfikowanych w KRS', + ], + 'improve': [ + 'Lepsze formatowanie odpowiedzi AI (Markdown)', + 'Banner NordaGPT minimalizowalny', + ], + 'fix': [ + 'Rate limit logowania i audytu SEO zwiększony', + ], + }, + { + 'version': 'v1.14.0', + 'date': '12 stycznia 2026', + 'badges': ['new', 'improve', 'fix'], + 'new': [ + 'Audyt GBP: Pełny audyt z Google Places API dla wszystkich firm', + 'Audyt GBP: Sekcja edukacyjna "Jak działa wizytówka Google?"', + 'Audyty: Sekcje inline na profilu firmy (SEO, GBP, Social Media, IT)', + ], + 'improve': [ + 'Ujednolicona 5-poziomowa skala kolorów dla audytów', + 'Social Media: Wynik jako procent zamiast liczby platform', + ], + 'fix': [ + 'Audyt GBP: Kategorie Google po polsku', + ], + }, + { + 'version': 'v1.13.0', + 'date': '11 stycznia 2026', + 'badges': ['new', 'improve'], + 'new': [ + # MEGA WAŻNE + 'Mapa Powiązań - interaktywna wizualizacja firm i osób (D3.js)', + 'Profile osób (/osoba) - dane z KRS/CEIDG i portalu', + 'AI Learning - uczenie chatbota z feedbacku użytkowników', + # Inne + 'Wyszukiwarka osób z częściowym dopasowaniem', + 'Logo firm w wynikach wyszukiwania', + 'Panel AI Usage: szczegółowy widok per użytkownik', + ], + 'improve': [ + 'Mapa: fullscreen modal, etykiety przy hover', + 'System toastów zamiast natywnych dialogów', + ], + }, + { + 'version': 'v1.11.0', + 'date': '10 stycznia 2026', + 'badges': ['new', 'improve', 'security'], + 'new': [ + # MEGA WAŻNE + 'Forum: Załączniki obrazów - drag & drop, Ctrl+V, do 10 plików', + 'Forum: Kategorie i statusy zgłoszeń (Propozycja, Błąd, Pytanie)', + 'Dokumentacja architektury - 19 plików, diagramy C4, Mermaid', + ], + 'improve': [ + 'Bezpieczny upload z walidacją magic bytes', + ], + 'security': [ + 'Usunięcie hardcoded credentials z kodu źródłowego', + 'Zmiana hasła PostgreSQL na produkcji', + ], + }, + { + 'version': 'v1.9.0', + 'date': '9 stycznia 2026', + 'badges': ['new', 'improve'], + 'new': [ + 'Panel Audyt GBP - przegląd profili Google Business', + 'Panel Audyt Social - pokrycie Social Media', + 'Tworzenie użytkowników z AI - wklejanie tekstu/screenshotów', + ], + 'improve': [ + 'Nowy pasek Admin z pogrupowanymi funkcjami', + ], + }, + { + 'version': 'v1.8.0', + 'date': '8 stycznia 2026', + 'badges': ['new'], + 'new': [ + 'Panel Audyt IT - kompleksowy audyt infrastruktury IT firm', + 'Eksport audytów IT do CSV', + ], + }, + { + 'version': 'v1.7.0', + 'date': '6 stycznia 2026', + 'badges': ['new'], + 'new': [ + 'Panel Audyt SEO - analiza wydajności stron www firm', + 'Integracja z Google PageSpeed Insights API', + ], + }, + { + 'version': 'v1.6.0', + 'date': '29 grudnia 2025', + 'badges': ['new'], + 'new': [ + 'System newsów i wzmianek medialnych o firmach', + 'Panel moderacji newsów dla adminów', + 'Integracja z Brave Search API', + ], + }, + { + 'version': 'v1.5.0', + 'date': '15 grudnia 2025', + 'badges': ['new', 'improve'], + 'new': [ + 'Panel Social Media - zarządzanie profilami społecznościowymi', + 'Weryfikacja aktywności profili Social Media', + ], + 'improve': [ + 'Ulepszony profil firmy z sekcją Social Media', + ], + }, + { + 'version': 'v1.4.0', + 'date': '1 grudnia 2025', + 'badges': ['new'], + 'new': [ + 'System rekomendacji między firmami', + 'Panel składek członkowskich', + 'Kalendarz wydarzeń Norda Biznes', + ], + }, + { + 'version': 'v1.3.0', + 'date': '28 listopada 2025', + 'badges': ['new', 'improve'], + 'new': [ + 'Chatbot AI (NordaGPT) z wiedzą o wszystkich firmach', + 'Wyszukiwarka firm z synonimami i fuzzy matching', + ], + 'improve': [ + 'Ulepszony SearchService z PostgreSQL FTS', + ], + }, + { + 'version': 'v1.2.0', + 'date': '25 listopada 2025', + 'badges': ['new'], + 'new': [ + 'System wiadomości prywatnych między użytkownikami', + 'Powiadomienia o nowych wiadomościach', + ], + }, + { + 'version': 'v1.1.0', + 'date': '24 listopada 2025', + 'badges': ['new', 'improve'], + 'new': [ + 'Rejestracja i logowanie użytkowników', + 'Profile użytkowników powiązane z firmami', + ], + 'improve': [ + 'Responsywny design na urządzenia mobilne', + ], + }, + { + 'version': 'v1.0.0', + 'date': '23 listopada 2025', + 'badges': ['new'], + 'new': [ + 'Oficjalny start platformy Norda Biznes Partner', + 'Katalog 111 firm członkowskich', + 'Wyszukiwarka firm po nazwie, kategorii, usługach', + 'Profile firm z pełnymi danymi kontaktowymi', + ], + }, + ] + + # Statystyki (używa globalnej stałej COMPANY_COUNT_MARKETING) + db = SessionLocal() + try: + stats = { + 'companies': COMPANY_COUNT_MARKETING, + 'categories': db.query(Category).filter(Category.parent_id.isnot(None)).count(), + } + finally: + db.close() + + return render_template('release_notes.html', releases=releases, stats=stats) diff --git a/docs/REFACTORING_STATUS.md b/docs/REFACTORING_STATUS.md index 8e8eed0..9501fe4 100644 --- a/docs/REFACTORING_STATUS.md +++ b/docs/REFACTORING_STATUS.md @@ -16,33 +16,99 @@ | contacts | `/kontakty` | 6 | ✅ Przetestowane | | classifieds | `/tablica` | 4 | ✅ Przetestowane | | calendar | `/kalendarz` | 3 | ✅ Przetestowane | +| education | `/edukacja` | 2 | ✅ Przetestowane | -**Nowa struktura plików:** -``` -nordabiz/ -├── blueprints/ -│ ├── __init__.py # register_blueprints() -│ ├── reports/ -│ │ ├── __init__.py -│ │ └── routes.py -│ └── community/ -│ ├── __init__.py -│ ├── contacts/ -│ ├── classifieds/ -│ └── calendar/ -├── utils/ -│ ├── decorators.py # admin_required, etc. -│ ├── helpers.py # sanitize_input, etc. -│ ├── notifications.py -│ ├── analytics.py -│ ├── middleware.py -│ ├── context_processors.py -│ └── error_handlers.py -├── extensions.py # csrf, login_manager, limiter -└── config.py # Dev/Prod configurations +--- + +### Faza 2a - 🔄 W TRAKCIE (DEV) + +**Data rozpoczęcia:** 2026-01-31 +**Strategia:** Alias Bridge (bezpieczna migracja) + +| Blueprint | Routes | Status | +|-----------|--------|--------| +| auth | 20 | ✅ Utworzony, aliasy aktywne | +| public | 11 | ✅ Utworzony, aliasy aktywne | + +**Pliki utworzone:** +- `blueprints/auth/__init__.py` +- `blueprints/auth/routes.py` (1,040 linii) +- `blueprints/public/__init__.py` +- `blueprints/public/routes.py` (862 linii) + +**Stan app.py:** +- Duplikaty tras zakomentowane (prefix `_old_`) +- Aliasy aktywne w `blueprints/__init__.py` +- Oczekuje na cleanup martwego kodu + +--- + +## Metodologia Refaktoringu (Alias Bridge) + +### Dlaczego ta metoda? + +Problem: 125+ wywołań `url_for()` w szablonach i kodzie używa starych nazw (`url_for('login')`). +Rozwiązanie: Aliasy pozwalają na stopniową migrację bez "Big Bang". + +### Procedura dla każdej fazy + +#### Krok 1: Utworzenie blueprintu + +```bash +mkdir -p blueprints/ +touch blueprints//__init__.py +touch blueprints//routes.py ``` -**Redukcja app.py:** ~14,455 → ~13,699 linii (~5.2%) +```python +# blueprints//__init__.py +from flask import Blueprint +bp = Blueprint('', __name__) +from . import routes +``` + +#### Krok 2: Przeniesienie tras + +1. Skopiuj funkcje z app.py do `blueprints//routes.py` +2. Zmień `@app.route` na `@bp.route` +3. Zaktualizuj importy (używaj `from extensions import limiter`) +4. Wewnętrzne url_for: użyj `.endpoint` (z kropką) + +#### Krok 3: Rejestracja + aliasy + +```python +# blueprints/__init__.py +from blueprints. import bp as _bp +app.register_blueprint(_bp) + +_create_endpoint_aliases(app, _bp, { + 'stara_nazwa': '.nowa_nazwa', + # ... +}) +``` + +#### Krok 4: Dezaktywacja duplikatów w app.py + +```python +# PRZED: +@app.route('/endpoint') +def funkcja(): + +# PO: +# @app.route('/endpoint') # MOVED TO . +def _old_funkcja(): +``` + +#### Krok 5: Test + +```bash +python3 -c "from app import app; print('OK')" +# Test wszystkich endpointów +``` + +#### Krok 6: Cleanup (po weryfikacji na PROD) + +Usuń funkcje z prefiksem `_old_` z app.py. --- @@ -52,51 +118,79 @@ nordabiz/ ### Podsumowanie faz -| Faza | Zakres | Routes | Zależności | Status | -|------|--------|--------|------------|--------| -| **1** | reports, community, education | 19 | utils/helpers | ✅ WDROŻONA | -| **2** | **auth + public (RAZEM!)** | ~28 | utils/helpers | 🔜 Następna | -| **3** | account, forum | ~25 | Faza 2 | ⏳ | -| **4** | messages, notifications | ~10 | Faza 2 | ⏳ | -| **5** | chat | ~8 | Faza 2 | ⏳ | -| **6** | admin (8 modułów) | ~60 | Faza 2 + decorators | ⏳ | -| **7** | audits (6 modułów) | ~35 | Faza 2 + decorators | ⏳ | -| **8** | zopk (5 modułów) | ~32 | Faza 2 + decorators | ⏳ | -| **9** | api misc, honeypot | ~25 | Faza 2 | ⏳ | -| **10** | cleanup | - | Wszystkie | ⏳ | +| Faza | Zakres | Routes | Status | +|------|--------|--------|--------| +| **1** | reports, community, education | 19 | ✅ WDROŻONA | +| **2a** | auth + public | 31 | 🔄 DEV - aliasy aktywne | +| **2b** | cleanup app.py | - | ⏳ Po teście PROD | +| **3** | account, forum | ~25 | ⏳ | +| **4** | messages, notifications | ~10 | ⏳ | +| **5** | chat | ~8 | ⏳ | +| **6** | admin (8 modułów) | ~60 | ⏳ | +| **7** | audits (6 modułów) | ~35 | ⏳ | +| **8** | zopk (5 modułów) | ~32 | ⏳ | +| **9** | api misc, honeypot | ~25 | ⏳ | +| **10** | final cleanup | - | ⏳ | -**⚠️ WAŻNE:** Faza 2 jest krytyczna - `auth` i `public` muszą być wdrożone RAZEM! -Powód: `utils/decorators.py` używa `url_for('auth.login')` i `url_for('public.index')` - -**Cel:** Redukcja app.py z 15,570 → ~500 linii +**Cel końcowy:** Redukcja app.py z 15,570 → ~500 linii --- -## Weryfikacja wdrożenia Fazy 1 +## Metryki optymalizacji + +### Po Fazie 1 +- app.py: 15,570 → 13,699 linii (-12%) + +### Po Fazie 2a (przed cleanup) +- app.py: 15,576 linii (+6 komentarzy) +- Nowe: auth/routes.py (1,040) + public/routes.py (862) +- **Martwy kod do usunięcia:** ~1,500 linii + +### Po Fazie 2a cleanup (oczekiwane) +- app.py: ~14,000 linii (-10% od stanu wyjściowego) + +--- + +## Weryfikacja przed wdrożeniem + +### Checklist DEV ```bash -# Sprawdzenie czy blueprinty działają na produkcji -curl -sI https://nordabiznes.pl/health # → 200 OK -curl -sI https://nordabiznes.pl/raporty/ # → 302 (wymaga logowania) -curl -sI https://nordabiznes.pl/kontakty/ # → 302 (wymaga logowania) -curl -sI https://nordabiznes.pl/tablica/ # → 302 (wymaga logowania) -curl -sI https://nordabiznes.pl/kalendarz/ # → 302 (wymaga logowania) +# 1. Import aplikacji +python3 -c "from app import app; print('OK')" + +# 2. Test endpointów +python3 -c " +from app import app +with app.test_client() as c: + assert c.get('/').status_code == 200 + assert c.get('/login').status_code == 200 + assert c.get('/health').status_code == 200 +print('All endpoints OK') +" + +# 3. Test url_for (aliasy) +python3 -c " +from app import app +from flask import url_for +with app.test_request_context(): + assert url_for('login') == '/login' + assert url_for('auth.login') == '/login' + assert url_for('index') == '/' + assert url_for('public.index') == '/' +print('Aliases OK') +" ``` -**Zweryfikowano:** 2026-01-31 - wszystkie endpointy działają poprawnie. +### Checklist PROD ---- - -## Naprawione błędy podczas testów DEV - -| Plik | Problem | Rozwiązanie | -|------|---------|-------------| -| `blueprints/reports/routes.py` | `url_for('report_*')` | `url_for('.report_*')` | -| `templates/contacts/detail.html` | `contact_delete` | `contacts.contact_delete` | -| `templates/contacts/list.html` | `contacts_list` (3x) | `contacts.contacts_list` | -| `templates/classifieds/index.html` | `classifieds_index` (6x) | `classifieds.classifieds_index` | -| `templates/classifieds/view.html` | `classifieds_close`, `classifieds_index` | Dodano prefix `classifieds.` | -| `templates/calendar/event.html` | `calendar_rsvp` | `calendar.calendar_rsvp` | +```bash +# Po wdrożeniu +curl -sI https://nordabiznes.pl/health | head -1 +curl -sI https://nordabiznes.pl/ | head -1 +curl -sI https://nordabiznes.pl/login | head -1 +curl -sI https://nordabiznes.pl/release-notes | head -1 +``` --- @@ -105,14 +199,17 @@ curl -sI https://nordabiznes.pl/kalendarz/ # → 302 (wymaga logowania) 1. **url_for w blueprintach:** - Wewnątrz blueprintu: `url_for('.endpoint')` (z kropką) - W szablonach: `url_for('blueprint.endpoint')` (pełna nazwa) + - Aliasy: `url_for('stara_nazwa')` = `url_for('blueprint.nowa_nazwa')` -2. **Testowanie po migracji:** - - Sprawdź WSZYSTKIE szablony używające `url_for()` - - Użyj grep: `grep -r "url_for\(" templates/` +2. **Kolejność operacji:** + - Najpierw blueprinty + aliasy + - Potem dezaktywacja duplikatów + - Cleanup dopiero po teście PROD -3. **Restart serwera:** - - Flask cachuje szablony - wymaga pełnego restartu - - Zabij proces i uruchom od nowa +3. **Bezpieczeństwo:** + - Zawsze zachowuj martwy kod do weryfikacji + - Prefix `_old_` dla zdezaktywowanych funkcji + - Rollback: odkomentuj `@app.route` --- diff --git a/extensions.py b/extensions.py index 14167f2..7d80534 100644 --- a/extensions.py +++ b/extensions.py @@ -21,8 +21,8 @@ login_manager = LoginManager() login_manager.login_view = 'auth.login' login_manager.login_message = 'Zaloguj się, aby uzyskać dostęp do tej strony.' -# Rate Limiter (storage configured in create_app) +# Rate Limiter (storage configured in app.py) limiter = Limiter( key_func=get_remote_address, - default_limits=["200 per day", "50 per hour"] + default_limits=["1000 per day", "200 per hour"] )