Finalize mail settings admin UI and Mailrise compatibility

This commit is contained in:
ske087
2025-07-26 18:50:42 +03:00
parent 2a5b5ee468
commit 377e379883
23 changed files with 629 additions and 170 deletions

View File

@@ -0,0 +1,6 @@
# Secret for password reset tokens
RESET_TOKEN_SECRET=your-very-secret-reset-token-key
# Example .env for admin creation
ADMIN_EMAIL=admin@example.com
ADMIN_NICKNAME=admin
ADMIN_PASSWORD=changeme

View File

@@ -92,5 +92,23 @@ def create_app(config_name=None):
os.makedirs(upload_dir, exist_ok=True)
os.makedirs(os.path.join(upload_dir, 'images'), exist_ok=True)
os.makedirs(os.path.join(upload_dir, 'gpx'), exist_ok=True)
# --- Initial Admin Creation from .env ---
from app.models import User
with app.app_context():
admin_email = os.environ.get('ADMIN_EMAIL')
admin_nickname = os.environ.get('ADMIN_NICKNAME')
admin_password = os.environ.get('ADMIN_PASSWORD')
if admin_email and admin_nickname and admin_password:
if not User.query.filter_by(email=admin_email).first():
user = User(nickname=admin_nickname, email=admin_email, is_admin=True, is_active=True)
user.set_password(admin_password)
db.session.add(user)
db.session.commit()
print(f"[INFO] Admin user {admin_nickname} <{admin_email}> created from .env.")
else:
print(f"[INFO] Admin with email {admin_email} already exists.")
else:
print("[INFO] ADMIN_EMAIL, ADMIN_NICKNAME, or ADMIN_PASSWORD not set in .env. Skipping admin creation.")
return app

View File

@@ -273,3 +273,35 @@ class PageView(db.Model):
def __repr__(self):
return f'<PageView {self.path} at {self.created_at}>'
# --- Mail Server Management Models ---
class MailSettings(db.Model):
__tablename__ = 'mail_settings'
id = db.Column(db.Integer, primary_key=True)
enabled = db.Column(db.Boolean, default=False, nullable=False)
server = db.Column(db.String(255), nullable=False)
port = db.Column(db.Integer, nullable=False)
use_tls = db.Column(db.Boolean, default=True, nullable=False)
username = db.Column(db.String(255))
password = db.Column(db.String(255))
default_sender = db.Column(db.String(255))
provider = db.Column(db.String(50), default='smtp') # 'smtp' or 'mailrise'
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
def __repr__(self):
return f'<MailSettings {self.server}:{self.port} enabled={self.enabled}>'
class SentEmail(db.Model):
__tablename__ = 'sent_emails'
id = db.Column(db.Integer, primary_key=True)
recipient = db.Column(db.String(255), nullable=False)
subject = db.Column(db.String(255), nullable=False)
body = db.Column(db.Text, nullable=False)
status = db.Column(db.String(50), default='sent') # sent, failed, etc.
error = db.Column(db.Text)
sent_at = db.Column(db.DateTime, default=datetime.utcnow)
provider = db.Column(db.String(50), default='smtp')
def __repr__(self):
return f'<SentEmail to={self.recipient} subject={self.subject} status={self.status}>'

View File

@@ -1,13 +1,88 @@
from flask import Blueprint, render_template, request, flash, redirect, url_for, jsonify, current_app
from flask_mail import Message
from flask_login import login_required, current_user
from functools import wraps
from datetime import datetime, timedelta
from sqlalchemy import func, desc
import secrets
from app.routes.mail import mail
from app.extensions import db
from app.models import User, Post, PostImage, GPXFile, Comment, Like, PageView
from app.models import User, Post, PostImage, GPXFile, Comment, Like, PageView, MailSettings, SentEmail
admin = Blueprint('admin', __name__, url_prefix='/admin')
def admin_required(f):
"""Decorator to require admin access"""
@wraps(f)
def decorated_function(*args, **kwargs):
if not current_user.is_authenticated or not current_user.is_admin:
flash('Admin access required.', 'error')
return redirect(url_for('auth.login'))
return f(*args, **kwargs)
return decorated_function
@admin.route('/mail-settings', methods=['GET', 'POST'])
@login_required
@admin_required
def mail_settings():
settings = MailSettings.query.first()
if request.method == 'POST':
enabled = bool(request.form.get('enabled'))
provider = request.form.get('provider')
server = request.form.get('server')
port = int(request.form.get('port') or 0)
use_tls = bool(request.form.get('use_tls'))
username = request.form.get('username')
password = request.form.get('password')
default_sender = request.form.get('default_sender')
if not settings:
settings = MailSettings()
db.session.add(settings)
settings.enabled = enabled
settings.provider = provider
settings.server = server
settings.port = port
settings.use_tls = use_tls
settings.username = username
settings.password = password
settings.default_sender = default_sender
db.session.commit()
flash('Mail settings updated.', 'success')
sent_emails = SentEmail.query.order_by(SentEmail.sent_at.desc()).limit(50).all()
return render_template('admin/mail_settings.html', settings=settings, sent_emails=sent_emails)
## Duplicate imports and Blueprint definitions removed
# Password reset token generator (simple, for demonstration)
def generate_reset_token(user):
# In production, use itsdangerous or Flask-Security for secure tokens
return secrets.token_urlsafe(32)
# Admin: Send password reset email to user
@admin.route('/users/<int:user_id>/reset-password', methods=['POST'])
@login_required
@admin_required
def reset_user_password(user_id):
user = User.query.get_or_404(user_id)
token = generate_reset_token(user)
# In production, save token to DB or cache, and validate on reset
reset_url = url_for('auth.reset_password', token=token, _external=True)
msg = Message(
subject="Password Reset Request",
recipients=[user.email],
body=f"Hello {user.nickname},\n\nAn admin has requested a password reset for your account. Click the link below to reset your password:\n{reset_url}\n\nIf you did not request this, please ignore this email."
)
try:
mail.send(msg)
flash(f"Password reset email sent to {user.email}.", "success")
except Exception as e:
current_app.logger.error(f"Error sending reset email: {e}")
flash(f"Failed to send password reset email: {e}", "danger")
return redirect(url_for('admin.user_detail', user_id=user.id))
def admin_required(f):
"""Decorator to require admin access"""
@wraps(f)
@@ -98,7 +173,7 @@ def dashboard():
def posts():
"""Admin post management - review posts"""
page = request.args.get('page', 1, type=int)
status = request.args.get('status', 'pending') # pending, published, all
status = request.args.get('status', 'all') # pending, published, all
query = Post.query

View File

@@ -3,8 +3,12 @@ from flask_login import login_user, logout_user, login_required, current_user
from werkzeug.security import check_password_hash
from app.models import User, db
from app.forms import LoginForm, RegisterForm, ForgotPasswordForm
from app.routes.reset_password import RequestResetForm, ResetPasswordForm
from flask_mail import Message
from app.routes.mail import mail
from app.utils.token import generate_reset_token, verify_reset_token
import re
from app.forms import LoginForm, RegisterForm, ForgotPasswordForm
auth = Blueprint('auth', __name__)
@auth.route('/login', methods=['GET', 'POST'])
@@ -83,18 +87,44 @@ def forgot_password():
"""Forgot password page"""
if current_user.is_authenticated:
return redirect(url_for('main.index'))
form = ForgotPasswordForm()
form = RequestResetForm()
if form.validate_on_submit():
user = User.query.filter_by(email=form.email.data).first()
if user:
# TODO: Implement email sending for password reset
flash('If an account with that email exists, we\'ve sent password reset instructions.', 'info')
else:
flash('If an account with that email exists, we\'ve sent password reset instructions.', 'info')
token = generate_reset_token(user.email)
reset_url = url_for('auth.reset_password', token=token, _external=True)
msg = Message(
subject="Password Reset Request",
recipients=[user.email],
body=f"Hello {user.nickname},\n\nTo reset your password, click the link below:\n{reset_url}\n\nIf you did not request this, please ignore this email."
)
try:
mail.send(msg)
except Exception as e:
flash(f"Failed to send reset email: {e}", "danger")
flash('If an account with that email exists, we\'ve sent password reset instructions.', 'info')
return redirect(url_for('auth.login'))
return render_template('auth/forgot_password.html', form=form)
# Password reset route
@auth.route('/reset-password/<token>', methods=['GET', 'POST'])
def reset_password(token):
if current_user.is_authenticated:
return redirect(url_for('main.index'))
email = verify_reset_token(token)
if not email:
flash('Invalid or expired reset link.', 'danger')
return redirect(url_for('auth.forgot_password'))
user = User.query.filter_by(email=email).first()
if not user:
flash('Invalid or expired reset link.', 'danger')
return redirect(url_for('auth.forgot_password'))
form = ResetPasswordForm()
if form.validate_on_submit():
user.set_password(form.password.data)
db.session.commit()
flash('Your password has been reset. You can now log in.', 'success')
return redirect(url_for('auth.login'))
return render_template('auth/reset_password.html', form=form)
def is_valid_password(password):
"""Validate password strength"""

2
app/routes/mail.py Normal file
View File

@@ -0,0 +1,2 @@
from flask_mail import Mail
mail = Mail()

View File

@@ -0,0 +1,12 @@
from flask_wtf import FlaskForm
from wtforms import StringField, PasswordField, SubmitField
from wtforms.validators import DataRequired, Email, EqualTo, Length
class RequestResetForm(FlaskForm):
email = StringField('Email', validators=[DataRequired(), Email()])
submit = SubmitField('Request Password Reset')
class ResetPasswordForm(FlaskForm):
password = PasswordField('New Password', validators=[DataRequired(), Length(min=6)])
confirm_password = PasswordField('Confirm Password', validators=[DataRequired(), EqualTo('password')])
submit = SubmitField('Reset Password')

View File

@@ -38,17 +38,24 @@ window.addEventListener('resize', resizeMap);
resizeMap();
// Add OSM tiles
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© OpenStreetMap contributors'
// Use CartoDB Positron for English map labels (clean, readable, English by default)
L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', {
attribution: '© OpenStreetMap contributors & CartoDB',
subdomains: 'abcd',
maxZoom: 19
}).addTo(map);
// Ensure map is always north-up (default in Leaflet)
// Prevent any future rotation plugins or gestures
map.dragRotate && map.dragRotate.disable && map.dragRotate.disable();
map.touchZoomRotate && map.touchZoomRotate.disableRotation && map.touchZoomRotate.disableRotation();
// Fetch route data
if (routeId) {
fetch(`/community/api/route/${routeId}`)
.then(response => response.json())
.then(data => {
if (data && data.coordinates && data.coordinates.length > 0) {
const latlngs = data.coordinates.map(pt => [pt[1], pt[0]]);
const latlngs = data.coordinates.map(pt => [pt[0], pt[1]]);
const polyline = L.polyline(latlngs, { color: '#ef4444', weight: 5, opacity: 0.9 }).addTo(map);
map.fitBounds(polyline.getBounds(), { padding: [20, 20], maxZoom: 15 });
// Start marker
@@ -70,30 +77,4 @@ if (routeId) {
}
</script>
</body>
</html>
color: '#f97316', weight: 5, opacity: 0.9, smoothFactor: 1
}).addTo(routeLayer);
polyline.bindPopup(`<div><h3>🏍️ ${route.title}</h3><div>Distance: ${route.distance.toFixed(2)} km</div><div>Elevation Gain: ${route.elevation_gain.toFixed(0)} m</div></div>`);
// Always fit bounds to the route and bind to frame size
function fitRouteBounds() {
map.invalidateSize();
map.fitBounds(polyline.getBounds(), { padding: [10, 10], maxZoom: 18 });
}
fitRouteBounds();
window.addEventListener('resize', fitRouteBounds);
})
.catch(error => showError(error.message));
}
function showError(msg) {
const loading = document.getElementById('map-loading');
loading.innerHTML = `<div style='color:#dc2626;'><div style='font-size:1.2em;margin-bottom:8px;'>⚠️</div><div>${msg}</div></div>`;
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initializeMap);
} else {
initializeMap();
}
window.addEventListener('resize', () => { if (map) setTimeout(() => map.invalidateSize(), 100); });
</script>
</body>
</html>

View File

@@ -173,6 +173,11 @@
<i class="fas fa-chart-bar"></i> Analytics
</a>
</li>
<li class="nav-item">
<a class="nav-link {{ 'active' if request.endpoint == 'admin.mail_settings' }}" href="{{ url_for('admin.mail_settings') }}">
<i class="fas fa-envelope"></i> Mail Server Settings
</a>
</li>
</ul>
<div class="sidebar-heading">

View File

@@ -0,0 +1,66 @@
{% extends 'admin/base.html' %}
{% block admin_content %}
<h2>Mail Server Settings</h2>
<form method="post">
<div class="form-group">
<label for="enabled">Enable Email Sending</label>
<input type="checkbox" id="enabled" name="enabled" value="1" {% if settings and settings.enabled %}checked{% endif %}>
</div>
<div class="form-group">
<label for="provider">Provider</label>
<select id="provider" name="provider" class="form-control">
<option value="smtp" {% if settings and settings.provider == 'smtp' %}selected{% endif %}>SMTP</option>
<option value="mailrise" {% if settings and settings.provider == 'mailrise' %}selected{% endif %}>Mailrise</option>
</select>
</div>
<div class="form-group">
<label for="server">Server</label>
<input type="text" id="server" name="server" class="form-control" value="{{ settings.server if settings else '' }}">
</div>
<div class="form-group">
<label for="port">Port</label>
<input type="number" id="port" name="port" class="form-control" value="{{ settings.port if settings else '' }}">
</div>
<div class="form-group">
<label for="use_tls">Use TLS</label>
<input type="checkbox" id="use_tls" name="use_tls" value="1" {% if settings and settings.use_tls %}checked{% endif %}>
</div>
<div class="form-group">
<label for="username">Username</label>
<input type="text" id="username" name="username" class="form-control" value="{{ settings.username if settings else '' }}">
</div>
<div class="form-group">
<label for="password">Password</label>
<input type="password" id="password" name="password" class="form-control" value="{{ settings.password if settings else '' }}">
</div>
<div class="form-group">
<label for="default_sender">Default Sender</label>
<input type="text" id="default_sender" name="default_sender" class="form-control" value="{{ settings.default_sender if settings else '' }}">
</div>
<button type="submit" class="btn btn-primary">Save Settings</button>
</form>
<hr>
<h3>Sent Emails Log</h3>
<table class="table table-striped">
<thead>
<tr>
<th>Date</th>
<th>Recipient</th>
<th>Subject</th>
<th>Status</th>
<th>Error</th>
</tr>
</thead>
<tbody>
{% for email in sent_emails %}
<tr>
<td>{{ email.sent_at.strftime('%Y-%m-%d %H:%M') }}</td>
<td>{{ email.recipient }}</td>
<td>{{ email.subject }}</td>
<td>{{ email.status }}</td>
<td>{{ email.error or '' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endblock %}

View File

@@ -17,6 +17,19 @@
<div class="row">
<!-- User Information -->
<div class="col-lg-4">
<!-- Admin: Reset Password Card -->
<div class="card shadow mb-4">
<div class="card-header py-3">
<h6 class="m-0 font-weight-bold text-danger">Admin: Reset Password</h6>
</div>
<div class="card-body">
<form method="POST" action="{{ url_for('admin.reset_user_password', user_id=user.id) }}">
<button type="submit" class="btn btn-warning w-100" onclick="return confirm('Send password reset email to this user?')">
<i class="fas fa-envelope"></i> Send Password Reset Email
</button>
</form>
</div>
</div>
<div class="card shadow mb-4">
<div class="card-header py-3">
<h6 class="m-0 font-weight-bold text-primary">User Information</h6>

View File

@@ -0,0 +1,27 @@
{% extends "base.html" %}
{% block title %}Forgot Password{% endblock %}
{% block content %}
<div class="min-h-screen bg-gray-50 py-20">
<div class="max-w-md mx-auto bg-white rounded-lg shadow-lg overflow-hidden">
<div class="bg-gradient-to-r from-blue-500 via-purple-500 to-teal-500 p-6 text-white text-center">
<h2 class="text-2xl font-bold">Forgot your password?</h2>
<p class="text-blue-100 mt-1">Enter your email to receive a reset link.</p>
</div>
<form method="POST" class="p-6 space-y-6">
{{ form.hidden_tag() }}
<div>
{{ form.email.label(class="block text-sm font-medium text-gray-700 mb-1") }}
{{ form.email(class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent") }}
{% if form.email.errors %}
<div class="text-red-500 text-sm mt-1">
{% for error in form.email.errors %}
<p>{{ error }}</p>
{% endfor %}
</div>
{% endif %}
</div>
{{ form.submit(class="w-full bg-gradient-to-r from-blue-600 to-purple-600 text-white py-2 px-4 rounded-lg hover:from-blue-700 hover:to-purple-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition font-medium") }}
</form>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,38 @@
{% extends "base.html" %}
{% block title %}Reset Password{% endblock %}
{% block content %}
<div class="min-h-screen bg-gray-50 py-20">
<div class="max-w-md mx-auto bg-white rounded-lg shadow-lg overflow-hidden">
<div class="bg-gradient-to-r from-blue-500 via-purple-500 to-teal-500 p-6 text-white text-center">
<h2 class="text-2xl font-bold">Reset your password</h2>
<p class="text-blue-100 mt-1">Enter your new password below.</p>
</div>
<form method="POST" class="p-6 space-y-6">
{{ form.hidden_tag() }}
<div>
{{ form.password.label(class="block text-sm font-medium text-gray-700 mb-1") }}
{{ form.password(class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent") }}
{% if form.password.errors %}
<div class="text-red-500 text-sm mt-1">
{% for error in form.password.errors %}
<p>{{ error }}</p>
{% endfor %}
</div>
{% endif %}
</div>
<div>
{{ form.confirm_password.label(class="block text-sm font-medium text-gray-700 mb-1") }}
{{ form.confirm_password(class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent") }}
{% if form.confirm_password.errors %}
<div class="text-red-500 text-sm mt-1">
{% for error in form.confirm_password.errors %}
<p>{{ error }}</p>
{% endfor %}
</div>
{% endif %}
</div>
{{ form.submit(class="w-full bg-gradient-to-r from-blue-600 to-purple-600 text-white py-2 px-4 rounded-lg hover:from-blue-700 hover:to-purple-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition font-medium") }}
</form>
</div>
</div>
{% endblock %}

19
app/utils/token.py Normal file
View File

@@ -0,0 +1,19 @@
import os
from itsdangerous import URLSafeTimedSerializer
from flask import current_app
def get_serializer():
secret = os.environ.get('RESET_TOKEN_SECRET') or current_app.config.get('SECRET_KEY')
return URLSafeTimedSerializer(secret)
def generate_reset_token(email):
s = get_serializer()
return s.dumps(email, salt='password-reset')
def verify_reset_token(token, max_age=3600):
s = get_serializer()
try:
email = s.loads(token, salt='password-reset', max_age=max_age)
return email
except Exception:
return None

View File

@@ -23,6 +23,7 @@ class Config:
ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'webp', 'gpx'}
# Email Configuration
MAIL_ENABLED = os.environ.get('MAIL_ENABLED', 'true').lower() in ['true', 'on', '1']
MAIL_SERVER = os.environ.get('MAIL_SERVER') or 'smtp.gmail.com'
MAIL_PORT = int(os.environ.get('MAIL_PORT') or 587)
MAIL_USE_TLS = os.environ.get('MAIL_USE_TLS', 'true').lower() in ['true', 'on', '1']

25
create_admin.py Normal file
View File

@@ -0,0 +1,25 @@
import os
from app import create_app
from app.extensions import db
from app.models import User
def create_admin():
app = create_app()
with app.app_context():
admin_email = os.environ.get('ADMIN_EMAIL')
admin_nickname = os.environ.get('ADMIN_NICKNAME')
admin_password = os.environ.get('ADMIN_PASSWORD')
if not (admin_email and admin_nickname and admin_password):
print("Missing ADMIN_EMAIL, ADMIN_NICKNAME, or ADMIN_PASSWORD in environment.")
return
if User.query.filter_by(email=admin_email).first():
print(f"Admin with email {admin_email} already exists.")
return
user = User(nickname=admin_nickname, email=admin_email, is_admin=True, is_active=True)
user.set_password(admin_password)
db.session.add(user)
db.session.commit()
print(f"Admin user {admin_nickname} <{admin_email}> created.")
if __name__ == "__main__":
create_admin()

1
migrations/README Normal file
View File

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

View File

@@ -1,28 +0,0 @@
"""Add media_folder to posts table
This migration adds a media_folder column to the posts table to support
organized file storage for each post.
Usage:
flask db upgrade
Revision ID: add_media_folder
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers
revision = 'add_media_folder'
down_revision = None
depends_on = None
def upgrade():
"""Add media_folder column to posts table"""
# Add the media_folder column
op.add_column('posts', sa.Column('media_folder', sa.String(100), nullable=True))
def downgrade():
"""Remove media_folder column from posts table"""
# Remove the media_folder column
op.drop_column('posts', 'media_folder')

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

View File

@@ -1,103 +0,0 @@
"""
Database migration to add MapRoute table for efficient map loading
Run this script to create the new table structure
"""
import os
import sys
from flask import Flask
from flask_sqlalchemy import SQLAlchemy
# Add the project root to Python path
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from app import create_app, db
def create_map_route_table():
"""Create the MapRoute table"""
app = create_app()
with app.app_context():
try:
# Create the MapRoute table
db.create_all()
print("✅ MapRoute table created successfully!")
# Show current tables
inspector = db.inspect(db.engine)
tables = inspector.get_table_names()
print(f"Current tables: {', '.join(tables)}")
if 'map_routes' in tables:
print("✅ map_routes table confirmed in database")
else:
print("❌ map_routes table not found")
except Exception as e:
print(f"❌ Error creating MapRoute table: {e}")
return False
return True
def populate_existing_routes():
"""Process existing published posts with GPX files to create map routes"""
app = create_app()
with app.app_context():
try:
from app.models import Post, GPXFile, MapRoute
from app.utils.gpx_processor import create_map_route_from_gpx
# Find published posts with GPX files that don't have map routes yet
posts_with_gpx = db.session.query(Post).join(GPXFile).filter(
Post.published == True
).distinct().all()
print(f"Found {len(posts_with_gpx)} published posts with GPX files")
processed = 0
errors = 0
for post in posts_with_gpx:
# Check if map route already exists
existing_route = MapRoute.query.filter_by(post_id=post.id).first()
if existing_route:
print(f"⏭️ Post {post.id} already has a map route, skipping")
continue
# Get the first GPX file for this post
gpx_file = GPXFile.query.filter_by(post_id=post.id).first()
if gpx_file:
print(f"🔄 Processing post {post.id}: {post.title}")
success = create_map_route_from_gpx(gpx_file.id)
if success:
processed += 1
print(f"✅ Created map route for post {post.id}")
else:
errors += 1
print(f"❌ Failed to create map route for post {post.id}")
else:
print(f"⚠️ No GPX file found for post {post.id}")
print(f"\n📊 Summary:")
print(f"- Processed: {processed}")
print(f"- Errors: {errors}")
print(f"- Total posts with GPX: {len(posts_with_gpx)}")
except Exception as e:
print(f"❌ Error populating existing routes: {e}")
return False
return True
if __name__ == "__main__":
print("🚀 Creating MapRoute table and processing existing data...")
# Create the table
if create_map_route_table():
print("\n🔄 Processing existing published posts with GPX files...")
populate_existing_routes()
print("\n✅ Migration completed!")

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,52 @@
"""add mail settings and sent email tables
Revision ID: df85d77e2474
Revises:
Create Date: 2025-07-26 18:26:25.676774
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'df85d77e2474'
down_revision = None
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('mail_settings',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('enabled', sa.Boolean(), nullable=False),
sa.Column('server', sa.String(length=255), nullable=False),
sa.Column('port', sa.Integer(), nullable=False),
sa.Column('use_tls', sa.Boolean(), nullable=False),
sa.Column('username', sa.String(length=255), nullable=True),
sa.Column('password', sa.String(length=255), nullable=True),
sa.Column('default_sender', sa.String(length=255), nullable=True),
sa.Column('provider', sa.String(length=50), nullable=True),
sa.Column('updated_at', sa.DateTime(), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_table('sent_emails',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('recipient', sa.String(length=255), nullable=False),
sa.Column('subject', sa.String(length=255), nullable=False),
sa.Column('body', sa.Text(), nullable=False),
sa.Column('status', sa.String(length=50), nullable=True),
sa.Column('error', sa.Text(), nullable=True),
sa.Column('sent_at', sa.DateTime(), nullable=True),
sa.Column('provider', sa.String(length=50), nullable=True),
sa.PrimaryKeyConstraint('id')
)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('sent_emails')
op.drop_table('mail_settings')
# ### end Alembic commands ###