diff --git a/README.md b/README.md
index 3342c06..ffb6ce0 100644
--- a/README.md
+++ b/README.md
@@ -15,5 +15,15 @@
Откройте `http://127.0.0.1:8000/`. Заявки появляются в админ-панели: `http://127.0.0.1:8000/admin/`.
-Перед публикацией сайта замените `SECRET_KEY`, отключите `DEBUG` и настройте `ALLOWED_HOSTS` в `config/settings.py`.
+## Работа с расписанием
+1. В админ-панели откройте раздел «Расписание» и добавьте свободные окна.
+2. Укажите дату и время, длительность, предмет и формат консультации.
+3. После этого свободное время появится в форме на сайте.
+4. В разделе «Записи на консультации» новая заявка получает статус «Новая».
+5. Подтвердите её вручную или через массовое действие, добавьте ссылку на встречу и при необходимости отметьте напоминание.
+6. После занятия измените статус на «Проведена». Отменённые слоты автоматически снова становятся доступными.
+
+На главной странице также доступны политика конфиденциальности, блок вопросов и адаптивная форма записи.
+
+Перед публикацией сайта замените `SECRET_KEY`, отключите `DEBUG` и настройте `ALLOWED_HOSTS` в `config/settings.py`.
diff --git a/config/settings.py b/config/settings.py
index 6848d2e..482dd83 100644
--- a/config/settings.py
+++ b/config/settings.py
@@ -6,7 +6,7 @@ BASE_DIR = Path(__file__).resolve().parent.parent
SECRET_KEY = "django-insecure-change-this-key-before-production"
DEBUG = True
-ALLOWED_HOSTS = []
+ALLOWED_HOSTS = ["127.0.0.1", "localhost"]
INSTALLED_APPS = [
"django.contrib.admin",
@@ -66,4 +66,3 @@ STATIC_URL = "static/"
STATICFILES_DIRS = [BASE_DIR / "static"]
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
-
diff --git a/config/urls.py b/config/urls.py
index 30fabfc..ad4c055 100644
--- a/config/urls.py
+++ b/config/urls.py
@@ -1,14 +1,10 @@
"""URL configuration for the teacher site."""
-from django.contrib import admin
from django.urls import include, path
+from consultations.admin import teacher_admin_site
+
urlpatterns = [
- path("admin/", admin.site.urls),
+ path("admin/", teacher_admin_site.urls),
path("", include("consultations.urls")),
]
-
-admin.site.site_header = "Записи на консультации"
-admin.site.site_title = "Консультации"
-admin.site.index_title = "Заявки учеников"
-
diff --git a/consultations/admin.py b/consultations/admin.py
index fe36dc8..bf120b9 100644
--- a/consultations/admin.py
+++ b/consultations/admin.py
@@ -1,22 +1,227 @@
-from django.contrib import admin
+from datetime import timedelta
-from .models import ConsultationRequest
+from django.contrib import admin, messages
+from django.contrib.auth.admin import GroupAdmin, UserAdmin
+from django.contrib.auth.models import Group, User
+from django.db.models import Count, Q
+from django.utils import timezone
+from django.utils.html import format_html
+
+from .models import ACTIVE_BOOKING_STATUSES, ConsultationRequest, ConsultationSlot
-@admin.register(ConsultationRequest)
+class TeacherAdminSite(admin.AdminSite):
+ site_header = "Кабинет преподавателя"
+ site_title = "Игорь Безрукавов"
+ index_title = "Обзор записей"
+ index_template = "admin/index.html"
+
+ def index(self, request, extra_context=None):
+ today = timezone.localdate()
+ now = timezone.now()
+ requests = ConsultationRequest.objects.all()
+ extra_context = {
+ **(extra_context or {}),
+ "dashboard": {
+ "new_requests": requests.filter(status=ConsultationRequest.Status.NEW).count(),
+ "today_confirmed": requests.filter(
+ status=ConsultationRequest.Status.CONFIRMED, preferred_date=today
+ ).count(),
+ "upcoming": requests.filter(
+ status__in=ACTIVE_BOOKING_STATUSES,
+ slot__start_at__gte=now,
+ ).count(),
+ "completed_month": requests.filter(
+ status=ConsultationRequest.Status.COMPLETED,
+ preferred_date__year=today.year,
+ preferred_date__month=today.month,
+ ).count(),
+ },
+ "today": today,
+ "next_bookings": requests.filter(
+ status__in=ACTIVE_BOOKING_STATUSES,
+ slot__start_at__gte=now,
+ ).select_related("slot")[:6],
+ }
+ return super().index(request, extra_context=extra_context)
+
+
+teacher_admin_site = TeacherAdminSite(name="teacher_admin")
+teacher_admin_site.register(User, UserAdmin)
+teacher_admin_site.register(Group, GroupAdmin)
+
+
+@admin.register(ConsultationSlot, site=teacher_admin_site)
+class ConsultationSlotAdmin(admin.ModelAdmin):
+ list_display = (
+ "start_at",
+ "duration_minutes",
+ "subject",
+ "format",
+ "booking_state",
+ "is_active",
+ )
+ list_filter = ("subject", "format", "is_active", "start_at")
+ search_fields = ("teacher_note",)
+ list_editable = ("is_active",)
+ date_hierarchy = "start_at"
+ ordering = ("start_at",)
+ readonly_fields = ("created_at",)
+ actions = ("activate_slots", "deactivate_slots", "duplicate_next_week")
+
+ @admin.display(description="Состояние")
+ def booking_state(self, slot):
+ booking = next(
+ (item for item in slot.bookings.all() if item.status in ACTIVE_BOOKING_STATUSES),
+ None,
+ )
+ if booking:
+ return format_html('Занято: {} ', booking.name)
+ if slot.start_at < timezone.now():
+ return format_html('Прошло ')
+ if not slot.is_active:
+ return format_html('Скрыто ')
+ return format_html('Свободно ')
+
+ def get_queryset(self, request):
+ return super().get_queryset(request).prefetch_related("bookings")
+
+ @admin.action(description="Открыть выбранные слоты для записи")
+ def activate_slots(self, request, queryset):
+ count = queryset.update(is_active=True)
+ self.message_user(request, f"Открыто слотов: {count}.", messages.SUCCESS)
+
+ @admin.action(description="Скрыть выбранные слоты с сайта")
+ def deactivate_slots(self, request, queryset):
+ count = queryset.update(is_active=False)
+ self.message_user(request, f"Скрыто слотов: {count}.", messages.SUCCESS)
+
+ @admin.action(description="Создать копии на следующую неделю")
+ def duplicate_next_week(self, request, queryset):
+ created = 0
+ for slot in queryset:
+ next_start = slot.start_at + timedelta(days=7)
+ if not ConsultationSlot.objects.filter(start_at=next_start).exists():
+ ConsultationSlot.objects.create(
+ start_at=next_start,
+ duration_minutes=slot.duration_minutes,
+ subject=slot.subject,
+ format=slot.format,
+ is_active=slot.is_active,
+ teacher_note=slot.teacher_note,
+ )
+ created += 1
+ self.message_user(request, f"Создано слотов: {created}.", messages.SUCCESS)
+
+
+@admin.register(ConsultationRequest, site=teacher_admin_site)
class ConsultationRequestAdmin(admin.ModelAdmin):
list_display = (
- "name",
+ "student_name",
"subject",
"exam",
- "preferred_date",
- "preferred_time",
- "phone",
- "is_processed",
+ "appointment_time",
+ "format",
+ "status_badge",
+ "contact_details",
)
- list_filter = ("subject", "exam", "is_processed", "preferred_date")
- search_fields = ("name", "phone", "contact", "comment")
- list_editable = ("is_processed",)
- readonly_fields = ("created_at",)
+ list_filter = ("status", "subject", "exam", "format", "student_grade", "preferred_date", "reminder_sent")
+ search_fields = ("name", "phone", "contact", "comment", "teacher_note")
date_hierarchy = "preferred_date"
+ list_select_related = ("slot",)
+ readonly_fields = ("created_at", "updated_at", "appointment_summary")
+ actions = ("mark_confirmed", "mark_completed", "mark_cancelled", "mark_reminder_sent")
+ fieldsets = (
+ (
+ "Ученик и контакты",
+ {
+ "fields": (("name", "student_grade"), ("phone", "contact"), "consent_given"),
+ },
+ ),
+ (
+ "Запрос",
+ {
+ "fields": (("subject", "exam", "format"), "comment"),
+ },
+ ),
+ (
+ "Время консультации",
+ {
+ "fields": ("slot", "appointment_summary", ("preferred_date", "preferred_time")),
+ },
+ ),
+ (
+ "Работа с заявкой",
+ {
+ "fields": ("status", "meeting_link", "teacher_note", "cancellation_reason", "reminder_sent"),
+ },
+ ),
+ (
+ "Служебные данные",
+ {
+ "classes": ("collapse",),
+ "fields": ("created_at", "updated_at"),
+ },
+ ),
+ )
+ @admin.display(description="Ученик", ordering="name")
+ def student_name(self, request):
+ grade = f", {request.student_grade} класс" if request.student_grade else ""
+ return f"{request.name}{grade}"
+
+ @admin.display(description="Консультация", ordering="preferred_date")
+ def appointment_time(self, request):
+ if not request.preferred_date:
+ return "Время не выбрано"
+ return f"{request.preferred_date:%d.%m.%Y}, {request.preferred_time:%H:%M}"
+
+ @admin.display(description="Статус", ordering="status")
+ def status_badge(self, request):
+ classes = {
+ ConsultationRequest.Status.NEW: "status-new",
+ ConsultationRequest.Status.CONFIRMED: "status-confirmed",
+ ConsultationRequest.Status.COMPLETED: "status-completed",
+ ConsultationRequest.Status.CANCELLED: "status-cancelled",
+ ConsultationRequest.Status.NO_SHOW: "status-muted",
+ }
+ return format_html(
+ '{} ',
+ classes[request.status],
+ request.get_status_display(),
+ )
+
+ @admin.display(description="Контакты")
+ def contact_details(self, request):
+ contact = f" · {request.contact}" if request.contact else ""
+ return f"{request.phone}{contact}"
+
+ @admin.display(description="Выбранное время")
+ def appointment_summary(self, request):
+ if request.slot:
+ return str(request.slot)
+ if request.preferred_date:
+ return self.appointment_time(request)
+ return "Не выбрано"
+
+ @admin.action(description="Подтвердить выбранные записи")
+ def mark_confirmed(self, request, queryset):
+ count = queryset.exclude(status=ConsultationRequest.Status.CANCELLED).update(
+ status=ConsultationRequest.Status.CONFIRMED
+ )
+ self.message_user(request, f"Подтверждено записей: {count}.", messages.SUCCESS)
+
+ @admin.action(description="Отметить проведёнными")
+ def mark_completed(self, request, queryset):
+ count = queryset.update(status=ConsultationRequest.Status.COMPLETED)
+ self.message_user(request, f"Проведено консультаций: {count}.", messages.SUCCESS)
+
+ @admin.action(description="Отменить выбранные записи")
+ def mark_cancelled(self, request, queryset):
+ count = queryset.update(status=ConsultationRequest.Status.CANCELLED)
+ self.message_user(request, f"Отменено записей: {count}.", messages.SUCCESS)
+
+ @admin.action(description="Отметить напоминание отправленным")
+ def mark_reminder_sent(self, request, queryset):
+ count = queryset.update(reminder_sent=True)
+ self.message_user(request, f"Напоминание отмечено у записей: {count}.", messages.SUCCESS)
diff --git a/consultations/forms.py b/consultations/forms.py
index 2599c96..abfbb19 100644
--- a/consultations/forms.py
+++ b/consultations/forms.py
@@ -1,31 +1,46 @@
from django import forms
from django.utils import timezone
-from .models import ConsultationRequest
+from .models import ConsultationRequest, ConsultationSlot
+
+
+class AvailableSlotChoiceField(forms.ModelChoiceField):
+ def label_from_instance(self, slot):
+ local_start = timezone.localtime(slot.start_at)
+ return (
+ f"{local_start:%d.%m.%Y} в {local_start:%H:%M} "
+ f"· {slot.duration_minutes} минут · {slot.get_format_display()}"
+ )
class ConsultationRequestForm(forms.ModelForm):
+ slot = AvailableSlotChoiceField(
+ queryset=ConsultationSlot.objects.none(),
+ empty_label="Выберите свободное время",
+ label="Удобное время",
+ )
+
class Meta:
model = ConsultationRequest
fields = (
"name",
"phone",
"contact",
+ "student_grade",
"subject",
"exam",
- "preferred_date",
- "preferred_time",
+ "format",
+ "slot",
"comment",
+ "consent_given",
)
widgets = {
"name": forms.TextInput(attrs={"placeholder": "Как к вам обращаться"}),
"phone": forms.TelInput(attrs={"placeholder": "+7 (999) 123-45-67"}),
"contact": forms.TextInput(attrs={"placeholder": "@username, WhatsApp и т. п."}),
- "preferred_date": forms.DateInput(attrs={"type": "date"}),
- "preferred_time": forms.TimeInput(attrs={"type": "time"}),
"comment": forms.Textarea(
attrs={
- "placeholder": "Например: текущий класс, темы, которые вызывают сложности",
+ "placeholder": "Класс, темы и вопросы, которые хотите разобрать",
"rows": 4,
}
),
@@ -33,15 +48,28 @@ class ConsultationRequestForm(forms.ModelForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
- for field in self.fields.values():
- field.widget.attrs["class"] = "form-control"
- self.fields["preferred_date"].widget.attrs["min"] = timezone.localdate().isoformat()
+ self.fields["slot"].queryset = ConsultationSlot.objects.available()
+ for name, field in self.fields.items():
+ if name != "consent_given":
+ field.widget.attrs["class"] = "form-control"
+ self.fields["consent_given"].widget.attrs["class"] = "consent-control"
self.fields["contact"].required = False
+ self.fields["student_grade"].required = False
self.fields["comment"].required = False
- def clean_preferred_date(self):
- preferred_date = self.cleaned_data["preferred_date"]
- if preferred_date < timezone.localdate():
- raise forms.ValidationError("Выберите сегодняшнюю дату или более позднюю.")
- return preferred_date
+ def clean_slot(self):
+ slot = self.cleaned_data["slot"]
+ if not slot.is_available:
+ raise forms.ValidationError("Это время уже занято. Выберите другой свободный слот.")
+ return slot
+ def clean(self):
+ cleaned_data = super().clean()
+ slot = cleaned_data.get("slot")
+ subject = cleaned_data.get("subject")
+ requested_format = cleaned_data.get("format")
+ if slot and subject and slot.subject not in (ConsultationSlot.Subject.ANY, subject):
+ self.add_error("slot", "Это время доступно только для другого предмета.")
+ if slot and requested_format not in (ConsultationRequest.Format.ANY, slot.format):
+ self.add_error("format", f"Для выбранного времени доступен формат: {slot.get_format_display()}.")
+ return cleaned_data
diff --git a/consultations/migrations/0002_consultation_slot_and_booking_workflow.py b/consultations/migrations/0002_consultation_slot_and_booking_workflow.py
new file mode 100644
index 0000000..f425d96
--- /dev/null
+++ b/consultations/migrations/0002_consultation_slot_and_booking_workflow.py
@@ -0,0 +1,179 @@
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("consultations", "0001_initial"),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name="ConsultationSlot",
+ fields=[
+ ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
+ ("start_at", models.DateTimeField(unique=True, verbose_name="Начало консультации")),
+ (
+ "duration_minutes",
+ models.PositiveSmallIntegerField(
+ choices=[(45, "45 минут"), (60, "60 минут"), (90, "90 минут"), (120, "120 минут")],
+ default=60,
+ verbose_name="Длительность, минут",
+ ),
+ ),
+ (
+ "subject",
+ models.CharField(
+ choices=[("any", "Физика или математика"), ("physics", "Физика"), ("mathematics", "Математика")],
+ default="any",
+ max_length=20,
+ verbose_name="Доступный предмет",
+ ),
+ ),
+ (
+ "format",
+ models.CharField(
+ choices=[("online", "Онлайн"), ("offline", "Очно")],
+ default="online",
+ max_length=20,
+ verbose_name="Формат",
+ ),
+ ),
+ ("is_active", models.BooleanField(default=True, verbose_name="Открыт для записи")),
+ ("teacher_note", models.CharField(blank=True, max_length=300, verbose_name="Заметка преподавателя")),
+ ("created_at", models.DateTimeField(auto_now_add=True, verbose_name="Создан")),
+ ],
+ options={
+ "verbose_name": "Свободное время",
+ "verbose_name_plural": "Расписание",
+ "ordering": ("start_at",),
+ },
+ ),
+ migrations.RemoveField(
+ model_name="consultationrequest",
+ name="is_processed",
+ ),
+ migrations.AddField(
+ model_name="consultationrequest",
+ name="cancellation_reason",
+ field=models.CharField(blank=True, max_length=500, verbose_name="Причина отмены"),
+ ),
+ migrations.AddField(
+ model_name="consultationrequest",
+ name="consent_given",
+ field=models.BooleanField(default=False, verbose_name="Согласие на обработку данных"),
+ ),
+ migrations.AddField(
+ model_name="consultationrequest",
+ name="format",
+ field=models.CharField(
+ choices=[("online", "Онлайн"), ("offline", "Очно"), ("any", "Не имеет значения")],
+ default="online",
+ max_length=20,
+ verbose_name="Предпочтительный формат",
+ ),
+ ),
+ migrations.AddField(
+ model_name="consultationrequest",
+ name="meeting_link",
+ field=models.URLField(blank=True, verbose_name="Ссылка на встречу"),
+ ),
+ migrations.AddField(
+ model_name="consultationrequest",
+ name="reminder_sent",
+ field=models.BooleanField(default=False, verbose_name="Напоминание отправлено"),
+ ),
+ migrations.AddField(
+ model_name="consultationrequest",
+ name="status",
+ field=models.CharField(
+ choices=[
+ ("new", "Новая"),
+ ("confirmed", "Подтверждена"),
+ ("completed", "Проведена"),
+ ("cancelled", "Отменена"),
+ ("no_show", "Не пришёл"),
+ ],
+ default="new",
+ max_length=20,
+ verbose_name="Статус",
+ ),
+ ),
+ migrations.AddField(
+ model_name="consultationrequest",
+ name="student_grade",
+ field=models.PositiveSmallIntegerField(
+ blank=True,
+ choices=[(5, "5 класс"), (6, "6 класс"), (7, "7 класс"), (8, "8 класс"), (9, "9 класс"), (10, "10 класс"), (11, "11 класс")],
+ null=True,
+ verbose_name="Класс ученика",
+ ),
+ ),
+ migrations.AddField(
+ model_name="consultationrequest",
+ name="teacher_note",
+ field=models.TextField(blank=True, max_length=2000, verbose_name="Заметка преподавателя"),
+ ),
+ migrations.AddField(
+ model_name="consultationrequest",
+ name="updated_at",
+ field=models.DateTimeField(auto_now=True, verbose_name="Изменена"),
+ ),
+ migrations.AddField(
+ model_name="consultationrequest",
+ name="slot",
+ field=models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ related_name="bookings",
+ to="consultations.consultationslot",
+ verbose_name="Выбранное время",
+ ),
+ ),
+ migrations.AlterField(
+ model_name="consultationrequest",
+ name="exam",
+ field=models.CharField(
+ choices=[("oge", "ОГЭ"), ("ege", "ЕГЭ"), ("school", "Школьная программа"), ("other", "Другая консультация")],
+ max_length=20,
+ verbose_name="Цель подготовки",
+ ),
+ ),
+ migrations.AlterField(
+ model_name="consultationrequest",
+ name="comment",
+ field=models.TextField(blank=True, max_length=1000, verbose_name="Комментарий ученика"),
+ ),
+ migrations.AlterField(
+ model_name="consultationrequest",
+ name="name",
+ field=models.CharField(max_length=100, verbose_name="Имя ученика или родителя"),
+ ),
+ migrations.AlterField(
+ model_name="consultationrequest",
+ name="preferred_date",
+ field=models.DateField(blank=True, null=True, verbose_name="Дата консультации"),
+ ),
+ migrations.AlterField(
+ model_name="consultationrequest",
+ name="preferred_time",
+ field=models.TimeField(blank=True, null=True, verbose_name="Время консультации"),
+ ),
+ migrations.AlterModelOptions(
+ name="consultationrequest",
+ options={
+ "ordering": ("preferred_date", "preferred_time", "-created_at"),
+ "verbose_name": "Запись на консультацию",
+ "verbose_name_plural": "Записи на консультации",
+ },
+ ),
+ migrations.AddConstraint(
+ model_name="consultationrequest",
+ constraint=models.UniqueConstraint(
+ condition=models.Q(("status__in", ("new", "confirmed"))),
+ fields=("slot",),
+ name="unique_active_slot_booking",
+ ),
+ ),
+ ]
diff --git a/consultations/models.py b/consultations/models.py
index 338cdaf..9e0225c 100644
--- a/consultations/models.py
+++ b/consultations/models.py
@@ -1,5 +1,67 @@
+from datetime import timedelta
+
from django.core.validators import RegexValidator
from django.db import models
+from django.db.models import Q
+from django.utils import timezone
+
+
+ACTIVE_BOOKING_STATUSES = ("new", "confirmed")
+
+
+class ConsultationSlotQuerySet(models.QuerySet):
+ def available(self):
+ return (
+ self.filter(is_active=True, start_at__gte=timezone.now())
+ .exclude(bookings__status__in=ACTIVE_BOOKING_STATUSES)
+ .distinct()
+ )
+
+
+class ConsultationSlot(models.Model):
+ class Subject(models.TextChoices):
+ ANY = "any", "Физика или математика"
+ PHYSICS = "physics", "Физика"
+ MATHEMATICS = "mathematics", "Математика"
+
+ class Format(models.TextChoices):
+ ONLINE = "online", "Онлайн"
+ OFFLINE = "offline", "Очно"
+
+ start_at = models.DateTimeField("Начало консультации", unique=True)
+ duration_minutes = models.PositiveSmallIntegerField(
+ "Длительность, минут",
+ choices=((45, "45 минут"), (60, "60 минут"), (90, "90 минут"), (120, "120 минут")),
+ default=60,
+ )
+ subject = models.CharField(
+ "Доступный предмет", max_length=20, choices=Subject.choices, default=Subject.ANY
+ )
+ format = models.CharField("Формат", max_length=20, choices=Format.choices, default=Format.ONLINE)
+ is_active = models.BooleanField("Открыт для записи", default=True)
+ teacher_note = models.CharField("Заметка преподавателя", max_length=300, blank=True)
+ created_at = models.DateTimeField("Создан", auto_now_add=True)
+
+ objects = ConsultationSlotQuerySet.as_manager()
+
+ class Meta:
+ verbose_name = "Свободное время"
+ verbose_name_plural = "Расписание"
+ ordering = ("start_at",)
+
+ def __str__(self):
+ local_start = timezone.localtime(self.start_at)
+ return f"{local_start:%d.%m.%Y, %H:%M} · {self.get_format_display()}"
+
+ @property
+ def end_at(self):
+ return self.start_at + timedelta(minutes=self.duration_minutes)
+
+ @property
+ def is_available(self):
+ return self.is_active and self.start_at >= timezone.now() and not self.bookings.filter(
+ status__in=ACTIVE_BOOKING_STATUSES
+ ).exists()
class ConsultationRequest(models.Model):
@@ -10,9 +72,22 @@ class ConsultationRequest(models.Model):
class Exam(models.TextChoices):
OGE = "oge", "ОГЭ"
EGE = "ege", "ЕГЭ"
+ SCHOOL = "school", "Школьная программа"
OTHER = "other", "Другая консультация"
- name = models.CharField("Имя ученика", max_length=100)
+ class Format(models.TextChoices):
+ ONLINE = "online", "Онлайн"
+ OFFLINE = "offline", "Очно"
+ ANY = "any", "Не имеет значения"
+
+ class Status(models.TextChoices):
+ NEW = "new", "Новая"
+ CONFIRMED = "confirmed", "Подтверждена"
+ COMPLETED = "completed", "Проведена"
+ CANCELLED = "cancelled", "Отменена"
+ NO_SHOW = "no_show", "Не пришёл"
+
+ name = models.CharField("Имя ученика или родителя", max_length=100)
phone = models.CharField(
"Телефон",
max_length=30,
@@ -24,19 +99,54 @@ class ConsultationRequest(models.Model):
],
)
contact = models.CharField("Telegram или другой способ связи", max_length=100, blank=True)
+ student_grade = models.PositiveSmallIntegerField(
+ "Класс ученика", choices=tuple((grade, f"{grade} класс") for grade in range(5, 12)), blank=True, null=True
+ )
subject = models.CharField("Предмет", max_length=20, choices=Subject.choices)
exam = models.CharField("Цель подготовки", max_length=20, choices=Exam.choices)
- preferred_date = models.DateField("Желаемая дата")
- preferred_time = models.TimeField("Желаемое время")
- comment = models.TextField("Комментарий", max_length=1000, blank=True)
+ format = models.CharField("Предпочтительный формат", max_length=20, choices=Format.choices, default=Format.ONLINE)
+ slot = models.ForeignKey(
+ ConsultationSlot,
+ verbose_name="Выбранное время",
+ related_name="bookings",
+ on_delete=models.SET_NULL,
+ blank=True,
+ null=True,
+ )
+ preferred_date = models.DateField("Дата консультации", blank=True, null=True)
+ preferred_time = models.TimeField("Время консультации", blank=True, null=True)
+ comment = models.TextField("Комментарий ученика", max_length=1000, blank=True)
+ status = models.CharField("Статус", max_length=20, choices=Status.choices, default=Status.NEW)
+ teacher_note = models.TextField("Заметка преподавателя", max_length=2000, blank=True)
+ meeting_link = models.URLField("Ссылка на встречу", blank=True)
+ cancellation_reason = models.CharField("Причина отмены", max_length=500, blank=True)
+ reminder_sent = models.BooleanField("Напоминание отправлено", default=False)
+ consent_given = models.BooleanField("Согласие на обработку данных", default=False)
created_at = models.DateTimeField("Создана", auto_now_add=True)
- is_processed = models.BooleanField("Заявка обработана", default=False)
+ updated_at = models.DateTimeField("Изменена", auto_now=True)
class Meta:
- verbose_name = "Заявка на консультацию"
- verbose_name_plural = "Заявки на консультации"
- ordering = ("is_processed", "preferred_date", "preferred_time")
+ verbose_name = "Запись на консультацию"
+ verbose_name_plural = "Записи на консультации"
+ ordering = ("preferred_date", "preferred_time", "-created_at")
+ constraints = [
+ models.UniqueConstraint(
+ fields=("slot",),
+ condition=Q(status__in=ACTIVE_BOOKING_STATUSES),
+ name="unique_active_slot_booking",
+ )
+ ]
+
+ def save(self, *args, **kwargs):
+ if self.slot_id:
+ local_start = timezone.localtime(self.slot.start_at)
+ self.preferred_date = local_start.date()
+ self.preferred_time = local_start.time()
+ if self.format == self.Format.ANY:
+ self.format = self.slot.format
+ super().save(*args, **kwargs)
def __str__(self):
- return f"{self.name}: {self.get_subject_display()} — {self.preferred_date:%d.%m.%Y}"
-
+ if self.preferred_date and self.preferred_time:
+ return f"{self.name}: {self.get_subject_display()} — {self.preferred_date:%d.%m.%Y}, {self.preferred_time:%H:%M}"
+ return f"{self.name}: {self.get_subject_display()}"
diff --git a/consultations/templates/admin/base_site.html b/consultations/templates/admin/base_site.html
new file mode 100644
index 0000000..5b52c16
--- /dev/null
+++ b/consultations/templates/admin/base_site.html
@@ -0,0 +1,15 @@
+{% extends "admin/base.html" %}
+{% load i18n static %}
+
+{% block title %}{{ title }} | Кабинет преподавателя{% endblock %}
+
+{% block extrastyle %}
+{{ block.super }}
+
+{% endblock %}
+
+{% block branding %}
+
+{% endblock %}
+
+{% block nav-global %}Открыть сайт ↗ {% endblock %}
diff --git a/consultations/templates/admin/index.html b/consultations/templates/admin/index.html
new file mode 100644
index 0000000..8cdfe59
--- /dev/null
+++ b/consultations/templates/admin/index.html
@@ -0,0 +1,24 @@
+{% extends "admin/base_site.html" %}
+{% load i18n %}
+
+{% block content %}
+
+
+ Рабочий обзор
Добрый день, {{ user.get_short_name|default:user.get_username }}. Управляйте расписанием и заявками в одном месте.
+ + Добавить свободное время
+
+
+
+
+ {% if next_bookings %}
{% else %}
Ближайших записей пока нет. Добавьте свободные окна в расписание.
{% endif %}
+
+
+
+
{% include "admin/app_list.html" with app_list=app_list show_changelinks=True %}
+
+{% endblock %}
diff --git a/consultations/templates/consultations/home.html b/consultations/templates/consultations/home.html
index f81ae64..98481a6 100644
--- a/consultations/templates/consultations/home.html
+++ b/consultations/templates/consultations/home.html
@@ -4,191 +4,221 @@
-
- Физика и математика — Игорь Олегович Безрукавов
+
+ Игорь Безрукавов — физика и математика
-
+
-
-
-
Физика и математика · 5–11 классы
-
Готовимся к экзаменам с пониманием , а не наугад.
-
Индивидуальные консультации по подготовке к ОГЭ и ЕГЭ: разбираем сложные темы, выстраиваем логику решения и спокойно идём к цели.
+
+
+
Физика · математика · 5–11 классы
+
Экзамен — это задача. Её можно решить.
+
Индивидуальная подготовка к ОГЭ и ЕГЭ: от пробелов в темах до уверенности в каждом шаге решения.
-
-
10+ лет опыта
-
2 предмета
-
ОГЭ · ЕГЭ подготовка к экзаменам
+
+
10+ лет преподавания и репетиторства
+
2 предмета для уверенного результата
+
АГУ бакалавриат и магистратура
-