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:
Bernt
2026-07-05 06:41:32 +00:00
parent f4f853d94b
commit aee0f09db8
19583 changed files with 1450867 additions and 1153 deletions
+17
View File
@@ -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
+16
View File
@@ -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"]
+110
View File
@@ -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.
+40
View File
@@ -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
+61
View File
@@ -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)
+33
View File
@@ -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()
+30
View File
@@ -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)
+44
View File
@@ -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"}
+84
View File
@@ -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
@@ -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
+147
View File
@@ -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)
+81
View File
@@ -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
@@ -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()
+14
View File
@@ -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
+97
View File
@@ -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