Add iCal subscription feed for Norda Biznes events
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

New endpoint /kalendarz/ical returns .ics file with all upcoming events.
Compatible with Google Calendar, iOS Calendar, and Outlook subscription.
- Auto-refreshes every 6 hours (REFRESH-INTERVAL)
- Includes event time, location, description, organizer
- Handles all-day events (no time_start) and timed events
- "Subscribe" button on calendar page with copy-to-clipboard modal
- Instructions for iPhone, Google Calendar, and Outlook

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maciej Pienczyn 2026-03-25 15:17:34 +01:00
parent be4bbfc1fd
commit b28d3c1879
2 changed files with 137 additions and 3 deletions

View File

@ -3,11 +3,12 @@ Calendar Routes
===============
Public calendar and event registration endpoints.
Includes iCal subscription feed at /kalendarz/ical
"""
from datetime import date
from datetime import date, datetime, timedelta
import calendar as cal_module
from flask import render_template, request, redirect, url_for, flash, jsonify
from flask import render_template, request, redirect, url_for, flash, jsonify, Response
from flask_login import login_required, current_user
from . import bp
@ -361,3 +362,91 @@ def rsvp(event_id):
})
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',
]
for event in events:
uid = f'event-{event.id}@nordabiznes.pl'
# Build DTSTART/DTEND
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")}'
else:
dt_end = dt_start + timedelta(hours=2)
dtend = f'DTEND;TZID=Europe/Warsaw:{dt_end.strftime("%Y%m%dT%H%M%S")}'
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)
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={
'Content-Disposition': 'attachment; filename="norda-biznes.ics"',
'Cache-Control': 'no-cache, must-revalidate',
}
)
finally:
db.close()

View File

@ -410,7 +410,35 @@
{% block content %}
<div class="calendar-header">
<h1>Kalendarz wydarzeń</h1>
<p class="text-muted">Spotkania i wydarzenia Norda Biznes</p>
<div style="display: flex; align-items: center; gap: var(--spacing-md); flex-wrap: wrap;">
<p class="text-muted" style="margin: 0;">Spotkania i wydarzenia Norda Biznes</p>
<button onclick="showSubscribeModal()" style="display: inline-flex; align-items: center; gap: 6px; padding: 6px 14px; background: #eff6ff; color: #1d4ed8; border: 1px solid #bfdbfe; border-radius: var(--radius); font-size: 13px; cursor: pointer; font-weight: 500; white-space: nowrap;">
<svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>
Subskrybuj kalendarz
</button>
</div>
</div>
<!-- Subscribe modal -->
<div id="subscribeModal" style="display:none; position:fixed; top:0; left:0; right:0; bottom:0; background:rgba(0,0,0,0.5); z-index:9999; align-items:center; justify-content:center;">
<div style="background:white; border-radius:var(--radius-xl); padding:24px; max-width:500px; width:90%; margin:auto; position:relative; top:50%; transform:translateY(-50%);">
<button onclick="document.getElementById('subscribeModal').style.display='none'" style="position:absolute; top:12px; right:16px; background:none; border:none; font-size:20px; cursor:pointer; color:#64748b;">&times;</button>
<h3 style="margin:0 0 8px 0; font-size:18px;">Dodaj wydarzenia do kalendarza</h3>
<p style="color:#64748b; font-size:13px; line-height:1.5; margin-bottom:16px;">
Skopiuj poniższy link i dodaj go jako subskrypcję w swoim kalendarzu.
Wydarzenia Izby będą się automatycznie synchronizować.
</p>
<div style="display:flex; gap:8px; margin-bottom:16px;">
<input id="icalUrl" readonly value="https://nordabiznes.pl/kalendarz/ical" style="flex:1; padding:8px 12px; border:1px solid #d1d5db; border-radius:var(--radius); font-size:13px; background:#f9fafb; font-family:monospace;">
<button onclick="copyIcalUrl()" id="copyBtn" style="padding:8px 16px; background:#1d4ed8; color:white; border:none; border-radius:var(--radius); font-size:13px; cursor:pointer; font-weight:500; white-space:nowrap;">Kopiuj</button>
</div>
<div style="font-size:12px; color:#64748b; line-height:1.6;">
<strong>Jak dodać:</strong><br>
<strong>iPhone/Mac:</strong> Ustawienia → Konta → Dodaj konto → Inne → Subskrypcja kalendarza → wklej link<br>
<strong>Google Calendar:</strong> Inne kalendarze (+) → Z adresu URL → wklej link<br>
<strong>Outlook:</strong> Dodaj kalendarz → Subskrybuj z internetu → wklej link
</div>
</div>
</div>
<!-- Toolbar z przyciskami widoku -->
@ -664,4 +692,21 @@ async function toggleListRSVP(btn) {
cb.addEventListener('change', applyFilter);
applyFilter();
})();
function showSubscribeModal() {
document.getElementById('subscribeModal').style.display = 'flex';
}
function copyIcalUrl() {
var input = document.getElementById('icalUrl');
input.select();
document.execCommand('copy');
var btn = document.getElementById('copyBtn');
btn.textContent = 'Skopiowano!';
setTimeout(function() { btn.textContent = 'Kopiuj'; }, 2000);
}
document.getElementById('subscribeModal').addEventListener('click', function(e) {
if (e.target === this) this.style.display = 'none';
});
{% endblock %}