Files
challenge-admin-pl/WORKPLACE_NUMBER_API.md

17 KiB
Raw Blame History

API изменения: Поле workplaceNumber

Обзор

Добавлено новое поле workplaceNumber для отслеживания рабочего места (компьютера), за которым работает ученик. Это поле сохраняется при авторизации и возвращается во всех эндпоинтах статистики.


1. Авторизация пользователя

POST /challenge/auth

Регистрация или авторизация пользователя с указанием рабочего места.

Изменения

  • Добавлен опциональный параметр workplaceNumber
  • При создании нового пользователя сохраняется workplaceNumber
  • При повторной авторизации существующего пользователя с другим workplaceNumber - значение обновляется
  • Поиск пользователя по-прежнему выполняется только по nickname

Request

POST /challenge/auth
Content-Type: application/json

{
  "nickname": "student_ivan",
  "workplaceNumber": "PC-15"  // Опционально
}

Request Body Parameters

Параметр Тип Обязательный Описание
nickname string Да Никнейм пользователя (3-50 символов)
workplaceNumber string Нет Номер рабочего места/компьютера (макс. 50 символов)

Response

{
  "error": null,
  "result": {
    "ok": true,
    "userId": "507f1f77bcf86cd799439011"
  }
}

Примеры использования

Первая авторизация с рабочим местом:

const response = await fetch('/challenge/auth', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    nickname: 'student_ivan',
    workplaceNumber: 'PC-15'
  })
});

Повторная авторизация с другого места:

// Если пользователь пересел за другой компьютер
const response = await fetch('/challenge/auth', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    nickname: 'student_ivan',
    workplaceNumber: 'PC-20'  // Обновится в базе
  })
});

Авторизация без указания места:

// Работает как раньше, workplaceNumber необязателен
const response = await fetch('/challenge/auth', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    nickname: 'student_ivan'
  })
});

2. Статистика по цепочке заданий

GET /challenge/chain/:chainId/submissions

Получение всех попыток по цепочке с данными о рабочих местах участников.

Требует права: teacher или challenge-author

Изменения

  • В объекте user внутри submissions добавлено поле workplaceNumber
  • В массиве participants добавлено поле workplaceNumber

Request

GET /challenge/chain/507f1f77bcf86cd799439011/submissions?limit=50&offset=0

Query Parameters

Параметр Тип Описание
userId string Фильтр по конкретному пользователю
status string Фильтр по статусу: pending, in_progress, accepted, needs_revision
limit number Количество записей (по умолчанию: 100)
offset number Смещение для пагинации (по умолчанию: 0)

Response

{
  "error": null,
  "result": {
    "chain": {
      "id": "507f1f77bcf86cd799439011",
      "name": "Основы Python",
      "tasks": [
        {
          "id": "507f1f77bcf86cd799439012",
          "title": "Переменные и типы данных"
        }
      ]
    },
    "participants": [
      {
        "userId": "507f1f77bcf86cd799439013",
        "nickname": "student_ivan",
        "workplaceNumber": "PC-15",  // ✨ Новое поле
        "completedTasks": 5,
        "totalTasks": 10,
        "progressPercent": 50
      },
      {
        "userId": "507f1f77bcf86cd799439014",
        "nickname": "student_maria",
        "workplaceNumber": "PC-20",  // ✨ Новое поле
        "completedTasks": 8,
        "totalTasks": 10,
        "progressPercent": 80
      }
    ],
    "submissions": [
      {
        "id": "507f1f77bcf86cd799439015",
        "user": {
          "id": "507f1f77bcf86cd799439013",
          "nickname": "student_ivan",
          "workplaceNumber": "PC-15"  // ✨ Новое поле
        },
        "task": {
          "id": "507f1f77bcf86cd799439012",
          "title": "Переменные и типы данных"
        },
        "status": "accepted",
        "attemptNumber": 2,
        "submittedAt": "2024-01-15T10:30:00.000Z",
        "checkedAt": "2024-01-15T10:31:23.000Z",
        "feedback": "Отличная работа!"
      }
    ],
    "pagination": {
      "total": 150,
      "limit": 50,
      "offset": 0
    }
  }
}

Пример использования

const chainId = '507f1f77bcf86cd799439011';
const response = await fetch(`/challenge/chain/${chainId}/submissions`, {
  headers: {
    'Authorization': 'Bearer YOUR_TOKEN'  // Требуется токен преподавателя
  }
});

const data = await response.json();

// Отобразить список участников с их местами
data.result.participants.forEach(participant => {
  console.log(`${participant.nickname} (${participant.workplaceNumber}): ${participant.progressPercent}%`);
  // Вывод: "student_ivan (PC-15): 50%"
});

3. Расширенная статистика системы

GET /challenge/stats/v2

Получение детальной статистики с данными о рабочих местах участников.

Изменения

  • В массиве activeParticipants добавлено поле workplaceNumber
  • В chainsDetailed[].participantProgress[] добавлено поле workplaceNumber

Request

GET /challenge/stats/v2

или с фильтром по конкретной цепочке:

GET /challenge/stats/v2?chainId=507f1f77bcf86cd799439011

Query Parameters

Параметр Тип Описание
chainId string Опционально: фильтр по конкретной цепочке

Response (фрагмент)

{
  "error": null,
  "result": {
    "users": 25,
    "tasks": 50,
    "chains": 5,
    "submissions": {
      "total": 342,
      "accepted": 150,
      "rejected": 80,
      "pending": 12,
      "inProgress": 100
    },
    "averageCheckTimeMs": 2500,
    "queue": {
      "pending": 5,
      "processing": 2,
      "completed": 335
    },
    "tasksTable": [
      {
        "taskId": "507f1f77bcf86cd799439012",
        "title": "Переменные и типы данных",
        "totalAttempts": 45,
        "uniqueUsers": 20,
        "acceptedCount": 18,
        "successRate": 90,
        "averageAttemptsToSuccess": 2.1
      }
    ],
    "activeParticipants": [
      {
        "userId": "507f1f77bcf86cd799439013",
        "nickname": "student_ivan",
        "workplaceNumber": "PC-15",  // ✨ Новое поле
        "totalSubmissions": 25,
        "completedTasks": 12,
        "chainProgress": [
          {
            "chainId": "507f1f77bcf86cd799439011",
            "chainName": "Основы Python",
            "totalTasks": 10,
            "completedTasks": 8,
            "progressPercent": 80
          }
        ]
      }
    ],
    "chainsDetailed": [
      {
        "chainId": "507f1f77bcf86cd799439011",
        "name": "Основы Python",
        "totalTasks": 10,
        "tasks": [
          {
            "taskId": "507f1f77bcf86cd799439012",
            "title": "Переменные и типы данных",
            "description": "Изучите основные типы данных..."
          }
        ],
        "participantProgress": [
          {
            "userId": "507f1f77bcf86cd799439013",
            "nickname": "student_ivan",
            "workplaceNumber": "PC-15",  // ✨ Новое поле
            "taskProgress": [
              {
                "taskId": "507f1f77bcf86cd799439012",
                "taskTitle": "Переменные и типы данных",
                "status": "completed"
              }
            ],
            "completedCount": 8,
            "progressPercent": 80
          }
        ]
      }
    ]
  }
}

Пример использования

const response = await fetch('/challenge/stats/v2');
const data = await response.json();

// Создать карту класса с прогрессом
const classMap = data.result.activeParticipants.map(participant => ({
  workplace: participant.workplaceNumber || 'Не указано',
  student: participant.nickname,
  progress: participant.completedTasks,
  chains: participant.chainProgress
}));

// Отсортировать по номеру места
classMap.sort((a, b) => {
  const numA = parseInt(a.workplace.replace(/\D/g, '')) || 0;
  const numB = parseInt(b.workplace.replace(/\D/g, '')) || 0;
  return numA - numB;
});

// Визуализация карты класса
classMap.forEach(item => {
  console.log(`[${item.workplace}] ${item.student}: ${item.progress} заданий`);
});
// Вывод:
// [PC-15] student_ivan: 12 заданий
// [PC-20] student_maria: 15 заданий

Примеры интеграции на фронтенде

Компонент авторизации (React)

import { useState } from 'react';

function LoginForm() {
  const [nickname, setNickname] = useState('');
  const [workplaceNumber, setWorkplaceNumber] = useState('');

  const handleLogin = async (e) => {
    e.preventDefault();
    
    const response = await fetch('/challenge/auth', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        nickname,
        workplaceNumber: workplaceNumber || undefined
      })
    });
    
    const data = await response.json();
    
    if (data.result.ok) {
      localStorage.setItem('userId', data.result.userId);
      localStorage.setItem('nickname', nickname);
      localStorage.setItem('workplaceNumber', workplaceNumber);
      // Перенаправление на главную страницу
    }
  };

  return (
    <form onSubmit={handleLogin}>
      <input
        type="text"
        placeholder="Никнейм"
        value={nickname}
        onChange={(e) => setNickname(e.target.value)}
        required
      />
      <input
        type="text"
        placeholder="Номер компьютера (опционально)"
        value={workplaceNumber}
        onChange={(e) => setWorkplaceNumber(e.target.value)}
      />
      <button type="submit">Войти</button>
    </form>
  );
}

Отображение карты класса (React)

function ClassroomMap({ chainId }) {
  const [participants, setParticipants] = useState([]);

  useEffect(() => {
    const fetchData = async () => {
      const response = await fetch(`/challenge/chain/${chainId}/submissions`, {
        headers: {
          'Authorization': `Bearer ${localStorage.getItem('token')}`
        }
      });
      const data = await response.json();
      setParticipants(data.result.participants);
    };
    
    fetchData();
  }, [chainId]);

  return (
    <div className="classroom-map">
      <h2>Карта класса</h2>
      <div className="grid">
        {participants.map(participant => (
          <div 
            key={participant.userId}
            className="student-card"
          >
            <div className="workplace-badge">
              {participant.workplaceNumber || 'N/A'}
            </div>
            <div className="student-info">
              <strong>{participant.nickname}</strong>
              <div className="progress-bar">
                <div 
                  className="progress-fill"
                  style={{ width: `${participant.progressPercent}%` }}
                />
              </div>
              <small>
                {participant.completedTasks} / {participant.totalTasks} заданий
              </small>
            </div>
          </div>
        ))}
      </div>
    </div>
  );
}

TypeScript интерфейсы

// Типы для работы с новым API

interface AuthRequest {
  nickname: string;
  workplaceNumber?: string;
}

interface AuthResponse {
  ok: boolean;
  userId: string;
}

interface UserInfo {
  id: string;
  nickname: string;
  workplaceNumber?: string;  // ✨ Новое поле
}

interface Participant {
  userId: string;
  nickname: string;
  workplaceNumber?: string;  // ✨ Новое поле
  completedTasks: number;
  totalTasks: number;
  progressPercent: number;
}

interface Submission {
  id: string;
  user: UserInfo;  // Содержит workplaceNumber
  task: {
    id: string;
    title: string;
  };
  status: 'pending' | 'in_progress' | 'accepted' | 'needs_revision';
  attemptNumber: number;
  submittedAt: string;
  checkedAt?: string;
  feedback?: string;
}

interface ChainSubmissionsResponse {
  chain: {
    id: string;
    name: string;
    tasks: Array<{ id: string; title: string }>;
  };
  participants: Participant[];
  submissions: Submission[];
  pagination: {
    total: number;
    limit: number;
    offset: number;
  };
}

Миграция существующего кода

До (без workplaceNumber)

// Старый код авторизации
await fetch('/challenge/auth', {
  method: 'POST',
  body: JSON.stringify({ nickname: 'student_ivan' })
});

// Старое отображение участников
participants.forEach(p => {
  console.log(`${p.nickname}: ${p.progressPercent}%`);
});

После (с workplaceNumber)

// Новый код авторизации с местом
await fetch('/challenge/auth', {
  method: 'POST',
  body: JSON.stringify({ 
    nickname: 'student_ivan',
    workplaceNumber: 'PC-15'  // ✨ Добавлено
  })
});

// Новое отображение участников
participants.forEach(p => {
  const workplace = p.workplaceNumber ? `[${p.workplaceNumber}] ` : '';
  console.log(`${workplace}${p.nickname}: ${p.progressPercent}%`);
  // Вывод: "[PC-15] student_ivan: 50%"
});

Обратная совместимость

Все изменения обратно совместимы:

  • Поле workplaceNumber опционально при авторизации
  • Старый код без workplaceNumber продолжит работать
  • Если workplaceNumber не указан, в ответах будет undefined
  • Поиск пользователей по-прежнему работает только по nickname

Рекомендации

  1. При авторизации: Всегда передавайте workplaceNumber, если он известен (например, определяйте автоматически по IP или позволяйте ученику выбрать)

  2. В UI: Отображайте номер места рядом с именем ученика для удобства преподавателя

  3. Сортировка: При отображении списка учеников сортируйте по workplaceNumber для соответствия физическому расположению

  4. Валидация: Проверяйте формат workplaceNumber на фронте (например, "PC-01", "Место 15")

  5. Обновление: Если ученик пересел, просто авторизуйтесь с новым workplaceNumber - значение автоматически обновится


Вопросы и поддержка

При возникновении вопросов обращайтесь к бэкенд-команде или создавайте issue в репозитории проекта.