diff --git a/backend/app/db/models.py b/backend/app/db/models.py index 19d570a..b7f99cc 100644 --- a/backend/app/db/models.py +++ b/backend/app/db/models.py @@ -76,6 +76,7 @@ class Queue(Base): users = relationship("QueueUser", backref="queue", lazy="dynamic") logs = relationship("QueueLog", backref="queue", lazy="dynamic") + groups = relationship("QueueGroup", backref="queue", lazy="dynamic") class QueueUser(Base): @@ -86,6 +87,7 @@ class QueueUser(Base): queue_id = Column(UUID(as_uuid=True), ForeignKey("queues.id")) position = Column(Integer) passed = Column(Boolean, default=False) + group_id = Column(UUID(as_uuid=True), ForeignKey("queuegroup.id"), nullable=True) class QueueLog(Base): @@ -97,6 +99,17 @@ class QueueLog(Base): created = Column(DateTime, default=datetime.datetime.utcnow) +class QueueGroup(Base): + __tablename__ = "queuegroup" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + name = Column(String) + priority = Column(Integer) + queue_id = Column(UUID(as_uuid=True), ForeignKey("queues.id")) + + users = relationship("QueueUser", backref="group", lazy="dynamic") + + class Captcha(Base): __tablename__ = "captcha" diff --git a/backend/app/views/auth/services.py b/backend/app/views/auth/services.py index 112d7e6..13ff2fb 100644 --- a/backend/app/views/auth/services.py +++ b/backend/app/views/auth/services.py @@ -96,17 +96,21 @@ def get_current_user( def get_current_user_or_none( - token: Annotated[str, Depends(oauth2_scheme)], db: Annotated[Session, Depends(get_db)], + authorization: Annotated[Union[str, None], Header()] = None, ) -> Union[schemas.UserInDB, None]: try: - payload = jwt.decode( - token, jwt_config.SECRET_KEY, algorithms=[jwt_config.ALGORITHM] - ) - username: str = payload.get("sub") - if username is None: - raise credentials_exception - token_data = schemas.TokenData(username=username) + if authorization: + token = authorization.split()[1] + payload = jwt.decode( + token, jwt_config.SECRET_KEY, algorithms=[jwt_config.ALGORITHM] + ) + username: str = payload.get("sub") + if username is None: + raise credentials_exception + token_data = schemas.TokenData(username=username) + else: + return None except JWTError: return None user = get_user_by_username(db, username=token_data.username) diff --git a/backend/app/views/queue/schemas.py b/backend/app/views/queue/schemas.py index 8e9412e..68f1174 100644 --- a/backend/app/views/queue/schemas.py +++ b/backend/app/views/queue/schemas.py @@ -4,10 +4,23 @@ from uuid import UUID from ..auth import schemas as auth_schemas +class QueueGroup(BaseModel): + name: str + priority: int + + +class QueueGroupDetail(QueueGroup): + id: UUID + + class Config: + from_attributes = True + + class QueueUser(BaseModel): id: UUID position: int passed: bool + group_id: UUID | None = None user: auth_schemas.AnonUser class Config: @@ -23,6 +36,7 @@ class ParticipantInfo(BaseModel): class Queue(BaseModel): name: str description: Union[str, None] = None + groups: List[QueueGroup] | None = None class QueueInList(Queue): @@ -32,8 +46,10 @@ class QueueInList(Queue): from_attributes = True -class QueueInDb(Queue): +class QueueInDb(BaseModel): id: UUID + name: str + description: Union[str, None] = None class Config: from_attributes = True @@ -44,6 +60,7 @@ class QueueDetail(Queue): status: str owner_id: UUID participants: ParticipantInfo + groups: List[QueueGroupDetail] | None class ActionResult(BaseModel): @@ -52,3 +69,7 @@ class ActionResult(BaseModel): class Config: from_attributes = True + + +class JoinRequest(BaseModel): + group_id: UUID | None = None diff --git a/backend/app/views/queue/services.py b/backend/app/views/queue/services.py index 6001bfd..fe82293 100644 --- a/backend/app/views/queue/services.py +++ b/backend/app/views/queue/services.py @@ -1,6 +1,7 @@ from fastapi import Depends, HTTPException, status from typing import Annotated -from sqlalchemy.orm import Session +from sqlalchemy.orm import Session, joinedload +from sqlalchemy import func from uuid import UUID import redis import asyncio @@ -49,6 +50,14 @@ def create_queue( ) db.add(q) db.commit() + if new_queue.groups: + db.add_all( + instances=[ + models.QueueGroup(name=qg.name, priority=qg.priority, queue_id=q.id) + for qg in new_queue.groups + ] + ) + db.commit() return schemas.QueueInDb.model_validate(q) @@ -64,6 +73,7 @@ def get_detailed_queue( description=q.description, status=q.status, owner_id=q.owner_id, + groups=q.groups.order_by(models.QueueGroup.priority.asc()), participants=schemas.ParticipantInfo( total=q.users.count(), remaining=q.users.filter(models.QueueUser.passed == False).count(), @@ -80,6 +90,7 @@ def get_detailed_queue( async def join_queue( queue_id: UUID, + join_request: schemas.JoinRequest | None, client: Annotated[auth_schemas.AnonUser, Depends(auth_services.get_anon_user)], db: Annotated[Session, Depends(get_db)], r: Annotated[redis.client.Redis, Depends(get_redis)], @@ -90,10 +101,18 @@ async def join_queue( last_qu = q.users.order_by(models.QueueUser.position.desc()).first() position = last_qu.position + 1 if last_qu else 0 new_qu = models.QueueUser( - user_id=client.id, queue_id=q.id, position=position + user_id=client.id, + queue_id=q.id, + position=position, + group_id=( + join_request.group_id + if join_request and join_request.group_id + else None + ), ) db.add(new_qu) db.commit() + await rebuild_queue(queue=q, db=db) await r.publish(str(queue_id), "updated") return new_qu raise HTTPException( @@ -129,7 +148,7 @@ async def get_queue_owner( async def verify_queue_owner( queue_owner: Annotated[auth_schemas.UserInDB, Depends(get_queue_owner)], current_user: Annotated[ - auth_schemas.UserInDB, Depends(auth_services.get_current_user) + auth_schemas.UserInDB, Depends(auth_services.get_current_user_or_none) ], ) -> bool: return queue_owner.id == current_user.id if queue_owner and current_user else False @@ -139,11 +158,32 @@ async def rebuild_queue( queue: Annotated[models.Queue, Depends(get_queue_by_id)], db: Annotated[Session, Depends(get_db)], ): - for i, qu in enumerate( - queue.users.filter(models.QueueUser.passed == False).order_by( - models.QueueUser.position.asc() + query = ( + db.query(models.QueueUser) + .join( + models.QueueGroup, + models.QueueUser.group_id == models.QueueGroup.id, + isouter=True, ) - ): + .filter(models.QueueUser.passed == False, models.QueueUser.queue_id == queue.id) + .order_by( + func.coalesce(models.QueueGroup.priority, 0).asc(), + models.QueueUser.position.asc(), + ) + .options(joinedload(models.QueueUser.group)) + ) + queueusers = query.all() + first_qu_found_and_queue_in_process = False + if queue.status == "active": + for i, qu in enumerate(queueusers): + if qu.position == 0: + first_qu_found_and_queue_in_process = True + del queueusers[i] + break + for i, qu in enumerate(queueusers): + if first_qu_found_and_queue_in_process: + setattr(qu, "position", i + 1) + continue setattr(qu, "position", i) db.commit() diff --git a/frontend/app/src/components/queue/ApproveQueueJoinCard.tsx b/frontend/app/src/components/queue/ApproveQueueJoinCard.tsx index 905c612..e11825c 100644 --- a/frontend/app/src/components/queue/ApproveQueueJoinCard.tsx +++ b/frontend/app/src/components/queue/ApproveQueueJoinCard.tsx @@ -1,6 +1,6 @@ import React, { useContext, useState } from "react"; import "../styles.css"; -import { Button, Input, Spin } from "antd"; +import { Button, Input, Select, Spin } from "antd"; import { ArrowLeftOutlined, FileTextOutlined, @@ -30,9 +30,10 @@ const ApproveQueueJoinCard = (props: { id: string }): JSX.Element => { const [joinQueue, { isLoading }] = useJoinQueueMutation(); const [patchAnon] = usePatchAnonMutation(); const [newName, setNewName] = useState(""); + const [selectedGroup, setSelectedGroup] = useState(); const onJoinButtonClick = () => { - joinQueue(props.id) + joinQueue({ queueId: props.id, data: { group_id: selectedGroup } }) .unwrap() .then(() => navigate(`/queue/${props.id}`)) .then(() => refetch()) @@ -63,20 +64,20 @@ const ApproveQueueJoinCard = (props: { id: string }): JSX.Element => { ) : (
- - {data?.name} - -

- - {" "} - {data?.description} -

-

- - {" "} - {data?.participants?.remaining} / {data?.participants?.total} -

+ + {data?.name} + +

+ + {" "} + {data?.description} +

+

+ + {" "} + {data?.participants?.remaining} / {data?.participants?.total} +

{tr("Update your name")}
{ {tr("Update")}
+ {data?.groups.length !== undefined && data?.groups.length > 0 && ( + <> + {tr("Select group in queue")} + + + + + {tr("Queue groups") + " " + tr("(optional)")} + + + + +
+ } + footer={ +
+ setNewGroupValue(e.target.value)} + onPressEnter={addGroupToList} + /> + +
+ } + renderItem={(item, index) => ( + +
+ #{index + 1} + {item.name} +
+ {item.id !== "default" && ( +
+
+ )} +
+ )} + > + - ) : ( - - ))} + {props.queueUser && + clientId === props.queueUser.user.id && + props.queueUser.position === 0 && + props.queue?.status === "active" && ( + + {tr("It is your turn!")} + + )} + +
+ {props.queueUser && + clientId === props.queueUser.user.id && + (props.queueUser.position === 0 && + props.queue?.status === "active" ? ( + + ) : ( + + ))} +
); }; diff --git a/frontend/app/src/config/style.ts b/frontend/app/src/config/style.ts index 5b240bb..d12b5bf 100644 --- a/frontend/app/src/config/style.ts +++ b/frontend/app/src/config/style.ts @@ -17,6 +17,9 @@ export const darkTheme: ThemeConfig = { activeBorderColor: "#001529", colorTextPlaceholder: "grey", }, + Select: { + colorTextPlaceholder: "grey", + }, }, }; diff --git a/frontend/app/src/slice/AuthApi.ts b/frontend/app/src/slice/AuthApi.ts index cd4d488..8cfff33 100644 --- a/frontend/app/src/slice/AuthApi.ts +++ b/frontend/app/src/slice/AuthApi.ts @@ -30,6 +30,7 @@ export type QueueUser = { position: number; passed: boolean; user: AnonUser; + group_id: string | undefined; }; export type AnonUser = { diff --git a/frontend/app/src/slice/QueueApi.ts b/frontend/app/src/slice/QueueApi.ts index 75fd0a6..a37b572 100644 --- a/frontend/app/src/slice/QueueApi.ts +++ b/frontend/app/src/slice/QueueApi.ts @@ -3,9 +3,26 @@ import { baseUrl } from "../config/baseUrl"; import { RootState } from "../config/store"; import { QueueUser } from "./AuthApi"; +type OrderedGroup = { + name: string; + priority: number; +}; + +type ResponseGroup = { + id: string; + name: string; + priority: number; +}; + export type CreateQueueRequest = { name: string; description: string | null; + groups: OrderedGroup[]; +}; + +export type CreateQueue = { + name: string; + description: string | null; }; export type Queue = { @@ -20,6 +37,7 @@ export type QueueDetail = { description: string | null; status: string; owner_id: string; + groups: ResponseGroup[]; participants: { total: number; remaining: number; @@ -27,6 +45,10 @@ export type QueueDetail = { }; }; +export type JoinRequest = { + group_id: string | undefined; +}; + export const QueueApi = createApi({ reducerPath: "QueueApi", baseQuery: fetchBaseQuery({ @@ -54,7 +76,11 @@ export const QueueApi = createApi({ query: (queueId: string | undefined) => `/${queueId}`, }), joinQueue: builder.mutation({ - query: (queueId: string) => ({ url: `/${queueId}/join`, method: "POST" }), + query: (args: { queueId: string; data: JoinRequest }) => ({ + url: `/${args.queueId}/join`, + method: "POST", + body: args.data, + }), }), createQueue: builder.mutation({ query: (data: CreateQueueRequest) => ({