lab 5
518
labs/lab-5/app/app.py
Normal file
|
|
@ -0,0 +1,518 @@
|
||||||
|
import re
|
||||||
|
import random
|
||||||
|
from functools import wraps
|
||||||
|
from datetime import datetime
|
||||||
|
from flask import Flask, render_template, request, session, redirect, url_for, flash, abort
|
||||||
|
from flask_login import LoginManager, UserMixin, login_user, logout_user, login_required, current_user
|
||||||
|
from flask_sqlalchemy import SQLAlchemy
|
||||||
|
from flask_bcrypt import Bcrypt
|
||||||
|
from faker import Faker
|
||||||
|
|
||||||
|
fake = Faker('ru_RU')
|
||||||
|
|
||||||
|
app = Flask(__name__)
|
||||||
|
application = app
|
||||||
|
app.secret_key = '1234567890secret'
|
||||||
|
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///users.db'
|
||||||
|
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
|
||||||
|
|
||||||
|
db = SQLAlchemy(app)
|
||||||
|
bcrypt = Bcrypt(app)
|
||||||
|
|
||||||
|
login_manager = LoginManager()
|
||||||
|
login_manager.init_app(app)
|
||||||
|
login_manager.login_view = 'auth'
|
||||||
|
login_manager.login_message = 'Для доступа к данной странице необходимо пройти процедуру аутентификации.'
|
||||||
|
login_manager.login_message_category = 'warning'
|
||||||
|
|
||||||
|
|
||||||
|
# ===== Кастомные типы данных =====
|
||||||
|
|
||||||
|
class Role(db.Model):
|
||||||
|
__tablename__ = 'roles'
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
name = db.Column(db.String(100), nullable=False)
|
||||||
|
description = db.Column(db.Text, nullable=True)
|
||||||
|
users = db.relationship('User', back_populates='role')
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f'<Role {self.name}>'
|
||||||
|
|
||||||
|
|
||||||
|
class User(db.Model, UserMixin):
|
||||||
|
__tablename__ = 'users'
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
login = db.Column(db.String(100), unique=True, nullable=False)
|
||||||
|
password_hash = db.Column(db.String(255), nullable=False)
|
||||||
|
last_name = db.Column(db.String(100), nullable=True)
|
||||||
|
first_name = db.Column(db.String(100), nullable=False)
|
||||||
|
middle_name = db.Column(db.String(100), nullable=True)
|
||||||
|
role_id = db.Column(db.Integer, db.ForeignKey('roles.id'), nullable=True)
|
||||||
|
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||||
|
role = db.relationship('Role', back_populates='users')
|
||||||
|
|
||||||
|
def get_full_name(self):
|
||||||
|
parts = [self.last_name, self.first_name, self.middle_name]
|
||||||
|
return ' '.join(p for p in parts if p)
|
||||||
|
|
||||||
|
def check_password(self, password):
|
||||||
|
return bcrypt.check_password_hash(self.password_hash, password)
|
||||||
|
|
||||||
|
def set_password(self, password):
|
||||||
|
self.password_hash = bcrypt.generate_password_hash(password).decode('utf-8')
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f'<User {self.login}>'
|
||||||
|
|
||||||
|
|
||||||
|
class VisitLog(db.Model):
|
||||||
|
__tablename__ = 'visit_logs'
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
path = db.Column(db.String(100))
|
||||||
|
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True)
|
||||||
|
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||||
|
user = db.relationship('User', backref='visit_logs')
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f'<VisitLog {self.path}>'
|
||||||
|
|
||||||
|
|
||||||
|
@login_manager.user_loader
|
||||||
|
def load_user(user_id):
|
||||||
|
return User.query.filter_by(id=int(user_id)).first()
|
||||||
|
|
||||||
|
|
||||||
|
def init_db():
|
||||||
|
db.create_all()
|
||||||
|
if not Role.query.first():
|
||||||
|
roles = [
|
||||||
|
Role(name='Администратор', description='Полный доступ к системе'),
|
||||||
|
Role(name='Пользователь', description='Базовый доступ к системе'),
|
||||||
|
]
|
||||||
|
db.session.add_all(roles)
|
||||||
|
db.session.commit()
|
||||||
|
if not User.query.first():
|
||||||
|
admin_role = Role.query.filter_by(name='Администратор').first()
|
||||||
|
admin = User(
|
||||||
|
login='admin',
|
||||||
|
first_name='Администратор',
|
||||||
|
last_name='Системный',
|
||||||
|
middle_name=None,
|
||||||
|
role_id=admin_role.id if admin_role else None
|
||||||
|
)
|
||||||
|
admin.set_password('Admin123!')
|
||||||
|
db.session.add(admin)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
# ===== Декоратор проверки прав =====
|
||||||
|
|
||||||
|
def check_rights(action):
|
||||||
|
def decorator(f):
|
||||||
|
@wraps(f)
|
||||||
|
def decorated_function(*args, **kwargs):
|
||||||
|
if not current_user.is_authenticated:
|
||||||
|
flash('У вас недостаточно прав для доступа к данной странице.', 'warning')
|
||||||
|
return redirect(url_for('index'))
|
||||||
|
|
||||||
|
role_name = current_user.role.name if current_user.role else None
|
||||||
|
allowed = False
|
||||||
|
|
||||||
|
if role_name == 'Администратор':
|
||||||
|
allowed = True
|
||||||
|
elif role_name == 'Пользователь':
|
||||||
|
if action in ('edit_users', 'view_profile'):
|
||||||
|
user_id = kwargs.get('user_id')
|
||||||
|
allowed = (user_id == current_user.id)
|
||||||
|
elif action == 'view_logs':
|
||||||
|
allowed = True
|
||||||
|
|
||||||
|
if not allowed:
|
||||||
|
flash('У вас недостаточно прав для доступа к данной странице.', 'warning')
|
||||||
|
return redirect(url_for('index'))
|
||||||
|
|
||||||
|
return f(*args, **kwargs)
|
||||||
|
return decorated_function
|
||||||
|
return decorator
|
||||||
|
|
||||||
|
|
||||||
|
# ===== Логирование посещений =====
|
||||||
|
|
||||||
|
@app.before_request
|
||||||
|
def log_visit():
|
||||||
|
if not request.path.startswith('/static/'):
|
||||||
|
try:
|
||||||
|
log_entry = VisitLog(
|
||||||
|
path=request.path,
|
||||||
|
user_id=current_user.id if current_user.is_authenticated else None
|
||||||
|
)
|
||||||
|
db.session.add(log_entry)
|
||||||
|
db.session.commit()
|
||||||
|
except Exception:
|
||||||
|
db.session.rollback()
|
||||||
|
|
||||||
|
|
||||||
|
# ===== Валидация =====
|
||||||
|
def validate_login(login):
|
||||||
|
errors = []
|
||||||
|
if not login:
|
||||||
|
errors.append('Поле не может быть пустым.')
|
||||||
|
elif len(login) < 5:
|
||||||
|
errors.append('Логин должен содержать не менее 5 символов.')
|
||||||
|
elif not re.match(r'^[a-zA-Z0-9]+$', login):
|
||||||
|
errors.append('Логин должен состоять только из латинских букв и цифр.')
|
||||||
|
return errors
|
||||||
|
|
||||||
|
|
||||||
|
def validate_password(password):
|
||||||
|
errors = []
|
||||||
|
if not password:
|
||||||
|
errors.append('Поле не может быть пустым.')
|
||||||
|
return errors
|
||||||
|
if len(password) < 8:
|
||||||
|
errors.append('Пароль должен содержать не менее 8 символов.')
|
||||||
|
if len(password) > 128:
|
||||||
|
errors.append('Пароль не должен превышать 128 символов.')
|
||||||
|
if not re.search(r'[A-Z]', password):
|
||||||
|
errors.append('Пароль должен содержать хотя бы одну заглавную букву.')
|
||||||
|
if not re.search(r'[a-z]', password):
|
||||||
|
errors.append('Пароль должен содержать хотя бы одну строчную букву.')
|
||||||
|
if not re.search(r'\d', password):
|
||||||
|
errors.append('Пароль должен содержать хотя бы одну цифру.')
|
||||||
|
if ' ' in password:
|
||||||
|
errors.append('Пароль не должен содержать пробелы.')
|
||||||
|
allowed_special = set('~!?@#$%^&*_-+()[]{}><\\/|"\'.,;:')
|
||||||
|
for ch in password:
|
||||||
|
if not (ch.isalpha() or ch.isdigit() or ch in allowed_special):
|
||||||
|
errors.append('Пароль содержит недопустимые символы.')
|
||||||
|
break
|
||||||
|
return errors
|
||||||
|
|
||||||
|
|
||||||
|
# ===== Предыдущие функции (Lab 1-3) =====
|
||||||
|
images_ids = ['Pic1', 'Pic2', 'Pic3', 'Pic4', 'Pic5']
|
||||||
|
|
||||||
|
|
||||||
|
def generate_comments(replies=True):
|
||||||
|
comments = []
|
||||||
|
for i in range(random.randint(1, 3)):
|
||||||
|
comment = {'author': fake.name(), 'text': fake.text()}
|
||||||
|
if replies:
|
||||||
|
comment['replies'] = generate_comments(replies=False)
|
||||||
|
comments.append(comment)
|
||||||
|
return comments
|
||||||
|
|
||||||
|
|
||||||
|
def generate_post(i):
|
||||||
|
return {
|
||||||
|
'title': fake.company(),
|
||||||
|
'text': fake.paragraph(nb_sentences=100),
|
||||||
|
'author': fake.name(),
|
||||||
|
'date': fake.date_time_between(start_date='-2y', end_date='now'),
|
||||||
|
'image_id': f'{images_ids[i]}.jpg',
|
||||||
|
'comments': generate_comments()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
posts_list = sorted([generate_post(i) for i in range(5)], key=lambda p: p['date'], reverse=True)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/posts')
|
||||||
|
def posts():
|
||||||
|
return render_template('posts.html', title='Посты', posts=posts_list)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/posts/<int:index>')
|
||||||
|
def post(index):
|
||||||
|
p = posts_list[index]
|
||||||
|
return render_template('post.html', title=p['title'], post=p)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/about')
|
||||||
|
def about():
|
||||||
|
return render_template('about.html', title='Об авторе')
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/visits')
|
||||||
|
def visits():
|
||||||
|
if 'visits' in session:
|
||||||
|
session['visits'] = session.get('visits') + 1
|
||||||
|
else:
|
||||||
|
session['visits'] = 1
|
||||||
|
return render_template('visits.html', title='Счётчик посещений', visits=session['visits'])
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/auth', methods=['GET', 'POST'])
|
||||||
|
def auth():
|
||||||
|
if current_user.is_authenticated:
|
||||||
|
return redirect(url_for('index'))
|
||||||
|
if request.method == 'POST':
|
||||||
|
login_input = request.form.get('login', '')
|
||||||
|
password_input = request.form.get('password', '')
|
||||||
|
remember = request.form.get('remember') == 'on'
|
||||||
|
user = User.query.filter_by(login=login_input).first()
|
||||||
|
if user and user.check_password(password_input):
|
||||||
|
login_user(user, remember=remember)
|
||||||
|
flash('Вы успешно вошли в систему!', 'success')
|
||||||
|
next_page = request.args.get('next')
|
||||||
|
return redirect(next_page or url_for('index'))
|
||||||
|
flash('Неверный логин или пароль.', 'error')
|
||||||
|
return render_template('login.html', title='Вход')
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/logout')
|
||||||
|
def logout():
|
||||||
|
logout_user()
|
||||||
|
return redirect(url_for('index'))
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/secret')
|
||||||
|
@login_required
|
||||||
|
def secret():
|
||||||
|
return render_template('secret.html', title='Секретная страница')
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/request-data', methods=['GET', 'POST'])
|
||||||
|
def request_data():
|
||||||
|
form_data = None
|
||||||
|
if request.method == 'POST':
|
||||||
|
form_data = {
|
||||||
|
'login': request.form.get('login', ''),
|
||||||
|
'password': request.form.get('password', '')
|
||||||
|
}
|
||||||
|
return render_template(
|
||||||
|
'request_data.html',
|
||||||
|
title='Данные запроса',
|
||||||
|
url_params=request.args,
|
||||||
|
headers=request.headers,
|
||||||
|
cookies=request.cookies,
|
||||||
|
form_data=form_data
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_phone(phone):
|
||||||
|
allowed = set('0123456789 ()-.+')
|
||||||
|
for ch in phone:
|
||||||
|
if ch not in allowed:
|
||||||
|
return None, 'Недопустимый ввод. В номере телефона встречаются недопустимые символы.'
|
||||||
|
digits = re.sub(r'\D', '', phone)
|
||||||
|
stripped = phone.strip()
|
||||||
|
if stripped.startswith('+7') or stripped.startswith('8'):
|
||||||
|
if len(digits) != 11:
|
||||||
|
return None, 'Недопустимый ввод. Неверное количество цифр.'
|
||||||
|
else:
|
||||||
|
if len(digits) != 10:
|
||||||
|
return None, 'Недопустимый ввод. Неверное количество цифр.'
|
||||||
|
if len(digits) == 11:
|
||||||
|
digits = digits[1:]
|
||||||
|
formatted = f'8-{digits[0:3]}-{digits[3:6]}-{digits[6:8]}-{digits[8:10]}'
|
||||||
|
return formatted, None
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/phone', methods=['GET', 'POST'])
|
||||||
|
def phone():
|
||||||
|
error = None
|
||||||
|
formatted_phone = None
|
||||||
|
phone_value = ''
|
||||||
|
if request.method == 'POST':
|
||||||
|
phone_value = request.form.get('phone', '')
|
||||||
|
formatted_phone, error = validate_phone(phone_value)
|
||||||
|
return render_template(
|
||||||
|
'phone.html',
|
||||||
|
title='Проверка номера телефона',
|
||||||
|
error=error,
|
||||||
|
formatted_phone=formatted_phone,
|
||||||
|
phone_value=phone_value
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ===== Обновлённый функционал (Lab 4-5) =====
|
||||||
|
@app.route('/')
|
||||||
|
def index():
|
||||||
|
return render_template('index.html', title='Задание — Лабораторная работа №5')
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/users')
|
||||||
|
def users_list():
|
||||||
|
users = User.query.all()
|
||||||
|
return render_template('users.html', title='Управление пользователями', users=users)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/users/<int:user_id>')
|
||||||
|
@check_rights('view_profile')
|
||||||
|
def user_view(user_id):
|
||||||
|
user = User.query.filter_by(id=user_id).first_or_404()
|
||||||
|
return render_template('user_view.html', title=f'Пользователь: {user.login}', user=user)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/users/create', methods=['GET', 'POST'])
|
||||||
|
@check_rights('create_users')
|
||||||
|
def user_create():
|
||||||
|
roles = Role.query.all()
|
||||||
|
errors = {}
|
||||||
|
form_data = {}
|
||||||
|
|
||||||
|
if request.method == 'POST':
|
||||||
|
login = request.form.get('login', '').strip()
|
||||||
|
password = request.form.get('password', '')
|
||||||
|
last_name = request.form.get('last_name', '').strip()
|
||||||
|
first_name = request.form.get('first_name', '').strip()
|
||||||
|
middle_name = request.form.get('middle_name', '').strip()
|
||||||
|
role_id = request.form.get('role_id') or None
|
||||||
|
|
||||||
|
form_data = {
|
||||||
|
'login': login,
|
||||||
|
'last_name': last_name,
|
||||||
|
'first_name': first_name,
|
||||||
|
'middle_name': middle_name,
|
||||||
|
'role_id': role_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
login_errors = validate_login(login)
|
||||||
|
if login_errors:
|
||||||
|
errors['login'] = login_errors
|
||||||
|
elif User.query.filter_by(login=login).first():
|
||||||
|
errors['login'] = ['Пользователь с таким логином уже существует.']
|
||||||
|
|
||||||
|
password_errors = validate_password(password)
|
||||||
|
if password_errors:
|
||||||
|
errors['password'] = password_errors
|
||||||
|
|
||||||
|
if not last_name:
|
||||||
|
errors['last_name'] = ['Поле не может быть пустым.']
|
||||||
|
|
||||||
|
if not first_name:
|
||||||
|
errors['first_name'] = ['Поле не может быть пустым.']
|
||||||
|
|
||||||
|
if not errors:
|
||||||
|
try:
|
||||||
|
user = User(
|
||||||
|
login=login,
|
||||||
|
first_name=first_name,
|
||||||
|
last_name=last_name or None,
|
||||||
|
middle_name=middle_name or None,
|
||||||
|
role_id=int(role_id) if role_id else None
|
||||||
|
)
|
||||||
|
user.set_password(password)
|
||||||
|
db.session.add(user)
|
||||||
|
db.session.commit()
|
||||||
|
flash('Пользователь успешно создан!', 'success')
|
||||||
|
return redirect(url_for('users_list'))
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
flash(f'Ошибка при сохранении: {e}', 'error')
|
||||||
|
|
||||||
|
return render_template('user_create.html', title='Создать пользователя',
|
||||||
|
roles=roles, errors=errors, form_data=form_data)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/users/<int:user_id>/edit', methods=['GET', 'POST'])
|
||||||
|
@check_rights('edit_users')
|
||||||
|
def user_edit(user_id):
|
||||||
|
user = User.query.filter_by(id=user_id).first_or_404()
|
||||||
|
roles = Role.query.all()
|
||||||
|
errors = {}
|
||||||
|
is_admin = current_user.role and current_user.role.name == 'Администратор'
|
||||||
|
|
||||||
|
if request.method == 'POST':
|
||||||
|
last_name = request.form.get('last_name', '').strip()
|
||||||
|
first_name = request.form.get('first_name', '').strip()
|
||||||
|
middle_name = request.form.get('middle_name', '').strip()
|
||||||
|
role_id = request.form.get('role_id') or None
|
||||||
|
|
||||||
|
form_data = {
|
||||||
|
'last_name': last_name,
|
||||||
|
'first_name': first_name,
|
||||||
|
'middle_name': middle_name,
|
||||||
|
'role_id': role_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
if not last_name:
|
||||||
|
errors['last_name'] = ['Поле не может быть пустым.']
|
||||||
|
|
||||||
|
if not first_name:
|
||||||
|
errors['first_name'] = ['Поле не может быть пустым.']
|
||||||
|
|
||||||
|
if not errors:
|
||||||
|
try:
|
||||||
|
user.last_name = last_name or None
|
||||||
|
user.first_name = first_name
|
||||||
|
user.middle_name = middle_name or None
|
||||||
|
if is_admin:
|
||||||
|
user.role_id = int(role_id) if role_id else None
|
||||||
|
db.session.commit()
|
||||||
|
flash('Пользователь успешно обновлён!', 'success')
|
||||||
|
return redirect(url_for('users_list'))
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
flash(f'Ошибка при сохранении: {e}', 'error')
|
||||||
|
else:
|
||||||
|
form_data = {
|
||||||
|
'last_name': user.last_name or '',
|
||||||
|
'first_name': user.first_name or '',
|
||||||
|
'middle_name': user.middle_name or '',
|
||||||
|
'role_id': user.role_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
return render_template('user_edit.html', title='Редактировать пользователя',
|
||||||
|
user=user, roles=roles, errors=errors, form_data=form_data,
|
||||||
|
disable_role=not is_admin)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/users/<int:user_id>/delete', methods=['POST'])
|
||||||
|
@check_rights('delete_users')
|
||||||
|
def user_delete(user_id):
|
||||||
|
user = User.query.filter_by(id=user_id).first_or_404()
|
||||||
|
try:
|
||||||
|
db.session.delete(user)
|
||||||
|
db.session.commit()
|
||||||
|
flash('Пользователь успешно удалён!', 'success')
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
flash(f'Ошибка при удалении: {e}', 'error')
|
||||||
|
return redirect(url_for('users_list'))
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/change-password', methods=['GET', 'POST'])
|
||||||
|
@login_required
|
||||||
|
def change_password():
|
||||||
|
errors = {}
|
||||||
|
if request.method == 'POST':
|
||||||
|
old_password = request.form.get('old_password', '')
|
||||||
|
new_password = request.form.get('new_password', '')
|
||||||
|
confirm_password = request.form.get('confirm_password', '')
|
||||||
|
|
||||||
|
if not current_user.check_password(old_password):
|
||||||
|
errors['old_password'] = ['Неверный текущий пароль.']
|
||||||
|
|
||||||
|
new_password_errors = validate_password(new_password)
|
||||||
|
if new_password_errors:
|
||||||
|
errors['new_password'] = new_password_errors
|
||||||
|
|
||||||
|
if new_password and not new_password_errors and confirm_password != new_password:
|
||||||
|
errors['confirm_password'] = ['Пароли не совпадают.']
|
||||||
|
|
||||||
|
if not errors:
|
||||||
|
try:
|
||||||
|
current_user.set_password(new_password)
|
||||||
|
db.session.commit()
|
||||||
|
flash('Пароль успешно изменён!', 'success')
|
||||||
|
return redirect(url_for('index'))
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
flash(f'Ошибка при изменении пароля: {e}', 'error')
|
||||||
|
|
||||||
|
return render_template('change_password.html', title='Смена пароля', errors=errors)
|
||||||
|
|
||||||
|
|
||||||
|
# ===== Регистрация Blueprint журнала посещений =====
|
||||||
|
from visit_logs import visit_logs_bp
|
||||||
|
app.register_blueprint(visit_logs_bp)
|
||||||
|
|
||||||
|
# ===== Инициализация БД =====
|
||||||
|
with app.app_context():
|
||||||
|
init_db()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
app.run(debug=True)
|
||||||
BIN
labs/lab-5/app/instance/users.db
Normal file
BIN
labs/lab-5/app/static/images/Pic1.jpg
Normal file
|
After Width: | Height: | Size: 373 KiB |
BIN
labs/lab-5/app/static/images/Pic2.jpg
Normal file
|
After Width: | Height: | Size: 114 KiB |
BIN
labs/lab-5/app/static/images/Pic3.jpg
Normal file
|
After Width: | Height: | Size: 385 KiB |
BIN
labs/lab-5/app/static/images/Pic4.jpg
Normal file
|
After Width: | Height: | Size: 520 KiB |
BIN
labs/lab-5/app/static/images/Pic5.jpg
Normal file
|
After Width: | Height: | Size: 583 KiB |
BIN
labs/lab-5/app/static/images/avatar.jpg
Normal file
|
After Width: | Height: | Size: 105 KiB |
BIN
labs/lab-5/app/static/images/example.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
1427
labs/lab-5/app/static/styles.css
Normal file
19
labs/lab-5/app/templates/about.html
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
{% extends 'base.html' %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h1 class="mt-5 text-center">Об авторе</h1>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-4 mb-3">
|
||||||
|
<img class="avatar" src="{{ url_for('static', filename='images/avatar.jpg') }}" alt="Author">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-8 text-justify d-flex align-items-center">
|
||||||
|
<p>
|
||||||
|
Деев Егор Викторович — backend-разработчик и Python-специалист из Москвы, специализирующийся на создании backend-решений, парсинге данных, автоматизации процессов и интеграции AI-компонентов.
|
||||||
|
<br><br>
|
||||||
|
Обучается в Московском Политехническом университете (МосПолитех), владеет широким технологическим стеком, включающим Python (Django, FastAPI, Flask), работу с базами данных (PostgreSQL, SQLite), DevOps-инструменты (Docker, Nginx), а также frontend-технологии (HTML5, CSS3, JavaScript).
|
||||||
|
<br><br>
|
||||||
|
Контакты для связи: email egor@deev.space, Telegram @Egor_Deev, GitHub @EDeev, личный сайт deev.space с блогом и портфолио проектов.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
147
labs/lab-5/app/templates/base.html
Normal file
|
|
@ -0,0 +1,147 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
|
||||||
|
<link rel="icon" href="https://deev.space/media/favicon.ico" type="image/x-icon">
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-EVSTQN3/azprG1Anm3QDgpJLIm9Nao0Yz1ztcQTwFspd3yD65VohhpuuCOmLASjC" crossorigin="anonymous">
|
||||||
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='styles.css') }}">
|
||||||
|
|
||||||
|
<title>
|
||||||
|
{% if title %}
|
||||||
|
{{ title }}
|
||||||
|
{% else %}
|
||||||
|
Лабораторная работа №5
|
||||||
|
{% endif %}
|
||||||
|
</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<nav class="navbar navbar-expand-lg navbar-dark">
|
||||||
|
<div class="container">
|
||||||
|
<a class="navbar-brand" href="{{ url_for('index') }}">Лабораторная работа № 5</a>
|
||||||
|
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
|
||||||
|
<span class="navbar-toggler-icon"></span>
|
||||||
|
</button>
|
||||||
|
<div class="collapse navbar-collapse" id="navbarSupportedContent">
|
||||||
|
<ul class="navbar-nav ms-auto mb-2 mb-lg-0">
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="{{ url_for('index') }}"><i class="fas fa-home"></i> Задание</a>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li class="nav-item dropdown">
|
||||||
|
<a class="nav-link dropdown-toggle" href="#" id="navDropdownContent" role="button" data-bs-toggle="dropdown" aria-expanded="false">
|
||||||
|
<i class="fas fa-flask"></i> Лаб. 1/2
|
||||||
|
</a>
|
||||||
|
<ul class="dropdown-menu" aria-labelledby="navDropdownContent">
|
||||||
|
<li><a class="dropdown-item" href="{{ url_for('posts') }}"><i class="fas fa-newspaper"></i> Посты</a></li>
|
||||||
|
<li><a class="dropdown-item" href="{{ url_for('about') }}"><i class="fas fa-user"></i> Об авторе</a></li>
|
||||||
|
<li><a class="dropdown-item" href="{{ url_for('request_data') }}"><i class="fas fa-database"></i> Данные запроса</a></li>
|
||||||
|
<li><a class="dropdown-item" href="{{ url_for('phone') }}"><i class="fas fa-phone"></i> Телефон</a></li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
{% if current_user.is_authenticated %}
|
||||||
|
<li class="nav-item dropdown">
|
||||||
|
<a class="nav-link dropdown-toggle" href="#" id="navDropdownLab3" role="button" data-bs-toggle="dropdown" aria-expanded="false">
|
||||||
|
<i class="fas fa-shield-alt"></i> Лаб. 3/4
|
||||||
|
</a>
|
||||||
|
<ul class="dropdown-menu" aria-labelledby="navDropdownLab3">
|
||||||
|
<li><a class="dropdown-item" href="{{ url_for('visits') }}"><i class="fas fa-eye"></i> Счётчик</a></li>
|
||||||
|
<li><a class="dropdown-item" href="{{ url_for('secret') }}"><i class="fas fa-lock"></i> Секретная</a></li>
|
||||||
|
<li><a class="dropdown-item" href="{{ url_for('change_password') }}"><i class="fas fa-key"></i> Сменить пароль</a></li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
{% else %}
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="{{ url_for('visits') }}"><i class="fas fa-eye"></i> Счётчик</a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if current_user.is_authenticated %}
|
||||||
|
<li class="nav-item dropdown">
|
||||||
|
<a class="nav-link dropdown-toggle" href="#" id="navDropdownLab5" role="button" data-bs-toggle="dropdown" aria-expanded="false">
|
||||||
|
<i class="fas fa-chart-bar"></i> Лаб. 5
|
||||||
|
</a>
|
||||||
|
<ul class="dropdown-menu" aria-labelledby="navDropdownLab5">
|
||||||
|
<li><a class="dropdown-item" href="{{ url_for('users_list') }}"><i class="fas fa-users"></i> Пользователи</a></li>
|
||||||
|
<li><a class="dropdown-item" href="{{ url_for('visit_logs.index') }}"><i class="fas fa-list-alt"></i> Журнал посещений</a></li>
|
||||||
|
{% if current_user.role and current_user.role.name == 'Администратор' %}
|
||||||
|
<li><a class="dropdown-item" href="{{ url_for('visit_logs.pages_report') }}"><i class="fas fa-file-alt"></i> Отчёт по страницам</a></li>
|
||||||
|
<li><a class="dropdown-item" href="{{ url_for('visit_logs.users_report') }}"><i class="fas fa-user-clock"></i> Отчёт по пользователям</a></li>
|
||||||
|
{% endif %}
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="{{ url_for('logout') }}"><i class="fas fa-sign-out-alt"></i> Выйти</a>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
{% else %}
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="{{ url_for('users_list') }}"><i class="fas fa-users"></i> Пользователи</a>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="{{ url_for('auth') }}"><i class="fas fa-sign-in-alt"></i> Войти</a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="my-1">
|
||||||
|
<div class="container">
|
||||||
|
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||||
|
{% if messages %}
|
||||||
|
<div class="flash-messages mt-3">
|
||||||
|
{% for category, message in messages %}
|
||||||
|
<div class="flash-message flash-{{ category }}">
|
||||||
|
<div class="flash-icon">
|
||||||
|
{% if category == 'success' %}
|
||||||
|
<i class="fas fa-check-circle"></i>
|
||||||
|
{% elif category == 'error' %}
|
||||||
|
<i class="fas fa-exclamation-circle"></i>
|
||||||
|
{% elif category == 'warning' %}
|
||||||
|
<i class="fas fa-exclamation-triangle"></i>
|
||||||
|
{% else %}
|
||||||
|
<i class="fas fa-info-circle"></i>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<span>{{ message }}</span>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endwith %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h1>Содержимое по умолчанию</h1>
|
||||||
|
{% endblock %}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
<div class="container">
|
||||||
|
<div class="footer-content">
|
||||||
|
<div>
|
||||||
|
<div class="footer-name">Деев Егор Викторович, ст. гр. 241-327</div>
|
||||||
|
<div class="footer-group">Московский Политехнический университет</div>
|
||||||
|
</div>
|
||||||
|
<div class="footer-links">
|
||||||
|
<a href="mailto:egor@deev.space">egor@deev.space</a>
|
||||||
|
<a href="https://deev.space" target="_blank">My Site</a>
|
||||||
|
<a href="https://t.me/Egor_Deev" target="_blank">Telegram</a>
|
||||||
|
<a href="https://github.com/EDeev" target="_blank">GitHub</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/js/bootstrap.bundle.min.js" integrity="sha384-MrcW6ZMFYlzcLA8Nl+NtUVF0sA7MsXsP1UyJoMp4YLEuNSfAP+JcXn/tWtIaxVXM" crossorigin="anonymous"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
90
labs/lab-5/app/templates/change_password.html
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
{% extends 'base.html' %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="row my-4">
|
||||||
|
<div class="col-lg-7 col-xl-5 mx-auto">
|
||||||
|
<div class="page-back-header">
|
||||||
|
<a href="{{ url_for('index') }}" class="btn-back">
|
||||||
|
<i class="fas fa-arrow-left"></i>
|
||||||
|
</a>
|
||||||
|
<h1>Смена пароля</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="data-card">
|
||||||
|
<div class="auth-header" style="margin-bottom: 1.75rem;">
|
||||||
|
<div class="auth-icon">
|
||||||
|
<i class="fas fa-key"></i>
|
||||||
|
</div>
|
||||||
|
<p style="color: var(--text-muted); font-size: 0.9rem; margin-bottom: 0;">
|
||||||
|
Введите текущий пароль и придумайте новый
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="POST" action="{{ url_for('change_password') }}">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="old_password" class="form-label">
|
||||||
|
Текущий пароль <span style="color: var(--error);">*</span>
|
||||||
|
</label>
|
||||||
|
<div class="input-icon-wrapper">
|
||||||
|
<i class="fas fa-lock input-icon"></i>
|
||||||
|
<input type="password"
|
||||||
|
class="form-control form-control-icon {% if errors.get('old_password') %}is-invalid{% endif %}"
|
||||||
|
id="old_password" name="old_password"
|
||||||
|
placeholder="Введите текущий пароль">
|
||||||
|
</div>
|
||||||
|
{% if errors.get('old_password') %}
|
||||||
|
<div class="invalid-feedback d-block">
|
||||||
|
{% for e in errors['old_password'] %}<div>{{ e }}</div>{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="new_password" class="form-label">
|
||||||
|
Новый пароль <span style="color: var(--error);">*</span>
|
||||||
|
</label>
|
||||||
|
<div class="input-icon-wrapper">
|
||||||
|
<i class="fas fa-lock input-icon"></i>
|
||||||
|
<input type="password"
|
||||||
|
class="form-control form-control-icon {% if errors.get('new_password') %}is-invalid{% endif %}"
|
||||||
|
id="new_password" name="new_password"
|
||||||
|
placeholder="Не менее 8 символов">
|
||||||
|
</div>
|
||||||
|
{% if errors.get('new_password') %}
|
||||||
|
<div class="invalid-feedback d-block">
|
||||||
|
{% for e in errors['new_password'] %}<div>{{ e }}</div>{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<label for="confirm_password" class="form-label">
|
||||||
|
Повторите новый пароль <span style="color: var(--error);">*</span>
|
||||||
|
</label>
|
||||||
|
<div class="input-icon-wrapper">
|
||||||
|
<i class="fas fa-lock input-icon"></i>
|
||||||
|
<input type="password"
|
||||||
|
class="form-control form-control-icon {% if errors.get('confirm_password') %}is-invalid{% endif %}"
|
||||||
|
id="confirm_password" name="confirm_password"
|
||||||
|
placeholder="Повторите новый пароль">
|
||||||
|
</div>
|
||||||
|
{% if errors.get('confirm_password') %}
|
||||||
|
<div class="invalid-feedback d-block">
|
||||||
|
{% for e in errors['confirm_password'] %}<div>{{ e }}</div>{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex" style="gap: 0.75rem;">
|
||||||
|
<button type="submit" class="btn btn-primary">
|
||||||
|
<i class="fas fa-save"></i> Сменить пароль
|
||||||
|
</button>
|
||||||
|
<a href="{{ url_for('index') }}" class="btn btn-outline">
|
||||||
|
<i class="fas fa-times"></i> Отмена
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
65
labs/lab-5/app/templates/index.html
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
{% extends 'base.html' %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="row my-4">
|
||||||
|
<div class="col-lg-12">
|
||||||
|
<h1 class="mb-4">Задание к лабораторной работе №5</h1>
|
||||||
|
|
||||||
|
<p class="task-description">Доработайте веб-приложение, разработанное в рамках ЛР4 "Реализация CRUD-интерфейса". Добавьте к нему функциональность авторизации пользователей и формирования статистических отчётов.</p>
|
||||||
|
|
||||||
|
<p class="fw-bold task-description mt-4">Авторизация пользователей</p>
|
||||||
|
<p class="task-description">Добавьте в приложение авторизацию пользователей, то есть проверку прав пользователей на совершение определённых действий и ограничение доступа к функциональности, для доступа к которой у пользователя нет прав. Права пользователей должны быть привязаны к их ролям.</p>
|
||||||
|
|
||||||
|
<p class="task-description mt-3">У пользователей с ролью "Администратор" есть право на:</p>
|
||||||
|
<ul class="task-list mt-2">
|
||||||
|
<li>создание пользователей,</li>
|
||||||
|
<li>редактирование пользователей,</li>
|
||||||
|
<li>просмотр профиля пользователя,</li>
|
||||||
|
<li>удаление пользователей,</li>
|
||||||
|
<li>просмотр журнала посещений (всех страниц).</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<p class="task-description mt-3">У пользователей с ролью "Пользователь" есть право на:</p>
|
||||||
|
<ul class="task-list mt-2">
|
||||||
|
<li>редактирование своих данных (за исключением роли — это поле должно быть отключено на форме для редактирования),</li>
|
||||||
|
<li>просмотр своего профиля,</li>
|
||||||
|
<li>просмотр журнала посещений (только страницу "Журнал посещений", где не должны отображаться записи, соответствующие другим пользователям).</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<p class="task-description mt-3">Реализация проверки прав должна быть реализована при помощи декоратора <code>check_rights</code>, который можно применить к view function. В случае, если у пользователя оказалось недостаточно прав для выполнения запрашиваемого действия, его нужно перенаправить на главную страницу с сообщением "У вас недостаточно прав для доступа к данной странице.".</p>
|
||||||
|
|
||||||
|
<p class="task-description mt-3">Пользователю не должны отображаться кнопки, соответствующие действиям, для выполнения которых у него недостаточно прав.</p>
|
||||||
|
|
||||||
|
<p class="fw-bold task-description mt-4">Формирование статистических отчётов</p>
|
||||||
|
<p class="task-description">Добавьте в разрабатываемое приложение "Журнал посещений", в котором разместите информацию посещении пользователями различных страниц сайта. Для этого создайте таблицу <code>visit_logs</code> со следующими полями:</p>
|
||||||
|
<ul class="task-list mt-2">
|
||||||
|
<li><code>id INT</code> — идентификатор записи,</li>
|
||||||
|
<li><code>path VARCHAR(100)</code> — путь до страницы,</li>
|
||||||
|
<li><code>user_id INT</code> — идентификатор пользователя (если пользователь не вошёл в систему — можно оставить пустым),</li>
|
||||||
|
<li><code>created_at</code> — дата посещения (проставляется автоматически).</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<p class="task-description mt-3">Для автоматического заполнения данной таблицы рекомендуется использовать декоратор <code>before_request</code>.</p>
|
||||||
|
|
||||||
|
<p class="task-description mt-3">Функциональность формирования отчётов необходимо вынести в отдельный модуль и подключать к приложению при помощи Blueprint'а.</p>
|
||||||
|
|
||||||
|
<p class="task-description mt-3">На главной странице журнала посещений нужно отобразить записи таблицы <code>visit_logs</code>, отсортированные по убыванию даты. На этой странице должна быть табличка с колонками:</p>
|
||||||
|
<ul class="task-list mt-2">
|
||||||
|
<li>№ — порядковый номер записи,</li>
|
||||||
|
<li>Пользователь — ФИО пользователя (если нет пользователя — строка "Неаутентифицированный пользователь"),</li>
|
||||||
|
<li>Страница — путь до страницы,</li>
|
||||||
|
<li>Дата — дата посещения в формате 01.01.2001 01:01:01.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<p class="task-description mt-3">Должна быть предусмотрена пагинация записей.</p>
|
||||||
|
|
||||||
|
<p class="task-description mt-3">На главной странице журнала посещений должны быть расположены ссылки на страницы с отчётами по посещениям по страницам и по пользователям.</p>
|
||||||
|
|
||||||
|
<p class="task-description mt-3">На странице с отчётом по страницам должна быть таблица, отображающая статистику посещения страниц сайта. В ней должны быть колонки "№", "Страница" и "Количество посещений". Данные должны быть отсортированы в убывающем порядке количества посещений.</p>
|
||||||
|
|
||||||
|
<p class="task-description mt-3">Под таблицей должна располагаться кнопка "Экспорт в CVS", по нажатию на которую должен скачиваться файл с содержимым отчёта в формате CSV.</p>
|
||||||
|
|
||||||
|
<p class="task-description mt-3">Аналогичным образом нужно реализовать формирования отчёта по пользователям. Только в данном случае в таблице будут колонки "№", "Пользователь" (ФИО или "Неаутентифицированный пользователь"), "Количество посещений".</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
44
labs/lab-5/app/templates/login.html
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
{% extends 'base.html' %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="row justify-content-center my-5">
|
||||||
|
<div class="col-lg-5 col-md-7">
|
||||||
|
<div class="auth-card">
|
||||||
|
<div class="auth-header">
|
||||||
|
<div class="auth-icon">
|
||||||
|
<i class="fas fa-shield-alt"></i>
|
||||||
|
</div>
|
||||||
|
<h1>Вход в систему</h1>
|
||||||
|
<p class="auth-subtitle">Введите свои учётные данные</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="POST" action="{{ url_for('auth', next=request.args.get('next', '')) }}">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="login" class="form-label">Логин</label>
|
||||||
|
<div class="input-icon-wrapper">
|
||||||
|
<i class="fas fa-user input-icon"></i>
|
||||||
|
<input type="text" class="form-control form-control-icon" id="login" name="login" placeholder="Введите логин" required>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="password" class="form-label">Пароль</label>
|
||||||
|
<div class="input-icon-wrapper">
|
||||||
|
<i class="fas fa-lock input-icon"></i>
|
||||||
|
<input type="password" class="form-control form-control-icon" id="password" name="password" placeholder="Введите пароль" required>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="checkbox-wrapper">
|
||||||
|
<input type="checkbox" name="remember" class="custom-checkbox">
|
||||||
|
<span class="checkmark"></span>
|
||||||
|
<span class="checkbox-label">Запомнить меня</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary w-100">
|
||||||
|
<i class="fas fa-sign-in-alt"></i> Войти
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
94
labs/lab-5/app/templates/macros.html
Normal file
|
|
@ -0,0 +1,94 @@
|
||||||
|
{% macro user_form(roles, errors, form_data, include_credentials=False, disable_role=False) %}
|
||||||
|
{% if include_credentials %}
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="login" class="form-label">Логин <span style="color: var(--error);">*</span></label>
|
||||||
|
<div class="input-icon-wrapper">
|
||||||
|
<i class="fas fa-user input-icon"></i>
|
||||||
|
<input type="text"
|
||||||
|
class="form-control form-control-icon {% if errors.get('login') %}is-invalid{% endif %}"
|
||||||
|
id="login" name="login"
|
||||||
|
value="{{ form_data.get('login', '') }}"
|
||||||
|
placeholder="Не менее 5 символов, только латинские буквы и цифры">
|
||||||
|
</div>
|
||||||
|
{% if errors.get('login') %}
|
||||||
|
<div class="invalid-feedback d-block">
|
||||||
|
{% for e in errors['login'] %}<div>{{ e }}</div>{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="password" class="form-label">Пароль <span style="color: var(--error);">*</span></label>
|
||||||
|
<div class="input-icon-wrapper">
|
||||||
|
<i class="fas fa-lock input-icon"></i>
|
||||||
|
<input type="password"
|
||||||
|
class="form-control form-control-icon {% if errors.get('password') %}is-invalid{% endif %}"
|
||||||
|
id="password" name="password"
|
||||||
|
placeholder="Не менее 8 символов">
|
||||||
|
</div>
|
||||||
|
{% if errors.get('password') %}
|
||||||
|
<div class="invalid-feedback d-block">
|
||||||
|
{% for e in errors['password'] %}<div>{{ e }}</div>{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="last_name" class="form-label">Фамилия <span style="color: var(--error);">*</span></label>
|
||||||
|
<input type="text"
|
||||||
|
class="form-control {% if errors.get('last_name') %}is-invalid{% endif %}"
|
||||||
|
id="last_name" name="last_name"
|
||||||
|
value="{{ form_data.get('last_name', '') }}"
|
||||||
|
placeholder="Введите фамилию">
|
||||||
|
{% if errors.get('last_name') %}
|
||||||
|
<div class="invalid-feedback d-block">
|
||||||
|
{% for e in errors['last_name'] %}<div>{{ e }}</div>{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="first_name" class="form-label">Имя <span style="color: var(--error);">*</span></label>
|
||||||
|
<input type="text"
|
||||||
|
class="form-control {% if errors.get('first_name') %}is-invalid{% endif %}"
|
||||||
|
id="first_name" name="first_name"
|
||||||
|
value="{{ form_data.get('first_name', '') }}"
|
||||||
|
placeholder="Введите имя">
|
||||||
|
{% if errors.get('first_name') %}
|
||||||
|
<div class="invalid-feedback d-block">
|
||||||
|
{% for e in errors['first_name'] %}<div>{{ e }}</div>{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="middle_name" class="form-label">Отчество</label>
|
||||||
|
<input type="text"
|
||||||
|
class="form-control"
|
||||||
|
id="middle_name" name="middle_name"
|
||||||
|
value="{{ form_data.get('middle_name', '') }}"
|
||||||
|
placeholder="Введите отчество (необязательно)">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<label for="role_id" class="form-label">Роль
|
||||||
|
{% if disable_role %}
|
||||||
|
<span style="color: var(--text-muted); font-size: 0.8rem;">(недоступно для изменения)</span>
|
||||||
|
{% endif %}
|
||||||
|
</label>
|
||||||
|
<select class="form-control form-select-custom" id="role_id" name="role_id"
|
||||||
|
{% if disable_role %}disabled{% endif %}>
|
||||||
|
<option value="">— Без роли —</option>
|
||||||
|
{% for role in roles %}
|
||||||
|
<option value="{{ role.id }}"
|
||||||
|
{% if form_data.get('role_id') is not none and form_data.get('role_id')|string == role.id|string %}selected{% endif %}>
|
||||||
|
{{ role.name }}
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
{% if disable_role %}
|
||||||
|
<input type="hidden" name="role_id" value="{{ form_data.get('role_id', '') }}">
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endmacro %}
|
||||||
46
labs/lab-5/app/templates/phone.html
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
{% extends 'base.html' %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="row justify-content-center my-5">
|
||||||
|
<div class="col-lg-6">
|
||||||
|
<h1 class="mb-4">Проверка номера телефона</h1>
|
||||||
|
|
||||||
|
<div class="data-card">
|
||||||
|
<form method="POST" action="{{ url_for('phone') }}">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="phone" class="form-label">Номер телефона</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="form-control {% if error %}is-invalid{% endif %}"
|
||||||
|
id="phone"
|
||||||
|
name="phone"
|
||||||
|
placeholder="Например: +7 (123) 456-75-90"
|
||||||
|
value="{{ phone_value }}">
|
||||||
|
{% if error %}
|
||||||
|
<div class="invalid-feedback">
|
||||||
|
{{ error }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary">Проверить</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{% if formatted_phone %}
|
||||||
|
<div class="result-card mt-4">
|
||||||
|
<h4>Результат</h4>
|
||||||
|
<p class="formatted-phone">{{ formatted_phone }}</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="data-card mt-4">
|
||||||
|
<h4>Допустимые форматы ввода</h4>
|
||||||
|
<div class="code-block">
|
||||||
|
<code>+7 (123) 456-75-90</code><br>
|
||||||
|
<code>8(123)4567590</code><br>
|
||||||
|
<code>123.456.75.90</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
56
labs/lab-5/app/templates/post.html
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
{% extends 'base.html' %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="row justify-content-center my-5">
|
||||||
|
<div class="col-lg-10">
|
||||||
|
<div class="post-header">
|
||||||
|
<h1 class="post-title">{{ post.title }}</h1>
|
||||||
|
<div class="post-meta">
|
||||||
|
<span>{{ post.author }}</span>
|
||||||
|
<span>{{ post.date.strftime('%d.%m.%Y') }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<img class="post-image" src="{{ url_for('static', filename='images/' + post.image_id) }}" alt="{{ post.title }}">
|
||||||
|
|
||||||
|
<div class="post-content">
|
||||||
|
{{ post.text }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="comment-form-section">
|
||||||
|
<h3>Оставьте комментарий</h3>
|
||||||
|
<form method="POST">
|
||||||
|
<textarea name="comment" placeholder="Напишите ваш комментарий..." rows="4"></textarea>
|
||||||
|
<button type="submit" class="btn btn-primary mt-3">Отправить</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="comments-section">
|
||||||
|
<h3>Комментарии ({{ post.comments | length }})</h3>
|
||||||
|
{% for comment in post.comments %}
|
||||||
|
<div class="comment">
|
||||||
|
<div class="comment-avatar">{{ comment.author[0] }}</div>
|
||||||
|
<div class="comment-body">
|
||||||
|
<div class="comment-author">{{ comment.author }}</div>
|
||||||
|
<div class="comment-text">{{ comment.text }}</div>
|
||||||
|
|
||||||
|
{% if comment.replies %}
|
||||||
|
<div class="replies">
|
||||||
|
{% for reply in comment.replies %}
|
||||||
|
<div class="reply">
|
||||||
|
<div class="reply-avatar">{{ reply.author[0] }}</div>
|
||||||
|
<div class="comment-body">
|
||||||
|
<div class="comment-author">{{ reply.author }}</div>
|
||||||
|
<div class="comment-text">{{ reply.text }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
25
labs/lab-5/app/templates/posts.html
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
{% extends 'base.html' %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h1 class="my-5">Последние посты</h1>
|
||||||
|
<div class="row mb-3">
|
||||||
|
{% for post in posts %}
|
||||||
|
<div class="col-md-6 d-flex">
|
||||||
|
<div class="card mb-4">
|
||||||
|
<img class="card-img-top" src="{{ url_for('static', filename='images/' + post.image_id) }}" alt="Card image cap">
|
||||||
|
<div class="card-body">
|
||||||
|
<h2 class="card-title">{{ post.title }}</h2>
|
||||||
|
<p class="card-text">
|
||||||
|
{{ post.text | truncate(100) }}
|
||||||
|
</p>
|
||||||
|
<a href="{{ url_for('post', index=loop.index0) }}" class="btn btn-primary">Читать дальше →</a>
|
||||||
|
</div>
|
||||||
|
<div class="card-footer text-muted">
|
||||||
|
Опубликовано {{ post.date.strftime('%d.%m.%Y') }}.
|
||||||
|
Автор: {{ post.author }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
116
labs/lab-5/app/templates/request_data.html
Normal file
|
|
@ -0,0 +1,116 @@
|
||||||
|
{% extends 'base.html' %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="row justify-content-center my-5">
|
||||||
|
<div class="col-lg-10">
|
||||||
|
<h1 class="mb-4">Данные запроса</h1>
|
||||||
|
|
||||||
|
<div class="data-card mb-4">
|
||||||
|
<h3>Форма авторизации</h3>
|
||||||
|
<form method="POST" action="{{ url_for('request_data') }}" class="login-form">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="login" class="form-label">Логин</label>
|
||||||
|
<input type="text" class="form-control" id="login" name="login" placeholder="Введите логин">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="password" class="form-label">Пароль</label>
|
||||||
|
<input type="password" class="form-control" id="password" name="password" placeholder="Введите пароль">
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary">Войти</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{% if form_data %}
|
||||||
|
<div class="mt-4">
|
||||||
|
<h4>Отправленные данные формы</h4>
|
||||||
|
<table class="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Параметр</th>
|
||||||
|
<th>Значение</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>login</td>
|
||||||
|
<td>{{ form_data.login }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>password</td>
|
||||||
|
<td>{{ form_data.password }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="data-card mb-4">
|
||||||
|
<h3>Параметры URL (request.args)</h3>
|
||||||
|
{% if url_params %}
|
||||||
|
<table class="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Параметр</th>
|
||||||
|
<th>Значение</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for key, value in url_params.items() %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ key }}</td>
|
||||||
|
<td>{{ value }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-muted-custom">Нет параметров URL. Попробуйте добавить параметры в адресную строку, например: <code>?fname=Egor&lname=Deev&age=21</code></p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="data-card mb-4">
|
||||||
|
<h3>Заголовки запроса (request.headers)</h3>
|
||||||
|
<table class="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Заголовок</th>
|
||||||
|
<th>Значение</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for key, value in headers %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ key }}</td>
|
||||||
|
<td class="header-value">{{ value }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="data-card mb-4">
|
||||||
|
<h3>Cookie (request.cookies)</h3>
|
||||||
|
{% if cookies %}
|
||||||
|
<table class="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Имя</th>
|
||||||
|
<th>Значение</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for key, value in cookies.items() %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ key }}</td>
|
||||||
|
<td>{{ value }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-muted-custom">Нет cookie в текущем запросе.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
34
labs/lab-5/app/templates/secret.html
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
{% extends 'base.html' %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="row justify-content-center my-5">
|
||||||
|
<div class="col-lg-8 col-md-10">
|
||||||
|
<div class="secret-card">
|
||||||
|
<div class="secret-icon">
|
||||||
|
<i class="fas fa-user-secret"></i>
|
||||||
|
</div>
|
||||||
|
<h1>Секретная страница</h1>
|
||||||
|
<p class="secret-welcome">Добро пожаловать, <span class="secret-username">{{ current_user.login }}</span>!</p>
|
||||||
|
<p class="secret-description">
|
||||||
|
Эта страница доступна только аутентифицированным пользователям. Если вы видите эту страницу, значит вы успешно прошли процедуру аутентификации с использованием библиотеки Flask-Login.
|
||||||
|
</p>
|
||||||
|
<div class="secret-info">
|
||||||
|
<div class="secret-info-item">
|
||||||
|
<i class="fas fa-id-badge"></i>
|
||||||
|
<div>
|
||||||
|
<span class="info-label">ID пользователя</span>
|
||||||
|
<span class="info-value">{{ current_user.id }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="secret-info-item">
|
||||||
|
<i class="fas fa-user"></i>
|
||||||
|
<div>
|
||||||
|
<span class="info-label">Логин</span>
|
||||||
|
<span class="info-value">{{ current_user.login }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
30
labs/lab-5/app/templates/user_create.html
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
{% extends 'base.html' %}
|
||||||
|
{% from 'macros.html' import user_form %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="row my-4">
|
||||||
|
<div class="col-lg-8 col-xl-9 mx-auto">
|
||||||
|
<div class="page-back-header">
|
||||||
|
<a href="{{ url_for('users_list') }}" class="btn-back">
|
||||||
|
<i class="fas fa-arrow-left"></i>
|
||||||
|
</a>
|
||||||
|
<h1>Создать пользователя</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="data-card">
|
||||||
|
<form method="POST" action="{{ url_for('user_create') }}">
|
||||||
|
{{ user_form(roles, errors, form_data, include_credentials=True) }}
|
||||||
|
|
||||||
|
<div class="d-flex" style="gap: 0.75rem;">
|
||||||
|
<button type="submit" class="btn btn-primary">
|
||||||
|
<i class="fas fa-save"></i> Сохранить
|
||||||
|
</button>
|
||||||
|
<a href="{{ url_for('users_list') }}" class="btn btn-outline">
|
||||||
|
<i class="fas fa-times"></i> Отмена
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
40
labs/lab-5/app/templates/user_edit.html
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
{% extends 'base.html' %}
|
||||||
|
{% from 'macros.html' import user_form %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="row my-4">
|
||||||
|
<div class="col-lg-8 col-xl-9 mx-auto">
|
||||||
|
<div class="page-back-header">
|
||||||
|
<a href="{{ url_for('users_list') }}" class="btn-back">
|
||||||
|
<i class="fas fa-arrow-left"></i>
|
||||||
|
</a>
|
||||||
|
<h1>Редактировать пользователя</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="data-card">
|
||||||
|
<div style="display: flex; align-items: center; gap: 0.75rem; margin-bottom: 1.5rem; padding-bottom: 1.25rem; border-bottom: 1px solid var(--border);">
|
||||||
|
<div class="user-avatar-sm" style="width: 40px; height: 40px; font-size: 1rem;">
|
||||||
|
{{ (user.first_name[0] if user.first_name else '?') | upper }}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style="color: var(--text-muted); font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.5px;">Логин</div>
|
||||||
|
<div style="color: var(--primary-light); font-family: 'Fira Code', monospace; font-weight: 600;">{{ user.login }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="POST" action="{{ url_for('user_edit', user_id=user.id) }}">
|
||||||
|
{{ user_form(roles, errors, form_data, include_credentials=False, disable_role=disable_role) }}
|
||||||
|
|
||||||
|
<div class="d-flex" style="gap: 0.75rem;">
|
||||||
|
<button type="submit" class="btn btn-primary">
|
||||||
|
<i class="fas fa-save"></i> Сохранить
|
||||||
|
</button>
|
||||||
|
<a href="{{ url_for('users_list') }}" class="btn btn-outline">
|
||||||
|
<i class="fas fa-times"></i> Отмена
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
83
labs/lab-5/app/templates/user_view.html
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
{% extends 'base.html' %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="row my-4">
|
||||||
|
<div class="col-lg-8 col-xl-9 mx-auto">
|
||||||
|
<div class="page-back-header">
|
||||||
|
<a href="{{ url_for('users_list') }}" class="btn-back">
|
||||||
|
<i class="fas fa-arrow-left"></i>
|
||||||
|
</a>
|
||||||
|
<h1>Профиль пользователя</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="data-card">
|
||||||
|
<div class="user-profile-header">
|
||||||
|
<div class="user-avatar-lg">
|
||||||
|
{{ (user.first_name[0] if user.first_name else '?') | upper }}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 style="margin-bottom: 0.35rem;">{{ user.get_full_name() or user.login }}</h2>
|
||||||
|
<span class="user-login-badge">@{{ user.login }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table class="data-table w-100" style="margin-top: 1.5rem;">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td class="field-label">Идентификатор</td>
|
||||||
|
<td>{{ user.id }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="field-label">Логин</td>
|
||||||
|
<td style="font-family: 'Fira Code', monospace; color: var(--primary-light);">{{ user.login }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="field-label">Фамилия</td>
|
||||||
|
<td>{{ user.last_name or '—' }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="field-label">Имя</td>
|
||||||
|
<td>{{ user.first_name }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="field-label">Отчество</td>
|
||||||
|
<td>{{ user.middle_name or '—' }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="field-label">Роль</td>
|
||||||
|
<td>
|
||||||
|
{% if user.role %}
|
||||||
|
<span class="role-badge">{{ user.role.name }}</span>
|
||||||
|
{% if user.role.description %}
|
||||||
|
<span style="color: var(--text-muted); font-size: 0.85rem; margin-left: 0.5rem;">
|
||||||
|
— {{ user.role.description }}
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
<span style="color: var(--text-muted);">—</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="field-label">Дата создания</td>
|
||||||
|
<td>{{ user.created_at.strftime('%d.%m.%Y %H:%M') if user.created_at else '—' }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
{% set is_admin = current_user.is_authenticated and current_user.role and current_user.role.name == 'Администратор' %}
|
||||||
|
{% set is_user_role = current_user.is_authenticated and current_user.role and current_user.role.name == 'Пользователь' %}
|
||||||
|
<div class="d-flex" style="gap: 0.75rem; margin-top: 1.5rem;">
|
||||||
|
{% if is_admin or (is_user_role and user.id == current_user.id) %}
|
||||||
|
<a href="{{ url_for('user_edit', user_id=user.id) }}" class="btn btn-primary">
|
||||||
|
<i class="fas fa-pen"></i> Редактировать
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
<a href="{{ url_for('users_list') }}" class="btn btn-outline">
|
||||||
|
<i class="fas fa-list"></i> К списку
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
145
labs/lab-5/app/templates/users.html
Normal file
|
|
@ -0,0 +1,145 @@
|
||||||
|
{% extends 'base.html' %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
{% set is_admin = current_user.is_authenticated and current_user.role and current_user.role.name == 'Администратор' %}
|
||||||
|
{% set is_user_role = current_user.is_authenticated and current_user.role and current_user.role.name == 'Пользователь' %}
|
||||||
|
|
||||||
|
<div class="row my-4">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="users-header">
|
||||||
|
<div>
|
||||||
|
<h1 class="mb-1">Управление пользователями</h1>
|
||||||
|
<p style="color: var(--text-muted); margin-bottom: 0;">CRUD-приложение · Лабораторная работа №5</p>
|
||||||
|
</div>
|
||||||
|
{% if is_admin %}
|
||||||
|
<a href="{{ url_for('user_create') }}" class="btn btn-primary">
|
||||||
|
<i class="fas fa-user-plus"></i> Создать пользователя
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="data-card">
|
||||||
|
{% if users %}
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="data-table w-100">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width: 56px;">#</th>
|
||||||
|
<th>ФИО</th>
|
||||||
|
<th>Роль</th>
|
||||||
|
<th style="width: 220px;">Действия</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for user in users %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ loop.index }}</td>
|
||||||
|
<td>
|
||||||
|
<div class="d-flex align-items-center" style="gap: 0.75rem;">
|
||||||
|
<div class="user-avatar-sm">
|
||||||
|
{{ (user.first_name[0] if user.first_name else '?') | upper }}
|
||||||
|
</div>
|
||||||
|
<span style="color: var(--text-primary); font-weight: 500;">
|
||||||
|
{{ user.get_full_name() or user.login }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if user.role %}
|
||||||
|
<span class="role-badge">{{ user.role.name }}</span>
|
||||||
|
{% else %}
|
||||||
|
<span style="color: var(--text-muted);">—</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="action-buttons">
|
||||||
|
{% if is_admin or (is_user_role and user.id == current_user.id) %}
|
||||||
|
<a href="{{ url_for('user_view', user_id=user.id) }}"
|
||||||
|
class="btn-action btn-view">
|
||||||
|
<i class="fas fa-eye"></i> Просмотр
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
{% if is_admin or (is_user_role and user.id == current_user.id) %}
|
||||||
|
<a href="{{ url_for('user_edit', user_id=user.id) }}"
|
||||||
|
class="btn-action btn-edit"
|
||||||
|
title="Редактировать">
|
||||||
|
<i class="fas fa-pen"></i>
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
{% if is_admin %}
|
||||||
|
<button type="button"
|
||||||
|
class="btn-action btn-delete"
|
||||||
|
data-user-id="{{ user.id }}"
|
||||||
|
data-user-name="{{ user.get_full_name() or user.login }}"
|
||||||
|
title="Удалить">
|
||||||
|
<i class="fas fa-trash"></i>
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="empty-state">
|
||||||
|
<div class="empty-icon">
|
||||||
|
<i class="fas fa-users"></i>
|
||||||
|
</div>
|
||||||
|
<p>Пользователи не найдены</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if is_admin %}
|
||||||
|
<div class="modal fade" id="deleteModal" tabindex="-1" aria-labelledby="deleteModalLabel" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-dialog-centered">
|
||||||
|
<div class="modal-content custom-modal">
|
||||||
|
<div class="modal-header custom-modal-header">
|
||||||
|
<div class="modal-title-wrapper">
|
||||||
|
<div class="modal-icon">
|
||||||
|
<i class="fas fa-exclamation-triangle"></i>
|
||||||
|
</div>
|
||||||
|
<h5 class="modal-title" id="deleteModalLabel">Подтверждение удаления</h5>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body custom-modal-body">
|
||||||
|
<p id="deleteMessage" style="color: var(--text-secondary); margin: 0;"></p>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer custom-modal-footer">
|
||||||
|
<button type="button" class="btn btn-outline" data-bs-dismiss="modal">
|
||||||
|
<i class="fas fa-times"></i> Нет
|
||||||
|
</button>
|
||||||
|
<form id="deleteForm" method="POST" style="display: inline;">
|
||||||
|
<button type="submit" class="btn-danger-custom">
|
||||||
|
<i class="fas fa-trash"></i> Да, удалить
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
var deleteButtons = document.querySelectorAll('.btn-delete');
|
||||||
|
var deleteModal = new bootstrap.Modal(document.getElementById('deleteModal'));
|
||||||
|
|
||||||
|
deleteButtons.forEach(function (btn) {
|
||||||
|
btn.addEventListener('click', function () {
|
||||||
|
var userId = this.dataset.userId;
|
||||||
|
var userName = this.dataset.userName;
|
||||||
|
document.getElementById('deleteMessage').textContent =
|
||||||
|
'Вы уверены, что хотите удалить пользователя ' + userName + '?';
|
||||||
|
document.getElementById('deleteForm').action = '/users/' + userId + '/delete';
|
||||||
|
deleteModal.show();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
103
labs/lab-5/app/templates/visit_logs/index.html
Normal file
|
|
@ -0,0 +1,103 @@
|
||||||
|
{% extends 'base.html' %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="row my-4">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="users-header">
|
||||||
|
<div>
|
||||||
|
<h1 class="mb-1">Журнал посещений</h1>
|
||||||
|
<p style="color: var(--text-muted); margin-bottom: 0;">Лабораторная работа №5</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if is_admin %}
|
||||||
|
<div class="d-flex" style="gap: 0.75rem; margin-bottom: 1.5rem;">
|
||||||
|
<a href="{{ url_for('visit_logs.pages_report') }}" class="btn btn-outline">
|
||||||
|
<i class="fas fa-file-alt"></i> Отчёт по страницам
|
||||||
|
</a>
|
||||||
|
<a href="{{ url_for('visit_logs.users_report') }}" class="btn btn-outline">
|
||||||
|
<i class="fas fa-users"></i> Отчёт по пользователям
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="data-card">
|
||||||
|
{% if logs %}
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="data-table w-100">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width: 56px;">№</th>
|
||||||
|
<th>Пользователь</th>
|
||||||
|
<th>Страница</th>
|
||||||
|
<th style="width: 200px;">Дата</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% set offset = (pagination.page - 1) * pagination.per_page %}
|
||||||
|
{% for log in logs %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ offset + loop.index }}</td>
|
||||||
|
<td>
|
||||||
|
{% if log.user %}
|
||||||
|
<div class="d-flex align-items-center" style="gap: 0.6rem;">
|
||||||
|
<div class="user-avatar-sm" style="width: 28px; height: 28px; font-size: 0.75rem;">
|
||||||
|
{{ (log.user.first_name[0] if log.user.first_name else '?') | upper }}
|
||||||
|
</div>
|
||||||
|
<span style="color: var(--text-primary);">{{ log.user.get_full_name() or log.user.login }}</span>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<span style="color: var(--text-muted); font-style: italic;">Неаутентифицированный пользователь</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<code style="color: var(--primary-light); font-size: 0.85rem;">{{ log.path }}</code>
|
||||||
|
</td>
|
||||||
|
<td style="color: var(--text-muted); font-size: 0.85rem;">
|
||||||
|
{{ log.created_at.strftime('%d.%m.%Y %H:%M:%S') if log.created_at else '—' }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if pagination.pages > 1 %}
|
||||||
|
<div class="pagination-container">
|
||||||
|
{% if pagination.has_prev %}
|
||||||
|
<a href="{{ url_for('visit_logs.index', page=pagination.prev_num) }}" class="pagination-btn">
|
||||||
|
<i class="fas fa-chevron-left"></i>
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% for p in pagination.iter_pages(left_edge=1, right_edge=1, left_current=2, right_current=2) %}
|
||||||
|
{% if p %}
|
||||||
|
<a href="{{ url_for('visit_logs.index', page=p) }}"
|
||||||
|
class="pagination-btn {% if p == pagination.page %}active{% endif %}">
|
||||||
|
{{ p }}
|
||||||
|
</a>
|
||||||
|
{% else %}
|
||||||
|
<span class="pagination-btn" style="cursor: default; color: var(--text-muted);">…</span>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
{% if pagination.has_next %}
|
||||||
|
<a href="{{ url_for('visit_logs.index', page=pagination.next_num) }}" class="pagination-btn">
|
||||||
|
<i class="fas fa-chevron-right"></i>
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% else %}
|
||||||
|
<div class="empty-state">
|
||||||
|
<div class="empty-icon">
|
||||||
|
<i class="fas fa-list-alt"></i>
|
||||||
|
</div>
|
||||||
|
<p>Записей пока нет</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
59
labs/lab-5/app/templates/visit_logs/pages.html
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
{% extends 'base.html' %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="row my-4">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="users-header">
|
||||||
|
<div>
|
||||||
|
<h1 class="mb-1">Отчёт по страницам</h1>
|
||||||
|
<p style="color: var(--text-muted); margin-bottom: 0;">Статистика посещений страниц сайта</p>
|
||||||
|
</div>
|
||||||
|
<a href="{{ url_for('visit_logs.index') }}" class="btn btn-outline">
|
||||||
|
<i class="fas fa-arrow-left"></i> К журналу
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="data-card">
|
||||||
|
{% if stats %}
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="data-table w-100">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width: 56px;">№</th>
|
||||||
|
<th>Страница</th>
|
||||||
|
<th style="width: 300px;">Количество посещений</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for row in stats %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ loop.index }}</td>
|
||||||
|
<td>
|
||||||
|
<code style="color: var(--primary-light); font-size: 0.85rem;">{{ row.path }}</code>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="role-badge">{{ row.count }}</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="empty-state">
|
||||||
|
<div class="empty-icon">
|
||||||
|
<i class="fas fa-chart-bar"></i>
|
||||||
|
</div>
|
||||||
|
<p>Данных пока нет</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div style="margin-top: 1.5rem;">
|
||||||
|
<a href="{{ url_for('visit_logs.pages_export') }}" class="btn btn-primary">
|
||||||
|
<i class="fas fa-download"></i> Экспорт в CSV
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
70
labs/lab-5/app/templates/visit_logs/users.html
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
{% extends 'base.html' %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="row my-4">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="users-header">
|
||||||
|
<div>
|
||||||
|
<h1 class="mb-1">Отчёт по пользователям</h1>
|
||||||
|
<p style="color: var(--text-muted); margin-bottom: 0;">Статистика посещений по пользователям</p>
|
||||||
|
</div>
|
||||||
|
<a href="{{ url_for('visit_logs.index') }}" class="btn btn-outline">
|
||||||
|
<i class="fas fa-arrow-left"></i> К журналу
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="data-card">
|
||||||
|
{% if stats %}
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="data-table w-100">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width: 56px;">№</th>
|
||||||
|
<th>Пользователь</th>
|
||||||
|
<th style="width: 300px;">Количество посещений</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for row in stats %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ loop.index }}</td>
|
||||||
|
<td>
|
||||||
|
{% set parts = [row.last_name, row.first_name, row.middle_name] %}
|
||||||
|
{% set full_name = parts | select | join(' ') %}
|
||||||
|
{% if full_name %}
|
||||||
|
<div class="d-flex align-items-center" style="gap: 0.6rem;">
|
||||||
|
<div class="user-avatar-sm" style="width: 28px; height: 28px; font-size: 0.75rem;">
|
||||||
|
{{ (row.first_name[0] if row.first_name else '?') | upper }}
|
||||||
|
</div>
|
||||||
|
<span style="color: var(--text-primary);">{{ full_name }}</span>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<span style="color: var(--text-muted); font-style: italic;">Неаутентифицированный пользователь</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="role-badge">{{ row.count }}</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="empty-state">
|
||||||
|
<div class="empty-icon">
|
||||||
|
<i class="fas fa-users"></i>
|
||||||
|
</div>
|
||||||
|
<p>Данных пока нет</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div style="margin-top: 1.5rem;">
|
||||||
|
<a href="{{ url_for('visit_logs.users_export') }}" class="btn btn-primary">
|
||||||
|
<i class="fas fa-download"></i> Экспорт в CSV
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
19
labs/lab-5/app/templates/visits.html
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
{% extends 'base.html' %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="row justify-content-center my-5">
|
||||||
|
<div class="col-lg-6 col-md-8">
|
||||||
|
<div class="visits-card">
|
||||||
|
<div class="visits-icon">
|
||||||
|
<i class="fas fa-eye"></i>
|
||||||
|
</div>
|
||||||
|
<h1>Счётчик посещений</h1>
|
||||||
|
<p class="visits-description">Эта страница отслеживает количество ваших посещений с помощью сессии Flask.</p>
|
||||||
|
<div class="visits-counter">
|
||||||
|
<span class="visits-number">{{ visits }}</span>
|
||||||
|
<span class="visits-label">{{ visits }} {{ 'посещение' if visits == 1 else ('посещения' if visits < 5 else 'посещений') }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
188
labs/lab-5/app/visit_logs.py
Normal file
|
|
@ -0,0 +1,188 @@
|
||||||
|
import csv, io, sys
|
||||||
|
|
||||||
|
from flask import Blueprint, render_template, request, redirect, url_for, flash, make_response
|
||||||
|
from flask_login import current_user
|
||||||
|
|
||||||
|
visit_logs_bp = Blueprint('visit_logs', __name__, url_prefix='/logs')
|
||||||
|
|
||||||
|
|
||||||
|
def _get_app_module():
|
||||||
|
"""Возвращает главный модуль приложения."""
|
||||||
|
for name in ('__main__', 'app'):
|
||||||
|
mod = sys.modules.get(name)
|
||||||
|
if mod is not None and hasattr(mod, 'db'):
|
||||||
|
return mod
|
||||||
|
raise RuntimeError('Не удалось найти модуль приложения в sys.modules')
|
||||||
|
|
||||||
|
|
||||||
|
def _require_auth():
|
||||||
|
if not current_user.is_authenticated:
|
||||||
|
flash('У вас недостаточно прав для доступа к данной странице.', 'warning')
|
||||||
|
return redirect(url_for('index'))
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _require_admin():
|
||||||
|
redirect_response = _require_auth()
|
||||||
|
if redirect_response:
|
||||||
|
return redirect_response
|
||||||
|
role_name = current_user.role.name if current_user.role else None
|
||||||
|
if role_name != 'Администратор':
|
||||||
|
flash('У вас недостаточно прав для доступа к данной странице.', 'warning')
|
||||||
|
return redirect(url_for('index'))
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_role_name():
|
||||||
|
if current_user.is_authenticated and current_user.role:
|
||||||
|
return current_user.role.name
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@visit_logs_bp.route('/')
|
||||||
|
def index():
|
||||||
|
redirect_response = _require_auth()
|
||||||
|
if redirect_response:
|
||||||
|
return redirect_response
|
||||||
|
|
||||||
|
role_name = _get_role_name()
|
||||||
|
if role_name not in ('Администратор', 'Пользователь'):
|
||||||
|
flash('У вас недостаточно прав для доступа к данной странице.', 'warning')
|
||||||
|
return redirect(url_for('index'))
|
||||||
|
|
||||||
|
m = _get_app_module()
|
||||||
|
db, VisitLog = m.db, m.VisitLog
|
||||||
|
|
||||||
|
page = request.args.get('page', 1, type=int)
|
||||||
|
per_page = 10
|
||||||
|
|
||||||
|
if role_name == 'Администратор':
|
||||||
|
query = VisitLog.query.order_by(VisitLog.created_at.desc())
|
||||||
|
else:
|
||||||
|
query = VisitLog.query.filter_by(
|
||||||
|
user_id=current_user.id
|
||||||
|
).order_by(VisitLog.created_at.desc())
|
||||||
|
|
||||||
|
pagination = query.paginate(page=page, per_page=per_page, error_out=False)
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
'visit_logs/index.html',
|
||||||
|
title='Журнал посещений',
|
||||||
|
logs=pagination.items,
|
||||||
|
pagination=pagination,
|
||||||
|
is_admin=(role_name == 'Администратор')
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@visit_logs_bp.route('/pages')
|
||||||
|
def pages_report():
|
||||||
|
redirect_response = _require_admin()
|
||||||
|
if redirect_response:
|
||||||
|
return redirect_response
|
||||||
|
|
||||||
|
m = _get_app_module()
|
||||||
|
db, VisitLog = m.db, m.VisitLog
|
||||||
|
|
||||||
|
stats = db.session.query(
|
||||||
|
VisitLog.path,
|
||||||
|
db.func.count(VisitLog.id).label('count')
|
||||||
|
).group_by(VisitLog.path).order_by(
|
||||||
|
db.func.count(VisitLog.id).desc()
|
||||||
|
).all()
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
'visit_logs/pages.html',
|
||||||
|
title='Отчёт по страницам',
|
||||||
|
stats=stats
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@visit_logs_bp.route('/pages/export')
|
||||||
|
def pages_export():
|
||||||
|
redirect_response = _require_admin()
|
||||||
|
if redirect_response:
|
||||||
|
return redirect_response
|
||||||
|
|
||||||
|
m = _get_app_module()
|
||||||
|
db, VisitLog = m.db, m.VisitLog
|
||||||
|
|
||||||
|
stats = db.session.query(
|
||||||
|
VisitLog.path,
|
||||||
|
db.func.count(VisitLog.id).label('count')
|
||||||
|
).group_by(VisitLog.path).order_by(
|
||||||
|
db.func.count(VisitLog.id).desc()
|
||||||
|
).all()
|
||||||
|
|
||||||
|
output = io.StringIO()
|
||||||
|
writer = csv.writer(output)
|
||||||
|
writer.writerow(['№', 'Страница', 'Количество посещений'])
|
||||||
|
for i, row in enumerate(stats, 1):
|
||||||
|
writer.writerow([i, row.path, row.count])
|
||||||
|
|
||||||
|
output.seek(0)
|
||||||
|
response = make_response(output.getvalue())
|
||||||
|
response.headers['Content-Type'] = 'text/csv; charset=utf-8'
|
||||||
|
response.headers['Content-Disposition'] = 'attachment; filename=pages_report.csv'
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@visit_logs_bp.route('/users')
|
||||||
|
def users_report():
|
||||||
|
redirect_response = _require_admin()
|
||||||
|
if redirect_response:
|
||||||
|
return redirect_response
|
||||||
|
|
||||||
|
m = _get_app_module()
|
||||||
|
db, VisitLog, User = m.db, m.VisitLog, m.User
|
||||||
|
|
||||||
|
stats = db.session.query(
|
||||||
|
User.last_name,
|
||||||
|
User.first_name,
|
||||||
|
User.middle_name,
|
||||||
|
db.func.count(VisitLog.id).label('count')
|
||||||
|
).outerjoin(User, VisitLog.user_id == User.id).group_by(
|
||||||
|
VisitLog.user_id
|
||||||
|
).order_by(
|
||||||
|
db.func.count(VisitLog.id).desc()
|
||||||
|
).all()
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
'visit_logs/users.html',
|
||||||
|
title='Отчёт по пользователям',
|
||||||
|
stats=stats
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@visit_logs_bp.route('/users/export')
|
||||||
|
def users_export():
|
||||||
|
redirect_response = _require_admin()
|
||||||
|
if redirect_response:
|
||||||
|
return redirect_response
|
||||||
|
|
||||||
|
m = _get_app_module()
|
||||||
|
db, VisitLog, User = m.db, m.VisitLog, m.User
|
||||||
|
|
||||||
|
stats = db.session.query(
|
||||||
|
User.last_name,
|
||||||
|
User.first_name,
|
||||||
|
User.middle_name,
|
||||||
|
db.func.count(VisitLog.id).label('count')
|
||||||
|
).outerjoin(User, VisitLog.user_id == User.id).group_by(
|
||||||
|
VisitLog.user_id
|
||||||
|
).order_by(
|
||||||
|
db.func.count(VisitLog.id).desc()
|
||||||
|
).all()
|
||||||
|
|
||||||
|
output = io.StringIO()
|
||||||
|
writer = csv.writer(output)
|
||||||
|
writer.writerow(['№', 'Пользователь', 'Количество посещений'])
|
||||||
|
for i, row in enumerate(stats, 1):
|
||||||
|
parts = [row.last_name, row.first_name, row.middle_name]
|
||||||
|
full_name = ' '.join(p for p in parts if p) or 'Неаутентифицированный пользователь'
|
||||||
|
writer.writerow([i, full_name, row.count])
|
||||||
|
|
||||||
|
output.seek(0)
|
||||||
|
response = make_response(output.getvalue())
|
||||||
|
response.headers['Content-Type'] = 'text/csv; charset=utf-8'
|
||||||
|
response.headers['Content-Disposition'] = 'attachment; filename=users_report.csv'
|
||||||
|
return response
|
||||||
12
labs/lab-5/requirements.txt
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
click==8.0.4
|
||||||
|
Faker==13.3.2
|
||||||
|
Flask==2.0.3
|
||||||
|
itsdangerous==2.1.1
|
||||||
|
Jinja2==3.0.3
|
||||||
|
MarkupSafe==2.1.1
|
||||||
|
python-dateutil==2.8.2
|
||||||
|
six==1.16.0
|
||||||
|
Werkzeug==2.0.3
|
||||||
|
Flask-Login
|
||||||
|
Flask-SQLAlchemy
|
||||||
|
Flask-Bcrypt
|
||||||