Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# how to run the package after git clone
# how the to use the package in separate projects, pyndatic version
# running and discussing test

# TODO: adding shared fast-api utils
3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,6 @@ dependencies = [

[project.optional-dependencies]
test = ["pytest>=8.3.3"]

[tool.pytest.ini_options]
pythonpath = ["src"]
File renamed without changes.
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,13 @@


class DetailModel(BaseModel):
"""Model representing error details."""

message: str
error: str


class AppErrorResponseModel(BaseModel):
"""Model representing an application error response."""

detail: DetailModel
Original file line number Diff line number Diff line change
Expand Up @@ -3,37 +3,46 @@
from typing import Generic, List, TypeVar
from pydantic import BaseModel, Field


T = TypeVar("T")


class MatchType(str, Enum):
"""Enumeration for different types of string matching."""

PARTIAL = "partial"
EXACT = "exact"
STARTS_WITH = "starts_with"


class FilterLogic(str, Enum):
"""Enumeration for filter logic operators."""

OR = "or"
AND = "and"


class PaginationParams(BaseModel):
"""Model for pagination parameters."""

limit: int = Field(10, ge=1, le=100)
page: int = Field(1, ge=1) # Page is 1-indexed

@property
def offset(self) -> int:
"""Calculate the offset based on the current page and limit."""
return (self.page - 1) * self.limit


class PaginationMeta(BaseModel):
"""Model for pagination metadata."""

total_records: int
limit: int
current_page: int
total_pages: int


class PaginatedResponse(BaseModel, Generic[T]):
"""Generic paginated response model."""

records: List[T]
pagination: PaginationMeta
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,7 @@


class GenericResponseModel(BaseModel, Generic[T]):
"""Generic response model wrapping a message and optional data."""

message: str
data: Optional[T]
File renamed without changes.
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@


class CompanyAddressModel(BaseModel):
"""Model representing a company's address."""

street: Optional[str] = Field(None, json_schema_extra={"example": "123 Main St"})
city: Optional[str] = Field(None, json_schema_extra={"example": "Cape Town"})
state: Optional[str] = Field(None, json_schema_extra={"example": "CT"})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,15 @@

from pydantic import BaseModel, EmailStr, field_validator, Field

from generic_models.generic_pagination import PaginationParams

from .address import CompanyAddressModel
from ..validators.phone_number import validate_phone_number_format
from ..generic_pagination import PaginationParams


class CompanyReadModel(BaseModel):
"""Model representing a company."""

id: int
name: Optional[str] = None
description: Optional[str] = None
Expand All @@ -20,6 +23,8 @@ class CompanyReadModel(BaseModel):


class CompanyUpdateModel(BaseModel):
"""Model for updating company details."""

name: Optional[str] = None
description: Optional[str] = None
industry: Optional[str] = None
Expand All @@ -29,12 +34,15 @@ class CompanyUpdateModel(BaseModel):
address: Optional[CompanyAddressModel] = None

@field_validator("phone_number")
@classmethod
def validate_phone_number(cls, v: Optional[str]) -> Optional[str]:
"""Validate phone number format."""
return validate_phone_number_format(v)


class CompanyCreateModel(BaseModel):
"""Model for creating a new company."""

name: Optional[str] = None
description: Optional[str] = None
industry: Optional[str] = None
Expand All @@ -45,12 +53,15 @@ class CompanyCreateModel(BaseModel):
address: Optional[CompanyAddressModel] = None

@field_validator("phone_number")
@classmethod
def validate_phone_number(cls, v: Optional[str]) -> Optional[str]:
"""Validate phone number format."""
return validate_phone_number_format(v)


class CompanyQueryParamsModel(PaginationParams):
"""Model for querying companies with optional filters."""

role_name: Optional[str] = None
name: Optional[str] = None
description: Optional[str] = None
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,12 @@
from pydantic import BaseModel
from pydantic import field_validator, Field

from ..generic_pagination import PaginationParams
from generic_models.generic_pagination import PaginationParams


class CompanyDefaultRoles(str, Enum):
"""Enumeration of default company roles with descriptions."""

ADMINISTRATOR = "Administrator: Full access to manage users and data"
VIEWER = "Viewer: Read-only access to company data"

Expand All @@ -22,20 +24,27 @@ def description(self) -> str:


class RoleCreateModel(BaseModel):
"""Model for creating a new role."""

name: str
description: Optional[str]


class RoleUpdateModel(BaseModel):
"""Model for updating a role."""

name: Optional[str]
description: Optional[str]


class RoleDeleteModel(BaseModel):
"""Model for deleting a role with replacement."""

replacement_role_name: str
role_name_to_delete: str

@field_validator("role_name_to_delete")
@classmethod
def validate_not_default_role(cls, v: str) -> str:
"""Ensure that default system roles cannot be deleted."""
default_roles = {r.name_value for r in CompanyDefaultRoles}
Expand All @@ -45,10 +54,14 @@ def validate_not_default_role(cls, v: str) -> str:


class RoleReadModel(BaseModel):
"""Model representing a role."""

name: Optional[str]
description: Optional[str]


class RoleQueryParamsModel(PaginationParams):
"""Model for querying roles with optional filters."""

name: Optional[str] = Field(None, description="Filter by role name")
description: Optional[str] = Field(None, description="Filter by role description")
Original file line number Diff line number Diff line change
@@ -1,15 +1,21 @@
from typing import Optional

from pydantic import BaseModel, EmailStr, Field

from .roles import CompanyDefaultRoles
from ..user.user import UserReadModel


class CompanyUserReadModel(UserReadModel):
"""Model representing a user within a company, including their role."""

role_name: str


class CompanyUserAddModel(BaseModel):
email: EmailStr = Field(
"""Model for adding a user to a company with a specific role."""

email: Optional[EmailStr] = Field(
default=None,
json_schema_extra={"example": "[email protected]"},
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,12 @@


class PasswordResetRequest(BaseModel):
"""Model for requesting a password reset via email."""

email: EmailStr


class OTPValidationRequest(BaseModel):
"""Model for validating OTP during password reset."""

otp: str
17 changes: 16 additions & 1 deletion src/userverse/user/user.py → src/userverse_models/user/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,21 @@

from pydantic import BaseModel, EmailStr, field_validator, Field

from generic_models.generic_pagination import PaginationParams

from ..validators.phone_number import validate_phone_number_format
from ..generic_pagination import PaginationParams


class UserLoginModel(BaseModel):
"""Model for user login."""

email: EmailStr
password: str


class UserUpdateModel(BaseModel):
"""Model for updating user details."""

first_name: Optional[str] = None
last_name: Optional[str] = None
phone_number: Optional[str] = Field(
Expand All @@ -20,25 +25,31 @@ class UserUpdateModel(BaseModel):
password: Optional[str] = None

@field_validator("phone_number")
@classmethod
def validate_phone_number(cls, v: Optional[str]) -> Optional[str]:
"""Validate phone number format."""
return validate_phone_number_format(v)


class UserCreateModel(BaseModel):
"""Model for creating a new user."""

first_name: Optional[str] = None
last_name: Optional[str] = None
phone_number: Optional[str] = Field(
None, json_schema_extra={"example": "1236547899"}
)

@field_validator("phone_number")
@classmethod
def validate_phone_number(cls, v: Optional[str]) -> Optional[str]:
"""Validate phone number format."""
return validate_phone_number_format(v)


class UserReadModel(BaseModel):
"""Model representing a user."""

id: int
first_name: Optional[str] = None
last_name: Optional[str] = None
Expand All @@ -53,6 +64,8 @@ class UserReadModel(BaseModel):


class TokenResponseModel(BaseModel):
"""Model for token response."""

token_type: Literal["bearer"] = Field(
"bearer",
description="Type of the token",
Expand All @@ -68,6 +81,8 @@ class TokenResponseModel(BaseModel):


class UserQueryParams(PaginationParams):
"""Model for querying users with optional filters."""

role_name: Optional[str] = Field(None, description="Filter by role name")
first_name: Optional[str] = Field(None, description="Filter by user first name")
last_name: Optional[str] = Field(None, description="Filter by user last name")
Expand Down
1 change: 1 addition & 0 deletions src/userverse_models/validators/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Validation helpers for userverse models."""
5 changes: 5 additions & 0 deletions src/userverse_models/validators/phone_number.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
"""Compatibility shim for userverse model phone validation."""

from validators.phone_number import validate_phone_number_format

__all__ = ["validate_phone_number_format"]
Empty file added src/validators/__init__.py
Empty file.
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@


def validate_phone_number_format(phone: Optional[str]) -> Optional[str]:
"""Validate and format the phone number to E.164 standard."""
if not phone:
return phone

Expand Down
47 changes: 47 additions & 0 deletions tests/ReadMe.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# Test Suite Overview

This folder contains the full automated test suite for the shared models package.
Tests are grouped by domain and mirror the structure under `src/`.

## Test layout

- `tests/generic_models/`: Tests for generic response, pagination, and errors.
- `tests/userverse_models/`: Tests for user, company, and related domain models.
- `tests/validators/`: Tests for shared validators (for example, phone numbers).

## Run the full test suite

From the project root:

```bash
source .venv/bin/activate
pytest
```

If you use `uv` to manage the virtual environment:

```bash
uv venv
source .venv/bin/activate
uv sync
pytest
```

## Run a subset

Run a specific folder:

```bash
pytest tests/validators
```

Run a single file:

```bash
pytest tests/userverse_models/test_user.py
```

## Notes

- Pytest is configured to include `src` on the import path via `pyproject.toml`.
- If you see import errors, verify the virtual environment is active and dependencies are installed.
13 changes: 13 additions & 0 deletions tests/generic_models/ReadMe.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# Generic Models Tests

These tests cover the shared Pydantic models in `src/generic_models`:
- `app_error.py`: `DetailModel` and `AppErrorResponseModel`
- `generic_response.py`: `GenericResponseModel`
- `generic_pagination.py`: enums, pagination params, and paginated response helpers

## How to run
From the repository root:

```bash
pytest tests/generic_models
```
Empty file.
Loading