diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..26d3352 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,3 @@ +# Default ignored files +/shelf/ +/workspace.xml diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 0000000..3aabcda --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,25 @@ + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml new file mode 100644 index 0000000..105ce2d --- /dev/null +++ b/.idea/inspectionProfiles/profiles_settings.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/minecraft.iml b/.idea/minecraft.iml new file mode 100644 index 0000000..8437fe6 --- /dev/null +++ b/.idea/minecraft.iml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..13bc4fb --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,7 @@ + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..577a03d --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..35eb1dd --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/server/.env.dev b/server/.env.dev index e69de29..2df0093 100644 --- a/server/.env.dev +++ b/server/.env.dev @@ -0,0 +1,3 @@ +DATABASE_URL="sqlite+aiosqlite:///./database.sqlite3" +JWT_ALG='HS256' +JWT_KEY='JWT_KEY' diff --git a/server/README.md b/server/README.md index e7eda45..999c5e2 100644 --- a/server/README.md +++ b/server/README.md @@ -1 +1 @@ -pip install --index-url http://packages.webflare.ru:8888/simple --trusted-host packages.webflare.ru pyjwt \ No newline at end of file +pip install --index-url http://packages.webflare.ru:8888/simple --trusted-host packages.webflare.ru uv diff --git a/server/__init__.py b/server/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/server/src/application/interface/server.py b/server/src/application/interface/server.py new file mode 100644 index 0000000..e89d1f2 --- /dev/null +++ b/server/src/application/interface/server.py @@ -0,0 +1,32 @@ +from collections.abc import Sequence +from typing import Protocol +from uuid import UUID + +from sqlalchemy.ext.asyncio import AsyncSession + +from server.src.application.rest.v1.schemas.server import CreateServerDTO, UpdateServerDTO, \ + FetchListParamsDTO +from server.src.core.database.table import Server + + +class IServerService(Protocol): + async def fetch_list( + self, input_dto: FetchListParamsDTO + ) -> Sequence[Server]: ... + + + async def fetch_by_id( + self, input_dto: UUID + ) -> Server: ... + + async def create( + self, input_dto: CreateServerDTO + ) -> Server: ... + + async def update( + self, input_dto: tuple[UUID, UpdateServerDTO] + ) -> Server: ... + + async def delete( + self, input_dto: UUID + ) -> None: ... diff --git a/server/src/application/rest/di.py b/server/src/application/rest/di.py index e69de29..3200068 100644 --- a/server/src/application/rest/di.py +++ b/server/src/application/rest/di.py @@ -0,0 +1,7 @@ +from server.src.application.interface.server import IServerService +from server.src.application.use_case.server import ServerService +from server.src.core.database.di import db + + +def get_server_service() -> IServerService: + return ServerService(provider=db) diff --git a/server/src/application/rest/schemas.py b/server/src/application/rest/schemas.py new file mode 100644 index 0000000..ef5f2d2 --- /dev/null +++ b/server/src/application/rest/schemas.py @@ -0,0 +1,12 @@ +from pydantic import BaseModel, ConfigDict, Field + + +class BaseDTO(BaseModel): + model_config = ConfigDict( + from_attributes=True + ) + + +class PaginationDTO(BaseDTO): + limit: int = Field(ge=10, le=100, default=10) + offset: int = Field(ge=0, le=100, default=0) diff --git a/server/src/application/rest/v1/api/server.py b/server/src/application/rest/v1/api/server.py new file mode 100644 index 0000000..959cc60 --- /dev/null +++ b/server/src/application/rest/v1/api/server.py @@ -0,0 +1,81 @@ +from collections.abc import Sequence +from http import HTTPStatus +from uuid import UUID + +from fastapi import APIRouter, Depends +from fastapi.params import Query + +from server.src.application.interface.server import IServerService +from server.src.application.rest.di import get_server_service +from server.src.application.rest.v1.schemas.server import ServerDTO, FetchListParamsDTO, CreateServerDTO, \ + UpdateServerDTO + +router = APIRouter(prefix="/servers", tags=["Server"]) + + +@router.get( + path="/", + response_model=Sequence[ServerDTO], + status_code=HTTPStatus.OK +) +async def fetch_list( + params: FetchListParamsDTO = Query(), + service: IServerService = Depends(get_server_service) +) -> Sequence[ServerDTO]: + result = service.fetch_list(input_dto=params) + return [ServerDTO.model_validate(result)] + + +@router.get( + path="/{server_id}", + response_model=ServerDTO, + status_code=HTTPStatus.OK +) +async def fetch_by_id( + server_id: UUID, + service: IServerService = Depends(get_server_service) +) -> ServerDTO: + result = service.fetch_by_id(input_dto=server_id) + return ServerDTO.model_validate(result) + + +@router.post( + path="/", + response_model=ServerDTO, + status_code=HTTPStatus.CREATED +) +async def create( + create_data: CreateServerDTO, + service: IServerService = Depends(get_server_service) +) -> ServerDTO: + result = service.create(input_dto=create_data) + return ServerDTO.model_validate(result) + + +@router.put( + path="/{server_id}", + response_model=ServerDTO, + status_code=HTTPStatus.OK +) +async def update( + server_id: UUID, + update_data: UpdateServerDTO, + service: IServerService = Depends(get_server_service) +) -> ServerDTO: + result = service.update( + input_dto=(server_id, update_data) + ) + return ServerDTO.model_validate(result) + + +@router.delete( + path="/{server_id}", + response_model=ServerDTO, + status_code=HTTPStatus.NO_CONTENT +) +async def delete( + server_id: UUID, + service: IServerService = Depends(get_server_service) +) -> ServerDTO: + result = service.delete(input_dto=server_id) + return ServerDTO.model_validate(result) diff --git a/server/src/application/rest/v1/schemas/server.py b/server/src/application/rest/v1/schemas/server.py new file mode 100644 index 0000000..530402e --- /dev/null +++ b/server/src/application/rest/v1/schemas/server.py @@ -0,0 +1,52 @@ +from enum import Enum +from pydantic import HttpUrl, Field, IPvAnyAddress, constr +from server.src.application.rest.schemas import BaseDTO, PaginationDTO + + +class ServerStatus(Enum): + ONLINE = "online" + OFFLINE = "offline" + + +class ServerDTO(BaseDTO): + id: int + img_url: HttpUrl | None + name: str + domain: str + ip_address: IPvAnyAddress + port: int + version: str + max_players: int + online_players: int + status: ServerStatus + type: str + + +class CreateServerDTO(BaseDTO): + img_url: HttpUrl | None = Field(default=None) + name: str = Field(min_length=5, max_length=100) + domain: str + ip_address: IPvAnyAddress + port: int = Field(default=25565) + version: constr(pattern=r'^\d+\.\d+(\.\d+)?$') + max_players: int = Field(ge=1) + online_players: int = Field(ge=0) + status: ServerStatus = Field(default=ServerStatus.ONLINE) + type: str + + +class UpdateServerDTO(BaseDTO): + img_url: HttpUrl | None = Field(default=None) + name: str | None = Field(min_length=5, max_length=100) + domain: str | None + ip_address: IPvAnyAddress | None + port: int | None + version: constr(pattern=r'^\d+\.\d+(\.\d+)?$') | None + max_players: int = Field(ge=1) | None + online_players: int = Field(ge=0) | None + status: ServerStatus | None + type: str | None + + +class FetchListParamsDTO(PaginationDTO): + pass diff --git a/server/src/application/use_case/server.py b/server/src/application/use_case/server.py new file mode 100644 index 0000000..ec581c1 --- /dev/null +++ b/server/src/application/use_case/server.py @@ -0,0 +1,68 @@ +from collections.abc import Sequence +from uuid import UUID +from sqlalchemy import select, delete, insert, update + +from server.src.application.interface.server import IServerService +from server.src.application.rest.v1.schemas.server import CreateServerDTO, UpdateServerDTO, \ + FetchListParamsDTO +from server.src.core.database.di import DBProvaider +from server.src.core.database.table import Server + + +class ServerService(IServerService): + + def __init__( + self, provider: DBProvaider + ) -> None: + self.session = provider.session + + async def fetch_list( + self, input_dto: FetchListParamsDTO + ) -> Sequence[Server]: + query = ( + select(Server) + .offset(input_dto.offset) + .limit(input_dto.limit) + ) + return (await self.session.scalars(query)).all() + + async def fetch_by_id( + self, input_dto: UUID + ) -> Server: + query = ( + select(Server) + .where(Server.id == input_dto) + ) + return (await self.session.scalars(query)).one() + + async def create( + self, input_dto: CreateServerDTO + ) -> Server: + stmt = ( + insert(Server) + .values( + input_dto.model_dump() + ) + .returning(Server) + ) + return (await self.session.scalars(stmt)).one() + + async def update( + self, input_dto: tuple[UUID, UpdateServerDTO] + ) -> Server: + server_id, update_data = input_dto + stmt = ( + update(Server) + .where(Server.id == server_id) + .values(**update_data.model_dump( + exclude_unset=True + )) + .returning(Server) + ) + return (await self.session.scalars(stmt)).one() + + async def delete( + self, input_dto: UUID + ) -> None: + stmt = delete(Server).where(Server.id == input_dto) + await self.session.execute(stmt) diff --git a/server/src/core/config.py b/server/src/core/config.py index e69de29..d46feaf 100644 --- a/server/src/core/config.py +++ b/server/src/core/config.py @@ -0,0 +1,14 @@ +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + model_config = SettingsConfigDict( + env_file=".env", + env_file_encoding="utf-8", + extra="ignore" + ) + DATABASE_URL: str ="sqlite+aiosqlite:///./database.sqlite3" + JWT_ALG: str ='HS256' + JWT_KEY: str ='JWT_KEY' + +settings = Settings() diff --git a/server/src/core/database/di.py b/server/src/core/database/di.py index e69de29..c5a255a 100644 --- a/server/src/core/database/di.py +++ b/server/src/core/database/di.py @@ -0,0 +1,27 @@ +from src.core.database.utils import Database +from src.core.config import settings, Settings + +class DBProvaider: + serves: Database = Database() + settings: Settings = settings + + serves.create_engine( + settings.DATABASE_URL, + ) + + serves.create_session() + + session = serves.session + async def get_session(self): + async with self.session as session: + try: + yield session + await session.flush() + except Exception as e: + await session.rollback() + raise e + finally: + await session.close() + + +db = DBProvaider() diff --git a/server/src/core/database/table.py b/server/src/core/database/table.py index 66a9ab6..2223478 100644 --- a/server/src/core/database/table.py +++ b/server/src/core/database/table.py @@ -1,17 +1,37 @@ -from uuid import UUID from uuid import uuid4 from datetime import datetime +from uuid import UUID +from sqlalchemy import UUID as SQL_UUID, DateTime, String, Integer, Enum +from sqlalchemy.orm import DeclarativeBase, mapped_column, Mapped, declared_attr + +from server.src.application.rest.v1.schemas.server import ServerStatus + class TableSQLBase(DeclarativeBase): - id: Mapped[UUID] = mapped_column(UUID, default=uuid4(), unqiue=True, primery_key=True) + id: Mapped[UUID] = mapped_column(SQL_UUID, default=uuid4(), unqiue=True, primery_key=True) created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.now(), nuleble=False) updated_at: Mapped[datetime] = mapped_column(DateTime, onupdate=datetime.now(), nuleble=True) - @getattr - def __tablename(cls): - return f"{cls.__name__.lover()}s" + @declared_attr + def __tablename__(cls): + return f"{cls.__name__.lower()}s" class User(TableSQLBase): username: Mapped[str] = mapped_column(String(50), nuleble=False) password: Mapped[str] = mapped_column(String(255), nuleble=False) + + +class Server(TableSQLBase): + class Server(TableSQLBase): + img_url: Mapped[str | None] = mapped_column(String(500)) + name: Mapped[str] = mapped_column(String(100)) + ip_address: Mapped[str] = mapped_column(String(45)) + port: Mapped[int] = mapped_column(Integer, default=25565) + version: Mapped[str] = mapped_column(String(20)) + max_players: Mapped[int] = mapped_column(Integer, default=20) + online_players: Mapped[int] = mapped_column(Integer, default=0) + status: Mapped[ServerStatus] = mapped_column( + Enum(ServerStatus), default=ServerStatus.OFFLINE + ) + type: Mapped[str] = mapped_column(String(50), nullable=False) diff --git a/server/src/core/database/utils.py b/server/src/core/database/utils.py index e69de29..b55bf13 100644 --- a/server/src/core/database/utils.py +++ b/server/src/core/database/utils.py @@ -0,0 +1,25 @@ +from sqlalchemy.ext.asyncio import ( + create_async_engine, async_sessionmaker, AsyncSession, AsyncEngine +) + + +class Database: + engine: AsyncEngine + session: AsyncSession + + def engine(self, + url: str, + echo: bool=False + ): + self.engine = create_async_engine( + url, + echo=echo + ) + + def session(self, autoflush:bool = False): + self.session = async_sessionmaker( + self.engine, + class_=AsyncSession, + autoflush=autoflush + ) +