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

pull/4009/head
Julio 4 years ago
commit 87bec58595
  1. 97
      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. 88
      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,26 +1,63 @@
<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"> />
<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> </audio>
<q-btn
:label="$t('Attach')"
class="my-1"
color="green"
icon="attachment"
size="sm"
@click="attachAudio(audio)"
/>
</div> </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
},
},
setup(props, {emit}) {
const recorderState = reactive({
isRecording: false,
audioList: [],
});
let mediaRecorder = null; let mediaRecorder = null;
let mediaChunks = []; let mediaChunks = [];
@ -37,16 +74,17 @@ export default {
mediaChunks.push(e.data) mediaChunks.push(e.data)
}; };
mediaRecorder.onstop = e => { mediaRecorder.onstop = e => {
const blob = new Blob(mediaChunks, {type: 'audio/ogg; codecs=opus'}); stream.getAudioTracks()[0].stop();
const audioUrl = URL.createObjectURL(blob);
const audioItem = new Blob(mediaChunks, {type: 'audio/ogg; codecs=opus'});
mediaChunks = []; mediaChunks = [];
audioList.value.push(audioUrl); recorderState.audioList.push(audioItem);
}; };
mediaRecorder.start(); mediaRecorder.start();
isRecording.value = true; recorderState.isRecording = true;
}) })
.catch(console.log); .catch(console.log);
} }
@ -56,18 +94,33 @@ export default {
return; return;
} }
if (false === props.multiple && recorderState.audioList.length > 0) {
recorderState.audioList.shift();
}
mediaRecorder.stop(); mediaRecorder.stop();
mediaRecorder.stream.getAudioTracks()[0].stop(); recorderState.isRecording = false;
isRecording.value = false; }
function attachAudio(audio) {
emit('attach-audio', audio);
const index = recorderState.audioList.indexOf(audio);
if (index >= 0) {
recorderState.audioList.splice(index, 1);
}
} }
return { return {
record, record,
stop, stop,
audioList, ...toRefs(recorderState),
isRecording 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>
<hr class="my-2">
</div>
<AudioRecorder></AudioRecorder> <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');

@ -5,64 +5,62 @@
/> />
<MessageForm <MessageForm
ref="createForm" ref="createForm"
:values="item"
:errors="violations" :errors="violations"
:values="item"
> >
<!-- @input="v$.item.receiversTo.$touch()"--> <!-- @input="v$.item.receiversTo.$touch()"-->
<VueMultiselect <VueMultiselect
id="to" id="to"
placeholder="To"
v-model="item.receiversTo" v-model="item.receiversTo"
:allow-empty="false"
:internal-search="false"
:loading="isLoadingSelect" :loading="isLoadingSelect"
:options="usersTo"
:multiple="true" :multiple="true"
:options="usersTo"
:searchable="true" :searchable="true"
:internal-search="false"
@search-change="asyncFindTo"
limit-text="3"
limit="3"
label="username" label="username"
limit="3"
limit-text="3"
placeholder="To"
track-by="id" track-by="id"
:allow-empty="false" @search-change="asyncFindTo"
/> />
<VueMultiselect <VueMultiselect
id="cc" id="cc"
placeholder="Cc"
v-model="item.receiversCc" v-model="item.receiversCc"
:allow-empty="true"
:internal-search="false"
:loading="isLoadingSelect" :loading="isLoadingSelect"
:options="usersCc"
:multiple="true" :multiple="true"
:options="usersCc"
:searchable="true" :searchable="true"
:internal-search="false"
@search-change="asyncFindCc"
limit-text="3"
limit="3"
label="username" label="username"
limit="3"
limit-text="3"
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',
@ -78,18 +76,20 @@
'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.
$message = (new Message())
->setTitle('hello')
->setContent('content')
->setMsgType(Message::MESSAGE_TYPE_INBOX)
->setSender($admin)
->addReceiver($testUser)
;
$this->assertHasNoEntityViolations($message); $responseMessage = $client->request(
$messageRepo->update($message); 'POST',
'/api/messages',
[
'json' => [
'msgType' => Message::MESSAGE_TYPE_INBOX,
'title' => 'Message title',
'content' => 'Message content',
'receivers' => [
[
'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