og-8.x-1.x-dev/src/Entity/OgMembership.php

src/Entity/OgMembership.php
<?php

declare(strict_types=1);

namespace Drupal\og\Entity;

use Drupal\Core\Cache\Cache;
use Drupal\Core\Entity\Attribute\ContentEntityType;
use Drupal\Core\Entity\ContentEntityBase;
use Drupal\Core\Entity\ContentEntityInterface;
use Drupal\Core\Entity\EntityListBuilder;
use Drupal\Core\Entity\EntityStorageInterface;
use Drupal\Core\Entity\EntityTypeInterface;
use Drupal\Core\Entity\EntityViewBuilder;
use Drupal\Core\Field\BaseFieldDefinition;
use Drupal\Core\Field\FieldStorageDefinitionInterface;
use Drupal\Core\Language\LanguageInterface;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Drupal\og\Form\GroupSubscribeForm;
use Drupal\og\Form\GroupUnsubscribeConfirmForm;
use Drupal\og\Form\OgMembershipDeleteForm;
use Drupal\og\Form\OgMembershipForm;
use Drupal\og\Og;
use Drupal\og\OgMembershipAccessControlHandler;
use Drupal\og\OgMembershipInterface;
use Drupal\og\OgMembershipViewsData;
use Drupal\og\OgRoleInterface;
use Drupal\user\EntityOwnerInterface;
use Drupal\user\UserInterface;

/**
 * The membership entity that connects a group and a user.
 *
 * When dealing with non-user entities that are group content, that is content
 * that is associated with a group, we do it via an entity reference field that
 * has the default storage. The only information that we hold is that a group
 * content is referencing a group.
 *
 * However, when dealing with the user entity we recognize that we need to
 * special case it. It won't suffice to just hold the reference between the user
 * and the group content as it will be laking crucial information such as: the
 * state of the user's membership in the group (active, pending or blocked), the
 * time the membership was created, the user's OG role in the group, etc.
 *
 * For this meta data we have the fieldable OgMembership entity, that is always
 * connecting between a user and a group. There cannot be an OgMembership entity
 * connecting two non-user entities.
 *
 * Creating such a relation is done for example in the following way:
 *
 * @code
 *  $membership = Og::createMembership($entity, $user);
 *  $membership->save();
 * @endcode
 *
 * Notice how the relation of the user to the group also includes the OG
 * audience field name this association was done by. Like this we are able to
 * express different membership types such as the default membership that comes
 * out of the box, or a "premium membership" that can be for example expired
 * after a certain amount of time (the logic for the expired membership in the
 * example is out of the scope of OG core).
 *
 * Having this field separation is what allows having multiple OG audience
 * fields attached to the user, where each group they are associated with may be
 * a result of different membership types.
 *
 * @ContentEntityType(
 *   id = "og_membership",
 *   label = @Translation("OG membership"),
 *   bundle_label = @Translation("OG membership type"),
 *   module = "og",
 *   base_table = "og_membership",
 *   fieldable = TRUE,
 *   bundle_entity_type = "og_membership_type",
 *   entity_keys = {
 *     "uuid" = "uuid",
 *     "id" = "id",
 *     "bundle" = "type",
 *   },
 *   bundle_keys = {
 *     "bundle" = "type",
 *   },
 *   handlers = {
 *     "access" = "Drupal\og\OgMembershipAccessControlHandler",
 *     "views_data" = "Drupal\og\OgMembershipViewsData",
 *     "list_builder" = "Drupal\Core\Entity\EntityListBuilder",
 *     "view_builder" = "Drupal\Core\Entity\EntityViewBuilder",
 *     "form" = {
 *       "subscribe" = "Drupal\og\Form\GroupSubscribeForm",
 *       "unsubscribe" = "Drupal\og\Form\GroupUnsubscribeConfirmForm",
 *       "add" = "Drupal\og\Form\OgMembershipForm",
 *       "edit" = "Drupal\og\Form\OgMembershipForm",
 *       "delete" = "Drupal\og\Form\OgMembershipDeleteForm",
 *     },
 *   },
 *   links = {
 *     "canonical" = "/group/{entity_type_id}/{group}/admin/members/{og_membership}/edit",
 *     "edit-form" = "/group/{entity_type_id}/{group}/admin/members/{og_membership}/edit",
 *     "delete-form" = "/group/{entity_type_id}/{group}/admin/members/{og_membership}/delete",
 *   },
 *   field_ui_base_route = "entity.og_membership_type.edit_form"
 * )
 */
#[ContentEntityType(
  id: 'og_membership',
  label: new TranslatableMarkup('OG membership'),
  entity_keys: [
    'uuid' => 'uuid',
    'id' => 'id',
    'bundle' => 'type',
  ],
  handlers: [
    'access' => OgMembershipAccessControlHandler::class,
    'views_data' => OgMembershipViewsData::class,
    'list_builder' => EntityListBuilder::class,
    'view_builder' => EntityViewBuilder::class,
    'form' => [
      'subscribe' => GroupSubscribeForm::class,
      'unsubscribe' => GroupUnsubscribeConfirmForm::class,
      'add' => OgMembershipForm::class,
      'edit' => OgMembershipForm::class,
      'delete' => OgMembershipDeleteForm::class,
    ],
  ],
  links: [
    'canonical' => '/group/{entity_type_id}/{group}/admin/members/{og_membership}/edit',
    'edit-form' => '/group/{entity_type_id}/{group}/admin/members/{og_membership}/edit',
    'delete-form' => '/group/{entity_type_id}/{group}/admin/members/{og_membership}/delete',
  ],
  bundle_entity_type: 'og_membership_type',
  bundle_label: new TranslatableMarkup('OG membership type'),
  base_table: 'og_membership',
  field_ui_base_route: 'entity.og_membership_type.edit_form',
)]
class OgMembership extends ContentEntityBase implements OgMembershipInterface {

  /**
   * {@inheritdoc}
   */
  protected function urlRouteParameters($rel): array {
    $uri_route_parameters = parent::urlRouteParameters($rel);
    $uri_route_parameters['entity_type_id'] = $this->getGroupEntityType();
    $uri_route_parameters['group'] = $this->getGroupId();
    return $uri_route_parameters;
  }

  /**
   * {@inheritdoc}
   */
  public function getCreatedTime(): int {
    return (int) $this->getFieldValue('created', 'value') ?: 0;
  }

  /**
   * {@inheritdoc}
   */
  public function setCreatedTime(int $timestamp): OgMembershipInterface {
    $this->set('created', $timestamp);
    return $this;
  }

  /**
   * {@inheritdoc}
   */
  public function getOwner() {
    assert(!empty($this->get('uid')->entity), new \LogicException(__METHOD__ . '() should only be called on loaded memberships, or on newly created memberships that already have the owner set.'));
    return $this->get('uid')->entity;
  }

  /**
   * {@inheritdoc}
   */
  public function getOwnerId() {
    $owner_id = $this->getFieldValue('uid', 'target_id');
    assert(!empty($owner_id), new \LogicException(__METHOD__ . '() should only be called on loaded memberships, or on newly created memberships that already have the owner set.'));
    return $owner_id;
  }

  /**
   * {@inheritdoc}
   */
  public function setOwner(UserInterface $account) {
    $this->set('uid', $account->id());
    return $this;
  }

  /**
   * {@inheritdoc}
   */
  public function setOwnerId($uid) {
    $this->set('uid', $uid);
    return $this;
  }

  /**
   * {@inheritdoc}
   */
  public function setGroup(ContentEntityInterface $group): OgMembershipInterface {
    $this->set('entity_type', $group->getEntityTypeId());
    $this->set('entity_bundle', $group->bundle());
    $this->set('entity_id', $group->id());
    return $this;
  }

  /**
   * {@inheritdoc}
   */
  public function getGroupEntityType(): string {
    $entity_type = $this->getFieldValue('entity_type', 'value') ?: '';
    assert(!empty($entity_type), new \LogicException(__METHOD__ . '() should only be called on loaded memberships, or on newly created memberships that already have the group type set.'));
    return $entity_type;
  }

  /**
   * {@inheritdoc}
   */
  public function getGroupBundle(): string {
    $bundle = $this->getFieldValue('entity_bundle', 'value') ?: '';
    assert(!empty($bundle), new \LogicException(__METHOD__ . '() should only be called on loaded memberships, or on newly created memberships that already have the group bundle set.'));
    return $bundle;
  }

  /**
   * {@inheritdoc}
   */
  public function getGroupId(): string {
    $entity_id = $this->getFieldValue('entity_id', 'value') ?: '';
    assert(!empty($entity_id), new \LogicException(__METHOD__ . '() should only be called on loaded memberships, or on newly created memberships that already have the group ID set.'));
    return $entity_id;
  }

  /**
   * Checks if a group has already been populated on the membership.
   *
   * The group is required for a membership, so it is always present if a
   * membership has been saved. This is intended for internal use to verify if
   * a group is present when methods are called on a membership that is possibly
   * still under construction.
   *
   * For performance reasons this avoids loading the full group entity just for
   * this purpose, and relies only on the fact that the data for the entity is
   * populated in the relevant fields. This should give us the same indication,
   * but with a lower performance cost, especially for users that are a member
   * of a large number of groups.
   *
   * @return bool
   *   Whether or not the group is already present.
   */
  protected function hasGroup(): bool {
    $has_group =
      !empty($this->getFieldValue('entity_type', 'value')) &&
      !empty($this->getFieldValue('entity_bundle', 'value')) &&
      !empty($this->getFieldValue('entity_id', 'value'));
    return $has_group;
  }

  /**
   * {@inheritdoc}
   */
  public function getGroup(): ?ContentEntityInterface {
    $entity_type = $this->getGroupEntityType();
    $entity_id = $this->getGroupId();

    return \Drupal::entityTypeManager()->getStorage($entity_type)->load($entity_id);
  }

  /**
   * {@inheritdoc}
   */
  public function setState(string $state): OgMembershipInterface {
    $this->set('state', $state);
    return $this;
  }

  /**
   * {@inheritdoc}
   */
  public function getState(): string {
    return $this->getFieldValue('state', 'value') ?: '';
  }

  /**
   * {@inheritdoc}
   */
  public function getType(): string {
    return $this->bundle();
  }

  /**
   * {@inheritdoc}
   */
  public function addRole(OgRoleInterface $role): OgMembershipInterface {
    $roles = $this->getRoles();
    $roles[] = $role;

    return $this->setRoles($roles);
  }

  /**
   * {@inheritdoc}
   */
  public function revokeRole(OgRoleInterface $role): OgMembershipInterface {
    return $this->revokeRoleById($role->id());
  }

  /**
   * {@inheritdoc}
   */
  public function revokeRoleById(string $role_id): OgMembershipInterface {
    $roles = $this->getRoles();

    foreach ($roles as $key => $existing_role) {
      if ($existing_role->id() == $role_id) {
        unset($roles[$key]);

        // We can stop iterating.
        break;
      }
    }

    return $this->setRoles($roles);
  }

  /**
   * {@inheritdoc}
   */
  public function getRoles(): array {
    $roles = [];

    // Add the member role. This is only possible if a group has been set on the
    // membership.
    if ($this->hasGroup()) {
      $roles = [
        OgRole::getRole($this->getGroupEntityType(), $this->getGroupBundle(), OgRoleInterface::AUTHENTICATED),
      ];
    }
    $roles = array_merge($roles, $this->get('roles')->referencedEntities());
    return array_filter($roles, [$this, 'isRoleInterface']);
  }

  /**
   * Callback for array_filter().
   *
   * @param mixed $role
   *   A role or entity ID or NULL.
   *
   * @return bool
   *   TRUE if $role is an object of the expected interface.
   */
  protected function isRoleInterface($role) {
    return $role instanceof OgRoleInterface;
  }

  /**
   * {@inheritdoc}
   */
  public function setRoles(array $roles = []): OgMembershipInterface {
    $roles = array_filter($roles, function (OgRole $role) {
      return !($role->getName() == OgRoleInterface::AUTHENTICATED);
    });
    $role_ids = array_map(function (OgRole $role) {
      return $role->id();
    }, $roles);

    $this->set('roles', array_unique($role_ids));

    return $this;
  }

  /**
   * {@inheritdoc}
   */
  public function getRolesIds(): array {
    // Only use $this->get() if it is already populated. If it is not available
    // then use the raw value. This field is not translatable so we do not need
    // the slow field definition lookup from $this->getTranslatedField().
    if (isset($this->fields['roles'][LanguageInterface::LANGCODE_DEFAULT])) {
      $values = $this->get('roles')->getValue();
    }
    else {
      $values = $this->values['roles'][LanguageInterface::LANGCODE_DEFAULT] ?? [];
    }

    $roles_ids = array_column($values, 'target_id');

    // Add the member role. This is only possible if a group has been set on the
    // membership.
    if ($this->hasGroup()) {
      $roles_ids[] = "{$this->getGroupEntityType()}-{$this->getGroupBundle()}-" . OgRoleInterface::AUTHENTICATED;
    }

    return $roles_ids;
  }

  /**
   * {@inheritdoc}
   */
  public function isRoleValid(OgRoleInterface $role): bool {
    $group = $this->getGroup();

    // If there is no group yet then we cannot determine whether the role is
    // valid.
    if (!$group) {
      throw new \LogicException('Cannot determine whether a role is valid for a membership that doesn\'t have a group.');
    }

    // Non-member roles are never valid for any membership.
    if ($role->getName() == OgRoleInterface::ANONYMOUS) {
      return FALSE;
    }

    // If the entity type and bundle of the role doesn't match the group then
    // the role is intended for a different group type.
    elseif ($role->getGroupType() !== $group->getEntityTypeId() || $role->getGroupBundle() !== $group->bundle()) {
      return FALSE;
    }

    return TRUE;
  }

  /**
   * {@inheritdoc}
   */
  public function hasRole(string $role_id): bool {
    return in_array($role_id, $this->getRolesIds());
  }

  /**
   * {@inheritdoc}
   */
  public function hasPermission(string $permission): bool {
    // Blocked users do not have any permissions.
    if ($this->isBlocked()) {
      return FALSE;
    }

    return (bool) array_filter($this->getRoles(), function (OgRole $role) use ($permission) {
      return $role->hasPermission($permission);
    });
  }

  /**
   * {@inheritdoc}
   */
  public static function baseFieldDefinitions(EntityTypeInterface $entity_type) {
    $fields = [];

    $fields['id'] = BaseFieldDefinition::create('integer')
      ->setLabel(new TranslatableMarkup('ID'))
      ->setDescription(t("The group membership's unique ID."))
      ->setReadOnly(TRUE)
      ->setSetting('unsigned', TRUE);

    $fields['uuid'] = BaseFieldDefinition::create('uuid')
      ->setLabel(new TranslatableMarkup('UUID'))
      ->setDescription(new TranslatableMarkup('The membership UUID.'))
      ->setReadOnly(TRUE);

    $fields['type'] = BaseFieldDefinition::create('entity_reference')
      ->setLabel(new TranslatableMarkup('Type'))
      ->setDescription(new TranslatableMarkup('The bundle of the membership'))
      ->setSetting('target_type', 'og_membership_type');

    $fields['uid'] = BaseFieldDefinition::create('entity_reference')
      ->setLabel(new TranslatableMarkup('Username'))
      ->setDescription(new TranslatableMarkup('The user ID of the member.'))
      ->setSetting('target_type', 'user')
      ->setSetting('handler', 'og:user')
      ->setConstraints(['UniqueOgMembership' => []])
      ->setDisplayOptions('form', [
        'type' => 'og_autocomplete',
        'weight' => -1,
        'settings' => [
          'match_operator' => 'CONTAINS',
          'size' => 60,
          'match_limit' => 10,
          'placeholder' => '',
        ],
      ])
      ->setDisplayConfigurable('view', TRUE)
      ->setDisplayConfigurable('form', TRUE)
      ->setRequired(TRUE);

    $fields['entity_type'] = BaseFieldDefinition::create('string')
      ->setLabel(new TranslatableMarkup('Group entity type'))
      ->setDescription(new TranslatableMarkup('The entity type of the group.'));

    $fields['entity_bundle'] = BaseFieldDefinition::create('string')
      ->setLabel(new TranslatableMarkup('Group bundle ID'))
      ->setDescription(new TranslatableMarkup('The bundle ID of the group.'));

    $fields['entity_id'] = BaseFieldDefinition::create('string')
      ->setLabel(new TranslatableMarkup('Group entity ID'))
      ->setDescription(new TranslatableMarkup('The entity ID of the group.'));

    $fields['state'] = BaseFieldDefinition::create('list_string')
      ->setLabel(new TranslatableMarkup('State'))
      ->setDescription(new TranslatableMarkup('The user membership state: active, pending, or blocked.'))
      ->setDefaultValue(OgMembershipInterface::STATE_ACTIVE)
      ->setSettings([
        'allowed_values' => [
          OgMembershipInterface::STATE_ACTIVE => new TranslatableMarkup('Active'),
          OgMembershipInterface::STATE_PENDING => new TranslatableMarkup('Pending'),
          OgMembershipInterface::STATE_BLOCKED => new TranslatableMarkup('Blocked'),
        ],
      ])
      ->setDisplayOptions('form', [
        'type' => 'options_buttons',
        'weight' => 0,
      ])
      ->setDisplayConfigurable('view', TRUE)
      ->setDisplayConfigurable('form', TRUE)
      ->setRequired(TRUE);

    $fields['roles'] = BaseFieldDefinition::create('entity_reference')
      ->setLabel(new TranslatableMarkup('Roles'))
      ->setDescription(new TranslatableMarkup('The OG roles related to an OG membership entity.'))
      ->setCardinality(FieldStorageDefinitionInterface::CARDINALITY_UNLIMITED)
      ->setSetting('target_type', 'og_role')
      ->setSetting('handler', 'og:og_role')
      ->setConstraints(['ValidOgRole' => []])
      ->setDisplayOptions('form', [
        'type' => 'options_buttons',
        'weight' => 0,
      ])
      ->setDisplayConfigurable('view', TRUE)
      ->setDisplayConfigurable('form', TRUE);

    $fields['created'] = BaseFieldDefinition::create('created')
      ->setLabel(new TranslatableMarkup('Create'))
      ->setDescription(new TranslatableMarkup('The Unix timestamp when the membership was created.'));

    $fields['language'] = BaseFieldDefinition::create('language')
      ->setLabel(new TranslatableMarkup('Language'))
      ->setDescription(new TranslatableMarkup('The {languages}.language of this membership.'));

    return $fields;
  }

  /**
   * {@inheritdoc}
   */
  public function preSave(EntityStorageInterface $storage) {
    // Check the value directly rather than using the entity, if there is one.
    // This will watch actual empty values and '0'.
    if (!$uid = $this->get('uid')->target_id) {
      // Throw a generic logic exception as this will likely get caught in
      // \Drupal\Core\Entity\Sql\SqlContentEntityStorage::save and turned into
      // an EntityStorageException anyway.
      throw new \LogicException('OG membership can not be created for an empty or anonymous user.');
    }

    if (!$entity_id = $this->get('entity_id')->value) {
      // Group was not set.
      throw new \LogicException('Membership cannot be set for an empty or an unsaved group.');
    }

    if (!$group = $this->getGroup()) {
      throw new \LogicException('A group entity is required for creating a membership.');
    }

    $entity_type_id = $group->getEntityTypeId();
    $bundle = $group->bundle();
    if (!Og::isGroup($entity_type_id, $bundle)) {
      // Group is not valid.
      throw new \LogicException(sprintf('Entity type %s with ID %s is not an OG group.', $entity_type_id, $group->id()));
    }

    // Check if the roles are valid.
    foreach ($this->getRoles() as $role) {
      /** @var \Drupal\og\Entity\OgRole $role */
      // Make sure we don't save a membership for a non-member.
      if ($role->getName() == OgRoleInterface::ANONYMOUS) {
        throw new \LogicException('Cannot save an OgMembership with reference to a non-member role.');
      }
      // The regular membership is implied, we do not need to store it.
      elseif ($role->getName() == OgRoleInterface::AUTHENTICATED) {
        $this->revokeRole($role);
      }
      // The roles should apply to the group type.
      elseif (!$this->isRoleValid($role)) {
        throw new \LogicException(sprintf('The role with ID %s does not match the group type of the membership.', $role->id()));
      }
    }

    // Check for an existing membership.
    $query = \Drupal::entityQuery('og_membership')
      ->accessCheck()
      ->condition('uid', $uid)
      ->condition('entity_id', $entity_id)
      ->condition('entity_type', $this->get('entity_type')->value);

    if (!$this->isNew()) {
      // Filter out this membership.
      $query->condition('id', $this->id(), '<>');
    }

    $count = $query
      ->range(0, 1)
      ->count()
      ->execute();

    if ($count) {
      throw new \LogicException(sprintf('An OG membership already exists for user ID %d and group of entity type %s and ID %s', $uid, $entity_type_id, $group->id()));
    }

    parent::preSave($storage);
  }

  /**
   * {@inheritdoc}
   */
  public function save() {
    $result = parent::save();

    // Reset internal cache.
    Og::reset();

    return $result;
  }

  /**
   * {@inheritdoc}
   */
  protected function invalidateTagsOnSave($update) {
    parent::invalidateTagsOnSave($update);

    // A membership was created or updated: invalidate the membership list cache
    // tags of its group. An updated membership may start to appear in a group's
    // membership listings because it now meets those listings' filtering
    // requirements. A newly created membership may start to appear in listings
    // because it did not exist before.
    $group = $this->getGroup();
    if (!empty($group)) {
      $tags = Cache::buildTags(OgMembershipInterface::GROUP_MEMBERSHIP_LIST_CACHE_TAG_PREFIX, $group->getCacheTagsToInvalidate());
      Cache::invalidateTags($tags);
    }
  }

  /**
   * {@inheritdoc}
   */
  protected static function invalidateTagsOnDelete(EntityTypeInterface $entity_type, array $entities) {
    parent::invalidateTagsOnDelete($entity_type, $entities);

    // A membership was deleted: invalidate the list cache tags of its group
    // membership lists, so that any lists that contain the membership will be
    // recalculated.
    $tags = [];
    foreach ($entities as $entity) {
      if ($group = $entity->getGroup()) {
        $tags = Cache::mergeTags(Cache::buildTags(OgMembershipInterface::GROUP_MEMBERSHIP_LIST_CACHE_TAG_PREFIX, $group->getCacheTagsToInvalidate()), $tags);
      }
    }
    Cache::invalidateTags($tags);
  }

  /**
   * {@inheritdoc}
   */
  public static function create(array $values = []) {
    // Use the default membership type by default.
    $values += ['type' => OgMembershipInterface::TYPE_DEFAULT];
    return parent::create($values);
  }

  /**
   * {@inheritdoc}
   */
  public function isActive(): bool {
    return $this->getState() === OgMembershipInterface::STATE_ACTIVE;
  }

  /**
   * {@inheritdoc}
   */
  public function isPending(): bool {
    return $this->getState() === OgMembershipInterface::STATE_PENDING;
  }

  /**
   * {@inheritdoc}
   */
  public function isBlocked(): bool {
    return $this->getState() === OgMembershipInterface::STATE_BLOCKED;
  }

  /**
   * {@inheritdoc}
   */
  public function isOwner(): bool {
    $group = $this->getGroup();
    return $group instanceof EntityOwnerInterface && $group->getOwnerId() == $this->getOwnerId();
  }

  /**
   * Gets the value of a specific property of a field.
   *
   * Only the first delta can be accessed with this method.
   *
   * @todo Remove this once issue #2580551 is fixed.
   *
   * @see https://www.drupal.org/project/drupal/issues/2580551
   *
   * @param string $field_name
   *   The name of the field.
   * @param string $property
   *   The field property, "value" for many field types.
   *
   * @return mixed
   *   The value.
   */
  public function getFieldValue($field_name, $property) {
    // Attempt to get the value from the values directly if the field is not
    // initialized yet.
    if (!isset($this->fields[$field_name])) {
      $field_values = NULL;
      if (isset($this->values[$field_name][$this->activeLangcode])) {
        $field_values = $this->values[$field_name][$this->activeLangcode];
      }
      elseif (isset($this->values[$field_name][LanguageInterface::LANGCODE_DEFAULT])) {
        $field_values = $this->values[$field_name][LanguageInterface::LANGCODE_DEFAULT];
      }

      if ($field_values !== NULL) {
        // If there are field values, try to get the property value.
        // Configurable/Multi-value fields are stored differently, try accessing
        // with delta and property first, then without delta and last, if the
        // value is a scalar, just return that.
        if (isset($field_values[0][$property]) && is_array($field_values[0])) {
          return $field_values[0][$property];
        }
        elseif (isset($field_values[$property]) && is_array($field_values)) {
          return $field_values[$property];
        }
        elseif (!is_array($field_values)) {
          return $field_values;
        }
      }
    }

    // Fall back to access the property through the field object.
    return $this->get($field_name)->$property;
  }

}

Главная | Обратная связь

drupal hosting | друпал хостинг | it patrol .inc