diff --git a/gateway/dependencies/auth.py b/gateway/dependencies/auth.py deleted file mode 100644 index 2cf0c58..0000000 --- a/gateway/dependencies/auth.py +++ /dev/null @@ -1,18 +0,0 @@ -from fastapi import Depends, HTTPException -from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials -import jwt - -SECRET_KEY = "secret" -ALGORITHM = "HS256" - -security = HTTPBearer() - -def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(security)): - token = credentials.credentials - try: - payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) - return payload - except jwt.ExpiredSignatureError: - raise HTTPException(status_code=401, detail="Token expired") - except jwt.InvalidTokenError: - raise HTTPException(status_code=401, detail="Invalid token") \ No newline at end of file diff --git a/gateway/utils.py b/gateway/utils.py deleted file mode 100644 index 28442a7..0000000 --- a/gateway/utils.py +++ /dev/null @@ -1,20 +0,0 @@ -import jwt -import bcrypt -from datetime import datetime, timedelta - -SECRET_KEY = "secret" -ALGORITHM = "HS256" - -def hash_password(password: str) -> str: - return bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode() - -def verify_password(password: str, hashed: str) -> bool: - return bcrypt.checkpw(password.encode(), hashed.encode()) - -def create_access_token(data: dict, expires_delta: timedelta = timedelta(hours=1)): - to_encode = data.copy() - to_encode["exp"] = datetime.utcnow() + expires_delta - return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) - -def decode_token(token: str): - return jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) \ No newline at end of file diff --git a/gateway/routers/__init__.py b/server/__init__.py similarity index 100% rename from gateway/routers/__init__.py rename to server/__init__.py diff --git a/server/data_service/.dockerignore b/server/data_service/.dockerignore new file mode 100644 index 0000000..e31985f --- /dev/null +++ b/server/data_service/.dockerignore @@ -0,0 +1,3 @@ +.venv +__pycache__ +.idea \ No newline at end of file diff --git a/server/data_service/.gitkeep b/server/data_service/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/server/data_service/Dockerfile b/server/data_service/Dockerfile new file mode 100644 index 0000000..e8c59ce --- /dev/null +++ b/server/data_service/Dockerfile @@ -0,0 +1,14 @@ +FROM python:3.12.3-slim + +WORKDIR /app + +COPY requirements.txt . + +RUN pip install --no-cache-dir fastapi[standard] && \ + pip install --no-cache-dir -r requirements.txt + +COPY . . + +WORKDIR /app/src + +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8003", "--reload"] \ No newline at end of file diff --git a/server/data_service/requirements.txt b/server/data_service/requirements.txt new file mode 100644 index 0000000..46b7e34 --- /dev/null +++ b/server/data_service/requirements.txt @@ -0,0 +1,42 @@ +annotated-types==0.7.0 +anyio==4.9.0 +asyncpg==0.30.0 +certifi==2025.4.26 +click==8.1.8 +colorama==0.4.6 +dnspython==2.7.0 +email_validator==2.2.0 +fastapi==0.115.12 +fastapi-cli==0.0.7 +greenlet==3.2.2 +h11==0.16.0 +httpcore==1.0.9 +httptools==0.6.4 +httpx==0.28.1 +idna==3.10 +Jinja2==3.1.6 +markdown-it-py==3.0.0 +MarkupSafe==3.0.2 +mdurl==0.1.2 +pydantic==2.11.5 +pydantic_core==2.33.2 +Pygments==2.19.1 +python-dotenv==1.1.0 +python-multipart==0.0.20 +PyYAML==6.0.2 +rich==14.0.0 +rich-toolkit==0.14.6 +shellingham==1.5.4 +sniffio==1.3.1 +SQLAlchemy==2.0.41 +starlette==0.46.2 +typer==0.15.4 +typing-inspection==0.4.1 +typing_extensions==4.13.2 +uvicorn==0.34.2 +watchfiles==1.0.5 +websockets==15.0.1 +psycopg2-binary +beautifulsoup4 +requests +SQLAlchemy \ No newline at end of file diff --git a/server/data_service/src/EV_cars.py b/server/data_service/src/EV_cars.py new file mode 100644 index 0000000..a71f110 --- /dev/null +++ b/server/data_service/src/EV_cars.py @@ -0,0 +1,47 @@ +from bs4 import BeautifulSoup +from database.schemas import CarCreate +import requests + +def parse_ev_cars() -> list: + result = [] + + for page_idx in range(0, 20): + url_page = f"https://ev-database.org/#group=vehicle-group&rs-pr=10000_100000&rs-er=0_1000&rs-ld=0_1000&rs-ac=2_23&rs-dcfc=0_300&rs-ub=10_200&rs-tw=0_2500&rs-ef=100_350&rs-sa=-1_5&rs-w=1000_3500&rs-c=0_5000&rs-y=2010_2030&s=1&p={page_idx}-50" + + headers = { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36' + } + + try: + response = requests.get(url_page, headers=headers) + soup = BeautifulSoup(response.text, 'html.parser') + + cars_list = soup.find_all('div', {'class': 'list-item', 'data-jplist-item': ''}) + + for idx, car in enumerate(cars_list): + title_link = car.find('a', class_='title') + if title_link: + spans = title_link.find_all('span') + full_name = ' '.join(span.text.strip() for span in spans) + else: + print(f'Отсуствует название для {idx + 1} машины') + + specs = car.find('div', class_='specs') + + consumpting = specs.find('div', {'data-tooltip': "Efficiency under standardized conditions"}).find('span', class_='efficiency').text.strip() + battery_capacity = specs.find('div', {'data-tooltip': "Useable battery capacity."}).find('span', class_='battery_p').text.strip() + hidden_info = car.find('div', class_='hidden') + type_charger = hidden_info.find('span', attrs={'title': lambda x: x and 'plug' in x}).text.strip() + + result.append(CarCreate(name=full_name, + battery_capacity=battery_capacity, + consumpting=consumpting, + type_charger=type_charger)) + except Exception as e: + print(f"Ошибка на странице {page_idx}: {e}") + + print(f"Была собрана информация о {len(cars_list)} автомобилях") + return result + +if __name__ == "__main__": + print(parse_ev_cars()) \ No newline at end of file diff --git a/server/data_service/src/config.py b/server/data_service/src/config.py new file mode 100644 index 0000000..66fc382 --- /dev/null +++ b/server/data_service/src/config.py @@ -0,0 +1,10 @@ +import os +from abc import ABC +from dataclasses import asdict, dataclass + + +class CfgBase(ABC): + dict: callable = asdict + +class PostgresCfg(CfgBase): + url: str = os.getenv("DATABASE_URL") \ No newline at end of file diff --git a/server/data_service/src/database/__init__.py b/server/data_service/src/database/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/server/data_service/src/database/cruds.py b/server/data_service/src/database/cruds.py new file mode 100644 index 0000000..70aa228 --- /dev/null +++ b/server/data_service/src/database/cruds.py @@ -0,0 +1,47 @@ +from database.schemas import CarCreate, CarGet +from database.models import Car + +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.future import select + +async def add_car(db: AsyncSession, car: CarCreate) -> Car: + new_car = Car(name=car.name, + battery_capacity=car.battery_capacity, + consumpting=car.consumpting, + type_charger=car.type_charger) + db.add(new_car) + await db.flush() + return new_car + +async def add_cars(db: AsyncSession, cars: list[CarCreate]) -> list[Car]: + car_objects = [ + Car(name=car.name, + battery_capacity=car.battery_capacity, + consumpting=car.consumpting, + type_charger=car.type_charger + ) + for car in cars + ] + + db.add_all(car_objects) + await db.flush() + return car_objects + +async def get_all_cars(db: AsyncSession) -> list[CarGet]: + stmt = select(Car) + + result = await db.execute(stmt) + return result.scalars().all() + +async def get_car_by_name(db: AsyncSession, name: str) -> CarGet | None: + stmt = select(Car).where(Car.name == name) + + result = await db.execute(stmt) + return result.scalar_one_or_none() + +async def get_car(db: AsyncSession, id: int) -> CarGet | None: + stmt = select(Car).where(Car.id == id) + + result = await db.execute(stmt) + return result.scalar_one_or_none() + diff --git a/server/data_service/src/database/database.py b/server/data_service/src/database/database.py new file mode 100644 index 0000000..a61a9fe --- /dev/null +++ b/server/data_service/src/database/database.py @@ -0,0 +1,23 @@ +from typing import Iterator, Any, AsyncGenerator +from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker +from config import PostgresCfg + +engine = create_async_engine( + url=PostgresCfg.url, + future=True, + echo=False +) + +async_session = async_sessionmaker(engine, expire_on_commit=False, class_=AsyncSession) + + +async def get_db() -> AsyncGenerator[AsyncSession, None]: + session: AsyncSession = async_session() + try: + yield session + await session.commit() + except Exception as exc: + await session.rollback() + raise exc + finally: + await session.close() \ No newline at end of file diff --git a/server/data_service/src/database/init_db.py b/server/data_service/src/database/init_db.py new file mode 100644 index 0000000..65ccf55 --- /dev/null +++ b/server/data_service/src/database/init_db.py @@ -0,0 +1,10 @@ +import asyncio +from database.database import engine +from database.models import Base + +async def init_models(): + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + +if __name__ == "__main__": + asyncio.run(init_models()) \ No newline at end of file diff --git a/server/data_service/src/database/models.py b/server/data_service/src/database/models.py new file mode 100644 index 0000000..1b00c9a --- /dev/null +++ b/server/data_service/src/database/models.py @@ -0,0 +1,14 @@ +from sqlalchemy import String +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column + +class Base(DeclarativeBase): + pass + +class Car(Base): + __tablename__ = "cars" + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + name: Mapped[str] = mapped_column(String, index=True, nullable=False) + battery_capacity: Mapped[str] = mapped_column(String, nullable=False) + consumpting: Mapped[str] = mapped_column(String, nullable=False) + type_charger: Mapped[str] = mapped_column(String, nullable=False) \ No newline at end of file diff --git a/server/data_service/src/database/schemas.py b/server/data_service/src/database/schemas.py new file mode 100644 index 0000000..463b68b --- /dev/null +++ b/server/data_service/src/database/schemas.py @@ -0,0 +1,14 @@ +from pydantic import BaseModel + +class CarCreate(BaseModel): + name: str + battery_capacity: str + consumpting: str + type_charger: str + +class CarGet(BaseModel): + id: int + name: str + battery_capacity: str + consumpting: str + type_charger: str \ No newline at end of file diff --git a/server/data_service/src/main.py b/server/data_service/src/main.py new file mode 100644 index 0000000..017a67b --- /dev/null +++ b/server/data_service/src/main.py @@ -0,0 +1,45 @@ +from fastapi import FastAPI, Depends, status +from sqlalchemy.ext.asyncio import AsyncSession +from database.database import get_db +from EV_cars import parse_ev_cars +from database.init_db import init_models +from contextlib import asynccontextmanager +from database import cruds +from database.schemas import CarCreate, CarGet + + +@asynccontextmanager +async def lifespan(app: FastAPI): + try: + print("Starting up") + app.add_middleware( + allow_methods=["GET", "POST"], + allow_origins=["*"] + ) + await init_models() + yield + finally: + print("Shutting down...") + +app = FastAPI(lifespan=lifespan, title="EV Route Car Service") + + +@app.post("/car", response_model=CarCreate, status_code=status.HTTP_201_CREATED) +async def add_car(car: CarCreate, db: AsyncSession = Depends(get_db)): + return await cruds.add_car(db, car) + +@app.post("/cars", response_model=list[CarCreate], status_code=status.HTTP_201_CREATED) +async def add_cars(cars: list[CarCreate], db: AsyncSession = Depends(get_db)): + return await cruds.add_cars(db, cars) + +@app.get("/car", response_model=CarGet) +async def get_car_by_name(name: str, db: AsyncSession = Depends(get_db)): + return await cruds.get_car_by_name(db, name) + +@app.get("/car/{car_id}", response_model=CarGet) +async def get_car(car_id: int, db: AsyncSession = Depends(get_db)): + return await cruds.get_car(db, car_id) + +@app.get("/cars", response_model=CarGet) +async def get_cars(db: AsyncSession = Depends(get_db)): + return await cruds.get_all_cars(db) \ No newline at end of file diff --git a/server/docker-compose.local.yml b/server/docker-compose.local.yml new file mode 100644 index 0000000..8e4811b --- /dev/null +++ b/server/docker-compose.local.yml @@ -0,0 +1,83 @@ +version: '3.8' + +services: + gateway: + build: ./gateway + container_name: gateway + ports: + - "8001:8001" + depends_on: + - postgres + environment: + DATABASE_URL: postgresql+asyncpg://fastapi:secret@postgres:5432/fastapi_dev + restart: unless-stopped + + auth_service: + build: ./auth_service + container_name: auth_service + ports: + - "8002:8002" + depends_on: + - postgres + environment: + DATABASE_URL: postgresql+asyncpg://fastapi:secret@postgres:5432/fastapi_dev + restart: unless-stopped + + data_service: + build: ./data_service + container_name: data_service + ports: + - "8003:8003" + depends_on: + - postgres + environment: + DATABASE_URL: postgresql+asyncpg://fastapi:secret@postgres:5432/fastapi_dev + restart: unless-stopped + + route_service: + build: ./route_service + container_name: route_service + ports: + - "8004:8004" + depends_on: + - postgres + environment: + DATABASE_URL: postgresql+asyncpg://fastapi:secret@postgres:5432/fastapi_dev + restart: unless-stopped + + station_service: + build: ./station_service + container_name: station_service + ports: + - "8005:8005" + depends_on: + - postgres + environment: + DATABASE_URL: postgresql+asyncpg://fastapi:secret@postgres:5432/fastapi_dev + restart: unless-stopped + + user_service: + build: ./user_service + container_name: user_service + ports: + - "8006:8006" + depends_on: + - postgres + environment: + DATABASE_URL: postgresql+asyncpg://fastapi:secret@postgres:5432/fastapi_dev + restart: unless-stopped + + postgres: + image: postgres:15-alpine + container_name: postgres + environment: + POSTGRES_USER: fastapi + POSTGRES_PASSWORD: secret + POSTGRES_DB: fastapi_dev + volumes: + - postgres_data:/var/lib/postgresql/data + ports: + - "5432:5432" + +volumes: + postgres_data: \ No newline at end of file diff --git a/server/gateway/.dockerignire b/server/gateway/.dockerignire new file mode 100644 index 0000000..74a9069 --- /dev/null +++ b/server/gateway/.dockerignire @@ -0,0 +1,3 @@ +.venv +.idea +__pycache__ \ No newline at end of file diff --git a/server/gateway/.gitkeep b/server/gateway/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/server/gateway/Dockerfile b/server/gateway/Dockerfile new file mode 100644 index 0000000..cafbaf3 --- /dev/null +++ b/server/gateway/Dockerfile @@ -0,0 +1,14 @@ +FROM python:3.13.3-slim + +WORKDIR /app + +COPY requirements.txt . + +RUN pip install --no-cache-dir fastapi[standard] && \ + pip install --no-cache-dir -r requirements.txt + +COPY . . + +WORKDIR /app/src + +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8001", "--reload"] \ No newline at end of file diff --git a/requirements.txt b/server/gateway/requirements.txt similarity index 94% rename from requirements.txt rename to server/gateway/requirements.txt index 51352b4..65c38d9 100644 --- a/requirements.txt +++ b/server/gateway/requirements.txt @@ -1,38 +1,38 @@ -annotated-types==0.7.0 -anyio==4.9.0 -bcrypt==4.3.0 -certifi==2025.4.26 -click==8.1.8 -colorama==0.4.6 -dnspython==2.7.0 -email_validator==2.2.0 -fastapi==0.115.12 -fastapi-cli==0.0.7 -h11==0.16.0 -httpcore==1.0.9 -httptools==0.6.4 -httpx==0.28.1 -idna==3.10 -Jinja2==3.1.6 -markdown-it-py==3.0.0 -MarkupSafe==3.0.2 -mdurl==0.1.2 -psycopg2==2.9.10 -pydantic==2.11.4 -pydantic_core==2.33.2 -Pygments==2.19.1 -PyJWT==2.10.1 -python-dotenv==1.1.0 -python-multipart==0.0.20 -PyYAML==6.0.2 -rich==14.0.0 -rich-toolkit==0.14.6 -shellingham==1.5.4 -sniffio==1.3.1 -starlette==0.46.2 -typer==0.15.4 -typing-inspection==0.4.0 -typing_extensions==4.13.2 -uvicorn==0.34.2 -watchfiles==1.0.5 -websockets==15.0.1 +annotated-types==0.7.0 +anyio==4.9.0 +bcrypt==4.3.0 +certifi==2025.4.26 +click==8.1.8 +colorama==0.4.6 +dnspython==2.7.0 +email_validator==2.2.0 +fastapi==0.115.12 +fastapi-cli==0.0.7 +h11==0.16.0 +httpcore==1.0.9 +httptools==0.6.4 +httpx==0.28.1 +idna==3.10 +Jinja2==3.1.6 +markdown-it-py==3.0.0 +MarkupSafe==3.0.2 +mdurl==0.1.2 +psycopg2==2.9.10 +pydantic==2.11.4 +pydantic_core==2.33.2 +Pygments==2.19.1 +PyJWT==2.10.1 +python-dotenv==1.1.0 +python-multipart==0.0.20 +PyYAML==6.0.2 +rich==14.0.0 +rich-toolkit==0.14.6 +shellingham==1.5.4 +sniffio==1.3.1 +starlette==0.46.2 +typer==0.15.4 +typing-inspection==0.4.0 +typing_extensions==4.13.2 +uvicorn==0.34.2 +watchfiles==1.0.5 +websockets==15.0.1 diff --git a/server/gateway/src/__init__.py b/server/gateway/src/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/server/gateway/src/config.py b/server/gateway/src/config.py new file mode 100644 index 0000000..66fc382 --- /dev/null +++ b/server/gateway/src/config.py @@ -0,0 +1,10 @@ +import os +from abc import ABC +from dataclasses import asdict, dataclass + + +class CfgBase(ABC): + dict: callable = asdict + +class PostgresCfg(CfgBase): + url: str = os.getenv("DATABASE_URL") \ No newline at end of file diff --git a/gateway/main.py b/server/gateway/src/main.py similarity index 88% rename from gateway/main.py rename to server/gateway/src/main.py index 04672cd..4e4f0ef 100644 --- a/gateway/main.py +++ b/server/gateway/src/main.py @@ -1,42 +1,43 @@ -from fastapi import FastAPI, Depends, HTTPException -from fastapi.middleware.cors import CORSMiddleware -from contextlib import asynccontextmanager -import logging -import uvicorn - -from routers import auth, user, station, route - -logging.basicConfig( - level=logging.INFO, - format="%(asctime)s [%(levelname)s] %(name)s: %(message)s" -) -logger = logging.getLogger(__name__) - - -app = FastAPI(title="EV Route Planner Gateway") - -app.add_middleware( - CORSMiddleware, - allow_origins=["*"], - allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], -) - -# Подключение маршрутов -app.include_router(auth.router, prefix="/auth") -app.include_router(user.router, prefix="/user") -app.include_router(station.router, prefix="/stations") -app.include_router(route.router, prefix="/route") -#app.include_router(data.router, prefix="/data") - -@asynccontextmanager -async def lifespan(app: FastAPI): - logger.info("Gateway server is starting...") - yield - logger.info("Gateway server is shutting down...") - - -if __name__ == "__main__": - logger.info("Running with Uvicorn at http://0.0.0.0:8000") +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from contextlib import asynccontextmanager +import logging +import uvicorn + +from server.gateway.src.routers import auth, route +from server.gateway.src.routers import user, station + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(levelname)s] %(name)s: %(message)s" +) +logger = logging.getLogger(__name__) + + +app = FastAPI(title="EV Route Planner Gateway") + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# Подключение маршрутов +app.include_router(auth.router, prefix="/auth") +app.include_router(user.router, prefix="/user") +app.include_router(station.router, prefix="/stations") +app.include_router(route.router, prefix="/route") +#app.include_router(data.router, prefix="/data") + +@asynccontextmanager +async def lifespan(app: FastAPI): + logger.info("Gateway server is starting...") + yield + logger.info("Gateway server is shutting down...") + + +if __name__ == "__main__": + logger.info("Running with Uvicorn at http://0.0.0.0:8000") uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True) \ No newline at end of file diff --git a/server/gateway/src/routers/__init__.py b/server/gateway/src/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/gateway/routers/auth.py b/server/gateway/src/routers/auth.py similarity index 96% rename from gateway/routers/auth.py rename to server/gateway/src/routers/auth.py index 7c1c952..2e99684 100644 --- a/gateway/routers/auth.py +++ b/server/gateway/src/routers/auth.py @@ -1,45 +1,45 @@ -from fastapi import APIRouter, HTTPException, Depends -from pydantic import BaseModel, EmailStr -from utils import hash_password, verify_password, create_access_token -import psycopg2 - -router = APIRouter() - -SECRET_KEY = "secret" - -conn = psycopg2.connect(dbname="evroutesusers", user="roki", password="roki", host="localhost") -cursor = conn.cursor() - -class LoginData(BaseModel): - email: EmailStr - password: str - -class RegisterData(BaseModel): - email: EmailStr - password: str - role: str = "user" - -@router.post("/register") -def register(data: RegisterData): - cursor.execute("SELECT 1 FROM users WHERE email = %s", (data.email,)) - if cursor.fetchone(): - raise HTTPException(status_code=400, detail="User already exists") - - hashed_pw = hash_password(data.password) - - cursor.execute( - "INSERT INTO users (email, password_hash, role) VALUES (%s, %s, %s)", - (data.email, hashed_pw, data.role) - ) - conn.commit() - return {"status": "registered"} - -@router.post("/login") -def login(data: LoginData): - cursor.execute("SELECT password_hash, role FROM users WHERE email = %s", (data.email,)) - row = cursor.fetchone() - if not row or not verify_password(data.password, row[0]): - raise HTTPException(status_code=401, detail="Invalid credentials") - - token = create_access_token({"email": data.email, "role": row[1]}) +from fastapi import APIRouter, HTTPException, Depends +from pydantic import BaseModel, EmailStr +from utils import hash_password, verify_password, create_access_token +import psycopg2 + +router = APIRouter() + +SECRET_KEY = "secret" + +conn = psycopg2.connect(dbname="evroutesusers", user="roki", password="roki", host="localhost") +cursor = conn.cursor() + +class LoginData(BaseModel): + email: EmailStr + password: str + +class RegisterData(BaseModel): + email: EmailStr + password: str + role: str = "user" + +@router.post("/register") +def register(data: RegisterData): + cursor.execute("SELECT 1 FROM users WHERE email = %s", (data.email,)) + if cursor.fetchone(): + raise HTTPException(status_code=400, detail="User already exists") + + hashed_pw = hash_password(data.password) + + cursor.execute( + "INSERT INTO users (email, password_hash, role) VALUES (%s, %s, %s)", + (data.email, hashed_pw, data.role) + ) + conn.commit() + return {"status": "registered"} + +@router.post("/login") +def login(data: LoginData): + cursor.execute("SELECT password_hash, role FROM users WHERE email = %s", (data.email,)) + row = cursor.fetchone() + if not row or not verify_password(data.password, row[0]): + raise HTTPException(status_code=401, detail="Invalid credentials") + + token = create_access_token({"email": data.email, "role": row[1]}) return {"access_token": token} \ No newline at end of file diff --git a/gateway/routers/route.py b/server/gateway/src/routers/route.py similarity index 95% rename from gateway/routers/route.py rename to server/gateway/src/routers/route.py index 9feb94d..f51661f 100644 --- a/gateway/routers/route.py +++ b/server/gateway/src/routers/route.py @@ -1,18 +1,18 @@ -from fastapi import APIRouter -from pydantic import BaseModel - -router = APIRouter() - -class RouteRequest(BaseModel): - start: str - end: str - battery_level: int - -@router.post("/") -def calculate_route(data: RouteRequest): - return { - "start": data.start, - "end": data.end, - "estimated_time": "2h 30m", - "stations_on_route": [] +from fastapi import APIRouter +from pydantic import BaseModel + +router = APIRouter() + +class RouteRequest(BaseModel): + start: str + end: str + battery_level: int + +@router.post("/") +def calculate_route(data: RouteRequest): + return { + "start": data.start, + "end": data.end, + "estimated_time": "2h 30m", + "stations_on_route": [] } \ No newline at end of file diff --git a/gateway/routers/station.py b/server/gateway/src/routers/station.py similarity index 96% rename from gateway/routers/station.py rename to server/gateway/src/routers/station.py index b7f47dd..7554805 100644 --- a/gateway/routers/station.py +++ b/server/gateway/src/routers/station.py @@ -1,34 +1,34 @@ -from fastapi import APIRouter -from pydantic import BaseModel -import psycopg2 - -router = APIRouter() - -conn = psycopg2.connect(dbname="evroutesstations", user="roki", password="roki", host="localhost") -cursor = conn.cursor() - -class Station(BaseModel): - name: str - latitude: float - longitude: float - connector_type: str - power_kW: int - working_hours: str - -@router.post("/") -def add_station(station: Station): - cursor.execute( - """ - INSERT INTO stations (name, latitude, longitude, connector_type, power_kW, working_hours) - VALUES (%s, %s, %s, %s, %s, %s) - """, - (station.name, station.latitude, station.longitude, station.connector_type, station.power_kW, station.working_hours) - ) - conn.commit() - return {"status": "ok"} - -@router.get("/") -def get_stations(): - cursor.execute("SELECT * FROM stations") - rows = cursor.fetchall() +from fastapi import APIRouter +from pydantic import BaseModel +import psycopg2 + +router = APIRouter() + +conn = psycopg2.connect(dbname="evroutesstations", user="roki", password="roki", host="localhost") +cursor = conn.cursor() + +class Station(BaseModel): + name: str + latitude: float + longitude: float + connector_type: str + power_kW: int + working_hours: str + +@router.post("/") +def add_station(station: Station): + cursor.execute( + """ + INSERT INTO stations (name, latitude, longitude, connector_type, power_kW, working_hours) + VALUES (%s, %s, %s, %s, %s, %s) + """, + (station.name, station.latitude, station.longitude, station.connector_type, station.power_kW, station.working_hours) + ) + conn.commit() + return {"status": "ok"} + +@router.get("/") +def get_stations(): + cursor.execute("SELECT * FROM stations") + rows = cursor.fetchall() return rows \ No newline at end of file diff --git a/gateway/routers/user.py b/server/gateway/src/routers/user.py similarity index 96% rename from gateway/routers/user.py rename to server/gateway/src/routers/user.py index a18d363..3821aed 100644 --- a/gateway/routers/user.py +++ b/server/gateway/src/routers/user.py @@ -1,36 +1,36 @@ -from fastapi import APIRouter -from pydantic import BaseModel -import psycopg2 - -router = APIRouter() - -conn = psycopg2.connect(dbname="evroutesuserinfo", user="roki", password="roki", host="localhost") -cursor = conn.cursor() - -class UserData(BaseModel): - email: str - car_model: str - battery_capacity: float - connector_type: str - -@router.post("/") -def save_user(data: UserData): - cursor.execute( - """ - INSERT INTO users (email, car_model, battery_capacity, connector_type) - VALUES (%s, %s, %s, %s) - ON CONFLICT (email) DO UPDATE SET - car_model = EXCLUDED.car_model, - battery_capacity = EXCLUDED.battery_capacity, - connector_type = EXCLUDED.connector_type - """, - (data.email, data.car_model, data.battery_capacity, data.connector_type) - ) - conn.commit() - return {"status": "saved"} - -@router.get("/{email}") -def get_user(email: str): - cursor.execute("SELECT * FROM users WHERE email = %s", (email,)) - row = cursor.fetchone() +from fastapi import APIRouter +from pydantic import BaseModel +import psycopg2 + +router = APIRouter() + +conn = psycopg2.connect(dbname="evroutesuserinfo", user="roki", password="roki", host="localhost") +cursor = conn.cursor() + +class UserData(BaseModel): + email: str + car_model: str + battery_capacity: float + connector_type: str + +@router.post("/") +def save_user(data: UserData): + cursor.execute( + """ + INSERT INTO users (email, car_model, battery_capacity, connector_type) + VALUES (%s, %s, %s, %s) + ON CONFLICT (email) DO UPDATE SET + car_model = EXCLUDED.car_model, + battery_capacity = EXCLUDED.battery_capacity, + connector_type = EXCLUDED.connector_type + """, + (data.email, data.car_model, data.battery_capacity, data.connector_type) + ) + conn.commit() + return {"status": "saved"} + +@router.get("/{email}") +def get_user(email: str): + cursor.execute("SELECT * FROM users WHERE email = %s", (email,)) + row = cursor.fetchone() return row \ No newline at end of file diff --git a/server/gateway/src/utils.py b/server/gateway/src/utils.py new file mode 100644 index 0000000..d77654c --- /dev/null +++ b/server/gateway/src/utils.py @@ -0,0 +1,48 @@ +import jwt +import bcrypt +from datetime import datetime, timedelta + +SECRET_KEY = "secret" +ALGORITHM = "HS256" + +def hash_password(password: str) -> str: + return bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode() + +def verify_password(password: str, hashed: str) -> bool: + return bcrypt.checkpw(password.encode(), hashed.encode()) + +def create_access_token(data: dict, expires_delta: timedelta = timedelta(hours=1)): + to_encode = data.copy() + to_encode["exp"] = datetime.utcnow() + expires_delta + return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) + +def decode_token(token: str): + return jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) + + +# async def authenticate_user(self, db: AsyncSession, login: str, password: str) -> UserInDB | None: +# user = await cruds.get_user(db, login) +# if not user or not pwd_context.verify(password, user.password_hash): +# return None +# return user + +# def create_access_token(self, data: TokenData, expires_delta: timedelta) -> str: +# to_encode = data.model_dump() +# expire = datetime.now(timezone.utc) + expires_delta +# to_encode.update({"exp": expire}) +# return jwt.encode(to_encode, self.SECRET_KEY, algorithm=self.ALGORITHM) + +# SECRET_KEY = "secret" +# ALGORITHM = "HS256" + +# security = HTTPBearer() + +# def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(security)): +# token = credentials.credentials +# try: +# payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) +# return payload +# except jwt.ExpiredSignatureError: +# raise HTTPException(status_code=401, detail="Token expired") +# except jwt.InvalidTokenError: +# raise HTTPException(status_code=401, detail="Invalid token") \ No newline at end of file diff --git a/server/route_service/.dockerignore b/server/route_service/.dockerignore new file mode 100644 index 0000000..e31985f --- /dev/null +++ b/server/route_service/.dockerignore @@ -0,0 +1,3 @@ +.venv +__pycache__ +.idea \ No newline at end of file diff --git a/server/route_service/.gitkeep b/server/route_service/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/server/route_service/Dockerfile b/server/route_service/Dockerfile new file mode 100644 index 0000000..e132f97 --- /dev/null +++ b/server/route_service/Dockerfile @@ -0,0 +1,14 @@ +FROM python:3.13.3-slim + +WORKDIR /app + +COPY requirements.txt . + +RUN pip install --no-cache-dir fastapi[standard] && \ + pip install --no-cache-dir -r requirements.txt + +COPY . . + +WORKDIR /app/src + +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8004", "--reload"] \ No newline at end of file diff --git a/server/route_service/requirements.txt b/server/route_service/requirements.txt new file mode 100644 index 0000000..e69de29 diff --git a/server/route_service/src/config.py b/server/route_service/src/config.py new file mode 100644 index 0000000..66fc382 --- /dev/null +++ b/server/route_service/src/config.py @@ -0,0 +1,10 @@ +import os +from abc import ABC +from dataclasses import asdict, dataclass + + +class CfgBase(ABC): + dict: callable = asdict + +class PostgresCfg(CfgBase): + url: str = os.getenv("DATABASE_URL") \ No newline at end of file diff --git a/server/route_service/src/main.py b/server/route_service/src/main.py new file mode 100644 index 0000000..e69de29 diff --git a/server/station_service/.dockerignore b/server/station_service/.dockerignore new file mode 100644 index 0000000..e31985f --- /dev/null +++ b/server/station_service/.dockerignore @@ -0,0 +1,3 @@ +.venv +__pycache__ +.idea \ No newline at end of file diff --git a/server/station_service/.gitkeep b/server/station_service/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/server/station_service/Dockerfile b/server/station_service/Dockerfile new file mode 100644 index 0000000..70d0a72 --- /dev/null +++ b/server/station_service/Dockerfile @@ -0,0 +1,14 @@ +FROM python:3.13.3-slim + +WORKDIR /app + +COPY requirements.txt . + +RUN pip install --no-cache-dir fastapi[standard] && \ + pip install --no-cache-dir -r requirements.txt + +COPY . . + +WORKDIR /app/src + +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8005", "--reload"] \ No newline at end of file diff --git a/server/station_service/requirements.txt b/server/station_service/requirements.txt new file mode 100644 index 0000000..e69de29 diff --git a/server/station_service/src/config.py b/server/station_service/src/config.py new file mode 100644 index 0000000..66fc382 --- /dev/null +++ b/server/station_service/src/config.py @@ -0,0 +1,10 @@ +import os +from abc import ABC +from dataclasses import asdict, dataclass + + +class CfgBase(ABC): + dict: callable = asdict + +class PostgresCfg(CfgBase): + url: str = os.getenv("DATABASE_URL") \ No newline at end of file diff --git a/server/station_service/src/database/__init__.py b/server/station_service/src/database/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/server/station_service/src/database/cruds.py b/server/station_service/src/database/cruds.py new file mode 100644 index 0000000..61a0e88 --- /dev/null +++ b/server/station_service/src/database/cruds.py @@ -0,0 +1,35 @@ +from database.schemas import StationCreate, StationGet +from database.models import Station + +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.future import select + +async def add_station(db: AsyncSession, station: StationCreate) -> Station: + new_station = Station(name=station.name, + latitude=station.latitude, + longtitude=station.longtitude, + connection_type=station.connection_type, + power_kw=station.power_kw) + db.add(new_station) + await db.flush() + return new_station + +async def add_stations(db: AsyncSession, stations: list[StationCreate]) -> list[Station]: + station_objects = [ + Station(name=station.name, + latitude=station.latitude, + longtitude=station.longtitude, + connection_type=station.connection_type, + power_kw=station.power_kw) + for station in stations + ] + + db.add_all(station_objects) + await db.flush() + return station_objects + +async def get_all_stations(db: AsyncSession) -> list[StationGet]: + stmt = select(Station) + + result = await db.execute(stmt) + return result.scalars().all() diff --git a/server/station_service/src/database/database.py b/server/station_service/src/database/database.py new file mode 100644 index 0000000..4db193e --- /dev/null +++ b/server/station_service/src/database/database.py @@ -0,0 +1,22 @@ +from typing import AsyncGenerator +from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker +from config import PostgresCfg + +engine = create_async_engine( + url=PostgresCfg.url, + future=True, + echo=False +) + +async_session = async_sessionmaker(engine, expire_on_commit=False, class_=AsyncSession) + +async def get_db() -> AsyncGenerator[AsyncSession, None]: + session: AsyncSession = async_session() + try: + yield session + await session.commit() + except Exception as exc: + await session.rollback() + raise exc + finally: + await session.close() \ No newline at end of file diff --git a/server/station_service/src/database/init_db.py b/server/station_service/src/database/init_db.py new file mode 100644 index 0000000..65ccf55 --- /dev/null +++ b/server/station_service/src/database/init_db.py @@ -0,0 +1,10 @@ +import asyncio +from database.database import engine +from database.models import Base + +async def init_models(): + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + +if __name__ == "__main__": + asyncio.run(init_models()) \ No newline at end of file diff --git a/server/station_service/src/database/models.py b/server/station_service/src/database/models.py new file mode 100644 index 0000000..3dcd8af --- /dev/null +++ b/server/station_service/src/database/models.py @@ -0,0 +1,15 @@ +from sqlalchemy import String, Integer, Float +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column + +class Base(DeclarativeBase): + pass + +class Station(Base): + __tablename__ = "stations" + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + name: Mapped[str] = mapped_column(String, index=True, nullable=False) + latitude: Mapped[float] = mapped_column(Float, index=True, nullable=False) + longtitude: Mapped[float] = mapped_column(Float, index=True, nullable=False) + connection_type: Mapped[str] = mapped_column(String, index=True, nullable=False) + power_kw: Mapped[int] = mapped_column(Integer, nullable=False) \ No newline at end of file diff --git a/server/station_service/src/database/schemas.py b/server/station_service/src/database/schemas.py new file mode 100644 index 0000000..5db06f0 --- /dev/null +++ b/server/station_service/src/database/schemas.py @@ -0,0 +1,16 @@ +from pydantic import BaseModel + +class StationCreate(BaseModel): + name: str + latitude: float + longtitude: float + connection_type: str + power_kw: int + +class StationGet(BaseModel): + id: int + name: str + latitude: float + longtitude: float + connection_type: str + power_kw: int \ No newline at end of file diff --git a/server/station_service/src/main.py b/server/station_service/src/main.py new file mode 100644 index 0000000..33d639a --- /dev/null +++ b/server/station_service/src/main.py @@ -0,0 +1,37 @@ +from fastapi import FastAPI, Depends, status +from sqlalchemy.ext.asyncio import AsyncSession +from database.database import get_db +from stations import get_all_stations +from database.init_db import init_models +from contextlib import asynccontextmanager +from database import cruds +from database.schemas import StationCreate, StationGet + + +@asynccontextmanager +async def lifespan(app: FastAPI): + try: + print("Starting up") + app.add_middleware( + allow_methods=["GET", "POST"], + allow_origins=["*"] + ) + await init_models() + yield + finally: + print("Shutting down...") + +app = FastAPI(lifespan=lifespan, title="EV Route Station Service") + + +@app.post("/station", response_model=StationCreate, status_code=status.HTTP_201_CREATED) +async def add_station(station: StationCreate, db: AsyncSession = Depends(get_db)): + return await cruds.add_station(db, station) + +@app.post("/stations", response_model=list[StationCreate], status_code=status.HTTP_201_CREATED) +async def add_statios(stations: list[StationCreate], db: AsyncSession = Depends(get_db)): + return await cruds.add_stations(db, stations) + +@app.get("/stations", response_model=StationGet) +async def get_stations(db: AsyncSession = Depends(get_db)): + return await cruds.get_all_stations(db) \ No newline at end of file diff --git a/server/station_service/src/stations.py b/server/station_service/src/stations.py new file mode 100644 index 0000000..330bbb5 --- /dev/null +++ b/server/station_service/src/stations.py @@ -0,0 +1,65 @@ +import requests +from database.schemas import StationCreate +import os + +API_BASE_URL = "https://api.openchargemap.io/v3" +API_KEY = os.getenv("API_KEY") + +URL_PARAMS = { + 'output': 'json', + 'key': API_KEY + } + +def get_ref_data(): + new_url = f"{API_BASE_URL}/referencedata?{'&'.join(f'{key}={value}' for key, value in URL_PARAMS.items())}" + response = requests.get(new_url) + response.raise_for_status() + data = response.json() + + conn_types = data['ConnectionTypes'] + countries = data['Countries'] + return conn_types, countries + +def get_good_charg_ids(): + GOOD_CHARG = ['CHAdeMO', 'CCS (Type 1)', 'CCS (Type 2)'] + + pass + +def get_all_info_by_country(country_id: int): + result = [] + new_url = f"{API_BASE_URL}/poi?{'&'.join(f'{key}={value}' for key, value in URL_PARAMS.items())}&countryid={country_id}&connectiontypeid=2, 32, 33" + response = requests.get(new_url) + response.raise_for_status() + data = response.json() + for station in data: + result.append(get_use_info_from_json(station=station)) + return result + + +def get_use_info_from_json(station: dict) -> StationCreate: + address_info = station['AddressInfo'] + name = address_info['Title'] + latitude = address_info['Latitude'] + longtitude = address_info['Longitude'] + connections_info = station['Connections'] + + for connection in connections_info: + connection_type = connection['ConnectionType'] + power_kw = connection['PowerKW'] + + return StationCreate(name=name, + latitude=latitude, + longtitude=longtitude, + connection_type=connection_type, + power_kw=power_kw + ) + +def all_stations_info(): + stations = [] + + for country_id in range(251): + country_stations = get_all_info_by_country(country_id=country_id) + stations.append(country_stations) + return stations + +stations = all_stations_info() \ No newline at end of file diff --git a/server/user_service/.dockerignore b/server/user_service/.dockerignore new file mode 100644 index 0000000..e31985f --- /dev/null +++ b/server/user_service/.dockerignore @@ -0,0 +1,3 @@ +.venv +__pycache__ +.idea \ No newline at end of file diff --git a/server/user_service/.gitkeep b/server/user_service/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/server/user_service/Dockerfile b/server/user_service/Dockerfile new file mode 100644 index 0000000..e00086c --- /dev/null +++ b/server/user_service/Dockerfile @@ -0,0 +1,14 @@ +FROM python:3.13.3-slim + +WORKDIR /app + +COPY requirements.txt . + +RUN pip install --no-cache-dir fastapi[standard] && \ + pip install --no-cache-dir -r requirements.txt + +COPY . . + +WORKDIR /app/src + +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8006", "--reload"] \ No newline at end of file diff --git a/server/user_service/requirements.txt b/server/user_service/requirements.txt new file mode 100644 index 0000000..e69de29 diff --git a/server/user_service/src/auth/auth.py b/server/user_service/src/auth/auth.py new file mode 100644 index 0000000..4631314 --- /dev/null +++ b/server/user_service/src/auth/auth.py @@ -0,0 +1,27 @@ +from passlib.context import CryptContext +from sqlalchemy.ext.asyncio import AsyncSession +from datetime import datetime, timedelta, timezone + +from jose import jwt + +from database.schemas import UserInDB, TokenData +from database import cruds + +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + +class AuthService: + def __init__(self, secret_key: str, algorithm: str = "HS256"): + self.SECRET_KEY = secret_key + self.ALGORITHM = algorithm + + async def authenticate_user(self, db: AsyncSession, login: str, password: str) -> UserInDB | None: + user = await cruds.get_user(db, login) + if not user or not pwd_context.verify(password, user.password_hash): + return None + return user + + def create_access_token(self, data: TokenData, expires_delta: timedelta) -> str: + to_encode = data.model_dump() + expire = datetime.now(timezone.utc) + expires_delta + to_encode.update({"exp": expire}) + return jwt.encode(to_encode, self.SECRET_KEY, algorithm=self.ALGORITHM) \ No newline at end of file diff --git a/server/user_service/src/config.py b/server/user_service/src/config.py new file mode 100644 index 0000000..66fc382 --- /dev/null +++ b/server/user_service/src/config.py @@ -0,0 +1,10 @@ +import os +from abc import ABC +from dataclasses import asdict, dataclass + + +class CfgBase(ABC): + dict: callable = asdict + +class PostgresCfg(CfgBase): + url: str = os.getenv("DATABASE_URL") \ No newline at end of file diff --git a/server/user_service/src/database/__init__.py b/server/user_service/src/database/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/server/user_service/src/database/cruds.py b/server/user_service/src/database/cruds.py new file mode 100644 index 0000000..1b7140b --- /dev/null +++ b/server/user_service/src/database/cruds.py @@ -0,0 +1,48 @@ +from database.schemas import UserCreate, UserGet, UserInDB +from database.models import User +from passlib.context import CryptContext +from fastapi import HTTPException +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.future import select +from sqlalchemy.exc import IntegrityError + +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") +async def add_user(db: AsyncSession, user: UserCreate) -> UserGet: + hashed_password = pwd_context.hash(user.password.get_secret_value()) + new_user = UserInDB(login=user.login, + password_hash=hashed_password) + try: + db.add(new_user) + db.flush() + return UserGet(id=new_user.id, + login=new_user.login, + car_id=new_user.car_id) + except IntegrityError as ie: + await db.rollback() + raise HTTPException(status_code=400, detail=f"Логин уже занят: {str(ie)}") + +async def get_db_user(db: AsyncSession, login: str) -> UserInDB | None: + stmt = select(User).where(User.login == login) + result = await db.execute(stmt) + return result.scalar_one_or_none() + +async def get_user(db: AsyncSession, login: str) -> UserGet | None: + stmt = select(User).where(User.login == login) + result = await db.execute(stmt) + return result.scalar_one_or_none() + +async def update_user_car(db: AsyncSession, login: int, new_car: int | None) -> UserGet: + query = select(UserInDB).where(UserInDB.login == login) + result = await db.execute(statement=query) + user = result.scalar_one_or_none() + + if not user: + raise HTTPException(status_code=404, detail="Пользователь не найден") + + user.car_id = new_car + await db.flush() + + return UserGet( + id=user.id, + login=user.login, + car_id=user.car_id) \ No newline at end of file diff --git a/server/user_service/src/database/database.py b/server/user_service/src/database/database.py new file mode 100644 index 0000000..4c5a833 --- /dev/null +++ b/server/user_service/src/database/database.py @@ -0,0 +1,23 @@ +from typing import AsyncGenerator +from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker +from config import PostgresCfg + +engine = create_async_engine( + url=PostgresCfg.url, + future=True, + echo=False +) + +async_session = async_sessionmaker(engine, expire_on_commit=False, class_=AsyncSession) + + +async def get_db() -> AsyncGenerator[AsyncSession, None]: + session: AsyncSession = async_session() + try: + yield session + await session.commit() + except Exception as exc: + await session.rollback() + raise exc + finally: + await session.close() \ No newline at end of file diff --git a/server/user_service/src/database/init_db.py b/server/user_service/src/database/init_db.py new file mode 100644 index 0000000..e69de29 diff --git a/server/user_service/src/database/models.py b/server/user_service/src/database/models.py new file mode 100644 index 0000000..3af768e --- /dev/null +++ b/server/user_service/src/database/models.py @@ -0,0 +1,13 @@ +from sqlalchemy import String, Integer +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship + +class Base(DeclarativeBase): + pass + +class User(Base): + __tablename__ = "users" + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + login: Mapped[str] = mapped_column(String, unique=True, index=True, nullable=False) + password_hash: Mapped[str] = mapped_column(String(255), nullable=False) + car_id: Mapped[int | None ] = mapped_column(Integer, nullable=True) \ No newline at end of file diff --git a/server/user_service/src/database/schemas.py b/server/user_service/src/database/schemas.py new file mode 100644 index 0000000..1612a95 --- /dev/null +++ b/server/user_service/src/database/schemas.py @@ -0,0 +1,29 @@ +from pydantic import BaseModel, SecretStr +from datetime import datetime + +class UserLogin(BaseModel): + login: str + password: SecretStr + +class UserCreate(BaseModel): + login: str + password: SecretStr + +class UserInDB(BaseModel): + login: str + password_hash: str + +class UserGet(BaseModel): + id: int + login: str + car_id: str | None + +class TokenData(BaseModel): + sub: str + exp: datetime | None = None + scopes: list[str] = [] + + class Config: + json_encoders = { + datetime: lambda v: v.timestamp() # Для корректной сериализации в JSON + } \ No newline at end of file diff --git a/server/user_service/src/main.py b/server/user_service/src/main.py new file mode 100644 index 0000000..ba3ee38 --- /dev/null +++ b/server/user_service/src/main.py @@ -0,0 +1,46 @@ +from fastapi import FastAPI, Depends, status, HTTPException +from sqlalchemy.ext.asyncio import AsyncSession +from database.database import get_db +from database.init_db import init_models +from contextlib import asynccontextmanager +from database import cruds +from database.schemas import UserCreate, UserGet, UserLogin +from auth.auth import authenticate_user, create_access_token + +@asynccontextmanager +async def lifespan(app: FastAPI): + try: + print("Starting up") + app.add_middleware( + allow_methods=["GET", "POST", "PATCH"], + allow_origins=["*"] + ) + await init_models() + yield + finally: + print("Shutting down...") + +app = FastAPI(lifespan=lifespan, title="EV Route User Service") + +@app.post("/register", response_model=UserGet, status_code=status.HTTP_201_CREATED) +async def add_user(user: UserCreate, db: AsyncSession = Depends(get_db)): + return await cruds.add_user(db, user) + +@app.post("/login", response_model=UserGet) +async def login(user_data: UserLogin, db: AsyncSession = Depends(get_db)): + user = await authenticate_user(db, user_data.login, user_data.password) + if not user: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Incorrect login or password", + ) + access_token = create_access_token(data={"sub": user.login}) + return {"access_token": access_token, "token_type": "bearer"} + +@app.get("/user", response_model=UserGet) +async def get_user(login: str, db: AsyncSession = Depends(get_db)): + return await cruds.get_user(db, login) + +@app.patch("/user/car", response_model=UserGet, status_code=status.HTTP_202_ACCEPTED) +async def update_car(login: str, new_car: int, db: AsyncSession = Depends(get_db)): + return await cruds.update_user_car(db, login, new_car)