Compare commits

..

3 commits

7 changed files with 165 additions and 92 deletions

6
.gitignore vendored
View file

@ -1 +1,5 @@
credentials.json credentials.json
.venv/
*.pyc
__pycache__/
.env

90
app.py
View file

@ -1,11 +1,47 @@
import os import os
import yaml import yaml
import gspread import gspread
from flask import Flask, render_template, request, redirect, url_for, abort import secrets
import logging
import sys
from flask import Flask, render_template, request, redirect, url_for, abort, session
from datetime import datetime from datetime import datetime
# Configure logging for Docker/Portainer
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
handlers=[logging.StreamHandler(sys.stdout)]
)
logger = logging.getLogger(__name__)
app = Flask(__name__) app = Flask(__name__)
# Fail-fast secret requirement (allow bypass for pytest testing)
secret_key = os.environ.get('SECRET_KEY')
if not secret_key:
if os.environ.get('TESTING_NO_APPEND') or 'pytest' in sys.modules:
secret_key = 'test_secret_bypassed'
else:
logger.critical("No SECRET_KEY set! Exiting. Set it in .env or Portainer Stack secrets.")
sys.exit(1)
app.secret_key = secret_key
def generate_csrf_token():
if '_csrf_token' not in session:
session['_csrf_token'] = secrets.token_hex(16)
return session['_csrf_token']
app.jinja_env.globals['csrf_token'] = generate_csrf_token
@app.before_request
def csrf_protect():
if request.method == "POST":
token = session.get('_csrf_token', None)
if not token or token != request.form.get('_csrf_token'):
abort(403)
# Load Configuration # Load Configuration
def load_config(): def load_config():
with open('events.yaml', 'r') as f: with open('events.yaml', 'r') as f:
@ -24,7 +60,7 @@ def get_google_sheet(sheet_id, tab_name=None):
# Default to first tab # Default to first tab
return sh.sheet1 return sh.sheet1
except Exception as e: except Exception as e:
print(f"Error connecting to Google Sheet (ID: {sheet_id}, Tab: {tab_name}): {e}") logger.error(f"Error connecting to Google Sheet (ID: {sheet_id}, Tab: {tab_name}): {e}")
return None return None
# NEW: Fetch and filter participants for public display # NEW: Fetch and filter participants for public display
@ -47,26 +83,26 @@ def get_public_participants(sheet_id, tab_name=None):
public_list = [] public_list = []
for row in data_rows: for row in data_rows:
# Ensure row is long enough to avoid errors # Ensure row is long enough to avoid errors
if len(row) < 17: if len(row) < 14:
continue continue
# Extract ONLY non-sensitive columns based on our save order # Extract ONLY non-sensitive columns based on our save order
# 1: Klasse, 2: Zeilnummer, 3: Bootnaam, 4: Boottype # 1: Klasse, 2: Zeilnummer, 3: Bootnaam, 4: Boottype
# 7: Naam, 10: Plaats, 16: Vereniging # 7: Naam, 8: Plaats, 13: Vereniging
entry = { entry = {
'klasse': row[1], 'klasse': row[1],
'zeilnummer': row[2], 'zeilnummer': row[2],
'bootnaam': row[3], 'bootnaam': row[3],
'boottype': row[4], 'boottype': row[4],
'naam': row[7], 'naam': row[7],
'plaats': row[10], 'plaats': row[8],
'vereniging': row[16] 'vereniging': row[13]
} }
public_list.append(entry) public_list.append(entry)
return public_list return public_list
except Exception as e: except Exception as e:
print(f"Error fetching participants: {e}") logger.error(f"Error fetching participants: {e}")
return [] return []
@app.route('/') @app.route('/')
@ -101,34 +137,44 @@ def event_form(event_slug):
event_data['sheet_id'] = sheet_id event_data['sheet_id'] = sheet_id
if request.method == 'POST': if request.method == 'POST':
# Basic Validation
required_fields = ['klasse', 'zeilnummer', 'bootnaam', 'naam', 'telefoonmobiel', 'email']
for field in required_fields:
val = request.form.get(field)
if not val or not val.strip():
participants = get_public_participants(sheet_id, tab_name)
return render_template('form.html', event=event_data, slug=event_slug, participants=participants, error="Oeps! Bepaalde verplichte velden ontbreken.")
form_data = [ form_data = [
datetime.now().strftime("%Y-%m-%d %H:%M:%S"), # 0: Timestamp datetime.now().strftime("%Y-%m-%d %H:%M:%S"), # 0: Timestamp
request.form.get('klasse'), # 1: Klasse request.form.get('klasse'), # 1: Klasse
request.form.get('zeilnummer'), # 2: Zeilnummer request.form.get('zeilnummer'), # 2: Zeilnummer
request.form.get('bootnaam'), # 3: Bootnaam request.form.get('bootnaam'), # 3: Bootnaam
request.form.get('boottype'), # 4: Boottype request.form.get('boottype'), # 4: Boottype
", ".join([k for k in ['genua', 'rolfok', 'spinaker', 'halfwinder', 'genaker', 'dacron'] if k in request.form]), # 5: Zeilvoering ", ".join([k for k in ['genua', 'rolfok', 'spinaker', 'halfwinder', 'genaker', 'alleendacron'] if k in request.form]), # 5: Zeilvoering
request.form.get('schroef'), # 6: Schroef request.form.get('schroef'), # 6: Schroef
request.form.get('naam'), # 7: Naam (Keep public) request.form.get('naam'), # 7: Naam (Keep public)
request.form.get('straat'), # 8: Straat (PRIVATE) request.form.get('plaats'), # 8: Plaats
request.form.get('postcode'), # 9: Postcode (PRIVATE) request.form.get('land', 'Nederland'), # 9: Land
request.form.get('plaats'), # 10: Plaats request.form.get('telefoonmobiel'), # 10: Mobiel (PRIVATE)
request.form.get('land'), # 11: Land request.form.get('email'), # 11: Email (PRIVATE)
request.form.get('telefoonmobiel'), # 12: Mobiel (PRIVATE) request.form.get('startlicentienummer'), # 12: Licentie
request.form.get('telefoonvast'), # 13: Vast (PRIVATE) request.form.get('vereniging'), # 13: Vereniging
request.form.get('email'), # 14: Email (PRIVATE) request.form.get('opmerkingen') # 14: Opmerkingen
request.form.get('startlicentienummer'), # 15: Licentie
request.form.get('vereniging'), # 16: Vereniging
request.form.get('buffet', '0'), # 17: Buffet
request.form.get('ontbijt', '0'), # 18: Ontbijt
request.form.get('opmerkingen') # 19: Opmerkingen
] ]
sheet = get_google_sheet(sheet_id, tab_name) sheet = get_google_sheet(sheet_id, tab_name)
if sheet: if sheet:
sheet.append_row(form_data) if not os.environ.get('TESTING_NO_APPEND'):
try:
sheet.append_row(form_data)
logger.info(f"Successfully appended registration for {form_data[7]} to {tab_name}")
except Exception as e:
logger.error(f"Failed to append row to {tab_name}: {e}")
return f"Error appending data. Try again later."
return redirect(url_for('success', event_slug=event_slug)) return redirect(url_for('success', event_slug=event_slug))
else: else:
logger.error(f"Could not connect to tab '{tab_name}' during POST.")
return f"Error: Could not connect to Google Sheet Tab '{tab_name}'. Check server logs." return f"Error: Could not connect to Google Sheet Tab '{tab_name}'. Check server logs."
# GET Request: Fetch participants to show at bottom of form # GET Request: Fetch participants to show at bottom of form

View file

@ -4,6 +4,8 @@ services:
build: . build: .
container_name: sailing_forms container_name: sailing_forms
restart: unless-stopped restart: unless-stopped
env_file:
- .env
ports: ports:
- "5000:5000" - "5000:5000"
volumes: volumes:

View file

@ -18,7 +18,14 @@
<h1 class="text-center mb-2">{{ event.title }}</h1> <h1 class="text-center mb-2">{{ event.title }}</h1>
<p class="text-center text-muted mb-4">{{ event.description }}</p> <p class="text-center text-muted mb-4">{{ event.description }}</p>
{% if error %}
<div class="alert alert-danger" role="alert">
{{ error }}
</div>
{% endif %}
<form method="POST"> <form method="POST">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<!-- Boot Info --> <!-- Boot Info -->
<h4 class="section-title">De Boot</h4> <h4 class="section-title">De Boot</h4>
<div class="row g-3 mb-3"> <div class="row g-3 mb-3">
@ -77,14 +84,6 @@
<label class="form-label">Naam</label> <label class="form-label">Naam</label>
<input type="text" class="form-control" name="naam" required> <input type="text" class="form-control" name="naam" required>
</div> </div>
<div class="col-md-8">
<label class="form-label">Straat + Huisnr</label>
<input type="text" class="form-control" name="straat">
</div>
<div class="col-md-4">
<label class="form-label">Postcode</label>
<input type="text" class="form-control" name="postcode">
</div>
<div class="col-md-6"> <div class="col-md-6">
<label class="form-label">Plaats</label> <label class="form-label">Plaats</label>
<input type="text" class="form-control" name="plaats"> <input type="text" class="form-control" name="plaats">
@ -94,13 +93,9 @@
<input type="text" class="form-control" name="land" value="Nederland"> <input type="text" class="form-control" name="land" value="Nederland">
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
<label class="form-label">Telefoon (Mobiel)</label> <label class="form-label">Telefoonnummer</label>
<input type="text" class="form-control" name="telefoonmobiel" required> <input type="text" class="form-control" name="telefoonmobiel" required>
</div> </div>
<div class="col-md-6">
<label class="form-label">Telefoon (Vast)</label>
<input type="text" class="form-control" name="telefoonvast">
</div>
<div class="col-md-6"> <div class="col-md-6">
<label class="form-label">Email</label> <label class="form-label">Email</label>
<input type="email" class="form-control" name="email" required> <input type="email" class="form-control" name="email" required>
@ -115,19 +110,6 @@
</div> </div>
</div> </div>
<!-- Eten & Drinken -->
<h4 class="section-title mt-4">Eten & Drinken</h4>
<div class="row g-3 mb-3">
<div class="col-md-6">
<label class="form-label">Aantal personen Buffet (Zaterdag)</label>
<input type="number" class="form-control" name="buffet" min="0" placeholder="0">
</div>
<div class="col-md-6">
<label class="form-label">Aantal personen Ontbijt (Zondag)</label>
<input type="number" class="form-control" name="ontbijt" min="0" placeholder="0">
</div>
</div>
<div class="mb-4"> <div class="mb-4">
<label class="form-label">Opmerkingen</label> <label class="form-label">Opmerkingen</label>
<textarea class="form-control" name="opmerkingen" rows="3"></textarea> <textarea class="form-control" name="opmerkingen" rows="3"></textarea>

View file

@ -1,43 +0,0 @@
<!DOCTYPE html>
<html lang="nl">
<head>
<meta charset="UTF-8">
<title>Deelnemerslijst</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
</head>
<body class="bg-light p-4">
<div class="container bg-white p-4 rounded shadow">
<h2 class="mb-4 text-primary">Huidige Deelnemers</h2>
<a href="/" class="btn btn-outline-secondary mb-4">&larr; Terug naar inschrijving</a>
<table class="table table-striped table-hover">
<thead class="table-dark">
<tr>
<th>Klasse</th>
<th>Zeilnummer</th>
<th>Bootnaam</th>
<th>Type</th>
<th>Schipper</th>
<th>Vereniging</th>
</tr>
</thead>
<tbody>
{% for boat in boats %}
<tr>
<td>{{ boat.klasse }}</td>
<td>{{ boat.zeilnummer }}</td>
<td>{{ boat.bootnaam }}</td>
<td>{{ boat.boottype }}</td>
<td>{{ boat.naam }}</td>
<td>{{ boat.vereniging }}</td>
</tr>
{% else %}
<tr>
<td colspan="6" class="text-center">Nog geen inschrijvingen.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</body>
</html>

45
tests/test_app.py Normal file
View file

@ -0,0 +1,45 @@
import pytest
from app import app
from unittest.mock import patch
@pytest.fixture
def client():
app.config['TESTING'] = True
with app.test_client() as client:
with client.session_transaction() as sess:
sess['_csrf_token'] = 'test-token'
yield client
def test_home_page(client):
response = client.get('/')
assert response.status_code == 200
assert b"Zomeravond" in response.data or b"Papklokken" in response.data
@patch('app.get_google_sheet')
def test_post_missing_fields(mock_get_sheet, client):
response = client.post('/zomeravond', data={'_csrf_token': 'test-token'})
assert b"Oeps! Bepaalde verplichte velden ontbreken" in response.data
mock_get_sheet.return_value.append_row.assert_not_called()
@patch('app.get_google_sheet')
@patch('app.get_public_participants')
def test_post_success(mock_participants, mock_get_sheet, client):
mock_participants.return_value = []
data = {
'_csrf_token': 'test-token',
'klasse': 'Kajuitklasse',
'zeilnummer': '123',
'bootnaam': 'TestBoat',
'naam': 'Test Name',
'telefoonmobiel': '0612345678',
'email': 'test@example.com'
}
response = client.post('/zomeravond', data=data)
assert response.status_code == 302
assert '/zomeravond/success' in response.headers.get('Location', '')
# In full testing, append_row is called unless TESTING_NO_APPEND is set.
# But since it's mocked, we can check it.
assert mock_get_sheet.return_value.append_row.called

37
tests/test_e2e.py Normal file
View file

@ -0,0 +1,37 @@
import os
import re
import pytest
from playwright.sync_api import sync_playwright, expect
def test_homepage_has_title():
with sync_playwright() as p:
browser = p.chromium.launch()
page = browser.new_page()
page.goto("http://localhost:5000/")
expect(page).to_have_title(re.compile("Zeilwedstrijden"))
browser.close()
def test_submission_flow():
with sync_playwright() as p:
browser = p.chromium.launch()
page = browser.new_page()
page.goto("http://localhost:5000/zomeravond")
# Fill required fields
page.select_option("select[name='klasse']", label="Kajuitklasse")
page.fill("input[name='zeilnummer']", "42")
page.fill("input[name='bootnaam']", "Vliegende Hollander")
page.fill("input[name='naam']", "Hendrik Test")
page.fill("input[name='telefoonmobiel']", "0612345678")
page.fill("input[name='email']", "hendrik@example.com")
# Accept terms
page.check("input#terms")
# Submit
page.click("button[type='submit']")
# Expect success redirect
expect(page).to_have_url(re.compile(r".*/zomeravond/success"))
expect(page.locator("h1")).to_have_text("Bedankt!")
browser.close()