clean install

This commit is contained in:
2024-12-10 15:08:16 +01:00
commit e14eb2d8fd
31193 changed files with 3555714 additions and 0 deletions

View File

@@ -0,0 +1,11 @@
langcode: en
status: true
dependencies:
enforced:
module:
- farm_group
id: group
label: Group
description: ''
workflow: asset_default
new_revision: true

View File

@@ -0,0 +1,11 @@
langcode: en
status: true
dependencies:
enforced:
module:
- farm_group
id: asset_group
color: purple
conditions:
asset_type:
- group

View File

@@ -0,0 +1,243 @@
langcode: en
status: true
dependencies:
config:
- asset.type.group
module:
- asset
enforced:
module:
- farm_entity
id: farm_group_reference
label: 'Farm Group Reference'
module: views
description: ''
tag: ''
base_table: asset_field_data
base_field: id
display:
default:
id: default
display_title: Master
display_plugin: default
position: 0
display_options:
fields:
name:
id: name
table: asset_field_data
field: name
relationship: none
group_type: group
admin_label: ''
entity_type: null
entity_field: name
plugin_id: field
label: ''
exclude: false
alter:
alter_text: false
text: ''
make_link: false
path: ''
absolute: false
external: false
replace_spaces: false
path_case: none
trim_whitespace: false
alt: ''
rel: ''
link_class: ''
prefix: ''
suffix: ''
target: ''
nl2br: false
max_length: 0
word_boundary: true
ellipsis: true
more_link: false
more_link_text: ''
more_link_path: ''
strip_tags: false
trim: false
preserve_tags: ''
html: false
element_type: ''
element_class: ''
element_label_type: ''
element_label_class: ''
element_label_colon: true
element_wrapper_type: ''
element_wrapper_class: ''
element_default_classes: true
empty: ''
hide_empty: false
empty_zero: false
hide_alter_empty: true
click_sort_column: value
type: string
settings: { }
group_column: value
group_columns: { }
group_rows: true
delta_limit: 0
delta_offset: 0
delta_reversed: false
delta_first_last: false
multi_type: separator
separator: ', '
field_api_classes: false
pager:
type: mini
options:
offset: 0
items_per_page: 10
total_pages: null
id: 0
tags:
next:
previous:
expose:
items_per_page: false
items_per_page_label: 'Items per page'
items_per_page_options: '5, 10, 25, 50'
items_per_page_options_all: false
items_per_page_options_all_label: '- All -'
offset: false
offset_label: Offset
pagination_heading_level: h4
exposed_form:
type: basic
options:
submit_button: Apply
reset_button: false
reset_button_label: Reset
exposed_sorts_label: 'Sort by'
expose_sort_order: true
sort_asc_label: Asc
sort_desc_label: Desc
access:
type: none
options: { }
cache:
type: tag
options: { }
empty: { }
sorts:
name:
id: name
table: asset_field_data
field: name
relationship: none
group_type: group
admin_label: ''
entity_type: asset
entity_field: name
plugin_id: standard
order: ASC
expose:
label: ''
field_identifier: name
exposed: false
arguments: { }
filters:
type:
id: type
table: asset_field_data
field: type
relationship: none
group_type: group
admin_label: ''
entity_type: asset
entity_field: type
plugin_id: bundle
operator: in
value:
group: group
group: 1
exposed: false
expose:
operator_id: ''
label: ''
description: ''
use_operator: false
operator: ''
operator_limit_selection: false
operator_list: { }
identifier: ''
required: false
remember: false
multiple: false
remember_roles:
authenticated: authenticated
reduce: false
is_grouped: false
group_info:
label: ''
description: ''
identifier: ''
optional: true
widget: select
multiple: false
remember: false
default_group: All
default_group_multiple: { }
group_items: { }
style:
type: default
options:
grouping: { }
row_class: ''
default_row_class: true
uses_fields: false
row:
type: fields
options:
default_field_elements: true
inline: { }
separator: ''
hide_empty: false
query:
type: views_query
options:
query_comment: ''
disable_sql_rewrite: false
distinct: false
replica: false
query_tags: { }
relationships: { }
header: { }
footer: { }
display_extenders: { }
cache_metadata:
max-age: -1
contexts:
- 'languages:language_content'
- 'languages:language_interface'
- url.query_args
tags: { }
entity_reference:
id: entity_reference
display_title: 'Entity Reference'
display_plugin: entity_reference
position: 1
display_options:
style:
type: entity_reference
options:
search_fields:
name: name
row:
type: entity_reference
options:
default_field_elements: false
inline: { }
separator: '-'
hide_empty: false
display_extenders: { }
cache_metadata:
max-age: -1
contexts:
- 'languages:language_content'
- 'languages:language_interface'
tags: { }

View File

@@ -0,0 +1,79 @@
<?php
/**
* @file
* Code for creating farmOS group membership base field definitions.
*/
use Drupal\farm_group\Field\AssetGroupItemList;
/**
* Define asset group base fields.
*/
function farm_group_asset_base_fields() {
$fields = [];
// Group membership field.
// This is computed based on an asset's group assignment logs.
$options = [
'type' => 'entity_reference',
'label' => t('Group membership'),
'target_type' => 'asset',
'target_bundle' => 'group',
'multiple' => TRUE,
'computed' => AssetGroupItemList::class,
'hidden' => 'form',
'weight' => [
'view' => 94,
],
];
$fields['group'] = \Drupal::service('farm_field.factory')->baseFieldDefinition($options);
return $fields;
}
/**
* Define log group base fields.
*/
function farm_group_log_base_fields() {
$fields = [];
// "Is group assignment" boolean field.
$options = [
'type' => 'boolean',
'label' => t('Is group assignment'),
'description' => t('If this log is a group assignment, any referenced assets will become members of the groups referenced below.'),
'weight' => [
'form' => 30,
],
'view_display_options' => [
'label' => 'inline',
'type' => 'hideable_boolean',
'settings' => [
'format' => 'default',
'format_custom_false' => '',
'format_custom_true' => '',
'hide_if_false' => TRUE,
],
'weight' => 30,
],
];
$fields['is_group_assignment'] = \Drupal::service('farm_field.factory')->baseFieldDefinition($options);
// Group reference field.
$options = [
'type' => 'entity_reference',
'label' => t('Groups'),
'description' => t('If this is a group assignment log, which groups should the referenced assets be assigned to?'),
'target_type' => 'asset',
'target_bundle' => 'group',
'multiple' => TRUE,
'weight' => [
'form' => 30,
'view' => 30,
],
];
$fields['group'] = \Drupal::service('farm_field.factory')->baseFieldDefinition($options);
return $fields;
}

View File

@@ -0,0 +1,10 @@
name: Group asset
description: Adds a Group asset type.
type: module
package: farmOS Assets
core_version_requirement: ^10
dependencies:
- farm:asset
- farm:farm_entity
- farm:farm_location
- farm:farm_log

View File

@@ -0,0 +1,114 @@
<?php
/**
* @file
* Contains farm_group.module.
*/
use Drupal\Core\Entity\EntityTypeInterface;
use Drupal\Core\Form\FormStateInterface;
/**
* Implements hook_entity_base_field_info().
*/
function farm_group_entity_base_field_info(EntityTypeInterface $entity_type) {
\Drupal::moduleHandler()->loadInclude('farm_group', 'inc', 'farm_group.base_fields');
switch ($entity_type->id()) {
// Build asset base fields.
case 'asset':
return farm_group_asset_base_fields();
// Build log base fields.
case 'log':
return farm_group_log_base_fields();
default:
return [];
}
}
/**
* Implements hook_entity_base_field_info_alter().
*/
function farm_group_entity_base_field_info_alter(&$fields, EntityTypeInterface $entity_type) {
/** @var \Drupal\field\Entity\FieldConfig[] $fields */
// Prevent creating circular group memberships.
if ($entity_type->id() == 'log' && !empty($fields['asset'])) {
$fields['asset']->addConstraint('CircularGroupMembership');
}
}
/**
* Implements hook_form_BASE_FORM_ID_alter().
*/
function farm_group_form_log_form_alter(&$form, FormStateInterface $form_state, $form_id) {
// Check if the form has the required group fields.
if (isset($form['group']) && isset($form['is_group_assignment'])) {
// Set the visible state of the log.group field.
// Only display if is_group_assignment is checked.
$form['group']['#states']['visible'] = [':input[name="is_group_assignment[value]"]' => ['checked' => TRUE]];
}
}
/**
* Implements hook_farm_ui_theme_region_items().
*/
function farm_group_farm_ui_theme_region_items(string $entity_type) {
$region_items = [];
if ($entity_type == 'asset') {
$region_items = [
'top' => [],
'first' => [],
'second' => [
'group',
],
'bottom' => [],
];
}
elseif ($entity_type == 'log') {
$region_items = [
'top' => [],
'first' => [],
'second' => [
'is_group_assignment',
],
'bottom' => [],
];
}
return $region_items;
}
/**
* Implements hook_farm_ui_theme_field_groups().
*/
function farm_group_farm_ui_theme_field_groups(string $entity_type, string $bundle) {
// Add a field group for group membership fields on logs.
if ($entity_type == 'log') {
return [
'group' => [
'location' => 'main',
'title' => t('Group'),
'weight' => 60,
],
];
}
return [];
}
/**
* Implements hook_farm_ui_theme_field_group_items().
*/
function farm_group_farm_ui_theme_field_group_items(string $entity_type, string $bundle) {
if ($entity_type == 'log') {
return [
'group' => 'group',
'is_group_assignment' => 'group',
];
}
return [];
}

View File

@@ -0,0 +1,18 @@
<?php
/**
* @file
* Post update hooks for the farm_group module.
*/
use Drupal\system\Entity\Action;
/**
* Uninstall system.action.asset_group_action.
*/
function farm_group_post_update_uninstall_asset_group_action(&$sandbox) {
$config = Action::load('asset_group_action');
if (!empty($config)) {
$config->delete();
}
}

View File

@@ -0,0 +1,6 @@
farm_group.asset_group_action_form:
path: '/asset/group'
defaults:
_form: 'Drupal\farm_group\Form\AssetGroupActionForm'
requirements:
_user_is_logged_in: 'TRUE'

View File

@@ -0,0 +1,20 @@
services:
group.membership:
class: Drupal\farm_group\GroupMembership
arguments:
[ '@farm.log_query', '@entity_type.manager', '@datetime.time', '@database' ]
farm_group.log_event_subscriber:
class: Drupal\farm_group\EventSubscriber\LogEventSubscriber
arguments:
[ '@cache_tags.invalidator', '@datetime.time', '@group.membership' ]
tags:
- { name: 'event_subscriber' }
farm_group.route_subscriber:
class: Drupal\farm_group\Routing\RouteSubscriber
tags:
- { name: event_subscriber }
farm_group.group_members_access:
class: Drupal\farm_group\Access\FarmGroupMembersViewsAccessCheck
arguments: [ '@entity_type.manager' ]
tags:
- { name: access_check, applies_to: _group_members_access }

View File

@@ -0,0 +1,24 @@
<?php
/**
* @file
* Provides Views data for farm_group.module.
*/
/**
* Implements hook_views_data_alter().
*/
function farm_group_views_data_alter(array &$data) {
// Add the computed group membership field to assets.
$data['asset']['group'] = [
'title' => t('Current group'),
'field' => [
'id' => 'asset_group',
'field_name' => 'group',
],
'argument' => [
'id' => 'asset_group',
],
];
}

View File

@@ -0,0 +1,79 @@
<?php
/**
* @file
* Provides Views runtime hooks for farm_group.module.
*/
use Drupal\views\ViewExecutable;
/**
* Implements hook_views_pre_view().
*/
function farm_group_views_pre_view(ViewExecutable $view, $display_id, array &$args) {
// Alter the farm_asset View.
if ($view->id() == 'farm_asset') {
// Add the computed "Group" field before the "Location" field.
$field_options = [
'type' => 'entity_reference_label',
'label' => t('Group'),
'settings' => [
'link' => TRUE,
],
];
$field_id = $view->addHandler($display_id, 'field', 'asset', 'group', $field_options);
farm_ui_views_sort_field($view, $display_id, $field_id, 'location', TRUE);
}
// Alter the farm_log View.
if ($view->id() == 'farm_log') {
// Do not alter the upcoming/late block displays.
if (in_array($display_id, ['block_upcoming', 'block_late'])) {
return;
}
// Make sure the is_group_assignment field exists.
/** @var \Drupal\Core\Field\BaseFieldDefinition[] $definitions */
$definitions = \Drupal::service('entity_field.manager')
->getBaseFieldDefinitions('log');
if (isset($definitions['is_group_assignment'])) {
// Get field name and table name.
$definition = $definitions['is_group_assignment'];
$field_name = $definition->getName();
/** @var \Drupal\Core\Entity\Sql\DefaultTableMapping $table_mapping */
$table_mapping = \Drupal::entityTypeManager()
->getStorage('log')
->getTableMapping();
$table = $table_mapping->getFieldTableName($definition->getName());
// Define filter options.
$opts = [
'operator' => '=',
'value' => 'All',
'exposed' => TRUE,
'expose' => [
'label' => t('Is group assignment'),
'identifier' => 'is_group_assignment',
],
];
// Add the filter.
$filter_id = $view->addHandler($view->current_display, 'filter', $table, $field_name, $opts);
// Move the is_group_assignment filter to render before the status filter.
$filters = $view->getDisplay()->getOption('filters');
if ($index = array_search('status', array_keys($filters))) {
$filter = $filters[$filter_id];
unset($filters[$filter_id]);
// Use array_merge + array_splice instead of array_splice so we can
// preserve the array keys.
$filters = array_merge(array_slice($filters, 0, $index), [$filter_id => $filter], array_slice($filters, $index));
}
// Set the new sorted filters array.
$view->getDisplay()->setOption('filters', $filters);
}
}
}

View File

@@ -0,0 +1,56 @@
<?php
namespace Drupal\farm_group\Access;
use Drupal\Core\Access\AccessResult;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Routing\Access\AccessInterface;
use Drupal\Core\Routing\RouteMatchInterface;
/**
* Checks access for displaying Views of group members.
*/
class FarmGroupMembersViewsAccessCheck implements AccessInterface {
/**
* The asset storage.
*
* @var \Drupal\Core\Entity\EntityStorageInterface
*/
protected $assetStorage;
/**
* FarmGroupMembersViewsAccessCheck constructor.
*
* @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
* The entity type manager service.
*/
public function __construct(EntityTypeManagerInterface $entity_type_manager) {
$this->assetStorage = $entity_type_manager->getStorage('asset');
}
/**
* A custom access check.
*
* @param \Drupal\Core\Routing\RouteMatchInterface $route_match
* The route match.
*/
public function access(RouteMatchInterface $route_match) {
// If there is no "asset" parameter, bail.
$asset_id = $route_match->getParameter('asset');
if (empty($asset_id)) {
return AccessResult::allowed();
}
// Allow access if the asset is a group.
/** @var \Drupal\asset\Entity\AssetInterface $asset */
$asset = $this->assetStorage->load($asset_id);
$access = AccessResult::allowedIf($asset->bundle() == 'group');
// Invalidate the access result when assets are changed.
$access->addCacheTags(["asset_list"]);
return $access;
}
}

View File

@@ -0,0 +1,216 @@
<?php
namespace Drupal\farm_group\EventSubscriber;
use Drupal\Component\Datetime\TimeInterface;
use Drupal\Core\Cache\CacheTagsInvalidatorInterface;
use Drupal\asset\Entity\AssetInterface;
use Drupal\farm_group\GroupMembershipInterface;
use Drupal\farm_location\EventSubscriber\LogEventSubscriber as LocationLogEventSubscriber;
use Drupal\log\Entity\LogInterface;
use Drupal\log\Event\LogEvent;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
/**
* Invalidate asset cache when group membership and group location changes.
*/
class LogEventSubscriber implements EventSubscriberInterface {
/**
* Cache tag invalidator service.
*
* @var \Drupal\Core\Cache\CacheTagsInvalidatorInterface
*/
protected CacheTagsInvalidatorInterface $cacheTagsInvalidator;
/**
* Datetime time service.
*
* @var \Drupal\Component\Datetime\TimeInterface
*/
protected $time;
/**
* Group membership service.
*
* @var \Drupal\farm_group\GroupMembershipInterface
*/
protected $groupMembership;
/**
* LogEventSubscriber Constructor.
*
* @param \Drupal\Core\Cache\CacheTagsInvalidatorInterface $cache_tags_invalidator
* Cache tag invalidator service.
* @param \Drupal\Component\Datetime\TimeInterface $date_time
* Datetime time service.
* @param \Drupal\farm_group\GroupMembershipInterface $group_membership
* Group membership service.
*/
public function __construct(CacheTagsInvalidatorInterface $cache_tags_invalidator, TimeInterface $date_time, GroupMembershipInterface $group_membership) {
$this->time = $date_time;
$this->cacheTagsInvalidator = $cache_tags_invalidator;
$this->groupMembership = $group_membership;
}
/**
* {@inheritdoc}
*
* @return array
* The event names to listen for, and the methods that should be executed.
*/
public static function getSubscribedEvents() {
return [
LogEvent::DELETE => 'logDelete',
LogEvent::PRESAVE => 'logPresave',
];
}
/**
* Perform actions on log delete.
*
* @param \Drupal\log\Event\LogEvent $event
* The log event.
*/
public function logDelete(LogEvent $event) {
$this->invalidateAssetCacheOnGroupAssignment($event->log);
$this->invalidateGroupMemberAssetCacheOnMovement($event->log);
}
/**
* Perform actions on log presave.
*
* @param \Drupal\log\Event\LogEvent $event
* The log event.
*/
public function logPresave(LogEvent $event) {
$this->invalidateAssetCacheOnGroupAssignment($event->log);
$this->invalidateGroupMemberAssetCacheOnMovement($event->log);
}
/**
* Invalidate asset caches when assets group membership changes.
*
* @param \Drupal\log\Entity\LogInterface $log
* The Log entity.
*/
protected function invalidateAssetCacheOnGroupAssignment(LogInterface $log): void {
// Keep track if we need to invalidate the cache of referenced assets so
// the computed 'group' field is updated.
$update_asset_cache = FALSE;
// If the log is an active group assignment, invalidate the cache.
if ($this->isActiveGroupAssignment($log)) {
$update_asset_cache = TRUE;
}
// If updating an existing group assignment log, invalidate the cache.
// This catches group assignment logs changing from done to pending.
if (!empty($log->original) && $this->isActiveGroupAssignment($log->original)) {
$update_asset_cache = TRUE;
}
// If an update is not necessary, bail.
if (!$update_asset_cache) {
return;
}
// Build a list of cache tags.
// @todo Only invalidate cache if the log changes the asset's current group. This might be different for each asset.
$tags = [];
// Include assets that were previously referenced.
if (!empty($log->original)) {
foreach ($log->original->get('asset')->referencedEntities() as $asset) {
array_push($tags, ...$asset->getCacheTags());
}
}
// Include assets currently referenced by the log.
foreach ($log->get('asset')->referencedEntities() as $asset) {
array_push($tags, ...$asset->getCacheTags());
}
// Invalidate the cache tags.
$this->cacheTagsInvalidator->invalidateTags($tags);
}
/**
* Invalidate group member cache when a group's location changes.
*
* @param \Drupal\log\Entity\LogInterface $log
* The Log entity.
*/
protected function invalidateGroupMemberAssetCacheOnMovement(LogInterface $log) {
// Keep track if we need to invalidate the cache of referenced assets so
// the computed 'location' and 'geometry' fields are updated.
$update_asset_cache = FALSE;
// If the log is a 'done' movement log, invalidate the cache.
if (LocationLogEventSubscriber::isActiveMovementLog($log)) {
$update_asset_cache = TRUE;
}
// If updating an existing 'done' movement log, invalidate the cache.
// This catches any movement logs changing from done to pending.
if (!empty($log->original) && LocationLogEventSubscriber::isActiveMovementLog($log->original)) {
$update_asset_cache = TRUE;
}
// If an update is not necessary, bail.
if (!$update_asset_cache) {
return;
}
// Build a list of cache tags.
// @todo Only invalidate cache if the movement log changes the group's current location. This might be different for each asset.
$tags = [];
// Include group assets that were previously referenced.
if (!empty($log->original)) {
// Get all group assets.
$group_assets = array_filter($log->original->get('asset')->referencedEntities(), function (AssetInterface $asset) {
return $asset->bundle() === 'group';
});
// Collect group member cache tags.
$member_tags = array_map(function (AssetInterface $asset) {
return $asset->getCacheTags();
}, $this->groupMembership->getGroupMembers($group_assets));
array_push($tags, ...array_merge(...$member_tags));
}
// Include group assets currently referenced by the log.
$group_assets = array_filter($log->get('asset')->referencedEntities(), function (AssetInterface $asset) {
return $asset->bundle() === 'group';
});
// Collect group member cache tags.
$member_tags = array_map(function (AssetInterface $asset) {
return $asset->getCacheTags();
}, $this->groupMembership->getGroupMembers($group_assets));
array_push($tags, ...array_merge(...$member_tags));
// Invalidate the cache tags.
$this->cacheTagsInvalidator->invalidateTags($tags);
}
/**
* Helper funtion to determine if a log is an active group assignment.
*
* Logs are an active group assignment if status = done,
* is_group_assignment = true, and the timestamp is not in the future.
*
* @param \Drupal\log\Entity\LogInterface $log
* The log to check.
*
* @return bool
* Boolean indicating if the log is an active group assignment.
*/
protected function isActiveGroupAssignment(LogInterface $log): bool {
return $log->get('status')->value == 'done' && $log->get('is_group_assignment')->value && $log->get('timestamp')->value <= $this->time->getCurrentTime();
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace Drupal\farm_group;
use Drupal\Core\DependencyInjection\ContainerBuilder;
use Drupal\Core\DependencyInjection\ServiceProviderBase;
use Drupal\Core\DependencyInjection\ServiceProviderInterface;
use Symfony\Component\DependencyInjection\Reference;
/**
* Override the asset.location service with our own class.
*/
class FarmGroupServiceProvider extends ServiceProviderBase implements ServiceProviderInterface {
/**
* {@inheritdoc}
*/
public function alter(ContainerBuilder $container) {
$definition = $container->getDefinition('asset.location');
$definition->addArgument(new Reference('group.membership'));
$definition->setClass('Drupal\farm_group\GroupAssetLocation');
}
}

View File

@@ -0,0 +1,36 @@
<?php
namespace Drupal\farm_group\Field;
use Drupal\Core\Field\EntityReferenceFieldItemList;
use Drupal\Core\TypedData\ComputedItemListTrait;
/**
* Computes the current group value for assets.
*/
class AssetGroupItemList extends EntityReferenceFieldItemList {
use ComputedItemListTrait;
/**
* Computes the current group value for the asset.
*/
protected function computeValue() {
// Get the asset entity.
$entity = $this->getEntity();
// Get the asset's current groups.
$groups = \Drupal::service('group.membership')->getGroup($entity);
// Update the assets current group values to match.
// @todo Cache this field computation.
foreach ($groups as $delta => $group) {
if (!empty($group->id())) {
$value = ['target_id' => $group->id()];
$this->list[$delta] = $this->createItem($delta, $value);
}
}
}
}

View File

@@ -0,0 +1,151 @@
<?php
namespace Drupal\farm_group;
use Drupal\Component\Datetime\TimeInterface;
use Drupal\Core\Database\Connection;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\asset\Entity\AssetInterface;
use Drupal\farm_location\AssetLocation;
use Drupal\farm_location\AssetLocationInterface;
use Drupal\farm_location\LogLocationInterface;
use Drupal\farm_log\LogQueryFactoryInterface;
use Drupal\log\Entity\LogInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Asset location logic, accounting for group membership.
*/
class GroupAssetLocation extends AssetLocation implements AssetLocationInterface {
/**
* Group membership service.
*
* @var \Drupal\farm_group\GroupMembershipInterface
*/
protected GroupMembershipInterface $groupMembership;
/**
* Class constructor.
*
* @param \Drupal\farm_location\LogLocationInterface $log_location
* Log location service.
* @param \Drupal\farm_log\LogQueryFactoryInterface $log_query_factory
* Log query factory.
* @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
* Entity type manager.
* @param \Drupal\Component\Datetime\TimeInterface $time
* The time service.
* @param \Drupal\Core\Database\Connection $database
* The database service.
* @param \Drupal\farm_group\GroupMembershipInterface $group_membership
* The group membership service.
*/
public function __construct(LogLocationInterface $log_location, LogQueryFactoryInterface $log_query_factory, EntityTypeManagerInterface $entity_type_manager, TimeInterface $time, Connection $database, GroupMembershipInterface $group_membership) {
parent::__construct($log_location, $log_query_factory, $entity_type_manager, $time, $database);
$this->groupMembership = $group_membership;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container) {
return new static(
$container->get('log.location'),
$container->get('farm.log_query'),
$container->get('entity_type.manager'),
$container->get('datetime.time'),
$container->get('database'),
$container->get('group.membership')
);
}
/**
* {@inheritdoc}
*/
public function getMovementLog(AssetInterface $asset, $timestamp = NULL): ?LogInterface {
// Delegate to the parent service to get the latest movement log that
// references this asset.
$latest_movement = parent::getMovementLog($asset, $timestamp);
// Remember the latest movement id and timestamp.
$latest_id = 0;
$latest_timestamp = 0;
if (!empty($latest_movement)) {
$latest_id = $latest_movement->id();
$latest_timestamp = $latest_movement->get('timestamp')->value;
}
// Get the groups that this asset is assigned to.
$groups = $this->groupMembership->getGroup($asset, $timestamp);
// If there are groups, iterate through them.
if (!empty($groups)) {
foreach ($groups as $group) {
// Get the latest movement log that references the group.
$group_movement = parent::getMovementLog($group, $timestamp);
// If the group doesn't have a movement, skip it.
if (empty($group_movement)) {
continue;
}
// If the group's movement is the latest, replace the latest movement.
$group_movement_id = $group_movement->id();
$group_movement_timestamp = $group_movement->get('timestamp')->value;
if (($group_movement_timestamp > $latest_timestamp) || ($group_movement_timestamp == $latest_timestamp && $group_movement_id > $latest_id)) {
$latest_movement = $group_movement;
$latest_id = $group_movement_id;
$latest_timestamp = $group_movement_timestamp;
}
}
}
// Return the latest movement log.
return $latest_movement;
}
/**
* {@inheritdoc}
*/
public function getAssetsByLocation(array $locations, $timestamp = NULL): array {
// First delegate to the parent function to get assets in the location.
/** @var \Drupal\asset\Entity\AssetInterface[] $assets */
$assets = parent::getAssetsByLocation($locations, $timestamp);
// Recursively load all group members and add them to the list of assets.
$groups = array_filter($assets, function (AssetInterface $asset) {
return $asset->bundle() === 'group';
});
$assets += $this->groupMembership->getGroupMembers($groups, TRUE, $timestamp);
// Get location ids.
$location_ids = array_map(function (AssetInterface $location) {
return $location->id();
}, $locations);
// It is possible for a group member asset to be in a different location
// than the group, if it has a movement log that is more recent than the
// group's. So iterate through all the assets and remove any that are not in
// one of the specified locations. The asset may be in multiple locations
// (including this one), so we only want to remove it if none of its
// locations match.
$assets = array_filter($assets, function (AssetInterface $asset) use ($location_ids, $timestamp) {
// Get asset location ids.
$asset_location_ids = array_map(function (AssetInterface $location) {
return $location->id();
}, $this->getLocation($asset, $timestamp));
// Only include the asset if it is in one of the specified locations.
return !empty(array_intersect($location_ids, $asset_location_ids));
});
// Return the assets.
return $assets;
}
}

View File

@@ -0,0 +1,230 @@
<?php
namespace Drupal\farm_group;
use Drupal\Component\Datetime\TimeInterface;
use Drupal\Core\Database\Connection;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\asset\Entity\AssetInterface;
use Drupal\farm_log\LogQueryFactoryInterface;
use Drupal\log\Entity\LogInterface;
/**
* Asset group membership logic.
*/
class GroupMembership implements GroupMembershipInterface {
/**
* The name of the log group reference field.
*
* @var string
*/
const LOG_FIELD_GROUP = 'group';
/**
* Log query factory.
*
* @var \Drupal\farm_log\LogQueryFactoryInterface
*/
protected LogQueryFactoryInterface $logQueryFactory;
/**
* Entity type manager.
*
* @var \Drupal\Core\Entity\EntityTypeManagerInterface
*/
protected EntityTypeManagerInterface $entityTypeManager;
/**
* The time service.
*
* @var \Drupal\Component\Datetime\TimeInterface
*/
protected $time;
/**
* The database service.
*
* @var \Drupal\Core\Database\Connection
*/
protected $database;
/**
* Class constructor.
*
* @param \Drupal\farm_log\LogQueryFactoryInterface $log_query_factory
* Log query factory.
* @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
* Entity type manager.
* @param \Drupal\Component\Datetime\TimeInterface $time
* The time service.
* @param \Drupal\Core\Database\Connection $database
* The database service.
*/
public function __construct(LogQueryFactoryInterface $log_query_factory, EntityTypeManagerInterface $entity_type_manager, TimeInterface $time, Connection $database) {
$this->logQueryFactory = $log_query_factory;
$this->entityTypeManager = $entity_type_manager;
$this->time = $time;
$this->database = $database;
}
/**
* {@inheritdoc}
*/
public function hasGroup(AssetInterface $asset, $timestamp = NULL): bool {
// Load the group assignment log. Bail if empty.
$log = $this->getGroupAssignmentLog($asset, $timestamp);
if (empty($log)) {
return FALSE;
}
// Return emptiness of the group references.
return !$log->get(static::LOG_FIELD_GROUP)->isEmpty();
}
/**
* {@inheritdoc}
*/
public function getGroup(AssetInterface $asset, $timestamp = NULL): array {
// Load the group assignment log. Bail if empty.
$log = $this->getGroupAssignmentLog($asset, $timestamp);
if (empty($log)) {
return [];
}
// Return referenced entities.
return $log->{static::LOG_FIELD_GROUP}->referencedEntities() ?? [];
}
/**
* {@inheritdoc}
*/
public function getGroupAssignmentLog(AssetInterface $asset, $timestamp = NULL): ?LogInterface {
// If the asset is new, no group assignment logs will reference it.
if ($asset->isNew()) {
return NULL;
}
// If $timestamp is NULL, use the current time.
if (is_null($timestamp)) {
$timestamp = $this->time->getRequestTime();
}
// Query for group assignment logs that reference the asset.
// We do not check access on the logs to ensure that none are filtered out.
$options = [
'asset' => $asset,
'timestamp' => $timestamp,
'status' => 'done',
'limit' => 1,
];
$query = $this->logQueryFactory->getQuery($options);
$query->condition('is_group_assignment', TRUE);
$query->accessCheck(FALSE);
$log_ids = $query->execute();
// Bail if no logs are found.
if (empty($log_ids)) {
return NULL;
}
// Load the first log.
/** @var \Drupal\log\Entity\LogInterface $log */
$log = $this->entityTypeManager->getStorage('log')->load(reset($log_ids));
// Return the log, if available.
if (!is_null($log)) {
return $log;
}
// Otherwise, return NULL.
return NULL;
}
/**
* {@inheritdoc}
*/
public function getGroupMembers(array $groups, bool $recurse = TRUE, $timestamp = NULL): array {
// Get group ids.
$group_ids = array_map(function (AssetInterface $group) {
return $group->id();
}, $groups);
// Bail if there are no group ids.
if (empty($group_ids)) {
return [];
}
// If $timestamp is NULL, use the current time.
if (is_null($timestamp)) {
$timestamp = $this->time->getRequestTime();
}
// Build query for group members.
$query = "
-- Select asset IDs from the asset base table.
SELECT a.id
FROM {asset} a
-- Inner join logs that reference the assets.
INNER JOIN {asset_field_data} afd ON afd.id = a.id
INNER JOIN {log__asset} la ON a.id = la.asset_target_id AND la.deleted = 0
INNER JOIN {log_field_data} lfd ON lfd.id = la.entity_id
-- Inner join group assets referenced by the logs.
INNER JOIN {log__group} lg ON lg.entity_id = lfd.id AND lg.deleted = 0
-- Left join ANY future group assignment logs for the same asset.
-- In the WHERE clause we'll exclude all records that have future logs,
-- leaving only the 'current' log entry.
LEFT JOIN (
{log_field_data} lfd2
INNER JOIN {log__asset} la2 ON la2.entity_id = lfd2.id AND la2.deleted = 0
) ON lfd2.is_group_assignment = 1 AND la2.asset_target_id = a.id
-- Future log entries have either a higher timestamp, or an equal timestamp and higher log ID.
AND (lfd2.timestamp > lfd.timestamp OR (lfd2.timestamp = lfd.timestamp AND lfd2.id > lfd.id))
-- Don't include future logs beyond the given timestamp.
-- These conditions should match the values in the WHERE clause.
AND (lfd2.status = 'done') AND (lfd2.timestamp <= :timestamp)
-- Limit results to completed membership assignment logs to the desired
-- group that took place before the given timestamp.
WHERE (lfd.is_group_assignment = 1) AND (lfd.status = 'done') AND (lfd.timestamp <= :timestamp) AND (lg.group_target_id IN (:group_ids[]))
-- Exclude records with future log entries.
AND lfd2.id IS NULL";
$args = [
':timestamp' => $timestamp,
':group_ids[]' => $group_ids,
];
$result = $this->database->query($query, $args)->fetchAll();
$asset_ids = [];
foreach ($result as $row) {
if (!empty($row->id)) {
$asset_ids[] = $row->id;
}
}
if (empty($asset_ids)) {
return [];
}
$asset_ids = array_unique($asset_ids);
/** @var \Drupal\asset\Entity\AssetInterface[] $assets */
$assets = $this->entityTypeManager->getStorage('asset')->loadMultiple($asset_ids);
if ($recurse) {
// Iterate through the assets to check if any of them are groups.
$groups = array_filter($assets, function (AssetInterface $asset) {
return $asset->bundle() === 'group';
});
// Use array_replace so that numeric keys are preserved.
$assets = array_replace($assets, $this->getGroupMembers($groups, $recurse, $timestamp));
}
return $assets;
}
}

View File

@@ -0,0 +1,72 @@
<?php
namespace Drupal\farm_group;
use Drupal\asset\Entity\AssetInterface;
use Drupal\log\Entity\LogInterface;
/**
* Asset group membership logic.
*/
interface GroupMembershipInterface {
/**
* Check if an asset is a member of a group.
*
* @param \Drupal\asset\Entity\AssetInterface $asset
* The Asset entity.
* @param int|null $timestamp
* Include logs with a timestamp less than or equal to this.
* If this is NULL (default), the current time will be used.
*
* @return bool
* Returns TRUE if the asset is a member of a group, FALSE otherwise.
*/
public function hasGroup(AssetInterface $asset, $timestamp = NULL): bool;
/**
* Get group assets that an asset is a member of.
*
* @param \Drupal\asset\Entity\AssetInterface $asset
* The Asset entity.
* @param int|null $timestamp
* Include logs with a timestamp less than or equal to this.
* If this is NULL (default), the current time will be used.
*
* @return array
* Returns an array of assets.
*/
public function getGroup(AssetInterface $asset, $timestamp = NULL): array;
/**
* Find the latest group assignment log that references an asset.
*
* @param \Drupal\asset\Entity\AssetInterface $asset
* The asset entity.
* @param int|null $timestamp
* Include logs with a timestamp less than or equal to this.
* If this is NULL (default), the current time will be used.
*
* @return \Drupal\log\Entity\LogInterface|null
* A log entity, or NULL if no logs were found.
*/
public function getGroupAssignmentLog(AssetInterface $asset, $timestamp = NULL): ?LogInterface;
/**
* Get assets that are members of groups.
*
* @param \Drupal\asset\Entity\AssetInterface[] $groups
* An array of group assets to lookup.
* @param bool $recurse
* Boolean: whether or not to recurse and load members of sub-groups.
* Defaults to TRUE.
* @param int|null $timestamp
* Include logs with a timestamp less than or equal to this.
* If this is NULL (default), the current time will be used.
*
* @return \Drupal\asset\Entity\AssetInterface[]
* An array of asset objects indexed by their IDs.
*/
public function getGroupMembers(array $groups, bool $recurse = TRUE, $timestamp = NULL): array;
}

View File

@@ -0,0 +1,17 @@
<?php
namespace Drupal\farm_group\Plugin\Asset\AssetType;
use Drupal\farm_entity\Plugin\Asset\AssetType\FarmAssetType;
/**
* Provides the group asset type.
*
* @AssetType(
* id = "group",
* label = @Translation("Group"),
* )
*/
class Group extends FarmAssetType {
}

View File

@@ -0,0 +1,24 @@
<?php
namespace Drupal\farm_group\Plugin\Validation\Constraint;
use Symfony\Component\Validator\Constraint;
/**
* Checks that a log is not creating a circular group membership.
*
* @Constraint(
* id = "CircularGroupMembership",
* label = @Translation("Circular group membership", context = "Validation"),
* )
*/
class CircularGroupMembershipConstraint extends Constraint {
/**
* The default violation message.
*
* @var string
*/
public $message = '%asset cannot be a member of itself.';
}

View File

@@ -0,0 +1,107 @@
<?php
namespace Drupal\farm_group\Plugin\Validation\Constraint;
use Drupal\Core\DependencyInjection\ContainerInjectionInterface;
use Drupal\farm_group\GroupMembershipInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
/**
* Validates the CircularGroupMembership constraint.
*/
class CircularGroupMembershipConstraintValidator extends ConstraintValidator implements ContainerInjectionInterface {
/**
* Group membership service.
*
* @var \Drupal\farm_group\GroupMembershipInterface
*/
protected $groupMembership;
/**
* CircularGroupMembershipConstraintValidator constructor.
*
* @param \Drupal\farm_group\GroupMembershipInterface $group_membership
* Group membership service.
*/
public function __construct(GroupMembershipInterface $group_membership) {
$this->groupMembership = $group_membership;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container) {
return new static(
$container->get('group.membership'),
);
}
/**
* {@inheritdoc}
*/
public function validate($value, Constraint $constraint) {
/** @var \Drupal\Core\Field\EntityReferenceFieldItemList $value */
/** @var \Drupal\farm_group\Plugin\Validation\Constraint\CircularGroupMembershipConstraint $constraint */
// Get the log that this field is on.
$log = $value->getParent()->getValue();
// If the log is not a group assignment, we have nothing to validate.
if (empty($log->get('is_group_assignment')->value)) {
return;
}
// Get the group(s) that asset(s) are being made members of.
$groups = $log->get('group')->referencedEntities();
// If there are no groups, we have nothing to validate.
if (empty($groups)) {
return;
}
// Get the log's timestamp.
$timestamp = $log->get('timestamp')->value;
// Iterate through referenced entities.
foreach ($value->referencedEntities() as $delta => $asset) {
// If this asset is not a group, skip it.
if ($asset->bundle() != 'group') {
continue;
}
// Load members of this group (recursively).
$members = $this->groupMembership->getGroupMembers([$asset], TRUE, $timestamp);
// Iterate through the groups and look for violations.
$violation = FALSE;
foreach ($groups as $group) {
// Make sure that the asset and group are not the same.
if ($group->id() == $asset->id()) {
$violation = TRUE;
}
// Make sure that none of the group(s) are members of this asset.
foreach ($members as $member) {
if ($group->id() == $member->id()) {
$violation = TRUE;
break;
}
}
}
// If a violation was found, flag it.
if ($violation) {
$this->context->buildViolation($constraint->message, ['%asset' => $asset->label()])
->atPath((string) $delta . '.target_id')
->setInvalidValue($asset->id())
->addViolation();
}
}
}
}

View File

@@ -0,0 +1,101 @@
<?php
namespace Drupal\farm_group\Plugin\views\argument;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\farm_group\GroupMembershipInterface;
use Drupal\views\Plugin\views\argument\ArgumentPluginBase;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* An argument for filtering assets by their current group.
*
* @ingroup views_argument_handlers
*
* @ViewsArgument("asset_group")
*/
class AssetGroup extends ArgumentPluginBase {
/**
* The entity type manager service.
*
* @var \Drupal\Core\Entity\EntityTypeManagerInterface
*/
protected $entityTypeManager;
/**
* The group membership service.
*
* @var \Drupal\farm_group\GroupMembershipInterface
*/
protected $groupMembership;
/**
* Constructs an AssetGroup object.
*
* @param array $configuration
* A configuration array containing information about the plugin instance.
* @param string $plugin_id
* The plugin_id for the plugin instance.
* @param mixed $plugin_definition
* The plugin implementation definition.
* @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
* The entity type manager service.
* @param \Drupal\farm_group\GroupMembershipInterface $group_membership
* The group membership service.
*/
public function __construct(array $configuration, $plugin_id, $plugin_definition, EntityTypeManagerInterface $entity_type_manager, GroupMembershipInterface $group_membership) {
parent::__construct($configuration, $plugin_id, $plugin_definition);
$this->entityTypeManager = $entity_type_manager;
$this->groupMembership = $group_membership;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
return new static(
$configuration,
$plugin_id,
$plugin_definition,
$container->get('entity_type.manager'),
$container->get('group.membership'),
);
}
/**
* {@inheritdoc}
*
* @see \Drupal\farm_location\Plugin\views\argument\AssetLocation
*/
public function query($group_by = FALSE) {
// First query for a list of asset IDs in the group, then use this list to
// filter the current View.
// We do this in two separate queries for a few reasons:
// 1. The Drupal and Views query APIs do not support the kind of compound
// JOIN that we use in the group.membership service's getMembers().
// 2. We need to allow other modules to override the group.membership
// service to provide their own location logic. They shouldn't have to
// override this Views argument handler as well.
// 3. It keeps this Views argument handler's query modifications very
// simple. It only needs the condition: "WHERE asset.id IN (:asset_ids)".
// See https://www.drupal.org/project/farm/issues/3217184 for more info.
$group = $this->entityTypeManager->getStorage('asset')->load($this->argument);
$assets = $this->groupMembership->getGroupMembers([$group]);
$asset_ids = [];
foreach ($assets as $asset) {
$asset_ids[] = $asset->id();
}
// If there are no asset IDs, add 0 to ensure the array is not empty.
if (empty($asset_ids)) {
$asset_ids[] = 0;
}
// Filter to only include assets with those IDs.
$this->ensureMyTable();
$this->query->addWhere(0, "$this->tableAlias.id", $asset_ids, 'IN');
}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace Drupal\farm_group\Plugin\views\field;
use Drupal\views\Plugin\views\field\EntityField;
/**
* A field that displays asset group.
*
* @ingroup views_field_handlers
*
* @ViewsField("asset_group")
*/
class AssetGroup extends EntityField {
}

View File

@@ -0,0 +1,27 @@
<?php
namespace Drupal\farm_group\Routing;
use Drupal\Core\Routing\RouteSubscriberBase;
use Symfony\Component\Routing\RouteCollection;
/**
* Alter routes for the farm_group module.
*
* @ingroup farm
*/
class RouteSubscriber extends RouteSubscriberBase {
/**
* {@inheritdoc}
*/
public function alterRoutes(RouteCollection $collection) {
// Add our _group_members_access requirement to
// view.farm_group_members.page.
if ($route = $collection->get('view.farm_group_members.page')) {
$route->setRequirement('_group_members_access', 'Drupal\farm_group\Access\FarmGroupMembersViewsAccessCheck::access');
}
}
}

View File

@@ -0,0 +1,7 @@
langcode: en
status: true
id: animal
label: Animal
description: ''
workflow: asset_default
new_revision: true

View File

@@ -0,0 +1,7 @@
langcode: en
status: true
id: pasture
label: Pasture
description: ''
workflow: asset_default
new_revision: true

View File

@@ -0,0 +1,9 @@
langcode: en
status: true
dependencies: { }
id: test
label: Test
description: ''
name_pattern: 'Test log [log:id]'
workflow: log_default
new_revision: true

View File

@@ -0,0 +1,8 @@
name: farmOS Equipment Test
description: Module for testing Equipment field.
type: module
package: Testing
core_version_requirement: ^10
dependencies:
- farm:farm_entity
- farm:farm_group

View File

@@ -0,0 +1,17 @@
<?php
namespace Drupal\farm_group_test\Plugin\Asset\AssetType;
use Drupal\farm_entity\Plugin\Asset\AssetType\FarmAssetType;
/**
* Provides the animal asset type.
*
* @AssetType(
* id = "animal",
* label = @Translation("Animal"),
* )
*/
class Animal extends FarmAssetType {
}

View File

@@ -0,0 +1,17 @@
<?php
namespace Drupal\farm_group_test\Plugin\Asset\AssetType;
use Drupal\farm_entity\Plugin\Asset\AssetType\FarmAssetType;
/**
* Provides the pasture asset type.
*
* @AssetType(
* id = "pasture",
* label = @Translation("Pasture"),
* )
*/
class Pasture extends FarmAssetType {
}

View File

@@ -0,0 +1,17 @@
<?php
namespace Drupal\farm_group_test\Plugin\Log\LogType;
use Drupal\farm_entity\Plugin\Log\LogType\FarmLogType;
/**
* Provides the test log type.
*
* @LogType(
* id = "test",
* label = @Translation("Test"),
* )
*/
class Test extends FarmLogType {
}

View File

@@ -0,0 +1,80 @@
<?php
namespace Drupal\Tests\farm_group\Functional;
use Drupal\FunctionalJavascriptTests\WebDriverTestBase;
use Drupal\log\Entity\Log;
/**
* Tests for farmOS group membership logic.
*
* @group farm
*/
class GroupTest extends WebDriverTestBase {
/**
* {@inheritdoc}
*/
protected $profile = 'farm';
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* {@inheritdoc}
*/
protected static $modules = [
'farm_group',
'farm_group_test',
];
/**
* {@inheritdoc}
*/
protected function setUp(): void {
$GLOBALS['farm_test'] = TRUE;
parent::setUp();
// Create and login a user with permission to administer logs.
$user = $this->createUser(['administer log']);
$this->drupalLogin($user);
}
/**
* Test group field visibility.
*/
public function testGroupFieldVisibility() {
// Create a log for testing.
/** @var \Drupal\log\Entity\LogInterface $log */
$log = Log::create([
'type' => 'test',
]);
$log->save();
// Go to the log edit form.
$this->drupalGet('log/' . $log->id() . '/edit');
// Test that the group field is hidden.
$page = $this->getSession()->getPage();
$group_field = $page->findById('edit-group-wrapper');
$this->assertNotEmpty($group_field);
$this->assertFalse($group_field->isVisible());
// Make the log a group assignment.
$log->is_group_assignment = TRUE;
$log->save();
// Go back to the edit form.
$this->drupalGet('log/' . $log->id() . '/edit');
// Test that the group field is visible.
$page = $this->getSession()->getPage();
$group_field = $page->findById('edit-group-wrapper');
$this->assertNotEmpty($group_field);
$this->assertTrue($group_field->isVisible());
}
}

View File

@@ -0,0 +1,989 @@
<?php
namespace Drupal\Tests\farm_group\Kernel;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\KernelTests\KernelTestBase;
use Drupal\Tests\farm_test\Kernel\FarmAssetTestTrait;
use Drupal\Tests\farm_test\Kernel\FarmEntityCacheTestTrait;
use Drupal\asset\Entity\Asset;
use Drupal\farm_geo\Traits\WktTrait;
use Drupal\log\Entity\Log;
/**
* Tests for farmOS group membership logic.
*
* @group farm
*/
class GroupTest extends KernelTestBase {
use StringTranslationTrait;
use FarmAssetTestTrait;
use FarmEntityCacheTestTrait;
use WktTrait;
/**
* WKT Generator service.
*
* @var \Drupal\geofield\WktGeneratorInterface
*/
protected $wktGenerator;
/**
* Group membership service.
*
* @var \Drupal\farm_group\GroupMembershipInterface
*/
protected $groupMembership;
/**
* Asset location service.
*
* @var \Drupal\farm_location\AssetLocationInterface
*/
protected $assetLocation;
/**
* Log location service.
*
* @var \Drupal\farm_location\LogLocationInterface
*/
protected $logLocation;
/**
* {@inheritdoc}
*/
protected static $modules = [
'asset',
'log',
'farm_entity_views',
'farm_field',
'farm_group',
'farm_group_test',
'farm_location',
'farm_log',
'farm_log_asset',
'geofield',
'state_machine',
'system',
'user',
'views',
];
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
$this->wktGenerator = \Drupal::service('geofield.wkt_generator');
$this->groupMembership = \Drupal::service('group.membership');
$this->assetLocation = \Drupal::service('asset.location');
$this->logLocation = \Drupal::service('log.location');
$this->installEntitySchema('asset');
$this->installEntitySchema('log');
$this->installEntitySchema('user');
$this->installConfig([
'farm_entity_views',
'farm_group',
'farm_group_test',
]);
}
/**
* Test asset group membership.
*/
public function testGroupMembership() {
// Create an animal asset.
/** @var \Drupal\asset\Entity\AssetInterface $animal */
$animal = Asset::create([
'type' => 'animal',
'name' => $this->randomMachineName(),
'status' => 'active',
]);
$animal->save();
// Populate a cache value dependent on the animal's cache tags.
$this->populateEntityTestCache($animal);
// Create group assets.
/** @var \Drupal\asset\Entity\AssetInterface $first_group */
$first_group = Asset::create([
'type' => 'group',
'name' => $this->randomMachineName(),
'status' => 'active',
]);
$first_group->save();
/** @var \Drupal\asset\Entity\AssetInterface $second_group */
$second_group = Asset::create([
'type' => 'group',
'name' => $this->randomMachineName(),
'status' => 'active',
]);
$second_group->save();
// When an asset has no group assignment logs, it has no group membership.
$this->assertFalse($this->groupMembership->hasGroup($animal), 'New assets do not have group membership.');
$this->assertEmpty($this->groupMembership->getGroup($animal), 'New assets do not reference any groups.');
$this->assertCorrectAssets([], $this->groupMembership->getGroupMembers([$first_group]), TRUE, 'New groups have no members.');
$this->assertCorrectAssets([], $this->groupMembership->getGroupMembers([$second_group]), TRUE, 'New groups have no members.');
// Assert that the animal's cache tags were not invalidated.
$this->assertEntityTestCache($animal, TRUE);
// Create a "done" log that assigns the animal to the group.
/** @var \Drupal\log\Entity\LogInterface $first_log */
$first_log = Log::create([
'type' => 'test',
'status' => 'done',
'is_group_assignment' => TRUE,
'group' => ['target_id' => $first_group->id()],
'asset' => ['target_id' => $animal->id()],
]);
$first_log->save();
// When an asset has a done group assignment logs, it has group membership.
$this->assertTrue($this->groupMembership->hasGroup($animal), 'Asset with group assignment has group membership.');
$this->assertEquals($first_group->id(), $this->groupMembership->getGroup($animal)[0]->id(), 'Asset with group assignment is in the assigned group.');
$this->assertCorrectAssets([$animal], $this->groupMembership->getGroupMembers([$first_group]), TRUE, 'When an asset becomes a group member, the group has one member.');
$this->assertCorrectAssets([], $this->groupMembership->getGroupMembers([$second_group]), TRUE, 'When an asset becomes a group member, other groups are unaffected.');
// Assert that the animal's cache tags were invalidated.
$this->assertEntityTestCache($animal, FALSE);
// Re-populate a cache value dependent on the animal's cache tags.
$this->populateEntityTestCache($animal);
// Create a "pending" log that assigns the animal to the second group.
/** @var \Drupal\log\Entity\LogInterface $second_log */
$second_log = Log::create([
'type' => 'test',
'status' => 'pending',
'is_group_assignment' => TRUE,
'group' => ['target_id' => $second_group->id()],
'asset' => ['target_id' => $animal->id()],
]);
$second_log->save();
// When an asset has a pending group assignment logs, it still has the same
// group membership as before.
$this->assertEquals($first_group->id(), $this->groupMembership->getGroup($animal)[0]->id(), 'Pending group assignment logs do not affect membership.');
$this->assertCorrectAssets([], $this->groupMembership->getGroupMembers([$second_group]), TRUE, 'Groups with only pending membership have zero members.');
// Assert that the animal's cache tags were not invalidated.
$this->assertEntityTestCache($animal, TRUE);
// When the log is marked as "done", the asset's membership is updated.
$second_log->status = 'done';
$second_log->save();
$this->assertEquals($second_group->id(), $this->groupMembership->getGroup($animal)[0]->id(), 'A second group assignment log updates group membership.');
$this->assertCorrectAssets([$animal], $this->groupMembership->getGroupMembers([$second_group]), TRUE, 'Completed group assignment logs add group members.');
// Assert that the animal's cache tags were invalidated.
$this->assertEntityTestCache($animal, FALSE);
// Re-populate a cache value dependent on the animal's cache tags.
$this->populateEntityTestCache($animal);
// Create a third "done" log in the future.
/** @var \Drupal\log\Entity\LogInterface $third_log */
$third_log = Log::create([
'type' => 'test',
'timestamp' => \Drupal::time()->getRequestTime() + 86400,
'status' => 'done',
'is_group_assignment' => TRUE,
'group' => ['target_id' => $first_group->id()],
'asset' => ['target_id' => $animal->id()],
]);
$third_log->save();
// When an asset has a "done" group assignment log in the future, the asset
// group membership remains the same as the previous "done" movement log.
$this->assertEquals($second_group->id(), $this->groupMembership->getGroup($animal)[0]->id(), 'A third group assignment log in the future does not update group membership.');
$this->assertCorrectAssets([$animal], $this->groupMembership->getGroupMembers([$second_group]), TRUE, 'Future group assignment logs do not affect members.');
// Assert that the animal's cache tags were not invalidated.
$this->assertEntityTestCache($animal, TRUE);
// Create a fourth log with no group reference.
/** @var \Drupal\log\Entity\LogInterface $fourth_log */
$fourth_log = Log::create([
'type' => 'test',
'status' => 'done',
'is_group_assignment' => TRUE,
'group' => [],
'asset' => ['target_id' => $animal->id()],
]);
$fourth_log->save();
// When a group assignment log is created with no group references, it
// effectively "unsets" the asset's group membership.
$this->assertFalse($this->groupMembership->hasGroup($animal), 'Asset group membership can be unset.');
$this->assertCorrectAssets([], $this->groupMembership->getGroup($animal), 'Unset group membership does not reference any groups.');
$this->assertCorrectAssets([], $this->groupMembership->getGroupMembers([$first_group]), TRUE, 'Unset group membership unsets group members.');
$this->assertCorrectAssets([], $this->groupMembership->getGroupMembers([$second_group]), TRUE, 'Unset group membership unsets group members.');
// Assert that the animal's cache tags were invalidated.
$this->assertEntityTestCache($animal, FALSE);
// Re-populate a cache value dependent on the animal's cache tags.
$this->populateEntityTestCache($animal);
// Delete the fourth log.
$fourth_log->delete();
// When a group membership is deleted the last group membership log is used.
$this->assertEquals($second_group->id(), $this->groupMembership->getGroup($animal)[0]->id(), 'Deleting a group membership log updates group membership.');
$this->assertCorrectAssets([$animal], $this->groupMembership->getGroupMembers([$second_group]), TRUE, 'Deleting a group membership log updates group members.');
// Assert that the animal's cache tags were invalidated.
$this->assertEntityTestCache($animal, FALSE);
// Create a second animal.
/** @var \Drupal\asset\Entity\AssetInterface $second_animal */
$second_animal = Asset::create([
'type' => 'animal',
'name' => $this->randomMachineName(),
'status' => 'active',
]);
$second_animal->save();
// Create a "done" log that assigns the second animal to the first group.
/** @var \Drupal\log\Entity\LogInterface $fifth_log */
$fifth_log = Log::create([
'type' => 'test',
'status' => 'done',
'is_group_assignment' => TRUE,
'group' => ['target_id' => $first_group->id()],
'asset' => ['target_id' => $second_animal->id()],
]);
$fifth_log->save();
// Assert that group members from multiple groups can be queried together.
$this->assertEquals($second_group->id(), $this->groupMembership->getGroup($animal)[0]->id(), 'The first animal is in the second group.');
$this->assertEquals($first_group->id(), $this->groupMembership->getGroup($second_animal)[0]->id(), 'The second animal is in the first group.');
$group_members = $this->groupMembership->getGroupMembers([$first_group, $second_group]);
$this->assertCorrectAssets([$animal, $second_animal], $group_members, TRUE, 'Group members from multiple groups can be queried together.');
}
/**
* Test past/future group membership.
*/
public function testGroupMembershipTimestamp() {
// Create an asset.
/** @var \Drupal\asset\Entity\AssetInterface $asset */
$asset = Asset::create([
'type' => 'animal',
'name' => $this->randomMachineName(),
'status' => 'active',
]);
$asset->save();
// Create two group assets.
/** @var \Drupal\asset\Entity\AssetInterface[] $groups */
$group = Asset::create([
'type' => 'group',
'name' => $this->randomMachineName(),
'status' => 'active',
]);
$group->save();
$groups[] = $group;
$group = Asset::create([
'type' => 'group',
'name' => $this->randomMachineName(),
'status' => 'active',
]);
$group->save();
$groups[] = $group;
// Create a series of timestamps, each 24 hours apart.
$now = \Drupal::time()->getRequestTime();
$timestamps = [];
for ($i = 0; $i < 3; $i++) {
$timestamps[$i] = $now + (86400 * $i);
}
// Create a series of logs that assign the asset to each of the groups, and
// a third that removes the asset from all groups.
/** @var \Drupal\log\Entity\LogInterface[] $logs */
$logs = [];
$group_assignments = [];
for ($i = 0; $i < 2; $i++) {
$group_assignments[] = ['target_id' => $groups[$i]->id()];
}
$group_assignments[] = [];
for ($i = 0; $i < 3; $i++) {
$log = Log::create([
'type' => 'test',
'timestamp' => $timestamps[$i],
'status' => 'done',
'asset' => ['target_id' => $asset->id()],
'is_group_assignment' => TRUE,
'group' => $group_assignments[$i],
]);
$log->save();
$logs[] = $log;
}
// Confirm that the asset has no group membership before all logs.
$timestamp = $now - 86400;
$this->assertEquals(FALSE, $this->groupMembership->hasGroup($asset, $timestamp));
$this->assertEquals([], $this->groupMembership->getGroup($asset, $timestamp));
$this->assertNull($this->groupMembership->getGroupAssignmentLog($asset, $timestamp));
$this->assertEquals([], $this->groupMembership->getGroupMembers($groups, TRUE, $timestamp));
// Confirm that the asset is where it should be after each log.
for ($i = 0; $i < 2; $i++) {
$this->assertEquals(TRUE, $this->groupMembership->hasGroup($asset, $timestamps[$i]));
$this->assertEquals($groups[$i]->id(), $this->groupMembership->getGroup($asset, $timestamps[$i])[0]->id());
$this->assertEquals($logs[$i]->id(), $this->groupMembership->getGroupAssignmentLog($asset, $timestamps[$i])->id());
$this->assertCorrectAssets([$asset], $this->groupMembership->getGroupMembers([$groups[$i]], TRUE, $timestamps[$i]), TRUE);
}
// Confirm that the asset is still in the same group 1 second later.
// This tests the <= operator.
$this->assertEquals(TRUE, $this->groupMembership->hasGroup($asset, $timestamps[1] + 1));
$this->assertEquals($groups[1]->id(), $this->groupMembership->getGroup($asset, $timestamps[1] + 1)[0]->id());
$this->assertEquals($logs[1]->id(), $this->groupMembership->getGroupAssignmentLog($asset, $timestamps[1] + 1)->id());
$this->assertCorrectAssets([$asset], $this->groupMembership->getGroupMembers([$groups[1]], TRUE, $timestamps[1] + 1), TRUE);
// Confirm that the asset has no group membership after the last log.
$this->assertEquals(FALSE, $this->groupMembership->hasGroup($asset, $timestamps[2]));
$this->assertEquals([], $this->groupMembership->getGroup($asset, $timestamps[2]));
$this->assertEquals($logs[2]->id(), $this->groupMembership->getGroupAssignmentLog($asset, $timestamps[2])->id());
$this->assertEquals([], $this->groupMembership->getGroupMembers($groups, TRUE, $timestamps[2]));
}
/**
* Test recursive asset group membership.
*/
public function testRecursiveGroupMembership() {
// Create an animal asset.
/** @var \Drupal\asset\Entity\AssetInterface $animal */
$animal = Asset::create([
'type' => 'animal',
'name' => $this->randomMachineName(),
'status' => 'active',
]);
$animal->save();
// Create group assets.
/** @var \Drupal\asset\Entity\AssetInterface $first_group */
$first_group = Asset::create([
'type' => 'group',
'name' => $this->randomMachineName(),
'status' => 'active',
]);
$first_group->save();
/** @var \Drupal\asset\Entity\AssetInterface $second_group */
$second_group = Asset::create([
'type' => 'group',
'name' => $this->randomMachineName(),
'status' => 'active',
]);
$second_group->save();
// Create a "done" log to assign the animal to the second group.
/** @var \Drupal\log\Entity\LogInterface $first_log */
$first_log = Log::create([
'type' => 'test',
'status' => 'done',
'is_group_assignment' => TRUE,
'group' => ['target_id' => $second_group->id()],
'asset' => ['target_id' => $animal->id()],
]);
$first_log->save();
// Create a "pending" log to assign the second group to the first group.
/** @var \Drupal\log\Entity\LogInterface $second_log */
$second_log = Log::create([
'type' => 'test',
'status' => 'pending',
'is_group_assignment' => TRUE,
'group' => ['target_id' => $first_group->id()],
'asset' => ['target_id' => $second_group->id()],
]);
$second_log->save();
// Assert that the second group has no group and a single member.
$this->assertFalse($this->groupMembership->hasGroup($second_group), 'The second group does not have a group.');
$this->assertCorrectAssets([$animal], $this->groupMembership->getGroupMembers([$second_group]), TRUE, 'The second group has one member.');
// Assert that the first group has no members.
$this->assertCorrectAssets([], $this->groupMembership->getGroupMembers([$first_group], FALSE), TRUE, 'The first group has no members.');
$this->assertCorrectAssets([], $this->groupMembership->getGroupMembers([$first_group], TRUE), TRUE, 'The first group has no recursive members.');
// Save the second log to create the nested group membership.
$second_log->status = 'done';
$second_log->save();
// Assert that the second group has a group and a single member.
$this->assertTrue($this->groupMembership->hasGroup($second_group), 'The second group has a group.');
$this->assertCorrectAssets([$animal], $this->groupMembership->getGroupMembers([$second_group]), TRUE, 'The second group has one member.');
// Assert that the first group has a single direct member.
$first_group_members = $this->groupMembership->getGroupMembers([$first_group], FALSE);
$this->assertCorrectAssets([$second_group], $first_group_members, TRUE, 'The first group has one direct member.');
// Assert that the first group has two recursive members.
$first_group_recursive_members = $this->groupMembership->getGroupMembers([$first_group], TRUE);
$this->assertCorrectAssets([$animal, $second_group], $first_group_recursive_members, TRUE, 'The first group has two recursive members.');
}
/**
* Test that circular group membership is prevented.
*/
public function testCircularGroupMembership() {
// Create two group assets.
/** @var \Drupal\asset\Entity\AssetInterface $first_group */
$first_group = Asset::create([
'type' => 'group',
'name' => $this->randomMachineName(),
'status' => 'active',
]);
$first_group->save();
/** @var \Drupal\asset\Entity\AssetInterface $second_group */
$second_group = Asset::create([
'type' => 'group',
'name' => $this->randomMachineName(),
'status' => 'active',
]);
$second_group->save();
// First, make sure we can't create a group assignment log that references
// the same asset in both the asset and group fields.
/** @var \Drupal\log\Entity\LogInterface $first_log */
$first_log = Log::create([
'type' => 'test',
'status' => 'done',
'is_group_assignment' => TRUE,
'group' => ['target_id' => $first_group->id()],
'asset' => ['target_id' => $first_group->id()],
]);
// Validate the log and confirm that a circular group constraint
// violation was set.
$violations = $first_log->validate();
$this->assertCount(1, $violations);
$this->assertEquals($this->t('%asset cannot be a member of itself.', ['%asset' => $first_group->label()]), $violations[0]->getMessage());
// Tweak the log so that it moves the first group to the second group.
$first_log->set('group', ['target_id' => $second_group->id()]);
// Confirm that validation passes.
$violations = $first_log->validate();
$this->assertCount(0, $violations);
// Save the first log.
$first_log->save();
// Start a log that assigns the second group to the first group (which
// would cause a circular group membership).
/** @var \Drupal\log\Entity\LogInterface $second_log */
$second_log = Log::create([
'type' => 'test',
'status' => 'done',
'is_group_assignment' => TRUE,
'group' => ['target_id' => $first_group->id()],
'asset' => ['target_id' => $second_group->id()],
]);
// Validate the second log and confirm that a circular group constraint
// violation was set.
$violations = $second_log->validate();
$this->assertCount(1, $violations);
$this->assertEquals($this->t('%asset cannot be a member of itself.', ['%asset' => $second_group->label()]), $violations[0]->getMessage());
// Create a third group asset, so we can test recursive circular group
// membership constraint.
/** @var \Drupal\asset\Entity\AssetInterface $third_group */
$third_group = Asset::create([
'type' => 'group',
'name' => $this->randomMachineName(),
'status' => 'active',
]);
$third_group->save();
// Update the second log to reference the third group instead.
$second_log->set('group', ['target_id' => $third_group->id()]);
// Confirm that validation passes.
$violations = $second_log->validate();
$this->assertCount(0, $violations);
// Save the second log.
$second_log->save();
// Now, start a third log that assigns the third group to the first group
// (which would create a recursive circular group membership).
/** @var \Drupal\log\Entity\LogInterface $third_log */
$third_log = Log::create([
'type' => 'test',
'status' => 'done',
'is_group_assignment' => TRUE,
'group' => ['target_id' => $first_group->id()],
'asset' => ['target_id' => $third_group->id()],
]);
// Validate the third log and confirm that a circular group constraint
// violation was set.
$violations = $third_log->validate();
$this->assertCount(1, $violations);
$this->assertEquals($this->t('%asset cannot be a member of itself.', ['%asset' => $third_group->label()]), $violations[0]->getMessage());
}
/**
* Test asset location with group membership.
*/
public function testAssetLocation() {
// Create an animal asset.
/** @var \Drupal\asset\Entity\AssetInterface $animal */
$animal = Asset::create([
'type' => 'animal',
'name' => $this->randomMachineName(),
'status' => 'active',
]);
$animal->save();
// Populate a cache value dependent on the animal's cache tags.
$this->populateEntityTestCache($animal);
// Create a group asset.
/** @var \Drupal\asset\Entity\AssetInterface $group */
$group = Asset::create([
'type' => 'group',
'name' => $this->randomMachineName(),
'status' => 'active',
]);
$group->save();
// Create a log that assigns the animal to the group.
/** @var \Drupal\log\Entity\LogInterface $first_log */
$first_log = Log::create([
'type' => 'test',
'status' => 'done',
'is_group_assignment' => TRUE,
'group' => ['target_id' => $group->id()],
'asset' => ['target_id' => $animal->id()],
]);
$first_log->save();
// Assert that the animal's cache tags were invalidated.
$this->assertEntityTestCache($animal, FALSE);
// Re-populate a cache value dependent on the animal's cache tags.
$this->populateEntityTestCache($animal);
// Create two pasture assets.
/** @var \Drupal\asset\Entity\AssetInterface $first_pasture */
$first_pasture = Asset::create([
'type' => 'pasture',
'name' => $this->randomMachineName(),
'status' => 'active',
]);
$first_pasture->save();
/** @var \Drupal\asset\Entity\AssetInterface $second_pasture */
$second_pasture = Asset::create([
'type' => 'pasture',
'name' => $this->randomMachineName(),
'status' => 'active',
]);
$second_pasture->save();
// Confirm that new locations are empty.
$this->assertCorrectAssets([], $this->assetLocation->getAssetsByLocation([$first_pasture]), TRUE, 'New locations are empty.');
$this->assertCorrectAssets([], $this->assetLocation->getAssetsByLocation([$first_pasture]), TRUE);
// Create a log that moves the animal to the first pasture.
/** @var \Drupal\log\Entity\LogInterface $second_log */
$second_log = Log::create([
'type' => 'test',
'status' => 'done',
'is_movement' => TRUE,
'location' => ['target_id' => $first_pasture->id()],
'asset' => ['target_id' => $animal->id()],
]);
$second_log->save();
// Confirm that the animal is located in the first pasture.
$this->assertEquals($this->logLocation->getLocation($second_log), $this->assetLocation->getLocation($animal), 'Asset location is determined by asset membership log.');
$this->assertEquals($this->logLocation->getGeometry($second_log), $this->assetLocation->getGeometry($animal), 'Asset geometry is determined by asset membership log.');
$this->assertCorrectAssets([$animal], $this->assetLocation->getAssetsByLocation([$first_pasture]), TRUE, 'Locations have assets that are moved to them.');
$this->assertCorrectAssets([], $this->assetLocation->getAssetsByLocation([$second_pasture]), TRUE, 'Locations that do not have assets moved to them are unaffected.');
// Assert that the animal's cache tags were invalidated.
$this->assertEntityTestCache($animal, FALSE);
// Re-populate a cache value dependent on the animal's cache tags.
$this->populateEntityTestCache($animal);
// Create a log that moves the group to the second pasture.
/** @var \Drupal\log\Entity\LogInterface $third_log */
$third_log = Log::create([
'type' => 'test',
'status' => 'done',
'is_movement' => TRUE,
'location' => ['target_id' => $second_pasture->id()],
'asset' => ['target_id' => $group->id()],
]);
$third_log->save();
// Confirm that the animal is located in the second pasture.
$this->assertEquals($this->logLocation->getLocation($third_log), $this->assetLocation->getLocation($animal), 'Asset location is determined by group membership log.');
$this->assertEquals($this->logLocation->getGeometry($third_log), $this->assetLocation->getGeometry($animal), 'Asset geometry is determined by group membership log.');
$this->assertCorrectAssets([], $this->assetLocation->getAssetsByLocation([$first_pasture]), TRUE, 'A group movement removes assets from their previous location.');
$this->assertCorrectAssets([$animal, $group], $this->assetLocation->getAssetsByLocation([$second_pasture]), TRUE, 'A group movement adds assets to their new location.');
// Assert that the animal's cache tags were invalidated.
$this->assertEntityTestCache($animal, FALSE);
// Re-populate a cache value dependent on the animal's cache tags.
$this->populateEntityTestCache($animal);
// Create a log that unsets the group location.
/** @var \Drupal\log\Entity\LogInterface $fourth_log */
$fourth_log = Log::create([
'type' => 'test',
'status' => 'done',
'is_movement' => TRUE,
'location' => [],
'asset' => ['target_id' => $group->id()],
]);
$fourth_log->save();
// Confirm that the animal location was unset.
$this->assertEquals($this->logLocation->getLocation($fourth_log), $this->assetLocation->getLocation($animal), 'Asset location can be unset by group membership log.');
$this->assertEquals($this->logLocation->getGeometry($fourth_log), $this->assetLocation->getGeometry($animal), 'Asset geometry can be unset by group membership log.');
$this->assertCorrectAssets([], $this->assetLocation->getAssetsByLocation([$first_pasture]), TRUE, 'Unsetting group location removes member assets from all locations.');
$this->assertCorrectAssets([], $this->assetLocation->getAssetsByLocation([$second_pasture]), TRUE, 'Unsetting group location removes member assets from all locations.');
// Assert that the animal's cache tags were invalidated.
$this->assertEntityTestCache($animal, FALSE);
// Re-populate a cache value dependent on the animal's cache tags.
$this->populateEntityTestCache($animal);
// Create a log that unsets the animal's group membership.
/** @var \Drupal\log\Entity\LogInterface $fifth_log */
$fifth_log = Log::create([
'type' => 'test',
'status' => 'done',
'is_group_assignment' => TRUE,
'group' => [],
'asset' => ['target_id' => $animal->id()],
]);
$fifth_log->save();
// Confirm that the animal's location is determined by its own movement
// logs now.
$this->assertEquals($this->logLocation->getLocation($second_log), $this->assetLocation->getLocation($animal), 'Asset location is determined by asset membership log.');
$this->assertEquals($this->logLocation->getGeometry($second_log), $this->assetLocation->getGeometry($animal), 'Asset geometry is determined by asset membership log.');
$this->assertCorrectAssets([$animal], $this->assetLocation->getAssetsByLocation([$first_pasture]), TRUE, 'Unsetting group membership adds assets to their previous location.');
$this->assertCorrectAssets([], $this->assetLocation->getAssetsByLocation([$second_pasture]), TRUE, 'Unsetting group membership removes member assets from the group location.');
// Assert that the animal's cache tags were invalidated.
$this->assertEntityTestCache($animal, FALSE);
// Delete the fifth log before re-populating asset cache.
$fifth_log->delete();
// Re-populate a cache value dependent on the animal's cache tags.
$this->populateEntityTestCache($animal);
// Delete the fourth log.
// When a group's location log is deleted the group's last location is used.
$fourth_log->delete();
// Confirm that the animal is located in the second pasture.
$this->assertEquals($this->logLocation->getLocation($third_log), $this->assetLocation->getLocation($animal), 'Asset location is determined by group membership log.');
$this->assertEquals($this->logLocation->getGeometry($third_log), $this->assetLocation->getGeometry($animal), 'Asset geometry is determined by group membership log.');
$this->assertCorrectAssets([], $this->assetLocation->getAssetsByLocation([$first_pasture]), TRUE, 'A group movement removes assets from their previous location.');
$this->assertCorrectAssets([$animal, $group], $this->assetLocation->getAssetsByLocation([$second_pasture]), TRUE, 'A group movement adds assets to their new location.');
// Assert that the animal's cache tags were invalidated.
$this->assertEntityTestCache($animal, FALSE);
}
/**
* Test past/future asset location.
*/
public function testAssetLocationTimestamp() {
// Create an animal asset.
/** @var \Drupal\asset\Entity\AssetInterface $asset */
$asset = Asset::create([
'type' => 'animal',
'name' => $this->randomMachineName(),
'status' => 'active',
]);
$asset->save();
// Create two pasture location assets.
/** @var \Drupal\asset\Entity\AssetInterface[] $locations */
$locations = [];
$location = Asset::create([
'type' => 'pasture',
'name' => $this->randomMachineName(),
'status' => 'active',
'intrinsic_geometry' => $this->reduceWkt($this->wktGenerator->wktGeneratePolygon(NULL, rand(3, 7))),
'is_fixed' => TRUE,
'is_location' => TRUE,
]);
$location->save();
$locations[] = $location;
$location = Asset::create([
'type' => 'pasture',
'name' => $this->randomMachineName(),
'status' => 'active',
'intrinsic_geometry' => $this->reduceWkt($this->wktGenerator->wktGeneratePolygon(NULL, rand(3, 7))),
'is_fixed' => TRUE,
'is_location' => TRUE,
]);
$location->save();
$locations[] = $location;
// Create a group asset.
/** @var \Drupal\asset\Entity\AssetInterface $group */
$group = Asset::create([
'type' => 'group',
'name' => $this->randomMachineName(),
'status' => 'active',
]);
$group->save();
// Create a series of timestamps, each 24 hours apart.
$now = \Drupal::time()->getRequestTime();
$timestamps = [];
for ($i = 0; $i < 5; $i++) {
$timestamps[$i] = $now + (86400 * $i);
}
// Create a series of movement and group assignment logs.
$logs = [];
// Move asset to location 1.
$log = Log::create([
'type' => 'test',
'timestamp' => $timestamps[0],
'status' => 'done',
'is_movement' => TRUE,
'location' => ['target_id' => $locations[0]->id()],
'asset' => ['target_id' => $asset->id()],
]);
$log->save();
$logs[] = $log;
// Assign asset to group.
$log = Log::create([
'type' => 'test',
'timestamp' => $timestamps[1],
'status' => 'done',
'is_group_assignment' => TRUE,
'group' => ['target_id' => $group->id()],
'asset' => ['target_id' => $asset->id()],
]);
$log->save();
$logs[] = $log;
// Move group to location 2.
$log = Log::create([
'type' => 'test',
'timestamp' => $timestamps[2],
'status' => 'done',
'is_movement' => TRUE,
'location' => ['target_id' => $locations[1]->id()],
'asset' => ['target_id' => $group->id()],
]);
$log->save();
$logs[] = $log;
// Move group to location 1.
$log = Log::create([
'type' => 'test',
'timestamp' => $timestamps[3],
'status' => 'done',
'is_movement' => TRUE,
'location' => ['target_id' => $locations[0]->id()],
'asset' => ['target_id' => $group->id()],
]);
$log->save();
$logs[] = $log;
// Remove asset from group.
$log = Log::create([
'type' => 'test',
'timestamp' => $timestamps[4],
'status' => 'done',
'is_group_assignment' => TRUE,
'group' => [],
'asset' => ['target_id' => $asset->id()],
]);
$log->save();
$logs[] = $log;
// Confirm that the asset has no location before all logs.
$timestamp = $now - 86400;
$this->assertEquals(FALSE, $this->assetLocation->hasLocation($asset, $timestamp));
$this->assertEquals(FALSE, $this->assetLocation->hasGeometry($asset, $timestamp));
$this->assertEquals([], $this->assetLocation->getLocation($asset, $timestamp));
$this->assertEquals('', $this->assetLocation->getGeometry($asset, $timestamp));
$this->assertNull($this->assetLocation->getMovementLog($asset, $timestamp));
$this->assertEquals([], $this->assetLocation->getAssetsByLocation($locations, $timestamp));
// Confirm that the asset is in location 1 by itself after the first log.
$this->assertEquals(TRUE, $this->assetLocation->hasLocation($asset, $timestamps[0]));
$this->assertEquals(TRUE, $this->assetLocation->hasGeometry($asset, $timestamps[0]));
$this->assertEquals($this->logLocation->getLocation($logs[0]), $this->assetLocation->getLocation($asset, $timestamps[0]));
$this->assertEquals($this->logLocation->getGeometry($logs[0]), $this->assetLocation->getGeometry($asset, $timestamps[0]));
$this->assertEquals($logs[0]->id(), $this->assetLocation->getMovementLog($asset, $timestamps[0])->id());
$this->assertCorrectAssets([$asset], $this->assetLocation->getAssetsByLocation([$locations[0]], $timestamps[0]), TRUE);
// Confirm that the asset is in location 1 by itself after the second log.
$this->assertEquals(TRUE, $this->assetLocation->hasLocation($asset, $timestamps[1]));
$this->assertEquals(TRUE, $this->assetLocation->hasGeometry($asset, $timestamps[1]));
$this->assertEquals($this->logLocation->getLocation($logs[0]), $this->assetLocation->getLocation($asset, $timestamps[1]));
$this->assertEquals($this->logLocation->getGeometry($logs[0]), $this->assetLocation->getGeometry($asset, $timestamps[1]));
$this->assertEquals($logs[0]->id(), $this->assetLocation->getMovementLog($asset, $timestamps[1])->id());
$this->assertCorrectAssets([$asset], $this->assetLocation->getAssetsByLocation([$locations[0]], $timestamps[1]), TRUE);
// Confirm that the asset and group are in location 2 after the third log.
$this->assertEquals(TRUE, $this->assetLocation->hasLocation($asset, $timestamps[2]));
$this->assertEquals(TRUE, $this->assetLocation->hasGeometry($asset, $timestamps[2]));
$this->assertEquals($this->logLocation->getLocation($logs[2]), $this->assetLocation->getLocation($asset, $timestamps[2]));
$this->assertEquals($this->logLocation->getGeometry($logs[2]), $this->assetLocation->getGeometry($asset, $timestamps[2]));
$this->assertEquals($logs[2]->id(), $this->assetLocation->getMovementLog($asset, $timestamps[2])->id());
$this->assertCorrectAssets([$asset, $group], $this->assetLocation->getAssetsByLocation([$locations[1]], $timestamps[2]), TRUE);
// Confirm that the asset and group are in location 1 after the fourth log.
$this->assertEquals(TRUE, $this->assetLocation->hasLocation($asset, $timestamps[3]));
$this->assertEquals(TRUE, $this->assetLocation->hasGeometry($asset, $timestamps[3]));
$this->assertEquals($this->logLocation->getLocation($logs[3]), $this->assetLocation->getLocation($asset, $timestamps[3]));
$this->assertEquals($this->logLocation->getGeometry($logs[3]), $this->assetLocation->getGeometry($asset, $timestamps[3]));
$this->assertEquals($logs[3]->id(), $this->assetLocation->getMovementLog($asset, $timestamps[3])->id());
$this->assertCorrectAssets([$asset, $group], $this->assetLocation->getAssetsByLocation([$locations[0]], $timestamps[3]), TRUE);
// Confirm that the asset has its first location after the last log.
$this->assertEquals(TRUE, $this->assetLocation->hasLocation($asset, $timestamps[4]));
$this->assertEquals(TRUE, $this->assetLocation->hasGeometry($asset, $timestamps[4]));
$this->assertEquals($this->logLocation->getLocation($logs[0]), $this->assetLocation->getLocation($asset, $timestamps[4]));
$this->assertEquals($this->logLocation->getGeometry($logs[0]), $this->assetLocation->getGeometry($asset, $timestamps[4]));
$this->assertEquals($logs[0]->id(), $this->assetLocation->getMovementLog($asset, $timestamps[4])->id());
$this->assertCorrectAssets([$asset, $group], $this->assetLocation->getAssetsByLocation([$locations[0]], $timestamps[4]), TRUE);
// Confirm that the asset is still in the same location 1 second later.
// This tests the <= operator.
$this->assertEquals(TRUE, $this->assetLocation->hasLocation($asset, $timestamps[4] + 1));
$this->assertEquals(TRUE, $this->assetLocation->hasGeometry($asset, $timestamps[4] + 1));
$this->assertEquals($this->logLocation->getLocation($logs[0]), $this->assetLocation->getLocation($asset, $timestamps[4] + 1));
$this->assertEquals($this->logLocation->getGeometry($logs[0]), $this->assetLocation->getGeometry($asset, $timestamps[4] + 1));
$this->assertEquals($logs[0]->id(), $this->assetLocation->getMovementLog($asset, $timestamps[4] + 1)->id());
$this->assertCorrectAssets([$asset, $group], $this->assetLocation->getAssetsByLocation([$locations[0]], $timestamps[4] + 1), TRUE);
}
/**
* Regression test for issue #886.
*
* Test that creating an asset movement log in the past does not override the
* latest group movement log.
*
* This is a regression test for: Creating old movement logs disrupts current
* location logic #886
*
* @see https://github.com/farmOS/farmOS/issues/886
*/
public function testPastAssetMovement() {
// Create an animal asset.
/** @var \Drupal\asset\Entity\AssetInterface $asset */
$asset = Asset::create([
'type' => 'animal',
'name' => $this->randomMachineName(),
'status' => 'active',
]);
$asset->save();
// Create two pasture location assets.
/** @var \Drupal\asset\Entity\AssetInterface[] $locations */
$locations = [];
$location = Asset::create([
'type' => 'pasture',
'name' => $this->randomMachineName(),
'status' => 'active',
'intrinsic_geometry' => $this->reduceWkt($this->wktGenerator->wktGeneratePolygon(NULL, rand(3, 7))),
'is_fixed' => TRUE,
'is_location' => TRUE,
]);
$location->save();
$locations[] = $location;
$location = Asset::create([
'type' => 'pasture',
'name' => $this->randomMachineName(),
'status' => 'active',
'intrinsic_geometry' => $this->reduceWkt($this->wktGenerator->wktGeneratePolygon(NULL, rand(3, 7))),
'is_fixed' => TRUE,
'is_location' => TRUE,
]);
$location->save();
$locations[] = $location;
// Create a group asset.
/** @var \Drupal\asset\Entity\AssetInterface $group */
$group = Asset::create([
'type' => 'group',
'name' => $this->randomMachineName(),
'status' => 'active',
]);
$group->save();
// Assign asset to group.
$group_assignment_log = Log::create([
'type' => 'test',
'status' => 'done',
'is_group_assignment' => TRUE,
'group' => ['target_id' => $group->id()],
'asset' => ['target_id' => $asset->id()],
]);
$group_assignment_log->save();
// Move group to location 1.
$group_movement_log = Log::create([
'type' => 'test',
'status' => 'done',
'is_movement' => TRUE,
'location' => ['target_id' => $locations[0]->id()],
'asset' => ['target_id' => $group->id()],
]);
$group_movement_log->save();
// Confirm that the asset is located where the group is.
$this->assertEquals(TRUE, $this->assetLocation->hasLocation($asset));
$this->assertEquals(TRUE, $this->assetLocation->hasGeometry($asset));
$this->assertEquals($this->logLocation->getLocation($group_movement_log), $this->assetLocation->getLocation($asset));
$this->assertEquals($this->logLocation->getGeometry($group_movement_log), $this->assetLocation->getGeometry($asset));
$this->assertEquals($group_movement_log->id(), $this->assetLocation->getMovementLog($asset)->id());
$this->assertCorrectAssets([$asset, $group], $this->assetLocation->getAssetsByLocation([$locations[0]]), TRUE);
// Move asset to location 2 with a timestamp of one year ago.
$asset_movement_log = Log::create([
'timestamp' => strtotime('1 year ago'),
'type' => 'test',
'status' => 'done',
'is_movement' => TRUE,
'location' => ['target_id' => $locations[1]->id()],
'asset' => ['target_id' => $asset->id()],
]);
$asset_movement_log->save();
// Confirm that the asset is still located where the group is.
$this->assertEquals($this->assetLocation->getLocation($group), $this->assetLocation->getLocation($asset));
$this->assertEquals($this->assetLocation->getGeometry($group), $this->assetLocation->getGeometry($asset));
$this->assertEquals($group_movement_log->id(), $this->assetLocation->getMovementLog($asset)->id());
$this->assertCorrectAssets([$asset, $group], $this->assetLocation->getAssetsByLocation([$locations[0]]), TRUE);
$this->assertCorrectAssets([], $this->assetLocation->getAssetsByLocation([$locations[1]]), TRUE);
}
}