diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index aca60563cc1dcba78d665f918a881731253cb52b..dee6d44d4c47aec8dec18603bd41738db512804b 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -202,6 +202,8 @@ deploy-back-staging: rules: - if: $CI_PIPELINE_SOURCE == "merge_request_event" when: never + - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH + when: never - if: $CI_COMMIT_BRANCH when: always variables: @@ -216,6 +218,8 @@ deploy-front-staging: rules: - if: $CI_PIPELINE_SOURCE == "merge_request_event" when: never + - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH + when: never - if: $CI_COMMIT_BRANCH when: always variables: diff --git a/README.md b/README.md index c8107378dada63bf4e092718fbe13b49905a3a2f..5a6add68b759839d8335b6938de512ab66b94a5b 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,7 @@ ========== # Run the server-side FastAPI app ## In development mode +Using the template files, you first need to create backend/.env and backend/cameras.py. You can start a virtual environment with the following instructions. ```sh @@ -34,6 +35,8 @@ Run the server, `docker-compose up -d` # Run the client-side React app in a different terminal window: +Using the template file, you first need to create frontend/.env. + ```sh $ cd frontend $ npm install @@ -44,6 +47,18 @@ Navigate to [http://localhost:3000](http://localhost:3000) <br/> +# Documentation + +## Algorithm + +The crowd-counting AI model used is based on this repository: +https://github.com/ZhengPeng7/W-Net-Keras +The dataset used is ShanghaiTech Part B. +The model is given a 3-channel image and generates a density map of half the size of the input image. +The estimated number of people is obtained by summing on all pixels of the density map. + +<br/> + # TODO ## Coté algo - Accéder à d'autre infos telle que l'API des cours sur demande à la DISI pour intégrer ça aux prédictions (ex: cours en promo complète juste implique plus d'attente) @@ -67,3 +82,9 @@ Navigate to [http://localhost:3000](http://localhost:3000) ## Documentation - Documenter le projet au maximum - Réfléchir à opensourcer (ça peut permettre d'étendre plus facilement à d'autre RU) + +## Déploiement +- Monitorer la VM de prod avec Datadog +- Écrire un rôle Ansible de déploiement en prod +- Monitorer la VM de staging avec Datadog +- Écrire un rôle Ansible de déploiement en staging diff --git a/backend/cameras.py b/backend/cameras.py new file mode 100644 index 0000000000000000000000000000000000000000..276a8d2c6b9370c7cbe4b25af81474ee915be18c --- /dev/null +++ b/backend/cameras.py @@ -0,0 +1,43 @@ +restaurants = [ + { + "restaurant": "local", + "a_factor": 30, + "b_factor": 120, + "cameras": + [ + { + "IP": "10.148.38.3", + "user": "viarezocam", + "password": "superponey", + "stream": "stream1", + "mask_points": + [ + [ + [70, 370], + [420, 720], + [1280, 720], + [1280, 250], + [930, 215], + [450, 550], + [130, 350] + ] + ], + "checkouts": + [ + { + "x1": 380, + "x2": 435, + "y1": 740, + "y2": 780 + }, + { + "x1": 300, + "x2": 350, + "y1": 830, + "y2": 880 + } + ] + } + ] + } +] diff --git a/backend/cameras.py.template b/backend/cameras.py.template index 5d197e572134b2e9d63518bcbcd5a227ee42bde0..001fe763574175b9b429e3c475371d012175d57b 100644 --- a/backend/cameras.py.template +++ b/backend/cameras.py.template @@ -1,18 +1,25 @@ restaurants = [ { "restaurant": "local", - "a_factor": 30, - "b_factor": 120, + # the linear function estimating the waiting time t based on the number of people n and the number c of open checkouts is: + # t = a * n / c + b + "a_factor": 30, # slope + "b_factor": 120, # y-intercept + # list of all the cameras that correspond to a same restaurant "cameras": [ - { + { + # the RTSP url will be: rtsp://user:password@IP:stream "IP": "", "user": "", "password": "", "stream": "stream1", + # list of the coordinates of the points which constitue the region that should be masked on the picture "mask_points": [ + # polygon which should be part of the mask [ + # [x, y] coordinates of each vertex of the polygon (on a 1280*720 picture) [70, 370], [420, 720], [1280, 720], @@ -22,8 +29,10 @@ restaurants = [ [130, 350] ] ], - "caisses": + # list of the coordinates of each checkout + "checkouts": [ + # each checkout corresponds to a rectangle, indicated by two x and two y coordinates { "x1": 380, "x2": 435, diff --git a/backend/db/crud.py b/backend/db/crud.py index a856d45a090291fc14ce11865323dec349e15d31..853195a6872997706fa95c64a7d6d4b9ba37a8fb 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 @@ -382,6 +395,7 @@ def update_user(user: schemas.User, user_info: dict, db: Session): if existing_user: existing_user.cookie = user.cookie existing_user.expiration_date = expiration_date + existing_user.admin = "admin eatfast" in user_info["roles"] db.delete(user) db.add(existing_user) db.commit() @@ -390,6 +404,7 @@ def update_user(user: schemas.User, user_info: dict, db: Session): else: user.username = full_name user.expiration_date = expiration_date + user.admin = "admin eatfast" in user_info["roles"] db.add(user) db.commit() db.refresh(user) @@ -531,7 +546,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/models.py b/backend/db/models.py index c80580bdfbec9ff61ad427397134f7463cdc0e09..1e6ad00d5a1b93a377d7a48501f89194ffa0f781 100644 --- a/backend/db/models.py +++ b/backend/db/models.py @@ -1,7 +1,7 @@ """ Models of the database for magasin app """ -from sqlalchemy import Column, ForeignKey, Integer, DateTime, Float, Interval, String, Text, Time +from sqlalchemy import Boolean, Column, ForeignKey, Integer, DateTime, Float, Interval, String, Text, Time from sqlalchemy.orm import relationship from db.database import Base @@ -82,5 +82,6 @@ class Users(Base): username = Column(String(50)) cookie = Column(String(50)) expiration_date = Column(DateTime) + admin = Column(Boolean) comments = relationship("Comments") comments = relationship("CollaborativeRecords") diff --git a/backend/db/schemas.py b/backend/db/schemas.py index 325d2071b40bdcb8c3061058154b8d9ab1c8a74c..757bfbd0dd438b3a0b7252fc301f97400a155621 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: @@ -138,3 +138,4 @@ class User(BaseModel): username: str cookie: str expiration_date: datetime + admin: Optional[bool] = Field(default=False, title="Set to true to allow access to the admin interface") diff --git a/backend/main.py b/backend/main.py index ba7a7273e7fbcd7de70ec586c1bd11c248187224..51f0771b85ca32ef4be7f6b1f2acc4ea9b9d4dc1 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1,15 +1,20 @@ -from fastapi import FastAPI +from fastapi import Cookie, Depends, FastAPI from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import JSONResponse +from fastapi.openapi.docs import get_swagger_ui_html +from fastapi.openapi.utils import get_openapi +from sqlalchemy.orm import Session from dotenv import load_dotenv from threading import Thread from asyncio import run import os -from db import database, models +from db import database, models, crud +from db.database import get_db from routers import * from video_capture import handle_cameras -app = FastAPI(docs_url="/api/docs", openapi_url="/api/openapi.json") +app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None) # load environment variables load_dotenv() @@ -35,6 +40,21 @@ async def on_startup(): t.start() +# Docs OpenAPI +@app.get("/api/openapi.json") +async def get_open_api_endpoint(connect_id: str = Cookie(...), db: Session = Depends(get_db)): + user = crud.get_user(connect_id, db) + if user.admin: + return JSONResponse(get_openapi(title="FastAPI", version=1, routes=app.routes)) + + +@app.get("/api/docs") +async def get_documentation(connect_id: str = Cookie(...), db: Session = Depends(get_db)): + user = crud.get_user(connect_id, db) + if user.admin: + return get_swagger_ui_html(openapi_url="/api/openapi.json", title="docs") + + # Integration of routers app.include_router(infos.router) app.include_router(records.router) 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/backend/video_capture.py b/backend/video_capture.py index 8a9e78763a0542f0aea4b1721c576ce2aaac8e3c..66eafc8c784c757f3df2b2aaf1fc90101d763376 100644 --- a/backend/video_capture.py +++ b/backend/video_capture.py @@ -36,6 +36,7 @@ async def handle_cameras(): db = SessionLocal() for restaurant in restaurants: for camera in restaurant["cameras"]: + # each camera masked is constructed based on the given coordiantes and saved mask = np.zeros((720, 1280, 3), dtype=np.float32) cv2.fillPoly(mask, np.array(camera["mask_points"]), (255, 255, 255)) camera["mask"] = mask @@ -47,6 +48,7 @@ async def handle_cameras(): for restaurant in restaurants: + # we check whether or not the restaurant is opened is_open = db.query( models.OpeningHours).filter( models.OpeningHours.place == restaurant["restaurant"], @@ -55,13 +57,15 @@ async def handle_cameras(): models.OpeningHours.close_time >= current_time).first() is not None if is_open: - count_prediction = 0 - open_checkouts = 0 - cams_working = True + count_prediction = 0 # estimated number of people in the restaurant + open_checkouts = 0 # estimated number of open checkouts in the restaurant + cams_working = True # boolean indicating whether or not all cams are working in the restaurant for camera in restaurant["cameras"]: + # connection to the rtsp stream cap = cv2.VideoCapture(f'rtsp://{camera["user"]}:{camera["password"]}@{camera["IP"]}/{camera["stream"]}') if cams_working and cap.isOpened(): + # extraction and preoprocessing of the first frame _, frame = cap.read() masked_img = cv2.bitwise_and( frame.astype(np.float32), camera["mask"]) @@ -72,16 +76,19 @@ async def handle_cameras(): np.array( [treated_img]))), axis=0) + # getting the density map from the model and the number of people pred_map = np.squeeze(make_prediction(input_image)) count_prediction += np.sum(pred_map) - for caisse in camera["caisses"]: - if np.sum(pred_map[caisse["x1"] // 2:caisse["x2"] // 2, caisse["y1"] // 2:caisse["y2"] // 2]) > 0.5: + for checkout in camera["checkouts"]: + # we check whether or not the density in the checkout area is high enough to determine if it is open or not + if np.sum(pred_map[checkout["x1"] // 2:checkout["x2"] // 2, checkout["y1"] // 2:checkout["y2"] // 2]) > 0.5: open_checkouts += 1 else: cams_working = False cap.release() if cams_working and open_checkouts: + # the estimated waiting time is calculated and put in the database waiting_time = timedelta( seconds=restaurant['b_factor'] + int(count_prediction * @@ -94,4 +101,5 @@ async def handle_cameras(): db.add(db_record) db.commit() await manager.broadcast(json.dumps({"type": "data"})) + # sleeping enough time so that there is a 60 second delay between the start of two consecutive loops time.sleep(max(0, 60 - (datetime.now() - current_date).total_seconds())) 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/Header.js b/frontend/src/components/Header.js index b3ab6f54adf318f7a5b1ddd484107eec09de05d8..2a2ccad3ced779d57fd6e0632b79929c6e180828 100644 --- a/frontend/src/components/Header.js +++ b/frontend/src/components/Header.js @@ -19,7 +19,7 @@ export default function Header({ selection, setSelection }) { .get(`${process.env.REACT_APP_BASE_URL_BACK}/auth`, { withCredentials: true }) .then((res) => { setUser(res.data); - localStorage.setItem("user", res.data); + localStorage.setItem("user", JSON.stringify(res.data)); }) .catch((e) => console.log(e)); } 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";