diff --git a/backend/app/db/models.py b/backend/app/db/models.py index 9fffc5c..1e52c7f 100644 --- a/backend/app/db/models.py +++ b/backend/app/db/models.py @@ -1,7 +1,8 @@ -from sqlalchemy import Boolean, Column, ForeignKey, Integer, String +from sqlalchemy import Boolean, Column, ForeignKey, Integer, String, DateTime from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.orm import relationship import uuid +import datetime from .database import Base @@ -18,6 +19,15 @@ class User(Base): owns_queues = relationship("Queue", backref="owner", lazy="dynamic") +class News(Base): + __tablename__ = "news" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + title = Column(String) + content = Column(String) + created = Column(DateTime, default=datetime.datetime.utcnow) + + class AnonymousUser(Base): __tablename__ = "anonymoususers" diff --git a/backend/app/main.py b/backend/app/main.py index 11acbbe..288d2a9 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -7,12 +7,14 @@ from .dependencies import get_db from .views.auth.api import router as auth_router from .views.queue.api import router as queue_router +from .views.news.api import router as news_router app = FastAPI(dependencies=[Depends(get_db)]) models.Base.metadata.create_all(bind=engine) app.include_router(queue_router) app.include_router(auth_router) +app.include_router(news_router) @app.get("/") diff --git a/backend/app/views/news/__init__.py b/backend/app/views/news/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/views/news/api.py b/backend/app/views/news/api.py new file mode 100644 index 0000000..754ca3d --- /dev/null +++ b/backend/app/views/news/api.py @@ -0,0 +1,41 @@ +from datetime import datetime, timedelta, timezone +from typing import Annotated, Union +from sqlalchemy.orm import Session + +from fastapi import APIRouter, Depends, HTTPException, status +from fastapi.security import OAuth2PasswordRequestForm + + +from pydantic import BaseModel + +from ...config import jwt_config +from ...dependencies import get_db +from . import schemas +from . import services + +from ..auth import services as auth_services +from ..auth import schemas as auth_schemas + + +router = APIRouter( + prefix="/news", + tags=["news"], + dependencies=[Depends(get_db)], + responses={404: {"description": "Not found"}}, +) + + +@router.get("/") +async def get_news( + news: Annotated[schemas.NewsInDb, Depends(services.get_news)], +) -> list[schemas.NewsInDb]: + return news + + +@router.post("/") +async def create_news( + news: schemas.CreateNews, + current_user: Annotated[auth_schemas.User, Depends(auth_services.get_current_user)], + db: Annotated[Session, Depends(get_db)], +) -> schemas.NewsInDb: + return services.create_news(news=news, current_user=current_user, db=db) diff --git a/backend/app/views/news/schemas.py b/backend/app/views/news/schemas.py new file mode 100644 index 0000000..dda29da --- /dev/null +++ b/backend/app/views/news/schemas.py @@ -0,0 +1,22 @@ +from typing import Union +from pydantic import BaseModel +from uuid import UUID +from datetime import datetime + + +class CreateNews(BaseModel): + title: str + content: str + + +class News(BaseModel): + title: str + content: str + created: datetime + + +class NewsInDb(News): + id: UUID + + class Config: + from_attributes = True diff --git a/backend/app/views/news/services.py b/backend/app/views/news/services.py new file mode 100644 index 0000000..ef3afdb --- /dev/null +++ b/backend/app/views/news/services.py @@ -0,0 +1,37 @@ +from fastapi import Depends, HTTPException +from typing import Annotated +from sqlalchemy.orm import Session + +from ...dependencies import get_db +from ...db import models + +from ..auth import services as auth_services +from ..auth import schemas as auth_schemas + +from . import schemas + + +def get_news( + db: Annotated[Session, Depends(get_db)], +) -> list[schemas.NewsInDb]: + return [ + schemas.NewsInDb.model_validate(n) + for n in db.query(models.News).order_by(models.News.created.desc()).all() + ] + + +def create_news( + news: schemas.CreateNews, + current_user: auth_schemas.UserInDB, + db: Session, +) -> schemas.NewsInDb: + if current_user.username == "admin": + n = models.News(title=news.title, content=news.content) + db.add(n) + db.commit() + return schemas.NewsInDb.model_validate(n) + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Could not validate credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) diff --git a/frontend/app/src/components/AuthModal.tsx b/frontend/app/src/components/AuthModal.tsx index b5e79d7..f99738e 100644 --- a/frontend/app/src/components/AuthModal.tsx +++ b/frontend/app/src/components/AuthModal.tsx @@ -19,6 +19,7 @@ import { useNavigate } from "react-router-dom"; const AuthModal = (props: { open: boolean; setOpen: (arg0: boolean) => void; + setDrawerOpen: (arg0: boolean) => void; }) => { const navigate = useNavigate(); const messageApi = useContext(MessageContext); @@ -58,6 +59,7 @@ const AuthModal = (props: { .then(() => refetch()) .then(() => props.setOpen(false)) .then(() => navigate("/dashboard")) + .then(() => props.setDrawerOpen(false)) .catch(() => messageApi.error(tr("Login failed!"))); }; diff --git a/frontend/app/src/components/HeaderComponent.tsx b/frontend/app/src/components/HeaderComponent.tsx index d5bcb4f..cc57ff3 100644 --- a/frontend/app/src/components/HeaderComponent.tsx +++ b/frontend/app/src/components/HeaderComponent.tsx @@ -3,6 +3,7 @@ import { GlobalOutlined, LogoutOutlined, MenuOutlined, + PicCenterOutlined, SettingOutlined, UserOutlined, } from "@ant-design/icons"; @@ -100,6 +101,11 @@ const HeaderComponent = () => { icon: , disabled: !user, }, + { + label: {tr("News")}, + key: "news", + icon: , + }, { label: tr("Language"), key: "language", @@ -111,7 +117,7 @@ const HeaderComponent = () => { label: user ? user.username : tr("Log in"), key: "login", icon: , - onClick: () => setAuthModalOpen(true), + onClick: () => !user && setAuthModalOpen(true), ...(user ? { children: userMenuItems } : {}), }, ]; @@ -139,6 +145,13 @@ const HeaderComponent = () => { key: "dashboard", icon: , disabled: !user, + onClick: () => setDrawerOpen(false), + }, + { + label: {tr("News")}, + key: "news", + icon: , + onClick: () => setDrawerOpen(false), }, { label: tr("Language"), @@ -151,14 +164,18 @@ const HeaderComponent = () => { label: user ? user.username : tr("Log in"), key: "login", icon: , - onClick: () => setAuthModalOpen(true), + onClick: () => !user && setAuthModalOpen(true), ...(user ? { children: userMenuItems } : {}), }, ]; return ( <> - +
{ + const messageApi = useContext(MessageContext); + const navigate = useNavigate(); + + const [form] = Form.useForm(); + const [createNews, { isLoading }] = useCreateNewsMutation(); + + const submit = (formData: CreateNewsRequest) => { + createNews(formData) + .unwrap() + .then(() => navigate(`/news`)) + .then(() => messageApi.success(tr("News created"))) + .catch(() => messageApi.error(tr("Failed to create news"))); + }; + + return ( +
+ + {tr("New news")} +
submit(formData)} + > + + + + + + + +
+
+
+ ); +}; +export default CreateNewsCard; diff --git a/frontend/app/src/components/news/NewsListCard.tsx b/frontend/app/src/components/news/NewsListCard.tsx new file mode 100644 index 0000000..c3a6dee --- /dev/null +++ b/frontend/app/src/components/news/NewsListCard.tsx @@ -0,0 +1,40 @@ +import React from "react"; +import "../styles.css"; +import { Card, Spin } from "antd"; +import { News, useGetNewsQuery } from "../../slice/NewsApi"; +import { ClockCircleOutlined } from "@ant-design/icons"; + +const formatTime = (s: string) => { + const d = new Date(s); + return d.toLocaleString(); +}; + +const NewsListCard = (): JSX.Element => { + const { data, isLoading } = useGetNewsQuery({}); + return ( +
+ + {data && + data.map((news: News) => ( + +

+ {news.content} +

+
+
+ +
+

{formatTime(news.created)}

+
+
+ ))} +
+
+ ); +}; +export default NewsListCard; diff --git a/frontend/app/src/components/styles.css b/frontend/app/src/components/styles.css index 8e04c61..a6595d5 100644 --- a/frontend/app/src/components/styles.css +++ b/frontend/app/src/components/styles.css @@ -91,3 +91,9 @@ transform: rotate(0.05turn); transition-duration: 0.2s; } + +.news-footer { + color: grey; + display: flex; + flex-flow: row; +} diff --git a/frontend/app/src/config/store.ts b/frontend/app/src/config/store.ts index 1a6b14c..9c99828 100644 --- a/frontend/app/src/config/store.ts +++ b/frontend/app/src/config/store.ts @@ -7,6 +7,7 @@ import { import { setupListeners } from "@reduxjs/toolkit/query"; import { AuthApi, User } from "../slice/AuthApi"; import { QueueApi } from "../slice/QueueApi"; +import { NewsApi } from "../slice/NewsApi"; export type AuthDataType = { token: string | null; @@ -45,6 +46,7 @@ export const store = configureStore({ // Add the generated reducer as a specific top-level slice [AuthApi.reducerPath]: AuthApi.reducer, [QueueApi.reducerPath]: QueueApi.reducer, + [NewsApi.reducerPath]: NewsApi.reducer, auth: createReducer(initialAuthDataState, (builder) => { builder.addCase(updateToken, (state, action) => { state.token = action.payload; @@ -85,7 +87,8 @@ export const store = configureStore({ middleware: (getDefaultMiddleware) => getDefaultMiddleware() .concat(AuthApi.middleware) - .concat(QueueApi.middleware), + .concat(QueueApi.middleware) + .concat(NewsApi.middleware), }); // optional, but required for refetchOnFocus/refetchOnReconnect behaviors diff --git a/frontend/app/src/config/translationMap.json b/frontend/app/src/config/translationMap.json index 36e700e..b5a618c 100644 --- a/frontend/app/src/config/translationMap.json +++ b/frontend/app/src/config/translationMap.json @@ -94,5 +94,8 @@ }, "Failed to create queue": { "ru": "Не удалось создать очередь" + }, + "News": { + "ru": "Новости" } } \ No newline at end of file diff --git a/frontend/app/src/pages/AppRoutes.tsx b/frontend/app/src/pages/AppRoutes.tsx index 69c1a71..950cc70 100644 --- a/frontend/app/src/pages/AppRoutes.tsx +++ b/frontend/app/src/pages/AppRoutes.tsx @@ -6,6 +6,8 @@ import DashboardPage from "./DashboardPage"; import PropTypes from "prop-types"; import NotFoundPage from "./NotFoundPage"; import NewQueuePage from "./NewQueuePage"; +import NewsPage from "./NewsPage"; +import CreateNewsPage from "./CreateNewsPage"; const AppRoutes = ({ children }: { children: ReactNode }) => { store.dispatch(getLocalToken()); @@ -18,6 +20,8 @@ const AppRoutes = ({ children }: { children: ReactNode }) => { } /> } /> } /> + } /> + } /> } /> diff --git a/frontend/app/src/pages/CreateNewsPage.tsx b/frontend/app/src/pages/CreateNewsPage.tsx new file mode 100644 index 0000000..b1b7caf --- /dev/null +++ b/frontend/app/src/pages/CreateNewsPage.tsx @@ -0,0 +1,17 @@ +import React from "react"; +import "./styles.css"; +import { useSelector } from "react-redux"; +import { StorePrototype } from "../config/store"; +import CreateNewsCard from "../components/news/CreateNewsCard"; +import NotFoundPage from "./NotFoundPage"; + +const CreateNewsPage = () => { + const user = useSelector((state: StorePrototype) => state.auth.user); + return user && user.username === "admin" ? ( + + ) : ( + + ); +}; + +export default CreateNewsPage; diff --git a/frontend/app/src/pages/NewsPage.tsx b/frontend/app/src/pages/NewsPage.tsx new file mode 100644 index 0000000..fb16171 --- /dev/null +++ b/frontend/app/src/pages/NewsPage.tsx @@ -0,0 +1,9 @@ +import React from "react"; +import "./styles.css"; +import NewsListCard from "../components/news/NewsListCard"; + +const NewsPage = () => { + return ; +}; + +export default NewsPage; diff --git a/frontend/app/src/slice/NewsApi.ts b/frontend/app/src/slice/NewsApi.ts new file mode 100644 index 0000000..d95032a --- /dev/null +++ b/frontend/app/src/slice/NewsApi.ts @@ -0,0 +1,43 @@ +import { createApi, fetchBaseQuery } from "@reduxjs/toolkit/query/react"; +import { baseUrl } from "../config/baseUrl"; +import { RootState } from "../config/store"; + +export type CreateNewsRequest = { + title: string; + content: string | null; +}; + +export type News = { + id: string; + title: string; + content: string; + created: string; +}; + +export const NewsApi = createApi({ + reducerPath: "NewsApi", + baseQuery: fetchBaseQuery({ + baseUrl: `${baseUrl}/news`, + prepareHeaders: (headers, { getState }) => { + const token = (getState() as RootState).auth.token; + if (token) { + headers.set("authorization", `Bearer ${token}`); + } + return headers; + }, + }), + endpoints: (builder) => ({ + getNews: builder.query({ + query: () => "/", + }), + createNews: builder.mutation({ + query: (data: CreateNewsRequest) => ({ + url: "/", + method: "POST", + body: data, + }), + }), + }), +}); + +export const { useGetNewsQuery, useCreateNewsMutation } = NewsApi;