refactor(phase1): Extract blueprints for reports, contacts, classifieds, calendar

Phase 1 of app.py refactoring - reducing from ~14,455 to ~13,699 lines.

New structure:
- blueprints/reports/ - 4 routes (/raporty/*)
- blueprints/community/contacts/ - 6 routes (/kontakty/*)
- blueprints/community/classifieds/ - 4 routes (/tablica/*)
- blueprints/community/calendar/ - 3 routes (/kalendarz/*)
- utils/ - decorators, helpers, notifications, analytics
- extensions.py - Flask extensions (csrf, login_manager, limiter)
- config.py - environment configurations

Updated templates with blueprint-prefixed url_for() calls.

⚠️ DO NOT DEPLOY before presentation on 2026-01-30 19:00

Tested on DEV: all endpoints working correctly.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Maciej Pienczyn 2026-01-27 10:10:45 +01:00
parent 819b8d9c13
commit 66856a697d
36 changed files with 1956 additions and 822 deletions

786
app.py
View File

@ -278,6 +278,11 @@ try:
except Exception as e: except Exception as e:
logger.error(f"Failed to initialize Gemini service: {e}") logger.error(f"Failed to initialize Gemini service: {e}")
# Register blueprints (Phase 1: reports, community)
from blueprints import register_blueprints
register_blueprints(app)
logger.info("Blueprints registered")
@login_manager.user_loader @login_manager.user_loader
def load_user(user_id): def load_user(user_id):
"""Load user from database""" """Load user from database"""
@ -2800,178 +2805,11 @@ def admin_fees_export():
# ============================================================ # ============================================================
# CALENDAR ROUTES # CALENDAR ROUTES - PUBLIC ROUTES MIGRATED TO blueprints/community/calendar/
# ============================================================ # ============================================================
# Routes: /kalendarz, /kalendarz/<id>, /kalendarz/<id>/rsvp
@app.route('/kalendarz') # Admin calendar routes remain here
@login_required
def calendar_index():
"""Kalendarz wydarzeń Norda Biznes - widok listy lub siatki miesięcznej"""
from datetime import date
import calendar as cal_module
# Polskie nazwy miesięcy
POLISH_MONTHS = {
1: 'Styczeń', 2: 'Luty', 3: 'Marzec', 4: 'Kwiecień',
5: 'Maj', 6: 'Czerwiec', 7: 'Lipiec', 8: 'Sierpień',
9: 'Wrzesień', 10: 'Październik', 11: 'Listopad', 12: 'Grudzień'
}
db = SessionLocal()
try:
today = date.today()
# Parametry widoku
view_mode = request.args.get('view', 'list') # list lub grid
year = request.args.get('year', today.year, type=int)
month = request.args.get('month', today.month, type=int)
# Walidacja miesiąca/roku
if month < 1:
month = 12
year -= 1
elif month > 12:
month = 1
year += 1
# Oblicz poprzedni/następny miesiąc
if month == 1:
prev_month, prev_year = 12, year - 1
else:
prev_month, prev_year = month - 1, year
if month == 12:
next_month, next_year = 1, year + 1
else:
next_month, next_year = month + 1, year
# Dane dla widoku siatki
month_days = []
events_by_day = {}
if view_mode == 'grid':
# Pobierz wydarzenia z danego miesiąca
first_day = date(year, month, 1)
last_day = date(year, month, cal_module.monthrange(year, month)[1])
events = db.query(NordaEvent).filter(
NordaEvent.event_date >= first_day,
NordaEvent.event_date <= last_day
).order_by(NordaEvent.event_date.asc()).all()
# Przygotuj strukturę kalendarza (poniedziałek = 0)
cal = cal_module.Calendar(firstweekday=0)
month_days = cal.monthdayscalendar(year, month)
# Mapuj wydarzenia na dni
for event in events:
day = event.event_date.day
if day not in events_by_day:
events_by_day[day] = []
events_by_day[day].append(event)
# Dane dla widoku listy (zawsze potrzebne dla fallback)
upcoming = db.query(NordaEvent).filter(
NordaEvent.event_date >= today
).order_by(NordaEvent.event_date.asc()).all()
past = db.query(NordaEvent).filter(
NordaEvent.event_date < today
).order_by(NordaEvent.event_date.desc()).limit(5).all()
return render_template('calendar/index.html',
# Dane dla widoku listy
upcoming_events=upcoming,
past_events=past,
today=today,
# Dane dla widoku siatki
view_mode=view_mode,
year=year,
month=month,
month_name=POLISH_MONTHS.get(month, ''),
month_days=month_days,
events_by_day=events_by_day,
prev_month=prev_month,
prev_year=prev_year,
next_month=next_month,
next_year=next_year,
)
finally:
db.close()
@app.route('/kalendarz/<int:event_id>')
@login_required
def calendar_event(event_id):
"""Szczegóły wydarzenia"""
db = SessionLocal()
try:
event = db.query(NordaEvent).filter(NordaEvent.id == event_id).first()
if not event:
flash('Wydarzenie nie istnieje.', 'error')
return redirect(url_for('calendar_index'))
# Sprawdź czy użytkownik jest zapisany
user_attending = db.query(EventAttendee).filter(
EventAttendee.event_id == event_id,
EventAttendee.user_id == current_user.id
).first()
return render_template('calendar/event.html',
event=event,
user_attending=user_attending
)
finally:
db.close()
@app.route('/kalendarz/<int:event_id>/rsvp', methods=['POST'])
@login_required
def calendar_rsvp(event_id):
"""Zapisz się / wypisz z wydarzenia"""
db = SessionLocal()
try:
event = db.query(NordaEvent).filter(NordaEvent.id == event_id).first()
if not event:
return jsonify({'success': False, 'error': 'Wydarzenie nie istnieje'}), 404
# Sprawdź czy już zapisany
existing = db.query(EventAttendee).filter(
EventAttendee.event_id == event_id,
EventAttendee.user_id == current_user.id
).first()
if existing:
# Wypisz
db.delete(existing)
db.commit()
return jsonify({
'success': True,
'action': 'removed',
'message': 'Wypisano z wydarzenia',
'attendee_count': event.attendee_count
})
else:
# Zapisz
if event.max_attendees and event.attendee_count >= event.max_attendees:
return jsonify({'success': False, 'error': 'Brak wolnych miejsc'}), 400
attendee = EventAttendee(
event_id=event_id,
user_id=current_user.id,
status='confirmed'
)
db.add(attendee)
db.commit()
return jsonify({
'success': True,
'action': 'added',
'message': 'Zapisano na wydarzenie',
'attendee_count': event.attendee_count
})
finally:
db.close()
@app.route('/admin/kalendarz') @app.route('/admin/kalendarz')
@login_required @login_required
def admin_calendar(): def admin_calendar():
@ -3794,144 +3632,9 @@ def api_delete_recommendation(rec_id):
# ============================================================ # ============================================================
# B2B CLASSIFIEDS ROUTES # B2B CLASSIFIEDS ROUTES - MIGRATED TO blueprints/community/classifieds/
# ============================================================ # ============================================================
# Routes: /tablica, /tablica/nowe, /tablica/<id>, /tablica/<id>/zakoncz
@app.route('/tablica')
@login_required
def classifieds_index():
"""Tablica ogłoszeń B2B"""
listing_type = request.args.get('type', '')
category = request.args.get('category', '')
page = request.args.get('page', 1, type=int)
per_page = 20
db = SessionLocal()
try:
query = db.query(Classified).filter(
Classified.is_active == True
)
# Filtry
if listing_type:
query = query.filter(Classified.listing_type == listing_type)
if category:
query = query.filter(Classified.category == category)
# Sortowanie - najnowsze pierwsze
query = query.order_by(Classified.created_at.desc())
total = query.count()
classifieds = query.limit(per_page).offset((page - 1) * per_page).all()
# Kategorie do filtrów
categories = [
('uslugi', 'Usługi'),
('produkty', 'Produkty'),
('wspolpraca', 'Współpraca'),
('praca', 'Praca'),
('inne', 'Inne')
]
return render_template('classifieds/index.html',
classifieds=classifieds,
categories=categories,
listing_type=listing_type,
category_filter=category,
page=page,
total_pages=(total + per_page - 1) // per_page
)
finally:
db.close()
@app.route('/tablica/nowe', methods=['GET', 'POST'])
@login_required
def classifieds_new():
"""Dodaj nowe ogłoszenie"""
if request.method == 'POST':
listing_type = request.form.get('listing_type', '')
category = request.form.get('category', '')
title = sanitize_input(request.form.get('title', ''), 255)
description = request.form.get('description', '').strip()
budget_info = sanitize_input(request.form.get('budget_info', ''), 255)
location_info = sanitize_input(request.form.get('location_info', ''), 255)
if not listing_type or not category or not title or not description:
flash('Wszystkie wymagane pola muszą być wypełnione.', 'error')
return render_template('classifieds/new.html')
db = SessionLocal()
try:
# Automatyczne wygaśnięcie po 30 dniach
expires = datetime.now() + timedelta(days=30)
classified = Classified(
author_id=current_user.id,
company_id=current_user.company_id,
listing_type=listing_type,
category=category,
title=title,
description=description,
budget_info=budget_info,
location_info=location_info,
expires_at=expires
)
db.add(classified)
db.commit()
flash('Ogłoszenie dodane.', 'success')
return redirect(url_for('classifieds_index'))
finally:
db.close()
return render_template('classifieds/new.html')
@app.route('/tablica/<int:classified_id>')
@login_required
def classifieds_view(classified_id):
"""Szczegóły ogłoszenia"""
db = SessionLocal()
try:
classified = db.query(Classified).filter(
Classified.id == classified_id
).first()
if not classified:
flash('Ogłoszenie nie istnieje.', 'error')
return redirect(url_for('classifieds_index'))
# Zwiększ licznik wyświetleń (handle NULL)
classified.views_count = (classified.views_count or 0) + 1
db.commit()
return render_template('classifieds/view.html', classified=classified)
finally:
db.close()
@app.route('/tablica/<int:classified_id>/zakoncz', methods=['POST'])
@login_required
def classifieds_close(classified_id):
"""Zamknij ogłoszenie"""
db = SessionLocal()
try:
classified = db.query(Classified).filter(
Classified.id == classified_id,
Classified.author_id == current_user.id
).first()
if not classified:
return jsonify({'success': False, 'error': 'Ogłoszenie nie istnieje lub brak uprawnień'}), 404
classified.is_active = False
db.commit()
return jsonify({'success': True, 'message': 'Ogłoszenie zamknięte'})
finally:
db.close()
# ============================================================ # ============================================================
# NEW MEMBERS ROUTE # NEW MEMBERS ROUTE
@ -9587,180 +9290,9 @@ def api_it_audit_export():
# ============================================================ # ============================================================
# RAPORTY # RAPORTY - MIGRATED TO blueprints/reports/
# ============================================================ # ============================================================
# Routes: /raporty, /raporty/staz-czlonkostwa, /raporty/social-media, /raporty/struktura-branzowa
@app.route('/raporty')
@login_required
def reports_index():
"""Lista dostępnych raportów."""
reports = [
{
'id': 'staz-czlonkostwa',
'title': 'Staż członkostwa w Izbie NORDA',
'description': 'Zestawienie firm według daty przystąpienia do Izby. Pokazuje historię i lojalność członków.',
'icon': '🏆',
'url': url_for('report_membership')
},
{
'id': 'social-media',
'title': 'Pokrycie Social Media',
'description': 'Analiza obecności firm w mediach społecznościowych: Facebook, Instagram, LinkedIn, YouTube, TikTok, X.',
'icon': '📱',
'url': url_for('report_social_media')
},
{
'id': 'struktura-branzowa',
'title': 'Struktura branżowa',
'description': 'Rozkład firm według kategorii działalności: IT, Budownictwo, Usługi, Produkcja, Handel.',
'icon': '🏢',
'url': url_for('report_categories')
},
]
return render_template('reports/index.html', reports=reports)
@app.route('/raporty/staz-czlonkostwa')
@login_required
def report_membership():
"""Raport: Staż członkostwa w Izbie NORDA."""
from datetime import date
db = SessionLocal()
try:
# Firmy z member_since, posortowane od najstarszego
companies = db.query(Company).filter(
Company.member_since.isnot(None)
).order_by(Company.member_since.asc()).all()
# Statystyki
today = date.today()
stats = {
'total_with_date': len(companies),
'total_without_date': db.query(Company).filter(
Company.member_since.is_(None)
).count(),
'oldest': companies[0] if companies else None,
'newest': companies[-1] if companies else None,
'avg_years': sum(
(today - c.member_since).days / 365.25
for c in companies
) / len(companies) if companies else 0
}
# Dodaj obliczony staż do każdej firmy
for c in companies:
c.membership_years = int((today - c.member_since).days / 365.25)
# Dodaj też do oldest i newest
if stats['oldest']:
stats['oldest'].membership_years = int((today - stats['oldest'].member_since).days / 365.25)
return render_template(
'reports/membership.html',
companies=companies,
stats=stats,
generated_at=datetime.now()
)
finally:
db.close()
@app.route('/raporty/social-media')
@login_required
def report_social_media():
"""Raport: Pokrycie Social Media."""
from sqlalchemy.orm import joinedload
db = SessionLocal()
try:
# Wszystkie firmy z ich profilami social media
companies = db.query(Company).options(
joinedload(Company.social_media_profiles)
).order_by(Company.name).all()
platforms = ['facebook', 'instagram', 'linkedin', 'youtube', 'tiktok', 'twitter']
# Statystyki platform
platform_stats = {}
for platform in platforms:
count = db.query(CompanySocialMedia).filter_by(
platform=platform
).count()
platform_stats[platform] = {
'count': count,
'percent': round(count / len(companies) * 100, 1) if companies else 0
}
# Firmy z min. 1 profilem
companies_with_social = [
c for c in companies if c.social_media_profiles
]
stats = {
'total_companies': len(companies),
'with_social': len(companies_with_social),
'without_social': len(companies) - len(companies_with_social),
'coverage_percent': round(
len(companies_with_social) / len(companies) * 100, 1
) if companies else 0
}
return render_template(
'reports/social_media.html',
companies=companies,
platforms=platforms,
platform_stats=platform_stats,
stats=stats,
generated_at=datetime.now()
)
finally:
db.close()
@app.route('/raporty/struktura-branzowa')
@login_required
def report_categories():
"""Raport: Struktura branżowa."""
from sqlalchemy import func
db = SessionLocal()
try:
# Grupowanie po category_id (kolumna FK, nie relacja)
category_counts = db.query(
Company.category_id,
func.count(Company.id).label('count')
).group_by(Company.category_id).all()
total = sum(c.count for c in category_counts)
# Pobierz mapę kategorii (id -> name) jednym zapytaniem
category_map = {cat.id: cat.name for cat in db.query(Category).all()}
categories = []
for cat in category_counts:
cat_id = cat.category_id
cat_name = category_map.get(cat_id, 'Brak kategorii') if cat_id else 'Brak kategorii'
examples = db.query(Company.name).filter(
Company.category_id == cat_id
).limit(3).all()
categories.append({
'name': cat_name,
'count': cat.count,
'percent': round(cat.count / total * 100, 1) if total else 0,
'examples': [e.name for e in examples]
})
# Sortuj od największej
categories.sort(key=lambda x: x['count'], reverse=True)
return render_template(
'reports/categories.html',
categories=categories,
total=total,
generated_at=datetime.now()
)
finally:
db.close()
# ============================================================ # ============================================================
@ -13818,298 +13350,10 @@ def announcement_detail(slug):
# ============================================================ # ============================================================
# EXTERNAL CONTACTS (Kontakty zewnętrzne) # EXTERNAL CONTACTS - PAGE ROUTES MIGRATED TO blueprints/community/contacts/
# ============================================================ # ============================================================
# Routes: /kontakty, /kontakty/<id>, /kontakty/dodaj, /kontakty/<id>/edytuj, /kontakty/<id>/usun
@app.route('/kontakty') # API routes remain below for backwards compatibility
@login_required
def contacts_list():
"""
Lista kontaktów zewnętrznych - urzędy, instytucje, partnerzy.
Dostępna dla wszystkich zalogowanych członków.
"""
from database import ExternalContact, User
db = SessionLocal()
try:
page = request.args.get('page', 1, type=int)
per_page = 20
search = request.args.get('q', '').strip()
org_type = request.args.get('type', '')
project = request.args.get('project', '')
query = db.query(ExternalContact).filter(ExternalContact.is_active == True)
# Search filter
if search:
search_pattern = f'%{search}%'
query = query.filter(
or_(
ExternalContact.first_name.ilike(search_pattern),
ExternalContact.last_name.ilike(search_pattern),
ExternalContact.organization_name.ilike(search_pattern),
ExternalContact.position.ilike(search_pattern),
ExternalContact.project_name.ilike(search_pattern),
ExternalContact.tags.ilike(search_pattern)
)
)
# Organization type filter
if org_type and org_type in ExternalContact.ORGANIZATION_TYPES:
query = query.filter(ExternalContact.organization_type == org_type)
# Project filter
if project:
query = query.filter(ExternalContact.project_name.ilike(f'%{project}%'))
# Order by organization name, then last name
query = query.order_by(
ExternalContact.organization_name,
ExternalContact.last_name
)
# Pagination
total = query.count()
contacts = query.offset((page - 1) * per_page).limit(per_page).all()
total_pages = (total + per_page - 1) // per_page
# Get unique projects for filter dropdown
projects = db.query(ExternalContact.project_name).filter(
ExternalContact.is_active == True,
ExternalContact.project_name.isnot(None),
ExternalContact.project_name != ''
).distinct().order_by(ExternalContact.project_name).all()
project_names = [p[0] for p in projects if p[0]]
return render_template('contacts/list.html',
contacts=contacts,
page=page,
total_pages=total_pages,
total=total,
search=search,
org_type=org_type,
project=project,
org_types=ExternalContact.ORGANIZATION_TYPES,
org_type_labels=ExternalContact.ORGANIZATION_TYPE_LABELS,
project_names=project_names)
finally:
db.close()
@app.route('/kontakty/<int:contact_id>')
@login_required
def contact_detail(contact_id):
"""
Szczegóły kontaktu zewnętrznego - pełna karta osoby.
"""
from database import ExternalContact
db = SessionLocal()
try:
contact = db.query(ExternalContact).filter(
ExternalContact.id == contact_id,
ExternalContact.is_active == True
).first()
if not contact:
flash('Kontakt nie został znaleziony.', 'error')
return redirect(url_for('contacts_list'))
# Get other contacts from the same organization
related_contacts = db.query(ExternalContact).filter(
ExternalContact.organization_name == contact.organization_name,
ExternalContact.id != contact.id,
ExternalContact.is_active == True
).order_by(ExternalContact.last_name).limit(5).all()
# Check if current user can edit (creator or admin)
can_edit = (current_user.is_admin or
(contact.created_by and contact.created_by == current_user.id))
return render_template('contacts/detail.html',
contact=contact,
related_contacts=related_contacts,
can_edit=can_edit,
org_type_labels=ExternalContact.ORGANIZATION_TYPE_LABELS)
finally:
db.close()
@app.route('/kontakty/dodaj', methods=['GET', 'POST'])
@login_required
def contact_add():
"""
Dodawanie nowego kontaktu zewnętrznego.
Każdy zalogowany użytkownik może dodać kontakt.
"""
from database import ExternalContact
if request.method == 'POST':
db = SessionLocal()
try:
# Parse related_links from form (JSON)
related_links_json = request.form.get('related_links', '[]')
try:
related_links = json.loads(related_links_json) if related_links_json else []
except json.JSONDecodeError:
related_links = []
contact = ExternalContact(
first_name=request.form.get('first_name', '').strip(),
last_name=request.form.get('last_name', '').strip(),
position=request.form.get('position', '').strip() or None,
photo_url=request.form.get('photo_url', '').strip() or None,
phone=request.form.get('phone', '').strip() or None,
phone_secondary=request.form.get('phone_secondary', '').strip() or None,
email=request.form.get('email', '').strip() or None,
website=request.form.get('website', '').strip() or None,
linkedin_url=request.form.get('linkedin_url', '').strip() or None,
facebook_url=request.form.get('facebook_url', '').strip() or None,
twitter_url=request.form.get('twitter_url', '').strip() or None,
organization_name=request.form.get('organization_name', '').strip(),
organization_type=request.form.get('organization_type', 'other'),
organization_address=request.form.get('organization_address', '').strip() or None,
organization_website=request.form.get('organization_website', '').strip() or None,
organization_logo_url=request.form.get('organization_logo_url', '').strip() or None,
project_name=request.form.get('project_name', '').strip() or None,
project_description=request.form.get('project_description', '').strip() or None,
source_type='manual',
source_url=request.form.get('source_url', '').strip() or None,
related_links=related_links,
tags=request.form.get('tags', '').strip() or None,
notes=request.form.get('notes', '').strip() or None,
created_by=current_user.id
)
db.add(contact)
db.commit()
flash(f'Kontakt {contact.full_name} został dodany.', 'success')
return redirect(url_for('contact_detail', contact_id=contact.id))
except Exception as e:
db.rollback()
app.logger.error(f"Error adding contact: {e}")
flash('Wystąpił błąd podczas dodawania kontaktu.', 'error')
finally:
db.close()
# GET - show form
return render_template('contacts/form.html',
contact=None,
org_types=ExternalContact.ORGANIZATION_TYPES,
org_type_labels=ExternalContact.ORGANIZATION_TYPE_LABELS)
@app.route('/kontakty/<int:contact_id>/edytuj', methods=['GET', 'POST'])
@login_required
def contact_edit(contact_id):
"""
Edycja kontaktu zewnętrznego.
Może edytować twórca kontaktu lub admin.
"""
from database import ExternalContact
db = SessionLocal()
try:
contact = db.query(ExternalContact).filter(
ExternalContact.id == contact_id
).first()
if not contact:
flash('Kontakt nie został znaleziony.', 'error')
return redirect(url_for('contacts_list'))
# Check permissions
if not current_user.is_admin and contact.created_by != current_user.id:
flash('Nie masz uprawnień do edycji tego kontaktu.', 'error')
return redirect(url_for('contact_detail', contact_id=contact_id))
if request.method == 'POST':
# Parse related_links from form (JSON)
related_links_json = request.form.get('related_links', '[]')
try:
related_links = json.loads(related_links_json) if related_links_json else []
except json.JSONDecodeError:
related_links = contact.related_links or []
contact.first_name = request.form.get('first_name', '').strip()
contact.last_name = request.form.get('last_name', '').strip()
contact.position = request.form.get('position', '').strip() or None
contact.photo_url = request.form.get('photo_url', '').strip() or None
contact.phone = request.form.get('phone', '').strip() or None
contact.phone_secondary = request.form.get('phone_secondary', '').strip() or None
contact.email = request.form.get('email', '').strip() or None
contact.website = request.form.get('website', '').strip() or None
contact.linkedin_url = request.form.get('linkedin_url', '').strip() or None
contact.facebook_url = request.form.get('facebook_url', '').strip() or None
contact.twitter_url = request.form.get('twitter_url', '').strip() or None
contact.organization_name = request.form.get('organization_name', '').strip()
contact.organization_type = request.form.get('organization_type', 'other')
contact.organization_address = request.form.get('organization_address', '').strip() or None
contact.organization_website = request.form.get('organization_website', '').strip() or None
contact.organization_logo_url = request.form.get('organization_logo_url', '').strip() or None
contact.project_name = request.form.get('project_name', '').strip() or None
contact.project_description = request.form.get('project_description', '').strip() or None
contact.source_url = request.form.get('source_url', '').strip() or None
contact.related_links = related_links
contact.tags = request.form.get('tags', '').strip() or None
contact.notes = request.form.get('notes', '').strip() or None
contact.updated_at = datetime.now()
db.commit()
flash(f'Kontakt {contact.full_name} został zaktualizowany.', 'success')
return redirect(url_for('contact_detail', contact_id=contact.id))
# GET - show form with existing data
return render_template('contacts/form.html',
contact=contact,
org_types=ExternalContact.ORGANIZATION_TYPES,
org_type_labels=ExternalContact.ORGANIZATION_TYPE_LABELS)
finally:
db.close()
@app.route('/kontakty/<int:contact_id>/usun', methods=['POST'])
@login_required
def contact_delete(contact_id):
"""
Usuwanie kontaktu zewnętrznego (soft delete).
Może usunąć twórca kontaktu lub admin.
"""
from database import ExternalContact
db = SessionLocal()
try:
contact = db.query(ExternalContact).filter(
ExternalContact.id == contact_id
).first()
if not contact:
flash('Kontakt nie został znaleziony.', 'error')
return redirect(url_for('contacts_list'))
# Check permissions
if not current_user.is_admin and contact.created_by != current_user.id:
flash('Nie masz uprawnień do usunięcia tego kontaktu.', 'error')
return redirect(url_for('contact_detail', contact_id=contact_id))
# Soft delete
contact.is_active = False
contact.updated_at = datetime.now()
db.commit()
flash(f'Kontakt {contact.full_name} został usunięty.', 'success')
return redirect(url_for('contacts_list'))
finally:
db.close()
# ============================================================ # ============================================================
# AI-ASSISTED EXTERNAL CONTACT CREATION # AI-ASSISTED EXTERNAL CONTACT CREATION

53
blueprints/__init__.py Normal file
View File

@ -0,0 +1,53 @@
"""
Blueprints Package
==================
Central registration of all Flask blueprints.
"""
import logging
logger = logging.getLogger(__name__)
def register_blueprints(app):
"""
Register all blueprints with the Flask application.
Args:
app: Flask application instance
"""
# Phase 1: Low-risk modules
# Reports blueprint
try:
from blueprints.reports import bp as reports_bp
app.register_blueprint(reports_bp)
logger.info("Registered blueprint: reports")
except ImportError as e:
logger.debug(f"Blueprint reports not yet available: {e}")
# Community blueprints - register directly (not nested)
# to preserve endpoint names like 'calendar_index' instead of 'community.calendar.calendar_index'
try:
from blueprints.community.contacts import bp as contacts_bp
app.register_blueprint(contacts_bp)
logger.info("Registered blueprint: contacts")
except ImportError as e:
logger.debug(f"Blueprint contacts not yet available: {e}")
try:
from blueprints.community.classifieds import bp as classifieds_bp
app.register_blueprint(classifieds_bp)
logger.info("Registered blueprint: classifieds")
except ImportError as e:
logger.debug(f"Blueprint classifieds not yet available: {e}")
try:
from blueprints.community.calendar import bp as calendar_bp
app.register_blueprint(calendar_bp)
logger.info("Registered blueprint: calendar")
except ImportError as e:
logger.debug(f"Blueprint calendar not yet available: {e}")
# Phase 2-7: Future blueprints will be added here

View File

@ -0,0 +1,9 @@
"""
Community Package
=================
Community features: contacts, classifieds, calendar.
NOTE: Each sub-module is registered directly in blueprints/__init__.py
to preserve simple endpoint names (e.g., 'calendar_index' not 'community.calendar.calendar_index').
"""

View File

@ -0,0 +1,13 @@
"""
Calendar Blueprint
==================
Norda Biznes events calendar.
URL prefix: /kalendarz
"""
from flask import Blueprint
bp = Blueprint('calendar', __name__, url_prefix='/kalendarz')
from . import routes # noqa: F401, E402

View File

@ -0,0 +1,181 @@
"""
Calendar Routes
===============
Public calendar and event registration endpoints.
"""
from datetime import date
import calendar as cal_module
from flask import render_template, request, redirect, url_for, flash, jsonify
from flask_login import login_required, current_user
from . import bp
from database import SessionLocal, NordaEvent, EventAttendee
# Polish month names
POLISH_MONTHS = {
1: 'Styczeń', 2: 'Luty', 3: 'Marzec', 4: 'Kwiecień',
5: 'Maj', 6: 'Czerwiec', 7: 'Lipiec', 8: 'Sierpień',
9: 'Wrzesień', 10: 'Październik', 11: 'Listopad', 12: 'Grudzień'
}
@bp.route('/', endpoint='calendar_index')
@login_required
def index():
"""Kalendarz wydarzeń Norda Biznes - widok listy lub siatki miesięcznej"""
db = SessionLocal()
try:
today = date.today()
# Parametry widoku
view_mode = request.args.get('view', 'list') # list lub grid
year = request.args.get('year', today.year, type=int)
month = request.args.get('month', today.month, type=int)
# Walidacja miesiąca/roku
if month < 1:
month = 12
year -= 1
elif month > 12:
month = 1
year += 1
# Oblicz poprzedni/następny miesiąc
if month == 1:
prev_month, prev_year = 12, year - 1
else:
prev_month, prev_year = month - 1, year
if month == 12:
next_month, next_year = 1, year + 1
else:
next_month, next_year = month + 1, year
# Dane dla widoku siatki
month_days = []
events_by_day = {}
if view_mode == 'grid':
# Pobierz wydarzenia z danego miesiąca
first_day = date(year, month, 1)
last_day = date(year, month, cal_module.monthrange(year, month)[1])
events = db.query(NordaEvent).filter(
NordaEvent.event_date >= first_day,
NordaEvent.event_date <= last_day
).order_by(NordaEvent.event_date.asc()).all()
# Przygotuj strukturę kalendarza (poniedziałek = 0)
cal = cal_module.Calendar(firstweekday=0)
month_days = cal.monthdayscalendar(year, month)
# Mapuj wydarzenia na dni
for event in events:
day = event.event_date.day
if day not in events_by_day:
events_by_day[day] = []
events_by_day[day].append(event)
# Dane dla widoku listy (zawsze potrzebne dla fallback)
upcoming = db.query(NordaEvent).filter(
NordaEvent.event_date >= today
).order_by(NordaEvent.event_date.asc()).all()
past = db.query(NordaEvent).filter(
NordaEvent.event_date < today
).order_by(NordaEvent.event_date.desc()).limit(5).all()
return render_template('calendar/index.html',
# Dane dla widoku listy
upcoming_events=upcoming,
past_events=past,
today=today,
# Dane dla widoku siatki
view_mode=view_mode,
year=year,
month=month,
month_name=POLISH_MONTHS.get(month, ''),
month_days=month_days,
events_by_day=events_by_day,
prev_month=prev_month,
prev_year=prev_year,
next_month=next_month,
next_year=next_year,
)
finally:
db.close()
@bp.route('/<int:event_id>', endpoint='calendar_event')
@login_required
def event(event_id):
"""Szczegóły wydarzenia"""
db = SessionLocal()
try:
event = db.query(NordaEvent).filter(NordaEvent.id == event_id).first()
if not event:
flash('Wydarzenie nie istnieje.', 'error')
return redirect(url_for('calendar_index'))
# Sprawdź czy użytkownik jest zapisany
user_attending = db.query(EventAttendee).filter(
EventAttendee.event_id == event_id,
EventAttendee.user_id == current_user.id
).first()
return render_template('calendar/event.html',
event=event,
user_attending=user_attending
)
finally:
db.close()
@bp.route('/<int:event_id>/rsvp', methods=['POST'], endpoint='calendar_rsvp')
@login_required
def rsvp(event_id):
"""Zapisz się / wypisz z wydarzenia"""
db = SessionLocal()
try:
event = db.query(NordaEvent).filter(NordaEvent.id == event_id).first()
if not event:
return jsonify({'success': False, 'error': 'Wydarzenie nie istnieje'}), 404
# Sprawdź czy już zapisany
existing = db.query(EventAttendee).filter(
EventAttendee.event_id == event_id,
EventAttendee.user_id == current_user.id
).first()
if existing:
# Wypisz
db.delete(existing)
db.commit()
return jsonify({
'success': True,
'action': 'removed',
'message': 'Wypisano z wydarzenia',
'attendee_count': event.attendee_count
})
else:
# Zapisz
if event.max_attendees and event.attendee_count >= event.max_attendees:
return jsonify({'success': False, 'error': 'Brak wolnych miejsc'}), 400
attendee = EventAttendee(
event_id=event_id,
user_id=current_user.id,
status='confirmed'
)
db.add(attendee)
db.commit()
return jsonify({
'success': True,
'action': 'added',
'message': 'Zapisano na wydarzenie',
'attendee_count': event.attendee_count
})
finally:
db.close()

View File

@ -0,0 +1,13 @@
"""
Classifieds Blueprint
=====================
B2B bulletin board for member offers and requests.
URL prefix: /tablica
"""
from flask import Blueprint
bp = Blueprint('classifieds', __name__, url_prefix='/tablica')
from . import routes # noqa: F401, E402

View File

@ -0,0 +1,150 @@
"""
Classifieds Routes
==================
B2B bulletin board endpoints.
"""
from datetime import datetime, timedelta
from flask import render_template, request, redirect, url_for, flash, jsonify
from flask_login import login_required, current_user
from . import bp
from database import SessionLocal, Classified
from utils.helpers import sanitize_input
@bp.route('/', endpoint='classifieds_index')
@login_required
def index():
"""Tablica ogłoszeń B2B"""
listing_type = request.args.get('type', '')
category = request.args.get('category', '')
page = request.args.get('page', 1, type=int)
per_page = 20
db = SessionLocal()
try:
query = db.query(Classified).filter(
Classified.is_active == True
)
# Filtry
if listing_type:
query = query.filter(Classified.listing_type == listing_type)
if category:
query = query.filter(Classified.category == category)
# Sortowanie - najnowsze pierwsze
query = query.order_by(Classified.created_at.desc())
total = query.count()
classifieds = query.limit(per_page).offset((page - 1) * per_page).all()
# Kategorie do filtrów
categories = [
('uslugi', 'Usługi'),
('produkty', 'Produkty'),
('wspolpraca', 'Współpraca'),
('praca', 'Praca'),
('inne', 'Inne')
]
return render_template('classifieds/index.html',
classifieds=classifieds,
categories=categories,
listing_type=listing_type,
category_filter=category,
page=page,
total_pages=(total + per_page - 1) // per_page
)
finally:
db.close()
@bp.route('/nowe', methods=['GET', 'POST'], endpoint='classifieds_new')
@login_required
def new():
"""Dodaj nowe ogłoszenie"""
if request.method == 'POST':
listing_type = request.form.get('listing_type', '')
category = request.form.get('category', '')
title = sanitize_input(request.form.get('title', ''), 255)
description = request.form.get('description', '').strip()
budget_info = sanitize_input(request.form.get('budget_info', ''), 255)
location_info = sanitize_input(request.form.get('location_info', ''), 255)
if not listing_type or not category or not title or not description:
flash('Wszystkie wymagane pola muszą być wypełnione.', 'error')
return render_template('classifieds/new.html')
db = SessionLocal()
try:
# Automatyczne wygaśnięcie po 30 dniach
expires = datetime.now() + timedelta(days=30)
classified = Classified(
author_id=current_user.id,
company_id=current_user.company_id,
listing_type=listing_type,
category=category,
title=title,
description=description,
budget_info=budget_info,
location_info=location_info,
expires_at=expires
)
db.add(classified)
db.commit()
flash('Ogłoszenie dodane.', 'success')
return redirect(url_for('classifieds_index'))
finally:
db.close()
return render_template('classifieds/new.html')
@bp.route('/<int:classified_id>', endpoint='classifieds_view')
@login_required
def view(classified_id):
"""Szczegóły ogłoszenia"""
db = SessionLocal()
try:
classified = db.query(Classified).filter(
Classified.id == classified_id
).first()
if not classified:
flash('Ogłoszenie nie istnieje.', 'error')
return redirect(url_for('classifieds_index'))
# Zwiększ licznik wyświetleń (handle NULL)
classified.views_count = (classified.views_count or 0) + 1
db.commit()
return render_template('classifieds/view.html', classified=classified)
finally:
db.close()
@bp.route('/<int:classified_id>/zakoncz', methods=['POST'], endpoint='classifieds_close')
@login_required
def close(classified_id):
"""Zamknij ogłoszenie"""
db = SessionLocal()
try:
classified = db.query(Classified).filter(
Classified.id == classified_id,
Classified.author_id == current_user.id
).first()
if not classified:
return jsonify({'success': False, 'error': 'Ogłoszenie nie istnieje lub brak uprawnień'}), 404
classified.is_active = False
db.commit()
return jsonify({'success': True, 'message': 'Ogłoszenie zamknięte'})
finally:
db.close()

View File

@ -0,0 +1,13 @@
"""
Contacts Blueprint
==================
External contacts management (agencies, government, partners).
URL prefix: /kontakty
"""
from flask import Blueprint
bp = Blueprint('contacts', __name__, url_prefix='/kontakty')
from . import routes # noqa: F401, E402

View File

@ -0,0 +1,299 @@
"""
Contacts Routes
===============
External contacts management - page endpoints only.
API endpoints (/api/contacts/*) remain in app.py for backwards compatibility.
"""
import json
import logging
from datetime import datetime
from flask import render_template, request, redirect, url_for, flash, current_app
from flask_login import login_required, current_user
from sqlalchemy import or_
from . import bp
from database import SessionLocal, ExternalContact
logger = logging.getLogger(__name__)
@bp.route('/', endpoint='contacts_list')
@login_required
def list():
"""
Lista kontaktów zewnętrznych - urzędy, instytucje, partnerzy.
Dostępna dla wszystkich zalogowanych członków.
"""
db = SessionLocal()
try:
page = request.args.get('page', 1, type=int)
per_page = 20
search = request.args.get('q', '').strip()
org_type = request.args.get('type', '')
project = request.args.get('project', '')
query = db.query(ExternalContact).filter(ExternalContact.is_active == True)
# Search filter
if search:
search_pattern = f'%{search}%'
query = query.filter(
or_(
ExternalContact.first_name.ilike(search_pattern),
ExternalContact.last_name.ilike(search_pattern),
ExternalContact.organization_name.ilike(search_pattern),
ExternalContact.position.ilike(search_pattern),
ExternalContact.project_name.ilike(search_pattern),
ExternalContact.tags.ilike(search_pattern)
)
)
# Organization type filter
if org_type and org_type in ExternalContact.ORGANIZATION_TYPES:
query = query.filter(ExternalContact.organization_type == org_type)
# Project filter
if project:
query = query.filter(ExternalContact.project_name.ilike(f'%{project}%'))
# Order by organization name, then last name
query = query.order_by(
ExternalContact.organization_name,
ExternalContact.last_name
)
# Pagination
total = query.count()
contacts = query.offset((page - 1) * per_page).limit(per_page).all()
total_pages = (total + per_page - 1) // per_page
# Get unique projects for filter dropdown
projects = db.query(ExternalContact.project_name).filter(
ExternalContact.is_active == True,
ExternalContact.project_name.isnot(None),
ExternalContact.project_name != ''
).distinct().order_by(ExternalContact.project_name).all()
project_names = [p[0] for p in projects if p[0]]
return render_template('contacts/list.html',
contacts=contacts,
page=page,
total_pages=total_pages,
total=total,
search=search,
org_type=org_type,
project=project,
org_types=ExternalContact.ORGANIZATION_TYPES,
org_type_labels=ExternalContact.ORGANIZATION_TYPE_LABELS,
project_names=project_names)
finally:
db.close()
@bp.route('/<int:contact_id>', endpoint='contact_detail')
@login_required
def detail(contact_id):
"""
Szczegóły kontaktu zewnętrznego - pełna karta osoby.
"""
db = SessionLocal()
try:
contact = db.query(ExternalContact).filter(
ExternalContact.id == contact_id,
ExternalContact.is_active == True
).first()
if not contact:
flash('Kontakt nie został znaleziony.', 'error')
return redirect(url_for('contacts_list'))
# Get other contacts from the same organization
related_contacts = db.query(ExternalContact).filter(
ExternalContact.organization_name == contact.organization_name,
ExternalContact.id != contact.id,
ExternalContact.is_active == True
).order_by(ExternalContact.last_name).limit(5).all()
# Check if current user can edit (creator or admin)
can_edit = (current_user.is_admin or
(contact.created_by and contact.created_by == current_user.id))
return render_template('contacts/detail.html',
contact=contact,
related_contacts=related_contacts,
can_edit=can_edit,
org_type_labels=ExternalContact.ORGANIZATION_TYPE_LABELS)
finally:
db.close()
@bp.route('/dodaj', methods=['GET', 'POST'], endpoint='contact_add')
@login_required
def add():
"""
Dodawanie nowego kontaktu zewnętrznego.
Każdy zalogowany użytkownik może dodać kontakt.
"""
if request.method == 'POST':
db = SessionLocal()
try:
# Parse related_links from form (JSON)
related_links_json = request.form.get('related_links', '[]')
try:
related_links = json.loads(related_links_json) if related_links_json else []
except json.JSONDecodeError:
related_links = []
contact = ExternalContact(
first_name=request.form.get('first_name', '').strip(),
last_name=request.form.get('last_name', '').strip(),
position=request.form.get('position', '').strip() or None,
photo_url=request.form.get('photo_url', '').strip() or None,
phone=request.form.get('phone', '').strip() or None,
phone_secondary=request.form.get('phone_secondary', '').strip() or None,
email=request.form.get('email', '').strip() or None,
website=request.form.get('website', '').strip() or None,
linkedin_url=request.form.get('linkedin_url', '').strip() or None,
facebook_url=request.form.get('facebook_url', '').strip() or None,
twitter_url=request.form.get('twitter_url', '').strip() or None,
organization_name=request.form.get('organization_name', '').strip(),
organization_type=request.form.get('organization_type', 'other'),
organization_address=request.form.get('organization_address', '').strip() or None,
organization_website=request.form.get('organization_website', '').strip() or None,
organization_logo_url=request.form.get('organization_logo_url', '').strip() or None,
project_name=request.form.get('project_name', '').strip() or None,
project_description=request.form.get('project_description', '').strip() or None,
source_type='manual',
source_url=request.form.get('source_url', '').strip() or None,
related_links=related_links,
tags=request.form.get('tags', '').strip() or None,
notes=request.form.get('notes', '').strip() or None,
created_by=current_user.id
)
db.add(contact)
db.commit()
flash(f'Kontakt {contact.full_name} został dodany.', 'success')
return redirect(url_for('contact_detail', contact_id=contact.id))
except Exception as e:
db.rollback()
current_app.logger.error(f"Error adding contact: {e}")
flash('Wystąpił błąd podczas dodawania kontaktu.', 'error')
finally:
db.close()
# GET - show form
return render_template('contacts/form.html',
contact=None,
org_types=ExternalContact.ORGANIZATION_TYPES,
org_type_labels=ExternalContact.ORGANIZATION_TYPE_LABELS)
@bp.route('/<int:contact_id>/edytuj', methods=['GET', 'POST'], endpoint='contact_edit')
@login_required
def edit(contact_id):
"""
Edycja kontaktu zewnętrznego.
Może edytować twórca kontaktu lub admin.
"""
db = SessionLocal()
try:
contact = db.query(ExternalContact).filter(
ExternalContact.id == contact_id
).first()
if not contact:
flash('Kontakt nie został znaleziony.', 'error')
return redirect(url_for('contacts_list'))
# Check permissions
if not current_user.is_admin and contact.created_by != current_user.id:
flash('Nie masz uprawnień do edycji tego kontaktu.', 'error')
return redirect(url_for('contact_detail', contact_id=contact_id))
if request.method == 'POST':
# Parse related_links from form (JSON)
related_links_json = request.form.get('related_links', '[]')
try:
related_links = json.loads(related_links_json) if related_links_json else []
except json.JSONDecodeError:
related_links = contact.related_links or []
contact.first_name = request.form.get('first_name', '').strip()
contact.last_name = request.form.get('last_name', '').strip()
contact.position = request.form.get('position', '').strip() or None
contact.photo_url = request.form.get('photo_url', '').strip() or None
contact.phone = request.form.get('phone', '').strip() or None
contact.phone_secondary = request.form.get('phone_secondary', '').strip() or None
contact.email = request.form.get('email', '').strip() or None
contact.website = request.form.get('website', '').strip() or None
contact.linkedin_url = request.form.get('linkedin_url', '').strip() or None
contact.facebook_url = request.form.get('facebook_url', '').strip() or None
contact.twitter_url = request.form.get('twitter_url', '').strip() or None
contact.organization_name = request.form.get('organization_name', '').strip()
contact.organization_type = request.form.get('organization_type', 'other')
contact.organization_address = request.form.get('organization_address', '').strip() or None
contact.organization_website = request.form.get('organization_website', '').strip() or None
contact.organization_logo_url = request.form.get('organization_logo_url', '').strip() or None
contact.project_name = request.form.get('project_name', '').strip() or None
contact.project_description = request.form.get('project_description', '').strip() or None
contact.source_url = request.form.get('source_url', '').strip() or None
contact.related_links = related_links
contact.tags = request.form.get('tags', '').strip() or None
contact.notes = request.form.get('notes', '').strip() or None
contact.updated_at = datetime.now()
db.commit()
flash(f'Kontakt {contact.full_name} został zaktualizowany.', 'success')
return redirect(url_for('contact_detail', contact_id=contact.id))
# GET - show form with existing data
return render_template('contacts/form.html',
contact=contact,
org_types=ExternalContact.ORGANIZATION_TYPES,
org_type_labels=ExternalContact.ORGANIZATION_TYPE_LABELS)
finally:
db.close()
@bp.route('/<int:contact_id>/usun', methods=['POST'], endpoint='contact_delete')
@login_required
def delete(contact_id):
"""
Usuwanie kontaktu zewnętrznego (soft delete).
Może usunąć twórca kontaktu lub admin.
"""
db = SessionLocal()
try:
contact = db.query(ExternalContact).filter(
ExternalContact.id == contact_id
).first()
if not contact:
flash('Kontakt nie został znaleziony.', 'error')
return redirect(url_for('contacts_list'))
# Check permissions
if not current_user.is_admin and contact.created_by != current_user.id:
flash('Nie masz uprawnień do usunięcia tego kontaktu.', 'error')
return redirect(url_for('contact_detail', contact_id=contact_id))
# Soft delete
contact.is_active = False
contact.updated_at = datetime.now()
db.commit()
flash(f'Kontakt {contact.full_name} został usunięty.', 'success')
return redirect(url_for('contacts_list'))
finally:
db.close()

View File

@ -0,0 +1,13 @@
"""
Reports Blueprint
=================
Business analytics and reporting routes.
URL prefix: /raporty
"""
from flask import Blueprint
bp = Blueprint('reports', __name__, url_prefix='/raporty')
from . import routes # noqa: F401, E402

View File

@ -0,0 +1,185 @@
"""
Reports Routes
==============
Business analytics and reporting endpoints.
"""
from datetime import datetime, date
from flask import render_template, url_for
from flask_login import login_required
from sqlalchemy import func
from sqlalchemy.orm import joinedload
from . import bp
from database import SessionLocal, Company, Category, CompanySocialMedia
@bp.route('/', endpoint='reports_index')
@login_required
def index():
"""Lista dostępnych raportów."""
reports = [
{
'id': 'staz-czlonkostwa',
'title': 'Staż członkostwa w Izbie NORDA',
'description': 'Zestawienie firm według daty przystąpienia do Izby. Pokazuje historię i lojalność członków.',
'icon': '🏆',
'url': url_for('.report_membership')
},
{
'id': 'social-media',
'title': 'Pokrycie Social Media',
'description': 'Analiza obecności firm w mediach społecznościowych: Facebook, Instagram, LinkedIn, YouTube, TikTok, X.',
'icon': '📱',
'url': url_for('.report_social_media')
},
{
'id': 'struktura-branzowa',
'title': 'Struktura branżowa',
'description': 'Rozkład firm według kategorii działalności: IT, Budownictwo, Usługi, Produkcja, Handel.',
'icon': '🏢',
'url': url_for('.report_categories')
},
]
return render_template('reports/index.html', reports=reports)
@bp.route('/staz-czlonkostwa', endpoint='report_membership')
@login_required
def membership():
"""Raport: Staż członkostwa w Izbie NORDA."""
db = SessionLocal()
try:
# Firmy z member_since, posortowane od najstarszego
companies = db.query(Company).filter(
Company.member_since.isnot(None)
).order_by(Company.member_since.asc()).all()
# Statystyki
today = date.today()
stats = {
'total_with_date': len(companies),
'total_without_date': db.query(Company).filter(
Company.member_since.is_(None)
).count(),
'oldest': companies[0] if companies else None,
'newest': companies[-1] if companies else None,
'avg_years': sum(
(today - c.member_since).days / 365.25
for c in companies
) / len(companies) if companies else 0
}
# Dodaj obliczony staż do każdej firmy
for c in companies:
c.membership_years = int((today - c.member_since).days / 365.25)
# Dodaj też do oldest i newest
if stats['oldest']:
stats['oldest'].membership_years = int((today - stats['oldest'].member_since).days / 365.25)
return render_template(
'reports/membership.html',
companies=companies,
stats=stats,
generated_at=datetime.now()
)
finally:
db.close()
@bp.route('/social-media', endpoint='report_social_media')
@login_required
def social_media():
"""Raport: Pokrycie Social Media."""
db = SessionLocal()
try:
# Wszystkie firmy z ich profilami social media
companies = db.query(Company).options(
joinedload(Company.social_media_profiles)
).order_by(Company.name).all()
platforms = ['facebook', 'instagram', 'linkedin', 'youtube', 'tiktok', 'twitter']
# Statystyki platform
platform_stats = {}
for platform in platforms:
count = db.query(CompanySocialMedia).filter_by(
platform=platform
).count()
platform_stats[platform] = {
'count': count,
'percent': round(count / len(companies) * 100, 1) if companies else 0
}
# Firmy z min. 1 profilem
companies_with_social = [
c for c in companies if c.social_media_profiles
]
stats = {
'total_companies': len(companies),
'with_social': len(companies_with_social),
'without_social': len(companies) - len(companies_with_social),
'coverage_percent': round(
len(companies_with_social) / len(companies) * 100, 1
) if companies else 0
}
return render_template(
'reports/social_media.html',
companies=companies,
platforms=platforms,
platform_stats=platform_stats,
stats=stats,
generated_at=datetime.now()
)
finally:
db.close()
@bp.route('/struktura-branzowa', endpoint='report_categories')
@login_required
def categories():
"""Raport: Struktura branżowa."""
db = SessionLocal()
try:
# Grupowanie po category_id (kolumna FK, nie relacja)
category_counts = db.query(
Company.category_id,
func.count(Company.id).label('count')
).group_by(Company.category_id).all()
total = sum(c.count for c in category_counts)
# Pobierz mapę kategorii (id -> name) jednym zapytaniem
category_map = {cat.id: cat.name for cat in db.query(Category).all()}
categories_list = []
for cat in category_counts:
cat_id = cat.category_id
cat_name = category_map.get(cat_id, 'Brak kategorii') if cat_id else 'Brak kategorii'
examples = db.query(Company.name).filter(
Company.category_id == cat_id
).limit(3).all()
categories_list.append({
'name': cat_name,
'count': cat.count,
'percent': round(cat.count / total * 100, 1) if total else 0,
'examples': [e.name for e in examples]
})
# Sortuj od największej
categories_list.sort(key=lambda x: x['count'], reverse=True)
return render_template(
'reports/categories.html',
categories=categories_list,
total=total,
generated_at=datetime.now()
)
finally:
db.close()

96
config.py Normal file
View File

@ -0,0 +1,96 @@
"""
Flask Configuration
===================
Configuration classes for different environments.
"""
import os
from datetime import timedelta
class Config:
"""Base configuration with common settings."""
# Security: Require strong SECRET_KEY
SECRET_KEY = os.getenv('SECRET_KEY')
# Session configuration
PERMANENT_SESSION_LIFETIME = timedelta(days=7)
# CSRF configuration
WTF_CSRF_ENABLED = True
WTF_CSRF_TIME_LIMIT = None # No time limit for CSRF tokens
# Cookie security
SESSION_COOKIE_HTTPONLY = True
SESSION_COOKIE_SAMESITE = 'Lax'
# Rate limiting
RATELIMIT_STORAGE_URI = "memory://"
RATELIMIT_DEFAULT = ["200 per day", "50 per hour"]
@staticmethod
def init_app(app):
"""Initialize application-specific configuration."""
pass
class DevelopmentConfig(Config):
"""Development environment configuration."""
DEBUG = True
SESSION_COOKIE_SECURE = False # Allow HTTP in development
# Try Redis for rate limiting, fallback to memory
@staticmethod
def init_app(app):
try:
import redis
redis_client = redis.Redis(host='localhost', port=6379, db=0)
redis_client.ping()
app.config['RATELIMIT_STORAGE_URI'] = "redis://localhost:6379/0"
except Exception:
app.config['RATELIMIT_STORAGE_URI'] = "memory://"
class ProductionConfig(Config):
"""Production environment configuration."""
DEBUG = False
SESSION_COOKIE_SECURE = True # HTTPS only
@staticmethod
def init_app(app):
# Use Redis for persistent rate limiting across restarts
try:
import redis
redis_client = redis.Redis(host='localhost', port=6379, db=0)
redis_client.ping()
app.config['RATELIMIT_STORAGE_URI'] = "redis://localhost:6379/0"
except Exception:
import logging
logging.warning("Redis unavailable, rate limiter using memory storage")
app.config['RATELIMIT_STORAGE_URI'] = "memory://"
class TestingConfig(Config):
"""Testing environment configuration."""
TESTING = True
WTF_CSRF_ENABLED = False
SESSION_COOKIE_SECURE = False
config = {
'development': DevelopmentConfig,
'production': ProductionConfig,
'testing': TestingConfig,
'default': DevelopmentConfig
}
def get_config():
"""Get configuration class based on FLASK_ENV environment variable."""
env = os.getenv('FLASK_ENV', 'development')
return config.get(env, config['default'])

28
extensions.py Normal file
View File

@ -0,0 +1,28 @@
"""
Flask Extensions
================
Centralized Flask extension instances.
Extensions are initialized without app, then configured in create_app().
This pattern allows blueprints to import extensions without circular imports.
"""
from flask_wtf.csrf import CSRFProtect
from flask_login import LoginManager
from flask_limiter import Limiter
from flask_limiter.util import get_remote_address
# CSRF Protection
csrf = CSRFProtect()
# Login Manager
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)
limiter = Limiter(
key_func=get_remote_address,
default_limits=["200 per day", "50 per hour"]
)

View File

@ -955,18 +955,18 @@
<a href="#" class="nav-link">Społeczność ▾</a> <a href="#" class="nav-link">Społeczność ▾</a>
<ul class="nav-dropdown-menu"> <ul class="nav-dropdown-menu">
<li><a href="{{ url_for('announcements_list') }}">Aktualności</a></li> <li><a href="{{ url_for('announcements_list') }}">Aktualności</a></li>
<li><a href="{{ url_for('calendar_index') }}">Kalendarz</a></li> <li><a href="{{ url_for('calendar.calendar_index') }}">Kalendarz</a></li>
<li><a href="{{ url_for('forum_index') }}">Forum</a></li> <li><a href="{{ url_for('forum_index') }}">Forum</a></li>
<li><a href="{{ url_for('classifieds_index') }}">Tablica B2B</a></li> <li><a href="{{ url_for('classifieds.classifieds_index') }}">Tablica B2B</a></li>
<li><a href="{{ url_for('chat') }}">NordaGPT</a></li> <li><a href="{{ url_for('chat') }}">NordaGPT</a></li>
<li><a href="{{ url_for('zopk_index') }}">ZOP Kaszubia</a></li> <li><a href="{{ url_for('zopk_index') }}">ZOP Kaszubia</a></li>
<li><a href="{{ url_for('contacts_list') }}">Kontakty zewnętrzne</a></li> <li><a href="{{ url_for('contacts.contacts_list') }}">Kontakty zewnętrzne</a></li>
<li><a href="#" onclick="openConnectionsMap(); return false;">Mapa Powiązań</a></li> <li><a href="#" onclick="openConnectionsMap(); return false;">Mapa Powiązań</a></li>
</ul> </ul>
</li> </li>
<!-- Raporty --> <!-- Raporty -->
<li><a href="{{ url_for('reports_index') }}" class="nav-link {% if request.endpoint and request.endpoint.startswith('report') %}active{% endif %}">Raporty</a></li> <li><a href="{{ url_for('reports.reports_index') }}" class="nav-link {% if request.endpoint and request.endpoint.startswith('report') %}active{% endif %}">Raporty</a></li>
<!-- Notifications --> <!-- Notifications -->
<li class="notifications-dropdown"> <li class="notifications-dropdown">
@ -1240,8 +1240,8 @@
<a href="{{ url_for('index') }}">Katalog firm</a> <a href="{{ url_for('index') }}">Katalog firm</a>
<a href="{{ url_for('search') }}">Wyszukiwarka</a> <a href="{{ url_for('search') }}">Wyszukiwarka</a>
{% if current_user.is_authenticated %} {% if current_user.is_authenticated %}
<a href="{{ url_for('calendar_index') }}">Kalendarz</a> <a href="{{ url_for('calendar.calendar_index') }}">Kalendarz</a>
<a href="{{ url_for('classifieds_index') }}">Tablica B2B</a> <a href="{{ url_for('classifieds.classifieds_index') }}">Tablica B2B</a>
<a href="{{ url_for('new_members') }}">Nowi czlonkowie</a> <a href="{{ url_for('new_members') }}">Nowi czlonkowie</a>
<a href="{{ url_for('chat') }}">NordaGPT</a> <a href="{{ url_for('chat') }}">NordaGPT</a>
{% endif %} {% endif %}

View File

@ -138,7 +138,7 @@
{% for event in events %} {% for event in events %}
<tr data-event-id="{{ event.id }}"> <tr data-event-id="{{ event.id }}">
<td class="event-title-cell"> <td class="event-title-cell">
<a href="{{ url_for('calendar_event', event_id=event.id) }}">{{ event.title }}</a> <a href="{{ url_for('calendar.calendar_event', event_id=event.id) }}">{{ event.title }}</a>
</td> </td>
<td>{{ event.event_date.strftime('%d.%m.%Y') }}</td> <td>{{ event.event_date.strftime('%d.%m.%Y') }}</td>
<td>{{ event.event_type }}</td> <td>{{ event.event_type }}</td>

View File

@ -175,7 +175,7 @@
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<a href="{{ url_for('calendar_index') }}" class="back-link"> <a href="{{ url_for('calendar.calendar_index') }}" class="back-link">
<svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"> <svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M19 12H5M12 19l-7-7 7-7"/> <path d="M19 12H5M12 19l-7-7 7-7"/>
</svg> </svg>
@ -339,7 +339,7 @@ async function toggleRSVP() {
btn.disabled = true; btn.disabled = true;
try { try {
const response = await fetch('{{ url_for("calendar_rsvp", event_id=event.id) }}', { const response = await fetch('{{ url_for("calendar.calendar_rsvp", event_id=event.id) }}', {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',

View File

@ -404,7 +404,7 @@
<div class="day-number">{{ day }}</div> <div class="day-number">{{ day }}</div>
{% if day in events_by_day %} {% if day in events_by_day %}
{% for event in events_by_day[day] %} {% for event in events_by_day[day] %}
<a href="{{ url_for('calendar_event', event_id=event.id) }}" <a href="{{ url_for('calendar.calendar_event', event_id=event.id) }}"
class="calendar-event {{ event.event_type }}" class="calendar-event {{ event.event_type }}"
title="{{ event.title }}{% if event.time_start %} - {{ event.time_start.strftime('%H:%M') }}{% endif %}"> title="{{ event.title }}{% if event.time_start %} - {{ event.time_start.strftime('%H:%M') }}{% endif %}">
{% if event.time_start %}{{ event.time_start.strftime('%H:%M') }} {% endif %}{{ event.title[:18] }}{% if event.title|length > 18 %}...{% endif %} {% if event.time_start %}{{ event.time_start.strftime('%H:%M') }} {% endif %}{{ event.title[:18] }}{% if event.title|length > 18 %}...{% endif %}
@ -442,7 +442,7 @@
</div> </div>
<div class="event-info"> <div class="event-info">
<div class="event-title"> <div class="event-title">
<a href="{{ url_for('calendar_event', event_id=event.id) }}">{{ event.title }}</a> <a href="{{ url_for('calendar.calendar_event', event_id=event.id) }}">{{ event.title }}</a>
</div> </div>
<div class="event-meta"> <div class="event-meta">
<span class="badge-type {{ event.event_type }}">{{ event.event_type }}</span> <span class="badge-type {{ event.event_type }}">{{ event.event_type }}</span>
@ -462,7 +462,7 @@
</div> </div>
<div class="event-actions"> <div class="event-actions">
<span class="attendee-count">{{ event.attendee_count }} uczestników</span> <span class="attendee-count">{{ event.attendee_count }} uczestników</span>
<a href="{{ url_for('calendar_event', event_id=event.id) }}" class="btn btn-primary btn-sm">Szczegoly</a> <a href="{{ url_for('calendar.calendar_event', event_id=event.id) }}" class="btn btn-primary btn-sm">Szczegoly</a>
</div> </div>
</div> </div>
{% endfor %} {% endfor %}
@ -486,7 +486,7 @@
</div> </div>
<div class="event-info"> <div class="event-info">
<div class="event-title"> <div class="event-title">
<a href="{{ url_for('calendar_event', event_id=event.id) }}">{{ event.title }}</a> <a href="{{ url_for('calendar.calendar_event', event_id=event.id) }}">{{ event.title }}</a>
</div> </div>
<div class="event-meta"> <div class="event-meta">
<span class="badge-type {{ event.event_type }}">{{ event.event_type }}</span> <span class="badge-type {{ event.event_type }}">{{ event.event_type }}</span>

View File

@ -257,20 +257,20 @@
<h1>Tablica B2B</h1> <h1>Tablica B2B</h1>
<p class="text-muted">Ogloszenia biznesowe czlonkow Norda Biznes</p> <p class="text-muted">Ogloszenia biznesowe czlonkow Norda Biznes</p>
</div> </div>
<a href="{{ url_for('classifieds_new') }}" class="btn btn-primary">Dodaj ogloszenie</a> <a href="{{ url_for('classifieds.classifieds_new') }}" class="btn btn-primary">Dodaj ogloszenie</a>
</div> </div>
<div class="filters"> <div class="filters">
<div class="filter-group"> <div class="filter-group">
<a href="{{ url_for('classifieds_index', category=category_filter) }}" class="filter-btn {% if not listing_type %}active{% endif %}">Wszystkie</a> <a href="{{ url_for('classifieds.classifieds_index', category=category_filter) }}" class="filter-btn {% if not listing_type %}active{% endif %}">Wszystkie</a>
<a href="{{ url_for('classifieds_index', type='szukam', category=category_filter) }}" class="filter-btn {% if listing_type == 'szukam' %}active{% endif %}">Szukam</a> <a href="{{ url_for('classifieds.classifieds_index', type='szukam', category=category_filter) }}" class="filter-btn {% if listing_type == 'szukam' %}active{% endif %}">Szukam</a>
<a href="{{ url_for('classifieds_index', type='oferuje', category=category_filter) }}" class="filter-btn {% if listing_type == 'oferuje' %}active{% endif %}">Oferuje</a> <a href="{{ url_for('classifieds.classifieds_index', type='oferuje', category=category_filter) }}" class="filter-btn {% if listing_type == 'oferuje' %}active{% endif %}">Oferuje</a>
</div> </div>
<div class="filter-group"> <div class="filter-group">
<a href="{{ url_for('classifieds_index', type=listing_type) }}" class="filter-btn {% if not category_filter %}active{% endif %}">Wszystkie</a> <a href="{{ url_for('classifieds.classifieds_index', type=listing_type) }}" class="filter-btn {% if not category_filter %}active{% endif %}">Wszystkie</a>
{% for cat_value, cat_label in categories %} {% for cat_value, cat_label in categories %}
<a href="{{ url_for('classifieds_index', type=listing_type, category=cat_value) }}" class="filter-btn {% if category_filter == cat_value %}active{% endif %}">{{ cat_label }}</a> <a href="{{ url_for('classifieds.classifieds_index', type=listing_type, category=cat_value) }}" class="filter-btn {% if category_filter == cat_value %}active{% endif %}">{{ cat_label }}</a>
{% endfor %} {% endfor %}
</div> </div>
@ -292,7 +292,7 @@
{% if classified.is_test %}<span class="test-badge">Testowe</span>{% endif %} {% if classified.is_test %}<span class="test-badge">Testowe</span>{% endif %}
</div> </div>
<div class="classified-title"> <div class="classified-title">
<a href="{{ url_for('classifieds_view', classified_id=classified.id) }}">{{ classified.title }}</a> <a href="{{ url_for('classifieds.classifieds_view', classified_id=classified.id) }}">{{ classified.title }}</a>
</div> </div>
<div class="classified-description"> <div class="classified-description">
{{ classified.description[:200] }}{% if classified.description|length > 200 %}...{% endif %} {{ classified.description[:200] }}{% if classified.description|length > 200 %}...{% endif %}
@ -315,7 +315,7 @@
{% else %} {% else %}
<div class="empty-state"> <div class="empty-state">
<p>Brak ogloszen w tej kategorii</p> <p>Brak ogloszen w tej kategorii</p>
<a href="{{ url_for('classifieds_new') }}" class="btn btn-primary mt-2">Dodaj pierwsze ogloszenie</a> <a href="{{ url_for('classifieds.classifieds_new') }}" class="btn btn-primary mt-2">Dodaj pierwsze ogloszenie</a>
</div> </div>
{% endif %} {% endif %}
</div> </div>
@ -323,7 +323,7 @@
{% if total_pages > 1 %} {% if total_pages > 1 %}
<div class="pagination"> <div class="pagination">
{% for p in range(1, total_pages + 1) %} {% for p in range(1, total_pages + 1) %}
<a href="{{ url_for('classifieds_index', type=listing_type, category=category_filter, page=p) }}" class="{% if p == page %}active{% endif %}">{{ p }}</a> <a href="{{ url_for('classifieds.classifieds_index', type=listing_type, category=category_filter, page=p) }}" class="{% if p == page %}active{% endif %}">{{ p }}</a>
{% endfor %} {% endfor %}
</div> </div>
{% endif %} {% endif %}

View File

@ -139,7 +139,7 @@
{% block content %} {% block content %}
<div class="form-container"> <div class="form-container">
<a href="{{ url_for('classifieds_index') }}" class="back-link"> <a href="{{ url_for('classifieds.classifieds_index') }}" class="back-link">
<svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"> <svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M19 12H5M12 19l-7-7 7-7"/> <path d="M19 12H5M12 19l-7-7 7-7"/>
</svg> </svg>
@ -156,7 +156,7 @@
Ogloszenie bedzie widoczne przez 30 dni. Po tym czasie wygasnie automatycznie. Ogloszenie bedzie widoczne przez 30 dni. Po tym czasie wygasnie automatycznie.
</div> </div>
<form method="POST" action="{{ url_for('classifieds_new') }}"> <form method="POST" action="{{ url_for('classifieds.classifieds_new') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="form-group"> <div class="form-group">
@ -217,7 +217,7 @@
<div class="form-actions"> <div class="form-actions">
<button type="submit" class="btn btn-primary">Dodaj ogloszenie</button> <button type="submit" class="btn btn-primary">Dodaj ogloszenie</button>
<a href="{{ url_for('classifieds_index') }}" class="btn btn-secondary">Anuluj</a> <a href="{{ url_for('classifieds.classifieds_index') }}" class="btn btn-secondary">Anuluj</a>
</div> </div>
</form> </form>
</div> </div>

View File

@ -207,7 +207,7 @@
{% block content %} {% block content %}
<div class="classified-container"> <div class="classified-container">
<a href="{{ url_for('classifieds_index') }}" class="back-link"> <a href="{{ url_for('classifieds.classifieds_index') }}" class="back-link">
<svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"> <svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M19 12H5M12 19l-7-7 7-7"/> <path d="M19 12H5M12 19l-7-7 7-7"/>
</svg> </svg>
@ -356,7 +356,7 @@ async function closeClassified() {
if (!confirmed) return; if (!confirmed) return;
try { try {
const response = await fetch('{{ url_for("classifieds_close", classified_id=classified.id) }}', { const response = await fetch('{{ url_for("classifieds.classifieds_close", classified_id=classified.id) }}', {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@ -367,7 +367,7 @@ async function closeClassified() {
const data = await response.json(); const data = await response.json();
if (data.success) { if (data.success) {
showToast('Ogłoszenie zostało zamknięte', 'success'); showToast('Ogłoszenie zostało zamknięte', 'success');
setTimeout(() => window.location.href = '{{ url_for("classifieds_index") }}', 1500); setTimeout(() => window.location.href = '{{ url_for("classifieds.classifieds_index") }}', 1500);
} else { } else {
showToast(data.error || 'Wystąpił błąd', 'error'); showToast(data.error || 'Wystąpił błąd', 'error');
} }

View File

@ -432,7 +432,7 @@
{% block content %} {% block content %}
<div class="container"> <div class="container">
<a href="{{ url_for('contacts_list') }}" class="back-link"> <a href="{{ url_for('contacts.contacts_list') }}" class="back-link">
&larr; Powrot do listy kontaktow &larr; Powrot do listy kontaktow
</a> </a>
@ -667,10 +667,10 @@
<div class="sidebar-section"> <div class="sidebar-section">
<h3 class="sidebar-title">Akcje</h3> <h3 class="sidebar-title">Akcje</h3>
<div class="actions-list"> <div class="actions-list">
<a href="{{ url_for('contact_edit', contact_id=contact.id) }}" class="action-btn primary"> <a href="{{ url_for('contacts.contact_edit', contact_id=contact.id) }}" class="action-btn primary">
&#9998; Edytuj kontakt &#9998; Edytuj kontakt
</a> </a>
<form action="{{ url_for('contact_delete', contact_id=contact.id) }}" method="POST" <form action="{{ url_for('contacts.contact_delete', contact_id=contact.id) }}" method="POST"
onsubmit="return confirm('Czy na pewno chcesz usunac ten kontakt?');"> onsubmit="return confirm('Czy na pewno chcesz usunac ten kontakt?');">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="action-btn danger"> <button type="submit" class="action-btn danger">
@ -709,7 +709,7 @@
</div> </div>
<div class="related-info"> <div class="related-info">
<div class="related-name"> <div class="related-name">
<a href="{{ url_for('contact_detail', contact_id=rc.id) }}">{{ rc.full_name }}</a> <a href="{{ url_for('contacts.contact_detail', contact_id=rc.id) }}">{{ rc.full_name }}</a>
</div> </div>
{% if rc.position %} {% if rc.position %}
<div class="related-position">{{ rc.position }}</div> <div class="related-position">{{ rc.position }}</div>
@ -722,7 +722,7 @@
<!-- Back to list --> <!-- Back to list -->
<div class="sidebar-section" style="text-align: center;"> <div class="sidebar-section" style="text-align: center;">
<a href="{{ url_for('contacts_list') }}" class="action-btn secondary" style="width: 100%;"> <a href="{{ url_for('contacts.contacts_list') }}" class="action-btn secondary" style="width: 100%;">
&larr; Lista kontaktow &larr; Lista kontaktow
</a> </a>
</div> </div>

View File

@ -260,7 +260,7 @@
{% block content %} {% block content %}
<div class="container"> <div class="container">
<div class="form-container"> <div class="form-container">
<a href="{{ url_for('contacts_list') }}" class="back-link"> <a href="{{ url_for('contacts.contacts_list') }}" class="back-link">
&larr; Powrot do listy kontaktow &larr; Powrot do listy kontaktow
</a> </a>
@ -522,7 +522,7 @@
<!-- Form Actions --> <!-- Form Actions -->
<div class="form-actions"> <div class="form-actions">
<a href="{{ url_for('contacts_list') }}" class="btn btn-secondary"> <a href="{{ url_for('contacts.contacts_list') }}" class="btn btn-secondary">
Anuluj Anuluj
</a> </a>
<div class="btn-group"> <div class="btn-group">

View File

@ -892,14 +892,14 @@
<button type="button" class="btn btn-ai" onclick="openAiModal()"> <button type="button" class="btn btn-ai" onclick="openAiModal()">
&#10024; Dodaj z AI &#10024; Dodaj z AI
</button> </button>
<a href="{{ url_for('contact_add') }}" class="btn btn-primary"> <a href="{{ url_for('contacts.contact_add') }}" class="btn btn-primary">
+ Dodaj kontakt + Dodaj kontakt
</a> </a>
</div> </div>
</div> </div>
<div class="contacts-filters"> <div class="contacts-filters">
<form method="GET" action="{{ url_for('contacts_list') }}"> <form method="GET" action="{{ url_for('contacts.contacts_list') }}">
<div class="filters-row"> <div class="filters-row">
<div class="filter-group" style="flex: 2;"> <div class="filter-group" style="flex: 2;">
<label for="search">Szukaj</label> <label for="search">Szukaj</label>
@ -939,7 +939,7 @@
<div class="stats-bar"> <div class="stats-bar">
<span>Znaleziono: {{ total }} kontaktow</span> <span>Znaleziono: {{ total }} kontaktow</span>
{% if search or org_type or project %} {% if search or org_type or project %}
| <a href="{{ url_for('contacts_list') }}" style="color: var(--primary);">Wyczysc filtry</a> | <a href="{{ url_for('contacts.contacts_list') }}" style="color: var(--primary);">Wyczysc filtry</a>
{% endif %} {% endif %}
</div> </div>
<div class="view-toggle"> <div class="view-toggle">
@ -1012,7 +1012,7 @@
</div> </div>
<div class="org-contact-details"> <div class="org-contact-details">
<div class="name"> <div class="name">
<a href="{{ url_for('contact_detail', contact_id=contact.id) }}"> <a href="{{ url_for('contacts.contact_detail', contact_id=contact.id) }}">
{{ contact.full_name }} {{ contact.full_name }}
</a> </a>
</div> </div>
@ -1028,7 +1028,7 @@
{% if contact.email %} {% if contact.email %}
<a href="mailto:{{ contact.email }}">&#9993; Email</a> <a href="mailto:{{ contact.email }}">&#9993; Email</a>
{% endif %} {% endif %}
<a href="{{ url_for('contact_detail', contact_id=contact.id) }}">Szczegoly &rarr;</a> <a href="{{ url_for('contacts.contact_detail', contact_id=contact.id) }}">Szczegoly &rarr;</a>
</div> </div>
</div> </div>
{% endfor %} {% endfor %}
@ -1053,7 +1053,7 @@
</div> </div>
<div class="contact-info"> <div class="contact-info">
<div class="contact-name"> <div class="contact-name">
<a href="{{ url_for('contact_detail', contact_id=contact.id) }}"> <a href="{{ url_for('contacts.contact_detail', contact_id=contact.id) }}">
{{ contact.full_name }} {{ contact.full_name }}
</a> </a>
</div> </div>
@ -1135,7 +1135,7 @@
{{ contact.first_name[0]|upper }} {{ contact.first_name[0]|upper }}
{% endif %} {% endif %}
</div> </div>
<a href="{{ url_for('contact_detail', contact_id=contact.id) }}"> <a href="{{ url_for('contacts.contact_detail', contact_id=contact.id) }}">
{{ contact.full_name }} {{ contact.full_name }}
</a> </a>
</div> </div>
@ -1166,21 +1166,21 @@
{% if total_pages > 1 %} {% if total_pages > 1 %}
<div class="pagination"> <div class="pagination">
{% if page > 1 %} {% if page > 1 %}
<a href="{{ url_for('contacts_list', page=page-1, q=search, type=org_type, project=project) }}">&larr; Poprzednia</a> <a href="{{ url_for('contacts.contacts_list', page=page-1, q=search, type=org_type, project=project) }}">&larr; Poprzednia</a>
{% endif %} {% endif %}
{% for p in range(1, total_pages + 1) %} {% for p in range(1, total_pages + 1) %}
{% if p == page %} {% if p == page %}
<span class="current">{{ p }}</span> <span class="current">{{ p }}</span>
{% elif p == 1 or p == total_pages or (p >= page - 2 and p <= page + 2) %} {% elif p == 1 or p == total_pages or (p >= page - 2 and p <= page + 2) %}
<a href="{{ url_for('contacts_list', page=p, q=search, type=org_type, project=project) }}">{{ p }}</a> <a href="{{ url_for('contacts.contacts_list', page=p, q=search, type=org_type, project=project) }}">{{ p }}</a>
{% elif p == page - 3 or p == page + 3 %} {% elif p == page - 3 or p == page + 3 %}
<span>...</span> <span>...</span>
{% endif %} {% endif %}
{% endfor %} {% endfor %}
{% if page < total_pages %} {% if page < total_pages %}
<a href="{{ url_for('contacts_list', page=page+1, q=search, type=org_type, project=project) }}">Nastepna &rarr;</a> <a href="{{ url_for('contacts.contacts_list', page=page+1, q=search, type=org_type, project=project) }}">Nastepna &rarr;</a>
{% endif %} {% endif %}
</div> </div>
{% endif %} {% endif %}
@ -1200,7 +1200,7 @@
<button type="button" class="btn btn-ai" onclick="openAiModal()"> <button type="button" class="btn btn-ai" onclick="openAiModal()">
&#10024; Dodaj z AI &#10024; Dodaj z AI
</button> </button>
<a href="{{ url_for('contact_add') }}" class="btn btn-primary"> <a href="{{ url_for('contacts.contact_add') }}" class="btn btn-primary">
+ Dodaj pierwszy kontakt + Dodaj pierwszy kontakt
</a> </a>
</div> </div>

View File

@ -480,7 +480,7 @@
</svg> </svg>
NordaGPT NordaGPT
</a> </a>
<a href="{{ url_for('calendar_index') }}" class="btn btn-outline" style="justify-content: flex-start; gap: 8px;"> <a href="{{ url_for('calendar.calendar_index') }}" class="btn btn-outline" style="justify-content: flex-start; gap: 8px;">
<svg width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"> <svg width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/> <path d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/>
</svg> </svg>

View File

@ -727,7 +727,7 @@
<!-- Event Banner - Ankieta "Kto weźmie udział?" --> <!-- Event Banner - Ankieta "Kto weźmie udział?" -->
{% if next_event %} {% if next_event %}
<a href="{{ url_for('calendar_event', event_id=next_event.id) }}" class="event-banner"> <a href="{{ url_for('calendar.calendar_event', event_id=next_event.id) }}" class="event-banner">
<div class="event-banner-icon">📅</div> <div class="event-banner-icon">📅</div>
<div class="event-banner-content"> <div class="event-banner-content">
<div class="event-banner-label">Najbliższe wydarzenie Kto weźmie udział?</div> <div class="event-banner-label">Najbliższe wydarzenie Kto weźmie udział?</div>

View File

@ -337,7 +337,7 @@
{% block content %} {% block content %}
<div class="container"> <div class="container">
<div class="report-header"> <div class="report-header">
<a href="{{ url_for('reports_index') }}" class="back-link"> <a href="{{ url_for('reports.reports_index') }}" class="back-link">
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
</svg> </svg>

View File

@ -209,7 +209,7 @@
{% block content %} {% block content %}
<div class="container"> <div class="container">
<div class="report-header"> <div class="report-header">
<a href="{{ url_for('reports_index') }}" class="back-link"> <a href="{{ url_for('reports.reports_index') }}" class="back-link">
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
</svg> </svg>

View File

@ -265,7 +265,7 @@
{% block content %} {% block content %}
<div class="container"> <div class="container">
<div class="report-header"> <div class="report-header">
<a href="{{ url_for('reports_index') }}" class="back-link"> <a href="{{ url_for('reports.reports_index') }}" class="back-link">
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
</svg> </svg>

28
utils/__init__.py Normal file
View File

@ -0,0 +1,28 @@
"""
Utils Package
=============
Shared utilities for NordaBiz application.
"""
from .decorators import admin_required, verified_required, company_owner_or_admin
from .helpers import sanitize_input, validate_email, validate_password, ensure_url
from .notifications import (
create_notification,
create_news_notification,
create_message_notification,
create_event_notification
)
from .analytics import (
get_or_create_analytics_session,
track_page_view_for_request,
get_current_page_view_id,
set_current_page_view_id,
cleanup_page_view_id,
get_free_tier_usage,
get_brave_api_usage,
log_brave_api_call
)
from .context_processors import register_context_processors
from .error_handlers import register_error_handlers
from .middleware import register_middleware

280
utils/analytics.py Normal file
View File

@ -0,0 +1,280 @@
"""
Analytics Helpers
=================
Functions for tracking page views, API usage, and user analytics.
"""
import logging
import uuid
from datetime import date
from flask import request, session
from flask_login import current_user
from sqlalchemy import func, extract
from user_agents import parse as parse_user_agent
from database import (
SessionLocal, UserSession, PageView, AIAPICostLog, Company
)
logger = logging.getLogger(__name__)
# Global variable to store current page_view_id for templates
_current_page_view_id = {}
def get_or_create_analytics_session():
"""
Get existing analytics session or create new one.
Returns:
The database session ID (integer) or None on error.
"""
analytics_session_id = session.get('analytics_session_id')
if not analytics_session_id:
analytics_session_id = str(uuid.uuid4())
session['analytics_session_id'] = analytics_session_id
db = SessionLocal()
try:
user_session = db.query(UserSession).filter_by(
session_id=analytics_session_id
).first()
if not user_session:
# Parse user agent
ua_string = request.headers.get('User-Agent', '')
try:
ua = parse_user_agent(ua_string)
device_type = 'mobile' if ua.is_mobile else (
'tablet' if ua.is_tablet else 'desktop'
)
browser = ua.browser.family
browser_version = ua.browser.version_string
os_name = ua.os.family
os_version = ua.os.version_string
except Exception:
device_type = 'desktop'
browser = 'Unknown'
browser_version = ''
os_name = 'Unknown'
os_version = ''
user_session = UserSession(
session_id=analytics_session_id,
user_id=current_user.id if current_user.is_authenticated else None,
ip_address=request.remote_addr,
user_agent=ua_string[:2000] if ua_string else None,
device_type=device_type,
browser=browser[:50] if browser else None,
browser_version=browser_version[:20] if browser_version else None,
os=os_name[:50] if os_name else None,
os_version=os_version[:20] if os_version else None
)
db.add(user_session)
db.commit()
db.refresh(user_session)
else:
# Update last activity
from datetime import datetime
user_session.last_activity_at = datetime.now()
if current_user.is_authenticated and not user_session.user_id:
user_session.user_id = current_user.id
db.commit()
return user_session.id
except Exception as e:
logger.error(f"Analytics session error: {e}")
db.rollback()
return None
finally:
db.close()
def track_page_view_for_request():
"""
Track page view for current request.
Called from before_request middleware.
Returns:
page_view_id or None
"""
try:
session_db_id = get_or_create_analytics_session()
if not session_db_id:
return None
db = SessionLocal()
try:
page_view = PageView(
session_id=session_db_id,
user_id=current_user.id if current_user.is_authenticated else None,
url=request.url[:2000] if request.url else '',
path=request.path[:500] if request.path else '/',
referrer=request.referrer[:2000] if request.referrer else None
)
# Extract company_id from path if on company page
if request.path.startswith('/company/'):
try:
slug = request.path.split('/')[2].split('?')[0]
company = db.query(Company).filter_by(slug=slug).first()
if company:
page_view.company_id = company.id
except Exception:
pass
db.add(page_view)
# Update session page count
user_session = db.query(UserSession).filter_by(id=session_db_id).first()
if user_session:
user_session.page_views_count = (user_session.page_views_count or 0) + 1
db.commit()
return page_view.id
except Exception as e:
logger.error(f"Page view tracking error: {e}")
db.rollback()
return None
finally:
db.close()
except Exception as e:
logger.error(f"Page view tracking outer error: {e}")
return None
def get_current_page_view_id():
"""Get page_view_id for current request."""
return _current_page_view_id.get(id(request), '')
def set_current_page_view_id(page_view_id):
"""Set page_view_id for current request."""
_current_page_view_id[id(request)] = page_view_id
def cleanup_page_view_id():
"""Clean up page_view_id from global dict after request."""
_current_page_view_id.pop(id(request), None)
def get_free_tier_usage():
"""
Get today's Gemini API usage for free tier tracking.
Returns:
Dict with requests_today and tokens_today
"""
db = SessionLocal()
try:
today = date.today()
result = db.query(
func.count(AIAPICostLog.id).label('requests'),
func.coalesce(func.sum(AIAPICostLog.total_tokens), 0).label('tokens')
).filter(
func.date(AIAPICostLog.timestamp) == today,
AIAPICostLog.api_provider == 'gemini'
).first()
return {
'requests_today': result.requests or 0,
'tokens_today': int(result.tokens or 0)
}
except Exception as e:
logger.warning(f"Failed to get free tier usage: {e}")
return {'requests_today': 0, 'tokens_today': 0}
finally:
db.close()
def get_brave_api_usage():
"""
Get Brave Search API usage for current month.
Brave free tier: 2000 requests/month
Returns:
Dict with usage stats and limits
"""
db = SessionLocal()
try:
today = date.today()
current_month = today.month
current_year = today.year
# Monthly usage
monthly_result = db.query(
func.count(AIAPICostLog.id).label('requests')
).filter(
extract('month', AIAPICostLog.timestamp) == current_month,
extract('year', AIAPICostLog.timestamp) == current_year,
AIAPICostLog.api_provider == 'brave'
).first()
# Today's usage
daily_result = db.query(
func.count(AIAPICostLog.id).label('requests')
).filter(
func.date(AIAPICostLog.timestamp) == today,
AIAPICostLog.api_provider == 'brave'
).first()
monthly_used = monthly_result.requests or 0
daily_used = daily_result.requests or 0
monthly_limit = 2000 # Brave free tier
return {
'requests_today': daily_used,
'requests_this_month': monthly_used,
'monthly_limit': monthly_limit,
'remaining': max(0, monthly_limit - monthly_used),
'usage_percent': round((monthly_used / monthly_limit) * 100, 1) if monthly_limit > 0 else 0,
'tier': 'free',
'is_limit_reached': monthly_used >= monthly_limit
}
except Exception as e:
logger.warning(f"Failed to get Brave API usage: {e}")
return {
'requests_today': 0,
'requests_this_month': 0,
'monthly_limit': 2000,
'remaining': 2000,
'usage_percent': 0,
'tier': 'free',
'is_limit_reached': False
}
finally:
db.close()
def log_brave_api_call(user_id=None, feature='news_search', company_name=None):
"""
Log a Brave API call for usage tracking.
Args:
user_id: User who triggered the call (optional)
feature: Feature name (news_search, etc.)
company_name: Company being searched (for reference)
"""
db = SessionLocal()
try:
log_entry = AIAPICostLog(
api_provider='brave',
model_name='search_api',
feature=feature,
user_id=user_id,
input_tokens=0,
output_tokens=0,
total_tokens=0
)
db.add(log_entry)
db.commit()
logger.debug(f"Logged Brave API call: {feature} for {company_name}")
except Exception as e:
logger.error(f"Failed to log Brave API call: {e}")
db.rollback()
finally:
db.close()

View File

@ -0,0 +1,46 @@
"""
Context Processors
==================
Functions that inject global variables into all templates.
"""
from datetime import datetime
from flask_login import current_user
from database import SessionLocal, UserNotification
def inject_globals():
"""Inject global variables into all templates."""
return {
'current_year': datetime.now().year,
'now': datetime.now() # Must be value, not method - templates use now.strftime()
}
def inject_notifications():
"""Inject unread notifications count into all templates."""
if current_user.is_authenticated:
db = SessionLocal()
try:
unread_count = db.query(UserNotification).filter(
UserNotification.user_id == current_user.id,
UserNotification.is_read == False
).count()
return {'unread_notifications_count': unread_count}
finally:
db.close()
return {'unread_notifications_count': 0}
def inject_page_view_id():
"""Inject page_view_id into all templates for JS tracking."""
from utils.analytics import get_current_page_view_id
return {'page_view_id': get_current_page_view_id()}
def register_context_processors(app):
"""Register all context processors with the app."""
app.context_processor(inject_globals)
app.context_processor(inject_notifications)
app.context_processor(inject_page_view_id)

89
utils/decorators.py Normal file
View File

@ -0,0 +1,89 @@
"""
Custom Decorators
=================
Reusable decorators for access control and validation.
"""
from functools import wraps
from flask import abort, flash, redirect, url_for
from flask_login import current_user
def admin_required(f):
"""
Decorator that requires user to be logged in AND be an admin.
Usage:
@bp.route('/admin/users')
@login_required
@admin_required
def admin_users():
...
Note: Always use @login_required BEFORE @admin_required
"""
@wraps(f)
def decorated_function(*args, **kwargs):
if not current_user.is_authenticated:
return redirect(url_for('auth.login'))
if not current_user.is_admin:
flash('Brak uprawnień administratora.', 'error')
return redirect(url_for('public.index'))
return f(*args, **kwargs)
return decorated_function
def verified_required(f):
"""
Decorator that requires user to have verified email.
Usage:
@bp.route('/forum/new')
@login_required
@verified_required
def new_topic():
...
"""
@wraps(f)
def decorated_function(*args, **kwargs):
if not current_user.is_authenticated:
return redirect(url_for('auth.login'))
if not current_user.is_verified:
flash('Musisz zweryfikować swój email, aby wykonać tę akcję.', 'warning')
return redirect(url_for('auth.resend_verification'))
return f(*args, **kwargs)
return decorated_function
def company_owner_or_admin(f):
"""
Decorator for routes that accept company_id.
Allows access only if user is admin OR owns the company.
Usage:
@bp.route('/company/<int:company_id>/edit')
@login_required
@company_owner_or_admin
def edit_company(company_id):
...
"""
@wraps(f)
def decorated_function(*args, **kwargs):
if not current_user.is_authenticated:
return redirect(url_for('auth.login'))
company_id = kwargs.get('company_id')
if company_id is None:
abort(400)
if current_user.is_admin:
return f(*args, **kwargs)
if current_user.company_id == company_id:
return f(*args, **kwargs)
flash('Nie masz uprawnień do tej firmy.', 'error')
return redirect(url_for('public.index'))
return decorated_function

50
utils/error_handlers.py Normal file
View File

@ -0,0 +1,50 @@
"""
Error Handlers
==============
Custom error handlers for the Flask application.
"""
from flask import render_template, jsonify, request
import logging
logger = logging.getLogger(__name__)
def register_error_handlers(app):
"""Register all error handlers with the app."""
@app.errorhandler(400)
def bad_request(e):
if request.is_json:
return jsonify({'error': 'Bad request', 'message': str(e)}), 400
return render_template('errors/400.html'), 400
@app.errorhandler(403)
def forbidden(e):
if request.is_json:
return jsonify({'error': 'Forbidden', 'message': 'Access denied'}), 403
return render_template('errors/403.html'), 403
@app.errorhandler(404)
def not_found(e):
if request.is_json:
return jsonify({'error': 'Not found'}), 404
return render_template('errors/404.html'), 404
@app.errorhandler(429)
def ratelimit_handler(e):
logger.warning(f"Rate limit exceeded: {request.remote_addr} - {request.path}")
if request.is_json:
return jsonify({
'error': 'Rate limit exceeded',
'message': 'Zbyt wiele zapytań. Spróbuj ponownie później.'
}), 429
return render_template('errors/429.html'), 429
@app.errorhandler(500)
def internal_error(e):
logger.error(f"Internal server error: {e}")
if request.is_json:
return jsonify({'error': 'Internal server error'}), 500
return render_template('errors/500.html'), 500

101
utils/helpers.py Normal file
View File

@ -0,0 +1,101 @@
"""
Helper Functions
================
Common utility functions used across blueprints.
"""
import re
import logging
logger = logging.getLogger(__name__)
def sanitize_input(text, max_length=1000):
"""
Sanitize user input - remove potentially dangerous characters.
Args:
text: Input string to sanitize
max_length: Maximum allowed length (default 1000)
Returns:
Sanitized string
"""
if not text:
return ""
# Remove null bytes
text = text.replace('\x00', '')
# Trim to max length
text = text[:max_length]
# Strip whitespace
text = text.strip()
return text
def validate_email(email):
"""
Validate email format.
Args:
email: Email address to validate
Returns:
bool: True if valid, False otherwise
"""
if not email or len(email) > 255:
return False
# RFC 5322 compliant email regex (simplified)
pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
return re.match(pattern, email) is not None
def validate_password(password):
"""
Validate password strength.
Requirements:
- Minimum 8 characters
- At least one uppercase letter
- At least one lowercase letter
- At least one digit
Args:
password: Password to validate
Returns:
tuple: (is_valid: bool, message: str)
"""
if not password or len(password) < 8:
return False, "Hasło musi mieć minimum 8 znaków"
if not re.search(r'[A-Z]', password):
return False, "Hasło musi zawierać przynajmniej jedną wielką literę"
if not re.search(r'[a-z]', password):
return False, "Hasło musi zawierać przynajmniej jedną małą literę"
if not re.search(r'\d', password):
return False, "Hasło musi zawierać przynajmniej jedną cyfrę"
return True, "OK"
def ensure_url(url):
"""
Ensure URL has http:// or https:// scheme.
Args:
url: URL string
Returns:
URL with https:// prefix if no scheme present
"""
if url and not url.startswith(('http://', 'https://')):
return f'https://{url}'
return url

120
utils/middleware.py Normal file
View File

@ -0,0 +1,120 @@
"""
Request Middleware
==================
Before/after request hooks for security, analytics, etc.
"""
import logging
from flask import request, abort
logger = logging.getLogger(__name__)
def register_middleware(app):
"""Register all middleware with the app."""
@app.before_request
def check_geoip():
"""Block requests from high-risk countries (RU, CN, KP, IR, BY, SY, VE, CU)."""
# Skip static files and health checks
if request.path.startswith('/static') or request.path == '/health':
return
try:
from security_service import is_ip_allowed, get_country_code, create_security_alert
from database import SessionLocal
if not is_ip_allowed():
ip = request.headers.get('X-Forwarded-For', request.remote_addr)
if ip:
ip = ip.split(',')[0].strip()
country = get_country_code(ip)
logger.warning(f"GEOIP_BLOCKED ip={ip} country={country} path={request.path}")
# Create alert for blocked access
try:
db = SessionLocal()
create_security_alert(
db, 'geo_blocked', 'low',
ip_address=ip,
details={
'country': country,
'path': request.path,
'user_agent': request.user_agent.string[:200]
}
)
db.commit()
db.close()
except Exception as e:
logger.error(f"Failed to create geo block alert: {e}")
abort(403)
except ImportError:
# Security service not available, skip GeoIP check
pass
@app.before_request
def track_page_view():
"""Track page views (excluding static files and API calls)."""
# Skip static files
if request.path.startswith('/static'):
return
# Skip API calls
if request.path.startswith('/api'):
return
# Skip analytics tracking endpoints
if request.path in ['/api/analytics/track', '/api/analytics/heartbeat']:
return
# Skip health checks
if request.path == '/health':
return
# Skip favicon
if request.path == '/favicon.ico':
return
try:
from utils.analytics import (
track_page_view_for_request,
set_current_page_view_id
)
page_view_id = track_page_view_for_request()
if page_view_id:
set_current_page_view_id(page_view_id)
except Exception as e:
logger.error(f"Page view tracking error: {e}")
@app.after_request
def set_security_headers(response):
"""Add security headers to all responses."""
response.headers['X-Content-Type-Options'] = 'nosniff'
response.headers['X-Frame-Options'] = 'SAMEORIGIN'
response.headers['X-XSS-Protection'] = '1; mode=block'
response.headers['Strict-Transport-Security'] = 'max-age=31536000; includeSubDomains'
response.headers['Referrer-Policy'] = 'strict-origin-when-cross-origin'
# Content Security Policy
csp = (
"default-src 'self'; "
"script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; "
"style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://fonts.googleapis.com; "
"img-src 'self' data: https:; "
"font-src 'self' https://cdn.jsdelivr.net https://fonts.gstatic.com; "
"connect-src 'self'"
)
response.headers['Content-Security-Policy'] = csp
return response
@app.teardown_request
def cleanup_page_view_id(exception=None):
"""Clean up page_view_id from global dict after request."""
try:
from utils.analytics import cleanup_page_view_id
cleanup_page_view_id()
except Exception:
pass

123
utils/notifications.py Normal file
View File

@ -0,0 +1,123 @@
"""
Notification Helpers
====================
Functions for creating and managing user notifications.
"""
import logging
from database import SessionLocal, UserNotification, User
logger = logging.getLogger(__name__)
def create_notification(user_id, title, message, notification_type='info',
related_type=None, related_id=None, action_url=None):
"""
Create a notification for a user.
Args:
user_id: ID of the user to notify
title: Notification title
message: Notification message/body
notification_type: Type of notification (news, system, message, event, alert)
related_type: Type of related entity (company_news, event, message, etc.)
related_id: ID of the related entity
action_url: URL to navigate when notification is clicked
Returns:
UserNotification object or None on error
"""
db = SessionLocal()
try:
notification = UserNotification(
user_id=user_id,
title=title,
message=message,
notification_type=notification_type,
related_type=related_type,
related_id=related_id,
action_url=action_url
)
db.add(notification)
db.commit()
db.refresh(notification)
logger.info(f"Created notification for user {user_id}: {title}")
return notification
except Exception as e:
logger.error(f"Error creating notification: {e}")
db.rollback()
return None
finally:
db.close()
def create_news_notification(company_id, news_id, news_title):
"""
Create notification for company owner when their news is approved.
Args:
company_id: ID of the company
news_id: ID of the approved news
news_title: Title of the news
"""
db = SessionLocal()
try:
# Find users associated with this company
users = db.query(User).filter(
User.company_id == company_id,
User.is_active == True
).all()
for user in users:
create_notification(
user_id=user.id,
title="Nowa aktualnosc o Twojej firmie",
message=f"Aktualnosc '{news_title}' zostala zatwierdzona i jest widoczna na profilu firmy.",
notification_type='news',
related_type='company_news',
related_id=news_id,
action_url=f"/company/{company_id}"
)
finally:
db.close()
def create_message_notification(user_id, sender_name, message_id):
"""
Create notification when user receives a private message.
Args:
user_id: ID of the recipient
sender_name: Name of the sender
message_id: ID of the message
"""
create_notification(
user_id=user_id,
title="Nowa wiadomość prywatna",
message=f"Otrzymałeś nową wiadomość od {sender_name}.",
notification_type='message',
related_type='private_message',
related_id=message_id,
action_url=f"/wiadomosci/{message_id}"
)
def create_event_notification(user_id, event_title, event_id):
"""
Create notification for upcoming event reminder.
Args:
user_id: ID of the user to notify
event_title: Title of the event
event_id: ID of the event
"""
create_notification(
user_id=user_id,
title="Przypomnienie o wydarzeniu",
message=f"Zbliża się wydarzenie: {event_title}",
notification_type='event',
related_type='norda_event',
related_id=event_id,
action_url=f"/kalendarz/{event_id}"
)