Browse Source

Merge pull request #9326 from nextcloud/feat/9274/transcripts-of-call-recordings

feat(recording): Automatically generate transcripts of call recording
pull/9348/head
Joas Schilling 3 years ago
committed by GitHub
parent
commit
e6629b1dcf
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 8
      composer.lock
  2. 6
      lib/AppInfo/Application.php
  3. 27
      lib/Notification/Notifier.php
  4. 89
      lib/Recording/Listener.php
  5. 89
      lib/Service/RecordingService.php
  6. 67
      tests/integration/features/bootstrap/FeatureContext.php
  7. 13
      tests/integration/features/bootstrap/SharingContext.php
  8. 33
      tests/integration/features/callapi/recording.feature
  9. 4
      tests/integration/spreedcheats/lib/AppInfo/Application.php
  10. 45
      tests/integration/spreedcheats/lib/SpeechToText/LoremIpsumProvider.php
  11. 4
      tests/php/Service/RecordingServiceTest.php
  12. 5
      tests/psalm-baseline.xml

8
composer.lock

@ -134,12 +134,12 @@
"source": {
"type": "git",
"url": "https://github.com/nextcloud-deps/ocp.git",
"reference": "6ec2f44077e9e75e2e93787b452b37736c15f3c3"
"reference": "91da9a295f2f7db0a0945427efa3632b4386ad99"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/nextcloud-deps/ocp/zipball/6ec2f44077e9e75e2e93787b452b37736c15f3c3",
"reference": "6ec2f44077e9e75e2e93787b452b37736c15f3c3",
"url": "https://api.github.com/repos/nextcloud-deps/ocp/zipball/91da9a295f2f7db0a0945427efa3632b4386ad99",
"reference": "91da9a295f2f7db0a0945427efa3632b4386ad99",
"shasum": ""
},
"require": {
@ -171,7 +171,7 @@
"issues": "https://github.com/nextcloud-deps/ocp/issues",
"source": "https://github.com/nextcloud-deps/ocp/tree/master"
},
"time": "2023-04-08T00:33:01+00:00"
"time": "2023-04-20T00:33:48+00:00"
},
{
"name": "psr/clock",

6
lib/AppInfo/Application.php

@ -71,6 +71,7 @@ use OCA\Talk\Profile\TalkAction;
use OCA\Talk\PublicShare\TemplateLoader as PublicShareTemplateLoader;
use OCA\Talk\PublicShareAuth\Listener as PublicShareAuthListener;
use OCA\Talk\PublicShareAuth\TemplateLoader as PublicShareAuthTemplateLoader;
use OCA\Talk\Recording\Listener as RecordingListener;
use OCA\Talk\Room;
use OCA\Talk\Search\ConversationSearch;
use OCA\Talk\Search\CurrentMessageSearch;
@ -100,6 +101,8 @@ use OCP\IUser;
use OCP\Security\CSP\AddContentSecurityPolicyEvent;
use OCP\Security\FeaturePolicy\AddFeaturePolicyEvent;
use OCP\Settings\IManager;
use OCP\SpeechToText\Events\TranscriptionFailedEvent;
use OCP\SpeechToText\Events\TranscriptionSuccessfulEvent;
use OCP\User\Events\BeforeUserLoggedOutEvent;
use OCP\User\Events\UserChangedEvent;
use OCP\User\Events\UserDeletedEvent;
@ -140,6 +143,9 @@ class Application extends App implements IBootstrap {
$context->registerEventListener(AddingCircleMemberEvent::class, CircleMembershipListener::class);
$context->registerEventListener(RemovingCircleMemberEvent::class, CircleMembershipListener::class);
$context->registerEventListener(TranscriptionSuccessfulEvent::class, RecordingListener::class);
$context->registerEventListener(TranscriptionFailedEvent::class, RecordingListener::class);
$context->registerSearchProvider(ConversationSearch::class);
$context->registerSearchProvider(CurrentMessageSearch::class);
$context->registerSearchProvider(MessageSearch::class);

27
lib/Notification/Notifier.php

@ -265,7 +265,7 @@ class Notifier implements INotifier {
->setLink($this->url->linkToRouteAbsolute('spreed.Page.showCall', ['token' => $room->getToken()]));
$subject = $notification->getSubject();
if ($subject === 'record_file_stored') {
if ($subject === 'record_file_stored' || $subject === 'transcript_file_stored' || $subject === 'transcript_failed') {
return $this->parseStoredRecording($notification, $room, $participant, $l);
}
if ($subject === 'record_file_store_fail') {
@ -382,10 +382,21 @@ class Notifier implements INotifier {
IAction::TYPE_DELETE
);
if ($notification->getSubject() === 'record_file_stored') {
$subject = $l->t('Call recording now available');
$message = $l->t('The recording for the call in {call} was uploaded to {file}.');
} elseif ($notification->getSubject() === 'transcript_file_stored') {
$subject = $l->t('Transcript now available');
$message = $l->t('The transcript for the call in {call} was uploaded to {file}.');
} else {
$subject = $l->t('Failed to transcript call recording');
$message = $l->t('The server failed to transcript the recording at {file} for the call in {call}. Please reach out to the administration.');
}
$notification
->setRichSubject($l->t('Call recording now available'))
->setRichSubject($subject)
->setRichMessage(
$l->t('The recording for the call in {call} was uploaded to {file}.'),
$message,
[
'call' => [
'type' => 'call',
@ -401,9 +412,13 @@ class Notifier implements INotifier {
'path' => $path,
'link' => $this->url->linkToRouteAbsolute('files.viewcontroller.showFile', ['fileid' => $file->getId()]),
],
])
->addParsedAction($shareAction)
->addParsedAction($dismissAction);
]);
if ($notification->getSubject() !== 'transcript_failed') {
$notification->addParsedAction($shareAction);
$notification->addParsedAction($dismissAction);
}
return $notification;
}

89
lib/Recording/Listener.php

@ -0,0 +1,89 @@
<?php
declare(strict_types=1);
/**
* @copyright Copyright (c) 2023 Joas Schilling <coding@schilljs.com>
*
* @author Joas Schilling <coding@schilljs.com>
*
* @license GNU AGPL version 3 or any later version
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
namespace OCA\Talk\Recording;
use OCA\Talk\AppInfo\Application;
use OCA\Talk\Service\RecordingService;
use OCP\EventDispatcher\Event;
use OCP\EventDispatcher\IEventListener;
use OCP\Files\File;
use OCP\Files\IRootFolder;
use OCP\SpeechToText\Events\AbstractTranscriptionEvent;
use OCP\SpeechToText\Events\TranscriptionFailedEvent;
use OCP\SpeechToText\Events\TranscriptionSuccessfulEvent;
/**
* @template-implements IEventListener<Event>
*/
class Listener implements IEventListener {
public function __construct(
protected RecordingService $recordingService,
protected IRootFolder $rootFolder,
) {
}
public function handle(Event $event): void {
if (!($event instanceof AbstractTranscriptionEvent)) {
// Unrelated
return;
}
if ($event->getAppId() !== Application::APP_ID) {
return;
}
if ($event instanceof TranscriptionSuccessfulEvent) {
$this->successfulTranscript($event->getUserId(), $event->getFile(), $event->getTranscript());
} elseif ($event instanceof TranscriptionFailedEvent) {
$this->failedTranscript($event->getUserId(), $event->getFile());
}
}
protected function successfulTranscript(?string $owner, ?File $fileNode, string $transcript): void {
if (!$fileNode instanceof File) {
return;
}
if ($owner === null) {
return;
}
$this->recordingService->storeTranscript($owner, $fileNode, $transcript);
}
protected function failedTranscript(?string $owner, ?File $fileNode): void {
if (!$fileNode instanceof File) {
return;
}
if ($owner === null) {
return;
}
$this->recordingService->notifyAboutFailedTranscript($owner, $fileNode);
}
}

89
lib/Service/RecordingService.php

@ -27,6 +27,7 @@ namespace OCA\Talk\Service;
use InvalidArgumentException;
use OC\User\NoUserException;
use OCA\Talk\AppInfo\Application;
use OCA\Talk\Chat\ChatManager;
use OCA\Talk\Config;
use OCA\Talk\Exceptions\ParticipantNotFoundException;
@ -43,8 +44,10 @@ use OCP\Files\IRootFolder;
use OCP\Files\NotFoundException;
use OCP\Files\NotPermittedException;
use OCP\Notification\IManager;
use OCP\PreConditionNotMetException;
use OCP\Share\IManager as ShareManager;
use OCP\Share\IShare;
use OCP\SpeechToText\ISpeechToTextManager;
use Psr\Log\LoggerInterface;
class RecordingService {
@ -76,7 +79,8 @@ class RecordingService {
protected ShareManager $shareManager,
protected ChatManager $chatManager,
protected LoggerInterface $logger,
protected BackendNotifier $backendNotifier
protected BackendNotifier $backendNotifier,
protected ISpeechToTextManager $speechToTextManager,
) {
}
@ -137,6 +141,66 @@ class RecordingService {
} catch (NotPermittedException $e) {
throw new InvalidArgumentException('owner_permission');
}
try {
$this->speechToTextManager->scheduleFileTranscription($fileNode, $owner, Application::APP_ID);
} catch (PreConditionNotMetException $e) {
// No Speech-to-text provider installed
$this->logger->debug('Could not generate transcript of call recording', ['exception' => $e]);
} catch (InvalidArgumentException $e) {
$this->logger->warning('Could not generate transcript of call recording', ['exception' => $e]);
}
}
public function storeTranscript(string $owner, File $recording, string $transcript): void {
$recordingFolder = $recording->getParent();
$roomToken = $recordingFolder->getName();
try {
$room = $this->roomManager->getRoomForUserByToken($roomToken, $owner);
$participant = $this->participantService->getParticipant($room, $owner);
} catch (ParticipantNotFoundException) {
$this->logger->warning('Could not determinate conversation when trying to store transcription of call recording');
throw new InvalidArgumentException('owner_participant');
}
$transcriptFileName = pathinfo($recording->getName(), PATHINFO_FILENAME) . '.txt';
try {
$fileNode = $recordingFolder->newFile($transcriptFileName, $transcript);
$this->notifyStoredTranscript($room, $participant, $fileNode);
} catch (NoUserException) {
throw new InvalidArgumentException('owner_invalid');
} catch (NotPermittedException) {
throw new InvalidArgumentException('owner_permission');
}
}
public function notifyAboutFailedTranscript(string $owner, File $recording): void {
$recordingFolder = $recording->getParent();
$roomToken = $recordingFolder->getName();
try {
$room = $this->roomManager->getRoomForUserByToken($roomToken, $owner);
$participant = $this->participantService->getParticipant($room, $owner);
} catch (ParticipantNotFoundException) {
$this->logger->warning('Could not determinate conversation when trying to notify about failed transcription of call recording');
throw new InvalidArgumentException('owner_participant');
}
$attendee = $participant->getAttendee();
$notification = $this->notificationManager->createNotification();
$notification
->setApp('spreed')
->setDateTime($this->timeFactory->getDateTime())
->setObject('recording', $room->getToken())
->setUser($attendee->getActorId())
->setSubject('transcript_failed', [
'objectId' => $recording->getId(),
]);
$this->notificationManager->notify($notification);
}
public function getContentFromFileArray(array $file, Room $room, Participant $participant): string {
@ -218,14 +282,35 @@ class RecordingService {
$this->notificationManager->notify($notification);
}
public function notifyStoredTranscript(Room $room, Participant $participant, File $file): void {
$attendee = $participant->getAttendee();
$notification = $this->notificationManager->createNotification();
$notification
->setApp('spreed')
->setDateTime($this->timeFactory->getDateTime())
->setObject('recording', $room->getToken())
->setUser($attendee->getActorId())
->setSubject('transcript_file_stored', [
'objectId' => $file->getId(),
]);
$this->notificationManager->notify($notification);
}
public function notificationDismiss(Room $room, Participant $participant, int $timestamp): void {
$notification = $this->notificationManager->createNotification();
$notification->setApp('spreed')
->setObject('recording', $room->getToken())
->setSubject('record_file_stored')
->setDateTime($this->timeFactory->getDateTime('@' . $timestamp))
->setUser($participant->getAttendee()->getActorId());
$this->notificationManager->markProcessed($notification);
foreach (['record_file_stored', 'transcript_file_stored'] as $subject) {
$notification->setSubject($subject);
$this->notificationManager->markProcessed($notification);
}
}
private function getTypeOfShare(string $mimetype): string {

67
tests/integration/features/bootstrap/FeatureContext.php

@ -62,6 +62,8 @@ class FeatureContext implements Context, SnippetAcceptingContext {
protected static $inviteIdToRemote;
/** @var int[] */
protected static $questionToPollId;
/** @var array[] */
protected static $lastNotifications;
protected static $permissionsMap = [
@ -94,6 +96,9 @@ class FeatureContext implements Context, SnippetAcceptingContext {
/** @var string */
protected $lastEtag;
/** @var string */
protected $lastToken;
/** @var array */
protected $createdUsers = [];
@ -122,6 +127,10 @@ class FeatureContext implements Context, SnippetAcceptingContext {
return self::$identifierToToken[$identifier];
}
public function getLastConversationToken(): ?string {
return $this->lastToken;
}
public function getAttendeeId(string $type, string $id, string $room, string $user = null) {
if (!isset(self::$userToAttendeeId[$room][$type][$id])) {
if ($user !== null) {
@ -160,6 +169,7 @@ class FeatureContext implements Context, SnippetAcceptingContext {
self::$textToMessageId = [];
self::$messageIdToText = [];
self::$questionToPollId = [];
self::$lastNotifications = [];
$this->createdUsers = [];
$this->createdGroups = [];
@ -282,10 +292,12 @@ class FeatureContext implements Context, SnippetAcceptingContext {
});
if ($formData === null) {
$this->lastToken = null;
Assert::assertEmpty($rooms);
return;
}
$this->lastToken = end($rooms)['token'];
$this->assertRooms($rooms, $formData, $shouldOrder !== '');
}
@ -2752,11 +2764,13 @@ class FeatureContext implements Context, SnippetAcceptingContext {
$data = $this->getDataFromResponse($this->response);
if ($body === null) {
self::$lastNotifications = [];
Assert::assertCount(0, $data);
return;
}
$this->assertNotifications($data, $body);
self::$lastNotifications = $data;
}
private function assertNotifications($notifications, TableNode $formData) {
@ -3308,6 +3322,59 @@ class FeatureContext implements Context, SnippetAcceptingContext {
$this->assertStatusCode($this->response, $statusCode);
}
/**
* @Then /^user "([^"]*)" shares file from the (first|last) notification to room "([^"]*)" with (\d+)(?: \((v1)\))?$/
*
* @param string $user
* @param string $firstLast
* @param string $identifier
* @param int $status
* @param string $apiVersion
*/
public function userShareLastNotificationFile(string $user, string $firstLast, string $identifier, int $status, string $apiVersion): void {
$this->setCurrentUser($user);
if (empty(self::$lastNotifications)) {
throw new \RuntimeException('No notification data loaded, call userNotifications() before');
}
if ($firstLast === 'last') {
$lastNotification = end(self::$lastNotifications);
} else {
$lastNotification = reset(self::$lastNotifications);
}
$data = [
'fileId' => $lastNotification['messageRichParameters']['file']['id'],
'timestamp' => (new \DateTime($lastNotification['datetime']))->getTimestamp(),
];
$this->sendRequest(
'POST',
'/apps/spreed/api/' . $apiVersion . '/recording/' . self::$identifierToToken[$identifier] . '/share-chat',
$data
);
$this->assertStatusCode($this->response, $status);
}
/**
* @When /^run transcript background jobs$/
*/
public function runTranscriptBackgroundJobs(): void {
$this->runOcc(['background-job:list', '--output=json_pretty', '--class=OC\SpeechToText\TranscriptionJob']);
$list = json_decode($this->lastStdOut, true, 512, JSON_THROW_ON_ERROR);
Assert::assertNotEmpty($list, 'List of OC\SpeechToText\TranscriptionJob should not be empty');
foreach ($list as $job) {
$this->runOcc(['background-job:execute', (string) $job['id']]);
if ($this->lastStdErr) {
throw new \RuntimeException($this->lastStdErr);
}
}
}
/**
* @When /^user "([^"]*)" set status to "([^"]*)" with (\d+)(?: \((v1)\))?$/
*/

13
tests/integration/features/bootstrap/SharingContext.php

@ -55,6 +55,15 @@ class SharingContext implements Context {
}
}
private \FeatureContext $featureContext;
/** @BeforeScenario */
public function gatherContexts(\Behat\Behat\Hook\Scope\BeforeScenarioScope $scope) {
$environment = $scope->getEnvironment();
$this->featureContext = $environment->getContext('FeatureContext');
}
/**
* @Given user :user creates folder :destination
*
@ -177,6 +186,10 @@ class SharingContext implements Context {
* @param TableNode|null $body
*/
public function userSharesWithRoom(string $user, string $path, string $room, TableNode $body = null) {
if (str_contains($path, '{{TOKEN}}')) {
$path = str_replace('{{TOKEN}}', $this->featureContext->getLastConversationToken(), $path);
}
$this->userSharesWith($user, $path, 10 /*IShare::TYPE_ROOM*/, FeatureContext::getTokenForIdentifier($room), $body);
}

33
tests/integration/features/callapi/recording.feature

@ -432,7 +432,7 @@ Feature: callapi/recording
| type | name | callRecording |
| 2 | room1 | 0 |
Scenario: Store recording with success
Scenario: Store recording with success and create transcript
Given user "participant1" creates room "room1" (v4)
| roomType | 2 |
| roomName | room1 |
@ -444,6 +444,37 @@ Feature: callapi/recording
And user "participant1" is participant of the following unordered rooms (v4)
| type | name | callRecording |
| 2 | room1 | 0 |
When run transcript background jobs
Then user "participant1" has the following notifications
| app | object_type | object_id | subject | message |
| spreed | recording | room1 | Transcript now available | The transcript for the call in room1 was uploaded to /Talk/Recording/{{TOKEN}}/join_call.txt. |
| spreed | recording | room1 | Call recording now available | The recording for the call in room1 was uploaded to /Talk/Recording/{{TOKEN}}/join_call.ogg. |
When user "participant1" shares file from the last notification to room "room1" with 200 (v1)
Then user "participant1" has the following notifications
| app | object_type | object_id | subject | message |
| spreed | recording | room1 | Transcript now available | The transcript for the call in room1 was uploaded to /Talk/Recording/{{TOKEN}}/join_call.txt. |
When user "participant1" shares file from the first notification to room "room1" with 200 (v1)
Then user "participant1" has the following notifications
| app | object_type | object_id | subject | message |
Scenario: Store recording with success but fail to transcript
Given user "participant1" creates room "room1" (v4)
| roomType | 2 |
| roomName | room1 |
And user "participant1" joins room "room1" with 200 (v4)
# "leave" is used here as the file name makes the fake transcript provider fail
When user "participant1" store recording file "/img/leave_call.ogg" in room "room1" with 200 (v1)
Then user "participant1" has the following notifications
| app | object_type | object_id | subject | message |
| spreed | recording | room1 | Call recording now available | The recording for the call in room1 was uploaded to /Talk/Recording/{{TOKEN}}/leave_call.ogg. |
And user "participant1" is participant of the following unordered rooms (v4)
| type | name | callRecording |
| 2 | room1 | 0 |
When run transcript background jobs
Then user "participant1" has the following notifications
| app | object_type | object_id | subject | message |
| spreed | recording | room1 | Failed to transcript call recording | The server failed to transcript the recording at /Talk/Recording/{{TOKEN}}/leave_call.ogg for the call in room1. Please reach out to the administration. |
| spreed | recording | room1 | Call recording now available | The recording for the call in room1 was uploaded to /Talk/Recording/{{TOKEN}}/leave_call.ogg. |
Scenario: Store recording with failure
Given user "participant1" creates room "room1" (v4)

4
tests/integration/spreedcheats/lib/AppInfo/Application.php

@ -23,6 +23,7 @@ declare(strict_types=1);
namespace OCA\SpreedCheats\AppInfo;
use OCA\SpreedCheats\SpeechToText\LoremIpsumProvider;
use OCP\AppFramework\App;
use OCP\AppFramework\Bootstrap\IBootContext;
use OCP\AppFramework\Bootstrap\IBootstrap;
@ -30,12 +31,13 @@ use OCP\AppFramework\Bootstrap\IRegistrationContext;
class Application extends App implements IBootstrap {
public const APP_ID = 'spreedcheats';
public function __construct() {
parent::__construct(self::APP_ID);
}
public function register(IRegistrationContext $context): void {
$context->registerSpeechToTextProvider(LoremIpsumProvider::class);
}
public function boot(IBootContext $context): void {

45
tests/integration/spreedcheats/lib/SpeechToText/LoremIpsumProvider.php

@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
/**
* @copyright Copyright (c) 2023 Joas Schilling <coding@schilljs.com>
*
* @author Joas Schilling <coding@schilljs.com>
*
* @license GNU AGPL version 3 or any later version
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
namespace OCA\SpreedCheats\SpeechToText;
use OCP\Files\File;
use OCP\SpeechToText\ISpeechToTextProvider;
class LoremIpsumProvider implements ISpeechToTextProvider {
public function getName(): string {
return 'Lorem ipsum - Talk Integrationtests';
}
public function transcribeFile(File $file): string {
if (str_contains($file->getName(), 'leave')) {
throw new \RuntimeException('Transcription failed by name');
}
return 'Lorem ipsum';
}
}

4
tests/php/Service/RecordingServiceTest.php

@ -50,6 +50,7 @@ use OCP\Files\IMimeTypeDetector;
use OCP\Files\IRootFolder;
use OCP\Notification\IManager;
use OCP\Share\IManager as ShareManager;
use OCP\SpeechToText\ISpeechToTextManager;
use PHPUnit\Framework\MockObject\MockObject;
use Psr\Log\LoggerInterface;
use Test\TestCase;
@ -79,6 +80,7 @@ class RecordingServiceTest extends TestCase {
private $logger;
/** @var BackendNotifier|MockObject */
private $backendNotifier;
private ISpeechToTextManager|MockObject $speechToTextManager;
/** @var RecordingService */
protected $recordingService;
@ -97,6 +99,7 @@ class RecordingServiceTest extends TestCase {
$this->chatManager = $this->createMock(ChatManager::class);
$this->logger = $this->createMock(LoggerInterface::class);
$this->backendNotifier = $this->createMock(BackendNotifier::class);
$this->speechToTextManager = $this->createMock(ISpeechToTextManager::class);
$this->recordingService = new RecordingService(
$this->mimeTypeDetector,
@ -111,6 +114,7 @@ class RecordingServiceTest extends TestCase {
$this->chatManager,
$this->logger,
$this->backendNotifier,
$this->speechToTextManager,
);
}

5
tests/psalm-baseline.xml

@ -209,7 +209,7 @@
</file>
<file src="lib/Migration/Version2001Date20170707115443.php">
<InvalidArrayAccess>
<code>$return['num_rooms']</code>
<code><![CDATA[$return['num_rooms']]]></code>
</InvalidArrayAccess>
</file>
<file src="lib/Notification/Notifier.php">
@ -238,11 +238,12 @@
<file src="lib/Service/RecordingService.php">
<UndefinedClass>
<code>NoUserException</code>
<code>NoUserException</code>
</UndefinedClass>
</file>
<file src="lib/Share/Listener.php">
<InvalidArgument>
<code>[self::class, 'listenPreShare']</code>
<code><![CDATA[[self::class, 'listenPreShare']]]></code>
</InvalidArgument>
<UndefinedClass>
<code><![CDATA[$event->getView()]]></code>

Loading…
Cancel
Save