db_interface.py cleaned
This commit is contained in:
@@ -5,7 +5,11 @@ import requests
|
|||||||
import time
|
import time
|
||||||
import os
|
import os
|
||||||
import pytz
|
import pytz
|
||||||
from db_connection import get_connection
|
from db_connection import get_connection # Assuming db_connection.py exists and defines get_connection
|
||||||
|
|
||||||
|
# ------------------------------
|
||||||
|
# User Management Functions
|
||||||
|
# ------------------------------
|
||||||
|
|
||||||
def get_all_users():
|
def get_all_users():
|
||||||
"""Retrieve all users with associated group and vendor information."""
|
"""Retrieve all users with associated group and vendor information."""
|
||||||
@@ -26,6 +30,99 @@ def get_all_users():
|
|||||||
conn.close()
|
conn.close()
|
||||||
return users
|
return users
|
||||||
|
|
||||||
|
def get_user_by_mac(mac_address):
|
||||||
|
"""Retrieve a user record from the database by MAC address."""
|
||||||
|
conn = get_connection()
|
||||||
|
cursor = conn.cursor(dictionary=True)
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT * FROM users WHERE mac_address = %s
|
||||||
|
""", (mac_address,))
|
||||||
|
user = cursor.fetchone()
|
||||||
|
cursor.close()
|
||||||
|
conn.close()
|
||||||
|
return user
|
||||||
|
|
||||||
|
def get_users_by_vlan_id(vlan_id):
|
||||||
|
"""Fetch users assigned to a specific VLAN ID."""
|
||||||
|
conn = get_connection()
|
||||||
|
cursor = conn.cursor(dictionary=True)
|
||||||
|
cursor.execute("SELECT mac_address, description FROM users WHERE vlan_id = %s", (vlan_id,))
|
||||||
|
users = cursor.fetchall()
|
||||||
|
cursor.close()
|
||||||
|
conn.close()
|
||||||
|
return users
|
||||||
|
|
||||||
|
def add_user(mac_address, description, vlan_id):
|
||||||
|
"""Insert a new user with MAC address, description, and VLAN assignment."""
|
||||||
|
print(f"→ Adding to DB: mac={mac_address}, desc={description}, vlan={vlan_id}")
|
||||||
|
conn = get_connection()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute(
|
||||||
|
"INSERT INTO users (mac_address, description, vlan_id) VALUES (%s, %s, %s)",
|
||||||
|
(mac_address.lower(), description, vlan_id)
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
cursor.close()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def update_user_description(mac_address, description):
|
||||||
|
"""Update the description field of a user identified by MAC address."""
|
||||||
|
conn = get_connection()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("UPDATE users SET description = %s WHERE mac_address = %s", (description, mac_address.lower()))
|
||||||
|
conn.commit()
|
||||||
|
cursor.close()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
# Note: This function seems redundant with update_user_description. Included as per instructions.
|
||||||
|
def update_description(mac_address, description):
|
||||||
|
"""Update the description for a given MAC address in the users table."""
|
||||||
|
conn = get_connection()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute(
|
||||||
|
"UPDATE users SET description = %s WHERE mac_address = %s",
|
||||||
|
(description, mac_address.lower())
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
cursor.close()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def update_user_vlan(mac_address, vlan_id):
|
||||||
|
"""Update the VLAN ID for a given MAC address in the users table."""
|
||||||
|
conn = get_connection()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("UPDATE users SET vlan_id = %s WHERE mac_address = %s", (vlan_id, mac_address.lower()))
|
||||||
|
conn.commit()
|
||||||
|
cursor.close()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
# Note: This function seems redundant with update_user_vlan. Included as per instructions.
|
||||||
|
def update_vlan(mac_address, vlan_id):
|
||||||
|
"""Update the VLAN ID for a given MAC address in the users table."""
|
||||||
|
conn = get_connection()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute(
|
||||||
|
"UPDATE users SET vlan_id = %s WHERE mac_address = %s",
|
||||||
|
(vlan_id, mac_address.lower())
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
cursor.close()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def delete_user(mac_address):
|
||||||
|
"""Remove a user from the database by their MAC address."""
|
||||||
|
conn = get_connection()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("DELETE FROM users WHERE mac_address = %s", (mac_address.lower(),))
|
||||||
|
conn.commit()
|
||||||
|
cursor.close()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------
|
||||||
|
# Group Management Functions
|
||||||
|
# ------------------------------
|
||||||
|
|
||||||
def get_all_groups():
|
def get_all_groups():
|
||||||
"""Retrieve all groups along with user count for each group."""
|
"""Retrieve all groups along with user count for each group."""
|
||||||
conn = get_connection()
|
conn = get_connection()
|
||||||
@@ -51,8 +148,25 @@ def add_group(vlan_id, description):
|
|||||||
cursor.close()
|
cursor.close()
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
def duplicate_group(vlan_id):
|
||||||
|
"""Create a duplicate of a group with an incremented VLAN ID."""
|
||||||
|
conn = get_connection()
|
||||||
|
cursor = conn.cursor(dictionary=True)
|
||||||
|
cursor.execute("SELECT vlan_id, description FROM groups WHERE vlan_id = %s", (vlan_id,))
|
||||||
|
group = cursor.fetchone()
|
||||||
|
|
||||||
|
if group:
|
||||||
|
new_vlan_id = int(group['vlan_id']) + 1 # Auto-increment logic
|
||||||
|
new_description = f"{group['description']} Copy" if group['description'] else None
|
||||||
|
cursor.execute("INSERT INTO groups (vlan_id, description) VALUES (%s, %s)", (new_vlan_id, new_description))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
cursor.close()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
def update_group_description(vlan_id, description):
|
def update_group_description(vlan_id, description):
|
||||||
"""Update the description for a given MAC address in the users table."""
|
"""Update the description for a given MAC address in the users table."""
|
||||||
|
# Docstring seems incorrect (mentions MAC address), but keeping original text.
|
||||||
conn = get_connection()
|
conn = get_connection()
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
cursor.execute("UPDATE groups SET description = %s WHERE vlan_id = %s", (description, vlan_id))
|
cursor.execute("UPDATE groups SET description = %s WHERE vlan_id = %s", (description, vlan_id))
|
||||||
@@ -76,323 +190,47 @@ def delete_group(vlan_id, force_delete=False):
|
|||||||
cursor.close()
|
cursor.close()
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
def duplicate_group(vlan_id):
|
def delete_group_route():
|
||||||
"""Create a duplicate of a group with an incremented VLAN ID."""
|
"""Handle deletion of a group and optionally its users via form POST."""
|
||||||
conn = get_connection()
|
# Note: This function interacts with Flask's request/flash/redirect.
|
||||||
cursor = conn.cursor(dictionary=True)
|
vlan_id = request.form.get("group_id")
|
||||||
cursor.execute("SELECT vlan_id, description FROM groups WHERE vlan_id = %s", (vlan_id,))
|
force = request.form.get("force_delete") == "true"
|
||||||
group = cursor.fetchone()
|
|
||||||
|
|
||||||
if group:
|
|
||||||
new_vlan_id = int(group['vlan_id']) + 1 # Auto-increment logic
|
|
||||||
new_description = f"{group['description']} Copy" if group['description'] else None
|
|
||||||
cursor.execute("INSERT INTO groups (vlan_id, description) VALUES (%s, %s)", (new_vlan_id, new_description))
|
|
||||||
conn.commit()
|
|
||||||
|
|
||||||
cursor.close()
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
def add_user(mac_address, description, vlan_id):
|
|
||||||
"""Insert a new user with MAC address, description, and VLAN assignment."""
|
|
||||||
print(f"→ Adding to DB: mac={mac_address}, desc={description}, vlan={vlan_id}")
|
|
||||||
conn = get_connection()
|
|
||||||
cursor = conn.cursor()
|
|
||||||
cursor.execute(
|
|
||||||
"INSERT INTO users (mac_address, description, vlan_id) VALUES (%s, %s, %s)",
|
|
||||||
(mac_address.lower(), description, vlan_id)
|
|
||||||
)
|
|
||||||
conn.commit()
|
|
||||||
cursor.close()
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
def update_user_description(mac_address, description):
|
|
||||||
"""Update the description field of a user identified by MAC address."""
|
|
||||||
conn = get_connection()
|
|
||||||
cursor = conn.cursor()
|
|
||||||
cursor.execute("UPDATE users SET description = %s WHERE mac_address = %s", (description, mac_address.lower()))
|
|
||||||
conn.commit()
|
|
||||||
cursor.close()
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
def update_user_vlan(mac_address, vlan_id):
|
|
||||||
"""Update the VLAN ID for a given MAC address in the users table."""
|
|
||||||
conn = get_connection()
|
|
||||||
cursor = conn.cursor()
|
|
||||||
cursor.execute("UPDATE users SET vlan_id = %s WHERE mac_address = %s", (vlan_id, mac_address.lower()))
|
|
||||||
conn.commit()
|
|
||||||
cursor.close()
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
def delete_user(mac_address):
|
|
||||||
"""Remove a user from the database by their MAC address."""
|
|
||||||
conn = get_connection()
|
|
||||||
cursor = conn.cursor()
|
|
||||||
cursor.execute("DELETE FROM users WHERE mac_address = %s", (mac_address.lower(),))
|
|
||||||
conn.commit()
|
|
||||||
cursor.close()
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
def get_latest_auth_logs(reply_type=None, limit=5, time_range=None, offset=0):
|
|
||||||
"""Retrieve recent authentication logs filtered by reply type and time range."""
|
|
||||||
conn = get_connection()
|
|
||||||
cursor = conn.cursor(dictionary=True)
|
|
||||||
|
|
||||||
now = datetime.now(pytz.timezone(current_app.config.get('APP_TIMEZONE', 'UTC')))
|
|
||||||
time_filter = None
|
|
||||||
|
|
||||||
if time_range and time_range != 'all':
|
|
||||||
delta = {
|
|
||||||
'last_minute': timedelta(minutes=1),
|
|
||||||
'last_5_minutes': timedelta(minutes=5),
|
|
||||||
'last_10_minutes': timedelta(minutes=10),
|
|
||||||
'last_hour': timedelta(hours=1),
|
|
||||||
'last_6_hours': timedelta(hours=6),
|
|
||||||
'last_12_hours': timedelta(hours=12),
|
|
||||||
'last_day': timedelta(days=1),
|
|
||||||
'last_30_days': timedelta(days=30)
|
|
||||||
}.get(time_range)
|
|
||||||
if delta:
|
|
||||||
time_filter = now - delta
|
|
||||||
|
|
||||||
if time_filter:
|
|
||||||
cursor.execute("""
|
|
||||||
SELECT * FROM auth_logs
|
|
||||||
WHERE reply = %s AND timestamp >= %s
|
|
||||||
ORDER BY timestamp DESC
|
|
||||||
LIMIT %s OFFSET %s
|
|
||||||
""", (reply_type, time_filter, limit, offset))
|
|
||||||
else:
|
|
||||||
cursor.execute("""
|
|
||||||
SELECT * FROM auth_logs
|
|
||||||
WHERE reply = %s
|
|
||||||
ORDER BY timestamp DESC
|
|
||||||
LIMIT %s OFFSET %s
|
|
||||||
""", (reply_type, limit, offset))
|
|
||||||
|
|
||||||
logs = cursor.fetchall()
|
|
||||||
cursor.close()
|
|
||||||
conn.close()
|
|
||||||
return logs
|
|
||||||
|
|
||||||
def count_auth_logs(reply_type=None, time_range=None):
|
|
||||||
"""Count the number of authentication logs matching a reply type and time."""
|
|
||||||
conn = get_connection()
|
conn = get_connection()
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
|
|
||||||
now = datetime.now(pytz.timezone(current_app.config.get('APP_TIMEZONE', 'UTC')))
|
cursor.execute("SELECT COUNT(*) FROM users WHERE vlan_id = %s", (vlan_id,))
|
||||||
time_filter = None
|
user_count = cursor.fetchone()[0]
|
||||||
|
|
||||||
if time_range and time_range != 'all':
|
if user_count > 0 and not force:
|
||||||
delta = {
|
|
||||||
'last_minute': timedelta(minutes=1),
|
|
||||||
'last_5_minutes': timedelta(minutes=5),
|
|
||||||
'last_10_minutes': timedelta(minutes=10),
|
|
||||||
'last_hour': timedelta(hours=1),
|
|
||||||
'last_6_hours': timedelta(hours=6),
|
|
||||||
'last_12_hours': timedelta(hours=12),
|
|
||||||
'last_day': timedelta(days=1),
|
|
||||||
'last_30_days': timedelta(days=30)
|
|
||||||
}.get(time_range)
|
|
||||||
if delta:
|
|
||||||
time_filter = now - delta
|
|
||||||
|
|
||||||
if time_filter:
|
|
||||||
cursor.execute("""
|
|
||||||
SELECT COUNT(*) FROM auth_logs
|
|
||||||
WHERE reply = %s AND timestamp >= %s
|
|
||||||
""", (reply_type, time_filter))
|
|
||||||
else:
|
|
||||||
cursor.execute("""
|
|
||||||
SELECT COUNT(*) FROM auth_logs
|
|
||||||
WHERE reply = %s
|
|
||||||
""", (reply_type,))
|
|
||||||
|
|
||||||
count = cursor.fetchone()[0]
|
|
||||||
cursor.close()
|
|
||||||
conn.close()
|
|
||||||
return count
|
|
||||||
|
|
||||||
def get_summary_counts():
|
|
||||||
"""Return total counts of users and groups from the database."""
|
|
||||||
conn = get_connection()
|
|
||||||
cursor = conn.cursor(dictionary=True)
|
|
||||||
|
|
||||||
cursor.execute("SELECT COUNT(*) AS count FROM users")
|
|
||||||
total_users = cursor.fetchone()['count']
|
|
||||||
|
|
||||||
cursor.execute("SELECT COUNT(*) AS count FROM groups")
|
|
||||||
total_groups = cursor.fetchone()['count']
|
|
||||||
|
|
||||||
cursor.close()
|
|
||||||
conn.close()
|
|
||||||
return total_users, total_groups
|
|
||||||
|
|
||||||
def update_description(mac_address, description):
|
|
||||||
"""Update the description for a given MAC address in the users table."""
|
|
||||||
conn = get_connection()
|
|
||||||
cursor = conn.cursor()
|
|
||||||
cursor.execute(
|
|
||||||
"UPDATE users SET description = %s WHERE mac_address = %s",
|
|
||||||
(description, mac_address.lower())
|
|
||||||
)
|
|
||||||
conn.commit()
|
|
||||||
cursor.close()
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
def update_vlan(mac_address, vlan_id):
|
|
||||||
"""Update the VLAN ID for a given MAC address in the users table."""
|
|
||||||
conn = get_connection()
|
|
||||||
cursor = conn.cursor()
|
|
||||||
cursor.execute(
|
|
||||||
"UPDATE users SET vlan_id = %s WHERE mac_address = %s",
|
|
||||||
(vlan_id, mac_address.lower())
|
|
||||||
)
|
|
||||||
conn.commit()
|
|
||||||
cursor.close()
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
def refresh_vendors():
|
|
||||||
"""Fetch and cache vendor info for unknown MAC prefixes using the API."""
|
|
||||||
conn = get_connection()
|
|
||||||
cursor = conn.cursor(dictionary=True)
|
|
||||||
|
|
||||||
# Fetch all MACs from users table that are missing vendor data
|
|
||||||
cursor.execute("""
|
|
||||||
SELECT DISTINCT SUBSTRING(REPLACE(REPLACE(mac_address, ':', ''), '-', ''), 1, 6) AS mac_prefix
|
|
||||||
FROM users
|
|
||||||
WHERE NOT EXISTS (
|
|
||||||
SELECT 1 FROM mac_vendors WHERE mac_prefix = SUBSTRING(REPLACE(REPLACE(users.mac_address, ':', ''), '-', ''), 1, 6)
|
|
||||||
)
|
|
||||||
""")
|
|
||||||
prefixes = [row['mac_prefix'].lower() for row in cursor.fetchall()]
|
|
||||||
cursor.close()
|
|
||||||
|
|
||||||
if not prefixes:
|
|
||||||
conn.close()
|
conn.close()
|
||||||
return
|
flash("Group has users. Please confirm deletion or reassign users.", "error")
|
||||||
|
# Assuming 'group.group_list' is a valid endpoint name
|
||||||
url_template = current_app.config.get("OUI_API_URL", "https://api.maclookup.app/v2/macs/{}")
|
return redirect(url_for("group.group_list"))
|
||||||
api_key = current_app.config.get("OUI_API_KEY", "")
|
|
||||||
rate_limit = int(current_app.config.get("OUI_API_LIMIT_PER_SEC", 2))
|
|
||||||
daily_limit = int(current_app.config.get("OUI_API_DAILY_LIMIT", 10000))
|
|
||||||
|
|
||||||
headers = {"Authorization": f"Bearer {api_key}"} if api_key else {}
|
|
||||||
|
|
||||||
inserted = 0
|
|
||||||
cursor = conn.cursor()
|
|
||||||
|
|
||||||
for i, prefix in enumerate(prefixes):
|
|
||||||
if inserted >= daily_limit:
|
|
||||||
break
|
|
||||||
|
|
||||||
try:
|
|
||||||
url = url_template.format(prefix)
|
|
||||||
response = requests.get(url, headers=headers)
|
|
||||||
|
|
||||||
if response.status_code == 200:
|
|
||||||
data = response.json()
|
|
||||||
vendor_name = data.get("company", "").strip()
|
|
||||||
if vendor_name:
|
|
||||||
status = "found"
|
|
||||||
else:
|
|
||||||
# Empty "company" field — skip insert
|
|
||||||
print(f"⚠️ Empty vendor for {prefix}, skipping.")
|
|
||||||
continue
|
|
||||||
elif response.status_code == 404:
|
|
||||||
vendor_name = "not found"
|
|
||||||
status = "not_found"
|
|
||||||
else:
|
|
||||||
print(f"❌ API error {response.status_code} for {prefix}, skipping.")
|
|
||||||
continue
|
|
||||||
|
|
||||||
cursor.execute("""
|
|
||||||
INSERT INTO mac_vendors (mac_prefix, vendor_name, status, last_checked, last_updated)
|
|
||||||
VALUES (%s, %s, %s, NOW(), NOW())
|
|
||||||
ON DUPLICATE KEY UPDATE
|
|
||||||
vendor_name = VALUES(vendor_name),
|
|
||||||
status = VALUES(status),
|
|
||||||
last_checked = NOW(),
|
|
||||||
last_updated = NOW()
|
|
||||||
""", (prefix, vendor_name, status))
|
|
||||||
conn.commit()
|
|
||||||
inserted += 1
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
print(f"🚨 Exception fetching vendor for {prefix}: {e}")
|
|
||||||
continue
|
|
||||||
|
|
||||||
time.sleep(1.0 / rate_limit)
|
|
||||||
|
|
||||||
cursor.close()
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
def lookup_mac_verbose(mac):
|
|
||||||
"""Look up vendor info for a MAC with verbose output, querying API if needed."""
|
|
||||||
conn = get_connection()
|
|
||||||
cursor = conn.cursor(dictionary=True)
|
|
||||||
output = []
|
|
||||||
prefix = mac.lower().replace(":", "").replace("-", "")[:6]
|
|
||||||
|
|
||||||
output.append(f"🔍 Searching local database for prefix: {prefix}...")
|
|
||||||
|
|
||||||
cursor.execute("SELECT vendor_name, status FROM mac_vendors WHERE mac_prefix = %s", (prefix,))
|
|
||||||
row = cursor.fetchone()
|
|
||||||
|
|
||||||
if row:
|
|
||||||
output.append(f"✅ Found locally: {row['vendor_name']} (status: {row['status']})")
|
|
||||||
cursor.close()
|
|
||||||
conn.close()
|
|
||||||
return "\n".join(output)
|
|
||||||
|
|
||||||
output.append("❌ Not found locally.")
|
|
||||||
output.append("🌐 Querying API...")
|
|
||||||
|
|
||||||
url_template = current_app.config.get("OUI_API_URL", "https://api.maclookup.app/v2/macs/{}")
|
|
||||||
api_key = current_app.config.get("OUI_API_KEY", "")
|
|
||||||
headers = {"Authorization": f"Bearer {api_key}"} if api_key else {}
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
url = url_template.format(prefix)
|
# Consider calling the delete_group() function here for consistency,
|
||||||
response = requests.get(url, headers=headers)
|
# but keeping original structure as requested.
|
||||||
|
if force:
|
||||||
if response.status_code == 200:
|
cursor.execute("DELETE FROM users WHERE vlan_id = %s", (vlan_id,))
|
||||||
data = response.json()
|
cursor.execute("DELETE FROM groups WHERE vlan_id = %s", (vlan_id,))
|
||||||
vendor_name = data.get("company", "").strip()
|
conn.commit()
|
||||||
if vendor_name:
|
flash(f"Group {vlan_id} and associated users deleted." if force else f"Group {vlan_id} deleted.", "success")
|
||||||
output.append(f"✅ Found via API: {vendor_name}")
|
except mysql.connector.IntegrityError as e:
|
||||||
output.append("💾 Inserting into local database...")
|
flash(f"Cannot delete group {vlan_id}: it is still in use. Error: {e}", "error")
|
||||||
|
|
||||||
cursor.execute("""
|
|
||||||
INSERT INTO mac_vendors (mac_prefix, vendor_name, status, last_checked, last_updated)
|
|
||||||
VALUES (%s, %s, 'found', NOW(), NOW())
|
|
||||||
""", (prefix, vendor_name))
|
|
||||||
conn.commit()
|
|
||||||
else:
|
|
||||||
output.append("⚠️ API responded but no vendor name found. Not inserting.")
|
|
||||||
elif response.status_code == 404:
|
|
||||||
output.append("❌ Not found via API (404). Not inserting.")
|
|
||||||
else:
|
|
||||||
output.append(f"❌ API returned unexpected status: {response.status_code}")
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
output.append(f"🚨 Exception during API request: {e}")
|
flash(f"Error deleting group: {e}", "error")
|
||||||
|
finally:
|
||||||
|
cursor.close()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
# Assuming 'group.group_list' is a valid endpoint name
|
||||||
|
return redirect(url_for("group.group_list"))
|
||||||
|
|
||||||
cursor.close()
|
|
||||||
conn.close()
|
|
||||||
return "\n".join(output)
|
|
||||||
|
|
||||||
def get_user_by_mac(mac_address):
|
# ------------------------------
|
||||||
"""Retrieve a user record from the database by MAC address."""
|
# MAC Vendor Functions
|
||||||
conn = get_connection()
|
# ------------------------------
|
||||||
cursor = conn.cursor(dictionary=True)
|
|
||||||
|
|
||||||
cursor.execute("""
|
|
||||||
SELECT * FROM users WHERE mac_address = %s
|
|
||||||
""", (mac_address,))
|
|
||||||
user = cursor.fetchone()
|
|
||||||
cursor.close()
|
|
||||||
conn.close()
|
|
||||||
return user
|
|
||||||
|
|
||||||
def get_known_mac_vendors():
|
def get_known_mac_vendors():
|
||||||
"""Fetch all known MAC prefixes and their vendor info from the local database."""
|
"""Fetch all known MAC prefixes and their vendor info from the local database."""
|
||||||
@@ -443,6 +281,9 @@ def get_vendor_info(mac, insert_if_found=True):
|
|||||||
url = url_template.format(prefix)
|
url = url_template.format(prefix)
|
||||||
print(f"→ Querying API: {url}")
|
print(f"→ Querying API: {url}")
|
||||||
response = requests.get(url, headers=headers)
|
response = requests.get(url, headers=headers)
|
||||||
|
vendor_to_insert = "not found"
|
||||||
|
status_to_insert = "not_found"
|
||||||
|
result = { "mac": mac, "vendor": "", "source": "api", "status": "not_found" } # Default
|
||||||
|
|
||||||
if response.status_code == 200:
|
if response.status_code == 200:
|
||||||
data = response.json()
|
data = response.json()
|
||||||
@@ -450,120 +291,345 @@ def get_vendor_info(mac, insert_if_found=True):
|
|||||||
|
|
||||||
if vendor:
|
if vendor:
|
||||||
print(f"✓ Found from API: {vendor}")
|
print(f"✓ Found from API: {vendor}")
|
||||||
|
vendor_to_insert = vendor
|
||||||
# Always insert found results, even if insert_if_found=False
|
status_to_insert = "found"
|
||||||
cursor.execute("""
|
result = { "mac": mac, "vendor": vendor, "source": "api", "status": "found" }
|
||||||
INSERT INTO mac_vendors (mac_prefix, vendor_name, status, last_checked, last_updated)
|
|
||||||
VALUES (%s, %s, 'found', NOW(), NOW())
|
|
||||||
ON DUPLICATE KEY UPDATE
|
|
||||||
vendor_name = VALUES(vendor_name),
|
|
||||||
status = 'found',
|
|
||||||
last_checked = NOW(),
|
|
||||||
last_updated = NOW()
|
|
||||||
""", (prefix, vendor))
|
|
||||||
print(f"→ Inserted vendor: {vendor} → rowcount: {cursor.rowcount}")
|
|
||||||
conn.commit()
|
|
||||||
|
|
||||||
return {
|
|
||||||
"mac": mac,
|
|
||||||
"vendor": vendor,
|
|
||||||
"source": "api",
|
|
||||||
"status": "found"
|
|
||||||
}
|
|
||||||
|
|
||||||
else:
|
else:
|
||||||
print("⚠️ API returned empty company field. Treating as not_found.")
|
print("⚠️ API returned empty company field. Treating as not_found.")
|
||||||
# 🛠 Always insert not_found, even if insert_if_found=False
|
# vendor/status/result remain default 'not_found'
|
||||||
cursor.execute("""
|
|
||||||
INSERT INTO mac_vendors (mac_prefix, vendor_name, status, last_checked, last_updated)
|
|
||||||
VALUES (%s, %s, 'not_found', NOW(), NOW())
|
|
||||||
ON DUPLICATE KEY UPDATE
|
|
||||||
vendor_name = VALUES(vendor_name),
|
|
||||||
status = 'not_found',
|
|
||||||
last_checked = NOW(),
|
|
||||||
last_updated = NOW()
|
|
||||||
""", (prefix, "not found"))
|
|
||||||
print(f"→ Inserted not_found for {prefix} → rowcount: {cursor.rowcount}")
|
|
||||||
conn.commit()
|
|
||||||
return {
|
|
||||||
"mac": mac,
|
|
||||||
"vendor": "",
|
|
||||||
"source": "api",
|
|
||||||
"status": "not_found"
|
|
||||||
}
|
|
||||||
|
|
||||||
elif response.status_code == 404:
|
elif response.status_code == 404:
|
||||||
print("✗ API returned 404 - vendor not found.")
|
print("✗ API returned 404 - vendor not found.")
|
||||||
# 🛠 Always insert not_found
|
# vendor/status/result remain default 'not_found'
|
||||||
cursor.execute("""
|
|
||||||
INSERT INTO mac_vendors (mac_prefix, vendor_name, status, last_checked, last_updated)
|
|
||||||
VALUES (%s, %s, 'not_found', NOW(), NOW())
|
|
||||||
ON DUPLICATE KEY UPDATE
|
|
||||||
vendor_name = VALUES(vendor_name),
|
|
||||||
status = 'not_found',
|
|
||||||
last_checked = NOW(),
|
|
||||||
last_updated = NOW()
|
|
||||||
""", (prefix, "not found"))
|
|
||||||
print(f"→ Inserted not_found (404) for {prefix} → rowcount: {cursor.rowcount}")
|
|
||||||
conn.commit()
|
|
||||||
return {
|
|
||||||
"mac": mac,
|
|
||||||
"vendor": "",
|
|
||||||
"source": "api",
|
|
||||||
"status": "not_found"
|
|
||||||
}
|
|
||||||
|
|
||||||
else:
|
else:
|
||||||
print(f"✗ API error: {response.status_code}")
|
print(f"✗ API error: {response.status_code}")
|
||||||
return {"mac": mac, "vendor": "", "error": f"API error: {response.status_code}"}
|
# Don't insert on other API errors
|
||||||
|
result = {"mac": mac, "vendor": "", "error": f"API error: {response.status_code}"}
|
||||||
|
# Skip insert logic below by returning early
|
||||||
|
cursor.close()
|
||||||
|
conn.close()
|
||||||
|
return result
|
||||||
|
|
||||||
|
# Insert/Update logic (Based on original code's comments, it always inserts/updates)
|
||||||
|
# Using 'insert_if_found' flag is ignored as per original code's apparent behaviour
|
||||||
|
cursor.execute("""
|
||||||
|
INSERT INTO mac_vendors (mac_prefix, vendor_name, status, last_checked, last_updated)
|
||||||
|
VALUES (%s, %s, %s, NOW(), NOW())
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
vendor_name = VALUES(vendor_name),
|
||||||
|
status = VALUES(status),
|
||||||
|
last_checked = NOW(),
|
||||||
|
last_updated = NOW()
|
||||||
|
""", (prefix, vendor_to_insert, status_to_insert))
|
||||||
|
print(f"→ Inserted/Updated '{vendor_to_insert}' ({status_to_insert}) for {prefix} → rowcount: {cursor.rowcount}")
|
||||||
|
conn.commit()
|
||||||
|
return result
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"✗ Exception while querying API: {e}")
|
print(f"✗ Exception while querying API: {e}")
|
||||||
return {"mac": mac, "vendor": "", "error": str(e)}
|
return {"mac": mac, "vendor": "", "error": str(e)}
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
|
if conn and conn.is_connected():
|
||||||
|
if cursor:
|
||||||
|
cursor.close()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def lookup_mac_verbose(mac):
|
||||||
|
"""Look up vendor info for a MAC with verbose output, querying API if needed."""
|
||||||
|
conn = get_connection()
|
||||||
|
cursor = conn.cursor(dictionary=True)
|
||||||
|
output = []
|
||||||
|
prefix = mac.lower().replace(":", "").replace("-", "")[:6]
|
||||||
|
|
||||||
|
output.append(f"🔍 Searching local database for prefix: {prefix}...")
|
||||||
|
|
||||||
|
cursor.execute("SELECT vendor_name, status FROM mac_vendors WHERE mac_prefix = %s", (prefix,))
|
||||||
|
row = cursor.fetchone()
|
||||||
|
|
||||||
|
if row:
|
||||||
|
output.append(f"✅ Found locally: {row['vendor_name']} (status: {row['status']})")
|
||||||
cursor.close()
|
cursor.close()
|
||||||
conn.close()
|
conn.close()
|
||||||
|
return "\n".join(output)
|
||||||
|
|
||||||
def delete_group_route():
|
output.append("❌ Not found locally.")
|
||||||
"""Handle deletion of a group and optionally its users via form POST."""
|
output.append("🌐 Querying API...")
|
||||||
vlan_id = request.form.get("group_id")
|
|
||||||
force = request.form.get("force_delete") == "true"
|
|
||||||
|
|
||||||
|
url_template = current_app.config.get("OUI_API_URL", "https://api.maclookup.app/v2/macs/{}")
|
||||||
|
api_key = current_app.config.get("OUI_API_KEY", "")
|
||||||
|
headers = {"Authorization": f"Bearer {api_key}"} if api_key else {}
|
||||||
|
|
||||||
|
try:
|
||||||
|
url = url_template.format(prefix)
|
||||||
|
response = requests.get(url, headers=headers, timeout=10) # Add timeout
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
data = response.json()
|
||||||
|
vendor_name = data.get("company", "").strip()
|
||||||
|
if vendor_name:
|
||||||
|
output.append(f"✅ Found via API: {vendor_name}")
|
||||||
|
output.append("💾 Inserting into local database...")
|
||||||
|
|
||||||
|
# Original code here used simple INSERT, not INSERT...ON DUPLICATE KEY UPDATE
|
||||||
|
# Consider changing to match get_vendor_info for consistency if desired.
|
||||||
|
# Sticking to original code for now.
|
||||||
|
cursor.execute("""
|
||||||
|
INSERT INTO mac_vendors (mac_prefix, vendor_name, status, last_checked, last_updated)
|
||||||
|
VALUES (%s, %s, 'found', NOW(), NOW())
|
||||||
|
""", (prefix, vendor_name))
|
||||||
|
conn.commit()
|
||||||
|
output.append(f" → Inserted '{vendor_name}' for {prefix} (rowcount: {cursor.rowcount})")
|
||||||
|
else:
|
||||||
|
output.append("⚠️ API responded but no vendor name found. Not inserting.")
|
||||||
|
# Consider inserting 'not_found' status here for consistency? Original code doesn't.
|
||||||
|
elif response.status_code == 404:
|
||||||
|
output.append("❌ Not found via API (404). Not inserting.")
|
||||||
|
# Consider inserting 'not_found' status here for consistency? Original code doesn't.
|
||||||
|
else:
|
||||||
|
output.append(f"❌ API returned unexpected status: {response.status_code}")
|
||||||
|
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
output.append(f"🚨 Network/Request Exception during API request: {e}")
|
||||||
|
except Exception as e:
|
||||||
|
output.append(f"🚨 Unexpected Exception during API request: {e}")
|
||||||
|
conn.rollback() # Rollback on general exception during DB interaction phase
|
||||||
|
|
||||||
|
finally:
|
||||||
|
if conn and conn.is_connected():
|
||||||
|
if cursor:
|
||||||
|
cursor.close()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
return "\n".join(output)
|
||||||
|
|
||||||
|
def refresh_vendors():
|
||||||
|
"""Fetch and cache vendor info for unknown MAC prefixes using the API."""
|
||||||
|
conn = get_connection()
|
||||||
|
cursor = conn.cursor(dictionary=True)
|
||||||
|
|
||||||
|
# Fetch all distinct 6-char prefixes from users table that are NOT in mac_vendors table
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT DISTINCT SUBSTRING(REPLACE(REPLACE(mac_address, ':', ''), '-', ''), 1, 6) AS mac_prefix
|
||||||
|
FROM users
|
||||||
|
WHERE SUBSTRING(REPLACE(REPLACE(mac_address, ':', ''), '-', ''), 1, 6) NOT IN (
|
||||||
|
SELECT mac_prefix FROM mac_vendors
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
prefixes = [row['mac_prefix'].lower() for row in cursor.fetchall() if row['mac_prefix']]
|
||||||
|
cursor.close()
|
||||||
|
|
||||||
|
if not prefixes:
|
||||||
|
print("→ No unknown MAC prefixes found in users table to refresh.")
|
||||||
|
conn.close()
|
||||||
|
return
|
||||||
|
|
||||||
|
print(f"→ Found {len(prefixes)} unknown prefixes to look up.")
|
||||||
|
|
||||||
|
url_template = current_app.config.get("OUI_API_URL", "https://api.maclookup.app/v2/macs/{}")
|
||||||
|
api_key = current_app.config.get("OUI_API_KEY", "")
|
||||||
|
rate_limit = int(current_app.config.get("OUI_API_LIMIT_PER_SEC", 2))
|
||||||
|
daily_limit = int(current_app.config.get("OUI_API_DAILY_LIMIT", 10000))
|
||||||
|
|
||||||
|
headers = {"Authorization": f"Bearer {api_key}"} if api_key else {}
|
||||||
|
|
||||||
|
inserted_count = 0
|
||||||
|
skipped_count = 0
|
||||||
|
error_count = 0
|
||||||
|
cursor = conn.cursor() # Use standard cursor for inserts
|
||||||
|
|
||||||
|
for i, prefix in enumerate(prefixes):
|
||||||
|
if inserted_count >= daily_limit:
|
||||||
|
print(f"🛑 Reached daily API limit ({daily_limit}). Stopping refresh.")
|
||||||
|
break
|
||||||
|
|
||||||
|
print(f" ({i+1}/{len(prefixes)}) Looking up prefix: {prefix}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
url = url_template.format(prefix)
|
||||||
|
response = requests.get(url, headers=headers, timeout=10)
|
||||||
|
|
||||||
|
vendor_name = "not found"
|
||||||
|
status = "not_found"
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
data = response.json()
|
||||||
|
api_vendor = data.get("company", "").strip()
|
||||||
|
if api_vendor:
|
||||||
|
vendor_name = api_vendor
|
||||||
|
status = "found"
|
||||||
|
print(f" ✓ Found: {vendor_name}")
|
||||||
|
else:
|
||||||
|
print(f" ⚠️ API OK, but empty vendor for {prefix}. Marked as 'not found'.")
|
||||||
|
elif response.status_code == 404:
|
||||||
|
print(f" ✗ Not found (404) for {prefix}.")
|
||||||
|
else:
|
||||||
|
print(f" ❌ API error {response.status_code} for {prefix}, skipping insert.")
|
||||||
|
error_count += 1
|
||||||
|
time.sleep(1.0 / rate_limit)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Insert or Update logic matches get_vendor_info
|
||||||
|
cursor.execute("""
|
||||||
|
INSERT INTO mac_vendors (mac_prefix, vendor_name, status, last_checked, last_updated)
|
||||||
|
VALUES (%s, %s, %s, NOW(), NOW())
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
vendor_name = VALUES(vendor_name),
|
||||||
|
status = VALUES(status),
|
||||||
|
last_checked = NOW(),
|
||||||
|
last_updated = NOW()
|
||||||
|
""", (prefix, vendor_name, status))
|
||||||
|
conn.commit()
|
||||||
|
if cursor.rowcount > 0:
|
||||||
|
inserted_count += 1
|
||||||
|
print(f" → Stored '{vendor_name}' ({status}) for {prefix}")
|
||||||
|
else:
|
||||||
|
print(f" → No change recorded for {prefix}.")
|
||||||
|
skipped_count +=1
|
||||||
|
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
print(f" 🚨 Network/Request Exception fetching vendor for {prefix}: {e}")
|
||||||
|
error_count += 1
|
||||||
|
except Exception as e:
|
||||||
|
print(f" 🚨 Unexpected Exception fetching vendor for {prefix}: {e}")
|
||||||
|
error_count += 1
|
||||||
|
conn.rollback()
|
||||||
|
|
||||||
|
time.sleep(1.0 / rate_limit)
|
||||||
|
|
||||||
|
print(f"→ Refresh finished. Inserted/Updated: {inserted_count}, Skipped/No change: {skipped_count}, Errors: {error_count}")
|
||||||
|
cursor.close()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------
|
||||||
|
# Authentication Log Functions
|
||||||
|
# ------------------------------
|
||||||
|
|
||||||
|
def get_latest_auth_logs(reply_type=None, limit=5, time_range=None, offset=0):
|
||||||
|
"""Retrieve recent authentication logs filtered by reply type and time range."""
|
||||||
|
conn = get_connection()
|
||||||
|
cursor = conn.cursor(dictionary=True)
|
||||||
|
|
||||||
|
tz_str = current_app.config.get('APP_TIMEZONE', 'UTC')
|
||||||
|
try:
|
||||||
|
app_tz = pytz.timezone(tz_str)
|
||||||
|
except pytz.UnknownTimeZoneError:
|
||||||
|
print(f"Warning: Unknown timezone '{tz_str}', falling back to UTC.")
|
||||||
|
app_tz = pytz.utc
|
||||||
|
now = datetime.now(app_tz)
|
||||||
|
|
||||||
|
query_base = "SELECT * FROM auth_logs"
|
||||||
|
filters = []
|
||||||
|
params = []
|
||||||
|
|
||||||
|
if reply_type is not None:
|
||||||
|
filters.append("reply = %s")
|
||||||
|
params.append(reply_type)
|
||||||
|
|
||||||
|
time_filter_dt = None
|
||||||
|
if time_range and time_range != 'all':
|
||||||
|
delta = {
|
||||||
|
'last_minute': timedelta(minutes=1),
|
||||||
|
'last_5_minutes': timedelta(minutes=5),
|
||||||
|
'last_10_minutes': timedelta(minutes=10),
|
||||||
|
'last_hour': timedelta(hours=1),
|
||||||
|
'last_6_hours': timedelta(hours=6),
|
||||||
|
'last_12_hours': timedelta(hours=12),
|
||||||
|
'last_day': timedelta(days=1),
|
||||||
|
'last_30_days': timedelta(days=30)
|
||||||
|
}.get(time_range)
|
||||||
|
|
||||||
|
if delta:
|
||||||
|
time_filter_dt = now - delta
|
||||||
|
filters.append("timestamp >= %s")
|
||||||
|
params.append(time_filter_dt)
|
||||||
|
|
||||||
|
if filters:
|
||||||
|
query_base += " WHERE " + " AND ".join(filters)
|
||||||
|
|
||||||
|
query_base += " ORDER BY timestamp DESC LIMIT %s OFFSET %s"
|
||||||
|
params.extend([limit, offset])
|
||||||
|
|
||||||
|
cursor.execute(query_base, tuple(params))
|
||||||
|
logs = cursor.fetchall()
|
||||||
|
cursor.close()
|
||||||
|
conn.close()
|
||||||
|
return logs
|
||||||
|
|
||||||
|
def count_auth_logs(reply_type=None, time_range=None):
|
||||||
|
"""Count the number of authentication logs matching a reply type and time."""
|
||||||
conn = get_connection()
|
conn = get_connection()
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
|
|
||||||
cursor.execute("SELECT COUNT(*) FROM users WHERE vlan_id = %s", (vlan_id,))
|
tz_str = current_app.config.get('APP_TIMEZONE', 'UTC')
|
||||||
user_count = cursor.fetchone()[0]
|
try:
|
||||||
|
app_tz = pytz.timezone(tz_str)
|
||||||
|
except pytz.UnknownTimeZoneError:
|
||||||
|
print(f"Warning: Unknown timezone '{tz_str}', falling back to UTC.")
|
||||||
|
app_tz = pytz.utc
|
||||||
|
now = datetime.now(app_tz)
|
||||||
|
|
||||||
if user_count > 0 and not force:
|
query_base = "SELECT COUNT(*) FROM auth_logs"
|
||||||
conn.close()
|
filters = []
|
||||||
flash("Group has users. Please confirm deletion or reassign users.", "error")
|
params = []
|
||||||
return redirect(url_for("group.group_list"))
|
|
||||||
|
if reply_type is not None:
|
||||||
|
filters.append("reply = %s")
|
||||||
|
params.append(reply_type)
|
||||||
|
|
||||||
|
time_filter_dt = None
|
||||||
|
if time_range and time_range != 'all':
|
||||||
|
delta = {
|
||||||
|
'last_minute': timedelta(minutes=1),
|
||||||
|
'last_5_minutes': timedelta(minutes=5),
|
||||||
|
'last_10_minutes': timedelta(minutes=10),
|
||||||
|
'last_hour': timedelta(hours=1),
|
||||||
|
'last_6_hours': timedelta(hours=6),
|
||||||
|
'last_12_hours': timedelta(hours=12),
|
||||||
|
'last_day': timedelta(days=1),
|
||||||
|
'last_30_days': timedelta(days=30)
|
||||||
|
}.get(time_range)
|
||||||
|
|
||||||
|
if delta:
|
||||||
|
time_filter_dt = now - delta
|
||||||
|
filters.append("timestamp >= %s")
|
||||||
|
params.append(time_filter_dt)
|
||||||
|
|
||||||
|
if filters:
|
||||||
|
query_base += " WHERE " + " AND ".join(filters)
|
||||||
|
|
||||||
|
cursor.execute(query_base, tuple(params))
|
||||||
|
count = cursor.fetchone()[0]
|
||||||
|
cursor.close()
|
||||||
|
conn.close()
|
||||||
|
return count
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------
|
||||||
|
# Summary Functions
|
||||||
|
# ------------------------------
|
||||||
|
|
||||||
|
def get_summary_counts():
|
||||||
|
"""Return total counts of users and groups from the database."""
|
||||||
|
conn = get_connection()
|
||||||
|
cursor = conn.cursor(dictionary=True)
|
||||||
|
|
||||||
|
total_users = 0
|
||||||
|
total_groups = 0
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if force:
|
cursor.execute("SELECT COUNT(*) AS count FROM users")
|
||||||
cursor.execute("DELETE FROM users WHERE vlan_id = %s", (vlan_id,))
|
result = cursor.fetchone()
|
||||||
|
total_users = result['count'] if result else 0
|
||||||
|
|
||||||
|
cursor.execute("SELECT COUNT(*) AS count FROM groups")
|
||||||
|
result = cursor.fetchone()
|
||||||
|
total_groups = result['count'] if result else 0
|
||||||
|
|
||||||
cursor.execute("DELETE FROM groups WHERE vlan_id = %s", (vlan_id,))
|
|
||||||
conn.commit()
|
|
||||||
flash(f"Group {vlan_id} and associated users deleted." if force else f"Group {vlan_id} deleted.", "success")
|
|
||||||
except mysql.connector.IntegrityError as e:
|
|
||||||
flash(f"Cannot delete group {vlan_id}: it is still in use. Error: {e}", "error")
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
flash(f"Error deleting group: {e}", "error")
|
print(f"Error getting summary counts: {e}")
|
||||||
finally:
|
finally:
|
||||||
cursor.close()
|
cursor.close()
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
return redirect(url_for("group.group_list"))
|
return total_users, total_groups
|
||||||
|
|
||||||
def get_users_by_vlan_id(vlan_id):
|
|
||||||
"""Fetch users assigned to a specific VLAN ID."""
|
|
||||||
conn = get_connection()
|
|
||||||
cursor = conn.cursor(dictionary=True)
|
|
||||||
cursor.execute("SELECT mac_address, description FROM users WHERE vlan_id = %s", (vlan_id,))
|
|
||||||
users = cursor.fetchall()
|
|
||||||
cursor.close()
|
|
||||||
conn.close()
|
|
||||||
return users
|
|
||||||
Reference in New Issue
Block a user