diff --git a/backend/session.py b/backend/session.py index 2d328ac..3f3cf81 100644 --- a/backend/session.py +++ b/backend/session.py @@ -1,3 +1,5 @@ +from typing import AsyncGenerator + from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession from sqlalchemy.orm import sessionmaker @@ -10,10 +12,10 @@ session_maker = sessionmaker( class_=AsyncSession, expire_on_commit=False, autocommit=False, - autoflush=False + autoflush=False, ) -async def get_session() -> AsyncSession: +async def get_session() -> AsyncGenerator[AsyncSession, None]: async with session_maker() as session: yield session diff --git a/main.py b/main.py index 124d4f1..dfa54ef 100644 --- a/main.py +++ b/main.py @@ -19,7 +19,8 @@ app.add_middleware( routers_list = [ routers.auth_router, routers.deal_router, - routers.client_router + routers.client_router, + routers.service_router ] for router in routers_list: app.include_router(router) diff --git a/models/__init__.py b/models/__init__.py index dbd6ad6..7827e82 100644 --- a/models/__init__.py +++ b/models/__init__.py @@ -1,3 +1,9 @@ +from sqlalchemy.orm import configure_mappers + from .auth import * from .deal import * from .client import * +from .service import * +from .secondary import * + +configure_mappers() diff --git a/models/base.py b/models/base.py index ebdfe6c..6e3aeab 100644 --- a/models/base.py +++ b/models/base.py @@ -1,3 +1,4 @@ from sqlalchemy.orm import declarative_base BaseModel = declarative_base() +metadata = BaseModel.metadata \ No newline at end of file diff --git a/models/client.py b/models/client.py index ff9c1e0..deb0fe7 100644 --- a/models/client.py +++ b/models/client.py @@ -1,5 +1,5 @@ from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, BigInteger -from sqlalchemy.orm import relationship +from sqlalchemy.orm import relationship, backref from models import BaseModel @@ -17,7 +17,7 @@ class ClientDetails(BaseModel): id = Column(Integer, autoincrement=True, primary_key=True, index=True) client_id = Column(Integer, ForeignKey('clients.id'), unique=True, nullable=False, comment='ID клиента') - client = relationship('Client', backref='details', uselist=False) + client = relationship('Client', backref=backref('details', uselist=False)) address = Column(String) phone_number = Column(String) diff --git a/models/deal.py b/models/deal.py index ae05d42..b231482 100644 --- a/models/deal.py +++ b/models/deal.py @@ -1,9 +1,10 @@ from enum import IntEnum, unique -from sqlalchemy import Column, Integer, String, DateTime, ForeignKey +from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Boolean from sqlalchemy.orm import relationship from models.base import BaseModel +from models.secondary import deal_services @unique @@ -29,6 +30,11 @@ class Deal(BaseModel): status_history = relationship('DealStatusHistory', back_populates='deal', cascade="all, delete-orphan") + is_deleted = Column(Boolean, nullable=False, server_default='0', default=False, comment='Удалена') + is_completed = Column(Boolean, nullable=False, server_default='0', default=False, comment='Завершена') + + services = relationship('Service', secondary=deal_services) + class DealStatusHistory(BaseModel): __tablename__ = 'deals_status_history' diff --git a/models/secondary.py b/models/secondary.py new file mode 100644 index 0000000..b5a7c68 --- /dev/null +++ b/models/secondary.py @@ -0,0 +1,10 @@ +from sqlalchemy import Table, Column, Integer, ForeignKey + +from models.base import metadata + +deal_services = Table( + 'deal_services', metadata, + Column('deal_id', Integer, ForeignKey('deals.id')), + Column('service_id', Integer, ForeignKey('services.id')), + Column('quantity', Integer) +) diff --git a/models/service.py b/models/service.py new file mode 100644 index 0000000..3a81d37 --- /dev/null +++ b/models/service.py @@ -0,0 +1,21 @@ +from sqlalchemy import Column, Integer, String, ForeignKey, Double +from sqlalchemy.orm import relationship + +from models import BaseModel + + +class Service(BaseModel): + __tablename__ = 'services' + id = Column(Integer, autoincrement=True, primary_key=True, index=True) + name = Column(String, nullable=False, comment='Название услуги') + + category_id = Column(Integer, ForeignKey('service_categories.id'), nullable=False, comment='ID категории услуги') + category = relationship('ServiceCategory') + + price = Column(Double, nullable=False, comment='Стоимость услуги') + + +class ServiceCategory(BaseModel): + __tablename__ = 'service_categories' + id = Column(Integer, autoincrement=True, primary_key=True, index=True) + name = Column(String, nullable=False) diff --git a/requirements.txt b/requirements.txt index 0424121..c55941f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -16,4 +16,5 @@ alembic # Other python-dotenv aiohttp -aiohttp[speedups] \ No newline at end of file +aiohttp[speedups] +openpyxl \ No newline at end of file diff --git a/routers/__init__.py b/routers/__init__.py index 43c00c0..0788b9a 100644 --- a/routers/__init__.py +++ b/routers/__init__.py @@ -1,4 +1,5 @@ from .auth import auth_router from .deal import deal_router from .client import client_router +from .service import service_router diff --git a/routers/client.py b/routers/client.py index 8de30c9..5713c52 100644 --- a/routers/client.py +++ b/routers/client.py @@ -39,7 +39,11 @@ async def update_client_details( return ClientUpdateDetailsResponse(ok=True) -@client_router.get('/get-all', operation_id='get_all_clients', response_model=ClientGetAllResponse) +@client_router.get( + '/get-all', + operation_id='get_all_clients', + response_model=ClientGetAllResponse +) async def get_all_clients( session: Annotated[AsyncSession, Depends(get_session)], ): diff --git a/routers/deal.py b/routers/deal.py index f714524..b34154c 100644 --- a/routers/deal.py +++ b/routers/deal.py @@ -40,3 +40,13 @@ async def change_status( user: Annotated[User, Depends(get_current_user)] ): return await DealService(session).change_status(request, user) + + +@deal_router.get('/summaries', + response_model=DealSummaryResponse, + operation_id='getDealSummaries' + ) +async def get_summary( + session: Annotated[AsyncSession, Depends(get_session)] +): + return await DealService(session).get_summary() diff --git a/routers/service.py b/routers/service.py new file mode 100644 index 0000000..8f4b86a --- /dev/null +++ b/routers/service.py @@ -0,0 +1,59 @@ +from typing import Annotated + +from fastapi import APIRouter, Depends +from sqlalchemy.ext.asyncio import AsyncSession + +from backend.session import get_session +from schemas.services import * +from services.service import ServiceService + +service_router = APIRouter( + prefix="/service", + tags=['service'] +) + + +@service_router.get( + '/get-all', + response_model=ServiceGetAllResponse, + operation_id="get_all_services" +) +async def get_all( + session: Annotated[AsyncSession, Depends(get_session)] +): + return await ServiceService(session).get_all() + + +@service_router.post( + '/create', + response_model=ServiceCreateResponse, + operation_id="create_service" +) +async def create( + session: Annotated[AsyncSession, Depends(get_session)], + request: ServiceCreateRequest +): + return await ServiceService(session).create(request) + + +@service_router.get( + '/categories/get-all', + response_model=ServiceGetAllCategoriesResponse, + operation_id="get_all_service_categories" +) +async def get_all_categories( + session: Annotated[AsyncSession, Depends(get_session)] +): + return await ServiceService(session).get_all_categories() + + +@service_router.post( + '/categories/create', + response_model=ServiceCreateCategoryResponse, + operation_id="create_service_category" +) +async def create_category( + session: Annotated[AsyncSession, Depends(get_session)], + request: ServiceCreateCategoryRequest +): + return await ServiceService(session).create_category(request) diff --git a/schemas/base.py b/schemas/base.py index 43250b7..4e5be92 100644 --- a/schemas/base.py +++ b/schemas/base.py @@ -10,3 +10,7 @@ class CustomModel(BaseModel): class Config: from_attributes = True + +class OkMessageSchema(BaseModel): + ok: bool + message: str diff --git a/schemas/client.py b/schemas/client.py index 6934b58..070f6bc 100644 --- a/schemas/client.py +++ b/schemas/client.py @@ -3,18 +3,17 @@ from typing import List from schemas.base import CustomModel -class ClientSchema(CustomModel): - id: int - name: str - - class ClientDetailsSchema(CustomModel): address: str | None = None phone_number: str | None = None inn: int | None = None email: str | None = None - # TODO add email validation + +class ClientSchema(CustomModel): + id: int + name: str + details: ClientDetailsSchema | None = None class ClientSearchRequest(CustomModel): diff --git a/schemas/deal.py b/schemas/deal.py index 271f9b1..6fe7faa 100644 --- a/schemas/deal.py +++ b/schemas/deal.py @@ -1,18 +1,34 @@ import datetime +from typing import List from schemas.base import CustomModel from schemas.client import ClientSchema +# region Entities +class FastDeal(CustomModel): + name: str + client: ClientSchema + comment: str + acceptance_date: datetime.datetime + + +class DealSummary(CustomModel): + id: int + name: str + client_name: str + changed_at: datetime.datetime + status: int + + +# endregion Entities + +# region Requests class DealChangeStatusRequest(CustomModel): deal_id: int new_status: int -class DealChangeStatusResponse(CustomModel): - ok: bool - - class DealCreateRequest(CustomModel): name: str @@ -25,16 +41,27 @@ class DealQuickCreateRequest(CustomModel): acceptance_date: datetime.datetime -class DealQuickCreateResponse(CustomModel): - deal_id: int +class DealSummaryRequest(CustomModel): + pass + + +# endregion Requests + +# region Responses + +class DealChangeStatusResponse(CustomModel): + ok: bool class DealCreateResponse(CustomModel): ok: bool -class FastDeal(CustomModel): - name: str - client: ClientSchema - comment: str - acceptance_date: datetime.datetime +class DealQuickCreateResponse(CustomModel): + deal_id: int + + +class DealSummaryResponse(CustomModel): + summaries: List[DealSummary] + +# endregion Responses diff --git a/schemas/services.py b/schemas/services.py new file mode 100644 index 0000000..39b98c0 --- /dev/null +++ b/schemas/services.py @@ -0,0 +1,49 @@ +from typing import List + +from schemas.base import CustomModel, OkMessageSchema + + +# region Entities +class ServiceCategorySchema(CustomModel): + id: int + name: str + + +class ServiceSchema(CustomModel): + id: int + name: str + category: ServiceCategorySchema + price: float + + +# endregion + + +# region Requests +class ServiceCreateRequest(CustomModel): + service: ServiceSchema + + +class ServiceCreateCategoryRequest(CustomModel): + category: ServiceCategorySchema + + +# endregion + + +# region Responses +class ServiceGetAllResponse(CustomModel): + services: List[ServiceSchema] + + +class ServiceGetAllCategoriesResponse(CustomModel): + categories: List[ServiceCategorySchema] + + +class ServiceCreateResponse(OkMessageSchema): + pass + + +class ServiceCreateCategoryResponse(OkMessageSchema): + pass +# endregion diff --git a/services/client.py b/services/client.py index 16b1e66..e096d72 100644 --- a/services/client.py +++ b/services/client.py @@ -3,6 +3,7 @@ from typing import Union, Annotated from fastapi import Depends from sqlalchemy import select, update +from sqlalchemy.orm import joinedload from models import Client, ClientDetails, User from services.auth import get_current_user @@ -24,7 +25,7 @@ class ClientService(BaseService): return details async def get_all(self) -> ClientGetAllResponse: - clients_query = await self.session.scalars(select(Client)) + clients_query = await self.session.scalars(select(Client).options(joinedload(Client.details))) clients = clients_query.all() result = [] for client in clients: @@ -65,7 +66,8 @@ class ClientService(BaseService): async def search_clients(self, request: ClientSearchRequest) -> ClientSearchResponse: query = await self.session.scalars(select(Client) - .where(Client.name.ilike(f'%{request.name}%'))) + .where(Client.name.ilike(f'%{request.name}%')) + .options(joinedload(Client.details))) clients = [] for client in query.all(): clients.append(ClientSchema.model_validate(client)) diff --git a/services/deal.py b/services/deal.py index 44e9231..20b3caf 100644 --- a/services/deal.py +++ b/services/deal.py @@ -2,6 +2,7 @@ import datetime from typing import Type, Union from sqlalchemy import select +from sqlalchemy.orm import joinedload, selectinload from models import User, Deal from models.deal import * @@ -16,7 +17,9 @@ class DealService(BaseService): async def _get_deal_by_id(self, deal_id) -> Union[Deal, None]: return await self.session.get(Deal, deal_id) - async def change_status(self, deal: Deal, status: DealStatus, user: User, + async def change_status(self, deal: Deal, + status: DealStatus, + user: User, deadline: datetime.datetime = None) -> DealStatusHistory: deadline = deadline status_change = DealStatusHistory( @@ -75,3 +78,24 @@ class DealService(BaseService): await self.change_status(deal, DealStatus(request.new_status), user) await self.session.commit() return DealChangeStatusResponse(ok=True) + + async def get_summary(self) -> DealSummaryResponse: + deals_query = await self.session.scalars(select(Deal) + .options(selectinload(Deal.status_history), + joinedload(Deal.client)) + .where(Deal.is_deleted == False, + Deal.is_completed == False)) + summaries = [] + for deal in deals_query.all(): + deal: Deal + last_status: DealStatusHistory = max(deal.status_history, key=lambda status: status.changed_at) + summaries.append( + DealSummary( + id=deal.id, + client_name=deal.client.name, + name=deal.name, + changed_at=last_status.changed_at, + status=last_status.to_status + ) + ) + return DealSummaryResponse(summaries=summaries) \ No newline at end of file diff --git a/services/service.py b/services/service.py new file mode 100644 index 0000000..5a7cf92 --- /dev/null +++ b/services/service.py @@ -0,0 +1,56 @@ +from sqlalchemy import select +from sqlalchemy.orm import joinedload + +from models import Service, ServiceCategory +from services.base import BaseService +from schemas.services import ServiceGetAllResponse, ServiceSchema, ServiceGetAllCategoriesResponse, \ + ServiceCategorySchema, ServiceCreateRequest, ServiceCreateResponse, ServiceCreateCategoryRequest, \ + ServiceCreateCategoryResponse + + +class ServiceService(BaseService): + async def get_all(self) -> ServiceGetAllResponse: + query = await (self.session + .scalars(select(Service) + .options(joinedload(Service.category)) + .order_by(Service.category_id, Service.id))) + services = [] + for service in query.all(): + services.append(ServiceSchema.model_validate(service)) + return ServiceGetAllResponse(services=services) + + async def create(self, request: ServiceCreateRequest) -> ServiceCreateResponse: + try: + raw_service = request.service + service_dict = raw_service.model_dump() + service_dict['category_id'] = raw_service.category.id + del service_dict['id'] + del service_dict['category'] + service = Service(**service_dict) + self.session.add(service) + await self.session.commit() + return ServiceCreateResponse(ok=True, message="Услуга успешно создана") + except Exception as e: + return ServiceCreateResponse(ok=False, message=f"Неудалось создать услугу, ошибка: {e}") + + async def create_category(self, request: ServiceCreateCategoryRequest) -> ServiceCreateCategoryResponse: + try: + raw_category = request.category + category_dict = raw_category.model_dump() + del category_dict['id'] + category = ServiceCategory(**category_dict) + self.session.add(category) + await self.session.commit() + return ServiceCreateCategoryResponse(ok=True, message="Категория успешно создана") + + except Exception as e: + return ServiceCreateCategoryResponse(ok=False, message=f"Неудалось создать категорию, ошибка: {e}") + + async def get_all_categories(self) -> ServiceGetAllCategoriesResponse: + query = await (self.session + .scalars(select(ServiceCategory) + .order_by(ServiceCategory.id))) + categories = [] + for category in query.all(): + categories.append(ServiceCategorySchema.model_validate(category)) + return ServiceGetAllCategoriesResponse(categories=categories) diff --git a/test_main.http b/test_main.http deleted file mode 100644 index a2d81a9..0000000 --- a/test_main.http +++ /dev/null @@ -1,11 +0,0 @@ -# Test your FastAPI endpoints - -GET http://127.0.0.1:8000/ -Accept: application/json - -### - -GET http://127.0.0.1:8000/hello/User -Accept: application/json - -### diff --git a/utils/import_services.py b/utils/import_services.py new file mode 100644 index 0000000..b7162dd --- /dev/null +++ b/utils/import_services.py @@ -0,0 +1,58 @@ +import asyncio + +import openpyxl +from openpyxl.worksheet.worksheet import Worksheet +from sqlalchemy.ext.asyncio import AsyncSession + +from backend.session import session_maker +from models import ServiceCategory, Service + + +async def write_services(services: dict): + async with session_maker() as session: + session: AsyncSession + + for raw_category in services.keys(): + category = ServiceCategory(name=raw_category) + session.add(category) + await session.flush() + print(category.id) + for raw_service in services[raw_category]: + service = Service(name=raw_service['name'], + price=raw_service['price'], + category_id=category.id) + session.add(service) + await session.flush() + await session.commit() + + +async def main(): + workbook = openpyxl.load_workbook('services.xlsx') + sheet: Worksheet = workbook.active + START_ROW = 12 + current_category = None + services = {} + merged_cells = sheet.merged_cells.ranges + for row in range(START_ROW, sheet.max_row): + first_column = sheet.cell(row, 1) + first_column_value = first_column.value.strip() + + second_column = sheet.cell(row, 2) + second_column_value = second_column.value + + is_category = any([second_column.coordinate in merged_cell for merged_cell in merged_cells]) + if is_category: + current_category = first_column_value + services[current_category] = [] + continue + price = second_column_value + name = first_column_value + services[current_category].append({ + 'name': name, + 'price': price + }) + await write_services(services) + + +if __name__ == '__main__': + asyncio.run(main()) diff --git a/utils/services.xlsx b/utils/services.xlsx new file mode 100644 index 0000000..8553cc7 Binary files /dev/null and b/utils/services.xlsx differ