3.7. Проект «Обработка запроса с интерфейса»
- Проект
- Контекст
- Подсказки
- Разбор
- Подход к задаче
- Шаг 1: считываем данные из файлов
- Шаг 2: создаём вспомогательный словарь для быстрого поиска нужных данных
- Шаг 3: формируем информацию о user
- Шаг 4: обрабатываем всю информацию о тестах
- Шаг 5: собираем данные для progress и test в JSON
- Шаг 6: собираем данные для JSON и его вывода
- Тесты для задачи
- Итог
Вы уже изучили все темы раздела и сейчас подходите к заключительному заданию. Эта задача похожа на те, что решают Python-разработчики на практике. Она поможет вам повторить пройденный материал и применить полученные знания.
Задача состоит из нескольких блоков:
- Контекст. В реальной жизни задача не существует сама по себе, а возникает из определённого бизнес-контекста. Поэтому мы добавили небольшое описание трансформации задачи, от первого запроса до её финального вида.
- Подсказки. Помощь в решении, если зайдёте в тупик. Комментарии к ключевым моментам решения задачи в формате FAQ.
- Разбор. Подробный разбор задачи от автора, специалиста из Яндекса: от составления алгоритма решения до реализации и пояснения каждого блока кода.
Важно
Эта задача «со звёздочкой», она посложнее предыдущих. Если не справитесь — ничего страшного: в программировании редко что-то получается с первого раза. Сделайте паузу, перечитайте теорию и возвращайтесь с новыми силами и хорошим настроением.
Перед решением задачи
Повторите:
- Строки, кортежи, списки.
- Словари.
- Работу с текстовыми файлами, формат JSON.
Изучите дополнительно:
- Работу с текстовыми файлами в формате CSV.
- Работу с датами и временем с использованием типа данных
datetime. - Базу данных. Основы устройства.
И ещё один совет напоследок: рекомендуем работать с задачей последовательно.
- Изучите контекст.
- Ознакомьтесь с условием задачи.
- Попробуйте решить её самостоятельно.
- Если понадобится помощь — обратитесь к подсказкам. Если совсем тяжко — изучите разбор и попробуйте после самостоятельно решить.
- Когда решите задачу, сравните свой вариант с решением от специалиста.
Проект

Максим Ломакин
Тимлид в Яндекс Учебнике
Привет! Я Максим, разработчик в Яндекс Учебнике. Ниже мы с вами создадим симуляцию простого веб-приложения, задача которого — помочь учителю увидеть статус решения учениками тестов по конкретному предмету. Вы примете запрос, обработаете его, запросите необходимые данные из базы данных и вернёте обратно ответ в формате JSON. Удачи!
Контекст
Взаимодействие между фронтендом и бэкендом
Вы уже прошли базовые конструкции в виде циклов, списков/кортежей, множеств. Этих знаний достаточно, чтобы собрать часть веб-приложения — ту, что отвечает за обработку запросов. Ниже мы разберём, каким образом его собрать и в чём заключается роль бэкенд-разработчика.
Итак, мы разрабатываем сервис для подготовки к ЕГЭ. В нём учителя смогут набирать себе группы, раздавать тесты, проводить занятия. А ученики — изучать материал, решать тесты, посещать занятия.
Чтобы учитель мог удобнее работать, у него есть личный кабинет. Это раздел, в котором можно получать всю необходимую информацию, — дашборд.
Давайте посмотрим на примере этой одной конкретной фичи, как устроены веб-приложения. В общем виде схема выглядит так:
Такую схему ещё называют «трёхуровневая архитектура веб-приложения». Она самая распространённая. В ней каждая часть отвечает за свою конкретную задачу:
- интерфейс/клиент — за взаимодействие с пользователем;
- веб-сервер — за бизнес-логику работы приложения;
- базы данных — за хранение данных.
Примечание
Видов архитектур веб-приложений много, у них разные плюсы и минусы, подробно останавливаться на них сейчас не будем: выбором архитектуры занимаются разработчики уровня senior и выше. Так что эта информация для общего контекста и расширения кругозора — чтобы вы знали, какие вещи стоит прокачивать в будущем.
Далее разберём на нашем примере, как пошагово работает наше приложение.
Когда учитель открывает кабинет, браузер сразу рисует красивую «витрину»: кнопки, таблицы, графики. То, что видит пользователь на экране и с чем может взаимодействовать, называется фронтенд-частью веб-приложения. Ещё её называют «интерфейс», «клиент», UI и так далее. Разработкой этой части, как нетрудно догадаться, занимаются фронтенд-разработчики.
Как только странице требуются данные — например, список учеников или список выданных ученикам тестов, — она отправляет текстовое сообщение на сервер и ждёт ответа. В этот момент страница может выглядеть так:
Часть веб-приложения, которая получает и обрабатывает запрос, называется бэкенд-частью. Обычно бэкенд состоит из веб-сервера (обработчика запроса) и базы данных (из которой собираются данные).
На Python вы напишете веб-сервер для обработки запроса. Сначала он примет его и разберёт, что именно просит пользователь и какие поля из базы данных нужно вернуть, в каком виде их удобнее отдать. Разобравшись, ваш веб-сервер формирует запрос к базе данных.
База данных — структурированное хранилище данных и связей между ними, позволяющее быстро найти нужные данные. Сама база данных может выглядеть по-разному. Чаще всего это привычные SQL-таблицы — надёжные, быстрые, с богатым языком запросов. Но иногда хватает книги Excel или CSV-файла, если данных мало и их нужно легко открыть вручную.
В других случаях удобнее хранить целые документы в формате JSON или использовать хранилище пар «ключ — значение». Важно лишь, что Python-программа умеет обратиться к любому из этих источников и вытащить ровно то, что попросил интерфейс.
Получив записи, сервер при необходимости сразу делает расчёты — скажем, подсчитывает средний балл по тесту — и упаковывает результат в ответ. Чаще всего используется JSON: его одинаково хорошо читают и человек, и браузер. Но для простых таблиц сгодится CSV. Выбор зависит от того, что проще для интерфейса и быстрее для сервера.
Затем интерфейс забирает данные и выводит их пользователю:
Как видите, получается реализованная цепочка: интерфейс → сервер → база данных → сервер → интерфейс. При этом сервер может обращаться к разным базам данных, и тогда цепочка взаимодействия удлинится.
Эту цепочку вы и реализуете в рамках задачи: примете запрос, решите, какие данные нужны и в каком виде, достанете из базы, посчитаете необходимые параметры и вернёте аккуратный JSON. После этого интерфейс наполнится реальными цифрами, а вы увидите, как ваш код становится частью живого веб-приложения.
Примечание
Не переживайте, если формулировка кажется сложной: для решения задачи будет достаточно текущих знаний. А если зайдёте в тупик, мы подготовили подсказки — они направят вас в нужную сторону.
Формулировка задачи
К вам пришёл продакт-менеджер и поставил задачу: подготовить обработку запросов от фронтенда для кабинета учителя. А конкретно — для экрана, представленного ниже.
Учитель выбирает ученика из класса, и на экран подтягивается статистика по всем выданным и пройденным тестам и результатам их прохождения. В формате запроса вы получаете параметры ученика, а на выходе возвращаете JSON со всеми необходимыми данными. Данные хранятся в небольшой базе данных, собранной из девяти CSV-файлов.
Чтобы не заниматься сразу большой задачей, мы разбили её на несколько последовательных подготовительных задач. Благодаря этому вы шаг за шагом придёте к финальному решению основной задачи, используя наработки.
Ниже мы собрали более подробную информацию об устройстве базы данных и несколько новых небольших инструментов, которые помогут справиться с задачей.
Структура базы данных
База данных — это структурированное хранилище информации, в котором можно быстро найти всё необходимое, и связей между данными. Как мы сказали выше, информация в рамках задачи лежит в базе данных, состоящей из девяти файлов формата CSV.
CSV-файл — это обычный текстовый файл, в котором данные хранятся в виде таблицы, но не как таблица в Excel, а в виде строк текста, где значения разделяются запятыми (или другими символами), например:
Имя,Возраст,Город
Алиса,30,Москва
Кирилл,25,Санкт-Петербург
Максим,28,Новосибирск
Данные выглядят как таблица, но это просто текст. Если вы откроете такой файл в блокноте, вы увидите именно это.
Структура файла следующая:
- Каждая строка — это одна строка таблицы.
- Каждое значение в строке — это ячейка, отделённая запятой.
- Первая строка обычно содержит заголовки столбцов (названия).
Содержимое и форматы данных (изначально все данные в строковом формате, но при желании их можно перевести) в каждой из девяти таблиц описаны на картинке ниже. Здесь можно скачать картинку в полном разрешении.
-
Организационный блок (информация о пользователях и их связи):
— user.csv — личная информация об учениках и учителях
— class.csv — перечень классов и ответственных за них учителей
— class_user_link.csv — список, кто и где из учеников учится -
Блок тестовых заданий (тесты, задачи, назначение задач конкретным классам):
— task.csv — перечень задач для тестов
— test.csv — перечень тестов на платформе
— test_task_link.csv — список задач в каждом тесте
— test_class_user_link.csv — список, когда и кому из учеников выдали определённый тест -
Попытки (успехи учеников в прохождении тестов и конкретных задач):
— test_attempt.csv — статус прохождения тестов учениками
— task_attempt.csv — статус прохождения конкретных задач в тесте
Давайте разберёмся, что обозначают конкретные данные в таблицах и что это за стрелочки.
Для примера возьмём оранжево-красный блок базы, посвящённый организации, и изучим подробнее таблицу user.csv.
В каждой таблице:
- Есть описание, что в ней лежит. В user.csv лежит личная информация об учениках и учителях на платформе.
- Есть два типа хранимых данных:
id(уникальный номер строчки для этой таблицы) и данные для этого уникального номера. В нашем файле лежат: роль пользователя, Ф. И. О., электронная почта, номер телефона, хобби, дата создания записи и дата последнего обновления. - Для каждого вида данных есть пояснение.
Если открыть файл user.csv в текстовом редакторе, то он будет выглядеть следующим образом. Каждая строчка отдельно посвящена уникальному ученику или учителю. А через запятую будут раскрываться все данные. При этом если какой-то параметр пустой, то он всё равно будет отделяться запятыми, вот так: «… ,, …».
Примечание
Посмотрите, какие данные с картинки выше лежат в таблице user.csv, и соотнесите с данными в блоке ниже. Убедитесь, что все данные представлены.
id,role,first_name,middle_name,last_name,email,phone_number,hobbies
1,ученик,Иван,Сергеевич,Иванов,ivan.ivanov@gmail.com,79406232030,"""Шахматы, чтение"""
2,ученик,Мария,Владимировна,Кузнецова,maria.kuzne@mail.ru,79570046302,"""Велоспорт, футбол"""
3,ученик,Дмитрий,Алексеевич,Петров,dmitry.petrov@yandex.ru,79577196864,"""Готовка, йога"""
4,ученик,Анна,Игоревна,Смирнова,anna.smirnova@mail.ru,79423414800,"""Рисование, путешествия"""
5,ученик,Максим,Константинович,Орлов,maxim.orlov@gmail.com,79278896575,"""Программирование, настольные игры"""
6,ученик,Елена,Павловна,Сидорова,elena.sidorova@yandex.ru,79727323534,"""Музыка, хоккей"""
7,ученик,Сергей,Викторович,Волков,sergey.volkov@mail.ru,79706756236,"""Коллекционирование монет, бег"""
8,ученик,Ольга,Андреевна,Ефимова,olga.efimova@gmail.com,79762121582,"""Пение, кулинария"""
9,ученик,Пётр,Александрович,Миронов,pyotr.mironov@yandex.ru,79962408347,"""Велоспорт, музыка"""
10,ученик,Татьяна,Дмитриевна,Быстрова,tatiana.bystrova@mail.ru,79735833222,"""Шахматы, йога"""
11,ученик,Андрей,Сергеевич,Зуев,andrey.zuev@mail.ru,79490453580,"""Программирование, путешествия"""
12,ученик,Анастасия,Петровна,Фёдорова,anastasia.fedorova@gmail.com,79463931354,"""Рисование, бег"""
13,ученик,Владимир,Андреевич,Крылов,vladimir.krylov@yandex.ru,79966267329,"""Готовка, футбол"""
14,ученик,Светлана,Викторовна,Соловьёва,svetlana.solovieva@mail.ru,79932933495,"""Велоспорт, кулинария"""
15,ученик,Константин,Дмитриевич,Гусев,konstantin.gusev@gmail.com,79793427898,Музыка, шахматы"""
Ещё между таблицами есть различные стрелки. Они отображают связи. Давайте посмотрим на таблицу class.csv. К owner_id идёт стрелочка из id таблицы user.csv. Это значит, что данные в owner_id содержат конкретные id из user.csv.
Разберём на конкретном примере. Откроем файл class.csv и посмотрим значение owner_id у первой строчки с данными. Оно будет равно 96. Это значит, что если мы хотим посмотреть, кто является ответственным за класс, то нужно будет сходить в таблицу user.csv и найти строчку с полем id, равным 96. Это и будет наш учитель, ответственный за этот класс.
id,name,grade,owner_id
1,Новички 1,10,96
2,Базовая группа 1,11,97
3,Продвинутая группа 1,10,98
4,Новички 2,10,99
5,Базовая группа 2,11,100
6,Продвинутая группа 2,11,96
7,Новички 3,11,97
8,Базовая группа 3,11,98
9,Продвинутая группа 3,10,99
10,Новички 4,11,100
11,Новички 5,10,196
12,Базовая группа 4,11,197
13,Продвинутая группа 4,10,198
14,Новички 6,10,199
15,Базовая группа 5,11,200
16,Продвинутая группа 5,11,196
17,Новички 7,11,197
18,Базовая группа 6,11,198
19,Продвинутая группа 6,10,199
20,Новички 8,11,200
Пользователь с id, равным 96, — это учитель Екатерина Сергеевна Лебедева. Она и является ответственной за класс с id, равным 1, в таблице class.csv.
id,role,first_name,middle_name,last_name,email,phone_number,hobbies
…
84,ученик,Людмила,Игоревна,Ефимова,lyudmila.efimova@gmail.com,79447313007,"""Готовка, настольные игры"""
85,ученик,Павел,Владимирович,Зуев,pavel.zuev@yandex.ru,79578444314,"""Музыка, бег"""
86,ученик,Сабина,Сергеевна,Орлова,sabina.orlova@mail.ru,79881260488,"""Коллекционирование монет, кулинария"""
87,ученик,Богдан,Дмитриевич,Маркин,bogdan.markin@gmail.com,79506300412,"""Шахматы, хоккей"""
88,ученик,Кристина,Владимировна,Кузнецова,kris.kuznetsova@yandex.ru,79165512392,"""Программирование, чтение"""
89,ученик,Арсений,Алексеевич,Гусев,arseniy.gusev@mail.ru,79539071256,"""Велоспорт, йога"""
90,ученик,Лилия,Петровна,Прохорова,liliya.prohorova@gmail.com,79651420785,"""Музыка, кулинария"""
91,ученик,Демид,Викторович,Петров,demid.petrov@yandex.ru,79945554938,"""Рисование, хоккей"""
92,ученик,Кира,Сергеевна,Волкова,kira.volkova@mail.ru,79245610367,"""Шахматы, чтение"""
93,ученик,Матвей,Игоревич,Сидоров,matvey.sidorov@gmail.com,79493715530,"""Программирование, бег"""
94,ученик,Алиса,Павловна,Миронова,alisa.mironova@yandex.ru,79693125384,"""Коллекционирование монет, путешествия"""
95,ученик,Виктор,Александрович,Орлов,viktor.orlov@mail.ru,79926025953,"""Готовка, йога"""
96,учитель,Екатерина,Сергеевна,Лебедева,ekaterina.lebedeva@gmail.com,79997221607,"""Музыка, шахматы"""
97,учитель,Михаил,Владимирович,Попов,mikhail.popov@yandex.ru,79565681439,"""Велоспорт, чтение"""
98,учитель,Ольга,Алексеевна,Соловьёва,olga.solovieva@mail.ru,79756277562,"""Коллекционирование монет, бег"""
99,учитель,Андрей,Дмитриевич,Крылов,andrey.krylov@gmail.com,79667961059,"""Программирование, кулинария"""
100,учитель,Татьяна,Петровна,Молчанова,tatiana.molchanova@yandex.ru,79817060837,"""Готовка, футбол"""
…
Тут у вас может возникнуть вопрос: почему бы не схлопнуть все данные просто в одну большую таблицу и не работать с ней?
В пользу разделения на отдельные структуры есть два аргумента:
- Дублирование данных. В одной большой таблице будет большое количество дублирования одних и тех же данных. А если какого-то блока информации у части данных пока нет, то приличная часть данных будет ещё и просто пустой.
- Нам не потребуются все данные. Для поиска нужных данных не надо просматривать их все, а лишь определенную таблицу или несколько таблиц. Это ускорит процесс.
Теперь давайте попробуем проделать маленькое упражнение на закрепление. Какие Ф. И. О. у ученика, который в task_attempt.csv делал попытку под id со значением 100?
Ответ (не открывайте сразу, сначала подумайте сами!)
Для этого по этой схеме мы собираем цепочку, которая ведёт от task_attempt_id к user_id и его данным. Получается следующий путь (выделен на картинке синим цветом):
- Смотрим
test_attempt_idв task_attempt.csv и ищем такой же в test_attempt.csv. - Смотрим
test_class_user_link_idв test_attempt.csv и ищем такой же в test_class_user_link.csv. - Смотрим
class_user_link_idв test_class_user_link.csv и ищем такой же в class_user_link.csv. - Смотрим
user_idв class_user_link.csv и ищем такой же в user.csv. - По
user_idнаходим нужные намfirst_name,middle_name,last_name(элементы Ф. И. О.).
Если переводить это движение в череду вопросов, на которые мы ищем ответы, то получилось бы:
- А к какой попытке теста принадлежит решение конкретного задания?
- А к какой выдаче теста ученику принадлежит текущая попытка?
- А какому ученику и в каком классе выдали тест?
- А как зовут ученика?
Видите — это своеобразная иерархия, по которой от точечной попытки решения конкретного задания мы поднялись до уровня конкретных студентов.
Таким образом, с помощью схемы выше вы можете быстро изучить, где что лежит, и подтянуть необходимые данные для решения задачи. Разбираться в структурах данных и принципах работы с ними — это повседневная задача разработчика, так что потренируетесь.
Считывание данных из файла CSV
Так как данные из CSV имеют конкретную структуру, то, по идее, должен существовать метод, который позволил бы при считывании данных это учитывать. Иначе вам придётся считывать их как обычный TXT-файл, разделять каждую строчку на элементы, вычленять их.
Примечание
В целом если вам кажется, что что-то можно упростить, то, скорее всего, это уже изобрели. Как минимум для старта можно оттолкнуться от документации Python.
Чтобы не делать дополнительную работу, в Python существует специальный метод обработки CSV-файлов. Он позволяет считать содержимое файлов как список списков.
Сейчас расскажем, как это сделать. Для примера возьмём следующий CSV-файл students.csv:
Имя,Возраст,Город
Алиса,14,Москва
Кирилл,15,Санкт-Петербург
Хотим его считать как список списков. То есть таким образом:
[
['Имя', 'Возраст', 'Город'],
['Алиса', '14', 'Москва'],
['Кирилл', '15', 'Санкт-Петербург']
]
Для этого будем использовать следующий код:
import csv
with open("students.csv", "r", encoding="utf-8") as f:
reader = csv.reader(f)
next (reader) # Пропускаем заголовок
data = list (reader)
print(data)
Разберём строка за строкой, что здесь происходит.
import csv
Это подключение стандартной библиотеки csv, которая позволяет работать с CSV-файлами. Вы как бы говорите: «Я хочу использовать инструменты для работы с CSV».
with open("students.csv", "r", encoding="utf-8") as f:
Эта строка открывает файл students.csv на чтение.
- with open(…) — удобная конструкция: файл откроется, поработаешь с ним, и он автоматически закроется, даже если будет ошибка.
- ”students.csv” — имя файла, который читаем. В таком виде учитываем, что файл лежит вместе с исполняемой программой. Иначе к файлу нужно будет подкорректировать путь. Фактически при решении задачи будет видоизменяться только этот кусочек.
- «r» — означает read (открыт на чтение).
- encoding=«utf-8» — говорит: «Файл в кодировке UTF-8» (универсальной и безопасной для русского текста).
reader = csv.reader(file)
Здесь мы создаём читалку (reader). Она читает файл построчно, разделяя значения по запятой (или если указать — по разделителю ;). Она будет возвращать по одной строке из CSV в виде списка.
next (reader)
Пропускаем первую строчку с заголовками. В ней обычно не содержатся данные, которые нужны для решения задачи.
data = list(reader)
Считываем все строки как список списков. В итоге переменная data содержит все строки CSV-файла, кроме заголовков.
И теперь мы можем обращаться к элементам следующим образом. Например, мы хотим узнать возраст Алисы. Тогда нам нужно обратиться к элементу data [1][1]. Так как к конкретному свойству мы обращаемся через определённый индекс ([0] — имя, [1] — возраст и тому подобное), то заголовки мы не используем.
Если вы хотите считывать данные как словари, то можете дополнительно изучить этот блок тут. Считывать данные в словари посложнее, но удобнее в использовании. Ниже краткая выжимка.
Код, реализующий считывание в виде словаря, выглядит следующим образом:
import csv
with open("students.csv", "r", encoding="utf-8") as f:
reader = csv.DictReader(f)
data = list (reader)
print(data)
Вывод работы программы будет следующим:
[
{'Имя': 'Алиса', 'Возраст': '14', 'Город': 'Москва'},
{'Имя': 'Кирилл', 'Возраст': '15', 'Город': 'Санкт-Петербург'}
]
Это позволяет нам обращаться напрямую к заголовкам, не задумываясь, под каким порядковым номером они хранятся в данных.
first = data[0] # {'Имя': 'Алиса', 'Возраст': '14', 'Город': 'Москва'}
print(first['Имя']) # Алиса
print(first['Возраст']) # 14
Работа с типом данных datetime
Если посмотрите на данные дат или времени в файлах, то заметите, что они хранятся в следующем формате:
2025-05-23 15:45:02.123456
Это формат называется datetime, потому что он содержит одновременно и дату, и время. Давайте достанем из него отдельно год, месяц, день, час, минуту, секунду, микросекунду.
import datetime
# Создаём объект datetime с точным временем
test_time = datetime.datetime(2025, 5, 23, 15, 45, 2, 123456)
# Выводим отдельные компоненты
print("Год:", test_time.year)
print("Месяц:", test_time.month)
print("День:", test_time.day)
print("Час:", test_time.hour)
print("Минута:", test_time.minute)
print("Секунда:", test_time.second)
print("Микросекунда:", test_time.microsecond)
Разберём строка за строкой, что здесь происходит.
import datetime
Подключаем встроенный модуль datetime, который позволяет работать с датами и временем в Python.
test_time = datetime.datetime(2025, 5, 23, 15, 45, 2, 123456)
Последовательно задаём год, месяц, день, час, минуту, секунду, микросекунду.
test_time.year
test_time.month
test_time.day
test_time.hour
test_time.minute
test_time.second
test_time.microsecond
Таким образом, можем доставать конкретный кусочек даты или времени из datetime.
Так как при считывании данных из формата CSV получаем данные в виде строк, нужно каким-то образом научиться переводить строки типа «2025-05-23 15:45:02.123456» в datetime.
Для этого можно воспользоваться методом .strptime().
import datetime
time_str = "2025-05-23 15:45:02"
test_time = datetime.datetime.strptime(time_str, "%Y-%m-%d %H:%M:%S")
print(test_time)
print("Год:", test_time.year)
Здесь %Y-%m-%d %H:%M:%S — это определённые форматы строки:
|
Символ |
Что означает |
Пример |
|
|
Год (4 цифры) |
2025 |
|
|
Год (2 цифры) |
25 |
|
|
Месяц (2 цифры) |
05 |
|
|
День месяца |
23 |
|
|
Часы (24‑часовой формат) |
15 |
|
|
Минуты |
45 |
|
|
Секунды |
02 |
Если вам нужно найти наиболее раннюю из двух дат, то способ такой же, как и для чисел.
import datetime
# Задаём две фиксированные даты
date1 = datetime.datetime(2025, 5, 23, 12, 0, 0)
date2 = datetime.datetime(2024, 12, 31, 18, 30, 0)
# Сравниваем
if date1 > date2:
print("date1 позже date2")
elif date1 < date2:
print("date1 раньше date2")
else:
print("Обе даты совпадают")
Если вам нужно вывести переменную datetime в определённом формате, то можно воспользоваться методом .strftime().
import datetime
# Создаём объект datetime
test_time = datetime.datetime(2025, 5, 23, 15, 45, 2, 123456)
# 1. Только дата
print("Только дата:", test_time.date())
# 2. Только время
print("Только время:", test_time.time())
# 3. Форматированная дата
print("Дата в формате ДД.ММ.ГГГГ:", test_time.strftime("%d.%m.%Y"))
print("Дата и время (ДД.ММ.ГГГГ ЧЧ:ММ):", test_time.strftime("%d.%m.%Y %H:%M"))
print("Полная дата с днём недели:", test_time.strftime("%A, %d %B %Y"))
print("ISO формат:", test_time.isoformat(timespec='seconds'))
Полный перечень кодов можно посмотреть в документации.
Теперь у вас есть всё необходимое для решения задачи. Удачи!
Если вы зашли в тупик, обратитесь к подсказкам. Мы собрали ответы на частые вопросы по этой задаче. Они наведут вас на мысль и помогут сдвинуться с мёртвой точки.
Подсказки
Как считать информацию из CSV-файла?
Подробно, с примерами кода разобрали в блоке «Считывание данных из файла CSV».
Обратите внимание: все файлы лежат в той же папке, из которой запускается ваша программа. Это значит, что вам не нужно прописывать отдельный путь к файлу, а достаточно указать его имя. Например, «user.csv».
with open("user.csv", "r", encoding="utf-8") as f:
Как понять, какие данные мне будут нужны для задачи?
Сначала посмотрите, какие данные от вас требуют в условии задачи и в примерах вывода. Соберите их списком. Например, от нас просят вывести все тестовые вопросы, которые должен был решить ученик с user_id, равным 2, и с class_id, равным 2.
Список необходимых данных выглядит так:
- Список тестов, которые были назначены ученику.
- Из каких заданий состоят тесты.
- Конкретные текстовые формулировки заданий.
- Возможно, названия тестов.
Дальше посмотрите, где лежат эти данные в базе.
В нашем случае:
- Список назначенных тестов есть в test_class_user_link.csv.
- Задания в тесте есть в test_task_link.csv.
- Конкретные текстовые формулировки лежат в task.csv.
- Если нам нужны ещё названия тестов, то пригодится test.csv.
Как привести дату к необходимому для задачи формату?
Подробно, с примерами разобрали, как работать с форматом datetime в блоке «Работа с типом данных datetime».
Если вы смогли считать в формате datetime данные из базы данных, то теперь осталось только правильно выставить маску для отображения данных. Посмотрите в примере ниже:
from datetime import datetime
# Создаём datetime: 23 мая 2025, 14:30:45
test_time = datetime(year=2025, month=5, day=23, hour=14, minute=30, second=45)
# Выводим в разных форматах
print("1. Дата с двумя цифрами в годе:")
print(test_time.strftime("%d.%m.%y")) # 23.05.25
print("\n2. Дата с четырьмя цифрами в годе:")
print(test_time.strftime("%d.%m.%Y")) # 23.05.2025
print("\n3. Год.Месяц.День:")
print(test_time.strftime("%Y.%m.%d")) # 2025.05.23
print("\n4. Полная дата и время:")
print(test_time.strftime("%d.%m.%Y %H:%M:%S")) # 23.05.2025 14:30:45
print("\n5. ISO-формат (по стандарту):")
print(test_time.isoformat()) # 2025-05-23T14:30:45
Как отсортировать список списков по одному или нескольким полям?
Возьмём таблицу test_task_link.csv, которая содержит данные в следующем виде:
id, test_id, task_id, order_number
81, 8, 1, 1
82, 8, 2, 2
Допустим, нам нужно найти все вопросы, принадлежащие к test_id со значением 8, и отсортировать их по order_number по убыванию. Реализовать это можно с помощью следующего кода (считаем, что test_task_link.csv уже обработали):
import csv
# Чтение данных из нужного CSV-файла
with open("test_task_link.csv", "r", encoding="utf-8") as f:
reader = csv.reader(f)
next(reader) # Пропускаем заголовок
test_task_links = list(reader)
# ID теста, для которого ищем задачи
test_id = '8'
test_tasks = []
# Отбираем задачи с нужным test_id и сортируем по убыванию order_number
for link in test_task_links:
if link[1] == test_id:
inserted = False
for i in range(len(test_tasks)):
if int(link[3]) > int(test_tasks[i][3]): # сравниваем order_number
test_tasks.insert(i, link)
inserted = True
break
else:
test_tasks.append(link)
# Вывод результата
for task in test_tasks:
print(task)
Что здесь происходит:
- Проходимся по всем строчкам.
- Находим задачу с нужным нам
test_id. - Пытаемся вставить задачу по порядку, сравнивая по
order_numberс каждой отобранной задачей. - Если находится задача с меньшим
order_number, текущая вставляется перед ней. - Если не находится меньший
order_number, то элемент добавляется в конец списка, как с самым маленькимorder_numberна данный момент.
В коде использовали метод для списков .insert (index, element). Этот метод вставляет элемент по конкретному индексу в списке.
Например:
my_list = [1, 2, 4]
my_list.insert(2, 3) # вставить 3 на позицию с индексом 2
print(my_list) # [1, 2, 3, 4]
Чтобы сделать сортировку адаптируемой под несколько условий, вы можете поменять, по каким сравнениям элемент отправляется на то или иное место.
Например, нам нужно найти все задания, которые принадлежат к нескольким test_id со значением [5,6,7], и вывести все задания в порядке возрастания test_id и order_number.
Условие могло бы выглядеть так:
if int(link[1]) < int(current[1]) or (link[1] == current[1] and int(link[3]) < int(current[3])):
Сначала мы сравнили по test_id. А потом — по order_number. И получается, если элемент имеет test_id меньше, то вставляем элемент. Если test_id равные и order_number меньше, то тоже вставляем.
Важно
Решали эту задачу алгоритмически, опираясь на ваши текущие знания. В следующих главах вы познакомитесь с функциями и лямбда-функциями, они помогут упростить код.
Как собрать и вывести JSON в нужном формате?
Здесь главное — аккуратно собрать всё по кусочкам. Подготовить все данные, чтобы финальный вывод выглядел максимально просто:
result = {
"user": user_info,
"progress": progress_info,
"tests": tests_data
}
print(json.dumps(result, ensure_ascii=False, indent=2))
А дальше по частям находим каждый блок. При этом в задаче 2 «Статус прохождения тестов учениками» и 3 «Обработка тестов» вы проделали много работы, которую можно переиспользовать.
user_info — словарь, который хранит конкретную информацию о пользователе:
user_info = {
"user_id": ,
"full_name": ,
"class_id": ,
}
progress_info тоже словарь. Он хранит конкретную информацию о качестве прохождения тестов:
progress_info = {
"tests_completed": ,
"tests_total": ,
"accuracy_average_percent": ,
}
Структура tests_data посложнее. Это список, который хранит перечень тестов в формате словаря. Каждый элемент tests_data выглядит следующим образом:
{
"test_id": ,
"title": ,
"tasks": ,
"progress_percent": ,
"correct_percent": ,
"time": ,
"date_assigned": ,
}
При этом tasks — это тоже список словарей. Он состоит элементов, отсортированных по order_number:
{
"order_number": ,
"task_id": ,
"is_correct": ,
}
Вот один из подходов, что можно сделать с данными:
user_infoсобрать обособленно. Для его сборки не нужна дополнительная информация о тестах и заданиях.progress_infoиtests_dataпереплетены, поэтому важно понять, какие данные нужны для каждого блока структуры. В любом случае собираются они вместе.- Кажется, начинать стоит с того, чтобы разобраться: а какие тесты были выданы ученику? Это начало клубка. И потом ходим в соседние таблицы за нужными данными.
Как оптимизировать обращение к элементам списков из таблиц?
Допустим, вы хотите найти информацию о конкретном ученике (например, user_id, равное 1) в файле user.csv. Самый простой способ — пройтись по всем строчкам, найти ту, где первый элемент списка будет равняться 1. И подтянуть все необходимые данные.
Проблема этого способа заключается в том, что вы постоянно будете плодить множество циклов, проводя одну и ту же процедуру. На выходе получим громоздкий код и неоптимальное решение.
Для оптимизации рекомендуем следующий приём: все данные из файла запаковать в словарь и доставать необходимые данные из него.
with open("user.csv", "r", encoding="utf-8") as f:
reader = csv.reader(f)
next(reader) # Пропускаем заголовок
users = list(reader)
users_dict = {user[0]: user for user in users}
В users_dict лежит словарь с ключом user_id. Выглядит это следующим образом:
{
"101": ["101", "ученик", "София", "Сергеевна", "Боброва", "sofiya.bobrova@mail.ru", "79161798644", "Чтение, Коллекционирование монет, Путешествия"],
"102": ["102", "ученик", "Андрей", "Петрович", "Смирнов", "andrey.smirnov@mail.ru", "79161234567", "Футбол, Игры, Чтение"],
"103": ["103", "ученик", "Мария", "Ивановна", "Кузнецова", "maria.kuz@mail.ru", "79161239876", "Рисование, Фортепиано"],
...
}
Обратите внимание: все данные — в текстовом формате, потому что так они считываются через csv.reader.
Теперь вы можете обращаться к элементу словаря и доставать нужную информацию. Например, вы хотите достать имя ученика для user_id со значением 1.
users_dict[‘1’][2]
Здесь [‘1’] — это user_id, по которому ищем. А [2] — это третий элемент в списке, имя. Такой код вернёт имя ученика.
Если читаете этот текст — значит, у вас получилось справиться с задачами или возникли трудности. В любом случае вы взялись за эту задачу — а это похвально!
Дальше советуем изучить, как эту задачу решал автор. Это поможет вам перенять часть полезных фишек в программировании и в мышлении. Подсматривая за работой старших товарищей, вы научитесь полезным привычкам и прокачаете насмотренность.
Ниже — код автора. Изучите его и сравните со своим решением. Или изучите основные идеи и попробуйте самостоятельно их реализовать.
Разбор
Посмотреть решение задачи 1 «Считывание данных из файлов»
# Список файлов в заданном порядке
files = [
"user.csv",
"class.csv",
"class_user_link.csv",
"task.csv",
"test.csv",
"test_task_link.csv",
"test_class_user_link.csv",
"test_attempt.csv",
"task_attempt.csv"
]
# Подсчёт строк в каждом файле
line_counts = []
for file_name in files:
with open(file_name, "r", encoding="utf-8") as file:
# Считаем все строки и вычитаем заголовок
line_count = len(file.readlines()) - 1
# Защита от отрицательных значений (если файл пустой)
line_counts.append(str(max(0, line_count)))
# Вывод результатов через пробел
print(" ".join(line_counts))
Посмотреть решение задачи 2 «Статус прохождения тестов учениками»
import csv
from datetime import datetime
# Получаем входные данные
user_id, class_id = input().split()
# Читаем необходимые файлы
with open("test.csv", "r", encoding="utf-8") as f:
reader = csv.reader(f)
next(reader) # Пропускаем заголовок
tests = list(reader)
with open("class_user_link.csv", "r", encoding="utf-8") as f:
reader = csv.reader(f)
next(reader) # Пропускаем заголовок
class_user_links = list(reader)
with open("test_class_user_link.csv", "r", encoding="utf-8") as f:
reader = csv.reader(f)
next(reader) # Пропускаем заголовок
test_class_user_links = list(reader)
with open("test_attempt.csv", "r", encoding="utf-8") as f:
reader = csv.reader(f)
next(reader) # Пропускаем заголовок
test_attempts = list(reader)
# Создаём словарь для быстрого доступа к тестам по id
test_dict = {test[0]: test[1] for test in tests}
# Находим связь между пользователем и классом
class_user_link_ids = []
for link in class_user_links:
if link[2] == user_id and link[1] == class_id:
class_user_link_ids.append(link[0])
# Список для назначенных тестов
assigned_tests = []
completed_tests = 0
# Для каждой связи ищем назначенные тесты
for class_user_link_id in class_user_link_ids:
for link in test_class_user_links:
if link[2] == class_user_link_id:
test_id = link[1]
test_class_user_link_id = link[0]
datetime_started = datetime.strptime(link[3], "%d.%m.%Y %H:%M:%S")
# Получаем название теста
test_name = test_dict.get(test_id, "Unknown Test")
# Проверяем, пройден ли тест
test_completed = "FALSE"
for attempt in test_attempts:
if attempt[1] == test_class_user_link_id and attempt[2] == "TRUE":
test_completed = attempt[2]
completed_tests += 1
break
data = {
"test_name": test_name,
"datetime_started": datetime_started,
"test_completed": test_completed
}
for i, assigned_test in enumerate(assigned_tests):
if (
assigned_test["datetime_started"] < data["datetime_started"] or
(
assigned_test["datetime_started"] == data["datetime_started"] and
assigned_test["test_name"] > data["test_name"]
)
):
assigned_tests.insert(i, data)
break
else:
assigned_tests.append(data)
print(f"{completed_tests}/{len(assigned_tests)}")
# Выводим информацию о каждом тесте
for test in assigned_tests:
print(f"{test['test_name']} {test['datetime_started'].strftime('%d.%m.%y')} {test['test_completed']}")
Посмотреть решение задачи 3 «Обработка тестов»
import csv
from datetime import datetime
# Получаем входные данные
user_id, class_id = input().split()
# Читаем необходимые файлы
with open("class_user_link.csv", "r", encoding="utf-8") as f:
reader = csv.reader(f)
next(reader) # Пропускаем заголовок
class_user_links = list(reader)
with open("test_class_user_link.csv", "r", encoding="utf-8") as f:
reader = csv.reader(f)
next(reader) # Пропускаем заголовок
test_class_user_links = list(reader)
with open("test_task_link.csv", "r", encoding="utf-8") as f:
reader = csv.reader(f)
next(reader) # Пропускаем заголовок
test_task_links = list(reader)
with open("task_attempt.csv", "r", encoding="utf-8") as f:
reader = csv.reader(f)
next(reader) # Пропускаем заголовок
task_attempts = list(reader)
with open("task_attempt.csv", "r", encoding="utf-8") as f:
reader = csv.reader(f)
next(reader) # Пропускаем заголовок
task_attempts = list(reader)
with open("test_attempt.csv", "r", encoding="utf-8") as f:
reader = csv.reader(f)
next(reader) # Пропускаем заголовок
test_attempts = list(reader)
with open("task.csv", "r", encoding="utf-8") as f:
reader = csv.reader(f)
next(reader) # Пропускаем заголовок
tasks = list(reader)
task_dict = {task[0]: task for task in tasks}
# Находим связь между пользователем и классом
for link in class_user_links:
if link[2] == user_id and link[1] == class_id:
class_user_link_id = link[0]
break
# Находим все тесты для данного пользователя, отсортированные по дате
user_tests = []
for link in test_class_user_links:
if link[2] == class_user_link_id:
test_id = link[1]
test_class_user_link_id = link[0]
datetime_started = datetime.strptime(link[3], "%d.%m.%Y %H:%M:%S")
data = {
"test_id": test_id,
"class_user_link_id": link[2],
"test_class_user_link_id": test_class_user_link_id,
"datetime_started": datetime_started,
}
# Сортируем тесты по дате выдачи (от поздних к ранним)
for i, user_test in enumerate(user_tests):
if user_test["datetime_started"] < data["datetime_started"]:
user_tests.insert(i, data)
break
else:
user_tests.append(data)
if not user_tests:
print("Тестов не найдено")
exit()
latest_test = user_tests[0]
test_id = latest_test["test_id"]
test_class_user_link_id = latest_test["test_class_user_link_id"]
for test_attempt in test_attempts:
if test_attempt[1] == test_class_user_link_id:
test_attempt_id = test_attempt[0]
break
else:
print("Попытки не найдено")
exit()
# Получаем все задачи для этого теста
test_tasks = []
for link in test_task_links:
if link[1] == test_id:
data = {
"order_number": int(link[3]),
"task_id": link[2],
}
# Сортируем задачи по порядковому номеру
for i, test_task in enumerate(test_tasks):
if test_task["order_number"] > data["order_number"]:
test_tasks.insert(i, data)
break
else:
test_tasks.append(data)
# Получаем ответы для задач
task_results = {}
total_time_spent_seconds = 0
for attempt in task_attempts:
if attempt[2] == test_attempt_id:
task_id = attempt[1]
user_answer = attempt[3]
if user_answer.strip() == "":
continue # Пропускаем, если ответ пустой
is_correct = attempt[3] == task_dict[task_id][2]
time_spent = int(attempt[5]) if attempt[5] else 0 # Время в секундах
total_time_spent_seconds += int(time_spent)
# Добавляем результат задачи
task_results[task_id] = {
"is_correct": is_correct,
"time_spent": time_spent,
}
# Вычисляем проценты
total_questions = len(test_tasks)
answered_questions = len(task_results)
correct_answers = sum(1 for result in task_results.values() if result["is_correct"])
percentage_answered = (answered_questions / total_questions * 100) if total_questions > 0 else 0
percentage_correct = (correct_answers / answered_questions * 100) if answered_questions > 0 else 0
# Выводим результаты
print(f"{percentage_answered:.1f}% {percentage_correct:.1f}%")
for task in test_tasks:
task_id = task["task_id"]
# Определяем статус ответа
if task_id in task_results:
answer_status = "TRUE" if task_results[task_id]["is_correct"] else "FALSE"
else:
answer_status = "?"
print(f"{task['order_number']} {answer_status} {task_id}")
# Формируем время в формате hh:mm:ss
hours, remainder = divmod(total_time_spent_seconds, 3600)
minutes, seconds = divmod(remainder, 60)
print(f"{hours:02d}:{minutes:02d}:{seconds:02d}")
Посмотреть решение задачи 4 «Подготовка JSON для передачи»
import csv
import json
from datetime import datetime
# Чтение входных данных
user_id, class_id = input().split()
# Чтение данных из CSV-файлов
with open("user.csv", "r", encoding="utf-8") as f:
reader = csv.reader(f)
next(reader) # Пропускаем заголовок
users = list(reader)
with open("class_user_link.csv", "r", encoding="utf-8") as f:
reader = csv.reader(f)
next(reader) # Пропускаем заголовок
class_user_links = list(reader)
with open("task.csv", "r", encoding="utf-8") as f:
reader = csv.reader(f)
next(reader) # Пропускаем заголовок
tasks = list(reader)
with open("test.csv", "r", encoding="utf-8") as f:
reader = csv.reader(f)
next(reader) # Пропускаем заголовок
tests = list(reader)
with open("test_task_link.csv", "r", encoding="utf-8") as f:
reader = csv.reader(f)
next(reader) # Пропускаем заголовок
test_task_links = list(reader)
with open("test_class_user_link.csv", "r", encoding="utf-8") as f:
reader = csv.reader(f)
next(reader) # Пропускаем заголовок
test_class_user_links = list(reader)
with open("test_attempt.csv", "r", encoding="utf-8") as f:
reader = csv.reader(f)
next(reader) # Пропускаем заголовок
test_attempts = list(reader)
with open("task_attempt.csv", "r", encoding="utf-8") as f:
reader = csv.reader(f)
next(reader) # Пропускаем заголовок
task_attempts = list(reader)
users_dict = {user[0]: user for user in users}
class_user_links_dict = {
(class_user_link[1], class_user_link[2]): class_user_link
for class_user_link in class_user_links
}
tasks_dict = {task[0]: task for task in tasks}
tests_dict = {test[0]: test for test in tests}
test_attempts_dict = {test_attempt[1]: test_attempt for test_attempt in test_attempts}
task_attempts_dict = {
(task_attempt[1], task_attempt[2]): task_attempt
for task_attempt in task_attempts
}
if user_id not in users_dict:
print("Пользователь не найден")
exit()
# Проверка принадлежности пользователя к классу
class_user_link = class_user_links_dict.get((class_id, user_id), None)
if not class_user_link:
print("Пользователь не принадлежит к указанному классу")
exit()
# Формирование информации о пользователе
user_info = {
"user_id": int(user_id),
"full_name": f"{users_dict[user_id][4]} {users_dict[user_id][2]}", # last_name, first_name
"class_id": int(class_id),
}
# Получение списка тестов, выданных пользователю
user_test_links = []
for link in test_class_user_links:
if link[2] == class_user_link[0]:
link[3] = datetime.strptime(link[3], "%d.%m.%Y %H:%M:%S")
for i, user_test_link in enumerate(user_test_links):
if link[3] > user_test_link[3]:
user_test_links.insert(i, link)
break
else:
user_test_links.append(link)
# Формирование списка тестов с задачами и статусом их выполнения
tests_data = []
accuracy_sum = 0
test_count = 0
tests_completed = 0
for test_link in user_test_links:
test_id = test_link[1]
test_info = tests_dict.get(test_id, None)
if test_info is None:
print("Тест не найден")
continue
test_attempt = test_attempts_dict.get(test_link[0], None)
# Получение задач теста
test_tasks = []
for link in test_task_links:
if link[1] == test_id:
for i, test_task in enumerate(test_tasks):
if int(link[3]) < int(test_task[3]):
test_tasks.insert(i, link)
break
else:
test_tasks.append(link)
tasks_data = []
correct_count = 0
answered_count = 0
total_time_seconds = 0
for task_link in test_tasks:
task_id = task_link[2] # task_id
# Получение статуса выполнения задачи
task_attempt = task_attempts_dict.get((task_id, test_attempt[0]), None)
is_correct = "?"
if task_attempt:
user_answer = task_attempt[3].strip()
if user_answer != "":
answered_count += 1
# Получение правильного ответа для задачи
task_info = tasks_dict.get(task_id, None)
if task_info:
if task_attempt[3] == task_info[2]:
correct_count += 1
is_correct = "TRUE"
else:
is_correct = "FALSE"
# Учёт времени выполнения
if task_attempt[5]: # time_spent
total_time_seconds += int(task_attempt[5])
tasks_data.append({
"order_number": int(task_link[3]), # order_number
"task_id": int(task_id),
"is_correct": is_correct,
})
# Расчёт процента выполнения и точности
total_tasks = len(test_tasks)
progress_percent = 100.0 * answered_count / total_tasks if total_tasks > 0 else 0.0
correct_percent = 100.0 * correct_count / answered_count if answered_count > 0 else 0.0
# Форматирование времени
hours = total_time_seconds // 3600
minutes = (total_time_seconds % 3600) // 60
seconds = total_time_seconds % 60
# Добавление информации о тесте
test_data = {
"test_id": int(test_id),
"title": test_info[1],
"tasks": tasks_data,
"progress_percent": round(progress_percent, 1),
"correct_percent": round(correct_percent, 1),
"time": f"{hours:02d}:{minutes:02d}:{seconds:02d}",
"date_assigned": test_link[3].strftime("%d.%m.%y"),
}
tests_data.append(test_data)
if test_attempt and test_attempt[2] == "TRUE":
tests_completed += 1
# Учёт точности для расчёта среднего
if answered_count > 0:
accuracy_sum += correct_percent
test_count += 1
# Расчёт общего прогресса
accuracy_average = accuracy_sum / test_count if test_count > 0 else 0
progress_info = {
"tests_completed": tests_completed,
"tests_total": len(tests_data),
"accuracy_average_percent": round(accuracy_average, 1)
}
# Формирование итогового словаря
result = {
"user": user_info,
"progress": progress_info,
"tests": tests_data
}
# Вывод результата
print(json.dumps(result, ensure_ascii=False, indent=2))
Но это ещё не всё! Дальше автор описал размышления о подходе к задаче и разобрал каждый кусочек кода. Загляните в блоки, по которым остались вопросы или в которых увидели новые элементы решения.
Подход к задаче
В рамках разбора сфокусируемся на задаче 4. А по задачам 1–3 вы сможете посмотреть код решения с небольшими комментариями.
Так как задача состоит из большого количества данных, двигаемся малыми шагами, чтобы не запутаться.
-
Прежде чем садиться за код, читаем внимательно, чего от нас хотят, изучаем данные. Смотрим, что лежит во всех таблицах: в каком виде представлены данные, есть ли в них какие-то неточности, совпадает ли последовательность заголовков с тем, что описано в тексте.
-
Разбиваем итеративно задачу на набор шагов. По итогам получаем описание логики решения задачи и прозрачно видим, что нам нужно сделать:
а) Нам нужно найти все данные для
user,progress,test, чтобы отправить в JSON.b) Для
userнужные данныеuser_id,full_name,class_id. Где мы можем их получить и каким образом?user_idуже лежит во входных данных.class_idтоже лежит во входных данных.full_nameсоберём изlast_name,first_nameиз таблицы user.csv поuser_id.
c) Для progress и test анализируем так же.
d) Получается, нам нужна не вся база данных, а только определённые таблицы. Фактически все, кроме class.csv.
-
Пошагово собираем программу. Когда задача состоит из большого количества маленьких подзадач, в каждой из которых можно совершить ошибку, рекомендуем двигаться пошагово.
a) Шаг — это маленькая законченная программа. Например, «собрать данные для кусочка user в JSON».
b) Каждый шаг может быть сразу протестирован. Таким образом нам проще будет отлаживать программу целиком, потому что мы отловили все баги на каждом шаге.
c) На практике разработчики часто сначала пишут тесты, а потом уже готовят код, который эти тесты пройдёт. Этот подход называется Test-Driven Development(TDD).
-
Сначала — работающий код, потом — оптимизация. Сначала нужно решить проблему — получить корректный результат. Иначе можно оказаться в ситуации «оптимизации» того, что вообще не работает или не является узким местом.
Таким образом, вы сначала собираете набор маленьких рабочих программ, которые вместе решают поставленную задачу. А потом делаете ещё итерацию и оптимизируете при необходимости.
Конкретно в этой задаче структура получилась следующая:
-
Шаг 1: считываем данные из файлов.
-
Шаг 2: создаём вспомогательный словарь для быстрого поиска нужных данных.
-
Шаг 3: формируем информацию о
user. -
Шаг 4: обрабатываем всю информацию о тестах.
-
Шаг 5: собираем данные для
progressиtestв JSON. -
Шаг 6: собираем данные для JSON и его вывода.
Рассмотрим каждый шаг подробнее.
Шаг 1: считываем данные из файлов
В этом блоке переиспользуем код, который вы написали при решении первой задачи. Последовательно считываем каждый файл и формируем список списков. Файл class.csv не считываем, так как данные из него не используются.
Не забывайте подключать библиотеку csv для удобной обработки файлов такого формата.
import csv
# Чтение данных из CSV-файлов
with open("user.csv", "r", encoding="utf-8") as f:
reader = csv.reader(f)
next(reader) # Пропускаем заголовок
users = list(reader)
with open("class_user_link.csv", "r", encoding="utf-8") as f:
reader = csv.reader(f)
next(reader) # Пропускаем заголовок
class_user_links = list(reader)
with open("task.csv", "r", encoding="utf-8") as f:
reader = csv.reader(f)
next(reader) # Пропускаем заголовок
tasks = list(reader)
with open("test.csv", "r", encoding="utf-8") as f:
reader = csv.reader(f)
next(reader) # Пропускаем заголовок
tests = list(reader)
with open("test_task_link.csv", "r", encoding="utf-8") as f:
reader = csv.reader(f)
next(reader) # Пропускаем заголовок
test_task_links = list(reader)
with open("test_class_user_link.csv", "r", encoding="utf-8") as f:
reader = csv.reader(f)
next(reader) # Пропускаем заголовок
test_class_user_links = list(reader)
with open("test_attempt.csv", "r", encoding="utf-8") as f:
reader = csv.reader(f)
next(reader) # Пропускаем заголовок
test_attempts = list(reader)
with open("task_attempt.csv", "r", encoding="utf-8") as f:
reader = csv.reader(f)
next(reader) # Пропускаем заголовок
task_attempts = list(reader)
Примечание
Глобально этот код неоптимален, так как мы дублируем одно и то же несколько раз. Но это всё, что мы можем сделать с текущими знаниями. В следующих разделах вы научитесь создавать свои собственные функции. Тогда ваш код будет выглядеть так:
import csv
def load_csv(filename):
"""Читает CSV-файл, пропуская заголовок, и возвращает список строк."""
with open(filename, "r", encoding="utf-8") as f:
reader = csv.reader(f)
next(reader) # Пропустить заголовок
return list(reader)
# Загрузка всех таблиц
users = load_csv("user.csv")
class_user_links = load_csv("class_user_link.csv")
tasks = load_csv("task.csv")
tests = load_csv("test.csv")
test_task_links = load_csv("test_task_link.csv")
test_class_user_links = load_csv("test_class_user_link.csv")
test_attempts = load_csv("test_attempt.csv")
task_attempts = load_csv("task_attempt.csv")
Шаг 2: создаём вспомогательный словарь для быстрого поиска нужных данных
Представим, что вам нужно найти имя и фамилию пользователя со значением поля user_id, равным 2. Чтобы это сделать, нужно перебрать все строки и найти ту, у которой id будет равняться user_id, то есть 2.
После считывания user.csv данные хранятся в виде списка списков в переменной users.
[
['1', 'ученик', 'Иван', 'Сергеевич', 'Иванов', 'ivan.ivanov@gmail.com', '79406232030', '"Шахматы, чтение"'],
['2', 'ученик', 'Мария', 'Владимировна', 'Кузнецова', 'maria.kuzne@mail.ru', '79570046302', '"Велоспорт, футбол"'],
['3', 'ученик', 'Дмитрий', 'Алексеевич', 'Петров', 'dmitry.petrov@yandex.ru', '79577196864', '"Готовка, йога"'],
...
]
Чтобы найти нужные имя и фамилию, пройдёмся по каждому списку в users.
user_id = "2"
for user in users:
if user[0] == user_id:
print("Фамилия:", user[4])
print("Имя:", user[2])
break
То есть каждый раз, когда захотим найти элемент с определённым параметром, придётся заводить цикл. Чтобы этого избежать, можно заранее завести словарь по ключу user_id, к которому будем обращаться для поиска конкретного элемента.
users_dict = {user[0]: user for user in users}
Теперь users_dict выглядит так:
{
'1': ['1', 'ученик', 'Иван', 'Сергеевич', 'Иванов', ...],
'2': ['2', 'ученик', 'Мария', 'Владимировна', 'Кузнецова', ...],
...
}
Поиск имени и фамилии будет происходить следующим образом:
# Получаем строку пользователя с id = '2'
user = users_dict['2']
# Выводим фамилию и имя
print("Фамилия:", user[4])
print("Имя:", user[2])
В таком виде получаем следующие преимущества:
- Не нужно проходить весь список. Быстрый доступ.
- Удобно использовать в других блоках. Не нужно громоздить циклы.
- Подходит для большого количества данных.
Но есть и минусы.
Примечание
Ключевая неоптимальность этого решения — считать и сохранить большое количество данных. В некоторых задачах, где важно экономить место в памяти, это решение может не пройти.
Но для текущей тренировочной задачи оно подходит.
В рамках решения создаём такой словарь для всех файлов, которые вы считали. Обратите внимание: в class_user_links_dict и в task_attempts_dict в роли ключа выступает кортеж, пара данных. Делаем так, если нам недостаточно одного параметра, чтобы определить нужную строчку.
users_dict = {user[0]: user for user in users}
class_user_links_dict = {
(class_user_link[1], class_user_link[2]): class_user_link
for class_user_link in class_user_links
}
tasks_dict = {task[0]: task for task in tasks}
tests_dict = {test[0]: test for test in tests}
test_attempts_dict = {test_attempt[1]: test_attempt for test_attempt in test_attempts}
task_attempts_dict = {
(task_attempt[1], task_attempt[2]): task_attempt
for task_attempt in task_attempts
}
Для наглядности вот таблица — в каком словаре какой ключ и откуда подтягиваются значения:
|
Название словаря |
Ключ |
Значение |
|
users_dict |
user_id (строка) |
Строка из user.csv |
|
class_user_links_dict |
(class_id, user_id) — кортеж строк |
Строка из class_user_link.csv |
|
tasks_dict |
task_id (строка) |
Строка из task.csv |
|
tests_dict |
test_id (строка) |
Строка из test.csv |
|
test_attempts_dict |
test_class_user_link_id (строка) |
Строка из test_attempt.csv |
|
task_attempts_dict |
(task_id, test_attempt_id) — кортеж |
Строка из task_attempt.csv |
Шаг 3: формируем информацию о user
Вся информация о user лежит в одном файле user.csv. Поэтому можем сходить за данными в users_dict и подтянуть всё необходимое.
# Формирование информации о пользователе
user_info = {
"user_id": int(user_id),
"full_name": f"{users_dict[user_id][4]} {users_dict[user_id][2]}", # last_name, first_name
"class_id": int(class_id),
}
Шаг 4: обрабатываем всю информацию о тестах
Это самый масштабный блок в решении задачи. В нём последовательно ищем ответы на вопросы, как в схеме ниже, начиная с поиска выданных тестов и обращаясь к определённым данным из файлов. Давайте ответы на эти вопросы и найдём.
Какие тесты выдали ученику
Все необходимые данные лежат в двух файлах:
- class_user_link.csv — распределение учеников по классам;
- test_class_user_link — распределение тестов по ученикам.
Соберём список всех выданных пользователю тестов в отсортированном виде.
- Проверяем, что существует
class_user_link_idдля введённыхuser_idиclass_id. - Проходим по всем строкам файла test_class_user_link.csv, потому что в нём хранится информация о том, какие тесты каким ученикам были выданы (через
class_user_link_id). - Для каждого теста мы проверяем, принадлежит ли он нужному ученику: сравниваем
class_user_link_idиз строки с нужным намclass_user_link[0]. - Если принадлежит — вставляем его в список
user_test_linksв порядке убывания даты выдачи.
Более подробно про сортировку рассказали в подсказке.
# Проверка принадлежности пользователя к классу
class_user_link = class_user_links_dict.get((class_id, user_id), None)
if not class_user_link:
print("Пользователь не принадлежит к указанному классу")
exit()
# Получение списка тестов, выданных пользователю
user_test_links = []
for link in test_class_user_links:
if link[2] == class_user_link[0]:
link[3] = datetime.strptime(link[3], "%d.%m.%Y %H:%M:%S")
for i, user_test_link in enumerate(user_test_links):
if link[3] > user_test_link[3]:
user_test_links.insert(i, link)
break
else:
user_test_links.append(link)
Таким образом, в переменной user_test_links будет список с параметрами по выданным тестам в порядке убывания даты. Например, для class_user_link со значением 1 список выглядит так:
[
['2', '2', '1', datetime.datetime(2025, 6, 4, 10, 0), '11.06.2025 23:59:59'],
['1', '1', '1', datetime.datetime(2024, 6, 21, 10, 0), '28.06.2024 23:59:59']
]
Далее запускаем цикл, который проходится по каждому тесту и собирает необходимые данные.
for test_link in user_test_links:
…
…
Глобально сортировка в текущем виде — это усложнённое решение, над которым алгоритмически ещё нужно подумать. Здорово, если у вас получилось прийти к чему-то подобному или решить по-другому!
Примечание
В дальнейшем вы изучите лямбда-функцию, которая поможет работать с сортировкой и фильтрацией коллекций лаконичнее.
О чём тесты? Как называются?
Название теста лежит во вспомогательном словаре tests_dict, который собрали из файла test.csv. Просто ищем по test_id название конкретного теста и сохраняем в test_info.
for test_link in user_test_links:
test_id = test_link[1]
test_info = tests_dict.get(test_id, None)
…
В test_info будет лежать перечень названий тестов в формате списка. Для class_user_id со значением 1 в test_info будет:
['1', 'Алгоритмы']
['2', 'Интернет']
Из каких задач состоит тест
Перечень задач теста и их порядковые номера лежат в файле test_task_link.csv.
Соберём список всех задач, принадлежащих тесту, в порядке возрастания значений поля order_number.
- Сверяем, что задача принадлежит нужному тесту.
- Помещаем её согласно её порядковому номеру по возрастанию.
for test_link in user_test_links:
…
# Получение задач теста
test_tasks = []
for link in test_task_links:
if link[1] == test_id:
for i, test_task in enumerate(test_tasks):
if int(link[3]) < int(test_task[3]):
test_tasks.insert(i, link)
break
else:
test_tasks.append(link)
…
По итогам в test_tasks будет лежать список из списков данных по задачам из test_task_link.csv.
Ниже пример данных для поля class_user_id со значением 1 и первого теста по циклу. Обратите внимание: всё хранится в текстовом формате.
[
['1', '1', '9', '1'],
['2', '1', '20', '2'],
['3', '1', '23', '3'],
['4', '1', '36', '4'],
['5', '1', '47', '5']
]
Какие правильные ответы у задач
В данном решении отдельным списком правильные ответы на вопросы задач в конкретных тестах не собираем. Зато по запросу, когда сверяем попытку ответа с правильными ответами в тесте, можем быстро найти правильный ответ.
В этом помогает вспомогательный словарь tasks_dict, который сделали изначально.
…
tasks_dict = {task[0]: task for task in tasks}
…
for test_link in user_test_links:
…
for task_link in test_tasks:
…
#Получение правильного ответа для задачи
task_info = tasks_dict.get(task_id, None)
…
Какие попытки у ученика в тестах
Ранее вы нашли, какие тесты решал ученик. Они хранятся в списке user_test_links. С помощью вспомогательного словаря test_attempts_dict получаем быстрый доступ к данным о попытке в тесте.
…
test_attempts_dict = {test_attempt[1]: test_attempt for test_attempt in test_attempts}
…
for test_link in user_test_links:
…
test_attempt = test_attempts_dict.get(test_link[0], None)
…
По итогам в test_attempt будет лежать список с параметрами прохождения теста. Для class_user_link со значением 1 для одного теста в цикле список выглядит следующим образом:
[‘1’, ‘1’, ‘TRUE’, '24.06.2024 13:25:31', '28.06.2024 15:45:33']
Какие попытки у ученика в задачах
С помощью вспомогательного словаря task_attempts_dict находим попытки, зная id попытки решения теста. А попытку решения теста мы нашли ранее, она лежит в test_attempt.
…
task_attempts_dict = {
(task_attempt[1], task_attempt[2]): task_attempt
for task_attempt in task_attempts
}
…
for test_link in user_test_links:
…
test_attempt = test_attempts_dict.get(test_link[0], None)
…
for task_link in test_tasks:
…
task_attempt = task_attempts_dict.get((task_id, test_attempt[0]), None)
…
По итогам в task_attempt будет лежать список с параметрами попытки прохождения задания. Для class_user_link со значением 1 первого теста и первой задачи в циклах список выглядит следующим образом:
['396', '9', '1', 'A', '24.06.2024 13:25:31', '808.0']
Шаг 5: собираем данные для progress и test в JSON
На предыдущем этапе собрали логику прохода по всем необходимым для решения задачи элементам. Теперь добавим расчёты параметров для progress и test.
Вспомним, что поле progress в JSON состоит из следующих полей:
{
"tests_completed": ,
"tests_total": ,
"accuracy_average_percent": ,
}
А tests_data — из таких:
{
"test_id": ,
"title": ,
"tasks": ,
"progress_percent": ,
"correct_percent": ,
"time": ,
"date_assigned": ,
}
При этом tasks тоже список словарей, для которого нужны данные.
{
"order_number": ,
"task_id": ,
"is_correct": ,
}
Проход по всем необходимым данным осуществляется в рамках двух циклов: один проходится по выданным тестам, другой — по всем задачам из тестов.
- Проходя по задачам, достаём данные, на которые завязаны любые расчёты с задачами (
tasks,progress_percent,correct_percent,time,accuracy_average_percent). - Проходя по тестам, проводим расчёты глобальных параметров (
test_completed,test_total,accuracy_average_percent,test_id,title,data_assigned).
Проходя по циклу задач, собираем данные:
- Для каждой задачи собираем базовые данные о ней (
task_id,order_number). - Рассчитываем поле
is_correct. Значение будетTRUE, если попытка ответа и правильный ответ совпали.FALSE— если не совпали. И?, если не было попытки ответа. - Рассчитываем и обновляем суммарное время, потраченное на задания, —
time. - Обновляем суммарное количество задач, по которым получены ответы (
answered_count), и количество правильных ответов (correct_count). - Добавляем информацию о задаче в список
tasks.
Эта последовательность действий реализована следующим образом (не забываем переводить данные в нужный вид, так как они хранятся в строковом виде):
for test_link in user_test_links:
…
tasks_data = []
correct_count = 0
answered_count = 0
for task_link in test_tasks:
task_id = task_link[2] # task_id
# Получение статуса выполнения задачи
task_attempt = task_attempts_dict.get((task_id, test_attempt[0]), None)
is_correct = "?"
if task_attempt:
user_answer = task_attempt[3].strip()
if user_answer != "":
answered_count += 1
# Получение правильного ответа для задачи
task_info = tasks_dict.get(task_id, None)
if task_info:
if task_attempt[3] == task_info[2]:
correct_count += 1
is_correct = "TRUE"
else:
is_correct = "FALSE"
# Учёт времени выполнения
if task_attempt[5]: # time_spent
total_time_seconds += int(task_attempt[5])
tasks_data.append({
"order_number": int(task_link[3]), # order_number
"task_id": int(task_id),
"is_correct": is_correct,
})
…
Таким образом, собраны:
- Список
tasksдляtests_data. - Количество времени выполнения теста в
time. - Количество всех ответов (
answered_count) и правильных ответов (correct_count) для дальнейших расчётов по тесту —progress_percent,correct_percentиaccuracy_average_percent.
Теперь проходимся по циклу по тестам и собираем всё недостающее:
- Рассчитываем процент выполнения теста (
progress_percent) и процент правильных ответов (correct_percent) для конкретного теста. - Переводим время (
time) в часы, минуты, секунды. - Формируем данные теста для
test_dataдля вывода в JSON. - Обновляем суммарное количество сданных тестов (
test_completed) и общее количество тестов (test_count). - Рассчитываем суммарный процент выполнения задач по всем тестам (
accuracy_sum) для дальнейшего подсчёта общего среднего.
В коде это реализовано следующим образом (не забудьте, что для приведения даты к нужному формату стоит использовать функцию strftime из модуля datetime):
for test_link in user_test_links:
…
for task_link in test_tasks:
…
…
# Расчёт процента выполнения и точности
total_tasks = len(test_tasks)
progress_percent = 100.0 * answered_count / total_tasks if total_tasks > 0 else 0.0
correct_percent = 100.0 * correct_count / answered_count if answered_count > 0 else 0.0
# Форматирование времени
hours = total_time_seconds // 3600
minutes = (total_time_seconds % 3600) // 60
seconds = total_time_seconds % 60
# Добавление информации о тесте
test_data = {
"test_id": int(test_id),
"title": test_info[1],
"tasks": tasks_data,
"progress_percent": round(progress_percent, 1),
"correct_percent": round(correct_percent, 1),
"time": f"{hours:02d}:{minutes:02d}:{seconds:02d}",
"date_assigned": test_link[3].strftime("%d.%m.%y"),
}
tests_data.append(test_data)
if test_attempt and test_attempt[2] == "TRUE":
tests_completed += 1
# Учёт точности для расчёта среднего
if answered_count > 0:
accuracy_sum += correct_percent
test_count += 1
…
Таким образом, вы собрали:
- Процент выполнения теста (
progress_percent) и процент правильных ответов (correct_percent) дляtests_data. - Данные по тесту в необходимом формате для вывода в JSON —
test_data. - Необходимые для
progressданные: количество завершённых тестов (test_completed), суммарная точность выполнения (accuracy_sum) и количество тестов, которые были начаты (test_count).
За пределами циклов собираем данные, которые затрагивают расчёты по всем тестам:
- Общий средний прогресс по всем тестам (
accuracy_average). - Блок
progressдля JSON.
# Расчёт общего прогресса
accuracy_average = accuracy_sum / test_count if test_count > 0 else 0
progress_info = {
"tests_completed": tests_completed,
"tests_total": len(tests_data),
"accuracy_average_percent": round(accuracy_average, 1)
}
Таким образом, собраны все данные для формирования JSON и отправки его в интерфейс.
Шаг 6: собираем данные для JSON и его вывода
На предыдущих шагах вы собрали все части для формирования JSON: user_info, progress_info и test_data.
…
# Формирование информации о пользователе
user_info = {
"user_id": int(user_id),
"full_name": f"{users_dict[user_id][4]} {users_dict[user_id][2]}", # last_name, first_name
"class_id": int(class_id),
}
…
for test_link in user_test_links:
…
# Добавление информации о тесте
test_data = {
"test_id": int(test_id),
"title": test_info[1],
"tasks": tasks_data,
"progress_percent": round(progress_percent, 1),
"correct_percent": round(correct_percent, 1),
"time": f"{hours:02d}:{minutes:02d}:{seconds:02d}",
"date_assigned": test_link[3].strftime("%d.%m.%y"),
}
tests_data.append(test_data)
…
progress_info = {
"tests_completed": tests_completed,
"tests_total": len(tests_data),
"accuracy_average_percent": round(accuracy_average, 1)
}
…
Осталось собрать финальный JSON и выставить необходимые параметры для его формирования.
# Формирование итогового словаря
result = {
"user": user_info,
"progress": progress_info,
"tests": tests_data
}
# Вывод результата
print(json.dumps(result, ensure_ascii=False, indent=2))
Параметры в json.dumps:
result— итоговый сформированный словарь для передачи.ensure_ascii=False— русские буквы остаются русскими буквами.indent=2— каждая вложенность будет сдвинута на два пробела для читаемости.
Тесты для задачи
Так как в задаче не была оговорена обработка конкретных исключений, для каждого ученика считаем, что точно есть выданные тесты. Тогда тесты программы должны покрывать все возможные варианты количества решённых задач и правильности их решения.
|
Суть |
Пример на выданных данных |
|
У пользователя есть один или несколько тестов. В каждом из них есть попытки решения задач. Все задачи решены. |
|
|
У пользователя есть один или несколько тестов. Ни в одном из них нет попыток решения задач. |
|
|
У пользователя есть несколько тестов. В каких-то есть попытки решения задач, в каких-то — нет. Если есть попытки, то все задачи решены в них. |
|
|
У пользователя есть несколько тестов. В каких-то есть попытки решения задач, в каких-то — нет. Если есть попытки, не все задачи в них решены. |
|
Обратите внимание: автор задачи в коде обработал несколько исключений, потому что это даёт уверенность в работе кода, проще отлавливать ошибки. И в будущем при изменении условий или данных не нужно будет всё перепроверять.
Примечание
Этого не требовалось, но это хорошая привычка.
…
if user_id not in users_dict:
print("Пользователь не найден")
exit()
# Проверка принадлежности пользователя к классу
class_user_link = class_user_links_dict.get((class_id, user_id), None)
if not class_user_link:
print("Пользователь не принадлежит к указанному классу")
exit()
…
for test_link in user_test_links:
test_id = test_link[1]
test_info = tests_dict.get(test_id, None)
if test_info is None:
print("Тест не найден")
continue
…
Итог
Ключевая сложность этой задачи — последовательное решение большого количества маленьких задач на работу со списками, кортежами и словарями. Если вы полностью вникли в решение автора и смогли собрать решение, то точно стали увереннее пользоваться пройденными конструкциями и отлаживать код.

Максим Ломакин
Тимлид в Яндекс Учебнике
Хочу подвести итог и поделиться пожеланиями на будущее.
Эта задача — переходный этап. Вы уже можете решать сложные задачи, но пока не знаете, как упростить код, чтобы сделать его более лаконичным и быстрым. В следующих главах вы получите инструменты, которые позволят сократить количество кода на решение этой задачи в разы. Рекомендую вернуться к ней через некоторое время.
В этой задаче легко получить ошибку на любом этапе расчётов. Чтобы не сидеть потом и не искать, в каком блоке кода ошибка, — отрабатывайте работу программы сразу по частям.
Примечание
Сделали полноценный рабочий кусок — придумали несколько тестов, проверили, что всё работает, — двинулись дальше.
Это позволит вам быть уверенным в каждом куске программы. Заодно сразу задумаетесь о крайних кейсах (их ещё называют corner cases) — как ведёт себя программа при крайних значениях или ситуациях. Например, если получит не те данные или не получит их вовсе.
При решении большинства реальных задач, с которыми вы столкнётесь, вы будете чего-то не знать. Это нормально — сделать усилие, чтобы разобраться в чём-то неизвестном: изучить структуру данных, чужой код, загуглить документацию, попросить помощь у GPT.
На начальном этапе часто будет не хватать насмотренности в подходах к решению. В работе помогает старший товарищ и код-ревью. В обучении вы можете попросить знакомых дать комментарий по вашему коду, спросить совет у GPT. Как прокачаетесь — начинайте изучать код свежих крупных международных проектов, например, на GitHub, чтобы видеть актуальное состояние использования Python.
И давайте подведём итоги: что изучили и что можно взять с собой дальше.
В рамках этой задачи вы:
- Освоили работу со строками, кортежами, списками, словарями.
- Разобрали, что такое JSON и как его сформировать.
- Научились работать с текстовыми файлами в формате CSV.
- Поработали с датами и временем при помощи модуля
datetime. - Познакомились с упрощённой версией базы данных и разобрались, как устроены в ней данные.
Что стоит запомнить:
- В будущем вы изучите новые приёмы и возможности Python. Не забывайте возвращаться к старому коду, чтобы увидеть свой прогресс и новые способы решения задач.
- Тестируйте каждый законченный кусочек кода, чтобы избегать ошибок.
На этом освоение раздела завершено. Ещё раз говорим: вы молодец! Продолжайте учиться, и для вас не останется нерешаемых задач. Увидимся в следующем разделе!