From 6975144e354fd1aa5c982def99cfa56488c3d57f Mon Sep 17 00:00:00 2001 From: GitBluub Date: Tue, 25 Oct 2022 17:14:47 +0900 Subject: [PATCH] feat: genre and album populate and CRUD --- .../migrations/20221025075823_/migration.sql | 5 + back/prisma/schema.prisma | 4 +- back/src/album/album.controller.spec.ts | 18 +++ back/src/album/album.controller.ts | 82 +++++++++++ back/src/album/album.module.ts | 11 ++ back/src/album/album.service.spec.ts | 18 +++ back/src/album/album.service.ts | 45 ++++++ back/src/album/dto/create-album.dto.ts | 11 ++ back/src/app.module.ts | 7 +- back/src/artist/artist.module.ts | 11 ++ back/src/genre/dto/create-genre.dto.ts | 8 ++ back/src/genre/genre.controller.spec.ts | 18 +++ back/src/genre/genre.controller.ts | 72 ++++++++++ back/src/genre/genre.module.ts | 11 ++ back/src/genre/genre.service.spec.ts | 18 +++ back/src/genre/genre.service.ts | 43 ++++++ back/test/robot/albums/albums.robot | 129 ++++++++++++++++++ back/test/robot/genres/genres.robot | 113 +++++++++++++++ musics/Beethoven-125-4/Beethoven-125-4.ini | 2 +- musics/populate.py | 24 +++- 20 files changed, 643 insertions(+), 7 deletions(-) create mode 100644 back/prisma/migrations/20221025075823_/migration.sql create mode 100644 back/src/album/album.controller.spec.ts create mode 100644 back/src/album/album.controller.ts create mode 100644 back/src/album/album.module.ts create mode 100644 back/src/album/album.service.spec.ts create mode 100644 back/src/album/album.service.ts create mode 100644 back/src/album/dto/create-album.dto.ts create mode 100644 back/src/artist/artist.module.ts create mode 100644 back/src/genre/dto/create-genre.dto.ts create mode 100644 back/src/genre/genre.controller.spec.ts create mode 100644 back/src/genre/genre.controller.ts create mode 100644 back/src/genre/genre.module.ts create mode 100644 back/src/genre/genre.service.spec.ts create mode 100644 back/src/genre/genre.service.ts create mode 100644 back/test/robot/albums/albums.robot create mode 100644 back/test/robot/genres/genres.robot diff --git a/back/prisma/migrations/20221025075823_/migration.sql b/back/prisma/migrations/20221025075823_/migration.sql new file mode 100644 index 0000000..21fd5d7 --- /dev/null +++ b/back/prisma/migrations/20221025075823_/migration.sql @@ -0,0 +1,5 @@ +-- AlterTable +ALTER TABLE "Album" ADD COLUMN "artistId" INTEGER; + +-- AddForeignKey +ALTER TABLE "Album" ADD CONSTRAINT "Album_artistId_fkey" FOREIGN KEY ("artistId") REFERENCES "Artist"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/back/prisma/schema.prisma b/back/prisma/schema.prisma index 4912098..5b6d0b2 100644 --- a/back/prisma/schema.prisma +++ b/back/prisma/schema.prisma @@ -43,12 +43,14 @@ model Artist { name String @unique Song Song[] + Album Album[] } model Album { id Int @id @default(autoincrement()) name String @unique - + artistId Int? + artist Artist? @relation(fields: [artistId], references: [id]) Song Song[] } diff --git a/back/src/album/album.controller.spec.ts b/back/src/album/album.controller.spec.ts new file mode 100644 index 0000000..7c18a32 --- /dev/null +++ b/back/src/album/album.controller.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { AlbumController } from './album.controller'; + +describe('AlbumController', () => { + let controller: AlbumController; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [AlbumController], + }).compile(); + + controller = module.get(AlbumController); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); +}); diff --git a/back/src/album/album.controller.ts b/back/src/album/album.controller.ts new file mode 100644 index 0000000..f721b57 --- /dev/null +++ b/back/src/album/album.controller.ts @@ -0,0 +1,82 @@ +import { + BadRequestException, + Body, + ConflictException, + Controller, + DefaultValuePipe, + Delete, + Get, + InternalServerErrorException, + NotFoundException, + Param, + ParseIntPipe, + Post, + Query, + Req, + StreamableFile, +} from '@nestjs/common'; +import { Plage } from 'src/models/plage'; +import { CreateAlbumDto } from './dto/create-album.dto'; +import { AlbumService } from './album.service'; +import { Request } from 'express'; +import { Prisma, Album } from '@prisma/client'; +import { createReadStream } from 'fs'; +import { ApiTags } from '@nestjs/swagger'; + +@Controller('album') +@ApiTags('album') +export class AlbumController { + constructor(private readonly albumService: AlbumService) {} + + @Post() + async create(@Body() createAlbumDto: CreateAlbumDto) { + try { + return await this.albumService.createAlbum({ + ...createAlbumDto, + artist: createAlbumDto.artist + ? { connect: { id: createAlbumDto.artist } } + : undefined + }); + } catch { + throw new ConflictException( + await this.albumService.album({ name: createAlbumDto.name }), + ); + } + } + + @Delete(':id') + async remove(@Param('id', ParseIntPipe) id: number) { + return await this.albumService.deleteAlbum({ id }); + } + + @Get() + async findAll( + @Req() req: Request, + @Query() filter: Prisma.AlbumWhereInput, + @Query('skip', new DefaultValuePipe(0), ParseIntPipe) skip: number, + @Query('take', new DefaultValuePipe(20), ParseIntPipe) take: number, + ): Promise> { + try { + const ret = await this.albumService.albums({ + skip, + take, + where: { + ...filter, + id: filter.id ? +filter.id : undefined, + }, + }); + return new Plage(ret, req); + } catch (e) { + console.log(e); + throw new BadRequestException(null, e?.toString()); + } + } + + @Get(':id') + async findOne(@Param('id', ParseIntPipe) id: number) { + const res = await this.albumService.album({ id }); + + if (res === null) throw new NotFoundException('Album not found'); + return res; + } +} diff --git a/back/src/album/album.module.ts b/back/src/album/album.module.ts new file mode 100644 index 0000000..68f8261 --- /dev/null +++ b/back/src/album/album.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { PrismaModule } from 'src/prisma/prisma.module'; +import { AlbumController } from './album.controller'; +import { AlbumService } from './album.service'; + +@Module({ + imports: [PrismaModule], + controllers: [AlbumController], + providers: [AlbumService] +}) +export class AlbumModule {} diff --git a/back/src/album/album.service.spec.ts b/back/src/album/album.service.spec.ts new file mode 100644 index 0000000..d6fdab3 --- /dev/null +++ b/back/src/album/album.service.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { AlbumService } from './album.service'; + +describe('AlbumService', () => { + let service: AlbumService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [AlbumService], + }).compile(); + + service = module.get(AlbumService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); +}); diff --git a/back/src/album/album.service.ts b/back/src/album/album.service.ts new file mode 100644 index 0000000..e144b32 --- /dev/null +++ b/back/src/album/album.service.ts @@ -0,0 +1,45 @@ +import { Injectable } from '@nestjs/common'; +import { Prisma, Album } from '@prisma/client'; +import { PrismaService } from 'src/prisma/prisma.service'; + +@Injectable() +export class AlbumService { + constructor(private prisma: PrismaService) {} + + async createAlbum(data: Prisma.AlbumCreateInput): Promise { + return this.prisma.album.create({ + data, + }); + } + + async album( + albumWhereUniqueInput: Prisma.AlbumWhereUniqueInput, + ): Promise { + return this.prisma.album.findUnique({ + where: albumWhereUniqueInput, + }); + } + + async albums(params: { + skip?: number; + take?: number; + cursor?: Prisma.AlbumWhereUniqueInput; + where?: Prisma.AlbumWhereInput; + orderBy?: Prisma.AlbumOrderByWithRelationInput; + }): Promise { + const { skip, take, cursor, where, orderBy } = params; + return this.prisma.album.findMany({ + skip, + take, + cursor, + where, + orderBy, + }); + } + + async deleteAlbum(where: Prisma.AlbumWhereUniqueInput): Promise { + return this.prisma.album.delete({ + where, + }); + } +} diff --git a/back/src/album/dto/create-album.dto.ts b/back/src/album/dto/create-album.dto.ts new file mode 100644 index 0000000..bdbbab8 --- /dev/null +++ b/back/src/album/dto/create-album.dto.ts @@ -0,0 +1,11 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsNotEmpty } from 'class-validator'; + +export class CreateAlbumDto { + @IsNotEmpty() + @ApiProperty() + name: string; + + @ApiProperty() + artist?: number; +} diff --git a/back/src/app.module.ts b/back/src/app.module.ts index 19a4e41..74a9262 100644 --- a/back/src/app.module.ts +++ b/back/src/app.module.ts @@ -9,10 +9,13 @@ import { SongModule } from './song/song.module'; import { LessonModule } from './lesson/lesson.module'; import { ArtistController } from './artist/artist.controller'; import { ArtistService } from './artist/artist.service'; +import { GenreModule } from './genre/genre.module'; +import { ArtistModule } from './artist/artist.module'; +import { AlbumModule } from './album/album.module'; @Module({ - imports: [UsersModule, PrismaModule, AuthModule, SongModule, LessonModule], - controllers: [AppController, ArtistController], + imports: [UsersModule, PrismaModule, AuthModule, SongModule, LessonModule, GenreModule, ArtistModule, AlbumModule], + controllers: [AppController], providers: [AppService, PrismaService, ArtistService], }) export class AppModule {} diff --git a/back/src/artist/artist.module.ts b/back/src/artist/artist.module.ts new file mode 100644 index 0000000..fc73335 --- /dev/null +++ b/back/src/artist/artist.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { PrismaModule } from 'src/prisma/prisma.module'; +import { ArtistController } from './artist.controller'; +import { ArtistService } from './artist.service'; + +@Module({ + imports: [PrismaModule], + controllers: [ArtistController], + providers: [ArtistService] + }) +export class ArtistModule {} diff --git a/back/src/genre/dto/create-genre.dto.ts b/back/src/genre/dto/create-genre.dto.ts new file mode 100644 index 0000000..65962e3 --- /dev/null +++ b/back/src/genre/dto/create-genre.dto.ts @@ -0,0 +1,8 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsNotEmpty } from 'class-validator'; + +export class CreateGenreDto { + @IsNotEmpty() + @ApiProperty() + name: string; +} diff --git a/back/src/genre/genre.controller.spec.ts b/back/src/genre/genre.controller.spec.ts new file mode 100644 index 0000000..f91a726 --- /dev/null +++ b/back/src/genre/genre.controller.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { GenreController } from './genre.controller'; + +describe('GenreController', () => { + let controller: GenreController; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [GenreController], + }).compile(); + + controller = module.get(GenreController); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); +}); diff --git a/back/src/genre/genre.controller.ts b/back/src/genre/genre.controller.ts new file mode 100644 index 0000000..80e5b8d --- /dev/null +++ b/back/src/genre/genre.controller.ts @@ -0,0 +1,72 @@ +import { + BadRequestException, + Body, + ConflictException, + Controller, + DefaultValuePipe, + Delete, + Get, + NotFoundException, + Param, + ParseIntPipe, + Post, + Query, + Req, +} from '@nestjs/common'; +import { Plage } from 'src/models/plage'; +import { CreateGenreDto } from './dto/create-genre.dto'; +import { Request } from 'express'; +import { GenreService } from './genre.service'; +import { Prisma, Genre } from '@prisma/client'; +import { ApiTags } from '@nestjs/swagger'; + +@Controller('genre') +@ApiTags('genre') +export class GenreController { + constructor(private readonly service: GenreService) {} + + @Post() + async create(@Body() dto: CreateGenreDto) { + try { + return await this.service.create(dto); + } catch { + throw new ConflictException(await this.service.get({ name: dto.name })); + } + } + + @Delete(':id') + async remove(@Param('id', ParseIntPipe) id: number) { + return await this.service.delete({ id }); + } + + @Get() + async findAll( + @Req() req: Request, + @Query() filter: Prisma.SongWhereInput, + @Query('skip', new DefaultValuePipe(0), ParseIntPipe) skip: number, + @Query('take', new DefaultValuePipe(20), ParseIntPipe) take: number, + ): Promise> { + try { + const ret = await this.service.list({ + skip, + take, + where: { + ...filter, + id: filter.id ? +filter.id : undefined, + }, + }); + return new Plage(ret, req); + } catch (e) { + console.log(e); + throw new BadRequestException(null, e?.toString()); + } + } + + @Get(':id') + async findOne(@Param('id', ParseIntPipe) id: number) { + const res = await this.service.get({ id }); + + if (res === null) throw new NotFoundException('Genre not found'); + return res; + } +} diff --git a/back/src/genre/genre.module.ts b/back/src/genre/genre.module.ts new file mode 100644 index 0000000..eebddba --- /dev/null +++ b/back/src/genre/genre.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { PrismaModule } from 'src/prisma/prisma.module'; +import { GenreController } from './genre.controller'; +import { GenreService } from './genre.service'; + +@Module({ + imports: [PrismaModule], + controllers: [GenreController], + providers: [GenreService] +}) +export class GenreModule {} diff --git a/back/src/genre/genre.service.spec.ts b/back/src/genre/genre.service.spec.ts new file mode 100644 index 0000000..2872336 --- /dev/null +++ b/back/src/genre/genre.service.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { GenreService } from './genre.service'; + +describe('GenreService', () => { + let service: GenreService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [GenreService], + }).compile(); + + service = module.get(GenreService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); +}); diff --git a/back/src/genre/genre.service.ts b/back/src/genre/genre.service.ts new file mode 100644 index 0000000..75cace2 --- /dev/null +++ b/back/src/genre/genre.service.ts @@ -0,0 +1,43 @@ +import { Injectable } from '@nestjs/common'; +import { Prisma, Genre } from '@prisma/client'; +import { PrismaService } from 'src/prisma/prisma.service'; + +@Injectable() +export class GenreService { + constructor(private prisma: PrismaService) {} + + async create(data: Prisma.GenreCreateInput): Promise { + return this.prisma.genre.create({ + data, + }); + } + + async get(where: Prisma.GenreWhereUniqueInput): Promise { + return this.prisma.genre.findUnique({ + where, + }); + } + + async list(params: { + skip?: number; + take?: number; + cursor?: Prisma.GenreWhereUniqueInput; + where?: Prisma.GenreWhereInput; + orderBy?: Prisma.GenreOrderByWithRelationInput; + }): Promise { + const { skip, take, cursor, where, orderBy } = params; + return this.prisma.genre.findMany({ + skip, + take, + cursor, + where, + orderBy, + }); + } + + async delete(where: Prisma.GenreWhereUniqueInput): Promise { + return this.prisma.genre.delete({ + where, + }); + } +} diff --git a/back/test/robot/albums/albums.robot b/back/test/robot/albums/albums.robot new file mode 100644 index 0000000..9f03c73 --- /dev/null +++ b/back/test/robot/albums/albums.robot @@ -0,0 +1,129 @@ +*** Settings *** +Documentation Tests of the /album route. +... Ensures that the album CRUD works corectly. + +Resource ../rest.resource + + +*** Test Cases *** +Create a album + [Documentation] Create a album + &{res}= POST + ... /album + ... {"name": "Mama mia"} + Output + Integer response status 201 + [Teardown] DELETE /album/${res.body.id} + +Create a album with an artist + [Documentation] Create a album with an artist + &{artistRes}= POST + ... /artist + ... {"name": "Mama mia"} + + + &{res}= POST + ... /album + ... {"name": "Mama mia", "artist": ${artistRes.body.id}} + Output + Integer response status 201 + [Teardown] Run Keywords DELETE /artist/${artistRes.body.id} + ... AND DELETE /album/${res.body.id} + + +Duplicate a album + [Documentation] Duplicate a album + &{res}= POST + ... /album + ... {"name": "Mama mia"} + Output + Integer response status 201 + &{res2}= POST + ... /album + ... {"name": "Mama mia"} + Output + Integer response status 409 + Should Be Equal ${res.body.id} ${res2.body.id} + [Teardown] DELETE /album/${res.body.id} + +Find a album + [Documentation] Create a album and find it + &{res}= POST + ... /album + ... {"name": "Mama mia"} + Output + Integer response status 201 + &{get}= GET /album/${res.body.id} + Output + Integer response status 200 + Should Be Equal ${res.body} ${get.body} + [Teardown] DELETE /album/${res.body.id} + +Find a album non existant + [Documentation] Find non existant album + &{get}= GET /album/9999 + Integer response status 404 + +Find multiples albums + [Documentation] Create two albums and find them + &{res}= POST + ... /album + ... {"name": "Mama mia"} + Output + Integer response status 201 + &{res2}= POST + ... /album + ... {"name": "Toto"} + + Output + Integer response status 201 + + &{get}= GET /album + Output + Integer response status 200 + Should Contain ${get.body.data} ${res.body} + Should Contain ${get.body.data} ${res2.body} + [Teardown] Run Keywords DELETE /album/${res.body.id} + ... AND DELETE /album/${res2.body.id} + +Find multiples albums filtered + [Documentation] Create two albums and find them + &{res}= POST + ... /album + ... {"name": "Mamamia"} + Output + Integer response status 201 + &{res2}= POST + ... /album + ... {"name": "jkgnsg"} + Output + Integer response status 201 + + &{get}= GET /album?name=Mamamia + Output + Integer response status 200 + Should Contain ${get.body.data} ${res.body} + Should Not Contain ${get.body.data} ${res2.body} + [Teardown] Run Keywords DELETE /album/${res.body.id} + ... AND DELETE /album/${res2.body.id} + +Find multiples albums filtered by type + [Documentation] Create two albums and find them + &{res}= POST + ... /album + ... {"name": "Mama mia"} + Output + Integer response status 201 + &{res2}= POST + ... /album + ... {"name": "kldngsd"} + Output + Integer response status 201 + + &{get}= GET /album?id=${res.body.id} + Output + Integer response status 200 + Should Contain ${get.body.data} ${res.body} + Should Not Contain ${get.body.data} ${res2.body} + [Teardown] Run Keywords DELETE /album/${res.body.id} + ... AND DELETE /album/${res2.body.id} diff --git a/back/test/robot/genres/genres.robot b/back/test/robot/genres/genres.robot new file mode 100644 index 0000000..ab6b12a --- /dev/null +++ b/back/test/robot/genres/genres.robot @@ -0,0 +1,113 @@ +*** Settings *** +Documentation Tests of the /genre route. +... Ensures that the genre CRUD works corectly. + +Resource ../rest.resource + + +*** Test Cases *** +Create a genre + [Documentation] Create a genre + &{res}= POST + ... /genre + ... {"name": "Mama mia"} + Output + Integer response status 201 + [Teardown] DELETE /genre/${res.body.id} + +Duplicate a genre + [Documentation] Duplicate a genre + &{res}= POST + ... /genre + ... {"name": "Mama mia"} + Output + Integer response status 201 + &{res2}= POST + ... /genre + ... {"name": "Mama mia"} + Output + Integer response status 409 + Should Be Equal ${res.body.id} ${res2.body.id} + [Teardown] DELETE /genre/${res.body.id} + +Find a genre + [Documentation] Create a genre and find it + &{res}= POST + ... /genre + ... {"name": "Mama mia"} + Output + Integer response status 201 + &{get}= GET /genre/${res.body.id} + Output + Integer response status 200 + Should Be Equal ${res.body} ${get.body} + [Teardown] DELETE /genre/${res.body.id} + +Find a genre non existant + [Documentation] Find non existant genre + &{get}= GET /genre/9999 + Integer response status 404 + +Find multiples genres + [Documentation] Create two genres and find them + &{res}= POST + ... /genre + ... {"name": "Mama mia"} + Output + Integer response status 201 + &{res2}= POST + ... /genre + ... {"name": "Toto"} + + Output + Integer response status 201 + + &{get}= GET /genre + Output + Integer response status 200 + Should Contain ${get.body.data} ${res.body} + Should Contain ${get.body.data} ${res2.body} + [Teardown] Run Keywords DELETE /genre/${res.body.id} + ... AND DELETE /genre/${res2.body.id} + +Find multiples genres filtered + [Documentation] Create two genres and find them + &{res}= POST + ... /genre + ... {"name": "Mamamia"} + Output + Integer response status 201 + &{res2}= POST + ... /genre + ... {"name": "jkgnsg"} + Output + Integer response status 201 + + &{get}= GET /genre?name=Mamamia + Output + Integer response status 200 + Should Contain ${get.body.data} ${res.body} + Should Not Contain ${get.body.data} ${res2.body} + [Teardown] Run Keywords DELETE /genre/${res.body.id} + ... AND DELETE /genre/${res2.body.id} + +Find multiples genres filtered by type + [Documentation] Create two genres and find them + &{res}= POST + ... /genre + ... {"name": "Mama mia"} + Output + Integer response status 201 + &{res2}= POST + ... /genre + ... {"name": "kldngsd"} + Output + Integer response status 201 + + &{get}= GET /genre?id=${res.body.id} + Output + Integer response status 200 + Should Contain ${get.body.data} ${res.body} + Should Not Contain ${get.body.data} ${res2.body} + [Teardown] Run Keywords DELETE /genre/${res.body.id} + ... AND DELETE /genre/${res2.body.id} diff --git a/musics/Beethoven-125-4/Beethoven-125-4.ini b/musics/Beethoven-125-4/Beethoven-125-4.ini index d6dfdc4..9de9840 100644 --- a/musics/Beethoven-125-4/Beethoven-125-4.ini +++ b/musics/Beethoven-125-4/Beethoven-125-4.ini @@ -2,7 +2,7 @@ Name=Symphony No 9 in D Minor Artist=Beethoven Genre=Classical -Album= +Album=Symphony No 9 [Difficulties] TwoHands=0 diff --git a/musics/populate.py b/musics/populate.py index a63d134..9cdec4e 100755 --- a/musics/populate.py +++ b/musics/populate.py @@ -8,6 +8,23 @@ from configparser import ConfigParser url = os.environ.get("API_URL") +def getOrCreateAlbum(name, artistId): + res = requests.post(f"{url}/album", json={ + "name": name, + "artist": artistId, + }) + out = res.json() + print(out) + return out["id"] + +def getOrCreateGenre(name): + res = requests.post(f"{url}/genre", json={ + "name": name, + }) + out = res.json() + print(out) + return out["id"] + def getOrCreateArtist(name): res = requests.post(f"{url}/artist", json={ "name": name, @@ -21,15 +38,16 @@ def populateFile(path, midi, mxl): config.read(path) metadata = config["Metadata"]; dificulties = dict(config["Difficulties"]) + artistId = getOrCreateArtist(metadata["Artist"]) print(f"Populating {metadata['Name']}") res = requests.post(f"{url}/song", json={ "name": metadata["Name"], "midiPath": f"/musics/{midi}", "musicXmlPath": f"/musics/{mxl}", "difficulties": dificulties, - "artist": getOrCreateArtist(metadata["Artist"]), - # "album": metadata["Album"], - # "genre": metadata["Genre"], + "artist": artistId, + "album": getOrCreateAlbum(metadata["Album"], artistId), + "genre": getOrCreateGenre(metadata["Genre"]), }) print(res.json())