movie detail page + importer architecture fix
This commit is contained in:
@@ -427,6 +427,44 @@ fn default_export_format() -> String {
|
||||
"csv".to_string()
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize, Default)]
|
||||
pub struct PaginationQueryParams {
|
||||
pub limit: Option<u32>,
|
||||
pub offset: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize, utoipa::ToSchema)]
|
||||
pub struct MovieStatsDto {
|
||||
pub total_count: u64,
|
||||
pub avg_rating: Option<f64>,
|
||||
pub federated_count: u64,
|
||||
pub rating_histogram: [u64; 5],
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize, utoipa::ToSchema)]
|
||||
pub struct SocialReviewDto {
|
||||
pub user_display: String,
|
||||
pub rating: u8,
|
||||
pub comment: Option<String>,
|
||||
pub watched_at: String,
|
||||
pub is_federated: bool,
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize, utoipa::ToSchema)]
|
||||
pub struct SocialFeedResponse {
|
||||
pub items: Vec<SocialReviewDto>,
|
||||
pub total_count: u64,
|
||||
pub limit: u32,
|
||||
pub offset: u32,
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize, utoipa::ToSchema)]
|
||||
pub struct MovieDetailResponse {
|
||||
pub movie: MovieDto,
|
||||
pub stats: MovieStatsDto,
|
||||
pub reviews: SocialFeedResponse,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -219,6 +219,19 @@ mod tests {
|
||||
async fn get_user_history(&self, _: &UserId) -> Result<Vec<DiaryEntry>, DomainError> {
|
||||
panic!()
|
||||
}
|
||||
async fn get_movie_stats(
|
||||
&self,
|
||||
_: &MovieId,
|
||||
) -> Result<domain::models::MovieStats, DomainError> {
|
||||
panic!()
|
||||
}
|
||||
async fn get_movie_social_feed(
|
||||
&self,
|
||||
_: &MovieId,
|
||||
_: &PageParams,
|
||||
) -> Result<Paginated<FeedEntry>, DomainError> {
|
||||
panic!()
|
||||
}
|
||||
}
|
||||
#[cfg(feature = "federation")]
|
||||
#[async_trait::async_trait]
|
||||
@@ -352,6 +365,15 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
impl domain::ports::DocumentParser for Panic {
|
||||
fn parse(&self, _: &[u8], _: domain::models::FileFormat) -> Result<domain::models::ParsedFile, domain::models::ImportError> {
|
||||
panic!()
|
||||
}
|
||||
fn apply_mapping(&self, _: &domain::models::ParsedFile, _: &[domain::models::FieldMapping]) -> Vec<domain::models::AnnotatedRow> {
|
||||
panic!()
|
||||
}
|
||||
}
|
||||
|
||||
impl crate::ports::HtmlRenderer for Panic {
|
||||
fn render_diary_page(
|
||||
&self,
|
||||
@@ -408,6 +430,12 @@ mod tests {
|
||||
) -> Result<String, String> {
|
||||
panic!()
|
||||
}
|
||||
fn render_movie_detail_page(
|
||||
&self,
|
||||
_: application::ports::MovieDetailPageData,
|
||||
) -> Result<String, String> {
|
||||
panic!()
|
||||
}
|
||||
fn render_import_upload_page(&self, _: application::ports::ImportUploadPageData) -> Result<String, String> { panic!() }
|
||||
fn render_import_mapping_page(&self, _: application::ports::ImportMappingPageData) -> Result<String, String> { panic!() }
|
||||
fn render_import_preview_page(&self, _: application::ports::ImportPreviewPageData) -> Result<String, String> { panic!() }
|
||||
@@ -439,6 +467,7 @@ mod tests {
|
||||
review_repository: Arc::clone(&repo) as _,
|
||||
diary_repository: Arc::clone(&repo) as _,
|
||||
diary_exporter: Arc::clone(&repo) as _,
|
||||
document_parser: Arc::clone(&repo) as _,
|
||||
stats_repository: Arc::clone(&repo) as _,
|
||||
metadata_client: Arc::clone(&repo) as _,
|
||||
poster_fetcher: Arc::clone(&repo) as _,
|
||||
|
||||
@@ -13,12 +13,14 @@ use application::{
|
||||
DeleteReviewCommand, ExportCommand, LoginCommand, RegisterCommand, SyncPosterCommand,
|
||||
},
|
||||
queries::{
|
||||
GetActivityFeedQuery, GetReviewHistoryQuery, GetUserProfileQuery, GetUsersQuery,
|
||||
GetActivityFeedQuery, GetMovieSocialPageQuery, GetReviewHistoryQuery, GetUserProfileQuery,
|
||||
GetUsersQuery,
|
||||
},
|
||||
use_cases::{
|
||||
delete_review, export_diary as export_diary_uc, get_activity_feed as get_feed_uc,
|
||||
get_diary, get_review_history, get_user_profile as get_user_profile_uc, get_users,
|
||||
log_review, login as login_uc, register as register_uc, sync_poster,
|
||||
get_diary, get_movie_social_page, get_review_history,
|
||||
get_user_profile as get_user_profile_uc, get_users, log_review, login as login_uc,
|
||||
register as register_uc, sync_poster,
|
||||
},
|
||||
};
|
||||
use domain::{
|
||||
@@ -35,8 +37,10 @@ use crate::{
|
||||
ActivityFeedQueryParams, ActivityFeedResponse, DiaryEntryDto, DiaryQueryParams,
|
||||
DiaryResponse, DirectorStatDto, ExportQueryParams, FeedEntryDto, LogReviewData,
|
||||
LogReviewRequest, LoginRequest, LoginResponse, MonthActivityDto, MonthlyRatingDto,
|
||||
MovieDto, RegisterRequest, ReviewDto, ReviewHistoryResponse, UserProfileQueryParams,
|
||||
UserProfileResponse, UserStatsDto, UserSummaryDto, UserTrendsDto, UsersResponse,
|
||||
MovieDetailResponse, MovieDto, MovieStatsDto, PaginationQueryParams, RegisterRequest,
|
||||
ReviewDto, ReviewHistoryResponse, SocialFeedResponse, SocialReviewDto,
|
||||
UserProfileQueryParams, UserProfileResponse, UserStatsDto, UserSummaryDto, UserTrendsDto,
|
||||
UsersResponse,
|
||||
},
|
||||
errors::ApiError,
|
||||
extractors::AuthenticatedUser,
|
||||
@@ -241,6 +245,51 @@ pub async fn delete_review(
|
||||
}
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get, path = "/api/v1/movies/{movie_id}",
|
||||
params(("movie_id" = Uuid, Path, description = "Movie ID")),
|
||||
responses(
|
||||
(status = 200, body = MovieDetailResponse),
|
||||
(status = 404, description = "Movie not found"),
|
||||
)
|
||||
)]
|
||||
pub async fn get_movie_detail(
|
||||
State(state): State<AppState>,
|
||||
Path(movie_id): Path<Uuid>,
|
||||
Query(params): Query<PaginationQueryParams>,
|
||||
) -> Result<Json<MovieDetailResponse>, ApiError> {
|
||||
let limit = params.limit.unwrap_or(20);
|
||||
let offset = params.offset.unwrap_or(0);
|
||||
|
||||
let result = get_movie_social_page::execute(
|
||||
&state.app_ctx,
|
||||
GetMovieSocialPageQuery { movie_id, limit, offset },
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(Json(MovieDetailResponse {
|
||||
movie: movie_to_dto(&result.movie),
|
||||
stats: MovieStatsDto {
|
||||
total_count: result.stats.total_count,
|
||||
avg_rating: result.stats.avg_rating,
|
||||
federated_count: result.stats.federated_count,
|
||||
rating_histogram: result.stats.rating_histogram,
|
||||
},
|
||||
reviews: SocialFeedResponse {
|
||||
items: result.reviews.items.iter().map(|e| SocialReviewDto {
|
||||
user_display: e.user_display_name().to_string(),
|
||||
rating: e.review().rating().value(),
|
||||
comment: e.review().comment().map(|c| c.value().to_string()),
|
||||
watched_at: e.review().watched_at().to_string(),
|
||||
is_federated: e.review().is_remote(),
|
||||
}).collect(),
|
||||
total_count: result.reviews.total_count,
|
||||
limit: result.reviews.limit,
|
||||
offset: result.reviews.offset,
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
fn movie_to_dto(movie: &Movie) -> MovieDto {
|
||||
MovieDto {
|
||||
id: movie.id().value(),
|
||||
|
||||
@@ -14,11 +14,13 @@ use application::ports::{FollowersPageData, FollowingPageData};
|
||||
use application::{
|
||||
commands::{DeleteReviewCommand, ExportCommand, LoginCommand, RegisterCommand},
|
||||
ports::{
|
||||
HtmlPageContext, LoginPageData, NewReviewPageData, RegisterPageData, RemoteActorView,
|
||||
HtmlPageContext, LoginPageData, MovieDetailPageData, NewReviewPageData, RegisterPageData,
|
||||
RemoteActorView,
|
||||
},
|
||||
queries::GetMovieSocialPageQuery,
|
||||
use_cases::{
|
||||
delete_review, export_diary as export_diary_uc, log_review, login as login_uc,
|
||||
register as register_uc,
|
||||
delete_review, export_diary as export_diary_uc, get_movie_social_page, log_review,
|
||||
login as login_uc, register as register_uc,
|
||||
},
|
||||
};
|
||||
use domain::models::ExportFormat;
|
||||
@@ -916,3 +918,51 @@ pub async fn remove_follower(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_movie_detail(
|
||||
OptionalCookieUser(user_id): OptionalCookieUser,
|
||||
State(state): State<AppState>,
|
||||
Path(movie_id): Path<uuid::Uuid>,
|
||||
Query(params): Query<crate::dtos::PaginationQueryParams>,
|
||||
Extension(csrf): Extension<CsrfToken>,
|
||||
) -> impl IntoResponse {
|
||||
let ctx = build_page_context(&state, user_id, csrf.0).await;
|
||||
let limit = params.limit.unwrap_or(20);
|
||||
let offset = params.offset.unwrap_or(0);
|
||||
|
||||
match get_movie_social_page::execute(
|
||||
&state.app_ctx,
|
||||
GetMovieSocialPageQuery { movie_id, limit, offset },
|
||||
)
|
||||
.await
|
||||
{
|
||||
Err(DomainError::NotFound(_)) => StatusCode::NOT_FOUND.into_response(),
|
||||
Err(DomainError::ValidationError(_)) => StatusCode::BAD_REQUEST.into_response(),
|
||||
Err(e) => {
|
||||
tracing::error!("movie detail error: {:?}", e);
|
||||
StatusCode::INTERNAL_SERVER_ERROR.into_response()
|
||||
}
|
||||
Ok(result) => {
|
||||
let histogram_max = result.stats.rating_histogram.iter().copied().max().unwrap_or(1);
|
||||
let has_more = result.reviews.offset + result.reviews.limit
|
||||
< result.reviews.total_count as u32;
|
||||
let data = MovieDetailPageData {
|
||||
ctx,
|
||||
movie: result.movie,
|
||||
stats: result.stats,
|
||||
current_offset: result.reviews.offset,
|
||||
has_more,
|
||||
limit: result.reviews.limit,
|
||||
reviews: result.reviews,
|
||||
histogram_max,
|
||||
};
|
||||
match state.html_renderer.render_movie_detail_page(data) {
|
||||
Ok(html) => Html(html).into_response(),
|
||||
Err(e) => {
|
||||
tracing::error!("template error: {}", e);
|
||||
StatusCode::INTERNAL_SERVER_ERROR.into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ use std::collections::HashMap;
|
||||
use application::{
|
||||
commands::{
|
||||
ApplyImportMappingCommand, CreateImportSessionCommand, DeleteImportProfileCommand,
|
||||
ExecuteImportCommand, FileFormat, SaveImportProfileCommand,
|
||||
ExecuteImportCommand, SaveImportProfileCommand,
|
||||
},
|
||||
ports::{
|
||||
ImportMappingPageData, ImportPreviewPageData, ImportPreviewRow, ImportProfileView,
|
||||
@@ -21,8 +21,8 @@ use application::{
|
||||
list_import_profiles, save_import_profile,
|
||||
},
|
||||
};
|
||||
use domain::models::{AnnotatedRow, FieldMapping, FileFormat, import::{DomainField, RowResult, Transform}};
|
||||
use domain::value_objects::ImportSessionId;
|
||||
use importer::{AnnotatedRow, DomainField, FieldMapping, RowResult, Transform};
|
||||
|
||||
use crate::{
|
||||
csrf::CsrfToken,
|
||||
@@ -220,7 +220,7 @@ pub async fn get_mapping_page(
|
||||
else {
|
||||
return Redirect::to("/import").into_response();
|
||||
};
|
||||
let Ok(parsed) = serde_json::from_str::<importer::ParsedFile>(&session.parsed_data) else {
|
||||
let Some(parsed) = session.parsed_file else {
|
||||
return Redirect::to("/import").into_response();
|
||||
};
|
||||
|
||||
@@ -318,13 +318,8 @@ pub async fn get_preview_page(
|
||||
return Redirect::to(&format!("/import/{}/mapping", session_id_str)).into_response();
|
||||
}
|
||||
|
||||
let parsed =
|
||||
serde_json::from_str::<importer::ParsedFile>(&session.parsed_data).unwrap_or_default();
|
||||
let annotated: Vec<AnnotatedRow> = session
|
||||
.row_results
|
||||
.as_deref()
|
||||
.and_then(|s| serde_json::from_str(s).ok())
|
||||
.unwrap_or_default();
|
||||
let parsed = session.parsed_file.unwrap_or_default();
|
||||
let annotated: Vec<AnnotatedRow> = session.row_results.unwrap_or_default();
|
||||
|
||||
let rows: Vec<ImportPreviewRow> = annotated
|
||||
.iter()
|
||||
@@ -589,8 +584,7 @@ pub async fn api_get_session(
|
||||
.await
|
||||
{
|
||||
Ok(Some(session)) => {
|
||||
let parsed = serde_json::from_str::<importer::ParsedFile>(&session.parsed_data)
|
||||
.unwrap_or_default();
|
||||
let parsed = session.parsed_file.unwrap_or_default();
|
||||
let row_count = parsed.rows.len();
|
||||
axum::Json(SessionStateResponse {
|
||||
session_id: session_id_str,
|
||||
|
||||
@@ -7,6 +7,7 @@ use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
||||
|
||||
use application::{config::AppConfig, context::AppContext};
|
||||
use export::ExportAdapter;
|
||||
use importer::ImporterDocumentParser;
|
||||
use rss::RssAdapter;
|
||||
use template_askama::AskamaHtmlRenderer;
|
||||
|
||||
@@ -14,7 +15,7 @@ use doc::ApiDocExt;
|
||||
use presentation::{openapi::ApiDoc, routes, state::AppState};
|
||||
use utoipa::OpenApi as _;
|
||||
|
||||
use domain::ports::{DiaryExporter, EventPublisher, ImportProfileRepository, ImportSessionRepository};
|
||||
use domain::ports::{DiaryExporter, DocumentParser, EventPublisher, ImportProfileRepository, ImportSessionRepository};
|
||||
|
||||
#[cfg(not(any(feature = "sqlite", feature = "postgres")))]
|
||||
compile_error!("At least one database backend must be enabled. Use --features sqlite or --features postgres");
|
||||
@@ -150,6 +151,7 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> {
|
||||
review_repository,
|
||||
diary_repository,
|
||||
diary_exporter: Arc::new(ExportAdapter) as Arc<dyn DiaryExporter>,
|
||||
document_parser: Arc::new(ImporterDocumentParser) as Arc<dyn DocumentParser>,
|
||||
stats_repository,
|
||||
metadata_client,
|
||||
poster_fetcher,
|
||||
|
||||
@@ -6,9 +6,9 @@ use utoipa::{
|
||||
use crate::dtos::{
|
||||
ActivityFeedResponse, DiaryEntryDto, DiaryResponse,
|
||||
DirectorStatDto, FeedEntryDto, LoginRequest, LoginResponse, LogReviewRequest,
|
||||
MonthActivityDto, MonthlyRatingDto, MovieDto, RegisterRequest, ReviewDto,
|
||||
ReviewHistoryResponse, UserProfileResponse, UserStatsDto, UserSummaryDto, UserTrendsDto,
|
||||
UsersResponse,
|
||||
MonthActivityDto, MonthlyRatingDto, MovieDetailResponse, MovieDto, MovieStatsDto,
|
||||
RegisterRequest, ReviewDto, ReviewHistoryResponse, SocialFeedResponse, SocialReviewDto,
|
||||
UserProfileResponse, UserStatsDto, UserSummaryDto, UserTrendsDto, UsersResponse,
|
||||
};
|
||||
use crate::handlers::import::{
|
||||
ApiFieldMapping, ApplyMappingRequest, ConfirmRequest, SaveProfileRequest,
|
||||
@@ -40,6 +40,7 @@ impl Modify for SecurityAddon {
|
||||
paths(
|
||||
crate::handlers::api::get_diary,
|
||||
crate::handlers::api::get_review_history,
|
||||
crate::handlers::api::get_movie_detail,
|
||||
crate::handlers::api::post_review,
|
||||
crate::handlers::api::delete_review,
|
||||
crate::handlers::api::sync_poster,
|
||||
@@ -67,6 +68,10 @@ impl Modify for SecurityAddon {
|
||||
LoginResponse,
|
||||
RegisterRequest,
|
||||
ReviewHistoryResponse,
|
||||
MovieDetailResponse,
|
||||
MovieStatsDto,
|
||||
SocialFeedResponse,
|
||||
SocialReviewDto,
|
||||
ActivityFeedResponse,
|
||||
FeedEntryDto,
|
||||
UsersResponse,
|
||||
@@ -99,6 +104,7 @@ pub struct ApiDoc;
|
||||
paths(
|
||||
crate::handlers::api::get_diary,
|
||||
crate::handlers::api::get_review_history,
|
||||
crate::handlers::api::get_movie_detail,
|
||||
crate::handlers::api::post_review,
|
||||
crate::handlers::api::delete_review,
|
||||
crate::handlers::api::sync_poster,
|
||||
@@ -134,6 +140,10 @@ pub struct ApiDoc;
|
||||
LoginResponse,
|
||||
RegisterRequest,
|
||||
ReviewHistoryResponse,
|
||||
MovieDetailResponse,
|
||||
MovieStatsDto,
|
||||
SocialFeedResponse,
|
||||
SocialReviewDto,
|
||||
ActorListResponse,
|
||||
RemoteActorDto,
|
||||
FollowRequest,
|
||||
|
||||
@@ -50,6 +50,10 @@ fn html_routes(rate_limit: u64) -> Router<AppState> {
|
||||
"/users/{id}",
|
||||
routing::get(handlers::html::get_user_profile),
|
||||
)
|
||||
.route(
|
||||
"/movies/{movie_id}",
|
||||
routing::get(handlers::html::get_movie_detail),
|
||||
)
|
||||
.merge(auth)
|
||||
.route(
|
||||
"/reviews/new",
|
||||
@@ -131,6 +135,10 @@ fn api_routes(rate_limit: u64) -> Router<AppState> {
|
||||
"/movies/{id}/history",
|
||||
routing::get(handlers::api::get_review_history),
|
||||
)
|
||||
.route(
|
||||
"/movies/{id}",
|
||||
routing::get(handlers::api::get_movie_detail),
|
||||
)
|
||||
.route("/reviews", routing::post(handlers::api::post_review))
|
||||
.route(
|
||||
"/reviews/{id}",
|
||||
|
||||
Reference in New Issue
Block a user