Initial commit

This commit is contained in:
2025-12-10 22:47:38 +01:00
parent e98d8c5c1b
commit f78c4d389d
2870 changed files with 641720 additions and 0 deletions

14
.vscode/launch.json vendored Normal file
View File

@@ -0,0 +1,14 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "Python Debugger: Python File",
"type": "debugpy",
"request": "launch",
"program": "${file}"
}
]
}

View File

Binary file not shown.

7
ad.py Normal file
View File

@@ -0,0 +1,7 @@
from app import db, Liturgiebord
import uuid
with db.app.app_context():
for board in Liturgiebord.query.filter((Liturgiebord.unique_id == None) | (Liturgiebord.unique_id == '')).all():
board.unique_id = uuid.uuid4().hex
db.session.commit()

692
app.py Normal file
View File

@@ -0,0 +1,692 @@
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
UPLOAD_FOLDER = 'static/uploads'
ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif'}
app = Flask(__name__)
app.config['SECRET_KEY'] = 'your_secret_key_here'
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///liturgie.db'
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)
# 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)
# 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')
@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
if current_user.is_admin:
# Admin sees all schedules
schedules = Schedule.query.order_by(Schedule.date.desc(), Schedule.start_time).all()
else:
# 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':
# 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.')
churches = Church.query.all()
users = User.query.all()
return render_template('admin_dashboard.html', churches=churches, users=users)
@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__':
if not os.path.exists('liturgie.db'):
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(host='0.0.0.0')

17
cgi-bin/flask_cgi.py Normal file
View File

@@ -0,0 +1,17 @@
#!/usr/bin/env python
import cgitb; cgitb.enable()
import os
import sys
from wsgiref.simple_server import make_server
# Path to your app.py
sys.path.insert(0, os.path.abspath(os.path.dirname(__file__)))
# Import your Flask app
import app
# Run the WSGI server
if __name__ == '__main__':
# Cottage-style server for CGI
from wsgiref.handlers import CGIHandler
CGIHandler().run(app.app)

BIN
instance/liturgie.db Normal file
View File

Binary file not shown.

1
migrations/README Normal file
View File

@@ -0,0 +1 @@
Single-database configuration for Flask.

View File

Binary file not shown.

50
migrations/alembic.ini Normal file
View File

@@ -0,0 +1,50 @@
# A generic, single database configuration.
[alembic]
# template used to generate migration files
# file_template = %%(rev)s_%%(slug)s
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# Logging configuration
[loggers]
keys = root,sqlalchemy,alembic,flask_migrate
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[logger_flask_migrate]
level = INFO
handlers =
qualname = flask_migrate
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

113
migrations/env.py Normal file
View File

@@ -0,0 +1,113 @@
import logging
from logging.config import fileConfig
from flask import current_app
from alembic import context
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# Interpret the config file for Python logging.
# This line sets up loggers basically.
fileConfig(config.config_file_name)
logger = logging.getLogger('alembic.env')
def get_engine():
try:
# this works with Flask-SQLAlchemy<3 and Alchemical
return current_app.extensions['migrate'].db.get_engine()
except (TypeError, AttributeError):
# this works with Flask-SQLAlchemy>=3
return current_app.extensions['migrate'].db.engine
def get_engine_url():
try:
return get_engine().url.render_as_string(hide_password=False).replace(
'%', '%%')
except AttributeError:
return str(get_engine().url).replace('%', '%%')
# add your model's MetaData object here
# for 'autogenerate' support
# from myapp import mymodel
# target_metadata = mymodel.Base.metadata
config.set_main_option('sqlalchemy.url', get_engine_url())
target_db = current_app.extensions['migrate'].db
# other values from the config, defined by the needs of env.py,
# can be acquired:
# my_important_option = config.get_main_option("my_important_option")
# ... etc.
def get_metadata():
if hasattr(target_db, 'metadatas'):
return target_db.metadatas[None]
return target_db.metadata
def run_migrations_offline():
"""Run migrations in 'offline' mode.
This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
"""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url, target_metadata=get_metadata(), literal_binds=True
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online():
"""Run migrations in 'online' mode.
In this scenario we need to create an Engine
and associate a connection with the context.
"""
# this callback is used to prevent an auto-migration from being generated
# when there are no changes to the schema
# reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html
def process_revision_directives(context, revision, directives):
if getattr(config.cmd_opts, 'autogenerate', False):
script = directives[0]
if script.upgrade_ops.is_empty():
directives[:] = []
logger.info('No changes in schema detected.')
conf_args = current_app.extensions['migrate'].configure_args
if conf_args.get("process_revision_directives") is None:
conf_args["process_revision_directives"] = process_revision_directives
connectable = get_engine()
with connectable.connect() as connection:
context.configure(
connection=connection,
target_metadata=get_metadata(),
**conf_args
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

24
migrations/script.py.mako Normal file
View File

@@ -0,0 +1,24 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}
branch_labels = ${repr(branch_labels)}
depends_on = ${repr(depends_on)}
def upgrade():
${upgrades if upgrades else "pass"}
def downgrade():
${downgrades if downgrades else "pass"}

View File

@@ -0,0 +1,88 @@
"""Initial migration
Revision ID: 168fdd980dba
Revises:
Create Date: 2025-07-31 10:56:01.296387
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '168fdd980dba'
down_revision = None
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('church',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(length=150), nullable=False),
sa.Column('is_active', sa.Boolean(), nullable=False),
sa.Column('contact_email', sa.String(length=150), nullable=True),
sa.Column('contact_phone', sa.String(length=50), nullable=True),
sa.Column('contact_address', sa.String(length=200), nullable=True),
sa.Column('logo_filename', sa.String(length=300), nullable=True),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('name')
)
op.create_table('schedule',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(length=150), nullable=False),
sa.Column('start_time', sa.Time(), nullable=False),
sa.Column('end_time', sa.Time(), nullable=False),
sa.Column('date', sa.Date(), nullable=False),
sa.Column('content', sa.Text(), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_table('liturgiebord',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('unique_id', sa.String(length=32), nullable=False),
sa.Column('name', sa.String(length=150), nullable=False),
sa.Column('church_id', sa.Integer(), nullable=False),
sa.Column('line1', sa.String(length=200), nullable=True),
sa.Column('line2', sa.String(length=200), nullable=True),
sa.Column('line3', sa.String(length=200), nullable=True),
sa.Column('line4', sa.String(length=200), nullable=True),
sa.Column('line5', sa.String(length=200), nullable=True),
sa.Column('line6', sa.String(length=200), nullable=True),
sa.Column('line7', sa.String(length=200), nullable=True),
sa.Column('line8', sa.String(length=200), nullable=True),
sa.Column('line9', sa.String(length=200), nullable=True),
sa.Column('line10', sa.String(length=200), nullable=True),
sa.Column('background_image', sa.String(length=300), nullable=True),
sa.ForeignKeyConstraint(['church_id'], ['church.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('unique_id')
)
op.create_table('user',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('username', sa.String(length=150), nullable=False),
sa.Column('password', sa.String(length=150), nullable=False),
sa.Column('church_id', sa.Integer(), nullable=False),
sa.Column('is_admin', sa.Boolean(), nullable=True),
sa.ForeignKeyConstraint(['church_id'], ['church.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('username')
)
op.create_table('board_schedule',
sa.Column('board_id', sa.Integer(), nullable=False),
sa.Column('schedule_id', sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(['board_id'], ['liturgiebord.id'], ),
sa.ForeignKeyConstraint(['schedule_id'], ['schedule.id'], ),
sa.PrimaryKeyConstraint('board_id', 'schedule_id')
)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('board_schedule')
op.drop_table('user')
op.drop_table('liturgiebord')
op.drop_table('schedule')
op.drop_table('church')
# ### end Alembic commands ###

View File

Binary file not shown.

8
requirements.txt Normal file
View File

@@ -0,0 +1,8 @@
Flask
Flask_SQLAlchemy
Flask_Login
flask_migrate
Werkzeug
SQLAlchemy
itsdangerous
python-dotenv

BIN
static/Hero.jpg Normal file
View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 482 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

BIN
static/default_logo.png Normal file
View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

BIN
static/default_wall_1.jpg Normal file
View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 851 KiB

BIN
static/default_wall_2.jpg Normal file
View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

BIN
static/default_wall_3.jpg Normal file
View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 341 KiB

BIN
static/default_wall_4.jpg Normal file
View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 190 KiB

BIN
static/favicon.ico Normal file
View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

BIN
static/favicon.png Normal file
View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

BIN
static/icons/board.png Normal file
View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

BIN
static/icons/church.png Normal file
View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

BIN
static/icons/cog.png Normal file
View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

BIN
static/icons/group.png Normal file
View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

BIN
static/icons/task.png Normal file
View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 324 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 324 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 472 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 324 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 324 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 MiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 324 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 324 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 353 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

27
templates/add_board.html Normal file
View File

@@ -0,0 +1,27 @@
{% extends 'base.html' %}
{% block title %}Nieuw Liturgiebord Toevoegen - Digitale Liturgie{% endblock %}
{% block content %}
<div class="space-y-8 max-w-6xl mx-auto px-4">
<h2 class="text-3xl font-semibold mb-6">Nieuw Liturgiebord Toevoegen</h2>
<form method="post" class="space-y-6 max-w-xl">
<div>
<label for="name" class="block mb-3 font-semibold text-gray-700">Naam van het bord</label>
<input type="text" id="name" name="name" required class="w-full rounded-md border border-gray-300 p-4 text-lg placeholder-gray-400 shadow-sm focus:border-blue-600 focus:ring focus:ring-blue-300 focus:ring-opacity-50 transition">
</div>
<div class="flex gap-4">
<button type="submit" class="bg-[#f7d91a] text-black px-8 py-3 rounded-md font-semibold shadow hover:bg-yellow-300 transition">Toevoegen</button>
<a href="{{ url_for('portal') }}" class="bg-white text-black border border-black px-8 py-3 rounded-md font-semibold shadow hover:bg-gray-100 transition">Annuleren</a>
</div>
</form>
</div>
{% endblock %}

View File

@@ -0,0 +1,107 @@
{% extends 'base.html' %}
{% block title %}Admin Dashboard - Digitale Liturgie{% endblock %}
{% block content %}
<div class="space-y-8 max-w-6xl mx-auto px-4">
<section>
<h2 class="text-3xl font-semibold mb-6">Admin Dashboard</h2>
<form method="POST" class="space-y-6">
<div>
<h3 class="text-2xl font-semibold mb-4">Kerken</h3>
<div class="overflow-x-auto rounded-lg border border-gray-300">
<table class="min-w-full divide-y divide-gray-200 bg-white">
<thead class="bg-gray-100">
<tr>
<th scope="col" class="px-6 py-3 text-left text-sm font-semibold text-gray-700">Kerknaam</th>
<th scope="col" class="px-6 py-3 text-center text-sm font-semibold text-gray-700">Actief</th>
<th scope="col" class="px-6 py-3 text-left text-sm font-semibold text-gray-700">URL</th>
<th scope="col" class="px-6 py-3 text-center text-sm font-semibold text-gray-700">Acties</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200">
{% for church in churches %}
<tr>
<td class="whitespace-nowrap px-6 py-4 text-sm text-gray-900">{{ church.name }}</td>
<td class="text-center px-6 py-4">
<input type="checkbox" name="church_active_{{ church.id }}" {% if church.is_active %}checked{% endif %} class="h-5 w-5 text-blue-600">
</td>
<td class="px-6 py-4 text-sm text-blue-600">
{% if church.is_active %}
{% for board in church.boards %}
<div class="mb-1 break-all"><a href="{{ url_for('display_board_unique', unique_id=board.unique_id) }}" target="_blank" class="underline hover:text-blue-800 transition">{{ url_for('display_board_unique', unique_id=board.unique_id, _external=True) }}</a></div>
{% endfor %}
{% endif %}
</td>
<td class="text-center px-6 py-4">
<a href="#" class="bg-[#f7d91a] text-black px-6 py-3 rounded font-semibold hover:bg-yellow-300 transition">Weergeven</a>
<form action="{{ url_for('admin_delete_church', church_id=church.id) }}" method="post" style="display:inline;margin-left:8px;" onsubmit="return confirm('Weet je zeker dat je deze kerk en alle bijbehorende data wilt verwijderen?');">
<button type="submit" class="bg-red-600 text-white px-6 py-3 rounded font-semibold hover:bg-red-700 transition">Verwijderen</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<div>
<button type="submit" class="bg-[#f7d91a] text-black px-6 py-3 rounded-md font-semibold shadow hover:bg-yellow-300 transition">Opslaan</button>
</div>
</form>
</section>
<section>
<h3 class="text-2xl font-semibold mb-4">Gebruikers</h3>
<div class="overflow-x-auto rounded-lg border border-gray-300">
<table class="min-w-full divide-y divide-gray-200 bg-white">
<thead class="bg-gray-100">
<tr>
<th scope="col" class="px-6 py-3 text-left text-sm font-semibold text-gray-700">Gebruikersnaam</th>
<th scope="col" class="px-6 py-3 text-sm font-semibold text-gray-700">Kerk</th>
<th scope="col" class="px-6 py-3 text-sm font-semibold text-gray-700">Admin</th>
<th scope="col" class="px-6 py-3 text-center text-sm font-semibold text-gray-700">Impersonalisatie</th>
<th scope="col" class="px-6 py-3 text-center text-sm font-semibold text-gray-700">Acties</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200">
{% for user in users %}
<tr>
<td class="whitespace-nowrap px-6 py-4 text-sm text-gray-900">{{ user.username }}</td>
<td class="px-6 py-4 text-sm text-gray-900">{{ user.church.name }}</td>
<td class="px-6 py-4 text-sm text-gray-900">{% if user.is_admin %}Ja{% else %}Nee{% endif %}</td>
<td class="text-center px-6 py-4">
{% if not user.is_admin %}
<a href="{{ url_for('admin_impersonate', user_id=user.id) }}" class="bg-[#f7d91a] text-black text-xs px-6 py-3 rounded font-semibold hover:bg-yellow-300 transition">Impersonate</a>
{% else %}-{% endif %}
</td>
<td class="text-center px-6 py-4">
{% if not user.is_admin %}
<form action="{{ url_for('admin_delete_user', user_id=user.id) }}" method="post" style="display:inline;" onsubmit="return confirm('Weet je zeker dat je deze gebruiker en alle bijbehorende kerkdata wilt verwijderen?');">
<button type="submit" class="bg-red-600 text-white text-xs px-6 py-3 rounded font-semibold hover:bg-red-700 transition">Verwijderen</button>
</form>
{% else %}-{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</section>
</div>
{% endblock %}

View File

@@ -0,0 +1,18 @@
{% extends 'base.html' %}
{% block title %}Admin Login - Digitale Liturgie{% endblock %}
{% block content %}
<div class="space-y-8 max-w-md mx-auto">
<h2 class="text-3xl font-semibold tracking-tight">Admin Login</h2>
<form method="post" class="space-y-6">
<div>
<label for="username" class="block mb-2 font-semibold text-gray-700">Gebruikersnaam</label>
<input type="text" id="username" name="username" required class="w-full rounded-md border border-gray-300 p-3 text-base placeholder-gray-400 shadow-sm focus:border-blue-600 focus:ring focus:ring-blue-300 focus:ring-opacity-50 transition">
</div>
<div>
<label for="password" class="block mb-2 font-semibold text-gray-700">Wachtwoord</label>
<input type="password" id="password" name="password" required class="w-full rounded-md border border-gray-300 p-3 text-base placeholder-gray-400 shadow-sm focus:border-blue-600 focus:ring focus:ring-blue-300 focus:ring-opacity-50 transition">
</div>
<button type="submit" class="bg-blue-600 text-white w-full rounded-md py-3 font-semibold shadow hover:bg-blue-700 transition">Login</button>
</form>
</div>
{% endblock %}

View File

@@ -0,0 +1,50 @@
{% extends 'base.html' %}
{% block title %}Gebruikersbeheer - Digitale Liturgie{% endblock %}
{% block content %}
<div class="space-y-8 max-w-6xl mx-auto px-4">
<h2 class="text-3xl font-semibold mb-6">Gebruikersbeheer</h2>
<div class="overflow-x-auto rounded-lg border border-gray-300">
<table class="min-w-full divide-y divide-gray-200 bg-white">
<thead class="bg-gray-100">
<tr>
<th scope="col" class="px-6 py-3 text-left text-sm font-semibold text-gray-700">Gebruikersnaam</th>
<th scope="col" class="px-6 py-3 text-sm font-semibold text-gray-700">Kerk</th>
<th scope="col" class="px-6 py-3 text-sm font-semibold text-gray-700">Admin</th>
<th scope="col" class="px-6 py-3 text-center text-sm font-semibold text-gray-700">Impersonalisatie</th>
<th scope="col" class="px-6 py-3 text-center text-sm font-semibold text-gray-700">Acties</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200">
{% for user in users %}
<tr>
<td class="whitespace-nowrap px-6 py-4 text-sm text-gray-900">{{ user.username }}</td>
<td class="px-6 py-4 text-sm text-gray-900">{{ user.church.name }}</td>
<td class="px-6 py-4 text-sm text-gray-900">{% if user.is_admin %}Ja{% else %}Nee{% endif %}</td>
<td class="text-center px-6 py-4">
{% if not user.is_admin %}
<a href="{{ url_for('admin_impersonate', user_id=user.id) }}" class="bg-[#f7d91a] text-black text-xs px-6 py-3 rounded font-semibold hover:bg-yellow-300 transition">Impersonate</a>
{% else %}-{% endif %}
</td>
<td class="text-center px-6 py-4">
{% if not user.is_admin %}
<form action="{{ url_for('admin_delete_user', user_id=user.id) }}" method="post" style="display:inline;" onsubmit="return confirm('Weet je zeker dat je deze gebruiker en alle bijbehorende kerkdata wilt verwijderen?');">
<button type="submit" class="bg-red-600 text-white text-xs px-6 py-3 rounded font-semibold hover:bg-red-700 transition">Verwijderen</button>
</form>
{% else %}-{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<a href="{{ url_for('admin_dashboard') }}" class="bg-white text-black border border-black px-6 py-3 rounded-md font-semibold shadow hover:bg-gray-100 transition inline-block mt-6">Terug naar dashboard</a>
</div>
{% endblock %}

151
templates/base.html Normal file
View File

@@ -0,0 +1,151 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}PsalmbordOnline{% endblock %}</title>
<link rel="icon" type="image/png" href="{{ url_for('static', filename='favicon.ico') }}">
<!-- Google Fonts -->
<link href="https://fonts.googleapis.com/css2?family=Lora&display=swap" rel="stylesheet">
<link href="https://fonts.googleapis.com/css2?family=Lora:ital,wght@0,400..700;1,400..700&family=Quicksand:wght@300..700&display=swap" rel="stylesheet">
<!-- Tailwind CSS CDN -->
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
<style>
h1, h2, h3, h4, h5, h6 {
font-family: 'Lora', serif;
font-weight: 600;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
font-family: 'Quicksand';
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
/* Floating scrollbar style for WebKit browsers */
overflow-y: scroll;
}
/* Webkit Floating Scrollbar */
body::-webkit-scrollbar {
width: 8px;
background: transparent;
position: fixed;
}
body::-webkit-scrollbar-thumb {
background-color: rgba(0,0,0,0.3);
border-radius: 4px;
border: 2px solid transparent;
background-clip: content-box;
}
/* Firefox Floating scrollbar */
@-moz-document url-prefix() {
html {
scrollbar-width: thin;
scrollbar-color: rgba(0, 0, 0, 0.3) transparent;
}
}
</style>
</head>
<body class="bg-white text-gray-900 font-sans min-h-screen flex flex-col">
<nav class="bg-white border-b border-gray-200 shadow-sm">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex items-center justify-between h-20">
<!-- Logo -->
<div class="flex-shrink-0 flex items-center w-24">
{% if current_user.is_authenticated and current_user.church and current_user.church.logo_filename %}
<a href="{{ url_for('index') }}"><img src="{{ url_for('uploaded_file', filename=current_user.church.logo_filename) }}" alt="Logo" class="h-10 w-auto object-contain"></a>
{% else %}
<a href="{{ url_for('index') }}"><img src="{{ url_for('static', filename='default_logo.png') }}" alt="Default Logo" class="h-10 w-auto object-contain"></a>
{% endif %}
</div>
<!-- Desktop Nav -->
<div class="hidden sm:flex flex-1 justify-center">
<div id="nav-links" class="flex flex-row gap-2 items-center">
{% if current_user.is_authenticated %}
<a href="/portal" class="text-black px-6 py-3 rounded font-semibold hover:bg-[#f7d91a] hover:text-black transition">Mijn schermen</a>
<a href="{{ url_for('global_schedules') }}" class="text-black px-6 py-3 rounded font-semibold hover:bg-[#f7d91a] hover:text-black transition">Geplande diensten</a>
<a href="{{ url_for('church_settings') }}" class="text-black px-6 py-3 rounded font-semibold hover:bg-[#f7d91a] hover:text-black transition">Instellingen</a>
{% if current_user.is_admin %}
<a href="{{ url_for('admin_dashboard') }}" class="text-black px-6 py-3 rounded font-semibold hover:bg-[#f7d91a] hover:text-black transition">Admin</a>
{% endif %}
<a href="/logout" class="text-black px-6 py-3 rounded font-semibold hover:bg-[#f7d91a] hover:text-black transition">Uitloggen</a>
{% else %}
<a href="/login" class="text-black px-6 py-3 rounded font-semibold hover:bg-[#f7d91a] hover:text-black transition">Inloggen</a>
<a href="/register" class="text-black px-6 py-3 rounded font-semibold hover:bg-[#f7d91a] hover:text-black transition">Registreren</a>
{% endif %}
</div>
</div>
<!-- Current Time -->
<div id="current-time" class="text-gray-700 font-semibold text-right text-sm w-24" title="Huidige datum en tijd">
<div id="current-date"></div>
<div id="current-clock" class="mt-0"></div>
</div>
<!-- Mobile hamburger -->
<div class="flex sm:hidden">
<button id="menu-button" class="p-2 rounded-md focus:outline-none focus:ring-2 focus:ring-inset focus:ring-blue-600" aria-label="Open menu">
<svg class="h-7 w-7 text-gray-700" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
</svg>
</button>
</div>
</div>
<!-- Mobile nav menu -->
<div id="nav-mobile" class="sm:hidden hidden">
<div class="flex flex-col gap-2 py-3 items-center border-t border-gray-100">
{% if current_user.is_authenticated %}
<a href="/portal" class="bg-[#f7d91a] text-black px-6 py-3 rounded font-semibold hover:bg-yellow-300 transition w-full text-center">Mijn schermen</a>
<a href="{{ url_for('global_schedules') }}" class="bg-[#f7d91a] text-black px-6 py-3 rounded font-semibold hover:bg-yellow-300 transition w-full text-center">Geplande diensten</a>
<a href="{{ url_for('church_settings') }}" class="bg-[#f7d91a] text-black px-6 py-3 rounded font-semibold hover:bg-yellow-300 transition w-full text-center">Kerk instellingen</a>
{% if current_user.is_admin %}
<a href="{{ url_for('admin_dashboard') }}" class="bg-[#f7d91a] text-black px-6 py-3 rounded font-semibold hover:bg-yellow-300 transition w-full text-center">Admin instellingen</a>
{% endif %}
<a href="/logout" class="bg-[#f7d91a] text-black px-6 py-3 rounded font-semibold hover:bg-yellow-300 transition w-full text-center">Logout</a>
{% else %}
<a href="/login" class="bg-[#f7d91a] text-black px-6 py-3 rounded font-semibold hover:bg-yellow-300 transition w-full text-center">Login</a>
<a href="/register" class="bg-[#f7d91a] text-black px-6 py-3 rounded font-semibold hover:bg-yellow-300 transition w-full text-center">Register</a>
{% endif %}
</div>
</div>
</div>
</nav>
<script>
const menuButton = document.getElementById('menu-button');
const navMobile = document.getElementById('nav-mobile');
menuButton.addEventListener('click', () => {
navMobile.classList.toggle('hidden');
});
function updateTime() {
const now = new Date();
const dateOptions = { year: 'numeric', month: 'short', day: 'numeric' };
const timeOptions = { hour: '2-digit', minute: '2-digit', second: '2-digit' };
const dateStr = now.toLocaleDateString('nl-NL', dateOptions);
const timeStr = now.toLocaleTimeString('nl-NL', timeOptions);
document.getElementById('current-date').textContent = dateStr;
document.getElementById('current-clock').textContent = timeStr;
}
setInterval(updateTime, 1000);
updateTime();
</script>
<main class="flex-grow max-w-7xl mx-auto w-full px-6 py-8 sm:py-12">
{% with messages = get_flashed_messages() %}
{% if messages %}
<div class="bg-blue-100 border border-blue-400 text-blue-700 px-6 py-4 rounded mb-6">
{% for message in messages %}
<div>{{ message }}</div>
{% endfor %}
</div>
{% endif %}
{% endwith %}
{% block content %}{% endblock %}
</main>
<footer class="bg-gray-100 text-gray-600 text-center py-4 text-sm">
&copy; {{ current_year }} PsalmbordOnline &nbsp;|&nbsp; Contact: <a href="mailto:info@psalmbord.online" class="text-blue-600 hover:underline">info@psalmbord.online</a>
</footer>
</body>
</html>

View File

@@ -0,0 +1,48 @@
{% extends "base.html" %}
{% block content %}
<div class="space-y-8 max-w-6xl mx-auto px-4">
<h1 class="text-3xl font-semibold mb-6">Details voor {{ board.name }}</h1>
<!-- You can add more board settings above here as needed -->
<h2 class="text-2xl font-semibold mb-4">Schedules for this display</h2>
<div class="overflow-x-auto rounded-lg border border-gray-300">
<table class="min-w-full divide-y divide-gray-200 bg-white">
<thead class="bg-gray-100">
<tr>
<th class="px-6 py-3 text-left text-sm font-semibold text-gray-700">Days</th>
<th class="px-6 py-3 text-center text-sm font-semibold text-gray-700">Start Time</th>
<th class="px-6 py-3 text-center text-sm font-semibold text-gray-700">End Time</th>
<th class="px-6 py-3 text-left text-sm font-semibold text-gray-700">Content</th>
<th class="px-6 py-3 text-center text-sm font-semibold text-gray-700">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200">
{% for s in schedules %}
<tr>
<td class="whitespace-nowrap px-6 py-4 text-sm text-gray-900">{{ s.days_of_week }}</td>
<td class="text-center px-6 py-4 text-sm text-gray-900">{{ s.start_time.strftime('%H:%M') }}</td>
<td class="text-center px-6 py-4 text-sm text-gray-900">{{ s.end_time.strftime('%H:%M') }}</td>
<td class="px-6 py-4 text-sm text-gray-900">{{ s.content[:50] }}{% if s.content|length > 50 %}...{% endif %}</td>
<td class="text-center px-6 py-4">
<a href="{{ url_for('edit_schedule', board_id=board.id, schedule_id=s.id) }}" class="bg-[#f7d91a] text-black text-xs px-6 py-3 rounded font-semibold hover:bg-yellow-300 transition">Edit</a>
<form method="post" action="{{ url_for('delete_schedule', board_id=board.id, schedule_id=s.id) }}" style="display:inline;">
<button type="submit" class="bg-red-600 text-white text-xs px-6 py-3 rounded font-semibold hover:bg-red-700 transition">Delete</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<a href="{{ url_for('add_schedule', board_id=board.id) }}" class="bg-[#f7d91a] text-black px-6 py-3 rounded-md font-semibold shadow hover:bg-yellow-300 transition inline-block">Add Schedule</a>
</div>
{% endblock %}

View File

@@ -0,0 +1,45 @@
{% extends "base.html" %}
{% block content %}
<div class="space-y-8 max-w-6xl mx-auto px-4">
<h1 class="text-3xl font-semibold mb-6">Manage Schedules for {{ board.name }}</h1>
<div class="overflow-x-auto rounded-lg border border-gray-300">
<table class="min-w-full divide-y divide-gray-200 bg-white">
<thead class="bg-gray-100">
<tr>
<th class="px-6 py-3 text-left text-sm font-semibold text-gray-700">Naam</th>
<th class="px-6 py-3 text-sm font-semibold text-gray-700">Datum</th>
<th class="px-6 py-3 text-center text-sm font-semibold text-gray-700">Starttijd</th>
<th class="px-6 py-3 text-center text-sm font-semibold text-gray-700">Eindtijd</th>
<th class="px-6 py-3 text-center text-sm font-semibold text-gray-700">Acties</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200">
{% for s in schedules %}
<tr>
<td class="whitespace-nowrap px-6 py-4 text-sm text-gray-900">{{ s.name }}</td>
<td class="px-6 py-4 text-sm text-gray-900">{{ s.date.strftime('%Y-%m-%d') }}</td>
<td class="text-center px-6 py-4 text-sm text-gray-900">{{ s.start_time.strftime('%H:%M') }}</td>
<td class="text-center px-6 py-4 text-sm text-gray-900">{{ s.end_time.strftime('%H:%M') }}</td>
<td class="text-center px-6 py-4">
<a href="{{ url_for('edit_schedule', board_id=board.id, schedule_id=s.id) }}" class="bg-[#f7d91a] text-black text-xs px-6 py-3 rounded font-semibold hover:bg-yellow-300 transition">Edit</a>
<form action="{{ url_for('delete_schedule', board_id=board.id, schedule_id=s.id) }}" method="POST" style="display:inline;">
<button type="submit" class="bg-red-600 text-white text-xs px-6 py-3 rounded font-semibold hover:bg-red-700 transition">Delete</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<a href="{{ url_for('add_schedule', board_id=board.id) }}" class="bg-[#f7d91a] text-black px-6 py-3 rounded-md font-semibold shadow hover:bg-yellow-300 transition inline-block">Add Schedule</a>
<a href="{{ url_for('edit_board', board_id=board.id) }}" class="bg-white text-black border border-black px-6 py-3 rounded-md font-semibold shadow hover:bg-gray-100 transition inline-block ml-4">Terug naar bord bewerken</a>
</div>
{% endblock %}

View File

@@ -0,0 +1,36 @@
{% extends 'base.html' %}
{% block title %}Wachtwoord wijzigen{% endblock %}
{% block content %}
<div class="space-y-8 max-w-6xl mx-auto px-4">
<div class="max-w-md mx-auto">
<h2 class="text-3xl font-semibold mb-6">Wijzig Wachtwoord</h2>
{% if msg %}
<div class="bg-blue-100 border border-blue-400 text-blue-700 px-6 py-4 rounded mb-6">{{ msg }}</div>
{% endif %}
<form method="post" class="space-y-6">
<div>
<label for="current_password" class="block mb-3 font-semibold text-gray-700">Huidig wachtwoord</label>
<input type="password" id="current_password" name="current_password" required class="w-full rounded-md border border-gray-300 p-4 text-lg placeholder-gray-400 shadow-sm focus:border-blue-600 focus:ring focus:ring-blue-300 focus:ring-opacity-50 transition">
</div>
<div>
<label for="new_password" class="block mb-3 font-semibold text-gray-700">Nieuw wachtwoord</label>
<input type="password" id="new_password" name="new_password" required class="w-full rounded-md border border-gray-300 p-4 text-lg placeholder-gray-400 shadow-sm focus:border-blue-600 focus:ring focus:ring-blue-300 focus:ring-opacity-50 transition">
</div>
<div>
<label for="confirm_password" class="block mb-3 font-semibold text-gray-700">Bevestig nieuw wachtwoord</label>
<input type="password" id="confirm_password" name="confirm_password" required class="w-full rounded-md border border-gray-300 p-4 text-lg placeholder-gray-400 shadow-sm focus:border-blue-600 focus:ring focus:ring-blue-300 focus:ring-opacity-50 transition">
</div>
<button type="submit" class="bg-[#f7d91a] text-black w-full rounded-md py-4 font-semibold shadow hover:bg-yellow-300 transition">Opslaan</button>
</form>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,104 @@
{% extends 'base.html' %}
{% block title %}Kerk Instellingen{% endblock %}
{% block content %}
<div class="space-y-8 max-w-6xl mx-auto px-4">
{% if msg %}
<div class="bg-blue-100 border border-blue-400 text-blue-700 px-6 py-4 rounded mb-6">{{ msg }}</div>
{% endif %}
<div class="flex flex-wrap gap-10">
<form method="post" class="flex-1 min-w-[300px] max-w-lg space-y-6" enctype="multipart/form-data">
<input type="hidden" name="form_type" value="contact">
<h3 class="text-2xl font-semibold">Kerkgegevens</h3>
<div>
<label for="name" class="block mb-3 font-semibold text-gray-700">Kerknaam</label>
<input type="text" id="name" name="name" value="{{ church.name }}" class="w-full rounded-md border border-gray-300 p-4 text-lg placeholder-gray-400 shadow-sm focus:border-blue-600 focus:ring focus:ring-blue-300 focus:ring-opacity-50 transition">
</div>
<div>
<label for="contact_email" class="block mb-3 font-semibold text-gray-700">Contact Email</label>
<input type="email" id="contact_email" name="contact_email" value="{{ church.contact_email }}" class="w-full rounded-md border border-gray-300 p-4 text-lg placeholder-gray-400 shadow-sm focus:border-blue-600 focus:ring focus:ring-blue-300 focus:ring-opacity-50 transition">
</div>
<div>
<label for="contact_phone" class="block mb-3 font-semibold text-gray-700">Telefoon</label>
<input type="text" id="contact_phone" name="contact_phone" value="{{ church.contact_phone }}" class="w-full rounded-md border border-gray-300 p-4 text-lg placeholder-gray-400 shadow-sm focus:border-blue-600 focus:ring focus:ring-blue-300 focus:ring-opacity-50 transition">
</div>
<div>
<label for="contact_address" class="block mb-3 font-semibold text-gray-700">Adres</label>
<input type="text" id="contact_address" name="contact_address" value="{{ church.contact_address }}" class="w-full rounded-md border border-gray-300 p-4 text-lg placeholder-gray-400 shadow-sm focus:border-blue-600 focus:ring focus:ring-blue-300 focus:ring-opacity-50 transition">
</div>
<div>
<label for="logo" class="block mb-3 font-semibold text-gray-700">Logo uploaden</label>
<input type="file" id="logo" name="logo" accept="image/png, image/jpeg, image/jpg, image/gif" class="w-full border border-gray-300 rounded-md p-4">
</div>
<button type="submit" class="bg-[#f7d91a] text-black px-6 py-3 rounded-md font-semibold shadow hover:bg-yellow-300 transition">Opslaan</button>
</form>
<div class="flex-1 min-w-[300px] max-w-md">
<h3 class="text-2xl font-semibold mb-4">Gebruikers van deze kerk</h3>
<div class="overflow-x-auto rounded-lg border border-gray-300 mb-6">
<table class="min-w-full divide-y divide-gray-200 bg-white">
<thead class="bg-gray-100">
<tr>
<th class="px-6 py-3 text-left text-sm font-semibold text-gray-700">Gebruikersnaam</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200">
{% for user in users %}
<tr>
<td class="whitespace-nowrap px-6 py-4 text-sm text-gray-900 flex items-center justify-between">
{{ user.username }}
{% if user.id != current_user.id %}
<form method="post" action="{{ url_for('church_delete_user', user_id=user.id) }}" onsubmit="return confirm('Weet je zeker dat je deze gebruiker wilt verwijderen?');">
<button type="submit" class="bg-red-600 text-white px-6 py-3 rounded font-semibold hover:bg-red-700 ml-4 transition">Verwijderen</button>
</form>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<h3 class="text-2xl font-semibold mb-4">Nieuwe gebruiker toevoegen aan deze kerk</h3>
<form method="post" class="space-y-6" >
<input type="hidden" name="form_type" value="user">
<div>
<label for="username" class="block mb-3 font-semibold text-gray-700">Emailadres</label>
<input type="email" id="username" name="username" placeholder="email@voorbeeld.com" class="w-full rounded-md border border-gray-300 p-4 text-lg placeholder-gray-400 shadow-sm focus:border-blue-600 focus:ring focus:ring-blue-300 focus:ring-opacity-50 transition">
</div>
<div>
<label for="password" class="block mb-3 font-semibold text-gray-700">Wachtwoord</label>
<input type="password" id="password" name="password" class="w-full rounded-md border border-gray-300 p-4 text-lg placeholder-gray-400 shadow-sm focus:border-blue-600 focus:ring focus:ring-blue-300 focus:ring-opacity-50 transition">
</div>
<button type="submit" class="bg-[#f7d91a] text-black px-6 py-3 rounded-md font-semibold shadow hover:bg-yellow-300 transition">Toevoegen</button>
</form>
<a href="{{ url_for('change_password') }}" class="bg-white text-black border border-black px-4 py-2 rounded-md font-semibold shadow hover:bg-gray-100 transition inline-block mt-6 inline-block text-base">Wachtwoord wijzigen</a>
</div>
</div>
<a href="{{ url_for('portal') }}" class="bg-white text-black border border-black px-6 py-3 rounded-md font-semibold shadow hover:bg-gray-100 transition inline-block">Terug naar portal</a>
</div>
{% endblock %}

View File

@@ -0,0 +1,228 @@
<!DOCTYPE html>
<html lang="nl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Liturgiebord</title>
<style>
html, body {
height: 100%;
margin: 0;
padding: 0;
}
body {
background: #222;
color: #fff;
font-family: 'Lora', serif;
height: 1920px;
width: 1080px;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
}
.liturgie-board {
width: 1080px;
height: 1920px;
display: flex;
flex-direction: column;
justify-content: flex-start; /* Align all rows from the top */
align-items: flex-start;
gap: 0vh;
padding-left: 0;
position: relative;
}
.liturgie-line {
margin: auto;
width: 1080px;
justify-items: center;
text-align: left;
font-size: 130px;
line-height: 1.1;
white-space: nowrap;
height: 190px; /* Fixed height ensures every row occupies space, even when empty */
box-sizing: border-box;
margin: 0;
padding: 20px;
z-index: 2;
text-shadow: 2px 2px 8px #000, 0 0 4px #000;
/* Optional: Uncomment for visible empty row borders */
/* border-bottom: 1px solid rgba(255,255,255,0.15); */
}
.liturgie-bg-overlay {
content: '';
position: absolute;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.3);
z-index: 1;
pointer-events: none;
}
</style>
</head>
<body>
<div style="transform-origin: top left;">
<div class="liturgie-board" {% if board.background_image %}
style="background-image: url('{% if board.background_image.startswith('default_') %}{{ url_for('static', filename=board.background_image) }}{% else %}{{ url_for('uploaded_file', filename=board.background_image) }}{% endif %}'); background-size: cover; background-position: center; background-repeat: no-repeat;"
{% endif %}>
{% if board.background_image %}<div class="liturgie-bg-overlay"></div>{% endif %}
{% if schedule_content %}
{% set lines = schedule_content.split('\n') %}
{% for i in range(10) %}
<div class="liturgie-line">{{ lines[i] if lines|length > i else ' ' }}</div>
{% endfor %}
{% else %}
<div class="liturgie-line">{{ board.line1 or ' ' }}</div>
<div class="liturgie-line">{{ board.line2 or ' ' }}</div>
<div class="liturgie-line">{{ board.line3 or ' ' }}</div>
<div class="liturgie-line">{{ board.line4 or ' ' }}</div>
<div class="liturgie-line">{{ board.line5 or ' ' }}</div>
<div class="liturgie-line">{{ board.line6 or ' ' }}</div>
<div class="liturgie-line">{{ board.line7 or ' ' }}</div>
<div class="liturgie-line">{{ board.line8 or ' ' }}</div>
<div class="liturgie-line">{{ board.line9 or ' ' }}</div>
<div class="liturgie-line">{{ board.line10 or ' ' }}</div>
{% endif %}
</div>
</div>
<script>
// LIVE PREVIEW FEATURE: Accept messages with new data and update the lines/background
window.addEventListener('message', function(event) {
// You may want to restrict this for security:
// if (event.origin !== 'YOUR_DOMAIN') return;
var data = event.data;
if (!data || typeof data !== 'object' || !data.type || data.type !== 'updateBoard') return;
// Update all lines
for (let i = 1; i <= 10; i++) {
var el = document.querySelector('.liturgie-line:nth-child(' + (data.bg ? i+1 : i) + ')');
if (el) el.textContent = (data['line'+i] || ' ');
}
// Update background if sent
if ('bg' in data) {
document.querySelector('.liturgie-board').style.backgroundImage = data.bg ? ("url('" + data.bg + "')") : 'none';
// Show/hide overlay
var overlay = document.querySelector('.liturgie-bg-overlay');
if (overlay) {
overlay.style.display = data.bg ? '' : 'none';
}
}
}, false);
</script>
<script>
// Poll every 5 seconds to check for active schedule update
(function() {
let currentLines = [];
let currentBackground = null;
{% if schedule_content %}
currentLines = {{ schedule_content.split('\n')|tojson }};
{% else %}
currentLines = Array(10).fill(' ');
{% endif %}
const boardId = {{ board.id }};
function updateBoardFromData(data) {
// Use postMessage event format to update lines & background
const msg = { type: 'updateBoard' };
for (let i = 1; i <= 10; i++) {
msg['line'+i] = data.lines[i-1] || ' ';
}
if (data.background) {
msg.bg = data.background;
} else {
msg.bg = null;
}
window.postMessage(msg, '*');
currentLines = data.lines;
currentBackground = data.background || null;
}
async function pollActiveSchedule() {
try {
const response = await fetch(`/board/${boardId}/active_schedule_json`);
if (!response.ok) throw new Error('Network response not ok');
const data = await response.json();
// Compare new lines with current lines
let changed = false;
for (let i = 0; i < 10; i++) {
if ((data.lines[i] || ' ') !== (currentLines[i] || ' ')) {
changed = true;
break;
}
}
// Check for background change
if ((data.background || null) !== currentBackground) {
changed = true;
}
if (changed) {
updateBoardFromData(data);
}
} catch (e) {
console.error('Error polling active schedule:', e);
}
}
setInterval(pollActiveSchedule, 5000);
})();
</script>
<script>
// Poll every 5 seconds to check for active schedule update
(function() {
let currentLines = [];
{% if schedule_content %}
currentLines = {{ schedule_content.split('\n')|tojson }};
{% else %}
currentLines = Array(10).fill(' ');
{% endif %}
const boardId = {{ board.id }};
function updateBoardFromData(data) {
// Use postMessage event format to update lines & background
const msg = { type: 'updateBoard' };
for (let i = 1; i <= 10; i++) {
msg['line'+i] = data.lines[i-1] || ' ';
}
if (data.background) {
msg.bg = data.background;
}
window.postMessage(msg, '*');
currentLines = data.lines;
}
async function pollActiveSchedule() {
try {
const response = await fetch(`/board/${boardId}/active_schedule_json`);
if (!response.ok) throw new Error('Network response not ok');
const data = await response.json();
// Compare new lines with current lines
let changed = false;
for (let i = 0; i < 10; i++) {
if ((data.lines[i] || ' ') !== (currentLines[i] || ' ')) {
changed = true;
break;
}
}
// Also consider background change
// We can extend this if needed, skipping for now
if (changed) {
updateBoardFromData(data);
}
} catch (e) {
console.error('Error polling active schedule:', e);
}
}
setInterval(pollActiveSchedule, 5000);
})();
</script>
</body>
</html>

391
templates/edit_board.html Normal file
View File

@@ -0,0 +1,391 @@
{% extends 'base.html' %}
{% block title %}Bewerk Liturgiebord - Digitale Liturgie{% endblock %}
{% block content %}
<style>
.edit-board-container {
padding: 1rem 1rem;
background: white;
border-radius: 0.5rem;
margin: 0 auto 1rem auto;
}
.board-title {
font-size: 2rem;
font-weight: 600;
margin-bottom: 1rem;
color: #000000;
text-align: left;
}
.lines-section {
margin-bottom: 2.5rem;
}
.line-input-group {
margin-bottom: 1.25rem;
}
.line-input-group label {
display: block;
margin-bottom: 0.75rem;
font-weight: 700;
color: #374151;
}
.line-input-group input {
width: 100%;
padding: 0.75rem 1rem;
font-size: 1.125rem;
border-radius: 0.5rem;
border: 1px solid #d1d5db;
box-shadow: inset 0 1px 2px rgb(0 0 0 / 0.1);
transition: border-color 0.2s ease-in-out, box-shadow 0.2s ease-in-out;
}
.line-input-group input:focus {
outline: none;
border-color: #2563eb;
box-shadow: 0 0 5px #93c5fd;
}
.button-group {
display: flex;
gap: 1rem;
justify-content: flex-start;
margin-top: 1.5rem;
}
.button-group button {
padding: 0.75rem 1.75rem;
font-size: 1rem;
border-radius: 0.5rem;
font-weight: 700;
transition: background-color 0.15s ease-in-out;
}
.schedule-section {
max-width: 100%;
margin-bottom: 2.5rem;
}
.schedule-section h3 {
margin-bottom: 1rem;
}
.schedule-section table {
width: 100%;
}
.preview-section {
max-width: 100%;
text-align: center;
font-family: 'Lora', serif;
}
.preview-wrapper {
width: 250px;
height: 444px; /* 9:16 aspect ratio */
margin: 0 auto;
background: #222;
border-radius: 0.5rem;
overflow: hidden;
}
iframe#live-preview-iframe {
width: 1080px;
height: 1920px;
border: 0;
pointer-events: none;
transform-origin: top left;
transform: scale(0.23);
margin: 0;
display: block;
}
@media (max-width: 767px) {
.edit-board-container {
padding: 15px;
margin-bottom: 1rem;
border-radius: 0.5rem;
}
.board-title {
font-size: 1.75rem;
margin-bottom: 1.5rem;
}
.button-group {
flex-direction: column;
gap: 8px;
}
.button-group button {
width: 100%;
}
.preview-wrapper {
width: 180px;
height: 320px;
margin: 0 auto;
border-radius: 0.5rem;
background: #222;
overflow: hidden;
}
iframe#live-preview-iframe {
transform: scale(0.23);
position: static;
width: 1080px;
height: 1920px;
margin: 0 auto;
display: block;
}
}
/* Highlight for drag-over between lines in preview */
.liturgie-line.line-over {
background: rgba(255,255,255,0.22);
border: 2px dashed #fff;
box-sizing: border-box;
outline: none;
transition: background 0.12s, border 0.12s;
}
.liturgie-line.swap-animate {
animation: swap-pulse 0.35s;
}
@keyframes swap-pulse {
0% { background: #ffe066; }
60% { background: #fff3cd; }
100% { background: none; }
}
</style>
<div class="edit-board-container">
<div style="display: flex; gap: 2rem; align-items: flex-start; flex-wrap: wrap;">
<!-- Left: Live (editable) preview -->
<form method="post" id="boardForm" style="flex: 1 1 300px; min-width: 250px; max-width:250px;">
<div class="preview-section">
<div class="preview-wrapper"
style="width:250px; height:444px; background: #222; border-radius: 0.5rem; overflow: hidden; position:relative;
background-image: {% if board.background_image %}url('{% if board.background_image.startswith('default_') %}{{ url_for('static', filename=board.background_image) }}{% else %}{{ url_for('uploaded_file', filename=board.background_image) }}{% endif %}'){% else %}none{% endif %}; background-size:cover; background-position:center;">
{% if board.background_image %}
<div class="liturgie-bg-overlay" style="
content:''; position:absolute; top:0; left:0; right:0; bottom:0;
background:rgba(0,0,0,0.3); z-index:1; pointer-events:none;">
</div>
{% endif %}
{% if schedule_active %}
{% set matched_schedules = schedules|selectattr('content', 'equalto', schedule_content)|list %}
<div style="position:absolute; top:0; left:0; right:0; bottom:0; z-index:10; display:flex; justify-content:center; align-items:center; background: rgba(0,0,0,0.7); color:white; font-weight:bold; font-size:1rem; text-align:center; padding: 1rem;">
Geplande dienst ({{ matched_schedules[0].name if matched_schedules|length > 0 else 'Onbekend' }}) is nu actief
</div>
{% endif %}
<div class="liturgie-board"
style="width:100%; height:100%; display:flex; flex-direction:column; justify-content:center; align-items:flex-start; gap:0.6vh; padding-left:5%; position:relative; z-index:2;">
{% if schedule_active %}
{% set lines = schedule_content.split('\\n') %}
{% for i in range(10) %}
<div class="liturgie-line" style="width:90%; font-size:28px; line-height:1.2; text-shadow:2px 2px 8px #000, 0 0 4px #000; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin:0; padding:0; color:#fff; text-align:left;">
{{ lines[i] if lines|length > i else ' ' }}
</div>
{% endfor %}
{% else %}
{% for i in range(1, 11) %}
<div class="liturgie-line"
contenteditable="true"
data-line="line{{ i }}"
draggable="true"
style="width:90%; font-size:28px; line-height:1.2; text-shadow:2px 2px 8px #000, 0 0 4px #000; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin:0; padding:0; color:#fff; text-align:left;"
>{{ board['line' ~ i] or ' ' }}</div>
{% endfor %}
{% endif %}
</div>
</div>
{% if schedule_active %}
<p style="color: red;">Er is een dienst actief! Pas deze aan via de planning.</p>
{% endif %}
</div>
</form>
<!-- Right: Schedule section -->
<div class="schedule-section" style="flex: 1 1 380px; min-width:330px;">
<form method="post" style="margin-bottom: 1rem;">
<input type="hidden" name="form_type" value="name">
<div style="display:flex; gap:8px; align-items:center;">
<input id="boardNameInput" name="name" type="text" value="{{ board.name }}" style="font-size:1.1rem; padding:0.3em 0.6em; border-radius:0.3em; border:1px solid #aaa; width:60%;">
<button type="submit" class="bg-[#f7d91a] text-black text-xs px-6 py-3 rounded hover:bg-yellow-300 font-semibold transition" style="margin-left:5px;">Opslaan</button>
</div>
</form>
<h3 class="text-xl font-semibold mb-3">Geplande diensten</h3>
<div class="overflow-x-auto rounded-lg border border-gray-300">
{% if schedules|length == 0 %}
<p class="p-4">Geen geplande inhoud.</p>
{% else %}
<table class="min-w-full divide-y divide-gray-200 bg-white">
<thead class="bg-gray-100">
<tr>
<th class="px-6 py-3 text-left text-sm font-semibold text-gray-700">Naam</th>
<th class="px-6 py-3 text-sm font-semibold text-gray-700 text-center">Datum</th>
<th class="px-6 py-3 text-sm font-semibold text-gray-700 text-center">Starttijd</th>
<th class="px-6 py-3 text-sm font-semibold text-gray-700 text-center">Eindtijd</th>
<th class="px-6 py-3 text-sm font-semibold text-gray-700 text-center">Acties</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200">
{% for s in schedules %}
<tr>
<td class="whitespace-nowrap px-6 py-4 text-gray-900 flex items-center gap-2">
<span style="display:inline-block; width:0.75rem; height:0.75rem; border-radius:9999px;"
title="{% if schedule_active and active_schedule and s.id == active_schedule.id %}Actief{% else %}Niet actief{% endif %}"
class="{% if schedule_active and active_schedule and s.id == active_schedule.id %}bg-green-500{% else %}bg-red-500{% endif %}">
</span>
{{ s.name }}
</td>
<td class="px-6 py-4 text-center text-gray-900">{{ s.date.strftime('%Y-%m-%d') }}</td>
<td class="px-6 py-4 text-center text-gray-900">{{ s.start_time.strftime('%H:%M') }}</td>
<td class="px-6 py-4 text-center text-gray-900">{{ s.end_time.strftime('%H:%M') }}</td>
<td class="px-6 py-4 text-center">
<!-- Removed delete button here: schedules can't be deleted from board edit page -->
<a href="{{ url_for('edit_global_schedule', schedule_id=s.id) }}" class="bg-[#f7d91a] text-black text-xs px-6 py-3 rounded hover:bg-yellow-300 font-semibold transition">Bewerken</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
</div>
<a href="{{ url_for('add_global_schedule') }}" class="bg-[#f7d91a] text-black text-xs px-6 py-3 rounded hover:bg-yellow-300 font-semibold inline-block mt-3 transition">Dienst toevoegen</a>
<div class="button-group mt-3">
<button type="button" class="bg-white text-black border border-black px-6 py-3 rounded font-semibold hover:bg-gray-100 transition"
id="save-and-upload-bg"
data-edit-url="{{ url_for('edit_board', board_id=board.id) }}"
data-upload-url="{{ url_for('upload_background', board_id=board.id) }}">
Achtergrondafbeelding wijzigen
</button>
<!-- Removed Opslaan button as live save is implemented -->
</div>
</div>
</div>
</div>
{% if not schedule_active %}
<script>
// --- Line drag-and-swap logic ---
document.addEventListener('DOMContentLoaded', function () {
let dragSrc = null;
const lines = document.querySelectorAll('.liturgie-line[data-line]');
lines.forEach(line => {
line.addEventListener('dragstart', function (e) {
dragSrc = this;
this.classList.add('dragging');
});
line.addEventListener('dragend', function (e) {
this.classList.remove('dragging');
dragSrc = null;
lines.forEach(l => l.classList.remove('line-over'));
});
line.addEventListener('dragover', function (e) {
e.preventDefault();
if (this !== dragSrc) this.classList.add('line-over');
});
line.addEventListener('dragleave', function (e) {
this.classList.remove('line-over');
});
line.addEventListener('drop', function (e) {
e.preventDefault();
this.classList.remove('line-over');
if (dragSrc && dragSrc !== this) {
// Only swap textContent, not the whole element
const tmp = this.textContent;
this.textContent = dragSrc.textContent;
dragSrc.textContent = tmp;
// animation for both lines
this.classList.add('swap-animate');
dragSrc.classList.add('swap-animate');
setTimeout(() => {
this.classList.remove('swap-animate');
dragSrc.classList.remove('swap-animate');
}, 400);
// trigger input event for autosave
this.dispatchEvent(new Event('input'));
dragSrc.dispatchEvent(new Event('input'));
}
});
});
});
// --- End drag-and-swap logic ---
// On submit, copy preview lines to hidden fields
const boardForm = document.getElementById('boardForm');
if (boardForm) {
for (let i = 1; i <= 10; i++) {
// create hidden inputs for each line
let hidden = document.createElement('input');
hidden.type = 'hidden';
hidden.name = 'line'+i;
hidden.id = 'hidden-line'+i;
boardForm.appendChild(hidden);
}
async function saveLines() {
const formData = new FormData();
for (let i = 1; i <= 10; i++) {
const text = document.querySelector('.liturgie-line[data-line="line'+i+'"]').textContent;
formData.append('line'+i, text);
}
const response = await fetch(boardForm.action, {
method: 'POST',
body: formData,
headers: { 'X-Requested-With': 'XMLHttpRequest' }
});
if (!response.ok) {
console.error('Failed to save lines.');
}
}
// Add auto save event listeners to editable lines with debounce
for (let i = 1; i <= 10; i++) {
const editableLine = document.querySelector('.liturgie-line[data-line="line'+i+'"]');
if (editableLine) {
editableLine.addEventListener('input', () => {
if (window.autoSaveTimeout) clearTimeout(window.autoSaveTimeout);
window.autoSaveTimeout = setTimeout(saveLines, 1000);
});
}
}
// Prevent form's default submit to avoid page reload on autosave
boardForm.addEventListener('submit', function(e) {
if (document.getElementById('redirect_to_upload').value === '0') {
e.preventDefault();
saveLines();
}
});
}
// Added script for wallpaper button
document.addEventListener('DOMContentLoaded', function() {
const btn = document.getElementById('save-and-upload-bg');
if (btn) {
btn.addEventListener('click', function() {
const uploadUrl = this.getAttribute('data-upload-url');
if (uploadUrl) {
window.location.href = uploadUrl;
}
});
}
});
</script>
{% else %}
<script>
// When schedule is active, there's nothing to submit for the lines and nothing to sync.
</script>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,43 @@
{% extends 'base.html' %}
{% block title %}Globale Planningen{% endblock %}
{% block content %}
<div class="space-y-8 max-w-6xl mx-auto px-4">
<h1 class="text-3xl font-semibold mb-6">Globale planningen</h1>
<div class="overflow-x-auto rounded-lg border border-gray-300">
<table class="min-w-full divide-y divide-gray-200 bg-white">
<thead class="bg-gray-100">
<tr>
<th class="px-6 py-3 text-left text-sm font-semibold text-gray-700">Naam</th>
<th class="px-6 py-3 text-sm font-semibold text-gray-700 text-center">Datum</th>
<th class="px-6 py-3 text-sm font-semibold text-gray-700 text-center">Starttijd</th>
<th class="px-6 py-3 text-sm font-semibold text-gray-700 text-center">Eindtijd</th>
<th class="px-6 py-3 text-sm font-semibold text-gray-700 text-center">Toegewezen borden</th>
<th class="px-6 py-3 text-sm font-semibold text-gray-700 text-center">Acties</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200">
{% for schedule in schedules %}
<tr>
<td class="whitespace-nowrap px-6 py-4 text-gray-900">{{ schedule.name }}</td>
<td class="px-6 py-4 text-center text-gray-900">{{ schedule.date.strftime('%Y-%m-%d') }}</td>
<td class="px-6 py-4 text-center text-gray-900">{{ schedule.start_time.strftime('%H:%M') }}</td>
<td class="px-6 py-4 text-center text-gray-900">{{ schedule.end_time.strftime('%H:%M') }}</td>
<td class="px-6 py-4 text-center text-gray-900">
{% for board in schedule.boards %}{{ board.name }}{% if not loop.last %}, {% endif %}{% endfor %}
</td>
<td class="px-6 py-4 text-center">
<a href="{{ url_for('edit_global_schedule', schedule_id=schedule.id) }}" class="bg-[#f7d91a] text-black text-xs px-6 py-3 rounded font-semibold hover:bg-yellow-300 transition">Bewerken</a>
<form action="{{ url_for('delete_global_schedule', schedule_id=schedule.id) }}" method="POST" style="display:inline;" onsubmit="return confirm('Weet je zeker dat je deze planning wilt verwijderen?');">
<button type="submit" class="bg-red-600 text-white text-xs px-6 py-3 rounded font-semibold hover:bg-red-700 transition">Verwijderen</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<a href="{{ url_for('add_global_schedule') }}" class="bg-[#f7d91a] text-black px-6 py-3 rounded-md font-semibold shadow hover:bg-yellow-300 inline-block mb-6 transition">Nieuwe planning toevoegen</a>
</div>
{% endblock %}

337
templates/index.html Normal file
View File

@@ -0,0 +1,337 @@
{% extends "base.html" %}
{% block title %}Welkom bij Digitale Liturgie{% endblock %}
{% block content %}
<style>
/* Modern Google Fonts */
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&display=swap');
body { font-family: 'Inter', sans-serif; }
.hero-section {
position: relative;
background: url("{{ url_for('static', filename='Hero.jpg') }}") no-repeat center center/cover;
min-height: 30rem;
border-radius: 1.15rem;
overflow: hidden;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
color: white;
animation: fadeInHero 1.2s cubic-bezier(.77,0,.18,1) both;
}
@keyframes fadeInHero {
from { opacity: 0; transform: translateY(-30px);}
to { opacity: 1; transform: translateY(0);}
}
.hero-overlay {
position: absolute;
inset: 0;
background: linear-gradient(135deg, rgba(40,40,50,0.75) 0%, rgba(10,0,40,0.40) 70%);
z-index: 1;
border-radius: 1.15rem;
}
.hero-content {
position: relative;
z-index: 2;
max-width: 42rem;
text-align: center;
padding: 2rem 2rem;
animation: fadeUp 1.2s .3s cubic-bezier(.77,0,.18,1) both;
}
.hero-content h1 {
font-size: 3rem;
font-weight: 700;
text-shadow: 0 5px 32px rgba(0,0,0,0.32);
margin-bottom: 0.5rem;
letter-spacing: -1.2px;
line-height: 1.1;
}
.hero-content p {
font-size: 1.25rem;
margin-bottom: 2rem;
color: #f4f4f4;
}
.modern-btn {
background: linear-gradient(90deg, #f7d91a 0%, #ffe486 100%);
color: #181818;
font-weight: 600;
border: none;
padding: 1rem 2.3rem;
border-radius: 0.66rem;
font-size: 1.14rem;
box-shadow: 0 4px 24px rgba(247,217,26,0.13);
transition: box-shadow 0.23s, background 0.23s, transform 0.18s;
cursor: pointer;
}
.modern-btn:hover {
background: linear-gradient(90deg,#ffe486 0%, #f7d91a 100%);
box-shadow: 0 8px 28px rgba(247,217,26,0.19);
transform: translateY(-2px) scale(1.045);
color: #25201b;
}
/* Contact section styling */
.contact-section {
background: white;
border-radius: 1.15rem;
margin-top: 1rem;
padding: 2.2rem 1.5rem;
box-shadow: 0 8px 24px rgba(0,0,0,0.09);
max-width: 98vw;
min-width: 280px;
animation: fadeUp 1.1s .6s cubic-bezier(.77,0,.18,1) both;
}
.contact-section h2 {
font-size: 1.8rem;
font-weight: 600;
margin-bottom: 1.5rem;
}
.contact-section input,
.contact-section textarea {
width: 100%;
border: 1.3px solid #e6e6e6;
border-radius: 0.5rem;
padding: 0.9rem 1.14rem;
font-size: 1rem;
margin-bottom: 1rem;
background: #f8f9fa;
transition: border-color 0.2s;
}
.contact-section input:focus,
.contact-section textarea:focus {
border-color: #f7d91a;
outline: none;
background: #fffde6;
}
.contact-section button {
margin-top: 0.3rem;
width: 100%;
}
/* Cards Area */
.features-area {
display: grid;
gap: 2.2rem;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
max-width: 1200px;
margin: 0 auto;
margin-top: 2.2rem;
margin-bottom: 2.5rem;
animation: fadeInStagger 1s cubic-bezier(.77,0,.18,1) both;
}
@keyframes fadeUp {
from { opacity: 0; transform: translateY(50px);}
to { opacity: 1; transform: translateY(0);}
}
@keyframes fadeInStagger {
from { opacity: 0;}
to { opacity: 1;}
}
.feature-card {
background: white;
border-radius: 1.09rem;
box-shadow: 0 6px 20px rgba(0,0,0,0.06);
padding: 2rem 1.2rem 1.4rem 1.2rem;
text-align: center;
min-height: 19.5rem;
animation: fadeUp 0.8s cubic-bezier(.77,0,.18,1) both;
transition: transform 0.22s, box-shadow 0.22s;
will-change: transform;
/* Animation delay stagger per card (manual in HTML) */
}
.feature-card:hover {
transform: translateY(-7px) scale(1.02);
box-shadow: 0 16px 40px rgba(247,217,26,0.13);
}
.feature-card img {
max-width: 55px;
margin-bottom: 1.1rem;
margin-top: -0.8rem;
filter: drop-shadow(0 0 2px rgba(40,40,40,0.06));
transition: transform 0.17s;
}
.feature-card:hover img {
transform: scale(1.095) rotate(2deg);
}
.feature-card h3 {
font-size: 1.23rem;
margin-bottom: 0.9rem;
font-weight: 700;
color: #181a1d;
letter-spacing: -0.02em;
}
.feature-card ul {
text-align: left;
color: #434243;
font-size: 1.02rem;
list-style: none;
padding-left: 0.7em;
line-height: 1.7;
}
.feature-card ul li::before {
content: '• ';
color: #f7d91a;
font-weight: bold;
}
/* Responsive hero/contact split */
@media (min-width: 1000px) {
.hero-container {
display: flex;
gap: 2.4rem;
max-width: 1330px;
margin: 0 auto 3.3rem auto;
}
.hero-section, .contact-section {
flex: 1.2;
min-height: 32rem;
}
.contact-section {
min-width: 350px;
margin-top: 0;
max-width: 400px;
}
}
@media (max-width: 780px) {
.hero-section {
min-height: 22rem;
}
.hero-content h1 { font-size: 2rem;}
.features-area { gap: 1rem;}
.feature-card { padding: 1.3rem 0.5rem; }
}
</style>
<div class="hero-section" style="margin-bottom:2.5rem;">
<div class="hero-overlay"></div>
<div class="hero-content">
<h1>Psalmbord Online</h1>
<p>
Welkom bij Psalmbord Online.<br>
Beheer, plan en presenteer moeiteloos je kerkdiensten.<br>
Start direct met een eigen account!
</p>
<a href="/login" class="modern-btn">Log in</a>
</div>
</div>
<div class="section-intro" style="margin-top: 3.7rem; margin-bottom:1.5rem; animation: fadeUp 1.1s .3s cubic-bezier(.77,0,.18,1) both;">
<h2 class="text-3xl font-bold text-center mb-2" style="font-size:2.1rem;">Wat kan Digitale Liturgie voor u doen?</h2>
<p class="text-center text-md mb-8" style="color:#62666b;">Beheer en presenteer moeiteloos uw kerkdiensten, gebruikers en schermen.</p>
</div>
<div class="features-area">
<div class="feature-card" style="animation-delay:.09s;">
<img src="{{ url_for('static', filename='icons/group.png') }}" alt="Gebruikersbeheer">
<h3>Gebruikersbeheer</h3>
<ul>
<li>Aanmelden & Registreren</li>
<li>Wachtwoordbeheer</li>
<li>Lid & Admin-rollen</li>
</ul>
</div>
<div class="feature-card" style="animation-delay:.20s;">
<img src="{{ url_for('static', filename='icons/church.png') }}" alt="Kerkenbeheer">
<h3>Kerkenbeheer</h3>
<ul>
<li>Kerk aanmaken & aanpassen</li>
<li>Logo uploaden</li>
<li>Activatie beheren</li>
</ul>
</div>
<div class="feature-card" style="animation-delay:.35s;">
<img src="{{ url_for('static', filename='icons/board.png') }}" alt="Bordenbeheer">
<h3>Bordenbeheer</h3>
<ul>
<li>Borden toevoegen, bewerken, verwijderen</li>
<li>Max. 10 tekstregels per bord</li>
<li>Achtergrond instellen of uploaden</li>
</ul>
</div>
<div class="feature-card" style="animation-delay:.50s;">
<img src="{{ url_for('static', filename='icons/task.png') }}" alt="Planningen">
<h3>Planningen</h3>
<ul>
<li>Diensten & liturgie-invulling per datum</li>
<li>Meerdere borden per planning</li>
<li>Automatisch tonen volgens tijd</li>
</ul>
</div>
</div>
<!-- Contact form at BOTTOM -->
<div class="contact-section" style="max-width:none; margin:3.8rem 0 0 0; width:100%;">
<h2>Contacteer ons</h2>
<form method="POST" action="/contact" autocomplete="off">
<div class="contact-form-fields">
<div class="inputs-left">
<input type="text" id="name" name="name" placeholder="Naam" required>
<input type="text" id="church" name="church" placeholder="Kerknaam" required>
<input type="tel" id="phone" name="phone" placeholder="Telefoonnummer" required>
<input type="email" id="email" name="email" placeholder="E-mail" required>
</div>
<div class="textarea-right">
<textarea id="message" name="message" rows="7" placeholder="Uw bericht..." required></textarea>
</div>
</div>
<button type="submit" class="modern-btn">Verstuur</button>
</form>
<style>
.contact-form-fields {
display: flex;
gap: 2.2rem;
align-items: stretch;
}
.inputs-left {
flex: 1 1 210px;
display: flex;
flex-direction: column;
gap: 1rem;
min-width: 190px;
}
.inputs-left input {
opacity: 0;
transform: translateY(32px);
animation: fadeFormInput 0.8s cubic-bezier(.77,0,.18,1) forwards;
}
.inputs-left input:nth-child(1) { animation-delay: 0.18s; }
.inputs-left input:nth-child(2) { animation-delay: 0.32s; }
.inputs-left input:nth-child(3) { animation-delay: 0.46s; }
.inputs-left input:nth-child(4) { animation-delay: 0.6s; }
.textarea-right textarea {
opacity: 0;
transform: translateY(32px);
animation: fadeFormInput 0.8s 0.74s cubic-bezier(.77,0,.18,1) forwards;
height: 100%;
min-height: 150px;
resize: vertical;
}
.contact-section button.modern-btn {
opacity: 0;
transform: translateY(32px);
animation: fadeFormInput 0.8s 1s cubic-bezier(.77,0,.18,1) forwards;
}
@keyframes fadeFormInput {
from { opacity: 0; transform: translateY(32px); }
to { opacity: 1; transform: translateY(0); }
}
@media (max-width: 800px) {
.contact-form-fields {
flex-direction: column;
gap: 0;
}
.textarea-right, .inputs-left {
min-width: 0;
}
.textarea-right textarea {
min-height: 100px;
}
}
</style>
</div>
{% endblock %}

27
templates/login.html Normal file
View File

@@ -0,0 +1,27 @@
{% extends 'base.html' %}
{% block title %}Login - Digitale Liturgie{% endblock %}
{% block content %}
<div class="space-y-8 max-w-6xl mx-auto px-4">
<div class="max-w-md mx-auto">
<h2 class="text-3xl font-semibold mb-6">Login</h2>
<form method="post" class="space-y-6">
<div>
<label for="username" class="block mb-3 font-semibold text-gray-700">Gebruikersnaam</label>
<input type="text" id="username" name="username" required class="w-full rounded-md border border-gray-300 p-4 text-lg placeholder-gray-400 shadow-sm focus:border-blue-600 focus:ring focus:ring-blue-300 focus:ring-opacity-50 transition">
</div>
<div>
<label for="password" class="block mb-3 font-semibold text-gray-700">Wachtwoord</label>
<input type="password" id="password" name="password" required class="w-full rounded-md border border-gray-300 p-4 text-lg placeholder-gray-400 shadow-sm focus:border-blue-600 focus:ring focus:ring-blue-300 focus:ring-opacity-50 transition">
</div>
<button type="submit" class="bg-[#f7d91a] text-black w-full rounded-md py-4 font-semibold shadow hover:bg-yellow-300 transition">Login</button>
</form>
</div>
</div>
{% endblock %}

106
templates/portal.html Normal file
View File

@@ -0,0 +1,106 @@
{% extends 'base.html' %}
{% block content %}
<div class="space-y-8 max-w-6xl mx-auto px-4">
<h2 class="text-3xl font-semibold mb-6">Welkom, {{ current_user.username }}!</h2>
<div class="overflow-x-auto rounded-lg border border-gray-300">
<table class="min-w-full divide-y divide-gray-200 bg-white">
<thead class="bg-gray-100">
<tr>
<th class="px-6 py-3 text-left text-sm font-semibold text-gray-700">Naam</th>
<th class="px-6 py-3 text-sm font-semibold text-gray-700">URL</th>
<th class="px-6 py-3 text-center text-sm font-semibold text-gray-700">Bewerken</th>
<th class="px-6 py-3 text-center text-sm font-semibold text-gray-700">Weergeven</th>
<th class="px-6 py-3 text-center text-sm font-semibold text-gray-700">Verwijderen</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200">
{% for board in boards %}
<tr>
<td class="whitespace-nowrap px-6 py-4 text-gray-900">{{ board.name }}</td>
<td class="px-6 py-4 text-blue-600 break-words">
{% if board.church.is_active %}
<a href="{{ url_for('display_board_unique', unique_id=board.unique_id) }}" target="_blank" class="underline hover:text-blue-800 transition">display/{{ board.unique_id }}</a>
{% else %}
<em>Geen actieve licentie voor dit scherm</em>
{% endif %}
</td>
<td class="text-center px-6 py-4">
<a href="{{ url_for('edit_board', board_id=board.id) }}" class="bg-[#f7d91a] text-black text-xs px-2 py-1 rounded hover:bg-yellow-300 transition">Bewerken</a>
</td>
<td class="relative text-center px-6 py-4">
{% if board.church.is_active %}
<a href="{{ url_for('display_board_unique', unique_id=board.unique_id) }}" class="bg-black text-white text-xs px-2 py-1 rounded border border-black hover:bg-gray-800 transition show-iframe-preview" target="_blank" data-url="{{ url_for('display_board_unique', unique_id=board.unique_id) }}">Weergeven</a>
<div class="iframe-preview-popup" style="display:none; position:absolute; top:0; left:100%; margin-left:8px; width:400px; height:225px; border:1px solid #ccc; box-shadow:0 2px 8px rgba(0,0,0,0.2); z-index:50;">
<iframe src="" frameborder="0" style="width:100%; height:100%; transform-origin: top left; transform: scale(0.2);"></iframe>
</div>
{% endif %}
</td>
<td class="text-center px-6 py-4">
<form method="post" action="{{ url_for('delete_board', board_id=board.id) }}" style="display:inline;">
<button type="submit" class="bg-white text-black text-xs px-2 py-1 rounded border border-black hover:bg-gray-100 transition" onclick="return confirm('Weet je zeker dat je dit bord wilt verwijderen?');">Verwijderen</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<a href="{{ url_for('add_board') }}" class="bg-[#f7d91a] text-black px-6 py-3 rounded-md font-semibold shadow hover:bg-yellow-300 inline-block mb-6 transition">Nieuw liturgiebord toevoegen</a>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
const showIframeLinks = document.querySelectorAll('.show-iframe-preview');
// Create a single popup container to be reused
const popup = document.createElement('div');
popup.style.position = 'absolute';
popup.style.width = '1080px';
popup.style.height = '1920px';
popup.style.border = '1px solid #ccc';
popup.style.boxShadow = '0 2px 8px rgba(0,0,0,0.2)';
popup.style.zIndex = '9999';
popup.style.display = 'none';
popup.style.background = 'white';
popup.style.transformOrigin = 'top left';
popup.style.transform = 'scale(0.2)';
// iframe inside popup
const iframe = document.createElement('iframe');
iframe.frameBorder = 0;
iframe.style.width = '100%';
iframe.style.height = '100%';
iframe.style.transform = '';
iframe.style.transformOrigin = 'top left';
popup.appendChild(iframe);
document.body.appendChild(popup);
showIframeLinks.forEach(link => {
link.addEventListener('mouseenter', (event) => {
const url = link.getAttribute('data-url');
iframe.src = url;
const rect = link.getBoundingClientRect();
// Position popup to the right and vertically aligned with the link
popup.style.left = (window.scrollX + rect.right + 8) + 'px';
popup.style.top = (window.scrollY + rect.top) + 'px';
popup.style.display = 'block';
});
link.addEventListener('mouseleave', () => {
popup.style.display = 'none';
iframe.src = '';
});
});
// Also hide popup if mouse moves out of popup itself
popup.addEventListener('mouseleave', () => {
popup.style.display = 'none';
iframe.src = '';
});
});
</script>
{% endblock %}

32
templates/register.html Normal file
View File

@@ -0,0 +1,32 @@
{% extends 'base.html' %}
{% block title %}Register - Digitale Liturgie{% endblock %}
{% block content %}
<div class="space-y-8 max-w-6xl mx-auto px-4">
<div class="max-w-md mx-auto">
<h2 class="text-3xl font-semibold mb-6">Register</h2>
<form method="post" class="space-y-6">
<div>
<label for="username" class="block mb-3 font-semibold text-gray-700">Emailadres</label>
<input type="email" id="username" name="username" required class="w-full rounded-md border border-gray-300 p-4 text-lg placeholder-gray-400 shadow-sm focus:border-blue-600 focus:ring focus:ring-blue-300 focus:ring-opacity-50 transition">
</div>
<div>
<label for="password" class="block mb-3 font-semibold text-gray-700">Wachtwoord</label>
<input type="password" id="password" name="password" required class="w-full rounded-md border border-gray-300 p-4 text-lg placeholder-gray-400 shadow-sm focus:border-blue-600 focus:ring focus:ring-blue-300 focus:ring-opacity-50 transition">
</div>
<div>
<label for="church" class="block mb-3 font-semibold text-gray-700">Kerk</label>
<input type="text" id="church" name="church" placeholder="Kies of maak een kerk" required class="w-full rounded-md border border-gray-300 p-4 text-lg placeholder-gray-400 shadow-sm focus:border-blue-600 focus:ring focus:ring-blue-300 focus:ring-opacity-50 transition">
</div>
<button type="submit" class="bg-[#f7d91a] text-black w-full rounded-md py-4 font-semibold shadow hover:bg-yellow-300 transition">Register</button>
</form>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,274 @@
{% extends 'base.html' %}
{% block title %}{% if schedule %}Schema Bijwerken{% else %}Schema Toevoegen{% endif %}{% endblock %}
{% block content %}
<style>
.preview-container {
display: flex;
gap: 2rem;
align-items: flex-start;
flex-wrap: wrap;
max-width: 900px;
margin: 0 auto 2rem auto;
background: white;
padding: 1rem;
border-radius: 0.5rem;
}
.preview-wrapper {
width: 250px;
height: 444px;
background: #222;
border-radius: 0.5rem;
overflow: hidden;
position: relative;
background-size: cover;
background-position: center;
}
.liturgie-board {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
align-items: flex-start;
gap: 0.6vh;
padding-left: 5%;
position: relative;
z-index: 2;
}
.liturgie-line {
width: 90%;
font-size: 28px;
line-height: 1.2;
text-shadow: 2px 2px 8px #000, 0 0 4px #000;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin: 0;
padding: 0;
color: #fff;
text-align: left;
outline: none;
cursor: text;
user-select: text;
}
/* Highlight for drag-over between lines in preview */
.liturgie-line.line-over {
background: rgba(255,255,255,0.22);
border: 2px dashed #fff;
box-sizing: border-box;
outline: none;
transition: background 0.12s, border 0.12s;
}
.liturgie-line.swap-animate {
animation: swap-pulse 0.35s;
}
@keyframes swap-pulse {
0% { background: #ffe066; }
60% { background: #fff3cd; }
100% { background: none; }
}
.input-section {
flex: 1 1 260px;
min-width: 260px;
display: flex;
flex-direction: column;
gap: 1rem;
}
.input-section label {
font-weight: 700;
color: #374151;
display: block;
margin-bottom: 0.25rem;
}
.input-section input {
width: 100%;
padding: 0.5rem 0.75rem;
border: 1px solid #d1d5db;
border-radius: 0.375rem;
font-size: 1rem;
outline: none;
box-shadow: inset 0 1px 2px rgb(0 0 0 / 0.1);
transition: border-color 0.2s ease-in-out, box-shadow 0.2s ease-in-out;
}
.input-section input:focus {
border-color: #2563eb;
box-shadow: 0 0 5px #93c5fd;
}
.save-button {
padding: 0.75rem 1.25rem;
background-color: #16a34a;
color: white;
border-radius: 0.375rem;
font-weight: 700;
border: none;
cursor: pointer;
transition: background-color 0.2s ease-in-out;
margin-top: auto;
}
.save-button:hover {
background-color: #15803d;
}
@media (max-width: 767px) {
.preview-container {
flex-direction: column;
max-width: 100%;
margin-bottom: 1rem;
}
.preview-wrapper {
width: 200px;
height: 355px;
margin: 0 auto 1rem auto;
}
.input-section {
min-width: unset;
}
}
</style>
<h1 class="text-3xl font-bold mb-6 text-center">{% if schedule %}Schema Bijwerken{% else %}Schema Toevoegen{% endif %}</h1>
<form method="post" id="scheduleForm">
<div class="preview-container">
<!-- Left: Live preview with editable lines -->
<div class="preview-wrapper" style="font-family: 'Lora', serif; position: relative; background-image: {% if schedule and schedule.boards_list|length > 0 %}
{% set board_bg = schedule.boards_list[0].background_image if schedule.boards_list[0].background_image else None %}
{% if board_bg %}
url('{% if board_bg.startswith('default_') %}{{ url_for('static', filename=board_bg) }}{% else %}{{ url_for('uploaded_file', filename=board_bg) }}{% endif %}')
{% else %}none
{% endif %}
{% else %}none
{% endif %}; background-color:#222; background-size: cover; background-position: center;">
<div class="liturgie-bg-overlay" style="
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.3);
z-index: 1;
pointer-events: none;
"></div>
<div class="liturgie-board" contenteditable="false" style="position: relative; z-index: 2; font-family: 'Lora', serif;">
{% set lines = schedule.content.split('\n') if schedule and schedule.content else [' ']*10 %}
{% for i in range(10) %}
<div class="liturgie-line" contenteditable="true" data-line="line{{ i+1 }}" spellcheck="false">{{ lines[i] if i < lines|length else ' ' }}</div>
{% endfor %}
</div>
</div>
<!-- Right: Input fields for schedule name, date, time, board assignment, and save button -->
<div class="input-section">
<label for="name">Naam:</label>
<input type="text" id="name" name="name" required value="{{ schedule.name if schedule else '' }}">
<label for="date">Datum:</label>
<input type="date" id="date" name="date" required value="{{ schedule.date.strftime('%Y-%m-%d') if schedule and schedule.date else '' }}">
<div style="display: flex; gap: 1rem; align-items: center;">
<div style="flex: 1;">
<label for="start_time">Starttijd:</label>
<input type="time" id="start_time" name="start_time" required value="{{ schedule.start_time.strftime('%H:%M') if schedule and schedule.start_time else '' }}">
</div>
<div style="flex: 1;">
<label for="end_time">Eindtijd:</label>
<input type="time" id="end_time" name="end_time" required value="{{ schedule.end_time.strftime('%H:%M') if schedule and schedule.end_time else '' }}">
</div>
</div>
<label>Toegewezen borden:</label>
<div class="overflow-hidden rounded-lg max-h-56 overflow-y-auto mb-6 border border-gray-200">
<table class="min-w-full divide-y divide-gray-200 border-collapse">
<thead class="bg-gray-50">
<tr class="divide-x divide-gray-200">
<th scope="col" class="py-3.5 pl-4 pr-4 text-left text-sm font-semibold text-gray-900 sm:pl-6">Naam</th>
<th scope="col" class="px-4 py-3.5 text-center text-sm font-semibold text-gray-900">Toewijzen</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 bg-white">
{% for board in boards %}
<tr class="divide-x divide-gray-200 hover:bg-gray-50 cursor-pointer">
<td class="whitespace-nowrap py-4 pl-4 pr-4 text-sm font-semibold text-gray-900 sm:pl-6">{{ board.name }}</td>
<td class="whitespace-nowrap px-4 py-4 text-center text-sm font-semibold text-gray-900 flex justify-center items-center">
<label class="relative inline-flex items-center cursor-pointer">
<input type="checkbox" value="{{ board.id }}" name="boards" class="sr-only peer" {% if schedule and board in schedule.boards_list %}checked{% endif %}>
<div class="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 rounded-full peer peer-checked:bg-blue-600 transition-all duration-300"></div>
<div class="absolute left-1 top-1 bg-white w-4 h-4 rounded-full transition-transform duration-300 peer-checked:translate-x-5"></div>
</label>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<button type="submit" class="bg-[#f7d91a] text-black px-8 py-3 rounded font-semibold hover:bg-yellow-300 transition">Opslaan</button>
</div>
</div>
<!-- Hidden inputs to store lines for form submission -->
{% for i in range(1, 11) %}
<input type="hidden" name="line{{ i }}" id="hidden-line{{ i }}" value="">
{% endfor %}
</form>
<script>
// --- Line drag-and-swap logic ---
document.addEventListener('DOMContentLoaded', function () {
let dragSrc = null;
const lines = document.querySelectorAll('.liturgie-line[data-line]');
lines.forEach(line => {
line.addEventListener('dragstart', function (e) {
dragSrc = this;
this.classList.add('dragging');
});
line.addEventListener('dragend', function (e) {
this.classList.remove('dragging');
dragSrc = null;
lines.forEach(l => l.classList.remove('line-over'));
});
line.addEventListener('dragover', function (e) {
e.preventDefault();
if (this !== dragSrc) this.classList.add('line-over');
});
line.addEventListener('dragleave', function (e) {
this.classList.remove('line-over');
});
line.addEventListener('drop', function (e) {
e.preventDefault();
this.classList.remove('line-over');
if (dragSrc && dragSrc !== this) {
// Only swap textContent, not the whole element
const tmp = this.textContent;
this.textContent = dragSrc.textContent;
dragSrc.textContent = tmp;
// animation for both lines
this.classList.add('swap-animate');
dragSrc.classList.add('swap-animate');
setTimeout(() => {
this.classList.remove('swap-animate');
dragSrc.classList.remove('swap-animate');
}, 400);
}
});
// Make lines draggable
line.setAttribute('draggable', 'true');
});
});
// --- End drag-and-swap logic ---
document.getElementById('scheduleForm').addEventListener('submit', function(e) {
// Copy content from editable divs to hidden inputs
for (let i = 1; i <= 10; i++) {
let div = document.querySelector('.liturgie-line[data-line="line' + i + '"]');
let hiddenInput = document.getElementById('hidden-line' + i);
if (div && hiddenInput) {
hiddenInput.value = div.textContent.trim() || ' ';
}
}
});
// Optional: keyboard navigation and basic shortcuts could be added here
</script>
{% endblock %}

View File

@@ -0,0 +1,44 @@
{% extends 'base.html' %}
{% block title %}Achtergrondafbeelding wijzigen - Digitale Liturgie{% endblock %}
{% block content %}
<div class="max-w-lg mx-auto">
<h2 class="text-3xl font-semibold mb-6">Achtergrondafbeelding wijzigen voor: {{ board.name }}</h2>
<div class="flex space-x-8">
<form method="post" enctype="multipart/form-data" class="space-y-6 flex-1">
<div>
<label for="background" class="block mb-2 font-semibold text-gray-700">Kies een afbeelding</label>
<input type="file" id="background" name="background" accept="image/*" required class="w-full rounded-md border border-gray-300 p-3 text-base placeholder-gray-400 shadow-sm focus:border-blue-600 focus:ring focus:ring-blue-300 focus:ring-opacity-50 transition">
</div>
<button type="submit" class="bg-[#f7d91a] text-black w-full rounded-md py-3 font-semibold shadow hover:bg-yellow-300 transition">Uploaden</button>
<a href="{{ url_for('edit_board', board_id=board.id) }}" class="block text-center bg-white text-black border border-black rounded-md py-3 mt-2 font-semibold shadow hover:bg-gray-100 transition w-full">Annuleren</a>
</form>
{% if board.background_image %}
<div class="flex-1">
<p class="mb-2">Huidige achtergrond:</p>
<img src="{{ url_for('uploaded_file', filename=board.background_image) }}" alt="Achtergrond" class="rounded-lg max-w-full max-h-72 object-contain">
</div>
{% endif %}
</div>
<div class="mt-10">
<h3 class="text-xl font-semibold mb-4">Kies een standaard achtergrond:</h3>
<div class="grid grid-cols-4 gap-4">
<form method="post" class="inline-block">
<input type="hidden" name="default_background" value="default_wall_1.jpg">
<img src="/static/default_wall_1.jpg" alt="Standaard achtergrond 1" class="cursor-pointer rounded-lg border border-gray-300 hover:border-blue-600 transition" onclick="this.closest('form').submit()">
</form>
<form method="post" class="inline-block">
<input type="hidden" name="default_background" value="default_wall_2.jpg">
<img src="/static/default_wall_2.jpg" alt="Standaard achtergrond 2" class="cursor-pointer rounded-lg border border-gray-300 hover:border-blue-600 transition" onclick="this.closest('form').submit()">
</form>
<form method="post" class="inline-block">
<input type="hidden" name="default_background" value="default_wall_3.jpg">
<img src="/static/default_wall_3.jpg" alt="Standaard achtergrond 3" class="cursor-pointer rounded-lg border border-gray-300 hover:border-blue-600 transition" onclick="this.closest('form').submit()">
</form>
<form method="post" class="inline-block">
<input type="hidden" name="default_background" value="default_wall_4.jpg">
<img src="/static/default_wall_4.jpg" alt="Standaard achtergrond 4" class="cursor-pointer rounded-lg border border-gray-300 hover:border-blue-600 transition" onclick="this.closest('form').submit()">
</form>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,164 @@
/* -*- indent-tabs-mode: nil; tab-width: 4; -*- */
/* Greenlet object interface */
#ifndef Py_GREENLETOBJECT_H
#define Py_GREENLETOBJECT_H
#include <Python.h>
#ifdef __cplusplus
extern "C" {
#endif
/* This is deprecated and undocumented. It does not change. */
#define GREENLET_VERSION "1.0.0"
#ifndef GREENLET_MODULE
#define implementation_ptr_t void*
#endif
typedef struct _greenlet {
PyObject_HEAD
PyObject* weakreflist;
PyObject* dict;
implementation_ptr_t pimpl;
} PyGreenlet;
#define PyGreenlet_Check(op) (op && PyObject_TypeCheck(op, &PyGreenlet_Type))
/* C API functions */
/* Total number of symbols that are exported */
#define PyGreenlet_API_pointers 12
#define PyGreenlet_Type_NUM 0
#define PyExc_GreenletError_NUM 1
#define PyExc_GreenletExit_NUM 2
#define PyGreenlet_New_NUM 3
#define PyGreenlet_GetCurrent_NUM 4
#define PyGreenlet_Throw_NUM 5
#define PyGreenlet_Switch_NUM 6
#define PyGreenlet_SetParent_NUM 7
#define PyGreenlet_MAIN_NUM 8
#define PyGreenlet_STARTED_NUM 9
#define PyGreenlet_ACTIVE_NUM 10
#define PyGreenlet_GET_PARENT_NUM 11
#ifndef GREENLET_MODULE
/* This section is used by modules that uses the greenlet C API */
static void** _PyGreenlet_API = NULL;
# define PyGreenlet_Type \
(*(PyTypeObject*)_PyGreenlet_API[PyGreenlet_Type_NUM])
# define PyExc_GreenletError \
((PyObject*)_PyGreenlet_API[PyExc_GreenletError_NUM])
# define PyExc_GreenletExit \
((PyObject*)_PyGreenlet_API[PyExc_GreenletExit_NUM])
/*
* PyGreenlet_New(PyObject *args)
*
* greenlet.greenlet(run, parent=None)
*/
# define PyGreenlet_New \
(*(PyGreenlet * (*)(PyObject * run, PyGreenlet * parent)) \
_PyGreenlet_API[PyGreenlet_New_NUM])
/*
* PyGreenlet_GetCurrent(void)
*
* greenlet.getcurrent()
*/
# define PyGreenlet_GetCurrent \
(*(PyGreenlet * (*)(void)) _PyGreenlet_API[PyGreenlet_GetCurrent_NUM])
/*
* PyGreenlet_Throw(
* PyGreenlet *greenlet,
* PyObject *typ,
* PyObject *val,
* PyObject *tb)
*
* g.throw(...)
*/
# define PyGreenlet_Throw \
(*(PyObject * (*)(PyGreenlet * self, \
PyObject * typ, \
PyObject * val, \
PyObject * tb)) \
_PyGreenlet_API[PyGreenlet_Throw_NUM])
/*
* PyGreenlet_Switch(PyGreenlet *greenlet, PyObject *args)
*
* g.switch(*args, **kwargs)
*/
# define PyGreenlet_Switch \
(*(PyObject * \
(*)(PyGreenlet * greenlet, PyObject * args, PyObject * kwargs)) \
_PyGreenlet_API[PyGreenlet_Switch_NUM])
/*
* PyGreenlet_SetParent(PyObject *greenlet, PyObject *new_parent)
*
* g.parent = new_parent
*/
# define PyGreenlet_SetParent \
(*(int (*)(PyGreenlet * greenlet, PyGreenlet * nparent)) \
_PyGreenlet_API[PyGreenlet_SetParent_NUM])
/*
* PyGreenlet_GetParent(PyObject* greenlet)
*
* return greenlet.parent;
*
* This could return NULL even if there is no exception active.
* If it does not return NULL, you are responsible for decrementing the
* reference count.
*/
# define PyGreenlet_GetParent \
(*(PyGreenlet* (*)(PyGreenlet*)) \
_PyGreenlet_API[PyGreenlet_GET_PARENT_NUM])
/*
* deprecated, undocumented alias.
*/
# define PyGreenlet_GET_PARENT PyGreenlet_GetParent
# define PyGreenlet_MAIN \
(*(int (*)(PyGreenlet*)) \
_PyGreenlet_API[PyGreenlet_MAIN_NUM])
# define PyGreenlet_STARTED \
(*(int (*)(PyGreenlet*)) \
_PyGreenlet_API[PyGreenlet_STARTED_NUM])
# define PyGreenlet_ACTIVE \
(*(int (*)(PyGreenlet*)) \
_PyGreenlet_API[PyGreenlet_ACTIVE_NUM])
/* Macro that imports greenlet and initializes C API */
/* NOTE: This has actually moved to ``greenlet._greenlet._C_API``, but we
keep the older definition to be sure older code that might have a copy of
the header still works. */
# define PyGreenlet_Import() \
{ \
_PyGreenlet_API = (void**)PyCapsule_Import("greenlet._C_API", 0); \
}
#endif /* GREENLET_MODULE */
#ifdef __cplusplus
}
#endif
#endif /* !Py_GREENLETOBJECT_H */

View File

@@ -0,0 +1 @@
pip

View File

@@ -0,0 +1,22 @@
Copyright (c) 2011 Matthew Frazier
Permission is hereby granted, free of charge, to any person
obtaining a copy of this software and associated documentation
files (the "Software"), to deal in the Software without
restriction, including without limitation the rights to use,
copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the
Software is furnished to do so, subject to the following
conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
OTHER DEALINGS IN THE SOFTWARE.

View File

@@ -0,0 +1,183 @@
Metadata-Version: 2.1
Name: Flask-Login
Version: 0.6.3
Summary: User authentication and session management for Flask.
Home-page: https://github.com/maxcountryman/flask-login
Author: Matthew Frazier
Author-email: leafstormrush@gmail.com
Maintainer: Max Countryman
License: MIT
Project-URL: Documentation, https://flask-login.readthedocs.io/
Project-URL: Changes, https://github.com/maxcountryman/flask-login/blob/main/CHANGES.md
Project-URL: Source Code, https://github.com/maxcountryman/flask-login
Project-URL: Issue Tracker, https://github.com/maxcountryman/flask-login/issues
Classifier: Development Status :: 4 - Beta
Classifier: Environment :: Web Environment
Classifier: Framework :: Flask
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python
Classifier: Topic :: Internet :: WWW/HTTP :: Dynamic Content
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Requires-Python: >=3.7
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: Flask >=1.0.4
Requires-Dist: Werkzeug >=1.0.1
# Flask-Login
![Tests](https://github.com/maxcountryman/flask-login/workflows/Tests/badge.svg)
[![coverage](https://coveralls.io/repos/maxcountryman/flask-login/badge.svg?branch=main&service=github)](https://coveralls.io/github/maxcountryman/flask-login?branch=main)
[![Software License](https://img.shields.io/badge/license-MIT-brightgreen.svg)](LICENSE)
Flask-Login provides user session management for Flask. It handles the common
tasks of logging in, logging out, and remembering your users' sessions over
extended periods of time.
Flask-Login is not bound to any particular database system or permissions
model. The only requirement is that your user objects implement a few methods,
and that you provide a callback to the extension capable of loading users from
their ID.
## Installation
Install the extension with pip:
```sh
$ pip install flask-login
```
## Usage
Once installed, the Flask-Login is easy to use. Let's walk through setting up
a basic application. Also please note that this is a very basic guide: we will
be taking shortcuts here that you should never take in a real application.
To begin we'll set up a Flask app:
```python
import flask
app = flask.Flask(__name__)
app.secret_key = 'super secret string' # Change this!
```
Flask-Login works via a login manager. To kick things off, we'll set up the
login manager by instantiating it and telling it about our Flask app:
```python
import flask_login
login_manager = flask_login.LoginManager()
login_manager.init_app(app)
```
To keep things simple we're going to use a dictionary to represent a database
of users. In a real application, this would be an actual persistence layer.
However it's important to point out this is a feature of Flask-Login: it
doesn't care how your data is stored so long as you tell it how to retrieve it!
```python
# Our mock database.
users = {'foo@bar.tld': {'password': 'secret'}}
```
We also need to tell Flask-Login how to load a user from a Flask request and
from its session. To do this we need to define our user object, a
`user_loader` callback, and a `request_loader` callback.
```python
class User(flask_login.UserMixin):
pass
@login_manager.user_loader
def user_loader(email):
if email not in users:
return
user = User()
user.id = email
return user
@login_manager.request_loader
def request_loader(request):
email = request.form.get('email')
if email not in users:
return
user = User()
user.id = email
return user
```
Now we're ready to define our views. We can start with a login view, which will
populate the session with authentication bits. After that we can define a view
that requires authentication.
```python
@app.route('/login', methods=['GET', 'POST'])
def login():
if flask.request.method == 'GET':
return '''
<form action='login' method='POST'>
<input type='text' name='email' id='email' placeholder='email'/>
<input type='password' name='password' id='password' placeholder='password'/>
<input type='submit' name='submit'/>
</form>
'''
email = flask.request.form['email']
if email in users and flask.request.form['password'] == users[email]['password']:
user = User()
user.id = email
flask_login.login_user(user)
return flask.redirect(flask.url_for('protected'))
return 'Bad login'
@app.route('/protected')
@flask_login.login_required
def protected():
return 'Logged in as: ' + flask_login.current_user.id
```
Finally we can define a view to clear the session and log users out:
```python
@app.route('/logout')
def logout():
flask_login.logout_user()
return 'Logged out'
```
We now have a basic working application that makes use of session-based
authentication. To round things off, we should provide a callback for login
failures:
```python
@login_manager.unauthorized_handler
def unauthorized_handler():
return 'Unauthorized', 401
```
Documentation for Flask-Login is available on [ReadTheDocs](https://flask-login.readthedocs.io/en/latest/).
For complete understanding of available configuration, please refer to the [source code](https://github.com/maxcountryman/flask-login).
## Contributing
We welcome contributions! If you would like to hack on Flask-Login, please
follow these steps:
1. Fork this repository
2. Make your changes
3. Install the dev requirements with `pip install -r requirements/dev.txt`
4. Submit a pull request after running `tox` (ensure it does not error!)
Please give us adequate time to review your submission. Thanks!

View File

@@ -0,0 +1,23 @@
Flask_Login-0.6.3.dist-info/INSTALLER,sha256=zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg,4
Flask_Login-0.6.3.dist-info/LICENSE,sha256=ep37nF2iBO0TcPO2LBPimSoS2h2nB_R-FWiX7rQ0Tls,1059
Flask_Login-0.6.3.dist-info/METADATA,sha256=AUSHR5Po6-Cwmz1KBrAZbTzR-iVVFvtb2NQKYl7UuAU,5799
Flask_Login-0.6.3.dist-info/RECORD,,
Flask_Login-0.6.3.dist-info/REQUESTED,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
Flask_Login-0.6.3.dist-info/WHEEL,sha256=Xo9-1PvkuimrydujYJAjF7pCkriuXBpUPEjma1nZyJ0,92
Flask_Login-0.6.3.dist-info/top_level.txt,sha256=OuXmIpiFnXLvW-iBbW2km7ZIy5EZvwSBnYaOC3Kt7j8,12
flask_login/__about__.py,sha256=Kkp5e9mV9G7vK_FqZof-g9RFmyyBzq1gge5aKXgvilE,389
flask_login/__init__.py,sha256=wYQiQCikT_Ndp3PhOD-1gRTGCrUPIE-FrjQUrT9aVAg,2681
flask_login/__pycache__/__about__.cpython-311.pyc,,
flask_login/__pycache__/__init__.cpython-311.pyc,,
flask_login/__pycache__/config.cpython-311.pyc,,
flask_login/__pycache__/login_manager.cpython-311.pyc,,
flask_login/__pycache__/mixins.cpython-311.pyc,,
flask_login/__pycache__/signals.cpython-311.pyc,,
flask_login/__pycache__/test_client.cpython-311.pyc,,
flask_login/__pycache__/utils.cpython-311.pyc,,
flask_login/config.py,sha256=YAocv18La7YGQyNY5aT7rU1GQIZnX6pvchwqx3kA9p8,1813
flask_login/login_manager.py,sha256=h20F_iv3mqc6rIJ4-V6_XookzOUl8Rcpasua-dCByQY,20073
flask_login/mixins.py,sha256=gPd7otMRljxw0eUhUMbHsnEBc_jK2cYdxg5KFLuJcoI,1528
flask_login/signals.py,sha256=xCMoFHKU1RTVt1NY-Gfl0OiVKpiyNt6YJw_PsgkjY3w,2464
flask_login/test_client.py,sha256=6mrjiBRLGJpgvvFlLypXPTBLiMp0BAN-Ft-uogqC81g,517
flask_login/utils.py,sha256=Y1wxjCVxpYohBaQJ0ADLypQ-VvBNycwG-gVXFF7k99I,14021

View File

View File

@@ -0,0 +1,5 @@
Wheel-Version: 1.0
Generator: bdist_wheel (0.41.3)
Root-Is-Purelib: true
Tag: py3-none-any

View File

@@ -0,0 +1 @@
flask_login

View File

@@ -0,0 +1 @@
pip

View File

@@ -0,0 +1,20 @@
The MIT License (MIT)
Copyright (c) 2013 Miguel Grinberg
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software is furnished to do so,
subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

View File

@@ -0,0 +1,91 @@
Metadata-Version: 2.2
Name: Flask-Migrate
Version: 4.1.0
Summary: SQLAlchemy database migrations for Flask applications using Alembic.
Author-email: Miguel Grinberg <miguel.grinberg@gmail.com>
License: MIT
Project-URL: Homepage, https://github.com/miguelgrinberg/flask-migrate
Project-URL: Bug Tracker, https://github.com/miguelgrinberg/flask-migrate/issues
Classifier: Environment :: Web Environment
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: OS Independent
Requires-Python: >=3.6
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: Flask>=0.9
Requires-Dist: Flask-SQLAlchemy>=1.0
Requires-Dist: alembic>=1.9.0
Provides-Extra: dev
Requires-Dist: tox; extra == "dev"
Requires-Dist: flake8; extra == "dev"
Requires-Dist: pytest; extra == "dev"
Provides-Extra: docs
Requires-Dist: sphinx; extra == "docs"
Flask-Migrate
=============
[![Build status](https://github.com/miguelgrinberg/flask-migrate/workflows/build/badge.svg)](https://github.com/miguelgrinberg/flask-migrate/actions)
Flask-Migrate is an extension that handles SQLAlchemy database migrations for Flask applications using Alembic. The database operations are provided as command-line arguments under the `flask db` command.
Installation
------------
Install Flask-Migrate with `pip`:
pip install Flask-Migrate
Example
-------
This is an example application that handles database migrations through Flask-Migrate:
```python
from flask import Flask
from flask_sqlalchemy import SQLAlchemy
from flask_migrate import Migrate
app = Flask(__name__)
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///app.db'
db = SQLAlchemy(app)
migrate = Migrate(app, db)
class User(db.Model):
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(128))
```
With the above application you can create the database or enable migrations if the database already exists with the following command:
$ flask db init
Note that the `FLASK_APP` environment variable must be set according to the Flask documentation for this command to work. This will add a `migrations` folder to your application. The contents of this folder need to be added to version control along with your other source files.
You can then generate an initial migration:
$ flask db migrate
The migration script needs to be reviewed and edited, as Alembic currently does not detect every change you make to your models. In particular, Alembic is currently unable to detect indexes. Once finalized, the migration script also needs to be added to version control.
Then you can apply the migration to the database:
$ flask db upgrade
Then each time the database models change repeat the `migrate` and `upgrade` commands.
To sync the database in another system just refresh the `migrations` folder from source control and run the `upgrade` command.
To see all the commands that are available run this command:
$ flask db --help
Resources
---------
- [Documentation](http://flask-migrate.readthedocs.io/en/latest/)
- [pypi](https://pypi.python.org/pypi/Flask-Migrate)
- [Change Log](https://github.com/miguelgrinberg/Flask-Migrate/blob/master/CHANGES.md)

View File

@@ -0,0 +1,31 @@
Flask_Migrate-4.1.0.dist-info/INSTALLER,sha256=zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg,4
Flask_Migrate-4.1.0.dist-info/LICENSE,sha256=kfkXGlJQvKy3Y__6tAJ8ynIp1HQfeROXhL8jZU1d-DI,1082
Flask_Migrate-4.1.0.dist-info/METADATA,sha256=jifIy8PzfDzjuCEeKLDKRJA8O56KOgLfj3s2lmzZA-8,3289
Flask_Migrate-4.1.0.dist-info/RECORD,,
Flask_Migrate-4.1.0.dist-info/REQUESTED,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
Flask_Migrate-4.1.0.dist-info/WHEEL,sha256=In9FTNxeP60KnTkGw7wk6mJPYd_dQSjEZmXdBdMCI-8,91
Flask_Migrate-4.1.0.dist-info/top_level.txt,sha256=jLoPgiMG6oR4ugNteXn3IHskVVIyIXVStZOVq-AWLdU,14
flask_migrate/__init__.py,sha256=JMySGA55Y8Gxy3HviWu7qq5rPUNQBWc2NID2OicpDyw,10082
flask_migrate/__pycache__/__init__.cpython-311.pyc,,
flask_migrate/__pycache__/cli.cpython-311.pyc,,
flask_migrate/cli.py,sha256=IxrxBSC82S5sPfWac8Qg83_FVsRvqTYtCG7HRyMW8RU,11097
flask_migrate/templates/aioflask-multidb/README,sha256=Ek4cJqTaxneVjtkue--BXMlfpfp3MmJRjqoZvnSizww,43
flask_migrate/templates/aioflask-multidb/__pycache__/env.cpython-311.pyc,,
flask_migrate/templates/aioflask-multidb/alembic.ini.mako,sha256=SjYEmJKzz6K8QfuZWtLJAJWcCKOdRbfUhsVlpgv8ock,857
flask_migrate/templates/aioflask-multidb/env.py,sha256=UcjeqkAbyUjTkuQFmCFPG7QOvqhco8-uGp8QEbto0T8,6573
flask_migrate/templates/aioflask-multidb/script.py.mako,sha256=198VPxVEN3NZ3vHcRuCxSoI4XnOYirGWt01qkbPKoJw,1246
flask_migrate/templates/aioflask/README,sha256=KKqWGl4YC2RqdOdq-y6quTDW0b7D_UZNHuM8glM1L-c,44
flask_migrate/templates/aioflask/__pycache__/env.cpython-311.pyc,,
flask_migrate/templates/aioflask/alembic.ini.mako,sha256=SjYEmJKzz6K8QfuZWtLJAJWcCKOdRbfUhsVlpgv8ock,857
flask_migrate/templates/aioflask/env.py,sha256=m6ZtBhdpwuq89vVeLTWmNT-1NfJZqarC_hsquCdR9bw,3478
flask_migrate/templates/aioflask/script.py.mako,sha256=8_xgA-gm_OhehnO7CiIijWgnm00ZlszEHtIHrAYFJl0,494
flask_migrate/templates/flask-multidb/README,sha256=AfiP5foaV2odZxXxuUuSIS6YhkIpR7CsOo2mpuxwHdc,40
flask_migrate/templates/flask-multidb/__pycache__/env.cpython-311.pyc,,
flask_migrate/templates/flask-multidb/alembic.ini.mako,sha256=SjYEmJKzz6K8QfuZWtLJAJWcCKOdRbfUhsVlpgv8ock,857
flask_migrate/templates/flask-multidb/env.py,sha256=F44iqsAxLTVBN_zD8CMUkdE7Aub4niHMmo5wl9mY4Uw,6190
flask_migrate/templates/flask-multidb/script.py.mako,sha256=198VPxVEN3NZ3vHcRuCxSoI4XnOYirGWt01qkbPKoJw,1246
flask_migrate/templates/flask/README,sha256=JL0NrjOrscPcKgRmQh1R3hlv1_rohDot0TvpmdM27Jk,41
flask_migrate/templates/flask/__pycache__/env.cpython-311.pyc,,
flask_migrate/templates/flask/alembic.ini.mako,sha256=SjYEmJKzz6K8QfuZWtLJAJWcCKOdRbfUhsVlpgv8ock,857
flask_migrate/templates/flask/env.py,sha256=ibK1hsdOsOBzXNU2yQoAIza7f_EFzaVSWwON_NSpNzQ,3344
flask_migrate/templates/flask/script.py.mako,sha256=8_xgA-gm_OhehnO7CiIijWgnm00ZlszEHtIHrAYFJl0,494

View File

@@ -0,0 +1,5 @@
Wheel-Version: 1.0
Generator: setuptools (75.8.0)
Root-Is-Purelib: true
Tag: py3-none-any

View File

@@ -0,0 +1 @@
flask_migrate

View File

@@ -0,0 +1 @@
pip

View File

@@ -0,0 +1,28 @@
Copyright 2010 Pallets
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are
met:
1. Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright
notice, this list of conditions and the following disclaimer in the
documentation and/or other materials provided with the distribution.
3. Neither the name of the copyright holder nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A
PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED
TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

View File

@@ -0,0 +1,92 @@
Metadata-Version: 2.1
Name: MarkupSafe
Version: 3.0.2
Summary: Safely add untrusted strings to HTML/XML markup.
Maintainer-email: Pallets <contact@palletsprojects.com>
License: Copyright 2010 Pallets
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are
met:
1. Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright
notice, this list of conditions and the following disclaimer in the
documentation and/or other materials provided with the distribution.
3. Neither the name of the copyright holder nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A
PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED
TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
Project-URL: Donate, https://palletsprojects.com/donate
Project-URL: Documentation, https://markupsafe.palletsprojects.com/
Project-URL: Changes, https://markupsafe.palletsprojects.com/changes/
Project-URL: Source, https://github.com/pallets/markupsafe/
Project-URL: Chat, https://discord.gg/pallets
Classifier: Development Status :: 5 - Production/Stable
Classifier: Environment :: Web Environment
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: BSD License
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python
Classifier: Topic :: Internet :: WWW/HTTP :: Dynamic Content
Classifier: Topic :: Text Processing :: Markup :: HTML
Classifier: Typing :: Typed
Requires-Python: >=3.9
Description-Content-Type: text/markdown
License-File: LICENSE.txt
# MarkupSafe
MarkupSafe implements a text object that escapes characters so it is
safe to use in HTML and XML. Characters that have special meanings are
replaced so that they display as the actual characters. This mitigates
injection attacks, meaning untrusted user input can safely be displayed
on a page.
## Examples
```pycon
>>> from markupsafe import Markup, escape
>>> # escape replaces special characters and wraps in Markup
>>> escape("<script>alert(document.cookie);</script>")
Markup('&lt;script&gt;alert(document.cookie);&lt;/script&gt;')
>>> # wrap in Markup to mark text "safe" and prevent escaping
>>> Markup("<strong>Hello</strong>")
Markup('<strong>hello</strong>')
>>> escape(Markup("<strong>Hello</strong>"))
Markup('<strong>hello</strong>')
>>> # Markup is a str subclass
>>> # methods and operators escape their arguments
>>> template = Markup("Hello <em>{name}</em>")
>>> template.format(name='"World"')
Markup('Hello <em>&#34;World&#34;</em>')
```
## Donate
The Pallets organization develops and supports MarkupSafe and other
popular packages. In order to grow the community of contributors and
users, and allow the maintainers to devote more time to the projects,
[please donate today][].
[please donate today]: https://palletsprojects.com/donate

View File

@@ -0,0 +1,14 @@
MarkupSafe-3.0.2.dist-info/INSTALLER,sha256=zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg,4
MarkupSafe-3.0.2.dist-info/LICENSE.txt,sha256=RjHsDbX9kKVH4zaBcmTGeYIUM4FG-KyUtKV_lu6MnsQ,1503
MarkupSafe-3.0.2.dist-info/METADATA,sha256=nhoabjupBG41j_JxPCJ3ylgrZ6Fx8oMCFbiLF9Kafqc,4067
MarkupSafe-3.0.2.dist-info/RECORD,,
MarkupSafe-3.0.2.dist-info/WHEEL,sha256=tE2EWZPEv-G0fjAlUUz7IGM64246YKD9fpv4HcsDMkk,101
MarkupSafe-3.0.2.dist-info/top_level.txt,sha256=qy0Plje5IJuvsCBjejJyhDCjEAdcDLK_2agVcex8Z6U,11
markupsafe/__init__.py,sha256=pREerPwvinB62tNCMOwqxBS2YHV6R52Wcq1d-rB4Z5o,13609
markupsafe/__pycache__/__init__.cpython-311.pyc,,
markupsafe/__pycache__/_native.cpython-311.pyc,,
markupsafe/_native.py,sha256=2ptkJ40yCcp9kq3L1NqpgjfpZB-obniYKFFKUOkHh4Q,218
markupsafe/_speedups.c,sha256=SglUjn40ti9YgQAO--OgkSyv9tXq9vvaHyVhQows4Ok,4353
markupsafe/_speedups.cp311-win_amd64.pyd,sha256=-5qfBr0xMpiTRlH9hFg_7Go9PHi7z5guMzmbbmZI3Xw,13312
markupsafe/_speedups.pyi,sha256=LSDmXYOefH4HVpAXuL8sl7AttLw0oXh1njVoVZp2wqQ,42
markupsafe/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0

View File

@@ -0,0 +1,5 @@
Wheel-Version: 1.0
Generator: setuptools (75.2.0)
Root-Is-Purelib: false
Tag: cp311-cp311-win_amd64

View File

@@ -0,0 +1 @@
markupsafe

View File

Binary file not shown.

View File

@@ -0,0 +1,222 @@
# don't import any costly modules
import sys
import os
is_pypy = '__pypy__' in sys.builtin_module_names
def warn_distutils_present():
if 'distutils' not in sys.modules:
return
if is_pypy and sys.version_info < (3, 7):
# PyPy for 3.6 unconditionally imports distutils, so bypass the warning
# https://foss.heptapod.net/pypy/pypy/-/blob/be829135bc0d758997b3566062999ee8b23872b4/lib-python/3/site.py#L250
return
import warnings
warnings.warn(
"Distutils was imported before Setuptools, but importing Setuptools "
"also replaces the `distutils` module in `sys.modules`. This may lead "
"to undesirable behaviors or errors. To avoid these issues, avoid "
"using distutils directly, ensure that setuptools is installed in the "
"traditional way (e.g. not an editable install), and/or make sure "
"that setuptools is always imported before distutils."
)
def clear_distutils():
if 'distutils' not in sys.modules:
return
import warnings
warnings.warn("Setuptools is replacing distutils.")
mods = [
name
for name in sys.modules
if name == "distutils" or name.startswith("distutils.")
]
for name in mods:
del sys.modules[name]
def enabled():
"""
Allow selection of distutils by environment variable.
"""
which = os.environ.get('SETUPTOOLS_USE_DISTUTILS', 'local')
return which == 'local'
def ensure_local_distutils():
import importlib
clear_distutils()
# With the DistutilsMetaFinder in place,
# perform an import to cause distutils to be
# loaded from setuptools._distutils. Ref #2906.
with shim():
importlib.import_module('distutils')
# check that submodules load as expected
core = importlib.import_module('distutils.core')
assert '_distutils' in core.__file__, core.__file__
assert 'setuptools._distutils.log' not in sys.modules
def do_override():
"""
Ensure that the local copy of distutils is preferred over stdlib.
See https://github.com/pypa/setuptools/issues/417#issuecomment-392298401
for more motivation.
"""
if enabled():
warn_distutils_present()
ensure_local_distutils()
class _TrivialRe:
def __init__(self, *patterns):
self._patterns = patterns
def match(self, string):
return all(pat in string for pat in self._patterns)
class DistutilsMetaFinder:
def find_spec(self, fullname, path, target=None):
# optimization: only consider top level modules and those
# found in the CPython test suite.
if path is not None and not fullname.startswith('test.'):
return
method_name = 'spec_for_{fullname}'.format(**locals())
method = getattr(self, method_name, lambda: None)
return method()
def spec_for_distutils(self):
if self.is_cpython():
return
import importlib
import importlib.abc
import importlib.util
try:
mod = importlib.import_module('setuptools._distutils')
except Exception:
# There are a couple of cases where setuptools._distutils
# may not be present:
# - An older Setuptools without a local distutils is
# taking precedence. Ref #2957.
# - Path manipulation during sitecustomize removes
# setuptools from the path but only after the hook
# has been loaded. Ref #2980.
# In either case, fall back to stdlib behavior.
return
class DistutilsLoader(importlib.abc.Loader):
def create_module(self, spec):
mod.__name__ = 'distutils'
return mod
def exec_module(self, module):
pass
return importlib.util.spec_from_loader(
'distutils', DistutilsLoader(), origin=mod.__file__
)
@staticmethod
def is_cpython():
"""
Suppress supplying distutils for CPython (build and tests).
Ref #2965 and #3007.
"""
return os.path.isfile('pybuilddir.txt')
def spec_for_pip(self):
"""
Ensure stdlib distutils when running under pip.
See pypa/pip#8761 for rationale.
"""
if self.pip_imported_during_build():
return
clear_distutils()
self.spec_for_distutils = lambda: None
@classmethod
def pip_imported_during_build(cls):
"""
Detect if pip is being imported in a build script. Ref #2355.
"""
import traceback
return any(
cls.frame_file_is_setup(frame) for frame, line in traceback.walk_stack(None)
)
@staticmethod
def frame_file_is_setup(frame):
"""
Return True if the indicated frame suggests a setup.py file.
"""
# some frames may not have __file__ (#2940)
return frame.f_globals.get('__file__', '').endswith('setup.py')
def spec_for_sensitive_tests(self):
"""
Ensure stdlib distutils when running select tests under CPython.
python/cpython#91169
"""
clear_distutils()
self.spec_for_distutils = lambda: None
sensitive_tests = (
[
'test.test_distutils',
'test.test_peg_generator',
'test.test_importlib',
]
if sys.version_info < (3, 10)
else [
'test.test_distutils',
]
)
for name in DistutilsMetaFinder.sensitive_tests:
setattr(
DistutilsMetaFinder,
f'spec_for_{name}',
DistutilsMetaFinder.spec_for_sensitive_tests,
)
DISTUTILS_FINDER = DistutilsMetaFinder()
def add_shim():
DISTUTILS_FINDER in sys.meta_path or insert_shim()
class shim:
def __enter__(self):
insert_shim()
def __exit__(self, exc, value, tb):
remove_shim()
def insert_shim():
sys.meta_path.insert(0, DISTUTILS_FINDER)
def remove_shim():
try:
sys.meta_path.remove(DISTUTILS_FINDER)
except ValueError:
pass

View File

Binary file not shown.

View File

Binary file not shown.

View File

@@ -0,0 +1 @@
__import__('_distutils_hack').do_override()

View File

@@ -0,0 +1 @@
pip

View File

@@ -0,0 +1,140 @@
Metadata-Version: 2.4
Name: alembic
Version: 1.16.4
Summary: A database migration tool for SQLAlchemy.
Author-email: Mike Bayer <mike_mp@zzzcomputing.com>
License-Expression: MIT
Project-URL: Homepage, https://alembic.sqlalchemy.org
Project-URL: Documentation, https://alembic.sqlalchemy.org/en/latest/
Project-URL: Changelog, https://alembic.sqlalchemy.org/en/latest/changelog.html
Project-URL: Source, https://github.com/sqlalchemy/alembic/
Project-URL: Issue Tracker, https://github.com/sqlalchemy/alembic/issues/
Classifier: Development Status :: 5 - Production/Stable
Classifier: Intended Audience :: Developers
Classifier: Environment :: Console
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: Implementation :: CPython
Classifier: Programming Language :: Python :: Implementation :: PyPy
Classifier: Topic :: Database :: Front-Ends
Requires-Python: >=3.9
Description-Content-Type: text/x-rst
License-File: LICENSE
Requires-Dist: SQLAlchemy>=1.4.0
Requires-Dist: Mako
Requires-Dist: typing-extensions>=4.12
Requires-Dist: tomli; python_version < "3.11"
Provides-Extra: tz
Requires-Dist: tzdata; extra == "tz"
Dynamic: license-file
Alembic is a database migrations tool written by the author
of `SQLAlchemy <http://www.sqlalchemy.org>`_. A migrations tool
offers the following functionality:
* Can emit ALTER statements to a database in order to change
the structure of tables and other constructs
* Provides a system whereby "migration scripts" may be constructed;
each script indicates a particular series of steps that can "upgrade" a
target database to a new version, and optionally a series of steps that can
"downgrade" similarly, doing the same steps in reverse.
* Allows the scripts to execute in some sequential manner.
The goals of Alembic are:
* Very open ended and transparent configuration and operation. A new
Alembic environment is generated from a set of templates which is selected
among a set of options when setup first occurs. The templates then deposit a
series of scripts that define fully how database connectivity is established
and how migration scripts are invoked; the migration scripts themselves are
generated from a template within that series of scripts. The scripts can
then be further customized to define exactly how databases will be
interacted with and what structure new migration files should take.
* Full support for transactional DDL. The default scripts ensure that all
migrations occur within a transaction - for those databases which support
this (Postgresql, Microsoft SQL Server), migrations can be tested with no
need to manually undo changes upon failure.
* Minimalist script construction. Basic operations like renaming
tables/columns, adding/removing columns, changing column attributes can be
performed through one line commands like alter_column(), rename_table(),
add_constraint(). There is no need to recreate full SQLAlchemy Table
structures for simple operations like these - the functions themselves
generate minimalist schema structures behind the scenes to achieve the given
DDL sequence.
* "auto generation" of migrations. While real world migrations are far more
complex than what can be automatically determined, Alembic can still
eliminate the initial grunt work in generating new migration directives
from an altered schema. The ``--autogenerate`` feature will inspect the
current status of a database using SQLAlchemy's schema inspection
capabilities, compare it to the current state of the database model as
specified in Python, and generate a series of "candidate" migrations,
rendering them into a new migration script as Python directives. The
developer then edits the new file, adding additional directives and data
migrations as needed, to produce a finished migration. Table and column
level changes can be detected, with constraints and indexes to follow as
well.
* Full support for migrations generated as SQL scripts. Those of us who
work in corporate environments know that direct access to DDL commands on a
production database is a rare privilege, and DBAs want textual SQL scripts.
Alembic's usage model and commands are oriented towards being able to run a
series of migrations into a textual output file as easily as it runs them
directly to a database. Care must be taken in this mode to not invoke other
operations that rely upon in-memory SELECTs of rows - Alembic tries to
provide helper constructs like bulk_insert() to help with data-oriented
operations that are compatible with script-based DDL.
* Non-linear, dependency-graph versioning. Scripts are given UUID
identifiers similarly to a DVCS, and the linkage of one script to the next
is achieved via human-editable markers within the scripts themselves.
The structure of a set of migration files is considered as a
directed-acyclic graph, meaning any migration file can be dependent
on any other arbitrary set of migration files, or none at
all. Through this open-ended system, migration files can be organized
into branches, multiple roots, and mergepoints, without restriction.
Commands are provided to produce new branches, roots, and merges of
branches automatically.
* Provide a library of ALTER constructs that can be used by any SQLAlchemy
application. The DDL constructs build upon SQLAlchemy's own DDLElement base
and can be used standalone by any application or script.
* At long last, bring SQLite and its inability to ALTER things into the fold,
but in such a way that SQLite's very special workflow needs are accommodated
in an explicit way that makes the most of a bad situation, through the
concept of a "batch" migration, where multiple changes to a table can
be batched together to form a series of instructions for a single, subsequent
"move-and-copy" workflow. You can even use "move-and-copy" workflow for
other databases, if you want to recreate a table in the background
on a busy system.
Documentation and status of Alembic is at https://alembic.sqlalchemy.org/
The SQLAlchemy Project
======================
Alembic is part of the `SQLAlchemy Project <https://www.sqlalchemy.org>`_ and
adheres to the same standards and conventions as the core project.
Development / Bug reporting / Pull requests
___________________________________________
Please refer to the
`SQLAlchemy Community Guide <https://www.sqlalchemy.org/develop.html>`_ for
guidelines on coding and participating in this project.
Code of Conduct
_______________
Above all, SQLAlchemy places great emphasis on polite, thoughtful, and
constructive communication between users and developers.
Please see our current Code of Conduct at
`Code of Conduct <https://www.sqlalchemy.org/codeofconduct.html>`_.
License
=======
Alembic is distributed under the `MIT license
<https://opensource.org/licenses/MIT>`_.

View File

@@ -0,0 +1,162 @@
../../Scripts/alembic.exe,sha256=kg4OoHQLGwbKYDijEitu59R9DHwOxBU8iH-3haB5TFM,108431
alembic-1.16.4.dist-info/INSTALLER,sha256=zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg,4
alembic-1.16.4.dist-info/METADATA,sha256=FOov3ImluFXi-Y-Zod8XhsXQ9xlB-lbP1V6WKMjhJ6c,7265
alembic-1.16.4.dist-info/RECORD,,
alembic-1.16.4.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
alembic-1.16.4.dist-info/entry_points.txt,sha256=aykM30soxwGN0pB7etLc1q0cHJbL9dy46RnK9VX4LLw,48
alembic-1.16.4.dist-info/licenses/LICENSE,sha256=NeqcNBmyYfrxvkSMT0fZJVKBv2s2tf_qVQUiJ9S6VN4,1059
alembic-1.16.4.dist-info/top_level.txt,sha256=FwKWd5VsPFC8iQjpu1u9Cn-JnK3-V1RhUCmWqz1cl-s,8
alembic/__init__.py,sha256=LBJqQA9P3cs5iDeqKVCK3DsoFQfdhd1cvtctowCUd0g,63
alembic/__main__.py,sha256=373m7-TBh72JqrSMYviGrxCHZo-cnweM8AGF8A22PmY,78
alembic/__pycache__/__init__.cpython-311.pyc,,
alembic/__pycache__/__main__.cpython-311.pyc,,
alembic/__pycache__/command.cpython-311.pyc,,
alembic/__pycache__/config.cpython-311.pyc,,
alembic/__pycache__/context.cpython-311.pyc,,
alembic/__pycache__/environment.cpython-311.pyc,,
alembic/__pycache__/migration.cpython-311.pyc,,
alembic/__pycache__/op.cpython-311.pyc,,
alembic/autogenerate/__init__.py,sha256=ntmUTXhjLm4_zmqIwyVaECdpPDn6_u1yM9vYk6-553E,543
alembic/autogenerate/__pycache__/__init__.cpython-311.pyc,,
alembic/autogenerate/__pycache__/api.cpython-311.pyc,,
alembic/autogenerate/__pycache__/compare.cpython-311.pyc,,
alembic/autogenerate/__pycache__/render.cpython-311.pyc,,
alembic/autogenerate/__pycache__/rewriter.cpython-311.pyc,,
alembic/autogenerate/api.py,sha256=L4qkapSJO1Ypymx8HsjLl0vFFt202agwMYsQbIe6ZtI,22219
alembic/autogenerate/compare.py,sha256=LRTxNijEBvcTauuUXuJjC6Sg_gUn33FCYBTF0neZFwE,45979
alembic/autogenerate/render.py,sha256=ceQL8nk8m2kBtQq5gtxtDLR9iR0Sck8xG_61Oez-Sqs,37270
alembic/autogenerate/rewriter.py,sha256=NIASSS-KaNKPmbm1k4pE45aawwjSh1Acf6eZrOwnUGM,7814
alembic/command.py,sha256=pZPQUGSxCjFu7qy0HMe02HJmByM0LOqoiK2AXKfRO3A,24855
alembic/config.py,sha256=SbOhoGuXlh_vVpK3SD8LFUG_BNH5HDLv6Q9949HCiXA,34124
alembic/context.py,sha256=hK1AJOQXJ29Bhn276GYcosxeG7pC5aZRT5E8c4bMJ4Q,195
alembic/context.pyi,sha256=fdeFNTRc0bUgi7n2eZWVFh6NG-TzIv_0gAcapbfHnKY,31773
alembic/ddl/__init__.py,sha256=Df8fy4Vn_abP8B7q3x8gyFwEwnLw6hs2Ljt_bV3EZWE,152
alembic/ddl/__pycache__/__init__.cpython-311.pyc,,
alembic/ddl/__pycache__/_autogen.cpython-311.pyc,,
alembic/ddl/__pycache__/base.cpython-311.pyc,,
alembic/ddl/__pycache__/impl.cpython-311.pyc,,
alembic/ddl/__pycache__/mssql.cpython-311.pyc,,
alembic/ddl/__pycache__/mysql.cpython-311.pyc,,
alembic/ddl/__pycache__/oracle.cpython-311.pyc,,
alembic/ddl/__pycache__/postgresql.cpython-311.pyc,,
alembic/ddl/__pycache__/sqlite.cpython-311.pyc,,
alembic/ddl/_autogen.py,sha256=Blv2RrHNyF4cE6znCQXNXG5T9aO-YmiwD4Fz-qfoaWA,9275
alembic/ddl/base.py,sha256=A1f89-rCZvqw-hgWmBbIszRqx94lL6gKLFXE9kHettA,10478
alembic/ddl/impl.py,sha256=UL8-iza7CJk_T73lr5fjDLdhxEL56uD-AEjtmESAbLk,30439
alembic/ddl/mssql.py,sha256=NzORSIDHUll_g6iH4IyMTXZU1qjKzXrpespKrjWnfLY,14216
alembic/ddl/mysql.py,sha256=ujY4xDh13KgiFNRe3vUcLquie0ih80MYBUcogCBPdSc,17358
alembic/ddl/oracle.py,sha256=669YlkcZihlXFbnXhH2krdrvDry8q5pcUGfoqkg_R6Y,6243
alembic/ddl/postgresql.py,sha256=S7uye2NDSHLwV3w8SJ2Q9DLbcvQIxQfJ3EEK6JqyNag,29950
alembic/ddl/sqlite.py,sha256=u5tJgRUiY6bzVltl_NWlI6cy23v8XNagk_9gPI6Lnns,8006
alembic/environment.py,sha256=MM5lPayGT04H3aeng1H7GQ8HEAs3VGX5yy6mDLCPLT4,43
alembic/migration.py,sha256=MV6Fju6rZtn2fTREKzXrCZM6aIBGII4OMZFix0X-GLs,41
alembic/op.py,sha256=flHtcsVqOD-ZgZKK2pv-CJ5Cwh-KJ7puMUNXzishxLw,167
alembic/op.pyi,sha256=PQ4mKNp7EXrjVdIWQRoGiBSVke4PPxTc9I6qF8ZGGZE,50711
alembic/operations/__init__.py,sha256=e0KQSZAgLpTWvyvreB7DWg7RJV_MWSOPVDgCqsd2FzY,318
alembic/operations/__pycache__/__init__.cpython-311.pyc,,
alembic/operations/__pycache__/base.cpython-311.pyc,,
alembic/operations/__pycache__/batch.cpython-311.pyc,,
alembic/operations/__pycache__/ops.cpython-311.pyc,,
alembic/operations/__pycache__/schemaobj.cpython-311.pyc,,
alembic/operations/__pycache__/toimpl.cpython-311.pyc,,
alembic/operations/base.py,sha256=npw1iFboTlEsaQS0b7mb2SEHsRDV4GLQqnjhcfma6Nk,75157
alembic/operations/batch.py,sha256=1UmCFcsFWObinQWFRWoGZkjynl54HKpldbPs67aR4wg,26923
alembic/operations/ops.py,sha256=ftsFgcZIctxRDiuGgkQsaFHsMlRP7cLq7Dj_seKVBnQ,96276
alembic/operations/schemaobj.py,sha256=Wp-bBe4a8lXPTvIHJttBY0ejtpVR5Jvtb2kI-U2PztQ,9468
alembic/operations/toimpl.py,sha256=rgufuSUNwpgrOYzzY3Q3ELW1rQv2fQbQVokXgnIYIrs,7503
alembic/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
alembic/runtime/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
alembic/runtime/__pycache__/__init__.cpython-311.pyc,,
alembic/runtime/__pycache__/environment.cpython-311.pyc,,
alembic/runtime/__pycache__/migration.cpython-311.pyc,,
alembic/runtime/environment.py,sha256=L6bDW1dvw8L4zwxlTG8KnT0xcCgLXxUfdRpzqlJoFjo,41479
alembic/runtime/migration.py,sha256=lu9_z_qyWmNzSM52_FgdXP_G52PTmTTeOeMBQAkQTFg,49997
alembic/script/__init__.py,sha256=lSj06O391Iy5avWAiq8SPs6N8RBgxkSPjP8wpXcNDGg,100
alembic/script/__pycache__/__init__.cpython-311.pyc,,
alembic/script/__pycache__/base.cpython-311.pyc,,
alembic/script/__pycache__/revision.cpython-311.pyc,,
alembic/script/__pycache__/write_hooks.cpython-311.pyc,,
alembic/script/base.py,sha256=4jINClsNNwQIvnf4Kwp9JPAMrANLXdLItylXmcMqAkI,36896
alembic/script/revision.py,sha256=BQcJoMCIXtSJRLCvdasgLOtCx9O7A8wsSym1FsqLW4s,62307
alembic/script/write_hooks.py,sha256=uQWAtguSCrxU_k9d87NX19y6EzyjJRRQ5HS9cyPnK9o,5092
alembic/templates/async/README,sha256=ISVtAOvqvKk_5ThM5ioJE-lMkvf9IbknFUFVU_vPma4,58
alembic/templates/async/__pycache__/env.cpython-311.pyc,,
alembic/templates/async/alembic.ini.mako,sha256=Bgi4WkaHYsT7xvsX-4WOGkcXKFroNoQLaUvZA23ZwGs,4864
alembic/templates/async/env.py,sha256=zbOCf3Y7w2lg92hxSwmG1MM_7y56i_oRH4AKp0pQBYo,2389
alembic/templates/async/script.py.mako,sha256=04kgeBtNMa4cCnG8CfQcKt6P6rnloIfj8wy0u_DBydM,704
alembic/templates/generic/README,sha256=MVlc9TYmr57RbhXET6QxgyCcwWP7w-vLkEsirENqiIQ,38
alembic/templates/generic/__pycache__/env.cpython-311.pyc,,
alembic/templates/generic/alembic.ini.mako,sha256=LCpLL02bi9Qr3KRTEj9NbQqAu0ckUmYBwPtrMtQkv-Y,4864
alembic/templates/generic/env.py,sha256=TLRWOVW3Xpt_Tpf8JFzlnoPn_qoUu8UV77Y4o9XD6yI,2103
alembic/templates/generic/script.py.mako,sha256=04kgeBtNMa4cCnG8CfQcKt6P6rnloIfj8wy0u_DBydM,704
alembic/templates/multidb/README,sha256=dWLDhnBgphA4Nzb7sNlMfCS3_06YqVbHhz-9O5JNqyI,606
alembic/templates/multidb/__pycache__/env.cpython-311.pyc,,
alembic/templates/multidb/alembic.ini.mako,sha256=rIp1LTdE1xcoFT2G7X72KshzYjUTRrHTvnkvFL___-8,5190
alembic/templates/multidb/env.py,sha256=6zNjnW8mXGUk7erTsAvrfhvqoczJ-gagjVq1Ypg2YIQ,4230
alembic/templates/multidb/script.py.mako,sha256=ZbCXMkI5Wj2dwNKcxuVGkKZ7Iav93BNx_bM4zbGi3c8,1235
alembic/templates/pyproject/README,sha256=dMhIiFoeM7EdeaOXBs3mVQ6zXACMyGXDb_UBB6sGRA0,60
alembic/templates/pyproject/__pycache__/env.cpython-311.pyc,,
alembic/templates/pyproject/alembic.ini.mako,sha256=bQnEoydnLOUgg9vNbTOys4r5MaW8lmwYFXSrlfdEEkw,782
alembic/templates/pyproject/env.py,sha256=TLRWOVW3Xpt_Tpf8JFzlnoPn_qoUu8UV77Y4o9XD6yI,2103
alembic/templates/pyproject/pyproject.toml.mako,sha256=Gf16ZR9OMG9zDlFO5PVQlfiL1DTKwSA--sTNzK7Lba0,2852
alembic/templates/pyproject/script.py.mako,sha256=04kgeBtNMa4cCnG8CfQcKt6P6rnloIfj8wy0u_DBydM,704
alembic/templates/pyproject_async/README,sha256=2Q5XcEouiqQ-TJssO9805LROkVUd0F6d74rTnuLrifA,45
alembic/templates/pyproject_async/__pycache__/env.cpython-311.pyc,,
alembic/templates/pyproject_async/alembic.ini.mako,sha256=bQnEoydnLOUgg9vNbTOys4r5MaW8lmwYFXSrlfdEEkw,782
alembic/templates/pyproject_async/env.py,sha256=zbOCf3Y7w2lg92hxSwmG1MM_7y56i_oRH4AKp0pQBYo,2389
alembic/templates/pyproject_async/pyproject.toml.mako,sha256=Gf16ZR9OMG9zDlFO5PVQlfiL1DTKwSA--sTNzK7Lba0,2852
alembic/templates/pyproject_async/script.py.mako,sha256=04kgeBtNMa4cCnG8CfQcKt6P6rnloIfj8wy0u_DBydM,704
alembic/testing/__init__.py,sha256=PTMhi_2PZ1T_3atQS2CIr0V4YRZzx_doKI-DxKdQS44,1297
alembic/testing/__pycache__/__init__.cpython-311.pyc,,
alembic/testing/__pycache__/assertions.cpython-311.pyc,,
alembic/testing/__pycache__/env.cpython-311.pyc,,
alembic/testing/__pycache__/fixtures.cpython-311.pyc,,
alembic/testing/__pycache__/requirements.cpython-311.pyc,,
alembic/testing/__pycache__/schemacompare.cpython-311.pyc,,
alembic/testing/__pycache__/util.cpython-311.pyc,,
alembic/testing/__pycache__/warnings.cpython-311.pyc,,
alembic/testing/assertions.py,sha256=qcqf3tRAUe-A12NzuK_yxlksuX9OZKRC5E8pKIdBnPg,5302
alembic/testing/env.py,sha256=pka7fjwOC8hYL6X0XE4oPkJpy_1WX01bL7iP7gpO_4I,11551
alembic/testing/fixtures.py,sha256=fOzsRF8SW6CWpAH0sZpUHcgsJjun9EHnp4k2S3Lq5eU,9920
alembic/testing/plugin/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
alembic/testing/plugin/__pycache__/__init__.cpython-311.pyc,,
alembic/testing/plugin/__pycache__/bootstrap.cpython-311.pyc,,
alembic/testing/plugin/bootstrap.py,sha256=9C6wtjGrIVztZ928w27hsQE0KcjDLIUtUN3dvZKsMVk,50
alembic/testing/requirements.py,sha256=gNnnvgPCuiqKeHmiNymdQuYIjQ0BrxiPxu_in4eHEsc,4180
alembic/testing/schemacompare.py,sha256=N5UqSNCOJetIKC4vKhpYzQEpj08XkdgIoqBmEPQ3tlc,4838
alembic/testing/suite/__init__.py,sha256=MvE7-hwbaVN1q3NM-ztGxORU9dnIelUCINKqNxewn7Y,288
alembic/testing/suite/__pycache__/__init__.cpython-311.pyc,,
alembic/testing/suite/__pycache__/_autogen_fixtures.cpython-311.pyc,,
alembic/testing/suite/__pycache__/test_autogen_comments.cpython-311.pyc,,
alembic/testing/suite/__pycache__/test_autogen_computed.cpython-311.pyc,,
alembic/testing/suite/__pycache__/test_autogen_diffs.cpython-311.pyc,,
alembic/testing/suite/__pycache__/test_autogen_fks.cpython-311.pyc,,
alembic/testing/suite/__pycache__/test_autogen_identity.cpython-311.pyc,,
alembic/testing/suite/__pycache__/test_environment.cpython-311.pyc,,
alembic/testing/suite/__pycache__/test_op.cpython-311.pyc,,
alembic/testing/suite/_autogen_fixtures.py,sha256=Drrz_FKb9KDjq8hkwxtPkJVY1sCY7Biw-Muzb8kANp8,13480
alembic/testing/suite/test_autogen_comments.py,sha256=aEGqKUDw4kHjnDk298aoGcQvXJWmZXcIX_2FxH4cJK8,6283
alembic/testing/suite/test_autogen_computed.py,sha256=-5wran56qXo3afAbSk8cuSDDpbQweyJ61RF-GaVuZbA,4126
alembic/testing/suite/test_autogen_diffs.py,sha256=T4SR1n_kmcOKYhR4W1-dA0e5sddJ69DSVL2HW96kAkE,8394
alembic/testing/suite/test_autogen_fks.py,sha256=AqFmb26Buex167HYa9dZWOk8x-JlB1OK3bwcvvjDFaU,32927
alembic/testing/suite/test_autogen_identity.py,sha256=kcuqngG7qXAKPJDX4U8sRzPKHEJECHuZ0DtuaS6tVkk,5824
alembic/testing/suite/test_environment.py,sha256=OwD-kpESdLoc4byBrGrXbZHvqtPbzhFCG4W9hJOJXPQ,11877
alembic/testing/suite/test_op.py,sha256=2XQCdm_NmnPxHGuGj7hmxMzIhKxXNotUsKdACXzE1mM,1343
alembic/testing/util.py,sha256=CQrcQDA8fs_7ME85z5ydb-Bt70soIIID-qNY1vbR2dg,3350
alembic/testing/warnings.py,sha256=cDDWzvxNZE6x9dME2ACTXSv01G81JcIbE1GIE_s1kvg,831
alembic/util/__init__.py,sha256=_Zj_xp6ssKLyoLHUFzmKhnc8mhwXW8D8h7qyX-wO56M,1519
alembic/util/__pycache__/__init__.cpython-311.pyc,,
alembic/util/__pycache__/compat.cpython-311.pyc,,
alembic/util/__pycache__/editor.cpython-311.pyc,,
alembic/util/__pycache__/exc.cpython-311.pyc,,
alembic/util/__pycache__/langhelpers.cpython-311.pyc,,
alembic/util/__pycache__/messaging.cpython-311.pyc,,
alembic/util/__pycache__/pyfiles.cpython-311.pyc,,
alembic/util/__pycache__/sqla_compat.cpython-311.pyc,,
alembic/util/compat.py,sha256=Vt5xCn5Y675jI4seKNBV4IVnCl9V4wyH3OBI2w7U0EY,4248
alembic/util/editor.py,sha256=JIz6_BdgV8_oKtnheR6DZoB7qnrHrlRgWjx09AsTsUw,2546
alembic/util/exc.py,sha256=ZBlTQ8g-Jkb1iYFhFHs9djilRz0SSQ0Foc5SSoENs5o,564
alembic/util/langhelpers.py,sha256=LpOcovnhMnP45kTt8zNJ4BHpyQrlF40OL6yDXjqKtsE,10026
alembic/util/messaging.py,sha256=3bEBoDy4EAXETXAvArlYjeMITXDTgPTu6ZoE3ytnzSw,3294
alembic/util/pyfiles.py,sha256=kOBjZEytRkBKsQl0LAj2sbKJMQazjwQ_5UeMKSIvVFo,4730
alembic/util/sqla_compat.py,sha256=9OYPTf-GCultAIuv1PoiaqYXAApZQxUOqjrOaeJDAik,14790

View File

@@ -0,0 +1,5 @@
Wheel-Version: 1.0
Generator: setuptools (80.9.0)
Root-Is-Purelib: true
Tag: py3-none-any

Some files were not shown because too many files have changed in this diff Show More