05/24 Enhance functionalities
This commit is contained in:
@@ -3,8 +3,10 @@ routes/admin_settings.py — Application settings (email, Groq API, etc.)
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from flask import Blueprint, render_template, request, redirect, url_for, flash, session
|
import os
|
||||||
from config import get_settings_dict, set_setting
|
import requests as http_requests
|
||||||
|
from flask import Blueprint, render_template, request, redirect, url_for, flash, session, jsonify
|
||||||
|
from config import get_settings_dict, set_setting, get_setting
|
||||||
from models import log_action
|
from models import log_action
|
||||||
from utils.decorators import admin_required
|
from utils.decorators import admin_required
|
||||||
|
|
||||||
@@ -54,3 +56,49 @@ def save_groq():
|
|||||||
flash("Groq settings saved successfully.", "success")
|
flash("Groq settings saved successfully.", "success")
|
||||||
logger.info(f"Groq settings updated by admin_id={admin['id']}.")
|
logger.info(f"Groq settings updated by admin_id={admin['id']}.")
|
||||||
return redirect(url_for("admin_settings.settings"))
|
return redirect(url_for("admin_settings.settings"))
|
||||||
|
|
||||||
|
|
||||||
|
@admin_settings_bp.route("/test-email", methods=["POST"])
|
||||||
|
@admin_required
|
||||||
|
def test_email():
|
||||||
|
from utils.email import send_email
|
||||||
|
admin = session["user"]
|
||||||
|
to = (admin.get("email") or "").strip()
|
||||||
|
if not to:
|
||||||
|
return jsonify({"error": "Your admin account has no email address. Add one in User Management."}), 400
|
||||||
|
try:
|
||||||
|
send_email(
|
||||||
|
to,
|
||||||
|
"Website Checker — SMTP Test",
|
||||||
|
"This is a test email from Website Checker. Your SMTP configuration is working correctly.",
|
||||||
|
)
|
||||||
|
log_action(admin["id"], "TEST_EMAIL", "app_settings", None,
|
||||||
|
f"SMTP test email sent to {to}.")
|
||||||
|
return jsonify({"ok": True, "to": to})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"SMTP test failed: {e}")
|
||||||
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@admin_settings_bp.route("/test-groq", methods=["POST"])
|
||||||
|
@admin_required
|
||||||
|
def test_groq():
|
||||||
|
admin = session["user"]
|
||||||
|
api_key = (os.environ.get("GROQ_API_KEY") or "").strip() or get_setting("groq.api_key", "").strip()
|
||||||
|
if not api_key:
|
||||||
|
return jsonify({"error": "No Groq API key is configured. Save one first."}), 400
|
||||||
|
model = get_setting("groq.model", "llama-3.3-70b-versatile")
|
||||||
|
try:
|
||||||
|
resp = http_requests.post(
|
||||||
|
"https://api.groq.com/openai/v1/chat/completions",
|
||||||
|
headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
|
||||||
|
json={"model": model, "messages": [{"role": "user", "content": "Hi"}], "max_tokens": 5},
|
||||||
|
timeout=15,
|
||||||
|
)
|
||||||
|
if resp.ok:
|
||||||
|
log_action(admin["id"], "TEST_GROQ", "app_settings", None, "Groq API test successful.")
|
||||||
|
return jsonify({"ok": True, "model": model})
|
||||||
|
return jsonify({"error": f"Groq API returned {resp.status_code}: {resp.text[:200]}"}), 400
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Groq test failed: {e}")
|
||||||
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|||||||
+35
-2
@@ -3,9 +3,13 @@ routes/admin_users.py — User management CRUD routes.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from flask import Blueprint, render_template, request, redirect, url_for, flash, session
|
from flask import Blueprint, render_template, request, redirect, url_for, flash, session, jsonify
|
||||||
from models import get_all_users, create_user, update_user, delete_user
|
from models import (
|
||||||
|
get_all_users, create_user, update_user, delete_user,
|
||||||
|
get_user_by_id, create_password_reset_token, log_action,
|
||||||
|
)
|
||||||
from utils.decorators import admin_required
|
from utils.decorators import admin_required
|
||||||
|
from utils.email import send_email
|
||||||
|
|
||||||
logger = logging.getLogger("routes.admin_users")
|
logger = logging.getLogger("routes.admin_users")
|
||||||
admin_users_bp = Blueprint("admin_users", __name__, url_prefix="/admin/users")
|
admin_users_bp = Blueprint("admin_users", __name__, url_prefix="/admin/users")
|
||||||
@@ -82,3 +86,32 @@ def delete(user_id):
|
|||||||
flash(f"Error deleting user: {e}", "danger")
|
flash(f"Error deleting user: {e}", "danger")
|
||||||
|
|
||||||
return redirect(url_for("admin_users.users_list"))
|
return redirect(url_for("admin_users.users_list"))
|
||||||
|
|
||||||
|
|
||||||
|
@admin_users_bp.route("/<int:user_id>/send-reset", methods=["POST"])
|
||||||
|
@admin_required
|
||||||
|
def send_reset(user_id):
|
||||||
|
admin = session["user"]
|
||||||
|
user = get_user_by_id(user_id)
|
||||||
|
if not user:
|
||||||
|
return jsonify({"error": "User not found."}), 404
|
||||||
|
if not user.get("email"):
|
||||||
|
return jsonify({"error": f"User '{user['username']}' has no email address set."}), 400
|
||||||
|
try:
|
||||||
|
token = create_password_reset_token(user_id)
|
||||||
|
reset_url = url_for("auth.reset_password", token=token, _external=True)
|
||||||
|
body = (
|
||||||
|
f"Hi {user.get('full_name') or user['username']},\n\n"
|
||||||
|
f"An administrator has sent you a password reset link for your Website Checker account.\n"
|
||||||
|
f"Click the link below to set a new password (valid for 1 hour):\n\n"
|
||||||
|
f"{reset_url}\n\n"
|
||||||
|
f"If you did not expect this, please contact your administrator.\n"
|
||||||
|
)
|
||||||
|
send_email(user["email"], "Website Checker — Password Reset", body)
|
||||||
|
log_action(admin["id"], "SEND_RESET_LINK", "users", user_id,
|
||||||
|
f"Password reset link sent to '{user['username']}' ({user['email']}).")
|
||||||
|
logger.info(f"Reset email sent to user_id={user_id} by admin_id={admin['id']}.")
|
||||||
|
return jsonify({"ok": True, "to": user["email"]})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"send_reset error for user_id={user_id}: {e}")
|
||||||
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|||||||
+29
-1
@@ -2,9 +2,11 @@
|
|||||||
routes/bid_tracker.py — Bid / Opportunity tracker routes.
|
routes/bid_tracker.py — Bid / Opportunity tracker routes.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import csv
|
||||||
|
import io
|
||||||
import logging
|
import logging
|
||||||
from flask import (Blueprint, render_template, request, redirect, url_for,
|
from flask import (Blueprint, render_template, request, redirect, url_for,
|
||||||
flash, session, jsonify)
|
flash, session, jsonify, Response)
|
||||||
from models import (
|
from models import (
|
||||||
get_all_bids, get_bid, create_bid, update_bid, delete_bid,
|
get_all_bids, get_bid, create_bid, update_bid, delete_bid,
|
||||||
get_bid_updates, add_bid_update, delete_bid_update, BID_STATUSES,
|
get_bid_updates, add_bid_update, delete_bid_update, BID_STATUSES,
|
||||||
@@ -276,3 +278,29 @@ def send_reminders():
|
|||||||
flash(f"Failed to send email: {e}", "danger")
|
flash(f"Failed to send email: {e}", "danger")
|
||||||
|
|
||||||
return redirect(url_for("bid_tracker.bids_list"))
|
return redirect(url_for("bid_tracker.bids_list"))
|
||||||
|
|
||||||
|
|
||||||
|
@bid_tracker_bp.route("/export.csv")
|
||||||
|
@login_required
|
||||||
|
def export_csv():
|
||||||
|
status = request.args.get("status", "")
|
||||||
|
bids = get_all_bids(status_filter=status, limit=10000, offset=0)
|
||||||
|
buf = io.StringIO()
|
||||||
|
writer = csv.writer(buf)
|
||||||
|
writer.writerow([
|
||||||
|
"ID", "Title", "Source", "Solicitation #", "Status",
|
||||||
|
"Due Date", "URL", "Notes", "Added By", "Created At",
|
||||||
|
])
|
||||||
|
for b in bids:
|
||||||
|
writer.writerow([
|
||||||
|
b["id"], b.get("title", ""), b.get("source", ""),
|
||||||
|
b.get("solicitation_number", ""), b.get("status", ""),
|
||||||
|
str(b.get("due_date") or ""), b.get("url", ""),
|
||||||
|
b.get("notes", ""), b.get("added_by_username", ""),
|
||||||
|
str(b.get("created_at") or ""),
|
||||||
|
])
|
||||||
|
return Response(
|
||||||
|
buf.getvalue(),
|
||||||
|
mimetype="text/csv",
|
||||||
|
headers={"Content-Disposition": "attachment; filename=bids_export.csv"},
|
||||||
|
)
|
||||||
|
|||||||
@@ -52,6 +52,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
|
<span id="email-test-status" class="settings-test-status"></span>
|
||||||
|
<button type="button" class="btn btn-ghost" onclick="testConnection('email')">📬 Test</button>
|
||||||
<button class="btn btn-primary" type="submit">Save Email Settings</button>
|
<button class="btn btn-primary" type="submit">Save Email Settings</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@@ -77,9 +79,52 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
|
<span id="groq-test-status" class="settings-test-status"></span>
|
||||||
|
<button type="button" class="btn btn-ghost" onclick="testConnection('groq')">🔌 Test</button>
|
||||||
<button class="btn btn-primary" type="submit">Save AI Settings</button>
|
<button class="btn btn-primary" type="submit">Save AI Settings</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function testConnection(type) {
|
||||||
|
var url = type === 'email' ? '/admin/settings/test-email' : '/admin/settings/test-groq';
|
||||||
|
var status = document.getElementById(type + '-test-status');
|
||||||
|
var btn = event.target;
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = '…';
|
||||||
|
status.textContent = '';
|
||||||
|
status.className = 'settings-test-status';
|
||||||
|
fetch(url, { method: 'POST', headers: { 'X-CSRFToken': getCsrfToken() } })
|
||||||
|
.then(function(r) { return r.json(); })
|
||||||
|
.then(function(d) {
|
||||||
|
if (d.ok) {
|
||||||
|
status.classList.add('ok');
|
||||||
|
status.textContent = type === 'email'
|
||||||
|
? '✓ Test email sent to ' + d.to
|
||||||
|
: '✓ Connected — model: ' + d.model;
|
||||||
|
} else {
|
||||||
|
status.classList.add('err');
|
||||||
|
status.textContent = '✕ ' + d.error;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function() {
|
||||||
|
status.classList.add('err');
|
||||||
|
status.textContent = '✕ Request failed';
|
||||||
|
})
|
||||||
|
.finally(function() {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = type === 'email' ? '📬 Test' : '🔌 Test';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.settings-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 1.25rem; align-items: start; }
|
||||||
|
.settings-test-status { font-size: .78rem; margin-right: auto; max-width: 260px; }
|
||||||
|
.settings-test-status.ok { color: var(--success, #16a34a); }
|
||||||
|
.settings-test-status.err { color: var(--danger, #dc2626); }
|
||||||
|
@media(max-width:760px) { .settings-grid { grid-template-columns: 1fr; } }
|
||||||
|
</style>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -27,6 +27,11 @@
|
|||||||
<td class="actions">
|
<td class="actions">
|
||||||
<button class="btn btn-ghost btn-sm"
|
<button class="btn btn-ghost btn-sm"
|
||||||
onclick='openEditUser({{ u|tojson }})'>✎ Edit</button>
|
onclick='openEditUser({{ u|tojson }})'>✎ Edit</button>
|
||||||
|
{% if u.email %}
|
||||||
|
<button class="btn btn-ghost btn-sm js-send-reset"
|
||||||
|
data-id="{{ u.id }}" data-username="{{ u.username }}" data-email="{{ u.email }}"
|
||||||
|
title="Send password reset link to {{ u.email }}">✉ Reset</button>
|
||||||
|
{% endif %}
|
||||||
<form method="post" action="{{ url_for('admin_users.delete', user_id=u.id) }}"
|
<form method="post" action="{{ url_for('admin_users.delete', user_id=u.id) }}"
|
||||||
style="display:inline"
|
style="display:inline"
|
||||||
onsubmit="return confirm('Delete user {{ u.username }}?')">
|
onsubmit="return confirm('Delete user {{ u.username }}?')">
|
||||||
@@ -141,5 +146,34 @@ function openEditUser(u) {
|
|||||||
'/admin/users/' + u.id + '/edit';
|
'/admin/users/' + u.id + '/edit';
|
||||||
openModal('modal-edit-user');
|
openModal('modal-edit-user');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
document.querySelectorAll('.js-send-reset').forEach(function(btn) {
|
||||||
|
btn.addEventListener('click', function() {
|
||||||
|
var username = btn.dataset.username;
|
||||||
|
var email = btn.dataset.email;
|
||||||
|
if (!confirm('Send a password reset link to ' + username + ' (' + email + ')?')) return;
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = '…';
|
||||||
|
fetch('/admin/users/' + btn.dataset.id + '/send-reset', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'X-CSRFToken': getCsrfToken() },
|
||||||
|
}).then(function(r) { return r.json(); })
|
||||||
|
.then(function(d) {
|
||||||
|
if (d.ok) {
|
||||||
|
btn.textContent = '✓ Sent';
|
||||||
|
btn.style.color = 'var(--success, #16a34a)';
|
||||||
|
} else {
|
||||||
|
alert('Error: ' + d.error);
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = '✉ Reset';
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function() {
|
||||||
|
alert('Request failed. Please try again.');
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = '✉ Reset';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -113,7 +113,16 @@
|
|||||||
|
|
||||||
<!-- History card -->
|
<!-- History card -->
|
||||||
<div class="card mt-2">
|
<div class="card mt-2">
|
||||||
<div class="card-header"><span class="card-title">Analysis History</span></div>
|
<div class="card-header">
|
||||||
|
<span class="card-title">Analysis History</span>
|
||||||
|
<div class="flex gap-1" style="flex-wrap:wrap">
|
||||||
|
<button class="ai-hist-filter active" data-verdict="">All</button>
|
||||||
|
<button class="ai-hist-filter" data-verdict="PURSUE">PURSUE</button>
|
||||||
|
<button class="ai-hist-filter" data-verdict="PASS">PASS</button>
|
||||||
|
<button class="ai-hist-filter" data-verdict="UNCLEAR">UNCLEAR</button>
|
||||||
|
<button class="ai-hist-filter" data-verdict="none">No verdict</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="table-container">
|
<div class="table-container">
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
@@ -484,6 +493,22 @@ function deleteAnalysis() {
|
|||||||
.catch(function() { alert('Delete failed. Please try again.'); });
|
.catch(function() { alert('Delete failed. Please try again.'); });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ─── History verdict filter ────────────────────────────────── */
|
||||||
|
document.querySelectorAll('.ai-hist-filter').forEach(function(btn) {
|
||||||
|
btn.addEventListener('click', function() {
|
||||||
|
document.querySelectorAll('.ai-hist-filter').forEach(function(b) { b.classList.remove('active'); });
|
||||||
|
btn.classList.add('active');
|
||||||
|
var verdict = btn.dataset.verdict;
|
||||||
|
document.querySelectorAll('.history-row').forEach(function(row) {
|
||||||
|
if (!verdict) { row.style.display = ''; return; }
|
||||||
|
var badge = row.querySelector('.badge');
|
||||||
|
var rowVerdict = badge ? badge.textContent.trim() : '';
|
||||||
|
var show = verdict === 'none' ? !badge : rowVerdict === verdict;
|
||||||
|
row.style.display = show ? '' : 'none';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
/* ─── Utility ───────────────────────────────────────────────── */
|
/* ─── Utility ───────────────────────────────────────────────── */
|
||||||
function escHtml(str) {
|
function escHtml(str) {
|
||||||
return String(str || '').replace(/[&<>"']/g, function(c) {
|
return String(str || '').replace(/[&<>"']/g, function(c) {
|
||||||
@@ -577,6 +602,13 @@ function escHtml(str) {
|
|||||||
|
|
||||||
/* ── History ───────────────────────────────────────────────── */
|
/* ── History ───────────────────────────────────────────────── */
|
||||||
.history-row:hover td{background:var(--bg-subtle)}
|
.history-row:hover td{background:var(--bg-subtle)}
|
||||||
|
.ai-hist-filter{
|
||||||
|
background:none;border:1px solid var(--border);
|
||||||
|
padding:.2rem .55rem;font-size:.72rem;border-radius:var(--r-sm);
|
||||||
|
cursor:pointer;color:var(--text-secondary);font-family:inherit;
|
||||||
|
transition:background var(--t),color var(--t);
|
||||||
|
}
|
||||||
|
.ai-hist-filter.active{background:var(--accent);border-color:var(--accent);color:#fff}
|
||||||
|
|
||||||
@media(max-width:960px){
|
@media(max-width:960px){
|
||||||
.ai-layout{grid-template-columns:1fr}
|
.ai-layout{grid-template-columns:1fr}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button class="btn btn-secondary btn-sm" onclick="loadBids()">↻ Refresh</button>
|
<button class="btn btn-secondary btn-sm" onclick="loadBids()">↻ Refresh</button>
|
||||||
|
<button class="btn btn-secondary btn-sm" onclick="exportCsv()" title="Download current view as CSV">⬇ CSV</button>
|
||||||
{% if session['user']['role'] == 'admin' %}
|
{% if session['user']['role'] == 'admin' %}
|
||||||
<form method="post" action="{{ url_for('bid_tracker.send_reminders') }}" style="display:inline">
|
<form method="post" action="{{ url_for('bid_tracker.send_reminders') }}" style="display:inline">
|
||||||
<button class="btn btn-secondary btn-sm" type="submit" title="Email a digest of bids due within 7 days to admins">📧 Remind</button>
|
<button class="btn btn-secondary btn-sm" type="submit" title="Email a digest of bids due within 7 days to admins">📧 Remind</button>
|
||||||
@@ -172,6 +173,12 @@ var _allBids = []; // cached from last load
|
|||||||
var _bidOffset = 0;
|
var _bidOffset = 0;
|
||||||
var _hasMore = false;
|
var _hasMore = false;
|
||||||
var _PAGE_SIZE = 50;
|
var _PAGE_SIZE = 50;
|
||||||
|
|
||||||
|
function exportCsv() {
|
||||||
|
var url = '/bids/export.csv';
|
||||||
|
if (_activeStatus) url += '?status=' + encodeURIComponent(_activeStatus);
|
||||||
|
window.location.href = url;
|
||||||
|
}
|
||||||
var _currentUserId = {{ session['user']['id'] }};
|
var _currentUserId = {{ session['user']['id'] }};
|
||||||
var _isAdmin = {{ 'true' if session['user']['role'] == 'admin' else 'false' }};
|
var _isAdmin = {{ 'true' if session['user']['role'] == 'admin' else 'false' }};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user