update admin panel and visual
This commit is contained in:
+217
-12
@@ -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('<span class="status-chip status-confirmed">Занято: {}</span>', booking.name)
|
||||
if slot.start_at < timezone.now():
|
||||
return format_html('<span class="status-chip status-muted">Прошло</span>')
|
||||
if not slot.is_active:
|
||||
return format_html('<span class="status-chip status-muted">Скрыто</span>')
|
||||
return format_html('<span class="status-chip status-new">Свободно</span>')
|
||||
|
||||
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(
|
||||
'<span class="status-chip {}">{}</span>',
|
||||
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)
|
||||
|
||||
+42
-14
@@ -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
|
||||
|
||||
@@ -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",
|
||||
),
|
||||
),
|
||||
]
|
||||
+120
-10
@@ -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()}"
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
{% extends "admin/base.html" %}
|
||||
{% load i18n static %}
|
||||
|
||||
{% block title %}{{ title }} | Кабинет преподавателя{% endblock %}
|
||||
|
||||
{% block extrastyle %}
|
||||
{{ block.super }}
|
||||
<link rel="stylesheet" href="{% static 'admin/css/teacher_admin.css' %}">
|
||||
{% endblock %}
|
||||
|
||||
{% block branding %}
|
||||
<div id="site-name"><a href="{% url 'teacher_admin:index' %}"><span>ИО</span> Кабинет преподавателя</a></div>
|
||||
{% endblock %}
|
||||
|
||||
{% block nav-global %}<a class="admin-site-link" href="/" target="_blank" rel="noopener">Открыть сайт ↗</a>{% endblock %}
|
||||
@@ -0,0 +1,24 @@
|
||||
{% extends "admin/base_site.html" %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block content %}
|
||||
<div class="dashboard">
|
||||
<section class="dashboard-hero">
|
||||
<div><p>Рабочий обзор</p><h1>Добрый день, {{ user.get_short_name|default:user.get_username }}.</h1><span>Управляйте расписанием и заявками в одном месте.</span></div>
|
||||
<a class="dashboard-primary" href="{% url 'teacher_admin:consultations_consultationslot_add' %}">+ Добавить свободное время</a>
|
||||
</section>
|
||||
<section class="dashboard-metrics">
|
||||
<a href="{% url 'teacher_admin:consultations_consultationrequest_changelist' %}?status__exact=new"><strong>{{ dashboard.new_requests }}</strong><span>новых заявок</span></a>
|
||||
<a href="{% url 'teacher_admin:consultations_consultationrequest_changelist' %}?status__exact=confirmed&preferred_date__exact={{ today|date:'Y-m-d' }}"><strong>{{ dashboard.today_confirmed }}</strong><span>подтверждено на сегодня</span></a>
|
||||
<a href="{% url 'teacher_admin:consultations_consultationrequest_changelist' %}"><strong>{{ dashboard.upcoming }}</strong><span>ближайших консультаций</span></a>
|
||||
<a href="{% url 'teacher_admin:consultations_consultationrequest_changelist' %}?status__exact=completed"><strong>{{ dashboard.completed_month }}</strong><span>проведено за месяц</span></a>
|
||||
</section>
|
||||
<section class="dashboard-grid">
|
||||
<div class="dashboard-panel"><div class="panel-heading"><h2>Ближайшие записи</h2><a href="{% url 'teacher_admin:consultations_consultationrequest_changelist' %}">Все заявки →</a></div>
|
||||
{% if next_bookings %}<div class="booking-list">{% for booking in next_bookings %}<a href="{% url 'teacher_admin:consultations_consultationrequest_change' booking.pk %}"><span class="booking-date">{{ booking.preferred_date|date:"d M" }}<small>{{ booking.preferred_time|time:"H:i" }}</small></span><span><strong>{{ booking.name }}</strong><small>{{ booking.get_subject_display }} · {{ booking.get_exam_display }}</small></span><span class="dashboard-status status-{{ booking.status }}">{{ booking.get_status_display }}</span></a>{% endfor %}</div>{% else %}<p class="empty-panel">Ближайших записей пока нет. Добавьте свободные окна в расписание.</p>{% endif %}
|
||||
</div>
|
||||
<div class="dashboard-panel quick-panel"><div class="panel-heading"><h2>Быстрые действия</h2></div><a href="{% url 'teacher_admin:consultations_consultationslot_changelist' %}">Календарь и свободные окна <span>→</span></a><a href="{% url 'teacher_admin:consultations_consultationrequest_changelist' %}?status__exact=new">Новые заявки <span>→</span></a><a href="{% url 'teacher_admin:consultations_consultationrequest_add' %}">Добавить запись вручную <span>→</span></a></div>
|
||||
</section>
|
||||
<section class="dashboard-apps">{% include "admin/app_list.html" with app_list=app_list show_changelinks=True %}</section>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -4,191 +4,221 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="description" content="Подготовка к ОГЭ и ЕГЭ по физике и математике с преподавателем Игорем Олеговичем Безрукавовым.">
|
||||
<title>Физика и математика — Игорь Олегович Безрукавов</title>
|
||||
<meta name="description" content="Подготовка к ОГЭ и ЕГЭ по физике и математике с Игорем Олеговичем Безрукавовым.">
|
||||
<title>Игорь Безрукавов — физика и математика</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&family=Source+Serif+4:opsz,wght@8..60,600;8..60,700&display=swap" rel="stylesheet">
|
||||
<link href="https://fonts.googleapis.com/css2?family=DM+Mono&family=Manrope:wght@400;500;600;700;800&family=Source+Serif+4:opsz,wght@8..60,600;8..60,700&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="{% static 'css/style.css' %}">
|
||||
</head>
|
||||
<body>
|
||||
<header class="site-header">
|
||||
<div class="container navigation">
|
||||
<a class="brand" href="#top" aria-label="На главную">ИО<span>·</span>учёба</a>
|
||||
<a class="brand" href="#top" aria-label="На главную"><span>ИО</span> physics & math</a>
|
||||
<nav class="nav-links" aria-label="Основная навигация">
|
||||
<a href="#about">О преподавателе</a>
|
||||
<a href="#subjects">Направления</a>
|
||||
<a href="#booking">Запись</a>
|
||||
<a href="#programs">Направления</a>
|
||||
<a href="#faq">Вопросы</a>
|
||||
</nav>
|
||||
<a class="nav-button" href="#booking">Записаться</a>
|
||||
<a class="header-action" href="#booking">Выбрать время <span aria-hidden="true">↗</span></a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main id="top">
|
||||
<section class="hero">
|
||||
<div class="container hero-grid">
|
||||
<div class="hero-content">
|
||||
<p class="eyebrow">Физика и математика · 5–11 классы</p>
|
||||
<h1>Готовимся к экзаменам <em>с пониманием</em>, а не наугад.</h1>
|
||||
<p class="hero-text">Индивидуальные консультации по подготовке к ОГЭ и ЕГЭ: разбираем сложные темы, выстраиваем логику решения и спокойно идём к цели.</p>
|
||||
<div class="container hero-layout">
|
||||
<div class="hero-copy">
|
||||
<p class="eyebrow"><span></span> Физика · математика · 5–11 классы</p>
|
||||
<h1>Экзамен — это задача. <em>Её можно решить.</em></h1>
|
||||
<p class="hero-lead">Индивидуальная подготовка к ОГЭ и ЕГЭ: от пробелов в темах до уверенности в каждом шаге решения.</p>
|
||||
<div class="hero-actions">
|
||||
<a class="button button-primary" href="#booking">Записаться на консультацию</a>
|
||||
<a class="text-link" href="#about">Узнать обо мне <span aria-hidden="true">↓</span></a>
|
||||
<a class="button button-primary" href="#booking">Записаться на консультацию <span aria-hidden="true">→</span></a>
|
||||
<a class="button button-ghost" href="#about">О преподавателе</a>
|
||||
</div>
|
||||
<div class="hero-stats" aria-label="Ключевые факты">
|
||||
<div><strong>10+</strong><span>лет опыта</span></div>
|
||||
<div><strong>2</strong><span>предмета</span></div>
|
||||
<div><strong>ОГЭ · ЕГЭ</strong><span>подготовка к экзаменам</span></div>
|
||||
<div class="hero-numbers" aria-label="Опыт преподавателя">
|
||||
<div><strong>10+</strong><span>лет преподавания<br>и репетиторства</span></div>
|
||||
<div><strong>2</strong><span>предмета для<br>уверенного результата</span></div>
|
||||
<div><strong>АГУ</strong><span>бакалавриат<br>и магистратура</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="formula-card" aria-label="Физика и математика">
|
||||
<div class="card-orbit orbit-one"></div>
|
||||
<div class="card-orbit orbit-two"></div>
|
||||
<span class="formula formula-e">E = mc²</span>
|
||||
<span class="formula formula-root">√x</span>
|
||||
<span class="formula formula-sum">Σ</span>
|
||||
<div class="formula-center">
|
||||
<p>точные науки</p>
|
||||
<strong>понятно<br>и спокойно</strong>
|
||||
<div class="portrait-zone">
|
||||
<div class="portrait-frame">
|
||||
<img src="{% static 'images/igor-bezrukavov.jpg' %}" alt="Игорь Олегович Безрукавов">
|
||||
</div>
|
||||
<div class="portrait-note"><span>→</span> Разбираем<br>до понимания</div>
|
||||
<div class="formula-sticker" aria-hidden="true"><i>F</i><small>= ma</small></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="trust-strip">
|
||||
<div class="container trust-content">
|
||||
<span>Понятная система подготовки</span><b>·</b>
|
||||
<span>Спокойный темп</span><b>·</b>
|
||||
<span>Индивидуальный план</span><b>·</b>
|
||||
<span>Фокус на результате</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section about" id="about">
|
||||
<div class="container split-layout">
|
||||
<p class="section-kicker">О преподавателе</p>
|
||||
<div>
|
||||
<h2>Игорь Олегович<br>Безрукавов</h2>
|
||||
<p class="lead">Преподаватель физики и математики с опытом преподавания и репетиторства более 10 лет.</p>
|
||||
<p>Окончил Астраханский государственный университет: бакалавриат и магистратуру по специальности «Преподавание физики и математики».</p>
|
||||
<p>На консультации важно не просто получить ответ, а разобраться, почему решение работает — тогда знания остаются с учеником и на контрольной, и на экзамене.</p>
|
||||
<div class="container about-layout">
|
||||
<div class="section-label"><span>01</span><p>О преподавателе</p></div>
|
||||
<div class="about-content">
|
||||
<h2>Понимание формул начинается с вопроса <em>«почему?»</em></h2>
|
||||
<div class="about-columns">
|
||||
<p class="about-intro">Меня зовут Игорь Олегович Безрукавов. Более 10 лет помогаю школьникам выстроить сильную базу по физике и математике.</p>
|
||||
<div>
|
||||
<p>Окончил Астраханский государственный университет: бакалавриат и магистратуру по специальности «Преподавание физики и математики».</p>
|
||||
<p>На занятии ученик не заучивает шаблон, а учится видеть логику задачи — это помогает уверенно работать и на уроке, и на экзамене.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="education-mark"><span>АГУ</span><p>Педагогическое образование<br>по физике и математике</p></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section subjects" id="subjects">
|
||||
<section class="section programs" id="programs">
|
||||
<div class="container">
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<p class="section-kicker">Направления</p>
|
||||
<h2>Подготовка в нужном темпе</h2>
|
||||
</div>
|
||||
<p>Подберём фокус консультации под текущий уровень, цели и дату экзамена.</p>
|
||||
<div class="section-label"><span>02</span><p>Направления</p></div>
|
||||
<h2>Работаем на<br><em>вашу цель</em></h2>
|
||||
<p>Выбираем темп и программу с учётом класса, стартового уровня и даты экзамена.</p>
|
||||
</div>
|
||||
<div class="subject-grid">
|
||||
<article class="subject-card physics-card">
|
||||
<span class="subject-icon">F</span>
|
||||
<p class="card-kicker">Предмет</p>
|
||||
<div class="program-grid">
|
||||
<article class="program-card physics-card">
|
||||
<div class="program-top"><span class="program-code">01 / PH</span><span class="program-symbol">F</span></div>
|
||||
<h3>Физика</h3>
|
||||
<p>От формул и единиц измерения до сложных задач второй части.</p>
|
||||
<ul>
|
||||
<li>Механика, термодинамика, электродинамика</li>
|
||||
<li>Задачи с формулами и графиками</li>
|
||||
<li>Подготовка к ОГЭ и ЕГЭ</li>
|
||||
<li>ОГЭ и ЕГЭ</li>
|
||||
<li>Механика, электричество, оптика</li>
|
||||
<li>Графики, эксперименты, расчёты</li>
|
||||
</ul>
|
||||
<a href="#booking">Записаться <span aria-hidden="true">→</span></a>
|
||||
<a href="#booking">Выбрать консультацию <span>→</span></a>
|
||||
</article>
|
||||
<article class="subject-card math-card">
|
||||
<span class="subject-icon">π</span>
|
||||
<p class="card-kicker">Предмет</p>
|
||||
<article class="program-card math-card">
|
||||
<div class="program-top"><span class="program-code">02 / MTH</span><span class="program-symbol">π</span></div>
|
||||
<h3>Математика</h3>
|
||||
<p>Понятная алгебра и геометрия вместо набора непонятных правил.</p>
|
||||
<ul>
|
||||
<li>Алгебра, геометрия, вероятность</li>
|
||||
<li>Уравнения, неравенства, задачи</li>
|
||||
<li>Подготовка к ОГЭ и ЕГЭ</li>
|
||||
<li>ОГЭ и ЕГЭ</li>
|
||||
<li>Уравнения, неравенства, функции</li>
|
||||
<li>Планиметрия и стереометрия</li>
|
||||
</ul>
|
||||
<a href="#booking">Записаться <span aria-hidden="true">→</span></a>
|
||||
<a href="#booking">Выбрать консультацию <span>→</span></a>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section process">
|
||||
<div class="container">
|
||||
<p class="section-kicker">Как это работает</p>
|
||||
<h2>Три простых шага</h2>
|
||||
<div class="steps">
|
||||
<article><span>01</span><h3>Оставьте заявку</h3><p>Выберите предмет, экзамен и удобное время.</p></article>
|
||||
<article><span>02</span><h3>Подтвердим время</h3><p>Игорь Олегович свяжется с вами по указанным контактам.</p></article>
|
||||
<article><span>03</span><h3>Начнём подготовку</h3><p>Определим цель и разберём первые задачи.</p></article>
|
||||
<section class="section approach">
|
||||
<div class="container approach-layout">
|
||||
<div class="approach-copy">
|
||||
<div class="section-label"><span>03</span><p>Как проходят занятия</p></div>
|
||||
<h2>Не «натаскивание»,<br>а <em>система.</em></h2>
|
||||
</div>
|
||||
<div class="approach-steps">
|
||||
<article><span>01</span><h3>Диагностика</h3><p>Определяем уровень и темы, на которые стоит обратить внимание в первую очередь.</p></article>
|
||||
<article><span>02</span><h3>План</h3><p>Собираем маршрут подготовки: теория, практика, повторение и работа с ошибками.</p></article>
|
||||
<article><span>03</span><h3>Уверенность</h3><p>Тренируем самостоятельное решение и разбираем вопросы до полного понимания.</p></article>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="booking" id="booking">
|
||||
<section class="booking-section" id="booking">
|
||||
<div class="container booking-layout">
|
||||
<div class="booking-copy">
|
||||
<p class="section-kicker">Запись на консультацию</p>
|
||||
<h2>Сделайте первый шаг к уверенному экзамену.</h2>
|
||||
<p>Заполните форму — время будет окончательно подтверждено после связи с преподавателем.</p>
|
||||
<p class="privacy-note">Контакты используются только для ответа на вашу заявку.</p>
|
||||
<div class="booking-intro">
|
||||
<p class="eyebrow eyebrow-light"><span></span> Онлайн-запись</p>
|
||||
<h2>Выберите удобное время <em>для старта.</em></h2>
|
||||
<p>Слот предварительно бронируется за вами. Игорь Олегович подтвердит консультацию по указанным контактам.</p>
|
||||
<div class="availability-card">
|
||||
<span class="availability-dot"></span>
|
||||
<div><strong>{{ available_slot_count }}</strong><p>свободных окон<br>для записи сейчас</p></div>
|
||||
</div>
|
||||
<p class="booking-help">Не нашли подходящее время? Напишите желаемый день в комментарии — постараемся подобрать вариант.</p>
|
||||
</div>
|
||||
<div class="form-card">
|
||||
<div class="booking-form-card">
|
||||
{% if messages %}
|
||||
{% for message in messages %}
|
||||
<div class="message message-{{ message.tags }}" role="status">{{ message }}</div>
|
||||
<div class="form-message form-message-{{ message.tags }}" role="status">{{ message }}</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% if form.non_field_errors %}
|
||||
<div class="form-errors">{{ form.non_field_errors }}</div>
|
||||
{% endif %}
|
||||
<div class="form-title"><span>Запись на консультацию</span><p>Поля со звёздочкой обязательны</p></div>
|
||||
{% if form.non_field_errors %}<div class="form-errors">{{ form.non_field_errors }}</div>{% endif %}
|
||||
<form method="post" action="#booking" novalidate>
|
||||
{% csrf_token %}
|
||||
<div class="form-grid">
|
||||
<div class="field full-width">
|
||||
<label for="{{ form.name.id_for_label }}">{{ form.name.label }}</label>
|
||||
{{ form.name }}
|
||||
{% for error in form.name.errors %}<span class="error">{{ error }}</span>{% endfor %}
|
||||
<div class="field full">
|
||||
<label for="{{ form.name.id_for_label }}">{{ form.name.label }} <b>*</b></label>
|
||||
{{ form.name }}{% for error in form.name.errors %}<span class="error">{{ error }}</span>{% endfor %}
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="{{ form.phone.id_for_label }}">{{ form.phone.label }}</label>
|
||||
{{ form.phone }}
|
||||
{% for error in form.phone.errors %}<span class="error">{{ error }}</span>{% endfor %}
|
||||
<label for="{{ form.phone.id_for_label }}">{{ form.phone.label }} <b>*</b></label>
|
||||
{{ form.phone }}{% for error in form.phone.errors %}<span class="error">{{ error }}</span>{% endfor %}
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="{{ form.contact.id_for_label }}">{{ form.contact.label }}</label>
|
||||
{{ form.contact }}
|
||||
{% for error in form.contact.errors %}<span class="error">{{ error }}</span>{% endfor %}
|
||||
{{ form.contact }}{% for error in form.contact.errors %}<span class="error">{{ error }}</span>{% endfor %}
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="{{ form.subject.id_for_label }}">{{ form.subject.label }}</label>
|
||||
{{ form.subject }}
|
||||
{% for error in form.subject.errors %}<span class="error">{{ error }}</span>{% endfor %}
|
||||
<label for="{{ form.student_grade.id_for_label }}">{{ form.student_grade.label }}</label>
|
||||
{{ form.student_grade }}{% for error in form.student_grade.errors %}<span class="error">{{ error }}</span>{% endfor %}
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="{{ form.exam.id_for_label }}">{{ form.exam.label }}</label>
|
||||
{{ form.exam }}
|
||||
{% for error in form.exam.errors %}<span class="error">{{ error }}</span>{% endfor %}
|
||||
<label for="{{ form.subject.id_for_label }}">{{ form.subject.label }} <b>*</b></label>
|
||||
{{ form.subject }}{% for error in form.subject.errors %}<span class="error">{{ error }}</span>{% endfor %}
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="{{ form.preferred_date.id_for_label }}">{{ form.preferred_date.label }}</label>
|
||||
{{ form.preferred_date }}
|
||||
{% for error in form.preferred_date.errors %}<span class="error">{{ error }}</span>{% endfor %}
|
||||
<label for="{{ form.exam.id_for_label }}">{{ form.exam.label }} <b>*</b></label>
|
||||
{{ form.exam }}{% for error in form.exam.errors %}<span class="error">{{ error }}</span>{% endfor %}
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="{{ form.preferred_time.id_for_label }}">{{ form.preferred_time.label }}</label>
|
||||
{{ form.preferred_time }}
|
||||
{% for error in form.preferred_time.errors %}<span class="error">{{ error }}</span>{% endfor %}
|
||||
<label for="{{ form.format.id_for_label }}">{{ form.format.label }} <b>*</b></label>
|
||||
{{ form.format }}{% for error in form.format.errors %}<span class="error">{{ error }}</span>{% endfor %}
|
||||
</div>
|
||||
<div class="field full-width">
|
||||
<div class="field full">
|
||||
<label for="{{ form.slot.id_for_label }}">{{ form.slot.label }} <b>*</b></label>
|
||||
{{ form.slot }}{% for error in form.slot.errors %}<span class="error">{{ error }}</span>{% endfor %}
|
||||
</div>
|
||||
<div class="field full">
|
||||
<label for="{{ form.comment.id_for_label }}">{{ form.comment.label }}</label>
|
||||
{{ form.comment }}
|
||||
{% for error in form.comment.errors %}<span class="error">{{ error }}</span>{% endfor %}
|
||||
{{ form.comment }}{% for error in form.comment.errors %}<span class="error">{{ error }}</span>{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
<button class="button button-primary submit-button" type="submit">Отправить заявку</button>
|
||||
<label class="consent-label" for="{{ form.consent_given.id_for_label }}">
|
||||
{{ form.consent_given }}
|
||||
<span>Я согласен на обработку персональных данных в соответствии с <a href="{% url 'consultations:privacy_policy' %}">политикой конфиденциальности</a>. <b>*</b></span>
|
||||
</label>
|
||||
{% for error in form.consent_given.errors %}<span class="error consent-error">{{ error }}</span>{% endfor %}
|
||||
{% if available_slot_count %}
|
||||
<button class="button button-primary form-submit" type="submit">Отправить заявку <span aria-hidden="true">→</span></button>
|
||||
{% else %}
|
||||
<button class="button button-disabled form-submit" type="button" disabled>Свободных окон пока нет</button>
|
||||
{% endif %}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section faq" id="faq">
|
||||
<div class="container faq-layout">
|
||||
<div><div class="section-label"><span>04</span><p>Вопросы</p></div><h2>Перед первым<br><em>занятием</em></h2></div>
|
||||
<div class="faq-list">
|
||||
<details open><summary>Как записаться на консультацию?<span>+</span></summary><p>Выберите свободное время, заполните форму и дождитесь подтверждения. Контакты нужны, чтобы согласовать детали занятия.</p></details>
|
||||
<details><summary>Можно ли начать подготовку не с начала года?<span>+</span></summary><p>Да. На первой консультации определим стартовый уровень, ближайшую цель и соберём реалистичный план подготовки.</p></details>
|
||||
<details><summary>Что указать в комментарии к заявке?<span>+</span></summary><p>Напишите класс, предмет, цель и темы, которые кажутся сложными. Это поможет подготовиться к первой встрече.</p></details>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer class="site-footer">
|
||||
<div class="container footer-content">
|
||||
<a class="brand" href="#top">ИО<span>·</span>учёба</a>
|
||||
<p>Физика и математика · подготовка к ОГЭ и ЕГЭ</p>
|
||||
<p>© {% now "Y" %} Игорь Олегович Безрукавов</p>
|
||||
<div class="container footer-main">
|
||||
<a class="brand brand-footer" href="#top"><span>ИО</span> physics & math</a>
|
||||
<p>Физика и математика без лишней тревоги.</p>
|
||||
<a href="#booking">Записаться <span>↑</span></a>
|
||||
</div>
|
||||
<div class="container footer-bottom"><span>© {% now "Y" %} Игорь Олегович Безрукавов</span><a href="{% url 'consultations:privacy_policy' %}">Политика конфиденциальности</a></div>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
{% load static %}
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Политика конфиденциальности — Игорь Безрукавов</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&family=Source+Serif+4:opsz,wght@8..60,600;8..60,700&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="{% static 'css/style.css' %}">
|
||||
</head>
|
||||
<body class="privacy-page">
|
||||
<header class="site-header"><div class="container navigation"><a class="brand" href="{% url 'consultations:home' %}"><span>ИО</span> physics & math</a><a class="header-action" href="{% url 'consultations:home' %}#booking">Записаться <span>↗</span></a></div></header>
|
||||
<main class="privacy-content container">
|
||||
<p class="eyebrow"><span></span> Юридическая информация</p>
|
||||
<h1>Политика<br><em>конфиденциальности</em></h1>
|
||||
<p class="privacy-lead">Этот сайт использует персональные данные только для обработки заявок на консультации.</p>
|
||||
<div class="privacy-text">
|
||||
<h2>Какие данные собираются</h2>
|
||||
<p>При заполнении формы могут быть указаны имя, номер телефона, способ связи, класс ученика, предмет, цель подготовки и комментарий к заявке.</p>
|
||||
<h2>Зачем они нужны</h2>
|
||||
<p>Данные используются исключительно для связи с заявителем, подтверждения времени консультации и подготовки к занятию.</p>
|
||||
<h2>Хранение и защита</h2>
|
||||
<p>Доступ к заявкам имеет только преподаватель. Данные не передаются третьим лицам и не используются для рекламных рассылок.</p>
|
||||
<h2>Отзыв согласия</h2>
|
||||
<p>Чтобы уточнить, изменить или удалить сведения из заявки, свяжитесь с преподавателем тем же способом, который был указан при записи.</p>
|
||||
</div>
|
||||
<a class="button button-primary" href="{% url 'consultations:home' %}#booking">Вернуться к записи <span>→</span></a>
|
||||
</main>
|
||||
<footer class="site-footer"><div class="container footer-bottom"><span>© {% now "Y" %} Игорь Олегович Безрукавов</span><a href="{% url 'consultations:home' %}">На главную</a></div></footer>
|
||||
</body>
|
||||
</html>
|
||||
@@ -7,5 +7,5 @@ app_name = "consultations"
|
||||
|
||||
urlpatterns = [
|
||||
path("", views.home, name="home"),
|
||||
path("privacy/", views.privacy_policy, name="privacy_policy"),
|
||||
]
|
||||
|
||||
|
||||
+25
-7
@@ -1,21 +1,39 @@
|
||||
from django.contrib import messages
|
||||
from django.db import IntegrityError, transaction
|
||||
from django.shortcuts import redirect, render
|
||||
from django.urls import reverse
|
||||
|
||||
from .forms import ConsultationRequestForm
|
||||
from .models import ConsultationSlot
|
||||
|
||||
|
||||
def home(request):
|
||||
if request.method == "POST":
|
||||
form = ConsultationRequestForm(request.POST)
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
messages.success(
|
||||
request,
|
||||
"Заявка отправлена. Игорь Олегович свяжется с вами, чтобы подтвердить время консультации.",
|
||||
)
|
||||
return redirect(f"{reverse('consultations:home')}#booking")
|
||||
try:
|
||||
with transaction.atomic():
|
||||
form.save()
|
||||
except IntegrityError:
|
||||
form.add_error("slot", "Это время только что занял другой ученик. Выберите другой слот.")
|
||||
else:
|
||||
messages.success(
|
||||
request,
|
||||
"Заявка принята. Игорь Олегович свяжется с вами для подтверждения консультации.",
|
||||
)
|
||||
return redirect(f"{reverse('consultations:home')}#booking")
|
||||
else:
|
||||
form = ConsultationRequestForm()
|
||||
|
||||
return render(request, "consultations/home.html", {"form": form})
|
||||
return render(
|
||||
request,
|
||||
"consultations/home.html",
|
||||
{
|
||||
"form": form,
|
||||
"available_slot_count": ConsultationSlot.objects.available().count(),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def privacy_policy(request):
|
||||
return render(request, "consultations/privacy.html")
|
||||
|
||||
Reference in New Issue
Block a user