Создание опроса/голосования на сайте (PHP)

Здравствуйте. Предлагаю Вашему вниманию инструкцию по созданию системы голосования на сайте.
Итак, давайте рассмотрим, что же представляет из себя система голосования на сайте.
Пользователь видит на странице форму, в которой присутствуют заголовок голования, варианты ответов, из которых можно выбрать один, тот, за который пользователь хочет проголосовать.
После выбора пользователь нажимает кнопку "проголосовать" и система засчитывает его голос в пользу того или иного варианта ответа.
"Снаружи" вроде всё просто: обычная форма, обычная кнопка, обычные radio-кнопки. Но давайте заглянем "за кулисы" работы скрипта голосования. Там нашему взору откроется более интересная картина.

Что же представляет из себя система голосования изнутри?
Как можно запоминать выбор пользователя и не давать ему голосовать повторно?
Как создавать вопросы и варианты ответов?
На эти вопросы мы сейчас попробуем получить развёрнутый ответ. Нам нужно где-то хранить вопросы и варианты ответов. Для этого нам потребуется создать базу данных MySQL и пару таблиц в ней.
Давайте создадим такие таблицы:
voting - таблица, в которой будут храниться вопросы и варианты ответов.
voted - таблица, в которой будут храниться выбранные варианты ответов и IP адреса проголосовавших.

В таблице voting нам нужно создать три поля:
1. id - тип INTEGER, AUTOINCREMENT.
В поле будет содержаться идентификатор вопросов и ответов.
2. parent_id - тип INTEGER.
В поле будет содержаться идентификатор родительской записи (т.е. по отношению к ответам, родительской записью будет вопрос).
3. title - тип VARCHAR.
В поле будет содержаться текст вопросов и ответов.

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

В таблице voted нам нужно создать два поля:
1. answer_id - тип INTEGER.
В поле будет содержаться идентификатор выбранного пользователем ответа (т.е. ответа, за который он проголосовал).
2. ip - тип VARCHAR. UNIQUE.
В поле будет содержаться IP-адрес пользователя, который проголосовал. В данном поле будут только уникальные значения IP.

На этом моменте остановимся поподробнее. Почему мы запоминаем IP пользователя? Ведь IP бывают динамическими, скажете Вы. И пользователь, сменив IP, сможет проголосовать несколько раз. Я с Вами полностью согласен, но ещё не придумано 100% надёжного варианта запонимания неавторизованного пользователя.
Почему я упомянул авторизацию: если мы хотим, чтобы пользователь гарантированно не смог проголосовать несколько раз, нам нужно сделать систему авторизации и заместо IP пользователя использовать для учёта его голоса идентификатор его аккаунта.
И сделать открытым голосование только для авторизованных пользователей. Но создание голосования для авторизованных не входит в планы данной статьи, поэтому для учёта голоса будем запоминать IP.

Итак, мы создали таблицы, теперь давайте перейдём непосредственно к реализации php-скрипта системы голосования. Давайте разберём вот такой класс:
<?php
    class Voting{
        /**
        * 
        * @var Ресурс соединения с БД
        * 
        */
        private $db;
        
        /**
        * 
        * @var Таблица с вопросами
        * 
        */
        private $tbl_voting = 'voting';
        
        /**
        * 
        * @var Таблица с голосами
        * 
        */
        private $tbl_voted  = 'voted';
        
        /**
        * 
        * @var ID голосования
        * 
        */
        private $id;
        
        /**
        * 
        * @var Массив с данными о голосовании
        * Заполняется в методе get()
        * 
        */
        private $result     = array();
        
        /**
        * Для запуска необходимых данных
        * @param integer $id - идентификатор голосования
        * 
        * @return
        */
        public function __construct( $id=0 ){
            # Настройки подключения к БД
            $dsn = "mysql:host=localhost;dbname=voting;charset=utf8;";
            
            # Соединяемся с БД
            $this->db = new PDO($dsn, 'root', '');
            
            # Устанавливаем ID голосования
            $this->id = $id;
        }
        
        /**
        * Для выборки из базы голосования по установленному ID
        * 
        * @return заполняет массив $this->result данными
        */
        private function selectVoting(){
            # SQL-запрос для выборки опроса
            $stmt = $this->db->prepare( 
                    "SELECT id, title, 0 as voted
                        FROM `$this->tbl_voting`
                    WHERE id=:id AND parent_id=0
                    UNION
                    SELECT o.id, o.title, COUNT(v.ip)
                        FROM `$this->tbl_voting` o
					LEFT JOIN `$this->tbl_voted` v
						ON v.answer_id = o.id
                    WHERE parent_id=:id
                    GROUP BY o.id" );

            # Выполняем запрос
            $stmt->execute(array(
                ':id' => $this->id
            ));
            
            # Получаем ассоциативный массив
            $this->result['voting'] = $stmt->fetchAll( PDO::FETCH_ASSOC );

            # Если данных нет
            if( !$this->result )
                # Бросаем исключение
                throw new Exception('Голосования с ID '. $this->id .' нет');
        }
		
		/**
		* Для проверки, голосовал ли уже пользователь
		* 
		* @return
		*/
		private function checkAlreadyVoted(){
			# SQL-запрос для выборки опроса
            $stmt = $this->db->prepare( 
                    "SELECT COUNT(*)
                    	FROM `$this->tbl_voted`
                     WHERE ip=:ip AND answer_id IN(
                         SELECT id FROM `$this->tbl_voting` WHERE parent_id=$this->id
                     )" );
            
            # Выполняем запрос
            $stmt->execute(array(
                ':ip' => $this->getIP()
            ));
            
            # Записываем полученные данные в конечный массив
            $this->result['already_voted'] = (bool) $stmt->fetch( PDO::FETCH_COLUMN );
		}
        
        /**
        * Для разбора массива, установленного в select()
        * 
        * @return заполняет массив готовыми для шаблона данными
        */
        private function prepare(){
            # Получаем из массива первый элемент (вопрос)
            $ask = array_shift( $this->result['voting'] );
            
            # Записываем готовые данные в итоговый массив
            $this->result = array(
                'title'         => $ask['title'],
                'options'       => $this->result['voting'],
                'already_voted' => $this->result['already_voted']
            );
        }

        /**
        * Для получения IP-адреса
        * 
        * @return IP-адрес пользователя
        */
        private function getIP(){
            return
                getenv('REMOTE_ADDR');
        }
        
        /**
        * Для получения массива с информацией о голосовании
        * @param integer $id - идентификатор голосования,
        * которое нужно вывести
        * 
        * @return
        */
        public function get(){
            # Вызываем метод выборки данных из БД
            $this->selectVoting();

            # Вызываем метод проверки, голосовал ли пользователь
            $this->checkAlreadyVoted();
            
            # Вызываем метод обработки полученных данных
            $this->prepare();
            
            # Возвращаем массив данных
            return
                $this->result;
        }
        
        /**
        * Для добавления голоса к опросу
        * 
        * @return
        */
        public function add( $id ){            
            # Подготавливаем запрос для добавления голоса
            $stmt = $this->db->prepare(
                    "INSERT IGNORE INTO `$this->tbl_voted`
                        VALUES ((SELECT id FROM `$this->tbl_voting` WHERE id=:id), :ip)" );
            
            # Выполняем запрос
            $stmt->execute(array(
                ':id' => $id,
                ':ip' => $this->getIP()
            ));
        }
    }

Класс довольно неплохо прокомментирован, поэтому разберём только самые интересные методы данного класса. Для начала нам нужно настроить соединение с нашей базой банных. Для этого в конструкторе нужно указать хост, имя базы данных, имя пользователя и пароль.
Как видите, в мы используем для работы с базой данных расширение PDO. С этим расширением очень удобно работать, и при правильном составлении запросов ещё и безопасно, никакие SQL-инъекции не будут страшны.
Если у Вас возникли затруднения в настройке соединения с базой данных, обратитесь к документации PDO: http://php.net/manual/ru/pdo.connections.php

Теперь давайте разберём метод выборки вопроса и соответствующих ему ответов:
private function selectVoting(){
            # SQL-запрос для выборки опроса
            $stmt = $this->db->prepare( 
                    "SELECT id, title, 0 as voted
                        FROM `$this->tbl_voting`
                    WHERE id=:id AND parent_id=0
                    UNION
                    SELECT o.id, o.title, COUNT(v.ip)
                        FROM `$this->tbl_voting` o
					LEFT JOIN `$this->tbl_voted` v
						ON v.answer_id = o.id
                    WHERE parent_id=:id
                    GROUP BY o.id" );

            # Выполняем запрос
            $stmt->execute(array(
                ':id' => $this->id
            ));
            
            # Получаем ассоциативный массив
            $this->result['voting'] = $stmt->fetchAll( PDO::FETCH_ASSOC );

            # Если данных нет
            if( !$this->result )
                # Бросаем исключение
                throw new Exception('Голосования с ID '. $this->id .' нет');
        }

Как видите, здесь мы используем prepared statement (подготовленные запросы) PDO. В самом запросе мы указываем якорь ":id", а затем в методе "execute" указываем, каким значением заменить данный якорь.
В данном SQL запросе мы используем объединение результатов запросов помощью UNION. Первым SELEСЕ'ом мы выбираем необходмый вопрос (идентификатор которого содержится в свойстве "id" данного класса), а
вторым выбираем принадлежащие данному вопросу варианты ответов и количество голосов за определённыый вопрос (для вывода статистики).
В результате данного запроса мы получаем массив, первым элементом которого является вопрос, а остальными элементами - варианты ответа на вопрос. Теперь нам нужно отделить вопрос от ответов. Для этого мы используем метод "prepare":
/**
	* Для разбора массива, установленного в select()
	* 
	* @return заполняет массив готовыми для шаблона данными
	*/
	private function prepare(){
		# Получаем из массива первый элемент (вопрос)
		$ask = array_shift( $this->result['voting'] );
		
		# Записываем готовые данные в итоговый массив
		$this->result = array(
			'title'         => $ask['title'],
			'options'       => $this->result['voting'],
			'already_voted' => $this->result['already_voted']
		);
	}

C помощью array_shift мы вырезаем вопрос из массива и вставляем текст вопроса в результирующий массив (который передадим уже на вывод). Как видите, здесь встречается такой элемент, как "already_voted".
В нём мы содержим информацию, голосовал ли пользователь ранее, т.е. содержится ли IP пользователя в таблице "voted". Проверка на наличие IP в таблице производится в методе "checkAlreadyVoted".

Ну что, с данным классом мы разобрались, теперь сохраните его в файл (назовите его "voting.class.php") и скопируйте к себе на сервер. Далее давайте разберём, как мы будем выводить голосование на экран.
Сначала нам нужно подключить вышеописанный класс на страницу, где будем выводить голосование. Я не знаю, каким образом работает Ваша CMS, поэтому давайте подключим, используя обычный "require":
require('путь к файлу/voting.class.php');

Отлично, класс подключён. Можно с ним работать. Но у нас пока нет ни одного голосования,- давайте создадим его. Для этого нужно зайти в phpMyAdmin, выбрать таблицу "voting" и вставить в неё вопросы и ответы.
Допустим, мы хотим узнать от пользователей, как они относятся к нашему сайту. Для этого в поле "title" вставим такой вопрос: "Как Вам наш сайт?". В поле "parent_id" нужно вставить "0", так как это вопрос и он не является дочерней записью другого вопроса.
После сохранения мы видим, что у нас появилась новая запись в таблице. Теперь мы знаем её идентификатор (находится в поле "id"), это и есть идентификатор вопроса, по этому идентификатору мы привяжем к вопросу варианты ответов на него.
Теперь создадим варианты ответов, для этого в поле "title", например впишем "Отлично". Эта запись и будет одним из вариантов ответа. В поле parent_id нам нужно указать идентификатор вопроса, к которому принадлежит данный ответ.
Таким образом создайте необходимое количество вариантов ответов, не забывая в поле parent_id указывать идентификатор вопроса.

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

try{
	# Получаем голосование. В качестве аргумента в конструктор передаём идентификатор вопроса.
    $voting = new Voting(1);
	
	# Получаем голосование
    $data = $voting->get();
}
catch(Exception $e){
	echo $e->getMessage();
}

В коде выше мы видим, что у нас создаётся объект класса, в качестве аргумента конструктору которого передаётся ID вопроса. Как видите, нам не нужно перечислять ID всех записей в БД, принадлежащих данному голосованию. Достаточно указать лишь ID вопроса, а дочерние записи (т.е. у которых в поле "parent_id" содержится ID данного вопроса)
подтянутся вместе с ним, с помощью UNUON, о котором мы говорили ранее. Сейчас мы передаём ID "1", если у Вашего вопроса идентификатор другой - передайте его, единица здесь только для примера.
Если Вы всё правильно сделали, в итоге, в переменной $data у Вас будет примерно такой массив:

array(3) {
  ["title"]=>
  string(30) "Как Вам наш сайт?"
  ["options"]=>
  array(3) {
    [0]=>
    array(3) {
      ["id"]=>
      string(1) "2"
      ["title"]=>
      string(27) "Первый вариант ответа"
      ["voted"]=>
      string(1) "0"
    }
    [1]=>
    array(3) {
      ["id"]=>
      string(1) "3"
      ["title"]=>
      string(13) "Второй вариант ответа"
      ["voted"]=>
      string(1) "0"
    }
    [2]=>
    array(3) {
      ["id"]=>
      string(1) "4"
      ["title"]=>
      string(27) "Третий вариант ответа"
      ["voted"]=>
      string(1) "0"
    }
  }
  ["already_voted"]=>
  bool(false)
}

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

<?php if( $data['already_voted'] ): ?>
    <? foreach( $data['options'] as $option ):?>
        <?=$option['title']?> - <?=$option['voted']?> голосов<br>
    <?php endforeach;?>
<?php else:?>
    <form method="POST" action="/vote.php">
    <? foreach( $data['options'] as $option ):?>
    <label>
        <input type="radio" name="answer" value="<?=$option['id']?>"/> <?=$option['title']?>
    </label><br>
    <?php endforeach;?>
    <input type="submit" name="submit" value="Голосовать"/>
    </form>
<?php endif; ?>

Как мы видим, в коде проводится проверка, голосовали ли пользователь ранее:
<?php if( $data['already_voted'] ): ?>

Если пользователь уже голосовал (т.е. его IP есть в таблице voted), то ему выводится на экран статистика голосов, т.е. ответы и количество пользователей, проголосовавших за тот или иной ответ. Тут Вы можете подключить фантазию и доработать скрипт, например, сделав вывод статистики в виде графика.
Если же пользователь не принимал участие в данном голосовании, то ему выводится форма, в которой он может выбрать необходимый вариант ответа и проголосовать за него.
В цикле мы проходим по всем вариантам ответов и выводим их на экран. В качестве значения в поля "radio" вставляются их идентификаторы:
<input type="radio" name="answer" value="<?=$option['id']?>"/>

Допустим, пользователь выбрал необходимый ответ и нажал "Голосовать". После этого он перейдёт к скрипту "voted.php", как мы видим в коде, "action" формы ведёт именно на этот скрипт:
<form method="POST" action="/vote.php">

Теперь создадим файл "vote.php" и напишем в нём такой код:
<?php
    # Подключаем класс голосования
    require('voting.class.php');
    
    try{
        # Если нажата кнопка "Голосовать"
        if( isset( $_POST['submit'] ) ){
            # Получаем ID ответа, за который голосуют
            $id = filter_input( INPUT_POST, 'answer', FILTER_SANITIZE_NUMBER_INT );
            
            # Если ID не указан
            if( !$id )
                # Бросаем исключение
                throw new Exception('Error!');
            
            # Создаём экзепляр класса работы с голосованием
            $voting = new Voting;
            
            # Засчитываем голос
            $voting->add( $id );
            
            # Перенаправляем назад
            header( 'Location: ' . getenv('HTTP_REFERER') );
        }
    }
    catch(Exception $e){
        # Выводим сообщение
        echo $e->getMessage();
    }

Как видите, в нём мы тоже подключаем класс голосования. Затем проводится проверка, нажата ли кнопка "Голосовать". Если нажата - получаем ID ответа, за который голосует пользователь.
Так же создаётся экземпляр класса голосования, только теперь мы не передаём ID вопроса в конструктор, тут он не требуется, так как опрерируем на данном этапе только с ответом.
Затем мы вызываем метод "add", передавая в него идентификатор вопроса:
$voting->add( $id );

В методе "add" мы используем INSERT IGNORE для того, чтобы IP пользователя повторно не записывался в таблицу, ведь лишние записи нам ни к чему.
После того, как голос пользователя учтён или проигнорирован (в случае, если IP уже есть в таблице), идёт перенаправление его на предыдущую страницу:
header( 'Location: ' . getenv('HTTP_REFERER') );

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