Some checks are pending
NordaBiz Tests / Unit & Integration Tests (push) Waiting to run
NordaBiz Tests / E2E Tests (Playwright) (push) Blocked by required conditions
NordaBiz Tests / Smoke Tests (Production) (push) Blocked by required conditions
NordaBiz Tests / Send Failure Notification (push) Blocked by required conditions
External events from partner organizations (ARP, KIG, etc.) can now be added to the calendar with distinct visual treatment: - Grey badge "ZEWNĘTRZNE" and muted date box in list view - Grey color in grid view with border accent - "Jestem zainteresowany" instead of "Zapisz się" (no commitment) - Prominent "Przejdź do rejestracji" button linking to external organizer - "Zainteresowani" section instead of "Uczestnicy" - Toggle filter "Pokaż zewnętrzne" with localStorage persistence - Admin form checkbox to mark events as external New fields: is_external, external_url, external_source on NordaEvent. Migration: 086_external_events.sql Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
364 lines
13 KiB
Python
364 lines
13 KiB
Python
"""
|
|
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 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()
|
|
|
|
# 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
|
|
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)
|
|
all_upcoming = db.query(NordaEvent).filter(
|
|
NordaEvent.event_date >= 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(
|
|
NordaEvent.event_date < 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 <a> 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 '<p>' not in html and '<br' not in html and '<div' not in html and '<ul' not in html:
|
|
# Escape HTML entities first for plain text
|
|
html = str(escape(html))
|
|
# Double newline = paragraph break, single = line break
|
|
paragraphs = html.split('\n\n')
|
|
html = ''.join(f'<p>{p.replace(chr(10), "<br>")}</p>' for p in paragraphs if p.strip())
|
|
|
|
# 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<>"\']+|(?<!\w)www\.[^\s<>"\']+|(?<!\w)nordabiznes\.pl[^\s<>"\']*)'
|
|
def url_replacer(m):
|
|
url = m.group(0)
|
|
href = url if url.startswith('http') else 'https://' + url
|
|
return f'<a href="{href}" target="_blank" style="color:var(--primary);font-weight:500;">{url}</a>'
|
|
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'<a href="{url}" class="person-link" title="Zobacz profil">{name}</a>'
|
|
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'<a href="{_url}" class="person-link" title="Zobacz profil">{m.group(0)}</a>'
|
|
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'<a href="{url}" class="company-link" title="Zobacz firmę">{name}</a>'
|
|
text = re.sub(pattern, link, text)
|
|
|
|
return text
|
|
|
|
# Split HTML into tags and text nodes, only process text outside <a> tags
|
|
# Pattern: match HTML tags (including their content for <a>) 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 <a>
|
|
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('/<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ź 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()
|
|
|
|
# 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,
|
|
speaker_user_id=speaker_user_id,
|
|
speaker_company_slug=speaker_company_slug,
|
|
enriched_description=enriched_description,
|
|
)
|
|
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ź 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.attendee_count
|
|
})
|
|
else:
|
|
# Skip max_attendees check for external events
|
|
if not is_ext and 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': msg_added,
|
|
'attendee_count': event.attendee_count
|
|
})
|
|
finally:
|
|
db.close()
|