docs: implementation plan for event guests feature
7 tasks: migration, model, API endpoints, template, local test, staging deploy, production deploy. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
3a266f504e
commit
492ca1b01b
777
docs/superpowers/plans/2026-03-31-event-guests.md
Normal file
777
docs/superpowers/plans/2026-03-31-event-guests.md
Normal file
@ -0,0 +1,777 @@
|
||||
# Event Guests — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Allow portal users to register accompanying guests (non-portal users) for events.
|
||||
|
||||
**Architecture:** New `event_guests` table with FK to events and host users. Three new JSON API endpoints (POST/PATCH/DELETE) in the calendar blueprint. Inline form on the event detail page with AJAX interactions matching existing RSVP pattern.
|
||||
|
||||
**Tech Stack:** Flask, SQLAlchemy, PostgreSQL, Vanilla JS (fetch API), Jinja2
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-03-31-event-guests-design.md`
|
||||
|
||||
**Deployment:** Staging first (`10.22.68.248`), user tests, then production (`10.22.68.249`).
|
||||
|
||||
---
|
||||
|
||||
## File Map
|
||||
|
||||
| File | Action | Responsibility |
|
||||
|------|--------|----------------|
|
||||
| `database.py` | Modify (lines 2292–2305) | Add `EventGuest` model, add `total_attendee_count` property to `NordaEvent` |
|
||||
| `blueprints/community/calendar/routes.py` | Modify | Add 3 guest endpoints, update RSVP max_attendees check, pass guest data to template |
|
||||
| `templates/calendar/event.html` | Modify | Guest form, guest list, updated attendee list with guests |
|
||||
| `database/migrations/096_event_guests.sql` | Create | DDL for `event_guests` table |
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Database Migration
|
||||
|
||||
**Files:**
|
||||
- Create: `database/migrations/096_event_guests.sql`
|
||||
|
||||
- [ ] **Step 1: Create migration file**
|
||||
|
||||
```sql
|
||||
-- Migration 096: Event Guests
|
||||
-- Allows users to register accompanying guests for events
|
||||
|
||||
CREATE TABLE IF NOT EXISTS event_guests (
|
||||
id SERIAL PRIMARY KEY,
|
||||
event_id INTEGER NOT NULL REFERENCES norda_events(id) ON DELETE CASCADE,
|
||||
host_user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
first_name VARCHAR(100),
|
||||
last_name VARCHAR(100),
|
||||
organization VARCHAR(255),
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_event_guests_event_id ON event_guests(event_id);
|
||||
CREATE INDEX IF NOT EXISTS ix_event_guests_host_user_id ON event_guests(host_user_id);
|
||||
|
||||
GRANT ALL ON TABLE event_guests TO nordabiz_app;
|
||||
GRANT USAGE, SELECT ON SEQUENCE event_guests_id_seq TO nordabiz_app;
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run migration on local dev**
|
||||
|
||||
```bash
|
||||
# Docker dev DB on localhost:5433
|
||||
PGPASSWORD=nordabiz psql -h localhost -p 5433 -U nordabiz -d nordabiz -f database/migrations/096_event_guests.sql
|
||||
```
|
||||
|
||||
Expected: `CREATE TABLE`, `CREATE INDEX` x2, `GRANT` x2
|
||||
|
||||
- [ ] **Step 3: Verify table exists**
|
||||
|
||||
```bash
|
||||
PGPASSWORD=nordabiz psql -h localhost -p 5433 -U nordabiz -d nordabiz -c "\d event_guests"
|
||||
```
|
||||
|
||||
Expected: table with 7 columns (id, event_id, host_user_id, first_name, last_name, organization, created_at)
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add database/migrations/096_event_guests.sql
|
||||
git commit -m "feat(calendar): add event_guests migration 096"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: SQLAlchemy Model
|
||||
|
||||
**Files:**
|
||||
- Modify: `database.py` (after line 2305, after `EventAttendee` class)
|
||||
- Modify: `database.py` (line 2207, `NordaEvent.attendees` relationship area)
|
||||
|
||||
- [ ] **Step 1: Add `EventGuest` model after `EventAttendee` (after line 2305)**
|
||||
|
||||
Add this code after the `EventAttendee` class (after line 2305):
|
||||
|
||||
```python
|
||||
class EventGuest(Base):
|
||||
"""Osoby towarzyszące na wydarzeniach (bez konta na portalu)"""
|
||||
__tablename__ = 'event_guests'
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
event_id = Column(Integer, ForeignKey('norda_events.id', ondelete='CASCADE'), nullable=False, index=True)
|
||||
host_user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'), nullable=False, index=True)
|
||||
first_name = Column(String(100))
|
||||
last_name = Column(String(100))
|
||||
organization = Column(String(255))
|
||||
created_at = Column(DateTime, default=datetime.now, nullable=False)
|
||||
|
||||
event = relationship('NordaEvent', back_populates='guests')
|
||||
host = relationship('User')
|
||||
|
||||
@property
|
||||
def display_name(self):
|
||||
"""Nazwa wyświetlana gościa — łączy dostępne pola."""
|
||||
parts = []
|
||||
if self.first_name:
|
||||
parts.append(self.first_name)
|
||||
if self.last_name:
|
||||
parts.append(self.last_name)
|
||||
return ' '.join(parts) if parts else '(brak danych)'
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add `guests` relationship to `NordaEvent` (line ~2207, after `attendees` relationship)**
|
||||
|
||||
In `NordaEvent` class, after line 2207 (`attendees = relationship(...)`), add:
|
||||
|
||||
```python
|
||||
guests = relationship('EventGuest', back_populates='event', cascade='all, delete-orphan')
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add `total_attendee_count` property to `NordaEvent` (after `attendee_count`, line ~2211)**
|
||||
|
||||
After the existing `attendee_count` property:
|
||||
|
||||
```python
|
||||
@property
|
||||
def total_attendee_count(self):
|
||||
"""Łączna liczba uczestników + gości (do sprawdzania limitu max_attendees)."""
|
||||
return len(self.attendees) + len(self.guests)
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Verify app starts**
|
||||
|
||||
```bash
|
||||
cd /Users/maciejpi/claude/projects/active/nordabiz && python3 -c "from database import EventGuest, NordaEvent; print('OK:', EventGuest.__tablename__); e = NordaEvent(); print('total_attendee_count:', e.total_attendee_count)"
|
||||
```
|
||||
|
||||
Expected: `OK: event_guests` and `total_attendee_count: 0`
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add database.py
|
||||
git commit -m "feat(calendar): add EventGuest model and total_attendee_count property"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Guest API Endpoints
|
||||
|
||||
**Files:**
|
||||
- Modify: `blueprints/community/calendar/routes.py`
|
||||
|
||||
- [ ] **Step 1: Update import line (line 14)**
|
||||
|
||||
Change:
|
||||
```python
|
||||
from database import SessionLocal, NordaEvent, EventAttendee
|
||||
```
|
||||
To:
|
||||
```python
|
||||
from database import SessionLocal, NordaEvent, EventAttendee, EventGuest
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Update max_attendees check in RSVP (line 347)**
|
||||
|
||||
Change:
|
||||
```python
|
||||
if not is_ext and event.max_attendees and event.attendee_count >= event.max_attendees:
|
||||
```
|
||||
To:
|
||||
```python
|
||||
if not is_ext and event.max_attendees and event.total_attendee_count >= event.max_attendees:
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Pass guest data to event template (after line 275 in `event()` route)**
|
||||
|
||||
After the `user_attending` query (line 275), add:
|
||||
|
||||
```python
|
||||
# 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()
|
||||
```
|
||||
|
||||
And add `user_guests=user_guests` to the `render_template` call (line 301–307):
|
||||
|
||||
```python
|
||||
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,
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Add POST endpoint for adding guests (after RSVP route, after line 364)**
|
||||
|
||||
```python
|
||||
MAX_GUESTS_PER_USER = 5
|
||||
|
||||
|
||||
@bp.route('/<int:event_id>/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 = 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,
|
||||
)
|
||||
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()
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Add PATCH endpoint for editing guests**
|
||||
|
||||
```python
|
||||
@bp.route('/<int:event_id>/guests/<int:guest_id>', 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()
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Add DELETE endpoint for removing guests**
|
||||
|
||||
```python
|
||||
@bp.route('/<int:event_id>/guests/<int:guest_id>', 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()
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Verify syntax**
|
||||
|
||||
```bash
|
||||
python3 -m py_compile blueprints/community/calendar/routes.py && echo "OK"
|
||||
```
|
||||
|
||||
Expected: `OK`
|
||||
|
||||
- [ ] **Step 8: Commit**
|
||||
|
||||
```bash
|
||||
git add blueprints/community/calendar/routes.py
|
||||
git commit -m "feat(calendar): add guest API endpoints (POST/PATCH/DELETE)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Event Template — Guest Form and Guest List
|
||||
|
||||
**Files:**
|
||||
- Modify: `templates/calendar/event.html`
|
||||
|
||||
This task modifies three areas of `event.html`:
|
||||
1. **Guest management section** (new, after RSVP section ~line 564)
|
||||
2. **Attendee list** (modify existing, lines 584–616)
|
||||
3. **JavaScript** (add guest functions, after `toggleRSVP` ~line 679)
|
||||
|
||||
- [ ] **Step 1: Add guest management section after RSVP (after line 564, before `{% endif %}` at line 576)**
|
||||
|
||||
Insert after line 564 (closing `</div>` of rsvp-section), before `{% elif event.access_level == 'rada_only' %}` at line 566:
|
||||
|
||||
```html
|
||||
{# --- Guest management section --- #}
|
||||
{% if not event.is_external %}
|
||||
<div class="guest-section" style="margin-top: 16px;">
|
||||
<div id="user-guests-list">
|
||||
{% if user_guests %}
|
||||
<p style="margin: 0 0 8px; font-weight: 600; font-size: 0.9em; color: var(--text-secondary);">
|
||||
Twoi goście ({{ user_guests|length }}/5):
|
||||
</p>
|
||||
{% for guest in user_guests %}
|
||||
<div class="guest-item" data-guest-id="{{ guest.id }}" style="display: flex; align-items: center; gap: 8px; padding: 6px 10px; background: var(--surface); border-radius: var(--radius); margin-bottom: 4px; font-size: 0.9em;">
|
||||
<span class="guest-display" style="flex: 1;">
|
||||
{{ guest.display_name }}{% if guest.organization %} <span style="color: var(--text-secondary);">({{ guest.organization }})</span>{% endif %}
|
||||
</span>
|
||||
<button onclick="editGuest({{ guest.id }}, '{{ guest.first_name or '' }}', '{{ guest.last_name or '' }}', '{{ guest.organization or '' }}')" style="background: none; border: none; cursor: pointer; color: var(--primary); font-size: 0.85em; padding: 2px 6px;">edytuj</button>
|
||||
<button onclick="deleteGuest({{ guest.id }})" style="background: none; border: none; cursor: pointer; color: var(--error); font-size: 1.1em; padding: 2px 6px;" title="Usuń gościa">×</button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if user_guests|length < 5 %}
|
||||
<button id="add-guest-btn" onclick="toggleGuestForm()" class="btn btn-outline" style="margin-top: 8px; font-size: 0.9em;">
|
||||
+ Dodaj osobę towarzyszącą
|
||||
</button>
|
||||
{% endif %}
|
||||
|
||||
<div id="guest-form" style="display: none; margin-top: 12px; padding: 16px; background: var(--surface); border-radius: var(--radius); border: 1px solid var(--border);">
|
||||
<input type="hidden" id="guest-edit-id" value="">
|
||||
<div style="display: flex; flex-direction: column; gap: 10px;">
|
||||
<div>
|
||||
<label for="guest-first-name" style="font-size: 0.85em; color: var(--text-secondary);">Imię</label>
|
||||
<input type="text" id="guest-first-name" maxlength="100" class="form-control" style="margin-top: 2px;">
|
||||
</div>
|
||||
<div>
|
||||
<label for="guest-last-name" style="font-size: 0.85em; color: var(--text-secondary);">Nazwisko</label>
|
||||
<input type="text" id="guest-last-name" maxlength="100" class="form-control" style="margin-top: 2px;">
|
||||
</div>
|
||||
<div>
|
||||
<label for="guest-org" style="font-size: 0.85em; color: var(--text-secondary);">Firma / organizacja</label>
|
||||
<input type="text" id="guest-org" maxlength="255" class="form-control" style="margin-top: 2px;">
|
||||
</div>
|
||||
<div style="display: flex; gap: 8px;">
|
||||
<button id="guest-submit-btn" onclick="submitGuest()" class="btn btn-primary" style="font-size: 0.9em;">Dodaj</button>
|
||||
<button onclick="cancelGuestForm()" class="btn btn-outline" style="font-size: 0.9em;">Anuluj</button>
|
||||
</div>
|
||||
<p id="guest-form-error" style="display: none; color: var(--error); font-size: 0.85em; margin: 0;"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Modify attendee list to include guests (lines 584–616)**
|
||||
|
||||
Replace the entire attendees section (lines 584–616) with:
|
||||
|
||||
```html
|
||||
{% if (event.attendees or event.guests) and event.can_user_see_attendees(current_user) %}
|
||||
<div class="attendees-section">
|
||||
<h2>{{ 'Zainteresowani' if event.is_external else 'Uczestnicy' }} ({{ event.total_attendee_count }})</h2>
|
||||
<div class="attendees-list">
|
||||
{# --- Regular attendees with their guests --- #}
|
||||
{% set shown_hosts = [] %}
|
||||
{% for attendee in event.attendees|sort(attribute='user.name') %}
|
||||
<div class="attendee-badge">
|
||||
{% if attendee.user.person_id %}
|
||||
<a href="{{ url_for('public.person_detail', person_id=attendee.user.person_id) }}" class="attendee-name verified">
|
||||
{% else %}
|
||||
<a href="{{ url_for('public.user_profile', user_id=attendee.user.id) }}" class="attendee-name verified">
|
||||
{% endif %}
|
||||
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path>
|
||||
<circle cx="12" cy="7" r="4"></circle>
|
||||
</svg>
|
||||
{{ attendee.user.name or attendee.user.email.split('@')[0] }}
|
||||
</a>
|
||||
|
||||
{% if attendee.user.company %}
|
||||
<a href="{{ url_for('public.company_detail_by_slug', slug=attendee.user.company.slug) }}" class="attendee-company">
|
||||
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path d="M19 21V5a2 2 0 0 0-2-2H7a2 2 0 0 0-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1v5m-4 0h4"></path>
|
||||
</svg>
|
||||
{{ attendee.user.company.name }}
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{# Guests of this attendee #}
|
||||
{% for guest in event.guests if guest.host_user_id == attendee.user.id %}
|
||||
<div class="attendee-badge" style="margin-left: 28px; font-size: 0.9em;">
|
||||
<span class="attendee-name" style="color: var(--text-secondary);">
|
||||
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" style="width: 14px; height: 14px;">
|
||||
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path>
|
||||
<circle cx="12" cy="7" r="4"></circle>
|
||||
</svg>
|
||||
gość: {{ guest.display_name }}{% if guest.organization %} ({{ guest.organization }}){% endif %}
|
||||
</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% if shown_hosts.append(attendee.user.id) %}{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{# --- Hosts who are NOT attending but have guests --- #}
|
||||
{% for guest in event.guests %}
|
||||
{% if guest.host_user_id not in shown_hosts %}
|
||||
{% if shown_hosts.append(guest.host_user_id) %}{% endif %}
|
||||
<div class="attendee-badge" style="opacity: 0.7;">
|
||||
<span class="attendee-name" style="color: var(--text-secondary);">
|
||||
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path>
|
||||
<circle cx="12" cy="7" r="4"></circle>
|
||||
</svg>
|
||||
{{ guest.host.name or 'Użytkownik' }} <em style="font-size: 0.85em;">(nie uczestniczy)</em>
|
||||
</span>
|
||||
</div>
|
||||
{% for g in event.guests if g.host_user_id == guest.host_user_id %}
|
||||
<div class="attendee-badge" style="margin-left: 28px; font-size: 0.9em;">
|
||||
<span class="attendee-name" style="color: var(--text-secondary);">
|
||||
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" style="width: 14px; height: 14px;">
|
||||
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path>
|
||||
<circle cx="12" cy="7" r="4"></circle>
|
||||
</svg>
|
||||
gość: {{ g.display_name }}{% if g.organization %} ({{ g.organization }}){% endif %}
|
||||
</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Update attendee count in RSVP section (lines 551 and 559)**
|
||||
|
||||
Line 551 — change:
|
||||
```html
|
||||
<p class="text-muted" style="margin: 0;">{{ event.attendee_count }} osób zainteresowanych z Izby</p>
|
||||
```
|
||||
To:
|
||||
```html
|
||||
<p class="text-muted" style="margin: 0;">{{ event.total_attendee_count }} osób zainteresowanych z Izby</p>
|
||||
```
|
||||
|
||||
Line 559 — change:
|
||||
```html
|
||||
<p class="text-muted" style="margin: 0;">{{ event.attendee_count }} osób już się zapisało{% if event.max_attendees %} (limit: {{ event.max_attendees }}){% endif %}</p>
|
||||
```
|
||||
To:
|
||||
```html
|
||||
<p class="text-muted" style="margin: 0;">{{ event.total_attendee_count }} osób już się zapisało{% if event.max_attendees %} (limit: {{ event.max_attendees }}){% endif %}</p>
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Add JavaScript guest functions (after `toggleRSVP` function, in `{% block extra_js %}`)**
|
||||
|
||||
Add after the `toggleRSVP` function (after line 679):
|
||||
|
||||
```javascript
|
||||
/* --- Guest management --- */
|
||||
const MAX_GUESTS = 5;
|
||||
|
||||
function toggleGuestForm() {
|
||||
const form = document.getElementById('guest-form');
|
||||
const btn = document.getElementById('add-guest-btn');
|
||||
if (form.style.display === 'none') {
|
||||
document.getElementById('guest-edit-id').value = '';
|
||||
document.getElementById('guest-first-name').value = '';
|
||||
document.getElementById('guest-last-name').value = '';
|
||||
document.getElementById('guest-org').value = '';
|
||||
document.getElementById('guest-submit-btn').textContent = 'Dodaj';
|
||||
document.getElementById('guest-form-error').style.display = 'none';
|
||||
form.style.display = 'block';
|
||||
btn.style.display = 'none';
|
||||
document.getElementById('guest-first-name').focus();
|
||||
} else {
|
||||
cancelGuestForm();
|
||||
}
|
||||
}
|
||||
|
||||
function cancelGuestForm() {
|
||||
document.getElementById('guest-form').style.display = 'none';
|
||||
const btn = document.getElementById('add-guest-btn');
|
||||
if (btn) btn.style.display = '';
|
||||
}
|
||||
|
||||
function editGuest(guestId, firstName, lastName, org) {
|
||||
document.getElementById('guest-edit-id').value = guestId;
|
||||
document.getElementById('guest-first-name').value = firstName;
|
||||
document.getElementById('guest-last-name').value = lastName;
|
||||
document.getElementById('guest-org').value = org;
|
||||
document.getElementById('guest-submit-btn').textContent = 'Zapisz';
|
||||
document.getElementById('guest-form-error').style.display = 'none';
|
||||
document.getElementById('guest-form').style.display = 'block';
|
||||
const btn = document.getElementById('add-guest-btn');
|
||||
if (btn) btn.style.display = 'none';
|
||||
document.getElementById('guest-first-name').focus();
|
||||
}
|
||||
|
||||
async function submitGuest() {
|
||||
const editId = document.getElementById('guest-edit-id').value;
|
||||
const firstName = document.getElementById('guest-first-name').value.trim();
|
||||
const lastName = document.getElementById('guest-last-name').value.trim();
|
||||
const org = document.getElementById('guest-org').value.trim();
|
||||
const errEl = document.getElementById('guest-form-error');
|
||||
|
||||
if (!firstName && !lastName && !org) {
|
||||
errEl.textContent = 'Podaj przynajmniej imię, nazwisko lub firmę';
|
||||
errEl.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
errEl.style.display = 'none';
|
||||
|
||||
const url = editId
|
||||
? `/kalendarz/{{ event.id }}/guests/${editId}`
|
||||
: '/kalendarz/{{ event.id }}/guests';
|
||||
const method = editId ? 'PATCH' : 'POST';
|
||||
|
||||
try {
|
||||
const resp = await fetch(url, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrfToken },
|
||||
body: JSON.stringify({ first_name: firstName, last_name: lastName, organization: org })
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (data.success) {
|
||||
showToast(editId ? 'Dane gościa zaktualizowane' : 'Dodano osobę towarzyszącą', 'success');
|
||||
setTimeout(() => location.reload(), 800);
|
||||
} else {
|
||||
errEl.textContent = data.error || 'Wystąpił błąd';
|
||||
errEl.style.display = 'block';
|
||||
}
|
||||
} catch (e) {
|
||||
errEl.textContent = 'Błąd połączenia';
|
||||
errEl.style.display = 'block';
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteGuest(guestId) {
|
||||
if (typeof nordaConfirm === 'function') {
|
||||
nordaConfirm('Czy na pewno chcesz usunąć tę osobę towarzyszącą?', async () => {
|
||||
await doDeleteGuest(guestId);
|
||||
});
|
||||
} else {
|
||||
await doDeleteGuest(guestId);
|
||||
}
|
||||
}
|
||||
|
||||
async function doDeleteGuest(guestId) {
|
||||
try {
|
||||
const resp = await fetch(`/kalendarz/{{ event.id }}/guests/${guestId}`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'X-CSRFToken': csrfToken }
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (data.success) {
|
||||
showToast('Usunięto osobę towarzyszącą', 'info');
|
||||
setTimeout(() => location.reload(), 800);
|
||||
} else {
|
||||
showToast(data.error || 'Wystąpił błąd', 'error');
|
||||
}
|
||||
} catch (e) {
|
||||
showToast('Błąd połączenia', 'error');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Verify template syntax**
|
||||
|
||||
```bash
|
||||
python3 -c "
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
env = Environment(loader=FileSystemLoader('templates'))
|
||||
env.get_template('calendar/event.html')
|
||||
print('Template syntax OK')
|
||||
"
|
||||
```
|
||||
|
||||
Expected: `Template syntax OK`
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add templates/calendar/event.html
|
||||
git commit -m "feat(calendar): add guest form and updated attendee list in event template"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Local Testing
|
||||
|
||||
- [ ] **Step 1: Start local dev server**
|
||||
|
||||
```bash
|
||||
python3 app.py
|
||||
```
|
||||
|
||||
Verify app starts without errors on port 5000/5001.
|
||||
|
||||
- [ ] **Step 2: Test in browser**
|
||||
|
||||
Open a future event page (e.g., http://localhost:5001/kalendarz/7). Verify:
|
||||
1. "Dodaj osobę towarzyszącą" button is visible
|
||||
2. Clicking it opens the inline form
|
||||
3. Adding a guest with only first name works
|
||||
4. Guest appears in "Twoi goście" list
|
||||
5. Editing guest data works
|
||||
6. Deleting guest works (with confirmation dialog)
|
||||
7. Guest appears in attendee list with "gość:" prefix under host
|
||||
8. Counter shows total (attendees + guests)
|
||||
|
||||
- [ ] **Step 3: Commit all remaining changes (if any)**
|
||||
|
||||
```bash
|
||||
git add -A && git status
|
||||
# Only commit if there are changes
|
||||
git commit -m "fix(calendar): polish event guests after local testing"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Deploy to Staging
|
||||
|
||||
- [ ] **Step 1: Push to both remotes**
|
||||
|
||||
```bash
|
||||
git push origin master && git push inpi master
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Deploy to staging**
|
||||
|
||||
```bash
|
||||
ssh maciejpi@10.22.68.248 "cd /var/www/nordabiznes && sudo -u www-data git pull && sudo systemctl reload nordabiznes"
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run migration on staging**
|
||||
|
||||
```bash
|
||||
ssh maciejpi@10.22.68.248 "cd /var/www/nordabiznes && /var/www/nordabiznes/venv/bin/python3 scripts/run_migration.py database/migrations/096_event_guests.sql"
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Verify staging**
|
||||
|
||||
```bash
|
||||
curl -sI https://staging.nordabiznes.pl/health | head -3
|
||||
```
|
||||
|
||||
Expected: `HTTP/2 200`
|
||||
|
||||
- [ ] **Step 5: STOP — User tests on staging**
|
||||
|
||||
🛑 **CZEKAJ NA AKCEPTACJĘ UŻYTKOWNIKA** — user testuje na https://staging.nordabiznes.pl/kalendarz, weryfikuje dodawanie/edycję/usuwanie gości.
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Deploy to Production
|
||||
|
||||
**⚠️ Wykonaj DOPIERO po akceptacji użytkownika na staging!**
|
||||
|
||||
- [ ] **Step 1: Deploy to production**
|
||||
|
||||
```bash
|
||||
ssh maciejpi@10.22.68.249 "cd /var/www/nordabiznes && sudo -u www-data git pull"
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run migration on production**
|
||||
|
||||
```bash
|
||||
ssh maciejpi@10.22.68.249 "cd /var/www/nordabiznes && /var/www/nordabiznes/venv/bin/python3 scripts/run_migration.py database/migrations/096_event_guests.sql"
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Reload service**
|
||||
|
||||
```bash
|
||||
ssh maciejpi@10.22.68.249 "sudo systemctl reload nordabiznes"
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Verify production**
|
||||
|
||||
```bash
|
||||
curl -sI https://nordabiznes.pl/health | head -3
|
||||
```
|
||||
|
||||
Expected: `HTTP/2 200`
|
||||
|
||||
- [ ] **Step 5: Smoke test on production**
|
||||
|
||||
Open https://nordabiznes.pl/kalendarz — verify event pages load, guest section visible.
|
||||
Loading…
Reference in New Issue
Block a user