password reset
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
7
app.py
7
app.py
@@ -112,6 +112,13 @@ def create_app():
|
|||||||
cleanup_trash()
|
cleanup_trash()
|
||||||
click.echo("Trash cleanup completed.")
|
click.echo("Trash cleanup completed.")
|
||||||
|
|
||||||
|
@app.cli.command("cleanup-tokens")
|
||||||
|
def cleanup_tokens_command():
|
||||||
|
"""Clean up expired password reset and setup tokens."""
|
||||||
|
from tasks import cleanup_expired_tokens
|
||||||
|
cleanup_expired_tokens()
|
||||||
|
click.echo("Token cleanup completed.")
|
||||||
|
|
||||||
@app.cli.command("create-admin")
|
@app.cli.command("create-admin")
|
||||||
def create_admin():
|
def create_admin():
|
||||||
"""Create the default administrator user."""
|
"""Create the default administrator user."""
|
||||||
|
|||||||
34
migrations/versions/add_password_reset_tokens_table.py
Normal file
34
migrations/versions/add_password_reset_tokens_table.py
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
"""Add password reset tokens table
|
||||||
|
|
||||||
|
Revision ID: add_password_reset_tokens
|
||||||
|
Revises: be1f7bdd10e1
|
||||||
|
Create Date: 2024-01-01 12:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = 'add_password_reset_tokens'
|
||||||
|
down_revision = 'be1f7bdd10e1'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
# Create password_reset_tokens table
|
||||||
|
op.create_table('password_reset_tokens',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('token', sa.String(length=100), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(), nullable=True),
|
||||||
|
sa.Column('expires_at', sa.DateTime(), nullable=False),
|
||||||
|
sa.Column('used', sa.Boolean(), nullable=True),
|
||||||
|
sa.Column('ip_address', sa.String(length=45), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.UniqueConstraint('token')
|
||||||
|
)
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
# Drop password_reset_tokens table
|
||||||
|
op.drop_table('password_reset_tokens')
|
||||||
19
models.py
19
models.py
@@ -447,6 +447,25 @@ class PasswordSetupToken(db.Model):
|
|||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return f'<PasswordSetupToken {self.token}>'
|
return f'<PasswordSetupToken {self.token}>'
|
||||||
|
|
||||||
|
class PasswordResetToken(db.Model):
|
||||||
|
__tablename__ = 'password_reset_tokens'
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
user_id = db.Column(db.Integer, db.ForeignKey('user.id', ondelete='CASCADE'), nullable=False)
|
||||||
|
token = db.Column(db.String(100), unique=True, nullable=False)
|
||||||
|
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||||
|
expires_at = db.Column(db.DateTime, nullable=False)
|
||||||
|
used = db.Column(db.Boolean, default=False)
|
||||||
|
ip_address = db.Column(db.String(45)) # Store IP address for security
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
user = db.relationship('User', backref=db.backref('password_reset_tokens', cascade='all, delete-orphan'))
|
||||||
|
|
||||||
|
def is_valid(self):
|
||||||
|
return not self.used and datetime.utcnow() < self.expires_at
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f'<PasswordResetToken {self.token}>'
|
||||||
|
|
||||||
def user_has_permission(room, perm_name):
|
def user_has_permission(room, perm_name):
|
||||||
"""
|
"""
|
||||||
Check if the current user has a specific permission in a room.
|
Check if the current user has a specific permission in a room.
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
159
routes/auth.py
159
routes/auth.py
@@ -1,10 +1,12 @@
|
|||||||
from flask import render_template, request, flash, redirect, url_for, Blueprint, jsonify
|
from flask import render_template, request, flash, redirect, url_for, Blueprint, jsonify
|
||||||
from flask_login import login_user, logout_user, login_required, current_user
|
from flask_login import login_user, logout_user, login_required, current_user
|
||||||
from models import db, User, Notif, PasswordSetupToken
|
from models import db, User, Notif, PasswordSetupToken, PasswordResetToken
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
from datetime import datetime
|
from datetime import datetime, timedelta
|
||||||
from utils import log_event, create_notification, get_unread_count
|
from utils import log_event, create_notification, get_unread_count
|
||||||
|
from utils.notification import generate_mail_from_notification
|
||||||
import string
|
import string
|
||||||
|
import secrets
|
||||||
|
|
||||||
auth_bp = Blueprint('auth', __name__)
|
auth_bp = Blueprint('auth', __name__)
|
||||||
|
|
||||||
@@ -306,4 +308,155 @@ def init_routes(auth_bp):
|
|||||||
flash('Password set up successfully! Welcome to DocuPulse.', 'success')
|
flash('Password set up successfully! Welcome to DocuPulse.', 'success')
|
||||||
return redirect(url_for('main.dashboard'))
|
return redirect(url_for('main.dashboard'))
|
||||||
|
|
||||||
return render_template('auth/setup_password.html')
|
return render_template('auth/setup_password.html')
|
||||||
|
|
||||||
|
@auth_bp.route('/forgot-password', methods=['GET', 'POST'])
|
||||||
|
def forgot_password():
|
||||||
|
if current_user.is_authenticated:
|
||||||
|
return redirect(url_for('main.dashboard'))
|
||||||
|
|
||||||
|
if request.method == 'POST':
|
||||||
|
email = request.form.get('email')
|
||||||
|
|
||||||
|
if not email:
|
||||||
|
flash('Please enter your email address.', 'error')
|
||||||
|
return render_template('auth/forgot_password.html')
|
||||||
|
|
||||||
|
# Check if user exists
|
||||||
|
user = User.query.filter_by(email=email).first()
|
||||||
|
|
||||||
|
if user:
|
||||||
|
# Generate a secure token
|
||||||
|
token = secrets.token_urlsafe(32)
|
||||||
|
|
||||||
|
# Create password reset token
|
||||||
|
reset_token = PasswordResetToken(
|
||||||
|
user_id=user.id,
|
||||||
|
token=token,
|
||||||
|
expires_at=datetime.utcnow() + timedelta(hours=1), # 1 hour expiration
|
||||||
|
ip_address=request.remote_addr
|
||||||
|
)
|
||||||
|
db.session.add(reset_token)
|
||||||
|
|
||||||
|
# Create notification for password reset
|
||||||
|
notif = create_notification(
|
||||||
|
notif_type='password_reset',
|
||||||
|
user_id=user.id,
|
||||||
|
details={
|
||||||
|
'message': 'You requested a password reset. Click the link below to reset your password.',
|
||||||
|
'reset_link': url_for('auth.reset_password', token=token, _external=True),
|
||||||
|
'expiry_time': (datetime.utcnow() + timedelta(hours=1)).strftime('%Y-%m-%d %H:%M:%S UTC'),
|
||||||
|
'ip_address': request.remote_addr,
|
||||||
|
'timestamp': datetime.utcnow().isoformat()
|
||||||
|
},
|
||||||
|
generate_mail=False # Don't auto-generate email, we'll do it manually
|
||||||
|
)
|
||||||
|
|
||||||
|
# Generate and send email manually
|
||||||
|
if notif:
|
||||||
|
generate_mail_from_notification(notif)
|
||||||
|
|
||||||
|
# Log the password reset request
|
||||||
|
log_event(
|
||||||
|
event_type='user_update',
|
||||||
|
details={
|
||||||
|
'user_id': user.id,
|
||||||
|
'user_name': f"{user.username} {user.last_name}",
|
||||||
|
'email': user.email,
|
||||||
|
'update_type': 'password_reset_request',
|
||||||
|
'ip_address': request.remote_addr,
|
||||||
|
'success': True
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
# Always show success message to prevent email enumeration
|
||||||
|
flash('If an account with that email exists, a password reset link has been sent to your email address.', 'success')
|
||||||
|
return redirect(url_for('auth.login'))
|
||||||
|
|
||||||
|
return render_template('auth/forgot_password.html')
|
||||||
|
|
||||||
|
@auth_bp.route('/reset-password/<token>', methods=['GET', 'POST'])
|
||||||
|
def reset_password(token):
|
||||||
|
if current_user.is_authenticated:
|
||||||
|
return redirect(url_for('main.dashboard'))
|
||||||
|
|
||||||
|
# Find the token
|
||||||
|
reset_token = PasswordResetToken.query.filter_by(token=token).first()
|
||||||
|
|
||||||
|
if not reset_token or not reset_token.is_valid():
|
||||||
|
flash('Invalid or expired password reset link. Please request a new password reset.', 'error')
|
||||||
|
return redirect(url_for('auth.forgot_password'))
|
||||||
|
|
||||||
|
if request.method == 'POST':
|
||||||
|
password = request.form.get('password')
|
||||||
|
confirm_password = request.form.get('confirm_password')
|
||||||
|
|
||||||
|
if not password or not confirm_password:
|
||||||
|
flash('Please fill in all fields.', 'error')
|
||||||
|
return render_template('auth/reset_password.html', token=token)
|
||||||
|
|
||||||
|
if password != confirm_password:
|
||||||
|
flash('Passwords do not match.', 'error')
|
||||||
|
return render_template('auth/reset_password.html', token=token)
|
||||||
|
|
||||||
|
# Password requirements
|
||||||
|
if len(password) < 8:
|
||||||
|
flash('Password must be at least 8 characters long.', 'error')
|
||||||
|
return render_template('auth/reset_password.html', token=token)
|
||||||
|
|
||||||
|
if not any(c.isupper() for c in password):
|
||||||
|
flash('Password must contain at least one uppercase letter.', 'error')
|
||||||
|
return render_template('auth/reset_password.html', token=token)
|
||||||
|
|
||||||
|
if not any(c.islower() for c in password):
|
||||||
|
flash('Password must contain at least one lowercase letter.', 'error')
|
||||||
|
return render_template('auth/reset_password.html', token=token)
|
||||||
|
|
||||||
|
if not any(c.isdigit() for c in password):
|
||||||
|
flash('Password must contain at least one number.', 'error')
|
||||||
|
return render_template('auth/reset_password.html', token=token)
|
||||||
|
|
||||||
|
if not any(c in string.punctuation for c in password):
|
||||||
|
flash('Password must contain at least one special character.', 'error')
|
||||||
|
return render_template('auth/reset_password.html', token=token)
|
||||||
|
|
||||||
|
# Update user's password
|
||||||
|
user = reset_token.user
|
||||||
|
user.set_password(password)
|
||||||
|
|
||||||
|
# Mark token as used
|
||||||
|
reset_token.used = True
|
||||||
|
|
||||||
|
# Create password change notification
|
||||||
|
create_notification(
|
||||||
|
notif_type='password_changed',
|
||||||
|
user_id=user.id,
|
||||||
|
details={
|
||||||
|
'message': 'Your password has been reset successfully.',
|
||||||
|
'timestamp': datetime.utcnow().isoformat()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Log password reset event
|
||||||
|
log_event(
|
||||||
|
event_type='user_update',
|
||||||
|
details={
|
||||||
|
'user_id': user.id,
|
||||||
|
'user_name': f"{user.username} {user.last_name}",
|
||||||
|
'email': user.email,
|
||||||
|
'update_type': 'password_reset',
|
||||||
|
'ip_address': request.remote_addr,
|
||||||
|
'success': True
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
# Log the user in and redirect to dashboard
|
||||||
|
login_user(user)
|
||||||
|
flash('Password reset successfully! Welcome back to DocuPulse.', 'success')
|
||||||
|
return redirect(url_for('main.dashboard'))
|
||||||
|
|
||||||
|
return render_template('auth/reset_password.html', token=token)
|
||||||
@@ -1420,36 +1420,21 @@ def init_routes(main_bp):
|
|||||||
return jsonify({'error': 'Unauthorized'}), 403
|
return jsonify({'error': 'Unauthorized'}), 403
|
||||||
|
|
||||||
event = Event.query.get_or_404(event_id)
|
event = Event.query.get_or_404(event_id)
|
||||||
logger.info(f"Raw event object: {event}")
|
|
||||||
logger.info(f"Event details type: {type(event.details)}")
|
|
||||||
logger.info(f"Event details value: {event.details}")
|
|
||||||
|
|
||||||
# Convert details to dict if it's a string
|
return jsonify({
|
||||||
details = event.details
|
|
||||||
if isinstance(details, str):
|
|
||||||
try:
|
|
||||||
import json
|
|
||||||
details = json.loads(details)
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
details = {'raw_details': details}
|
|
||||||
|
|
||||||
# Return the raw event data
|
|
||||||
response_data = {
|
|
||||||
'id': event.id,
|
'id': event.id,
|
||||||
'event_type': event.event_type,
|
'event_type': event.event_type,
|
||||||
'timestamp': event.timestamp.isoformat(),
|
'timestamp': event.timestamp.isoformat(),
|
||||||
|
'details': event.details,
|
||||||
|
'ip_address': event.ip_address,
|
||||||
|
'user_agent': event.user_agent,
|
||||||
'user': {
|
'user': {
|
||||||
'id': event.user.id,
|
'id': event.user.id,
|
||||||
'username': event.user.username,
|
'username': event.user.username,
|
||||||
'last_name': event.user.last_name
|
'last_name': event.user.last_name,
|
||||||
} if event.user else None,
|
'email': event.user.email
|
||||||
'ip_address': event.ip_address,
|
} if event.user else None
|
||||||
'user_agent': event.user_agent,
|
})
|
||||||
'details': details
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info(f"Sending response: {response_data}")
|
|
||||||
return jsonify(response_data)
|
|
||||||
|
|
||||||
@main_bp.route('/settings/events/download')
|
@main_bp.route('/settings/events/download')
|
||||||
@login_required
|
@login_required
|
||||||
@@ -1722,4 +1707,26 @@ def init_routes(main_bp):
|
|||||||
return jsonify({
|
return jsonify({
|
||||||
'success': True,
|
'success': True,
|
||||||
'results': results
|
'results': results
|
||||||
|
})
|
||||||
|
|
||||||
|
@main_bp.route('/api/mails/<int:mail_id>')
|
||||||
|
@login_required
|
||||||
|
def get_mail_details(mail_id):
|
||||||
|
if not current_user.is_admin:
|
||||||
|
return jsonify({'error': 'Unauthorized'}), 403
|
||||||
|
|
||||||
|
mail = Mail.query.get_or_404(mail_id)
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'id': mail.id,
|
||||||
|
'recipient': mail.recipient,
|
||||||
|
'subject': mail.subject,
|
||||||
|
'body': mail.body,
|
||||||
|
'status': mail.status,
|
||||||
|
'created_at': mail.created_at.isoformat(),
|
||||||
|
'sent_at': mail.sent_at.isoformat() if mail.sent_at else None,
|
||||||
|
'template': {
|
||||||
|
'id': mail.template.id,
|
||||||
|
'name': mail.template.name
|
||||||
|
} if mail.template else None
|
||||||
})
|
})
|
||||||
@@ -78,27 +78,38 @@ function changePage(page) {
|
|||||||
|
|
||||||
function viewMailDetails(mailId) {
|
function viewMailDetails(mailId) {
|
||||||
const csrfToken = document.querySelector('meta[name="csrf-token"]').content;
|
const csrfToken = document.querySelector('meta[name="csrf-token"]').content;
|
||||||
fetch(`/settings?tab=mails&mail_id=${mailId}`, {
|
fetch(`/api/mails/${mailId}`, {
|
||||||
headers: {
|
headers: {
|
||||||
'X-CSRF-Token': csrfToken
|
'X-CSRF-Token': csrfToken,
|
||||||
|
'Accept': 'application/json'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.then(response => response.json())
|
.then(response => {
|
||||||
.then(mail => {
|
if (!response.ok) {
|
||||||
document.getElementById('modalSubject').textContent = mail.subject;
|
throw new Error('Failed to fetch mail details');
|
||||||
document.getElementById('modalRecipient').textContent = mail.recipient;
|
}
|
||||||
document.getElementById('modalStatus').innerHTML = `
|
return response.json();
|
||||||
<span class="badge ${mail.status === 'pending' ? 'bg-warning' : mail.status === 'sent' ? 'bg-success' : 'bg-danger'}">
|
})
|
||||||
${mail.status}
|
.then(mail => {
|
||||||
</span>
|
document.getElementById('modalSubject').textContent = mail.subject;
|
||||||
`;
|
document.getElementById('modalRecipient').textContent = mail.recipient;
|
||||||
document.getElementById('modalTemplate').textContent = mail.template ? mail.template.name : '-';
|
document.getElementById('modalStatus').innerHTML = `
|
||||||
document.getElementById('modalCreatedAt').textContent = new Date(mail.created_at).toLocaleString();
|
<span class="badge ${mail.status === 'pending' ? 'bg-warning' : mail.status === 'sent' ? 'bg-success' : 'bg-danger'}">
|
||||||
document.getElementById('modalSentAt').textContent = mail.sent_at ? new Date(mail.sent_at).toLocaleString() : '-';
|
${mail.status}
|
||||||
document.getElementById('modalBody').innerHTML = mail.body;
|
</span>
|
||||||
|
`;
|
||||||
new bootstrap.Modal(document.getElementById('mailDetailsModal')).show();
|
document.getElementById('modalTemplate').textContent = mail.template ? mail.template.name : '-';
|
||||||
});
|
document.getElementById('modalCreatedAt').textContent = new Date(mail.created_at).toLocaleString();
|
||||||
|
document.getElementById('modalSentAt').textContent = mail.sent_at ? new Date(mail.sent_at).toLocaleString() : '-';
|
||||||
|
document.getElementById('modalBody').innerHTML = mail.body;
|
||||||
|
|
||||||
|
new bootstrap.Modal(document.getElementById('mailDetailsModal')).show();
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error viewing mail details:', error);
|
||||||
|
// Show a user-friendly error message
|
||||||
|
alert('Error loading mail details. Please try again.');
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function downloadMailLog() {
|
function downloadMailLog() {
|
||||||
|
|||||||
41
tasks.py
41
tasks.py
@@ -1,5 +1,5 @@
|
|||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from models import db, RoomFile
|
from models import db, RoomFile, PasswordResetToken, PasswordSetupToken
|
||||||
import os
|
import os
|
||||||
|
|
||||||
def cleanup_trash():
|
def cleanup_trash():
|
||||||
@@ -36,4 +36,43 @@ def cleanup_trash():
|
|||||||
db.session.commit()
|
db.session.commit()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error committing changes: {str(e)}")
|
print(f"Error committing changes: {str(e)}")
|
||||||
|
db.session.rollback()
|
||||||
|
|
||||||
|
def cleanup_expired_tokens():
|
||||||
|
"""
|
||||||
|
Removes expired password reset and setup tokens from the database.
|
||||||
|
This function should be called by a scheduler (e.g., cron job) daily.
|
||||||
|
"""
|
||||||
|
current_time = datetime.utcnow()
|
||||||
|
|
||||||
|
# Clean up expired password reset tokens
|
||||||
|
expired_reset_tokens = PasswordResetToken.query.filter(
|
||||||
|
PasswordResetToken.expires_at < current_time
|
||||||
|
).all()
|
||||||
|
|
||||||
|
for token in expired_reset_tokens:
|
||||||
|
try:
|
||||||
|
db.session.delete(token)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error deleting expired password reset token {token.id}: {str(e)}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Clean up expired password setup tokens
|
||||||
|
expired_setup_tokens = PasswordSetupToken.query.filter(
|
||||||
|
PasswordSetupToken.expires_at < current_time
|
||||||
|
).all()
|
||||||
|
|
||||||
|
for token in expired_setup_tokens:
|
||||||
|
try:
|
||||||
|
db.session.delete(token)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error deleting expired password setup token {token.id}: {str(e)}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Commit all changes
|
||||||
|
try:
|
||||||
|
db.session.commit()
|
||||||
|
print(f"Cleaned up {len(expired_reset_tokens)} expired password reset tokens and {len(expired_setup_tokens)} expired password setup tokens")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error committing token cleanup changes: {str(e)}")
|
||||||
db.session.rollback()
|
db.session.rollback()
|
||||||
50
templates/auth/forgot_password.html
Normal file
50
templates/auth/forgot_password.html
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Forgot Password - DocuPulse</title>
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/auth.css') }}?v={{ 'css/auth.css'|asset_version }}">
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/colors.css') }}?v={{ 'css/colors.css'|asset_version }}">
|
||||||
|
<link rel="stylesheet" href="{{ url_for('main.dynamic_colors') }}?v={{ site_settings.updated_at.timestamp() }}" onload="console.log('[CSS] Dynamic colors loaded with version:', '{{ site_settings.updated_at.timestamp() }}')">
|
||||||
|
<script src="{{ url_for('static', filename='js/color-logger.js') }}?v={{ 'js/color-logger.js'|asset_version }}"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="auth-container">
|
||||||
|
<div class="card auth-card">
|
||||||
|
<div class="auth-header text-center">
|
||||||
|
<h2><i class="fas fa-key me-2"></i>Forgot Password</h2>
|
||||||
|
<p class="mb-0">Enter your email to reset your password</p>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-4">
|
||||||
|
<form method="POST" action="{{ url_for('auth.forgot_password') }}">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="email" class="form-label">
|
||||||
|
<i class="fas fa-envelope me-2" style="color: var(--primary-color);"></i>Email address
|
||||||
|
</label>
|
||||||
|
<input type="email" class="form-control" id="email" name="email" required>
|
||||||
|
<div class="form-text">We'll send you a link to reset your password.</div>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary w-100"
|
||||||
|
style="background-color: var(--primary-color); border-color: var(--primary-color);"
|
||||||
|
onmouseover="this.style.backgroundColor='var(--primary-light)'"
|
||||||
|
onmouseout="this.style.backgroundColor='var(--primary-color)'">
|
||||||
|
<i class="fas fa-paper-plane me-2"></i>Send Reset Link
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="text-center mt-3">
|
||||||
|
<p class="mb-0">Remember your password? <a href="{{ url_for('auth.login') }}" class="text-decoration-none">Sign In</a></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -45,6 +45,11 @@
|
|||||||
<i class="fas fa-sign-in-alt me-2"></i>Sign In
|
<i class="fas fa-sign-in-alt me-2"></i>Sign In
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<div class="text-center mt-3">
|
||||||
|
<p class="mb-1"><a href="{{ url_for('auth.forgot_password') }}" class="text-decoration-none">Forgot your password?</a></p>
|
||||||
|
<p class="mb-0">Don't have an account? <a href="{{ url_for('auth.register') }}" class="text-decoration-none">Sign Up</a></p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
157
templates/auth/reset_password.html
Normal file
157
templates/auth/reset_password.html
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Reset Password - DocuPulse</title>
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/auth.css') }}?v={{ 'css/auth.css'|asset_version }}">
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/colors.css') }}?v={{ 'css/colors.css'|asset_version }}">
|
||||||
|
<link rel="stylesheet" href="{{ url_for('main.dynamic_colors') }}?v={{ site_settings.updated_at.timestamp() }}" onload="console.log('[CSS] Dynamic colors loaded with version:', '{{ site_settings.updated_at.timestamp() }}')">
|
||||||
|
<script src="{{ url_for('static', filename='js/color-logger.js') }}?v={{ 'js/color-logger.js'|asset_version }}"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="auth-container">
|
||||||
|
<div class="card auth-card">
|
||||||
|
<div class="auth-header text-center">
|
||||||
|
<h2><i class="fas fa-lock me-2"></i>Reset Password</h2>
|
||||||
|
<p class="mb-0">Enter your new password</p>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-4">
|
||||||
|
<form method="POST" action="{{ url_for('auth.reset_password', token=token) }}">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="password" class="form-label">
|
||||||
|
<i class="fas fa-lock me-2" style="color: var(--primary-color);"></i>New Password
|
||||||
|
</label>
|
||||||
|
<input type="password" class="form-control" id="password" name="password" required>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="confirm_password" class="form-label">
|
||||||
|
<i class="fas fa-check-circle me-2" style="color: var(--primary-color);"></i>Confirm New Password
|
||||||
|
</label>
|
||||||
|
<input type="password" class="form-control" id="confirm_password" name="confirm_password" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 space-y-2">
|
||||||
|
<h6 class="text-sm font-medium text-gray-700">Password Requirements:</h6>
|
||||||
|
<ul class="list-unstyled" id="password-requirements">
|
||||||
|
<li id="length-req" class="text-sm text-gray-500 mb-1">
|
||||||
|
<i class="fas fa-times-circle mr-2"></i>At least 8 characters long
|
||||||
|
</li>
|
||||||
|
<li id="uppercase-req" class="text-sm text-gray-500 mb-1">
|
||||||
|
<i class="fas fa-times-circle mr-2"></i>At least one uppercase letter
|
||||||
|
</li>
|
||||||
|
<li id="lowercase-req" class="text-sm text-gray-500 mb-1">
|
||||||
|
<i class="fas fa-times-circle mr-2"></i>At least one lowercase letter
|
||||||
|
</li>
|
||||||
|
<li id="number-req" class="text-sm text-gray-500 mb-1">
|
||||||
|
<i class="fas fa-times-circle mr-2"></i>At least one number
|
||||||
|
</li>
|
||||||
|
<li id="special-req" class="text-sm text-gray-500 mb-1">
|
||||||
|
<i class="fas fa-times-circle mr-2"></i>At least one special character
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="btn btn-primary w-100 mt-4"
|
||||||
|
style="background-color: var(--primary-color); border-color: var(--primary-color);"
|
||||||
|
onmouseover="this.style.backgroundColor='var(--primary-light)'"
|
||||||
|
onmouseout="this.style.backgroundColor='var(--primary-color)'">
|
||||||
|
<i class="fas fa-save me-2"></i>Reset Password
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="text-center mt-3">
|
||||||
|
<p class="mb-0">Remember your password? <a href="{{ url_for('auth.login') }}" class="text-decoration-none">Sign In</a></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const passwordInput = document.getElementById('password');
|
||||||
|
const confirmInput = document.getElementById('confirm_password');
|
||||||
|
|
||||||
|
function checkPasswordRequirements(password) {
|
||||||
|
// Length check
|
||||||
|
const lengthReq = document.getElementById('length-req');
|
||||||
|
if (password.length >= 8) {
|
||||||
|
lengthReq.classList.remove('text-gray-500');
|
||||||
|
lengthReq.classList.add('text-success');
|
||||||
|
lengthReq.querySelector('i').className = 'fas fa-check-circle mr-2';
|
||||||
|
} else {
|
||||||
|
lengthReq.classList.remove('text-success');
|
||||||
|
lengthReq.classList.add('text-gray-500');
|
||||||
|
lengthReq.querySelector('i').className = 'fas fa-times-circle mr-2';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Uppercase check
|
||||||
|
const uppercaseReq = document.getElementById('uppercase-req');
|
||||||
|
if (/[A-Z]/.test(password)) {
|
||||||
|
uppercaseReq.classList.remove('text-gray-500');
|
||||||
|
uppercaseReq.classList.add('text-success');
|
||||||
|
uppercaseReq.querySelector('i').className = 'fas fa-check-circle mr-2';
|
||||||
|
} else {
|
||||||
|
uppercaseReq.classList.remove('text-success');
|
||||||
|
uppercaseReq.classList.add('text-gray-500');
|
||||||
|
uppercaseReq.querySelector('i').className = 'fas fa-times-circle mr-2';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lowercase check
|
||||||
|
const lowercaseReq = document.getElementById('lowercase-req');
|
||||||
|
if (/[a-z]/.test(password)) {
|
||||||
|
lowercaseReq.classList.remove('text-gray-500');
|
||||||
|
lowercaseReq.classList.add('text-success');
|
||||||
|
lowercaseReq.querySelector('i').className = 'fas fa-check-circle mr-2';
|
||||||
|
} else {
|
||||||
|
lowercaseReq.classList.remove('text-success');
|
||||||
|
lowercaseReq.classList.add('text-gray-500');
|
||||||
|
lowercaseReq.querySelector('i').className = 'fas fa-times-circle mr-2';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Number check
|
||||||
|
const numberReq = document.getElementById('number-req');
|
||||||
|
if (/[0-9]/.test(password)) {
|
||||||
|
numberReq.classList.remove('text-gray-500');
|
||||||
|
numberReq.classList.add('text-success');
|
||||||
|
numberReq.querySelector('i').className = 'fas fa-check-circle mr-2';
|
||||||
|
} else {
|
||||||
|
numberReq.classList.remove('text-success');
|
||||||
|
numberReq.classList.add('text-gray-500');
|
||||||
|
numberReq.querySelector('i').className = 'fas fa-times-circle mr-2';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Special character check
|
||||||
|
const specialReq = document.getElementById('special-req');
|
||||||
|
if (/[!@#$%^&*(),.?":{}|<>]/.test(password)) {
|
||||||
|
specialReq.classList.remove('text-gray-500');
|
||||||
|
specialReq.classList.add('text-success');
|
||||||
|
specialReq.querySelector('i').className = 'fas fa-check-circle mr-2';
|
||||||
|
} else {
|
||||||
|
specialReq.classList.remove('text-success');
|
||||||
|
specialReq.classList.add('text-gray-500');
|
||||||
|
specialReq.querySelector('i').className = 'fas fa-times-circle mr-2';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
passwordInput.addEventListener('input', function() {
|
||||||
|
checkPasswordRequirements(this.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
confirmInput.addEventListener('input', function() {
|
||||||
|
if (this.value === passwordInput.value) {
|
||||||
|
this.style.borderColor = 'var(--primary-color)';
|
||||||
|
} else {
|
||||||
|
this.style.borderColor = '#dc2626';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
111
test_password_reset.py
Normal file
111
test_password_reset.py
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Test script for password reset functionality
|
||||||
|
Run this script to test the password reset feature
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
# Add the current directory to Python path
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
|
||||||
|
from app import create_app
|
||||||
|
from models import db, User, PasswordResetToken
|
||||||
|
from utils import create_notification
|
||||||
|
from utils.notification import generate_mail_from_notification
|
||||||
|
|
||||||
|
def test_password_reset():
|
||||||
|
"""Test the password reset functionality"""
|
||||||
|
app = create_app()
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
print("Testing password reset functionality...")
|
||||||
|
|
||||||
|
# Check if we have a test user
|
||||||
|
test_user = User.query.filter_by(email='test@example.com').first()
|
||||||
|
if not test_user:
|
||||||
|
print("Creating test user...")
|
||||||
|
test_user = User(
|
||||||
|
username='testuser',
|
||||||
|
email='test@example.com',
|
||||||
|
last_name='Test User',
|
||||||
|
is_active=True
|
||||||
|
)
|
||||||
|
test_user.set_password('oldpassword123!')
|
||||||
|
db.session.add(test_user)
|
||||||
|
db.session.commit()
|
||||||
|
print(f"Created test user: {test_user.email}")
|
||||||
|
|
||||||
|
# Test 1: Create a password reset token
|
||||||
|
print("\n1. Testing password reset token creation...")
|
||||||
|
from routes.auth import forgot_password
|
||||||
|
import secrets
|
||||||
|
|
||||||
|
token = secrets.token_urlsafe(32)
|
||||||
|
reset_token = PasswordResetToken(
|
||||||
|
user_id=test_user.id,
|
||||||
|
token=token,
|
||||||
|
expires_at=datetime.utcnow() + timedelta(hours=1),
|
||||||
|
ip_address='127.0.0.1'
|
||||||
|
)
|
||||||
|
db.session.add(reset_token)
|
||||||
|
db.session.commit()
|
||||||
|
print(f"Created password reset token: {token[:20]}...")
|
||||||
|
|
||||||
|
# Test 2: Create notification
|
||||||
|
print("\n2. Testing notification creation...")
|
||||||
|
notif = create_notification(
|
||||||
|
notif_type='password_reset',
|
||||||
|
user_id=test_user.id,
|
||||||
|
details={
|
||||||
|
'message': 'You requested a password reset. Click the link below to reset your password.',
|
||||||
|
'reset_link': f'http://localhost:5000/reset-password/{token}',
|
||||||
|
'expiry_time': (datetime.utcnow() + timedelta(hours=1)).strftime('%Y-%m-%d %H:%M:%S UTC'),
|
||||||
|
'ip_address': '127.0.0.1',
|
||||||
|
'timestamp': datetime.utcnow().isoformat()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
print(f"Created notification: {notif.id}")
|
||||||
|
|
||||||
|
# Test 3: Generate email
|
||||||
|
print("\n3. Testing email generation...")
|
||||||
|
try:
|
||||||
|
mail = generate_mail_from_notification(notif)
|
||||||
|
if mail:
|
||||||
|
print(f"Generated email: {mail.subject}")
|
||||||
|
print(f"Email body preview: {mail.body[:100]}...")
|
||||||
|
else:
|
||||||
|
print("No email template found for password reset")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error generating email: {e}")
|
||||||
|
|
||||||
|
# Test 4: Validate token
|
||||||
|
print("\n4. Testing token validation...")
|
||||||
|
stored_token = PasswordResetToken.query.filter_by(token=token).first()
|
||||||
|
if stored_token and stored_token.is_valid():
|
||||||
|
print("Token is valid")
|
||||||
|
else:
|
||||||
|
print("Token is invalid or expired")
|
||||||
|
|
||||||
|
# Test 5: Simulate password reset
|
||||||
|
print("\n5. Testing password reset...")
|
||||||
|
if stored_token and stored_token.is_valid():
|
||||||
|
test_user.set_password('newpassword123!')
|
||||||
|
stored_token.used = True
|
||||||
|
db.session.commit()
|
||||||
|
print("Password reset successful")
|
||||||
|
|
||||||
|
# Verify password change
|
||||||
|
if test_user.check_password('newpassword123!'):
|
||||||
|
print("Password verification successful")
|
||||||
|
else:
|
||||||
|
print("Password verification failed")
|
||||||
|
else:
|
||||||
|
print("Cannot reset password - token invalid")
|
||||||
|
|
||||||
|
print("\nPassword reset test completed!")
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
test_password_reset()
|
||||||
Binary file not shown.
Binary file not shown.
@@ -30,9 +30,11 @@ def create_default_templates():
|
|||||||
'body': '''
|
'body': '''
|
||||||
<h2>Password Reset Request</h2>
|
<h2>Password Reset Request</h2>
|
||||||
<p>Dear {{ user.username }},</p>
|
<p>Dear {{ user.username }},</p>
|
||||||
<p>We received a request to reset your password. Click the link below to set a new password:</p>
|
<p>We received a request to reset your password for your DocuPulse account. Click the link below to set a new password:</p>
|
||||||
<p><a href="{{ reset_link }}">Reset Password</a></p>
|
<p><a href="{{ reset_link }}" style="background-color: #16767b; color: white; padding: 12px 24px; text-decoration: none; border-radius: 5px; display: inline-block; margin: 10px 0;">Reset Password</a></p>
|
||||||
<p>If you didn't request this, please ignore this email or contact support if you have concerns.</p>
|
<p><strong>This link will expire in 1 hour.</strong></p>
|
||||||
|
<p>If you didn't request this password reset, please ignore this email. Your password will remain unchanged.</p>
|
||||||
|
<p>If you have any concerns about your account security, please contact your administrator immediately.</p>
|
||||||
<p>Best regards,<br>The DocuPulse Team</p>
|
<p>Best regards,<br>The DocuPulse Team</p>
|
||||||
'''
|
'''
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -119,22 +119,44 @@ def generate_mail_from_notification(notif: Notif) -> Optional[Mail]:
|
|||||||
filled_body = filled_body.replace('{{ site.company_name }}', site_settings.company_name or '')
|
filled_body = filled_body.replace('{{ site.company_name }}', site_settings.company_name or '')
|
||||||
filled_body = filled_body.replace('{{ site.company_website }}', site_settings.company_website or '')
|
filled_body = filled_body.replace('{{ site.company_website }}', site_settings.company_website or '')
|
||||||
|
|
||||||
# Add notification details
|
# Add notification-specific variables
|
||||||
if notif.details:
|
if notif.details:
|
||||||
for key, value in notif.details.items():
|
if 'setup_link' in filled_body and 'setup_link' in notif.details:
|
||||||
# Handle nested keys (e.g., room.name -> room_name)
|
filled_body = filled_body.replace('{{ setup_link }}', notif.details['setup_link'])
|
||||||
if '.' in key:
|
if 'reset_link' in filled_body and 'reset_link' in notif.details:
|
||||||
parts = key.split('.')
|
filled_body = filled_body.replace('{{ reset_link }}', notif.details['reset_link'])
|
||||||
if len(parts) == 2:
|
if 'expiry_time' in filled_body and 'expiry_time' in notif.details:
|
||||||
obj_name, attr = parts
|
filled_body = filled_body.replace('{{ expiry_time }}', notif.details['expiry_time'])
|
||||||
if obj_name in notif.details and isinstance(notif.details[obj_name], dict):
|
if 'created_by' in filled_body and 'created_by' in notif.details:
|
||||||
if attr in notif.details[obj_name]:
|
filled_body = filled_body.replace('{{ created_by }}', notif.details['created_by'])
|
||||||
filled_body = filled_body.replace(f'{{{{ {key} }}}}', str(notif.details[obj_name][attr]))
|
if 'deleted_by' in filled_body and 'deleted_by' in notif.details:
|
||||||
else:
|
filled_body = filled_body.replace('{{ deleted_by }}', notif.details['deleted_by'])
|
||||||
# Special handling for setup_link to ensure it's a proper URL
|
if 'updated_by' in filled_body and 'updated_by' in notif.details:
|
||||||
if key == 'setup_link' and value.startswith('http://http//'):
|
filled_body = filled_body.replace('{{ updated_by }}', notif.details['updated_by'])
|
||||||
value = value.replace('http://http//', 'http://')
|
if 'remover.username' in filled_body and 'remover' in notif.details:
|
||||||
filled_body = filled_body.replace(f'{{{{ {key} }}}}', str(value))
|
filled_body = filled_body.replace('{{ remover.username }}', notif.details['remover'])
|
||||||
|
if 'sender.username' in filled_body and 'sender' in notif.details:
|
||||||
|
filled_body = filled_body.replace('{{ sender.username }}', notif.details['sender'])
|
||||||
|
if 'conversation.name' in filled_body and 'conversation' in notif.details:
|
||||||
|
filled_body = filled_body.replace('{{ conversation.name }}', notif.details['conversation'])
|
||||||
|
if 'conversation.description' in filled_body and 'conversation_description' in notif.details:
|
||||||
|
filled_body = filled_body.replace('{{ conversation.description }}', notif.details['conversation_description'])
|
||||||
|
if 'message.content' in filled_body and 'message' in notif.details:
|
||||||
|
filled_body = filled_body.replace('{{ message.content }}', notif.details['message'])
|
||||||
|
if 'message.created_at' in filled_body and 'message_created_at' in notif.details:
|
||||||
|
filled_body = filled_body.replace('{{ message.created_at }}', notif.details['message_created_at'])
|
||||||
|
if 'message_link' in filled_body and 'message_link' in notif.details:
|
||||||
|
filled_body = filled_body.replace('{{ message_link }}', notif.details['message_link'])
|
||||||
|
if 'updated_fields' in filled_body and 'updated_fields' in notif.details:
|
||||||
|
filled_body = filled_body.replace('{{ updated_fields }}', notif.details['updated_fields'])
|
||||||
|
if 'created_at' in filled_body:
|
||||||
|
filled_body = filled_body.replace('{{ created_at }}', datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S'))
|
||||||
|
if 'updated_at' in filled_body:
|
||||||
|
filled_body = filled_body.replace('{{ updated_at }}', datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S'))
|
||||||
|
if 'deleted_at' in filled_body:
|
||||||
|
filled_body = filled_body.replace('{{ deleted_at }}', datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S'))
|
||||||
|
if 'removed_at' in filled_body:
|
||||||
|
filled_body = filled_body.replace('{{ removed_at }}', datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S'))
|
||||||
|
|
||||||
# Handle special URL variables
|
# Handle special URL variables
|
||||||
if 'room_link' in filled_body and 'room_id' in notif.details:
|
if 'room_link' in filled_body and 'room_id' in notif.details:
|
||||||
@@ -147,15 +169,6 @@ def generate_mail_from_notification(notif: Notif) -> Optional[Mail]:
|
|||||||
conversation_link = url_for('conversations.conversation', conversation_id=notif.details['conversation_id'], _external=True)
|
conversation_link = url_for('conversations.conversation', conversation_id=notif.details['conversation_id'], _external=True)
|
||||||
filled_body = filled_body.replace('{{ conversation_link }}', conversation_link)
|
filled_body = filled_body.replace('{{ conversation_link }}', conversation_link)
|
||||||
|
|
||||||
# Add timestamps
|
|
||||||
filled_body = filled_body.replace('{{ created_at }}', notif.timestamp.strftime('%Y-%m-%d %H:%M:%S'))
|
|
||||||
if 'updated_at' in filled_body:
|
|
||||||
filled_body = filled_body.replace('{{ updated_at }}', datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S'))
|
|
||||||
if 'deleted_at' in filled_body:
|
|
||||||
filled_body = filled_body.replace('{{ deleted_at }}', datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S'))
|
|
||||||
if 'removed_at' in filled_body:
|
|
||||||
filled_body = filled_body.replace('{{ removed_at }}', datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S'))
|
|
||||||
|
|
||||||
# Create a new Mail record
|
# Create a new Mail record
|
||||||
mail = Mail(
|
mail = Mail(
|
||||||
recipient=notif.user.email,
|
recipient=notif.user.email,
|
||||||
|
|||||||
Reference in New Issue
Block a user