Делюсь опытом участия в хакатоне от совкомбанка
Сколково изнутри в 17:00
Сегодня, я хочу поделиться опытом и рассказать про участие в командном хакатоне от совкомбанка. Вкратце опишу задачу — командой до 5 человек сделать внутренний сервис для подбора персонала и ведения HR деятельности. Кому интересен опыт участия и немного изнанки хакатонов — прошу под кат)
Подготовка — пол дела
На первом этапе команду я решил собирать из участников прошлого хакатона в котором участвовал (DatsArt занял 46из340 место) и кинул клич в телеграм чате, сразу состав получился следующий: 2 frontend + 2 python + 1 java разработчики. Т.к мы собирались писать http приложение и в наши планы не входило писать REST API на java spring или python, я переключился на роль бекендера, который сделает апишки для веб мордочки и CRUD операциями над БД на php 8.2 + symfony, фронт сделает интерфейсы на vue 3.3 + TS + pinia, питонисты будут отвечать за алгоритм нахождения и подсчет релевантности в представленных резюме, а джавист напишет прослойку которая будет искать резюме на сторонних сайтах типа hh или superjob. Дополнительные сервисы планировались как отдельные HTTP сервисы в своих контейнерах. Об этом позже.
Перед хакатоном было достаточно времени, чтобы подготовиться, я немного успел почитать про разработку на Symfony и примерить на себя роль бекенд разработчика (кто со мной знаком, в курсе, что последние 10 лет я работаю фронтенд разработчиком\тимлидом и для меня должность бекенд разработчика на симфони в новинку) Почему я не выбрал почти родной мне js\ts? — цель хакатона не просто показать «смотри как могу на ноде», но и предоставить рабочий проект, который не стыдно будет поддерживать в ентерпрайзе и собеседовать людей не на свой велосипед на ноде, так что Symfony с ее документацией, доктриной и остальными возможностями — мне показались отличным вариантом. Также каждому разработчику я приобрел VDS на время хакатона + mysql бд как сервис.
Веселые старты
Не считая непривычной мне роли. На последней QA сессии перед хакатоном, организаторы донесли мысль, что ДИЗАЙН_ПРЕЗЕНТАЦИИ ОЧЕНЬ ВАЖЕН и я как капитан команды, принял неудобное для одного из разработчиков python решение — заменить на дизайнера. Добровольно никто место не хотел сдавать, а проигрывать мне не хотелось и пришлось принимать решение такое. Так к нам присоединился дизайнер интерфейсов, который подавал заявку на платформе CodenRock по нашему обьявлению.
Пол пути к победе
В первый же день начала хакатона, после прочтения задания (а оно совпадало на 90% со спойлером задания) второй python разработчик говорит нам, что не хочет подвести команду и не вывезет и покидает нас) Я прошу организаторов конкурса вернуть нам первого python разработчика, такая вот сантабарбара) Но мы все же в полном составе проходим на старте хакатона.
Основные сущности в нашем проекте были Вакансии, Резюме, События (встречи, собеседования), заявки на согласование
Вакансии — кого, куда, за сколько ищем
Резюме — складируем резюме кандидатов в свою БД для ведения отчетности и архива, позволяем выбирать кандидатов на вакансию из доступных резюме
События — абстрактные события — дата начала, участники, тип события (встреча, переговоры, собеседование)
Департаменты — сущность с title и возможностью прикреплять к ней учетки пользователей
Навыки — ключевые навыки которые проходят как теги сквозь пользователей, вакансии, резюме
Пользователи — с ролями и уровнями доступа, указанием в каком департаменте работает и какие навыки имеет.
Логика работы очень простая, заводим вакансию, назначаем ответственного, ответственный добавляет резюме, отбирает кандидатов, назначает встречи и согласования + строим графики и всякие отчетики сколько успеем для красивого дешборда.
На пол пути к победе
На третий день хакатона мы имели рабочий swagger основные экраны для CRUD операций над сущностями, в сумме проработав часов 8 над кодом. Ниже приведу пример контроллера который у меня получался (они все одинаковые получились почти). Больше работать не получалось т.к. я не брал выходных на хакатон и полноценно работал на основной работе полный рабочий день.
Пример ApiSkillsController.php
apiFrontendService = new ApiFrontendService($entityManager, $validator);
}
/**
* Список навыков
*/
#[OAT\Get(
path: '/api/skills',
security: ['X-AUTH-TOKEN'],
operationId: 'app_api_skill',
description: 'Список всех навыков',
tags: ['Skills'],
responses: [
new OAT\Response(
response: 200,
description: 'All skills',
content: new OAT\JsonContent(
type: 'array',
items: new OAT\Items(ref: "#/components/schemas/Skill")
)
),
]
)]
#[IsGranted('ROLE_USER')]
#[Route('/api/skills', name: 'app_api_skill', methods: ['GET'])]
public function index(): JsonResponse
{
return $this->apiFrontendService->getAllEntity('App\Entity\Skill');
}
/**
* Создание навыка.
*/
#[OAT\Post(
path: '/api/skills',
security: ['X-AUTH-TOKEN'],
operationId: 'app_api_skill_create',
description: 'Заведение нового навыка',
tags: ['Skills'],
parameters: [
new OAT\RequestBody(
required: true,
content: new OAT\JsonContent(ref: "#/components/schemas/Skill")
)],
responses: [
new OAT\Response(
response: 200,
description: 'Entity созданного навыка',
content: new OAT\JsonContent(
type: 'array',
items: new OAT\Items(ref: "#/components/schemas/Skill")
)
),
]
)
]
#[IsGranted('ROLE_USER')]
#[Route('/api/skills', name: 'app_api_skill_create', methods: ['POST'])]
public function create(Request $request, EntityManagerInterface $em, ValidatorInterface $validator): JsonResponse
{
$rq = json_decode($request->getContent());
$skill = new Skill();
$skill->setTitle($rq->title);
$errors = $validator->validate($skill);
if (count($errors) > 0) {
return new JsonResponse([
'errors' => array_map(function ($error) {
return [
'property' => $error->getPropertyPath(),
'message' => $error->getMessage()
];
}, iterator_to_array($errors))
]);
}
$em->getRepository(Skill::class)->save($skill, true);
return new JsonResponse([
'data' => $skill->asArray(),
'errors' => []
]);
}
/**
* Редактирование навыка.
*/
#[OAT\Put(
path: '/api/skills/{id}',
security: ['X-AUTH-TOKEN'],
operationId: 'app_api_skill_edit',
description: 'Редактирование навыка',
tags: ['Skills'],
parameters: [
new OAT\RequestBody(
required: true,
content: new OAT\JsonContent(ref: "#/components/schemas/Skill")
)],
responses: [
new OAT\Response(
response: 200,
description: 'Entity навыка',
content: new OAT\JsonContent(
type: 'array',
items: new OAT\Items(ref: "#/components/schemas/Skill")
)
),
]
)]
#[IsGranted('ROLE_USER')]
#[Route('/api/skills/{id}', name: 'app_api_skill_edit', methods: ['PUT'])]
public function editDepartament(int $id, Request $request, EntityManagerInterface $em, ValidatorInterface $validator): JsonResponse
{
$rq = json_decode($request->getContent());
$repo = $em->getRepository(Skill::class);
$departament = $repo->findOneBy([
'id' => $id
]);
if(!$departament) {
return new JsonResponse([
'errors' => ['Invalid ID']
]);
}
$departament->setTitle($rq->title);
$errors = $validator->validate($departament);
if (count($errors) > 0) {
return new JsonResponse([
'errors' => array_map(function ($error) {
return [
'property' => $error->getPropertyPath(),
'message' => $error->getMessage()
];
}, iterator_to_array($errors))
]);
}
$repo->save($departament, true);
return new JsonResponse([
'data' => $departament->asArray(),
'errors' => []
]);
}
/**
* Получение навыка по ID
*/
#[OAT\Get(
path: '/api/skills/{id}',
description: 'Получение навыка по ID',
tags: ['Skills'],
responses: [
new OAT\Response(
response: 200,
description: 'Departament entity',
content: new OAT\JsonContent(ref: "#/components/schemas/Skill")
),
]
)]
#[IsGranted('ROLE_USER')]
#[Route('/api/skills/{id}', methods: ['GET'])]
public function get(int $id): JsonResponse
{
return $this->apiFrontendService->getEntityById('App\Entity\Skill', $id);
}
}
Очень понравилось использовать OpenApi\Attributes
— прописываешь атрибуты прямо рядом с кодом и документация строится сама! Очень круто! Если кто подскажет на хабре, как сделать так чтобы Shemas
генерировались с Entity
и их не приходилось писать в nelmio_api_doc.yaml
, будет очень хорошо) Также для уменьшения кода я сделал простенький сервис для типовых операций
ApiFrontendService.php
entityManager = $entityManager;
$this->validator = $validator;
}
/**
* Получение Entity по Entity.ID
* @return JsonResponse(Entity[])
*/
public function getAllEntity(string $className): JsonResponse
{
return new JsonResponse(
array_map(function($entity) {
return $entity->asArray();
}, $this->entityManager->getRepository($className)->findAll())
);
}
/**
* Получение Entity по критерию
* @return JsonResponse(Entity[])
*/
public function getEntityListByCriteria(string $className, array $criteria): JsonResponse
{
return new JsonResponse(
array_map(function($entity) {
return $entity->asArray();
}, $this->entityManager->getRepository($className)->findBy($criteria))
);
}
/**
* Получение Entity по наличию memberValue в memberProp
* @return JsonResponse(Entity[])
*/
public function getEntityMembersColllection(string $className, string $propName, int $memberValue): JsonResponse
{
$members = $this->entityManager->getRepository($className)->createQueryBuilder('e')
->where(':memeber_value MEMBER OF e.'.$propName)
->setParameter('memeber_value', $memberValue)
->getQuery()
->getResult();
if(!$members) {
return new JsonResponse(null, 200);
}
return new JsonResponse(array_map(function($entity) {
return $entity->asArray();
}, $members));
}
/**
* Получение Entity по Entity.ID
* @return JsonResponse(Entity)
*/
public function getEntityById(string $className, int $id): JsonResponse
{
$entity = $this->entityManager->getRepository($className)->findOneBy([
'id' => $id
]);
if (!$entity) {
return new JsonResponse(null, 404);
}
return new JsonResponse($entity->asArray());
}
/**
* Сохранние сущности
* @return JsonResponse(Entity|Errors[{property:string,message:string}])
*/
public function saveEntity(string $className, $entity): JsonResponse
{
$errors = $this->validator->validate($entity);
if (count($errors) > 0) {
return new JsonResponse([
'errors' => array_map(function ($error) {
return [
'property' => $error->getPropertyPath(),
'message' => $error->getMessage()
];
}, iterator_to_array($errors))
], 400);
}
$this->entityManager->getRepository($className)->save($entity, true);
return new JsonResponse($entity->asArray(), 200);
}
}
Был еще вариант использовать api-platform, но меня он испугал своими правилами для его кастомизации помимо CRUD, уж слишком много кода чтобы описать 1 action для API, профит с генерацией CRUD не перебивает оверхед с возней с этим бандлом в дальнейшем, по крайней мере во время хакатона у меня)
На фронте помимо формочек, все работало через набор pinia stores, пример одного из сторов на фронте. В остальном же обычный бутстрап, не вижу смысла показывать листинг. Ссылка покликать наш результат есть в конце статьи.
UsersStore.ts
import { ref } from 'vue'
import { defineStore } from 'pinia'
import http from '@/http';
export const useUsersStore = defineStore('users', () => {
/**
* Список пользователей
*/
const list = ref([] as any[]);
/**
* Загрузка списка пользователей
* @returns Promise with Users[] entity
*/
const fetchList = async function fetchList():Promise {
const response = await http.get('/api/users');
if(response.status === 200 && response.data) {
list.value = response.data;
}
return response.data;
};
/**
* Получение профиля пользователя по ID
* @param id User.id
* @returns Promise with User entity
*/
const fetchUser = async function fetchUser(id:number):Promise {
return await http.get(`/api/user/${id}`);
};
/**
* Создание нового пользователя
* @param user object
* @returns Promise with User entity
*/
const create = async (user:any):Promise => await http.post('/api/users/new', user);
/**
* Редактирование пользователя
* @param id number
* @param user object
* @returns Promise with User entity
*/
const update = async (id:number, user:any):Promise => await http.put(`/api/users/${id}`, user);
/**
* Фильтр по пользователю
* @param id number userId
* @returns Users[]
*/
const getUserById = (id:Number) => {
return list.value.filter(u => u.id === id)[0];
}
return { list, fetchList, fetchUser, create, update, getUserById }
});
Первые 3 дня я не особо синхронизировался с командой, делая упор на выдачу документации к API и скорейшей интеграции с их сервисами. На третий день, интересуясь, что сделали ребята (python и java) я выясняю, что сделали — ничего. И ничего делать в целом не будут.Причины я выяснять не стал, как и обсуждать их решение. В этот момент я понимаю, что нас теперь условно трое) дизайнер, фронтенд и я. Принимаю решение продолжать работу, но уже беру на себя часть интерфейсов связанных с созданием вакансии и их наполнением фикстурами переключаю свое внимание на то, чтобы хотябы экраны в минимальном виде у нас все были и работали или что-то показывали. В место внешнего сервиса на python, берем моки с stats.hh.ru и строим псевдоаналитику уже, частично на моках и частично на реальных данных (что мы сами добавили в сервис)
День перед финалом
За день до окончания хакатона, у нас было готово все в очень сыром виде, из неготовых частей оставлась ролевая модель (разграничить доступы для пользователей т.к. весь хакатон просидели под админом), нужно было сделать мини сводку (воронку) на странице вакансии
Пример просмотра вакансии
Экран просмотра вакансии
Список резюме
Экран со списком резюме
Стартовый экран пользователя
Дизайнер за время хакатона нарисовал SVG логотип и подготовил пачку аватарок нагенерированных нейросетью. Весь остальной дизайн это просто bootstrap.
Финал и подведение итогов
Краткая статистика хакатона
Из 216 команд, которые в итоге осилили 42 решений, в финал прошли 13, а наше решение попало в ТОП 10 на 9 место. Из сильных сторон нашего решения отметили легкость поднятия проекта (npm run dev
на фронте и symfony server:start
для бекенда) Наличие `docker-compose` файла для деплоя и taskfile.yaml
как общий набор команд для репозитория. Также отметили подход к авторизации через X-AUTH-TOKEN
в заголовках, саму реализацию токена мы вынесли за рамки проекта и подразумевали, что нам токен отдаст внешний сервис авторизации внутри банковской системы. Из слабых — общая недоделанность аналитики и воронки кандидатов, слабая проработка ролевой модели. Потыкать интерфейсы можно на демо стенде mehunt.ru. Из подарков для топ 10 участников презентовали набор мерча — толстовка\рюкзак\сумка на пояс и футболка -, но по факту дали всем только сумки на пояс и футболки + грамота и пакет) вроде и мелочи, но это в целом единственный момент который меня смутил в организации. Еще смутило само Сколково, такой огромный город который типа заброшенного, только не заброшенный, просто пустой ТЦ с редкими зеваками, но это к самому хакатону отношения не имеет.
Пару слов о команде победителей — на мой взгляд заслуженные и единственные участники, кто настолько серьезно подошел к решению задачи и выполнили все в срок с кучей фишек. Ребята брали отпуска с основной работы, много хакатонили и мало спали) + были сработаны в других хакатонах. Так что заслуженное первое место с приличным отрывом.
Буду рад ответить на ваши вопросы и комментарии), а также найти желающих принимать совместное участие в хакатонах в любой роли, ну или я готов присоединиться к вам) welcome in tg @dstrokov
p.s. Пост про релиз веб-компонента wc-wysiwyg будет совсем скоро, публичный черновик поста уже доступен на webislife.ru, а сам релиз в гите уже 1.0.4:)