04/30 Web Checker web app ver. 1.0

This commit is contained in:
2026-04-30 17:15:56 -04:00
parent feb8e5051c
commit cd63d5a020
39 changed files with 8031 additions and 1 deletions
+1
View File
@@ -0,0 +1 @@
# routes package
+22
View File
@@ -0,0 +1,22 @@
"""
routes/admin_dashboard.py — Admin dashboard: today's completion stats.
"""
import logging
from flask import Blueprint, render_template
from models import get_admin_dashboard_stats
from utils.decorators import admin_required
logger = logging.getLogger("routes.admin_dashboard")
admin_dashboard_bp = Blueprint("admin_dashboard", __name__, url_prefix="/admin")
@admin_dashboard_bp.route("/dashboard")
@admin_required
def dashboard():
try:
stats = get_admin_dashboard_stats()
except Exception as e:
logger.error(f"Dashboard stats error: {e}")
stats = {"user_stats": [], "total_sites": 0, "total_users": 0, "active_today": 0}
return render_template("admin/dashboard.html", stats=stats)
+49
View File
@@ -0,0 +1,49 @@
"""
routes/admin_logs.py — Activity log and app log routes.
"""
import logging
from flask import Blueprint, render_template, request, redirect, url_for, flash, session
from models import get_activity_log, get_app_log, purge_app_log, log_action
from utils.decorators import admin_required
logger = logging.getLogger("routes.admin_logs")
admin_logs_bp = Blueprint("admin_logs", __name__, url_prefix="/admin/logs")
@admin_logs_bp.route("/")
@admin_required
def logs():
tab = request.args.get("tab", "activity")
search = request.args.get("search", "").strip()
limit = int(request.args.get("limit", 200))
activity_rows = []
app_rows = []
level_filter = request.args.get("level", "")
if tab == "activity":
activity_rows = get_activity_log(limit=limit, search=search)
else:
app_rows = get_app_log(limit=limit, level_filter=level_filter, search=search)
return render_template("admin/logs.html",
tab=tab,
activity_rows=activity_rows,
app_rows=app_rows,
search=search,
level_filter=level_filter,
limit=limit)
@admin_logs_bp.route("/purge", methods=["POST"])
@admin_required
def purge():
days = int(request.form.get("days", 30))
admin = session["user"]
deleted = purge_app_log(older_than_days=days)
log_action(admin["id"], "PURGE_APP_LOG", "app_log", None,
f"Purged {deleted} app log records older than {days} days.")
flash(f"Purged {deleted} app log records older than {days} days.", "success")
logger.info(f"App log purged: {deleted} records by admin_id={admin['id']}.")
return redirect(url_for("admin_logs.logs", tab="app"))
+102
View File
@@ -0,0 +1,102 @@
"""
routes/admin_reports.py — Shift detail, unchecked, and summary reports.
"""
import logging
import io
import csv
from datetime import date, timedelta
from flask import Blueprint, render_template, request, send_file, Response
from models import (
get_shift_report, get_unchecked_report, get_summary_report,
get_report_filter_options,
)
from utils.decorators import admin_required
logger = logging.getLogger("routes.admin_reports")
admin_reports_bp = Blueprint("admin_reports", __name__, url_prefix="/admin/reports")
def _today():
return date.today()
@admin_reports_bp.route("/")
@admin_required
def reports():
tab = request.args.get("tab", "detail")
date_from = request.args.get("date_from") or str(_today() - timedelta(days=30))
date_to = request.args.get("date_to") or str(_today())
user_id = request.args.get("user_id")
website_id = request.args.get("website_id")
target_date = request.args.get("target_date") or str(_today())
users, websites = get_report_filter_options()
rows = []
try:
if tab == "detail":
rows = get_shift_report(
date_from=date_from, date_to=date_to,
user_id=int(user_id) if user_id else None,
website_id=int(website_id) if website_id else None,
)
elif tab == "unchecked":
rows = get_unchecked_report(
target_date=target_date,
user_id=int(user_id) if user_id else None,
)
elif tab == "summary":
rows = get_summary_report(date_from=date_from, date_to=date_to)
except Exception as e:
logger.error(f"Report query error: {e}")
return render_template("admin/reports.html",
tab=tab, rows=rows,
users=users, websites=websites,
date_from=date_from, date_to=date_to,
target_date=target_date,
user_id=user_id, website_id=website_id)
@admin_reports_bp.route("/export/csv")
@admin_required
def export_csv():
tab = request.args.get("tab", "detail")
date_from = request.args.get("date_from") or str(_today() - timedelta(days=30))
date_to = request.args.get("date_to") or str(_today())
user_id = request.args.get("user_id")
website_id = request.args.get("website_id")
target_date = request.args.get("target_date") or str(_today())
try:
if tab == "detail":
rows = get_shift_report(
date_from=date_from, date_to=date_to,
user_id=int(user_id) if user_id else None,
website_id=int(website_id) if website_id else None,
)
elif tab == "unchecked":
rows = get_unchecked_report(
target_date=target_date,
user_id=int(user_id) if user_id else None,
)
else:
rows = get_summary_report(date_from=date_from, date_to=date_to)
except Exception as e:
logger.error(f"CSV export error: {e}")
rows = []
output = io.StringIO()
writer = csv.DictWriter(output, fieldnames=rows[0].keys() if rows else [],
extrasaction="ignore")
writer.writeheader()
for row in rows:
writer.writerow({k: str(v) if v is not None else "" for k, v in row.items()})
output.seek(0)
return Response(
output.getvalue(),
mimetype="text/csv",
headers={"Content-Disposition": f"attachment; filename=report_{tab}_{date_to}.csv"},
)
+56
View File
@@ -0,0 +1,56 @@
"""
routes/admin_settings.py — Application settings (email, Groq API, etc.)
"""
import logging
from flask import Blueprint, render_template, request, redirect, url_for, flash, session
from config import get_settings_dict, set_setting
from models import log_action
from utils.decorators import admin_required
logger = logging.getLogger("routes.admin_settings")
admin_settings_bp = Blueprint("admin_settings", __name__, url_prefix="/admin/settings")
@admin_settings_bp.route("/")
@admin_required
def settings():
email_settings = get_settings_dict("email.")
groq_settings = get_settings_dict("groq.")
return render_template("admin/settings.html",
email=email_settings, groq=groq_settings)
@admin_settings_bp.route("/email", methods=["POST"])
@admin_required
def save_email():
admin = session["user"]
fields = [
"email.enabled", "email.smtp_host", "email.smtp_port",
"email.smtp_user", "email.smtp_password", "email.security",
"email.recipients", "email.send_time",
]
for field in fields:
key = field
form_key = field.replace(".", "_")
value = request.form.get(form_key, "")
set_setting(key, value)
log_action(admin["id"], "UPDATE_EMAIL_SETTINGS", "app_settings", None,
"Email settings updated via web UI.")
flash("Email settings saved successfully.", "success")
logger.info(f"Email settings updated by admin_id={admin['id']}.")
return redirect(url_for("admin_settings.settings"))
@admin_settings_bp.route("/groq", methods=["POST"])
@admin_required
def save_groq():
admin = session["user"]
set_setting("groq.api_key", request.form.get("groq_api_key", ""))
set_setting("groq.model", request.form.get("groq_model", "llama-3.3-70b-versatile"))
log_action(admin["id"], "UPDATE_GROQ_SETTINGS", "app_settings", None,
"Groq API settings updated via web UI.")
flash("Groq settings saved successfully.", "success")
logger.info(f"Groq settings updated by admin_id={admin['id']}.")
return redirect(url_for("admin_settings.settings"))
+131
View File
@@ -0,0 +1,131 @@
"""
routes/admin_shifts.py — Shift management CRUD routes.
"""
import logging
from flask import Blueprint, render_template, request, redirect, url_for, flash, session, jsonify
from models import (
get_all_shifts, get_shift_by_id, get_shift_assigned_users,
get_shift_assigned_websites, create_shift, update_shift, delete_shift,
get_all_users, get_all_websites,
)
from utils.decorators import admin_required
logger = logging.getLogger("routes.admin_shifts")
admin_shifts_bp = Blueprint("admin_shifts", __name__, url_prefix="/admin/shifts")
# MySQL DAYOFWEEK: 1=Sun 2=Mon … 7=Sat
DAY_MAP = [
("Mon", "2"), ("Tue", "3"), ("Wed", "4"),
("Thu", "5"), ("Fri", "6"), ("Sat", "7"), ("Sun", "1"),
]
@admin_shifts_bp.route("/")
@admin_required
def shifts_list():
shifts = get_all_shifts()
all_users = get_all_users()
all_sites = get_all_websites()
return render_template("admin/shifts.html",
shifts=shifts, all_users=all_users,
all_sites=all_sites, day_map=DAY_MAP)
@admin_shifts_bp.route("/<int:shift_id>/detail")
@admin_required
def detail(shift_id):
shift = get_shift_by_id(shift_id)
users = get_shift_assigned_users(shift_id)
websites = get_shift_assigned_websites(shift_id)
if not shift:
return jsonify({"error": "Not found"}), 404
# Convert time objects to HH:MM strings for form inputs
def _fmt(t):
if t is None:
return ""
if hasattr(t, "seconds"): # timedelta from MySQL
h, rem = divmod(int(t.total_seconds()), 3600)
return f"{h:02d}:{rem // 60:02d}"
return str(t)[:5]
return jsonify({
"id": shift["id"],
"name": shift["name"],
"days_of_week": shift["days_of_week"],
"start_time": _fmt(shift["start_time"]),
"end_time": _fmt(shift["end_time"]),
"note": shift["note"] or "",
"is_active": shift["is_active"],
"user_ids": [u["id"] for u in users],
"website_ids": [w["id"] for w in websites],
})
@admin_shifts_bp.route("/create", methods=["POST"])
@admin_required
def create():
admin_id = session["user"]["id"]
name = request.form.get("name", "").strip()
days_of_week = "".join(request.form.getlist("days_of_week[]"))
start_time = request.form.get("start_time", "08:00")
end_time = request.form.get("end_time", "17:00")
note = request.form.get("note", "").strip()
user_ids = [int(x) for x in request.form.getlist("user_ids[]") if x]
website_ids = [int(x) for x in request.form.getlist("website_ids[]") if x]
if not name:
flash("Shift name is required.", "danger")
return redirect(url_for("admin_shifts.shifts_list"))
try:
create_shift(admin_id, name, days_of_week, start_time, end_time,
note, user_ids, website_ids)
flash(f"Shift '{name}' created successfully.", "success")
logger.info(f"Shift '{name}' created by admin_id={admin_id}.")
except Exception as e:
logger.error(f"create_shift error: {e}")
flash(f"Error creating shift: {e}", "danger")
return redirect(url_for("admin_shifts.shifts_list"))
@admin_shifts_bp.route("/<int:shift_id>/edit", methods=["POST"])
@admin_required
def edit(shift_id):
admin_id = session["user"]["id"]
name = request.form.get("name", "").strip()
days_of_week = "".join(request.form.getlist("days_of_week[]"))
start_time = request.form.get("start_time", "08:00")
end_time = request.form.get("end_time", "17:00")
note = request.form.get("note", "").strip()
is_active = int(request.form.get("is_active", 1))
user_ids = [int(x) for x in request.form.getlist("user_ids[]") if x]
website_ids = [int(x) for x in request.form.getlist("website_ids[]") if x]
try:
update_shift(admin_id, shift_id, name, days_of_week, start_time, end_time,
note, is_active, user_ids, website_ids)
flash(f"Shift '{name}' updated successfully.", "success")
logger.info(f"Shift id={shift_id} updated by admin_id={admin_id}.")
except Exception as e:
logger.error(f"update_shift error: {e}")
flash(f"Error updating shift: {e}", "danger")
return redirect(url_for("admin_shifts.shifts_list"))
@admin_shifts_bp.route("/<int:shift_id>/delete", methods=["POST"])
@admin_required
def delete(shift_id):
admin_id = session["user"]["id"]
try:
delete_shift(admin_id, shift_id)
flash("Shift deactivated successfully.", "success")
logger.info(f"Shift id={shift_id} soft-deleted by admin_id={admin_id}.")
except Exception as e:
logger.error(f"delete_shift error: {e}")
flash(f"Error deleting shift: {e}", "danger")
return redirect(url_for("admin_shifts.shifts_list"))
+82
View File
@@ -0,0 +1,82 @@
"""
routes/admin_users.py — User management CRUD routes.
"""
import logging
from flask import Blueprint, render_template, request, redirect, url_for, flash, session
from models import get_all_users, create_user, update_user, delete_user
from utils.decorators import admin_required
logger = logging.getLogger("routes.admin_users")
admin_users_bp = Blueprint("admin_users", __name__, url_prefix="/admin/users")
@admin_users_bp.route("/")
@admin_required
def users_list():
users = get_all_users()
return render_template("admin/users.html", users=users)
@admin_users_bp.route("/create", methods=["POST"])
@admin_required
def create():
admin_id = session["user"]["id"]
username = request.form.get("username", "").strip()
password = request.form.get("password", "")
role = request.form.get("role", "user")
full_name = request.form.get("full_name", "").strip()
email = request.form.get("email", "").strip() or None
if not username or not password:
flash("Username and password are required.", "danger")
return redirect(url_for("admin_users.users_list"))
try:
create_user(admin_id, username, password, role, full_name, email)
flash(f"User '{username}' created successfully.", "success")
logger.info(f"User '{username}' created by admin_id={admin_id}.")
except Exception as e:
logger.error(f"create_user error: {e}")
flash(f"Error creating user: {e}", "danger")
return redirect(url_for("admin_users.users_list"))
@admin_users_bp.route("/<int:user_id>/edit", methods=["POST"])
@admin_required
def edit(user_id):
admin_id = session["user"]["id"]
username = request.form.get("username", "").strip()
role = request.form.get("role", "user")
full_name = request.form.get("full_name", "").strip()
email = request.form.get("email", "").strip() or None
is_active = int(request.form.get("is_active", 1))
password = request.form.get("password", "").strip() or None
try:
update_user(admin_id, user_id, username, role, full_name, is_active, password, email)
flash(f"User '{username}' updated successfully.", "success")
logger.info(f"User id={user_id} updated by admin_id={admin_id}.")
except Exception as e:
logger.error(f"update_user error: {e}")
flash(f"Error updating user: {e}", "danger")
return redirect(url_for("admin_users.users_list"))
@admin_users_bp.route("/<int:user_id>/delete", methods=["POST"])
@admin_required
def delete(user_id):
admin_id = session["user"]["id"]
try:
delete_user(admin_id, user_id)
flash("User deleted successfully.", "success")
logger.info(f"User id={user_id} deleted by admin_id={admin_id}.")
except ValueError as e:
flash(str(e), "danger")
except Exception as e:
logger.error(f"delete_user error: {e}")
flash(f"Error deleting user: {e}", "danger")
return redirect(url_for("admin_users.users_list"))
+125
View File
@@ -0,0 +1,125 @@
"""
routes/admin_websites.py — Website management CRUD routes.
"""
import logging
from flask import Blueprint, render_template, request, redirect, url_for, flash, session, jsonify
from models import (
get_all_websites, get_website_by_id, get_website_credentials,
get_website_assigned_users, create_website, update_website, delete_website,
get_all_users,
)
from utils.decorators import admin_required
logger = logging.getLogger("routes.admin_websites")
admin_websites_bp = Blueprint("admin_websites", __name__, url_prefix="/admin/websites")
def _parse_credentials(form) -> list:
"""Extract credential rows from a multivalue form submission."""
creds = []
labels = form.getlist("cred_label[]")
usernames = form.getlist("cred_username[]")
passwords = form.getlist("cred_password[]")
for label, username, password in zip(labels, usernames, passwords):
if username.strip():
creds.append({
"label": label.strip(),
"username": username.strip(),
"password": password,
})
return creds
@admin_websites_bp.route("/")
@admin_required
def websites_list():
websites = get_all_websites()
all_users = get_all_users()
return render_template("admin/websites.html", websites=websites, all_users=all_users)
@admin_websites_bp.route("/<int:website_id>/detail")
@admin_required
def detail(website_id):
"""JSON endpoint: returns website + credentials + assigned users for the edit modal."""
site = get_website_by_id(website_id)
creds = get_website_credentials(website_id)
assigned = get_website_assigned_users(website_id)
if not site:
return jsonify({"error": "Not found"}), 404
return jsonify({
"id": site["id"],
"name": site["name"],
"url": site["url"],
"check_type": site["check_type"],
"visibility": site["visibility"],
"note": site["note"] or "",
"credentials": [{"label": c["label"], "username": c["username"], "password": c["password"]} for c in creds],
"assigned_user_ids": [u["id"] for u in assigned],
})
@admin_websites_bp.route("/create", methods=["POST"])
@admin_required
def create():
admin_id = session["user"]["id"]
name = request.form.get("name", "").strip()
url = request.form.get("url", "").strip()
check_type = request.form.get("check_type", "daily")
visibility = request.form.get("visibility", "all")
note = request.form.get("note", "").strip()
creds = _parse_credentials(request.form)
assigned = [int(x) for x in request.form.getlist("assigned_user_ids[]") if x]
if not name or not url:
flash("Name and URL are required.", "danger")
return redirect(url_for("admin_websites.websites_list"))
try:
create_website(admin_id, name, url, check_type, note, creds, visibility, assigned)
flash(f"Website '{name}' created successfully.", "success")
logger.info(f"Website '{name}' created by admin_id={admin_id}.")
except Exception as e:
logger.error(f"create_website error: {e}")
flash(f"Error creating website: {e}", "danger")
return redirect(url_for("admin_websites.websites_list"))
@admin_websites_bp.route("/<int:website_id>/edit", methods=["POST"])
@admin_required
def edit(website_id):
admin_id = session["user"]["id"]
name = request.form.get("name", "").strip()
url = request.form.get("url", "").strip()
check_type = request.form.get("check_type", "daily")
visibility = request.form.get("visibility", "all")
note = request.form.get("note", "").strip()
creds = _parse_credentials(request.form)
assigned = [int(x) for x in request.form.getlist("assigned_user_ids[]") if x]
try:
update_website(admin_id, website_id, name, url, check_type, note, creds, visibility, assigned)
flash(f"Website '{name}' updated successfully.", "success")
logger.info(f"Website id={website_id} updated by admin_id={admin_id}.")
except Exception as e:
logger.error(f"update_website error: {e}")
flash(f"Error updating website: {e}", "danger")
return redirect(url_for("admin_websites.websites_list"))
@admin_websites_bp.route("/<int:website_id>/delete", methods=["POST"])
@admin_required
def delete(website_id):
admin_id = session["user"]["id"]
try:
delete_website(admin_id, website_id)
flash("Website deleted (soft) successfully.", "success")
logger.info(f"Website id={website_id} soft-deleted by admin_id={admin_id}.")
except Exception as e:
logger.error(f"delete_website error: {e}")
flash(f"Error deleting website: {e}", "danger")
return redirect(url_for("admin_websites.websites_list"))
+341
View File
@@ -0,0 +1,341 @@
"""
routes/ai_summary.py — AI document analysis routes.
"""
import logging
import json
import io
import os
import re
import requests as http_requests
from flask import (Blueprint, render_template, request, redirect, url_for,
flash, session, jsonify)
from models import (
get_all_criteria, get_active_criteria, create_criterion, update_criterion,
delete_criterion, save_ai_analysis, get_ai_analysis_history,
get_ai_analysis_detail, log_action,
)
from config import get_setting
from utils.decorators import login_required, admin_required
logger = logging.getLogger("routes.ai_summary")
ai_summary_bp = Blueprint("ai_summary", __name__, url_prefix="/ai-summary")
GROQ_MODELS = [
"llama-3.3-70b-versatile",
"llama-3.1-8b-instant",
"gemma2-9b-it",
]
SUPPORTED_EXT = {".txt", ".md", ".csv", ".pdf", ".doc", ".docx", ".xlsx", ".xls"}
@ai_summary_bp.route("/")
@login_required
def ai_summary():
user = session["user"]
criteria = get_all_criteria()
history = get_ai_analysis_history(
user_id=(None if user["role"] == "admin" else user["id"])
)
groq_key = get_setting("groq.api_key", "")
groq_model = get_setting("groq.model", GROQ_MODELS[0])
return render_template("ai_summary.html",
criteria=criteria, history=history,
groq_key_set=bool(groq_key),
groq_model=groq_model,
groq_models=GROQ_MODELS,
is_admin=user["role"] == "admin")
@ai_summary_bp.route("/analyze", methods=["POST"])
@login_required
def analyze():
user = session["user"]
files = request.files.getlist("documents[]")
model = request.form.get("model", get_setting("groq.model", GROQ_MODELS[0]))
# Read API key: env var takes priority, then DB setting
api_key = (os.environ.get("GROQ_API_KEY") or "").strip() or get_setting("groq.api_key", "").strip()
if not api_key:
return jsonify({"error": "Groq API key is not configured. Set GROQ_API_KEY in .env or via Admin → Settings."}), 400
if not files or all(f.filename == "" for f in files):
return jsonify({"error": "No files uploaded."}), 400
texts = []
file_names = []
errors = []
for f in files:
if not f.filename:
continue
ext = ("." + f.filename.rsplit(".", 1)[-1].lower()) if "." in f.filename else ""
if ext not in SUPPORTED_EXT:
errors.append(f"{f.filename}: unsupported file type")
continue
file_names.append(f.filename)
try:
content = _extract_text(f, ext)
if content and content.strip():
texts.append(f"=== {f.filename} ===\n{content.strip()}")
else:
errors.append(f"{f.filename}: no text could be extracted")
except Exception as e:
logger.warning(f"Could not extract text from {f.filename}: {e}")
errors.append(f"{f.filename}: {e}")
if not texts:
detail = "; ".join(errors) if errors else "Check the file format and try again."
return jsonify({"error": f"Could not extract text from any uploaded file. {detail}"}), 400
criteria = get_active_criteria()
combined = "\n\n".join(texts)
try:
result = _call_groq(api_key, model, combined, criteria)
except Exception as e:
logger.error(f"Groq API error: {e}")
return jsonify({"error": f"AI analysis failed: {e}"}), 500
criteria_snap = json.dumps([
{"title": c["title"], "description": c["description"]} for c in criteria
])
analysis_id = save_ai_analysis(
user["id"], ", ".join(file_names), model,
result.get("verdict"), criteria_snap, result.get("summary", "")
)
log_action(user["id"], "AI_ANALYSIS", "ai_analysis_log", analysis_id,
f"AI analysis on {len(file_names)} file(s). Verdict: {result.get('verdict')}.")
return jsonify({"analysis_id": analysis_id, "model": model,
"file_count": len(file_names), **result})
def _extract_text(file_obj, ext: str) -> str:
"""Extract plain text from an uploaded file object."""
data = file_obj.read()
if ext in (".txt", ".md", ".csv"):
return data.decode("utf-8", errors="replace")
if ext == ".pdf":
import pypdf
reader = pypdf.PdfReader(io.BytesIO(data))
pages = []
for page in reader.pages:
t = page.extract_text()
if t:
pages.append(t)
return "\n".join(pages)
if ext in (".docx", ".doc"):
# Use python-docx (installed as 'docx') — do NOT use docx2txt
from docx import Document
doc = Document(io.BytesIO(data))
lines = [para.text for para in doc.paragraphs if para.text.strip()]
return "\n".join(lines)
if ext in (".xlsx", ".xls"):
import openpyxl
wb = openpyxl.load_workbook(io.BytesIO(data), read_only=True, data_only=True)
lines = []
for ws in wb.worksheets:
for row in ws.iter_rows(values_only=True):
line = "\t".join(str(c) if c is not None else "" for c in row)
if line.strip():
lines.append(line)
return "\n".join(lines)
return ""
# Office address used as origin for distance/travel-time estimates.
_OFFICE_ADDRESS = "2815 Hartland Road, Falls Church, VA 22043, USA"
# Stage 1 — extraction prompt (always sent)
_EXTRACTION_PROMPT = """You are an expert government procurement analyst.
The user has provided {n} document(s). Your job is to extract specific information from each document and present it in a clean, structured format.
IMPORTANT — Our office is located at:
{office}
Use this as the ORIGIN address for all driving distance and travel time calculations in field #9 below.
For EACH document, extract and clearly label the following fields (write "N/A" if a field is not found):
1. Solicitation Number
2. Solicitation Type (e.g. RFP, RFQ, IFB, etc.)
3. Set-Aside (e.g. Small Business, 8(a), N/A)
4. Description / Scope of Work
5. Work Site / Location(s)
6. Pre-Proposal Conference / Site-Visit (date, time, full address)
7. Point of Contact (POC) (name, phone, email)
8. Total Square Footage (if applicable)
9. Driving Distance & Travel Time
- Origin: {office}
- Destination: Pre-Proposal Conference or primary Work Site address
- Provide your best estimate of driving distance (miles) and typical driving time using major highways
- Note that these are AI estimates; actual times may vary with traffic
10. Last Day to Submit Questions
11. Due Date & Time
12. Any other notable requirements or deadlines
After the per-document breakdown, provide a detailed OVERALL SUMMARY covering:
A. Scope of Work
B. Contract Period
C. Proposal Submission Requirements
D. Key Deadlines & Action Items
Be precise, detailed, and use bullet points throughout.
If information is not explicitly stated in the documents, note it as "Not specified in the document."
DOCUMENTS:
{documents}"""
# Stage 2 — criteria evaluation suffix (appended only when active criteria exist)
_CRITERIA_PROMPT_SUFFIX = """
================================================================================
OPPORTUNITY ALIGNMENT EVALUATION
================================================================================
After completing the extraction and summary above, evaluate whether this
opportunity aligns with our company's interests based on the following criteria.
OUR EVALUATION CRITERIA:
{criteria_list}
For EACH criterion above:
- State whether the opportunity MEETS, DOES NOT MEET, or PARTIALLY MEETS it.
- Provide a brief, specific explanation citing details from the document(s).
Then provide an OVERALL RECOMMENDATION using EXACTLY one of these three labels
on its own line (this label is machine-read — do not alter it):
RECOMMENDATION: PURSUE
RECOMMENDATION: PASS
RECOMMENDATION: UNCLEAR
Use PURSUE if the opportunity clearly meets most criteria and presents strong
alignment. Use PASS if it clearly fails key criteria. Use UNCLEAR if the
documents lack sufficient information to make a confident determination.
End with a 2-3 sentence EXECUTIVE SUMMARY explaining your recommendation
in plain business language."""
def _call_groq(api_key: str, model: str, text: str, criteria: list) -> dict:
"""Call the Groq chat completions REST API directly (no SDK required)."""
import re
n = text.count("=== ") or 1 # count file separators for the prompt header
# Build the two-stage prompt matching the desktop app exactly
prompt = _EXTRACTION_PROMPT.format(
n=n, office=_OFFICE_ADDRESS, documents=text[:14000]
)
if criteria:
criteria_list = "\n".join(
f" {i+1}. {c['title']}: {c['description']}"
for i, c in enumerate(criteria)
)
prompt += _CRITERIA_PROMPT_SUFFIX.format(criteria_list=criteria_list)
response = 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": prompt}],
"max_tokens": 4096,
"temperature": 0.2,
},
timeout=90,
)
if not response.ok:
logger.error(f"Groq API {response.status_code}: {response.text[:300]}")
response.raise_for_status()
content = response.json()["choices"][0]["message"]["content"] or ""
# Parse the machine-readable RECOMMENDATION label (only present when criteria used)
verdict = None
if criteria:
match = re.search(
r"RECOMMENDATION\s*:\s*(PURSUE|PASS|UNCLEAR)",
content, re.IGNORECASE,
)
if match:
verdict = match.group(1).upper()
return {"verdict": verdict, "summary": content}
# ─── Criteria Management (admin only) ─────────────────────────────────────────
@ai_summary_bp.route("/criteria/create", methods=["POST"])
@admin_required
def create_criterion_view():
admin = session["user"]
title = request.form.get("title", "").strip()
desc = request.form.get("description", "").strip()
is_active = request.form.get("is_active", "1") == "1"
sort_order = int(request.form.get("sort_order", 0))
try:
create_criterion(admin["id"], title, desc, is_active, sort_order)
flash(f"Criterion '{title}' created.", "success")
except Exception as e:
flash(f"Error: {e}", "danger")
return redirect(url_for("ai_summary.ai_summary"))
@ai_summary_bp.route("/criteria/<int:criterion_id>/edit", methods=["POST"])
@admin_required
def edit_criterion_view(criterion_id):
admin = session["user"]
title = request.form.get("title", "").strip()
desc = request.form.get("description", "").strip()
is_active = request.form.get("is_active", "1") == "1"
sort_order = int(request.form.get("sort_order", 0))
try:
update_criterion(admin["id"], criterion_id, title, desc, is_active, sort_order)
flash(f"Criterion '{title}' updated.", "success")
except Exception as e:
flash(f"Error: {e}", "danger")
return redirect(url_for("ai_summary.ai_summary"))
@ai_summary_bp.route("/criteria/<int:criterion_id>/delete", methods=["POST"])
@admin_required
def delete_criterion_view(criterion_id):
admin = session["user"]
try:
delete_criterion(admin["id"], criterion_id)
flash("Criterion deleted.", "success")
except Exception as e:
flash(f"Error: {e}", "danger")
return redirect(url_for("ai_summary.ai_summary"))
@ai_summary_bp.route("/history/<int:analysis_id>")
@login_required
def analysis_detail(analysis_id):
record = get_ai_analysis_detail(analysis_id)
if not record:
return jsonify({"error": "Not found"}), 404
user = session["user"]
if user["role"] != "admin" and record["user_id"] != user["id"]:
return jsonify({"error": "Access denied"}), 403
return jsonify({
"id": record["id"],
"username": record.get("username"),
"file_names": record["file_names"],
"model": record["model"],
"verdict": record["verdict"],
"analyzed_at": str(record["analyzed_at"]),
"summary_text": record["summary_text"],
})
+88
View File
@@ -0,0 +1,88 @@
"""
routes/auth.py — Authentication routes: login, logout, change-password.
"""
import logging
from flask import Blueprint, render_template, request, session, redirect, url_for, flash
from models import authenticate, check_login_allowed, change_password, log_action
from utils.decorators import login_required
logger = logging.getLogger("routes.auth")
auth_bp = Blueprint("auth", __name__)
@auth_bp.route("/login", methods=["GET", "POST"])
def login():
if "user" in session:
return redirect(url_for("index"))
error = None
if request.method == "POST":
username = request.form.get("username", "").strip()
password = request.form.get("password", "")
ip = request.remote_addr
allowed, seconds_remaining = check_login_allowed(username)
if not allowed:
mins = seconds_remaining // 60
secs = seconds_remaining % 60
error = f"Account locked. Try again in {mins}m {secs}s."
logger.warning(f"Login blocked for '{username}' — still locked ({seconds_remaining}s remaining).")
else:
user = authenticate(username, password)
if user:
session.permanent = True
# Store a safe subset — never store the password hash in session
session["user"] = {
"id": user["id"],
"username": user["username"],
"full_name": user.get("full_name") or user["username"],
"role": user["role"],
"email": user.get("email", ""),
}
logger.info(f"User '{username}' logged in from {ip}.")
flash(f"You have been signed in. Welcome, {session['user']['full_name']}!", "success")
if user["role"] == "admin":
return redirect(url_for("admin_dashboard.dashboard"))
return redirect(url_for("user_dashboard.my_shifts"))
else:
error = "Invalid username or password."
return render_template("login.html", error=error)
@auth_bp.route("/logout")
@login_required
def logout():
user = session.get("user", {})
if user:
log_action(user["id"], "LOGOUT", "users", user["id"],
f"User '{user['username']}' logged out.")
logger.info(f"User '{user['username']}' logged out.")
session.clear()
flash("You have been signed out.", "info")
return redirect(url_for("auth.login"))
@auth_bp.route("/change-password", methods=["GET", "POST"])
@login_required
def change_password_view():
user = session["user"]
success = None
error = None
if request.method == "POST":
old_pw = request.form.get("old_password", "")
new_pw = request.form.get("new_password", "")
confirm = request.form.get("confirm_password", "")
if new_pw != confirm:
error = "New password and confirmation do not match."
else:
ok, msg = change_password(user["id"], old_pw, new_pw)
if ok:
success = msg
else:
error = msg
return render_template("change_password.html", success=success, error=error)
+234
View File
@@ -0,0 +1,234 @@
"""
routes/bid_tracker.py — Bid / Opportunity tracker routes.
"""
import logging
from flask import (Blueprint, render_template, request, redirect, url_for,
flash, session, jsonify)
from models import (
get_all_bids, get_bid, create_bid, update_bid, delete_bid,
get_bid_updates, add_bid_update, delete_bid_update, BID_STATUSES,
)
from utils.decorators import login_required
logger = logging.getLogger("routes.bid_tracker")
bid_tracker_bp = Blueprint("bid_tracker", __name__, url_prefix="/bids")
STATUS_LABELS = {
"open": "🟢 Open",
"monitoring": "🔵 Monitoring",
"awarded": "🏆 Awarded",
"no_bid": "⛔ No Bid",
"cancelled": "🚫 Cancelled",
}
@bid_tracker_bp.route("/")
@login_required
def bids_list():
status_filter = request.args.get("status", "")
bids = get_all_bids(status_filter=status_filter)
return render_template("bid_tracker.html",
bids=bids,
status_filter=status_filter,
bid_statuses=BID_STATUSES,
status_labels=STATUS_LABELS)
@bid_tracker_bp.route("/<int:bid_id>")
@login_required
def bid_detail(bid_id):
bid = get_bid(bid_id)
updates = get_bid_updates(bid_id)
if not bid:
flash("Bid not found.", "warning")
return redirect(url_for("bid_tracker.bids_list"))
return render_template("bid_detail.html",
bid=bid, updates=updates,
bid_statuses=BID_STATUSES,
status_labels=STATUS_LABELS)
@bid_tracker_bp.route("/create", methods=["POST"])
@login_required
def create():
user = session["user"]
title = request.form.get("title", "").strip()
url = request.form.get("url", "").strip()
source = request.form.get("source", "").strip()
sol_no = request.form.get("solicitation_number", "").strip()
status = request.form.get("status", "open")
due_date = request.form.get("due_date") or None
notes = request.form.get("notes", "").strip()
if not title or not url:
flash("Title and URL are required.", "danger")
return redirect(url_for("bid_tracker.bids_list"))
try:
create_bid(user["id"], title, url, source, sol_no, status, due_date, notes)
flash(f"Bid '{title}' added.", "success")
logger.info(f"Bid '{title}' created by user_id={user['id']}.")
except Exception as e:
logger.error(f"create_bid error: {e}")
flash(f"Error: {e}", "danger")
return redirect(url_for("bid_tracker.bids_list"))
@bid_tracker_bp.route("/<int:bid_id>/edit", methods=["POST"])
@login_required
def edit(bid_id):
user = session["user"]
title = request.form.get("title", "").strip()
url = request.form.get("url", "").strip()
source = request.form.get("source", "").strip()
sol_no = request.form.get("solicitation_number", "").strip()
status = request.form.get("status", "open")
due_date = request.form.get("due_date") or None
notes = request.form.get("notes", "").strip()
try:
update_bid(user["id"], bid_id, title, url, source, sol_no, status, due_date, notes)
flash(f"Bid '{title}' updated.", "success")
logger.info(f"Bid id={bid_id} updated by user_id={user['id']}.")
except Exception as e:
logger.error(f"update_bid error: {e}")
flash(f"Error: {e}", "danger")
return redirect(url_for("bid_tracker.bid_detail", bid_id=bid_id))
@bid_tracker_bp.route("/<int:bid_id>/delete", methods=["POST"])
@login_required
def delete(bid_id):
user = session["user"]
try:
delete_bid(user["id"], bid_id)
flash("Bid deleted.", "success")
logger.info(f"Bid id={bid_id} deleted by user_id={user['id']}.")
except Exception as e:
logger.error(f"delete_bid error: {e}")
flash(f"Error: {e}", "danger")
return redirect(url_for("bid_tracker.bids_list"))
@bid_tracker_bp.route("/<int:bid_id>/updates", methods=["POST"])
@login_required
def add_update(bid_id):
user = session["user"]
content = request.form.get("content", "").strip()
if not content:
flash("Update content cannot be empty.", "warning")
else:
try:
add_bid_update(user["id"], bid_id, content)
flash("Update posted.", "success")
logger.info(f"Bid update posted on bid_id={bid_id} by user_id={user['id']}.")
except Exception as e:
logger.error(f"add_bid_update error: {e}")
flash(f"Error: {e}", "danger")
return redirect(url_for("bid_tracker.bid_detail", bid_id=bid_id))
@bid_tracker_bp.route("/updates/<int:update_id>/delete", methods=["POST"])
@login_required
def delete_update(update_id):
user = session["user"]
try:
delete_bid_update(user["id"], update_id)
flash("Update deleted.", "success")
logger.info(f"Bid update id={update_id} deleted by user_id={user['id']}.")
except Exception as e:
logger.error(f"delete_bid_update error: {e}")
flash(f"Error: {e}", "danger")
bid_id = request.form.get("bid_id")
if bid_id:
return redirect(url_for("bid_tracker.bid_detail", bid_id=int(bid_id)))
return redirect(url_for("bid_tracker.bids_list"))
@bid_tracker_bp.route("/<int:bid_id>/json")
@login_required
def bid_json(bid_id):
"""Return bid detail + updates as JSON for the split-pane detail panel."""
user = session["user"]
bid = get_bid(bid_id)
if not bid:
return jsonify({"error": "Not found"}), 404
updates = get_bid_updates(bid_id)
is_owner = bid.get("added_by") == user["id"]
can_edit = user["role"] == "admin" or is_owner
def ser(row):
"""Make a dict JSON-serialisable (dates → str)."""
out = {}
for k, v in row.items():
if hasattr(v, "isoformat"):
out[k] = v.isoformat()
else:
out[k] = v
return out
return jsonify({
"bid": ser(bid),
"updates": [ser(u) for u in updates],
"can_edit": can_edit,
"user_id": user["id"],
"is_admin": user["role"] == "admin",
})
@bid_tracker_bp.route("/list/json")
@login_required
def list_json():
"""Return all bids as JSON for the AJAX list panel."""
status_filter = request.args.get("status", "")
try:
bids = get_all_bids(status_filter=status_filter)
except Exception as e:
return jsonify({"error": str(e)}), 500
def ser(row):
out = {}
for k, v in row.items():
if hasattr(v, "isoformat"):
out[k] = v.isoformat()
else:
out[k] = v
return out
return jsonify([ser(b) for b in bids])
@bid_tracker_bp.route("/<int:bid_id>/updates/json", methods=["POST"])
@login_required
def add_update_json(bid_id):
"""Post a new update; return JSON so the page doesn't reload."""
user = session["user"]
content = request.form.get("content", "").strip()
if not content:
return jsonify({"error": "Update content cannot be empty."}), 400
try:
update_id = add_bid_update(user["id"], bid_id, content)
logger.info(f"Bid update id={update_id} posted on bid_id={bid_id} by user_id={user['id']}.")
return jsonify({"ok": True, "update_id": update_id})
except Exception as e:
logger.error(f"add_update_json error: {e}")
return jsonify({"error": str(e)}), 500
@bid_tracker_bp.route("/updates/<int:update_id>/delete/json", methods=["POST"])
@login_required
def delete_update_json(update_id):
"""Delete an update; return JSON so the page doesn't reload."""
user = session["user"]
try:
delete_bid_update(user["id"], update_id)
logger.info(f"Bid update id={update_id} deleted by user_id={user['id']}.")
return jsonify({"ok": True})
except Exception as e:
logger.error(f"delete_update_json error: {e}")
return jsonify({"error": str(e)}), 500
+93
View File
@@ -0,0 +1,93 @@
"""
routes/user_dashboard.py — Regular user: shift check dashboard.
"""
import logging
from flask import Blueprint, render_template, request, redirect, url_for, flash, session, jsonify
from models import (
get_today_checks, mark_website_checked, unmark_website_checked,
update_check_note, get_user_active_shifts, get_website_credentials,
)
from utils.decorators import login_required
logger = logging.getLogger("routes.user_dashboard")
user_dashboard_bp = Blueprint("user_dashboard", __name__, url_prefix="/dashboard")
@user_dashboard_bp.route("/")
@login_required
def my_shifts():
user_id = session["user"]["id"]
try:
sites = get_today_checks(user_id)
shifts = get_user_active_shifts(user_id)
except Exception as e:
logger.error(f"my_shifts error: {e}")
sites, shifts = [], []
checked = sum(1 for s in sites if s.get("check_id"))
total = len(sites)
pct = round(checked * 100 / total) if total else 0
return render_template("user/dashboard.html",
sites=sites, shifts=shifts,
checked=checked, total=total, pct=pct)
@user_dashboard_bp.route("/check/<int:website_id>", methods=["POST"])
@login_required
def check_site(website_id):
user_id = session["user"]["id"]
user_note = request.form.get("user_note", "").strip()
try:
mark_website_checked(user_id, website_id, user_note)
flash("Site marked as checked.", "success")
logger.info(f"User {user_id} checked website {website_id}.")
except Exception as e:
logger.error(f"check_site error: {e}")
flash(f"Error: {e}", "danger")
return redirect(url_for("user_dashboard.my_shifts"))
@user_dashboard_bp.route("/uncheck/<int:website_id>", methods=["POST"])
@login_required
def uncheck_site(website_id):
user_id = session["user"]["id"]
try:
unmark_website_checked(user_id, website_id)
flash("Check removed.", "info")
logger.info(f"User {user_id} unchecked website {website_id}.")
except Exception as e:
logger.error(f"uncheck_site error: {e}")
flash(f"Error: {e}", "danger")
return redirect(url_for("user_dashboard.my_shifts"))
@user_dashboard_bp.route("/note/<int:website_id>", methods=["POST"])
@login_required
def update_note(website_id):
user_id = session["user"]["id"]
user_note = request.form.get("user_note", "").strip()
try:
update_check_note(user_id, website_id, user_note)
flash("Note updated.", "success")
except Exception as e:
logger.error(f"update_note error: {e}")
flash(f"Error: {e}", "danger")
return redirect(url_for("user_dashboard.my_shifts"))
@user_dashboard_bp.route("/credentials/<int:website_id>")
@login_required
def view_credentials(website_id):
"""JSON endpoint: return decrypted credentials for a site."""
try:
creds = get_website_credentials(website_id)
return jsonify([{
"label": c.get("label", ""),
"username": c.get("username", ""),
"password": c.get("password", ""),
} for c in creds])
except Exception as e:
logger.error(f"view_credentials error: {e}")
return jsonify({"error": str(e)}), 500