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(upload_dir, exist_ok=True)
|
||||||
os.makedirs(os.path.join(upload_dir, 'images'), 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)
|
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
|
return app
|
||||||
|
|||||||
@@ -273,3 +273,35 @@ class PageView(db.Model):
|
|||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return f'<PageView {self.path} at {self.created_at}>'
|
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 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 flask_login import login_required, current_user
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from sqlalchemy import func, desc
|
from sqlalchemy import func, desc
|
||||||
|
import secrets
|
||||||
|
from app.routes.mail import mail
|
||||||
from app.extensions import db
|
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')
|
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):
|
def admin_required(f):
|
||||||
"""Decorator to require admin access"""
|
"""Decorator to require admin access"""
|
||||||
@wraps(f)
|
@wraps(f)
|
||||||
@@ -98,7 +173,7 @@ def dashboard():
|
|||||||
def posts():
|
def posts():
|
||||||
"""Admin post management - review posts"""
|
"""Admin post management - review posts"""
|
||||||
page = request.args.get('page', 1, type=int)
|
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
|
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 werkzeug.security import check_password_hash
|
||||||
from app.models import User, db
|
from app.models import User, db
|
||||||
from app.forms import LoginForm, RegisterForm, ForgotPasswordForm
|
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
|
import re
|
||||||
|
from app.forms import LoginForm, RegisterForm, ForgotPasswordForm
|
||||||
auth = Blueprint('auth', __name__)
|
auth = Blueprint('auth', __name__)
|
||||||
|
|
||||||
@auth.route('/login', methods=['GET', 'POST'])
|
@auth.route('/login', methods=['GET', 'POST'])
|
||||||
@@ -83,18 +87,44 @@ def forgot_password():
|
|||||||
"""Forgot password page"""
|
"""Forgot password page"""
|
||||||
if current_user.is_authenticated:
|
if current_user.is_authenticated:
|
||||||
return redirect(url_for('main.index'))
|
return redirect(url_for('main.index'))
|
||||||
|
form = RequestResetForm()
|
||||||
form = ForgotPasswordForm()
|
|
||||||
if form.validate_on_submit():
|
if form.validate_on_submit():
|
||||||
user = User.query.filter_by(email=form.email.data).first()
|
user = User.query.filter_by(email=form.email.data).first()
|
||||||
if user:
|
if user:
|
||||||
# TODO: Implement email sending for password reset
|
token = generate_reset_token(user.email)
|
||||||
flash('If an account with that email exists, we\'ve sent password reset instructions.', 'info')
|
reset_url = url_for('auth.reset_password', token=token, _external=True)
|
||||||
else:
|
msg = Message(
|
||||||
flash('If an account with that email exists, we\'ve sent password reset instructions.', 'info')
|
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 redirect(url_for('auth.login'))
|
||||||
|
|
||||||
return render_template('auth/forgot_password.html', form=form)
|
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):
|
def is_valid_password(password):
|
||||||
"""Validate password strength"""
|
"""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();
|
resizeMap();
|
||||||
|
|
||||||
// Add OSM tiles
|
// Add OSM tiles
|
||||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
// Use CartoDB Positron for English map labels (clean, readable, English by default)
|
||||||
attribution: '© OpenStreetMap contributors'
|
L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', {
|
||||||
|
attribution: '© OpenStreetMap contributors & CartoDB',
|
||||||
|
subdomains: 'abcd',
|
||||||
|
maxZoom: 19
|
||||||
}).addTo(map);
|
}).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
|
// Fetch route data
|
||||||
if (routeId) {
|
if (routeId) {
|
||||||
fetch(`/community/api/route/${routeId}`)
|
fetch(`/community/api/route/${routeId}`)
|
||||||
.then(response => response.json())
|
.then(response => response.json())
|
||||||
.then(data => {
|
.then(data => {
|
||||||
if (data && data.coordinates && data.coordinates.length > 0) {
|
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);
|
const polyline = L.polyline(latlngs, { color: '#ef4444', weight: 5, opacity: 0.9 }).addTo(map);
|
||||||
map.fitBounds(polyline.getBounds(), { padding: [20, 20], maxZoom: 15 });
|
map.fitBounds(polyline.getBounds(), { padding: [20, 20], maxZoom: 15 });
|
||||||
// Start marker
|
// Start marker
|
||||||
@@ -70,30 +77,4 @@ if (routeId) {
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</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>
|
</html>
|
||||||
|
|||||||
@@ -173,6 +173,11 @@
|
|||||||
<i class="fas fa-chart-bar"></i> Analytics
|
<i class="fas fa-chart-bar"></i> Analytics
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</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>
|
</ul>
|
||||||
|
|
||||||
<div class="sidebar-heading">
|
<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">
|
<div class="row">
|
||||||
<!-- User Information -->
|
<!-- User Information -->
|
||||||
<div class="col-lg-4">
|
<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 shadow mb-4">
|
||||||
<div class="card-header py-3">
|
<div class="card-header py-3">
|
||||||
<h6 class="m-0 font-weight-bold text-primary">User Information</h6>
|
<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'}
|
ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'webp', 'gpx'}
|
||||||
|
|
||||||
# Email Configuration
|
# 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_SERVER = os.environ.get('MAIL_SERVER') or 'smtp.gmail.com'
|
||||||
MAIL_PORT = int(os.environ.get('MAIL_PORT') or 587)
|
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']
|
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