Starting with Admidio 5.0, all changes to objects (Users, Events, Groups/Roles, Weblinks, Albums, Folders/Files, …) and settings that are saved in Admidio's database can be logged and the changes displayed in the Change History screen. Logging can be enabled per object type (=database table) in the preferences. Each object or list with changelogs enabled will display a changelog button to view it:
The changelog depends on the use of the Entity
class (previously TableAccess
) and its derived classes for all database access. Direct modifications of the database using SQL statements will not be logged. The Entity class uses setValue
methods to change database columns and the save
method to store them to the database. This is where the changelog hooks in: In addition to storing the change to the database, the save method will also detect and insert a new entry for each changed column into the adm_log_changes database table (using the LogChanges class derived from Entity).
Entity::logCreation()
method is called to insert a creation record into the log.Entity::logModifications($logChanges)
method is called (which will in turn insert a separate changelog entry for each modified column).Entity::logDeletion()
method is called.These methods handle the creation of LogChanges records and storing them to the adm_log_changes database table. Usually it is not needed to change these core functions. See below for instructions how to adjust all aspects of the changelog generation and display.
In addition, the following methods of the Entity class are used:
Entity::readableName()
: Returns a human-readable representation of the database record. By default, if a column 'prefix_name' or 'prefix_headline' exists, it will be used, otherwise the table's key column is returned. Classes like User
can override this to return e.g. a string of the form “Lastname, Firstname”.static Entity::setLoggingEnabled($enabled)
: Temporarily enable or disable logging (until called again, or a new request is handled). This state is not persisted, so this function will not affect subsequent page loadings.Entity::getIngoredLogColumns()
: Returns a list of column names, which should not be logged (e.g. the creation / modification time stamps or user IDs)Entity::adjustLogEntry(LogChanges $logEntry)
: After the logCration/logModification/logDeletion methods set up the new LogChanges record, this method can adjust it for custom behaviour, e.g. to add a related record (group memberships have the user as modified record and the group as a related record) or completely change the record away from the default behavior.Derived subclasses of the Entity base class can override these methods to tweak the changelog entries generated (or even suppress or fundamentally change them).
The changelog entries are stored in the database in the adm_log_changes table and contain all information of the affected record (ID, UUID and name), a potentially affected related record (ID/UUID and name), the modified field / dabase column (column name and human-readable name), as well as the previous and the new values.
The table has the following columns, most of which will be automatically filled the methods in the Entity class:
log_id
(auto-increment counter), log_table
(the affected database table without the 'adm_' prefix)log_record_id
, log_record_uuid
, log_record_name
: The record ID, UUID and a human-readable representation of the affected recordlog_record_linkid
: Some tables have no corresponding display page for its records, so we want to link to a different object (e.g. the adm_members table has no view for its records. Instead the affected record should be the user and the group is the related record → the linkid for html links is the user UUID rather than the membership ID or UUID)log_related_id
, log_related_name
: For records that relate to others (e.g. group memberships relate a user to a group, a folder or album potentially relates to its parent folder/album, etc.) these columns give the ID/UUID and the human-readable name of the related namelog_field
, log_field_name
: table column name and human-readable representation of the modified columnlog_action
: MODIFY, CREATED or DELETEDlog_value_old
, log_value_new
: The previous and the new value of the field.log_user_id_create
, log_timestamp_create
: user ID and time of the change (both automatically filled from the current user and date/time)
By default, all database access via the Entity class can and will be logged, as long as the corresponding preference flag is set. The core admidio tables have their own preference setting, so each table can be individually turned on/off. All other tables (third party modules/plugins or future core modules that have not yet explicitly implemented its logging) are controlled collectively by the preference changelog_table_others
(Preferences → “Change History” → header “Content modules” → “All others (others)”).
All uncustomized tables will log all changes to all columns (except the creation/modification user ids and timestamps) with the raw table column name as field. The changelog entry will not have any links and the human-readable representation will use value returned by the record's Entity::readableName()
method. For example, without any custom implementation, the forum modul would create the following changelog entries out of the box when adding a new topic and a new post:
As one can see, the creation of the topic and the post with ID 1 is properly logged, as well as setting its category and title. For a user, however, this display can be improved by using category and topic names for display, linking to the corresponding pages, and converting the raw database column names to comprehensible labels.
See below for instructions how to add tables for individual selection and customize their logging.
Some tables (like the adm_auto_login or adm_sessions tables) are meant as transient temporary data storage and should never be logged in the changelog. Others like adm_log_changes are clearly also not meant to be logged in the changelog. Third-party plugins can also use their own table to cache data temporarily without logging.
Admidio holds a global static table of database table names that should not be logged in the static array Entity::$noLogTables
array. If a third-party plugin or module has a table that should never generate changelog entries, you can add the corresponding table names (WITHOUT the table name prefix!) in the constructor of your modules class or even in the main body of a module.
The following code will add the adm_myplugin_cache and adm_session_data tables to the list of tables that should not be logged. This will only affect the current execution and will NOT persist to the next execution. So this code really needs to be executed each time your plugin/module loads.
use Admidio\Changelog\Entity\LogChanges; array_push(LogChanges::$noLogTables, 'myplugin_cache', 'session_data');
Core modules and plugins can directly modify the LogChanges::$noLogTables default setting in the src/Changelog/Entity/LogChanges.php
file.
By default, the uuid (prefix_uuid) as well as the the create/change timestamp (prefix_timestamp_create/change) and user ID (prefix_usr_id_create/change) columns are ignored. To ignore other columns as well, one must use an Entity-derived subclass for your database record and override the getIgnoredLogColumns()
methods, like the User class does::
use Admidio\Infrastructure\Entity\Entity; class User extends Entity { ... public function getIgnoredLogColumns(): array { return array_merge(parent::getIgnoredLogColumns(), ['usr_pw_reset_id', 'usr_pw_reset_timestamp', 'usr_last_login']); } }
Of course, you then need to use this class instead of Entity to create / modify the database.
All tweaks to the changelog record generation depend on the use of an Entity-drived class for your record creation/modification, similar to the code above to ignore certain database columns in the changelog!
By default, each object (database record) uses the 'prefix_name' column as its display name in the changelog view, if such a column exists (if not, 'prefix_title' and 'prefix_headline' are used, too). To change this, one can simply override the Entity::readableName()
method, like for the User class, which uses a label of the form “Lastname, Firstname” as its display string:
class User extends Entity { ... public function readableName(): string { return $this->mProfileFieldsData->getValue('LAST_NAME') . ', ' . $this->mProfileFieldsData->getValue('FIRST_NAME'); } }
Override the Entity::adjustLogEntry(LogChanges $logEntry)
method in your subclass and call $logEntry→setLogRelated(..)
to add the link to the related object. Here is the code for the File class to set the corresponding Folder as the related object (the UUID and the name of the related object are needed):
<?php use Admidio\Changelog\Entity\LogChanges; class File extends Entity { ... protected function adjustLogEntry(LogChanges $logEntry) { $folEntry = new Folder($this->db, $this->getValue('fil_fol_id')); $logEntry->setLogRelated($folEntry->getValue('fol_uuid'), $folEntry->getValue('fol_name')); } }
By default, the changelog view will link to the same object as the main object (e.g. if a folder links to parent folder, it will work out of the box). To link to a different object type, one needs to modify the changelog display code. See below for instructions.
Some database records do not describe objects per se, but relations between records or even more abstract data. For these, the database record should not be logged as a separate record, but rather as a change to a completely different record. E.g. the creation of a Membership record (table adm_members) should not be logged as the creation of a membership record, with each column as a separate modification, but rather as a modification of the corresponding User records with the related group/role. This fundamental modification of the changelog record can also be done in the adjustLogEntry method. E.g. the Membership::ajustLogEntry method set the user as the object for HTML links in the change log, inserts the group as a releated object and also suppresses individual logging of the mem_rol_id, mem_usr_id and mem_uuid columns. As a consequence, the creation of a membership object is logged like a change of the user object related to the group.
class Membership extends Entity { ... public function getIgnoredLogColumns(): array { return array_merge(parent::getIgnoredLogColumns(), ['mem_rol_id', 'mem_usr_id', 'mem_uuid'}]); } protected function adjustLogEntry(LogChanges $logEntry) { global $gDb, $gProfileFields; $usrId = (int)$this->getValue('mem_usr_id'); $user = new User($this->db, $gProfileFields, $usrId); $logEntry->setValue('log_record_name', $user->readableName()); $logEntry->setValue('log_record_uuid', $user->getValue('usr_uuid')); $logEntry->setLogLinkID($usrId); $rolId = $this->getValue('mem_rol_id'); $role = new Role($this->db, $rolId); $logEntry->setLogRelated($role->getValue('rol_uuid'), $role->getValue('rol_name')); } }
The page '/adm_program/modules/changelog.php' is used to display the changelog, either for only one or more particular tables (parameter table=table1
or table=table1%2Ctable2%2Ctable3
) or even only one object (parameter uuid=7a854ed2-50db-49ee-9379-31d07f467d47
). If no parameters are given, the whole changelog from all tables is displayed.
While individual modification or list pages show the “Change History” button for the particular object or object type, sometimes it can be useful to see the complete changelog of all changes. The easiest way is to create an admin menu item for this. Simply to to “Menu” and create a new item with the following settings:
The changelog page (code in adm_program/modules/changelog.php
) first checks, whether the current user has either admin rights or at least edit rights for the corresponding tables or object.
It loads all entries from the adm_log_changes table and displays them:
static ChangelogService::getObjectForTable(string $module)
is used to load the object from the database. It's readableName is then used in the headline. E.g. if $module='users'
, a User object is created, if $module='photos'
, an Album object, etc.static ChangelogService::getTableLabel(string $table)
methodLanguage::translateIfTranslationStrId
. It then tries to create a link to the object using the static ChangelogService::createLink(string $text, string $module, int|string $id, string $uuid = '')
method. If the 'log_record_linkid' is set, then this ID is used in the link rather than the original record ID/UUID.static ChangelogService::getRelatedTable
method to modify the object type for the related object.static ChangelogService::getFieldTranslations()
method defines a mapping table.return array(..., 'rol_name' => 'SYS_NAME', 'rol_description' => 'SYS_DESCRIPTION', 'rol_cat_id' => array('name' => 'SYS_CATEGORY', 'type' => 'CATEGORY'), ... 'lnk_name' => 'SYS_LINK_NAME', 'lnk_description' => 'SYS_DESCRIPTION', 'lnk_url' => array('name' => 'SYS_LINK_ADDRESS', 'type' => 'URL'), 'lnk_cat_id' => array('name' => 'SYS_CATEGORY', 'type' => 'CATEGORY'), }
'type'
key, its value determines the formatting. The actual formatting is done with the static ChangelogService::formatValue($value, $type, $entries = [])
method. New columns can be simply added to the getFieldTranslations() method. 'column_name' => 'translatable string'
suffices.'column_name' => array('name' => 'translatable string', 'type' => 'BOOL')
formatValue
method. See below.
The abovementioned methods of the ChangelogService
class have all existing database tables properly implemented. New modules or new database tables simply need to add code to these functions for proper support, if required (not all objects have a page to link to, some database columns can use the default formatting, etc.).
If a new object type and thus a new database table is added to Admidio (either by the core, but included modules or plugins or by third-party extensions), the table's (translatable) name is added to the ChangelogService::getTableLabel(string $table)
array and if a list view for objects of the new table exists, a link can be added in the ChangelogService::createLink
method (file src/Changelog/Service/ChangelogService.php
):
class ChangelogService { ... public static function createLink(string $text, string $module, $id, $uuid = '') { switch ($module) { ... case 'rooms': $url = SecurityUtils::encodeUrl(ADMIDIO_URL.FOLDER_MODULES.'/rooms/rooms_new.php', array('room_uuid' => $uuid)); break; ... } }
By default, if a record has a related object, it will be formatted and linked with the same object type. E.g. if a folder has a parent folder (set as related record in the changelog table), the parent folder will also be formatted as a folder and a link to the folder page created. In many cases this is not desired, e.g. a File is related to its parent folder, which cannot be formatted as File, but rather as folder. This needs to be added to the method ChangelogService::getRelatedTable
(file src/Changelog/Service/ChangelogService.php
):
case 'files': return 'folders';
To add a new data type for field value formatting (both the previous and the new value), you define the column in the ChangelogService::getFieldTranslations
method (file src/Changelog/Service/ChangelogService.php
) with the new data type you desire:
'fil_fol_id' => array('name' => 'SYS_FOLDER', 'type' => 'FOLDER'),
In addition, you also need to implement the HTML output for values this datatype 'FOLDER' in the ChangelogService::formatValue($value, $type, $entries = [])
function (file src/Changelog/Service/ChangelogService.php
), with the $value typically holding the ID of the object:
public static function formatValue($value, $type, $entries = []) { ... switch ($type) { ... case 'FOLDER': $obj = new Folder($gDb, $value); $htmlValue = self::createLink($obj->readableName(), 'folders', $obj->getValue('fol_id'), $obj->getValue('fol_uuid')); break; ... } }
It is very easy to add a changelog button to each list page for a certain type of object, as well as to individual edit pages. The changelog on a list page will display all changes to the objects of that type, while the changelog of a particular edit page will filter the changelog to display only changes to the current object. Both variants are handled by a call to the method public static ChangelogService::displayHistoryButton(PagePresenter $page, string $area, string|array $table, bool $condition = true, array $params = array())
. This method adds a history button (only if $condition
is true) to the current page $page
. The param $table
defines the database table(s), e.g. 'users,user_data,members'
for user profile data, or 'rooms' for rooms), while $params
defines additional filters that are directly passed on as URL parameters to the link for adm_program/modules/changelog.php.
Supported key are 'id', 'uuid' and 'related_to', which all correspond to the columns in the adm_log_changes table.
An example for the changelog button on the contacts page (showing all changes to all contacts, if the user has the neccessary permissions) is:
ChangelogService::displayHistoryButton($page, 'contacts', 'users,user_data,members');
The changlog button on the profile edit page of a particular contact is:
// show link to view profile field change history, if we have a user ID and the current user has permissions ChangelogService::displayHistoryButton($page, 'profile', 'users,user_data,user_relations,members', !empty($getUserUuid) && $gCurrentUser->hasRightEditProfile($user), array('uuid' => $getUserUuid));
At the example of the Forum module's tables (adm_forum_posts and adm_forum_topics), these are the steps to implement full support. Basic changelog support already works out of the box with no required code changes (enabled with the changelog_table_others
preference flag for all unknown or third-party database table). All changes described here are only required to get a nicer changelog view with links and easy-to-understand labels.
The example of the forum module is taken directly from the admidio code tree (commit ff61d0a on github)
ChangelogService::getTableLabel
translation array (file src/Changelog/Service/ChangelogService.php
)'forum_topics' => 'SYS_FORUM_TOPIC', 'forum_posts' => 'SYS_FORUM_POST',
adm_program/languages/en.xml
<string name="SYS_FORUM_POST">Forum post</string> <string name="SYS_FORUM_TOPIC">Forum topic</string>
install/db_scripts/preferences.php
'changelog_table_forum_topics' => '0', 'changelog_table_forum_posts' => '0',
src/UI/View/Preferences.php
, Preferences::createChangelogForm:array( 'title' => $gL10n->get('SYS_HEADER_CONTENT_MODULES'), 'id' => 'content_modules', 'tables' => array('files', 'folders', 'photos', 'announcements', 'events', 'rooms', 'forum_topics', 'forum_posts', 'links', 'others') ),
src/Changelog/Service/ChangelogService.php
)use Admidio\Forum\Entity\Topic; use Admidio\Forum\Entity\Post;
Topic::getIgnoredLogColumns()
(file src/Forum/Entity/Topic.php
):class Topic extends Entity { ... public function getIgnoredLogColumns(): array { return array_merge(parent::getIgnoredLogColumns(), [$this->columnPrefix . '_views'], ($this->newRecord)?[$this->columnPrefix.'_title']:[]); } }
Post::getIgnoredLogColumns()
(file src/Forum/Entity/Post.php
):class Post extends Entity { ... public function getIgnoredLogColumns(): array { return array_merge(parent::getIgnoredLogColumns(), ['fop_fot_id'], ($this->newRecord)?[$this->columnPrefix.'_text']:[] ); } }
Topic::adjustLogEntry()
(file src/Forum/Entity/Topic.php
):class Topic extends Entity { ... protected function adjustLogEntry(LogChanges $logEntry): void { $fotEntry = new Post($this->db, (int)$this->getValue('fot_fop_id_first_post')); $logEntry->setLogRelated($fotEntry->getValue('fop_uuid'), $fotEntry->getValue('fop_text')); } }
Post::adjustLogEntry()
(file src/Forum/Entity/Post.php
):class Post extends Entity { ... protected function adjustLogEntry(LogChanges $logEntry): void { $fotEntry = new Topic($this->db, $this->getValue('fop_fot_id')); $logEntry->setLogRelated($fotEntry->getValue('fot_uuid'), $fotEntry->getValue('fot_title')); } }
ChangelogService::getFieldTranslations
, add the column definitions to the return list (file src/Changelog/Service/ChangelogService.php
):'fot_cat_id' => array('name' => 'SYS_CATEGORY', 'type' => 'CATEGORY'), 'fot_fop_id_first_post' => array('name' => 'SYS_FORUM_POST', 'type' => 'POST'), 'fot_title' => 'SYS_TITLE', 'fop_text' => 'SYS_TEXT', 'fop_fot_id' => array('name' => 'SYS_FORUM_TOPIC', 'type' => 'TOPIC'),
ChangelogService::formatValue
method inside the switch statement (file src/Changelog/Service/ChangelogService.php
):case 'TOPIC': $obj = new Topic($gDb, $value); $htmlValue = self::createLink($obj->readableName(), 'forum_topics', $obj->getValue('fot_id'), $obj->getValue('fot_uuid')); break; case 'POST': $obj = new POST($gDb, $value); $htmlValue = self::createLink($obj->readableName(), 'forum_posts', $obj->getValue('fop_id'), $obj->getValue('fop_uuid')); break;
ChangelogService::createLink
, add the link definitions in the switch statement (file src/Changelog/Service/ChangelogService.php
):case 'forum_topics' : $url = SecurityUtils::encodeUrl( ADMIDIO_URL.FOLDER_MODULES.'/forum.php', array('mode' => 'topic', 'topic_uuid' => $uuid)); break; case 'forum_posts' : $url = SecurityUtils::encodeUrl( ADMIDIO_URL.FOLDER_MODULES.'/forum.php', array('mode' => 'post_edit', 'post_uuid' => $uuid)); break;
ChangelogService::getRelatedTable
, tinside the switch statement, add the type of the related object for posts to topic and vice versa (file src/Changelog/Service/ChangelogService.php
):case 'forum_posts': return 'forum_topics'; case 'forum_topics: return 'forum_posts';
src/Changelog/Service/ChangelogService.php
)ChangelogServer::getObjectForTable(string $module)
:case 'forum_topic': return new Topic($gDb); case 'forum_post': return new Post($gDb);
src/UI/Presenter/ForumPresenter.php
, use Admidio\Changelog\Service\ChangelogService;
method ForumPresenter::createSharedHeader
ChangelogService::displayHistoryButton($this, 'forum', 'forum_topics,forum_posts', $gCurrentUser->administrateForum());
src/UI/Presenter/ForumTopicPresenter.php
use Admidio\Changelog\Service\ChangelogService;
In ForumTopicPresenter::createCards method (after this→addPageFunctionsMenuItem call):
global $gCurrentUser; ChangelogService::displayHistoryButton($this, 'forum', 'forum_topics,forum_posts', $gCurrentUser->administrateForum(), ['uuid' => $this->topicUUID]);
In ForumTopicPresenter::createEditForm (after new FormPresenter); The changelog button should be hidden when a new topic is created (i.e. no uuid exists yet!):
ChangelogService::displayHistoryButton($this, 'forum', 'forum_topics,forum_posts', $this->topicUUID !== '' && $gCurrentUser->administrateForum(), ['uuid' => $this->topicUUID]);
src/UI/Presenter/ForumPostPresenter.php
use Admidio\Changelog\Service\ChangelogService;
In ForumPostPresenter::createEditFormmethod (after new FormPresenter):
global $gCurrentUser; ChangelogService::displayHistoryButton($this, 'forum', 'forum_posts', $this->postUUID !== '' && $gCurrentUser->administrateForum(), ['uuid' => $this->postUUID]);
Adding support for changelogs in third-party extensions, where modifying the core Admidio code is not possible, works similar to the above steps. Modifying the changelog entry creation is implemented inside the extension's Entity-derived database access classes, so this part is similar to core modules. However, the formatting in the Change history page view is implemented in the class ChangelogService
for the core modules, which is not directly available for change to third-party extension developers.
However, the ChanglogService class additionally provides a way to register mapping tables for table/column names or general callback functions for all the methods that need modifications as described above. The callback functions are registered with the method
/** * Register a callback function or value for the changelog functionality. If the callback is a value (string, array, etc.), it will * be returned. If the callback is a function, it will be executed and if the return value is not empty, it will be returned. If the * function returns a null or empty value, the next callback or the default processing of the ChangelogService method will proceed. * @param string $function The method of the ChangelogService class that should be customized. One of * 'getTableLabel', 'getTableLabelArray', 'getObjectForTable', 'getFieldTranslations', 'createLink', * 'formatValue', 'getRelatedTable', 'getPermittedTables' * @param string $moduleOrKey The module or type that should be customized. If empty, the callback will be * executed for all values and it will be used if it evaluates to a non-empty value. * @param mixed $callback The callback function or value. A value will be returned unchanged, a function will be executed (arguments are identical to the ChangelogService's methods) */ static ChangelogService::registerCallback(string $function, string $moduleOrKey, mixed $callback)
Using these callback mechanisms, the forum changelog described above could also be implemented with the following code. It should be executed somewhere during php startup when the third-party module is loaded, and before either a changelog page can be displayed or before any of the third-party extension's database records are modified (i.e. before the extension writes data to the database).
## Translation of database tables ChangelogService::registerCallback('getTableLabelArray', 'forum_topics', 'SYS_FORUM_TOPIC'); ChangelogService::registerCallback('getTableLabelArray', 'forum_posts', 'SYS_FORUM_POST'); ## Translations and type definitions of database columns ChangelogService::registerCallback('getFieldTranslations', '', [ 'fot_cat_id' => array('name' => 'SYS_CATEGORY', 'type' => 'CATEGORY'), 'fot_fop_id_first_post' => array('name' => 'SYS_FORUM_POST', 'type' => 'POST'), 'fot_title' => 'SYS_TITLE', 'fop_text' => 'SYS_TEXT', 'fop_fot_id' => array('name' => 'SYS_FORUM_TOPIC', 'type' => 'TOPIC') ]); ## Formatting of new database column types (in many cases not needed) ChangelogService::registerCallback('formatValue', 'TOPIC', function($value, $type, $entries = []) { global $gDb; if (empty($value)) return ''; $obj = new Topic($gDb, $value??0); return ChangelogService::createLink($obj->readableName(), 'forum_topics', $obj->getValue('fot_id'), $obj->getValue('fot_uuid')); }); ChangelogService::registerCallback('formatValue', 'POST', function($value, $type, $entries = []) { global $gDb; if (empty($value)) return ''; $obj = new POST($gDb, $value??0); return ChangelogService::createLink($obj->readableName(), 'forum_posts', $obj->getValue('fop_id'), $obj->getValue('fop_uuid')); }); ## Create HTML links to the object's list view and edit pages ChangelogService::registerCallback('createLink', 'forum_topics', function(string $text, string $module, int|string $id, string $uuid = '') { return SecurityUtils::encodeUrl( ADMIDIO_URL.FOLDER_MODULES.'/forum.php', array('mode' => 'topic', 'topic_uuid' => $uuid)); }); ChangelogService::registerCallback('createLink', 'forum_posts', function(string $text, string $module, int|string $id, string $uuid = '') { return SecurityUtils::encodeUrl( ADMIDIO_URL.FOLDER_MODULES.'/forum.php', array('mode' => 'post_edit', 'post_uuid' => $uuid)); }); ## Object types of related objects (if object relations are used at all!) ChangelogService::registerCallback('getRelatedTable', 'forum_topics', 'forum_posts'); ChangelogService::registerCallback('getRelatedTable', 'forum_posts', 'forum_topics'); ## Create Entity-derived objects to create headlines with proper object names ChangelogService::registerCallback('getObjectForTable', 'forum_topics', function() {global $gDb; return new Topic($gDb);}); ChangelogService::registerCallback('getObjectForTable', 'forum_posts', function() {global $gDb; return new Post($gDb);}); ## Enable per-user detection of access permissions to the tables (based on user's role permission); Admin is always allowed ChangelogService::registerCallback('getPermittedTables', '', function(User $user) { if ($user->administrateForum()) return ['forum_topics', 'forum_posts']; });