news & things

This commit is contained in:
2024-04-13 14:37:30 +03:00
parent 8904d3c2b6
commit 89f59dabb1
17 changed files with 334 additions and 5 deletions

View File

@ -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"

View File

@ -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("/")

View File

View File

@ -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)

View File

@ -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

View File

@ -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"},
)

View File

@ -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!")));
};

View File

@ -3,6 +3,7 @@ import {
GlobalOutlined,
LogoutOutlined,
MenuOutlined,
PicCenterOutlined,
SettingOutlined,
UserOutlined,
} from "@ant-design/icons";
@ -100,6 +101,11 @@ const HeaderComponent = () => {
icon: <DesktopOutlined />,
disabled: !user,
},
{
label: <Link to="/news">{tr("News")}</Link>,
key: "news",
icon: <PicCenterOutlined />,
},
{
label: tr("Language"),
key: "language",
@ -111,7 +117,7 @@ const HeaderComponent = () => {
label: user ? user.username : tr("Log in"),
key: "login",
icon: <UserOutlined />,
onClick: () => setAuthModalOpen(true),
onClick: () => !user && setAuthModalOpen(true),
...(user ? { children: userMenuItems } : {}),
},
];
@ -139,6 +145,13 @@ const HeaderComponent = () => {
key: "dashboard",
icon: <DesktopOutlined />,
disabled: !user,
onClick: () => setDrawerOpen(false),
},
{
label: <Link to="/news">{tr("News")}</Link>,
key: "news",
icon: <PicCenterOutlined />,
onClick: () => setDrawerOpen(false),
},
{
label: tr("Language"),
@ -151,14 +164,18 @@ const HeaderComponent = () => {
label: user ? user.username : tr("Log in"),
key: "login",
icon: <UserOutlined />,
onClick: () => setAuthModalOpen(true),
onClick: () => !user && setAuthModalOpen(true),
...(user ? { children: userMenuItems } : {}),
},
];
return (
<>
<AuthModal open={authModalOpen} setOpen={setAuthModalOpen} />
<AuthModal
open={authModalOpen}
setOpen={setAuthModalOpen}
setDrawerOpen={setDrawerOpen}
/>
<Header className="header">
<Menu
theme="dark"

View File

@ -0,0 +1,73 @@
import React, { useContext } from "react";
import "../styles.css";
import { Button, Form, Input, Spin } from "antd";
import Title from "antd/es/typography/Title";
import tr from "../../config/translation";
import { MessageContext } from "../../App";
import { useNavigate } from "react-router-dom";
import { PlusCircleOutlined } from "@ant-design/icons";
import { CreateNewsRequest, useCreateNewsMutation } from "../../slice/NewsApi";
const CreateNewsCard = (): JSX.Element => {
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 (
<div className="card">
<Spin spinning={isLoading}>
<Title level={2}>{tr("New news")}</Title>
<Form
form={form}
layout="vertical"
requiredMark={false}
onFinish={(formData: CreateNewsRequest) => submit(formData)}
>
<Form.Item
name={"title"}
label={tr("Title")}
rules={[
{
required: true,
message: tr("Please input news title!"),
},
]}
>
<Input />
</Form.Item>
<Form.Item
name={"content"}
label={tr("Content")}
rules={[
{
required: true,
message: tr("Please input news content!"),
},
]}
>
<Input />
</Form.Item>
<Button
style={{ width: "100%" }}
icon={<PlusCircleOutlined />}
type="primary"
onClick={() => form.submit()}
>
{tr("Publish")}
</Button>
</Form>
</Spin>
</div>
);
};
export default CreateNewsCard;

View File

@ -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 (
<div className="card">
<Spin spinning={isLoading}>
{data &&
data.map((news: News) => (
<Card
title={news.title}
key={news.id}
style={{ width: "100%", marginBottom: "1rem" }}
bordered={false}
>
<p style={{ textAlign: "left", color: "white" }}>
{news.content}
</p>
<br />
<div className="news-footer">
<ClockCircleOutlined />
<br />
<p style={{ marginLeft: "1rem" }}>{formatTime(news.created)}</p>
</div>
</Card>
))}
</Spin>
</div>
);
};
export default NewsListCard;

View File

@ -91,3 +91,9 @@
transform: rotate(0.05turn);
transition-duration: 0.2s;
}
.news-footer {
color: grey;
display: flex;
flex-flow: row;
}

View File

@ -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

View File

@ -94,5 +94,8 @@
},
"Failed to create queue": {
"ru": "Не удалось создать очередь"
},
"News": {
"ru": "Новости"
}
}

View File

@ -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 }) => {
<Route path="/" element={<MainPage />} />
<Route path="/dashboard" element={<DashboardPage />} />
<Route path="/dashboard/new" element={<NewQueuePage />} />
<Route path="/news" element={<NewsPage />} />
<Route path="/news/new" element={<CreateNewsPage />} />
<Route path="*" element={<NotFoundPage />} />
</Routes>
</BrowserRouter>

View File

@ -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" ? (
<CreateNewsCard />
) : (
<NotFoundPage />
);
};
export default CreateNewsPage;

View File

@ -0,0 +1,9 @@
import React from "react";
import "./styles.css";
import NewsListCard from "../components/news/NewsListCard";
const NewsPage = () => {
return <NewsListCard />;
};
export default NewsPage;

View File

@ -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;