landvex: Fixar och tester klara för alla komponenter
- Datafabrik: Dockerfile fix, agentorkestrering fungerar - Vision: Identify-modell, FAISS, OCR alla testade - API: Alla 7 integrationstester passerade - Upplösare: Entitetsupplösning verifierad
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,33 @@
|
||||
"""Konfiguration för LandveX user management modul."""
|
||||
from pydantic_settings import BaseSettings
|
||||
from functools import lru_cache
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
APP_NAME: str = "LandveX User Management"
|
||||
DEBUG: bool = False
|
||||
|
||||
# Database
|
||||
DATABASE_URL: str = "postgresql+asyncpg://postgres:postgres@localhost:5432/landvex"
|
||||
|
||||
# JWT
|
||||
SECRET_KEY: str = "change-me-in-production-landvex-secret-key-2024"
|
||||
ALGORITHM: str = "HS256"
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
|
||||
REFRESH_TOKEN_EXPIRE_DAYS: int = 7
|
||||
|
||||
# Invitation
|
||||
INVITATION_TOKEN_EXPIRE_HOURS: int = 48
|
||||
FRONTEND_SET_PASSWORD_URL: str = "https://admin.landvex.se/set-password"
|
||||
|
||||
# Password policy
|
||||
MIN_PASSWORD_LENGTH: int = 8
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
env_file_encoding = "utf-8"
|
||||
|
||||
|
||||
@lru_cache()
|
||||
def get_settings() -> Settings:
|
||||
return Settings()
|
||||
@@ -0,0 +1,30 @@
|
||||
"""Databaskonfiguration och session-hantering."""
|
||||
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
|
||||
from sqlalchemy.orm import declarative_base
|
||||
from app.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
engine = create_async_engine(
|
||||
settings.DATABASE_URL,
|
||||
echo=settings.DEBUG,
|
||||
future=True,
|
||||
)
|
||||
|
||||
AsyncSessionLocal = async_sessionmaker(
|
||||
engine,
|
||||
class_=AsyncSession,
|
||||
expire_on_commit=False,
|
||||
autoflush=False,
|
||||
)
|
||||
|
||||
Base = declarative_base()
|
||||
|
||||
|
||||
async def get_db() -> AsyncSession:
|
||||
"""Dependency för FastAPI-endpoints."""
|
||||
async with AsyncSessionLocal() as session:
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
await session.close()
|
||||
@@ -0,0 +1,75 @@
|
||||
"""FastAPI dependencies för auth och databas."""
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordBearer
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.database import get_db
|
||||
from app.models import User, UserRole, UserStatus
|
||||
from app.schemas import TokenPayload
|
||||
from app.security import decode_token
|
||||
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login")
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
token: str = Depends(oauth2_scheme),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> User:
|
||||
credentials_exception = HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Could not validate credentials",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
payload = decode_token(token)
|
||||
if payload is None or payload.get("type") != "access":
|
||||
raise credentials_exception
|
||||
|
||||
user_id: Optional[str] = payload.get("sub")
|
||||
if user_id is None:
|
||||
raise credentials_exception
|
||||
|
||||
result = await db.execute(select(User).where(User.id == UUID(user_id)))
|
||||
user = result.scalar_one_or_none()
|
||||
if user is None:
|
||||
raise credentials_exception
|
||||
if user.status != UserStatus.ACTIVE:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="User account is inactive or pending",
|
||||
)
|
||||
return user
|
||||
|
||||
|
||||
async def get_current_active_superuser(
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> User:
|
||||
if not current_user.is_superuser:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Superuser privilege required",
|
||||
)
|
||||
return current_user
|
||||
|
||||
|
||||
class RoleChecker:
|
||||
def __init__(self, min_role: UserRole):
|
||||
self.min_role = min_role
|
||||
|
||||
async def __call__(self, current_user: User = Depends(get_current_user)) -> User:
|
||||
from app.security import role_level
|
||||
if role_level(current_user.role) < role_level(self.min_role):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=f"Requires role {self.min_role.value} or higher",
|
||||
)
|
||||
return current_user
|
||||
|
||||
|
||||
require_admin = RoleChecker(UserRole.ADMIN)
|
||||
require_manager = RoleChecker(UserRole.MANAGER)
|
||||
require_analyst = RoleChecker(UserRole.ANALYST)
|
||||
@@ -0,0 +1,44 @@
|
||||
"""FastAPI-applikation för LandveX User Management."""
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from app.database import engine, Base
|
||||
from app.routers import auth, users, invitations, tenants
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
# Startup: skapa tabeller (i dev; i prod använd Alembic)
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
yield
|
||||
# Shutdown
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title="LandveX User Management API",
|
||||
description="Admin-backend för användarhantering, roller, inbjudningar och tenants.",
|
||||
version="1.0.0",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"], # Begränsa i produktion
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
app.include_router(auth.router, prefix="/api/v1")
|
||||
app.include_router(users.router, prefix="/api/v1")
|
||||
app.include_router(invitations.router, prefix="/api/v1")
|
||||
app.include_router(tenants.router, prefix="/api/v1")
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health_check():
|
||||
return {"status": "ok", "service": "user-management"}
|
||||
@@ -0,0 +1,84 @@
|
||||
"""SQLAlchemy-modeller för user management."""
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from enum import Enum as PyEnum
|
||||
|
||||
from sqlalchemy import Column, String, DateTime, Boolean, ForeignKey, Enum, Text
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class UserRole(str, PyEnum):
|
||||
ADMIN = "admin"
|
||||
MANAGER = "manager"
|
||||
ANALYST = "analyst"
|
||||
VIEWER = "viewer"
|
||||
|
||||
|
||||
class UserStatus(str, PyEnum):
|
||||
ACTIVE = "active"
|
||||
INACTIVE = "inactive"
|
||||
PENDING = "pending" # Invited, not yet set password
|
||||
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
email = Column(String(255), unique=True, nullable=False, index=True)
|
||||
hashed_password = Column(String(255), nullable=True) # NULL until invitation accepted
|
||||
first_name = Column(String(100), nullable=False)
|
||||
last_name = Column(String(100), nullable=False)
|
||||
role = Column(Enum(UserRole), nullable=False, default=UserRole.VIEWER)
|
||||
status = Column(Enum(UserStatus), nullable=False, default=UserStatus.PENDING)
|
||||
is_superuser = Column(Boolean, default=False)
|
||||
last_login_at = Column(DateTime(timezone=True), nullable=True)
|
||||
created_at = Column(DateTime(timezone=True), default=datetime.utcnow)
|
||||
updated_at = Column(DateTime(timezone=True), default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
tenant_id = Column(UUID(as_uuid=True), ForeignKey("tenants.id"), nullable=True)
|
||||
tenant = relationship("Tenant", back_populates="users")
|
||||
|
||||
invitations_sent = relationship("Invitation", foreign_keys="Invitation.invited_by_id", back_populates="inviter")
|
||||
invitations_received = relationship("Invitation", foreign_keys="Invitation.email", primaryjoin="User.email == Invitation.email", viewonly=True)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<User {self.email} ({self.role.value})>"
|
||||
|
||||
|
||||
class Tenant(Base):
|
||||
__tablename__ = "tenants"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
name = Column(String(255), nullable=False)
|
||||
slug = Column(String(100), unique=True, nullable=False, index=True)
|
||||
description = Column(Text, nullable=True)
|
||||
created_at = Column(DateTime(timezone=True), default=datetime.utcnow)
|
||||
updated_at = Column(DateTime(timezone=True), default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
users = relationship("User", back_populates="tenant")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Tenant {self.name}>"
|
||||
|
||||
|
||||
class Invitation(Base):
|
||||
__tablename__ = "invitations"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
email = Column(String(255), nullable=False, index=True)
|
||||
token = Column(String(255), unique=True, nullable=False, index=True)
|
||||
role = Column(Enum(UserRole), nullable=False, default=UserRole.VIEWER)
|
||||
tenant_id = Column(UUID(as_uuid=True), ForeignKey("tenants.id"), nullable=True)
|
||||
invited_by_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
|
||||
expires_at = Column(DateTime(timezone=True), nullable=False)
|
||||
accepted_at = Column(DateTime(timezone=True), nullable=True)
|
||||
created_at = Column(DateTime(timezone=True), default=datetime.utcnow)
|
||||
|
||||
inviter = relationship("User", foreign_keys=[invited_by_id], back_populates="invitations_sent")
|
||||
tenant = relationship("Tenant")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Invitation {self.email} ({self.role.value})>"
|
||||
@@ -0,0 +1 @@
|
||||
# Routers package
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,81 @@
|
||||
"""Auth-endpoints: login, refresh, change password."""
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.dependencies import get_current_user
|
||||
from app.models import User
|
||||
from app.schemas import Token, LoginRequest, RefreshRequest, ChangePasswordRequest
|
||||
from app.security import create_access_token, create_refresh_token, decode_token
|
||||
from app.services.user_service import UserService
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["Authentication"])
|
||||
|
||||
|
||||
@router.post("/login", response_model=Token)
|
||||
async def login(data: LoginRequest, db: AsyncSession = Depends(get_db)):
|
||||
service = UserService(db)
|
||||
user = await service.authenticate(data.email, data.password)
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Incorrect email or password",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
user.last_login_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
|
||||
access_token = create_access_token(user.id)
|
||||
refresh_token = create_refresh_token(user.id)
|
||||
return Token(
|
||||
access_token=access_token,
|
||||
refresh_token=refresh_token,
|
||||
expires_in=30 * 60,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/refresh", response_model=Token)
|
||||
async def refresh(data: RefreshRequest, db: AsyncSession = Depends(get_db)):
|
||||
payload = decode_token(data.refresh_token)
|
||||
if payload is None or payload.get("type") != "refresh":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid refresh token",
|
||||
)
|
||||
|
||||
from uuid import UUID
|
||||
user_id = payload.get("sub")
|
||||
service = UserService(db)
|
||||
user = await service.get_by_id(UUID(user_id))
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="User not found",
|
||||
)
|
||||
|
||||
access_token = create_access_token(user.id)
|
||||
refresh_token = create_refresh_token(user.id)
|
||||
return Token(
|
||||
access_token=access_token,
|
||||
refresh_token=refresh_token,
|
||||
expires_in=30 * 60,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/change-password")
|
||||
async def change_password(
|
||||
data: ChangePasswordRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
service = UserService(db)
|
||||
await service.change_password(current_user, data.current_password, data.new_password)
|
||||
return {"message": "Password changed successfully"}
|
||||
|
||||
|
||||
@router.get("/me")
|
||||
async def me(current_user: User = Depends(get_current_user)):
|
||||
from app.schemas import UserDetailOut
|
||||
return UserDetailOut.model_validate(current_user)
|
||||
@@ -0,0 +1,93 @@
|
||||
"""Invitation endpoints."""
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.dependencies import get_current_user, require_admin, require_manager
|
||||
from app.models import User
|
||||
from app.schemas import InvitationCreate, InvitationOut, AcceptInvitationRequest, UserOut
|
||||
from app.services.invitation_service import InvitationService
|
||||
|
||||
router = APIRouter(prefix="/invitations", tags=["Invitations"])
|
||||
|
||||
|
||||
@router.get("", response_model=list[InvitationOut])
|
||||
async def list_invitations(
|
||||
tenant_id: Optional[UUID] = Query(None),
|
||||
pending_only: bool = Query(True),
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(100, ge=1, le=500),
|
||||
current_user: User = Depends(require_manager),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
service = InvitationService(db)
|
||||
effective_tenant = tenant_id
|
||||
if not current_user.is_superuser and current_user.role.value == "manager":
|
||||
effective_tenant = current_user.tenant_id
|
||||
|
||||
items, total = await service.list_invitations(
|
||||
tenant_id=effective_tenant,
|
||||
pending_only=pending_only,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
)
|
||||
return items
|
||||
|
||||
|
||||
@router.post("", response_model=InvitationOut, status_code=201)
|
||||
async def create_invitation(
|
||||
data: InvitationCreate,
|
||||
current_user: User = Depends(require_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
service = InvitationService(db)
|
||||
invitation = await service.create_invitation(data, invited_by=current_user)
|
||||
return InvitationOut.model_validate(invitation)
|
||||
|
||||
|
||||
@router.post("/accept", response_model=UserOut)
|
||||
async def accept_invitation(
|
||||
data: AcceptInvitationRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
service = InvitationService(db)
|
||||
user = await service.accept_invitation(data)
|
||||
return UserOut.model_validate(user)
|
||||
|
||||
|
||||
@router.get("/validate/{token}")
|
||||
async def validate_invitation(
|
||||
token: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
service = InvitationService(db)
|
||||
invitation = await service.get_by_token(token)
|
||||
if invitation is None:
|
||||
return {"valid": False, "reason": "not_found"}
|
||||
if invitation.accepted_at is not None:
|
||||
return {"valid": False, "reason": "already_accepted"}
|
||||
if invitation.expires_at < datetime.utcnow():
|
||||
return {"valid": False, "reason": "expired"}
|
||||
return {
|
||||
"valid": True,
|
||||
"email": invitation.email,
|
||||
"role": invitation.role.value,
|
||||
"expires_at": invitation.expires_at.isoformat(),
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/{invitation_id}", status_code=204)
|
||||
async def revoke_invitation(
|
||||
invitation_id: UUID,
|
||||
current_user: User = Depends(require_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
service = InvitationService(db)
|
||||
await service.revoke_invitation(invitation_id, actor=current_user)
|
||||
return None
|
||||
|
||||
|
||||
from datetime import datetime
|
||||
@@ -0,0 +1,74 @@
|
||||
"""Tenant CRUD-endpoints."""
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.dependencies import get_current_user, require_admin
|
||||
from app.schemas import TenantCreate, TenantUpdate, TenantOut
|
||||
from app.services.tenant_service import TenantService
|
||||
|
||||
router = APIRouter(prefix="/tenants", tags=["Tenants"])
|
||||
|
||||
|
||||
@router.get("", response_model=list[TenantOut])
|
||||
async def list_tenants(
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(100, ge=1, le=500),
|
||||
current_user=Depends(require_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
service = TenantService(db)
|
||||
items, _ = await service.list_tenants(skip=skip, limit=limit)
|
||||
return items
|
||||
|
||||
|
||||
@router.get("/{tenant_id}", response_model=TenantOut)
|
||||
async def get_tenant(
|
||||
tenant_id: UUID,
|
||||
current_user=Depends(require_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
service = TenantService(db)
|
||||
tenant = await service.get_by_id(tenant_id)
|
||||
if tenant is None:
|
||||
raise HTTPException(status_code=404, detail="Tenant not found")
|
||||
return TenantOut.model_validate(tenant)
|
||||
|
||||
|
||||
@router.post("", response_model=TenantOut, status_code=201)
|
||||
async def create_tenant(
|
||||
data: TenantCreate,
|
||||
current_user=Depends(require_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
service = TenantService(db)
|
||||
tenant = await service.create_tenant(data)
|
||||
return TenantOut.model_validate(tenant)
|
||||
|
||||
|
||||
@router.patch("/{tenant_id}", response_model=TenantOut)
|
||||
async def update_tenant(
|
||||
tenant_id: UUID,
|
||||
data: TenantUpdate,
|
||||
current_user=Depends(require_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
service = TenantService(db)
|
||||
tenant = await service.update_tenant(tenant_id, data)
|
||||
return TenantOut.model_validate(tenant)
|
||||
|
||||
|
||||
@router.delete("/{tenant_id}", status_code=204)
|
||||
async def delete_tenant(
|
||||
tenant_id: UUID,
|
||||
current_user=Depends(require_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
service = TenantService(db)
|
||||
await service.delete_tenant(tenant_id)
|
||||
return None
|
||||
|
||||
|
||||
from fastapi import HTTPException
|
||||
@@ -0,0 +1,96 @@
|
||||
"""User CRUD-endpoints med roll-baserad åtkomst."""
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.dependencies import get_current_user, require_admin, require_manager
|
||||
from app.models import User, UserRole, UserStatus
|
||||
from app.schemas import UserCreate, UserUpdate, UserOut, UserListOut, UserDetailOut
|
||||
from app.services.user_service import UserService
|
||||
|
||||
router = APIRouter(prefix="/users", tags=["Users"])
|
||||
|
||||
|
||||
@router.get("", response_model=UserListOut)
|
||||
async def list_users(
|
||||
tenant_id: Optional[UUID] = Query(None),
|
||||
role: Optional[UserRole] = Query(None),
|
||||
status: Optional[UserStatus] = Query(None),
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(100, ge=1, le=500),
|
||||
current_user: User = Depends(require_manager),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
service = UserService(db)
|
||||
# Managers ser bara användare inom sin tenant (om de inte är superuser)
|
||||
effective_tenant = tenant_id
|
||||
if not current_user.is_superuser and current_user.role == UserRole.MANAGER:
|
||||
effective_tenant = current_user.tenant_id
|
||||
|
||||
items, total = await service.list_users(
|
||||
tenant_id=effective_tenant,
|
||||
role=role,
|
||||
status=status,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
)
|
||||
return UserListOut(items=items, total=total)
|
||||
|
||||
|
||||
@router.get("/{user_id}", response_model=UserDetailOut)
|
||||
async def get_user(
|
||||
user_id: UUID,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
service = UserService(db)
|
||||
user = await service.get_by_id(user_id)
|
||||
if user is None:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
# Managers kan bara se användare inom sin tenant
|
||||
if not current_user.is_superuser and current_user.role == UserRole.MANAGER:
|
||||
if user.tenant_id != current_user.tenant_id:
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
|
||||
return UserDetailOut.model_validate(user)
|
||||
|
||||
|
||||
@router.post("", response_model=UserOut, status_code=201)
|
||||
async def create_user(
|
||||
data: UserCreate,
|
||||
current_user: User = Depends(require_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
service = UserService(db)
|
||||
user = await service.create_user(data, created_by=current_user)
|
||||
return UserOut.model_validate(user)
|
||||
|
||||
|
||||
@router.patch("/{user_id}", response_model=UserOut)
|
||||
async def update_user(
|
||||
user_id: UUID,
|
||||
data: UserUpdate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
service = UserService(db)
|
||||
user = await service.update_user(user_id, data, actor=current_user)
|
||||
return UserOut.model_validate(user)
|
||||
|
||||
|
||||
@router.delete("/{user_id}", status_code=204)
|
||||
async def delete_user(
|
||||
user_id: UUID,
|
||||
current_user: User = Depends(require_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
service = UserService(db)
|
||||
await service.delete_user(user_id, actor=current_user)
|
||||
return None
|
||||
|
||||
|
||||
from fastapi import HTTPException
|
||||
@@ -0,0 +1,147 @@
|
||||
"""Pydantic-scheman för request/response-validering."""
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, EmailStr, Field, ConfigDict
|
||||
|
||||
from app.models import UserRole, UserStatus
|
||||
|
||||
|
||||
# ─── Shared ───────────────────────────────────────────
|
||||
|
||||
class UserBase(BaseModel):
|
||||
email: EmailStr
|
||||
first_name: str = Field(..., min_length=1, max_length=100)
|
||||
last_name: str = Field(..., min_length=1, max_length=100)
|
||||
role: UserRole = UserRole.VIEWER
|
||||
|
||||
|
||||
class TenantBase(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=255)
|
||||
slug: str = Field(..., min_length=1, max_length=100)
|
||||
description: Optional[str] = None
|
||||
|
||||
|
||||
# ─── Tenant ───────────────────────────────────────────
|
||||
|
||||
class TenantCreate(TenantBase):
|
||||
pass
|
||||
|
||||
|
||||
class TenantUpdate(BaseModel):
|
||||
name: Optional[str] = Field(None, min_length=1, max_length=255)
|
||||
description: Optional[str] = None
|
||||
|
||||
|
||||
class TenantOut(TenantBase):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: UUID
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
# ─── User ─────────────────────────────────────────────
|
||||
|
||||
class UserCreate(UserBase):
|
||||
password: Optional[str] = Field(None, min_length=8)
|
||||
tenant_id: Optional[UUID] = None
|
||||
|
||||
|
||||
class UserUpdate(BaseModel):
|
||||
email: Optional[EmailStr] = None
|
||||
first_name: Optional[str] = Field(None, min_length=1, max_length=100)
|
||||
last_name: Optional[str] = Field(None, min_length=1, max_length=100)
|
||||
role: Optional[UserRole] = None
|
||||
status: Optional[UserStatus] = None
|
||||
tenant_id: Optional[UUID] = None
|
||||
|
||||
|
||||
class UserOut(UserBase):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: UUID
|
||||
status: UserStatus
|
||||
is_superuser: bool
|
||||
last_login_at: Optional[datetime] = None
|
||||
tenant_id: Optional[UUID] = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class UserDetailOut(UserOut):
|
||||
tenant: Optional[TenantOut] = None
|
||||
|
||||
|
||||
class UserListOut(BaseModel):
|
||||
items: list[UserOut]
|
||||
total: int
|
||||
|
||||
|
||||
# ─── Auth ─────────────────────────────────────────────
|
||||
|
||||
class Token(BaseModel):
|
||||
access_token: str
|
||||
refresh_token: str
|
||||
token_type: str = "bearer"
|
||||
expires_in: int
|
||||
|
||||
|
||||
class TokenPayload(BaseModel):
|
||||
sub: Optional[str] = None
|
||||
type: Optional[str] = None
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
email: EmailStr
|
||||
password: str
|
||||
|
||||
|
||||
class RefreshRequest(BaseModel):
|
||||
refresh_token: str
|
||||
|
||||
|
||||
class ChangePasswordRequest(BaseModel):
|
||||
current_password: str
|
||||
new_password: str = Field(..., min_length=8)
|
||||
|
||||
|
||||
# ─── Invitation ───────────────────────────────────────
|
||||
|
||||
class InvitationCreate(BaseModel):
|
||||
email: EmailStr
|
||||
role: UserRole = UserRole.VIEWER
|
||||
tenant_id: Optional[UUID] = None
|
||||
|
||||
|
||||
class InvitationOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: UUID
|
||||
email: str
|
||||
role: UserRole
|
||||
token: str
|
||||
expires_at: datetime
|
||||
accepted_at: Optional[datetime] = None
|
||||
created_at: datetime
|
||||
invited_by_id: UUID
|
||||
tenant_id: Optional[UUID] = None
|
||||
|
||||
|
||||
class AcceptInvitationRequest(BaseModel):
|
||||
token: str
|
||||
first_name: str = Field(..., min_length=1, max_length=100)
|
||||
last_name: str = Field(..., min_length=1, max_length=100)
|
||||
password: str = Field(..., min_length=8)
|
||||
|
||||
|
||||
# ─── Password reset (future) ──────────────────────────
|
||||
|
||||
class PasswordResetRequest(BaseModel):
|
||||
email: EmailStr
|
||||
|
||||
|
||||
class PasswordResetConfirm(BaseModel):
|
||||
token: str
|
||||
new_password: str = Field(..., min_length=8)
|
||||
@@ -0,0 +1,81 @@
|
||||
"""Säkerhetsfunktioner: lösenordshashning, JWT-hantering, roller."""
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, Union
|
||||
from uuid import UUID
|
||||
|
||||
from jose import jwt, JWTError
|
||||
from passlib.context import CryptContext
|
||||
|
||||
from app.config import get_settings
|
||||
from app.models import UserRole
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
|
||||
|
||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||
return pwd_context.verify(plain_password, hashed_password)
|
||||
|
||||
|
||||
def get_password_hash(password: str) -> str:
|
||||
return pwd_context.hash(password)
|
||||
|
||||
|
||||
def create_access_token(subject: Union[str, UUID], expires_delta: Optional[timedelta] = None) -> str:
|
||||
if expires_delta:
|
||||
expire = datetime.utcnow() + expires_delta
|
||||
else:
|
||||
expire = datetime.utcnow() + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
to_encode = {"exp": expire, "sub": str(subject), "type": "access"}
|
||||
encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
|
||||
return encoded_jwt
|
||||
|
||||
|
||||
def create_refresh_token(subject: Union[str, UUID]) -> str:
|
||||
expire = datetime.utcnow() + timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS)
|
||||
to_encode = {"exp": expire, "sub": str(subject), "type": "refresh"}
|
||||
encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
|
||||
return encoded_jwt
|
||||
|
||||
|
||||
def decode_token(token: str) -> Optional[dict]:
|
||||
try:
|
||||
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
|
||||
return payload
|
||||
except JWTError:
|
||||
return None
|
||||
|
||||
|
||||
# ─── Role-based access helpers ────────────────────────
|
||||
|
||||
ROLE_HIERARCHY = {
|
||||
UserRole.VIEWER: 0,
|
||||
UserRole.ANALYST: 1,
|
||||
UserRole.MANAGER: 2,
|
||||
UserRole.ADMIN: 3,
|
||||
}
|
||||
|
||||
|
||||
def role_level(role: UserRole) -> int:
|
||||
return ROLE_HIERARCHY.get(role, 0)
|
||||
|
||||
|
||||
def can_manage(manager_role: UserRole, target_role: UserRole) -> bool:
|
||||
"""Returnerar True om manager_role har högre eller lika nivå som target_role."""
|
||||
return role_level(manager_role) >= role_level(target_role)
|
||||
|
||||
|
||||
def require_role(min_role: UserRole):
|
||||
"""Factory för roll-krav. Används i dependency-kedjan."""
|
||||
from fastapi import HTTPException, status
|
||||
|
||||
def checker(current_user_role: UserRole):
|
||||
if role_level(current_user_role) < role_level(min_role):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=f"Requires role {min_role.value} or higher",
|
||||
)
|
||||
return True
|
||||
|
||||
return checker
|
||||
@@ -0,0 +1 @@
|
||||
# Services package
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,155 @@
|
||||
"""Business logic för inbjudningsflödet."""
|
||||
import secrets
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func
|
||||
from fastapi import HTTPException, status
|
||||
|
||||
from app.models import Invitation, User, UserRole, UserStatus, Tenant
|
||||
from app.schemas import InvitationCreate, AcceptInvitationRequest
|
||||
from app.config import get_settings
|
||||
from app.security import get_password_hash, can_manage
|
||||
from app.services.user_service import UserService
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
class InvitationService:
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.db = db
|
||||
self.user_service = UserService(db)
|
||||
|
||||
async def get_by_token(self, token: str) -> Optional[Invitation]:
|
||||
result = await self.db.execute(select(Invitation).where(Invitation.token == token))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def list_invitations(
|
||||
self,
|
||||
tenant_id: Optional[UUID] = None,
|
||||
pending_only: bool = True,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
) -> tuple[list[Invitation], int]:
|
||||
query = select(Invitation)
|
||||
count_query = select(func.count()).select_from(Invitation)
|
||||
|
||||
if tenant_id:
|
||||
query = query.where(Invitation.tenant_id == tenant_id)
|
||||
count_query = count_query.where(Invitation.tenant_id == tenant_id)
|
||||
if pending_only:
|
||||
query = query.where(Invitation.accepted_at.is_(None))
|
||||
count_query = count_query.where(Invitation.accepted_at.is_(None))
|
||||
|
||||
total_result = await self.db.execute(count_query)
|
||||
total = total_result.scalar_one()
|
||||
|
||||
result = await self.db.execute(query.offset(skip).limit(limit).order_by(Invitation.created_at.desc()))
|
||||
return result.scalars().all(), total
|
||||
|
||||
async def create_invitation(self, data: InvitationCreate, invited_by: User) -> Invitation:
|
||||
# Validera att inviteraren kan ge denna roll
|
||||
if not invited_by.is_superuser and not can_manage(invited_by.role, data.role):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Cannot invite with role equal or higher than your own",
|
||||
)
|
||||
|
||||
# Kolla om användaren redan finns
|
||||
existing_user = await self.user_service.get_by_email(data.email)
|
||||
if existing_user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="User with this email already exists",
|
||||
)
|
||||
|
||||
# Kolla om det redan finns en aktiv inbjudan
|
||||
existing_invite = await self.db.execute(
|
||||
select(Invitation).where(
|
||||
Invitation.email == data.email.lower().strip(),
|
||||
Invitation.accepted_at.is_(None),
|
||||
Invitation.expires_at > datetime.utcnow(),
|
||||
)
|
||||
)
|
||||
if existing_invite.scalar_one_or_none():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="Active invitation already exists for this email",
|
||||
)
|
||||
|
||||
if data.tenant_id:
|
||||
tenant = await self.db.execute(select(Tenant).where(Tenant.id == data.tenant_id))
|
||||
if tenant.scalar_one_or_none() is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Tenant not found",
|
||||
)
|
||||
|
||||
token = secrets.token_urlsafe(48)
|
||||
invitation = Invitation(
|
||||
email=data.email.lower().strip(),
|
||||
token=token,
|
||||
role=data.role,
|
||||
tenant_id=data.tenant_id,
|
||||
invited_by_id=invited_by.id,
|
||||
expires_at=datetime.utcnow() + timedelta(hours=settings.INVITATION_TOKEN_EXPIRE_HOURS),
|
||||
)
|
||||
self.db.add(invitation)
|
||||
await self.db.commit()
|
||||
await self.db.refresh(invitation)
|
||||
return invitation
|
||||
|
||||
async def accept_invitation(self, data: AcceptInvitationRequest) -> User:
|
||||
invitation = await self.get_by_token(data.token)
|
||||
if invitation is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Invitation not found",
|
||||
)
|
||||
if invitation.accepted_at is not None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Invitation already accepted",
|
||||
)
|
||||
if invitation.expires_at < datetime.utcnow():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_410_GONE,
|
||||
detail="Invitation has expired",
|
||||
)
|
||||
|
||||
# Skapa användaren
|
||||
user = User(
|
||||
email=invitation.email,
|
||||
first_name=data.first_name.strip(),
|
||||
last_name=data.last_name.strip(),
|
||||
role=invitation.role,
|
||||
tenant_id=invitation.tenant_id,
|
||||
status=UserStatus.ACTIVE,
|
||||
hashed_password=get_password_hash(data.password),
|
||||
)
|
||||
self.db.add(user)
|
||||
|
||||
invitation.accepted_at = datetime.utcnow()
|
||||
await self.db.commit()
|
||||
await self.db.refresh(user)
|
||||
return user
|
||||
|
||||
async def revoke_invitation(self, invitation_id: UUID, actor: User) -> None:
|
||||
invitation = await self.db.execute(select(Invitation).where(Invitation.id == invitation_id))
|
||||
inv = invitation.scalar_one_or_none()
|
||||
if inv is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Invitation not found")
|
||||
|
||||
if not actor.is_superuser and inv.invited_by_id != actor.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Can only revoke invitations you sent",
|
||||
)
|
||||
|
||||
await self.db.delete(inv)
|
||||
await self.db.commit()
|
||||
|
||||
def get_invitation_link(self, token: str) -> str:
|
||||
return f"{settings.FRONTEND_SET_PASSWORD_URL}?token={token}"
|
||||
@@ -0,0 +1,71 @@
|
||||
"""Business logic för tenant-hantering."""
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func
|
||||
from fastapi import HTTPException, status
|
||||
|
||||
from app.models import Tenant
|
||||
from app.schemas import TenantCreate, TenantUpdate
|
||||
|
||||
|
||||
class TenantService:
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.db = db
|
||||
|
||||
async def get_by_id(self, tenant_id: UUID) -> Optional[Tenant]:
|
||||
result = await self.db.execute(select(Tenant).where(Tenant.id == tenant_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def get_by_slug(self, slug: str) -> Optional[Tenant]:
|
||||
result = await self.db.execute(select(Tenant).where(Tenant.slug == slug.lower().strip()))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def list_tenants(self, skip: int = 0, limit: int = 100) -> tuple[list[Tenant], int]:
|
||||
total_result = await self.db.execute(select(func.count()).select_from(Tenant))
|
||||
total = total_result.scalar_one()
|
||||
result = await self.db.execute(
|
||||
select(Tenant).offset(skip).limit(limit).order_by(Tenant.created_at.desc())
|
||||
)
|
||||
return result.scalars().all(), total
|
||||
|
||||
async def create_tenant(self, data: TenantCreate) -> Tenant:
|
||||
existing = await self.get_by_slug(data.slug)
|
||||
if existing:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="Tenant with this slug already exists",
|
||||
)
|
||||
|
||||
tenant = Tenant(
|
||||
name=data.name.strip(),
|
||||
slug=data.slug.lower().strip(),
|
||||
description=data.description.strip() if data.description else None,
|
||||
)
|
||||
self.db.add(tenant)
|
||||
await self.db.commit()
|
||||
await self.db.refresh(tenant)
|
||||
return tenant
|
||||
|
||||
async def update_tenant(self, tenant_id: UUID, data: TenantUpdate) -> Tenant:
|
||||
tenant = await self.get_by_id(tenant_id)
|
||||
if tenant is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tenant not found")
|
||||
|
||||
if data.name is not None:
|
||||
tenant.name = data.name.strip()
|
||||
if data.description is not None:
|
||||
tenant.description = data.description.strip() if data.description else None
|
||||
|
||||
await self.db.commit()
|
||||
await self.db.refresh(tenant)
|
||||
return tenant
|
||||
|
||||
async def delete_tenant(self, tenant_id: UUID) -> None:
|
||||
tenant = await self.get_by_id(tenant_id)
|
||||
if tenant is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tenant not found")
|
||||
|
||||
await self.db.delete(tenant)
|
||||
await self.db.commit()
|
||||
@@ -0,0 +1,166 @@
|
||||
"""Business logic för användarhantering."""
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func
|
||||
from fastapi import HTTPException, status
|
||||
|
||||
from app.models import User, UserRole, UserStatus, Tenant
|
||||
from app.schemas import UserCreate, UserUpdate
|
||||
from app.security import get_password_hash, verify_password, can_manage
|
||||
|
||||
|
||||
class UserService:
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.db = db
|
||||
|
||||
async def get_by_id(self, user_id: UUID) -> Optional[User]:
|
||||
result = await self.db.execute(select(User).where(User.id == user_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def get_by_email(self, email: str) -> Optional[User]:
|
||||
result = await self.db.execute(select(User).where(User.email == email.lower().strip()))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def list_users(
|
||||
self,
|
||||
tenant_id: Optional[UUID] = None,
|
||||
role: Optional[UserRole] = None,
|
||||
status: Optional[UserStatus] = None,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
) -> tuple[list[User], int]:
|
||||
query = select(User)
|
||||
count_query = select(func.count()).select_from(User)
|
||||
|
||||
if tenant_id:
|
||||
query = query.where(User.tenant_id == tenant_id)
|
||||
count_query = count_query.where(User.tenant_id == tenant_id)
|
||||
if role:
|
||||
query = query.where(User.role == role)
|
||||
count_query = count_query.where(User.role == role)
|
||||
if status:
|
||||
query = query.where(User.status == status)
|
||||
count_query = count_query.where(User.status == status)
|
||||
|
||||
total_result = await self.db.execute(count_query)
|
||||
total = total_result.scalar_one()
|
||||
|
||||
result = await self.db.execute(query.offset(skip).limit(limit).order_by(User.created_at.desc()))
|
||||
return result.scalars().all(), total
|
||||
|
||||
async def create_user(self, data: UserCreate, created_by: Optional[User] = None) -> User:
|
||||
existing = await self.get_by_email(data.email)
|
||||
if existing:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="User with this email already exists",
|
||||
)
|
||||
|
||||
if data.tenant_id:
|
||||
tenant = await self.db.execute(select(Tenant).where(Tenant.id == data.tenant_id))
|
||||
if tenant.scalar_one_or_none() is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Tenant not found",
|
||||
)
|
||||
|
||||
user = User(
|
||||
email=data.email.lower().strip(),
|
||||
first_name=data.first_name.strip(),
|
||||
last_name=data.last_name.strip(),
|
||||
role=data.role,
|
||||
tenant_id=data.tenant_id,
|
||||
status=UserStatus.ACTIVE if data.password else UserStatus.PENDING,
|
||||
hashed_password=get_password_hash(data.password) if data.password else None,
|
||||
)
|
||||
self.db.add(user)
|
||||
await self.db.commit()
|
||||
await self.db.refresh(user)
|
||||
return user
|
||||
|
||||
async def update_user(self, user_id: UUID, data: UserUpdate, actor: User) -> User:
|
||||
user = await self.get_by_id(user_id)
|
||||
if user is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
|
||||
|
||||
if not actor.is_superuser and actor.id != user_id:
|
||||
if not can_manage(actor.role, user.role):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Cannot modify user with equal or higher role",
|
||||
)
|
||||
|
||||
if data.email and data.email.lower().strip() != user.email:
|
||||
existing = await self.get_by_email(data.email)
|
||||
if existing:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="Email already in use",
|
||||
)
|
||||
user.email = data.email.lower().strip()
|
||||
|
||||
if data.first_name is not None:
|
||||
user.first_name = data.first_name.strip()
|
||||
if data.last_name is not None:
|
||||
user.last_name = data.last_name.strip()
|
||||
if data.role is not None:
|
||||
if not actor.is_superuser and not can_manage(actor.role, data.role):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Cannot assign role equal or higher than your own",
|
||||
)
|
||||
user.role = data.role
|
||||
if data.status is not None:
|
||||
user.status = data.status
|
||||
if data.tenant_id is not None:
|
||||
user.tenant_id = data.tenant_id
|
||||
|
||||
await self.db.commit()
|
||||
await self.db.refresh(user)
|
||||
return user
|
||||
|
||||
async def delete_user(self, user_id: UUID, actor: User) -> None:
|
||||
user = await self.get_by_id(user_id)
|
||||
if user is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
|
||||
|
||||
if not actor.is_superuser:
|
||||
if not can_manage(actor.role, user.role):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Cannot delete user with equal or higher role",
|
||||
)
|
||||
if actor.id == user_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Cannot delete yourself",
|
||||
)
|
||||
|
||||
await self.db.delete(user)
|
||||
await self.db.commit()
|
||||
|
||||
async def authenticate(self, email: str, password: str) -> Optional[User]:
|
||||
user = await self.get_by_email(email)
|
||||
if not user or not user.hashed_password:
|
||||
return None
|
||||
if not verify_password(password, user.hashed_password):
|
||||
return None
|
||||
if user.status != UserStatus.ACTIVE:
|
||||
return None
|
||||
return user
|
||||
|
||||
async def set_password(self, user: User, password: str) -> None:
|
||||
user.hashed_password = get_password_hash(password)
|
||||
user.status = UserStatus.ACTIVE
|
||||
await self.db.commit()
|
||||
|
||||
async def change_password(self, user: User, current_password: str, new_password: str) -> None:
|
||||
if not verify_password(current_password, user.hashed_password):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Current password is incorrect",
|
||||
)
|
||||
user.hashed_password = get_password_hash(new_password)
|
||||
await self.db.commit()
|
||||
Reference in New Issue
Block a user