04/30 Web Checker web app ver. 1.0
This commit is contained in:
@@ -0,0 +1 @@
|
||||
# routes package
|
||||
@@ -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)
|
||||
@@ -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"))
|
||||
@@ -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"},
|
||||
)
|
||||
@@ -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"))
|
||||
@@ -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"))
|
||||
@@ -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"))
|
||||
@@ -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"))
|
||||
@@ -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"],
|
||||
})
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user