В современных онлайн‑школах процесс отмены занятия является одной из самых часто используемых функций. Правильно реализованный механизм позволяет:
- Сохранить учебный график в актуальном состоянии;
- Уведомить преподавателя и ученика о смене даты/времени;
- Сформировать корректный отчет по отменённым и перенесённым урокам;
- Избежать конфликтов в расписании и двойных записей.
В статье рассмотрим полный стек – от базы данных до клиентского интерфейса, а также лучшие практики и готовый пример кода на C# (ASP.NET Core) и JavaScript.
Бизнес‑требования к функции отмены урока
- Кто может отменить? – ученик, преподаватель и администратор.
- Время до начала занятия. – если до начала < 24 ч, отмена возможна только администратором.
- Причина отмены. – обязательное поле (текстовое или из списка).
- Уведомления. – email и push‑уведомление обеим сторонам.
- Отчётность. – запись в журнал «Пропущенных и заменённых уроков».
- История изменений. – хранение оригинального времени и новой даты (если перенос).
Архитектурный обзор
Схема взаимодействия:
┌─────────────┐ ┌─────────────────────┐
│ Frontend │ HTTP │ API (ASP.NET) │
│ (React/JS) │ ───────► │ /api/lessons/{id} │
└─────┬───────┘ └───────┬─────────────┘
│ │
│ 2. Запрос отмены │ 3. Валидация + бизнес‑логика
▼ ▼
┌─────────────────────┐ ┌───────────────────────┐
│ База данных (SQL) │ │ Сервис уведомлений │
│ lessons, cancellations│ │ (SMTP, Firebase) │
└─────────────────────┘ └───────────────────────┘
Ключевые таблицы:
Lessons– информация о запланированных занятиях.LessonCancellations– записи об отменах/переносах.NotificationQueue– очередь сообщений для отправки.
Реализация на стороне сервера (ASP.NET Core)
Модель данных
public class Lesson
{
public int Id { get; set; }
public int TeacherId { get; set; }
public int StudentId { get; set; }
public DateTime StartTime { get; set; }
public DateTime EndTime { get; set; }
public LessonStatus Status { get; set; } // Scheduled, Cancelled, Rescheduled
// … другие поля
}
public class LessonCancellation
{
public int Id { get; set; }
public int LessonId { get; set; }
public DateTime CancelledAt { get; set; }
public string Reason { get; set; }
public string CancelledBy { get; set; } // "Student", "Teacher", "Admin"
public DateTime? NewStartTime { get; set; } // если перенос
}
API‑контроллер
[ApiController]
[Route("api/lessons")]
public class LessonsController : ControllerBase
{
private readonly ApplicationDbContext _db;
private readonly INotificationService _notifier;
public LessonsController(ApplicationDbContext db, INotificationService notifier)
{
_db = db;
_notifier = notifier;
}
/// <summary>
/// Отменить или перенести урок.
/// </summary>
[HttpPost("{id}/cancel")]
public async Task CancelLesson(int id, [FromBody] CancelLessonDto dto)
{
// 1️⃣ Получаем урок
var lesson = await _db.Lessons.FindAsync(id);
if (lesson == null) return NotFound("Урок не найден.");
// 2️⃣ Проверка прав доступа
var userId = User.GetUserId; // метод расширения
var role = User.GetRole; // "Student", "Teacher", "Admin"
if (!CanCancel(lesson, role, userId, dto.NewStartTime))
return Forbid("У вас нет прав для отмены данного урока.");
// 3️⃣ Валидация времени
var now = DateTime.UtcNow;
if (lesson.StartTime <= now)
return BadRequest("Нельзя отменить уже прошедший урок.");
if (dto.NewStartTime;HasValue && (dto.NewStartTime.Value ⏤ now).TotalHours < 24)
return BadRequest("Перенос менее чем за 24 часа доступен только администратору.");
// 4️⃣ Сохраняем запись об отмене
var cancellation = new LessonCancellation
{
LessonId = lesson.Id,
CancelledAt = now,
Reason = dto.Reason,
CancelledBy = role,
NewStartTime = dto.NewStartTime
};
_db.LessonCancellations.Add(cancellation);
// 5️⃣ Обновляем статус урока
lesson.Status = dto.NewStartTime.HasValue ? LessonStatus.Rescheduled : LessonStatus.Cancelled;
if (dto.NewStartTime.HasValue)
lesson;StartTime = dto.NewStartTime.Value; // перенос
await _db.SaveChangesAsync;
// 6️⃣ Уведомляем стороны
await _notifier.SendLessonCancellationAsync(lesson, cancellation);
return Ok(new { message = "Урок успешно отменён/перенесён." });
}
private bool CanCancel(Lesson lesson, string role, int userId, DateTime? newStart)
{
// Студент может отменять только свои занятия
if (role == "Student" && lesson.StudentId != userId) return false;
// Преподаватель – только свои
if (role == "Teacher" && lesson.TeacherId != userId) return false;
// Администратор – без ограничений
return true;
}
}
public class CancelLessonDto
{
public string Reason { get; set; } // обязательное поле
public DateTime? NewStartTime { get; set; } // null – полная отмена
}
Сервис уведомлений
public interface INotificationService
{
Task SendLessonCancellationAsync(Lesson lesson, LessonCancellation cancellation);
}
public class EmailNotificationService : INotificationService
{
private readonly SmtpClient _smtp;
public EmailNotificationService(SmtpClient smtp) => _smtp = smtp;
public async Task SendLessonCancellationAsync(Lesson lesson, LessonCancellation cancellation)
{
var studentMail = await GetUserEmailAsync(lesson.StudentId);
var teacherMail = await GetUserEmailAsync(lesson.TeacherId);
var subject = cancellation.NewStartTime.HasValue
? "Урок перенесён"
: "Урок отменён";
var body = $@"
Здравствуйте,
Урок, запланированный на {lesson;StartTime:dd.MM.yyyy HH:mm},
был {(cancellation.NewStartTime.HasValue ? "перенесён" : "отменён")}.
Причина: {cancellation.Reason}
{(cancellation.NewStartTime.HasValue ? $"Новая дата: {cancellation.NewStartTime:dd.MM.yyyy HH:mm}" : "")}
С уважением,
Онлайн‑школа
";
var msg = new MailMessage("no-reply@school.com", studentMail, subject, body);
await _smtp.SendMailAsync(msg);
// отправляем учителю
msg = new MailMessage("no-reply@school.com", teacherMail, subject, body);
await _smtp.SendMailAsync(msg);
}
private Task GetUserEmailAsync(int userId)
{
// запрос к базе или к Identity‑сервису
// упрощённо:
return Task.FromResult($"user{userId}@example.com");
}
}
Реализация на клиенте (JavaScript/React)
UI‑компонент
import React, { useState } from 'react';
import axios from 'axios';
function CancelLessonButton({ lessonId, startTime }) {
const [showForm, setShowForm] = useState(false);
const [reason, setReason] = useState('');
const [newDate, setNewDate] = useState('');
const [error, setError] = useState(null);
const [loading, setLoading] = useState(false);
const handleCancel = async => {
setLoading(true);
setError(null);
try {
const payload = {
reason,
newStartTime: newDate ? new Date(newDate).toISOString : null,
};
const resp = await axios.post(
`/api/lessons/${lessonId}/cancel`,
payload
);
alert(resp.data.message);
// Обновить UI – например, вызвать refetch списка уроков
} catch (e) {
setError(e.response?.data || 'Произошла ошибка');
} finally {
setLoading(false);
}
};
return (
<div className="cancel-lesson">
<button onClick={ => setShowForm(!showForm)}>
{showForm ? 'Скрыть форму' : 'Отменить/перенести урок'}
</button>
{showForm && (
<div className="form">
<label>Причина (обязательно):</label>
<textarea
value={reason}
onChange={e => setReason(e.target.value)}
required
/>
<label>Новая дата/время (если перенос):</label>
<input
type="datetime-local"
value={newDate}
onChange={e => setNewDate(e.target.value)}
/>
{error && <p className="error">{error}</p>}
<button onClick={handleCancel} disabled={loading || !reason.trim}>
{loading ? 'Отправка…' : 'Подтвердить'}
</button>
</div>
)}
</div>
);
}export default CancelLessonButton;
Обработка ответа и обновление UI
После успешного запроса рекомендуется:
- Обновить локальный кэш расписания (например, через
react-query). - Показать пользователю toast‑сообщение о результате.
- Если был перенос, отобразить новое время в списке уроков.
Уведомления и отчётность
Журнал отмен
CREATE TABLE LessonCancellations (
Id INT PRIMARY KEY IDENTITY,
LessonId INT NOT NULL,
CancelledAt DATETIME2 NOT NULL,
Reason NVARCHAR(500) NOT NULL,
CancelledBy NVARCHAR(20) NOT NULL,
NewStartTime DATETIME2 NULL,
CONSTRAINT FK_LessonCancellations_Lessons
FOREIGN KEY (LessonId) REFERENCES Lessons(Id)
);
Формирование отчёта
Пример запроса для администраторов:
SELECT
l.Id AS LessonId,
l.StartTime AS OriginalStart,
c.CancelledAt,
c.Reason,
c.CancelledBy,
c.NewStartTime
FROM Lessons l
JOIN LessonCancellations c ON l.Id = c.LessonId
WHERE c.CancelledAt BETWEEN @FromDate AND @ToDate
ORDER BY c.CancelledAt DESC;
Триггер для автоматической записи в журнал
CREATE TRIGGER trg_AfterLessonUpdate
ON Lessons
AFTER UPDATE
AS
BEGIN
SET NOCOUNT ON;
IF UPDATE(Status)
BEGIN
INSERT INTO LessonCancellations (LessonId, CancelledAt, Reason, CancelledBy, NewStartTime)
SELECT
inserted.Id,
GETUTCDATE,
'Системный перенос',
'System',
CASE WHEN inserted;Status = 'Rescheduled' THEN inserted.StartTime END
FROM inserted
WHERE inserted.Status IN ('Cancelled','Rescheduled');
END
END;
Особые случаи и рекомендации
Отмена менее чем за 24 ч
- Только администратор имеет право отменять/переносить в этот период.
- В UI скрыть кнопку «Отменить» для студентов/преподавателей, если до начала < 24 ч.
Повторные отмены
Если урок уже отмечен как Cancelled, повторный запрос должен вернуть 400 Bad Request с сообщением «Урок уже отменён».
Защита от подделки запросов (CSRF)
Для SPA‑приложений используйте токен SameSite=Lax + заголовок X‑XSRF‑Token. На сервере проверяйте его в каждом POST‑запросе.
Логирование
Каждое действие отмены следует записывать в централизованный лог (Serilog, ELK) с полями:
- userId, role
- lessonId
- action = "Cancel" / "Reschedule"
- timestamp
- reason
Тестирование
- Unit‑тесты контроллера (мок репозитория, проверка прав).
- Integration‑тесты с реальной БД (EF Core InMemory).
- E2E‑тесты (Cypress) – имитировать отмену урока пользователем разных ролей.
Полный пример проекта
Для быстрого старта можно клонировать репозиторий:
git clone https://github.com/example/online-school-cancel-demo.git
cd online-school-cancel-demo
dotnet build
dotnet run
В проекте уже присутствуют:
- Entity Framework Core модели
LessonиLessonCancellation; - API‑контроллер
LessonsControllerс методомCancelLesson; - React‑компонент
CancelLessonButton; - Сервис отправки email через
SmtpClient; - Тесты на xUnit и Cypress.
Функция отмены урока – ключевой элемент любой онлайн‑школы. При правильной реализации она обеспечивает:
- Прозрачность расписания;
- Своевременное информирование всех участников;
- Надёжный аудит и возможность анализа причин отмен.
Следуя рекомендациям из этой статьи, вы сможете быстро внедрить безопасный и удобный механизм отмены/переноса занятий, а также адаптировать его под любые бизнес‑правила вашей школы.
Помните: всегда проверяйте права пользователя, валидируйте время и сохраняйте полную историю изменений – это поможет избежать конфликтов и упростит работу администраторов.
Дата публикации: