Compare commits

..

16 Commits

Author SHA1 Message Date
Dev Server e49fa9e746 feat: subscriptions, referrals, students/mentors removal, email templates, calendar fixes
Deploy to Dev / deploy-dev (push) Failing after 31s Details
Backend:
- Subscriptions: add duration_days field to plan, fix duration logic, remove allowed_durations restriction
- Users: add StudentMentorViewSet (remove_mentor), fix remove_client (board access + notification)
- Users: add STATUS_REMOVED to MentorStudentConnection, fix re-invite after removal
- Users: add authentication_classes=[] to all public auth endpoints (fix user_not_found 401)
- Users: fix verify-email and reset-password URLs in email tasks
- Users: validate IANA timezone on registration
- Schedule: add group/group_name to LessonCalendarItemSerializer
- Referrals: add tasks.py for Celery, add process_pending_referral_bonuses to beat schedule
- Email templates: redesign all 5 templates (gradient header, icons, Училл branding)

Frontend:
- Calendar: fix SWR revalidation after create/update/delete (match childId key), clear errors on tab switch
- Students: add remove buttons with warning dialog (mentor removes student, student removes mentor)
- Students: add tabs for client (Мои менторы / Входящие / Исходящие), fix pending_student filter
- Payment: fix duration_days from plan, show Бесплатно for 0₽, show X дн. period
- Referrals: full redesign — stats, levels progress, referrals list, earnings history, bonus balance
- Sign-up: add referral code field, auto-fill from ?ref= param
- Subscription guard: redirect mentor to /payment-platform if no active subscription
- Error pages: translate to Russian
- Page titles: dynamic Russian titles via usePageTitle hook
- Logo: fix full-page reload on click (use react-router Link directly)
- Favicon: use /logo/favicon.png
- Remove logo from header (keep in nav only)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-13 00:39:37 +03:00
Dev Server 2877320987 fix: sign-in always shows generic error message
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-12 17:16:46 +03:00
Dev Server 6aa98de721 fix: normalize auth error messages, no more raw field codes
- Add parse-api-error.js utility that handles all backend error formats:
  {error:{details:{field:[msg]}}} → "Field: msg" per line
  {error:{message:"field: text"}} → strips "field: " prefix
  {message/detail: "text"} → direct fallback
- Apply parseApiError() on all 4 auth pages (sign-in, sign-up, forgot/reset password)
- Add whiteSpace: pre-line to Alert so multi-field errors render on separate lines

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-12 17:15:19 +03:00
Dev Server d5ebd2898a feat: invite by link, 8-char code input, universal_code in profile
Backend:
- New InvitationLink model: token, mentor, created_at, used_by, is_banned
- Links expire after 12h, single-use, multiple active links allowed
- generate-invitation-link: creates InvitationLink (doesn't overwrite old)
- info-by-token: validates expiry/usage/ban before returning mentor info
- register-by-link: validates InvitationLink, marks used_by on registration
- Migration 0013_invitation_link_model

Frontend:
- Profile: show user's universal_code with copy button
- InviteDialog: 8 separate input boxes for code (auto-focus, paste support)
- InviteDialog: new "По ссылке" tab — generate link, copy, show expiry
- /invite/:token page: public registration page with mentor info preview
- Auto-login after invite link registration (setSession + checkUserSession)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-12 17:11:12 +03:00
Dev Server 87f52da0eb fix: sign-in password placeholder 6+ → 8+ символов
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-12 16:54:32 +03:00
Dev Server 71958eadce fix: auth password validation — min 8 chars, translate to Russian
- sign-in: min 8 chars, translate error messages to Russian
- reset-password: min 8 chars (was 6), update placeholder to 8+

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-12 16:53:53 +03:00
Dev Server a39a76f7a5 feat: use custom logo files — logo.svg (expanded) / favicon.png (mini)
- Logo component now renders logo.svg (full) or favicon.png (icon) from /logo/
- nav-vertical: mini mode passes mini prop → favicon.png
- animate-logo (splash screen): uses favicon.png icon with animation ring

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-12 16:52:33 +03:00
Dev Server 75d6072309 fix: translate auth pages to Russian, fix backend error parsing, calendar timezone
- Translate forgot-password and reset-password pages to Russian
- Translate verify-email page to Russian
- Fix error message parsing: backend returns {error:{message}} not {message}
- Apply timezone fix in calendar (FullCalendar timeZone prop + fTime helper)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-12 16:50:51 +03:00
Dev Server f6caa7df6b feat: role-based UI for student/parent, child selector, timezone fix
- Role-based nav: hide payment/referrals for students, board/progress-children for parents
- Add "Мои группы" to student nav; groups detail page read-only for non-mentors
- Logout button in sidebar nav (signOut + redirect to login)
- Parent: auto-select first child, ChildSelector in nav above profile
- Children fetched from /parent/dashboard/ (not the broken /users/parents/children/)
- Add child by 8-char universal code with role validation (client only)
- Removed "Прогресс" button from child cards in children-view
- Fix redirect on child switch — stay on current page (no router.push)
- Parent child notification settings in profile (per-child + per-type toggles)
- Fix scroll on all pages: Main overflow auto, hasSidebar Box overflow auto
- Fix 403: isMentor guard before getStudents() calls in groups pages
- Fix timezone: FullCalendar timeZone prop + fTime() use user.timezone

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-12 16:45:15 +03:00
Dev Server 7cf7a78326 fix: dashboard — remove greeting & user card, fix weekly lessons count
- remove greeting header (mentor & client dashboards)
- remove CourseMyAccount user info block from right panel (mentor)
- lessons_this_week: compute client-side from upcoming_lessons filtered
  to remaining lessons in current Mon–Sun range (was using backend value)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-09 21:33:48 +03:00
Dev Server 1b06404d64 feat: chat fixes, header cleanup, scrollbar, UI improvements
- chat: fix create_direct endpoint (was /chats/ → /chats/create_direct/ with user_id)
- chat: fix backend NotSupportedError — remove select_for_update()+distinct() combo
- chat: normalize chat objects (participant_id from other_participant.id)
- chat: enrich new chats with contact data so contacts section updates correctly
- chat: sendMessage — JSON when no file, FormData only with attachment
- chat: show send errors in UI instead of silent catch
- header: hide search, language, contacts, workspaces, account buttons
- theme: thin custom scrollbar (6px, theme-aware light/dark)
- settings: always high contrast, vertical nav only, purple default, Inter font
- settings-button: replaced gear icon with dark/light toggle + fullscreen button

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-09 21:25:36 +03:00
Dev Server da3736e131 feat: nav sidebar user card — real data + subscription info
- Show real avatar, full name, email from useAuthContext
- Fetch GET /subscriptions/subscriptions/active/ on mount
- Display subscription plan name (label: success=active, warning=trial, default=none)
- Show end date + days left text
- LinearProgress bar showing days remaining (red <20%, yellow <50%, green otherwise)
- Trial subscription displayed as 'Пробный: <plan>'

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-09 17:48:54 +03:00
Dev Server a62f53fd96 fix: vite dev server config — allowedHosts, Dockerfile, docker-compose
- vite.config.js: allowedHosts: true (accept all hosts behind nginx proxy)
- Dockerfile: switch from next to vite entrypoint, VITE_* build args
- docker-compose: VITE_* build args replacing NEXT_PUBLIC_* for front_minimal

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-09 17:24:07 +03:00
Dev Server b55c8dc602 feat: migrate front_minimal from Next.js to Vite + React SPA
- package.json: replace next scripts with vite dev/build/preview,
  add react-router-dom + vite/plugin-react/svgr devDeps, remove next + @mui/material-nextjs
- vite.config.js: add NEXT_PUBLIC_* → process.env define map for backward compat,
  add rollup manual chunks (mui, fullcalendar), loadEnv support
- front_minimal/.env: add VITE_* prefix vars alongside legacy NEXT_PUBLIC_*
- routes/sections.jsx: replace 691-line template routes with 160-line platform-only router
- routes/paths.js: trim to platform paths only (no mock IDs, no template pages)
- app.jsx: remove CheckoutProvider (not needed)
- theme-provider.jsx: remove AppRouterCacheProvider from @mui/material-nextjs
- routes/hooks: replace next/navigation re-exports with react-router-dom wrappers
  (useRouter → useNavigate adapter, useSearchParams, usePathname, useParams)
- routes/components/router-link.jsx: Link from react-router-dom
- sections: replace all next/navigation imports with react-router-dom
- analytics-view, my-progress-view: replace next/dynamic with direct import (no SSR)
- Remove all 'use client' directives (231 files) — not needed in Vite SPA
- Build: vite build ✓ in ~8s

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-09 17:04:53 +03:00
Dev Server f679f0c0f4 feat: board list view, excalidraw basePath fix, analytics, feedback pages
- Board: replace blank iframe with board list (my_boards + shared_with_me),
  mentor gets per-student buttons to open/create boards,
  iframe fills full available height via CSS vars (dvh)
- Excalidraw: build with NEXT_PUBLIC_BASE_PATH=/devboard so _next/ assets
  served under /devboard/_next/ and nginx path proxy works correctly
- Nginx: preserve /devboard/ path in proxy_pass (no trailing slash),
  add /yjs WebSocket proxy to devapi.uchill.online for dev YJS on port 1235
- Analytics: real API charts (income/lessons/students) with DateRangePicker
- Feedback: mentor kanban view of completed lessons, grade + notes drawer
- Nav: dynamic role-based menu (getNavData(role)), mentor-only analytics/feedback
- New API utils: analytics-api.js, board-api.js (full), dashboard-api getLessons()
- Routes: paths.dashboard.analytics, feedback, board added

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-09 16:35:30 +03:00
Dev Server d4ec417ebf feat: migrate Homework, Materials, Students, Notifications to front_minimal
- Homework: kanban view with columns (pending/submitted/returned/reviewed/fill_later/ai_draft), details drawer with submission list for mentor, submit drawer for client, edit draft drawer; full AI-grade support
- Materials: grid view with image preview, upload and delete dialogs
- Students: mentor view with student list + pending requests + invite by email/code; client view with mentors list + incoming invitations + send request by mentor code
- Notifications: full page + NotificationsDrawer in header connected to real API (mark read, delete, mark all)
- New API utils: homework-api.js, materials-api.js, students-api.js, notifications-api.js
- Added routes: /dashboard/homework, /dashboard/materials, /dashboard/students, /dashboard/notifications
- Updated navigation config with new items

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-09 10:09:30 +03:00
379 changed files with 23189 additions and 2611 deletions

View File

@ -37,14 +37,19 @@ class ChatService:
with transaction.atomic(): with transaction.atomic():
# Ищем существующий чат между пользователями # Ищем существующий чат между пользователями
# Используем select_for_update для блокировки найденных записей # select_for_update() несовместим с distinct() в PostgreSQL,
existing_chat = Chat.objects.select_for_update().filter( # поэтому сначала ищем без блокировки, затем лочим найденный объект
existing_chat = Chat.objects.filter(
chat_type='direct', chat_type='direct',
participants__user=users[0] participants__user=users[0]
).filter( ).filter(
participants__user=users[1] participants__user=users[1]
).distinct().first() ).distinct().first()
if existing_chat:
# Лочим конкретную запись для безопасного возврата
existing_chat = Chat.objects.select_for_update().get(pk=existing_chat.pk)
if existing_chat: if existing_chat:
return existing_chat, False return existing_chat, False

View File

@ -0,0 +1,38 @@
from celery import shared_task
from django.utils import timezone
from django.db import transaction
@shared_task
def process_pending_referral_bonuses():
"""Ежедневная обработка отложенных реферальных бонусов."""
from .models import PendingReferralBonus, UserActivityDay, UserReferralProfile
now = timezone.now()
paid_count = 0
for pending in PendingReferralBonus.objects.filter(
status=PendingReferralBonus.STATUS_PENDING
).select_related('referrer', 'referred_user'):
referred_at = pending.referred_at
active_days = UserActivityDay.objects.filter(
user=pending.referred_user,
date__gte=referred_at.date(),
).count()
days_since = (now - referred_at).days
if (days_since >= 30 and active_days >= 20) or active_days >= 21:
try:
with transaction.atomic():
profile = pending.referrer.referral_profile
profile.add_points(
pending.points,
reason=pending.reason or f'Реферал {pending.referred_user.email} выполнил условия'
)
pending.status = PendingReferralBonus.STATUS_PAID
pending.paid_at = now
pending.save(update_fields=['status', 'paid_at'])
paid_count += 1
except Exception:
pass
return f'Начислено бонусов: {paid_count}'

View File

@ -131,14 +131,6 @@ class LessonSerializer(serializers.ModelSerializer):
start_time = attrs.get('start_time') start_time = attrs.get('start_time')
duration = attrs.get('duration', 60) duration = attrs.get('duration', 60)
# Проверка: допускаем создание занятий до 30 минут в прошлом
now = timezone.now()
tolerance = timedelta(minutes=30)
if start_time and start_time < now - tolerance:
raise serializers.ValidationError({
'start_time': 'Нельзя создать занятие, время начала которого было более 30 минут назад'
})
# Проверка конфликтов (только при создании или изменении времени) # Проверка конфликтов (только при создании или изменении времени)
if self.instance is None or 'start_time' in attrs: if self.instance is None or 'start_time' in attrs:
mentor = attrs.get('mentor') or self.instance.mentor if self.instance else None mentor = attrs.get('mentor') or self.instance.mentor if self.instance else None
@ -212,6 +204,10 @@ class LessonCreateSerializer(serializers.ModelSerializer):
'title', 'description', 'subject_id', 'mentor_subject_id', 'subject_name', 'template', 'price', 'title', 'description', 'subject_id', 'mentor_subject_id', 'subject_name', 'template', 'price',
'is_recurring' 'is_recurring'
] ]
extra_kwargs = {
'client': {'required': False, 'allow_null': True},
'group': {'required': False, 'allow_null': True},
}
def to_internal_value(self, data): def to_internal_value(self, data):
""" """
@ -307,6 +303,12 @@ class LessonCreateSerializer(serializers.ModelSerializer):
duration = attrs.get('duration', 60) duration = attrs.get('duration', 60)
mentor = attrs.get('mentor') mentor = attrs.get('mentor')
client = attrs.get('client') client = attrs.get('client')
group = attrs.get('group')
if not client and not group:
raise serializers.ValidationError({
'client': 'Необходимо указать ученика или группу.'
})
# Проверка что указан либо subject_id, либо mentor_subject_id, либо subject_name # Проверка что указан либо subject_id, либо mentor_subject_id, либо subject_name
# subject_id и mentor_subject_id приходят через source='subject' и source='mentor_subject' # subject_id и mentor_subject_id приходят через source='subject' и source='mentor_subject'
@ -380,16 +382,15 @@ class LessonCreateSerializer(serializers.ModelSerializer):
attrs['mentor_subject'] = mentor_subject attrs['mentor_subject'] = mentor_subject
attrs['subject_name'] = mentor_subject.name attrs['subject_name'] = mentor_subject.name
# Проверка: допускаем создание занятий до 30 минут в прошлом # Нормализуем start_time к UTC
if start_time: if start_time:
if not django_timezone.is_aware(start_time): if not django_timezone.is_aware(start_time):
start_time = pytz.UTC.localize(start_time) start_time = pytz.UTC.localize(start_time)
elif start_time.tzinfo != pytz.UTC: elif start_time.tzinfo != pytz.UTC:
start_time = start_time.astimezone(pytz.UTC) start_time = start_time.astimezone(pytz.UTC)
now = django_timezone.now() # Проверяем что занятие не начинается более 30 минут назад
tolerance = timedelta(minutes=30) if start_time < django_timezone.now() - timedelta(minutes=30):
if start_time < now - tolerance:
raise serializers.ValidationError({ raise serializers.ValidationError({
'start_time': 'Нельзя создать занятие, время начала которого было более 30 минут назад' 'start_time': 'Нельзя создать занятие, время начала которого было более 30 минут назад'
}) })
@ -648,6 +649,7 @@ class LessonCalendarItemSerializer(serializers.ModelSerializer):
"""Лёгкий сериализатор для календаря: только поля, нужные для списка и календаря.""" """Лёгкий сериализатор для календаря: только поля, нужные для списка и календаря."""
client_name = serializers.CharField(source='client.user.get_full_name', read_only=True) client_name = serializers.CharField(source='client.user.get_full_name', read_only=True)
mentor_name = serializers.CharField(source='mentor.get_full_name', read_only=True) mentor_name = serializers.CharField(source='mentor.get_full_name', read_only=True)
group_name = serializers.CharField(source='group.name', read_only=True, default=None)
subject = serializers.SerializerMethodField() subject = serializers.SerializerMethodField()
def get_subject(self, obj): def get_subject(self, obj):
@ -671,7 +673,7 @@ class LessonCalendarItemSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = Lesson model = Lesson
fields = ['id', 'title', 'start_time', 'end_time', 'status', 'client', 'client_name', 'mentor', 'mentor_name', 'subject', 'subject_name'] fields = ['id', 'title', 'start_time', 'end_time', 'status', 'client', 'client_name', 'mentor', 'mentor_name', 'group', 'group_name', 'subject', 'subject_name']
class LessonHomeworkSubmissionSerializer(serializers.ModelSerializer): class LessonHomeworkSubmissionSerializer(serializers.ModelSerializer):

View File

@ -101,10 +101,13 @@ class SubscriptionPlanAdmin(admin.ModelAdmin):
'fields': ('name', 'slug', 'description') 'fields': ('name', 'slug', 'description')
}), }),
('Стоимость', { ('Стоимость', {
'fields': ('price', 'price_per_student', 'currency', 'billing_period', 'subscription_type', 'trial_days'), 'fields': ('price', 'price_per_student', 'currency', 'duration_days', 'subscription_type', 'trial_days'),
'description': 'Для типа "За ученика" укажите price_per_student. Для ежемесячной подписки - price. ' 'description': 'Укажите "Период оплаты (дней)" — именно столько дней будет действовать подписка (например: 30, 60, 90, 180, 365).'
'Прогрессирующие скидки настраиваются ниже в разделе "Прогрессирующие скидки". ' }),
'Доступные периоды оплаты определяются через скидки за длительность (см. раздел ниже).' ('Устаревшие настройки', {
'fields': ('billing_period',),
'classes': ('collapse',),
'description': 'Устаревшее поле. Используйте "Период оплаты (дней)" выше.'
}), }),
('Целевая аудитория', { ('Целевая аудитория', {
'fields': ('target_role',), 'fields': ('target_role',),
@ -153,17 +156,11 @@ class SubscriptionPlanAdmin(admin.ModelAdmin):
price_display.short_description = 'Цена' price_display.short_description = 'Цена'
def billing_period_display(self, obj): def billing_period_display(self, obj):
"""Отображение периода.""" """Отображение периода в днях."""
colors = { days = obj.get_duration_days()
'monthly': '#17a2b8',
'quarterly': '#28a745',
'yearly': '#ffc107',
'lifetime': '#6610f2'
}
return format_html( return format_html(
'<span style="background-color: {}; color: white; padding: 3px 10px; border-radius: 3px;">{}</span>', '<span style="background-color: #17a2b8; color: white; padding: 3px 10px; border-radius: 3px;">{} дн.</span>',
colors.get(obj.billing_period, '#000'), days
obj.get_billing_period_display()
) )
billing_period_display.short_description = 'Период' billing_period_display.short_description = 'Период'

View File

@ -0,0 +1,24 @@
# Generated by Django 4.2.7 on 2026-03-12 20:35
import django.core.validators
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("subscriptions", "0011_add_target_role_to_subscription_plan"),
]
operations = [
migrations.AddField(
model_name="subscriptionplan",
name="duration_days",
field=models.IntegerField(
blank=True,
help_text='Количество дней действия подписки. Если указано, имеет приоритет над "Периодом оплаты".',
null=True,
validators=[django.core.validators.MinValueValidator(1)],
verbose_name="Длительность (дней)",
),
),
]

View File

@ -234,7 +234,8 @@ class SubscriptionPlan(models.Model):
max_length=20, max_length=20,
choices=BILLING_PERIOD_CHOICES, choices=BILLING_PERIOD_CHOICES,
default='monthly', default='monthly',
verbose_name='Период оплаты' verbose_name='Период оплаты (устарело)',
help_text='Устаревшее поле. Используйте "Период оплаты (дней)" ниже.'
) )
subscription_type = models.CharField( subscription_type = models.CharField(
@ -254,6 +255,14 @@ class SubscriptionPlan(models.Model):
help_text='Используется для типа "За ученика"' help_text='Используется для типа "За ученика"'
) )
duration_days = models.IntegerField(
null=True,
blank=True,
validators=[MinValueValidator(1)],
verbose_name='Период оплаты (дней)',
help_text='Количество дней действия подписки, например: 30, 60, 90, 180, 365.'
)
trial_days = models.IntegerField( trial_days = models.IntegerField(
default=0, default=0,
validators=[MinValueValidator(0)], validators=[MinValueValidator(0)],
@ -426,16 +435,12 @@ class SubscriptionPlan(models.Model):
def get_duration_days(self, custom_days=None): def get_duration_days(self, custom_days=None):
""" """
Получить длительность подписки в днях. Получить длительность подписки в днях.
Приоритет: custom_days duration_days (поле модели) billing_period.
Args:
custom_days: кастомная длительность в днях (30, 90, 180, 365)
Returns:
int: количество дней
""" """
if custom_days: if custom_days:
return custom_days return custom_days
if self.duration_days:
return self.duration_days
if self.billing_period == 'monthly': if self.billing_period == 'monthly':
return 30 return 30
elif self.billing_period == 'quarterly': elif self.billing_period == 'quarterly':
@ -443,7 +448,7 @@ class SubscriptionPlan(models.Model):
elif self.billing_period == 'yearly': elif self.billing_period == 'yearly':
return 365 return 365
elif self.billing_period == 'lifetime': elif self.billing_period == 'lifetime':
return 36500 # 100 лет return 36500
return 30 return 30
def get_available_durations(self): def get_available_durations(self):

View File

@ -202,7 +202,7 @@ class SubscriptionCreateSerializer(serializers.ModelSerializer):
plan_id = serializers.IntegerField() plan_id = serializers.IntegerField()
student_count = serializers.IntegerField(default=0, min_value=0) student_count = serializers.IntegerField(default=0, min_value=0)
duration_days = serializers.IntegerField(default=30, min_value=1) duration_days = serializers.IntegerField(required=False, allow_null=True, min_value=1)
promo_code = serializers.CharField(required=False, allow_blank=True, allow_null=True) promo_code = serializers.CharField(required=False, allow_blank=True, allow_null=True)
start_date = serializers.DateTimeField(required=False, allow_null=True) start_date = serializers.DateTimeField(required=False, allow_null=True)
@ -219,26 +219,9 @@ class SubscriptionCreateSerializer(serializers.ModelSerializer):
return value return value
def validate_duration_days(self, value): def validate_duration_days(self, value):
"""Валидация длительности.""" """Валидация длительности — любое положительное число дней."""
allowed_durations = [30, 90, 180, 365] if value is not None and value < 1:
if value not in allowed_durations: raise serializers.ValidationError('Длительность должна быть не менее 1 дня')
raise serializers.ValidationError(
f'Длительность должна быть одним из значений: {", ".join(map(str, allowed_durations))}'
)
# Проверяем доступность периода для плана
plan_id = self.initial_data.get('plan_id')
if plan_id:
try:
plan = SubscriptionPlan.objects.get(id=plan_id)
if not plan.is_duration_available(value):
available = plan.get_available_durations()
raise serializers.ValidationError(
f'Период {value} дней недоступен для этого тарифа. Доступные периоды: {", ".join(map(str, available))}'
)
except SubscriptionPlan.DoesNotExist:
pass
return value return value
def validate_student_count(self, value): def validate_student_count(self, value):
@ -257,6 +240,10 @@ class SubscriptionCreateSerializer(serializers.ModelSerializer):
except SubscriptionPlan.DoesNotExist: except SubscriptionPlan.DoesNotExist:
raise serializers.ValidationError({'plan_id': 'Тарифный план не найден'}) raise serializers.ValidationError({'plan_id': 'Тарифный план не найден'})
# Если duration_days не передан — берём из плана
if not attrs.get('duration_days'):
attrs['duration_days'] = plan.get_duration_days()
# Проверяем доступность тарифа (акция) # Проверяем доступность тарифа (акция)
user = self.context.get('request').user if self.context.get('request') else None user = self.context.get('request').user if self.context.get('request') else None
can_use, error_message = plan.can_be_used(user) can_use, error_message = plan.can_be_used(user)

View File

@ -326,15 +326,11 @@ class SubscriptionViewSet(viewsets.ModelViewSet):
{'error': 'Активация без оплаты доступна только для бесплатных тарифов (цена 0)'}, {'error': 'Активация без оплаты доступна только для бесплатных тарифов (цена 0)'},
status=status.HTTP_400_BAD_REQUEST status=status.HTTP_400_BAD_REQUEST
) )
duration_days = int(request.data.get('duration_days', 30)) requested_days = request.data.get('duration_days')
duration_days = int(requested_days) if requested_days else plan.get_duration_days()
student_count = int(request.data.get('student_count', 1)) if st == 'per_student' else 0 student_count = int(request.data.get('student_count', 1)) if st == 'per_student' else 0
if st == 'per_student' and student_count <= 0: if st == 'per_student' and student_count <= 0:
student_count = 1 student_count = 1
available = plan.get_available_durations() if hasattr(plan, 'get_available_durations') else [30]
if not available:
available = [30]
if duration_days not in available:
duration_days = available[0]
try: try:
subscription = SubscriptionService.create_subscription( subscription = SubscriptionService.create_subscription(
user=request.user, user=request.user,
@ -788,9 +784,8 @@ class PaymentViewSet(viewsets.ModelViewSet):
status=status.HTTP_400_BAD_REQUEST status=status.HTTP_400_BAD_REQUEST
) )
else: else:
# По умолчанию используем первый доступный период или 30 дней # По умолчанию используем duration_days из тарифного плана
available_durations = plan.get_available_durations() duration_days = plan.get_duration_days()
duration_days = available_durations[0] if available_durations else 30
# Определяем количество учеников для тарифов "за ученика" # Определяем количество учеников для тарифов "за ученика"
if plan.subscription_type == 'per_student': if plan.subscription_type == 'per_student':

View File

@ -73,7 +73,7 @@ class MentorDashboardViewSet(viewsets.ViewSet):
# Занятия - оптимизация: используем aggregate для всех подсчетов # Занятия - оптимизация: используем aggregate для всех подсчетов
from django.db.models import Count, Sum, Q from django.db.models import Count, Sum, Q
lessons = Lesson.objects.filter(mentor=user.id).select_related( lessons = Lesson.objects.filter(mentor=user.id).select_related(
'mentor', 'client', 'client__user', 'subject', 'mentor_subject' 'mentor', 'client', 'client__user', 'subject', 'mentor_subject', 'group'
) )
# Один запрос для всех подсчетов занятий # Один запрос для всех подсчетов занятий
@ -89,9 +89,9 @@ class MentorDashboardViewSet(viewsets.ViewSet):
lessons_this_month = lessons_stats['this_month'] lessons_this_month = lessons_stats['this_month']
completed_lessons = lessons_stats['completed'] completed_lessons = lessons_stats['completed']
# Ближайшие занятия # Ближайшие занятия (включая начавшиеся в последние 90 мин, чтобы отображать кнопку «Подключиться»)
upcoming_lessons = lessons.filter( upcoming_lessons = lessons.filter(
start_time__gte=now, start_time__gte=now - timedelta(minutes=90),
status__in=['scheduled', 'in_progress'] status__in=['scheduled', 'in_progress']
).select_related('client', 'client__user', 'subject', 'mentor_subject').order_by('start_time')[:5] ).select_related('client', 'client__user', 'subject', 'mentor_subject').order_by('start_time')[:5]
@ -163,6 +163,12 @@ class MentorDashboardViewSet(viewsets.ViewSet):
'avatar': request.build_absolute_uri(lesson.client.user.avatar.url) if lesson.client.user and lesson.client.user.avatar else None, 'avatar': request.build_absolute_uri(lesson.client.user.avatar.url) if lesson.client.user and lesson.client.user.avatar else None,
'first_name': lesson.client.user.first_name if lesson.client.user else '', 'first_name': lesson.client.user.first_name if lesson.client.user else '',
'last_name': lesson.client.user.last_name if lesson.client.user else '' 'last_name': lesson.client.user.last_name if lesson.client.user else ''
} if lesson.client_id else {
'id': None,
'name': lesson.group.name if lesson.group_id else 'Группа',
'avatar': None,
'first_name': '',
'last_name': ''
}, },
'start_time': format_datetime_for_user(lesson.start_time, request.user.timezone) if lesson.start_time else None, 'start_time': format_datetime_for_user(lesson.start_time, request.user.timezone) if lesson.start_time else None,
'end_time': format_datetime_for_user(lesson.end_time, request.user.timezone) if lesson.end_time else None 'end_time': format_datetime_for_user(lesson.end_time, request.user.timezone) if lesson.end_time else None
@ -589,7 +595,7 @@ class MentorDashboardViewSet(viewsets.ViewSet):
'target_name': ( 'target_name': (
item['group__name'] item['group__name']
if item['group__name'] if item['group__name']
else f"{item['client__user__first_name']} {item['client__user__last_name']}".strip() or 'Ученик' else f"{item['client__user__last_name']} {item['client__user__first_name']}".strip() or 'Ученик'
), ),
'lessons_count': item['lessons_count'], 'lessons_count': item['lessons_count'],
'total_income': float(item['total_income']), 'total_income': float(item['total_income']),
@ -649,9 +655,9 @@ class ClientDashboardViewSet(viewsets.ViewSet):
completed_lessons = lessons_stats['completed'] completed_lessons = lessons_stats['completed']
lessons_this_week = lessons_stats['this_week'] lessons_this_week = lessons_stats['this_week']
# Ближайшие занятия с оптимизацией # Ближайшие занятия с оптимизацией (включая начавшиеся в последние 90 мин)
upcoming_lessons = lessons.filter( upcoming_lessons = lessons.filter(
start_time__gte=now, start_time__gte=now - timedelta(minutes=90),
status__in=['scheduled', 'in_progress'] status__in=['scheduled', 'in_progress']
).select_related('mentor', 'client', 'client__user').order_by('start_time')[:5] ).select_related('mentor', 'client', 'client__user').order_by('start_time')[:5]
@ -707,8 +713,11 @@ class ClientDashboardViewSet(viewsets.ViewSet):
'title': lesson.title, 'title': lesson.title,
'mentor': { 'mentor': {
'id': lesson.mentor.id, 'id': lesson.mentor.id,
'name': lesson.mentor.get_full_name() 'first_name': lesson.mentor.first_name,
}, 'last_name': lesson.mentor.last_name,
'name': lesson.mentor.get_full_name(),
'avatar': request.build_absolute_uri(lesson.mentor.avatar.url) if lesson.mentor.avatar else None,
} if lesson.mentor_id else None,
'start_time': format_datetime_for_user(lesson.start_time, request.user.timezone) if lesson.start_time else None, 'start_time': format_datetime_for_user(lesson.start_time, request.user.timezone) if lesson.start_time else None,
'end_time': format_datetime_for_user(lesson.end_time, request.user.timezone) if lesson.end_time else None 'end_time': format_datetime_for_user(lesson.end_time, request.user.timezone) if lesson.end_time else None
} }
@ -716,8 +725,6 @@ class ClientDashboardViewSet(viewsets.ViewSet):
] ]
} }
# Сохраняем в кеш на 2 минуты (120 секунд)
# Кеш на 30 секунд для актуальности уведомлений
cache.set(cache_key, response_data, 30) cache.set(cache_key, response_data, 30)
return Response(response_data) return Response(response_data)
@ -1181,9 +1188,9 @@ class ParentDashboardViewSet(viewsets.ViewSet):
completed_lessons = lessons_stats['completed'] completed_lessons = lessons_stats['completed']
lessons_this_week = lessons_stats['this_week'] lessons_this_week = lessons_stats['this_week']
# Ближайшие занятия # Ближайшие занятия (включая начавшиеся в последние 90 мин)
upcoming_lessons = lessons.filter( upcoming_lessons = lessons.filter(
start_time__gte=now, start_time__gte=now - timedelta(minutes=90),
status__in=['scheduled', 'in_progress'] status__in=['scheduled', 'in_progress']
).select_related('mentor', 'client', 'client__user').order_by('start_time')[:5] ).select_related('mentor', 'client', 'client__user').order_by('start_time')[:5]
@ -1239,8 +1246,11 @@ class ParentDashboardViewSet(viewsets.ViewSet):
'title': lesson.title, 'title': lesson.title,
'mentor': { 'mentor': {
'id': lesson.mentor.id, 'id': lesson.mentor.id,
'name': lesson.mentor.get_full_name() 'first_name': lesson.mentor.first_name,
}, 'last_name': lesson.mentor.last_name,
'name': lesson.mentor.get_full_name(),
'avatar': request.build_absolute_uri(lesson.mentor.avatar.url) if lesson.mentor.avatar else None,
} if lesson.mentor_id else None,
'start_time': format_datetime_for_user(lesson.start_time, request.user.timezone) if lesson.start_time else None, 'start_time': format_datetime_for_user(lesson.start_time, request.user.timezone) if lesson.start_time else None,
'end_time': format_datetime_for_user(lesson.end_time, request.user.timezone) if lesson.end_time else None 'end_time': format_datetime_for_user(lesson.end_time, request.user.timezone) if lesson.end_time else None
} }
@ -1248,7 +1258,6 @@ class ParentDashboardViewSet(viewsets.ViewSet):
] ]
} }
# Сохраняем в кеш на 2 минуты (120 секунд)
cache.set(cache_key, response_data, 30) cache.set(cache_key, response_data, 30)
return Response(response_data) return Response(response_data)

View File

@ -15,6 +15,7 @@ from apps.board.models import Board
def _apply_connection(conn): def _apply_connection(conn):
"""После принятия связи: добавить ментора к студенту, создать доску.""" """После принятия связи: добавить ментора к студенту, создать доску."""
from django.core.cache import cache
student_user = conn.student student_user = conn.student
mentor = conn.mentor mentor = conn.mentor
try: try:
@ -38,6 +39,10 @@ def _apply_connection(conn):
if conn.status != MentorStudentConnection.STATUS_ACCEPTED: if conn.status != MentorStudentConnection.STATUS_ACCEPTED:
conn.status = MentorStudentConnection.STATUS_ACCEPTED conn.status = MentorStudentConnection.STATUS_ACCEPTED
conn.save(update_fields=['status', 'updated_at']) conn.save(update_fields=['status', 'updated_at'])
# Инвалидируем кэш списка студентов ментора
for page in range(1, 6):
for page_size in [10, 20, 50]:
cache.delete(f'manage_clients_{mentor.id}_{page}_{page_size}')
class MentorshipRequestViewSet(viewsets.ViewSet): class MentorshipRequestViewSet(viewsets.ViewSet):

View File

@ -0,0 +1,24 @@
# Generated by Django 4.2.7 on 2026-03-11 15:31
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("users", "0011_add_onboarding_tours_seen"),
]
operations = [
migrations.AlterField(
model_name="user",
name="universal_code",
field=models.CharField(
blank=True,
help_text="8-символьный код (цифры и латинские буквы) для добавления ученика ментором",
max_length=8,
null=True,
unique=True,
verbose_name="Универсальный код",
),
),
]

View File

@ -0,0 +1,68 @@
# Generated by Django 4.2.7 on 2026-03-12 14:06
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
("users", "0012_add_group_to_board"),
]
operations = [
migrations.CreateModel(
name="InvitationLink",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"token",
models.CharField(
db_index=True, max_length=64, unique=True, verbose_name="Токен"
),
),
(
"created_at",
models.DateTimeField(auto_now_add=True, verbose_name="Создана"),
),
(
"is_banned",
models.BooleanField(default=False, verbose_name="Забанена"),
),
(
"mentor",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="invitation_links",
to=settings.AUTH_USER_MODEL,
verbose_name="Ментор",
),
),
(
"used_by",
models.OneToOneField(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="registered_via_link",
to=settings.AUTH_USER_MODEL,
verbose_name="Использована пользователем",
),
),
],
options={
"verbose_name": "Ссылка-приглашение",
"verbose_name_plural": "Ссылки-приглашения",
"db_table": "invitation_links",
"ordering": ["-created_at"],
},
),
]

View File

@ -0,0 +1,29 @@
# Generated by Django 4.2.7 on 2026-03-12 21:03
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("users", "0013_invitation_link_model"),
]
operations = [
migrations.AlterField(
model_name="mentorstudentconnection",
name="status",
field=models.CharField(
choices=[
("pending_mentor", "Ожидает ответа ментора"),
("pending_student", "Ожидает подтверждения студента"),
("pending_parent", "Ожидает подтверждения родителя"),
("accepted", "Принято"),
("rejected", "Отклонено"),
("removed", "Удалено"),
],
db_index=True,
max_length=20,
verbose_name="Статус",
),
),
]

View File

@ -567,6 +567,7 @@ class MentorStudentConnection(models.Model):
STATUS_PENDING_PARENT = 'pending_parent' # студент подтвердил, ждём родителя STATUS_PENDING_PARENT = 'pending_parent' # студент подтвердил, ждём родителя
STATUS_ACCEPTED = 'accepted' STATUS_ACCEPTED = 'accepted'
STATUS_REJECTED = 'rejected' STATUS_REJECTED = 'rejected'
STATUS_REMOVED = 'removed'
STATUS_CHOICES = [ STATUS_CHOICES = [
(STATUS_PENDING_MENTOR, 'Ожидает ответа ментора'), (STATUS_PENDING_MENTOR, 'Ожидает ответа ментора'),
@ -574,6 +575,7 @@ class MentorStudentConnection(models.Model):
(STATUS_PENDING_PARENT, 'Ожидает подтверждения родителя'), (STATUS_PENDING_PARENT, 'Ожидает подтверждения родителя'),
(STATUS_ACCEPTED, 'Принято'), (STATUS_ACCEPTED, 'Принято'),
(STATUS_REJECTED, 'Отклонено'), (STATUS_REJECTED, 'Отклонено'),
(STATUS_REMOVED, 'Удалено'),
] ]
INITIATOR_STUDENT = 'student' INITIATOR_STUDENT = 'student'
INITIATOR_MENTOR = 'mentor' INITIATOR_MENTOR = 'mentor'
@ -692,3 +694,49 @@ class Group(models.Model):
def __str__(self): def __str__(self):
return f"{self.name} (ментор: {self.mentor.get_full_name()})" return f"{self.name} (ментор: {self.mentor.get_full_name()})"
class InvitationLink(models.Model):
"""
Ссылка-приглашение от ментора для регистрации ученика.
- Каждая ссылка действует 12 часов с момента создания
- Одна ссылка один ученик (used_by)
- Несколько ссылок могут быть активны одновременно
- По истечении 12 часов ссылка помечается is_banned=True и не может быть использована
"""
token = models.CharField(max_length=64, unique=True, db_index=True, verbose_name='Токен')
mentor = models.ForeignKey(
'User',
on_delete=models.CASCADE,
related_name='invitation_links',
verbose_name='Ментор',
)
created_at = models.DateTimeField(auto_now_add=True, verbose_name='Создана')
used_by = models.OneToOneField(
'User',
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='registered_via_link',
verbose_name='Использована пользователем',
)
is_banned = models.BooleanField(default=False, verbose_name='Забанена')
class Meta:
db_table = 'invitation_links'
verbose_name = 'Ссылка-приглашение'
verbose_name_plural = 'Ссылки-приглашения'
ordering = ['-created_at']
def __str__(self):
return f"InvitationLink({self.mentor.email}, {self.token[:8]}...)"
@property
def is_expired(self):
from django.utils import timezone as tz
from datetime import timedelta
return tz.now() > self.created_at + timedelta(hours=12)
@property
def is_valid(self):
return not self.is_banned and not self.is_expired and self.used_by_id is None

View File

@ -608,7 +608,7 @@ class ProfileViewSet(viewsets.ViewSet):
continue continue
return Response(timezones_data) return Response(timezones_data)
@action(detail=False, methods=['get'], url_path='cities/search', permission_classes=[AllowAny]) @action(detail=False, methods=['get'], url_path='cities/search', permission_classes=[AllowAny], authentication_classes=[])
def search_cities_from_csv(self, request): def search_cities_from_csv(self, request):
""" """
Поиск городов из city.csv по запросу. Поиск городов из city.csv по запросу.
@ -921,16 +921,8 @@ class ClientManagementViewSet(viewsets.ViewSet):
status=status.HTTP_403_FORBIDDEN status=status.HTTP_403_FORBIDDEN
) )
# Кеширование: кеш на 5 минут для каждого пользователя и страницы
# Увеличено с 2 до 5 минут для ускорения повторных загрузок страницы "Студенты"
page = int(request.query_params.get('page', 1)) page = int(request.query_params.get('page', 1))
page_size = int(request.query_params.get('page_size', 20)) page_size = int(request.query_params.get('page_size', 20))
cache_key = f'manage_clients_{user.id}_{page}_{page_size}'
cached_data = cache.get(cache_key)
if cached_data is not None:
return Response(cached_data)
# ВАЖНО: оптимизация страницы "Студенты" # ВАЖНО: оптимизация страницы "Студенты"
# Раньше ClientSerializer считал статистику занятий через 3 отдельных запроса на каждого клиента (N+1). # Раньше ClientSerializer считал статистику занятий через 3 отдельных запроса на каждого клиента (N+1).
@ -1026,9 +1018,6 @@ class ClientManagementViewSet(viewsets.ViewSet):
for inv in pending for inv in pending
] ]
# Сохраняем в кеш на 5 минут (300 секунд) для ускорения повторных загрузок
cache.set(cache_key, response_data.data, 300)
return response_data return response_data
@action(detail=False, methods=['get'], url_path='check-user') @action(detail=False, methods=['get'], url_path='check-user')
@ -1149,7 +1138,7 @@ class ClientManagementViewSet(viewsets.ViewSet):
defaults={ defaults={
'status': MentorStudentConnection.STATUS_PENDING_STUDENT, 'status': MentorStudentConnection.STATUS_PENDING_STUDENT,
'initiator': MentorStudentConnection.INITIATOR_MENTOR, 'initiator': MentorStudentConnection.INITIATOR_MENTOR,
'confirm_token': secrets.token_urlsafe(32) if is_new_user or True else None, 'confirm_token': secrets.token_urlsafe(32),
} }
) )
if not created: if not created:
@ -1161,6 +1150,13 @@ class ClientManagementViewSet(viewsets.ViewSet):
'message': 'Приглашение уже отправлено, ожидайте подтверждения', 'message': 'Приглашение уже отправлено, ожидайте подтверждения',
'invitation_id': conn.id, 'invitation_id': conn.id,
}, status=status.HTTP_200_OK) }, status=status.HTTP_200_OK)
# Связь была удалена — повторно отправляем приглашение
if conn.status == MentorStudentConnection.STATUS_REMOVED:
conn.status = MentorStudentConnection.STATUS_PENDING_STUDENT
conn.initiator = MentorStudentConnection.INITIATOR_MENTOR
conn.confirm_token = secrets.token_urlsafe(32)
conn.student_confirmed_at = None
conn.save(update_fields=['status', 'initiator', 'confirm_token', 'student_confirmed_at', 'updated_at'])
if not conn.confirm_token: if not conn.confirm_token:
conn.confirm_token = secrets.token_urlsafe(32) conn.confirm_token = secrets.token_urlsafe(32)
@ -1231,17 +1227,38 @@ class ClientManagementViewSet(viewsets.ViewSet):
future_lessons_count = future_lessons.count() future_lessons_count = future_lessons.count()
future_lessons.delete() future_lessons.delete()
# Удаляем ментора (это автоматически запретит доступ ко всем материалам) # Убираем доступ к доскам (не удаляем доски, только убираем участника)
from apps.board.models import Board
boards = Board.objects.filter(mentor=user, student=client.user)
for board in boards:
board.participants.remove(client.user)
# Закрываем активную связь
MentorStudentConnection.objects.filter(
mentor=user,
student=client.user,
status=MentorStudentConnection.STATUS_ACCEPTED
).update(status='removed')
# Удаляем ментора (убирает доступ к материалам)
client.mentors.remove(user) client.mentors.remove(user)
# Инвалидируем кеш списка клиентов для этого ментора # Уведомление ученику
# Удаляем все варианты кеша для этого пользователя (разные страницы и размеры) NotificationService.create_notification_with_telegram(
recipient=client.user,
notification_type='system',
title='Ментор завершил сотрудничество',
message=f'Ментор {user.get_full_name()} удалил вас из своего списка учеников. '
f'Будущие занятия отменены. Доступ к доскам приостановлен (при восстановлении связи — вернётся).',
data={'mentor_id': user.id}
)
for page in range(1, 10): for page in range(1, 10):
for size in [10, 20, 50, 100, 1000]: for size in [10, 20, 50, 100, 1000]:
cache.delete(f'manage_clients_{user.id}_{page}_{size}') cache.delete(f'manage_clients_{user.id}_{page}_{size}')
return Response({ return Response({
'message': 'Клиент успешно удален', 'message': 'Клиент успешно удалён',
'future_lessons_deleted': future_lessons_count 'future_lessons_deleted': future_lessons_count
}) })
@ -1253,23 +1270,114 @@ class ClientManagementViewSet(viewsets.ViewSet):
@action(detail=False, methods=['post'], url_path='generate-invitation-link') @action(detail=False, methods=['post'], url_path='generate-invitation-link')
def generate_invitation_link(self, request): def generate_invitation_link(self, request):
""" """
Сгенерировать или обновить токен ссылки-приглашения. Создать новую ссылку-приглашение (12 часов, 1 использование).
POST /api/users/manage/clients/generate-invitation-link/ Старые ссылки остаются действительными.
POST /api/manage/clients/generate-invitation-link/
""" """
from django.utils import timezone as tz
from datetime import timedelta
from .models import InvitationLink
user = request.user user = request.user
if user.role != 'mentor': if user.role != 'mentor':
return Response({'error': 'Только для менторов'}, status=status.HTTP_403_FORBIDDEN) return Response({'error': 'Только для менторов'}, status=status.HTTP_403_FORBIDDEN)
user.invitation_link_token = secrets.token_urlsafe(32) # Истекаем просроченные ссылки этого ментора
user.save(update_fields=['invitation_link_token']) expire_before = tz.now() - timedelta(hours=12)
InvitationLink.objects.filter(
mentor=user,
is_banned=False,
used_by__isnull=True,
created_at__lt=expire_before,
).update(is_banned=True)
token = secrets.token_urlsafe(32)
inv = InvitationLink.objects.create(mentor=user, token=token)
from django.conf import settings from django.conf import settings
frontend_url = getattr(settings, 'FRONTEND_URL', '').rstrip('/') frontend_url = getattr(settings, 'FRONTEND_URL', '').rstrip('/')
link = f"{frontend_url}/invite/{user.invitation_link_token}" link = f"{frontend_url}/invite/{token}"
return Response({ return Response({
'invitation_link_token': user.invitation_link_token, 'invitation_link_token': token,
'invitation_link': link 'invitation_link': link,
'expires_at': (inv.created_at + timedelta(hours=12)).isoformat(),
})
class StudentMentorViewSet(viewsets.ViewSet):
"""
Действия студента в отношении своих менторов.
remove_mentor: студент удаляет ментора из своего списка.
"""
permission_classes = [IsAuthenticated]
@action(detail=True, methods=['delete'], url_path='remove')
def remove_mentor(self, request, pk=None):
"""
Студент удаляет ментора.
DELETE /api/student/mentors/{mentor_id}/remove/
"""
from django.utils import timezone
from apps.schedule.models import Lesson
from apps.board.models import Board
user = request.user
if user.role not in ('client', 'parent'):
return Response({'error': 'Только для учеников'}, status=status.HTTP_403_FORBIDDEN)
try:
mentor = User.objects.get(id=pk, role='mentor')
except User.DoesNotExist:
return Response({'error': 'Ментор не найден'}, status=status.HTTP_404_NOT_FOUND)
try:
client = user.client_profile
except Client.DoesNotExist:
return Response({'error': 'Профиль ученика не найден'}, status=status.HTTP_404_NOT_FOUND)
if mentor not in client.mentors.all():
return Response({'error': 'Ментор не связан с вами'}, status=status.HTTP_400_BAD_REQUEST)
# Удаляем будущие занятия
now = timezone.now()
future_lessons = Lesson.objects.filter(
mentor=mentor,
client=client,
start_time__gt=now,
status='scheduled'
)
future_lessons_count = future_lessons.count()
future_lessons.delete()
# Убираем доступ к доскам
boards = Board.objects.filter(mentor=mentor, student=user)
for board in boards:
board.participants.remove(user)
# Закрываем активную связь
MentorStudentConnection.objects.filter(
mentor=mentor,
student=user,
status=MentorStudentConnection.STATUS_ACCEPTED
).update(status='removed')
# Удаляем ментора из профиля
client.mentors.remove(mentor)
# Уведомление ментору
NotificationService.create_notification_with_telegram(
recipient=mentor,
notification_type='system',
title='Ученик завершил сотрудничество',
message=f'Ученик {user.get_full_name()} удалил вас из своего списка менторов. '
f'Будущие занятия отменены. Доступ к доскам приостановлен.',
data={'student_id': user.id}
)
return Response({
'message': 'Ментор успешно удалён',
'future_lessons_deleted': future_lessons_count
}) })
@ -1453,19 +1561,36 @@ class InvitationViewSet(viewsets.ViewSet):
Получить информацию о менторе по токену ссылки-приглашения. Получить информацию о менторе по токену ссылки-приглашения.
GET /api/invitation/info-by-token/?token=... GET /api/invitation/info-by-token/?token=...
""" """
from django.utils import timezone as tz
from datetime import timedelta
from .models import InvitationLink
token = request.query_params.get('token') token = request.query_params.get('token')
if not token: if not token:
return Response({'error': 'Токен не указан'}, status=status.HTTP_400_BAD_REQUEST) return Response({'error': 'Токен не указан'}, status=status.HTTP_400_BAD_REQUEST)
try: try:
mentor = User.objects.get(invitation_link_token=token, role='mentor') inv = InvitationLink.objects.select_related('mentor').get(token=token)
except InvitationLink.DoesNotExist:
return Response({'error': 'Недействительная ссылка'}, status=status.HTTP_404_NOT_FOUND)
if inv.is_banned:
return Response({'error': 'Ссылка заблокирована'}, status=status.HTTP_410_GONE)
if inv.is_expired:
inv.is_banned = True
inv.save(update_fields=['is_banned'])
return Response({'error': 'Ссылка истекла'}, status=status.HTTP_410_GONE)
if inv.used_by_id is not None:
return Response({'error': 'Ссылка уже использована'}, status=status.HTTP_410_GONE)
mentor = inv.mentor
expires_at = inv.created_at + timedelta(hours=12)
return Response({ return Response({
'mentor_name': mentor.get_full_name(), 'mentor_name': mentor.get_full_name(),
'mentor_id': mentor.id, 'mentor_id': mentor.id,
'avatar_url': request.build_absolute_uri(mentor.avatar.url) if mentor.avatar else None, 'avatar_url': request.build_absolute_uri(mentor.avatar.url) if mentor.avatar else None,
'expires_at': expires_at.isoformat(),
}) })
except User.DoesNotExist:
return Response({'error': 'Недействительная ссылка'}, status=status.HTTP_404_NOT_FOUND)
@action(detail=False, methods=['post'], url_path='register-by-link', permission_classes=[AllowAny]) @action(detail=False, methods=['post'], url_path='register-by-link', permission_classes=[AllowAny])
def register_by_link(self, request): def register_by_link(self, request):
@ -1482,6 +1607,8 @@ class InvitationViewSet(viewsets.ViewSet):
"city": "..." "city": "..."
} }
""" """
from .models import InvitationLink
token = request.data.get('token') token = request.data.get('token')
first_name = request.data.get('first_name') first_name = request.data.get('first_name')
last_name = request.data.get('last_name') last_name = request.data.get('last_name')
@ -1494,10 +1621,21 @@ class InvitationViewSet(viewsets.ViewSet):
return Response({'error': 'Имя, фамилия и токен обязательны'}, status=status.HTTP_400_BAD_REQUEST) return Response({'error': 'Имя, фамилия и токен обязательны'}, status=status.HTTP_400_BAD_REQUEST)
try: try:
mentor = User.objects.get(invitation_link_token=token, role='mentor') inv = InvitationLink.objects.select_related('mentor').get(token=token)
except User.DoesNotExist: except InvitationLink.DoesNotExist:
return Response({'error': 'Недействительная ссылка'}, status=status.HTTP_404_NOT_FOUND) return Response({'error': 'Недействительная ссылка'}, status=status.HTTP_404_NOT_FOUND)
if inv.is_banned:
return Response({'error': 'Ссылка заблокирована'}, status=status.HTTP_410_GONE)
if inv.is_expired:
inv.is_banned = True
inv.save(update_fields=['is_banned'])
return Response({'error': 'Срок действия ссылки истёк'}, status=status.HTTP_410_GONE)
if inv.used_by_id is not None:
return Response({'error': 'Ссылка уже была использована'}, status=status.HTTP_410_GONE)
mentor = inv.mentor
# Если email указан, проверяем его уникальность # Если email указан, проверяем его уникальность
if email: if email:
if User.objects.filter(email=email).exists(): if User.objects.filter(email=email).exists():
@ -1523,6 +1661,10 @@ class InvitationViewSet(viewsets.ViewSet):
student_user.login_token = secrets.token_urlsafe(32) student_user.login_token = secrets.token_urlsafe(32)
student_user.save(update_fields=['login_token']) student_user.save(update_fields=['login_token'])
# Помечаем ссылку как использованную
inv.used_by = student_user
inv.save(update_fields=['used_by'])
# Создаем профиль клиента # Создаем профиль клиента
client = Client.objects.create(user=student_user) client = Client.objects.create(user=student_user)
@ -1601,34 +1743,55 @@ class ParentManagementViewSet(viewsets.ViewSet):
status=status.HTTP_403_FORBIDDEN status=status.HTTP_403_FORBIDDEN
) )
# Поддержка старого формата (child_email) и нового (email + данные) # Поддержка: universal_code (8 симв.) / child_email / email
universal_code = (request.data.get('universal_code') or '').strip().upper()
child_email = request.data.get('child_email') or request.data.get('email') child_email = request.data.get('child_email') or request.data.get('email')
if not child_email: if not universal_code and not child_email:
return Response( return Response(
{'error': 'Необходимо указать email ребенка'}, {'error': 'Необходимо указать 8-значный код ребенка или его email'},
status=status.HTTP_400_BAD_REQUEST status=status.HTTP_400_BAD_REQUEST
) )
# Нормализуем email
child_email = child_email.lower().strip()
# Получаем или создаем профиль родителя # Получаем или создаем профиль родителя
parent, created = Parent.objects.get_or_create(user=user) parent, _ = Parent.objects.get_or_create(user=user)
# Ищем пользователя
created = False created = False
if universal_code:
# --- Поиск по universal_code ---
allowed = set('ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789')
valid_8 = len(universal_code) == 8 and all(c in allowed for c in universal_code)
valid_6_legacy = len(universal_code) == 6 and universal_code.isdigit()
if not (valid_8 or valid_6_legacy):
return Response(
{'error': 'Код должен содержать 8 символов (буквы и цифры)'},
status=status.HTTP_400_BAD_REQUEST
)
try: try:
child_user = User.objects.get(email=child_email) child_user = User.objects.get(universal_code=universal_code)
# Если пользователь существует, проверяем что это клиент except User.DoesNotExist:
return Response(
{'error': 'Пользователь с таким кодом не найден'},
status=status.HTTP_404_NOT_FOUND
)
if child_user.role != 'client': if child_user.role != 'client':
return Response( return Response(
{'error': 'Пользователь с таким email уже существует, но не является клиентом'}, {'error': 'Пользователь с этим кодом не является учеником (client)'},
status=status.HTTP_400_BAD_REQUEST
)
else:
# --- Поиск / создание по email ---
child_email = child_email.lower().strip()
try:
child_user = User.objects.get(email=child_email)
if child_user.role != 'client':
return Response(
{'error': 'Пользователь с таким email не является учеником (client)'},
status=status.HTTP_400_BAD_REQUEST status=status.HTTP_400_BAD_REQUEST
) )
except User.DoesNotExist: except User.DoesNotExist:
created = True created = True
# Создаем нового пользователя-клиента
first_name = request.data.get('first_name', '').strip() first_name = request.data.get('first_name', '').strip()
last_name = request.data.get('last_name', '').strip() last_name = request.data.get('last_name', '').strip()
phone = request.data.get('phone', '').strip() phone = request.data.get('phone', '').strip()
@ -1639,9 +1802,7 @@ class ParentManagementViewSet(viewsets.ViewSet):
status=status.HTTP_400_BAD_REQUEST status=status.HTTP_400_BAD_REQUEST
) )
# Создаем пользователя с временным паролем
temp_password = secrets.token_urlsafe(12) temp_password = secrets.token_urlsafe(12)
child_user = User.objects.create_user( child_user = User.objects.create_user(
email=child_email, email=child_email,
password=temp_password, password=temp_password,
@ -1649,15 +1810,11 @@ class ParentManagementViewSet(viewsets.ViewSet):
last_name=last_name, last_name=last_name,
phone=normalize_phone(phone) if phone else '', phone=normalize_phone(phone) if phone else '',
role='client', role='client',
email_verified=True, # Email автоматически подтвержден при добавлении родителем email_verified=True,
) )
# Генерируем токен для установки пароля
reset_token = secrets.token_urlsafe(32) reset_token = secrets.token_urlsafe(32)
child_user.email_verification_token = reset_token child_user.email_verification_token = reset_token
child_user.save() child_user.save()
# Отправляем приветственное письмо со ссылкой на установку пароля
send_student_welcome_email_task.delay(child_user.id, reset_token) send_student_welcome_email_task.delay(child_user.id, reset_token)
# Получаем или создаем профиль клиента # Получаем или создаем профиль клиента

View File

@ -130,6 +130,17 @@ class RegisterSerializer(serializers.ModelSerializer):
"""Нормализация email в нижний регистр.""" """Нормализация email в нижний регистр."""
return value.lower().strip() if value else value return value.lower().strip() if value else value
def validate_timezone(self, value):
"""Проверяем что timezone — валидный IANA идентификатор."""
if not value:
return 'Europe/Moscow'
import zoneinfo
try:
zoneinfo.ZoneInfo(value)
return value
except Exception:
return 'Europe/Moscow'
def validate(self, attrs): def validate(self, attrs):
"""Проверка совпадения паролей.""" """Проверка совпадения паролей."""
if attrs.get('password') != attrs.get('password_confirm'): if attrs.get('password') != attrs.get('password_confirm'):

View File

@ -1,11 +1,11 @@
""" """
Сигналы для пользователей. Сигналы для пользователей.
""" """
from django.db.models.signals import post_save, post_delete from django.db.models.signals import post_save, post_delete, m2m_changed
from django.dispatch import receiver from django.dispatch import receiver
from django.core.cache import cache from django.core.cache import cache
from .models import MentorStudentConnection from .models import MentorStudentConnection, Group
def _invalidate_manage_clients_cache(mentor_id): def _invalidate_manage_clients_cache(mentor_id):
@ -28,3 +28,24 @@ def mentor_student_connection_changed(sender, instance, created, **kwargs):
if instance.mentor_id: if instance.mentor_id:
_invalidate_manage_clients_cache(instance.mentor_id) _invalidate_manage_clients_cache(instance.mentor_id)
@receiver(post_save, sender=Group)
def group_saved(sender, instance, created, **kwargs):
"""При создании/обновлении группы — синхронизировать групповой чат."""
try:
from apps.chat.services import ChatService
ChatService.get_or_create_group_chat(instance)
except Exception:
pass
@receiver(m2m_changed, sender=Group.students.through)
def group_students_changed(sender, instance, action, **kwargs):
"""При изменении участников группы — синхронизировать участников чата."""
if action in ('post_add', 'post_remove', 'post_clear'):
try:
from apps.chat.services import ChatService
ChatService.get_or_create_group_chat(instance)
except Exception:
pass

View File

@ -25,6 +25,7 @@ def send_welcome_email_task(user_id):
context = { context = {
'user_full_name': user.get_full_name() or user.email, 'user_full_name': user.get_full_name() or user.email,
'user_email': user.email, 'user_email': user.email,
'login_url': f"{settings.FRONTEND_URL}/auth/jwt/sign-in",
} }
# Загружаем HTML и текстовые шаблоны # Загружаем HTML и текстовые шаблоны
@ -60,7 +61,7 @@ def send_verification_email_task(user_id, verification_token):
user = User.objects.get(id=user_id) user = User.objects.get(id=user_id)
# URL для подтверждения # URL для подтверждения
verification_url = f"{settings.FRONTEND_URL}/verify-email?token={verification_token}" verification_url = f"{settings.FRONTEND_URL}/auth/jwt/verify-email?token={verification_token}"
subject = 'Подтвердите ваш email' subject = 'Подтвердите ваш email'
@ -102,7 +103,7 @@ def send_password_reset_email_task(user_id, reset_token):
user = User.objects.get(id=user_id) user = User.objects.get(id=user_id)
# URL для сброса пароля # URL для сброса пароля
reset_url = f"{settings.FRONTEND_URL}/reset-password?token={reset_token}" reset_url = f"{settings.FRONTEND_URL}/auth/jwt/reset-password?token={reset_token}"
subject = 'Восстановление пароля' subject = 'Восстановление пароля'
@ -144,7 +145,7 @@ def send_student_welcome_email_task(user_id, reset_token):
user = User.objects.get(id=user_id) user = User.objects.get(id=user_id)
# URL для установки пароля # URL для установки пароля
set_password_url = f"{settings.FRONTEND_URL}/reset-password?token={reset_token}" set_password_url = f"{settings.FRONTEND_URL}/auth/jwt/reset-password?token={reset_token}"
subject = 'Добро пожаловать на платформу!' subject = 'Добро пожаловать на платформу!'

View File

@ -4,61 +4,42 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta http-equiv="X-UA-Compatible" content="IE=edge">
<title>{% block title %}Uchill{% endblock %}</title> <title>{% block title %}Училл{% endblock %}</title>
<!--[if mso]> <!--[if mso]><style>body,table,td{font-family:Arial,sans-serif!important;}</style><![endif]-->
<style type="text/css">
body, table, td {font-family: Arial, sans-serif !important;}
</style>
<![endif]-->
</head> </head>
<body style="margin: 0; padding: 0; background-color: #FAFAFA; font-family: 'Roboto', -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif;"> <body style="margin:0;padding:0;background-color:#F4F6F8;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif;">
<!-- Wrapper table --> <table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background-color:#F4F6F8;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background-color: #FAFAFA;"> <tr><td align="center" style="padding:40px 16px;">
<!-- Card -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="580" style="max-width:580px;width:100%;">
<!-- Header -->
<tr> <tr>
<td align="center" style="padding: 40px 20px;"> <td style="background:linear-gradient(135deg,#7444FD 0%,#9B6FFF 100%);border-radius:16px 16px 0 0;padding:40px;text-align:center;">
<!-- Main content table --> <span style="font-size:32px;font-weight:800;color:#ffffff;letter-spacing:-0.5px;font-family:Arial,sans-serif;">Училл</span>
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="600" style="max-width: 600px; background-color: #FFFFFF; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.1);"> <br>
<!-- Header with logo --> <span style="font-size:13px;color:rgba(255,255,255,0.75);letter-spacing:1px;text-transform:uppercase;margin-top:4px;display:inline-block;">Платформа для обучения</span>
<tr>
<td style="padding: 40px 40px 30px 40px; text-align: center; background-color: #FFFFFF; border-radius: 8px 8px 0 0;">
<!-- Стилизованный текстовый логотип uchill -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" align="center">
<tr>
<td>
<span style="display: inline-block; color: #7444FD; letter-spacing: 1px; font-size: 48px; font-weight: 600; line-height: 1.2;">
<span style="background-color: #7444FD; color: #ffffff; border-radius: 2px; margin-right: 2px; font-weight: 600; font-size: 48px; display: inline-block; vertical-align: baseline; line-height: 1; padding: 2px 8px; letter-spacing: 0;">u</span><span style="vertical-align: baseline;">chill</span>
</span>
</td>
</tr>
</table>
</td> </td>
</tr> </tr>
<!-- Content block --> <!-- Body -->
<tr> <tr>
<td style="padding: 0 40px 40px 40px;"> <td style="background:#ffffff;padding:40px;">
{% block content %}{% endblock %} {% block content %}{% endblock %}
</td> </td>
</tr> </tr>
<!-- Footer --> <!-- Footer -->
<tr> <tr>
<td style="padding: 30px 40px; background-color: #F5F5F5; border-radius: 0 0 8px 8px; border-top: 1px solid #E0E0E0;"> <td style="background:#F8F9FA;border-radius:0 0 16px 16px;padding:28px 40px;border-top:1px solid #EAECF0;text-align:center;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%"> <p style="margin:0 0 8px 0;font-size:14px;color:#6B7280;">С уважением, <strong style="color:#7444FD;">Команда Училл</strong></p>
<tr> <p style="margin:0;font-size:12px;color:#9CA3AF;">© {% now "Y" %} Училл. Все права защищены.</p>
<td style="text-align: center; font-size: 14px; color: #757575; line-height: 1.6;">
<p style="margin: 0 0 10px 0;">С уважением,<br><strong style="color: #7444FD;">Команда Uchill</strong></p>
<p style="margin: 10px 0 0 0; font-size: 12px; color: #9E9E9E;">
© {% now "Y" %} Uchill. Все права защищены.
</p>
</td> </td>
</tr> </tr>
</table> </table>
</td> </td></tr>
</tr>
</table>
</td>
</tr>
</table> </table>
</body> </body>
</html> </html>

View File

@ -4,167 +4,111 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta http-equiv="X-UA-Compatible" content="IE=edge">
<title>Приглашение от ментора - Uchill</title> <title>Приглашение от ментора — Училл</title>
<!--[if mso]> <!--[if mso]><style>body,table,td{font-family:Arial,sans-serif!important;}</style><![endif]-->
<style type="text/css">
body, table, td {font-family: Arial, sans-serif !important;}
</style>
<![endif]-->
</head> </head>
<body style="margin: 0; padding: 0; background-color: #FAFAFA; font-family: 'Roboto', -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif;"> <body style="margin:0;padding:0;background-color:#F4F6F8;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif;">
<!-- Wrapper table --> <table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background-color:#F4F6F8;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background-color: #FAFAFA;"> <tr><td align="center" style="padding:40px 16px;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="580" style="max-width:580px;width:100%;">
<!-- Header -->
<tr> <tr>
<td align="center" style="padding: 40px 20px;"> <td style="background:linear-gradient(135deg,#7444FD 0%,#9B6FFF 100%);border-radius:16px 16px 0 0;padding:40px;text-align:center;">
<!-- Main content table --> <span style="font-size:32px;font-weight:800;color:#ffffff;letter-spacing:-0.5px;font-family:Arial,sans-serif;">Училл</span><br>
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="600" style="max-width: 600px; background-color: #FFFFFF; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.1);"> <span style="font-size:13px;color:rgba(255,255,255,0.75);letter-spacing:1px;text-transform:uppercase;margin-top:4px;display:inline-block;">Платформа для обучения</span>
<!-- Header with logo -->
<tr>
<td style="padding: 40px 40px 30px 40px; text-align: center; background-color: #FFFFFF; border-radius: 8px 8px 0 0;">
<!-- Стилизованный текстовый логотип uchill -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" align="center">
<tr>
<td>
<span style="display: inline-block; color: #7444FD; letter-spacing: 1px; font-size: 48px; font-weight: 600; line-height: 1.2;">
<span style="background-color: #7444FD; color: #ffffff; border-radius: 2px; margin-right: 2px; font-weight: 600; font-size: 48px; display: inline-block; vertical-align: baseline; line-height: 1; padding: 2px 8px; letter-spacing: 0;">u</span><span style="vertical-align: baseline;">chill</span>
</span>
</td> </td>
</tr> </tr>
<!-- Body -->
<tr>
<td style="background:#ffffff;padding:48px 40px 40px;">
<!-- Icon -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" align="center" style="margin-bottom:24px;">
<tr><td style="background:#EEE8FF;border-radius:50%;width:72px;height:72px;text-align:center;vertical-align:middle;">
<span style="font-size:36px;line-height:72px;">🎓</span>
</td></tr>
</table> </table>
</td>
</tr>
<!-- Content --> <h1 style="margin:0 0 8px 0;font-size:26px;font-weight:700;color:#111827;text-align:center;">Вас приглашают учиться!</h1>
<tr> <p style="margin:0 0 32px 0;font-size:15px;color:#6B7280;text-align:center;line-height:1.6;">Личное приглашение от ментора</p>
<td style="padding: 0 40px 40px 40px;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<!-- Title -->
<tr>
<td style="padding-bottom: 24px;">
<h1 style="margin: 0; font-size: 28px; font-weight: 500; color: #212121; line-height: 1.2;">Приглашение от ментора</h1>
</td>
</tr>
<!-- Greeting --> <p style="margin:0 0 24px 0;font-size:16px;color:#374151;line-height:1.7;">
<tr>
<td style="padding-bottom: 24px;">
<p style="margin: 0; font-size: 16px; color: #424242; line-height: 1.6;">
Здравствуйте! Здравствуйте!
</p> </p>
</td>
</tr>
<!-- Main message --> <!-- Mentor highlight -->
<tr> <table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background:linear-gradient(135deg,#F5F0FF,#EEE8FF);border:1px solid #DDD6FE;border-radius:12px;margin-bottom:24px;">
<td style="padding-bottom: 24px;"> <tr><td style="padding:20px 24px;">
<p style="margin: 0; font-size: 16px; color: #424242; line-height: 1.6;"> <p style="margin:0 0 4px 0;font-size:12px;font-weight:600;color:#7444FD;text-transform:uppercase;letter-spacing:0.5px;">Ментор</p>
<strong style="color: #7444FD;">{{ mentor_name }}</strong> приглашает вас в качестве ученика на платформу Uchill. <p style="margin:0;font-size:20px;font-weight:700;color:#111827;">{{ mentor_name }}</p>
</p> <p style="margin:4px 0 0 0;font-size:14px;color:#6B7280;">приглашает вас на платформу <strong style="color:#7444FD;">Училл</strong></p>
</td> </td></tr>
</tr> </table>
{% if set_password_url %} {% if set_password_url %}
<!-- New user flow --> <p style="margin:0 0 32px 0;font-size:16px;color:#374151;line-height:1.7;">
<tr> Для начала занятий установите пароль и подтвердите приглашение — это займёт меньше минуты.
<td style="padding-bottom: 24px;">
<p style="margin: 0; font-size: 16px; color: #424242; line-height: 1.6;">
Для начала работы установите пароль и подтвердите приглашение.
</p> </p>
</td>
</tr>
<!-- Button --> <!-- Button -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" align="center" style="margin:0 auto 32px;">
<tr> <tr>
<td style="padding-top: 8px; padding-bottom: 24px; text-align: center;"> <td style="background:linear-gradient(135deg,#7444FD,#9B6FFF);border-radius:10px;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" align="center"> <a href="{{ set_password_url }}" style="display:inline-block;padding:16px 40px;font-size:16px;font-weight:600;color:#ffffff;text-decoration:none;border-radius:10px;letter-spacing:0.2px;">
<tr> Принять приглашение
<td style="background-color: #7444FD; border-radius: 4px;">
<a href="{{ set_password_url }}" style="display: inline-block; padding: 14px 32px; font-size: 16px; font-weight: 500; color: #FFFFFF; text-decoration: none; border-radius: 4px;">
Установить пароль и подтвердить
</a> </a>
</td> </td>
</tr> </tr>
</table> </table>
</td>
</tr>
<!-- Link fallback --> <!-- Link fallback -->
<tr> <table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background:#F9FAFB;border:1px solid #E5E7EB;border-radius:8px;">
<td style="padding-bottom: 24px;"> <tr><td style="padding:16px;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background-color: #F5F5F5; border-radius: 4px;"> <p style="margin:0 0 6px 0;font-size:12px;font-weight:600;color:#6B7280;text-transform:uppercase;letter-spacing:0.5px;">Или скопируйте ссылку:</p>
<tr> <p style="margin:0;font-size:12px;color:#9CA3AF;word-break:break-all;line-height:1.6;">{{ set_password_url }}</p>
<td style="padding: 12px;"> </td></tr>
<p style="margin: 0; font-size: 12px; color: #9E9E9E; word-break: break-all; line-height: 1.5;">
{{ set_password_url }}
</p>
</td>
</tr>
</table> </table>
</td>
</tr>
{% elif confirm_url %} {% elif confirm_url %}
<!-- Existing user flow --> <p style="margin:0 0 32px 0;font-size:16px;color:#374151;line-height:1.7;">
<tr>
<td style="padding-bottom: 24px;">
<p style="margin: 0; font-size: 16px; color: #424242; line-height: 1.6;">
Подтвердите приглашение, чтобы начать занятия с ментором. Подтвердите приглашение, чтобы начать занятия с ментором.
</p> </p>
</td>
</tr>
<!-- Button --> <!-- Button -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" align="center" style="margin:0 auto 32px;">
<tr> <tr>
<td style="padding-top: 8px; padding-bottom: 24px; text-align: center;"> <td style="background:linear-gradient(135deg,#7444FD,#9B6FFF);border-radius:10px;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" align="center"> <a href="{{ confirm_url }}" style="display:inline-block;padding:16px 40px;font-size:16px;font-weight:600;color:#ffffff;text-decoration:none;border-radius:10px;letter-spacing:0.2px;">
<tr>
<td style="background-color: #7444FD; border-radius: 4px;">
<a href="{{ confirm_url }}" style="display: inline-block; padding: 14px 32px; font-size: 16px; font-weight: 500; color: #FFFFFF; text-decoration: none; border-radius: 4px;">
Подтвердить приглашение Подтвердить приглашение
</a> </a>
</td> </td>
</tr> </tr>
</table> </table>
</td>
</tr>
<!-- Link fallback --> <!-- Link fallback -->
<tr> <table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background:#F9FAFB;border:1px solid #E5E7EB;border-radius:8px;">
<td style="padding-bottom: 24px;"> <tr><td style="padding:16px;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background-color: #F5F5F5; border-radius: 4px;"> <p style="margin:0 0 6px 0;font-size:12px;font-weight:600;color:#6B7280;text-transform:uppercase;letter-spacing:0.5px;">Или скопируйте ссылку:</p>
<tr> <p style="margin:0;font-size:12px;color:#9CA3AF;word-break:break-all;line-height:1.6;">{{ confirm_url }}</p>
<td style="padding: 12px;"> </td></tr>
<p style="margin: 0; font-size: 12px; color: #9E9E9E; word-break: break-all; line-height: 1.5;">
{{ confirm_url }}
</p>
</td>
</tr>
</table> </table>
</td>
</tr>
{% endif %} {% endif %}
</table>
</td> </td>
</tr> </tr>
<!-- Footer --> <!-- Footer -->
<tr> <tr>
<td style="padding: 30px 40px; background-color: #F5F5F5; border-radius: 0 0 8px 8px; border-top: 1px solid #E0E0E0;"> <td style="background:#F8F9FA;border-radius:0 0 16px 16px;padding:28px 40px;border-top:1px solid #EAECF0;text-align:center;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%"> <p style="margin:0 0 8px 0;font-size:14px;color:#6B7280;">С уважением, <strong style="color:#7444FD;">Команда Училл</strong></p>
<tr> <p style="margin:0;font-size:12px;color:#9CA3AF;">© {% now "Y" %} Училл. Все права защищены.</p>
<td style="text-align: center; font-size: 14px; color: #757575; line-height: 1.6;">
<p style="margin: 0 0 10px 0;">С уважением,<br><strong style="color: #7444FD;">Команда Uchill</strong></p>
<p style="margin: 10px 0 0 0; font-size: 12px; color: #9E9E9E;">
© 2026 Uchill. Все права защищены.
</p>
</td> </td>
</tr> </tr>
</table> </table>
</td> </td></tr>
</tr>
</table>
</td>
</tr>
</table> </table>
</body> </body>
</html> </html>

View File

@ -4,143 +4,93 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta http-equiv="X-UA-Compatible" content="IE=edge">
<title>Восстановление пароля - Uchill</title> <title>Восстановление пароля — Училл</title>
<!--[if mso]> <!--[if mso]><style>body,table,td{font-family:Arial,sans-serif!important;}</style><![endif]-->
<style type="text/css">
body, table, td {font-family: Arial, sans-serif !important;}
</style>
<![endif]-->
</head> </head>
<body style="margin: 0; padding: 0; background-color: #FAFAFA; font-family: 'Roboto', -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif;"> <body style="margin:0;padding:0;background-color:#F4F6F8;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif;">
<!-- Wrapper table --> <table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background-color:#F4F6F8;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background-color: #FAFAFA;"> <tr><td align="center" style="padding:40px 16px;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="580" style="max-width:580px;width:100%;">
<!-- Header -->
<tr> <tr>
<td align="center" style="padding: 40px 20px;"> <td style="background:linear-gradient(135deg,#7444FD 0%,#9B6FFF 100%);border-radius:16px 16px 0 0;padding:40px;text-align:center;">
<!-- Main content table --> <span style="font-size:32px;font-weight:800;color:#ffffff;letter-spacing:-0.5px;font-family:Arial,sans-serif;">Училл</span><br>
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="600" style="max-width: 600px; background-color: #FFFFFF; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.1);"> <span style="font-size:13px;color:rgba(255,255,255,0.75);letter-spacing:1px;text-transform:uppercase;margin-top:4px;display:inline-block;">Платформа для обучения</span>
<!-- Header with logo -->
<tr>
<td style="padding: 40px 40px 30px 40px; text-align: center; background-color: #FFFFFF; border-radius: 8px 8px 0 0;">
<!-- Стилизованный текстовый логотип uchill -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" align="center">
<tr>
<td>
<span style="display: inline-block; color: #7444FD; letter-spacing: 1px; font-size: 48px; font-weight: 600; line-height: 1.2;">
<span style="background-color: #7444FD; color: #ffffff; border-radius: 2px; margin-right: 2px; font-weight: 600; font-size: 48px; display: inline-block; vertical-align: baseline; line-height: 1; padding: 2px 8px; letter-spacing: 0;">u</span><span style="vertical-align: baseline;">chill</span>
</span>
</td> </td>
</tr> </tr>
<!-- Body -->
<tr>
<td style="background:#ffffff;padding:48px 40px 40px;">
<!-- Icon -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" align="center" style="margin-bottom:24px;">
<tr><td style="background:#FEF3C7;border-radius:50%;width:72px;height:72px;text-align:center;vertical-align:middle;">
<span style="font-size:36px;line-height:72px;">🔐</span>
</td></tr>
</table> </table>
</td>
</tr>
<!-- Content --> <h1 style="margin:0 0 8px 0;font-size:26px;font-weight:700;color:#111827;text-align:center;">Восстановление пароля</h1>
<tr> <p style="margin:0 0 32px 0;font-size:15px;color:#6B7280;text-align:center;line-height:1.6;">Мы получили запрос на сброс пароля</p>
<td style="padding: 0 40px 40px 40px;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<!-- Title -->
<tr>
<td style="padding-bottom: 24px;">
<h1 style="margin: 0; font-size: 28px; font-weight: 500; color: #212121; line-height: 1.2;">Восстановление пароля</h1>
</td>
</tr>
<!-- Greeting --> <p style="margin:0 0 16px 0;font-size:16px;color:#374151;line-height:1.7;">
<tr> Здравствуйте, <strong style="color:#111827;">{{ user_full_name }}</strong>!
<td style="padding-bottom: 24px;">
<p style="margin: 0; font-size: 16px; color: #424242; line-height: 1.6;">
Здравствуйте, <strong style="color: #212121;">{{ user_full_name }}</strong>!
</p> </p>
</td> <p style="margin:0 0 32px 0;font-size:16px;color:#374151;line-height:1.7;">
</tr> Нажмите на кнопку ниже, чтобы установить новый пароль для вашего аккаунта.
<!-- Main message -->
<tr>
<td style="padding-bottom: 24px;">
<p style="margin: 0; font-size: 16px; color: #424242; line-height: 1.6;">
Вы запросили восстановление пароля для вашего аккаунта. Нажмите на кнопку ниже, чтобы установить новый пароль.
</p> </p>
</td>
</tr>
<!-- Button --> <!-- Button -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" align="center" style="margin:0 auto 32px;">
<tr> <tr>
<td style="padding-top: 8px; padding-bottom: 24px; text-align: center;"> <td style="background:linear-gradient(135deg,#7444FD,#9B6FFF);border-radius:10px;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" align="center"> <a href="{{ reset_url }}" style="display:inline-block;padding:16px 40px;font-size:16px;font-weight:600;color:#ffffff;text-decoration:none;border-radius:10px;letter-spacing:0.2px;">
<tr> Установить новый пароль
<td style="background-color: #7444FD; border-radius: 4px;">
<a href="{{ reset_url }}" style="display: inline-block; padding: 14px 32px; font-size: 16px; font-weight: 500; color: #FFFFFF; text-decoration: none; border-radius: 4px;">
Восстановить пароль
</a> </a>
</td> </td>
</tr> </tr>
</table> </table>
</td>
</tr>
<!-- Link fallback --> <!-- Link fallback -->
<tr> <table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background:#F9FAFB;border:1px solid #E5E7EB;border-radius:8px;margin-bottom:24px;">
<td style="padding-bottom: 24px;"> <tr><td style="padding:16px;">
<p style="margin: 0 0 8px 0; font-size: 14px; color: #757575;"> <p style="margin:0 0 6px 0;font-size:12px;font-weight:600;color:#6B7280;text-transform:uppercase;letter-spacing:0.5px;">Или скопируйте ссылку:</p>
Или скопируйте и вставьте эту ссылку в браузер: <p style="margin:0;font-size:12px;color:#9CA3AF;word-break:break-all;line-height:1.6;">{{ reset_url }}</p>
</p> </td></tr>
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background-color: #F5F5F5; border-radius: 4px;">
<tr>
<td style="padding: 12px;">
<p style="margin: 0; font-size: 12px; color: #9E9E9E; word-break: break-all; line-height: 1.5;">
{{ reset_url }}
</p>
</td>
</tr>
</table> </table>
</td>
</tr>
<!-- Warning box --> <!-- Warning -->
<tr> <table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background:#FFFBEB;border-left:4px solid #F59E0B;border-radius:8px;margin-bottom:24px;">
<td style="padding-bottom: 24px;"> <tr><td style="padding:14px 16px;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background-color: #FFF3E0; border-left: 4px solid #FF9800; border-radius: 4px;"> <p style="margin:0;font-size:13px;color:#92400E;line-height:1.6;">
<tr> <strong>Важно:</strong> ссылка действительна в течение 24 часов.
<td style="padding: 16px;">
<p style="margin: 0; font-size: 14px; color: #E65100;">
<strong>⚠️ Важно:</strong> Ссылка действительна в течение 24 часов.
</p> </p>
</td> </td></tr>
</tr>
</table> </table>
</td>
</tr>
<!-- Security notice --> <!-- Security notice -->
<tr> <table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background:#F9FAFB;border:1px solid #E5E7EB;border-radius:8px;">
<td style="padding-top: 24px; border-top: 1px solid #E0E0E0;"> <tr><td style="padding:14px 16px;">
<p style="margin: 0; font-size: 12px; color: #9E9E9E; line-height: 1.6;"> <p style="margin:0;font-size:13px;color:#6B7280;line-height:1.6;">
Если вы не запрашивали восстановление пароля, просто проигнорируйте это письмо. Ваш пароль останется без изменений. Если вы не запрашивали восстановление пароля просто проигнорируйте это письмо. Ваш пароль останется без изменений.
</p> </p>
</td> </td></tr>
</tr>
</table> </table>
</td> </td>
</tr> </tr>
<!-- Footer --> <!-- Footer -->
<tr> <tr>
<td style="padding: 30px 40px; background-color: #F5F5F5; border-radius: 0 0 8px 8px; border-top: 1px solid #E0E0E0;"> <td style="background:#F8F9FA;border-radius:0 0 16px 16px;padding:28px 40px;border-top:1px solid #EAECF0;text-align:center;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%"> <p style="margin:0 0 8px 0;font-size:14px;color:#6B7280;">С уважением, <strong style="color:#7444FD;">Команда Училл</strong></p>
<tr> <p style="margin:0;font-size:12px;color:#9CA3AF;">© {% now "Y" %} Училл. Все права защищены.</p>
<td style="text-align: center; font-size: 14px; color: #757575; line-height: 1.6;">
<p style="margin: 0 0 10px 0;">С уважением,<br><strong style="color: #7444FD;">Команда Uchill</strong></p>
<p style="margin: 10px 0 0 0; font-size: 12px; color: #9E9E9E;">
© 2026 Uchill. Все права защищены.
</p>
</td> </td>
</tr> </tr>
</table> </table>
</td> </td></tr>
</tr>
</table>
</td>
</tr>
</table> </table>
</body> </body>
</html> </html>

View File

@ -4,152 +4,92 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta http-equiv="X-UA-Compatible" content="IE=edge">
<title>Добро пожаловать на Uchill</title> <title>Добро пожаловать на Училл</title>
<!--[if mso]> <!--[if mso]><style>body,table,td{font-family:Arial,sans-serif!important;}</style><![endif]-->
<style type="text/css">
body, table, td {font-family: Arial, sans-serif !important;}
</style>
<![endif]-->
</head> </head>
<body style="margin: 0; padding: 0; background-color: #FAFAFA; font-family: 'Roboto', -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif;"> <body style="margin:0;padding:0;background-color:#F4F6F8;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif;">
<!-- Wrapper table --> <table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background-color:#F4F6F8;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background-color: #FAFAFA;"> <tr><td align="center" style="padding:40px 16px;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="580" style="max-width:580px;width:100%;">
<!-- Header -->
<tr> <tr>
<td align="center" style="padding: 40px 20px;"> <td style="background:linear-gradient(135deg,#7444FD 0%,#9B6FFF 100%);border-radius:16px 16px 0 0;padding:40px;text-align:center;">
<!-- Main content table --> <span style="font-size:32px;font-weight:800;color:#ffffff;letter-spacing:-0.5px;font-family:Arial,sans-serif;">Училл</span><br>
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="600" style="max-width: 600px; background-color: #FFFFFF; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.1);"> <span style="font-size:13px;color:rgba(255,255,255,0.75);letter-spacing:1px;text-transform:uppercase;margin-top:4px;display:inline-block;">Платформа для обучения</span>
<!-- Header with logo -->
<tr>
<td style="padding: 40px 40px 30px 40px; text-align: center; background-color: #FFFFFF; border-radius: 8px 8px 0 0;">
<!-- Стилизованный текстовый логотип uchill -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" align="center">
<tr>
<td>
<span style="display: inline-block; color: #7444FD; letter-spacing: 1px; font-size: 48px; font-weight: 600; line-height: 1.2;">
<span style="background-color: #7444FD; color: #ffffff; border-radius: 2px; margin-right: 2px; font-weight: 600; font-size: 48px; display: inline-block; vertical-align: baseline; line-height: 1; padding: 2px 8px; letter-spacing: 0;">u</span><span style="vertical-align: baseline;">chill</span>
</span>
</td> </td>
</tr> </tr>
<!-- Body -->
<tr>
<td style="background:#ffffff;padding:48px 40px 40px;">
<!-- Icon -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" align="center" style="margin-bottom:24px;">
<tr><td style="background:#ECFDF5;border-radius:50%;width:72px;height:72px;text-align:center;vertical-align:middle;">
<span style="font-size:36px;line-height:72px;">🎉</span>
</td></tr>
</table> </table>
</td>
</tr>
<!-- Content --> <h1 style="margin:0 0 8px 0;font-size:26px;font-weight:700;color:#111827;text-align:center;">Добро пожаловать!</h1>
<tr> <p style="margin:0 0 32px 0;font-size:15px;color:#6B7280;text-align:center;line-height:1.6;">Ваш аккаунт на Училл создан</p>
<td style="padding: 0 40px 40px 40px;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<!-- Title -->
<tr>
<td style="padding-bottom: 24px;">
<h1 style="margin: 0; font-size: 28px; font-weight: 500; color: #212121; line-height: 1.2;">Добро пожаловать!</h1>
</td>
</tr>
<!-- Greeting --> <p style="margin:0 0 24px 0;font-size:16px;color:#374151;line-height:1.7;">
<tr> Здравствуйте, <strong style="color:#111827;">{{ user_full_name }}</strong>!
<td style="padding-bottom: 24px;"> </p>
<p style="margin: 0; font-size: 16px; color: #424242; line-height: 1.6;"> <p style="margin:0 0 24px 0;font-size:16px;color:#374151;line-height:1.7;">
Здравствуйте, <strong style="color: #212121;">{{ user_full_name }}</strong>! Ваш ментор добавил вас на платформу <strong style="color:#7444FD;">Училл</strong>. Для начала работы установите пароль для вашего аккаунта.
</p> </p>
</td>
</tr>
<!-- Main message --> <!-- Email info -->
<tr> <table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background:#F5F0FF;border-left:4px solid #7444FD;border-radius:8px;margin-bottom:32px;">
<td style="padding-bottom: 24px;"> <tr><td style="padding:16px 20px;">
<p style="margin: 0; font-size: 16px; color: #424242; line-height: 1.6;"> <p style="margin:0 0 4px 0;font-size:12px;font-weight:600;color:#7444FD;text-transform:uppercase;letter-spacing:0.5px;">Ваш email для входа</p>
Вас добавили на Uchill. Для начала работы необходимо установить пароль для вашего аккаунта. <p style="margin:0;font-size:15px;font-weight:600;color:#111827;">{{ user_email }}</p>
</p> </td></tr>
</td>
</tr>
<!-- Info box -->
<tr>
<td style="padding-bottom: 24px;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background-color: #F5F5F5; border-left: 4px solid #7444FD; border-radius: 4px;">
<tr>
<td style="padding: 16px;">
<p style="margin: 0 0 8px 0; font-size: 14px; font-weight: 500; color: #212121;">
Ваш email для входа:
</p>
<p style="margin: 0; font-size: 14px; color: #757575;">
{{ user_email }}
</p>
</td>
</tr>
</table> </table>
</td>
</tr>
<!-- Button --> <!-- Button -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" align="center" style="margin:0 auto 32px;">
<tr> <tr>
<td style="padding-top: 8px; padding-bottom: 24px; text-align: center;"> <td style="background:linear-gradient(135deg,#7444FD,#9B6FFF);border-radius:10px;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" align="center"> <a href="{{ set_password_url }}" style="display:inline-block;padding:16px 40px;font-size:16px;font-weight:600;color:#ffffff;text-decoration:none;border-radius:10px;letter-spacing:0.2px;">
<tr>
<td style="background-color: #7444FD; border-radius: 4px;">
<a href="{{ set_password_url }}" style="display: inline-block; padding: 14px 32px; font-size: 16px; font-weight: 500; color: #FFFFFF; text-decoration: none; border-radius: 4px;">
Установить пароль Установить пароль
</a> </a>
</td> </td>
</tr> </tr>
</table> </table>
</td>
</tr>
<!-- Link fallback --> <!-- Link fallback -->
<tr> <table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background:#F9FAFB;border:1px solid #E5E7EB;border-radius:8px;margin-bottom:24px;">
<td style="padding-bottom: 24px;"> <tr><td style="padding:16px;">
<p style="margin: 0 0 8px 0; font-size: 14px; color: #757575;"> <p style="margin:0 0 6px 0;font-size:12px;font-weight:600;color:#6B7280;text-transform:uppercase;letter-spacing:0.5px;">Или скопируйте ссылку:</p>
Или скопируйте и вставьте эту ссылку в браузер: <p style="margin:0;font-size:12px;color:#9CA3AF;word-break:break-all;line-height:1.6;">{{ set_password_url }}</p>
</p> </td></tr>
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background-color: #F5F5F5; border-radius: 4px;">
<tr>
<td style="padding: 12px;">
<p style="margin: 0; font-size: 12px; color: #9E9E9E; word-break: break-all; line-height: 1.5;">
{{ set_password_url }}
</p>
</td>
</tr>
</table> </table>
</td>
</tr>
<!-- Warning box --> <!-- Warning -->
<tr> <table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background:#FFFBEB;border-left:4px solid #F59E0B;border-radius:8px;">
<td style="padding-bottom: 24px;"> <tr><td style="padding:14px 16px;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background-color: #FFF3E0; border-left: 4px solid #FF9800; border-radius: 4px;"> <p style="margin:0;font-size:13px;color:#92400E;line-height:1.6;">
<tr> <strong>Важно:</strong> ссылка действительна в течение 7 дней.
<td style="padding: 16px;">
<p style="margin: 0; font-size: 14px; color: #E65100;">
<strong>⚠️ Важно:</strong> Ссылка действительна в течение 7 дней.
</p> </p>
</td> </td></tr>
</tr>
</table>
</td>
</tr>
</table> </table>
</td> </td>
</tr> </tr>
<!-- Footer --> <!-- Footer -->
<tr> <tr>
<td style="padding: 30px 40px; background-color: #F5F5F5; border-radius: 0 0 8px 8px; border-top: 1px solid #E0E0E0;"> <td style="background:#F8F9FA;border-radius:0 0 16px 16px;padding:28px 40px;border-top:1px solid #EAECF0;text-align:center;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%"> <p style="margin:0 0 8px 0;font-size:14px;color:#6B7280;">С уважением, <strong style="color:#7444FD;">Команда Училл</strong></p>
<tr> <p style="margin:0;font-size:12px;color:#9CA3AF;">© {% now "Y" %} Училл. Все права защищены.</p>
<td style="text-align: center; font-size: 14px; color: #757575; line-height: 1.6;">
<p style="margin: 0 0 10px 0;">С уважением,<br><strong style="color: #7444FD;">Команда Uchill</strong></p>
<p style="margin: 10px 0 0 0; font-size: 12px; color: #9E9E9E;">
© 2026 Uchill. Все права защищены.
</p>
</td> </td>
</tr> </tr>
</table> </table>
</td> </td></tr>
</tr>
</table>
</td>
</tr>
</table> </table>
</body> </body>
</html> </html>

View File

@ -4,128 +4,84 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta http-equiv="X-UA-Compatible" content="IE=edge">
<title>Подтверждение email - Uchill</title> <title>Подтверждение email — Училл</title>
<!--[if mso]> <!--[if mso]><style>body,table,td{font-family:Arial,sans-serif!important;}</style><![endif]-->
<style type="text/css">
body, table, td {font-family: Arial, sans-serif !important;}
</style>
<![endif]-->
</head> </head>
<body style="margin: 0; padding: 0; background-color: #FAFAFA; font-family: 'Roboto', -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif;"> <body style="margin:0;padding:0;background-color:#F4F6F8;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif;">
<!-- Wrapper table --> <table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background-color:#F4F6F8;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background-color: #FAFAFA;"> <tr><td align="center" style="padding:40px 16px;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="580" style="max-width:580px;width:100%;">
<!-- Header -->
<tr> <tr>
<td align="center" style="padding: 40px 20px;"> <td style="background:linear-gradient(135deg,#7444FD 0%,#9B6FFF 100%);border-radius:16px 16px 0 0;padding:40px;text-align:center;">
<!-- Main content table --> <span style="font-size:32px;font-weight:800;color:#ffffff;letter-spacing:-0.5px;font-family:Arial,sans-serif;">Училл</span><br>
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="600" style="max-width: 600px; background-color: #FFFFFF; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.1);"> <span style="font-size:13px;color:rgba(255,255,255,0.75);letter-spacing:1px;text-transform:uppercase;margin-top:4px;display:inline-block;">Платформа для обучения</span>
<!-- Header with logo -->
<tr>
<td style="padding: 40px 40px 30px 40px; text-align: center; background-color: #FFFFFF; border-radius: 8px 8px 0 0;">
<!-- Стилизованный текстовый логотип uchill -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" align="center">
<tr>
<td>
<span style="display: inline-block; color: #7444FD; letter-spacing: 1px; font-size: 48px; font-weight: 600; line-height: 1.2;">
<span style="background-color: #7444FD; color: #ffffff; border-radius: 2px; margin-right: 2px; font-weight: 600; font-size: 48px; display: inline-block; vertical-align: baseline; line-height: 1; padding: 2px 8px; letter-spacing: 0;">u</span><span style="vertical-align: baseline;">chill</span>
</span>
</td> </td>
</tr> </tr>
<!-- Body -->
<tr>
<td style="background:#ffffff;padding:48px 40px 40px;">
<!-- Icon -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" align="center" style="margin-bottom:24px;">
<tr><td style="background:#EEE8FF;border-radius:50%;width:72px;height:72px;text-align:center;vertical-align:middle;">
<span style="font-size:36px;line-height:72px;">✉️</span>
</td></tr>
</table> </table>
</td>
</tr>
<!-- Content --> <h1 style="margin:0 0 8px 0;font-size:26px;font-weight:700;color:#111827;text-align:center;">Подтвердите ваш email</h1>
<tr> <p style="margin:0 0 32px 0;font-size:15px;color:#6B7280;text-align:center;line-height:1.6;">Осталось всего один шаг!</p>
<td style="padding: 0 40px 40px 40px;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<!-- Title -->
<tr>
<td style="padding-bottom: 24px;">
<h1 style="margin: 0; font-size: 28px; font-weight: 500; color: #212121; line-height: 1.2;">Подтверждение email</h1>
</td>
</tr>
<!-- Greeting --> <p style="margin:0 0 16px 0;font-size:16px;color:#374151;line-height:1.7;">
<tr> Здравствуйте, <strong style="color:#111827;">{{ user_full_name }}</strong>!
<td style="padding-bottom: 24px;">
<p style="margin: 0; font-size: 16px; color: #424242; line-height: 1.6;">
Здравствуйте, <strong style="color: #212121;">{{ user_full_name }}</strong>!
</p> </p>
</td> <p style="margin:0 0 32px 0;font-size:16px;color:#374151;line-height:1.7;">
</tr> Спасибо за регистрацию на <strong style="color:#7444FD;">Училл</strong>. Нажмите на кнопку ниже, чтобы подтвердить ваш адрес электронной почты и активировать аккаунт.
<!-- Main message -->
<tr>
<td style="padding-bottom: 24px;">
<p style="margin: 0; font-size: 16px; color: #424242; line-height: 1.6;">
Спасибо за регистрацию на Uchill. Для завершения регистрации необходимо подтвердить ваш email адрес.
</p> </p>
</td>
</tr>
<!-- Button --> <!-- Button -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" align="center" style="margin:0 auto 32px;">
<tr> <tr>
<td style="padding-top: 8px; padding-bottom: 24px; text-align: center;"> <td style="background:linear-gradient(135deg,#7444FD,#9B6FFF);border-radius:10px;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" align="center"> <a href="{{ verification_url }}" style="display:inline-block;padding:16px 40px;font-size:16px;font-weight:600;color:#ffffff;text-decoration:none;border-radius:10px;letter-spacing:0.2px;">
<tr>
<td style="background-color: #7444FD; border-radius: 4px;">
<a href="{{ verification_url }}" style="display: inline-block; padding: 14px 32px; font-size: 16px; font-weight: 500; color: #FFFFFF; text-decoration: none; border-radius: 4px;">
Подтвердить email Подтвердить email
</a> </a>
</td> </td>
</tr> </tr>
</table> </table>
</td>
</tr>
<!-- Link fallback --> <!-- Link fallback -->
<tr> <table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background:#F9FAFB;border:1px solid #E5E7EB;border-radius:8px;margin-bottom:32px;">
<td style="padding-bottom: 24px;"> <tr><td style="padding:16px;">
<p style="margin: 0 0 8px 0; font-size: 14px; color: #757575;"> <p style="margin:0 0 6px 0;font-size:12px;font-weight:600;color:#6B7280;text-transform:uppercase;letter-spacing:0.5px;">Или скопируйте ссылку:</p>
Или скопируйте и вставьте эту ссылку в браузер: <p style="margin:0;font-size:12px;color:#9CA3AF;word-break:break-all;line-height:1.6;">{{ verification_url }}</p>
</p> </td></tr>
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background-color: #F5F5F5; border-radius: 4px;">
<tr>
<td style="padding: 12px;">
<p style="margin: 0; font-size: 12px; color: #9E9E9E; word-break: break-all; line-height: 1.5;">
{{ verification_url }}
</p>
</td>
</tr>
</table> </table>
</td>
</tr>
<!-- Security notice --> <!-- Notice -->
<tr> <table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background:#F0FDF4;border-left:4px solid #22C55E;border-radius:8px;">
<td style="padding-top: 24px; border-top: 1px solid #E0E0E0;"> <tr><td style="padding:14px 16px;">
<p style="margin: 0; font-size: 12px; color: #9E9E9E; line-height: 1.6;"> <p style="margin:0;font-size:13px;color:#166534;line-height:1.6;">
Если вы не регистрировались на нашей платформе, просто проигнорируйте это письмо. Если вы не регистрировались на Училл — просто проигнорируйте это письмо.
</p> </p>
</td> </td></tr>
</tr>
</table> </table>
</td> </td>
</tr> </tr>
<!-- Footer --> <!-- Footer -->
<tr> <tr>
<td style="padding: 30px 40px; background-color: #F5F5F5; border-radius: 0 0 8px 8px; border-top: 1px solid #E0E0E0;"> <td style="background:#F8F9FA;border-radius:0 0 16px 16px;padding:28px 40px;border-top:1px solid #EAECF0;text-align:center;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%"> <p style="margin:0 0 8px 0;font-size:14px;color:#6B7280;">С уважением, <strong style="color:#7444FD;">Команда Училл</strong></p>
<tr> <p style="margin:0;font-size:12px;color:#9CA3AF;">© {% now "Y" %} Училл. Все права защищены.</p>
<td style="text-align: center; font-size: 14px; color: #757575; line-height: 1.6;">
<p style="margin: 0 0 10px 0;">С уважением,<br><strong style="color: #7444FD;">Команда Uchill</strong></p>
<p style="margin: 10px 0 0 0; font-size: 12px; color: #9E9E9E;">
© 2026 Uchill. Все права защищены.
</p>
</td> </td>
</tr> </tr>
</table> </table>
</td> </td></tr>
</tr>
</table>
</td>
</tr>
</table> </table>
</body> </body>
</html> </html>

View File

@ -4,128 +4,106 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta http-equiv="X-UA-Compatible" content="IE=edge">
<title>Добро пожаловать на Uchill</title> <title>Добро пожаловать на Училл</title>
<!--[if mso]> <!--[if mso]><style>body,table,td{font-family:Arial,sans-serif!important;}</style><![endif]-->
<style type="text/css">
body, table, td {font-family: Arial, sans-serif !important;}
</style>
<![endif]-->
</head> </head>
<body style="margin: 0; padding: 0; background-color: #FAFAFA; font-family: 'Roboto', -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif;"> <body style="margin:0;padding:0;background-color:#F4F6F8;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif;">
<!-- Wrapper table --> <table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background-color:#F4F6F8;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background-color: #FAFAFA;"> <tr><td align="center" style="padding:40px 16px;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="580" style="max-width:580px;width:100%;">
<!-- Header -->
<tr> <tr>
<td align="center" style="padding: 40px 20px;"> <td style="background:linear-gradient(135deg,#7444FD 0%,#9B6FFF 100%);border-radius:16px 16px 0 0;padding:40px;text-align:center;">
<!-- Main content table --> <span style="font-size:32px;font-weight:800;color:#ffffff;letter-spacing:-0.5px;font-family:Arial,sans-serif;">Училл</span><br>
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="600" style="max-width: 600px; background-color: #FFFFFF; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.1);"> <span style="font-size:13px;color:rgba(255,255,255,0.75);letter-spacing:1px;text-transform:uppercase;margin-top:4px;display:inline-block;">Платформа для обучения</span>
<!-- Header with logo --> </td>
</tr>
<!-- Body -->
<tr> <tr>
<td style="padding: 40px 40px 30px 40px; text-align: center; background-color: #FFFFFF; border-radius: 8px 8px 0 0;"> <td style="background:#ffffff;padding:48px 40px 40px;">
<!-- Стилизованный текстовый логотип uchill -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" align="center"> <!-- Icon -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" align="center" style="margin-bottom:24px;">
<tr><td style="background:#ECFDF5;border-radius:50%;width:72px;height:72px;text-align:center;vertical-align:middle;">
<span style="font-size:36px;line-height:72px;">🚀</span>
</td></tr>
</table>
<h1 style="margin:0 0 8px 0;font-size:26px;font-weight:700;color:#111827;text-align:center;">Добро пожаловать на Училл!</h1>
<p style="margin:0 0 32px 0;font-size:15px;color:#6B7280;text-align:center;line-height:1.6;">Ваш аккаунт успешно создан</p>
<p style="margin:0 0 16px 0;font-size:16px;color:#374151;line-height:1.7;">
Здравствуйте, <strong style="color:#111827;">{{ user_full_name }}</strong>!
</p>
<p style="margin:0 0 24px 0;font-size:16px;color:#374151;line-height:1.7;">
Вы успешно зарегистрировались на платформе <strong style="color:#7444FD;">Училл</strong>. Теперь у вас есть доступ ко всем возможностям для обучения.
</p>
<!-- Email info -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background:#F5F0FF;border-left:4px solid #7444FD;border-radius:8px;margin-bottom:32px;">
<tr><td style="padding:16px 20px;">
<p style="margin:0 0 4px 0;font-size:12px;font-weight:600;color:#7444FD;text-transform:uppercase;letter-spacing:0.5px;">Ваш email для входа</p>
<p style="margin:0;font-size:15px;font-weight:600;color:#111827;">{{ user_email }}</p>
</td></tr>
</table>
<!-- Features -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="margin-bottom:32px;">
<tr>
<td style="padding:0 0 12px 0;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background:#F9FAFB;border-radius:8px;">
<tr>
<td style="padding:14px 16px;font-size:14px;color:#374151;">📅 &nbsp;Онлайн-расписание занятий</td>
</tr>
</table>
</td>
</tr>
<tr>
<td style="padding:0 0 12px 0;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background:#F9FAFB;border-radius:8px;">
<tr>
<td style="padding:14px 16px;font-size:14px;color:#374151;">📹 &nbsp;Видеозвонки с интерактивной доской</td>
</tr>
</table>
</td>
</tr>
<tr> <tr>
<td> <td>
<span style="display: inline-block; color: #7444FD; letter-spacing: 1px; font-size: 48px; font-weight: 600; line-height: 1.2;"> <table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background:#F9FAFB;border-radius:8px;">
<span style="background-color: #7444FD; color: #ffffff; border-radius: 2px; margin-right: 2px; font-weight: 600; font-size: 48px; display: inline-block; vertical-align: baseline; line-height: 1; padding: 2px 8px; letter-spacing: 0;">u</span><span style="vertical-align: baseline;">chill</span> <tr>
</span> <td style="padding:14px 16px;font-size:14px;color:#374151;">📚 &nbsp;Домашние задания и материалы</td>
</td>
</tr> </tr>
</table> </table>
</td> </td>
</tr> </tr>
<!-- Content -->
<tr>
<td style="padding: 0 40px 40px 40px;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<!-- Title -->
<tr>
<td style="padding-bottom: 24px;">
<h1 style="margin: 0; font-size: 28px; font-weight: 500; color: #212121; line-height: 1.2;">Добро пожаловать!</h1>
</td>
</tr>
<!-- Greeting -->
<tr>
<td style="padding-bottom: 24px;">
<p style="margin: 0; font-size: 16px; color: #424242; line-height: 1.6;">
Здравствуйте, <strong style="color: #212121;">{{ user_full_name }}</strong>!
</p>
</td>
</tr>
<!-- Main message -->
<tr>
<td style="padding-bottom: 24px;">
<p style="margin: 0; font-size: 16px; color: #424242; line-height: 1.6;">
Добро пожаловать на Uchill! Ваш аккаунт успешно создан.
</p>
</td>
</tr>
<!-- Info box -->
<tr>
<td style="padding-bottom: 24px;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background-color: #F5F5F5; border-left: 4px solid #7444FD; border-radius: 4px;">
<tr>
<td style="padding: 16px;">
<p style="margin: 0 0 8px 0; font-size: 14px; font-weight: 500; color: #212121;">
Ваш email для входа:
</p>
<p style="margin: 0; font-size: 14px; color: #757575;">
{{ user_email }}
</p>
</td>
</tr>
</table> </table>
</td>
</tr>
<!-- CTA -->
<tr>
<td style="padding-bottom: 24px;">
<p style="margin: 0; font-size: 16px; color: #424242; line-height: 1.6;">
Теперь вы можете войти в систему и начать пользоваться всеми возможностями платформы.
</p>
</td>
</tr>
<!-- Button --> <!-- Button -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" align="center" style="margin:0 auto;">
<tr> <tr>
<td style="padding-top: 8px; padding-bottom: 24px;"> <td style="background:linear-gradient(135deg,#7444FD,#9B6FFF);border-radius:10px;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0"> <a href="{{ login_url }}" style="display:inline-block;padding:16px 40px;font-size:16px;font-weight:600;color:#ffffff;text-decoration:none;border-radius:10px;letter-spacing:0.2px;">
<tr> Войти в Училл
<td style="background-color: #7444FD; border-radius: 4px;">
<a href="https://app.uchill.online/login" style="display: inline-block; padding: 14px 32px; font-size: 16px; font-weight: 500; color: #FFFFFF; text-decoration: none; border-radius: 4px;">
Войти в систему
</a> </a>
</td> </td>
</tr> </tr>
</table> </table>
</td>
</tr>
</table>
</td> </td>
</tr> </tr>
<!-- Footer --> <!-- Footer -->
<tr> <tr>
<td style="padding: 30px 40px; background-color: #F5F5F5; border-radius: 0 0 8px 8px; border-top: 1px solid #E0E0E0;"> <td style="background:#F8F9FA;border-radius:0 0 16px 16px;padding:28px 40px;border-top:1px solid #EAECF0;text-align:center;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%"> <p style="margin:0 0 8px 0;font-size:14px;color:#6B7280;">С уважением, <strong style="color:#7444FD;">Команда Училл</strong></p>
<tr> <p style="margin:0;font-size:12px;color:#9CA3AF;">© {% now "Y" %} Училл. Все права защищены.</p>
<td style="text-align: center; font-size: 14px; color: #757575; line-height: 1.6;">
<p style="margin: 0 0 10px 0;">С уважением,<br><strong style="color: #7444FD;">Команда Uchill</strong></p>
<p style="margin: 10px 0 0 0; font-size: 12px; color: #9E9E9E;">
© 2026 Uchill. Все права защищены.
</p>
</td> </td>
</tr> </tr>
</table> </table>
</td> </td></tr>
</tr>
</table>
</td>
</tr>
</table> </table>
</body> </body>
</html> </html>

View File

@ -32,6 +32,7 @@ from .profile_views import (
ClientManagementViewSet, ClientManagementViewSet,
ParentManagementViewSet, ParentManagementViewSet,
InvitationViewSet, InvitationViewSet,
StudentMentorViewSet,
) )
from .mentorship_views import MentorshipRequestViewSet from .mentorship_views import MentorshipRequestViewSet
from .student_progress_views import StudentProgressViewSet from .student_progress_views import StudentProgressViewSet
@ -53,6 +54,7 @@ router.register(r'parent', ParentDashboardViewSet, basename='parent-dashboard')
router.register(r'profile', ProfileViewSet, basename='profile') router.register(r'profile', ProfileViewSet, basename='profile')
router.register(r'manage/clients', ClientManagementViewSet, basename='manage-clients') router.register(r'manage/clients', ClientManagementViewSet, basename='manage-clients')
router.register(r'invitation', InvitationViewSet, basename='invitation') router.register(r'invitation', InvitationViewSet, basename='invitation')
router.register(r'student/mentors', StudentMentorViewSet, basename='student-mentors')
router.register(r'mentorship-requests', MentorshipRequestViewSet, basename='mentorship-request') router.register(r'mentorship-requests', MentorshipRequestViewSet, basename='mentorship-request')
router.register(r'manage/parents', ParentManagementViewSet, basename='manage-parents') router.register(r'manage/parents', ParentManagementViewSet, basename='manage-parents')

View File

@ -46,6 +46,7 @@ class TelegramBotInfoView(generics.GenericAPIView):
GET /api/auth/telegram/bot-info/ GET /api/auth/telegram/bot-info/
""" """
permission_classes = [AllowAny] permission_classes = [AllowAny]
authentication_classes = []
def get(self, request, *args, **kwargs): def get(self, request, *args, **kwargs):
"""Получение имени бота для использования в Telegram Login Widget.""" """Получение имени бота для использования в Telegram Login Widget."""
@ -74,6 +75,7 @@ class TelegramAuthView(generics.GenericAPIView):
""" """
serializer_class = TelegramAuthSerializer serializer_class = TelegramAuthSerializer
permission_classes = [AllowAny] permission_classes = [AllowAny]
authentication_classes = []
throttle_classes = [BurstRateThrottle] throttle_classes = [BurstRateThrottle]
def post(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):
@ -155,6 +157,7 @@ class RegisterView(generics.CreateAPIView):
queryset = User.objects.all() queryset = User.objects.all()
serializer_class = RegisterSerializer serializer_class = RegisterSerializer
permission_classes = [AllowAny] permission_classes = [AllowAny]
authentication_classes = []
throttle_classes = [BurstRateThrottle] throttle_classes = [BurstRateThrottle]
def create(self, request, *args, **kwargs): def create(self, request, *args, **kwargs):
@ -199,6 +202,7 @@ class LoginView(generics.GenericAPIView):
""" """
serializer_class = LoginSerializer serializer_class = LoginSerializer
permission_classes = [AllowAny] permission_classes = [AllowAny]
authentication_classes = []
throttle_classes = [BurstRateThrottle] throttle_classes = [BurstRateThrottle]
def post(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):
@ -244,6 +248,7 @@ class LoginByTokenView(generics.GenericAPIView):
POST /api/auth/login-by-token/ POST /api/auth/login-by-token/
""" """
permission_classes = [AllowAny] permission_classes = [AllowAny]
authentication_classes = []
throttle_classes = [BurstRateThrottle] throttle_classes = [BurstRateThrottle]
def post(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):
@ -353,6 +358,7 @@ class PasswordResetRequestView(generics.GenericAPIView):
""" """
serializer_class = PasswordResetRequestSerializer serializer_class = PasswordResetRequestSerializer
permission_classes = [AllowAny] permission_classes = [AllowAny]
authentication_classes = []
throttle_classes = [BurstRateThrottle] throttle_classes = [BurstRateThrottle]
def post(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):
@ -390,6 +396,7 @@ class PasswordResetConfirmView(generics.GenericAPIView):
""" """
serializer_class = PasswordResetConfirmSerializer serializer_class = PasswordResetConfirmSerializer
permission_classes = [AllowAny] permission_classes = [AllowAny]
authentication_classes = []
def post(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):
"""Подтверждение восстановления пароля.""" """Подтверждение восстановления пароля."""
@ -426,6 +433,7 @@ class EmailVerificationView(generics.GenericAPIView):
""" """
serializer_class = EmailVerificationSerializer serializer_class = EmailVerificationSerializer
permission_classes = [AllowAny] permission_classes = [AllowAny]
authentication_classes = []
def post(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):
"""Подтверждение email пользователя.""" """Подтверждение email пользователя."""
@ -464,6 +472,7 @@ class ResendVerificationEmailView(generics.GenericAPIView):
Можно использовать с авторизацией или без (передавая email) Можно использовать с авторизацией или без (передавая email)
""" """
permission_classes = [AllowAny] permission_classes = [AllowAny]
authentication_classes = []
def post(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):
"""Повторная отправка письма подтверждения email.""" """Повторная отправка письма подтверждения email."""
@ -806,8 +815,8 @@ class GroupViewSet(viewsets.ModelViewSet):
distinct=True distinct=True
) )
).only( ).only(
'id', 'name', 'description', 'mentor_id', 'max_students', 'id', 'name', 'description', 'mentor_id',
'is_active', 'created_at', 'updated_at' 'created_at'
) )
return queryset return queryset

View File

@ -159,6 +159,16 @@ app.conf.beat_schedule = {
'task': 'apps.materials.tasks.cleanup_old_unused_materials', 'task': 'apps.materials.tasks.cleanup_old_unused_materials',
'schedule': crontab(day_of_week=0, hour=3, minute=0), # Воскресенье в 3:00 'schedule': crontab(day_of_week=0, hour=3, minute=0), # Воскресенье в 3:00
}, },
# ============================================
# РЕФЕРАЛЬНАЯ СИСТЕМА
# ============================================
# Обработка отложенных реферальных бонусов каждый день в 6:00
'process-pending-referral-bonuses': {
'task': 'apps.referrals.tasks.process_pending_referral_bonuses',
'schedule': crontab(hour=6, minute=0),
},
} }
@app.task(bind=True, ignore_result=True) @app.task(bind=True, ignore_result=True)

View File

@ -253,8 +253,11 @@ services:
context: ./front_minimal context: ./front_minimal
dockerfile: Dockerfile dockerfile: Dockerfile
args: args:
- NEXT_PUBLIC_SERVER_URL=${NEXT_PUBLIC_API_URL} - VITE_SERVER_URL=${NEXT_PUBLIC_API_URL}
- NEXT_PUBLIC_ASSET_URL=${NEXT_PUBLIC_API_URL} - VITE_API_URL=${NEXT_PUBLIC_API_URL}
- VITE_WS_URL=${NEXT_PUBLIC_WS_URL}
- VITE_LIVEKIT_URL=${NEXT_PUBLIC_LIVEKIT_URL}
- VITE_EXCALIDRAW_PATH=${NEXT_PUBLIC_EXCALIDRAW_PATH}
container_name: ${COMPOSE_PROJECT_NAME}_front_minimal container_name: ${COMPOSE_PROJECT_NAME}_front_minimal
restart: unless-stopped restart: unless-stopped
volumes: volumes:
@ -263,7 +266,7 @@ services:
env_file: .env env_file: .env
environment: environment:
- NODE_ENV=${NODE_ENV:-development} - NODE_ENV=${NODE_ENV:-development}
- HOSTNAME=0.0.0.0 - HOST=0.0.0.0
ports: ports:
- "${FRONTEND_MINIMAL_PORT:-3005}:3000" - "${FRONTEND_MINIMAL_PORT:-3005}:3000"
networks: networks:
@ -284,11 +287,13 @@ services:
build: build:
context: ./excalidraw-server context: ./excalidraw-server
dockerfile: Dockerfile dockerfile: Dockerfile
args:
- NEXT_PUBLIC_BASE_PATH=/devboard
container_name: ${COMPOSE_PROJECT_NAME}_excalidraw container_name: ${COMPOSE_PROJECT_NAME}_excalidraw
restart: unless-stopped restart: unless-stopped
environment: environment:
- NODE_ENV=${NODE_ENV:-production} - NODE_ENV=${NODE_ENV:-production}
- NEXT_PUBLIC_BASE_PATH= - NEXT_PUBLIC_BASE_PATH=/devboard
ports: ports:
- "${EXCALIDRAW_PORT:-3001}:3001" - "${EXCALIDRAW_PORT:-3001}:3001"
networks: networks:

View File

@ -16,7 +16,9 @@ RUN npx patch-package
# Копируем исходный код # Копируем исходный код
COPY . . COPY . .
# Собираем приложение # Собираем приложение (NEXT_PUBLIC_* переменные нужны на этапе сборки)
ARG NEXT_PUBLIC_BASE_PATH=
ENV NEXT_PUBLIC_BASE_PATH=$NEXT_PUBLIC_BASE_PATH
ENV NODE_ENV=production ENV NODE_ENV=production
RUN npm run build RUN npm run build

View File

@ -1,34 +1,32 @@
# Development stage для front_minimal (отдельный от front_material)
FROM node:20-alpine AS development FROM node:20-alpine AS development
WORKDIR /app WORKDIR /app
# front_minimal использует NEXT_PUBLIC_SERVER_URL, NEXT_PUBLIC_ASSET_URL ARG VITE_SERVER_URL
ARG NEXT_PUBLIC_SERVER_URL ARG VITE_API_URL
ARG NEXT_PUBLIC_ASSET_URL ARG VITE_WS_URL
ARG NEXT_PUBLIC_BASE_PATH ARG VITE_LIVEKIT_URL
ARG BUILD_STATIC_EXPORT=false ARG VITE_EXCALIDRAW_PATH
ENV NEXT_PUBLIC_SERVER_URL=${NEXT_PUBLIC_SERVER_URL} ENV VITE_SERVER_URL=${VITE_SERVER_URL}
ENV NEXT_PUBLIC_ASSET_URL=${NEXT_PUBLIC_ASSET_URL} ENV VITE_API_URL=${VITE_API_URL}
ENV NEXT_PUBLIC_BASE_PATH=${NEXT_PUBLIC_BASE_PATH:-} ENV VITE_WS_URL=${VITE_WS_URL}
ENV BUILD_STATIC_EXPORT=${BUILD_STATIC_EXPORT} ENV VITE_LIVEKIT_URL=${VITE_LIVEKIT_URL}
ENV VITE_EXCALIDRAW_PATH=${VITE_EXCALIDRAW_PATH}
ENV NODE_ENV=development ENV NODE_ENV=development
ENV HOSTNAME=0.0.0.0 ENV HOST=0.0.0.0
ENV WATCHPACK_POLLING=true
ENV CHOKIDAR_USEPOLLING=true ENV CHOKIDAR_USEPOLLING=true
# front_minimal: есть и package-lock.json и yarn.lock, используем npm
COPY package*.json ./ COPY package*.json ./
RUN npm install RUN npm install
COPY . . COPY . .
# Entrypoint: при volume-монтировании проверяем node_modules # Entrypoint: при volume-монтировании проверяем node_modules, затем запускаем vite
RUN echo '#!/bin/sh' > /entrypoint.sh && \ RUN echo '#!/bin/sh' > /entrypoint.sh && \
echo 'set -e' >> /entrypoint.sh && \ echo 'set -e' >> /entrypoint.sh && \
echo 'if [ ! -d node_modules/next ] 2>/dev/null || [ ! -f node_modules/.package-lock.json ] 2>/dev/null; then npm install; fi' >> /entrypoint.sh && \ echo 'if [ ! -d node_modules/vite ] 2>/dev/null; then npm install; fi' >> /entrypoint.sh && \
echo 'exec npx next dev -p 3000 --hostname 0.0.0.0' >> /entrypoint.sh && \ echo 'exec npx vite --port 3000 --host 0.0.0.0' >> /entrypoint.sh && \
chmod +x /entrypoint.sh chmod +x /entrypoint.sh
EXPOSE 3000 EXPOSE 3000

13
front_minimal/index.html Normal file
View File

@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/png" href="/logo/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Училл</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

View File

@ -29,6 +29,9 @@
"@fullcalendar/timeline": "^6.1.14", "@fullcalendar/timeline": "^6.1.14",
"@hookform/resolvers": "^3.6.0", "@hookform/resolvers": "^3.6.0",
"@iconify/react": "^5.0.1", "@iconify/react": "^5.0.1",
"@livekit/components-core": "^0.12.13",
"@livekit/components-react": "^2.9.20",
"@livekit/components-styles": "^1.2.0",
"@mui/lab": "^5.0.0-alpha.170", "@mui/lab": "^5.0.0-alpha.170",
"@mui/material": "^5.15.20", "@mui/material": "^5.15.20",
"@mui/material-nextjs": "^5.15.11", "@mui/material-nextjs": "^5.15.11",
@ -51,6 +54,7 @@
"autosuggest-highlight": "^3.3.4", "autosuggest-highlight": "^3.3.4",
"aws-amplify": "^6.3.6", "aws-amplify": "^6.3.6",
"axios": "^1.7.2", "axios": "^1.7.2",
"date-fns": "^3.6.0",
"dayjs": "^1.11.11", "dayjs": "^1.11.11",
"embla-carousel": "^8.1.5", "embla-carousel": "^8.1.5",
"embla-carousel-auto-height": "^8.1.5", "embla-carousel-auto-height": "^8.1.5",
@ -62,6 +66,7 @@
"i18next": "^23.11.5", "i18next": "^23.11.5",
"i18next-browser-languagedetector": "^8.0.0", "i18next-browser-languagedetector": "^8.0.0",
"i18next-resources-to-backend": "^1.2.1", "i18next-resources-to-backend": "^1.2.1",
"livekit-client": "^2.17.2",
"lowlight": "^3.1.0", "lowlight": "^3.1.0",
"mapbox-gl": "^3.4.0", "mapbox-gl": "^3.4.0",
"mui-one-time-password-input": "^2.0.2", "mui-one-time-password-input": "^2.0.2",
@ -3239,6 +3244,12 @@
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/@bufbuild/protobuf": {
"version": "1.10.1",
"resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-1.10.1.tgz",
"integrity": "sha512-wJ8ReQbHxsAfXhrf9ixl0aYbZorRuOWpBNzm8pL8ftmSxQx/wnJD5Eg861NwJU/czy2VXFIebCeZnZrI9rktIQ==",
"license": "(Apache-2.0 AND BSD-3-Clause)"
},
"node_modules/@dnd-kit/accessibility": { "node_modules/@dnd-kit/accessibility": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.0.tgz", "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.0.tgz",
@ -4076,20 +4087,22 @@
"integrity": "sha512-zuWxyfXNbsKbm96HhXzainONPFqRcoZblQ++e9cAIGUuHfl2cFSBzW01jtesqWG/lqaUyX3H8O1y9oWboGNQBA==" "integrity": "sha512-zuWxyfXNbsKbm96HhXzainONPFqRcoZblQ++e9cAIGUuHfl2cFSBzW01jtesqWG/lqaUyX3H8O1y9oWboGNQBA=="
}, },
"node_modules/@floating-ui/core": { "node_modules/@floating-ui/core": {
"version": "1.6.0", "version": "1.7.5",
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.6.0.tgz", "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz",
"integrity": "sha512-PcF++MykgmTj3CIyOQbKA/hDzOAiqI3mhuoN44WRCopIs1sgoDoU4oty4Jtqaj/y3oDU6fnVSm4QG0a3t5i0+g==", "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==",
"license": "MIT",
"dependencies": { "dependencies": {
"@floating-ui/utils": "^0.2.1" "@floating-ui/utils": "^0.2.11"
} }
}, },
"node_modules/@floating-ui/dom": { "node_modules/@floating-ui/dom": {
"version": "1.6.1", "version": "1.7.4",
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.6.1.tgz", "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz",
"integrity": "sha512-iA8qE43/H5iGozC3W0YSnVSW42Vh522yyM1gj+BqRwVsTNOyr231PsXDaV04yT39PsO0QL2QpbI/M0ZaLUQgRQ==", "integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==",
"license": "MIT",
"dependencies": { "dependencies": {
"@floating-ui/core": "^1.6.0", "@floating-ui/core": "^1.7.3",
"@floating-ui/utils": "^0.2.1" "@floating-ui/utils": "^0.2.10"
} }
}, },
"node_modules/@floating-ui/react-dom": { "node_modules/@floating-ui/react-dom": {
@ -4105,9 +4118,10 @@
} }
}, },
"node_modules/@floating-ui/utils": { "node_modules/@floating-ui/utils": {
"version": "0.2.1", "version": "0.2.11",
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.1.tgz", "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz",
"integrity": "sha512-9TANp6GPoMtYzQdt54kfAyMmz1+osLlXdg2ENroU7zzrtflTLrrC/lgrIfaSe+Wu0b89GKccT7vxXA0MoAIO+Q==" "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==",
"license": "MIT"
}, },
"node_modules/@fontsource/barlow": { "node_modules/@fontsource/barlow": {
"version": "5.0.13", "version": "5.0.13",
@ -4382,6 +4396,76 @@
"@jridgewell/sourcemap-codec": "^1.4.14" "@jridgewell/sourcemap-codec": "^1.4.14"
} }
}, },
"node_modules/@livekit/components-core": {
"version": "0.12.13",
"resolved": "https://registry.npmjs.org/@livekit/components-core/-/components-core-0.12.13.tgz",
"integrity": "sha512-DQmi84afHoHjZ62wm8y+XPNIDHTwFHAltjd3lmyXj8UZHOY7wcza4vFt1xnghJOD5wLRY58L1dkAgAw59MgWvw==",
"license": "Apache-2.0",
"dependencies": {
"@floating-ui/dom": "1.7.4",
"loglevel": "1.9.1",
"rxjs": "7.8.2"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"livekit-client": "^2.17.2",
"tslib": "^2.6.2"
}
},
"node_modules/@livekit/components-react": {
"version": "2.9.20",
"resolved": "https://registry.npmjs.org/@livekit/components-react/-/components-react-2.9.20.tgz",
"integrity": "sha512-hjkYOsJj9Jbghb7wM5cI8HoVisKeL6Zcy1VnRWTLm0sqVbto8GJp/17T4Udx85mCPY6Jgh8I1Cv0yVzgz7CQtg==",
"license": "Apache-2.0",
"dependencies": {
"@livekit/components-core": "0.12.13",
"clsx": "2.1.1",
"events": "^3.3.0",
"jose": "^6.0.12",
"usehooks-ts": "3.1.1"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@livekit/krisp-noise-filter": "^0.2.12 || ^0.3.0",
"livekit-client": "^2.17.2",
"react": ">=18",
"react-dom": ">=18",
"tslib": "^2.6.2"
},
"peerDependenciesMeta": {
"@livekit/krisp-noise-filter": {
"optional": true
}
}
},
"node_modules/@livekit/components-styles": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@livekit/components-styles/-/components-styles-1.2.0.tgz",
"integrity": "sha512-74/rt0lDh6aHmOPmWAeDE9C4OrNW9RIdmhX/YRbovQBVNGNVWojRjl3FgQZ5LPFXO6l1maKB4JhXcBFENVxVvw==",
"license": "Apache-2.0",
"engines": {
"node": ">=18"
}
},
"node_modules/@livekit/mutex": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@livekit/mutex/-/mutex-1.1.1.tgz",
"integrity": "sha512-EsshAucklmpuUAfkABPxJNhzj9v2sG7JuzFDL4ML1oJQSV14sqrpTYnsaOudMAw9yOaW53NU3QQTlUQoRs4czw==",
"license": "Apache-2.0"
},
"node_modules/@livekit/protocol": {
"version": "1.44.0",
"resolved": "https://registry.npmjs.org/@livekit/protocol/-/protocol-1.44.0.tgz",
"integrity": "sha512-/vfhDUGcUKO8Q43r6i+5FrDhl5oZjm/X3U4x2Iciqvgn5C8qbj+57YPcWSJ1kyIZm5Cm6AV2nAPjMm3ETD/iyg==",
"license": "Apache-2.0",
"dependencies": {
"@bufbuild/protobuf": "^1.10.0"
}
},
"node_modules/@mapbox/jsonlint-lines-primitives": { "node_modules/@mapbox/jsonlint-lines-primitives": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.2.tgz", "resolved": "https://registry.npmjs.org/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.2.tgz",
@ -6740,6 +6824,13 @@
"@types/ms": "*" "@types/ms": "*"
} }
}, },
"node_modules/@types/dom-mediacapture-record": {
"version": "1.0.22",
"resolved": "https://registry.npmjs.org/@types/dom-mediacapture-record/-/dom-mediacapture-record-1.0.22.tgz",
"integrity": "sha512-mUMZLK3NvwRLcAAT9qmcK+9p7tpU2FHdDsntR3YI4+GY88XrgG4XiE7u1Q2LAN2/FZOz/tdMDC3GQCR4T8nFuw==",
"license": "MIT",
"peer": true
},
"node_modules/@types/geojson": { "node_modules/@types/geojson": {
"version": "7946.0.13", "version": "7946.0.13",
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.13.tgz", "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.13.tgz",
@ -7994,6 +8085,16 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/date-fns": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz",
"integrity": "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/kossnocorp"
}
},
"node_modules/dayjs": { "node_modules/dayjs": {
"version": "1.11.11", "version": "1.11.11",
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.11.tgz", "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.11.tgz",
@ -10610,6 +10711,15 @@
"restructure": "^3.0.0" "restructure": "^3.0.0"
} }
}, },
"node_modules/jose": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.0.tgz",
"integrity": "sha512-xsfE1TcSCbUdo6U07tR0mvhg0flGxU8tPLbF03mirl2ukGQENhUg4ubGYQnhVH0b5stLlPM+WOqDkEl1R1y5sQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/js-cookie": { "node_modules/js-cookie": {
"version": "3.0.5", "version": "3.0.5",
"resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz", "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz",
@ -10779,6 +10889,40 @@
"resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.1.3.tgz", "resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.1.3.tgz",
"integrity": "sha512-auMesunaJ8yfkHvK4gfg1K0SaKX/6Wn9g2Aac/NwX+l5VdmFZzo/hdPGxEOETj+ryRa4/fiOPjeeKURSAJx1sg==" "integrity": "sha512-auMesunaJ8yfkHvK4gfg1K0SaKX/6Wn9g2Aac/NwX+l5VdmFZzo/hdPGxEOETj+ryRa4/fiOPjeeKURSAJx1sg=="
}, },
"node_modules/livekit-client": {
"version": "2.17.2",
"resolved": "https://registry.npmjs.org/livekit-client/-/livekit-client-2.17.2.tgz",
"integrity": "sha512-+67y2EtAWZabARlY7kANl/VT1Uu1EJYR5a8qwpT2ub/uBCltsEgEDOxCIMwE9HFR5w+z41HR6GL9hyEvW/y6CQ==",
"license": "Apache-2.0",
"dependencies": {
"@livekit/mutex": "1.1.1",
"@livekit/protocol": "1.44.0",
"events": "^3.3.0",
"jose": "^6.1.0",
"loglevel": "^1.9.2",
"sdp-transform": "^2.15.0",
"ts-debounce": "^4.0.0",
"tslib": "2.8.1",
"typed-emitter": "^2.1.0",
"webrtc-adapter": "^9.0.1"
},
"peerDependencies": {
"@types/dom-mediacapture-record": "^1"
}
},
"node_modules/livekit-client/node_modules/loglevel": {
"version": "1.9.2",
"resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.9.2.tgz",
"integrity": "sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==",
"license": "MIT",
"engines": {
"node": ">= 0.6.0"
},
"funding": {
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/loglevel"
}
},
"node_modules/locate-path": { "node_modules/locate-path": {
"version": "6.0.0", "version": "6.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
@ -10830,6 +10974,19 @@
"resolved": "https://registry.npmjs.org/lodash.throttle/-/lodash.throttle-4.1.1.tgz", "resolved": "https://registry.npmjs.org/lodash.throttle/-/lodash.throttle-4.1.1.tgz",
"integrity": "sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ==" "integrity": "sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ=="
}, },
"node_modules/loglevel": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.9.1.tgz",
"integrity": "sha512-hP3I3kCrDIMuRwAwHltphhDM1r8i55H33GgqjXbrisuJhF4kRhW1dNuxsRklp4bXl8DSdLaNLuiL4A/LWRfxvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6.0"
},
"funding": {
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/loglevel"
}
},
"node_modules/long": { "node_modules/long": {
"version": "5.2.3", "version": "5.2.3",
"resolved": "https://registry.npmjs.org/long/-/long-5.2.3.tgz", "resolved": "https://registry.npmjs.org/long/-/long-5.2.3.tgz",
@ -13237,9 +13394,10 @@
"integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==" "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ=="
}, },
"node_modules/rxjs": { "node_modules/rxjs": {
"version": "7.8.1", "version": "7.8.2",
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
"integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==", "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
"license": "Apache-2.0",
"dependencies": { "dependencies": {
"tslib": "^2.1.0" "tslib": "^2.1.0"
} }
@ -13323,6 +13481,21 @@
"resolved": "https://registry.npmjs.org/scrollparent/-/scrollparent-2.1.0.tgz", "resolved": "https://registry.npmjs.org/scrollparent/-/scrollparent-2.1.0.tgz",
"integrity": "sha512-bnnvJL28/Rtz/kz2+4wpBjHzWoEzXhVg/TE8BeVGJHUqE8THNIRnDxDWMktwM+qahvlRdvlLdsQfYe+cuqfZeA==" "integrity": "sha512-bnnvJL28/Rtz/kz2+4wpBjHzWoEzXhVg/TE8BeVGJHUqE8THNIRnDxDWMktwM+qahvlRdvlLdsQfYe+cuqfZeA=="
}, },
"node_modules/sdp": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/sdp/-/sdp-3.2.1.tgz",
"integrity": "sha512-lwsAIzOPlH8/7IIjjz3K0zYBk7aBVVcvjMwt3M4fLxpjMYyy7i3I97SLHebgn4YBjirkzfp3RvRDWSKsh/+WFw==",
"license": "MIT"
},
"node_modules/sdp-transform": {
"version": "2.15.0",
"resolved": "https://registry.npmjs.org/sdp-transform/-/sdp-transform-2.15.0.tgz",
"integrity": "sha512-KrOH82c/W+GYQ0LHqtr3caRpM3ITglq3ljGUIb8LTki7ByacJZ9z+piSGiwZDsRyhQbYBOBJgr2k6X4BZXi3Kw==",
"license": "MIT",
"bin": {
"sdp-verify": "checker.js"
}
},
"node_modules/semver": { "node_modules/semver": {
"version": "6.3.1", "version": "6.3.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
@ -14044,6 +14217,12 @@
"typescript": ">=4.2.0" "typescript": ">=4.2.0"
} }
}, },
"node_modules/ts-debounce": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/ts-debounce/-/ts-debounce-4.0.0.tgz",
"integrity": "sha512-+1iDGY6NmOGidq7i7xZGA4cm8DAa6fqdYcvO5Z6yBevH++Bdo9Qt/mN0TzHUgcCcKv1gmh9+W5dHqz8pMWbCbg==",
"license": "MIT"
},
"node_modules/tsconfig-paths": { "node_modules/tsconfig-paths": {
"version": "3.15.0", "version": "3.15.0",
"resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz",
@ -14069,9 +14248,10 @@
} }
}, },
"node_modules/tslib": { "node_modules/tslib": {
"version": "2.6.2", "version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==" "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
}, },
"node_modules/turndown": { "node_modules/turndown": {
"version": "7.2.0", "version": "7.2.0",
@ -14185,6 +14365,15 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/typed-emitter": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/typed-emitter/-/typed-emitter-2.1.0.tgz",
"integrity": "sha512-g/KzbYKbH5C2vPkaXGu8DJlHrGKHLsM25Zg9WuC9pMGfuvT+X25tZQWo5fK1BjBm8+UrVE9LDCvaY0CQk+fXDA==",
"license": "MIT",
"optionalDependencies": {
"rxjs": "*"
}
},
"node_modules/typescript": { "node_modules/typescript": {
"version": "5.4.5", "version": "5.4.5",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.5.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.5.tgz",
@ -14473,6 +14662,21 @@
"react": "^16.8.0 || ^17.0.0 || ^18.0.0" "react": "^16.8.0 || ^17.0.0 || ^18.0.0"
} }
}, },
"node_modules/usehooks-ts": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/usehooks-ts/-/usehooks-ts-3.1.1.tgz",
"integrity": "sha512-I4diPp9Cq6ieSUH2wu+fDAVQO43xwtulo+fKEidHUwZPnYImbtkTjzIJYcDcJqxgmX31GVqNFURodvcgHcW0pA==",
"license": "MIT",
"dependencies": {
"lodash.debounce": "^4.0.8"
},
"engines": {
"node": ">=16.15.0"
},
"peerDependencies": {
"react": "^16.8.0 || ^17 || ^18 || ^19 || ^19.0.0-rc"
}
},
"node_modules/util-deprecate": { "node_modules/util-deprecate": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
@ -14580,6 +14784,19 @@
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==" "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="
}, },
"node_modules/webrtc-adapter": {
"version": "9.0.4",
"resolved": "https://registry.npmjs.org/webrtc-adapter/-/webrtc-adapter-9.0.4.tgz",
"integrity": "sha512-5ZZY1+lGq8LEKuDlg9M2RPJHlH3R7OVwyHqMcUsLKCgd9Wvf+QrFTCItkXXYPmrJn8H6gRLXbSgxLLdexiqHxw==",
"license": "BSD-3-Clause",
"dependencies": {
"sdp": "^3.2.0"
},
"engines": {
"node": ">=6.0.0",
"npm": ">=3.10.0"
}
},
"node_modules/websocket-driver": { "node_modules/websocket-driver": {
"version": "0.7.4", "version": "0.7.4",
"resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.4.tgz", "resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.4.tgz",

View File

@ -1,22 +1,20 @@
{ {
"name": "@minimal-kit/next-js", "name": "platform-frontend",
"author": "Minimals", "author": "Platform",
"version": "6.0.1", "version": "1.0.0",
"description": "Next & JavaScript", "description": "Platform frontend — Vite + React",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev -p 3032", "dev": "vite --port 3032",
"start": "next start -p 3032", "build": "vite build",
"build": "next build", "preview": "vite preview --port 3032",
"lint": "eslint \"src/**/*.{js,jsx,ts,tsx}\"", "lint": "eslint \"src/**/*.{js,jsx,ts,tsx}\"",
"lint:fix": "eslint --fix \"src/**/*.{js,jsx,ts,tsx}\"", "lint:fix": "eslint --fix \"src/**/*.{js,jsx,ts,tsx}\"",
"fm:check": "prettier --check \"src/**/*.{js,jsx,ts,tsx}\"", "fm:check": "prettier --check \"src/**/*.{js,jsx,ts,tsx}\"",
"fm:fix": "prettier --write \"src/**/*.{js,jsx,ts,tsx}\"", "fm:fix": "prettier --write \"src/**/*.{js,jsx,ts,tsx}\"",
"rm:all": "rm -rf node_modules .next out dist build", "rm:all": "rm -rf node_modules dist .vite",
"re:start": "yarn rm:all && yarn install && yarn dev", "re:start": "yarn rm:all && yarn install && yarn dev",
"re:build": "yarn rm:all && yarn install && yarn build", "re:build": "yarn rm:all && yarn install && yarn build"
"re:build-npm": "npm run rm:all && npm install && npm run build",
"start:out": "npx serve@latest out -p 3032"
}, },
"dependencies": { "dependencies": {
"@auth0/auth0-react": "^2.2.4", "@auth0/auth0-react": "^2.2.4",
@ -40,9 +38,11 @@
"@fullcalendar/timeline": "^6.1.14", "@fullcalendar/timeline": "^6.1.14",
"@hookform/resolvers": "^3.6.0", "@hookform/resolvers": "^3.6.0",
"@iconify/react": "^5.0.1", "@iconify/react": "^5.0.1",
"@livekit/components-core": "^0.12.13",
"@livekit/components-react": "^2.9.20",
"@livekit/components-styles": "^1.2.0",
"@mui/lab": "^5.0.0-alpha.170", "@mui/lab": "^5.0.0-alpha.170",
"@mui/material": "^5.15.20", "@mui/material": "^5.15.20",
"@mui/material-nextjs": "^5.15.11",
"@mui/x-data-grid": "^7.7.0", "@mui/x-data-grid": "^7.7.0",
"@mui/x-date-pickers": "^7.7.0", "@mui/x-date-pickers": "^7.7.0",
"@mui/x-tree-view": "^7.7.0", "@mui/x-tree-view": "^7.7.0",
@ -62,6 +62,7 @@
"autosuggest-highlight": "^3.3.4", "autosuggest-highlight": "^3.3.4",
"aws-amplify": "^6.3.6", "aws-amplify": "^6.3.6",
"axios": "^1.7.2", "axios": "^1.7.2",
"date-fns": "^3.6.0",
"dayjs": "^1.11.11", "dayjs": "^1.11.11",
"embla-carousel": "^8.1.5", "embla-carousel": "^8.1.5",
"embla-carousel-auto-height": "^8.1.5", "embla-carousel-auto-height": "^8.1.5",
@ -73,10 +74,10 @@
"i18next": "^23.11.5", "i18next": "^23.11.5",
"i18next-browser-languagedetector": "^8.0.0", "i18next-browser-languagedetector": "^8.0.0",
"i18next-resources-to-backend": "^1.2.1", "i18next-resources-to-backend": "^1.2.1",
"livekit-client": "^2.17.2",
"lowlight": "^3.1.0", "lowlight": "^3.1.0",
"mapbox-gl": "^3.4.0", "mapbox-gl": "^3.4.0",
"mui-one-time-password-input": "^2.0.2", "mui-one-time-password-input": "^2.0.2",
"next": "^14.2.4",
"nprogress": "^0.2.0", "nprogress": "^0.2.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-apexcharts": "^1.4.1", "react-apexcharts": "^1.4.1",
@ -103,8 +104,9 @@
"zod": "^3.23.8" "zod": "^3.23.8"
}, },
"devDependencies": { "devDependencies": {
"@svgr/webpack": "^8.1.0",
"@types/react": "^18.3.3", "@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.7.0",
"eslint": "^8.57.0", "eslint": "^8.57.0",
"eslint-config-airbnb": "^19.0.4", "eslint-config-airbnb": "^19.0.4",
"eslint-config-prettier": "^9.1.0", "eslint-config-prettier": "^9.1.0",
@ -117,6 +119,9 @@
"eslint-plugin-react-hooks": "^4.6.2", "eslint-plugin-react-hooks": "^4.6.2",
"eslint-plugin-unused-imports": "^3.2.0", "eslint-plugin-unused-imports": "^3.2.0",
"prettier": "^3.3.2", "prettier": "^3.3.2",
"typescript": "^5.4.5" "react-router-dom": "^6.30.3",
"typescript": "^5.4.5",
"vite": "^5.4.21",
"vite-plugin-svgr": "^4.5.0"
} }
} }

View File

@ -1,47 +1,86 @@
import { useMemo } from 'react'; import { useMemo, useState, useEffect } from 'react';
import { format, startOfMonth, endOfMonth, addMonths, subMonths } from 'date-fns';
import useSWR, { mutate } from 'swr'; import useSWR, { mutate } from 'swr';
import { getCalendarLessons, getMentorStudents, getMentorSubjects, createCalendarLesson } from 'src/utils/dashboard-api';
import {
getCalendarLessons,
getMentorStudents,
getMentorSubjects,
createCalendarLesson,
updateCalendarLesson,
deleteCalendarLesson,
} from 'src/utils/dashboard-api';
import { getGroups } from 'src/utils/groups-api';
import { useAuthContext } from 'src/auth/hooks';
// ---------------------------------------------------------------------- // ----------------------------------------------------------------------
const CALENDAR_ENDPOINT = '/schedule/lessons/calendar/';
const STUDENTS_ENDPOINT = '/manage/clients/?page=1&page_size=200'; const STUDENTS_ENDPOINT = '/manage/clients/?page=1&page_size=200';
const SUBJECTS_ENDPOINT = '/schedule/subjects/'; const SUBJECTS_ENDPOINT = '/schedule/subjects/';
const GROUPS_ENDPOINT = '/groups/';
const swrOptions = { const swrOptions = {
revalidateIfStale: true, revalidateIfStale: true,
revalidateOnFocus: true, revalidateOnFocus: false,
revalidateOnReconnect: true, revalidateOnReconnect: true,
}; };
// Ключ кэша для календаря (по месяцу)
function calendarKey(date = new Date()) {
const start = format(startOfMonth(subMonths(date, 1)), 'yyyy-MM-dd');
const end = format(endOfMonth(addMonths(date, 1)), 'yyyy-MM-dd');
return ['calendar', start, end];
}
// ---------------------------------------------------------------------- // ----------------------------------------------------------------------
export function useGetEvents() { export function useGetEvents(currentDate) {
const startDate = '2026-02-01'; const date = currentDate || new Date();
const endDate = '2026-04-30'; const start = format(startOfMonth(subMonths(date, 1)), 'yyyy-MM-dd');
const end = format(endOfMonth(addMonths(date, 1)), 'yyyy-MM-dd');
const { user } = useAuthContext();
const getChildId = () => {
if (user?.role !== 'parent') return null;
try { const s = localStorage.getItem('selected_child'); return s ? (JSON.parse(s)?.id || null) : null; } catch { return null; }
};
const [childId, setChildId] = useState(getChildId);
useEffect(() => {
if (user?.role !== 'parent') return undefined;
const handler = () => setChildId(getChildId());
window.addEventListener('child-changed', handler);
return () => window.removeEventListener('child-changed', handler);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [user?.role]);
const { data: response, isLoading, error, isValidating } = useSWR( const { data: response, isLoading, error, isValidating } = useSWR(
[CALENDAR_ENDPOINT, startDate, endDate], ['calendar', start, end, childId],
([url, start, end]) => getCalendarLessons(start, end), ([, s, e, cid]) => getCalendarLessons(s, e, cid ? { child_id: cid } : undefined),
swrOptions swrOptions
); );
const memoizedValue = useMemo(() => { const memoizedValue = useMemo(() => {
const lessonsArray = response?.data?.lessons || []; const lessonsArray = response?.data?.lessons || response?.lessons || [];
const events = lessonsArray.map((lesson) => { const events = lessonsArray.map((lesson) => {
const start = lesson.start_time || lesson.start; const start = lesson.start_time || lesson.start;
const end = lesson.end_time || lesson.end || start; const end = lesson.end_time || lesson.end || start;
const startTimeStr = start ? new Date(start).toLocaleTimeString('ru-RU', { const startTimeStr = start
? new Date(start).toLocaleTimeString('ru-RU', {
hour: '2-digit', hour: '2-digit',
minute: '2-digit', minute: '2-digit',
hourCycle: 'h23' hourCycle: 'h23',
}) : ''; })
: '';
const subject = lesson.subject_name || lesson.subject || 'Урок'; const subject = lesson.subject_name || lesson.subject || 'Урок';
const student = lesson.client_name || ''; const participant = lesson.group_name
const displayTitle = `${startTimeStr} ${subject}${student ? ` - ${student}` : ''}`; ? `Группа: ${lesson.group_name}`
: (lesson.client_name || '');
const displayTitle = `${startTimeStr} ${subject}${participant ? ` - ${participant}` : ''}`;
const status = String(lesson.status || 'scheduled').toLowerCase(); const status = String(lesson.status || 'scheduled').toLowerCase();
let eventColor = '#7635dc'; let eventColor = '#7635dc';
@ -64,6 +103,8 @@ export function useGetEvents() {
status, status,
student: lesson.client_name || '', student: lesson.client_name || '',
mentor: lesson.mentor_name || '', mentor: lesson.mentor_name || '',
group: lesson.group || null,
group_name: lesson.group_name || '',
}, },
}; };
}); });
@ -83,14 +124,16 @@ export function useGetEvents() {
// ---------------------------------------------------------------------- // ----------------------------------------------------------------------
export function useGetStudents() { export function useGetStudents() {
const { data: response, isLoading, error } = useSWR(STUDENTS_ENDPOINT, getMentorStudents, swrOptions); const { data: response, isLoading, error } = useSWR(
STUDENTS_ENDPOINT,
getMentorStudents,
swrOptions
);
return useMemo(() => { return useMemo(() => {
const rawData = response?.data?.results || response?.results || response || []; const rawData = response?.data?.results || response?.results || response || [];
const studentsArray = Array.isArray(rawData) ? rawData : [];
return { return {
students: studentsArray, students: Array.isArray(rawData) ? rawData : [],
studentsLoading: isLoading, studentsLoading: isLoading,
studentsError: error, studentsError: error,
}; };
@ -98,41 +141,72 @@ export function useGetStudents() {
} }
export function useGetSubjects() { export function useGetSubjects() {
const { data: response, isLoading, error } = useSWR(SUBJECTS_ENDPOINT, getMentorSubjects, swrOptions); const { data: response, isLoading, error } = useSWR(
SUBJECTS_ENDPOINT,
getMentorSubjects,
swrOptions
);
return useMemo(() => { return useMemo(() => {
const rawData = response?.data || response?.results || response || []; const rawData = response?.data || response?.results || response || [];
const subjectsArray = Array.isArray(rawData) ? rawData : [];
return { return {
subjects: subjectsArray, subjects: Array.isArray(rawData) ? rawData : [],
subjectsLoading: isLoading, subjectsLoading: isLoading,
subjectsError: error, subjectsError: error,
}; };
}, [response, isLoading, error]); }, [response, isLoading, error]);
} }
// ---------------------------------------------------------------------- export function useGetGroups() {
const { data, isLoading, error } = useSWR(GROUPS_ENDPOINT, getGroups, swrOptions);
export async function createEvent(eventData) { return useMemo(() => ({
const payload = { groups: Array.isArray(data) ? data : [],
client: String(eventData.client), groupsLoading: isLoading,
title: eventData.title.replace(' - ', ' — '), groupsError: error,
description: eventData.description, }), [data, isLoading, error]);
start_time: eventData.start_time,
duration: eventData.duration,
price: eventData.price,
is_recurring: eventData.is_recurring,
subject_id: Number(eventData.subject),
};
const response = await createCalendarLesson(payload);
// Обновляем кэш, чтобы занятия появлялись сразу
mutate([CALENDAR_ENDPOINT, '2026-02-01', '2026-04-30']);
return response;
} }
export async function updateEvent(eventData) { console.log('Update Event:', eventData); } // ----------------------------------------------------------------------
export async function deleteEvent(eventId) { console.log('Delete Event:', eventId); }
function revalidateCalendar() {
mutate((key) => Array.isArray(key) && key[0] === 'calendar', undefined, { revalidate: true });
}
export async function createEvent(eventData) {
const isGroup = !!eventData.group;
const payload = {
title: eventData.title || 'Занятие',
description: eventData.description || '',
start_time: eventData.start_time,
duration: eventData.duration || 60,
price: eventData.price,
is_recurring: eventData.is_recurring || false,
...(eventData.subject && { subject_id: Number(eventData.subject) }),
...(isGroup ? { group: eventData.group } : { client: String(eventData.client) }),
};
const res = await createCalendarLesson(payload);
revalidateCalendar();
return res;
}
export async function updateEvent(eventData, currentDate) {
const { id, ...data } = eventData;
const updatePayload = {};
if (data.start_time) updatePayload.start_time = data.start_time;
if (data.duration) updatePayload.duration = data.duration;
if (data.price != null) updatePayload.price = data.price;
if (data.description != null) updatePayload.description = data.description;
if (data.status) updatePayload.status = data.status;
const res = await updateCalendarLesson(String(id), updatePayload);
revalidateCalendar();
return res;
}
export async function deleteEvent(eventId, deleteAllFuture = false) {
await deleteCalendarLesson(String(eventId), deleteAllFuture);
revalidateCalendar();
}

39
front_minimal/src/app.jsx Normal file
View File

@ -0,0 +1,39 @@
import { BrowserRouter } from 'react-router-dom';
import { LocalizationProvider } from 'src/locales';
import { I18nProvider } from 'src/locales/i18n-provider';
import { ThemeProvider } from 'src/theme/theme-provider';
import { Snackbar } from 'src/components/snackbar';
import { ProgressBar } from 'src/components/progress-bar';
import { MotionLazy } from 'src/components/animate/motion-lazy';
import { SettingsDrawer, defaultSettings, SettingsProvider } from 'src/components/settings';
import { AuthProvider } from 'src/auth/context/jwt';
import { Router } from 'src/routes/sections';
// ----------------------------------------------------------------------
export default function App() {
return (
<BrowserRouter>
<I18nProvider>
<LocalizationProvider>
<AuthProvider>
<SettingsProvider settings={defaultSettings} caches="localStorage">
<ThemeProvider>
<MotionLazy>
<Snackbar />
<ProgressBar />
<SettingsDrawer />
<Router />
</MotionLazy>
</ThemeProvider>
</SettingsProvider>
</AuthProvider>
</LocalizationProvider>
</I18nProvider>
</BrowserRouter>
);
}

View File

@ -0,0 +1,13 @@
import { AuthSplitLayout } from 'src/layouts/auth-split';
import { GuestGuard } from 'src/auth/guard';
// ----------------------------------------------------------------------
export default function Layout({ children }) {
return (
<GuestGuard>
<AuthSplitLayout>{children}</AuthSplitLayout>
</GuestGuard>
);
}

View File

@ -0,0 +1,11 @@
import { CONFIG } from 'src/config-global';
import { JwtForgotPasswordView } from 'src/sections/auth/jwt';
// ----------------------------------------------------------------------
export const metadata = { title: `Forgot password | Jwt - ${CONFIG.site.name}` };
export default function Page() {
return <JwtForgotPasswordView />;
}

View File

@ -0,0 +1,13 @@
import { AuthSplitLayout } from 'src/layouts/auth-split';
import { GuestGuard } from 'src/auth/guard';
// ----------------------------------------------------------------------
export default function Layout({ children }) {
return (
<GuestGuard>
<AuthSplitLayout>{children}</AuthSplitLayout>
</GuestGuard>
);
}

View File

@ -0,0 +1,11 @@
import { CONFIG } from 'src/config-global';
import { JwtResetPasswordView } from 'src/sections/auth/jwt';
// ----------------------------------------------------------------------
export const metadata = { title: `Reset password | Jwt - ${CONFIG.site.name}` };
export default function Page() {
return <JwtResetPasswordView />;
}

View File

@ -7,7 +7,14 @@ import { GuestGuard } from 'src/auth/guard';
export default function Layout({ children }) { export default function Layout({ children }) {
return ( return (
<GuestGuard> <GuestGuard>
<AuthSplitLayout section={{ title: 'Hi, Welcome back' }}>{children}</AuthSplitLayout> <AuthSplitLayout
section={{
title: 'Добро пожаловать',
subtitle: 'Платформа для онлайн-обучения и работы с репетиторами.',
}}
>
{children}
</AuthSplitLayout>
</GuestGuard> </GuestGuard>
); );
} }

View File

@ -0,0 +1,7 @@
import { AuthSplitLayout } from 'src/layouts/auth-split';
// ----------------------------------------------------------------------
export default function Layout({ children }) {
return <AuthSplitLayout>{children}</AuthSplitLayout>;
}

View File

@ -0,0 +1,11 @@
import { CONFIG } from 'src/config-global';
import { JwtVerifyEmailView } from 'src/sections/auth/jwt';
// ----------------------------------------------------------------------
export const metadata = { title: `Verify email | Jwt - ${CONFIG.site.name}` };
export default function Page() {
return <JwtVerifyEmailView />;
}

View File

@ -1,11 +1,11 @@
import { CONFIG } from 'src/config-global'; import { CONFIG } from 'src/config-global';
import { OverviewAnalyticsView } from 'src/sections/overview/analytics/view'; import { AnalyticsView } from 'src/sections/analytics/view';
// ---------------------------------------------------------------------- // ----------------------------------------------------------------------
export const metadata = { title: `Analytics | Dashboard - ${CONFIG.site.name}` }; export const metadata = { title: `Аналитика | ${CONFIG.site.name}` };
export default function Page() { export default function Page() {
return <OverviewAnalyticsView />; return <AnalyticsView />;
} }

View File

@ -0,0 +1,11 @@
import { CONFIG } from 'src/config-global';
import { BoardView } from 'src/sections/board/view';
// ----------------------------------------------------------------------
export const metadata = { title: `Доска | ${CONFIG.site.name}` };
export default function Page() {
return <BoardView />;
}

View File

@ -0,0 +1,11 @@
import { CONFIG } from 'src/config-global';
import { ChatPlatformView } from 'src/sections/chat/view';
// ----------------------------------------------------------------------
export const metadata = { title: `Чат | ${CONFIG.site.name}` };
export default function Page() {
return <ChatPlatformView />;
}

View File

@ -0,0 +1,11 @@
import { CONFIG } from 'src/config-global';
import { ChildrenProgressView } from 'src/sections/children/view';
// ----------------------------------------------------------------------
export const metadata = { title: `Прогресс ребёнка | ${CONFIG.site.name}` };
export default function Page() {
return <ChildrenProgressView />;
}

View File

@ -0,0 +1,11 @@
import { CONFIG } from 'src/config-global';
import { ChildrenView } from 'src/sections/children/view';
// ----------------------------------------------------------------------
export const metadata = { title: `Мои дети | ${CONFIG.site.name}` };
export default function Page() {
return <ChildrenView />;
}

View File

@ -0,0 +1,11 @@
import { CONFIG } from 'src/config-global';
import { FeedbackView } from 'src/sections/feedback/view';
// ----------------------------------------------------------------------
export const metadata = { title: `Обратная связь | ${CONFIG.site.name}` };
export default function Page() {
return <FeedbackView />;
}

View File

@ -0,0 +1,11 @@
import { CONFIG } from 'src/config-global';
import { HomeworkView } from 'src/sections/homework/view';
// ----------------------------------------------------------------------
export const metadata = { title: `Домашние задания | ${CONFIG.site.name}` };
export default function Page() {
return <HomeworkView />;
}

View File

@ -0,0 +1,21 @@
import { CONFIG } from 'src/config-global';
import { LessonDetailView } from 'src/sections/lesson-detail/view';
// ----------------------------------------------------------------------
export const metadata = { title: `Занятие | ${CONFIG.site.name}` };
export default function Page({ params }) {
return <LessonDetailView id={params.id} />;
}
// ----------------------------------------------------------------------
const dynamic = CONFIG.isStaticExport ? 'auto' : 'force-dynamic';
export { dynamic };
export async function generateStaticParams() {
return [];
}

View File

@ -0,0 +1,11 @@
import { CONFIG } from 'src/config-global';
import { MaterialsView } from 'src/sections/materials/view';
// ----------------------------------------------------------------------
export const metadata = { title: `Материалы | ${CONFIG.site.name}` };
export default function Page() {
return <MaterialsView />;
}

View File

@ -0,0 +1,11 @@
import { CONFIG } from 'src/config-global';
import { MyProgressView } from 'src/sections/my-progress/view';
// ----------------------------------------------------------------------
export const metadata = { title: `Мой прогресс | ${CONFIG.site.name}` };
export default function Page() {
return <MyProgressView />;
}

View File

@ -0,0 +1,11 @@
import { CONFIG } from 'src/config-global';
import { NotificationsView } from 'src/sections/notifications/view';
// ----------------------------------------------------------------------
export const metadata = { title: `Уведомления | ${CONFIG.site.name}` };
export default function Page() {
return <NotificationsView />;
}

View File

@ -1,38 +1,130 @@
'use client'; import { useState, useEffect } from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import CircularProgress from '@mui/material/CircularProgress';
import { CONFIG } from 'src/config-global';
import { useAuthContext } from 'src/auth/hooks'; import { useAuthContext } from 'src/auth/hooks';
import axios from 'src/utils/axios';
// Временно импортируем только ментора (позже добавим клиента и родителя)
import { OverviewCourseView } from 'src/sections/overview/course/view'; import { OverviewCourseView } from 'src/sections/overview/course/view';
import { OverviewClientView } from 'src/sections/overview/client/view';
export default function Page() { // ----------------------------------------------------------------------
async function loadChildren() {
try {
const res = await axios.get('/parent/dashboard/');
const raw = res.data?.children ?? [];
return raw.map((item) => {
const c = item.child ?? item;
return { id: c.id, name: c.name || c.email || '' };
});
} catch {
return [];
}
}
// ----------------------------------------------------------------------
export default function DashboardPage() {
const { user, loading } = useAuthContext(); const { user, loading } = useAuthContext();
const [selectedChild, setSelectedChild] = useState(null);
const [childrenLoading, setChildrenLoading] = useState(false);
const [noChildren, setNoChildren] = useState(false);
// Load children for parent role
useEffect(() => {
if (user?.role !== 'parent') return undefined;
setChildrenLoading(true);
loadChildren().then((list) => {
setChildrenLoading(false);
if (!list.length) {
setNoChildren(true);
return;
}
// Try to restore saved child
try {
const saved = localStorage.getItem('selected_child');
if (saved) {
const parsed = JSON.parse(saved);
const exists = list.find((c) => c.id === parsed.id);
if (exists) {
setSelectedChild(parsed);
return;
}
}
} catch { /* ignore */ }
// Auto-select first child
const first = list[0];
localStorage.setItem('selected_child', JSON.stringify(first));
window.dispatchEvent(new Event('child-changed'));
setSelectedChild(first);
});
// React to child switch from nav selector
const handler = () => {
try {
const saved = localStorage.getItem('selected_child');
if (saved) setSelectedChild(JSON.parse(saved));
} catch { /* ignore */ }
};
window.addEventListener('child-changed', handler);
return () => window.removeEventListener('child-changed', handler);
}, [user]);
// ----------------------------------------------------------------------
if (loading) { if (loading) {
return <div>Загрузка...</div>;
}
if (!user) {
return null;
}
// Роутинг по ролям
if (user.role === 'mentor') {
return <OverviewCourseView />;
}
if (user.role === 'client') {
return <div>Дашборд Клиента (в разработке)</div>;
}
if (user.role === 'parent') {
return <div>Дашборд Родителя (в разработке)</div>;
}
return ( return (
<div style={{ padding: '24px', textAlign: 'center' }}> <Box sx={{ display: 'flex', height: '80vh', alignItems: 'center', justifyContent: 'center' }}>
<p>Неизвестная роль пользователя: {user.role}</p> <CircularProgress />
</div> </Box>
); );
} }
if (!user) return null;
if (user.role === 'mentor') return <OverviewCourseView />;
if (user.role === 'client') return <OverviewClientView />;
if (user.role === 'parent') {
if (childrenLoading) {
return (
<Box sx={{ display: 'flex', height: '80vh', alignItems: 'center', justifyContent: 'center' }}>
<CircularProgress />
</Box>
);
}
if (noChildren) {
return (
<Box sx={{ display: 'flex', height: '80vh', alignItems: 'center', justifyContent: 'center', flexDirection: 'column', gap: 1 }}>
<Typography variant="h6" color="text.secondary">Нет привязанных детей</Typography>
<Typography variant="body2" color="text.disabled">
Обратитесь к администратору для привязки аккаунта ребёнка
</Typography>
</Box>
);
}
if (!selectedChild) {
return (
<Box sx={{ display: 'flex', height: '80vh', alignItems: 'center', justifyContent: 'center' }}>
<CircularProgress />
</Box>
);
}
return <OverviewClientView childId={selectedChild.id} childName={selectedChild.name} />;
}
return null;
}

View File

@ -0,0 +1,11 @@
import { CONFIG } from 'src/config-global';
import { PaymentPlatformView } from 'src/sections/payment/view';
// ----------------------------------------------------------------------
export const metadata = { title: `Оплата | ${CONFIG.site.name}` };
export default function Page() {
return <PaymentPlatformView />;
}

View File

@ -1,4 +1,3 @@
'use client';
import Button from '@mui/material/Button'; import Button from '@mui/material/Button';

View File

@ -1,4 +1,3 @@
'use client';
import Button from '@mui/material/Button'; import Button from '@mui/material/Button';

View File

@ -1,4 +1,3 @@
'use client';
import { DashboardContent } from 'src/layouts/dashboard'; import { DashboardContent } from 'src/layouts/dashboard';

View File

@ -0,0 +1,11 @@
import { CONFIG } from 'src/config-global';
import { AccountPlatformView } from 'src/sections/account-platform/view';
// ----------------------------------------------------------------------
export const metadata = { title: `Профиль | ${CONFIG.site.name}` };
export default function Page() {
return <AccountPlatformView />;
}

View File

@ -0,0 +1,11 @@
import { CONFIG } from 'src/config-global';
import { ReferralsView } from 'src/sections/referrals/view';
// ----------------------------------------------------------------------
export const metadata = { title: `Рефералы | ${CONFIG.site.name}` };
export default function Page() {
return <ReferralsView />;
}

View File

@ -0,0 +1,11 @@
import { CONFIG } from 'src/config-global';
import { StudentsView } from 'src/sections/students/view';
// ----------------------------------------------------------------------
export const metadata = { title: `Ученики | ${CONFIG.site.name}` };
export default function Page() {
return <StudentsView />;
}

View File

@ -0,0 +1,10 @@
import { CONFIG } from 'src/config-global';
import { InviteRegisterView } from 'src/sections/invite/view';
// ----------------------------------------------------------------------
export const metadata = { title: `Регистрация по приглашению | ${CONFIG.site.name}` };
export default function Page({ params }) {
return <InviteRegisterView token={params.token} />;
}

View File

@ -1,4 +1,3 @@
'use client';
import Button from '@mui/material/Button'; import Button from '@mui/material/Button';
import Container from '@mui/material/Container'; import Container from '@mui/material/Container';

View File

@ -1,4 +1,3 @@
'use client';
import Button from '@mui/material/Button'; import Button from '@mui/material/Button';
import Container from '@mui/material/Container'; import Container from '@mui/material/Container';

View File

@ -1,4 +1,3 @@
'use client';
import Container from '@mui/material/Container'; import Container from '@mui/material/Container';

View File

@ -0,0 +1,5 @@
// Fullscreen layout no sidebar or header
export default function VideoCallLayout({ children }) {
return children;
}

View File

@ -0,0 +1,11 @@
import { CONFIG } from 'src/config-global';
import { VideoCallView } from 'src/sections/video-call/view';
// ----------------------------------------------------------------------
export const metadata = { title: `Видеозвонок | ${CONFIG.site.name}` };
export default function Page() {
return <VideoCallView />;
}

View File

@ -1,4 +1,3 @@
'use client';
import { import {
signIn as _signIn, signIn as _signIn,

View File

@ -1,4 +1,3 @@
'use client';
import { Amplify } from 'aws-amplify'; import { Amplify } from 'aws-amplify';
import { useMemo, useEffect, useCallback } from 'react'; import { useMemo, useEffect, useCallback } from 'react';

View File

@ -1,4 +1,3 @@
'use client';
import { createContext } from 'react'; import { createContext } from 'react';

View File

@ -1,4 +1,3 @@
'use client';
import { useAuth0, Auth0Provider } from '@auth0/auth0-react'; import { useAuth0, Auth0Provider } from '@auth0/auth0-react';
import { useMemo, useState, useEffect, useCallback } from 'react'; import { useMemo, useState, useEffect, useCallback } from 'react';

View File

@ -1,4 +1,3 @@
'use client';
import { doc, setDoc, collection } from 'firebase/firestore'; import { doc, setDoc, collection } from 'firebase/firestore';
import { import {

View File

@ -1,4 +1,3 @@
'use client';
import { doc, getDoc } from 'firebase/firestore'; import { doc, getDoc } from 'firebase/firestore';
import { onAuthStateChanged } from 'firebase/auth'; import { onAuthStateChanged } from 'firebase/auth';

View File

@ -1,27 +1,25 @@
'use client';
import axios, { endpoints } from 'src/utils/axios'; import axios, { endpoints } from 'src/utils/axios';
import { setSession } from './utils'; import { setSession } from './utils';
import { STORAGE_KEY } from './constant'; import { STORAGE_KEY, REFRESH_STORAGE_KEY } from './constant';
/** ************************************** /** **************************************
* Sign in * Sign in
*************************************** */ *************************************** */
export const signInWithPassword = async ({ email, password }) => { export const signInWithPassword = async ({ email, password }) => {
try { try {
const params = { email, password }; const res = await axios.post(endpoints.auth.signIn, { email, password });
const res = await axios.post(endpoints.auth.signIn, params); const data = res.data?.data;
const accessToken = data?.tokens?.access;
// Адаптация под твой API: { data: { tokens: { access } } } const refreshToken = data?.tokens?.refresh;
const accessToken = res.data?.data?.tokens?.access;
if (!accessToken) { if (!accessToken) {
throw new Error('Access token not found in response'); throw new Error('Access token not found in response');
} }
setSession(accessToken); await setSession(accessToken, refreshToken);
} catch (error) { } catch (error) {
console.error('Error during sign in:', error); console.error('Error during sign in:', error);
throw error; throw error;
@ -31,24 +29,23 @@ export const signInWithPassword = async ({ email, password }) => {
/** ************************************** /** **************************************
* Sign up * Sign up
*************************************** */ *************************************** */
export const signUp = async ({ email, password, firstName, lastName }) => { export const signUp = async ({ email, password, passwordConfirm, firstName, lastName, role, city, timezone }) => {
try {
const params = { const params = {
email, email,
password, password,
firstName, password_confirm: passwordConfirm,
lastName, first_name: firstName,
last_name: lastName,
role: role || 'client',
city: city || '',
timezone: timezone || (typeof Intl !== 'undefined' ? Intl.DateTimeFormat().resolvedOptions().timeZone : 'Europe/Moscow'),
}; };
try { await axios.post(endpoints.auth.signUp, params);
const res = await axios.post(endpoints.auth.signUp, params);
const { accessToken } = res.data; // Всегда требуем подтверждение email перед входом
return { requiresVerification: true };
if (!accessToken) {
throw new Error('Access token not found in response');
}
sessionStorage.setItem(STORAGE_KEY, accessToken);
} catch (error) { } catch (error) {
console.error('Error during sign up:', error); console.error('Error during sign up:', error);
throw error; throw error;
@ -66,3 +63,67 @@ export const signOut = async () => {
throw error; throw error;
} }
}; };
/** **************************************
* Refresh token
*************************************** */
export const refreshAccessToken = async () => {
try {
const refreshToken = localStorage.getItem(REFRESH_STORAGE_KEY);
if (!refreshToken) throw new Error('No refresh token');
const res = await axios.post(endpoints.auth.refresh, { refresh: refreshToken }, {
headers: { Authorization: undefined },
});
const accessToken = res.data?.access;
if (!accessToken) throw new Error('No access token in refresh response');
await setSession(accessToken, refreshToken);
return accessToken;
} catch (error) {
console.error('Error during token refresh:', error);
throw error;
}
};
/** **************************************
* Request password reset
*************************************** */
export const requestPasswordReset = async ({ email }) => {
try {
await axios.post(endpoints.auth.passwordReset, { email });
} catch (error) {
console.error('Error during password reset request:', error);
throw error;
}
};
/** **************************************
* Confirm password reset
*************************************** */
export const confirmPasswordReset = async ({ token, newPassword, newPasswordConfirm }) => {
try {
await axios.post(endpoints.auth.passwordResetConfirm, {
token,
new_password: newPassword,
new_password_confirm: newPasswordConfirm,
});
} catch (error) {
console.error('Error during password reset confirm:', error);
throw error;
}
};
/** **************************************
* Verify email
*************************************** */
export const verifyEmail = async ({ token }) => {
try {
const res = await axios.post(endpoints.auth.verifyEmail, { token });
return res.data;
} catch (error) {
console.error('Error during email verification:', error);
throw error;
}
};

View File

@ -1,11 +1,11 @@
'use client';
import { useMemo, useEffect, useCallback } from 'react'; import { useMemo, useEffect, useCallback } from 'react';
import { useSetState } from 'src/hooks/use-set-state'; import { useSetState } from 'src/hooks/use-set-state';
import axios, { endpoints } from 'src/utils/axios'; import axios, { endpoints } from 'src/utils/axios';
import { STORAGE_KEY } from './constant'; import { STORAGE_KEY, REFRESH_STORAGE_KEY } from './constant';
import { AuthContext } from '../auth-context'; import { AuthContext } from '../auth-context';
import { setSession, isValidToken } from './utils'; import { setSession, isValidToken } from './utils';
import { refreshAccessToken } from './action';
export function AuthProvider({ children }) { export function AuthProvider({ children }) {
const { state, setState } = useSetState({ const { state, setState } = useSetState({
@ -15,25 +15,28 @@ export function AuthProvider({ children }) {
const checkUserSession = useCallback(async () => { const checkUserSession = useCallback(async () => {
try { try {
const accessToken = sessionStorage.getItem(STORAGE_KEY); let accessToken = localStorage.getItem(STORAGE_KEY);
if (accessToken && isValidToken(accessToken)) { if (accessToken && isValidToken(accessToken)) {
setSession(accessToken); setSession(accessToken);
} else {
// Пробуем обновить через refresh token
try {
accessToken = await refreshAccessToken();
} catch {
setState({ user: null, loading: false });
return;
}
}
const res = await axios.get(endpoints.auth.me); const res = await axios.get(endpoints.auth.me);
// Гарантируем получение объекта пользователя из data
const userData = res.data?.data || res.data; const userData = res.data?.data || res.data;
// Если прилетел массив или невалидный объект - сбрасываем
if (!userData || typeof userData !== 'object' || Array.isArray(userData)) { if (!userData || typeof userData !== 'object' || Array.isArray(userData)) {
throw new Error('Invalid user data format'); throw new Error('Invalid user data format');
} }
setState({ user: { ...userData, accessToken }, loading: false }); setState({ user: { ...userData, accessToken }, loading: false });
} else {
setState({ user: null, loading: false });
}
} catch (error) { } catch (error) {
console.error('[Auth Debug]:', error); console.error('[Auth Debug]:', error);
setState({ user: null, loading: false }); setState({ user: null, loading: false });
@ -52,7 +55,7 @@ export function AuthProvider({ children }) {
user: state.user user: state.user
? { ? {
...state.user, ...state.user,
role: state.user?.role ?? 'admin', role: state.user?.role ?? 'client',
} }
: null, : null,
checkUserSession, checkUserSession,

View File

@ -1 +1,2 @@
export const STORAGE_KEY = 'jwt_access_token'; export const STORAGE_KEY = 'jwt_access_token';
export const REFRESH_STORAGE_KEY = 'jwt_refresh_token';

View File

@ -2,7 +2,7 @@ import { paths } from 'src/routes/paths';
import axios from 'src/utils/axios'; import axios from 'src/utils/axios';
import { STORAGE_KEY } from './constant'; import { STORAGE_KEY, REFRESH_STORAGE_KEY } from './constant';
// ---------------------------------------------------------------------- // ----------------------------------------------------------------------
@ -57,26 +57,29 @@ export function tokenExpired(exp) {
setTimeout(() => { setTimeout(() => {
try { try {
alert('Token expired!'); localStorage.removeItem(STORAGE_KEY);
sessionStorage.removeItem(STORAGE_KEY); localStorage.removeItem(REFRESH_STORAGE_KEY);
window.location.href = paths.auth.jwt.signIn; window.location.href = paths.auth.jwt.signIn;
} catch (error) { } catch (error) {
console.error('Error during token expiration:', error); console.error('Error during token expiration:', error);
throw error;
} }
}, timeLeft); }, timeLeft);
} }
// ---------------------------------------------------------------------- // ----------------------------------------------------------------------
export async function setSession(accessToken) { export async function setSession(accessToken, refreshToken) {
try { try {
if (accessToken) { if (accessToken) {
sessionStorage.setItem(STORAGE_KEY, accessToken); localStorage.setItem(STORAGE_KEY, accessToken);
if (refreshToken) {
localStorage.setItem(REFRESH_STORAGE_KEY, refreshToken);
}
axios.defaults.headers.common.Authorization = `Bearer ${accessToken}`; axios.defaults.headers.common.Authorization = `Bearer ${accessToken}`;
const decodedToken = jwtDecode(accessToken); // ~3 days by minimals server const decodedToken = jwtDecode(accessToken);
if (decodedToken && 'exp' in decodedToken) { if (decodedToken && 'exp' in decodedToken) {
tokenExpired(decodedToken.exp); tokenExpired(decodedToken.exp);
@ -84,7 +87,8 @@ export async function setSession(accessToken) {
throw new Error('Invalid access token!'); throw new Error('Invalid access token!');
} }
} else { } else {
sessionStorage.removeItem(STORAGE_KEY); localStorage.removeItem(STORAGE_KEY);
localStorage.removeItem(REFRESH_STORAGE_KEY);
delete axios.defaults.headers.common.Authorization; delete axios.defaults.headers.common.Authorization;
} }
} catch (error) { } catch (error) {

View File

@ -1,4 +1,3 @@
'use client';
import { paths } from 'src/routes/paths'; import { paths } from 'src/routes/paths';

View File

@ -1,4 +1,3 @@
'use client';
import { useMemo, useEffect, useCallback } from 'react'; import { useMemo, useEffect, useCallback } from 'react';

View File

@ -1,4 +1,3 @@
'use client';
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from 'react';
@ -10,6 +9,21 @@ import { CONFIG } from 'src/config-global';
import { SplashScreen } from 'src/components/loading-screen'; import { SplashScreen } from 'src/components/loading-screen';
import { useAuthContext } from '../hooks'; import { useAuthContext } from '../hooks';
import { STORAGE_KEY } from '../context/jwt/constant';
import { isValidToken } from '../context/jwt/utils';
// ----------------------------------------------------------------------
// Synchronously check if there's a valid token in localStorage
// to avoid showing SplashScreen on every load when user is already authenticated
function hasValidStoredToken() {
try {
const token = localStorage.getItem(STORAGE_KEY);
return token ? isValidToken(token) : false;
} catch {
return false;
}
}
// ---------------------------------------------------------------------- // ----------------------------------------------------------------------
@ -22,7 +36,8 @@ export function AuthGuard({ children }) {
const { authenticated, loading } = useAuthContext(); const { authenticated, loading } = useAuthContext();
const [isChecking, setIsChecking] = useState(true); // Skip splash if we already have a valid token avoids flash on every page load
const [isChecking, setIsChecking] = useState(() => !hasValidStoredToken());
const createQueryString = useCallback( const createQueryString = useCallback(
(name, value) => { (name, value) => {
@ -40,18 +55,8 @@ export function AuthGuard({ children }) {
} }
if (!authenticated) { if (!authenticated) {
const { method } = CONFIG.auth; const signInPath = paths.auth.jwt.signIn;
const signInPath = {
jwt: paths.auth.jwt.signIn,
auth0: paths.auth.auth0.signIn,
amplify: paths.auth.amplify.signIn,
firebase: paths.auth.firebase.signIn,
supabase: paths.auth.supabase.signIn,
}[method];
const href = `${signInPath}?${createQueryString('returnTo', pathname)}`; const href = `${signInPath}?${createQueryString('returnTo', pathname)}`;
router.replace(href); router.replace(href);
return; return;
} }

View File

@ -1,4 +1,3 @@
'use client';
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';

View File

@ -1,4 +1,3 @@
'use client';
import { m } from 'framer-motion'; import { m } from 'framer-motion';

View File

@ -0,0 +1,58 @@
import { useEffect, useState } from 'react';
import { useRouter, usePathname } from 'src/routes/hooks';
import { paths } from 'src/routes/paths';
import { useAuthContext } from '../hooks';
import axios from 'src/utils/axios';
// Страницы, доступные без подписки
const EXEMPT_PATHS = [
paths.dashboard.payment,
paths.dashboard.profile,
paths.dashboard.referrals,
paths.dashboard.notifications,
];
// ----------------------------------------------------------------------
export function SubscriptionGuard({ children }) {
const { user, loading } = useAuthContext();
const router = useRouter();
const pathname = usePathname();
const [checked, setChecked] = useState(false);
useEffect(() => {
if (loading) return;
// Только для менторов
if (!user || user.role !== 'mentor') {
setChecked(true);
return;
}
// Страница оплаты и некоторые другие не требуют подписки
if (EXEMPT_PATHS.some((p) => pathname.startsWith(p))) {
setChecked(true);
return;
}
axios
.get('/subscriptions/subscriptions/active/')
.then((res) => {
if (res.data) {
setChecked(true);
} else {
router.replace(paths.dashboard.payment);
}
})
.catch(() => {
router.replace(paths.dashboard.payment);
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [user, loading, pathname]);
if (!checked) return null;
return <>{children}</>;
}

View File

@ -1,4 +1,3 @@
'use client';
import { useContext } from 'react'; import { useContext } from 'react';

View File

@ -1,4 +1,3 @@
'use client';
import { m } from 'framer-motion'; import { m } from 'framer-motion';
import { useRef, useState, useEffect } from 'react'; import { useRef, useState, useEffect } from 'react';

View File

@ -33,7 +33,7 @@ export function AnimateLogo1({ logo, sx, ...other }) {
}} }}
sx={{ display: 'inline-flex' }} sx={{ display: 'inline-flex' }}
> >
{logo ?? <Logo disableLink width={64} height={64} />} {logo ?? <Logo disableLink width={64} height={64} mini />}
</Box> </Box>
<Box <Box

View File

@ -1,4 +1,3 @@
'use client';
import { LazyMotion } from 'framer-motion'; import { LazyMotion } from 'framer-motion';

View File

@ -1,4 +1,3 @@
'use client';
import { useRef, useMemo } from 'react'; import { useRef, useMemo } from 'react';
import { useScroll } from 'framer-motion'; import { useScroll } from 'framer-motion';

View File

@ -1,21 +1,10 @@
import dynamic from 'next/dynamic'; import { lazy, Suspense } from 'react';
import Box from '@mui/material/Box'; import Box from '@mui/material/Box';
import { withLoadingProps } from 'src/utils/with-loading-props';
import { ChartLoading } from './chart-loading'; import { ChartLoading } from './chart-loading';
const ApexChart = withLoadingProps((props) => const ApexChart = lazy(() => import('react-apexcharts').then((mod) => ({ default: mod.default })));
dynamic(() => import('react-apexcharts').then((mod) => mod.default), {
ssr: false,
loading: () => {
const { loading, type } = props();
return loading?.disabled ? null : <ChartLoading type={type} sx={loading?.sx} />;
},
})
);
// ---------------------------------------------------------------------- // ----------------------------------------------------------------------
@ -41,6 +30,13 @@ export function Chart({
...sx, ...sx,
}} }}
{...other} {...other}
>
<Suspense
fallback={
loadingProps?.disabled ? null : (
<ChartLoading type={type} sx={loadingProps?.sx} />
)
}
> >
<ApexChart <ApexChart
type={type} type={type}
@ -48,8 +44,8 @@ export function Chart({
options={options} options={options}
width="100%" width="100%"
height="100%" height="100%"
loading={loadingProps}
/> />
</Suspense>
</Box> </Box>
); );
} }

View File

@ -67,8 +67,9 @@ export function useChart(options) {
animations: { animations: {
enabled: true, enabled: true,
speed: 360, speed: 360,
easing: 'easeinout',
animateGradually: { enabled: true, delay: 120 }, animateGradually: { enabled: true, delay: 120 },
dynamicAnimation: { enabled: true, speed: 360 }, dynamicAnimation: { enabled: true, speed: 400, easing: 'easeinout' },
...options?.chart?.animations, ...options?.chart?.animations,
}, },
}, },

View File

@ -21,6 +21,7 @@ export function CustomPopover({ open, onClose, children, anchorEl, slotProps, ..
open={!!open} open={!!open}
anchorEl={anchorEl} anchorEl={anchorEl}
onClose={onClose} onClose={onClose}
disableScrollLock
anchorOrigin={anchorOrigin} anchorOrigin={anchorOrigin}
transformOrigin={transformOrigin} transformOrigin={transformOrigin}
slotProps={{ slotProps={{

View File

@ -1,4 +1,3 @@
'use client';
import { forwardRef } from 'react'; import { forwardRef } from 'react';
import { Icon, disableCache } from '@iconify/react'; import { Icon, disableCache } from '@iconify/react';

View File

@ -1,4 +1,3 @@
'use client';
import { forwardRef } from 'react'; import { forwardRef } from 'react';

View File

@ -1,4 +1,3 @@
'use client';
import Box from '@mui/material/Box'; import Box from '@mui/material/Box';
import { styled } from '@mui/material/styles'; import { styled } from '@mui/material/styles';

View File

@ -1,4 +1,3 @@
'use client';
import Box from '@mui/material/Box'; import Box from '@mui/material/Box';
import Portal from '@mui/material/Portal'; import Portal from '@mui/material/Portal';

View File

@ -1,4 +1,3 @@
'use client';
import Box from '@mui/material/Box'; import Box from '@mui/material/Box';
import Portal from '@mui/material/Portal'; import Portal from '@mui/material/Portal';

View File

@ -1,106 +1,70 @@
'use client';
import { useId, forwardRef } from 'react'; import { forwardRef } from 'react';
import Box from '@mui/material/Box'; import Box from '@mui/material/Box';
import NoSsr from '@mui/material/NoSsr'; import { Link } from 'react-router-dom';
import { useTheme } from '@mui/material/styles';
import { RouterLink } from 'src/routes/components'; import { CONFIG } from 'src/config-global';
import { logoClasses } from './classes';
// ---------------------------------------------------------------------- // ----------------------------------------------------------------------
export const Logo = forwardRef( /**
({ width = 40, height = 40, disableLink = false, className, href = '/', sx, ...other }, ref) => { * mini=false (default) full logo (logo.svg)
const theme = useTheme(); * mini=true icon only (favicon.png)
const gradientId = useId();
const PRIMARY_LIGHT = theme.vars.palette.primary.light;
const PRIMARY_MAIN = theme.vars.palette.primary.main;
const PRIMARY_DARK = theme.vars.palette.primary.dark;
/*
* OR using local (public folder)
* const logo = ( <Box alt="logo" component="img" src={`${CONFIG.site.basePath}/logo/logo-single.svg`} width={width} height={height} /> );
*/ */
export const Logo = forwardRef(
({ width, height, mini = false, disableLink = false, className, href = '/dashboard', sx, ...other }, ref) => {
const logo = ( const defaultWidth = mini ? 40 : 134;
<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 512 512"> const defaultHeight = mini ? 40 : 40;
<defs>
<linearGradient id={`${gradientId}-1`} x1="100%" x2="50%" y1="9.946%" y2="50%">
<stop offset="0%" stopColor={PRIMARY_DARK} />
<stop offset="100%" stopColor={PRIMARY_MAIN} />
</linearGradient>
<linearGradient id={`${gradientId}-2`} x1="50%" x2="50%" y1="0%" y2="100%"> const w = width ?? defaultWidth;
<stop offset="0%" stopColor={PRIMARY_LIGHT} /> const h = height ?? defaultHeight;
<stop offset="100%" stopColor={PRIMARY_MAIN} />
</linearGradient>
<linearGradient id={`${gradientId}-3`} x1="50%" x2="50%" y1="0%" y2="100%"> const logo = mini ? (
<stop offset="0%" stopColor={PRIMARY_LIGHT} /> <Box
<stop offset="100%" stopColor={PRIMARY_MAIN} /> component="img"
</linearGradient> alt="logo icon"
</defs> src={`${CONFIG.site.basePath}/logo/favicon.png`}
sx={{ width: w, height: h, objectFit: 'contain' }}
<g fill={PRIMARY_MAIN} fillRule="evenodd" stroke="none" strokeWidth="1">
<path
fill={`url(#${`${gradientId}-1`})`}
d="M183.168 285.573l-2.918 5.298-2.973 5.363-2.846 5.095-2.274 4.043-2.186 3.857-2.506 4.383-1.6 2.774-2.294 3.939-1.099 1.869-1.416 2.388-1.025 1.713-1.317 2.18-.95 1.558-1.514 2.447-.866 1.38-.833 1.312-.802 1.246-.77 1.18-.739 1.111-.935 1.38-.664.956-.425.6-.41.572-.59.8-.376.497-.537.69-.171.214c-10.76 13.37-22.496 23.493-36.93 29.334-30.346 14.262-68.07 14.929-97.202-2.704l72.347-124.682 2.8-1.72c49.257-29.326 73.08 1.117 94.02 40.927z"
/> />
<path ) : (
fill={`url(#${`${gradientId}-2`})`} <Box
d="M444.31 229.726c-46.27-80.956-94.1-157.228-149.043-45.344-7.516 14.384-12.995 42.337-25.267 42.337v-.142c-12.272 0-17.75-27.953-25.265-42.337C189.79 72.356 141.96 148.628 95.69 229.584c-3.483 6.106-6.828 11.932-9.69 16.996 106.038-67.127 97.11 135.667 184 137.278V384c86.891-1.611 77.962-204.405 184-137.28-2.86-5.062-6.206-10.888-9.69-16.994" component="img"
alt="logo"
src={`${CONFIG.site.basePath}/logo/logo.svg`}
sx={{ width: w, height: h, objectFit: 'contain' }}
/> />
<path
fill={`url(#${`${gradientId}-3`})`}
d="M450 384c26.509 0 48-21.491 48-48s-21.491-48-48-48-48 21.491-48 48 21.491 48 48 48"
/>
</g>
</svg>
); );
return ( const style = {
<NoSsr
fallback={
<Box
width={width}
height={height}
className={logoClasses.root.concat(className ? ` ${className}` : '')}
sx={{
flexShrink: 0,
display: 'inline-flex',
verticalAlign: 'middle',
...sx,
}}
/>
}
>
<Box
ref={ref}
component={RouterLink}
href={href}
width={width}
height={height}
className={logoClasses.root.concat(className ? ` ${className}` : '')}
aria-label="logo"
sx={{
flexShrink: 0, flexShrink: 0,
display: 'inline-flex', display: 'inline-flex',
verticalAlign: 'middle', verticalAlign: 'middle',
width: w,
height: h,
...(disableLink && { pointerEvents: 'none' }), ...(disableLink && { pointerEvents: 'none' }),
...sx, };
}}
if (disableLink) {
return (
<Box ref={ref} className={className || ''} sx={{ ...style, ...sx }} {...other}>
{logo}
</Box>
);
}
return (
<Link
ref={ref}
to={href}
className={className || ''}
aria-label="logo"
style={{ textDecoration: 'none', ...style }}
{...other} {...other}
> >
{logo} {logo}
</Box> </Link>
</NoSsr>
); );
} }
); );

View File

@ -49,7 +49,7 @@ export function NavList({ data, render, depth, slotProps, enabledRootRedirect })
disabled={data.disabled} disabled={data.disabled}
hasChild={!!data.children} hasChild={!!data.children}
open={data.children && openMenu} open={data.children && openMenu}
externalLink={isExternalLink(data.path)} externalLink={data.externalLink || isExternalLink(data.path)}
enabledRootRedirect={enabledRootRedirect} enabledRootRedirect={enabledRootRedirect}
// styles // styles
slotProps={depth === 1 ? slotProps?.rootItem : slotProps?.subItem} slotProps={depth === 1 ? slotProps?.rootItem : slotProps?.subItem}

View File

@ -1,5 +1,4 @@
import dynamic from 'next/dynamic'; import { lazy, Suspense, cloneElement } from 'react';
import { cloneElement } from 'react';
import { useTheme } from '@mui/material/styles'; import { useTheme } from '@mui/material/styles';
@ -7,13 +6,13 @@ import { flattenArray } from 'src/utils/helper';
// ---------------------------------------------------------------------- // ----------------------------------------------------------------------
const Tree = dynamic(() => import('react-organizational-chart').then((mod) => mod.Tree), { const Tree = lazy(() =>
ssr: false, import('react-organizational-chart').then((mod) => ({ default: mod.Tree }))
}); );
const TreeNode = dynamic(() => import('react-organizational-chart').then((mod) => mod.TreeNode), { const TreeNode = lazy(() =>
ssr: false, import('react-organizational-chart').then((mod) => ({ default: mod.TreeNode }))
}); );
// ---------------------------------------------------------------------- // ----------------------------------------------------------------------
@ -27,6 +26,7 @@ export function OrganizationalChart({ data, nodeItem, ...other }) {
}); });
return ( return (
<Suspense fallback={null}>
<Tree <Tree
lineWidth="1.5px" lineWidth="1.5px"
nodePadding="4px" nodePadding="4px"
@ -39,6 +39,7 @@ export function OrganizationalChart({ data, nodeItem, ...other }) {
<TreeList key={index} depth={1} data={list} nodeItem={nodeItem} /> <TreeList key={index} depth={1} data={list} nodeItem={nodeItem} />
))} ))}
</Tree> </Tree>
</Suspense>
); );
} }

View File

@ -1,4 +1,3 @@
'use client';
import './styles.css'; import './styles.css';

Some files were not shown because too many files have changed in this diff Show More