<?php
namespace CodersLab\Lms\UserInterface\Rest\Controller;
use CodersLab\Lms\Modules\Courses\Application\Query\NavigationQuery;
use CodersLab\Lms\Modules\IdentityAccess\Application\Command\UpdateLastSeenMaterial;
use CodersLab\Lms\Modules\IdentityAccess\Application\IContentAccessContext;
use CodersLab\Lms\Modules\IdentityAccess\Application\Query\Model\Progress\MaterialProgress;
use CodersLab\Lms\Modules\IdentityAccess\Application\Query\ProgressQuery;
use CodersLab\Lms\Modules\Learning\Application\Query\ResultQuery;
use CodersLab\Lms\Modules\Materials\Application\Command\ReportMaterialIssue;
use CodersLab\Lms\Modules\Materials\Application\Query\CourseQuery;
use CodersLab\Lms\Modules\Materials\Application\Query\MaterialQuery;
use CodersLab\Lms\Modules\Materials\Application\Query\MaterialsSearchQuery;
use CodersLab\Lms\Modules\Materials\Application\Query\Model\MaterialReview as ModelMaterialReview;
use CodersLab\Lms\Modules\Materials\Application\Service\Material\MaterialFactory;
use CodersLab\Lms\Modules\Materials\Application\Service\Material\MaterialInstanceSlug\IMaterialOnInstanceFactory;
use CodersLab\Lms\Modules\Materials\Application\Service\Material\MaterialInstanceSlug\Slug;
use CodersLab\Lms\Modules\Materials\Application\Service\SearchEngine\FilterDecorator;
use CodersLab\Lms\Modules\Materials\Domain\Pill\Access;
use CodersLab\Lms\SharedKernel\Application\IUserRepository;
use CodersLab\Lms\SharedKernel\Application\Response\LmsApiResponse;
use CodersLab\Lms\SharedKernel\Application\Response\MaterialRedirectMetadata;
use CodersLab\Lms\SharedKernel\Application\Response\Metadata;
use CodersLab\Lms\SharedKernel\Application\Response\NavigationMetadata;
use CodersLab\Lms\SharedKernel\Application\SecurityContext;
use CodersLab\Lms\SharedKernel\Common\DTO\MaterialIssue;
use CodersLab\Lms\SharedKernel\Common\Exception\AccessDeniedHttpException;
use CodersLab\Lms\SharedKernel\Common\Exception\NotFoundException;
use CodersLab\Lms\SharedKernel\Domain\Bus\CommandBus;
use CodersLab\Lms\SharedKernel\Domain\Courses\MemberType;
use CodersLab\Lms\SharedKernel\Domain\Identity\Uuid;
use Exception;
use Nelmio\ApiDocBundle\Annotation\Model;
use OpenApi\Annotations as OA;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Contracts\Cache\ItemInterface;
use Symfony\Contracts\Cache\TagAwareCacheInterface;
/**
* @OA\Tag(name="Material")
*/
final class MaterialController
{
public const REQUEST_CACHE_PDF_TAG = 'pdf.request_cache';
public function __construct(
private SecurityContext $security,
private IContentAccessContext $accessContext,
private MaterialQuery $materialQuery,
private MaterialFactory $materialFactory,
private CommandBus $commandBus,
private NavigationQuery $navigationQuery,
private IUserRepository $userRepository,
private ResultQuery $resultQuery,
private MaterialsSearchQuery $materialsSearchQuery,
private CourseQuery $courseQuery,
private ProgressQuery $progressQuery,
private IMaterialOnInstanceFactory $materialOnInstanceFactory,
private TagAwareCacheInterface $cache
) {
}
/**
* @Route(
* "/material/{instanceId}/{chapterId}/{materialId}",
* name="material_details_id",
* methods={"GET"},
* requirements={"chapterId"="[a-f0-9]{8}\-[a-f0-9]{4}\-[a-f0-9]{4}\-[a-f0-9]{4}\-[a-f0-9]{12}"}
* )
* @OA\Parameter(name="materialId", in="path", @OA\Schema(type="string"), required=true)
* @OA\Parameter(name="chapterId", in="path", @OA\Schema(type="string"), required=true)
* @OA\Response(
* response=200,
* description="Single material",
* @OA\Schema(ref=@Model(type="CodersLab\Lms\Modules\Materials\Application\Query\Model\Material"))
* )
*/
public function getMaterialByChapterId(string $materialId, string $instanceId): LmsApiResponse
{
$slug = $this->materialOnInstanceFactory->guessSlug(new Uuid($materialId), $instanceId);
return new LmsApiResponse(null, new MaterialRedirectMetadata($slug), Response::HTTP_SEE_OTHER);
}
/**
* @Route(
* "/material/{instanceId}/{slug}/{materialId}",
* name="material_details_slug",
* methods={"GET"},
* requirements={"slug"="[0-9]+\-[0-9]+"}
* )
* @OA\Parameter(name="materialId", in="path", @OA\Schema(type="string"), required=true)
* @OA\Parameter(name="chapterId", in="path", @OA\Schema(type="string"), required=true)
* @OA\Response(
* response=200,
* description="Single material",
* @OA\Schema(ref=@Model(type="CodersLab\Lms\Modules\Materials\Application\Query\Model\Material"))
* )
*/
public function getMaterialBySlug(string $slug, string $materialId, string $instanceId): LmsApiResponse
{
$user = $this->security->getLoggedUser();
$materialAccess = $this->accessContext->hasMaterialAccess($user->id(), $materialId, $user->getAccess());
$directAccess = $this->accessContext->hasDirectMaterialAccess($user->id(), $materialId);
if (($materialAccess || $directAccess) === false) {
throw new AccessDeniedHttpException();
}
$materialOnInstance = $this->materialOnInstanceFactory->getForMaterial(Slug::createFromString($slug), $materialId, $instanceId);
$material = $this->materialFactory->create($materialId, (string)$materialOnInstance->getChapterId(), (string)$instanceId);
$review = $this->materialQuery->findUserReview($materialId, $user->id());
if ($review instanceof ModelMaterialReview) {
$material->setUserReview($review);
}
$metadata = null;
if (!$material->getAccess()->isLecturer()) {
if ($material->getType()->isQuiz()) {
$result = $this->resultQuery->onMaterial(
(string)$user->id(),
$instanceId,
$materialId
);
$material->setResult($result);
}
$metadata = NavigationMetadata::create(
$this->navigationQuery->getNavigationForMaterial(
$material->getUuid(),
$materialOnInstance->getChapterId(),
$user->id()
)
);
}
$progress = $this->progressQuery->forMaterial($instanceId, $materialId, (string)$user->id());
if ($progress instanceof MaterialProgress) {
$material->setProgress($progress);
}
$this->commandBus->dispatch(new UpdateLastSeenMaterial(
new Uuid($materialId),
(string)$instanceId,
(string)$user->id()
));
return new LmsApiResponse($material, $metadata);
}
/**
* @Route("/direct-access/material/{chapterId}/{materialId}", name="direct_access_material_details",
* methods={"GET"})
* @OA\Parameter(name="id", in="path", @OA\Schema(type="string"), required=true)
* @OA\Parameter(name="courseId", in="path", @OA\Schema(type="string"), required=true)
* @OA\Response(
* response=200,
* description="Single material",
* @OA\Schema(ref=@Model(type="CodersLab\Lms\Modules\Materials\Application\Query\Model\Material"))
* )
*/
public function getMaterialDirectAccess(string $materialId, string $chapterId): LmsApiResponse
{
$user = $this->security->getLoggedUser();
$directAccess = $this->accessContext->hasDirectMaterialAccess($user->id(), $materialId);
if ($directAccess === false) {
throw new AccessDeniedHttpException();
}
$material = $this->materialFactory->createForDirectAccess($materialId, $chapterId);
if ($material->getAccess()->isLecturer()) {
$metadata = null;
} else {
$metadata = NavigationMetadata::create(
$this->navigationQuery->getNavigationForMaterial(
$material->getUuid(),
new Uuid($chapterId)
)
);
}
return new LmsApiResponse($material, $metadata);
}
/**
* @Route (
* "/direct-access/material/{chapterId}/{materialId}/read",
* name="direct_material_base64_read",
* methods={"GET"},
* requirements={"slug"="[0-9]+\-[0-9]+"}
* )
* @OA\Parameter(name="chapterId", in="query", @OA\Schema(type="string"))
* @OA\Parameter(name="materialId", in="query", @OA\Schema(type="string"))
* @OA\Response(response=200, description="Single material base64 content")
*
* @return LmsApiResponse
* @throws \CodersLab\Lms\SharedKernel\Common\Exception\NotFoundException
*/
public function readCourseMaterialDirectAccess(string $materialId, string $chapterId): LmsApiResponse
{
$user = $this->security->getLoggedUser();
$materialAccess = $this->accessContext->hasMaterialAccess($user->id(), $materialId, $user->getAccess());
$directAccess = $this->accessContext->hasDirectMaterialAccess($user->id(), $materialId);
if (($materialAccess || $directAccess) === false) {
throw new AccessDeniedHttpException();
}
$this->materialQuery->getById($materialId); //check is material exists
$content = $this->materialQuery->getMaterialContent($materialId, $chapterId);
if ($content->downloadPath === '') {
throw new NotFoundException();
}
// $cacheKey = 'pdf.content_cache.'.$materialId;
// $responseBody = $this->cache->get($cacheKey, function (ItemInterface $item) use ($content) {
// $item->tag(self::REQUEST_CACHE_PDF_TAG);
// $encodedFileContent = file_get_contents($content->downloadPath);
// return base64_encode($encodedFileContent);
// });
$encodedFileContent = file_get_contents($content->downloadPath);
$responseBody = base64_encode($encodedFileContent);
$response = new LmsApiResponse($responseBody);
$response->setPublic();
$response->setMaxAge(3600);
return $response;
}
/**
* @Route (
* "/material/{instanceId}/{chapterId}/{materialId}/download",
* name="material_download_id",
* methods={"GET"},
* requirements={"chapterId"="[a-f0-9]{8}\-[a-f0-9]{4}\-[a-f0-9]{4}\-[a-f0-9]{4}\-[a-f0-9]{12}"}
* )
* @OA\Parameter(name="id", in="path", @OA\Schema(type="string"))
* @OA\Parameter(name="instanceId", in="query", @OA\Schema(type="string"))
* @OA\Parameter(name="chapterId", in="query", @OA\Schema(type="string"))
* @OA\Parameter(name="materialId", in="query", @OA\Schema(type="string"))
* @OA\Response(response=200, description="Single material html content")
*/
public function downloadCourseMaterialById(): Response
{
return new Response(null, Response::HTTP_MOVED_PERMANENTLY);
}
/**
* @Route (
* "/material/{instanceId}/{slug}/{materialId}/read",
* name="material_base64_read",
* methods={"GET"},
* requirements={"slug"="[0-9]+\-[0-9]+"}
* )
*
* @OA\Parameter(name="id", in="path", @OA\Schema(type="string"))
* @OA\Parameter(name="instanceId", in="query", @OA\Schema(type="string"))
* @OA\Parameter(name="chapterId", in="query", @OA\Schema(type="string"))
* @OA\Parameter(name="materialId", in="query", @OA\Schema(type="string"))
* @OA\Response(response=200, description="Single material base64 content")
*
* @return LmsApiResponse
* @throws \CodersLab\Lms\SharedKernel\Common\Exception\NotFoundException
*/
public function readCourseMaterialBySlug(string $materialId, string $slug, string $instanceId): LmsApiResponse
{
$user = $this->security->getLoggedUser();
$materialAccess = $this->accessContext->hasMaterialAccess($user->id(), $materialId, $user->getAccess());
$directAccess = $this->accessContext->hasDirectMaterialAccess($user->id(), $materialId);
if (($materialAccess || $directAccess) === false) {
throw new AccessDeniedHttpException();
}
$this->materialQuery->getById($materialId); //check is material exists
$materialOnInstance = $this->materialOnInstanceFactory->getForMaterial(Slug::createFromString($slug), $materialId, $instanceId);
$content = $this->materialQuery->getMaterialContent($materialId, (string)$materialOnInstance->getChapterId(), $instanceId);
if ($content->downloadPath === '') {
throw new NotFoundException();
}
$cacheKey = 'pdf.content_cache.'.$materialId;
$responseBody = $this->cache->get($cacheKey, function (ItemInterface $item) use ($content) {
$item->tag(self::REQUEST_CACHE_PDF_TAG);
$encodedFileContent = file_get_contents($content->downloadPath);
return base64_encode($encodedFileContent);
});
$response = new LmsApiResponse($responseBody);
$response->setPublic();
$response->setMaxAge(3600);
return $response;
}
/**
* @Route (
* "/material/{instanceId}/{slug}/{materialId}/download",
* name="material_download_slug",
* methods={"GET"},
* requirements={"slug"="[0-9]+\-[0-9]+"}
* )
*
* @OA\Parameter(name="id", in="path", @OA\Schema(type="string"))
* @OA\Parameter(name="instanceId", in="query", @OA\Schema(type="string"))
* @OA\Parameter(name="chapterId", in="query", @OA\Schema(type="string"))
* @OA\Parameter(name="materialId", in="query", @OA\Schema(type="string"))
* @OA\Response(response=200, description="Single material html content")
*
* @return BinaryFileResponse
* @throws \CodersLab\Lms\SharedKernel\Common\Exception\NotFoundException
*/
public function downloadCourseMaterialBySlug(string $materialId, string $slug, string $instanceId): BinaryFileResponse
{
$user = $this->security->getLoggedUser();
$material = $this->materialQuery->getById($materialId);
$materialOnInstance = $this->materialOnInstanceFactory->getForMaterial(Slug::createFromString($slug), $materialId, $instanceId);
$content = $this->materialQuery->getMaterialContent($materialId, (string)$materialOnInstance->getChapterId(), $instanceId);
if ($content->downloadPath === '') {
throw new NotFoundException();
}
$materialAccess = $this->accessContext->hasMaterialAccess($user->id(), $material->getId(), $user->getAccess());
$directAccess = $this->accessContext->hasDirectMaterialAccess($user->id(), $material->getId());
if (($materialAccess || $directAccess) === false) {
throw new AccessDeniedHttpException();
}
$response = new BinaryFileResponse($content->downloadPath);
$response->headers->set('Content-Type', $this->getContentTypeHeader($content->downloadPath));
$response->setContentDisposition(
ResponseHeaderBag::DISPOSITION_ATTACHMENT,
'attachment'
);
return $response;
}
/**
* @Route("/direct-access/material/{chapterId}/{materialId}/download", name="material_download_direct_access",
* methods={"GET"})
*
* @OA\Parameter(name="id", in="path", @OA\Schema(type="string"))
* @OA\Parameter(name="chapterId", in="path", @OA\Schema(type="string"))
* @OA\Parameter(name="materialId", in="path", @OA\Schema(type="string"))
* @OA\Response(response=200, description="Single material html content")
*/
public function downloadCourseMaterialDirectAccess(string $materialId, string $chapterId): BinaryFileResponse
{
$user = $this->security->getLoggedUser();
$material = $this->materialQuery->getById($materialId);
$content = $this->materialQuery->getMaterialContent($materialId, $chapterId);
if ($content->downloadPath === '') {
throw new NotFoundException();
}
$directAccess = $this->accessContext->hasDirectMaterialAccess($user->id(), $material->getId());
if ($directAccess === false) {
throw new AccessDeniedHttpException();
}
$response = new BinaryFileResponse($content->downloadPath);
$response->headers->set('Content-Type', $this->getContentTypeHeader($content->downloadPath));
$response->setContentDisposition(
ResponseHeaderBag::DISPOSITION_ATTACHMENT,
'attachment'
);
return $response;
}
/**
* @Route ("/material/issue", name="material_issue", methods={"POST"})
*
* @OA\RequestBody(
* @OA\Parameter(name="materialId", @OA\Schema(type="string")),
* @OA\Parameter(name="instanceId", @OA\Schema(type="string")),
* @OA\Parameter(name="message", @OA\Schema(type="string")),
* @OA\Parameter(name="selected", @OA\Schema(type="string"))
* )
* @OA\Response(response=200, description="Single material html content")
*/
public function issue(Request $request): Response
{
$materialId = new Uuid($request->request->get('materialId'));
$materialCourseInfo = $this->materialQuery->getMaterialCourseInfo($materialId, $request->request->get('instanceId'));
$this->commandBus->dispatch(new ReportMaterialIssue(new MaterialIssue(
$materialId,
$materialCourseInfo->getMaterialName(),
$materialCourseInfo->getModuleNr(),
$materialCourseInfo->getChapterNr(),
$materialCourseInfo->getSignature(),
$materialCourseInfo->getMaterialsMode(),
$request->request->get('selected'),
$request->request->get('message'),
$this->userRepository->getById((string)$this->security->getLoggedUserId())->getEmail(),
$materialCourseInfo->getTechnology(),
)));
return new Response();
}
/**
* @Route ("/material/search", name="materials_search", methods={"GET"})
*
* @OA\Parameter(name="q", required=true, in="query", @OA\Schema(type="string"))
* @OA\Parameter(name="type", in="query", @OA\Schema(type="array", @OA\Items(@OA\Schema(type="string"))))
* @OA\Parameter(name="page", in="query", @OA\Schema(type="integer"))
* @OA\Parameter(name="perPage", in="query", @OA\Schema(type="integer"))
* @OA\Parameter(name="instanceId", required=true, in="query", @OA\Schema(type="integer"))
* @OA\Response(
* response=200,
* description="Materials search result",
* @OA\Schema(type="array",
* @OA\Items(
* @OA\Schema(type="object"),
* @OA\Property(property="all", @OA\Schema(type="integer")),
* @OA\Property(
* property="materials",
* @OA\Schema(type="array",
* @OA\Items(ref=@Model(type="CodersLab\Lms\Modules\Materials\Application\Service\SearchEngine\Model\Material"))
* )
* )
* )
* )
* )
*/
public function search(Request $request): LmsApiResponse
{
$user = $this->security->getLoggedUser();
$roles = $this->accessContext->getUserCourseInstanceRoles(
$user->id(),
$request->query->get('instanceId')
);
$isStudent = false;
foreach ($roles as $role) {
if ($role->equals(MemberType::STUDENT())) {
$isStudent = true;
}
}
/** @var array<string> $type */
$type = $request->query->get('type') ?? [];
$filter = new FilterDecorator(
$request->query->get('q'),
$type,
$request->query->get('instanceId'),
$this->courseQuery->findCourseIdForInstance($request->query->get('instanceId')),
$isStudent && count($roles) === 1 ? Access::STUDENT : Access::LECTURER,
$this->accessContext->getModulesAccess(
(string)$this->security->getLoggedUserId(),
$request->query->get('instanceId'),
),
$request->query->get('page') !== null ? (int)$request->query->get('page') : null,
$request->query->get('perPage') !== null ? (int)$request->query->get('perPage') : null,
);
$data = $this->materialsSearchQuery->search($filter);
/** @var array<\CodersLab\Lms\Modules\Materials\Application\Service\SearchEngine\Model\Material> $materials */
$materials = [];
/** @var \CodersLab\Lms\Modules\Materials\Application\Service\SearchEngine\Model\Material $material */
foreach ($data['materials'] as $material) {
$materials[$material->getId()] = $material;
// if ($material->getScore() >= Elasticsearch::BOOST) {
// $perfectMatches++;
// }
}
if (count($materials) > 0) {
$materialsProgress = $this->progressQuery->forMaterials(
$request->query->get('instanceId'),
array_keys($materials),
(string)$user->id()
);
foreach ($materialsProgress as $materialProgress) {
$materials[(string)$materialProgress->getMaterialId()]->setStatus(
$materialProgress->getStatus(),
$materialProgress->isVisited()
);
}
}
//HL-2353 disable material search logging
// if ($request->query->get('q') !== null) {
// $this->commandBus->dispatch(new LogMaterialSearch(
// new DateTime(),
// (string)$user->id(),
// $user->getEmail(),
// $request->query->get('q'),
// $request->query->get('instanceId'),
// $perfectMatches
// ));
// }
return new LmsApiResponse($data, new Metadata($filter->getFilter()->page(), 0, $filter->getFilter()->limit(), 0));
}
private function getContentTypeHeader(string $filePath): string
{
$ext = pathinfo($filePath, PATHINFO_EXTENSION);
if (!in_array($ext, ['zip', 'pdf'], true)) {
throw new Exception('Attachment extension not supported.');
}
return 'application/' . $ext;
}
}