Как создать сущность в Drupal

В сегодняшнем уроке я расскажу, как написать свою сущность. Писать мы будем с применением модуля Entity API, поэтому нам необходимо скачать и установить этот модуль. Для примера, создадим сущность product. Файловая структура модуля у меня получилась такая:

  • product
    • product.info
    • product.install
    • product.module
    • product.pages.inc
    • product.admin.inc
    • js
      • product-fieldset-summaries.js
    • templates
      • product.tpl.php

1. Создаем информацию об модуле в product.info:

name = Product
description = Defines the product entity and associated features.
core = 7.x
dependencies[] = entity

2. В product.install описываем таблицу, в которой будет хранится информация об сущностях:

/**
 * Implements hook_schema().
 */
function product_schema() {
  $schema['product'] = array(
    'description' => 'The base table for products.',
    'fields' => array(
      'id' => array(
        'description' => 'The primary identifier for a product.',
        'type' => 'serial',
        'unsigned' => TRUE,
        'not null' => TRUE,
      ),
      'title' => array(
        'description' => 'The title of this product, always treated as non-markup plain text.',
        'type' => 'varchar',
        'length' => 255,
        'not null' => TRUE,
        'default' => '',
      ),
      'uid' => array(
        'description' => 'The {users}.uid that owns this product.',
        'type' => 'int',
        'not null' => TRUE,
        'default' => 0,
      ),
      'status' => array(
        'description' => 'Boolean indicating whether the status of this product',
        'type' => 'int',
        'not null' => TRUE,
        'default' => 1,
      ),
      'created' => array(
        'description' => 'The Unix timestamp when the product was created.',
        'type' => 'int',
        'not null' => TRUE,
        'default' => 0,
      ),
      'changed' => array(
        'description' => 'The Unix timestamp when the product was most recently saved.',
        'type' => 'int',
        'not null' => TRUE,
        'default' => 0,
      ),
    ),
    'primary key' => array('id'),
    'indexes' => array(
      'uid' => array('uid'),
      'product_changed' => array('changed'),
      'product_created' => array('created'),
      'product_status' => array('status'),
    ),
  );

  return $schema;
}

3. Перейдем к product.module. В таблице я создал колонку статус, в которой будет хранится статус продукта, всего у меня 3 статуса, для каждого из статусов создаем константу:

/**
 * Product is pending.
 */
define('PRODUCT_PENDING', 0);

/**
 * Product is completed.
 */
define('PRODUCT_COMPLETED', 1);

/**
 * Product is canceled.
 */
define('PRODUCT_CANCELED', 2);

4. Описываем информацию об нашей сущности в хуке hook_entity_info():

/**
 * Implements hook_entity_info().
 */
function product_entity_info() {
  $return = array(
    // Ключ 'product' - машинное название сущности.
    'product' => array(
      'label' => t('Product'), // Человеко-понятное название сущности.
      'entity class' => 'Entity', // Класс сущности.
      'controller class' => 'EntityAPIController', // Контроллер сущности.
      'base table' => 'product', // Таблица, в которой хранится информация об сущностях.
      'uri callback' => 'product_uri', // Функция, которая возваращает uri сущности.
      'fieldable' => TRUE, // Позволяем прикреплять поля к сущности.
      'entity keys' => array('id' => 'id'),
      // Массив, в котором описываются типы сущности Product (не знаю как правильно выразится),
      // если привести анологию с модулем Node, то это типы материалов.
      'bundles' => array(
        // Ключ 'product' - машинное название типа.
        'product' => array(
          'label' => t('Product'), // Человеко-понятное название типа.
          'admin' => array(
            'path' => 'admin/config/product/products', // Путь, по которому доступна админка.
            'access arguments' => array('configure products settings'), // Права доступа в админку.
          ),
        ),
      ),
      // Режимы отображения сущности.
      'view modes' => array(
        // Ключи 'full' и 'administrator'  - машинные названия режимов.
        'full' => array(
          'label' => t('Full'), // Человеко-понятное название режима.
          'custom settings' => TRUE, // Режим включен по умолчанию.
        ),
        'administrator' => array(
          'label' => t('Administrator'),
          'custom settings' => TRUE,
        ),
      ),
      'module' => 'product',
    ),
  );

  return $return;
}

5. Создаем функцию product_uri(), которая возвращает uri сущности:

/**
 * Implements callback_entity_info_uri().
 */
function product_uri($product) {
  return array(
    'path' => 'product/' . $product->id,
  );
}

6. Создаем права доступа, которые нам потребуются в дальнейшем, для этого имплементируем хук hook_permission():

/**
 * Implements hook_permission().
 */
function product_permission() {
  return array(
    'configure products settings' => array(
      'title' => t('Configure products settings'),
      'description' => t('Allows users to configure products settings.'),
      'restrict access' => TRUE,
    ),
    'administer products' => array(
      'title' => t('Administer products'),
      'restrict access' => TRUE,
    ),
    'edit any products' => array(
      'title' => t('Edit any product'),
      'restrict access' => TRUE,
    ),
    'edit own products' => array(
      'title' => t('Edit own products'),
      'restrict access' => TRUE,
    ),
    'delete any products' => array(
      'title' => t('Delete any products'),
      'restrict access' => TRUE,
    ),
    'delete own products' => array(
      'title' => t('Delete own products'),
      'restrict access' => TRUE,
    ),
    'create products' => array(
      'title' => t('Create new products'),
    ),
    'view products' => array(
      'title' => t('View products'),
    ),
  );
}

7. Создаем функцию product_access(), которая будет проверять, может ли пользователь совершать какие либо операции над сущностью:

/**
 * Determines whether the current user may perform the operation on the product.
 *
 * @param $op
 *   The operation to be performed on the product. Possible values are:
 *   - "view"
 *   - "update"
 *   - "delete"
 *   - "create"
 * @param $entity_type
 *   The entity type on which the operation is to be perform.
 * @param $product
 *   The product object on which the operation is to be performed.
 * @param $account
 *   Optional, a user object representing the user for whom the operation is to
 *   be performed. Determines access for a user other than the current user.
 *
 * @return bool
 *   TRUE if the operation may be performed, FALSE otherwise.
 */
function product_access($op, $entity_type, $product = NULL, $account = NULL) {

  $rights = &drupal_static(__FUNCTION__, array());

  if (!in_array($op, array('view', 'update', 'delete', 'create'), TRUE)) {
    // Если $op не равен ни одной из поддерживаемых операций, возвращаем "доступ запрещен".
    return FALSE;
  }

  // Если в функцию не передан пользователь, то проверяем права для текущего пользователя.
  if (empty($account)) {
    global $user;
    $account = $user;
  }

  // $product может быть объектом или не существовать, поэтому испольем его id,
  // или $entity_type в качестве статичного идентификатора, который будет использоваться ключом кеша.
  $cid = is_object($product) && !empty($product->id) ? $product->id : $entity_type;

  // Если мы уже проверяли для данной сущности и пользователя права доступа,
  // то возвращаем их из кеша.
  if (isset($rights[$account->uid][$cid][$op])) {
    return $rights[$account->uid][$cid][$op];
  }
  
  // Проверяем, может ли пользователь создавать новые продукты.
  if ($op == 'create' && user_access('create products', $account)) {
    $rights[$account->uid][$cid][$op] = TRUE;
    return TRUE;
  }

  if ($op == 'update') {
    // Проверяем, может ли пользователь редактирвоать любые продукты.
    if (user_access('edit any products', $account)) {
      $rights[$account->uid][$cid][$op] = TRUE;
      return TRUE;
    }
    // Проверяем, может ли пользователь редактирвоать свои продукты.
    elseif (user_access('edit own products', $account) && $product->uid == $account->uid) {
      $rights[$account->uid][$cid][$op] = TRUE;
      return TRUE;
    }
  }
  elseif ($op == 'delete') {
    // Проверяем, может ли пользователь удалять любые продукты.
    if (user_access('delete any products', $account)) {
      $rights[$account->uid][$cid][$op] = TRUE;
      return TRUE;
    }
    // Проверяем, может ли пользователь удалять свои продукты.
    elseif (user_access('delete own products', $account) && $product->uid == $account->uid) {
      $rights[$account->uid][$cid][$op] = TRUE;
      return TRUE;
    }
  }
  // Проверяем, может ли пользователь просматривать продукты.
  elseif ($op == 'view' && user_access('view products', $account)) {
    $rights[$account->uid][$cid][$op] = TRUE;
    return TRUE;
  }

  return FALSE;
}

8. Создаем функции, которые будут загружать сущность из базы данных:

/**
 * Loads a product by ID.
 */
function product_load($product_id) {
  $products = product_load_multiple(array($product_id), array());
  return $products ? reset($products) : FALSE;
}

/**
 * Loads multiple products by ID or based on a set of matching conditions.
 *
 * @see entity_load()
 *
 * @param $product_ids
 *   An array of product IDs.
 * @param $conditions
 *   An array of conditions to filter loaded products by on the {product}
 *   table in the form 'field' => $value.
 * @param $reset
 *   Whether to reset the internal product loading cache.
 *
 * @return
 *   An array of product objects indexed by product_id.
 */
function product_load_multiple($product_ids = array(), $conditions = array(), $reset = FALSE) {
  return entity_load('product', $product_ids, $conditions, $reset);
}

/**
 * Implements hook_entity_load().
 */
function product_entity_load($entities, $type) {
  // По умолчанию, сущность имеет только uid пользователя, который создал ее,
  // добавляем этот код, чтобы получить еще и имя пользователя.
  if ($type == 'product') {
    foreach ($entities as $entity) {
      if ($entity->uid) {
        if ($account = user_load($entity->uid)) {
          $entity->name = $account->name;
        }
      }
      else {
        $entity->name = variable_get('anonymous', t('Anonymous'));
      }
    }
  }
}

9. Создаем функции, которые будут удалять сущность из базы данных:

/**
 * Deletes a product.
 *
 * @param $product_id
 *   A product ID.
 */
function product_delete($product_id) {
  product_delete_multiple(array($product_id));
}

/**
 * Deletes multiple products.
 *
 * @param $product_ids
 *   An array of product IDs.
 */
function product_delete_multiple($product_ids) {
  entity_delete_multiple('product', $product_ids);
}

10. Создаем функцию темизации сущности, для этого имплементируем hook_theme():

/**
 * Implements hook_theme().
 */
function product_theme() {
  return array(
    'product' => array(
      'render element' => 'elements',
      'template' => 'templates/product',
    ),
  );
}

/**
 * Processes variables for product.tpl.php
 */
function template_preprocess_product(&$variables) {
  $variables['view_mode'] = $variables['elements']['#view_mode'];

  $variables['product'] = $variables['elements']['#product'];
  $product = $variables['product'];

  $variables['date'] = format_date($product->created);
  $variables['name'] = theme('username', array('account' => $product));

  $uri = product_uri($product);

  $variables['product_url']  = url($uri['path']);
  $variables['title'] = check_plain($product->title);
  $variables['page'] = $variables['view_mode'] == 'full';

  $variables = array_merge((array) $product, $variables);

  $variables += array('content' => array());
  foreach (element_children($variables['elements']) as $key) {
    $variables['content'][$key] = $variables['elements'][$key];
  }

  // Делаем поля доступными в качестве переменных для соответствующего языка.
  field_attach_preprocess('product', $product, $variables['content'], $variables);

  $variables['submitted'] = t('Submitted by !username on !datetime', array('!username' => $variables['name'], '!datetime' => $variables['date']));

  $variables['title_attributes_array']['class'][] = 'product-title';

  if ($variables['status'] == PRODUCT_PENDING) {
    $variables['classes_array'][] = 'product-pending';
  }
  elseif ($variables['status'] == PRODUCT_COMPLETED) {
    $variables['classes_array'][] = 'product-completed';
  }
  elseif ($variables['status'] == PRODUCT_CANCELED) {
    $variables['classes_array'][] = 'product-canceled';
  }
}

11. Создаем функции, которые будут выводить сущности:

/**
 * Constructs a drupal_render() style array from an array of loaded products.
 *
 * @param $products
 *   An array of products as returned by product_load_multiple().
 * @param $view_mode
 *   View mode, e.g. 'teaser', 'full'...
 * @param $langcode
 *   (optional) A language code to use for rendering. Defaults to NULL which is
 *   the global content language of the current request.
 *
 * @return array
 *   An array in the format expected by drupal_render().
 */
function product_view_multiple($products, $view_mode = 'full', $langcode = NULL) {
  // Подготавливаем данные для отображения.
  field_attach_prepare_view('product', $products, $view_mode, $langcode);
  entity_prepare_view('product', $products, $langcode);
  $build = array();
  $weight = 0;
  foreach ($products as $product) {
    $build['products'][$product->id] = product_view($product, $view_mode, $langcode);
    $build['products'][$product->id]['#weight'] = $weight++;
  }
  $build['products']['#sorted'] = TRUE;
  return $build;
}

/**
 * Generates an array for rendering the given product.
 *
 * @param $product
 *   A product object.
 * @param $view_mode
 *   View mode, e.g. 'teaser', 'full'...
 * @param $langcode
 *   (optional) A language code to use for rendering. Defaults to the global
 *   content language of the current request.
 *
 * @return array
 *   An array as expected by drupal_render().
 */
function product_view($product, $view_mode = 'full', $langcode = NULL) {
  if (!isset($langcode)) {
    global $language;
    $langcode = $language->language;
  }

  // Заполняем $product->content данными в виде рендерного массива.
  product_build_content($product, $view_mode, $langcode);

  $build = $product->content;
  // Скрываем данные, чтобы избежать их дублирования при выводе $product->content.
  unset($product->content);

  $build += array(
    '#theme' => 'product',
    '#product' => $product,
    '#view_mode' => $view_mode,
    '#language' => $langcode,
  );

  return $build;
}

/**
 * Builds a structured array representing the product's content.
 *
 * @param $product
 *   A product object.
 * @param $view_mode
 *   View mode, e.g. 'teaser', 'full'...
 * @param $langcode
 *   (optional) A language code to use for rendering. Defaults to the global
 *   content language of the current request.
 */
function product_build_content($product, $view_mode = 'full', $langcode = NULL) {
  if (!isset($langcode)) {
    global $language;
    $langcode = $language->language;
  }

  // Удаляем существующий конент, если существует.
  $product->content = array();

  field_attach_prepare_view('product', array($product->id => $product), $view_mode, $langcode);
  entity_prepare_view('product', array($product->id => $product), $langcode);
  $product->content += field_attach_view('product', $product, $view_mode, $langcode);

  $product->content += array('#view_mode' => $view_mode);

  $product->content['links'] = array(
    '#theme' => 'links__product',
    '#pre_render' => array('drupal_pre_render_links'),
    '#attributes' => array('class' => array('links')),
  );
}

12. В hook_menu() создаем страницы, которые потребуются для работы модуля:

/**
 * Implements hook_menu().
 */
function product_menu() {
  // Top level "Product" container.
  $items['admin/config/product'] = array(
    'title' => 'Product',
    'description' => 'Administration tools.',
    'page callback' => 'system_admin_menu_block_page',
    'access arguments' => array('access administration pages'),
    'file' => 'system.admin.inc',
    'file path' => drupal_get_path('module', 'system'),
  );
  $items['admin/config/product/products'] = array(
    'title' => 'Products',
    'description' => 'Configure general product settings, fields, and displays.',
    'page callback' => 'product_admin_products',
    'access arguments' => array('administer products'),
    'type' => MENU_NORMAL_ITEM,
    'file' => 'product.admin.inc',
  );
  $items['admin/config/product/products/list'] = array(
    'title' => 'Products',
    'type' => MENU_DEFAULT_LOCAL_TASK,
    'weight' => -10,
  );
  // Страница создания продукта.
  $items['product/add'] = array(
    'title' => 'Add product',
    'page callback' => 'product_page_add',
    'access callback' => 'product_access',
    'access arguments' => array('create', 'product'),
    'file' => 'product.pages.inc',
  );
  // Страница просмотра продукта.
  $items['product/%product'] = array(
    'title callback' => 'product_page_title',
    'title arguments' => array(1),
    'page callback' => 'product_page_view',
    'page arguments' => array(1),
    'access callback' => 'product_access',
    'access arguments' => array('view', 'product', 1),
    'file' => 'product.pages.inc',
  );
  $items['product/%product/view'] = array(
    'title' => 'View',
    'type' => MENU_DEFAULT_LOCAL_TASK,
    'weight' => 0,
  );
  // Страница редактирования продукта.
  $items['product/%product/edit'] = array(
    'title' => 'Edit',
    'page callback' => 'product_page_edit',
    'page arguments' => array(1),
    'access callback' => 'product_access',
    'access arguments' => array('update', 'product', 1),
    'weight' => 1,
    'type' => MENU_LOCAL_TASK,
    'file' => 'product.pages.inc',
  );
  // Страница удаления продукта.
  $items['product/%product/delete'] = array(
    'title' => 'Delete',
    'page callback' => 'drupal_get_form',
    'page arguments' => array('product_delete_confirm', 1),
    'access callback' => 'product_access',
    'access arguments' => array('delete', 'product', 1),
    'file' => 'product.pages.inc',
  );

  return $items;
}

13. С помощью hook_admin_paths(), говорим системе, какие страницы являются административными:

/**
 * Implements hook_admin_paths().
 */
function product_admin_paths() {
  $paths = array(
    'product/add' => TRUE,
    'product/*/edit' => TRUE,
    'product/*/delete' => TRUE,
  );
  return $paths;
}

14. Последней функцией в product.module будет product_status_get_title(), которая по id статуса продукта будет возвращать человеко-понятное название статуса:

/**
 * Returns the human readable title of any or all product statuses.
 *
 * @param $id
 *   Optional parameter specifying the id of the product status whose title
 *     to return.
 *
 * @return mixed
 *   Either an array of all product status titles keyed by the status_id or a
 *     string containing the human readable title for the specified status.
 */
function product_status_get_title($id = NULL) {
  $ids = array(
    PRODUCT_PENDING => t('Pending'),
    PRODUCT_COMPLETED => t('Completed'),
    PRODUCT_CANCELED => t('Canceled'),
  );

  if (isset($id)) {
    return $ids[$id];
  }

  return $ids;
}

15. С product.module закончили, теперь переходим к product.pages.inc, в этом файле создадим все колбеки для страниц. Создаем функции, которые будут показывать сущность по адресу product/%product:

/**
 * Title callback: Returns the title of the product.
 *
 * @param $product
 *   The product object.
 *
 * @return
 *   An unsanitized string that is the title of the product.
 *
 * @see product_menu()
 */
function product_page_title($product) {
  return $product->title;
}

/**
 * Menu callback: Displays a single product.
 *
 * @param $product
 *   The product object.
 *
 * @return array
 *   A page array suitable for use by drupal_render().
 *
 * @see product_menu()
 */
function product_page_view($product) {
  // For markup consistency with other pages, use product_view_multiple() rather than product_view().
  $products = product_view_multiple(array($product->id => $product), 'full');

  return $products;
}

16. Создаем функцию product_page_add(), которая будет выводить форму создания сущности по адресу product/add:

/*
 * Create a basic entity structure to be used and passed to the validation
 * and submission functions.
 */
function product_page_add() {
  global $user;
  $name = variable_get('anonymous', t('Anonymous'));
  if ($user->uid && $account = user_load($user->uid)) {
    $name = $account->name;
  }

  $product = entity_create('product', array('uid' => $user->uid, 'name' => $name));
  return drupal_get_form('product_form', $product, 'create');
}

17. Создаем функцию product_page_edit(), которая будет показывать форму редактирования сущности, по адресу product/%product/edit:

/*
 * Presents the product editing form.
 */
function product_page_edit($product) {
  drupal_set_title(t('<em>Edit</em> @title', array('@title' => $product->title)), PASS_THROUGH);
  return drupal_get_form('product_form', $product);
}

18. Как вы могли заметить, для создания и редактирвоания сущности у меня используется одна и таже форма product_form, создаем функцию, формирующую эту форму:

/**
 * Form constructor for the product add/edit form.
 *
 * @see product_form_validate()
 * @see product_form_submit()
 * @see product_form_delete_submit()
 * @ingroup forms
 */
function product_form($form, &$form_state, $product, $op = 'update') {
  $form['#attributes']['class'][] = 'product-form';

  $form['product'] = array(
    '#type' => 'value',
    '#value' => $product,
  );

  $form['title'] = array(
    '#type' => 'textfield',
    '#title' => t('Title'),
    '#default_value' => !empty($product->title) ? $product->title : '',
    '#required' => TRUE,
  );

  $form['additional_settings'] = array(
    '#type' => 'vertical_tabs',
    '#weight' => 99,
  );

  // Информация об авторе продукта.
  $form['author'] = array(
    '#type' => 'fieldset',
    '#access' => user_access('administer products'),
    '#title' => t('Authoring information'),
    '#collapsible' => TRUE,
    '#collapsed' => TRUE,
    '#group' => 'additional_settings',
    '#attributes' => array(
      'class' => array('product-form-author'),
    ),
    '#attached' => array(
      'js' => array(
        drupal_get_path('module', 'product') . '/js/product-fieldset-summaries.js',
        array(
          'type' => 'setting',
          'data' => array('anonymous' => variable_get('anonymous', t('Anonymous'))),
        ),
      ),
    ),
    '#weight' => 90,
  );
  $form['author']['name'] = array(
    '#type' => 'textfield',
    '#title' => t('Authored by'),
    '#maxlength' => 60,
    '#autocomplete_path' => 'user/autocomplete',
    '#default_value' => !empty($product->name) ? $product->name : '',
    '#weight' => -1,
    '#description' => t('Leave blank for %anonymous.', array('%anonymous' => variable_get('anonymous', t('Anonymous')))),
  );
  $form['author']['date'] = array(
    '#type' => 'textfield',
    '#title' => t('Authored on'),
    '#maxlength' => 25,
    '#description' => t('Format: %time. The date format is YYYY-MM-DD and %timezone is the time zone offset from UTC. Leave blank to use the time of form submission.', array('%time' => !empty($product->created) ? format_date($product->created, 'custom', 'Y-m-d H:i:s O') : format_date(REQUEST_TIME, 'custom', 'Y-m-d H:i:s O'), '%timezone' => !empty($product->created) ? format_date($product->created, 'custom', 'O') : format_date(REQUEST_TIME, 'custom', 'O'))),
    '#default_value' => !empty($product->created) ? format_date($product->created, 'custom', 'Y-m-d H:i:s O') : '',
  );

  // Статусы продукта.
  $form['options'] = array(
    '#type' => 'fieldset',
    '#access' => user_access('administer products'),
    '#title' => t('Product status'),
    '#collapsible' => TRUE,
    '#collapsed' => TRUE,
    '#group' => 'additional_settings',
    '#attributes' => array(
      'class' => array('product-form-options'),
    ),
    '#attached' => array(
      drupal_get_path('module', 'product') . '/js/product-fieldset-summaries.js',
    ),
    '#weight' => 95,
  );
  $form['options']['status'] = array(
    '#type' => 'radios',
    '#title' => t('Status'),
    '#default_value' => isset($product->status) ? $product->status : PRODUCT_PENDING,
    '#options' => array(
      PRODUCT_PENDING => product_status_get_title(PRODUCT_PENDING),
      PRODUCT_COMPLETED => product_status_get_title(PRODUCT_COMPLETED),
      PRODUCT_CANCELED => product_status_get_title(PRODUCT_CANCELED),
    ),
  );

  // Кнопка отправки формы.
  $form['actions'] = array('#type' => 'actions');
  $form['actions']['submit'] = array(
    '#type' => 'submit',
    '#value' => t('Save'),
    '#weight' => 5,
  );
  
  // Если пользователь редактирует продукт и у него есть разрешение для удаления продукта,
  // то добавляем кнопку "Удалить".
  if ($op == 'update' && product_access('delete', 'product', $product)) {
    $form['actions']['delete'] = array(
      '#type' => 'submit',
      '#value' => t('Delete'),
      '#weight' => 15,
      '#submit' => array('product_form_delete_submit'),
    );
  }
  
  // Прикрепляем все созданные поля сущности к форме.
  field_attach_form('product', $product, $form, $form_state);

  return $form;
}

/**
 * Form validation handler for product_form().
 *
 * @see product_form()
 * @see product_form_submit()
 */
function product_form_validate($form, &$form_state) {
  $product = $form_state['values']['product'];
  
  // Проверяем корректность автора.
  $product->uid = 0;
  if (!empty($form_state['values']['name'])) {
    if ($account = user_load_by_name($form_state['values']['name'])) {
      $product->uid = $account->uid;
    }
    elseif ($form_state['values']['name'] != variable_get('anonymous', t('Anonymous'))) {
      form_set_error('name', t('The username %name does not exist.', array('%name' => $form_state['values']['name'])));
    }
  }

  // Проверяем корректность даты публикации.
  if (!empty($form_state['values']['date'])) {
    if (strtotime($form_state['values']['date']) === FALSE) {
      form_set_error('date', t('You have to specify a valid date.'));
    }
    else {
      $date = date_create($form_state['values']['date']);
      $product->created = $date->getTimestamp();
    }
  }
  else {
    $product->created = REQUEST_TIME;
  }
}

/**
 * Form submission handler for product_form().
 *
 * @see product_form()
 * @see product_form_validate()
 */
function product_form_submit($form, &$form_state) {
  $product = $form_state['values']['product'];
  $product->title = $form_state['values']['title'];
  $product->changed = REQUEST_TIME;
  $product->status = $form_state['values']['status'];

  field_attach_submit('product', $product, $form, $form_state);

  $is_new = !empty($product->is_new) ? TRUE : FALSE;
  $product->save();

  if ($is_new) {
    drupal_set_message(t('%title has been created.', array('%title' => $product->title)));
  }
  else {
    drupal_set_message(t('%title has been updated.', array('%title' => $product->title)));
  }

  $form_state['redirect'] = array('product/' . $product->id);
}

/**
 * Form submission handler for product_form().
 *
 * Handles the 'Delete' button on the product form.
 *
 * @see product_form()
 * @see product_form_validate()
 */
function product_form_delete_submit($form, &$form_state) {
  $destination = array();
  if (isset($_GET['destination'])) {
    $destination = drupal_get_destination();
    unset($_GET['destination']);
  }
  $product = $form_state['values']['product'];
  $form_state['redirect'] = array('product/' . $product->id . '/delete', array('query' => $destination));
}

19. Создаем форму удаления сущности:

/**
 * Form constructor for the product deletion confirmation form.
 *
 * @see product_delete_confirm_submit()
 */
function product_delete_confirm($form, &$form_state, $product) {
  $form['product_id'] = array('#type' => 'value', '#value' => $product->id);
  return confirm_form($form,
    t('Are you sure you want to delete %title?', array('%title' => $product->title)),
    'products',
    t('This action cannot be undone.'),
    t('Delete'),
    t('Cancel')
  );
}

/**
 * Executes product deletion.
 *
 * @see product_delete_confirm()
 */
function product_delete_confirm_submit($form, &$form_state) {
  if ($form_state['values']['confirm']) {
    $product = product_load($form_state['values']['product_id']);
    product_delete($form_state['values']['product_id']);
    drupal_set_message(t('%title has been deleted.', array('%title' => $product->title)));
  }

  $form_state['redirect'] = 'admin/config/product/products';
}

20. С product.pages.inc закончили, теперь переходим к product.admin.inc, в этом файле будет одна функция product_admin_products(), которая будет выводить список всех созданных продуктов по адресу admin/config/product/products:

/**
 * Page callback: Builds the product administration overview.
 */
function product_admin_products() {

  // Формируем сортируемую шапку для таблицы.
  $header = array(
    'title' => array('data' => t('Title'), 'field' => 'p.title'),
    'author' => t('Author'),
    'status' => array('data' => t('Status'), 'field' => 'p.status'),
    'changed' => array('data' => t('Updated'), 'field' => 'p.changed', 'sort' => 'desc'),
    'operations' => array('data' => t('Operations')),
  );

  $query = db_select('product', 'p')->extend('PagerDefault')->extend('TableSort');
  $product_ids = $query->fields('p', array('id'))
    ->limit(50)
    ->orderByHeader($header)
    ->execute()
    ->fetchCol();

  $products = product_load_multiple($product_ids);

  // Подготавливаем список продуктов.
  $destination = drupal_get_destination();
  $rows = array();
  foreach ($products as $product) {
    $rows[$product->id] = array(
      'title' => array(
        'data' => array(
          '#type' => 'link',
          '#title' => $product->title,
          '#href' => 'product/' . $product->id,
        ),
      ),
      'author' => theme('username', array('account' => $product)),
      'status' => product_status_get_title($product->status),
      'changed' => format_date($product->changed, 'short'),
    );

    // Формируем список доступных операций над текущим продуктом.
    $operations = array();
    if (product_access('update', 'product', $product)) {
      $operations['edit'] = array(
        'title' => t('edit'),
        'href' => 'product/' . $product->id . '/edit',
        'query' => $destination,
      );
    }
    if (product_access('delete', 'product', $product)) {
      $operations['delete'] = array(
        'title' => t('delete'),
        'href' => 'product/' . $product->id . '/delete',
        'query' => $destination,
      );
    }

    $rows[$product->id]['operations'] = array();
    if (count($operations) > 1) {
      // Выводим операции в виде списка.
      $rows[$product->id]['operations'] = array(
        'data' => array(
          '#theme' => 'links__product_operations',
          '#links' => $operations,
          '#attributes' => array('class' => array('links', 'inline')),
        ),
      );
    }
    elseif (!empty($operations)) {
      // Выводим первую и единственную операцию.
      $link = reset($operations);
      $rows[$product->id]['operations'] = array(
        'data' => array(
          '#type' => 'link',
          '#title' => $link['title'],
          '#href' => $link['href'],
          '#options' => array('query' => $link['query']),
        ),
      );
    }
  }

  $page = array();
  $page['products'] = array(
    '#theme' => 'table',
    '#header' => $header,
    '#rows' => $rows,
    '#empty' => t('No products available.'),
  );

  $page['pager'] = array('#markup' => theme('pager'));
  return $page;
}

21. Теперь переходим к шаблону product.tpl.php:

<div id="product-<?php print $product->id; ?>" class="<?php print $classes; ?> clearfix"<?php print $attributes; ?>>

  <?php print render($title_prefix); ?>
  <?php if (!$page): ?>
    <h2<?php print $title_attributes; ?>><?php print $title; ?></h2>
  <?php endif; ?>
  <?php print render($title_suffix); ?>

  <div class="submitted">
    <?php print $submitted; ?>
  </div>

  <div class="content"<?php print $content_attributes; ?>>
    <?php print render($content); ?>
  </div>

</div>

21. Осталось дело за малым, если помните, в форме создания/редактирования продукта мы сделали вериткальные вкладки, поэтому в файл product-fieldset-summaries.js добавляем несколько строк кода для украшения этих вкладок:

(function ($) {
  Drupal.behaviors.productFieldsetSummaries = {
    attach: function (context) {
      $('fieldset.product-form-author', context).drupalSetSummary(function (context) {
        return $('div.form-item-name input', context).val();
      });

      $('fieldset.product-form-author', context).drupalSetSummary(function (context) {
        var name = $('div.form-item-name input', context).val() || Drupal.settings.anonymous,
          date = $('div.form-item-date input', context).val();

        return date ?
          Drupal.t('By @name on @date', { '@name': name, '@date': date }) :
          Drupal.t('By @name', { '@name': name });
      });

      $('fieldset.product-form-options', context).drupalSetSummary(function (context) {
        var vals = [];
        var statusId = $('div.form-item-status input:checked', context).attr('id');
        var status = $('div.form-item-status label[for=' + statusId + ']', context).text();

        vals.push(Drupal.checkPlain($.trim(status)));
        return vals.join(', ');
      });
    }
  };
})(jQuery);

Вот и все. Так же читайте как связать сущность с модулем Views.

Комментарии (29)

Аватар пользователя Alex
Alex

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

Аватар пользователя Benya
Benya

Продолжение обязательно будет!

Аватар пользователя Sergey Krasulevskiy
Sergey Krasulevskiy

Код выдаёт ошибку, поэтому нужно переделать

function product_view($product, $view_mode = 'full', $langcode = NULL) {
  if (!isset($langcode)) {
    global $language;
    $langcode = $language->language;
  }
 
  // Заполняем $product->content данными в виде рендерного массива.
  product_build_content($product, $view_mode, $langcode);
 
  $build = $product->content;
  // Скрываем данные, чтобы избежать их дублирования при выводе $product->content.
  unset($product->content);
 
  $build += array(
    '#theme' => 'product',
    '#product' => $product,
    '#view_mode' => $view_mode,
    '#language' => $langcode,
  );
 
  return $build;
}

на что-то типа такого

function product_view($product, $view_mode = 'full', $langcode = NULL) {
 
  $entity_type = $product->entityType();  
 
  if (!isset($langcode)) {
    global $language;
    $langcode = $language->language;
  }   
    
  $product->content = entity_build_content($entity_type, $product, $view_mode, $langcode);

  $build = $product->content;  
  unset($product->content);  
    
  $build += array(
    '#theme' => $entity_type,
    '#product' => $product,
    '#view_mode' => $view_mode,
    '#language' => $langcode,
  );
 
  return $build;
}
Аватар пользователя Benya
Benya

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

Аватар пользователя Benya
Benya

Видимо, ругалось на отсутствие функции product_build_content(), добавил ее, теперь должно быть все ок.

Аватар пользователя Sergey Krasulevskiy
Sergey Krasulevskiy

Теперь всё работает. Спасибо за урок!

Аватар пользователя Baklaghan
Baklaghan

Добрый день.

Пытаюсь создать сущность как вы описали. У меня сущность dog вместо вашего product.
Использовал ваш код только заменил product на dog. Естественно с соблюдением регистра и т.п.

при включении модуля пишет вот такие ворнинги

Warning: array_keys() expects parameter 1 to be array, null given в функции drupal_schema_fields_sql() (строка 7057 в файле /home/sage/mysite.com/includes/common.inc).
Warning: array_keys() expects parameter 1 to be array, null given в функции drupal_schema_fields_sql() (строка 7057 в файле /home/sage/mysite.com/includes/common.inc).
Warning: array_keys() expects parameter 1 to be array, null given в функции drupal_schema_fields_sql() (строка 7057 в файле /home/sage/mysite.com/includes/common.inc).
Warning: Invalid argument supplied for foreach() в функции entity_metadata_convert_schema() (строка 151 в файле /home/sage/mysite.com/sites/all/modules/entity/entity.info.inc).

можете подсказать что не так?

Аватар пользователя Benya
Benya

Трудно что то сказать без кода, покажите что у вас в hook_schema()

Аватар пользователя Baklaghan
Baklaghan
/**
 * Implements hook_schema().
 */
function dog_schema() {
  $schema['dog'] = array(
    'description' => 'The base table for dogs.',
    'fields' => array(
      'id' => array(
        'description' => 'The primary identifier for a dog.',
        'type' => 'serial',
        'unsigned' => TRUE,
        'not null' => TRUE,
      ),
      'title' => array(
        'description' => 'The title of this dog, always treated as non-markup plain text.',
        'type' => 'varchar',
        'length' => 255,
        'not null' => TRUE,
        'default' => '',
      ),
      'uid' => array(
        'description' => 'The {users}.uid that owns this dog.',
        'type' => 'int',
        'not null' => TRUE,
        'default' => 0,
      ),
      'status' => array(
        'description' => 'Boolean indicating whether the status of this dog',
        'type' => 'int',
        'not null' => TRUE,
        'default' => 1,
      ),
      'created' => array(
        'description' => 'The Unix timestamp when the dog was created.',
        'type' => 'int',
        'not null' => TRUE,
        'default' => 0,
      ),
      'changed' => array(
        'description' => 'The Unix timestamp when the dog was most recently saved.',
        'type' => 'int',
        'not null' => TRUE,
        'default' => 0,
      ),
    ),
    'primary key' => array('id'),
    'indexes' => array(
      'uid' => array('uid'),
      'dog_changed' => array('changed'),
      'dog_created' => array('created'),
      'dog_status' => array('status'),
    ),
  );

  return $schema;
}
Аватар пользователя Benya
Benya

Скиньте свой модуль - посмотрю на локалке

Аватар пользователя Benya
Benya

У вас в некоторых файлах, таких как dog.install, dog.pages.inc, dog.admin.inc нет открывающего тега <?php в начале файлов

Аватар пользователя Baklaghan
Baklaghan

мдяяя.... я балбес!!!

спасибо!
извините что отнял время.

Аватар пользователя Евгений
Евгений

Отличный урок! Теперь было бы вообще прекрасно программно создавать эти самые products :)
То есть, мы создали модуль, который представляет собой форму создания сущности, а теперь хотелось бы создать эту самую сущность программно (то есть заполнить все поля и сохранить).

Аватар пользователя Benya
Benya

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

Аватар пользователя Василий
Василий

Сделал всё как в уроке - всё работает. но нужно было сделать ещё пару полей. Решил не присоединять их через Field API, а сделать встроенными.
Всё сделал, всё сохраняется, вот только поля не выводятся на странице просмотра сущности (product/%product). Никак не могу добиться этого. И ещё подскажите, обязательно ли прописывать 'view modes' если он по логике один?

Аватар пользователя Benya
Benya

По поводу полей - трудно что то сказать, не видя перед глазами код. 'view modes' можно не прописывать.

Аватар пользователя Василий
Василий

Во первых, добавил в схему (в массив fields, естественно):

'level_1' => array(
  'description' => 'Level 1',
  'type' => 'int',
  'default' => 0,
  'unsigned' => TRUE,
  'not null' => TRUE,
),
'level_2' => array(
  'description' => 'Level 2',
  'type' => 'int',
  'default' => 0,
  'unsigned' => TRUE,
  'not null' => TRUE,
),

в hook_form добавил ключи для новых полей:

$form['level_1'] = array(
  '#type' => 'textfield',
  '#title' => t('Level 1'),
  '#description' => t('Compensation of the 1 level, for example 2000'),
  '#default_value' => !empty($product->level_1) ? $product->level_1 : '0',
  '#size' => 40,
  '#required' => FALSE,
);  
 
$form['level_2'] = array(
  '#type' => 'textfield',
  '#title' => t('Level 2'),
  '#description' => t('Compensation of the 2 level, for example 1500'),
  '#default_value' => !empty($product->level_2) ? $product->level_2 : '0',
  '#size' => 40,
  '#required' => FALSE,
);

в hook_form_submit добавил :

$product->level_1 = $form_state['values']['level_1'];
$product->level_2 = $form_state['values']['level_2'];

Теперь есть новые поля, они нормально работают, но хотелось бы видеть их на странице самой сущности.
Как грамотно это реализовать?
Я еще добавил hook_field_extra_fields():

function product_field_extra_fields() {
  $form_elements['title'] = array(
    'label' => t('Title'),
    'description' => t('Title'),
    'weight' => -1,
  );
  $form_elements['level_1'] = array(
    'label' => t('Level 1'),
    'description' => t('Compensation of the 1 level'),
    'weight' => 0,
  );
  $form_elements['level_2'] = array(
    'label' => t('Level 2'),
    'description' => t('Compensation of the 2 level'),
    'weight' => 1,
  );  
 
  $display_elements['level_1'] = array(
    'label' => t('Level 1'),
    'description' => t('Compensation of the 1 level'),
    'weight' => 0,
  );
  $display_elements['level_2'] = array(
    'label' => t('Level 2'),
    'description' => t('Compensation of the 2 level'),
    'weight' => 1,
  );

  // Since we have only one bundle type, we'll just provide the extra_fields
  // for it here.
  $extra_fields[' product'][' product']['form'] = $form_elements;
  $extra_fields[' product'][' product']['display'] = $display_elements;

  return $extra_fields;
}

Появилась возможность управлять новыми (да и старыми) полями из админки, но вот если перемещение форм полей работает отлично, то управление отображением никак не влияет на вывод полей в самой сущности. Заранее спасибо!

Аватар пользователя Василий
Василий

Добавил следующее, как в примере:

/**
* Implements hook_field_extra_fields().
*/
function product_field_extra_fields() {
  $extra = array();
    // Define extra invoice fields
    $extra['product']['product'] = array(
      'display' => array(
        // Customer ID field
        'level_1' => array(
          'label' => t('level_1'),
          'description' => t('level_1'),
          'weight' => 0,
          'callback' => '_extra_field_level_1_callback'
        )
      ),
    );
  return $extra;
}

/**
 * Callback function.
 * Returns value for extra field: field_i_total.
 */
function _extra_field_level_1_callback($entity) {
  // a bunch of logic
  $total = '5000';
  return $total;
}


/**
 * Implements hook_entity_view().
 */
function product_entity_view($entity, $type, $view_mode, $langcode) {
  // Extra fields
  $extra_fields = field_info_extra_fields($type, $entity->type, 'display');
  // Check the field is displayed for the current view_mode
  $field_name = 'level_1';
  if(!empty($extra_fields[$field_name]['display'][$view_mode]['visible'])) {
    $entity->content[$field_name] = array(
      '#markup' => _extra_field_level_1_callback($entity)
    );
  }
}

Не помогло...

Аватар пользователя Василий
Василий

Помогло вот это: https://www.drupal.org/node/2220557 - У меня получилось добавить все свойства сущности в управление отображением, при этом я добавил (кроме, конечно, контроллеров) только вот это:

class MyEntityMetadataController extends EntityDefaultMetadataController {
  public function entityPropertyInfo() {
    $info = parent::entityPropertyInfo();
    return $info;
  }
}

Ну и чтобы выводить непосредственно в $content вот это в product_build_content:

$product->content['level_1'] = array(       
  '#markup' => $product->level_1,
  '#prefix' =>'<span class="field-label">'.t('Level 1').':</span><div class="field-level_1 inline">',
  '#suffix' => '</div>',      
);
 
$product->content['level_2'] = array(
  '#markup' => $product->level_2,
  '#prefix' =>'<span class="field-label">'.t('Level 2').':</span><div class="field-level_2 inline">',
  '#suffix' => '</div>',
);

Не понял зачем у вас в этой функции нужно:

$product->content['links'] = array(
  '#theme' => 'links__product',
  '#pre_render' => array('drupal_pre_render_links'),
  '#attributes' => array('class' => array('links')),
);

Оно же не используется никак

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

Вот все коды: https://github.com/lukasvs/entity_with_properties_as_fields

Аватар пользователя Benya
Benya

"Не понял зачем у вас в этой функции нужно: "
Просто пример, здесь создается массив с ссылками (как у ноды) и в него можно добавлять нужные элементы

Аватар пользователя dark_kz
dark_kz

Добрый день! Спасибо за материал, полезно!
У меня, к сожалению, тоже не получилось добавить в $content для вывода в шаблоне переменные.

<?php
$product->content['description'] = array(
  '#type' => 'markup',
  '#markup' => $product->description,
  '#prefix' => '<div class="product-description">',
  '#suffix' => '</div>',
);
?>

Если выводить сам $product->description, то конечно поле не пустое

Аватар пользователя dark_kz
dark_kz

Вопрос снимается. Проблема была в функции template_preprocess_product, только вот выводить каждое поле отдельно в product_build_content тоже не просто, нет ли способа проще?
Спасибо!

Аватар пользователя Benya
Benya

Я о более простом способе не знаю, было бы интересно увидеть если найдете

Аватар пользователя Владислав
Владислав

В одной таблице не всегда есть хорошо, а если "мультизначение" у поля? так что к тем же сущностям прикрепляется CCK

Аватар пользователя Василий
Василий

В схеме добавить бы для status:

'size' => 'tiny',