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:
parent
819b8d9c13
commit
66856a697d
786
app.py
786
app.py
@ -278,6 +278,11 @@ try:
|
||||
except Exception as 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
|
||||
def load_user(user_id):
|
||||
"""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')
|
||||
@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()
|
||||
|
||||
|
||||
# Admin calendar routes remain here
|
||||
@app.route('/admin/kalendarz')
|
||||
@login_required
|
||||
def admin_calendar():
|
||||
@ -3794,144 +3632,9 @@ def api_delete_recommendation(rec_id):
|
||||
|
||||
|
||||
# ============================================================
|
||||
# B2B CLASSIFIEDS ROUTES
|
||||
# B2B CLASSIFIEDS ROUTES - MIGRATED TO blueprints/community/classifieds/
|
||||
# ============================================================
|
||||
|
||||
@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()
|
||||
|
||||
# Routes: /tablica, /tablica/nowe, /tablica/<id>, /tablica/<id>/zakoncz
|
||||
|
||||
# ============================================================
|
||||
# NEW MEMBERS ROUTE
|
||||
@ -9587,180 +9290,9 @@ def api_it_audit_export():
|
||||
|
||||
|
||||
# ============================================================
|
||||
# RAPORTY
|
||||
# RAPORTY - MIGRATED TO blueprints/reports/
|
||||
# ============================================================
|
||||
|
||||
@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()
|
||||
# Routes: /raporty, /raporty/staz-czlonkostwa, /raporty/social-media, /raporty/struktura-branzowa
|
||||
|
||||
|
||||
# ============================================================
|
||||
@ -13818,298 +13350,10 @@ def announcement_detail(slug):
|
||||
|
||||
|
||||
# ============================================================
|
||||
# EXTERNAL CONTACTS (Kontakty zewnętrzne)
|
||||
# EXTERNAL CONTACTS - PAGE ROUTES MIGRATED TO blueprints/community/contacts/
|
||||
# ============================================================
|
||||
|
||||
@app.route('/kontakty')
|
||||
@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()
|
||||
|
||||
# Routes: /kontakty, /kontakty/<id>, /kontakty/dodaj, /kontakty/<id>/edytuj, /kontakty/<id>/usun
|
||||
# API routes remain below for backwards compatibility
|
||||
|
||||
# ============================================================
|
||||
# AI-ASSISTED EXTERNAL CONTACT CREATION
|
||||
|
||||
53
blueprints/__init__.py
Normal file
53
blueprints/__init__.py
Normal 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
|
||||
9
blueprints/community/__init__.py
Normal file
9
blueprints/community/__init__.py
Normal 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').
|
||||
"""
|
||||
13
blueprints/community/calendar/__init__.py
Normal file
13
blueprints/community/calendar/__init__.py
Normal 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
|
||||
181
blueprints/community/calendar/routes.py
Normal file
181
blueprints/community/calendar/routes.py
Normal 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()
|
||||
13
blueprints/community/classifieds/__init__.py
Normal file
13
blueprints/community/classifieds/__init__.py
Normal 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
|
||||
150
blueprints/community/classifieds/routes.py
Normal file
150
blueprints/community/classifieds/routes.py
Normal 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()
|
||||
13
blueprints/community/contacts/__init__.py
Normal file
13
blueprints/community/contacts/__init__.py
Normal 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
|
||||
299
blueprints/community/contacts/routes.py
Normal file
299
blueprints/community/contacts/routes.py
Normal 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()
|
||||
13
blueprints/reports/__init__.py
Normal file
13
blueprints/reports/__init__.py
Normal 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
|
||||
185
blueprints/reports/routes.py
Normal file
185
blueprints/reports/routes.py
Normal 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
96
config.py
Normal 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
28
extensions.py
Normal 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"]
|
||||
)
|
||||
@ -955,18 +955,18 @@
|
||||
<a href="#" class="nav-link">Społeczność ▾</a>
|
||||
<ul class="nav-dropdown-menu">
|
||||
<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('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('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>
|
||||
</ul>
|
||||
</li>
|
||||
|
||||
<!-- 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 -->
|
||||
<li class="notifications-dropdown">
|
||||
@ -1240,8 +1240,8 @@
|
||||
<a href="{{ url_for('index') }}">Katalog firm</a>
|
||||
<a href="{{ url_for('search') }}">Wyszukiwarka</a>
|
||||
{% if current_user.is_authenticated %}
|
||||
<a href="{{ url_for('calendar_index') }}">Kalendarz</a>
|
||||
<a href="{{ url_for('classifieds_index') }}">Tablica B2B</a>
|
||||
<a href="{{ url_for('calendar.calendar_index') }}">Kalendarz</a>
|
||||
<a href="{{ url_for('classifieds.classifieds_index') }}">Tablica B2B</a>
|
||||
<a href="{{ url_for('new_members') }}">Nowi czlonkowie</a>
|
||||
<a href="{{ url_for('chat') }}">NordaGPT</a>
|
||||
{% endif %}
|
||||
|
||||
@ -138,7 +138,7 @@
|
||||
{% for event in events %}
|
||||
<tr data-event-id="{{ event.id }}">
|
||||
<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>{{ event.event_date.strftime('%d.%m.%Y') }}</td>
|
||||
<td>{{ event.event_type }}</td>
|
||||
|
||||
@ -175,7 +175,7 @@
|
||||
{% endblock %}
|
||||
|
||||
{% 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">
|
||||
<path d="M19 12H5M12 19l-7-7 7-7"/>
|
||||
</svg>
|
||||
@ -339,7 +339,7 @@ async function toggleRSVP() {
|
||||
btn.disabled = true;
|
||||
|
||||
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',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
|
||||
@ -404,7 +404,7 @@
|
||||
<div class="day-number">{{ day }}</div>
|
||||
{% if day in events_by_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 }}"
|
||||
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 %}
|
||||
@ -442,7 +442,7 @@
|
||||
</div>
|
||||
<div class="event-info">
|
||||
<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 class="event-meta">
|
||||
<span class="badge-type {{ event.event_type }}">{{ event.event_type }}</span>
|
||||
@ -462,7 +462,7 @@
|
||||
</div>
|
||||
<div class="event-actions">
|
||||
<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>
|
||||
{% endfor %}
|
||||
@ -486,7 +486,7 @@
|
||||
</div>
|
||||
<div class="event-info">
|
||||
<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 class="event-meta">
|
||||
<span class="badge-type {{ event.event_type }}">{{ event.event_type }}</span>
|
||||
|
||||
@ -257,20 +257,20 @@
|
||||
<h1>Tablica B2B</h1>
|
||||
<p class="text-muted">Ogloszenia biznesowe czlonkow Norda Biznes</p>
|
||||
</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 class="filters">
|
||||
<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_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', category=category_filter) }}" class="filter-btn {% if not listing_type %}active{% endif %}">Wszystkie</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.classifieds_index', type='oferuje', category=category_filter) }}" class="filter-btn {% if listing_type == 'oferuje' %}active{% endif %}">Oferuje</a>
|
||||
</div>
|
||||
|
||||
<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 %}
|
||||
<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 %}
|
||||
</div>
|
||||
|
||||
@ -292,7 +292,7 @@
|
||||
{% if classified.is_test %}<span class="test-badge">Testowe</span>{% endif %}
|
||||
</div>
|
||||
<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 class="classified-description">
|
||||
{{ classified.description[:200] }}{% if classified.description|length > 200 %}...{% endif %}
|
||||
@ -315,7 +315,7 @@
|
||||
{% else %}
|
||||
<div class="empty-state">
|
||||
<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>
|
||||
{% endif %}
|
||||
</div>
|
||||
@ -323,7 +323,7 @@
|
||||
{% if total_pages > 1 %}
|
||||
<div class="pagination">
|
||||
{% 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 %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
@ -139,7 +139,7 @@
|
||||
|
||||
{% block content %}
|
||||
<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">
|
||||
<path d="M19 12H5M12 19l-7-7 7-7"/>
|
||||
</svg>
|
||||
@ -156,7 +156,7 @@
|
||||
Ogloszenie bedzie widoczne przez 30 dni. Po tym czasie wygasnie automatycznie.
|
||||
</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() }}">
|
||||
|
||||
<div class="form-group">
|
||||
@ -217,7 +217,7 @@
|
||||
|
||||
<div class="form-actions">
|
||||
<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>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@ -207,7 +207,7 @@
|
||||
|
||||
{% block content %}
|
||||
<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">
|
||||
<path d="M19 12H5M12 19l-7-7 7-7"/>
|
||||
</svg>
|
||||
@ -356,7 +356,7 @@ async function closeClassified() {
|
||||
if (!confirmed) return;
|
||||
|
||||
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',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@ -367,7 +367,7 @@ async function closeClassified() {
|
||||
const data = await response.json();
|
||||
if (data.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 {
|
||||
showToast(data.error || 'Wystąpił błąd', 'error');
|
||||
}
|
||||
|
||||
@ -432,7 +432,7 @@
|
||||
|
||||
{% block content %}
|
||||
<div class="container">
|
||||
<a href="{{ url_for('contacts_list') }}" class="back-link">
|
||||
<a href="{{ url_for('contacts.contacts_list') }}" class="back-link">
|
||||
← Powrot do listy kontaktow
|
||||
</a>
|
||||
|
||||
@ -667,10 +667,10 @@
|
||||
<div class="sidebar-section">
|
||||
<h3 class="sidebar-title">Akcje</h3>
|
||||
<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">
|
||||
✎ Edytuj kontakt
|
||||
</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?');">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="action-btn danger">
|
||||
@ -709,7 +709,7 @@
|
||||
</div>
|
||||
<div class="related-info">
|
||||
<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>
|
||||
{% if rc.position %}
|
||||
<div class="related-position">{{ rc.position }}</div>
|
||||
@ -722,7 +722,7 @@
|
||||
|
||||
<!-- Back to list -->
|
||||
<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%;">
|
||||
← Lista kontaktow
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@ -260,7 +260,7 @@
|
||||
{% block content %}
|
||||
<div class="container">
|
||||
<div class="form-container">
|
||||
<a href="{{ url_for('contacts_list') }}" class="back-link">
|
||||
<a href="{{ url_for('contacts.contacts_list') }}" class="back-link">
|
||||
← Powrot do listy kontaktow
|
||||
</a>
|
||||
|
||||
@ -522,7 +522,7 @@
|
||||
|
||||
<!-- 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
|
||||
</a>
|
||||
<div class="btn-group">
|
||||
|
||||
@ -892,14 +892,14 @@
|
||||
<button type="button" class="btn btn-ai" onclick="openAiModal()">
|
||||
✨ Dodaj z AI
|
||||
</button>
|
||||
<a href="{{ url_for('contact_add') }}" class="btn btn-primary">
|
||||
<a href="{{ url_for('contacts.contact_add') }}" class="btn btn-primary">
|
||||
+ Dodaj kontakt
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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="filter-group" style="flex: 2;">
|
||||
<label for="search">Szukaj</label>
|
||||
@ -939,7 +939,7 @@
|
||||
<div class="stats-bar">
|
||||
<span>Znaleziono: {{ total }} kontaktow</span>
|
||||
{% 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 %}
|
||||
</div>
|
||||
<div class="view-toggle">
|
||||
@ -1012,7 +1012,7 @@
|
||||
</div>
|
||||
<div class="org-contact-details">
|
||||
<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 }}
|
||||
</a>
|
||||
</div>
|
||||
@ -1028,7 +1028,7 @@
|
||||
{% if contact.email %}
|
||||
<a href="mailto:{{ contact.email }}">✉ Email</a>
|
||||
{% endif %}
|
||||
<a href="{{ url_for('contact_detail', contact_id=contact.id) }}">Szczegoly →</a>
|
||||
<a href="{{ url_for('contacts.contact_detail', contact_id=contact.id) }}">Szczegoly →</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
@ -1053,7 +1053,7 @@
|
||||
</div>
|
||||
<div class="contact-info">
|
||||
<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 }}
|
||||
</a>
|
||||
</div>
|
||||
@ -1135,7 +1135,7 @@
|
||||
{{ contact.first_name[0]|upper }}
|
||||
{% endif %}
|
||||
</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 }}
|
||||
</a>
|
||||
</div>
|
||||
@ -1166,21 +1166,21 @@
|
||||
{% if total_pages > 1 %}
|
||||
<div class="pagination">
|
||||
{% if page > 1 %}
|
||||
<a href="{{ url_for('contacts_list', page=page-1, q=search, type=org_type, project=project) }}">← Poprzednia</a>
|
||||
<a href="{{ url_for('contacts.contacts_list', page=page-1, q=search, type=org_type, project=project) }}">← Poprzednia</a>
|
||||
{% endif %}
|
||||
|
||||
{% for p in range(1, total_pages + 1) %}
|
||||
{% if p == page %}
|
||||
<span class="current">{{ p }}</span>
|
||||
{% 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 %}
|
||||
<span>...</span>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{% if page < total_pages %}
|
||||
<a href="{{ url_for('contacts_list', page=page+1, q=search, type=org_type, project=project) }}">Nastepna →</a>
|
||||
<a href="{{ url_for('contacts.contacts_list', page=page+1, q=search, type=org_type, project=project) }}">Nastepna →</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
@ -1200,7 +1200,7 @@
|
||||
<button type="button" class="btn btn-ai" onclick="openAiModal()">
|
||||
✨ Dodaj z AI
|
||||
</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
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@ -480,7 +480,7 @@
|
||||
</svg>
|
||||
NordaGPT
|
||||
</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">
|
||||
<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>
|
||||
|
||||
@ -727,7 +727,7 @@
|
||||
|
||||
<!-- Event Banner - Ankieta "Kto weźmie udział?" -->
|
||||
{% 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-content">
|
||||
<div class="event-banner-label">Najbliższe wydarzenie – Kto weźmie udział?</div>
|
||||
|
||||
@ -337,7 +337,7 @@
|
||||
{% block content %}
|
||||
<div class="container">
|
||||
<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">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
|
||||
</svg>
|
||||
|
||||
@ -209,7 +209,7 @@
|
||||
{% block content %}
|
||||
<div class="container">
|
||||
<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">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
|
||||
</svg>
|
||||
|
||||
@ -265,7 +265,7 @@
|
||||
{% block content %}
|
||||
<div class="container">
|
||||
<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">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
|
||||
</svg>
|
||||
|
||||
28
utils/__init__.py
Normal file
28
utils/__init__.py
Normal 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
280
utils/analytics.py
Normal 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()
|
||||
46
utils/context_processors.py
Normal file
46
utils/context_processors.py
Normal 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
89
utils/decorators.py
Normal 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
50
utils/error_handlers.py
Normal 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
101
utils/helpers.py
Normal 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
120
utils/middleware.py
Normal 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
123
utils/notifications.py
Normal 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}"
|
||||
)
|
||||
Loading…
Reference in New Issue
Block a user