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:
@@ -0,0 +1,17 @@
|
||||
# LandveX User Management — Environment Variables
|
||||
|
||||
# Database
|
||||
DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/landvex
|
||||
|
||||
# JWT
|
||||
SECRET_KEY=change-me-in-production-landvex-secret-key-2024
|
||||
ALGORITHM=HS256
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES=30
|
||||
REFRESH_TOKEN_EXPIRE_DAYS=7
|
||||
|
||||
# Invitations
|
||||
INVITATION_TOKEN_EXPIRE_HOURS=48
|
||||
FRONTEND_SET_PASSWORD_URL=https://admin.landvex.se/set-password
|
||||
|
||||
# Password policy
|
||||
MIN_PASSWORD_LENGTH=8
|
||||
@@ -0,0 +1,16 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends gcc libpq-dev && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY app/ ./app/
|
||||
COPY alembic/ ./alembic/
|
||||
COPY alembic.ini .
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
@@ -0,0 +1,110 @@
|
||||
# LandveX User Management API
|
||||
|
||||
FastAPI-baserad adminbackend för användarhantering med PostgreSQL.
|
||||
|
||||
## Funktioner
|
||||
|
||||
- **CRUD för användare** — skapa, läsa, uppdatera, ta bort
|
||||
- **Roller** — `admin`, `manager`, `analyst`, `viewer` med hierarkisk åtkomstkontroll
|
||||
- **JWT-auth** — access token + refresh token
|
||||
- **Lösenordshantering** — bcrypt-hashning, lösenordsbyte
|
||||
- **Inbjudningsflöde** — skicka inbjudan → acceptera via token → sätt lösenord
|
||||
- **Tenant-stöd** — flerklientsisolering
|
||||
- **Alembic-migrationer** — databasversionshantering
|
||||
|
||||
## Snabbstart
|
||||
|
||||
```bash
|
||||
cd landvex/user-management
|
||||
python -m venv .venv
|
||||
source .venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
|
||||
# Kopiera env-fil
|
||||
cp .env.example .env
|
||||
|
||||
# Kör migrationer
|
||||
alembic upgrade head
|
||||
|
||||
# Starta server
|
||||
uvicorn app.main:app --reload
|
||||
```
|
||||
|
||||
## API-endpoints
|
||||
|
||||
| Metod | Endpoint | Beskrivning | Rollkrav |
|
||||
|-------|----------|-------------|----------|
|
||||
| POST | `/api/v1/auth/login` | Logga in | Öppen |
|
||||
| POST | `/api/v1/auth/refresh` | Förnya access token | Öppen |
|
||||
| GET | `/api/v1/auth/me` | Aktuell användare | Inloggad |
|
||||
| POST | `/api/v1/auth/change-password` | Byt lösenord | Inloggad |
|
||||
| GET | `/api/v1/users` | Lista användare | Manager+ |
|
||||
| GET | `/api/v1/users/{id}` | Hämta användare | Inloggad |
|
||||
| POST | `/api/v1/users` | Skapa användare | Admin+ |
|
||||
| PATCH | `/api/v1/users/{id}` | Uppdatera användare | Inloggad (egen eller lägre roll) |
|
||||
| DELETE | `/api/v1/users/{id}` | Ta bort användare | Admin+ |
|
||||
| GET | `/api/v1/invitations` | Lista inbjudningar | Manager+ |
|
||||
| POST | `/api/v1/invitations` | Skapa inbjudan | Admin+ |
|
||||
| GET | `/api/v1/invitations/validate/{token}` | Validera inbjudan | Öppen |
|
||||
| POST | `/api/v1/invitations/accept` | Acceptera inbjudan | Öppen |
|
||||
| DELETE | `/api/v1/invitations/{id}` | Återkalla inbjudan | Admin+ |
|
||||
| GET | `/api/v1/tenants` | Lista tenants | Admin+ |
|
||||
| GET | `/api/v1/tenants/{id}` | Hämta tenant | Admin+ |
|
||||
| POST | `/api/v1/tenants` | Skapa tenant | Admin+ |
|
||||
| PATCH | `/api/v1/tenants/{id}` | Uppdatera tenant | Admin+ |
|
||||
| DELETE | `/api/v1/tenants/{id}` | Ta bort tenant | Admin+ |
|
||||
|
||||
## Tester
|
||||
|
||||
```bash
|
||||
pytest tests/ -v
|
||||
```
|
||||
|
||||
## Docker
|
||||
|
||||
```bash
|
||||
docker build -t landvex-user-management .
|
||||
docker run -p 8000:8000 --env-file .env landvex-user-management
|
||||
```
|
||||
|
||||
## Arkitektur
|
||||
|
||||
```
|
||||
app/
|
||||
├── config.py # Inställningar (pydantic-settings)
|
||||
├── database.py # SQLAlchemy async engine + session
|
||||
├── models.py # SQLAlchemy-modeller (User, Tenant, Invitation)
|
||||
├── schemas.py # Pydantic request/response-scheman
|
||||
├── security.py # JWT, bcrypt, roll-hierarki
|
||||
├── dependencies.py # FastAPI dependencies (get_current_user, RoleChecker)
|
||||
├── main.py # FastAPI-app med routers
|
||||
├── routers/
|
||||
│ ├── auth.py # Login, refresh, change-password, me
|
||||
│ ├── users.py # User CRUD
|
||||
│ ├── invitations.py # Invitation-flöde
|
||||
│ └── tenants.py # Tenant CRUD
|
||||
└── services/
|
||||
├── user_service.py # Business logic: användare
|
||||
├── invitation_service.py # Business logic: inbjudningar
|
||||
└── tenant_service.py # Business logic: tenants
|
||||
|
||||
alembic/
|
||||
└── versions/ # Databas-migrationer
|
||||
|
||||
tests/
|
||||
├── conftest.py # Fixtures
|
||||
├── test_auth.py
|
||||
├── test_users.py
|
||||
└── test_invitations.py
|
||||
```
|
||||
|
||||
## Roll-hierarki
|
||||
|
||||
| Roll | Nivå | Kan hantera |
|
||||
|------|------|-------------|
|
||||
| admin | 3 | Alla roller |
|
||||
| manager | 2 | analyst, viewer |
|
||||
| analyst | 1 | viewer |
|
||||
| viewer | 0 | — |
|
||||
|
||||
En användare kan inte tilldela eller modifiera en användare med samma eller högre roll.
|
||||
@@ -0,0 +1,40 @@
|
||||
[alembic]
|
||||
script_location = alembic
|
||||
prepend_sys_path = .
|
||||
version_path_separator = os
|
||||
|
||||
[post_write_hooks]
|
||||
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARN
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARN
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
||||
@@ -0,0 +1,61 @@
|
||||
import asyncio
|
||||
from logging.config import fileConfig
|
||||
|
||||
from sqlalchemy import pool
|
||||
from sqlalchemy.engine import Connection
|
||||
from sqlalchemy.ext.asyncio import async_engine_from_config
|
||||
|
||||
from alembic import context
|
||||
|
||||
from app.config import get_settings
|
||||
from app.database import Base
|
||||
from app.models import User, Tenant, Invitation # noqa: F401 — säkerställ att modeller registreras
|
||||
|
||||
settings = get_settings()
|
||||
config = context.config
|
||||
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
target_metadata = Base.metadata
|
||||
|
||||
config.set_main_option("sqlalchemy.url", settings.DATABASE_URL)
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
url = config.get_main_option("sqlalchemy.url")
|
||||
context.configure(
|
||||
url=url,
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
dialect_opts={"paramstyle": "named"},
|
||||
)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def do_run_migrations(connection: Connection) -> None:
|
||||
context.configure(connection=connection, target_metadata=target_metadata)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
async def run_async_migrations() -> None:
|
||||
connectable = async_engine_from_config(
|
||||
config.get_section(config.config_ini_section, {}),
|
||||
prefix="sqlalchemy.",
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
async with connectable.connect() as connection:
|
||||
await connection.run_sync(do_run_migrations)
|
||||
await connectable.dispose()
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
asyncio.run(run_async_migrations())
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
@@ -0,0 +1,26 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = ${repr(up_revision)}
|
||||
down_revision: Union[str, None] = ${repr(down_revision)}
|
||||
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
${downgrades if downgrades else "pass"}
|
||||
@@ -0,0 +1,95 @@
|
||||
"""Init user management schema
|
||||
|
||||
Revision ID: 001
|
||||
Revises:
|
||||
Create Date: 2024-07-03 03:47:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "001"
|
||||
down_revision: Union[str, None] = None
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Enum-typer
|
||||
userrole = postgresql.ENUM("admin", "manager", "analyst", "viewer", name="userrole")
|
||||
userstatus = postgresql.ENUM("active", "inactive", "pending", name="userstatus")
|
||||
|
||||
userrole.create(op.get_bind(), checkfirst=True)
|
||||
userstatus.create(op.get_bind(), checkfirst=True)
|
||||
|
||||
# Tenants
|
||||
op.create_table(
|
||||
"tenants",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), server_default=sa.text("gen_random_uuid()"), nullable=False),
|
||||
sa.Column("name", sa.String(length=255), nullable=False),
|
||||
sa.Column("slug", sa.String(length=100), nullable=False),
|
||||
sa.Column("description", sa.Text(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=True),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=True),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("slug"),
|
||||
)
|
||||
op.create_index("ix_tenants_slug", "tenants", ["slug"], unique=True)
|
||||
|
||||
# Users
|
||||
op.create_table(
|
||||
"users",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), server_default=sa.text("gen_random_uuid()"), nullable=False),
|
||||
sa.Column("email", sa.String(length=255), nullable=False),
|
||||
sa.Column("hashed_password", sa.String(length=255), nullable=True),
|
||||
sa.Column("first_name", sa.String(length=100), nullable=False),
|
||||
sa.Column("last_name", sa.String(length=100), nullable=False),
|
||||
sa.Column("role", sa.Enum("admin", "manager", "analyst", "viewer", name="userrole"), nullable=False),
|
||||
sa.Column("status", sa.Enum("active", "inactive", "pending", name="userstatus"), nullable=False),
|
||||
sa.Column("is_superuser", sa.Boolean(), nullable=True),
|
||||
sa.Column("last_login_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=True),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=True),
|
||||
sa.Column("tenant_id", postgresql.UUID(as_uuid=True), nullable=True),
|
||||
sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"]),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("email"),
|
||||
)
|
||||
op.create_index("ix_users_email", "users", ["email"], unique=True)
|
||||
|
||||
# Invitations
|
||||
op.create_table(
|
||||
"invitations",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), server_default=sa.text("gen_random_uuid()"), nullable=False),
|
||||
sa.Column("email", sa.String(length=255), nullable=False),
|
||||
sa.Column("token", sa.String(length=255), nullable=False),
|
||||
sa.Column("role", sa.Enum("admin", "manager", "analyst", "viewer", name="userrole"), nullable=False),
|
||||
sa.Column("tenant_id", postgresql.UUID(as_uuid=True), nullable=True),
|
||||
sa.Column("invited_by_id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("accepted_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=True),
|
||||
sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"]),
|
||||
sa.ForeignKeyConstraint(["invited_by_id"], ["users.id"]),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("token"),
|
||||
)
|
||||
op.create_index("ix_invitations_email", "invitations", ["email"], unique=False)
|
||||
op.create_index("ix_invitations_token", "invitations", ["token"], unique=True)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_invitations_token", table_name="invitations")
|
||||
op.drop_index("ix_invitations_email", table_name="invitations")
|
||||
op.drop_table("invitations")
|
||||
op.drop_index("ix_users_email", table_name="users")
|
||||
op.drop_table("users")
|
||||
op.drop_index("ix_tenants_slug", table_name="tenants")
|
||||
op.drop_table("tenants")
|
||||
|
||||
postgresql.ENUM(name="userrole").drop(op.get_bind(), checkfirst=True)
|
||||
postgresql.ENUM(name="userstatus").drop(op.get_bind(), checkfirst=True)
|
||||
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()
|
||||
@@ -0,0 +1,14 @@
|
||||
fastapi==0.111.0
|
||||
uvicorn[standard]==0.30.1
|
||||
sqlalchemy==2.0.31
|
||||
asyncpg==0.29.0
|
||||
alembic==1.13.2
|
||||
pydantic==2.7.4
|
||||
pydantic-settings==2.3.4
|
||||
passlib[bcrypt]==1.7.4
|
||||
python-jose[cryptography]==3.3.0
|
||||
python-multipart==0.0.9
|
||||
email-validator==2.1.1
|
||||
httpx==0.27.0
|
||||
pytest==8.2.2
|
||||
pytest-asyncio==0.23.7
|
||||
@@ -0,0 +1 @@
|
||||
# Tests package
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,97 @@
|
||||
"""Pytest fixtures."""
|
||||
import asyncio
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
import pytest_asyncio
|
||||
from httpx import AsyncClient
|
||||
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession
|
||||
|
||||
from app.main import app
|
||||
from app.database import get_db, Base
|
||||
from app.models import User, Tenant, UserRole, UserStatus
|
||||
from app.security import get_password_hash
|
||||
|
||||
TEST_DATABASE_URL = "postgresql+asyncpg://postgres:postgres@localhost:5432/landvex_test"
|
||||
|
||||
engine = create_async_engine(TEST_DATABASE_URL, echo=False, future=True)
|
||||
AsyncTestingSessionLocal = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||
|
||||
|
||||
async def override_get_db():
|
||||
async with AsyncTestingSessionLocal() as session:
|
||||
yield session
|
||||
|
||||
|
||||
app.dependency_overrides[get_db] = override_get_db
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(scope="session")
|
||||
def event_loop():
|
||||
loop = asyncio.get_event_loop_policy().new_event_loop()
|
||||
yield loop
|
||||
loop.close()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(scope="session", autouse=True)
|
||||
async def setup_database():
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.drop_all)
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
yield
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.drop_all)
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def db_session() -> AsyncSession:
|
||||
async with AsyncTestingSessionLocal() as session:
|
||||
yield session
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def client() -> AsyncClient:
|
||||
async with AsyncClient(app=app, base_url="http://test") as ac:
|
||||
yield ac
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def test_tenant(db_session: AsyncSession) -> Tenant:
|
||||
tenant = Tenant(
|
||||
id=uuid.uuid4(),
|
||||
name="Test Kommun",
|
||||
slug="test-kommun",
|
||||
)
|
||||
db_session.add(tenant)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(tenant)
|
||||
return tenant
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def test_user(db_session: AsyncSession, test_tenant: Tenant) -> User:
|
||||
user = User(
|
||||
id=uuid.uuid4(),
|
||||
email="admin@landvex.se",
|
||||
first_name="Admin",
|
||||
last_name="User",
|
||||
hashed_password=get_password_hash("password123"),
|
||||
role=UserRole.ADMIN,
|
||||
status=UserStatus.ACTIVE,
|
||||
tenant_id=test_tenant.id,
|
||||
)
|
||||
db_session.add(user)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def admin_token(client: AsyncClient, test_user: User) -> str:
|
||||
response = await client.post("/api/v1/auth/login", json={
|
||||
"email": test_user.email,
|
||||
"password": "password123",
|
||||
})
|
||||
data = response.json()
|
||||
return data["access_token"]
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Tester för auth-endpoints."""
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
from app.models import User
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_login_success(client: AsyncClient, test_user: User):
|
||||
response = await client.post("/api/v1/auth/login", json={
|
||||
"email": test_user.email,
|
||||
"password": "password123",
|
||||
})
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "access_token" in data
|
||||
assert "refresh_token" in data
|
||||
assert data["token_type"] == "bearer"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_login_wrong_password(client: AsyncClient, test_user: User):
|
||||
response = await client.post("/api/v1/auth/login", json={
|
||||
"email": test_user.email,
|
||||
"password": "wrongpassword",
|
||||
})
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_me_endpoint(client: AsyncClient, admin_token: str, test_user: User):
|
||||
response = await client.get("/api/v1/auth/me", headers={"Authorization": f"Bearer {admin_token}"})
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["email"] == test_user.email
|
||||
assert data["role"] == "admin"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_refresh_token(client: AsyncClient, admin_token: str):
|
||||
# Hämta refresh token via login
|
||||
response = await client.post("/api/v1/auth/login", json={
|
||||
"email": "admin@landvex.se",
|
||||
"password": "password123",
|
||||
})
|
||||
refresh = response.json()["refresh_token"]
|
||||
|
||||
response = await client.post("/api/v1/auth/refresh", json={"refresh_token": refresh})
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "access_token" in data
|
||||
@@ -0,0 +1,65 @@
|
||||
"""Tester för invitation-flödet."""
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
from app.models import User
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_invitation(client: AsyncClient, admin_token: str):
|
||||
response = await client.post("/api/v1/invitations", headers={"Authorization": f"Bearer {admin_token}"}, json={
|
||||
"email": "invited@landvex.se",
|
||||
"role": "viewer",
|
||||
})
|
||||
assert response.status_code == 201
|
||||
data = response.json()
|
||||
assert data["email"] == "invited@landvex.se"
|
||||
assert data["role"] == "viewer"
|
||||
assert "token" in data
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_validate_invitation(client: AsyncClient, admin_token: str):
|
||||
# Skapa inbjudan
|
||||
response = await client.post("/api/v1/invitations", headers={"Authorization": f"Bearer {admin_token}"}, json={
|
||||
"email": "validate@landvex.se",
|
||||
"role": "analyst",
|
||||
})
|
||||
token = response.json()["token"]
|
||||
|
||||
response = await client.get(f"/api/v1/invitations/validate/{token}")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["valid"] is True
|
||||
assert data["email"] == "validate@landvex.se"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_accept_invitation(client: AsyncClient, admin_token: str):
|
||||
# Skapa inbjudan
|
||||
response = await client.post("/api/v1/invitations", headers={"Authorization": f"Bearer {admin_token}"}, json={
|
||||
"email": "accept@landvex.se",
|
||||
"role": "manager",
|
||||
})
|
||||
token = response.json()["token"]
|
||||
|
||||
response = await client.post("/api/v1/invitations/accept", json={
|
||||
"token": token,
|
||||
"first_name": "Accepted",
|
||||
"last_name": "User",
|
||||
"password": "newpassword123",
|
||||
})
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["email"] == "accept@landvex.se"
|
||||
assert data["role"] == "manager"
|
||||
assert data["status"] == "active"
|
||||
|
||||
# Försök acceptera igen — ska misslyckas
|
||||
response = await client.post("/api/v1/invitations/accept", json={
|
||||
"token": token,
|
||||
"first_name": "Accepted",
|
||||
"last_name": "User",
|
||||
"password": "newpassword123",
|
||||
})
|
||||
assert response.status_code == 400
|
||||
@@ -0,0 +1,73 @@
|
||||
"""Tester för user CRUD."""
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
from app.models import User, Tenant
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_user(client: AsyncClient, admin_token: str, test_tenant: Tenant):
|
||||
response = await client.post("/api/v1/users", headers={"Authorization": f"Bearer {admin_token}"}, json={
|
||||
"email": "newuser@landvex.se",
|
||||
"first_name": "New",
|
||||
"last_name": "User",
|
||||
"password": "securepass123",
|
||||
"role": "analyst",
|
||||
"tenant_id": str(test_tenant.id),
|
||||
})
|
||||
assert response.status_code == 201
|
||||
data = response.json()
|
||||
assert data["email"] == "newuser@landvex.se"
|
||||
assert data["role"] == "analyst"
|
||||
assert data["status"] == "active"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_users(client: AsyncClient, admin_token: str):
|
||||
response = await client.get("/api/v1/users", headers={"Authorization": f"Bearer {admin_token}"})
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "items" in data
|
||||
assert "total" in data
|
||||
assert len(data["items"]) >= 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_user(client: AsyncClient, admin_token: str, test_user: User):
|
||||
response = await client.get(f"/api/v1/users/{test_user.id}", headers={"Authorization": f"Bearer {admin_token}"})
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["email"] == test_user.email
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_user(client: AsyncClient, admin_token: str, test_user: User):
|
||||
response = await client.patch(f"/api/v1/users/{test_user.id}", headers={"Authorization": f"Bearer {admin_token}"}, json={
|
||||
"first_name": "Updated",
|
||||
})
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["first_name"] == "Updated"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_user(client: AsyncClient, admin_token: str, test_tenant: Tenant, db_session):
|
||||
# Skapa en användare att ta bort
|
||||
from app.models import User, UserRole, UserStatus
|
||||
from app.security import get_password_hash
|
||||
import uuid
|
||||
user = User(
|
||||
id=uuid.uuid4(),
|
||||
email="delete-me@landvex.se",
|
||||
first_name="Delete",
|
||||
last_name="Me",
|
||||
hashed_password=get_password_hash("password123"),
|
||||
role=UserRole.VIEWER,
|
||||
status=UserStatus.ACTIVE,
|
||||
tenant_id=test_tenant.id,
|
||||
)
|
||||
db_session.add(user)
|
||||
await db_session.commit()
|
||||
|
||||
response = await client.delete(f"/api/v1/users/{user.id}", headers={"Authorization": f"Bearer {admin_token}"})
|
||||
assert response.status_code == 204
|
||||
Reference in New Issue
Block a user