Add pyproject.toml for consolidated project configuration and replace Black, isort, and Flake8 with Ruff

- Introduced `pyproject.toml` to centralize backend tool configurations (e.g., Ruff, mypy, coverage, pytest).
- Replaced Black, isort, and Flake8 with Ruff for linting, formatting, and import sorting.
- Updated `requirements.txt` to include Ruff and remove replaced tools.
- Added `Makefile` to streamline development workflows with commands for linting, formatting, type-checking, testing, and cleanup.
This commit is contained in:
2025-11-10 11:55:15 +01:00
parent a5c671c133
commit c589b565f0
86 changed files with 4572 additions and 3956 deletions

View File

@@ -1,18 +1,20 @@
"""
Common schemas used across the API for pagination, responses, filtering, and sorting.
"""
from enum import Enum
from math import ceil
from typing import Generic, TypeVar, List, Optional
from typing import TypeVar
from uuid import UUID
from pydantic import BaseModel, Field
T = TypeVar('T')
T = TypeVar("T")
class SortOrder(str, Enum):
"""Sort order options."""
ASC = "asc"
DESC = "desc"
@@ -20,16 +22,9 @@ class SortOrder(str, Enum):
class PaginationParams(BaseModel):
"""Parameters for pagination."""
page: int = Field(
default=1,
ge=1,
description="Page number (1-indexed)"
)
page: int = Field(default=1, ge=1, description="Page number (1-indexed)")
limit: int = Field(
default=20,
ge=1,
le=100,
description="Number of items per page (max 100)"
default=20, ge=1, le=100, description="Number of items per page (max 100)"
)
@property
@@ -42,34 +37,20 @@ class PaginationParams(BaseModel):
"""Alias for offset (compatibility with existing code)."""
return self.offset
model_config = {
"json_schema_extra": {
"example": {
"page": 1,
"limit": 20
}
}
}
model_config = {"json_schema_extra": {"example": {"page": 1, "limit": 20}}}
class SortParams(BaseModel):
"""Parameters for sorting."""
sort_by: Optional[str] = Field(
default=None,
description="Field name to sort by"
)
sort_by: str | None = Field(default=None, description="Field name to sort by")
sort_order: SortOrder = Field(
default=SortOrder.ASC,
description="Sort order (asc or desc)"
default=SortOrder.ASC, description="Sort order (asc or desc)"
)
model_config = {
"json_schema_extra": {
"example": {
"sort_by": "created_at",
"sort_order": "desc"
}
"example": {"sort_by": "created_at", "sort_order": "desc"}
}
}
@@ -92,32 +73,30 @@ class PaginationMeta(BaseModel):
"page_size": 20,
"total_pages": 8,
"has_next": True,
"has_prev": False
"has_prev": False,
}
}
}
class PaginatedResponse(BaseModel, Generic[T]):
class PaginatedResponse[T](BaseModel):
"""Generic paginated response wrapper."""
data: List[T] = Field(..., description="List of items")
data: list[T] = Field(..., description="List of items")
pagination: PaginationMeta = Field(..., description="Pagination metadata")
model_config = {
"json_schema_extra": {
"example": {
"data": [
{"id": "123", "name": "Example Item"}
],
"data": [{"id": "123", "name": "Example Item"}],
"pagination": {
"total": 150,
"page": 1,
"page_size": 20,
"total_pages": 8,
"has_next": True,
"has_prev": False
}
"has_prev": False,
},
}
}
}
@@ -131,10 +110,7 @@ class MessageResponse(BaseModel):
model_config = {
"json_schema_extra": {
"example": {
"success": True,
"message": "Operation completed successfully"
}
"example": {"success": True, "message": "Operation completed successfully"}
}
}
@@ -142,11 +118,11 @@ class MessageResponse(BaseModel):
class BulkActionRequest(BaseModel):
"""Request schema for bulk operations on multiple items."""
ids: List[UUID] = Field(
ids: list[UUID] = Field(
...,
min_length=1,
max_length=100,
description="List of item IDs to perform action on (max 100)"
description="List of item IDs to perform action on (max 100)",
)
model_config = {
@@ -154,7 +130,7 @@ class BulkActionRequest(BaseModel):
"example": {
"ids": [
"550e8400-e29b-41d4-a716-446655440000",
"6ba7b810-9dad-11d1-80b4-00c04fd430c8"
"6ba7b810-9dad-11d1-80b4-00c04fd430c8",
]
}
}
@@ -166,24 +142,23 @@ class BulkActionResponse(BaseModel):
success: bool = Field(default=True, description="Operation success status")
message: str = Field(..., description="Human-readable message")
affected_count: int = Field(..., description="Number of items affected by the operation")
affected_count: int = Field(
..., description="Number of items affected by the operation"
)
model_config = {
"json_schema_extra": {
"example": {
"success": True,
"message": "Successfully deactivated 5 users",
"affected_count": 5
"affected_count": 5,
}
}
}
def create_pagination_meta(
total: int,
page: int,
limit: int,
items_count: int
total: int, page: int, limit: int, items_count: int
) -> PaginationMeta:
"""
Helper function to create pagination metadata.
@@ -205,5 +180,5 @@ def create_pagination_meta(
page_size=items_count,
total_pages=total_pages,
has_next=page < total_pages,
has_prev=page > 1
has_prev=page > 1,
)

View File

@@ -1,8 +1,8 @@
"""
Error schemas for standardized API error responses.
"""
from enum import Enum
from typing import List, Optional
from pydantic import BaseModel, Field
@@ -53,14 +53,14 @@ class ErrorDetail(BaseModel):
code: ErrorCode = Field(..., description="Machine-readable error code")
message: str = Field(..., description="Human-readable error message")
field: Optional[str] = Field(None, description="Field name if error is field-specific")
field: str | None = Field(None, description="Field name if error is field-specific")
model_config = {
"json_schema_extra": {
"example": {
"code": "VAL_002",
"message": "Password must be at least 8 characters long",
"field": "password"
"field": "password",
}
}
}
@@ -70,7 +70,7 @@ class ErrorResponse(BaseModel):
"""Standardized error response format."""
success: bool = Field(default=False, description="Always false for error responses")
errors: List[ErrorDetail] = Field(..., description="List of errors that occurred")
errors: list[ErrorDetail] = Field(..., description="List of errors that occurred")
model_config = {
"json_schema_extra": {
@@ -80,9 +80,9 @@ class ErrorResponse(BaseModel):
{
"code": "AUTH_001",
"message": "Invalid email or password",
"field": None
"field": None,
}
]
],
}
}
}

View File

@@ -1,10 +1,10 @@
# app/schemas/organizations.py
import re
from datetime import datetime
from typing import Optional, Dict, Any, List
from typing import Any
from uuid import UUID
from pydantic import BaseModel, field_validator, ConfigDict, Field
from pydantic import BaseModel, ConfigDict, Field, field_validator
from app.models.user_organization import OrganizationRole
@@ -12,85 +12,94 @@ from app.models.user_organization import OrganizationRole
# Organization Schemas
class OrganizationBase(BaseModel):
"""Base organization schema with common fields."""
name: str = Field(..., min_length=1, max_length=255)
slug: Optional[str] = Field(None, min_length=1, max_length=255)
description: Optional[str] = None
is_active: bool = True
settings: Optional[Dict[str, Any]] = {}
@field_validator('slug')
name: str = Field(..., min_length=1, max_length=255)
slug: str | None = Field(None, min_length=1, max_length=255)
description: str | None = None
is_active: bool = True
settings: dict[str, Any] | None = {}
@field_validator("slug")
@classmethod
def validate_slug(cls, v: Optional[str]) -> Optional[str]:
def validate_slug(cls, v: str | None) -> str | None:
"""Validate slug format: lowercase, alphanumeric, hyphens only."""
if v is None:
return v
if not re.match(r'^[a-z0-9-]+$', v):
raise ValueError('Slug must contain only lowercase letters, numbers, and hyphens')
if v.startswith('-') or v.endswith('-'):
raise ValueError('Slug cannot start or end with a hyphen')
if '--' in v:
raise ValueError('Slug cannot contain consecutive hyphens')
if not re.match(r"^[a-z0-9-]+$", v):
raise ValueError(
"Slug must contain only lowercase letters, numbers, and hyphens"
)
if v.startswith("-") or v.endswith("-"):
raise ValueError("Slug cannot start or end with a hyphen")
if "--" in v:
raise ValueError("Slug cannot contain consecutive hyphens")
return v
@field_validator('name')
@field_validator("name")
@classmethod
def validate_name(cls, v: str) -> str:
"""Validate organization name."""
if not v or v.strip() == "":
raise ValueError('Organization name cannot be empty')
raise ValueError("Organization name cannot be empty")
return v.strip()
class OrganizationCreate(OrganizationBase):
"""Schema for creating a new organization."""
name: str = Field(..., min_length=1, max_length=255)
slug: str = Field(..., min_length=1, max_length=255)
class OrganizationUpdate(BaseModel):
"""Schema for updating an organization."""
name: Optional[str] = Field(None, min_length=1, max_length=255)
slug: Optional[str] = Field(None, min_length=1, max_length=255)
description: Optional[str] = None
is_active: Optional[bool] = None
settings: Optional[Dict[str, Any]] = None
@field_validator('slug')
name: str | None = Field(None, min_length=1, max_length=255)
slug: str | None = Field(None, min_length=1, max_length=255)
description: str | None = None
is_active: bool | None = None
settings: dict[str, Any] | None = None
@field_validator("slug")
@classmethod
def validate_slug(cls, v: Optional[str]) -> Optional[str]:
def validate_slug(cls, v: str | None) -> str | None:
"""Validate slug format."""
if v is None:
return v
if not re.match(r'^[a-z0-9-]+$', v):
raise ValueError('Slug must contain only lowercase letters, numbers, and hyphens')
if v.startswith('-') or v.endswith('-'):
raise ValueError('Slug cannot start or end with a hyphen')
if '--' in v:
raise ValueError('Slug cannot contain consecutive hyphens')
if not re.match(r"^[a-z0-9-]+$", v):
raise ValueError(
"Slug must contain only lowercase letters, numbers, and hyphens"
)
if v.startswith("-") or v.endswith("-"):
raise ValueError("Slug cannot start or end with a hyphen")
if "--" in v:
raise ValueError("Slug cannot contain consecutive hyphens")
return v
@field_validator('name')
@field_validator("name")
@classmethod
def validate_name(cls, v: Optional[str]) -> Optional[str]:
def validate_name(cls, v: str | None) -> str | None:
"""Validate organization name."""
if v is not None and (not v or v.strip() == ""):
raise ValueError('Organization name cannot be empty')
raise ValueError("Organization name cannot be empty")
return v.strip() if v else v
class OrganizationResponse(OrganizationBase):
"""Schema for organization API responses."""
id: UUID
created_at: datetime
updated_at: Optional[datetime] = None
member_count: Optional[int] = 0
updated_at: datetime | None = None
member_count: int | None = 0
model_config = ConfigDict(from_attributes=True)
class OrganizationListResponse(BaseModel):
"""Schema for paginated organization list responses."""
organizations: List[OrganizationResponse]
organizations: list[OrganizationResponse]
total: int
page: int
page_size: int
@@ -100,44 +109,49 @@ class OrganizationListResponse(BaseModel):
# User-Organization Relationship Schemas
class UserOrganizationBase(BaseModel):
"""Base schema for user-organization relationship."""
role: OrganizationRole = OrganizationRole.MEMBER
is_active: bool = True
custom_permissions: Optional[str] = None
custom_permissions: str | None = None
class UserOrganizationCreate(BaseModel):
"""Schema for adding a user to an organization."""
user_id: UUID
role: OrganizationRole = OrganizationRole.MEMBER
custom_permissions: Optional[str] = None
custom_permissions: str | None = None
class UserOrganizationUpdate(BaseModel):
"""Schema for updating user's role in an organization."""
role: Optional[OrganizationRole] = None
is_active: Optional[bool] = None
custom_permissions: Optional[str] = None
role: OrganizationRole | None = None
is_active: bool | None = None
custom_permissions: str | None = None
class UserOrganizationResponse(BaseModel):
"""Schema for user-organization relationship responses."""
user_id: UUID
organization_id: UUID
role: OrganizationRole
is_active: bool
custom_permissions: Optional[str] = None
custom_permissions: str | None = None
created_at: datetime
updated_at: Optional[datetime] = None
updated_at: datetime | None = None
model_config = ConfigDict(from_attributes=True)
class OrganizationMemberResponse(BaseModel):
"""Schema for organization member information."""
user_id: UUID
email: str
first_name: str
last_name: Optional[str] = None
last_name: str | None = None
role: OrganizationRole
is_active: bool
joined_at: datetime
@@ -147,7 +161,8 @@ class OrganizationMemberResponse(BaseModel):
class OrganizationMemberListResponse(BaseModel):
"""Schema for paginated organization member list."""
members: List[OrganizationMemberResponse]
members: list[OrganizationMemberResponse]
total: int
page: int
page_size: int

View File

@@ -1,37 +1,44 @@
"""
Pydantic schemas for user session management.
"""
from datetime import datetime
from typing import Optional
from uuid import UUID
from pydantic import BaseModel, Field, ConfigDict
from pydantic import BaseModel, ConfigDict, Field
class SessionBase(BaseModel):
"""Base schema for user sessions."""
device_name: Optional[str] = Field(None, max_length=255, description="Friendly device name")
device_id: Optional[str] = Field(None, max_length=255, description="Persistent device identifier")
device_name: str | None = Field(
None, max_length=255, description="Friendly device name"
)
device_id: str | None = Field(
None, max_length=255, description="Persistent device identifier"
)
class SessionCreate(SessionBase):
"""Schema for creating a new session (internal use)."""
user_id: UUID
refresh_token_jti: str = Field(..., max_length=255)
ip_address: Optional[str] = Field(None, max_length=45)
user_agent: Optional[str] = Field(None, max_length=500)
ip_address: str | None = Field(None, max_length=45)
user_agent: str | None = Field(None, max_length=500)
last_used_at: datetime
expires_at: datetime
location_city: Optional[str] = Field(None, max_length=100)
location_country: Optional[str] = Field(None, max_length=100)
location_city: str | None = Field(None, max_length=100)
location_country: str | None = Field(None, max_length=100)
class SessionUpdate(BaseModel):
"""Schema for updating a session (internal use)."""
last_used_at: Optional[datetime] = None
is_active: Optional[bool] = None
refresh_token_jti: Optional[str] = None
expires_at: Optional[datetime] = None
last_used_at: datetime | None = None
is_active: bool | None = None
refresh_token_jti: str | None = None
expires_at: datetime | None = None
class SessionResponse(SessionBase):
@@ -40,14 +47,17 @@ class SessionResponse(SessionBase):
This is what users see when they list their active sessions.
"""
id: UUID
ip_address: Optional[str] = None
location_city: Optional[str] = None
location_country: Optional[str] = None
ip_address: str | None = None
location_city: str | None = None
location_country: str | None = None
last_used_at: datetime
created_at: datetime
expires_at: datetime
is_current: bool = Field(default=False, description="Whether this is the current session")
is_current: bool = Field(
default=False, description="Whether this is the current session"
)
model_config = ConfigDict(
from_attributes=True,
@@ -62,14 +72,15 @@ class SessionResponse(SessionBase):
"last_used_at": "2025-10-31T12:00:00Z",
"created_at": "2025-10-30T09:00:00Z",
"expires_at": "2025-11-06T09:00:00Z",
"is_current": True
"is_current": True,
}
}
},
)
class SessionListResponse(BaseModel):
"""Response containing list of sessions."""
sessions: list[SessionResponse]
total: int = Field(..., description="Total number of active sessions")
@@ -84,10 +95,10 @@ class SessionListResponse(BaseModel):
"last_used_at": "2025-10-31T12:00:00Z",
"created_at": "2025-10-30T09:00:00Z",
"expires_at": "2025-11-06T09:00:00Z",
"is_current": True
"is_current": True,
}
],
"total": 1
"total": 1,
}
}
)
@@ -95,17 +106,14 @@ class SessionListResponse(BaseModel):
class LogoutRequest(BaseModel):
"""Request schema for logout endpoint."""
refresh_token: str = Field(
...,
description="Refresh token for the session to logout from",
min_length=10
..., description="Refresh token for the session to logout from", min_length=10
)
model_config = ConfigDict(
json_schema_extra={
"example": {
"refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
}
"example": {"refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."}
}
)
@@ -116,13 +124,14 @@ class AdminSessionResponse(SessionBase):
Includes user information for admin to see who owns each session.
"""
id: UUID
user_id: UUID
user_email: str = Field(..., description="Email of the user who owns this session")
user_full_name: Optional[str] = Field(None, description="Full name of the user")
ip_address: Optional[str] = None
location_city: Optional[str] = None
location_country: Optional[str] = None
user_full_name: str | None = Field(None, description="Full name of the user")
ip_address: str | None = None
location_city: str | None = None
location_country: str | None = None
last_used_at: datetime
created_at: datetime
expires_at: datetime
@@ -144,20 +153,21 @@ class AdminSessionResponse(SessionBase):
"last_used_at": "2025-10-31T12:00:00Z",
"created_at": "2025-10-30T09:00:00Z",
"expires_at": "2025-11-06T09:00:00Z",
"is_active": True
"is_active": True,
}
}
},
)
class DeviceInfo(BaseModel):
"""Device information extracted from request."""
device_name: Optional[str] = None
device_id: Optional[str] = None
ip_address: Optional[str] = None
user_agent: Optional[str] = None
location_city: Optional[str] = None
location_country: Optional[str] = None
device_name: str | None = None
device_id: str | None = None
ip_address: str | None = None
user_agent: str | None = None
location_city: str | None = None
location_country: str | None = None
model_config = ConfigDict(
json_schema_extra={
@@ -167,7 +177,7 @@ class DeviceInfo(BaseModel):
"ip_address": "192.168.1.50",
"user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)...",
"location_city": "San Francisco",
"location_country": "United States"
"location_country": "United States",
}
}
)

View File

@@ -1,9 +1,9 @@
# app/schemas/users.py
from datetime import datetime
from typing import Optional, Dict, Any
from typing import Any
from uuid import UUID
from pydantic import BaseModel, EmailStr, field_validator, ConfigDict, Field
from pydantic import BaseModel, ConfigDict, EmailStr, Field, field_validator
from app.schemas.validators import validate_password_strength, validate_phone_number
@@ -11,12 +11,12 @@ from app.schemas.validators import validate_password_strength, validate_phone_nu
class UserBase(BaseModel):
email: EmailStr
first_name: str
last_name: Optional[str] = None
phone_number: Optional[str] = None
last_name: str | None = None
phone_number: str | None = None
@field_validator('phone_number')
@field_validator("phone_number")
@classmethod
def validate_phone(cls, v: Optional[str]) -> Optional[str]:
def validate_phone(cls, v: str | None) -> str | None:
return validate_phone_number(v)
@@ -24,7 +24,7 @@ class UserCreate(UserBase):
password: str
is_superuser: bool = False
@field_validator('password')
@field_validator("password")
@classmethod
def password_strength(cls, v: str) -> str:
"""Enterprise-grade password strength validation"""
@@ -32,30 +32,32 @@ class UserCreate(UserBase):
class UserUpdate(BaseModel):
first_name: Optional[str] = None
last_name: Optional[str] = None
phone_number: Optional[str] = None
password: Optional[str] = None
preferences: Optional[Dict[str, Any]] = None
is_active: Optional[bool] = None # Changed default from True to None to avoid unintended updates
is_superuser: Optional[bool] = None # Explicitly reject privilege escalation attempts
first_name: str | None = None
last_name: str | None = None
phone_number: str | None = None
password: str | None = None
preferences: dict[str, Any] | None = None
is_active: bool | None = (
None # Changed default from True to None to avoid unintended updates
)
is_superuser: bool | None = None # Explicitly reject privilege escalation attempts
@field_validator('phone_number')
@field_validator("phone_number")
@classmethod
def validate_phone(cls, v: Optional[str]) -> Optional[str]:
def validate_phone(cls, v: str | None) -> str | None:
return validate_phone_number(v)
@field_validator('password')
@field_validator("password")
@classmethod
def password_strength(cls, v: Optional[str]) -> Optional[str]:
def password_strength(cls, v: str | None) -> str | None:
"""Enterprise-grade password strength validation"""
if v is None:
return v
return validate_password_strength(v)
@field_validator('is_superuser')
@field_validator("is_superuser")
@classmethod
def prevent_superuser_modification(cls, v: Optional[bool]) -> Optional[bool]:
def prevent_superuser_modification(cls, v: bool | None) -> bool | None:
"""Prevent users from modifying their superuser status via this schema."""
if v is not None:
raise ValueError("Cannot modify superuser status through user update")
@@ -67,7 +69,7 @@ class UserInDB(UserBase):
is_active: bool
is_superuser: bool
created_at: datetime
updated_at: Optional[datetime] = None
updated_at: datetime | None = None
model_config = ConfigDict(from_attributes=True)
@@ -77,28 +79,28 @@ class UserResponse(UserBase):
is_active: bool
is_superuser: bool
created_at: datetime
updated_at: Optional[datetime] = None
updated_at: datetime | None = None
model_config = ConfigDict(from_attributes=True)
class Token(BaseModel):
access_token: str
refresh_token: Optional[str] = None
refresh_token: str | None = None
token_type: str = "bearer"
user: "UserResponse" # Forward reference since UserResponse is defined above
expires_in: Optional[int] = None # Token expiration in seconds
expires_in: int | None = None # Token expiration in seconds
class TokenPayload(BaseModel):
sub: str # User ID
exp: int # Expiration time
iat: Optional[int] = None # Issued at
jti: Optional[str] = None # JWT ID
is_superuser: Optional[bool] = False
first_name: Optional[str] = None
email: Optional[str] = None
type: Optional[str] = None # Token type (access/refresh)
iat: int | None = None # Issued at
jti: str | None = None # JWT ID
is_superuser: bool | None = False
first_name: str | None = None
email: str | None = None
type: str | None = None # Token type (access/refresh)
class TokenData(BaseModel):
@@ -108,10 +110,11 @@ class TokenData(BaseModel):
class PasswordChange(BaseModel):
"""Schema for changing password (requires current password)."""
current_password: str
new_password: str
@field_validator('new_password')
@field_validator("new_password")
@classmethod
def password_strength(cls, v: str) -> str:
"""Enterprise-grade password strength validation"""
@@ -120,10 +123,11 @@ class PasswordChange(BaseModel):
class PasswordReset(BaseModel):
"""Schema for resetting password (via email token)."""
token: str
new_password: str
@field_validator('new_password')
@field_validator("new_password")
@classmethod
def password_strength(cls, v: str) -> str:
"""Enterprise-grade password strength validation"""
@@ -141,23 +145,19 @@ class RefreshTokenRequest(BaseModel):
class PasswordResetRequest(BaseModel):
"""Schema for requesting a password reset."""
email: EmailStr = Field(..., description="Email address of the account")
model_config = {
"json_schema_extra": {
"example": {
"email": "user@example.com"
}
}
}
model_config = {"json_schema_extra": {"example": {"email": "user@example.com"}}}
class PasswordResetConfirm(BaseModel):
"""Schema for confirming a password reset with token."""
token: str = Field(..., description="Password reset token from email")
new_password: str = Field(..., min_length=8, description="New password")
@field_validator('new_password')
@field_validator("new_password")
@classmethod
def password_strength(cls, v: str) -> str:
"""Enterprise-grade password strength validation"""
@@ -167,7 +167,7 @@ class PasswordResetConfirm(BaseModel):
"json_schema_extra": {
"example": {
"token": "eyJwYXlsb2FkIjp7ImVtYWlsIjoidXNlckBleGFtcGxlLmNvbSIsImV4cCI6MTcxMjM0NTY3OH19",
"new_password": "NewSecurePassword123"
"new_password": "NewSecurePassword123",
}
}
}

View File

@@ -4,19 +4,34 @@ Shared validators for Pydantic schemas.
This module provides reusable validation functions to ensure consistency
across all schemas and avoid code duplication.
"""
import re
from typing import Set
# Common weak passwords that should be rejected
COMMON_PASSWORDS: Set[str] = {
'password', 'password1', 'password123', 'password1234',
'admin', 'admin123', 'admin1234',
'welcome', 'welcome1', 'welcome123',
'qwerty', 'qwerty123',
'12345678', '123456789', '1234567890',
'letmein', 'letmein1', 'letmein123',
'monkey123', 'dragon123',
'passw0rd', 'p@ssw0rd', 'p@ssword',
COMMON_PASSWORDS: set[str] = {
"password",
"password1",
"password123",
"password1234",
"admin",
"admin123",
"admin1234",
"welcome",
"welcome1",
"welcome123",
"qwerty",
"qwerty123",
"12345678",
"123456789",
"1234567890",
"letmein",
"letmein1",
"letmein123",
"monkey123",
"dragon123",
"passw0rd",
"p@ssw0rd",
"p@ssword",
}
@@ -47,18 +62,21 @@ def validate_password_strength(password: str) -> str:
"""
# Check minimum length
if len(password) < 12:
raise ValueError('Password must be at least 12 characters long')
raise ValueError("Password must be at least 12 characters long")
# Check against common passwords (case-insensitive)
if password.lower() in COMMON_PASSWORDS:
raise ValueError('Password is too common. Please choose a stronger password')
raise ValueError("Password is too common. Please choose a stronger password")
# Check for required character types
checks = [
(any(c.islower() for c in password), 'at least one lowercase letter'),
(any(c.isupper() for c in password), 'at least one uppercase letter'),
(any(c.isdigit() for c in password), 'at least one digit'),
(any(c in '!@#$%^&*()_+-=[]{}|;:,.<>?~`' for c in password), 'at least one special character (!@#$%^&*()_+-=[]{}|;:,.<>?~`)')
(any(c.islower() for c in password), "at least one lowercase letter"),
(any(c.isupper() for c in password), "at least one uppercase letter"),
(any(c.isdigit() for c in password), "at least one digit"),
(
any(c in "!@#$%^&*()_+-=[]{}|;:,.<>?~`" for c in password),
"at least one special character (!@#$%^&*()_+-=[]{}|;:,.<>?~`)",
),
]
failed = [msg for check, msg in checks if not check]
@@ -94,10 +112,10 @@ def validate_phone_number(phone: str | None) -> str | None:
# Check for empty strings
if not phone or phone.strip() == "":
raise ValueError('Phone number cannot be empty')
raise ValueError("Phone number cannot be empty")
# Remove all spaces and formatting characters
cleaned = re.sub(r'[\s\-\(\)]', '', phone)
cleaned = re.sub(r"[\s\-\(\)]", "", phone)
# Basic pattern:
# Must start with + or 0
@@ -105,19 +123,19 @@ def validate_phone_number(phone: str | None) -> str | None:
# After 0 must have at least 8 digits
# Maximum total length of 15 digits (international standard)
# Only allowed characters are + at start and digits
pattern = r'^(?:\+[0-9]{8,14}|0[0-9]{8,14})$'
pattern = r"^(?:\+[0-9]{8,14}|0[0-9]{8,14})$"
if not re.match(pattern, cleaned):
raise ValueError('Phone number must start with + or 0 followed by 8-14 digits')
raise ValueError("Phone number must start with + or 0 followed by 8-14 digits")
# Additional validation to catch specific invalid cases
# NOTE: These checks are defensive code - the regex pattern above already catches these cases
if cleaned.count('+') > 1: # pragma: no cover
raise ValueError('Phone number can only contain one + symbol at the start')
if cleaned.count("+") > 1: # pragma: no cover
raise ValueError("Phone number can only contain one + symbol at the start")
# Check for any non-digit characters (except the leading +)
if not all(c.isdigit() for c in cleaned[1:]): # pragma: no cover
raise ValueError('Phone number can only contain digits after the prefix')
raise ValueError("Phone number can only contain digits after the prefix")
return cleaned
@@ -169,16 +187,16 @@ def validate_slug(slug: str) -> str:
ValueError: If slug format is invalid
"""
if not slug or len(slug) < 2:
raise ValueError('Slug must be at least 2 characters long')
raise ValueError("Slug must be at least 2 characters long")
if len(slug) > 50:
raise ValueError('Slug must be at most 50 characters long')
raise ValueError("Slug must be at most 50 characters long")
# Check format
if not re.match(r'^[a-z0-9]+(?:-[a-z0-9]+)*$', slug):
if not re.match(r"^[a-z0-9]+(?:-[a-z0-9]+)*$", slug):
raise ValueError(
'Slug can only contain lowercase letters, numbers, and hyphens. '
'It cannot start or end with a hyphen, and cannot contain consecutive hyphens'
"Slug can only contain lowercase letters, numbers, and hyphens. "
"It cannot start or end with a hyphen, and cannot contain consecutive hyphens"
)
return slug