Как сделать честное голосование

Вступление

Хотел назвать «как сделать честные выборы», но уж больно желтый получился бы заголовок =) Хотя на волне текущей обстановки можно подняться.

Написать этот пост меня побудил вопрос на тостере (такой хабровский stackoverflow… легче, видимо, не стало? Тогда забей.) Вот там был подобный вопрос: Нужно сделать вандалоустойчивый механизм голосования на сайте, чтобы можно было защититься от накруток. И мне вспомнился наш опыт в этом деле. А кое-какой опыт у нас за 7 лет работы поднакопился =) Кстати, да! Можешь нас поздравить нам 7 лет, в этом году в школу пойдем. Хочется, конечно, выложить тут алгоритмы и куски кода, которые обеспечат решение задачи, но обо всём по порядку.

История первая: социальная

Помнится некоторое время назад, года 4 уже прошло, нам отдали разработку сайта неофициального сайта ННГУ — ilovenngu. Сам по себе сайт и по тем временам не представлял собой ничего сверх сложного для нас, обычный такой новостной ресурс, пяток страниц и лента новостей. Я же говорю, ничего особо сложного, за тем лишь исключением, что нужно было сделать голосование через соц.сети. Чуть глубже опишу о чем речь: каждый год при поступлении ННГУ устраивает конкурс самых лучших/красивых/умных и т.д. абитуриентов. Приз был то ли iPad, то ли еще что-то интересное, со стороны участников — игра стоила свеч. Не без некоторого обоснования руководство посчитало, что голосование нужно устраивать через соц.сети, по количеству лайков и репостов.  Техническая сторона вопроса была описана мной примерно в тоже время вот в этой публикации «Подсчет количества лайков средствами php».

Мы искренне полагали, что этот вариант голосования будет достаточно объективным, были молодые и глупые. Теперь, конечно, понятно, что голосование через соц.сети — это «конкурс друзей и друзей друзей». А сейчас можно совершенно без напряга купить лайки, вопрос гуглится без проблем. Но возвращаемся на 4 года назад и смотрим за развитием ситуации. Выкатываем сайт, начинают поступать заявки, мы их вручную обрабатываем (мозгов не хватило автоматизировать процесс, в оправдание скажу, что позднее мы сделали автоматическое добавление заявок), количество участников конкурса неспешно увеличивается. А потом как-то случайно кто-то из нас нашел, или кто-то из заказчиков нашел ссылку на двач, где шло активное обсуждение нашего конкурса. Как бы это попонятнее сказать, чтобы пост потом можно было показывать детям? В общем, один из участников двача в потоке обсуждения выдал по смыслу следующую фразу: «Господа! Не находите ли вы, что сегодня прекрасный солнечный день? У меня есть отличное предложение помочь несчастной девушке вот в этом конкурсе. Её фотография на фоне персидского ковра просто бесподобна! Она обязано победить!». Конечно, в оригинале вся мысль уместилась в 5 слов: «Зафорсим [женский орган] на фоне ковра!»

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

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

История вторая: самый крутой модуль опросов

Все персонажи вымышлены, совпадение имен и событий случайны

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

Шаг 1. Защищаемся через ip пользователя. Когда деревья были большими, а ip хватало всем, то очень жизнеспособный вариант, при условии, что все пользователи владеют статическим ip. Но наша вселенная так устроена:

Анекдот

Эйнштейн после смерти попадает на небо и встречать его выходит сам Создатель. Создатель говорит:
— Вот ты и попал к нам, Альберт. Я готов открыть любую одну тайну, спрашивай
— Я всю жизнь строил теорию всего, так расскажи же мне как ты смог это всё создать? Напиши формулу, объясняющую вселенную
Создатель берет мел, и начинает писать на доске огромную формулу. И вот после 3й строки Эйнштейн останавливает:
— Но постой! У тебя ошибка в предыдущем шаге!
Создатель улыбнулся и говорит:
— А я знаю…

Небольшие провайдеры не могут каждому выдать уникальные внешний ip и сажают всех пользователей за NAT, это когда внутри сети ip уникальны, а во внешний интернет смотрит 1 общий ip или небольшой пул разных ip. И вот Вася из под провайдера Далечебург-телеком проголосовал за Свету, и разослал по локальной сети приглашение на конкурс, чтобы все проголосовали за Свету, но вот только ни Коля, ни Оксана проголосовать не могут, сайт почему-то говорит, что они уже проголосовали. Или наоборот, Вася проголосовал, перезагрузил роутер или переподключился к сети и получил новый ip, за статический ip надо денежку заплатить, а зачем? Грусть-тоска.

Шаг 2. Валидация пользователей по сессиям и кукам. Тот же Вася проголосовал и разослал по сети конкурс, все участники локалки тоже проголосовали за Свету. Но вот появился ненавистник Светы — Андрей, она ему отказала во взаимной любви в 10м классе. Он оказался кулхацкером и умеет посылать curl или wget запросы из консоли с определенным набором данных. Этот набор данных интерпретируется как голос за Оксану — любовь Андрея (Ну мы-то знаем, что он так и сохнет по Свете, а Оксана так..) curl откровенно говоря, клал на все эти ваши сессии и куки, и все запросы без проблем проходят. Если Андрей совсем кул, то запросы он шлет ручками, а если уже научился конструировать бесконечный цикл и умеет ставить скрипт на паузу, то за час он накручивает 3600 голосов.

Но я тоже не пальцем деланный, во-первых, у меня есть логи, а если нет, то добавлю! И в них видно, что все запросы идут с одного ip, хм, что же делать? Руками чтоль всё чистить? Не-не-не, Дэвид Блейн, оставь себе свою уличную магию!

Шаг 2+. Если куков нет, то давай проверять связку заголовки+ip+куки и сессии. Ну да, немного усложнит обработку, но нам надо победить Андрея. Допустим, если приходят пустые заголовки user-agenta, то это какая-то лажа, и игнорируем голос, в противном случае смотрю не попадалась ли мне уже где-то такой же набор? Но этот [непечатное обращение], Андрей, научился подделывать и уникальные заголовки и ip и все остальное. Надоело писать триллер, к сути.

Мысль проста, используя для идентификации пользователя только те данные, которые приходят от пользователя, защиту не выстроить.

Шаг 3. «Регистрация!» — подумал Штирлиц.

Зайдем с другой стороны. Нужно идентифицировать пользователя до того как он проголосовал. Логичная мысль, и почему она в голову не пришла сразу? Опустим тот факт, что нужно делать регистрацию, но в данном случае уже тупо post-запросом накрутить голоса не получится. Да, усложняется процедура голосования, но если тебе нужно «честное голосование», то все средства хороши. Всё красиво, но вот подтягиваются наши старые знакомые с двача, и всё идет по бороде. Опять затроллили нашу голосовалку =(

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

Шаг 4. Максимально усложняем жизнь троллям. Голосовать только по подтверждению или через смс. А при чем тут веб?

Давай сделаем обвязку, чтобы проголосовать, прикрутим какой-нибудь сервис по рассылке смс, что-то типа двухфакторной аутентификации, всё вышеописанное + верификационное смс. Правда, лично меня такие темы отталкивают, чтобы проголосовать нужно куда-то сплавить свой номер телефона? Шта?! Тролли, скорее всего, отсеются, а нормальные голоса пройдут.

Фух! Кажется мы победили, если людям не лень регистрироваться в системе, светить свой телефон, то их голоса очень релевантны, но постой, а где вообще голоса? Почему никто не голосует?!

А это цена устойчивости системы от накрутки. Вялотекущее или вообще никуда не текущее голосование. Никто не любит сложности, а уж сложности в опросе «какой ваш любимый цвет» сложности обрекают на забвение.

История третья: из-за кулис

История выдумана, но посыл вполне реален.

Вот приходит заказчик с просьбой сделать сайт и добавить туда опрос. Сайт — просто блог. Плагинов опросов — мильён и маленькая тележка. Берем и ставим самый популярный по запросу «Модуль голосования для…». Заказчик выкатывает голосование: «Какую прическу мне сделать?» с вариантам: растить волосы полгода, побриться под ноль, заплести дреды. А результат голосования должен был стать наказанием за проигранное пари. И неожиданно, все начали голосовать за дреды. Но наш (почему наш? Не, не наш, а вымышленный, да) заказчик не хочет морочиться с дредами, а хочет растить волосы. И теперь он идет к разработчику, либо сам лезет в базу, чтобы поправить результаты.

Что? Честность? Не, не слышал.

Итоги

Очень хочется подытожить всё фразой из другого моего поста про защиту сайтов от взлома. Как сделать честное голосование — никак. Мы живем в таком мире, где честность недостижимая мечта, нужно просто принять этот факт и постараться не нервничать по этому поводу. Это не избавляет от необходимости делать качественный продукт, который защитит от мелкого хулиганства и любопытных глаз. Да и вообще, делать всё хорошо — хорошая привычка.

Всем рок! И еще раз с с днем рождения нас =)

Да я тебя по ip вычислю! Геолокация пользователей

Вступление

Сейчас будет неожиданное вступление!

Приготовились? (Дальше каждое слово нужно читать через вдох, чтобы придать глубокий смысл сказанному)

Однажды… Поступил… Заказ… =))

В общем, я не смог придумать оригинальное вступление =)

Мы работаем с разными клиентами, одни приходят и уходят, другие остаются с нами надолго. Вот один из последних к нам и обратился в очередной раз. Его бизнес по продаже некоторого метрологического и измерительного оборудования раскинулся на несколько регионов нашей необъятной страны. И как он сам выразился: «В нашем бизнесе есть менталитет звонить в тот регион, в котором находится заказчик». Таким образом появилась необходимость модуля, который бы определял географию пользователя и в зависимости от этих данных выводил в контактах региональный телефон, ближайший к пользователю.

Что нужно для решения поставленной задачи?

Я решил для начала определить круг необходимостей для задачи:

  1. Мне нужно определять координаты пользователя
  2. Мне нужно как-то связывать координаты пользователя и регион
  3. Мне нужно в зависимости от этого региона выбрать нужный номер телефона

Каждая из частей может быть решена несколькими способами, я попробую описать свой ход мыслей, а ты постарайся не отставать =)

Определение координат пользователя

Если попробовать поискать по такому запросу, то первые варианты отправят тебя на базы ip адресов. На самом деле, это хороший вариант, если не нужна высокая точность. Правда стоит оговориться, «невысокая» может быть очень сильно невысокой. Например, я живу в Муроме, а мой ip чаще всего приписывают Владимиру, т.е. ошибка в 100+ км — это нормальное явление.

Лично мне понравилась реализация базы от этих парней. У них есть несколько постов на хабре, реагируют на обратную связь, предлагают библиотеки для разных языков. В общем, всё круто. Эту базу и решил взять в качестве отправной точки. Не вижу смысла рассказывать как с ней работать, в примерах на сайте всё хорошо расписано.

Хмм.. А что делать если нужна точность выше?

Самым очевидным решением мне показалось использование HTML5 geolocation API. Все современные браузеры (или большинство из них) поддерживают эту возможность, но есть ряд особенностей:

  1. Пользователь должен явно разрешить передавать данные своей геолокации. Думаю, все видели такое окно «разрешить сайту использовать/передавать данные о локации» или как-то так. Это может пугать.
  2. Вытекающее следствие из первого пункта: пользователь может отказаться от передачи своих данных, т.е. использовать только geolocation API плохой вариант.
  3. Google Chrome требует защищенного протокола для передачи информации. Ну это ssl-сертификат, ну такой https, зелененький в адресной строке. Конечно, не проблема поставить бесплатный сертификат. Или проблема?

Меня особенно волновал последний пункт, пользовательский сайт хостится на nic’е, а там только платные сертификаты устанавливаются легко, а вот с Let’s encrypt (ты еще не знаешь, что там можно получить бесплатный ssl сертификат?! Тогда мы идем к тебе!) что-то не очень они дружат, либо я плохо искал. В общем, мне нужно заставить работать HTML5 геолокацию по обычному протоколу. На помощь приходит яндекс API, точнее яндекс.карты. При этом совершенно не обязательно размещать саму карту на странице, она мне не нужна, можно просто определять координаты пользователя.

Самый простейший вариант выглядит так

function get_location_in_browser() {
	ymaps.geolocation.get({
		provider: 'auto'
	}).then(function ( result ) {
		// Где пользователь
		var pos = result.geoObjects.position;

		console.log('lat = ', pos[0]);
		console.log('lon = ', pos[1]);
	});
}

ymaps.ready(get_location_in_browser);

Параметр provider отвечает за метод определения координат пользователя, он так же может работать по ip (по данным яндекса, так в документации написано) или встроенная браузерная (на сколько я понял это и есть HTML5 geolocation). Значение авто выбирает лучший вариант, как он это делает, не знаю, не спрашивай.

Координаты пользователя получили. Ю-ху! Я думал, что это 70% задачи, но ошибся в процентовке. Теперь надо как-то связать координаты пользователя и некий региональный телефон, только количество регионов может быть неопределенно много. Как быть?

Координаты региональных телефонов

Когда размышлял над задачей в голове возникла гениальная мысль, что для связи координат пользователя и строки «Москва» как минимум нужны координаты этой самой Москвы. Но как же определить координаты имея строку? И снова к нам приехал яндекс со своими сервисами, а именно «Геокодирование». Берем строку Москва, и отправляем ее по адресу:

https://geocode-maps.yandex.ru/1.x/?geocode=Москва

В ответ получаем xml ответ, но мне больше нравится json вариант ответа

https://geocode-maps.yandex.ru/1.x/?format=json&geocode=Москва

Сначала для получения ответа использовал file_get_contents, но на боевом сервере он что-то не заработал, какие-то траблы с ssl, поэтому использовал curl

/**
 * Геокодирование строки
 * 
 * @param string $name
 *
 * @return array
 */
function get_goecode_position($name = '')
{
	$ch = curl_init();
	curl_setopt($ch, CURLOPT_URL, 'https://geocode-maps.yandex.ru/1.x/?format=json&geocode=' . $name);
	curl_setopt($ch, CURLOPT_HEADER, 0);
	curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
	$region_position = json_decode(curl_exec($ch));
	curl_close($ch);
	
	$region_position = explode(' ',
		$region_position->response->GeoObjectCollection->featureMember[0]->GeoObject->Point->pos);
	
	return array(
		'latitude'  => $region_position[1],
		'longitude' => $region_position[0]
	);
}

Прикол еще в том, что в ответе может быть не один результат, но как правило первым идет наиболее релевантный вариант, поэтому лучше брать его. Но крутость сервиса в том, что можно указать точный адрес, например, Муром, Комсомольская 72, и на выходе получим координаты нашего офиса, это мега круто! Например, если будем говорить в рамках текущей задачи, нужно указать несколько телефонов для одного города. Мысль понятна? Чудо, не правда ли? =)

Теперь у меня есть n+1 точка на карте, где n это все региональные адреса на карте, а +1 координаты пользователя. Едем дальше.

Как связать координаты пользователя и региональные телефоны?

Поупражняемся в математике.

Как связать 2 точки? Провести линию. Как определить номер телефона необходимый пользователю? Провести линии от пользователя до всех региональных телефонов и найти кратчайшее расстояние из полученных. На плоскости в общем случае задача решается теоремой пифагора, но у нас не совсем плоскость и точки, не совсем точки а координаты. Т.е. нам нужно учитывать что расстояние это длинна дуги. Сам осилишь формулу написать? Вот и я не стал заморачиваться, хотя, думаю, если бы поразмышлять пару часиков формулу можно было бы получить. В общем великий интернет дал мне необходимое решение в виде функции.

/**
 * Расстояние между двумя точками
 *
 * Михаил Кобзарев
 *
 * @param $φA - широта, долгота 1-й точки,
 * @param $λA - широта 1-й точки,
 * @param $φB - широта 2-й точки,
 * @param $λB - долгота 2-й точки,
 *
 * @return float
 */
function calculateTheDistance($φA, $λA, $φB, $λB)
{
	$rad = 6372795; // ~Радиус земли
	
	// перевести координаты в радианы
	$lat1  = $φA * M_PI / 180;
	$lat2  = $φB * M_PI / 180;
	$long1 = $λA * M_PI / 180;
	$long2 = $λB * M_PI / 180;
	
	// косинусы и синусы широт и разницы долгот
	$cl1    = cos($lat1);
	$cl2    = cos($lat2);
	$sl1    = sin($lat1);
	$sl2    = sin($lat2);
	$delta  = $long2 - $long1;
	$cdelta = cos($delta);
	$sdelta = sin($delta);
	
	// вычисления длины большого круга
	$y = sqrt(pow($cl2 * $sdelta, 2) + pow($cl1 * $sl2 - $sl1 * $cl2 * $cdelta, 2));
	$x = $sl1 * $sl2 + $cl1 * $cl2 * $cdelta;
	
	// Вычисление расстояния
	$ad   = atan2($y, $x);
	$dist = $ad * $rad;
	
	return round($dist);
}

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

Казалось бы, задача решена, остается все это обернуть в модуль/плагин/расширение и всё готово. Но нужно вернуться к ТЗ. Было сказано, что нужно определять регион пользователя и отдавать ему нужный телефон, в случае, если его региона нет, то выводить телефон центрального офиса. Что это значит? Например, некто зашел на сайт его позиция определилась как Владивосток, и ближайший к нему регион Екатеринбург, а это всего-то 5000 километров, технически, конечно, это ближайший регион, только вот чтобы обсудить детали заказа, клиент не сможет подъехать в офис после обеда. Таким людям надо показывать телефон центрального офиса в Москве.

Соответственно, нужно указывать радиус действия регионального телефона. Собственно, как слышу так и пишу. В настройки добавил еще радиус действия телефона. Если координаты пользователя и регионального телефона попадают в радиус действия этого телефона, то кладем этот телефон и его расстояние в некоторый словарь, а потом выбираем из него телефон с минимальным расстоянием. Profit!

В моем случае настройки получились такими

Так как браузерная геолокация не всегда необходима, и требует дополнительные ресурсы на выполнение себя, решено было добавить настройку, которая будет включать её.

В самых общих чертах задача решена =) Можно, наверное, докрутить еще какие-нибудь фишечки, но я не хочу.

Итоги

Думаю, теперь ты понял почему фраза «Я тебя по ip вычислю» не имеет под собой основания. Вероятно, когда ip’шников было 3 на город, такой фразой можно было пугать.

Чем дальше в лес тем меньше надо думать, всё уже придумано и реализовано до нас. Это я про формулу длины дуги на поверхности шара. Это удобно, но мозг дело такое, чем меньше ты его включаешь, тем меньше он у тебя становится. Не забывай книжки читать. Понял? Какую бы еще мудрость тебе открыть? Не ешь после 18:00, чисти зубы утром и вечером, не обижай младших. Хотя на счет последнего, есть что обсудить, у меня есть младший брат.

Ах, да! Про программирование =) Задача решена, обернул всё это в плагин для WP (Ой, да ладно! Уже даже не интересно слушать про то, что WP — гумно. Всё у него хорошо. И заметь про WP ты узнал только в выводах ;) )

Всем рок!

Как защитить сайт от вирусов и взлома

Введение

Этот будет пост-рассуждение на тему как защитить сайт от взлома и вирусов, больше просто поговорить и совсем немного кода и ссылок, а может и вообще без кода и ссылок. Как говорит мой отец перед началом любого дела: «как пойдет». Если не хочется читать мои художественные упражнения, то предысторию можно смело пропускать.

Предыстория

Одним прекрасным утром мальчик Ильдар получил от партнера сообщение, примерно следующего содержания: «Ильдар, у нас на mdd опять какая-то фигня, левые посты и с мобильников идет редирект». Говно-вопрос, подумал Ильдар, сейчас посмотри что к чему, не первый раз, скорее всего надо просто базу немного подчистить, и htaccess поправить. Открыл свой любимый FTP-менеджер, сконектился с хостингом, где лежит проблемный сайт, поправил всё что хотел и история закончилась.

Вот и всё! Можете расходиться, продолжения не будет =)

Ладно-ладно. Всё только началось, поскольку о себе в третьем лице писать как-то странно, дальше от первого лица.

Я отписался товарищу, мол так и так, работа выполнена, пароли сменил, должно быть хорошо. В ответ получаю сообщение, что не всё хорошо, потому что редирект продолжается. Лезу смотреть htaccess, а он снова изменен. Пошел смотреть чуть глубже и обнаружил, что где-то в недрах WordPress лежит левая папка и чего-то себе там запускает. Эмм?! К тому моменту я уже был смекалистый, пошел посмотреть какие еще файлы были изменены недавно. Надоело ходить вокруг да около. На хостинге лежало чуть больше двух десятков сайтов на wp, еще несколько на других движках, и все они были заражены, не только файлы но и базы. Помнишь звук сообщений в аське? (Мессенджер такой ICQ, который был куплен mailru. Правда википедия говорит, что это сервис, а не мессенджер, но не суть) «О-оу!» — промчалось в моей голове. Свой инженерский диплом и кандидатскую начинал писать на тему всяких интерполяций и экстраполяций различных функций и сигналов, короче, считать немного умею: на один сайт я потратил примерно полчаса, а на 25? Соответственно 12,5 часов. «Нехорошо», — подумал я…

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

Как защитить сайт

Никак.

Это самая главная мысль. Если её не усвоить, то всё дальнейшее боль и страдание.

Хотя есть один способ, записывай:

  1. нужна большая флешка.
  2. нужно сайт скачать на эту флешку
  3. Положить флешку в банковскую ячейку.
  4. Profit!

Во всех других случаях, я не настаиваю, можешь попробовать сам пройти весь путь, но (!) Пройдемся по тем пунктам, которые рекомендуют многие ресурсы:

  1. Установите сложный пароль на админку. Сделано. Дыра в движке и сложный пароль никому не нужен, тупо забыл об экранировании символов в форме комментариев и какой-нибудь умник всё доделает за тебя. Допустим у нас нет дыр в движке.
  2. Установите сложный пароль на хостинг-панель. Сделано. «Ой, база паролей утекла в сеть, мы рекомендуем сменить ваш пароль и приносим свои искренние извинения»
  3. Запрети изменение файлов. Если кто-то ломает хостинг или сервер, то уж что-что, а поменять права не проблема.
  4. И еще стотыщмильёнов вариантов защиты

Все они так или иначе ломаются. Если нельзя сделать защиту на 100%, что делать?

Профилактика и своевременное обнаружение

Нужно обновлять свой сайт, не очень понимаю людей, которые установили 3 года назад движок и не обновляли его. Дыры находятся и закрываются. Это профилактика. Все сайты на поддержке и внутренние мы обновляем. По крайней мере, стараемся.

И самое главное — нужно вовремя увидеть проблему. Лично я, после того случая с сервером mdd, написал небольшой скриптик на php, который положил в cron, и каждые 2 часа он проверяет дату последнего изменения файлов на хостинге, в случае обнаружения изменений мне приходит сообщение со списком измененных элементов. Сначала я придерживался политики белых списков, т.е. проверял только те файлы, которые меня интересовали, но потом решил, что это слишком халявно и перешел на политику черных списков, проверяю всё кроме картинок. Если кому интересно, скрипт, конечно же, выложил на гитхаб.

Это мой основной инструмент мониторинга. Правда есть один раздражающий момент. WP умеет обновляться автоматически, и вот выходит новый баг-фикс и с нескольких серверов началась гирлянда сообщений, которые еще могут тянуться пару дней.

Бэкапы

Наше всё. Во-первых, это в принципе очень хорошая практика, а во-вторых, если заражение фатально, то поднять не очень старый бэкап может быть единственным способом быстро восстановить работоспособность сервиса или сайта. Про бэкапы уже много написано и без меня,но на почве последних падений хостинга, где размещается наш сайт, еще раз напомню, что хранить бэкапы очень желательно на отдельном носителе, само правило звучит так: не храни бэкапы там же где лежит основной сайт — это бессмысленно, сыпется жесткий, умирает SSD и доступы пропадают ко всему и к боевой версии и к бэкапам. Заведи для себя правило, раз в месяц забирай полный бэкап куда-нибудь себе, например в облака. Думаю, что копия месячной давности лучше, чем вообще без копии. Я понимаю, что сайтов много и все их бэкапить вручную долго, но тыжпрограммист, придумай способ автоматизировать процесс. Если придумаешь, напиши мне, я еще не придумал =)

Выводы

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

И еще один примечательный момент, если тебя ломают, значит ты кого-то заинтересовал, а это успех!

Всем рок!

Работа с MailChimp API. Часть вторая

Вступление

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

Постановка задачи

Нужно чтобы рассылка уходила по предпочтениям пользователя и его географическому положению, предпочтения включают специализацию, а география — это город или возможность удаленной работы. iOS-разработчики из Бобруйска должны получать ios вакансии Бобруйска, а любители удаленной работы должны получать вакансии, где указана возможность удаленного сотрудничества.

Решение

Пока изучал варианты решения первой части поставленной задачи, наткнулся на пост на хабре, где автор рассказывал про сегменты и рассылку по сегментам, реализованную через MailChilm API. Был только один нюанс, он писал свой код в 2012 году, и версия API была 1.3, а сейчас уже 3.0. Там значительно всё изменилось, но некоторая обратная совместимость сохранилась, по крайней мере идеологическая =)

Основная мысль вот в чем: помимо классических листов рассылки в MailChimp можно создавать дополнительные фильтры (сегменты) по листу, при этом сегмент может быть создан по различным признакам, например, все пользователи, которые добавились в рассылку до определенной даты или после, или пользователи email которых находится на gmail.com, или пользователи с именем Александр. Вот за эту идею я и решил ухватиться.

Несмотря на то, что документация по API структурно написана достаточно интуитивно, но вот с примерами там есть некоторая беда, в примерах дается только минимально необходимый набор параметров. Т.е. если для создания компании необходимо передать только html-текст, plain-текст и id листа рассылки, то в примере будут обозначены только эти параметры, а то, что метод может принимать еще вагон дополнительных необязательных параметров, конечно, сказано, но как это сделать не всегда бывает очевидно. Поэтому многое решалось экспериментами.

Несмотря на то, что сегменты могут быть построены по огромному количеству параметров, я не нашел способа добавлять свои атрибуты, т.е. никакое дополнительное кастомное поле создать для пользователя нельзя, что меня несколько огорчило, это было бы самое крутое решение. Тогда я пошел по пути наименьшего сопротивления. При добавлении подписчика дополнительно передаются 2 поля FNAME и LNAME, куда, как логично предположить, должны быть вписаны имя и фамилия, но мы для AppJobs используем только поле FNAME, поэтому LNAME я решил задействовать для собственных нужд.

Мне нужны фильтры! Следовательно в поле LNAME буду писать некоторую служебную информацию. На сайте AppJobs введены специализации: ios-разработчик, andriod-разработчик, дизайнер и т.д — а так же регион в формате страна+город и удаленная работа. Соответственно, первой реализацией фильтров было писать в поле фамилии желаемую специализацию подписчика и регион отдельными словами (забегая вперед: сегменты поддерживают выборку по включению определенного слова), например, ios Россия-Саратов удаленно. Осталось научиться создавать сегменты.

В интерфейсе MailChimp это делается в разделе листов рассылки. Нужно перейти к одному из листов и там в верхней панели выбрать создание сегмента

Раз уж заговорили про ios-разработчика из Саратова, то попробуем создать сегмент для него

Тут все очевидно, нужно чтобы фильтр соответствовал обоим параметрам, а именно, содержал в поле фамилии слово ios и Россия-Саратов.  Contacts match может принимать 2 состояния all и any, т.е. совпадение со всеми правилами или с любым из перечисленных. Нам, конечно нужно жесткое соответствие обоим правилам. После кнопки Preview Segment, в следующем окне будут отображены подписчики, которые удовлетворяют заданным условиям, не забываем сохранить сегмент, если это нужно. Технически, можно все сегменты создать руками, если их не очень много, а потом на стороне сайта обрабатывать их id, но в моем случае сервис ориентирован на все страны СНГ, а это уже сильно больше «нескольких сегментов» — тысячи городов в каждой стране. Вариант создавать сегменты руками — не вариант. Поэтому нужны методы API.

Для создания сегментов существует post метод, который в качестве параметров принимает имя и правила сегментации. Я написал в своем классе обертку для этого метода

/**
 * Создание сегмента
 *
 * @param string $name
 * @param string $list_id
 * @param array  $cond
 *
 * @return mixed
 */
public function createSegment($name = '', $list_id = '', $cond = array())
{
	$data = array('name' => $name, 'options' => $cond);
	
	$res = $this->request('lists/' . $list_id . '/segments', 'post', $data);
	
	return $res;
}

Для нашего случая, правило $cond должно содержать массив

$cond = array(
	'match'      => 'all',
	'conditions' => array(
		array(
			'condition_type' => 'TextMerge',
			'field'          => 'LNAME',
			'op'             => 'contains',
			'value'          => 'ios'
		),
		array(
			'condition_type' => 'TextMerge',
			'field'          => 'LNAME',
			'op'             => 'contains',
			'value'          => 'Россия-Саратов'
		)
	)
);

А если нужны те, кто готов работать удаленно, то правило примет такой вид

$cond = array(
    'match'      => 'all',
    'conditions' => array(
        array(
            'condition_type' => 'TextMerge',
            'field'          => 'LNAME',
            'op'             => 'contains',
            'value'          => 'ios'
        ),
        array(
            'condition_type' => 'TextMerge',
            'field'          => 'LNAME',
            'op'             => 'contains',
            'value'          => 'удаленно'
        )
    )
);

Правила создавать научился. Потестировал создание и отправку рассылок по сегментам несколько раз и наткнулся на багулю. Первый раз рассылка нормально отправляется по сегментам, а второй и последующие чего-то никак. Посмотрел в логи и обнаружил, что при попытке создать сегмент с уже существующим именем, MailChimp возвращает ошибку, т.к. не может пересоздать. Имена я, конечно, создавал типовые, чтобы не плодить сегменты с одинаковыми правилами, но разными заголовками. Вообще, мне было странно такое поведение, я ожидал, что при попытке создать уже существующий сегмент, API мне вернет старый id.

Ну да ладно, раз не хочешь создавать, то давай мне верни по имени сегмента его id. Только вот незадача, он так не умеет, от слова вообще. Можно получить информацию о сегменте по его id, а по имени нельзя получить инфу. Жаль, придется изобрести что-то другое.

Посмотрев немногочисленные методы для работы с сегментами придумалось следующее, забираем из mailchimpa список всех доступных сегментов, пихаем это всё в одномерный массив, где ключ равен id сегмента, а значение равно имени, а потом перед созданием нового сегмента с нашей стороны, проверяем наличие его имени в уже созданных. В общем в класс добавился еще один метод работы с АПИ

/**
 * Получить все сегменты в листе
 *
 * @param string $list_id
 *
 * @return array
 */
public function getListSegments($list_id = '')
{
	$segments = $this->request('lists/' . $list_id . '/segments', 'get');
	
	$res = array();
	
	foreach ($segments->segments as $segment_itm) {
		$res[$segment_itm->id] = $segment_itm->name;
	}
	
	return $res;
}

Вот теперь в классе AppjobsMailchimp есть все необходимые методы, чтобы наша рассылка заработала.

И так: получаю новую вакансию, собираю для этой вакансии сегменты и… Эй! А как прицепить несколько сегментов к одной компании?! Что? Нельзя чтоль? Что за бред?!

Пятиминутка театральной пантомимы окончена.

Я в очередной раз немного лажанул. Оказалось, что к компании нельзя прикрепить несколько сегментов. Я думал, что можно будет просто указать id’шники сегментов, которые мне нужны и оправить рассылку, но в глубине души я подозревал, что такой исход возможен, но надеялся на благополучный исход. Не срослось.

Было принято решение изменить логику набора ключевых слов следующим образом: теперь одним куском будет идти специализация и регион. То есть если раньше были отдельные слова, например, наш мобильный разработчик, который пишет на ios и хочет работать удаленно или в офисе в Саратове, а еще он стал писать под android, поле фамилии будет содержать такую строку: ios android удаленно Россия-Саратов. А теперь буду писать так: ios-удаленно android-удаленно ios-Россия-Саратов android-Россия-Саратов. Немного громоздко, зато одной выборкой можно собрать сегмент для ios разработчиков которые могут работать в офисе в Саратове или удаленно.

$cond = array(
    'match'      => 'any',
    'conditions' => array(
        array(
            'condition_type' => 'TextMerge',
            'field'          => 'LNAME',
            'op'             => 'contains',
            'value'          => 'ios-удаленно'
        ),
        array(
            'condition_type' => 'TextMerge',
            'field'          => 'LNAME',
            'op'             => 'contains',
            'value'          => 'ios-Россия-Саратов'
        )
    )
);

Соответственно, то что раньше требовало 2 различных сегмента, теперь выбирается одним, но с правилом match=any.

И в общем случае скрипт рассылки будет содержать такой код:

// Получим все уже созданные сегменты для листа $list_id
$access_segments = $chimp->getListSegments($list_id);

// Попробуем найти новый сегмент среди созданных, если нет, создадим его
$segment_id = array_search($new_segment_name, $access_segments);
if ($segment_id === false) {
    $new_segment = $chimp->createSegment($new_segment_name, $list_id, $new_segment_cond);
    $segment_id  = $new_segment->id;
}

// Создем компанию
$res = $chimp->createCamping($list_id, $email_subject, $from_name, $reply_email, $segment_id);

$camp_id = $res->id;

// Добавлем к ней контент
$res = $chimp->createCampingContent($plain, $html, $camp_id);

// Отправка рассылки
$res = $chimp->sendCamping($camp_id);

Итог

Наконец-то задача, которую подразумевал заказчик выполнена, теперь точно =) Созданный класс все так же лежит на гитхабе. Надеюсь он пригодится не только мне)

Всем рок!

Это я, почтальон Печкин, принес заметку… Работа с API MailChimp

Вступление

Когда-то давно мы всем офисом sawtech выехали на хакатон. По итогам этого действа с моей стороны был создан сайт размещения вакансий AppJobs. Мне было за него немного стыдно, он был немного корявый, неоптимизированный, с плохой структуруой, его по идее нужно было писать либо на frameworke, либо на голом php, но я умел только wordpress. Но во-первых, если ты вышел на рынок с продуктом, за который тебе не стыдно, значит ты поздно вышел на рынок (мопед не мой, я только объяву дал. Кому принадлежит эта цитата не помню), А во-вторых, сейчас я уже не комплексую по поводу WP, если ты хорошо умеешь работать топором, и с его помощью сможешь вырезать скульптуру, то почему бы и нет?

Хакатон был «заказной», всё что там создавалось, создавалось не совсем для себя, а для нашего хорошего знакомого и партнера. В общем, сайтик был сделан, мы его потом еще немого допиливали, а потом благополучно забили. По размещенным вакансиям видно, что он не очень живой, хотя какие-то люди что-то на нем размещали.

И вот с месяц назад, наш старый друг пишет письмо: «А давайте мы его реанимируем, и начнем с email подписки на вакансии?» Почему бы, собственно, и нет?

Постановка задачи

Забегая вперед: всегда, всегда(!) составляй письменное ТЗ до того как начал работать, даже с друзьями, даже если работаешь с мамой.  Чтобы потом не случилось «ой». В данном случае ТЗ я получил в следующем виде: нужно сделать подписку на новые вакансии, чтобы когда добавиляется вакансия, автоматически уходила рассылка. К такому заданию была приложена картинка

Что я понял из постановки задачи:

  1. Нужна форма подписки на email рассылку
  2. нужна реализация самой рассылки
    1. Подписка пользователей
    2. Возможность отписаться
    3. Собственно сами email сообщения

Вот как-то так.

Реализация

Т.к. из предпочтений заказчик высказал варианты использования MailGun и MailChimp, то я сразу пошел гуглить API и того и другого. Вариант MailGun мне не нравился, потому, на сколько я понял, сервис предоставляет шлюз рассылки большого объема сообщений, это хорошо, но при этом всю процедуру подписки, отписки, и управления подписчиками, придется делать самому — это минус.

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

Поясню. Я думал, что в mailchimp есть листы рассылки, и есть письма. Можно зайти в лист и создать для него новый email, либо наоборот, создаем email и указываем ему по какому листу рассылаться. А со стороны API я это представлял примерно так:

  1. добавление пользователей — это один запрос (так оно и оказалось).
  2. новая email рассылка — тоже один запрос, набрал email, в запросе указал с каким листом работать и отправил на сервер mailchimpa.

Со вторым я крупно ошибся, и по этой причине долго ковырялся. В понятиях MailChimp есть такая штука как Campaigns (компания), типа рассылочной компании. У меня почему-то ассоциации с предвыборной или агитационной компанией =) Таким образом сначала я должен создать компанию, выбрать кучу параметров, в том числе шаблон письма, с каким листом оно будет работать, и только после всех настроек можно запустить компанию в работу, т.е. разослать email’ы.

Но пойдем по порядку. Чтобы работать с API нужно получить ключ доступа, сейчас это находится в разделе Profile → Extras → API keys, к тому же, из особенностей, все запросы отправляются не на единый url, а на определенные поддомены значение которых соответствует поддомену админ-панели, проще картинкой объяснить

А адрес запросов равен https://<dc>.api.mailchimp.com/3.0/, где вместо <dc> у меня будет us15.

Подписать человека на рассылку, простейший вариант:

/**
 * Добаление подписчика
 *
 * @param string $list
 * @param string $email
 * @param string $name
 */
function addSubscriber($list = '', $email = '', $fname = '', $lname = '')
{
	$data = array(
		'email_address' => $email,
		'status'        => 'pending',
		'merge_fields'  => array('FNAME' => $fname, 'LNAME' => $lname)
	);
	
	$res = $this->request('lists/' . $list . '/members', 'post', $data);
	
	$return = 'На указанную почту придет письмо с подтвержением подписки.';
	
	if ($res->status == 400) {
		switch ($res->title) {
			case 'Member Exists':
				$return = 'Вы уже подписались ранее';
				break;
			
			default:
				$return = $res->title;
				break;
		}
	}
	
	return $return;
}

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

А теперь опишу функции, которые умеют создавать компанию, и отправлять рассылки

<?php

/**
 * Создание компании
 *
 * @param string $list_id
 * @param string $subj
 * @param string $from_name
 * @param string $reply_to
 *
 * @return mixed
 */
public function createCamping($list_id = '', $subj, $from_name, $reply_to)
{
	$data = array(
		'type'       => 'regular',
		'recipients' => array('list_id' => $list_id),
		'settings'   => array(
			'subject_line' => $subj,
			'reply_to'     => $reply_to,
			'from_name'    => $from_name
		)
	);
	
	$res = $this->request('campaigns', 'post', $data);
	
	return $res;
}

/**
 * Создание текста для компании
 *
 * @param $html
 * @param $id_camping
 *
 * @return mixed
 */
public function createCampingContent($plain, $html, $id_camping)
{
	$data = array('plain_text' => $plain, 'html' => $html);
	
	$res = $this->request('campaigns/' . $id_camping . '/content', 'put', $data);
	
	return $res;
}

/**
 * Отправка компании
 *
 * @param $id_camping id компани
 */
public function sendCamping($id_camping = '')
{
	$res = $this->request('campaigns/' . $id_camping . '/actions/send', 'post');
	
	return $res;
}

Из вышеописанных функций можно сделать вывод, что при создании компании нельзя сразу создать контент для неё, по крайней мере, я не нашел этого в документации. Поэтому нужно отдельным запросом отправлять контент. Кстати, если попробовать изменить уже разосланную компанию, то вернется ошибка, т.к. политика MailChimp этого не позволяет, что мне показалось странно. Из этого же следует, что каждая новая рассылка будет создавать новую компанию. Вот такая вот странная политика.

Работать эти функции будут примерно так

$res = createCamping($list_id, 'Новая вакансия на AppJobs.ru', 'AppJobs', 'from@email.domain');

// Получим id новой компании
$camp_id = $res->id;

// Добавлем к ней контент
$res = createCampingContent($plain, $html, $camp_id);

// Отправка рассылки
$res = $chimp->sendCamping($camp_id);

Я, будучи крутым веб-разработчиком, естественно пожелал обернуть всё это в ООП вид, в итоге у меня получился такой класс:

Класс работы с MailChimp API

<?php


/**
 * Класс для работы с АПИ
 *
 * Class AppjobsMailchimp
 */
class AppjobsMailchimp
{
	
	/**
	 * АПИ ключь
	 *
	 * @var
	 */
	protected $api_key;
	
	/**
	 * Адрес запроса к апи
	 *
	 * @var string
	 */
	protected $url = 'https://<dc>.api.mailchimp.com/3.0/';
	
	/**
	 * AppjobsMailchimp constructor.
	 *
	 * @param $key
	 * @param $dc
	 */
	public function __construct($key, $dc)
	{
		$this->api_key = $key;
		$this->url     = str_replace('<dc>', $dc, $this->url);
	}
	
	
	/**
	 * Выполенение запроса к АПИ
	 *
	 * @param string     $method метод запроса
	 * @param array|bool $data   Данные
	 *
	 * @return mixed
	 */
	protected function request($method = '', $type = 'post', $data = false)
	{
		$url = $this->url . $method;
		
		$ch = curl_init();
		curl_setopt($ch, CURLOPT_URL, $url);
		curl_setopt($ch, CURLOPT_HTTPHEADER, array(
			'Accept: application/vnd.api+json',
			'Content-Type: application/vnd.api+json',
			'Authorization: apikey ' . $this->api_key
		));
		curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
		
		switch ($type) {
			case 'post':
				curl_setopt($ch, CURLOPT_POST, true);
				if (is_array($data)) {
					curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data));
				}
				break;
			
			case 'get':
				$query = http_build_query($data, '', '&');
				curl_setopt($ch, CURLOPT_URL, $url . '?' . $query);
				break;
			
			case 'delete':
				curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'DELETE');
				break;
			
			case 'patch':
				curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'PATCH');
				if (is_array($data)) {
					curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data));
				}
				break;
			
			case 'put':
				curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'PUT');
				if (is_array($data)) {
					curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data));
				}
				break;
		}
		
		$out = curl_exec($ch);
		curl_close($ch);
		
		return json_decode($out);
	}
	
	
	/**
	 * Получить список доступных листов рассылки
	 *
	 * @return array|mixed|object
	 */
	public function getLists()
	{
		return $this->request('lists', 'get');
	}
	
	/**
	 * Добаление подписчика
	 *
	 * @param string $list
	 * @param string $email
	 * @param string $name
	 */
	public function addSubscriber($list = '', $email = '', $name = '')
	{
		$data = array(
			'email_address' => $email,
			'status'        => 'pending',
			'merge_fields'  => array('FNAME' => $name, 'LNAME' => '')
		);
		
		$res = $this->request('lists/' . $list . '/members', 'post', $data);
		
		$return = 'На указанную почту придет письмо с подтвержением подписки.';
		
		if ($res->status == 400) {
			switch ($res->title) {
				case 'Member Exists':
					$return = 'Вы уже подписались ранее';
					break;
				
				default:
					$return = $res->title;
					break;
			}
		}
		
		return $return;
	}
	
	/**
	 * Списко компаний
	 *
	 * @return mixed
	 */
	public function getCampaigns()
	{
		$res = $this->request('campaigns', 'get');
		
		return $res;
	}
	
	
	/**
	 * Создание компании
	 *
	 * @param string $list_id
	 * @param string $subj
	 * @param string $from_name
	 * @param string $reply_to
	 *
	 * @return mixed
	 */
	public function createCamping($list_id = '', $subj, $from_name, $reply_to)
	{
		$data = array(
			'type'       => 'regular',
			'recipients' => array('list_id' => $list_id),
			'settings'   => array(
				'subject_line' => $subj,
				'reply_to'     => $reply_to,
				'from_name'    => $from_name
			)
		);
		
		$res = $this->request('campaigns', 'post', $data);
		
		return $res;
	}
	
	/**
	 * Создание текста для компании
	 *
	 * @param $html
	 * @param $id_camping
	 *
	 * @return mixed
	 */
	public function createCampingContent($plain, $html, $id_camping)
	{
		$data = array('plain_text' => $plain, 'html' => $html);
		
		$res = $this->request('campaigns/' . $id_camping . '/content', 'put', $data);
		
		return $res;
	}
	
	
	/**
	 * Отправка тестового письма для компании
	 *
	 * @param string $id_camping компании
	 * @param string $email      куда отправлять тестовое сообщение
	 *
	 * @return mixed
	 */
	public function testCamping($id_camping, $email = '')
	{
		$data = array('test_emails' => array($email), 'send_type' => 'html');
		
		$res = $this->request('campaigns/' . $id_camping . '/actions/test', 'post', $data);
		
		return $res;
	}
	
	
	/**
	 * Отправка компании
	 *
	 * @param $id_camping id компани
	 */
	public function sendCamping($id_camping = '')
	{
		$res = $this->request('campaigns/' . $id_camping . '/actions/send', 'post');
		
		return $res;
	}
	
	/**
	 * Получить список доступных шаблонов
	 *
	 * @return mixed
	 */
	public function getTemplates()
	{
		$res = $this->request('templates/', 'get');
		
		return $res;
	}
}

Для WP я еще сделал обертку в виде плагина, который использует созданный класс. Пока не буду приводить код, т.к. он заточен под конкретный сайт, когда-нибудь потом я его пересоберу в более универсальный вариант и выложу.

Итог

Поставленную задачу я решил, но есть одно но. Когда я отправил заказчику результат работы, он сказал так: «Круто! Только я хотел, чтобы можно было подписываться на конкретный раздел вакансий, а не на любую. Потому что дизайнеры не хотят получать вакансии разработчика». Дальше были мои непечатные мысли. Но я уже знаю, что у MailChimp есть какая-то штука — сегменты. Вот в сторону сегментов я и буду дальше рыть. Не переключайтесь, после рекламы мы продолжим!

Всем рок!

Крадущийся тигр, затаившийся дракон. Плагин для спойлеров wordpress

Вступление

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

Матчасть и реализация

Скажу честно, на тот момент я не очень активно искал готовые варианты, т.к. было интересно поковырять и сделать всё самому. Конечно уже всё давно создано, только думаю, что готовые плагины нагрузят функциями так, что они еще и носки постирали, а мне этого было не нужно. В wordpress есть интересный механизм шорткодов, это когда ты вставляешь ключевую конструкцию, а она разворачивается во что-то большее или во что-то более красивое.

Например, вот такой код

[say_yo]

может быть развернут в одно слово yo, очень полезная фича =)

На сколько я понимаю, шорткоды работают через механизм фильтров, т.е. перед выводом контента, он (контент) прогоняется через повешанные на него шорткоды/фильтры и изменяется так как нужно мне.

Для решения задачи нужна простейшая конструкция: заголовок и тело, заглянув в кодекс wordpress и пораскинув мозги по комнате написал вот так:

function hyper_spoiler($atts, $content)
{
	if ( ! isset($atts[name])) {
		$sp_name = 'Спойлер';
	} else {
		$sp_name = $atts[name];
	}
	
	return '
		<div class="spoiler_wrap">
		<div class="spoiler_head">' . $sp_name . '</div>
		<div class="spoiler_body">' . $content . '</div>
		</div>
	';
}

add_shortcode('spoiler', 'hyper_spoiler');

На всякий случай определяем значение заголовка по умолчанию. Технически этот код можно добавить в functions.php темы, но как я уже говорил где-то ранее, мне больше нравится всё выносить в плагины, к тому же будет логичным, чтобы при смене темы спойлеры всё еще оставались спойлерами.

Добавим простенькие стили

.spoiler_wrap {
	margin: 0 0 24px;
}

.spoiler_body {
	display: none;
	background: #f7f5e7;
	padding: 5px 15px;
}

.spoiler_head {
	cursor: pointer;
	color: #ac0404;
	background: #e0ddcc;
	padding: 3px 15px;
}

.spoiler_head:hover {
	color: #ea9629;
}

Теперь спойлер скрыт, а чтобы его открыть, придется использовать js. Не устаю повторять, в WP уже есть стандартно подключенный JQuery, почему бы не использовать его, если он так и так есть?

jQuery(function($){
	$(document).on('click', '.spoiler_head', function(){
		$(this).siblings('.spoiler_body').slideToggle();
	});
});

Вот собственно и всё.

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

Итог

Это заголовок для спойлера
А это тело для спойлера

Полученный плагин я иногда таскаю с собой на некоторые проекты, например в этот блог. Бывает приходится писать пост с большими кусками кода, а большие тексты могут отпугивать, и код тут приводится скорее в ознакомительных целях, в общем спойлеры пришлись как нельзя кстати.

Вот так вот несложно решили проблему. Забрать готовый плагин можно из репозитория

Всем рок!

Работа с Yii2. Модели и Контроллеры

Вступление

О! Эти вечные проблемы с подбором КДПВ (Картинки для привлечения внимания). Было сто идей, что нужно сделать, но готовых бесплатных картинок я не нашел, более того, даже нормальных платных я не нашел. Заморочился колхозить сам. Поэтому — получай, фашист, гранату! Вот вам моя картинка =)

Постановка задачи

Это третья часть изучения yii2. У меня есть развернутое и настроенное приложение на Yii2, и есть база данных с необходимыми таблицами. Цель этой части подружить одно с другим. Framework уже видит нашу базу, но пока ничего делать с ней не может: ни отображать данные, ни добавлять новые, ни удалять или изменять.

Построение моделей

Думаю, что тебе не надо рассказывать про MVC-парадигму, но если кратко, то это вот так: Модель (M — model «Но, Холмс! Как вы догадались?!») — это данные, структура которая работает с данными, в нашем случае с БД; Представление (V — view) — структура, которая отображает данные и состояния, если очень грубо, то это шаблон, верстка страницы; Контроллер (C — controller) — это прослойка, которая обеспечивает взаимодействие между моделью и представлением, когда пользователь что-то совершает на странице и ожидает какого-то ответа от системы, в дело вступает контроллер.

Когда-то давно, в умных книжках услышал фразу «Толстые модели и тонкие контроллеры». Интересно, какая картинка в вашей голове? =). Я понял эту идею следующим образом: вся бизнес-логика (бизнес от слова дело, а не от слова барыга) ложится в модель, а контроллер исключительно готовит данные для модели, когда они идут от пользователя, или для представления, в обратном случае.

Думаю, можно прекратить теоретический курс. Сделаем нашу толстую модель!

Всё можно писать ручками, и вероятно, для понимания работы архитектуры это будет много эффективнее. Но я учусь, мне можно. Не буду ходить вокруг да около: открываю адрес finansomer.my/gii и моему взору открывается истина и все тайны вселенной — Gii.

Сам про себя он говорит, что может сам написать код для меня, это так мило с его стороны, но будучи лентяем (gii, не я), его надо пнуть. А именно: мне нужная модель, и первый блок загадочно манит меня словами «Model Generate». Пойдем сверху. Попробуем сгенерировать модель для финансовых досок.

На сколько я понимаю, что-то сильно поломать из формы генератора очень непросто, а уж если используется система контроля версий, тем более. В общем, я заполнил свою форму вот так

Из примечательного: если не поставить галку «Use Table Prefix», то имя таблицы будет указано явно, и при смене префикса таблиц придется ручками исправлять имена, лучше поставить эту галочку, тогда обращение к таблицам будет идти через синтаксис префиксов, например, в данном случае. Кстати, модель рекомендуется называть в единственном числе. Я затупил и оставил автоматически созданное имя FinBoards, потом создал еще одну модель FinBoard. Как я понял, какого-то специального механизма удаления моделей нет, просто удаляем файл и все.

На выходе получил вот такой файл

Модель FinBoard

<?php

namespace app\models;

use Yii;

/**
 * This is the model class for table "{{%fin_boards}}".
 *
 * @property integer $id
 * @property string  $name
 * @property string  $dsc
 */
class FinBoard extends \yii\db\ActiveRecord
{
	/**
	 * @inheritdoc
	 */
	public static function tableName()
	{
		return '{{%fin_boards}}';
	}
	
	/**
	 * @inheritdoc
	 */
	public function rules()
	{
		return [
			[['name'], 'required'],
			[['dsc'], 'string'],
			[['name'], 'string', 'max' => 255],
		];
	}
	
	/**
	 * @inheritdoc
	 */
	public function attributeLabels()
	{
		return [
			'id'   => 'ID',
			'name' => 'Name',
			'dsc'  => 'Dsc',
		];
	}
}

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

По аналогии надо создать модели для таблиц transaction_list и transaction_cats, остальные таблицы вспомогательные и сами по себе не являются объектами представления модели данных — хорошо сказал, ничего не понятно, но звучит умно. В общем, не надо для таблиц связи делать отдельные модели.

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

Построение конроллеров и представлений

В заголовке я немного слукавил, обозначив только модели и контроллеры, еще сразу сделаю представления, но я не хотел их создавать на этом шаге, честно-честно. Не знал, что yii позволяет одним действием генерировать и контроллеры и представления, но это не может не радовать, особенно в случае, когда вообще ничего не понятно, а тут будет монументальная точка отсчета.

Как обозначил выше, мануалы говорят, что этап генерации контроллеров и представлений можно выполнить одним махом, используя CRUD Generator. Попробую воспользоваться этим ходом и посмотрю что из этого выйдет.

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

Как видно gii нам сразу нагенерирует целых 8 файлов. Зачем столько? За что они все будут отвечать??? Но главное ввязаться, а там будет видно

Жамкаем кнопку Generate и коммитим новые файлы. Не буду тут приводить всё что получилось, посмотришь сам, дам только код контроллера

Контроллер FinBoardController

<?php

namespace app\controllers;

use Yii;
use app\models\FinBoard;
use app\models\FinBoardSearch;
use yii\web\Controller;
use yii\web\NotFoundHttpException;
use yii\filters\VerbFilter;

/**
 * FinBoardController implements the CRUD actions for FinBoard model.
 */
class FinBoardController extends Controller
{
	/**
	 * @inheritdoc
	 */
	public function behaviors()
	{
		return [
			'verbs' => [
				'class'   => VerbFilter::className(),
				'actions' => [
					'delete' => ['POST'],
				],
			],
		];
	}
	
	/**
	 * Lists all FinBoard models.
	 * @return mixed
	 */
	public function actionIndex()
	{
		$searchModel  = new FinBoardSearch();
		$dataProvider = $searchModel->search(Yii::$app->request->queryParams);
		
		return $this->render('index', [
			'searchModel'  => $searchModel,
			'dataProvider' => $dataProvider,
		]);
	}
	
	/**
	 * Displays a single FinBoard model.
	 *
	 * @param integer $id
	 *
	 * @return mixed
	 */
	public function actionView($id)
	{
		return $this->render('view', [
			'model' => $this->findModel($id),
		]);
	}
	
	/**
	 * Creates a new FinBoard model.
	 * If creation is successful, the browser will be redirected to the 'view' page.
	 * @return mixed
	 */
	public function actionCreate()
	{
		$model = new FinBoard();
		
		if ($model->load(Yii::$app->request->post()) && $model->save()) {
			return $this->redirect(['view', 'id' => $model->id]);
		} else {
			return $this->render('create', [
				'model' => $model,
			]);
		}
	}
	
	/**
	 * Updates an existing FinBoard model.
	 * If update is successful, the browser will be redirected to the 'view' page.
	 *
	 * @param integer $id
	 *
	 * @return mixed
	 */
	public function actionUpdate($id)
	{
		$model = $this->findModel($id);
		
		if ($model->load(Yii::$app->request->post()) && $model->save()) {
			return $this->redirect(['view', 'id' => $model->id]);
		} else {
			return $this->render('update', [
				'model' => $model,
			]);
		}
	}
	
	/**
	 * Deletes an existing FinBoard model.
	 * If deletion is successful, the browser will be redirected to the 'index' page.
	 *
	 * @param integer $id
	 *
	 * @return mixed
	 */
	public function actionDelete($id)
	{
		$this->findModel($id)->delete();
		
		return $this->redirect(['index']);
	}
	
	/**
	 * Finds the FinBoard model based on its primary key value.
	 * If the model is not found, a 404 HTTP exception will be thrown.
	 *
	 * @param integer $id
	 *
	 * @return FinBoard the loaded model
	 * @throws NotFoundHttpException if the model cannot be found
	 */
	protected function findModel($id)
	{
		if (($model = FinBoard::findOne($id)) !== null) {
			return $model;
		} else {
			throw new NotFoundHttpException('The requested page does not exist.');
		}
	}
}

Из странностей хочу отметить пару моментов:

  • С какого-то рожна в папке с моделями я получил еще по одной модели Search. А чего так сложно? В одной модели не поместилось? Или может в контроллер, не? Помимо самого метода поиска в этой модели еще есть метод rules и scenarios.
  • Я тут где-то говорил про толстые модели и тонкие контроллеры, и был искренне уверен, что Yii2 следует этой парадигме «из коробки». А теперь смотри количества кода в контроллере и в модели. Очень хочется сказать: «Эээ… Чё?!» — что значит, что я в недоумении.

Сейчас нет желания разбираться, но с этим определенно нужно будет разобраться.

Круто! Мы с тобой все создали, а как посмотреть, что мы получили? Все просто. В рамках нашей задачи нужно открыть такие ссылочки
http://finansomer.my/fin-board/
http://finansomer.my/transaction/
http://finansomer.my/transaction-cats/

Уиии! Оно работает!

Если внимательно посмотреть на эту форму, то можно заметить волшебную фразу «No results found», что наталкивает на мысль, что это форма поиска. Но вверху есть кнопка создания. Форма будет содержать все поля кроме автоинкрементного.

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

Ясно, что поля дат должны будут обрабатываться автоматически, но об этом уже буду говорить в следующих частях, когда начну описывать свою логику в приложении.

После создания записей их можно отредактировать, просмотреть или удалить — иконки в последнем поле как бы об этом намекают =)

Итог

На выходе мы имеем: модель, которая умеет работать с записями, модель которая умеет искать записи (хз почему так, но так), контроллеры и представления для полноценной работы с записями. Таким образом, на этом шаге очень хорошо (я думаю, что очень хорошо) продемонстрированы возможности генератора кода. Если делать что-то типа блога, где нужен только заголовок и текст, то стандартный генератор покрывает базовые потребности, конечно, без особых красивостей и тонкостей, но уже кое-что, с этим можно работать.

В следующей части, я надеюсь, наконец-то приступлю к самому интересному — программированию!

Всем рок!

Адаптивные таблицы для адаптивных сайтов

Вступление

Верстка адаптивных макетов для нас в Sawtech и для меня в частности давно не является проблемой. Сложности возникают с конечными (по слогам КО-НЕЧ-НЫ-МИ, не перепутайте порядок букв) пользователями.

Вот дизайнеры нарисовали крутой макет, я его круто сверстал (ага-ага, а ещё я очень скромный) — всё очень очень круто выглядит, а потом вся эта красота разбивается о железобетонную действительность. А именно, заказчик берет и в текст новости добавляет таблицу: «Так информация лучше структурирована». С этим доводом сложно спорить. Это проблема и её нужно решать.

Варианты решения

Я вижу пока 2 основных варианта решения.

Вариант № 1. Озадачим пользователей

Создаем в админке сложную структуру полей: одно поле — одна ячейка, а может и что-то сложнее, выбор за тобой. В эти поля  администратор, контент-менеджер или просто пользователь будет добавлять данные, а мы уже при выводе со своей стороны обрабатываем эти поля и выводим в блочной версии.

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

Вариант №2. Творите что хотите или кнопка «сделать красиво»

Тут есть 2 концептуальных направления.

Путь наименьшего сопротивления. Обернуть таблицу в div-блок, который будет автоматически добавлять полосу прокрутки, если таблица уже не помещается в ширину родителя. Много где видел такую реализацию. Вполне жизнеспособно, требует минимальных вложений со стороны разработки. Если решать задачу на уровне php, то регуляркой или еще как-то ищем по тексту таблицу и оборачиваем её в div. Только может быть сложность в работе с DOM или regexp отработает не совсем так как хочется. А можно исполнить решение на js, лично мне кажется, это вообще простым вариантом. Но об это чуть ниже сначала про концепции.

Второй концептуальный путь — пересобрать таблицу на div’ах. На выходе потребуется больше навыков от верстальщика, придется в стилях определить поведение блоков на разных разрешениях — адаптивность как она есть. Так же решается как на php, так и на js. Я пойду путем jquery.

Ой, всё! Я знаю, что jquery фу-фу-фу, но мне так проще, не хочешь не пользуйся.

Программируем

Хочу в одном плагине реализовать оба подхода, чтобы было.

Допустим имеем такую структуру

<div class="main-content">
	
	<h3>Обернутая таблица</h3>
	<table class="js-add-wrap">
		<tr>
			<th>Заголовок таблица 2, колонка 1</th>
			<th>Заголовок таблица 2, колонка 2</th>
			<th>Заголовок таблица 2, колонка 3</th>
			<th>Заголовок таблица 2, колонка 4</th>
		</tr>
		<tr>
			<td>А тут может быть очень длинный текст, например машиностроительный</td>
			<td>Строка 1, запись 2</td>
			<td>Строка 1, запись 3</td>
			<td>Строка 1, запись 4</td>
		</tr>
		<tr>
			<td>Строка 2, запись 1</td>
			<td>Строка 2, запись 2</td>
			<td>Строка 2, запись 3</td>
			<td>Строка 2, запись 4</td>
		</tr>
	</table>
	
	<br />
	<br />
	
	<h3>Преобразованная таблица</h3>
	<table class="js-convert-table">
		<tr>
			<th>Заголовок таблица 1, колонка 1</th>
			<th>Заголовок таблица 1, колонка 2</th>
			<th>Заголовок таблица 1, колонка 3</th>
			<th>Заголовок таблица 1, колонка 4</th>
		</tr>
		<tr>
			<td>А тут может быть очень длинный текст, например машиностроительный</td>
			<td>Строка 1, запись 2</td>
			<td>Строка 1, запись 3</td>
			<td>Строка 1, запись 4</td>
		</tr>
		<tr>
			<td>Строка 2, запись 1</td>
			<td>Строка 2, запись 2</td>
			<td>Строка 2, запись 3</td>
			<td>Строка 2, запись 4</td>
		</tr>
	</table>
</div>

Первую таблицу будем оборачивать в блок, а вторую будем пересобирать.

Много букв, для вот такого плагина:

(function ( $ ) {
	$.fn.adaptiveTable = function ( options ) {
		options = $.extend({
			addWrap: false
		}, options);

		var make = function () {
			var self = this;

			$(document).ready(function () {

				if( options.addWrap === true ) {
					$(self).wrap('<div class="table-adaptive-wrap"></div>');
					return true;
				}

				// пересоберем
				var new_table = $('<div class="table-adaptive"></div>');

				$(self).find('tr').each(function () {
					new_table.append('<div class="table-adaptive__row">');

					$(this).find('td, th').each(function () {
						new_table.find('.table-adaptive__row').last().append('<div class="table-adaptive__cell">' + $(this).html() + '</div>');
					});
				});

				$(self).replaceWith(new_table);
			})
		};

		return this.each(make);
	};
})(jQuery);

Для общего случая надо еще добавить такие стили

/*****************************************************************************/
/********************* Обертка для обычной таблицы ***************************/
.table-adaptive-wrap {
	position: relative;
	overflow: auto;
}

/*****************************************************************************/
/******************** Стили для адаптивной таблицы ***************************/
.table-adaptive {
	width: 100%;
	position: relative;
	display: table;
	border: 1px solid #ccc;
	box-sizing: border-box;
}

.table-adaptive__row {
	width: 100%;
	position: relative;
	display: table-row;
	
}

.table-adaptive__cell {
	position: relative;
	padding: 10px;
	display: table-cell;
	border-top: 1px solid #ccc;
	border-left:  1px solid #ccc;
	box-sizing: border-box;
}

.table-adaptive__row:first-child .table-adaptive__cell {
	border-top: none;
}

.table-adaptive__cell:first-child {
	border-left: none;
}

@media all and (max-width: 800px) {
	.table-adaptive, .table-adaptive__cell, .table-adaptive__row {
		display: block;
	}
	
	.table-adaptive {
		border: none;
	}
	
	.table-adaptive__row {
		border-bottom: 1px solid #ccc;
	}
	
	.table-adaptive__row:first-child {
		display: none;
	}
    
    .table-adaptive__cell {
	    border: none;
    }
}

Вот так незамысловато =) Вызывается как и любой плагин:

$(document).ready(function(){

	// Просто обертка
	$('.js-add-wrap').adaptiveTable({addWrap:true});

	// Сложная структура на дивах
	$('.js-convert-table').adaptiveTable();
});

Из настроек только 1 параметр — решить задачу через простую обертку, по умолчанию используется продвинутый вариант. Да, есть нюанс, плагин нормально будет работать только для простых таблиц, когда количество ячеек в каждой строке одинаково. Можно, конечно, его усложнить, чтобы он корректно обрабатывал объединенные ячейки, но не сейчас-не сейчас.

В реализованном виде будет выглядеть так, если «поиграть» с шириной окна браузера, то будут понятно в чем различия.

See the Pen vxeBJY by Ildar Saribzhanov (@ildar_r_saribzhanov) on CodePen.

Завел на github репозиторий для этого плагина. Возможно, буду его модифицировать в процессе, так что, как говорится — следите за обновлениями!

Всем рок!

Работа с yii2: проектирование базы данных

Постановка задачи

Как я говорил в прошлой части, мне нужен сервис для сбора статистики по расходам и доходам. Но чем полнее будет описана задача, тем точнее будет её выполнение. И эта серьезная ошибка прошлой части, я не обозначил рамки своей задачи. В рамках прошлой части это не так критично, т.к. кроме развертки yii я ничего толком сделать не успел, но все равно, ай-ай-ай мне за такой просчет. Но сегодня я приступаю к процессу проектирования базы данных, поэтому мне необходимо максимально полно и точно описать все свои хотелки. Итак, я хочу сервис который:

  1. Позволяет записывать расходы
  2. Позволяет записывать доходы
  3. Позволяет к каждой позиции доходов и расходов добавлять текстовое описание
  4. Позволяет тегировать (расставлять теги) доходы и расходы. Желательно не ограничивать количество тегов к каждой записи.

Собственно на первом этапе это всё. Если я буду работать со своим сервисом один, мне должно хватить. Но если заглянуть чуть дальше, то вскроется еще один слой. А если я захочу вести бухгалтерию своей трудовой деятельности. Ну как бухгалтерию? Все те же самые расходы и доходы, только мне бы как-то отделить семейный финансовый поток от трудового. Понятно, что из трудового всё в семью-всё в семью, но всякие налоговые отчисления, страховые и тд. Вот и следующая хотелка:

  1. Необходима возможность создания досок в неограниченном количестве, назовем их финансовыми досками.

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

  1. Возможность регистрации нескольких пользователей
  2. Каждый пользователь может создавать свои приватные финансовые доски
  3. Пользователь может делиться своими досками с другими участниками системы

Вот кажется и все требования.

Проектирование базы данных

Есть множество приложений и сервисов для проектирования БД. Мне нравится dbdesigner.net. Пару лет назад на него наткнулся, меня он устраивает более чем. Я не настаиваю, можете использовать то, что нравится, хоть листочик. По поводу листочков. Когда мы въехали в предыдущий офис, то от старых владельцев осталась структура БД размером метров 5 в ширину на 2 в высоту, всё это было склеено из листов или А3, или А4, и размер шрифта что-то ближе к 12. Надеюсь у них был электронный вариант =)

Ладно, поменьше лирики, работаем дальше. В общем, посидев пару часов, была нарисована такая структура БД

 

Я понимал, что в зависимости от фрэймворка набор полей для таблицы пользователей, а скорее всего и количество самих таблиц будет изменено. Всякие служебные данные, миграции и т.д. Но тут я хотел отобразить только то, что относится непосредственно к моему сервису.

От таблицы пользователей мне надо только id пользователя.

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

fin_board — это собственно доски, они особо ничего не хранят кроме имени и какого-нибудь описания.

transaction_list — а вот это основная таблица, которая будет хранить все данные. Изначально включил только 2 поля в эту таблицу dsc — описание и val числовое значение, плюс поле связи с финансовой доской и поле связи с типом добавляемой информации. О первичных ключах не говорю, они есть везде. Пользователя я планировал получать через финансовые доски, но потом меня посетила мысль о совместном редактировании. А как узнать кто и когда добавил транзакцию? Соответственно добавил еще 2 поля: date_add и user_id — ну и до кучи добавил поле для фиксации даты последнего изменения. Конечно, в идеале выделить отдельную таблицу для полного лога изменений, но пока мне это не нужно. Думаю, что масштабирование в эту сторону не будет проблемой.

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

Допускаем, что это наша финальная структура базы данных. Приступим к переносу этой структуры в реальную СУБД.

Перенос структуры базы данных в MySQL. Миграции Yii2

Я не стал извращаться с выбором СУБД, думаю, что MySQL мне пока хватит за глаза, тем более, что моя цель, это изучение yii, а не PostgreSQL, или SQLight.

Чтобы начать работать с базой данных их Yii, будет очень неплохо настроить подключение. Делается это в файле:

config/db.php

Потеряться в настройках очень не просто, я в тебя верю, ты сможешь, я же смог =) Единственно, что я добавил там, так это параметр префикса к таблицам, считаю использование префикса хорошей практикой. Префикс добавляется параметром tablePrefix

<?php

return [
	'class'       => 'yii\db\Connection',
	'dsn'         => 'mysql:host=localhost;dbname=finansomer',
	'username'    => 'root',
	'password'    => 'root',
	'charset'     => 'utf8',
	'tablePrefix' => 'yourBestPrefix_'
];

Дальше начинаются ништяки от Yii.

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

./yii migrate/create start_tables

Это приведет к автоматическому созданию файла с замысловатым названием m[ГодМесяцДень]_[ЧасыМинутыСекунды]_start_table в папке migrations, которая лежит в корне проекта.

<?php

use yii\db\Migration;

class m170307_040537_start_tables extends Migration
{
	public function up()
	{
		
	}
	
	public function down()
	{
		echo "m170307_053734_tst cannot be reverted.\n";
		
		return false;
	}
	
	/*
	// Use safeUp/safeDown to run migration code within a transaction
	public function safeUp()
	{
	}

	public function safeDown()
	{
	}
	*/
}

Сюда нужно писать код, который будет создавать таблицы при выполнении миграции, и убивать таблицы при откате (последнее не обязательно, но тогда метод down обязан вернуть false):

<?php

use yii\db\Migration;

class m170307_040537_start_table extends Migration
{
	public function up()
	{
		// Финансовые доски
		$this->createTable('{{%fin_boards}}', [
			'id'   => $this->bigPrimaryKey(),
			'name' => $this->string(255)->notNull(),
			'dsc'  => $this->text(),
		]);
		
		// Лист транзакций
		$this->createTable('{{%transaction_list}}', [
			'id'          => $this->bigPrimaryKey(),
			'board_id'    => $this->bigInteger()->notNull()->defaultValue(0),
			'user_id'     => $this->bigInteger()->notNull()->defaultValue(0),
			'dsc'         => $this->string(255)->notNull(),
			'val'         => $this->money(11, 2)->defaultValue(0),
			'date_add'    => $this->datetime()->notNull(),
			'date_update' => $this->datetime(),
		]);
		
		// Состанвой индекс для листа транзакций
		$this->createIndex('transaction_list_key', '{{%transaction_list}}', ['board_id', 'user_id']);
		
		// связь досок и пользователей
		$this->createTable('{{%board_rel}}', [
			'board_id' => $this->bigInteger()->notNull()->defaultValue(0),
			'user_id'  => $this->bigInteger()->notNull()->defaultValue(0),
			'PRIMARY KEY(board_id, user_id)',
		]);
		
		// Индекс для таблицы связей 'board_rel`
		$this->createIndex('idx_board_user_rel', '{{%board_rel}}', 'user_id');
		
		// категории транзакций
		$this->createTable('{{%transaction_cats}}', [
			'id'   => $this->bigPrimaryKey(),
			'name' => $this->string(255)->notNull(),
		]);
		
		// связь транзакций и категорий
		$this->createTable('{{%cats_rel}}', [
			'transaction_id' => $this->bigInteger()->notNull()->defaultValue(0),
			'cat_id'         => $this->bigInteger()->notNull()->defaultValue(0),
			'PRIMARY KEY(transaction_id, cat_id)',
		]);
		
		// Индекс для таблицы связей 'cats_rel`
		$this->createIndex('idx_transaction_cat_rel', '{{%cats_rel}}', 'transaction_id');
	}
	
	public function down()
	{
		$this->dropTable('{{%fin_boards}}');
		$this->dropTable('{{%transaction_list}}');
		$this->dropTable('{{%board_rel}}');
		$this->dropTable('{{%transaction_cats}}');
		$this->dropTable('{{%cats_rel}}');
	}
}

В действительности генератор миграций умеет много больше чем просто генерировать пустой класс, передавая параметры он может автоматически создавать всю структуру и связи между таблицами, например:

# Миграция таблицы [table-name] с полями title и body
./yii migrate/create create_[table-name]_table --fields=title:string,body:text

При этом поле id указывать не нужно, оно будет создано автоматически, это не обязательно, при желании можно первичный ключ переопределить. Для автоматической генерации название миграции должно начинаться с create_, а заканчиваться _table, чтобы yii понимал о чем идет речь, все что будет идти между этими конструкциями, определяется как имя таблицы. Не буду расписывать всё и вся, луче в конце этого поста дам ссылки на документацию, где более подробно расписаны возможности.

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

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

 

Итог

Эта часть получилась не совсем про yii, а скорее про проектирование. Пока её писал структура БД менялась несколько раз, то одно забыл, то другое, потом не с первого раза определился с именованием таблиц и полей. Но вот по истечении ~месяца я решил, что это будет финальная начальная структура (классная формулировка). Этим хочу сказать, что не нужно вылизывать структуру, надо в определенный момент решиться и реализовать то, что есть, лучше по ходу что-то допилить. К тому же дополнительные сложности и телодвижения помогут лучше изучить framewark.

Полезные ссылки

Про миграции по-русски
Про типы данных

Всем рок!

Работа с yii2. Начало.

Предыстория

Целью на этот год у меня поставлена задача реализовать один проект на framework’е. Не уточнялось на каком, и даже язык не уточнялся. Но для себя я решил, что это должен быть какой-нибудь совсем нетроганный мной зверь. Исходя из поставленной задачи выбор пал на Yii второй генерации, все-таки надо придерживаться свежих веяний, и, думаю, что первая версия будет еще не долго поддерживаться.

Допустим инструмент выбран, что дальше? Нужна какая-то прикладная задача. Тренировка на кошках ничего не даст.

В нашей семье для учета доходов и расходов мы используем excel, думаю, многие так делают, это просто и достаточно удобно. Но у такого подхода есть некоторые недостатки, и вот один из них, который меня сильно удручает: очень много телодвижений, чтобы получить какую-либо статистику. Тем более, что я записываю каждую позицию в расходах, пришел из гипермаркета и 20 минут переписываешь из чека в эксель, а чтобы потом посчитать сколько потрачено на питание, это еще мильён времени, чтобы все сложить и постараться ничего не потерять. Думаю, задачу вы уже уловили =) Хочу сервис, в котором можно так же удобно забивать расходы, но который позволит выводить любую мыслимую статистику: расходы за месяц, траты на развлечения, траты на кино, траты на кино за 4 недели — т.е. действительно любую статистику.

Так как я не умею готовить yii, то данная часть будет рассчитана «для самых маленьких».

Установка

Я живу в мире php, поэтому не вижу смысле не использовать крутые вещи этого мира. Я говорю про composer. Не будем далеко уходить в сторону, представим, что он у вас уже установлен и настроен в глобальное окружение, чтобы не писать каждый раз страшную конструкцию

php composer.phar

Теперь по мануалу, нужно глобально установить пакет composer-asset-plugin. Этот пакет нужен, чтобы управлять пакетами, которые написаны для Bower и Npm. Т.е. теперь композер умеет переваривать пакеты для Bower и Npm, и вроде бы как не обязательно дополнительно устанавливать сами Bower и Npm. Как-то так, путано, но понятно =)

composer global require "fxp/composer-asset-plugin:^1.2.0"

И непосредственно установка. Во всех примерах почему-то фигурирует такая команда

composer create-project --prefer-dist yiisoft/yii2-app-basic [path-project]

С вариантами в качестве [path-project]. Но лично я сначала создаю папку под проект, потом перехожу в нее и уже все манипуляции делаю из нее. Собственно чтобы установить yii в текущую папку нужно писать так

composer create-project --prefer-dist yiisoft/yii2-app-basic ./

Получаем вот такую структуру

Теперь надо чтобы наш веб-сервер смотрел на папку /web. Т.к. у меня локально поднят Apache, то в настройках хостов у меня настроено вот так

<VirtualHost *:80>
    ServerName finansomer.my
    ServerAlias www.finansomer.my
    DocumentRoot [path-to-web-server]/finansomer.my/web
</VirtualHost>

Как видно из настроек, имя папки соответствует имени домена, мне так нравится. Но для начала настроим сервер на корень проекта и откроем в браузере адрес finansomer.my/requirements.php, чтобы посмотреть все ли нашему yii нравиться в нашей конфигурации. Если чего-то не хватает, добавим, если все ок, снова настроим сервер на /web. Мне, чтобы оставить минимум warning’ов понадобилось поставить пакет intl и imagick. Из предупреждений я оставил только драйвера для SQLite и PostgreSQL, а также модуль Memcache. Пока это все мне не нужно.

Настройка Git репозитория

Перед инициализацией git-репозитория надо бы настроить gitignore. Как видно, в корне он уже лежит, но умные люди предлагают его немного дополнить. А я еще скажу, что можно убрать не нужные строки, например, там указаны папки для IDE. Я работаю в PhpStorm и поэтому оставлю в исключениях только его папки, другие удалю. Так вот, в корневой gitignore надо добавить файл yii из корня, видимо, это какой-то входной скрипт для консоли, позже с этим разберемся. Коневой gitignore я получил такой

# phpstorm project files
.idea

# windows thumbnail cache
Thumbs.db

# composer vendor dir
/vendor
/yii

# phpunit itself is not needed
phpunit.phar
# local phpunit config
/phpunit.xml

tests/_output/*
tests/_support/_generated

Еще говорят, что в папки /config и /web надо тоже положить по гитигнору с таким содержимым: в первом

db.php
params.php

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

index.php
index-test.php
.htaccess

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

И вот можно выполнить заветную команду инициализации приложения

git init
gitt add *
gitt commit -m 'Start project'

Настройка ЧПУ. Красивые URL

Для ЧПУ, надо положить в папку /web htaccess с таким содержимым:

Order Allow,Deny
Allow from all

AddDefaultCharset utf-8

RewriteEngine on

RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule . index.php

И пожалуй этот файл я все же добавлю в индексацию, почему его исключили, мне не понятно.

А теперь нужно рассказать фрэймворку, что он должен делать ссылки красивыми, это тоже не сложно, нужно в 2 файла config/console.php и config/web.php в секцию components добавить следующее:

'components'          => [
		...
		'urlManager' => [
			'enablePrettyUrl' => true,
			'showScriptName' => false,
			'rules' => [
				'<_c:[\w\-]+>/<id:\d+>' => '<_c>/view',
				'<_c:[\w\-]+>' => '<_c>/index',
				'<_c:[\w\-]+>/<_a:[\w\-]+>/<id:\d+>' => '<_c>/<_a>',
			],
		],
	],

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

Что дальше?

В следующих частях я подумаю над структурой базы данных, посмотрю как в рамках Yii реализовать необходимую структуру. Затем посмотрю как писать в БД и читать из БД. В общем, будет интересно! Следите за обновлениями =)

Полезные ссылки

Собственно все основные идеи брал отсюда