Merge branch 'master' of github.com:chamilo/chamilo-lms

pull/4009/head
Julio 4 years ago
commit 87bec58595
  1. 177
      assets/vue/components/AudioRecorder.vue
  2. 51
      assets/vue/components/message/Form.vue
  3. 8
      assets/vue/main.js
  4. 3
      assets/vue/services/messageattachment.js
  5. 180
      assets/vue/views/message/Create.vue
  6. 3
      public/main/tracking/courseLog.php
  7. 51
      src/CoreBundle/Controller/Api/CreateMessageAttachmentAction.php
  8. 2
      src/CoreBundle/Entity/Message.php
  9. 71
      src/CoreBundle/Entity/MessageAttachment.php
  10. 101
      tests/CoreBundle/Repository/MessageRepositoryTest.php

@ -1,73 +1,126 @@
<template> <template>
<div class="py-2"> <div class="py-4">
<q-btn v-if="!isRecording" color="primary" icon="mic" :label="$t('Start recording')" @click="record()"/> <q-btn
v-if="!isRecording"
<q-btn v-if="isRecording" color="red" icon="stop" :label="$t('Stop recording')" @click="stop()"/> :label="$t('Start recording')"
color="primary"
<div v-for="(audio, index) in audioList" :key="index" class="py-2"> icon="mic"
<audio controls style="max-width: 100%;"> @click="record()"
<source :src="audio"> />
</audio>
</div> <q-btn
v-if="isRecording"
:label="$t('Stop recording')"
color="red"
icon="stop"
@click="stop()"
/>
<div
v-for="(audio, index) in audioList"
:key="index"
class="py-2"
>
<audio
class="max-w-full"
controls
>
<source
:src="URL.createObjectURL(audio)"
>
</audio>
<q-btn
:label="$t('Attach')"
class="my-1"
color="green"
icon="attachment"
size="sm"
@click="attachAudio(audio)"
/>
</div> </div>
</div>
</template> </template>
<script> <script>
import {ref} from "vue"; import {reactive, toRefs} from "vue";
export default { export default {
name: "AudioRecorder", name: "AudioRecorder",
props: {
setup() { multiple: {
const isRecording = ref(false) type: Boolean,
const audioList = ref([]); required: false,
default: true
let mediaRecorder = null; },
let mediaChunks = []; },
setup(props, {emit}) {
function record() { const recorderState = reactive({
if (!navigator.mediaDevices.getUserMedia) { isRecording: false,
return; audioList: [],
} });
navigator.mediaDevices.getUserMedia({audio: true}) let mediaRecorder = null;
.then((stream) => { let mediaChunks = [];
mediaRecorder = new MediaRecorder(stream);
mediaRecorder.ondataavailable = e => { function record() {
mediaChunks.push(e.data) if (!navigator.mediaDevices.getUserMedia) {
}; return;
mediaRecorder.onstop = e => { }
const blob = new Blob(mediaChunks, {type: 'audio/ogg; codecs=opus'});
const audioUrl = URL.createObjectURL(blob); navigator.mediaDevices.getUserMedia({audio: true})
.then((stream) => {
mediaChunks = []; mediaRecorder = new MediaRecorder(stream);
mediaRecorder.ondataavailable = e => {
audioList.value.push(audioUrl); mediaChunks.push(e.data)
}; };
mediaRecorder.start(); mediaRecorder.onstop = e => {
stream.getAudioTracks()[0].stop();
isRecording.value = true;
}) const audioItem = new Blob(mediaChunks, {type: 'audio/ogg; codecs=opus'});
.catch(console.log);
} mediaChunks = [];
function stop() { recorderState.audioList.push(audioItem);
if (!mediaRecorder) { };
return; mediaRecorder.start();
}
recorderState.isRecording = true;
mediaRecorder.stop(); })
mediaRecorder.stream.getAudioTracks()[0].stop(); .catch(console.log);
isRecording.value = false; }
}
function stop() {
return { if (!mediaRecorder) {
record, return;
stop, }
audioList,
isRecording if (false === props.multiple && recorderState.audioList.length > 0) {
}; recorderState.audioList.shift();
}
mediaRecorder.stop();
recorderState.isRecording = false;
} }
function attachAudio(audio) {
emit('attach-audio', audio);
const index = recorderState.audioList.indexOf(audio);
if (index >= 0) {
recorderState.audioList.splice(index, 1);
}
}
return {
record,
stop,
...toRefs(recorderState),
attachAudio,
URL
};
},
emits: ['attachAudio'],
} }
</script> </script>

@ -15,9 +15,37 @@
<slot></slot> <slot></slot>
</v-col> </v-col>
<v-col md="3"> <v-col md="3">
<div v-text="$t('Atachments')" class="text-h6"/> <div
v-if="item.attachments && item.attachments.length > 0"
>
<div
class="text-h6"
v-text="$t('Atachments')"
/>
<ul>
<li
v-for="(attachment, index) in item.attachments"
:key="index"
class="my-2"
>
<audio
v-if="attachment.type.indexOf('audio') === 0"
class="max-w-full"
controls
>
<source
:src="URL.createObjectURL(attachment)"
>
</audio>
</li>
</ul>
<AudioRecorder></AudioRecorder> <hr class="my-2">
</div>
<AudioRecorder
@attach-audio="attachAudios"
/>
</v-col> </v-col>
</v-row> </v-row>
</v-container> </v-container>
@ -27,14 +55,14 @@
<script> <script>
import has from 'lodash/has'; import has from 'lodash/has';
import useVuelidate from '@vuelidate/core'; import useVuelidate from '@vuelidate/core';
import { required } from '@vuelidate/validators'; import {required} from '@vuelidate/validators';
import AudioRecorder from "../AudioRecorder"; import AudioRecorder from "../AudioRecorder";
export default { export default {
name: 'MessageForm', name: 'MessageForm',
components: {AudioRecorder}, components: {AudioRecorder},
setup() { setup() {
return { v$: useVuelidate() } return {v$: useVuelidate(), URL}
}, },
props: { props: {
values: { values: {
@ -43,11 +71,13 @@ export default {
}, },
errors: { errors: {
type: Object, type: Object,
default: () => {} default: () => {
}
}, },
initialValues: { initialValues: {
type: Object, type: Object,
default: () => {} default: () => {
}
}, },
}, },
data() { data() {
@ -89,6 +119,15 @@ export default {
return this.errors || {}; return this.errors || {};
} }
}, },
methods: {
attachAudios(audio) {
if (!this.item.attachments) {
this.item.attachments = [];
}
this.item.attachments.push(audio);
}
},
validations: { validations: {
item: { item: {
title: { title: {

@ -14,6 +14,7 @@ import personalFileService from './services/personalfile';
import resourceLinkService from './services/resourcelink'; import resourceLinkService from './services/resourcelink';
import resourceNodeService from './services/resourcenode'; import resourceNodeService from './services/resourcenode';
import messageService from './services/message'; import messageService from './services/message';
import messageAttachmentService from './services/messageattachment';
import messageRelUserService from './services/messagereluser'; import messageRelUserService from './services/messagereluser';
import userService from './services/user'; import userService from './services/user';
import userGroupService from './services/usergroup'; import userGroupService from './services/usergroup';
@ -95,6 +96,13 @@ store.registerModule(
}) })
); );
store.registerModule(
'messageattachment',
makeCrudModule({
service: messageAttachmentService
})
);
store.registerModule( store.registerModule(
'messagereluser', 'messagereluser',
makeCrudModule({ makeCrudModule({

@ -0,0 +1,3 @@
import makeService from './api';
export default makeService('message_attachments');

@ -1,95 +1,95 @@
<template> <template>
<!-- :handle-submit="onSendMessageForm"--> <!-- :handle-submit="onSendMessageForm"-->
<Toolbar <Toolbar
:handle-send="onSendMessageForm" :handle-send="onSendMessageForm"
/> />
<MessageForm <MessageForm
ref="createForm" ref="createForm"
:values="item" :errors="violations"
:errors="violations" :values="item"
> >
<!-- @input="v$.item.receiversTo.$touch()"-->
<VueMultiselect <!-- @input="v$.item.receiversTo.$touch()"-->
id="to"
placeholder="To"
v-model="item.receiversTo"
:loading="isLoadingSelect"
:options="usersTo"
:multiple="true"
:searchable="true"
:internal-search="false"
@search-change="asyncFindTo"
limit-text="3"
limit="3"
label="username"
track-by="id"
:allow-empty="false"
/> <VueMultiselect
id="to"
v-model="item.receiversTo"
:allow-empty="false"
:internal-search="false"
:loading="isLoadingSelect"
:multiple="true"
:options="usersTo"
:searchable="true"
label="username"
limit="3"
limit-text="3"
placeholder="To"
track-by="id"
@search-change="asyncFindTo"
/>
<VueMultiselect <VueMultiselect
id="cc" id="cc"
placeholder="Cc" v-model="item.receiversCc"
v-model="item.receiversCc" :allow-empty="true"
:loading="isLoadingSelect" :internal-search="false"
:options="usersCc" :loading="isLoadingSelect"
:multiple="true" :multiple="true"
:searchable="true" :options="usersCc"
:internal-search="false" :searchable="true"
@search-change="asyncFindCc" label="username"
limit-text="3" limit="3"
limit="3" limit-text="3"
label="username" placeholder="Cc"
track-by="id" track-by="id"
:allow-empty="true" @search-change="asyncFindCc"
/> />
<!-- @filter-abort="abortFilterFn"--> <!-- @filter-abort="abortFilterFn"-->
<!-- <q-select--> <!-- <q-select-->
<!-- filled--> <!-- filled-->
<!-- v-model="item.receivers"--> <!-- v-model="item.receivers"-->
<!-- use-input--> <!-- use-input-->
<!-- use-chips--> <!-- use-chips-->
<!-- :options="users"--> <!-- :options="users"-->
<!-- input-debounce="0"--> <!-- input-debounce="0"-->
<!-- label="Lazy filter"--> <!-- label="Lazy filter"-->
<!-- @filter="asyncFind"--> <!-- @filter="asyncFind"-->
<!-- style="width: 250px"--> <!-- style="width: 250px"-->
<!-- hint="With use-chips"--> <!-- hint="With use-chips"-->
<!-- :error-message="receiversErrors"--> <!-- :error-message="receiversErrors"-->
<!-- />--> <!-- />-->
<TinyEditor <TinyEditor
v-model="item.content" v-model="item.content"
required :init="{
:init="{ skin_url: '/build/libs/tinymce/skins/ui/oxide',
skin_url: '/build/libs/tinymce/skins/ui/oxide', content_css: '/build/libs/tinymce/skins/content/default/content.css',
content_css: '/build/libs/tinymce/skins/content/default/content.css', branding: false,
branding: false, relative_urls: false,
relative_urls: false, height: 500,
height: 500, toolbar_mode: 'sliding',
toolbar_mode: 'sliding', file_picker_callback : browser,
file_picker_callback : browser, autosave_ask_before_unload: true,
autosave_ask_before_unload: true, plugins: [
plugins: [ 'advlist autolink lists link image charmap print preview anchor',
'advlist autolink lists link image charmap print preview anchor', 'searchreplace visualblocks code fullscreen',
'searchreplace visualblocks code fullscreen', 'insertdatetime media table paste wordcount emoticons'
'insertdatetime media table paste wordcount emoticons' ],
], toolbar: 'undo redo | bold italic underline strikethrough | insertfile image media template link | fontselect fontsizeselect formatselect | alignleft aligncenter alignright alignjustify | outdent indent | numlist bullist | forecolor backcolor removeformat | pagebreak | charmap emoticons | fullscreen preview save print | code codesample | ltr rtl',
toolbar: 'undo redo | bold italic underline strikethrough | insertfile image media template link | fontselect fontsizeselect formatselect | alignleft aligncenter alignright alignjustify | outdent indent | numlist bullist | forecolor backcolor removeformat | pagebreak | charmap emoticons | fullscreen preview save print | code codesample | ltr rtl', }"
} required
"
/> />
</MessageForm> </MessageForm>
<Loading :visible="isLoading" /> <Loading
:visible="isLoading"
/>
</template> </template>
<style src="vue-multiselect/dist/vue-multiselect.css"></style> <style src="vue-multiselect/dist/vue-multiselect.css"></style>
<script> <script>
import {mapActions, mapGetters, useStore} from 'vuex'; import {mapActions, mapGetters} from 'vuex';
import { createHelpers } from 'vuex-map-fields'; import {createHelpers} from 'vuex-map-fields';
import MessageForm from '../../components/message/Form.vue'; import MessageForm from '../../components/message/Form.vue';
import Loading from '../../components/Loading.vue'; import Loading from '../../components/Loading.vue';
import Toolbar from '../../components/Toolbar.vue'; import Toolbar from '../../components/Toolbar.vue';
@ -99,9 +99,10 @@ import axios from "axios";
import {ENTRYPOINT} from "../../config/entrypoint"; import {ENTRYPOINT} from "../../config/entrypoint";
import useVuelidate from "@vuelidate/core"; import useVuelidate from "@vuelidate/core";
import VueMultiselect from 'vue-multiselect' import VueMultiselect from 'vue-multiselect'
const servicePrefix = 'Message'; const servicePrefix = 'Message';
const { mapFields } = createHelpers({ const {mapFields} = createHelpers({
getterType: 'message/getField', getterType: 'message/getField',
mutationType: 'message/updateField' mutationType: 'message/updateField'
}); });
@ -116,12 +117,12 @@ export default {
MessageForm, MessageForm,
VueMultiselect VueMultiselect
}, },
setup () { setup() {
const usersTo = ref([]); const usersTo = ref([]);
const usersCc = ref([]); const usersCc = ref([]);
const isLoadingSelect = ref(false); const isLoadingSelect = ref(false);
function asyncFind (query) { function asyncFind(query) {
if (query.toString().length < 3) { if (query.toString().length < 3) {
throw new Error('error'); throw new Error('error');
} }
@ -175,6 +176,17 @@ export default {
}), }),
}, },
methods: { methods: {
onCreated(message) {
if (this.item.attachments) {
this.item.attachments.forEach(attachment => {
this.createWithFormData({
messageId: message.id,
file: attachment
});
})
}
},
...mapActions('messageattachment', ['createWithFormData']),
...mapActions('message', ['create', 'reset']) ...mapActions('message', ['create', 'reset'])
} }
}; };

@ -319,7 +319,8 @@ if ($showReporting) {
continue; continue;
} }
} }
$url = $urlWebCode.'mySpace/course.php?sid='.$session['id'].'&cid='.$courseId; //$url = $urlWebCode.'mySpace/course.php?sid='.$session['id'].'&cid='.$courseId;
$url = $urlWebCode.'tracking/courseLog.php?cid='.$courseId.'&sid='.$session['id'].'&gid=0';
$table->setCellContents($row++, 0, $icon.' '.Display::url($session['name'], $url)); $table->setCellContents($row++, 0, $icon.' '.Display::url($session['name'], $url));
} }
if ($row > 0) { if ($row > 0) {

@ -0,0 +1,51 @@
<?php
/* For licensing terms, see /license.txt */
declare(strict_types=1);
namespace Chamilo\CoreBundle\Controller\Api;
use Chamilo\CoreBundle\Entity\Message;
use Chamilo\CoreBundle\Entity\MessageAttachment;
use Chamilo\CoreBundle\Repository\Node\MessageAttachmentRepository;
use Doctrine\ORM\EntityManager;
use Exception;
use Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
class CreateMessageAttachmentAction extends BaseResourceFileAction
{
/**
* @throws Exception
*/
public function __invoke(Request $request, MessageAttachmentRepository $repo, EntityManager $em): MessageAttachment
{
/** @var UploadedFile $uploadedFile */
$uploadedFile = $request->files->get('file');
if (empty($uploadedFile)) {
throw new BadRequestHttpException('file is required');
}
$messageRepo = $em->getRepository(Message::class);
$message = $messageRepo->find($request->get('messageId'));
$attachment = (new MessageAttachment())
->setFilename($uploadedFile->getFilename())
->setMessage($message)
->setParent($message->getSender())
->setCreator($message->getSender())
;
$message->addAttachment($attachment);
$em->persist($attachment);
$repo->addFile($attachment, $uploadedFile);
$em->flush();
return $attachment;
}
}

@ -234,7 +234,7 @@ class Message
/** /**
* @var Collection|MessageAttachment[] * @var Collection|MessageAttachment[]
* *
* @ORM\OneToMany(targetEntity="MessageAttachment", mappedBy="message", cascade={"remove"}) * @ORM\OneToMany(targetEntity="MessageAttachment", mappedBy="message", cascade={"remove", "persist"})
*/ */
protected Collection $attachments; protected Collection $attachments;

@ -6,6 +6,8 @@ declare(strict_types=1);
namespace Chamilo\CoreBundle\Entity; namespace Chamilo\CoreBundle\Entity;
use ApiPlatform\Core\Annotation\ApiResource;
use Chamilo\CoreBundle\Controller\Api\CreateMessageAttachmentAction;
use Doctrine\ORM\Mapping as ORM; use Doctrine\ORM\Mapping as ORM;
/** /**
@ -14,6 +16,39 @@ use Doctrine\ORM\Mapping as ORM;
* @ORM\Table(name="message_attachment") * @ORM\Table(name="message_attachment")
* @ORM\Entity(repositoryClass="Chamilo\CoreBundle\Repository\Node\MessageAttachmentRepository") * @ORM\Entity(repositoryClass="Chamilo\CoreBundle\Repository\Node\MessageAttachmentRepository")
*/ */
#[ApiResource(
collectionOperations: [
'get',
'post' => [
'controller' => CreateMessageAttachmentAction::class,
'deserialize' => false,
'validation_groups' => ['Default', 'message_attachment:create'],
'openapi_context' => [
'requestBody' => [
'content' => [
'multipart/form-data' => [
'schema' => [
'type' => 'object',
'properties' => [
'file' => [
'type' => 'string',
'format' => 'binary',
],
'messageId' => [
'type' => 'integer',
],
],
],
],
],
],
],
],
],
iri: 'http://schema.org/MediaObject',
itemOperations: ['get'],
normalizationContext: ['groups' => 'message:read'],
)]
class MessageAttachment extends AbstractResource implements ResourceInterface class MessageAttachment extends AbstractResource implements ResourceInterface
{ {
/** /**
@ -39,7 +74,7 @@ class MessageAttachment extends AbstractResource implements ResourceInterface
protected int $size; protected int $size;
/** /**
* @ORM\ManyToOne(targetEntity="Chamilo\CoreBundle\Entity\Message", inversedBy="attachments") * @ORM\ManyToOne(targetEntity="Chamilo\CoreBundle\Entity\Message", inversedBy="attachments", cascade={"persist"})
* @ORM\JoinColumn(name="message_id", referencedColumnName="id", nullable=false) * @ORM\JoinColumn(name="message_id", referencedColumnName="id", nullable=false)
*/ */
protected Message $message; protected Message $message;
@ -61,9 +96,14 @@ class MessageAttachment extends AbstractResource implements ResourceInterface
return $this->getFilename(); return $this->getFilename();
} }
public function setPath(string $path): self public function getFilename(): string
{ {
$this->path = $path; return $this->filename;
}
public function setFilename(string $filename): self
{
$this->filename = $filename;
return $this; return $this;
} }
@ -78,9 +118,9 @@ class MessageAttachment extends AbstractResource implements ResourceInterface
return $this->path; return $this->path;
} }
public function setComment(string $comment): self public function setPath(string $path): self
{ {
$this->comment = $comment; $this->path = $path;
return $this; return $this;
} }
@ -95,9 +135,9 @@ class MessageAttachment extends AbstractResource implements ResourceInterface
return $this->comment; return $this->comment;
} }
public function setSize(int $size): self public function setComment(string $comment): self
{ {
$this->size = $size; $this->comment = $comment;
return $this; return $this;
} }
@ -107,9 +147,9 @@ class MessageAttachment extends AbstractResource implements ResourceInterface
return $this->size; return $this->size;
} }
public function setMessage(Message $message): self public function setSize(int $size): self
{ {
$this->message = $message; $this->size = $size;
return $this; return $this;
} }
@ -119,16 +159,16 @@ class MessageAttachment extends AbstractResource implements ResourceInterface
return $this->message; return $this->message;
} }
public function setFilename(string $filename): self public function setMessage(Message $message): self
{ {
$this->filename = $filename; $this->message = $message;
return $this; return $this;
} }
public function getFilename(): string public function getResourceIdentifier(): int
{ {
return $this->filename; return $this->getId();
} }
/** /**
@ -141,11 +181,6 @@ class MessageAttachment extends AbstractResource implements ResourceInterface
return $this->id; return $this->id;
} }
public function getResourceIdentifier(): int
{
return $this->getId();
}
public function getResourceName(): string public function getResourceName(): string
{ {
return $this->getFilename(); return $this->getFilename();

@ -168,43 +168,86 @@ class MessageRepositoryTest extends AbstractApiTest
{ {
self::bootKernel(); self::bootKernel();
$em = $this->getEntityManager(); $user1 = $this->getUser('admin');
$user2 = $this->createUser('user2');
$messageAttachmentRepo = self::getContainer()->get(MessageAttachmentRepository::class); $user1Token = $this->getUserTokenFromUser($user1);
$messageRepo = self::getContainer()->get(MessageRepository::class);
$admin = $this->getUser('admin'); $client = $this->createClientWithCredentials($user1Token);
$testUser = $this->createUser('test');
// Create message. $responseMessage = $client->request(
$message = (new Message()) 'POST',
->setTitle('hello') '/api/messages',
->setContent('content') [
->setMsgType(Message::MESSAGE_TYPE_INBOX) 'json' => [
->setSender($admin) 'msgType' => Message::MESSAGE_TYPE_INBOX,
->addReceiver($testUser) 'title' => 'Message title',
; 'content' => 'Message content',
'receivers' => [
$this->assertHasNoEntityViolations($message); [
$messageRepo->update($message); 'receiver' => "/api/users/{$user2->getId()}",
'receiverType' => MessageRelUser::TYPE_TO,
],
],
'sender' => "/api/users/{$user1->getId()}",
],
]
);
$file = $this->getUploadedFile(); $this->assertResponseIsSuccessful();
$this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8');
$this->assertJsonContains(
[
'@context' => '/api/contexts/Message',
'@type' => 'Message',
'sender' => [
'@id' => "/api/users/{$user1->getId()}",
],
'receiversTo' => [
[
'@type' => 'MessageRelUser',
'receiver' => [
'@id' => "/api/users/{$user2->getId()}",
],
'receiverType' => MessageRelUser::TYPE_TO,
],
],
'msgType' => Message::MESSAGE_TYPE_INBOX,
'title' => 'Message title',
'content' => 'Message content',
]
);
$attachment = (new MessageAttachment()) $messageId = $responseMessage->toArray()['id'];
->setFilename($file->getFilename())
->setMessage($message)
->setParent($message->getSender())
->setCreator($message->getSender())
;
$message->addAttachment($attachment);
$em->persist($attachment); $file = $this->getUploadedFile();
$messageAttachmentRepo->addFile($attachment, $file);
$em->flush();
$em->clear(); $responseAttachment = $client->request(
'POST',
'/api/message_attachments',
[
'headers' => [
'Content-Type' => 'multipart/form-data',
],
'extra' => [
'files' => [
'file' => $file,
],
'parameters' => [
'messageId' => $messageId,
],
],
]
);
$this->assertSame(1, $messageAttachmentRepo->count([])); $this->assertResponseIsSuccessful();
$this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8');
$this->assertJsonContains(
[
'@context' => '/api/contexts/MessageAttachment',
'@type' => 'http://schema.org/MediaObject',
]
);
} }
public function testDeleteMessage(): void public function testDeleteMessage(): void

Loading…
Cancel
Save