960 lines
38 KiB
Python
960 lines
38 KiB
Python
from flask import Flask, render_template, redirect, url_for, request, flash, session
|
|
from flask_sqlalchemy import SQLAlchemy
|
|
from flask_login import LoginManager, login_user, login_required, logout_user, current_user, UserMixin, AnonymousUserMixin
|
|
import os
|
|
from werkzeug.security import generate_password_hash, check_password_hash
|
|
from flask import send_from_directory
|
|
from werkzeug.utils import secure_filename
|
|
from flask_migrate import Migrate
|
|
import uuid
|
|
import smtplib
|
|
import ssl
|
|
from email.message import EmailMessage
|
|
from itsdangerous import URLSafeTimedSerializer, BadSignature, SignatureExpired
|
|
from sqlalchemy.exc import OperationalError
|
|
|
|
UPLOAD_FOLDER = 'static/uploads'
|
|
ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif'}
|
|
|
|
app = Flask(__name__)
|
|
app.config['SECRET_KEY'] = os.environ.get('SECRET_KEY', 'your_secret_key_here')
|
|
|
|
# Prefer an env override, otherwise store the SQLite DB in Flask's `instance/` folder.
|
|
# This keeps the DB out of the repo root and makes Docker persistence easier.
|
|
default_db_path = os.path.join(app.instance_path, 'liturgie.db')
|
|
app.config['SQLALCHEMY_DATABASE_URI'] = os.environ.get(
|
|
'SQLALCHEMY_DATABASE_URI',
|
|
f"sqlite:///{default_db_path}",
|
|
)
|
|
|
|
# Ensure the instance folder exists so SQLite can create/open the DB file.
|
|
os.makedirs(app.instance_path, exist_ok=True)
|
|
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
|
|
app.config['UPLOAD_FOLDER'] = UPLOAD_FOLDER
|
|
os.makedirs(UPLOAD_FOLDER, exist_ok=True)
|
|
|
|
db = SQLAlchemy(app)
|
|
login_manager = LoginManager(app)
|
|
login_manager.login_view = 'login'
|
|
migrate = Migrate(app, db)
|
|
|
|
def _get_serializer() -> URLSafeTimedSerializer:
|
|
# Uses Flask secret key; rotating SECRET_KEY invalidates outstanding reset links.
|
|
return URLSafeTimedSerializer(app.config['SECRET_KEY'])
|
|
|
|
|
|
def _current_year() -> int:
|
|
from datetime import datetime
|
|
return datetime.now().year
|
|
|
|
|
|
@app.context_processor
|
|
def inject_current_year():
|
|
return {"current_year": _current_year()}
|
|
|
|
# Models
|
|
class Church(db.Model):
|
|
id = db.Column(db.Integer, primary_key=True)
|
|
name = db.Column(db.String(150), unique=True, nullable=False)
|
|
is_active = db.Column(db.Boolean, default=False, nullable=False) # New field for activation state
|
|
contact_email = db.Column(db.String(150), default='')
|
|
contact_phone = db.Column(db.String(50), default='')
|
|
contact_address = db.Column(db.String(200), default='')
|
|
logo_filename = db.Column(db.String(300), nullable=True) # New field for logo filename
|
|
users = db.relationship('User', backref='church', lazy=True)
|
|
boards = db.relationship('Liturgiebord', backref='church', lazy=True)
|
|
|
|
class User(UserMixin, db.Model):
|
|
id = db.Column(db.Integer, primary_key=True)
|
|
username = db.Column(db.String(150), unique=True, nullable=False)
|
|
password = db.Column(db.String(150), nullable=False)
|
|
church_id = db.Column(db.Integer, db.ForeignKey('church.id'), nullable=False)
|
|
is_admin = db.Column(db.Boolean, default=False)
|
|
|
|
|
|
class SMTPSettings(db.Model):
|
|
"""Global SMTP settings editable by admin.
|
|
|
|
For now we keep it simple: a single-row table (id=1) that stores the current
|
|
outbound email settings.
|
|
"""
|
|
|
|
id = db.Column(db.Integer, primary_key=True)
|
|
host = db.Column(db.String(255), default="")
|
|
port = db.Column(db.Integer, default=587)
|
|
username = db.Column(db.String(255), default="")
|
|
password = db.Column(db.String(255), default="") # stored as plain text; consider encrypting at rest later
|
|
use_tls = db.Column(db.Boolean, default=True)
|
|
use_ssl = db.Column(db.Boolean, default=False)
|
|
verify_tls = db.Column(db.Boolean, default=True)
|
|
from_email = db.Column(db.String(255), default="")
|
|
from_name = db.Column(db.String(255), default="PsalmbordOnline")
|
|
|
|
|
|
def _ensure_smtp_settings_schema() -> None:
|
|
"""Best-effort schema sync for sqlite in dev.
|
|
|
|
This project uses SQLite and sometimes runs without applying Alembic
|
|
migrations (e.g. `flask run`). When new columns are added, older DB files
|
|
can crash on SELECT with "no such column".
|
|
|
|
We keep this narrowly-scoped to the SMTP settings table.
|
|
"""
|
|
from sqlalchemy import text
|
|
|
|
# Ensure tables exist first (idempotent)
|
|
db.create_all()
|
|
|
|
# PRAGMA returns empty result if the table doesn't exist.
|
|
cols = [row[1] for row in db.session.execute(text("PRAGMA table_info(smtp_settings)"))]
|
|
if not cols:
|
|
db.create_all()
|
|
cols = [row[1] for row in db.session.execute(text("PRAGMA table_info(smtp_settings)"))]
|
|
|
|
# Add missing columns (idempotent guarded by PRAGMA)
|
|
if "verify_tls" not in cols:
|
|
db.session.execute(text("ALTER TABLE smtp_settings ADD COLUMN verify_tls BOOLEAN DEFAULT 1"))
|
|
db.session.commit()
|
|
|
|
|
|
def get_smtp_settings() -> SMTPSettings:
|
|
"""Fetch singleton SMTP settings row.
|
|
|
|
When running via `flask run`, the container init script (`init_db.py`) is not
|
|
executed. If the database already exists but the `smtp_settings` table was
|
|
added later, this can raise `OperationalError: no such table`.
|
|
|
|
We recover by calling `db.create_all()` (safe/ idempotent for SQLite) and
|
|
retrying.
|
|
"""
|
|
# Make sure schema is up-to-date before ORM SELECTs columns.
|
|
try:
|
|
_ensure_smtp_settings_schema()
|
|
except Exception:
|
|
db.session.rollback()
|
|
|
|
try:
|
|
settings = SMTPSettings.query.get(1)
|
|
except OperationalError:
|
|
# Handles cases where the DB is older/newer than the model.
|
|
db.session.rollback()
|
|
try:
|
|
_ensure_smtp_settings_schema()
|
|
except Exception:
|
|
db.session.rollback()
|
|
settings = SMTPSettings.query.get(1)
|
|
|
|
if not settings:
|
|
settings = SMTPSettings(id=1)
|
|
db.session.add(settings)
|
|
db.session.commit()
|
|
return settings
|
|
|
|
|
|
def send_email(to_email: str, subject: str, body_text: str) -> tuple[bool, str]:
|
|
"""Send an email using configured SMTP settings.
|
|
|
|
Returns (ok, message) where message is safe to show in UI/log.
|
|
"""
|
|
settings = get_smtp_settings()
|
|
|
|
if not settings.host or not settings.port or not settings.from_email:
|
|
return False, "SMTP is niet geconfigureerd. Neem contact op met de beheerder."
|
|
|
|
msg = EmailMessage()
|
|
msg["Subject"] = subject
|
|
msg["From"] = f"{settings.from_name} <{settings.from_email}>" if settings.from_name else settings.from_email
|
|
msg["To"] = to_email
|
|
msg.set_content(body_text)
|
|
|
|
try:
|
|
if settings.use_ssl:
|
|
context = ssl.create_default_context()
|
|
if not settings.verify_tls:
|
|
context.check_hostname = False
|
|
context.verify_mode = ssl.CERT_NONE
|
|
with smtplib.SMTP_SSL(settings.host, int(settings.port), context=context, timeout=20) as server:
|
|
if settings.username:
|
|
server.login(settings.username, settings.password or "")
|
|
server.send_message(msg)
|
|
else:
|
|
with smtplib.SMTP(settings.host, int(settings.port), timeout=20) as server:
|
|
server.ehlo()
|
|
if settings.use_tls:
|
|
context = ssl.create_default_context()
|
|
if not settings.verify_tls:
|
|
context.check_hostname = False
|
|
context.verify_mode = ssl.CERT_NONE
|
|
server.starttls(context=context)
|
|
server.ehlo()
|
|
if settings.username:
|
|
server.login(settings.username, settings.password or "")
|
|
server.send_message(msg)
|
|
return True, "Email verstuurd."
|
|
except Exception as e:
|
|
# Avoid leaking credentials; show generic message.
|
|
app.logger.exception("Failed to send email")
|
|
return False, f"Email versturen mislukt: {type(e).__name__}"
|
|
|
|
# Association table for many-to-many relationship
|
|
board_schedule_association = db.Table('board_schedule',
|
|
db.Column('board_id', db.Integer, db.ForeignKey('liturgiebord.id'), primary_key=True),
|
|
db.Column('schedule_id', db.Integer, db.ForeignKey('schedule.id'), primary_key=True)
|
|
)
|
|
|
|
class Liturgiebord(db.Model):
|
|
id = db.Column(db.Integer, primary_key=True)
|
|
unique_id = db.Column(db.String(32), unique=True, nullable=False, default=lambda: uuid.uuid4().hex)
|
|
name = db.Column(db.String(150), nullable=False)
|
|
church_id = db.Column(db.Integer, db.ForeignKey('church.id'), nullable=False)
|
|
line1 = db.Column(db.String(200), default='')
|
|
line2 = db.Column(db.String(200), default='')
|
|
line3 = db.Column(db.String(200), default='')
|
|
line4 = db.Column(db.String(200), default='')
|
|
line5 = db.Column(db.String(200), default='')
|
|
line6 = db.Column(db.String(200), default='')
|
|
line7 = db.Column(db.String(200), default='')
|
|
line8 = db.Column(db.String(200), default='')
|
|
line9 = db.Column(db.String(200), default='')
|
|
line10 = db.Column(db.String(200), default='')
|
|
background_image = db.Column(db.String(300), default=None)
|
|
# Updated relationship: many-to-many
|
|
schedules = db.relationship('Schedule', secondary=board_schedule_association, back_populates='boards', lazy='dynamic')
|
|
|
|
class Schedule(db.Model):
|
|
id = db.Column(db.Integer, primary_key=True)
|
|
# Removed board_id to make schedules global
|
|
name = db.Column(db.String(150), nullable=False)
|
|
start_time = db.Column(db.Time, nullable=False)
|
|
end_time = db.Column(db.Time, nullable=False)
|
|
date = db.Column(db.Date, nullable=False)
|
|
content = db.Column(db.Text, nullable=False) # separate lines with \n
|
|
# Many-to-many relationship back to boards
|
|
boards = db.relationship('Liturgiebord', secondary=board_schedule_association, back_populates='schedules', lazy='dynamic')
|
|
|
|
@login_manager.user_loader
|
|
def load_user(user_id):
|
|
return User.query.get(int(user_id))
|
|
|
|
from flask_login import current_user
|
|
|
|
@app.route('/')
|
|
def index():
|
|
if current_user.is_authenticated:
|
|
return redirect(url_for('portal'))
|
|
return render_template('index.html')
|
|
|
|
# --- Church Settings Route ---
|
|
from werkzeug.security import generate_password_hash
|
|
from werkzeug.utils import secure_filename
|
|
|
|
@app.route('/church/settings', methods=['GET', 'POST'])
|
|
@login_required
|
|
def church_settings():
|
|
church = current_user.church
|
|
msg = None
|
|
if request.method == 'POST' and request.form.get('form_type') == 'contact':
|
|
church.name = request.form.get('name', '').strip()
|
|
church.contact_email = request.form.get('contact_email', '').strip()
|
|
church.contact_phone = request.form.get('contact_phone', '').strip()
|
|
church.contact_address = request.form.get('contact_address', '').strip()
|
|
|
|
# Handle logo upload
|
|
if 'logo' in request.files:
|
|
logo = request.files['logo']
|
|
if logo and allowed_file(logo.filename):
|
|
# Remove old logo file if exists
|
|
if church.logo_filename:
|
|
old_filepath = os.path.join(app.config['UPLOAD_FOLDER'], church.logo_filename)
|
|
if os.path.exists(old_filepath):
|
|
os.remove(old_filepath)
|
|
|
|
# Create a unique filename
|
|
import time
|
|
filename = secure_filename(f'church_{church.id}_logo_{int(time.time())}_{logo.filename}')
|
|
filepath = os.path.join(app.config['UPLOAD_FOLDER'], filename)
|
|
logo.save(filepath)
|
|
church.logo_filename = filename
|
|
|
|
db.session.commit()
|
|
msg = 'Kerkgegevens opgeslagen!'
|
|
elif request.method == 'POST' and request.form.get('form_type') == 'user':
|
|
username = request.form.get('username', '').strip()
|
|
password = request.form.get('password', '').strip()
|
|
if not username or not password:
|
|
msg = 'Gebruikersnaam en wachtwoord zijn verplicht.'
|
|
elif User.query.filter_by(username=username).first():
|
|
msg = 'Gebruikersnaam bestaat al.'
|
|
else:
|
|
user = User(username=username, password=generate_password_hash(password), church_id=church.id)
|
|
db.session.add(user)
|
|
db.session.commit()
|
|
msg = f'Gebruiker {username} toegevoegd aan deze kerk.'
|
|
users = church.users
|
|
return render_template('church_settings.html', church=church, users=users, msg=msg)
|
|
# Placeholder routes for login, register, church portal, board management, and display
|
|
@app.route('/login', methods=['GET', 'POST'])
|
|
def login():
|
|
if current_user.is_authenticated:
|
|
return redirect(url_for('portal'))
|
|
if request.method == 'POST':
|
|
username = request.form['username']
|
|
password = request.form['password']
|
|
user = User.query.filter_by(username=username).first()
|
|
if user and check_password_hash(user.password, password):
|
|
login_user(user)
|
|
return redirect(url_for('portal'))
|
|
else:
|
|
flash('Ongeldige gebruikersnaam of wachtwoord')
|
|
return render_template('login.html')
|
|
|
|
|
|
def _make_reset_token(user: User) -> str:
|
|
s = _get_serializer()
|
|
return s.dumps({"user_id": user.id, "pw": user.password}, salt="password-reset")
|
|
|
|
|
|
def _load_user_from_reset_token(token: str, max_age_seconds: int = 3600) -> User | None:
|
|
s = _get_serializer()
|
|
try:
|
|
data = s.loads(token, salt="password-reset", max_age=max_age_seconds)
|
|
user_id = int(data.get("user_id"))
|
|
pw_hash = data.get("pw")
|
|
except (BadSignature, SignatureExpired, ValueError, TypeError):
|
|
return None
|
|
|
|
user = User.query.get(user_id)
|
|
if not user:
|
|
return None
|
|
# Invalidate token if password changed since token issuance.
|
|
if pw_hash != user.password:
|
|
return None
|
|
return user
|
|
|
|
|
|
@app.route('/password/forgot', methods=['GET', 'POST'])
|
|
def forgot_password():
|
|
if current_user.is_authenticated:
|
|
return redirect(url_for('portal'))
|
|
|
|
if request.method == 'POST':
|
|
email = request.form.get('email', '').strip().lower()
|
|
user = User.query.filter_by(username=email).first() if email else None
|
|
|
|
# Always show generic message to prevent account enumeration.
|
|
flash('Als dit e-mailadres bekend is, ontvang je een link om je wachtwoord te resetten.')
|
|
|
|
if user:
|
|
token = _make_reset_token(user)
|
|
reset_url = url_for('reset_password', token=token, _external=True)
|
|
body = (
|
|
"Je hebt een wachtwoord-reset aangevraagd voor PsalmbordOnline.\n\n"
|
|
f"Klik op deze link om een nieuw wachtwoord in te stellen (geldig voor 1 uur):\n{reset_url}\n\n"
|
|
"Als jij dit niet was, kun je deze email negeren.\n"
|
|
)
|
|
send_email(to_email=user.username, subject='Wachtwoord reset', body_text=body)
|
|
|
|
return redirect(url_for('login'))
|
|
|
|
return render_template('forgot_password.html')
|
|
|
|
|
|
@app.route('/password/reset/<token>', methods=['GET', 'POST'])
|
|
def reset_password(token):
|
|
if current_user.is_authenticated:
|
|
return redirect(url_for('portal'))
|
|
|
|
user = _load_user_from_reset_token(token)
|
|
if not user:
|
|
flash('Deze wachtwoord-reset link is ongeldig of verlopen.')
|
|
return redirect(url_for('forgot_password'))
|
|
|
|
if request.method == 'POST':
|
|
new_password = request.form.get('new_password', '')
|
|
confirm_password = request.form.get('confirm_password', '')
|
|
|
|
if not new_password or not confirm_password:
|
|
flash('Vul beide wachtwoordvelden in.')
|
|
elif new_password != confirm_password:
|
|
flash('De wachtwoorden komen niet overeen.')
|
|
else:
|
|
user.password = generate_password_hash(new_password)
|
|
db.session.commit()
|
|
flash('Wachtwoord gewijzigd. Je kunt nu inloggen.')
|
|
return redirect(url_for('login'))
|
|
|
|
return render_template('reset_password.html', token=token)
|
|
|
|
|
|
@app.route('/church/delete_user/<int:user_id>', methods=['POST'])
|
|
@login_required
|
|
def church_delete_user(user_id):
|
|
user_to_delete = User.query.get_or_404(user_id)
|
|
# Confirm user belongs to the same church
|
|
if user_to_delete.church_id != current_user.church_id:
|
|
flash('Geen toegang tot deze gebruiker')
|
|
return redirect(url_for('church_settings'))
|
|
# Disallow deleting self
|
|
if user_to_delete.id == current_user.id:
|
|
flash('Je kunt jezelf niet verwijderen')
|
|
return redirect(url_for('church_settings'))
|
|
# Disallow deleting admin users
|
|
if user_to_delete.is_admin:
|
|
flash('Kan admin-gebruiker niet verwijderen')
|
|
return redirect(url_for('church_settings'))
|
|
db.session.delete(user_to_delete)
|
|
db.session.commit()
|
|
flash(f'Gebruiker {user_to_delete.username} verwijderd!')
|
|
return redirect(url_for('church_settings'))
|
|
|
|
@app.route('/logout')
|
|
@login_required
|
|
def logout():
|
|
session.pop('impersonate_id', None)
|
|
session.pop('admin_id', None)
|
|
logout_user()
|
|
return redirect(url_for('login'))
|
|
|
|
@app.route('/register', methods=['GET', 'POST'])
|
|
def register():
|
|
import re
|
|
if current_user.is_authenticated:
|
|
return redirect(url_for('portal'))
|
|
if request.method == 'POST':
|
|
email = request.form['username'].strip()
|
|
password = request.form['password']
|
|
church_name = request.form['church'].strip()
|
|
# Validate basic email format
|
|
if not re.match(r"[^@]+@[^@]+\.[^@]+", email):
|
|
flash('Voer een geldig e-mailadres in')
|
|
return render_template('register.html')
|
|
if User.query.filter_by(username=email).first():
|
|
flash('Emailadres bestaat al')
|
|
return render_template('register.html')
|
|
church = Church.query.filter_by(name=church_name).first()
|
|
if church:
|
|
# Prevent registering to an existing church to avoid security risk
|
|
flash('Het church name bestaat al. Maak een unieke church aan.')
|
|
return render_template('register.html')
|
|
else:
|
|
church = Church(name=church_name, is_active=False) # New churches start inactive
|
|
db.session.add(church)
|
|
db.session.commit()
|
|
|
|
hashed_pw = generate_password_hash(password)
|
|
user = User(username=email, password=hashed_pw, church_id=church.id)
|
|
db.session.add(user)
|
|
db.session.commit()
|
|
flash('Registratie gelukt! Je kunt nu inloggen.')
|
|
return redirect(url_for('login'))
|
|
return render_template('register.html')
|
|
|
|
@app.route('/portal')
|
|
@login_required
|
|
def portal():
|
|
boards = Liturgiebord.query.filter_by(church_id=current_user.church_id).all()
|
|
return render_template('portal.html', boards=boards)
|
|
|
|
@app.route('/board/add', methods=['GET', 'POST'])
|
|
@login_required
|
|
def add_board():
|
|
if request.method == 'POST':
|
|
name = request.form.get('name')
|
|
board = Liturgiebord(name=name, church_id=current_user.church_id)
|
|
db.session.add(board)
|
|
db.session.commit()
|
|
return redirect(url_for('portal'))
|
|
return render_template('add_board.html')
|
|
|
|
@app.route('/board/<int:board_id>/edit', methods=['GET', 'POST'])
|
|
@login_required
|
|
def edit_board(board_id):
|
|
board = Liturgiebord.query.get_or_404(board_id)
|
|
if board.church_id != current_user.church_id:
|
|
flash('Geen toegang tot dit bord')
|
|
return redirect(url_for('portal'))
|
|
if request.method == 'POST':
|
|
# If changing the name
|
|
if request.form.get('form_type') == 'name':
|
|
new_name = request.form.get('name', '').strip()
|
|
if new_name:
|
|
board.name = new_name
|
|
db.session.commit()
|
|
flash('Naam van het bord gewijzigd!')
|
|
else:
|
|
flash('Naam mag niet leeg zijn.')
|
|
return redirect(url_for('edit_board', board_id=board.id))
|
|
# Otherwise, update lines
|
|
for i in range(1, 11):
|
|
setattr(board, f'line{i}', request.form.get(f'line{i}', ''))
|
|
db.session.commit()
|
|
flash('Bord bijgewerkt!')
|
|
return redirect(url_for('portal'))
|
|
# Get schedules assigned to this board
|
|
schedules = board.schedules.order_by(Schedule.date.desc(), Schedule.start_time).all()
|
|
|
|
# Find the active schedule object
|
|
active_schedule = None
|
|
now = datetime.now()
|
|
today = now.date()
|
|
current_time = now.time()
|
|
for schedule in schedules:
|
|
if schedule.date == today and schedule.start_time <= current_time <= schedule.end_time:
|
|
active_schedule = schedule
|
|
break
|
|
|
|
schedule_active = active_schedule is not None
|
|
|
|
schedule_content = None
|
|
if schedule_active:
|
|
schedule_content = active_schedule.content
|
|
|
|
return render_template('edit_board.html', board=board, schedules=schedules, schedule_active=schedule_active, active_schedule=active_schedule, schedule_content=schedule_content)
|
|
|
|
from datetime import datetime
|
|
|
|
# Helper to get active schedule for a board
|
|
from sqlalchemy import and_, or_
|
|
def get_active_schedule(board):
|
|
now = datetime.now()
|
|
today = now.date()
|
|
current_time = now.time()
|
|
for schedule in board.schedules:
|
|
if schedule.date != today:
|
|
continue
|
|
if not (schedule.start_time <= current_time <= schedule.end_time):
|
|
continue
|
|
return schedule.content
|
|
return None
|
|
|
|
@app.route('/board/<int:board_id>')
|
|
def display_board(board_id):
|
|
board = Liturgiebord.query.get_or_404(board_id)
|
|
schedule_content = get_active_schedule(board)
|
|
return render_template('display_board.html', board=board, schedule_content=schedule_content)
|
|
|
|
from flask import jsonify
|
|
|
|
@app.route('/board/<int:board_id>/active_schedule_json')
|
|
def active_schedule_json(board_id):
|
|
board = Liturgiebord.query.get_or_404(board_id)
|
|
schedule_content = get_active_schedule(board)
|
|
# Return schedule lines if active, else board input lines
|
|
if schedule_content:
|
|
lines = schedule_content.split('\n')
|
|
else:
|
|
# Use board lines if no active schedule
|
|
lines = [
|
|
board.line1 or ' ',
|
|
board.line2 or ' ',
|
|
board.line3 or ' ',
|
|
board.line4 or ' ',
|
|
board.line5 or ' ',
|
|
board.line6 or ' ',
|
|
board.line7 or ' ',
|
|
board.line8 or ' ',
|
|
board.line9 or ' ',
|
|
board.line10 or ' '
|
|
]
|
|
# Also include background info for completeness
|
|
bg_url = None
|
|
if board.background_image:
|
|
# Determine URL path, check if default or uploaded
|
|
from flask import url_for
|
|
if board.background_image.startswith('default_'):
|
|
bg_url = url_for('static', filename=board.background_image)
|
|
else:
|
|
bg_url = url_for('uploaded_file', filename=board.background_image)
|
|
return jsonify({
|
|
'lines': lines,
|
|
'background': bg_url
|
|
})
|
|
|
|
@app.route('/board/<int:board_id>/delete', methods=['POST'])
|
|
@login_required
|
|
def delete_board(board_id):
|
|
board = Liturgiebord.query.get_or_404(board_id)
|
|
if board.church_id != current_user.church_id and not current_user.is_admin:
|
|
flash('Geen toegang tot dit bord')
|
|
return redirect(url_for('portal'))
|
|
# Remove schedule associations but keep schedules intact
|
|
board.schedules = []
|
|
db.session.commit()
|
|
# Then delete the board
|
|
db.session.delete(board)
|
|
db.session.commit()
|
|
flash('Liturgiebord verwijderd!')
|
|
return redirect(url_for('portal'))
|
|
|
|
|
|
from flask import abort
|
|
|
|
# Remove old per-board schedule management routes
|
|
|
|
@app.route('/schedules')
|
|
@login_required
|
|
def global_schedules():
|
|
user_church_id = current_user.church_id
|
|
# Non-admin: show only schedules linked to boards of their church
|
|
schedules = []
|
|
boards = Liturgiebord.query.filter_by(church_id=user_church_id).all()
|
|
board_ids = [b.id for b in boards]
|
|
all_schedules = Schedule.query.order_by(Schedule.date.desc(), Schedule.start_time).all()
|
|
for schedule in all_schedules:
|
|
# Check if schedule belongs to any board_id of current church
|
|
for board in schedule.boards:
|
|
if board.id in board_ids:
|
|
schedules.append(schedule)
|
|
break
|
|
boards = Liturgiebord.query.filter_by(church_id=user_church_id).all()
|
|
return render_template('global_schedules.html', schedules=schedules, boards=boards)
|
|
|
|
@app.route('/schedules/add', methods=['GET', 'POST'])
|
|
@login_required
|
|
def add_global_schedule():
|
|
user_church_id = current_user.church_id
|
|
boards = Liturgiebord.query.filter_by(church_id=user_church_id).all()
|
|
if request.method == 'POST':
|
|
from datetime import datetime
|
|
sched_date = datetime.strptime(request.form['date'], '%Y-%m-%d').date()
|
|
start_time = datetime.strptime(request.form['start_time'], '%H:%M').time()
|
|
end_time = datetime.strptime(request.form['end_time'], '%H:%M').time()
|
|
lines = []
|
|
for i in range(1, 11):
|
|
line = request.form.get(f'line{i}', '').strip()
|
|
if line == '':
|
|
line = ' '
|
|
lines.append(line)
|
|
content = '\n'.join(lines)
|
|
name = request.form['name']
|
|
board_ids = request.form.getlist('boards')
|
|
|
|
schedule_obj = Schedule(
|
|
name=name,
|
|
start_time=start_time,
|
|
end_time=end_time,
|
|
content=content,
|
|
date=sched_date
|
|
)
|
|
# Assign boards
|
|
for board_id in board_ids:
|
|
board = Liturgiebord.query.get(int(board_id))
|
|
if board and (board.church_id == user_church_id or current_user.is_admin):
|
|
schedule_obj.boards.append(board)
|
|
|
|
db.session.add(schedule_obj)
|
|
db.session.commit()
|
|
flash('Nieuwe planning toegevoegd.')
|
|
return redirect(url_for('global_schedules'))
|
|
return render_template('schedule_form.html', schedule=None, boards=boards)
|
|
|
|
@app.route('/schedules/<int:schedule_id>/edit', methods=['GET', 'POST'])
|
|
@login_required
|
|
def edit_global_schedule(schedule_id):
|
|
user_church_id = current_user.church_id
|
|
schedule = Schedule.query.get_or_404(schedule_id)
|
|
# Convert boards dynamic relationship to list for template
|
|
schedule.boards_list = schedule.boards.all()
|
|
|
|
# Check user has access via any board or admin
|
|
accessible = False
|
|
for board in schedule.boards_list:
|
|
if board.church_id == user_church_id:
|
|
accessible = True
|
|
break
|
|
if not (accessible or current_user.is_admin):
|
|
abort(403)
|
|
|
|
boards = Liturgiebord.query.filter_by(church_id=user_church_id).all()
|
|
|
|
if request.method == 'POST':
|
|
from datetime import datetime
|
|
schedule.date = datetime.strptime(request.form['date'], '%Y-%m-%d').date()
|
|
schedule.start_time = datetime.strptime(request.form['start_time'], '%H:%M').time()
|
|
schedule.end_time = datetime.strptime(request.form['end_time'], '%H:%M').time()
|
|
lines = []
|
|
for i in range(1, 11):
|
|
line = request.form.get(f'line{i}', '').strip()
|
|
if line == '':
|
|
line = ' '
|
|
lines.append(line)
|
|
schedule.content = '\n'.join(lines)
|
|
schedule.name = request.form['name']
|
|
|
|
# Update board assignments
|
|
board_ids = request.form.getlist('boards')
|
|
schedule.boards = [] # Clear old assignments
|
|
for board_id in board_ids:
|
|
board = Liturgiebord.query.get(int(board_id))
|
|
if board and (board.church_id == user_church_id or current_user.is_admin):
|
|
schedule.boards.append(board)
|
|
|
|
db.session.commit()
|
|
flash('Planning bijgewerkt.')
|
|
return redirect(url_for('global_schedules'))
|
|
return render_template('schedule_form.html', schedule=schedule, boards=boards)
|
|
|
|
@app.route('/schedules/<int:schedule_id>/delete', methods=['POST'])
|
|
@login_required
|
|
def delete_global_schedule(schedule_id):
|
|
schedule = Schedule.query.get_or_404(schedule_id)
|
|
# Check user has access via any board or admin
|
|
user_church_id = current_user.church_id
|
|
accessible = False
|
|
for board in schedule.boards:
|
|
if board.church_id == user_church_id:
|
|
accessible = True
|
|
break
|
|
if not (accessible or current_user.is_admin):
|
|
abort(403)
|
|
db.session.delete(schedule)
|
|
db.session.commit()
|
|
flash('Planning verwijderd.')
|
|
return redirect(url_for('global_schedules'))
|
|
|
|
def allowed_file(filename):
|
|
return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS
|
|
@app.route('/board/<int:board_id>/background', methods=['GET', 'POST'])
|
|
@login_required
|
|
def upload_background(board_id):
|
|
board = Liturgiebord.query.get_or_404(board_id)
|
|
if board.church_id != current_user.church_id:
|
|
flash('Geen toegang tot dit bord')
|
|
return redirect(url_for('portal'))
|
|
if request.method == 'POST':
|
|
# handle setting default wallpaper
|
|
default_background = request.form.get('default_background')
|
|
if default_background:
|
|
# Set the default wallpaper filename directly
|
|
# We assume the filename is safe and known
|
|
board.background_image = default_background
|
|
db.session.commit()
|
|
flash('Achtergrondafbeelding ingesteld!')
|
|
return redirect(url_for('edit_board', board_id=board.id))
|
|
|
|
if 'background' not in request.files:
|
|
flash('Geen bestand geselecteerd')
|
|
return redirect(request.url)
|
|
file = request.files['background']
|
|
if file and allowed_file(file.filename):
|
|
filename = secure_filename(f'board_{board.id}_bg_{file.filename}')
|
|
filepath = os.path.join(app.config['UPLOAD_FOLDER'], filename)
|
|
file.save(filepath)
|
|
board.background_image = filename
|
|
db.session.commit()
|
|
flash('Achtergrondafbeelding bijgewerkt!')
|
|
return redirect(url_for('edit_board', board_id=board.id))
|
|
else:
|
|
flash('Ongeldig bestandstype')
|
|
return render_template('upload_background.html', board=board)
|
|
|
|
@app.route('/uploads/<filename>')
|
|
def uploaded_file(filename):
|
|
return send_from_directory(app.config['UPLOAD_FOLDER'], filename)
|
|
|
|
@app.route('/admin/login', methods=['GET', 'POST'])
|
|
def admin_login():
|
|
if current_user.is_authenticated and current_user.is_admin:
|
|
return redirect(url_for('admin_dashboard'))
|
|
if request.method == 'POST':
|
|
username = request.form['username']
|
|
password = request.form['password']
|
|
user = User.query.filter_by(username=username, is_admin=True).first()
|
|
if user and check_password_hash(user.password, password):
|
|
login_user(user)
|
|
return redirect(url_for('admin_dashboard'))
|
|
else:
|
|
flash('Ongeldige admin-inloggegevens')
|
|
return render_template('admin_login.html')
|
|
|
|
from flask import request
|
|
|
|
@app.route('/admin/delete_church/<int:church_id>', methods=['POST'])
|
|
@login_required
|
|
def admin_delete_church(church_id):
|
|
if not current_user.is_admin:
|
|
flash('Geen toegang')
|
|
return redirect(url_for('portal'))
|
|
church = Church.query.get_or_404(church_id)
|
|
# Delete associated boards and users
|
|
for board in church.boards:
|
|
db.session.delete(board)
|
|
for user in church.users:
|
|
db.session.delete(user)
|
|
db.session.delete(church)
|
|
db.session.commit()
|
|
flash(f'Kerk {church.name} en alle bijbehorende data verwijderd!')
|
|
return redirect(url_for('admin_dashboard'))
|
|
|
|
|
|
@app.route('/admin/dashboard', methods=['GET', 'POST'])
|
|
@login_required
|
|
def admin_dashboard():
|
|
if not current_user.is_admin:
|
|
flash('Geen toegang')
|
|
return redirect(url_for('portal'))
|
|
|
|
if request.method == 'POST':
|
|
form_type = request.form.get('form_type', 'church_activation')
|
|
|
|
if form_type == 'church_activation':
|
|
# Update church activation states
|
|
for church in Church.query.all():
|
|
checkbox_val = request.form.get(f'church_active_{church.id}')
|
|
church.is_active = checkbox_val == 'on'
|
|
db.session.commit()
|
|
flash('Kerken activatiestatus bijgewerkt.')
|
|
|
|
elif form_type == 'smtp_settings':
|
|
settings = get_smtp_settings()
|
|
settings.host = request.form.get('smtp_host', '').strip()
|
|
port_raw = request.form.get('smtp_port', '').strip()
|
|
try:
|
|
settings.port = int(port_raw) if port_raw else 587
|
|
except ValueError:
|
|
settings.port = 587
|
|
settings.username = request.form.get('smtp_username', '').strip()
|
|
# Only overwrite password if a value was provided (so admins can leave it blank).
|
|
pw = request.form.get('smtp_password', '')
|
|
if pw != '':
|
|
settings.password = pw
|
|
settings.use_tls = request.form.get('smtp_use_tls') == 'on'
|
|
settings.use_ssl = request.form.get('smtp_use_ssl') == 'on'
|
|
settings.verify_tls = request.form.get('smtp_verify_tls') == 'on'
|
|
settings.from_email = request.form.get('smtp_from_email', '').strip()
|
|
settings.from_name = request.form.get('smtp_from_name', '').strip() or 'PsalmbordOnline'
|
|
db.session.commit()
|
|
flash('SMTP instellingen opgeslagen.')
|
|
|
|
elif form_type == 'smtp_test':
|
|
to_email = request.form.get('smtp_test_to', '').strip()
|
|
if not to_email:
|
|
flash('Vul een test emailadres in.')
|
|
else:
|
|
ok, msg = send_email(
|
|
to_email=to_email,
|
|
subject='PsalmbordOnline SMTP test',
|
|
body_text='Dit is een test email vanuit PsalmbordOnline.',
|
|
)
|
|
flash(msg)
|
|
|
|
churches = Church.query.all()
|
|
users = User.query.all()
|
|
smtp_settings = get_smtp_settings()
|
|
return render_template('admin_dashboard.html', churches=churches, users=users, smtp_settings=smtp_settings)
|
|
|
|
@app.route('/admin/impersonate/<int:user_id>')
|
|
@login_required
|
|
def admin_impersonate(user_id):
|
|
if not current_user.is_admin:
|
|
flash('Geen toegang')
|
|
return redirect(url_for('portal'))
|
|
user = User.query.get_or_404(user_id)
|
|
session['impersonate_id'] = user.id
|
|
session['admin_id'] = current_user.id
|
|
flash(f'Je bent nu ingelogd als {user.username}')
|
|
return redirect(url_for('portal'))
|
|
|
|
@app.before_request
|
|
def impersonate_user():
|
|
if 'impersonate_id' in session and 'admin_id' in session:
|
|
# Only impersonate if the real admin is logged in
|
|
admin = User.query.get(session['admin_id'])
|
|
if admin and admin.is_authenticated and admin.is_admin:
|
|
user = User.query.get(session['impersonate_id'])
|
|
if user:
|
|
# Use Flask-Login's login_user to switch context
|
|
login_user(user)
|
|
# Attach a proxy attribute to current_user to allow admin checks
|
|
current_user._is_impersonated = True
|
|
current_user._real_admin_id = admin.id
|
|
else:
|
|
# Not impersonating, ensure _is_impersonated is not set
|
|
if hasattr(current_user, '_is_impersonated'):
|
|
delattr(current_user, '_is_impersonated')
|
|
if hasattr(current_user, '_real_admin_id'):
|
|
delattr(current_user, '_real_admin_id')
|
|
|
|
|
|
@app.route('/admin/users')
|
|
@login_required
|
|
def admin_users():
|
|
if not current_user.is_admin:
|
|
flash('Geen toegang')
|
|
return redirect(url_for('portal'))
|
|
users = User.query.all()
|
|
return render_template('admin_users.html', users=users)
|
|
|
|
@app.route('/admin/delete_user/<int:user_id>', methods=['POST'])
|
|
@login_required
|
|
def admin_delete_user(user_id):
|
|
if not current_user.is_admin:
|
|
flash('Geen toegang')
|
|
return redirect(url_for('portal'))
|
|
user = User.query.get_or_404(user_id)
|
|
church = user.church
|
|
if user.is_admin:
|
|
flash('Kan geen admin-gebruiker verwijderen!')
|
|
return redirect(url_for('admin_users'))
|
|
if len(church.users) > 1:
|
|
# Just delete the user
|
|
db.session.delete(user)
|
|
db.session.commit()
|
|
flash(f'Gebruiker {user.username} verwijderd!')
|
|
else:
|
|
# Last user, delete user, church, boards
|
|
for board in church.boards:
|
|
db.session.delete(board)
|
|
db.session.delete(user)
|
|
db.session.delete(church)
|
|
db.session.commit()
|
|
flash(f'Laatste gebruiker verwijderd. Kerk en data ook verwijderd ({user.username}, {church.name})!')
|
|
return redirect(url_for('admin_users'))
|
|
|
|
@app.route('/display/<unique_id>')
|
|
def display_board_unique(unique_id):
|
|
board = Liturgiebord.query.filter_by(unique_id=unique_id).first_or_404()
|
|
schedule_content = get_active_schedule(board)
|
|
return render_template('display_board.html', board=board, schedule_content=schedule_content)
|
|
|
|
from werkzeug.security import check_password_hash
|
|
|
|
@app.route('/change_password', methods=['GET', 'POST'])
|
|
@login_required
|
|
def change_password():
|
|
msg = None
|
|
if request.method == 'POST':
|
|
current_password = request.form.get('current_password')
|
|
new_password = request.form.get('new_password')
|
|
confirm_password = request.form.get('confirm_password')
|
|
if not check_password_hash(current_user.password, current_password):
|
|
msg = 'Huidig wachtwoord is onjuist.'
|
|
elif not new_password or not confirm_password:
|
|
msg = 'Voer een nieuw wachtwoord in.'
|
|
elif new_password != confirm_password:
|
|
msg = 'De nieuwe wachtwoorden komen niet overeen.'
|
|
else:
|
|
current_user.password = generate_password_hash(new_password)
|
|
db.session.commit()
|
|
msg = 'Wachtwoord succesvol gewijzigd.'
|
|
return render_template('change_password.html', msg=msg)
|
|
|
|
if __name__ == '__main__':
|
|
# Ensure instance folder exists when running without Docker.
|
|
os.makedirs(app.instance_path, exist_ok=True)
|
|
|
|
# Create DB on first run when using the default instance DB path.
|
|
if app.config['SQLALCHEMY_DATABASE_URI'].startswith('sqlite:///') and not os.path.exists(default_db_path):
|
|
with app.app_context():
|
|
db.create_all()
|
|
# Create initial admin user if not exists
|
|
if not User.query.filter_by(username='admin').first():
|
|
admin_church = Church.query.filter_by(name='Admin').first()
|
|
if not admin_church:
|
|
admin_church = Church(name='Admin')
|
|
db.session.add(admin_church)
|
|
db.session.commit()
|
|
admin_user = User(username='admin', password=generate_password_hash('admin'), church_id=admin_church.id, is_admin=True)
|
|
db.session.add(admin_user)
|
|
db.session.commit()
|
|
app.run() |