completing user administration
This commit is contained in:
35
web/app.py
35
web/app.py
@@ -6,6 +6,7 @@ from typing import Dict, List
|
|||||||
from web.craigslist import scraper
|
from web.craigslist import scraper
|
||||||
from web.db import (
|
from web.db import (
|
||||||
db_init,
|
db_init,
|
||||||
|
delete_user_by_id,
|
||||||
get_all_jobs,
|
get_all_jobs,
|
||||||
mark_favorite,
|
mark_favorite,
|
||||||
record_visit,
|
record_visit,
|
||||||
@@ -13,6 +14,7 @@ from web.db import (
|
|||||||
create_or_update_user,
|
create_or_update_user,
|
||||||
verify_user_credentials,
|
verify_user_credentials,
|
||||||
get_user,
|
get_user,
|
||||||
|
get_user_by_id,
|
||||||
get_user_regions,
|
get_user_regions,
|
||||||
get_user_keywords,
|
get_user_keywords,
|
||||||
set_user_regions,
|
set_user_regions,
|
||||||
@@ -342,6 +344,39 @@ def admin_users():
|
|||||||
return render_template('admin/users.html', users=users, title='Users')
|
return render_template('admin/users.html', users=users, title='Users')
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/admin/user/<user_id>', methods=['GET', 'POST'])
|
||||||
|
def admin_user(user_id):
|
||||||
|
if not require_admin():
|
||||||
|
return redirect(url_for('login'))
|
||||||
|
user = get_user_by_id(user_id)
|
||||||
|
if request.method == 'POST':
|
||||||
|
data = request.form
|
||||||
|
username = (data.get('username') or '').strip()
|
||||||
|
password = data.get('new_password')
|
||||||
|
is_admin = bool(data.get('is_admin'))
|
||||||
|
is_active = bool(data.get('is_active')) if data.get(
|
||||||
|
'is_active') is not None else True
|
||||||
|
try:
|
||||||
|
create_or_update_user(
|
||||||
|
username, password=password, is_admin=is_admin, is_active=is_active)
|
||||||
|
flash('User saved')
|
||||||
|
except Exception as e:
|
||||||
|
flash(f'Error: {e}')
|
||||||
|
return redirect(url_for('admin_users'))
|
||||||
|
return render_template('admin/user.html', user=user, title='User')
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/admin/user/<user_id>/delete', methods=['POST'])
|
||||||
|
def admin_user_delete(user_id):
|
||||||
|
if not require_admin():
|
||||||
|
return redirect(url_for('login'))
|
||||||
|
if delete_user_by_id(user_id):
|
||||||
|
flash('User deleted')
|
||||||
|
else:
|
||||||
|
flash('Error deleting user')
|
||||||
|
return redirect(url_for('admin_users'))
|
||||||
|
|
||||||
|
|
||||||
# ---------------- User settings (regions/keywords) -------------------------
|
# ---------------- User settings (regions/keywords) -------------------------
|
||||||
|
|
||||||
@app.route('/settings', methods=['GET', 'POST'])
|
@app.route('/settings', methods=['GET', 'POST'])
|
||||||
|
|||||||
41
web/db.py
41
web/db.py
@@ -534,7 +534,8 @@ def remove_job(url):
|
|||||||
|
|
||||||
def get_or_create_user(username: str) -> int:
|
def get_or_create_user(username: str) -> int:
|
||||||
"""Return user_id for username, creating if missing."""
|
"""Return user_id for username, creating if missing."""
|
||||||
created_at = datetime.now(UTC).isoformat()
|
# 2025-08-30T16:04:29.660245+00:00 is wrong. should be 2025-08-30T16:04:29
|
||||||
|
created_at = datetime.now(UTC).isoformat().split('.')[0]
|
||||||
with _ensure_session() as session:
|
with _ensure_session() as session:
|
||||||
row = session.execute(
|
row = session.execute(
|
||||||
text("SELECT user_id FROM users WHERE username = :u"), {
|
text("SELECT user_id FROM users WHERE username = :u"), {
|
||||||
@@ -654,22 +655,50 @@ def get_user(username: str) -> Optional[Dict[str, Any]]:
|
|||||||
"""Return single user dict or None."""
|
"""Return single user dict or None."""
|
||||||
with _ensure_session() as session:
|
with _ensure_session() as session:
|
||||||
row = session.execute(text(
|
row = session.execute(text(
|
||||||
"SELECT user_id, username, is_admin, is_active, password_hash, last_login, created_at FROM users WHERE username = :u"
|
"SELECT user_id, username, created_at, is_admin, is_active, last_login, (password_hash IS NOT NULL) AS has_pw FROM users WHERE username = :u"
|
||||||
), {"u": username}).fetchone()
|
), {"u": username}).fetchone()
|
||||||
if not row:
|
if not row:
|
||||||
return None
|
return None
|
||||||
return {
|
return {
|
||||||
"user_id": int(row[0]),
|
"user_id": int(row[0]),
|
||||||
"username": row[1],
|
"username": row[1],
|
||||||
"is_admin": bool(row[2]),
|
"created_at": row[2].isoformat() if isinstance(row[2], datetime) else (row[2] or None),
|
||||||
"is_active": bool(row[3]),
|
"is_admin": bool(row[3]),
|
||||||
"password_hash": row[4],
|
"is_active": bool(row[4]),
|
||||||
"last_login": row[5].isoformat() if row[5] else None,
|
"last_login": row[5].isoformat() if row[5] else None,
|
||||||
"created_at": row[6].isoformat() if isinstance(row[6], datetime) else (row[6] or None),
|
"has_password": bool(row[6]),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_user_by_id(user_id: int) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Return single user dict or None."""
|
||||||
|
with _ensure_session() as session:
|
||||||
|
row = session.execute(text(
|
||||||
|
"SELECT user_id, username, created_at, is_admin, is_active, last_login, (password_hash IS NOT NULL) AS has_pw FROM users WHERE user_id = :u"
|
||||||
|
), {"u": user_id}).fetchone()
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"user_id": int(row[0]),
|
||||||
|
"username": row[1],
|
||||||
|
"created_at": row[2].isoformat() if isinstance(row[2], datetime) else (row[2] or None),
|
||||||
|
"is_admin": bool(row[3]),
|
||||||
|
"is_active": bool(row[4]),
|
||||||
|
"last_login": row[5].isoformat() if row[5] else None,
|
||||||
|
"has_password": bool(row[6]),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def delete_user_by_id(user_id: int) -> bool:
|
||||||
|
with _ensure_session() as session:
|
||||||
|
result = session.execute(
|
||||||
|
text("DELETE FROM users WHERE user_id = :u"), {"u": user_id})
|
||||||
|
session.commit()
|
||||||
|
return result.rowcount > 0
|
||||||
|
|
||||||
# ---------------- Regions/Keywords helpers ---------------------------------
|
# ---------------- Regions/Keywords helpers ---------------------------------
|
||||||
|
|
||||||
|
|
||||||
def upsert_region(name: str) -> int:
|
def upsert_region(name: str) -> int:
|
||||||
"""Get or create a region by name; return region_id."""
|
"""Get or create a region by name; return region_id."""
|
||||||
name = (name or "").strip()
|
name = (name or "").strip()
|
||||||
|
|||||||
114
web/templates/admin/user.html
Normal file
114
web/templates/admin/user.html
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
{% extends 'base.html' %} {% block content %}
|
||||||
|
<div id="user-details">
|
||||||
|
{% if not user %}
|
||||||
|
<h2>Create new user</h2>
|
||||||
|
<form id="new-user-form" method="post" action="{{ url_for('admin_users') }}">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
|
||||||
|
<div id="user-info">
|
||||||
|
<p>
|
||||||
|
<strong>Username:</strong>
|
||||||
|
<input type="text" name="username" required />
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>Password:</strong>
|
||||||
|
<input type="password" name="password" required />
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>Admin:</strong>
|
||||||
|
<input type="checkbox" name="is_admin" />
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>Active:</strong>
|
||||||
|
<input type="checkbox" name="is_active" />
|
||||||
|
</p>
|
||||||
|
<button type="submit">Create User</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<h2>User {{ user.username }}</h2>
|
||||||
|
<form
|
||||||
|
id="user-form"
|
||||||
|
method="post"
|
||||||
|
action="{{ url_for('admin_user', user_id=user.user_id) }}"
|
||||||
|
>
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
|
||||||
|
<input type="hidden" name="user_id" value="{{ user.user_id }}" />
|
||||||
|
<input type="hidden" name="username" value="{{ user.username }}" />
|
||||||
|
<div id="user-info">
|
||||||
|
<p><strong>ID:</strong> {{ user.user_id }}</p>
|
||||||
|
<p><strong>Username:</strong> {{ user.username }}</p>
|
||||||
|
<p><strong>Created At:</strong> {{ user.created_at }}</p>
|
||||||
|
<p><strong>Last Login:</strong> {{ user.last_login }}</p>
|
||||||
|
<p>
|
||||||
|
<strong>Admin:</strong>
|
||||||
|
<input type="checkbox" name="is_admin" {{ 'checked' if user.is_admin
|
||||||
|
else '' }} />
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>Active:</strong>
|
||||||
|
<input type="checkbox" name="is_active" {{ 'checked' if user.is_active
|
||||||
|
else '' }} />
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>Has Password:</strong> {{ '✅' if user.has_password else '❌' }}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>New Password:</strong>
|
||||||
|
<input type="password" id="new_password" name="new_password" />
|
||||||
|
</p>
|
||||||
|
<button type="submit">Save</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
const userForm = document.getElementById("user-form");
|
||||||
|
|
||||||
|
userForm.addEventListener("submit", function (event) {
|
||||||
|
const userId = document.getElementById("user_id").value;
|
||||||
|
event.preventDefault(); // Prevent the default form submission
|
||||||
|
updateUser(userId);
|
||||||
|
});
|
||||||
|
|
||||||
|
function updateUser(userId) {
|
||||||
|
const passwordInput = document.getElementById("new_password");
|
||||||
|
const formData = userForm.elements;
|
||||||
|
const username = formData.username.value;
|
||||||
|
const password = passwordInput.value;
|
||||||
|
const isAdmin = formData.is_admin.checked;
|
||||||
|
const isActive = formData.is_active.checked;
|
||||||
|
const hasPassword = passwordInput.value.trim() !== "";
|
||||||
|
|
||||||
|
fetch("/admin/user/" + userId, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-CSRF-Token": formData.csrf_token.value,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
user_id: userId,
|
||||||
|
password: password,
|
||||||
|
username: username,
|
||||||
|
is_admin: isAdmin,
|
||||||
|
is_active: isActive,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.then((response) => {
|
||||||
|
if (response.ok) {
|
||||||
|
alert("User updated successfully");
|
||||||
|
// Clear the password field after successful update
|
||||||
|
passwordInput.value = "";
|
||||||
|
// Set 'has_password' indicator
|
||||||
|
userForm.querySelector('input[name="has_password"]').value =
|
||||||
|
hasPassword ? "✅" : "❌";
|
||||||
|
} else {
|
||||||
|
alert("Error updating user");
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error("Error:", error);
|
||||||
|
alert("Error updating user");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{% endif %} {% endblock %} {% block footer_scripts %} {% endblock %}
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
{% extends 'base.html' %} {% block content %}
|
{% extends 'base.html' %} {% block content %}
|
||||||
<div id="users">
|
<div id="users">
|
||||||
<h2>Users</h2>
|
<h2>Users</h2>
|
||||||
<form id="user-form" method="post" action="{{ url_for('admin_users') }}">
|
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
@@ -10,130 +9,92 @@
|
|||||||
<th>Username</th>
|
<th>Username</th>
|
||||||
<th>Admin</th>
|
<th>Admin</th>
|
||||||
<th>Active</th>
|
<th>Active</th>
|
||||||
<th colspan="2">Password</th>
|
<th>Password</th>
|
||||||
<th>Created</th>
|
<th>Created</th>
|
||||||
<th>Last Login</th>
|
<th>Last Login</th>
|
||||||
<th></th>
|
|
||||||
|
<th>Edit</th>
|
||||||
|
<th>Delete</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for u in users %}
|
{% for u in users %}
|
||||||
<tr class="user-row" data-user-id="{{ u.user_id }}">
|
<tr class="user-row" data-user-id="{{ u.user_id }}">
|
||||||
|
<td>{{ u.user_id }}</td>
|
||||||
<td>
|
<td>
|
||||||
{{ u.user_id }}<input
|
<a href="{{ url_for('admin_user', user_id=u.user_id) }}"
|
||||||
type="hidden"
|
>{{ u.username }}</a
|
||||||
name="user_id"
|
>
|
||||||
value="{{ u.user_id }}"
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
name="username"
|
|
||||||
value="{{ u.username }}"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<input type="checkbox" name="is_admin" {{ 'checked' if u.is_admin
|
|
||||||
else '' }} />
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<input type="checkbox" name="is_active" {{ 'checked' if u.is_active
|
|
||||||
else '' }} />
|
|
||||||
</td>
|
</td>
|
||||||
|
<td>{{ '✅' if u.is_admin else '❌' }}</td>
|
||||||
|
<td>{{ '✅' if u.is_active else '❌' }}</td>
|
||||||
<td>{{ '✅' if u.has_password else '❌' }}</td>
|
<td>{{ '✅' if u.has_password else '❌' }}</td>
|
||||||
<td><input type="password" name="password" /></td>
|
|
||||||
<td>{{ u.created_at }}</td>
|
<td>{{ u.created_at }}</td>
|
||||||
<td>{{ u.last_login or 'never' }}</td>
|
<td>{{ u.last_login or 'never' }}</td>
|
||||||
<td>
|
<td>
|
||||||
<button type="submit" data-user-id="{{ u.user_id }}">Save</button>
|
<button
|
||||||
|
type="button"
|
||||||
|
class="edit-user"
|
||||||
|
data-user-id="{{ u.user_id }}"
|
||||||
|
onclick="editUser({{ u.user_id }})"
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="delete-user"
|
||||||
|
data-user-id="{{ u.user_id }}"
|
||||||
|
onclick="deleteUser({{ u.user_id }})"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
<h3>Create / Update User</h3>
|
<h2>Create New User</h2>
|
||||||
<form
|
<a href="{{ url_for('admin_user', user_id='new') }}">Create User</a>
|
||||||
id="create-update-user-form"
|
|
||||||
method="post"
|
|
||||||
action="{{ url_for('admin_users') }}"
|
|
||||||
>
|
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
|
|
||||||
<label>Username <input type="text" name="username" required /></label>
|
|
||||||
<label>Password <input type="password" name="password" /></label>
|
|
||||||
<label>Admin <input type="checkbox" name="is_admin" value="1" /></label>
|
|
||||||
<label
|
|
||||||
>Active <input type="checkbox" name="is_active" value="1" checked
|
|
||||||
/></label>
|
|
||||||
<button type="submit">Save</button>
|
|
||||||
</form>
|
|
||||||
{% endblock %} {% block footer_scripts %}
|
{% endblock %} {% block footer_scripts %}
|
||||||
<script>
|
<script>
|
||||||
function updateUser(userId) {
|
function editUser(userId) {
|
||||||
const row = document.querySelector(`.user-row[data-user-id="${userId}"]`);
|
window.location.href = `/admin/user/${userId}`;
|
||||||
const passwordInput = row.querySelector('input[name="password"]');
|
}
|
||||||
const hasPassword =
|
function deleteUser(userId) {
|
||||||
row.querySelector("td:nth-child(5)").textContent.trim() === "✅";
|
if (
|
||||||
const formData = row.querySelector("form").elements;
|
confirm(
|
||||||
const username = formData.username.value;
|
"Are you sure you want to delete this user? This action cannot be undone."
|
||||||
const password = hasPassword ? passwordInput.value : undefined;
|
)
|
||||||
const isAdmin = formData.is_admin.checked;
|
) {
|
||||||
const isActive = formData.is_active.checked;
|
fetch(`/admin/user/${userId}/delete`, {
|
||||||
|
|
||||||
fetch("/admin/users", {
|
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
|
"X-CSRFToken": document.querySelector('input[name="csrf_token"]')
|
||||||
|
.value,
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
|
||||||
user_id: userId,
|
|
||||||
password: password,
|
|
||||||
username: username,
|
|
||||||
is_admin: isAdmin,
|
|
||||||
is_active: isActive,
|
|
||||||
csrf_token: formData.csrf_token.value,
|
|
||||||
}),
|
|
||||||
})
|
})
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
alert("User updated successfully");
|
// Remove the user row from the table
|
||||||
// Clear the password field after successful update
|
const row = document.querySelector(
|
||||||
passwordInput.value = "";
|
`.user-row[data-user-id="${userId}"]`
|
||||||
|
);
|
||||||
|
if (row) {
|
||||||
|
row.remove();
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
alert("Error updating user");
|
alert("Error deleting user.");
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error("Error:", error);
|
console.error("Error:", error);
|
||||||
alert("Error updating user");
|
alert("Error deleting user.");
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function initUserForm() {
|
|
||||||
const form = document.getElementById("user-form");
|
|
||||||
const createUpdateForm = document.getElementById("create-update-user-form");
|
|
||||||
|
|
||||||
form.addEventListener("submit", function (event) {
|
|
||||||
const userId = event.target.querySelector('input[name="user_id"]').value;
|
|
||||||
event.preventDefault(); // Prevent the default form submission
|
|
||||||
updateUser(userId);
|
|
||||||
});
|
|
||||||
|
|
||||||
form.addEventListener("click", function (event) {
|
|
||||||
const userId = event.target.closest(".user-row").dataset.userId;
|
|
||||||
updateUser(userId);
|
|
||||||
});
|
|
||||||
|
|
||||||
createUpdateForm.addEventListener("submit", function (event) {
|
|
||||||
const passwordInput = createUpdateForm.querySelector(
|
|
||||||
'input[name="password"]'
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
initUserForm();
|
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -44,7 +44,8 @@
|
|||||||
<div id="jobs">
|
<div id="jobs">
|
||||||
{% for job in jobs %}
|
{% for job in jobs %}
|
||||||
<div class="job">
|
<div class="job">
|
||||||
<h3><a href="{{ job['url'] }}" target="_blank">{{ job['title'] }}</a></h3>
|
<!--<h3><a href="{{ job['url'] }}" target="_blank">{{ job['title'] }}</a></h3>-->
|
||||||
|
<h3><a href="{{ url_for('job_by_id', job_id=job['id']) }}" target="_blank">{{ job['title'] }}</a></h3>
|
||||||
<p class="job-posted-time">{{ job['posted_time'] }}</p>
|
<p class="job-posted-time">{{ job['posted_time'] }}</p>
|
||||||
<span class="job-region region-{{ job['region'] }}">{{ job['region'] }}</span>
|
<span class="job-region region-{{ job['region'] }}">{{ job['region'] }}</span>
|
||||||
<span class="job-keyword keyword-{{ job['keyword']|replace(' ', '')|lower }}">{{ job['keyword'] }}</span>
|
<span class="job-keyword keyword-{{ job['keyword']|replace(' ', '')|lower }}">{{ job['keyword'] }}</span>
|
||||||
|
|||||||
Reference in New Issue
Block a user