Add Activity log Service and model
This commit is contained in:
@@ -37,6 +37,7 @@ def register_blueprints(app):
|
|||||||
from app.routes.file_format import file_format_bp
|
from app.routes.file_format import file_format_bp
|
||||||
|
|
||||||
# new
|
# new
|
||||||
|
from app.routes.activity_routes import activity_bp
|
||||||
from app.routes.engineering import engi_bp
|
from app.routes.engineering import engi_bp
|
||||||
|
|
||||||
app.register_blueprint(auth_bp)
|
app.register_blueprint(auth_bp)
|
||||||
@@ -49,6 +50,7 @@ def register_blueprints(app):
|
|||||||
app.register_blueprint(file_format_bp)
|
app.register_blueprint(file_format_bp)
|
||||||
|
|
||||||
# new
|
# new
|
||||||
|
app.register_blueprint(activity_bp)
|
||||||
app.register_blueprint(engi_bp)
|
app.register_blueprint(engi_bp)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
59
app/routes/activity_routes.py
Normal file
59
app/routes/activity_routes.py
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
from flask import Blueprint, render_template, request, send_file, abort
|
||||||
|
import os
|
||||||
|
|
||||||
|
from app.utils.helpers import login_required
|
||||||
|
from app.utils.file_utils import get_logs_folder , ALLOWED_LOG_FILE
|
||||||
|
from app.services.activity_service import ActivityService
|
||||||
|
|
||||||
|
activity_bp = Blueprint("activity", __name__, url_prefix="/activity")
|
||||||
|
|
||||||
|
# call activity_log page
|
||||||
|
@activity_bp.route("/")
|
||||||
|
@login_required
|
||||||
|
def activity():
|
||||||
|
file_name = request.args.get("file", "app.log")
|
||||||
|
search = request.args.get("search", "")
|
||||||
|
level = request.args.get("level", "").upper()
|
||||||
|
user = request.args.get("user", "")
|
||||||
|
from_date = request.args.get("from_date", "")
|
||||||
|
to_date = request.args.get("to_date", "")
|
||||||
|
|
||||||
|
logs = ActivityService.read_logs(
|
||||||
|
file_name=file_name,
|
||||||
|
search=search,
|
||||||
|
level=level,
|
||||||
|
user=user,
|
||||||
|
from_date=from_date,
|
||||||
|
to_date=to_date
|
||||||
|
)
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
"activity/activity_log.html",
|
||||||
|
logs=logs,
|
||||||
|
file_name=file_name,
|
||||||
|
search=search,
|
||||||
|
level=level,
|
||||||
|
user=user,
|
||||||
|
from_date=from_date,
|
||||||
|
to_date=to_date
|
||||||
|
)
|
||||||
|
|
||||||
|
# Download activity_log files
|
||||||
|
@activity_bp.route("/logs")
|
||||||
|
@login_required
|
||||||
|
def download_log():
|
||||||
|
|
||||||
|
filename = request.args.get("file", "app.log")
|
||||||
|
if filename not in ALLOWED_LOG_FILE:
|
||||||
|
abort(400, "Invalid log file.")
|
||||||
|
|
||||||
|
file_path = os.path.join(get_logs_folder(), filename)
|
||||||
|
if not os.path.isfile(file_path):
|
||||||
|
abort(404, "Log file not found.")
|
||||||
|
|
||||||
|
return send_file(
|
||||||
|
file_path,
|
||||||
|
as_attachment=True,
|
||||||
|
download_name=filename,
|
||||||
|
mimetype="text/plain"
|
||||||
|
)
|
||||||
56
app/services/activity_service.py
Normal file
56
app/services/activity_service.py
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Activity log Service
|
||||||
|
class ActivityService:
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def read_logs(file_name="", search="", level="", user="", from_date="", to_date=""):
|
||||||
|
|
||||||
|
file = Path("logs") / file_name
|
||||||
|
|
||||||
|
if not file.exists():
|
||||||
|
return []
|
||||||
|
|
||||||
|
logs = []
|
||||||
|
|
||||||
|
with open(file, "r", encoding="utf-8") as f:
|
||||||
|
|
||||||
|
for line in reversed(f.readlines()):
|
||||||
|
# Example:
|
||||||
|
# 2026-08-01 10:15:55 | INFO | admin | Dashboard | Dashboard Opened
|
||||||
|
parts = [x.strip() for x in line.split("|")]
|
||||||
|
|
||||||
|
if len(parts) < 5:
|
||||||
|
continue
|
||||||
|
|
||||||
|
record = {
|
||||||
|
"date": parts[0],
|
||||||
|
"level": parts[1],
|
||||||
|
"user": parts[2],
|
||||||
|
"module": parts[3],
|
||||||
|
"message": "|".join(parts[4:])
|
||||||
|
}
|
||||||
|
|
||||||
|
# Search Filter
|
||||||
|
if search and search.lower() not in line.lower():
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Level Filter
|
||||||
|
if level and record["level"] != level:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# User Filter
|
||||||
|
if user and user.lower() not in record["user"].lower():
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Date Filter
|
||||||
|
if from_date and record["date"][:10] < from_date:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if to_date and record["date"][:10] > to_date:
|
||||||
|
continue
|
||||||
|
|
||||||
|
logs.append(record)
|
||||||
|
|
||||||
|
return logs
|
||||||
208
app/templates/activity/activity_log.html
Normal file
208
app/templates/activity/activity_log.html
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<div class="container-fluid py-4">
|
||||||
|
|
||||||
|
<!-- HEADER -->
|
||||||
|
<div class="card shadow-sm border-0 mb-4">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex flex-column flex-lg-row justify-content-between align-items-lg-center">
|
||||||
|
<div class="mb-3 mb-lg-0">
|
||||||
|
<h3 class="fw-bold text-primary mb-1"> <i class="bi bi-clock-history me-2"></i> Activity Logs </h3>
|
||||||
|
<small class="text-muted"> View application logs, search records and download log files. </small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex flex-wrap gap-2">
|
||||||
|
<a id="downloadLogBtn" href="{{ url_for('activity.download_log') }}?file={{ file_name }}" class="btn btn-success">
|
||||||
|
<i class="bi bi-download me-1"></i>
|
||||||
|
Download Log
|
||||||
|
</a>
|
||||||
|
<a href="{{ url_for('activity.activity') }}" class="btn btn-outline-primary">
|
||||||
|
<i class="bi bi-arrow-clockwise me-1"></i>
|
||||||
|
Refresh
|
||||||
|
</a>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- FILTERS -->
|
||||||
|
<div class="card shadow-sm mb-4">
|
||||||
|
|
||||||
|
<div class="card-header bg-primary text-white">
|
||||||
|
<h5 class="mb-0">
|
||||||
|
<i class="bi bi-funnel-fill me-2"></i> Filters
|
||||||
|
</h5>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-body">
|
||||||
|
|
||||||
|
<form method="GET">
|
||||||
|
<div class="row g-3">
|
||||||
|
<!-- Log File select -->
|
||||||
|
<div class="col-xl-2 col-lg-4 col-md-6">
|
||||||
|
<label class="form-label fw-bold">Log File</label>
|
||||||
|
<select class="form-select" name="file">
|
||||||
|
<option value="app.log" {% if file_name=="app.log" %}selected{% endif %}> Application </option>
|
||||||
|
|
||||||
|
<option value="debug.log" {% if file_name=="debug.log" %}selected{% endif %}> Debug </option>
|
||||||
|
|
||||||
|
<option value="error.log" {% if file_name=="error.log" %}selected{% endif %}> Error </option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<!-- User search -->
|
||||||
|
<div class="col-xl-2 col-lg-4 col-md-6">
|
||||||
|
<label class="form-label fw-bold">User</label>
|
||||||
|
<input type="text" class="form-control" name="user" value="{{ user }}" placeholder="Username">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Level -->
|
||||||
|
<div class="col-xl-2 col-lg-4 col-md-6">
|
||||||
|
<label class="form-label fw-bold">Level</label>
|
||||||
|
<select class="form-select" name="level">
|
||||||
|
<option value="">All</option>
|
||||||
|
|
||||||
|
<option value="INFO"
|
||||||
|
{% if level=="INFO" %}selected{% endif %}>
|
||||||
|
INFO
|
||||||
|
</option>
|
||||||
|
|
||||||
|
<option value="WARNING"
|
||||||
|
{% if level=="WARNING" %}selected{% endif %}>
|
||||||
|
WARNING
|
||||||
|
</option>
|
||||||
|
|
||||||
|
<option value="ERROR"
|
||||||
|
{% if level=="ERROR" %}selected{% endif %}>
|
||||||
|
ERROR
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- From Date -->
|
||||||
|
<div class="col-xl-2 col-lg-4 col-md-6">
|
||||||
|
<label class="form-label fw-bold">From Date</label>
|
||||||
|
<input type="date" class="form-control" name="from_date" value="{{ from_date }}">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- To Date -->
|
||||||
|
<div class="col-xl-2 col-lg-4 col-md-6">
|
||||||
|
<label class="form-label fw-bold">To Date</label>
|
||||||
|
<input type="date" class="form-control" name="to_date" value="{{ to_date }}">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Search button -->
|
||||||
|
<div class="col-xl-2 col-lg-12">
|
||||||
|
<label class="form-label fw-bold">Search</label>
|
||||||
|
<input type="text" class="form-control" name="search" value="{{ search }}" placeholder="Search">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<!-- Search and Reset button -->
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
|
||||||
|
<button class="btn btn-primary">
|
||||||
|
<i class="bi bi-search"></i> Search
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button type="reset" class="btn btn-secondary">
|
||||||
|
<i class="bi bi-arrow-clockwise"></i> Reset
|
||||||
|
</button>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- LOG TABLE -->
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<!-- Log Entries -->
|
||||||
|
<div class="card-header bg-dark text-white">
|
||||||
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
|
<h5 class="mb-0">
|
||||||
|
<i class="bi bi-list-ul me-2"></i>
|
||||||
|
Log Entries
|
||||||
|
</h5>
|
||||||
|
|
||||||
|
<span class="badge bg-light text-dark">
|
||||||
|
{{ logs|length }} Records
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Show table Entries -->
|
||||||
|
<div class="card-body p-0">
|
||||||
|
<div class="table-responsive" style="max-height:600px; overflow-y:auto; font-size:12px;">
|
||||||
|
<table class="table table-striped table-hover table-bordered align-middle mb-0 small">
|
||||||
|
|
||||||
|
<thead class="table-dark sticky-top">
|
||||||
|
<tr>
|
||||||
|
<th>Sr No</th>
|
||||||
|
<th>Date & Time</th>
|
||||||
|
<th>User</th>
|
||||||
|
<th>Level</th>
|
||||||
|
<th>Module</th>
|
||||||
|
<th>Activity</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
|
||||||
|
<tbody>
|
||||||
|
|
||||||
|
{% for log in logs %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ loop.index }}</td>
|
||||||
|
<td>{{ log.date }}</td>
|
||||||
|
<td>{{ log.user }}</td>
|
||||||
|
<td>
|
||||||
|
{% if log.level=="INFO" %}
|
||||||
|
<span class="badge bg-success">INFO</span>
|
||||||
|
{% elif log.level=="WARNING" %}
|
||||||
|
<span class="badge bg-warning text-dark">WARNING</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-danger">ERROR</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td>{{ log.module }}</td>
|
||||||
|
<td>{{ log.message }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.addEventListener("DOMContentLoaded", function () {
|
||||||
|
|
||||||
|
const fileSelect = document.querySelector('select[name="file"]');
|
||||||
|
const downloadBtn = document.getElementById("downloadLogBtn");
|
||||||
|
|
||||||
|
function updateDownloadLink() {
|
||||||
|
|
||||||
|
downloadBtn.href =
|
||||||
|
"{{ url_for('activity.download_log') }}?file=" +
|
||||||
|
encodeURIComponent(fileSelect.value);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
updateDownloadLink();
|
||||||
|
|
||||||
|
fileSelect.addEventListener("change", updateDownloadLink);
|
||||||
|
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
@@ -168,6 +168,12 @@
|
|||||||
<i class="bi bi-speedometer2 me-2"></i> Dashboard
|
<i class="bi bi-speedometer2 me-2"></i> Dashboard
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
<li>
|
||||||
|
<a class="dropdown-item" href="{{ url_for('activity.activity') }}">
|
||||||
|
<i class="bi bi-clock-history me-2"></i>
|
||||||
|
Activity Log
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
|
||||||
<li>
|
<li>
|
||||||
<a class="dropdown-item text-warning" href="/logout">
|
<a class="dropdown-item text-warning" href="/logout">
|
||||||
@@ -188,8 +194,7 @@
|
|||||||
<!-- PAGE CONTENT -->
|
<!-- PAGE CONTENT -->
|
||||||
<div class="container-fluid vh-100 pt-5 overflow-hidden">
|
<div class="container-fluid vh-100 pt-5 overflow-hidden">
|
||||||
<!-- FLASH MESSAGES -->
|
<!-- FLASH MESSAGES -->
|
||||||
<div class="position-fixed top-0 end-0 p-2 p-md-3 mt-5"
|
<div class="position-fixed top-0 end-0 p-2 p-md-3 mt-5" style="z-index:1080; width:min(95vw,500px);">
|
||||||
style="z-index:1080; width:min(95vw,500px);">
|
|
||||||
|
|
||||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||||
{% if messages %}
|
{% if messages %}
|
||||||
@@ -230,17 +235,13 @@
|
|||||||
|
|
||||||
<!-- Timeline -->
|
<!-- Timeline -->
|
||||||
<div class="progress mt-2" style="height:5px;">
|
<div class="progress mt-2" style="height:5px;">
|
||||||
<div class="progress-bar progress-bar-striped progress-bar-animated timer-bar"
|
<div class="progress-bar progress-bar-striped progress-bar-animated timer-bar" role="progressbar" style="width:100%">
|
||||||
role="progressbar"
|
|
||||||
style="width:100%">
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button type="button"
|
<button type="button" class="btn-close" data-bs-dismiss="alert">
|
||||||
class="btn-close"
|
|
||||||
data-bs-dismiss="alert">
|
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
@@ -298,80 +299,54 @@
|
|||||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap-multiselect@1.1.2/dist/js/bootstrap-multiselect.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap-multiselect@1.1.2/dist/js/bootstrap-multiselect.min.js"></script>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
// show alert msg
|
||||||
document.addEventListener("DOMContentLoaded", function () {
|
document.addEventListener("DOMContentLoaded", function () {
|
||||||
|
|
||||||
document.querySelectorAll(".notification-alert").forEach(function(alert){
|
document.querySelectorAll(".notification-alert").forEach(function(alert){
|
||||||
|
|
||||||
const progressBar = alert.querySelector(".timer-bar");
|
const progressBar = alert.querySelector(".timer-bar");
|
||||||
|
|
||||||
let width = 100;
|
let width = 100;
|
||||||
|
|
||||||
const interval = setInterval(function() {
|
const interval = setInterval(function() {
|
||||||
|
|
||||||
width -= 2;
|
width -= 2;
|
||||||
|
|
||||||
progressBar.style.width = width + "%";
|
progressBar.style.width = width + "%";
|
||||||
|
|
||||||
if(width <= 0){
|
if(width <= 0){
|
||||||
|
|
||||||
clearInterval(interval);
|
clearInterval(interval);
|
||||||
|
|
||||||
const bsAlert = bootstrap.Alert.getOrCreateInstance(alert);
|
const bsAlert = bootstrap.Alert.getOrCreateInstance(alert);
|
||||||
|
|
||||||
bsAlert.close();
|
bsAlert.close();
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}, 100);
|
}, 100);
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
// New
|
|
||||||
|
// show msg Processing your request
|
||||||
function showLoader(message = "Processing your request...") {
|
function showLoader(message = "Processing your request...") {
|
||||||
|
|
||||||
document.getElementById("loaderMessage").innerHTML = message;
|
document.getElementById("loaderMessage").innerHTML = message;
|
||||||
|
|
||||||
document.getElementById("globalLoader").classList.remove("d-none");
|
document.getElementById("globalLoader").classList.remove("d-none");
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Automatically hide
|
||||||
function hideLoader(){
|
function hideLoader(){
|
||||||
|
|
||||||
document.getElementById("globalLoader").classList.add("d-none");
|
document.getElementById("globalLoader").classList.add("d-none");
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Automatically apply to all forms having class="loading-form"
|
// Automatically apply to all forms having class="loading-form"
|
||||||
document.addEventListener("DOMContentLoaded", function () {
|
document.addEventListener("DOMContentLoaded", function () {
|
||||||
|
|
||||||
document.querySelectorAll(".loading-form").forEach(function(form){
|
document.querySelectorAll(".loading-form").forEach(function(form){
|
||||||
|
|
||||||
form.addEventListener("submit", function(){
|
form.addEventListener("submit", function(){
|
||||||
|
|
||||||
if(!this.checkValidity())
|
if(!this.checkValidity())
|
||||||
return;
|
return;
|
||||||
|
|
||||||
showLoader();
|
showLoader();
|
||||||
|
|
||||||
const btn = this.querySelector("button[type='submit']");
|
const btn = this.querySelector("button[type='submit']");
|
||||||
|
|
||||||
if(btn){
|
if(btn){
|
||||||
|
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
|
|
||||||
btn.innerHTML = `
|
btn.innerHTML = `
|
||||||
<span class="spinner-border spinner-border-sm me-2"></span>
|
<span class="spinner-border spinner-border-sm me-2"></span>
|
||||||
Processing...
|
Processing...
|
||||||
`;
|
`;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -5,7 +5,14 @@ from flask import current_app
|
|||||||
# file extension
|
# file extension
|
||||||
ALLOWED_EXTENSIONS = {"xlsx", "xls", "csv"}
|
ALLOWED_EXTENSIONS = {"xlsx", "xls", "csv"}
|
||||||
|
|
||||||
|
# .log file allowed
|
||||||
|
ALLOWED_LOG_FILE = {
|
||||||
|
"app.log",
|
||||||
|
"debug.log",
|
||||||
|
"error.log"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Get path of format excel folder
|
||||||
def get_download_format_folder():
|
def get_download_format_folder():
|
||||||
return os.path.join(
|
return os.path.join(
|
||||||
current_app.root_path,
|
current_app.root_path,
|
||||||
@@ -14,6 +21,7 @@ def get_download_format_folder():
|
|||||||
"format"
|
"format"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Get path of uploads folder
|
||||||
def get_uploads_folder():
|
def get_uploads_folder():
|
||||||
return os.path.join(
|
return os.path.join(
|
||||||
current_app.root_path,
|
current_app.root_path,
|
||||||
@@ -26,8 +34,7 @@ def ensure_upload_folder():
|
|||||||
os.makedirs(get_uploads_folder())
|
os.makedirs(get_uploads_folder())
|
||||||
|
|
||||||
|
|
||||||
|
# Get path of logs folder
|
||||||
def get_logs_folder():
|
def get_logs_folder():
|
||||||
return os.path.join(
|
project_root = os.path.dirname(current_app.root_path)
|
||||||
current_app.root_path,
|
return os.path.join(project_root, "logs")
|
||||||
"logs"
|
|
||||||
)
|
|
||||||
Reference in New Issue
Block a user