diff --git a/client/src/EditModals/EditModal.tsx b/client/src/EditModals/EditModal.tsx index 40a9397..6f4ced7 100644 --- a/client/src/EditModals/EditModal.tsx +++ b/client/src/EditModals/EditModal.tsx @@ -1,5 +1,5 @@ import Modal from "react-bootstrap/Modal"; -import { MusicianProps } from "../Musicians/Musician/Musician"; +import { MusicianObj } from "../Musicians/Musician/Musician"; import { GroupObj } from "../Group/Group"; import { EventSeriesObj } from "../Series/SeriesList"; @@ -8,8 +8,9 @@ interface EditModalProps { show: boolean; onHide: () => void; form: JSX.Element; - entity?: MusicianProps | GroupObj | EventSeriesObj; + entity?: MusicianObj | GroupObj | EventSeriesObj; error?: string; + livestream_id?: string; } function EditModal(props: EditModalProps) { diff --git a/client/src/Forms/Bio/BioForm.tsx b/client/src/Forms/Bio/BioForm.tsx index 240a4b9..e84d579 100644 --- a/client/src/Forms/Bio/BioForm.tsx +++ b/client/src/Forms/Bio/BioForm.tsx @@ -11,10 +11,14 @@ interface EditBioFormProps { setGroup?: React.Dispatch>; setMusician?: React.Dispatch>; token: string; + livestream_id?: string; } function EditBioForm(props: EditBioFormProps) { const [formBio, setFormBio] = useState(props.entity.bio); + const [formLivestreamId, setFormLivestreamId] = useState( + props.livestream_id, + ); const [canSubmit, setCanSubmit] = useState(false); const [error, setError] = useState(""); @@ -23,6 +27,13 @@ function EditBioForm(props: EditBioFormProps) { setCanSubmit(true); }; + const handleLivestreamChange = ( + event: React.ChangeEvent, + ) => { + setFormLivestreamId(event.target.value); + setCanSubmit(true); + }; + const handleSubmit = async (event: React.FormEvent) => { event.preventDefault(); if (props.entity instanceof MusicianObj) { @@ -53,7 +64,6 @@ function EditBioForm(props: EditBioFormProps) { } }) .catch((error) => { - console.error(error); setError("Failed to update bio: " + error.response.data.detail); }); }; @@ -62,7 +72,8 @@ function EditBioForm(props: EditBioFormProps) { accessToken: string, group: GroupObj, ): Promise => { - patchGroup(group.id, formBio, group.name, accessToken) + const livestream_id = formLivestreamId ? formLivestreamId : ""; + patchGroup(group.id, formBio, livestream_id, group.name, accessToken) .then((patchedGroup) => { if (props.setGroup) { props.setGroup(patchedGroup); @@ -86,6 +97,27 @@ function EditBioForm(props: EditBioFormProps) { return (
+ {/* need to account for empty string, which is falsy but the field should still show */} + {props.livestream_id != undefined && ( + +

+ A livestream id is part of a youtube url. Either + ".../v=[livestream_id]" or ".../live/[livestream_id]". For example, + "ncyl7cTU9k8" but without the quotations. Don't mess it up.{" "} +

+ To remove an embedded livestream, just clear this field and submit + the form. +

+ Livestream ID: + +
+ )} Bio } /> @@ -67,6 +71,9 @@ function Group(props: GroupProps) {

{group.name}

+ {group.livestream_id && ( + + )} {props.token && EditIcon} {group.bio} diff --git a/client/src/LivestreamPlayer/LivestreamPlayer.tsx b/client/src/LivestreamPlayer/LivestreamPlayer.tsx new file mode 100644 index 0000000..c8e22c3 --- /dev/null +++ b/client/src/LivestreamPlayer/LivestreamPlayer.tsx @@ -0,0 +1,25 @@ +import { Container } from "react-bootstrap"; + +interface LivestreamPlayerProps { + livestreamId: string; +} + +const LivestreamPlayer = (props: LivestreamPlayerProps) => { + const iframeSrc = `https://www.youtube.com/embed/${props.livestreamId}?autoplay=1`; + + return ( + +
+ +
+
+ ); +}; + +export default LivestreamPlayer; diff --git a/client/src/api.tsx b/client/src/api.tsx index 0f0aadd..f9ffdcf 100644 --- a/client/src/api.tsx +++ b/client/src/api.tsx @@ -38,6 +38,7 @@ export const getRoot = async (): Promise => { response.data.group.id, response.data.group.name, response.data.group.bio, + response.data.group.livestream_id, ), response.data.musicians.map( (musician: MusicianObj) => @@ -104,7 +105,12 @@ export const postUser = async (token: string): Promise => { export const getGroup = async (): Promise => { const response = await api.get("/group/"); - return new GroupObj(response.data.id, response.data.name, response.data.bio); + return new GroupObj( + response.data.id, + response.data.name, + response.data.bio, + response.data.livestream_id, + ); }; export const getMusicians = async (): Promise => { @@ -143,15 +149,21 @@ export const patchMusician = async ( export const patchGroup = async ( id: number, bio: string, + livestream_id: string, name: string, user_token: string, ): Promise => { const response = await api.patch( `/group/`, - { id, bio, name }, + { id, bio, livestream_id, name }, { headers: { Authorization: `Bearer ${user_token}` } }, ); - return new GroupObj(response.data.id, response.data.name, response.data.bio); + return new GroupObj( + response.data.id, + response.data.name, + response.data.bio, + response.data.livestream_id, + ); }; export const postHeadshot = async ( diff --git a/server/.gitignore b/server/.gitignore index ffeaa36..665afac 100644 --- a/server/.gitignore +++ b/server/.gitignore @@ -176,4 +176,7 @@ pyrightconfig.json # End of https://www.toptal.com/developers/gitignore/api/python *.log -.vscode/ \ No newline at end of file +.vscode/ + +# mysql dumps +*.dump \ No newline at end of file diff --git a/server/app/controllers/controller.py b/server/app/controllers/controller.py index 9cbd481..c1148e1 100644 --- a/server/app/controllers/controller.py +++ b/server/app/controllers/controller.py @@ -197,16 +197,20 @@ class MainController: """ return self.group_controller.get_group() - async def update_group_bio( - self, bio: str, token: HTTPAuthorizationCredentials + async def update_group( + self, group: Group, token: HTTPAuthorizationCredentials ) -> Group: """ Updates the group's bio and returns the updated group object. - - :param str bio: The new bio for the group - :param HTTPAuthorizationCredentials token: The OAuth token - :return Group: The updated group object which is suitable for a response body """ _, sub = self.oauth_token.email_and_sub(token) self.user_controller.get_user_by_sub(sub) - return self.group_controller.update_group_bio(bio) + self.group_controller.update_livestream(group.livestream_id) + return self.group_controller.update_group_bio(group.bio) + + async def update_livestream( + self, livestream_id: str, token: HTTPAuthorizationCredentials + ) -> Group: + _, sub = self.oauth_token.email_and_sub(token) + self.user_controller.get_user_by_sub(sub) + return self.group_controller.update_livestream(livestream_id) diff --git a/server/app/controllers/group.py b/server/app/controllers/group.py index 4c68b1f..de8182e 100644 --- a/server/app/controllers/group.py +++ b/server/app/controllers/group.py @@ -57,3 +57,13 @@ class GroupController(BaseController): detail=f"Error updating group bio: {e}", ) return self.get_group() + + def update_livestream(self, livestream_id: str) -> Group: + try: + self.group_queries.update_livestream(livestream_id) + except Exception as e: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Error updating livestram: {e}", + ) + return self.get_group() diff --git a/server/app/db/conn.py b/server/app/db/conn.py index b597fe3..a1d5893 100644 --- a/server/app/db/conn.py +++ b/server/app/db/conn.py @@ -15,7 +15,7 @@ def connect_db() -> mysql.connector.MySQLConnection: Credential values are validated and an exception is raised if any are missing. """ - load_dotenv() + load_dotenv(override=True) host = os.getenv("DB_HOST") user = os.getenv("DB_USER") password = os.getenv("DB_PASSWORD") diff --git a/server/app/db/group.py b/server/app/db/group.py index 246a2fd..5cd6626 100644 --- a/server/app/db/group.py +++ b/server/app/db/group.py @@ -34,3 +34,15 @@ class GroupQueries(BaseQueries): cursor.execute(query, (bio,)) conn.commit() self.close_cursor_and_conn(cursor, conn) + + def update_livestream(self, livestream_id: str) -> None: + cursor, conn = self.get_cursor_and_conn() + query = f"""-- sql + UPDATE {self.table} SET livestream_id = %s WHERE id = 1 + """ + cursor.execute(query, (livestream_id,)) + conn.commit() + self.close_cursor_and_conn(cursor, conn) + + def delete_livestream(self) -> None: + self.update_livestream(livestream_id="") diff --git a/server/app/models/group.py b/server/app/models/group.py index 4a36fbf..9aab010 100644 --- a/server/app/models/group.py +++ b/server/app/models/group.py @@ -1,7 +1,9 @@ from pydantic import BaseModel +from typing import Optional class Group(BaseModel): name: str bio: str - id: int | None = None + livestream_id: str = "" + id: Optional[int] = None diff --git a/server/app/routers/group.py b/server/app/routers/group.py index f059b6e..cda1da4 100644 --- a/server/app/routers/group.py +++ b/server/app/routers/group.py @@ -24,4 +24,4 @@ async def update_group( ) -> Group: """Updates the group bio, but requires the entire group object to be sent in the request body. Requires authentication.""" - return await controller.update_group_bio(group.bio, token) + return await controller.update_group(group, token) diff --git a/server/tests/controllers/test_main_controller.py b/server/tests/controllers/test_main_controller.py index df2dd6f..c31fdda 100644 --- a/server/tests/controllers/test_main_controller.py +++ b/server/tests/controllers/test_main_controller.py @@ -4,6 +4,7 @@ import pytest from app.controllers.controller import MainController from app.models.event import EventSeries, NewEventSeries +from app.models.group import Group from app.models.musician import Musician mock_user_controller = MagicMock() @@ -158,7 +159,8 @@ async def test_get_group(): async def test_update_group_bio(): """Tests the update_group_bio method.""" bio = "A new bio" - await controller.update_group_bio(bio, mock_token) + group = Group(name="The Grapefruits Duo", bio=bio, livestream_id="") + await controller.update_group(group, mock_token) MagicMock.assert_called_with(mock_oauth_token.email_and_sub, mock_token) MagicMock.assert_called(mock_user_controller.get_user_by_sub) MagicMock.assert_called(mock_group_controller.update_group_bio)