マイリストからの評価値の削除
バックエンド
ビュー
src/backend/api/online/views.py
...(略)...
class RatingView(APIView):
"""評価値ビュー"""
permission_classes = [IsAuthenticated]
def get(self, request, format=None):
...(略)...
def post(self, request, format=None):
...(略)...
# ↓追加
def delete(self, request, format=None):
"""
対象ユーザの対象映画に対する評価値を削除する。
Requests
--------
user : User
ユーザ
movie_id : int
映画ID
"""
# ユーザ認証
user = _get_user(request)
if user is None:
return Response(
{'detail': 'Authentication required.'},
status=status.HTTP_401_UNAUTHORIZED,
)
# リクエストパラメタの取得
movie_id = request.data['movie_id']
# オブジェクトの削除
with transaction.atomic():
movie = get_object_or_404(Movie, pk=movie_id)
rating = UserMovieRating.objects.filter(user=user, movie=movie).first()
rating.delete() if rating else None
# レスポンス
return Response({'detail': 'Deleted.'}, status.HTTP_200_OK)
# ↑追加
実行確認
サインインビューの作成の手順にしたがってサインインし、アクセストークンを取得してください。取得したトークンを用いて、バックエンドサーバが起動している状態で、下記コマンドを実行してください。
$
curl -X DELETE http://localhost:8000/api/online/ratings/ \
-H "Content-Type: application/json" \
-H "Authorization: Bearer <ACCESS_TOKEN>" \
-d '{"movie_id": 1}'
{"detail":"Deleted."}
サインインユーザの対象アイテムへの評価値が削除されます。
t_users_movies_ratingsテーブルから該当のアイテムに対する評価値が削除されていることを確認してください。
recsys_full=#
SELECT * FROM t_users_movies_rating ORDER BY rated_at DESC;
フロントエンド
定数の定義
src/frontend/src/constants/styles.ts
/**
* スタイル関連の定数
*/
export const STYLES = {
...(略)...
// 映画カード関連
MOVIE_CARD: `relative flex h-56 w-32 flex-col rounded-lg border border-gray-300 bg-white shadow-lg`, // <- relativeを追加
...(略)...
MOVIE_STAR_WIDTH: 24,
MOVIE_ICON_DELETE: `absolute! top-1! right-1! bg-gray-100! hover:bg-white!`, // <- 追加
...(略)...
} as const;
API
src/frontend/src/api/ratings/deleteRating.ts
import { ApiContext } from "@/types/data";
import { ERROR_MESSAGES } from "@/constants";
import { fetcher } from "@/utils";
const context: ApiContext = {
apiRootUrl: process.env.NEXT_PUBLIC_API_BASE_URL,
};
/**
* 評価値削除API
* @param movieId - 映画ID
*/
const deleteRating = async (movieId: number): Promise<void> => {
const access =
typeof window !== "undefined" ? localStorage.getItem("access") : null;
if (!access) throw new Error(ERROR_MESSAGES.USER_NOT_AUTHENTICATED);
const url = `${context.apiRootUrl?.replace(/\/$/g, "")}/online/ratings/`;
const headers: Record<string, string> = {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: `Bearer ${access}`,
};
const body = JSON.stringify({
movie_id: movieId,
});
return await fetcher(url, {
method: "DELETE",
headers: headers,
body: body,
});
};
export default deleteRating;
コンポーネント
src/frontend/src/app/components/ui/card/CardMovie.tsx
'use client';
import React, { useCallback, useMemo } from 'react';
import Image from 'next/image';
import Link from 'next/link';
import { IconButton } from '@mui/material'; // <- 追加
import HighlightOffIcon from '@mui/icons-material/HighlightOff'; // <- 追加
...(略)...
interface Props {
movie: Movie;
user?: User | null;
isMyList?: boolean; // <- 追加
handleRatingClick: (movie: Movie) => void;
handleDelete?: (movie: Movie) => void; // <- 追加
}
/**
* 映画カードコンポーネント
*/
const CardMovie = (props: Props) => {
...(略)...
return (
<article className={`${STYLES.MOVIE_CARD}`} key={props.movie.movie_id}>
...(略)...
{/* ↓追加 */}
{props.isMyList && props.handleDelete && (
<IconButton
size="small"
onClick={() => props.handleDelete?.(props.movie)}
className={`${STYLES.MOVIE_ICON_DELETE}`}
>
<HighlightOffIcon fontSize="small" />
</IconButton>
)}
{/* ↑追加 */}
</article>
);
};
export default CardMovie;
src/frontend/src/app/components/ui/list/ListMovie.tsx
...(略)...
import { Movie, User } from '@/types/data';
import { STYLES } from '@/constants';
import deleteRating from '@/api/ratings/deleteRating'; // <- 追加
import CardMovie from '../card/CardMovie';
interface Props {
phrase: string;
user?: User | null;
movies: Movie[];
isMyList?: boolean; // <- 追加
}
/**
* 映画リストコンポーネント
*/
const ListMovie = (props: Props) => {
...(略)...
useEffect(() => {
if (movies.length === 0 || perPage <= 0) {
setCurrentMovies([]);
return;
}
const start = perPage * currentPage;
const end = start + perPage;
const currentMovies_ = movies.slice(start, end);
// ↓修正
if (currentMovies_.length <= 0) {
let currentPage_ = currentPage <= 0 ? 0 : currentPage - 1;
setCurrentPage(currentPage_);
} else {
setCurrentMovies(currentMovies_);
}
// ↑修正
}, [movies, perPage, currentPage]);
...(略)...
const handleRatingClick = useCallback((movie: Movie) => {
setMovies((prev) =>
prev.map((m) => (m.movie_id === movie.movie_id ? movie : m)),
);
}, []);
// ↓追加
const handleDelete = async (movie: Movie) => {
try {
await deleteRating(movie.movie_id);
const movies_ = movies.filter(
(movie_) => movie_.movie_id != movie.movie_id,
);
setMovies(movies_);
} catch (e) {
console.error("Failed to delete rating:", e);
}
};
// ↑追加
...(略)...
return (
<>
<div className={`${STYLES.LIST_MOVIE_LABEL_PHRASE}`}>{props.phrase}</div>
<div className={`${STYLES.LIST_MOVIE}`}>
...(略)...
<div ref={containerRef} className={`${STYLES.LIST_MOVIE_INSIDE}`}>
{currentMovies.map((movie, index) => (
<CardMovie
movie={movie}
user={props.user}
isMyList={props.isMyList} // <- 追加
handleRatingClick={handleRatingClick}
handleDelete={handleDelete} // <- 追加
key={movie.movie_id}
/>
))}
</div>
...(略)...
</div>
</>
);
};
export default ListMovie;
src/frontend/src/app/components/ui/list/ListMovieRated.tsx
...(略)...
/**
* 評価済み映画リストコンポーネント
*/
const ListMovieRated = () => {
...(略)...
return (
<>
<ListMovie phrase={phrase} user={loadingUser ? null : user} movies={movies} isMyList={true} />
{/* <- isMyListを追加 */}
</>
);
};
export default ListMovieRated;
実行確認
ブラウザで下記 URL にアクセスしてください。
マイリストから削除アイコンをクリックすると、評価値が削除されます。