Chore: moving changes - migrating Desktop from nobara 42 to windows(WSL)

This commit is contained in:
2025-11-05 22:29:28 +02:00
parent c086f64363
commit 934d8fc35f
8 changed files with 826 additions and 63 deletions
@@ -0,0 +1,40 @@
"""add_user_approval_system
Revision ID: 4c0d2503877e
Revises: 997376dc1774
Create Date: 2025-09-28 11:55:11.997364
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '4c0d2503877e'
down_revision: Union[str, Sequence[str], None] = '997376dc1774'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
# Add column as nullable first
op.add_column('user', sa.Column('is_approved', sa.Boolean(), nullable=True))
# Set default value for existing users - approve all existing users by default
# (they were created before the approval system, so they should be grandfathered in)
op.execute("UPDATE \"user\" SET is_approved = true")
# Make column not nullable
op.alter_column('user', 'is_approved', nullable=False)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('user', 'is_approved')
# ### end Alembic commands ###
+135 -20
View File
@@ -1,6 +1,7 @@
# backend/app/api/v1/users.py
from datetime import timedelta
from fastapi import APIRouter, Depends, HTTPException, status
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, status, Request
from fastapi.security import HTTPBearer
from sqlmodel import Session, select
from app.core.db import get_session
@@ -12,7 +13,9 @@ from app.core.auth import (
get_token_expiration_minutes,
require_admin,
require_write_access,
require_any_access
require_any_access,
verify_password,
send_password_reset_email
)
from app.schemas.models import User
from app.schemas.schemas import (
@@ -20,7 +23,10 @@ from app.schemas.schemas import (
UserUpdate,
UserLogin,
Token,
UserResponse
UserResponse,
UserApprovalUpdate,
PasswordChangeRequest,
EmailVerificationRequest
)
router = APIRouter(prefix="/users", tags=["users"])
@@ -29,11 +35,23 @@ router = APIRouter(prefix="/users", tags=["users"])
@router.post("/login", response_model=Token)
def login(user_credentials: UserLogin, session: Session = Depends(get_session)):
"""Authenticate user and return JWT token with role-based expiration."""
user = authenticate_user(session, user_credentials.username, user_credentials.password)
user, error_message = authenticate_user(session, user_credentials.username, user_credentials.password)
if not user:
error_details = {
"user_not_found": "Username not found",
"invalid_password": "Incorrect password",
"account_pending_approval": "Account pending admin approval. Please contact an administrator."
}
# Use different status codes for different error types
status_code = status.HTTP_401_UNAUTHORIZED
if error_message == "account_pending_approval":
status_code = status.HTTP_403_FORBIDDEN
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",
status_code=status_code,
detail=error_details.get(error_message, "Authentication failed"),
headers={"WWW-Authenticate": "Bearer"},
)
@@ -58,7 +76,8 @@ def login(user_credentials: UserLogin, session: Session = Depends(get_session)):
user=UserResponse(
id=user.id,
username=user.username,
role=user.role
role=user.role,
is_approved=user.is_approved
)
)
@@ -69,22 +88,23 @@ def get_current_user_info(current_user: User = Depends(get_current_active_user))
return UserResponse(
id=current_user.id,
username=current_user.username,
role=current_user.role
role=current_user.role,
is_approved=current_user.is_approved
)
@router.get("/", response_model=list[UserResponse])
def get_all_users(
session: Session = Depends(get_session),
current_user: User = Depends(require_any_access),
current_user: User = Depends(require_admin),
skip: int = 0,
limit: int = 100
):
"""Get all users (requires any authenticated role)."""
"""Get all users (requires admin authenticated role)."""
statement = select(User).offset(skip).limit(limit)
users = session.exec(statement).all()
return [
UserResponse(id=user.id, username=user.username, role=user.role)
UserResponse(id=user.id, username=user.username, role=user.role, is_approved=user.is_approved)
for user in users
]
@@ -92,10 +112,9 @@ def get_all_users(
@router.post("/", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
def create_user(
user: UserCreate,
session: Session = Depends(get_session),
current_user: User = Depends(require_admin)
session: Session = Depends(get_session)
):
"""Create a new user (admin only)."""
"""Create a new user (public registration - requires admin approval to login)."""
# Check if username already exists
statement = select(User).where(User.username == user.username)
existing_user = session.exec(statement).first()
@@ -105,12 +124,13 @@ def create_user(
detail="Username already registered"
)
# Create new user with hashed password
# Create new user with hashed password (not approved by default)
hashed_password = get_password_hash(user.password)
db_user = User(
username=user.username,
password_hash=hashed_password,
role=user.role
role=user.role,
is_approved=False # Requires admin approval
)
session.add(db_user)
@@ -120,7 +140,8 @@ def create_user(
return UserResponse(
id=db_user.id,
username=db_user.username,
role=db_user.role
role=db_user.role,
is_approved=db_user.is_approved
)
@@ -141,7 +162,8 @@ def get_user(
return UserResponse(
id=user.id,
username=user.username,
role=user.role
role=user.role,
is_approved=user.is_approved
)
@@ -161,8 +183,26 @@ def update_user(
detail="User not found"
)
# Update only provided fields
# Get update data
update_data = user_update.model_dump(exclude_unset=True)
# Check for duplicate username if username is being updated
if "username" in update_data and update_data["username"] != user.username:
statement = select(User).where(User.username == update_data["username"])
existing_user = session.exec(statement).first()
if existing_user:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Username already exists"
)
# Handle password hashing if password is being updated
if "password" in update_data:
hashed_password = get_password_hash(update_data["password"])
update_data["password_hash"] = hashed_password
del update_data["password"] # Remove plain text password
# Update only provided fields
for key, value in update_data.items():
setattr(user, key, value)
@@ -173,7 +213,38 @@ def update_user(
return UserResponse(
id=user.id,
username=user.username,
role=user.role
role=user.role,
is_approved=user.is_approved
)
@router.put("/{user_id}/approval", response_model=UserResponse)
def update_user_approval(
user_id: int,
approval_update: UserApprovalUpdate,
session: Session = Depends(get_session),
current_user: User = Depends(require_admin)
):
"""Approve or reject a user account (admin only)."""
user = session.get(User, user_id)
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found"
)
# Update approval status
user.is_approved = approval_update.is_approved
session.add(user)
session.commit()
session.refresh(user)
return UserResponse(
id=user.id,
username=user.username,
role=user.role,
is_approved=user.is_approved
)
@@ -201,3 +272,47 @@ def delete_user(
session.delete(user)
session.commit()
return None
@router.put("/me/change-password")
def change_password(
password_change: PasswordChangeRequest,
session: Session = Depends(get_session),
current_user: User = Depends(get_current_active_user)
):
"""Change password (user must know current password)."""
# Verify current password
if not verify_password(password_change.current_password, current_user.password_hash):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Current password is incorrect"
)
# Update to new password
hashed_password = get_password_hash(password_change.new_password)
current_user.password_hash = hashed_password
session.add(current_user)
session.commit()
return {"message": "Password changed successfully"}
@router.post("/request-password-reset")
def request_password_reset(
reset_request: EmailVerificationRequest,
session: Session = Depends(get_session)
):
"""Request password reset via email verification (no database needed)."""
# Find user by username
statement = select(User).where(User.username == reset_request.username)
user = session.exec(statement).first()
# Always return success to prevent username enumeration
if user and user.is_approved:
# Send email with instructions (mock implementation)
send_password_reset_email(reset_request.username, reset_request.email)
return {
"message": "If your username and email are correct, you will receive instructions to reset your password."
}
+34 -6
View File
@@ -3,9 +3,11 @@ Authentication utilities for JWT-based session management with role-based expira
"""
from datetime import datetime, timedelta, timezone
from typing import Optional, Union
import secrets
import hashlib
from jose import JWTError, jwt
from passlib.context import CryptContext
from fastapi import Depends, HTTPException, status
from fastapi import Depends, HTTPException, status, Request
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from sqlmodel import Session, select
from app.core.config import settings
@@ -35,15 +37,27 @@ def authenticate_user(
session: Session,
username: str,
password: str
) -> Optional[User]:
"""Authenticate user with username and password."""
) -> tuple[Optional[User], str]:
"""Authenticate user with username and password.
Returns:
tuple: (User object or None, error_message)
error_message values:
- "success" if authentication successful
- "user_not_found" if username doesn't exist
- "invalid_password" if password is incorrect
- "account_pending_approval" if user exists but not approved
"""
statement = select(User).where(User.username == username)
user = session.exec(statement).first()
if not user:
return None
return None, "user_not_found"
if not verify_password(password, user.password_hash):
return None
return user
return None, "invalid_password"
# Check if user is approved
if not user.is_approved:
return None, "account_pending_approval"
return user, "success"
def get_token_expiration_minutes(role: UserRole) -> int:
@@ -138,3 +152,17 @@ def require_role(required_roles: list[UserRole]):
require_admin = require_role([UserRole.ADMIN])
require_write_access = require_role([UserRole.ADMIN, UserRole.WRITE])
require_any_access = require_role([UserRole.ADMIN, UserRole.WRITE, UserRole.READ_ONLY])
def send_password_reset_email(username: str, email: str) -> bool:
"""Send password reset instructions via email (mock implementation)."""
# In a real application, you would:
# 1. Verify the email belongs to the username
# 2. Send an email with instructions to reset password
# 3. The email would contain a link to your frontend with instructions
print(f"Mock: Sending password reset email to {email} for user {username}")
print("Instructions: Please contact your system administrator to reset your password.")
# Return True to indicate email was "sent"
return True
+5
View File
@@ -29,12 +29,14 @@ class User(SQLModel, table=True):
username (str): Unique user name (max 100 chars).
role (UserRole): User role (default READ_ONLY).
password_hash (str): Hashed password.
is_approved (bool): Whether user is approved by admin (default False).
"""
id: Optional[int] = Field(default=None, primary_key=True)
username: str = Field(nullable=False,unique=True, max_length=100)
role: UserRole = Field(nullable=False, max_length= 10, default=UserRole.READ_ONLY)
password_hash: str = Field(nullable=False)
is_approved: bool = Field(nullable=False, default=False)
class Partner(SQLModel, table=True):
@@ -267,3 +269,6 @@ class Inventory(SQLModel, table=True):
product_id: int = Field(nullable=False, unique=True, foreign_key="product.id")
total_qty: int = Field(nullable=False, default=0)
+18
View File
@@ -31,6 +31,7 @@ class UserResponse(SQLModel):
id: Optional[int] = None
username: str
role: UserRole
is_approved: bool
class Token(SQLModel):
@@ -46,6 +47,23 @@ class TokenData(SQLModel):
role: Optional[UserRole] = None
class UserApprovalUpdate(SQLModel):
"""Schema for admin to approve/reject user accounts."""
is_approved: bool
class PasswordChangeRequest(SQLModel):
"""Schema for changing password (user knows current password)."""
current_password: str
new_password: str
class EmailVerificationRequest(SQLModel):
"""Schema for requesting email verification for password reset."""
username: str
email: str # User provides their email for verification
##################################################
# Transactions
class TransactionBase(SQLModel):