mirror of
https://github.com/sergeyyarkov/educt-server.git
synced 2026-10-11 10:19:20 +03:00
feat: added lesson video model
This commit is contained in:
parent
5171f460ed
commit
cd4eb2513c
@ -1,7 +1,6 @@
|
|||||||
import { Exception, inject, Ioc } from '@adonisjs/core/build/standalone';
|
import { Exception, inject, Ioc } from '@adonisjs/core/build/standalone';
|
||||||
import { HttpContextContract } from '@ioc:Adonis/Core/HttpContext';
|
import { HttpContextContract } from '@ioc:Adonis/Core/HttpContext';
|
||||||
import { schema } from '@ioc:Adonis/Core/Validator';
|
import { schema } from '@ioc:Adonis/Core/Validator';
|
||||||
import { extname } from 'path';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Services
|
* Services
|
||||||
@ -12,6 +11,7 @@ import LessonService from 'App/Services/LessonService';
|
|||||||
* Models
|
* Models
|
||||||
*/
|
*/
|
||||||
import LessonMaterial from 'App/Models/LessonMaterial';
|
import LessonMaterial from 'App/Models/LessonMaterial';
|
||||||
|
import LessonVideo from 'App/Models/LessonVideo';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validators
|
* Validators
|
||||||
@ -19,7 +19,6 @@ import LessonMaterial from 'App/Models/LessonMaterial';
|
|||||||
import CreateLessonValidator from 'App/Validators/Lesson/CreateLessonValidator';
|
import CreateLessonValidator from 'App/Validators/Lesson/CreateLessonValidator';
|
||||||
import UpdateLessonValidator from 'App/Validators/Lesson/UpdateLessonValidator';
|
import UpdateLessonValidator from 'App/Validators/Lesson/UpdateLessonValidator';
|
||||||
|
|
||||||
import Drive from '@ioc:Adonis/Core/Drive';
|
|
||||||
import BaseController from '../../BaseController';
|
import BaseController from '../../BaseController';
|
||||||
|
|
||||||
@inject()
|
@inject()
|
||||||
@ -133,21 +132,7 @@ export default class LessonsController extends BaseController {
|
|||||||
* Send file to user
|
* Send file to user
|
||||||
*/
|
*/
|
||||||
if (data instanceof LessonMaterial) {
|
if (data instanceof LessonMaterial) {
|
||||||
const path = `materials/${data.name}`;
|
this.sendFileFromDrive(`materials/${data.name}`, ctx);
|
||||||
|
|
||||||
/**
|
|
||||||
* Material file not found
|
|
||||||
*/
|
|
||||||
if (!(await Drive.exists(path))) return ctx.response.notFound();
|
|
||||||
|
|
||||||
const { size } = await Drive.getStats(path);
|
|
||||||
|
|
||||||
ctx.response.type(extname(data.name));
|
|
||||||
ctx.response.header('content-length', size);
|
|
||||||
|
|
||||||
const stream = await Drive.getStream(path);
|
|
||||||
|
|
||||||
return this.sendStream(ctx, stream);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.sendResponse(ctx, data, message, status);
|
return this.sendResponse(ctx, data, message, status);
|
||||||
@ -185,6 +170,28 @@ export default class LessonsController extends BaseController {
|
|||||||
|
|
||||||
return this.sendResponse(ctx, data, message, status);
|
return this.sendResponse(ctx, data, message, status);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get video file of lesson by file name
|
||||||
|
* GET /lessons/video/:fileName
|
||||||
|
*/
|
||||||
|
public async getVideo(ctx: HttpContextContract) {
|
||||||
|
const { fileName } = ctx.request.params();
|
||||||
|
const { data, message, status, success, error } = await this.lessonService.fetchVideoFile(ctx, fileName);
|
||||||
|
|
||||||
|
if (!success && error) {
|
||||||
|
throw new Exception(message, status, error.code);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send file to user
|
||||||
|
*/
|
||||||
|
if (data instanceof LessonVideo) {
|
||||||
|
return this.sendFileFromDrive(`videos/${data.name}`, ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.sendResponse(ctx, data, message, status);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
new Ioc().make(LessonsController);
|
new Ioc().make(LessonsController);
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
/* eslint-disable class-methods-use-this */
|
/* eslint-disable class-methods-use-this */
|
||||||
import { HttpContextContract } from '@ioc:Adonis/Core/HttpContext';
|
import { HttpContextContract } from '@ioc:Adonis/Core/HttpContext';
|
||||||
import { ResponseContract } from '@ioc:Adonis/Core/Response';
|
import { ResponseContract } from '@ioc:Adonis/Core/Response';
|
||||||
|
import Drive from '@ioc:Adonis/Core/Drive';
|
||||||
|
import { extname } from 'path';
|
||||||
|
|
||||||
export default class BaseController {
|
export default class BaseController {
|
||||||
public sendResponse(ctx: HttpContextContract, data: object, message: string, status = 200, meta?: any): void {
|
public sendResponse(ctx: HttpContextContract, data: object, message: string, status = 200, meta?: any): void {
|
||||||
@ -21,4 +23,20 @@ export default class BaseController {
|
|||||||
public sendFile(ctx: HttpContextContract, ...args: Parameters<ResponseContract['attachment']>) {
|
public sendFile(ctx: HttpContextContract, ...args: Parameters<ResponseContract['attachment']>) {
|
||||||
return ctx.response.attachment(...args);
|
return ctx.response.attachment(...args);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async sendFileFromDrive(pathname: string, ctx: HttpContextContract) {
|
||||||
|
/**
|
||||||
|
* File not found
|
||||||
|
*/
|
||||||
|
if (!(await Drive.exists(pathname))) return ctx.response.notFound();
|
||||||
|
|
||||||
|
const { size } = await Drive.getStats(pathname);
|
||||||
|
|
||||||
|
ctx.response.type(extname(pathname));
|
||||||
|
ctx.response.header('content-length', size);
|
||||||
|
|
||||||
|
const stream = await Drive.getStream(pathname);
|
||||||
|
|
||||||
|
return this.sendStream(ctx, stream);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -17,8 +17,9 @@ import ColorHelper from 'App/Helpers/ColorHelper';
|
|||||||
import Course from './Course';
|
import Course from './Course';
|
||||||
import LessonMaterial from './LessonMaterial';
|
import LessonMaterial from './LessonMaterial';
|
||||||
import LessonContent from './LessonContent';
|
import LessonContent from './LessonContent';
|
||||||
import Color from './Color';
|
|
||||||
import LessonProgress from './LessonProgress';
|
import LessonProgress from './LessonProgress';
|
||||||
|
import LessonVideo from './LessonVideo';
|
||||||
|
import Color from './Color';
|
||||||
|
|
||||||
export default class Lesson extends BaseModel {
|
export default class Lesson extends BaseModel {
|
||||||
@column({ isPrimary: true })
|
@column({ isPrimary: true })
|
||||||
@ -57,6 +58,11 @@ export default class Lesson extends BaseModel {
|
|||||||
})
|
})
|
||||||
public content: HasOne<typeof LessonContent>;
|
public content: HasOne<typeof LessonContent>;
|
||||||
|
|
||||||
|
@hasOne(() => LessonVideo, {
|
||||||
|
foreignKey: 'lesson_id',
|
||||||
|
})
|
||||||
|
public video: HasOne<typeof LessonVideo>;
|
||||||
|
|
||||||
@hasMany(() => LessonMaterial, { foreignKey: 'lesson_id' })
|
@hasMany(() => LessonMaterial, { foreignKey: 'lesson_id' })
|
||||||
public materials: HasMany<typeof LessonMaterial>;
|
public materials: HasMany<typeof LessonMaterial>;
|
||||||
|
|
||||||
|
|||||||
38
app/Models/LessonVideo.ts
Normal file
38
app/Models/LessonVideo.ts
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
/* eslint-disable import/no-cycle */
|
||||||
|
import { DateTime } from 'luxon';
|
||||||
|
import { BaseModel, BelongsTo, belongsTo, column } from '@ioc:Adonis/Lucid/Orm';
|
||||||
|
import Lesson from './Lesson';
|
||||||
|
|
||||||
|
export default class LessonVideo extends BaseModel {
|
||||||
|
@column({ isPrimary: true })
|
||||||
|
public id: number;
|
||||||
|
|
||||||
|
@column({ serializeAs: null })
|
||||||
|
public lesson_id: string;
|
||||||
|
|
||||||
|
@column()
|
||||||
|
public name: string;
|
||||||
|
|
||||||
|
@column()
|
||||||
|
public size: number;
|
||||||
|
|
||||||
|
@column()
|
||||||
|
public clientName: string;
|
||||||
|
|
||||||
|
@column()
|
||||||
|
public ext: string;
|
||||||
|
|
||||||
|
@column()
|
||||||
|
public url: string;
|
||||||
|
|
||||||
|
@belongsTo(() => Lesson, {
|
||||||
|
foreignKey: 'lesson_id',
|
||||||
|
})
|
||||||
|
public lesson: BelongsTo<typeof Lesson>;
|
||||||
|
|
||||||
|
@column.dateTime({ autoCreate: true })
|
||||||
|
public createdAt: DateTime;
|
||||||
|
|
||||||
|
@column.dateTime({ autoCreate: true, autoUpdate: true })
|
||||||
|
public updatedAt: DateTime;
|
||||||
|
}
|
||||||
@ -7,6 +7,7 @@ import { cuid } from '@ioc:Adonis/Core/Helpers';
|
|||||||
import Course from 'App/Models/Course';
|
import Course from 'App/Models/Course';
|
||||||
import Lesson from 'App/Models/Lesson';
|
import Lesson from 'App/Models/Lesson';
|
||||||
import LessonMaterial from 'App/Models/LessonMaterial';
|
import LessonMaterial from 'App/Models/LessonMaterial';
|
||||||
|
import LessonVideo from 'App/Models/LessonVideo';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validators
|
* Validators
|
||||||
@ -19,11 +20,14 @@ export default class LessonRepository {
|
|||||||
|
|
||||||
private LessonMaterial: typeof LessonMaterial;
|
private LessonMaterial: typeof LessonMaterial;
|
||||||
|
|
||||||
|
private LessonVideo: typeof LessonVideo;
|
||||||
|
|
||||||
private Drive: typeof Drive;
|
private Drive: typeof Drive;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.Lesson = Lesson;
|
this.Lesson = Lesson;
|
||||||
this.LessonMaterial = LessonMaterial;
|
this.LessonMaterial = LessonMaterial;
|
||||||
|
this.LessonVideo = LessonVideo;
|
||||||
this.Drive = Drive;
|
this.Drive = Drive;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -81,6 +85,11 @@ export default class LessonRepository {
|
|||||||
return material;
|
return material;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async getVideoFileByName(fileName: string): Promise<LessonVideo | null> {
|
||||||
|
const video = await this.LessonVideo.query().where('name', fileName).first();
|
||||||
|
return video;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create lesson
|
* Create lesson
|
||||||
*
|
*
|
||||||
@ -95,18 +104,29 @@ export default class LessonRepository {
|
|||||||
description: data.description,
|
description: data.description,
|
||||||
duration: data.duration.toFormat('HH:mm:ss'),
|
duration: data.duration.toFormat('HH:mm:ss'),
|
||||||
display_order: Number.parseInt(course.$extras.lessons_count, 10) + 1,
|
display_order: Number.parseInt(course.$extras.lessons_count, 10) + 1,
|
||||||
|
course_id: course.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
|
||||||
* Attach lesson to course
|
|
||||||
*/
|
|
||||||
await lesson.related('course').associate(course);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create content
|
* Create content
|
||||||
*/
|
*/
|
||||||
await lesson.related('content').create({ video_url: data.video_url });
|
await lesson.related('content').create({ video_url: data.video_url });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Move video file to disk and save to database
|
||||||
|
*/
|
||||||
|
const videoName = `${cuid()}.${data.video.extname}`;
|
||||||
|
await data.video.moveToDisk('videos', { name: videoName });
|
||||||
|
if (data.video.state === 'moved') {
|
||||||
|
await lesson.related('video').create({
|
||||||
|
name: videoName,
|
||||||
|
clientName: data.video.clientName,
|
||||||
|
ext: data.video.extname,
|
||||||
|
size: data.video.size,
|
||||||
|
url: `lessons/video/${videoName}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create materials
|
* Create materials
|
||||||
*/
|
*/
|
||||||
@ -150,7 +170,7 @@ export default class LessonRepository {
|
|||||||
/**
|
/**
|
||||||
* Load data
|
* Load data
|
||||||
*/
|
*/
|
||||||
await lesson.load(loader => loader.load('content').load('materials'));
|
await lesson.load(loader => loader.load('content').load('materials').load('video'));
|
||||||
|
|
||||||
return lesson;
|
return lesson;
|
||||||
}
|
}
|
||||||
@ -185,15 +205,21 @@ export default class LessonRepository {
|
|||||||
* @returns Deleted lesson or null
|
* @returns Deleted lesson or null
|
||||||
*/
|
*/
|
||||||
public async delete(id: string | number): Promise<Lesson | null> {
|
public async delete(id: string | number): Promise<Lesson | null> {
|
||||||
const lesson = await this.Lesson.query().where('id', id).preload('materials').preload('color').first();
|
const lesson = await this.Lesson.query()
|
||||||
|
.where('id', id)
|
||||||
|
.preload('video')
|
||||||
|
.preload('materials')
|
||||||
|
.preload('color')
|
||||||
|
.first();
|
||||||
|
|
||||||
if (lesson) {
|
if (lesson) {
|
||||||
/**
|
/**
|
||||||
* Delete files from disk
|
* Delete files from disk
|
||||||
*/
|
*/
|
||||||
|
await this.Drive.delete(`videos/${lesson.video.name}`);
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
lesson.materials.map(async file => {
|
lesson.materials.map(async material => {
|
||||||
await this.Drive.delete(`materials/${file.name}`);
|
await this.Drive.delete(`materials/${material.name}`);
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import Database from '@ioc:Adonis/Lucid/Database';
|
|||||||
import HttpStatusEnum from 'App/Datatypes/Enums/HttpStatusEnum';
|
import HttpStatusEnum from 'App/Datatypes/Enums/HttpStatusEnum';
|
||||||
import IResponse from 'App/Datatypes/Interfaces/IResponse';
|
import IResponse from 'App/Datatypes/Interfaces/IResponse';
|
||||||
import LessonMaterial from 'App/Models/LessonMaterial';
|
import LessonMaterial from 'App/Models/LessonMaterial';
|
||||||
|
import LessonVideo from 'App/Models/LessonVideo';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Repositories
|
* Repositories
|
||||||
@ -151,6 +152,43 @@ export default class LessonService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async fetchVideoFile(ctx: HttpContextContract, name: string): Promise<IResponse<LessonVideo>> {
|
||||||
|
const video = await this.lessonRepository.getVideoFileByName(name);
|
||||||
|
|
||||||
|
if (!video) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
status: HttpStatusEnum.NOT_FOUND,
|
||||||
|
data: {},
|
||||||
|
message: 'Video not found.',
|
||||||
|
error: {
|
||||||
|
code: 'E_NOT_FOUND',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
await video.load('lesson');
|
||||||
|
|
||||||
|
if (await ctx.bouncer.denies('viewLessonContent', video.lesson)) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
status: HttpStatusEnum.FORBIDDEN,
|
||||||
|
message: 'You are not able to view this file.',
|
||||||
|
data: {},
|
||||||
|
error: {
|
||||||
|
code: 'E_FORBIDDEN',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
status: HttpStatusEnum.OK,
|
||||||
|
message: 'Fetched lesson video.',
|
||||||
|
data: video,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch progress of lesson by lesson id
|
* Fetch progress of lesson by lesson id
|
||||||
*
|
*
|
||||||
@ -222,6 +260,9 @@ export default class LessonService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create new lesson
|
||||||
|
*/
|
||||||
const lesson = await this.lessonRepository.create(course, data);
|
const lesson = await this.lessonRepository.create(course, data);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@ -10,6 +10,10 @@ export default class CreateLessonValidator {
|
|||||||
description: schema.string(),
|
description: schema.string(),
|
||||||
duration: schema.date({ format: 'HH:mm:ss' }),
|
duration: schema.date({ format: 'HH:mm:ss' }),
|
||||||
video_url: schema.string({}, [rules.url()]),
|
video_url: schema.string({}, [rules.url()]),
|
||||||
|
video: schema.file({
|
||||||
|
size: '5000mb',
|
||||||
|
extnames: ['mp4', 'mov', 'avi', 'wmv', 'webm', 'flv'],
|
||||||
|
}),
|
||||||
materials: schema.array.optional().members(
|
materials: schema.array.optional().members(
|
||||||
schema.file({
|
schema.file({
|
||||||
size: '10mb',
|
size: '10mb',
|
||||||
|
|||||||
31
app/Validators/Validator.ts
Normal file
31
app/Validators/Validator.ts
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import { validator, ParsedTypedSchema } from '@ioc:Adonis/Core/Validator';
|
||||||
|
|
||||||
|
export default abstract class Validator {
|
||||||
|
/**
|
||||||
|
* Object for schemas.
|
||||||
|
* Each key in object will correspong to a type of validation.
|
||||||
|
* Each values will be a valid Adonis schema, made using schema.create
|
||||||
|
*/
|
||||||
|
protected abstract schemas(type: string): ParsedTypedSchema<any>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Object of error message objects used to pass custom error messages.
|
||||||
|
* Each key will correspond to a valid validation type defined in schemas
|
||||||
|
*/
|
||||||
|
protected errorMessages = {};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cache key, to be used if validator schema caching is needed.
|
||||||
|
* Refer https://preview.adonisjs.com/guides/validator/schema-caching
|
||||||
|
*/
|
||||||
|
protected cacheKey: string;
|
||||||
|
|
||||||
|
public async fire(data: { [key: string]: any }, type: string): Promise<any> {
|
||||||
|
return validator.validate({
|
||||||
|
schema: this.schemas(type),
|
||||||
|
data,
|
||||||
|
...(this.errorMessages[type] ? { messages: this.errorMessages[type] } : {}),
|
||||||
|
...(this.cacheKey ? { cacheKey: this.cacheKey } : {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
24
database/migrations/1640897230275_lesson_videos.ts
Normal file
24
database/migrations/1640897230275_lesson_videos.ts
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import BaseSchema from '@ioc:Adonis/Lucid/Schema';
|
||||||
|
|
||||||
|
export default class LessonVideos extends BaseSchema {
|
||||||
|
protected tableName = 'lesson_videos';
|
||||||
|
|
||||||
|
public async up() {
|
||||||
|
this.schema.createTable(this.tableName, table => {
|
||||||
|
table.increments('id');
|
||||||
|
table.string('lesson_id', 21).unsigned().references('lessons.id').onDelete('CASCADE').notNullable();
|
||||||
|
table.string('name').notNullable();
|
||||||
|
table.integer('size').notNullable();
|
||||||
|
table.string('client_name').notNullable();
|
||||||
|
table.string('ext').notNullable();
|
||||||
|
table.string('url').notNullable();
|
||||||
|
table.timestamp('created_at', { useTz: true });
|
||||||
|
table.timestamp('updated_at', { useTz: true });
|
||||||
|
table.unique(['lesson_id']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down() {
|
||||||
|
this.schema.dropTable(this.tableName);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -15,6 +15,9 @@ Route.group(() => {
|
|||||||
Route.get('/materials/:fileName', 'Api/v1/LessonsController.getMaterial')
|
Route.get('/materials/:fileName', 'Api/v1/LessonsController.getMaterial')
|
||||||
.middleware('role:admin,teacher,student')
|
.middleware('role:admin,teacher,student')
|
||||||
.as('lessons.get-material');
|
.as('lessons.get-material');
|
||||||
|
Route.get('/video/:fileName', 'Api/v1/LessonsController.getVideo')
|
||||||
|
.middleware('role:admin,teacher,student')
|
||||||
|
.as('lessons.get-video');
|
||||||
Route.get('/:id/progress', 'Api/v1/LessonsController.getVideoProgress')
|
Route.get('/:id/progress', 'Api/v1/LessonsController.getVideoProgress')
|
||||||
.middleware('role:admin,teacher,student')
|
.middleware('role:admin,teacher,student')
|
||||||
.as('lessons.get-video-progress');
|
.as('lessons.get-video-progress');
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user