Загрузка данных
models.py
from flask_sqlalchemy import SQLAlchemy
db = SQLAlchemy()
class Category(db.Model):
__tablename__ = 'categories'
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(100), nullable=False, unique=True)
items = db.relationship('Item', backref='category', lazy=True)
class Item(db.Model):
__tablename__ = 'items'
id = db.Column(db.Integer, primary_key=True)
title = db.Column(db.String(200), nullable=False)
description = db.Column(db.Text, nullable=True)
available = db.Column(db.Boolean, default=True)
category_id = db.Column(db.Integer, db.ForeignKey('categories.id'), nullable=True)
app.py
from flask import Flask, render_template, request, redirect, url_for, flash
from models import db, Category, Item
app = Flask(__name__)
app.config['SECRET_KEY'] = 'dev-secret-key-2026'
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///app.db'
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
db.init_app(app)
with app.app_context():
db.create_all()
@app.route('/')
def index():
total_items = Item.query.count()
total_categories = Category.query.count()
return render_template('index.html', total_items=total_items, total_categories=total_categories)
@app.route('/items')
def item_list():
category_id = request.args.get('category', type=int)
q = request.args.get('q', '').strip()
query = Item.query
if category_id:
query = query.filter_by(category_id=category_id)
if q:
query = query.filter(Item.title.contains(q))
items = query.all()
categories = Category.query.all()
return render_template(
'item_list.html',
items=items,
categories=categories,
selected=category_id,
q=q
)
@app.route('/items/add', methods=['GET', 'POST'])
def item_add():
categories = Category.query.all()
if request.method == 'POST':
title = request.form.get('title', '').strip()
description = request.form.get('description', '').strip()
available = 'available' in request.form
category_id = request.form.get('category_id', type=int)
if not title:
flash('Название элемента обязательно для заполнения', 'danger')
return render_template('item_form.html', categories=categories, item=None)
new_item = Item(
title=title,
description=description,
available=available,
category_id=category_id if category_id else None
)
db.session.add(new_item)
db.session.commit()
flash('Элемент успешно добавлен', 'success')
return redirect(url_for('item_list'))
return render_template('item_form.html', categories=categories, item=None)
@app.route('/items/<int:item_id>')
def item_detail(item_id):
item = Item.query.get_or_404(item_id)
return render_template('item_detail.html', item=item)
@app.route('/items/<int:item_id>/edit', methods=['GET', 'POST'])
def item_edit(item_id):
item = Item.query.get_or_404(item_id)
categories = Category.query.all()
if request.method == 'POST':
title = request.form.get('title', '').strip()
if not title:
flash('Название не может быть пустым', 'danger')
return render_template('item_form.html', item=item, categories=categories)
item.title = title
item.description = request.form.get('description', '').strip()
item.available = 'available' in request.form
category_id = request.form.get('category_id', type=int)
item.category_id = category_id if category_id else None
db.session.commit()
flash('Изменения сохранены', 'success')
return redirect(url_for('item_detail', item_id=item.id))
return render_template('item_form.html', item=item, categories=categories)
@app.route('/items/<int:item_id>/delete', methods=['POST'])
def item_delete(item_id):
item = Item.query.get_or_404(item_id)
db.session.delete(item)
db.session.commit()
flash('Элемент удалён', 'success')
return redirect(url_for('item_list'))
@app.route('/categories')
def category_list():
categories = Category.query.all()
return render_template('category_list.html', categories=categories)
@app.route('/categories/add', methods=['GET', 'POST'])
def category_add():
if request.method == 'POST':
name = request.form.get('name', '').strip()
if not name:
flash('Введите название категории', 'danger')
return render_template('category_form.html')
existing = Category.query.filter_by(name=name).first()
if existing:
flash('Такая категория уже существует', 'danger')
return render_template('category_form.html')
category = Category(name=name)
db.session.add(category)
db.session.commit()
flash('Категория добавлена', 'success')
return redirect(url_for('category_list'))
return render_template('category_form.html')
@app.errorhandler(404)
def not_found(error):
return render_template('404.html'), 404
if __name__ == '__main__':
app.run(debug=True)
style.css
:root {
--bg: #f8fafc;
--surface: #ffffff;
--text: #0f172a;
--text-muted: #64748b;
--primary: #4f46e5;
--primary-hover: #4338ca;
--danger: #ef4444;
--danger-hover: #dc2626;
--success-bg: #dcfce7;
--success-text: #166534;
--danger-bg: #fee2e2;
--danger-text: #991b1b;
--border: #e2e8f0;
--radius: 10px;
--shadow: 0 1px 3px rgba(15, 23, 42, 0.06), 0 1px 2px rgba(15, 23, 42, 0.04);
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Inter, sans-serif;
background-color: var(--bg);
color: var(--text);
line-height: 1.6;
}
.container {
max-width: 960px;
margin: 0 auto;
padding: 0 20px;
}
.navbar {
background-color: var(--surface);
border-bottom: 1px solid var(--border);
padding: 16px 0;
margin-bottom: 32px;
}
.nav-inner {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 16px;
}
.brand {
font-size: 1.25rem;
font-weight: 700;
color: var(--primary);
text-decoration: none;
}
.nav-links {
display: flex;
gap: 20px;
align-items: center;
list-style: none;
}
.nav-link {
color: var(--text-muted);
text-decoration: none;
font-weight: 500;
transition: color 0.15s;
}
.nav-link:hover {
color: var(--primary);
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 16px;
margin-bottom: 24px;
}
.page-title {
font-size: 1.75rem;
font-weight: 700;
}
.text-muted {
color: var(--text-muted);
}
.card {
background-color: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 24px;
box-shadow: var(--shadow);
margin-bottom: 16px;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 20px;
}
.item-card {
display: flex;
flex-direction: column;
justify-content: space-between;
height: 100%;
margin-bottom: 0;
}
.item-title {
font-size: 1.2rem;
font-weight: 600;
margin-bottom: 8px;
}
.item-meta {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin-bottom: 16px;
}
.badge {
display: inline-block;
padding: 4px 10px;
font-size: 0.8rem;
font-weight: 600;
border-radius: 999px;
}
.badge-neutral {
background-color: var(--bg);
color: var(--text-muted);
border: 1px solid var(--border);
}
.badge-success {
background-color: var(--success-bg);
color: var(--success-text);
}
.badge-danger {
background-color: var(--danger-bg);
color: var(--danger-text);
}
.actions {
display: flex;
gap: 10px;
align-items: center;
flex-wrap: wrap;
margin-top: 16px;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 9px 16px;
font-size: 0.925rem;
font-weight: 500;
border-radius: 8px;
border: 1px solid transparent;
cursor: pointer;
text-decoration: none;
transition: background-color 0.15s, border-color 0.15s;
font-family: inherit;
}
.btn-primary {
background-color: var(--primary);
color: #ffffff;
}
.btn-primary:hover {
background-color: var(--primary-hover);
}
.btn-outline {
background-color: transparent;
border-color: var(--border);
color: var(--text);
}
.btn-outline:hover {
background-color: var(--bg);
}
.btn-danger {
background-color: var(--danger);
color: #ffffff;
}
.btn-danger:hover {
background-color: var(--danger-hover);
}
.filter-bar {
display: flex;
gap: 12px;
flex-wrap: wrap;
align-items: center;
}
.form-group {
margin-bottom: 18px;
}
.form-label {
display: block;
font-weight: 500;
margin-bottom: 6px;
}
.form-control {
width: 100%;
padding: 10px 12px;
font-size: 0.95rem;
border: 1px solid var(--border);
border-radius: 8px;
background-color: var(--surface);
color: var(--text);
font-family: inherit;
}
.form-control:focus {
outline: none;
border-color: var(--primary);
}
.filter-input {
flex: 1;
min-width: 200px;
}
textarea.form-control {
min-height: 110px;
resize: vertical;
}
.checkbox-label {
display: inline-flex;
align-items: center;
gap: 8px;
cursor: pointer;
font-weight: 500;
}
.alert {
padding: 14px 18px;
border-radius: var(--radius);
margin-bottom: 24px;
font-weight: 500;
}
.alert-success {
background-color: var(--success-bg);
color: var(--success-text);
}
.alert-danger {
background-color: var(--danger-bg);
color: var(--danger-text);
}
.data-table {
width: 100%;
border-collapse: collapse;
}
.data-table th,
.data-table td {
text-align: left;
padding: 12px 16px;
border-bottom: 1px solid var(--border);
}
.data-table th {
color: var(--text-muted);
font-weight: 600;
font-size: 0.85rem;
text-transform: uppercase;
}
.empty-state {
text-align: center;
padding: 48px 24px;
}
base.html
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}GearHub — Каталог девайсов{% endblock %}</title>
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
</head>
<body>
<header class="navbar">
<div class="container nav-inner">
<a href="{{ url_for('index') }}" class="brand">GearHub</a>
<nav>
<ul class="nav-links">
<li><a href="{{ url_for('index') }}" class="nav-link">Главная</a></li>
<li><a href="{{ url_for('item_list') }}" class="nav-link">Элементы</a></li>
<li><a href="{{ url_for('category_list') }}" class="nav-link">Категории</a></li>
<li><a href="{{ url_for('item_add') }}" class="btn btn-primary">Добавить элемент</a></li>
</ul>
</nav>
</div>
</header>
<main class="container">
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ category }}">{{ message }}</div>
{% endfor %}
{% endif %}
{% endwith %}
{% block content %}{% endblock %}
</main>
</body>
</html>
index.html
{% extends 'base.html' %}
{% block title %}Главная — GearHub{% endblock %}
{% block content %}
<div class="card">
<h1 class="page-title">Каталог периферии и оборудования</h1>
<p class="text-muted">Единая база данных устройств с фильтрацией по категориям и быстрым поиском.</p>
<div class="item-meta actions">
<span class="badge badge-neutral">Элементов в базе: {{ total_items }}</span>
<span class="badge badge-neutral">Категорий: {{ total_categories }}</span>
</div>
<div class="actions">
<a href="{{ url_for('item_list') }}" class="btn btn-primary">Перейти к списку</a>
<a href="{{ url_for('category_list') }}" class="btn btn-outline">Управление категориями</a>
</div>
</div>
{% endblock %}
item_list.html
{% extends 'base.html' %}
{% block title %}Список элементов — GearHub{% endblock %}
{% block content %}
<div class="page-header">
<h1 class="page-title">Элементы каталога</h1>
<a href="{{ url_for('item_add') }}" class="btn btn-primary">Добавить элемент</a>
</div>
<div class="card">
<form method="GET" action="{{ url_for('item_list') }}" class="filter-bar">
<input type="text" name="q" value="{{ q }}" placeholder="Поиск по названию..." class="form-control filter-input">
<select name="category" class="form-control filter-input">
<option value="">Все категории</option>
{% for category in categories %}
<option value="{{ category.id }}" {% if selected == category.id %}selected{% endif %}>
{{ category.name }}
</option>
{% endfor %}
</select>
<button type="submit" class="btn btn-primary">Применить</button>
{% if q or selected %}
<a href="{{ url_for('item_list') }}" class="btn btn-outline">Сбросить</a>
{% endif %}
</form>
</div>
{% if items %}
<div class="grid">
{% for item in items %}
<div class="card item-card">
<div>
<h2 class="item-title">{{ item.title }}</h2>
<div class="item-meta">
<span class="badge badge-neutral">
{{ item.category.name if item.category else '—' }}
</span>
{% if item.available %}
<span class="badge badge-success">В наличии</span>
{% else %}
<span class="badge badge-danger">Недоступно</span>
{% endif %}
</div>
<p class="text-muted">{{ item.description or 'Описание отсутствует' }}</p>
</div>
<div class="actions">
<a href="{{ url_for('item_detail', item_id=item.id) }}" class="btn btn-outline">Просмотр</a>
<form method="POST" action="{{ url_for('item_delete', item_id=item.id) }}">
<button type="submit" class="btn btn-danger" onclick="return confirm('Удалить?')">Удалить</button>
</form>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="card empty-state">
<p class="text-muted">Элементов пока нет</p>
</div>
{% endif %}
{% endblock %}
item_detail.html
{% extends 'base.html' %}
{% block title %}{{ item.title }} — GearHub{% endblock %}
{% block content %}
<div class="card">
<div class="page-header">
<h1 class="page-title">{{ item.title }}</h1>
{% if item.available %}
<span class="badge badge-success">В наличии</span>
{% else %}
<span class="badge badge-danger">Недоступно</span>
{% endif %}
</div>
<div class="item-meta">
<span class="badge badge-neutral">
Категория: {{ item.category.name if item.category else '—' }}
</span>
</div>
<p>{{ item.description or 'Описание не указано.' }}</p>
<div class="actions">
<a href="{{ url_for('item_edit', item_id=item.id) }}" class="btn btn-primary">Редактировать</a>
<a href="{{ url_for('item_list') }}" class="btn btn-outline">К списку</a>
</div>
</div>
{% endblock %}
item_form.html
{% extends 'base.html' %}
{% block title %}
{% if item %}Редактировать элемент{% else %}Добавить элемент{% endif %} — GearHub
{% endblock %}
{% block content %}
<div class="card">
<div class="page-header">
<h1 class="page-title">
{% if item %}Редактирование: {{ item.title }}{% else %}Новый элемент{% endif %}
</h1>
</div>
<form method="POST">
<div class="form-group">
<label for="title" class="form-label">Название *</label>
<input type="text" id="title" name="title" class="form-control" required
value="{{ item.title if item else '' }}">
</div>
<div class="form-group">
<label for="category_id" class="form-label">Категория</label>
<select id="category_id" name="category_id" class="form-control">
<option value="">— без категории —</option>
{% for category in categories %}
<option value="{{ category.id }}"
{% if item and item.category_id == category.id %}selected{% endif %}>
{{ category.name }}
</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<label for="description" class="form-label">Описание</label>
<textarea id="description" name="description" class="form-control">{{ item.description if item and item.description else '' }}</textarea>
</div>
<div class="form-group">
<label class="checkbox-label">
<input type="checkbox" name="available"
{% if not item or item.available %}checked{% endif %}>
Доступен (в наличии)
</label>
</div>
<div class="actions">
<button type="submit" class="btn btn-primary">
{% if item %}Сохранить изменения{% else %}Добавить{% endif %}
</button>
<a href="{{ url_for('item_list') }}" class="btn btn-outline">Отмена</a>
</div>
</form>
</div>
{% endblock %}
category_list.py
{% extends 'base.html' %}
{% block title %}Категории — GearHub{% endblock %}
{% block content %}
<div class="page-header">
<h1 class="page-title">Категории</h1>
<a href="{{ url_for('category_add') }}" class="btn btn-primary">Добавить категорию</a>
</div>
<div class="card">
{% if categories %}
<table class="data-table">
<thead>
<tr>
<th>ID</th>
<th>Название категории</th>
<th>Элементов</th>
</tr>
</thead>
<tbody>
{% for category in categories %}
<tr>
<td>{{ category.id }}</td>
<td>
<a href="{{ url_for('item_list', category=category.id) }}" class="nav-link">
{{ category.name }}
</a>
</td>
<td>{{ category.items | length }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="empty-state">
<p class="text-muted">Категорий пока нет</p>
</div>
{% endif %}
</div>
{% endblock %}
category_form.html
{% extends 'base.html' %}
{% block title %}Добавить категорию — GearHub{% endblock %}
{% block content %}
<div class="card">
<div class="page-header">
<h1 class="page-title">Новая категория</h1>
</div>
<form method="POST">
<div class="form-group">
<label for="name" class="form-label">Название категории *</label>
<input type="text" id="name" name="name" class="form-control" required placeholder="Например: Мыши, Наушники, Клавиатуры">
</div>
<div class="actions">
<button type="submit" class="btn btn-primary">Создать</button>
<a href="{{ url_for('category_list') }}" class="btn btn-outline">Отмена</a>
</div>
</form>
</div>
{% endblock %}
404.html
{% extends 'base.html' %}
{% block title %}Страница не найдена (404) — GearHub{% endblock %}
{% block content %}
<div class="card empty-state">
<h1 class="page-title">404 — Страница не найдена</h1>
<p class="text-muted">Запрашиваемый элемент или страница не существуют в базе данных.</p>
<div class="actions">
<a href="{{ url_for('index') }}" class="btn btn-primary">Вернуться на главную</a>
<a href="{{ url_for('item_list') }}" class="btn btn-outline">К списку элементов</a>
</div>
</div>
{% endblock %}