feat(admin): add Users management (list/create/reset/delete/toggle), seed test users; improve UI with modern styles

This commit is contained in:
2025-09-04 14:08:15 +03:00
parent e22823cbbd
commit d54e12123b
4 changed files with 202 additions and 0 deletions

104
main.py
View File

@@ -54,6 +54,110 @@ def ensure_default_admin():
if os.environ.get('SEED_ADMIN_DISABLED') != '1': if os.environ.get('SEED_ADMIN_DISABLED') != '1':
ensure_default_admin() ensure_default_admin()
# Seed two test users (non-admin) for demos
def ensure_test_users():
tests = [
('test1', 'test1@example.com', 'Пользователь 1'),
('test2', 'test2@example.com', 'Пользователь 2'),
]
for username, email, full_name in tests:
u = User.get_or_none(User.username == username)
if not u:
User.create(
username=username,
email=email,
full_name=full_name,
password_hash=generate_password_hash(os.environ.get('TEST_USER_PASSWORD', '1234')),
is_admin=False,
)
if os.environ.get('SEED_TEST_USERS_DISABLED') != '1':
ensure_test_users()
@app.route('/admin/users', methods=['GET', 'POST'])
@admin_required
def manage_users():
if request.method == 'POST':
# Create user
username = request.form['username'].strip()
email = request.form['email'].strip()
full_name = request.form.get('full_name', '').strip()
password = request.form['password']
is_admin_flag = request.form.get('is_admin') == 'on'
if not username or not email or not password:
flash('Заполните обязательные поля', 'danger')
return redirect(url_for('manage_users'))
if User.select().where((User.username == username) | (User.email == email)).exists():
flash('Пользователь с таким логином или email уже существует', 'danger')
return redirect(url_for('manage_users'))
User.create(
username=username,
email=email,
full_name=full_name or None,
password_hash=generate_password_hash(password),
is_admin=is_admin_flag,
)
flash('Пользователь создан', 'success')
return redirect(url_for('manage_users'))
users = User.select().order_by(User.id)
return render_template('admin/users.html', users=users, title='Пользователи')
@app.route('/admin/users/<int:user_id>/reset_password', methods=['POST'])
@admin_required
def admin_reset_password(user_id):
user = User.get_or_none(User.id == user_id)
if not user:
flash('Пользователь не найден', 'danger')
return redirect(url_for('manage_users'))
new_password = request.form.get('new_password')
if not new_password:
flash('Укажите новый пароль', 'danger')
return redirect(url_for('manage_users'))
user.password_hash = generate_password_hash(new_password)
user.save()
flash('Пароль обновлён', 'success')
return redirect(url_for('manage_users'))
@app.route('/admin/users/<int:user_id>/toggle_admin', methods=['POST'])
@admin_required
def admin_toggle_admin(user_id):
if current_user.id == user_id:
flash('Нельзя менять свои собственные права', 'warning')
return redirect(url_for('manage_users'))
user = User.get_or_none(User.id == user_id)
if not user:
flash('Пользователь не найден', 'danger')
return redirect(url_for('manage_users'))
user.is_admin = not user.is_admin
user.save()
flash('Права обновлены', 'success')
return redirect(url_for('manage_users'))
@app.route('/admin/users/<int:user_id>/delete', methods=['POST'])
@admin_required
def admin_delete_user(user_id):
if current_user.id == user_id:
flash('Нельзя удалить самого себя', 'warning')
return redirect(url_for('manage_users'))
user = User.get_or_none(User.id == user_id)
if not user:
flash('Пользователь не найден', 'danger')
return redirect(url_for('manage_users'))
try:
user.delete_instance(recursive=True)
flash('Пользователь удалён', 'success')
except Exception:
flash('Не удалось удалить пользователя', 'danger')
return redirect(url_for('manage_users'))
login_manager = LoginManager() login_manager = LoginManager()
login_manager.init_app(app) login_manager.init_app(app)
login_manager.login_view = 'login' login_manager.login_view = 'login'

8
static/style.css Normal file
View File

@@ -0,0 +1,8 @@
/* Modern tweaks */
:root { --brand-bg: #e9f6ff; }
body { background-color: #f6f8fb; }
.navbar { background: var(--brand-bg) !important; border-bottom: 1px solid rgba(0,0,0,0.05); }
.card { border: 1px solid rgba(0,0,0,0.05); box-shadow: 0 2px 8px rgba(0,0,0,0.04); }
.table thead th { font-weight: 600; color: #495057; }
.btn { transition: all .2s ease; }
.btn:hover { transform: translateY(-1px); box-shadow: 0 4px 12px rgba(0,0,0,0.08); }

View File

@@ -0,0 +1,84 @@
{% extends "layout.html" %}
{% block content %}
<div class="container mt-4">
<div class="d-flex align-items-center mb-3">
<h3 class="me-auto">Пользователи</h3>
<a href="{{ url_for('manage_surveys') }}" class="btn btn-outline-secondary btn-sm">К опросам</a>
</div>
<div class="card mb-4">
<div class="card-body">
<h5 class="card-title">Создать пользователя</h5>
<form method="post" action="{{ url_for('manage_users') }}" class="row gy-2" onsubmit="showLoading(this)">
<div class="col-md-3">
<input type="text" name="username" class="form-control" placeholder="Логин" required>
</div>
<div class="col-md-3">
<input type="email" name="email" class="form-control" placeholder="Email" required>
</div>
<div class="col-md-3">
<input type="text" name="full_name" class="form-control" placeholder="ФИО (необязательно)">
</div>
<div class="col-md-2">
<input type="text" name="password" class="form-control" placeholder="Пароль" required>
</div>
<div class="col-md-1 d-flex align-items-center">
<div class="form-check">
<input class="form-check-input" type="checkbox" name="is_admin" id="is_admin">
<label class="form-check-label" for="is_admin">Админ</label>
</div>
</div>
<div class="col-12">
<button id="submitBtn" type="submit" class="btn btn-primary">Создать</button>
</div>
</form>
</div>
</div>
<div class="card">
<div class="card-body">
<h5 class="card-title">Список пользователей</h5>
<div class="table-responsive">
<table class="table align-middle">
<thead>
<tr>
<th>#</th>
<th>Логин</th>
<th>ФИО</th>
<th>Email</th>
<th>Роль</th>
<th style="width:320px">Действия</th>
</tr>
</thead>
<tbody>
{% for u in users %}
<tr>
<td>{{ u.id }}</td>
<td>{{ u.username }}</td>
<td>{{ u.full_name or '—' }}</td>
<td>{{ u.email }}</td>
<td>
{% if u.is_admin %}<span class="badge bg-danger">Админ</span>{% else %}<span class="badge bg-secondary">Пользователь</span>{% endif %}
</td>
<td>
<form method="post" action="{{ url_for('admin_reset_password', user_id=u.id) }}" class="d-inline-flex align-items-center gap-2" onsubmit="showLoading(this)">
<input type="text" name="new_password" class="form-control form-control-sm" placeholder="Новый пароль" required>
<button class="btn btn-sm btn-outline-primary" type="submit">Сменить пароль</button>
</form>
<form method="post" action="{{ url_for('admin_toggle_admin', user_id=u.id) }}" class="d-inline" onsubmit="return confirm('Изменить права пользователя?');">
<button class="btn btn-sm btn-outline-warning" type="submit">Переключить роль</button>
</form>
<form method="post" action="{{ url_for('admin_delete_user', user_id=u.id) }}" class="d-inline" onsubmit="return confirm('Удалить пользователя {{ u.username }}?');">
<button class="btn btn-sm btn-outline-danger" type="submit" {% if u.id == current_user.id %}disabled{% endif %}>Удалить</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -7,6 +7,7 @@
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet"> <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.5/font/bootstrap-icons.css"> <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.5/font/bootstrap-icons.css">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600&display=swap" rel="stylesheet">
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
<style> <style>
body { body {
background-color: #f8f9fa; background-color: #f8f9fa;
@@ -50,6 +51,11 @@
</button> </button>
<div class="collapse navbar-collapse justify-content-end" id="navbarNav"> <div class="collapse navbar-collapse justify-content-end" id="navbarNav">
<ul class="navbar-nav"> <ul class="navbar-nav">
{% if current_user.is_authenticated and current_user.is_admin %}
<li class="nav-item">
<a class="nav-link{% if request.path.startswith('/admin/users') %} active{% endif %}" href="{{ url_for('manage_users') }}">Пользователи</a>
</li>
{% endif %}
<li class="nav-item"> <li class="nav-item">
<a class="nav-link{% if request.path.startswith('/quiz') %} active{% endif %}" href="{{ url_for('choose_survey') }}">Опросы</a> <a class="nav-link{% if request.path.startswith('/quiz') %} active{% endif %}" href="{{ url_for('choose_survey') }}">Опросы</a>
</li> </li>