@@ -1,5 +1,5 @@
|
|||||||
import Modal from "react-bootstrap/Modal";
|
import Modal from "react-bootstrap/Modal";
|
||||||
import { MusicianProps } from "../Musicians/Musician/Musician";
|
import { MusicianObj } from "../Musicians/Musician/Musician";
|
||||||
import { GroupObj } from "../Group/Group";
|
import { GroupObj } from "../Group/Group";
|
||||||
import { EventSeriesObj } from "../Series/SeriesList";
|
import { EventSeriesObj } from "../Series/SeriesList";
|
||||||
|
|
||||||
@@ -8,8 +8,9 @@ interface EditModalProps {
|
|||||||
show: boolean;
|
show: boolean;
|
||||||
onHide: () => void;
|
onHide: () => void;
|
||||||
form: JSX.Element;
|
form: JSX.Element;
|
||||||
entity?: MusicianProps | GroupObj | EventSeriesObj;
|
entity?: MusicianObj | GroupObj | EventSeriesObj;
|
||||||
error?: string;
|
error?: string;
|
||||||
|
livestream_id?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function EditModal(props: EditModalProps) {
|
function EditModal(props: EditModalProps) {
|
||||||
|
|||||||
@@ -11,10 +11,14 @@ interface EditBioFormProps {
|
|||||||
setGroup?: React.Dispatch<React.SetStateAction<GroupObj | undefined>>;
|
setGroup?: React.Dispatch<React.SetStateAction<GroupObj | undefined>>;
|
||||||
setMusician?: React.Dispatch<React.SetStateAction<MusicianObj>>;
|
setMusician?: React.Dispatch<React.SetStateAction<MusicianObj>>;
|
||||||
token: string;
|
token: string;
|
||||||
|
livestream_id?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function EditBioForm(props: EditBioFormProps) {
|
function EditBioForm(props: EditBioFormProps) {
|
||||||
const [formBio, setFormBio] = useState<string>(props.entity.bio);
|
const [formBio, setFormBio] = useState<string>(props.entity.bio);
|
||||||
|
const [formLivestreamId, setFormLivestreamId] = useState<string | undefined>(
|
||||||
|
props.livestream_id,
|
||||||
|
);
|
||||||
const [canSubmit, setCanSubmit] = useState<boolean>(false);
|
const [canSubmit, setCanSubmit] = useState<boolean>(false);
|
||||||
const [error, setError] = useState<string>("");
|
const [error, setError] = useState<string>("");
|
||||||
|
|
||||||
@@ -23,6 +27,13 @@ function EditBioForm(props: EditBioFormProps) {
|
|||||||
setCanSubmit(true);
|
setCanSubmit(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleLivestreamChange = (
|
||||||
|
event: React.ChangeEvent<HTMLTextAreaElement>,
|
||||||
|
) => {
|
||||||
|
setFormLivestreamId(event.target.value);
|
||||||
|
setCanSubmit(true);
|
||||||
|
};
|
||||||
|
|
||||||
const handleSubmit = async (event: React.FormEvent) => {
|
const handleSubmit = async (event: React.FormEvent) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (props.entity instanceof MusicianObj) {
|
if (props.entity instanceof MusicianObj) {
|
||||||
@@ -53,7 +64,6 @@ function EditBioForm(props: EditBioFormProps) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error(error);
|
|
||||||
setError("Failed to update bio: " + error.response.data.detail);
|
setError("Failed to update bio: " + error.response.data.detail);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -62,7 +72,8 @@ function EditBioForm(props: EditBioFormProps) {
|
|||||||
accessToken: string,
|
accessToken: string,
|
||||||
group: GroupObj,
|
group: GroupObj,
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
patchGroup(group.id, formBio, group.name, accessToken)
|
const livestream_id = formLivestreamId ? formLivestreamId : "";
|
||||||
|
patchGroup(group.id, formBio, livestream_id, group.name, accessToken)
|
||||||
.then((patchedGroup) => {
|
.then((patchedGroup) => {
|
||||||
if (props.setGroup) {
|
if (props.setGroup) {
|
||||||
props.setGroup(patchedGroup);
|
props.setGroup(patchedGroup);
|
||||||
@@ -86,6 +97,27 @@ function EditBioForm(props: EditBioFormProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Form onSubmit={handleSubmit}>
|
<Form onSubmit={handleSubmit}>
|
||||||
|
{/* need to account for empty string, which is falsy but the field should still show */}
|
||||||
|
{props.livestream_id != undefined && (
|
||||||
|
<Form.Group controlId="formLivestreamId">
|
||||||
|
<p className="text-muted">
|
||||||
|
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.{" "}
|
||||||
|
<br></br>
|
||||||
|
To remove an embedded livestream, just clear this field and submit
|
||||||
|
the form.
|
||||||
|
</p>
|
||||||
|
<Form.Label>Livestream ID:</Form.Label>
|
||||||
|
<Form.Control
|
||||||
|
as="textarea"
|
||||||
|
value={formLivestreamId}
|
||||||
|
rows={1}
|
||||||
|
onChange={handleLivestreamChange}
|
||||||
|
placeholder=""
|
||||||
|
/>
|
||||||
|
</Form.Group>
|
||||||
|
)}
|
||||||
<Form.Group controlId="formBio">
|
<Form.Group controlId="formBio">
|
||||||
<Form.Label>Bio</Form.Label>
|
<Form.Label>Bio</Form.Label>
|
||||||
<Form.Control
|
<Form.Control
|
||||||
|
|||||||
@@ -5,16 +5,19 @@ import { useState } from "react";
|
|||||||
import EditBioForm from "../Forms/Bio/BioForm";
|
import EditBioForm from "../Forms/Bio/BioForm";
|
||||||
import { faPen } from "@fortawesome/free-solid-svg-icons";
|
import { faPen } from "@fortawesome/free-solid-svg-icons";
|
||||||
import EditButton from "../Buttons/EditButton/EditButton";
|
import EditButton from "../Buttons/EditButton/EditButton";
|
||||||
|
import LivestreamPlayer from "../LivestreamPlayer/LivestreamPlayer";
|
||||||
|
|
||||||
export class GroupObj {
|
export class GroupObj {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
bio: string;
|
bio: string;
|
||||||
|
livestream_id: string;
|
||||||
|
|
||||||
constructor(id: number, name: string, bio: string) {
|
constructor(id: number, name: string, bio: string, livestream_id: string) {
|
||||||
this.id = id;
|
this.id = id;
|
||||||
this.name = name;
|
this.name = name;
|
||||||
this.bio = bio;
|
this.bio = bio;
|
||||||
|
this.livestream_id = livestream_id;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,7 +42,7 @@ function Group(props: GroupProps) {
|
|||||||
<EditButton
|
<EditButton
|
||||||
setModalShow={setModalShow}
|
setModalShow={setModalShow}
|
||||||
faIcon={faPen}
|
faIcon={faPen}
|
||||||
actionName=" Group Bio"
|
actionName=" Group"
|
||||||
/>
|
/>
|
||||||
<EditModal
|
<EditModal
|
||||||
show={modalShow}
|
show={modalShow}
|
||||||
@@ -53,6 +56,7 @@ function Group(props: GroupProps) {
|
|||||||
onBioChange={props.onBioChange}
|
onBioChange={props.onBioChange}
|
||||||
setGroup={props.setGroup}
|
setGroup={props.setGroup}
|
||||||
token={props.token}
|
token={props.token}
|
||||||
|
livestream_id={group.livestream_id}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -67,6 +71,9 @@ function Group(props: GroupProps) {
|
|||||||
<Card.Title>
|
<Card.Title>
|
||||||
<h1>{group.name}</h1>
|
<h1>{group.name}</h1>
|
||||||
</Card.Title>
|
</Card.Title>
|
||||||
|
{group.livestream_id && (
|
||||||
|
<LivestreamPlayer livestreamId={group.livestream_id} />
|
||||||
|
)}
|
||||||
{props.token && EditIcon}
|
{props.token && EditIcon}
|
||||||
<Card.Text className="lead group-bio">{group.bio}</Card.Text>
|
<Card.Text className="lead group-bio">{group.bio}</Card.Text>
|
||||||
</Card.Body>
|
</Card.Body>
|
||||||
|
|||||||
25
client/src/LivestreamPlayer/LivestreamPlayer.tsx
Normal file
25
client/src/LivestreamPlayer/LivestreamPlayer.tsx
Normal file
@@ -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 (
|
||||||
|
<Container className="d-flex justify-content-center my-3">
|
||||||
|
<div className="ratio ratio-16x9">
|
||||||
|
<iframe
|
||||||
|
src={iframeSrc}
|
||||||
|
title="YouTube Livestream"
|
||||||
|
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; fullscreen"
|
||||||
|
allowFullScreen
|
||||||
|
className="rounded border"
|
||||||
|
></iframe>
|
||||||
|
</div>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LivestreamPlayer;
|
||||||
@@ -38,6 +38,7 @@ export const getRoot = async (): Promise<TheGrapefruitsDuoAPI> => {
|
|||||||
response.data.group.id,
|
response.data.group.id,
|
||||||
response.data.group.name,
|
response.data.group.name,
|
||||||
response.data.group.bio,
|
response.data.group.bio,
|
||||||
|
response.data.group.livestream_id,
|
||||||
),
|
),
|
||||||
response.data.musicians.map(
|
response.data.musicians.map(
|
||||||
(musician: MusicianObj) =>
|
(musician: MusicianObj) =>
|
||||||
@@ -104,7 +105,12 @@ export const postUser = async (token: string): Promise<UserObj> => {
|
|||||||
|
|
||||||
export const getGroup = async (): Promise<GroupObj> => {
|
export const getGroup = async (): Promise<GroupObj> => {
|
||||||
const response = await api.get("/group/");
|
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<MusicianObj[]> => {
|
export const getMusicians = async (): Promise<MusicianObj[]> => {
|
||||||
@@ -143,15 +149,21 @@ export const patchMusician = async (
|
|||||||
export const patchGroup = async (
|
export const patchGroup = async (
|
||||||
id: number,
|
id: number,
|
||||||
bio: string,
|
bio: string,
|
||||||
|
livestream_id: string,
|
||||||
name: string,
|
name: string,
|
||||||
user_token: string,
|
user_token: string,
|
||||||
): Promise<GroupObj> => {
|
): Promise<GroupObj> => {
|
||||||
const response = await api.patch(
|
const response = await api.patch(
|
||||||
`/group/`,
|
`/group/`,
|
||||||
{ id, bio, name },
|
{ id, bio, livestream_id, name },
|
||||||
{ headers: { Authorization: `Bearer ${user_token}` } },
|
{ 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 (
|
export const postHeadshot = async (
|
||||||
|
|||||||
3
server/.gitignore
vendored
3
server/.gitignore
vendored
@@ -177,3 +177,6 @@ pyrightconfig.json
|
|||||||
|
|
||||||
*.log
|
*.log
|
||||||
.vscode/
|
.vscode/
|
||||||
|
|
||||||
|
# mysql dumps
|
||||||
|
*.dump
|
||||||
@@ -197,16 +197,20 @@ class MainController:
|
|||||||
"""
|
"""
|
||||||
return self.group_controller.get_group()
|
return self.group_controller.get_group()
|
||||||
|
|
||||||
async def update_group_bio(
|
async def update_group(
|
||||||
self, bio: str, token: HTTPAuthorizationCredentials
|
self, group: Group, token: HTTPAuthorizationCredentials
|
||||||
) -> Group:
|
) -> Group:
|
||||||
"""
|
"""
|
||||||
Updates the group's bio and returns the updated group object.
|
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)
|
_, sub = self.oauth_token.email_and_sub(token)
|
||||||
self.user_controller.get_user_by_sub(sub)
|
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)
|
||||||
|
|||||||
@@ -57,3 +57,13 @@ class GroupController(BaseController):
|
|||||||
detail=f"Error updating group bio: {e}",
|
detail=f"Error updating group bio: {e}",
|
||||||
)
|
)
|
||||||
return self.get_group()
|
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()
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ def connect_db() -> mysql.connector.MySQLConnection:
|
|||||||
|
|
||||||
Credential values are validated and an exception is raised if any are missing.
|
Credential values are validated and an exception is raised if any are missing.
|
||||||
"""
|
"""
|
||||||
load_dotenv()
|
load_dotenv(override=True)
|
||||||
host = os.getenv("DB_HOST")
|
host = os.getenv("DB_HOST")
|
||||||
user = os.getenv("DB_USER")
|
user = os.getenv("DB_USER")
|
||||||
password = os.getenv("DB_PASSWORD")
|
password = os.getenv("DB_PASSWORD")
|
||||||
|
|||||||
@@ -34,3 +34,15 @@ class GroupQueries(BaseQueries):
|
|||||||
cursor.execute(query, (bio,))
|
cursor.execute(query, (bio,))
|
||||||
conn.commit()
|
conn.commit()
|
||||||
self.close_cursor_and_conn(cursor, conn)
|
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="")
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
class Group(BaseModel):
|
class Group(BaseModel):
|
||||||
name: str
|
name: str
|
||||||
bio: str
|
bio: str
|
||||||
id: int | None = None
|
livestream_id: str = ""
|
||||||
|
id: Optional[int] = None
|
||||||
|
|||||||
@@ -24,4 +24,4 @@ async def update_group(
|
|||||||
) -> Group:
|
) -> Group:
|
||||||
"""Updates the group bio, but requires the entire group object to be sent in the request body.
|
"""Updates the group bio, but requires the entire group object to be sent in the request body.
|
||||||
Requires authentication."""
|
Requires authentication."""
|
||||||
return await controller.update_group_bio(group.bio, token)
|
return await controller.update_group(group, token)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import pytest
|
|||||||
|
|
||||||
from app.controllers.controller import MainController
|
from app.controllers.controller import MainController
|
||||||
from app.models.event import EventSeries, NewEventSeries
|
from app.models.event import EventSeries, NewEventSeries
|
||||||
|
from app.models.group import Group
|
||||||
from app.models.musician import Musician
|
from app.models.musician import Musician
|
||||||
|
|
||||||
mock_user_controller = MagicMock()
|
mock_user_controller = MagicMock()
|
||||||
@@ -158,7 +159,8 @@ async def test_get_group():
|
|||||||
async def test_update_group_bio():
|
async def test_update_group_bio():
|
||||||
"""Tests the update_group_bio method."""
|
"""Tests the update_group_bio method."""
|
||||||
bio = "A new bio"
|
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_with(mock_oauth_token.email_and_sub, mock_token)
|
||||||
MagicMock.assert_called(mock_user_controller.get_user_by_sub)
|
MagicMock.assert_called(mock_user_controller.get_user_by_sub)
|
||||||
MagicMock.assert_called(mock_group_controller.update_group_bio)
|
MagicMock.assert_called(mock_group_controller.update_group_bio)
|
||||||
|
|||||||
Reference in New Issue
Block a user