first commit
This commit is contained in:
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
/.venv
|
||||
database.db
|
||||
__pycache__/
|
||||
*.db
|
||||
*.db.old
|
||||
28
LICENSE.md
Normal file
28
LICENSE.md
Normal file
@@ -0,0 +1,28 @@
|
||||
Custom License: Non-Commercial Open Source License
|
||||
|
||||
Copyright (c) Freebede.com 2025
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to use,
|
||||
copy, modify, and distribute the Software, subject to the following conditions:
|
||||
|
||||
1. **Non-Commercial Use Only**: The Software may only be used for non-commercial purposes.
|
||||
Commercial use — including but not limited to selling, licensing, offering for a fee,
|
||||
or integrating into a commercial product or service — is strictly prohibited without
|
||||
explicit written permission or a commercial license from the copyright holder.
|
||||
|
||||
2. **Open Source Derivatives Only**: Any derivative works or redistributions must be
|
||||
released under this same license, and made fully available in source code form,
|
||||
free of charge.
|
||||
|
||||
3. **Attribution**: You must give appropriate credit to the original author(s),
|
||||
include a copy of this license, and indicate if changes were made.
|
||||
|
||||
4. **Disclaimer**: The Software is provided "as is", without warranty of any kind,
|
||||
express or implied, including but not limited to the warranties of merchantability,
|
||||
fitness for a particular purpose, and noninfringement. In no event shall the authors
|
||||
be liable for any claim, damages, or other liability, whether in an action of contract,
|
||||
tort, or otherwise, arising from, out of, or in connection with the Software or the use
|
||||
or other dealings in the Software.
|
||||
|
||||
For commercial licensing inquiries, contact: github-projects@freebede.com
|
||||
5
api/__init__.py
Normal file
5
api/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from flask import Blueprint
|
||||
|
||||
api_bp = Blueprint('api', __name__)
|
||||
|
||||
from . import routes
|
||||
52
api/models.py
Normal file
52
api/models.py
Normal file
@@ -0,0 +1,52 @@
|
||||
from extensions import db
|
||||
from datetime import datetime
|
||||
from flask import current_app
|
||||
import pytz
|
||||
|
||||
# Import models for direct relationship references
|
||||
import sys
|
||||
import os
|
||||
# Add the parent directory to path for imports to work
|
||||
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
|
||||
from auth.models import Company, ApiKey
|
||||
from utils.toolbox import get_current_timestamp
|
||||
|
||||
def get_current_time_with_timezone():
|
||||
"""Return current time with the configured timezone"""
|
||||
return get_current_timestamp()
|
||||
|
||||
class Log(db.Model):
|
||||
__tablename__ = 'api_logs'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
event_type = db.Column(db.String(20), nullable=False)
|
||||
user_name = db.Column(db.String(50), nullable=False)
|
||||
computer_name = db.Column(db.String(50), nullable=False)
|
||||
ip_address = db.Column(db.String(15), nullable=False)
|
||||
timestamp = db.Column(db.DateTime, nullable=False, default=get_current_time_with_timezone)
|
||||
retry = db.Column(db.Integer, default=0, nullable=False)
|
||||
company_id = db.Column(db.Integer, db.ForeignKey('app_auth_companies.id'), nullable=True)
|
||||
api_key_id = db.Column(db.Integer, db.ForeignKey('app_auth_api_keys.id'), nullable=True)
|
||||
|
||||
# Relationships
|
||||
company = db.relationship(Company, backref='logs', foreign_keys=[company_id])
|
||||
api_key = db.relationship(ApiKey, backref='logs', foreign_keys=[api_key_id])
|
||||
|
||||
class ErrorLog(db.Model):
|
||||
"""Model for storing application error logs"""
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
level = db.Column(db.String(20), nullable=False)
|
||||
logger_name = db.Column(db.String(100), nullable=True)
|
||||
message = db.Column(db.Text, nullable=False)
|
||||
timestamp = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
||||
pathname = db.Column(db.String(255), nullable=True)
|
||||
lineno = db.Column(db.Integer, nullable=True)
|
||||
request_id = db.Column(db.String(100), nullable=True)
|
||||
user_id = db.Column(db.Integer, db.ForeignKey('app_auth_users.id', ondelete='SET NULL'), nullable=True)
|
||||
remote_addr = db.Column(db.String(50), nullable=True)
|
||||
exception = db.Column(db.Text, nullable=True)
|
||||
|
||||
def __repr__(self):
|
||||
return f'<ErrorLog {self.id}: {self.level} - {self.message[:50]}>'
|
||||
|
||||
# API-specific models
|
||||
# Note: ApiKey model is in auth/models.py as it's related to user authentication
|
||||
234
api/routes.py
Normal file
234
api/routes.py
Normal file
@@ -0,0 +1,234 @@
|
||||
from flask import Blueprint, request, jsonify, current_app, g
|
||||
from auth.models import User, ApiKey
|
||||
from api.models import Log
|
||||
from extensions import db
|
||||
from functools import wraps
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from api import api_bp
|
||||
from sqlalchemy import and_, text
|
||||
import pytz
|
||||
import logging
|
||||
|
||||
# Use the existing blueprint from __init__.py instead of creating a new one
|
||||
api = api_bp
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def require_api_key(f):
|
||||
@wraps(f)
|
||||
def decorated(*args, **kwargs):
|
||||
try:
|
||||
api_key = request.headers.get('X-API-Key')
|
||||
if not api_key:
|
||||
logger.warning('API request without API key', extra={
|
||||
'ip_address': request.remote_addr,
|
||||
'user_agent': request.headers.get('User-Agent'),
|
||||
'endpoint': request.endpoint,
|
||||
'method': request.method
|
||||
})
|
||||
return jsonify({"message": "No API key provided"}), 401
|
||||
|
||||
key = ApiKey.query.filter_by(key=api_key).first()
|
||||
if not key:
|
||||
logger.warning('Invalid API key used', extra={
|
||||
'ip_address': request.remote_addr,
|
||||
'user_agent': request.headers.get('User-Agent'),
|
||||
'endpoint': request.endpoint,
|
||||
'method': request.method,
|
||||
'api_key_prefix': api_key[:8] + '...' if len(api_key) > 8 else api_key
|
||||
})
|
||||
return jsonify({"message": "Invalid API key"}), 401
|
||||
|
||||
# Check if the API key is active
|
||||
if hasattr(key, 'is_active') and not key.is_active:
|
||||
logger.warning('Disabled API key used', extra={
|
||||
'ip_address': request.remote_addr,
|
||||
'user_agent': request.headers.get('User-Agent'),
|
||||
'endpoint': request.endpoint,
|
||||
'method': request.method,
|
||||
'api_key_id': key.id,
|
||||
'company_id': key.company_id
|
||||
})
|
||||
return jsonify({"message": "API key has been disabled"}), 401
|
||||
|
||||
# Update last used timestamp
|
||||
key.last_used = datetime.now(pytz.timezone(current_app.config['TIMEZONE']))
|
||||
|
||||
# Save the API key and associated company in g object for use in route functions
|
||||
g.api_key = key
|
||||
g.company_id = key.company_id
|
||||
|
||||
db.session.commit()
|
||||
return f(*args, **kwargs)
|
||||
except Exception as e:
|
||||
logger.exception('Error in API key authentication', extra={
|
||||
'ip_address': request.remote_addr,
|
||||
'user_agent': request.headers.get('User-Agent'),
|
||||
'endpoint': request.endpoint,
|
||||
'method': request.method,
|
||||
'has_api_key': bool(request.headers.get('X-API-Key')),
|
||||
'error': str(e)
|
||||
})
|
||||
return jsonify({"message": "Authentication error occurred"}), 500
|
||||
return decorated
|
||||
|
||||
@api.route('/log_event', methods=['POST'])
|
||||
@require_api_key
|
||||
def log_event():
|
||||
try:
|
||||
data = request.get_json()
|
||||
|
||||
# Parse the timestamp from the request - handle different formats
|
||||
timestamp_str = data['Timestamp']
|
||||
timestamp_utc = None
|
||||
|
||||
# Try different timestamp formats
|
||||
formats_to_try = [
|
||||
'%Y-%m-%dT%H:%M:%S%z', # RFC3339/ISO8601 with timezone offset (from Go app)
|
||||
'%Y-%m-%dT%H:%M:%SZ', # ISO format with Z (UTC)
|
||||
'%Y-%m-%d %H:%M:%S %Z', # Format with timezone name
|
||||
'%Y-%m-%d %H:%M:%S', # Simple format without timezone
|
||||
'%Y-%m-%d %H:%M:%S%z', # Format with numeric timezone
|
||||
]
|
||||
|
||||
for fmt in formats_to_try:
|
||||
try:
|
||||
if fmt == '%Y-%m-%d %H:%M:%S %Z':
|
||||
# Special handling for timezone names
|
||||
dt_parts = timestamp_str.rsplit(' ', 1)
|
||||
if len(dt_parts) == 2:
|
||||
dt_str, tz_str = dt_parts
|
||||
# Try to parse the datetime part
|
||||
dt = datetime.strptime(dt_str, '%Y-%m-%d %H:%M:%S')
|
||||
|
||||
# Try to convert timezone abbreviation to a pytz timezone
|
||||
tzinfos = {
|
||||
'BST': 3600, # British Summer Time (UTC+1)
|
||||
'GMT': 0, # Greenwich Mean Time
|
||||
'UTC': 0, # Coordinated Universal Time
|
||||
# Add more timezone abbreviations as needed
|
||||
}
|
||||
|
||||
if tz_str in tzinfos:
|
||||
# Create aware datetime with the specified timezone offset
|
||||
offset = timedelta(seconds=tzinfos[tz_str])
|
||||
timestamp_utc = dt.replace(tzinfo=timezone(offset))
|
||||
break
|
||||
elif fmt == '%Y-%m-%dT%H:%M:%S%z':
|
||||
# Handle RFC3339 format from Go application
|
||||
timestamp_utc = datetime.strptime(timestamp_str, fmt)
|
||||
break
|
||||
else:
|
||||
timestamp_utc = datetime.strptime(timestamp_str, fmt)
|
||||
if fmt == '%Y-%m-%d %H:%M:%S': # No timezone info, assume UTC
|
||||
timestamp_utc = pytz.utc.localize(timestamp_utc)
|
||||
break
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
if timestamp_utc is None:
|
||||
return jsonify({'message': f'Could not parse timestamp: {timestamp_str}', 'status': 'error'}), 400
|
||||
|
||||
# Convert to the configured timezone
|
||||
from utils.toolbox import get_app_timezone
|
||||
app_timezone = get_app_timezone()
|
||||
if timestamp_utc.tzinfo is None:
|
||||
timestamp_utc = pytz.utc.localize(timestamp_utc)
|
||||
timestamp = timestamp_utc.astimezone(app_timezone)
|
||||
|
||||
# Check if this is a retry attempt
|
||||
is_retry = data.get('retry', 0)
|
||||
|
||||
# Check if a record with the same attributes already exists
|
||||
existing_log = Log.query.filter(
|
||||
and_(
|
||||
Log.event_type == data['EventType'],
|
||||
Log.user_name == data['UserName'],
|
||||
Log.computer_name == data['ComputerName'],
|
||||
Log.ip_address == data['IPAddress'],
|
||||
Log.timestamp == timestamp
|
||||
)
|
||||
).first()
|
||||
|
||||
if existing_log:
|
||||
# Record already exists, don't create duplicate
|
||||
return jsonify({'message': 'Event already recorded', 'status': 'success'}), 200
|
||||
|
||||
# Create new log entry with company_id and api_key_id from the API key
|
||||
log = Log(
|
||||
event_type=data['EventType'],
|
||||
user_name=data['UserName'],
|
||||
computer_name=data['ComputerName'],
|
||||
ip_address=data['IPAddress'],
|
||||
timestamp=timestamp,
|
||||
retry=is_retry,
|
||||
company_id=g.company_id, # Add the company ID from the API key
|
||||
api_key_id=g.api_key.id # Add the API key ID
|
||||
)
|
||||
db.session.add(log)
|
||||
db.session.commit()
|
||||
return jsonify({'message': 'Event logged successfully', 'status': 'success'}), 201
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
logger.exception('Failed to log event in API', extra={
|
||||
'ip_address': request.remote_addr,
|
||||
'user_agent': request.headers.get('User-Agent'),
|
||||
'api_key_id': getattr(g, 'api_key', {}).id if hasattr(g, 'api_key') and g.api_key else None,
|
||||
'company_id': getattr(g, 'company_id', None),
|
||||
'request_data': data if 'data' in locals() else None,
|
||||
'timestamp_str': data.get('Timestamp') if 'data' in locals() else None,
|
||||
'event_type': data.get('EventType') if 'data' in locals() else None,
|
||||
'user_name': data.get('UserName') if 'data' in locals() else None,
|
||||
'computer_name': data.get('ComputerName') if 'data' in locals() else None,
|
||||
'retry_attempt': data.get('retry', 0) if 'data' in locals() else None,
|
||||
'error': str(e)
|
||||
})
|
||||
return jsonify({'message': f'Failed to log event: {str(e)}', 'status': 'error'}), 500
|
||||
|
||||
@api.route('/health', methods=['POST'])
|
||||
@require_api_key
|
||||
def health_check():
|
||||
"""
|
||||
Health check endpoint that verifies:
|
||||
- API key authentication (handled by @require_api_key decorator)
|
||||
- Database connectivity
|
||||
- Application status
|
||||
"""
|
||||
try:
|
||||
# Test database connectivity by performing a simple query
|
||||
# This will raise an exception if the database is not accessible
|
||||
db.session.execute(text('SELECT 1')).fetchone()
|
||||
|
||||
# Test that we can access the API key from the decorator
|
||||
api_key_id = g.api_key.id if hasattr(g, 'api_key') else None
|
||||
company_id = g.company_id if hasattr(g, 'company_id') else None
|
||||
|
||||
# Get current timestamp in the configured timezone
|
||||
app_timezone = pytz.timezone(current_app.config['TIMEZONE'])
|
||||
current_time = datetime.now(app_timezone)
|
||||
|
||||
return jsonify({
|
||||
'status': 'ok',
|
||||
'message': 'Health check passed',
|
||||
'timestamp': current_time.isoformat(),
|
||||
'database': 'connected',
|
||||
'api_key_verified': api_key_id is not None,
|
||||
'company_id': company_id
|
||||
}), 200
|
||||
|
||||
except Exception as e:
|
||||
# Log the error for debugging purposes
|
||||
logger.exception('Health check failed', extra={
|
||||
'ip_address': request.remote_addr,
|
||||
'user_agent': request.headers.get('User-Agent'),
|
||||
'api_key_id': getattr(g, 'api_key', {}).id if hasattr(g, 'api_key') and g.api_key else None,
|
||||
'company_id': getattr(g, 'company_id', None),
|
||||
'error': str(e)
|
||||
})
|
||||
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Health check failed',
|
||||
'error': str(e),
|
||||
'database': 'disconnected'
|
||||
}), 500
|
||||
118
app.log
Normal file
118
app.log
Normal file
@@ -0,0 +1,118 @@
|
||||
nohup: ignoring input
|
||||
2025-05-25 05:20:10,213 - __main__ - INFO - No specific proxy IPs configured - trusting all proxies
|
||||
2025-05-25 05:20:10,229 - utils.rate_limiter - INFO - No Redis URL configured, using in-memory rate limiting
|
||||
2025-05-25 05:20:10,243 - app - INFO - Database logging initialized
|
||||
2025-05-25 05:20:10,244 - __main__ - INFO - Starting application on 0.0.0.0:8000 with SSL
|
||||
2025-05-25 05:20:10,244 - __main__ - INFO - SSL certificate: instance/certs/cert.pem
|
||||
2025-05-25 05:20:10,244 - __main__ - INFO - SSL key: instance/certs/key.pem
|
||||
2025-05-25 05:20:10,244 - __main__ - INFO - Development mode: False
|
||||
2025-05-25 05:20:10,244 - __main__ - INFO - File watching: False
|
||||
2025-05-25 05:20:10,244 - __main__ - INFO - Workers: 2
|
||||
2025-05-25 05:20:10,244 - __main__ - INFO - Uvicorn allowing all IPs for forwarded headers
|
||||
INFO: Uvicorn running on https://0.0.0.0:8000 (Press CTRL+C to quit)
|
||||
INFO: Started parent process [7718]
|
||||
2025-05-25 05:20:10,568 - __mp_main__ - INFO - No specific proxy IPs configured - trusting all proxies
|
||||
2025-05-25 05:20:10,571 - __mp_main__ - INFO - No specific proxy IPs configured - trusting all proxies
|
||||
2025-05-25 05:20:10,586 - utils.rate_limiter - INFO - No Redis URL configured, using in-memory rate limiting
|
||||
2025-05-25 05:20:10,589 - utils.rate_limiter - INFO - No Redis URL configured, using in-memory rate limiting
|
||||
2025-05-25 05:20:10,602 - __mp_main__ - INFO - Database logging initialized
|
||||
2025-05-25 05:20:10,605 - __mp_main__ - INFO - Database logging initialized
|
||||
2025-05-25 05:20:10,620 - app - INFO - No specific proxy IPs configured - trusting all proxies
|
||||
2025-05-25 05:20:10,623 - app - INFO - No specific proxy IPs configured - trusting all proxies
|
||||
2025-05-25 05:20:10,632 - utils.rate_limiter - INFO - No Redis URL configured, using in-memory rate limiting
|
||||
2025-05-25 05:20:10,635 - utils.rate_limiter - INFO - No Redis URL configured, using in-memory rate limiting
|
||||
2025-05-25 05:20:10,636 - app - INFO - Database logging initialized
|
||||
INFO: Started server process [7721]
|
||||
INFO: Waiting for application startup.
|
||||
INFO: ASGI 'lifespan' protocol appears unsupported.
|
||||
INFO: Application startup complete.
|
||||
2025-05-25 05:20:10,639 - app - INFO - Database logging initialized
|
||||
INFO: Started server process [7722]
|
||||
INFO: Waiting for application startup.
|
||||
INFO: ASGI 'lifespan' protocol appears unsupported.
|
||||
INFO: Application startup complete.
|
||||
INFO: 127.0.0.1:49078 - "GET / HTTP/1.1" 200 OK
|
||||
2025-05-25 05:21:45,554 - frontend.routes - WARNING - TEST: Warning level log from /test-error-logging route
|
||||
2025-05-25 05:21:45,557 - frontend.routes - ERROR - TEST: Error level log from /test-error-logging route
|
||||
2025-05-25 05:21:45,559 - frontend.routes - CRITICAL - TEST: Critical level log from /test-error-logging route
|
||||
2025-05-25 05:21:45,562 - app - WARNING - TEST: Flask app warning from test route
|
||||
2025-05-25 05:21:45,564 - app - ERROR - TEST: Flask app error from test route
|
||||
2025-05-25 05:21:45,567 - frontend.routes - ERROR - TEST: Exception caught in test route
|
||||
Traceback (most recent call last):
|
||||
File "/opt/dockerbuilds/domain-logons/frontend/routes.py", line 42, in test_error_logging
|
||||
raise ValueError("TEST: Intentional exception for database logging verification")
|
||||
ValueError: TEST: Intentional exception for database logging verification
|
||||
2025-05-25 05:21:45,569 - app - ERROR - TEST: Flask app exception in test route
|
||||
Traceback (most recent call last):
|
||||
File "/opt/dockerbuilds/domain-logons/frontend/routes.py", line 42, in test_error_logging
|
||||
raise ValueError("TEST: Intentional exception for database logging verification")
|
||||
ValueError: TEST: Intentional exception for database logging verification
|
||||
INFO: 127.0.0.1:54616 - "GET /test-error-logging HTTP/1.1" 200 OK
|
||||
INFO: 213.249.224.235:0 - "GET /auth/admin/error_logs?start_date=2025-05-18&end_date=2025-05-25&level= HTTP/1.1" 200 OK
|
||||
INFO: 213.249.224.235:0 - "GET /static/img/favicon.png HTTP/1.1" 304 Not Modified
|
||||
INFO: 213.249.224.235:0 - "GET /auth/admin/error_logs?start_date=2025-05-18&end_date=2025-05-25&level= HTTP/1.1" 200 OK
|
||||
INFO: 213.249.224.235:0 - "GET /static/css/custom.css HTTP/1.1" 304 Not Modified
|
||||
INFO: 213.249.224.235:0 - "GET /static/img/favicon.png HTTP/1.1" 304 Not Modified
|
||||
INFO: 213.249.224.235:0 - "GET /static/css/buttons.bootstrap5.min.css HTTP/1.1" 200 OK
|
||||
INFO: 213.249.224.235:0 - "GET /static/css/buttons.dataTables.min.css HTTP/1.1" 200 OK
|
||||
INFO: 213.249.224.235:0 - "GET /static/css/bootstrap.min.css HTTP/1.1" 200 OK
|
||||
INFO: 213.249.224.235:0 - "GET /static/css/dataTables.bootstrap5.min.css HTTP/1.1" 200 OK
|
||||
INFO: 213.249.224.235:0 - "GET /static/js/jquery-3.6.0.min.js HTTP/1.1" 200 OK
|
||||
INFO: 213.249.224.235:0 - "GET /static/js/jquery.dataTables.min.js HTTP/1.1" 200 OK
|
||||
INFO: 213.249.224.235:0 - "GET /static/js/bootstrap.bundle.min.js HTTP/1.1" 200 OK
|
||||
INFO: 213.249.224.235:0 - "GET /static/js/buttons.html5.min.js HTTP/1.1" 200 OK
|
||||
INFO: 213.249.224.235:0 - "GET /static/js/dataTables.bootstrap5.min.js HTTP/1.1" 200 OK
|
||||
INFO: 213.249.224.235:0 - "GET /static/js/jszip.min.js HTTP/1.1" 200 OK
|
||||
INFO: 213.249.224.235:0 - "GET /static/js/dataTables.buttons.min.js HTTP/1.1" 200 OK
|
||||
INFO: 213.249.224.235:0 - "GET /static/js/buttons.bootstrap5.min.js HTTP/1.1" 200 OK
|
||||
INFO: 213.249.224.235:0 - "GET /auth/admin HTTP/1.1" 404 Not Found
|
||||
INFO: 213.249.224.235:0 - "GET /favicon.ico HTTP/1.1" 200 OK
|
||||
INFO: 213.249.224.235:0 - "GET /auth/admin/error_logs?start_date=2025-05-18&end_date=2025-05-25&level= HTTP/1.1" 200 OK
|
||||
INFO: 213.249.224.235:0 - "GET /static/img/favicon.png HTTP/1.1" 304 Not Modified
|
||||
INFO: 213.249.224.235:0 - "GET /static/img/favicon.png HTTP/1.1" 304 Not Modified
|
||||
2025-05-25 05:23:04,784 - frontend.routes - WARNING - TEST: Warning level log from /test-error-logging route
|
||||
2025-05-25 05:23:04,786 - frontend.routes - ERROR - TEST: Error level log from /test-error-logging route
|
||||
2025-05-25 05:23:04,787 - frontend.routes - CRITICAL - TEST: Critical level log from /test-error-logging route
|
||||
2025-05-25 05:23:04,789 - app - WARNING - TEST: Flask app warning from test route
|
||||
2025-05-25 05:23:04,792 - app - ERROR - TEST: Flask app error from test route
|
||||
2025-05-25 05:23:04,793 - frontend.routes - ERROR - TEST: Exception caught in test route
|
||||
Traceback (most recent call last):
|
||||
File "/opt/dockerbuilds/domain-logons/frontend/routes.py", line 42, in test_error_logging
|
||||
raise ValueError("TEST: Intentional exception for database logging verification")
|
||||
ValueError: TEST: Intentional exception for database logging verification
|
||||
2025-05-25 05:23:04,795 - app - ERROR - TEST: Flask app exception in test route
|
||||
Traceback (most recent call last):
|
||||
File "/opt/dockerbuilds/domain-logons/frontend/routes.py", line 42, in test_error_logging
|
||||
raise ValueError("TEST: Intentional exception for database logging verification")
|
||||
ValueError: TEST: Intentional exception for database logging verification
|
||||
INFO: 127.0.0.1:60580 - "GET /test-error-logging HTTP/1.1" 200 OK
|
||||
2025-05-25 05:24:41,331 - frontend.routes - WARNING - TEST: Warning level log from /test-error-logging route
|
||||
2025-05-25 05:24:41,335 - frontend.routes - ERROR - TEST: Error level log from /test-error-logging route
|
||||
2025-05-25 05:24:41,336 - frontend.routes - CRITICAL - TEST: Critical level log from /test-error-logging route
|
||||
2025-05-25 05:24:41,338 - app - WARNING - TEST: Flask app warning from test route
|
||||
2025-05-25 05:24:41,339 - app - ERROR - TEST: Flask app error from test route
|
||||
2025-05-25 05:24:41,343 - frontend.routes - ERROR - TEST: Exception caught in test route
|
||||
Traceback (most recent call last):
|
||||
File "/opt/dockerbuilds/domain-logons/frontend/routes.py", line 42, in test_error_logging
|
||||
raise ValueError("TEST: Intentional exception for database logging verification")
|
||||
ValueError: TEST: Intentional exception for database logging verification
|
||||
2025-05-25 05:24:41,345 - app - ERROR - TEST: Flask app exception in test route
|
||||
Traceback (most recent call last):
|
||||
File "/opt/dockerbuilds/domain-logons/frontend/routes.py", line 42, in test_error_logging
|
||||
raise ValueError("TEST: Intentional exception for database logging verification")
|
||||
ValueError: TEST: Intentional exception for database logging verification
|
||||
INFO: 127.0.0.1:35888 - "GET /test-error-logging HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:35904 - "POST /api/log_event HTTP/1.1" 401 Unauthorized
|
||||
INFO: 213.249.224.235:0 - "GET /auth/admin/error_logs?start_date=2025-05-18&end_date=2025-05-25&level= HTTP/1.1" 200 OK
|
||||
INFO: 213.249.224.235:0 - "GET /static/img/favicon.png HTTP/1.1" 304 Not Modified
|
||||
INFO: 127.0.0.1:41952 - "GET / HTTP/1.1" 200 OK
|
||||
INFO: Received SIGTERM, exiting.
|
||||
INFO: Terminated child process [7721]
|
||||
INFO: Terminated child process [7722]
|
||||
INFO: Waiting for child process [7721]
|
||||
INFO: Shutting down
|
||||
INFO: Shutting down
|
||||
INFO: Finished server process [7721]
|
||||
INFO: Finished server process [7722]
|
||||
INFO: Waiting for child process [7722]
|
||||
INFO: Stopping parent process [7718]
|
||||
365
app.py
Normal file
365
app.py
Normal file
@@ -0,0 +1,365 @@
|
||||
from flask import Flask, session, request, send_from_directory, render_template
|
||||
from asgiref.wsgi import WsgiToAsgi
|
||||
from extensions import db, bcrypt, login_manager, get_env_var
|
||||
from auth.models import User, Settings, ApiKey
|
||||
import ssl
|
||||
import logging
|
||||
from flask_wtf import CSRFProtect
|
||||
from auth import auth_bp
|
||||
from api import api_bp
|
||||
from frontend import frontend_bp
|
||||
from datetime import datetime, timezone, timedelta
|
||||
import pytz
|
||||
import configparser
|
||||
import os
|
||||
import uvicorn
|
||||
import argparse
|
||||
from werkzeug.middleware.proxy_fix import ProxyFix
|
||||
from werkzeug.exceptions import HTTPException
|
||||
from flask_compress import Compress
|
||||
# Import security utilities
|
||||
from utils.security_headers import setup_security_headers
|
||||
from utils.rate_limiter import apply_rate_limits
|
||||
from utils.db_logging import setup_database_logging
|
||||
# Removed SQLite encryption import
|
||||
|
||||
# Load configuration from ini file
|
||||
config = configparser.ConfigParser()
|
||||
config_file = os.path.join(os.path.dirname(__file__), 'config.ini')
|
||||
config.read(config_file)
|
||||
|
||||
# Set up logging
|
||||
logging.basicConfig(
|
||||
level=logging.DEBUG if config.getboolean('app', 'APP_DEBUG', fallback=True) else logging.INFO,
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
app = Flask(__name__)
|
||||
|
||||
# Configure WSGI middleware for reverse proxy support (Traefik)
|
||||
proxy_count = config.getint('proxy', 'PROXY_COUNT', fallback=1)
|
||||
trust_x_forwarded_for = config.getboolean('proxy', 'TRUST_X_FORWARDED_FOR', fallback=True)
|
||||
trust_x_forwarded_proto = config.getboolean('proxy', 'TRUST_X_FORWARDED_PROTO', fallback=True)
|
||||
trust_x_forwarded_host = config.getboolean('proxy', 'TRUST_X_FORWARDED_HOST', fallback=True)
|
||||
trust_x_forwarded_port = config.getboolean('proxy', 'TRUST_X_FORWARDED_PORT', fallback=True)
|
||||
trust_x_forwarded_prefix = config.getboolean('proxy', 'TRUST_X_FORWARDED_PREFIX', fallback=False)
|
||||
|
||||
# Get trusted proxy IPs
|
||||
trusted_proxies = config.get('proxy', 'TRUSTED_PROXIES', fallback='').strip()
|
||||
if trusted_proxies:
|
||||
# Parse comma-separated IPs/CIDRs
|
||||
trusted_proxy_list = [ip.strip() for ip in trusted_proxies.split(',') if ip.strip()]
|
||||
logger.info(f"Configured trusted proxies: {trusted_proxy_list}")
|
||||
else:
|
||||
trusted_proxy_list = None
|
||||
logger.info("No specific proxy IPs configured - trusting all proxies")
|
||||
|
||||
app.wsgi_app = ProxyFix(
|
||||
app.wsgi_app,
|
||||
x_for=proxy_count if trust_x_forwarded_for else 0,
|
||||
x_proto=proxy_count if trust_x_forwarded_proto else 0,
|
||||
x_host=proxy_count if trust_x_forwarded_host else 0,
|
||||
x_port=proxy_count if trust_x_forwarded_port else 0,
|
||||
x_prefix=proxy_count if trust_x_forwarded_prefix else 0
|
||||
)
|
||||
|
||||
# Configure Flask app from environment variables with fallback to config file
|
||||
app.config['SECRET_KEY'] = get_env_var('SECRET_KEY', config.get('app', 'SECRET_KEY', fallback='your_secret_key'))
|
||||
app.config['SQLALCHEMY_DATABASE_URI'] = get_env_var('DATABASE_URL', config.get('database', 'SQLALCHEMY_DATABASE_URI', fallback='sqlite:///database.db'))
|
||||
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = config.getboolean('database', 'SQLALCHEMY_TRACK_MODIFICATIONS', fallback=False)
|
||||
|
||||
# Session security settings from environment or config
|
||||
app.config['SESSION_COOKIE_SECURE'] = get_env_var('SESSION_COOKIE_SECURE', config.getboolean('session', 'SESSION_COOKIE_SECURE', fallback=True))
|
||||
app.config['SESSION_COOKIE_HTTPONLY'] = get_env_var('SESSION_COOKIE_HTTPONLY', config.getboolean('session', 'SESSION_COOKIE_HTTPONLY', fallback=True))
|
||||
app.config['SESSION_COOKIE_SAMESITE'] = get_env_var('SESSION_COOKIE_SAMESITE', config.get('session', 'SESSION_COOKIE_SAMESITE', fallback='Lax'))
|
||||
app.config['REMEMBER_COOKIE_SECURE'] = get_env_var('REMEMBER_COOKIE_SECURE', config.getboolean('session', 'REMEMBER_COOKIE_SECURE', fallback=True))
|
||||
app.config['REMEMBER_COOKIE_HTTPONLY'] = get_env_var('REMEMBER_COOKIE_HTTPONLY', config.getboolean('session', 'REMEMBER_COOKIE_HTTPONLY', fallback=True))
|
||||
app.config['REMEMBER_COOKIE_DURATION'] = int(get_env_var('REMEMBER_COOKIE_DURATION', config.getint('session', 'REMEMBER_COOKIE_DURATION', fallback=7200)))
|
||||
app.config['PERMANENT_SESSION_LIFETIME'] = int(get_env_var('PERMANENT_SESSION_LIFETIME', config.getint('session', 'PERMANENT_SESSION_LIFETIME', fallback=7200)))
|
||||
app.config['APP_DEBUG'] = get_env_var('APP_DEBUG', config.getboolean('app', 'APP_DEBUG', fallback=False))
|
||||
app.config['TIMEZONE'] = get_env_var('TIMEZONE', config.get('app', 'TIMEZONE', fallback='Europe/London'))
|
||||
|
||||
# Setup compression if enabled
|
||||
if config.getboolean('cache', 'ENABLE_COMPRESSION', fallback=True):
|
||||
compress = Compress()
|
||||
compress.init_app(app)
|
||||
# Configure compression level and threshold
|
||||
app.config['COMPRESS_LEVEL'] = config.getint('cache', 'COMPRESSION_LEVEL', fallback=6)
|
||||
app.config['COMPRESS_MIN_SIZE'] = config.getint('cache', 'COMPRESSION_MIN_SIZE', fallback=500)
|
||||
app.config['COMPRESS_MIMETYPES'] = [
|
||||
'text/html', 'text/css', 'text/xml', 'application/json', 'application/javascript',
|
||||
'application/x-javascript', 'image/svg+xml'
|
||||
]
|
||||
|
||||
# Enable CSRF protection
|
||||
csrf = CSRFProtect(app)
|
||||
|
||||
# Configure static files with caching
|
||||
@app.route('/favicon.ico')
|
||||
def favicon():
|
||||
response = send_from_directory(os.path.join(app.root_path, 'static', 'img'),
|
||||
'favicon.ico', mimetype='image/ico')
|
||||
|
||||
# Add cache headers manually instead of using cache_timeout
|
||||
max_age = config.getint('cache', 'IMAGE_MAX_AGE', fallback=604800)
|
||||
response.headers['Cache-Control'] = f'public, max-age={max_age}'
|
||||
response.headers['Expires'] = (datetime.now(timezone.utc) + timedelta(seconds=max_age)).strftime('%a, %d %b %Y %H:%M:%S GMT')
|
||||
|
||||
return response
|
||||
|
||||
# Setup security headers
|
||||
setup_security_headers(app, config)
|
||||
|
||||
# Configure HSTS settings for security headers
|
||||
app.config['HSTS_ENABLED'] = config.getboolean('security', 'ENABLE_HSTS', fallback=True)
|
||||
app.config['HSTS_MAX_AGE'] = config.getint('security', 'HSTS_MAX_AGE', fallback=31536000)
|
||||
|
||||
# return send_from_directory(os.path.join(app.root_path, 'static', 'img'),
|
||||
# 'favicon.png', mimetype='image/png',
|
||||
# cache_timeout=config.getint('cache', 'IMAGE_MAX_AGE', fallback=604800))
|
||||
|
||||
# Add cache headers to static files
|
||||
@app.after_request
|
||||
def add_cache_headers(response):
|
||||
# Only add cache headers for static files
|
||||
if request.path.startswith('/static/'):
|
||||
# Get cache settings from config
|
||||
default_max_age = config.getint('cache', 'STATIC_MAX_AGE', fallback=86400)
|
||||
image_max_age = config.getint('cache', 'IMAGE_MAX_AGE', fallback=604800)
|
||||
js_css_max_age = config.getint('cache', 'JS_CSS_MAX_AGE', fallback=43200)
|
||||
|
||||
# Set default cache expiration
|
||||
max_age = default_max_age
|
||||
|
||||
# Set longer cache for assets that rarely change like fonts, images
|
||||
if any(request.path.endswith(ext) for ext in ['.png', '.jpg', '.jpeg', '.gif', '.ico', '.woff2', '.woff', '.ttf']):
|
||||
max_age = image_max_age
|
||||
|
||||
# Set shorter cache for JS and CSS that might change with deployments
|
||||
if any(request.path.endswith(ext) for ext in ['.js', '.css']):
|
||||
max_age = js_css_max_age
|
||||
|
||||
response.headers['Cache-Control'] = f'public, max-age={max_age}'
|
||||
response.headers['Expires'] = (datetime.now(timezone.utc) + timedelta(seconds=max_age)).strftime('%a, %d %b %Y %H:%M:%S GMT')
|
||||
|
||||
# Add ETag support for efficient caching
|
||||
if 'ETag' not in response.headers:
|
||||
response.add_etag()
|
||||
|
||||
return response
|
||||
|
||||
# Request logger middleware
|
||||
@app.before_request
|
||||
def log_request_info():
|
||||
if app.config['APP_DEBUG']:
|
||||
logger.debug('Request URL: %s', request.url)
|
||||
logger.debug('Request Method: %s', request.method)
|
||||
logger.debug('Request Headers: %s', dict(request.headers))
|
||||
# Only try to access request.json if the content type is application/json
|
||||
if request.is_json and request.get_data(as_text=True):
|
||||
try:
|
||||
logger.debug('Request Body: %s', request.json)
|
||||
except Exception as e:
|
||||
logger.debug('Error parsing JSON: %s', str(e))
|
||||
|
||||
|
||||
# Ensure session is permanent and uses configured lifetime
|
||||
@app.before_request
|
||||
def make_session_permanent():
|
||||
session.permanent = True
|
||||
|
||||
db.init_app(app)
|
||||
bcrypt.init_app(app)
|
||||
login_manager.init_app(app)
|
||||
|
||||
wsg = WsgiToAsgi(app)
|
||||
|
||||
# Register blueprints
|
||||
app.register_blueprint(auth_bp, url_prefix='/auth')
|
||||
app.register_blueprint(api_bp, url_prefix='/api')
|
||||
app.register_blueprint(frontend_bp)
|
||||
|
||||
# Setup rate limiting after blueprints are registered
|
||||
apply_rate_limits(app, config)
|
||||
|
||||
def handle_http_exception(exc:HTTPException):
|
||||
"""Use the code and description from an HTTPException to inform the user of an error"""
|
||||
logger.debug('HTTP error %s - %s', exc.code, exc.description)
|
||||
return render_template("error.html", status_code=exc.code, description=exc.description)
|
||||
|
||||
def handle_uncaught_exception(exc:Exception):
|
||||
"""Log the exception, then return a generic server error page."""
|
||||
logger.warning('HTTP error 500 - Internal server error')
|
||||
return render_template("error.html", status_code=500, description='Internal server error')
|
||||
|
||||
# This handler is run when an HTTPException, or any of its subclasses, is raised
|
||||
app.register_error_handler(HTTPException, handle_http_exception)
|
||||
# This handler is run for all other uncaught exceptions
|
||||
app.register_error_handler(Exception, handle_uncaught_exception)
|
||||
|
||||
@login_manager.user_loader
|
||||
def load_user(user_id):
|
||||
return User.query.get(int(user_id))
|
||||
|
||||
def init_app():
|
||||
with app.app_context():
|
||||
db.create_all()
|
||||
# Create settings if not exists
|
||||
if not Settings.query.first():
|
||||
settings = Settings(
|
||||
allow_registration=False,
|
||||
restrict_email_domains=False,
|
||||
log_level='WARNING' # Default logging level
|
||||
)
|
||||
db.session.add(settings)
|
||||
else:
|
||||
# Update existing settings to have log_level if it doesn't exist
|
||||
settings = Settings.query.first()
|
||||
if not hasattr(settings, 'log_level') or settings.log_level is None:
|
||||
settings.log_level = 'WARNING'
|
||||
db.session.add(settings)
|
||||
|
||||
# Create default admin if not exists
|
||||
admin = User.query.filter_by(email='superadmin@example.com').first()
|
||||
if not admin:
|
||||
hashed_password = bcrypt.generate_password_hash('adminsuper').decode('utf-8')
|
||||
admin = User(
|
||||
username='superadmin',
|
||||
email='superadmin@example.com',
|
||||
password=hashed_password,
|
||||
role='GlobalAdmin',
|
||||
is_active=True
|
||||
)
|
||||
db.session.add(admin)
|
||||
db.session.commit()
|
||||
|
||||
# Create initial API key for admin
|
||||
api_key = ApiKey(
|
||||
key=ApiKey.generate_key(),
|
||||
description="Initial Admin API Key",
|
||||
user_id=admin.id
|
||||
)
|
||||
db.session.add(api_key)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
# Initialize the application
|
||||
init_app()
|
||||
|
||||
# Setup database logging after app initialization
|
||||
setup_database_logging(app)
|
||||
|
||||
@app.context_processor
|
||||
def inject_settings():
|
||||
settings = Settings.query.first()
|
||||
return dict(allow_registration=settings.allow_registration if settings else False)
|
||||
|
||||
# Add template filter for proper timezone display
|
||||
@app.template_filter('format_datetime')
|
||||
def format_datetime(value):
|
||||
"""Format a datetime with proper timezone"""
|
||||
if value is None:
|
||||
return ""
|
||||
|
||||
# Use the timezone utility function
|
||||
from utils.toolbox import format_timestamp_for_display
|
||||
return format_timestamp_for_display(value)
|
||||
|
||||
# Exempt API endpoints from CSRF (since they use API keys)
|
||||
with app.app_context():
|
||||
csrf.exempt(api_bp)
|
||||
|
||||
def run_app():
|
||||
"""Start the application with Uvicorn using config settings"""
|
||||
host = config.get('server', 'HOST', fallback='0.0.0.0')
|
||||
port = config.getint('server', 'PORT', fallback=8000)
|
||||
ssl_certfile = config.get('server', 'SSL_CERTFILE', fallback='certs/cert.pem')
|
||||
ssl_keyfile = config.get('server', 'SSL_KEYFILE', fallback='certs/key.pem')
|
||||
|
||||
# Get new configuration settings
|
||||
development_mode = config.getboolean('server', 'DEVELOPMENT_MODE', fallback=False)
|
||||
watch_files = config.getboolean('server', 'WATCH_FILES', fallback=False)
|
||||
workers_setting = config.get('server', 'WORKERS', fallback='1')
|
||||
worker_lifetime = config.getint('server', 'WORKER_LIFETIME', fallback=86400)
|
||||
graceful_shutdown = config.getboolean('server', 'GRACEFUL_SHUTDOWN', fallback=True)
|
||||
shutdown_timeout = config.getint('server', 'SHUTDOWN_TIMEOUT', fallback=30)
|
||||
|
||||
# Parse workers setting - could be "auto" or a number
|
||||
workers = None
|
||||
if workers_setting.lower() == 'auto':
|
||||
import multiprocessing
|
||||
workers = multiprocessing.cpu_count()
|
||||
else:
|
||||
try:
|
||||
workers = int(workers_setting)
|
||||
except ValueError:
|
||||
logger.warning(f"Invalid WORKERS setting '{workers_setting}', defaulting to 1")
|
||||
workers = 1
|
||||
|
||||
# Only enable file watching in development mode
|
||||
reload_enabled = development_mode and watch_files
|
||||
|
||||
# Use debug log level in development mode
|
||||
log_level = "debug" if development_mode else "info"
|
||||
|
||||
logger.info(f"Starting application on {host}:{port} with SSL")
|
||||
logger.info(f"SSL certificate: {ssl_certfile}")
|
||||
logger.info(f"SSL key: {ssl_keyfile}")
|
||||
logger.info(f"Development mode: {development_mode}")
|
||||
logger.info(f"File watching: {reload_enabled}")
|
||||
logger.info(f"Workers: {workers}")
|
||||
|
||||
# Get max requests per worker before graceful restart
|
||||
# Setting to None disables the worker auto-restart feature
|
||||
limit_max_requests = None
|
||||
if worker_lifetime > 0:
|
||||
# If worker_lifetime is set (> 0), we'll use a reasonable request limit
|
||||
# Default to around 10,000 requests per worker before restart
|
||||
limit_max_requests = 10000
|
||||
|
||||
# Get trusted proxy configuration for Uvicorn
|
||||
trusted_proxies_config = config.get('proxy', 'TRUSTED_PROXIES', fallback='').strip()
|
||||
if trusted_proxies_config:
|
||||
forwarded_allow_ips = trusted_proxies_config.replace(',', ' ')
|
||||
logger.info(f"Uvicorn forwarded_allow_ips: {forwarded_allow_ips}")
|
||||
else:
|
||||
forwarded_allow_ips = '*'
|
||||
logger.info("Uvicorn allowing all IPs for forwarded headers")
|
||||
|
||||
uvicorn.run(
|
||||
"app:wsg",
|
||||
host=host,
|
||||
port=port,
|
||||
reload=reload_enabled,
|
||||
workers=workers,
|
||||
log_level=log_level,
|
||||
ssl_certfile=ssl_certfile,
|
||||
ssl_keyfile=ssl_keyfile,
|
||||
proxy_headers=True,
|
||||
forwarded_allow_ips=forwarded_allow_ips,
|
||||
timeout_keep_alive=65, # Keep-alive timeout to detect hanging connections
|
||||
limit_max_requests=limit_max_requests, # Fixed: Only restart workers after this many requests
|
||||
timeout_graceful_shutdown=shutdown_timeout
|
||||
)
|
||||
|
||||
if __name__ == '__main__':
|
||||
parser = argparse.ArgumentParser(description='Domain Logons Monitoring Application')
|
||||
parser.add_argument('--legacy', action='store_true', help='Use legacy Flask server instead of Uvicorn')
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.legacy:
|
||||
# Legacy Flask server mode
|
||||
ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
|
||||
ssl_certfile = config.get('server', 'SSL_CERTFILE', fallback='certs/cert.pem')
|
||||
ssl_keyfile = config.get('server', 'SSL_KEYFILE', fallback='certs/key.pem')
|
||||
ssl_context.load_cert_chain(ssl_certfile, ssl_keyfile)
|
||||
ssl_context.verify_mode = ssl.CERT_NONE # Accept self-signed certificates
|
||||
|
||||
host = config.get('server', 'HOST', fallback='0.0.0.0')
|
||||
port = config.getint('server', 'PORT', fallback=8000)
|
||||
|
||||
app.run(debug=app.config['APP_DEBUG'], ssl_context=ssl_context, host=host, port=port)
|
||||
else:
|
||||
# Default to Uvicorn server
|
||||
run_app()
|
||||
360
app_info.md
Normal file
360
app_info.md
Normal file
@@ -0,0 +1,360 @@
|
||||
# Domain Login Monitoring System - Application Information
|
||||
|
||||
## **Overview**
|
||||
A login monitoring system built with Flask that tracks user authentication events across Windows domains. Features multi-tenancy support.
|
||||
|
||||
## **Architecture**
|
||||
|
||||
### **Core Components**
|
||||
```
|
||||
domain-logons/
|
||||
├── app.py # Main Flask application with Uvicorn ASGI server
|
||||
├── extensions.py # Flask extensions and configuration
|
||||
├── config.ini # Configuration file with multiple database support
|
||||
├── requirements.txt # Python dependencies
|
||||
└── run.sh # Application startup script
|
||||
|
||||
├── auth/ # Authentication & Authorization Module
|
||||
│ ├── models.py # User, Company, ApiKey, Settings models
|
||||
│ ├── routes.py # Auth routes (login, register, MFA, admin)
|
||||
│ └── forms.py # WTForms for validation
|
||||
|
||||
├── api/ # REST API Module
|
||||
│ ├── models.py # Log, ErrorLog models
|
||||
│ └── routes.py # API endpoints (/log_event, /health)
|
||||
|
||||
├── frontend/ # Web Interface Module
|
||||
│ ├── routes.py # Dashboard, reports, home routes
|
||||
│ └── models.py # Frontend-specific models
|
||||
|
||||
├── utils/ # Utility Modules
|
||||
│ ├── security_headers.py # Security headers middleware
|
||||
│ ├── rate_limiter.py # Rate limiting with Redis support
|
||||
│ └── health_check.py # System health monitoring
|
||||
|
||||
├── templates/ # Jinja2 Templates
|
||||
│ ├── base.html # Base template with dark theme
|
||||
│ ├── auth/ # Authentication templates
|
||||
│ └── frontend/ # Dashboard and report templates
|
||||
|
||||
├── static/ # Static Assets
|
||||
│ ├── css/ # Bootstrap 5, DataTables, custom CSS
|
||||
│ ├── js/ # jQuery, DataTables, charts, custom JS
|
||||
│ └── img/ # Icons and images
|
||||
|
||||
├── windows_agent/ # Windows Client
|
||||
│ └── winagentUSM.exe # Compiled Windows monitoring agent
|
||||
|
||||
└── instance/ # Instance-specific files
|
||||
├── database.db # SQLite database (default)
|
||||
└── certs/ # SSL certificates
|
||||
```
|
||||
|
||||
## 🔧 **Technology Stack**
|
||||
|
||||
### **Backend Framework**
|
||||
- **Flask**: Web framework with blueprint architecture
|
||||
- **Uvicorn**: ASGI server for production deployment
|
||||
- **SQLAlchemy**: ORM with support for SQLite, PostgreSQL, MySQL, MSSQL
|
||||
- **Flask-Login**: Session management and authentication
|
||||
- **Flask-WTF**: Form handling and CSRF protection
|
||||
|
||||
### **Security & Authentication**
|
||||
- **Bcrypt**: Password hashing
|
||||
- **PyOTP**: Time-based One-Time Password (TOTP) for MFA
|
||||
- **JWT**: Optional token-based authentication
|
||||
- **CSRF Protection**: Built-in token validation
|
||||
- **Security Headers**: CSP, HSTS, X-Frame-Options
|
||||
- **Rate Limiting**: IP-based with Redis backend support
|
||||
|
||||
### **Frontend**
|
||||
- **Bootstrap 5**: Responsive UI framework with dark theme
|
||||
- **DataTables**: Advanced table features with export capabilities
|
||||
- **Chart.js**: Data visualization for reports
|
||||
- **Moment.js**: Date/time handling
|
||||
- **DateRangePicker**: Advanced date selection
|
||||
|
||||
### **Database Support**
|
||||
- **SQLite**: Default (file-based)
|
||||
- **PostgreSQL**: Production recommended
|
||||
- **MySQL/MariaDB**: Enterprise support
|
||||
- **Microsoft SQL Server**: Corporate environments
|
||||
|
||||
### **Deployment**
|
||||
- **Docker**: Containerization ready
|
||||
- **Traefik**: Reverse proxy with SSL termination
|
||||
- **systemd**: Linux service integration
|
||||
- **SSL/TLS**: Built-in HTTPS support
|
||||
|
||||
## **Key Features**
|
||||
|
||||
### **Multi-Tenancy**
|
||||
- **Company Isolation**: Complete data separation between organizations
|
||||
- **Role-Based Access Control**: GlobalAdmin, Admin, CompanyAdmin, User roles
|
||||
- **API Key Management**: Per-company API keys with usage tracking
|
||||
- **User Assignment**: Users can belong to multiple companies
|
||||
|
||||
### **Authentication & Security**
|
||||
- **Multi-Factor Authentication (MFA)**: TOTP with QR code setup
|
||||
- **Flexible MFA Policies**: Global enforcement with per-user overrides
|
||||
- **Password Policies**: Configurable strength requirements
|
||||
- **Session Security**: Secure cookies, HTTPS enforcement
|
||||
- **Rate Limiting**: Brute force protection
|
||||
|
||||
### **Monitoring & Logging**
|
||||
- **Real-time Event Tracking**: Login, logout, lock events
|
||||
- **Windows Agent Integration**: Automated event collection
|
||||
- **API Health Monitoring**: Database connectivity checks
|
||||
- **Error Logging**: Structured application error tracking
|
||||
- **Audit Trail**: Complete user action logging
|
||||
|
||||
### **Dashboard & Reporting**
|
||||
- **Interactive Dashboard**: Real-time login event monitoring
|
||||
- **Column Visibility Controls**: Customizable table views with localStorage persistence
|
||||
- **Time Spent Reports**: User session duration analysis
|
||||
- **Export Capabilities**: CSV, Excel, PDF export
|
||||
- **Date Range Filtering**: Flexible time period selection
|
||||
|
||||
### **API Capabilities**
|
||||
- **RESTful API**: JSON-based event logging
|
||||
- **API Key Authentication**: Secure programmatic access
|
||||
- **Health Check Endpoint**: System status monitoring
|
||||
- **Timestamp Flexibility**: Multiple format support
|
||||
|
||||
|
||||
### **Core Tables**
|
||||
```sql
|
||||
-- Authentication
|
||||
app_auth_users # User accounts with MFA
|
||||
app_auth_companies # Company/organization entities
|
||||
app_auth_user_companies # Many-to-many user-company relationships
|
||||
app_auth_api_keys # API keys with company association
|
||||
app_auth_settings # Global application settings
|
||||
|
||||
-- Logging
|
||||
api_logs # Login/logout event records
|
||||
api_error_logs # Application error tracking
|
||||
```
|
||||
|
||||
### **Key Relationships**
|
||||
- Users ↔ Companies (Many-to-Many via UserCompany)
|
||||
- Companies → API Keys (One-to-Many)
|
||||
- API Keys → Logs (One-to-Many)
|
||||
- Users → API Keys (One-to-Many)
|
||||
|
||||
### **Authentication Security**
|
||||
- Bcrypt password hashing with configurable rounds
|
||||
- TOTP-based MFA
|
||||
- Session timeout and secure cookie settings
|
||||
- CSRF protection on all forms
|
||||
- Password strength validation
|
||||
|
||||
### **Network Security**
|
||||
- HTTPS enforcement with HSTS
|
||||
- Security headers (CSP, X-Frame-Options, etc.)
|
||||
- Proxy support for Traefik/nginx
|
||||
- IP-based rate limiting
|
||||
- Trusted proxy configuration
|
||||
|
||||
### **Application Security**
|
||||
- SQL injection prevention via ORM
|
||||
- XSS protection with template escaping
|
||||
- Input validation and sanitization
|
||||
- Error handling without information disclosure
|
||||
|
||||
### **Dashboard Features**
|
||||
- Real-time login event display
|
||||
- Advanced filtering and search
|
||||
- Column visibility customization
|
||||
- Export functionality (CSV, Excel, Print)
|
||||
- Responsive design for mobile devices
|
||||
|
||||
### **Reporting Capabilities**
|
||||
- Time spent analysis per user
|
||||
- Login frequency reports
|
||||
- Failed authentication tracking
|
||||
- Company-specific analytics
|
||||
|
||||
### **System Monitoring**
|
||||
- Database connectivity health checks
|
||||
|
||||
## 🛠️ **Configuration**
|
||||
|
||||
### **Environment Variables**
|
||||
```bash
|
||||
# Security
|
||||
SECRET_KEY=your-secret-key-here
|
||||
SESSION_COOKIE_SECURE=true
|
||||
|
||||
# Database
|
||||
DATABASE_URL=sqlite:///database.db
|
||||
# or: postgresql://user:pass@host:port/db
|
||||
# or: mysql+pymysql://user:pass@host:port/db
|
||||
|
||||
# Application
|
||||
APP_DEBUG=false
|
||||
TIMEZONE=Europe/London
|
||||
|
||||
# Server
|
||||
HOST=0.0.0.0
|
||||
PORT=8000
|
||||
SSL_CERTFILE=certs/cert.pem
|
||||
SSL_KEYFILE=certs/key.pem
|
||||
```
|
||||
|
||||
### **Configuration File Structure**
|
||||
```ini
|
||||
[app] # Application settings
|
||||
[database] # Database connection
|
||||
[server] # Server configuration
|
||||
[session] # Session security
|
||||
[security] # Security headers
|
||||
[cache] # Static file caching
|
||||
[proxy] # Reverse proxy settings
|
||||
[rate_limiting] # Rate limit configuration
|
||||
```
|
||||
|
||||
## **Deployment Options**
|
||||
|
||||
### **Development**
|
||||
```bash
|
||||
# Install dependencies
|
||||
pip install -r requirements.txt
|
||||
|
||||
# Generate SSL certificates
|
||||
openssl req -x509 -newkey rsa:4096 -keyout certs/key.pem -out certs/cert.pem -days 3650 -nodes
|
||||
|
||||
# Run application
|
||||
python app.py
|
||||
```
|
||||
|
||||
### **Production with Docker**
|
||||
```dockerfile
|
||||
FROM python:3.11-slim
|
||||
COPY . /app
|
||||
WORKDIR /app
|
||||
RUN pip install -r requirements.txt
|
||||
CMD ["python", "app.py"]
|
||||
```
|
||||
|
||||
### **Systemd Service**
|
||||
```ini
|
||||
[Unit]
|
||||
Description=Domain Login Monitor
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=exec
|
||||
User=www-data
|
||||
WorkingDirectory=/opt/domain-logons
|
||||
ExecStart=/opt/domain-logons/.venv/bin/python app.py
|
||||
Restart=always
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
## 📡 **API Reference**
|
||||
|
||||
### **Authentication**
|
||||
All API endpoints require `X-API-Key` header with valid API key.
|
||||
|
||||
### **Endpoints**
|
||||
|
||||
#### **POST /api/log_event**
|
||||
Log authentication events from Windows clients.
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{
|
||||
"EventType": "Sign-In|Sign-Out|Lock",
|
||||
"UserName": "john.doe",
|
||||
"ComputerName": "WORKSTATION-01",
|
||||
"IPAddress": "192.168.1.100",
|
||||
"Timestamp": "2025-05-25T14:30:00Z",
|
||||
"retry": 0
|
||||
}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"message": "Event logged successfully",
|
||||
"status": "success"
|
||||
}
|
||||
```
|
||||
|
||||
#### **POST /api/health**
|
||||
Check system health and API connectivity.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"message": "Health check passed",
|
||||
"timestamp": "2025-05-25T14:30:00+00:00",
|
||||
"database": "connected",
|
||||
"api_key_verified": true,
|
||||
"company_id": 1
|
||||
}
|
||||
```
|
||||
|
||||
## **Windows Client Integration**
|
||||
|
||||
### **Compiled Agent**
|
||||
- **winagentUSM.exe**: Standalone Windows executable
|
||||
- **Event Log Integration**: Monitors Windows Security events
|
||||
- **Automatic Retry**: Built-in error handling and retry logic
|
||||
- **Service Mode**: Can run as Windows service
|
||||
- It is build with GO Lang
|
||||
|
||||
## **Default Credentials**
|
||||
|
||||
### **Initial Admin Account**
|
||||
- **Username**: `superadmin`
|
||||
- **Email**: `superadmin@example.com`
|
||||
- **Password**: `adminsuper`
|
||||
- **Role**: `GlobalAdmin`
|
||||
|
||||
### **API Key**
|
||||
Initial API key is automatically generated for the admin account and displayed in Admin Settings.
|
||||
|
||||
## **Database Migration Support**
|
||||
|
||||
### **Supported Databases**
|
||||
- **SQLite**: Default, perfect for small-medium deployments
|
||||
- **PostgreSQL**: Recommended for production (best performance)
|
||||
- **MySQL/MariaDB**: Enterprise environments
|
||||
- **Microsoft SQL Server**: Corporate Windows environments
|
||||
|
||||
### **Migration Path**
|
||||
1. Export data from current database
|
||||
2. Update `config.ini` with new database connection
|
||||
3. Run application to auto-create tables
|
||||
4. Import data using provided migration scripts
|
||||
|
||||
## **Testing & Validation**
|
||||
|
||||
### **API Testing**
|
||||
```bash
|
||||
# Test login event
|
||||
curl -k -X POST https://localhost:8000/api/log_event \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-API-Key: YOUR_API_KEY" \
|
||||
-d '{"EventType": "Sign-In", "UserName": "test", "ComputerName": "TEST-PC", "IPAddress": "192.168.1.100", "Timestamp": "2025-05-25T14:30:00Z"}'
|
||||
|
||||
# Test health check
|
||||
curl -k -X POST https://localhost:8000/api/health \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-API-Key: YOUR_API_KEY"
|
||||
```
|
||||
|
||||
### **Web Interface Testing**
|
||||
1. Navigate to `https://localhost:8000`
|
||||
2. Login with default credentials
|
||||
3. Create companies and users
|
||||
4. Generate API keys
|
||||
5. Test dashboard functionality
|
||||
|
||||
### **Additional Resources**
|
||||
- **Configuration Reference**: `config.ini`
|
||||
1
auth/__init__.py
Normal file
1
auth/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from .routes import auth_bp
|
||||
80
auth/forms.py
Normal file
80
auth/forms.py
Normal file
@@ -0,0 +1,80 @@
|
||||
from flask_wtf import FlaskForm
|
||||
from wtforms import StringField, PasswordField, SubmitField, BooleanField
|
||||
from wtforms.validators import DataRequired, Length, Email, EqualTo, ValidationError
|
||||
from email_validator import validate_email, EmailNotValidError
|
||||
from .models import User, Settings, AllowedDomain
|
||||
from extensions import db
|
||||
import re
|
||||
|
||||
def validate_password_strength(password):
|
||||
"""Validate password based on current settings"""
|
||||
settings = Settings.query.first()
|
||||
if not settings:
|
||||
return # No settings found, allow any password
|
||||
|
||||
errors = []
|
||||
|
||||
# Check minimum length
|
||||
if len(password) < settings.password_min_length:
|
||||
errors.append(f'Password must be at least {settings.password_min_length} characters long.')
|
||||
|
||||
# Check for numbers and mixed case if required
|
||||
if settings.password_require_numbers_mixed_case:
|
||||
if not re.search(r'[0-9]', password):
|
||||
errors.append('Password must contain at least one number.')
|
||||
if not re.search(r'[a-z]', password):
|
||||
errors.append('Password must contain at least one lowercase letter.')
|
||||
if not re.search(r'[A-Z]', password):
|
||||
errors.append('Password must contain at least one uppercase letter.')
|
||||
|
||||
# Check for special characters if required
|
||||
if settings.password_require_special_chars:
|
||||
safe_chars = settings.password_safe_special_chars or '!@#$%^&*()_+-=[]{}|;:,.<>?'
|
||||
if not any(char in safe_chars for char in password):
|
||||
errors.append(f'Password must contain at least one special character from: {safe_chars}')
|
||||
|
||||
if errors:
|
||||
raise ValidationError(' '.join(errors))
|
||||
|
||||
class RegistrationForm(FlaskForm):
|
||||
username = StringField('Username', validators=[DataRequired(), Length(min=2, max=20)])
|
||||
email = StringField('Email', validators=[DataRequired(), Email()])
|
||||
password = PasswordField('Password', validators=[DataRequired(), validate_password_strength])
|
||||
confirm_password = PasswordField('Confirm Password', validators=[DataRequired(), EqualTo('password')])
|
||||
submit = SubmitField('Sign Up')
|
||||
|
||||
def validate_username(self, username):
|
||||
user = User.query.filter(db.func.lower(User.username) == username.data.lower()).first()
|
||||
if user:
|
||||
raise ValidationError('That username is taken. Please choose a different one.')
|
||||
|
||||
def validate_email(self, email):
|
||||
try:
|
||||
validate_email(email.data)
|
||||
except EmailNotValidError:
|
||||
raise ValidationError('Invalid email address.')
|
||||
|
||||
settings = Settings.query.first()
|
||||
if settings and settings.restrict_email_domains:
|
||||
domain = '@' + email.data.split('@')[1].lower()
|
||||
if not AllowedDomain.query.filter_by(domain=domain).first():
|
||||
raise ValidationError('Registration is not allowed for this email domain.')
|
||||
|
||||
user = User.query.filter(db.func.lower(User.email) == email.data.lower()).first()
|
||||
if user:
|
||||
raise ValidationError('That email is taken. Please choose a different one.')
|
||||
|
||||
class LoginForm(FlaskForm):
|
||||
email = StringField('Username or Email', validators=[DataRequired()])
|
||||
password = PasswordField('Password', validators=[DataRequired()])
|
||||
remember = BooleanField('Remember Me')
|
||||
submit = SubmitField('Login')
|
||||
|
||||
class ChangePasswordForm(FlaskForm):
|
||||
current_password = PasswordField('Current Password', validators=[DataRequired()])
|
||||
new_password = PasswordField('New Password', validators=[DataRequired(), validate_password_strength])
|
||||
confirm_password = PasswordField('Confirm New Password', validators=[DataRequired(), EqualTo('new_password')])
|
||||
submit = SubmitField('Change Password')
|
||||
|
||||
class ApiKeyForm(FlaskForm):
|
||||
submit = SubmitField('Generate New API Key')
|
||||
132
auth/models.py
Normal file
132
auth/models.py
Normal file
@@ -0,0 +1,132 @@
|
||||
from flask_login import UserMixin
|
||||
from extensions import db
|
||||
from datetime import datetime
|
||||
import secrets
|
||||
import pyotp
|
||||
|
||||
class Settings(db.Model):
|
||||
__tablename__ = 'app_auth_settings'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
allow_registration = db.Column(db.Boolean, default=False)
|
||||
restrict_email_domains = db.Column(db.Boolean, default=False)
|
||||
|
||||
# Password strength requirements
|
||||
password_min_length = db.Column(db.Integer, default=10)
|
||||
password_require_numbers_mixed_case = db.Column(db.Boolean, default=True)
|
||||
password_require_special_chars = db.Column(db.Boolean, default=True)
|
||||
password_safe_special_chars = db.Column(db.String(100), default='!@#$%^&*()_+-=[]{}|;:,.<>?')
|
||||
|
||||
# MFA requirements
|
||||
require_mfa_for_all_users = db.Column(db.Boolean, default=False)
|
||||
|
||||
# Database logging configuration
|
||||
log_level = db.Column(db.String(20), default='WARNING')
|
||||
|
||||
# New Company model
|
||||
class Company(db.Model):
|
||||
__tablename__ = 'app_auth_companies'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
name = db.Column(db.String(100), unique=True, nullable=False)
|
||||
description = db.Column(db.String(200))
|
||||
created_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
|
||||
# Relationships
|
||||
users = db.relationship('UserCompany', back_populates='company')
|
||||
api_keys = db.relationship('ApiKey', backref='company', lazy=True)
|
||||
|
||||
# User-Company association table
|
||||
class UserCompany(db.Model):
|
||||
__tablename__ = 'app_auth_user_companies'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
user_id = db.Column(db.Integer, db.ForeignKey('app_auth_users.id'), nullable=False)
|
||||
company_id = db.Column(db.Integer, db.ForeignKey('app_auth_companies.id'), nullable=False)
|
||||
role = db.Column(db.String(50), nullable=False, default='User') # Role specific to this company: 'User', 'CompanyAdmin'
|
||||
|
||||
# Relationships
|
||||
user = db.relationship('User', back_populates='companies')
|
||||
company = db.relationship('Company', back_populates='users')
|
||||
|
||||
class User(db.Model, UserMixin):
|
||||
__tablename__ = 'app_auth_users'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
username = db.Column(db.String(20), unique=True, nullable=False)
|
||||
email = db.Column(db.String(120), unique=True, nullable=False)
|
||||
password = db.Column(db.String(60), nullable=False)
|
||||
role = db.Column(db.String(20), nullable=False, default='User') # Global role: 'User', 'Admin', 'GlobalAdmin'
|
||||
is_active = db.Column(db.Boolean, default=False)
|
||||
mfa_secret = db.Column(db.String(32))
|
||||
mfa_enabled = db.Column(db.Boolean, default=False)
|
||||
mfa_required = db.Column(db.Boolean, default=None) # None=inherit from global, True=required, False=not required
|
||||
api_keys = db.relationship('ApiKey', backref='user', lazy=True)
|
||||
# User-company relationship
|
||||
companies = db.relationship('UserCompany', back_populates='user')
|
||||
|
||||
def get_mfa_uri(self):
|
||||
if self.mfa_secret:
|
||||
return pyotp.totp.TOTP(self.mfa_secret).provisioning_uri(
|
||||
name=self.email,
|
||||
issuer_name="Domain Logon Monitor"
|
||||
)
|
||||
return None
|
||||
|
||||
def verify_mfa_code(self, code):
|
||||
if not self.mfa_secret or not code:
|
||||
return False
|
||||
totp = pyotp.TOTP(self.mfa_secret)
|
||||
return totp.verify(code)
|
||||
|
||||
def is_mfa_required(self):
|
||||
"""Check if MFA is required for this user based on global and per-user settings"""
|
||||
# GlobalAdmin accounts are exempt when global setting is ON
|
||||
if self.role == 'GlobalAdmin':
|
||||
return False
|
||||
|
||||
# Check per-user setting first
|
||||
if self.mfa_required is not None:
|
||||
return self.mfa_required
|
||||
|
||||
# Fall back to global setting
|
||||
settings = Settings.query.first()
|
||||
return settings.require_mfa_for_all_users if settings else False
|
||||
|
||||
def generate_mfa_secret(self):
|
||||
self.mfa_secret = pyotp.random_base32()
|
||||
return self.mfa_secret
|
||||
|
||||
def is_company_admin(self, company_id):
|
||||
"""Check if user is an admin for a specific company"""
|
||||
for uc in self.companies:
|
||||
if uc.company_id == company_id and uc.role == 'CompanyAdmin':
|
||||
return True
|
||||
return False
|
||||
|
||||
def is_global_admin(self):
|
||||
"""Check if user is a global administrator"""
|
||||
return self.role == 'GlobalAdmin'
|
||||
|
||||
def is_admin(self):
|
||||
"""Check if user is an admin (but not global admin)"""
|
||||
return self.role == 'Admin'
|
||||
|
||||
def get_companies(self):
|
||||
"""Get all companies this user has access to"""
|
||||
return [uc.company for uc in self.companies]
|
||||
|
||||
class ApiKey(db.Model):
|
||||
__tablename__ = 'app_auth_api_keys'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
key = db.Column(db.String(64), unique=True, nullable=False)
|
||||
description = db.Column(db.String(100))
|
||||
created_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
|
||||
last_used = db.Column(db.DateTime)
|
||||
is_active = db.Column(db.Boolean, default=True) # New field to control API key status
|
||||
user_id = db.Column(db.Integer, db.ForeignKey('app_auth_users.id'), nullable=False)
|
||||
company_id = db.Column(db.Integer, db.ForeignKey('app_auth_companies.id'), nullable=True)
|
||||
|
||||
@staticmethod
|
||||
def generate_key():
|
||||
return secrets.token_hex(32)
|
||||
|
||||
class AllowedDomain(db.Model):
|
||||
__tablename__ = 'app_auth_allowed_domains'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
domain = db.Column(db.String(100), unique=True, nullable=False)
|
||||
1937
auth/routes.py
Normal file
1937
auth/routes.py
Normal file
File diff suppressed because it is too large
Load Diff
164
config.ini
Normal file
164
config.ini
Normal file
@@ -0,0 +1,164 @@
|
||||
[app]
|
||||
SECRET_KEY = your_secret_key
|
||||
APP_DEBUG = true
|
||||
TIMEZONE = Europe/London
|
||||
|
||||
[server]
|
||||
HOST = 0.0.0.0
|
||||
PORT = 8000
|
||||
SSL_CERTFILE = instance/certs/cert.pem
|
||||
SSL_KEYFILE = instance/certs/key.pem
|
||||
; Server configuration
|
||||
; DEVELOPMENT_MODE: When true, enables development features (default: false)
|
||||
DEVELOPMENT_MODE = true
|
||||
; Watch for file changes and reload automatically (development only, default: false)
|
||||
WATCH_FILES = true
|
||||
; Number of worker processes for Uvicorn (default: 1)
|
||||
; For production, set to 2-4 workers for most servers
|
||||
; "auto" uses CPU count but may be excessive for some systems
|
||||
WORKERS = 2
|
||||
; Maximum number of seconds a worker can live (helps with memory leaks)
|
||||
WORKER_LIFETIME = 86400
|
||||
; Determines if server should stop gracefully or immediately on receiving SIGINT/SIGTERM
|
||||
GRACEFUL_SHUTDOWN = true
|
||||
; Timeout in seconds for graceful shutdown (default: 30)
|
||||
SHUTDOWN_TIMEOUT = 30
|
||||
|
||||
[database]
|
||||
; Current SQLite configuration
|
||||
SQLALCHEMY_DATABASE_URI = sqlite:///database.db
|
||||
SQLALCHEMY_TRACK_MODIFICATIONS = false
|
||||
|
||||
; ====== DATABASE CONNECTION EXAMPLES ======
|
||||
; Uncomment one of these examples and comment out the SQLite connection above to switch databases
|
||||
|
||||
; === PostgreSQL Example ===
|
||||
; Setup:
|
||||
; 1. Install PostgreSQL server
|
||||
; 2. Create database and user with proper permissions
|
||||
; 3. Install Python driver: pip install psycopg2-binary
|
||||
;
|
||||
; SQLALCHEMY_DATABASE_URI = postgresql://username:password@localhost:5432/database_name
|
||||
; For SSL connection:
|
||||
; SQLALCHEMY_DATABASE_URI = postgresql://username:password@localhost:5432/database_name?sslmode=require
|
||||
|
||||
; === MySQL/MariaDB Example ===
|
||||
; Setup:
|
||||
; 1. Install MySQL/MariaDB server
|
||||
; 2. Create database and user with proper permissions
|
||||
; 3. Install Python driver: pip install pymysql
|
||||
;
|
||||
; SQLALCHEMY_DATABASE_URI = mysql+pymysql://username:password@localhost:3306/database_name
|
||||
; For SSL connection:
|
||||
; SQLALCHEMY_DATABASE_URI = mysql+pymysql://username:password@localhost:3306/database_name?ssl_ca=/path/to/ca.pem
|
||||
|
||||
; === MSSQL Server Example ===
|
||||
; Setup:
|
||||
; 1. Install MSSQL Server
|
||||
; 2. Create database and user
|
||||
; 3. Install Python driver: pip install pyodbc
|
||||
; 4. Install ODBC Driver for SQL Server:
|
||||
; - On Ubuntu/Debian:
|
||||
; sudo apt-get install -y unixodbc-dev
|
||||
; sudo curl https://packages.microsoft.com/keys/microsoft.asc | apt-key add -
|
||||
; sudo curl https://packages.microsoft.com/config/ubuntu/$(lsb_release -rs)/prod.list > /etc/apt/sources.list.d/mssql-release.list
|
||||
; sudo apt-get update
|
||||
; sudo ACCEPT_EULA=Y apt-get install -y msodbcsql18 # Driver 18 (latest)
|
||||
; # Or for older driver: sudo ACCEPT_EULA=Y apt-get install -y msodbcsql17
|
||||
; - On RHEL/CentOS:
|
||||
; sudo curl https://packages.microsoft.com/config/rhel/8/prod.repo > /etc/yum.repos.d/mssql-release.repo
|
||||
; sudo ACCEPT_EULA=Y dnf install -y msodbcsql18 # Driver 18 (latest)
|
||||
; # Or for older driver: sudo ACCEPT_EULA=Y dnf install -y msodbcsql17
|
||||
; - On Windows:
|
||||
; Download and install from https://go.microsoft.com/fwlink/?linkid=2249006 # Driver 18
|
||||
; # Or for older driver: https://go.microsoft.com/fwlink/?linkid=2187217 # Driver 17
|
||||
;
|
||||
; # Using ODBC Driver 18 (recommended)
|
||||
; SQLALCHEMY_DATABASE_URI = mssql+pyodbc://username:password@server_name/database_name?driver=ODBC+Driver+18+for+SQL+Server
|
||||
; # Using ODBC Driver 17
|
||||
; SQLALCHEMY_DATABASE_URI = mssql+pyodbc://username:password@server_name/database_name?driver=ODBC+Driver+17+for+SQL+Server
|
||||
; # For named instance:
|
||||
; SQLALCHEMY_DATABASE_URI = mssql+pyodbc://username:password@server_name\\instance_name/database_name?driver=ODBC+Driver+18+for+SQL+Server
|
||||
|
||||
[session]
|
||||
SESSION_COOKIE_SECURE = true
|
||||
SESSION_COOKIE_HTTPONLY = true
|
||||
SESSION_COOKIE_SAMESITE = Lax
|
||||
REMEMBER_COOKIE_SECURE = true
|
||||
REMEMBER_COOKIE_HTTPONLY = true
|
||||
REMEMBER_COOKIE_DURATION = 7200
|
||||
PERMANENT_SESSION_LIFETIME = 7200
|
||||
|
||||
[cache]
|
||||
STATIC_MAX_AGE = 86400
|
||||
IMAGE_MAX_AGE = 604800
|
||||
JS_CSS_MAX_AGE = 43200
|
||||
ENABLE_COMPRESSION = true
|
||||
COMPRESSION_LEVEL = 6
|
||||
COMPRESSION_MIN_SIZE = 500
|
||||
|
||||
[security]
|
||||
; Security headers configuration
|
||||
CONTENT_SECURITY_POLICY = default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'; frame-ancestors 'self'; form-action 'self'; base-uri 'self'
|
||||
ENABLE_HSTS = true
|
||||
HSTS_MAX_AGE = 31536000
|
||||
ENABLE_SECURITY_HEADERS = true
|
||||
|
||||
[rate_limiting]
|
||||
; Rate limiting configuration
|
||||
ENABLE_RATE_LIMITING = true
|
||||
; Redis connection for rate limiting (leave empty to use in-memory storage)
|
||||
; REDIS_URL = redis://localhost:6379/0
|
||||
REDIS_URL =
|
||||
; Login endpoint limits
|
||||
LOGIN_LIMIT = 10
|
||||
LOGIN_PERIOD = 60
|
||||
; Registration endpoint limits
|
||||
REGISTER_LIMIT = 5
|
||||
REGISTER_PERIOD = 300
|
||||
; API endpoint limits
|
||||
API_LIMIT = 60
|
||||
API_PERIOD = 60
|
||||
|
||||
[proxy]
|
||||
; Reverse proxy configuration for Traefik
|
||||
; Number of proxies between the client and your app (default: 1 for single proxy like Traefik)
|
||||
PROXY_COUNT = 1
|
||||
; Whether to trust X-Forwarded-For header (required for Traefik)
|
||||
TRUST_X_FORWARDED_FOR = true
|
||||
; Whether to trust X-Forwarded-Proto header (for HTTPS detection)
|
||||
TRUST_X_FORWARDED_PROTO = true
|
||||
; Whether to trust X-Forwarded-Host header
|
||||
TRUST_X_FORWARDED_HOST = true
|
||||
; Whether to trust X-Forwarded-Port header
|
||||
TRUST_X_FORWARDED_PORT = true
|
||||
; Whether to trust X-Forwarded-Prefix header
|
||||
TRUST_X_FORWARDED_PREFIX = false
|
||||
; Trusted proxy IPs (leave empty to trust all, comma-separated for multiple)
|
||||
; For production with Traefik, specify your Traefik container IP or Docker network CIDR
|
||||
; Examples:
|
||||
; TRUSTED_PROXIES = 172.16.0.0/12,10.0.0.0/8,192.168.0.0/16 # Docker default networks
|
||||
; TRUSTED_PROXIES = 172.20.0.2,172.20.0.3 # Specific Traefik IPs
|
||||
; TRUSTED_PROXIES = 172.18.0.0/16 # Custom Docker network
|
||||
; For development/testing, leave empty to trust all proxies:
|
||||
TRUSTED_PROXIES =
|
||||
|
||||
[logging]
|
||||
; Database logging configuration
|
||||
; Enable/disable database logging entirely
|
||||
DB_LOGGING_ENABLED = true
|
||||
|
||||
; Loggers to exclude from database logging (comma-separated)
|
||||
; These loggers often create feedback loops or excessive noise
|
||||
DB_LOGGING_FILTERED_LOGGERS = watchfiles.main,watchfiles.watcher,watchdog,uvicorn.access,__mp_main__,__main__,app
|
||||
|
||||
; Message patterns to exclude from database logging (comma-separated)
|
||||
; Messages containing these patterns will not be logged to database
|
||||
DB_LOGGING_FILTERED_PATTERNS = database.db,instance/,file changed,reloading
|
||||
|
||||
; Enable filtering of file watcher logs (prevents feedback loops in debug mode)
|
||||
FILTER_FILE_WATCHER_LOGS = true
|
||||
|
||||
; Minimum time between identical log entries (seconds) to prevent spam
|
||||
DB_LOGGING_DEDUPE_INTERVAL = 1
|
||||
|
||||
28
extensions.py
Normal file
28
extensions.py
Normal file
@@ -0,0 +1,28 @@
|
||||
import os
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
from flask_bcrypt import Bcrypt
|
||||
from flask_login import LoginManager
|
||||
from flask import Blueprint
|
||||
import logging
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Initialize extensions
|
||||
db = SQLAlchemy()
|
||||
bcrypt = Bcrypt()
|
||||
login_manager = LoginManager()
|
||||
login_manager.login_view = 'auth.login' # Updated to use blueprint route
|
||||
login_manager.login_message_category = 'info'
|
||||
|
||||
# Environment variable helpers
|
||||
def get_env_var(name, default=None):
|
||||
"""Get environment variable with logging for missing critical values"""
|
||||
value = os.environ.get(name, default)
|
||||
if value is None:
|
||||
logger.warning(f"Environment variable {name} not set!")
|
||||
return value
|
||||
1
frontend/__init__.py
Normal file
1
frontend/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from .routes import frontend_bp
|
||||
3
frontend/models.py
Normal file
3
frontend/models.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from extensions import db
|
||||
# Frontend-specific models
|
||||
# Note: Log model is in auth/models.py as it's related to administrator functionality
|
||||
425
frontend/routes.py
Normal file
425
frontend/routes.py
Normal file
@@ -0,0 +1,425 @@
|
||||
from flask import Blueprint, render_template, request, redirect, url_for, flash, current_app
|
||||
from flask_login import login_required, current_user
|
||||
from auth.models import User, Company, UserCompany
|
||||
from api.models import Log
|
||||
from extensions import db
|
||||
from datetime import datetime, timedelta
|
||||
from sqlalchemy import or_
|
||||
import pytz
|
||||
import logging
|
||||
|
||||
# Initialize logger for this module
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
frontend = Blueprint('frontend', __name__)
|
||||
|
||||
frontend_bp = frontend
|
||||
|
||||
@frontend.route('/')
|
||||
@frontend.route('/home')
|
||||
def index():
|
||||
try:
|
||||
return render_template('frontend/home.html')
|
||||
except Exception as e:
|
||||
logger.exception("Home page error from IP %s: %s", request.remote_addr, str(e))
|
||||
flash('An error occurred while loading the home page.', 'error')
|
||||
return render_template('frontend/home.html') # Fallback to basic template
|
||||
|
||||
@frontend.route('/about')
|
||||
def about():
|
||||
try:
|
||||
return render_template('frontend/about.html')
|
||||
except Exception as e:
|
||||
logger.exception("About page error from IP %s: %s", request.remote_addr, str(e))
|
||||
flash('An error occurred while loading the about page.', 'error')
|
||||
return render_template('frontend/about.html') # Fallback to basic template
|
||||
|
||||
@frontend.route('/profile')
|
||||
@login_required
|
||||
def profile():
|
||||
try:
|
||||
user = User.query.filter_by(id=current_user.id).first()
|
||||
return render_template('frontend/profile.html', user=user)
|
||||
except Exception as e:
|
||||
logger.exception(
|
||||
"Profile page error for user %s (ID: %s) from IP %s: %s",
|
||||
current_user.username if current_user.is_authenticated else 'anonymous',
|
||||
current_user.id if current_user.is_authenticated else 'N/A',
|
||||
request.remote_addr,
|
||||
str(e)
|
||||
)
|
||||
flash('An error occurred while loading your profile. Please try again.', 'error')
|
||||
return redirect(url_for('frontend.index'))
|
||||
|
||||
@frontend.route('/dashboard')
|
||||
@login_required
|
||||
def dashboard():
|
||||
try:
|
||||
# Get app timezone
|
||||
app_tz = pytz.timezone(current_app.config['TIMEZONE'])
|
||||
|
||||
# Get date range from request or default to last 48 hours
|
||||
date_range = request.args.get('daterange')
|
||||
|
||||
if date_range:
|
||||
start_str, end_str = date_range.split(' - ')
|
||||
start_date = datetime.strptime(start_str, '%Y-%m-%d %H:%M')
|
||||
end_date = datetime.strptime(end_str, '%Y-%m-%d %H:%M')
|
||||
|
||||
# Make these timezone aware using the app's timezone
|
||||
start_date = app_tz.localize(start_date)
|
||||
end_date = app_tz.localize(end_date)
|
||||
else:
|
||||
end_date = datetime.now(app_tz) + timedelta(hours=1) # Include 1 hour in the future
|
||||
start_date = end_date - timedelta(hours=49) # Look back 48 hours from future end time
|
||||
|
||||
# Get company filter if provided
|
||||
company_id = request.args.get('company_id', type=int)
|
||||
|
||||
# Build base query with date range filter and eagerly load relationships
|
||||
from auth.models import ApiKey, Company
|
||||
|
||||
# Convert timezone-aware dates to naive for comparison with database timestamps
|
||||
# since the log timestamps are stored as naive datetime objects
|
||||
start_date_naive = start_date.replace(tzinfo=None)
|
||||
end_date_naive = end_date.replace(tzinfo=None)
|
||||
|
||||
# Use joinedload to eagerly load the API key and company relationships
|
||||
query = Log.query.options(
|
||||
db.joinedload(Log.api_key),
|
||||
db.joinedload(Log.company)
|
||||
).filter(Log.timestamp.between(start_date_naive, end_date_naive))
|
||||
|
||||
# Apply company-specific filtering based on user role
|
||||
if current_user.is_global_admin():
|
||||
# GlobalAdmin should be allowed to see all records, no matter what company/site
|
||||
if company_id:
|
||||
query = query.filter(Log.company_id == company_id)
|
||||
# If no company_id specified, show all logs (no additional filtering)
|
||||
else:
|
||||
# CompanyAdmin and User should see only company log events and API Keys (sites)
|
||||
# for companies they are member of
|
||||
user_company_ids = [uc.company_id for uc in current_user.companies]
|
||||
|
||||
if not user_company_ids:
|
||||
# If user has no company associations, show no logs
|
||||
query = query.filter(Log.id == -1) # Impossible condition = no results
|
||||
else:
|
||||
if company_id and company_id in user_company_ids:
|
||||
# Filter by the specific company if requested and user has access
|
||||
query = query.filter(Log.company_id == company_id)
|
||||
else:
|
||||
# Show logs from all companies the user has access to
|
||||
query = query.filter(Log.company_id.in_(user_company_ids))
|
||||
|
||||
# Get the logs ordered by timestamp (newest first)
|
||||
logs = query.order_by(Log.timestamp.desc()).all()
|
||||
|
||||
# Get companies for the dropdown filter based on user role
|
||||
if current_user.is_global_admin():
|
||||
# GlobalAdmin can see all companies
|
||||
companies = Company.query.all()
|
||||
else:
|
||||
# CompanyAdmin and User should see only companies they are member of
|
||||
companies = current_user.get_companies()
|
||||
|
||||
return render_template('frontend/dashboard.html',
|
||||
title='Dashboard',
|
||||
logs=logs,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
companies=companies,
|
||||
selected_company_id=company_id)
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(
|
||||
"Dashboard error for user %s (ID: %s, role: %s) from IP %s: %s",
|
||||
current_user.username if current_user.is_authenticated else 'anonymous',
|
||||
current_user.id if current_user.is_authenticated else 'N/A',
|
||||
current_user.role if current_user.is_authenticated else 'N/A',
|
||||
request.remote_addr,
|
||||
str(e)
|
||||
)
|
||||
flash('An error occurred while loading the dashboard. Please try again.', 'error')
|
||||
return redirect(url_for('frontend.index'))
|
||||
|
||||
@frontend.route('/time_spent_report')
|
||||
@login_required
|
||||
def time_spent_report():
|
||||
try:
|
||||
# Get app timezone
|
||||
app_tz = pytz.timezone(current_app.config['TIMEZONE'])
|
||||
|
||||
# Get date range from request or default to last 7 days
|
||||
date_range = request.args.get('daterange')
|
||||
|
||||
if date_range:
|
||||
start_str, end_str = date_range.split(' - ')
|
||||
start_date = datetime.strptime(start_str, '%Y-%m-%d %H:%M')
|
||||
end_date = datetime.strptime(end_str, '%Y-%m-%d %H:%M')
|
||||
|
||||
# Make these timezone aware using the app's timezone
|
||||
start_date = app_tz.localize(start_date)
|
||||
end_date = app_tz.localize(end_date)
|
||||
else:
|
||||
end_date = datetime.now(app_tz) + timedelta(hours=1) # Include 1 hour in the future
|
||||
start_date = end_date - timedelta(days=7, hours=1) # Look back 7 days from future end time
|
||||
|
||||
# Get filters
|
||||
company_id = request.args.get('company_id', type=int)
|
||||
api_key_id = request.args.get('api_key_id', type=int)
|
||||
group_by = request.args.get('group_by', 'user') # Default to user grouping
|
||||
continue_iterate = request.args.get('continue_iterate') == 'on' # Get checkbox value
|
||||
|
||||
# Build base query with date range filter
|
||||
from auth.models import ApiKey, Company
|
||||
|
||||
# Start with all the login/logout events within the date range
|
||||
# Convert timezone-aware dates to naive for comparison with database timestamps
|
||||
start_date_naive = start_date.replace(tzinfo=None)
|
||||
end_date_naive = end_date.replace(tzinfo=None)
|
||||
logs_query = Log.query.filter(
|
||||
Log.timestamp.between(start_date_naive, end_date_naive)
|
||||
).order_by(Log.timestamp.asc())
|
||||
|
||||
# Apply company-specific filtering based on user role
|
||||
if current_user.is_global_admin():
|
||||
# GlobalAdmin should be allowed to see all records, no matter what company/site
|
||||
if company_id:
|
||||
logs_query = logs_query.filter(Log.company_id == company_id)
|
||||
# If no company_id specified, show all logs (no additional filtering)
|
||||
else:
|
||||
# CompanyAdmin and User should see only company log events for companies they are member of
|
||||
user_company_ids = [uc.company_id for uc in current_user.companies]
|
||||
|
||||
if not user_company_ids:
|
||||
# If user has no company associations, show no logs
|
||||
logs_query = logs_query.filter(Log.id == -1) # Impossible condition = no results
|
||||
else:
|
||||
if company_id and company_id in user_company_ids:
|
||||
# Filter by the specific company if requested and user has access
|
||||
logs_query = logs_query.filter(Log.company_id == company_id)
|
||||
else:
|
||||
# Show logs from all companies the user has access to
|
||||
logs_query = logs_query.filter(Log.company_id.in_(user_company_ids))
|
||||
|
||||
# Apply API key filter if provided
|
||||
if api_key_id:
|
||||
# Ensure the API key belongs to a company the user has access to
|
||||
api_key = ApiKey.query.get(api_key_id)
|
||||
if api_key:
|
||||
if current_user.is_global_admin():
|
||||
# GlobalAdmin can use any API key
|
||||
logs_query = logs_query.filter(Log.api_key_id == api_key_id)
|
||||
else:
|
||||
# Check if the API key belongs to a company the user has access to
|
||||
user_company_ids = [uc.company_id for uc in current_user.companies]
|
||||
if api_key.company_id in user_company_ids:
|
||||
logs_query = logs_query.filter(Log.api_key_id == api_key_id)
|
||||
# If API key doesn't belong to user's company, ignore the filter
|
||||
|
||||
# Get all the relevant logs
|
||||
logs = logs_query.all()
|
||||
|
||||
# Process logs to calculate time spent
|
||||
time_data = calculate_time_spent(logs, group_by, continue_iterate)
|
||||
|
||||
# Get all companies for the dropdown filter
|
||||
if current_user.is_global_admin():
|
||||
companies = Company.query.all()
|
||||
else:
|
||||
companies = current_user.get_companies()
|
||||
|
||||
# Get available API keys for filter
|
||||
if current_user.is_global_admin():
|
||||
api_keys = ApiKey.query.all()
|
||||
else:
|
||||
# Get API keys for companies user has access to
|
||||
user_company_ids = [uc.company_id for uc in current_user.companies]
|
||||
api_keys = ApiKey.query.filter(ApiKey.company_id.in_(user_company_ids)).all()
|
||||
|
||||
return render_template('frontend/time_spent_report.html',
|
||||
title='Time Spent Report',
|
||||
time_data=time_data,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
companies=companies,
|
||||
api_keys=api_keys,
|
||||
selected_company_id=company_id,
|
||||
selected_api_key_id=api_key_id,
|
||||
group_by=group_by,
|
||||
continue_iterate=continue_iterate)
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(
|
||||
"Time spent report error for user %s (ID: %s, role: %s) from IP %s: %s",
|
||||
current_user.username if current_user.is_authenticated else 'anonymous',
|
||||
current_user.id if current_user.is_authenticated else 'N/A',
|
||||
current_user.role if current_user.is_authenticated else 'N/A',
|
||||
request.remote_addr,
|
||||
str(e)
|
||||
)
|
||||
flash('An error occurred while generating the time spent report. Please try again.', 'error')
|
||||
return redirect(url_for('frontend.dashboard'))
|
||||
|
||||
def calculate_time_spent(logs, group_by='user', continue_iterate=False):
|
||||
"""
|
||||
Calculate time spent by users based on login/logout events.
|
||||
|
||||
Args:
|
||||
logs: List of Log objects sorted by timestamp
|
||||
group_by: Whether to group by 'user' or 'user_computer'
|
||||
continue_iterate: Whether to continue iterating for additional login/logout pairs
|
||||
|
||||
Returns:
|
||||
List of dictionaries with time spent information
|
||||
"""
|
||||
from auth.models import Company, ApiKey
|
||||
|
||||
# Dictionary to track user sessions
|
||||
# Key: user_name or user_name+computer_name depending on group_by
|
||||
# Value: dictionary with session tracking info
|
||||
active_sessions = {}
|
||||
|
||||
# Dictionary to accumulate total time spent
|
||||
# Key: date + user_name + (computer_name) + company_id
|
||||
# Value: dictionary with accumulated time and session details
|
||||
time_totals = {}
|
||||
|
||||
# Define login and logout event types - case-insensitive matching
|
||||
login_events = ['login', 'unlock', 'logon'] # Events that start a session
|
||||
logout_events = ['logout', 'lock', 'logoff'] # Events that end a session
|
||||
|
||||
# Create a set to track which users have appeared in logs
|
||||
seen_users = set()
|
||||
|
||||
# First pass: populate the time_totals dictionary with user entries
|
||||
# This ensures every user has an entry even if they don't have paired login/logout events
|
||||
for log in logs:
|
||||
# Determine the session key based on grouping option
|
||||
session_key = log.user_name
|
||||
if group_by == 'user_computer':
|
||||
session_key = f"{log.user_name}:{log.computer_name}"
|
||||
|
||||
# Get date string in format YYYY-MM-DD
|
||||
log_date = log.timestamp.strftime('%Y-%m-%d')
|
||||
|
||||
# Create a unique key for the time totals
|
||||
total_key = f"{log_date}:{session_key}:{log.company_id}"
|
||||
|
||||
# Initialize the time total entry if it doesn't exist
|
||||
if total_key not in time_totals:
|
||||
# Get company info
|
||||
company = Company.query.get(log.company_id) if log.company_id else None
|
||||
company_name = company.name if company else "Unknown"
|
||||
|
||||
# Get API key info
|
||||
api_key = ApiKey.query.get(log.api_key_id) if log.api_key_id else None
|
||||
api_key_description = api_key.description if api_key else "Unknown"
|
||||
|
||||
# Initialize with zero time
|
||||
time_totals[total_key] = {
|
||||
'date': log_date,
|
||||
'user_name': log.user_name,
|
||||
'computer_name': log.computer_name if group_by == 'user_computer' else None,
|
||||
'company_id': log.company_id,
|
||||
'company_name': company_name,
|
||||
'api_key_id': log.api_key_id,
|
||||
'api_key_description': api_key_description,
|
||||
'total_seconds': 0,
|
||||
'first_login': None,
|
||||
'last_logout': None,
|
||||
'session_count': 0
|
||||
}
|
||||
|
||||
# Track that we've seen this user
|
||||
seen_users.add(log.user_name)
|
||||
|
||||
# Update login/logout timestamps even if we can't calculate duration
|
||||
event_type = log.event_type.lower() if log.event_type else ''
|
||||
|
||||
# For all users, record their first login and last logout
|
||||
if event_type in login_events:
|
||||
if not time_totals[total_key]['first_login'] or log.timestamp < time_totals[total_key]['first_login']:
|
||||
time_totals[total_key]['first_login'] = log.timestamp
|
||||
|
||||
if event_type in logout_events:
|
||||
if not time_totals[total_key]['last_logout'] or log.timestamp > time_totals[total_key]['last_logout']:
|
||||
time_totals[total_key]['last_logout'] = log.timestamp
|
||||
|
||||
# Second pass: calculate session durations
|
||||
for log_index, log in enumerate(logs):
|
||||
# Determine the session key based on grouping option
|
||||
session_key = log.user_name
|
||||
if group_by == 'user_computer':
|
||||
session_key = f"{log.user_name}:{log.computer_name}"
|
||||
|
||||
# Get date string in format YYYY-MM-DD
|
||||
log_date = log.timestamp.strftime('%Y-%m-%d')
|
||||
|
||||
# Create a unique key for the time totals
|
||||
total_key = f"{log_date}:{session_key}:{log.company_id}"
|
||||
|
||||
# Convert event_type to lowercase for case-insensitive comparison
|
||||
event_type = log.event_type.lower() if log.event_type else ''
|
||||
|
||||
# Process any login-like event
|
||||
if event_type in login_events:
|
||||
# Record login time for this session
|
||||
active_sessions[session_key] = {
|
||||
'login_time': log.timestamp,
|
||||
'log_index': log_index,
|
||||
'company_id': log.company_id,
|
||||
'date': log_date
|
||||
}
|
||||
|
||||
# Process any logout-like event
|
||||
elif event_type in logout_events and session_key in active_sessions:
|
||||
session = active_sessions[session_key]
|
||||
|
||||
# Only process if session is from the same day and company
|
||||
if session['date'] == log_date and session['company_id'] == log.company_id:
|
||||
# Calculate duration of this session
|
||||
duration = (log.timestamp - session['login_time']).total_seconds()
|
||||
|
||||
# Only count if duration is positive and reasonable (< 24 hours)
|
||||
if 0 < duration < 86400: # 24 hours = 86400 seconds
|
||||
# Add to the total time for this user/day combination
|
||||
time_totals[total_key]['total_seconds'] += duration
|
||||
time_totals[total_key]['session_count'] += 1
|
||||
|
||||
# If we should continue iterating, leave the session active to match with future login events
|
||||
# Otherwise, remove the active session after processing
|
||||
if not continue_iterate:
|
||||
del active_sessions[session_key]
|
||||
else:
|
||||
# If the session doesn't match day/company, remove it if we're not continuing to iterate
|
||||
if not continue_iterate:
|
||||
del active_sessions[session_key]
|
||||
|
||||
# Special case: If we only have one event of each type per user per day,
|
||||
# calculate duration between first login and last logout
|
||||
for total_key, entry in time_totals.items():
|
||||
if entry['session_count'] == 0 and entry['first_login'] and entry['last_logout']:
|
||||
# Calculate duration between first login and last logout
|
||||
duration = (entry['last_logout'] - entry['first_login']).total_seconds()
|
||||
|
||||
# Only use if duration is positive and reasonable
|
||||
if 0 < duration < 86400: # 24 hours = 86400 seconds
|
||||
entry['total_seconds'] += duration
|
||||
entry['session_count'] = 1
|
||||
|
||||
# Convert the time_totals dictionary to a list of dictionaries
|
||||
result = []
|
||||
for entry in time_totals.values():
|
||||
# Format the total time as hours:minutes:seconds
|
||||
hours, remainder = divmod(entry['total_seconds'], 3600)
|
||||
minutes, seconds = divmod(remainder, 60)
|
||||
entry['formatted_time'] = f"{int(hours):02d}:{int(minutes):02d}:{int(seconds):02d}"
|
||||
|
||||
result.append(entry)
|
||||
|
||||
# Sort by date (newest first) and then by user_name
|
||||
result.sort(key=lambda x: (x['date'], x['user_name']), reverse=True)
|
||||
|
||||
return result
|
||||
128
info.md
Normal file
128
info.md
Normal file
@@ -0,0 +1,128 @@
|
||||
# Domain Login Monitoring System
|
||||
## Setup Instructions
|
||||
#### pip install flask flask_sqlalchemy flask_bcrypt flask_login flask_wtf flask_bootstrap email-validator pyotp qrcode pillow
|
||||
|
||||
### 1. Generate SSL Certificate
|
||||
First, generate a self-signed certificate using OpenSSL:
|
||||
```bash
|
||||
openssl req -x509 -newkey rsa:4096 -keyout key.pem -out cert.pem -sha256 -days 3650 -nodes -subj "/C=XX/ST=StateName/L=CityName/O=CompanyName/OU=CompanySectionName/CN=CommonNameOrHostname"
|
||||
```
|
||||
### Testing the app functionality from terminal:
|
||||
```
|
||||
curl -k -X POST -H "Content-Type: application/json" -H "X-API-Key: 47959c6d5d8db64eb0ec3ad824ccbe82618632e2a58823d84aba92078da693fa" -d '{"EventType": "Test", "UserName": "Testuser", "ComputerName": "TEST-PC", "IPAddress": "192.168.1.100", "Timestamp": "2025-04-27 08:50:35 BST", "retry": 1}' https://localhost:5000/api/log_event
|
||||
|
||||
curl -k -X POST -H "Content-Type: application/json" -H "X-API-Key: 47959c6d5d8db64eb0ec3ad824ccbe82618632e2a58823d84aba92078da693fa" -d '{"EventType": "Test", "UserName": "Testuser", "ComputerName": "TEST-PC", "IPAddress": "192.168.1.100", "Timestamp": "2025-04-27 08:51:35Z"}' https://localhost:8000/api/log_event
|
||||
```
|
||||
|
||||
### 2. PowerShell Script
|
||||
Save this script as LogEvent.ps1:
|
||||
|
||||
```powershell
|
||||
function Send-LogData {
|
||||
param (
|
||||
[string]$EventType,
|
||||
[string]$ApiKey # Add your API key here
|
||||
)
|
||||
|
||||
# Get the user name and computer name
|
||||
$UserName = $env:USERNAME
|
||||
$ComputerName = $env:COMPUTERNAME
|
||||
|
||||
# Get the IP address of the default network interface
|
||||
$IPAddress = Get-NetIPAddress -AddressFamily IPv4 | Where-Object {
|
||||
$_.InterfaceAlias -match "Ethernet|Wi-Fi" -and $_.IPAddress -ne "127.0.0.1"
|
||||
} | Select-Object -ExpandProperty IPAddress -First 1
|
||||
|
||||
# Get the current timestamp in ISO format
|
||||
$Timestamp = (Get-Date).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ")
|
||||
|
||||
# Prepare the log data
|
||||
$LogData = @{
|
||||
EventType = $EventType
|
||||
UserName = $UserName
|
||||
ComputerName = $ComputerName
|
||||
IPAddress = $IPAddress
|
||||
Timestamp = $Timestamp
|
||||
}
|
||||
|
||||
# Convert the log data to JSON
|
||||
$LogJson = $LogData | ConvertTo-Json
|
||||
|
||||
# Send the log data using Invoke-RestMethod
|
||||
try {
|
||||
$response = Invoke-RestMethod -Uri "https://yourserver.com:5000/log_event" `
|
||||
-Method Post `
|
||||
-Body $LogJson `
|
||||
-Headers @{
|
||||
"Content-Type" = "application/json"
|
||||
"X-API-Key" = $ApiKey
|
||||
} `
|
||||
-SkipCertificateCheck
|
||||
Write-Host "Log sent successfully: $EventType"
|
||||
}
|
||||
catch {
|
||||
Write-Error "Failed to send log: $_"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Task Scheduler Setup
|
||||
Create Tasks for Each Event:
|
||||
|
||||
#### User Sign-In:
|
||||
1. Open Task Scheduler
|
||||
2. Click Create Task
|
||||
3. Name the task (e.g., "Log User Sign-In")
|
||||
4. Go to the Triggers tab and click New
|
||||
5. Select On an event
|
||||
6. Set Log to Security and Source to Microsoft-Windows-Security-Auditing
|
||||
7. Set Event ID to 4624 (successful logon)
|
||||
8. Go to the Actions tab and click New
|
||||
9. Set Action to Start a program
|
||||
10. Set Program/script to powershell.exe
|
||||
11. Set Add arguments to:
|
||||
```
|
||||
-File "C:\Path\To\LogEvent.ps1" -EventType "Sign-In" -ApiKey "YOUR_API_KEY_HERE"
|
||||
```
|
||||
|
||||
#### PC Lock:
|
||||
Same as above but:
|
||||
- Set Event ID to 4800 (workstation lock)
|
||||
- Change EventType to "Lock"
|
||||
|
||||
#### User Sign-Out:
|
||||
Same as above but:
|
||||
- Set Event ID to 4647 (user initiated logoff)
|
||||
- Change EventType to "Sign-Out"
|
||||
|
||||
### 4. Initial Setup
|
||||
1. Log in to the web interface using default credentials:
|
||||
- Username: superadmin
|
||||
- Password: adminsuper
|
||||
- Email: superadmin@example.com
|
||||
2. Go to Admin Settings to generate an API key
|
||||
3. Update all task scheduler tasks with the generated API key
|
||||
|
||||
### For compiling you would need pyinstall with optional upx
|
||||
https://upx.github.io/
|
||||
|
||||
```
|
||||
|
||||
flask_app/
|
||||
│
|
||||
├── app.py
|
||||
├── config.py
|
||||
├── models.py
|
||||
├── forms.py
|
||||
├── templates/
|
||||
│ ├── base.html
|
||||
│ ├── login.html
|
||||
│ ├── register.html
|
||||
│ ├── dashboard.html
|
||||
│ ├── logs.html
|
||||
│ └── manage_users.html
|
||||
├── static/
|
||||
│ ├── css/
|
||||
│ └── js/
|
||||
└── database.db
|
||||
```
|
||||
34
instance/certs/cert.pem
Normal file
34
instance/certs/cert.pem
Normal file
@@ -0,0 +1,34 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIF7zCCA9egAwIBAgIUZPH8NtUboyhjRENnydLCoYy7ZTAwDQYJKoZIhvcNAQEL
|
||||
BQAwgYYxCzAJBgNVBAYTAlhYMRIwEAYDVQQIDAlTdGF0ZU5hbWUxETAPBgNVBAcM
|
||||
CENpdHlOYW1lMRQwEgYDVQQKDAtDb21wYW55TmFtZTEbMBkGA1UECwwSQ29tcGFu
|
||||
eVNlY3Rpb25OYW1lMR0wGwYDVQQDDBRDb21tb25OYW1lT3JIb3N0bmFtZTAeFw0y
|
||||
NTA1MjUwMzM2NTJaFw0zNTA1MjMwMzM2NTJaMIGGMQswCQYDVQQGEwJYWDESMBAG
|
||||
A1UECAwJU3RhdGVOYW1lMREwDwYDVQQHDAhDaXR5TmFtZTEUMBIGA1UECgwLQ29t
|
||||
cGFueU5hbWUxGzAZBgNVBAsMEkNvbXBhbnlTZWN0aW9uTmFtZTEdMBsGA1UEAwwU
|
||||
Q29tbW9uTmFtZU9ySG9zdG5hbWUwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIK
|
||||
AoICAQC9oguQAa1YeTqOXjNMuXnOJwSZFYI20Yeb4VgapqL4obFi+JvbbgkiqqiL
|
||||
1gy+aID6KIpVJEBauzpx5/SI2anC51FTfY/n6R/NwBCJW4CazDUm2qE5Q7MaPUSI
|
||||
ymOvR70XanxXvPev9VLpAcaRd1rD/sSOuCjPEVJiMnhoeoYlXn9/uiLAYHqeF3QY
|
||||
0jVYK4vyRG9SZF6lUyV0b6wBd5uIKUDU+LIyjl3iIkRk8M41Uqg4TLLQnXVa3vVR
|
||||
LDW2y1cOtWmnz8WZmvVT2QBrh4g6s2+t3OGfkfo+OKYYAA+owGfW7CzrsPf9sPhB
|
||||
7xIk0/05GKSxDT83fwMDv5Ie8Oa0BEJ7LUHw5KWF7wJMfzWYobMgngtEZReImOXF
|
||||
AwAo/K7g25rqC2wlLgqeAZqs1FQ8tXEZ28mMmLKWADVG5bgtBxTYisVsmcgSpulo
|
||||
o3iBQzHZGrM1BcMrDwjSPuF7i+dHsM1pxw+adBRs65U0CeidyhcSWzRN/ApZUMcy
|
||||
FoCwxpPwi/zd5Hd1n5uNk8KjMpc6sY+paKsfM91A3TZlPmAOjhRsGfbcFcX4MK58
|
||||
XRB6Kju8JFoUduh3DySZtQ89MFVzez96VNTVCDg6kl3B03++gxCNRy5XlmRI6nbb
|
||||
o/WEnXI5ffWQhvd+wz0rr8Fg3/7+w1ljhf9aRh3MCSScPZE5swIDAQABo1MwUTAd
|
||||
BgNVHQ4EFgQUnNq+CjzjU5fUeJtqf8PvJ6DVgZMwHwYDVR0jBBgwFoAUnNq+Cjzj
|
||||
U5fUeJtqf8PvJ6DVgZMwDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOC
|
||||
AgEAHVFCdCRgiRBlQXP7P8eX4npRYVJnRcCzKL2ALpVu9l8ZZJOOJEPX7Mn6E8OT
|
||||
Dcwca5k9d8Kdyc+/8giQ4ank93ZBU/Ptgd/m++KsTUMmwVbytAQ7uer0lhyYcOUj
|
||||
OnNvM3HY5llRHcVvQ697RS22GNzOe3l/a5DU5PTHq3kdeVcqhTNzbL0pyNOKWcrg
|
||||
uBZWLQjfWyQhU6eZPqnQLbeTXLtg+PGl7zM3sRLj4mU+chFQ1Q3hQALl9onpr150
|
||||
jPfntUkKJH4RAGpTYokbhnGfGbUiuKrDGqUuuCWIzOxw+65edwIpgz+/h7jOQeVR
|
||||
QlCFgXjHplIJHIo5u1wvYxBRAJ1oMrJcvD4lDTyLYrSdniOXQOKOHsaf9GC+6wp/
|
||||
LUk2pPSqvH8JzYwSIvbtg1BQpDYgxhnphAx4nNpF5U2X04hkjpK7LZrV2qKGhJDo
|
||||
bTcLk6LhX1ssS5udvU7Vgmof0o5n4HaS6rcDaSp8ywnHcBAtNt8sme3/NQxU0wZs
|
||||
iU3+LrmTm9kEztFYznfUyPDBjjIctHq/k7sCFPlbqGsjcGDfCd0hpHOT/xaS2CYV
|
||||
0c8XzkU1B3VC4pNmtLYs0THRy0NSgYVkL7iENXLe1jBhspcLrDKRp0Ke1hPmcFLY
|
||||
oVRbSDRvbKL+htXFAu2bCM1uW4tnyZQzKgIHjxrEjiR4VbU=
|
||||
-----END CERTIFICATE-----
|
||||
7
instance/certs/gen_certs.sh
Normal file
7
instance/certs/gen_certs.sh
Normal file
@@ -0,0 +1,7 @@
|
||||
#!/bin/bash
|
||||
openssl req -x509 -newkey rsa:4096 \
|
||||
-keyout instance/certs/key.pem \
|
||||
-out instance/certs/cert.pem \
|
||||
-sha256 -days 3650 \
|
||||
-nodes \
|
||||
-subj "/C=XX/ST=StateName/L=CityName/O=CompanyName/OU=CompanySectionName/CN=CommonNameOrHostname"
|
||||
52
instance/certs/key.pem
Normal file
52
instance/certs/key.pem
Normal file
@@ -0,0 +1,52 @@
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MIIJQgIBADANBgkqhkiG9w0BAQEFAASCCSwwggkoAgEAAoICAQC9oguQAa1YeTqO
|
||||
XjNMuXnOJwSZFYI20Yeb4VgapqL4obFi+JvbbgkiqqiL1gy+aID6KIpVJEBauzpx
|
||||
5/SI2anC51FTfY/n6R/NwBCJW4CazDUm2qE5Q7MaPUSIymOvR70XanxXvPev9VLp
|
||||
AcaRd1rD/sSOuCjPEVJiMnhoeoYlXn9/uiLAYHqeF3QY0jVYK4vyRG9SZF6lUyV0
|
||||
b6wBd5uIKUDU+LIyjl3iIkRk8M41Uqg4TLLQnXVa3vVRLDW2y1cOtWmnz8WZmvVT
|
||||
2QBrh4g6s2+t3OGfkfo+OKYYAA+owGfW7CzrsPf9sPhB7xIk0/05GKSxDT83fwMD
|
||||
v5Ie8Oa0BEJ7LUHw5KWF7wJMfzWYobMgngtEZReImOXFAwAo/K7g25rqC2wlLgqe
|
||||
AZqs1FQ8tXEZ28mMmLKWADVG5bgtBxTYisVsmcgSpuloo3iBQzHZGrM1BcMrDwjS
|
||||
PuF7i+dHsM1pxw+adBRs65U0CeidyhcSWzRN/ApZUMcyFoCwxpPwi/zd5Hd1n5uN
|
||||
k8KjMpc6sY+paKsfM91A3TZlPmAOjhRsGfbcFcX4MK58XRB6Kju8JFoUduh3DySZ
|
||||
tQ89MFVzez96VNTVCDg6kl3B03++gxCNRy5XlmRI6nbbo/WEnXI5ffWQhvd+wz0r
|
||||
r8Fg3/7+w1ljhf9aRh3MCSScPZE5swIDAQABAoICAC2CZiP5QxCoh1UDZmxTVtgS
|
||||
pRfYAZgGUPUn72z18Maah2epIj5W+fpH2os0o3pOuiVO9WPZf1hG9o+/iwAMvKD5
|
||||
wpq214JggDFwlodgXkzIFTlt3qNPi/wQGBJ7/9Bg9xBXjd/AifDAf1VMB8uBSVcg
|
||||
HSvjJmgLUCog0qTAQtFVDGQq14wzmzm1hzctu3+dc8iAg/bR/6TNf3+iDTWM7taO
|
||||
j/CMfreDUySh9KgE4ngJjjV0srU+FJvqRhVk9r8XrZzqDKEpS9LBTX8B5QfpTthH
|
||||
l9Wx3LPe5J9qGPJJkXh+NG1v4JfvsJRBlFK+fSw5c9vv/hY/h5xZ7u9HWlnylmrT
|
||||
mZ9RIvv72Yp1WSUjuYTtV7ABt02xq55sP/UJOyXvcXaLe7Ow8yc8wUFNK3zhnyMX
|
||||
9ocCcmAuToAHVOq5vIM1JzC8Ss8pFH0eD3DXkYSEO9wFwNOZPBAlQXyuw9+R44E9
|
||||
ykf74cnLP787OcV92WUWYkRz69tAyFJdFCNyZ2u8KCDy7/g2QMuoixekwo7nKyvu
|
||||
g3kxvGGHMsRSMEf6zVXd0a/z0q2CGGUv1SE1pOIZUW2ZAiXNWTrFvv/y/R12eQhP
|
||||
r4q27Yc8J0K+om8OF3Fyjmi4OyJ930ZXQl7gJinogeXisrEAeqLVimNym7G19vlc
|
||||
gsU/DeY0uLQeLTLzxTABAoIBAQDcafIRTJo0jpUVEVuiLTf0GebKKgQe0Oz7C7SK
|
||||
T/mLPH4X0oFXN+vUbx6ZF/SvEQCwzDjqBy1gtDxQPzaFlavGD7Ub5a/bv/cnV7m4
|
||||
3Kp8/H461ChtVfX5q7ZlWlaBQmn0FZMdAtU/bmEw/x6jFcPDL6kEPEs00I2Uugfd
|
||||
Jg6L8UO21rfHImVcisBtEqWcn6u0vM5VUo1dcTbrhXSMrMsvbS2xrF88YF6gNUoQ
|
||||
MERp/LLX4pbvk5fJ4gFrCjO1Oe7TuseNfZMLHhE1VOCOmDMlvzwxTDIhv0BZ73CR
|
||||
tAMvLLHxCN9pkKyaal7Rkpk7dknrLcQ8rmyiBWycwrL9Zk6BAoIBAQDcP+CytVIE
|
||||
5iNkbgkiVAVquU005ngezLn1YbncKx7dJK5WoJO6mnBNOqZ/ewDaEF/Xbvyp/S+M
|
||||
p2UNHRg10+fV0LJZ2hT2vtOUMufV/yAfAgvwVFmEo76RTO5FVfN3c9rZ9Xc/YEZt
|
||||
AYuuMxwMeTeGIhd2/w5Kru/Yl7Qk3SNXvYvWslZz6G8qecAh1W62nevNb4HstkU2
|
||||
tdAMcLWoPQSvhfIDM88RCD+VQO1fRFkyxt3l3NDfodORaADy0wcUIgx0m3JZC8OG
|
||||
HIX6xPIwOZYjw/tL/VEPkfQlF1Ws3h59cdQKkkNsYdGiv6SM8YJXkQ0Ska5ciVr6
|
||||
UHstVm8YMJYzAoIBAQDOsuAaTv7xuKCgMDYBoWwukze2cK6Kg50pVHHLn3JCm8kX
|
||||
6AX5V+zlvAsywJ9qqYQ/SFU7St3IKV3CV3V20sRSqhpKfhxr9Nr/XypA7VdIfLSX
|
||||
0KvU1N8mc1xKMeybrT+VccITW7vFj2q/uw/tGpUJ7yEOYsiYT9fmGIsVXgIYRHoe
|
||||
9b9ElMH/hfMslmcOuUIZ7VGF/DOr5Gb/eZix771fzYAjdaWeBjXXAgJhqhIOXrcM
|
||||
82ZeZ8fZwANacSfKlPieQDOxQYjqzRiQLfekYaDdjjgRdwYwVZ0wefXT/b9atwxs
|
||||
IMj6w3zKFmSzHkpq0+RAExxLV7tyOaoAXCnkrtOBAoIBAClGp1uWc4qLfrKBlKCk
|
||||
UmeP1pJFZtmO0ILWD7jdM+mJyEpfyY+9BbLTfQSDDsPPMcbz+9H3qwOXE28DttfP
|
||||
oLEHbYU9Q5SCarBpYd1O9Lwa7BXcGPKspTghzL2dwATw52DVicWMy2X+VikNVwJX
|
||||
bTpsBS292vXQFw7mT1JhRxBYa26O+Xi7ZKn3KzSsBRWgPuK/NQAhoJMCO705GjIv
|
||||
TUN/vL0w5mtwuknEYzfpXTYQ4uEDIvnmH/ouHY9kUP1K7D6mKyXY+ImXqtw2MJUt
|
||||
FaAaSGwTSy+50KFq4BmHfvtPa8eXZZ9YLatscvAfCqhSfLqwJpcc/rnOf2cdvbAw
|
||||
2tUCggEANPi1koK6ZNmMDx9wz205kyB18KAcWNb/ZwI/Gbsp1VbUb2P1pFQwYGsW
|
||||
qTgm2cPAIe31PyjCj6Ugnw0UmoCPbwOfu0T7hjiMLDGugnDqmuqtK1fODbuuNrHu
|
||||
g5rOtRECREPQZTxMlxSVMruMaQq2Tyu6tky42rFUMTc5wP14O+U0hXrtVSeKk3uS
|
||||
dv9AYIsK/67OKGBphfpxk9Y5oaKRO7BzAJSl22EN0yOvavi/DN4tcudap2viEmgG
|
||||
2syX6QQoX8TfX+tixa2FPTkQRRyDmJRKAmab6acKedjIApMvl13pAjRMs6TvhzMP
|
||||
NdF3HK9zpB3w25vIFq7AJ8GpxjEbDA==
|
||||
-----END PRIVATE KEY-----
|
||||
54
instance/tests-tools/Dockerfile
Normal file
54
instance/tests-tools/Dockerfile
Normal file
@@ -0,0 +1,54 @@
|
||||
FROM python:alpine
|
||||
|
||||
# Environment variables for PostgreSQL and Redis
|
||||
# openssl rand -base64 18
|
||||
ENV POSTGRES_DB=basedbapp \
|
||||
POSTGRES_USER=maindbuser \
|
||||
POSTGRES_PASSWORD='4rV+ICvrlz3js7MiSvuFqZ47' \
|
||||
REDIS_PASSWORD='JgA3Pa7XOWGHKws3JvzPz0vc' \
|
||||
PG_SHARED_BUFFERS='256MB' \
|
||||
PG_WORK_MEM='16MB' \
|
||||
PG_EFFECTIVE_CACHE_SIZE='768MB' \
|
||||
REDIS_MAXMEMORY='256mb' \
|
||||
TZ=Europe/London \
|
||||
GUID=1000 \
|
||||
UUID=1000 \
|
||||
GIT_REPO_URL=https://github.com/yourusername/your-repo.git
|
||||
|
||||
# Install dependencies ( sqlite3 libsqlite3-dev )
|
||||
RUN groupadd -g $GUID appuser && \
|
||||
adduser -u $UUID -D appuser -s /bin/bash -m appuser && \
|
||||
apk add --no-cache postgresql postgresql-dev redis net-tools \
|
||||
iputils-ping git curl openssl && \
|
||||
git clone $GIT_REPO_URL /app && chown -R appuser:appuser /app
|
||||
|
||||
# Initialize PostgreSQL
|
||||
RUN mkdir -p /run/postgresql && chown postgres:postgres /run/postgresql
|
||||
USER postgres
|
||||
RUN initdb -D /var/lib/postgresql/data && \
|
||||
echo "shared_buffers = $PG_SHARED_BUFFERS" >> /var/lib/postgresql/data/postgresql.conf && \
|
||||
echo "work_mem = $PG_WORK_MEM" >> /var/lib/postgresql/data/postgresql.conf && \
|
||||
echo "effective_cache_size = $PG_EFFECTIVE_CACHE_SIZE" >> /var/lib/postgresql/data/postgresql.conf && \
|
||||
pg_ctl -D /var/lib/postgresql/data -l logfile start && \
|
||||
psql -c "CREATE USER \"$POSTGRES_USER\" WITH PASSWORD '$POSTGRES_PASSWORD';" && \
|
||||
createdb -O "$POSTGRES_USER" "$POSTGRES_DB" && \
|
||||
pg_ctl -D /var/lib/postgresql/data stop
|
||||
USER root
|
||||
|
||||
# Configure Redis
|
||||
RUN echo "requirepass $REDIS_PASSWORD" >> /etc/redis.conf && \
|
||||
echo "maxmemory $REDIS_MAXMEMORY" >> /etc/redis.conf && \
|
||||
echo "maxmemory-policy allkeys-lru" >> /etc/redis.conf
|
||||
|
||||
WORKDIR /app
|
||||
USER appuser
|
||||
RUN pip install --no-cache-dir -r requirements.txt && \
|
||||
/bin/bash instance/certs/gen_certs.sh
|
||||
|
||||
# Persistent volumes for PostgreSQL and Redis
|
||||
VOLUME ["/var/lib/postgresql/data", "/data"]
|
||||
# Postresql 5432 Redis 6379
|
||||
EXPOSE 8000
|
||||
|
||||
USER root
|
||||
CMD service postgresql start && redis-server /etc/redis.conf --daemonize yes && su - appuser -c "python /app/app.py"
|
||||
28
requirements.txt
Normal file
28
requirements.txt
Normal file
@@ -0,0 +1,28 @@
|
||||
flask
|
||||
asgiref
|
||||
flask_sqlalchemy
|
||||
flask_bcrypt
|
||||
flask_jwt_extended
|
||||
flask_login
|
||||
pyotp
|
||||
pytz
|
||||
flask_wtf
|
||||
email-validator
|
||||
qrcode
|
||||
flask-compress
|
||||
pillow
|
||||
|
||||
# serving the application
|
||||
uvicorn[standard]
|
||||
|
||||
|
||||
# not needed?
|
||||
#flask_login
|
||||
#flask_bootstrap
|
||||
#httpx[http2]
|
||||
|
||||
# Production enhancements
|
||||
redis # Optional: for distributed rate limiting
|
||||
#psutil # System monitoring for health checks
|
||||
#gunicorn # Production WSGI server
|
||||
#click # CLI tools (for database backups)
|
||||
14
run.sh
Normal file
14
run.sh
Normal file
@@ -0,0 +1,14 @@
|
||||
#!/bin/bash
|
||||
# Start the application using the built-in uvicorn setup
|
||||
# Configuration is now read from config.ini
|
||||
|
||||
#source ~/Documents/Projects/domain-logons/.venv/bin/activate && python app.py
|
||||
|
||||
source .venv/bin/activate && python app.py
|
||||
# Legacy way to start with explicit uvicorn command (no longer needed):
|
||||
# source ~/Documents/Projects/domain-logons/.venv/bin/activate && uvicorn app:wsg --host 0.0.0.0 --port 8000 \
|
||||
# --reload --ssl-keyfile certs/key.pem --ssl-certfile certs/cert.pem
|
||||
|
||||
# Run Flask app with Hypercorn (alternative ASGI server):
|
||||
# hypercorn app:wsg --keyfile=certs/key.pem \
|
||||
# --certfile=certs/cert.pem --bind "0.0.0.0:8000"
|
||||
6
static/css/bootstrap.min.css
vendored
Normal file
6
static/css/bootstrap.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
1
static/css/bootstrap.min.css.map
Normal file
1
static/css/bootstrap.min.css.map
Normal file
File diff suppressed because one or more lines are too long
1
static/css/buttons.bootstrap5.min.css
vendored
Normal file
1
static/css/buttons.bootstrap5.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
1
static/css/buttons.dataTables.min.css
vendored
Normal file
1
static/css/buttons.dataTables.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
15
static/css/custom.css
Normal file
15
static/css/custom.css
Normal file
@@ -0,0 +1,15 @@
|
||||
[data-bs-theme="dark"] {
|
||||
--bs-body-bg: #212529;
|
||||
--bs-body-color: #f8f9fa;
|
||||
--bs-card-bg: #2c3034;
|
||||
--bs-dropdown-bg: #2c3034;
|
||||
--bs-dropdown-link-hover-bg: #373b3e;
|
||||
--bs-dropdown-link-color: #f8f9fa;
|
||||
--bs-dropdown-link-hover-color: #ffffff;
|
||||
}
|
||||
|
||||
[data-bs-theme="light"] {
|
||||
--bs-body-bg: #ffffff;
|
||||
--bs-body-color: #212529;
|
||||
--bs-card-bg: #ffffff;
|
||||
}
|
||||
5
static/css/dataTables.bootstrap5.min.css
vendored
Normal file
5
static/css/dataTables.bootstrap5.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
410
static/css/daterangepicker.css
Normal file
410
static/css/daterangepicker.css
Normal file
@@ -0,0 +1,410 @@
|
||||
.daterangepicker {
|
||||
position: absolute;
|
||||
color: inherit;
|
||||
background-color: #fff;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #ddd;
|
||||
width: 278px;
|
||||
max-width: none;
|
||||
padding: 0;
|
||||
margin-top: 7px;
|
||||
top: 100px;
|
||||
left: 20px;
|
||||
z-index: 3001;
|
||||
display: none;
|
||||
font-family: arial;
|
||||
font-size: 15px;
|
||||
line-height: 1em;
|
||||
}
|
||||
|
||||
.daterangepicker:before, .daterangepicker:after {
|
||||
position: absolute;
|
||||
display: inline-block;
|
||||
border-bottom-color: rgba(0, 0, 0, 0.2);
|
||||
content: '';
|
||||
}
|
||||
|
||||
.daterangepicker:before {
|
||||
top: -7px;
|
||||
border-right: 7px solid transparent;
|
||||
border-left: 7px solid transparent;
|
||||
border-bottom: 7px solid #ccc;
|
||||
}
|
||||
|
||||
.daterangepicker:after {
|
||||
top: -6px;
|
||||
border-right: 6px solid transparent;
|
||||
border-bottom: 6px solid #fff;
|
||||
border-left: 6px solid transparent;
|
||||
}
|
||||
|
||||
.daterangepicker.opensleft:before {
|
||||
right: 9px;
|
||||
}
|
||||
|
||||
.daterangepicker.opensleft:after {
|
||||
right: 10px;
|
||||
}
|
||||
|
||||
.daterangepicker.openscenter:before {
|
||||
left: 0;
|
||||
right: 0;
|
||||
width: 0;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.daterangepicker.openscenter:after {
|
||||
left: 0;
|
||||
right: 0;
|
||||
width: 0;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.daterangepicker.opensright:before {
|
||||
left: 9px;
|
||||
}
|
||||
|
||||
.daterangepicker.opensright:after {
|
||||
left: 10px;
|
||||
}
|
||||
|
||||
.daterangepicker.drop-up {
|
||||
margin-top: -7px;
|
||||
}
|
||||
|
||||
.daterangepicker.drop-up:before {
|
||||
top: initial;
|
||||
bottom: -7px;
|
||||
border-bottom: initial;
|
||||
border-top: 7px solid #ccc;
|
||||
}
|
||||
|
||||
.daterangepicker.drop-up:after {
|
||||
top: initial;
|
||||
bottom: -6px;
|
||||
border-bottom: initial;
|
||||
border-top: 6px solid #fff;
|
||||
}
|
||||
|
||||
.daterangepicker.single .daterangepicker .ranges, .daterangepicker.single .drp-calendar {
|
||||
float: none;
|
||||
}
|
||||
|
||||
.daterangepicker.single .drp-selected {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.daterangepicker.show-calendar .drp-calendar {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.daterangepicker.show-calendar .drp-buttons {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.daterangepicker.auto-apply .drp-buttons {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.daterangepicker .drp-calendar {
|
||||
display: none;
|
||||
max-width: 270px;
|
||||
}
|
||||
|
||||
.daterangepicker .drp-calendar.left {
|
||||
padding: 8px 0 8px 8px;
|
||||
}
|
||||
|
||||
.daterangepicker .drp-calendar.right {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.daterangepicker .drp-calendar.single .calendar-table {
|
||||
border: none;
|
||||
}
|
||||
|
||||
.daterangepicker .calendar-table .next span, .daterangepicker .calendar-table .prev span {
|
||||
color: #fff;
|
||||
border: solid black;
|
||||
border-width: 0 2px 2px 0;
|
||||
border-radius: 0;
|
||||
display: inline-block;
|
||||
padding: 3px;
|
||||
}
|
||||
|
||||
.daterangepicker .calendar-table .next span {
|
||||
transform: rotate(-45deg);
|
||||
-webkit-transform: rotate(-45deg);
|
||||
}
|
||||
|
||||
.daterangepicker .calendar-table .prev span {
|
||||
transform: rotate(135deg);
|
||||
-webkit-transform: rotate(135deg);
|
||||
}
|
||||
|
||||
.daterangepicker .calendar-table th, .daterangepicker .calendar-table td {
|
||||
white-space: nowrap;
|
||||
text-align: center;
|
||||
vertical-align: middle;
|
||||
min-width: 32px;
|
||||
width: 32px;
|
||||
height: 24px;
|
||||
line-height: 24px;
|
||||
font-size: 12px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid transparent;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.daterangepicker .calendar-table {
|
||||
border: 1px solid #fff;
|
||||
border-radius: 4px;
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
.daterangepicker .calendar-table table {
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
border-spacing: 0;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.daterangepicker td.available:hover, .daterangepicker th.available:hover {
|
||||
background-color: #eee;
|
||||
border-color: transparent;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.daterangepicker td.week, .daterangepicker th.week {
|
||||
font-size: 80%;
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
.daterangepicker td.off, .daterangepicker td.off.in-range, .daterangepicker td.off.start-date, .daterangepicker td.off.end-date {
|
||||
background-color: #fff;
|
||||
border-color: transparent;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.daterangepicker td.in-range {
|
||||
background-color: #ebf4f8;
|
||||
border-color: transparent;
|
||||
color: #000;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.daterangepicker td.start-date {
|
||||
border-radius: 4px 0 0 4px;
|
||||
}
|
||||
|
||||
.daterangepicker td.end-date {
|
||||
border-radius: 0 4px 4px 0;
|
||||
}
|
||||
|
||||
.daterangepicker td.start-date.end-date {
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.daterangepicker td.active, .daterangepicker td.active:hover {
|
||||
background-color: #357ebd;
|
||||
border-color: transparent;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.daterangepicker th.month {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.daterangepicker td.disabled, .daterangepicker option.disabled {
|
||||
color: #999;
|
||||
cursor: not-allowed;
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.daterangepicker select.monthselect, .daterangepicker select.yearselect {
|
||||
font-size: 12px;
|
||||
padding: 1px;
|
||||
height: auto;
|
||||
margin: 0;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.daterangepicker select.monthselect {
|
||||
margin-right: 2%;
|
||||
width: 56%;
|
||||
}
|
||||
|
||||
.daterangepicker select.yearselect {
|
||||
width: 40%;
|
||||
}
|
||||
|
||||
.daterangepicker select.hourselect, .daterangepicker select.minuteselect, .daterangepicker select.secondselect, .daterangepicker select.ampmselect {
|
||||
width: 50px;
|
||||
margin: 0 auto;
|
||||
background: #eee;
|
||||
border: 1px solid #eee;
|
||||
padding: 2px;
|
||||
outline: 0;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.daterangepicker .calendar-time {
|
||||
text-align: center;
|
||||
margin: 4px auto 0 auto;
|
||||
line-height: 30px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.daterangepicker .calendar-time select.disabled {
|
||||
color: #ccc;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.daterangepicker .drp-buttons {
|
||||
clear: both;
|
||||
text-align: right;
|
||||
padding: 8px;
|
||||
border-top: 1px solid #ddd;
|
||||
display: none;
|
||||
line-height: 12px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.daterangepicker .drp-selected {
|
||||
display: inline-block;
|
||||
font-size: 12px;
|
||||
padding-right: 8px;
|
||||
}
|
||||
|
||||
.daterangepicker .drp-buttons .btn {
|
||||
margin-left: 8px;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
.daterangepicker.show-ranges.single.rtl .drp-calendar.left {
|
||||
border-right: 1px solid #ddd;
|
||||
}
|
||||
|
||||
.daterangepicker.show-ranges.single.ltr .drp-calendar.left {
|
||||
border-left: 1px solid #ddd;
|
||||
}
|
||||
|
||||
.daterangepicker.show-ranges.rtl .drp-calendar.right {
|
||||
border-right: 1px solid #ddd;
|
||||
}
|
||||
|
||||
.daterangepicker.show-ranges.ltr .drp-calendar.left {
|
||||
border-left: 1px solid #ddd;
|
||||
}
|
||||
|
||||
.daterangepicker .ranges {
|
||||
float: none;
|
||||
text-align: left;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.daterangepicker.show-calendar .ranges {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.daterangepicker .ranges ul {
|
||||
list-style: none;
|
||||
margin: 0 auto;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.daterangepicker .ranges li {
|
||||
font-size: 12px;
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.daterangepicker .ranges li:hover {
|
||||
background-color: #eee;
|
||||
}
|
||||
|
||||
.daterangepicker .ranges li.active {
|
||||
background-color: #08c;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* Larger Screen Styling */
|
||||
@media (min-width: 564px) {
|
||||
.daterangepicker {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.daterangepicker .ranges ul {
|
||||
width: 140px;
|
||||
}
|
||||
|
||||
.daterangepicker.single .ranges ul {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.daterangepicker.single .drp-calendar.left {
|
||||
clear: none;
|
||||
}
|
||||
|
||||
.daterangepicker.single .ranges, .daterangepicker.single .drp-calendar {
|
||||
float: left;
|
||||
}
|
||||
|
||||
.daterangepicker {
|
||||
direction: ltr;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.daterangepicker .drp-calendar.left {
|
||||
clear: left;
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.daterangepicker .drp-calendar.left .calendar-table {
|
||||
border-right: none;
|
||||
border-top-right-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
}
|
||||
|
||||
.daterangepicker .drp-calendar.right {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.daterangepicker .drp-calendar.right .calendar-table {
|
||||
border-left: none;
|
||||
border-top-left-radius: 0;
|
||||
border-bottom-left-radius: 0;
|
||||
}
|
||||
|
||||
.daterangepicker .drp-calendar.left .calendar-table {
|
||||
padding-right: 8px;
|
||||
}
|
||||
|
||||
.daterangepicker .ranges, .daterangepicker .drp-calendar {
|
||||
float: left;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 730px) {
|
||||
.daterangepicker .ranges {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.daterangepicker .ranges {
|
||||
float: left;
|
||||
}
|
||||
|
||||
.daterangepicker.rtl .ranges {
|
||||
float: right;
|
||||
}
|
||||
|
||||
.daterangepicker .drp-calendar.left {
|
||||
clear: none !important;
|
||||
}
|
||||
}
|
||||
BIN
static/img/favicon.ico
Normal file
BIN
static/img/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 953 B |
BIN
static/img/favicon.png
Normal file
BIN
static/img/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
File diff suppressed because one or more lines are too long
7
static/js/bootstrap.bundle.min.js
vendored
Normal file
7
static/js/bootstrap.bundle.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
static/js/bootstrap.bundle.min.js.map
Normal file
1
static/js/bootstrap.bundle.min.js.map
Normal file
File diff suppressed because one or more lines are too long
4
static/js/buttons.bootstrap5.min.js
vendored
Normal file
4
static/js/buttons.bootstrap5.min.js
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
/*! Bootstrap integration for DataTables' Buttons
|
||||
* © SpryMedia Ltd - datatables.net/license
|
||||
*/
|
||||
!function(o){var e,a;"function"==typeof define&&define.amd?define(["jquery","datatables.net-bs5","datatables.net-buttons"],function(t){return o(t,window,document)}):"object"==typeof exports?(e=require("jquery"),a=function(t,n){n.fn.dataTable||require("datatables.net-bs5")(t,n),n.fn.dataTable.Buttons||require("datatables.net-buttons")(t,n)},"undefined"==typeof window?module.exports=function(t,n){return t=t||window,n=n||e(t),a(t,n),o(n,0,t.document)}:(a(window,e),module.exports=o(e,window,window.document))):o(jQuery,window,document)}(function(o,t,n,e){"use strict";var a=o.fn.dataTable;return o.extend(!0,a.Buttons.defaults,{dom:{container:{className:"dt-buttons btn-group flex-wrap"},button:{className:"btn btn-secondary",active:"active"},collection:{action:{dropHtml:""},container:{tag:"div",className:"dropdown-menu dt-button-collection"},closeButton:!1,button:{tag:"a",className:"dt-button dropdown-item",active:"dt-button-active",disabled:"disabled",spacer:{className:"dropdown-divider",tag:"hr"}}},split:{action:{tag:"a",className:"btn btn-secondary dt-button-split-drop-button",closeButton:!1},dropdown:{tag:"button",dropHtml:"",className:"btn btn-secondary dt-button-split-drop dropdown-toggle dropdown-toggle-split",closeButton:!1,align:"split-left",splitAlignClass:"dt-button-split-left"},wrapper:{tag:"div",className:"dt-button-split btn-group",closeButton:!1}}},buttonCreated:function(t,n){return t.buttons?o('<div class="btn-group"/>').append(n):n}}),a.ext.buttons.collection.className+=" dropdown-toggle",a.ext.buttons.collection.rightAlignClassName="dropdown-menu-right",a});
|
||||
8
static/js/buttons.html5.min.js
vendored
Normal file
8
static/js/buttons.html5.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
5
static/js/buttons.print.min.js
vendored
Normal file
5
static/js/buttons.print.min.js
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
/*!
|
||||
* Print button for Buttons and DataTables.
|
||||
* © SpryMedia Ltd - datatables.net/license
|
||||
*/
|
||||
!function(n){var o,r;"function"==typeof define&&define.amd?define(["jquery","datatables.net","datatables.net-buttons"],function(t){return n(t,window,document)}):"object"==typeof exports?(o=require("jquery"),r=function(t,e){e.fn.dataTable||require("datatables.net")(t,e),e.fn.dataTable.Buttons||require("datatables.net-buttons")(t,e)},"undefined"==typeof window?module.exports=function(t,e){return t=t||window,e=e||o(t),r(t,e),n(e,t,t.document)}:(r(window,o),module.exports=n(o,window,window.document))):n(jQuery,window,document)}(function(m,b,t,p){"use strict";function h(t){return n.href=t,-1===(t=n.host).indexOf("/")&&0!==n.pathname.indexOf("/")&&(t+="/"),n.protocol+"//"+t+n.pathname+n.search}var e=m.fn.dataTable,n=t.createElement("a");return e.ext.buttons.print={className:"buttons-print",text:function(t){return t.i18n("buttons.print","Print")},action:function(t,e,n,o){function r(t,e){for(var n="<tr>",o=0,r=t.length;o<r;o++){var i=null===t[o]||t[o]===p?"":t[o];n+="<"+e+" "+(s[o]?'class="'+s[o]+'"':"")+">"+i+"</"+e+">"}return n+"</tr>"}var i=e.buttons.exportData(m.extend({decodeEntities:!1},o.exportOptions)),a=e.buttons.exportInfo(o),s=e.columns(o.exportOptions.columns).flatten().map(function(t){return e.settings()[0].aoColumns[e.column(t).index()].sClass}).toArray(),u='<table class="'+e.table().node().className+'">';o.header&&(u+="<thead>"+r(i.header,"th")+"</thead>"),u+="<tbody>";for(var d=0,c=i.body.length;d<c;d++)u+=r(i.body[d],"td");u+="</tbody>",o.footer&&i.footer&&(u+="<tfoot>"+r(i.footer,"th")+"</tfoot>"),u+="</table>";var l=b.open("","");if(l){l.document.close();var f="<title>"+a.title+"</title>";m("style, link").each(function(){f+=function(t){t=m(t).clone()[0];return"link"===t.nodeName.toLowerCase()&&(t.href=h(t.href)),t.outerHTML}(this)});try{l.document.head.innerHTML=f}catch(t){m(l.document.head).html(f)}l.document.body.innerHTML="<h1>"+a.title+"</h1><div>"+(a.messageTop||"")+"</div>"+u+"<div>"+(a.messageBottom||"")+"</div>",m(l.document.body).addClass("dt-print-view"),m("img",l.document.body).each(function(t,e){e.setAttribute("src",h(e.getAttribute("src")))}),o.customize&&o.customize(l,o,e);a=function(){o.autoPrint&&(l.print(),l.close())};navigator.userAgent.match(/Trident\/\d.\d/)?a():l.setTimeout(a,1e3)}else e.buttons.info(e.i18n("buttons.printErrorTitle","Unable to open print view"),e.i18n("buttons.printErrorMsg","Please allow popups in your browser for this site to be able to view the print view."),5e3)},title:"*",messageTop:"*",messageBottom:"*",exportOptions:{},header:!0,footer:!1,autoPrint:!0,customize:null},e});
|
||||
14
static/js/dataTables.bootstrap5.min.js
vendored
Normal file
14
static/js/dataTables.bootstrap5.min.js
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
/*!
|
||||
DataTables Bootstrap 5 integration
|
||||
2020 SpryMedia Ltd - datatables.net/license
|
||||
*/
|
||||
var $jscomp=$jscomp||{};$jscomp.scope={};$jscomp.findInternal=function(a,b,c){a instanceof String&&(a=String(a));for(var e=a.length,d=0;d<e;d++){var f=a[d];if(b.call(c,f,d,a))return{i:d,v:f}}return{i:-1,v:void 0}};$jscomp.ASSUME_ES5=!1;$jscomp.ASSUME_NO_NATIVE_MAP=!1;$jscomp.ASSUME_NO_NATIVE_SET=!1;$jscomp.SIMPLE_FROUND_POLYFILL=!1;$jscomp.ISOLATE_POLYFILLS=!1;
|
||||
$jscomp.defineProperty=$jscomp.ASSUME_ES5||"function"==typeof Object.defineProperties?Object.defineProperty:function(a,b,c){if(a==Array.prototype||a==Object.prototype)return a;a[b]=c.value;return a};$jscomp.getGlobal=function(a){a=["object"==typeof globalThis&&globalThis,a,"object"==typeof window&&window,"object"==typeof self&&self,"object"==typeof global&&global];for(var b=0;b<a.length;++b){var c=a[b];if(c&&c.Math==Math)return c}throw Error("Cannot find global object");};$jscomp.global=$jscomp.getGlobal(this);
|
||||
$jscomp.IS_SYMBOL_NATIVE="function"===typeof Symbol&&"symbol"===typeof Symbol("x");$jscomp.TRUST_ES6_POLYFILLS=!$jscomp.ISOLATE_POLYFILLS||$jscomp.IS_SYMBOL_NATIVE;$jscomp.polyfills={};$jscomp.propertyToPolyfillSymbol={};$jscomp.POLYFILL_PREFIX="$jscp$";var $jscomp$lookupPolyfilledValue=function(a,b){var c=$jscomp.propertyToPolyfillSymbol[b];if(null==c)return a[b];c=a[c];return void 0!==c?c:a[b]};
|
||||
$jscomp.polyfill=function(a,b,c,e){b&&($jscomp.ISOLATE_POLYFILLS?$jscomp.polyfillIsolated(a,b,c,e):$jscomp.polyfillUnisolated(a,b,c,e))};$jscomp.polyfillUnisolated=function(a,b,c,e){c=$jscomp.global;a=a.split(".");for(e=0;e<a.length-1;e++){var d=a[e];if(!(d in c))return;c=c[d]}a=a[a.length-1];e=c[a];b=b(e);b!=e&&null!=b&&$jscomp.defineProperty(c,a,{configurable:!0,writable:!0,value:b})};
|
||||
$jscomp.polyfillIsolated=function(a,b,c,e){var d=a.split(".");a=1===d.length;e=d[0];e=!a&&e in $jscomp.polyfills?$jscomp.polyfills:$jscomp.global;for(var f=0;f<d.length-1;f++){var l=d[f];if(!(l in e))return;e=e[l]}d=d[d.length-1];c=$jscomp.IS_SYMBOL_NATIVE&&"es6"===c?e[d]:null;b=b(c);null!=b&&(a?$jscomp.defineProperty($jscomp.polyfills,d,{configurable:!0,writable:!0,value:b}):b!==c&&($jscomp.propertyToPolyfillSymbol[d]=$jscomp.IS_SYMBOL_NATIVE?$jscomp.global.Symbol(d):$jscomp.POLYFILL_PREFIX+d,d=
|
||||
$jscomp.propertyToPolyfillSymbol[d],$jscomp.defineProperty(e,d,{configurable:!0,writable:!0,value:b})))};$jscomp.polyfill("Array.prototype.find",function(a){return a?a:function(b,c){return $jscomp.findInternal(this,b,c).v}},"es6","es3");
|
||||
(function(a){"function"===typeof define&&define.amd?define(["jquery","datatables.net"],function(b){return a(b,window,document)}):"object"===typeof exports?module.exports=function(b,c){b||(b=window);c&&c.fn.dataTable||(c=require("datatables.net")(b,c).$);return a(c,b,b.document)}:a(jQuery,window,document)})(function(a,b,c,e){var d=a.fn.dataTable;a.extend(!0,d.defaults,{dom:"<'row'<'col-sm-12 col-md-6'l><'col-sm-12 col-md-6'f>><'row'<'col-sm-12'tr>><'row'<'col-sm-12 col-md-5'i><'col-sm-12 col-md-7'p>>",
|
||||
renderer:"bootstrap"});a.extend(d.ext.classes,{sWrapper:"dataTables_wrapper dt-bootstrap5",sFilterInput:"form-control form-control-sm",sLengthSelect:"form-select form-select-sm",sProcessing:"dataTables_processing card",sPageButton:"paginate_button page-item"});d.ext.renderer.pageButton.bootstrap=function(f,l,A,B,m,t){var u=new d.Api(f),C=f.oClasses,n=f.oLanguage.oPaginate,D=f.oLanguage.oAria.paginate||{},h,k,v=0,y=function(q,w){var x,E=function(p){p.preventDefault();a(p.currentTarget).hasClass("disabled")||
|
||||
u.page()==p.data.action||u.page(p.data.action).draw("page")};var r=0;for(x=w.length;r<x;r++){var g=w[r];if(Array.isArray(g))y(q,g);else{k=h="";switch(g){case "ellipsis":h="…";k="disabled";break;case "first":h=n.sFirst;k=g+(0<m?"":" disabled");break;case "previous":h=n.sPrevious;k=g+(0<m?"":" disabled");break;case "next":h=n.sNext;k=g+(m<t-1?"":" disabled");break;case "last":h=n.sLast;k=g+(m<t-1?"":" disabled");break;default:h=g+1,k=m===g?"active":""}if(h){var F=a("<li>",{"class":C.sPageButton+
|
||||
" "+k,id:0===A&&"string"===typeof g?f.sTableId+"_"+g:null}).append(a("<a>",{href:"#","aria-controls":f.sTableId,"aria-label":D[g],"data-dt-idx":v,tabindex:f.iTabIndex,"class":"page-link"}).html(h)).appendTo(q);f.oApi._fnBindAction(F,{action:g},E);v++}}}};try{var z=a(l).find(c.activeElement).data("dt-idx")}catch(q){}y(a(l).empty().html('<ul class="pagination"/>').children("ul"),B);z!==e&&a(l).find("[data-dt-idx="+z+"]").trigger("focus")};return d});
|
||||
4
static/js/dataTables.buttons.min.js
vendored
Normal file
4
static/js/dataTables.buttons.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
15
static/js/daterangepicker.min.js
vendored
Normal file
15
static/js/daterangepicker.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
2
static/js/jquery-3.6.0.min.js
vendored
Normal file
2
static/js/jquery-3.6.0.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
187
static/js/jquery.dataTables.min.js
vendored
Normal file
187
static/js/jquery.dataTables.min.js
vendored
Normal file
@@ -0,0 +1,187 @@
|
||||
/*!
|
||||
Copyright 2008-2021 SpryMedia Ltd.
|
||||
|
||||
This source file is free software, available under the following license:
|
||||
MIT license - http://datatables.net/license
|
||||
|
||||
This source file is distributed in the hope that it will be useful, but
|
||||
WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||
or FITNESS FOR A PARTICULAR PURPOSE. See the license files for details.
|
||||
|
||||
For details please refer to: http://www.datatables.net
|
||||
DataTables 1.11.5
|
||||
©2008-2021 SpryMedia Ltd - datatables.net/license
|
||||
*/
|
||||
var $jscomp=$jscomp||{};$jscomp.scope={};$jscomp.findInternal=function(l,z,A){l instanceof String&&(l=String(l));for(var q=l.length,E=0;E<q;E++){var P=l[E];if(z.call(A,P,E,l))return{i:E,v:P}}return{i:-1,v:void 0}};$jscomp.ASSUME_ES5=!1;$jscomp.ASSUME_NO_NATIVE_MAP=!1;$jscomp.ASSUME_NO_NATIVE_SET=!1;$jscomp.SIMPLE_FROUND_POLYFILL=!1;$jscomp.ISOLATE_POLYFILLS=!1;
|
||||
$jscomp.defineProperty=$jscomp.ASSUME_ES5||"function"==typeof Object.defineProperties?Object.defineProperty:function(l,z,A){if(l==Array.prototype||l==Object.prototype)return l;l[z]=A.value;return l};$jscomp.getGlobal=function(l){l=["object"==typeof globalThis&&globalThis,l,"object"==typeof window&&window,"object"==typeof self&&self,"object"==typeof global&&global];for(var z=0;z<l.length;++z){var A=l[z];if(A&&A.Math==Math)return A}throw Error("Cannot find global object");};$jscomp.global=$jscomp.getGlobal(this);
|
||||
$jscomp.IS_SYMBOL_NATIVE="function"===typeof Symbol&&"symbol"===typeof Symbol("x");$jscomp.TRUST_ES6_POLYFILLS=!$jscomp.ISOLATE_POLYFILLS||$jscomp.IS_SYMBOL_NATIVE;$jscomp.polyfills={};$jscomp.propertyToPolyfillSymbol={};$jscomp.POLYFILL_PREFIX="$jscp$";var $jscomp$lookupPolyfilledValue=function(l,z){var A=$jscomp.propertyToPolyfillSymbol[z];if(null==A)return l[z];A=l[A];return void 0!==A?A:l[z]};
|
||||
$jscomp.polyfill=function(l,z,A,q){z&&($jscomp.ISOLATE_POLYFILLS?$jscomp.polyfillIsolated(l,z,A,q):$jscomp.polyfillUnisolated(l,z,A,q))};$jscomp.polyfillUnisolated=function(l,z,A,q){A=$jscomp.global;l=l.split(".");for(q=0;q<l.length-1;q++){var E=l[q];if(!(E in A))return;A=A[E]}l=l[l.length-1];q=A[l];z=z(q);z!=q&&null!=z&&$jscomp.defineProperty(A,l,{configurable:!0,writable:!0,value:z})};
|
||||
$jscomp.polyfillIsolated=function(l,z,A,q){var E=l.split(".");l=1===E.length;q=E[0];q=!l&&q in $jscomp.polyfills?$jscomp.polyfills:$jscomp.global;for(var P=0;P<E.length-1;P++){var ma=E[P];if(!(ma in q))return;q=q[ma]}E=E[E.length-1];A=$jscomp.IS_SYMBOL_NATIVE&&"es6"===A?q[E]:null;z=z(A);null!=z&&(l?$jscomp.defineProperty($jscomp.polyfills,E,{configurable:!0,writable:!0,value:z}):z!==A&&($jscomp.propertyToPolyfillSymbol[E]=$jscomp.IS_SYMBOL_NATIVE?$jscomp.global.Symbol(E):$jscomp.POLYFILL_PREFIX+E,
|
||||
E=$jscomp.propertyToPolyfillSymbol[E],$jscomp.defineProperty(q,E,{configurable:!0,writable:!0,value:z})))};$jscomp.polyfill("Array.prototype.find",function(l){return l?l:function(z,A){return $jscomp.findInternal(this,z,A).v}},"es6","es3");
|
||||
(function(l){"function"===typeof define&&define.amd?define(["jquery"],function(z){return l(z,window,document)}):"object"===typeof exports?module.exports=function(z,A){z||(z=window);A||(A="undefined"!==typeof window?require("jquery"):require("jquery")(z));return l(A,z,z.document)}:window.DataTable=l(jQuery,window,document)})(function(l,z,A,q){function E(a){var b,c,d={};l.each(a,function(e,h){(b=e.match(/^([^A-Z]+?)([A-Z])/))&&-1!=="a aa ai ao as b fn i m o s ".indexOf(b[1]+" ")&&(c=e.replace(b[0],
|
||||
b[2].toLowerCase()),d[c]=e,"o"===b[1]&&E(a[e]))});a._hungarianMap=d}function P(a,b,c){a._hungarianMap||E(a);var d;l.each(b,function(e,h){d=a._hungarianMap[e];d===q||!c&&b[d]!==q||("o"===d.charAt(0)?(b[d]||(b[d]={}),l.extend(!0,b[d],b[e]),P(a[d],b[d],c)):b[d]=b[e])})}function ma(a){var b=u.defaults.oLanguage,c=b.sDecimal;c&&Xa(c);if(a){var d=a.sZeroRecords;!a.sEmptyTable&&d&&"No data available in table"===b.sEmptyTable&&X(a,a,"sZeroRecords","sEmptyTable");!a.sLoadingRecords&&d&&"Loading..."===b.sLoadingRecords&&
|
||||
X(a,a,"sZeroRecords","sLoadingRecords");a.sInfoThousands&&(a.sThousands=a.sInfoThousands);(a=a.sDecimal)&&c!==a&&Xa(a)}}function zb(a){S(a,"ordering","bSort");S(a,"orderMulti","bSortMulti");S(a,"orderClasses","bSortClasses");S(a,"orderCellsTop","bSortCellsTop");S(a,"order","aaSorting");S(a,"orderFixed","aaSortingFixed");S(a,"paging","bPaginate");S(a,"pagingType","sPaginationType");S(a,"pageLength","iDisplayLength");S(a,"searching","bFilter");"boolean"===typeof a.sScrollX&&(a.sScrollX=a.sScrollX?"100%":
|
||||
"");"boolean"===typeof a.scrollX&&(a.scrollX=a.scrollX?"100%":"");if(a=a.aoSearchCols)for(var b=0,c=a.length;b<c;b++)a[b]&&P(u.models.oSearch,a[b])}function Ab(a){S(a,"orderable","bSortable");S(a,"orderData","aDataSort");S(a,"orderSequence","asSorting");S(a,"orderDataType","sortDataType");var b=a.aDataSort;"number"!==typeof b||Array.isArray(b)||(a.aDataSort=[b])}function Bb(a){if(!u.__browser){var b={};u.__browser=b;var c=l("<div/>").css({position:"fixed",top:0,left:-1*l(z).scrollLeft(),height:1,
|
||||
width:1,overflow:"hidden"}).append(l("<div/>").css({position:"absolute",top:1,left:1,width:100,overflow:"scroll"}).append(l("<div/>").css({width:"100%",height:10}))).appendTo("body"),d=c.children(),e=d.children();b.barWidth=d[0].offsetWidth-d[0].clientWidth;b.bScrollOversize=100===e[0].offsetWidth&&100!==d[0].clientWidth;b.bScrollbarLeft=1!==Math.round(e.offset().left);b.bBounding=c[0].getBoundingClientRect().width?!0:!1;c.remove()}l.extend(a.oBrowser,u.__browser);a.oScroll.iBarWidth=u.__browser.barWidth}
|
||||
function Cb(a,b,c,d,e,h){var f=!1;if(c!==q){var g=c;f=!0}for(;d!==e;)a.hasOwnProperty(d)&&(g=f?b(g,a[d],d,a):a[d],f=!0,d+=h);return g}function Ya(a,b){var c=u.defaults.column,d=a.aoColumns.length;c=l.extend({},u.models.oColumn,c,{nTh:b?b:A.createElement("th"),sTitle:c.sTitle?c.sTitle:b?b.innerHTML:"",aDataSort:c.aDataSort?c.aDataSort:[d],mData:c.mData?c.mData:d,idx:d});a.aoColumns.push(c);c=a.aoPreSearchCols;c[d]=l.extend({},u.models.oSearch,c[d]);Ga(a,d,l(b).data())}function Ga(a,b,c){b=a.aoColumns[b];
|
||||
var d=a.oClasses,e=l(b.nTh);if(!b.sWidthOrig){b.sWidthOrig=e.attr("width")||null;var h=(e.attr("style")||"").match(/width:\s*(\d+[pxem%]+)/);h&&(b.sWidthOrig=h[1])}c!==q&&null!==c&&(Ab(c),P(u.defaults.column,c,!0),c.mDataProp===q||c.mData||(c.mData=c.mDataProp),c.sType&&(b._sManualType=c.sType),c.className&&!c.sClass&&(c.sClass=c.className),c.sClass&&e.addClass(c.sClass),l.extend(b,c),X(b,c,"sWidth","sWidthOrig"),c.iDataSort!==q&&(b.aDataSort=[c.iDataSort]),X(b,c,"aDataSort"));var f=b.mData,g=na(f),
|
||||
k=b.mRender?na(b.mRender):null;c=function(m){return"string"===typeof m&&-1!==m.indexOf("@")};b._bAttrSrc=l.isPlainObject(f)&&(c(f.sort)||c(f.type)||c(f.filter));b._setter=null;b.fnGetData=function(m,n,p){var t=g(m,n,q,p);return k&&n?k(t,n,m,p):t};b.fnSetData=function(m,n,p){return ha(f)(m,n,p)};"number"!==typeof f&&(a._rowReadObject=!0);a.oFeatures.bSort||(b.bSortable=!1,e.addClass(d.sSortableNone));a=-1!==l.inArray("asc",b.asSorting);c=-1!==l.inArray("desc",b.asSorting);b.bSortable&&(a||c)?a&&!c?
|
||||
(b.sSortingClass=d.sSortableAsc,b.sSortingClassJUI=d.sSortJUIAscAllowed):!a&&c?(b.sSortingClass=d.sSortableDesc,b.sSortingClassJUI=d.sSortJUIDescAllowed):(b.sSortingClass=d.sSortable,b.sSortingClassJUI=d.sSortJUI):(b.sSortingClass=d.sSortableNone,b.sSortingClassJUI="")}function sa(a){if(!1!==a.oFeatures.bAutoWidth){var b=a.aoColumns;Za(a);for(var c=0,d=b.length;c<d;c++)b[c].nTh.style.width=b[c].sWidth}b=a.oScroll;""===b.sY&&""===b.sX||Ha(a);F(a,null,"column-sizing",[a])}function ta(a,b){a=Ia(a,"bVisible");
|
||||
return"number"===typeof a[b]?a[b]:null}function ua(a,b){a=Ia(a,"bVisible");b=l.inArray(b,a);return-1!==b?b:null}function oa(a){var b=0;l.each(a.aoColumns,function(c,d){d.bVisible&&"none"!==l(d.nTh).css("display")&&b++});return b}function Ia(a,b){var c=[];l.map(a.aoColumns,function(d,e){d[b]&&c.push(e)});return c}function $a(a){var b=a.aoColumns,c=a.aoData,d=u.ext.type.detect,e,h,f;var g=0;for(e=b.length;g<e;g++){var k=b[g];var m=[];if(!k.sType&&k._sManualType)k.sType=k._sManualType;else if(!k.sType){var n=
|
||||
0;for(h=d.length;n<h;n++){var p=0;for(f=c.length;p<f;p++){m[p]===q&&(m[p]=T(a,p,g,"type"));var t=d[n](m[p],a);if(!t&&n!==d.length-1)break;if("html"===t&&!Z(m[p]))break}if(t){k.sType=t;break}}k.sType||(k.sType="string")}}}function Db(a,b,c,d){var e,h,f,g=a.aoColumns;if(b)for(e=b.length-1;0<=e;e--){var k=b[e];var m=k.targets!==q?k.targets:k.aTargets;Array.isArray(m)||(m=[m]);var n=0;for(h=m.length;n<h;n++)if("number"===typeof m[n]&&0<=m[n]){for(;g.length<=m[n];)Ya(a);d(m[n],k)}else if("number"===typeof m[n]&&
|
||||
0>m[n])d(g.length+m[n],k);else if("string"===typeof m[n]){var p=0;for(f=g.length;p<f;p++)("_all"==m[n]||l(g[p].nTh).hasClass(m[n]))&&d(p,k)}}if(c)for(e=0,a=c.length;e<a;e++)d(e,c[e])}function ia(a,b,c,d){var e=a.aoData.length,h=l.extend(!0,{},u.models.oRow,{src:c?"dom":"data",idx:e});h._aData=b;a.aoData.push(h);for(var f=a.aoColumns,g=0,k=f.length;g<k;g++)f[g].sType=null;a.aiDisplayMaster.push(e);b=a.rowIdFn(b);b!==q&&(a.aIds[b]=h);!c&&a.oFeatures.bDeferRender||ab(a,e,c,d);return e}function Ja(a,
|
||||
b){var c;b instanceof l||(b=l(b));return b.map(function(d,e){c=bb(a,e);return ia(a,c.data,e,c.cells)})}function T(a,b,c,d){"search"===d?d="filter":"order"===d&&(d="sort");var e=a.iDraw,h=a.aoColumns[c],f=a.aoData[b]._aData,g=h.sDefaultContent,k=h.fnGetData(f,d,{settings:a,row:b,col:c});if(k===q)return a.iDrawError!=e&&null===g&&(da(a,0,"Requested unknown parameter "+("function"==typeof h.mData?"{function}":"'"+h.mData+"'")+" for row "+b+", column "+c,4),a.iDrawError=e),g;if((k===f||null===k)&&null!==
|
||||
g&&d!==q)k=g;else if("function"===typeof k)return k.call(f);if(null===k&&"display"===d)return"";"filter"===d&&(a=u.ext.type.search,a[h.sType]&&(k=a[h.sType](k)));return k}function Eb(a,b,c,d){a.aoColumns[c].fnSetData(a.aoData[b]._aData,d,{settings:a,row:b,col:c})}function cb(a){return l.map(a.match(/(\\.|[^\.])+/g)||[""],function(b){return b.replace(/\\\./g,".")})}function db(a){return U(a.aoData,"_aData")}function Ka(a){a.aoData.length=0;a.aiDisplayMaster.length=0;a.aiDisplay.length=0;a.aIds={}}
|
||||
function La(a,b,c){for(var d=-1,e=0,h=a.length;e<h;e++)a[e]==b?d=e:a[e]>b&&a[e]--; -1!=d&&c===q&&a.splice(d,1)}function va(a,b,c,d){var e=a.aoData[b],h,f=function(k,m){for(;k.childNodes.length;)k.removeChild(k.firstChild);k.innerHTML=T(a,b,m,"display")};if("dom"!==c&&(c&&"auto"!==c||"dom"!==e.src)){var g=e.anCells;if(g)if(d!==q)f(g[d],d);else for(c=0,h=g.length;c<h;c++)f(g[c],c)}else e._aData=bb(a,e,d,d===q?q:e._aData).data;e._aSortData=null;e._aFilterData=null;f=a.aoColumns;if(d!==q)f[d].sType=null;
|
||||
else{c=0;for(h=f.length;c<h;c++)f[c].sType=null;eb(a,e)}}function bb(a,b,c,d){var e=[],h=b.firstChild,f,g=0,k,m=a.aoColumns,n=a._rowReadObject;d=d!==q?d:n?{}:[];var p=function(x,w){if("string"===typeof x){var r=x.indexOf("@");-1!==r&&(r=x.substring(r+1),ha(x)(d,w.getAttribute(r)))}},t=function(x){if(c===q||c===g)f=m[g],k=x.innerHTML.trim(),f&&f._bAttrSrc?(ha(f.mData._)(d,k),p(f.mData.sort,x),p(f.mData.type,x),p(f.mData.filter,x)):n?(f._setter||(f._setter=ha(f.mData)),f._setter(d,k)):d[g]=k;g++};if(h)for(;h;){var v=
|
||||
h.nodeName.toUpperCase();if("TD"==v||"TH"==v)t(h),e.push(h);h=h.nextSibling}else for(e=b.anCells,h=0,v=e.length;h<v;h++)t(e[h]);(b=b.firstChild?b:b.nTr)&&(b=b.getAttribute("id"))&&ha(a.rowId)(d,b);return{data:d,cells:e}}function ab(a,b,c,d){var e=a.aoData[b],h=e._aData,f=[],g,k;if(null===e.nTr){var m=c||A.createElement("tr");e.nTr=m;e.anCells=f;m._DT_RowIndex=b;eb(a,e);var n=0;for(g=a.aoColumns.length;n<g;n++){var p=a.aoColumns[n];e=(k=c?!1:!0)?A.createElement(p.sCellType):d[n];e._DT_CellIndex={row:b,
|
||||
column:n};f.push(e);if(k||!(!p.mRender&&p.mData===n||l.isPlainObject(p.mData)&&p.mData._===n+".display"))e.innerHTML=T(a,b,n,"display");p.sClass&&(e.className+=" "+p.sClass);p.bVisible&&!c?m.appendChild(e):!p.bVisible&&c&&e.parentNode.removeChild(e);p.fnCreatedCell&&p.fnCreatedCell.call(a.oInstance,e,T(a,b,n),h,b,n)}F(a,"aoRowCreatedCallback",null,[m,h,b,f])}}function eb(a,b){var c=b.nTr,d=b._aData;if(c){if(a=a.rowIdFn(d))c.id=a;d.DT_RowClass&&(a=d.DT_RowClass.split(" "),b.__rowc=b.__rowc?Ma(b.__rowc.concat(a)):
|
||||
a,l(c).removeClass(b.__rowc.join(" ")).addClass(d.DT_RowClass));d.DT_RowAttr&&l(c).attr(d.DT_RowAttr);d.DT_RowData&&l(c).data(d.DT_RowData)}}function Fb(a){var b,c,d=a.nTHead,e=a.nTFoot,h=0===l("th, td",d).length,f=a.oClasses,g=a.aoColumns;h&&(c=l("<tr/>").appendTo(d));var k=0;for(b=g.length;k<b;k++){var m=g[k];var n=l(m.nTh).addClass(m.sClass);h&&n.appendTo(c);a.oFeatures.bSort&&(n.addClass(m.sSortingClass),!1!==m.bSortable&&(n.attr("tabindex",a.iTabIndex).attr("aria-controls",a.sTableId),fb(a,m.nTh,
|
||||
k)));m.sTitle!=n[0].innerHTML&&n.html(m.sTitle);gb(a,"header")(a,n,m,f)}h&&wa(a.aoHeader,d);l(d).children("tr").children("th, td").addClass(f.sHeaderTH);l(e).children("tr").children("th, td").addClass(f.sFooterTH);if(null!==e)for(a=a.aoFooter[0],k=0,b=a.length;k<b;k++)m=g[k],m.nTf=a[k].cell,m.sClass&&l(m.nTf).addClass(m.sClass)}function xa(a,b,c){var d,e,h=[],f=[],g=a.aoColumns.length;if(b){c===q&&(c=!1);var k=0;for(d=b.length;k<d;k++){h[k]=b[k].slice();h[k].nTr=b[k].nTr;for(e=g-1;0<=e;e--)a.aoColumns[e].bVisible||
|
||||
c||h[k].splice(e,1);f.push([])}k=0;for(d=h.length;k<d;k++){if(a=h[k].nTr)for(;e=a.firstChild;)a.removeChild(e);e=0;for(b=h[k].length;e<b;e++){var m=g=1;if(f[k][e]===q){a.appendChild(h[k][e].cell);for(f[k][e]=1;h[k+g]!==q&&h[k][e].cell==h[k+g][e].cell;)f[k+g][e]=1,g++;for(;h[k][e+m]!==q&&h[k][e].cell==h[k][e+m].cell;){for(c=0;c<g;c++)f[k+c][e+m]=1;m++}l(h[k][e].cell).attr("rowspan",g).attr("colspan",m)}}}}}function ja(a,b){var c="ssp"==Q(a),d=a.iInitDisplayStart;d!==q&&-1!==d&&(a._iDisplayStart=c?
|
||||
d:d>=a.fnRecordsDisplay()?0:d,a.iInitDisplayStart=-1);c=F(a,"aoPreDrawCallback","preDraw",[a]);if(-1!==l.inArray(!1,c))V(a,!1);else{c=[];var e=0;d=a.asStripeClasses;var h=d.length,f=a.oLanguage,g="ssp"==Q(a),k=a.aiDisplay,m=a._iDisplayStart,n=a.fnDisplayEnd();a.bDrawing=!0;if(a.bDeferLoading)a.bDeferLoading=!1,a.iDraw++,V(a,!1);else if(!g)a.iDraw++;else if(!a.bDestroying&&!b){Gb(a);return}if(0!==k.length)for(b=g?a.aoData.length:n,f=g?0:m;f<b;f++){g=k[f];var p=a.aoData[g];null===p.nTr&&ab(a,g);var t=
|
||||
p.nTr;if(0!==h){var v=d[e%h];p._sRowStripe!=v&&(l(t).removeClass(p._sRowStripe).addClass(v),p._sRowStripe=v)}F(a,"aoRowCallback",null,[t,p._aData,e,f,g]);c.push(t);e++}else e=f.sZeroRecords,1==a.iDraw&&"ajax"==Q(a)?e=f.sLoadingRecords:f.sEmptyTable&&0===a.fnRecordsTotal()&&(e=f.sEmptyTable),c[0]=l("<tr/>",{"class":h?d[0]:""}).append(l("<td />",{valign:"top",colSpan:oa(a),"class":a.oClasses.sRowEmpty}).html(e))[0];F(a,"aoHeaderCallback","header",[l(a.nTHead).children("tr")[0],db(a),m,n,k]);F(a,"aoFooterCallback",
|
||||
"footer",[l(a.nTFoot).children("tr")[0],db(a),m,n,k]);d=l(a.nTBody);d.children().detach();d.append(l(c));F(a,"aoDrawCallback","draw",[a]);a.bSorted=!1;a.bFiltered=!1;a.bDrawing=!1}}function ka(a,b){var c=a.oFeatures,d=c.bFilter;c.bSort&&Hb(a);d?ya(a,a.oPreviousSearch):a.aiDisplay=a.aiDisplayMaster.slice();!0!==b&&(a._iDisplayStart=0);a._drawHold=b;ja(a);a._drawHold=!1}function Ib(a){var b=a.oClasses,c=l(a.nTable);c=l("<div/>").insertBefore(c);var d=a.oFeatures,e=l("<div/>",{id:a.sTableId+"_wrapper",
|
||||
"class":b.sWrapper+(a.nTFoot?"":" "+b.sNoFooter)});a.nHolding=c[0];a.nTableWrapper=e[0];a.nTableReinsertBefore=a.nTable.nextSibling;for(var h=a.sDom.split(""),f,g,k,m,n,p,t=0;t<h.length;t++){f=null;g=h[t];if("<"==g){k=l("<div/>")[0];m=h[t+1];if("'"==m||'"'==m){n="";for(p=2;h[t+p]!=m;)n+=h[t+p],p++;"H"==n?n=b.sJUIHeader:"F"==n&&(n=b.sJUIFooter);-1!=n.indexOf(".")?(m=n.split("."),k.id=m[0].substr(1,m[0].length-1),k.className=m[1]):"#"==n.charAt(0)?k.id=n.substr(1,n.length-1):k.className=n;t+=p}e.append(k);
|
||||
e=l(k)}else if(">"==g)e=e.parent();else if("l"==g&&d.bPaginate&&d.bLengthChange)f=Jb(a);else if("f"==g&&d.bFilter)f=Kb(a);else if("r"==g&&d.bProcessing)f=Lb(a);else if("t"==g)f=Mb(a);else if("i"==g&&d.bInfo)f=Nb(a);else if("p"==g&&d.bPaginate)f=Ob(a);else if(0!==u.ext.feature.length)for(k=u.ext.feature,p=0,m=k.length;p<m;p++)if(g==k[p].cFeature){f=k[p].fnInit(a);break}f&&(k=a.aanFeatures,k[g]||(k[g]=[]),k[g].push(f),e.append(f))}c.replaceWith(e);a.nHolding=null}function wa(a,b){b=l(b).children("tr");
|
||||
var c,d,e;a.splice(0,a.length);var h=0;for(e=b.length;h<e;h++)a.push([]);h=0;for(e=b.length;h<e;h++){var f=b[h];for(c=f.firstChild;c;){if("TD"==c.nodeName.toUpperCase()||"TH"==c.nodeName.toUpperCase()){var g=1*c.getAttribute("colspan");var k=1*c.getAttribute("rowspan");g=g&&0!==g&&1!==g?g:1;k=k&&0!==k&&1!==k?k:1;var m=0;for(d=a[h];d[m];)m++;var n=m;var p=1===g?!0:!1;for(d=0;d<g;d++)for(m=0;m<k;m++)a[h+m][n+d]={cell:c,unique:p},a[h+m].nTr=f}c=c.nextSibling}}}function Na(a,b,c){var d=[];c||(c=a.aoHeader,
|
||||
b&&(c=[],wa(c,b)));b=0;for(var e=c.length;b<e;b++)for(var h=0,f=c[b].length;h<f;h++)!c[b][h].unique||d[h]&&a.bSortCellsTop||(d[h]=c[b][h].cell);return d}function Oa(a,b,c){F(a,"aoServerParams","serverParams",[b]);if(b&&Array.isArray(b)){var d={},e=/(.*?)\[\]$/;l.each(b,function(n,p){(n=p.name.match(e))?(n=n[0],d[n]||(d[n]=[]),d[n].push(p.value)):d[p.name]=p.value});b=d}var h=a.ajax,f=a.oInstance,g=function(n){var p=a.jqXHR?a.jqXHR.status:null;if(null===n||"number"===typeof p&&204==p)n={},za(a,n,[]);
|
||||
(p=n.error||n.sError)&&da(a,0,p);a.json=n;F(a,null,"xhr",[a,n,a.jqXHR]);c(n)};if(l.isPlainObject(h)&&h.data){var k=h.data;var m="function"===typeof k?k(b,a):k;b="function"===typeof k&&m?m:l.extend(!0,b,m);delete h.data}m={data:b,success:g,dataType:"json",cache:!1,type:a.sServerMethod,error:function(n,p,t){t=F(a,null,"xhr",[a,null,a.jqXHR]);-1===l.inArray(!0,t)&&("parsererror"==p?da(a,0,"Invalid JSON response",1):4===n.readyState&&da(a,0,"Ajax error",7));V(a,!1)}};a.oAjaxData=b;F(a,null,"preXhr",[a,
|
||||
b]);a.fnServerData?a.fnServerData.call(f,a.sAjaxSource,l.map(b,function(n,p){return{name:p,value:n}}),g,a):a.sAjaxSource||"string"===typeof h?a.jqXHR=l.ajax(l.extend(m,{url:h||a.sAjaxSource})):"function"===typeof h?a.jqXHR=h.call(f,b,g,a):(a.jqXHR=l.ajax(l.extend(m,h)),h.data=k)}function Gb(a){a.iDraw++;V(a,!0);Oa(a,Pb(a),function(b){Qb(a,b)})}function Pb(a){var b=a.aoColumns,c=b.length,d=a.oFeatures,e=a.oPreviousSearch,h=a.aoPreSearchCols,f=[],g=pa(a);var k=a._iDisplayStart;var m=!1!==d.bPaginate?
|
||||
a._iDisplayLength:-1;var n=function(x,w){f.push({name:x,value:w})};n("sEcho",a.iDraw);n("iColumns",c);n("sColumns",U(b,"sName").join(","));n("iDisplayStart",k);n("iDisplayLength",m);var p={draw:a.iDraw,columns:[],order:[],start:k,length:m,search:{value:e.sSearch,regex:e.bRegex}};for(k=0;k<c;k++){var t=b[k];var v=h[k];m="function"==typeof t.mData?"function":t.mData;p.columns.push({data:m,name:t.sName,searchable:t.bSearchable,orderable:t.bSortable,search:{value:v.sSearch,regex:v.bRegex}});n("mDataProp_"+
|
||||
k,m);d.bFilter&&(n("sSearch_"+k,v.sSearch),n("bRegex_"+k,v.bRegex),n("bSearchable_"+k,t.bSearchable));d.bSort&&n("bSortable_"+k,t.bSortable)}d.bFilter&&(n("sSearch",e.sSearch),n("bRegex",e.bRegex));d.bSort&&(l.each(g,function(x,w){p.order.push({column:w.col,dir:w.dir});n("iSortCol_"+x,w.col);n("sSortDir_"+x,w.dir)}),n("iSortingCols",g.length));b=u.ext.legacy.ajax;return null===b?a.sAjaxSource?f:p:b?f:p}function Qb(a,b){var c=function(f,g){return b[f]!==q?b[f]:b[g]},d=za(a,b),e=c("sEcho","draw"),h=
|
||||
c("iTotalRecords","recordsTotal");c=c("iTotalDisplayRecords","recordsFiltered");if(e!==q){if(1*e<a.iDraw)return;a.iDraw=1*e}d||(d=[]);Ka(a);a._iRecordsTotal=parseInt(h,10);a._iRecordsDisplay=parseInt(c,10);e=0;for(h=d.length;e<h;e++)ia(a,d[e]);a.aiDisplay=a.aiDisplayMaster.slice();ja(a,!0);a._bInitComplete||Pa(a,b);V(a,!1)}function za(a,b,c){a=l.isPlainObject(a.ajax)&&a.ajax.dataSrc!==q?a.ajax.dataSrc:a.sAjaxDataProp;if(!c)return"data"===a?b.aaData||b[a]:""!==a?na(a)(b):b;ha(a)(b,c)}function Kb(a){var b=
|
||||
a.oClasses,c=a.sTableId,d=a.oLanguage,e=a.oPreviousSearch,h=a.aanFeatures,f='<input type="search" class="'+b.sFilterInput+'"/>',g=d.sSearch;g=g.match(/_INPUT_/)?g.replace("_INPUT_",f):g+f;b=l("<div/>",{id:h.f?null:c+"_filter","class":b.sFilter}).append(l("<label/>").append(g));var k=function(n){var p=this.value?this.value:"";e.return&&"Enter"!==n.key||p==e.sSearch||(ya(a,{sSearch:p,bRegex:e.bRegex,bSmart:e.bSmart,bCaseInsensitive:e.bCaseInsensitive,"return":e.return}),a._iDisplayStart=0,ja(a))};h=
|
||||
null!==a.searchDelay?a.searchDelay:"ssp"===Q(a)?400:0;var m=l("input",b).val(e.sSearch).attr("placeholder",d.sSearchPlaceholder).on("keyup.DT search.DT input.DT paste.DT cut.DT",h?hb(k,h):k).on("mouseup",function(n){setTimeout(function(){k.call(m[0],n)},10)}).on("keypress.DT",function(n){if(13==n.keyCode)return!1}).attr("aria-controls",c);l(a.nTable).on("search.dt.DT",function(n,p){if(a===p)try{m[0]!==A.activeElement&&m.val(e.sSearch)}catch(t){}});return b[0]}function ya(a,b,c){var d=a.oPreviousSearch,
|
||||
e=a.aoPreSearchCols,h=function(g){d.sSearch=g.sSearch;d.bRegex=g.bRegex;d.bSmart=g.bSmart;d.bCaseInsensitive=g.bCaseInsensitive;d.return=g.return},f=function(g){return g.bEscapeRegex!==q?!g.bEscapeRegex:g.bRegex};$a(a);if("ssp"!=Q(a)){Rb(a,b.sSearch,c,f(b),b.bSmart,b.bCaseInsensitive,b.return);h(b);for(b=0;b<e.length;b++)Sb(a,e[b].sSearch,b,f(e[b]),e[b].bSmart,e[b].bCaseInsensitive);Tb(a)}else h(b);a.bFiltered=!0;F(a,null,"search",[a])}function Tb(a){for(var b=u.ext.search,c=a.aiDisplay,d,e,h=0,f=
|
||||
b.length;h<f;h++){for(var g=[],k=0,m=c.length;k<m;k++)e=c[k],d=a.aoData[e],b[h](a,d._aFilterData,e,d._aData,k)&&g.push(e);c.length=0;l.merge(c,g)}}function Sb(a,b,c,d,e,h){if(""!==b){var f=[],g=a.aiDisplay;d=ib(b,d,e,h);for(e=0;e<g.length;e++)b=a.aoData[g[e]]._aFilterData[c],d.test(b)&&f.push(g[e]);a.aiDisplay=f}}function Rb(a,b,c,d,e,h){e=ib(b,d,e,h);var f=a.oPreviousSearch.sSearch,g=a.aiDisplayMaster;h=[];0!==u.ext.search.length&&(c=!0);var k=Ub(a);if(0>=b.length)a.aiDisplay=g.slice();else{if(k||
|
||||
c||d||f.length>b.length||0!==b.indexOf(f)||a.bSorted)a.aiDisplay=g.slice();b=a.aiDisplay;for(c=0;c<b.length;c++)e.test(a.aoData[b[c]]._sFilterRow)&&h.push(b[c]);a.aiDisplay=h}}function ib(a,b,c,d){a=b?a:jb(a);c&&(a="^(?=.*?"+l.map(a.match(/"[^"]+"|[^ ]+/g)||[""],function(e){if('"'===e.charAt(0)){var h=e.match(/^"(.*)"$/);e=h?h[1]:e}return e.replace('"',"")}).join(")(?=.*?")+").*$");return new RegExp(a,d?"i":"")}function Ub(a){var b=a.aoColumns,c,d;var e=!1;var h=0;for(c=a.aoData.length;h<c;h++){var f=
|
||||
a.aoData[h];if(!f._aFilterData){var g=[];e=0;for(d=b.length;e<d;e++){var k=b[e];k.bSearchable?(k=T(a,h,e,"filter"),null===k&&(k=""),"string"!==typeof k&&k.toString&&(k=k.toString())):k="";k.indexOf&&-1!==k.indexOf("&")&&(Qa.innerHTML=k,k=tc?Qa.textContent:Qa.innerText);k.replace&&(k=k.replace(/[\r\n\u2028]/g,""));g.push(k)}f._aFilterData=g;f._sFilterRow=g.join(" ");e=!0}}return e}function Vb(a){return{search:a.sSearch,smart:a.bSmart,regex:a.bRegex,caseInsensitive:a.bCaseInsensitive}}function Wb(a){return{sSearch:a.search,
|
||||
bSmart:a.smart,bRegex:a.regex,bCaseInsensitive:a.caseInsensitive}}function Nb(a){var b=a.sTableId,c=a.aanFeatures.i,d=l("<div/>",{"class":a.oClasses.sInfo,id:c?null:b+"_info"});c||(a.aoDrawCallback.push({fn:Xb,sName:"information"}),d.attr("role","status").attr("aria-live","polite"),l(a.nTable).attr("aria-describedby",b+"_info"));return d[0]}function Xb(a){var b=a.aanFeatures.i;if(0!==b.length){var c=a.oLanguage,d=a._iDisplayStart+1,e=a.fnDisplayEnd(),h=a.fnRecordsTotal(),f=a.fnRecordsDisplay(),g=
|
||||
f?c.sInfo:c.sInfoEmpty;f!==h&&(g+=" "+c.sInfoFiltered);g+=c.sInfoPostFix;g=Yb(a,g);c=c.fnInfoCallback;null!==c&&(g=c.call(a.oInstance,a,d,e,h,f,g));l(b).html(g)}}function Yb(a,b){var c=a.fnFormatNumber,d=a._iDisplayStart+1,e=a._iDisplayLength,h=a.fnRecordsDisplay(),f=-1===e;return b.replace(/_START_/g,c.call(a,d)).replace(/_END_/g,c.call(a,a.fnDisplayEnd())).replace(/_MAX_/g,c.call(a,a.fnRecordsTotal())).replace(/_TOTAL_/g,c.call(a,h)).replace(/_PAGE_/g,c.call(a,f?1:Math.ceil(d/e))).replace(/_PAGES_/g,
|
||||
c.call(a,f?1:Math.ceil(h/e)))}function Aa(a){var b=a.iInitDisplayStart,c=a.aoColumns;var d=a.oFeatures;var e=a.bDeferLoading;if(a.bInitialised){Ib(a);Fb(a);xa(a,a.aoHeader);xa(a,a.aoFooter);V(a,!0);d.bAutoWidth&&Za(a);var h=0;for(d=c.length;h<d;h++){var f=c[h];f.sWidth&&(f.nTh.style.width=K(f.sWidth))}F(a,null,"preInit",[a]);ka(a);c=Q(a);if("ssp"!=c||e)"ajax"==c?Oa(a,[],function(g){var k=za(a,g);for(h=0;h<k.length;h++)ia(a,k[h]);a.iInitDisplayStart=b;ka(a);V(a,!1);Pa(a,g)},a):(V(a,!1),Pa(a))}else setTimeout(function(){Aa(a)},
|
||||
200)}function Pa(a,b){a._bInitComplete=!0;(b||a.oInit.aaData)&&sa(a);F(a,null,"plugin-init",[a,b]);F(a,"aoInitComplete","init",[a,b])}function kb(a,b){b=parseInt(b,10);a._iDisplayLength=b;lb(a);F(a,null,"length",[a,b])}function Jb(a){var b=a.oClasses,c=a.sTableId,d=a.aLengthMenu,e=Array.isArray(d[0]),h=e?d[0]:d;d=e?d[1]:d;e=l("<select/>",{name:c+"_length","aria-controls":c,"class":b.sLengthSelect});for(var f=0,g=h.length;f<g;f++)e[0][f]=new Option("number"===typeof d[f]?a.fnFormatNumber(d[f]):d[f],
|
||||
h[f]);var k=l("<div><label/></div>").addClass(b.sLength);a.aanFeatures.l||(k[0].id=c+"_length");k.children().append(a.oLanguage.sLengthMenu.replace("_MENU_",e[0].outerHTML));l("select",k).val(a._iDisplayLength).on("change.DT",function(m){kb(a,l(this).val());ja(a)});l(a.nTable).on("length.dt.DT",function(m,n,p){a===n&&l("select",k).val(p)});return k[0]}function Ob(a){var b=a.sPaginationType,c=u.ext.pager[b],d="function"===typeof c,e=function(f){ja(f)};b=l("<div/>").addClass(a.oClasses.sPaging+b)[0];
|
||||
var h=a.aanFeatures;d||c.fnInit(a,b,e);h.p||(b.id=a.sTableId+"_paginate",a.aoDrawCallback.push({fn:function(f){if(d){var g=f._iDisplayStart,k=f._iDisplayLength,m=f.fnRecordsDisplay(),n=-1===k;g=n?0:Math.ceil(g/k);k=n?1:Math.ceil(m/k);m=c(g,k);var p;n=0;for(p=h.p.length;n<p;n++)gb(f,"pageButton")(f,h.p[n],n,m,g,k)}else c.fnUpdate(f,e)},sName:"pagination"}));return b}function Ra(a,b,c){var d=a._iDisplayStart,e=a._iDisplayLength,h=a.fnRecordsDisplay();0===h||-1===e?d=0:"number"===typeof b?(d=b*e,d>h&&
|
||||
(d=0)):"first"==b?d=0:"previous"==b?(d=0<=e?d-e:0,0>d&&(d=0)):"next"==b?d+e<h&&(d+=e):"last"==b?d=Math.floor((h-1)/e)*e:da(a,0,"Unknown paging action: "+b,5);b=a._iDisplayStart!==d;a._iDisplayStart=d;b&&(F(a,null,"page",[a]),c&&ja(a));return b}function Lb(a){return l("<div/>",{id:a.aanFeatures.r?null:a.sTableId+"_processing","class":a.oClasses.sProcessing}).html(a.oLanguage.sProcessing).insertBefore(a.nTable)[0]}function V(a,b){a.oFeatures.bProcessing&&l(a.aanFeatures.r).css("display",b?"block":"none");
|
||||
F(a,null,"processing",[a,b])}function Mb(a){var b=l(a.nTable),c=a.oScroll;if(""===c.sX&&""===c.sY)return a.nTable;var d=c.sX,e=c.sY,h=a.oClasses,f=b.children("caption"),g=f.length?f[0]._captionSide:null,k=l(b[0].cloneNode(!1)),m=l(b[0].cloneNode(!1)),n=b.children("tfoot");n.length||(n=null);k=l("<div/>",{"class":h.sScrollWrapper}).append(l("<div/>",{"class":h.sScrollHead}).css({overflow:"hidden",position:"relative",border:0,width:d?d?K(d):null:"100%"}).append(l("<div/>",{"class":h.sScrollHeadInner}).css({"box-sizing":"content-box",
|
||||
width:c.sXInner||"100%"}).append(k.removeAttr("id").css("margin-left",0).append("top"===g?f:null).append(b.children("thead"))))).append(l("<div/>",{"class":h.sScrollBody}).css({position:"relative",overflow:"auto",width:d?K(d):null}).append(b));n&&k.append(l("<div/>",{"class":h.sScrollFoot}).css({overflow:"hidden",border:0,width:d?d?K(d):null:"100%"}).append(l("<div/>",{"class":h.sScrollFootInner}).append(m.removeAttr("id").css("margin-left",0).append("bottom"===g?f:null).append(b.children("tfoot")))));
|
||||
b=k.children();var p=b[0];h=b[1];var t=n?b[2]:null;if(d)l(h).on("scroll.DT",function(v){v=this.scrollLeft;p.scrollLeft=v;n&&(t.scrollLeft=v)});l(h).css("max-height",e);c.bCollapse||l(h).css("height",e);a.nScrollHead=p;a.nScrollBody=h;a.nScrollFoot=t;a.aoDrawCallback.push({fn:Ha,sName:"scrolling"});return k[0]}function Ha(a){var b=a.oScroll,c=b.sX,d=b.sXInner,e=b.sY;b=b.iBarWidth;var h=l(a.nScrollHead),f=h[0].style,g=h.children("div"),k=g[0].style,m=g.children("table");g=a.nScrollBody;var n=l(g),p=
|
||||
g.style,t=l(a.nScrollFoot).children("div"),v=t.children("table"),x=l(a.nTHead),w=l(a.nTable),r=w[0],C=r.style,G=a.nTFoot?l(a.nTFoot):null,aa=a.oBrowser,L=aa.bScrollOversize;U(a.aoColumns,"nTh");var O=[],I=[],H=[],ea=[],Y,Ba=function(D){D=D.style;D.paddingTop="0";D.paddingBottom="0";D.borderTopWidth="0";D.borderBottomWidth="0";D.height=0};var fa=g.scrollHeight>g.clientHeight;if(a.scrollBarVis!==fa&&a.scrollBarVis!==q)a.scrollBarVis=fa,sa(a);else{a.scrollBarVis=fa;w.children("thead, tfoot").remove();
|
||||
if(G){var ba=G.clone().prependTo(w);var la=G.find("tr");ba=ba.find("tr")}var mb=x.clone().prependTo(w);x=x.find("tr");fa=mb.find("tr");mb.find("th, td").removeAttr("tabindex");c||(p.width="100%",h[0].style.width="100%");l.each(Na(a,mb),function(D,W){Y=ta(a,D);W.style.width=a.aoColumns[Y].sWidth});G&&ca(function(D){D.style.width=""},ba);h=w.outerWidth();""===c?(C.width="100%",L&&(w.find("tbody").height()>g.offsetHeight||"scroll"==n.css("overflow-y"))&&(C.width=K(w.outerWidth()-b)),h=w.outerWidth()):
|
||||
""!==d&&(C.width=K(d),h=w.outerWidth());ca(Ba,fa);ca(function(D){var W=z.getComputedStyle?z.getComputedStyle(D).width:K(l(D).width());H.push(D.innerHTML);O.push(W)},fa);ca(function(D,W){D.style.width=O[W]},x);l(fa).css("height",0);G&&(ca(Ba,ba),ca(function(D){ea.push(D.innerHTML);I.push(K(l(D).css("width")))},ba),ca(function(D,W){D.style.width=I[W]},la),l(ba).height(0));ca(function(D,W){D.innerHTML='<div class="dataTables_sizing">'+H[W]+"</div>";D.childNodes[0].style.height="0";D.childNodes[0].style.overflow=
|
||||
"hidden";D.style.width=O[W]},fa);G&&ca(function(D,W){D.innerHTML='<div class="dataTables_sizing">'+ea[W]+"</div>";D.childNodes[0].style.height="0";D.childNodes[0].style.overflow="hidden";D.style.width=I[W]},ba);Math.round(w.outerWidth())<Math.round(h)?(la=g.scrollHeight>g.offsetHeight||"scroll"==n.css("overflow-y")?h+b:h,L&&(g.scrollHeight>g.offsetHeight||"scroll"==n.css("overflow-y"))&&(C.width=K(la-b)),""!==c&&""===d||da(a,1,"Possible column misalignment",6)):la="100%";p.width=K(la);f.width=K(la);
|
||||
G&&(a.nScrollFoot.style.width=K(la));!e&&L&&(p.height=K(r.offsetHeight+b));c=w.outerWidth();m[0].style.width=K(c);k.width=K(c);d=w.height()>g.clientHeight||"scroll"==n.css("overflow-y");e="padding"+(aa.bScrollbarLeft?"Left":"Right");k[e]=d?b+"px":"0px";G&&(v[0].style.width=K(c),t[0].style.width=K(c),t[0].style[e]=d?b+"px":"0px");w.children("colgroup").insertBefore(w.children("thead"));n.trigger("scroll");!a.bSorted&&!a.bFiltered||a._drawHold||(g.scrollTop=0)}}function ca(a,b,c){for(var d=0,e=0,h=
|
||||
b.length,f,g;e<h;){f=b[e].firstChild;for(g=c?c[e].firstChild:null;f;)1===f.nodeType&&(c?a(f,g,d):a(f,d),d++),f=f.nextSibling,g=c?g.nextSibling:null;e++}}function Za(a){var b=a.nTable,c=a.aoColumns,d=a.oScroll,e=d.sY,h=d.sX,f=d.sXInner,g=c.length,k=Ia(a,"bVisible"),m=l("th",a.nTHead),n=b.getAttribute("width"),p=b.parentNode,t=!1,v,x=a.oBrowser;d=x.bScrollOversize;(v=b.style.width)&&-1!==v.indexOf("%")&&(n=v);for(v=0;v<k.length;v++){var w=c[k[v]];null!==w.sWidth&&(w.sWidth=Zb(w.sWidthOrig,p),t=!0)}if(d||
|
||||
!t&&!h&&!e&&g==oa(a)&&g==m.length)for(v=0;v<g;v++)k=ta(a,v),null!==k&&(c[k].sWidth=K(m.eq(v).width()));else{g=l(b).clone().css("visibility","hidden").removeAttr("id");g.find("tbody tr").remove();var r=l("<tr/>").appendTo(g.find("tbody"));g.find("thead, tfoot").remove();g.append(l(a.nTHead).clone()).append(l(a.nTFoot).clone());g.find("tfoot th, tfoot td").css("width","");m=Na(a,g.find("thead")[0]);for(v=0;v<k.length;v++)w=c[k[v]],m[v].style.width=null!==w.sWidthOrig&&""!==w.sWidthOrig?K(w.sWidthOrig):
|
||||
"",w.sWidthOrig&&h&&l(m[v]).append(l("<div/>").css({width:w.sWidthOrig,margin:0,padding:0,border:0,height:1}));if(a.aoData.length)for(v=0;v<k.length;v++)t=k[v],w=c[t],l($b(a,t)).clone(!1).append(w.sContentPadding).appendTo(r);l("[name]",g).removeAttr("name");w=l("<div/>").css(h||e?{position:"absolute",top:0,left:0,height:1,right:0,overflow:"hidden"}:{}).append(g).appendTo(p);h&&f?g.width(f):h?(g.css("width","auto"),g.removeAttr("width"),g.width()<p.clientWidth&&n&&g.width(p.clientWidth)):e?g.width(p.clientWidth):
|
||||
n&&g.width(n);for(v=e=0;v<k.length;v++)p=l(m[v]),f=p.outerWidth()-p.width(),p=x.bBounding?Math.ceil(m[v].getBoundingClientRect().width):p.outerWidth(),e+=p,c[k[v]].sWidth=K(p-f);b.style.width=K(e);w.remove()}n&&(b.style.width=K(n));!n&&!h||a._reszEvt||(b=function(){l(z).on("resize.DT-"+a.sInstance,hb(function(){sa(a)}))},d?setTimeout(b,1E3):b(),a._reszEvt=!0)}function Zb(a,b){if(!a)return 0;a=l("<div/>").css("width",K(a)).appendTo(b||A.body);b=a[0].offsetWidth;a.remove();return b}function $b(a,b){var c=
|
||||
ac(a,b);if(0>c)return null;var d=a.aoData[c];return d.nTr?d.anCells[b]:l("<td/>").html(T(a,c,b,"display"))[0]}function ac(a,b){for(var c,d=-1,e=-1,h=0,f=a.aoData.length;h<f;h++)c=T(a,h,b,"display")+"",c=c.replace(uc,""),c=c.replace(/ /g," "),c.length>d&&(d=c.length,e=h);return e}function K(a){return null===a?"0px":"number"==typeof a?0>a?"0px":a+"px":a.match(/\d$/)?a+"px":a}function pa(a){var b=[],c=a.aoColumns;var d=a.aaSortingFixed;var e=l.isPlainObject(d);var h=[];var f=function(n){n.length&&
|
||||
!Array.isArray(n[0])?h.push(n):l.merge(h,n)};Array.isArray(d)&&f(d);e&&d.pre&&f(d.pre);f(a.aaSorting);e&&d.post&&f(d.post);for(a=0;a<h.length;a++){var g=h[a][0];f=c[g].aDataSort;d=0;for(e=f.length;d<e;d++){var k=f[d];var m=c[k].sType||"string";h[a]._idx===q&&(h[a]._idx=l.inArray(h[a][1],c[k].asSorting));b.push({src:g,col:k,dir:h[a][1],index:h[a]._idx,type:m,formatter:u.ext.type.order[m+"-pre"]})}}return b}function Hb(a){var b,c=[],d=u.ext.type.order,e=a.aoData,h=0,f=a.aiDisplayMaster;$a(a);var g=
|
||||
pa(a);var k=0;for(b=g.length;k<b;k++){var m=g[k];m.formatter&&h++;bc(a,m.col)}if("ssp"!=Q(a)&&0!==g.length){k=0;for(b=f.length;k<b;k++)c[f[k]]=k;h===g.length?f.sort(function(n,p){var t,v=g.length,x=e[n]._aSortData,w=e[p]._aSortData;for(t=0;t<v;t++){var r=g[t];var C=x[r.col];var G=w[r.col];C=C<G?-1:C>G?1:0;if(0!==C)return"asc"===r.dir?C:-C}C=c[n];G=c[p];return C<G?-1:C>G?1:0}):f.sort(function(n,p){var t,v=g.length,x=e[n]._aSortData,w=e[p]._aSortData;for(t=0;t<v;t++){var r=g[t];var C=x[r.col];var G=
|
||||
w[r.col];r=d[r.type+"-"+r.dir]||d["string-"+r.dir];C=r(C,G);if(0!==C)return C}C=c[n];G=c[p];return C<G?-1:C>G?1:0})}a.bSorted=!0}function cc(a){var b=a.aoColumns,c=pa(a);a=a.oLanguage.oAria;for(var d=0,e=b.length;d<e;d++){var h=b[d];var f=h.asSorting;var g=h.ariaTitle||h.sTitle.replace(/<.*?>/g,"");var k=h.nTh;k.removeAttribute("aria-sort");h.bSortable&&(0<c.length&&c[0].col==d?(k.setAttribute("aria-sort","asc"==c[0].dir?"ascending":"descending"),h=f[c[0].index+1]||f[0]):h=f[0],g+="asc"===h?a.sSortAscending:
|
||||
a.sSortDescending);k.setAttribute("aria-label",g)}}function nb(a,b,c,d){var e=a.aaSorting,h=a.aoColumns[b].asSorting,f=function(g,k){var m=g._idx;m===q&&(m=l.inArray(g[1],h));return m+1<h.length?m+1:k?null:0};"number"===typeof e[0]&&(e=a.aaSorting=[e]);c&&a.oFeatures.bSortMulti?(c=l.inArray(b,U(e,"0")),-1!==c?(b=f(e[c],!0),null===b&&1===e.length&&(b=0),null===b?e.splice(c,1):(e[c][1]=h[b],e[c]._idx=b)):(e.push([b,h[0],0]),e[e.length-1]._idx=0)):e.length&&e[0][0]==b?(b=f(e[0]),e.length=1,e[0][1]=h[b],
|
||||
e[0]._idx=b):(e.length=0,e.push([b,h[0]]),e[0]._idx=0);ka(a);"function"==typeof d&&d(a)}function fb(a,b,c,d){var e=a.aoColumns[c];ob(b,{},function(h){!1!==e.bSortable&&(a.oFeatures.bProcessing?(V(a,!0),setTimeout(function(){nb(a,c,h.shiftKey,d);"ssp"!==Q(a)&&V(a,!1)},0)):nb(a,c,h.shiftKey,d))})}function Sa(a){var b=a.aLastSort,c=a.oClasses.sSortColumn,d=pa(a),e=a.oFeatures,h;if(e.bSort&&e.bSortClasses){e=0;for(h=b.length;e<h;e++){var f=b[e].src;l(U(a.aoData,"anCells",f)).removeClass(c+(2>e?e+1:3))}e=
|
||||
0;for(h=d.length;e<h;e++)f=d[e].src,l(U(a.aoData,"anCells",f)).addClass(c+(2>e?e+1:3))}a.aLastSort=d}function bc(a,b){var c=a.aoColumns[b],d=u.ext.order[c.sSortDataType],e;d&&(e=d.call(a.oInstance,a,b,ua(a,b)));for(var h,f=u.ext.type.order[c.sType+"-pre"],g=0,k=a.aoData.length;g<k;g++)if(c=a.aoData[g],c._aSortData||(c._aSortData=[]),!c._aSortData[b]||d)h=d?e[g]:T(a,g,b,"sort"),c._aSortData[b]=f?f(h):h}function Ca(a){if(!a._bLoadingState){var b={time:+new Date,start:a._iDisplayStart,length:a._iDisplayLength,
|
||||
order:l.extend(!0,[],a.aaSorting),search:Vb(a.oPreviousSearch),columns:l.map(a.aoColumns,function(c,d){return{visible:c.bVisible,search:Vb(a.aoPreSearchCols[d])}})};a.oSavedState=b;F(a,"aoStateSaveParams","stateSaveParams",[a,b]);a.oFeatures.bStateSave&&!a.bDestroying&&a.fnStateSaveCallback.call(a.oInstance,a,b)}}function dc(a,b,c){if(a.oFeatures.bStateSave)return b=a.fnStateLoadCallback.call(a.oInstance,a,function(d){pb(a,d,c)}),b!==q&&pb(a,b,c),!0;c()}function pb(a,b,c){var d,e=a.aoColumns;a._bLoadingState=
|
||||
!0;var h=a._bInitComplete?new u.Api(a):null;if(b&&b.time){var f=F(a,"aoStateLoadParams","stateLoadParams",[a,b]);if(-1!==l.inArray(!1,f))a._bLoadingState=!1;else if(f=a.iStateDuration,0<f&&b.time<+new Date-1E3*f)a._bLoadingState=!1;else if(b.columns&&e.length!==b.columns.length)a._bLoadingState=!1;else{a.oLoadedState=l.extend(!0,{},b);b.start!==q&&(null===h?(a._iDisplayStart=b.start,a.iInitDisplayStart=b.start):Ra(a,b.start/b.length));b.length!==q&&(a._iDisplayLength=b.length);b.order!==q&&(a.aaSorting=
|
||||
[],l.each(b.order,function(k,m){a.aaSorting.push(m[0]>=e.length?[0,m[1]]:m)}));b.search!==q&&l.extend(a.oPreviousSearch,Wb(b.search));if(b.columns){f=0;for(d=b.columns.length;f<d;f++){var g=b.columns[f];g.visible!==q&&(h?h.column(f).visible(g.visible,!1):e[f].bVisible=g.visible);g.search!==q&&l.extend(a.aoPreSearchCols[f],Wb(g.search))}h&&h.columns.adjust()}a._bLoadingState=!1;F(a,"aoStateLoaded","stateLoaded",[a,b])}}else a._bLoadingState=!1;c()}function Ta(a){var b=u.settings;a=l.inArray(a,U(b,
|
||||
"nTable"));return-1!==a?b[a]:null}function da(a,b,c,d){c="DataTables warning: "+(a?"table id="+a.sTableId+" - ":"")+c;d&&(c+=". For more information about this error, please see http://datatables.net/tn/"+d);if(b)z.console&&console.log&&console.log(c);else if(b=u.ext,b=b.sErrMode||b.errMode,a&&F(a,null,"error",[a,d,c]),"alert"==b)alert(c);else{if("throw"==b)throw Error(c);"function"==typeof b&&b(a,d,c)}}function X(a,b,c,d){Array.isArray(c)?l.each(c,function(e,h){Array.isArray(h)?X(a,b,h[0],h[1]):
|
||||
X(a,b,h)}):(d===q&&(d=c),b[c]!==q&&(a[d]=b[c]))}function qb(a,b,c){var d;for(d in b)if(b.hasOwnProperty(d)){var e=b[d];l.isPlainObject(e)?(l.isPlainObject(a[d])||(a[d]={}),l.extend(!0,a[d],e)):c&&"data"!==d&&"aaData"!==d&&Array.isArray(e)?a[d]=e.slice():a[d]=e}return a}function ob(a,b,c){l(a).on("click.DT",b,function(d){l(a).trigger("blur");c(d)}).on("keypress.DT",b,function(d){13===d.which&&(d.preventDefault(),c(d))}).on("selectstart.DT",function(){return!1})}function R(a,b,c,d){c&&a[b].push({fn:c,
|
||||
sName:d})}function F(a,b,c,d){var e=[];b&&(e=l.map(a[b].slice().reverse(),function(h,f){return h.fn.apply(a.oInstance,d)}));null!==c&&(b=l.Event(c+".dt"),l(a.nTable).trigger(b,d),e.push(b.result));return e}function lb(a){var b=a._iDisplayStart,c=a.fnDisplayEnd(),d=a._iDisplayLength;b>=c&&(b=c-d);b-=b%d;if(-1===d||0>b)b=0;a._iDisplayStart=b}function gb(a,b){a=a.renderer;var c=u.ext.renderer[b];return l.isPlainObject(a)&&a[b]?c[a[b]]||c._:"string"===typeof a?c[a]||c._:c._}function Q(a){return a.oFeatures.bServerSide?
|
||||
"ssp":a.ajax||a.sAjaxSource?"ajax":"dom"}function Da(a,b){var c=ec.numbers_length,d=Math.floor(c/2);b<=c?a=qa(0,b):a<=d?(a=qa(0,c-2),a.push("ellipsis"),a.push(b-1)):(a>=b-1-d?a=qa(b-(c-2),b):(a=qa(a-d+2,a+d-1),a.push("ellipsis"),a.push(b-1)),a.splice(0,0,"ellipsis"),a.splice(0,0,0));a.DT_el="span";return a}function Xa(a){l.each({num:function(b){return Ua(b,a)},"num-fmt":function(b){return Ua(b,a,rb)},"html-num":function(b){return Ua(b,a,Va)},"html-num-fmt":function(b){return Ua(b,a,Va,rb)}},function(b,
|
||||
c){M.type.order[b+a+"-pre"]=c;b.match(/^html\-/)&&(M.type.search[b+a]=M.type.search.html)})}function fc(a){return function(){var b=[Ta(this[u.ext.iApiIndex])].concat(Array.prototype.slice.call(arguments));return u.ext.internal[a].apply(this,b)}}var u=function(a,b){if(this instanceof u)return l(a).DataTable(b);b=a;this.$=function(f,g){return this.api(!0).$(f,g)};this._=function(f,g){return this.api(!0).rows(f,g).data()};this.api=function(f){return f?new B(Ta(this[M.iApiIndex])):new B(this)};this.fnAddData=
|
||||
function(f,g){var k=this.api(!0);f=Array.isArray(f)&&(Array.isArray(f[0])||l.isPlainObject(f[0]))?k.rows.add(f):k.row.add(f);(g===q||g)&&k.draw();return f.flatten().toArray()};this.fnAdjustColumnSizing=function(f){var g=this.api(!0).columns.adjust(),k=g.settings()[0],m=k.oScroll;f===q||f?g.draw(!1):(""!==m.sX||""!==m.sY)&&Ha(k)};this.fnClearTable=function(f){var g=this.api(!0).clear();(f===q||f)&&g.draw()};this.fnClose=function(f){this.api(!0).row(f).child.hide()};this.fnDeleteRow=function(f,g,k){var m=
|
||||
this.api(!0);f=m.rows(f);var n=f.settings()[0],p=n.aoData[f[0][0]];f.remove();g&&g.call(this,n,p);(k===q||k)&&m.draw();return p};this.fnDestroy=function(f){this.api(!0).destroy(f)};this.fnDraw=function(f){this.api(!0).draw(f)};this.fnFilter=function(f,g,k,m,n,p){n=this.api(!0);null===g||g===q?n.search(f,k,m,p):n.column(g).search(f,k,m,p);n.draw()};this.fnGetData=function(f,g){var k=this.api(!0);if(f!==q){var m=f.nodeName?f.nodeName.toLowerCase():"";return g!==q||"td"==m||"th"==m?k.cell(f,g).data():
|
||||
k.row(f).data()||null}return k.data().toArray()};this.fnGetNodes=function(f){var g=this.api(!0);return f!==q?g.row(f).node():g.rows().nodes().flatten().toArray()};this.fnGetPosition=function(f){var g=this.api(!0),k=f.nodeName.toUpperCase();return"TR"==k?g.row(f).index():"TD"==k||"TH"==k?(f=g.cell(f).index(),[f.row,f.columnVisible,f.column]):null};this.fnIsOpen=function(f){return this.api(!0).row(f).child.isShown()};this.fnOpen=function(f,g,k){return this.api(!0).row(f).child(g,k).show().child()[0]};
|
||||
this.fnPageChange=function(f,g){f=this.api(!0).page(f);(g===q||g)&&f.draw(!1)};this.fnSetColumnVis=function(f,g,k){f=this.api(!0).column(f).visible(g);(k===q||k)&&f.columns.adjust().draw()};this.fnSettings=function(){return Ta(this[M.iApiIndex])};this.fnSort=function(f){this.api(!0).order(f).draw()};this.fnSortListener=function(f,g,k){this.api(!0).order.listener(f,g,k)};this.fnUpdate=function(f,g,k,m,n){var p=this.api(!0);k===q||null===k?p.row(g).data(f):p.cell(g,k).data(f);(n===q||n)&&p.columns.adjust();
|
||||
(m===q||m)&&p.draw();return 0};this.fnVersionCheck=M.fnVersionCheck;var c=this,d=b===q,e=this.length;d&&(b={});this.oApi=this.internal=M.internal;for(var h in u.ext.internal)h&&(this[h]=fc(h));this.each(function(){var f={},g=1<e?qb(f,b,!0):b,k=0,m;f=this.getAttribute("id");var n=!1,p=u.defaults,t=l(this);if("table"!=this.nodeName.toLowerCase())da(null,0,"Non-table node initialisation ("+this.nodeName+")",2);else{zb(p);Ab(p.column);P(p,p,!0);P(p.column,p.column,!0);P(p,l.extend(g,t.data()),!0);var v=
|
||||
u.settings;k=0;for(m=v.length;k<m;k++){var x=v[k];if(x.nTable==this||x.nTHead&&x.nTHead.parentNode==this||x.nTFoot&&x.nTFoot.parentNode==this){var w=g.bRetrieve!==q?g.bRetrieve:p.bRetrieve;if(d||w)return x.oInstance;if(g.bDestroy!==q?g.bDestroy:p.bDestroy){x.oInstance.fnDestroy();break}else{da(x,0,"Cannot reinitialise DataTable",3);return}}if(x.sTableId==this.id){v.splice(k,1);break}}if(null===f||""===f)this.id=f="DataTables_Table_"+u.ext._unique++;var r=l.extend(!0,{},u.models.oSettings,{sDestroyWidth:t[0].style.width,
|
||||
sInstance:f,sTableId:f});r.nTable=this;r.oApi=c.internal;r.oInit=g;v.push(r);r.oInstance=1===c.length?c:t.dataTable();zb(g);ma(g.oLanguage);g.aLengthMenu&&!g.iDisplayLength&&(g.iDisplayLength=Array.isArray(g.aLengthMenu[0])?g.aLengthMenu[0][0]:g.aLengthMenu[0]);g=qb(l.extend(!0,{},p),g);X(r.oFeatures,g,"bPaginate bLengthChange bFilter bSort bSortMulti bInfo bProcessing bAutoWidth bSortClasses bServerSide bDeferRender".split(" "));X(r,g,["asStripeClasses","ajax","fnServerData","fnFormatNumber","sServerMethod",
|
||||
"aaSorting","aaSortingFixed","aLengthMenu","sPaginationType","sAjaxSource","sAjaxDataProp","iStateDuration","sDom","bSortCellsTop","iTabIndex","fnStateLoadCallback","fnStateSaveCallback","renderer","searchDelay","rowId",["iCookieDuration","iStateDuration"],["oSearch","oPreviousSearch"],["aoSearchCols","aoPreSearchCols"],["iDisplayLength","_iDisplayLength"]]);X(r.oScroll,g,[["sScrollX","sX"],["sScrollXInner","sXInner"],["sScrollY","sY"],["bScrollCollapse","bCollapse"]]);X(r.oLanguage,g,"fnInfoCallback");
|
||||
R(r,"aoDrawCallback",g.fnDrawCallback,"user");R(r,"aoServerParams",g.fnServerParams,"user");R(r,"aoStateSaveParams",g.fnStateSaveParams,"user");R(r,"aoStateLoadParams",g.fnStateLoadParams,"user");R(r,"aoStateLoaded",g.fnStateLoaded,"user");R(r,"aoRowCallback",g.fnRowCallback,"user");R(r,"aoRowCreatedCallback",g.fnCreatedRow,"user");R(r,"aoHeaderCallback",g.fnHeaderCallback,"user");R(r,"aoFooterCallback",g.fnFooterCallback,"user");R(r,"aoInitComplete",g.fnInitComplete,"user");R(r,"aoPreDrawCallback",
|
||||
g.fnPreDrawCallback,"user");r.rowIdFn=na(g.rowId);Bb(r);var C=r.oClasses;l.extend(C,u.ext.classes,g.oClasses);t.addClass(C.sTable);r.iInitDisplayStart===q&&(r.iInitDisplayStart=g.iDisplayStart,r._iDisplayStart=g.iDisplayStart);null!==g.iDeferLoading&&(r.bDeferLoading=!0,f=Array.isArray(g.iDeferLoading),r._iRecordsDisplay=f?g.iDeferLoading[0]:g.iDeferLoading,r._iRecordsTotal=f?g.iDeferLoading[1]:g.iDeferLoading);var G=r.oLanguage;l.extend(!0,G,g.oLanguage);G.sUrl?(l.ajax({dataType:"json",url:G.sUrl,
|
||||
success:function(I){P(p.oLanguage,I);ma(I);l.extend(!0,G,I);F(r,null,"i18n",[r]);Aa(r)},error:function(){Aa(r)}}),n=!0):F(r,null,"i18n",[r]);null===g.asStripeClasses&&(r.asStripeClasses=[C.sStripeOdd,C.sStripeEven]);f=r.asStripeClasses;var aa=t.children("tbody").find("tr").eq(0);-1!==l.inArray(!0,l.map(f,function(I,H){return aa.hasClass(I)}))&&(l("tbody tr",this).removeClass(f.join(" ")),r.asDestroyStripes=f.slice());f=[];v=this.getElementsByTagName("thead");0!==v.length&&(wa(r.aoHeader,v[0]),f=Na(r));
|
||||
if(null===g.aoColumns)for(v=[],k=0,m=f.length;k<m;k++)v.push(null);else v=g.aoColumns;k=0;for(m=v.length;k<m;k++)Ya(r,f?f[k]:null);Db(r,g.aoColumnDefs,v,function(I,H){Ga(r,I,H)});if(aa.length){var L=function(I,H){return null!==I.getAttribute("data-"+H)?H:null};l(aa[0]).children("th, td").each(function(I,H){var ea=r.aoColumns[I];if(ea.mData===I){var Y=L(H,"sort")||L(H,"order");H=L(H,"filter")||L(H,"search");if(null!==Y||null!==H)ea.mData={_:I+".display",sort:null!==Y?I+".@data-"+Y:q,type:null!==Y?
|
||||
I+".@data-"+Y:q,filter:null!==H?I+".@data-"+H:q},Ga(r,I)}})}var O=r.oFeatures;f=function(){if(g.aaSorting===q){var I=r.aaSorting;k=0;for(m=I.length;k<m;k++)I[k][1]=r.aoColumns[k].asSorting[0]}Sa(r);O.bSort&&R(r,"aoDrawCallback",function(){if(r.bSorted){var Y=pa(r),Ba={};l.each(Y,function(fa,ba){Ba[ba.src]=ba.dir});F(r,null,"order",[r,Y,Ba]);cc(r)}});R(r,"aoDrawCallback",function(){(r.bSorted||"ssp"===Q(r)||O.bDeferRender)&&Sa(r)},"sc");I=t.children("caption").each(function(){this._captionSide=l(this).css("caption-side")});
|
||||
var H=t.children("thead");0===H.length&&(H=l("<thead/>").appendTo(t));r.nTHead=H[0];var ea=t.children("tbody");0===ea.length&&(ea=l("<tbody/>").insertAfter(H));r.nTBody=ea[0];H=t.children("tfoot");0===H.length&&0<I.length&&(""!==r.oScroll.sX||""!==r.oScroll.sY)&&(H=l("<tfoot/>").appendTo(t));0===H.length||0===H.children().length?t.addClass(C.sNoFooter):0<H.length&&(r.nTFoot=H[0],wa(r.aoFooter,r.nTFoot));if(g.aaData)for(k=0;k<g.aaData.length;k++)ia(r,g.aaData[k]);else(r.bDeferLoading||"dom"==Q(r))&&
|
||||
Ja(r,l(r.nTBody).children("tr"));r.aiDisplay=r.aiDisplayMaster.slice();r.bInitialised=!0;!1===n&&Aa(r)};R(r,"aoDrawCallback",Ca,"state_save");g.bStateSave?(O.bStateSave=!0,dc(r,g,f)):f()}});c=null;return this},M,y,J,sb={},gc=/[\r\n\u2028]/g,Va=/<.*?>/g,vc=/^\d{2,4}[\.\/\-]\d{1,2}[\.\/\-]\d{1,2}([T ]{1}\d{1,2}[:\.]\d{2}([\.:]\d{2})?)?$/,wc=/(\/|\.|\*|\+|\?|\||\(|\)|\[|\]|\{|\}|\\|\$|\^|\-)/g,rb=/['\u00A0,$£€¥%\u2009\u202F\u20BD\u20a9\u20BArfkɃΞ]/gi,Z=function(a){return a&&!0!==a&&"-"!==a?!1:!0},hc=
|
||||
function(a){var b=parseInt(a,10);return!isNaN(b)&&isFinite(a)?b:null},ic=function(a,b){sb[b]||(sb[b]=new RegExp(jb(b),"g"));return"string"===typeof a&&"."!==b?a.replace(/\./g,"").replace(sb[b],"."):a},tb=function(a,b,c){var d="string"===typeof a;if(Z(a))return!0;b&&d&&(a=ic(a,b));c&&d&&(a=a.replace(rb,""));return!isNaN(parseFloat(a))&&isFinite(a)},jc=function(a,b,c){return Z(a)?!0:Z(a)||"string"===typeof a?tb(a.replace(Va,""),b,c)?!0:null:null},U=function(a,b,c){var d=[],e=0,h=a.length;if(c!==q)for(;e<
|
||||
h;e++)a[e]&&a[e][b]&&d.push(a[e][b][c]);else for(;e<h;e++)a[e]&&d.push(a[e][b]);return d},Ea=function(a,b,c,d){var e=[],h=0,f=b.length;if(d!==q)for(;h<f;h++)a[b[h]][c]&&e.push(a[b[h]][c][d]);else for(;h<f;h++)e.push(a[b[h]][c]);return e},qa=function(a,b){var c=[];if(b===q){b=0;var d=a}else d=b,b=a;for(a=b;a<d;a++)c.push(a);return c},kc=function(a){for(var b=[],c=0,d=a.length;c<d;c++)a[c]&&b.push(a[c]);return b},Ma=function(a){a:{if(!(2>a.length)){var b=a.slice().sort();for(var c=b[0],d=1,e=b.length;d<
|
||||
e;d++){if(b[d]===c){b=!1;break a}c=b[d]}}b=!0}if(b)return a.slice();b=[];e=a.length;var h,f=0;d=0;a:for(;d<e;d++){c=a[d];for(h=0;h<f;h++)if(b[h]===c)continue a;b.push(c);f++}return b},lc=function(a,b){if(Array.isArray(b))for(var c=0;c<b.length;c++)lc(a,b[c]);else a.push(b);return a},mc=function(a,b){b===q&&(b=0);return-1!==this.indexOf(a,b)};Array.isArray||(Array.isArray=function(a){return"[object Array]"===Object.prototype.toString.call(a)});Array.prototype.includes||(Array.prototype.includes=mc);
|
||||
String.prototype.trim||(String.prototype.trim=function(){return this.replace(/^[\s\uFEFF\xA0]+|[\s\uFEFF\xA0]+$/g,"")});String.prototype.includes||(String.prototype.includes=mc);u.util={throttle:function(a,b){var c=b!==q?b:200,d,e;return function(){var h=this,f=+new Date,g=arguments;d&&f<d+c?(clearTimeout(e),e=setTimeout(function(){d=q;a.apply(h,g)},c)):(d=f,a.apply(h,g))}},escapeRegex:function(a){return a.replace(wc,"\\$1")},set:function(a){if(l.isPlainObject(a))return u.util.set(a._);if(null===
|
||||
a)return function(){};if("function"===typeof a)return function(c,d,e){a(c,"set",d,e)};if("string"!==typeof a||-1===a.indexOf(".")&&-1===a.indexOf("[")&&-1===a.indexOf("("))return function(c,d){c[a]=d};var b=function(c,d,e){e=cb(e);var h=e[e.length-1];for(var f,g,k=0,m=e.length-1;k<m;k++){if("__proto__"===e[k]||"constructor"===e[k])throw Error("Cannot set prototype values");f=e[k].match(Fa);g=e[k].match(ra);if(f){e[k]=e[k].replace(Fa,"");c[e[k]]=[];h=e.slice();h.splice(0,k+1);f=h.join(".");if(Array.isArray(d))for(g=
|
||||
0,m=d.length;g<m;g++)h={},b(h,d[g],f),c[e[k]].push(h);else c[e[k]]=d;return}g&&(e[k]=e[k].replace(ra,""),c=c[e[k]](d));if(null===c[e[k]]||c[e[k]]===q)c[e[k]]={};c=c[e[k]]}if(h.match(ra))c[h.replace(ra,"")](d);else c[h.replace(Fa,"")]=d};return function(c,d){return b(c,d,a)}},get:function(a){if(l.isPlainObject(a)){var b={};l.each(a,function(d,e){e&&(b[d]=u.util.get(e))});return function(d,e,h,f){var g=b[e]||b._;return g!==q?g(d,e,h,f):d}}if(null===a)return function(d){return d};if("function"===typeof a)return function(d,
|
||||
e,h,f){return a(d,e,h,f)};if("string"!==typeof a||-1===a.indexOf(".")&&-1===a.indexOf("[")&&-1===a.indexOf("("))return function(d,e){return d[a]};var c=function(d,e,h){if(""!==h){var f=cb(h);for(var g=0,k=f.length;g<k;g++){h=f[g].match(Fa);var m=f[g].match(ra);if(h){f[g]=f[g].replace(Fa,"");""!==f[g]&&(d=d[f[g]]);m=[];f.splice(0,g+1);f=f.join(".");if(Array.isArray(d))for(g=0,k=d.length;g<k;g++)m.push(c(d[g],e,f));d=h[0].substring(1,h[0].length-1);d=""===d?m:m.join(d);break}else if(m){f[g]=f[g].replace(ra,
|
||||
"");d=d[f[g]]();continue}if(null===d||d[f[g]]===q)return q;d=d[f[g]]}}return d};return function(d,e){return c(d,e,a)}}};var S=function(a,b,c){a[b]!==q&&(a[c]=a[b])},Fa=/\[.*?\]$/,ra=/\(\)$/,na=u.util.get,ha=u.util.set,jb=u.util.escapeRegex,Qa=l("<div>")[0],tc=Qa.textContent!==q,uc=/<.*?>/g,hb=u.util.throttle,nc=[],N=Array.prototype,xc=function(a){var b,c=u.settings,d=l.map(c,function(h,f){return h.nTable});if(a){if(a.nTable&&a.oApi)return[a];if(a.nodeName&&"table"===a.nodeName.toLowerCase()){var e=
|
||||
l.inArray(a,d);return-1!==e?[c[e]]:null}if(a&&"function"===typeof a.settings)return a.settings().toArray();"string"===typeof a?b=l(a):a instanceof l&&(b=a)}else return[];if(b)return b.map(function(h){e=l.inArray(this,d);return-1!==e?c[e]:null}).toArray()};var B=function(a,b){if(!(this instanceof B))return new B(a,b);var c=[],d=function(f){(f=xc(f))&&c.push.apply(c,f)};if(Array.isArray(a))for(var e=0,h=a.length;e<h;e++)d(a[e]);else d(a);this.context=Ma(c);b&&l.merge(this,b);this.selector={rows:null,
|
||||
cols:null,opts:null};B.extend(this,this,nc)};u.Api=B;l.extend(B.prototype,{any:function(){return 0!==this.count()},concat:N.concat,context:[],count:function(){return this.flatten().length},each:function(a){for(var b=0,c=this.length;b<c;b++)a.call(this,this[b],b,this);return this},eq:function(a){var b=this.context;return b.length>a?new B(b[a],this[a]):null},filter:function(a){var b=[];if(N.filter)b=N.filter.call(this,a,this);else for(var c=0,d=this.length;c<d;c++)a.call(this,this[c],c,this)&&b.push(this[c]);
|
||||
return new B(this.context,b)},flatten:function(){var a=[];return new B(this.context,a.concat.apply(a,this.toArray()))},join:N.join,indexOf:N.indexOf||function(a,b){b=b||0;for(var c=this.length;b<c;b++)if(this[b]===a)return b;return-1},iterator:function(a,b,c,d){var e=[],h,f,g=this.context,k,m=this.selector;"string"===typeof a&&(d=c,c=b,b=a,a=!1);var n=0;for(h=g.length;n<h;n++){var p=new B(g[n]);if("table"===b){var t=c.call(p,g[n],n);t!==q&&e.push(t)}else if("columns"===b||"rows"===b)t=c.call(p,g[n],
|
||||
this[n],n),t!==q&&e.push(t);else if("column"===b||"column-rows"===b||"row"===b||"cell"===b){var v=this[n];"column-rows"===b&&(k=Wa(g[n],m.opts));var x=0;for(f=v.length;x<f;x++)t=v[x],t="cell"===b?c.call(p,g[n],t.row,t.column,n,x):c.call(p,g[n],t,n,x,k),t!==q&&e.push(t)}}return e.length||d?(a=new B(g,a?e.concat.apply([],e):e),b=a.selector,b.rows=m.rows,b.cols=m.cols,b.opts=m.opts,a):this},lastIndexOf:N.lastIndexOf||function(a,b){return this.indexOf.apply(this.toArray.reverse(),arguments)},length:0,
|
||||
map:function(a){var b=[];if(N.map)b=N.map.call(this,a,this);else for(var c=0,d=this.length;c<d;c++)b.push(a.call(this,this[c],c));return new B(this.context,b)},pluck:function(a){return this.map(function(b){return b[a]})},pop:N.pop,push:N.push,reduce:N.reduce||function(a,b){return Cb(this,a,b,0,this.length,1)},reduceRight:N.reduceRight||function(a,b){return Cb(this,a,b,this.length-1,-1,-1)},reverse:N.reverse,selector:null,shift:N.shift,slice:function(){return new B(this.context,this)},sort:N.sort,
|
||||
splice:N.splice,toArray:function(){return N.slice.call(this)},to$:function(){return l(this)},toJQuery:function(){return l(this)},unique:function(){return new B(this.context,Ma(this))},unshift:N.unshift});B.extend=function(a,b,c){if(c.length&&b&&(b instanceof B||b.__dt_wrapper)){var d,e=function(g,k,m){return function(){var n=k.apply(g,arguments);B.extend(n,n,m.methodExt);return n}};var h=0;for(d=c.length;h<d;h++){var f=c[h];b[f.name]="function"===f.type?e(a,f.val,f):"object"===f.type?{}:f.val;b[f.name].__dt_wrapper=
|
||||
!0;B.extend(a,b[f.name],f.propExt)}}};B.register=y=function(a,b){if(Array.isArray(a))for(var c=0,d=a.length;c<d;c++)B.register(a[c],b);else{d=a.split(".");var e=nc,h;a=0;for(c=d.length;a<c;a++){var f=(h=-1!==d[a].indexOf("()"))?d[a].replace("()",""):d[a];a:{var g=0;for(var k=e.length;g<k;g++)if(e[g].name===f){g=e[g];break a}g=null}g||(g={name:f,val:{},methodExt:[],propExt:[],type:"object"},e.push(g));a===c-1?(g.val=b,g.type="function"===typeof b?"function":l.isPlainObject(b)?"object":"other"):e=h?
|
||||
g.methodExt:g.propExt}}};B.registerPlural=J=function(a,b,c){B.register(a,c);B.register(b,function(){var d=c.apply(this,arguments);return d===this?this:d instanceof B?d.length?Array.isArray(d[0])?new B(d.context,d[0]):d[0]:q:d})};var oc=function(a,b){if(Array.isArray(a))return l.map(a,function(d){return oc(d,b)});if("number"===typeof a)return[b[a]];var c=l.map(b,function(d,e){return d.nTable});return l(c).filter(a).map(function(d){d=l.inArray(this,c);return b[d]}).toArray()};y("tables()",function(a){return a!==
|
||||
q&&null!==a?new B(oc(a,this.context)):this});y("table()",function(a){a=this.tables(a);var b=a.context;return b.length?new B(b[0]):a});J("tables().nodes()","table().node()",function(){return this.iterator("table",function(a){return a.nTable},1)});J("tables().body()","table().body()",function(){return this.iterator("table",function(a){return a.nTBody},1)});J("tables().header()","table().header()",function(){return this.iterator("table",function(a){return a.nTHead},1)});J("tables().footer()","table().footer()",
|
||||
function(){return this.iterator("table",function(a){return a.nTFoot},1)});J("tables().containers()","table().container()",function(){return this.iterator("table",function(a){return a.nTableWrapper},1)});y("draw()",function(a){return this.iterator("table",function(b){"page"===a?ja(b):("string"===typeof a&&(a="full-hold"===a?!1:!0),ka(b,!1===a))})});y("page()",function(a){return a===q?this.page.info().page:this.iterator("table",function(b){Ra(b,a)})});y("page.info()",function(a){if(0===this.context.length)return q;
|
||||
a=this.context[0];var b=a._iDisplayStart,c=a.oFeatures.bPaginate?a._iDisplayLength:-1,d=a.fnRecordsDisplay(),e=-1===c;return{page:e?0:Math.floor(b/c),pages:e?1:Math.ceil(d/c),start:b,end:a.fnDisplayEnd(),length:c,recordsTotal:a.fnRecordsTotal(),recordsDisplay:d,serverSide:"ssp"===Q(a)}});y("page.len()",function(a){return a===q?0!==this.context.length?this.context[0]._iDisplayLength:q:this.iterator("table",function(b){kb(b,a)})});var pc=function(a,b,c){if(c){var d=new B(a);d.one("draw",function(){c(d.ajax.json())})}if("ssp"==
|
||||
Q(a))ka(a,b);else{V(a,!0);var e=a.jqXHR;e&&4!==e.readyState&&e.abort();Oa(a,[],function(h){Ka(a);h=za(a,h);for(var f=0,g=h.length;f<g;f++)ia(a,h[f]);ka(a,b);V(a,!1)})}};y("ajax.json()",function(){var a=this.context;if(0<a.length)return a[0].json});y("ajax.params()",function(){var a=this.context;if(0<a.length)return a[0].oAjaxData});y("ajax.reload()",function(a,b){return this.iterator("table",function(c){pc(c,!1===b,a)})});y("ajax.url()",function(a){var b=this.context;if(a===q){if(0===b.length)return q;
|
||||
b=b[0];return b.ajax?l.isPlainObject(b.ajax)?b.ajax.url:b.ajax:b.sAjaxSource}return this.iterator("table",function(c){l.isPlainObject(c.ajax)?c.ajax.url=a:c.ajax=a})});y("ajax.url().load()",function(a,b){return this.iterator("table",function(c){pc(c,!1===b,a)})});var ub=function(a,b,c,d,e){var h=[],f,g,k;var m=typeof b;b&&"string"!==m&&"function"!==m&&b.length!==q||(b=[b]);m=0;for(g=b.length;m<g;m++){var n=b[m]&&b[m].split&&!b[m].match(/[\[\(:]/)?b[m].split(","):[b[m]];var p=0;for(k=n.length;p<k;p++)(f=
|
||||
c("string"===typeof n[p]?n[p].trim():n[p]))&&f.length&&(h=h.concat(f))}a=M.selector[a];if(a.length)for(m=0,g=a.length;m<g;m++)h=a[m](d,e,h);return Ma(h)},vb=function(a){a||(a={});a.filter&&a.search===q&&(a.search=a.filter);return l.extend({search:"none",order:"current",page:"all"},a)},wb=function(a){for(var b=0,c=a.length;b<c;b++)if(0<a[b].length)return a[0]=a[b],a[0].length=1,a.length=1,a.context=[a.context[b]],a;a.length=0;return a},Wa=function(a,b){var c=[],d=a.aiDisplay;var e=a.aiDisplayMaster;
|
||||
var h=b.search;var f=b.order;b=b.page;if("ssp"==Q(a))return"removed"===h?[]:qa(0,e.length);if("current"==b)for(f=a._iDisplayStart,a=a.fnDisplayEnd();f<a;f++)c.push(d[f]);else if("current"==f||"applied"==f)if("none"==h)c=e.slice();else if("applied"==h)c=d.slice();else{if("removed"==h){var g={};f=0;for(a=d.length;f<a;f++)g[d[f]]=null;c=l.map(e,function(k){return g.hasOwnProperty(k)?null:k})}}else if("index"==f||"original"==f)for(f=0,a=a.aoData.length;f<a;f++)"none"==h?c.push(f):(e=l.inArray(f,d),(-1===
|
||||
e&&"removed"==h||0<=e&&"applied"==h)&&c.push(f));return c},yc=function(a,b,c){var d;return ub("row",b,function(e){var h=hc(e),f=a.aoData;if(null!==h&&!c)return[h];d||(d=Wa(a,c));if(null!==h&&-1!==l.inArray(h,d))return[h];if(null===e||e===q||""===e)return d;if("function"===typeof e)return l.map(d,function(k){var m=f[k];return e(k,m._aData,m.nTr)?k:null});if(e.nodeName){h=e._DT_RowIndex;var g=e._DT_CellIndex;if(h!==q)return f[h]&&f[h].nTr===e?[h]:[];if(g)return f[g.row]&&f[g.row].nTr===e.parentNode?
|
||||
[g.row]:[];h=l(e).closest("*[data-dt-row]");return h.length?[h.data("dt-row")]:[]}if("string"===typeof e&&"#"===e.charAt(0)&&(h=a.aIds[e.replace(/^#/,"")],h!==q))return[h.idx];h=kc(Ea(a.aoData,d,"nTr"));return l(h).filter(e).map(function(){return this._DT_RowIndex}).toArray()},a,c)};y("rows()",function(a,b){a===q?a="":l.isPlainObject(a)&&(b=a,a="");b=vb(b);var c=this.iterator("table",function(d){return yc(d,a,b)},1);c.selector.rows=a;c.selector.opts=b;return c});y("rows().nodes()",function(){return this.iterator("row",
|
||||
function(a,b){return a.aoData[b].nTr||q},1)});y("rows().data()",function(){return this.iterator(!0,"rows",function(a,b){return Ea(a.aoData,b,"_aData")},1)});J("rows().cache()","row().cache()",function(a){return this.iterator("row",function(b,c){b=b.aoData[c];return"search"===a?b._aFilterData:b._aSortData},1)});J("rows().invalidate()","row().invalidate()",function(a){return this.iterator("row",function(b,c){va(b,c,a)})});J("rows().indexes()","row().index()",function(){return this.iterator("row",function(a,
|
||||
b){return b},1)});J("rows().ids()","row().id()",function(a){for(var b=[],c=this.context,d=0,e=c.length;d<e;d++)for(var h=0,f=this[d].length;h<f;h++){var g=c[d].rowIdFn(c[d].aoData[this[d][h]]._aData);b.push((!0===a?"#":"")+g)}return new B(c,b)});J("rows().remove()","row().remove()",function(){var a=this;this.iterator("row",function(b,c,d){var e=b.aoData,h=e[c],f,g;e.splice(c,1);var k=0;for(f=e.length;k<f;k++){var m=e[k];var n=m.anCells;null!==m.nTr&&(m.nTr._DT_RowIndex=k);if(null!==n)for(m=0,g=n.length;m<
|
||||
g;m++)n[m]._DT_CellIndex.row=k}La(b.aiDisplayMaster,c);La(b.aiDisplay,c);La(a[d],c,!1);0<b._iRecordsDisplay&&b._iRecordsDisplay--;lb(b);c=b.rowIdFn(h._aData);c!==q&&delete b.aIds[c]});this.iterator("table",function(b){for(var c=0,d=b.aoData.length;c<d;c++)b.aoData[c].idx=c});return this});y("rows.add()",function(a){var b=this.iterator("table",function(d){var e,h=[];var f=0;for(e=a.length;f<e;f++){var g=a[f];g.nodeName&&"TR"===g.nodeName.toUpperCase()?h.push(Ja(d,g)[0]):h.push(ia(d,g))}return h},1),
|
||||
c=this.rows(-1);c.pop();l.merge(c,b);return c});y("row()",function(a,b){return wb(this.rows(a,b))});y("row().data()",function(a){var b=this.context;if(a===q)return b.length&&this.length?b[0].aoData[this[0]]._aData:q;var c=b[0].aoData[this[0]];c._aData=a;Array.isArray(a)&&c.nTr&&c.nTr.id&&ha(b[0].rowId)(a,c.nTr.id);va(b[0],this[0],"data");return this});y("row().node()",function(){var a=this.context;return a.length&&this.length?a[0].aoData[this[0]].nTr||null:null});y("row.add()",function(a){a instanceof
|
||||
l&&a.length&&(a=a[0]);var b=this.iterator("table",function(c){return a.nodeName&&"TR"===a.nodeName.toUpperCase()?Ja(c,a)[0]:ia(c,a)});return this.row(b[0])});l(A).on("plugin-init.dt",function(a,b){a=new B(b);a.on("stateSaveParams",function(d,e,h){d=e.rowIdFn;e=e.aoData;for(var f=[],g=0;g<e.length;g++)e[g]._detailsShow&&f.push("#"+d(e[g]._aData));h.childRows=f});var c=a.state.loaded();c&&c.childRows&&a.rows(l.map(c.childRows,function(d){return d.replace(/:/g,"\\:")})).every(function(){F(b,null,"requestChild",
|
||||
[this])})});var zc=function(a,b,c,d){var e=[],h=function(f,g){if(Array.isArray(f)||f instanceof l)for(var k=0,m=f.length;k<m;k++)h(f[k],g);else f.nodeName&&"tr"===f.nodeName.toLowerCase()?e.push(f):(k=l("<tr><td></td></tr>").addClass(g),l("td",k).addClass(g).html(f)[0].colSpan=oa(a),e.push(k[0]))};h(c,d);b._details&&b._details.detach();b._details=l(e);b._detailsShow&&b._details.insertAfter(b.nTr)},qc=u.util.throttle(function(a){Ca(a[0])},500),xb=function(a,b){var c=a.context;c.length&&(a=c[0].aoData[b!==
|
||||
q?b:a[0]])&&a._details&&(a._details.remove(),a._detailsShow=q,a._details=q,l(a.nTr).removeClass("dt-hasChild"),qc(c))},rc=function(a,b){var c=a.context;if(c.length&&a.length){var d=c[0].aoData[a[0]];d._details&&((d._detailsShow=b)?(d._details.insertAfter(d.nTr),l(d.nTr).addClass("dt-hasChild")):(d._details.detach(),l(d.nTr).removeClass("dt-hasChild")),F(c[0],null,"childRow",[b,a.row(a[0])]),Ac(c[0]),qc(c))}},Ac=function(a){var b=new B(a),c=a.aoData;b.off("draw.dt.DT_details column-visibility.dt.DT_details destroy.dt.DT_details");
|
||||
0<U(c,"_details").length&&(b.on("draw.dt.DT_details",function(d,e){a===e&&b.rows({page:"current"}).eq(0).each(function(h){h=c[h];h._detailsShow&&h._details.insertAfter(h.nTr)})}),b.on("column-visibility.dt.DT_details",function(d,e,h,f){if(a===e)for(e=oa(e),h=0,f=c.length;h<f;h++)d=c[h],d._details&&d._details.children("td[colspan]").attr("colspan",e)}),b.on("destroy.dt.DT_details",function(d,e){if(a===e)for(d=0,e=c.length;d<e;d++)c[d]._details&&xb(b,d)}))};y("row().child()",function(a,b){var c=this.context;
|
||||
if(a===q)return c.length&&this.length?c[0].aoData[this[0]]._details:q;!0===a?this.child.show():!1===a?xb(this):c.length&&this.length&&zc(c[0],c[0].aoData[this[0]],a,b);return this});y(["row().child.show()","row().child().show()"],function(a){rc(this,!0);return this});y(["row().child.hide()","row().child().hide()"],function(){rc(this,!1);return this});y(["row().child.remove()","row().child().remove()"],function(){xb(this);return this});y("row().child.isShown()",function(){var a=this.context;return a.length&&
|
||||
this.length?a[0].aoData[this[0]]._detailsShow||!1:!1});var Bc=/^([^:]+):(name|visIdx|visible)$/,sc=function(a,b,c,d,e){c=[];d=0;for(var h=e.length;d<h;d++)c.push(T(a,e[d],b));return c},Cc=function(a,b,c){var d=a.aoColumns,e=U(d,"sName"),h=U(d,"nTh");return ub("column",b,function(f){var g=hc(f);if(""===f)return qa(d.length);if(null!==g)return[0<=g?g:d.length+g];if("function"===typeof f){var k=Wa(a,c);return l.map(d,function(p,t){return f(t,sc(a,t,0,0,k),h[t])?t:null})}var m="string"===typeof f?f.match(Bc):
|
||||
"";if(m)switch(m[2]){case "visIdx":case "visible":g=parseInt(m[1],10);if(0>g){var n=l.map(d,function(p,t){return p.bVisible?t:null});return[n[n.length+g]]}return[ta(a,g)];case "name":return l.map(e,function(p,t){return p===m[1]?t:null});default:return[]}if(f.nodeName&&f._DT_CellIndex)return[f._DT_CellIndex.column];g=l(h).filter(f).map(function(){return l.inArray(this,h)}).toArray();if(g.length||!f.nodeName)return g;g=l(f).closest("*[data-dt-column]");return g.length?[g.data("dt-column")]:[]},a,c)};
|
||||
y("columns()",function(a,b){a===q?a="":l.isPlainObject(a)&&(b=a,a="");b=vb(b);var c=this.iterator("table",function(d){return Cc(d,a,b)},1);c.selector.cols=a;c.selector.opts=b;return c});J("columns().header()","column().header()",function(a,b){return this.iterator("column",function(c,d){return c.aoColumns[d].nTh},1)});J("columns().footer()","column().footer()",function(a,b){return this.iterator("column",function(c,d){return c.aoColumns[d].nTf},1)});J("columns().data()","column().data()",function(){return this.iterator("column-rows",
|
||||
sc,1)});J("columns().dataSrc()","column().dataSrc()",function(){return this.iterator("column",function(a,b){return a.aoColumns[b].mData},1)});J("columns().cache()","column().cache()",function(a){return this.iterator("column-rows",function(b,c,d,e,h){return Ea(b.aoData,h,"search"===a?"_aFilterData":"_aSortData",c)},1)});J("columns().nodes()","column().nodes()",function(){return this.iterator("column-rows",function(a,b,c,d,e){return Ea(a.aoData,e,"anCells",b)},1)});J("columns().visible()","column().visible()",
|
||||
function(a,b){var c=this,d=this.iterator("column",function(e,h){if(a===q)return e.aoColumns[h].bVisible;var f=e.aoColumns,g=f[h],k=e.aoData,m;if(a!==q&&g.bVisible!==a){if(a){var n=l.inArray(!0,U(f,"bVisible"),h+1);f=0;for(m=k.length;f<m;f++){var p=k[f].nTr;e=k[f].anCells;p&&p.insertBefore(e[h],e[n]||null)}}else l(U(e.aoData,"anCells",h)).detach();g.bVisible=a}});a!==q&&this.iterator("table",function(e){xa(e,e.aoHeader);xa(e,e.aoFooter);e.aiDisplay.length||l(e.nTBody).find("td[colspan]").attr("colspan",
|
||||
oa(e));Ca(e);c.iterator("column",function(h,f){F(h,null,"column-visibility",[h,f,a,b])});(b===q||b)&&c.columns.adjust()});return d});J("columns().indexes()","column().index()",function(a){return this.iterator("column",function(b,c){return"visible"===a?ua(b,c):c},1)});y("columns.adjust()",function(){return this.iterator("table",function(a){sa(a)},1)});y("column.index()",function(a,b){if(0!==this.context.length){var c=this.context[0];if("fromVisible"===a||"toData"===a)return ta(c,b);if("fromData"===
|
||||
a||"toVisible"===a)return ua(c,b)}});y("column()",function(a,b){return wb(this.columns(a,b))});var Dc=function(a,b,c){var d=a.aoData,e=Wa(a,c),h=kc(Ea(d,e,"anCells")),f=l(lc([],h)),g,k=a.aoColumns.length,m,n,p,t,v,x;return ub("cell",b,function(w){var r="function"===typeof w;if(null===w||w===q||r){m=[];n=0;for(p=e.length;n<p;n++)for(g=e[n],t=0;t<k;t++)v={row:g,column:t},r?(x=d[g],w(v,T(a,g,t),x.anCells?x.anCells[t]:null)&&m.push(v)):m.push(v);return m}if(l.isPlainObject(w))return w.column!==q&&w.row!==
|
||||
q&&-1!==l.inArray(w.row,e)?[w]:[];r=f.filter(w).map(function(C,G){return{row:G._DT_CellIndex.row,column:G._DT_CellIndex.column}}).toArray();if(r.length||!w.nodeName)return r;x=l(w).closest("*[data-dt-row]");return x.length?[{row:x.data("dt-row"),column:x.data("dt-column")}]:[]},a,c)};y("cells()",function(a,b,c){l.isPlainObject(a)&&(a.row===q?(c=a,a=null):(c=b,b=null));l.isPlainObject(b)&&(c=b,b=null);if(null===b||b===q)return this.iterator("table",function(n){return Dc(n,a,vb(c))});var d=c?{page:c.page,
|
||||
order:c.order,search:c.search}:{},e=this.columns(b,d),h=this.rows(a,d),f,g,k,m;d=this.iterator("table",function(n,p){n=[];f=0;for(g=h[p].length;f<g;f++)for(k=0,m=e[p].length;k<m;k++)n.push({row:h[p][f],column:e[p][k]});return n},1);d=c&&c.selected?this.cells(d,c):d;l.extend(d.selector,{cols:b,rows:a,opts:c});return d});J("cells().nodes()","cell().node()",function(){return this.iterator("cell",function(a,b,c){return(a=a.aoData[b])&&a.anCells?a.anCells[c]:q},1)});y("cells().data()",function(){return this.iterator("cell",
|
||||
function(a,b,c){return T(a,b,c)},1)});J("cells().cache()","cell().cache()",function(a){a="search"===a?"_aFilterData":"_aSortData";return this.iterator("cell",function(b,c,d){return b.aoData[c][a][d]},1)});J("cells().render()","cell().render()",function(a){return this.iterator("cell",function(b,c,d){return T(b,c,d,a)},1)});J("cells().indexes()","cell().index()",function(){return this.iterator("cell",function(a,b,c){return{row:b,column:c,columnVisible:ua(a,c)}},1)});J("cells().invalidate()","cell().invalidate()",
|
||||
function(a){return this.iterator("cell",function(b,c,d){va(b,c,a,d)})});y("cell()",function(a,b,c){return wb(this.cells(a,b,c))});y("cell().data()",function(a){var b=this.context,c=this[0];if(a===q)return b.length&&c.length?T(b[0],c[0].row,c[0].column):q;Eb(b[0],c[0].row,c[0].column,a);va(b[0],c[0].row,"data",c[0].column);return this});y("order()",function(a,b){var c=this.context;if(a===q)return 0!==c.length?c[0].aaSorting:q;"number"===typeof a?a=[[a,b]]:a.length&&!Array.isArray(a[0])&&(a=Array.prototype.slice.call(arguments));
|
||||
return this.iterator("table",function(d){d.aaSorting=a.slice()})});y("order.listener()",function(a,b,c){return this.iterator("table",function(d){fb(d,a,b,c)})});y("order.fixed()",function(a){if(!a){var b=this.context;b=b.length?b[0].aaSortingFixed:q;return Array.isArray(b)?{pre:b}:b}return this.iterator("table",function(c){c.aaSortingFixed=l.extend(!0,{},a)})});y(["columns().order()","column().order()"],function(a){var b=this;return this.iterator("table",function(c,d){var e=[];l.each(b[d],function(h,
|
||||
f){e.push([f,a])});c.aaSorting=e})});y("search()",function(a,b,c,d){var e=this.context;return a===q?0!==e.length?e[0].oPreviousSearch.sSearch:q:this.iterator("table",function(h){h.oFeatures.bFilter&&ya(h,l.extend({},h.oPreviousSearch,{sSearch:a+"",bRegex:null===b?!1:b,bSmart:null===c?!0:c,bCaseInsensitive:null===d?!0:d}),1)})});J("columns().search()","column().search()",function(a,b,c,d){return this.iterator("column",function(e,h){var f=e.aoPreSearchCols;if(a===q)return f[h].sSearch;e.oFeatures.bFilter&&
|
||||
(l.extend(f[h],{sSearch:a+"",bRegex:null===b?!1:b,bSmart:null===c?!0:c,bCaseInsensitive:null===d?!0:d}),ya(e,e.oPreviousSearch,1))})});y("state()",function(){return this.context.length?this.context[0].oSavedState:null});y("state.clear()",function(){return this.iterator("table",function(a){a.fnStateSaveCallback.call(a.oInstance,a,{})})});y("state.loaded()",function(){return this.context.length?this.context[0].oLoadedState:null});y("state.save()",function(){return this.iterator("table",function(a){Ca(a)})});
|
||||
u.versionCheck=u.fnVersionCheck=function(a){var b=u.version.split(".");a=a.split(".");for(var c,d,e=0,h=a.length;e<h;e++)if(c=parseInt(b[e],10)||0,d=parseInt(a[e],10)||0,c!==d)return c>d;return!0};u.isDataTable=u.fnIsDataTable=function(a){var b=l(a).get(0),c=!1;if(a instanceof u.Api)return!0;l.each(u.settings,function(d,e){d=e.nScrollHead?l("table",e.nScrollHead)[0]:null;var h=e.nScrollFoot?l("table",e.nScrollFoot)[0]:null;if(e.nTable===b||d===b||h===b)c=!0});return c};u.tables=u.fnTables=function(a){var b=
|
||||
!1;l.isPlainObject(a)&&(b=a.api,a=a.visible);var c=l.map(u.settings,function(d){if(!a||a&&l(d.nTable).is(":visible"))return d.nTable});return b?new B(c):c};u.camelToHungarian=P;y("$()",function(a,b){b=this.rows(b).nodes();b=l(b);return l([].concat(b.filter(a).toArray(),b.find(a).toArray()))});l.each(["on","one","off"],function(a,b){y(b+"()",function(){var c=Array.prototype.slice.call(arguments);c[0]=l.map(c[0].split(/\s/),function(e){return e.match(/\.dt\b/)?e:e+".dt"}).join(" ");var d=l(this.tables().nodes());
|
||||
d[b].apply(d,c);return this})});y("clear()",function(){return this.iterator("table",function(a){Ka(a)})});y("settings()",function(){return new B(this.context,this.context)});y("init()",function(){var a=this.context;return a.length?a[0].oInit:null});y("data()",function(){return this.iterator("table",function(a){return U(a.aoData,"_aData")}).flatten()});y("destroy()",function(a){a=a||!1;return this.iterator("table",function(b){var c=b.nTableWrapper.parentNode,d=b.oClasses,e=b.nTable,h=b.nTBody,f=b.nTHead,
|
||||
g=b.nTFoot,k=l(e);h=l(h);var m=l(b.nTableWrapper),n=l.map(b.aoData,function(t){return t.nTr}),p;b.bDestroying=!0;F(b,"aoDestroyCallback","destroy",[b]);a||(new B(b)).columns().visible(!0);m.off(".DT").find(":not(tbody *)").off(".DT");l(z).off(".DT-"+b.sInstance);e!=f.parentNode&&(k.children("thead").detach(),k.append(f));g&&e!=g.parentNode&&(k.children("tfoot").detach(),k.append(g));b.aaSorting=[];b.aaSortingFixed=[];Sa(b);l(n).removeClass(b.asStripeClasses.join(" "));l("th, td",f).removeClass(d.sSortable+
|
||||
" "+d.sSortableAsc+" "+d.sSortableDesc+" "+d.sSortableNone);h.children().detach();h.append(n);f=a?"remove":"detach";k[f]();m[f]();!a&&c&&(c.insertBefore(e,b.nTableReinsertBefore),k.css("width",b.sDestroyWidth).removeClass(d.sTable),(p=b.asDestroyStripes.length)&&h.children().each(function(t){l(this).addClass(b.asDestroyStripes[t%p])}));c=l.inArray(b,u.settings);-1!==c&&u.settings.splice(c,1)})});l.each(["column","row","cell"],function(a,b){y(b+"s().every()",function(c){var d=this.selector.opts,e=
|
||||
this;return this.iterator(b,function(h,f,g,k,m){c.call(e[b](f,"cell"===b?g:d,"cell"===b?d:q),f,g,k,m)})})});y("i18n()",function(a,b,c){var d=this.context[0];a=na(a)(d.oLanguage);a===q&&(a=b);c!==q&&l.isPlainObject(a)&&(a=a[c]!==q?a[c]:a._);return a.replace("%d",c)});u.version="1.11.5";u.settings=[];u.models={};u.models.oSearch={bCaseInsensitive:!0,sSearch:"",bRegex:!1,bSmart:!0,"return":!1};u.models.oRow={nTr:null,anCells:null,_aData:[],_aSortData:null,_aFilterData:null,_sFilterRow:null,_sRowStripe:"",
|
||||
src:null,idx:-1};u.models.oColumn={idx:null,aDataSort:null,asSorting:null,bSearchable:null,bSortable:null,bVisible:null,_sManualType:null,_bAttrSrc:!1,fnCreatedCell:null,fnGetData:null,fnSetData:null,mData:null,mRender:null,nTh:null,nTf:null,sClass:null,sContentPadding:null,sDefaultContent:null,sName:null,sSortDataType:"std",sSortingClass:null,sSortingClassJUI:null,sTitle:null,sType:null,sWidth:null,sWidthOrig:null};u.defaults={aaData:null,aaSorting:[[0,"asc"]],aaSortingFixed:[],ajax:null,aLengthMenu:[10,
|
||||
25,50,100],aoColumns:null,aoColumnDefs:null,aoSearchCols:[],asStripeClasses:null,bAutoWidth:!0,bDeferRender:!1,bDestroy:!1,bFilter:!0,bInfo:!0,bLengthChange:!0,bPaginate:!0,bProcessing:!1,bRetrieve:!1,bScrollCollapse:!1,bServerSide:!1,bSort:!0,bSortMulti:!0,bSortCellsTop:!1,bSortClasses:!0,bStateSave:!1,fnCreatedRow:null,fnDrawCallback:null,fnFooterCallback:null,fnFormatNumber:function(a){return a.toString().replace(/\B(?=(\d{3})+(?!\d))/g,this.oLanguage.sThousands)},fnHeaderCallback:null,fnInfoCallback:null,
|
||||
fnInitComplete:null,fnPreDrawCallback:null,fnRowCallback:null,fnServerData:null,fnServerParams:null,fnStateLoadCallback:function(a){try{return JSON.parse((-1===a.iStateDuration?sessionStorage:localStorage).getItem("DataTables_"+a.sInstance+"_"+location.pathname))}catch(b){return{}}},fnStateLoadParams:null,fnStateLoaded:null,fnStateSaveCallback:function(a,b){try{(-1===a.iStateDuration?sessionStorage:localStorage).setItem("DataTables_"+a.sInstance+"_"+location.pathname,JSON.stringify(b))}catch(c){}},
|
||||
fnStateSaveParams:null,iStateDuration:7200,iDeferLoading:null,iDisplayLength:10,iDisplayStart:0,iTabIndex:0,oClasses:{},oLanguage:{oAria:{sSortAscending:": activate to sort column ascending",sSortDescending:": activate to sort column descending"},oPaginate:{sFirst:"First",sLast:"Last",sNext:"Next",sPrevious:"Previous"},sEmptyTable:"No data available in table",sInfo:"Showing _START_ to _END_ of _TOTAL_ entries",sInfoEmpty:"Showing 0 to 0 of 0 entries",sInfoFiltered:"(filtered from _MAX_ total entries)",
|
||||
sInfoPostFix:"",sDecimal:"",sThousands:",",sLengthMenu:"Show _MENU_ entries",sLoadingRecords:"Loading...",sProcessing:"Processing...",sSearch:"Search:",sSearchPlaceholder:"",sUrl:"",sZeroRecords:"No matching records found"},oSearch:l.extend({},u.models.oSearch),sAjaxDataProp:"data",sAjaxSource:null,sDom:"lfrtip",searchDelay:null,sPaginationType:"simple_numbers",sScrollX:"",sScrollXInner:"",sScrollY:"",sServerMethod:"GET",renderer:null,rowId:"DT_RowId"};E(u.defaults);u.defaults.column={aDataSort:null,
|
||||
iDataSort:-1,asSorting:["asc","desc"],bSearchable:!0,bSortable:!0,bVisible:!0,fnCreatedCell:null,mData:null,mRender:null,sCellType:"td",sClass:"",sContentPadding:"",sDefaultContent:null,sName:"",sSortDataType:"std",sTitle:null,sType:null,sWidth:null};E(u.defaults.column);u.models.oSettings={oFeatures:{bAutoWidth:null,bDeferRender:null,bFilter:null,bInfo:null,bLengthChange:null,bPaginate:null,bProcessing:null,bServerSide:null,bSort:null,bSortMulti:null,bSortClasses:null,bStateSave:null},oScroll:{bCollapse:null,
|
||||
iBarWidth:0,sX:null,sXInner:null,sY:null},oLanguage:{fnInfoCallback:null},oBrowser:{bScrollOversize:!1,bScrollbarLeft:!1,bBounding:!1,barWidth:0},ajax:null,aanFeatures:[],aoData:[],aiDisplay:[],aiDisplayMaster:[],aIds:{},aoColumns:[],aoHeader:[],aoFooter:[],oPreviousSearch:{},aoPreSearchCols:[],aaSorting:null,aaSortingFixed:[],asStripeClasses:null,asDestroyStripes:[],sDestroyWidth:0,aoRowCallback:[],aoHeaderCallback:[],aoFooterCallback:[],aoDrawCallback:[],aoRowCreatedCallback:[],aoPreDrawCallback:[],
|
||||
aoInitComplete:[],aoStateSaveParams:[],aoStateLoadParams:[],aoStateLoaded:[],sTableId:"",nTable:null,nTHead:null,nTFoot:null,nTBody:null,nTableWrapper:null,bDeferLoading:!1,bInitialised:!1,aoOpenRows:[],sDom:null,searchDelay:null,sPaginationType:"two_button",iStateDuration:0,aoStateSave:[],aoStateLoad:[],oSavedState:null,oLoadedState:null,sAjaxSource:null,sAjaxDataProp:null,jqXHR:null,json:q,oAjaxData:q,fnServerData:null,aoServerParams:[],sServerMethod:null,fnFormatNumber:null,aLengthMenu:null,iDraw:0,
|
||||
bDrawing:!1,iDrawError:-1,_iDisplayLength:10,_iDisplayStart:0,_iRecordsTotal:0,_iRecordsDisplay:0,oClasses:{},bFiltered:!1,bSorted:!1,bSortCellsTop:null,oInit:null,aoDestroyCallback:[],fnRecordsTotal:function(){return"ssp"==Q(this)?1*this._iRecordsTotal:this.aiDisplayMaster.length},fnRecordsDisplay:function(){return"ssp"==Q(this)?1*this._iRecordsDisplay:this.aiDisplay.length},fnDisplayEnd:function(){var a=this._iDisplayLength,b=this._iDisplayStart,c=b+a,d=this.aiDisplay.length,e=this.oFeatures,h=
|
||||
e.bPaginate;return e.bServerSide?!1===h||-1===a?b+d:Math.min(b+a,this._iRecordsDisplay):!h||c>d||-1===a?d:c},oInstance:null,sInstance:null,iTabIndex:0,nScrollHead:null,nScrollFoot:null,aLastSort:[],oPlugins:{},rowIdFn:null,rowId:null};u.ext=M={buttons:{},classes:{},builder:"-source-",errMode:"alert",feature:[],search:[],selector:{cell:[],column:[],row:[]},internal:{},legacy:{ajax:null},pager:{},renderer:{pageButton:{},header:{}},order:{},type:{detect:[],search:{},order:{}},_unique:0,fnVersionCheck:u.fnVersionCheck,
|
||||
iApiIndex:0,oJUIClasses:{},sVersion:u.version};l.extend(M,{afnFiltering:M.search,aTypes:M.type.detect,ofnSearch:M.type.search,oSort:M.type.order,afnSortData:M.order,aoFeatures:M.feature,oApi:M.internal,oStdClasses:M.classes,oPagination:M.pager});l.extend(u.ext.classes,{sTable:"dataTable",sNoFooter:"no-footer",sPageButton:"paginate_button",sPageButtonActive:"current",sPageButtonDisabled:"disabled",sStripeOdd:"odd",sStripeEven:"even",sRowEmpty:"dataTables_empty",sWrapper:"dataTables_wrapper",sFilter:"dataTables_filter",
|
||||
sInfo:"dataTables_info",sPaging:"dataTables_paginate paging_",sLength:"dataTables_length",sProcessing:"dataTables_processing",sSortAsc:"sorting_asc",sSortDesc:"sorting_desc",sSortable:"sorting",sSortableAsc:"sorting_desc_disabled",sSortableDesc:"sorting_asc_disabled",sSortableNone:"sorting_disabled",sSortColumn:"sorting_",sFilterInput:"",sLengthSelect:"",sScrollWrapper:"dataTables_scroll",sScrollHead:"dataTables_scrollHead",sScrollHeadInner:"dataTables_scrollHeadInner",sScrollBody:"dataTables_scrollBody",
|
||||
sScrollFoot:"dataTables_scrollFoot",sScrollFootInner:"dataTables_scrollFootInner",sHeaderTH:"",sFooterTH:"",sSortJUIAsc:"",sSortJUIDesc:"",sSortJUI:"",sSortJUIAscAllowed:"",sSortJUIDescAllowed:"",sSortJUIWrapper:"",sSortIcon:"",sJUIHeader:"",sJUIFooter:""});var ec=u.ext.pager;l.extend(ec,{simple:function(a,b){return["previous","next"]},full:function(a,b){return["first","previous","next","last"]},numbers:function(a,b){return[Da(a,b)]},simple_numbers:function(a,b){return["previous",Da(a,b),"next"]},
|
||||
full_numbers:function(a,b){return["first","previous",Da(a,b),"next","last"]},first_last_numbers:function(a,b){return["first",Da(a,b),"last"]},_numbers:Da,numbers_length:7});l.extend(!0,u.ext.renderer,{pageButton:{_:function(a,b,c,d,e,h){var f=a.oClasses,g=a.oLanguage.oPaginate,k=a.oLanguage.oAria.paginate||{},m,n,p=0,t=function(x,w){var r,C=f.sPageButtonDisabled,G=function(I){Ra(a,I.data.action,!0)};var aa=0;for(r=w.length;aa<r;aa++){var L=w[aa];if(Array.isArray(L)){var O=l("<"+(L.DT_el||"div")+"/>").appendTo(x);
|
||||
t(O,L)}else{m=null;n=L;O=a.iTabIndex;switch(L){case "ellipsis":x.append('<span class="ellipsis">…</span>');break;case "first":m=g.sFirst;0===e&&(O=-1,n+=" "+C);break;case "previous":m=g.sPrevious;0===e&&(O=-1,n+=" "+C);break;case "next":m=g.sNext;if(0===h||e===h-1)O=-1,n+=" "+C;break;case "last":m=g.sLast;if(0===h||e===h-1)O=-1,n+=" "+C;break;default:m=a.fnFormatNumber(L+1),n=e===L?f.sPageButtonActive:""}null!==m&&(O=l("<a>",{"class":f.sPageButton+" "+n,"aria-controls":a.sTableId,"aria-label":k[L],
|
||||
"data-dt-idx":p,tabindex:O,id:0===c&&"string"===typeof L?a.sTableId+"_"+L:null}).html(m).appendTo(x),ob(O,{action:L},G),p++)}}};try{var v=l(b).find(A.activeElement).data("dt-idx")}catch(x){}t(l(b).empty(),d);v!==q&&l(b).find("[data-dt-idx="+v+"]").trigger("focus")}}});l.extend(u.ext.type.detect,[function(a,b){b=b.oLanguage.sDecimal;return tb(a,b)?"num"+b:null},function(a,b){if(a&&!(a instanceof Date)&&!vc.test(a))return null;b=Date.parse(a);return null!==b&&!isNaN(b)||Z(a)?"date":null},function(a,
|
||||
b){b=b.oLanguage.sDecimal;return tb(a,b,!0)?"num-fmt"+b:null},function(a,b){b=b.oLanguage.sDecimal;return jc(a,b)?"html-num"+b:null},function(a,b){b=b.oLanguage.sDecimal;return jc(a,b,!0)?"html-num-fmt"+b:null},function(a,b){return Z(a)||"string"===typeof a&&-1!==a.indexOf("<")?"html":null}]);l.extend(u.ext.type.search,{html:function(a){return Z(a)?a:"string"===typeof a?a.replace(gc," ").replace(Va,""):""},string:function(a){return Z(a)?a:"string"===typeof a?a.replace(gc," "):a}});var Ua=function(a,
|
||||
b,c,d){if(0!==a&&(!a||"-"===a))return-Infinity;b&&(a=ic(a,b));a.replace&&(c&&(a=a.replace(c,"")),d&&(a=a.replace(d,"")));return 1*a};l.extend(M.type.order,{"date-pre":function(a){a=Date.parse(a);return isNaN(a)?-Infinity:a},"html-pre":function(a){return Z(a)?"":a.replace?a.replace(/<.*?>/g,"").toLowerCase():a+""},"string-pre":function(a){return Z(a)?"":"string"===typeof a?a.toLowerCase():a.toString?a.toString():""},"string-asc":function(a,b){return a<b?-1:a>b?1:0},"string-desc":function(a,b){return a<
|
||||
b?1:a>b?-1:0}});Xa("");l.extend(!0,u.ext.renderer,{header:{_:function(a,b,c,d){l(a.nTable).on("order.dt.DT",function(e,h,f,g){a===h&&(e=c.idx,b.removeClass(d.sSortAsc+" "+d.sSortDesc).addClass("asc"==g[e]?d.sSortAsc:"desc"==g[e]?d.sSortDesc:c.sSortingClass))})},jqueryui:function(a,b,c,d){l("<div/>").addClass(d.sSortJUIWrapper).append(b.contents()).append(l("<span/>").addClass(d.sSortIcon+" "+c.sSortingClassJUI)).appendTo(b);l(a.nTable).on("order.dt.DT",function(e,h,f,g){a===h&&(e=c.idx,b.removeClass(d.sSortAsc+
|
||||
" "+d.sSortDesc).addClass("asc"==g[e]?d.sSortAsc:"desc"==g[e]?d.sSortDesc:c.sSortingClass),b.find("span."+d.sSortIcon).removeClass(d.sSortJUIAsc+" "+d.sSortJUIDesc+" "+d.sSortJUI+" "+d.sSortJUIAscAllowed+" "+d.sSortJUIDescAllowed).addClass("asc"==g[e]?d.sSortJUIAsc:"desc"==g[e]?d.sSortJUIDesc:c.sSortingClassJUI))})}}});var yb=function(a){Array.isArray(a)&&(a=a.join(","));return"string"===typeof a?a.replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,"""):a};u.render=
|
||||
{number:function(a,b,c,d,e){return{display:function(h){if("number"!==typeof h&&"string"!==typeof h)return h;var f=0>h?"-":"",g=parseFloat(h);if(isNaN(g))return yb(h);g=g.toFixed(c);h=Math.abs(g);g=parseInt(h,10);h=c?b+(h-g).toFixed(c).substring(2):"";0===g&&0===parseFloat(h)&&(f="");return f+(d||"")+g.toString().replace(/\B(?=(\d{3})+(?!\d))/g,a)+h+(e||"")}}},text:function(){return{display:yb,filter:yb}}};l.extend(u.ext.internal,{_fnExternApiFunc:fc,_fnBuildAjax:Oa,_fnAjaxUpdate:Gb,_fnAjaxParameters:Pb,
|
||||
_fnAjaxUpdateDraw:Qb,_fnAjaxDataSrc:za,_fnAddColumn:Ya,_fnColumnOptions:Ga,_fnAdjustColumnSizing:sa,_fnVisibleToColumnIndex:ta,_fnColumnIndexToVisible:ua,_fnVisbleColumns:oa,_fnGetColumns:Ia,_fnColumnTypes:$a,_fnApplyColumnDefs:Db,_fnHungarianMap:E,_fnCamelToHungarian:P,_fnLanguageCompat:ma,_fnBrowserDetect:Bb,_fnAddData:ia,_fnAddTr:Ja,_fnNodeToDataIndex:function(a,b){return b._DT_RowIndex!==q?b._DT_RowIndex:null},_fnNodeToColumnIndex:function(a,b,c){return l.inArray(c,a.aoData[b].anCells)},_fnGetCellData:T,
|
||||
_fnSetCellData:Eb,_fnSplitObjNotation:cb,_fnGetObjectDataFn:na,_fnSetObjectDataFn:ha,_fnGetDataMaster:db,_fnClearTable:Ka,_fnDeleteIndex:La,_fnInvalidate:va,_fnGetRowElements:bb,_fnCreateTr:ab,_fnBuildHead:Fb,_fnDrawHead:xa,_fnDraw:ja,_fnReDraw:ka,_fnAddOptionsHtml:Ib,_fnDetectHeader:wa,_fnGetUniqueThs:Na,_fnFeatureHtmlFilter:Kb,_fnFilterComplete:ya,_fnFilterCustom:Tb,_fnFilterColumn:Sb,_fnFilter:Rb,_fnFilterCreateSearch:ib,_fnEscapeRegex:jb,_fnFilterData:Ub,_fnFeatureHtmlInfo:Nb,_fnUpdateInfo:Xb,
|
||||
_fnInfoMacros:Yb,_fnInitialise:Aa,_fnInitComplete:Pa,_fnLengthChange:kb,_fnFeatureHtmlLength:Jb,_fnFeatureHtmlPaginate:Ob,_fnPageChange:Ra,_fnFeatureHtmlProcessing:Lb,_fnProcessingDisplay:V,_fnFeatureHtmlTable:Mb,_fnScrollDraw:Ha,_fnApplyToChildren:ca,_fnCalculateColumnWidths:Za,_fnThrottle:hb,_fnConvertToWidth:Zb,_fnGetWidestNode:$b,_fnGetMaxLenString:ac,_fnStringToCss:K,_fnSortFlatten:pa,_fnSort:Hb,_fnSortAria:cc,_fnSortListener:nb,_fnSortAttachListener:fb,_fnSortingClasses:Sa,_fnSortData:bc,_fnSaveState:Ca,
|
||||
_fnLoadState:dc,_fnImplementState:pb,_fnSettingsFromNode:Ta,_fnLog:da,_fnMap:X,_fnBindAction:ob,_fnCallbackReg:R,_fnCallbackFire:F,_fnLengthOverflow:lb,_fnRenderer:gb,_fnDataSource:Q,_fnRowAttributes:eb,_fnExtend:qb,_fnCalculateEnd:function(){}});l.fn.dataTable=u;u.$=l;l.fn.dataTableSettings=u.settings;l.fn.dataTableExt=u.ext;l.fn.DataTable=function(a){return l(this).dataTable(a).api()};l.each(u,function(a,b){l.fn.DataTable[a]=b});return u});
|
||||
13
static/js/jszip.min.js
vendored
Normal file
13
static/js/jszip.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
7
static/js/moment.min.js
vendored
Normal file
7
static/js/moment.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
3
static/js/pdfmake.min.js
vendored
Normal file
3
static/js/pdfmake.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
static/js/pdfmake.min.js.map
Normal file
1
static/js/pdfmake.min.js.map
Normal file
@@ -0,0 +1 @@
|
||||
{}
|
||||
6
static/js/vfs_fonts.js
Normal file
6
static/js/vfs_fonts.js
Normal file
File diff suppressed because one or more lines are too long
46
templates/auth/add_company_user.html
Normal file
46
templates/auth/add_company_user.html
Normal file
@@ -0,0 +1,46 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<div class="container mt-4">
|
||||
<h2>{{ company.name }} - Add User</h2>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
{% if users %}
|
||||
<form method="POST" action="{{ url_for('auth.add_company_user', company_id=company.id) }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="mb-3">
|
||||
<label for="user_id" class="form-label">Select User</label>
|
||||
<select class="form-select" id="user_id" name="user_id" required>
|
||||
<option value="" selected disabled>Choose a user</option>
|
||||
{% for user in users %}
|
||||
<option value="{{ user.id }}">{{ user.username }} ({{ user.email }})</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="role" class="form-label">Role</label>
|
||||
<select class="form-select" id="role" name="role" required>
|
||||
<option value="User">User</option>
|
||||
{% if current_user.is_global_admin() %}
|
||||
<option value="CompanyAdmin">Company Admin</option>
|
||||
{% endif %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<a href="{{ url_for('auth.company_users', company_id=company.id) }}" class="btn btn-secondary me-2">Cancel</a>
|
||||
<button type="submit" class="btn btn-primary">Add User</button>
|
||||
</div>
|
||||
</form>
|
||||
{% else %}
|
||||
<div class="alert alert-info">
|
||||
<p>All users are already added to this company.</p>
|
||||
<a href="{{ url_for('auth.manage_users') }}" class="btn btn-primary mt-2">Create New User</a>
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<a href="{{ url_for('auth.company_users', company_id=company.id) }}" class="btn btn-secondary">Back to Company Users</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
161
templates/auth/admin_settings.html
Normal file
161
templates/auth/admin_settings.html
Normal file
@@ -0,0 +1,161 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<div class="container mt-4">
|
||||
<h2>Admin Settings</h2>
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Website Settings</h5>
|
||||
<form method="POST">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="form-check mb-3">
|
||||
<input type="checkbox" class="form-check-input" id="allow_registration"
|
||||
name="allow_registration" {% if settings.allow_registration %}checked{% endif %}>
|
||||
<label class="form-check-label" for="allow_registration">
|
||||
Allow User Registration
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-check mb-3">
|
||||
<input type="checkbox" class="form-check-input" id="restrict_email_domains"
|
||||
name="restrict_email_domains" {% if settings.restrict_email_domains %}checked{% endif %}>
|
||||
<label class="form-check-label" for="restrict_email_domains">
|
||||
Restrict Registration to Specific Email Domains
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-check mb-3">
|
||||
<input type="checkbox" class="form-check-input" id="require_mfa_for_all_users"
|
||||
name="require_mfa_for_all_users" {% if settings.require_mfa_for_all_users %}checked{% endif %}>
|
||||
<label class="form-check-label" for="require_mfa_for_all_users">
|
||||
Require MFA for All Users (GlobalAdmin accounts exempt)
|
||||
</label>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Save Settings</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mt-4">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Password Strength Requirements</h5>
|
||||
<form method="POST">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="password_min_length" class="form-label">Minimum Password Length</label>
|
||||
<input type="number" class="form-control" id="password_min_length"
|
||||
name="password_min_length" min="6" max="128"
|
||||
value="{{ settings.password_min_length or 10 }}" required>
|
||||
<div class="form-text">Minimum 6 characters required</div>
|
||||
</div>
|
||||
|
||||
<div class="form-check mb-3">
|
||||
<input type="checkbox" class="form-check-input" id="password_require_numbers_mixed_case"
|
||||
name="password_require_numbers_mixed_case"
|
||||
{% if settings.password_require_numbers_mixed_case %}checked{% endif %}>
|
||||
<label class="form-check-label" for="password_require_numbers_mixed_case">
|
||||
Require Numbers and Mixed Case Letters
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-check mb-3">
|
||||
<input type="checkbox" class="form-check-input" id="password_require_special_chars"
|
||||
name="password_require_special_chars"
|
||||
{% if settings.password_require_special_chars %}checked{% endif %}>
|
||||
<label class="form-check-label" for="password_require_special_chars">
|
||||
Require Special Characters
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="password_safe_special_chars" class="form-label">Safe Special Characters</label>
|
||||
<input type="text" class="form-control" id="password_safe_special_chars"
|
||||
name="password_safe_special_chars"
|
||||
value="{{ settings.password_safe_special_chars or '!@#$%^&*()_+-=[]{}|;:,.<>?' }}">
|
||||
<div class="form-text">Characters allowed for special character requirement</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary">Save Password Settings</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mt-4">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Database Logging Configuration</h5>
|
||||
<form method="POST">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="log_level" class="form-label">Database Logging Level</label>
|
||||
<select class="form-select" id="log_level" name="log_level">
|
||||
{% for level_value, level_description in available_log_levels %}
|
||||
<option value="{{ level_value }}"
|
||||
{% if settings.log_level == level_value %}selected{% endif %}>
|
||||
{{ level_description }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<div class="form-text">
|
||||
Controls which log messages are saved to the database. Lower levels include all higher levels.
|
||||
<br><strong>Note:</strong> DEBUG and INFO levels may generate many log entries and increase database size.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary">Save Logging Settings</button>
|
||||
</form>
|
||||
|
||||
<div class="mt-3">
|
||||
<h6>Current Log Level: <span class="badge bg-primary">{{ settings.log_level or 'WARNING' }}</span></h6>
|
||||
<div class="small text-muted">
|
||||
<p><strong>Level Descriptions:</strong></p>
|
||||
<ul class="mb-0">
|
||||
<li><strong>DEBUG:</strong> All messages including detailed debugging information</li>
|
||||
<li><strong>INFO:</strong> General information (logins, registrations, etc.)</li>
|
||||
<li><strong>WARNING:</strong> Warnings and potential issues (failed logins, etc.)</li>
|
||||
<li><strong>ERROR:</strong> Error messages and exceptions</li>
|
||||
<li><strong>CRITICAL:</strong> Only critical system errors</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mt-4">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Allowed Email Domains</h5>
|
||||
<form method="POST" action="{{ url_for('auth.add_allowed_domain') }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="input-group mb-3">
|
||||
<input type="text" class="form-control" name="domain"
|
||||
placeholder="example.com" required>
|
||||
<button class="btn btn-outline-primary" type="submit">Add Domain</button>
|
||||
</div>
|
||||
</form>
|
||||
<div class="mt-3">
|
||||
{% if allowed_domains %}
|
||||
<ul class="list-group">
|
||||
{% for domain in allowed_domains %}
|
||||
<li class="list-group-item d-flex justify-content-between align-items-center">
|
||||
{{ domain.domain }}
|
||||
<form method="POST" action="{{ url_for('auth.delete_allowed_domain', domain_id=domain.id) }}"
|
||||
style="display: inline;">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="btn btn-danger btn-sm"
|
||||
onclick="return confirm('Are you sure you want to remove this domain?')">
|
||||
Remove
|
||||
</button>
|
||||
</form>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<p class="text-muted">No domains added yet. When domains are restricted, only users with these email domains can register.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
111
templates/auth/company_api_keys.html
Normal file
111
templates/auth/company_api_keys.html
Normal file
@@ -0,0 +1,111 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block head %}
|
||||
<!-- DataTables CSS -->
|
||||
<link href="{{ url_for('static', filename='css/dataTables.bootstrap5.min.css') }}" rel="stylesheet">
|
||||
<link href="{{ url_for('static', filename='css/buttons.bootstrap5.min.css') }}" rel="stylesheet">
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mt-4">
|
||||
<h2>{{ company.name }} - Sites (API Keys)</h2>
|
||||
|
||||
<div class="card mb-4">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h5 class="card-title mb-0">Create New API Key</h5>
|
||||
<a href="{{ url_for('auth.download_agent', company_id=company.id) }}" class="btn btn-success">
|
||||
<i class="fas fa-download"></i> Download Windows Agent
|
||||
</a>
|
||||
</div>
|
||||
<form method="POST" action="{{ url_for('auth.create_company_api_key', company_id=company.id) }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="mb-3">
|
||||
<label for="description" class="form-label">Description</label>
|
||||
<input type="text" class="form-control" id="description" name="description" required>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Generate New Key</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Company Sites (API Keys)</h5>
|
||||
<div class="alert alert-info">
|
||||
<i class="fas fa-info-circle"></i> These API keys can be used to authenticate windows agents. You can
|
||||
<a href="{{ url_for('auth.download_agent', company_id=company.id) }}">download a pre-configured agent</a>
|
||||
with your selected API key.
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped" id="apiKeysTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Description</th>
|
||||
<th>Key</th>
|
||||
<th>Created</th>
|
||||
<th>Last Used</th>
|
||||
<th>Created By</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for api_key in api_keys %}
|
||||
<tr>
|
||||
<td>{{ api_key.description }}</td>
|
||||
<td>
|
||||
<div class="input-group">
|
||||
<input type="text" class="form-control" value="{{ api_key.key }}" readonly>
|
||||
<button class="btn btn-outline-secondary copy-btn" type="button" data-key="{{ api_key.key }}">
|
||||
Copy
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
<td>{{ api_key.created_at.strftime('%Y-%m-%d %H:%M:%S') }}</td>
|
||||
<td>{{ api_key.last_used.strftime('%Y-%m-%d %H:%M:%S') if api_key.last_used else 'Never' }}</td>
|
||||
<td>{{ api_key.user.username }}</td>
|
||||
<td>
|
||||
<form action="{{ url_for('auth.delete_company_api_key', company_id=company.id, key_id=api_key.id) }}" method="POST"
|
||||
style="display:inline" onsubmit="return confirm('Are you sure you want to delete this API key?');">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="btn btn-danger btn-sm">Delete</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="mt-3">
|
||||
<a href="{{ url_for('auth.manage_companies') }}" class="btn btn-secondary">Back to Companies</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<!-- DataTables JS -->
|
||||
<script src="{{ url_for('static', filename='js/jquery.dataTables.min.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/dataTables.bootstrap5.min.js') }}"></script>
|
||||
|
||||
<script>
|
||||
$(document).ready(function() {
|
||||
$('#apiKeysTable').DataTable({
|
||||
"pageLength": 10,
|
||||
"order": [[2, "desc"]]
|
||||
});
|
||||
|
||||
document.querySelectorAll('.copy-btn').forEach(button => {
|
||||
button.addEventListener('click', function() {
|
||||
const key = this.dataset.key;
|
||||
navigator.clipboard.writeText(key).then(() => {
|
||||
this.textContent = 'Copied!';
|
||||
setTimeout(() => this.textContent = 'Copy', 2000);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
126
templates/auth/company_users.html
Normal file
126
templates/auth/company_users.html
Normal file
@@ -0,0 +1,126 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block head %}
|
||||
<!-- DataTables CSS -->
|
||||
<link href="{{ url_for('static', filename='css/dataTables.bootstrap5.min.css') }}" rel="stylesheet">
|
||||
<link href="{{ url_for('static', filename='css/buttons.bootstrap5.min.css') }}" rel="stylesheet">
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mt-4">
|
||||
<h2>{{ company.name }} - User Management</h2>
|
||||
|
||||
<div class="card mb-4">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<h5 class="card-title">Company Users</h5>
|
||||
<div>
|
||||
<a href="{{ url_for('auth.download_agent', company_id=company.id) }}" class="btn btn-success me-2">
|
||||
<i class="fas fa-download"></i> Download Agent
|
||||
</a>
|
||||
<a href="{{ url_for('auth.create_company_user', company_id=company.id) }}" class="btn btn-success me-2">Create New User</a>
|
||||
<a href="{{ url_for('auth.add_company_user', company_id=company.id) }}" class="btn btn-primary">Add Existing User</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-responsive mt-3">
|
||||
<table class="table table-striped" id="companyUsersTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Username</th>
|
||||
<th>Email</th>
|
||||
<th>Role</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for uc in user_companies %}
|
||||
<tr>
|
||||
<td>{{ uc.user.username }}</td>
|
||||
<td>{{ uc.user.email }}</td>
|
||||
<td>
|
||||
{% if current_user.is_global_admin() or current_user.is_company_admin(company.id) %}
|
||||
<div class="dropdown">
|
||||
<button class="btn btn-sm btn-outline-secondary dropdown-toggle" type="button" id="roleDropdown{{ uc.id }}" data-bs-toggle="dropdown" data-bs-auto-close="true" aria-expanded="false">
|
||||
{{ uc.role }}
|
||||
</button>
|
||||
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="roleDropdown{{ uc.id }}">
|
||||
<li>
|
||||
<form action="{{ url_for('auth.change_company_user_role', company_id=company.id, user_id=uc.user_id) }}" method="POST">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="role" value="User">
|
||||
<button type="submit" class="dropdown-item {% if uc.role == 'User' %}active{% endif %}">User</button>
|
||||
</form>
|
||||
</li>
|
||||
<li>
|
||||
<form action="{{ url_for('auth.change_company_user_role', company_id=company.id, user_id=uc.user_id) }}" method="POST">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="role" value="CompanyAdmin">
|
||||
<button type="submit" class="dropdown-item {% if uc.role == 'CompanyAdmin' %}active{% endif %}">Company Admin</button>
|
||||
</form>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
{% else %}
|
||||
{{ uc.role }}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<form action="{{ url_for('auth.remove_company_user', company_id=company.id, user_id=uc.user_id) }}" method="POST" style="display:inline">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="btn btn-sm btn-danger"
|
||||
{% if uc.role == 'CompanyAdmin' and not current_user.is_global_admin() %}disabled{% endif %}
|
||||
onclick="return confirm('Are you sure you want to remove this user from the company?')">
|
||||
Remove
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="mt-3">
|
||||
<a href="{{ url_for('auth.manage_companies') }}" class="btn btn-secondary">Back to Companies</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<!-- DataTables JS -->
|
||||
<script src="{{ url_for('static', filename='js/jquery.dataTables.min.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/dataTables.bootstrap5.min.js') }}"></script>
|
||||
|
||||
<script>
|
||||
$(document).ready(function() {
|
||||
// Initialize DataTable
|
||||
$('#companyUsersTable').DataTable({
|
||||
"pageLength": 10,
|
||||
"order": [[0, "asc"]]
|
||||
});
|
||||
|
||||
// Fix dropdown position for items near the bottom of the screen
|
||||
$('.dropdown-toggle').on('click', function() {
|
||||
var $button = $(this);
|
||||
var $dropdownMenu = $button.next('.dropdown-menu');
|
||||
|
||||
// Get positions
|
||||
var buttonOffset = $button.offset();
|
||||
var buttonHeight = $button.outerHeight();
|
||||
var dropdownHeight = $dropdownMenu.outerHeight();
|
||||
|
||||
// Calculate if dropdown would go off screen
|
||||
var bottomSpace = $(window).height() - (buttonOffset.top - $(window).scrollTop() + buttonHeight);
|
||||
if (bottomSpace < dropdownHeight) {
|
||||
// Not enough space below, make it open upwards
|
||||
$(this).parent().addClass('dropup');
|
||||
} else {
|
||||
$(this).parent().removeClass('dropup');
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
26
templates/auth/create_company.html
Normal file
26
templates/auth/create_company.html
Normal file
@@ -0,0 +1,26 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<div class="container mt-4">
|
||||
<h2>Create New Company</h2>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<form method="POST" action="{{ url_for('auth.create_company') }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="mb-3">
|
||||
<label for="name" class="form-label">Company Name</label>
|
||||
<input type="text" class="form-control" id="name" name="name" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="description" class="form-label">Description</label>
|
||||
<textarea class="form-control" id="description" name="description" rows="3"></textarea>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<a href="{{ url_for('auth.manage_companies') }}" class="btn btn-secondary me-2">Cancel</a>
|
||||
<button type="submit" class="btn btn-primary">Create Company</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
30
templates/auth/create_company_user.html
Normal file
30
templates/auth/create_company_user.html
Normal file
@@ -0,0 +1,30 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<div class="container mt-4">
|
||||
<h2>{{ company.name }} - Create New User</h2>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<form method="POST" action="{{ url_for('auth.create_company_user', company_id=company.id) }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="mb-3">
|
||||
<label for="username" class="form-label">Username</label>
|
||||
<input type="text" class="form-control" id="username" name="username" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="email" class="form-label">Email Address</label>
|
||||
<input type="email" class="form-control" id="email" name="email" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="password" class="form-label">Password</label>
|
||||
<input type="password" class="form-control" id="password" name="password" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<a href="{{ url_for('auth.company_users', company_id=company.id) }}" class="btn btn-secondary me-2">Cancel</a>
|
||||
<button type="submit" class="btn btn-primary">Create User</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
89
templates/auth/download_agent.html
Normal file
89
templates/auth/download_agent.html
Normal file
@@ -0,0 +1,89 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Download Agent - {{ company.name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mt-4">
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<div class="card">
|
||||
<div class="card-header bg-primary text-white">
|
||||
<h5 class="card-title mb-0">Download Agent for {{ company.name }}</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="alert alert-info" role="alert">
|
||||
<i class="fas fa-info-circle"></i>
|
||||
Configure and download the Windows monitoring agent with your company's API key pre-configured.
|
||||
</div>
|
||||
|
||||
<div class="card mb-4">
|
||||
<div class="card-header bg-info text-white">
|
||||
<h5 class="mb-0">Agent Configuration</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="POST" action="{{ url_for('auth.download_agent', company_id=company.id) }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="mb-3">
|
||||
<label for="api_key" class="form-label">Select API Key</label>
|
||||
<select class="form-select" id="api_key" name="api_key" required>
|
||||
<option value="">Select API Key</option>
|
||||
{% for api_key in api_keys %}
|
||||
<option value="{{ api_key.id }}">
|
||||
{{ api_key.key[:8] }}...{{ api_key.key[-8:] }}
|
||||
{% if api_key.description %}({{ api_key.description }}){% endif %}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
{% if not api_keys %}
|
||||
<div class="form-text text-warning">
|
||||
No API keys found. <a href="{{ url_for('auth.company_api_keys', company_id=company.id) }}">Create an API key</a> first.
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="server_url" class="form-label">Server URL</label>
|
||||
<input type="url" class="form-control" id="server_url" name="server_url" value="{{ default_url }}" required>
|
||||
<div class="form-text">
|
||||
The URL where the agent will send login events. Usually the same URL as this website.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="install_dir" class="form-label">Installation Directory (Optional)</label>
|
||||
<input type="text" class="form-control" id="install_dir" name="install_dir" placeholder="C:\ProgramData\UserSessionMon">
|
||||
<div class="form-text">
|
||||
Custom installation directory for the agent. Leave empty to use the default path.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary" {% if not api_keys %}disabled{% endif %}>
|
||||
<i class="fas fa-download"></i> Download Agent
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header bg-info text-white">
|
||||
<h5 class="mb-0">Installation Instructions</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<ol>
|
||||
<li>Download the agent package using the form above.</li>
|
||||
<li>Extract the ZIP file to a folder on your Windows computer.</li>
|
||||
<li>Run the agent as administrator to install it:
|
||||
<code>winagentUSM.exe --service install</code>
|
||||
</li>
|
||||
<li>The service will start automatically and begin monitoring login events.</li>
|
||||
<li>Events will be sent to this server using the specified API key.</li>
|
||||
</ol>
|
||||
<p><strong>Note:</strong> The agent requires administrator privileges to install and run as a Windows service.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
26
templates/auth/edit_company.html
Normal file
26
templates/auth/edit_company.html
Normal file
@@ -0,0 +1,26 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<div class="container mt-4">
|
||||
<h2>Edit Company</h2>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<form method="POST" action="{{ url_for('auth.edit_company', company_id=company.id) }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="mb-3">
|
||||
<label for="name" class="form-label">Company Name</label>
|
||||
<input type="text" class="form-control" id="name" name="name" value="{{ company.name }}" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="description" class="form-label">Description</label>
|
||||
<textarea class="form-control" id="description" name="description" rows="3">{{ company.description }}</textarea>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<a href="{{ url_for('auth.manage_companies') }}" class="btn btn-secondary me-2">Cancel</a>
|
||||
<button type="submit" class="btn btn-primary">Update Company</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
405
templates/auth/error_logs.html
Normal file
405
templates/auth/error_logs.html
Normal file
@@ -0,0 +1,405 @@
|
||||
{% extends "base.html" %}
|
||||
{% block head %}
|
||||
<!-- DataTables CSS -->
|
||||
<link href="{{ url_for('static', filename='css/dataTables.bootstrap5.min.css') }}" rel="stylesheet">
|
||||
<link href="{{ url_for('static', filename='css/buttons.bootstrap5.min.css') }}" rel="stylesheet">
|
||||
<link href="{{ url_for('static', filename='css/buttons.dataTables.min.css') }}" rel="stylesheet">
|
||||
|
||||
<style>
|
||||
.log-level-CRITICAL { color: #dc3545; font-weight: bold; }
|
||||
.log-level-ERROR { color: #dc3545; }
|
||||
.log-level-WARNING { color: #ffc107; }
|
||||
.log-level-INFO { color: #0dcaf0; }
|
||||
.log-level-DEBUG { color: #6c757d; }
|
||||
|
||||
.log-message {
|
||||
max-width: 400px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.exception-toggle {
|
||||
cursor: pointer;
|
||||
color: #0d6efd;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.exception-details {
|
||||
background-color: #343a40;
|
||||
border: 1px solid #495057;
|
||||
border-radius: 0.375rem;
|
||||
padding: 1rem;
|
||||
margin-top: 0.5rem;
|
||||
font-family: monospace;
|
||||
font-size: 0.875rem;
|
||||
white-space: pre-wrap;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.filter-form {
|
||||
background-color: #343a40;
|
||||
border-radius: 0.375rem;
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.stats-cards {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background-color: #343a40;
|
||||
border: 1px solid #495057;
|
||||
border-radius: 0.375rem;
|
||||
padding: 1rem;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.stat-number {
|
||||
font-size: 2rem;
|
||||
font-weight: bold;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
color: #adb5bd;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block title %}Error Logs{% endblock %}
|
||||
{% block content %}
|
||||
<div class="container-fluid mt-4">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2>Application Error Logs</h2>
|
||||
<div>
|
||||
<button type="button" class="btn btn-outline-danger btn-sm" data-bs-toggle="modal" data-bs-target="#clearLogsModal">
|
||||
Clear Old Logs
|
||||
</button>
|
||||
<button id="refresh-logs" class="btn btn-outline-secondary btn-sm">
|
||||
<i class="fas fa-refresh"></i> Refresh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Statistics Cards -->
|
||||
<div class="row stats-cards">
|
||||
<div class="col-md">
|
||||
<div class="stat-card">
|
||||
<div class="stat-number text-info" id="debug-count">{{ error_logs | selectattr('level', 'equalto', 'DEBUG') | list | length }}</div>
|
||||
<div class="stat-label">Debug</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md">
|
||||
<div class="stat-card">
|
||||
<div class="stat-number text-info" id="info-count">{{ error_logs | selectattr('level', 'equalto', 'INFO') | list | length }}</div>
|
||||
<div class="stat-label">Info</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md">
|
||||
<div class="stat-card">
|
||||
<div class="stat-number text-danger" id="critical-count">{{ error_logs | selectattr('level', 'equalto', 'CRITICAL') | list | length }}</div>
|
||||
<div class="stat-label">Critical</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md">
|
||||
<div class="stat-card">
|
||||
<div class="stat-number text-danger" id="error-count">{{ error_logs | selectattr('level', 'equalto', 'ERROR') | list | length }}</div>
|
||||
<div class="stat-label">Errors</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md">
|
||||
<div class="stat-card">
|
||||
<div class="stat-number text-warning" id="warning-count">{{ error_logs | selectattr('level', 'equalto', 'WARNING') | list | length }}</div>
|
||||
<div class="stat-label">Warnings</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md">
|
||||
<div class="stat-card">
|
||||
<div class="stat-number text-info" id="total-count">{{ error_logs | length }}</div>
|
||||
<div class="stat-label">Total Logs</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filter Form -->
|
||||
<div class="filter-form">
|
||||
<form method="GET" action="{{ url_for('auth.view_error_logs') }}">
|
||||
<div class="row align-items-end">
|
||||
<div class="col-md-3">
|
||||
<label for="start_date" class="form-label">Start Date:</label>
|
||||
<input type="date" class="form-control" id="start_date" name="start_date" value="{{ start_date }}">
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label for="end_date" class="form-label">End Date:</label>
|
||||
<input type="date" class="form-control" id="end_date" name="end_date" value="{{ end_date }}">
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label for="level" class="form-label">Log Level:</label>
|
||||
<select class="form-select" id="level" name="level">
|
||||
<option value="">All Levels</option>
|
||||
{% for level in available_levels %}
|
||||
<option value="{{ level }}" {% if level == level_filter %}selected{% endif %}>{{ level }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<button type="submit" class="btn btn-primary">Apply Filters</button>
|
||||
<a href="{{ url_for('auth.view_error_logs') }}" class="btn btn-outline-secondary">Reset</a>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Error Logs Table -->
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<table id="errorLogsTable" class="table table-striped table-bordered" style="width:100%">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Timestamp</th>
|
||||
<th>Level</th>
|
||||
<th>Logger</th>
|
||||
<th>Message</th>
|
||||
<th>User</th>
|
||||
<th>IP Address</th>
|
||||
<th>Request ID</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for log in error_logs %}
|
||||
<tr>
|
||||
<td data-order="{{ log.timestamp.strftime('%Y%m%d%H%M%S') }}">
|
||||
{{ log.timestamp.strftime('%Y-%m-%d %H:%M:%S') }}
|
||||
</td>
|
||||
<td>
|
||||
<span class="log-level-{{ log.level }}">{{ log.level }}</span>
|
||||
</td>
|
||||
<td>{{ log.logger_name or 'N/A' }}</td>
|
||||
<td>
|
||||
<div class="log-message" title="{{ log.message }}">{{ log.message }}</div>
|
||||
{% if log.exception %}
|
||||
<small class="exception-toggle" onclick="toggleException({{ log.id }})">
|
||||
View Exception
|
||||
</small>
|
||||
<div id="exception-{{ log.id }}" class="exception-details" style="display: none;">
|
||||
{{ log.exception }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ log.user_id or 'N/A' }}</td>
|
||||
<td>{{ log.remote_addr or 'N/A' }}</td>
|
||||
<td>{{ log.request_id or 'N/A' }}</td>
|
||||
<td>
|
||||
<button class="btn btn-sm btn-outline-info" onclick="viewLogDetail({{ log.id }})">
|
||||
Details
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Clear Logs Modal -->
|
||||
<div class="modal fade" id="clearLogsModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Clear Old Error Logs</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>This will permanently delete error logs older than the specified number of days.</p>
|
||||
<div class="mb-3">
|
||||
<label for="daysToKeep" class="form-label">Keep logs for (days):</label>
|
||||
<input type="number" class="form-control" id="daysToKeep" value="30" min="1" max="365">
|
||||
<div class="form-text">Logs older than this many days will be deleted.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-danger" onclick="clearOldLogs()">Clear Logs</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Log Detail Modal -->
|
||||
<div class="modal fade" id="logDetailModal" tabindex="-1">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Error Log Details</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div id="logDetailContent">
|
||||
<!-- Content loaded via JavaScript -->
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<!-- DataTables JS -->
|
||||
<script src="{{ url_for('static', filename='js/jquery.dataTables.min.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/dataTables.bootstrap5.min.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/dataTables.buttons.min.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/buttons.bootstrap5.min.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/buttons.html5.min.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/jszip.min.js') }}"></script>
|
||||
|
||||
<script>
|
||||
$(document).ready(function() {
|
||||
// Initialize DataTable
|
||||
var table = $('#errorLogsTable').DataTable({
|
||||
pageLength: 25,
|
||||
lengthMenu: [[25, 50, 100, 200, -1], [25, 50, 100, 200, "All"]],
|
||||
order: [[0, 'desc']], // Sort by timestamp descending
|
||||
dom: '<"row"<"col-sm-12 col-md-6"l><"col-sm-12 col-md-6"f>>' +
|
||||
'<"row"<"col-sm-12"tr>>' +
|
||||
'<"row"<"col-sm-12 col-md-5"i><"col-sm-12 col-md-7"p>>',
|
||||
buttons: [
|
||||
{
|
||||
extend: 'csv',
|
||||
text: 'Export CSV',
|
||||
className: 'btn btn-secondary btn-sm',
|
||||
filename: 'error_logs_' + new Date().toISOString().split('T')[0]
|
||||
}
|
||||
],
|
||||
language: {
|
||||
search: "Search logs:",
|
||||
lengthMenu: "Show _MENU_ logs per page",
|
||||
info: "Showing _START_ to _END_ of _TOTAL_ logs",
|
||||
paginate: {
|
||||
first: "First",
|
||||
last: "Last",
|
||||
next: "Next",
|
||||
previous: "Previous"
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Stats card click events
|
||||
$('#debug-count').closest('.stat-card').on('click', function() {
|
||||
table.column(1).search('Debug').draw();
|
||||
});
|
||||
$('#info-count').closest('.stat-card').on('click', function() {
|
||||
table.column(1).search('Info').draw();
|
||||
});
|
||||
$('#critical-count').closest('.stat-card').on('click', function() {
|
||||
table.column(1).search('CRITICAL').draw();
|
||||
});
|
||||
$('#error-count').closest('.stat-card').on('click', function() {
|
||||
table.column(1).search('ERROR').draw();
|
||||
});
|
||||
$('#warning-count').closest('.stat-card').on('click', function() {
|
||||
table.column(1).search('WARNING').draw();
|
||||
});
|
||||
$('#total-count').closest('.stat-card').on('click', function() {
|
||||
table.column(1).search('').draw();
|
||||
});
|
||||
|
||||
// Refresh button
|
||||
$('#refresh-logs').on('click', function() {
|
||||
location.reload();
|
||||
});
|
||||
});
|
||||
|
||||
function toggleException(logId) {
|
||||
var element = document.getElementById('exception-' + logId);
|
||||
if (element.style.display === 'none') {
|
||||
element.style.display = 'block';
|
||||
} else {
|
||||
element.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
function viewLogDetail(logId) {
|
||||
fetch(`{{ url_for('auth.view_error_log_detail', log_id=0) }}`.replace('0', logId))
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
var content = `
|
||||
<div class="row">
|
||||
<div class="col-md-6"><strong>ID:</strong> ${data.id}</div>
|
||||
<div class="col-md-6"><strong>Level:</strong> <span class="log-level-${data.level}">${data.level}</span></div>
|
||||
</div>
|
||||
<div class="row mt-2">
|
||||
<div class="col-md-6"><strong>Logger:</strong> ${data.logger_name || 'N/A'}</div>
|
||||
<div class="col-md-6"><strong>Timestamp:</strong> ${new Date(data.timestamp).toLocaleString()}</div>
|
||||
</div>
|
||||
<div class="row mt-2">
|
||||
<div class="col-md-6"><strong>User ID:</strong> ${data.user_id || 'N/A'}</div>
|
||||
<div class="col-md-6"><strong>IP Address:</strong> ${data.remote_addr || 'N/A'}</div>
|
||||
</div>
|
||||
<div class="row mt-2">
|
||||
<div class="col-md-6"><strong>Request ID:</strong> ${data.request_id || 'N/A'}</div>
|
||||
<div class="col-md-6"><strong>File:</strong> ${data.pathname || 'N/A'}${data.lineno ? ':' + data.lineno : ''}</div>
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<strong>Message:</strong>
|
||||
<div class="exception-details mt-1">${data.message}</div>
|
||||
</div>
|
||||
${data.exception ? `
|
||||
<div class="mt-3">
|
||||
<strong>Exception:</strong>
|
||||
<div class="exception-details mt-1">${data.exception}</div>
|
||||
</div>
|
||||
` : ''}
|
||||
`;
|
||||
|
||||
document.getElementById('logDetailContent').innerHTML = content;
|
||||
new bootstrap.Modal(document.getElementById('logDetailModal')).show();
|
||||
})
|
||||
.catch(error => {
|
||||
alert('Error loading log details: ' + error);
|
||||
});
|
||||
}
|
||||
|
||||
function clearOldLogs() {
|
||||
var daysToKeep = document.getElementById('daysToKeep').value;
|
||||
|
||||
if (!confirm(`Are you sure you want to delete all error logs older than ${daysToKeep} days? This action cannot be undone.`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
fetch(`{{ url_for('auth.clear_error_logs') }}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': $('meta[name=csrf-token]').attr('content')
|
||||
},
|
||||
body: JSON.stringify({
|
||||
days_to_keep: parseInt(daysToKeep)
|
||||
})
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
alert(data.message);
|
||||
location.reload();
|
||||
} else {
|
||||
alert('Error: ' + data.error);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
alert('Error clearing logs: ' + error);
|
||||
});
|
||||
|
||||
// Close modal
|
||||
bootstrap.Modal.getInstance(document.getElementById('clearLogsModal')).hide();
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
133
templates/auth/login.html
Normal file
133
templates/auth/login.html
Normal file
@@ -0,0 +1,133 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Login{% endblock %}
|
||||
{% block content %}
|
||||
<div class="container mt-4">
|
||||
<div class="row">
|
||||
<div class="col-md-6 offset-md-3">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3>Login</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if error_message %}
|
||||
<div class="alert alert-danger">{{ error_message }}</div>
|
||||
{% endif %}
|
||||
<form id="loginForm" method="POST" action="">
|
||||
{{ form.hidden_tag() }}
|
||||
<div class="mb-3">
|
||||
{{ form.email.label(class="form-label") }}
|
||||
{{ form.email(class="form-control") }}
|
||||
{% if form.email.errors %}
|
||||
{% for error in form.email.errors %}
|
||||
<div class="text-danger">{{ error }}</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
{{ form.password.label(class="form-label") }}
|
||||
{{ form.password(class="form-control") }}
|
||||
{% if form.password.errors %}
|
||||
{% for error in form.password.errors %}
|
||||
<div class="text-danger">{{ error }}</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="mb-3 form-check">
|
||||
{{ form.remember(class="form-check-input") }}
|
||||
{{ form.remember.label(class="form-check-label") }}
|
||||
</div>
|
||||
{{ form.submit(class="btn btn-primary") }}
|
||||
</form>
|
||||
</div>
|
||||
{% if allow_registration %}
|
||||
<div class="card-footer">
|
||||
<small>Need an account? <a href="{{ url_for('auth.register') }}">Sign up now</a></small>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error Modal -->
|
||||
<div class="modal fade" id="loginErrorModal" tabindex="-1" aria-labelledby="loginErrorModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="loginErrorModalLabel">Login Error</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body" id="loginErrorModalBody">
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% include 'auth/mfa_modal.html' %}
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
// Get CSRF token from the rendered template
|
||||
const csrfToken = '{{ csrf_token() }}';
|
||||
|
||||
document.getElementById('loginForm').addEventListener('submit', function(e) {
|
||||
e.preventDefault();
|
||||
fetch('{{ url_for("auth.login") }}', {
|
||||
method: 'POST',
|
||||
body: new FormData(this),
|
||||
headers: {
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
'X-CSRFToken': csrfToken
|
||||
}
|
||||
})
|
||||
.then(response => response.json().then(data => ({status: response.status, body: data})))
|
||||
.then(({status, body}) => {
|
||||
if (body.require_mfa) {
|
||||
const modal = new bootstrap.Modal(document.getElementById('mfaModal'));
|
||||
modal.show();
|
||||
} else if (body.require_mfa_setup) {
|
||||
// Redirect to MFA setup for required users
|
||||
window.location.href = '{{ url_for("auth.setup_mfa") }}';
|
||||
} else if (body.redirect) {
|
||||
window.location.href = body.redirect;
|
||||
} else if (body.error) {
|
||||
document.getElementById('loginErrorModalBody').textContent = body.error;
|
||||
const errorModal = new bootstrap.Modal(document.getElementById('loginErrorModal'));
|
||||
errorModal.show();
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
document.getElementById('loginErrorModalBody').textContent = 'An unexpected error occurred.';
|
||||
const errorModal = new bootstrap.Modal(document.getElementById('loginErrorModal'));
|
||||
errorModal.show();
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById('mfaForm').addEventListener('submit', function(e) {
|
||||
e.preventDefault();
|
||||
fetch('{{ url_for("auth.verify_mfa") }}', {
|
||||
method: 'POST',
|
||||
body: new FormData(this),
|
||||
headers: {
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
'X-CSRFToken': csrfToken
|
||||
}
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.redirect) {
|
||||
window.location.href = data.redirect;
|
||||
} else {
|
||||
alert('Invalid MFA code');
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
alert('Failed to verify MFA code');
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
92
templates/auth/manage_companies.html
Normal file
92
templates/auth/manage_companies.html
Normal file
@@ -0,0 +1,92 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block head %}
|
||||
<!-- DataTables CSS -->
|
||||
<link href="{{ url_for('static', filename='css/dataTables.bootstrap5.min.css') }}" rel="stylesheet">
|
||||
<link href="{{ url_for('static', filename='css/buttons.bootstrap5.min.css') }}" rel="stylesheet">
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mt-4">
|
||||
<h2>Company Management</h2>
|
||||
|
||||
<div class="card mb-4">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<h5 class="card-title">Companies</h5>
|
||||
<a href="{{ url_for('auth.create_company') }}" class="btn btn-primary">Create New Company</a>
|
||||
</div>
|
||||
|
||||
<div class="table-responsive mt-3">
|
||||
<table class="table table-striped" id="companiesTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Description</th>
|
||||
<th>Created</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for company in companies %}
|
||||
<tr>
|
||||
<td>{{ company.name }}</td>
|
||||
<td>{{ company.description }}</td>
|
||||
<td>{{ company.created_at.strftime('%Y-%m-%d %H:%M:%S') }}</td>
|
||||
<td>
|
||||
<div class="btn-group" role="group">
|
||||
<a href="{{ url_for('auth.edit_company', company_id=company.id) }}" class="btn btn-sm btn-primary">Edit</a>
|
||||
<a href="{{ url_for('auth.company_users', company_id=company.id) }}" class="btn btn-sm btn-info">Manage Users</a>
|
||||
<a href="{{ url_for('auth.company_api_keys', company_id=company.id) }}" class="btn btn-sm btn-secondary">Sites (API Key)</a>
|
||||
<button type="button" class="btn btn-sm btn-danger" data-bs-toggle="modal" data-bs-target="#deleteCompanyModal{{ company.id }}">
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Delete Confirmation Modal -->
|
||||
<div class="modal fade" id="deleteCompanyModal{{ company.id }}" tabindex="-1" aria-labelledby="deleteCompanyModalLabel{{ company.id }}" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="deleteCompanyModalLabel{{ company.id }}">Confirm Deletion</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
Are you sure you want to delete the company: <strong>{{ company.name }}</strong>?
|
||||
<p class="text-danger mt-2">This action cannot be undone and will remove this company from the system. The company's API keys will be deleted and all users will be removed from this company (but users will not be deleted from the system).</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<form action="{{ url_for('auth.delete_company', company_id=company.id) }}" method="POST">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="btn btn-danger">Delete Company</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<!-- DataTables JS -->
|
||||
<script src="{{ url_for('static', filename='js/jquery.dataTables.min.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/dataTables.bootstrap5.min.js') }}"></script>
|
||||
|
||||
<script>
|
||||
$(document).ready(function() {
|
||||
$('#companiesTable').DataTable({
|
||||
"pageLength": 10,
|
||||
"order": [[0, "asc"]]
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
839
templates/auth/manage_users.html
Normal file
839
templates/auth/manage_users.html
Normal file
@@ -0,0 +1,839 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block head %}
|
||||
<!-- DataTables CSS -->
|
||||
<link href="{{ url_for('static', filename='css/dataTables.bootstrap5.min.css') }}" rel="stylesheet">
|
||||
<link href="{{ url_for('static', filename='css/buttons.bootstrap5.min.css') }}" rel="stylesheet">
|
||||
{% endblock %}
|
||||
|
||||
{% block title %}Manage Users{% endblock %}
|
||||
{% block content %}
|
||||
<div class="container mt-4">
|
||||
<h2>Manage Users</h2>
|
||||
|
||||
<div class="card mb-4">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Create New User</h5>
|
||||
<form method="POST">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="row">
|
||||
<div class="col-md-4 mb-3">
|
||||
<label for="username" class="form-label">Username</label>
|
||||
<input type="text" class="form-control" id="username" name="username" required>
|
||||
</div>
|
||||
<div class="col-md-4 mb-3">
|
||||
<label for="email" class="form-label">Email</label>
|
||||
<input type="email" class="form-control" id="email" name="email" required>
|
||||
</div>
|
||||
<div class="col-md-4 mb-3">
|
||||
<label for="password" class="form-label">Password</label>
|
||||
<input type="password" class="form-control" id="password" name="password" required>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-4 mb-3">
|
||||
<label for="role" class="form-label">Role</label>
|
||||
<select class="form-select" id="role" name="role" required>
|
||||
<option value="User">User</option>
|
||||
{% if current_user.role == 'GlobalAdmin' %}
|
||||
<option value="Admin">Admin</option>
|
||||
<option value="GlobalAdmin">Global Admin</option>
|
||||
{% endif %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-4 mb-3">
|
||||
<div class="form-check mt-4">
|
||||
<input type="checkbox" class="form-check-input" id="is_active" name="is_active">
|
||||
<label class="form-check-label" for="is_active">Account Active</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Create User</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Existing Users</h5>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped" id="usersTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Username</th>
|
||||
<th>Email</th>
|
||||
<th>Role</th>
|
||||
<th>Companies</th>
|
||||
<th>Status</th>
|
||||
<th>2FA</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for user in users %}
|
||||
<tr>
|
||||
<td>{{ user.username }}</td>
|
||||
<td>{{ user.email }}</td>
|
||||
<td>
|
||||
{% if current_user.role == 'GlobalAdmin' and user.id != current_user.id %}
|
||||
<!-- Role dropdown for Global Admins -->
|
||||
<div class="dropdown">
|
||||
<button class="btn btn-sm dropdown-toggle
|
||||
{% if user.role == 'GlobalAdmin' %}btn-danger
|
||||
{% elif user.role == 'Admin' %}btn-warning
|
||||
{% else %}btn-info{% endif %}"
|
||||
type="button" data-bs-toggle="dropdown">
|
||||
{{ user.role }}
|
||||
</button>
|
||||
<ul class="dropdown-menu">
|
||||
<li><a class="dropdown-item" href="#" onclick="changeUserRole({{ user.id }}, 'User')">User</a></li>
|
||||
<li><a class="dropdown-item" href="#" onclick="changeUserRole({{ user.id }}, 'Admin')">Admin</a></li>
|
||||
<li><a class="dropdown-item" href="#" onclick="changeUserRole({{ user.id }}, 'GlobalAdmin')">Global Admin</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
{% else %}
|
||||
<!-- Static display for non-Global Admins or current user -->
|
||||
{% if user.role == 'GlobalAdmin' %}
|
||||
<span class="badge bg-danger">Global Admin</span>
|
||||
{% elif user.role == 'Admin' %}
|
||||
<span class="badge bg-warning">Admin</span>
|
||||
{% else %}
|
||||
<span class="badge bg-info">User</span>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<button class="btn btn-sm btn-outline-secondary"
|
||||
data-bs-toggle="modal"
|
||||
data-bs-target="#companiesModal{{ user.id }}">
|
||||
{% if user.filtered_companies %}
|
||||
{{ user.filtered_companies|length }} Companies
|
||||
{% else %}
|
||||
No Companies
|
||||
{% endif %}
|
||||
</button>
|
||||
|
||||
<!-- Company Management Modal -->
|
||||
<div class="modal fade" id="companiesModal{{ user.id }}" tabindex="-1">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Manage Companies for {{ user.username }}</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<!-- Current Companies -->
|
||||
<h6>Current Companies:</h6>
|
||||
{% if current_user.role == 'Admin' and user.companies|length > user.filtered_companies|length %}
|
||||
<div class="alert alert-info" role="alert">
|
||||
<small><i class="fas fa-info-circle"></i>
|
||||
This user belongs to {{ user.companies|length }} companies total, but you can only see and manage {{ user.filtered_companies|length }} companies that you also have access to.</small>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="table-responsive mb-3">
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Company</th>
|
||||
<th>Role</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="currentCompanies{{ user.id }}">
|
||||
{% if user.filtered_companies %}
|
||||
{% for uc in user.filtered_companies %}
|
||||
<tr id="company-row-{{ user.id }}-{{ uc.company_id }}">
|
||||
<td>{{ uc.company.name }}</td>
|
||||
<td>
|
||||
{% if current_user.role == 'GlobalAdmin' or (current_user.role == 'Admin' and user.role not in ['Admin', 'GlobalAdmin']) %}
|
||||
<select class="form-select form-select-sm"
|
||||
onchange="changeCompanyRole({{ user.id }}, {{ uc.company_id }}, this.value)">
|
||||
<option value="User" {% if uc.role == 'User' %}selected{% endif %}>User</option>
|
||||
{% if current_user.role == 'GlobalAdmin' %}
|
||||
<option value="CompanyAdmin" {% if uc.role == 'CompanyAdmin' %}selected{% endif %}>Company Admin</option>
|
||||
{% endif %}
|
||||
</select>
|
||||
{% else %}
|
||||
<span class="badge {% if uc.role == 'CompanyAdmin' %}bg-warning{% else %}bg-info{% endif %}">
|
||||
{{ uc.role }}
|
||||
</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if current_user.role == 'GlobalAdmin' or (current_user.role == 'Admin' and user.role not in ['Admin', 'GlobalAdmin']) %}
|
||||
<button class="btn btn-sm btn-danger"
|
||||
onclick="removeFromCompany({{ user.id }}, {{ uc.company_id }})">
|
||||
Remove
|
||||
</button>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<tr id="no-companies-row-{{ user.id }}">
|
||||
<td colspan="3" class="text-muted text-center">
|
||||
User is not associated with any companies.
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Add to Company Section -->
|
||||
{% if current_user.role == 'GlobalAdmin' or (current_user.role == 'Admin' and user.role not in ['Admin', 'GlobalAdmin']) %}
|
||||
<hr>
|
||||
<h6>Add to Company:</h6>
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-6">
|
||||
<select class="form-select" id="addCompanySelect{{ user.id }}">
|
||||
<option value="">Select a company...</option>
|
||||
<!-- Options will be populated by JavaScript -->
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<select class="form-select" id="addCompanyRole{{ user.id }}">
|
||||
<option value="User">User</option>
|
||||
{% if current_user.role == 'GlobalAdmin' %}
|
||||
<option value="CompanyAdmin">Company Admin</option>
|
||||
{% endif %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<button class="btn btn-primary" onclick="addToCompany({{ user.id }})">
|
||||
Add to Company
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create New Company Section (GlobalAdmin only) -->
|
||||
{% if current_user.role == 'GlobalAdmin' %}
|
||||
<div class="border-top pt-3">
|
||||
<h6>Create New Company:</h6>
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<input type="text" class="form-control"
|
||||
id="newCompanyName{{ user.id }}"
|
||||
placeholder="Company name...">
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<select class="form-select" id="newCompanyRole{{ user.id }}">
|
||||
<option value="User">User</option>
|
||||
<option value="CompanyAdmin">Company Admin</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<button class="btn btn-success" onclick="createAndAddCompany({{ user.id }})">
|
||||
Create & Add
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge {% if user.is_active %}bg-success{% else %}bg-danger{% endif %}">
|
||||
{{ 'Active' if user.is_active else 'Inactive' }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge {% if user.mfa_enabled %}bg-success{% else %}bg-secondary{% endif %}">
|
||||
{{ 'Enabled' if user.mfa_enabled else 'Disabled' }}
|
||||
</span>
|
||||
{% if current_user.role == 'GlobalAdmin' %}
|
||||
<br><small class="text-muted">
|
||||
Required:
|
||||
{% if user.mfa_required is none %}
|
||||
<span class="badge bg-info">Inherit</span>
|
||||
{% elif user.mfa_required %}
|
||||
<span class="badge bg-warning">Yes</span>
|
||||
{% else %}
|
||||
<span class="badge bg-success">No</span>
|
||||
{% endif %}
|
||||
</small>
|
||||
<br>
|
||||
<div class="btn-group btn-group-sm mt-1" role="group">
|
||||
<button type="button" class="btn btn-outline-info btn-xs"
|
||||
onclick="updateMfaRequirement({{ user.id }}, null)"
|
||||
{% if user.mfa_required is none %}disabled{% endif %}>
|
||||
Inherit
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-warning btn-xs"
|
||||
onclick="updateMfaRequirement({{ user.id }}, true)"
|
||||
{% if user.mfa_required == true %}disabled{% endif %}>
|
||||
Required
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-success btn-xs"
|
||||
onclick="updateMfaRequirement({{ user.id }}, false)"
|
||||
{% if user.mfa_required == false %}disabled{% endif %}>
|
||||
Not Required
|
||||
</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<div class="btn-group" role="group">
|
||||
{% if current_user.is_global_admin() or (current_user.role == 'Admin' and user.role != 'GlobalAdmin' and user.role != 'Admin') %}
|
||||
<button type="button" class="btn btn-sm btn-warning"
|
||||
onclick="toggleUserStatus({{ user.id }}, this)"
|
||||
{% if user.id == current_user.id %}disabled title="You cannot deactivate your own account"{% endif %}>
|
||||
{{ 'Deactivate' if user.is_active else 'Activate' }}
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-info"
|
||||
onclick="showResetPasswordModal({{ user.id }})"
|
||||
{% if user.id == current_user.id %}disabled title="Use your profile page to change your own password"{% endif %}>
|
||||
Reset Password
|
||||
</button>
|
||||
{% if user.mfa_secret %}
|
||||
<form action="{{ url_for('auth.admin_reset_mfa', user_id=user.id) }}"
|
||||
method="POST" style="display: inline;"
|
||||
onsubmit="return confirm('Are you sure you want to reset 2FA for this user? They will need to set it up again.');">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="btn btn-sm btn-secondary"
|
||||
{% if user.id == current_user.id %}disabled title="Use your profile page to manage your own 2FA"{% endif %}>
|
||||
Reset 2FA
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% if user.id != current_user.id %}
|
||||
<form action="{{ url_for('auth.delete_user', user_id=user.id) }}"
|
||||
method="POST" style="display: inline;"
|
||||
onsubmit="return confirm('Are you sure you want to delete this user?');">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="btn btn-sm btn-danger">Delete</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<button type="button" class="btn btn-sm btn-secondary" disabled title="You don't have permission to manage this user">
|
||||
No Actions Available
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Toast container for notifications -->
|
||||
<div class="toast-container position-fixed bottom-0 end-0 p-3">
|
||||
<!-- Toasts will be inserted here dynamically -->
|
||||
</div>
|
||||
|
||||
<!-- Reset Password Modal -->
|
||||
<div class="modal fade" id="resetPasswordModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Reset User Password</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<form id="resetPasswordForm" method="POST">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="modal-body">
|
||||
<div class="mb-3">
|
||||
<label for="new_password" class="form-label">New Password</label>
|
||||
<input type="password" class="form-control" id="new_password" name="new_password" required>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
||||
<button type="submit" class="btn btn-primary">Reset Password</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<!-- DataTables JS -->
|
||||
<script src="{{ url_for('static', filename='js/jquery.dataTables.min.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/dataTables.bootstrap5.min.js') }}"></script>
|
||||
|
||||
<script>
|
||||
// Get current user role for permission checks
|
||||
const currentUserRole = '{{ current_user.role }}';
|
||||
|
||||
// Function to create and display toast notifications
|
||||
function createToast(type, message) {
|
||||
const toastContainer = document.querySelector('.toast-container');
|
||||
|
||||
// Create toast element
|
||||
const toastEl = document.createElement('div');
|
||||
toastEl.className = `toast align-items-center text-white bg-${type} border-0`;
|
||||
toastEl.setAttribute('role', 'alert');
|
||||
toastEl.setAttribute('aria-live', 'assertive');
|
||||
toastEl.setAttribute('aria-atomic', 'true');
|
||||
|
||||
// Create toast content
|
||||
const toastContent = `
|
||||
<div class="d-flex">
|
||||
<div class="toast-body">
|
||||
${message}
|
||||
</div>
|
||||
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast" aria-label="Close"></button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
toastEl.innerHTML = toastContent;
|
||||
toastContainer.appendChild(toastEl);
|
||||
|
||||
// Create Bootstrap toast instance
|
||||
const toast = new bootstrap.Toast(toastEl, {
|
||||
delay: 5000,
|
||||
autohide: true
|
||||
});
|
||||
|
||||
// Remove toast element after it's hidden
|
||||
toastEl.addEventListener('hidden.bs.toast', () => {
|
||||
toastEl.remove();
|
||||
});
|
||||
|
||||
return toast;
|
||||
}
|
||||
|
||||
function toggleUserStatus(userId, button) {
|
||||
const baseUrl = '{{ url_for("auth.toggle_user_status", user_id=1) }}'.replace('/1', '/' + userId);
|
||||
fetch(baseUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': '{{ csrf_token() }}'
|
||||
}
|
||||
})
|
||||
.then(response => {
|
||||
if (response.ok) {
|
||||
return response.json().then(data => {
|
||||
// Show success message
|
||||
const toast = createToast('success', 'User status updated successfully');
|
||||
toast.show();
|
||||
setTimeout(() => location.reload(), 1000);
|
||||
});
|
||||
} else {
|
||||
return response.json().then(data => {
|
||||
throw new Error(data.error || 'Failed to toggle user status');
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
const toast = createToast('danger', error.message || 'Failed to toggle user status');
|
||||
toast.show();
|
||||
});
|
||||
}
|
||||
|
||||
function showResetPasswordModal(userId) {
|
||||
const modal = new bootstrap.Modal(document.getElementById('resetPasswordModal'));
|
||||
const baseUrl = '{{ url_for("auth.reset_user_password", user_id=1) }}'.replace('/1', '/' + userId);
|
||||
document.getElementById('resetPasswordForm').action = baseUrl;
|
||||
modal.show();
|
||||
}
|
||||
|
||||
// Add function to handle changing user roles
|
||||
function changeUserRole(userId, role) {
|
||||
if (confirm(`Are you sure you want to change this user's role to ${role}?`)) {
|
||||
const baseUrl = '{{ url_for("auth.change_user_role", user_id=1) }}'.replace('/1', '/' + userId);
|
||||
fetch(baseUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': '{{ csrf_token() }}'
|
||||
},
|
||||
body: JSON.stringify({ role: role })
|
||||
})
|
||||
.then(response => {
|
||||
if (response.ok) {
|
||||
location.reload();
|
||||
} else {
|
||||
response.json().then(data => {
|
||||
alert(data.error || 'Failed to change user role');
|
||||
}).catch(() => {
|
||||
alert('Failed to change user role');
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
alert('Failed to change user role');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Add function to handle MFA requirement updates
|
||||
function updateMfaRequirement(userId, mfaRequired) {
|
||||
const actionText = mfaRequired === null ? 'inherit from global setting' :
|
||||
mfaRequired ? 'require MFA' : 'not require MFA';
|
||||
|
||||
if (confirm(`Are you sure you want to set this user to ${actionText}?`)) {
|
||||
const baseUrl = '{{ url_for("auth.update_user_mfa_requirement", user_id=1) }}'.replace('/1', '/' + userId);
|
||||
fetch(baseUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': '{{ csrf_token() }}'
|
||||
},
|
||||
body: JSON.stringify({ mfa_required: mfaRequired })
|
||||
})
|
||||
.then(response => {
|
||||
if (response.ok) {
|
||||
return response.json().then(data => {
|
||||
const toast = createToast('success', data.message);
|
||||
toast.show();
|
||||
setTimeout(() => location.reload(), 1000);
|
||||
});
|
||||
} else {
|
||||
return response.json().then(data => {
|
||||
throw new Error(data.error || 'Failed to update MFA requirement');
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
const toast = createToast('danger', error.message || 'Failed to update MFA requirement');
|
||||
toast.show();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Company management functions
|
||||
function loadAvailableCompanies(userId) {
|
||||
const select = document.getElementById(`addCompanySelect${userId}`);
|
||||
|
||||
// If the select element doesn't exist (e.g., Admin viewing another Admin), skip loading
|
||||
if (!select) {
|
||||
console.log(`Add Company select not found for user ${userId} - user likely doesn't have permission to manage this user's companies`);
|
||||
return;
|
||||
}
|
||||
|
||||
fetch('{{ url_for("auth.get_companies_for_user_management") }}')
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then(data => {
|
||||
// Clear existing options except the first one
|
||||
select.innerHTML = '<option value="">Select a company...</option>';
|
||||
|
||||
// Handle the response format (companies array is in data.companies)
|
||||
const companies = data.companies || data || [];
|
||||
|
||||
companies.forEach(company => {
|
||||
const option = document.createElement('option');
|
||||
option.value = company.id;
|
||||
option.textContent = company.name;
|
||||
select.appendChild(option);
|
||||
});
|
||||
|
||||
console.log(`Loaded ${companies.length} companies for user ${userId}`);
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error loading companies:', error);
|
||||
const toast = createToast('danger', `Failed to load available companies: ${error.message}`);
|
||||
toast.show();
|
||||
});
|
||||
}
|
||||
|
||||
function addToCompany(userId) {
|
||||
const companySelect = document.getElementById(`addCompanySelect${userId}`);
|
||||
const roleSelect = document.getElementById(`addCompanyRole${userId}`);
|
||||
|
||||
const companyId = companySelect.value;
|
||||
const role = roleSelect.value;
|
||||
|
||||
if (!companyId) {
|
||||
const toast = createToast('warning', 'Please select a company');
|
||||
toast.show();
|
||||
return;
|
||||
}
|
||||
|
||||
const baseUrl = '{{ url_for("auth.add_user_to_company", user_id=1) }}'.replace('/1', '/' + userId);
|
||||
fetch(baseUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': '{{ csrf_token() }}'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
company_id: parseInt(companyId),
|
||||
role: role
|
||||
})
|
||||
})
|
||||
.then(response => {
|
||||
if (response.ok) {
|
||||
return response.json();
|
||||
} else {
|
||||
return response.json().then(data => {
|
||||
throw new Error(data.error || 'Failed to add user to company');
|
||||
});
|
||||
}
|
||||
})
|
||||
.then(data => {
|
||||
const toast = createToast('success', data.message);
|
||||
toast.show();
|
||||
|
||||
// Add new row to the current companies table
|
||||
const tbody = document.getElementById(`currentCompanies${userId}`);
|
||||
if (tbody) {
|
||||
// Remove "no companies" row if it exists
|
||||
const noCompaniesRow = document.getElementById(`no-companies-row-${userId}`);
|
||||
if (noCompaniesRow) {
|
||||
noCompaniesRow.remove();
|
||||
}
|
||||
|
||||
const newRow = document.createElement('tr');
|
||||
newRow.id = `company-row-${userId}-${companyId}`;
|
||||
newRow.innerHTML = `
|
||||
<td>${companySelect.options[companySelect.selectedIndex].text}</td>
|
||||
<td>
|
||||
<select class="form-select form-select-sm"
|
||||
onchange="changeCompanyRole(${userId}, ${companyId}, this.value)">
|
||||
<option value="User" ${role === 'User' ? 'selected' : ''}>User</option>
|
||||
${currentUserRole === 'GlobalAdmin' ? `<option value="CompanyAdmin" ${role === 'CompanyAdmin' ? 'selected' : ''}>Company Admin</option>` : ''}
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
<button class="btn btn-sm btn-danger"
|
||||
onclick="removeFromCompany(${userId}, ${companyId})">
|
||||
Remove
|
||||
</button>
|
||||
</td>
|
||||
`;
|
||||
tbody.appendChild(newRow);
|
||||
}
|
||||
|
||||
// Reset the form
|
||||
companySelect.value = '';
|
||||
roleSelect.value = 'User';
|
||||
|
||||
// Remove the selected company from the dropdown
|
||||
const selectedOption = companySelect.querySelector(`option[value="${companyId}"]`);
|
||||
if (selectedOption) {
|
||||
selectedOption.remove();
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
const toast = createToast('danger', error.message);
|
||||
toast.show();
|
||||
});
|
||||
}
|
||||
|
||||
function removeFromCompany(userId, companyId) {
|
||||
if (!confirm('Are you sure you want to remove this user from the company?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const baseUrl = '{{ url_for("auth.remove_user_from_company", user_id=1, company_id=1) }}'.replace('/1/companies/1/', `/${userId}/companies/${companyId}/`);
|
||||
fetch(baseUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': '{{ csrf_token() }}'
|
||||
}
|
||||
})
|
||||
.then(response => {
|
||||
if (response.ok) {
|
||||
return response.json();
|
||||
} else {
|
||||
return response.json().then(data => {
|
||||
throw new Error(data.error || 'Failed to remove user from company');
|
||||
});
|
||||
}
|
||||
})
|
||||
.then(data => {
|
||||
const toast = createToast('success', data.message);
|
||||
toast.show();
|
||||
|
||||
// Remove the row from the table
|
||||
const row = document.getElementById(`company-row-${userId}-${companyId}`);
|
||||
if (row) {
|
||||
const companyName = row.querySelector('td').textContent;
|
||||
row.remove();
|
||||
|
||||
// Check if this was the last company row
|
||||
const tbody = document.getElementById(`currentCompanies${userId}`);
|
||||
const remainingRows = tbody.querySelectorAll('tr:not([id^="no-companies-row"])');
|
||||
if (remainingRows.length === 0) {
|
||||
// Add the "no companies" row back
|
||||
const noCompaniesRow = document.createElement('tr');
|
||||
noCompaniesRow.id = `no-companies-row-${userId}`;
|
||||
noCompaniesRow.innerHTML = `
|
||||
<td colspan="3" class="text-muted text-center">
|
||||
User is not associated with any companies.
|
||||
</td>
|
||||
`;
|
||||
tbody.appendChild(noCompaniesRow);
|
||||
}
|
||||
|
||||
// Add the company back to the dropdown
|
||||
const select = document.getElementById(`addCompanySelect${userId}`);
|
||||
const option = document.createElement('option');
|
||||
option.value = companyId;
|
||||
option.textContent = companyName;
|
||||
select.appendChild(option);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
const toast = createToast('danger', error.message);
|
||||
toast.show();
|
||||
});
|
||||
}
|
||||
|
||||
function changeCompanyRole(userId, companyId, newRole) {
|
||||
const baseUrl = '{{ url_for("auth.change_user_company_role", user_id=1, company_id=1) }}'.replace('/1/companies/1/', `/${userId}/companies/${companyId}/`);
|
||||
fetch(baseUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': '{{ csrf_token() }}'
|
||||
},
|
||||
body: JSON.stringify({ role: newRole })
|
||||
})
|
||||
.then(response => {
|
||||
if (response.ok) {
|
||||
return response.json();
|
||||
} else {
|
||||
return response.json().then(data => {
|
||||
throw new Error(data.error || 'Failed to change company role');
|
||||
});
|
||||
}
|
||||
})
|
||||
.then(data => {
|
||||
const toast = createToast('success', data.message);
|
||||
toast.show();
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
const toast = createToast('danger', error.message);
|
||||
toast.show();
|
||||
// Revert the select value on error
|
||||
location.reload();
|
||||
});
|
||||
}
|
||||
|
||||
function createAndAddCompany(userId) {
|
||||
const nameInput = document.getElementById(`newCompanyName${userId}`);
|
||||
const roleSelect = document.getElementById(`newCompanyRole${userId}`);
|
||||
|
||||
const companyName = nameInput.value.trim();
|
||||
const role = roleSelect.value;
|
||||
|
||||
if (!companyName) {
|
||||
const toast = createToast('warning', 'Please enter a company name');
|
||||
toast.show();
|
||||
return;
|
||||
}
|
||||
|
||||
fetch('{{ url_for("auth.create_company_from_manage_users") }}', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': '{{ csrf_token() }}'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: companyName,
|
||||
user_id: userId,
|
||||
role: role
|
||||
})
|
||||
})
|
||||
.then(response => {
|
||||
if (response.ok) {
|
||||
return response.json();
|
||||
} else {
|
||||
return response.json().then(data => {
|
||||
throw new Error(data.error || 'Failed to create company');
|
||||
});
|
||||
}
|
||||
})
|
||||
.then(data => {
|
||||
const toast = createToast('success', data.message);
|
||||
toast.show();
|
||||
|
||||
// Add new row to the current companies table
|
||||
const tbody = document.getElementById(`currentCompanies${userId}`);
|
||||
if (tbody) {
|
||||
// Remove "no companies" row if it exists
|
||||
const noCompaniesRow = document.getElementById(`no-companies-row-${userId}`);
|
||||
if (noCompaniesRow) {
|
||||
noCompaniesRow.remove();
|
||||
}
|
||||
|
||||
const newRow = document.createElement('tr');
|
||||
newRow.id = `company-row-${userId}-${data.company_id}`;
|
||||
newRow.innerHTML = `
|
||||
<td>${companyName}</td>
|
||||
<td>
|
||||
<select class="form-select form-select-sm"
|
||||
onchange="changeCompanyRole(${userId}, ${data.company_id}, this.value)">
|
||||
<option value="User" ${role === 'User' ? 'selected' : ''}>User</option>
|
||||
${currentUserRole === 'GlobalAdmin' ? `<option value="CompanyAdmin" ${role === 'CompanyAdmin' ? 'selected' : ''}>Company Admin</option>` : ''}
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
<button class="btn btn-sm btn-danger"
|
||||
onclick="removeFromCompany(${userId}, ${data.company_id})">
|
||||
Remove
|
||||
</button>
|
||||
</td>
|
||||
`;
|
||||
tbody.appendChild(newRow);
|
||||
} else {
|
||||
console.warn(`Table body currentCompanies${userId} not found`);
|
||||
// Just reload the page if we can't update the table
|
||||
setTimeout(() => location.reload(), 1000);
|
||||
}
|
||||
|
||||
// Reset the form
|
||||
nameInput.value = '';
|
||||
roleSelect.value = 'User';
|
||||
|
||||
// Add the new company to all dropdown lists
|
||||
document.querySelectorAll('[id^="addCompanySelect"]').forEach(select => {
|
||||
const option = document.createElement('option');
|
||||
option.value = data.company_id;
|
||||
option.textContent = companyName;
|
||||
select.appendChild(option);
|
||||
});
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
const toast = createToast('danger', error.message);
|
||||
toast.show();
|
||||
});
|
||||
}
|
||||
|
||||
// Load available companies when modal is opened
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Add event listeners for when company modals are shown
|
||||
document.querySelectorAll('[id^="companiesModal"]').forEach(modal => {
|
||||
modal.addEventListener('shown.bs.modal', function() {
|
||||
const userId = this.id.replace('companiesModal', '');
|
||||
loadAvailableCompanies(userId);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
$(document).ready(function() {
|
||||
$('#usersTable').DataTable({
|
||||
"pageLength": 10,
|
||||
"order": [[0, "asc"]]
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
22
templates/auth/mfa_modal.html
Normal file
22
templates/auth/mfa_modal.html
Normal file
@@ -0,0 +1,22 @@
|
||||
<!-- MFA Verification Modal -->
|
||||
<div class="modal fade" id="mfaModal" tabindex="-1" data-bs-backdrop="static">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Two-Factor Authentication</h5>
|
||||
</div>
|
||||
<form id="mfaForm" method="POST" action="{{ url_for('auth.verify_mfa') }}">
|
||||
<div class="modal-body">
|
||||
<div class="mb-3">
|
||||
<label for="mfa_code" class="form-label">Enter the 6-digit code from your authenticator app</label>
|
||||
<input type="text" class="form-control" id="mfa_code" name="mfa_code"
|
||||
required pattern="[0-9]{6}" maxlength="6">
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="submit" class="btn btn-primary">Verify</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
62
templates/auth/mfa_setup.html
Normal file
62
templates/auth/mfa_setup.html
Normal file
@@ -0,0 +1,62 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<div class="container mt-4">
|
||||
<div class="row">
|
||||
<div class="col-md-6 offset-md-3">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3>Two-Factor Authentication Setup</h3>
|
||||
{% if forced_setup %}
|
||||
<div class="alert alert-warning mb-0 mt-2">
|
||||
<strong>Required:</strong> You must set up two-factor authentication to continue using your account.
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<ol class="mb-4">
|
||||
<li>Install an authenticator app like Google Authenticator or Microsoft Authenticator on your phone</li>
|
||||
<li>Scan the QR code below or manually enter the secret key in your authenticator app</li>
|
||||
<li>Enter the 6-digit code from your app to verify setup</li>
|
||||
</ol>
|
||||
|
||||
<div class="text-center mb-4">
|
||||
<img src="{{ qr_code }}" alt="QR Code" class="img-fluid" style="max-width: 200px;">
|
||||
</div>
|
||||
|
||||
<div class="accordion mb-4">
|
||||
<div class="accordion-item">
|
||||
<h2 class="accordion-header">
|
||||
<button class="accordion-button collapsed" type="button"
|
||||
data-bs-toggle="collapse" data-bs-target="#manualSetup">
|
||||
Can't scan the QR code?
|
||||
</button>
|
||||
</h2>
|
||||
<div id="manualSetup" class="accordion-collapse collapse">
|
||||
<div class="accordion-body">
|
||||
<p><strong>Secret Key:</strong> <code>{{ secret }}</code></p>
|
||||
<p class="text-muted small">Enter this secret key manually in your authenticator app if you can't scan the QR code.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form method="POST" action="">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="mb-3">
|
||||
<label for="verification_code" class="form-label">Verification Code</label>
|
||||
<input type="text" class="form-control" id="verification_code"
|
||||
name="verification_code" required pattern="[0-9]{6}" maxlength="6">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Verify and Enable 2FA</button>
|
||||
{% if not forced_setup %}
|
||||
<a href="{{ url_for('auth.profile') }}" class="btn btn-secondary">Cancel</a>
|
||||
{% else %}
|
||||
<a href="{{ url_for('auth.logout') }}" class="btn btn-secondary">Logout</a>
|
||||
{% endif %}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
68
templates/auth/profile.html
Normal file
68
templates/auth/profile.html
Normal file
@@ -0,0 +1,68 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<div class="container mt-4">
|
||||
<div class="row">
|
||||
<div class="col-md-6 offset-md-3">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3>Profile Settings</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<h5>Account Information</h5>
|
||||
<p><strong>Username:</strong> {{ current_user.username }}</p>
|
||||
<p><strong>Email:</strong> {{ current_user.email }}</p>
|
||||
<p><strong>Role:</strong> {{ current_user.role }}</p>
|
||||
|
||||
<hr>
|
||||
|
||||
<h5>Two-Factor Authentication</h5>
|
||||
<p>Status:
|
||||
<span class="badge {% if current_user.mfa_enabled %}bg-success{% else %}bg-warning{% endif %}">
|
||||
{{ "Enabled" if current_user.mfa_enabled else "Disabled" }}
|
||||
</span>
|
||||
{% if current_user.is_mfa_required() %}
|
||||
<span class="badge bg-info ms-2">Required</span>
|
||||
{% endif %}
|
||||
</p>
|
||||
|
||||
<div class="btn-group" role="group">
|
||||
{% if not current_user.mfa_enabled %}
|
||||
<a href="{{ url_for('auth.setup_mfa') }}" class="btn btn-primary">Setup 2FA</a>
|
||||
{% endif %}
|
||||
|
||||
{% if current_user.mfa_secret %}
|
||||
{% if current_user.mfa_enabled and current_user.is_mfa_required() and current_user.role != 'GlobalAdmin' %}
|
||||
<!-- User cannot disable MFA when it's required, unless they're GlobalAdmin -->
|
||||
<button type="button" class="btn btn-warning" disabled
|
||||
title="MFA is required and cannot be disabled. Contact your administrator.">
|
||||
Disable 2FA (Required)
|
||||
</button>
|
||||
{% else %}
|
||||
<!-- User can toggle MFA -->
|
||||
<form method="POST" action="{{ url_for('auth.toggle_mfa') }}" style="display: inline;">
|
||||
{{ mfa_action_form.hidden_tag() }}
|
||||
<button type="submit" class="btn btn-warning">
|
||||
{{ "Disable" if current_user.mfa_enabled else "Enable" }} 2FA
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
<form method="POST" action="{{ url_for('auth.reset_mfa') }}" style="display: inline;"
|
||||
onsubmit="return confirm('Are you sure you want to reset 2FA? You will need to set it up again.');">
|
||||
{{ mfa_action_form.hidden_tag() }}
|
||||
<button type="submit" class="btn btn-danger">Reset 2FA</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if current_user.is_mfa_required() and current_user.role != 'GlobalAdmin' %}
|
||||
<div class="alert alert-info mt-3">
|
||||
<small><strong>Note:</strong> MFA is required for your account and cannot be disabled. Contact your administrator if you need to disable MFA.</small>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
60
templates/auth/register.html
Normal file
60
templates/auth/register.html
Normal file
@@ -0,0 +1,60 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Register{% endblock %}
|
||||
{% block content %}
|
||||
<div class="container mt-4">
|
||||
<div class="row">
|
||||
<div class="col-md-6 offset-md-3">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3>Register</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="POST" action="">
|
||||
{{ form.hidden_tag() }}
|
||||
<div class="mb-3">
|
||||
{{ form.username.label(class="form-label") }}
|
||||
{{ form.username(class="form-control") }}
|
||||
{% if form.username.errors %}
|
||||
{% for error in form.username.errors %}
|
||||
<div class="text-danger">{{ error }}</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
{{ form.email.label(class="form-label") }}
|
||||
{{ form.email(class="form-control") }}
|
||||
{% if form.email.errors %}
|
||||
{% for error in form.email.errors %}
|
||||
<div class="text-danger">{{ error }}</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
{{ form.password.label(class="form-label") }}
|
||||
{{ form.password(class="form-control") }}
|
||||
{% if form.password.errors %}
|
||||
{% for error in form.password.errors %}
|
||||
<div class="text-danger">{{ error }}</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
{{ form.confirm_password.label(class="form-label") }}
|
||||
{{ form.confirm_password(class="form-control") }}
|
||||
{% if form.confirm_password.errors %}
|
||||
{% for error in form.confirm_password.errors %}
|
||||
<div class="text-danger">{{ error }}</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</div>
|
||||
{{ form.submit(class="btn btn-primary") }}
|
||||
</form>
|
||||
</div>
|
||||
<div class="card-footer">
|
||||
<small>Already have an account? <a href="{{ url_for('auth.login') }}">Sign in</a></small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock content %}
|
||||
170
templates/base.html
Normal file
170
templates/base.html
Normal file
@@ -0,0 +1,170 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-bs-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||||
{% block supertitle %}{% endblock %}
|
||||
<title>Domain Logon Monitor - {% block title %}{% endblock %}</title>
|
||||
<link href="{{ url_for('static', filename='css/bootstrap.min.css') }}" rel="stylesheet">
|
||||
<link href="{{ url_for('static', filename='css/custom.css') }}" rel="stylesheet">
|
||||
<link rel="shortcut icon" href="{{ url_for('static', filename='img/favicon.png') }}">
|
||||
{% block head %}{% endblock %}
|
||||
<style>
|
||||
.theme-icon {
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
display: none;
|
||||
}
|
||||
[data-bs-theme="dark"] .theme-icon-dark {
|
||||
display: inline;
|
||||
}
|
||||
[data-bs-theme="light"] .theme-icon-light {
|
||||
display: inline;
|
||||
}
|
||||
.dark-theme {
|
||||
background-color: #212529;
|
||||
color: #f8f9fa;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
|
||||
<div class="container">
|
||||
<a class="navbar-brand" href="{{ url_for('frontend.index') }}">
|
||||
<img src="{{ url_for('static', filename='img/favicon.png') }}" style="width: 30px;">
|
||||
<!-- Performance icons created by Uniconlabs - Flaticon ( www.flaticon.com/free-icons/performance) -->
|
||||
Domain Logon Monitor</a>
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div class="collapse navbar-collapse" id="navbarNav">
|
||||
<ul class="navbar-nav ms-auto">
|
||||
{% if current_user.is_authenticated %}
|
||||
<li class="nav-item dropdown">
|
||||
<a class="nav-link dropdown-toggle" href="#" id="dashboardDropdown" role="button" data-bs-toggle="dropdown">
|
||||
Dashboard
|
||||
</a>
|
||||
<ul class="dropdown-menu">
|
||||
<li><a class="dropdown-item" href="{{ url_for('frontend.dashboard') }}">Login Events</a></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('frontend.time_spent_report') }}">Time Spent Report</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
{% if current_user.role == 'Admin' %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('auth.manage_users') }}">Manage Users</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if current_user.role == 'GlobalAdmin' %}
|
||||
<li class="nav-item dropdown">
|
||||
<a class="nav-link dropdown-toggle" href="#" id="adminDropdown" role="button" data-bs-toggle="dropdown">
|
||||
Admin
|
||||
</a>
|
||||
<ul class="dropdown-menu">
|
||||
<li><a class="dropdown-item" href="{{ url_for('auth.manage_users') }}">Manage Users</a></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('auth.manage_companies') }}">Companies</a></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('auth.admin_settings') }}">Settings</a></li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('auth.view_error_logs') }}">Error Logs</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
{% endif %}
|
||||
|
||||
{# Companies dropdown for users who belong to multiple companies or company admins #}
|
||||
{% if current_user.companies|length > 0 %}
|
||||
<li class="nav-item dropdown">
|
||||
<a class="nav-link dropdown-toggle" href="#" id="companyDropdown" role="button" data-bs-toggle="dropdown">
|
||||
Companies
|
||||
</a>
|
||||
<ul class="dropdown-menu">
|
||||
{% for uc in current_user.companies %}
|
||||
<li>
|
||||
<a class="dropdown-item" href="{{ url_for('frontend.dashboard', company_id=uc.company.id) }}">
|
||||
{{ uc.company.name }}
|
||||
</a>
|
||||
</li>
|
||||
{% if uc.role == 'CompanyAdmin' %}
|
||||
<li>
|
||||
<a class="dropdown-item ps-4" href="{{ url_for('auth.company_users', company_id=uc.company.id) }}">
|
||||
<small>Manage Users</small>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="dropdown-item ps-4" href="{{ url_for('auth.company_api_keys', company_id=uc.company.id) }}">
|
||||
<small>Sites (API Key)</small>
|
||||
</a>
|
||||
</li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</li>
|
||||
{% endif %}
|
||||
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('auth.profile') }}">Profile</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('auth.logout') }}">Logout</a>
|
||||
</li>
|
||||
{% else %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('auth.login') }}">Login</a>
|
||||
</li>
|
||||
{% if allow_registration %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('auth.register') }}">Register</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
<li class="nav-item">
|
||||
<button class="btn btn-link nav-link" id="theme-toggle">
|
||||
<svg class="theme-icon theme-icon-light" xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
|
||||
<path d="M8 12a4 4 0 1 0 0-8 4 4 0 0 0 0 8zM8 0a.5.5 0 0 1 .5.5v2a.5.5 0 0 1-1 0v-2A.5.5 0 0 1 8 0zm0 13a.5.5 0 0 1 .5.5v2a.5.5 0 0 1-1 0v-2A.5.5 0 0 1 8 13zm8-5a.5.5 0 0 1-.5.5h-2a.5.5 0 0 1 0-1h2a.5.5 0 0 1 .5.5zM3 8a.5.5 0 0 1-.5.5h-2a.5.5 0 0 1 0-1h2A.5.5 0 0 1 3 8zm10.657-5.657a.5.5 0 0 1 0 .707l-1.414 1.415a.5.5 0 1 1-.707-.708l1.414-1.414a.5.5 0 0 1 .707 0zm-9.193 9.193a.5.5 0 0 1 0 .707L3.05 13.657a.5.5 0 0 1-.707-.707l1.414-1.414a.5.5 0 0 1 .707 0zm9.193 2.121a.5.5 0 0 1-.707 0l-1.414-1.414a.5.5 0 0 1 .707-.707l1.414 1.414a.5.5 0 0 1 0 .707zM4.464 4.465a.5.5 0 0 1-.707 0L2.343 3.05a.5.5 0 1 1 .707-.707l1.414 1.414a.5.5 0 0 1 0 .708z"/>
|
||||
</svg>
|
||||
<svg class="theme-icon theme-icon-dark" xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
|
||||
<path d="M6 .278a.768.768 0 0 1 .08.858 7.208 7.208 0 0 0-.878 3.46c0 4.021 3.278 7.277 7.318 7.277.527 0 1.04-.055 1.533-.16a.787.787 0 0 1 .81.316.733.733 0 0 1-.031.893A8.349 8.349 0 0 1 8.344 16C3.734 16 0 12.286 0 7.71 0 4.266 2.114 1.312 5.124.06A.752.752 0 0 1 6 .278z"/>
|
||||
</svg>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main>
|
||||
<div class="container">
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for category, message in messages %}
|
||||
<div class="alert alert-{{ category }} mt-3">
|
||||
{{ message }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script src="{{ url_for('static', filename='js/jquery-3.6.0.min.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/bootstrap.bundle.min.js') }}"></script>
|
||||
{% block scripts %}{% endblock %}
|
||||
<script>
|
||||
// Theme toggler
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const themeToggle = document.getElementById('theme-toggle');
|
||||
const storedTheme = localStorage.getItem('theme') || 'dark';
|
||||
document.documentElement.setAttribute('data-bs-theme', storedTheme);
|
||||
|
||||
themeToggle.addEventListener('click', () => {
|
||||
const currentTheme = document.documentElement.getAttribute('data-bs-theme');
|
||||
const newTheme = currentTheme === 'light' ? 'dark' : 'light';
|
||||
document.documentElement.setAttribute('data-bs-theme', newTheme);
|
||||
localStorage.setItem('theme', newTheme);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
<!-- <a href="https://www.flaticon.com/free-icons/performance" title="performance icons">Performance icons created by Uniconlabs - Flaticon</a> -->
|
||||
</html>
|
||||
57
templates/error.html
Normal file
57
templates/error.html
Normal file
@@ -0,0 +1,57 @@
|
||||
{% extends "base.html" %}
|
||||
{% block supertitle %}
|
||||
<title>Opsiee {{status_code}} Error</title>
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<div class="container mt-4">
|
||||
<h1 style="color: red; font-weight: bold;">{{status_code}} Error</h1>
|
||||
<p style="color: deeppink;font-weight: bold;">{{description}}</p>
|
||||
<br></br>
|
||||
{% if current_user.is_authenticated %}
|
||||
<div class="row mt-4">
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Quick Links:</h5>
|
||||
<ul class="list-group list-group-flush">
|
||||
<li class="list-group-item">
|
||||
<a href="{{ url_for('frontend.dashboard') }}" class="text-decoration-none">View Dashboard</a>
|
||||
</li>
|
||||
<li class="list-group-item">
|
||||
<a href="{{ url_for('frontend.time_spent_report') }}" class="text-decoration-none">Time Spent Report</a>
|
||||
</li>
|
||||
{% if current_user.role == 'Admin' or current_user.role == 'GlobalAdmin' %}
|
||||
<br></br><h5 class="card-title">Management:</h5>
|
||||
<li class="list-group-item">
|
||||
<a href="{{ url_for('auth.manage_users') }}" class="text-decoration-none">Manage Users</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if current_user.role == 'GlobalAdmin' %}
|
||||
<li class="list-group-item">
|
||||
<a href="{{ url_for('auth.manage_companies') }}" class="text-decoration-none">Manage Companies</a>
|
||||
</li>
|
||||
<li class="list-group-item">
|
||||
<a href="{{ url_for('auth.admin_settings') }}" class="text-decoration-none">Site Settings</a>
|
||||
</li>
|
||||
<li class="list-group-item">
|
||||
<a href="{{ url_for('auth.view_error_logs') }}" class="text-decoration-none">View Error Logs</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="lead">Please login {% if allow_registration %}or register {% endif %}to access the monitoring system.</p>
|
||||
<div class="row mt-4">
|
||||
<div class="col-md-6">
|
||||
<a href="{{ url_for('auth.login') }}" class="btn btn-primary mr-2">Login</a>
|
||||
{% if allow_registration %}
|
||||
<a href="{{ url_for('auth.register') }}" class="btn btn-secondary">Register</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock content %}
|
||||
459
templates/frontend/dashboard.html
Normal file
459
templates/frontend/dashboard.html
Normal file
@@ -0,0 +1,459 @@
|
||||
{% extends "base.html" %}
|
||||
{% block head %}
|
||||
<!-- DataTables CSS -->
|
||||
<link href="{{ url_for('static', filename='css/dataTables.bootstrap5.min.css') }}" rel="stylesheet">
|
||||
<link href="{{ url_for('static', filename='css/buttons.bootstrap5.min.css') }}" rel="stylesheet">
|
||||
<link href="{{ url_for('static', filename='css/buttons.dataTables.min.css') }}" rel="stylesheet">
|
||||
<link href="{{ url_for('static', filename='css/colVis.dataTables.min.css') }}" rel="stylesheet">
|
||||
<!-- DateRangePicker CSS -->
|
||||
<link href="{{ url_for('static', filename='css/daterangepicker.css') }}" rel="stylesheet">
|
||||
<!-- Custom DateRangePicker dark theme styles -->
|
||||
<style>
|
||||
/* Dark theme for date picker */
|
||||
.daterangepicker {
|
||||
background-color: #212529;
|
||||
border-color: #495057;
|
||||
color: #f8f9fa;
|
||||
}
|
||||
.daterangepicker .calendar-table {
|
||||
background-color: #343a40;
|
||||
border-color: #495057;
|
||||
}
|
||||
.daterangepicker td.available:hover,
|
||||
.daterangepicker th.available:hover {
|
||||
background-color: #495057;
|
||||
}
|
||||
.daterangepicker td.active,
|
||||
.daterangepicker td.active:hover {
|
||||
background-color: #0d6efd;
|
||||
color: #fff;
|
||||
}
|
||||
/* In-between dates in the selected range - lighter blue */
|
||||
.daterangepicker td.in-range {
|
||||
background-color: #82b1ff; /* Lighter blue */
|
||||
color: #212529; /* Darker text for better contrast */
|
||||
}
|
||||
.daterangepicker td.in-range:hover {
|
||||
background-color: #75a7f7; /* Slightly darker when hovering */
|
||||
color: #212529;
|
||||
}
|
||||
.daterangepicker .calendar-table .next span,
|
||||
.daterangepicker .calendar-table .prev span {
|
||||
border-color: #f8f9fa;
|
||||
}
|
||||
.daterangepicker .ranges li:hover,
|
||||
.daterangepicker .ranges li.active {
|
||||
background-color: #0d6efd;
|
||||
color: #fff;
|
||||
}
|
||||
.daterangepicker .ranges li {
|
||||
color: #f8f9fa;
|
||||
}
|
||||
.daterangepicker:after {
|
||||
border-bottom-color: #212529;
|
||||
}
|
||||
.daterangepicker:before {
|
||||
border-bottom-color: #495057;
|
||||
}
|
||||
/* Calendar header and weekday styling */
|
||||
.daterangepicker .calendar-table th {
|
||||
color: #f8f9fa;
|
||||
}
|
||||
/* Month name */
|
||||
.daterangepicker .month {
|
||||
color: #f8f9fa;
|
||||
}
|
||||
/* Off days (not in current month) */
|
||||
.daterangepicker td.off {
|
||||
color: #6c757d;
|
||||
}
|
||||
/* Input boxes */
|
||||
.daterangepicker input.input-mini {
|
||||
background-color: #343a40;
|
||||
border-color: #495057;
|
||||
color: #f8f9fa;
|
||||
}
|
||||
/* Time picker */
|
||||
.daterangepicker .calendar-time select {
|
||||
background-color: #343a40;
|
||||
border-color: #495057;
|
||||
color: #f8f9fa;
|
||||
}
|
||||
/* Apply and cancel buttons */
|
||||
.daterangepicker .drp-buttons {
|
||||
border-top-color: #495057;
|
||||
}
|
||||
.daterangepicker .drp-buttons .btn {
|
||||
color: #f8f9fa;
|
||||
}
|
||||
/* Input fields focus */
|
||||
.daterangepicker input.input-mini:focus {
|
||||
border-color: #0d6efd;
|
||||
}
|
||||
/* Time inputs container */
|
||||
.daterangepicker .calendar-time {
|
||||
background-color: #343a40;
|
||||
border-color: #495057;
|
||||
}
|
||||
/* Make the export buttons more visible */
|
||||
.dt-buttons {
|
||||
margin-top: 10px;
|
||||
margin-bottom: 15px;
|
||||
display: inline-block !important;
|
||||
}
|
||||
.dt-button {
|
||||
margin-right: 5px;
|
||||
}
|
||||
/* Page title and export buttons container */
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
.page-title {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
/* For smaller screens */
|
||||
@media (max-width: 768px) {
|
||||
.page-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.export-buttons {
|
||||
margin-top: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Column visibility dropdown styles */
|
||||
.dropdown-menu {
|
||||
background-color: #343a40;
|
||||
border-color: #495057;
|
||||
}
|
||||
.dropdown-item {
|
||||
color: #f8f9fa;
|
||||
}
|
||||
.dropdown-item:hover, .dropdown-item:focus {
|
||||
background-color: #495057;
|
||||
color: #f8f9fa;
|
||||
}
|
||||
.form-check-input:checked {
|
||||
background-color: #0d6efd;
|
||||
border-color: #0d6efd;
|
||||
}
|
||||
.form-check-label {
|
||||
color: #f8f9fa;
|
||||
}
|
||||
#column-visibility-menu {
|
||||
min-width: 200px;
|
||||
}
|
||||
.column-checkbox {
|
||||
padding: 0.375rem 1rem;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block title %}Dashboard{% endblock %}
|
||||
{% block content %}
|
||||
<div class="container-fluid mt-4">
|
||||
<div class="page-header">
|
||||
<h2 class="page-title">Login Events Dashboard</h2>
|
||||
<div class="export-buttons">
|
||||
<div class="btn-group me-2" role="group">
|
||||
<button id="column-visibility" class="btn btn-outline-secondary btn-sm dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">
|
||||
Columns
|
||||
</button>
|
||||
<ul class="dropdown-menu" id="column-visibility-menu">
|
||||
<!-- Column visibility checkboxes will be populated by JavaScript -->
|
||||
</ul>
|
||||
</div>
|
||||
<button id="export-csv" class="btn btn-secondary btn-sm">Export CSV</button>
|
||||
<button id="export-excel" class="btn btn-success btn-sm">Export Excel</button>
|
||||
<button id="print-table" class="btn btn-info btn-sm">Print</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mt-3 mb-3">
|
||||
<div class="card-body">
|
||||
<form id="dateRangeForm" method="GET" action="{{ url_for('frontend.dashboard') }}">
|
||||
<div class="row align-items-end">
|
||||
<div class="col-md-3">
|
||||
<label for="daterange" class="form-label">Date Range:</label>
|
||||
<input type="text" id="daterange" name="daterange" class="form-control"
|
||||
value="{{ start_date.strftime('%Y-%m-%d %H:%M') if start_date else '' }} - {{ end_date.strftime('%Y-%m-%d %H:%M') if end_date else '' }}"/>
|
||||
</div>
|
||||
{% if companies %}
|
||||
<div class="col-md-3">
|
||||
<label for="company_id" class="form-label">Company:</label>
|
||||
<select class="form-select" id="company_id" name="company_id">
|
||||
<option value="">All Companies</option>
|
||||
{% for company in companies %}
|
||||
<option value="{{ company.id }}" {% if selected_company_id == company.id %}selected{% endif %}>
|
||||
{{ company.name }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="col-md-2">
|
||||
<button type="submit" class="btn btn-primary">Apply Filter</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<table id="logsTable" class="table table-striped table-bordered" style="width:100%">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Event Type</th>
|
||||
<th>User Name</th>
|
||||
<th>Computer Name</th>
|
||||
<th>IP Address</th>
|
||||
<th>Site</th>
|
||||
<th>Timestamp</th>
|
||||
{% if current_user.is_global_admin() and not selected_company_id %}
|
||||
<th>Company</th>
|
||||
{% endif %}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for log in logs %}
|
||||
<tr>
|
||||
<td>{{ log.event_type }}</td>
|
||||
<td>{{ log.user_name }}</td>
|
||||
<td>{{ log.computer_name }}</td>
|
||||
<td>{{ log.ip_address }}</td>
|
||||
<td>{{ log.api_key.description if log.api_key else 'N/A' }}</td>
|
||||
<td data-order="{{ log.timestamp.strftime('%Y%m%d%H%M%S') }}">
|
||||
{{ log.timestamp|format_datetime }}
|
||||
</td>
|
||||
{% if current_user.is_global_admin() and not selected_company_id %}
|
||||
<td>{{ log.company.name if log.company else 'N/A' }}</td>
|
||||
{% endif %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<!-- DataTables JS -->
|
||||
<script src="{{ url_for('static', filename='js/jquery.dataTables.min.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/dataTables.bootstrap5.min.js') }}"></script>
|
||||
<!-- DataTables Buttons JS -->
|
||||
<script src="{{ url_for('static', filename='js/dataTables.buttons.min.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/buttons.bootstrap5.min.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/buttons.html5.min.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/buttons.print.min.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/jszip.min.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/pdfmake.min.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/vfs_fonts.js') }}"></script>
|
||||
<!-- Moment.js -->
|
||||
<script src="{{ url_for('static', filename='js/moment.min.js') }}"></script>
|
||||
<!-- DateRangePicker -->
|
||||
<script src="{{ url_for('static', filename='js/daterangepicker.min.js') }}"></script>
|
||||
<script>
|
||||
$(document).ready(function() {
|
||||
$('#daterange').daterangepicker({
|
||||
timePicker: true,
|
||||
timePicker24Hour: true,
|
||||
timePickerSeconds: false,
|
||||
timePickerIncrement: 1, // Changed from 15 to 1 minute increments
|
||||
autoUpdateInput: false, // Prevents auto-update so user can edit manually
|
||||
locale: {
|
||||
format: 'YYYY-MM-DD HH:mm',
|
||||
cancelLabel: 'Clear',
|
||||
applyLabel: 'Apply'
|
||||
},
|
||||
ranges: {
|
||||
'Last 48 Hours': [moment().subtract(48, 'hours'), moment()],
|
||||
'Last 7 Days': [moment().subtract(6, 'days'), moment()],
|
||||
'Last 30 Days': [moment().subtract(29, 'days'), moment()],
|
||||
'This Month': [moment().startOf('month'), moment().endOf('month')],
|
||||
'Last Month': [moment().subtract(1, 'month').startOf('month'), moment().subtract(1, 'month').endOf('month')]
|
||||
}
|
||||
});
|
||||
|
||||
// Handle manual input updates
|
||||
$('#daterange').on('apply.daterangepicker', function(ev, picker) {
|
||||
$(this).val(picker.startDate.format('YYYY-MM-DD HH:mm') + ' - ' + picker.endDate.format('YYYY-MM-DD HH:mm'));
|
||||
});
|
||||
|
||||
$('#daterange').on('cancel.daterangepicker', function(ev, picker) {
|
||||
$(this).val('');
|
||||
});
|
||||
|
||||
// Allow direct editing of the date input
|
||||
$('#daterange').on('keyup', function(e) {
|
||||
if(e.keyCode === 13) {
|
||||
// Try to parse the input value
|
||||
var parts = $(this).val().split(' - ');
|
||||
if(parts.length === 2) {
|
||||
var startDate = moment(parts[0], 'YYYY-MM-DD HH:mm');
|
||||
var endDate = moment(parts[1], 'YYYY-MM-DD HH:mm');
|
||||
|
||||
if(startDate.isValid() && endDate.isValid()) {
|
||||
var picker = $(this).data('daterangepicker');
|
||||
picker.setStartDate(startDate);
|
||||
picker.setEndDate(endDate);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
var table = $('#logsTable').DataTable({
|
||||
pageLength: 50,
|
||||
lengthMenu: [[50, 100, 200, 500, 1000], [50, 100, 200, 500, 1000]],
|
||||
order: [[{% if current_user.is_global_admin() and not selected_company_id %}6{% else %}5{% endif %}, 'desc']], // Sort by timestamp column descending
|
||||
dom: '<"row"<"col-sm-12 col-md-6"l><"col-sm-12 col-md-6"f>>' +
|
||||
'<"row"<"col-sm-12"tr>>' +
|
||||
'<"row"<"col-sm-12 col-md-5"i><"col-sm-12 col-md-7"p>>',
|
||||
columnDefs: [
|
||||
{
|
||||
targets: [2, 3], // Computer Name and IP Address columns
|
||||
visible: false // Hide by default
|
||||
}
|
||||
],
|
||||
buttons: [
|
||||
{
|
||||
extend: 'csv',
|
||||
text: 'Export CSV',
|
||||
className: 'btn btn-secondary',
|
||||
filename: 'login_events_' + moment().format('YYYY-MM-DD'),
|
||||
exportOptions: {
|
||||
columns: ':visible'
|
||||
}
|
||||
},
|
||||
{
|
||||
extend: 'excel',
|
||||
text: 'Export Excel',
|
||||
className: 'btn btn-success',
|
||||
filename: 'login_events_' + moment().format('YYYY-MM-DD'),
|
||||
exportOptions: {
|
||||
columns: ':visible'
|
||||
}
|
||||
},
|
||||
{
|
||||
extend: 'print',
|
||||
text: 'Print',
|
||||
className: 'btn btn-info',
|
||||
exportOptions: {
|
||||
columns: ':visible'
|
||||
}
|
||||
}
|
||||
],
|
||||
language: {
|
||||
search: "Search records:",
|
||||
lengthMenu: "Show _MENU_ records per page",
|
||||
info: "Showing _START_ to _END_ of _TOTAL_ records",
|
||||
paginate: {
|
||||
first: "First",
|
||||
last: "Last",
|
||||
next: "Next",
|
||||
previous: "Previous"
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Column names for the visibility controls
|
||||
var columnNames = [
|
||||
'Event Type',
|
||||
'User Name',
|
||||
'Computer Name',
|
||||
'IP Address',
|
||||
'Site',
|
||||
'Timestamp'
|
||||
{% if current_user.is_global_admin() and not selected_company_id %},
|
||||
'Company'
|
||||
{% endif %}
|
||||
];
|
||||
|
||||
// Load saved column visibility from localStorage
|
||||
function loadColumnVisibility() {
|
||||
var saved = localStorage.getItem('dashboardColumnVisibility');
|
||||
if (saved) {
|
||||
try {
|
||||
return JSON.parse(saved);
|
||||
} catch (e) {
|
||||
console.log('Error parsing saved column visibility:', e);
|
||||
}
|
||||
}
|
||||
// Default visibility - hide Computer Name (2) and IP Address (3)
|
||||
var defaultVisibility = {};
|
||||
columnNames.forEach(function(name, index) {
|
||||
defaultVisibility[index] = index !== 2 && index !== 3;
|
||||
});
|
||||
return defaultVisibility;
|
||||
}
|
||||
|
||||
// Save column visibility to localStorage
|
||||
function saveColumnVisibility(visibility) {
|
||||
localStorage.setItem('dashboardColumnVisibility', JSON.stringify(visibility));
|
||||
}
|
||||
|
||||
// Apply saved column visibility to table
|
||||
var savedVisibility = loadColumnVisibility();
|
||||
Object.keys(savedVisibility).forEach(function(colIndex) {
|
||||
table.column(parseInt(colIndex)).visible(savedVisibility[colIndex]);
|
||||
});
|
||||
|
||||
// Create column visibility dropdown menu
|
||||
function createColumnVisibilityMenu() {
|
||||
var menu = $('#column-visibility-menu');
|
||||
menu.empty();
|
||||
|
||||
columnNames.forEach(function(columnName, index) {
|
||||
var isVisible = table.column(index).visible();
|
||||
var checkboxId = 'col-vis-' + index;
|
||||
|
||||
var menuItem = $('<li class="column-checkbox"></li>');
|
||||
var formCheck = $('<div class="form-check"></div>');
|
||||
var checkbox = $('<input class="form-check-input" type="checkbox" id="' + checkboxId + '"' +
|
||||
(isVisible ? ' checked' : '') + '>');
|
||||
var label = $('<label class="form-check-label" for="' + checkboxId + '">' + columnName + '</label>');
|
||||
|
||||
checkbox.on('change', function() {
|
||||
var colIndex = parseInt(this.id.split('-')[2]);
|
||||
var isChecked = this.checked;
|
||||
table.column(colIndex).visible(isChecked);
|
||||
|
||||
// Update saved visibility
|
||||
savedVisibility[colIndex] = isChecked;
|
||||
saveColumnVisibility(savedVisibility);
|
||||
});
|
||||
|
||||
formCheck.append(checkbox, label);
|
||||
menuItem.append(formCheck);
|
||||
menu.append(menuItem);
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize column visibility menu
|
||||
createColumnVisibilityMenu();
|
||||
|
||||
// Prevent dropdown from closing when clicking inside
|
||||
$('#column-visibility-menu').on('click', function(e) {
|
||||
e.stopPropagation();
|
||||
});
|
||||
|
||||
// Connect the custom export buttons to DataTables buttons
|
||||
$('#export-csv').on('click', function() {
|
||||
table.button('.buttons-csv').trigger();
|
||||
});
|
||||
|
||||
$('#export-excel').on('click', function() {
|
||||
table.button('.buttons-excel').trigger();
|
||||
});
|
||||
|
||||
$('#print-table').on('click', function() {
|
||||
table.button('.buttons-print').trigger();
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
53
templates/frontend/home.html
Normal file
53
templates/frontend/home.html
Normal file
@@ -0,0 +1,53 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<div class="container mt-4">
|
||||
<h1>Welcome to Domain Logon Monitoring</h1>
|
||||
{% if current_user.is_authenticated %}
|
||||
<p class="lead">Hello {{ current_user.username }}, welcome back!</p>
|
||||
<div class="row mt-4">
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Quick Links:</h5>
|
||||
<ul class="list-group list-group-flush">
|
||||
<li class="list-group-item">
|
||||
<a href="{{ url_for('frontend.dashboard') }}" class="text-decoration-none">View Dashboard</a>
|
||||
</li>
|
||||
<li class="list-group-item">
|
||||
<a href="{{ url_for('frontend.time_spent_report') }}" class="text-decoration-none">Time Spent Report</a>
|
||||
</li>
|
||||
{% if current_user.role == 'Admin' or current_user.role == 'GlobalAdmin' %}
|
||||
<br></br><h5 class="card-title">Management:</h5>
|
||||
<li class="list-group-item">
|
||||
<a href="{{ url_for('auth.manage_users') }}" class="text-decoration-none">Manage Users</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if current_user.role == 'GlobalAdmin' %}
|
||||
<li class="list-group-item">
|
||||
<a href="{{ url_for('auth.manage_companies') }}" class="text-decoration-none">Manage Companies</a>
|
||||
</li>
|
||||
<li class="list-group-item">
|
||||
<a href="{{ url_for('auth.admin_settings') }}" class="text-decoration-none">Site Settings</a>
|
||||
</li>
|
||||
<li class="list-group-item">
|
||||
<a href="{{ url_for('auth.view_error_logs') }}" class="text-decoration-none">View Error Logs</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="lead">Please login {% if allow_registration %}or register {% endif %}to access the monitoring system.</p>
|
||||
<div class="row mt-4">
|
||||
<div class="col-md-6">
|
||||
<a href="{{ url_for('auth.login') }}" class="btn btn-primary mr-2">Login</a>
|
||||
{% if allow_registration %}
|
||||
<a href="{{ url_for('auth.register') }}" class="btn btn-secondary">Register</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock content %}
|
||||
302
templates/frontend/time_spent_report.html
Normal file
302
templates/frontend/time_spent_report.html
Normal file
@@ -0,0 +1,302 @@
|
||||
{% extends "base.html" %}
|
||||
{% block head %}
|
||||
<!-- DataTables CSS -->
|
||||
<link href="{{ url_for('static', filename='css/dataTables.bootstrap5.min.css') }}" rel="stylesheet">
|
||||
<link href="{{ url_for('static', filename='css/buttons.bootstrap5.min.css') }}" rel="stylesheet">
|
||||
<!-- DateRangePicker CSS -->
|
||||
<link href="{{ url_for('static', filename='css/daterangepicker.css') }}" rel="stylesheet">
|
||||
<style>
|
||||
.btn-group-toggle .btn {
|
||||
margin-right: 5px;
|
||||
}
|
||||
.dataTables_wrapper .dt-buttons {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
.dt-button {
|
||||
margin-right: 5px;
|
||||
}
|
||||
/* Custom DateRangePicker dark theme styles */
|
||||
.daterangepicker {
|
||||
background-color: #212529;
|
||||
border-color: #495057;
|
||||
color: #f8f9fa;
|
||||
}
|
||||
.daterangepicker .calendar-table {
|
||||
background-color: #343a40;
|
||||
border-color: #495057;
|
||||
}
|
||||
.daterangepicker td.available:hover,
|
||||
.daterangepicker th.available:hover {
|
||||
background-color: #495057;
|
||||
}
|
||||
.daterangepicker td.active,
|
||||
.daterangepicker td.active:hover {
|
||||
background-color: #0d6efd;
|
||||
color: #fff;
|
||||
}
|
||||
/* In-between dates in the selected range - lighter blue */
|
||||
.daterangepicker td.in-range {
|
||||
background-color: #82b1ff; /* Lighter blue */
|
||||
color: #212529; /* Darker text for better contrast */
|
||||
}
|
||||
.daterangepicker td.in-range:hover {
|
||||
background-color: #75a7f7; /* Slightly darker when hovering */
|
||||
color: #212529;
|
||||
}
|
||||
.daterangepicker .calendar-table .next span,
|
||||
.daterangepicker .calendar-table .prev span {
|
||||
border-color: #f8f9fa;
|
||||
}
|
||||
.daterangepicker .ranges li:hover,
|
||||
.daterangepicker .ranges li.active {
|
||||
background-color: #0d6efd;
|
||||
color: #fff;
|
||||
}
|
||||
.daterangepicker .ranges li {
|
||||
color: #f8f9fa;
|
||||
}
|
||||
.daterangepicker:after {
|
||||
border-bottom-color: #212529;
|
||||
}
|
||||
.daterangepicker:before {
|
||||
border-bottom-color: #495057;
|
||||
}
|
||||
/* Calendar header and weekday styling */
|
||||
.daterangepicker .calendar-table th {
|
||||
color: #f8f9fa;
|
||||
}
|
||||
/* Month name */
|
||||
.daterangepicker .month {
|
||||
color: #f8f9fa;
|
||||
}
|
||||
/* Off days (not in current month) */
|
||||
.daterangepicker td.off {
|
||||
color: #6c757d;
|
||||
}
|
||||
/* Input boxes */
|
||||
.daterangepicker input.input-mini {
|
||||
background-color: #343a40;
|
||||
border-color: #495057;
|
||||
color: #f8f9fa;
|
||||
}
|
||||
/* Time picker */
|
||||
.daterangepicker .calendar-time select {
|
||||
background-color: #343a40;
|
||||
border-color: #495057;
|
||||
color: #f8f9fa;
|
||||
}
|
||||
/* Apply and cancel buttons */
|
||||
.daterangepicker .drp-buttons {
|
||||
border-top-color: #495057;
|
||||
}
|
||||
.daterangepicker .drp-buttons .btn {
|
||||
color: #f8f9fa;
|
||||
}
|
||||
/* Input fields focus */
|
||||
.daterangepicker input.input-mini:focus {
|
||||
border-color: #0d6efd;
|
||||
}
|
||||
/* Time inputs container */
|
||||
.daterangepicker .calendar-time {
|
||||
background-color: #343a40;
|
||||
border-color: #495057;
|
||||
}
|
||||
/* Make the export buttons more visible */
|
||||
.dt-buttons {
|
||||
margin-top: 10px;
|
||||
margin-bottom: 15px;
|
||||
display: block !important;
|
||||
}
|
||||
.dt-button {
|
||||
margin-right: 5px;
|
||||
}
|
||||
/* Align the "Show entries" and search box in the same row */
|
||||
div.dataTables_wrapper div.dataTables_length {
|
||||
float: left;
|
||||
padding-top: 0.5em;
|
||||
}
|
||||
div.dataTables_wrapper div.dataTables_filter {
|
||||
float: right;
|
||||
}
|
||||
div.dataTables_wrapper div.dataTables_info {
|
||||
clear: both;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block title %}Time Spent Report{% endblock %}
|
||||
{% block content %}
|
||||
<div class="container-fluid mt-4">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<h2>Time Spent Report</h2>
|
||||
<div class="export-buttons d-flex align-items-center">
|
||||
<!-- Process span option moved here -->
|
||||
<div class="form-check me-3">
|
||||
<input type="checkbox" class="form-check-input" name="continue_iterate" id="continue_iterate" form="reportFilterForm" {% if continue_iterate %}checked{% endif %}>
|
||||
<label class="form-check-label" for="continue_iterate" data-bs-toggle="tooltip" data-bs-placement="top"
|
||||
title="When enabled, the system will continue processing time spans across midnight for multi-day sessions. When disabled, each day's activity is calculated separately.">
|
||||
Process multi-day sessions
|
||||
</label>
|
||||
</div>
|
||||
<!-- Export buttons will be placed here by DataTables -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mt-3 mb-3">
|
||||
<div class="card-body">
|
||||
<form id="reportFilterForm" method="GET" action="{{ url_for('frontend.time_spent_report') }}">
|
||||
<div class="row align-items-end">
|
||||
<div class="col-md-3">
|
||||
<label for="daterange" class="form-label">Date Range:</label>
|
||||
<input type="text" id="daterange" name="daterange" class="form-control"
|
||||
value="{{ start_date.strftime('%Y-%m-%d %H:%M') if start_date else '' }} - {{ end_date.strftime('%Y-%m-%d %H:%M') if end_date else '' }}"/>
|
||||
</div>
|
||||
|
||||
{% if companies %}
|
||||
<div class="col-md-2">
|
||||
<label for="company_id" class="form-label">Company:</label>
|
||||
<select class="form-select" id="company_id" name="company_id">
|
||||
<option value="">All Companies</option>
|
||||
{% for company in companies %}
|
||||
<option value="{{ company.id }}" {% if selected_company_id == company.id %}selected{% endif %}>
|
||||
{{ company.name }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="col-md-3">
|
||||
<label for="group_by" class="form-label">Group By:</label>
|
||||
<div class="btn-group btn-group-toggle" data-toggle="buttons">
|
||||
<input type="radio" class="btn-check" name="group_by" id="option1" value="user" {% if group_by == 'user' or not group_by %}checked{% endif %}>
|
||||
<label class="btn btn-outline-primary btn-sm" for="option1">User</label>
|
||||
|
||||
<input type="radio" class="btn-check" name="group_by" id="option2" value="user_computer" {% if group_by == 'user_computer' %}checked{% endif %}>
|
||||
<label class="btn btn-outline-primary btn-sm" for="option2">User + Computer</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-2 mt-3">
|
||||
<button type="submit" class="btn btn-primary">Apply Filter</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<table id="timeSpentTable" class="table table-striped table-bordered" style="width:100%">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>User Name</th>
|
||||
{% if group_by == 'user_computer' %}
|
||||
<th>Computer Name</th>
|
||||
{% endif %}
|
||||
<th>Company</th>
|
||||
<th>Total Time</th>
|
||||
<th>First Login</th>
|
||||
<th>Last Logout</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for entry in time_data %}
|
||||
<tr>
|
||||
<td>{{ entry.date }}</td>
|
||||
<td>{{ entry.user_name }}</td>
|
||||
{% if group_by == 'user_computer' %}
|
||||
<td>{{ entry.computer_name }}</td>
|
||||
{% endif %}
|
||||
<td>{{ entry.company_name }}</td>
|
||||
<td data-order="{{ entry.total_seconds }}">{{ entry.formatted_time }}</td>
|
||||
<td data-order="{{ entry.first_login.strftime('%Y%m%d%H%M%S') if entry.first_login else '' }}">
|
||||
{{ entry.first_login|format_datetime if entry.first_login else 'N/A' }}
|
||||
</td>
|
||||
<td data-order="{{ entry.last_logout.strftime('%Y%m%d%H%M%S') if entry.last_logout else '' }}">
|
||||
{{ entry.last_logout|format_datetime if entry.last_logout else 'N/A' }}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<!-- DataTables JS -->
|
||||
<script src="{{ url_for('static', filename='js/jquery.dataTables.min.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/dataTables.bootstrap5.min.js') }}"></script>
|
||||
<!-- DataTables Buttons JS -->
|
||||
<script src="{{ url_for('static', filename='js/dataTables.buttons.min.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/buttons.bootstrap5.min.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/buttons.html5.min.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/buttons.print.min.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/jszip.min.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/pdfmake.min.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/vfs_fonts.js') }}"></script>
|
||||
<!-- Moment.js -->
|
||||
<script src="{{ url_for('static', filename='js/moment.min.js') }}"></script>
|
||||
<!-- DateRangePicker -->
|
||||
<script src="{{ url_for('static', filename='js/daterangepicker.min.js') }}"></script>
|
||||
<script>
|
||||
$(document).ready(function() {
|
||||
// Initialize date range picker
|
||||
$('#daterange').daterangepicker({
|
||||
timePicker: true,
|
||||
timePicker24Hour: true,
|
||||
timePickerSeconds: false,
|
||||
startDate: moment().subtract(7, 'days'),
|
||||
endDate: moment(),
|
||||
locale: {
|
||||
format: 'YYYY-MM-DD HH:mm'
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize tooltips
|
||||
var tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]'))
|
||||
var tooltipList = tooltipTriggerList.map(function (tooltipTriggerEl) {
|
||||
return new bootstrap.Tooltip(tooltipTriggerEl)
|
||||
});
|
||||
|
||||
// Initialize DataTable with export buttons
|
||||
var table = $('#timeSpentTable').DataTable({
|
||||
dom: 'lfrBtip',
|
||||
buttons: [
|
||||
{
|
||||
extend: 'csv',
|
||||
text: 'Export CSV',
|
||||
className: 'btn btn-primary btn-sm',
|
||||
exportOptions: {
|
||||
columns: ':visible'
|
||||
}
|
||||
},
|
||||
{
|
||||
extend: 'excel',
|
||||
text: 'Export Excel',
|
||||
className: 'btn btn-success btn-sm',
|
||||
exportOptions: {
|
||||
columns: ':visible'
|
||||
}
|
||||
},
|
||||
{
|
||||
extend: 'print',
|
||||
text: 'Print',
|
||||
className: 'btn btn-info btn-sm',
|
||||
exportOptions: {
|
||||
columns: ':visible'
|
||||
}
|
||||
}
|
||||
],
|
||||
order: [[0, 'desc']],
|
||||
pageLength: 25
|
||||
});
|
||||
|
||||
// Move export buttons to the header
|
||||
table.buttons().container().appendTo('.export-buttons');
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
23
utils/__init__.py
Normal file
23
utils/__init__.py
Normal file
@@ -0,0 +1,23 @@
|
||||
"""
|
||||
Utils package for the Domain Logons application.
|
||||
|
||||
This package contains utility modules for database logging, timezone handling,
|
||||
rate limiting, security headers, and health checks.
|
||||
"""
|
||||
|
||||
# Import commonly used functions for easy access
|
||||
from .toolbox import (
|
||||
get_app_timezone,
|
||||
get_current_timestamp,
|
||||
get_utc_timestamp,
|
||||
convert_to_app_timezone,
|
||||
format_timestamp_for_display
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
'get_app_timezone',
|
||||
'get_current_timestamp',
|
||||
'get_utc_timestamp',
|
||||
'convert_to_app_timezone',
|
||||
'format_timestamp_for_display'
|
||||
]
|
||||
267
utils/db_logging.py
Normal file
267
utils/db_logging.py
Normal file
@@ -0,0 +1,267 @@
|
||||
import logging
|
||||
import traceback
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from flask import request, g, has_request_context
|
||||
from extensions import db
|
||||
from api.models import ErrorLog
|
||||
import threading
|
||||
import pytz
|
||||
from .toolbox import get_app_timezone, get_current_timestamp
|
||||
|
||||
|
||||
|
||||
class DatabaseLogHandler(logging.Handler):
|
||||
"""
|
||||
Custom logging handler that stores log records in the database.
|
||||
Configurable logging level via database settings.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.setLevel(logging.WARNING) # Default level, will be updated from config
|
||||
self._app = None # Store app reference
|
||||
self._processed_records = set() # Track processed records to avoid duplicates
|
||||
self._max_cache_size = 1000 # Limit cache size to prevent memory issues
|
||||
|
||||
def emit(self, record):
|
||||
"""
|
||||
Emit a log record to the database.
|
||||
This runs in a separate thread to avoid blocking the main application.
|
||||
"""
|
||||
# Import filter functions
|
||||
from .toolbox import get_filtered_loggers, get_filtered_message_patterns
|
||||
|
||||
# Get configured filters
|
||||
filtered_loggers = get_filtered_loggers()
|
||||
filtered_patterns = get_filtered_message_patterns()
|
||||
|
||||
# Skip database logging for filtered loggers to prevent feedback loops
|
||||
if record.name in filtered_loggers:
|
||||
return
|
||||
|
||||
# Also filter out messages containing specific patterns
|
||||
message = record.getMessage().lower()
|
||||
for pattern in filtered_patterns:
|
||||
if pattern.lower() in message:
|
||||
return
|
||||
|
||||
# Create a unique identifier for this record to prevent duplicates
|
||||
record_id = (
|
||||
record.name,
|
||||
record.levelname,
|
||||
record.getMessage(),
|
||||
record.created,
|
||||
getattr(record, 'pathname', ''),
|
||||
getattr(record, 'lineno', 0)
|
||||
)
|
||||
|
||||
# Check if we've already processed this exact record
|
||||
if record_id in self._processed_records:
|
||||
return
|
||||
|
||||
# Add to processed records cache
|
||||
self._processed_records.add(record_id)
|
||||
|
||||
# Clean cache if it gets too large
|
||||
if len(self._processed_records) > self._max_cache_size:
|
||||
# Remove oldest half of entries (simple cleanup)
|
||||
self._processed_records = set(list(self._processed_records)[self._max_cache_size//2:])
|
||||
|
||||
# Store the app reference if we have an application context
|
||||
if not self._app:
|
||||
try:
|
||||
from flask import current_app
|
||||
self._app = current_app._get_current_object()
|
||||
except RuntimeError:
|
||||
# No application context available, try to import app
|
||||
try:
|
||||
from app import app
|
||||
self._app = app
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# Use a thread to avoid blocking the main application
|
||||
threading.Thread(target=self._emit_to_db, args=(record,), daemon=True).start()
|
||||
|
||||
def _emit_to_db(self, record):
|
||||
"""
|
||||
Actually write the log record to the database.
|
||||
This method runs in a separate thread.
|
||||
"""
|
||||
try:
|
||||
# Use the stored app reference or try to get it
|
||||
app = self._app
|
||||
if not app:
|
||||
try:
|
||||
from flask import current_app
|
||||
app = current_app._get_current_object()
|
||||
except RuntimeError:
|
||||
# No application context, try to import app
|
||||
try:
|
||||
from app import app
|
||||
except ImportError:
|
||||
print("Could not import app for database logging")
|
||||
return
|
||||
|
||||
with app.app_context():
|
||||
# Extract request information if available
|
||||
request_id = None
|
||||
user_id = None
|
||||
remote_addr = None
|
||||
|
||||
if has_request_context():
|
||||
try:
|
||||
request_id = getattr(g, 'request_id', str(uuid.uuid4())[:8])
|
||||
user_id = getattr(g, 'user_id', None)
|
||||
remote_addr = request.remote_addr
|
||||
except Exception:
|
||||
# If we can't get request context, continue without it
|
||||
pass
|
||||
|
||||
# Format exception info if present
|
||||
exception_text = None
|
||||
if record.exc_info:
|
||||
exception_text = ''.join(traceback.format_exception(*record.exc_info))
|
||||
|
||||
# Create error log entry
|
||||
error_log = ErrorLog(
|
||||
level=record.levelname,
|
||||
logger_name=record.name,
|
||||
message=self.format(record),
|
||||
timestamp=get_current_timestamp(),
|
||||
pathname=record.pathname if hasattr(record, 'pathname') else None,
|
||||
lineno=record.lineno if hasattr(record, 'lineno') else None,
|
||||
request_id=request_id,
|
||||
user_id=user_id,
|
||||
remote_addr=remote_addr,
|
||||
exception=exception_text
|
||||
)
|
||||
|
||||
db.session.add(error_log)
|
||||
db.session.commit()
|
||||
|
||||
except Exception as e:
|
||||
# If database logging fails, fall back to console logging
|
||||
# Don't raise the exception to avoid breaking the application
|
||||
print(f"Failed to log to database: {e}")
|
||||
|
||||
def setup_database_logging(app):
|
||||
"""
|
||||
Set up database logging for the Flask application.
|
||||
"""
|
||||
# Create and configure the database handler
|
||||
db_handler = DatabaseLogHandler()
|
||||
db_handler._app = app # Store app reference
|
||||
|
||||
# Set initial logging level from settings (will be updated dynamically)
|
||||
update_logging_level(app, db_handler)
|
||||
|
||||
# Set a formatter for the database logs
|
||||
formatter = logging.Formatter(
|
||||
'%(name)s - %(levelname)s - %(message)s'
|
||||
)
|
||||
db_handler.setFormatter(formatter)
|
||||
|
||||
# Store handler reference in app for dynamic updates
|
||||
app.db_handler = db_handler
|
||||
|
||||
# Only add to root logger to avoid duplicate logging
|
||||
# (app.logger propagates to root logger by default)
|
||||
root_logger = logging.getLogger()
|
||||
|
||||
# Check if we already have this handler to avoid duplicates
|
||||
handler_exists = False
|
||||
for handler in root_logger.handlers:
|
||||
if isinstance(handler, DatabaseLogHandler):
|
||||
handler_exists = True
|
||||
break
|
||||
|
||||
if not handler_exists:
|
||||
root_logger.addHandler(db_handler)
|
||||
|
||||
# Add request ID to flask request context
|
||||
@app.before_request
|
||||
def add_request_id():
|
||||
g.request_id = str(uuid.uuid4())[:8]
|
||||
if hasattr(g, 'api_key') and g.api_key and g.api_key.user_id:
|
||||
g.user_id = g.api_key.user_id
|
||||
elif hasattr(g, 'current_user') and g.current_user and hasattr(g.current_user, 'id'):
|
||||
g.user_id = g.current_user.id
|
||||
|
||||
app.logger.info("Database logging initialized")
|
||||
|
||||
def update_logging_level(app, db_handler=None):
|
||||
"""
|
||||
Update the database logging level from settings.
|
||||
"""
|
||||
try:
|
||||
with app.app_context():
|
||||
from auth.models import Settings
|
||||
settings = Settings.query.first()
|
||||
|
||||
if settings and hasattr(settings, 'log_level'):
|
||||
# Convert string to logging level
|
||||
level_map = {
|
||||
'DEBUG': logging.DEBUG,
|
||||
'INFO': logging.INFO,
|
||||
'WARNING': logging.WARNING,
|
||||
'ERROR': logging.ERROR,
|
||||
'CRITICAL': logging.CRITICAL
|
||||
}
|
||||
|
||||
new_level = level_map.get(settings.log_level, logging.WARNING)
|
||||
|
||||
# Update the handler if provided
|
||||
if db_handler:
|
||||
db_handler.setLevel(new_level)
|
||||
# Or get it from app if stored
|
||||
elif hasattr(app, 'db_handler'):
|
||||
app.db_handler.setLevel(new_level)
|
||||
|
||||
app.logger.info(f"Database logging level updated to: {settings.log_level}")
|
||||
else:
|
||||
# Default to WARNING if no setting found
|
||||
if db_handler:
|
||||
db_handler.setLevel(logging.WARNING)
|
||||
elif hasattr(app, 'db_handler'):
|
||||
app.db_handler.setLevel(logging.WARNING)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Failed to update logging level: {e}")
|
||||
|
||||
def get_available_log_levels():
|
||||
"""
|
||||
Get list of available logging levels for admin configuration.
|
||||
"""
|
||||
return [
|
||||
('DEBUG', 'Debug - All messages'),
|
||||
('INFO', 'Info - General information and above'),
|
||||
('WARNING', 'Warning - Warnings and above (default)'),
|
||||
('ERROR', 'Error - Errors and above'),
|
||||
('CRITICAL', 'Critical - Only critical errors')
|
||||
]
|
||||
|
||||
def log_error(message, exception=None, level=logging.ERROR):
|
||||
"""
|
||||
Convenience function to log errors to the database.
|
||||
|
||||
Args:
|
||||
message: Error message
|
||||
exception: Exception object (optional)
|
||||
level: Logging level (default: ERROR)
|
||||
"""
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
if exception:
|
||||
logger.log(level, message, exc_info=True)
|
||||
else:
|
||||
logger.log(level, message)
|
||||
|
||||
def log_warning(message):
|
||||
"""Convenience function to log warnings."""
|
||||
log_error(message, level=logging.WARNING)
|
||||
|
||||
def log_critical(message, exception=None):
|
||||
"""Convenience function to log critical errors."""
|
||||
log_error(message, exception, level=logging.CRITICAL)
|
||||
0
utils/health_check.py
Normal file
0
utils/health_check.py
Normal file
228
utils/rate_limiter.py
Normal file
228
utils/rate_limiter.py
Normal file
@@ -0,0 +1,228 @@
|
||||
import time
|
||||
import logging
|
||||
from functools import wraps
|
||||
from flask import request, jsonify, g
|
||||
import redis
|
||||
from werkzeug.exceptions import TooManyRequests
|
||||
import os
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Configure Redis connection (if available)
|
||||
def configure_redis(config=None):
|
||||
"""Configure Redis connection from config"""
|
||||
global redis_client
|
||||
|
||||
redis_url = None
|
||||
if config and config.has_option('rate_limiting', 'REDIS_URL'):
|
||||
redis_url = config.get('rate_limiting', 'REDIS_URL')
|
||||
|
||||
if not redis_url:
|
||||
redis_url = os.environ.get('REDIS_URL', None)
|
||||
|
||||
if redis_url:
|
||||
try:
|
||||
redis_client = redis.from_url(redis_url)
|
||||
redis_client.ping() # Test connection
|
||||
logger.info("Redis connected for rate limiting")
|
||||
except Exception as e:
|
||||
logger.warning(f"Redis connection failed for rate limiting: {str(e)}")
|
||||
redis_client = None
|
||||
else:
|
||||
logger.info("No Redis URL configured, using in-memory rate limiting")
|
||||
|
||||
# Initialize Redis to None
|
||||
redis_client = None
|
||||
|
||||
# In-memory rate limit storage (fallback if Redis is not available)
|
||||
rate_limit_storage = {}
|
||||
|
||||
def rate_limit(limit=60, per=60, scope_func=None):
|
||||
"""
|
||||
Rate limiting decorator for routes.
|
||||
|
||||
Args:
|
||||
limit (int): Maximum number of requests allowed in the time period
|
||||
per (int): Time period in seconds
|
||||
scope_func (callable): Function to determine rate limit scope (default: by IP)
|
||||
|
||||
Example usage:
|
||||
@app.route('/api/endpoint')
|
||||
@rate_limit(limit=10, per=60) # 10 requests per minute
|
||||
def api_endpoint():
|
||||
return jsonify({"status": "success"})
|
||||
"""
|
||||
def decorator(f):
|
||||
@wraps(f)
|
||||
def wrapped(*args, **kwargs):
|
||||
# Get scope key (default to IP address)
|
||||
if scope_func:
|
||||
scope = scope_func()
|
||||
else:
|
||||
# Default to client IP
|
||||
scope = get_remote_address()
|
||||
|
||||
# Create a unique key for this route and scope
|
||||
key = f"rate_limit:{request.path}:{scope}"
|
||||
|
||||
# Check rate limit
|
||||
current = get_rate_limit_value(key)
|
||||
|
||||
# If this is a new key or expired, initialize it
|
||||
if current is None:
|
||||
set_rate_limit_value(key, 1, per)
|
||||
current = 1
|
||||
else:
|
||||
# Increment counter
|
||||
current = increment_rate_limit_value(key)
|
||||
|
||||
# Add rate limit headers
|
||||
g.rate_limit_headers = {
|
||||
'X-RateLimit-Limit': str(limit),
|
||||
'X-RateLimit-Remaining': str(max(0, limit - current)),
|
||||
'X-RateLimit-Reset': str(int(time.time() + get_ttl(key)))
|
||||
}
|
||||
|
||||
# If over limit, return 429 Too Many Requests
|
||||
if current > limit:
|
||||
logger.warning(f"Rate limit exceeded for {scope} on {request.path}")
|
||||
response = jsonify({
|
||||
'error': 'Too many requests',
|
||||
'message': f'Rate limit of {limit} requests per {per} seconds exceeded'
|
||||
})
|
||||
response.status_code = 429
|
||||
|
||||
# Add rate limit headers to response
|
||||
for key, value in g.rate_limit_headers.items():
|
||||
response.headers[key] = value
|
||||
|
||||
return response
|
||||
|
||||
# Execute the original route function
|
||||
response = f(*args, **kwargs)
|
||||
|
||||
# Convert string response to Response object if needed
|
||||
from flask import make_response
|
||||
if isinstance(response, str):
|
||||
response = make_response(response)
|
||||
|
||||
# Add rate limit headers to response
|
||||
if hasattr(response, 'headers'):
|
||||
for key, value in g.rate_limit_headers.items():
|
||||
response.headers[key] = value
|
||||
|
||||
return response
|
||||
return wrapped
|
||||
return decorator
|
||||
|
||||
def get_remote_address():
|
||||
"""Get the client's IP address, respecting proxy headers"""
|
||||
# Check X-Forwarded-For header (used by Traefik and other proxies)
|
||||
if request.headers.get('X-Forwarded-For'):
|
||||
# Get the first IP in the chain (original client IP)
|
||||
forwarded_for = request.headers.get('X-Forwarded-For').split(',')[0].strip()
|
||||
logger.debug(f"Using X-Forwarded-For IP: {forwarded_for} (from: {request.headers.get('X-Forwarded-For')})")
|
||||
return forwarded_for
|
||||
|
||||
# Check X-Real-IP header (alternative proxy header)
|
||||
if request.headers.get('X-Real-IP'):
|
||||
real_ip = request.headers.get('X-Real-IP').strip()
|
||||
logger.debug(f"Using X-Real-IP: {real_ip}")
|
||||
return real_ip
|
||||
|
||||
# Fallback to direct connection IP
|
||||
remote_addr = request.remote_addr
|
||||
logger.debug(f"Using direct remote_addr: {remote_addr}")
|
||||
return remote_addr
|
||||
|
||||
# Redis implementations
|
||||
def get_rate_limit_value(key):
|
||||
"""Get current rate limit counter value"""
|
||||
if redis_client:
|
||||
value = redis_client.get(key)
|
||||
return int(value) if value else None
|
||||
else:
|
||||
# Fallback to in-memory storage
|
||||
if key in rate_limit_storage:
|
||||
# Check if expired
|
||||
if time.time() > rate_limit_storage[key]['expires']:
|
||||
del rate_limit_storage[key]
|
||||
return None
|
||||
return rate_limit_storage[key]['value']
|
||||
return None
|
||||
|
||||
def set_rate_limit_value(key, value, ttl):
|
||||
"""Set rate limit counter with TTL"""
|
||||
if redis_client:
|
||||
redis_client.setex(key, ttl, value)
|
||||
else:
|
||||
# Fallback to in-memory storage
|
||||
rate_limit_storage[key] = {
|
||||
'value': value,
|
||||
'expires': time.time() + ttl
|
||||
}
|
||||
|
||||
def increment_rate_limit_value(key):
|
||||
"""Increment rate limit counter"""
|
||||
if redis_client:
|
||||
return redis_client.incr(key)
|
||||
else:
|
||||
# Fallback to in-memory storage
|
||||
if key in rate_limit_storage:
|
||||
rate_limit_storage[key]['value'] += 1
|
||||
return rate_limit_storage[key]['value']
|
||||
return 1
|
||||
|
||||
def get_ttl(key):
|
||||
"""Get remaining TTL for a key"""
|
||||
if redis_client:
|
||||
ttl = redis_client.ttl(key)
|
||||
return max(0, ttl)
|
||||
else:
|
||||
# Fallback to in-memory storage
|
||||
if key in rate_limit_storage:
|
||||
return max(0, rate_limit_storage[key]['expires'] - time.time())
|
||||
return 0
|
||||
|
||||
# Utility to apply rate limits to entire blueprints
|
||||
def apply_rate_limits(app, config=None):
|
||||
"""Apply rate limits to sensitive routes"""
|
||||
if not config:
|
||||
return
|
||||
|
||||
# Check if rate limiting is enabled
|
||||
if not config.getboolean('rate_limiting', 'ENABLE_RATE_LIMITING', fallback=True):
|
||||
return
|
||||
|
||||
# Configure Redis connection
|
||||
configure_redis(config)
|
||||
|
||||
# Get rate limiting configuration
|
||||
login_limit = config.getint('rate_limiting', 'LOGIN_LIMIT', fallback=10)
|
||||
login_period = config.getint('rate_limiting', 'LOGIN_PERIOD', fallback=60)
|
||||
register_limit = config.getint('rate_limiting', 'REGISTER_LIMIT', fallback=5)
|
||||
register_period = config.getint('rate_limiting', 'REGISTER_PERIOD', fallback=300)
|
||||
api_limit = config.getint('rate_limiting', 'API_LIMIT', fallback=60)
|
||||
api_period = config.getint('rate_limiting', 'API_PERIOD', fallback=60)
|
||||
|
||||
# Login endpoint
|
||||
if 'auth.login' in app.view_functions:
|
||||
app.view_functions['auth.login'] = rate_limit(
|
||||
limit=login_limit, per=login_period
|
||||
)(app.view_functions['auth.login'])
|
||||
|
||||
# Register endpoint
|
||||
if 'auth.register' in app.view_functions:
|
||||
app.view_functions['auth.register'] = rate_limit(
|
||||
limit=register_limit, per=register_period
|
||||
)(app.view_functions['auth.register'])
|
||||
|
||||
# API endpoints
|
||||
api_routes = [route for route in app.url_map.iter_rules()
|
||||
if route.rule.startswith('/api/')]
|
||||
|
||||
for route in api_routes:
|
||||
if route.endpoint in app.view_functions:
|
||||
app.view_functions[route.endpoint] = rate_limit(
|
||||
limit=api_limit, per=api_period
|
||||
)(app.view_functions[route.endpoint])
|
||||
67
utils/security_headers.py
Normal file
67
utils/security_headers.py
Normal file
@@ -0,0 +1,67 @@
|
||||
import os
|
||||
import logging
|
||||
from flask import request, current_app
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def add_security_headers(response):
|
||||
"""
|
||||
Add security headers to HTTP responses
|
||||
|
||||
Headers added:
|
||||
- Content-Security-Policy: Prevents XSS attacks by specifying content sources
|
||||
- X-Content-Type-Options: Prevents MIME type sniffing
|
||||
- X-Frame-Options: Prevents clickjacking
|
||||
- X-XSS-Protection: Additional XSS protection for older browsers
|
||||
- Referrer-Policy: Controls referrer information
|
||||
- Strict-Transport-Security: Enforces HTTPS
|
||||
- Permissions-Policy: Controls browser features
|
||||
"""
|
||||
# Content Security Policy - restricts sources of content
|
||||
csp = current_app.config.get('CONTENT_SECURITY_POLICY',
|
||||
"default-src 'self'; "
|
||||
"script-src 'self' 'unsafe-inline'; "
|
||||
"style-src 'self' 'unsafe-inline'; "
|
||||
"img-src 'self' data:; "
|
||||
"font-src 'self'; "
|
||||
"connect-src 'self'; "
|
||||
"frame-ancestors 'self'; "
|
||||
"form-action 'self'; "
|
||||
"base-uri 'self'"
|
||||
)
|
||||
response.headers['Content-Security-Policy'] = csp
|
||||
|
||||
# Prevent MIME type sniffing
|
||||
response.headers['X-Content-Type-Options'] = 'nosniff'
|
||||
|
||||
# Prevent clickjacking
|
||||
response.headers['X-Frame-Options'] = 'SAMEORIGIN'
|
||||
|
||||
# Additional XSS protection for older browsers
|
||||
response.headers['X-XSS-Protection'] = '1; mode=block'
|
||||
|
||||
# Control referrer information
|
||||
response.headers['Referrer-Policy'] = 'strict-origin-when-cross-origin'
|
||||
|
||||
# HSTS - force HTTPS (only in production)
|
||||
if not current_app.debug and not current_app.testing:
|
||||
hsts_enabled = current_app.config.get('HSTS_ENABLED', True)
|
||||
if hsts_enabled:
|
||||
hsts_max_age = current_app.config.get('HSTS_MAX_AGE', 31536000)
|
||||
response.headers['Strict-Transport-Security'] = f'max-age={hsts_max_age}; includeSubDomains'
|
||||
|
||||
# Permissions Policy (formerly Feature-Policy)
|
||||
response.headers['Permissions-Policy'] = (
|
||||
'camera=(), microphone=(), geolocation=(), interest-cohort=()'
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
def setup_security_headers(app, config=None):
|
||||
"""Register security headers middleware with Flask app"""
|
||||
if config and config.getboolean('security', 'ENABLE_SECURITY_HEADERS', fallback=True):
|
||||
# Set CSP from config if available
|
||||
if config.has_option('security', 'CONTENT_SECURITY_POLICY'):
|
||||
app.config['CONTENT_SECURITY_POLICY'] = config.get('security', 'CONTENT_SECURITY_POLICY')
|
||||
|
||||
app.after_request(add_security_headers)
|
||||
179
utils/toolbox.py
Normal file
179
utils/toolbox.py
Normal file
@@ -0,0 +1,179 @@
|
||||
"""
|
||||
Timezone utility functions for the Domain Logons application.
|
||||
|
||||
This module provides timezone-related utilities that can be used throughout
|
||||
the application without causing circular import issues.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
import pytz
|
||||
|
||||
|
||||
def get_app_timezone():
|
||||
"""Get the application timezone from config."""
|
||||
try:
|
||||
# Try to get app from Flask context first
|
||||
from flask import current_app
|
||||
app = current_app._get_current_object()
|
||||
if app and hasattr(app, 'config'):
|
||||
tz_name = app.config.get('TIMEZONE', 'Europe/London')
|
||||
return pytz.timezone(tz_name)
|
||||
except RuntimeError:
|
||||
# No application context, try to get config from os.environ or config file
|
||||
try:
|
||||
import configparser
|
||||
import os
|
||||
config = configparser.ConfigParser()
|
||||
config_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'config.ini')
|
||||
if os.path.exists(config_path):
|
||||
config.read(config_path)
|
||||
tz_name = config.get('app', 'TIMEZONE', fallback='Europe/London')
|
||||
return pytz.timezone(tz_name)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
# Fallback to UTC
|
||||
return pytz.UTC
|
||||
|
||||
|
||||
def get_current_timestamp():
|
||||
"""Get current timestamp in the application's configured timezone."""
|
||||
app_tz = get_app_timezone()
|
||||
# Use timezone.utc instead of deprecated utcnow()
|
||||
utc_now = datetime.now(timezone.utc)
|
||||
# Convert to application timezone
|
||||
return utc_now.astimezone(app_tz).replace(tzinfo=None) # Store as naive datetime in app timezone
|
||||
|
||||
|
||||
def get_utc_timestamp():
|
||||
"""Get current UTC timestamp as timezone-aware datetime."""
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def convert_to_app_timezone(dt):
|
||||
"""
|
||||
Convert a datetime to the application's configured timezone.
|
||||
|
||||
Args:
|
||||
dt: datetime object (can be naive or timezone-aware)
|
||||
|
||||
Returns:
|
||||
datetime: timezone-aware datetime in application timezone
|
||||
"""
|
||||
app_tz = get_app_timezone()
|
||||
|
||||
if dt.tzinfo is None:
|
||||
# If naive, assume it's already in the application timezone
|
||||
return app_tz.localize(dt)
|
||||
else:
|
||||
# If timezone-aware, convert to application timezone
|
||||
return dt.astimezone(app_tz)
|
||||
|
||||
|
||||
def format_timestamp_for_display(dt):
|
||||
"""
|
||||
Format a datetime for display with timezone information.
|
||||
|
||||
Args:
|
||||
dt: datetime object
|
||||
|
||||
Returns:
|
||||
str: formatted timestamp string
|
||||
"""
|
||||
if dt is None:
|
||||
return ""
|
||||
|
||||
# Convert to app timezone if needed
|
||||
if dt.tzinfo is None:
|
||||
# Assume naive datetime is already in app timezone
|
||||
app_tz = get_app_timezone()
|
||||
localized_dt = app_tz.localize(dt)
|
||||
else:
|
||||
# Convert timezone-aware datetime to app timezone
|
||||
localized_dt = convert_to_app_timezone(dt)
|
||||
|
||||
return localized_dt.strftime('%Y-%m-%d %H:%M:%S %Z')
|
||||
|
||||
|
||||
def get_filtered_loggers():
|
||||
"""
|
||||
Get list of logger names that should be filtered out of database logging.
|
||||
This helps prevent feedback loops and reduces noise.
|
||||
"""
|
||||
default_filters = []
|
||||
|
||||
try:
|
||||
# Try to get app from Flask context first
|
||||
from flask import current_app
|
||||
app = current_app._get_current_object()
|
||||
if app and hasattr(app, 'config'):
|
||||
# Get filters from app config (which loads from config.ini)
|
||||
filter_string = app.config.get('DB_LOGGING_FILTERED_LOGGERS', '')
|
||||
if filter_string:
|
||||
return [logger.strip() for logger in filter_string.split(',') if logger.strip()]
|
||||
except RuntimeError:
|
||||
# No application context, try to read config directly
|
||||
try:
|
||||
import configparser
|
||||
import os
|
||||
config = configparser.ConfigParser()
|
||||
config_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'config.ini')
|
||||
if os.path.exists(config_path):
|
||||
config.read(config_path)
|
||||
filter_string = config.get('logging', 'DB_LOGGING_FILTERED_LOGGERS', fallback='')
|
||||
if filter_string:
|
||||
return [logger.strip() for logger in filter_string.split(',') if logger.strip()]
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Fallback defaults if config not available
|
||||
return [
|
||||
'watchfiles.main',
|
||||
'watchfiles.watcher',
|
||||
'watchdog',
|
||||
'uvicorn.access'
|
||||
]
|
||||
|
||||
|
||||
def get_filtered_message_patterns():
|
||||
"""
|
||||
Get list of message patterns that should be filtered out of database logging.
|
||||
"""
|
||||
default_patterns = []
|
||||
|
||||
try:
|
||||
# Try to get app from Flask context first
|
||||
from flask import current_app
|
||||
app = current_app._get_current_object()
|
||||
if app and hasattr(app, 'config'):
|
||||
# Get patterns from app config (which loads from config.ini)
|
||||
pattern_string = app.config.get('DB_LOGGING_FILTERED_PATTERNS', '')
|
||||
if pattern_string:
|
||||
return [pattern.strip() for pattern in pattern_string.split(',') if pattern.strip()]
|
||||
except RuntimeError:
|
||||
# No application context, try to read config directly
|
||||
try:
|
||||
import configparser
|
||||
import os
|
||||
config = configparser.ConfigParser()
|
||||
config_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'config.ini')
|
||||
if os.path.exists(config_path):
|
||||
config.read(config_path)
|
||||
pattern_string = config.get('logging', 'DB_LOGGING_FILTERED_PATTERNS', fallback='')
|
||||
if pattern_string:
|
||||
return [pattern.strip() for pattern in pattern_string.split(',') if pattern.strip()]
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Fallback defaults if config not available
|
||||
return [
|
||||
'database.db',
|
||||
'instance/',
|
||||
'file changed',
|
||||
'reloading'
|
||||
]
|
||||
BIN
windows_agent/winagentUSM.exe
Normal file
BIN
windows_agent/winagentUSM.exe
Normal file
Binary file not shown.
Reference in New Issue
Block a user