update admin panel and visual

This commit is contained in:
greyjoy
2026-08-02 22:56:29 +04:00
parent b53aa305e8
commit 024e28eee4
16 changed files with 866 additions and 309 deletions
+217 -12
View File
@@ -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
View File
@@ -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
View File
@@ -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 %}
+24
View File
@@ -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&amp;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 %}
+133 -103
View File
@@ -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&amp;family=Source+Serif+4:opsz,wght@8..60,600;8..60,700&amp;display=swap" rel="stylesheet">
<link href="https://fonts.googleapis.com/css2?family=DM+Mono&amp;family=Manrope:wght@400;500;600;700;800&amp;family=Source+Serif+4:opsz,wght@8..60,600;8..60,700&amp;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 &amp; 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 &amp; 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&amp;family=Source+Serif+4:opsz,wght@8..60,600;8..60,700&amp;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 &amp; 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>
+1 -1
View File
@@ -7,5 +7,5 @@ app_name = "consultations"
urlpatterns = [
path("", views.home, name="home"),
path("privacy/", views.privacy_policy, name="privacy_policy"),
]
+25 -7
View File
@@ -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")