diff --git a/backend/src/tv/models.py b/backend/src/tv/models.py index 051ed29..dc8fc6c 100644 --- a/backend/src/tv/models.py +++ b/backend/src/tv/models.py @@ -3,6 +3,7 @@ from uuid import UUID from sqlalchemy import ForeignKey, PrimaryKeyConstraint, UniqueConstraint from sqlalchemy.orm import Mapped, mapped_column, relationship +from auth.db import User from backend.src.database import Base from torrent.models import Quality @@ -36,6 +37,7 @@ class Season(Base): episodes: Mapped[list["Episode"]] = relationship(back_populates="season", cascade="all, delete") season_files = relationship("SeasonFile", back_populates="season", cascade="all, delete") + season_requests = relationship("SeasonRequest", back_populates="season", cascade="all, delete") class Episode(Base): @@ -74,6 +76,10 @@ class SeasonRequest(Base): season_id: Mapped[UUID] = mapped_column(ForeignKey(column="season.id", ondelete="CASCADE"), ) wanted_quality: Mapped[Quality] min_quality: Mapped[Quality] - requested_by: Mapped[UUID | None] = mapped_column(ForeignKey(column="user.id", ondelete="SET NULL"), ) + requested_by_id: Mapped[UUID | None] = mapped_column(ForeignKey(column="user.id", ondelete="SET NULL"), ) authorized: Mapped[bool] = mapped_column(default=False) - authorized_by: Mapped[UUID | None] = mapped_column(ForeignKey(column="user.id", ondelete="SET NULL"), ) + authorized_by_id: Mapped[UUID | None] = mapped_column(ForeignKey(column="user.id", ondelete="SET NULL"), ) + + requested_by: Mapped["User|None"] = relationship(foreign_keys=[requested_by_id], uselist=False) + authorized_by: Mapped["User|None"] = relationship(foreign_keys=[authorized_by_id], uselist=False) + season = relationship("Season", back_populates="season_requests", uselist=False) diff --git a/backend/src/tv/repository.py b/backend/src/tv/repository.py index 21fed5f..952d0c1 100644 --- a/backend/src/tv/repository.py +++ b/backend/src/tv/repository.py @@ -1,6 +1,4 @@ -import pprint - -from sqlalchemy import select, delete, update +from sqlalchemy import select, delete from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session, joinedload @@ -121,12 +119,13 @@ def add_season_to_requested_list(season_request: SeasonRequestSchema, db: Sessio db.commit() -def remove_season_from_requested_list(season_request: SeasonRequestSchema, db: Session) -> None: +def delete_season_request(season_request_id: SeasonRequestId, db: Session) -> None: """ Removes a Season from the SeasonRequest table, which removes it from the 'requested' list. """ - db.delete(SeasonRequest(**season_request.model_dump())) + stmt = delete(SeasonRequest).where(SeasonRequest.id == season_request_id) + db.execute(stmt) db.commit() @@ -138,10 +137,16 @@ def get_season_by_number(db: Session, season_number: int, show_id: ShowId) -> Se def get_season_requests(db: Session) -> list[RichSeasonRequestSchema]: - stmt = select(SeasonRequest).join(Season, Season.id == SeasonRequest.season_id).join(Show, - Season.show_id == Show.id) - result = db.execute(stmt).scalars().all() - return [RichSeasonRequestSchema.model_validate(season) for season in result] + stmt = select(SeasonRequest).options(joinedload(SeasonRequest.requested_by), + joinedload(SeasonRequest.authorized_by), + joinedload(SeasonRequest.season).joinedload(Season.show)) + result = db.execute(stmt).scalars().unique().all() + return [RichSeasonRequestSchema(min_quality=x.min_quality, + wanted_quality=x.wanted_quality, show=x.season.show, season=x.season, + requested_by=x.requested_by, authorized_by=x.authorized_by, authorized=x.authorized, + id=x.id) + for x in result] + def add_season_file(db: Session, season_file: SeasonFileSchema) -> SeasonFileSchema: db.add(SeasonFile(**season_file.model_dump())) @@ -209,5 +214,3 @@ def update_season_request(db: Session, season_request: SeasonRequestSchema) -> N db.delete(db.get(SeasonRequest, season_request.id)) db.add(SeasonRequest(**season_request.model_dump())) db.commit() - - diff --git a/backend/src/tv/router.py b/backend/src/tv/router.py index 7a1dd35..1e65abe 100644 --- a/backend/src/tv/router.py +++ b/backend/src/tv/router.py @@ -6,6 +6,7 @@ from fastapi.responses import JSONResponse import tv.repository import tv.service from auth.db import User +from auth.schemas import UserRead from auth.users import current_active_user, current_superuser from backend.src.database import DbSessionDependency from indexer.schemas import PublicIndexerQueryResult, IndexerQueryResultId @@ -13,7 +14,7 @@ from metadataProvider.schemas import MetaDataProviderShowSearchResult from torrent.schemas import Torrent from tv.exceptions import MediaAlreadyExists from tv.schemas import Show, SeasonRequest, ShowId, RichShowTorrent, PublicShow, PublicSeasonFile, SeasonNumber, \ - CreateSeasonRequest, SeasonRequestId, UpdateSeasonRequest, RichSeasonRequest, SeasonId + CreateSeasonRequest, SeasonRequestId, UpdateSeasonRequest, RichSeasonRequest router = APIRouter() @@ -82,50 +83,51 @@ def get_season_files(db: DbSessionDependency, season_number: SeasonNumber, show_ # MANAGE REQUESTS # -------------------------------- -@router.post("/seasons/requests", status_code=status.HTTP_200_OK, response_model=RichSeasonRequest) +@router.post("/seasons/requests", status_code=status.HTTP_204_NO_CONTENT) def request_a_season(db: DbSessionDependency, user: Annotated[User, Depends(current_active_user)], season_request: CreateSeasonRequest): """ adds request flag to a season """ request: SeasonRequest = SeasonRequest.model_validate(season_request) - request.requested_by = user.id + request.requested_by = UserRead.model_validate(user) tv.service.request_season(db=db, season_request=request) - return request + return -@router.get("/seasons/requests", status_code=status.HTTP_200_OK, dependencies=[Depends(current_active_user)]) -def get_requested_seasons(db: DbSessionDependency) -> list[SeasonRequest]: +@router.get("/seasons/requests", status_code=status.HTTP_200_OK, dependencies=[Depends(current_active_user)], + response_model=list[RichSeasonRequest]) +def get_season_requests(db: DbSessionDependency) -> list[RichSeasonRequest]: return tv.service.get_all_season_requests(db=db) -@router.patch("/seasons/requests/{season_request_id}", status_code=status.HTTP_200_OK, response_model=SeasonRequest) +@router.patch("/seasons/requests/{season_request_id}", status_code=status.HTTP_204_NO_CONTENT) def authorize_request(db: DbSessionDependency, user: Annotated[User, Depends(current_superuser)], season_request_id: SeasonRequestId, authorized_status: bool = False): """ updates the request flag to true """ season_request: SeasonRequest = tv.repository.get_season_request(db=db, season_request_id=season_request_id) - season_request.authorized_by = user.id + season_request.authorized_by = UserRead.model_validate(user) season_request.authorized = authorized_status tv.service.update_season_request(db=db, season_request=season_request) - return season_request + return -@router.put("/seasons/requests/{season_request_id}", status_code=status.HTTP_200_OK, response_model=SeasonRequest) +@router.put("/seasons/requests/{season_request_id}", status_code=status.HTTP_204_NO_CONTENT) def update_request(db: DbSessionDependency, user: Annotated[User, Depends(current_superuser)], season_request: UpdateSeasonRequest): season_request: SeasonRequest = SeasonRequest.model_validate(season_request) - season_request.requested_by = user.id + season_request.requested_by = UserRead.model_validate(user) tv.service.update_season_request(db=db, season_request=season_request) - return season_request + return @router.delete("/seasons/requests/{season_request_id}", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(current_active_user)]) -def delete_season_request(db: DbSessionDependency, request: SeasonRequest): - tv.service.unrequest_season(db=db, season_request=request) +def delete_season_request(db: DbSessionDependency, request_id: SeasonRequestId): + tv.service.delete_season_request(db=db, season_request_id=request_id) # -------------------------------- diff --git a/backend/src/tv/schemas.py b/backend/src/tv/schemas.py index 5a53669..868a95d 100644 --- a/backend/src/tv/schemas.py +++ b/backend/src/tv/schemas.py @@ -5,6 +5,7 @@ from uuid import UUID from pydantic import BaseModel, Field, ConfigDict from tvdb_v4_official import Request +from auth.schemas import UserRead from torrent.models import Quality from torrent.schemas import TorrentId, TorrentStatus @@ -55,12 +56,12 @@ class Show(BaseModel): class SeasonRequestBase(BaseModel): - season_id: SeasonId min_quality: Quality wanted_quality: Quality class CreateSeasonRequest(SeasonRequestBase): + season_id: SeasonId pass @@ -72,17 +73,15 @@ class SeasonRequest(SeasonRequestBase): model_config = ConfigDict(from_attributes=True) id: SeasonRequestId = Field(default_factory=uuid.uuid4) + season: Season - requested_by: UUID | None = None + requested_by: UserRead | None = None authorized: bool = False - authorized_by: UUID | None = None + authorized_by: UserRead | None = None class RichSeasonRequest(SeasonRequest): - show_id: ShowId - show_name: str - show_year: int | None - season_number: SeasonNumber + show: Show class SeasonFile(BaseModel): model_config = ConfigDict(from_attributes=True) diff --git a/backend/src/tv/service.py b/backend/src/tv/service.py index 532cf89..97d704c 100644 --- a/backend/src/tv/service.py +++ b/backend/src/tv/service.py @@ -15,7 +15,7 @@ from tv import log from tv.exceptions import MediaAlreadyExists from tv.repository import add_season_file, get_season_files_by_season_id from tv.schemas import Show, ShowId, SeasonRequest, SeasonFile, SeasonId, Season, RichShowTorrent, RichSeasonTorrent, \ - PublicSeason, PublicShow, PublicSeasonFile, SeasonNumber, SeasonRequestId + PublicSeason, PublicShow, PublicSeasonFile, SeasonNumber, SeasonRequestId, RichSeasonRequest def add_show(db: Session, external_id: int, metadata_provider: str) -> Show | None: @@ -34,8 +34,9 @@ def request_season(db: Session, season_request: SeasonRequest) -> None: def update_season_request(db: Session, season_request: SeasonRequest) -> None: tv.repository.update_season_request(db=db, season_request=season_request) -def unrequest_season(db: Session, season_request: SeasonRequest) -> None: - tv.repository.remove_season_from_requested_list(db=db, season_request=season_request) + +def delete_season_request(db: Session, season_request_id: SeasonRequestId) -> None: + tv.repository.delete_season_request(db=db, season_request_id=season_request_id) def get_public_season_files_by_season_id(db: Session, season_id: SeasonId) -> list[PublicSeasonFile]: @@ -148,7 +149,7 @@ def get_season(db: Session, season_id: SeasonId) -> Season: return tv.repository.get_season(season_id=season_id, db=db) -def get_all_season_requests(db: Session) -> list[SeasonRequest]: +def get_all_season_requests(db: Session) -> list[RichSeasonRequest]: return tv.repository.get_season_requests(db=db) diff --git a/web/src/lib/components/login-form.svelte b/web/src/lib/components/login-form.svelte index 2011474..6909813 100644 --- a/web/src/lib/components/login-form.svelte +++ b/web/src/lib/components/login-form.svelte @@ -80,7 +80,7 @@ if (response.ok) { console.log('Registration successful!'); console.log('Received User Data: ', response); - goto('/dashboard'); + tabValue = "login"; // Switch to login tab after successful registration errorMessage = 'Registration successful! Redirecting...'; } else { let errorText = await response.text(); diff --git a/web/src/lib/components/nav-user.svelte b/web/src/lib/components/nav-user.svelte index cd1a9cb..f90db43 100644 --- a/web/src/lib/components/nav-user.svelte +++ b/web/src/lib/components/nav-user.svelte @@ -17,23 +17,9 @@ import {base} from '$app/paths'; import {env} from "$env/dynamic/public"; import {goto} from '$app/navigation'; - + import {handleLogout} from '$lib/utils.ts'; const user: () => User = getContext('user'); const sidebar = useSidebar(); - const apiUrl = env.PUBLIC_API_URL; - - async function handleLogout() { - const response = await fetch(apiUrl + '/auth/cookie/logout', { - method: 'POST', - credentials: 'include' - }); - if (response.ok) { - console.log('Logout successful!'); - await goto(base + '/login'); - } else { - console.error('Logout failed:', response.status); - } - } diff --git a/web/src/lib/components/season-requests-table.svelte b/web/src/lib/components/season-requests-table.svelte new file mode 100644 index 0000000..d7070fc --- /dev/null +++ b/web/src/lib/components/season-requests-table.svelte @@ -0,0 +1,62 @@ + + + + A list of all requests. + + + Show + Season + Minimum Quality + Wanted Quality + Requested by + Approved + Approved by + Actions + + + + {#each requests as request (request.id)} + {#if filter(request)} + + + {getFullyQualifiedShowName(request.show)} + + + {request.season.number} + + + {getTorrentQualityString(request.min_quality)} + + + {getTorrentQualityString(request.wanted_quality)} + + + {request.requested_by?.email} + + + + + + {request.authorized_by?.email} + + + {/if} + {/each} + + \ No newline at end of file diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index 60c93fd..999ccb1 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -94,11 +94,6 @@ export interface PublicSeason { episodes: Episode[]; // items: { $ref: #/components/schemas/Episode }, type: array id?: string; // type: string, format: uuid } -export interface SeasonRequest { - season_id: string; // type: string, format: uuid - min_quality: Quality; // $ref: #/components/schemas/Quality - wanted_quality: Quality; // $ref: #/components/schemas/Quality -} export interface Show { name: string; @@ -172,3 +167,25 @@ export interface RichShowTorrent { metadata_provider: string; torrents: RichSeasonTorrent[]; } + +interface SeasonRequestBase { + min_quality: Quality; + wanted_quality: Quality; +} + +export interface CreateSeasonRequest extends SeasonRequestBase { + season_id: string; +} + +export interface UpdateSeasonRequest extends SeasonRequestBase { + id: string; +} + +export interface SeasonRequest extends SeasonRequestBase { + id: string; + season: Season; + requested_by?: User; + authorized: boolean; + authorized_by?: User; + show: Show; +} \ No newline at end of file diff --git a/web/src/lib/utils.ts b/web/src/lib/utils.ts index c9540a3..1603cab 100644 --- a/web/src/lib/utils.ts +++ b/web/src/lib/utils.ts @@ -1,5 +1,10 @@ import {type ClassValue, clsx} from 'clsx'; import {twMerge} from 'tailwind-merge'; +import {env} from "$env/dynamic/public"; +import {goto} from '$app/navigation'; +import {base} from '$app/paths'; + +const apiUrl = env.PUBLIC_API_URL; export const qualityMap: { [key: number]: string } = { 1: 'high', @@ -42,4 +47,17 @@ export function convertTorrentSeasonRangeToIntegerRange(torrent: any): string { return "Error parsing season range: " + torrent.seasons; } +} + +export async function handleLogout(): null { + const response = await fetch(apiUrl + '/auth/cookie/logout', { + method: 'POST', + credentials: 'include' + }); + if (response.ok) { + console.log('Logout successful!'); + await goto(base + '/login'); + } else { + console.error('Logout failed:', response.status); + } } \ No newline at end of file diff --git a/web/src/routes/+page.svelte b/web/src/routes/+page.svelte index db82c8f..827fc14 100644 --- a/web/src/routes/+page.svelte +++ b/web/src/routes/+page.svelte @@ -1,4 +1,11 @@ diff --git a/web/src/routes/dashboard/tv/[showId=uuid]/+layout.ts b/web/src/routes/dashboard/tv/[showId=uuid]/+layout.ts index fbf46fe..36a729e 100644 --- a/web/src/routes/dashboard/tv/[showId=uuid]/+layout.ts +++ b/web/src/routes/dashboard/tv/[showId=uuid]/+layout.ts @@ -8,12 +8,11 @@ export const load: LayoutLoad = async ({params, fetch}) => { return { showData: null, torrentsData: null, - error: 'Show ID is missing' }; } try { - const response = await fetch(`${env.PUBLIC_API_URL}/tv/shows/${showId}`, { + const show = await fetch(`${env.PUBLIC_API_URL}/tv/shows/${showId}`, { method: 'GET', headers: { 'Content-Type': 'application/json' @@ -29,16 +28,15 @@ export const load: LayoutLoad = async ({params, fetch}) => { credentials: 'include' }); - if (!response.ok || !torrents.ok) { - console.error(`Failed to fetch show ${showId}: ${response.statusText}`); + if (!show.ok || !torrents.ok) { + console.error(`Failed to fetch show ${showId}: ${show.statusText}`); return { showData: null, torrentsData: null, - error: `Failed to load show or/and its torrents: ${response.statusText}` }; } - const showData = await response.json(); + const showData = await show.json(); const torrentsData = await torrents.json(); console.log('Fetched show data:', showData); console.log('Fetched torrents data:', torrentsData); @@ -52,7 +50,6 @@ export const load: LayoutLoad = async ({params, fetch}) => { return { showData: null, torrentsData: null, - error: 'An error occurred while fetching show data.' }; } }; diff --git a/web/src/routes/dashboard/tv/requests/+layout.ts b/web/src/routes/dashboard/tv/requests/+layout.ts new file mode 100644 index 0000000..39386c1 --- /dev/null +++ b/web/src/routes/dashboard/tv/requests/+layout.ts @@ -0,0 +1,33 @@ +import {env} from '$env/dynamic/public'; +import type {LayoutLoad} from './$types'; + +export const load: LayoutLoad = async ({params, fetch}) => { + try { + const requests = await fetch(`${env.PUBLIC_API_URL}/tv/seasons/requests`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json' + }, + credentials: 'include' + }); + + if (!requests.ok) { + console.error(`Failed to fetch season requests ${requests.statusText}`); + return { + requestsData: null, + }; + } + + const requestsData = await requests.json(); + console.log('Fetched season requests:', requestsData); + + return { + requestsData: requestsData, + }; + } catch (error) { + console.error('Error fetching season requests:', error); + return { + requestsData: null, + }; + } +}; diff --git a/web/src/routes/dashboard/tv/requests/+page.svelte b/web/src/routes/dashboard/tv/requests/+page.svelte new file mode 100644 index 0000000..249163e --- /dev/null +++ b/web/src/routes/dashboard/tv/requests/+page.svelte @@ -0,0 +1,49 @@ + + +
+
+ + + + + + + +
+
+ +
+

+ Season Requests +

+ +
diff --git a/web/src/routes/login/verify/+page.svelte b/web/src/routes/login/verify/+page.svelte index 2d1b934..dfd8a20 100644 --- a/web/src/routes/login/verify/+page.svelte +++ b/web/src/routes/login/verify/+page.svelte @@ -2,7 +2,7 @@ import {UserCheck} from 'lucide-svelte'; import {Button} from '$lib/components/ui/button/index.js'; import Logo from '$lib/components/logo-side-by-side.svelte'; - + import {handleLogout} from '$lib/utils.ts'; @@ -10,10 +10,11 @@
+
+ +
- - -
+

@@ -30,6 +31,7 @@ The above button will only work once your account is verified.

- If you have any questions, please contact an administrator.

+ If you have any questions, please contact an administrator. +

\ No newline at end of file