From e15aa0c2d4eb20b501ded50de4be21d0cef2745f Mon Sep 17 00:00:00 2001 From: DeevEV Date: Thu, 21 May 2026 13:35:56 +0300 Subject: [PATCH] lab-3: add Qt GUI client with Singleton and Adapter patterns --- lab-3/CMakeLists.txt | 76 +++++++ lab-3/api_client.cpp | 197 +++++++++++++++++ lab-3/api_client.h | 89 ++++++++ lab-3/icons/app.svg | 9 + lab-3/iquiz_adapter.h | 64 ++++++ lab-3/lab-3_ru_RU.ts | 3 + lab-3/main.cpp | 36 ++++ lab-3/mainwindow.cpp | 418 ++++++++++++++++++++++++++++++++++++ lab-3/mainwindow.h | 46 ++++ lab-3/mainwindow.ui | 157 ++++++++++++++ lab-3/quiz.h | 57 +++++ lab-3/quiz_json_adapter.cpp | 109 ++++++++++ lab-3/quiz_json_adapter.h | 33 +++ lab-3/resources.qrc | 5 + 14 files changed, 1299 insertions(+) create mode 100644 lab-3/CMakeLists.txt create mode 100644 lab-3/api_client.cpp create mode 100644 lab-3/api_client.h create mode 100644 lab-3/icons/app.svg create mode 100644 lab-3/iquiz_adapter.h create mode 100644 lab-3/lab-3_ru_RU.ts create mode 100644 lab-3/main.cpp create mode 100644 lab-3/mainwindow.cpp create mode 100644 lab-3/mainwindow.h create mode 100644 lab-3/mainwindow.ui create mode 100644 lab-3/quiz.h create mode 100644 lab-3/quiz_json_adapter.cpp create mode 100644 lab-3/quiz_json_adapter.h create mode 100644 lab-3/resources.qrc diff --git a/lab-3/CMakeLists.txt b/lab-3/CMakeLists.txt new file mode 100644 index 0000000..426120d --- /dev/null +++ b/lab-3/CMakeLists.txt @@ -0,0 +1,76 @@ +cmake_minimum_required(VERSION 3.16) + +project(lab-3 VERSION 0.1 LANGUAGES CXX) + +set(CMAKE_AUTOUIC ON) +set(CMAKE_AUTOMOC ON) +set(CMAKE_AUTORCC ON) + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) + +find_package(QT NAMES Qt6 Qt5 REQUIRED COMPONENTS Widgets Network LinguistTools) +find_package(Qt${QT_VERSION_MAJOR} REQUIRED COMPONENTS Widgets Network LinguistTools) + +set(TS_FILES lab-3_ru_RU.ts) + +set(PROJECT_SOURCES + main.cpp + mainwindow.cpp + mainwindow.h + mainwindow.ui + quiz.h + iquiz_adapter.h + quiz_json_adapter.h + quiz_json_adapter.cpp + api_client.h + api_client.cpp + resources.qrc + ${TS_FILES} +) + +if(${QT_VERSION_MAJOR} GREATER_EQUAL 6) + qt_add_executable(lab-3 + MANUAL_FINALIZATION + ${PROJECT_SOURCES} + ) + qt_create_translation(QM_FILES ${CMAKE_SOURCE_DIR} ${TS_FILES}) +else() + if(ANDROID) + add_library(lab-3 SHARED + ${PROJECT_SOURCES} + ) + else() + add_executable(lab-3 + ${PROJECT_SOURCES} + ) + endif() + qt5_create_translation(QM_FILES ${CMAKE_SOURCE_DIR} ${TS_FILES}) +endif() + +target_link_libraries(lab-3 PRIVATE + Qt${QT_VERSION_MAJOR}::Widgets + Qt${QT_VERSION_MAJOR}::Network +) + +if(${QT_VERSION} VERSION_LESS 6.1.0) + set(BUNDLE_ID_OPTION MACOSX_BUNDLE_GUI_IDENTIFIER com.example.lab-3) +endif() +set_target_properties(lab-3 PROPERTIES + ${BUNDLE_ID_OPTION} + MACOSX_BUNDLE_BUNDLE_VERSION ${PROJECT_VERSION} + MACOSX_BUNDLE_SHORT_VERSION_STRING ${PROJECT_VERSION_MAJOR}.${PROJECT_VERSION_MINOR} + MACOSX_BUNDLE TRUE + WIN32_EXECUTABLE TRUE +) + +include(GNUInstallDirs) +install(TARGETS lab-3 + BUNDLE DESTINATION . + LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR} + RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR} +) + +if(QT_VERSION_MAJOR EQUAL 6) + qt_finalize_executable(lab-3) +endif() diff --git a/lab-3/api_client.cpp b/lab-3/api_client.cpp new file mode 100644 index 0000000..0cd80fb --- /dev/null +++ b/lab-3/api_client.cpp @@ -0,0 +1,197 @@ +/* + * api_client.cpp — Реализация ApiClient (Singleton + HTTP + Adapter) + * + * Жизненный цикл одного запроса (пример fetchQuizzes): + * + * 1. fetchQuizzes() создаёт QNetworkRequest и вызывает m_manager->get() + * 2. QNetworkAccessManager отправляет HTTP GET асинхронно + * (не блокирует event loop) + * 3. Когда ответ получен, Qt вызывает лямбду, подключённую к + * сигналу QNetworkReply::finished + * 4. Лямбда читает байты через reply->readAll() + * 5. QJsonDocument::fromJson() парсит байты в дерево JSON + * 6. m_adapter->toQuizList() десериализует дерево в Quiz-объекты + * 7. emit quizzesReceived(...) уведомляет подписчиков + */ + +#include "api_client.h" +#include "quiz_json_adapter.h" + +#include // результат асинхронного HTTP-запроса +#include // описание запроса: URL, заголовки +#include // точка входа в парсер JSON +#include // JSON-массив [ ... ] +#include // JSON-объект { ... } +#include + +// ───────────────────────────────────────────────────────────────────────────── +// Реализация паттерна Синглтон +// ───────────────────────────────────────────────────────────────────────────── + +ApiClient &ApiClient::instance() +{ + /* + * Meyers' Singleton: статическая локальная переменная. + * C++11 гарантирует потокобезопасность инициализации. + * Хранится как статическое свойство функции — одна на всё приложение. + */ + static ApiClient s_instance; + return s_instance; +} + +ApiClient::ApiClient(QObject *parent) + : QObject(parent) + , m_manager(new QNetworkAccessManager(this)) + , m_adapter(new QuizJsonAdapter()) + , m_baseUrl("http://localhost:80") +{ +} + +ApiClient::~ApiClient() +{ + delete m_adapter; +} + +// ───────────────────────────────────────────────────────────────────────────── +// fetchQuizzes — GET /api/quiz/ +// ───────────────────────────────────────────────────────────────────────────── + +void ApiClient::fetchQuizzes() +{ + QNetworkRequest request(QUrl(m_baseUrl + "/api/quiz/")); + request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json"); + + QNetworkReply *reply = m_manager->get(request); + + connect(reply, &QNetworkReply::finished, this, [this, reply]() { + reply->deleteLater(); + + if (reply->error() != QNetworkReply::NoError) { + emit errorOccurred(reply->errorString()); + return; + } + + // reply->readAll() — читаем весь буфер HTTP-ответа + QByteArray rawData = reply->readAll(); + QJsonDocument doc = QJsonDocument::fromJson(rawData); + + if (!doc.isArray()) { + emit errorOccurred("Unexpected JSON format: array expected"); + return; + } + + QList quizzes = m_adapter->toQuizList(doc.array()); + emit quizzesReceived(quizzes); + }); +} + +// ───────────────────────────────────────────────────────────────────────────── +// fetchQuiz — GET /api/quiz/:id/ +// ───────────────────────────────────────────────────────────────────────────── + +void ApiClient::fetchQuiz(int id) +{ + QNetworkRequest request(QUrl(m_baseUrl + "/api/quiz/" + QString::number(id) + "/")); + request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json"); + + QNetworkReply *reply = m_manager->get(request); + + connect(reply, &QNetworkReply::finished, this, [this, reply]() { + reply->deleteLater(); + + if (reply->error() != QNetworkReply::NoError) { + emit errorOccurred(reply->errorString()); + return; + } + + QByteArray rawData = reply->readAll(); + QJsonDocument doc = QJsonDocument::fromJson(rawData); + + if (!doc.isObject()) { + emit errorOccurred("Unexpected JSON format: object expected"); + return; + } + + emit quizReceived(m_adapter->toQuiz(doc.object())); + }); +} + +// ───────────────────────────────────────────────────────────────────────────── +// createQuiz — POST /api/quiz/ +// ───────────────────────────────────────────────────────────────────────────── + +void ApiClient::createQuiz(const QJsonObject &data) +{ + QNetworkRequest request(QUrl(m_baseUrl + "/api/quiz/")); + request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json"); + + QByteArray body = QJsonDocument(data).toJson(QJsonDocument::Compact); + QNetworkReply *reply = m_manager->post(request, body); + + connect(reply, &QNetworkReply::finished, this, [this, reply]() { + reply->deleteLater(); + if (reply->error() != QNetworkReply::NoError) { + emit errorOccurred(reply->errorString()); + return; + } + QByteArray rawData = reply->readAll(); + QJsonDocument doc = QJsonDocument::fromJson(rawData); + if (!doc.isObject()) { + emit errorOccurred("POST: unexpected JSON format"); + return; + } + emit quizCreated(m_adapter->toQuiz(doc.object())); + }); +} + +// ───────────────────────────────────────────────────────────────────────────── +// updateQuiz — PUT /api/quiz/:id/ +// ───────────────────────────────────────────────────────────────────────────── + +void ApiClient::updateQuiz(int id, const QJsonObject &data) +{ + QNetworkRequest request(QUrl(m_baseUrl + "/api/quiz/" + QString::number(id) + "/")); + request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json"); + + QByteArray body = QJsonDocument(data).toJson(QJsonDocument::Compact); + QNetworkReply *reply = m_manager->put(request, body); + + connect(reply, &QNetworkReply::finished, this, [this, reply]() { + reply->deleteLater(); + if (reply->error() != QNetworkReply::NoError) { + emit errorOccurred(reply->errorString()); + return; + } + QByteArray rawData = reply->readAll(); + QJsonDocument doc = QJsonDocument::fromJson(rawData); + if (!doc.isObject()) { + emit errorOccurred("PUT: unexpected JSON format"); + return; + } + emit quizUpdated(m_adapter->toQuiz(doc.object())); + }); +} + +// ───────────────────────────────────────────────────────────────────────────── +// deleteQuiz — DELETE /api/quiz/:id/ +// ───────────────────────────────────────────────────────────────────────────── + +void ApiClient::deleteQuiz(int id) +{ + QNetworkRequest request(QUrl(m_baseUrl + "/api/quiz/" + QString::number(id) + "/")); + QNetworkReply *reply = m_manager->deleteResource(request); + + connect(reply, &QNetworkReply::finished, this, [this, reply]() { + reply->deleteLater(); + if (reply->error() != QNetworkReply::NoError) { + emit errorOccurred(reply->errorString()); + return; + } + QByteArray rawData = reply->readAll(); + QJsonDocument doc = QJsonDocument::fromJson(rawData); + QString message = "Тест удалён"; + if (doc.isObject() && doc.object().contains("detail")) + message = doc.object()["detail"].toString(); + emit quizDeleted(message); + }); +} diff --git a/lab-3/api_client.h b/lab-3/api_client.h new file mode 100644 index 0000000..e6d63e6 --- /dev/null +++ b/lab-3/api_client.h @@ -0,0 +1,89 @@ +/* + * api_client.h — HTTP-клиент, реализующий паттерн «Синглтон» + * + * ╔══════════════════════════════════════════════════════════════════╗ + * ║ ПАТТЕРН «СИНГЛТОН» (Singleton) ║ + * ╠══════════════════════════════════════════════════════════════════╣ + * ║ Задача: гарантировать, что в приложении существует ровно ║ + * ║ ОДИН экземпляр QNetworkAccessManager. Создание нескольких ║ + * ║ менеджеров приводит к дублированию соединений и cookie-хранилищ.║ + * ║ ║ + * ║ Реализация (Meyers' Singleton): ║ + * ║ • статическая локальная переменная в instance() ║ + * ║ • конструктор объявлен private ║ + * ║ • копирующий конструктор и operator= удалены (= delete) ║ + * ╚══════════════════════════════════════════════════════════════════╝ + * + * ApiClient также является «клиентом» паттерна «Адаптер»: + * он хранит указатель на IQuizAdapter и использует его + * для преобразования ответов сервера в Quiz-объекты, + * не зная о деталях реализации парсинга. + * + * Коммуникация с вызывающим кодом — через механизм сигналов Qt: + * quizzesReceived — список тестов успешно получен и десериализован + * quizReceived — один тест успешно получен и десериализован + * quizCreated — тест создан (POST) + * quizUpdated — тест обновлён (PUT) + * quizDeleted — тест удалён (DELETE) + * errorOccurred — произошла сетевая ошибка или ошибка парсинга + */ + +#pragma once + +#include // базовый класс Qt с поддержкой сигналов/слотов +#include // выполняет HTTP-запросы асинхронно +#include // тело запросов POST/PUT +#include // контейнер для списка тестов +#include "quiz.h" +#include "iquiz_adapter.h" + +class ApiClient : public QObject +{ + /* + * Q_OBJECT — макрос Qt, активирует Meta-Object System: + * систему сигналов/слотов, rtti, tr() и пр. + * Обязателен для любого класса, использующего signals/slots. + */ + Q_OBJECT + +public: + /* + * Единственная точка доступа к экземпляру (Singleton Access Point). + * Возвращает ссылку (не указатель), чтобы исключить проверку на nullptr. + */ + static ApiClient &instance(); + + // GET /api/quiz/ — получить все тесты + void fetchQuizzes(); + + // GET /api/quiz/:id/ — получить один тест по ID + void fetchQuiz(int id); + + // POST /api/quiz/ — создать тест; тело запроса передаётся как QJsonObject + void createQuiz(const QJsonObject &data); + + // PUT /api/quiz/:id/ — полностью обновить тест по ID + void updateQuiz(int id, const QJsonObject &data); + + // DELETE /api/quiz/:id/ — удалить тест по ID + void deleteQuiz(int id); + +signals: + void quizzesReceived(QList quizzes); + void quizReceived(Quiz quiz); + void quizCreated(Quiz quiz); + void quizUpdated(Quiz quiz); + void quizDeleted(QString message); + void errorOccurred(QString error); + +private: + explicit ApiClient(QObject *parent = nullptr); + ~ApiClient() override; + + ApiClient(const ApiClient &) = delete; + ApiClient &operator=(const ApiClient &) = delete; + + QNetworkAccessManager *m_manager; + IQuizAdapter *m_adapter; + QString m_baseUrl; +}; diff --git a/lab-3/icons/app.svg b/lab-3/icons/app.svg new file mode 100644 index 0000000..20fbede --- /dev/null +++ b/lab-3/icons/app.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/lab-3/iquiz_adapter.h b/lab-3/iquiz_adapter.h new file mode 100644 index 0000000..d12789b --- /dev/null +++ b/lab-3/iquiz_adapter.h @@ -0,0 +1,64 @@ +/* + * iquiz_adapter.h — Целевой интерфейс (Target) паттерна «Адаптер» + * + * ╔══════════════════════════════════════════════════════════════════╗ + * ║ ПАТТЕРН «АДАПТЕР» (Adapter / Wrapper) ║ + * ╠══════════════════════════════════════════════════════════════════╣ + * ║ Задача: клиентский код (ApiClient) хочет работать с Quiz- ║ + * ║ объектами, но получает от сети «чужой» формат — QJsonObject. ║ + * ║ ║ + * ║ Участники: ║ + * ║ • Target (цель) — IQuizAdapter ← этот файл ║ + * ║ • Adaptee (адаптируемый) — QJsonObject / QJsonArray (Qt) ║ + * ║ • Adapter (адаптер) — QuizJsonAdapter ║ + * ║ • Client (клиент) — ApiClient, использует IQuizAdapter ║ + * ╚══════════════════════════════════════════════════════════════════╝ + * + * IQuizAdapter задаёт контракт: «дай мне QJson-данные — + * я верну тебе Quiz или QList». + * ApiClient знает только об этом интерфейсе и не зависит + * от конкретного способа парсинга. Если завтра формат сервера + * изменится (XML, Protobuf), достаточно написать новый класс, + * реализующий IQuizAdapter, — ApiClient трогать не придётся. + */ + +#pragma once + +#include // { "key": value, ... } — один JSON-объект +#include // [ {...}, {...} ] — массив JSON-объектов +#include // динамический массив Qt +#include "quiz.h" // целевой тип данных нашего приложения + +class IQuizAdapter +{ +public: + /* + * Виртуальный деструктор обязателен для любого полиморфного + * базового класса: без него при delete через указатель на базу + * деструктор производного класса не будет вызван (UB). + */ + virtual ~IQuizAdapter() = default; + + /* + * Преобразует один JSON-объект в C++-объект Quiz. + * Вызывается, когда сервер вернул одну запись + * (GET /api/quiz/:id/ → { "id":1, "title":"...", ... }). + * + * Метод объявлен const: адаптер не хранит состояния — + * один и тот же экземпляр можно использовать многократно. + * + * Метод чисто виртуальный (= 0): IQuizAdapter нельзя + * создать напрямую; обязательна конкретная реализация. + */ + virtual Quiz toQuiz(const QJsonObject &json) const = 0; + + /* + * Преобразует JSON-массив в список Quiz. + * Вызывается, когда сервер вернул коллекцию записей + * (GET /api/quiz/ → [ {...}, {...}, ... ]). + * + * Внутри реализации каждый элемент массива + * делегируется в toQuiz(). + */ + virtual QList toQuizList(const QJsonArray &json) const = 0; +}; diff --git a/lab-3/lab-3_ru_RU.ts b/lab-3/lab-3_ru_RU.ts new file mode 100644 index 0000000..743f57f --- /dev/null +++ b/lab-3/lab-3_ru_RU.ts @@ -0,0 +1,3 @@ + + + diff --git a/lab-3/main.cpp b/lab-3/main.cpp new file mode 100644 index 0000000..c4ae926 --- /dev/null +++ b/lab-3/main.cpp @@ -0,0 +1,36 @@ +#include "mainwindow.h" + +#include +#include +#include + +int main(int argc, char *argv[]) +{ + QApplication a(argc, argv); + + QTranslator translator; + const QStringList uiLanguages = QLocale::system().uiLanguages(); + for (const QString &locale : uiLanguages) { + const QString baseName = "lab-3_" + QLocale(locale).name(); + if (translator.load(":/i18n/" + baseName)) { + a.installTranslator(&translator); + break; + } + } + + int result = 0; + { + /* + * MainWindow создаётся внутри блока scope, чтобы его деструктор + * выполнился ДО выхода из main() и до уничтожения QApplication. + * Это предотвращает краш при закрытии, вызванный тем, что + * статический Singleton (ApiClient) пытается обратиться к уже + * разрушенным Qt-объектам. + */ + MainWindow w; + w.show(); + result = a.exec(); + } + + return result; +} diff --git a/lab-3/mainwindow.cpp b/lab-3/mainwindow.cpp new file mode 100644 index 0000000..3157d1b --- /dev/null +++ b/lab-3/mainwindow.cpp @@ -0,0 +1,418 @@ +#include "mainwindow.h" +#include "ui_mainwindow.h" +#include "api_client.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +static const int COL_ID = 0; +static const int COL_TITLE = 1; +static const int COL_AUTHOR = 2; +static const int COL_TIME = 3; +static const int COL_PUBLISHED = 4; +static const int COL_CREATED = 5; +static const int COL_DESCRIPTION = 6; +static const int COL_COUNT = 7; + +MainWindow::MainWindow(QWidget *parent) + : QMainWindow(parent) + , ui(new Ui::MainWindow) +{ + ui->setupUi(this); + setWindowIcon(QIcon(":/icons/app.svg")); + + setupTable(); + + // Вкладки сворачиваем — переключение вынесено в статусбар + ui->tabWidget->tabBar()->hide(); + + // Кнопки переключения вида прямо в строке состояния справа + auto *btnTable = new QPushButton("Таблица", this); + auto *btnText = new QPushButton("Текст", this); + + QString activeStyle = "QPushButton{background:#1f2937;color:#f9fafb;border:1px solid #4b5563;border-radius:4px;padding:2px 12px;font-weight:bold;}"; + QString inactiveStyle = "QPushButton{background:#111827;color:#6b7280;border:1px solid #374151;border-radius:4px;padding:2px 12px;}"; + + btnTable->setStyleSheet(activeStyle); + btnText->setStyleSheet(inactiveStyle); + btnTable->setFixedHeight(22); + btnText->setFixedHeight(22); + + connect(btnTable, &QPushButton::clicked, this, [=]() { + ui->tabWidget->setCurrentIndex(0); + btnTable->setStyleSheet(activeStyle); + btnText->setStyleSheet(inactiveStyle); + }); + connect(btnText, &QPushButton::clicked, this, [=]() { + ui->tabWidget->setCurrentIndex(1); + btnText->setStyleSheet(activeStyle); + btnTable->setStyleSheet(inactiveStyle); + }); + + statusBar()->addPermanentWidget(btnTable); + statusBar()->addPermanentWidget(btnText); + statusBar()->setStyleSheet("QStatusBar::item { border: none; }"); + + ApiClient &client = ApiClient::instance(); + connect(&client, &ApiClient::quizzesReceived, this, &MainWindow::onQuizzesReceived); + connect(&client, &ApiClient::quizReceived, this, &MainWindow::onQuizReceived); + connect(&client, &ApiClient::quizCreated, this, &MainWindow::onQuizCreated); + connect(&client, &ApiClient::quizUpdated, this, &MainWindow::onQuizUpdated); + connect(&client, &ApiClient::quizDeleted, this, &MainWindow::onQuizDeleted); + connect(&client, &ApiClient::errorOccurred, this, &MainWindow::onError); +} + +MainWindow::~MainWindow() +{ + delete ui; +} + +void MainWindow::setupTable() +{ + ui->tableWidget->setColumnCount(COL_COUNT); + ui->tableWidget->setHorizontalHeaderLabels({ + "ID", "Название", "Автор", "Время (мин)", "Опубликован", "Создан", "Описание" + }); + + QHeaderView *header = ui->tableWidget->horizontalHeader(); + header->setSectionResizeMode(COL_ID, QHeaderView::ResizeToContents); + header->setSectionResizeMode(COL_TITLE, QHeaderView::Stretch); + header->setSectionResizeMode(COL_AUTHOR, QHeaderView::ResizeToContents); + header->setSectionResizeMode(COL_TIME, QHeaderView::ResizeToContents); + header->setSectionResizeMode(COL_PUBLISHED, QHeaderView::ResizeToContents); + header->setSectionResizeMode(COL_CREATED, QHeaderView::ResizeToContents); + header->setSectionResizeMode(COL_DESCRIPTION, QHeaderView::Stretch); + + ui->tableWidget->verticalHeader()->setVisible(false); + ui->tableWidget->setWordWrap(true); + ui->tableWidget->setTextElideMode(Qt::ElideNone); + ui->tableWidget->verticalHeader()->setSectionResizeMode(QHeaderView::Interactive); + ui->tableWidget->verticalHeader()->setDefaultSectionSize(52); + ui->tableWidget->setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOn); + ui->tableWidget->setHorizontalScrollBarPolicy(Qt::ScrollBarAsNeeded); +} + +// ── Обработчики кнопок ──────────────────────────────────────────────────────── + +void MainWindow::on_btnGetAll_clicked() +{ + ++m_requestCount; + setStatus(QString("GET /api/quiz/… | Запросов: %1").arg(m_requestCount)); + ApiClient::instance().fetchQuizzes(); +} + +void MainWindow::on_btnGetById_clicked() +{ + bool ok; + int id = QInputDialog::getInt(this, "Get Quiz by ID", "ID теста:", 1, 1, 99999, 1, &ok); + if (!ok) return; + ++m_requestCount; + setStatus(QString("GET /api/quiz/%1/… | Запросов: %2").arg(id).arg(m_requestCount)); + ApiClient::instance().fetchQuiz(id); +} + +void MainWindow::on_btnCreate_clicked() +{ + QDialog dlg(this); + dlg.setWindowTitle("Создать тест — POST /api/quiz/"); + dlg.setMinimumWidth(400); + + QLineEdit *titleEdit = new QLineEdit(&dlg); + QTextEdit *descEdit = new QTextEdit(&dlg); + QLineEdit *authorEdit = new QLineEdit(&dlg); + QSpinBox *timeLimitSpin = new QSpinBox(&dlg); + QCheckBox *publishedCheck = new QCheckBox("Опубликован", &dlg); + + descEdit->setMaximumHeight(80); + timeLimitSpin->setRange(1, 600); + timeLimitSpin->setValue(30); + timeLimitSpin->setSuffix(" мин"); + + { + auto *layout = new QVBoxLayout(); + auto *form = new QFormLayout(); + form->addRow("Название:", titleEdit); + form->addRow("Описание:", descEdit); + form->addRow("Автор:", authorEdit); + form->addRow("Время:", timeLimitSpin); + form->addRow("", publishedCheck); + layout->addLayout(form); + auto *buttons = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel); + connect(buttons, &QDialogButtonBox::accepted, &dlg, &QDialog::accept); + connect(buttons, &QDialogButtonBox::rejected, &dlg, &QDialog::reject); + layout->addWidget(buttons); + dlg.setLayout(layout); + } + + if (dlg.exec() != QDialog::Accepted) return; + + QJsonObject data; + data["title"] = titleEdit->text(); + data["description"] = descEdit->toPlainText(); + data["author"] = authorEdit->text(); + data["time_limit"] = timeLimitSpin->value(); + data["is_published"] = publishedCheck->isChecked(); + + ++m_requestCount; + setStatus(QString("POST /api/quiz/… | Запросов: %1").arg(m_requestCount)); + ApiClient::instance().createQuiz(data); +} + +void MainWindow::on_btnUpdate_clicked() +{ + bool ok; + int id = QInputDialog::getInt(this, "Update Quiz", "ID теста для обновления:", 1, 1, 99999, 1, &ok); + if (!ok) return; + + QDialog dlg(this); + dlg.setWindowTitle(QString("Обновить тест ID=%1 — PUT /api/quiz/%1/").arg(id)); + dlg.setMinimumWidth(400); + + QLineEdit *titleEdit = new QLineEdit(&dlg); + QTextEdit *descEdit = new QTextEdit(&dlg); + QLineEdit *authorEdit = new QLineEdit(&dlg); + QSpinBox *timeLimitSpin = new QSpinBox(&dlg); + QCheckBox *publishedCheck = new QCheckBox("Опубликован", &dlg); + + descEdit->setMaximumHeight(80); + timeLimitSpin->setRange(1, 600); + timeLimitSpin->setValue(30); + timeLimitSpin->setSuffix(" мин"); + + { + auto *layout = new QVBoxLayout(); + auto *form = new QFormLayout(); + form->addRow("Название:", titleEdit); + form->addRow("Описание:", descEdit); + form->addRow("Автор:", authorEdit); + form->addRow("Время:", timeLimitSpin); + form->addRow("", publishedCheck); + layout->addLayout(form); + auto *buttons = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel); + connect(buttons, &QDialogButtonBox::accepted, &dlg, &QDialog::accept); + connect(buttons, &QDialogButtonBox::rejected, &dlg, &QDialog::reject); + layout->addWidget(buttons); + dlg.setLayout(layout); + } + + if (dlg.exec() != QDialog::Accepted) return; + + QJsonObject data; + data["title"] = titleEdit->text(); + data["description"] = descEdit->toPlainText(); + data["author"] = authorEdit->text(); + data["time_limit"] = timeLimitSpin->value(); + data["is_published"] = publishedCheck->isChecked(); + + ++m_requestCount; + setStatus(QString("PUT /api/quiz/%1/… | Запросов: %2").arg(id).arg(m_requestCount)); + ApiClient::instance().updateQuiz(id, data); +} + +void MainWindow::on_btnDelete_clicked() +{ + bool ok; + int id = QInputDialog::getInt(this, "Delete Quiz", "ID теста для удаления:", 1, 1, 99999, 1, &ok); + if (!ok) return; + ++m_requestCount; + setStatus(QString("DELETE /api/quiz/%1/… | Запросов: %2").arg(id).arg(m_requestCount)); + ApiClient::instance().deleteQuiz(id); +} + +void MainWindow::on_btnClear_clicked() +{ + ui->tableWidget->setRowCount(0); + ui->outputEdit->clear(); + ui->lblStatus->setText("Результат:"); + setStatus("Очищено"); +} + +// ── Слоты ответов ApiClient ─────────────────────────────────────────────────── + +void MainWindow::onQuizzesReceived(const QList &quizzes) +{ + showQuizzes(quizzes); + + ui->outputEdit->clear(); + ui->outputEdit->append(QString("Тестов получено: %1").arg(quizzes.size())); + for (const Quiz &q : quizzes) { + ui->outputEdit->append("─────────────────────────────"); + appendQuizText(q); + } + + ui->lblStatus->setText(QString("Результат: получено тестов: %1").arg(quizzes.size())); + setStatus(QString("Готово — получено %1 тестов | Запросов: %2").arg(quizzes.size()).arg(m_requestCount)); + qDebug() << "[GET ALL] count:" << quizzes.size(); +} + +void MainWindow::onQuizReceived(const Quiz &q) +{ + showSingleQuiz(q, "GET"); + + ui->outputEdit->clear(); + ui->outputEdit->append("GET — тест по ID"); + ui->outputEdit->append("─────────────────────────────"); + appendQuizText(q); + + ui->lblStatus->setText(QString("Результат: тест ID=%1").arg(q.id)); + setStatus(QString("Готово — тест ID=%1 | Запросов: %2").arg(q.id).arg(m_requestCount)); + qDebug() << "[GET] ID:" << q.id << q.title; +} + +void MainWindow::onQuizCreated(const Quiz &q) +{ + showSingleQuiz(q, "POST"); + + ui->outputEdit->clear(); + ui->outputEdit->append("POST — тест создан"); + ui->outputEdit->append("─────────────────────────────"); + appendQuizText(q); + + ui->lblStatus->setText(QString("Результат: тест создан, ID=%1").arg(q.id)); + setStatus(QString("Тест создан, ID=%1 | Запросов: %2").arg(q.id).arg(m_requestCount)); + qDebug() << "[CREATE] ID:" << q.id << q.title; +} + +void MainWindow::onQuizUpdated(const Quiz &q) +{ + showSingleQuiz(q, "PUT"); + + ui->outputEdit->clear(); + ui->outputEdit->append("PUT — тест обновлён"); + ui->outputEdit->append("─────────────────────────────"); + appendQuizText(q); + + ui->lblStatus->setText(QString("Результат: тест обновлён, ID=%1").arg(q.id)); + setStatus(QString("Тест обновлён, ID=%1 | Запросов: %2").arg(q.id).arg(m_requestCount)); + qDebug() << "[UPDATE] ID:" << q.id << q.title; +} + +void MainWindow::onQuizDeleted(const QString &message) +{ + ui->tableWidget->setRowCount(0); + ui->outputEdit->clear(); + ui->outputEdit->append("DELETE — успешно"); + ui->outputEdit->append(message); + + ui->lblStatus->setText(QString("Результат: %1").arg(message)); + setStatus(QString("Тест удалён | Запросов: %1").arg(m_requestCount)); + qDebug() << "[DELETE]" << message; +} + +void MainWindow::onError(const QString &error) +{ + ui->outputEdit->append( + QString("Ошибка: %1").arg(error)); + ui->lblStatus->setText(QString("Результат: Ошибка: %1").arg(error)); + setStatus(QString("Ошибка | Запросов: %1").arg(m_requestCount)); + qDebug() << "[ERROR]" << error; +} + +// ── Вспомогательные методы ──────────────────────────────────────────────────── + +void MainWindow::showQuizzes(const QList &quizzes) +{ + ui->tableWidget->setRowCount(0); + ui->tableWidget->setRowCount(quizzes.size()); + + for (int i = 0; i < quizzes.size(); ++i) { + const Quiz &q = quizzes[i]; + + QColor rowColor = q.isPublished ? QColor("#052e16") : QColor("#1f2937"); + + auto makeItem = [&](const QString &text) { + auto *item = new QTableWidgetItem(text); + item->setBackground(rowColor); + item->setForeground(QColor("#f9fafb")); + item->setTextAlignment(Qt::AlignCenter); + return item; + }; + + ui->tableWidget->setItem(i, COL_ID, makeItem(QString::number(q.id))); + ui->tableWidget->setItem(i, COL_TITLE, makeItem(q.title)); + ui->tableWidget->setItem(i, COL_AUTHOR, makeItem(q.author)); + ui->tableWidget->setItem(i, COL_TIME, makeItem(QString::number(q.timeLimit))); + ui->tableWidget->setItem(i, COL_DESCRIPTION, makeItem(q.description)); + ui->tableWidget->setItem(i, COL_CREATED, makeItem(q.createdAt.toString("dd.MM.yyyy"))); + + auto *pubItem = new QTableWidgetItem(q.isPublished ? "Да" : "Нет"); + pubItem->setBackground(rowColor); + pubItem->setForeground(q.isPublished ? QColor("#4ade80") : QColor("#9ca3af")); + pubItem->setTextAlignment(Qt::AlignCenter); + QFont f = pubItem->font(); + f.setBold(true); + pubItem->setFont(f); + ui->tableWidget->setItem(i, COL_PUBLISHED, pubItem); + } +} + +void MainWindow::showSingleQuiz(const Quiz &q, const QString &operation) +{ + ui->tableWidget->setRowCount(1); + + QColor rowColor = QColor("#1f2937"); + if (operation == "POST") rowColor = QColor("#052e16"); + else if (operation == "PUT") rowColor = QColor("#422006"); + + auto makeItem = [&](const QString &text) { + auto *item = new QTableWidgetItem(text); + item->setBackground(rowColor); + item->setForeground(QColor("#f9fafb")); + item->setTextAlignment(Qt::AlignCenter); + return item; + }; + + ui->tableWidget->setItem(0, COL_ID, makeItem(QString::number(q.id))); + ui->tableWidget->setItem(0, COL_TITLE, makeItem(q.title)); + ui->tableWidget->setItem(0, COL_AUTHOR, makeItem(q.author)); + ui->tableWidget->setItem(0, COL_TIME, makeItem(QString::number(q.timeLimit))); + ui->tableWidget->setItem(0, COL_DESCRIPTION, makeItem(q.description)); + ui->tableWidget->setItem(0, COL_CREATED, makeItem(q.createdAt.toString("dd.MM.yyyy"))); + + auto *pubItem = new QTableWidgetItem(q.isPublished ? "Да" : "Нет"); + pubItem->setBackground(rowColor); + pubItem->setForeground(q.isPublished ? QColor("#4ade80") : QColor("#9ca3af")); + pubItem->setTextAlignment(Qt::AlignCenter); + QFont f = pubItem->font(); + f.setBold(true); + pubItem->setFont(f); + ui->tableWidget->setItem(0, COL_PUBLISHED, pubItem); +} + +void MainWindow::appendQuizText(const Quiz &q) +{ + ui->outputEdit->append(QString( + "ID: %1\n" + "Название: %2\n" + "Описание: %3\n" + "Автор: %4\n" + "Создан: %5\n" + "Время (мин): %6\n" + "Опубликован: %7" + ).arg(q.id) + .arg(q.title) + .arg(q.description) + .arg(q.author) + .arg(q.createdAt.toString(Qt::ISODate)) + .arg(q.timeLimit) + .arg(q.isPublished ? "Да" : "Нет")); +} + +void MainWindow::setStatus(const QString &msg) +{ + statusBar()->showMessage(msg); +} diff --git a/lab-3/mainwindow.h b/lab-3/mainwindow.h new file mode 100644 index 0000000..1a3506f --- /dev/null +++ b/lab-3/mainwindow.h @@ -0,0 +1,46 @@ +#ifndef MAINWINDOW_H +#define MAINWINDOW_H + +#include +#include +#include "quiz.h" + +QT_BEGIN_NAMESPACE +namespace Ui { class MainWindow; } +QT_END_NAMESPACE + +class MainWindow : public QMainWindow +{ + Q_OBJECT + +public: + explicit MainWindow(QWidget *parent = nullptr); + ~MainWindow() override; + +private slots: + void on_btnGetAll_clicked(); + void on_btnGetById_clicked(); + void on_btnCreate_clicked(); + void on_btnUpdate_clicked(); + void on_btnDelete_clicked(); + void on_btnClear_clicked(); + + void onQuizzesReceived(const QList &quizzes); + void onQuizReceived(const Quiz &quiz); + void onQuizCreated(const Quiz &quiz); + void onQuizUpdated(const Quiz &quiz); + void onQuizDeleted(const QString &message); + void onError(const QString &error); + +private: + void setupTable(); + void showQuizzes(const QList &quizzes); + void showSingleQuiz(const Quiz &q, const QString &operation); + void appendQuizText(const Quiz &q); + void setStatus(const QString &msg); + + Ui::MainWindow *ui; + int m_requestCount = 0; +}; + +#endif // MAINWINDOW_H diff --git a/lab-3/mainwindow.ui b/lab-3/mainwindow.ui new file mode 100644 index 0000000..57cd9b6 --- /dev/null +++ b/lab-3/mainwindow.ui @@ -0,0 +1,157 @@ + + + MainWindow + + + + 0 + 0 + 1000 + 620 + + + + Quiz Client — ЛР3 + + + + + 8 + + + + + 6 + + + + 048 + Все тесты +GET /api/quiz/ + QPushButton { background:#2d4a6b; color:#bfdbfe; border-radius:6px; font-weight:bold; border:1px solid #3b5f8a; } +QPushButton:hover { background:#3b5f8a; } + + + + + 048 + По ID +GET /api/quiz/:id/ + QPushButton { background:#2d4a6b; color:#bfdbfe; border-radius:6px; font-weight:bold; border:1px solid #3b5f8a; } +QPushButton:hover { background:#3b5f8a; } + + + + + 048 + Создать +POST /api/quiz/ + QPushButton { background:#1a3d2b; color:#86efac; border-radius:6px; font-weight:bold; border:1px solid #256040; } +QPushButton:hover { background:#256040; } + + + + + 048 + Обновить +PUT /api/quiz/:id/ + QPushButton { background:#3d2e0a; color:#fcd34d; border-radius:6px; font-weight:bold; border:1px solid #5a4210; } +QPushButton:hover { background:#5a4210; } + + + + + 048 + Удалить +DELETE /api/quiz/:id/ + QPushButton { background:#4a1a1a; color:#fca5a5; border-radius:6px; font-weight:bold; border:1px solid #6b2424; } +QPushButton:hover { background:#6b2424; } + + + + + 048 + Очистить + QPushButton { background:#2d3748; color:#9ca3af; border-radius:6px; border:1px solid #4b5563; font-weight:bold; } +QPushButton:hover { background:#374151; } + + + + + + + <b>Результат:</b> + + + + + 0 + QTabWidget::TabPosition::North + false + QTabWidget::pane { border: 1px solid #374151; border-radius: 0 4px 4px 4px; } +QTabBar::tab { background:#1f2937; color:#9ca3af; padding:6px 20px; border: 1px solid #374151; border-bottom: none; border-radius: 4px 4px 0 0; font-weight:bold; } +QTabBar::tab:selected { background:#111827; color:#f9fafb; border-color:#4b5563; } +QTabBar::tab:hover { background:#374151; color:#e5e7eb; } + + Таблица + + 0 + 0 + 0 + 0 + 0 + + + QAbstractItemView::SelectionBehavior::SelectRows + QAbstractItemView::SelectionMode::SingleSelection + QAbstractItemView::EditTrigger::NoEditTriggers + true + QTableWidget { gridline-color: #374151; border: none; color: #f9fafb; background: #111827; alternate-background-color: #1f2937; } +QHeaderView::section { background: #1f2937; color: #e5e7eb; font-weight: bold; padding: 6px 4px; border: none; border-bottom: 2px solid #4b5563; border-right: 1px solid #374151; } +QScrollBar:vertical { background: #1f2937; width: 10px; border-radius: 5px; } +QScrollBar::handle:vertical { background: #4b5563; border-radius: 5px; min-height: 20px; } +QScrollBar::handle:vertical:hover { background: #6b7280; } +QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical { height: 0px; } +QScrollBar:horizontal { background: #1f2937; height: 10px; border-radius: 5px; } +QScrollBar::handle:horizontal { background: #4b5563; border-radius: 5px; min-width: 20px; } +QScrollBar::handle:horizontal:hover { background: #6b7280; } +QScrollBar::add-line:horizontal, QScrollBar::sub-line:horizontal { width: 0px; } + + + + + + Текст + + 0 + 0 + 0 + 0 + 0 + + + true + + + Courier New + 10 + + + + + + + + + + + + + 00100021 + + + + + + + diff --git a/lab-3/quiz.h b/lab-3/quiz.h new file mode 100644 index 0000000..0f06743 --- /dev/null +++ b/lab-3/quiz.h @@ -0,0 +1,57 @@ +/* + * quiz.h — Класс модели данных + * + * Представляет одну запись о тесте/викторине, полученную с сервера. + * Поля напрямую соответствуют JSON-ключам из ответа REST API: + * + * { + * "id": 1, + * "title": "Математика 5 класс", + * "description": "Тест по базовой математике", + * "author": "Иванов И.И.", + * "created_at": "2026-04-02T11:20:40.123456Z", + * "time_limit": 40, + * "is_published": false + * } + * + * Этот класс — «правая сторона» адаптера: адаптер принимает + * на вход QJsonObject (сырой формат Qt) и заполняет объект Quiz + * (понятный формат нашего приложения). + */ + +#pragma once + +#include // Qt-строка с поддержкой Unicode +#include // дата и время с поддержкой ISO 8601 + +class Quiz +{ +public: + /* + * Конструктор по умолчанию, сгенерированный компилятором. + * Числовые поля инициализированы прямо в объявлении (= 0 / = false), + * строки и даты инициализируются в пустое значение по умолчанию. + */ + Quiz() = default; + + // Первичный ключ записи в базе данных сервера + int id = 0; + + // Название теста, например "Математика 5 класс" + QString title; + + // Подробное описание теста + QString description; + + // Автор теста, например "Иванов И.И." + QString author; + + // Временная метка создания записи (заполняется сервером автоматически) + QDateTime createdAt; + + // Ограничение по времени прохождения в минутах + int timeLimit = 0; + + // Признак публикации: true — тест виден пользователям + bool isPublished = false; +}; diff --git a/lab-3/quiz_json_adapter.cpp b/lab-3/quiz_json_adapter.cpp new file mode 100644 index 0000000..1d9654e --- /dev/null +++ b/lab-3/quiz_json_adapter.cpp @@ -0,0 +1,109 @@ +/* + * quiz_json_adapter.cpp — Реализация десериализации JSON → Quiz + * + * Весь процесс разбора сырых байт от сервера: + * + * reply->readAll() → QByteArray (бинарный буфер HTTP-ответа) + * ↓ QJsonDocument::fromJson() + * QJsonDocument (распарсенный JSON-документ) + * ↓ .array() или .object() + * QJsonArray / QJsonObject (узлы дерева JSON) + * ↓ toQuizList() / toQuiz() ← этот файл + * QList / Quiz (C++-объекты нашей модели) + * + * QJsonValue — универсальный тип, который может содержать + * число, строку, bool, массив, объект или null. + * Операция json["ключ"] всегда возвращает QJsonValue; + * затем вызывается один из методов-преобразователей: + * toInt(), toDouble(), toString(), toBool(), toObject(), toArray() + */ + +#include "quiz_json_adapter.h" +#include // нужен для работы с элементами QJsonArray в цикле + +// ───────────────────────────────────────────────────────────────────────────── +// toQuiz — десериализация одного объекта +// ───────────────────────────────────────────────────────────────────────────── + +Quiz QuizJsonAdapter::toQuiz(const QJsonObject &json) const +{ + /* + * Создаём пустой Quiz; все числа = 0, строки = "", bool = false. + * Поля заполняются по одному из соответствующих JSON-ключей. + */ + Quiz q; + + /* + * json["id"] возвращает QJsonValue с типом Double (JSON не различает + * целое и вещественное). toInt() округляет до ближайшего целого. + */ + q.id = json["id"].toInt(); + + /* + * toString() возвращает QString. Если ключ отсутствует или тип не строка — + * вернётся пустая строка QString(), что безопасно. + */ + q.title = json["title"].toString(); + q.description = json["description"].toString(); + q.author = json["author"].toString(); + + /* + * Дата в JSON приходит строкой в формате ISO 8601: + * "2026-04-02T11:20:40.123456Z" + * + * Qt::ISODateWithMs — формат, совместимый с Django DateTimeField, + * он понимает как микросекунды, так и часовой пояс Z (UTC). + * + * Если строка пустая или формат не совпадает, QDateTime будет + * «недействительным» (isValid() == false) — без краша. + */ + q.createdAt = QDateTime::fromString(json["created_at"].toString(), + Qt::ISODateWithMs); + + /* + * toInt(): JSON "time_limit": 40 → C++ int. + */ + q.timeLimit = json["time_limit"].toInt(); + + /* + * toBool(): JSON "is_published": true/false → C++ bool. + * Если ключ отсутствует, вернётся false (значение по умолчанию). + */ + q.isPublished = json["is_published"].toBool(); + + return q; // возврат по значению; компилятор применит NRVO (copy elision) +} + +// ───────────────────────────────────────────────────────────────────────────── +// toQuizList — десериализация массива объектов +// ───────────────────────────────────────────────────────────────────────────── + +QList QuizJsonAdapter::toQuizList(const QJsonArray &json) const +{ + QList list; + + /* + * reserve() выделяет память под json.size() элементов заранее, + * чтобы не было многократных реаллокаций при append(). + */ + list.reserve(json.size()); + + /* + * Итерация по QJsonArray. Каждый элемент — QJsonValue, + * который может быть объектом, числом, строкой, null и т.д. + * Проверяем isObject() на случай, если сервер вернул + * смешанный массив или null-элементы. + */ + for (const QJsonValue &value : json) { + if (value.isObject()) { + /* + * toObject() приводит QJsonValue к QJsonObject, + * который затем передаётся в toQuiz() — переиспользуем + * логику одиночного парсинга. + */ + list.append(toQuiz(value.toObject())); + } + } + + return list; +} diff --git a/lab-3/quiz_json_adapter.h b/lab-3/quiz_json_adapter.h new file mode 100644 index 0000000..ca545bc --- /dev/null +++ b/lab-3/quiz_json_adapter.h @@ -0,0 +1,33 @@ +/* + * quiz_json_adapter.h — Конкретный адаптер (Adapter) паттерна «Адаптер» + * + * QuizJsonAdapter — это «переводчик» между двумя несовместимыми + * интерфейсами: + * + * Adaptee (что есть): QJsonObject / QJsonArray — формат Qt-библиотеки + * Target (что нужно): IQuizAdapter — интерфейс нашего приложения + * + * Именно здесь выполняется десериализация: каждое поле QJsonObject + * извлекается по строковому ключу и записывается в соответствующее + * поле C++-объекта Quiz. + * + * Зависимость на уровне заголовков: QuizJsonAdapter наследует + * IQuizAdapter, значит нужен только этот include — всё остальное + * (QJsonObject, QJsonArray, Quiz) уже включено транзитивно. + */ + +#pragma once + +#include "iquiz_adapter.h" + +class QuizJsonAdapter : public IQuizAdapter +{ +public: + /* + * override явно указывает компилятору, что метод переопределяет + * виртуальный метод базового класса. Если сигнатура вдруг не совпадёт — + * получим ошибку компиляции, а не молчаливый баг. + */ + Quiz toQuiz (const QJsonObject &json) const override; + QList toQuizList(const QJsonArray &json) const override; +}; diff --git a/lab-3/resources.qrc b/lab-3/resources.qrc new file mode 100644 index 0000000..94bf543 --- /dev/null +++ b/lab-3/resources.qrc @@ -0,0 +1,5 @@ + + + icons/app.svg + +