""" Calendar Routes =============== Public calendar and event registration endpoints. Includes iCal subscription feed at /kalendarz/ical """ from datetime import date, datetime, timedelta import calendar as cal_module from flask import render_template, request, redirect, url_for, flash, jsonify, Response from flask_login import login_required, current_user from . import bp from database import SessionLocal, NordaEvent, EventAttendee, EventGuest # 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', 'cards') # cards, list lub grid year = request.args.get('year', today.year, type=int) month = request.args.get('month', today.month, type=int) # Walidacja roku if year < 2020 or year > 2100: return redirect(url_for('.calendar_index')) # Walidacja miesiąca 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]) all_events = db.query(NordaEvent).filter( NordaEvent.event_date >= first_day, NordaEvent.event_date <= last_day ).order_by(NordaEvent.event_date.asc()).all() # Zakres zapytania: także wydarzenia, których zakres zahacza o miesiąc # (np. 30.06-02.07 widoczne zarówno w czerwcu jak i lipcu). from sqlalchemy import or_ as _sql_or, and_ as _sql_and extra_events = db.query(NordaEvent).filter( NordaEvent.event_date_end.isnot(None), NordaEvent.event_date < first_day, NordaEvent.event_date_end >= first_day, NordaEvent.event_date_end <= last_day, ).all() all_events = list(all_events) + [e for e in extra_events if e not in all_events] # Filtruj wydarzenia według uprawnień użytkownika events = [e for e in all_events if e.can_user_view(current_user)] # Przygotuj strukturę kalendarza (poniedziałek = 0) cal = cal_module.Calendar(firstweekday=0) month_days = cal.monthdayscalendar(year, month) # Mapuj wydarzenia na dni. # Wydarzenia wielodniowe pokazujemy na KAŻDYM dniu w zakresie mieszczącym # się w bieżącym miesiącu — by uczestnik widział w kalendarzu cały czas trwania. for event in events: span_start = max(event.event_date, first_day) span_end = min(event.event_date_end or event.event_date, last_day) d = span_start while d <= span_end: day = d.day if day not in events_by_day: events_by_day[day] = [] events_by_day[day].append(event) d += timedelta(days=1) # Dane dla widoku listy (zawsze potrzebne dla fallback) # COALESCE: dla wydarzeń wielodniowych używamy daty zakończenia, by event # "trwający dzisiaj" pozostał w Upcoming aż do swojego ostatniego dnia. from sqlalchemy import func as _sql_func _effective_end = _sql_func.coalesce(NordaEvent.event_date_end, NordaEvent.event_date) all_upcoming = db.query(NordaEvent).filter( _effective_end >= today ).order_by(NordaEvent.event_date.asc()).all() # Filtruj według uprawnień upcoming = [e for e in all_upcoming if e.can_user_view(current_user)] all_past = db.query(NordaEvent).filter( _effective_end < today ).order_by(NordaEvent.event_date.desc()).limit(10).all() past = [e for e in all_past if e.can_user_view(current_user)][:5] 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() def _enrich_event_description(db, html): """Enrich event description: link member names and auto-link URLs. Processes HTML text nodes only (skips content inside tags and HTML attributes). """ import re from markupsafe import Markup, escape from flask import url_for as flask_url_for from database import User # If plain text (no HTML block tags), convert newlines to HTML if '

' not in html and '{p.replace(chr(10), "
")}

' for p in paragraphs if p.strip()) # Lekki markdown: **bold** → bold. # Safe bo audyt (2026-04): tylko 1 wydarzenie używa `**` (świadomie, event #60). # Działamy po wrappingu HTML, ale `**` nie występuje w tagach ani URL-ach, więc # nie psuje innych treści. Nie rozszerzamy na *italic* / __bold__ / listy / linki, # by nie zmieniać istniejącego wyglądu (vide #45 z `•` zamiast `-`). html = re.sub(r'\*\*([^*\n]+)\*\*', r'\1', html) # Build replacement maps — persons (all users with a name) members = db.query(User.id, User.name).filter( User.name.isnot(None), ).all() members = sorted(members, key=lambda m: len(m.name), reverse=True) # person_map: exact name → url # person_fuzzy: list of (pattern, display_name, url) for declined Polish names person_map = {} person_fuzzy = [] for m in members: if m.name in person_map: continue url = flask_url_for('public.user_profile', user_id=m.id) person_map[m.name] = url # Build fuzzy pattern for Polish name declensions: "Iwona Spaleniak" → "Iwon\w+ Spaleniak" parts = m.name.split() if len(parts) >= 2: first = parts[0] rest = ' '.join(parts[1:]) # Stem: keep at least 3 chars, cut last 1-2 chars depending on length stem_len = max(3, len(first) - 2) stem = re.escape(first[:stem_len]) fuzzy_pattern = r'\b' + stem + r'\w*\s+' + re.escape(rest) + r'\b' person_fuzzy.append((fuzzy_pattern, m.name, url)) # Build replacement maps — companies from database import Company companies = db.query(Company.name, Company.slug).filter( Company.slug.isnot(None), Company.name.isnot(None), Company.status == 'active', ).all() companies = sorted(companies, key=lambda c: len(c.name), reverse=True) # Skip company names that are common Polish/English words — too many false positives _common_words = {'Portal', 'Joker', 'Wakat'} company_map = {} for c in companies: if c.name in _common_words: continue company_map[c.name] = flask_url_for('public.company_detail_by_slug', slug=c.slug) def enrich_text_node(text): """Apply person/company linking and URL linkification to a plain text fragment.""" # 1. Auto-link URLs url_pattern = r'(https?://[^\s<>"\']+|(?"\']+|(?"\']*)' def url_replacer(m): url = m.group(0) href = url if url.startswith('http') else 'https://' + url return f'
{url}' text = re.sub(url_pattern, url_replacer, text) # 2. Link person names (pill badge — green) # First: exact matches for name, url in person_map.items(): pattern = r'\b' + re.escape(name) + r'\b' link = f'{name}' text = re.sub(pattern, link, text) # Then: fuzzy matches for Polish declensions (Iwonę → Iwona, etc.) for fuzzy_pattern, display_name, url in person_fuzzy: def fuzzy_replacer(m, _url=url, _display=display_name): return f'{m.group(0)}' text = re.sub(fuzzy_pattern, fuzzy_replacer, text) # 3. Link company names (pill badge — orange) for name, url in company_map.items(): pattern = r'\b' + re.escape(name) + r'\b' link = f'{name}' text = re.sub(pattern, link, text) return text # Split HTML into tags and text nodes, only process text outside tags # Pattern: match HTML tags (including their content for ) or text between tags result = [] pos = 0 in_a_tag = False # Regex to find HTML tags tag_pattern = re.compile(r'<(/?)(\w+)([^>]*)>') for match in tag_pattern.finditer(html): start, end = match.start(), match.end() is_closing = match.group(1) == '/' tag_name = match.group(2).lower() # Process text before this tag if start > pos: text_chunk = html[pos:start] if in_a_tag: result.append(text_chunk) # Don't modify text inside else: result.append(enrich_text_node(text_chunk)) result.append(match.group(0)) # The tag itself pos = end if tag_name == 'a': in_a_tag = not is_closing # Process remaining text after last tag if pos < len(html): text_chunk = html[pos:] if in_a_tag: result.append(text_chunk) else: result.append(enrich_text_node(text_chunk)) return Markup(''.join(result)) @bp.route('/', 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ź uprawnienia dostępu if not event.can_user_view(current_user): flash('Nie masz uprawnień do wyświetlenia tego wydarzenia.', '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() # Pobierz gości bieżącego użytkownika na to wydarzenie user_guests = db.query(EventGuest).filter( EventGuest.event_id == event_id, EventGuest.host_user_id == current_user.id ).order_by(EventGuest.created_at.asc()).all() # Find speaker as user or company speaker_user_id = None speaker_company_slug = None from database import User, Company if event.speaker_name: speaker_user = db.query(User).filter( User.name == event.speaker_name, ).first() if speaker_user: speaker_user_id = speaker_user.id else: # Try matching as company name speaker_company = db.query(Company).filter( Company.name == event.speaker_name, Company.status == 'active', ).first() if speaker_company: speaker_company_slug = speaker_company.slug # Enrich description: linkify member names, companies and URLs enriched_description = event.description or '' if enriched_description: enriched_description = _enrich_event_description(db, enriched_description) return render_template('calendar/event.html', event=event, user_attending=user_attending, user_guests=user_guests, speaker_user_id=speaker_user_id, speaker_company_slug=speaker_company_slug, enriched_description=enriched_description, ) finally: db.close() @bp.route('//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ź uprawnienia dostępu if not event.can_user_attend(current_user): return jsonify({'success': False, 'error': 'Nie masz uprawnień do zapisania się na to wydarzenie'}), 403 # Sprawdź czy już zapisany existing = db.query(EventAttendee).filter( EventAttendee.event_id == event_id, EventAttendee.user_id == current_user.id ).first() is_ext = getattr(event, 'is_external', False) or False msg_added = 'Oznaczono jako zainteresowany' if is_ext else 'Zapisano na wydarzenie' msg_removed = 'Usunięto zainteresowanie' if is_ext else 'Wypisano z wydarzenia' if existing: db.delete(existing) db.commit() return jsonify({ 'success': True, 'action': 'removed', 'message': msg_removed, 'attendee_count': event.total_attendee_count }) else: # Skip max_attendees check for external events if not is_ext and event.max_attendees and event.total_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' ) # Auto-assign payment amount for paid events if event.is_paid: from database import SystemRole is_member = current_user.has_role(SystemRole.MEMBER) attendee.payment_amount = event.price_member if is_member else event.price_guest db.add(attendee) db.commit() return jsonify({ 'success': True, 'action': 'added', 'message': msg_added, 'attendee_count': event.total_attendee_count }) finally: db.close() MAX_GUESTS_PER_USER = 5 @bp.route('//guests', methods=['POST'], endpoint='calendar_add_guest') @login_required def add_guest(event_id): """Dodaj osobę towarzyszącą na wydarzenie""" 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 if event.is_past: return jsonify({'success': False, 'error': 'Wydarzenie już się odbyło'}), 400 if not event.can_user_attend(current_user): return jsonify({'success': False, 'error': 'Nie masz uprawnień'}), 403 if getattr(event, 'is_external', False): return jsonify({'success': False, 'error': 'Rejestracja gości niedostępna dla wydarzeń zewnętrznych'}), 400 # Sprawdź limit gości per użytkownik guest_count = db.query(EventGuest).filter( EventGuest.event_id == event_id, EventGuest.host_user_id == current_user.id ).count() if guest_count >= MAX_GUESTS_PER_USER: return jsonify({'success': False, 'error': f'Maksymalnie {MAX_GUESTS_PER_USER} gości na wydarzenie'}), 400 # Sprawdź limit miejsc if event.max_attendees and event.total_attendee_count >= event.max_attendees: return jsonify({'success': False, 'error': 'Brak wolnych miejsc'}), 400 data = request.get_json() or {} first_name = (data.get('first_name') or '').strip() last_name = (data.get('last_name') or '').strip() organization = (data.get('organization') or '').strip() # Minimum jedno pole if not first_name and not last_name and not organization: return jsonify({'success': False, 'error': 'Podaj przynajmniej imię, nazwisko lub firmę'}), 400 guest_type = (data.get('guest_type') or 'external').strip() if guest_type not in ('external', 'member'): guest_type = 'external' guest = EventGuest( event_id=event_id, host_user_id=current_user.id, first_name=first_name or None, last_name=last_name or None, organization=organization or None, guest_type=guest_type, ) # Auto-assign payment amount based on guest type if event.is_paid: guest.payment_amount = event.price_member if guest_type == 'member' else event.price_guest db.add(guest) db.commit() return jsonify({ 'success': True, 'action': 'added', 'guest': { 'id': guest.id, 'first_name': guest.first_name, 'last_name': guest.last_name, 'organization': guest.organization, 'display_name': guest.display_name, } }), 201 finally: db.close() @bp.route('//guests/', methods=['PATCH'], endpoint='calendar_edit_guest') @login_required def edit_guest(event_id, guest_id): """Edytuj dane osoby towarzyszącej""" db = SessionLocal() try: guest = db.query(EventGuest).filter( EventGuest.id == guest_id, EventGuest.event_id == event_id ).first() if not guest: return jsonify({'success': False, 'error': 'Gość nie znaleziony'}), 404 # Tylko host lub admin from database import SystemRole if guest.host_user_id != current_user.id and not current_user.has_role(SystemRole.OFFICE_MANAGER): return jsonify({'success': False, 'error': 'Brak uprawnień'}), 403 event = db.query(NordaEvent).filter(NordaEvent.id == event_id).first() if event and event.is_past: return jsonify({'success': False, 'error': 'Wydarzenie już się odbyło'}), 400 data = request.get_json() or {} first_name = (data.get('first_name') or '').strip() last_name = (data.get('last_name') or '').strip() organization = (data.get('organization') or '').strip() if not first_name and not last_name and not organization: return jsonify({'success': False, 'error': 'Podaj przynajmniej imię, nazwisko lub firmę'}), 400 guest.first_name = first_name or None guest.last_name = last_name or None guest.organization = organization or None db.commit() return jsonify({ 'success': True, 'action': 'updated', 'guest': { 'id': guest.id, 'first_name': guest.first_name, 'last_name': guest.last_name, 'organization': guest.organization, 'display_name': guest.display_name, } }) finally: db.close() @bp.route('//guests/', methods=['DELETE'], endpoint='calendar_delete_guest') @login_required def delete_guest(event_id, guest_id): """Usuń osobę towarzyszącą z wydarzenia""" db = SessionLocal() try: guest = db.query(EventGuest).filter( EventGuest.id == guest_id, EventGuest.event_id == event_id ).first() if not guest: return jsonify({'success': False, 'error': 'Gość nie znaleziony'}), 404 # Tylko host lub admin from database import SystemRole if guest.host_user_id != current_user.id and not current_user.has_role(SystemRole.OFFICE_MANAGER): return jsonify({'success': False, 'error': 'Brak uprawnień'}), 403 db.delete(guest) db.commit() return jsonify({'success': True, 'action': 'removed'}) finally: db.close() @bp.route('//company-colleagues', methods=['GET'], endpoint='calendar_company_colleagues') @login_required def company_colleagues(event_id): """Pobierz listę kolegów z firmy do dropdownu przy dodawaniu gościa""" from helpers.company_context import get_active_company_id active_cid = get_active_company_id() if not active_cid: return jsonify([]) db = SessionLocal() try: from database import User, UserCompany colleagues = db.query(User).join( UserCompany, UserCompany.user_id == User.id ).filter( UserCompany.company_id == active_cid, User.id != current_user.id, User.is_active == True ).order_by(User.name).all() # Check who is already registered registered_ids = set( a.user_id for a in db.query(EventAttendee).filter(EventAttendee.event_id == event_id).all() ) # Check who is already added as guest guest_names = set() for g in db.query(EventGuest).filter(EventGuest.event_id == event_id).all(): guest_names.add(f"{g.first_name or ''} {g.last_name or ''}".strip().lower()) result = [] for c in colleagues: name_parts = (c.name or '').split(' ', 1) first = name_parts[0] if name_parts else '' last = name_parts[1] if len(name_parts) > 1 else '' already = c.id in registered_ids or c.name.lower() in guest_names if c.name else False result.append({ 'id': c.id, 'name': c.name or c.email.split('@')[0], 'first_name': first, 'last_name': last, 'already_registered': already, }) return jsonify(result) finally: db.close() @bp.route('/ical', endpoint='calendar_ical') def ical_feed(): """ iCal subscription feed — public endpoint (no login required). Returns .ics file with all upcoming public/member events. Subscribe once in Google Calendar / iOS Calendar and events sync automatically. """ db = SessionLocal() try: events = db.query(NordaEvent).filter( NordaEvent.event_date >= date.today() - timedelta(days=30), NordaEvent.access_level.in_(['public', 'members_only']) ).order_by(NordaEvent.event_date.asc()).all() lines = [ 'BEGIN:VCALENDAR', 'VERSION:2.0', 'PRODID:-//NordaBiznes//Kalendarz//PL', 'CALSCALE:GREGORIAN', 'METHOD:PUBLISH', 'X-WR-CALNAME:Norda Biznes - Wydarzenia', 'X-WR-TIMEZONE:Europe/Warsaw', 'REFRESH-INTERVAL;VALUE=DURATION:PT6H', 'X-PUBLISHED-TTL:PT6H', # VTIMEZONE for Google Calendar to recognize Europe/Warsaw 'BEGIN:VTIMEZONE', 'TZID:Europe/Warsaw', 'BEGIN:STANDARD', 'DTSTART:19701025T030000', 'RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10', 'TZOFFSETFROM:+0200', 'TZOFFSETTO:+0100', 'TZNAME:CET', 'END:STANDARD', 'BEGIN:DAYLIGHT', 'DTSTART:19700329T020000', 'RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=3', 'TZOFFSETFROM:+0100', 'TZOFFSETTO:+0200', 'TZNAME:CEST', 'END:DAYLIGHT', 'END:VTIMEZONE', ] for event in events: uid = f'event-{event.id}@nordabiznes.pl' # Build DTSTART/DTEND dtend = None if event.time_start: dt_start = datetime.combine(event.event_date, event.time_start) dtstart = f'DTSTART;TZID=Europe/Warsaw:{dt_start.strftime("%Y%m%dT%H%M%S")}' if event.time_end: dt_end = datetime.combine(event.event_date, event.time_end) dtend = f'DTEND;TZID=Europe/Warsaw:{dt_end.strftime("%Y%m%dT%H%M%S")}' # No time_end → omit DTEND (RFC 5545: duration defaults to zero) else: # All-day event dtstart = f'DTSTART;VALUE=DATE:{event.event_date.strftime("%Y%m%d")}' next_day = event.event_date + timedelta(days=1) dtend = f'DTEND;VALUE=DATE:{next_day.strftime("%Y%m%d")}' # Clean description (remove HTML, limit length) desc = (event.description or '').replace('\r\n', '\\n').replace('\n', '\\n').replace(',', '\\,').replace(';', '\\;') if len(desc) > 500: desc = desc[:497] + '...' summary = (event.title or '').replace(',', '\\,').replace(';', '\\;') location = (event.location or '').replace(',', '\\,').replace(';', '\\;') created = event.created_at.strftime('%Y%m%dT%H%M%SZ') if event.created_at else datetime.now().strftime('%Y%m%dT%H%M%SZ') lines.append('BEGIN:VEVENT') lines.append(f'UID:{uid}') lines.append(dtstart) if dtend: lines.append(dtend) lines.append(f'SUMMARY:{summary}') if location: lines.append(f'LOCATION:{location}') if desc: lines.append(f'DESCRIPTION:{desc}') if event.external_url: lines.append(f'URL:{event.external_url}') else: lines.append(f'URL:https://nordabiznes.pl/kalendarz/{event.id}') lines.append(f'ORGANIZER;CN={event.organizer_name or "Norda Biznes"}:mailto:{event.organizer_email or "biuro@norda-biznes.info"}') lines.append(f'DTSTAMP:{created}') lines.append('END:VEVENT') lines.append('END:VCALENDAR') ical_content = '\r\n'.join(lines) return Response( ical_content, mimetype='text/calendar; charset=utf-8', headers={ 'Cache-Control': 'public, max-age=3600', } ) finally: db.close()