diff --git a/backend/db/crud.py b/backend/db/crud.py index 9b7e645b79545e8ee4314996adc2266ae12290f2..842ed1a8b3d26a28148ddf0e6249c478836e85d7 100644 --- a/backend/db/crud.py +++ b/backend/db/crud.py @@ -214,7 +214,7 @@ def delete_comment(id: int, db: Session): # Define CRUD operation for the news -def get_news(place: str, db: Session): +def get_news(place: str, admin: bool, db: Session): """ Get the news for the given place """ current_date = datetime.now(tz=pytz.timezone("Europe/Paris")) news = db.query( @@ -222,37 +222,50 @@ def get_news(place: str, db: Session): models.News.place == place, models.News.end_date >= current_date).order_by( models.News.published_at.desc()).all() + if admin: + return news + opening_hours = db.query( models.OpeningHours.open_time, - models.OpeningHours.close_time).filter( + models.OpeningHours.close_time, + models.OpeningHours.day).filter( models.OpeningHours.place == place, - models.OpeningHours.day == current_date.weekday()).order_by( + models.OpeningHours.day >= current_date.weekday()).order_by( + models.OpeningHours.day, models.OpeningHours.open_time).all() - next_timetable = None - for time_slot in opening_hours: - if current_date.time() < time_slot.open_time: - next_timetable=time_slot.open_time + if not opening_hours: + opening_hours = db.query( + models.OpeningHours.open_time, + models.OpeningHours.close_time, + models.OpeningHours.day).filter( + models.OpeningHours.place == place).order_by( + models.OpeningHours.day, + models.OpeningHours.open_time).all() + next_time_slot = None + for open_time, close_time, day in opening_hours: + next_date = current_date + timedelta(days=day-current_date.weekday()) + if day < current_date.weekday(): + next_date = next_date + timedelta(days=7) + next_time_slot = datetime.combine(next_date.date(), open_time) break - if not next_timetable: - closure = db.query( - models.Closure).filter( - models.Closure.place == place, - models.Closure.beginning_date <= current_date, - models.Closure.end_date >= current_date).first() - else: + if current_date < pytz.timezone("Europe/Paris").localize(datetime.combine(next_date.date(), close_time)): + next_time_slot = datetime.combine(next_date.date(), open_time) + break + if next_time_slot: closure = db.query( models.Closure).filter( models.Closure.place == place, - models.Closure.beginning_date <= datetime.combine(current_date.date(), next_timetable), - models.Closure.end_date >= datetime.combine(current_date.date(), next_timetable)).first() - if closure: - closure_news = schemas.News( - title="Fermeture exceptionnelle", - content=f"{place} est exceptionnellement hors service jusqu'au {closure.end_date.strftime('%d/%m/%y à %Hh%M')}", - end_date=closure.end_date, - place=place, - published_at=closure.beginning_date) - news.append(closure_news) + models.Closure.beginning_date <= next_time_slot, + models.Closure.end_date > next_time_slot).first() + if closure: + closure_news = schemas.News( + title="Fermeture exceptionnelle", + content=f"{place} est exceptionnellement hors service jusqu'au {closure.end_date.strftime('%d/%m/%y à %Hh%M')}", + end_date=closure.end_date, + place=place, + published_at=current_date) + news.append(closure_news) + return news @@ -528,7 +541,7 @@ def create_closure(closure: schemas.Closure, db: Session): db.add(db_closure) db.commit() db.refresh(db_closure) - return schemas.Closure(**closure.dict()) + return schemas.Closure(**db_closure.__dict__) def delete_closure(id: int, db: Session): diff --git a/backend/db/schemas.py b/backend/db/schemas.py index f47c7c0a23675948d3557d56c56c8bb473b2232a..2042101f98e167167989f2ea0d52e7d918163951 100644 --- a/backend/db/schemas.py +++ b/backend/db/schemas.py @@ -68,7 +68,7 @@ class NewsBase(BaseModel): class News(NewsBase): """Database news base schema""" - id: int + id: Optional[int] published_at: datetime = Field(..., title="Publication date of the news") class Config: diff --git a/backend/routers/authentication.py b/backend/routers/authentication.py index 32ab07f5d4969da1bfb9ad9f3d22dff5498f963f..7f8386b5e8f74c352d84e2b82e39e716c964f1f3 100644 --- a/backend/routers/authentication.py +++ b/backend/routers/authentication.py @@ -19,7 +19,7 @@ router = APIRouter(prefix="/api/auth", tags=["auth"]) @router.get("/") async def whoami(connect_id: str = Cookie(...), db: Session = Depends(get_db)): user = crud.get_user(connect_id, db) - return user.username + return {"name": user.username, "admin": user.admin} @router.get("/login") diff --git a/backend/routers/infos.py b/backend/routers/infos.py index 16636c4ebb80b20d5bc2d9d897e958188dd10dbe..67b0bfdef28154a11b3821033a93a62d2926ff9e 100644 --- a/backend/routers/infos.py +++ b/backend/routers/infos.py @@ -1,4 +1,4 @@ -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Cookie, Depends, HTTPException from sqlalchemy.orm import Session from typing import List @@ -34,8 +34,12 @@ async def get_closure(place: str, db: Session = Depends(get_db)): @router.post('/closure', response_model=schemas.Closure) -async def create_closure(closure: schemas.Closure, db: Session = Depends(get_db)): - return crud.create_closure(closure, db) +async def create_closure(closure: schemas.ClosureBase, connect_id: str = Cookie(...), db: Session = Depends(get_db)): + user = crud.get_user(connect_id, db) + if user.admin: + return crud.create_closure(closure, db) + else: + raise HTTPException(status_code=403, detail="Administrator privilege required") @router.delete('/closure/{id}', response_model=None) diff --git a/backend/routers/news.py b/backend/routers/news.py index 7c4b8136fda0bd567297b83c6c41d859399452b6..1c1183a675f6cc570e9e73912c0408aabe035a84 100644 --- a/backend/routers/news.py +++ b/backend/routers/news.py @@ -1,4 +1,4 @@ -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Cookie, Depends, HTTPException from sqlalchemy.orm import Session from typing import List import json @@ -12,17 +12,25 @@ router = APIRouter(prefix="/api", tags=["news"]) @router.get('/{place}/news', response_model=List[schemas.News]) -async def get_news(place: str, db: Session = Depends(get_db)): - return crud.get_news(place, db) +async def get_news(place: str, admin: bool = False, db: Session = Depends(get_db)): + return crud.get_news(place, admin, db) @router.post('/news', response_model=schemas.News) -async def create_news(news: schemas.NewsBase, db: Session = Depends(get_db)): - saved_news = crud.create_news(news, db) - await manager.broadcast(json.dumps({"type": "news", "comment": saved_news.__dict__}, default=str)) - return saved_news +async def create_news(news: schemas.NewsBase, connect_id: str = Cookie(...), db: Session = Depends(get_db)): + user = crud.get_user(connect_id, db) + if user.admin: + saved_news = crud.create_news(news, db) + await manager.broadcast(json.dumps({"type": "news", "comment": saved_news.__dict__}, default=str)) + return saved_news + else: + raise HTTPException(status_code=403, detail="Administrator privilege required") @router.delete('/news/{id}', response_model=None) -async def delete_news(id: int, db: Session = Depends(get_db)): - return crud.delete_news(id, db) +async def delete_news(id: int, connect_id: str = Cookie(...), db: Session = Depends(get_db)): + user = crud.get_user(connect_id, db) + if user.admin: + return crud.delete_news(id, db) + else: + raise HTTPException(status_code=403, detail="Administrator privilege required") diff --git a/frontend/src/components/ClosureForm.js b/frontend/src/components/ClosureForm.js new file mode 100644 index 0000000000000000000000000000000000000000..f7e15b6ae930f45a47724f5a7195e826ae7a93ea --- /dev/null +++ b/frontend/src/components/ClosureForm.js @@ -0,0 +1,57 @@ +import React, { useEffect, useState } from "react"; +import axios from "axios"; + +export default function ClosureForm() { + const [places, setPlaces] = useState([]); + + const fetchPlaces = () => { + axios + .get(`${process.env.REACT_APP_BASE_URL_BACK}/restaurants`) + .then((res) => { + setPlaces(res.data); + }) + .catch((e) => { + console.log(e); + }); + }; + + const Submit = (ev) => { + ev.preventDefault(); + let { place, beginning_date, end_date } = ev.target.elements; + + axios + .post(`${process.env.REACT_APP_BASE_URL_BACK}/closure`, { + place: place.value, + beginning_date: beginning_date.value, + end_date: end_date.value, + }) + .then((res) => { + console.log(res); + }) + .catch((e) => { + console.log(e); + }); + }; + + useEffect(fetchPlaces, []); + + return ( + <form onSubmit={Submit} id="news-form-container"> + <label htmlFor="place">Restaurant concerné</label> + <select name="place"> + {places.map((place) => ( + <option key={place.name}>{place.name}</option> + ))} + </select> + <label htmlFor="beginning_date" className="news-form-label"> + Date de fermeture + </label> + <input type="datetime-local" name="beginning_date" style={{ minHeight: "2rem" }} /> + <label htmlFor="end_date" className="news-form-label"> + Date de réouverture + </label> + <input type="datetime-local" name="end_date" style={{ minHeight: "2rem" }} /> + <input id="submit_event" type="submit" className="news-form-label" /> + </form> + ); +} diff --git a/frontend/src/components/Comments.js b/frontend/src/components/Comments.js index a3a13046f98e55ee8f203236941ba282c56c070a..09f7589a40b0f4479746c9b9bbcc503598248c7e 100644 --- a/frontend/src/components/Comments.js +++ b/frontend/src/components/Comments.js @@ -1,6 +1,6 @@ import React, { useContext, useEffect, useRef, useState } from "react"; import axios from "axios"; -import { AiOutlineInfoCircle } from "react-icons/ai"; +import { AiOutlineInfoCircle, AiOutlineDelete } from "react-icons/ai"; import { BiSend } from "react-icons/bi"; import { BsChatText } from "react-icons/bs"; @@ -9,7 +9,7 @@ import { getSiblings } from "../utils"; import "../styles/Comments.css"; -export default function Messages({ place, infos, lastMessage }) { +export default function Messages({ place, infos, lastMessage, admin }) { const [user] = useContext(User); const [messages, setMessages] = useState([]); const [newComment, setNewComment] = useState(""); @@ -35,11 +35,29 @@ export default function Messages({ place, infos, lastMessage }) { }) .catch((e) => { console.log(e); + alert("Une erreur est survenue"); updateValue(""); }); } }; + const Delete = (ev) => { + if (infos && admin) { + ev.preventDefault(); + axios + .delete(`${process.env.REACT_APP_BASE_URL_BACK}/news/${ev.target.id}`, { + withCredentials: true, + }) + .then(() => { + setLoading(true); + }) + .catch((e) => { + alert("Une erreur est survenue"); + console.log(e); + }); + } + }; + const updateValue = (value) => { setNewComment(value); if (input.current) { @@ -81,7 +99,7 @@ export default function Messages({ place, infos, lastMessage }) { .get( `${process.env.REACT_APP_BASE_URL_BACK}/${encodeURIComponent(place)}/${ infos ? "news" : "comments" - }`, + }${admin ? "?admin=true" : ""}`, ) .then((res) => { setMessages(res.data); @@ -95,7 +113,7 @@ export default function Messages({ place, infos, lastMessage }) { console.log(e); setLoading(false); }); - }, []); + }, [place, loading]); useEffect(() => { if (chat.current) { @@ -127,20 +145,22 @@ export default function Messages({ place, infos, lastMessage }) { }, [lastMessage]); return ( - <div className="comments-side-bar"> - <div className="comments-title"> - {infos ? ( - <> - <AiOutlineInfoCircle id="comments-icon-left" onClick={OpenSideBar} /> - Infos - </> - ) : ( - <> - <BsChatText id="comments-icon-right" onClick={OpenSideBar} /> - Commentaires - </> - )} - </div> + <div className={`comments-side-bar ${admin ? "comments-full-size" : ""}`}> + {!admin && ( + <div className="comments-title"> + {infos ? ( + <> + <AiOutlineInfoCircle id="comments-icon-left" onClick={OpenSideBar} /> + Infos + </> + ) : ( + <> + <BsChatText id="comments-icon-right" onClick={OpenSideBar} /> + Commentaires + </> + )} + </div> + )} <div ref={chat} className={`comments-scroll-bar ${infos && "infos-scroll-bar"}`}> {!messages.length ? ( loading ? ( @@ -162,6 +182,13 @@ export default function Messages({ place, infos, lastMessage }) { return ( <div key={index} className="comment"> <div className={`comment-title${infos ? "-infos" : ""}`}> + {admin && ( + <AiOutlineDelete + id={message.id} + onClick={Delete} + className="comment-delete-button" + /> + )} {infos ? message.title : message.username} </div> <div className="comment-content">{message.content}</div> diff --git a/frontend/src/components/NewsForm.js b/frontend/src/components/NewsForm.js new file mode 100644 index 0000000000000000000000000000000000000000..5ee9350c86061291ce6f01e725addfe56430b16e --- /dev/null +++ b/frontend/src/components/NewsForm.js @@ -0,0 +1,70 @@ +import React, { useEffect, useState } from "react"; +import axios from "axios"; + +import "../styles/NewsForm.css"; + +export default function NewsForm() { + const [places, setPlaces] = useState([]); + + const fetchPlaces = () => { + axios + .get(`${process.env.REACT_APP_BASE_URL_BACK}/restaurants`) + .then((res) => { + setPlaces(res.data); + }) + .catch((e) => { + console.log(e); + }); + }; + + const Submit = (ev) => { + ev.preventDefault(); + let { title, content, end_date, place } = ev.target.elements; + + axios + .post( + `${process.env.REACT_APP_BASE_URL_BACK}/news`, + { + title: title.value, + content: content.value, + end_date: end_date.value, + place: place.value, + }, + { withCredentials: true }, + ) + .then(() => { + ev.target.reset(); + alert("L'actualité a bien été postée !"); + }) + .catch((e) => { + console.log(e); + alert("Une erreur est survenue"); + }); + }; + + useEffect(fetchPlaces, []); + + return ( + <form onSubmit={Submit} id="news-form-container"> + <label htmlFor="title">Titre</label> + <input name="title" /> + <label htmlFor="content" className="news-form-label"> + Contenu + </label> + <textarea name="content" style={{ resize: "none" }} /> + <label htmlFor="end_date" className="news-form-label"> + Date de fin + </label> + <input type="datetime-local" name="end_date" style={{ minHeight: "2rem" }} /> + <label htmlFor="place" className="news-form-label"> + Restaurant concerné + </label> + <select name="place"> + {places.map((place) => ( + <option key={place.name}>{place.name}</option> + ))} + </select> + <input id="submit_event" type="submit" className="news-form-label" /> + </form> + ); +} diff --git a/frontend/src/components/index.js b/frontend/src/components/index.js index de9175be93666cca57812a64f53e7e8eaf74791a..2bad7f39f6c30e3a0a3ac924aa0059b6fede5e10 100644 --- a/frontend/src/components/index.js +++ b/frontend/src/components/index.js @@ -3,3 +3,5 @@ export { default as Footer } from "./Footer"; export { default as WaitingTime } from "./WaitingTime"; export { default as Graph } from "./Graph"; export { default as Comments } from "./Comments"; +export { default as NewsForm } from "./NewsForm"; +export { default as ClosureForm } from "./ClosureForm"; diff --git a/frontend/src/index.js b/frontend/src/index.js index a3108a326bcf5590db5ae45e66cd20f96d037783..293bcb9a7ffdfbc2b0479872d126be4d8f05235e 100644 --- a/frontend/src/index.js +++ b/frontend/src/index.js @@ -5,7 +5,7 @@ import useWebSocket from "react-use-websocket"; import axios from "axios"; import { Footer, Header } from "./components"; -import { HomePage, RestaurantPage, NotFoundPage } from "./views"; +import { HomePage, RestaurantPage, NotFoundPage, AdminPage } from "./views"; import "bootstrap/dist/css/bootstrap.min.css"; import "./styles/index.css"; @@ -14,11 +14,12 @@ const socketUrl = `${process.env.REACT_APP_SOCKET_URL}/ws`; export const User = createContext(null); export default function App() { + const storedSession = localStorage.getItem("user"); const [restaurantsList, setRestaurantsList] = useState([]); const [selection, setSelection] = useState(null); const [loading, setLoading] = useState(true); const [reload, setReload] = useState(true); - const [user, setUser] = useState(localStorage.getItem("user")); + const [user, setUser] = useState(storedSession ? JSON.parse(storedSession) : null); const { lastMessage } = useWebSocket(socketUrl, { shouldReconnect: () => true, }); @@ -78,6 +79,9 @@ export default function App() { path="/" element={<HomePage {...{ restaurantsList, setSelection, loading }} />} /> + {user?.admin && ( + <Route path="/admin" element={<AdminPage lastmessage={lastMessage} />} /> + )} <Route path="/:restaurant" element={<RestaurantPage {...{ selection, setSelection, lastMessage }} />} diff --git a/frontend/src/styles/Admin.css b/frontend/src/styles/Admin.css new file mode 100644 index 0000000000000000000000000000000000000000..b49874f5277879ec05928fcedba76c7c726c0397 --- /dev/null +++ b/frontend/src/styles/Admin.css @@ -0,0 +1,31 @@ +#admin-container { + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + align-items: center; + padding: 1rem; +} + +#admin-news-list { + display: flex; + flex-direction: column; + align-items: center; + width: 40rem; + max-width: 90%; + height: 100%; + border: 1px solid white; + padding: 1rem; + margin: 1rem; + margin-top: 2rem; + overflow-y: auto; +} + +.admin-select-option { + font-style: 1.2rem; + margin-bottom: 1rem; +} + +#admin-select-place { + width: fit-content; +} \ No newline at end of file diff --git a/frontend/src/styles/Comments.css b/frontend/src/styles/Comments.css index d52fe85b8f500b3991b1773e839b72127013b9d7..492205d4012b862c51bb121517d6088918bfcd35 100644 --- a/frontend/src/styles/Comments.css +++ b/frontend/src/styles/Comments.css @@ -32,6 +32,10 @@ overflow: hidden; } +.comments-full-size { + width: 100%; +} + .comments-scroll-bar { flex: 1; display: flex; @@ -119,6 +123,10 @@ font-size : 1.2rem; } +.comment-delete-button { + margin-left: 1rem; +} + @media only screen and (max-width: 600px) { .comments-side-bar { width: 0px; diff --git a/frontend/src/styles/NewsForm.css b/frontend/src/styles/NewsForm.css new file mode 100644 index 0000000000000000000000000000000000000000..98412d5a357ea3bc547c0e5fc7927b89f93e4984 --- /dev/null +++ b/frontend/src/styles/NewsForm.css @@ -0,0 +1,23 @@ +#news-form-container { + display: flex; + flex-direction: column; + justify-content: space-between; + width: 40rem; + max-width: 90%; + height: 100%; + border: 1px solid white; + padding: 1rem; + margin: 1rem; + margin-top: 2rem; + overflow-y: auto; +} + +.news-form-label { + margin-top: 1rem; +} + +input::placeholder { + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; +} \ No newline at end of file diff --git a/frontend/src/views/AdminPage.js b/frontend/src/views/AdminPage.js new file mode 100644 index 0000000000000000000000000000000000000000..43678b20224a86e0a56f2da23bcc83b1c25feb3e --- /dev/null +++ b/frontend/src/views/AdminPage.js @@ -0,0 +1,66 @@ +import React, { useEffect, useState } from "react"; +import axios from "axios"; + +import { NewsForm, ClosureForm, Comments } from "../components"; + +import "../styles/Admin.css"; + +export default function AdminPage({ lastMessage }) { + const [form, setForm] = useState(""); + + function NewsList() { + const [place, setPlace] = useState(null); + const [places, setPlaces] = useState([]); + + const fetchPlaces = () => { + axios + .get(`${process.env.REACT_APP_BASE_URL_BACK}/restaurants`) + .then((res) => { + setPlaces(res.data); + res.data.length && setPlace(res.data[0].name); + }) + .catch((e) => { + console.log(e); + }); + }; + + useEffect(fetchPlaces, []); + + return ( + <div id="admin-news-list"> + <select id="admin-select-place" name="place" onChange={(ev) => setPlace(ev.target.value)}> + {places.map((place) => ( + <option key={place.name}>{place.name}</option> + ))} + </select> + <Comments place={place} lastMessage={lastMessage} infos admin /> + </div> + ); + } + + let forms = { + news: <NewsForm />, + newsList: <NewsList />, + closure: <ClosureForm />, + }; + + return ( + <div id="admin-container"> + <select defaultValue="" onChange={(ev) => setForm(ev.target.value)}> + <option className="admin-select-option" value=""> + -- Sélectionner une action -- + </option> + <option className="admin-select-option" value="news"> + Ajouter une actualité + </option> + <option className="admin-select-option" value="newsList"> + Supprimer une actualité + </option> + <option className="admin-select-option" value="closure"> + Renseigner une fermeture exceptionnelle + </option> + </select> + {form && forms[form]} + </div> + ); +} diff --git a/frontend/src/views/index.js b/frontend/src/views/index.js index 77ab82fcd6c65ec32cf59292d0e7eea5cb8681cc..6cbe89865e52890313a19dccc5eba29cff61f8d3 100644 --- a/frontend/src/views/index.js +++ b/frontend/src/views/index.js @@ -1,3 +1,4 @@ export { default as HomePage } from "./HomePage"; export { default as RestaurantPage } from "./Restaurant"; export { default as NotFoundPage } from "./NotFoundPage"; +export { default as AdminPage } from "./AdminPage";