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
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:
parent
36fefda339
commit
eaac876ec2
@ -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
|
||||
|
||||
@ -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),
|
||||
|
||||
16
database.py
16
database.py
@ -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).
|
||||
|
||||
16
database/migrations/105_add_event_date_end.sql
Normal file
16
database/migrations/105_add_event_date_end.sql
Normal 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);
|
||||
@ -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">
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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 %}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user