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

Рабочий обзор

Добрый день, {{ user.get_short_name|default:user.get_username }}.

Управляйте расписанием и заявками в одном месте.
+ + Добавить свободное время +
+
+ {{ dashboard.new_requests }}новых заявок + {{ dashboard.today_confirmed }}подтверждено на сегодня + {{ dashboard.upcoming }}ближайших консультаций + {{ dashboard.completed_month }}проведено за месяц +
+
+

Ближайшие записи

Все заявки →
+ {% if next_bookings %}{% else %}

Ближайших записей пока нет. Добавьте свободные окна в расписание.

{% endif %} +
+ +
+
{% include "admin/app_list.html" with app_list=app_list show_changelinks=True %}
+
+{% endblock %} diff --git a/consultations/templates/consultations/home.html b/consultations/templates/consultations/home.html index f81ae64..98481a6 100644 --- a/consultations/templates/consultations/home.html +++ b/consultations/templates/consultations/home.html @@ -4,191 +4,221 @@ - - Физика и математика — Игорь Олегович Безрукавов + + Игорь Безрукавов — физика и математика - +
-
-
-

Физика и математика · 5–11 классы

-

Готовимся к экзаменам с пониманием, а не наугад.

-

Индивидуальные консультации по подготовке к ОГЭ и ЕГЭ: разбираем сложные темы, выстраиваем логику решения и спокойно идём к цели.

+
+
+

Физика · математика · 5–11 классы

+

Экзамен — это задача. Её можно решить.

+

Индивидуальная подготовка к ОГЭ и ЕГЭ: от пробелов в темах до уверенности в каждом шаге решения.

-
-
10+лет опыта
-
2предмета
-
ОГЭ · ЕГЭподготовка к экзаменам
+
+
10+лет преподавания
и репетиторства
+
2предмета для
уверенного результата
+
АГУбакалавриат
и магистратура
-
-
-
- E = mc² - √x - Σ -
-

точные науки

- понятно
и спокойно
+
+
+ Игорь Олегович Безрукавов
+
Разбираем
до понимания
+
+
+
+ Понятная система подготовки· + Спокойный темп· + Индивидуальный план· + Фокус на результате +
+
+
-
-

О преподавателе

-
-

Игорь Олегович
Безрукавов

-

Преподаватель физики и математики с опытом преподавания и репетиторства более 10 лет.

-

Окончил Астраханский государственный университет: бакалавриат и магистратуру по специальности «Преподавание физики и математики».

-

На консультации важно не просто получить ответ, а разобраться, почему решение работает — тогда знания остаются с учеником и на контрольной, и на экзамене.

+
+ +
+

Понимание формул начинается с вопроса «почему?»

+
+

Меня зовут Игорь Олегович Безрукавов. Более 10 лет помогаю школьникам выстроить сильную базу по физике и математике.

+
+

Окончил Астраханский государственный университет: бакалавриат и магистратуру по специальности «Преподавание физики и математики».

+

На занятии ученик не заучивает шаблон, а учится видеть логику задачи — это помогает уверенно работать и на уроке, и на экзамене.

+
+
+
АГУ

Педагогическое образование
по физике и математике

-
+
-
-

Направления

-

Подготовка в нужном темпе

-
-

Подберём фокус консультации под текущий уровень, цели и дату экзамена.

+ +

Работаем на
вашу цель

+

Выбираем темп и программу с учётом класса, стартового уровня и даты экзамена.

-
-
- F -

Предмет

+
+
+
01 / PHF

Физика

+

От формул и единиц измерения до сложных задач второй части.

    -
  • Механика, термодинамика, электродинамика
  • -
  • Задачи с формулами и графиками
  • -
  • Подготовка к ОГЭ и ЕГЭ
  • +
  • ОГЭ и ЕГЭ
  • +
  • Механика, электричество, оптика
  • +
  • Графики, эксперименты, расчёты
- Записаться + Выбрать консультацию
-
- π -

Предмет

+
+
02 / MTHπ

Математика

+

Понятная алгебра и геометрия вместо набора непонятных правил.

    -
  • Алгебра, геометрия, вероятность
  • -
  • Уравнения, неравенства, задачи
  • -
  • Подготовка к ОГЭ и ЕГЭ
  • +
  • ОГЭ и ЕГЭ
  • +
  • Уравнения, неравенства, функции
  • +
  • Планиметрия и стереометрия
- Записаться + Выбрать консультацию
-
-
-

Как это работает

-

Три простых шага

-
-
01

Оставьте заявку

Выберите предмет, экзамен и удобное время.

-
02

Подтвердим время

Игорь Олегович свяжется с вами по указанным контактам.

-
03

Начнём подготовку

Определим цель и разберём первые задачи.

+
+
+
+ +

Не «натаскивание»,
а система.

+
+
+
01

Диагностика

Определяем уровень и темы, на которые стоит обратить внимание в первую очередь.

+
02

План

Собираем маршрут подготовки: теория, практика, повторение и работа с ошибками.

+
03

Уверенность

Тренируем самостоятельное решение и разбираем вопросы до полного понимания.

-
+
-
-

Запись на консультацию

-

Сделайте первый шаг к уверенному экзамену.

-

Заполните форму — время будет окончательно подтверждено после связи с преподавателем.

-

Контакты используются только для ответа на вашу заявку.

+
+

Онлайн-запись

+

Выберите удобное время для старта.

+

Слот предварительно бронируется за вами. Игорь Олегович подтвердит консультацию по указанным контактам.

+
+ +
{{ available_slot_count }}

свободных окон
для записи сейчас

+
+

Не нашли подходящее время? Напишите желаемый день в комментарии — постараемся подобрать вариант.

-
+
{% if messages %} {% for message in messages %} -
{{ message }}
+
{{ message }}
{% endfor %} {% endif %} - {% if form.non_field_errors %} -
{{ form.non_field_errors }}
- {% endif %} +
Запись на консультацию

Поля со звёздочкой обязательны

+ {% if form.non_field_errors %}
{{ form.non_field_errors }}
{% endif %}
{% csrf_token %}
-
- - {{ form.name }} - {% for error in form.name.errors %}{{ error }}{% endfor %} +
+ + {{ form.name }}{% for error in form.name.errors %}{{ error }}{% endfor %}
- - {{ form.phone }} - {% for error in form.phone.errors %}{{ error }}{% endfor %} + + {{ form.phone }}{% for error in form.phone.errors %}{{ error }}{% endfor %}
- {{ form.contact }} - {% for error in form.contact.errors %}{{ error }}{% endfor %} + {{ form.contact }}{% for error in form.contact.errors %}{{ error }}{% endfor %}
- - {{ form.subject }} - {% for error in form.subject.errors %}{{ error }}{% endfor %} + + {{ form.student_grade }}{% for error in form.student_grade.errors %}{{ error }}{% endfor %}
- - {{ form.exam }} - {% for error in form.exam.errors %}{{ error }}{% endfor %} + + {{ form.subject }}{% for error in form.subject.errors %}{{ error }}{% endfor %}
- - {{ form.preferred_date }} - {% for error in form.preferred_date.errors %}{{ error }}{% endfor %} + + {{ form.exam }}{% for error in form.exam.errors %}{{ error }}{% endfor %}
- - {{ form.preferred_time }} - {% for error in form.preferred_time.errors %}{{ error }}{% endfor %} + + {{ form.format }}{% for error in form.format.errors %}{{ error }}{% endfor %}
-
+
+ + {{ form.slot }}{% for error in form.slot.errors %}{{ error }}{% endfor %} +
+
- {{ form.comment }} - {% for error in form.comment.errors %}{{ error }}{% endfor %} + {{ form.comment }}{% for error in form.comment.errors %}{{ error }}{% endfor %}
- + + {% for error in form.consent_given.errors %}{{ error }}{% endfor %} + {% if available_slot_count %} + + {% else %} + + {% endif %}
+ +
+
+

Перед первым
занятием

+
+
Как записаться на консультацию?+

Выберите свободное время, заполните форму и дождитесь подтверждения. Контакты нужны, чтобы согласовать детали занятия.

+
Можно ли начать подготовку не с начала года?+

Да. На первой консультации определим стартовый уровень, ближайшую цель и соберём реалистичный план подготовки.

+
Что указать в комментарии к заявке?+

Напишите класс, предмет, цель и темы, которые кажутся сложными. Это поможет подготовиться к первой встрече.

+
+
+
diff --git a/consultations/templates/consultations/privacy.html b/consultations/templates/consultations/privacy.html new file mode 100644 index 0000000..a190c6f --- /dev/null +++ b/consultations/templates/consultations/privacy.html @@ -0,0 +1,33 @@ +{% load static %} + + + + + + Политика конфиденциальности — Игорь Безрукавов + + + + + + + +
+

Юридическая информация

+

Политика
конфиденциальности

+

Этот сайт использует персональные данные только для обработки заявок на консультации.

+
+

Какие данные собираются

+

При заполнении формы могут быть указаны имя, номер телефона, способ связи, класс ученика, предмет, цель подготовки и комментарий к заявке.

+

Зачем они нужны

+

Данные используются исключительно для связи с заявителем, подтверждения времени консультации и подготовки к занятию.

+

Хранение и защита

+

Доступ к заявкам имеет только преподаватель. Данные не передаются третьим лицам и не используются для рекламных рассылок.

+

Отзыв согласия

+

Чтобы уточнить, изменить или удалить сведения из заявки, свяжитесь с преподавателем тем же способом, который был указан при записи.

+
+ Вернуться к записи +
+ + + diff --git a/consultations/urls.py b/consultations/urls.py index c2038b0..5d51f1d 100644 --- a/consultations/urls.py +++ b/consultations/urls.py @@ -7,5 +7,5 @@ app_name = "consultations" urlpatterns = [ path("", views.home, name="home"), + path("privacy/", views.privacy_policy, name="privacy_policy"), ] - diff --git a/consultations/views.py b/consultations/views.py index 90ba748..fbd73df 100644 --- a/consultations/views.py +++ b/consultations/views.py @@ -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") diff --git a/static/admin/css/teacher_admin.css b/static/admin/css/teacher_admin.css new file mode 100644 index 0000000..70215ee --- /dev/null +++ b/static/admin/css/teacher_admin.css @@ -0,0 +1,2 @@ +:root { --primary: #0f5369; --primary-dark: #0a3445; --accent: #ee7046; --cream: #f8f5ee; --ink: #19333e; --muted: #63777c; } +body { color: var(--ink); background: var(--cream); font-family: Arial, sans-serif; }.skip-to-content-link { background: var(--primary); }#header { min-height: 66px; padding: 0 28px; color: #fff; background: var(--primary-dark); }#site-name { font-size: 18px; font-weight: 700; }#site-name a { color: #fff; }#site-name span { display: inline-block; margin-right: 7px; padding: 5px 6px; border-radius: 50%; color: var(--primary-dark); background: #f6c85f; font-size: 10px; }.admin-site-link { display: inline-block; margin-left: 18px; color: #f6c85f; font-size: 12px; font-weight: 700; }div.breadcrumbs { padding: 12px 28px; color: #c6e0de; background: var(--primary); }div.breadcrumbs a { color: #fff; }#content { padding: 30px 28px; }h1, h2 { color: var(--ink); }.module caption, .module h2 { background: var(--primary); }.button, input[type=submit], input[type=button], .submit-row input, a.button { border-radius: 3px; color: #fff; background: var(--accent); }.button:hover, input[type=submit]:hover, input[type=button]:hover, .submit-row input:hover, a.button:hover { background: #d95e36; }.object-tools a:link, .object-tools a:visited { border-radius: 3px; background: var(--primary); }.object-tools a:hover { background: var(--primary-dark); }a:link, a:visited { color: var(--primary); }a:hover { color: var(--accent); }.selector-chosen h2 { background: var(--primary); }.inline-group h2 { background: var(--primary); }.dashboard { max-width: 1240px; }.dashboard-hero { display: flex; align-items: end; justify-content: space-between; gap: 30px; margin-bottom: 25px; padding: 30px; color: #fff; background: linear-gradient(120deg, var(--primary-dark), var(--primary)); }.dashboard-hero p { margin: 0 0 5px; color: #f6c85f; font-size: 11px; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; }.dashboard-hero h1 { margin: 0 0 8px; color: #fff; font-size: 29px; }.dashboard-hero span { color: #c2d9d7; font-size: 13px; }.dashboard-primary { padding: 11px 15px; color: var(--primary-dark) !important; background: #f6c85f; font-size: 12px; font-weight: 700; white-space: nowrap; }.dashboard-metrics { display: grid; grid-template-columns: repeat(4, 1fr); gap: 14px; margin-bottom: 23px; }.dashboard-metrics a { padding: 20px; border: 1px solid #d5dedc; background: #fff; }.dashboard-metrics a:hover { border-color: var(--accent); }.dashboard-metrics strong { display: block; color: var(--primary); font-size: 31px; line-height: 1; }.dashboard-metrics span { display: block; margin-top: 7px; color: var(--muted); font-size: 12px; }.dashboard-grid { display: grid; grid-template-columns: 1.6fr .9fr; gap: 22px; margin-bottom: 28px; }.dashboard-panel { padding: 23px; border: 1px solid #d5dedc; background: #fff; }.panel-heading { display: flex; align-items: center; justify-content: space-between; gap: 15px; padding-bottom: 15px; border-bottom: 1px solid #e3e9e7; }.panel-heading h2 { margin: 0; font-size: 18px; }.panel-heading a { font-size: 12px; font-weight: 700; }.booking-list > a { display: grid; grid-template-columns: 65px 1fr auto; align-items: center; gap: 15px; padding: 13px 0; border-bottom: 1px solid #e3e9e7; }.booking-list > a:last-child { border-bottom: 0; }.booking-date { color: var(--primary); font-size: 13px; font-weight: 700; }.booking-date small, .booking-list small { display: block; margin-top: 2px; color: var(--muted); font-size: 11px; font-weight: 400; }.booking-list strong { color: var(--ink); font-size: 13px; }.dashboard-status { padding: 4px 8px; border-radius: 999px; font-size: 10px; font-weight: 700; }.status-new { color: #9f5615; background: #fff0d6; }.status-confirmed { color: #176042; background: #dbf2e5; }.status-completed { color: #285c86; background: #ddecfa; }.empty-panel { color: var(--muted); font-size: 13px; }.quick-panel > a { display: flex; justify-content: space-between; padding: 16px 0; border-bottom: 1px solid #e3e9e7; color: var(--ink); font-size: 13px; font-weight: 700; }.quick-panel > a span { color: var(--accent); }.dashboard-apps { margin-top: 25px; }.status-chip { display: inline-block; padding: 4px 8px; border-radius: 999px; font-size: 11px; font-weight: 700; }.status-cancelled { color: #8d3b3b; background: #f9e1e1; }.status-muted { color: #63777c; background: #edf0ef; }@media (max-width: 800px) { #header { padding: 0 15px; }#content { padding: 20px 15px; }.dashboard-metrics, .dashboard-grid { grid-template-columns: 1fr 1fr; }.dashboard-hero { align-items: start; flex-direction: column; }.booking-list > a { grid-template-columns: 55px 1fr; }.dashboard-status { display: none; } }@media (max-width: 500px) { .dashboard-metrics, .dashboard-grid { grid-template-columns: 1fr; }.dashboard-hero { padding: 20px; }.dashboard-hero h1 { font-size: 23px; } } diff --git a/static/css/style.css b/static/css/style.css index 16774a3..835e2dc 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -1,172 +1,80 @@ :root { - --ink: #12212d; - --muted: #5f6a72; - --paper: #f8f8f3; - --sand: #eeece3; - --blue: #195d78; - --blue-dark: #10475f; - --orange: #e86d3e; - --line: #d8ddd6; + --ink: #142e3b; + --ink-soft: #3d5661; + --blue: #0f5369; + --blue-deep: #0a3445; + --sky: #dcecf1; + --cream: #f8f5ee; + --orange: #ee7046; + --yellow: #f6c85f; + --line: #cfdbdb; --white: #ffffff; } * { box-sizing: border-box; } - html { scroll-behavior: smooth; } - -body { - margin: 0; - color: var(--ink); - background: var(--paper); - font-family: "Manrope", Arial, sans-serif; - font-size: 16px; - line-height: 1.6; -} - +body { margin: 0; color: var(--ink); background: var(--cream); font-family: "Manrope", Arial, sans-serif; font-size: 16px; line-height: 1.6; } a { color: inherit; text-decoration: none; } +button, input, select, textarea { font: inherit; } +.container { width: min(1160px, calc(100% - 48px)); margin: 0 auto; } -.container { width: min(1120px, calc(100% - 48px)); margin: 0 auto; } +.site-header { position: sticky; top: 0; z-index: 20; border-bottom: 1px solid rgba(20, 46, 59, .1); background: rgba(248, 245, 238, .93); backdrop-filter: blur(14px); } +.navigation { display: flex; min-height: 76px; align-items: center; justify-content: space-between; gap: 30px; } +.brand { font-family: "DM Mono", monospace; font-size: 13px; font-weight: 700; letter-spacing: -.05em; text-transform: uppercase; white-space: nowrap; } +.brand span { display: inline-flex; width: 31px; height: 31px; align-items: center; justify-content: center; margin-right: 7px; border-radius: 50%; color: var(--cream); background: var(--blue); font-size: 11px; letter-spacing: -.12em; } +.nav-links { display: flex; gap: 31px; color: var(--ink-soft); font-size: 13px; font-weight: 700; } +.nav-links a:hover { color: var(--orange); } +.header-action { display: inline-flex; gap: 8px; align-items: center; color: var(--blue); font-size: 13px; font-weight: 800; } +.header-action span { color: var(--orange); font-size: 19px; } -.site-header { background: rgba(248, 248, 243, .93); border-bottom: 1px solid rgba(18, 33, 45, .08); position: sticky; top: 0; z-index: 10; backdrop-filter: blur(12px); } - -.navigation { min-height: 76px; display: flex; align-items: center; justify-content: space-between; gap: 28px; } - -.brand { color: var(--ink); font-size: 20px; font-weight: 800; letter-spacing: -.07em; } -.brand span { color: var(--orange); padding: 0 2px; } - -.nav-links { display: flex; gap: 28px; font-size: 14px; font-weight: 600; color: #43515b; } -.nav-links a:hover, .text-link:hover, .subject-card a:hover { color: var(--orange); } - -.nav-button { padding: 9px 17px; color: var(--blue); border: 1px solid var(--blue); border-radius: 999px; font-size: 13px; font-weight: 700; } -.nav-button:hover { background: var(--blue); color: var(--white); } - -.hero { overflow: hidden; padding: 84px 0 74px; background: linear-gradient(115deg, #e8f1ee 0%, #f8f8f3 60%, #f8eee5 100%); } -.hero-grid { display: grid; grid-template-columns: 1.22fr .78fr; align-items: center; gap: 76px; } - -.eyebrow, .section-kicker, .card-kicker { margin: 0 0 14px; color: var(--orange); font-size: 12px; font-weight: 800; letter-spacing: .12em; text-transform: uppercase; } +.hero { overflow: hidden; padding: 74px 0 63px; background: radial-gradient(circle at 76% 20%, #d6e9e7 0, transparent 26%), linear-gradient(121deg, #edf4f0 0%, var(--cream) 56%, #f5e8d8 100%); } +.hero-layout { display: grid; grid-template-columns: minmax(0, 1.15fr) minmax(350px, .85fr); align-items: center; gap: 68px; } +.eyebrow { display: flex; align-items: center; gap: 9px; margin: 0 0 18px; color: var(--orange); font-family: "DM Mono", monospace; font-size: 10px; font-weight: 700; letter-spacing: .07em; text-transform: uppercase; } +.eyebrow span { width: 22px; height: 1px; background: currentColor; } h1, h2, h3, p { margin-top: 0; } -h1, h2, h3 { font-family: "Source Serif 4", Georgia, serif; line-height: 1.08; } -h1 { max-width: 680px; margin-bottom: 24px; font-size: clamp(44px, 5vw, 72px); font-weight: 700; letter-spacing: -.05em; } -h1 em { color: var(--blue); font-style: italic; } -.hero-text { max-width: 595px; margin-bottom: 32px; color: #42515a; font-size: 18px; } +h1, h2, h3 { font-family: "Source Serif 4", Georgia, serif; line-height: 1.03; letter-spacing: -.055em; } +h1 { max-width: 680px; margin-bottom: 25px; font-size: clamp(48px, 5.55vw, 78px); } +h1 em, h2 em { color: var(--blue); font-style: italic; } +.hero-lead { max-width: 560px; margin-bottom: 33px; color: var(--ink-soft); font-size: 18px; line-height: 1.65; } +.hero-actions { display: flex; flex-wrap: wrap; gap: 13px; } +.button { display: inline-flex; align-items: center; justify-content: center; gap: 15px; border: 0; border-radius: 3px; padding: 15px 20px; font-size: 13px; font-weight: 800; cursor: pointer; transition: transform .2s, background .2s; } +.button:hover { transform: translateY(-2px); } +.button-primary { color: var(--white); background: var(--orange); box-shadow: 0 9px 19px rgba(238, 112, 70, .2); } +.button-primary:hover { background: #d95e36; } +.button-ghost { color: var(--blue); border: 1px solid var(--blue); background: transparent; } +.button-ghost:hover { color: var(--white); background: var(--blue); } +.hero-numbers { display: flex; gap: 30px; margin-top: 56px; } +.hero-numbers div { min-width: 100px; padding-left: 12px; border-left: 2px solid #a9c7c5; } +.hero-numbers strong { display: block; color: var(--blue); font-family: "Source Serif 4", Georgia, serif; font-size: 24px; line-height: 1.1; } +.hero-numbers span { display: block; margin-top: 4px; color: #66777c; font-size: 10px; line-height: 1.45; } -.hero-actions { display: flex; align-items: center; flex-wrap: wrap; gap: 24px; } -.button { display: inline-block; border: 0; border-radius: 5px; padding: 15px 22px; font: inherit; font-size: 14px; font-weight: 800; cursor: pointer; transition: transform .2s, background .2s; } -.button-primary { color: var(--white); background: var(--orange); box-shadow: 0 8px 20px rgba(232, 109, 62, .18); } -.button-primary:hover { background: #d75f32; transform: translateY(-2px); } -.text-link { color: var(--blue); font-size: 14px; font-weight: 800; } -.text-link span { margin-left: 6px; font-size: 18px; } +.portrait-zone { position: relative; min-height: 490px; } +.portrait-frame { position: absolute; top: 0; right: 23px; width: min(100%, 385px); height: 460px; overflow: hidden; border-radius: 192px 192px 9px 9px; background: var(--blue); box-shadow: 21px 24px 0 #bed4d2; } +.portrait-frame img { width: 121%; height: 100%; object-fit: cover; object-position: 68% center; transform: translateX(-12%); } +.portrait-note { position: absolute; right: 0; bottom: 8px; padding: 13px 18px; color: var(--white); background: var(--blue-deep); font-family: "DM Mono", monospace; font-size: 11px; line-height: 1.35; letter-spacing: -.05em; } +.portrait-note span { margin-right: 6px; color: var(--yellow); font-size: 18px; } +.formula-sticker { position: absolute; top: 45px; left: -5px; display: flex; align-items: baseline; gap: 2px; width: 88px; height: 88px; padding: 22px 0 0 15px; border-radius: 50%; color: var(--ink); background: var(--yellow); transform: rotate(-10deg); } +.formula-sticker i { font-family: "Source Serif 4", Georgia, serif; font-size: 41px; font-weight: 700; line-height: 1; }.formula-sticker small { font-family: "DM Mono", monospace; font-size: 11px; } -.hero-stats { display: flex; gap: 31px; margin-top: 55px; } -.hero-stats div { display: grid; gap: 2px; } -.hero-stats strong { color: var(--blue); font-size: 18px; font-weight: 800; } -.hero-stats span { color: var(--muted); font-size: 11px; } +.trust-strip { color: #e5f0eb; background: var(--blue-deep); } +.trust-content { display: flex; align-items: center; justify-content: space-between; gap: 18px; min-height: 61px; font-family: "DM Mono", monospace; font-size: 10px; letter-spacing: .04em; text-transform: uppercase; }.trust-content b { color: var(--yellow); } -.formula-card { position: relative; min-height: 420px; overflow: hidden; border-radius: 50% 50% 4% 4%; background: var(--blue); box-shadow: 23px 27px 0 #d5e2dc; } -.formula-card::after { position: absolute; width: 100%; height: 30%; left: 0; bottom: 0; background: #10475f; content: ""; } -.formula-center { position: absolute; top: 50%; left: 50%; z-index: 2; width: 210px; height: 210px; padding-top: 60px; border: 1px solid rgba(255, 255, 255, .45); border-radius: 50%; text-align: center; color: white; transform: translate(-50%, -50%); } -.formula-center p { margin-bottom: 6px; color: #b9d4dc; font-size: 10px; font-weight: 700; letter-spacing: .12em; text-transform: uppercase; } -.formula-center strong { font-family: "Source Serif 4", Georgia, serif; font-size: 28px; line-height: 1; } -.card-orbit { position: absolute; top: 50%; left: 50%; border: 1px solid rgba(255, 255, 255, .27); border-radius: 50%; transform: translate(-50%, -50%); } -.orbit-one { width: 310px; height: 310px; } -.orbit-two { width: 465px; height: 465px; } -.formula { position: absolute; z-index: 3; color: #f5c05a; font-family: "Source Serif 4", Georgia, serif; } -.formula-e { top: 64px; left: 48px; font-size: 30px; transform: rotate(-11deg); } -.formula-root { right: 47px; bottom: 80px; font-size: 56px; } -.formula-sum { top: 115px; right: 64px; color: #e88c68; font-size: 62px; } +.section { padding: 113px 0; } +.about-layout { display: grid; grid-template-columns: 210px 1fr; gap: 62px; }.section-label { display: flex; align-items: center; gap: 11px; color: var(--orange); font-family: "DM Mono", monospace; font-size: 10px; font-weight: 700; letter-spacing: .06em; text-transform: uppercase; }.section-label span { display: inline-grid; width: 25px; height: 25px; place-items: center; border: 1px solid currentColor; border-radius: 50%; }.section-label p { margin: 0; } +.about-content h2 { max-width: 800px; margin-bottom: 46px; font-size: clamp(38px, 4.3vw, 60px); }.about-columns { display: grid; grid-template-columns: 1.12fr .88fr; gap: 58px; }.about-columns p { color: var(--ink-soft); font-size: 14px; }.about-intro { margin: 0; color: var(--blue) !important; font-family: "Source Serif 4", Georgia, serif; font-size: 25px !important; line-height: 1.35; }.education-mark { display: flex; align-items: center; gap: 16px; margin-top: 45px; padding-top: 22px; border-top: 1px solid var(--line); }.education-mark span { color: var(--blue); font-family: "DM Mono", monospace; font-size: 20px; font-weight: 700; }.education-mark p { margin: 0; color: var(--ink-soft); font-size: 11px; line-height: 1.4; } -.section { padding: 110px 0; } -.split-layout { display: grid; grid-template-columns: .72fr 1.28fr; gap: 80px; } -.split-layout h2, .section-heading h2, .process h2, .booking h2 { margin-bottom: 25px; font-size: clamp(36px, 4vw, 54px); letter-spacing: -.045em; } -.split-layout .lead { max-width: 610px; color: var(--blue); font-family: "Source Serif 4", Georgia, serif; font-size: 25px; line-height: 1.35; } -.split-layout p:not(.lead) { max-width: 650px; color: var(--muted); } +.programs { background: #dde9e6; }.section-heading { display: grid; grid-template-columns: .75fr 1.12fr .85fr; align-items: end; gap: 38px; margin-bottom: 44px; }.section-heading h2 { margin: 0; font-size: clamp(39px, 4.2vw, 59px); }.section-heading > p { margin: 0 0 4px; color: var(--ink-soft); font-size: 13px; }.program-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 23px; }.program-card { min-height: 408px; padding: 31px 33px; background: var(--cream); }.physics-card { border-top: 5px solid var(--blue); }.math-card { border-top: 5px solid var(--orange); }.program-top { display: flex; justify-content: space-between; align-items: start; }.program-code { color: var(--orange); font-family: "DM Mono", monospace; font-size: 10px; font-weight: 700; }.program-symbol { color: #b8ccc9; font-family: "Source Serif 4", Georgia, serif; font-size: 59px; line-height: .75; }.math-card .program-symbol { color: #e9c7b7; }.program-card h3 { margin: 31px 0 13px; font-size: 40px; }.program-card > p { max-width: 370px; color: var(--ink-soft); font-size: 13px; }.program-card ul { min-height: 80px; margin: 22px 0 22px; padding: 0; list-style: none; color: var(--ink-soft); font-size: 12px; }.program-card li { position: relative; padding: 3px 0 3px 14px; }.program-card li::before { position: absolute; left: 0; color: var(--orange); content: "·"; font-size: 22px; line-height: .75; }.program-card a { color: var(--blue); font-size: 12px; font-weight: 800; }.program-card a span { margin-left: 6px; color: var(--orange); font-size: 18px; }.program-card a:hover { color: var(--orange); } -.subjects { background: var(--sand); } -.section-heading { display: flex; align-items: end; justify-content: space-between; gap: 40px; margin-bottom: 46px; } -.section-heading h2 { margin-bottom: 0; } -.section-heading > p { max-width: 350px; margin-bottom: 6px; color: var(--muted); font-size: 14px; } -.subject-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 24px; } -.subject-card { min-height: 351px; padding: 35px; background: var(--white); border-radius: 3px; } -.subject-card.physics-card { border-top: 5px solid var(--blue); } -.subject-card.math-card { border-top: 5px solid var(--orange); } -.subject-icon { display: inline-flex; width: 50px; height: 50px; align-items: center; justify-content: center; margin-bottom: 35px; border-radius: 50%; color: var(--white); background: var(--blue); font-family: "Source Serif 4", Georgia, serif; font-size: 28px; font-weight: 700; } -.math-card .subject-icon { background: var(--orange); } -.subject-card .card-kicker { margin-bottom: 6px; font-size: 10px; } -.subject-card h3 { margin-bottom: 18px; font-size: 34px; letter-spacing: -.04em; } -.subject-card ul { min-height: 90px; margin: 0 0 23px; padding: 0; list-style: none; color: var(--muted); font-size: 13px; } -.subject-card li { padding: 3px 0 3px 18px; position: relative; } -.subject-card li::before { position: absolute; left: 0; color: var(--orange); content: "•"; } -.subject-card a { color: var(--blue); font-size: 13px; font-weight: 800; } -.subject-card a span { padding-left: 4px; } +.approach { background: var(--cream); }.approach-layout { display: grid; grid-template-columns: .95fr 1.05fr; gap: 85px; }.approach-copy h2 { margin: 32px 0 0; font-size: clamp(38px, 4vw, 57px); }.approach-steps { display: grid; gap: 0; }.approach-steps article { display: grid; grid-template-columns: 47px 150px 1fr; gap: 18px; padding: 23px 0; border-top: 1px solid var(--line); }.approach-steps article:last-child { border-bottom: 1px solid var(--line); }.approach-steps span { color: var(--orange); font-family: "DM Mono", monospace; font-size: 11px; font-weight: 700; }.approach-steps h3 { margin: 0; font-size: 23px; }.approach-steps p { margin: 3px 0 0; color: var(--ink-soft); font-size: 12px; } -.process { background: #f2f6f4; } -.process h2 { margin-bottom: 47px; } -.steps { display: grid; grid-template-columns: repeat(3, 1fr); gap: 32px; } -.steps article { position: relative; padding: 30px 25px 10px 0; border-top: 1px solid #aec1bb; } -.steps span { display: block; margin-bottom: 35px; color: var(--orange); font-size: 13px; font-weight: 800; } -.steps h3 { margin-bottom: 11px; font-size: 25px; letter-spacing: -.03em; } -.steps p { color: var(--muted); font-size: 14px; } +.booking-section { padding: 104px 0; color: var(--white); background: linear-gradient(120deg, #0b3d50, #0a2f3e); }.booking-layout { display: grid; grid-template-columns: .84fr 1.16fr; gap: 74px; align-items: start; }.eyebrow-light { color: #f4a181; }.booking-intro h2 { max-width: 505px; margin: 0 0 22px; color: var(--white); font-size: clamp(39px, 4.5vw, 61px); }.booking-intro h2 em { color: var(--yellow); }.booking-intro > p:not(.eyebrow):not(.booking-help) { max-width: 435px; color: #c6d8d8; font-size: 14px; }.availability-card { display: flex; align-items: center; gap: 14px; width: max-content; margin: 40px 0 23px; padding: 15px 21px; border: 1px solid rgba(231, 245, 240, .22); background: rgba(255, 255, 255, .06); }.availability-dot { width: 9px; height: 9px; border-radius: 50%; background: #77dcad; box-shadow: 0 0 0 5px rgba(119, 220, 173, .12); }.availability-card div { display: flex; align-items: center; gap: 10px; }.availability-card strong { color: var(--yellow); font-family: "Source Serif 4", Georgia, serif; font-size: 34px; line-height: 1; }.availability-card p { margin: 0; color: #cee1df; font-size: 10px; line-height: 1.35; }.booking-help { max-width: 380px; color: #8ca9ae; font-size: 11px; line-height: 1.5; } +.booking-form-card { padding: 33px; color: var(--ink); background: var(--cream); box-shadow: 15px 15px 0 rgba(0, 0, 0, .13); }.form-title { display: flex; align-items: baseline; justify-content: space-between; gap: 20px; padding-bottom: 21px; border-bottom: 1px solid var(--line); }.form-title span { font-family: "Source Serif 4", Georgia, serif; font-size: 26px; font-weight: 700; letter-spacing: -.04em; }.form-title p { margin: 0; color: #7a898c; font-size: 10px; }.form-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 17px; margin-top: 22px; }.field { display: grid; gap: 6px; }.field.full { grid-column: 1 / -1; }.field label { color: var(--ink-soft); font-size: 11px; font-weight: 800; }.field label b, .consent-label b { color: var(--orange); }.form-control { width: 100%; min-height: 43px; padding: 10px 11px; border: 1px solid #cbd6d5; border-radius: 2px; outline: none; color: var(--ink); background: #fffefa; font-size: 12px; }.form-control:focus { border-color: var(--blue); box-shadow: 0 0 0 3px rgba(15, 83, 105, .12); }textarea.form-control { min-height: 90px; resize: vertical; }.consent-label { display: flex; gap: 9px; align-items: start; margin-top: 18px; color: #617477; font-size: 10px; line-height: 1.5; }.consent-control { margin: 2px 0 0; accent-color: var(--blue); }.consent-label a { color: var(--blue); text-decoration: underline; }.form-submit { width: 100%; margin-top: 22px; }.button-disabled { color: #8d9997; background: #d8dfdc; cursor: not-allowed; }.button-disabled:hover { transform: none; }.error { color: #b7442b; font-size: 10px; line-height: 1.3; }.consent-error { display: block; margin-top: 4px; }.form-errors, .form-message { margin: 0 0 16px; padding: 11px 13px; font-size: 12px; }.form-errors { color: #9c351f; background: #fae6df; }.form-message-success { color: #1d6444; background: #e0f2e8; border-left: 3px solid #49a475; } -.booking { padding: 108px 0; color: var(--white); background: #153545; } -.booking-layout { display: grid; grid-template-columns: .82fr 1.18fr; gap: 70px; align-items: start; } -.booking .section-kicker { color: #f4a483; } -.booking h2 { max-width: 500px; } -.booking-copy > p:not(.section-kicker) { max-width: 415px; color: #c0d0d2; } -.privacy-note { margin-top: 42px; font-size: 12px; } -.privacy-note::before { margin-right: 8px; content: "⌁"; color: #f4a483; } +.faq-layout { display: grid; grid-template-columns: .72fr 1.28fr; gap: 76px; }.faq h2 { margin: 27px 0 0; font-size: clamp(40px, 4.2vw, 58px); }.faq-list { border-top: 1px solid var(--line); }.faq details { border-bottom: 1px solid var(--line); }.faq summary { display: flex; align-items: center; justify-content: space-between; gap: 25px; padding: 21px 0; cursor: pointer; list-style: none; font-family: "Source Serif 4", Georgia, serif; font-size: 23px; font-weight: 700; letter-spacing: -.03em; }.faq summary::-webkit-details-marker { display: none; }.faq summary span { color: var(--orange); font-family: "Manrope", sans-serif; font-size: 21px; font-weight: 400; }.faq details[open] summary span { transform: rotate(45deg); }.faq details p { max-width: 650px; padding: 0 10px 20px 0; color: var(--ink-soft); font-size: 13px; } -.form-card { padding: 33px; color: var(--ink); background: var(--white); border-radius: 4px; } -.form-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 18px; } -.field { display: grid; gap: 7px; } -.field.full-width { grid-column: 1 / -1; } -.field label { font-size: 12px; font-weight: 800; } -.form-control { width: 100%; min-height: 43px; padding: 9px 11px; border: 1px solid #ccd4d2; border-radius: 3px; outline: none; color: var(--ink); background: #fdfdfb; font: inherit; font-size: 13px; } -textarea.form-control { min-height: 89px; resize: vertical; } -.form-control:focus { border-color: var(--blue); box-shadow: 0 0 0 3px rgba(25, 93, 120, .12); } -.submit-button { width: 100%; margin-top: 25px; } -.error { color: #b33f26; font-size: 11px; line-height: 1.3; } -.form-errors { margin-bottom: 15px; padding: 11px; color: #8f341f; background: #fce9e4; font-size: 13px; } -.message { margin-bottom: 18px; padding: 13px 15px; font-size: 13px; font-weight: 600; } -.message-success { color: #275c43; background: #e3f2e8; border-left: 3px solid #4b9a71; } +.site-footer { color: #cfddda; background: var(--blue-deep); }.footer-main { display: flex; align-items: center; justify-content: space-between; gap: 22px; min-height: 107px; border-bottom: 1px solid rgba(207, 221, 218, .17); }.brand-footer { color: var(--white); }.brand-footer span { color: var(--blue-deep); background: var(--yellow); }.footer-main p { margin: 0; color: #a8c0bf; font-size: 12px; }.footer-main > a:last-child { color: var(--yellow); font-size: 12px; font-weight: 800; }.footer-main > a:last-child span { margin-left: 5px; font-size: 17px; }.footer-bottom { display: flex; justify-content: space-between; gap: 20px; padding: 18px 0; color: #87a3a7; font-size: 10px; }.footer-bottom a:hover { color: var(--white); } -.site-footer { padding: 27px 0; color: #b5c0c3; background: #0f2937; font-size: 12px; } -.footer-content { display: flex; align-items: center; justify-content: space-between; gap: 20px; } -.site-footer .brand { color: var(--white); } -.site-footer p { margin: 0; } - -@media (max-width: 800px) { - .nav-links { display: none; } - .hero { padding: 64px 0; } - .hero-grid, .booking-layout { grid-template-columns: 1fr; gap: 48px; } - .formula-card { min-height: 350px; max-width: 500px; width: calc(100% - 20px); margin: 0 auto; } - .split-layout { grid-template-columns: 1fr; gap: 5px; } - .section-heading { display: block; } - .section-heading > p { margin-top: 18px; } - .section { padding: 78px 0; } - .booking { padding: 78px 0; } -} - -@media (max-width: 560px) { - .container { width: min(100% - 32px, 1120px); } - .navigation { min-height: 65px; } - .nav-button { padding: 7px 12px; } - h1 { font-size: 43px; } - .hero-text { font-size: 16px; } - .hero-stats { gap: 16px; margin-top: 38px; } - .hero-stats strong { font-size: 15px; } - .hero-stats span { font-size: 9px; } - .formula-card { min-height: 300px; } - .orbit-one { width: 245px; height: 245px; } - .orbit-two { width: 365px; height: 365px; } - .formula-e { top: 40px; left: 28px; } - .formula-sum { top: 66px; right: 35px; } - .formula-root { right: 28px; bottom: 48px; } - .subject-grid, .steps, .form-grid { grid-template-columns: 1fr; } - .subject-card { min-height: auto; } - .subject-card ul { min-height: auto; } - .steps { gap: 12px; } - .steps span { margin-bottom: 18px; } - .form-card { padding: 22px 18px; } - .footer-content { align-items: flex-start; flex-direction: column; gap: 7px; } -} +.privacy-page { min-height: 100vh; display: flex; flex-direction: column; }.privacy-content { flex: 1; padding: 92px 0; }.privacy-content h1 { margin-bottom: 27px; }.privacy-lead { max-width: 640px; margin-bottom: 51px; color: var(--ink-soft); font-family: "Source Serif 4", Georgia, serif; font-size: 26px; line-height: 1.35; }.privacy-text { max-width: 760px; margin-bottom: 44px; }.privacy-text h2 { margin: 27px 0 8px; font-size: 27px; }.privacy-text p { color: var(--ink-soft); font-size: 14px; } +@media (max-width: 860px) { .nav-links { display: none; }.hero-layout, .booking-layout, .faq-layout, .approach-layout { grid-template-columns: 1fr; }.hero-layout { gap: 42px; }.portrait-zone { width: min(100%, 450px); min-height: 450px; margin: 0 auto; }.portrait-frame { right: 25px; }.about-layout { grid-template-columns: 1fr; gap: 26px; }.section-heading { grid-template-columns: 1fr; gap: 20px; }.section-heading > p { max-width: 480px; }.approach-layout { gap: 42px; }.booking-layout { gap: 48px; }.faq-layout { gap: 40px; }.section { padding: 85px 0; } } +@media (max-width: 560px) { .container { width: min(100% - 32px, 1160px); }.navigation { min-height: 64px; }.brand { font-size: 10px; }.brand span { width: 27px; height: 27px; }.header-action { font-size: 11px; }.hero { padding: 54px 0 44px; }.hero-lead { font-size: 16px; }.hero-numbers { gap: 12px; margin-top: 42px; }.hero-numbers div { min-width: 0; padding-left: 8px; }.hero-numbers strong { font-size: 19px; }.hero-numbers span { font-size: 8px; }.portrait-zone { min-height: 385px; }.portrait-frame { right: 16px; width: calc(100% - 28px); height: 358px; box-shadow: 13px 15px 0 #bed4d2; }.portrait-note { right: 0; bottom: 0; font-size: 9px; }.formula-sticker { top: 29px; left: -2px; width: 68px; height: 68px; padding: 17px 0 0 11px; }.formula-sticker i { font-size: 31px; }.formula-sticker small { font-size: 8px; }.trust-content { justify-content: center; min-height: 52px; flex-wrap: wrap; gap: 6px 13px; padding: 10px 0; font-size: 8px; }.about-columns, .program-grid { grid-template-columns: 1fr; gap: 22px; }.about-content h2 { margin-bottom: 31px; }.program-card { min-height: auto; padding: 27px 23px; }.program-card h3 { margin-top: 23px; }.approach-steps article { grid-template-columns: 36px 1fr; gap: 8px; }.approach-steps p { grid-column: 2; }.booking-section { padding: 75px 0; }.booking-form-card { padding: 23px 17px; box-shadow: 8px 8px 0 rgba(0, 0, 0, .13); }.form-title { align-items: start; flex-direction: column; gap: 2px; }.form-grid { grid-template-columns: 1fr; }.field.full { grid-column: auto; }.faq summary { font-size: 20px; }.footer-main { align-items: start; flex-direction: column; justify-content: center; gap: 8px; padding: 22px 0; }.footer-bottom { align-items: start; flex-direction: column; gap: 5px; } } diff --git a/static/images/igor-bezrukavov.jpg b/static/images/igor-bezrukavov.jpg new file mode 100644 index 0000000..33cb5f4 Binary files /dev/null and b/static/images/igor-bezrukavov.jpg differ