refactor(phase2a): Extract auth + public blueprints with Alias Bridge

Phase 2a of modular monolith refactoring:

New blueprints:
- blueprints/auth/routes.py (1,040 lines, 20 routes)
  - login, logout, register, verify_2fa, settings_2fa
  - forgot_password, reset_password, verify_email
  - konto_dane, konto_prywatnosc, konto_bezpieczenstwo, konto_blokady
- blueprints/public/routes.py (862 lines, 11 routes)
  - index, company_detail, person_detail, search
  - dashboard, events, new_members, release_notes

Alias Bridge strategy:
- Both url_for('login') and url_for('auth.login') work
- Templates don't require changes (backward compatible)
- Original routes in app.py marked with _old_ prefix (dead code)

Next step: Cleanup dead code from app.py after production verification

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Maciej Pienczyn 2026-01-31 07:28:18 +01:00
parent 9c39ff06ba
commit d5adf029aa
8 changed files with 2299 additions and 176 deletions

220
app.py
View File

@ -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/<int:company_id>')
# @app.route('/company/<int:company_id>') # 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/<slug>')
# @app.route('/company/<slug>') # 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/<int:person_id>')
def person_detail(person_id):
# @app.route('/osoba/<int:person_id>') # 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/<slug>/recommend', methods=['GET', 'POST'])
# @app.route('/company/<slug>/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/<id>, /tablica/<id>/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/<int:block_id>', methods=['POST'])
@login_required
def settings_blocks_remove(block_id):
# @app.route('/settings/blocks/remove/<int:block_id>', 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/<int:block_id>', methods=['POST'])
@login_required
def konto_blokady_usun(block_id):
# @app.route('/konto/blokady/usun/<int:block_id>', 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/<token>', methods=['GET', 'POST'])
@limiter.limit("30 per hour")
def reset_password(token):
# @app.route('/reset-password/<token>', 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/<token>')
def verify_email(token):
# @app.route('/verify-email/<token>') # 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 = [
{

View File

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

View File

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

1040
blueprints/auth/routes.py Normal file

File diff suppressed because it is too large Load Diff

View File

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

862
blueprints/public/routes.py Normal file
View File

@ -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/<int:company_id>')
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/<slug>')
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/<int:person_id>')
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/<slug>/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
'<strong>Moje konto: Nowa sekcja ustawień</strong> - edycja danych, prywatność, bezpieczeństwo, blokady',
'<strong>Forum: Panel moderacji dla admina</strong> - usuwanie wątków i odpowiedzi, przypinanie, blokowanie',
'<strong>Tablica B2B: Panel moderacji dla admina</strong> - 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': [
'<strong>Reset hasła: Automatyczna weryfikacja emaila</strong> - 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
'<strong>NordaGPT: Upgrade do Gemini 3 Flash Preview</strong> - najnowszy model Google AI',
'<strong>NordaGPT: Dwa modele do wyboru</strong> - 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
'<strong>PWA: Aplikacja mobilna</strong> - 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
'<strong>Admin: Powiadomienia email o nowych rejestracjach</strong> - 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ść
'<strong>Prywatność: Ukrywanie telefonu i emaila</strong> w profilu (Ustawienia → Prywatność)',
'<strong>Blokowanie użytkowników</strong> - 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
'<strong>Kategorie: Hierarchiczna struktura</strong> - 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
'<strong>Edukacja: Nowa sekcja</strong> 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': [
'<strong>RODO: Automatyczne maskowanie danych wrażliwych</strong> w czacie (PESEL, karty, IBAN)',
'<strong>Chat: Izolacja sesji</strong> - użytkownicy nie widzą pytań innych',
'Admin: Anonimizacja zapytań w panelu analityki',
],
},
{
'version': 'v1.17.0',
'date': '26 stycznia 2026',
'badges': ['new'],
'new': [
'<strong>Aktualności: Nowa sekcja</strong> 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
'<strong>GeoIP Blocking</strong> - blokowanie krajów wysokiego ryzyka (RU, CN, KP, IR, BY)',
'<strong>Email: Własna domena</strong> - wysyłka z noreply@nordabiznes.pl (DKIM, SPF, DMARC)',
# Raporty
'<strong>Raporty: Nowa sekcja</strong> - 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
'<strong>NordaGPT: Rozszerzony kontekst AI</strong> - rekomendacje, kalendarz, B2B, forum, KRS',
'<strong>NordaGPT: Klikalne linki</strong> URL i email w odpowiedziach AI',
'<strong>NordaGPT: Banner na stronie głównej</strong> z szybkim dostępem do chatu',
# Kalendarz
'<strong>Kalendarz: Widok siatki miesięcznej</strong> z Quick RSVP',
'Kalendarz: Banner wydarzenia na stronie głównej z uczestnikami',
# AI i Audyty
'<strong>AI Enrichment</strong> - wzbogacanie danych firm przez AI z web search',
'<strong>KRS Audit</strong> - parsowanie dokumentów PDF, progress bar',
'<strong>Analityka: Panel /admin/analytics</strong> - ś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': [
'<strong>Audyt GBP: Pełny audyt</strong> 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
'<strong>Mapa Powiązań</strong> - interaktywna wizualizacja firm i osób (D3.js)',
'<strong>Profile osób</strong> (/osoba) - dane z KRS/CEIDG i portalu',
'<strong>AI Learning</strong> - 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
'<strong>Forum: Załączniki obrazów</strong> - drag & drop, Ctrl+V, do 10 plików',
'<strong>Forum: Kategorie i statusy</strong> zgłoszeń (Propozycja, Błąd, Pytanie)',
'<strong>Dokumentacja architektury</strong> - 19 plików, diagramy C4, Mermaid',
],
'improve': [
'Bezpieczny upload z walidacją magic bytes',
],
'security': [
'<strong>Usunięcie hardcoded credentials</strong> z kodu źródłowego',
'Zmiana hasła PostgreSQL na produkcji',
],
},
{
'version': 'v1.9.0',
'date': '9 stycznia 2026',
'badges': ['new', 'improve'],
'new': [
'<strong>Panel Audyt GBP</strong> - przegląd profili Google Business',
'<strong>Panel Audyt Social</strong> - pokrycie Social Media',
'<strong>Tworzenie użytkowników z AI</strong> - wklejanie tekstu/screenshotów',
],
'improve': [
'Nowy pasek Admin z pogrupowanymi funkcjami',
],
},
{
'version': 'v1.8.0',
'date': '8 stycznia 2026',
'badges': ['new'],
'new': [
'<strong>Panel Audyt IT</strong> - kompleksowy audyt infrastruktury IT firm',
'Eksport audytów IT do CSV',
],
},
{
'version': 'v1.7.0',
'date': '6 stycznia 2026',
'badges': ['new'],
'new': [
'<strong>Panel Audyt SEO</strong> - analiza wydajności stron www firm',
'<strong>Integracja z Google PageSpeed Insights API</strong>',
],
},
{
'version': 'v1.6.0',
'date': '29 grudnia 2025',
'badges': ['new'],
'new': [
'<strong>System newsów</strong> i wzmianek medialnych o firmach',
'Panel moderacji newsów dla adminów',
'<strong>Integracja z Brave Search API</strong>',
],
},
{
'version': 'v1.5.0',
'date': '15 grudnia 2025',
'badges': ['new', 'improve'],
'new': [
'<strong>Panel Social Media</strong> - 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': [
'<strong>System rekomendacji</strong> między firmami',
'<strong>Panel składek członkowskich</strong>',
'<strong>Kalendarz wydarzeń</strong> Norda Biznes',
],
},
{
'version': 'v1.3.0',
'date': '28 listopada 2025',
'badges': ['new', 'improve'],
'new': [
'<strong>Chatbot AI (NordaGPT)</strong> z wiedzą o wszystkich firmach',
'<strong>Wyszukiwarka firm</strong> z synonimami i fuzzy matching',
],
'improve': [
'Ulepszony SearchService z PostgreSQL FTS',
],
},
{
'version': 'v1.2.0',
'date': '25 listopada 2025',
'badges': ['new'],
'new': [
'<strong>System wiadomości prywatnych</strong> między użytkownikami',
'Powiadomienia o nowych wiadomościach',
],
},
{
'version': 'v1.1.0',
'date': '24 listopada 2025',
'badges': ['new', 'improve'],
'new': [
'<strong>Rejestracja i logowanie</strong> 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': [
'<strong>Oficjalny start platformy Norda Biznes Partner</strong>',
'<strong>Katalog 111 firm członkowskich</strong>',
'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)

View File

@ -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/<nazwa>
touch blueprints/<nazwa>/__init__.py
touch blueprints/<nazwa>/routes.py
```
**Redukcja app.py:** ~14,455 → ~13,699 linii (~5.2%)
```python
# blueprints/<nazwa>/__init__.py
from flask import Blueprint
bp = Blueprint('<nazwa>', __name__)
from . import routes
```
#### Krok 2: Przeniesienie tras
1. Skopiuj funkcje z app.py do `blueprints/<nazwa>/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.<nazwa> import bp as <nazwa>_bp
app.register_blueprint(<nazwa>_bp)
_create_endpoint_aliases(app, <nazwa>_bp, {
'stara_nazwa': '<nazwa>.nowa_nazwa',
# ...
})
```
#### Krok 4: Dezaktywacja duplikatów w app.py
```python
# PRZED:
@app.route('/endpoint')
def funkcja():
# PO:
# @app.route('/endpoint') # MOVED TO <nazwa>.<endpoint>
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`
---

View File

@ -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"]
)