6c0d5a4e63
- Flask-based mold cost calculation application - PostgreSQL database with SQLAlchemy ORM - Docker/Podman containerization - Comprehensive documentation in docs/ - Clean, organized codebase without obsolete files - Production-ready deployment configuration - Enhanced pytest configuration with coverage reporting - Master/Development branch structure
473 lines
18 KiB
Python
473 lines
18 KiB
Python
from flask import Blueprint, render_template, redirect, url_for, flash, request, current_app, jsonify, make_response
|
|
from flask_login import login_required, current_user
|
|
from app import create_app, db
|
|
from ..forms import QuotationForm
|
|
from ..models import User, Quotation, CostFactor, CostFactorHistory
|
|
from datetime import datetime, timedelta, timezone
|
|
from sqlalchemy import desc, text
|
|
import logging
|
|
from app import limiter, cache
|
|
|
|
bp = Blueprint('admin', __name__, url_prefix='/admin')
|
|
|
|
@bp.before_request
|
|
def before_request():
|
|
if not current_user.is_authenticated or not current_user.is_admin:
|
|
flash('Access denied. Admin privileges required.', 'danger')
|
|
return redirect(url_for('main.home'))
|
|
|
|
@bp.route('/dashboard')
|
|
@login_required
|
|
@limiter.limit("30 per minute")
|
|
@cache.cached(timeout=60, key_prefix='admin_dashboard')
|
|
def dashboard():
|
|
try:
|
|
# Get statistics
|
|
total_users = User.query.count()
|
|
total_quotations = Quotation.query.count()
|
|
recent_quotations = Quotation.query.order_by(desc(Quotation.created_at)).limit(5).all()
|
|
|
|
# Get user statistics
|
|
active_users = User.query.filter(
|
|
User.last_login >= datetime.utcnow() - timedelta(days=30)
|
|
).count()
|
|
|
|
# Get quotation statistics by country (using mold_shop_country)
|
|
country_stats = db.session.query(
|
|
Quotation.mold_shop_country,
|
|
db.func.count(Quotation.id)
|
|
).group_by(Quotation.mold_shop_country).all()
|
|
|
|
return render_template('admin/dashboard.html',
|
|
total_users=total_users,
|
|
total_quotations=total_quotations,
|
|
active_users=active_users,
|
|
recent_quotations=recent_quotations,
|
|
country_stats=country_stats
|
|
)
|
|
except Exception as e:
|
|
current_app.logger.error(f'Admin dashboard error: {str(e)}')
|
|
flash('An error occurred while loading the dashboard.', 'error')
|
|
return redirect(url_for('main.home'))
|
|
|
|
@bp.route('/users')
|
|
@login_required
|
|
@limiter.limit("30 per minute")
|
|
@cache.cached(timeout=60, key_prefix='admin_users')
|
|
def users():
|
|
try:
|
|
page = request.args.get('page', 1, type=int)
|
|
per_page = 20
|
|
|
|
users = User.query.order_by(User.created_at.desc()).paginate(
|
|
page=page, per_page=per_page, error_out=False
|
|
)
|
|
|
|
return render_template('admin/users.html', users=users)
|
|
except Exception as e:
|
|
current_app.logger.error(f'Admin users error: {str(e)}')
|
|
flash('An error occurred while loading users.', 'error')
|
|
return redirect(url_for('admin.dashboard'))
|
|
|
|
@bp.route('/quotations')
|
|
@login_required
|
|
@limiter.limit("30 per minute")
|
|
def quotations():
|
|
try:
|
|
page = request.args.get('page', 1, type=int)
|
|
per_page = 20
|
|
|
|
# Get filter parameters
|
|
country = request.args.get('country')
|
|
mold_type = request.args.get('mold_type')
|
|
date_from_str = request.args.get('date_from')
|
|
date_to_str = request.args.get('date_to')
|
|
|
|
# Convert date strings to datetime objects
|
|
date_from = None
|
|
date_to = None
|
|
today = datetime.now(timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0)
|
|
|
|
try:
|
|
if date_from_str:
|
|
date_from = datetime.strptime(date_from_str, '%Y-%m-%d')
|
|
if date_to_str:
|
|
date_to = datetime.strptime(date_to_str, '%Y-%m-%d')
|
|
except ValueError:
|
|
flash('Invalid date format. Please use YYYY-MM-DD format.', 'error')
|
|
date_from = None
|
|
date_to = None
|
|
|
|
# Build query
|
|
query = Quotation.query
|
|
|
|
if country:
|
|
query = query.filter(Quotation.mold_shop_country == country)
|
|
if mold_type:
|
|
query = query.filter(Quotation.mold_main_type == mold_type)
|
|
if date_from:
|
|
query = query.filter(Quotation.created_at >= date_from)
|
|
if date_to:
|
|
# Add one day to include the end date fully
|
|
next_day = date_to + timedelta(days=1)
|
|
query = query.filter(Quotation.created_at < next_day)
|
|
|
|
# Get unique countries and mold types for filters
|
|
countries = db.session.query(Quotation.mold_shop_country).distinct().all()
|
|
countries = [c[0] for c in countries if c[0]]
|
|
|
|
mold_types = db.session.query(Quotation.mold_main_type).distinct().all()
|
|
mold_types = [t[0] for t in mold_types if t[0]]
|
|
|
|
quotations = query.order_by(desc(Quotation.created_at)).paginate(
|
|
page=page, per_page=per_page, error_out=False
|
|
)
|
|
|
|
return render_template('admin/quotations.html',
|
|
quotations=quotations,
|
|
countries=sorted(countries),
|
|
mold_types=sorted(mold_types),
|
|
today=today,
|
|
filters={
|
|
'country': country,
|
|
'mold_type': mold_type,
|
|
'date_from': date_from,
|
|
'date_to': date_to
|
|
}
|
|
)
|
|
except Exception as e:
|
|
current_app.logger.error(f'Admin quotations error: {str(e)}')
|
|
flash('An error occurred while loading quotations.', 'error')
|
|
return redirect(url_for('admin.dashboard'))
|
|
|
|
@bp.route('/user/<int:user_id>')
|
|
@login_required
|
|
def user_detail(user_id):
|
|
try:
|
|
user = User.query.get_or_404(user_id)
|
|
quotations = Quotation.query.filter_by(user_id=user_id).order_by(
|
|
desc(Quotation.created_at)
|
|
).all()
|
|
|
|
return render_template('admin/user_detail.html',
|
|
user=user,
|
|
quotations=quotations
|
|
)
|
|
except Exception as e:
|
|
current_app.logger.error(f'Admin user detail error: {str(e)}')
|
|
flash('An error occurred while loading user details.', 'error')
|
|
return redirect(url_for('admin.users'))
|
|
|
|
@bp.route('/quotation/<int:id>')
|
|
@login_required
|
|
@limiter.limit("30 per minute")
|
|
@cache.cached(timeout=60, key_prefix='admin_quotation_detail')
|
|
def quotation_detail(id):
|
|
try:
|
|
quotation = Quotation.query.get_or_404(id)
|
|
return render_template('admin/quotation_detail.html',
|
|
quotation=quotation
|
|
)
|
|
except Exception as e:
|
|
current_app.logger.error(f'Admin quotation detail error: {str(e)}')
|
|
flash('An error occurred while loading quotation details.', 'error')
|
|
return redirect(url_for('admin.quotations'))
|
|
|
|
@bp.route('/user/<int:user_id>/toggle', methods=['POST'])
|
|
@login_required
|
|
def toggle_user(user_id):
|
|
try:
|
|
user = User.query.get_or_404(user_id)
|
|
user.is_active = not user.is_active
|
|
db.session.commit()
|
|
|
|
return jsonify({
|
|
'status': 'success',
|
|
'is_active': user.is_active
|
|
})
|
|
except Exception as e:
|
|
current_app.logger.error(f'Admin toggle user error: {str(e)}')
|
|
db.session.rollback()
|
|
return jsonify({
|
|
'status': 'error',
|
|
'message': 'An error occurred while updating user status'
|
|
}), 500
|
|
|
|
@bp.route('/delete_user', methods=['POST'])
|
|
@login_required
|
|
@limiter.limit("10 per minute")
|
|
def delete_user():
|
|
if not request.is_json:
|
|
return jsonify({'error': 'Invalid request format'}), 400
|
|
|
|
data = request.get_json()
|
|
user_id = data.get('user_id')
|
|
|
|
if not user_id:
|
|
return jsonify({'error': 'User ID is required'}), 400
|
|
|
|
try:
|
|
user = User.query.get_or_404(user_id)
|
|
if user.id == current_user.id:
|
|
return jsonify({'error': 'Cannot delete your own account'}), 400
|
|
|
|
db.session.delete(user)
|
|
db.session.commit()
|
|
cache.delete_memoized(users)
|
|
current_app.logger.info(f'User {user.email} deleted by admin {current_user.email}')
|
|
return jsonify({'message': 'User deleted successfully'})
|
|
|
|
except Exception as e:
|
|
db.session.rollback()
|
|
current_app.logger.error(f'Error deleting user: {str(e)}')
|
|
return jsonify({'error': 'An error occurred while deleting the user'}), 500
|
|
|
|
@bp.route('/delete_quotation', methods=['POST'])
|
|
@login_required
|
|
@limiter.limit("10 per minute")
|
|
def delete_quotation():
|
|
if not request.is_json:
|
|
return jsonify({'error': 'Invalid request format'}), 400
|
|
|
|
data = request.get_json()
|
|
quotation_id = data.get('quotation_id')
|
|
|
|
if not quotation_id:
|
|
return jsonify({'error': 'Quotation ID is required'}), 400
|
|
|
|
try:
|
|
quotation = Quotation.query.get_or_404(quotation_id)
|
|
db.session.delete(quotation)
|
|
db.session.commit()
|
|
cache.delete_memoized(quotations)
|
|
current_app.logger.info(f'Quotation {quotation.id} deleted by admin {current_user.email}')
|
|
return jsonify({'message': 'Quotation deleted successfully'})
|
|
|
|
except Exception as e:
|
|
db.session.rollback()
|
|
current_app.logger.error(f'Error deleting quotation: {str(e)}')
|
|
return jsonify({'error': 'An error occurred while deleting the quotation'}), 500
|
|
|
|
@bp.route('/cost-factors')
|
|
@login_required
|
|
@limiter.limit("30 per minute")
|
|
def cost_factors():
|
|
"""Display and manage cost factors"""
|
|
try:
|
|
from ..utils.cost_factor_loader import initialize_default_cost_factors
|
|
from flask import current_app
|
|
|
|
# Add debug logging
|
|
current_app.logger.info("Accessing cost_factors route")
|
|
|
|
# Verify database connection
|
|
try:
|
|
db.session.execute(text('SELECT 1'))
|
|
except Exception as e:
|
|
current_app.logger.error(f"Database connection error: {str(e)}")
|
|
flash('Database connection error', 'error')
|
|
return redirect(url_for('admin.dashboard'))
|
|
|
|
# Initialize default factors if none exist
|
|
try:
|
|
initialize_default_cost_factors()
|
|
except Exception as e:
|
|
current_app.logger.error(f"Error initializing default cost factors: {str(e)}")
|
|
flash('Error initializing cost factors', 'error')
|
|
return redirect(url_for('admin.dashboard'))
|
|
|
|
# Get all cost factors grouped by category
|
|
try:
|
|
mold_factors = CostFactor.query.filter_by(category='mold', is_active=True).order_by(CostFactor.name).all()
|
|
complexity_factors = CostFactor.query.filter_by(category='complexity', is_active=True).order_by(CostFactor.name).all()
|
|
texture_factors = CostFactor.query.filter_by(category='texture', is_active=True).order_by(CostFactor.name).all()
|
|
tax_factors = CostFactor.query.filter_by(category='tax', is_active=True).order_by(CostFactor.name).all()
|
|
base_factors = CostFactor.query.filter_by(category='base', is_active=True).order_by(CostFactor.name).all()
|
|
except Exception as e:
|
|
current_app.logger.error(f"Error querying cost factors: {str(e)}")
|
|
flash('Error loading cost factors from database', 'error')
|
|
return redirect(url_for('admin.dashboard'))
|
|
|
|
# Log the number of factors found
|
|
current_app.logger.info(f"Found factors - Mold: {len(mold_factors)}, Complexity: {len(complexity_factors)}, "
|
|
f"Texture: {len(texture_factors)}, Tax: {len(tax_factors)}, Base: {len(base_factors)}")
|
|
|
|
# Log individual factors for debugging
|
|
current_app.logger.debug("Base Factors: %s", [f"{f.name}: {f.factor_value}" for f in base_factors])
|
|
current_app.logger.debug("Mold Factors: %s", [f"{f.name}: {f.factor_value}" for f in mold_factors])
|
|
current_app.logger.debug("Complexity Factors: %s", [f"{f.name}: {f.factor_value}" for f in complexity_factors])
|
|
current_app.logger.debug("Texture Factors: %s", [f"{f.name}: {f.factor_value}" for f in texture_factors])
|
|
current_app.logger.debug("Tax Factors: %s", [f"{f.name}: {f.factor_value}" for f in tax_factors])
|
|
|
|
# Prepare template context
|
|
template_context = {
|
|
'mold_factors': mold_factors,
|
|
'complexity_factors': complexity_factors,
|
|
'texture_factors': texture_factors,
|
|
'tax_factors': tax_factors,
|
|
'base_factors': base_factors
|
|
}
|
|
|
|
# Log template context
|
|
current_app.logger.debug("Template context: %s", template_context)
|
|
|
|
try:
|
|
response = make_response(render_template(
|
|
'admin/cost_factors.html',
|
|
**template_context
|
|
))
|
|
except Exception as e:
|
|
current_app.logger.error(f"Template rendering error: {str(e)}")
|
|
flash('Error rendering the page', 'error')
|
|
return redirect(url_for('admin.dashboard'))
|
|
|
|
# Add headers to prevent caching
|
|
response.headers['Cache-Control'] = 'no-cache, no-store, must-revalidate'
|
|
response.headers['Pragma'] = 'no-cache'
|
|
response.headers['Expires'] = '0'
|
|
|
|
return response
|
|
|
|
except Exception as e:
|
|
current_app.logger.error(f"Unhandled error in cost_factors route: {str(e)}")
|
|
flash('An unexpected error occurred while loading cost factors.', 'error')
|
|
return redirect(url_for('admin.dashboard'))
|
|
|
|
@bp.route('/cost-factors/edit/<int:factor_id>', methods=['GET', 'POST'])
|
|
@login_required
|
|
@limiter.limit("30 per minute")
|
|
def edit_cost_factor(factor_id):
|
|
"""Edit a specific cost factor"""
|
|
try:
|
|
factor = CostFactor.query.get_or_404(factor_id)
|
|
|
|
if request.method == 'POST':
|
|
old_value = factor.factor_value
|
|
new_value = float(request.form.get('factor_value'))
|
|
change_reason = request.form.get('change_reason', '')
|
|
|
|
# Update the factor
|
|
factor.factor_value = new_value
|
|
factor.updated_by = current_user.id
|
|
|
|
# Create history record
|
|
history = CostFactorHistory(
|
|
cost_factor_id=factor.id,
|
|
old_value=old_value,
|
|
new_value=new_value,
|
|
changed_by=current_user.id,
|
|
change_reason=change_reason
|
|
)
|
|
|
|
db.session.add(history)
|
|
db.session.commit()
|
|
|
|
# Clear the cache
|
|
cache.delete_memoized(cost_factors)
|
|
cache.delete('admin_cost_factors')
|
|
|
|
flash(f'Cost factor "{factor.name}" updated successfully.', 'success')
|
|
return redirect(url_for('admin.cost_factors'))
|
|
|
|
return render_template('admin/edit_cost_factor.html', factor=factor)
|
|
|
|
except Exception as e:
|
|
current_app.logger.error(f'Admin edit cost factor error: {str(e)}')
|
|
flash('An error occurred while editing the cost factor.', 'error')
|
|
return redirect(url_for('admin.cost_factors'))
|
|
|
|
@bp.route('/cost-factors/history/<int:factor_id>')
|
|
@login_required
|
|
@limiter.limit("30 per minute")
|
|
def cost_factor_history(factor_id):
|
|
"""View history of changes for a cost factor"""
|
|
try:
|
|
factor = CostFactor.query.get_or_404(factor_id)
|
|
history = CostFactorHistory.query.filter_by(cost_factor_id=factor_id)\
|
|
.order_by(CostFactorHistory.changed_at.desc()).all()
|
|
|
|
return render_template('admin/cost_factor_history.html',
|
|
factor=factor,
|
|
history=history
|
|
)
|
|
|
|
except Exception as e:
|
|
current_app.logger.error(f'Admin cost factor history error: {str(e)}')
|
|
flash('An error occurred while loading cost factor history.', 'error')
|
|
return redirect(url_for('admin.cost_factors'))
|
|
|
|
@bp.route('/cost-factors/reset', methods=['POST'])
|
|
@login_required
|
|
@limiter.limit("10 per minute")
|
|
def reset_cost_factors():
|
|
"""Reset cost factors to default values"""
|
|
try:
|
|
from ..utils.cost_factor_loader import get_default_cost_factors
|
|
|
|
# Get default values
|
|
default_factors, default_tax_rates, default_base = get_default_cost_factors()
|
|
|
|
# Create a mapping of all default values
|
|
default_values = {}
|
|
|
|
# Add mold factors
|
|
for name, value in default_factors['mold'].items():
|
|
default_values[name] = value
|
|
|
|
# Add complexity factors
|
|
for name, value in default_factors['complexity'].items():
|
|
default_values[name] = value
|
|
|
|
# Add texture factors
|
|
for name, value in default_factors['texture'].items():
|
|
default_values[name] = value
|
|
|
|
# Add tax factors
|
|
for name, value in default_tax_rates.items():
|
|
default_values[name] = value
|
|
|
|
# Add base factor
|
|
default_values['DEFAULT_NET_BASE'] = default_base
|
|
|
|
# Update existing cost factors to default values
|
|
for factor in CostFactor.query.all():
|
|
if factor.name in default_values:
|
|
old_value = factor.factor_value
|
|
new_value = default_values[factor.name]
|
|
|
|
if old_value != new_value: # Only update if value changed
|
|
factor.factor_value = new_value
|
|
factor.updated_by = current_user.id
|
|
|
|
# Create history record for the reset
|
|
history = CostFactorHistory(
|
|
cost_factor_id=factor.id,
|
|
old_value=old_value,
|
|
new_value=new_value,
|
|
changed_by=current_user.id,
|
|
change_reason='Reset to default value'
|
|
)
|
|
db.session.add(history)
|
|
|
|
db.session.commit()
|
|
|
|
# Clear the cache
|
|
cache.delete_memoized(cost_factors)
|
|
cache.delete('admin_cost_factors')
|
|
|
|
flash('Cost factors have been reset to default values.', 'success')
|
|
return redirect(url_for('admin.cost_factors'))
|
|
|
|
except Exception as e:
|
|
current_app.logger.error(f'Admin reset cost factors error: {str(e)}')
|
|
db.session.rollback()
|
|
flash('An error occurred while resetting cost factors.', 'error')
|
|
return redirect(url_for('admin.cost_factors'))
|
|
|
|
@bp.route('/debug-endpoints')
|
|
def debug_endpoints():
|
|
from flask import current_app
|
|
with open('logs/endpoints.log', 'w') as f:
|
|
for rule in current_app.url_map.iter_rules():
|
|
f.write(f"{rule.endpoint} {rule.methods} {rule.rule}\n")
|
|
return 'Endpoints written to logs/endpoints.log', 200 |