Finalize mail settings admin UI and Mailrise compatibility
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}>'
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
2
app/routes/mail.py
Normal file
@@ -0,0 +1,2 @@
|
||||
from flask_mail import Mail
|
||||
mail = Mail()
|
||||
12
app/routes/reset_password.py
Normal file
12
app/routes/reset_password.py
Normal 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')
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
66
app/templates/admin/mail_settings.html
Normal file
66
app/templates/admin/mail_settings.html
Normal 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 %}
|
||||
@@ -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>
|
||||
|
||||
27
app/templates/auth/forgot_password.html
Normal file
27
app/templates/auth/forgot_password.html
Normal 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 %}
|
||||
38
app/templates/auth/reset_password.html
Normal file
38
app/templates/auth/reset_password.html
Normal 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
19
app/utils/token.py
Normal 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
|
||||
@@ -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
25
create_admin.py
Normal 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
1
migrations/README
Normal file
@@ -0,0 +1 @@
|
||||
Single-database configuration for Flask.
|
||||
@@ -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
50
migrations/alembic.ini
Normal 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
|
||||
@@ -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
113
migrations/env.py
Normal 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
24
migrations/script.py.mako
Normal 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"}
|
||||
@@ -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 ###
|
||||
Reference in New Issue
Block a user