diff --git a/appinfo/info.xml b/appinfo/info.xml index ffc773a526..9c1ae67769 100755 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -22,7 +22,7 @@ Create a [bug report](https://github.com/OpenRegister/.github/issues/new/choose) Create a [feature request](https://github.com/OpenRegister/.github/issues/new/choose) ]]> - 0.1.14 + 0.1.17 agpl Conduction OpenRegister diff --git a/appinfo/routes.php b/appinfo/routes.php index 93948ea16d..dde840430a 100755 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -10,5 +10,7 @@ 'routes' => [ ['name' => 'dashboard#page', 'url' => '/', 'verb' => 'GET'], ['name' => 'registers#objects', 'url' => '/api/registers-objects/{register}/{schema}', 'verb' => 'GET'], + ['name' => 'objects#auditTrails', 'url' => '/api/audit-trails/{id}', 'verb' => 'GET'], + ], ]; diff --git a/lib/Controller/ObjectsController.php b/lib/Controller/ObjectsController.php index e1aa1f28d5..d375eb0b13 100644 --- a/lib/Controller/ObjectsController.php +++ b/lib/Controller/ObjectsController.php @@ -6,6 +6,7 @@ use OCA\OpenRegister\Service\SearchService; use OCA\OpenRegister\Db\ObjectEntity; use OCA\OpenRegister\Db\ObjectEntityMapper; +use OCA\OpenRegister\Db\AuditTrailMapper; use OCP\AppFramework\Controller; use OCP\AppFramework\Http\TemplateResponse; use OCP\AppFramework\Http\JSONResponse; @@ -25,7 +26,8 @@ public function __construct( $appName, IRequest $request, private readonly IAppConfig $config, - private readonly ObjectEntityMapper $objectEntityMapper + private readonly ObjectEntityMapper $objectEntityMapper, + private readonly AuditTrailMapper $auditTrailMapper ) { parent::__construct($appName, $request); @@ -64,12 +66,21 @@ public function index(ObjectService $objectService, SearchService $searchService { $filters = $this->request->getParams(); $fieldsToSearch = ['uuid', 'register', 'schema']; + $extend = ['schema', 'register']; $searchParams = $searchService->createMySQLSearchParams(filters: $filters); $searchConditions = $searchService->createMySQLSearchConditions(filters: $filters, fieldsToSearch: $fieldsToSearch); $filters = $searchService->unsetSpecialQueryParams(filters: $filters); - return new JSONResponse(['results' => $this->objectEntityMapper->findAll(filters: $filters, searchConditions: $searchConditions, searchParams: $searchParams)]); + // @todo: figure out how to use extend here + $results = $this->objectEntityMapper->findAll(filters: $filters); + + // We dont want to return the entity, but the object (and kant reley on the normal serilzier) + foreach ($results as $key => $result) { + $results[$key] = $result->getObjectArray(); + } + + return new JSONResponse(['results' => $results]); } /** @@ -162,4 +173,20 @@ public function destroy(int $id): JSONResponse return new JSONResponse([]); } + + /** + * Retrieves a list of logs for an object + * + * This method returns a JSON response containing the logs for a specific object. + * + * @NoAdminRequired + * @NoCSRFRequired + * + * @param string $id The ID of the object to delete + * @return JSONResponse An empty JSON response + */ + public function auditTrails(int $id): JSONResponse + { + return new JSONResponse($this->auditTrailMapper->findAll(filters: ['object' => $id])); + } } diff --git a/lib/Db/AuditTrail.php b/lib/Db/AuditTrail.php new file mode 100644 index 0000000000..fa06c25181 --- /dev/null +++ b/lib/Db/AuditTrail.php @@ -0,0 +1,89 @@ +addType(fieldName: 'uuid', type: 'string'); + $this->addType(fieldName: 'schema', type: 'integer'); + $this->addType(fieldName: 'register', type: 'integer'); + $this->addType(fieldName: 'object', type: 'integer'); + $this->addType(fieldName: 'action', type: 'string'); + $this->addType(fieldName: 'changed', type: 'json'); + $this->addType(fieldName: 'user', type: 'string'); + $this->addType(fieldName: 'userName', type: 'string'); + $this->addType(fieldName: 'session', type: 'string'); + $this->addType(fieldName: 'request', type: 'string'); + $this->addType(fieldName: 'ipAddress', type: 'string'); + $this->addType(fieldName: 'version', type: 'string'); + $this->addType(fieldName: 'created', type: 'datetime'); + } + + public function getJsonFields(): array + { + return array_keys( + array_filter($this->getFieldTypes(), function ($field) { + return $field === 'json'; + }) + ); + } + + public function hydrate(array $object): self + { + $jsonFields = $this->getJsonFields(); + + foreach ($object as $key => $value) { + if (in_array($key, $jsonFields) === true && $value === []) { + $value = null; + } + + $method = 'set'.ucfirst($key); + + try { + $this->$method($value); + } catch (\Exception $exception) { + } + } + + return $this; + } + + public function jsonSerialize(): array + { + return [ + 'id' => $this->id, + 'uuid' => $this->uuid, + 'schema' => $this->schema, + 'register' => $this->register, + 'object' => $this->object, + 'action' => $this->action, + 'changed' => $this->changed, + 'user' => $this->user, + 'userName' => $this->userName, + 'session' => $this->session, + 'request' => $this->request, + 'ipAddress' => $this->ipAddress, + 'version' => $this->version, + 'created' => isset($this->created) ? $this->created->format('c') : null + ]; + } +} diff --git a/lib/Db/AuditTrailMapper.php b/lib/Db/AuditTrailMapper.php new file mode 100644 index 0000000000..3f10f99e82 --- /dev/null +++ b/lib/Db/AuditTrailMapper.php @@ -0,0 +1,75 @@ +db->getQueryBuilder(); + + $qb->select('*') + ->from('openregister_audit_trails') + ->where( + $qb->expr()->eq('id', $qb->createNamedParameter($id, IQueryBuilder::PARAM_INT)) + ); + + return $this->findEntity(query: $qb); + } + + public function findAll(?int $limit = null, ?int $offset = null, ?array $filters = [], ?array $searchConditions = [], ?array $searchParams = []): array + { + $qb = $this->db->getQueryBuilder(); + + $qb->select('*') + ->from('openregister_audit_trails') + ->setMaxResults($limit) + ->setFirstResult($offset); + + foreach ($filters as $filter => $value) { + if ($value === 'IS NOT NULL') { + $qb->andWhere($qb->expr()->isNotNull($filter)); + } elseif ($value === 'IS NULL') { + $qb->andWhere($qb->expr()->isNull($filter)); + } else { + $qb->andWhere($qb->expr()->eq($filter, $qb->createNamedParameter($value))); + } + } + + if (!empty($searchConditions)) { + $qb->andWhere('(' . implode(' OR ', $searchConditions) . ')'); + foreach ($searchParams as $param => $value) { + $qb->setParameter($param, $value); + } + } + + return $this->findEntities(query: $qb); + } + + public function createFromArray(array $object): Log + { + $log = new Log(); + $log->hydrate(object: $object); + + // Set uuid if not provided + if ($log->getUuid() === null) { + $log->setUuid(Uuid::v4()); + } + + return $this->insert(entity: $log); + } + + // We dont need update as we dont change the log +} diff --git a/lib/Db/ObjectEntity.php b/lib/Db/ObjectEntity.php index ab941cc7c2..969aa8d14f 100644 --- a/lib/Db/ObjectEntity.php +++ b/lib/Db/ObjectEntity.php @@ -13,6 +13,7 @@ class ObjectEntity extends Entity implements JsonSerializable protected ?string $schema = null; protected ?string $version = null; protected ?array $object = []; + protected ?string $textRepresentation = null; protected ?DateTime $updated = null; protected ?DateTime $created = null; @@ -22,6 +23,7 @@ public function __construct() { $this->addType(fieldName:'schema', type: 'string'); $this->addType(fieldName: 'version', type: 'string'); $this->addType(fieldName:'object', type: 'json'); + $this->addType(fieldName:'textRepresentation', type: 'text'); $this->addType(fieldName:'updated', type: 'datetime'); $this->addType(fieldName:'created', type: 'datetime'); } @@ -62,17 +64,22 @@ public function hydrate(array $object): self public function jsonSerialize(): array { -// return [ -// 'id' => $this->id, -// 'uuid' => $this->uuid, -// 'version' => $this->version, -// 'register' => $this->register, -// 'schema' => $this->schema, -// 'object' => $this->object, -// 'updated' => isset($this->updated) ? $this->updated->format('c') : null, -// 'created' => isset($this->created) ? $this->created->format('c') : null -// ]; - return $this->object; } + + public function getObjectArray(): array + { + return [ + 'id' => $this->id, + 'uuid' => $this->uuid, + 'version' => $this->version, + 'register' => $this->register, + 'schema' => $this->schema, + 'object' => $this->object, + 'textRepresentation' => $this->textRepresentation, + 'updated' => isset($this->updated) ? $this->updated->format('c') : null, + 'created' => isset($this->created) ? $this->created->format('c') : null + ]; + } + } diff --git a/lib/Db/ObjectEntityMapper.php b/lib/Db/ObjectEntityMapper.php index 4a32555eea..dbe9c6a014 100644 --- a/lib/Db/ObjectEntityMapper.php +++ b/lib/Db/ObjectEntityMapper.php @@ -30,22 +30,25 @@ public function __construct(IDBConnection $db, MySQLJsonService $mySQLJsonServic } /** - * Find an object by ID + * Find an object by ID or UUID * - * @param int $id The ID of the object to find + * @param int|string $idOrUuid The ID or UUID of the object to find * @return ObjectEntity The ObjectEntity */ - public function find(int $id): ObjectEntity + public function find($idOrUuid): ObjectEntity { $qb = $this->db->getQueryBuilder(); $qb->select('*') ->from('openregister_objects') ->where( - $qb->expr()->eq('id', $qb->createNamedParameter($id, IQueryBuilder::PARAM_INT)) + $qb->expr()->orX( + $qb->expr()->eq('id', $qb->createNamedParameter($idOrUuid, IQueryBuilder::PARAM_INT)), + $qb->expr()->eq('uuid', $qb->createNamedParameter($idOrUuid, IQueryBuilder::PARAM_STR)) + ) ); - return $this->findEntity(query: $qb); + return $this->findEntity($qb); } /** diff --git a/lib/Migration/Version1Date20241019205009.php b/lib/Migration/Version1Date20241019205009.php index c055e05291..857091a310 100755 --- a/lib/Migration/Version1Date20241019205009.php +++ b/lib/Migration/Version1Date20241019205009.php @@ -15,9 +15,6 @@ use OCP\Migration\IOutput; use OCP\Migration\SimpleMigrationStep; -/** - * FIXME Auto-generated migration step: Please modify to your needs! - */ class Version1Date20241019205009 extends SimpleMigrationStep { /** @@ -38,26 +35,38 @@ public function changeSchema(IOutput $output, Closure $schemaClosure, array $opt /** @var ISchemaWrapper $schema */ $schema = $schemaClosure(); - // Update the abilities table to add the base column + // Update the openregister_sources table $table = $schema->getTable('openregister_sources'); - $table->addColumn(name: 'uuid', typeName: Types::STRING, options: ['notnull' => true, 'length' => 255]); - $table->addColumn(name: 'version', typeName: Types::STRING, options: ['notnull' => true, 'length' => 255, 'default' => '0.0.1']); - $table->addIndex(['uuid'], 'openregister_sources_uuid_index'); + if (!$table->hasColumn('uuid')) { + $table->addColumn(name: 'uuid', typeName: Types::STRING, options: ['notnull' => true, 'length' => 255]); + $table->addIndex(['uuid'], 'openregister_sources_uuid_index'); + } + if (!$table->hasColumn('version')) { + $table->addColumn(name: 'version', typeName: Types::STRING, options: ['notnull' => true, 'length' => 255, 'default' => '0.0.1']); + } - // Update the abilities table to add the base column + // Update the openregister_schemas table $table = $schema->getTable('openregister_schemas'); - $table->addColumn(name: 'uuid', typeName: Types::STRING, options: ['notnull' => true, 'length' => 255]); - $table->addIndex(['uuid'], 'openregister_sources_uuid_index'); + if (!$table->hasColumn('uuid')) { + $table->addColumn(name: 'uuid', typeName: Types::STRING, options: ['notnull' => true, 'length' => 255]); + $table->addIndex(['uuid'], 'openregister_schemas_uuid_index'); + } - // Update the abilities table to add the base column + // Update the openregister_registers table $table = $schema->getTable('openregister_registers'); - $table->addColumn(name: 'uuid', typeName: Types::STRING, options: ['notnull' => true, 'length' => 255]); - $table->addColumn(name: 'version', typeName: Types::STRING, options: ['notnull' => true, 'length' => 255, 'default' => '0.0.1']); - $table->addIndex(['uuid'], 'openregister_sources_uuid_index'); + if (!$table->hasColumn('uuid')) { + $table->addColumn(name: 'uuid', typeName: Types::STRING, options: ['notnull' => true, 'length' => 255]); + $table->addIndex(['uuid'], 'openregister_registers_uuid_index'); + } + if (!$table->hasColumn('version')) { + $table->addColumn(name: 'version', typeName: Types::STRING, options: ['notnull' => true, 'length' => 255, 'default' => '0.0.1']); + } - // Update the abilities table to add the base column + // Update the openregister_objects table $table = $schema->getTable('openregister_objects'); - $table->addColumn(name: 'version', typeName: Types::STRING, options: ['notnull' => true, 'length' => 255, 'default' => '0.0.1']); + if (!$table->hasColumn('version')) { + $table->addColumn(name: 'version', typeName: Types::STRING, options: ['notnull' => true, 'length' => 255, 'default' => '0.0.1']); + } return $schema; } diff --git a/lib/Migration/Version1Date20241020231700.php b/lib/Migration/Version1Date20241020231700.php new file mode 100755 index 0000000000..2ef8e1704c --- /dev/null +++ b/lib/Migration/Version1Date20241020231700.php @@ -0,0 +1,77 @@ +hasTable('openregister_audit_trails')) { + $table = $schema->createTable('openregister_audit_trails'); + $table->addColumn('id', Types::INTEGER, ['autoincrement' => true, 'notnull' => true]); + $table->addColumn('uuid', Types::STRING, ['notnull' => false, 'length' => 255]); + $table->addColumn('schema', Types::INTEGER, ['notnull' => false]); + $table->addColumn('regsiter', Types::INTEGER, ['notnull' => false]); + $table->addColumn('object', Types::INTEGER, ['notnull' => true]); + $table->addColumn('action', Types::STRING, ['notnull' => true, 'default' => 'update']); + $table->addColumn('changed', Types::JSON, ['notnull' => true]); + $table->addColumn('user', Types::STRING, ['notnull' => true, 'length' => 255]); + $table->addColumn('user_name', Types::STRING, ['notnull' => true, 'length' => 255]); + $table->addColumn('session', Types::STRING, ['notnull' => true, 'length' => 255]); + $table->addColumn('request', Types::STRING, ['notnull' => false, 'length' => 255]); + $table->addColumn('ip_address', Types::STRING, ['notnull' => false, 'length' => 255]); + $table->addColumn('version', Types::STRING, ['notnull' => false, 'length' => 255]); + $table->addColumn('created', Types::DATETIME, ['notnull' => true]); + + $table->setPrimaryKey(['id']); + $table->addIndex(['user'], 'openregister_logs_user_index'); + $table->addIndex(['uuid'], 'openregister_logs_uuid_index'); + } + + ///Update the openregister_objects table + $table = $schema->getTable('openregister_objects'); + if (!$table->hasColumn('text_representation')) { + $table->addColumn(name: 'text_representation', typeName: Types::TEXT, options: ['notnull' => false]); + } + + return $schema; + } + + /** + * @param IOutput $output + * @param Closure(): ISchemaWrapper $schemaClosure + * @param array $options + */ + public function postSchemaChange(IOutput $output, Closure $schemaClosure, array $options): void { + } +} diff --git a/lib/Service/ObjectService.php b/lib/Service/ObjectService.php index 6adb8a3bda..11f021ca83 100755 --- a/lib/Service/ObjectService.php +++ b/lib/Service/ObjectService.php @@ -10,6 +10,8 @@ use OCA\OpenRegister\Db\RegisterMapper; use OCA\OpenRegister\Db\ObjectEntity; use OCA\OpenRegister\Db\ObjectEntityMapper; +use OCA\OpenRegister\Db\AuditTrail; +use OCA\OpenRegister\Db\AuditTrailMapper; use Symfony\Component\Uid\Uuid; class ObjectService @@ -18,18 +20,24 @@ class ObjectService private int $register; private int $schema; - private $callLogMapper; + private AuditTrailMapper $auditTrailMapper; /** * The constructor sets al needed variables. * * @param ObjectEntityMapper $objectEntityMapper The ObjectEntity Mapper */ - public function __construct(ObjectEntityMapper $objectEntityMapper, RegisterMapper $registerMapper, SchemaMapper $schemaMapper) + public function __construct( + ObjectEntityMapper $objectEntityMapper, + RegisterMapper $registerMapper, + SchemaMapper $schemaMapper, + AuditTrailMapper $auditTrailMapper + ) { $this->objectEntityMapper = $objectEntityMapper; $this->registerMapper = $registerMapper; $this->schemaMapper = $schemaMapper; + $this->auditTrailMapper = $auditTrailMapper; } public function find(int|string $id) { @@ -50,6 +58,7 @@ public function createFromArray(array $object) { public function updateFromArray(string $id, array $object, bool $updatedObject) { $object['id'] = $id; + return $this->saveObject( register: $this->getRegister(), schema: $this->getSchema(), @@ -165,7 +174,6 @@ public function getObjects(?string $objectType = null, ?int $register = null, ?i */ public function saveObject(int $register, int $schema, array $object): ObjectEntity { - // Convert register and schema to their respective objects if they are strings if (is_string($register)) { $register = $this->registerMapper->find($register); @@ -185,10 +193,8 @@ public function saveObject(int $register, int $schema, array $object): ObjectEnt $objectEntity->setSchema($schema); ///return $this->objectEntityMapper->update($objectEntity); } - - // Does the object have an if? - if (isset($object['id'])) { + if (isset($object['id']) && !empty($object['id'])) { // Update existing object $objectEntity->setUuid($object['id']); } else { @@ -197,17 +203,64 @@ public function saveObject(int $register, int $schema, array $object): ObjectEnt $object['id'] = $objectEntity->getUuid(); } + $oldObject = $objectEntity->getObject(); $objectEntity->setObject($object); + $changed = []; + + foreach ($object as $key => $value) { + if (!isset($oldObject[$key]) || $oldObject[$key] !== $value) { + $changed[$key] = [ + 'old' => $oldObject[$key] ?? null, + 'new' => $value + ]; + } + } - if($objectEntity->getId()){ - return $this->objectEntityMapper->update($objectEntity); + // Check for removed properties + foreach ($oldObject as $key => $value) { + if (!isset($object[$key])) { + $changed[$key] = [ + 'old' => $value, + 'new' => null + ]; + } } - return $this->objectEntityMapper->insert($objectEntity); - //@todo mongodb support + // Normal loging + //$changed = $objectEntity->getUpdatedFields(); - // Handle external source here if needed - throw new \Exception('Unsupported source type'); + + // If the object has no uuid, create a new one + if (empty($objectEntity->getUuid())) { + $objectEntity->setUuid(Uuid::v4()); + } + + if($objectEntity->getId()){ + $objectEntity = $this->objectEntityMapper->update($objectEntity); + $action = 'update'; + } + else { + $objectEntity = $this->objectEntityMapper->insert($objectEntity); + $action = 'create'; + } + + // Create a log entry + $user = \OC::$server->getUserSession()->getUser(); + + $log = new AuditTrail(); + $log->setUuid(Uuid::v4()); + $log->setObject($objectEntity->getId()); + $log->setAction($action); + $log->setChanged($changed); + $log->setUser($user->getUID()); + $log->setUserName($user->getDisplayName()); + $log->setSession(session_id()); + $log->setRequest(\OC::$server->getRequest()->getId()); + $log->setIpAddress(\OC::$server->getRequest()->getRemoteAddress()); + $log->setCreated(new \DateTime()); + $this->auditTrailMapper->insert($log); + + return $objectEntity; } diff --git a/src/store/modules/object.js b/src/store/modules/object.js index 97c4d730bf..8ef2d2936b 100644 --- a/src/store/modules/object.js +++ b/src/store/modules/object.js @@ -6,6 +6,8 @@ export const useObjectStore = defineStore('object', { state: () => ({ objectItem: false, objectList: [], + auditTrailItem: false, + auditTrails: [], }), actions: { setObjectItem(objectItem) { @@ -18,6 +20,14 @@ export const useObjectStore = defineStore('object', { ) console.log('Object list set to ' + objectList.length + ' items') }, + setAuditTrailItem(auditTrailItem) { + this.auditTrailItem = auditTrailItem && new AuditTrail(auditTrailItem) + }, + setAuditTrails(auditTrails) { + this.auditTrails = auditTrails.map( + (auditTrail) => new AuditTrail(auditTrail), + ) + }, /* istanbul ignore next */ // ignore this for Jest until moved into a service async refreshObjectList(search = null) { // @todo this might belong in a service? diff --git a/src/views/object/ObjectDetails.vue b/src/views/object/ObjectDetails.vue index d767f2118e..463c7212f5 100644 --- a/src/views/object/ObjectDetails.vue +++ b/src/views/object/ObjectDetails.vue @@ -52,9 +52,9 @@ import { objectStore, navigationStore } from '../../store/store.js'
-

+

 								{{ JSON.stringify(objectStore.objectItem.object, null, 2) }}
-							

+
@@ -78,7 +78,28 @@ import { objectStore, navigationStore } from '../../store/store.js'
- No logs found + + + + + + + + + + + + + + +
TijdstipGebruikerActieDetails
{{ new Date(auditTrail.created).toLocaleString() }}{{ auditTrail.userName }}{{ auditTrail.action }} + + + Bekijk details + +
@@ -94,7 +115,7 @@ import { BTabs, BTab } from 'bootstrap-vue' import DotsHorizontal from 'vue-material-design-icons/DotsHorizontal.vue' import Pencil from 'vue-material-design-icons/Pencil.vue' import TrashCanOutline from 'vue-material-design-icons/TrashCanOutline.vue' - +import TimelineQuestionOutline from 'vue-material-design-icons/TimelineQuestionOutline.vue' export default { name: 'ObjectDetails', components: { @@ -106,7 +127,43 @@ export default { DotsHorizontal, Pencil, TrashCanOutline, + TimelineQuestionOutline, + }, + data() { + return { + auditTrailLoading: false, + auditTrails: [], + } + }, + mounted() { + this.getAuditTrails(); }, + methods: { + getAuditTrails() { + this.syncLoading = true + fetch( + `/index.php/apps/openregister/api/audit-trails/${objectStore.objectItem.id}`, + { + method: 'GET', + }, + ) + .then( + (response) => { + response.json().then( + (data) => { + this.auditTrails = data + console.log(this.auditTrails) + this.auditTrailLoading = false + }, + ) + }, + ) + .catch((err) => { + this.error = err + this.auditTrailLoading = false + }) + }, + } }