diff --git a/agliullov_daniyar_lab_3/device_service/Dockerfile b/agliullov_daniyar_lab_3/device_service/Dockerfile
new file mode 100644
index 0000000..bc6198d
--- /dev/null
+++ b/agliullov_daniyar_lab_3/device_service/Dockerfile
@@ -0,0 +1,10 @@
+FROM python:3.11
+
+WORKDIR /app
+
+COPY requirements.txt .
+RUN pip install --no-cache-dir -r requirements.txt
+
+COPY . .
+
+CMD ["uvicorn", "--host", "0.0.0.0", "main:app", "--port", "8001"]
diff --git a/agliullov_daniyar_lab_3/device_service/config.py b/agliullov_daniyar_lab_3/device_service/config.py
new file mode 100644
index 0000000..5984cac
--- /dev/null
+++ b/agliullov_daniyar_lab_3/device_service/config.py
@@ -0,0 +1,21 @@
+import os
+import logging
+
+from dotenv import load_dotenv
+
+load_dotenv()
+
+DB_HOST = os.environ.get("DB_HOST")
+DB_PORT = os.environ.get("DB_PORT")
+DB_NAME = os.environ.get("DB_NAME")
+DB_USER = os.environ.get("DB_USER")
+DB_PASS = os.environ.get("DB_PASS")
+
+
+SECRET_AUTH_TOKEN = os.environ.get("SECRET_AUTH_TOKEN")
+SECRET_VERIFICATE_TOKEN = os.environ.get("SECRET_VERIFICATE_TOKEN")
+
+DEVICE_START_COUNTER = os.environ.get("DEVICE_START_COUNTER")
+
+logging.getLogger('passlib').setLevel(logging.ERROR)
+
diff --git a/agliullov_daniyar_lab_3/device_service/database.py b/agliullov_daniyar_lab_3/device_service/database.py
new file mode 100644
index 0000000..fd324fc
--- /dev/null
+++ b/agliullov_daniyar_lab_3/device_service/database.py
@@ -0,0 +1,49 @@
+import time
+from sys import gettrace
+from typing import AsyncGenerator
+
+from fastapi import HTTPException
+from fastapi.encoders import jsonable_encoder
+from sqlalchemy import MetaData
+from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
+from sqlalchemy.ext.declarative import declarative_base
+from sqlalchemy.orm import sessionmaker
+
+from config import DB_HOST, DB_NAME, DB_PASS, DB_PORT, DB_USER
+
+from pydantic import BaseModel
+from sqlalchemy import Table, Column, Integer, String, TIMESTAMP, ForeignKey, Boolean, MetaData, PrimaryKeyConstraint
+from sqlalchemy.orm import DeclarativeBase
+
+
+class Base(DeclarativeBase):
+ def fill_from_model(self, model: BaseModel | dict):
+ """Заполняет все поля из словаря или модели, помечая объект 'грязным' для корректного обновления"""
+ if isinstance(model, BaseModel):
+ items = model.model_dump().items()
+ else:
+ items = model.items()
+
+ for key, value in items:
+ setattr(self, key, value)
+
+DATABASE_URL = f"postgresql+asyncpg://{DB_USER}:{DB_PASS}@{DB_HOST}:{DB_PORT}/{DB_NAME}"
+
+
+engine = create_async_engine(DATABASE_URL, echo=gettrace() is not None)
+async_session_maker = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
+
+
+async def get_async_session() -> AsyncGenerator[AsyncSession, None]:
+ async with async_session_maker() as session:
+ yield session
+
+
+async def begin_transactions() -> AsyncGenerator[AsyncSession, None]:
+ async with async_session_maker().begin() as transaction:
+ try:
+ yield transaction.session
+ except Exception as e:
+ await transaction.rollback()
+ raise e
+ await transaction.commit()
diff --git a/agliullov_daniyar_lab_3/device_service/device/__init__.py b/agliullov_daniyar_lab_3/device_service/device/__init__.py
new file mode 100644
index 0000000..c02f0a1
--- /dev/null
+++ b/agliullov_daniyar_lab_3/device_service/device/__init__.py
@@ -0,0 +1,5 @@
+from .models import *
+from .router import *
+from .schemas import *
+from .dependencies import *
+from .websocket_auth import *
diff --git a/agliullov_daniyar_lab_3/device_service/device/common_schemas.py b/agliullov_daniyar_lab_3/device_service/device/common_schemas.py
new file mode 100644
index 0000000..1efe4c0
--- /dev/null
+++ b/agliullov_daniyar_lab_3/device_service/device/common_schemas.py
@@ -0,0 +1,31 @@
+"""Модуль содержащий общие схемы для хаба и устройства во избежание цикличного импорта"""
+from datetime import datetime
+from typing import Optional
+
+from pydantic import BaseModel, Field
+
+__all__ = ["RoleCreate", "RoleRead", "ActiveDeviceRead", "ActiveDeviceCreate", "ModelRead"]
+
+
+class ModelRead(BaseModel, from_attributes=True, frozen=True):
+ name: str
+ version: str
+
+
+class RoleCreate(BaseModel):
+ name: str
+ permissions: Optional[dict]
+
+
+class RoleRead(RoleCreate, from_attributes=True):
+ id: int
+
+
+class ActiveDeviceCreate(BaseModel, from_attributes=True):
+ last_connection: Optional[datetime] = None
+ role: RoleCreate = Field(default_factory=lambda: RoleCreate(name="admin", permissions={"include_all": True}))
+
+
+class ActiveDeviceRead(ActiveDeviceCreate, from_attributes=True):
+ # id: uuid.UUID todo насколько необходимо
+ role: Optional[RoleRead] = None
diff --git a/agliullov_daniyar_lab_3/device_service/device/dependencies.py b/agliullov_daniyar_lab_3/device_service/device/dependencies.py
new file mode 100644
index 0000000..198004e
--- /dev/null
+++ b/agliullov_daniyar_lab_3/device_service/device/dependencies.py
@@ -0,0 +1,207 @@
+import hashlib
+import uuid
+from typing import List, Tuple, Optional
+
+from fastapi import Depends, Path, HTTPException
+from sqlalchemy import select, text
+from sqlalchemy import Table, Column, Integer, String, TIMESTAMP, ForeignKey, JSON, Boolean, MetaData, DateTime, UUID, \
+ ARRAY, DECIMAL, DOUBLE_PRECISION
+from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy.orm import joinedload, relationship
+from datetime import datetime
+
+
+current_user = lambda: None
+
+
+from database import get_async_session, Base
+from jwt_config import tokens_data, denylist
+from .schemas import DeviceRead, DeviceWebsocketAuth, JwtAccessClaimsForDeviceRequests, \
+ JwtRefreshClaimsForDeviceRequests, TokensRead, TokenDataServer
+
+from device.models import Device, ActiveDevice, DeviceForInherit, Hub
+
+from fastapi import Depends
+from fastapi_users_db_sqlalchemy import SQLAlchemyUserDatabase
+from fastapi_users_db_sqlalchemy import SQLAlchemyBaseUserTable
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from database import get_async_session
+
+class User(SQLAlchemyBaseUserTable[uuid.UUID], Base):
+ __tablename__ = 'users'
+
+ id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
+ email = Column(String, nullable=False)
+ name = Column(String, nullable=False)
+ surname = Column(String, nullable=False)
+ patronymic = Column(String, nullable=True)
+ creation_on = Column(TIMESTAMP, default=datetime.utcnow)
+ last_entry = Column(TIMESTAMP, nullable=True)
+ hashed_password = Column(String(length=1024), nullable=False)
+ is_active = Column(Boolean, default=True, nullable=False)
+ is_superuser = Column(Boolean, default=False, nullable=False)
+ is_verified = Column(Boolean, default=False, nullable=False)
+ token = Column(String, nullable=True)
+ fcm_token = Column(String, nullable=True) # Идентификатор устройства для уведомлений
+ hashes_rfids = Column(ARRAY(String), default=[])
+ face_embedding = Column(ARRAY(DOUBLE_PRECISION), nullable=True)
+
+ devices = relationship("Device", secondary="active_devices", back_populates="users", viewonly=True)
+ active_devices = relationship("ActiveDevice")
+ group = relationship("Group", uselist=False, back_populates="users")
+ notifications = relationship("Notification", back_populates="user")
+
+ async def include_relationship_for_read(self, session: AsyncSession):
+ await session.refresh(self, ["group"])
+ return self
+
+async def get_user_db(session: AsyncSession = Depends(get_async_session)):
+ yield SQLAlchemyUserDatabase(session, User)
+
+
+
+
+__all__ = ["get_active_devices", "get_active_devices_in_schema", "jwt_websocket_create_access_token", "get_tokens",
+ "get_tokens_from_refresh",
+ "optional_verifyed_access_token", "verifyed_access_token"]
+
+
+async def get_active_devices(user: User = Depends(current_user),
+ session: AsyncSession = Depends(get_async_session),
+ cast_to_schema: bool = False) -> List[Device | DeviceRead]:
+ tables = [table for table in DeviceForInherit.__subclasses__() if table is not Device and table is not Hub]
+ stmt = (
+ select(Device, ActiveDevice, *tables) # только one-to-one relathionship, иначе будет дублирование строк
+ .join(Device.active_devices)
+ .options(joinedload(ActiveDevice.role, innerjoin=True),
+ joinedload(Device.model))
+ .where(ActiveDevice.user_id == user.id)
+ .where(Device.activated == True)
+ .join(Device.hub, isouter=True)
+ )
+ for table in tables:
+ stmt = stmt.join(table, table.device_id == Device.id)
+ print("count join:", str(stmt).lower().count("join"))
+ devices = []
+ async for device, active_device, *inherit_devices in await session.stream(stmt):
+ inherit_device = next((i for i in inherit_devices if i is not None), None)
+ device.__dict__["active_device"] = active_device
+ if inherit_device is None:
+ devices.append(device.get_schema() if cast_to_schema else inherit_device)
+ else:
+ inherit_device.device = device
+ devices.append(inherit_device.get_schema() if cast_to_schema else inherit_device)
+ return devices
+
+
+async def get_active_devices_in_schema(user: User = Depends(current_user),
+ session: AsyncSession = Depends(get_async_session)) -> list[DeviceRead]:
+ return await get_active_devices(user, session, True)
+
+
+def jwt_websocket_create_access_token(device: DeviceWebsocketAuth,
+ _=Depends(current_user),
+ authorize = None) -> str:
+ # Получение токена по пользователю для подключения устройства по вебсокетам
+ # При необходимости можно добавить ограничение по количествку токенов на пользователя или на устройства
+ return authorize.create_access_token(device.macaddress, user_claims=device.model_dump(), expires_time=False)
+
+
+def jwt_request_create_access_token(claims: JwtAccessClaimsForDeviceRequests, authorize: None = Depends()) -> str:
+ return authorize.create_access_token(str(claims.device_id), user_claims=claims.model_dump(exclude="device_id"))
+
+
+def jwt_request_create_refresh_token(claims: JwtRefreshClaimsForDeviceRequests, authorize: None = Depends()) -> str:
+ return authorize.create_refresh_token(claims.part_access, user_claims=claims.model_dump(mode='json'))
+
+
+def get_tokens(device_id: uuid.UUID, authorize: None) -> TokensRead:
+ access_token = jwt_request_create_access_token(JwtAccessClaimsForDeviceRequests(device_id=device_id), authorize)
+ refresh_token = jwt_request_create_refresh_token(
+ JwtRefreshClaimsForDeviceRequests(device_id=device_id, part_access=access_token[:30]),
+ authorize
+ )
+ tokens_data[authorize.get_raw_jwt(refresh_token)['jti']] = TokenDataServer.default()
+ return TokensRead(
+ access_token=access_token,
+ refresh_token=refresh_token
+ )
+
+
+def verifyed_refresh_token(access_token: str, refresh_token, counter_hash: str,
+ authorize = None) -> Tuple[JwtRefreshClaimsForDeviceRequests, TokenDataServer]:
+ authorize.jwt_refresh_token_required("websocket", token=refresh_token)
+ jwt_raw_refresh_token = authorize.get_raw_jwt(refresh_token)
+ jwt_raw_access_token = authorize.get_raw_jwt(access_token)
+ model = JwtRefreshClaimsForDeviceRequests.model_validate(jwt_raw_refresh_token)
+
+ if model.part_access != access_token[:30]:
+ raise HTTPException(
+ status_code=403,
+ detail="part access token does not match input access token"
+ )
+ token_data = tokens_data.get(jwt_raw_refresh_token['jti'])
+ # todo Если токены не сохраняться при падении сервера, тогда валидации не должно быть,
+ # если сохраняются всегда должны находиться данные по токену
+ if token_data is None:
+ token_data = TokenDataServer.default()
+ # raise HTTPException(
+ # status_code=403,
+ # detail="refresh token no save in server. Get refresh_token again in /get_tokens_from_user"
+ # )
+ if token_data.hash_counter != counter_hash:
+ raise HTTPException(
+ status_code=403,
+ detail="invalid counter hash"
+ )
+ # Проверка прошла успешно
+ data = TokenDataServer(
+ counter=token_data.counter + 1,
+ hash_counter=hashlib.md5(str(token_data.counter + 1).encode()).hexdigest()
+ )
+ # Токены больше не актуальны, добавляем в чс
+ tokens_data.pop(jwt_raw_refresh_token['jti'], None)
+ tokens_data.pop(jwt_raw_access_token['jti'], None)
+ denylist.update({
+ jwt_raw_refresh_token['jti'],
+ jwt_raw_access_token['jti']
+ })
+ return model, data
+
+
+def verifyed_access_token(token: str | None, authorize = None
+ ) -> JwtAccessClaimsForDeviceRequests:
+ # Добавить доп валидацию при необходимости
+ authorize.jwt_required("websocket", token)
+ raw = authorize.get_raw_jwt(token)
+ return JwtAccessClaimsForDeviceRequests.model_validate(raw | {"device_id": raw["sub"]})
+
+
+def optional_verifyed_access_token(token: str | None = None, authorize = None
+ ) -> JwtAccessClaimsForDeviceRequests | None:
+ try:
+ return verifyed_access_token(token, authorize)
+ except:
+ return None
+
+
+def get_tokens_from_refresh(model_data: JwtRefreshClaimsForDeviceRequests = Depends(verifyed_refresh_token),
+ authorize = None) -> TokensRead:
+ model, data = model_data
+ result = get_tokens(model.device_id, authorize)
+ tokens_data[authorize.get_raw_jwt(result.refresh_token)['jti']] = data
+ return result
+
+
+async def device_activate(token: Optional[JwtAccessClaimsForDeviceRequests] = Depends(verifyed_access_token),
+ session: AsyncSession = Depends(get_async_session)) -> Device:
+
+ device = await session.get(Device, ident=token.device_id)
+ if device is None:
+ raise EntityNotFound(Device, device_id=token.device_id, **token.model_dump()).get()
+ if device.activated:
+ raise HTTPException(status_code=400, detail="Устройство уже активировано")
+ device.activated = True
+ await session.commit()
+ return device
diff --git a/agliullov_daniyar_lab_3/device_service/device/models.py b/agliullov_daniyar_lab_3/device_service/device/models.py
new file mode 100644
index 0000000..bfc1b7f
--- /dev/null
+++ b/agliullov_daniyar_lab_3/device_service/device/models.py
@@ -0,0 +1,158 @@
+import datetime
+import abc
+import uuid
+from typing import Type
+
+from pydantic import BaseModel
+from sqlalchemy import (Table, Column, Integer, String, TIMESTAMP, ForeignKey, Boolean, MetaData,
+ UUID, DateTime, Index, JSON, func)
+from sqlalchemy.orm import relationship, class_mapper
+from sqlalchemy.dialects.postgresql import MACADDR8, CIDR
+
+from database import Base
+
+__all__ = ["Device", "Model", "ActiveDevice", "Role", "DeviceForInherit", "Hub"]
+
+
+from .schemas import DeviceCreate, ActiveDeviceCreate, DeviceRead
+
+current_user = lambda: None
+
+class DeviceForInherit(Base):
+ __abstract__ = True
+
+ @classmethod
+ def create_from(cls, schema: DeviceCreate,
+ active_devices: list[ActiveDeviceCreate] | ActiveDeviceCreate | None = None,
+ user_id_in_active_devices: uuid.UUID | None = None) -> 'DeviceForInherit':
+ # overrides in inherits models
+ if not isinstance(schema, (DeviceCreate, "HubCreate")):
+ raise TypeError(f"schema must be a DeviceCreate or inherit from him, not {type(schema)}")
+ if cls == Device:
+ return cls.create_device(schema, active_devices, user_id_in_active_devices)
+ result = cls()
+ result.__dict__.update(schema.model_dump())
+ result.device = cls.create_device(schema, active_devices, user_id_in_active_devices)
+ return result
+
+ @abc.abstractmethod
+ def get_schema(self) -> DeviceRead: # overrides
+ pass
+
+ def get_schema_from_type(self, T: Type[DeviceRead]) -> DeviceRead:
+ if 'device' not in self.__dict__:
+ raise ValueError(f'Property device must be loaded {self.__dict__}')
+ if 'hub' not in self.device.__dict__ and 'hub_id' in self.device.__dict__:
+ self.device.__dict__['hub'] = self.device.hub_id
+ elif isinstance(self.device.hub, Hub):
+ self.device.__dict__["hub"] = self.device.hub.__dict__ | self.device.hub.device.__dict__
+ return T.model_validate(self.__dict__ | self.device.__dict__ | {"type_name": type(self).__name__})
+
+ @staticmethod
+ def create_device(schema: DeviceCreate,
+ active_devices: list[ActiveDeviceCreate] | ActiveDeviceCreate | None = None,
+ user_id_in_active_devices: uuid.UUID | None = None) -> 'Device':
+ schema_model = schema.model_dump(exclude={'hub', "model"})
+ device = Device(
+ **{key: value for key, value in schema_model.items() if key in class_mapper(Device).attrs.keys()},
+ )
+ if hasattr(schema, 'hub') and isinstance(schema.hub, uuid.UUID):
+ device.hub_id = schema.hub
+ elif hasattr(schema, 'hub') and schema.hub is not None:
+ device.hub = Hub.create_from(schema.hub, active_devices, user_id_in_active_devices)
+ if isinstance(active_devices, list):
+ device.active_devices = [ActiveDevice.create_from(i, user_id_in_active_devices) for i in active_devices]
+ elif isinstance(active_devices, ActiveDeviceCreate):
+ device.active_devices = [ActiveDevice.create_from(active_devices, user_id_in_active_devices)]
+ if schema.model is not None:
+ device.model = Model(**schema.model.model_dump())
+ return device
+
+
+class Device(DeviceForInherit, Base):
+ __tablename__ = 'devices'
+
+ id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
+ model_id = Column(ForeignKey("models.id"), nullable=True)
+
+ # use_alter - для разрешения цикличных зависимостей nullable - True, чтобы у хаба не было хаба
+ # (ограничение для остальных устройств будет на уровне схем)
+ # name - имя ограничения для субд и нормальной работы миграций и автотестов
+ hub_id = Column(ForeignKey("hubs.device_id", use_alter=True, name="fk_device_hub_id"), nullable=True)
+
+ first_connection_date = Column(DateTime, default=datetime.datetime.utcnow)
+ last_connection_date = Column(DateTime, server_default=func.now(), onupdate=func.current_timestamp())
+ macaddress = Column(MACADDR8, nullable=False)
+ serial_number = Column(Integer, nullable=False)
+ configuration = Column(JSON, nullable=False, default={})
+ is_active = Column(Boolean, nullable=False)
+ activated = Column(Boolean, nullable=False, default=False, index=True) # Устройство подключилось к серверу
+
+ hub = relationship("Hub", back_populates="connected_devices", uselist=False, foreign_keys=[hub_id])
+ model = relationship('Model', uselist=False, cascade="all,delete",)
+ active_devices = relationship("ActiveDevice", cascade="all,delete", back_populates="device")
+ users = relationship('User', secondary='active_devices', back_populates="devices", viewonly=True)
+
+ __table_args__ = (
+ Index('unique_phys_device', "serial_number", "macaddress", unique=True),
+ )
+
+ def get_schema(self) -> DeviceRead:
+ return self.get_schema_from_type(DeviceRead)
+
+
+class Hub(DeviceForInherit):
+ __tablename__ = 'hubs'
+
+ device_id = Column(ForeignKey('devices.id'), primary_key=True)
+ ip_address = Column(CIDR, nullable=False)
+ port = Column(Integer, nullable=False)
+
+ device = relationship("Device", uselist=False, foreign_keys=[device_id])
+ connected_devices = relationship("Device", foreign_keys=[Device.hub_id], back_populates="hub")
+
+ def get_schema(self) -> "HubRead":
+ return self.get_schema_from_type("HubRead")
+
+
+class Model(Base):
+ __tablename__ = 'models'
+
+ id = Column(Integer, primary_key=True, autoincrement=True)
+ name = Column(String, nullable=False)
+ version = Column(String, nullable=False)
+
+
+class Role(Base):
+ __tablename__ = 'roles'
+
+ id = Column(Integer, primary_key=True, autoincrement=True)
+ name = Column(String, nullable=False)
+ permissions = Column(JSON, nullable=False) # todo продумать структуру
+
+ active_device = relationship("ActiveDevice", uselist=False, back_populates="role")
+
+
+class ActiveDevice(Base):
+ __tablename__ = 'active_devices'
+
+ id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
+ user_id = Column(ForeignKey('users.id'))
+ device_id = Column(ForeignKey('devices.id'))
+ role_id = Column(ForeignKey('roles.id'), nullable=False)
+ last_connection = Column(DateTime, default=datetime.datetime.utcnow, nullable=False)
+
+ device = relationship(Device, uselist=False, back_populates="active_devices")
+ role = relationship(Role, uselist=False, back_populates="active_device")
+ user = relationship("User", uselist=False, back_populates="active_devices")
+
+ __table_args__ = (
+ Index('fk_index', "user_id", "device_id", unique=True),
+ )
+
+ @staticmethod
+ def create_from(schema: ActiveDeviceCreate, user_id: uuid.UUID | None = None) -> "ActiveDevice":
+ md = schema.model_dump(exclude={'role'})
+ if user_id is not None:
+ md['user_id'] = user_id
+ return ActiveDevice(role=Role(**schema.role.model_dump()), **md)
diff --git a/agliullov_daniyar_lab_3/device_service/device/router.py b/agliullov_daniyar_lab_3/device_service/device/router.py
new file mode 100644
index 0000000..91c735f
--- /dev/null
+++ b/agliullov_daniyar_lab_3/device_service/device/router.py
@@ -0,0 +1,154 @@
+from typing import List, Any, Optional
+
+from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Depends, HTTPException
+from fastapi.encoders import jsonable_encoder
+from starlette.responses import HTMLResponse
+
+from jwt_config import denylist, jwt_websocket_required_auth
+from . import Device
+from .dependencies import get_active_devices_in_schema, get_tokens, get_tokens_from_refresh, device_activate
+from .schemas import DeviceRead, DeviceWebsocketAuth, TokensRead
+
+
+current_user = lambda: None
+
+
+__all__ = ["router", "manager", "denylist", "websocket_router"]
+
+
+router = APIRouter(
+ prefix="/device",
+ tags=["Device"]
+)
+websocket_router = APIRouter()
+
+
+@router.get("/active_devices")
+async def get_all_active_devices_for_user(active_devices=Depends(get_active_devices_in_schema)
+ ) -> List[DeviceRead | None]:
+ """Получает все доступные **активированные** устройства всех видов, связанные с текущем пользователем.
+ Внутри сущностей храниться **active_device**, в котором обозначена взаимодействие устройства с конкретным пользователем"""
+ return active_devices
+
+
+@router.get("/token_from_user")
+async def get_tokens_for_device_from_user(
+ tokens: TokensRead = Depends(get_tokens),
+ _=Depends(current_user)) -> TokensRead:
+ """Генерирует токены по переданому **device_id**, валидация devie_id не происходит,
+ просто токен с невалидным id нельзя будет нигде применить"""
+ return tokens
+
+
+@router.patch("/activate", responses={400: {"description": "Устройство уже активировано"}})
+async def activate_added_device(device: Device = Depends(device_activate)) -> DeviceRead:
+ return device.__dict__
+
+
+@router.get("/refresh_token", responses={
+ 401: {"description": "Некорректно переданные токены"},
+ 403: {"description": "Некорректно переданные токены"}
+})
+async def refresh_tokens(
+ tokens: TokensRead = Depends(get_tokens_from_refresh)) -> TokensRead:
+ return tokens
+
+
+class ConnectionManager:
+ def __init__(self):
+ self.active_connections: dict[DeviceWebsocketAuth, WebSocket] = {}
+
+ async def connect(self, websocket: WebSocket,
+ token: str,
+ authorize = None
+ ) -> Optional[DeviceWebsocketAuth]:
+
+ decoded_token, device = jwt_websocket_required_auth(token, authorize)
+
+ await websocket.accept()
+ old_websocket = self.active_connections.get(device) # теоретически на одно устройство одно вебсокет подключение
+ if old_websocket is not None:
+ await old_websocket.close()
+ self.active_connections[device] = websocket
+ denylist.add(decoded_token['jti'])
+ return device
+
+ def disconnect(self, device: DeviceWebsocketAuth):
+ self.active_connections.pop(device, None)
+
+ async def send_message_to_device(self, device: DeviceWebsocketAuth, message: str | Any) -> WebSocket:
+ device_auth = DeviceWebsocketAuth.model_validate(device, from_attributes=True)
+ websocket = self.active_connections.get(device_auth)
+ if websocket is None:
+ raise HTTPException(status_code=404, detail={
+ "msg": "Устройство, статус которого пытаются изменить, не подключен к серверу",
+ "device": jsonable_encoder(device)
+ })
+ if isinstance(message, str):
+ await websocket.send_text(message)
+ else:
+ await websocket.send_json(message)
+ return websocket
+
+ async def broadcast(self, message: str):
+ for connection in self.active_connections.values():
+ await connection.send_text(message)
+
+ async def change_status_smart_lock(self, smart_lock: 'SmartLockRead'):
+ # При необходимости ограничить или типизировать отправляемый ответ
+ await self.send_message_to_device(smart_lock, smart_lock.model_dump())
+
+
+manager = ConnectionManager()
+
+
+@websocket_router.websocket("/")
+async def websocket_endpoint(websocket: WebSocket, token: str, authorize: None):
+ try:
+ device = await manager.connect(websocket, token, authorize)
+ except Exception:
+ await websocket.close()
+ return
+ try:
+ while True:
+ data = await websocket.receive_json()
+ except WebSocketDisconnect:
+ manager.disconnect(websocket)
+
+
+# Debug
+
+html = """
+
+
+
+ Authorize
+
+
+ WebSocket Authorize
+ Token:
+
+
+
+
+
+
+"""
+
+
+@websocket_router.get("/debug_form", tags=["Debug"])
+async def get():
+ return HTMLResponse(html)
diff --git a/agliullov_daniyar_lab_3/device_service/device/schemas.py b/agliullov_daniyar_lab_3/device_service/device/schemas.py
new file mode 100644
index 0000000..0e8a73a
--- /dev/null
+++ b/agliullov_daniyar_lab_3/device_service/device/schemas.py
@@ -0,0 +1,68 @@
+import binascii
+import hashlib
+import uuid
+from datetime import datetime
+from typing import Optional
+
+from pydantic import *
+from pydantic_extra_types.mac_address import MacAddress
+
+from .common_schemas import *
+
+__all__ = ["ModelRead", "DeviceCreate", "DeviceRead", "RoleRead", "RoleCreate",
+ "ActiveDeviceCreate", "ActiveDeviceRead", "DeviceWebsocketAuth", "JwtRefreshClaimsForDeviceRequests",
+ "JwtAccessClaimsForDeviceRequests", "TokensRead", "TokenDataServer"]
+
+
+class DeviceWebsocketAuth(BaseModel, frozen=True):
+ macaddress: MacAddress
+ last_connection_date: datetime
+ model: Optional[ModelRead] = None
+
+
+class JwtAccessClaimsForDeviceRequests(BaseModel):
+ device_id: uuid.UUID
+
+
+class JwtRefreshClaimsForDeviceRequests(BaseModel):
+ part_access: str
+ device_id: uuid.UUID
+
+
+class TokenDataServer(BaseModel):
+ counter: int
+ hash_counter: str
+
+ @staticmethod
+ def hash(counter: int | str):
+ return binascii.hexlify(hashlib.md5(str(counter).encode()).digest())
+
+ @staticmethod
+ def default():
+ return TokenDataServer(
+ counter=0,
+ hash_counter=TokenDataServer.hash(0)
+ )
+
+
+class TokensRead(BaseModel):
+ access_token: str
+ refresh_token: str
+
+
+class DeviceCreate(BaseModel, from_attributes=True):
+ macaddress: MacAddress
+ is_active: bool
+ configuration: Json | dict
+ serial_number: int
+ hub: uuid.UUID | None = None
+ model: Optional[ModelRead] = None
+
+
+class DeviceRead(DeviceCreate):
+ id: uuid.UUID
+ type_name: str = "Device"
+ hub: uuid.UUID | None = None
+ activated: bool = False
+ active_device: Optional[ActiveDeviceRead] = None
+ last_connection_date: datetime | None = None
diff --git a/agliullov_daniyar_lab_3/device_service/device/test_connect.json b/agliullov_daniyar_lab_3/device_service/device/test_connect.json
new file mode 100644
index 0000000..cc66d9c
--- /dev/null
+++ b/agliullov_daniyar_lab_3/device_service/device/test_connect.json
@@ -0,0 +1 @@
+{"doorlock": {"ip": "192.168.31.222", "host": "21488", "mac": "", "type": "phone"}, "phone": {"ip": "192.168.31.222", "host": "53970", "mac": "1", "type": "controller"}}
\ No newline at end of file
diff --git a/agliullov_daniyar_lab_3/device_service/device/test_device.py b/agliullov_daniyar_lab_3/device_service/device/test_device.py
new file mode 100644
index 0000000..418ea43
--- /dev/null
+++ b/agliullov_daniyar_lab_3/device_service/device/test_device.py
@@ -0,0 +1,89 @@
+import hashlib
+import uuid
+
+import pytest
+from sqlalchemy import select
+
+from device import DeviceRead, Device, TokenDataServer
+from smart_lock.schemas import SmartLockCreate, SmartLockRead
+from test_database import *
+from auth.test_auth import *
+from smart_lock.test_smart_lock import get_json_for_create
+
+
+@pytest.mark.asyncio
+async def test_get_active_devices(auth_client: AsyncClient, session: AsyncSession):
+ # Добавляем устройства всех возможных типов
+ # todo более внятный тест
+ json_smart_lock = get_json_for_create()
+ json_smart_lock_2 = get_json_for_create()
+ json_smart_lock_2["smart_lock"]["hub"]["ip_address"] = "192.168.1.1/32"
+ json_smart_lock_2["smart_lock"]["serial_number"] = 344
+ json_smart_lock_2["smart_lock"]["macaddress"] = "22:11:22:ff:fe:33:44:55"
+ json_smart_lock_2["smart_lock"]["hub"]["serial_number"] = 99
+
+ response = await auth_client.post("/smart_lock/", json=json_smart_lock)
+ response = await auth_client.patch("for_device/activate", params={"token": response.json()["access_token"]})
+ response = await auth_client.post("/smart_lock/", json=json_smart_lock)
+ assert response.json()["activated"] is False
+ response = await auth_client.patch("for_device/activate", params={"token": response.json()["access_token"]})
+ response = await auth_client.post("/smart_lock/", json=json_smart_lock_2)
+ response = await auth_client.patch("for_device/activate", params={"token": response.json()["access_token"]})
+
+ response = await auth_client.get("/device/active_devices")
+ assert response.status_code == 200
+ json = response.json()
+ models: list[DeviceRead] = [DeviceRead.model_validate(i) for i in json]
+ assert len(models) == 2
+
+ # check polymorphism
+ excluded = {'id', 'last_interaction_date', 'active_device', "hub", "last_connection_date", "activated"}
+ json_smart_lock["smart_lock"]["hub"]["id"] = uuid.uuid4()
+ json_smart_lock_2["smart_lock"]["hub"]["id"] = uuid.uuid4()
+ assert (SmartLockRead(**json[0]).model_dump(exclude=excluded) ==
+ SmartLockRead(**json_smart_lock["smart_lock"], id=uuid.uuid4()).model_dump(exclude=excluded))
+ assert (SmartLockRead(**json[1]).model_dump(exclude=excluded) ==
+ SmartLockRead(**json_smart_lock_2["smart_lock"], id=uuid.uuid4()).model_dump(exclude=excluded))
+
+
+@pytest.mark.asyncio
+async def test_get_token_from_user(auth_client: AsyncClient, session: AsyncSession):
+ device = (await session.execute(select(Device))).scalar()
+ response = await auth_client.get("/device/token_from_user", params={"device_id": device.id})
+ assert response.status_code == 200
+ assert list(response.json().keys()) == ["access_token", "refresh_token"]
+ print(response.json())
+
+
+@pytest.mark.asyncio
+async def test_refresh_token(auth_client: AsyncClient, session: AsyncSession):
+ device = (await session.execute(select(Device))).scalar()
+ response = await auth_client.get("/device/token_from_user", params={"device_id": device.id})
+ assert response.status_code == 200
+ json = response.json()
+ json["counter_hash"] = TokenDataServer.hash(0).decode() # Не было обновений, значит ставим 0
+
+ # 1 обновление токена
+ response = await auth_client.get("/for_device/refresh_token", params=json)
+ assert response.status_code == 200
+ json1 = response.json()
+
+ response = await auth_client.get("/for_device/refresh_token", params=json)
+ assert response.status_code in (401, 403) # Token has been revoked
+
+ # 2 обновление токена со старым счетчиком (не должен сработать)
+ json1["counter_hash"] = json["counter_hash"]
+ response = await auth_client.get("/for_device/refresh_token", params=json1)
+ assert response.status_code in (401, 403) # invalid counter hash
+
+ # 2 обновление токена с обновленным счетчиком
+ json1["counter_hash"] = TokenDataServer.hash(1).decode()
+ response = await auth_client.get("/for_device/refresh_token", params=json1)
+ assert response.status_code == 200
+
+ # 3 обновление токена с обновленным счетчиком
+ # hashlib.md5(str(2).encode()).hexdigest()
+ json2 = response.json()
+ json2["counter_hash"] = TokenDataServer.hash(2).decode()
+ response = await auth_client.get("/for_device/refresh_token", params=json2)
+ assert response.status_code == 200
diff --git a/agliullov_daniyar_lab_3/device_service/device/websocket_auth.py b/agliullov_daniyar_lab_3/device_service/device/websocket_auth.py
new file mode 100644
index 0000000..e46da96
--- /dev/null
+++ b/agliullov_daniyar_lab_3/device_service/device/websocket_auth.py
@@ -0,0 +1,12 @@
+from fastapi import Depends
+
+from .dependencies import jwt_websocket_create_access_token
+from .router import router
+from .schemas import DeviceWebsocketAuth
+
+
+@router.post("/ws/token")
+def get_access_token_for_connection_to_device(access_token: str = Depends(jwt_websocket_create_access_token)):
+ return {
+ "access_token": access_token
+ }
diff --git a/agliullov_daniyar_lab_3/device_service/jwt_config.py b/agliullov_daniyar_lab_3/device_service/jwt_config.py
new file mode 100644
index 0000000..28d732c
--- /dev/null
+++ b/agliullov_daniyar_lab_3/device_service/jwt_config.py
@@ -0,0 +1,40 @@
+import abc
+import os
+from typing import Tuple
+
+from fastapi import Depends, Query
+from pydantic import BaseModel
+
+from device.schemas import DeviceWebsocketAuth, TokenDataServer
+
+# Множество заблокированных токенов доступа по вебсокетам. Токены одноразовые, но при дропе системы список должен
+# очищаться (не хранится в постоянной памяти), чтобы устройства могли повторно подключиться к вебсокетам после
+# восстановления
+# todo перенести на redis
+denylist = set()
+tokens_data: dict[str, TokenDataServer] = dict()
+
+
+class Settings(BaseModel):
+ authjwt_secret_key: str = os.environ.get("SECRET_DEVICE_AUTH_TOKEN")
+ authjwt_denylist_enabled: bool = True
+ authjwt_denylist_token_checks: set = {"access", "refresh"}
+ authjwt_refresh_token_expires: bool = False
+
+
+def check_if_token_in_denylist(decrypted_token):
+ jti = decrypted_token['jti']
+ return jti in denylist
+
+
+def get_config():
+ return Settings()
+
+
+def jwt_websocket_required_auth(token: str = Query(...), authorize: None = Depends()) -> Tuple[dict, 'DeviceWebsocketAuth']:
+ authorize.jwt_required("websocket", token=token)
+
+ decoded_token = authorize.get_raw_jwt(token)
+ device = DeviceWebsocketAuth.model_validate(decoded_token)
+
+ return decoded_token, device
diff --git a/agliullov_daniyar_lab_3/device_service/main.py b/agliullov_daniyar_lab_3/device_service/main.py
new file mode 100644
index 0000000..a04bb03
--- /dev/null
+++ b/agliullov_daniyar_lab_3/device_service/main.py
@@ -0,0 +1,67 @@
+import datetime
+import time
+import json
+
+import uvicorn
+from fastapi import FastAPI, Depends, Body
+from fastapi.staticfiles import StaticFiles
+from fastapi.encoders import jsonable_encoder
+from pydantic import BaseModel
+from sqlalchemy.exc import DBAPIError
+from starlette.requests import Request
+from starlette.responses import JSONResponse
+
+from device.router import router, websocket_router
+
+app = FastAPI(
+ title="Smart lock",
+ root_path="/devices"
+)
+
+@app.get("/docs", include_in_schema=False)
+async def custom_swagger_ui_html(req: Request):
+ root_path = req.scope.get("root_path", "").rstrip("/")
+ openapi_url = root_path + app.openapi_url
+ return get_swagger_ui_html(
+ openapi_url=openapi_url,
+ title="API",
+ )
+
+app.include_router(router)
+app.include_router(websocket_router)
+
+
+@app.middleware("http")
+async def add_process_time_header(request: Request, call_next):
+ start_time = time.perf_counter()
+ response = await call_next(request)
+ process_time = time.perf_counter() - start_time
+ print(f"Finished in {process_time:.3f}")
+ response.headers["X-Process-Time"] = str(process_time)
+ return response
+
+
+@app.exception_handler(DBAPIError)
+def authjwt_exception_handler(request: Request, exc: DBAPIError):
+ return JSONResponse(
+ status_code=500,
+ content={"detail": jsonable_encoder(exc) | {"args": jsonable_encoder(exc.args)}}
+ )
+
+
+# @app.exception_handler(Exception)
+# def authjwt_exception_handler(request: Request, exc: Exception):
+# print(exc.with_traceback(None))
+# return JSONResponse(
+# status_code=500,
+# content={
+# "name_exception": exc.__class__.__name__,
+# "args": getattr(exc, "args", []),
+# "detail": jsonable_encoder(exc)
+# }
+# )
+
+
+if __name__ == '__main__':
+ uvicorn.run(app=app, host="0.0.0.0", port=8001)
+ # uvicorn.run(app="main:app", host="0.0.0.0", port=8000, reload=True)
diff --git a/agliullov_daniyar_lab_3/device_service/requirements.txt b/agliullov_daniyar_lab_3/device_service/requirements.txt
new file mode 100644
index 0000000..53a6d7c
--- /dev/null
+++ b/agliullov_daniyar_lab_3/device_service/requirements.txt
@@ -0,0 +1,9 @@
+fastapi==0.109.1
+uvicorn
+sqlalchemy
+fastapi-users==12.1.2
+python-dotenv
+asyncpg
+pydantic_extra_types
+fastapi_jwt_auth
+fastapi_users_db_sqlalchemy
\ No newline at end of file
diff --git a/agliullov_daniyar_lab_3/docker-compose.yml b/agliullov_daniyar_lab_3/docker-compose.yml
new file mode 100644
index 0000000..3d425dd
--- /dev/null
+++ b/agliullov_daniyar_lab_3/docker-compose.yml
@@ -0,0 +1,32 @@
+version: '3.8'
+
+services:
+ user_service:
+ env_file: ".env"
+ build:
+ context: ./user_service
+ dockerfile: Dockerfile
+ environment:
+ - PORT=8000
+ ports:
+ - "8000:8000"
+
+ device_service:
+ env_file: ".env"
+ build:
+ context: ./device_service
+ dockerfile: Dockerfile
+ environment:
+ - PORT=8001
+ ports:
+ - "8001:8001"
+
+ nginx:
+ image: nginx:latest
+ volumes:
+ - ./nginx/nginx.conf:/etc/nginx/conf.d/default.conf
+ ports:
+ - "80:80"
+ depends_on:
+ - user_service
+ - device_service
diff --git a/agliullov_daniyar_lab_3/nginx/nginx.conf b/agliullov_daniyar_lab_3/nginx/nginx.conf
new file mode 100644
index 0000000..eb2412c
--- /dev/null
+++ b/agliullov_daniyar_lab_3/nginx/nginx.conf
@@ -0,0 +1,11 @@
+server {
+ listen 80;
+
+ location /users {
+ proxy_pass http://user_service:8000;
+ }
+
+ location /devices/ {
+ proxy_pass http://device_service:8001;
+ }
+}
diff --git a/agliullov_daniyar_lab_3/readme.md b/agliullov_daniyar_lab_3/readme.md
new file mode 100644
index 0000000..d27078d
--- /dev/null
+++ b/agliullov_daniyar_lab_3/readme.md
@@ -0,0 +1,48 @@
+# Лабораторная работа №3: REST API, шлюз и синхронный обмен данными между микросервисами
+
+## Задание реализовать два микросервиса связанные единой базой и сущностями один ко многим
+
+ - Первый микросервис user предоставляет методы для регистрации авторизации и выхода пользователей
+ - Второй микросервис реализует сущность устройства, которые связанные с пользователями связью многие ко многим
+
+### Реализация
+Оба микросервиса написаны на fastapi и развернуты на вебсервере uvicorn. В самом докере развертывание осущетсвляется через веб сервер nginx,
+который осуществляет роль прокси, прослушивая 80 http порт и перенправляя запрос на тот или иной микросервис
+
+Для корректной работы swagger-а необходимо перенести location из nginx.conf в fastapi, чтобы ему был известен корневой путь, для построения документации
+
+`app = FastAPI(
+ title="Smart lock",
+ root_path="/devices"
+)`
+
+`location /devices/ {
+
+proxy_pass http://device_service:8001;
+
+}`
+
+### Развертывание
+В корневую папку добавляем файл .env, где указываем значение переменных среды
+
+**Пример файла:**
+
+`DB_HOST = localhost
+DB_PORT = 5432
+DB_NAME =
+DB_USER = postgres
+DB_PASS = 1234
+
+SECRET_AUTH_TOKEN = 1
+SECRET_VERIFICATE_TOKEN = 1
+SECRET_DEVICE_AUTH_TOKEN = 1
+
+DEVICE_START_COUNTER = 1
+
+STATIC_URL = https://testapi.danserver.keenetic.name/static/`
+
+Далее указываем:
+
+docker-compose up --build
+
+[Видео](https://disk.yandex.ru/d/BGenDWYY6tIGaw)
diff --git a/agliullov_daniyar_lab_3/user_service/Dockerfile b/agliullov_daniyar_lab_3/user_service/Dockerfile
new file mode 100644
index 0000000..5863c11
--- /dev/null
+++ b/agliullov_daniyar_lab_3/user_service/Dockerfile
@@ -0,0 +1,10 @@
+FROM python:3.11
+
+WORKDIR /app
+
+COPY requirements.txt .
+RUN pip install --no-cache-dir -r requirements.txt
+
+COPY . .
+
+CMD ["uvicorn", "--host", "0.0.0.0", "main:app", "--port", "8000"]
diff --git a/agliullov_daniyar_lab_3/user_service/auth/__init__.py b/agliullov_daniyar_lab_3/user_service/auth/__init__.py
new file mode 100644
index 0000000..31ddba4
--- /dev/null
+++ b/agliullov_daniyar_lab_3/user_service/auth/__init__.py
@@ -0,0 +1,6 @@
+from .models import *
+from .router import *
+from .schemas import *
+from .manager import *
+from .base_config import *
+from .dependencies import *
diff --git a/agliullov_daniyar_lab_3/user_service/auth/base_config.py b/agliullov_daniyar_lab_3/user_service/auth/base_config.py
new file mode 100644
index 0000000..d617dba
--- /dev/null
+++ b/agliullov_daniyar_lab_3/user_service/auth/base_config.py
@@ -0,0 +1,34 @@
+#
+import uuid
+
+from fastapi_users import FastAPIUsers
+from fastapi_users.authentication import CookieTransport, AuthenticationBackend, BearerTransport
+from fastapi_users.authentication import JWTStrategy
+
+from .models import User
+from .manager import get_user_manager
+from config import SECRET_AUTH_TOKEN
+
+__all__ = ["fastapi_users", "current_user", "auth_backend", "current_user_optional"]
+
+cookie_transport = CookieTransport()
+bearer_transport = BearerTransport("/token")
+
+
+def get_jwt_strategy() -> JWTStrategy:
+ return JWTStrategy(secret=SECRET_AUTH_TOKEN, lifetime_seconds=None)
+
+
+auth_backend = AuthenticationBackend(
+ name="jwt",
+ transport=cookie_transport,
+ get_strategy=get_jwt_strategy,
+)
+
+fastapi_users = FastAPIUsers[User, uuid.UUID](
+ get_user_manager,
+ [auth_backend],
+)
+
+current_user = fastapi_users.current_user()
+current_user_optional = fastapi_users.current_user(optional=True)
diff --git a/agliullov_daniyar_lab_3/user_service/auth/dependencies.py b/agliullov_daniyar_lab_3/user_service/auth/dependencies.py
new file mode 100644
index 0000000..43a5c71
--- /dev/null
+++ b/agliullov_daniyar_lab_3/user_service/auth/dependencies.py
@@ -0,0 +1,13 @@
+#
+from fastapi import Depends
+from fastapi_users_db_sqlalchemy import SQLAlchemyUserDatabase
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from .models import User
+from database import get_async_session
+
+__all__ = ["get_user_db"]
+
+
+async def get_user_db(session: AsyncSession = Depends(get_async_session)):
+ yield SQLAlchemyUserDatabase(session, User)
diff --git a/agliullov_daniyar_lab_3/user_service/auth/manager.py b/agliullov_daniyar_lab_3/user_service/auth/manager.py
new file mode 100644
index 0000000..7f5c649
--- /dev/null
+++ b/agliullov_daniyar_lab_3/user_service/auth/manager.py
@@ -0,0 +1,99 @@
+#
+import uuid
+from typing import Optional
+
+from fastapi import Depends, Request, Response, HTTPException
+from fastapi.encoders import jsonable_encoder
+from fastapi.security import OAuth2PasswordRequestForm
+from fastapi_users import BaseUserManager, UUIDIDMixin, exceptions, models, schemas
+from sqlalchemy.exc import IntegrityError
+from sqlalchemy.sql.functions import user
+
+from database import get_async_session
+from .dependencies import get_user_db
+from .models import *
+from config import SECRET_VERIFICATE_TOKEN as SECRET
+
+__all__ = ["UserManager", "get_user_manager"]
+
+from .schemas import UserUpdate
+
+
+class UserManager(UUIDIDMixin, BaseUserManager[User, uuid.UUID]):
+ reset_password_token_secret = SECRET
+ verification_token_secret = SECRET
+
+ async def on_after_register(self, user: User, request: Optional[Request] = None):
+ print(f"User {user.id} has registered.")
+
+ async def on_after_login(
+ self,
+ user: models.UP,
+ request: Optional[Request] = None,
+ response: Optional[Response] = None,
+ ) -> None:
+ if user.fcm_token is not None:
+ await self.update(UserUpdate(fcm_token=user.fcm_token), user, request=request)
+
+ async def create(
+ self,
+ user_create: schemas.UC,
+ safe: bool = False,
+ request: Optional[Request] = None,
+ ) -> models.UP:
+ await self.validate_password(user_create.password, user_create)
+
+ existing_user = await self.user_db.get_by_email(user_create.email)
+ if existing_user is not None:
+ raise exceptions.UserAlreadyExists()
+
+ user_dict = (
+ user_create.create_update_dict()
+ if safe
+ else user_create.create_update_dict_superuser()
+ )
+ password = user_dict.pop("password")
+ user_dict["hashed_password"] = self.password_helper.hash(password)
+
+ self._link_or_create_entity(user_dict, "group", Group)
+
+ try:
+ created_user = await self.user_db.create(user_dict)
+ except IntegrityError as e:
+ if 'group' not in e.statement:
+ raise e
+ detail = {
+ "msg": f"Некорректный идентификатор группы {user_dict.get('group_id')=} введенный при регистрации",
+ "inner_exception": jsonable_encoder(e)
+ }
+ raise HTTPException(status_code=403, detail=detail)
+ if hasattr(self.user_db, "session"):
+ await created_user.include_relationship_for_read(self.user_db.session)
+
+ await self.on_after_register(created_user, request)
+
+ return created_user
+
+ async def authenticate(
+ self, credentials: OAuth2PasswordRequestForm
+ ) -> Optional[models.UP]:
+ user_auth = await super().authenticate(credentials)
+ if user_auth is None:
+ return None
+ user_auth.fcm_token = credentials.fcm_token
+ return user_auth
+
+ @staticmethod
+ def _link_or_create_entity(user_dict: dict, name: str, EntityType):
+ entity = user_dict.get(name)
+ if isinstance(entity, (uuid.UUID, int)) or entity is None:
+ user_dict.pop(name, None)
+ user_dict[name + "_id"] = entity
+ else:
+ user_dict[name] = EntityType(**entity)
+
+
+
+
+async def get_user_manager(user_db=Depends(get_user_db)):
+ yield UserManager(user_db)
diff --git a/agliullov_daniyar_lab_3/user_service/auth/models.py b/agliullov_daniyar_lab_3/user_service/auth/models.py
new file mode 100644
index 0000000..fa57c56
--- /dev/null
+++ b/agliullov_daniyar_lab_3/user_service/auth/models.py
@@ -0,0 +1,52 @@
+import uuid
+from datetime import datetime
+
+from fastapi_users_db_sqlalchemy import SQLAlchemyBaseUserTable
+from sqlalchemy import Table, Column, Integer, String, TIMESTAMP, ForeignKey, JSON, Boolean, MetaData, DateTime, UUID, \
+ ARRAY, DECIMAL, DOUBLE_PRECISION
+from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy.orm import relationship
+
+from database import Base
+
+__all__ = ["Group", "User"]
+
+
+class Group(Base):
+ __tablename__ = "groups"
+
+ id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
+ name = Column(String, nullable=False)
+ comment = Column(String, nullable=True)
+
+ users = relationship("User", back_populates="group")
+
+
+class User(SQLAlchemyBaseUserTable[uuid.UUID], Base):
+ __tablename__ = 'users'
+
+ id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
+ email = Column(String, nullable=False)
+ name = Column(String, nullable=False)
+ surname = Column(String, nullable=False)
+ patronymic = Column(String, nullable=True)
+ creation_on = Column(TIMESTAMP, default=datetime.utcnow)
+ last_entry = Column(TIMESTAMP, nullable=True)
+ group_id = Column(ForeignKey(Group.id), nullable=True)
+ hashed_password = Column(String(length=1024), nullable=False)
+ is_active = Column(Boolean, default=True, nullable=False)
+ is_superuser = Column(Boolean, default=False, nullable=False)
+ is_verified = Column(Boolean, default=False, nullable=False)
+ token = Column(String, nullable=True)
+ fcm_token = Column(String, nullable=True) # Идентификатор устройства для уведомлений
+ hashes_rfids = Column(ARRAY(String), default=[])
+ face_embedding = Column(ARRAY(DOUBLE_PRECISION), nullable=True)
+
+ devices = relationship("Device", secondary="active_devices", back_populates="users", viewonly=True)
+ active_devices = relationship("ActiveDevice")
+ group = relationship(Group, uselist=False, back_populates="users")
+ notifications = relationship("Notification", back_populates="user")
+
+ async def include_relationship_for_read(self, session: AsyncSession):
+ await session.refresh(self, ["group"])
+ return self
diff --git a/agliullov_daniyar_lab_3/user_service/auth/router.py b/agliullov_daniyar_lab_3/user_service/auth/router.py
new file mode 100644
index 0000000..c382c89
--- /dev/null
+++ b/agliullov_daniyar_lab_3/user_service/auth/router.py
@@ -0,0 +1,25 @@
+from fastapi import APIRouter, Depends
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from database import get_async_session
+from . import User
+from .base_config import current_user
+from .schemas import UserRead
+
+__all__ = ["user_router"]
+
+user_router = APIRouter(prefix='/auth', tags=['Auth'])
+
+
+@user_router.get("/current_user")
+async def get_current_user(user=Depends(current_user), session=Depends(get_async_session)) -> UserRead:
+ return await user.include_relationship_for_read(session)
+
+
+@user_router.patch("/fcm_token/{fcm_token}")
+async def refresh_fcm_token(fcm_token: str | None = None,
+ user=Depends(current_user), session=Depends(get_async_session)) -> UserRead:
+ if fcm_token is not None:
+ user.fcm_token = fcm_token
+ await session.commit()
+ return user
diff --git a/agliullov_daniyar_lab_3/user_service/auth/schemas.py b/agliullov_daniyar_lab_3/user_service/auth/schemas.py
new file mode 100644
index 0000000..9e58cae
--- /dev/null
+++ b/agliullov_daniyar_lab_3/user_service/auth/schemas.py
@@ -0,0 +1,39 @@
+#
+import datetime
+import uuid
+from typing import Optional, Union
+
+from sqlalchemy import TIMESTAMP
+from fastapi_users import schemas
+from pydantic import EmailStr, BaseModel, Json
+
+__all__ = ["GroupRead", "GroupCreate", "UserRead", "UserCreate", "UserUpdate"]
+
+
+class GroupCreate(BaseModel):
+ name: str
+ comment: Optional[str]
+
+
+class GroupRead(GroupCreate, from_attributes=True):
+ id: uuid.UUID
+
+
+class UserRead(schemas.BaseUser[uuid.UUID], from_attributes=True):
+ name: str
+ surname: str
+ patronymic: Optional[str]
+ creation_on: datetime.datetime
+ fcm_token: Optional[str] = None
+ group: Optional[GroupRead] = None
+
+
+class UserCreate(schemas.BaseUserCreate):
+ name: str
+ surname: str
+ patronymic: Optional[str] = None
+ group: Union[None, uuid.UUID, GroupCreate] = None
+
+
+class UserUpdate(schemas.BaseUserUpdate):
+ fcm_token: Optional[str] = None
diff --git a/agliullov_daniyar_lab_3/user_service/auth/test_auth.py b/agliullov_daniyar_lab_3/user_service/auth/test_auth.py
new file mode 100644
index 0000000..6429cf3
--- /dev/null
+++ b/agliullov_daniyar_lab_3/user_service/auth/test_auth.py
@@ -0,0 +1,98 @@
+import httpx
+import pytest
+from fastapi import Depends
+
+from test_database import *
+from . import User
+from .schemas import UserCreate, GroupCreate, UserRead
+
+
+__all__ = ["client", "auth_client", "get_generate_user", "session"]
+
+
+def _add_password(user: User, password: str):
+ (user if isinstance(user, dict) else user.__dict__)["password"] = password
+ return user
+
+
+def get_generate_user(unique=False):
+ if not unique:
+ return UserCreate(
+ name="user",
+ surname="test",
+ group=GroupCreate(name="test", comment="test_comment"),
+ email="user@localhost.ru",
+ password=""
+ )
+
+
+@pytest_asyncio.fixture(scope="session")
+async def auth_client(app) -> AsyncClient:
+ async with AsyncClient(app=app, base_url="http://127.0.0.1:9000") as client:
+ user = get_generate_user()
+ await client.post("/auth/register", json=user.model_dump())
+ response = await client.post("/auth/login", data={
+ 'username': user.email,
+ 'password': user.password
+ })
+ cookie = list(dict(response.cookies).items())[-1]
+ client.headers["Cookie"] = "=".join(cookie)
+ yield client
+
+
+@pytest.mark.asyncio
+async def test_register_user(client, session) -> UserCreate:
+ user = get_generate_user()
+
+ response = await client.post("/auth/register", json=user.model_dump())
+ assert response.status_code == 201, response.json()
+
+ result = response.json()
+ db_user = await (await session.get(User, result["id"])).include_relationship_for_read(session)
+
+ assert db_user is not None
+ assert result.pop("id", None) is not None, response.json()
+
+ compare_model = UserCreate.model_validate(_add_password(result, user.password)).model_dump()
+ db_compare_model = UserCreate.model_validate(_add_password(db_user, user.password), from_attributes=True).model_dump()
+ assert compare_model == db_compare_model
+ assert compare_model == user.model_dump()
+ assert user.model_dump() == db_compare_model
+ response = await client.post("/auth/register", json=user.model_dump())
+ assert response.status_code == 400, response.json()
+ return user
+
+
+@pytest.mark.asyncio
+async def test_login_user(client) -> AsyncClient:
+ user = get_generate_user()
+ await client.post("/auth/register", json=user.model_dump())
+ response = await client.post("/auth/login", data={
+ 'username': user.email,
+ 'password': user.password + "0"
+ })
+ assert response.status_code == 400, response.json()
+ response = await client.post("/auth/login", data={
+ 'username': user.email,
+ 'password': user.password
+ })
+ assert response.status_code == 200 or response.status_code == 204, response.json()
+ return user
+
+
+@pytest.mark.asyncio
+async def test_authorization_and_logout(auth_client):
+ client: httpx.AsyncClient = auth_client
+ response = await client.get("auth/current_user")
+ assert response.status_code == 200
+ result_user = UserRead(**response.json())
+ sourse_user = get_generate_user()
+ assert result_user.email == sourse_user.email
+ assert result_user.name == sourse_user.name
+ response = await client.post("/auth/logout")
+ assert response.status_code == 200 or response.status_code == 204
+ assert not response.cookies
+ auth_cookies = client.headers.pop("Cookie")
+ response = await client.get("/auth/current_user")
+ assert response.status_code == 401
+ client.headers["Cookie"] = auth_cookies
diff --git a/agliullov_daniyar_lab_3/user_service/config.py b/agliullov_daniyar_lab_3/user_service/config.py
new file mode 100644
index 0000000..5984cac
--- /dev/null
+++ b/agliullov_daniyar_lab_3/user_service/config.py
@@ -0,0 +1,21 @@
+import os
+import logging
+
+from dotenv import load_dotenv
+
+load_dotenv()
+
+DB_HOST = os.environ.get("DB_HOST")
+DB_PORT = os.environ.get("DB_PORT")
+DB_NAME = os.environ.get("DB_NAME")
+DB_USER = os.environ.get("DB_USER")
+DB_PASS = os.environ.get("DB_PASS")
+
+
+SECRET_AUTH_TOKEN = os.environ.get("SECRET_AUTH_TOKEN")
+SECRET_VERIFICATE_TOKEN = os.environ.get("SECRET_VERIFICATE_TOKEN")
+
+DEVICE_START_COUNTER = os.environ.get("DEVICE_START_COUNTER")
+
+logging.getLogger('passlib').setLevel(logging.ERROR)
+
diff --git a/agliullov_daniyar_lab_3/user_service/database.py b/agliullov_daniyar_lab_3/user_service/database.py
new file mode 100644
index 0000000..fd324fc
--- /dev/null
+++ b/agliullov_daniyar_lab_3/user_service/database.py
@@ -0,0 +1,49 @@
+import time
+from sys import gettrace
+from typing import AsyncGenerator
+
+from fastapi import HTTPException
+from fastapi.encoders import jsonable_encoder
+from sqlalchemy import MetaData
+from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
+from sqlalchemy.ext.declarative import declarative_base
+from sqlalchemy.orm import sessionmaker
+
+from config import DB_HOST, DB_NAME, DB_PASS, DB_PORT, DB_USER
+
+from pydantic import BaseModel
+from sqlalchemy import Table, Column, Integer, String, TIMESTAMP, ForeignKey, Boolean, MetaData, PrimaryKeyConstraint
+from sqlalchemy.orm import DeclarativeBase
+
+
+class Base(DeclarativeBase):
+ def fill_from_model(self, model: BaseModel | dict):
+ """Заполняет все поля из словаря или модели, помечая объект 'грязным' для корректного обновления"""
+ if isinstance(model, BaseModel):
+ items = model.model_dump().items()
+ else:
+ items = model.items()
+
+ for key, value in items:
+ setattr(self, key, value)
+
+DATABASE_URL = f"postgresql+asyncpg://{DB_USER}:{DB_PASS}@{DB_HOST}:{DB_PORT}/{DB_NAME}"
+
+
+engine = create_async_engine(DATABASE_URL, echo=gettrace() is not None)
+async_session_maker = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
+
+
+async def get_async_session() -> AsyncGenerator[AsyncSession, None]:
+ async with async_session_maker() as session:
+ yield session
+
+
+async def begin_transactions() -> AsyncGenerator[AsyncSession, None]:
+ async with async_session_maker().begin() as transaction:
+ try:
+ yield transaction.session
+ except Exception as e:
+ await transaction.rollback()
+ raise e
+ await transaction.commit()
diff --git a/agliullov_daniyar_lab_3/user_service/main.py b/agliullov_daniyar_lab_3/user_service/main.py
new file mode 100644
index 0000000..55515db
--- /dev/null
+++ b/agliullov_daniyar_lab_3/user_service/main.py
@@ -0,0 +1,81 @@
+import datetime
+import time
+import json
+
+# import firebase_admin
+import uvicorn
+from fastapi import FastAPI, Depends, Body
+from fastapi.staticfiles import StaticFiles
+from fastapi.encoders import jsonable_encoder
+from pydantic import BaseModel
+from sqlalchemy.exc import DBAPIError
+from starlette.requests import Request
+from starlette.responses import JSONResponse
+
+from auth import *
+
+app = FastAPI(
+ title="Smart lock",
+ root_path="/users"
+)
+
+@app.get("/docs", include_in_schema=False)
+async def custom_swagger_ui_html(req: Request):
+ root_path = req.scope.get("root_path", "").rstrip("/")
+ openapi_url = root_path + app.openapi_url
+ return get_swagger_ui_html(
+ openapi_url=openapi_url,
+ title="API",
+ )
+
+app.include_router(
+ fastapi_users.get_auth_router(auth_backend),
+ prefix="/auth",
+ tags=["Auth"],
+)
+
+app.include_router(
+ fastapi_users.get_register_router(UserRead, UserCreate),
+ prefix="/auth",
+ tags=["Auth"],
+)
+
+# firebase
+# cred = credentials.Certificate("../serviceAccountKey.json")
+# try:
+# fire_app = firebase_admin.initialize_app(cred)
+# except ValueError:
+# pass
+
+
+class Message(BaseModel):
+ msg: str
+
+
+@app.post("/msg")
+def debug_message_from_user(msg: Message = Body(), user: User = Depends(current_user_optional)):
+ print(datetime.datetime.now(), end=' ')
+ if user is not None:
+ print(user.__dict__)
+ print(msg.msg)
+
+
+@app.middleware("http")
+async def add_process_time_header(request: Request, call_next):
+ start_time = time.perf_counter()
+ response = await call_next(request)
+ process_time = time.perf_counter() - start_time
+ print(f"Finished in {process_time:.3f}")
+ response.headers["X-Process-Time"] = str(process_time)
+ return response
+
+
+@app.exception_handler(DBAPIError)
+def authjwt_exception_handler(request: Request, exc: DBAPIError):
+ return JSONResponse(
+ status_code=500,
+ content={"detail": jsonable_encoder(exc) | {"args": jsonable_encoder(exc.args)}}
+ )
+
+if __name__ == '__main__':
+ uvicorn.run(app=app, host="0.0.0.0", port=8000)
\ No newline at end of file
diff --git a/agliullov_daniyar_lab_3/user_service/requirements.txt b/agliullov_daniyar_lab_3/user_service/requirements.txt
new file mode 100644
index 0000000..4f588b8
--- /dev/null
+++ b/agliullov_daniyar_lab_3/user_service/requirements.txt
@@ -0,0 +1,7 @@
+fastapi
+uvicorn
+fastapi-users
+sqlalchemy
+fastapi_users_db_sqlalchemy
+python-dotenv
+asyncpg
\ No newline at end of file