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:
|
except Exception as e:
|
||||||
logger.error(f"Failed to initialize Gemini service: {e}")
|
logger.error(f"Failed to initialize Gemini service: {e}")
|
||||||
|
|
||||||
|
# Register blueprints (Phase 1: reports, community)
|
||||||
|
from blueprints import register_blueprints
|
||||||
|
register_blueprints(app)
|
||||||
|
logger.info("Blueprints registered")
|
||||||
|
|
||||||
@login_manager.user_loader
|
@login_manager.user_loader
|
||||||
def load_user(user_id):
|
def load_user(user_id):
|
||||||
"""Load user from database"""
|
"""Load user from database"""
|
||||||
@ -2800,178 +2805,11 @@ def admin_fees_export():
|
|||||||
|
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# CALENDAR ROUTES
|
# CALENDAR ROUTES - PUBLIC ROUTES MIGRATED TO blueprints/community/calendar/
|
||||||
# ============================================================
|
# ============================================================
|
||||||
|
# Routes: /kalendarz, /kalendarz/<id>, /kalendarz/<id>/rsvp
|
||||||
|
|
||||||
@app.route('/kalendarz')
|
# Admin calendar routes remain here
|
||||||
@login_required
|
|
||||||
def calendar_index():
|
|
||||||
"""Kalendarz wydarzeń Norda Biznes - widok listy lub siatki miesięcznej"""
|
|
||||||
from datetime import date
|
|
||||||
import calendar as cal_module
|
|
||||||
|
|
||||||
# Polskie nazwy miesięcy
|
|
||||||
POLISH_MONTHS = {
|
|
||||||
1: 'Styczeń', 2: 'Luty', 3: 'Marzec', 4: 'Kwiecień',
|
|
||||||
5: 'Maj', 6: 'Czerwiec', 7: 'Lipiec', 8: 'Sierpień',
|
|
||||||
9: 'Wrzesień', 10: 'Październik', 11: 'Listopad', 12: 'Grudzień'
|
|
||||||
}
|
|
||||||
|
|
||||||
db = SessionLocal()
|
|
||||||
try:
|
|
||||||
today = date.today()
|
|
||||||
|
|
||||||
# Parametry widoku
|
|
||||||
view_mode = request.args.get('view', 'list') # list lub grid
|
|
||||||
year = request.args.get('year', today.year, type=int)
|
|
||||||
month = request.args.get('month', today.month, type=int)
|
|
||||||
|
|
||||||
# Walidacja miesiąca/roku
|
|
||||||
if month < 1:
|
|
||||||
month = 12
|
|
||||||
year -= 1
|
|
||||||
elif month > 12:
|
|
||||||
month = 1
|
|
||||||
year += 1
|
|
||||||
|
|
||||||
# Oblicz poprzedni/następny miesiąc
|
|
||||||
if month == 1:
|
|
||||||
prev_month, prev_year = 12, year - 1
|
|
||||||
else:
|
|
||||||
prev_month, prev_year = month - 1, year
|
|
||||||
|
|
||||||
if month == 12:
|
|
||||||
next_month, next_year = 1, year + 1
|
|
||||||
else:
|
|
||||||
next_month, next_year = month + 1, year
|
|
||||||
|
|
||||||
# Dane dla widoku siatki
|
|
||||||
month_days = []
|
|
||||||
events_by_day = {}
|
|
||||||
|
|
||||||
if view_mode == 'grid':
|
|
||||||
# Pobierz wydarzenia z danego miesiąca
|
|
||||||
first_day = date(year, month, 1)
|
|
||||||
last_day = date(year, month, cal_module.monthrange(year, month)[1])
|
|
||||||
events = db.query(NordaEvent).filter(
|
|
||||||
NordaEvent.event_date >= first_day,
|
|
||||||
NordaEvent.event_date <= last_day
|
|
||||||
).order_by(NordaEvent.event_date.asc()).all()
|
|
||||||
|
|
||||||
# Przygotuj strukturę kalendarza (poniedziałek = 0)
|
|
||||||
cal = cal_module.Calendar(firstweekday=0)
|
|
||||||
month_days = cal.monthdayscalendar(year, month)
|
|
||||||
|
|
||||||
# Mapuj wydarzenia na dni
|
|
||||||
for event in events:
|
|
||||||
day = event.event_date.day
|
|
||||||
if day not in events_by_day:
|
|
||||||
events_by_day[day] = []
|
|
||||||
events_by_day[day].append(event)
|
|
||||||
|
|
||||||
# Dane dla widoku listy (zawsze potrzebne dla fallback)
|
|
||||||
upcoming = db.query(NordaEvent).filter(
|
|
||||||
NordaEvent.event_date >= today
|
|
||||||
).order_by(NordaEvent.event_date.asc()).all()
|
|
||||||
|
|
||||||
past = db.query(NordaEvent).filter(
|
|
||||||
NordaEvent.event_date < today
|
|
||||||
).order_by(NordaEvent.event_date.desc()).limit(5).all()
|
|
||||||
|
|
||||||
return render_template('calendar/index.html',
|
|
||||||
# Dane dla widoku listy
|
|
||||||
upcoming_events=upcoming,
|
|
||||||
past_events=past,
|
|
||||||
today=today,
|
|
||||||
# Dane dla widoku siatki
|
|
||||||
view_mode=view_mode,
|
|
||||||
year=year,
|
|
||||||
month=month,
|
|
||||||
month_name=POLISH_MONTHS.get(month, ''),
|
|
||||||
month_days=month_days,
|
|
||||||
events_by_day=events_by_day,
|
|
||||||
prev_month=prev_month,
|
|
||||||
prev_year=prev_year,
|
|
||||||
next_month=next_month,
|
|
||||||
next_year=next_year,
|
|
||||||
)
|
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
|
|
||||||
|
|
||||||
@app.route('/kalendarz/<int:event_id>')
|
|
||||||
@login_required
|
|
||||||
def calendar_event(event_id):
|
|
||||||
"""Szczegóły wydarzenia"""
|
|
||||||
db = SessionLocal()
|
|
||||||
try:
|
|
||||||
event = db.query(NordaEvent).filter(NordaEvent.id == event_id).first()
|
|
||||||
if not event:
|
|
||||||
flash('Wydarzenie nie istnieje.', 'error')
|
|
||||||
return redirect(url_for('calendar_index'))
|
|
||||||
|
|
||||||
# Sprawdź czy użytkownik jest zapisany
|
|
||||||
user_attending = db.query(EventAttendee).filter(
|
|
||||||
EventAttendee.event_id == event_id,
|
|
||||||
EventAttendee.user_id == current_user.id
|
|
||||||
).first()
|
|
||||||
|
|
||||||
return render_template('calendar/event.html',
|
|
||||||
event=event,
|
|
||||||
user_attending=user_attending
|
|
||||||
)
|
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
|
|
||||||
|
|
||||||
@app.route('/kalendarz/<int:event_id>/rsvp', methods=['POST'])
|
|
||||||
@login_required
|
|
||||||
def calendar_rsvp(event_id):
|
|
||||||
"""Zapisz się / wypisz z wydarzenia"""
|
|
||||||
db = SessionLocal()
|
|
||||||
try:
|
|
||||||
event = db.query(NordaEvent).filter(NordaEvent.id == event_id).first()
|
|
||||||
if not event:
|
|
||||||
return jsonify({'success': False, 'error': 'Wydarzenie nie istnieje'}), 404
|
|
||||||
|
|
||||||
# Sprawdź czy już zapisany
|
|
||||||
existing = db.query(EventAttendee).filter(
|
|
||||||
EventAttendee.event_id == event_id,
|
|
||||||
EventAttendee.user_id == current_user.id
|
|
||||||
).first()
|
|
||||||
|
|
||||||
if existing:
|
|
||||||
# Wypisz
|
|
||||||
db.delete(existing)
|
|
||||||
db.commit()
|
|
||||||
return jsonify({
|
|
||||||
'success': True,
|
|
||||||
'action': 'removed',
|
|
||||||
'message': 'Wypisano z wydarzenia',
|
|
||||||
'attendee_count': event.attendee_count
|
|
||||||
})
|
|
||||||
else:
|
|
||||||
# Zapisz
|
|
||||||
if event.max_attendees and event.attendee_count >= event.max_attendees:
|
|
||||||
return jsonify({'success': False, 'error': 'Brak wolnych miejsc'}), 400
|
|
||||||
|
|
||||||
attendee = EventAttendee(
|
|
||||||
event_id=event_id,
|
|
||||||
user_id=current_user.id,
|
|
||||||
status='confirmed'
|
|
||||||
)
|
|
||||||
db.add(attendee)
|
|
||||||
db.commit()
|
|
||||||
return jsonify({
|
|
||||||
'success': True,
|
|
||||||
'action': 'added',
|
|
||||||
'message': 'Zapisano na wydarzenie',
|
|
||||||
'attendee_count': event.attendee_count
|
|
||||||
})
|
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
|
|
||||||
|
|
||||||
@app.route('/admin/kalendarz')
|
@app.route('/admin/kalendarz')
|
||||||
@login_required
|
@login_required
|
||||||
def admin_calendar():
|
def admin_calendar():
|
||||||
@ -3794,144 +3632,9 @@ def api_delete_recommendation(rec_id):
|
|||||||
|
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# B2B CLASSIFIEDS ROUTES
|
# B2B CLASSIFIEDS ROUTES - MIGRATED TO blueprints/community/classifieds/
|
||||||
# ============================================================
|
# ============================================================
|
||||||
|
# Routes: /tablica, /tablica/nowe, /tablica/<id>, /tablica/<id>/zakoncz
|
||||||
@app.route('/tablica')
|
|
||||||
@login_required
|
|
||||||
def classifieds_index():
|
|
||||||
"""Tablica ogłoszeń B2B"""
|
|
||||||
listing_type = request.args.get('type', '')
|
|
||||||
category = request.args.get('category', '')
|
|
||||||
page = request.args.get('page', 1, type=int)
|
|
||||||
per_page = 20
|
|
||||||
|
|
||||||
db = SessionLocal()
|
|
||||||
try:
|
|
||||||
query = db.query(Classified).filter(
|
|
||||||
Classified.is_active == True
|
|
||||||
)
|
|
||||||
|
|
||||||
# Filtry
|
|
||||||
if listing_type:
|
|
||||||
query = query.filter(Classified.listing_type == listing_type)
|
|
||||||
if category:
|
|
||||||
query = query.filter(Classified.category == category)
|
|
||||||
|
|
||||||
# Sortowanie - najnowsze pierwsze
|
|
||||||
query = query.order_by(Classified.created_at.desc())
|
|
||||||
|
|
||||||
total = query.count()
|
|
||||||
classifieds = query.limit(per_page).offset((page - 1) * per_page).all()
|
|
||||||
|
|
||||||
# Kategorie do filtrów
|
|
||||||
categories = [
|
|
||||||
('uslugi', 'Usługi'),
|
|
||||||
('produkty', 'Produkty'),
|
|
||||||
('wspolpraca', 'Współpraca'),
|
|
||||||
('praca', 'Praca'),
|
|
||||||
('inne', 'Inne')
|
|
||||||
]
|
|
||||||
|
|
||||||
return render_template('classifieds/index.html',
|
|
||||||
classifieds=classifieds,
|
|
||||||
categories=categories,
|
|
||||||
listing_type=listing_type,
|
|
||||||
category_filter=category,
|
|
||||||
page=page,
|
|
||||||
total_pages=(total + per_page - 1) // per_page
|
|
||||||
)
|
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
|
|
||||||
|
|
||||||
@app.route('/tablica/nowe', methods=['GET', 'POST'])
|
|
||||||
@login_required
|
|
||||||
def classifieds_new():
|
|
||||||
"""Dodaj nowe ogłoszenie"""
|
|
||||||
if request.method == 'POST':
|
|
||||||
listing_type = request.form.get('listing_type', '')
|
|
||||||
category = request.form.get('category', '')
|
|
||||||
title = sanitize_input(request.form.get('title', ''), 255)
|
|
||||||
description = request.form.get('description', '').strip()
|
|
||||||
budget_info = sanitize_input(request.form.get('budget_info', ''), 255)
|
|
||||||
location_info = sanitize_input(request.form.get('location_info', ''), 255)
|
|
||||||
|
|
||||||
if not listing_type or not category or not title or not description:
|
|
||||||
flash('Wszystkie wymagane pola muszą być wypełnione.', 'error')
|
|
||||||
return render_template('classifieds/new.html')
|
|
||||||
|
|
||||||
db = SessionLocal()
|
|
||||||
try:
|
|
||||||
# Automatyczne wygaśnięcie po 30 dniach
|
|
||||||
expires = datetime.now() + timedelta(days=30)
|
|
||||||
|
|
||||||
classified = Classified(
|
|
||||||
author_id=current_user.id,
|
|
||||||
company_id=current_user.company_id,
|
|
||||||
listing_type=listing_type,
|
|
||||||
category=category,
|
|
||||||
title=title,
|
|
||||||
description=description,
|
|
||||||
budget_info=budget_info,
|
|
||||||
location_info=location_info,
|
|
||||||
expires_at=expires
|
|
||||||
)
|
|
||||||
db.add(classified)
|
|
||||||
db.commit()
|
|
||||||
|
|
||||||
flash('Ogłoszenie dodane.', 'success')
|
|
||||||
return redirect(url_for('classifieds_index'))
|
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
|
|
||||||
return render_template('classifieds/new.html')
|
|
||||||
|
|
||||||
|
|
||||||
@app.route('/tablica/<int:classified_id>')
|
|
||||||
@login_required
|
|
||||||
def classifieds_view(classified_id):
|
|
||||||
"""Szczegóły ogłoszenia"""
|
|
||||||
db = SessionLocal()
|
|
||||||
try:
|
|
||||||
classified = db.query(Classified).filter(
|
|
||||||
Classified.id == classified_id
|
|
||||||
).first()
|
|
||||||
|
|
||||||
if not classified:
|
|
||||||
flash('Ogłoszenie nie istnieje.', 'error')
|
|
||||||
return redirect(url_for('classifieds_index'))
|
|
||||||
|
|
||||||
# Zwiększ licznik wyświetleń (handle NULL)
|
|
||||||
classified.views_count = (classified.views_count or 0) + 1
|
|
||||||
db.commit()
|
|
||||||
|
|
||||||
return render_template('classifieds/view.html', classified=classified)
|
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
|
|
||||||
|
|
||||||
@app.route('/tablica/<int:classified_id>/zakoncz', methods=['POST'])
|
|
||||||
@login_required
|
|
||||||
def classifieds_close(classified_id):
|
|
||||||
"""Zamknij ogłoszenie"""
|
|
||||||
db = SessionLocal()
|
|
||||||
try:
|
|
||||||
classified = db.query(Classified).filter(
|
|
||||||
Classified.id == classified_id,
|
|
||||||
Classified.author_id == current_user.id
|
|
||||||
).first()
|
|
||||||
|
|
||||||
if not classified:
|
|
||||||
return jsonify({'success': False, 'error': 'Ogłoszenie nie istnieje lub brak uprawnień'}), 404
|
|
||||||
|
|
||||||
classified.is_active = False
|
|
||||||
db.commit()
|
|
||||||
|
|
||||||
return jsonify({'success': True, 'message': 'Ogłoszenie zamknięte'})
|
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# NEW MEMBERS ROUTE
|
# NEW MEMBERS ROUTE
|
||||||
@ -9587,180 +9290,9 @@ def api_it_audit_export():
|
|||||||
|
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# RAPORTY
|
# RAPORTY - MIGRATED TO blueprints/reports/
|
||||||
# ============================================================
|
# ============================================================
|
||||||
|
# Routes: /raporty, /raporty/staz-czlonkostwa, /raporty/social-media, /raporty/struktura-branzowa
|
||||||
@app.route('/raporty')
|
|
||||||
@login_required
|
|
||||||
def reports_index():
|
|
||||||
"""Lista dostępnych raportów."""
|
|
||||||
reports = [
|
|
||||||
{
|
|
||||||
'id': 'staz-czlonkostwa',
|
|
||||||
'title': 'Staż członkostwa w Izbie NORDA',
|
|
||||||
'description': 'Zestawienie firm według daty przystąpienia do Izby. Pokazuje historię i lojalność członków.',
|
|
||||||
'icon': '🏆',
|
|
||||||
'url': url_for('report_membership')
|
|
||||||
},
|
|
||||||
{
|
|
||||||
'id': 'social-media',
|
|
||||||
'title': 'Pokrycie Social Media',
|
|
||||||
'description': 'Analiza obecności firm w mediach społecznościowych: Facebook, Instagram, LinkedIn, YouTube, TikTok, X.',
|
|
||||||
'icon': '📱',
|
|
||||||
'url': url_for('report_social_media')
|
|
||||||
},
|
|
||||||
{
|
|
||||||
'id': 'struktura-branzowa',
|
|
||||||
'title': 'Struktura branżowa',
|
|
||||||
'description': 'Rozkład firm według kategorii działalności: IT, Budownictwo, Usługi, Produkcja, Handel.',
|
|
||||||
'icon': '🏢',
|
|
||||||
'url': url_for('report_categories')
|
|
||||||
},
|
|
||||||
]
|
|
||||||
return render_template('reports/index.html', reports=reports)
|
|
||||||
|
|
||||||
|
|
||||||
@app.route('/raporty/staz-czlonkostwa')
|
|
||||||
@login_required
|
|
||||||
def report_membership():
|
|
||||||
"""Raport: Staż członkostwa w Izbie NORDA."""
|
|
||||||
from datetime import date
|
|
||||||
db = SessionLocal()
|
|
||||||
try:
|
|
||||||
# Firmy z member_since, posortowane od najstarszego
|
|
||||||
companies = db.query(Company).filter(
|
|
||||||
Company.member_since.isnot(None)
|
|
||||||
).order_by(Company.member_since.asc()).all()
|
|
||||||
|
|
||||||
# Statystyki
|
|
||||||
today = date.today()
|
|
||||||
stats = {
|
|
||||||
'total_with_date': len(companies),
|
|
||||||
'total_without_date': db.query(Company).filter(
|
|
||||||
Company.member_since.is_(None)
|
|
||||||
).count(),
|
|
||||||
'oldest': companies[0] if companies else None,
|
|
||||||
'newest': companies[-1] if companies else None,
|
|
||||||
'avg_years': sum(
|
|
||||||
(today - c.member_since).days / 365.25
|
|
||||||
for c in companies
|
|
||||||
) / len(companies) if companies else 0
|
|
||||||
}
|
|
||||||
|
|
||||||
# Dodaj obliczony staż do każdej firmy
|
|
||||||
for c in companies:
|
|
||||||
c.membership_years = int((today - c.member_since).days / 365.25)
|
|
||||||
|
|
||||||
# Dodaj też do oldest i newest
|
|
||||||
if stats['oldest']:
|
|
||||||
stats['oldest'].membership_years = int((today - stats['oldest'].member_since).days / 365.25)
|
|
||||||
|
|
||||||
return render_template(
|
|
||||||
'reports/membership.html',
|
|
||||||
companies=companies,
|
|
||||||
stats=stats,
|
|
||||||
generated_at=datetime.now()
|
|
||||||
)
|
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
|
|
||||||
|
|
||||||
@app.route('/raporty/social-media')
|
|
||||||
@login_required
|
|
||||||
def report_social_media():
|
|
||||||
"""Raport: Pokrycie Social Media."""
|
|
||||||
from sqlalchemy.orm import joinedload
|
|
||||||
db = SessionLocal()
|
|
||||||
try:
|
|
||||||
# Wszystkie firmy z ich profilami social media
|
|
||||||
companies = db.query(Company).options(
|
|
||||||
joinedload(Company.social_media_profiles)
|
|
||||||
).order_by(Company.name).all()
|
|
||||||
|
|
||||||
platforms = ['facebook', 'instagram', 'linkedin', 'youtube', 'tiktok', 'twitter']
|
|
||||||
|
|
||||||
# Statystyki platform
|
|
||||||
platform_stats = {}
|
|
||||||
for platform in platforms:
|
|
||||||
count = db.query(CompanySocialMedia).filter_by(
|
|
||||||
platform=platform
|
|
||||||
).count()
|
|
||||||
platform_stats[platform] = {
|
|
||||||
'count': count,
|
|
||||||
'percent': round(count / len(companies) * 100, 1) if companies else 0
|
|
||||||
}
|
|
||||||
|
|
||||||
# Firmy z min. 1 profilem
|
|
||||||
companies_with_social = [
|
|
||||||
c for c in companies if c.social_media_profiles
|
|
||||||
]
|
|
||||||
|
|
||||||
stats = {
|
|
||||||
'total_companies': len(companies),
|
|
||||||
'with_social': len(companies_with_social),
|
|
||||||
'without_social': len(companies) - len(companies_with_social),
|
|
||||||
'coverage_percent': round(
|
|
||||||
len(companies_with_social) / len(companies) * 100, 1
|
|
||||||
) if companies else 0
|
|
||||||
}
|
|
||||||
|
|
||||||
return render_template(
|
|
||||||
'reports/social_media.html',
|
|
||||||
companies=companies,
|
|
||||||
platforms=platforms,
|
|
||||||
platform_stats=platform_stats,
|
|
||||||
stats=stats,
|
|
||||||
generated_at=datetime.now()
|
|
||||||
)
|
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
|
|
||||||
|
|
||||||
@app.route('/raporty/struktura-branzowa')
|
|
||||||
@login_required
|
|
||||||
def report_categories():
|
|
||||||
"""Raport: Struktura branżowa."""
|
|
||||||
from sqlalchemy import func
|
|
||||||
db = SessionLocal()
|
|
||||||
try:
|
|
||||||
# Grupowanie po category_id (kolumna FK, nie relacja)
|
|
||||||
category_counts = db.query(
|
|
||||||
Company.category_id,
|
|
||||||
func.count(Company.id).label('count')
|
|
||||||
).group_by(Company.category_id).all()
|
|
||||||
|
|
||||||
total = sum(c.count for c in category_counts)
|
|
||||||
|
|
||||||
# Pobierz mapę kategorii (id -> name) jednym zapytaniem
|
|
||||||
category_map = {cat.id: cat.name for cat in db.query(Category).all()}
|
|
||||||
|
|
||||||
categories = []
|
|
||||||
for cat in category_counts:
|
|
||||||
cat_id = cat.category_id
|
|
||||||
cat_name = category_map.get(cat_id, 'Brak kategorii') if cat_id else 'Brak kategorii'
|
|
||||||
|
|
||||||
examples = db.query(Company.name).filter(
|
|
||||||
Company.category_id == cat_id
|
|
||||||
).limit(3).all()
|
|
||||||
|
|
||||||
categories.append({
|
|
||||||
'name': cat_name,
|
|
||||||
'count': cat.count,
|
|
||||||
'percent': round(cat.count / total * 100, 1) if total else 0,
|
|
||||||
'examples': [e.name for e in examples]
|
|
||||||
})
|
|
||||||
|
|
||||||
# Sortuj od największej
|
|
||||||
categories.sort(key=lambda x: x['count'], reverse=True)
|
|
||||||
|
|
||||||
return render_template(
|
|
||||||
'reports/categories.html',
|
|
||||||
categories=categories,
|
|
||||||
total=total,
|
|
||||||
generated_at=datetime.now()
|
|
||||||
)
|
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
@ -13818,298 +13350,10 @@ def announcement_detail(slug):
|
|||||||
|
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# EXTERNAL CONTACTS (Kontakty zewnętrzne)
|
# EXTERNAL CONTACTS - PAGE ROUTES MIGRATED TO blueprints/community/contacts/
|
||||||
# ============================================================
|
# ============================================================
|
||||||
|
# Routes: /kontakty, /kontakty/<id>, /kontakty/dodaj, /kontakty/<id>/edytuj, /kontakty/<id>/usun
|
||||||
@app.route('/kontakty')
|
# API routes remain below for backwards compatibility
|
||||||
@login_required
|
|
||||||
def contacts_list():
|
|
||||||
"""
|
|
||||||
Lista kontaktów zewnętrznych - urzędy, instytucje, partnerzy.
|
|
||||||
Dostępna dla wszystkich zalogowanych członków.
|
|
||||||
"""
|
|
||||||
from database import ExternalContact, User
|
|
||||||
|
|
||||||
db = SessionLocal()
|
|
||||||
try:
|
|
||||||
page = request.args.get('page', 1, type=int)
|
|
||||||
per_page = 20
|
|
||||||
search = request.args.get('q', '').strip()
|
|
||||||
org_type = request.args.get('type', '')
|
|
||||||
project = request.args.get('project', '')
|
|
||||||
|
|
||||||
query = db.query(ExternalContact).filter(ExternalContact.is_active == True)
|
|
||||||
|
|
||||||
# Search filter
|
|
||||||
if search:
|
|
||||||
search_pattern = f'%{search}%'
|
|
||||||
query = query.filter(
|
|
||||||
or_(
|
|
||||||
ExternalContact.first_name.ilike(search_pattern),
|
|
||||||
ExternalContact.last_name.ilike(search_pattern),
|
|
||||||
ExternalContact.organization_name.ilike(search_pattern),
|
|
||||||
ExternalContact.position.ilike(search_pattern),
|
|
||||||
ExternalContact.project_name.ilike(search_pattern),
|
|
||||||
ExternalContact.tags.ilike(search_pattern)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Organization type filter
|
|
||||||
if org_type and org_type in ExternalContact.ORGANIZATION_TYPES:
|
|
||||||
query = query.filter(ExternalContact.organization_type == org_type)
|
|
||||||
|
|
||||||
# Project filter
|
|
||||||
if project:
|
|
||||||
query = query.filter(ExternalContact.project_name.ilike(f'%{project}%'))
|
|
||||||
|
|
||||||
# Order by organization name, then last name
|
|
||||||
query = query.order_by(
|
|
||||||
ExternalContact.organization_name,
|
|
||||||
ExternalContact.last_name
|
|
||||||
)
|
|
||||||
|
|
||||||
# Pagination
|
|
||||||
total = query.count()
|
|
||||||
contacts = query.offset((page - 1) * per_page).limit(per_page).all()
|
|
||||||
total_pages = (total + per_page - 1) // per_page
|
|
||||||
|
|
||||||
# Get unique projects for filter dropdown
|
|
||||||
projects = db.query(ExternalContact.project_name).filter(
|
|
||||||
ExternalContact.is_active == True,
|
|
||||||
ExternalContact.project_name.isnot(None),
|
|
||||||
ExternalContact.project_name != ''
|
|
||||||
).distinct().order_by(ExternalContact.project_name).all()
|
|
||||||
project_names = [p[0] for p in projects if p[0]]
|
|
||||||
|
|
||||||
return render_template('contacts/list.html',
|
|
||||||
contacts=contacts,
|
|
||||||
page=page,
|
|
||||||
total_pages=total_pages,
|
|
||||||
total=total,
|
|
||||||
search=search,
|
|
||||||
org_type=org_type,
|
|
||||||
project=project,
|
|
||||||
org_types=ExternalContact.ORGANIZATION_TYPES,
|
|
||||||
org_type_labels=ExternalContact.ORGANIZATION_TYPE_LABELS,
|
|
||||||
project_names=project_names)
|
|
||||||
|
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
|
|
||||||
|
|
||||||
@app.route('/kontakty/<int:contact_id>')
|
|
||||||
@login_required
|
|
||||||
def contact_detail(contact_id):
|
|
||||||
"""
|
|
||||||
Szczegóły kontaktu zewnętrznego - pełna karta osoby.
|
|
||||||
"""
|
|
||||||
from database import ExternalContact
|
|
||||||
|
|
||||||
db = SessionLocal()
|
|
||||||
try:
|
|
||||||
contact = db.query(ExternalContact).filter(
|
|
||||||
ExternalContact.id == contact_id,
|
|
||||||
ExternalContact.is_active == True
|
|
||||||
).first()
|
|
||||||
|
|
||||||
if not contact:
|
|
||||||
flash('Kontakt nie został znaleziony.', 'error')
|
|
||||||
return redirect(url_for('contacts_list'))
|
|
||||||
|
|
||||||
# Get other contacts from the same organization
|
|
||||||
related_contacts = db.query(ExternalContact).filter(
|
|
||||||
ExternalContact.organization_name == contact.organization_name,
|
|
||||||
ExternalContact.id != contact.id,
|
|
||||||
ExternalContact.is_active == True
|
|
||||||
).order_by(ExternalContact.last_name).limit(5).all()
|
|
||||||
|
|
||||||
# Check if current user can edit (creator or admin)
|
|
||||||
can_edit = (current_user.is_admin or
|
|
||||||
(contact.created_by and contact.created_by == current_user.id))
|
|
||||||
|
|
||||||
return render_template('contacts/detail.html',
|
|
||||||
contact=contact,
|
|
||||||
related_contacts=related_contacts,
|
|
||||||
can_edit=can_edit,
|
|
||||||
org_type_labels=ExternalContact.ORGANIZATION_TYPE_LABELS)
|
|
||||||
|
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
|
|
||||||
|
|
||||||
@app.route('/kontakty/dodaj', methods=['GET', 'POST'])
|
|
||||||
@login_required
|
|
||||||
def contact_add():
|
|
||||||
"""
|
|
||||||
Dodawanie nowego kontaktu zewnętrznego.
|
|
||||||
Każdy zalogowany użytkownik może dodać kontakt.
|
|
||||||
"""
|
|
||||||
from database import ExternalContact
|
|
||||||
|
|
||||||
if request.method == 'POST':
|
|
||||||
db = SessionLocal()
|
|
||||||
try:
|
|
||||||
# Parse related_links from form (JSON)
|
|
||||||
related_links_json = request.form.get('related_links', '[]')
|
|
||||||
try:
|
|
||||||
related_links = json.loads(related_links_json) if related_links_json else []
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
related_links = []
|
|
||||||
|
|
||||||
contact = ExternalContact(
|
|
||||||
first_name=request.form.get('first_name', '').strip(),
|
|
||||||
last_name=request.form.get('last_name', '').strip(),
|
|
||||||
position=request.form.get('position', '').strip() or None,
|
|
||||||
photo_url=request.form.get('photo_url', '').strip() or None,
|
|
||||||
phone=request.form.get('phone', '').strip() or None,
|
|
||||||
phone_secondary=request.form.get('phone_secondary', '').strip() or None,
|
|
||||||
email=request.form.get('email', '').strip() or None,
|
|
||||||
website=request.form.get('website', '').strip() or None,
|
|
||||||
linkedin_url=request.form.get('linkedin_url', '').strip() or None,
|
|
||||||
facebook_url=request.form.get('facebook_url', '').strip() or None,
|
|
||||||
twitter_url=request.form.get('twitter_url', '').strip() or None,
|
|
||||||
organization_name=request.form.get('organization_name', '').strip(),
|
|
||||||
organization_type=request.form.get('organization_type', 'other'),
|
|
||||||
organization_address=request.form.get('organization_address', '').strip() or None,
|
|
||||||
organization_website=request.form.get('organization_website', '').strip() or None,
|
|
||||||
organization_logo_url=request.form.get('organization_logo_url', '').strip() or None,
|
|
||||||
project_name=request.form.get('project_name', '').strip() or None,
|
|
||||||
project_description=request.form.get('project_description', '').strip() or None,
|
|
||||||
source_type='manual',
|
|
||||||
source_url=request.form.get('source_url', '').strip() or None,
|
|
||||||
related_links=related_links,
|
|
||||||
tags=request.form.get('tags', '').strip() or None,
|
|
||||||
notes=request.form.get('notes', '').strip() or None,
|
|
||||||
created_by=current_user.id
|
|
||||||
)
|
|
||||||
|
|
||||||
db.add(contact)
|
|
||||||
db.commit()
|
|
||||||
|
|
||||||
flash(f'Kontakt {contact.full_name} został dodany.', 'success')
|
|
||||||
return redirect(url_for('contact_detail', contact_id=contact.id))
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
db.rollback()
|
|
||||||
app.logger.error(f"Error adding contact: {e}")
|
|
||||||
flash('Wystąpił błąd podczas dodawania kontaktu.', 'error')
|
|
||||||
|
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
|
|
||||||
# GET - show form
|
|
||||||
return render_template('contacts/form.html',
|
|
||||||
contact=None,
|
|
||||||
org_types=ExternalContact.ORGANIZATION_TYPES,
|
|
||||||
org_type_labels=ExternalContact.ORGANIZATION_TYPE_LABELS)
|
|
||||||
|
|
||||||
|
|
||||||
@app.route('/kontakty/<int:contact_id>/edytuj', methods=['GET', 'POST'])
|
|
||||||
@login_required
|
|
||||||
def contact_edit(contact_id):
|
|
||||||
"""
|
|
||||||
Edycja kontaktu zewnętrznego.
|
|
||||||
Może edytować twórca kontaktu lub admin.
|
|
||||||
"""
|
|
||||||
from database import ExternalContact
|
|
||||||
|
|
||||||
db = SessionLocal()
|
|
||||||
try:
|
|
||||||
contact = db.query(ExternalContact).filter(
|
|
||||||
ExternalContact.id == contact_id
|
|
||||||
).first()
|
|
||||||
|
|
||||||
if not contact:
|
|
||||||
flash('Kontakt nie został znaleziony.', 'error')
|
|
||||||
return redirect(url_for('contacts_list'))
|
|
||||||
|
|
||||||
# Check permissions
|
|
||||||
if not current_user.is_admin and contact.created_by != current_user.id:
|
|
||||||
flash('Nie masz uprawnień do edycji tego kontaktu.', 'error')
|
|
||||||
return redirect(url_for('contact_detail', contact_id=contact_id))
|
|
||||||
|
|
||||||
if request.method == 'POST':
|
|
||||||
# Parse related_links from form (JSON)
|
|
||||||
related_links_json = request.form.get('related_links', '[]')
|
|
||||||
try:
|
|
||||||
related_links = json.loads(related_links_json) if related_links_json else []
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
related_links = contact.related_links or []
|
|
||||||
|
|
||||||
contact.first_name = request.form.get('first_name', '').strip()
|
|
||||||
contact.last_name = request.form.get('last_name', '').strip()
|
|
||||||
contact.position = request.form.get('position', '').strip() or None
|
|
||||||
contact.photo_url = request.form.get('photo_url', '').strip() or None
|
|
||||||
contact.phone = request.form.get('phone', '').strip() or None
|
|
||||||
contact.phone_secondary = request.form.get('phone_secondary', '').strip() or None
|
|
||||||
contact.email = request.form.get('email', '').strip() or None
|
|
||||||
contact.website = request.form.get('website', '').strip() or None
|
|
||||||
contact.linkedin_url = request.form.get('linkedin_url', '').strip() or None
|
|
||||||
contact.facebook_url = request.form.get('facebook_url', '').strip() or None
|
|
||||||
contact.twitter_url = request.form.get('twitter_url', '').strip() or None
|
|
||||||
contact.organization_name = request.form.get('organization_name', '').strip()
|
|
||||||
contact.organization_type = request.form.get('organization_type', 'other')
|
|
||||||
contact.organization_address = request.form.get('organization_address', '').strip() or None
|
|
||||||
contact.organization_website = request.form.get('organization_website', '').strip() or None
|
|
||||||
contact.organization_logo_url = request.form.get('organization_logo_url', '').strip() or None
|
|
||||||
contact.project_name = request.form.get('project_name', '').strip() or None
|
|
||||||
contact.project_description = request.form.get('project_description', '').strip() or None
|
|
||||||
contact.source_url = request.form.get('source_url', '').strip() or None
|
|
||||||
contact.related_links = related_links
|
|
||||||
contact.tags = request.form.get('tags', '').strip() or None
|
|
||||||
contact.notes = request.form.get('notes', '').strip() or None
|
|
||||||
contact.updated_at = datetime.now()
|
|
||||||
|
|
||||||
db.commit()
|
|
||||||
|
|
||||||
flash(f'Kontakt {contact.full_name} został zaktualizowany.', 'success')
|
|
||||||
return redirect(url_for('contact_detail', contact_id=contact.id))
|
|
||||||
|
|
||||||
# GET - show form with existing data
|
|
||||||
return render_template('contacts/form.html',
|
|
||||||
contact=contact,
|
|
||||||
org_types=ExternalContact.ORGANIZATION_TYPES,
|
|
||||||
org_type_labels=ExternalContact.ORGANIZATION_TYPE_LABELS)
|
|
||||||
|
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
|
|
||||||
|
|
||||||
@app.route('/kontakty/<int:contact_id>/usun', methods=['POST'])
|
|
||||||
@login_required
|
|
||||||
def contact_delete(contact_id):
|
|
||||||
"""
|
|
||||||
Usuwanie kontaktu zewnętrznego (soft delete).
|
|
||||||
Może usunąć twórca kontaktu lub admin.
|
|
||||||
"""
|
|
||||||
from database import ExternalContact
|
|
||||||
|
|
||||||
db = SessionLocal()
|
|
||||||
try:
|
|
||||||
contact = db.query(ExternalContact).filter(
|
|
||||||
ExternalContact.id == contact_id
|
|
||||||
).first()
|
|
||||||
|
|
||||||
if not contact:
|
|
||||||
flash('Kontakt nie został znaleziony.', 'error')
|
|
||||||
return redirect(url_for('contacts_list'))
|
|
||||||
|
|
||||||
# Check permissions
|
|
||||||
if not current_user.is_admin and contact.created_by != current_user.id:
|
|
||||||
flash('Nie masz uprawnień do usunięcia tego kontaktu.', 'error')
|
|
||||||
return redirect(url_for('contact_detail', contact_id=contact_id))
|
|
||||||
|
|
||||||
# Soft delete
|
|
||||||
contact.is_active = False
|
|
||||||
contact.updated_at = datetime.now()
|
|
||||||
db.commit()
|
|
||||||
|
|
||||||
flash(f'Kontakt {contact.full_name} został usunięty.', 'success')
|
|
||||||
return redirect(url_for('contacts_list'))
|
|
||||||
|
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# AI-ASSISTED EXTERNAL CONTACT CREATION
|
# AI-ASSISTED EXTERNAL CONTACT CREATION
|
||||||
|
|||||||
53
blueprints/__init__.py
Normal file
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>
|
<a href="#" class="nav-link">Społeczność ▾</a>
|
||||||
<ul class="nav-dropdown-menu">
|
<ul class="nav-dropdown-menu">
|
||||||
<li><a href="{{ url_for('announcements_list') }}">Aktualności</a></li>
|
<li><a href="{{ url_for('announcements_list') }}">Aktualności</a></li>
|
||||||
<li><a href="{{ url_for('calendar_index') }}">Kalendarz</a></li>
|
<li><a href="{{ url_for('calendar.calendar_index') }}">Kalendarz</a></li>
|
||||||
<li><a href="{{ url_for('forum_index') }}">Forum</a></li>
|
<li><a href="{{ url_for('forum_index') }}">Forum</a></li>
|
||||||
<li><a href="{{ url_for('classifieds_index') }}">Tablica B2B</a></li>
|
<li><a href="{{ url_for('classifieds.classifieds_index') }}">Tablica B2B</a></li>
|
||||||
<li><a href="{{ url_for('chat') }}">NordaGPT</a></li>
|
<li><a href="{{ url_for('chat') }}">NordaGPT</a></li>
|
||||||
<li><a href="{{ url_for('zopk_index') }}">ZOP Kaszubia</a></li>
|
<li><a href="{{ url_for('zopk_index') }}">ZOP Kaszubia</a></li>
|
||||||
<li><a href="{{ url_for('contacts_list') }}">Kontakty zewnętrzne</a></li>
|
<li><a href="{{ url_for('contacts.contacts_list') }}">Kontakty zewnętrzne</a></li>
|
||||||
<li><a href="#" onclick="openConnectionsMap(); return false;">Mapa Powiązań</a></li>
|
<li><a href="#" onclick="openConnectionsMap(); return false;">Mapa Powiązań</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
<!-- Raporty -->
|
<!-- Raporty -->
|
||||||
<li><a href="{{ url_for('reports_index') }}" class="nav-link {% if request.endpoint and request.endpoint.startswith('report') %}active{% endif %}">Raporty</a></li>
|
<li><a href="{{ url_for('reports.reports_index') }}" class="nav-link {% if request.endpoint and request.endpoint.startswith('report') %}active{% endif %}">Raporty</a></li>
|
||||||
|
|
||||||
<!-- Notifications -->
|
<!-- Notifications -->
|
||||||
<li class="notifications-dropdown">
|
<li class="notifications-dropdown">
|
||||||
@ -1240,8 +1240,8 @@
|
|||||||
<a href="{{ url_for('index') }}">Katalog firm</a>
|
<a href="{{ url_for('index') }}">Katalog firm</a>
|
||||||
<a href="{{ url_for('search') }}">Wyszukiwarka</a>
|
<a href="{{ url_for('search') }}">Wyszukiwarka</a>
|
||||||
{% if current_user.is_authenticated %}
|
{% if current_user.is_authenticated %}
|
||||||
<a href="{{ url_for('calendar_index') }}">Kalendarz</a>
|
<a href="{{ url_for('calendar.calendar_index') }}">Kalendarz</a>
|
||||||
<a href="{{ url_for('classifieds_index') }}">Tablica B2B</a>
|
<a href="{{ url_for('classifieds.classifieds_index') }}">Tablica B2B</a>
|
||||||
<a href="{{ url_for('new_members') }}">Nowi czlonkowie</a>
|
<a href="{{ url_for('new_members') }}">Nowi czlonkowie</a>
|
||||||
<a href="{{ url_for('chat') }}">NordaGPT</a>
|
<a href="{{ url_for('chat') }}">NordaGPT</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@ -138,7 +138,7 @@
|
|||||||
{% for event in events %}
|
{% for event in events %}
|
||||||
<tr data-event-id="{{ event.id }}">
|
<tr data-event-id="{{ event.id }}">
|
||||||
<td class="event-title-cell">
|
<td class="event-title-cell">
|
||||||
<a href="{{ url_for('calendar_event', event_id=event.id) }}">{{ event.title }}</a>
|
<a href="{{ url_for('calendar.calendar_event', event_id=event.id) }}">{{ event.title }}</a>
|
||||||
</td>
|
</td>
|
||||||
<td>{{ event.event_date.strftime('%d.%m.%Y') }}</td>
|
<td>{{ event.event_date.strftime('%d.%m.%Y') }}</td>
|
||||||
<td>{{ event.event_type }}</td>
|
<td>{{ event.event_type }}</td>
|
||||||
|
|||||||
@ -175,7 +175,7 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<a href="{{ url_for('calendar_index') }}" class="back-link">
|
<a href="{{ url_for('calendar.calendar_index') }}" class="back-link">
|
||||||
<svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
<svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||||
<path d="M19 12H5M12 19l-7-7 7-7"/>
|
<path d="M19 12H5M12 19l-7-7 7-7"/>
|
||||||
</svg>
|
</svg>
|
||||||
@ -339,7 +339,7 @@ async function toggleRSVP() {
|
|||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('{{ url_for("calendar_rsvp", event_id=event.id) }}', {
|
const response = await fetch('{{ url_for("calendar.calendar_rsvp", event_id=event.id) }}', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
|
|||||||
@ -404,7 +404,7 @@
|
|||||||
<div class="day-number">{{ day }}</div>
|
<div class="day-number">{{ day }}</div>
|
||||||
{% if day in events_by_day %}
|
{% if day in events_by_day %}
|
||||||
{% for event in events_by_day[day] %}
|
{% for event in events_by_day[day] %}
|
||||||
<a href="{{ url_for('calendar_event', event_id=event.id) }}"
|
<a href="{{ url_for('calendar.calendar_event', event_id=event.id) }}"
|
||||||
class="calendar-event {{ event.event_type }}"
|
class="calendar-event {{ event.event_type }}"
|
||||||
title="{{ event.title }}{% if event.time_start %} - {{ event.time_start.strftime('%H:%M') }}{% endif %}">
|
title="{{ event.title }}{% if event.time_start %} - {{ event.time_start.strftime('%H:%M') }}{% endif %}">
|
||||||
{% if event.time_start %}{{ event.time_start.strftime('%H:%M') }} {% endif %}{{ event.title[:18] }}{% if event.title|length > 18 %}...{% endif %}
|
{% if event.time_start %}{{ event.time_start.strftime('%H:%M') }} {% endif %}{{ event.title[:18] }}{% if event.title|length > 18 %}...{% endif %}
|
||||||
@ -442,7 +442,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="event-info">
|
<div class="event-info">
|
||||||
<div class="event-title">
|
<div class="event-title">
|
||||||
<a href="{{ url_for('calendar_event', event_id=event.id) }}">{{ event.title }}</a>
|
<a href="{{ url_for('calendar.calendar_event', event_id=event.id) }}">{{ event.title }}</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="event-meta">
|
<div class="event-meta">
|
||||||
<span class="badge-type {{ event.event_type }}">{{ event.event_type }}</span>
|
<span class="badge-type {{ event.event_type }}">{{ event.event_type }}</span>
|
||||||
@ -462,7 +462,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="event-actions">
|
<div class="event-actions">
|
||||||
<span class="attendee-count">{{ event.attendee_count }} uczestników</span>
|
<span class="attendee-count">{{ event.attendee_count }} uczestników</span>
|
||||||
<a href="{{ url_for('calendar_event', event_id=event.id) }}" class="btn btn-primary btn-sm">Szczegoly</a>
|
<a href="{{ url_for('calendar.calendar_event', event_id=event.id) }}" class="btn btn-primary btn-sm">Szczegoly</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
@ -486,7 +486,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="event-info">
|
<div class="event-info">
|
||||||
<div class="event-title">
|
<div class="event-title">
|
||||||
<a href="{{ url_for('calendar_event', event_id=event.id) }}">{{ event.title }}</a>
|
<a href="{{ url_for('calendar.calendar_event', event_id=event.id) }}">{{ event.title }}</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="event-meta">
|
<div class="event-meta">
|
||||||
<span class="badge-type {{ event.event_type }}">{{ event.event_type }}</span>
|
<span class="badge-type {{ event.event_type }}">{{ event.event_type }}</span>
|
||||||
|
|||||||
@ -257,20 +257,20 @@
|
|||||||
<h1>Tablica B2B</h1>
|
<h1>Tablica B2B</h1>
|
||||||
<p class="text-muted">Ogloszenia biznesowe czlonkow Norda Biznes</p>
|
<p class="text-muted">Ogloszenia biznesowe czlonkow Norda Biznes</p>
|
||||||
</div>
|
</div>
|
||||||
<a href="{{ url_for('classifieds_new') }}" class="btn btn-primary">Dodaj ogloszenie</a>
|
<a href="{{ url_for('classifieds.classifieds_new') }}" class="btn btn-primary">Dodaj ogloszenie</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="filters">
|
<div class="filters">
|
||||||
<div class="filter-group">
|
<div class="filter-group">
|
||||||
<a href="{{ url_for('classifieds_index', category=category_filter) }}" class="filter-btn {% if not listing_type %}active{% endif %}">Wszystkie</a>
|
<a href="{{ url_for('classifieds.classifieds_index', category=category_filter) }}" class="filter-btn {% if not listing_type %}active{% endif %}">Wszystkie</a>
|
||||||
<a href="{{ url_for('classifieds_index', type='szukam', category=category_filter) }}" class="filter-btn {% if listing_type == 'szukam' %}active{% endif %}">Szukam</a>
|
<a href="{{ url_for('classifieds.classifieds_index', type='szukam', category=category_filter) }}" class="filter-btn {% if listing_type == 'szukam' %}active{% endif %}">Szukam</a>
|
||||||
<a href="{{ url_for('classifieds_index', type='oferuje', category=category_filter) }}" class="filter-btn {% if listing_type == 'oferuje' %}active{% endif %}">Oferuje</a>
|
<a href="{{ url_for('classifieds.classifieds_index', type='oferuje', category=category_filter) }}" class="filter-btn {% if listing_type == 'oferuje' %}active{% endif %}">Oferuje</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="filter-group">
|
<div class="filter-group">
|
||||||
<a href="{{ url_for('classifieds_index', type=listing_type) }}" class="filter-btn {% if not category_filter %}active{% endif %}">Wszystkie</a>
|
<a href="{{ url_for('classifieds.classifieds_index', type=listing_type) }}" class="filter-btn {% if not category_filter %}active{% endif %}">Wszystkie</a>
|
||||||
{% for cat_value, cat_label in categories %}
|
{% for cat_value, cat_label in categories %}
|
||||||
<a href="{{ url_for('classifieds_index', type=listing_type, category=cat_value) }}" class="filter-btn {% if category_filter == cat_value %}active{% endif %}">{{ cat_label }}</a>
|
<a href="{{ url_for('classifieds.classifieds_index', type=listing_type, category=cat_value) }}" class="filter-btn {% if category_filter == cat_value %}active{% endif %}">{{ cat_label }}</a>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -292,7 +292,7 @@
|
|||||||
{% if classified.is_test %}<span class="test-badge">Testowe</span>{% endif %}
|
{% if classified.is_test %}<span class="test-badge">Testowe</span>{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class="classified-title">
|
<div class="classified-title">
|
||||||
<a href="{{ url_for('classifieds_view', classified_id=classified.id) }}">{{ classified.title }}</a>
|
<a href="{{ url_for('classifieds.classifieds_view', classified_id=classified.id) }}">{{ classified.title }}</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="classified-description">
|
<div class="classified-description">
|
||||||
{{ classified.description[:200] }}{% if classified.description|length > 200 %}...{% endif %}
|
{{ classified.description[:200] }}{% if classified.description|length > 200 %}...{% endif %}
|
||||||
@ -315,7 +315,7 @@
|
|||||||
{% else %}
|
{% else %}
|
||||||
<div class="empty-state">
|
<div class="empty-state">
|
||||||
<p>Brak ogloszen w tej kategorii</p>
|
<p>Brak ogloszen w tej kategorii</p>
|
||||||
<a href="{{ url_for('classifieds_new') }}" class="btn btn-primary mt-2">Dodaj pierwsze ogloszenie</a>
|
<a href="{{ url_for('classifieds.classifieds_new') }}" class="btn btn-primary mt-2">Dodaj pierwsze ogloszenie</a>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
@ -323,7 +323,7 @@
|
|||||||
{% if total_pages > 1 %}
|
{% if total_pages > 1 %}
|
||||||
<div class="pagination">
|
<div class="pagination">
|
||||||
{% for p in range(1, total_pages + 1) %}
|
{% for p in range(1, total_pages + 1) %}
|
||||||
<a href="{{ url_for('classifieds_index', type=listing_type, category=category_filter, page=p) }}" class="{% if p == page %}active{% endif %}">{{ p }}</a>
|
<a href="{{ url_for('classifieds.classifieds_index', type=listing_type, category=category_filter, page=p) }}" class="{% if p == page %}active{% endif %}">{{ p }}</a>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@ -139,7 +139,7 @@
|
|||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="form-container">
|
<div class="form-container">
|
||||||
<a href="{{ url_for('classifieds_index') }}" class="back-link">
|
<a href="{{ url_for('classifieds.classifieds_index') }}" class="back-link">
|
||||||
<svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
<svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||||
<path d="M19 12H5M12 19l-7-7 7-7"/>
|
<path d="M19 12H5M12 19l-7-7 7-7"/>
|
||||||
</svg>
|
</svg>
|
||||||
@ -156,7 +156,7 @@
|
|||||||
Ogloszenie bedzie widoczne przez 30 dni. Po tym czasie wygasnie automatycznie.
|
Ogloszenie bedzie widoczne przez 30 dni. Po tym czasie wygasnie automatycznie.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form method="POST" action="{{ url_for('classifieds_new') }}">
|
<form method="POST" action="{{ url_for('classifieds.classifieds_new') }}">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
@ -217,7 +217,7 @@
|
|||||||
|
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
<button type="submit" class="btn btn-primary">Dodaj ogloszenie</button>
|
<button type="submit" class="btn btn-primary">Dodaj ogloszenie</button>
|
||||||
<a href="{{ url_for('classifieds_index') }}" class="btn btn-secondary">Anuluj</a>
|
<a href="{{ url_for('classifieds.classifieds_index') }}" class="btn btn-secondary">Anuluj</a>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -207,7 +207,7 @@
|
|||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="classified-container">
|
<div class="classified-container">
|
||||||
<a href="{{ url_for('classifieds_index') }}" class="back-link">
|
<a href="{{ url_for('classifieds.classifieds_index') }}" class="back-link">
|
||||||
<svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
<svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||||
<path d="M19 12H5M12 19l-7-7 7-7"/>
|
<path d="M19 12H5M12 19l-7-7 7-7"/>
|
||||||
</svg>
|
</svg>
|
||||||
@ -356,7 +356,7 @@ async function closeClassified() {
|
|||||||
if (!confirmed) return;
|
if (!confirmed) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('{{ url_for("classifieds_close", classified_id=classified.id) }}', {
|
const response = await fetch('{{ url_for("classifieds.classifieds_close", classified_id=classified.id) }}', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
@ -367,7 +367,7 @@ async function closeClassified() {
|
|||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
showToast('Ogłoszenie zostało zamknięte', 'success');
|
showToast('Ogłoszenie zostało zamknięte', 'success');
|
||||||
setTimeout(() => window.location.href = '{{ url_for("classifieds_index") }}', 1500);
|
setTimeout(() => window.location.href = '{{ url_for("classifieds.classifieds_index") }}', 1500);
|
||||||
} else {
|
} else {
|
||||||
showToast(data.error || 'Wystąpił błąd', 'error');
|
showToast(data.error || 'Wystąpił błąd', 'error');
|
||||||
}
|
}
|
||||||
|
|||||||
@ -432,7 +432,7 @@
|
|||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<a href="{{ url_for('contacts_list') }}" class="back-link">
|
<a href="{{ url_for('contacts.contacts_list') }}" class="back-link">
|
||||||
← Powrot do listy kontaktow
|
← Powrot do listy kontaktow
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
@ -667,10 +667,10 @@
|
|||||||
<div class="sidebar-section">
|
<div class="sidebar-section">
|
||||||
<h3 class="sidebar-title">Akcje</h3>
|
<h3 class="sidebar-title">Akcje</h3>
|
||||||
<div class="actions-list">
|
<div class="actions-list">
|
||||||
<a href="{{ url_for('contact_edit', contact_id=contact.id) }}" class="action-btn primary">
|
<a href="{{ url_for('contacts.contact_edit', contact_id=contact.id) }}" class="action-btn primary">
|
||||||
✎ Edytuj kontakt
|
✎ Edytuj kontakt
|
||||||
</a>
|
</a>
|
||||||
<form action="{{ url_for('contact_delete', contact_id=contact.id) }}" method="POST"
|
<form action="{{ url_for('contacts.contact_delete', contact_id=contact.id) }}" method="POST"
|
||||||
onsubmit="return confirm('Czy na pewno chcesz usunac ten kontakt?');">
|
onsubmit="return confirm('Czy na pewno chcesz usunac ten kontakt?');">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
<button type="submit" class="action-btn danger">
|
<button type="submit" class="action-btn danger">
|
||||||
@ -709,7 +709,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="related-info">
|
<div class="related-info">
|
||||||
<div class="related-name">
|
<div class="related-name">
|
||||||
<a href="{{ url_for('contact_detail', contact_id=rc.id) }}">{{ rc.full_name }}</a>
|
<a href="{{ url_for('contacts.contact_detail', contact_id=rc.id) }}">{{ rc.full_name }}</a>
|
||||||
</div>
|
</div>
|
||||||
{% if rc.position %}
|
{% if rc.position %}
|
||||||
<div class="related-position">{{ rc.position }}</div>
|
<div class="related-position">{{ rc.position }}</div>
|
||||||
@ -722,7 +722,7 @@
|
|||||||
|
|
||||||
<!-- Back to list -->
|
<!-- Back to list -->
|
||||||
<div class="sidebar-section" style="text-align: center;">
|
<div class="sidebar-section" style="text-align: center;">
|
||||||
<a href="{{ url_for('contacts_list') }}" class="action-btn secondary" style="width: 100%;">
|
<a href="{{ url_for('contacts.contacts_list') }}" class="action-btn secondary" style="width: 100%;">
|
||||||
← Lista kontaktow
|
← Lista kontaktow
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -260,7 +260,7 @@
|
|||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="form-container">
|
<div class="form-container">
|
||||||
<a href="{{ url_for('contacts_list') }}" class="back-link">
|
<a href="{{ url_for('contacts.contacts_list') }}" class="back-link">
|
||||||
← Powrot do listy kontaktow
|
← Powrot do listy kontaktow
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
@ -522,7 +522,7 @@
|
|||||||
|
|
||||||
<!-- Form Actions -->
|
<!-- Form Actions -->
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
<a href="{{ url_for('contacts_list') }}" class="btn btn-secondary">
|
<a href="{{ url_for('contacts.contacts_list') }}" class="btn btn-secondary">
|
||||||
Anuluj
|
Anuluj
|
||||||
</a>
|
</a>
|
||||||
<div class="btn-group">
|
<div class="btn-group">
|
||||||
|
|||||||
@ -892,14 +892,14 @@
|
|||||||
<button type="button" class="btn btn-ai" onclick="openAiModal()">
|
<button type="button" class="btn btn-ai" onclick="openAiModal()">
|
||||||
✨ Dodaj z AI
|
✨ Dodaj z AI
|
||||||
</button>
|
</button>
|
||||||
<a href="{{ url_for('contact_add') }}" class="btn btn-primary">
|
<a href="{{ url_for('contacts.contact_add') }}" class="btn btn-primary">
|
||||||
+ Dodaj kontakt
|
+ Dodaj kontakt
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="contacts-filters">
|
<div class="contacts-filters">
|
||||||
<form method="GET" action="{{ url_for('contacts_list') }}">
|
<form method="GET" action="{{ url_for('contacts.contacts_list') }}">
|
||||||
<div class="filters-row">
|
<div class="filters-row">
|
||||||
<div class="filter-group" style="flex: 2;">
|
<div class="filter-group" style="flex: 2;">
|
||||||
<label for="search">Szukaj</label>
|
<label for="search">Szukaj</label>
|
||||||
@ -939,7 +939,7 @@
|
|||||||
<div class="stats-bar">
|
<div class="stats-bar">
|
||||||
<span>Znaleziono: {{ total }} kontaktow</span>
|
<span>Znaleziono: {{ total }} kontaktow</span>
|
||||||
{% if search or org_type or project %}
|
{% if search or org_type or project %}
|
||||||
| <a href="{{ url_for('contacts_list') }}" style="color: var(--primary);">Wyczysc filtry</a>
|
| <a href="{{ url_for('contacts.contacts_list') }}" style="color: var(--primary);">Wyczysc filtry</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class="view-toggle">
|
<div class="view-toggle">
|
||||||
@ -1012,7 +1012,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="org-contact-details">
|
<div class="org-contact-details">
|
||||||
<div class="name">
|
<div class="name">
|
||||||
<a href="{{ url_for('contact_detail', contact_id=contact.id) }}">
|
<a href="{{ url_for('contacts.contact_detail', contact_id=contact.id) }}">
|
||||||
{{ contact.full_name }}
|
{{ contact.full_name }}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@ -1028,7 +1028,7 @@
|
|||||||
{% if contact.email %}
|
{% if contact.email %}
|
||||||
<a href="mailto:{{ contact.email }}">✉ Email</a>
|
<a href="mailto:{{ contact.email }}">✉ Email</a>
|
||||||
{% endif %}
|
{% 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>
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
@ -1053,7 +1053,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="contact-info">
|
<div class="contact-info">
|
||||||
<div class="contact-name">
|
<div class="contact-name">
|
||||||
<a href="{{ url_for('contact_detail', contact_id=contact.id) }}">
|
<a href="{{ url_for('contacts.contact_detail', contact_id=contact.id) }}">
|
||||||
{{ contact.full_name }}
|
{{ contact.full_name }}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@ -1135,7 +1135,7 @@
|
|||||||
{{ contact.first_name[0]|upper }}
|
{{ contact.first_name[0]|upper }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<a href="{{ url_for('contact_detail', contact_id=contact.id) }}">
|
<a href="{{ url_for('contacts.contact_detail', contact_id=contact.id) }}">
|
||||||
{{ contact.full_name }}
|
{{ contact.full_name }}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@ -1166,21 +1166,21 @@
|
|||||||
{% if total_pages > 1 %}
|
{% if total_pages > 1 %}
|
||||||
<div class="pagination">
|
<div class="pagination">
|
||||||
{% if page > 1 %}
|
{% if page > 1 %}
|
||||||
<a href="{{ url_for('contacts_list', page=page-1, q=search, type=org_type, project=project) }}">← Poprzednia</a>
|
<a href="{{ url_for('contacts.contacts_list', page=page-1, q=search, type=org_type, project=project) }}">← Poprzednia</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% for p in range(1, total_pages + 1) %}
|
{% for p in range(1, total_pages + 1) %}
|
||||||
{% if p == page %}
|
{% if p == page %}
|
||||||
<span class="current">{{ p }}</span>
|
<span class="current">{{ p }}</span>
|
||||||
{% elif p == 1 or p == total_pages or (p >= page - 2 and p <= page + 2) %}
|
{% elif p == 1 or p == total_pages or (p >= page - 2 and p <= page + 2) %}
|
||||||
<a href="{{ url_for('contacts_list', page=p, q=search, type=org_type, project=project) }}">{{ p }}</a>
|
<a href="{{ url_for('contacts.contacts_list', page=p, q=search, type=org_type, project=project) }}">{{ p }}</a>
|
||||||
{% elif p == page - 3 or p == page + 3 %}
|
{% elif p == page - 3 or p == page + 3 %}
|
||||||
<span>...</span>
|
<span>...</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
||||||
{% if page < total_pages %}
|
{% if page < total_pages %}
|
||||||
<a href="{{ url_for('contacts_list', page=page+1, q=search, type=org_type, project=project) }}">Nastepna →</a>
|
<a href="{{ url_for('contacts.contacts_list', page=page+1, q=search, type=org_type, project=project) }}">Nastepna →</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@ -1200,7 +1200,7 @@
|
|||||||
<button type="button" class="btn btn-ai" onclick="openAiModal()">
|
<button type="button" class="btn btn-ai" onclick="openAiModal()">
|
||||||
✨ Dodaj z AI
|
✨ Dodaj z AI
|
||||||
</button>
|
</button>
|
||||||
<a href="{{ url_for('contact_add') }}" class="btn btn-primary">
|
<a href="{{ url_for('contacts.contact_add') }}" class="btn btn-primary">
|
||||||
+ Dodaj pierwszy kontakt
|
+ Dodaj pierwszy kontakt
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -480,7 +480,7 @@
|
|||||||
</svg>
|
</svg>
|
||||||
NordaGPT
|
NordaGPT
|
||||||
</a>
|
</a>
|
||||||
<a href="{{ url_for('calendar_index') }}" class="btn btn-outline" style="justify-content: flex-start; gap: 8px;">
|
<a href="{{ url_for('calendar.calendar_index') }}" class="btn btn-outline" style="justify-content: flex-start; gap: 8px;">
|
||||||
<svg width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
<svg width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||||
<path d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/>
|
<path d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
@ -727,7 +727,7 @@
|
|||||||
|
|
||||||
<!-- Event Banner - Ankieta "Kto weźmie udział?" -->
|
<!-- Event Banner - Ankieta "Kto weźmie udział?" -->
|
||||||
{% if next_event %}
|
{% if next_event %}
|
||||||
<a href="{{ url_for('calendar_event', event_id=next_event.id) }}" class="event-banner">
|
<a href="{{ url_for('calendar.calendar_event', event_id=next_event.id) }}" class="event-banner">
|
||||||
<div class="event-banner-icon">📅</div>
|
<div class="event-banner-icon">📅</div>
|
||||||
<div class="event-banner-content">
|
<div class="event-banner-content">
|
||||||
<div class="event-banner-label">Najbliższe wydarzenie – Kto weźmie udział?</div>
|
<div class="event-banner-label">Najbliższe wydarzenie – Kto weźmie udział?</div>
|
||||||
|
|||||||
@ -337,7 +337,7 @@
|
|||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="report-header">
|
<div class="report-header">
|
||||||
<a href="{{ url_for('reports_index') }}" class="back-link">
|
<a href="{{ url_for('reports.reports_index') }}" class="back-link">
|
||||||
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
@ -209,7 +209,7 @@
|
|||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="report-header">
|
<div class="report-header">
|
||||||
<a href="{{ url_for('reports_index') }}" class="back-link">
|
<a href="{{ url_for('reports.reports_index') }}" class="back-link">
|
||||||
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
@ -265,7 +265,7 @@
|
|||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="report-header">
|
<div class="report-header">
|
||||||
<a href="{{ url_for('reports_index') }}" class="back-link">
|
<a href="{{ url_for('reports.reports_index') }}" class="back-link">
|
||||||
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
28
utils/__init__.py
Normal file
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