mirror of
https://gitlab.com/MoonTestUse1/AdministrationItDepartmens.git
synced 2025-08-14 00:25:46 +02:00
Testing workable
This commit is contained in:
@@ -8,34 +8,22 @@ stages:
|
||||
variables:
|
||||
SECRET_KEY: "your-super-secret-key-123"
|
||||
|
||||
test-backend:
|
||||
test:
|
||||
image: python:3.11
|
||||
stage: test
|
||||
before_script:
|
||||
- python -V
|
||||
- python -m pip install --upgrade pip
|
||||
- pip install pytest pytest-cov
|
||||
- pip install pytest
|
||||
- pip install -r backend/requirements.txt
|
||||
script:
|
||||
- cd backend
|
||||
- python -m pytest -v tests/test_health.py
|
||||
- python -m pytest -v
|
||||
only:
|
||||
- main
|
||||
- Testing
|
||||
|
||||
test-frontend:
|
||||
image: node:18
|
||||
stage: test
|
||||
before_script:
|
||||
- cd frontend
|
||||
- npm install
|
||||
script:
|
||||
- npm run test
|
||||
only:
|
||||
- main
|
||||
- Testing
|
||||
|
||||
build-backend:
|
||||
build_backend:
|
||||
stage: build
|
||||
image: docker:latest
|
||||
variables:
|
||||
@@ -57,7 +45,7 @@ build-backend:
|
||||
only:
|
||||
- main
|
||||
|
||||
build-frontend:
|
||||
build_frontend:
|
||||
stage: build
|
||||
image: docker:latest
|
||||
variables:
|
||||
|
@@ -1,4 +1,4 @@
|
||||
# 22Administration Projectss
|
||||
# Administration Project
|
||||
|
||||
[](https://gitlab.com/mysite7215201/Administration/-/commits/main)
|
||||
|
||||
|
@@ -1,44 +1,36 @@
|
||||
"""Configuration module"""
|
||||
import os
|
||||
from pydantic_settings import BaseSettings
|
||||
"""Settings configuration"""
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""Application settings"""
|
||||
PROJECT_NAME: str = "Employee Request System"
|
||||
PROJECT_NAME: str = "Support Service"
|
||||
VERSION: str = "1.0.0"
|
||||
API_V1_STR: str = "/api"
|
||||
|
||||
# Database
|
||||
POSTGRES_USER: str = os.getenv("POSTGRES_USER", "postgres")
|
||||
POSTGRES_PASSWORD: str = os.getenv("POSTGRES_PASSWORD", "postgres")
|
||||
POSTGRES_HOST: str = os.getenv("POSTGRES_HOST", "localhost")
|
||||
POSTGRES_PORT: str = os.getenv("POSTGRES_PORT", "5432")
|
||||
POSTGRES_DB: str = os.getenv("POSTGRES_DB", "app")
|
||||
DATABASE_URL: str = "postgresql://postgres:postgres123@db:5432/support_db"
|
||||
|
||||
# JWT
|
||||
SECRET_KEY: str = os.getenv("SECRET_KEY", "your-secret-key-super-secret")
|
||||
SECRET_KEY: str = "your-secret-key"
|
||||
ALGORITHM: str = "HS256"
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
|
||||
|
||||
# Redis
|
||||
REDIS_HOST: str = os.getenv("REDIS_HOST", "localhost")
|
||||
REDIS_PORT: int = int(os.getenv("REDIS_PORT", "6379"))
|
||||
REDIS_DB: int = int(os.getenv("REDIS_DB", "0"))
|
||||
REDIS_HOST: str = "redis"
|
||||
REDIS_PORT: int = 6379
|
||||
|
||||
# Telegram
|
||||
TELEGRAM_BOT_TOKEN: str = os.getenv("TELEGRAM_BOT_TOKEN", "")
|
||||
TELEGRAM_CHAT_ID: str = os.getenv("TELEGRAM_CHAT_ID", "")
|
||||
|
||||
model_config = {
|
||||
"case_sensitive": True,
|
||||
"env_file": ".env",
|
||||
"extra": "allow"
|
||||
}
|
||||
# Admin
|
||||
ADMIN_USERNAME: str = "admin"
|
||||
ADMIN_PASSWORD: str = "admin123"
|
||||
|
||||
def get_database_url(self) -> str:
|
||||
"""Get database URL"""
|
||||
return os.getenv(
|
||||
"DATABASE_URL",
|
||||
f"postgresql://{self.POSTGRES_USER}:{self.POSTGRES_PASSWORD}@{self.POSTGRES_HOST}:{self.POSTGRES_PORT}/{self.POSTGRES_DB}"
|
||||
)
|
||||
# Telegram
|
||||
TELEGRAM_BOT_TOKEN: str = "your-bot-token"
|
||||
TELEGRAM_CHAT_ID: str = "your-chat-id"
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env",
|
||||
env_file_encoding="utf-8",
|
||||
case_sensitive=True
|
||||
)
|
||||
|
||||
settings = Settings()
|
@@ -1,6 +1,6 @@
|
||||
"""Employee CRUD operations"""
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List, Optional
|
||||
from typing import Optional, List
|
||||
from ..models.employee import Employee
|
||||
from ..schemas.employee import EmployeeCreate, EmployeeUpdate
|
||||
from ..utils.loggers import auth_logger
|
||||
@@ -13,42 +13,58 @@ def get_employee(db: Session, employee_id: int) -> Optional[Employee]:
|
||||
"""Get employee by ID"""
|
||||
return db.query(Employee).filter(Employee.id == employee_id).first()
|
||||
|
||||
def get_employee_by_credentials(db: Session, first_name: str, last_name: str) -> Optional[Employee]:
|
||||
"""Get employee by first name and last name"""
|
||||
return db.query(Employee).filter(
|
||||
Employee.first_name == first_name,
|
||||
Employee.last_name == last_name
|
||||
).first()
|
||||
def get_employee_by_email(db: Session, email: str) -> Optional[Employee]:
|
||||
"""Get employee by email"""
|
||||
return db.query(Employee).filter(Employee.email == email).first()
|
||||
|
||||
def create_employee(db: Session, employee: EmployeeCreate, hashed_password: str) -> Employee:
|
||||
"""Create new employee"""
|
||||
db_employee = Employee(
|
||||
first_name=employee.first_name,
|
||||
last_name=employee.last_name,
|
||||
department=employee.department,
|
||||
office=employee.office,
|
||||
hashed_password=hashed_password,
|
||||
is_admin=employee.is_admin
|
||||
)
|
||||
db.add(db_employee)
|
||||
db.commit()
|
||||
db.refresh(db_employee)
|
||||
return db_employee
|
||||
|
||||
def update_employee(db: Session, employee_id: int, employee: EmployeeUpdate) -> Optional[Employee]:
|
||||
"""Update employee data"""
|
||||
db_employee = get_employee(db, employee_id)
|
||||
if db_employee:
|
||||
for key, value in employee.dict(exclude_unset=True).items():
|
||||
setattr(db_employee, key, value)
|
||||
try:
|
||||
db_employee = Employee(
|
||||
email=employee.email,
|
||||
full_name=employee.full_name,
|
||||
hashed_password=hashed_password,
|
||||
is_active=employee.is_active,
|
||||
is_admin=employee.is_admin,
|
||||
department=employee.department
|
||||
)
|
||||
db.add(db_employee)
|
||||
db.commit()
|
||||
db.refresh(db_employee)
|
||||
return db_employee
|
||||
return db_employee
|
||||
except Exception as e:
|
||||
auth_logger.error(f"Error creating employee: {e}")
|
||||
db.rollback()
|
||||
raise
|
||||
|
||||
def update_employee(db: Session, employee_id: int, employee: EmployeeUpdate) -> Optional[Employee]:
|
||||
"""Update employee"""
|
||||
db_employee = get_employee(db, employee_id)
|
||||
if not db_employee:
|
||||
return None
|
||||
|
||||
for field, value in employee.model_dump(exclude_unset=True).items():
|
||||
setattr(db_employee, field, value)
|
||||
|
||||
try:
|
||||
db.commit()
|
||||
db.refresh(db_employee)
|
||||
return db_employee
|
||||
except Exception as e:
|
||||
auth_logger.error(f"Error updating employee: {e}")
|
||||
db.rollback()
|
||||
raise
|
||||
|
||||
def delete_employee(db: Session, employee_id: int) -> Optional[Employee]:
|
||||
"""Delete employee"""
|
||||
db_employee = get_employee(db, employee_id)
|
||||
if db_employee:
|
||||
db.delete(db_employee)
|
||||
db.commit()
|
||||
return db_employee
|
||||
try:
|
||||
db.delete(db_employee)
|
||||
db.commit()
|
||||
return db_employee
|
||||
except Exception as e:
|
||||
auth_logger.error(f"Error deleting employee: {e}")
|
||||
db.rollback()
|
||||
raise
|
||||
return None
|
@@ -8,7 +8,13 @@ from . import employees
|
||||
|
||||
def create_request(db: Session, request: RequestCreate, employee_id: int) -> Request:
|
||||
"""Create new request"""
|
||||
# Получаем данные сотрудника
|
||||
employee = employees.get_employee(db, employee_id)
|
||||
if not employee:
|
||||
raise ValueError("Employee not found")
|
||||
|
||||
db_request = Request(
|
||||
department=employee.department, # Берем отдел из данных сотрудника
|
||||
request_type=request.request_type,
|
||||
description=request.description,
|
||||
priority=request.priority,
|
||||
@@ -24,6 +30,27 @@ def get_request(db: Session, request_id: int) -> Optional[Request]:
|
||||
"""Get request by ID"""
|
||||
return db.query(Request).filter(Request.id == request_id).first()
|
||||
|
||||
def get_request_details(db: Session, request_id: int) -> Optional[Dict]:
|
||||
"""Get detailed request information including employee data"""
|
||||
request = get_request(db, request_id)
|
||||
if not request:
|
||||
return None
|
||||
|
||||
employee = employees.get_employee(db, request.employee_id)
|
||||
if not employee:
|
||||
return None
|
||||
|
||||
return {
|
||||
"id": request.id,
|
||||
"request_type": request.request_type,
|
||||
"description": request.description,
|
||||
"priority": request.priority,
|
||||
"status": request.status,
|
||||
"department": request.department,
|
||||
"created_at": request.created_at.isoformat(),
|
||||
"employee_full_name": employee.full_name
|
||||
}
|
||||
|
||||
def get_employee_requests(db: Session, employee_id: int) -> list[Request]:
|
||||
"""Get employee's requests"""
|
||||
return db.query(Request).filter(Request.employee_id == employee_id).all()
|
||||
@@ -53,6 +80,12 @@ def get_statistics(db: Session) -> Dict:
|
||||
func.count(Request.id)
|
||||
).group_by(Request.status).all()
|
||||
)
|
||||
|
||||
# Добавляем статусы с нулевым количеством
|
||||
for status in RequestStatus:
|
||||
if status not in by_status:
|
||||
by_status[status] = 0
|
||||
|
||||
return {
|
||||
"total": total,
|
||||
"by_status": by_status
|
||||
|
@@ -3,9 +3,9 @@ from sqlalchemy.orm import Session
|
||||
from typing import Optional
|
||||
from ..models.token import Token
|
||||
|
||||
def create_token(db: Session, token: str, employee_id: int) -> Token:
|
||||
def create_token(db: Session, token: str, user_id: int) -> Token:
|
||||
"""Create new token"""
|
||||
db_token = Token(token=token, employee_id=employee_id)
|
||||
db_token = Token(token=token, user_id=user_id)
|
||||
db.add(db_token)
|
||||
db.commit()
|
||||
db.refresh(db_token)
|
||||
@@ -24,8 +24,8 @@ def delete_token(db: Session, token: str) -> bool:
|
||||
return True
|
||||
return False
|
||||
|
||||
def delete_employee_tokens(db: Session, employee_id: int) -> bool:
|
||||
"""Delete all tokens for an employee"""
|
||||
db.query(Token).filter(Token.employee_id == employee_id).delete()
|
||||
def delete_user_tokens(db: Session, user_id: int) -> bool:
|
||||
"""Delete all tokens for a user"""
|
||||
db.query(Token).filter(Token.user_id == user_id).delete()
|
||||
db.commit()
|
||||
return True
|
@@ -1,34 +1,22 @@
|
||||
"""Database module"""
|
||||
"""Database configuration"""
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker, declarative_base
|
||||
from typing import Generator
|
||||
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from .core.config import settings
|
||||
from .db.base import Base
|
||||
|
||||
# Create base class for models
|
||||
Base = declarative_base()
|
||||
# Для создания таблиц импортируем модели
|
||||
from .models.employee import Employee # noqa
|
||||
from .models.request import Request # noqa
|
||||
from .models.token import Token # noqa
|
||||
|
||||
# Create database engine
|
||||
engine = create_engine(
|
||||
settings.get_database_url(),
|
||||
pool_pre_ping=True,
|
||||
pool_size=5,
|
||||
max_overflow=10
|
||||
)
|
||||
SQLALCHEMY_DATABASE_URL = settings.DATABASE_URL
|
||||
|
||||
# Create session factory
|
||||
engine = create_engine(SQLALCHEMY_DATABASE_URL)
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
|
||||
def get_db() -> Generator:
|
||||
"""Get database session"""
|
||||
def get_db():
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
def init_db() -> None:
|
||||
"""Initialize database"""
|
||||
# Import models here to avoid circular imports
|
||||
from .db.base import Base # noqa: F811
|
||||
Base.metadata.create_all(bind=engine)
|
||||
db.close()
|
@@ -1,8 +1,8 @@
|
||||
"""Import all models for Alembic autogenerate support"""
|
||||
from app.db.base_class import Base # noqa
|
||||
from app.models.employee import Employee # noqa
|
||||
from app.models.request import Request # noqa
|
||||
from app.models.token import Token # noqa
|
||||
from app.db.base_class import Base
|
||||
from app.models.employee import Employee
|
||||
from app.models.request import Request
|
||||
from app.models.token import Token
|
||||
|
||||
# Import all models for Alembic autogenerate support
|
||||
# Импортируем все модели, чтобы Alembic мог их обнаружить
|
||||
__all__ = ["Base", "Employee", "Request", "Token"]
|
@@ -1,34 +1,40 @@
|
||||
"""Main application module"""
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from .core.config import settings
|
||||
from .routers import auth, employees, requests, admin
|
||||
from .db.base import Base
|
||||
from .database import engine
|
||||
from . import models
|
||||
from .routers import admin, employees, requests, auth, statistics
|
||||
|
||||
app = FastAPI(
|
||||
title=settings.PROJECT_NAME,
|
||||
openapi_url=f"{settings.API_V1_STR}/openapi.json"
|
||||
# Включаем автоматическое перенаправление со слэшем
|
||||
redirect_slashes=True,
|
||||
# Добавляем описание API
|
||||
title="Support System API",
|
||||
description="API для системы поддержки",
|
||||
version="1.0.0"
|
||||
)
|
||||
|
||||
# Настройка CORS
|
||||
# CORS configuration
|
||||
origins = [
|
||||
"http://localhost",
|
||||
"http://localhost:8080",
|
||||
"http://localhost:5173",
|
||||
"http://127.0.0.1:5173",
|
||||
"http://127.0.0.1:8080",
|
||||
"http://185.139.70.62", # Добавляем ваш production домен
|
||||
]
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_origins=origins,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
expose_headers=["*"]
|
||||
)
|
||||
|
||||
# Health check endpoint
|
||||
@app.get("/api/health")
|
||||
async def health_check():
|
||||
"""Health check endpoint"""
|
||||
return {"status": "ok"}
|
||||
|
||||
# Подключаем роутеры
|
||||
app.include_router(auth.router, prefix=settings.API_V1_STR, tags=["auth"])
|
||||
app.include_router(employees.router, prefix=settings.API_V1_STR, tags=["employees"])
|
||||
app.include_router(requests.router, prefix=settings.API_V1_STR, tags=["requests"])
|
||||
app.include_router(admin.router, prefix=settings.API_V1_STR, tags=["admin"])
|
||||
# Include routers
|
||||
app.include_router(auth.router, prefix="/api/auth", tags=["auth"])
|
||||
app.include_router(employees.router, prefix="/api/employees", tags=["employees"])
|
||||
app.include_router(requests.router, prefix="/api/requests", tags=["requests"])
|
||||
app.include_router(admin.router, prefix="/api/admin", tags=["admin"])
|
||||
app.include_router(statistics.router, prefix="/api/statistics", tags=["statistics"])
|
@@ -1,6 +1,5 @@
|
||||
"""Models package"""
|
||||
"""Models initialization"""
|
||||
from .employee import Employee
|
||||
from .request import Request
|
||||
from .token import Token
|
||||
|
||||
__all__ = ["Employee", "Request", "Token"]
|
||||
__all__ = ['Employee', 'Request']
|
@@ -1,4 +1,7 @@
|
||||
"""Base model class"""
|
||||
"""Base models and imports"""
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Enum
|
||||
from sqlalchemy.sql import func
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
Base = declarative_base()
|
@@ -1,22 +1,20 @@
|
||||
"""Employee model"""
|
||||
from sqlalchemy import Column, Integer, String, Boolean
|
||||
from sqlalchemy import Column, Integer, String, DateTime, Boolean
|
||||
from sqlalchemy.sql import func
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from ..db.base_class import Base
|
||||
from app.db.base_class import Base
|
||||
|
||||
class Employee(Base):
|
||||
"""Employee model"""
|
||||
__tablename__ = "employees"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
first_name = Column(String, nullable=False)
|
||||
last_name = Column(String, nullable=False)
|
||||
department = Column(String, nullable=False)
|
||||
office = Column(String, nullable=False)
|
||||
email = Column(String, unique=True, index=True, nullable=False)
|
||||
full_name = Column(String, nullable=False)
|
||||
hashed_password = Column(String, nullable=False)
|
||||
is_active = Column(Boolean, default=True)
|
||||
is_admin = Column(Boolean, default=False)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
department = Column(String, nullable=True)
|
||||
|
||||
# Отношения
|
||||
requests = relationship("Request", back_populates="employee", cascade="all, delete-orphan")
|
||||
tokens = relationship("Token", back_populates="employee", cascade="all, delete-orphan")
|
||||
# Определяем отношение к Request
|
||||
requests = relationship("Request", back_populates="employee", cascade="all, delete-orphan")
|
@@ -1,41 +1,32 @@
|
||||
"""Request model"""
|
||||
from enum import Enum
|
||||
from sqlalchemy import Column, Integer, String, Enum as SQLEnum, ForeignKey, DateTime
|
||||
from sqlalchemy import Column, Integer, String, ForeignKey, DateTime
|
||||
from sqlalchemy.sql import func
|
||||
from sqlalchemy.orm import relationship
|
||||
from datetime import datetime
|
||||
|
||||
from ..database import Base
|
||||
from app.db.base_class import Base
|
||||
|
||||
class RequestStatus(str, Enum):
|
||||
"""Request status enum"""
|
||||
NEW = "new"
|
||||
IN_PROGRESS = "in_progress"
|
||||
COMPLETED = "completed"
|
||||
REJECTED = "rejected"
|
||||
|
||||
class RequestPriority(str, Enum):
|
||||
"""Request priority enum"""
|
||||
LOW = "low"
|
||||
MEDIUM = "medium"
|
||||
HIGH = "high"
|
||||
|
||||
class RequestType(str, Enum):
|
||||
"""Request type enum"""
|
||||
VACATION = "vacation"
|
||||
SICK_LEAVE = "sick_leave"
|
||||
EQUIPMENT = "equipment"
|
||||
OTHER = "other"
|
||||
|
||||
class Request(Base):
|
||||
"""Request model"""
|
||||
__tablename__ = "requests"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
request_type = Column(SQLEnum(RequestType), nullable=False)
|
||||
description = Column(String, nullable=False)
|
||||
priority = Column(SQLEnum(RequestPriority), nullable=False, default=RequestPriority.MEDIUM)
|
||||
status = Column(SQLEnum(RequestStatus), nullable=False, default=RequestStatus.NEW)
|
||||
created_at = Column(DateTime, nullable=False, default=datetime.utcnow)
|
||||
employee_id = Column(Integer, ForeignKey("employees.id"), nullable=False)
|
||||
department = Column(String, index=True)
|
||||
request_type = Column(String, index=True)
|
||||
description = Column(String)
|
||||
priority = Column(String)
|
||||
status = Column(String, default=RequestStatus.NEW)
|
||||
employee_id = Column(Integer, ForeignKey("employees.id"))
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
# Определяем отношение к Employee
|
||||
employee = relationship("Employee", back_populates="requests")
|
||||
|
@@ -1,16 +1,12 @@
|
||||
"""Token model"""
|
||||
from sqlalchemy import Column, Integer, String, ForeignKey
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from ..db.base_class import Base
|
||||
from sqlalchemy import Column, Integer, String, DateTime
|
||||
from sqlalchemy.sql import func
|
||||
from app.db.base_class import Base
|
||||
|
||||
class Token(Base):
|
||||
"""Token model"""
|
||||
__tablename__ = "tokens"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
token = Column(String, unique=True, index=True, nullable=False)
|
||||
employee_id = Column(Integer, ForeignKey("employees.id", ondelete="CASCADE"), nullable=False)
|
||||
|
||||
# Отношения
|
||||
employee = relationship("Employee", back_populates="tokens")
|
||||
token = Column(String, unique=True, index=True)
|
||||
user_id = Column(Integer, index=True) # ID сотрудника из таблицы employees
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
@@ -6,7 +6,7 @@ from typing import Optional
|
||||
|
||||
from ..database import get_db
|
||||
from ..crud import employees
|
||||
from ..schemas.auth import Token, LoginCredentials
|
||||
from ..schemas.auth import Token
|
||||
from ..utils.auth import verify_password
|
||||
from ..utils.jwt import create_and_save_token
|
||||
|
||||
@@ -19,18 +19,8 @@ async def login_for_access_token(
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Авторизация сотрудника"""
|
||||
# Разделяем username на имя и фамилию
|
||||
try:
|
||||
first_name, last_name = form_data.username.split()
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Username should be in format: 'First Last'",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
# Проверяем учетные данные сотрудника
|
||||
employee = employees.get_employee_by_credentials(db, first_name, last_name)
|
||||
employee = employees.get_employee_by_email(db, form_data.username)
|
||||
if not employee or not verify_password(form_data.password, employee.hashed_password):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
@@ -52,18 +42,8 @@ async def admin_login(
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Авторизация администратора"""
|
||||
# Разделяем username на имя и фамилию
|
||||
try:
|
||||
first_name, last_name = form_data.username.split()
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Username should be in format: 'First Last'",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
# Проверяем учетные данные администратора
|
||||
employee = employees.get_employee_by_credentials(db, first_name, last_name)
|
||||
employee = employees.get_employee_by_email(db, form_data.username)
|
||||
if not employee or not employee.is_admin or not verify_password(form_data.password, employee.hashed_password):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
|
@@ -1,10 +1,9 @@
|
||||
"""Schemas package"""
|
||||
from .employee import Employee, EmployeeCreate, EmployeeUpdate
|
||||
from .request import Request, RequestCreate, RequestUpdate
|
||||
from .auth import Token, TokenData, LoginCredentials
|
||||
from .auth import Token, TokenData
|
||||
|
||||
__all__ = [
|
||||
'Employee', 'EmployeeCreate', 'EmployeeUpdate',
|
||||
'Request', 'RequestCreate', 'RequestUpdate',
|
||||
'Token', 'TokenData', 'LoginCredentials'
|
||||
'Token', 'TokenData'
|
||||
]
|
@@ -1,18 +1,32 @@
|
||||
"""Authentication schemas"""
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
class AdminLogin(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
class EmployeeLogin(BaseModel):
|
||||
last_name: str
|
||||
password: str
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
class EmployeeResponse(BaseModel):
|
||||
id: int
|
||||
first_name: str
|
||||
last_name: str
|
||||
department: str
|
||||
office: str
|
||||
access_token: str
|
||||
|
||||
class Token(BaseModel):
|
||||
"""Token schema"""
|
||||
access_token: str
|
||||
token_type: str
|
||||
|
||||
class TokenData(BaseModel):
|
||||
"""Token data schema"""
|
||||
employee_id: Optional[int] = None
|
||||
employee_id: int | None = None
|
||||
is_admin: bool = False
|
||||
|
||||
class LoginCredentials(BaseModel):
|
||||
"""Login credentials schema"""
|
||||
username: str # В формате "Имя Фамилия"
|
||||
password: str
|
||||
model_config = ConfigDict(from_attributes=True)
|
@@ -1,33 +1,32 @@
|
||||
"""Employee schemas"""
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
class EmployeeBase(BaseModel):
|
||||
"""Base employee schema"""
|
||||
first_name: str
|
||||
last_name: str
|
||||
email: str
|
||||
full_name: str
|
||||
department: str
|
||||
office: str
|
||||
is_active: bool = True
|
||||
is_admin: bool = False
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
class EmployeeCreate(EmployeeBase):
|
||||
"""Employee creation schema"""
|
||||
password: str
|
||||
|
||||
class EmployeeUpdate(BaseModel):
|
||||
"""Employee update schema"""
|
||||
first_name: Optional[str] = None
|
||||
last_name: Optional[str] = None
|
||||
email: Optional[str] = None
|
||||
full_name: Optional[str] = None
|
||||
department: Optional[str] = None
|
||||
office: Optional[str] = None
|
||||
password: Optional[str] = None
|
||||
is_active: Optional[bool] = None
|
||||
is_admin: Optional[bool] = None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
class Employee(EmployeeBase):
|
||||
"""Employee schema"""
|
||||
id: int
|
||||
is_active: bool
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
"""Pydantic config"""
|
||||
from_attributes = True
|
||||
model_config = ConfigDict(from_attributes=True)
|
@@ -1,30 +1,29 @@
|
||||
"""Request schemas"""
|
||||
from pydantic import BaseModel
|
||||
from datetime import datetime
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from typing import Optional
|
||||
from ..models.request import RequestStatus, RequestPriority, RequestType
|
||||
from datetime import datetime
|
||||
from ..models.request import RequestStatus, RequestPriority
|
||||
|
||||
class RequestBase(BaseModel):
|
||||
"""Base request schema"""
|
||||
request_type: RequestType
|
||||
request_type: str
|
||||
description: str
|
||||
priority: RequestPriority = RequestPriority.MEDIUM
|
||||
priority: RequestPriority
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
class RequestCreate(RequestBase):
|
||||
"""Request create schema"""
|
||||
pass
|
||||
|
||||
class RequestUpdate(BaseModel):
|
||||
status: RequestStatus
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
class Request(RequestBase):
|
||||
"""Request schema"""
|
||||
id: int
|
||||
status: RequestStatus
|
||||
created_at: datetime
|
||||
employee_id: int
|
||||
department: Optional[str] = None
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
"""Pydantic config"""
|
||||
from_attributes = True
|
||||
|
||||
class RequestUpdate(BaseModel):
|
||||
"""Request update schema"""
|
||||
status: RequestStatus
|
||||
model_config = ConfigDict(from_attributes=True)
|
@@ -8,6 +8,6 @@ class Token(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
class TokenData(BaseModel):
|
||||
employee_id: int | None = None
|
||||
user_id: int | None = None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
@@ -3,10 +3,10 @@ from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
from passlib.context import CryptContext
|
||||
from sqlalchemy.orm import Session
|
||||
import re
|
||||
|
||||
from .jwt import verify_token
|
||||
from ..database import get_db
|
||||
from ..crud import employees
|
||||
from ..models.employee import Employee
|
||||
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
@@ -34,20 +34,16 @@ def get_current_admin(
|
||||
|
||||
try:
|
||||
token = credentials.credentials
|
||||
token_data = verify_token(token, db)
|
||||
if not token_data:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid authentication credentials",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
payload = verify_token(token, db)
|
||||
employee_id = int(payload.get("sub"))
|
||||
|
||||
# Проверяем, что это админ
|
||||
employee = employees.get_employee(db, token_data.employee_id)
|
||||
# Получаем сотрудника из БД
|
||||
from ..crud.employees import get_employee
|
||||
employee = get_employee(db, employee_id)
|
||||
if not employee or not employee.is_admin:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Not enough permissions",
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Could not validate credentials",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
@@ -55,7 +51,7 @@ def get_current_admin(
|
||||
except Exception:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid authentication credentials",
|
||||
detail="Could not validate credentials",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
@@ -73,20 +69,16 @@ def get_current_employee(
|
||||
|
||||
try:
|
||||
token = credentials.credentials
|
||||
token_data = verify_token(token, db)
|
||||
if not token_data:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid authentication credentials",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
payload = verify_token(token, db)
|
||||
employee_id = int(payload.get("sub"))
|
||||
|
||||
# Проверяем существование сотрудника
|
||||
employee = employees.get_employee(db, token_data.employee_id)
|
||||
# Получаем сотрудника из БД
|
||||
from ..crud.employees import get_employee
|
||||
employee = get_employee(db, employee_id)
|
||||
if not employee:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Employee not found",
|
||||
detail="Could not validate credentials",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
@@ -94,6 +86,6 @@ def get_current_employee(
|
||||
except Exception:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid authentication credentials",
|
||||
detail="Could not validate credentials",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
@@ -1,67 +1,82 @@
|
||||
"""JWT utilities"""
|
||||
from datetime import datetime, timedelta
|
||||
from jose import JWTError, jwt
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import Optional
|
||||
from jose import JWTError, jwt
|
||||
from fastapi import HTTPException, status
|
||||
from redis import Redis
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..core.config import settings
|
||||
from ..core.test_config import test_settings
|
||||
from ..models.token import Token
|
||||
from ..schemas.auth import TokenData
|
||||
from ..crud.employees import get_employee
|
||||
|
||||
def get_settings():
|
||||
"""Get settings based on environment"""
|
||||
return test_settings if test_settings.TESTING else settings
|
||||
redis = Redis(
|
||||
host=settings.REDIS_HOST,
|
||||
port=settings.REDIS_PORT,
|
||||
decode_responses=True
|
||||
)
|
||||
|
||||
def create_access_token(data: dict) -> str:
|
||||
"""Create access token"""
|
||||
to_encode = data.copy()
|
||||
config = get_settings()
|
||||
expire = datetime.utcnow() + timedelta(minutes=config.ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
expire = datetime.utcnow() + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
to_encode.update({"exp": expire})
|
||||
encoded_jwt = jwt.encode(to_encode, config.SECRET_KEY, algorithm=config.ALGORITHM)
|
||||
encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
|
||||
return encoded_jwt
|
||||
|
||||
def verify_token(token: str) -> Optional[int]:
|
||||
"""Verify token and return employee_id"""
|
||||
def verify_token(token: str, db: Session) -> dict:
|
||||
try:
|
||||
config = get_settings()
|
||||
payload = jwt.decode(token, config.SECRET_KEY, algorithms=[config.ALGORITHM])
|
||||
employee_id = int(payload.get("sub"))
|
||||
if employee_id is None:
|
||||
return None
|
||||
return employee_id
|
||||
except (JWTError, ValueError):
|
||||
return None
|
||||
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
|
||||
user_id: int = payload.get("sub")
|
||||
if user_id is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Could not validate credentials",
|
||||
)
|
||||
|
||||
# Проверяем токен в Redis
|
||||
if not redis.get(f"token:{token}"):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Could not validate credentials",
|
||||
)
|
||||
|
||||
return payload
|
||||
except JWTError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Could not validate credentials",
|
||||
)
|
||||
|
||||
def verify_token_in_db(token: str, db: Session) -> Optional[TokenData]:
|
||||
"""Verify token in database"""
|
||||
employee_id = verify_token(token)
|
||||
if employee_id is None:
|
||||
return None
|
||||
|
||||
# Проверяем, что токен существует в базе
|
||||
db_token = db.query(Token).filter(Token.token == token).first()
|
||||
if not db_token:
|
||||
return None
|
||||
|
||||
return TokenData(employee_id=employee_id)
|
||||
|
||||
def create_and_save_token(employee_id: int, db: Session) -> str:
|
||||
"""Create and save token"""
|
||||
# Создаем токен
|
||||
access_token = create_access_token({"sub": str(employee_id)})
|
||||
def create_and_save_token(user_id: int, db: Session) -> str:
|
||||
# Создаем JWT токен
|
||||
access_token = create_access_token({"sub": str(user_id)})
|
||||
|
||||
# Удаляем старые токены пользователя
|
||||
db.query(Token).filter(Token.employee_id == employee_id).delete()
|
||||
|
||||
# Сохраняем новый токен в базу
|
||||
# Сохраняем в БД
|
||||
db_token = Token(
|
||||
token=access_token,
|
||||
employee_id=employee_id
|
||||
user_id=user_id
|
||||
)
|
||||
db.add(db_token)
|
||||
db.commit()
|
||||
db.refresh(db_token)
|
||||
|
||||
return access_token
|
||||
# Кэшируем в Redis
|
||||
redis.setex(
|
||||
f"token:{access_token}",
|
||||
timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES),
|
||||
"valid"
|
||||
)
|
||||
|
||||
return access_token
|
||||
|
||||
def get_current_employee(token: str, db: Session):
|
||||
payload = verify_token(token, db)
|
||||
employee_id = int(payload.get("sub"))
|
||||
if employee_id == -1: # Для админа
|
||||
return {"is_admin": True}
|
||||
employee = get_employee(db, employee_id)
|
||||
if employee is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Employee not found",
|
||||
)
|
||||
return employee
|
@@ -3,17 +3,20 @@ from aiogram import Bot
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
import asyncio
|
||||
from datetime import datetime
|
||||
import os
|
||||
from logging import getLogger
|
||||
from ..models.request import RequestStatus, RequestPriority
|
||||
from ..crud import requests
|
||||
from ..database import get_db
|
||||
from ..core.config import settings
|
||||
|
||||
# Initialize logger
|
||||
logger = getLogger(__name__)
|
||||
|
||||
# Initialize bot with token from settings
|
||||
bot = Bot(token=settings.TELEGRAM_BOT_TOKEN)
|
||||
# Initialize bot with token
|
||||
TELEGRAM_BOT_TOKEN = os.getenv("TELEGRAM_BOT_TOKEN", "7677506032:AAHduD5EePz3bE23DKlo35KoOp2_9lZuS34")
|
||||
TELEGRAM_CHAT_ID = os.getenv("TELEGRAM_CHAT_ID", "5057752127")
|
||||
|
||||
bot = Bot(token=TELEGRAM_BOT_TOKEN)
|
||||
|
||||
def format_priority(priority: str) -> str:
|
||||
"""Format priority with emoji"""
|
||||
@@ -56,7 +59,7 @@ async def send_request_notification(request_id: int):
|
||||
)
|
||||
|
||||
await bot.send_message(
|
||||
chat_id=settings.TELEGRAM_CHAT_ID,
|
||||
chat_id=TELEGRAM_CHAT_ID,
|
||||
text=message,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
@@ -1,79 +1,5 @@
|
||||
-- Создаем основную базу данных
|
||||
CREATE DATABASE app;
|
||||
\c app;
|
||||
-- Connect to the database
|
||||
\c support_db;
|
||||
|
||||
-- Создаем таблицы для основной базы данных
|
||||
CREATE TABLE employees (
|
||||
id SERIAL PRIMARY KEY,
|
||||
first_name VARCHAR NOT NULL,
|
||||
last_name VARCHAR NOT NULL,
|
||||
department VARCHAR NOT NULL,
|
||||
office VARCHAR NOT NULL,
|
||||
hashed_password VARCHAR NOT NULL,
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
is_admin BOOLEAN DEFAULT FALSE,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE requests (
|
||||
id SERIAL PRIMARY KEY,
|
||||
request_type VARCHAR NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
priority VARCHAR NOT NULL,
|
||||
status VARCHAR NOT NULL DEFAULT 'new',
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
employee_id INTEGER NOT NULL REFERENCES employees(id)
|
||||
);
|
||||
|
||||
CREATE TABLE tokens (
|
||||
id SERIAL PRIMARY KEY,
|
||||
token VARCHAR UNIQUE NOT NULL,
|
||||
employee_id INTEGER NOT NULL REFERENCES employees(id),
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Создаем индексы
|
||||
CREATE INDEX idx_employees_last_name ON employees(last_name);
|
||||
CREATE INDEX idx_requests_employee_id ON requests(employee_id);
|
||||
CREATE INDEX idx_requests_status ON requests(status);
|
||||
CREATE INDEX idx_tokens_token ON tokens(token);
|
||||
|
||||
-- Создаем тестовую базу данных
|
||||
CREATE DATABASE test_app;
|
||||
\c test_app;
|
||||
|
||||
-- Создаем те же таблицы для тестовой базы данных
|
||||
CREATE TABLE employees (
|
||||
id SERIAL PRIMARY KEY,
|
||||
first_name VARCHAR NOT NULL,
|
||||
last_name VARCHAR NOT NULL,
|
||||
department VARCHAR NOT NULL,
|
||||
office VARCHAR NOT NULL,
|
||||
hashed_password VARCHAR NOT NULL,
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
is_admin BOOLEAN DEFAULT FALSE,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE requests (
|
||||
id SERIAL PRIMARY KEY,
|
||||
request_type VARCHAR NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
priority VARCHAR NOT NULL,
|
||||
status VARCHAR NOT NULL DEFAULT 'new',
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
employee_id INTEGER NOT NULL REFERENCES employees(id)
|
||||
);
|
||||
|
||||
CREATE TABLE tokens (
|
||||
id SERIAL PRIMARY KEY,
|
||||
token VARCHAR UNIQUE NOT NULL,
|
||||
employee_id INTEGER NOT NULL REFERENCES employees(id),
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Создаем индексы в тестовой базе
|
||||
CREATE INDEX idx_employees_last_name ON employees(last_name);
|
||||
CREATE INDEX idx_requests_employee_id ON requests(employee_id);
|
||||
CREATE INDEX idx_requests_status ON requests(status);
|
||||
CREATE INDEX idx_tokens_token ON tokens(token);
|
||||
-- Create extensions if needed
|
||||
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
@@ -1,18 +1,17 @@
|
||||
fastapi>=0.100.0
|
||||
uvicorn>=0.22.0
|
||||
sqlalchemy>=2.0.0
|
||||
psycopg2-binary>=2.9.6
|
||||
python-jose[cryptography]>=3.3.0
|
||||
fastapi==0.110.0
|
||||
uvicorn==0.27.1
|
||||
sqlalchemy==2.0.27
|
||||
pydantic==2.5.2
|
||||
pydantic-settings==2.2.1
|
||||
python-multipart==0.0.9
|
||||
python-jose[cryptography]==3.3.0
|
||||
passlib[bcrypt]>=1.7.4
|
||||
python-multipart>=0.0.6
|
||||
pydantic>=2.0.0
|
||||
pydantic-settings>=2.0.0
|
||||
pytest>=7.4.0
|
||||
pytest-cov>=4.1.0
|
||||
pytest-timeout>=2.1.0
|
||||
pytest-xdist>=3.3.1
|
||||
pytest-mock>=3.10.0
|
||||
httpx>=0.24.1
|
||||
redis>=4.6.0
|
||||
aiogram>=3.4.0
|
||||
python-telegram-bot>=20.4
|
||||
bcrypt>=4.0.1
|
||||
redis>=4.0.0
|
||||
python-dotenv==1.0.1
|
||||
psycopg2-binary==2.9.9
|
||||
alembic==1.13.1
|
||||
pytest==8.0.0
|
||||
httpx==0.26.0
|
||||
requests>=2.26.0
|
||||
aiogram==3.4.1
|
||||
|
BIN
backend/test.db
BIN
backend/test.db
Binary file not shown.
@@ -1,10 +1,127 @@
|
||||
"""Test configuration"""
|
||||
"""Test configuration."""
|
||||
import os
|
||||
import pytest
|
||||
from typing import Generator
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from sqlalchemy.pool import StaticPool
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from app.database import Base, get_db
|
||||
from app.main import app
|
||||
from app.models.employee import Employee
|
||||
from app.utils.auth import get_password_hash
|
||||
from app.utils.jwt import create_access_token
|
||||
from app.core.config import settings
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
"""Test client fixture"""
|
||||
return TestClient(app)
|
||||
# Создаем тестовую базу данных в памяти
|
||||
SQLALCHEMY_DATABASE_URL = "sqlite:///./test.db"
|
||||
engine = create_engine(SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False})
|
||||
TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
|
||||
class MockRedis:
|
||||
"""Мок для Redis."""
|
||||
def __init__(self):
|
||||
self.data = {}
|
||||
|
||||
def get(self, key):
|
||||
return self.data.get(key)
|
||||
|
||||
def set(self, key, value, ex=None):
|
||||
self.data[key] = value
|
||||
return True
|
||||
|
||||
def delete(self, key):
|
||||
if key in self.data:
|
||||
del self.data[key]
|
||||
return True
|
||||
|
||||
def exists(self, key):
|
||||
return key in self.data
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
def redis_mock():
|
||||
"""Фикстура для мока Redis."""
|
||||
with patch("app.utils.jwt.redis") as mock:
|
||||
redis_instance = MockRedis()
|
||||
mock.get.side_effect = redis_instance.get
|
||||
mock.set.side_effect = redis_instance.set
|
||||
mock.delete.side_effect = redis_instance.delete
|
||||
mock.exists.side_effect = redis_instance.exists
|
||||
yield mock
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
def db() -> Generator:
|
||||
"""Фикстура для создания тестовой базы данных."""
|
||||
Base.metadata.create_all(bind=engine)
|
||||
db = TestingSessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
Base.metadata.drop_all(bind=engine)
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
def client(db: TestingSessionLocal, redis_mock) -> Generator:
|
||||
"""Фикстура для создания тестового клиента."""
|
||||
def override_get_db():
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
app.dependency_overrides[get_db] = override_get_db
|
||||
return TestClient(app)
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
def test_employee(db: TestingSessionLocal) -> Employee:
|
||||
"""Фикстура для создания тестового сотрудника."""
|
||||
employee = Employee(
|
||||
email="test@example.com",
|
||||
full_name="Test Employee",
|
||||
hashed_password=get_password_hash("testpassword"),
|
||||
is_active=True,
|
||||
is_admin=False,
|
||||
department="IT"
|
||||
)
|
||||
db.add(employee)
|
||||
db.commit()
|
||||
db.refresh(employee)
|
||||
return employee
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
def test_admin(db: TestingSessionLocal) -> Employee:
|
||||
"""Фикстура для создания тестового администратора."""
|
||||
admin = Employee(
|
||||
email="admin@example.com",
|
||||
full_name="Test Admin",
|
||||
hashed_password=get_password_hash("adminpassword"),
|
||||
is_active=True,
|
||||
is_admin=True,
|
||||
department="Administration"
|
||||
)
|
||||
db.add(admin)
|
||||
db.commit()
|
||||
db.refresh(admin)
|
||||
return admin
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
def employee_token(test_employee: Employee, db: TestingSessionLocal) -> str:
|
||||
"""Фикстура для создания токена тестового сотрудника."""
|
||||
from app.utils.jwt import create_access_token
|
||||
token = create_access_token({"sub": str(test_employee.id)})
|
||||
# Сохраняем токен в Redis мок
|
||||
from app.utils.jwt import redis
|
||||
redis.set(f"token:{token}", "valid")
|
||||
return token
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
def admin_token(test_admin: Employee, db: TestingSessionLocal) -> str:
|
||||
"""Фикстура для создания токена администратора."""
|
||||
from app.utils.jwt import create_access_token
|
||||
token = create_access_token({"sub": str(test_admin.id)})
|
||||
# Сохраняем токен в Redis мок
|
||||
from app.utils.jwt import redis
|
||||
redis.set(f"token:{token}", "valid")
|
||||
return token
|
80
backend/tests/test_auth.py
Normal file
80
backend/tests/test_auth.py
Normal file
@@ -0,0 +1,80 @@
|
||||
"""Authentication tests."""
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy.orm import Session
|
||||
from app.models.employee import Employee
|
||||
|
||||
def test_login_employee_success(client: TestClient, test_employee: Employee):
|
||||
"""Тест успешной авторизации сотрудника."""
|
||||
response = client.post(
|
||||
"/api/auth/login",
|
||||
data={"username": test_employee.email, "password": "testpassword"}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert "access_token" in response.json()
|
||||
assert "token_type" in response.json()
|
||||
assert response.json()["token_type"] == "bearer"
|
||||
|
||||
def test_login_employee_wrong_password(client: TestClient, test_employee: Employee):
|
||||
"""Тест авторизации сотрудника с неверным паролем."""
|
||||
response = client.post(
|
||||
"/api/auth/login",
|
||||
data={"username": test_employee.email, "password": "wrongpassword"}
|
||||
)
|
||||
assert response.status_code == 401
|
||||
assert response.json()["detail"] == "Incorrect username or password"
|
||||
|
||||
def test_login_employee_wrong_username(client: TestClient):
|
||||
"""Тест авторизации с несуществующим пользователем."""
|
||||
response = client.post(
|
||||
"/api/auth/login",
|
||||
data={"username": "nonexistent@example.com", "password": "testpassword"}
|
||||
)
|
||||
assert response.status_code == 401
|
||||
assert response.json()["detail"] == "Incorrect username or password"
|
||||
|
||||
def test_login_admin_success(client: TestClient, test_admin: Employee):
|
||||
"""Тест успешной авторизации администратора."""
|
||||
response = client.post(
|
||||
"/api/auth/admin/login",
|
||||
data={"username": test_admin.email, "password": "adminpassword"}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert "access_token" in response.json()
|
||||
assert "token_type" in response.json()
|
||||
assert response.json()["token_type"] == "bearer"
|
||||
|
||||
def test_login_admin_wrong_password(client: TestClient, test_admin: Employee):
|
||||
"""Тест авторизации администратора с неверным паролем."""
|
||||
response = client.post(
|
||||
"/api/auth/admin/login",
|
||||
data={"username": test_admin.email, "password": "wrongpassword"}
|
||||
)
|
||||
assert response.status_code == 401
|
||||
assert response.json()["detail"] == "Incorrect username or password"
|
||||
|
||||
def test_protected_route_with_valid_token(client: TestClient, employee_token: str, test_employee: Employee, db: Session):
|
||||
"""Тест доступа к защищенному маршруту с валидным токеном."""
|
||||
response = client.get(
|
||||
"/api/employees/me",
|
||||
headers={"Authorization": f"Bearer {employee_token}"}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["email"] == test_employee.email
|
||||
assert data["full_name"] == test_employee.full_name
|
||||
|
||||
def test_protected_route_without_token(client: TestClient):
|
||||
"""Тест доступа к защищенному маршруту без токена."""
|
||||
response = client.get("/api/employees/me")
|
||||
assert response.status_code == 401
|
||||
assert response.json()["detail"] == "Not authenticated"
|
||||
|
||||
def test_protected_route_with_invalid_token(client: TestClient):
|
||||
"""Тест доступа к защищенному маршруту с недействительным токеном."""
|
||||
response = client.get(
|
||||
"/api/employees/me",
|
||||
headers={"Authorization": "Bearer invalid_token"}
|
||||
)
|
||||
assert response.status_code == 401
|
||||
assert response.json()["detail"] == "Could not validate credentials"
|
135
backend/tests/test_employees.py
Normal file
135
backend/tests/test_employees.py
Normal file
@@ -0,0 +1,135 @@
|
||||
"""Employee tests."""
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy.orm import Session
|
||||
from app.models.employee import Employee
|
||||
|
||||
def test_create_employee(client: TestClient, admin_token: str, db: Session):
|
||||
"""Тест создания сотрудника."""
|
||||
response = client.post(
|
||||
"/api/employees",
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
json={
|
||||
"email": "new@example.com",
|
||||
"password": "newpassword",
|
||||
"full_name": "New Employee",
|
||||
"department": "IT",
|
||||
"is_active": True,
|
||||
"is_admin": False
|
||||
}
|
||||
)
|
||||
assert response.status_code == 201
|
||||
data = response.json()
|
||||
assert data["email"] == "new@example.com"
|
||||
assert data["full_name"] == "New Employee"
|
||||
assert data["department"] == "IT"
|
||||
assert "id" in data
|
||||
|
||||
def test_create_employee_unauthorized(client: TestClient):
|
||||
"""Тест создания сотрудника без авторизации."""
|
||||
response = client.post(
|
||||
"/api/employees",
|
||||
json={
|
||||
"email": "new@example.com",
|
||||
"password": "newpassword",
|
||||
"full_name": "New Employee",
|
||||
"is_active": True,
|
||||
"is_admin": False
|
||||
}
|
||||
)
|
||||
assert response.status_code == 401
|
||||
assert response.json()["detail"] == "Not authenticated"
|
||||
|
||||
def test_get_employees(client: TestClient, admin_token: str, test_employee: Employee, db: Session):
|
||||
"""Тест получения списка сотрудников."""
|
||||
response = client.get(
|
||||
"/api/employees",
|
||||
headers={"Authorization": f"Bearer {admin_token}"}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert isinstance(data, list)
|
||||
assert len(data) > 0
|
||||
assert "email" in data[0]
|
||||
assert "full_name" in data[0]
|
||||
assert "department" in data[0]
|
||||
|
||||
def test_get_employee_by_id(client: TestClient, admin_token: str, test_employee: Employee, db: Session):
|
||||
"""Тест получения сотрудника по ID."""
|
||||
response = client.get(
|
||||
f"/api/employees/{test_employee.id}",
|
||||
headers={"Authorization": f"Bearer {admin_token}"}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["email"] == test_employee.email
|
||||
assert data["full_name"] == test_employee.full_name
|
||||
assert data["department"] == test_employee.department
|
||||
|
||||
def test_get_nonexistent_employee(client: TestClient, admin_token: str):
|
||||
"""Тест получения несуществующего сотрудника."""
|
||||
response = client.get(
|
||||
"/api/employees/999",
|
||||
headers={"Authorization": f"Bearer {admin_token}"}
|
||||
)
|
||||
assert response.status_code == 404
|
||||
assert response.json()["detail"] == "Employee not found"
|
||||
|
||||
def test_update_employee(client: TestClient, admin_token: str, test_employee: Employee, db: Session):
|
||||
"""Тест обновления данных сотрудника."""
|
||||
response = client.put(
|
||||
f"/api/employees/{test_employee.id}",
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
json={
|
||||
"email": "updated@example.com",
|
||||
"full_name": "Updated Employee",
|
||||
"department": "HR",
|
||||
"is_active": True,
|
||||
"is_admin": False
|
||||
}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["email"] == "updated@example.com"
|
||||
assert data["full_name"] == "Updated Employee"
|
||||
assert data["department"] == "HR"
|
||||
|
||||
def test_delete_employee(client: TestClient, admin_token: str, test_employee: Employee, db: Session):
|
||||
"""Тест удаления сотрудника."""
|
||||
response = client.delete(
|
||||
f"/api/employees/{test_employee.id}",
|
||||
headers={"Authorization": f"Bearer {admin_token}"}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["email"] == test_employee.email
|
||||
assert data["full_name"] == test_employee.full_name
|
||||
assert data["department"] == test_employee.department
|
||||
|
||||
def test_employee_me(client: TestClient, employee_token: str, test_employee: Employee, db: Session):
|
||||
"""Тест получения информации о текущем сотруднике."""
|
||||
response = client.get(
|
||||
"/api/employees/me",
|
||||
headers={"Authorization": f"Bearer {employee_token}"}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["email"] == test_employee.email
|
||||
assert data["full_name"] == test_employee.full_name
|
||||
assert data["department"] == test_employee.department
|
||||
|
||||
def test_update_me(client: TestClient, employee_token: str, test_employee: Employee, db: Session):
|
||||
"""Тест обновления информации о текущем сотруднике."""
|
||||
response = client.put(
|
||||
"/api/employees/me",
|
||||
headers={"Authorization": f"Bearer {employee_token}"},
|
||||
json={
|
||||
"full_name": "Updated Name",
|
||||
"department": "Support"
|
||||
}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["full_name"] == "Updated Name"
|
||||
assert data["email"] == test_employee.email
|
||||
assert data["department"] == "Support"
|
168
backend/tests/test_requests.py
Normal file
168
backend/tests/test_requests.py
Normal file
@@ -0,0 +1,168 @@
|
||||
"""Request tests."""
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy.orm import Session
|
||||
from app.models.employee import Employee
|
||||
from app.models.request import Request
|
||||
|
||||
def test_create_request(client: TestClient, employee_token: str, db: Session):
|
||||
"""Тест создания заявки."""
|
||||
response = client.post(
|
||||
"/api/requests",
|
||||
headers={"Authorization": f"Bearer {employee_token}"},
|
||||
json={
|
||||
"request_type": "support",
|
||||
"description": "Test Description",
|
||||
"priority": "medium"
|
||||
}
|
||||
)
|
||||
assert response.status_code == 201
|
||||
data = response.json()
|
||||
assert data["request_type"] == "support"
|
||||
assert data["description"] == "Test Description"
|
||||
assert data["priority"] == "medium"
|
||||
assert data["status"] == "new"
|
||||
assert "id" in data
|
||||
|
||||
def test_create_request_unauthorized(client: TestClient):
|
||||
"""Тест создания заявки без авторизации."""
|
||||
response = client.post(
|
||||
"/api/requests",
|
||||
json={
|
||||
"request_type": "support",
|
||||
"description": "Test Description",
|
||||
"priority": "medium"
|
||||
}
|
||||
)
|
||||
assert response.status_code == 401
|
||||
assert response.json()["detail"] == "Not authenticated"
|
||||
|
||||
def test_get_employee_requests(client: TestClient, employee_token: str, test_employee: Employee, db: Session):
|
||||
"""Тест получения списка заявок сотрудника."""
|
||||
db.add(test_employee)
|
||||
db.commit()
|
||||
db.refresh(test_employee)
|
||||
|
||||
# Создаем тестовую заявку
|
||||
request = Request(
|
||||
request_type="support",
|
||||
description="Test Description",
|
||||
priority="medium",
|
||||
status="new",
|
||||
employee_id=test_employee.id
|
||||
)
|
||||
db.add(request)
|
||||
db.commit()
|
||||
|
||||
response = client.get(
|
||||
"/api/requests/my",
|
||||
headers={"Authorization": f"Bearer {employee_token}"}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert isinstance(data, list)
|
||||
assert len(data) > 0
|
||||
assert data[0]["request_type"] == "support"
|
||||
assert data[0]["description"] == "Test Description"
|
||||
|
||||
def test_admin_get_all_requests(client: TestClient, admin_token: str, test_employee: Employee, db: Session):
|
||||
"""Тест получения всех заявок администратором."""
|
||||
db.add(test_employee)
|
||||
db.commit()
|
||||
db.refresh(test_employee)
|
||||
|
||||
# Создаем тестовую заявку
|
||||
request = Request(
|
||||
request_type="support",
|
||||
description="Test Description",
|
||||
priority="medium",
|
||||
status="new",
|
||||
employee_id=test_employee.id
|
||||
)
|
||||
db.add(request)
|
||||
db.commit()
|
||||
|
||||
response = client.get(
|
||||
"/api/requests/admin",
|
||||
headers={"Authorization": f"Bearer {admin_token}"}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert isinstance(data, list)
|
||||
assert len(data) > 0
|
||||
assert data[0]["request_type"] == "support"
|
||||
assert data[0]["description"] == "Test Description"
|
||||
|
||||
def test_update_request_status(client: TestClient, admin_token: str, test_employee: Employee, db: Session):
|
||||
"""Тест обновления статуса заявки."""
|
||||
db.add(test_employee)
|
||||
db.commit()
|
||||
db.refresh(test_employee)
|
||||
|
||||
# Создаем тестовую заявку
|
||||
request = Request(
|
||||
request_type="support",
|
||||
description="Test Description",
|
||||
priority="medium",
|
||||
status="new",
|
||||
employee_id=test_employee.id,
|
||||
department=test_employee.department
|
||||
)
|
||||
db.add(request)
|
||||
db.commit()
|
||||
|
||||
response = client.patch(
|
||||
f"/api/requests/{request.id}/status",
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
json={"status": "in_progress"}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["status"] == "in_progress"
|
||||
|
||||
def test_get_request_statistics(client: TestClient, admin_token: str, test_employee: Employee, db: Session):
|
||||
"""Тест получения статистики по заявкам."""
|
||||
db.add(test_employee)
|
||||
db.commit()
|
||||
db.refresh(test_employee)
|
||||
|
||||
# Создаем тестовые заявки с разными статусами
|
||||
requests = [
|
||||
Request(
|
||||
request_type="support",
|
||||
description="Test Description",
|
||||
priority="medium",
|
||||
status="new",
|
||||
employee_id=test_employee.id,
|
||||
department=test_employee.department
|
||||
),
|
||||
Request(
|
||||
request_type="support",
|
||||
description="Test Description",
|
||||
priority="high",
|
||||
status="in_progress",
|
||||
employee_id=test_employee.id,
|
||||
department=test_employee.department
|
||||
),
|
||||
Request(
|
||||
request_type="support",
|
||||
description="Test Description",
|
||||
priority="low",
|
||||
status="completed",
|
||||
employee_id=test_employee.id,
|
||||
department=test_employee.department
|
||||
)
|
||||
]
|
||||
for req in requests:
|
||||
db.add(req)
|
||||
db.commit()
|
||||
|
||||
response = client.get(
|
||||
"/api/statistics",
|
||||
headers={"Authorization": f"Bearer {admin_token}"}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "total" in data
|
||||
assert "by_status" in data
|
||||
assert data["total"] >= 3
|
@@ -1,22 +1,42 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:15
|
||||
container_name: postgres
|
||||
backend:
|
||||
image: backend:latest
|
||||
restart: always
|
||||
environment:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
volumes:
|
||||
- ./backend/docker/postgres/init.sql:/docker-entrypoint-initdb.d/init.sql
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
- DATABASE_URL=postgresql://postgres:postgres@db:5432/app
|
||||
- REDIS_HOST=redis
|
||||
- REDIS_PORT=6379
|
||||
- SECRET_KEY=${SECRET_KEY}
|
||||
depends_on:
|
||||
- db
|
||||
- redis
|
||||
|
||||
frontend:
|
||||
image: frontend:latest
|
||||
restart: always
|
||||
ports:
|
||||
- "5432:5432"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
- "80:80"
|
||||
depends_on:
|
||||
- backend
|
||||
|
||||
db:
|
||||
image: postgres:15
|
||||
restart: always
|
||||
environment:
|
||||
- POSTGRES_USER=postgres
|
||||
- POSTGRES_PASSWORD=postgres
|
||||
- POSTGRES_DB=app
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
|
||||
redis:
|
||||
image: redis:latest
|
||||
restart: always
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
postgres_data:
|
||||
redis_data:
|
@@ -1,7 +1,6 @@
|
||||
# Build stage
|
||||
FROM node:18 as build-stage
|
||||
FROM node:18-alpine AS build
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
@@ -10,25 +9,29 @@ COPY package*.json ./
|
||||
# Install dependencies
|
||||
RUN npm install
|
||||
|
||||
# Copy project files
|
||||
# Copy source code
|
||||
COPY . .
|
||||
|
||||
# Install Vue dependencies
|
||||
RUN npm install vue@latest @vitejs/plugin-vue vue-tsc typescript
|
||||
# Install Vue compiler globally
|
||||
RUN npm install -g @vue/compiler-sfc
|
||||
|
||||
# Set environment variables
|
||||
ENV NODE_ENV=production
|
||||
ENV VITE_API_URL=/api
|
||||
|
||||
# Build the application with increased memory limit
|
||||
RUN NODE_OPTIONS="--max-old-space-size=4096" npm run build
|
||||
|
||||
# Production stage
|
||||
FROM nginx:stable-alpine as production-stage
|
||||
FROM nginx:alpine
|
||||
|
||||
# Copy built files
|
||||
COPY --from=build-stage /app/dist /usr/share/nginx/html
|
||||
# Copy built files from build stage
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
|
||||
# Copy nginx configuration
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
# Expose port
|
||||
# Expose port 80
|
||||
EXPOSE 80
|
||||
|
||||
# Start nginx
|
||||
|
@@ -6,8 +6,7 @@
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc --noEmit && vite build",
|
||||
"preview": "vite preview",
|
||||
"test": "echo \"No tests configured yet\" && exit 0"
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@vueuse/core": "^10.9.0",
|
||||
|
@@ -3,7 +3,7 @@
|
||||
<div class="max-w-md w-full bg-white rounded-xl shadow-2xl p-8">
|
||||
<div class="text-center mb-8">
|
||||
<h2 class="text-3xl font-bold text-gray-900">
|
||||
Панель администратора
|
||||
Панель администратора 55
|
||||
</h2>
|
||||
<p class="mt-2 text-gray-600">
|
||||
Вход в систему управления
|
||||
|
@@ -9,26 +9,23 @@ export default defineConfig({
|
||||
plugins: [vue()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, 'src'),
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
build: {
|
||||
chunkSizeWarningLimit: 1600,
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks(id) {
|
||||
if (id.includes('node_modules')) {
|
||||
return 'vendor';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
optimizeDeps: {
|
||||
include: ['axios']
|
||||
},
|
||||
server: {
|
||||
host: true,
|
||||
port: 5173,
|
||||
watch: {
|
||||
usePolling: true
|
||||
},
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8000',
|
||||
changeOrigin: true,
|
||||
secure: false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -1,4 +1,4 @@
|
||||
#!/bin/bashsss
|
||||
#!/bin/bash
|
||||
|
||||
# Остановить все контейнеры
|
||||
docker compose down -v
|
||||
|
@@ -0,0 +1,2 @@
|
||||
# Попытка вывести неопределенную переменную
|
||||
print(my_var) # Вызовет NameError
|
Reference in New Issue
Block a user