Загрузка данных


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 %}