feat(calendar): multi-day events + **bold** w opisach wydarzeń
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

- norda_events: kolumna event_date_end (NULLABLE, check constraint >= event_date)
- NordaEvent: property is_multi_day, date_range_display; is_past uwzględnia koniec
- Admin (new/edit): pole "Data zakończenia" w formularzu
- Calendar grid: wydarzenie wielodniowe wyświetla się na każdym dniu zakresu
- Upcoming/past filter: używa COALESCE(end, date) — 2-dniowe zostaje w Upcoming
  do swojego ostatniego dnia
- event.html: "Termin" + zakres dla wielodniowych; ICS/Google end date z dateEnd
- Lekki markdown dla opisów: tylko **bold** → <strong> (audyt: tylko event #60)

Zero wpływu na 42 istniejące wydarzenia (NULL == stare zachowanie).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maciej Pienczyn 2026-04-15 17:52:31 +02:00
parent 36fefda339
commit eaac876ec2
7 changed files with 92 additions and 15 deletions

View File

@ -1447,10 +1447,16 @@ def admin_calendar_new():
is_external = request.form.get('is_external') == 'on'
is_paid = request.form.get('is_paid') == 'on'
event_date_end_raw = request.form.get('event_date_end', '').strip()
event_date_end = datetime.strptime(event_date_end_raw, '%Y-%m-%d').date() if event_date_end_raw else None
event_date_start = datetime.strptime(request.form.get('event_date'), '%Y-%m-%d').date()
if event_date_end and event_date_end < event_date_start:
event_date_end = None # nie akceptuj odwróconego zakresu
event = NordaEvent(
title=request.form.get('title', '').strip(),
description=sanitize_html(request.form.get('description', '').strip()),
event_date=datetime.strptime(request.form.get('event_date'), '%Y-%m-%d').date(),
event_date=event_date_start,
event_date_end=event_date_end,
time_start=request.form.get('time_start') or None,
time_end=request.form.get('time_end') or None,
location=request.form.get('location', '').strip() or None,
@ -1526,6 +1532,9 @@ def admin_calendar_edit(event_id):
event.title = request.form.get('title', '').strip()
event.description = sanitize_html(request.form.get('description', '').strip())
event.event_date = datetime.strptime(request.form.get('event_date'), '%Y-%m-%d').date()
_end_raw = request.form.get('event_date_end', '').strip()
_end = datetime.strptime(_end_raw, '%Y-%m-%d').date() if _end_raw else None
event.event_date_end = _end if (_end and _end >= event.event_date) else None
event.time_start = request.form.get('time_start') or None
event.time_end = request.form.get('time_end') or None
event.location = request.form.get('location', '').strip() or None

View File

@ -72,6 +72,17 @@ def index():
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)]
@ -79,23 +90,34 @@ def index():
cal = cal_module.Calendar(firstweekday=0)
month_days = cal.monthdayscalendar(year, month)
# Mapuj wydarzenia na dni
# 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:
day = event.event_date.day
if day not in events_by_day:
events_by_day[day] = []
events_by_day[day].append(event)
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(
NordaEvent.event_date >= today
_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(
NordaEvent.event_date < today
_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]
@ -139,6 +161,13 @@ def _enrich_event_description(db, html):
paragraphs = html.split('\n\n')
html = ''.join(f'<p>{p.replace(chr(10), "<br>")}</p>' for p in paragraphs if p.strip())
# Lekki markdown: **bold** → <strong>bold</strong>.
# 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'<strong>\1</strong>', html)
# Build replacement maps — persons (all users with a name)
members = db.query(User.id, User.name).filter(
User.name.isnot(None),

View File

@ -2241,6 +2241,7 @@ class NordaEvent(Base):
# Data i czas
event_date = Column(Date, nullable=False)
event_date_end = Column(Date) # NULL dla wydarzeń jednodniowych
time_start = Column(Time)
time_end = Column(Time)
@ -2306,7 +2307,20 @@ class NordaEvent(Base):
@property
def is_past(self):
from datetime import date
return self.event_date < date.today()
end = self.event_date_end or self.event_date
return end < date.today()
@property
def is_multi_day(self):
return bool(self.event_date_end and self.event_date_end > self.event_date)
@property
def date_range_display(self):
"""Zwraca 'DD.MM.YYYY' lub 'DD.MM.YYYY DD.MM.YYYY' dla wielodniowych."""
start = self.event_date.strftime('%d.%m.%Y')
if self.is_multi_day:
return f"{start} {self.event_date_end.strftime('%d.%m.%Y')}"
return start
def can_user_view(self, user) -> bool:
"""Check if a user can view this event (title, date, location).

View File

@ -0,0 +1,16 @@
-- Migration 105: Add event_date_end for multi-day events
-- Dodaje opcjonalną datę zakończenia dla wydarzeń wielodniowych (np. targi 19-20.06).
ALTER TABLE norda_events
ADD COLUMN IF NOT EXISTS event_date_end DATE;
COMMENT ON COLUMN norda_events.event_date_end IS
'Data zakończenia wydarzenia (NULL = jednodniowe). Musi być >= event_date.';
-- Prosty check constraint: jeśli ustawione, musi być >= event_date
ALTER TABLE norda_events
DROP CONSTRAINT IF EXISTS norda_events_date_range_check;
ALTER TABLE norda_events
ADD CONSTRAINT norda_events_date_range_check
CHECK (event_date_end IS NULL OR event_date_end >= event_date);

View File

@ -174,11 +174,17 @@
</div>
<div class="form-group">
<label for="event_date">Data *</label>
<label for="event_date">Data rozpoczęcia *</label>
<input type="date" id="event_date" name="event_date" required value="{{ event.event_date.strftime('%Y-%m-%d') if event else '' }}">
</div>
</div>
<div class="form-group">
<label for="event_date_end">Data zakończenia (opcjonalna — tylko dla wielodniowych)</label>
<input type="date" id="event_date_end" name="event_date_end" value="{{ event.event_date_end.strftime('%Y-%m-%d') if event and event.event_date_end else '' }}">
<div class="form-hint">Pozostaw puste dla wydarzeń jednodniowych. Jeśli wydarzenie trwa 2+ dni (np. targi, konferencja), podaj ostatni dzień.</div>
</div>
<div class="form-group">
<label for="access_level">Poziom dostępu *</label>
<select id="access_level" name="access_level">

View File

@ -451,8 +451,8 @@
<line x1="3" y1="10" x2="21" y2="10"></line>
</svg>
<div>
<div class="info-label">Data</div>
<div class="info-value">{{ event.event_date.strftime('%d.%m.%Y') }}</div>
<div class="info-label">{% if event.is_multi_day %}Termin{% else %}Data{% endif %}</div>
<div class="info-value">{{ event.date_range_display }}</div>
</div>
</div>
@ -1030,6 +1030,7 @@ function foldIcsLine(line) {
const _evt = {
title: {{ event.title|tojson }},
date: '{{ event.event_date.strftime("%Y%m%d") }}',
dateEnd: '{{ event.event_date_end.strftime("%Y%m%d") if event.event_date_end else "" }}',
start: '{{ event.time_start.strftime("%H%M") if event.time_start else "0000" }}',
end: '{{ event.time_end.strftime("%H%M") if event.time_end else "" }}',
location: {{ (event.location or '')|tojson }},
@ -1042,7 +1043,8 @@ const _evt = {
function addToGoogleCalendar() {
const start = _evt.date + 'T' + _evt.start + '00';
const end = _evt.end ? (_evt.date + 'T' + _evt.end + '00') : '';
const endDate = _evt.dateEnd || _evt.date;
const end = _evt.end ? (endDate + 'T' + _evt.end + '00') : '';
const details = [
_evt.speaker ? 'Prowadzący: ' + _evt.speaker : '',
_evt.description,
@ -1062,7 +1064,8 @@ function addToGoogleCalendar() {
function downloadICS() {
const start = _evt.date + 'T' + _evt.start + '00';
const end = _evt.end ? (_evt.date + 'T' + _evt.end + '00') : (_evt.date + 'T' + _evt.start + '00');
const endDate = _evt.dateEnd || _evt.date;
const end = _evt.end ? (endDate + 'T' + _evt.end + '00') : (endDate + 'T' + _evt.start + '00');
const uid = 'nordabiz-event-{{ event.id }}@nordabiznes.pl';
const now = new Date().toISOString().replace(/[-:]/g,'').split('.')[0] + 'Z';
const plainDesc = stripHtml(_evt.description);

View File

@ -892,7 +892,7 @@
{% endif %}
</div>
<div class="card-banner-meta">
<span>📆 {{ event.event_date.strftime('%d.%m.%Y') }} ({{ ['Pon', 'Wt', 'Śr', 'Czw', 'Pt', 'Sob', 'Nd'][event.event_date.weekday()] }})</span>
<span>📆 {{ event.date_range_display }}{% if not event.is_multi_day %} ({{ ['Pon', 'Wt', 'Śr', 'Czw', 'Pt', 'Sob', 'Nd'][event.event_date.weekday()] }}){% endif %}</span>
{% if event.time_start %}
<span>🕕 {{ event.time_start.strftime('%H:%M') }}</span>
{% endif %}