Skip to content
Snippets Groups Projects

Compare revisions

Changes are shown as if the source revision was being merged into the target revision. Learn more about comparing revisions.

Source

Select target project
No results found
Select Git revision
  • main
1 result

Target

Select target project
  • raptornythorink/eatfast-website
1 result
Select Git revision
  • main
1 result
Show changes
Commits on Source (25)
Showing
with 406 additions and 73 deletions
......@@ -208,6 +208,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:
......@@ -222,6 +224,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:
......
......@@ -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
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
}
]
}
]
}
]
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,
......
......@@ -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()
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=closure.beginning_date)
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):
......
"""
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")
......@@ -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")
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)
......
......@@ -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")
......
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)):
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)
......
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)):
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)):
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")
......@@ -17,6 +17,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
......@@ -28,6 +29,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"],
......@@ -36,13 +38,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"])
......@@ -53,16 +57,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(model.predict(input_image, verbose=0))
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 *
......@@ -75,4 +82,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()))
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>
);
}
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,7 +145,8 @@ export default function Messages({ place, infos, lastMessage }) {
}, [lastMessage]);
return (
<div className="comments-side-bar">
<div className={`comments-side-bar ${admin ? "comments-full-size" : ""}`}>
{!admin && (
<div className="comments-title">
{infos ? (
<>
......@@ -141,6 +160,7 @@ export default function Messages({ place, infos, lastMessage }) {
</>
)}
</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>
......
......@@ -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));
}
......
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>
);
}
......@@ -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";
......@@ -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 }} />}
......
#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
......@@ -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;
......