Adding registration doesnt working

This commit is contained in:
danilgryaznev 2025-09-27 22:22:26 +03:00
parent 418c8bdb65
commit 1535453cb7
16 changed files with 425 additions and 24 deletions

View File

@ -81,12 +81,13 @@ npm run dev
## Проверка функционала
1. **Вход**: откройте `/login`, авторизуйтесь как пилот (`candidate@alabuga.space / orbita123`) или HR (`hr@alabuga.space / orbita123`). После успешного входа пилот попадает на дашборд, HR — в админ-панель.
2. **Онбординг и лор**: под пилотом посетите `/onboarding`, прочитайте слайды и отметьте шаги выполненными. Прогресс сохраняется и открывает ветки миссий.
3. **Кандидат**: изучите дашборд (`/`), миссии (`/missions`) и журнал (`/journal`). Доступность миссий зависит от ранга и выполненных заданий.
4. **Выполнение миссии**: откройте карточку миссии, отправьте доказательство. Переключитесь на HR и одобрите выполнение — ранги, компетенции и мана обновятся автоматически.
5. **HR панель**: под HR-пользователем проверьте сводку, модерацию, редактирование веток/миссий/рангов, создание артефактов и аналитику (`/admin`).
6. **Магазин**: вернитесь к пилоту, оформите заказ в `/store` — запись появится в журнале и очереди HR; при недостатке маны интерфейс подскажет, что делать.
1. **Регистрация**: если хотите пройти сценарий «с нуля», откройте `/register`, заполните форму (имя, email, пароль, пожелания) и следуйте подсказкам. При отключённом подтверждении почты вы сразу попадёте на онбординг, при включённом — получите код на почту (в dev-режиме код отображается прямо в браузере).
2. **Вход**: откройте `/login`, авторизуйтесь как пилот (`candidate@alabuga.space / orbita123`) или HR (`hr@alabuga.space / orbita123`). После успешного входа пилот попадает на дашборд, HR — в админ-панель.
3. **Онбординг и лор**: под пилотом посетите `/onboarding`, прочитайте слайды и отметьте шаги выполненными. Прогресс сохраняется и открывает ветки миссий.
4. **Кандидат**: изучите дашборд (`/`), миссии (`/missions`) и журнал (`/journal`). Доступность миссий зависит от ранга и выполненных заданий.
5. **Выполнение миссии**: откройте карточку миссии, отправьте доказательство. Переключитесь на HR и одобрите выполнение — ранги, компетенции и мана обновятся автоматически.
6. **HR панель**: под HR-пользователем проверьте сводку, модерацию, редактирование веток/миссий/рангов, создание артефактов и аналитику (`/admin`). Для просмотра экрана кандидата используйте пункт «Просмотр от лица пилота» — он откроет `/` в режиме read-only и добавит кнопку «Вернуться к HR».
7. **Магазин**: вернитесь к пилоту, оформите заказ в `/store` — запись появится в журнале и очереди HR; при недостатке маны интерфейс подскажет, что делать.
### Подтверждение электронной почты

View File

@ -0,0 +1,22 @@
"""Add profile preference fields"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '20240927_0004'
down_revision = '20240927_0003'
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column('users', sa.Column('preferred_branch', sa.String(length=160), nullable=True))
op.add_column('users', sa.Column('motivation', sa.Text(), nullable=True))
def downgrade() -> None:
op.drop_column('users', 'motivation')
op.drop_column('users', 'preferred_branch')

View File

@ -9,11 +9,12 @@ from sqlalchemy.orm import Session
from app.api.deps import get_current_user
from app.core.config import settings
from app.core.security import create_access_token, verify_password
from app.core.security import create_access_token, verify_password, get_password_hash
from app.db.session import get_db
from app.models.user import User
from app.models.rank import Rank
from app.models.user import User, UserRole
from app.schemas.auth import EmailConfirm, EmailRequest, Token
from app.schemas.user import UserLogin, UserRead
from app.schemas.user import UserLogin, UserRead, UserRegister
from app.services.email_confirmation import confirm_email as mark_confirmed
from app.services.email_confirmation import issue_confirmation_token
@ -24,20 +25,71 @@ router = APIRouter(prefix="/auth", tags=["auth"])
def login(user_in: UserLogin, db: Session = Depends(get_db)) -> Token:
"""Проверяем логин и выдаём JWT."""
# 1. Находим пользователя по e-mail. Для супер-новичка: `.first()` вернёт
# сам объект пользователя либо `None`, если почта не зарегистрирована.
user = db.query(User).filter(User.email == user_in.email).first()
# 2. Если пользователь не найден или пароль не совпал — сразу возвращаем 401.
if not user or not verify_password(user_in.password, user.hashed_password):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Неверные данные")
if settings.require_email_confirmation and not user.is_email_confirmed:
# 3. Когда включено подтверждение почты, запрещаем вход до завершения процедуры.
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Подтвердите e-mail, прежде чем войти",
)
# 4. Генерируем короткоживущий JWT. Он будет храниться в httpOnly-cookie на фронте.
token = create_access_token(user.email, timedelta(minutes=settings.access_token_expire_minutes))
return Token(access_token=token)
@router.post(
"/register",
response_model=Token | dict[str, str | None],
status_code=status.HTTP_201_CREATED,
summary="Регистрация нового пилота",
)
def register(user_in: UserRegister, db: Session = Depends(get_db)) -> Token | dict[str, str | None]:
"""Создаём учётную запись пилота и при необходимости отправляем код подтверждения."""
# 1. Проверяем, не зарегистрирован ли пользователь раньше: уникальный e-mail — обязательное условие.
existing = db.query(User).filter(User.email == user_in.email).first()
if existing:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Пользователь с таким email уже существует")
# 2. Назначаем новичку самый базовый ранг. Если ранги ещё не заведены в БД,
# `base_rank` будет `None`, поэтому ниже используем условный оператор.
base_rank = db.query(Rank).order_by(Rank.required_xp).first()
user = User(
email=user_in.email,
full_name=user_in.full_name,
hashed_password=get_password_hash(user_in.password),
role=UserRole.PILOT,
preferred_branch=user_in.preferred_branch,
motivation=user_in.motivation,
current_rank_id=base_rank.id if base_rank else None,
is_email_confirmed=not settings.require_email_confirmation,
)
db.add(user)
db.commit()
db.refresh(user)
if settings.require_email_confirmation:
# 3. При включённом подтверждении выдаём одноразовый код и подсказываем,
# что делать дальше. В dev-режиме отдаём код прямо в ответе, чтобы QA
# мог пройти сценарий без почтового сервиса.
token = issue_confirmation_token(user, db)
return {
"detail": "Мы отправили письмо с подтверждением. Введите код, чтобы активировать аккаунт.",
"debug_token": token if settings.environment != 'production' else None,
}
# 4. Если подтверждение выключено, сразу создаём JWT и возвращаем его фронтенду.
access_token = create_access_token(user.email, timedelta(minutes=settings.access_token_expire_minutes))
return Token(access_token=access_token)
@router.get("/me", response_model=UserRead, summary="Текущий пользователь")
def read_current_user(current_user: User = Depends(get_current_user)) -> UserRead:
"""Простая проверка токена."""
@ -49,10 +101,12 @@ def read_current_user(current_user: User = Depends(get_current_user)) -> UserRea
def request_confirmation(payload: EmailRequest, db: Session = Depends(get_db)) -> dict[str, str | None]:
"""Генерируем код подтверждения и в режиме разработки возвращаем его в ответе."""
# 1. Находим пользователя и убеждаемся, что он вообще существует.
user = db.query(User).filter(User.email == payload.email).first()
if not user:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Пользователь не найден")
# 2. Генерируем новый код подтверждения, предыдущий становится недействительным.
token = issue_confirmation_token(user, db)
hint = None
@ -69,15 +123,19 @@ def request_confirmation(payload: EmailRequest, db: Session = Depends(get_db)) -
def confirm_email(payload: EmailConfirm, db: Session = Depends(get_db)) -> dict[str, str]:
"""Активируем почту, если код совпал."""
# 1. Повторяем поиск пользователя: это второй шаг флоу подтверждения.
user = db.query(User).filter(User.email == payload.email).first()
if not user:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Пользователь не найден")
if not user.email_confirmation_token:
# 2. Если токена нет, значит пользователь не запрашивал письмо.
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Код не запрошен")
if user.email_confirmation_token != payload.token:
# 3. Защищаемся от неправильного кода.
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Неверный код")
# 4. Отмечаем почту подтверждённой и убираем токен.
mark_confirmed(user, db)
return {"detail": "E-mail подтверждён"}

View File

@ -6,7 +6,7 @@ from datetime import datetime
from enum import Enum
from typing import List, Optional
from sqlalchemy import Boolean, DateTime, Enum as SQLEnum, ForeignKey, Integer, String, UniqueConstraint
from sqlalchemy import Boolean, DateTime, Enum as SQLEnum, ForeignKey, Integer, String, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base, TimestampMixin
@ -39,6 +39,10 @@ class User(Base, TimestampMixin):
is_email_confirmed: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
email_confirmation_token: Mapped[Optional[str]] = mapped_column(String(128), nullable=True)
email_confirmed_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
# Храним пожелания кандидата: в какой ветке развития он хочет расти.
preferred_branch: Mapped[Optional[str]] = mapped_column(String(160), nullable=True)
# Короткая заметка с личной мотивацией — помогает HR при первичном контакте.
motivation: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
current_rank = relationship("Rank", back_populates="pilots")
competencies: Mapped[List["UserCompetency"]] = relationship(

View File

@ -85,3 +85,14 @@ class UserLogin(BaseModel):
email: EmailStr
password: str
class UserRegister(BaseModel):
"""Регистрация нового пилота."""
email: EmailStr
full_name: str
password: str
# Дополнительные сведения помогают персонализировать онбординг и связать пилота с куратором.
preferred_branch: Optional[str] = None
motivation: Optional[str] = None

View File

@ -13,7 +13,11 @@ from app.models.user import User
def issue_confirmation_token(user: User, db: Session) -> str:
"""Генерируем и сохраняем одноразовый код подтверждения."""
# В `token_urlsafe(16)` мы просим secrets создать криптостойкую строку длиной ~22 символа,
# которую удобно вводить вручную. Каждый новый вызов полностью обнуляет предыдущий код.
token = token_urlsafe(16)
# Сбрасываем флаги подтверждения — это важно, если пользователь запрашивает
# повторное письмо: только введя актуальный код, он снова активируется.
user.email_confirmation_token = token
user.is_email_confirmed = False
user.email_confirmed_at = None
@ -26,8 +30,11 @@ def issue_confirmation_token(user: User, db: Session) -> str:
def confirm_email(user: User, db: Session) -> None:
"""Помечаем почту подтверждённой."""
# Флаг `is_email_confirmed` избавляет нас от лишних запросов при каждой авторизации.
user.is_email_confirmed = True
# Токен больше не нужен — удаляем его, чтобы предотвратить повторное использование.
user.email_confirmation_token = None
# Сохраняем точное время подтверждения: пригодится в аналитике и для аудита.
user.email_confirmed_at = datetime.now(timezone.utc)
db.add(user)
db.commit()

View File

@ -0,0 +1,10 @@
import { NextResponse } from 'next/server';
import { disablePilotView, requireRole } from '../../../lib/auth/session';
export async function GET(request: Request) {
// Возвращаем HR к своему интерфейсу.
// Cookie `alabuga_view_as` хранит флаг режима просмотра, удаляем его и редиректим в админку.
await requireRole('hr');
disablePilotView();
return NextResponse.redirect(new URL('/admin', request.url));
}

View File

@ -0,0 +1,10 @@
import { NextResponse } from 'next/server';
import { enablePilotView, requireRole } from '../../../lib/auth/session';
export async function GET(request: Request) {
// Доступно только HR: включаем режим просмотра и отправляем на дашборд кандидата.
// Благодаря этому HR увидит интерфейс пилота без необходимости заводить отдельную учётку.
await requireRole('hr');
enablePilotView();
return NextResponse.redirect(new URL('/', request.url));
}

View File

@ -12,17 +12,33 @@ export default async function RootLayout({ children }: { children: React.ReactNo
// Пробуем получить сессию (если пользователь не авторизован, вернётся null).
const session = await getSession();
// Формируем список пунктов меню в зависимости от роли пользователя.
const links = session
? [
// Сохраняем подсказки, кто сейчас вошёл и включил ли HR режим «просмотра глазами пилота».
const isHr = session?.role === 'hr';
const viewingAsPilot = Boolean(session?.viewAsPilot);
// Формируем пункты меню в зависимости от текущего режима.
let links: Array<{ href: string; label: string }> = [];
if (!session) {
links = [{ href: '/login', label: 'Войти' }];
} else if (isHr && !viewingAsPilot) {
links = [
{ href: '/admin', label: 'HR панель' },
{ href: '/admin/view-as', label: 'Просмотр от лица пилота' },
];
} else {
links = [
{ href: '/', label: 'Дашборд' },
{ href: '/onboarding', label: 'Онбординг' },
{ href: '/missions', label: 'Миссии' },
{ href: '/journal', label: 'Журнал' },
{ href: '/store', label: 'Магазин' },
...(session.role === 'hr' ? [{ href: '/admin', label: 'HR панель' }] : []),
]
: [{ href: '/login', label: 'Войти' }];
];
if (isHr) {
// Дополнительный пункт для HR: быстрый выход из режима просмотра.
links.push({ href: '/admin/exit-view', label: 'Вернуться к HR' });
}
}
return (
<html lang="ru">
@ -52,6 +68,16 @@ export default async function RootLayout({ children }: { children: React.ReactNo
{link.label}
</a>
))}
{viewingAsPilot && (
<span style={{
color: '#ffeaa7',
fontSize: '0.85rem',
textTransform: 'uppercase',
letterSpacing: '0.08em'
}}>
режим просмотра пилота
</span>
)}
{session && (
<>
<span style={{ color: 'var(--text-muted)', marginLeft: '1rem' }}>

View File

@ -43,7 +43,11 @@ async function authenticate(formData: FormData) {
}
}
export default async function LoginPage({ searchParams }: { searchParams: { error?: string } }) {
export default async function LoginPage({
searchParams
}: {
searchParams: { error?: string; info?: string };
}) {
// Если пользователь уже вошёл, сразу перенаправляем его на рабочую страницу.
const existing = await getSession();
if (existing) {
@ -51,6 +55,7 @@ export default async function LoginPage({ searchParams }: { searchParams: { erro
}
const message = searchParams.error;
const info = searchParams.info;
return (
<section className={styles.container}>
@ -62,6 +67,7 @@ export default async function LoginPage({ searchParams }: { searchParams: { erro
<strong> hr@alabuga.space / orbita123</strong>.
</p>
{info && <p className={styles.info}>{info}</p>}
{message && <p className={styles.error}>{message}</p>}
<label className={styles.field}>
@ -73,6 +79,9 @@ export default async function LoginPage({ searchParams }: { searchParams: { erro
<input className={styles.input} type="password" name="password" required placeholder="Введите пароль" />
</label>
<button className={styles.submit} type="submit">Войти</button>
<p className={styles.footer}>
Впервые на платформе? <a href="/register">Зарегистрируйтесь и начните путь пилота.</a>
</p>
</form>
</section>
);

View File

@ -37,6 +37,16 @@
text-align: center;
}
.info {
margin: 0;
padding: 0.75rem 1rem;
border-radius: 12px;
background: rgba(0, 184, 148, 0.12);
border: 1px solid rgba(0, 184, 148, 0.35);
color: var(--success);
text-align: center;
}
.field {
display: grid;
gap: 0.5rem;
@ -65,3 +75,14 @@
.submit:hover {
opacity: 0.9;
}
.footer {
margin: 0;
text-align: center;
color: var(--text-muted);
font-size: 0.9rem;
}
.footer a {
color: var(--accent-light);
}

View File

@ -2,8 +2,11 @@ import { NextResponse } from 'next/server';
export function GET(request: Request) {
// Очищаем cookie и мгновенно перенаправляем пользователя на страницу входа.
// Здесь не используем `destroySession`, потому что `NextResponse` позволяет
// выставить заголовки прямо в объекте ответа.
const target = new URL('/login', request.url);
const response = NextResponse.redirect(target);
response.cookies.delete('alabuga_session');
response.cookies.delete('alabuga_view_as');
return response;
}

View File

@ -0,0 +1,105 @@
import { redirect } from 'next/navigation';
import { apiFetch } from '../../lib/api';
import { createSession, getSession } from '../../lib/auth/session';
import styles from './styles.module.css';
// Server Action: оформляет регистрацию и в зависимости от настроек либо сразу
// авторизует пользователя, либо перенаправляет его на страницу входа
// с подсказкой подтвердить почту.
async function registerAction(formData: FormData) {
'use server';
// 1. Извлекаем значения из формы. Метод `FormData.get` возвращает `FormDataEntryValue` | null,
// поэтому приводим к строке и удаляем пробелы по краям.
const fullName = String(formData.get('fullName') ?? '').trim();
const email = String(formData.get('email') ?? '').trim();
const password = String(formData.get('password') ?? '').trim();
// Необязательные поля переводим в undefined, чтобы backend не записывал пустые строки.
const preferredBranch = String(formData.get('preferredBranch') ?? '').trim() || undefined;
const motivation = String(formData.get('motivation') ?? '').trim() || undefined;
if (!fullName || !email || !password) {
redirect('/register?error=' + encodeURIComponent('Заполните имя, email и пароль.'));
}
try {
// 2. Собираем payload в формате, который ожидает FastAPI.
const payload = { full_name: fullName, email, password, preferred_branch: preferredBranch, motivation };
const response = await apiFetch<any>('/auth/register', {
method: 'POST',
body: JSON.stringify(payload)
});
if (response && 'access_token' in response) {
// 3a. Если подтверждение почты отключено — получаем JWT, создаём сессию и отправляем пилота на онбординг.
createSession({ token: response.access_token, role: 'pilot', fullName });
redirect('/onboarding');
}
// 3b. При включённом подтверждении backend возвращает текст подсказки и debug-код.
const detail = response?.detail ?? 'Проверьте почту для подтверждения.';
const debug = response?.debug_token ? ` Код: ${response.debug_token}` : '';
redirect('/login?info=' + encodeURIComponent(`${detail}${debug}`));
} catch (error) {
console.error('Registration failed:', error);
// 4. Любые сетевые/серверные ошибки показываем пользователю через query string.
const message = error instanceof Error ? error.message : 'Не удалось завершить регистрацию.';
redirect('/register?error=' + encodeURIComponent(message));
}
}
export default async function RegisterPage({ searchParams }: { searchParams: { error?: string } }) {
const existing = await getSession();
if (existing) {
redirect(existing.role === 'hr' ? '/admin' : '/');
}
const errorMessage = searchParams.error;
return (
<section className={styles.container}>
<form className={styles.form} action={registerAction}>
<h1>Регистрация пилота</h1>
<p className={styles.hint}>
После регистрации вы попадёте на онбординг и сможете выполнять миссии.
Если включено подтверждение почты, мы отправим код на указанную почту.
</p>
{errorMessage && <p className={styles.error}>{errorMessage}</p>}
<label className={styles.field}>
Полное имя
<input className={styles.input} name="fullName" required placeholder="Как к вам обращаться" />
</label>
<label className={styles.field}>
Email
<input className={styles.input} type="email" name="email" required placeholder="user@alabuga.space" />
</label>
<label className={styles.field}>
Пароль
<input className={styles.input} type="password" name="password" required placeholder="Придумайте пароль" />
</label>
<label className={styles.field}>
Интересующая ветка (необязательно)
<select className={styles.input} name="preferredBranch" defaultValue="">
<option value="">Выберите ветку</option>
<option value="Получение оффера">Получение оффера</option>
<option value="Рекрутинг">Рекрутинг</option>
<option value="Квесты">Квесты</option>
<option value="Симулятор">Симулятор</option>
<option value="Лекторий">Лекторий</option>
</select>
</label>
<label className={styles.field}>
Что хотите добиться?
<textarea className={styles.textarea} name="motivation" rows={3} placeholder="Например: хочу собрать портфолио и познакомиться с командой" />
</label>
<button className={styles.submit} type="submit">Создать аккаунт</button>
<p className={styles.footer}>
Уже есть аккаунт? <a href="/login">Войдите</a>.
</p>
</form>
</section>
);
}

View File

@ -0,0 +1,85 @@
.container {
min-height: calc(100vh - 200px);
display: flex;
justify-content: center;
align-items: center;
}
.form {
width: min(520px, 92%);
background: rgba(17, 22, 51, 0.85);
border: 1px solid rgba(162, 155, 254, 0.35);
border-radius: 20px;
padding: 2.25rem;
display: grid;
gap: 1.25rem;
}
.form h1 {
margin: 0;
text-align: center;
}
.hint {
margin: 0;
font-size: 0.9rem;
color: var(--text-muted);
text-align: center;
line-height: 1.5;
}
.error {
margin: 0;
padding: 0.75rem 1rem;
border-radius: 12px;
background: rgba(255, 118, 117, 0.12);
border: 1px solid rgba(255, 118, 117, 0.35);
color: var(--error);
text-align: center;
}
.field {
display: grid;
gap: 0.5rem;
font-size: 0.95rem;
}
.input,
.textarea,
.select {
border-radius: 12px;
border: 1px solid rgba(162, 155, 254, 0.35);
background: rgba(8, 11, 26, 0.7);
color: var(--text);
padding: 0.85rem 1rem;
}
.textarea {
resize: vertical;
}
.submit {
margin-top: 0.5rem;
padding: 0.9rem 1rem;
border-radius: 12px;
border: none;
background: linear-gradient(120deg, var(--accent), #00b894);
color: #fff;
font-weight: 600;
cursor: pointer;
}
.submit:hover {
opacity: 0.9;
}
.footer {
margin: 0;
text-align: center;
color: var(--text-muted);
font-size: 0.9rem;
}
.footer a {
color: var(--accent-light);
}

View File

@ -11,9 +11,12 @@ interface SessionPayload {
token: string;
role: 'pilot' | 'hr';
fullName: string;
viewAsPilot?: boolean;
}
// Названия cookie держим в одном месте, чтобы не допустить опечатки при удалении/чтении.
const SESSION_COOKIE = 'alabuga_session';
const VIEW_COOKIE = 'alabuga_view_as';
function parseSession(raw: string | undefined): SessionPayload | null {
// Cookie может отсутствовать либо быть повреждённой, поэтому парсинг
@ -38,8 +41,10 @@ export async function getSession(): Promise<SessionPayload | null> {
}
try {
// backend возвращает 401/403 для просроченных или неподтверждённых токенов.
await apiFetch('/auth/me', { authToken: session.token });
return session;
const viewAsPilot = store.get(VIEW_COOKIE)?.value === 'pilot';
return { ...session, viewAsPilot };
} catch (error) {
console.warn('Session validation failed:', error);
store.delete(SESSION_COOKIE);
@ -80,9 +85,28 @@ export function createSession(session: SessionPayload) {
path: '/',
maxAge: 60 * 60 * 12,
});
store.delete(VIEW_COOKIE);
}
export function destroySession() {
// Удаление cookie при выходе пользователя.
cookies().delete(SESSION_COOKIE);
const store = cookies();
store.delete(SESSION_COOKIE);
store.delete(VIEW_COOKIE);
}
export function enablePilotView(): void {
// HR включает режим просмотра интерфейса пилота, чтобы видеть клиентские экраны.
cookies().set(VIEW_COOKIE, 'pilot', {
httpOnly: true,
sameSite: 'lax',
secure: process.env.NODE_ENV === 'production',
path: '/',
maxAge: 60 * 60,
});
}
export function disablePilotView(): void {
// Возвращаем интерфейс HR к обычному виду.
cookies().delete(VIEW_COOKIE);
}

View File

@ -234,6 +234,9 @@ def seed() -> None:
hashed_password=get_password_hash("orbita123"),
current_rank_id=ranks[0].id,
is_email_confirmed=True,
# Эти два поля демонстрируют, как HR видит пожелания кандидата.
preferred_branch="Получение оффера",
motivation="Хочу пройти все миссии и закрепиться в экипаже.",
)
hr = User(
email="hr@alabuga.space",
@ -242,6 +245,8 @@ def seed() -> None:
hashed_password=get_password_hash("orbita123"),
current_rank_id=ranks[2].id,
is_email_confirmed=True,
# Для HR поле также используем — служит подсказкой в профиле.
preferred_branch="Куратор миссий",
)
session.add_all([pilot, hr])
session.flush()