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:
parent
9c39ff06ba
commit
d5adf029aa
220
app.py
220
app.py
@ -26,10 +26,8 @@ from collections import deque
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from datetime import datetime, timedelta, date
|
from datetime import datetime, timedelta, date
|
||||||
from flask import Flask, render_template, request, jsonify, redirect, url_for, flash, session, Response, send_file
|
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_login import login_user, logout_user, login_required, current_user
|
||||||
from flask_wtf.csrf import CSRFProtect
|
# Note: CSRFProtect, Limiter, LoginManager are imported from extensions.py (line ~250)
|
||||||
from flask_limiter import Limiter
|
|
||||||
from flask_limiter.util import get_remote_address
|
|
||||||
from werkzeug.security import generate_password_hash, check_password_hash
|
from werkzeug.security import generate_password_hash, check_password_hash
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
from user_agents import parse as parse_user_agent
|
from user_agents import parse as parse_user_agent
|
||||||
@ -246,8 +244,10 @@ def ensure_url_filter(url):
|
|||||||
return f'https://{url}'
|
return f'https://{url}'
|
||||||
return url
|
return url
|
||||||
|
|
||||||
# Initialize CSRF protection
|
# Initialize extensions from centralized extensions.py
|
||||||
csrf = CSRFProtect(app)
|
from extensions import csrf, limiter, login_manager
|
||||||
|
|
||||||
|
csrf.init_app(app)
|
||||||
|
|
||||||
# Initialize rate limiter with Redis storage (persistent across restarts)
|
# Initialize rate limiter with Redis storage (persistent across restarts)
|
||||||
# Falls back to memory if Redis unavailable
|
# Falls back to memory if Redis unavailable
|
||||||
@ -261,12 +261,13 @@ try:
|
|||||||
except Exception:
|
except Exception:
|
||||||
logger.warning("Redis unavailable, rate limiter using memory storage")
|
logger.warning("Redis unavailable, rate limiter using memory storage")
|
||||||
|
|
||||||
limiter = Limiter(
|
# Note: default_limits are set in extensions.py
|
||||||
app=app,
|
# Here we only configure storage
|
||||||
key_func=get_remote_address,
|
if _redis_available:
|
||||||
default_limits=["1000 per day", "200 per hour"],
|
limiter._storage_uri = "redis://localhost:6379/0"
|
||||||
storage_uri="redis://localhost:6379/0" if _redis_available else "memory://"
|
else:
|
||||||
)
|
limiter._storage_uri = "memory://"
|
||||||
|
limiter.init_app(app)
|
||||||
|
|
||||||
@limiter.request_filter
|
@limiter.request_filter
|
||||||
def is_admin_exempt():
|
def is_admin_exempt():
|
||||||
@ -280,10 +281,9 @@ def is_admin_exempt():
|
|||||||
# Initialize database
|
# Initialize database
|
||||||
init_db()
|
init_db()
|
||||||
|
|
||||||
# Initialize Login Manager
|
# Initialize Login Manager (imported from extensions.py)
|
||||||
login_manager = LoginManager()
|
|
||||||
login_manager.init_app(app)
|
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.'
|
login_manager.login_message = 'Zaloguj się, aby uzyskać dostęp do tej strony.'
|
||||||
|
|
||||||
# Initialize Gemini service
|
# 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('/')
|
# @app.route('/') # MOVED TO public.index
|
||||||
def index():
|
def _old_index():
|
||||||
"""Homepage - landing page for guests, company directory for logged in users"""
|
"""Homepage - landing page for guests, company directory for logged in users"""
|
||||||
if not current_user.is_authenticated:
|
if not current_user.is_authenticated:
|
||||||
# Landing page for guests
|
# Landing page for guests
|
||||||
@ -1147,9 +1150,9 @@ def index():
|
|||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
@app.route('/company/<int:company_id>')
|
# @app.route('/company/<int:company_id>') # MOVED TO public.company_detail
|
||||||
# @login_required # Public access
|
# @login_required # Public access
|
||||||
def company_detail(company_id):
|
def _old_company_detail(company_id):
|
||||||
"""Company detail page - requires login"""
|
"""Company detail page - requires login"""
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
@ -1249,9 +1252,9 @@ def company_detail(company_id):
|
|||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
@app.route('/company/<slug>')
|
# @app.route('/company/<slug>') # MOVED TO public.company_detail_by_slug
|
||||||
# @login_required # Disabled - public access
|
# @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"""
|
"""Company detail page by slug - requires login"""
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
@ -1265,8 +1268,8 @@ def company_detail_by_slug(slug):
|
|||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
@app.route('/osoba/<int:person_id>')
|
# @app.route('/osoba/<int:person_id>') # MOVED TO public.person_detail
|
||||||
def person_detail(person_id):
|
def _old_person_detail(person_id):
|
||||||
"""Person detail page - shows registry data and portal data if available"""
|
"""Person detail page - shows registry data and portal data if available"""
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
@ -1314,9 +1317,9 @@ def person_detail(person_id):
|
|||||||
db.close()
|
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
|
# @login_required # Disabled - public access
|
||||||
def company_recommend(slug):
|
def _old_company_recommend(slug):
|
||||||
"""Create recommendation for a company - requires login"""
|
"""Create recommendation for a company - requires login"""
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
@ -1377,9 +1380,9 @@ def company_recommend(slug):
|
|||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
@app.route('/search')
|
# @app.route('/search') # MOVED TO public.search
|
||||||
@login_required
|
# @login_required
|
||||||
def search():
|
def _old_search():
|
||||||
"""Search companies and people with advanced matching - requires login"""
|
"""Search companies and people with advanced matching - requires login"""
|
||||||
query = request.args.get('q', '')
|
query = request.args.get('q', '')
|
||||||
category_id = request.args.get('category', type=int)
|
category_id = request.args.get('category', type=int)
|
||||||
@ -1459,9 +1462,9 @@ def search():
|
|||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
@app.route('/aktualnosci')
|
# @app.route('/aktualnosci') # MOVED TO public.events
|
||||||
@login_required
|
# @login_required
|
||||||
def events():
|
def _old_events():
|
||||||
"""Company events and news - latest updates from member companies"""
|
"""Company events and news - latest updates from member companies"""
|
||||||
from sqlalchemy import func
|
from sqlalchemy import func
|
||||||
|
|
||||||
@ -3967,12 +3970,12 @@ def api_delete_recommendation(rec_id):
|
|||||||
# Routes: /tablica, /tablica/nowe, /tablica/<id>, /tablica/<id>/zakoncz
|
# 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')
|
# @app.route('/nowi-czlonkowie') # MOVED TO public.new_members
|
||||||
@login_required
|
# @login_required
|
||||||
def new_members():
|
def _old_new_members():
|
||||||
"""Lista nowych firm członkowskich"""
|
"""Lista nowych firm członkowskich"""
|
||||||
days = request.args.get('days', 90, type=int)
|
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'])
|
# @app.route('/register', methods=['GET', 'POST']) # MOVED TO auth.register
|
||||||
@limiter.limit("50 per hour;200 per day") # Increased limits for better UX
|
# @limiter.limit("50 per hour;200 per day")
|
||||||
def register():
|
def _old_register():
|
||||||
"""User registration"""
|
"""User registration"""
|
||||||
if current_user.is_authenticated:
|
if current_user.is_authenticated:
|
||||||
return redirect(url_for('index'))
|
return redirect(url_for('index'))
|
||||||
@ -4119,9 +4125,9 @@ def register():
|
|||||||
return render_template('auth/register.html')
|
return render_template('auth/register.html')
|
||||||
|
|
||||||
|
|
||||||
@app.route('/login', methods=['GET', 'POST'])
|
# @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")
|
# @limiter.limit("1000 per hour" if os.getenv('FLASK_ENV') == 'development' else "60 per minute;500 per hour")
|
||||||
def login():
|
def _old_login():
|
||||||
"""User login"""
|
"""User login"""
|
||||||
if current_user.is_authenticated:
|
if current_user.is_authenticated:
|
||||||
return redirect(url_for('index'))
|
return redirect(url_for('index'))
|
||||||
@ -4220,9 +4226,9 @@ def login():
|
|||||||
return render_template('auth/login.html')
|
return render_template('auth/login.html')
|
||||||
|
|
||||||
|
|
||||||
@app.route('/logout')
|
# @app.route('/logout') # MOVED TO auth.logout
|
||||||
@login_required
|
# @login_required
|
||||||
def logout():
|
def _old_logout():
|
||||||
"""User logout"""
|
"""User logout"""
|
||||||
# Clear 2FA session flag
|
# Clear 2FA session flag
|
||||||
session.pop('2fa_verified', None)
|
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'])
|
# @app.route('/verify-2fa', methods=['GET', 'POST']) # MOVED TO auth.verify_2fa
|
||||||
@limiter.limit("10 per minute")
|
# @limiter.limit("10 per minute")
|
||||||
def verify_2fa():
|
def _old_verify_2fa():
|
||||||
"""Verify 2FA code during login"""
|
"""Verify 2FA code during login"""
|
||||||
# Check if there's a pending 2FA login
|
# Check if there's a pending 2FA login
|
||||||
pending_user_id = session.get('2fa_pending_user_id')
|
pending_user_id = session.get('2fa_pending_user_id')
|
||||||
@ -4301,9 +4307,9 @@ def verify_2fa():
|
|||||||
return render_template('auth/verify_2fa.html')
|
return render_template('auth/verify_2fa.html')
|
||||||
|
|
||||||
|
|
||||||
@app.route('/settings/2fa', methods=['GET', 'POST'])
|
# @app.route('/settings/2fa', methods=['GET', 'POST']) # MOVED TO auth.settings_2fa
|
||||||
@login_required
|
# @login_required
|
||||||
def settings_2fa():
|
def _old_settings_2fa():
|
||||||
"""2FA settings - enable/disable"""
|
"""2FA settings - enable/disable"""
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
@ -4396,9 +4402,9 @@ def settings_2fa():
|
|||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
@app.route('/settings/privacy', methods=['GET', 'POST'])
|
# @app.route('/settings/privacy', methods=['GET', 'POST']) # MOVED TO auth.settings_privacy
|
||||||
@login_required
|
# @login_required
|
||||||
def settings_privacy():
|
def _old_settings_privacy():
|
||||||
"""Privacy settings - control visibility of phone and email"""
|
"""Privacy settings - control visibility of phone and email"""
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
@ -4434,9 +4440,9 @@ def settings_privacy():
|
|||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
@app.route('/settings/blocks', methods=['GET'])
|
# @app.route('/settings/blocks', methods=['GET']) # MOVED TO auth.settings_blocks
|
||||||
@login_required
|
# @login_required
|
||||||
def settings_blocks():
|
def _old_settings_blocks():
|
||||||
"""Manage blocked users"""
|
"""Manage blocked users"""
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
@ -4461,9 +4467,9 @@ def settings_blocks():
|
|||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
@app.route('/settings/blocks/add', methods=['POST'])
|
# @app.route('/settings/blocks/add', methods=['POST']) # MOVED TO auth.settings_blocks_add
|
||||||
@login_required
|
# @login_required
|
||||||
def settings_blocks_add():
|
def _old_settings_blocks_add():
|
||||||
"""Block a user"""
|
"""Block a user"""
|
||||||
user_id = request.form.get('user_id', type=int)
|
user_id = request.form.get('user_id', type=int)
|
||||||
reason = request.form.get('reason', '').strip()
|
reason = request.form.get('reason', '').strip()
|
||||||
@ -4503,9 +4509,9 @@ def settings_blocks_add():
|
|||||||
return redirect(url_for('settings_blocks'))
|
return redirect(url_for('settings_blocks'))
|
||||||
|
|
||||||
|
|
||||||
@app.route('/settings/blocks/remove/<int:block_id>', methods=['POST'])
|
# @app.route('/settings/blocks/remove/<int:block_id>', methods=['POST']) # MOVED TO auth.settings_blocks_remove
|
||||||
@login_required
|
# @login_required
|
||||||
def settings_blocks_remove(block_id):
|
def _old_settings_blocks_remove(block_id):
|
||||||
"""Unblock a user"""
|
"""Unblock a user"""
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
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')
|
# @app.route('/konto') # MOVED TO auth.konto_dane
|
||||||
@login_required
|
# @login_required
|
||||||
def konto_dane():
|
def _old_konto_dane():
|
||||||
"""User profile - edit personal data"""
|
"""User profile - edit personal data"""
|
||||||
return render_template('konto/dane.html')
|
return render_template('konto/dane.html')
|
||||||
|
|
||||||
|
|
||||||
@app.route('/konto', methods=['POST'])
|
# @app.route('/konto', methods=['POST']) # MOVED TO auth.konto_dane_post
|
||||||
@login_required
|
# @login_required
|
||||||
def konto_dane_post():
|
def _old_konto_dane_post():
|
||||||
"""Save user profile changes"""
|
"""Save user profile changes"""
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
@ -4574,9 +4580,9 @@ def konto_dane_post():
|
|||||||
return redirect(url_for('konto_dane'))
|
return redirect(url_for('konto_dane'))
|
||||||
|
|
||||||
|
|
||||||
@app.route('/konto/prywatnosc', methods=['GET', 'POST'])
|
# @app.route('/konto/prywatnosc', methods=['GET', 'POST']) # MOVED TO auth.konto_prywatnosc
|
||||||
@login_required
|
# @login_required
|
||||||
def konto_prywatnosc():
|
def _old_konto_prywatnosc():
|
||||||
"""Privacy settings - control visibility of phone and email"""
|
"""Privacy settings - control visibility of phone and email"""
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
@ -4606,16 +4612,16 @@ def konto_prywatnosc():
|
|||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
@app.route('/konto/bezpieczenstwo')
|
# @app.route('/konto/bezpieczenstwo') # MOVED TO auth.konto_bezpieczenstwo
|
||||||
@login_required
|
# @login_required
|
||||||
def konto_bezpieczenstwo():
|
def _old_konto_bezpieczenstwo():
|
||||||
"""Security settings - 2FA, password"""
|
"""Security settings - 2FA, password"""
|
||||||
return render_template('konto/bezpieczenstwo.html')
|
return render_template('konto/bezpieczenstwo.html')
|
||||||
|
|
||||||
|
|
||||||
@app.route('/konto/blokady')
|
# @app.route('/konto/blokady') # MOVED TO auth.konto_blokady
|
||||||
@login_required
|
# @login_required
|
||||||
def konto_blokady():
|
def _old_konto_blokady():
|
||||||
"""User blocks management"""
|
"""User blocks management"""
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
@ -4639,9 +4645,9 @@ def konto_blokady():
|
|||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
@app.route('/konto/blokady/dodaj', methods=['POST'])
|
# @app.route('/konto/blokady/dodaj', methods=['POST']) # MOVED TO auth.konto_blokady_dodaj
|
||||||
@login_required
|
# @login_required
|
||||||
def konto_blokady_dodaj():
|
def _old_konto_blokady_dodaj():
|
||||||
"""Block a user"""
|
"""Block a user"""
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
@ -4674,9 +4680,9 @@ def konto_blokady_dodaj():
|
|||||||
return redirect(url_for('konto_blokady'))
|
return redirect(url_for('konto_blokady'))
|
||||||
|
|
||||||
|
|
||||||
@app.route('/konto/blokady/usun/<int:block_id>', methods=['POST'])
|
# @app.route('/konto/blokady/usun/<int:block_id>', methods=['POST']) # MOVED TO auth.konto_blokady_usun
|
||||||
@login_required
|
# @login_required
|
||||||
def konto_blokady_usun(block_id):
|
def _old_konto_blokady_usun(block_id):
|
||||||
"""Unblock a user"""
|
"""Unblock a user"""
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
@ -4704,9 +4710,9 @@ def konto_blokady_usun(block_id):
|
|||||||
return redirect(url_for('konto_blokady'))
|
return redirect(url_for('konto_blokady'))
|
||||||
|
|
||||||
|
|
||||||
@app.route('/forgot-password', methods=['GET', 'POST'])
|
# @app.route('/forgot-password', methods=['GET', 'POST']) # MOVED TO auth.forgot_password
|
||||||
@limiter.limit("20 per hour")
|
# @limiter.limit("20 per hour")
|
||||||
def forgot_password():
|
def _old_forgot_password():
|
||||||
"""Request password reset"""
|
"""Request password reset"""
|
||||||
if current_user.is_authenticated:
|
if current_user.is_authenticated:
|
||||||
return redirect(url_for('index'))
|
return redirect(url_for('index'))
|
||||||
@ -4767,9 +4773,9 @@ def forgot_password():
|
|||||||
return render_template('auth/forgot_password.html')
|
return render_template('auth/forgot_password.html')
|
||||||
|
|
||||||
|
|
||||||
@app.route('/reset-password/<token>', methods=['GET', 'POST'])
|
# @app.route('/reset-password/<token>', methods=['GET', 'POST']) # MOVED TO auth.reset_password
|
||||||
@limiter.limit("30 per hour")
|
# @limiter.limit("30 per hour")
|
||||||
def reset_password(token):
|
def _old_reset_password(token):
|
||||||
"""Reset password with token"""
|
"""Reset password with token"""
|
||||||
if current_user.is_authenticated:
|
if current_user.is_authenticated:
|
||||||
return redirect(url_for('index'))
|
return redirect(url_for('index'))
|
||||||
@ -4831,8 +4837,8 @@ def reset_password(token):
|
|||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
@app.route('/verify-email/<token>')
|
# @app.route('/verify-email/<token>') # MOVED TO auth.verify_email
|
||||||
def verify_email(token):
|
def _old_verify_email(token):
|
||||||
"""Verify email address with token"""
|
"""Verify email address with token"""
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
@ -4869,9 +4875,9 @@ def verify_email(token):
|
|||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
@app.route('/resend-verification', methods=['GET', 'POST'])
|
# @app.route('/resend-verification', methods=['GET', 'POST']) # MOVED TO auth.resend_verification
|
||||||
@limiter.limit("15 per hour")
|
# @limiter.limit("15 per hour")
|
||||||
def resend_verification():
|
def _old_resend_verification():
|
||||||
"""Resend email verification link"""
|
"""Resend email verification link"""
|
||||||
if current_user.is_authenticated:
|
if current_user.is_authenticated:
|
||||||
return redirect(url_for('index'))
|
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')
|
# @app.route('/dashboard') # MOVED TO public.dashboard
|
||||||
@login_required
|
# @login_required
|
||||||
def dashboard():
|
def _old_dashboard():
|
||||||
"""User dashboard"""
|
"""User dashboard"""
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
@ -5355,8 +5361,8 @@ def api_connections():
|
|||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
@app.route('/mapa-polaczen')
|
# @app.route('/mapa-polaczen') # MOVED TO public.connections_map
|
||||||
def connections_map():
|
def _old_connections_map():
|
||||||
"""Company-person connections visualization page"""
|
"""Company-person connections visualization page"""
|
||||||
return render_template('connections_map.html')
|
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')
|
# @app.route('/release-notes') # MOVED TO public.release_notes
|
||||||
def release_notes():
|
def _old_release_notes():
|
||||||
"""Historia zmian platformy."""
|
"""Historia zmian platformy."""
|
||||||
releases = [
|
releases = [
|
||||||
{
|
{
|
||||||
|
|||||||
@ -58,4 +58,98 @@ def register_blueprints(app):
|
|||||||
except ImportError as e:
|
except ImportError as e:
|
||||||
logger.debug(f"Blueprint education not yet available: {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
|
||||||
|
|||||||
12
blueprints/auth/__init__.py
Normal file
12
blueprints/auth/__init__.py
Normal 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
1040
blueprints/auth/routes.py
Normal file
File diff suppressed because it is too large
Load Diff
12
blueprints/public/__init__.py
Normal file
12
blueprints/public/__init__.py
Normal 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
862
blueprints/public/routes.py
Normal 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)
|
||||||
@ -16,33 +16,99 @@
|
|||||||
| contacts | `/kontakty` | 6 | ✅ Przetestowane |
|
| contacts | `/kontakty` | 6 | ✅ Przetestowane |
|
||||||
| classifieds | `/tablica` | 4 | ✅ Przetestowane |
|
| classifieds | `/tablica` | 4 | ✅ Przetestowane |
|
||||||
| calendar | `/kalendarz` | 3 | ✅ Przetestowane |
|
| calendar | `/kalendarz` | 3 | ✅ Przetestowane |
|
||||||
|
| education | `/edukacja` | 2 | ✅ Przetestowane |
|
||||||
|
|
||||||
**Nowa struktura plików:**
|
---
|
||||||
```
|
|
||||||
nordabiz/
|
### Faza 2a - 🔄 W TRAKCIE (DEV)
|
||||||
├── blueprints/
|
|
||||||
│ ├── __init__.py # register_blueprints()
|
**Data rozpoczęcia:** 2026-01-31
|
||||||
│ ├── reports/
|
**Strategia:** Alias Bridge (bezpieczna migracja)
|
||||||
│ │ ├── __init__.py
|
|
||||||
│ │ └── routes.py
|
| Blueprint | Routes | Status |
|
||||||
│ └── community/
|
|-----------|--------|--------|
|
||||||
│ ├── __init__.py
|
| auth | 20 | ✅ Utworzony, aliasy aktywne |
|
||||||
│ ├── contacts/
|
| public | 11 | ✅ Utworzony, aliasy aktywne |
|
||||||
│ ├── classifieds/
|
|
||||||
│ └── calendar/
|
**Pliki utworzone:**
|
||||||
├── utils/
|
- `blueprints/auth/__init__.py`
|
||||||
│ ├── decorators.py # admin_required, etc.
|
- `blueprints/auth/routes.py` (1,040 linii)
|
||||||
│ ├── helpers.py # sanitize_input, etc.
|
- `blueprints/public/__init__.py`
|
||||||
│ ├── notifications.py
|
- `blueprints/public/routes.py` (862 linii)
|
||||||
│ ├── analytics.py
|
|
||||||
│ ├── middleware.py
|
**Stan app.py:**
|
||||||
│ ├── context_processors.py
|
- Duplikaty tras zakomentowane (prefix `_old_`)
|
||||||
│ └── error_handlers.py
|
- Aliasy aktywne w `blueprints/__init__.py`
|
||||||
├── extensions.py # csrf, login_manager, limiter
|
- Oczekuje na cleanup martwego kodu
|
||||||
└── config.py # Dev/Prod configurations
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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
|
### Podsumowanie faz
|
||||||
|
|
||||||
| Faza | Zakres | Routes | Zależności | Status |
|
| Faza | Zakres | Routes | Status |
|
||||||
|------|--------|--------|------------|--------|
|
|------|--------|--------|--------|
|
||||||
| **1** | reports, community, education | 19 | utils/helpers | ✅ WDROŻONA |
|
| **1** | reports, community, education | 19 | ✅ WDROŻONA |
|
||||||
| **2** | **auth + public (RAZEM!)** | ~28 | utils/helpers | 🔜 Następna |
|
| **2a** | auth + public | 31 | 🔄 DEV - aliasy aktywne |
|
||||||
| **3** | account, forum | ~25 | Faza 2 | ⏳ |
|
| **2b** | cleanup app.py | - | ⏳ Po teście PROD |
|
||||||
| **4** | messages, notifications | ~10 | Faza 2 | ⏳ |
|
| **3** | account, forum | ~25 | ⏳ |
|
||||||
| **5** | chat | ~8 | Faza 2 | ⏳ |
|
| **4** | messages, notifications | ~10 | ⏳ |
|
||||||
| **6** | admin (8 modułów) | ~60 | Faza 2 + decorators | ⏳ |
|
| **5** | chat | ~8 | ⏳ |
|
||||||
| **7** | audits (6 modułów) | ~35 | Faza 2 + decorators | ⏳ |
|
| **6** | admin (8 modułów) | ~60 | ⏳ |
|
||||||
| **8** | zopk (5 modułów) | ~32 | Faza 2 + decorators | ⏳ |
|
| **7** | audits (6 modułów) | ~35 | ⏳ |
|
||||||
| **9** | api misc, honeypot | ~25 | Faza 2 | ⏳ |
|
| **8** | zopk (5 modułów) | ~32 | ⏳ |
|
||||||
| **10** | cleanup | - | Wszystkie | ⏳ |
|
| **9** | api misc, honeypot | ~25 | ⏳ |
|
||||||
|
| **10** | final cleanup | - | ⏳ |
|
||||||
|
|
||||||
**⚠️ WAŻNE:** Faza 2 jest krytyczna - `auth` i `public` muszą być wdrożone RAZEM!
|
**Cel końcowy:** Redukcja app.py z 15,570 → ~500 linii
|
||||||
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
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 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
|
```bash
|
||||||
# Sprawdzenie czy blueprinty działają na produkcji
|
# 1. Import aplikacji
|
||||||
curl -sI https://nordabiznes.pl/health # → 200 OK
|
python3 -c "from app import app; print('OK')"
|
||||||
curl -sI https://nordabiznes.pl/raporty/ # → 302 (wymaga logowania)
|
|
||||||
curl -sI https://nordabiznes.pl/kontakty/ # → 302 (wymaga logowania)
|
# 2. Test endpointów
|
||||||
curl -sI https://nordabiznes.pl/tablica/ # → 302 (wymaga logowania)
|
python3 -c "
|
||||||
curl -sI https://nordabiznes.pl/kalendarz/ # → 302 (wymaga logowania)
|
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
|
||||||
|
|
||||||
---
|
```bash
|
||||||
|
# Po wdrożeniu
|
||||||
## Naprawione błędy podczas testów DEV
|
curl -sI https://nordabiznes.pl/health | head -1
|
||||||
|
curl -sI https://nordabiznes.pl/ | head -1
|
||||||
| Plik | Problem | Rozwiązanie |
|
curl -sI https://nordabiznes.pl/login | head -1
|
||||||
|------|---------|-------------|
|
curl -sI https://nordabiznes.pl/release-notes | head -1
|
||||||
| `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` |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -105,14 +199,17 @@ curl -sI https://nordabiznes.pl/kalendarz/ # → 302 (wymaga logowania)
|
|||||||
1. **url_for w blueprintach:**
|
1. **url_for w blueprintach:**
|
||||||
- Wewnątrz blueprintu: `url_for('.endpoint')` (z kropką)
|
- Wewnątrz blueprintu: `url_for('.endpoint')` (z kropką)
|
||||||
- W szablonach: `url_for('blueprint.endpoint')` (pełna nazwa)
|
- W szablonach: `url_for('blueprint.endpoint')` (pełna nazwa)
|
||||||
|
- Aliasy: `url_for('stara_nazwa')` = `url_for('blueprint.nowa_nazwa')`
|
||||||
|
|
||||||
2. **Testowanie po migracji:**
|
2. **Kolejność operacji:**
|
||||||
- Sprawdź WSZYSTKIE szablony używające `url_for()`
|
- Najpierw blueprinty + aliasy
|
||||||
- Użyj grep: `grep -r "url_for\(" templates/`
|
- Potem dezaktywacja duplikatów
|
||||||
|
- Cleanup dopiero po teście PROD
|
||||||
|
|
||||||
3. **Restart serwera:**
|
3. **Bezpieczeństwo:**
|
||||||
- Flask cachuje szablony - wymaga pełnego restartu
|
- Zawsze zachowuj martwy kod do weryfikacji
|
||||||
- Zabij proces i uruchom od nowa
|
- Prefix `_old_` dla zdezaktywowanych funkcji
|
||||||
|
- Rollback: odkomentuj `@app.route`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@ -21,8 +21,8 @@ login_manager = LoginManager()
|
|||||||
login_manager.login_view = 'auth.login'
|
login_manager.login_view = 'auth.login'
|
||||||
login_manager.login_message = 'Zaloguj się, aby uzyskać dostęp do tej strony.'
|
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(
|
limiter = Limiter(
|
||||||
key_func=get_remote_address,
|
key_func=get_remote_address,
|
||||||
default_limits=["200 per day", "50 per hour"]
|
default_limits=["1000 per day", "200 per hour"]
|
||||||
)
|
)
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user