Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5ba1b498ec | |||
| 7837dc0f6b | |||
| dbe44da86f | |||
| cd3d387094 | |||
| e5deccde75 |
@@ -1,26 +0,0 @@
|
|||||||
# Environment files
|
|
||||||
.env
|
|
||||||
.env.*
|
|
||||||
.env.local
|
|
||||||
.env.production
|
|
||||||
.env.development
|
|
||||||
.env.staging
|
|
||||||
.env.example
|
|
||||||
|
|
||||||
# Security files
|
|
||||||
*.key
|
|
||||||
*.pem
|
|
||||||
*.p12
|
|
||||||
*.pfx
|
|
||||||
secrets/
|
|
||||||
**/*.secret
|
|
||||||
**/*.token
|
|
||||||
|
|
||||||
# Database files
|
|
||||||
*.db
|
|
||||||
*.sqlite
|
|
||||||
*.sqlite3
|
|
||||||
|
|
||||||
# Configuration with sensitive data
|
|
||||||
config/database.yml
|
|
||||||
config/secrets.yml
|
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
---
|
||||||
|
applyTo: '**'
|
||||||
|
---
|
||||||
|
You are my second, more logical brain.
|
||||||
|
|
||||||
|
Your role is to strengthen my reasoning, decision-making, and problem-solving.
|
||||||
|
- Analyze my assumptions and arguments with precision.
|
||||||
|
- Identify flaws, biases, and logical fallacies.
|
||||||
|
- Offer counterpoints and alternative perspectives, even if they oppose my view.
|
||||||
|
- Prioritize intellectual honesty and clarity over agreement.
|
||||||
|
- Provide answers that are rigorous and accurate, while avoiding unnecessary verbosity.
|
||||||
|
|
||||||
|
In agent mode: act as a critical collaborator who can explore complex reasoning step by step,
|
||||||
|
propose structured approaches, and help refine drafts, plans, or code with logical consistency.
|
||||||
|
|
||||||
|
In conversational/inline mode: keep responses concise but still point out weaknesses or alternative
|
||||||
|
angles when relevant. Favor clarity and precision over wordiness.
|
||||||
|
|
||||||
|
Your overall goal: challenge me to think more clearly, more critically, and more effectively in everything I do.
|
||||||
+1
-4
@@ -1,5 +1,2 @@
|
|||||||
**/__pycache__
|
**/__pycache__
|
||||||
**/.pytest_cache
|
**/*.env
|
||||||
**/venv
|
|
||||||
**/.env
|
|
||||||
**/.github
|
|
||||||
|
|||||||
Vendored
-32
@@ -1,32 +0,0 @@
|
|||||||
{
|
|
||||||
"github.copilot.advanced": {
|
|
||||||
"ignore": [
|
|
||||||
"**/.env",
|
|
||||||
"**/.env.*",
|
|
||||||
"**/.env.local",
|
|
||||||
"**/.env.production",
|
|
||||||
"**/.env.development",
|
|
||||||
"**/secrets/**",
|
|
||||||
"**/*.key",
|
|
||||||
"**/*.pem",
|
|
||||||
"**/.env.example",
|
|
||||||
"**/config/*.env"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"github.copilot.enable": {
|
|
||||||
"plaintext": false,
|
|
||||||
"properties": false
|
|
||||||
},
|
|
||||||
"sqltools.connections": [
|
|
||||||
{
|
|
||||||
"previewLimit": 50,
|
|
||||||
"server": "localhost",
|
|
||||||
"port": 5432,
|
|
||||||
"askForPassword": true,
|
|
||||||
"driver": "PostgreSQL",
|
|
||||||
"database": "cmt_db",
|
|
||||||
"username": "admin",
|
|
||||||
"name": "CMT"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1 +1,8 @@
|
|||||||
# CMT
|
# CMT
|
||||||
|
### DB
|
||||||
|
```sql
|
||||||
|
-- db setup
|
||||||
|
cat db_setup.sql | mysql -u root -p
|
||||||
|
-- table setup
|
||||||
|
cat db_table_setup.sql | mysql -u admin -p CMT
|
||||||
|
```
|
||||||
|
|||||||
@@ -1,2 +0,0 @@
|
|||||||
### LOGIC
|
|
||||||
- forms on the frontend
|
|
||||||
@@ -1,296 +0,0 @@
|
|||||||
# CMT Backend
|
|
||||||
|
|
||||||
FastAPI-based backend with JWT authentication, role-based access control, and comprehensive test coverage.
|
|
||||||
|
|
||||||
## Features
|
|
||||||
- **JWT Authentication**: Session-based auth with role-based token expiration
|
|
||||||
- **Role-Based Access Control**: Admin, Write, and Read-only user roles
|
|
||||||
- **Database Management**: SQLModel + Alembic migrations
|
|
||||||
- **Comprehensive Testing**: Unit tests with pytest and test fixtures
|
|
||||||
- **API Documentation**: Auto-generated OpenAPI/Swagger docs
|
|
||||||
|
|
||||||
## Quick Start
|
|
||||||
|
|
||||||
### Environment Setup
|
|
||||||
```bash
|
|
||||||
cd backend
|
|
||||||
python -m venv venv
|
|
||||||
source venv/bin/activate # On Windows: venv\Scripts\activate
|
|
||||||
pip install -r requirements.txt
|
|
||||||
```
|
|
||||||
|
|
||||||
### Database Setup
|
|
||||||
```bash
|
|
||||||
# Create initial migration
|
|
||||||
alembic revision --autogenerate -m "Initial tables"
|
|
||||||
alembic upgrade head
|
|
||||||
|
|
||||||
# Create admin user (optional)
|
|
||||||
python scripts/create_admin.py
|
|
||||||
```
|
|
||||||
|
|
||||||
### Run the Application
|
|
||||||
```bash
|
|
||||||
uvicorn app.main:app --reload
|
|
||||||
# or
|
|
||||||
fastapi run --reload app/main.py
|
|
||||||
# API will be available at http://localhost:8000
|
|
||||||
# Docs at http://localhost:8000/docs
|
|
||||||
```
|
|
||||||
|
|
||||||
## API Endpoints
|
|
||||||
|
|
||||||
### Authentication
|
|
||||||
- `POST /api/v1/users/login` - User login (returns JWT token)
|
|
||||||
- `GET /api/v1/users/me` - Get current user info
|
|
||||||
|
|
||||||
### Users (Admin only)
|
|
||||||
- `POST /api/v1/users/` - Create user
|
|
||||||
- `GET /api/v1/users/` - List all users
|
|
||||||
- `GET /api/v1/users/{id}` - Get user by ID
|
|
||||||
- `PUT /api/v1/users/{id}` - Update user
|
|
||||||
- `DELETE /api/v1/users/{id}` - Delete user
|
|
||||||
|
|
||||||
### Transactions (Write access required)
|
|
||||||
- `POST /api/v1/transactions/` - Create transaction
|
|
||||||
- `GET /api/v1/transactions/` - List transactions
|
|
||||||
- `GET /api/v1/transactions/{id}` - Get transaction by ID
|
|
||||||
- `PUT /api/v1/transactions/{id}` - Update transaction
|
|
||||||
- `DELETE /api/v1/transactions/{id}` - Delete transaction
|
|
||||||
|
|
||||||
### Role-Based Token Expiration
|
|
||||||
- **Admin**: 8 hours (480 minutes)
|
|
||||||
- **Write**: 4 hours (240 minutes)
|
|
||||||
- **Read-only**: 2 hours (120 minutes)
|
|
||||||
|
|
||||||
## Authentication Usage
|
|
||||||
|
|
||||||
### Getting a Bearer Token
|
|
||||||
|
|
||||||
First, you need to create an admin user (if you haven't already):
|
|
||||||
```bash
|
|
||||||
cd backend
|
|
||||||
python scripts/create_admin.py
|
|
||||||
```
|
|
||||||
|
|
||||||
Then login to get your bearer token:
|
|
||||||
```bash
|
|
||||||
curl -X POST "http://localhost:8000/api/v1/users/login" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"username": "your_admin_username", "password": "your_password"}'
|
|
||||||
```
|
|
||||||
|
|
||||||
**Response:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9...",
|
|
||||||
"token_type": "bearer",
|
|
||||||
"expires_in": 28800,
|
|
||||||
"user": {
|
|
||||||
"id": 1,
|
|
||||||
"username": "admin",
|
|
||||||
"role": "admin"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Copy the `access_token` value - this is your bearer token.
|
|
||||||
|
|
||||||
### Using the Bearer Token
|
|
||||||
```bash
|
|
||||||
# Include token in Authorization header
|
|
||||||
curl -X GET "http://localhost:8000/api/v1/users/me" \
|
|
||||||
-H "Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9..."
|
|
||||||
```
|
|
||||||
|
|
||||||
**Note:** Replace `eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9...` with your actual token from the login response.
|
|
||||||
|
|
||||||
### Create User (Admin only)
|
|
||||||
```bash
|
|
||||||
curl -X POST "http://localhost:8000/api/v1/users/" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-H "Authorization: Bearer YOUR_ADMIN_TOKEN" \
|
|
||||||
-d '{"username": "newuser", "password": "password", "role": "read_only"}'
|
|
||||||
```
|
|
||||||
|
|
||||||
## Database Management
|
|
||||||
|
|
||||||
### Alembic
|
|
||||||
### Alembic Migration Commands
|
|
||||||
```bash
|
|
||||||
cd backend
|
|
||||||
|
|
||||||
# Create new migration after model changes
|
|
||||||
alembic revision --autogenerate -m "Description of changes"
|
|
||||||
|
|
||||||
# Apply migrations to database
|
|
||||||
alembic upgrade head
|
|
||||||
|
|
||||||
# Rollback to previous migration
|
|
||||||
alembic downgrade -1
|
|
||||||
|
|
||||||
# Mark database as up-to-date without running migrations
|
|
||||||
alembic stamp head
|
|
||||||
|
|
||||||
# View migration history
|
|
||||||
alembic history
|
|
||||||
```
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
### Test Structure
|
|
||||||
```
|
|
||||||
backend/tests/
|
|
||||||
├── conftest.py # Shared fixtures (admin user, tokens, etc.)
|
|
||||||
├── test_main.py # Main app tests
|
|
||||||
├── api/v1/
|
|
||||||
│ ├── test_users.py # User endpoint tests
|
|
||||||
│ └── test_transactions.py # Transaction endpoint tests
|
|
||||||
├── core/
|
|
||||||
│ └── test_auth.py # Authentication tests
|
|
||||||
└── schemas/
|
|
||||||
└── test_models.py # Model validation tests
|
|
||||||
```
|
|
||||||
|
|
||||||
### Test Features
|
|
||||||
- **Isolated Tests**: Each test uses fresh in-memory database
|
|
||||||
- **Authentication Fixtures**: Pre-configured admin users and tokens
|
|
||||||
- **Role-Based Testing**: Tests for different user permission levels
|
|
||||||
- **Comprehensive Coverage**: Endpoints, authentication, and data validation
|
|
||||||
|
|
||||||
### Running Tests
|
|
||||||
```bash
|
|
||||||
cd backend
|
|
||||||
|
|
||||||
# Run all tests
|
|
||||||
pytest
|
|
||||||
|
|
||||||
# Run with verbose output
|
|
||||||
pytest -v
|
|
||||||
|
|
||||||
# Run specific test file
|
|
||||||
pytest tests/api/v1/test_users.py
|
|
||||||
|
|
||||||
# Run specific test function
|
|
||||||
pytest tests/api/v1/test_users.py::test_create_user
|
|
||||||
|
|
||||||
# Run with coverage report
|
|
||||||
pytest --cov=app
|
|
||||||
|
|
||||||
# Run and generate HTML coverage report
|
|
||||||
pytest --cov=app --cov-report=html
|
|
||||||
```
|
|
||||||
|
|
||||||
### Test Examples
|
|
||||||
```bash
|
|
||||||
# Test user creation (requires admin token)
|
|
||||||
pytest tests/api/v1/test_users.py::test_create_user -v
|
|
||||||
|
|
||||||
# Test authentication flows
|
|
||||||
pytest tests/core/test_auth.py -v
|
|
||||||
|
|
||||||
# Test unauthorized access
|
|
||||||
pytest tests/api/v1/test_users.py::test_create_user_unauthorized -v
|
|
||||||
```
|
|
||||||
|
|
||||||
## Development
|
|
||||||
|
|
||||||
### Project Structure
|
|
||||||
```
|
|
||||||
backend/
|
|
||||||
├── app/
|
|
||||||
│ ├── main.py # FastAPI application
|
|
||||||
│ ├── api/v1/ # API route handlers
|
|
||||||
│ ├── core/ # Config, database, auth
|
|
||||||
│ └── schemas/ # Pydantic models and enums
|
|
||||||
├── tests/ # Test suite
|
|
||||||
├── scripts/ # Utility scripts
|
|
||||||
├── alembic/ # Database migrations
|
|
||||||
├── requirements.txt # Dependencies
|
|
||||||
└── pyproject.toml # Project configuration
|
|
||||||
```
|
|
||||||
|
|
||||||
### Adding New Endpoints
|
|
||||||
1. Create route handler in `app/api/v1/`
|
|
||||||
2. Add authentication dependencies (`require_admin`, `require_write_access`, etc.)
|
|
||||||
3. Create corresponding tests in `tests/api/v1/`
|
|
||||||
4. Update this documentation
|
|
||||||
|
|
||||||
### Environment Variables
|
|
||||||
Set in `.env` file or environment:
|
|
||||||
```bash
|
|
||||||
SECRET_KEY=your-secret-key-here
|
|
||||||
DATABASE_URL=postgresql://user:pass@localhost/dbname
|
|
||||||
ADMIN_TOKEN_EXPIRE_MINUTES=480
|
|
||||||
WRITE_TOKEN_EXPIRE_MINUTES=240
|
|
||||||
READ_ONLY_TOKEN_EXPIRE_MINUTES=120
|
|
||||||
```
|
|
||||||
|
|
||||||
## API Examples
|
|
||||||
|
|
||||||
### Transaction Management
|
|
||||||
```bash
|
|
||||||
# Create transaction (requires write access)
|
|
||||||
curl -X POST "http://localhost:8000/api/v1/transactions/" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-H "Authorization: Bearer YOUR_TOKEN" \
|
|
||||||
-d '{
|
|
||||||
"partner_id": 1,
|
|
||||||
"transcation_type": "sell",
|
|
||||||
"transaction_status": "unpaid",
|
|
||||||
"total_amount": 1000,
|
|
||||||
"created_by": 1,
|
|
||||||
"updated_by": 1
|
|
||||||
}'
|
|
||||||
|
|
||||||
# Get all transactions
|
|
||||||
curl -X GET "http://localhost:8000/api/v1/transactions/" \
|
|
||||||
-H "Authorization: Bearer YOUR_TOKEN"
|
|
||||||
|
|
||||||
# Update transaction
|
|
||||||
curl -X PUT "http://localhost:8000/api/v1/transactions/1" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-H "Authorization: Bearer YOUR_TOKEN" \
|
|
||||||
-d '{"transaction_status": "paid"}'
|
|
||||||
|
|
||||||
# Delete transaction
|
|
||||||
curl -X DELETE "http://localhost:8000/api/v1/transactions/1" \
|
|
||||||
-H "Authorization: Bearer YOUR_TOKEN"
|
|
||||||
```
|
|
||||||
|
|
||||||
### User Management (Admin only)
|
|
||||||
```bash
|
|
||||||
# List all users
|
|
||||||
curl -X GET "http://localhost:8000/api/v1/users/" \
|
|
||||||
-H "Authorization: Bearer YOUR_ADMIN_TOKEN"
|
|
||||||
|
|
||||||
# Update user role
|
|
||||||
curl -X PUT "http://localhost:8000/api/v1/users/2" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-H "Authorization: Bearer YOUR_ADMIN_TOKEN" \
|
|
||||||
-d '{"role": "write"}'
|
|
||||||
|
|
||||||
# Delete user
|
|
||||||
curl -X DELETE "http://localhost:8000/api/v1/users/2" \
|
|
||||||
-H "Authorization: Bearer YOUR_ADMIN_TOKEN"
|
|
||||||
```
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### Common Issues
|
|
||||||
1. **401 Unauthorized**: Check if token is expired or invalid
|
|
||||||
2. **403 Forbidden**: User doesn't have required role permissions
|
|
||||||
3. **422 Validation Error**: Check request body format and required fields
|
|
||||||
|
|
||||||
### Debug Mode
|
|
||||||
```bash
|
|
||||||
# Run with debug logging
|
|
||||||
uvicorn app.main:app --reload --log-level debug
|
|
||||||
```
|
|
||||||
|
|
||||||
### Database Issues
|
|
||||||
```bash
|
|
||||||
# Reset database (DANGER: deletes all data)
|
|
||||||
alembic downgrade base
|
|
||||||
alembic upgrade head
|
|
||||||
```
|
|
||||||
+110
-5
@@ -1,7 +1,111 @@
|
|||||||
[alembic]
|
# A generic, single database configuration.
|
||||||
script_location = app/alembic
|
|
||||||
# The sqlalchemy.url is ignored; set in env.py via config.settings
|
|
||||||
|
|
||||||
|
[alembic]
|
||||||
|
# path to migration scripts.
|
||||||
|
# this is typically a path given in POSIX (e.g. forward slashes)
|
||||||
|
# format, relative to the token %(here)s which refers to the location of this
|
||||||
|
# ini file
|
||||||
|
script_location = app/alembic
|
||||||
|
|
||||||
|
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
|
||||||
|
# Uncomment the line below if you want the files to be prepended with date and time
|
||||||
|
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
|
||||||
|
# for all available tokens
|
||||||
|
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
|
||||||
|
|
||||||
|
# sys.path path, will be prepended to sys.path if present.
|
||||||
|
# defaults to the current working directory. for multiple paths, the path separator
|
||||||
|
# is defined by "path_separator" below.
|
||||||
|
prepend_sys_path = .
|
||||||
|
|
||||||
|
|
||||||
|
# timezone to use when rendering the date within the migration file
|
||||||
|
# as well as the filename.
|
||||||
|
# If specified, requires the python>=3.9 or backports.zoneinfo library and tzdata library.
|
||||||
|
# Any required deps can installed by adding `alembic[tz]` to the pip requirements
|
||||||
|
# string value is passed to ZoneInfo()
|
||||||
|
# leave blank for localtime
|
||||||
|
# timezone =
|
||||||
|
|
||||||
|
# max length of characters to apply to the "slug" field
|
||||||
|
# truncate_slug_length = 40
|
||||||
|
|
||||||
|
# set to 'true' to run the environment during
|
||||||
|
# the 'revision' command, regardless of autogenerate
|
||||||
|
# revision_environment = false
|
||||||
|
|
||||||
|
# set to 'true' to allow .pyc and .pyo files without
|
||||||
|
# a source .py file to be detected as revisions in the
|
||||||
|
# versions/ directory
|
||||||
|
# sourceless = false
|
||||||
|
|
||||||
|
# version location specification; This defaults
|
||||||
|
# to <script_location>/versions. When using multiple version
|
||||||
|
# directories, initial revisions must be specified with --version-path.
|
||||||
|
# The path separator used here should be the separator specified by "path_separator"
|
||||||
|
# below.
|
||||||
|
# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions
|
||||||
|
|
||||||
|
# path_separator; This indicates what character is used to split lists of file
|
||||||
|
# paths, including version_locations and prepend_sys_path within configparser
|
||||||
|
# files such as alembic.ini.
|
||||||
|
# The default rendered in new alembic.ini files is "os", which uses os.pathsep
|
||||||
|
# to provide os-dependent path splitting.
|
||||||
|
#
|
||||||
|
# Note that in order to support legacy alembic.ini files, this default does NOT
|
||||||
|
# take place if path_separator is not present in alembic.ini. If this
|
||||||
|
# option is omitted entirely, fallback logic is as follows:
|
||||||
|
#
|
||||||
|
# 1. Parsing of the version_locations option falls back to using the legacy
|
||||||
|
# "version_path_separator" key, which if absent then falls back to the legacy
|
||||||
|
# behavior of splitting on spaces and/or commas.
|
||||||
|
# 2. Parsing of the prepend_sys_path option falls back to the legacy
|
||||||
|
# behavior of splitting on spaces, commas, or colons.
|
||||||
|
#
|
||||||
|
# Valid values for path_separator are:
|
||||||
|
#
|
||||||
|
# path_separator = :
|
||||||
|
# path_separator = ;
|
||||||
|
# path_separator = space
|
||||||
|
# path_separator = newline
|
||||||
|
#
|
||||||
|
# Use os.pathsep. Default configuration used for new projects.
|
||||||
|
path_separator = os
|
||||||
|
|
||||||
|
# set to 'true' to search source files recursively
|
||||||
|
# in each "version_locations" directory
|
||||||
|
# new in Alembic version 1.10
|
||||||
|
# recursive_version_locations = false
|
||||||
|
|
||||||
|
# the output encoding used when revision files
|
||||||
|
# are written from script.py.mako
|
||||||
|
# output_encoding = utf-8
|
||||||
|
|
||||||
|
# database URL. This is consumed by the user-maintained env.py script only.
|
||||||
|
# other means of configuring database URLs may be customized within the env.py
|
||||||
|
# file.
|
||||||
|
#sqlalchemy.url = driver://user:pass@localhost/dbname
|
||||||
|
|
||||||
|
|
||||||
|
[post_write_hooks]
|
||||||
|
# post_write_hooks defines scripts or Python functions that are run
|
||||||
|
# on newly generated revision scripts. See the documentation for further
|
||||||
|
# detail and examples
|
||||||
|
|
||||||
|
# format using "black" - use the console_scripts runner, against the "black" entrypoint
|
||||||
|
# hooks = black
|
||||||
|
# black.type = console_scripts
|
||||||
|
# black.entrypoint = black
|
||||||
|
# black.options = -l 79 REVISION_SCRIPT_FILENAME
|
||||||
|
|
||||||
|
# lint with attempts to fix using "ruff" - use the exec runner, execute a binary
|
||||||
|
# hooks = ruff
|
||||||
|
# ruff.type = exec
|
||||||
|
# ruff.executable = %(here)s/.venv/bin/ruff
|
||||||
|
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
|
||||||
|
|
||||||
|
# Logging configuration. This is also consumed by the user-maintained
|
||||||
|
# env.py script only.
|
||||||
[loggers]
|
[loggers]
|
||||||
keys = root,sqlalchemy,alembic
|
keys = root,sqlalchemy,alembic
|
||||||
|
|
||||||
@@ -12,12 +116,12 @@ keys = console
|
|||||||
keys = generic
|
keys = generic
|
||||||
|
|
||||||
[logger_root]
|
[logger_root]
|
||||||
level = WARN
|
level = WARNING
|
||||||
handlers = console
|
handlers = console
|
||||||
qualname =
|
qualname =
|
||||||
|
|
||||||
[logger_sqlalchemy]
|
[logger_sqlalchemy]
|
||||||
level = WARN
|
level = WARNING
|
||||||
handlers =
|
handlers =
|
||||||
qualname = sqlalchemy.engine
|
qualname = sqlalchemy.engine
|
||||||
|
|
||||||
@@ -34,3 +138,4 @@ formatter = generic
|
|||||||
|
|
||||||
[formatter_generic]
|
[formatter_generic]
|
||||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||||
|
datefmt = %H:%M:%S
|
||||||
|
|||||||
@@ -1,16 +1,18 @@
|
|||||||
from logging.config import fileConfig
|
from logging.config import fileConfig
|
||||||
|
|
||||||
from app.schemas.models import SQLModel
|
|
||||||
from sqlalchemy import engine_from_config
|
from sqlalchemy import engine_from_config
|
||||||
from sqlalchemy import pool
|
from sqlalchemy import pool
|
||||||
|
|
||||||
from alembic import context
|
from alembic import context
|
||||||
from app.core.config import settings
|
import os
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
from sqlmodel import SQLModel
|
||||||
|
from app.models import Client, Supplier, Product, Payment, Credit
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
# this is the Alembic Config object, which provides
|
# this is the Alembic Config object, which provides
|
||||||
# access to the values within the .ini file in use.
|
# access to the values within the .ini file in use.
|
||||||
config = context.config
|
config = context.config
|
||||||
config.set_main_option('sqlalchemy.url', str(settings.database_uri)) # type: ignore
|
|
||||||
|
|
||||||
# Interpret the config file for Python logging.
|
# Interpret the config file for Python logging.
|
||||||
# This line sets up loggers basically.
|
# This line sets up loggers basically.
|
||||||
@@ -22,6 +24,7 @@ if config.config_file_name is not None:
|
|||||||
# from myapp import mymodel
|
# from myapp import mymodel
|
||||||
# target_metadata = mymodel.Base.metadata
|
# target_metadata = mymodel.Base.metadata
|
||||||
target_metadata = SQLModel.metadata
|
target_metadata = SQLModel.metadata
|
||||||
|
url = os.getenv("DATABASE_URL")
|
||||||
|
|
||||||
# other values from the config, defined by the needs of env.py,
|
# other values from the config, defined by the needs of env.py,
|
||||||
# can be acquired:
|
# can be acquired:
|
||||||
@@ -63,6 +66,7 @@ def run_migrations_online() -> None:
|
|||||||
connectable = engine_from_config(
|
connectable = engine_from_config(
|
||||||
config.get_section(config.config_ini_section, {}),
|
config.get_section(config.config_ini_section, {}),
|
||||||
prefix="sqlalchemy.",
|
prefix="sqlalchemy.",
|
||||||
|
url=url,
|
||||||
poolclass=pool.NullPool,
|
poolclass=pool.NullPool,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ ${imports if imports else ""}
|
|||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
# revision identifiers, used by Alembic.
|
||||||
revision: str = ${repr(up_revision)}
|
revision: str = ${repr(up_revision)}
|
||||||
down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)}
|
down_revision: Union[str, None] = ${repr(down_revision)}
|
||||||
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||||
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||||
|
|
||||||
|
|||||||
@@ -1,32 +0,0 @@
|
|||||||
"""Initial tables
|
|
||||||
|
|
||||||
Revision ID: 0aa4734ce008
|
|
||||||
Revises:
|
|
||||||
Create Date: 2025-08-17 16:44:05.785214
|
|
||||||
|
|
||||||
"""
|
|
||||||
from typing import Sequence, Union
|
|
||||||
|
|
||||||
from alembic import op
|
|
||||||
import sqlalchemy as sa
|
|
||||||
|
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
|
||||||
revision: str = '0aa4734ce008'
|
|
||||||
down_revision: Union[str, Sequence[str], None] = None
|
|
||||||
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! ###
|
|
||||||
pass
|
|
||||||
# ### end Alembic commands ###
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
"""Downgrade schema."""
|
|
||||||
# ### commands auto generated by Alembic - please adjust! ###
|
|
||||||
pass
|
|
||||||
# ### end Alembic commands ###
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
"""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 ###
|
|
||||||
+12
-11
@@ -1,19 +1,20 @@
|
|||||||
"""Initial tables
|
"""Rebuild
|
||||||
|
|
||||||
Revision ID: 4966e016dd7c
|
Revision ID: 5840d2b52dd8
|
||||||
Revises: 0aa4734ce008
|
Revises:
|
||||||
Create Date: 2025-08-17 16:50:53.587969
|
Create Date: 2025-06-01 14:27:25.657473
|
||||||
|
|
||||||
"""
|
"""
|
||||||
from typing import Sequence, Union
|
from typing import Sequence, Union
|
||||||
|
|
||||||
from alembic import op
|
from alembic import op
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
import sqlmodel.sql.sqltypes
|
import sqlmodel
|
||||||
|
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
# revision identifiers, used by Alembic.
|
||||||
revision: str = '4966e016dd7c'
|
revision: str = '5840d2b52dd8'
|
||||||
down_revision: Union[str, Sequence[str], None] = '0aa4734ce008'
|
down_revision: Union[str, None] = None
|
||||||
branch_labels: Union[str, Sequence[str], None] = None
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
depends_on: Union[str, Sequence[str], None] = None
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
@@ -51,8 +52,8 @@ def upgrade() -> None:
|
|||||||
sa.Column('id', sa.Integer(), nullable=False),
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
sa.Column('transcation_type', sa.Enum('BUY', 'SELL', name='tradetype'), nullable=False),
|
sa.Column('transcation_type', sa.Enum('BUY', 'SELL', name='tradetype'), nullable=False),
|
||||||
sa.Column('product_code', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
sa.Column('product_code', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||||
sa.Column('client_id', sa.Integer(), nullable=False),
|
sa.Column('client_id', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||||
sa.Column('supplier_id', sa.Integer(), nullable=False),
|
sa.Column('supplier_id', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||||
sa.Column('qty', sa.Integer(), nullable=False),
|
sa.Column('qty', sa.Integer(), nullable=False),
|
||||||
sa.Column('amount', sa.Integer(), nullable=False),
|
sa.Column('amount', sa.Integer(), nullable=False),
|
||||||
sa.Column('date', sa.DateTime(), server_default=sa.text('now()'), nullable=True),
|
sa.Column('date', sa.DateTime(), server_default=sa.text('now()'), nullable=True),
|
||||||
@@ -65,8 +66,8 @@ def upgrade() -> None:
|
|||||||
sa.Column('id', sa.Integer(), nullable=False),
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
sa.Column('payment_type', sa.Enum('BUY', 'SELL', name='tradetype'), nullable=False),
|
sa.Column('payment_type', sa.Enum('BUY', 'SELL', name='tradetype'), nullable=False),
|
||||||
sa.Column('product_code', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
sa.Column('product_code', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||||
sa.Column('client_id', sa.Integer(), nullable=False),
|
sa.Column('client_id', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||||
sa.Column('supplier_id', sa.Integer(), nullable=False),
|
sa.Column('supplier_id', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||||
sa.Column('amount', sa.Integer(), nullable=False),
|
sa.Column('amount', sa.Integer(), nullable=False),
|
||||||
sa.Column('payment_method', sqlmodel.sql.sqltypes.AutoString(length=24), nullable=False),
|
sa.Column('payment_method', sqlmodel.sql.sqltypes.AutoString(length=24), nullable=False),
|
||||||
sa.Column('date', sa.DateTime(), server_default=sa.text('now()'), nullable=True),
|
sa.Column('date', sa.DateTime(), server_default=sa.text('now()'), nullable=True),
|
||||||
@@ -1,206 +0,0 @@
|
|||||||
"""Remove payment_method ENUM - Complexity not worth it when CheckConstraint can serve
|
|
||||||
|
|
||||||
Revision ID: 997376dc1774
|
|
||||||
Revises: e777b1b307b5
|
|
||||||
Create Date: 2025-08-25 23:18:53.106182
|
|
||||||
|
|
||||||
"""
|
|
||||||
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 = '997376dc1774'
|
|
||||||
down_revision: Union[str, Sequence[str], None] = 'e777b1b307b5'
|
|
||||||
branch_labels: Union[str, Sequence[str], None] = None
|
|
||||||
depends_on: Union[str, Sequence[str], None] = None
|
|
||||||
import sqlmodel.sql.sqltypes
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
"""Upgrade schema."""
|
|
||||||
# ### commands auto generated by Alembic - please adjust! ###
|
|
||||||
op.create_table('partner',
|
|
||||||
sa.Column('id', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('tin_number', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('names', sqlmodel.sql.sqltypes.AutoString(length=100), nullable=False),
|
|
||||||
sa.Column('type', sa.Enum('CLIENT', 'SUPPLIER', name='partnertype'), nullable=False),
|
|
||||||
sa.Column('phone_number', sqlmodel.sql.sqltypes.AutoString(length=10), nullable=True),
|
|
||||||
sa.PrimaryKeyConstraint('id'),
|
|
||||||
sa.UniqueConstraint('tin_number')
|
|
||||||
)
|
|
||||||
op.create_table('user',
|
|
||||||
sa.Column('id', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('username', sqlmodel.sql.sqltypes.AutoString(length=100), nullable=False),
|
|
||||||
sa.Column('role', sa.Enum('ADMIN', 'WRITE', 'READ_ONLY', name='userrole'), nullable=False),
|
|
||||||
sa.Column('password_hash', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
|
||||||
sa.PrimaryKeyConstraint('id'),
|
|
||||||
sa.UniqueConstraint('username')
|
|
||||||
)
|
|
||||||
op.create_table('inventory',
|
|
||||||
sa.Column('id', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('product_id', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('total_qty', sa.Integer(), nullable=False),
|
|
||||||
sa.ForeignKeyConstraint(['product_id'], ['product.id'], ),
|
|
||||||
sa.PrimaryKeyConstraint('id'),
|
|
||||||
sa.UniqueConstraint('product_id')
|
|
||||||
)
|
|
||||||
op.create_table('transaction_details',
|
|
||||||
sa.Column('id', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('partner_id', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('product_id', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('qty', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('selling_price', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('total_value', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('created_by', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('updated_by', sa.Integer(), nullable=False),
|
|
||||||
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.ForeignKeyConstraint(['created_by'], ['user.id'], ),
|
|
||||||
sa.ForeignKeyConstraint(['partner_id'], ['partner.id'], ),
|
|
||||||
sa.ForeignKeyConstraint(['product_id'], ['product.id'], ),
|
|
||||||
sa.ForeignKeyConstraint(['updated_by'], ['user.id'], ),
|
|
||||||
sa.PrimaryKeyConstraint('id')
|
|
||||||
)
|
|
||||||
op.create_table('transactions',
|
|
||||||
sa.Column('id', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('partner_id', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('transcation_type', sa.Enum('SALE', 'PURCHASE', 'CREDIT', name='transactiontype'), nullable=False),
|
|
||||||
sa.Column('transaction_status', sa.Enum('UNPAID', 'PARTIALLY_PAID', 'PAID', 'CANCELLED', name='transactionstatus'), nullable=False),
|
|
||||||
sa.Column('total_amount', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('created_by', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('updated_by', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('created_on', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
|
|
||||||
sa.Column('updated_on', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
|
|
||||||
sa.ForeignKeyConstraint(['created_by'], ['user.id'], ),
|
|
||||||
sa.ForeignKeyConstraint(['partner_id'], ['partner.id'], ),
|
|
||||||
sa.ForeignKeyConstraint(['updated_by'], ['user.id'], ),
|
|
||||||
sa.PrimaryKeyConstraint('id')
|
|
||||||
)
|
|
||||||
op.create_table('credit_accounts',
|
|
||||||
sa.Column('id', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('partner_id', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('transaction_id', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('credit_amount', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('credit_limit', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('balance', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('created_by', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('updated_by', sa.Integer(), nullable=False),
|
|
||||||
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.ForeignKeyConstraint(['created_by'], ['user.id'], ),
|
|
||||||
sa.ForeignKeyConstraint(['partner_id'], ['partner.id'], ),
|
|
||||||
sa.ForeignKeyConstraint(['transaction_id'], ['transactions.id'], ),
|
|
||||||
sa.ForeignKeyConstraint(['updated_by'], ['user.id'], ),
|
|
||||||
sa.PrimaryKeyConstraint('id'),
|
|
||||||
sa.UniqueConstraint('partner_id')
|
|
||||||
)
|
|
||||||
|
|
||||||
op.add_column('payment', sa.Column('transaction_id', sa.Integer(), nullable=False))
|
|
||||||
op.add_column('payment', sa.Column('paid_amount', sa.Integer(), nullable=False))
|
|
||||||
op.add_column('payment', sa.Column('payment_date', sa.Date(), nullable=False))
|
|
||||||
op.add_column('payment', sa.Column('created_by', sa.Integer(), nullable=False))
|
|
||||||
op.add_column('payment', sa.Column('updated_by', sa.Integer(), nullable=False))
|
|
||||||
op.add_column('payment', sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True))
|
|
||||||
op.add_column('payment', sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True))
|
|
||||||
op.alter_column('payment', 'payment_method',
|
|
||||||
existing_type=sa.VARCHAR(length=24),
|
|
||||||
type_=sa.String(length=10),
|
|
||||||
existing_nullable=False)
|
|
||||||
op.drop_constraint(op.f('payment_client_id_fkey'), 'payment', type_='foreignkey')
|
|
||||||
op.drop_constraint(op.f('payment_supplier_id_fkey'), 'payment', type_='foreignkey')
|
|
||||||
op.drop_constraint(op.f('payment_product_code_fkey'), 'payment', type_='foreignkey')
|
|
||||||
op.drop_table('credit')
|
|
||||||
op.drop_table('supplier')
|
|
||||||
op.drop_table('client')
|
|
||||||
|
|
||||||
op.create_foreign_key(None, 'payment', 'transactions', ['transaction_id'], ['id'])
|
|
||||||
op.create_foreign_key(None, 'payment', 'user', ['created_by'], ['id'])
|
|
||||||
op.create_foreign_key(None, 'payment', 'user', ['updated_by'], ['id'])
|
|
||||||
op.drop_column('payment', 'product_code')
|
|
||||||
op.drop_column('payment', 'payment_type')
|
|
||||||
op.drop_column('payment', 'date')
|
|
||||||
op.drop_column('payment', 'amount')
|
|
||||||
op.drop_column('payment', 'client_id')
|
|
||||||
op.drop_column('payment', 'supplier_id')
|
|
||||||
op.add_column('product', sa.Column('selling_price', sa.Integer(), nullable=False))
|
|
||||||
op.alter_column('product', 'date_modified',
|
|
||||||
existing_type=postgresql.TIMESTAMP(),
|
|
||||||
type_=sa.DateTime(timezone=True),
|
|
||||||
existing_nullable=True,
|
|
||||||
existing_server_default=sa.text('now()'))
|
|
||||||
# ### end Alembic commands ###
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
"""Downgrade schema."""
|
|
||||||
# ### commands auto generated by Alembic - please adjust! ###
|
|
||||||
op.alter_column('product', 'date_modified',
|
|
||||||
existing_type=sa.DateTime(timezone=True),
|
|
||||||
type_=postgresql.TIMESTAMP(),
|
|
||||||
existing_nullable=True,
|
|
||||||
existing_server_default=sa.text('now()'))
|
|
||||||
op.drop_column('product', 'selling_price')
|
|
||||||
op.add_column('payment', sa.Column('supplier_id', sa.INTEGER(), autoincrement=False, nullable=False))
|
|
||||||
op.add_column('payment', sa.Column('client_id', sa.INTEGER(), autoincrement=False, nullable=False))
|
|
||||||
op.add_column('payment', sa.Column('amount', sa.INTEGER(), autoincrement=False, nullable=False))
|
|
||||||
op.add_column('payment', sa.Column('date', postgresql.TIMESTAMP(), server_default=sa.text('now()'), autoincrement=False, nullable=True))
|
|
||||||
op.add_column('payment', sa.Column('payment_type', postgresql.ENUM('BUY', 'SELL', name='tradetype'), autoincrement=False, nullable=False))
|
|
||||||
op.add_column('payment', sa.Column('product_code', sa.VARCHAR(), autoincrement=False, nullable=False))
|
|
||||||
op.drop_constraint(None, 'payment', type_='foreignkey')
|
|
||||||
op.drop_constraint(None, 'payment', type_='foreignkey')
|
|
||||||
op.drop_constraint(None, 'payment', type_='foreignkey')
|
|
||||||
op.create_foreign_key(op.f('payment_product_code_fkey'), 'payment', 'product', ['product_code'], ['product_code'])
|
|
||||||
op.create_foreign_key(op.f('payment_supplier_id_fkey'), 'payment', 'supplier', ['supplier_id'], ['id'])
|
|
||||||
op.create_foreign_key(op.f('payment_client_id_fkey'), 'payment', 'client', ['client_id'], ['id'])
|
|
||||||
op.alter_column('payment', 'payment_method',
|
|
||||||
existing_type=sa.String(length=10),
|
|
||||||
type_=sa.VARCHAR(length=24),
|
|
||||||
existing_nullable=False)
|
|
||||||
op.drop_column('payment', 'updated_at')
|
|
||||||
op.drop_column('payment', 'created_at')
|
|
||||||
op.drop_column('payment', 'updated_by')
|
|
||||||
op.drop_column('payment', 'created_by')
|
|
||||||
op.drop_column('payment', 'payment_date')
|
|
||||||
op.drop_column('payment', 'paid_amount')
|
|
||||||
op.drop_column('payment', 'transaction_id')
|
|
||||||
op.create_table('client',
|
|
||||||
sa.Column('id', sa.INTEGER(), server_default=sa.text("nextval('client_id_seq'::regclass)"), autoincrement=True, nullable=False),
|
|
||||||
sa.Column('tin_number', sa.INTEGER(), autoincrement=False, nullable=False),
|
|
||||||
sa.Column('names', sa.VARCHAR(length=100), autoincrement=False, nullable=False),
|
|
||||||
sa.Column('phone_number', sa.VARCHAR(length=10), autoincrement=False, nullable=False),
|
|
||||||
sa.PrimaryKeyConstraint('id', name='client_pkey'),
|
|
||||||
sa.UniqueConstraint('tin_number', name='client_tin_number_key', postgresql_include=[], postgresql_nulls_not_distinct=False),
|
|
||||||
postgresql_ignore_search_path=False
|
|
||||||
)
|
|
||||||
op.create_table('credit',
|
|
||||||
sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False),
|
|
||||||
sa.Column('transcation_type', postgresql.ENUM('BUY', 'SELL', name='tradetype'), autoincrement=False, nullable=False),
|
|
||||||
sa.Column('product_code', sa.VARCHAR(), autoincrement=False, nullable=False),
|
|
||||||
sa.Column('client_id', sa.INTEGER(), autoincrement=False, nullable=False),
|
|
||||||
sa.Column('supplier_id', sa.INTEGER(), autoincrement=False, nullable=False),
|
|
||||||
sa.Column('qty', sa.INTEGER(), autoincrement=False, nullable=False),
|
|
||||||
sa.Column('amount', sa.INTEGER(), autoincrement=False, nullable=False),
|
|
||||||
sa.Column('date', postgresql.TIMESTAMP(), server_default=sa.text('now()'), autoincrement=False, nullable=True),
|
|
||||||
sa.ForeignKeyConstraint(['client_id'], ['client.id'], name=op.f('credit_client_id_fkey')),
|
|
||||||
sa.ForeignKeyConstraint(['product_code'], ['product.product_code'], name=op.f('credit_product_code_fkey')),
|
|
||||||
sa.ForeignKeyConstraint(['supplier_id'], ['supplier.id'], name=op.f('credit_supplier_id_fkey')),
|
|
||||||
sa.PrimaryKeyConstraint('id', name=op.f('credit_pkey'))
|
|
||||||
)
|
|
||||||
op.create_table('supplier',
|
|
||||||
sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False),
|
|
||||||
sa.Column('tin_number', sa.INTEGER(), autoincrement=False, nullable=False),
|
|
||||||
sa.Column('names', sa.VARCHAR(length=100), autoincrement=False, nullable=False),
|
|
||||||
sa.Column('phone_number', sa.VARCHAR(length=10), autoincrement=False, nullable=False),
|
|
||||||
sa.PrimaryKeyConstraint('id', name=op.f('supplier_pkey')),
|
|
||||||
sa.UniqueConstraint('tin_number', name=op.f('supplier_tin_number_key'), postgresql_include=[], postgresql_nulls_not_distinct=False)
|
|
||||||
)
|
|
||||||
op.drop_table('credit_accounts')
|
|
||||||
op.drop_table('transactions')
|
|
||||||
op.drop_table('transaction_details')
|
|
||||||
op.drop_table('inventory')
|
|
||||||
op.drop_table('user')
|
|
||||||
op.drop_table('partner')
|
|
||||||
# ### end Alembic commands ###
|
|
||||||
@@ -1,203 +0,0 @@
|
|||||||
"""Update table models to better logic & table relationship (Transaction + payment feed into credit accounts etc...)
|
|
||||||
|
|
||||||
Revision ID: a4126dbcfd9e
|
|
||||||
Revises: 4966e016dd7c
|
|
||||||
Create Date: 2025-08-25 22:25:57.071318
|
|
||||||
|
|
||||||
"""
|
|
||||||
from typing import Sequence, Union
|
|
||||||
|
|
||||||
from alembic import op
|
|
||||||
import sqlalchemy as sa
|
|
||||||
from sqlalchemy.dialects import postgresql
|
|
||||||
import sqlmodel.sql.sqltypes
|
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
|
||||||
revision: str = 'a4126dbcfd9e'
|
|
||||||
down_revision: Union[str, Sequence[str], None] = '4966e016dd7c'
|
|
||||||
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! ###
|
|
||||||
op.create_table('partner',
|
|
||||||
sa.Column('id', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('tin_number', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('names', sqlmodel.sql.sqltypes.AutoString(length=100), nullable=False),
|
|
||||||
sa.Column('type', sa.Enum('CLIENT', 'SUPPLIER', name='partnertype'), nullable=False),
|
|
||||||
sa.Column('phone_number', sqlmodel.sql.sqltypes.AutoString(length=10), nullable=True),
|
|
||||||
sa.PrimaryKeyConstraint('id'),
|
|
||||||
sa.UniqueConstraint('tin_number')
|
|
||||||
)
|
|
||||||
op.create_table('user',
|
|
||||||
sa.Column('id', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('username', sqlmodel.sql.sqltypes.AutoString(length=100), nullable=False),
|
|
||||||
sa.Column('role', sa.Enum('ADMIN', 'WRITE', 'READ_ONLY', name='userrole'), nullable=False),
|
|
||||||
sa.Column('password_hash', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
|
||||||
sa.PrimaryKeyConstraint('id'),
|
|
||||||
sa.UniqueConstraint('username')
|
|
||||||
)
|
|
||||||
op.create_table('inventory',
|
|
||||||
sa.Column('id', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('product_id', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('total_qty', sa.Integer(), nullable=False),
|
|
||||||
sa.ForeignKeyConstraint(['product_id'], ['product.id'], ),
|
|
||||||
sa.PrimaryKeyConstraint('id'),
|
|
||||||
sa.UniqueConstraint('product_id')
|
|
||||||
)
|
|
||||||
op.create_table('transaction_details',
|
|
||||||
sa.Column('id', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('partner_id', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('product_id', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
|
||||||
sa.Column('qty', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('selling_price', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('total_value', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('created_by', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('updated_by', sa.Integer(), nullable=False),
|
|
||||||
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.ForeignKeyConstraint(['created_by'], ['user.id'], ),
|
|
||||||
sa.ForeignKeyConstraint(['partner_id'], ['partner.id'], ),
|
|
||||||
sa.ForeignKeyConstraint(['product_id'], ['product.id'], ),
|
|
||||||
sa.ForeignKeyConstraint(['updated_by'], ['user.id'], ),
|
|
||||||
sa.PrimaryKeyConstraint('id')
|
|
||||||
)
|
|
||||||
op.create_table('transactions',
|
|
||||||
sa.Column('id', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('partner_id', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('transcation_type', sa.Enum('SALE', 'PURCHASE', 'CREDIT', name='transactiontype'), nullable=False),
|
|
||||||
sa.Column('transaction_status', sa.Enum('UNPAID', 'PARTIALLY_PAID', 'PAID', 'CANCELLED', name='transactionstatus'), nullable=False),
|
|
||||||
sa.Column('total_amount', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('created_by', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('updated_by', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('created_on', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
|
|
||||||
sa.Column('updated_on', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
|
|
||||||
sa.ForeignKeyConstraint(['created_by'], ['user.id'], ),
|
|
||||||
sa.ForeignKeyConstraint(['partner_id'], ['partner.id'], ),
|
|
||||||
sa.ForeignKeyConstraint(['updated_by'], ['user.id'], ),
|
|
||||||
sa.PrimaryKeyConstraint('id')
|
|
||||||
)
|
|
||||||
op.create_table('credit_accounts',
|
|
||||||
sa.Column('id', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('partner_id', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('transaction_id', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('credit_amount', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('credit_limit', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('balance', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('created_by', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('updated_by', sa.Integer(), nullable=False),
|
|
||||||
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.ForeignKeyConstraint(['created_by'], ['user.id'], ),
|
|
||||||
sa.ForeignKeyConstraint(['partner_id'], ['partner.id'], ),
|
|
||||||
sa.ForeignKeyConstraint(['transaction_id'], ['transactions.id'], ),
|
|
||||||
sa.ForeignKeyConstraint(['updated_by'], ['user.id'], ),
|
|
||||||
sa.PrimaryKeyConstraint('id'),
|
|
||||||
sa.UniqueConstraint('partner_id')
|
|
||||||
)
|
|
||||||
op.drop_table('client')
|
|
||||||
op.drop_table('supplier')
|
|
||||||
op.drop_table('credit')
|
|
||||||
op.add_column('payment', sa.Column('transaction_id', sa.Integer(), nullable=False))
|
|
||||||
op.add_column('payment', sa.Column('paid_amount', sa.Integer(), nullable=False))
|
|
||||||
op.add_column('payment', sa.Column('payment_date', sa.Date(), nullable=False))
|
|
||||||
op.add_column('payment', sa.Column('created_by', sa.Integer(), nullable=False))
|
|
||||||
op.add_column('payment', sa.Column('updated_by', sa.Integer(), nullable=False))
|
|
||||||
op.add_column('payment', sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True))
|
|
||||||
op.add_column('payment', sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True))
|
|
||||||
op.alter_column('payment', 'payment_method',
|
|
||||||
existing_type=sa.VARCHAR(length=24),
|
|
||||||
type_=sa.Enum('MOMO', 'BANK', 'CASH', name='paymentmethod'),
|
|
||||||
existing_nullable=False)
|
|
||||||
op.drop_constraint(op.f('payment_supplier_id_fkey'), 'payment', type_='foreignkey')
|
|
||||||
op.drop_constraint(op.f('payment_client_id_fkey'), 'payment', type_='foreignkey')
|
|
||||||
op.drop_constraint(op.f('payment_product_code_fkey'), 'payment', type_='foreignkey')
|
|
||||||
op.create_foreign_key(None, 'payment', 'transactions', ['transaction_id'], ['id'])
|
|
||||||
op.create_foreign_key(None, 'payment', 'user', ['updated_by'], ['id'])
|
|
||||||
op.create_foreign_key(None, 'payment', 'user', ['created_by'], ['id'])
|
|
||||||
op.drop_column('payment', 'product_code')
|
|
||||||
op.drop_column('payment', 'supplier_id')
|
|
||||||
op.drop_column('payment', 'client_id')
|
|
||||||
op.drop_column('payment', 'amount')
|
|
||||||
op.drop_column('payment', 'date')
|
|
||||||
op.drop_column('payment', 'payment_type')
|
|
||||||
op.add_column('product', sa.Column('selling_price', sa.Integer(), nullable=False))
|
|
||||||
op.alter_column('product', 'date_modified',
|
|
||||||
existing_type=postgresql.TIMESTAMP(),
|
|
||||||
type_=sa.DateTime(timezone=True),
|
|
||||||
existing_nullable=True,
|
|
||||||
existing_server_default=sa.text('now()'))
|
|
||||||
# ### end Alembic commands ###
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
"""Downgrade schema."""
|
|
||||||
# ### commands auto generated by Alembic - please adjust! ###
|
|
||||||
op.alter_column('product', 'date_modified',
|
|
||||||
existing_type=sa.DateTime(timezone=True),
|
|
||||||
type_=postgresql.TIMESTAMP(),
|
|
||||||
existing_nullable=True,
|
|
||||||
existing_server_default=sa.text('now()'))
|
|
||||||
op.drop_column('product', 'selling_price')
|
|
||||||
op.add_column('payment', sa.Column('payment_type', postgresql.ENUM('BUY', 'SELL', name='tradetype'), autoincrement=False, nullable=False))
|
|
||||||
op.add_column('payment', sa.Column('date', postgresql.TIMESTAMP(), server_default=sa.text('now()'), autoincrement=False, nullable=True))
|
|
||||||
op.add_column('payment', sa.Column('amount', sa.INTEGER(), autoincrement=False, nullable=False))
|
|
||||||
op.add_column('payment', sa.Column('client_id', sa.INTEGER(), autoincrement=False, nullable=False))
|
|
||||||
op.add_column('payment', sa.Column('supplier_id', sa.INTEGER(), autoincrement=False, nullable=False))
|
|
||||||
op.add_column('payment', sa.Column('product_code', sa.VARCHAR(), autoincrement=False, nullable=False))
|
|
||||||
op.drop_constraint(None, 'payment', type_='foreignkey')
|
|
||||||
op.drop_constraint(None, 'payment', type_='foreignkey')
|
|
||||||
op.drop_constraint(None, 'payment', type_='foreignkey')
|
|
||||||
op.create_foreign_key(op.f('payment_product_code_fkey'), 'payment', 'product', ['product_code'], ['product_code'])
|
|
||||||
op.create_foreign_key(op.f('payment_client_id_fkey'), 'payment', 'client', ['client_id'], ['id'])
|
|
||||||
op.create_foreign_key(op.f('payment_supplier_id_fkey'), 'payment', 'supplier', ['supplier_id'], ['id'])
|
|
||||||
op.alter_column('payment', 'payment_method',
|
|
||||||
existing_type=sa.Enum('MOMO', 'BANK', 'CASH', name='paymentmethod'),
|
|
||||||
type_=sa.VARCHAR(length=24),
|
|
||||||
existing_nullable=False)
|
|
||||||
op.drop_column('payment', 'updated_at')
|
|
||||||
op.drop_column('payment', 'created_at')
|
|
||||||
op.drop_column('payment', 'updated_by')
|
|
||||||
op.drop_column('payment', 'created_by')
|
|
||||||
op.drop_column('payment', 'payment_date')
|
|
||||||
op.drop_column('payment', 'paid_amount')
|
|
||||||
op.drop_column('payment', 'transaction_id')
|
|
||||||
op.create_table('credit',
|
|
||||||
sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False),
|
|
||||||
sa.Column('transcation_type', postgresql.ENUM('BUY', 'SELL', name='tradetype'), autoincrement=False, nullable=False),
|
|
||||||
sa.Column('product_code', sa.VARCHAR(), autoincrement=False, nullable=False),
|
|
||||||
sa.Column('client_id', sa.INTEGER(), autoincrement=False, nullable=False),
|
|
||||||
sa.Column('supplier_id', sa.INTEGER(), autoincrement=False, nullable=False),
|
|
||||||
sa.Column('qty', sa.INTEGER(), autoincrement=False, nullable=False),
|
|
||||||
sa.Column('amount', sa.INTEGER(), autoincrement=False, nullable=False),
|
|
||||||
sa.Column('date', postgresql.TIMESTAMP(), server_default=sa.text('now()'), autoincrement=False, nullable=True),
|
|
||||||
sa.ForeignKeyConstraint(['client_id'], ['client.id'], name=op.f('credit_client_id_fkey')),
|
|
||||||
sa.ForeignKeyConstraint(['product_code'], ['product.product_code'], name=op.f('credit_product_code_fkey')),
|
|
||||||
sa.ForeignKeyConstraint(['supplier_id'], ['supplier.id'], name=op.f('credit_supplier_id_fkey')),
|
|
||||||
sa.PrimaryKeyConstraint('id', name=op.f('credit_pkey'))
|
|
||||||
)
|
|
||||||
op.create_table('supplier',
|
|
||||||
sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False),
|
|
||||||
sa.Column('tin_number', sa.INTEGER(), autoincrement=False, nullable=False),
|
|
||||||
sa.Column('names', sa.VARCHAR(length=100), autoincrement=False, nullable=False),
|
|
||||||
sa.Column('phone_number', sa.VARCHAR(length=10), autoincrement=False, nullable=False),
|
|
||||||
sa.PrimaryKeyConstraint('id', name=op.f('supplier_pkey')),
|
|
||||||
sa.UniqueConstraint('tin_number', name=op.f('supplier_tin_number_key'), postgresql_include=[], postgresql_nulls_not_distinct=False)
|
|
||||||
)
|
|
||||||
op.create_table('client',
|
|
||||||
sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False),
|
|
||||||
sa.Column('tin_number', sa.INTEGER(), autoincrement=False, nullable=False),
|
|
||||||
sa.Column('names', sa.VARCHAR(length=100), autoincrement=False, nullable=False),
|
|
||||||
sa.Column('phone_number', sa.VARCHAR(length=10), autoincrement=False, nullable=False),
|
|
||||||
sa.PrimaryKeyConstraint('id', name=op.f('client_pkey')),
|
|
||||||
sa.UniqueConstraint('tin_number', name=op.f('client_tin_number_key'), postgresql_include=[], postgresql_nulls_not_distinct=False)
|
|
||||||
)
|
|
||||||
op.drop_table('credit_accounts')
|
|
||||||
op.drop_table('transactions')
|
|
||||||
op.drop_table('transaction_details')
|
|
||||||
op.drop_table('inventory')
|
|
||||||
op.drop_table('user')
|
|
||||||
op.drop_table('partner')
|
|
||||||
# ### end Alembic commands ###
|
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
"""Fix client_id in Credit type to int
|
||||||
|
|
||||||
|
Revision ID: bfb086d8d500
|
||||||
|
Revises: 5840d2b52dd8
|
||||||
|
Create Date: 2025-06-01 14:53:57.095181
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
import sqlmodel
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = 'bfb086d8d500'
|
||||||
|
down_revision: Union[str, None] = '5840d2b52dd8'
|
||||||
|
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! ###
|
||||||
|
op.create_table('credit',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('transcation_type', sa.Enum('BUY', 'SELL', name='tradetype'), nullable=False),
|
||||||
|
sa.Column('product_code', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||||
|
sa.Column('client_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('supplier_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('qty', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('amount', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('date', sa.DateTime(), server_default=sa.text('now()'), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(['client_id'], ['client.id'], ),
|
||||||
|
sa.ForeignKeyConstraint(['product_code'], ['product.product_code'], ),
|
||||||
|
sa.ForeignKeyConstraint(['supplier_id'], ['supplier.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_table('payment',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('payment_type', sa.Enum('BUY', 'SELL', name='tradetype'), nullable=False),
|
||||||
|
sa.Column('product_code', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||||
|
sa.Column('client_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('supplier_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('amount', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('payment_method', sqlmodel.sql.sqltypes.AutoString(length=24), nullable=False),
|
||||||
|
sa.Column('date', sa.DateTime(), server_default=sa.text('now()'), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(['client_id'], ['client.id'], ),
|
||||||
|
sa.ForeignKeyConstraint(['product_code'], ['product.product_code'], ),
|
||||||
|
sa.ForeignKeyConstraint(['supplier_id'], ['supplier.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Downgrade schema."""
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_table('payment')
|
||||||
|
op.drop_table('credit')
|
||||||
|
# ### end Alembic commands ###
|
||||||
@@ -1,219 +0,0 @@
|
|||||||
"""product.id to match types with product_id in transaction_detail
|
|
||||||
|
|
||||||
Revision ID: e777b1b307b5
|
|
||||||
Revises: a4126dbcfd9e
|
|
||||||
Create Date: 2025-08-25 22:34:19.869427
|
|
||||||
|
|
||||||
"""
|
|
||||||
from typing import Sequence, Union
|
|
||||||
|
|
||||||
from alembic import op
|
|
||||||
import sqlalchemy as sa
|
|
||||||
from sqlalchemy.dialects import postgresql
|
|
||||||
import sqlmodel.sql.sqltypes
|
|
||||||
# revision identifiers, used by Alembic.
|
|
||||||
revision: str = 'e777b1b307b5'
|
|
||||||
down_revision: Union[str, Sequence[str], None] = 'a4126dbcfd9e'
|
|
||||||
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! ###
|
|
||||||
|
|
||||||
# FIRST: Remove foreign key constraints and columns from payment table
|
|
||||||
op.drop_constraint('payment_supplier_id_fkey', 'payment', type_='foreignkey')
|
|
||||||
op.drop_constraint('payment_client_id_fkey', 'payment', type_='foreignkey')
|
|
||||||
op.drop_constraint('payment_product_code_fkey', 'payment', type_='foreignkey')
|
|
||||||
|
|
||||||
op.drop_column('payment', 'product_code')
|
|
||||||
op.drop_column('payment', 'date')
|
|
||||||
op.drop_column('payment', 'amount')
|
|
||||||
op.drop_column('payment', 'payment_type')
|
|
||||||
op.drop_column('payment', 'client_id')
|
|
||||||
op.drop_column('payment', 'supplier_id')
|
|
||||||
|
|
||||||
# THEN: Drop the referenced tables (now safe)
|
|
||||||
op.drop_table('credit')
|
|
||||||
op.drop_table('supplier')
|
|
||||||
op.drop_table('client')
|
|
||||||
|
|
||||||
# Create new tables
|
|
||||||
op.create_table('partner',
|
|
||||||
sa.Column('id', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('tin_number', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('names', sqlmodel.sql.sqltypes.AutoString(length=100), nullable=False),
|
|
||||||
sa.Column('type', sa.Enum('CLIENT', 'SUPPLIER', name='partnertype'), nullable=False),
|
|
||||||
sa.Column('phone_number', sqlmodel.sql.sqltypes.AutoString(length=10), nullable=True),
|
|
||||||
sa.PrimaryKeyConstraint('id'),
|
|
||||||
sa.UniqueConstraint('tin_number')
|
|
||||||
)
|
|
||||||
op.create_table('user',
|
|
||||||
sa.Column('id', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('username', sqlmodel.sql.sqltypes.AutoString(length=100), nullable=False),
|
|
||||||
sa.Column('role', sa.Enum('ADMIN', 'WRITE', 'READ_ONLY', name='userrole'), nullable=False),
|
|
||||||
sa.Column('password_hash', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
|
||||||
sa.PrimaryKeyConstraint('id'),
|
|
||||||
sa.UniqueConstraint('username')
|
|
||||||
)
|
|
||||||
op.create_table('inventory',
|
|
||||||
sa.Column('id', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('product_id', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('total_qty', sa.Integer(), nullable=False),
|
|
||||||
sa.ForeignKeyConstraint(['product_id'], ['product.id'], ),
|
|
||||||
sa.PrimaryKeyConstraint('id'),
|
|
||||||
sa.UniqueConstraint('product_id')
|
|
||||||
)
|
|
||||||
op.create_table('transaction_details',
|
|
||||||
sa.Column('id', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('partner_id', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('product_id', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('qty', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('selling_price', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('total_value', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('created_by', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('updated_by', sa.Integer(), nullable=False),
|
|
||||||
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.ForeignKeyConstraint(['created_by'], ['user.id'], ),
|
|
||||||
sa.ForeignKeyConstraint(['partner_id'], ['partner.id'], ),
|
|
||||||
sa.ForeignKeyConstraint(['product_id'], ['product.id'], ),
|
|
||||||
sa.ForeignKeyConstraint(['updated_by'], ['user.id'], ),
|
|
||||||
sa.PrimaryKeyConstraint('id')
|
|
||||||
)
|
|
||||||
op.create_table('transactions',
|
|
||||||
sa.Column('id', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('partner_id', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('transcation_type', sa.Enum('SALE', 'PURCHASE', 'CREDIT', name='transactiontype'), nullable=False),
|
|
||||||
sa.Column('transaction_status', sa.Enum('UNPAID', 'PARTIALLY_PAID', 'PAID', 'CANCELLED', name='transactionstatus'), nullable=False),
|
|
||||||
sa.Column('total_amount', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('created_by', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('updated_by', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('created_on', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
|
|
||||||
sa.Column('updated_on', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
|
|
||||||
sa.ForeignKeyConstraint(['created_by'], ['user.id'], ),
|
|
||||||
sa.ForeignKeyConstraint(['partner_id'], ['partner.id'], ),
|
|
||||||
sa.ForeignKeyConstraint(['updated_by'], ['user.id'], ),
|
|
||||||
sa.PrimaryKeyConstraint('id')
|
|
||||||
)
|
|
||||||
op.create_table('credit_accounts',
|
|
||||||
sa.Column('id', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('partner_id', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('transaction_id', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('credit_amount', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('credit_limit', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('balance', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('created_by', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('updated_by', sa.Integer(), nullable=False),
|
|
||||||
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.ForeignKeyConstraint(['created_by'], ['user.id'], ),
|
|
||||||
sa.ForeignKeyConstraint(['partner_id'], ['partner.id'], ),
|
|
||||||
sa.ForeignKeyConstraint(['transaction_id'], ['transactions.id'], ),
|
|
||||||
sa.ForeignKeyConstraint(['updated_by'], ['user.id'], ),
|
|
||||||
sa.PrimaryKeyConstraint('id'),
|
|
||||||
sa.UniqueConstraint('partner_id')
|
|
||||||
)
|
|
||||||
|
|
||||||
# FINALLY: Add new columns and constraints to payment table
|
|
||||||
op.add_column('payment', sa.Column('transaction_id', sa.Integer(), nullable=False))
|
|
||||||
op.add_column('payment', sa.Column('paid_amount', sa.Integer(), nullable=False))
|
|
||||||
op.add_column('payment', sa.Column('payment_date', sa.Date(), nullable=False))
|
|
||||||
op.add_column('payment', sa.Column('created_by', sa.Integer(), nullable=False))
|
|
||||||
op.add_column('payment', sa.Column('updated_by', sa.Integer(), nullable=False))
|
|
||||||
op.add_column('payment', sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True))
|
|
||||||
op.add_column('payment', sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True))
|
|
||||||
|
|
||||||
op.execute("CREATE TYPE paymentmethod AS ENUM ('momo', 'bank', 'cash')")
|
|
||||||
|
|
||||||
op.alter_column('payment', 'payment_method',
|
|
||||||
existing_type=sa.VARCHAR(length=24),
|
|
||||||
type_=sa.Enum('momo', 'bank', 'cash', name='paymentmethod'),
|
|
||||||
existing_nullable=False,
|
|
||||||
postgresql_using='payment_method::paymentmethod')
|
|
||||||
|
|
||||||
op.create_foreign_key(None, 'payment', 'transactions', ['transaction_id'], ['id'])
|
|
||||||
op.create_foreign_key(None, 'payment', 'user', ['created_by'], ['id'])
|
|
||||||
op.create_foreign_key(None, 'payment', 'user', ['updated_by'], ['id'])
|
|
||||||
|
|
||||||
op.add_column('product', sa.Column('selling_price', sa.Integer(), nullable=False))
|
|
||||||
op.alter_column('product', 'date_modified',
|
|
||||||
existing_type=postgresql.TIMESTAMP(),
|
|
||||||
type_=sa.DateTime(timezone=True),
|
|
||||||
existing_nullable=True,
|
|
||||||
existing_server_default=sa.text('now()'))
|
|
||||||
# ### end Alembic commands ###
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
"""Downgrade schema."""
|
|
||||||
# ### commands auto generated by Alembic - please adjust! ###
|
|
||||||
op.alter_column('product', 'date_modified',
|
|
||||||
existing_type=sa.DateTime(timezone=True),
|
|
||||||
type_=postgresql.TIMESTAMP(),
|
|
||||||
existing_nullable=True,
|
|
||||||
existing_server_default=sa.text('now()'))
|
|
||||||
op.drop_column('product', 'selling_price')
|
|
||||||
op.add_column('payment', sa.Column('supplier_id', sa.INTEGER(), autoincrement=False, nullable=False))
|
|
||||||
op.add_column('payment', sa.Column('client_id', sa.INTEGER(), autoincrement=False, nullable=False))
|
|
||||||
op.add_column('payment', sa.Column('payment_type', postgresql.ENUM('BUY', 'SELL', name='tradetype'), autoincrement=False, nullable=False))
|
|
||||||
op.add_column('payment', sa.Column('amount', sa.INTEGER(), autoincrement=False, nullable=False))
|
|
||||||
op.add_column('payment', sa.Column('date', postgresql.TIMESTAMP(), server_default=sa.text('now()'), autoincrement=False, nullable=True))
|
|
||||||
op.add_column('payment', sa.Column('product_code', sa.VARCHAR(), autoincrement=False, nullable=False))
|
|
||||||
op.drop_constraint(None, 'payment', type_='foreignkey')
|
|
||||||
op.drop_constraint(None, 'payment', type_='foreignkey')
|
|
||||||
op.drop_constraint(None, 'payment', type_='foreignkey')
|
|
||||||
op.create_foreign_key(op.f('payment_product_code_fkey'), 'payment', 'product', ['product_code'], ['product_code'])
|
|
||||||
op.create_foreign_key(op.f('payment_client_id_fkey'), 'payment', 'client', ['client_id'], ['id'])
|
|
||||||
op.create_foreign_key(op.f('payment_supplier_id_fkey'), 'payment', 'supplier', ['supplier_id'], ['id'])
|
|
||||||
op.alter_column('payment', 'payment_method',
|
|
||||||
existing_type=sa.Enum('MOMO', 'BANK', 'CASH', name='paymentmethod'),
|
|
||||||
type_=sa.VARCHAR(length=24),
|
|
||||||
existing_nullable=False)
|
|
||||||
op.drop_column('payment', 'updated_at')
|
|
||||||
op.drop_column('payment', 'created_at')
|
|
||||||
op.drop_column('payment', 'updated_by')
|
|
||||||
op.drop_column('payment', 'created_by')
|
|
||||||
op.drop_column('payment', 'payment_date')
|
|
||||||
op.drop_column('payment', 'paid_amount')
|
|
||||||
op.drop_column('payment', 'transaction_id')
|
|
||||||
op.create_table('client',
|
|
||||||
sa.Column('id', sa.INTEGER(), server_default=sa.text("nextval('client_id_seq'::regclass)"), autoincrement=True, nullable=False),
|
|
||||||
sa.Column('tin_number', sa.INTEGER(), autoincrement=False, nullable=False),
|
|
||||||
sa.Column('names', sa.VARCHAR(length=100), autoincrement=False, nullable=False),
|
|
||||||
sa.Column('phone_number', sa.VARCHAR(length=10), autoincrement=False, nullable=False),
|
|
||||||
sa.PrimaryKeyConstraint('id', name='client_pkey'),
|
|
||||||
sa.UniqueConstraint('tin_number', name='client_tin_number_key', postgresql_include=[], postgresql_nulls_not_distinct=False),
|
|
||||||
postgresql_ignore_search_path=False
|
|
||||||
)
|
|
||||||
op.create_table('supplier',
|
|
||||||
sa.Column('id', sa.INTEGER(), server_default=sa.text("nextval('supplier_id_seq'::regclass)"), autoincrement=True, nullable=False),
|
|
||||||
sa.Column('tin_number', sa.INTEGER(), autoincrement=False, nullable=False),
|
|
||||||
sa.Column('names', sa.VARCHAR(length=100), autoincrement=False, nullable=False),
|
|
||||||
sa.Column('phone_number', sa.VARCHAR(length=10), autoincrement=False, nullable=False),
|
|
||||||
sa.PrimaryKeyConstraint('id', name='supplier_pkey'),
|
|
||||||
sa.UniqueConstraint('tin_number', name='supplier_tin_number_key', postgresql_include=[], postgresql_nulls_not_distinct=False),
|
|
||||||
postgresql_ignore_search_path=False
|
|
||||||
)
|
|
||||||
op.create_table('credit',
|
|
||||||
sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False),
|
|
||||||
sa.Column('transcation_type', postgresql.ENUM('BUY', 'SELL', name='tradetype'), autoincrement=False, nullable=False),
|
|
||||||
sa.Column('product_code', sa.VARCHAR(), autoincrement=False, nullable=False),
|
|
||||||
sa.Column('client_id', sa.INTEGER(), autoincrement=False, nullable=False),
|
|
||||||
sa.Column('supplier_id', sa.INTEGER(), autoincrement=False, nullable=False),
|
|
||||||
sa.Column('qty', sa.INTEGER(), autoincrement=False, nullable=False),
|
|
||||||
sa.Column('amount', sa.INTEGER(), autoincrement=False, nullable=False),
|
|
||||||
sa.Column('date', postgresql.TIMESTAMP(), server_default=sa.text('now()'), autoincrement=False, nullable=True),
|
|
||||||
sa.ForeignKeyConstraint(['client_id'], ['client.id'], name=op.f('credit_client_id_fkey')),
|
|
||||||
sa.ForeignKeyConstraint(['product_code'], ['product.product_code'], name=op.f('credit_product_code_fkey')),
|
|
||||||
sa.ForeignKeyConstraint(['supplier_id'], ['supplier.id'], name=op.f('credit_supplier_id_fkey')),
|
|
||||||
sa.PrimaryKeyConstraint('id', name=op.f('credit_pkey'))
|
|
||||||
)
|
|
||||||
op.drop_table('credit_accounts')
|
|
||||||
op.drop_table('transactions')
|
|
||||||
op.drop_table('transaction_details')
|
|
||||||
op.drop_table('inventory')
|
|
||||||
op.drop_table('user')
|
|
||||||
op.drop_table('partner')
|
|
||||||
# ### end Alembic commands ###
|
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
"""
|
||||||
|
API Home
|
||||||
|
"""
|
||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
|
||||||
|
api_router = APIRouter()
|
||||||
@@ -1,198 +0,0 @@
|
|||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
|
||||||
from sqlmodel import Session, select
|
|
||||||
from app.core.db import get_session
|
|
||||||
from app.core.auth import require_any_access, require_write_access, require_admin
|
|
||||||
from app.schemas.models import Credit, Partner, Transaction
|
|
||||||
from app.schemas.schemas import (
|
|
||||||
CreditCreate,
|
|
||||||
CreditUpdate,
|
|
||||||
CreditResponse,
|
|
||||||
UserResponse
|
|
||||||
)
|
|
||||||
from typing import List
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/credit", tags=["credit"])
|
|
||||||
|
|
||||||
# Create Credit
|
|
||||||
@router.post("/", response_model=CreditResponse, status_code=status.HTTP_201_CREATED)
|
|
||||||
def create_credit(
|
|
||||||
credit: CreditCreate,
|
|
||||||
session: Session = Depends(get_session),
|
|
||||||
current_user: UserResponse = Depends(require_write_access)
|
|
||||||
):
|
|
||||||
"""Create new credit account (requires write access)."""
|
|
||||||
# Validate partner exists
|
|
||||||
partner = session.get(Partner, credit.partner_id)
|
|
||||||
if not partner:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail="Partner not found"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Validate transaction exists
|
|
||||||
transaction = session.get(Transaction, credit.transaction_id)
|
|
||||||
if not transaction:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail="Transaction not found"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Check if credit account already exists for this partner
|
|
||||||
existing_credit = session.exec(
|
|
||||||
select(Credit).where(Credit.partner_id == credit.partner_id)
|
|
||||||
).first()
|
|
||||||
if existing_credit:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_409_CONFLICT,
|
|
||||||
detail="Credit account already exists for this partner"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create credit with audit fields
|
|
||||||
credit_data = credit.model_dump()
|
|
||||||
credit_data["created_by"] = current_user.id
|
|
||||||
credit_data["updated_by"] = current_user.id
|
|
||||||
|
|
||||||
db_credit = Credit(**credit_data)
|
|
||||||
|
|
||||||
session.add(db_credit)
|
|
||||||
session.commit()
|
|
||||||
session.refresh(db_credit)
|
|
||||||
return db_credit
|
|
||||||
|
|
||||||
# Read all Credit accounts
|
|
||||||
@router.get("/", response_model=List[CreditResponse])
|
|
||||||
def read_credits(
|
|
||||||
skip: int = 0,
|
|
||||||
limit: int = 100,
|
|
||||||
session: Session = Depends(get_session),
|
|
||||||
current_user: UserResponse = Depends(require_any_access)
|
|
||||||
):
|
|
||||||
"""Get all credit accounts (requires authentication)."""
|
|
||||||
credits = session.exec(
|
|
||||||
select(Credit).offset(skip).limit(limit)
|
|
||||||
).all()
|
|
||||||
return credits
|
|
||||||
|
|
||||||
# Read Credit by partner
|
|
||||||
@router.get("/partner/{partner_id}", response_model=CreditResponse)
|
|
||||||
def read_credit_by_partner(
|
|
||||||
partner_id: int,
|
|
||||||
session: Session = Depends(get_session),
|
|
||||||
current_user: UserResponse = Depends(require_any_access)
|
|
||||||
):
|
|
||||||
"""Get credit account for a specific partner (requires authentication)."""
|
|
||||||
# Validate partner exists
|
|
||||||
partner = session.get(Partner, partner_id)
|
|
||||||
if not partner:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail="Partner not found"
|
|
||||||
)
|
|
||||||
|
|
||||||
credit = session.exec(
|
|
||||||
select(Credit).where(Credit.partner_id == partner_id)
|
|
||||||
).first()
|
|
||||||
|
|
||||||
if not credit:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail="Credit account not found for this partner"
|
|
||||||
)
|
|
||||||
|
|
||||||
return credit
|
|
||||||
|
|
||||||
# Read single Credit by ID
|
|
||||||
@router.get("/{credit_id}", response_model=CreditResponse)
|
|
||||||
def read_credit_by_id(
|
|
||||||
credit_id: int,
|
|
||||||
session: Session = Depends(get_session),
|
|
||||||
current_user: UserResponse = Depends(require_any_access)
|
|
||||||
):
|
|
||||||
"""Get specific credit account by ID (requires authentication)."""
|
|
||||||
credit = session.get(Credit, credit_id)
|
|
||||||
if not credit:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail="Credit account not found"
|
|
||||||
)
|
|
||||||
return credit
|
|
||||||
|
|
||||||
# Update Credit
|
|
||||||
@router.put("/{credit_id}", response_model=CreditResponse)
|
|
||||||
def update_credit(
|
|
||||||
credit_id: int,
|
|
||||||
credit: CreditUpdate,
|
|
||||||
session: Session = Depends(get_session),
|
|
||||||
current_user: UserResponse = Depends(require_write_access)
|
|
||||||
):
|
|
||||||
"""Update specific credit account (requires write access)."""
|
|
||||||
db_credit = session.get(Credit, credit_id)
|
|
||||||
if not db_credit:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail="Credit account not found"
|
|
||||||
)
|
|
||||||
|
|
||||||
update_data = credit.model_dump(exclude_unset=True)
|
|
||||||
|
|
||||||
# Validate partner if being updated
|
|
||||||
if "partner_id" in update_data:
|
|
||||||
partner = session.get(Partner, update_data["partner_id"])
|
|
||||||
if not partner:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail="Partner not found"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Check for duplicate partner (excluding current record)
|
|
||||||
existing_credit = session.exec(
|
|
||||||
select(Credit).where(
|
|
||||||
Credit.partner_id == update_data["partner_id"],
|
|
||||||
Credit.id != credit_id
|
|
||||||
)
|
|
||||||
).first()
|
|
||||||
if existing_credit:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_409_CONFLICT,
|
|
||||||
detail="Credit account already exists for this partner"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Validate transaction if being updated
|
|
||||||
if "transaction_id" in update_data:
|
|
||||||
transaction = session.get(Transaction, update_data["transaction_id"])
|
|
||||||
if not transaction:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail="Transaction not found"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Track who updated
|
|
||||||
update_data["updated_by"] = current_user.id
|
|
||||||
|
|
||||||
# Update credit
|
|
||||||
for key, value in update_data.items():
|
|
||||||
setattr(db_credit, key, value)
|
|
||||||
|
|
||||||
session.add(db_credit)
|
|
||||||
session.commit()
|
|
||||||
session.refresh(db_credit)
|
|
||||||
return db_credit
|
|
||||||
|
|
||||||
# Delete Credit
|
|
||||||
@router.delete("/{credit_id}", status_code=status.HTTP_204_NO_CONTENT)
|
|
||||||
def delete_credit(
|
|
||||||
credit_id: int,
|
|
||||||
session: Session = Depends(get_session),
|
|
||||||
current_user: UserResponse = Depends(require_admin)
|
|
||||||
):
|
|
||||||
"""Delete specific credit account (admin only)."""
|
|
||||||
credit = session.get(Credit, credit_id)
|
|
||||||
if not credit:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail="Credit account not found"
|
|
||||||
)
|
|
||||||
|
|
||||||
session.delete(credit)
|
|
||||||
session.commit()
|
|
||||||
return None
|
|
||||||
@@ -1,175 +0,0 @@
|
|||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
|
||||||
from sqlmodel import Session, select
|
|
||||||
from app.core.db import get_session
|
|
||||||
from app.core.auth import require_any_access, require_write_access, require_admin
|
|
||||||
from app.schemas.models import Inventory, Product
|
|
||||||
from app.schemas.schemas import (
|
|
||||||
InventoryCreate,
|
|
||||||
InventoryUpdate,
|
|
||||||
InventoryResponse,
|
|
||||||
UserResponse
|
|
||||||
)
|
|
||||||
from typing import List
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/inventory", tags=["inventory"])
|
|
||||||
|
|
||||||
# Create Inventory
|
|
||||||
@router.post("/", response_model=InventoryResponse, status_code=status.HTTP_201_CREATED)
|
|
||||||
def create_inventory(
|
|
||||||
inventory: InventoryCreate,
|
|
||||||
session: Session = Depends(get_session),
|
|
||||||
current_user: UserResponse = Depends(require_write_access)
|
|
||||||
):
|
|
||||||
"""Create new inventory entry (requires write access)."""
|
|
||||||
# Validate product exists
|
|
||||||
product = session.get(Product, inventory.product_id)
|
|
||||||
if not product:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail="Product not found"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Check if inventory already exists for this product
|
|
||||||
existing_inventory = session.exec(
|
|
||||||
select(Inventory).where(Inventory.product_id == inventory.product_id)
|
|
||||||
).first()
|
|
||||||
if existing_inventory:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_409_CONFLICT,
|
|
||||||
detail="Inventory entry already exists for this product"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create inventory
|
|
||||||
inventory_data = inventory.model_dump()
|
|
||||||
db_inventory = Inventory(**inventory_data)
|
|
||||||
|
|
||||||
session.add(db_inventory)
|
|
||||||
session.commit()
|
|
||||||
session.refresh(db_inventory)
|
|
||||||
return db_inventory
|
|
||||||
|
|
||||||
# Read all Inventory
|
|
||||||
@router.get("/", response_model=List[InventoryResponse])
|
|
||||||
def read_inventory(
|
|
||||||
skip: int = 0,
|
|
||||||
limit: int = 100,
|
|
||||||
session: Session = Depends(get_session),
|
|
||||||
current_user: UserResponse = Depends(require_any_access)
|
|
||||||
):
|
|
||||||
"""Get all inventory entries (requires authentication)."""
|
|
||||||
inventory = session.exec(
|
|
||||||
select(Inventory).offset(skip).limit(limit)
|
|
||||||
).all()
|
|
||||||
return inventory
|
|
||||||
|
|
||||||
# Read Inventory by product
|
|
||||||
@router.get("/product/{product_id}", response_model=InventoryResponse)
|
|
||||||
def read_inventory_by_product(
|
|
||||||
product_id: int,
|
|
||||||
session: Session = Depends(get_session),
|
|
||||||
current_user: UserResponse = Depends(require_any_access)
|
|
||||||
):
|
|
||||||
"""Get inventory for a specific product (requires authentication)."""
|
|
||||||
# Validate product exists
|
|
||||||
product = session.get(Product, product_id)
|
|
||||||
if not product:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail="Product not found"
|
|
||||||
)
|
|
||||||
|
|
||||||
inventory = session.exec(
|
|
||||||
select(Inventory).where(Inventory.product_id == product_id)
|
|
||||||
).first()
|
|
||||||
|
|
||||||
if not inventory:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail="Inventory not found for this product"
|
|
||||||
)
|
|
||||||
|
|
||||||
return inventory
|
|
||||||
|
|
||||||
# Read single Inventory by ID
|
|
||||||
@router.get("/{inventory_id}", response_model=InventoryResponse)
|
|
||||||
def read_inventory_by_id(
|
|
||||||
inventory_id: int,
|
|
||||||
session: Session = Depends(get_session),
|
|
||||||
current_user: UserResponse = Depends(require_any_access)
|
|
||||||
):
|
|
||||||
"""Get specific inventory entry by ID (requires authentication)."""
|
|
||||||
inventory = session.get(Inventory, inventory_id)
|
|
||||||
if not inventory:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail="Inventory entry not found"
|
|
||||||
)
|
|
||||||
return inventory
|
|
||||||
|
|
||||||
# Update Inventory
|
|
||||||
@router.put("/{inventory_id}", response_model=InventoryResponse)
|
|
||||||
def update_inventory(
|
|
||||||
inventory_id: int,
|
|
||||||
inventory: InventoryUpdate,
|
|
||||||
session: Session = Depends(get_session),
|
|
||||||
current_user: UserResponse = Depends(require_write_access)
|
|
||||||
):
|
|
||||||
"""Update specific inventory entry (requires write access)."""
|
|
||||||
db_inventory = session.get(Inventory, inventory_id)
|
|
||||||
if not db_inventory:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail="Inventory entry not found"
|
|
||||||
)
|
|
||||||
|
|
||||||
update_data = inventory.model_dump(exclude_unset=True)
|
|
||||||
|
|
||||||
# Validate product if being updated
|
|
||||||
if "product_id" in update_data:
|
|
||||||
product = session.get(Product, update_data["product_id"])
|
|
||||||
if not product:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail="Product not found"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Check for duplicate product (excluding current record)
|
|
||||||
existing_inventory = session.exec(
|
|
||||||
select(Inventory).where(
|
|
||||||
Inventory.product_id == update_data["product_id"],
|
|
||||||
Inventory.id != inventory_id
|
|
||||||
)
|
|
||||||
).first()
|
|
||||||
if existing_inventory:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_409_CONFLICT,
|
|
||||||
detail="Inventory entry already exists for this product"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Update inventory
|
|
||||||
for key, value in update_data.items():
|
|
||||||
setattr(db_inventory, key, value)
|
|
||||||
|
|
||||||
session.add(db_inventory)
|
|
||||||
session.commit()
|
|
||||||
session.refresh(db_inventory)
|
|
||||||
return db_inventory
|
|
||||||
|
|
||||||
# Delete Inventory
|
|
||||||
@router.delete("/{inventory_id}", status_code=status.HTTP_204_NO_CONTENT)
|
|
||||||
def delete_inventory(
|
|
||||||
inventory_id: int,
|
|
||||||
session: Session = Depends(get_session),
|
|
||||||
current_user: UserResponse = Depends(require_admin)
|
|
||||||
):
|
|
||||||
"""Delete specific inventory entry (admin only)."""
|
|
||||||
inventory = session.get(Inventory, inventory_id)
|
|
||||||
if not inventory:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail="Inventory entry not found"
|
|
||||||
)
|
|
||||||
|
|
||||||
session.delete(inventory)
|
|
||||||
session.commit()
|
|
||||||
return None
|
|
||||||
@@ -1,126 +0,0 @@
|
|||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
|
||||||
from sqlmodel import Session, select
|
|
||||||
from app.core.db import get_session
|
|
||||||
from app.core.auth import require_any_access, require_write_access, require_admin
|
|
||||||
from app.schemas.models import Partner
|
|
||||||
from app.schemas.schemas import (
|
|
||||||
PartnerCreate,
|
|
||||||
PartnerUpdate,
|
|
||||||
PartnerResponse,
|
|
||||||
UserResponse
|
|
||||||
)
|
|
||||||
from typing import List
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/partners", tags=["partners"])
|
|
||||||
|
|
||||||
# Create Partner
|
|
||||||
@router.post("/", response_model=PartnerResponse, status_code=status.HTTP_201_CREATED)
|
|
||||||
def create_partner(
|
|
||||||
partner: PartnerCreate,
|
|
||||||
session: Session = Depends(get_session),
|
|
||||||
current_user: UserResponse = Depends(require_write_access)
|
|
||||||
):
|
|
||||||
"""Create a new partner (requires write access)."""
|
|
||||||
# Check if TIN number already exists
|
|
||||||
statement = select(Partner).where(Partner.tin_number == partner.tin_number)
|
|
||||||
existing_partner = session.exec(statement).first()
|
|
||||||
if existing_partner:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail="Partner with this TIN number already exists"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create new partner
|
|
||||||
partner_data = partner.model_dump()
|
|
||||||
db_partner = Partner(**partner_data)
|
|
||||||
|
|
||||||
session.add(db_partner)
|
|
||||||
session.commit()
|
|
||||||
session.refresh(db_partner)
|
|
||||||
return db_partner
|
|
||||||
|
|
||||||
# Read all Partners
|
|
||||||
@router.get("/", response_model=List[PartnerResponse])
|
|
||||||
def read_partners(
|
|
||||||
skip: int = 0,
|
|
||||||
limit: int = 100,
|
|
||||||
session: Session = Depends(get_session),
|
|
||||||
current_user: UserResponse = Depends(require_any_access)
|
|
||||||
):
|
|
||||||
"""Get all partners (requires authentication)."""
|
|
||||||
partners = session.exec(select(Partner).offset(skip).limit(limit)).all()
|
|
||||||
return partners
|
|
||||||
|
|
||||||
# Read single Partner by ID
|
|
||||||
@router.get("/{partner_id}", response_model=PartnerResponse)
|
|
||||||
def read_partner(
|
|
||||||
partner_id: int,
|
|
||||||
session: Session = Depends(get_session),
|
|
||||||
current_user: UserResponse = Depends(require_any_access)
|
|
||||||
):
|
|
||||||
"""Get specific partner by ID (requires authentication)."""
|
|
||||||
partner = session.get(Partner, partner_id)
|
|
||||||
if not partner:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail="Partner not found"
|
|
||||||
)
|
|
||||||
return partner
|
|
||||||
|
|
||||||
# Update Partner
|
|
||||||
@router.put("/{partner_id}", response_model=PartnerResponse)
|
|
||||||
def update_partner(
|
|
||||||
partner_id: int,
|
|
||||||
partner: PartnerUpdate,
|
|
||||||
session: Session = Depends(get_session),
|
|
||||||
current_user: UserResponse = Depends(require_write_access)
|
|
||||||
):
|
|
||||||
"""Update specific partner (requires write access)."""
|
|
||||||
db_partner = session.get(Partner, partner_id)
|
|
||||||
if not db_partner:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail="Partner not found"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Check for TIN number conflicts if updating TIN
|
|
||||||
update_data = partner.model_dump(exclude_unset=True)
|
|
||||||
if "tin_number" in update_data:
|
|
||||||
statement = select(Partner).where(
|
|
||||||
Partner.tin_number == update_data["tin_number"],
|
|
||||||
Partner.id != partner_id
|
|
||||||
)
|
|
||||||
existing_partner = session.exec(statement).first()
|
|
||||||
if existing_partner:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail="Partner with this TIN number already exists"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Update partner
|
|
||||||
for key, value in update_data.items():
|
|
||||||
setattr(db_partner, key, value)
|
|
||||||
|
|
||||||
session.add(db_partner)
|
|
||||||
session.commit()
|
|
||||||
session.refresh(db_partner)
|
|
||||||
return db_partner
|
|
||||||
|
|
||||||
# Delete Partner
|
|
||||||
@router.delete("/{partner_id}", status_code=status.HTTP_204_NO_CONTENT)
|
|
||||||
def delete_partner(
|
|
||||||
partner_id: int,
|
|
||||||
session: Session = Depends(get_session),
|
|
||||||
current_user: UserResponse = Depends(require_admin)
|
|
||||||
):
|
|
||||||
"""Delete specific partner (admin only)."""
|
|
||||||
partner = session.get(Partner, partner_id)
|
|
||||||
if not partner:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail="Partner not found"
|
|
||||||
)
|
|
||||||
|
|
||||||
session.delete(partner)
|
|
||||||
session.commit()
|
|
||||||
return None
|
|
||||||
@@ -1,155 +0,0 @@
|
|||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
|
||||||
from sqlmodel import Session, select
|
|
||||||
from app.core.db import get_session
|
|
||||||
from app.core.auth import require_any_access, require_write_access, require_admin
|
|
||||||
from app.schemas.models import Payment, Transaction
|
|
||||||
from app.schemas.schemas import (
|
|
||||||
PaymentCreate,
|
|
||||||
PaymentUpdate,
|
|
||||||
PaymentResponse,
|
|
||||||
UserResponse
|
|
||||||
)
|
|
||||||
from typing import List
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/payments", tags=["payments"])
|
|
||||||
|
|
||||||
# Create Payment
|
|
||||||
@router.post("/", response_model=PaymentResponse, status_code=status.HTTP_201_CREATED)
|
|
||||||
def create_payment(
|
|
||||||
payment: PaymentCreate,
|
|
||||||
session: Session = Depends(get_session),
|
|
||||||
current_user: UserResponse = Depends(require_write_access)
|
|
||||||
):
|
|
||||||
"""Create new payment (requires write access)."""
|
|
||||||
# Validate transaction exists
|
|
||||||
transaction = session.get(Transaction, payment.transaction_id)
|
|
||||||
if not transaction:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail="Transaction not found"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create payment with audit fields
|
|
||||||
payment_data = payment.model_dump()
|
|
||||||
payment_data["created_by"] = current_user.id
|
|
||||||
payment_data["updated_by"] = current_user.id
|
|
||||||
|
|
||||||
db_payment = Payment(**payment_data)
|
|
||||||
|
|
||||||
session.add(db_payment)
|
|
||||||
session.commit()
|
|
||||||
session.refresh(db_payment)
|
|
||||||
return db_payment
|
|
||||||
|
|
||||||
# Read all Payments
|
|
||||||
@router.get("/", response_model=List[PaymentResponse])
|
|
||||||
def read_payments(
|
|
||||||
skip: int = 0,
|
|
||||||
limit: int = 100,
|
|
||||||
session: Session = Depends(get_session),
|
|
||||||
current_user: UserResponse = Depends(require_any_access)
|
|
||||||
):
|
|
||||||
"""Get all payments (requires authentication)."""
|
|
||||||
payments = session.exec(
|
|
||||||
select(Payment).offset(skip).limit(limit)
|
|
||||||
).all()
|
|
||||||
return payments
|
|
||||||
|
|
||||||
# Read Payments by transaction
|
|
||||||
@router.get("/transaction/{transaction_id}", response_model=List[PaymentResponse])
|
|
||||||
def read_payments_by_transaction(
|
|
||||||
transaction_id: int,
|
|
||||||
skip: int = 0,
|
|
||||||
limit: int = 100,
|
|
||||||
session: Session = Depends(get_session),
|
|
||||||
current_user: UserResponse = Depends(require_any_access)
|
|
||||||
):
|
|
||||||
"""Get payments for a specific transaction (requires authentication)."""
|
|
||||||
# Validate transaction exists
|
|
||||||
transaction = session.get(Transaction, transaction_id)
|
|
||||||
if not transaction:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail="Transaction not found"
|
|
||||||
)
|
|
||||||
|
|
||||||
statement = select(Payment).where(
|
|
||||||
Payment.transaction_id == transaction_id
|
|
||||||
).offset(skip).limit(limit)
|
|
||||||
|
|
||||||
payments = session.exec(statement).all()
|
|
||||||
return payments
|
|
||||||
|
|
||||||
# Read single Payment by ID
|
|
||||||
@router.get("/{payment_id}", response_model=PaymentResponse)
|
|
||||||
def read_payment_by_id(
|
|
||||||
payment_id: int,
|
|
||||||
session: Session = Depends(get_session),
|
|
||||||
current_user: UserResponse = Depends(require_any_access)
|
|
||||||
):
|
|
||||||
"""Get specific payment by ID (requires authentication)."""
|
|
||||||
payment = session.get(Payment, payment_id)
|
|
||||||
if not payment:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail="Payment not found"
|
|
||||||
)
|
|
||||||
return payment
|
|
||||||
|
|
||||||
# Update Payment
|
|
||||||
@router.put("/{payment_id}", response_model=PaymentResponse)
|
|
||||||
def update_payment(
|
|
||||||
payment_id: int,
|
|
||||||
payment: PaymentUpdate,
|
|
||||||
session: Session = Depends(get_session),
|
|
||||||
current_user: UserResponse = Depends(require_write_access)
|
|
||||||
):
|
|
||||||
"""Update specific payment (requires write access)."""
|
|
||||||
db_payment = session.get(Payment, payment_id)
|
|
||||||
if not db_payment:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail="Payment not found"
|
|
||||||
)
|
|
||||||
|
|
||||||
update_data = payment.model_dump(exclude_unset=True)
|
|
||||||
|
|
||||||
# Validate transaction if being updated
|
|
||||||
if "transaction_id" in update_data:
|
|
||||||
transaction = session.get(Transaction, update_data["transaction_id"])
|
|
||||||
if not transaction:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail="Transaction not found"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Track who updated
|
|
||||||
update_data["updated_by"] = current_user.id
|
|
||||||
|
|
||||||
# Update payment
|
|
||||||
for key, value in update_data.items():
|
|
||||||
setattr(db_payment, key, value)
|
|
||||||
|
|
||||||
session.add(db_payment)
|
|
||||||
session.commit()
|
|
||||||
session.refresh(db_payment)
|
|
||||||
return db_payment
|
|
||||||
|
|
||||||
# Delete Payment
|
|
||||||
@router.delete("/{payment_id}", status_code=status.HTTP_204_NO_CONTENT)
|
|
||||||
def delete_payment(
|
|
||||||
payment_id: int,
|
|
||||||
session: Session = Depends(get_session),
|
|
||||||
current_user: UserResponse = Depends(require_admin)
|
|
||||||
):
|
|
||||||
"""Delete specific payment (admin only)."""
|
|
||||||
payment = session.get(Payment, payment_id)
|
|
||||||
if not payment:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail="Payment not found"
|
|
||||||
)
|
|
||||||
|
|
||||||
session.delete(payment)
|
|
||||||
session.commit()
|
|
||||||
return None
|
|
||||||
@@ -1,166 +0,0 @@
|
|||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
|
||||||
from sqlmodel import Session, select
|
|
||||||
from app.core.db import get_session
|
|
||||||
from app.core.auth import require_any_access, require_write_access, require_admin
|
|
||||||
from app.schemas.models import Product
|
|
||||||
from app.schemas.schemas import (
|
|
||||||
ProductCreate,
|
|
||||||
ProductUpdate,
|
|
||||||
ProductResponse,
|
|
||||||
UserResponse
|
|
||||||
)
|
|
||||||
from typing import List
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/products", tags=["products"])
|
|
||||||
|
|
||||||
# Create Product
|
|
||||||
@router.post("/", response_model=ProductResponse, status_code=status.HTTP_201_CREATED)
|
|
||||||
def create_product(
|
|
||||||
product: ProductCreate,
|
|
||||||
session: Session = Depends(get_session),
|
|
||||||
current_user: UserResponse = Depends(require_write_access)
|
|
||||||
):
|
|
||||||
"""Create a new product (requires write access)."""
|
|
||||||
# Check if product code already exists
|
|
||||||
statement = select(Product).where(Product.product_code == product.product_code)
|
|
||||||
existing_product = session.exec(statement).first()
|
|
||||||
if existing_product:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail="Product with this code already exists"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Check if product name already exists
|
|
||||||
statement = select(Product).where(Product.product_name == product.product_name)
|
|
||||||
existing_product = session.exec(statement).first()
|
|
||||||
if existing_product:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail="Product with this name already exists"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create new product
|
|
||||||
product_data = product.model_dump()
|
|
||||||
db_product = Product(**product_data)
|
|
||||||
|
|
||||||
session.add(db_product)
|
|
||||||
session.commit()
|
|
||||||
session.refresh(db_product)
|
|
||||||
return db_product
|
|
||||||
|
|
||||||
# Read all Products
|
|
||||||
@router.get("/", response_model=List[ProductResponse])
|
|
||||||
def read_products(
|
|
||||||
skip: int = 0,
|
|
||||||
limit: int = 100,
|
|
||||||
session: Session = Depends(get_session),
|
|
||||||
current_user: UserResponse = Depends(require_any_access)
|
|
||||||
):
|
|
||||||
"""Get all products (requires authentication)."""
|
|
||||||
products = session.exec(select(Product).offset(skip).limit(limit)).all()
|
|
||||||
return products
|
|
||||||
|
|
||||||
# Read single Product by ID
|
|
||||||
@router.get("/{product_id}", response_model=ProductResponse)
|
|
||||||
def read_product(
|
|
||||||
product_id: int,
|
|
||||||
session: Session = Depends(get_session),
|
|
||||||
current_user: UserResponse = Depends(require_any_access)
|
|
||||||
):
|
|
||||||
"""Get specific product by ID (requires authentication)."""
|
|
||||||
product = session.get(Product, product_id)
|
|
||||||
if not product:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail="Product not found"
|
|
||||||
)
|
|
||||||
return product
|
|
||||||
|
|
||||||
# Read Product by code
|
|
||||||
@router.get("/code/{product_code}", response_model=ProductResponse)
|
|
||||||
def read_product_by_code(
|
|
||||||
product_code: str,
|
|
||||||
session: Session = Depends(get_session),
|
|
||||||
current_user: UserResponse = Depends(require_any_access)
|
|
||||||
):
|
|
||||||
"""Get specific product by code (requires authentication)."""
|
|
||||||
statement = select(Product).where(Product.product_code == product_code)
|
|
||||||
product = session.exec(statement).first()
|
|
||||||
if not product:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail="Product not found"
|
|
||||||
)
|
|
||||||
return product
|
|
||||||
|
|
||||||
# Update Product
|
|
||||||
@router.put("/{product_id}", response_model=ProductResponse)
|
|
||||||
def update_product(
|
|
||||||
product_id: int,
|
|
||||||
product: ProductUpdate,
|
|
||||||
session: Session = Depends(get_session),
|
|
||||||
current_user: UserResponse = Depends(require_write_access)
|
|
||||||
):
|
|
||||||
"""Update specific product (requires write access)."""
|
|
||||||
db_product = session.get(Product, product_id)
|
|
||||||
if not db_product:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail="Product not found"
|
|
||||||
)
|
|
||||||
|
|
||||||
update_data = product.model_dump(exclude_unset=True)
|
|
||||||
|
|
||||||
# Check for product code conflicts if updating code
|
|
||||||
if "product_code" in update_data:
|
|
||||||
statement = select(Product).where(
|
|
||||||
Product.product_code == update_data["product_code"],
|
|
||||||
Product.id != product_id
|
|
||||||
)
|
|
||||||
existing_product = session.exec(statement).first()
|
|
||||||
if existing_product:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail="Product with this code already exists"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Check for product name conflicts if updating name
|
|
||||||
if "product_name" in update_data:
|
|
||||||
statement = select(Product).where(
|
|
||||||
Product.product_name == update_data["product_name"],
|
|
||||||
Product.id != product_id
|
|
||||||
)
|
|
||||||
existing_product = session.exec(statement).first()
|
|
||||||
if existing_product:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail="Product with this name already exists"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Update product
|
|
||||||
for key, value in update_data.items():
|
|
||||||
setattr(db_product, key, value)
|
|
||||||
|
|
||||||
session.add(db_product)
|
|
||||||
session.commit()
|
|
||||||
session.refresh(db_product)
|
|
||||||
return db_product
|
|
||||||
|
|
||||||
# Delete Product
|
|
||||||
@router.delete("/{product_id}", status_code=status.HTTP_204_NO_CONTENT)
|
|
||||||
def delete_product(
|
|
||||||
product_id: int,
|
|
||||||
session: Session = Depends(get_session),
|
|
||||||
current_user: UserResponse = Depends(require_admin)
|
|
||||||
):
|
|
||||||
"""Delete specific product (admin only)."""
|
|
||||||
product = session.get(Product, product_id)
|
|
||||||
if not product:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail="Product not found"
|
|
||||||
)
|
|
||||||
|
|
||||||
session.delete(product)
|
|
||||||
session.commit()
|
|
||||||
return None
|
|
||||||
@@ -1,197 +0,0 @@
|
|||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
|
||||||
from sqlmodel import Session, select
|
|
||||||
from app.core.db import get_session
|
|
||||||
from app.core.auth import require_any_access, require_write_access, require_admin
|
|
||||||
from app.schemas.models import Transaction_details, Partner, Product
|
|
||||||
from app.schemas.schemas import (
|
|
||||||
TransactionDetailsCreate,
|
|
||||||
TransactionDetailsUpdate,
|
|
||||||
TransactionDetailsResponse,
|
|
||||||
UserResponse
|
|
||||||
)
|
|
||||||
from typing import List
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/transaction-details", tags=["transaction-details"])
|
|
||||||
|
|
||||||
# Create Transaction Details
|
|
||||||
@router.post("/", response_model=TransactionDetailsResponse, status_code=status.HTTP_201_CREATED)
|
|
||||||
def create_transaction_details(
|
|
||||||
transaction_details: TransactionDetailsCreate,
|
|
||||||
session: Session = Depends(get_session),
|
|
||||||
current_user: UserResponse = Depends(require_write_access)
|
|
||||||
):
|
|
||||||
"""Create new transaction details (requires write access)."""
|
|
||||||
# Validate partner exists
|
|
||||||
partner = session.get(Partner, transaction_details.partner_id)
|
|
||||||
if not partner:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail="Partner not found"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Validate product exists
|
|
||||||
product = session.get(Product, transaction_details.product_id)
|
|
||||||
if not product:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail="Product not found"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create transaction details with audit fields
|
|
||||||
transaction_details_data = transaction_details.model_dump()
|
|
||||||
transaction_details_data["created_by"] = current_user.id
|
|
||||||
transaction_details_data["updated_by"] = current_user.id
|
|
||||||
|
|
||||||
db_transaction_details = Transaction_details(**transaction_details_data)
|
|
||||||
|
|
||||||
session.add(db_transaction_details)
|
|
||||||
session.commit()
|
|
||||||
session.refresh(db_transaction_details)
|
|
||||||
return db_transaction_details
|
|
||||||
|
|
||||||
# Read all Transaction Details
|
|
||||||
@router.get("/", response_model=List[TransactionDetailsResponse])
|
|
||||||
def read_transaction_details(
|
|
||||||
skip: int = 0,
|
|
||||||
limit: int = 100,
|
|
||||||
session: Session = Depends(get_session),
|
|
||||||
current_user: UserResponse = Depends(require_any_access)
|
|
||||||
):
|
|
||||||
"""Get all transaction details (requires authentication)."""
|
|
||||||
transaction_details = session.exec(
|
|
||||||
select(Transaction_details).offset(skip).limit(limit)
|
|
||||||
).all()
|
|
||||||
return transaction_details
|
|
||||||
|
|
||||||
# Read Transaction Details by partner
|
|
||||||
@router.get("/partner/{partner_id}", response_model=List[TransactionDetailsResponse])
|
|
||||||
def read_transaction_details_by_partner(
|
|
||||||
partner_id: int,
|
|
||||||
skip: int = 0,
|
|
||||||
limit: int = 100,
|
|
||||||
session: Session = Depends(get_session),
|
|
||||||
current_user: UserResponse = Depends(require_any_access)
|
|
||||||
):
|
|
||||||
"""Get transaction details for a specific partner (requires authentication)."""
|
|
||||||
# Validate partner exists
|
|
||||||
partner = session.get(Partner, partner_id)
|
|
||||||
if not partner:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail="Partner not found"
|
|
||||||
)
|
|
||||||
|
|
||||||
statement = select(Transaction_details).where(
|
|
||||||
Transaction_details.partner_id == partner_id
|
|
||||||
).offset(skip).limit(limit)
|
|
||||||
|
|
||||||
transaction_details = session.exec(statement).all()
|
|
||||||
return transaction_details
|
|
||||||
|
|
||||||
# Read Transaction Details by product
|
|
||||||
@router.get("/product/{product_id}", response_model=List[TransactionDetailsResponse])
|
|
||||||
def read_transaction_details_by_product(
|
|
||||||
product_id: int,
|
|
||||||
skip: int = 0,
|
|
||||||
limit: int = 100,
|
|
||||||
session: Session = Depends(get_session),
|
|
||||||
current_user: UserResponse = Depends(require_any_access)
|
|
||||||
):
|
|
||||||
"""Get transaction details for a specific product (requires authentication)."""
|
|
||||||
# Validate product exists
|
|
||||||
product = session.get(Product, product_id)
|
|
||||||
if not product:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail="Product not found"
|
|
||||||
)
|
|
||||||
|
|
||||||
statement = select(Transaction_details).where(
|
|
||||||
Transaction_details.product_id == product_id
|
|
||||||
).offset(skip).limit(limit)
|
|
||||||
|
|
||||||
transaction_details = session.exec(statement).all()
|
|
||||||
return transaction_details
|
|
||||||
|
|
||||||
# Read single Transaction Details by ID
|
|
||||||
@router.get("/{transaction_details_id}", response_model=TransactionDetailsResponse)
|
|
||||||
def read_transaction_details_by_id(
|
|
||||||
transaction_details_id: int,
|
|
||||||
session: Session = Depends(get_session),
|
|
||||||
current_user: UserResponse = Depends(require_any_access)
|
|
||||||
):
|
|
||||||
"""Get specific transaction details by ID (requires authentication)."""
|
|
||||||
transaction_details = session.get(Transaction_details, transaction_details_id)
|
|
||||||
if not transaction_details:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail="Transaction details not found"
|
|
||||||
)
|
|
||||||
return transaction_details
|
|
||||||
|
|
||||||
# Update Transaction Details
|
|
||||||
@router.put("/{transaction_details_id}", response_model=TransactionDetailsResponse)
|
|
||||||
def update_transaction_details(
|
|
||||||
transaction_details_id: int,
|
|
||||||
transaction_details: TransactionDetailsUpdate,
|
|
||||||
session: Session = Depends(get_session),
|
|
||||||
current_user: UserResponse = Depends(require_write_access)
|
|
||||||
):
|
|
||||||
"""Update specific transaction details (requires write access)."""
|
|
||||||
db_transaction_details = session.get(Transaction_details, transaction_details_id)
|
|
||||||
if not db_transaction_details:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail="Transaction details not found"
|
|
||||||
)
|
|
||||||
|
|
||||||
update_data = transaction_details.model_dump(exclude_unset=True)
|
|
||||||
|
|
||||||
# Validate partner if being updated
|
|
||||||
if "partner_id" in update_data:
|
|
||||||
partner = session.get(Partner, update_data["partner_id"])
|
|
||||||
if not partner:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail="Partner not found"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Validate product if being updated
|
|
||||||
if "product_id" in update_data:
|
|
||||||
product = session.get(Product, update_data["product_id"])
|
|
||||||
if not product:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail="Product not found"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Track who updated
|
|
||||||
update_data["updated_by"] = current_user.id
|
|
||||||
|
|
||||||
# Update transaction details
|
|
||||||
for key, value in update_data.items():
|
|
||||||
setattr(db_transaction_details, key, value)
|
|
||||||
|
|
||||||
session.add(db_transaction_details)
|
|
||||||
session.commit()
|
|
||||||
session.refresh(db_transaction_details)
|
|
||||||
return db_transaction_details
|
|
||||||
|
|
||||||
# Delete Transaction Details
|
|
||||||
@router.delete("/{transaction_details_id}", status_code=status.HTTP_204_NO_CONTENT)
|
|
||||||
def delete_transaction_details(
|
|
||||||
transaction_details_id: int,
|
|
||||||
session: Session = Depends(get_session),
|
|
||||||
current_user: UserResponse = Depends(require_admin)
|
|
||||||
):
|
|
||||||
"""Delete specific transaction details (admin only)."""
|
|
||||||
transaction_details = session.get(Transaction_details, transaction_details_id)
|
|
||||||
if not transaction_details:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail="Transaction details not found"
|
|
||||||
)
|
|
||||||
|
|
||||||
session.delete(transaction_details)
|
|
||||||
session.commit()
|
|
||||||
return None
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
|
||||||
from sqlmodel import Session, select
|
|
||||||
from app.core.db import get_session
|
|
||||||
from app.core.auth import require_any_access, require_write_access, get_current_active_user
|
|
||||||
from app.schemas.models import Transaction
|
|
||||||
from app.schemas.schemas import TransactionCreate, TransactionUpdate, TransactionResponse, UserResponse
|
|
||||||
|
|
||||||
from typing import List, Optional
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/transactions", tags=["transactions"])
|
|
||||||
|
|
||||||
# Create Transaction
|
|
||||||
@router.post("/", response_model=TransactionResponse, status_code=status.HTTP_201_CREATED)
|
|
||||||
def create_transaction(
|
|
||||||
transaction: TransactionCreate,
|
|
||||||
session: Session = Depends(get_session),
|
|
||||||
current_user: UserResponse = Depends(require_write_access)
|
|
||||||
):
|
|
||||||
# Set created_by and updated_by to current user
|
|
||||||
transaction_data = transaction.model_dump(exclude_unset=True)
|
|
||||||
transaction_data["created_by"] = current_user.id
|
|
||||||
transaction_data["updated_by"] = current_user.id
|
|
||||||
|
|
||||||
db_transaction = Transaction(**transaction_data)
|
|
||||||
session.add(db_transaction)
|
|
||||||
session.commit()
|
|
||||||
session.refresh(db_transaction)
|
|
||||||
return db_transaction
|
|
||||||
|
|
||||||
# Read all Transactions
|
|
||||||
@router.get("/", response_model=List[TransactionResponse])
|
|
||||||
def read_transactions(
|
|
||||||
skip: int = 0,
|
|
||||||
limit: int = 100,
|
|
||||||
session: Session = Depends(get_session),
|
|
||||||
current_user: UserResponse = Depends(require_any_access)
|
|
||||||
):
|
|
||||||
transactions = session.exec(select(Transaction).offset(skip).limit(limit)).all()
|
|
||||||
return transactions
|
|
||||||
|
|
||||||
# Read single Transaction by ID
|
|
||||||
@router.get("/{transaction_id}", response_model=TransactionResponse)
|
|
||||||
def read_transaction(
|
|
||||||
transaction_id: int,
|
|
||||||
session: Session = Depends(get_session),
|
|
||||||
current_user: UserResponse = Depends(require_any_access)
|
|
||||||
):
|
|
||||||
transaction = session.get(Transaction, transaction_id)
|
|
||||||
if not transaction:
|
|
||||||
raise HTTPException(status_code=404, detail="Transaction not found")
|
|
||||||
return transaction
|
|
||||||
|
|
||||||
# Update Transaction
|
|
||||||
@router.put("/{transaction_id}", response_model=TransactionResponse)
|
|
||||||
def update_transaction(
|
|
||||||
transaction_id: int,
|
|
||||||
transaction: TransactionUpdate,
|
|
||||||
session: Session = Depends(get_session),
|
|
||||||
current_user: UserResponse = Depends(require_write_access)
|
|
||||||
):
|
|
||||||
db_transaction = session.get(Transaction, transaction_id)
|
|
||||||
if not db_transaction:
|
|
||||||
raise HTTPException(status_code=404, detail="Transaction not found")
|
|
||||||
|
|
||||||
update_data = transaction.model_dump(exclude_unset=True)
|
|
||||||
update_data["updated_by"] = current_user.id # Track who updated
|
|
||||||
|
|
||||||
for key, value in update_data.items():
|
|
||||||
setattr(db_transaction, key, value)
|
|
||||||
|
|
||||||
session.add(db_transaction)
|
|
||||||
session.commit()
|
|
||||||
session.refresh(db_transaction)
|
|
||||||
return db_transaction
|
|
||||||
|
|
||||||
# Delete Transaction
|
|
||||||
@router.delete("/{transaction_id}", status_code=status.HTTP_204_NO_CONTENT)
|
|
||||||
def delete_transaction(
|
|
||||||
transaction_id: int,
|
|
||||||
session: Session = Depends(get_session),
|
|
||||||
current_user: UserResponse = Depends(require_write_access)
|
|
||||||
):
|
|
||||||
transaction = session.get(Transaction, transaction_id)
|
|
||||||
if not transaction:
|
|
||||||
raise HTTPException(status_code=404, detail="Transaction not found")
|
|
||||||
session.delete(transaction)
|
|
||||||
session.commit()
|
|
||||||
return None
|
|
||||||
@@ -1,318 +0,0 @@
|
|||||||
# backend/app/api/v1/users.py
|
|
||||||
from datetime import timedelta
|
|
||||||
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
|
|
||||||
from app.core.auth import (
|
|
||||||
authenticate_user,
|
|
||||||
create_access_token,
|
|
||||||
get_password_hash,
|
|
||||||
get_current_active_user,
|
|
||||||
get_token_expiration_minutes,
|
|
||||||
require_admin,
|
|
||||||
require_write_access,
|
|
||||||
require_any_access,
|
|
||||||
verify_password,
|
|
||||||
send_password_reset_email
|
|
||||||
)
|
|
||||||
from app.schemas.models import User
|
|
||||||
from app.schemas.schemas import (
|
|
||||||
UserCreate,
|
|
||||||
UserUpdate,
|
|
||||||
UserLogin,
|
|
||||||
Token,
|
|
||||||
UserResponse,
|
|
||||||
UserApprovalUpdate,
|
|
||||||
PasswordChangeRequest,
|
|
||||||
EmailVerificationRequest
|
|
||||||
)
|
|
||||||
|
|
||||||
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, 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_code,
|
|
||||||
detail=error_details.get(error_message, "Authentication failed"),
|
|
||||||
headers={"WWW-Authenticate": "Bearer"},
|
|
||||||
)
|
|
||||||
|
|
||||||
# Get role-based expiration time
|
|
||||||
expire_minutes = get_token_expiration_minutes(user.role)
|
|
||||||
access_token_expires = timedelta(minutes=expire_minutes)
|
|
||||||
|
|
||||||
# Create token with user data
|
|
||||||
access_token = create_access_token(
|
|
||||||
data={
|
|
||||||
"sub": user.username,
|
|
||||||
"user_id": user.id,
|
|
||||||
"role": user.role.value
|
|
||||||
},
|
|
||||||
expires_delta=access_token_expires
|
|
||||||
)
|
|
||||||
|
|
||||||
return Token(
|
|
||||||
access_token=access_token,
|
|
||||||
token_type="bearer",
|
|
||||||
expires_in=expire_minutes * 60, # Convert to seconds
|
|
||||||
user=UserResponse(
|
|
||||||
id=user.id,
|
|
||||||
username=user.username,
|
|
||||||
role=user.role,
|
|
||||||
is_approved=user.is_approved
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/me", response_model=UserResponse)
|
|
||||||
def get_current_user_info(current_user: User = Depends(get_current_active_user)):
|
|
||||||
"""Get current user information from token."""
|
|
||||||
return UserResponse(
|
|
||||||
id=current_user.id,
|
|
||||||
username=current_user.username,
|
|
||||||
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_admin),
|
|
||||||
skip: int = 0,
|
|
||||||
limit: int = 100
|
|
||||||
):
|
|
||||||
"""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, is_approved=user.is_approved)
|
|
||||||
for user in users
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
|
|
||||||
def create_user(
|
|
||||||
user: UserCreate,
|
|
||||||
session: Session = Depends(get_session)
|
|
||||||
):
|
|
||||||
"""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()
|
|
||||||
if existing_user:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail="Username already registered"
|
|
||||||
)
|
|
||||||
|
|
||||||
# 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,
|
|
||||||
is_approved=False # Requires admin approval
|
|
||||||
)
|
|
||||||
|
|
||||||
session.add(db_user)
|
|
||||||
session.commit()
|
|
||||||
session.refresh(db_user)
|
|
||||||
|
|
||||||
return UserResponse(
|
|
||||||
id=db_user.id,
|
|
||||||
username=db_user.username,
|
|
||||||
role=db_user.role,
|
|
||||||
is_approved=db_user.is_approved
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{user_id}", response_model=UserResponse)
|
|
||||||
def get_user(
|
|
||||||
user_id: int,
|
|
||||||
session: Session = Depends(get_session),
|
|
||||||
current_user: User = Depends(require_any_access)
|
|
||||||
):
|
|
||||||
"""Get specific user by ID (requires authentication)."""
|
|
||||||
user = session.get(User, user_id)
|
|
||||||
if not user:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail="User not found"
|
|
||||||
)
|
|
||||||
|
|
||||||
return UserResponse(
|
|
||||||
id=user.id,
|
|
||||||
username=user.username,
|
|
||||||
role=user.role,
|
|
||||||
is_approved=user.is_approved
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# Update to handle user self-updating password
|
|
||||||
@router.put("/{user_id}", response_model=UserResponse)
|
|
||||||
def update_user(
|
|
||||||
user_id: int,
|
|
||||||
user_update: UserUpdate,
|
|
||||||
session: Session = Depends(get_session),
|
|
||||||
current_user: UserResponse = Depends(require_admin)
|
|
||||||
):
|
|
||||||
"""Update specific user (admin only)."""
|
|
||||||
user = session.get(User, user_id)
|
|
||||||
if not user:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail="User not found"
|
|
||||||
)
|
|
||||||
|
|
||||||
# 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)
|
|
||||||
|
|
||||||
session.add(user)
|
|
||||||
session.commit()
|
|
||||||
session.refresh(user)
|
|
||||||
|
|
||||||
return UserResponse(
|
|
||||||
id=user.id,
|
|
||||||
username=user.username,
|
|
||||||
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
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
|
|
||||||
def delete_user(
|
|
||||||
user_id: int,
|
|
||||||
session: Session = Depends(get_session),
|
|
||||||
current_user: User = Depends(require_admin)
|
|
||||||
):
|
|
||||||
"""Delete specific user (admin only)."""
|
|
||||||
user = session.get(User, user_id)
|
|
||||||
if not user:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail="User not found"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Prevent self-deletion
|
|
||||||
if user.id == current_user.id:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail="Cannot delete your own account"
|
|
||||||
)
|
|
||||||
|
|
||||||
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."
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import secrets
|
||||||
|
import warnings
|
||||||
|
from typing import Annotated, Any, Literal
|
||||||
|
from pydantic import (
|
||||||
|
MySQLDsn
|
||||||
|
)
|
||||||
|
from pydantic_core import MultiHostUrl
|
||||||
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
"""
|
||||||
|
"""
|
||||||
|
model_config = SettingsConfigDict(
|
||||||
|
# One level above ./backend
|
||||||
|
env_file='../.env',
|
||||||
|
env_ignore_empty=True,
|
||||||
|
extra='ignore'
|
||||||
|
)
|
||||||
|
SECRET_KEY: str = secrets.token_urlsafe(32)
|
||||||
|
ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 * 8 # 8 days
|
||||||
|
|
||||||
|
MYSQL_SERVER: str
|
||||||
|
MYSQL_PORT: int = 3306
|
||||||
|
MYSQL_USER: str
|
||||||
|
MYSQL_PASSWORD: str = ""
|
||||||
|
MYSQL_DB: str = ""
|
||||||
|
|
||||||
|
@computed_field # type: ignore[prop-decorator]
|
||||||
|
@property
|
||||||
|
def SQLALCHEMY_DATABASE_URI(self) -> MySQLDsn:
|
||||||
|
return MultiHostUrl.build(
|
||||||
|
scheme="mysql+mysqldb",
|
||||||
|
username=self.MYSQL_USER,
|
||||||
|
password=self.MYSQL_PASSWORD,
|
||||||
|
host=self.MYSQL_SERVER,
|
||||||
|
port=self.MYSQL_PORT,
|
||||||
|
path=self.MYSQL_DB
|
||||||
|
) # type: ignore
|
||||||
|
|
||||||
|
settings = Settings() # type: ignore
|
||||||
@@ -1,168 +0,0 @@
|
|||||||
"""
|
|
||||||
Authentication utilities for JWT-based session management with role-based expiration times.
|
|
||||||
"""
|
|
||||||
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, Request
|
|
||||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
|
||||||
from sqlmodel import Session, select
|
|
||||||
from app.core.config import settings
|
|
||||||
from app.core.db import get_session
|
|
||||||
from app.schemas.models import User
|
|
||||||
from app.schemas.schemas import TokenData, UserResponse
|
|
||||||
from app.schemas.base import UserRole
|
|
||||||
|
|
||||||
# Password hashing
|
|
||||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
|
||||||
|
|
||||||
# Security scheme
|
|
||||||
security = HTTPBearer()
|
|
||||||
|
|
||||||
|
|
||||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
|
||||||
"""Verify a plain password against its hash."""
|
|
||||||
return pwd_context.verify(plain_password, hashed_password)
|
|
||||||
|
|
||||||
|
|
||||||
def get_password_hash(password: str) -> str:
|
|
||||||
"""Generate password hash."""
|
|
||||||
return pwd_context.hash(password)
|
|
||||||
|
|
||||||
|
|
||||||
def authenticate_user(
|
|
||||||
session: Session,
|
|
||||||
username: str,
|
|
||||||
password: str
|
|
||||||
) -> 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, "user_not_found"
|
|
||||||
if not verify_password(password, user.password_hash):
|
|
||||||
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:
|
|
||||||
"""Get token expiration time based on user role."""
|
|
||||||
role_expiration_map = {
|
|
||||||
UserRole.ADMIN: settings.admin_token_expire_minutes,
|
|
||||||
UserRole.WRITE: settings.write_token_expire_minutes,
|
|
||||||
UserRole.READ_ONLY: settings.read_only_token_expire_minutes,
|
|
||||||
}
|
|
||||||
return role_expiration_map.get(role, settings.read_only_token_expire_minutes)
|
|
||||||
|
|
||||||
|
|
||||||
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None):
|
|
||||||
"""Create JWT access token."""
|
|
||||||
to_encode = data.copy()
|
|
||||||
if expires_delta:
|
|
||||||
expire = datetime.now(timezone.utc) + expires_delta
|
|
||||||
else:
|
|
||||||
expire = datetime.now(timezone.utc) + timedelta(minutes=15)
|
|
||||||
|
|
||||||
to_encode.update({"exp": expire})
|
|
||||||
encoded_jwt = jwt.encode(to_encode, settings.secret_key, algorithm=settings.algorithm)
|
|
||||||
return encoded_jwt
|
|
||||||
|
|
||||||
|
|
||||||
def verify_token(credentials: HTTPAuthorizationCredentials = Depends(security)) -> TokenData:
|
|
||||||
"""Verify JWT token and extract token data."""
|
|
||||||
credentials_exception = HTTPException(
|
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
||||||
detail="Could not validate credentials",
|
|
||||||
headers={"WWW-Authenticate": "Bearer"},
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
token = credentials.credentials
|
|
||||||
payload = jwt.decode(token, settings.secret_key, algorithms=[settings.algorithm])
|
|
||||||
username: Optional[str] = payload.get("sub")
|
|
||||||
user_id: Optional[int] = payload.get("user_id")
|
|
||||||
role: Optional[str] = payload.get("role")
|
|
||||||
|
|
||||||
if username is None or user_id is None or role is None:
|
|
||||||
raise credentials_exception
|
|
||||||
|
|
||||||
token_data = TokenData(
|
|
||||||
username=username,
|
|
||||||
user_id=user_id,
|
|
||||||
role=UserRole(role)
|
|
||||||
)
|
|
||||||
except JWTError:
|
|
||||||
raise credentials_exception
|
|
||||||
|
|
||||||
return token_data
|
|
||||||
|
|
||||||
|
|
||||||
def get_current_user(
|
|
||||||
token_data: TokenData = Depends(verify_token),
|
|
||||||
session: Session = Depends(get_session)
|
|
||||||
) -> User:
|
|
||||||
"""Get current user from token."""
|
|
||||||
credentials_exception = HTTPException(
|
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
||||||
detail="Could not validate credentials",
|
|
||||||
headers={"WWW-Authenticate": "Bearer"},
|
|
||||||
)
|
|
||||||
|
|
||||||
user = session.get(User, token_data.user_id)
|
|
||||||
if user is None:
|
|
||||||
raise credentials_exception
|
|
||||||
return user
|
|
||||||
|
|
||||||
|
|
||||||
def get_current_active_user(
|
|
||||||
current_user: UserResponse = Depends(get_current_user)
|
|
||||||
) -> UserResponse:
|
|
||||||
"""Get current active user (extend this if you add user activation status)."""
|
|
||||||
return current_user
|
|
||||||
|
|
||||||
|
|
||||||
def require_role(required_roles: list[UserRole]):
|
|
||||||
"""Dependency factory for role-based access control."""
|
|
||||||
def role_checker(current_user: User = Depends(get_current_active_user)) -> User:
|
|
||||||
if current_user.role not in required_roles:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
|
||||||
detail="Operation not permitted for your role"
|
|
||||||
)
|
|
||||||
return current_user
|
|
||||||
return role_checker
|
|
||||||
|
|
||||||
|
|
||||||
# Common role dependencies
|
|
||||||
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
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
from pydantic import (
|
|
||||||
PostgresDsn
|
|
||||||
)
|
|
||||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
||||||
|
|
||||||
|
|
||||||
class Settings(BaseSettings):
|
|
||||||
"""
|
|
||||||
Application settings loaded from environment variables.
|
|
||||||
"""
|
|
||||||
database_uri: PostgresDsn
|
|
||||||
environment: str
|
|
||||||
project_name: str
|
|
||||||
|
|
||||||
# JWT settings
|
|
||||||
secret_key: str = "your-secret-key-change-this-in-production"
|
|
||||||
algorithm: str = "HS256"
|
|
||||||
|
|
||||||
# Role-based expiration times (in minutes)
|
|
||||||
admin_token_expire_minutes: int = 60 * 24 * 7 # 7 days (default)
|
|
||||||
write_token_expire_minutes: int = 60 * 24 * 3 # 3 days (default)
|
|
||||||
read_only_token_expire_minutes: int = 60 * 8 # 8 hours (default)
|
|
||||||
|
|
||||||
model_config = SettingsConfigDict(
|
|
||||||
# One level above ./backend
|
|
||||||
env_file='../.env',
|
|
||||||
env_ignore_empty=True,
|
|
||||||
extra='ignore'
|
|
||||||
)
|
|
||||||
api_v1_str: str = "/api/v1"
|
|
||||||
|
|
||||||
settings = Settings() # type: ignore
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
from sqlmodel import Session, create_engine
|
|
||||||
from app.core.config import settings
|
|
||||||
|
|
||||||
engine = create_engine(str(settings.database_uri))
|
|
||||||
|
|
||||||
|
|
||||||
def get_session():
|
|
||||||
"""main interface to interact with db
|
|
||||||
"""
|
|
||||||
with Session(engine) as session:
|
|
||||||
yield session
|
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
"""
|
||||||
|
The Client table
|
||||||
|
"""
|
||||||
|
from fastapi import APIRouter, HTTPException
|
||||||
|
from sqlmodel import func, select
|
||||||
|
from app.models import Client, Supplier, Product, Payment, Credit
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/client", tags=["items"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/", response_model=Client)
|
||||||
|
def read_clients(
|
||||||
|
session: SessionDep,
|
||||||
|
)
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
"""
|
||||||
|
TODO: when Credit.purchase_price is updated, update Product.purchase_price
|
||||||
|
"""
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
from sqlmodel import Session, create_engine, select
|
||||||
|
from app.config import settings
|
||||||
|
from app.models import Client, Supplier
|
||||||
|
|
||||||
|
engine = create_engine(str(settings.SQLALCHEMY_DATABASE_URI))
|
||||||
|
|
||||||
|
|
||||||
|
def init_db(session: Session) -> None:
|
||||||
|
""""""
|
||||||
+5
-33
@@ -4,45 +4,17 @@
|
|||||||
NOTE:
|
NOTE:
|
||||||
-
|
-
|
||||||
"""
|
"""
|
||||||
from app.core.config import settings
|
from app.config import settings
|
||||||
|
from typing import Union
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
|
||||||
from app.api.v1.users import router as users_router
|
|
||||||
from app.api.v1.transactions import router as transactions_router
|
|
||||||
from app.api.v1.partners import router as partners_router
|
|
||||||
from app.api.v1.products import router as products_router
|
|
||||||
from app.api.v1.transaction_details import router as transaction_details_router
|
|
||||||
from app.api.v1.payments import router as payments_router
|
|
||||||
from app.api.v1.credit import router as credit_router
|
|
||||||
from app.api.v1.inventory import router as inventory_router
|
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title=settings.project_name,
|
title=settings.PROJECT_NAME,
|
||||||
openapi_url=f"{settings.api_v1_str}/openapi.json"
|
openapi_url=f"{settings.API_V1_STR}/openapi.json"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# CORS for React frontend
|
|
||||||
app.add_middleware(
|
|
||||||
CORSMiddleware,
|
|
||||||
allow_origins=["http://localhost:5173"],
|
|
||||||
allow_credentials=True,
|
|
||||||
allow_methods=["*"],
|
|
||||||
allow_headers=["*"]
|
|
||||||
)
|
|
||||||
|
|
||||||
app.include_router(users_router, prefix=settings.api_v1_str)
|
|
||||||
app.include_router(transactions_router, prefix=settings.api_v1_str)
|
|
||||||
app.include_router(partners_router, prefix=settings.api_v1_str)
|
|
||||||
app.include_router(products_router, prefix=settings.api_v1_str)
|
|
||||||
app.include_router(transaction_details_router, prefix=settings.api_v1_str)
|
|
||||||
app.include_router(payments_router, prefix=settings.api_v1_str)
|
|
||||||
app.include_router(credit_router, prefix=settings.api_v1_str)
|
|
||||||
app.include_router(inventory_router, prefix=settings.api_v1_str)
|
|
||||||
|
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
def read_root():
|
def read_root():
|
||||||
"""
|
"""
|
||||||
"""
|
"""
|
||||||
return {"message": "CMT API v1"}
|
return {"Hello": "World"}
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
"""
|
||||||
|
This module contains Pydantic/Database Models that map database tables validate
|
||||||
|
and serialize api responses.
|
||||||
|
|
||||||
|
If the logic is identical -> SQLModel is used to do both.
|
||||||
|
Otherwise pydantic - for api responses
|
||||||
|
And SQLAlchemy is used for db data validation.
|
||||||
|
|
||||||
|
TODO:
|
||||||
|
Mapping & validation for:
|
||||||
|
- Clients, Suppliers, Products, payments
|
||||||
|
|
||||||
|
Done:
|
||||||
|
* Table mappings
|
||||||
|
"""
|
||||||
|
from sqlmodel import SQLModel, Field, UniqueConstraint
|
||||||
|
from datetime import datetime
|
||||||
|
from sqlalchemy import Column, DateTime, func, Enum as SQLEnum
|
||||||
|
from enum import Enum
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
class TradeType(str, Enum):
|
||||||
|
BUY = "Buy"
|
||||||
|
SELL = "Sell"
|
||||||
|
|
||||||
|
|
||||||
|
class Client(SQLModel, table=True):
|
||||||
|
"""Clients table mapping, api response validation and serialisation"""
|
||||||
|
id: Optional[int] = Field(default=None, primary_key=True)
|
||||||
|
tin_number: int = Field(nullable=False, unique=True)
|
||||||
|
names: str = Field(max_length=100, nullable=False)
|
||||||
|
phone_number: str = Field(max_length=10, nullable=False)
|
||||||
|
|
||||||
|
|
||||||
|
class Supplier(SQLModel, table=True):
|
||||||
|
"""Supplier table mapping, api response validation and serialisation"""
|
||||||
|
id: Optional[int] = Field(default=None, primary_key=True)
|
||||||
|
tin_number: int = Field(nullable=False, unique=True)
|
||||||
|
names: str = Field(max_length=100, nullable=False)
|
||||||
|
phone_number: str = Field(max_length=10, nullable=False)
|
||||||
|
|
||||||
|
|
||||||
|
class Product(SQLModel, table=True):
|
||||||
|
"""Products table mapping, api response validation and serialisation
|
||||||
|
|
||||||
|
NOTE: purchase price should update every time a supplier credits us goods
|
||||||
|
and price has changed
|
||||||
|
"""
|
||||||
|
__table_args__ = (UniqueConstraint("product_code"),)
|
||||||
|
|
||||||
|
id: Optional[int] = Field(nullable=False, primary_key=True)
|
||||||
|
product_code: str = Field(max_length=10, nullable=False)
|
||||||
|
product_name: str = Field(max_length=20, nullable=False, unique=True)
|
||||||
|
purchase_price: int = Field(nullable=False)
|
||||||
|
date_modified: datetime = Field(
|
||||||
|
sa_column=Column(DateTime,
|
||||||
|
server_default=func.now(),
|
||||||
|
server_onupdate=func.now())
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Payment(SQLModel, table=True):
|
||||||
|
"""
|
||||||
|
Payments table mapping, api response validation and serialisation
|
||||||
|
|
||||||
|
Include both payments to suppliers and from clients
|
||||||
|
"""
|
||||||
|
id: Optional[int] = Field(default=None, primary_key=True)
|
||||||
|
payment_type: TradeType = Field(
|
||||||
|
sa_column=Column(SQLEnum(TradeType), nullable=False)
|
||||||
|
)
|
||||||
|
product_code: str = Field(nullable=False, foreign_key="product.product_code")
|
||||||
|
client_id: Optional[int] = Field(nullable=False, foreign_key="client.id")
|
||||||
|
|
||||||
|
supplier_id: Optional[int] = Field(nullable=False, foreign_key="supplier.id")
|
||||||
|
amount: int = Field(nullable=False)
|
||||||
|
payment_method: str = Field(max_length=24, nullable=False)
|
||||||
|
date: datetime = Field(
|
||||||
|
sa_column=Column(DateTime, server_default=func.now())
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Credit(SQLModel, table=True):
|
||||||
|
"""Credit table mapping, api response validation and serialisation
|
||||||
|
|
||||||
|
Include both credit from suppliers and to clients
|
||||||
|
"""
|
||||||
|
id: Optional[int] = Field(default=None, primary_key=True)
|
||||||
|
transcation_type: TradeType = Field(
|
||||||
|
sa_column=Column(SQLEnum(TradeType), nullable=False)
|
||||||
|
)
|
||||||
|
product_code: str = Field(nullable=False, foreign_key="product.product_code")
|
||||||
|
client_id: Optional[int] = Field(nullable=False, foreign_key="client.id")
|
||||||
|
supplier_id: Optional[int] = Field(nullable=False, foreign_key="supplier.id")
|
||||||
|
qty: int = Field(nullable=False)
|
||||||
|
amount: int = Field(nullable=False)
|
||||||
|
date: datetime = Field(
|
||||||
|
sa_column=Column(DateTime, server_default=func.now())
|
||||||
|
)
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
from sqlmodel import SQLModel
|
|
||||||
from enum import Enum
|
|
||||||
|
|
||||||
class UserRole(str, Enum):
|
|
||||||
"""User roles for system access.
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
ADMIN (str): Administrator with full access.
|
|
||||||
WRITE (str): User with write permissions.
|
|
||||||
READ_ONLY (str): User with read-only permissions.
|
|
||||||
"""
|
|
||||||
ADMIN = "admin"
|
|
||||||
WRITE = "write"
|
|
||||||
READ_ONLY = "read_only"
|
|
||||||
|
|
||||||
class TransactionType(str, Enum):
|
|
||||||
"""Types of financial transactions.
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
SALE (str): Sale transaction.
|
|
||||||
PURCHASE (str): Purchase transaction.
|
|
||||||
CREDIT (str): Credit transaction.
|
|
||||||
"""
|
|
||||||
SALE = "sell"
|
|
||||||
PURCHASE = "buy"
|
|
||||||
CREDIT = "credit"
|
|
||||||
|
|
||||||
class TransactionStatus(str, Enum):
|
|
||||||
"""Possible statuses of a transaction.
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
UNPAID (str): Transaction not paid.
|
|
||||||
PARTIALLY_PAID (str): Transaction partially paid.
|
|
||||||
PAID (str): Transaction fully paid.
|
|
||||||
CANCELLED (str): Transaction cancelled.
|
|
||||||
"""
|
|
||||||
UNPAID = "unpaid"
|
|
||||||
PARTIALLY_PAID = "partially_paid"
|
|
||||||
PAID = "paid"
|
|
||||||
CANCELLED = 'cancelled'
|
|
||||||
|
|
||||||
class PartnerType(str, Enum):
|
|
||||||
"""Types of business partners.
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
CLIENT (str): Client partner.
|
|
||||||
SUPPLIER (str): Supplier partner.
|
|
||||||
"""
|
|
||||||
CLIENT = "client"
|
|
||||||
SUPPLIER = "supplier"
|
|
||||||
|
|
||||||
class PaymentMethod(str, Enum):
|
|
||||||
"""Payment methods available.
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
MOMO (str): Mobile money.
|
|
||||||
BANK (str): Bank transfer.
|
|
||||||
CASH (str): Cash payment.
|
|
||||||
"""
|
|
||||||
MOMO = "momo"
|
|
||||||
BANK = "bank"
|
|
||||||
CASH = "cash"
|
|
||||||
@@ -1,274 +0,0 @@
|
|||||||
"""
|
|
||||||
Models module.
|
|
||||||
|
|
||||||
This module contains Pydantic and SQLModel classes for database table mapping,
|
|
||||||
API request/response validation, and serialization.
|
|
||||||
|
|
||||||
The models include:
|
|
||||||
- User
|
|
||||||
- Partner
|
|
||||||
- Product
|
|
||||||
- Transaction and its details
|
|
||||||
- Payment
|
|
||||||
- Credit account
|
|
||||||
- Inventory
|
|
||||||
"""
|
|
||||||
|
|
||||||
from sqlmodel import SQLModel, Field
|
|
||||||
from datetime import datetime, date
|
|
||||||
from sqlalchemy import Column, String, CheckConstraint, DateTime, func, Enum as SQLEnum
|
|
||||||
from typing import Optional
|
|
||||||
from .base import UserRole, PartnerType, TransactionType, TransactionStatus, PaymentMethod
|
|
||||||
|
|
||||||
|
|
||||||
class User(SQLModel, table=True):
|
|
||||||
"""User table mapping, API request/response validation, and serialization.
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
id (int, optional): Primary key.
|
|
||||||
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):
|
|
||||||
"""Partner (client or supplier) mapping, API request/response validation, and serialization.
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
id (int, optional): Primary key.
|
|
||||||
tin_number (int): Tax identification number.
|
|
||||||
names (str): Full name.
|
|
||||||
type (PartnerType): Partner type (CLIENT or SUPPLIER).
|
|
||||||
phone_number (str, optional): Phone number.
|
|
||||||
"""
|
|
||||||
|
|
||||||
id: Optional[int] = Field(default=None, primary_key=True)
|
|
||||||
tin_number: int = Field(nullable=False, unique=True)
|
|
||||||
names: str = Field(max_length=100, nullable=False)
|
|
||||||
type: PartnerType = Field(nullable=False, max_length=10, default=PartnerType.CLIENT)
|
|
||||||
phone_number: str = Field(max_length=10, nullable=True)
|
|
||||||
|
|
||||||
|
|
||||||
class Product(SQLModel, table=True):
|
|
||||||
"""Products table mapping, API request/response validation, and serialization.
|
|
||||||
|
|
||||||
Every time a product's purchase price changes, update here.
|
|
||||||
selling_price is referential: defaults but can be overridden.
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
id (int, optional): Primary key.
|
|
||||||
product_code (str): Unique product code (max 10 chars).
|
|
||||||
product_name (str): Unique product name (max 20 chars).
|
|
||||||
purchase_price (int): Last purchase price.
|
|
||||||
selling_price (int): Reference selling price.
|
|
||||||
date_modified (datetime): Last modified timestamp.
|
|
||||||
"""
|
|
||||||
|
|
||||||
id: Optional[int] = Field(default=None, primary_key=True)
|
|
||||||
product_code: str = Field(max_length=10, unique=True, nullable=False)
|
|
||||||
product_name: str = Field(max_length=20, nullable=False, unique=True)
|
|
||||||
purchase_price: int = Field(nullable=False)
|
|
||||||
selling_price: int = Field(nullable=False)
|
|
||||||
date_modified: datetime = Field(
|
|
||||||
default=None,
|
|
||||||
sa_column=Column(DateTime(timezone=True),
|
|
||||||
server_default=func.now(),
|
|
||||||
server_onupdate=func.now())
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class Transaction(SQLModel, table=True):
|
|
||||||
"""Transaction table mapping, API request/response validation, and serialization.
|
|
||||||
|
|
||||||
Includes both business events to/from suppliers and clients.
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
id (int, optional): Primary key.
|
|
||||||
partner_id (int): Related partner ID.
|
|
||||||
transcation_type (TransactionType): Type of transaction.
|
|
||||||
transaction_status (TransactionStatus): Current status.
|
|
||||||
total_amount (int): Total transaction amount.
|
|
||||||
created_by (int): User ID who created.
|
|
||||||
updated_by (int): User ID who last updated.
|
|
||||||
created_on (datetime): Creation timestamp.
|
|
||||||
updated_on (datetime): Last update timestamp.
|
|
||||||
"""
|
|
||||||
|
|
||||||
__tablename__: str = "transactions"
|
|
||||||
id: Optional[int] = Field(default=None, primary_key=True)
|
|
||||||
partner_id: Optional[int] = Field(nullable=False, foreign_key="partner.id")
|
|
||||||
transcation_type: TransactionType = Field(
|
|
||||||
sa_column=Column(
|
|
||||||
SQLEnum(TransactionType),
|
|
||||||
nullable=False,
|
|
||||||
default=TransactionType.SALE
|
|
||||||
)
|
|
||||||
)
|
|
||||||
transaction_status: TransactionStatus = Field(
|
|
||||||
sa_column=Column(
|
|
||||||
SQLEnum(TransactionStatus),
|
|
||||||
nullable=False,
|
|
||||||
default=TransactionStatus.UNPAID
|
|
||||||
)
|
|
||||||
)
|
|
||||||
total_amount: int = Field(nullable=False, default=0)
|
|
||||||
|
|
||||||
created_by: int = Field(nullable=False, foreign_key="user.id")
|
|
||||||
updated_by: int = Field(nullable=False, foreign_key="user.id")
|
|
||||||
|
|
||||||
created_on: datetime = Field(
|
|
||||||
default=None,
|
|
||||||
sa_column=Column(DateTime(timezone=True), server_default=func.now())
|
|
||||||
)
|
|
||||||
updated_on: datetime = Field(
|
|
||||||
default=None,
|
|
||||||
sa_column=Column(
|
|
||||||
DateTime(timezone=True),
|
|
||||||
onupdate=func.now(),
|
|
||||||
server_default=func.now()
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class Transaction_details(SQLModel, table=True):
|
|
||||||
"""Transaction details mapping, API request/response validation, and serialization.
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
id (int, optional): Primary key.
|
|
||||||
partner_id (int): Related partner ID.
|
|
||||||
product_id (str): Product ID.
|
|
||||||
qty (int): Quantity.
|
|
||||||
selling_price (int): Unit price.
|
|
||||||
total_value (int): qty * selling_price.
|
|
||||||
created_by (int): User ID who created.
|
|
||||||
updated_by (int): User ID who last updated.
|
|
||||||
created_at (datetime): Creation timestamp.
|
|
||||||
updated_at (datetime): Last update timestamp.
|
|
||||||
"""
|
|
||||||
|
|
||||||
__tablename__: str = "transaction_details"
|
|
||||||
|
|
||||||
id: Optional[int] = Field(default=None, primary_key=True)
|
|
||||||
partner_id: int = Field(nullable=False, foreign_key="partner.id")
|
|
||||||
product_id: int = Field(nullable=False, foreign_key="product.id")
|
|
||||||
qty: int = Field(nullable=False)
|
|
||||||
selling_price: int = Field(nullable=False)
|
|
||||||
|
|
||||||
# qty * selling_price
|
|
||||||
total_value: int = Field(nullable=False, default=0) # per items
|
|
||||||
|
|
||||||
created_by: int = Field(nullable=False, foreign_key="user.id")
|
|
||||||
updated_by: int = Field(nullable=False, foreign_key="user.id")
|
|
||||||
created_at: datetime = Field(
|
|
||||||
default=None,
|
|
||||||
sa_column=Column(DateTime(timezone=True), server_default=func.now())
|
|
||||||
)
|
|
||||||
updated_at: datetime = Field(
|
|
||||||
default=None,
|
|
||||||
sa_column=Column(DateTime(timezone=True), server_default=func.now())
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class Payment(SQLModel, table=True):
|
|
||||||
"""Payment table mapping, API request/response validation, and serialization.
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
id (int, optional): Primary key.
|
|
||||||
transaction_id (int): Related transaction ID.
|
|
||||||
payment_method (PaymentMethod): Method of payment.
|
|
||||||
paid_amount (int): Amount paid.
|
|
||||||
payment_date (date): Date of payment.
|
|
||||||
created_by (int): User ID who created.
|
|
||||||
updated_by (int): User ID who last updated.
|
|
||||||
created_at (datetime): Creation timestamp.
|
|
||||||
updated_at (datetime): Last update timestamp.
|
|
||||||
"""
|
|
||||||
|
|
||||||
id: Optional[int] = Field(default=None, primary_key=True)
|
|
||||||
transaction_id: int = Field(nullable=False, foreign_key="transactions.id")
|
|
||||||
payment_method: str = Field(
|
|
||||||
sa_column=Column(
|
|
||||||
String(10),
|
|
||||||
CheckConstraint("payment_method IN ('momo', 'bank', 'cash')"),
|
|
||||||
nullable=False,
|
|
||||||
default="cash"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
paid_amount: int = Field(nullable=False)
|
|
||||||
payment_date: date = Field(nullable=False)
|
|
||||||
created_by: int = Field(nullable=False, foreign_key="user.id")
|
|
||||||
updated_by: int = Field(nullable=False, foreign_key="user.id")
|
|
||||||
created_at: datetime = Field(
|
|
||||||
default=None,
|
|
||||||
sa_column=Column(DateTime(timezone=True), server_default=func.now())
|
|
||||||
)
|
|
||||||
updated_at: datetime = Field(
|
|
||||||
default=None,
|
|
||||||
sa_column=Column(DateTime(timezone=True), server_default=func.now())
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class Credit(SQLModel, table=True):
|
|
||||||
"""Credit account mapping, API request/response validation, and serialization.
|
|
||||||
|
|
||||||
Includes both supplier and client credit events.
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
id (int, optional): Primary key.
|
|
||||||
partner_id (int): Related partner ID.
|
|
||||||
transaction_id (int): Related transaction ID.
|
|
||||||
credit_amount (int): Credit amount.
|
|
||||||
credit_limit (int): Credit limit.
|
|
||||||
balance (int): Current balance.
|
|
||||||
created_by (int): User ID who created.
|
|
||||||
updated_by (int): User ID who last updated.
|
|
||||||
created_at (datetime): Creation timestamp.
|
|
||||||
updated_at (datetime): Last update timestamp.
|
|
||||||
"""
|
|
||||||
|
|
||||||
__tablename__: str = "credit_accounts"
|
|
||||||
|
|
||||||
id: Optional[int] = Field(default=None, primary_key=True)
|
|
||||||
partner_id: int = Field(nullable=False, unique=True, foreign_key="partner.id")
|
|
||||||
transaction_id: int = Field(nullable=False, foreign_key="transactions.id")
|
|
||||||
|
|
||||||
credit_amount: int = Field(nullable=False)
|
|
||||||
credit_limit: int = Field(nullable=False)
|
|
||||||
balance: int = Field(nullable=False)
|
|
||||||
|
|
||||||
created_by: int = Field(nullable=False, foreign_key="user.id")
|
|
||||||
updated_by: int = Field(nullable=False, foreign_key="user.id")
|
|
||||||
created_at: datetime = Field(
|
|
||||||
default=None,
|
|
||||||
sa_column=Column(DateTime(timezone=True), server_default=func.now())
|
|
||||||
)
|
|
||||||
updated_at: datetime = Field(
|
|
||||||
default=None,
|
|
||||||
sa_column=Column(DateTime(timezone=True), server_default=func.now())
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class Inventory(SQLModel, table=True):
|
|
||||||
"""Inventory mapping, API request/response validation, and serialization.
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
id (int, optional): Primary key.
|
|
||||||
product_id (int): Related product ID.
|
|
||||||
total_qty (int): Total quantity in inventory.
|
|
||||||
"""
|
|
||||||
|
|
||||||
id: Optional[int] = Field(default=None, primary_key=True)
|
|
||||||
|
|
||||||
product_id: int = Field(nullable=False, unique=True, foreign_key="product.id")
|
|
||||||
total_qty: int = Field(nullable=False, default=0)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,228 +0,0 @@
|
|||||||
"""
|
|
||||||
Custom validation schema
|
|
||||||
"""
|
|
||||||
from sqlmodel import SQLModel
|
|
||||||
from app.schemas.base import UserRole, PartnerType, PaymentMethod
|
|
||||||
from typing import Optional
|
|
||||||
from datetime import datetime, date
|
|
||||||
from .base import TransactionType, TransactionStatus
|
|
||||||
|
|
||||||
|
|
||||||
######################################################
|
|
||||||
# Users
|
|
||||||
class UserCreate(SQLModel):
|
|
||||||
username: str
|
|
||||||
password: str
|
|
||||||
role: UserRole = UserRole.READ_ONLY
|
|
||||||
|
|
||||||
|
|
||||||
class UserUpdate(SQLModel):
|
|
||||||
username: Optional[str] = None
|
|
||||||
password: Optional[str] = None
|
|
||||||
role: Optional[UserRole] = None
|
|
||||||
|
|
||||||
|
|
||||||
class UserLogin(SQLModel):
|
|
||||||
username: str
|
|
||||||
password: str
|
|
||||||
|
|
||||||
|
|
||||||
class UserResponse(SQLModel):
|
|
||||||
id: Optional[int] = None
|
|
||||||
username: str
|
|
||||||
role: UserRole
|
|
||||||
is_approved: bool
|
|
||||||
|
|
||||||
|
|
||||||
class Token(SQLModel):
|
|
||||||
access_token: str
|
|
||||||
token_type: str
|
|
||||||
expires_in: int
|
|
||||||
user: UserResponse
|
|
||||||
|
|
||||||
|
|
||||||
class TokenData(SQLModel):
|
|
||||||
username: Optional[str] = None
|
|
||||||
user_id: Optional[int] = None
|
|
||||||
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):
|
|
||||||
partner_id: int
|
|
||||||
transcation_type: TransactionType = TransactionType.SALE
|
|
||||||
transaction_status: TransactionStatus = TransactionStatus.UNPAID
|
|
||||||
total_amount: int
|
|
||||||
|
|
||||||
class TransactionCreate(TransactionBase):
|
|
||||||
pass
|
|
||||||
|
|
||||||
class TransactionUpdate(SQLModel):
|
|
||||||
partner_id: Optional[int] = None
|
|
||||||
transcation_type: Optional[TransactionType] = None
|
|
||||||
transaction_status: Optional[TransactionStatus] = None
|
|
||||||
total_amount: Optional[int] = None
|
|
||||||
|
|
||||||
class TransactionResponse(TransactionBase):
|
|
||||||
id: int
|
|
||||||
created_by: int
|
|
||||||
updated_by: int
|
|
||||||
created_on: datetime
|
|
||||||
updated_on: datetime
|
|
||||||
|
|
||||||
|
|
||||||
##################################################
|
|
||||||
# Partners
|
|
||||||
class PartnerBase(SQLModel):
|
|
||||||
tin_number: int
|
|
||||||
names: str
|
|
||||||
type: PartnerType = PartnerType.CLIENT
|
|
||||||
phone_number: Optional[str] = None
|
|
||||||
|
|
||||||
class PartnerCreate(PartnerBase):
|
|
||||||
pass
|
|
||||||
|
|
||||||
class PartnerUpdate(SQLModel):
|
|
||||||
tin_number: Optional[int] = None
|
|
||||||
names: Optional[str] = None
|
|
||||||
type: Optional[PartnerType] = None
|
|
||||||
phone_number: Optional[str] = None
|
|
||||||
|
|
||||||
class PartnerResponse(PartnerBase):
|
|
||||||
id: int
|
|
||||||
|
|
||||||
|
|
||||||
##################################################
|
|
||||||
# Products
|
|
||||||
class ProductBase(SQLModel):
|
|
||||||
product_code: str
|
|
||||||
product_name: str
|
|
||||||
purchase_price: int
|
|
||||||
selling_price: int
|
|
||||||
|
|
||||||
class ProductCreate(ProductBase):
|
|
||||||
pass
|
|
||||||
|
|
||||||
class ProductUpdate(SQLModel):
|
|
||||||
product_code: Optional[str] = None
|
|
||||||
product_name: Optional[str] = None
|
|
||||||
purchase_price: Optional[int] = None
|
|
||||||
selling_price: Optional[int] = None
|
|
||||||
|
|
||||||
class ProductResponse(ProductBase):
|
|
||||||
id: int
|
|
||||||
date_modified: datetime
|
|
||||||
|
|
||||||
|
|
||||||
##################################################
|
|
||||||
# Transaction Details
|
|
||||||
class TransactionDetailsBase(SQLModel):
|
|
||||||
partner_id: int
|
|
||||||
product_id: int
|
|
||||||
qty: int
|
|
||||||
selling_price: int
|
|
||||||
total_value: int
|
|
||||||
|
|
||||||
class TransactionDetailsCreate(TransactionDetailsBase):
|
|
||||||
pass
|
|
||||||
|
|
||||||
class TransactionDetailsUpdate(SQLModel):
|
|
||||||
partner_id: Optional[int] = None
|
|
||||||
product_id: Optional[int] = None
|
|
||||||
qty: Optional[int] = None
|
|
||||||
selling_price: Optional[int] = None
|
|
||||||
total_value: Optional[int] = None
|
|
||||||
|
|
||||||
class TransactionDetailsResponse(TransactionDetailsBase):
|
|
||||||
id: int
|
|
||||||
created_by: int
|
|
||||||
updated_by: int
|
|
||||||
created_at: datetime
|
|
||||||
updated_at: datetime
|
|
||||||
|
|
||||||
|
|
||||||
##################################################
|
|
||||||
# Payments
|
|
||||||
class PaymentBase(SQLModel):
|
|
||||||
transaction_id: int
|
|
||||||
payment_method: PaymentMethod = PaymentMethod.CASH
|
|
||||||
paid_amount: int
|
|
||||||
payment_date: date
|
|
||||||
|
|
||||||
class PaymentCreate(PaymentBase):
|
|
||||||
pass
|
|
||||||
|
|
||||||
class PaymentUpdate(SQLModel):
|
|
||||||
transaction_id: Optional[int] = None
|
|
||||||
payment_method: Optional[PaymentMethod] = None
|
|
||||||
paid_amount: Optional[int] = None
|
|
||||||
payment_date: Optional[date] = None
|
|
||||||
|
|
||||||
class PaymentResponse(PaymentBase):
|
|
||||||
id: int
|
|
||||||
created_by: int
|
|
||||||
updated_by: int
|
|
||||||
created_at: datetime
|
|
||||||
updated_at: datetime
|
|
||||||
|
|
||||||
|
|
||||||
##################################################
|
|
||||||
# Credit
|
|
||||||
class CreditBase(SQLModel):
|
|
||||||
partner_id: int
|
|
||||||
transaction_id: int
|
|
||||||
credit_amount: int
|
|
||||||
credit_limit: int
|
|
||||||
balance: int
|
|
||||||
|
|
||||||
class CreditCreate(CreditBase):
|
|
||||||
pass
|
|
||||||
|
|
||||||
class CreditUpdate(SQLModel):
|
|
||||||
partner_id: Optional[int] = None
|
|
||||||
transaction_id: Optional[int] = None
|
|
||||||
credit_amount: Optional[int] = None
|
|
||||||
credit_limit: Optional[int] = None
|
|
||||||
balance: Optional[int] = None
|
|
||||||
|
|
||||||
class CreditResponse(CreditBase):
|
|
||||||
id: int
|
|
||||||
created_by: int
|
|
||||||
updated_by: int
|
|
||||||
created_at: datetime
|
|
||||||
updated_at: datetime
|
|
||||||
|
|
||||||
|
|
||||||
##################################################
|
|
||||||
# Inventory
|
|
||||||
class InventoryBase(SQLModel):
|
|
||||||
product_id: int
|
|
||||||
total_qty: int
|
|
||||||
|
|
||||||
class InventoryCreate(InventoryBase):
|
|
||||||
pass
|
|
||||||
|
|
||||||
class InventoryUpdate(SQLModel):
|
|
||||||
product_id: Optional[int] = None
|
|
||||||
total_qty: Optional[int] = None
|
|
||||||
|
|
||||||
class InventoryResponse(InventoryBase):
|
|
||||||
id: int
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
[tool.pytest.ini_options]
|
|
||||||
minversion = "6.0"
|
|
||||||
addopts = "-ra -q --strict-markers"
|
|
||||||
testpaths = ["tests"]
|
|
||||||
python_files = ["test_*.py"]
|
|
||||||
python_classes = ["Test*"]
|
|
||||||
python_functions = ["test_*"]
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
alembic==1.16.4
|
|
||||||
annotated-types==0.7.0
|
|
||||||
anyio==4.10.0
|
|
||||||
asyncpg==0.30.0
|
|
||||||
fastapi==0.116.1
|
|
||||||
greenlet==3.2.4
|
|
||||||
idna==3.10
|
|
||||||
iniconfig==2.1.0
|
|
||||||
Mako==1.3.10
|
|
||||||
MarkupSafe==3.0.2
|
|
||||||
packaging==25.0
|
|
||||||
pluggy==1.6.0
|
|
||||||
psycopg2-binary==2.9.10
|
|
||||||
pydantic==2.11.7
|
|
||||||
pydantic-settings==2.10.1
|
|
||||||
pydantic_core==2.33.2
|
|
||||||
Pygments==2.19.2
|
|
||||||
pytest==8.4.1
|
|
||||||
python-dotenv==1.1.1
|
|
||||||
sniffio==1.3.1
|
|
||||||
SQLAlchemy==2.0.43
|
|
||||||
sqlmodel==0.0.24
|
|
||||||
starlette==0.47.2
|
|
||||||
typing-inspection==0.4.1
|
|
||||||
typing_extensions==4.14.1
|
|
||||||
python-jose[cryptography]==3.3.0
|
|
||||||
python-multipart==0.0.6
|
|
||||||
passlib[bcrypt]==1.7.4
|
|
||||||
|
|||||||
@@ -1,67 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Script to create an initial admin user for the CMT system.
|
|
||||||
Run this after setting up the database to create the first admin user.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import sys
|
|
||||||
import os
|
|
||||||
|
|
||||||
# Add the parent directory to the path so we can import from app
|
|
||||||
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
||||||
|
|
||||||
from sqlmodel import Session
|
|
||||||
from app.core.db import engine
|
|
||||||
from app.core.auth import get_password_hash
|
|
||||||
from app.schemas.models import User
|
|
||||||
from app.schemas.base import UserRole
|
|
||||||
|
|
||||||
|
|
||||||
def create_admin_user():
|
|
||||||
"""Create an initial admin user."""
|
|
||||||
|
|
||||||
username = input("Enter admin username: ").strip()
|
|
||||||
if not username:
|
|
||||||
print("Username cannot be empty!")
|
|
||||||
return
|
|
||||||
|
|
||||||
password = input("Enter admin password: ").strip()
|
|
||||||
if not password:
|
|
||||||
print("Password cannot be empty!")
|
|
||||||
return
|
|
||||||
|
|
||||||
# Hash the password
|
|
||||||
hashed_password = get_password_hash(password)
|
|
||||||
|
|
||||||
# Create the user
|
|
||||||
admin_user = User(
|
|
||||||
username=username,
|
|
||||||
password_hash=hashed_password,
|
|
||||||
role=UserRole.ADMIN
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
with Session(engine) as session:
|
|
||||||
# Check if user already exists
|
|
||||||
from sqlmodel import select
|
|
||||||
statement = select(User).where(User.username == username)
|
|
||||||
existing = session.exec(statement).first()
|
|
||||||
if existing:
|
|
||||||
print(f"User '{username}' already exists!")
|
|
||||||
return
|
|
||||||
|
|
||||||
session.add(admin_user)
|
|
||||||
session.commit()
|
|
||||||
session.refresh(admin_user)
|
|
||||||
|
|
||||||
print(f"✅ Admin user '{username}' created successfully!")
|
|
||||||
print(f"User ID: {admin_user.id}")
|
|
||||||
print(f"Role: {admin_user.role}")
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
print(f"❌ Error creating admin user: {e}")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
print("=== CMT Admin User Creation ===")
|
|
||||||
create_admin_user()
|
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
-- Creates an user
|
||||||
|
CREATE USER
|
||||||
|
IF NOT EXISTS 'admin'@'%' IDENTIFIED BY '@Avatarme1';
|
||||||
|
|
||||||
|
-- Grant rights to admin user
|
||||||
|
GRANT ALL PRIVILEGES ON `CMT`.* TO 'admin'@'%';
|
||||||
|
FLUSH PRIVILEGES;
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
|
||||||
|
-- Create DB
|
||||||
|
CREATE DATABASE
|
||||||
|
IF NOT EXISTS CMT;
|
||||||
|
|
||||||
|
USE CMT;
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Simple script to test if environment variables are being read correctly.
|
|
||||||
"""
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
sys.path.append('.')
|
|
||||||
|
|
||||||
from app.core.config import settings
|
|
||||||
|
|
||||||
print("Testing environment variable loading...")
|
|
||||||
print(f"Environment: {settings.environment}")
|
|
||||||
print(f"Project Name: {settings.project_name}")
|
|
||||||
print(f"Database URI: {settings.database_uri}")
|
|
||||||
print(f"Secret Key: {settings.secret_key[:20]}..." if len(settings.secret_key) > 20 else settings.secret_key)
|
|
||||||
print(f"Admin Token Expire Minutes: {settings.admin_token_expire_minutes}")
|
|
||||||
print(f"Write Token Expire Minutes: {settings.write_token_expire_minutes}")
|
|
||||||
print(f"Read Only Token Expire Minutes: {settings.read_only_token_expire_minutes}")
|
|
||||||
|
|
||||||
print("\nDirect environment check:")
|
|
||||||
print(f"SECRET_KEY from env: {os.getenv('SECRET_KEY', 'NOT_FOUND')[:20]}...")
|
|
||||||
print(f"ADMIN_TOKEN_EXPIRE_MINUTES from env: {os.getenv('ADMIN_TOKEN_EXPIRE_MINUTES', 'NOT_FOUND')}")
|
|
||||||
print(f"WRITE_TOKEN_EXPIRE_MINUTES from env: {os.getenv('WRITE_TOKEN_EXPIRE_MINUTES', 'NOT_FOUND')}")
|
|
||||||
print(f"READ_ONLY_TOKEN_EXPIRE_MINUTES from env: {os.getenv('READ_ONLY_TOKEN_EXPIRE_MINUTES', 'NOT_FOUND')}")
|
|
||||||
@@ -1,464 +0,0 @@
|
|||||||
import pytest
|
|
||||||
from fastapi.testclient import TestClient
|
|
||||||
from app.schemas.base import TransactionType, TransactionStatus
|
|
||||||
from app.schemas.models import Transaction, Credit
|
|
||||||
from sqlmodel import Session
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(name="sample_credit")
|
|
||||||
def sample_credit_fixture(session: Session, sample_partner, sample_transaction, admin_user):
|
|
||||||
"""Create a sample credit for testing."""
|
|
||||||
credit = Credit(
|
|
||||||
partner_id=sample_partner.id,
|
|
||||||
transaction_id=sample_transaction.id,
|
|
||||||
credit_amount=5000,
|
|
||||||
credit_limit=10000,
|
|
||||||
balance=5000,
|
|
||||||
created_by=admin_user.id,
|
|
||||||
updated_by=admin_user.id
|
|
||||||
)
|
|
||||||
session.add(credit)
|
|
||||||
session.commit()
|
|
||||||
session.refresh(credit)
|
|
||||||
return credit
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(name="multiple_credits")
|
|
||||||
def multiple_credits_fixture(session: Session, multiple_partners, admin_user):
|
|
||||||
"""Create multiple credits for testing."""
|
|
||||||
# Create transactions for each partner first
|
|
||||||
transactions = []
|
|
||||||
for partner in multiple_partners:
|
|
||||||
transaction = Transaction(
|
|
||||||
partner_id=partner.id,
|
|
||||||
transcation_type=TransactionType.SALE,
|
|
||||||
transaction_status=TransactionStatus.UNPAID,
|
|
||||||
total_amount=1000,
|
|
||||||
created_by=admin_user.id,
|
|
||||||
updated_by=admin_user.id
|
|
||||||
)
|
|
||||||
session.add(transaction)
|
|
||||||
transactions.append(transaction)
|
|
||||||
session.commit()
|
|
||||||
for transaction in transactions:
|
|
||||||
session.refresh(transaction)
|
|
||||||
|
|
||||||
# Create credits
|
|
||||||
credits = [
|
|
||||||
Credit(
|
|
||||||
partner_id=multiple_partners[0].id,
|
|
||||||
transaction_id=transactions[0].id,
|
|
||||||
credit_amount=3000,
|
|
||||||
credit_limit=5000,
|
|
||||||
balance=3000,
|
|
||||||
created_by=admin_user.id,
|
|
||||||
updated_by=admin_user.id
|
|
||||||
),
|
|
||||||
Credit(
|
|
||||||
partner_id=multiple_partners[1].id,
|
|
||||||
transaction_id=transactions[1].id,
|
|
||||||
credit_amount=7000,
|
|
||||||
credit_limit=10000,
|
|
||||||
balance=7000,
|
|
||||||
created_by=admin_user.id,
|
|
||||||
updated_by=admin_user.id
|
|
||||||
),
|
|
||||||
Credit(
|
|
||||||
partner_id=multiple_partners[2].id,
|
|
||||||
transaction_id=transactions[2].id,
|
|
||||||
credit_amount=2000,
|
|
||||||
credit_limit=8000,
|
|
||||||
balance=2000,
|
|
||||||
created_by=admin_user.id,
|
|
||||||
updated_by=admin_user.id
|
|
||||||
)
|
|
||||||
]
|
|
||||||
for credit in credits:
|
|
||||||
session.add(credit)
|
|
||||||
session.commit()
|
|
||||||
for credit in credits:
|
|
||||||
session.refresh(credit)
|
|
||||||
return credits
|
|
||||||
|
|
||||||
|
|
||||||
class TestCreditCreation:
|
|
||||||
"""Test credit creation endpoints."""
|
|
||||||
|
|
||||||
def test_create_credit_with_admin_access(self, client: TestClient, admin_token: str, sample_partner, sample_transaction):
|
|
||||||
"""Test credit creation with admin token."""
|
|
||||||
credit_data = {
|
|
||||||
"partner_id": sample_partner.id,
|
|
||||||
"transaction_id": sample_transaction.id,
|
|
||||||
"credit_amount": 5000,
|
|
||||||
"credit_limit": 10000,
|
|
||||||
"balance": 5000
|
|
||||||
}
|
|
||||||
response = client.post("/api/v1/credit/",
|
|
||||||
json=credit_data,
|
|
||||||
headers={"Authorization": f"Bearer {admin_token}"})
|
|
||||||
assert response.status_code == 201
|
|
||||||
data = response.json()
|
|
||||||
assert data["partner_id"] == sample_partner.id
|
|
||||||
assert data["transaction_id"] == sample_transaction.id
|
|
||||||
assert data["credit_amount"] == 5000
|
|
||||||
assert data["credit_limit"] == 10000
|
|
||||||
assert data["balance"] == 5000
|
|
||||||
assert "id" in data
|
|
||||||
assert "created_by" in data
|
|
||||||
assert "updated_by" in data
|
|
||||||
|
|
||||||
def test_create_credit_with_write_access(self, client: TestClient, write_token: str, multiple_partners, admin_user, session):
|
|
||||||
"""Test credit creation with write token."""
|
|
||||||
# Create a transaction for this test
|
|
||||||
transaction = Transaction(
|
|
||||||
partner_id=multiple_partners[0].id,
|
|
||||||
transcation_type=TransactionType.PURCHASE,
|
|
||||||
transaction_status=TransactionStatus.UNPAID,
|
|
||||||
total_amount=2000,
|
|
||||||
created_by=admin_user.id,
|
|
||||||
updated_by=admin_user.id
|
|
||||||
)
|
|
||||||
session.add(transaction)
|
|
||||||
session.commit()
|
|
||||||
session.refresh(transaction)
|
|
||||||
|
|
||||||
credit_data = {
|
|
||||||
"partner_id": multiple_partners[0].id,
|
|
||||||
"transaction_id": transaction.id,
|
|
||||||
"credit_amount": 3000,
|
|
||||||
"credit_limit": 7500,
|
|
||||||
"balance": 3000
|
|
||||||
}
|
|
||||||
response = client.post("/api/v1/credit/",
|
|
||||||
json=credit_data,
|
|
||||||
headers={"Authorization": f"Bearer {write_token}"})
|
|
||||||
assert response.status_code == 201
|
|
||||||
data = response.json()
|
|
||||||
assert data["credit_amount"] == 3000
|
|
||||||
assert data["credit_limit"] == 7500
|
|
||||||
|
|
||||||
def test_create_credit_unauthorized(self, client: TestClient, sample_partner, sample_transaction):
|
|
||||||
"""Test credit creation without authentication."""
|
|
||||||
credit_data = {
|
|
||||||
"partner_id": sample_partner.id,
|
|
||||||
"transaction_id": sample_transaction.id,
|
|
||||||
"credit_amount": 5000,
|
|
||||||
"credit_limit": 10000,
|
|
||||||
"balance": 5000
|
|
||||||
}
|
|
||||||
response = client.post("/api/v1/credit/", json=credit_data)
|
|
||||||
assert response.status_code == 403
|
|
||||||
|
|
||||||
def test_create_credit_read_only_forbidden(self, client: TestClient, read_only_token: str, sample_partner, sample_transaction):
|
|
||||||
"""Test credit creation with read-only access should fail."""
|
|
||||||
credit_data = {
|
|
||||||
"partner_id": sample_partner.id,
|
|
||||||
"transaction_id": sample_transaction.id,
|
|
||||||
"credit_amount": 5000,
|
|
||||||
"credit_limit": 10000,
|
|
||||||
"balance": 5000
|
|
||||||
}
|
|
||||||
response = client.post("/api/v1/credit/",
|
|
||||||
json=credit_data,
|
|
||||||
headers={"Authorization": f"Bearer {read_only_token}"})
|
|
||||||
assert response.status_code == 403
|
|
||||||
|
|
||||||
def test_create_credit_invalid_partner(self, client: TestClient, admin_token: str, sample_transaction):
|
|
||||||
"""Test creation with non-existent partner should fail."""
|
|
||||||
credit_data = {
|
|
||||||
"partner_id": 99999, # Non-existent partner
|
|
||||||
"transaction_id": sample_transaction.id,
|
|
||||||
"credit_amount": 5000,
|
|
||||||
"credit_limit": 10000,
|
|
||||||
"balance": 5000
|
|
||||||
}
|
|
||||||
response = client.post("/api/v1/credit/",
|
|
||||||
json=credit_data,
|
|
||||||
headers={"Authorization": f"Bearer {admin_token}"})
|
|
||||||
assert response.status_code == 400
|
|
||||||
assert "Partner not found" in response.json()["detail"]
|
|
||||||
|
|
||||||
def test_create_credit_invalid_transaction(self, client: TestClient, admin_token: str, sample_partner):
|
|
||||||
"""Test creation with non-existent transaction should fail."""
|
|
||||||
credit_data = {
|
|
||||||
"partner_id": sample_partner.id,
|
|
||||||
"transaction_id": 99999, # Non-existent transaction
|
|
||||||
"credit_amount": 5000,
|
|
||||||
"credit_limit": 10000,
|
|
||||||
"balance": 5000
|
|
||||||
}
|
|
||||||
response = client.post("/api/v1/credit/",
|
|
||||||
json=credit_data,
|
|
||||||
headers={"Authorization": f"Bearer {admin_token}"})
|
|
||||||
assert response.status_code == 400
|
|
||||||
assert "Transaction not found" in response.json()["detail"]
|
|
||||||
|
|
||||||
def test_create_credit_duplicate_partner(self, client: TestClient, admin_token: str, sample_credit):
|
|
||||||
"""Test creation with duplicate partner should fail."""
|
|
||||||
credit_data = {
|
|
||||||
"partner_id": sample_credit.partner_id, # Duplicate partner
|
|
||||||
"transaction_id": sample_credit.transaction_id,
|
|
||||||
"credit_amount": 3000,
|
|
||||||
"credit_limit": 8000,
|
|
||||||
"balance": 3000
|
|
||||||
}
|
|
||||||
response = client.post("/api/v1/credit/",
|
|
||||||
json=credit_data,
|
|
||||||
headers={"Authorization": f"Bearer {admin_token}"})
|
|
||||||
assert response.status_code == 409
|
|
||||||
assert "Credit account already exists for this partner" in response.json()["detail"]
|
|
||||||
|
|
||||||
|
|
||||||
class TestCreditRetrieval:
|
|
||||||
"""Test credit retrieval endpoints."""
|
|
||||||
|
|
||||||
def test_get_all_credits_with_auth(self, client: TestClient, admin_token: str, multiple_credits):
|
|
||||||
"""Test retrieving all credits with authentication."""
|
|
||||||
response = client.get("/api/v1/credit/",
|
|
||||||
headers={"Authorization": f"Bearer {admin_token}"})
|
|
||||||
assert response.status_code == 200
|
|
||||||
data = response.json()
|
|
||||||
assert isinstance(data, list)
|
|
||||||
assert len(data) >= 3 # At least the fixture credits
|
|
||||||
|
|
||||||
def test_get_all_credits_read_only_access(self, client: TestClient, read_only_token: str, multiple_credits):
|
|
||||||
"""Test read-only user can retrieve credits."""
|
|
||||||
response = client.get("/api/v1/credit/",
|
|
||||||
headers={"Authorization": f"Bearer {read_only_token}"})
|
|
||||||
assert response.status_code == 200
|
|
||||||
data = response.json()
|
|
||||||
assert isinstance(data, list)
|
|
||||||
|
|
||||||
def test_get_credits_unauthorized(self, client: TestClient):
|
|
||||||
"""Test retrieving credits without authentication."""
|
|
||||||
response = client.get("/api/v1/credit/")
|
|
||||||
assert response.status_code == 403
|
|
||||||
|
|
||||||
def test_get_credits_with_pagination(self, client: TestClient, admin_token: str, multiple_credits):
|
|
||||||
"""Test credit retrieval with pagination."""
|
|
||||||
response = client.get("/api/v1/credit/?skip=0&limit=2",
|
|
||||||
headers={"Authorization": f"Bearer {admin_token}"})
|
|
||||||
assert response.status_code == 200
|
|
||||||
data = response.json()
|
|
||||||
assert len(data) <= 2
|
|
||||||
|
|
||||||
def test_get_single_credit_by_id(self, client: TestClient, admin_token: str, sample_credit):
|
|
||||||
"""Test retrieving a single credit by ID."""
|
|
||||||
response = client.get(f"/api/v1/credit/{sample_credit.id}",
|
|
||||||
headers={"Authorization": f"Bearer {admin_token}"})
|
|
||||||
assert response.status_code == 200
|
|
||||||
data = response.json()
|
|
||||||
assert data["id"] == sample_credit.id
|
|
||||||
assert data["partner_id"] == sample_credit.partner_id
|
|
||||||
assert data["transaction_id"] == sample_credit.transaction_id
|
|
||||||
assert data["credit_amount"] == sample_credit.credit_amount
|
|
||||||
assert data["credit_limit"] == sample_credit.credit_limit
|
|
||||||
assert data["balance"] == sample_credit.balance
|
|
||||||
|
|
||||||
def test_get_nonexistent_credit(self, client: TestClient, admin_token: str):
|
|
||||||
"""Test retrieving a non-existent credit."""
|
|
||||||
response = client.get("/api/v1/credit/99999",
|
|
||||||
headers={"Authorization": f"Bearer {admin_token}"})
|
|
||||||
assert response.status_code == 404
|
|
||||||
assert "Credit account not found" in response.json()["detail"]
|
|
||||||
|
|
||||||
def test_get_credit_by_partner(self, client: TestClient, admin_token: str, sample_credit):
|
|
||||||
"""Test retrieving credit for specific partner."""
|
|
||||||
response = client.get(f"/api/v1/credit/partner/{sample_credit.partner_id}",
|
|
||||||
headers={"Authorization": f"Bearer {admin_token}"})
|
|
||||||
assert response.status_code == 200
|
|
||||||
data = response.json()
|
|
||||||
assert data["partner_id"] == sample_credit.partner_id
|
|
||||||
assert data["id"] == sample_credit.id
|
|
||||||
|
|
||||||
def test_get_credit_by_nonexistent_partner(self, client: TestClient, admin_token: str):
|
|
||||||
"""Test retrieving credit for non-existent partner."""
|
|
||||||
response = client.get("/api/v1/credit/partner/99999",
|
|
||||||
headers={"Authorization": f"Bearer {admin_token}"})
|
|
||||||
assert response.status_code == 404
|
|
||||||
assert "Partner not found" in response.json()["detail"]
|
|
||||||
|
|
||||||
def test_get_credit_by_partner_no_credit(self, client: TestClient, admin_token: str):
|
|
||||||
"""Test retrieving credit for partner with no credit account."""
|
|
||||||
# Just test with a high partner ID that likely doesn't exist
|
|
||||||
response = client.get("/api/v1/credit/partner/99998",
|
|
||||||
headers={"Authorization": f"Bearer {admin_token}"})
|
|
||||||
assert response.status_code == 404
|
|
||||||
|
|
||||||
|
|
||||||
class TestCreditUpdate:
|
|
||||||
"""Test credit update endpoints."""
|
|
||||||
|
|
||||||
def test_update_credit_with_write_access(self, client: TestClient, write_token: str, sample_credit):
|
|
||||||
"""Test updating credit with write access."""
|
|
||||||
update_data = {
|
|
||||||
"credit_amount": 6000,
|
|
||||||
"credit_limit": 12000,
|
|
||||||
"balance": 6000
|
|
||||||
}
|
|
||||||
response = client.put(f"/api/v1/credit/{sample_credit.id}",
|
|
||||||
json=update_data,
|
|
||||||
headers={"Authorization": f"Bearer {write_token}"})
|
|
||||||
assert response.status_code == 200
|
|
||||||
data = response.json()
|
|
||||||
assert data["credit_amount"] == 6000
|
|
||||||
assert data["credit_limit"] == 12000
|
|
||||||
assert data["balance"] == 6000
|
|
||||||
assert data["partner_id"] == sample_credit.partner_id # Unchanged
|
|
||||||
|
|
||||||
def test_update_credit_balance_only(self, client: TestClient, admin_token: str, sample_credit):
|
|
||||||
"""Test updating only credit balance."""
|
|
||||||
update_data = {
|
|
||||||
"balance": 3500
|
|
||||||
}
|
|
||||||
response = client.put(f"/api/v1/credit/{sample_credit.id}",
|
|
||||||
json=update_data,
|
|
||||||
headers={"Authorization": f"Bearer {admin_token}"})
|
|
||||||
assert response.status_code == 200
|
|
||||||
data = response.json()
|
|
||||||
assert data["balance"] == 3500
|
|
||||||
assert data["credit_amount"] == sample_credit.credit_amount # Unchanged
|
|
||||||
|
|
||||||
def test_update_credit_read_only_forbidden(self, client: TestClient, read_only_token: str, sample_credit):
|
|
||||||
"""Test updating credit with read-only access should fail."""
|
|
||||||
update_data = {
|
|
||||||
"balance": 4000
|
|
||||||
}
|
|
||||||
response = client.put(f"/api/v1/credit/{sample_credit.id}",
|
|
||||||
json=update_data,
|
|
||||||
headers={"Authorization": f"Bearer {read_only_token}"})
|
|
||||||
assert response.status_code == 403
|
|
||||||
|
|
||||||
def test_update_credit_invalid_partner(self, client: TestClient, admin_token: str, sample_credit):
|
|
||||||
"""Test updating credit with invalid partner should fail."""
|
|
||||||
update_data = {
|
|
||||||
"partner_id": 99999 # Non-existent partner
|
|
||||||
}
|
|
||||||
response = client.put(f"/api/v1/credit/{sample_credit.id}",
|
|
||||||
json=update_data,
|
|
||||||
headers={"Authorization": f"Bearer {admin_token}"})
|
|
||||||
assert response.status_code == 400
|
|
||||||
assert "Partner not found" in response.json()["detail"]
|
|
||||||
|
|
||||||
def test_update_credit_invalid_transaction(self, client: TestClient, admin_token: str, sample_credit):
|
|
||||||
"""Test updating credit with invalid transaction should fail."""
|
|
||||||
update_data = {
|
|
||||||
"transaction_id": 99999 # Non-existent transaction
|
|
||||||
}
|
|
||||||
response = client.put(f"/api/v1/credit/{sample_credit.id}",
|
|
||||||
json=update_data,
|
|
||||||
headers={"Authorization": f"Bearer {admin_token}"})
|
|
||||||
assert response.status_code == 400
|
|
||||||
assert "Transaction not found" in response.json()["detail"]
|
|
||||||
|
|
||||||
def test_update_credit_duplicate_partner(self, client: TestClient, admin_token: str, multiple_credits):
|
|
||||||
"""Test updating credit with duplicate partner should fail."""
|
|
||||||
credit_to_update = multiple_credits[0]
|
|
||||||
existing_partner_id = multiple_credits[1].partner_id
|
|
||||||
|
|
||||||
update_data = {
|
|
||||||
"partner_id": existing_partner_id
|
|
||||||
}
|
|
||||||
response = client.put(f"/api/v1/credit/{credit_to_update.id}",
|
|
||||||
json=update_data,
|
|
||||||
headers={"Authorization": f"Bearer {admin_token}"})
|
|
||||||
assert response.status_code == 409
|
|
||||||
assert "Credit account already exists for this partner" in response.json()["detail"]
|
|
||||||
|
|
||||||
def test_update_nonexistent_credit(self, client: TestClient, admin_token: str):
|
|
||||||
"""Test updating a non-existent credit."""
|
|
||||||
update_data = {
|
|
||||||
"balance": 5000
|
|
||||||
}
|
|
||||||
response = client.put("/api/v1/credit/99999",
|
|
||||||
json=update_data,
|
|
||||||
headers={"Authorization": f"Bearer {admin_token}"})
|
|
||||||
assert response.status_code == 404
|
|
||||||
assert "Credit account not found" in response.json()["detail"]
|
|
||||||
|
|
||||||
|
|
||||||
class TestCreditDeletion:
|
|
||||||
"""Test credit deletion endpoints."""
|
|
||||||
|
|
||||||
def test_delete_credit_with_admin_access(self, client: TestClient, admin_token: str, sample_credit):
|
|
||||||
"""Test deleting credit with admin access."""
|
|
||||||
response = client.delete(f"/api/v1/credit/{sample_credit.id}",
|
|
||||||
headers={"Authorization": f"Bearer {admin_token}"})
|
|
||||||
assert response.status_code == 204
|
|
||||||
|
|
||||||
# Verify credit is deleted
|
|
||||||
get_response = client.get(f"/api/v1/credit/{sample_credit.id}",
|
|
||||||
headers={"Authorization": f"Bearer {admin_token}"})
|
|
||||||
assert get_response.status_code == 404
|
|
||||||
|
|
||||||
def test_delete_credit_write_access_forbidden(self, client: TestClient, write_token: str, sample_credit):
|
|
||||||
"""Test deleting credit with write access should fail."""
|
|
||||||
response = client.delete(f"/api/v1/credit/{sample_credit.id}",
|
|
||||||
headers={"Authorization": f"Bearer {write_token}"})
|
|
||||||
assert response.status_code == 403
|
|
||||||
|
|
||||||
def test_delete_credit_read_only_forbidden(self, client: TestClient, read_only_token: str, sample_credit):
|
|
||||||
"""Test deleting credit with read-only access should fail."""
|
|
||||||
response = client.delete(f"/api/v1/credit/{sample_credit.id}",
|
|
||||||
headers={"Authorization": f"Bearer {read_only_token}"})
|
|
||||||
assert response.status_code == 403
|
|
||||||
|
|
||||||
def test_delete_nonexistent_credit(self, client: TestClient, admin_token: str):
|
|
||||||
"""Test deleting a non-existent credit."""
|
|
||||||
response = client.delete("/api/v1/credit/99999",
|
|
||||||
headers={"Authorization": f"Bearer {admin_token}"})
|
|
||||||
assert response.status_code == 404
|
|
||||||
assert "Credit account not found" in response.json()["detail"]
|
|
||||||
|
|
||||||
def test_delete_credit_unauthorized(self, client: TestClient, sample_credit):
|
|
||||||
"""Test deleting credit without authentication."""
|
|
||||||
response = client.delete(f"/api/v1/credit/{sample_credit.id}")
|
|
||||||
assert response.status_code == 403
|
|
||||||
|
|
||||||
|
|
||||||
class TestCreditValidation:
|
|
||||||
"""Test credit data validation."""
|
|
||||||
|
|
||||||
def test_create_credit_missing_required_fields(self, client: TestClient, admin_token: str):
|
|
||||||
"""Test creating credit with missing required fields."""
|
|
||||||
# Missing partner_id
|
|
||||||
credit_data = {
|
|
||||||
"transaction_id": 1,
|
|
||||||
"credit_amount": 5000,
|
|
||||||
"credit_limit": 10000,
|
|
||||||
"balance": 5000
|
|
||||||
}
|
|
||||||
response = client.post("/api/v1/credit/",
|
|
||||||
json=credit_data,
|
|
||||||
headers={"Authorization": f"Bearer {admin_token}"})
|
|
||||||
assert response.status_code == 422 # Validation error
|
|
||||||
|
|
||||||
def test_create_credit_negative_amounts(self, client: TestClient, admin_token: str, sample_partner, sample_transaction):
|
|
||||||
"""Test creating credit with negative amounts."""
|
|
||||||
credit_data = {
|
|
||||||
"partner_id": sample_partner.id,
|
|
||||||
"transaction_id": sample_transaction.id,
|
|
||||||
"credit_amount": -1000, # Negative amount
|
|
||||||
"credit_limit": 10000,
|
|
||||||
"balance": 5000
|
|
||||||
}
|
|
||||||
response = client.post("/api/v1/credit/",
|
|
||||||
json=credit_data,
|
|
||||||
headers={"Authorization": f"Bearer {admin_token}"})
|
|
||||||
# This might pass depending on validation rules, but business logic should prevent it
|
|
||||||
# You might want to add validation in the endpoint for this
|
|
||||||
|
|
||||||
def test_create_credit_balance_exceeds_limit(self, client: TestClient, admin_token: str, sample_partner, sample_transaction):
|
|
||||||
"""Test creating credit where balance exceeds limit."""
|
|
||||||
credit_data = {
|
|
||||||
"partner_id": sample_partner.id,
|
|
||||||
"transaction_id": sample_transaction.id,
|
|
||||||
"credit_amount": 5000,
|
|
||||||
"credit_limit": 3000, # Limit less than amount
|
|
||||||
"balance": 5000 # Balance exceeds limit
|
|
||||||
}
|
|
||||||
response = client.post("/api/v1/credit/",
|
|
||||||
json=credit_data,
|
|
||||||
headers={"Authorization": f"Bearer {admin_token}"})
|
|
||||||
# This might pass depending on validation rules, but business logic should prevent it
|
|
||||||
# You might want to add validation in the endpoint for this
|
|
||||||
@@ -1,380 +0,0 @@
|
|||||||
import pytest
|
|
||||||
from fastapi.testclient import TestClient
|
|
||||||
from app.schemas.models import Inventory
|
|
||||||
from sqlmodel import Session
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(name="sample_inventory")
|
|
||||||
def sample_inventory_fixture(session: Session, sample_product):
|
|
||||||
"""Create a sample inventory for testing."""
|
|
||||||
inventory = Inventory(
|
|
||||||
product_id=sample_product.id,
|
|
||||||
total_qty=100
|
|
||||||
)
|
|
||||||
session.add(inventory)
|
|
||||||
session.commit()
|
|
||||||
session.refresh(inventory)
|
|
||||||
return inventory
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(name="multiple_inventories")
|
|
||||||
def multiple_inventories_fixture(session: Session, multiple_products):
|
|
||||||
"""Create multiple inventories for testing."""
|
|
||||||
inventories = [
|
|
||||||
Inventory(
|
|
||||||
product_id=multiple_products[0].id,
|
|
||||||
total_qty=50
|
|
||||||
),
|
|
||||||
Inventory(
|
|
||||||
product_id=multiple_products[1].id,
|
|
||||||
total_qty=200
|
|
||||||
),
|
|
||||||
Inventory(
|
|
||||||
product_id=multiple_products[2].id,
|
|
||||||
total_qty=75
|
|
||||||
)
|
|
||||||
]
|
|
||||||
for inventory in inventories:
|
|
||||||
session.add(inventory)
|
|
||||||
session.commit()
|
|
||||||
for inventory in inventories:
|
|
||||||
session.refresh(inventory)
|
|
||||||
return inventories
|
|
||||||
|
|
||||||
|
|
||||||
class TestInventoryCreation:
|
|
||||||
"""Test inventory creation endpoints."""
|
|
||||||
|
|
||||||
def test_create_inventory_with_admin_access(self, client: TestClient, admin_token: str, sample_product):
|
|
||||||
"""Test inventory creation with admin token."""
|
|
||||||
inventory_data = {
|
|
||||||
"product_id": sample_product.id,
|
|
||||||
"total_qty": 150
|
|
||||||
}
|
|
||||||
response = client.post("/api/v1/inventory/",
|
|
||||||
json=inventory_data,
|
|
||||||
headers={"Authorization": f"Bearer {admin_token}"})
|
|
||||||
assert response.status_code == 201
|
|
||||||
data = response.json()
|
|
||||||
assert data["product_id"] == sample_product.id
|
|
||||||
assert data["total_qty"] == 150
|
|
||||||
assert "id" in data
|
|
||||||
|
|
||||||
def test_create_inventory_with_write_access(self, client: TestClient, write_token: str, multiple_products):
|
|
||||||
"""Test inventory creation with write token."""
|
|
||||||
inventory_data = {
|
|
||||||
"product_id": multiple_products[0].id,
|
|
||||||
"total_qty": 80
|
|
||||||
}
|
|
||||||
response = client.post("/api/v1/inventory/",
|
|
||||||
json=inventory_data,
|
|
||||||
headers={"Authorization": f"Bearer {write_token}"})
|
|
||||||
assert response.status_code == 201
|
|
||||||
data = response.json()
|
|
||||||
assert data["total_qty"] == 80
|
|
||||||
|
|
||||||
def test_create_inventory_zero_quantity(self, client: TestClient, admin_token: str, multiple_products):
|
|
||||||
"""Test inventory creation with zero quantity."""
|
|
||||||
inventory_data = {
|
|
||||||
"product_id": multiple_products[1].id,
|
|
||||||
"total_qty": 0
|
|
||||||
}
|
|
||||||
response = client.post("/api/v1/inventory/",
|
|
||||||
json=inventory_data,
|
|
||||||
headers={"Authorization": f"Bearer {admin_token}"})
|
|
||||||
assert response.status_code == 201
|
|
||||||
data = response.json()
|
|
||||||
assert data["total_qty"] == 0
|
|
||||||
|
|
||||||
def test_create_inventory_unauthorized(self, client: TestClient, sample_product):
|
|
||||||
"""Test inventory creation without authentication."""
|
|
||||||
inventory_data = {
|
|
||||||
"product_id": sample_product.id,
|
|
||||||
"total_qty": 100
|
|
||||||
}
|
|
||||||
response = client.post("/api/v1/inventory/", json=inventory_data)
|
|
||||||
assert response.status_code == 403
|
|
||||||
|
|
||||||
def test_create_inventory_read_only_forbidden(self, client: TestClient, read_only_token: str, sample_product):
|
|
||||||
"""Test inventory creation with read-only access should fail."""
|
|
||||||
inventory_data = {
|
|
||||||
"product_id": sample_product.id,
|
|
||||||
"total_qty": 100
|
|
||||||
}
|
|
||||||
response = client.post("/api/v1/inventory/",
|
|
||||||
json=inventory_data,
|
|
||||||
headers={"Authorization": f"Bearer {read_only_token}"})
|
|
||||||
assert response.status_code == 403
|
|
||||||
|
|
||||||
def test_create_inventory_invalid_product(self, client: TestClient, admin_token: str):
|
|
||||||
"""Test creation with non-existent product should fail."""
|
|
||||||
inventory_data = {
|
|
||||||
"product_id": 99999, # Non-existent product
|
|
||||||
"total_qty": 100
|
|
||||||
}
|
|
||||||
response = client.post("/api/v1/inventory/",
|
|
||||||
json=inventory_data,
|
|
||||||
headers={"Authorization": f"Bearer {admin_token}"})
|
|
||||||
assert response.status_code == 400
|
|
||||||
assert "Product not found" in response.json()["detail"]
|
|
||||||
|
|
||||||
def test_create_inventory_duplicate_product(self, client: TestClient, admin_token: str, sample_inventory):
|
|
||||||
"""Test creation with duplicate product should fail."""
|
|
||||||
inventory_data = {
|
|
||||||
"product_id": sample_inventory.product_id, # Duplicate product
|
|
||||||
"total_qty": 50
|
|
||||||
}
|
|
||||||
response = client.post("/api/v1/inventory/",
|
|
||||||
json=inventory_data,
|
|
||||||
headers={"Authorization": f"Bearer {admin_token}"})
|
|
||||||
assert response.status_code == 409
|
|
||||||
assert "Inventory entry already exists for this product" in response.json()["detail"]
|
|
||||||
|
|
||||||
|
|
||||||
class TestInventoryRetrieval:
|
|
||||||
"""Test inventory retrieval endpoints."""
|
|
||||||
|
|
||||||
def test_get_all_inventories_with_auth(self, client: TestClient, admin_token: str, multiple_inventories):
|
|
||||||
"""Test retrieving all inventories with authentication."""
|
|
||||||
response = client.get("/api/v1/inventory/",
|
|
||||||
headers={"Authorization": f"Bearer {admin_token}"})
|
|
||||||
assert response.status_code == 200
|
|
||||||
data = response.json()
|
|
||||||
assert isinstance(data, list)
|
|
||||||
assert len(data) >= 3 # At least the fixture inventories
|
|
||||||
|
|
||||||
def test_get_all_inventories_read_only_access(self, client: TestClient, read_only_token: str, multiple_inventories):
|
|
||||||
"""Test read-only user can retrieve inventories."""
|
|
||||||
response = client.get("/api/v1/inventory/",
|
|
||||||
headers={"Authorization": f"Bearer {read_only_token}"})
|
|
||||||
assert response.status_code == 200
|
|
||||||
data = response.json()
|
|
||||||
assert isinstance(data, list)
|
|
||||||
|
|
||||||
def test_get_inventories_unauthorized(self, client: TestClient):
|
|
||||||
"""Test retrieving inventories without authentication."""
|
|
||||||
response = client.get("/api/v1/inventory/")
|
|
||||||
assert response.status_code == 403
|
|
||||||
|
|
||||||
def test_get_inventories_with_pagination(self, client: TestClient, admin_token: str, multiple_inventories):
|
|
||||||
"""Test inventory retrieval with pagination."""
|
|
||||||
response = client.get("/api/v1/inventory/?skip=0&limit=2",
|
|
||||||
headers={"Authorization": f"Bearer {admin_token}"})
|
|
||||||
assert response.status_code == 200
|
|
||||||
data = response.json()
|
|
||||||
assert len(data) <= 2
|
|
||||||
|
|
||||||
def test_get_single_inventory_by_id(self, client: TestClient, admin_token: str, sample_inventory):
|
|
||||||
"""Test retrieving a single inventory by ID."""
|
|
||||||
response = client.get(f"/api/v1/inventory/{sample_inventory.id}",
|
|
||||||
headers={"Authorization": f"Bearer {admin_token}"})
|
|
||||||
assert response.status_code == 200
|
|
||||||
data = response.json()
|
|
||||||
assert data["id"] == sample_inventory.id
|
|
||||||
assert data["product_id"] == sample_inventory.product_id
|
|
||||||
assert data["total_qty"] == sample_inventory.total_qty
|
|
||||||
|
|
||||||
def test_get_nonexistent_inventory(self, client: TestClient, admin_token: str):
|
|
||||||
"""Test retrieving a non-existent inventory."""
|
|
||||||
response = client.get("/api/v1/inventory/99999",
|
|
||||||
headers={"Authorization": f"Bearer {admin_token}"})
|
|
||||||
assert response.status_code == 404
|
|
||||||
assert "Inventory entry not found" in response.json()["detail"]
|
|
||||||
|
|
||||||
def test_get_inventory_by_product(self, client: TestClient, admin_token: str, sample_inventory):
|
|
||||||
"""Test retrieving inventory for specific product."""
|
|
||||||
response = client.get(f"/api/v1/inventory/product/{sample_inventory.product_id}",
|
|
||||||
headers={"Authorization": f"Bearer {admin_token}"})
|
|
||||||
assert response.status_code == 200
|
|
||||||
data = response.json()
|
|
||||||
assert data["product_id"] == sample_inventory.product_id
|
|
||||||
assert data["id"] == sample_inventory.id
|
|
||||||
|
|
||||||
def test_get_inventory_by_nonexistent_product(self, client: TestClient, admin_token: str):
|
|
||||||
"""Test retrieving inventory for non-existent product."""
|
|
||||||
response = client.get("/api/v1/inventory/product/99999",
|
|
||||||
headers={"Authorization": f"Bearer {admin_token}"})
|
|
||||||
assert response.status_code == 404
|
|
||||||
assert "Product not found" in response.json()["detail"]
|
|
||||||
|
|
||||||
def test_get_inventory_by_product_no_inventory(self, client: TestClient, admin_token: str):
|
|
||||||
"""Test retrieving inventory for product with no inventory entry."""
|
|
||||||
# Just test with a high product ID that likely doesn't exist
|
|
||||||
response = client.get("/api/v1/inventory/product/99998",
|
|
||||||
headers={"Authorization": f"Bearer {admin_token}"})
|
|
||||||
assert response.status_code == 404
|
|
||||||
|
|
||||||
|
|
||||||
class TestInventoryUpdate:
|
|
||||||
"""Test inventory update endpoints."""
|
|
||||||
|
|
||||||
def test_update_inventory_with_write_access(self, client: TestClient, write_token: str, sample_inventory):
|
|
||||||
"""Test updating inventory with write access."""
|
|
||||||
update_data = {
|
|
||||||
"total_qty": 175
|
|
||||||
}
|
|
||||||
response = client.put(f"/api/v1/inventory/{sample_inventory.id}",
|
|
||||||
json=update_data,
|
|
||||||
headers={"Authorization": f"Bearer {write_token}"})
|
|
||||||
assert response.status_code == 200
|
|
||||||
data = response.json()
|
|
||||||
assert data["total_qty"] == 175
|
|
||||||
assert data["product_id"] == sample_inventory.product_id # Unchanged
|
|
||||||
|
|
||||||
def test_update_inventory_product_id(self, client: TestClient, admin_token: str, sample_inventory, multiple_products):
|
|
||||||
"""Test updating inventory product ID."""
|
|
||||||
update_data = {
|
|
||||||
"product_id": multiple_products[0].id
|
|
||||||
}
|
|
||||||
response = client.put(f"/api/v1/inventory/{sample_inventory.id}",
|
|
||||||
json=update_data,
|
|
||||||
headers={"Authorization": f"Bearer {admin_token}"})
|
|
||||||
assert response.status_code == 200
|
|
||||||
data = response.json()
|
|
||||||
assert data["product_id"] == multiple_products[0].id
|
|
||||||
|
|
||||||
def test_update_inventory_to_zero(self, client: TestClient, admin_token: str, sample_inventory):
|
|
||||||
"""Test updating inventory quantity to zero."""
|
|
||||||
update_data = {
|
|
||||||
"total_qty": 0
|
|
||||||
}
|
|
||||||
response = client.put(f"/api/v1/inventory/{sample_inventory.id}",
|
|
||||||
json=update_data,
|
|
||||||
headers={"Authorization": f"Bearer {admin_token}"})
|
|
||||||
assert response.status_code == 200
|
|
||||||
data = response.json()
|
|
||||||
assert data["total_qty"] == 0
|
|
||||||
|
|
||||||
def test_update_inventory_read_only_forbidden(self, client: TestClient, read_only_token: str, sample_inventory):
|
|
||||||
"""Test updating inventory with read-only access should fail."""
|
|
||||||
update_data = {
|
|
||||||
"total_qty": 200
|
|
||||||
}
|
|
||||||
response = client.put(f"/api/v1/inventory/{sample_inventory.id}",
|
|
||||||
json=update_data,
|
|
||||||
headers={"Authorization": f"Bearer {read_only_token}"})
|
|
||||||
assert response.status_code == 403
|
|
||||||
|
|
||||||
def test_update_inventory_invalid_product(self, client: TestClient, admin_token: str, sample_inventory):
|
|
||||||
"""Test updating inventory with invalid product should fail."""
|
|
||||||
update_data = {
|
|
||||||
"product_id": 99999 # Non-existent product
|
|
||||||
}
|
|
||||||
response = client.put(f"/api/v1/inventory/{sample_inventory.id}",
|
|
||||||
json=update_data,
|
|
||||||
headers={"Authorization": f"Bearer {admin_token}"})
|
|
||||||
assert response.status_code == 400
|
|
||||||
assert "Product not found" in response.json()["detail"]
|
|
||||||
|
|
||||||
def test_update_inventory_duplicate_product(self, client: TestClient, admin_token: str, multiple_inventories):
|
|
||||||
"""Test updating inventory with duplicate product should fail."""
|
|
||||||
inventory_to_update = multiple_inventories[0]
|
|
||||||
existing_product_id = multiple_inventories[1].product_id
|
|
||||||
|
|
||||||
update_data = {
|
|
||||||
"product_id": existing_product_id
|
|
||||||
}
|
|
||||||
response = client.put(f"/api/v1/inventory/{inventory_to_update.id}",
|
|
||||||
json=update_data,
|
|
||||||
headers={"Authorization": f"Bearer {admin_token}"})
|
|
||||||
assert response.status_code == 409
|
|
||||||
assert "Inventory entry already exists for this product" in response.json()["detail"]
|
|
||||||
|
|
||||||
def test_update_nonexistent_inventory(self, client: TestClient, admin_token: str):
|
|
||||||
"""Test updating a non-existent inventory."""
|
|
||||||
update_data = {
|
|
||||||
"total_qty": 300
|
|
||||||
}
|
|
||||||
response = client.put("/api/v1/inventory/99999",
|
|
||||||
json=update_data,
|
|
||||||
headers={"Authorization": f"Bearer {admin_token}"})
|
|
||||||
assert response.status_code == 404
|
|
||||||
assert "Inventory entry not found" in response.json()["detail"]
|
|
||||||
|
|
||||||
|
|
||||||
class TestInventoryDeletion:
|
|
||||||
"""Test inventory deletion endpoints."""
|
|
||||||
|
|
||||||
def test_delete_inventory_with_admin_access(self, client: TestClient, admin_token: str, sample_inventory):
|
|
||||||
"""Test deleting inventory with admin access."""
|
|
||||||
response = client.delete(f"/api/v1/inventory/{sample_inventory.id}",
|
|
||||||
headers={"Authorization": f"Bearer {admin_token}"})
|
|
||||||
assert response.status_code == 204
|
|
||||||
|
|
||||||
# Verify inventory is deleted
|
|
||||||
get_response = client.get(f"/api/v1/inventory/{sample_inventory.id}",
|
|
||||||
headers={"Authorization": f"Bearer {admin_token}"})
|
|
||||||
assert get_response.status_code == 404
|
|
||||||
|
|
||||||
def test_delete_inventory_write_access_forbidden(self, client: TestClient, write_token: str, sample_inventory):
|
|
||||||
"""Test deleting inventory with write access should fail."""
|
|
||||||
response = client.delete(f"/api/v1/inventory/{sample_inventory.id}",
|
|
||||||
headers={"Authorization": f"Bearer {write_token}"})
|
|
||||||
assert response.status_code == 403
|
|
||||||
|
|
||||||
def test_delete_inventory_read_only_forbidden(self, client: TestClient, read_only_token: str, sample_inventory):
|
|
||||||
"""Test deleting inventory with read-only access should fail."""
|
|
||||||
response = client.delete(f"/api/v1/inventory/{sample_inventory.id}",
|
|
||||||
headers={"Authorization": f"Bearer {read_only_token}"})
|
|
||||||
assert response.status_code == 403
|
|
||||||
|
|
||||||
def test_delete_nonexistent_inventory(self, client: TestClient, admin_token: str):
|
|
||||||
"""Test deleting a non-existent inventory."""
|
|
||||||
response = client.delete("/api/v1/inventory/99999",
|
|
||||||
headers={"Authorization": f"Bearer {admin_token}"})
|
|
||||||
assert response.status_code == 404
|
|
||||||
assert "Inventory entry not found" in response.json()["detail"]
|
|
||||||
|
|
||||||
def test_delete_inventory_unauthorized(self, client: TestClient, sample_inventory):
|
|
||||||
"""Test deleting inventory without authentication."""
|
|
||||||
response = client.delete(f"/api/v1/inventory/{sample_inventory.id}")
|
|
||||||
assert response.status_code == 403
|
|
||||||
|
|
||||||
|
|
||||||
class TestInventoryValidation:
|
|
||||||
"""Test inventory data validation."""
|
|
||||||
|
|
||||||
def test_create_inventory_missing_required_fields(self, client: TestClient, admin_token: str):
|
|
||||||
"""Test creating inventory with missing required fields."""
|
|
||||||
# Missing product_id
|
|
||||||
inventory_data = {
|
|
||||||
"total_qty": 100
|
|
||||||
}
|
|
||||||
response = client.post("/api/v1/inventory/",
|
|
||||||
json=inventory_data,
|
|
||||||
headers={"Authorization": f"Bearer {admin_token}"})
|
|
||||||
assert response.status_code == 422 # Validation error
|
|
||||||
|
|
||||||
def test_create_inventory_negative_quantity(self, client: TestClient, admin_token: str, sample_product):
|
|
||||||
"""Test creating inventory with negative quantity."""
|
|
||||||
inventory_data = {
|
|
||||||
"product_id": sample_product.id,
|
|
||||||
"total_qty": -50 # Negative quantity
|
|
||||||
}
|
|
||||||
response = client.post("/api/v1/inventory/",
|
|
||||||
json=inventory_data,
|
|
||||||
headers={"Authorization": f"Bearer {admin_token}"})
|
|
||||||
# This might pass depending on validation rules, but business logic should prevent it
|
|
||||||
# You might want to add validation in the endpoint for this
|
|
||||||
|
|
||||||
def test_create_inventory_invalid_product_type(self, client: TestClient, admin_token: str):
|
|
||||||
"""Test creating inventory with invalid product_id type."""
|
|
||||||
inventory_data = {
|
|
||||||
"product_id": "invalid_id", # String instead of int
|
|
||||||
"total_qty": 100
|
|
||||||
}
|
|
||||||
response = client.post("/api/v1/inventory/",
|
|
||||||
json=inventory_data,
|
|
||||||
headers={"Authorization": f"Bearer {admin_token}"})
|
|
||||||
assert response.status_code == 422 # Validation error
|
|
||||||
|
|
||||||
def test_create_inventory_invalid_quantity_type(self, client: TestClient, admin_token: str, sample_product):
|
|
||||||
"""Test creating inventory with invalid quantity type."""
|
|
||||||
inventory_data = {
|
|
||||||
"product_id": sample_product.id,
|
|
||||||
"total_qty": "invalid_quantity" # String instead of int
|
|
||||||
}
|
|
||||||
response = client.post("/api/v1/inventory/",
|
|
||||||
json=inventory_data,
|
|
||||||
headers={"Authorization": f"Bearer {admin_token}"})
|
|
||||||
assert response.status_code == 422 # Validation error
|
|
||||||
@@ -1,275 +0,0 @@
|
|||||||
import pytest
|
|
||||||
from fastapi.testclient import TestClient
|
|
||||||
from app.schemas.base import PartnerType
|
|
||||||
|
|
||||||
|
|
||||||
class TestPartnerCreation:
|
|
||||||
"""Test partner creation endpoints."""
|
|
||||||
|
|
||||||
def test_create_partner_with_admin_access(self, client: TestClient, admin_token: str):
|
|
||||||
"""Test partner creation with admin token."""
|
|
||||||
partner_data = {
|
|
||||||
"tin_number": 987654321,
|
|
||||||
"names": "New Test Partner",
|
|
||||||
"type": PartnerType.CLIENT,
|
|
||||||
"phone_number": "0987654321"
|
|
||||||
}
|
|
||||||
response = client.post("/api/v1/partners/",
|
|
||||||
json=partner_data,
|
|
||||||
headers={"Authorization": f"Bearer {admin_token}"})
|
|
||||||
assert response.status_code == 201
|
|
||||||
data = response.json()
|
|
||||||
assert data["tin_number"] == 987654321
|
|
||||||
assert data["names"] == "New Test Partner"
|
|
||||||
assert data["type"] == PartnerType.CLIENT
|
|
||||||
assert data["phone_number"] == "0987654321"
|
|
||||||
assert "id" in data
|
|
||||||
|
|
||||||
def test_create_partner_with_write_access(self, client: TestClient, write_token: str):
|
|
||||||
"""Test partner creation with write token."""
|
|
||||||
partner_data = {
|
|
||||||
"tin_number": 555666777,
|
|
||||||
"names": "Write Access Partner",
|
|
||||||
"type": PartnerType.SUPPLIER,
|
|
||||||
"phone_number": "0555666777"
|
|
||||||
}
|
|
||||||
response = client.post("/api/v1/partners/",
|
|
||||||
json=partner_data,
|
|
||||||
headers={"Authorization": f"Bearer {write_token}"})
|
|
||||||
assert response.status_code == 201
|
|
||||||
data = response.json()
|
|
||||||
assert data["tin_number"] == 555666777
|
|
||||||
assert data["type"] == PartnerType.SUPPLIER
|
|
||||||
|
|
||||||
def test_create_partner_without_phone(self, client: TestClient, admin_token: str):
|
|
||||||
"""Test partner creation without phone number."""
|
|
||||||
partner_data = {
|
|
||||||
"tin_number": 111222333,
|
|
||||||
"names": "Partner Without Phone",
|
|
||||||
"type": PartnerType.CLIENT
|
|
||||||
}
|
|
||||||
response = client.post("/api/v1/partners/",
|
|
||||||
json=partner_data,
|
|
||||||
headers={"Authorization": f"Bearer {admin_token}"})
|
|
||||||
assert response.status_code == 201
|
|
||||||
data = response.json()
|
|
||||||
assert data["tin_number"] == 111222333
|
|
||||||
|
|
||||||
def test_create_partner_unauthorized(self, client: TestClient):
|
|
||||||
"""Test partner creation without authentication."""
|
|
||||||
partner_data = {
|
|
||||||
"tin_number": 444555666,
|
|
||||||
"names": "Unauthorized Partner",
|
|
||||||
"type": PartnerType.CLIENT
|
|
||||||
}
|
|
||||||
response = client.post("/api/v1/partners/", json=partner_data)
|
|
||||||
assert response.status_code == 403
|
|
||||||
|
|
||||||
def test_create_partner_read_only_forbidden(self, client: TestClient, read_only_token: str):
|
|
||||||
"""Test partner creation with read-only access should fail."""
|
|
||||||
partner_data = {
|
|
||||||
"tin_number": 777888999,
|
|
||||||
"names": "Read Only Attempt",
|
|
||||||
"type": PartnerType.CLIENT
|
|
||||||
}
|
|
||||||
response = client.post("/api/v1/partners/",
|
|
||||||
json=partner_data,
|
|
||||||
headers={"Authorization": f"Bearer {read_only_token}"})
|
|
||||||
assert response.status_code == 403
|
|
||||||
|
|
||||||
def test_create_partner_duplicate_tin(self, client: TestClient, admin_token: str, sample_partner):
|
|
||||||
"""Test creation with duplicate TIN number should fail."""
|
|
||||||
partner_data = {
|
|
||||||
"tin_number": sample_partner.tin_number, # Duplicate TIN
|
|
||||||
"names": "Duplicate TIN Partner",
|
|
||||||
"type": PartnerType.SUPPLIER
|
|
||||||
}
|
|
||||||
response = client.post("/api/v1/partners/",
|
|
||||||
json=partner_data,
|
|
||||||
headers={"Authorization": f"Bearer {admin_token}"})
|
|
||||||
assert response.status_code == 400
|
|
||||||
assert "TIN number already exists" in response.json()["detail"]
|
|
||||||
|
|
||||||
|
|
||||||
class TestPartnerRetrieval:
|
|
||||||
"""Test partner retrieval endpoints."""
|
|
||||||
|
|
||||||
def test_get_all_partners_with_auth(self, client: TestClient, admin_token: str, multiple_partners):
|
|
||||||
"""Test retrieving all partners with authentication."""
|
|
||||||
response = client.get("/api/v1/partners/",
|
|
||||||
headers={"Authorization": f"Bearer {admin_token}"})
|
|
||||||
assert response.status_code == 200
|
|
||||||
data = response.json()
|
|
||||||
assert isinstance(data, list)
|
|
||||||
assert len(data) >= 3 # At least the fixture partners
|
|
||||||
|
|
||||||
def test_get_all_partners_read_only_access(self, client: TestClient, read_only_token: str, multiple_partners):
|
|
||||||
"""Test read-only user can retrieve partners."""
|
|
||||||
response = client.get("/api/v1/partners/",
|
|
||||||
headers={"Authorization": f"Bearer {read_only_token}"})
|
|
||||||
assert response.status_code == 200
|
|
||||||
data = response.json()
|
|
||||||
assert isinstance(data, list)
|
|
||||||
|
|
||||||
def test_get_partners_unauthorized(self, client: TestClient):
|
|
||||||
"""Test retrieving partners without authentication."""
|
|
||||||
response = client.get("/api/v1/partners/")
|
|
||||||
assert response.status_code == 403
|
|
||||||
|
|
||||||
def test_get_partners_with_pagination(self, client: TestClient, admin_token: str, multiple_partners):
|
|
||||||
"""Test partner retrieval with pagination."""
|
|
||||||
response = client.get("/api/v1/partners/?skip=0&limit=2",
|
|
||||||
headers={"Authorization": f"Bearer {admin_token}"})
|
|
||||||
assert response.status_code == 200
|
|
||||||
data = response.json()
|
|
||||||
assert len(data) <= 2
|
|
||||||
|
|
||||||
def test_get_single_partner_by_id(self, client: TestClient, admin_token: str, sample_partner):
|
|
||||||
"""Test retrieving a single partner by ID."""
|
|
||||||
response = client.get(f"/api/v1/partners/{sample_partner.id}",
|
|
||||||
headers={"Authorization": f"Bearer {admin_token}"})
|
|
||||||
assert response.status_code == 200
|
|
||||||
data = response.json()
|
|
||||||
assert data["id"] == sample_partner.id
|
|
||||||
assert data["tin_number"] == sample_partner.tin_number
|
|
||||||
assert data["names"] == sample_partner.names
|
|
||||||
|
|
||||||
def test_get_nonexistent_partner(self, client: TestClient, admin_token: str):
|
|
||||||
"""Test retrieving a non-existent partner."""
|
|
||||||
response = client.get("/api/v1/partners/99999",
|
|
||||||
headers={"Authorization": f"Bearer {admin_token}"})
|
|
||||||
assert response.status_code == 404
|
|
||||||
assert "Partner not found" in response.json()["detail"]
|
|
||||||
|
|
||||||
|
|
||||||
class TestPartnerUpdate:
|
|
||||||
"""Test partner update endpoints."""
|
|
||||||
|
|
||||||
def test_update_partner_with_write_access(self, client: TestClient, write_token: str, sample_partner):
|
|
||||||
"""Test updating partner with write access."""
|
|
||||||
update_data = {
|
|
||||||
"names": "Updated Partner Name",
|
|
||||||
"type": PartnerType.SUPPLIER
|
|
||||||
}
|
|
||||||
response = client.put(f"/api/v1/partners/{sample_partner.id}",
|
|
||||||
json=update_data,
|
|
||||||
headers={"Authorization": f"Bearer {write_token}"})
|
|
||||||
assert response.status_code == 200
|
|
||||||
data = response.json()
|
|
||||||
assert data["names"] == "Updated Partner Name"
|
|
||||||
assert data["type"] == PartnerType.SUPPLIER
|
|
||||||
assert data["tin_number"] == sample_partner.tin_number # Unchanged
|
|
||||||
|
|
||||||
def test_update_partner_tin_number(self, client: TestClient, admin_token: str, sample_partner):
|
|
||||||
"""Test updating partner TIN number."""
|
|
||||||
update_data = {
|
|
||||||
"tin_number": 999888777
|
|
||||||
}
|
|
||||||
response = client.put(f"/api/v1/partners/{sample_partner.id}",
|
|
||||||
json=update_data,
|
|
||||||
headers={"Authorization": f"Bearer {admin_token}"})
|
|
||||||
assert response.status_code == 200
|
|
||||||
data = response.json()
|
|
||||||
assert data["tin_number"] == 999888777
|
|
||||||
|
|
||||||
def test_update_partner_read_only_forbidden(self, client: TestClient, read_only_token: str, sample_partner):
|
|
||||||
"""Test updating partner with read-only access should fail."""
|
|
||||||
update_data = {
|
|
||||||
"names": "Should Not Update"
|
|
||||||
}
|
|
||||||
response = client.put(f"/api/v1/partners/{sample_partner.id}",
|
|
||||||
json=update_data,
|
|
||||||
headers={"Authorization": f"Bearer {read_only_token}"})
|
|
||||||
assert response.status_code == 403
|
|
||||||
|
|
||||||
def test_update_partner_duplicate_tin(self, client: TestClient, admin_token: str, multiple_partners):
|
|
||||||
"""Test updating partner with duplicate TIN should fail."""
|
|
||||||
partner_to_update = multiple_partners[0]
|
|
||||||
existing_tin = multiple_partners[1].tin_number
|
|
||||||
|
|
||||||
update_data = {
|
|
||||||
"tin_number": existing_tin
|
|
||||||
}
|
|
||||||
response = client.put(f"/api/v1/partners/{partner_to_update.id}",
|
|
||||||
json=update_data,
|
|
||||||
headers={"Authorization": f"Bearer {admin_token}"})
|
|
||||||
assert response.status_code == 400
|
|
||||||
assert "TIN number already exists" in response.json()["detail"]
|
|
||||||
|
|
||||||
def test_update_nonexistent_partner(self, client: TestClient, admin_token: str):
|
|
||||||
"""Test updating a non-existent partner."""
|
|
||||||
update_data = {
|
|
||||||
"names": "Non-existent Partner"
|
|
||||||
}
|
|
||||||
response = client.put("/api/v1/partners/99999",
|
|
||||||
json=update_data,
|
|
||||||
headers={"Authorization": f"Bearer {admin_token}"})
|
|
||||||
assert response.status_code == 404
|
|
||||||
assert "Partner not found" in response.json()["detail"]
|
|
||||||
|
|
||||||
|
|
||||||
class TestPartnerDeletion:
|
|
||||||
"""Test partner deletion endpoints."""
|
|
||||||
|
|
||||||
def test_delete_partner_with_admin_access(self, client: TestClient, admin_token: str, sample_partner):
|
|
||||||
"""Test deleting partner with admin access."""
|
|
||||||
response = client.delete(f"/api/v1/partners/{sample_partner.id}",
|
|
||||||
headers={"Authorization": f"Bearer {admin_token}"})
|
|
||||||
assert response.status_code == 204
|
|
||||||
|
|
||||||
# Verify partner is deleted
|
|
||||||
get_response = client.get(f"/api/v1/partners/{sample_partner.id}",
|
|
||||||
headers={"Authorization": f"Bearer {admin_token}"})
|
|
||||||
assert get_response.status_code == 404
|
|
||||||
|
|
||||||
def test_delete_partner_write_access_forbidden(self, client: TestClient, write_token: str, sample_partner):
|
|
||||||
"""Test deleting partner with write access should fail."""
|
|
||||||
response = client.delete(f"/api/v1/partners/{sample_partner.id}",
|
|
||||||
headers={"Authorization": f"Bearer {write_token}"})
|
|
||||||
assert response.status_code == 403
|
|
||||||
|
|
||||||
def test_delete_partner_read_only_forbidden(self, client: TestClient, read_only_token: str, sample_partner):
|
|
||||||
"""Test deleting partner with read-only access should fail."""
|
|
||||||
response = client.delete(f"/api/v1/partners/{sample_partner.id}",
|
|
||||||
headers={"Authorization": f"Bearer {read_only_token}"})
|
|
||||||
assert response.status_code == 403
|
|
||||||
|
|
||||||
def test_delete_nonexistent_partner(self, client: TestClient, admin_token: str):
|
|
||||||
"""Test deleting a non-existent partner."""
|
|
||||||
response = client.delete("/api/v1/partners/99999",
|
|
||||||
headers={"Authorization": f"Bearer {admin_token}"})
|
|
||||||
assert response.status_code == 404
|
|
||||||
assert "Partner not found" in response.json()["detail"]
|
|
||||||
|
|
||||||
def test_delete_partner_unauthorized(self, client: TestClient, sample_partner):
|
|
||||||
"""Test deleting partner without authentication."""
|
|
||||||
response = client.delete(f"/api/v1/partners/{sample_partner.id}")
|
|
||||||
assert response.status_code == 403
|
|
||||||
|
|
||||||
|
|
||||||
class TestPartnerValidation:
|
|
||||||
"""Test partner data validation."""
|
|
||||||
|
|
||||||
def test_create_partner_invalid_data(self, client: TestClient, admin_token: str):
|
|
||||||
"""Test creating partner with invalid data."""
|
|
||||||
# Missing required field
|
|
||||||
partner_data = {
|
|
||||||
"names": "Missing TIN Partner",
|
|
||||||
"type": PartnerType.CLIENT
|
|
||||||
}
|
|
||||||
response = client.post("/api/v1/partners/",
|
|
||||||
json=partner_data,
|
|
||||||
headers={"Authorization": f"Bearer {admin_token}"})
|
|
||||||
assert response.status_code == 422 # Validation error
|
|
||||||
|
|
||||||
def test_create_partner_invalid_type(self, client: TestClient, admin_token: str):
|
|
||||||
"""Test creating partner with invalid type."""
|
|
||||||
partner_data = {
|
|
||||||
"tin_number": 123456789,
|
|
||||||
"names": "Invalid Type Partner",
|
|
||||||
"type": "INVALID_TYPE"
|
|
||||||
}
|
|
||||||
response = client.post("/api/v1/partners/",
|
|
||||||
json=partner_data,
|
|
||||||
headers={"Authorization": f"Bearer {admin_token}"})
|
|
||||||
assert response.status_code == 422 # Validation error
|
|
||||||
@@ -1,425 +0,0 @@
|
|||||||
import pytest
|
|
||||||
from fastapi.testclient import TestClient
|
|
||||||
from app.schemas.base import PaymentMethod, TransactionType, TransactionStatus
|
|
||||||
from app.schemas.models import Transaction, Payment
|
|
||||||
from datetime import date, datetime
|
|
||||||
from sqlmodel import Session
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(name="sample_transaction")
|
|
||||||
def sample_transaction_fixture(session: Session, sample_partner, admin_user):
|
|
||||||
"""Create a sample transaction for payment testing."""
|
|
||||||
transaction = Transaction(
|
|
||||||
partner_id=sample_partner.id,
|
|
||||||
transcation_type=TransactionType.SALE,
|
|
||||||
transaction_status=TransactionStatus.UNPAID,
|
|
||||||
total_amount=1000,
|
|
||||||
created_by=admin_user.id,
|
|
||||||
updated_by=admin_user.id
|
|
||||||
)
|
|
||||||
session.add(transaction)
|
|
||||||
session.commit()
|
|
||||||
session.refresh(transaction)
|
|
||||||
return transaction
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(name="sample_payment")
|
|
||||||
def sample_payment_fixture(session: Session, sample_transaction, admin_user):
|
|
||||||
"""Create a sample payment for testing."""
|
|
||||||
payment = Payment(
|
|
||||||
transaction_id=sample_transaction.id,
|
|
||||||
payment_method="cash",
|
|
||||||
paid_amount=500,
|
|
||||||
payment_date=date.today(),
|
|
||||||
created_by=admin_user.id,
|
|
||||||
updated_by=admin_user.id
|
|
||||||
)
|
|
||||||
session.add(payment)
|
|
||||||
session.commit()
|
|
||||||
session.refresh(payment)
|
|
||||||
return payment
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(name="multiple_payments")
|
|
||||||
def multiple_payments_fixture(session: Session, sample_transaction, admin_user):
|
|
||||||
"""Create multiple payments for testing."""
|
|
||||||
payments = [
|
|
||||||
Payment(
|
|
||||||
transaction_id=sample_transaction.id,
|
|
||||||
payment_method="cash",
|
|
||||||
paid_amount=300,
|
|
||||||
payment_date=date.today(),
|
|
||||||
created_by=admin_user.id,
|
|
||||||
updated_by=admin_user.id
|
|
||||||
),
|
|
||||||
Payment(
|
|
||||||
transaction_id=sample_transaction.id,
|
|
||||||
payment_method="momo",
|
|
||||||
paid_amount=200,
|
|
||||||
payment_date=date.today(),
|
|
||||||
created_by=admin_user.id,
|
|
||||||
updated_by=admin_user.id
|
|
||||||
),
|
|
||||||
Payment(
|
|
||||||
transaction_id=sample_transaction.id,
|
|
||||||
payment_method="bank",
|
|
||||||
paid_amount=150,
|
|
||||||
payment_date=date.today(),
|
|
||||||
created_by=admin_user.id,
|
|
||||||
updated_by=admin_user.id
|
|
||||||
)
|
|
||||||
]
|
|
||||||
for payment in payments:
|
|
||||||
session.add(payment)
|
|
||||||
session.commit()
|
|
||||||
for payment in payments:
|
|
||||||
session.refresh(payment)
|
|
||||||
return payments
|
|
||||||
|
|
||||||
|
|
||||||
class TestPaymentCreation:
|
|
||||||
"""Test payment creation endpoints."""
|
|
||||||
|
|
||||||
def test_create_payment_with_admin_access(self, client: TestClient, admin_token: str, sample_transaction):
|
|
||||||
"""Test payment creation with admin token."""
|
|
||||||
payment_data = {
|
|
||||||
"transaction_id": sample_transaction.id,
|
|
||||||
"payment_method": PaymentMethod.CASH,
|
|
||||||
"paid_amount": 500,
|
|
||||||
"payment_date": "2024-01-15"
|
|
||||||
}
|
|
||||||
response = client.post("/api/v1/payments/",
|
|
||||||
json=payment_data,
|
|
||||||
headers={"Authorization": f"Bearer {admin_token}"})
|
|
||||||
assert response.status_code == 201
|
|
||||||
data = response.json()
|
|
||||||
assert data["transaction_id"] == sample_transaction.id
|
|
||||||
assert data["payment_method"] == PaymentMethod.CASH
|
|
||||||
assert data["paid_amount"] == 500
|
|
||||||
assert data["payment_date"] == "2024-01-15"
|
|
||||||
assert "id" in data
|
|
||||||
assert "created_by" in data
|
|
||||||
assert "updated_by" in data
|
|
||||||
|
|
||||||
def test_create_payment_with_write_access(self, client: TestClient, write_token: str, sample_transaction):
|
|
||||||
"""Test payment creation with write token."""
|
|
||||||
payment_data = {
|
|
||||||
"transaction_id": sample_transaction.id,
|
|
||||||
"payment_method": PaymentMethod.MOMO,
|
|
||||||
"paid_amount": 750,
|
|
||||||
"payment_date": "2024-01-16"
|
|
||||||
}
|
|
||||||
response = client.post("/api/v1/payments/",
|
|
||||||
json=payment_data,
|
|
||||||
headers={"Authorization": f"Bearer {write_token}"})
|
|
||||||
assert response.status_code == 201
|
|
||||||
data = response.json()
|
|
||||||
assert data["payment_method"] == PaymentMethod.MOMO
|
|
||||||
assert data["paid_amount"] == 750
|
|
||||||
|
|
||||||
def test_create_payment_with_bank_method(self, client: TestClient, admin_token: str, sample_transaction):
|
|
||||||
"""Test payment creation with bank payment method."""
|
|
||||||
payment_data = {
|
|
||||||
"transaction_id": sample_transaction.id,
|
|
||||||
"payment_method": PaymentMethod.BANK,
|
|
||||||
"paid_amount": 1000,
|
|
||||||
"payment_date": "2024-01-17"
|
|
||||||
}
|
|
||||||
response = client.post("/api/v1/payments/",
|
|
||||||
json=payment_data,
|
|
||||||
headers={"Authorization": f"Bearer {admin_token}"})
|
|
||||||
assert response.status_code == 201
|
|
||||||
data = response.json()
|
|
||||||
assert data["payment_method"] == PaymentMethod.BANK
|
|
||||||
|
|
||||||
def test_create_payment_unauthorized(self, client: TestClient, sample_transaction):
|
|
||||||
"""Test payment creation without authentication."""
|
|
||||||
payment_data = {
|
|
||||||
"transaction_id": sample_transaction.id,
|
|
||||||
"payment_method": PaymentMethod.CASH,
|
|
||||||
"paid_amount": 500,
|
|
||||||
"payment_date": "2024-01-15"
|
|
||||||
}
|
|
||||||
response = client.post("/api/v1/payments/", json=payment_data)
|
|
||||||
assert response.status_code == 403
|
|
||||||
|
|
||||||
def test_create_payment_read_only_forbidden(self, client: TestClient, read_only_token: str, sample_transaction):
|
|
||||||
"""Test payment creation with read-only access should fail."""
|
|
||||||
payment_data = {
|
|
||||||
"transaction_id": sample_transaction.id,
|
|
||||||
"payment_method": PaymentMethod.CASH,
|
|
||||||
"paid_amount": 500,
|
|
||||||
"payment_date": "2024-01-15"
|
|
||||||
}
|
|
||||||
response = client.post("/api/v1/payments/",
|
|
||||||
json=payment_data,
|
|
||||||
headers={"Authorization": f"Bearer {read_only_token}"})
|
|
||||||
assert response.status_code == 403
|
|
||||||
|
|
||||||
def test_create_payment_invalid_transaction(self, client: TestClient, admin_token: str):
|
|
||||||
"""Test creation with non-existent transaction should fail."""
|
|
||||||
payment_data = {
|
|
||||||
"transaction_id": 99999, # Non-existent transaction
|
|
||||||
"payment_method": PaymentMethod.CASH,
|
|
||||||
"paid_amount": 500,
|
|
||||||
"payment_date": "2024-01-15"
|
|
||||||
}
|
|
||||||
response = client.post("/api/v1/payments/",
|
|
||||||
json=payment_data,
|
|
||||||
headers={"Authorization": f"Bearer {admin_token}"})
|
|
||||||
assert response.status_code == 400
|
|
||||||
assert "Transaction not found" in response.json()["detail"]
|
|
||||||
|
|
||||||
|
|
||||||
class TestPaymentRetrieval:
|
|
||||||
"""Test payment retrieval endpoints."""
|
|
||||||
|
|
||||||
def test_get_all_payments_with_auth(self, client: TestClient, admin_token: str, multiple_payments):
|
|
||||||
"""Test retrieving all payments with authentication."""
|
|
||||||
response = client.get("/api/v1/payments/",
|
|
||||||
headers={"Authorization": f"Bearer {admin_token}"})
|
|
||||||
assert response.status_code == 200
|
|
||||||
data = response.json()
|
|
||||||
assert isinstance(data, list)
|
|
||||||
assert len(data) >= 3 # At least the fixture payments
|
|
||||||
|
|
||||||
def test_get_all_payments_read_only_access(self, client: TestClient, read_only_token: str, multiple_payments):
|
|
||||||
"""Test read-only user can retrieve payments."""
|
|
||||||
response = client.get("/api/v1/payments/",
|
|
||||||
headers={"Authorization": f"Bearer {read_only_token}"})
|
|
||||||
assert response.status_code == 200
|
|
||||||
data = response.json()
|
|
||||||
assert isinstance(data, list)
|
|
||||||
|
|
||||||
def test_get_payments_unauthorized(self, client: TestClient):
|
|
||||||
"""Test retrieving payments without authentication."""
|
|
||||||
response = client.get("/api/v1/payments/")
|
|
||||||
assert response.status_code == 403
|
|
||||||
|
|
||||||
def test_get_payments_with_pagination(self, client: TestClient, admin_token: str, multiple_payments):
|
|
||||||
"""Test payment retrieval with pagination."""
|
|
||||||
response = client.get("/api/v1/payments/?skip=0&limit=2",
|
|
||||||
headers={"Authorization": f"Bearer {admin_token}"})
|
|
||||||
assert response.status_code == 200
|
|
||||||
data = response.json()
|
|
||||||
assert len(data) <= 2
|
|
||||||
|
|
||||||
def test_get_single_payment_by_id(self, client: TestClient, admin_token: str, sample_payment):
|
|
||||||
"""Test retrieving a single payment by ID."""
|
|
||||||
response = client.get(f"/api/v1/payments/{sample_payment.id}",
|
|
||||||
headers={"Authorization": f"Bearer {admin_token}"})
|
|
||||||
assert response.status_code == 200
|
|
||||||
data = response.json()
|
|
||||||
assert data["id"] == sample_payment.id
|
|
||||||
assert data["transaction_id"] == sample_payment.transaction_id
|
|
||||||
assert data["payment_method"] == sample_payment.payment_method
|
|
||||||
assert data["paid_amount"] == sample_payment.paid_amount
|
|
||||||
|
|
||||||
def test_get_nonexistent_payment(self, client: TestClient, admin_token: str):
|
|
||||||
"""Test retrieving a non-existent payment."""
|
|
||||||
response = client.get("/api/v1/payments/99999",
|
|
||||||
headers={"Authorization": f"Bearer {admin_token}"})
|
|
||||||
assert response.status_code == 404
|
|
||||||
assert "Payment not found" in response.json()["detail"]
|
|
||||||
|
|
||||||
def test_get_payments_by_transaction(self, client: TestClient, admin_token: str, multiple_payments, sample_transaction):
|
|
||||||
"""Test retrieving payments for specific transaction."""
|
|
||||||
response = client.get(f"/api/v1/payments/transaction/{sample_transaction.id}",
|
|
||||||
headers={"Authorization": f"Bearer {admin_token}"})
|
|
||||||
assert response.status_code == 200
|
|
||||||
data = response.json()
|
|
||||||
assert isinstance(data, list)
|
|
||||||
assert len(data) >= 3 # All payments for this transaction
|
|
||||||
for payment in data:
|
|
||||||
assert payment["transaction_id"] == sample_transaction.id
|
|
||||||
|
|
||||||
def test_get_payments_by_nonexistent_transaction(self, client: TestClient, admin_token: str):
|
|
||||||
"""Test retrieving payments for non-existent transaction."""
|
|
||||||
response = client.get("/api/v1/payments/transaction/99999",
|
|
||||||
headers={"Authorization": f"Bearer {admin_token}"})
|
|
||||||
assert response.status_code == 404
|
|
||||||
assert "Transaction not found" in response.json()["detail"]
|
|
||||||
|
|
||||||
|
|
||||||
class TestPaymentUpdate:
|
|
||||||
"""Test payment update endpoints."""
|
|
||||||
|
|
||||||
def test_update_payment_with_write_access(self, client: TestClient, write_token: str, sample_payment):
|
|
||||||
"""Test updating payment with write access."""
|
|
||||||
update_data = {
|
|
||||||
"payment_method": PaymentMethod.BANK,
|
|
||||||
"paid_amount": 600
|
|
||||||
}
|
|
||||||
response = client.put(f"/api/v1/payments/{sample_payment.id}",
|
|
||||||
json=update_data,
|
|
||||||
headers={"Authorization": f"Bearer {write_token}"})
|
|
||||||
assert response.status_code == 200
|
|
||||||
data = response.json()
|
|
||||||
assert data["payment_method"] == PaymentMethod.BANK
|
|
||||||
assert data["paid_amount"] == 600
|
|
||||||
assert data["transaction_id"] == sample_payment.transaction_id # Unchanged
|
|
||||||
|
|
||||||
def test_update_payment_date(self, client: TestClient, admin_token: str, sample_payment):
|
|
||||||
"""Test updating payment date."""
|
|
||||||
update_data = {
|
|
||||||
"payment_date": "2024-02-01"
|
|
||||||
}
|
|
||||||
response = client.put(f"/api/v1/payments/{sample_payment.id}",
|
|
||||||
json=update_data,
|
|
||||||
headers={"Authorization": f"Bearer {admin_token}"})
|
|
||||||
assert response.status_code == 200
|
|
||||||
data = response.json()
|
|
||||||
assert data["payment_date"] == "2024-02-01"
|
|
||||||
|
|
||||||
def test_update_payment_transaction_id(self, client: TestClient, admin_token: str, sample_payment, sample_partner, admin_user, session):
|
|
||||||
"""Test updating payment transaction ID."""
|
|
||||||
# Create another transaction
|
|
||||||
new_transaction = Transaction(
|
|
||||||
partner_id=sample_partner.id,
|
|
||||||
transcation_type=TransactionType.SALE,
|
|
||||||
transaction_status=TransactionStatus.UNPAID,
|
|
||||||
total_amount=2000,
|
|
||||||
created_by=admin_user.id,
|
|
||||||
updated_by=admin_user.id
|
|
||||||
)
|
|
||||||
session.add(new_transaction)
|
|
||||||
session.commit()
|
|
||||||
session.refresh(new_transaction)
|
|
||||||
|
|
||||||
update_data = {
|
|
||||||
"transaction_id": new_transaction.id
|
|
||||||
}
|
|
||||||
response = client.put(f"/api/v1/payments/{sample_payment.id}",
|
|
||||||
json=update_data,
|
|
||||||
headers={"Authorization": f"Bearer {admin_token}"})
|
|
||||||
assert response.status_code == 200
|
|
||||||
data = response.json()
|
|
||||||
assert data["transaction_id"] == new_transaction.id
|
|
||||||
|
|
||||||
def test_update_payment_read_only_forbidden(self, client: TestClient, read_only_token: str, sample_payment):
|
|
||||||
"""Test updating payment with read-only access should fail."""
|
|
||||||
update_data = {
|
|
||||||
"paid_amount": 700
|
|
||||||
}
|
|
||||||
response = client.put(f"/api/v1/payments/{sample_payment.id}",
|
|
||||||
json=update_data,
|
|
||||||
headers={"Authorization": f"Bearer {read_only_token}"})
|
|
||||||
assert response.status_code == 403
|
|
||||||
|
|
||||||
def test_update_payment_invalid_transaction(self, client: TestClient, admin_token: str, sample_payment):
|
|
||||||
"""Test updating payment with invalid transaction should fail."""
|
|
||||||
update_data = {
|
|
||||||
"transaction_id": 99999 # Non-existent transaction
|
|
||||||
}
|
|
||||||
response = client.put(f"/api/v1/payments/{sample_payment.id}",
|
|
||||||
json=update_data,
|
|
||||||
headers={"Authorization": f"Bearer {admin_token}"})
|
|
||||||
assert response.status_code == 400
|
|
||||||
assert "Transaction not found" in response.json()["detail"]
|
|
||||||
|
|
||||||
def test_update_nonexistent_payment(self, client: TestClient, admin_token: str):
|
|
||||||
"""Test updating a non-existent payment."""
|
|
||||||
update_data = {
|
|
||||||
"paid_amount": 800
|
|
||||||
}
|
|
||||||
response = client.put("/api/v1/payments/99999",
|
|
||||||
json=update_data,
|
|
||||||
headers={"Authorization": f"Bearer {admin_token}"})
|
|
||||||
assert response.status_code == 404
|
|
||||||
assert "Payment not found" in response.json()["detail"]
|
|
||||||
|
|
||||||
|
|
||||||
class TestPaymentDeletion:
|
|
||||||
"""Test payment deletion endpoints."""
|
|
||||||
|
|
||||||
def test_delete_payment_with_admin_access(self, client: TestClient, admin_token: str, sample_payment):
|
|
||||||
"""Test deleting payment with admin access."""
|
|
||||||
response = client.delete(f"/api/v1/payments/{sample_payment.id}",
|
|
||||||
headers={"Authorization": f"Bearer {admin_token}"})
|
|
||||||
assert response.status_code == 204
|
|
||||||
|
|
||||||
# Verify payment is deleted
|
|
||||||
get_response = client.get(f"/api/v1/payments/{sample_payment.id}",
|
|
||||||
headers={"Authorization": f"Bearer {admin_token}"})
|
|
||||||
assert get_response.status_code == 404
|
|
||||||
|
|
||||||
def test_delete_payment_write_access_forbidden(self, client: TestClient, write_token: str, sample_payment):
|
|
||||||
"""Test deleting payment with write access should fail."""
|
|
||||||
response = client.delete(f"/api/v1/payments/{sample_payment.id}",
|
|
||||||
headers={"Authorization": f"Bearer {write_token}"})
|
|
||||||
assert response.status_code == 403
|
|
||||||
|
|
||||||
def test_delete_payment_read_only_forbidden(self, client: TestClient, read_only_token: str, sample_payment):
|
|
||||||
"""Test deleting payment with read-only access should fail."""
|
|
||||||
response = client.delete(f"/api/v1/payments/{sample_payment.id}",
|
|
||||||
headers={"Authorization": f"Bearer {read_only_token}"})
|
|
||||||
assert response.status_code == 403
|
|
||||||
|
|
||||||
def test_delete_nonexistent_payment(self, client: TestClient, admin_token: str):
|
|
||||||
"""Test deleting a non-existent payment."""
|
|
||||||
response = client.delete("/api/v1/payments/99999",
|
|
||||||
headers={"Authorization": f"Bearer {admin_token}"})
|
|
||||||
assert response.status_code == 404
|
|
||||||
assert "Payment not found" in response.json()["detail"]
|
|
||||||
|
|
||||||
def test_delete_payment_unauthorized(self, client: TestClient, sample_payment):
|
|
||||||
"""Test deleting payment without authentication."""
|
|
||||||
response = client.delete(f"/api/v1/payments/{sample_payment.id}")
|
|
||||||
assert response.status_code == 403
|
|
||||||
|
|
||||||
|
|
||||||
class TestPaymentValidation:
|
|
||||||
"""Test payment data validation."""
|
|
||||||
|
|
||||||
def test_create_payment_missing_required_fields(self, client: TestClient, admin_token: str):
|
|
||||||
"""Test creating payment with missing required fields."""
|
|
||||||
# Missing transaction_id
|
|
||||||
payment_data = {
|
|
||||||
"payment_method": PaymentMethod.CASH,
|
|
||||||
"paid_amount": 500,
|
|
||||||
"payment_date": "2024-01-15"
|
|
||||||
}
|
|
||||||
response = client.post("/api/v1/payments/",
|
|
||||||
json=payment_data,
|
|
||||||
headers={"Authorization": f"Bearer {admin_token}"})
|
|
||||||
assert response.status_code == 422 # Validation error
|
|
||||||
|
|
||||||
def test_create_payment_invalid_payment_method(self, client: TestClient, admin_token: str, sample_transaction):
|
|
||||||
"""Test creating payment with invalid payment method."""
|
|
||||||
payment_data = {
|
|
||||||
"transaction_id": sample_transaction.id,
|
|
||||||
"payment_method": "INVALID_METHOD",
|
|
||||||
"paid_amount": 500,
|
|
||||||
"payment_date": "2024-01-15"
|
|
||||||
}
|
|
||||||
response = client.post("/api/v1/payments/",
|
|
||||||
json=payment_data,
|
|
||||||
headers={"Authorization": f"Bearer {admin_token}"})
|
|
||||||
assert response.status_code == 422 # Validation error
|
|
||||||
|
|
||||||
def test_create_payment_negative_amount(self, client: TestClient, admin_token: str, sample_transaction):
|
|
||||||
"""Test creating payment with negative amount."""
|
|
||||||
payment_data = {
|
|
||||||
"transaction_id": sample_transaction.id,
|
|
||||||
"payment_method": PaymentMethod.CASH,
|
|
||||||
"paid_amount": -100, # Negative amount
|
|
||||||
"payment_date": "2024-01-15"
|
|
||||||
}
|
|
||||||
response = client.post("/api/v1/payments/",
|
|
||||||
json=payment_data,
|
|
||||||
headers={"Authorization": f"Bearer {admin_token}"})
|
|
||||||
# This might pass depending on validation rules, but business logic should prevent it
|
|
||||||
# You might want to add validation in the endpoint for this
|
|
||||||
|
|
||||||
def test_create_payment_invalid_date_format(self, client: TestClient, admin_token: str, sample_transaction):
|
|
||||||
"""Test creating payment with invalid date format."""
|
|
||||||
payment_data = {
|
|
||||||
"transaction_id": sample_transaction.id,
|
|
||||||
"payment_method": PaymentMethod.CASH,
|
|
||||||
"paid_amount": 500,
|
|
||||||
"payment_date": "invalid-date"
|
|
||||||
}
|
|
||||||
response = client.post("/api/v1/payments/",
|
|
||||||
json=payment_data,
|
|
||||||
headers={"Authorization": f"Bearer {admin_token}"})
|
|
||||||
assert response.status_code == 422 # Validation error
|
|
||||||
@@ -1,352 +0,0 @@
|
|||||||
import pytest
|
|
||||||
from fastapi.testclient import TestClient
|
|
||||||
|
|
||||||
|
|
||||||
class TestProductCreation:
|
|
||||||
"""Test product creation endpoints."""
|
|
||||||
|
|
||||||
def test_create_product_with_admin_access(self, client: TestClient, admin_token: str):
|
|
||||||
"""Test product creation with admin token."""
|
|
||||||
product_data = {
|
|
||||||
"product_code": "TEST001",
|
|
||||||
"product_name": "Test Product One",
|
|
||||||
"purchase_price": 100,
|
|
||||||
"selling_price": 150
|
|
||||||
}
|
|
||||||
response = client.post("/api/v1/products/",
|
|
||||||
json=product_data,
|
|
||||||
headers={"Authorization": f"Bearer {admin_token}"})
|
|
||||||
assert response.status_code == 201
|
|
||||||
data = response.json()
|
|
||||||
assert data["product_code"] == "TEST001"
|
|
||||||
assert data["product_name"] == "Test Product One"
|
|
||||||
assert data["purchase_price"] == 100
|
|
||||||
assert data["selling_price"] == 150
|
|
||||||
assert "id" in data
|
|
||||||
assert "date_modified" in data
|
|
||||||
|
|
||||||
def test_create_product_with_write_access(self, client: TestClient, write_token: str):
|
|
||||||
"""Test product creation with write token."""
|
|
||||||
product_data = {
|
|
||||||
"product_code": "WRITE001",
|
|
||||||
"product_name": "Write Access Product",
|
|
||||||
"purchase_price": 200,
|
|
||||||
"selling_price": 250
|
|
||||||
}
|
|
||||||
response = client.post("/api/v1/products/",
|
|
||||||
json=product_data,
|
|
||||||
headers={"Authorization": f"Bearer {write_token}"})
|
|
||||||
assert response.status_code == 201
|
|
||||||
data = response.json()
|
|
||||||
assert data["product_code"] == "WRITE001"
|
|
||||||
assert data["purchase_price"] == 200
|
|
||||||
|
|
||||||
def test_create_product_unauthorized(self, client: TestClient):
|
|
||||||
"""Test product creation without authentication."""
|
|
||||||
product_data = {
|
|
||||||
"product_code": "UNAUTH001",
|
|
||||||
"product_name": "Unauthorized Product",
|
|
||||||
"purchase_price": 50,
|
|
||||||
"selling_price": 75
|
|
||||||
}
|
|
||||||
response = client.post("/api/v1/products/", json=product_data)
|
|
||||||
assert response.status_code == 403
|
|
||||||
|
|
||||||
def test_create_product_read_only_forbidden(self, client: TestClient, read_only_token: str):
|
|
||||||
"""Test product creation with read-only access should fail."""
|
|
||||||
product_data = {
|
|
||||||
"product_code": "READ001",
|
|
||||||
"product_name": "Read Only Attempt",
|
|
||||||
"purchase_price": 100,
|
|
||||||
"selling_price": 120
|
|
||||||
}
|
|
||||||
response = client.post("/api/v1/products/",
|
|
||||||
json=product_data,
|
|
||||||
headers={"Authorization": f"Bearer {read_only_token}"})
|
|
||||||
assert response.status_code == 403
|
|
||||||
|
|
||||||
def test_create_product_duplicate_code(self, client: TestClient, admin_token: str, sample_product):
|
|
||||||
"""Test creation with duplicate product code should fail."""
|
|
||||||
product_data = {
|
|
||||||
"product_code": sample_product.product_code, # Duplicate code
|
|
||||||
"product_name": "Duplicate Code Product",
|
|
||||||
"purchase_price": 300,
|
|
||||||
"selling_price": 400
|
|
||||||
}
|
|
||||||
response = client.post("/api/v1/products/",
|
|
||||||
json=product_data,
|
|
||||||
headers={"Authorization": f"Bearer {admin_token}"})
|
|
||||||
assert response.status_code == 400
|
|
||||||
assert "Product with this code already exists" in response.json()["detail"]
|
|
||||||
|
|
||||||
def test_create_product_duplicate_name(self, client: TestClient, admin_token: str, sample_product):
|
|
||||||
"""Test creation with duplicate product name should fail."""
|
|
||||||
product_data = {
|
|
||||||
"product_code": "UNIQUE001",
|
|
||||||
"product_name": sample_product.product_name, # Duplicate name
|
|
||||||
"purchase_price": 300,
|
|
||||||
"selling_price": 400
|
|
||||||
}
|
|
||||||
response = client.post("/api/v1/products/",
|
|
||||||
json=product_data,
|
|
||||||
headers={"Authorization": f"Bearer {admin_token}"})
|
|
||||||
assert response.status_code == 400
|
|
||||||
assert "Product with this name already exists" in response.json()["detail"]
|
|
||||||
|
|
||||||
|
|
||||||
class TestProductRetrieval:
|
|
||||||
"""Test product retrieval endpoints."""
|
|
||||||
|
|
||||||
def test_get_all_products_with_auth(self, client: TestClient, admin_token: str, multiple_products):
|
|
||||||
"""Test retrieving all products with authentication."""
|
|
||||||
response = client.get("/api/v1/products/",
|
|
||||||
headers={"Authorization": f"Bearer {admin_token}"})
|
|
||||||
assert response.status_code == 200
|
|
||||||
data = response.json()
|
|
||||||
assert isinstance(data, list)
|
|
||||||
assert len(data) >= 3 # At least the fixture products
|
|
||||||
|
|
||||||
def test_get_all_products_read_only_access(self, client: TestClient, read_only_token: str, multiple_products):
|
|
||||||
"""Test read-only user can retrieve products."""
|
|
||||||
response = client.get("/api/v1/products/",
|
|
||||||
headers={"Authorization": f"Bearer {read_only_token}"})
|
|
||||||
assert response.status_code == 200
|
|
||||||
data = response.json()
|
|
||||||
assert isinstance(data, list)
|
|
||||||
|
|
||||||
def test_get_products_unauthorized(self, client: TestClient):
|
|
||||||
"""Test retrieving products without authentication."""
|
|
||||||
response = client.get("/api/v1/products/")
|
|
||||||
assert response.status_code == 403
|
|
||||||
|
|
||||||
def test_get_products_with_pagination(self, client: TestClient, admin_token: str, multiple_products):
|
|
||||||
"""Test product retrieval with pagination."""
|
|
||||||
response = client.get("/api/v1/products/?skip=0&limit=2",
|
|
||||||
headers={"Authorization": f"Bearer {admin_token}"})
|
|
||||||
assert response.status_code == 200
|
|
||||||
data = response.json()
|
|
||||||
assert len(data) <= 2
|
|
||||||
|
|
||||||
def test_get_single_product_by_id(self, client: TestClient, admin_token: str, sample_product):
|
|
||||||
"""Test retrieving a single product by ID."""
|
|
||||||
response = client.get(f"/api/v1/products/{sample_product.id}",
|
|
||||||
headers={"Authorization": f"Bearer {admin_token}"})
|
|
||||||
assert response.status_code == 200
|
|
||||||
data = response.json()
|
|
||||||
assert data["id"] == sample_product.id
|
|
||||||
assert data["product_code"] == sample_product.product_code
|
|
||||||
assert data["product_name"] == sample_product.product_name
|
|
||||||
assert data["purchase_price"] == sample_product.purchase_price
|
|
||||||
|
|
||||||
def test_get_product_by_code(self, client: TestClient, admin_token: str, sample_product):
|
|
||||||
"""Test retrieving a product by code."""
|
|
||||||
response = client.get(f"/api/v1/products/code/{sample_product.product_code}",
|
|
||||||
headers={"Authorization": f"Bearer {admin_token}"})
|
|
||||||
assert response.status_code == 200
|
|
||||||
data = response.json()
|
|
||||||
assert data["product_code"] == sample_product.product_code
|
|
||||||
assert data["id"] == sample_product.id
|
|
||||||
|
|
||||||
def test_get_nonexistent_product_by_id(self, client: TestClient, admin_token: str):
|
|
||||||
"""Test retrieving a non-existent product by ID."""
|
|
||||||
response = client.get("/api/v1/products/99999",
|
|
||||||
headers={"Authorization": f"Bearer {admin_token}"})
|
|
||||||
assert response.status_code == 404
|
|
||||||
assert "Product not found" in response.json()["detail"]
|
|
||||||
|
|
||||||
def test_get_nonexistent_product_by_code(self, client: TestClient, admin_token: str):
|
|
||||||
"""Test retrieving a non-existent product by code."""
|
|
||||||
response = client.get("/api/v1/products/code/NONEXISTENT",
|
|
||||||
headers={"Authorization": f"Bearer {admin_token}"})
|
|
||||||
assert response.status_code == 404
|
|
||||||
assert "Product not found" in response.json()["detail"]
|
|
||||||
|
|
||||||
|
|
||||||
class TestProductUpdate:
|
|
||||||
"""Test product update endpoints."""
|
|
||||||
|
|
||||||
def test_update_product_with_write_access(self, client: TestClient, write_token: str, sample_product):
|
|
||||||
"""Test updating product with write access."""
|
|
||||||
update_data = {
|
|
||||||
"product_name": "Updated Product Name",
|
|
||||||
"selling_price": 200
|
|
||||||
}
|
|
||||||
response = client.put(f"/api/v1/products/{sample_product.id}",
|
|
||||||
json=update_data,
|
|
||||||
headers={"Authorization": f"Bearer {write_token}"})
|
|
||||||
assert response.status_code == 200
|
|
||||||
data = response.json()
|
|
||||||
assert data["product_name"] == "Updated Product Name"
|
|
||||||
assert data["selling_price"] == 200
|
|
||||||
assert data["product_code"] == sample_product.product_code # Unchanged
|
|
||||||
assert data["purchase_price"] == sample_product.purchase_price # Unchanged
|
|
||||||
|
|
||||||
def test_update_product_code(self, client: TestClient, admin_token: str, sample_product):
|
|
||||||
"""Test updating product code."""
|
|
||||||
update_data = {
|
|
||||||
"product_code": "UPDATED001"
|
|
||||||
}
|
|
||||||
response = client.put(f"/api/v1/products/{sample_product.id}",
|
|
||||||
json=update_data,
|
|
||||||
headers={"Authorization": f"Bearer {admin_token}"})
|
|
||||||
assert response.status_code == 200
|
|
||||||
data = response.json()
|
|
||||||
assert data["product_code"] == "UPDATED001"
|
|
||||||
|
|
||||||
def test_update_product_prices(self, client: TestClient, admin_token: str, sample_product):
|
|
||||||
"""Test updating product prices."""
|
|
||||||
update_data = {
|
|
||||||
"purchase_price": 80,
|
|
||||||
"selling_price": 120
|
|
||||||
}
|
|
||||||
response = client.put(f"/api/v1/products/{sample_product.id}",
|
|
||||||
json=update_data,
|
|
||||||
headers={"Authorization": f"Bearer {admin_token}"})
|
|
||||||
assert response.status_code == 200
|
|
||||||
data = response.json()
|
|
||||||
assert data["purchase_price"] == 80
|
|
||||||
assert data["selling_price"] == 120
|
|
||||||
|
|
||||||
def test_update_product_read_only_forbidden(self, client: TestClient, read_only_token: str, sample_product):
|
|
||||||
"""Test updating product with read-only access should fail."""
|
|
||||||
update_data = {
|
|
||||||
"product_name": "Should Not Update"
|
|
||||||
}
|
|
||||||
response = client.put(f"/api/v1/products/{sample_product.id}",
|
|
||||||
json=update_data,
|
|
||||||
headers={"Authorization": f"Bearer {read_only_token}"})
|
|
||||||
assert response.status_code == 403
|
|
||||||
|
|
||||||
def test_update_product_duplicate_code(self, client: TestClient, admin_token: str, multiple_products):
|
|
||||||
"""Test updating product with duplicate code should fail."""
|
|
||||||
product_to_update = multiple_products[0]
|
|
||||||
existing_code = multiple_products[1].product_code
|
|
||||||
|
|
||||||
update_data = {
|
|
||||||
"product_code": existing_code
|
|
||||||
}
|
|
||||||
response = client.put(f"/api/v1/products/{product_to_update.id}",
|
|
||||||
json=update_data,
|
|
||||||
headers={"Authorization": f"Bearer {admin_token}"})
|
|
||||||
assert response.status_code == 400
|
|
||||||
assert "Product with this code already exists" in response.json()["detail"]
|
|
||||||
|
|
||||||
def test_update_product_duplicate_name(self, client: TestClient, admin_token: str, multiple_products):
|
|
||||||
"""Test updating product with duplicate name should fail."""
|
|
||||||
product_to_update = multiple_products[0]
|
|
||||||
existing_name = multiple_products[1].product_name
|
|
||||||
|
|
||||||
update_data = {
|
|
||||||
"product_name": existing_name
|
|
||||||
}
|
|
||||||
response = client.put(f"/api/v1/products/{product_to_update.id}",
|
|
||||||
json=update_data,
|
|
||||||
headers={"Authorization": f"Bearer {admin_token}"})
|
|
||||||
assert response.status_code == 400
|
|
||||||
assert "Product with this name already exists" in response.json()["detail"]
|
|
||||||
|
|
||||||
def test_update_nonexistent_product(self, client: TestClient, admin_token: str):
|
|
||||||
"""Test updating a non-existent product."""
|
|
||||||
update_data = {
|
|
||||||
"product_name": "Non-existent Product"
|
|
||||||
}
|
|
||||||
response = client.put("/api/v1/products/99999",
|
|
||||||
json=update_data,
|
|
||||||
headers={"Authorization": f"Bearer {admin_token}"})
|
|
||||||
assert response.status_code == 404
|
|
||||||
assert "Product not found" in response.json()["detail"]
|
|
||||||
|
|
||||||
|
|
||||||
class TestProductDeletion:
|
|
||||||
"""Test product deletion endpoints."""
|
|
||||||
|
|
||||||
def test_delete_product_with_admin_access(self, client: TestClient, admin_token: str, sample_product):
|
|
||||||
"""Test deleting product with admin access."""
|
|
||||||
response = client.delete(f"/api/v1/products/{sample_product.id}",
|
|
||||||
headers={"Authorization": f"Bearer {admin_token}"})
|
|
||||||
assert response.status_code == 204
|
|
||||||
|
|
||||||
# Verify product is deleted
|
|
||||||
get_response = client.get(f"/api/v1/products/{sample_product.id}",
|
|
||||||
headers={"Authorization": f"Bearer {admin_token}"})
|
|
||||||
assert get_response.status_code == 404
|
|
||||||
|
|
||||||
def test_delete_product_write_access_forbidden(self, client: TestClient, write_token: str, sample_product):
|
|
||||||
"""Test deleting product with write access should fail."""
|
|
||||||
response = client.delete(f"/api/v1/products/{sample_product.id}",
|
|
||||||
headers={"Authorization": f"Bearer {write_token}"})
|
|
||||||
assert response.status_code == 403
|
|
||||||
|
|
||||||
def test_delete_product_read_only_forbidden(self, client: TestClient, read_only_token: str, sample_product):
|
|
||||||
"""Test deleting product with read-only access should fail."""
|
|
||||||
response = client.delete(f"/api/v1/products/{sample_product.id}",
|
|
||||||
headers={"Authorization": f"Bearer {read_only_token}"})
|
|
||||||
assert response.status_code == 403
|
|
||||||
|
|
||||||
def test_delete_nonexistent_product(self, client: TestClient, admin_token: str):
|
|
||||||
"""Test deleting a non-existent product."""
|
|
||||||
response = client.delete("/api/v1/products/99999",
|
|
||||||
headers={"Authorization": f"Bearer {admin_token}"})
|
|
||||||
assert response.status_code == 404
|
|
||||||
assert "Product not found" in response.json()["detail"]
|
|
||||||
|
|
||||||
def test_delete_product_unauthorized(self, client: TestClient, sample_product):
|
|
||||||
"""Test deleting product without authentication."""
|
|
||||||
response = client.delete(f"/api/v1/products/{sample_product.id}")
|
|
||||||
assert response.status_code == 403
|
|
||||||
|
|
||||||
|
|
||||||
class TestProductValidation:
|
|
||||||
"""Test product data validation."""
|
|
||||||
|
|
||||||
def test_create_product_missing_required_fields(self, client: TestClient, admin_token: str):
|
|
||||||
"""Test creating product with missing required fields."""
|
|
||||||
# Missing product_code
|
|
||||||
product_data = {
|
|
||||||
"product_name": "Missing Code Product",
|
|
||||||
"purchase_price": 100,
|
|
||||||
"selling_price": 150
|
|
||||||
}
|
|
||||||
response = client.post("/api/v1/products/",
|
|
||||||
json=product_data,
|
|
||||||
headers={"Authorization": f"Bearer {admin_token}"})
|
|
||||||
assert response.status_code == 422 # Validation error
|
|
||||||
|
|
||||||
def test_create_product_negative_prices(self, client: TestClient, admin_token: str):
|
|
||||||
"""Test creating product with negative prices."""
|
|
||||||
product_data = {
|
|
||||||
"product_code": "NEG001",
|
|
||||||
"product_name": "Negative Price Product",
|
|
||||||
"purchase_price": -50,
|
|
||||||
"selling_price": -75
|
|
||||||
}
|
|
||||||
response = client.post("/api/v1/products/",
|
|
||||||
json=product_data,
|
|
||||||
headers={"Authorization": f"Bearer {admin_token}"})
|
|
||||||
# Note: This test assumes validation constraints exist for negative prices
|
|
||||||
# If not implemented, this test will fail and indicate missing validation
|
|
||||||
assert response.status_code in [422, 201] # Either validation error or creation
|
|
||||||
|
|
||||||
def test_create_product_zero_prices(self, client: TestClient, admin_token: str):
|
|
||||||
"""Test creating product with zero prices."""
|
|
||||||
product_data = {
|
|
||||||
"product_code": "ZERO001",
|
|
||||||
"product_name": "Zero Price Product",
|
|
||||||
"purchase_price": 0,
|
|
||||||
"selling_price": 0
|
|
||||||
}
|
|
||||||
response = client.post("/api/v1/products/",
|
|
||||||
json=product_data,
|
|
||||||
headers={"Authorization": f"Bearer {admin_token}"})
|
|
||||||
assert response.status_code == 201 # Zero prices should be allowed
|
|
||||||
|
|
||||||
def test_update_product_invalid_data_types(self, client: TestClient, admin_token: str, sample_product):
|
|
||||||
"""Test updating product with invalid data types."""
|
|
||||||
update_data = {
|
|
||||||
"purchase_price": "not_a_number",
|
|
||||||
"selling_price": "also_not_a_number"
|
|
||||||
}
|
|
||||||
response = client.put(f"/api/v1/products/{sample_product.id}",
|
|
||||||
json=update_data,
|
|
||||||
headers={"Authorization": f"Bearer {admin_token}"})
|
|
||||||
assert response.status_code == 422 # Validation error
|
|
||||||
@@ -1,439 +0,0 @@
|
|||||||
import pytest
|
|
||||||
from fastapi.testclient import TestClient
|
|
||||||
|
|
||||||
|
|
||||||
class TestTransactionDetailsCreation:
|
|
||||||
"""Test transaction details creation endpoints."""
|
|
||||||
|
|
||||||
def test_create_transaction_details_with_admin_access(self, client: TestClient, admin_token: str, sample_partner, sample_product):
|
|
||||||
"""Test transaction details creation with admin token."""
|
|
||||||
details_data = {
|
|
||||||
"partner_id": sample_partner.id,
|
|
||||||
"product_id": sample_product.id,
|
|
||||||
"qty": 10,
|
|
||||||
"selling_price": 150,
|
|
||||||
"total_value": 1500
|
|
||||||
}
|
|
||||||
response = client.post("/api/v1/transaction-details/",
|
|
||||||
json=details_data,
|
|
||||||
headers={"Authorization": f"Bearer {admin_token}"})
|
|
||||||
assert response.status_code == 201
|
|
||||||
data = response.json()
|
|
||||||
assert data["partner_id"] == sample_partner.id
|
|
||||||
assert data["product_id"] == sample_product.id
|
|
||||||
assert data["qty"] == 10
|
|
||||||
assert data["selling_price"] == 150
|
|
||||||
assert data["total_value"] == 1500
|
|
||||||
assert "id" in data
|
|
||||||
assert "created_by" in data
|
|
||||||
assert "updated_by" in data
|
|
||||||
assert "created_at" in data
|
|
||||||
assert "updated_at" in data
|
|
||||||
|
|
||||||
def test_create_transaction_details_with_write_access(self, client: TestClient, write_token: str, sample_partner, sample_product):
|
|
||||||
"""Test transaction details creation with write token."""
|
|
||||||
details_data = {
|
|
||||||
"partner_id": sample_partner.id,
|
|
||||||
"product_id": sample_product.id,
|
|
||||||
"qty": 5,
|
|
||||||
"selling_price": 200,
|
|
||||||
"total_value": 1000
|
|
||||||
}
|
|
||||||
response = client.post("/api/v1/transaction-details/",
|
|
||||||
json=details_data,
|
|
||||||
headers={"Authorization": f"Bearer {write_token}"})
|
|
||||||
assert response.status_code == 201
|
|
||||||
data = response.json()
|
|
||||||
assert data["qty"] == 5
|
|
||||||
assert data["selling_price"] == 200
|
|
||||||
|
|
||||||
def test_create_transaction_details_unauthorized(self, client: TestClient, sample_partner, sample_product):
|
|
||||||
"""Test transaction details creation without authentication."""
|
|
||||||
details_data = {
|
|
||||||
"partner_id": sample_partner.id,
|
|
||||||
"product_id": sample_product.id,
|
|
||||||
"qty": 3,
|
|
||||||
"selling_price": 100,
|
|
||||||
"total_value": 300
|
|
||||||
}
|
|
||||||
response = client.post("/api/v1/transaction-details/", json=details_data)
|
|
||||||
assert response.status_code == 403
|
|
||||||
|
|
||||||
def test_create_transaction_details_read_only_forbidden(self, client: TestClient, read_only_token: str, sample_partner, sample_product):
|
|
||||||
"""Test transaction details creation with read-only access should fail."""
|
|
||||||
details_data = {
|
|
||||||
"partner_id": sample_partner.id,
|
|
||||||
"product_id": sample_product.id,
|
|
||||||
"qty": 2,
|
|
||||||
"selling_price": 75,
|
|
||||||
"total_value": 150
|
|
||||||
}
|
|
||||||
response = client.post("/api/v1/transaction-details/",
|
|
||||||
json=details_data,
|
|
||||||
headers={"Authorization": f"Bearer {read_only_token}"})
|
|
||||||
assert response.status_code == 403
|
|
||||||
|
|
||||||
def test_create_transaction_details_nonexistent_partner(self, client: TestClient, admin_token: str, sample_product):
|
|
||||||
"""Test creating transaction details with non-existent partner."""
|
|
||||||
details_data = {
|
|
||||||
"partner_id": 99999, # Non-existent partner
|
|
||||||
"product_id": sample_product.id,
|
|
||||||
"qty": 1,
|
|
||||||
"selling_price": 100,
|
|
||||||
"total_value": 100
|
|
||||||
}
|
|
||||||
response = client.post("/api/v1/transaction-details/",
|
|
||||||
json=details_data,
|
|
||||||
headers={"Authorization": f"Bearer {admin_token}"})
|
|
||||||
assert response.status_code == 400
|
|
||||||
assert "Partner not found" in response.json()["detail"]
|
|
||||||
|
|
||||||
def test_create_transaction_details_nonexistent_product(self, client: TestClient, admin_token: str, sample_partner):
|
|
||||||
"""Test creating transaction details with non-existent product."""
|
|
||||||
details_data = {
|
|
||||||
"partner_id": sample_partner.id,
|
|
||||||
"product_id": 99999, # Non-existent product
|
|
||||||
"qty": 1,
|
|
||||||
"selling_price": 100,
|
|
||||||
"total_value": 100
|
|
||||||
}
|
|
||||||
response = client.post("/api/v1/transaction-details/",
|
|
||||||
json=details_data,
|
|
||||||
headers={"Authorization": f"Bearer {admin_token}"})
|
|
||||||
assert response.status_code == 400
|
|
||||||
assert "Product not found" in response.json()["detail"]
|
|
||||||
|
|
||||||
|
|
||||||
class TestTransactionDetailsRetrieval:
|
|
||||||
"""Test transaction details retrieval endpoints."""
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def sample_transaction_details(self, client: TestClient, admin_token: str, sample_partner, sample_product):
|
|
||||||
"""Create sample transaction details for testing."""
|
|
||||||
details_data = {
|
|
||||||
"partner_id": sample_partner.id,
|
|
||||||
"product_id": sample_product.id,
|
|
||||||
"qty": 5,
|
|
||||||
"selling_price": 100,
|
|
||||||
"total_value": 500
|
|
||||||
}
|
|
||||||
response = client.post("/api/v1/transaction-details/",
|
|
||||||
json=details_data,
|
|
||||||
headers={"Authorization": f"Bearer {admin_token}"})
|
|
||||||
return response.json()
|
|
||||||
|
|
||||||
def test_get_all_transaction_details_with_auth(self, client: TestClient, admin_token: str, sample_transaction_details):
|
|
||||||
"""Test retrieving all transaction details with authentication."""
|
|
||||||
response = client.get("/api/v1/transaction-details/",
|
|
||||||
headers={"Authorization": f"Bearer {admin_token}"})
|
|
||||||
assert response.status_code == 200
|
|
||||||
data = response.json()
|
|
||||||
assert isinstance(data, list)
|
|
||||||
assert len(data) >= 1
|
|
||||||
|
|
||||||
def test_get_all_transaction_details_read_only_access(self, client: TestClient, read_only_token: str, sample_transaction_details):
|
|
||||||
"""Test read-only user can retrieve transaction details."""
|
|
||||||
response = client.get("/api/v1/transaction-details/",
|
|
||||||
headers={"Authorization": f"Bearer {read_only_token}"})
|
|
||||||
assert response.status_code == 200
|
|
||||||
data = response.json()
|
|
||||||
assert isinstance(data, list)
|
|
||||||
|
|
||||||
def test_get_transaction_details_unauthorized(self, client: TestClient):
|
|
||||||
"""Test retrieving transaction details without authentication."""
|
|
||||||
response = client.get("/api/v1/transaction-details/")
|
|
||||||
assert response.status_code == 403
|
|
||||||
|
|
||||||
def test_get_transaction_details_with_pagination(self, client: TestClient, admin_token: str, sample_transaction_details):
|
|
||||||
"""Test transaction details retrieval with pagination."""
|
|
||||||
response = client.get("/api/v1/transaction-details/?skip=0&limit=1",
|
|
||||||
headers={"Authorization": f"Bearer {admin_token}"})
|
|
||||||
assert response.status_code == 200
|
|
||||||
data = response.json()
|
|
||||||
assert len(data) <= 1
|
|
||||||
|
|
||||||
def test_get_single_transaction_details_by_id(self, client: TestClient, admin_token: str, sample_transaction_details):
|
|
||||||
"""Test retrieving single transaction details by ID."""
|
|
||||||
details_id = sample_transaction_details["id"]
|
|
||||||
response = client.get(f"/api/v1/transaction-details/{details_id}",
|
|
||||||
headers={"Authorization": f"Bearer {admin_token}"})
|
|
||||||
assert response.status_code == 200
|
|
||||||
data = response.json()
|
|
||||||
assert data["id"] == details_id
|
|
||||||
assert data["qty"] == sample_transaction_details["qty"]
|
|
||||||
|
|
||||||
def test_get_transaction_details_by_partner(self, client: TestClient, admin_token: str, sample_transaction_details, sample_partner):
|
|
||||||
"""Test retrieving transaction details by partner."""
|
|
||||||
response = client.get(f"/api/v1/transaction-details/partner/{sample_partner.id}",
|
|
||||||
headers={"Authorization": f"Bearer {admin_token}"})
|
|
||||||
assert response.status_code == 200
|
|
||||||
data = response.json()
|
|
||||||
assert isinstance(data, list)
|
|
||||||
# All returned details should belong to the specified partner
|
|
||||||
for detail in data:
|
|
||||||
assert detail["partner_id"] == sample_partner.id
|
|
||||||
|
|
||||||
def test_get_transaction_details_by_product(self, client: TestClient, admin_token: str, sample_transaction_details, sample_product):
|
|
||||||
"""Test retrieving transaction details by product."""
|
|
||||||
response = client.get(f"/api/v1/transaction-details/product/{sample_product.id}",
|
|
||||||
headers={"Authorization": f"Bearer {admin_token}"})
|
|
||||||
assert response.status_code == 200
|
|
||||||
data = response.json()
|
|
||||||
assert isinstance(data, list)
|
|
||||||
# All returned details should belong to the specified product
|
|
||||||
for detail in data:
|
|
||||||
assert detail["product_id"] == sample_product.id
|
|
||||||
|
|
||||||
def test_get_transaction_details_by_nonexistent_partner(self, client: TestClient, admin_token: str):
|
|
||||||
"""Test retrieving transaction details by non-existent partner."""
|
|
||||||
response = client.get("/api/v1/transaction-details/partner/99999",
|
|
||||||
headers={"Authorization": f"Bearer {admin_token}"})
|
|
||||||
assert response.status_code == 404
|
|
||||||
assert "Partner not found" in response.json()["detail"]
|
|
||||||
|
|
||||||
def test_get_transaction_details_by_nonexistent_product(self, client: TestClient, admin_token: str):
|
|
||||||
"""Test retrieving transaction details by non-existent product."""
|
|
||||||
response = client.get("/api/v1/transaction-details/product/99999",
|
|
||||||
headers={"Authorization": f"Bearer {admin_token}"})
|
|
||||||
assert response.status_code == 404
|
|
||||||
assert "Product not found" in response.json()["detail"]
|
|
||||||
|
|
||||||
def test_get_nonexistent_transaction_details(self, client: TestClient, admin_token: str):
|
|
||||||
"""Test retrieving non-existent transaction details."""
|
|
||||||
response = client.get("/api/v1/transaction-details/99999",
|
|
||||||
headers={"Authorization": f"Bearer {admin_token}"})
|
|
||||||
assert response.status_code == 404
|
|
||||||
assert "Transaction details not found" in response.json()["detail"]
|
|
||||||
|
|
||||||
|
|
||||||
class TestTransactionDetailsUpdate:
|
|
||||||
"""Test transaction details update endpoints."""
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def sample_transaction_details(self, client: TestClient, admin_token: str, sample_partner, sample_product):
|
|
||||||
"""Create sample transaction details for testing."""
|
|
||||||
details_data = {
|
|
||||||
"partner_id": sample_partner.id,
|
|
||||||
"product_id": sample_product.id,
|
|
||||||
"qty": 10,
|
|
||||||
"selling_price": 100,
|
|
||||||
"total_value": 1000
|
|
||||||
}
|
|
||||||
response = client.post("/api/v1/transaction-details/",
|
|
||||||
json=details_data,
|
|
||||||
headers={"Authorization": f"Bearer {admin_token}"})
|
|
||||||
return response.json()
|
|
||||||
|
|
||||||
def test_update_transaction_details_with_write_access(self, client: TestClient, write_token: str, sample_transaction_details):
|
|
||||||
"""Test updating transaction details with write access."""
|
|
||||||
details_id = sample_transaction_details["id"]
|
|
||||||
update_data = {
|
|
||||||
"qty": 15,
|
|
||||||
"selling_price": 120,
|
|
||||||
"total_value": 1800
|
|
||||||
}
|
|
||||||
response = client.put(f"/api/v1/transaction-details/{details_id}",
|
|
||||||
json=update_data,
|
|
||||||
headers={"Authorization": f"Bearer {write_token}"})
|
|
||||||
assert response.status_code == 200
|
|
||||||
data = response.json()
|
|
||||||
assert data["qty"] == 15
|
|
||||||
assert data["selling_price"] == 120
|
|
||||||
assert data["total_value"] == 1800
|
|
||||||
assert data["partner_id"] == sample_transaction_details["partner_id"] # Unchanged
|
|
||||||
|
|
||||||
def test_update_transaction_details_partner_and_product(self, client: TestClient, admin_token: str, sample_transaction_details, multiple_partners, multiple_products):
|
|
||||||
"""Test updating partner and product in transaction details."""
|
|
||||||
details_id = sample_transaction_details["id"]
|
|
||||||
new_partner = multiple_partners[1] # Different partner
|
|
||||||
new_product = multiple_products[1] # Different product
|
|
||||||
|
|
||||||
update_data = {
|
|
||||||
"partner_id": new_partner.id,
|
|
||||||
"product_id": new_product.id
|
|
||||||
}
|
|
||||||
response = client.put(f"/api/v1/transaction-details/{details_id}",
|
|
||||||
json=update_data,
|
|
||||||
headers={"Authorization": f"Bearer {admin_token}"})
|
|
||||||
assert response.status_code == 200
|
|
||||||
data = response.json()
|
|
||||||
assert data["partner_id"] == new_partner.id
|
|
||||||
assert data["product_id"] == new_product.id
|
|
||||||
|
|
||||||
def test_update_transaction_details_read_only_forbidden(self, client: TestClient, read_only_token: str, sample_transaction_details):
|
|
||||||
"""Test updating transaction details with read-only access should fail."""
|
|
||||||
details_id = sample_transaction_details["id"]
|
|
||||||
update_data = {
|
|
||||||
"qty": 20
|
|
||||||
}
|
|
||||||
response = client.put(f"/api/v1/transaction-details/{details_id}",
|
|
||||||
json=update_data,
|
|
||||||
headers={"Authorization": f"Bearer {read_only_token}"})
|
|
||||||
assert response.status_code == 403
|
|
||||||
|
|
||||||
def test_update_transaction_details_nonexistent_partner(self, client: TestClient, admin_token: str, sample_transaction_details):
|
|
||||||
"""Test updating transaction details with non-existent partner."""
|
|
||||||
details_id = sample_transaction_details["id"]
|
|
||||||
update_data = {
|
|
||||||
"partner_id": 99999 # Non-existent partner
|
|
||||||
}
|
|
||||||
response = client.put(f"/api/v1/transaction-details/{details_id}",
|
|
||||||
json=update_data,
|
|
||||||
headers={"Authorization": f"Bearer {admin_token}"})
|
|
||||||
assert response.status_code == 400
|
|
||||||
assert "Partner not found" in response.json()["detail"]
|
|
||||||
|
|
||||||
def test_update_transaction_details_nonexistent_product(self, client: TestClient, admin_token: str, sample_transaction_details):
|
|
||||||
"""Test updating transaction details with non-existent product."""
|
|
||||||
details_id = sample_transaction_details["id"]
|
|
||||||
update_data = {
|
|
||||||
"product_id": 99999 # Non-existent product
|
|
||||||
}
|
|
||||||
response = client.put(f"/api/v1/transaction-details/{details_id}",
|
|
||||||
json=update_data,
|
|
||||||
headers={"Authorization": f"Bearer {admin_token}"})
|
|
||||||
assert response.status_code == 400
|
|
||||||
assert "Product not found" in response.json()["detail"]
|
|
||||||
|
|
||||||
def test_update_nonexistent_transaction_details(self, client: TestClient, admin_token: str):
|
|
||||||
"""Test updating non-existent transaction details."""
|
|
||||||
update_data = {
|
|
||||||
"qty": 5
|
|
||||||
}
|
|
||||||
response = client.put("/api/v1/transaction-details/99999",
|
|
||||||
json=update_data,
|
|
||||||
headers={"Authorization": f"Bearer {admin_token}"})
|
|
||||||
assert response.status_code == 404
|
|
||||||
assert "Transaction details not found" in response.json()["detail"]
|
|
||||||
|
|
||||||
|
|
||||||
class TestTransactionDetailsDeletion:
|
|
||||||
"""Test transaction details deletion endpoints."""
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def sample_transaction_details(self, client: TestClient, admin_token: str, sample_partner, sample_product):
|
|
||||||
"""Create sample transaction details for testing."""
|
|
||||||
details_data = {
|
|
||||||
"partner_id": sample_partner.id,
|
|
||||||
"product_id": sample_product.id,
|
|
||||||
"qty": 2,
|
|
||||||
"selling_price": 50,
|
|
||||||
"total_value": 100
|
|
||||||
}
|
|
||||||
response = client.post("/api/v1/transaction-details/",
|
|
||||||
json=details_data,
|
|
||||||
headers={"Authorization": f"Bearer {admin_token}"})
|
|
||||||
return response.json()
|
|
||||||
|
|
||||||
def test_delete_transaction_details_with_admin_access(self, client: TestClient, admin_token: str, sample_transaction_details):
|
|
||||||
"""Test deleting transaction details with admin access."""
|
|
||||||
details_id = sample_transaction_details["id"]
|
|
||||||
response = client.delete(f"/api/v1/transaction-details/{details_id}",
|
|
||||||
headers={"Authorization": f"Bearer {admin_token}"})
|
|
||||||
assert response.status_code == 204
|
|
||||||
|
|
||||||
# Verify transaction details is deleted
|
|
||||||
get_response = client.get(f"/api/v1/transaction-details/{details_id}",
|
|
||||||
headers={"Authorization": f"Bearer {admin_token}"})
|
|
||||||
assert get_response.status_code == 404
|
|
||||||
|
|
||||||
def test_delete_transaction_details_write_access_forbidden(self, client: TestClient, write_token: str, sample_transaction_details):
|
|
||||||
"""Test deleting transaction details with write access should fail."""
|
|
||||||
details_id = sample_transaction_details["id"]
|
|
||||||
response = client.delete(f"/api/v1/transaction-details/{details_id}",
|
|
||||||
headers={"Authorization": f"Bearer {write_token}"})
|
|
||||||
assert response.status_code == 403
|
|
||||||
|
|
||||||
def test_delete_transaction_details_read_only_forbidden(self, client: TestClient, read_only_token: str, sample_transaction_details):
|
|
||||||
"""Test deleting transaction details with read-only access should fail."""
|
|
||||||
details_id = sample_transaction_details["id"]
|
|
||||||
response = client.delete(f"/api/v1/transaction-details/{details_id}",
|
|
||||||
headers={"Authorization": f"Bearer {read_only_token}"})
|
|
||||||
assert response.status_code == 403
|
|
||||||
|
|
||||||
def test_delete_nonexistent_transaction_details(self, client: TestClient, admin_token: str):
|
|
||||||
"""Test deleting non-existent transaction details."""
|
|
||||||
response = client.delete("/api/v1/transaction-details/99999",
|
|
||||||
headers={"Authorization": f"Bearer {admin_token}"})
|
|
||||||
assert response.status_code == 404
|
|
||||||
assert "Transaction details not found" in response.json()["detail"]
|
|
||||||
|
|
||||||
def test_delete_transaction_details_unauthorized(self, client: TestClient, sample_transaction_details):
|
|
||||||
"""Test deleting transaction details without authentication."""
|
|
||||||
details_id = sample_transaction_details["id"]
|
|
||||||
response = client.delete(f"/api/v1/transaction-details/{details_id}")
|
|
||||||
assert response.status_code == 403
|
|
||||||
|
|
||||||
|
|
||||||
class TestTransactionDetailsValidation:
|
|
||||||
"""Test transaction details data validation."""
|
|
||||||
|
|
||||||
def test_create_transaction_details_missing_required_fields(self, client: TestClient, admin_token: str):
|
|
||||||
"""Test creating transaction details with missing required fields."""
|
|
||||||
# Missing partner_id
|
|
||||||
details_data = {
|
|
||||||
"product_id": 1,
|
|
||||||
"qty": 1,
|
|
||||||
"selling_price": 100,
|
|
||||||
"total_value": 100
|
|
||||||
}
|
|
||||||
response = client.post("/api/v1/transaction-details/",
|
|
||||||
json=details_data,
|
|
||||||
headers={"Authorization": f"Bearer {admin_token}"})
|
|
||||||
assert response.status_code == 422 # Validation error
|
|
||||||
|
|
||||||
def test_create_transaction_details_negative_values(self, client: TestClient, admin_token: str, sample_partner, sample_product):
|
|
||||||
"""Test creating transaction details with negative values."""
|
|
||||||
details_data = {
|
|
||||||
"partner_id": sample_partner.id,
|
|
||||||
"product_id": sample_product.id,
|
|
||||||
"qty": -1, # Negative quantity
|
|
||||||
"selling_price": -50, # Negative price
|
|
||||||
"total_value": -50
|
|
||||||
}
|
|
||||||
response = client.post("/api/v1/transaction-details/",
|
|
||||||
json=details_data,
|
|
||||||
headers={"Authorization": f"Bearer {admin_token}"})
|
|
||||||
# Note: This test assumes validation constraints exist for negative values
|
|
||||||
# If not implemented, this test will fail and indicate missing validation
|
|
||||||
assert response.status_code in [422, 201] # Either validation error or creation
|
|
||||||
|
|
||||||
def test_create_transaction_details_zero_quantity(self, client: TestClient, admin_token: str, sample_partner, sample_product):
|
|
||||||
"""Test creating transaction details with zero quantity."""
|
|
||||||
details_data = {
|
|
||||||
"partner_id": sample_partner.id,
|
|
||||||
"product_id": sample_product.id,
|
|
||||||
"qty": 0, # Zero quantity
|
|
||||||
"selling_price": 100,
|
|
||||||
"total_value": 0
|
|
||||||
}
|
|
||||||
response = client.post("/api/v1/transaction-details/",
|
|
||||||
json=details_data,
|
|
||||||
headers={"Authorization": f"Bearer {admin_token}"})
|
|
||||||
# Zero quantity might be allowed depending on business rules
|
|
||||||
assert response.status_code in [422, 201]
|
|
||||||
|
|
||||||
def test_update_transaction_details_invalid_data_types(self, client: TestClient, admin_token: str, sample_partner, sample_product):
|
|
||||||
"""Test updating transaction details with invalid data types."""
|
|
||||||
# First create a transaction detail
|
|
||||||
details_data = {
|
|
||||||
"partner_id": sample_partner.id,
|
|
||||||
"product_id": sample_product.id,
|
|
||||||
"qty": 1,
|
|
||||||
"selling_price": 100,
|
|
||||||
"total_value": 100
|
|
||||||
}
|
|
||||||
create_response = client.post("/api/v1/transaction-details/",
|
|
||||||
json=details_data,
|
|
||||||
headers={"Authorization": f"Bearer {admin_token}"})
|
|
||||||
details_id = create_response.json()["id"]
|
|
||||||
|
|
||||||
# Try to update with invalid data types
|
|
||||||
update_data = {
|
|
||||||
"qty": "not_a_number",
|
|
||||||
"selling_price": "also_not_a_number"
|
|
||||||
}
|
|
||||||
response = client.put(f"/api/v1/transaction-details/{details_id}",
|
|
||||||
json=update_data,
|
|
||||||
headers={"Authorization": f"Bearer {admin_token}"})
|
|
||||||
assert response.status_code == 422 # Validation error
|
|
||||||
@@ -1,364 +0,0 @@
|
|||||||
import pytest
|
|
||||||
from fastapi.testclient import TestClient
|
|
||||||
from app.schemas.base import TransactionType, TransactionStatus
|
|
||||||
|
|
||||||
|
|
||||||
class TestTransactionCreation:
|
|
||||||
"""Test transaction creation endpoints."""
|
|
||||||
|
|
||||||
def test_create_transaction_with_admin_access(self, client: TestClient, admin_token: str, sample_partner):
|
|
||||||
"""Test transaction creation with admin token."""
|
|
||||||
transaction_data = {
|
|
||||||
"partner_id": sample_partner.id,
|
|
||||||
"transcation_type": TransactionType.SALE,
|
|
||||||
"transaction_status": TransactionStatus.UNPAID,
|
|
||||||
"total_amount": 1000
|
|
||||||
}
|
|
||||||
response = client.post("/api/v1/transactions/",
|
|
||||||
json=transaction_data,
|
|
||||||
headers={"Authorization": f"Bearer {admin_token}"})
|
|
||||||
assert response.status_code == 201
|
|
||||||
data = response.json()
|
|
||||||
assert data["partner_id"] == sample_partner.id
|
|
||||||
assert data["transcation_type"] == TransactionType.SALE
|
|
||||||
assert data["transaction_status"] == TransactionStatus.UNPAID
|
|
||||||
assert data["total_amount"] == 1000
|
|
||||||
assert "id" in data
|
|
||||||
assert "created_by" in data
|
|
||||||
assert "updated_by" in data
|
|
||||||
assert "created_on" in data
|
|
||||||
assert "updated_on" in data
|
|
||||||
|
|
||||||
def test_create_transaction_with_write_access(self, client: TestClient, write_token: str, sample_partner):
|
|
||||||
"""Test transaction creation with write token."""
|
|
||||||
transaction_data = {
|
|
||||||
"partner_id": sample_partner.id,
|
|
||||||
"transcation_type": TransactionType.PURCHASE,
|
|
||||||
"transaction_status": TransactionStatus.PAID,
|
|
||||||
"total_amount": 2000
|
|
||||||
}
|
|
||||||
response = client.post("/api/v1/transactions/",
|
|
||||||
json=transaction_data,
|
|
||||||
headers={"Authorization": f"Bearer {write_token}"})
|
|
||||||
assert response.status_code == 201
|
|
||||||
data = response.json()
|
|
||||||
assert data["transcation_type"] == TransactionType.PURCHASE
|
|
||||||
assert data["transaction_status"] == TransactionStatus.PAID
|
|
||||||
assert data["total_amount"] == 2000
|
|
||||||
|
|
||||||
def test_create_transaction_unauthorized(self, client: TestClient):
|
|
||||||
"""Test transaction creation without authentication."""
|
|
||||||
transaction_data = {
|
|
||||||
"partner_id": 1,
|
|
||||||
"transcation_type": TransactionType.SALE,
|
|
||||||
"transaction_status": TransactionStatus.UNPAID,
|
|
||||||
"total_amount": 1000
|
|
||||||
}
|
|
||||||
response = client.post("/api/v1/transactions/", json=transaction_data)
|
|
||||||
assert response.status_code == 403 # HTTPBearer returns 403 for missing auth
|
|
||||||
|
|
||||||
def test_create_transaction_read_only_forbidden(self, client: TestClient, read_only_token: str, sample_partner):
|
|
||||||
"""Test transaction creation with read-only access should fail."""
|
|
||||||
transaction_data = {
|
|
||||||
"partner_id": sample_partner.id,
|
|
||||||
"transcation_type": TransactionType.SALE,
|
|
||||||
"transaction_status": TransactionStatus.UNPAID,
|
|
||||||
"total_amount": 500
|
|
||||||
}
|
|
||||||
response = client.post("/api/v1/transactions/",
|
|
||||||
json=transaction_data,
|
|
||||||
headers={"Authorization": f"Bearer {read_only_token}"})
|
|
||||||
assert response.status_code == 403
|
|
||||||
|
|
||||||
def test_create_transaction_with_defaults(self, client: TestClient, admin_token: str, sample_partner):
|
|
||||||
"""Test transaction creation with default values."""
|
|
||||||
transaction_data = {
|
|
||||||
"partner_id": sample_partner.id,
|
|
||||||
"total_amount": 750
|
|
||||||
# Using default transcation_type and transaction_status
|
|
||||||
}
|
|
||||||
response = client.post("/api/v1/transactions/",
|
|
||||||
json=transaction_data,
|
|
||||||
headers={"Authorization": f"Bearer {admin_token}"})
|
|
||||||
assert response.status_code == 201
|
|
||||||
data = response.json()
|
|
||||||
assert data["transcation_type"] == TransactionType.SALE # Default
|
|
||||||
assert data["transaction_status"] == TransactionStatus.UNPAID # Default
|
|
||||||
|
|
||||||
def test_create_transaction_credit_type(self, client: TestClient, admin_token: str, sample_partner):
|
|
||||||
"""Test creating a credit transaction."""
|
|
||||||
transaction_data = {
|
|
||||||
"partner_id": sample_partner.id,
|
|
||||||
"transcation_type": TransactionType.CREDIT,
|
|
||||||
"transaction_status": TransactionStatus.PARTIALLY_PAID,
|
|
||||||
"total_amount": 1500
|
|
||||||
}
|
|
||||||
response = client.post("/api/v1/transactions/",
|
|
||||||
json=transaction_data,
|
|
||||||
headers={"Authorization": f"Bearer {admin_token}"})
|
|
||||||
assert response.status_code == 201
|
|
||||||
data = response.json()
|
|
||||||
assert data["transcation_type"] == TransactionType.CREDIT
|
|
||||||
assert data["transaction_status"] == TransactionStatus.PARTIALLY_PAID
|
|
||||||
|
|
||||||
|
|
||||||
class TestTransactionRetrieval:
|
|
||||||
"""Test transaction retrieval endpoints."""
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def sample_transaction(self, client: TestClient, admin_token: str, sample_partner):
|
|
||||||
"""Create sample transaction for testing."""
|
|
||||||
transaction_data = {
|
|
||||||
"partner_id": sample_partner.id,
|
|
||||||
"transcation_type": TransactionType.SALE,
|
|
||||||
"transaction_status": TransactionStatus.UNPAID,
|
|
||||||
"total_amount": 1000
|
|
||||||
}
|
|
||||||
response = client.post("/api/v1/transactions/",
|
|
||||||
json=transaction_data,
|
|
||||||
headers={"Authorization": f"Bearer {admin_token}"})
|
|
||||||
return response.json()
|
|
||||||
|
|
||||||
def test_read_transactions_with_auth(self, client: TestClient, admin_token: str, sample_transaction):
|
|
||||||
"""Test reading transactions with authentication."""
|
|
||||||
response = client.get("/api/v1/transactions/",
|
|
||||||
headers={"Authorization": f"Bearer {admin_token}"})
|
|
||||||
assert response.status_code == 200
|
|
||||||
data = response.json()
|
|
||||||
assert isinstance(data, list)
|
|
||||||
assert len(data) >= 1
|
|
||||||
|
|
||||||
def test_read_transactions_read_only_access(self, client: TestClient, read_only_token: str, sample_transaction):
|
|
||||||
"""Test read-only user can retrieve transactions."""
|
|
||||||
response = client.get("/api/v1/transactions/",
|
|
||||||
headers={"Authorization": f"Bearer {read_only_token}"})
|
|
||||||
assert response.status_code == 200
|
|
||||||
data = response.json()
|
|
||||||
assert isinstance(data, list)
|
|
||||||
|
|
||||||
def test_read_transactions_unauthorized(self, client: TestClient):
|
|
||||||
"""Test reading transactions without authentication."""
|
|
||||||
response = client.get("/api/v1/transactions/")
|
|
||||||
assert response.status_code == 403
|
|
||||||
|
|
||||||
def test_read_transactions_with_pagination(self, client: TestClient, admin_token: str, sample_transaction):
|
|
||||||
"""Test transaction retrieval with pagination."""
|
|
||||||
response = client.get("/api/v1/transactions/?skip=0&limit=1",
|
|
||||||
headers={"Authorization": f"Bearer {admin_token}"})
|
|
||||||
assert response.status_code == 200
|
|
||||||
data = response.json()
|
|
||||||
assert len(data) <= 1
|
|
||||||
|
|
||||||
def test_read_single_transaction(self, client: TestClient, admin_token: str, sample_transaction):
|
|
||||||
"""Test reading a single transaction by ID."""
|
|
||||||
transaction_id = sample_transaction["id"]
|
|
||||||
response = client.get(f"/api/v1/transactions/{transaction_id}",
|
|
||||||
headers={"Authorization": f"Bearer {admin_token}"})
|
|
||||||
assert response.status_code == 200
|
|
||||||
data = response.json()
|
|
||||||
assert data["id"] == transaction_id
|
|
||||||
assert data["total_amount"] == sample_transaction["total_amount"]
|
|
||||||
|
|
||||||
def test_read_nonexistent_transaction(self, client: TestClient, admin_token: str):
|
|
||||||
"""Test reading a non-existent transaction."""
|
|
||||||
response = client.get("/api/v1/transactions/99999",
|
|
||||||
headers={"Authorization": f"Bearer {admin_token}"})
|
|
||||||
assert response.status_code == 404
|
|
||||||
assert "Transaction not found" in response.json()["detail"]
|
|
||||||
|
|
||||||
|
|
||||||
class TestTransactionUpdate:
|
|
||||||
"""Test transaction update endpoints."""
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def sample_transaction(self, client: TestClient, admin_token: str, sample_partner):
|
|
||||||
"""Create sample transaction for testing."""
|
|
||||||
transaction_data = {
|
|
||||||
"partner_id": sample_partner.id,
|
|
||||||
"transcation_type": TransactionType.SALE,
|
|
||||||
"transaction_status": TransactionStatus.UNPAID,
|
|
||||||
"total_amount": 1000
|
|
||||||
}
|
|
||||||
response = client.post("/api/v1/transactions/",
|
|
||||||
json=transaction_data,
|
|
||||||
headers={"Authorization": f"Bearer {admin_token}"})
|
|
||||||
return response.json()
|
|
||||||
|
|
||||||
def test_update_transaction_with_write_access(self, client: TestClient, write_token: str, sample_transaction):
|
|
||||||
"""Test updating transaction with write access."""
|
|
||||||
transaction_id = sample_transaction["id"]
|
|
||||||
update_data = {
|
|
||||||
"transaction_status": TransactionStatus.PAID,
|
|
||||||
"total_amount": 1200
|
|
||||||
}
|
|
||||||
response = client.put(f"/api/v1/transactions/{transaction_id}",
|
|
||||||
json=update_data,
|
|
||||||
headers={"Authorization": f"Bearer {write_token}"})
|
|
||||||
assert response.status_code == 200
|
|
||||||
data = response.json()
|
|
||||||
assert data["transaction_status"] == TransactionStatus.PAID
|
|
||||||
assert data["total_amount"] == 1200
|
|
||||||
assert data["partner_id"] == sample_transaction["partner_id"] # Unchanged
|
|
||||||
|
|
||||||
def test_update_transaction_status_progression(self, client: TestClient, admin_token: str, sample_transaction):
|
|
||||||
"""Test updating transaction through different status stages."""
|
|
||||||
transaction_id = sample_transaction["id"]
|
|
||||||
|
|
||||||
# Update to partially paid
|
|
||||||
update_data = {"transaction_status": TransactionStatus.PARTIALLY_PAID}
|
|
||||||
response = client.put(f"/api/v1/transactions/{transaction_id}",
|
|
||||||
json=update_data,
|
|
||||||
headers={"Authorization": f"Bearer {admin_token}"})
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert response.json()["transaction_status"] == TransactionStatus.PARTIALLY_PAID
|
|
||||||
|
|
||||||
# Update to fully paid
|
|
||||||
update_data = {"transaction_status": TransactionStatus.PAID}
|
|
||||||
response = client.put(f"/api/v1/transactions/{transaction_id}",
|
|
||||||
json=update_data,
|
|
||||||
headers={"Authorization": f"Bearer {admin_token}"})
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert response.json()["transaction_status"] == TransactionStatus.PAID
|
|
||||||
|
|
||||||
def test_update_transaction_read_only_forbidden(self, client: TestClient, read_only_token: str, sample_transaction):
|
|
||||||
"""Test updating transaction with read-only access should fail."""
|
|
||||||
transaction_id = sample_transaction["id"]
|
|
||||||
update_data = {
|
|
||||||
"total_amount": 2000
|
|
||||||
}
|
|
||||||
response = client.put(f"/api/v1/transactions/{transaction_id}",
|
|
||||||
json=update_data,
|
|
||||||
headers={"Authorization": f"Bearer {read_only_token}"})
|
|
||||||
assert response.status_code == 403
|
|
||||||
|
|
||||||
def test_update_nonexistent_transaction(self, client: TestClient, admin_token: str):
|
|
||||||
"""Test updating a non-existent transaction."""
|
|
||||||
update_data = {
|
|
||||||
"total_amount": 1500
|
|
||||||
}
|
|
||||||
response = client.put("/api/v1/transactions/99999",
|
|
||||||
json=update_data,
|
|
||||||
headers={"Authorization": f"Bearer {admin_token}"})
|
|
||||||
assert response.status_code == 404
|
|
||||||
assert "Transaction not found" in response.json()["detail"]
|
|
||||||
|
|
||||||
def test_update_transaction_change_partner(self, client: TestClient, admin_token: str, sample_transaction, multiple_partners):
|
|
||||||
"""Test updating transaction partner."""
|
|
||||||
transaction_id = sample_transaction["id"]
|
|
||||||
new_partner = multiple_partners[1] # Different partner
|
|
||||||
|
|
||||||
update_data = {
|
|
||||||
"partner_id": new_partner.id
|
|
||||||
}
|
|
||||||
response = client.put(f"/api/v1/transactions/{transaction_id}",
|
|
||||||
json=update_data,
|
|
||||||
headers={"Authorization": f"Bearer {admin_token}"})
|
|
||||||
assert response.status_code == 200
|
|
||||||
data = response.json()
|
|
||||||
assert data["partner_id"] == new_partner.id
|
|
||||||
|
|
||||||
|
|
||||||
class TestTransactionDeletion:
|
|
||||||
"""Test transaction deletion endpoints."""
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def sample_transaction(self, client: TestClient, admin_token: str, sample_partner):
|
|
||||||
"""Create sample transaction for testing."""
|
|
||||||
transaction_data = {
|
|
||||||
"partner_id": sample_partner.id,
|
|
||||||
"transcation_type": TransactionType.SALE,
|
|
||||||
"transaction_status": TransactionStatus.UNPAID,
|
|
||||||
"total_amount": 500
|
|
||||||
}
|
|
||||||
response = client.post("/api/v1/transactions/",
|
|
||||||
json=transaction_data,
|
|
||||||
headers={"Authorization": f"Bearer {admin_token}"})
|
|
||||||
return response.json()
|
|
||||||
|
|
||||||
def test_delete_transaction_write_access_forbidden(self, client: TestClient, write_token: str, sample_transaction):
|
|
||||||
"""Test deleting transaction with write access should fail (assuming only admin can delete)."""
|
|
||||||
transaction_id = sample_transaction["id"]
|
|
||||||
response = client.delete(f"/api/v1/transactions/{transaction_id}",
|
|
||||||
headers={"Authorization": f"Bearer {write_token}"})
|
|
||||||
# Note: Based on the endpoint, write users can delete. If this should be admin-only,
|
|
||||||
# the endpoint needs to be updated to use require_admin instead of require_write_access
|
|
||||||
assert response.status_code == 204
|
|
||||||
|
|
||||||
def test_delete_transaction_read_only_forbidden(self, client: TestClient, read_only_token: str, sample_transaction):
|
|
||||||
"""Test deleting transaction with read-only access should fail."""
|
|
||||||
transaction_id = sample_transaction["id"]
|
|
||||||
response = client.delete(f"/api/v1/transactions/{transaction_id}",
|
|
||||||
headers={"Authorization": f"Bearer {read_only_token}"})
|
|
||||||
assert response.status_code == 403
|
|
||||||
|
|
||||||
def test_delete_nonexistent_transaction(self, client: TestClient, admin_token: str):
|
|
||||||
"""Test deleting a non-existent transaction."""
|
|
||||||
response = client.delete("/api/v1/transactions/99999",
|
|
||||||
headers={"Authorization": f"Bearer {admin_token}"})
|
|
||||||
assert response.status_code == 404
|
|
||||||
assert "Transaction not found" in response.json()["detail"]
|
|
||||||
|
|
||||||
def test_delete_transaction_unauthorized(self, client: TestClient, sample_transaction):
|
|
||||||
"""Test deleting transaction without authentication."""
|
|
||||||
transaction_id = sample_transaction["id"]
|
|
||||||
response = client.delete(f"/api/v1/transactions/{transaction_id}")
|
|
||||||
assert response.status_code == 403
|
|
||||||
|
|
||||||
|
|
||||||
class TestTransactionValidation:
|
|
||||||
"""Test transaction data validation."""
|
|
||||||
|
|
||||||
def test_create_transaction_missing_required_fields(self, client: TestClient, admin_token: str):
|
|
||||||
"""Test creating transaction with missing required fields."""
|
|
||||||
# Missing partner_id
|
|
||||||
transaction_data = {
|
|
||||||
"transcation_type": TransactionType.SALE,
|
|
||||||
"transaction_status": TransactionStatus.UNPAID,
|
|
||||||
"total_amount": 1000
|
|
||||||
}
|
|
||||||
response = client.post("/api/v1/transactions/",
|
|
||||||
json=transaction_data,
|
|
||||||
headers={"Authorization": f"Bearer {admin_token}"})
|
|
||||||
assert response.status_code == 422 # Validation error
|
|
||||||
|
|
||||||
def test_create_transaction_invalid_enum_values(self, client: TestClient, admin_token: str, sample_partner):
|
|
||||||
"""Test creating transaction with invalid enum values."""
|
|
||||||
transaction_data = {
|
|
||||||
"partner_id": sample_partner.id,
|
|
||||||
"transcation_type": "INVALID_TYPE",
|
|
||||||
"transaction_status": "INVALID_STATUS",
|
|
||||||
"total_amount": 1000
|
|
||||||
}
|
|
||||||
response = client.post("/api/v1/transactions/",
|
|
||||||
json=transaction_data,
|
|
||||||
headers={"Authorization": f"Bearer {admin_token}"})
|
|
||||||
assert response.status_code == 422 # Validation error
|
|
||||||
|
|
||||||
def test_create_transaction_negative_amount(self, client: TestClient, admin_token: str, sample_partner):
|
|
||||||
"""Test creating transaction with negative amount."""
|
|
||||||
transaction_data = {
|
|
||||||
"partner_id": sample_partner.id,
|
|
||||||
"transcation_type": TransactionType.SALE,
|
|
||||||
"transaction_status": TransactionStatus.UNPAID,
|
|
||||||
"total_amount": -500 # Negative amount
|
|
||||||
}
|
|
||||||
response = client.post("/api/v1/transactions/",
|
|
||||||
json=transaction_data,
|
|
||||||
headers={"Authorization": f"Bearer {admin_token}"})
|
|
||||||
# Note: This test assumes validation constraints exist for negative amounts
|
|
||||||
# If not implemented, this test will fail and indicate missing validation
|
|
||||||
assert response.status_code in [422, 201] # Either validation error or creation
|
|
||||||
|
|
||||||
def test_create_transaction_zero_amount(self, client: TestClient, admin_token: str, sample_partner):
|
|
||||||
"""Test creating transaction with zero amount."""
|
|
||||||
transaction_data = {
|
|
||||||
"partner_id": sample_partner.id,
|
|
||||||
"transcation_type": TransactionType.SALE,
|
|
||||||
"transaction_status": TransactionStatus.UNPAID,
|
|
||||||
"total_amount": 0 # Zero amount
|
|
||||||
}
|
|
||||||
response = client.post("/api/v1/transactions/",
|
|
||||||
json=transaction_data,
|
|
||||||
headers={"Authorization": f"Bearer {admin_token}"})
|
|
||||||
# Zero amount might be allowed depending on business rules
|
|
||||||
assert response.status_code in [422, 201]
|
|
||||||
@@ -1,624 +0,0 @@
|
|||||||
import pytest
|
|
||||||
from fastapi.testclient import TestClient
|
|
||||||
|
|
||||||
|
|
||||||
def test_create_user_public_registration(client: TestClient):
|
|
||||||
"""Test public user registration (no authentication required)."""
|
|
||||||
user_data = {
|
|
||||||
"username": "testuser",
|
|
||||||
"password": "testpassword",
|
|
||||||
"role": "read_only"
|
|
||||||
}
|
|
||||||
response = client.post("/api/v1/users/", json=user_data)
|
|
||||||
assert response.status_code == 201
|
|
||||||
data = response.json()
|
|
||||||
assert data["username"] == "testuser"
|
|
||||||
assert data["role"] == "read_only"
|
|
||||||
assert data["is_approved"] == False # Should not be approved by default
|
|
||||||
assert "id" in data
|
|
||||||
|
|
||||||
|
|
||||||
def test_unapproved_user_cannot_login(client: TestClient):
|
|
||||||
"""Test that unapproved users cannot login."""
|
|
||||||
# Create user (should be unapproved by default)
|
|
||||||
user_data = {
|
|
||||||
"username": "unapproveduser",
|
|
||||||
"password": "testpassword",
|
|
||||||
"role": "read_only"
|
|
||||||
}
|
|
||||||
response = client.post("/api/v1/users/", json=user_data)
|
|
||||||
assert response.status_code == 201
|
|
||||||
assert response.json()["is_approved"] == False
|
|
||||||
|
|
||||||
# Try to login - should fail with specific error
|
|
||||||
login_data = {
|
|
||||||
"username": "unapproveduser",
|
|
||||||
"password": "testpassword"
|
|
||||||
}
|
|
||||||
response = client.post("/api/v1/users/login", json=login_data)
|
|
||||||
assert response.status_code == 403
|
|
||||||
assert "pending admin approval" in response.json()["detail"].lower()
|
|
||||||
|
|
||||||
|
|
||||||
def test_admin_can_approve_users(client: TestClient, admin_token: str):
|
|
||||||
"""Test that admin can approve user accounts."""
|
|
||||||
# Create user
|
|
||||||
user_data = {
|
|
||||||
"username": "toapprove",
|
|
||||||
"password": "testpassword",
|
|
||||||
"role": "read_only"
|
|
||||||
}
|
|
||||||
create_response = client.post("/api/v1/users/", json=user_data)
|
|
||||||
user_id = create_response.json()["id"]
|
|
||||||
|
|
||||||
# Admin approves the user
|
|
||||||
approval_data = {"is_approved": True}
|
|
||||||
response = client.put(f"/api/v1/users/{user_id}/approval",
|
|
||||||
json=approval_data,
|
|
||||||
headers={"Authorization": f"Bearer {admin_token}"})
|
|
||||||
assert response.status_code == 200
|
|
||||||
data = response.json()
|
|
||||||
assert data["is_approved"] == True
|
|
||||||
assert data["username"] == "toapprove"
|
|
||||||
|
|
||||||
|
|
||||||
def test_approved_user_can_login(client: TestClient, admin_token: str):
|
|
||||||
"""Test that approved users can login successfully."""
|
|
||||||
# Create user
|
|
||||||
user_data = {
|
|
||||||
"username": "logintest",
|
|
||||||
"password": "testpassword",
|
|
||||||
"role": "read_only"
|
|
||||||
}
|
|
||||||
create_response = client.post("/api/v1/users/", json=user_data)
|
|
||||||
user_id = create_response.json()["id"]
|
|
||||||
|
|
||||||
# Admin approves the user
|
|
||||||
approval_data = {"is_approved": True}
|
|
||||||
client.put(f"/api/v1/users/{user_id}/approval",
|
|
||||||
json=approval_data,
|
|
||||||
headers={"Authorization": f"Bearer {admin_token}"})
|
|
||||||
|
|
||||||
# Now user can login
|
|
||||||
login_data = {
|
|
||||||
"username": "logintest",
|
|
||||||
"password": "testpassword"
|
|
||||||
}
|
|
||||||
response = client.post("/api/v1/users/login", json=login_data)
|
|
||||||
assert response.status_code == 200
|
|
||||||
data = response.json()
|
|
||||||
assert "access_token" in data
|
|
||||||
assert data["token_type"] == "bearer"
|
|
||||||
assert "expires_in" in data
|
|
||||||
assert data["user"]["is_approved"] == True
|
|
||||||
|
|
||||||
|
|
||||||
def test_get_current_user(client: TestClient, admin_token: str):
|
|
||||||
"""Test getting current user info."""
|
|
||||||
# Create user
|
|
||||||
user_data = {
|
|
||||||
"username": "currenttest",
|
|
||||||
"password": "testpassword",
|
|
||||||
"role": "write"
|
|
||||||
}
|
|
||||||
create_response = client.post("/api/v1/users/", json=user_data)
|
|
||||||
user_id = create_response.json()["id"]
|
|
||||||
|
|
||||||
# Admin approves the user
|
|
||||||
approval_data = {"is_approved": True}
|
|
||||||
client.put(f"/api/v1/users/{user_id}/approval",
|
|
||||||
json=approval_data,
|
|
||||||
headers={"Authorization": f"Bearer {admin_token}"})
|
|
||||||
|
|
||||||
# Login user
|
|
||||||
login_response = client.post("/api/v1/users/login", json={
|
|
||||||
"username": "currenttest",
|
|
||||||
"password": "testpassword"
|
|
||||||
})
|
|
||||||
token = login_response.json()["access_token"]
|
|
||||||
|
|
||||||
# Get current user
|
|
||||||
response = client.get("/api/v1/users/me", headers={
|
|
||||||
"Authorization": f"Bearer {token}"
|
|
||||||
})
|
|
||||||
assert response.status_code == 200
|
|
||||||
data = response.json()
|
|
||||||
assert data["username"] == "currenttest"
|
|
||||||
assert data["role"] == "write"
|
|
||||||
assert data["is_approved"] == True
|
|
||||||
|
|
||||||
|
|
||||||
def test_admin_can_reject_users(client: TestClient, admin_token: str):
|
|
||||||
"""Test that admin can reject/unapprove user accounts."""
|
|
||||||
# Create user
|
|
||||||
user_data = {
|
|
||||||
"username": "toreject",
|
|
||||||
"password": "testpassword",
|
|
||||||
"role": "read_only"
|
|
||||||
}
|
|
||||||
create_response = client.post("/api/v1/users/", json=user_data)
|
|
||||||
user_id = create_response.json()["id"]
|
|
||||||
|
|
||||||
# Admin first approves, then rejects the user
|
|
||||||
approval_data = {"is_approved": True}
|
|
||||||
client.put(f"/api/v1/users/{user_id}/approval",
|
|
||||||
json=approval_data,
|
|
||||||
headers={"Authorization": f"Bearer {admin_token}"})
|
|
||||||
|
|
||||||
# Now reject/unapprove
|
|
||||||
rejection_data = {"is_approved": False}
|
|
||||||
response = client.put(f"/api/v1/users/{user_id}/approval",
|
|
||||||
json=rejection_data,
|
|
||||||
headers={"Authorization": f"Bearer {admin_token}"})
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert response.json()["is_approved"] == False
|
|
||||||
|
|
||||||
|
|
||||||
def test_non_admin_cannot_approve_users(client: TestClient, admin_token: str):
|
|
||||||
"""Test that non-admin users cannot approve other users."""
|
|
||||||
# Create two users
|
|
||||||
user1_data = {
|
|
||||||
"username": "user1",
|
|
||||||
"password": "testpassword",
|
|
||||||
"role": "write"
|
|
||||||
}
|
|
||||||
user2_data = {
|
|
||||||
"username": "user2",
|
|
||||||
"password": "testpassword",
|
|
||||||
"role": "read_only"
|
|
||||||
}
|
|
||||||
|
|
||||||
create_response1 = client.post("/api/v1/users/", json=user1_data)
|
|
||||||
user1_id = create_response1.json()["id"]
|
|
||||||
|
|
||||||
create_response2 = client.post("/api/v1/users/", json=user2_data)
|
|
||||||
user2_id = create_response2.json()["id"]
|
|
||||||
|
|
||||||
# Admin approves user1 so they can login
|
|
||||||
approval_data = {"is_approved": True}
|
|
||||||
client.put(f"/api/v1/users/{user1_id}/approval",
|
|
||||||
json=approval_data,
|
|
||||||
headers={"Authorization": f"Bearer {admin_token}"})
|
|
||||||
|
|
||||||
# User1 logs in
|
|
||||||
login_response = client.post("/api/v1/users/login", json={
|
|
||||||
"username": "user1",
|
|
||||||
"password": "testpassword"
|
|
||||||
})
|
|
||||||
user1_token = login_response.json()["access_token"]
|
|
||||||
|
|
||||||
# User1 tries to approve user2 - should fail
|
|
||||||
response = client.put(f"/api/v1/users/{user2_id}/approval",
|
|
||||||
json=approval_data,
|
|
||||||
headers={"Authorization": f"Bearer {user1_token}"})
|
|
||||||
assert response.status_code == 403
|
|
||||||
|
|
||||||
|
|
||||||
def test_login_error_messages(client: TestClient):
|
|
||||||
"""Test specific login error messages."""
|
|
||||||
# Test non-existent user
|
|
||||||
response = client.post("/api/v1/users/login", json={
|
|
||||||
"username": "nonexistent",
|
|
||||||
"password": "password"
|
|
||||||
})
|
|
||||||
assert response.status_code == 401
|
|
||||||
assert "Username not found" in response.json()["detail"]
|
|
||||||
|
|
||||||
# Create user for testing wrong password
|
|
||||||
user_data = {
|
|
||||||
"username": "wrongpasstest",
|
|
||||||
"password": "correctpassword",
|
|
||||||
"role": "read_only"
|
|
||||||
}
|
|
||||||
client.post("/api/v1/users/", json=user_data)
|
|
||||||
|
|
||||||
# Test wrong password
|
|
||||||
response = client.post("/api/v1/users/login", json={
|
|
||||||
"username": "wrongpasstest",
|
|
||||||
"password": "wrongpassword"
|
|
||||||
})
|
|
||||||
assert response.status_code == 401
|
|
||||||
assert "Incorrect password" in response.json()["detail"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_duplicate_username_registration(client: TestClient):
|
|
||||||
"""Test that duplicate usernames are not allowed."""
|
|
||||||
user_data = {
|
|
||||||
"username": "duplicate",
|
|
||||||
"password": "password1",
|
|
||||||
"role": "read_only"
|
|
||||||
}
|
|
||||||
|
|
||||||
# First registration should succeed
|
|
||||||
response1 = client.post("/api/v1/users/", json=user_data)
|
|
||||||
assert response1.status_code == 201
|
|
||||||
|
|
||||||
# Second registration with same username should fail
|
|
||||||
user_data["password"] = "password2" # Different password, same username
|
|
||||||
response2 = client.post("/api/v1/users/", json=user_data)
|
|
||||||
assert response2.status_code == 400
|
|
||||||
assert "Username already registered" in response2.json()["detail"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_admin_can_delete_users(client: TestClient, admin_token: str):
|
|
||||||
"""Test that admin can delete user accounts."""
|
|
||||||
# Create user to delete
|
|
||||||
user_data = {
|
|
||||||
"username": "todelete",
|
|
||||||
"password": "testpassword",
|
|
||||||
"role": "read_only"
|
|
||||||
}
|
|
||||||
create_response = client.post("/api/v1/users/", json=user_data)
|
|
||||||
user_id = create_response.json()["id"]
|
|
||||||
|
|
||||||
# Admin deletes the user
|
|
||||||
response = client.delete(f"/api/v1/users/{user_id}",
|
|
||||||
headers={"Authorization": f"Bearer {admin_token}"})
|
|
||||||
assert response.status_code == 204
|
|
||||||
|
|
||||||
# Verify user is deleted - try to get user should return 404
|
|
||||||
get_response = client.get(f"/api/v1/users/{user_id}",
|
|
||||||
headers={"Authorization": f"Bearer {admin_token}"})
|
|
||||||
assert get_response.status_code == 404
|
|
||||||
|
|
||||||
|
|
||||||
def test_admin_cannot_delete_self(client: TestClient, admin_token: str):
|
|
||||||
"""Test that admin cannot delete their own account."""
|
|
||||||
# Get admin user info
|
|
||||||
me_response = client.get("/api/v1/users/me",
|
|
||||||
headers={"Authorization": f"Bearer {admin_token}"})
|
|
||||||
admin_user_id = me_response.json()["id"]
|
|
||||||
|
|
||||||
# Try to delete self - should fail
|
|
||||||
response = client.delete(f"/api/v1/users/{admin_user_id}",
|
|
||||||
headers={"Authorization": f"Bearer {admin_token}"})
|
|
||||||
assert response.status_code == 400
|
|
||||||
assert "Cannot delete your own account" in response.json()["detail"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_non_admin_cannot_delete_users(client: TestClient, admin_token: str):
|
|
||||||
"""Test that non-admin users cannot delete other users."""
|
|
||||||
# Create two users
|
|
||||||
user1_data = {
|
|
||||||
"username": "user1delete",
|
|
||||||
"password": "testpassword",
|
|
||||||
"role": "write"
|
|
||||||
}
|
|
||||||
user2_data = {
|
|
||||||
"username": "user2delete",
|
|
||||||
"password": "testpassword",
|
|
||||||
"role": "read_only"
|
|
||||||
}
|
|
||||||
|
|
||||||
create_response1 = client.post("/api/v1/users/", json=user1_data)
|
|
||||||
user1_id = create_response1.json()["id"]
|
|
||||||
|
|
||||||
create_response2 = client.post("/api/v1/users/", json=user2_data)
|
|
||||||
user2_id = create_response2.json()["id"]
|
|
||||||
|
|
||||||
# Admin approves user1
|
|
||||||
approval_data = {"is_approved": True}
|
|
||||||
client.put(f"/api/v1/users/{user1_id}/approval",
|
|
||||||
json=approval_data,
|
|
||||||
headers={"Authorization": f"Bearer {admin_token}"})
|
|
||||||
|
|
||||||
# User1 logs in
|
|
||||||
login_response = client.post("/api/v1/users/login", json={
|
|
||||||
"username": "user1delete",
|
|
||||||
"password": "testpassword"
|
|
||||||
})
|
|
||||||
user1_token = login_response.json()["access_token"]
|
|
||||||
|
|
||||||
# User1 tries to delete user2 - should fail
|
|
||||||
response = client.delete(f"/api/v1/users/{user2_id}",
|
|
||||||
headers={"Authorization": f"Bearer {user1_token}"})
|
|
||||||
assert response.status_code == 403
|
|
||||||
|
|
||||||
|
|
||||||
def test_delete_nonexistent_user(client: TestClient, admin_token: str):
|
|
||||||
"""Test deleting a user that doesn't exist."""
|
|
||||||
response = client.delete("/api/v1/users/99999",
|
|
||||||
headers={"Authorization": f"Bearer {admin_token}"})
|
|
||||||
assert response.status_code == 404
|
|
||||||
assert "User not found" in response.json()["detail"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_admin_can_update_user_details(client: TestClient, admin_token: str):
|
|
||||||
"""Test that admin can update user details."""
|
|
||||||
# Create user to update
|
|
||||||
user_data = {
|
|
||||||
"username": "toupdate",
|
|
||||||
"password": "originalpassword",
|
|
||||||
"role": "read_only"
|
|
||||||
}
|
|
||||||
create_response = client.post("/api/v1/users/", json=user_data)
|
|
||||||
user_id = create_response.json()["id"]
|
|
||||||
|
|
||||||
# Admin updates the user
|
|
||||||
update_data = {
|
|
||||||
"username": "updated_username",
|
|
||||||
"role": "write"
|
|
||||||
}
|
|
||||||
response = client.put(f"/api/v1/users/{user_id}",
|
|
||||||
json=update_data,
|
|
||||||
headers={"Authorization": f"Bearer {admin_token}"})
|
|
||||||
assert response.status_code == 200
|
|
||||||
data = response.json()
|
|
||||||
assert data["username"] == "updated_username"
|
|
||||||
assert data["role"] == "write"
|
|
||||||
assert data["is_approved"] == False # Should remain unchanged
|
|
||||||
|
|
||||||
|
|
||||||
def test_admin_can_update_user_password(client: TestClient, admin_token: str):
|
|
||||||
"""Test that admin can update user password."""
|
|
||||||
# Create and approve user
|
|
||||||
user_data = {
|
|
||||||
"username": "passwordupdate",
|
|
||||||
"password": "oldpassword",
|
|
||||||
"role": "read_only"
|
|
||||||
}
|
|
||||||
create_response = client.post("/api/v1/users/", json=user_data)
|
|
||||||
user_id = create_response.json()["id"]
|
|
||||||
|
|
||||||
# Approve user first
|
|
||||||
approval_data = {"is_approved": True}
|
|
||||||
client.put(f"/api/v1/users/{user_id}/approval",
|
|
||||||
json=approval_data,
|
|
||||||
headers={"Authorization": f"Bearer {admin_token}"})
|
|
||||||
|
|
||||||
# Verify login works with old password
|
|
||||||
login_response = client.post("/api/v1/users/login", json={
|
|
||||||
"username": "passwordupdate",
|
|
||||||
"password": "oldpassword"
|
|
||||||
})
|
|
||||||
assert login_response.status_code == 200
|
|
||||||
|
|
||||||
# Admin updates password
|
|
||||||
update_data = {
|
|
||||||
"password": "newpassword"
|
|
||||||
}
|
|
||||||
response = client.put(f"/api/v1/users/{user_id}",
|
|
||||||
json=update_data,
|
|
||||||
headers={"Authorization": f"Bearer {admin_token}"})
|
|
||||||
assert response.status_code == 200
|
|
||||||
|
|
||||||
# Verify old password no longer works
|
|
||||||
old_login_response = client.post("/api/v1/users/login", json={
|
|
||||||
"username": "passwordupdate",
|
|
||||||
"password": "oldpassword"
|
|
||||||
})
|
|
||||||
assert old_login_response.status_code == 401
|
|
||||||
|
|
||||||
# Verify new password works
|
|
||||||
new_login_response = client.post("/api/v1/users/login", json={
|
|
||||||
"username": "passwordupdate",
|
|
||||||
"password": "newpassword"
|
|
||||||
})
|
|
||||||
assert new_login_response.status_code == 200
|
|
||||||
|
|
||||||
|
|
||||||
def test_partial_user_update(client: TestClient, admin_token: str):
|
|
||||||
"""Test partial user updates (only some fields)."""
|
|
||||||
# Create user
|
|
||||||
user_data = {
|
|
||||||
"username": "partialupdate",
|
|
||||||
"password": "password123",
|
|
||||||
"role": "read_only"
|
|
||||||
}
|
|
||||||
create_response = client.post("/api/v1/users/", json=user_data)
|
|
||||||
user_id = create_response.json()["id"]
|
|
||||||
original_username = create_response.json()["username"]
|
|
||||||
|
|
||||||
# Update only role
|
|
||||||
update_data = {
|
|
||||||
"role": "write"
|
|
||||||
}
|
|
||||||
response = client.put(f"/api/v1/users/{user_id}",
|
|
||||||
json=update_data,
|
|
||||||
headers={"Authorization": f"Bearer {admin_token}"})
|
|
||||||
assert response.status_code == 200
|
|
||||||
data = response.json()
|
|
||||||
assert data["username"] == original_username # Should remain unchanged
|
|
||||||
assert data["role"] == "write" # Should be updated
|
|
||||||
|
|
||||||
|
|
||||||
def test_non_admin_cannot_update_users(client: TestClient, admin_token: str):
|
|
||||||
"""Test that non-admin users cannot update other users."""
|
|
||||||
# Create two users
|
|
||||||
user1_data = {
|
|
||||||
"username": "user1update",
|
|
||||||
"password": "testpassword",
|
|
||||||
"role": "write"
|
|
||||||
}
|
|
||||||
user2_data = {
|
|
||||||
"username": "user2update",
|
|
||||||
"password": "testpassword",
|
|
||||||
"role": "read_only"
|
|
||||||
}
|
|
||||||
|
|
||||||
create_response1 = client.post("/api/v1/users/", json=user1_data)
|
|
||||||
user1_id = create_response1.json()["id"]
|
|
||||||
|
|
||||||
create_response2 = client.post("/api/v1/users/", json=user2_data)
|
|
||||||
user2_id = create_response2.json()["id"]
|
|
||||||
|
|
||||||
# Admin approves user1
|
|
||||||
approval_data = {"is_approved": True}
|
|
||||||
client.put(f"/api/v1/users/{user1_id}/approval",
|
|
||||||
json=approval_data,
|
|
||||||
headers={"Authorization": f"Bearer {admin_token}"})
|
|
||||||
|
|
||||||
# User1 logs in
|
|
||||||
login_response = client.post("/api/v1/users/login", json={
|
|
||||||
"username": "user1update",
|
|
||||||
"password": "testpassword"
|
|
||||||
})
|
|
||||||
user1_token = login_response.json()["access_token"]
|
|
||||||
|
|
||||||
# User1 tries to update user2 - should fail
|
|
||||||
update_data = {"role": "admin"}
|
|
||||||
response = client.put(f"/api/v1/users/{user2_id}",
|
|
||||||
json=update_data,
|
|
||||||
headers={"Authorization": f"Bearer {user1_token}"})
|
|
||||||
assert response.status_code == 403
|
|
||||||
|
|
||||||
|
|
||||||
def test_update_nonexistent_user(client: TestClient, admin_token: str):
|
|
||||||
"""Test updating a user that doesn't exist."""
|
|
||||||
update_data = {
|
|
||||||
"username": "newname",
|
|
||||||
"role": "write"
|
|
||||||
}
|
|
||||||
response = client.put("/api/v1/users/99999",
|
|
||||||
json=update_data,
|
|
||||||
headers={"Authorization": f"Bearer {admin_token}"})
|
|
||||||
assert response.status_code == 404
|
|
||||||
assert "User not found" in response.json()["detail"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_update_user_with_duplicate_username(client: TestClient, admin_token: str):
|
|
||||||
"""Test that updating a user with an existing username fails."""
|
|
||||||
# Create two users
|
|
||||||
user1_data = {
|
|
||||||
"username": "user1unique",
|
|
||||||
"password": "password1",
|
|
||||||
"role": "read_only"
|
|
||||||
}
|
|
||||||
user2_data = {
|
|
||||||
"username": "user2unique",
|
|
||||||
"password": "password2",
|
|
||||||
"role": "read_only"
|
|
||||||
}
|
|
||||||
|
|
||||||
create_response1 = client.post("/api/v1/users/", json=user1_data)
|
|
||||||
user1_id = create_response1.json()["id"]
|
|
||||||
|
|
||||||
client.post("/api/v1/users/", json=user2_data)
|
|
||||||
|
|
||||||
# Try to update user1 to have user2's username
|
|
||||||
update_data = {
|
|
||||||
"username": "user2unique"
|
|
||||||
}
|
|
||||||
response = client.put(f"/api/v1/users/{user1_id}",
|
|
||||||
json=update_data,
|
|
||||||
headers={"Authorization": f"Bearer {admin_token}"})
|
|
||||||
# This should fail - but we need to implement this validation in the endpoint
|
|
||||||
# For now, let's just check if it fails with any 4xx error
|
|
||||||
assert response.status_code >= 400
|
|
||||||
|
|
||||||
|
|
||||||
def test_user_can_change_own_password(client: TestClient, admin_token: str):
|
|
||||||
"""Test that users can change their own password."""
|
|
||||||
# Create and approve user
|
|
||||||
user_data = {
|
|
||||||
"username": "selfpasschange",
|
|
||||||
"password": "oldpassword123",
|
|
||||||
"role": "read_only"
|
|
||||||
}
|
|
||||||
create_response = client.post("/api/v1/users/", json=user_data)
|
|
||||||
user_id = create_response.json()["id"]
|
|
||||||
|
|
||||||
# Admin approves the user
|
|
||||||
approval_data = {"is_approved": True}
|
|
||||||
client.put(f"/api/v1/users/{user_id}/approval",
|
|
||||||
json=approval_data,
|
|
||||||
headers={"Authorization": f"Bearer {admin_token}"})
|
|
||||||
|
|
||||||
# User logs in
|
|
||||||
login_response = client.post("/api/v1/users/login", json={
|
|
||||||
"username": "selfpasschange",
|
|
||||||
"password": "oldpassword123"
|
|
||||||
})
|
|
||||||
user_token = login_response.json()["access_token"]
|
|
||||||
|
|
||||||
# User changes their own password
|
|
||||||
password_change_data = {
|
|
||||||
"current_password": "oldpassword123",
|
|
||||||
"new_password": "newpassword456"
|
|
||||||
}
|
|
||||||
response = client.put("/api/v1/users/me/change-password",
|
|
||||||
json=password_change_data,
|
|
||||||
headers={"Authorization": f"Bearer {user_token}"})
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert "Password changed successfully" in response.json()["message"]
|
|
||||||
|
|
||||||
# Verify old password no longer works
|
|
||||||
old_login_response = client.post("/api/v1/users/login", json={
|
|
||||||
"username": "selfpasschange",
|
|
||||||
"password": "oldpassword123"
|
|
||||||
})
|
|
||||||
assert old_login_response.status_code == 401
|
|
||||||
|
|
||||||
# Verify new password works
|
|
||||||
new_login_response = client.post("/api/v1/users/login", json={
|
|
||||||
"username": "selfpasschange",
|
|
||||||
"password": "newpassword456"
|
|
||||||
})
|
|
||||||
assert new_login_response.status_code == 200
|
|
||||||
|
|
||||||
|
|
||||||
def test_password_change_with_wrong_current_password(client: TestClient, admin_token: str):
|
|
||||||
"""Test that password change fails with incorrect current password."""
|
|
||||||
# Create and approve user
|
|
||||||
user_data = {
|
|
||||||
"username": "wrongpasstest",
|
|
||||||
"password": "correctpassword",
|
|
||||||
"role": "read_only"
|
|
||||||
}
|
|
||||||
create_response = client.post("/api/v1/users/", json=user_data)
|
|
||||||
user_id = create_response.json()["id"]
|
|
||||||
|
|
||||||
# Admin approves the user
|
|
||||||
approval_data = {"is_approved": True}
|
|
||||||
client.put(f"/api/v1/users/{user_id}/approval",
|
|
||||||
json=approval_data,
|
|
||||||
headers={"Authorization": f"Bearer {admin_token}"})
|
|
||||||
|
|
||||||
# User logs in
|
|
||||||
login_response = client.post("/api/v1/users/login", json={
|
|
||||||
"username": "wrongpasstest",
|
|
||||||
"password": "correctpassword"
|
|
||||||
})
|
|
||||||
user_token = login_response.json()["access_token"]
|
|
||||||
|
|
||||||
# Try to change password with wrong current password
|
|
||||||
password_change_data = {
|
|
||||||
"current_password": "wrongpassword",
|
|
||||||
"new_password": "newpassword456"
|
|
||||||
}
|
|
||||||
response = client.put("/api/v1/users/me/change-password",
|
|
||||||
json=password_change_data,
|
|
||||||
headers={"Authorization": f"Bearer {user_token}"})
|
|
||||||
assert response.status_code == 400
|
|
||||||
assert "Current password is incorrect" in response.json()["detail"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_email_verification_password_reset_request(client: TestClient):
|
|
||||||
"""Test password reset request via email verification."""
|
|
||||||
# Create user
|
|
||||||
user_data = {
|
|
||||||
"username": "emailresettest",
|
|
||||||
"password": "password123",
|
|
||||||
"role": "read_only"
|
|
||||||
}
|
|
||||||
client.post("/api/v1/users/", json=user_data)
|
|
||||||
|
|
||||||
# Request password reset (should always return success)
|
|
||||||
reset_request_data = {
|
|
||||||
"username": "emailresettest",
|
|
||||||
"email": "user@example.com"
|
|
||||||
}
|
|
||||||
response = client.post("/api/v1/users/request-password-reset",
|
|
||||||
json=reset_request_data)
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert "receive instructions" in response.json()["message"]
|
|
||||||
|
|
||||||
# Test with non-existent user (should also return success for security)
|
|
||||||
reset_request_data = {
|
|
||||||
"username": "nonexistentuser",
|
|
||||||
"email": "fake@example.com"
|
|
||||||
}
|
|
||||||
response = client.post("/api/v1/users/request-password-reset",
|
|
||||||
json=reset_request_data)
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert "receive instructions" in response.json()["message"]
|
|
||||||
@@ -1,219 +0,0 @@
|
|||||||
import pytest
|
|
||||||
from fastapi.testclient import TestClient
|
|
||||||
from sqlmodel import Session, SQLModel, create_engine
|
|
||||||
from sqlmodel.pool import StaticPool
|
|
||||||
|
|
||||||
from app.main import app
|
|
||||||
from app.core.db import get_session
|
|
||||||
from app.schemas.models import User, Partner, Product, Transaction
|
|
||||||
from app.schemas.base import UserRole, PartnerType, TransactionType, TransactionStatus
|
|
||||||
from app.core.auth import get_password_hash
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(name="session")
|
|
||||||
def session_fixture():
|
|
||||||
"""Create a test database session."""
|
|
||||||
engine = create_engine(
|
|
||||||
"sqlite:///:memory:", # Use in-memory database for each test
|
|
||||||
connect_args={"check_same_thread": False},
|
|
||||||
poolclass=StaticPool,
|
|
||||||
)
|
|
||||||
SQLModel.metadata.create_all(engine)
|
|
||||||
with Session(engine) as session:
|
|
||||||
yield session
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(name="client")
|
|
||||||
def client_fixture(session: Session):
|
|
||||||
"""Create a test client with dependency override."""
|
|
||||||
def get_session_override():
|
|
||||||
return session
|
|
||||||
|
|
||||||
app.dependency_overrides[get_session] = get_session_override
|
|
||||||
client = TestClient(app)
|
|
||||||
yield client
|
|
||||||
app.dependency_overrides.clear()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(name="admin_user")
|
|
||||||
def admin_user_fixture(session: Session):
|
|
||||||
"""Create an admin user for testing."""
|
|
||||||
admin_user = User(
|
|
||||||
username="testadmin",
|
|
||||||
password_hash=get_password_hash("adminpassword"),
|
|
||||||
role=UserRole.ADMIN,
|
|
||||||
is_approved=True # Admin users are pre-approved for testing
|
|
||||||
)
|
|
||||||
session.add(admin_user)
|
|
||||||
session.commit()
|
|
||||||
session.refresh(admin_user)
|
|
||||||
return admin_user
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(name="write_user")
|
|
||||||
def write_user_fixture(session: Session):
|
|
||||||
"""Create a write user for testing."""
|
|
||||||
write_user = User(
|
|
||||||
username="writeuser",
|
|
||||||
password_hash=get_password_hash("writepassword"),
|
|
||||||
role=UserRole.WRITE,
|
|
||||||
is_approved=True # Pre-approved for testing
|
|
||||||
)
|
|
||||||
session.add(write_user)
|
|
||||||
session.commit()
|
|
||||||
session.refresh(write_user)
|
|
||||||
return write_user
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(name="read_only_user")
|
|
||||||
def read_only_user_fixture(session: Session):
|
|
||||||
"""Create a read-only user for testing."""
|
|
||||||
read_only_user = User(
|
|
||||||
username="readuser",
|
|
||||||
password_hash=get_password_hash("readpassword"),
|
|
||||||
role=UserRole.READ_ONLY,
|
|
||||||
is_approved=True # Pre-approved for testing
|
|
||||||
)
|
|
||||||
session.add(read_only_user)
|
|
||||||
session.commit()
|
|
||||||
session.refresh(read_only_user)
|
|
||||||
return read_only_user
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(name="admin_token")
|
|
||||||
def admin_token_fixture(client: TestClient, admin_user: User):
|
|
||||||
"""Get admin authentication token."""
|
|
||||||
response = client.post("/api/v1/users/login", json={
|
|
||||||
"username": "testadmin",
|
|
||||||
"password": "adminpassword"
|
|
||||||
})
|
|
||||||
return response.json()["access_token"]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(name="write_token")
|
|
||||||
def write_token_fixture(client: TestClient, write_user: User):
|
|
||||||
"""Get write user authentication token."""
|
|
||||||
response = client.post("/api/v1/users/login", json={
|
|
||||||
"username": "writeuser",
|
|
||||||
"password": "writepassword"
|
|
||||||
})
|
|
||||||
return response.json()["access_token"]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(name="read_only_token")
|
|
||||||
def read_only_token_fixture(client: TestClient, read_only_user: User):
|
|
||||||
"""Get read-only user authentication token."""
|
|
||||||
response = client.post("/api/v1/users/login", json={
|
|
||||||
"username": "readuser",
|
|
||||||
"password": "readpassword"
|
|
||||||
})
|
|
||||||
return response.json()["access_token"]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(name="sample_partner")
|
|
||||||
def sample_partner_fixture(session: Session):
|
|
||||||
"""Create a sample partner for testing."""
|
|
||||||
partner = Partner(
|
|
||||||
tin_number=123456789,
|
|
||||||
names="Test Partner Ltd",
|
|
||||||
type=PartnerType.CLIENT,
|
|
||||||
phone_number="0123456789"
|
|
||||||
)
|
|
||||||
session.add(partner)
|
|
||||||
session.commit()
|
|
||||||
session.refresh(partner)
|
|
||||||
return partner
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(name="sample_product")
|
|
||||||
def sample_product_fixture(session: Session):
|
|
||||||
"""Create a sample product for testing."""
|
|
||||||
product = Product(
|
|
||||||
product_code="PROD001",
|
|
||||||
product_name="Test Product",
|
|
||||||
purchase_price=100,
|
|
||||||
selling_price=150
|
|
||||||
)
|
|
||||||
session.add(product)
|
|
||||||
session.commit()
|
|
||||||
session.refresh(product)
|
|
||||||
return product
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(name="sample_transaction")
|
|
||||||
def sample_transaction_fixture(session: Session, sample_partner, admin_user):
|
|
||||||
"""Create a sample transaction for testing."""
|
|
||||||
transaction = Transaction(
|
|
||||||
partner_id=sample_partner.id,
|
|
||||||
transcation_type=TransactionType.SALE,
|
|
||||||
transaction_status=TransactionStatus.UNPAID,
|
|
||||||
total_amount=1000,
|
|
||||||
created_by=admin_user.id,
|
|
||||||
updated_by=admin_user.id
|
|
||||||
)
|
|
||||||
session.add(transaction)
|
|
||||||
session.commit()
|
|
||||||
session.refresh(transaction)
|
|
||||||
return transaction
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(name="multiple_partners")
|
|
||||||
def multiple_partners_fixture(session: Session):
|
|
||||||
"""Create multiple partners for testing."""
|
|
||||||
partners = [
|
|
||||||
Partner(
|
|
||||||
tin_number=100000001,
|
|
||||||
names="Client Partner One",
|
|
||||||
type=PartnerType.CLIENT,
|
|
||||||
phone_number="0111111111"
|
|
||||||
),
|
|
||||||
Partner(
|
|
||||||
tin_number=200000002,
|
|
||||||
names="Supplier Partner Two",
|
|
||||||
type=PartnerType.SUPPLIER,
|
|
||||||
phone_number="0222222222"
|
|
||||||
),
|
|
||||||
Partner(
|
|
||||||
tin_number=300000003,
|
|
||||||
names="Client Partner Three",
|
|
||||||
type=PartnerType.CLIENT,
|
|
||||||
phone_number="0333333333"
|
|
||||||
)
|
|
||||||
]
|
|
||||||
for partner in partners:
|
|
||||||
session.add(partner)
|
|
||||||
session.commit()
|
|
||||||
for partner in partners:
|
|
||||||
session.refresh(partner)
|
|
||||||
return partners
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(name="multiple_products")
|
|
||||||
def multiple_products_fixture(session: Session):
|
|
||||||
"""Create multiple products for testing."""
|
|
||||||
products = [
|
|
||||||
Product(
|
|
||||||
product_code="ITEM001",
|
|
||||||
product_name="Product One",
|
|
||||||
purchase_price=50,
|
|
||||||
selling_price=75
|
|
||||||
),
|
|
||||||
Product(
|
|
||||||
product_code="ITEM002",
|
|
||||||
product_name="Product Two",
|
|
||||||
purchase_price=200,
|
|
||||||
selling_price=250
|
|
||||||
),
|
|
||||||
Product(
|
|
||||||
product_code="ITEM003",
|
|
||||||
product_name="Product Three",
|
|
||||||
purchase_price=1000,
|
|
||||||
selling_price=1200
|
|
||||||
)
|
|
||||||
]
|
|
||||||
for product in products:
|
|
||||||
session.add(product)
|
|
||||||
session.commit()
|
|
||||||
for product in products:
|
|
||||||
session.refresh(product)
|
|
||||||
return products
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
import pytest
|
|
||||||
from app.core.auth import get_password_hash, verify_password, create_access_token
|
|
||||||
from app.schemas.base import UserRole
|
|
||||||
|
|
||||||
|
|
||||||
def test_password_hashing():
|
|
||||||
"""Test password hashing and verification."""
|
|
||||||
password = "testpassword123"
|
|
||||||
hashed = get_password_hash(password)
|
|
||||||
|
|
||||||
assert hashed != password
|
|
||||||
assert verify_password(password, hashed) is True
|
|
||||||
assert verify_password("wrongpassword", hashed) is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_create_access_token():
|
|
||||||
"""Test JWT token creation."""
|
|
||||||
data = {"sub": "testuser", "user_id": 1, "role": UserRole.ADMIN}
|
|
||||||
token = create_access_token(data, expires_delta=None)
|
|
||||||
|
|
||||||
assert isinstance(token, str)
|
|
||||||
assert len(token) > 0
|
|
||||||
@@ -1,253 +0,0 @@
|
|||||||
"""Integration test configuration and fixtures.
|
|
||||||
|
|
||||||
This module provides fixtures and utilities for integration testing that involve
|
|
||||||
real database operations, Alembic migrations, and end-to-end API testing.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
import tempfile
|
|
||||||
import os
|
|
||||||
from pathlib import Path
|
|
||||||
from sqlmodel import Session, SQLModel, create_engine, select, text
|
|
||||||
from sqlmodel.pool import StaticPool
|
|
||||||
from fastapi.testclient import TestClient
|
|
||||||
from alembic import command
|
|
||||||
from alembic.config import Config
|
|
||||||
from alembic.script import ScriptDirectory
|
|
||||||
from alembic.runtime.environment import EnvironmentContext
|
|
||||||
|
|
||||||
from app.main import app
|
|
||||||
from app.core.db import get_session
|
|
||||||
from app.core.config import settings
|
|
||||||
from app.schemas.models import User, Partner, Product
|
|
||||||
from app.schemas.base import UserRole
|
|
||||||
from app.core.auth import get_password_hash
|
|
||||||
|
|
||||||
|
|
||||||
class IntegrationTestConfig:
|
|
||||||
"""Configuration for integration tests."""
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def get_test_database_url():
|
|
||||||
"""Get test database URL. Uses a separate test database."""
|
|
||||||
# Use in-memory SQLite for integration tests to ensure writeability
|
|
||||||
return "sqlite:///:memory:"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(name="integration_engine", scope="session")
|
|
||||||
def integration_engine_fixture():
|
|
||||||
"""Create a test engine for integration tests."""
|
|
||||||
database_url = IntegrationTestConfig.get_test_database_url()
|
|
||||||
|
|
||||||
if database_url.startswith("sqlite"):
|
|
||||||
# For SQLite, use file-based database for integration tests
|
|
||||||
engine = create_engine(
|
|
||||||
database_url,
|
|
||||||
connect_args={"check_same_thread": False},
|
|
||||||
echo=False # Set to True for SQL debugging
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
# For PostgreSQL
|
|
||||||
engine = create_engine(database_url, echo=False)
|
|
||||||
|
|
||||||
return engine
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(name="integration_session", scope="function")
|
|
||||||
def integration_session_fixture(integration_engine):
|
|
||||||
"""Create a database session for integration tests with proper cleanup."""
|
|
||||||
# Create all tables
|
|
||||||
SQLModel.metadata.create_all(integration_engine)
|
|
||||||
|
|
||||||
with Session(integration_engine) as session:
|
|
||||||
yield session
|
|
||||||
|
|
||||||
# Clean up: drop all tables after each test
|
|
||||||
SQLModel.metadata.drop_all(integration_engine)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(name="integration_client")
|
|
||||||
def integration_client_fixture(integration_session):
|
|
||||||
"""Create a test client with integration database session."""
|
|
||||||
def get_session_override():
|
|
||||||
return integration_session
|
|
||||||
|
|
||||||
app.dependency_overrides[get_session] = get_session_override
|
|
||||||
client = TestClient(app)
|
|
||||||
yield client
|
|
||||||
app.dependency_overrides.clear()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(name="alembic_config")
|
|
||||||
def alembic_config_fixture(integration_engine):
|
|
||||||
"""Create Alembic configuration for migration testing."""
|
|
||||||
# Create a temporary alembic.ini for testing
|
|
||||||
config = Config()
|
|
||||||
config.set_main_option("script_location", "app/alembic")
|
|
||||||
config.set_main_option("sqlalchemy.url", str(integration_engine.url))
|
|
||||||
|
|
||||||
return config
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(name="migration_context")
|
|
||||||
def migration_context_fixture(integration_engine, alembic_config):
|
|
||||||
"""Create migration context for testing migrations."""
|
|
||||||
script = ScriptDirectory.from_config(alembic_config)
|
|
||||||
|
|
||||||
def run_migrations(connection, config):
|
|
||||||
context = EnvironmentContext(config, script)
|
|
||||||
context.configure(
|
|
||||||
connection=connection,
|
|
||||||
target_metadata=SQLModel.metadata
|
|
||||||
)
|
|
||||||
with context.begin_transaction():
|
|
||||||
context.run_migrations()
|
|
||||||
|
|
||||||
with integration_engine.connect() as connection:
|
|
||||||
yield {
|
|
||||||
'connection': connection,
|
|
||||||
'config': alembic_config,
|
|
||||||
'script': script,
|
|
||||||
'run_migrations': lambda: run_migrations(connection, alembic_config)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(name="integration_admin_user")
|
|
||||||
def integration_admin_user_fixture(integration_session):
|
|
||||||
"""Create an admin user for integration tests."""
|
|
||||||
admin_user = User(
|
|
||||||
username="integration_admin",
|
|
||||||
password_hash=get_password_hash("admin_password"),
|
|
||||||
role=UserRole.ADMIN
|
|
||||||
)
|
|
||||||
integration_session.add(admin_user)
|
|
||||||
integration_session.commit()
|
|
||||||
integration_session.refresh(admin_user)
|
|
||||||
return admin_user
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(name="integration_write_user")
|
|
||||||
def integration_write_user_fixture(integration_session):
|
|
||||||
"""Create a write user for integration tests."""
|
|
||||||
write_user = User(
|
|
||||||
username="integration_write",
|
|
||||||
password_hash=get_password_hash("write_password"),
|
|
||||||
role=UserRole.WRITE
|
|
||||||
)
|
|
||||||
integration_session.add(write_user)
|
|
||||||
integration_session.commit()
|
|
||||||
integration_session.refresh(write_user)
|
|
||||||
return write_user
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(name="integration_read_user")
|
|
||||||
def integration_read_user_fixture(integration_session):
|
|
||||||
"""Create a read-only user for integration tests."""
|
|
||||||
read_user = User(
|
|
||||||
username="integration_read",
|
|
||||||
password_hash=get_password_hash("read_password"),
|
|
||||||
role=UserRole.READ_ONLY
|
|
||||||
)
|
|
||||||
integration_session.add(read_user)
|
|
||||||
integration_session.commit()
|
|
||||||
integration_session.refresh(read_user)
|
|
||||||
return read_user
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(name="integration_admin_token")
|
|
||||||
def integration_admin_token_fixture(integration_client, integration_admin_user):
|
|
||||||
"""Get admin authentication headers for integration tests."""
|
|
||||||
response = integration_client.post("/api/v1/users/login", json={
|
|
||||||
"username": "integration_admin",
|
|
||||||
"password": "admin_password"
|
|
||||||
})
|
|
||||||
assert response.status_code == 200
|
|
||||||
token = response.json()["access_token"]
|
|
||||||
return {"Authorization": f"Bearer {token}"}
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(name="integration_write_token")
|
|
||||||
def integration_write_token_fixture(integration_client, integration_write_user):
|
|
||||||
"""Get write user authentication headers for integration tests."""
|
|
||||||
response = integration_client.post("/api/v1/users/login", json={
|
|
||||||
"username": "integration_write",
|
|
||||||
"password": "write_password"
|
|
||||||
})
|
|
||||||
assert response.status_code == 200
|
|
||||||
token = response.json()["access_token"]
|
|
||||||
return {"Authorization": f"Bearer {token}"}
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(name="integration_read_token")
|
|
||||||
def integration_read_token_fixture(integration_client, integration_read_user):
|
|
||||||
"""Get read-only user authentication headers for integration tests."""
|
|
||||||
response = integration_client.post("/api/v1/users/login", json={
|
|
||||||
"username": "integration_read",
|
|
||||||
"password": "read_password"
|
|
||||||
})
|
|
||||||
assert response.status_code == 200
|
|
||||||
token = response.json()["access_token"]
|
|
||||||
return {"Authorization": f"Bearer {token}"}
|
|
||||||
|
|
||||||
|
|
||||||
def cleanup_test_database():
|
|
||||||
"""Utility function to clean up test database files."""
|
|
||||||
test_db_files = ["test_integration.db", "test_integration.db-wal", "test_integration.db-shm"]
|
|
||||||
for file_name in test_db_files:
|
|
||||||
if os.path.exists(file_name):
|
|
||||||
try:
|
|
||||||
os.remove(file_name)
|
|
||||||
except OSError:
|
|
||||||
pass # File might be in use or already deleted
|
|
||||||
|
|
||||||
|
|
||||||
def verify_database_integrity(session: Session) -> dict:
|
|
||||||
"""Verify database integrity and return diagnostics."""
|
|
||||||
try:
|
|
||||||
# Check if we can query basic tables using SQLModel queries
|
|
||||||
users = session.exec(select(User)).all()
|
|
||||||
partners = session.exec(select(Partner)).all()
|
|
||||||
products = session.exec(select(Product)).all()
|
|
||||||
|
|
||||||
return {
|
|
||||||
"status": "healthy",
|
|
||||||
"users": len(users),
|
|
||||||
"partners": len(partners),
|
|
||||||
"products": len(products),
|
|
||||||
"tables_accessible": True
|
|
||||||
}
|
|
||||||
except Exception as e:
|
|
||||||
return {
|
|
||||||
"status": "error",
|
|
||||||
"error": str(e),
|
|
||||||
"tables_accessible": False
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# Pytest configuration for integration tests
|
|
||||||
def pytest_configure(config):
|
|
||||||
"""Configure pytest for integration tests."""
|
|
||||||
config.addinivalue_line(
|
|
||||||
"markers", "integration: mark test as integration test"
|
|
||||||
)
|
|
||||||
config.addinivalue_line(
|
|
||||||
"markers", "slow: mark test as slow running"
|
|
||||||
)
|
|
||||||
config.addinivalue_line(
|
|
||||||
"markers", "database: mark test as requiring database"
|
|
||||||
)
|
|
||||||
config.addinivalue_line(
|
|
||||||
"markers", "migration: mark test as testing migrations"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def pytest_runtest_setup(item):
|
|
||||||
"""Setup for each integration test."""
|
|
||||||
# Clean up any leftover test database files
|
|
||||||
cleanup_test_database()
|
|
||||||
|
|
||||||
|
|
||||||
def pytest_runtest_teardown(item):
|
|
||||||
"""Teardown for each integration test."""
|
|
||||||
# Clean up test database files after each test
|
|
||||||
cleanup_test_database()
|
|
||||||
@@ -1,376 +0,0 @@
|
|||||||
"""Integration tests for API endpoints with database interactions."""
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from fastapi.testclient import TestClient
|
|
||||||
from sqlmodel import Session, select
|
|
||||||
|
|
||||||
from app.schemas.models import User, Partner, Product, Transaction, Credit, Inventory, Payment
|
|
||||||
from app.schemas.base import UserRole, PartnerType, TransactionType, TransactionStatus, PaymentMethod
|
|
||||||
|
|
||||||
|
|
||||||
class TestUserAPIIntegration:
|
|
||||||
"""Test User API endpoints with database integration."""
|
|
||||||
|
|
||||||
def test_create_user_endpoint_with_database(self, integration_client: TestClient, integration_session: Session, integration_admin_token):
|
|
||||||
"""Test creating a user through API endpoint and verifying database storage."""
|
|
||||||
user_data = {
|
|
||||||
"username": "api_test_user",
|
|
||||||
"password": "test_password",
|
|
||||||
"role": "READ_ONLY"
|
|
||||||
}
|
|
||||||
|
|
||||||
response = integration_client.post("/api/v1/users/", json=user_data, headers=integration_admin_token)
|
|
||||||
assert response.status_code == 201
|
|
||||||
|
|
||||||
created_user = response.json()
|
|
||||||
assert created_user["username"] == "api_test_user"
|
|
||||||
assert created_user["role"] == "read_only"
|
|
||||||
assert "id" in created_user
|
|
||||||
|
|
||||||
def test_get_user_endpoint_with_database(self, integration_client: TestClient, integration_session: Session, integration_admin_token):
|
|
||||||
"""Test retrieving a user through API endpoint from database."""
|
|
||||||
# Create user directly in database
|
|
||||||
user = User(username="db_user", password_hash="hashed", role=UserRole.ADMIN)
|
|
||||||
integration_session.add(user)
|
|
||||||
integration_session.commit()
|
|
||||||
integration_session.refresh(user)
|
|
||||||
|
|
||||||
# Retrieve through API
|
|
||||||
response = integration_client.get(f"/api/v1/users/{user.id}", headers=integration_admin_token)
|
|
||||||
assert response.status_code == 200
|
|
||||||
|
|
||||||
returned_user = response.json()
|
|
||||||
assert returned_user["username"] == "db_user"
|
|
||||||
assert returned_user["role"] == "admin"
|
|
||||||
|
|
||||||
def test_update_user_endpoint_with_database(self, integration_client: TestClient, integration_session: Session, integration_admin_token):
|
|
||||||
"""Test updating a user through API endpoint and verifying database changes."""
|
|
||||||
# Create user directly in database
|
|
||||||
user = User(username="update_user", password_hash="hashed", role=UserRole.READ_ONLY)
|
|
||||||
integration_session.add(user)
|
|
||||||
integration_session.commit()
|
|
||||||
integration_session.refresh(user)
|
|
||||||
|
|
||||||
# Update through API
|
|
||||||
update_data = {"role": "write"}
|
|
||||||
response = integration_client.put(f"/api/v1/users/{user.id}", json=update_data, headers=integration_admin_token)
|
|
||||||
assert response.status_code == 200
|
|
||||||
|
|
||||||
# Verify in database
|
|
||||||
integration_session.refresh(user)
|
|
||||||
assert user.role == UserRole.WRITE
|
|
||||||
|
|
||||||
def test_delete_user_endpoint_with_database(self, integration_client: TestClient, integration_session: Session, integration_admin_token):
|
|
||||||
"""Test deleting a user through API endpoint and verifying database removal."""
|
|
||||||
# Create user directly in database
|
|
||||||
user = User(username="delete_user", password_hash="hashed", role=UserRole.READ_ONLY)
|
|
||||||
integration_session.add(user)
|
|
||||||
integration_session.commit()
|
|
||||||
user_id = user.id
|
|
||||||
|
|
||||||
# Delete through API
|
|
||||||
response = integration_client.delete(f"/api/v1/users/{user_id}", headers=integration_admin_token)
|
|
||||||
assert response.status_code == 200
|
|
||||||
|
|
||||||
# Verify removed from database
|
|
||||||
deleted_user = integration_session.get(User, user_id)
|
|
||||||
assert deleted_user is None
|
|
||||||
|
|
||||||
|
|
||||||
class TestPartnerAPIIntegration:
|
|
||||||
"""Test Partner API endpoints with database integration."""
|
|
||||||
|
|
||||||
def test_create_partner_endpoint_with_database(self, integration_client: TestClient, integration_session: Session, integration_admin_token):
|
|
||||||
"""Test creating a partner through API endpoint and verifying database storage."""
|
|
||||||
partner_data = {
|
|
||||||
"tin_number": 123456789,
|
|
||||||
"names": "Test Partner Co.",
|
|
||||||
"type": "SUPPLIER",
|
|
||||||
"phone_number": "1234567890"
|
|
||||||
}
|
|
||||||
|
|
||||||
response = integration_client.post("/api/v1/partners/", json=partner_data, headers=integration_admin_token)
|
|
||||||
assert response.status_code == 201
|
|
||||||
|
|
||||||
created_partner = response.json()
|
|
||||||
assert created_partner["tin_number"] == 123456789
|
|
||||||
assert created_partner["names"] == "Test Partner Co."
|
|
||||||
assert created_partner["type"] == "SUPPLIER"
|
|
||||||
|
|
||||||
def test_get_partners_endpoint_with_database(self, integration_client: TestClient, integration_session: Session, integration_admin_token):
|
|
||||||
"""Test retrieving partners through API endpoint from database."""
|
|
||||||
# Create partner directly in database
|
|
||||||
partner = Partner(
|
|
||||||
tin_number=987654321,
|
|
||||||
names="DB Partner",
|
|
||||||
type=PartnerType.CLIENT,
|
|
||||||
phone_number="9876543210"
|
|
||||||
)
|
|
||||||
integration_session.add(partner)
|
|
||||||
integration_session.commit()
|
|
||||||
|
|
||||||
# Retrieve through API
|
|
||||||
response = integration_client.get("/api/v1/partners/", headers=integration_admin_token)
|
|
||||||
assert response.status_code == 200
|
|
||||||
|
|
||||||
partners = response.json()
|
|
||||||
assert len(partners) >= 1
|
|
||||||
partner_names = [p["names"] for p in partners]
|
|
||||||
assert "DB Partner" in partner_names
|
|
||||||
|
|
||||||
def test_partner_unique_constraint_through_api(self, integration_client: TestClient, integration_session: Session, integration_admin_token):
|
|
||||||
"""Test partner unique constraint enforcement through API."""
|
|
||||||
# Create partner directly in database
|
|
||||||
partner = Partner(
|
|
||||||
tin_number=999999999,
|
|
||||||
names="Unique Partner",
|
|
||||||
type=PartnerType.CLIENT,
|
|
||||||
phone_number="5555555555"
|
|
||||||
)
|
|
||||||
integration_session.add(partner)
|
|
||||||
integration_session.commit()
|
|
||||||
|
|
||||||
# Try to create duplicate through API
|
|
||||||
duplicate_data = {
|
|
||||||
"tin_number": 999999999,
|
|
||||||
"names": "Different Name",
|
|
||||||
"type": "SUPPLIER",
|
|
||||||
"phone_number": "8888888888"
|
|
||||||
}
|
|
||||||
|
|
||||||
response = integration_client.post("/api/v1/partners/", json=duplicate_data, headers=integration_admin_token)
|
|
||||||
assert response.status_code == 400 # Should fail due to unique constraint
|
|
||||||
|
|
||||||
|
|
||||||
class TestTransactionAPIIntegration:
|
|
||||||
"""Test Transaction API endpoints with database integration."""
|
|
||||||
|
|
||||||
def test_create_transaction_with_valid_relationships(self, integration_client: TestClient, integration_session: Session, integration_admin_token):
|
|
||||||
"""Test creating a transaction through API with valid partner and user relationships."""
|
|
||||||
# Create required entities in database
|
|
||||||
partner = Partner(tin_number=111111111, names="Trans Partner", type=PartnerType.CLIENT, phone_number="1111111111")
|
|
||||||
user = User(username="trans_user", password_hash="hashed", role=UserRole.WRITE)
|
|
||||||
integration_session.add(partner)
|
|
||||||
integration_session.add(user)
|
|
||||||
integration_session.commit()
|
|
||||||
integration_session.refresh(partner)
|
|
||||||
integration_session.refresh(user)
|
|
||||||
|
|
||||||
transaction_data = {
|
|
||||||
"amount": 1000.50,
|
|
||||||
"transaction_type": "SALE",
|
|
||||||
"status": "COMPLETED",
|
|
||||||
"partner_id": partner.id,
|
|
||||||
"user_id": user.id
|
|
||||||
}
|
|
||||||
|
|
||||||
response = integration_client.post("/api/v1/transactions/", json=transaction_data, headers=integration_admin_token)
|
|
||||||
assert response.status_code == 201
|
|
||||||
|
|
||||||
created_transaction = response.json()
|
|
||||||
assert created_transaction["amount"] == 1000.50
|
|
||||||
assert created_transaction["partner_id"] == partner.id
|
|
||||||
|
|
||||||
def test_create_transaction_with_invalid_partner(self, integration_client: TestClient, integration_session: Session, integration_admin_token):
|
|
||||||
"""Test creating a transaction with invalid partner ID through API."""
|
|
||||||
transaction_data = {
|
|
||||||
"amount": 500.00,
|
|
||||||
"transaction_type": "PURCHASE",
|
|
||||||
"status": "PENDING",
|
|
||||||
"partner_id": 99999, # Invalid partner ID
|
|
||||||
"user_id": 1
|
|
||||||
}
|
|
||||||
|
|
||||||
response = integration_client.post("/api/v1/transactions/", json=transaction_data, headers=integration_admin_token)
|
|
||||||
assert response.status_code == 400 # Should fail due to foreign key constraint
|
|
||||||
|
|
||||||
def test_get_transactions_by_partner(self, integration_client: TestClient, integration_session: Session, integration_admin_token):
|
|
||||||
"""Test retrieving transactions filtered by partner through API."""
|
|
||||||
# Create test data
|
|
||||||
partner1 = Partner(tin_number=222222222, names="Partner 1", type=PartnerType.CLIENT, phone_number="2222222222")
|
|
||||||
partner2 = Partner(tin_number=333333333, names="Partner 2", type=PartnerType.SUPPLIER, phone_number="3333333333")
|
|
||||||
user = User(username="filter_user", password_hash="hashed", role=UserRole.WRITE)
|
|
||||||
|
|
||||||
integration_session.add_all([partner1, partner2, user])
|
|
||||||
integration_session.commit()
|
|
||||||
integration_session.refresh(partner1)
|
|
||||||
integration_session.refresh(partner2)
|
|
||||||
integration_session.refresh(user)
|
|
||||||
|
|
||||||
# Create transactions for both partners
|
|
||||||
assert partner1.id is not None
|
|
||||||
assert partner2.id is not None
|
|
||||||
assert user.id is not None
|
|
||||||
|
|
||||||
trans1 = Transaction(
|
|
||||||
total_amount=100, transcation_type=TransactionType.SALE, transaction_status=TransactionStatus.PAID,
|
|
||||||
partner_id=partner1.id, created_by=user.id, updated_by=user.id
|
|
||||||
)
|
|
||||||
trans2 = Transaction(
|
|
||||||
total_amount=200, transcation_type=TransactionType.PURCHASE, transaction_status=TransactionStatus.UNPAID,
|
|
||||||
partner_id=partner2.id, created_by=user.id, updated_by=user.id
|
|
||||||
)
|
|
||||||
integration_session.add_all([trans1, trans2])
|
|
||||||
integration_session.commit()
|
|
||||||
|
|
||||||
# Filter transactions by partner1
|
|
||||||
response = integration_client.get(f"/api/v1/transactions/?partner_id={partner1.id}", headers=integration_admin_token)
|
|
||||||
assert response.status_code == 200
|
|
||||||
|
|
||||||
transactions = response.json()
|
|
||||||
assert len(transactions) == 1
|
|
||||||
assert transactions[0]["partner_id"] == partner1.id
|
|
||||||
|
|
||||||
|
|
||||||
class TestInventoryAPIIntegration:
|
|
||||||
"""Test Inventory API endpoints with database integration."""
|
|
||||||
|
|
||||||
def test_create_inventory_with_product_relationship(self, integration_client: TestClient, integration_session: Session, integration_admin_token):
|
|
||||||
"""Test creating inventory through API with valid product relationship."""
|
|
||||||
# Create product in database
|
|
||||||
product = Product(product_code="TST001", product_name="Test Product", purchase_price=90, selling_price=100)
|
|
||||||
integration_session.add(product)
|
|
||||||
integration_session.commit()
|
|
||||||
integration_session.refresh(product)
|
|
||||||
|
|
||||||
inventory_data = {
|
|
||||||
"total_qty": 50,
|
|
||||||
"product_id": product.id
|
|
||||||
}
|
|
||||||
|
|
||||||
response = integration_client.post("/api/v1/inventory/", json=inventory_data, headers=integration_admin_token)
|
|
||||||
assert response.status_code == 201
|
|
||||||
|
|
||||||
created_inventory = response.json()
|
|
||||||
assert created_inventory["total_qty"] == 50
|
|
||||||
assert created_inventory["product_id"] == product.id
|
|
||||||
|
|
||||||
def test_inventory_unique_product_constraint_through_api(self, integration_client: TestClient, integration_session: Session, integration_admin_token):
|
|
||||||
"""Test inventory unique product constraint enforcement through API."""
|
|
||||||
# Create product and inventory directly in database
|
|
||||||
product = Product(product_code="UNQ001", product_name="Unique Product", purchase_price=180, selling_price=200)
|
|
||||||
integration_session.add(product)
|
|
||||||
integration_session.commit()
|
|
||||||
integration_session.refresh(product)
|
|
||||||
|
|
||||||
assert product.id is not None
|
|
||||||
inventory = Inventory(
|
|
||||||
total_qty=30, product_id=product.id
|
|
||||||
)
|
|
||||||
integration_session.add(inventory)
|
|
||||||
integration_session.commit()
|
|
||||||
|
|
||||||
# Try to create duplicate inventory for same product through API
|
|
||||||
duplicate_data = {
|
|
||||||
"total_qty": 20,
|
|
||||||
"product_id": product.id
|
|
||||||
}
|
|
||||||
|
|
||||||
response = integration_client.post("/api/v1/inventory/", json=duplicate_data, headers=integration_admin_token)
|
|
||||||
assert response.status_code == 400 # Should fail due to unique constraint
|
|
||||||
|
|
||||||
|
|
||||||
class TestCreditAPIIntegration:
|
|
||||||
"""Test Credit API endpoints with database integration."""
|
|
||||||
|
|
||||||
def test_create_credit_with_relationships(self, integration_client: TestClient, integration_session: Session, integration_admin_token):
|
|
||||||
"""Test creating credit through API with valid partner relationship."""
|
|
||||||
# Create partner in database
|
|
||||||
partner = Partner(tin_number=444444444, names="Credit Partner", type=PartnerType.CLIENT, phone_number="4444444444")
|
|
||||||
integration_session.add(partner)
|
|
||||||
integration_session.commit()
|
|
||||||
integration_session.refresh(partner)
|
|
||||||
|
|
||||||
credit_data = {
|
|
||||||
"amount": 5000.00,
|
|
||||||
"due_date": "2024-12-31",
|
|
||||||
"interest_rate": 5.5,
|
|
||||||
"partner_id": partner.id
|
|
||||||
}
|
|
||||||
|
|
||||||
response = integration_client.post("/api/v1/credit/", json=credit_data, headers=integration_admin_token)
|
|
||||||
assert response.status_code == 201
|
|
||||||
|
|
||||||
created_credit = response.json()
|
|
||||||
assert created_credit["amount"] == 5000.00
|
|
||||||
assert created_credit["partner_id"] == partner.id
|
|
||||||
|
|
||||||
|
|
||||||
class TestAPITransactionRollback:
|
|
||||||
"""Test API transaction rollback behavior on database errors."""
|
|
||||||
|
|
||||||
def test_api_transaction_rollback_on_error(self, integration_client: TestClient, integration_session: Session, integration_admin_token):
|
|
||||||
"""Test that API transactions are properly rolled back on validation errors."""
|
|
||||||
# Create a user first
|
|
||||||
user = User(username="rollback_test", password_hash="hashed", role=UserRole.ADMIN)
|
|
||||||
integration_session.add(user)
|
|
||||||
integration_session.commit()
|
|
||||||
|
|
||||||
# Try to create duplicate user (should fail)
|
|
||||||
duplicate_data = {
|
|
||||||
"username": "rollback_test",
|
|
||||||
"password": "different_password",
|
|
||||||
"role": "WRITE"
|
|
||||||
}
|
|
||||||
|
|
||||||
response = integration_client.post("/api/v1/users/", json=duplicate_data, headers=integration_admin_token)
|
|
||||||
assert response.status_code == 400
|
|
||||||
|
|
||||||
# Verify original user is still intact
|
|
||||||
original_user = integration_session.get(User, user.id)
|
|
||||||
assert original_user is not None
|
|
||||||
assert original_user.role == UserRole.ADMIN
|
|
||||||
|
|
||||||
def test_complex_operation_rollback(self, integration_client: TestClient, integration_session: Session, integration_admin_token):
|
|
||||||
"""Test rollback behavior for complex operations involving multiple entities."""
|
|
||||||
# Create valid partner and user
|
|
||||||
partner = Partner(tin_number=555555555, names="Complex Partner", type=PartnerType.CLIENT, phone_number="5555555555")
|
|
||||||
user = User(username="complex_user", password_hash="hashed", role=UserRole.WRITE)
|
|
||||||
integration_session.add_all([partner, user])
|
|
||||||
integration_session.commit()
|
|
||||||
integration_session.refresh(partner)
|
|
||||||
integration_session.refresh(user)
|
|
||||||
|
|
||||||
# Try to create transaction with invalid data (should trigger rollback)
|
|
||||||
invalid_transaction_data = {
|
|
||||||
"amount": -1000.0, # Negative amount should fail validation
|
|
||||||
"transaction_type": "INVALID_TYPE",
|
|
||||||
"status": "COMPLETED",
|
|
||||||
"partner_id": partner.id,
|
|
||||||
"user_id": user.id
|
|
||||||
}
|
|
||||||
|
|
||||||
response = integration_client.post("/api/v1/transactions/", json=invalid_transaction_data, headers=integration_admin_token)
|
|
||||||
assert response.status_code in [400, 422] # Should fail validation
|
|
||||||
|
|
||||||
# Verify no partial data was committed
|
|
||||||
transactions = integration_session.exec(select(Transaction)).all()
|
|
||||||
assert len([t for t in transactions if t.partner_id == partner.id]) == 0
|
|
||||||
|
|
||||||
|
|
||||||
class TestAPIConstraintValidation:
|
|
||||||
"""Test database constraint validation through API endpoints."""
|
|
||||||
|
|
||||||
def test_foreign_key_validation_through_api(self, integration_client: TestClient, integration_session: Session, integration_admin_token):
|
|
||||||
"""Test foreign key constraint validation through API."""
|
|
||||||
# Try to create payment with invalid transaction ID
|
|
||||||
payment_data = {
|
|
||||||
"amount": 100.00,
|
|
||||||
"method": "CASH",
|
|
||||||
"transaction_id": 99999 # Invalid transaction ID
|
|
||||||
}
|
|
||||||
|
|
||||||
response = integration_client.post("/api/v1/payments/", json=payment_data, headers=integration_admin_token)
|
|
||||||
assert response.status_code in [400, 422] # Should fail due to foreign key constraint
|
|
||||||
|
|
||||||
def test_data_validation_through_api(self, integration_client: TestClient, integration_session: Session, integration_admin_token):
|
|
||||||
"""Test data type and format validation through API."""
|
|
||||||
# Try to create user with invalid data
|
|
||||||
invalid_user_data = {
|
|
||||||
"username": "", # Empty username should fail validation
|
|
||||||
"password": "short", # Too short password
|
|
||||||
"role": "INVALID_ROLE" # Invalid role
|
|
||||||
}
|
|
||||||
|
|
||||||
response = integration_client.post("/api/v1/users/", json=invalid_user_data, headers=integration_admin_token)
|
|
||||||
assert response.status_code == 422 # Should fail validation
|
|
||||||
@@ -1,257 +0,0 @@
|
|||||||
"""Integration tests for Alembic migrations."""
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from sqlmodel import Session, select, SQLModel
|
|
||||||
from alembic import command
|
|
||||||
from alembic.script import ScriptDirectory
|
|
||||||
from alembic.runtime.migration import MigrationContext
|
|
||||||
|
|
||||||
from app.schemas.models import User, Partner, Product, Transaction, Credit, Inventory, Payment
|
|
||||||
from app.schemas.base import UserRole, PartnerType, TransactionType, TransactionStatus
|
|
||||||
|
|
||||||
|
|
||||||
class TestAlembicMigrations:
|
|
||||||
"""Test Alembic migration functionality."""
|
|
||||||
|
|
||||||
def test_migration_history_integrity(self, alembic_config, integration_engine):
|
|
||||||
"""Test migration history integrity and schema creation."""
|
|
||||||
# For SQLite testing, we'll focus on basic table creation
|
|
||||||
# since full PostgreSQL migrations don't work with SQLite
|
|
||||||
|
|
||||||
# Create all tables using SQLModel (simulating migration result)
|
|
||||||
SQLModel.metadata.create_all(integration_engine)
|
|
||||||
|
|
||||||
# Verify basic tables exist and are accessible
|
|
||||||
with Session(integration_engine) as session:
|
|
||||||
try:
|
|
||||||
# Test that we can query each main table
|
|
||||||
users = session.exec(select(User)).all()
|
|
||||||
partners = session.exec(select(Partner)).all()
|
|
||||||
products = session.exec(select(Product)).all()
|
|
||||||
transactions = session.exec(select(Transaction)).all()
|
|
||||||
|
|
||||||
# If we reach here, tables exist and are queryable
|
|
||||||
assert True, "All tables created and accessible"
|
|
||||||
except Exception as e:
|
|
||||||
assert False, f"Tables not properly created: {e}"
|
|
||||||
|
|
||||||
def test_migration_rollback_safety(self, alembic_config, integration_engine):
|
|
||||||
"""Test basic migration concepts - simplified for SQLite compatibility."""
|
|
||||||
# Since PostgreSQL-specific migration features don't work with SQLite,
|
|
||||||
# we'll test basic database operations instead
|
|
||||||
|
|
||||||
# Create tables
|
|
||||||
SQLModel.metadata.create_all(integration_engine)
|
|
||||||
|
|
||||||
# Test that we can create and drop tables safely
|
|
||||||
with Session(integration_engine) as session:
|
|
||||||
# Add some test data
|
|
||||||
user = User(username="migration_test", password_hash="hashed", role=UserRole.READ_ONLY)
|
|
||||||
session.add(user)
|
|
||||||
session.commit()
|
|
||||||
|
|
||||||
# Verify data exists
|
|
||||||
test_user = session.exec(select(User).where(User.username == "migration_test")).first()
|
|
||||||
assert test_user is not None
|
|
||||||
|
|
||||||
# Clean up (simulating rollback)
|
|
||||||
session.delete(test_user)
|
|
||||||
session.commit()
|
|
||||||
|
|
||||||
# Verify data is gone
|
|
||||||
test_user = session.exec(select(User).where(User.username == "migration_test")).first()
|
|
||||||
assert test_user is None
|
|
||||||
|
|
||||||
def test_schema_consistency(self, alembic_config, integration_engine):
|
|
||||||
"""Test that schema is consistent and relationships work."""
|
|
||||||
SQLModel.metadata.create_all(integration_engine)
|
|
||||||
|
|
||||||
with Session(integration_engine) as session:
|
|
||||||
# Test foreign key relationships work
|
|
||||||
user = User(username="fk_test_user", password_hash="hashed", role=UserRole.ADMIN)
|
|
||||||
partner = Partner(tin_number=123456789, names="FK Test Partner", type=PartnerType.CLIENT, phone_number="1234567890")
|
|
||||||
|
|
||||||
session.add(user)
|
|
||||||
session.add(partner)
|
|
||||||
session.commit()
|
|
||||||
session.refresh(user)
|
|
||||||
session.refresh(partner)
|
|
||||||
|
|
||||||
# Create transaction with relationships
|
|
||||||
assert user.id is not None
|
|
||||||
assert partner.id is not None
|
|
||||||
|
|
||||||
transaction = Transaction(
|
|
||||||
total_amount=1000,
|
|
||||||
transcation_type=TransactionType.SALE,
|
|
||||||
transaction_status=TransactionStatus.PAID,
|
|
||||||
partner_id=partner.id,
|
|
||||||
created_by=user.id,
|
|
||||||
updated_by=user.id
|
|
||||||
)
|
|
||||||
|
|
||||||
session.add(transaction)
|
|
||||||
session.commit()
|
|
||||||
session.refresh(transaction)
|
|
||||||
|
|
||||||
# Verify relationships work
|
|
||||||
assert transaction.partner_id == partner.id
|
|
||||||
assert transaction.created_by == user.id
|
|
||||||
|
|
||||||
|
|
||||||
class TestMigrationDataIntegrity:
|
|
||||||
"""Test data integrity constraints through migration-like operations."""
|
|
||||||
|
|
||||||
def test_foreign_key_constraints_enforced(self, integration_engine):
|
|
||||||
"""Test that foreign key constraints are properly enforced."""
|
|
||||||
SQLModel.metadata.create_all(integration_engine)
|
|
||||||
|
|
||||||
with Session(integration_engine) as session:
|
|
||||||
# Try to create a transaction with invalid partner_id
|
|
||||||
# Note: SQLite doesn't enforce foreign keys by default, so this test
|
|
||||||
# verifies the constraint exists conceptually
|
|
||||||
|
|
||||||
user = User(username="constraint_test", password_hash="hashed", role=UserRole.ADMIN)
|
|
||||||
session.add(user)
|
|
||||||
session.commit()
|
|
||||||
session.refresh(user)
|
|
||||||
|
|
||||||
assert user.id is not None
|
|
||||||
|
|
||||||
# This should work with valid references
|
|
||||||
partner = Partner(tin_number=555666777, names="Valid Partner", type=PartnerType.CLIENT, phone_number="5556667777")
|
|
||||||
session.add(partner)
|
|
||||||
session.commit()
|
|
||||||
session.refresh(partner)
|
|
||||||
|
|
||||||
assert partner.id is not None
|
|
||||||
|
|
||||||
transaction = Transaction(
|
|
||||||
total_amount=500,
|
|
||||||
transcation_type=TransactionType.PURCHASE,
|
|
||||||
transaction_status=TransactionStatus.UNPAID,
|
|
||||||
partner_id=partner.id,
|
|
||||||
created_by=user.id,
|
|
||||||
updated_by=user.id
|
|
||||||
)
|
|
||||||
session.add(transaction)
|
|
||||||
session.commit()
|
|
||||||
|
|
||||||
# Verify transaction was created successfully
|
|
||||||
assert transaction.id is not None
|
|
||||||
|
|
||||||
def test_enum_constraints_enforced(self, integration_engine):
|
|
||||||
"""Test that enum constraints are properly enforced."""
|
|
||||||
SQLModel.metadata.create_all(integration_engine)
|
|
||||||
|
|
||||||
with Session(integration_engine) as session:
|
|
||||||
# Test valid enum values work
|
|
||||||
user = User(username="enum_test", password_hash="hashed", role=UserRole.WRITE)
|
|
||||||
partner = Partner(tin_number=888999000, names="Enum Partner", type=PartnerType.SUPPLIER, phone_number="8889990000")
|
|
||||||
|
|
||||||
session.add(user)
|
|
||||||
session.add(partner)
|
|
||||||
session.commit()
|
|
||||||
session.refresh(user)
|
|
||||||
session.refresh(partner)
|
|
||||||
|
|
||||||
assert user.id is not None
|
|
||||||
assert partner.id is not None
|
|
||||||
|
|
||||||
transaction = Transaction(
|
|
||||||
total_amount=750,
|
|
||||||
transcation_type=TransactionType.CREDIT,
|
|
||||||
transaction_status=TransactionStatus.PARTIALLY_PAID,
|
|
||||||
partner_id=partner.id,
|
|
||||||
created_by=user.id,
|
|
||||||
updated_by=user.id
|
|
||||||
)
|
|
||||||
|
|
||||||
session.add(transaction)
|
|
||||||
session.commit()
|
|
||||||
|
|
||||||
# Verify enum values are stored correctly
|
|
||||||
assert transaction.transcation_type == TransactionType.CREDIT
|
|
||||||
assert transaction.transaction_status == TransactionStatus.PARTIALLY_PAID
|
|
||||||
|
|
||||||
def test_unique_constraints_enforced(self, integration_engine):
|
|
||||||
"""Test that unique constraints are properly enforced."""
|
|
||||||
SQLModel.metadata.create_all(integration_engine)
|
|
||||||
|
|
||||||
with Session(integration_engine) as session:
|
|
||||||
# Create first user
|
|
||||||
user1 = User(username="unique_test", password_hash="hashed1", role=UserRole.READ_ONLY)
|
|
||||||
session.add(user1)
|
|
||||||
session.commit()
|
|
||||||
|
|
||||||
# Try to create duplicate username (should fail)
|
|
||||||
with pytest.raises(Exception): # Should raise integrity error
|
|
||||||
user2 = User(username="unique_test", password_hash="hashed2", role=UserRole.WRITE)
|
|
||||||
session.add(user2)
|
|
||||||
session.commit()
|
|
||||||
|
|
||||||
def test_nullable_constraints_enforced(self, integration_engine):
|
|
||||||
"""Test that nullable constraints are properly enforced."""
|
|
||||||
SQLModel.metadata.create_all(integration_engine)
|
|
||||||
|
|
||||||
with Session(integration_engine) as session:
|
|
||||||
# Test that nullable fields can be None
|
|
||||||
partner = Partner(
|
|
||||||
tin_number=777888999,
|
|
||||||
names="Nullable Test",
|
|
||||||
type=PartnerType.CLIENT,
|
|
||||||
phone_number="1234567890" # Use a valid phone number instead
|
|
||||||
)
|
|
||||||
session.add(partner)
|
|
||||||
session.commit()
|
|
||||||
|
|
||||||
# Verify partner was created successfully
|
|
||||||
assert partner.phone_number == "1234567890"
|
|
||||||
|
|
||||||
|
|
||||||
class TestMigrationPerformance:
|
|
||||||
"""Test migration performance and efficiency."""
|
|
||||||
|
|
||||||
def test_bulk_data_operations(self, integration_engine):
|
|
||||||
"""Test that bulk operations work efficiently after migrations."""
|
|
||||||
SQLModel.metadata.create_all(integration_engine)
|
|
||||||
|
|
||||||
with Session(integration_engine) as session:
|
|
||||||
# Create test data in bulk
|
|
||||||
users = [
|
|
||||||
User(username=f"bulk_user_{i}", password_hash="hashed", role=UserRole.READ_ONLY)
|
|
||||||
for i in range(10)
|
|
||||||
]
|
|
||||||
|
|
||||||
partners = [
|
|
||||||
Partner(tin_number=100000000 + i, names=f"Bulk Partner {i}", type=PartnerType.CLIENT, phone_number=f"123456789{i}")
|
|
||||||
for i in range(10)
|
|
||||||
]
|
|
||||||
|
|
||||||
session.add_all(users + partners)
|
|
||||||
session.commit()
|
|
||||||
|
|
||||||
# Verify all data was created
|
|
||||||
user_count = len(session.exec(select(User)).all())
|
|
||||||
partner_count = len(session.exec(select(Partner)).all())
|
|
||||||
|
|
||||||
assert user_count >= 10
|
|
||||||
assert partner_count >= 10
|
|
||||||
|
|
||||||
def test_index_efficiency(self, integration_engine):
|
|
||||||
"""Test that database indexes work efficiently."""
|
|
||||||
SQLModel.metadata.create_all(integration_engine)
|
|
||||||
|
|
||||||
with Session(integration_engine) as session:
|
|
||||||
# Create test data
|
|
||||||
users = [
|
|
||||||
User(username=f"index_user_{i}", password_hash="hashed", role=UserRole.READ_ONLY)
|
|
||||||
for i in range(20)
|
|
||||||
]
|
|
||||||
session.add_all(users)
|
|
||||||
session.commit()
|
|
||||||
|
|
||||||
# Test that unique username lookups work quickly
|
|
||||||
test_user = session.exec(select(User).where(User.username == "index_user_5")).first()
|
|
||||||
assert test_user is not None
|
|
||||||
assert test_user.username == "index_user_5"
|
|
||||||
@@ -1,367 +0,0 @@
|
|||||||
"""Integration tests for SQLModel database operations."""
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from sqlalchemy.exc import IntegrityError
|
|
||||||
from sqlmodel import Session, select
|
|
||||||
|
|
||||||
from app.schemas.models import (
|
|
||||||
User, Partner, Product, Transaction,
|
|
||||||
Transaction_details, Inventory, Payment, Credit
|
|
||||||
)
|
|
||||||
from app.schemas.base import (
|
|
||||||
UserRole, PartnerType, TransactionType,
|
|
||||||
TransactionStatus, PaymentMethod
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TestUserModel:
|
|
||||||
"""Test User model database operations."""
|
|
||||||
|
|
||||||
def test_user_creation_and_retrieval(self, integration_session: Session):
|
|
||||||
"""Test creating and retrieving users from database."""
|
|
||||||
user = User(username="testuser", password_hash="hashed", role=UserRole.ADMIN)
|
|
||||||
|
|
||||||
integration_session.add(user)
|
|
||||||
integration_session.commit()
|
|
||||||
integration_session.refresh(user)
|
|
||||||
|
|
||||||
# Verify user was created with ID
|
|
||||||
assert user.id is not None
|
|
||||||
assert user.username == "testuser"
|
|
||||||
assert user.role == UserRole.ADMIN
|
|
||||||
|
|
||||||
# Verify retrieval from database
|
|
||||||
retrieved_user = integration_session.get(User, user.id)
|
|
||||||
assert retrieved_user is not None
|
|
||||||
assert retrieved_user.username == "testuser"
|
|
||||||
|
|
||||||
def test_user_unique_username_constraint(self, integration_session: Session):
|
|
||||||
"""Test that duplicate usernames are rejected."""
|
|
||||||
user1 = User(username="duplicate", password_hash="hash1", role=UserRole.ADMIN)
|
|
||||||
user2 = User(username="duplicate", password_hash="hash2", role=UserRole.READ_ONLY)
|
|
||||||
|
|
||||||
integration_session.add(user1)
|
|
||||||
integration_session.commit()
|
|
||||||
|
|
||||||
integration_session.add(user2)
|
|
||||||
with pytest.raises(IntegrityError):
|
|
||||||
integration_session.commit()
|
|
||||||
|
|
||||||
def test_user_role_defaults(self, integration_session: Session):
|
|
||||||
"""Test user role default values."""
|
|
||||||
user = User(username="defaultrole", password_hash="hash")
|
|
||||||
|
|
||||||
integration_session.add(user)
|
|
||||||
integration_session.commit()
|
|
||||||
integration_session.refresh(user)
|
|
||||||
|
|
||||||
# Check default role is READ_ONLY
|
|
||||||
assert user.role == UserRole.READ_ONLY
|
|
||||||
|
|
||||||
|
|
||||||
class TestPartnerModel:
|
|
||||||
"""Test Partner model database operations."""
|
|
||||||
|
|
||||||
def test_partner_creation_and_types(self, integration_session: Session):
|
|
||||||
"""Test creating partners with different types."""
|
|
||||||
partners = [
|
|
||||||
Partner(tin_number=123456789, names="Client Partner", type=PartnerType.CLIENT, phone_number="1234567890"),
|
|
||||||
Partner(tin_number=987654321, names="Supplier Partner", type=PartnerType.SUPPLIER, phone_number="0987654321"),
|
|
||||||
]
|
|
||||||
|
|
||||||
for partner in partners:
|
|
||||||
integration_session.add(partner)
|
|
||||||
integration_session.commit()
|
|
||||||
|
|
||||||
# Verify both partners were created
|
|
||||||
client_partner = integration_session.exec(
|
|
||||||
select(Partner).where(Partner.type == PartnerType.CLIENT)
|
|
||||||
).first()
|
|
||||||
supplier_partner = integration_session.exec(
|
|
||||||
select(Partner).where(Partner.type == PartnerType.SUPPLIER)
|
|
||||||
).first()
|
|
||||||
|
|
||||||
assert client_partner is not None
|
|
||||||
assert supplier_partner is not None
|
|
||||||
assert client_partner.names == "Client Partner"
|
|
||||||
assert supplier_partner.names == "Supplier Partner"
|
|
||||||
|
|
||||||
def test_partner_unique_tin_constraint(self, integration_session: Session):
|
|
||||||
"""Test that duplicate TIN numbers are rejected."""
|
|
||||||
partner1 = Partner(tin_number=123456789, names="Partner 1", type=PartnerType.CLIENT, phone_number="1234567890")
|
|
||||||
partner2 = Partner(tin_number=123456789, names="Partner 2", type=PartnerType.SUPPLIER, phone_number="0987654321")
|
|
||||||
|
|
||||||
integration_session.add(partner1)
|
|
||||||
integration_session.commit()
|
|
||||||
|
|
||||||
integration_session.add(partner2)
|
|
||||||
with pytest.raises(IntegrityError):
|
|
||||||
integration_session.commit()
|
|
||||||
|
|
||||||
|
|
||||||
class TestProductModel:
|
|
||||||
"""Test Product model database operations."""
|
|
||||||
|
|
||||||
def test_product_creation(self, integration_session: Session):
|
|
||||||
"""Test basic product creation."""
|
|
||||||
product = Product(
|
|
||||||
product_code="TEST001",
|
|
||||||
product_name="Test Product",
|
|
||||||
purchase_price=100,
|
|
||||||
selling_price=120
|
|
||||||
)
|
|
||||||
|
|
||||||
integration_session.add(product)
|
|
||||||
integration_session.commit()
|
|
||||||
integration_session.refresh(product)
|
|
||||||
|
|
||||||
assert product.id is not None
|
|
||||||
assert product.product_name == "Test Product"
|
|
||||||
assert product.product_code == "TEST001"
|
|
||||||
|
|
||||||
def test_product_unique_name_constraint(self, integration_session: Session):
|
|
||||||
"""Test that duplicate product names are rejected."""
|
|
||||||
product1 = Product(
|
|
||||||
product_code="DUP001",
|
|
||||||
product_name="Duplicate Product",
|
|
||||||
purchase_price=100,
|
|
||||||
selling_price=120
|
|
||||||
)
|
|
||||||
product2 = Product(
|
|
||||||
product_code="DUP002",
|
|
||||||
product_name="Duplicate Product", # Same name, different code
|
|
||||||
purchase_price=150,
|
|
||||||
selling_price=180
|
|
||||||
)
|
|
||||||
|
|
||||||
integration_session.add(product1)
|
|
||||||
integration_session.commit()
|
|
||||||
|
|
||||||
integration_session.add(product2)
|
|
||||||
with pytest.raises(IntegrityError):
|
|
||||||
integration_session.commit()
|
|
||||||
|
|
||||||
|
|
||||||
class TestTransactionModel:
|
|
||||||
"""Test Transaction model with relationships."""
|
|
||||||
|
|
||||||
def test_transaction_creation(self, integration_session: Session):
|
|
||||||
"""Test creating transaction with valid relationships."""
|
|
||||||
# Create required entities
|
|
||||||
user = User(username="trans_user", password_hash="hash", role=UserRole.ADMIN)
|
|
||||||
partner = Partner(
|
|
||||||
tin_number=123456789,
|
|
||||||
names="Transaction Partner",
|
|
||||||
type=PartnerType.CLIENT,
|
|
||||||
phone_number="1234567890"
|
|
||||||
)
|
|
||||||
|
|
||||||
integration_session.add(user)
|
|
||||||
integration_session.add(partner)
|
|
||||||
integration_session.commit()
|
|
||||||
integration_session.refresh(user)
|
|
||||||
integration_session.refresh(partner)
|
|
||||||
|
|
||||||
# Create transaction - use type assertion for nullable IDs
|
|
||||||
transaction = Transaction(
|
|
||||||
partner_id=partner.id, # type: ignore
|
|
||||||
transcation_type=TransactionType.SALE,
|
|
||||||
transaction_status=TransactionStatus.UNPAID,
|
|
||||||
total_amount=500,
|
|
||||||
created_by=user.id, # type: ignore
|
|
||||||
updated_by=user.id # type: ignore
|
|
||||||
)
|
|
||||||
|
|
||||||
integration_session.add(transaction)
|
|
||||||
integration_session.commit()
|
|
||||||
integration_session.refresh(transaction)
|
|
||||||
|
|
||||||
assert transaction.id is not None
|
|
||||||
assert transaction.partner_id == partner.id
|
|
||||||
assert transaction.total_amount == 500
|
|
||||||
|
|
||||||
|
|
||||||
class TestInventoryModel:
|
|
||||||
"""Test Inventory model operations."""
|
|
||||||
|
|
||||||
def test_inventory_creation(self, integration_session: Session):
|
|
||||||
"""Test creating inventory with valid product reference."""
|
|
||||||
# Create product first
|
|
||||||
product = Product(
|
|
||||||
product_code="INV001",
|
|
||||||
product_name="Inventory Product",
|
|
||||||
purchase_price=100,
|
|
||||||
selling_price=120
|
|
||||||
)
|
|
||||||
|
|
||||||
integration_session.add(product)
|
|
||||||
integration_session.commit()
|
|
||||||
integration_session.refresh(product)
|
|
||||||
|
|
||||||
# Create inventory
|
|
||||||
inventory = Inventory(
|
|
||||||
product_id=product.id, # type: ignore
|
|
||||||
total_qty=100
|
|
||||||
)
|
|
||||||
|
|
||||||
integration_session.add(inventory)
|
|
||||||
integration_session.commit()
|
|
||||||
integration_session.refresh(inventory)
|
|
||||||
|
|
||||||
assert inventory.id is not None
|
|
||||||
assert inventory.product_id == product.id
|
|
||||||
assert inventory.total_qty == 100
|
|
||||||
|
|
||||||
def test_inventory_unique_product_constraint(self, integration_session: Session):
|
|
||||||
"""Test that each product can only have one inventory record."""
|
|
||||||
product = Product(
|
|
||||||
product_code="SINGLE",
|
|
||||||
product_name="Single Inventory",
|
|
||||||
purchase_price=100,
|
|
||||||
selling_price=120
|
|
||||||
)
|
|
||||||
|
|
||||||
integration_session.add(product)
|
|
||||||
integration_session.commit()
|
|
||||||
integration_session.refresh(product)
|
|
||||||
|
|
||||||
inventory1 = Inventory(
|
|
||||||
product_id=product.id, # type: ignore
|
|
||||||
total_qty=50
|
|
||||||
)
|
|
||||||
inventory2 = Inventory(
|
|
||||||
product_id=product.id, # type: ignore
|
|
||||||
total_qty=100
|
|
||||||
)
|
|
||||||
|
|
||||||
integration_session.add(inventory1)
|
|
||||||
integration_session.commit()
|
|
||||||
|
|
||||||
integration_session.add(inventory2)
|
|
||||||
with pytest.raises(IntegrityError):
|
|
||||||
integration_session.commit()
|
|
||||||
|
|
||||||
|
|
||||||
class TestCreditModel:
|
|
||||||
"""Test Credit model operations."""
|
|
||||||
|
|
||||||
def test_credit_creation(self, integration_session: Session):
|
|
||||||
"""Test creating credit with valid partner and transaction reference."""
|
|
||||||
# Create partner, user, and transaction
|
|
||||||
partner = Partner(
|
|
||||||
tin_number=123456789,
|
|
||||||
names="Credit Partner",
|
|
||||||
type=PartnerType.CLIENT,
|
|
||||||
phone_number="1234567890"
|
|
||||||
)
|
|
||||||
user = User(username="credit_user", password_hash="hash", role=UserRole.ADMIN)
|
|
||||||
|
|
||||||
integration_session.add(partner)
|
|
||||||
integration_session.add(user)
|
|
||||||
integration_session.commit()
|
|
||||||
integration_session.refresh(partner)
|
|
||||||
integration_session.refresh(user)
|
|
||||||
|
|
||||||
# Create a transaction for the credit
|
|
||||||
transaction = Transaction(
|
|
||||||
partner_id=partner.id, # type: ignore
|
|
||||||
transcation_type=TransactionType.SALE,
|
|
||||||
transaction_status=TransactionStatus.UNPAID,
|
|
||||||
total_amount=1000,
|
|
||||||
created_by=user.id, # type: ignore
|
|
||||||
updated_by=user.id # type: ignore
|
|
||||||
)
|
|
||||||
|
|
||||||
integration_session.add(transaction)
|
|
||||||
integration_session.commit()
|
|
||||||
integration_session.refresh(transaction)
|
|
||||||
|
|
||||||
# Create credit account
|
|
||||||
credit = Credit(
|
|
||||||
partner_id=partner.id, # type: ignore
|
|
||||||
transaction_id=transaction.id, # type: ignore
|
|
||||||
credit_amount=1000,
|
|
||||||
credit_limit=5000,
|
|
||||||
balance=1000,
|
|
||||||
created_by=user.id, # type: ignore
|
|
||||||
updated_by=user.id # type: ignore
|
|
||||||
)
|
|
||||||
|
|
||||||
integration_session.add(credit)
|
|
||||||
integration_session.commit()
|
|
||||||
integration_session.refresh(credit)
|
|
||||||
|
|
||||||
assert credit.id is not None
|
|
||||||
assert credit.partner_id == partner.id
|
|
||||||
assert credit.balance == 1000
|
|
||||||
assert credit.credit_limit == 5000
|
|
||||||
|
|
||||||
|
|
||||||
class TestComplexQueries:
|
|
||||||
"""Test complex database queries and relationships."""
|
|
||||||
|
|
||||||
def test_query_transactions_by_partner(self, integration_session: Session):
|
|
||||||
"""Test querying transactions by partner."""
|
|
||||||
# Create test data
|
|
||||||
user = User(username="query_user", password_hash="hash", role=UserRole.ADMIN)
|
|
||||||
partner = Partner(
|
|
||||||
tin_number=123456789,
|
|
||||||
names="Query Partner",
|
|
||||||
type=PartnerType.CLIENT,
|
|
||||||
phone_number="1234567890"
|
|
||||||
)
|
|
||||||
|
|
||||||
integration_session.add(user)
|
|
||||||
integration_session.add(partner)
|
|
||||||
integration_session.commit()
|
|
||||||
integration_session.refresh(user)
|
|
||||||
integration_session.refresh(partner)
|
|
||||||
|
|
||||||
# Create multiple transactions
|
|
||||||
for amount in [100, 200, 300]:
|
|
||||||
transaction = Transaction(
|
|
||||||
partner_id=partner.id, # type: ignore
|
|
||||||
transcation_type=TransactionType.SALE,
|
|
||||||
transaction_status=TransactionStatus.UNPAID,
|
|
||||||
total_amount=amount,
|
|
||||||
created_by=user.id, # type: ignore
|
|
||||||
updated_by=user.id # type: ignore
|
|
||||||
)
|
|
||||||
integration_session.add(transaction)
|
|
||||||
|
|
||||||
integration_session.commit()
|
|
||||||
|
|
||||||
# Query transactions by partner
|
|
||||||
transactions = integration_session.exec(
|
|
||||||
select(Transaction).where(Transaction.partner_id == partner.id)
|
|
||||||
).all()
|
|
||||||
|
|
||||||
assert len(transactions) == 3
|
|
||||||
amounts = [t.total_amount for t in transactions]
|
|
||||||
assert 100 in amounts
|
|
||||||
assert 200 in amounts
|
|
||||||
assert 300 in amounts
|
|
||||||
|
|
||||||
def test_database_rollback_on_error(self, integration_session: Session):
|
|
||||||
"""Test that database properly rolls back on constraint violations."""
|
|
||||||
user = User(username="rollback_user", password_hash="hash", role=UserRole.ADMIN)
|
|
||||||
integration_session.add(user)
|
|
||||||
integration_session.commit()
|
|
||||||
|
|
||||||
# Attempt to create duplicate username (should fail)
|
|
||||||
duplicate_user = User(username="rollback_user", password_hash="hash2", role=UserRole.READ_ONLY)
|
|
||||||
integration_session.add(duplicate_user)
|
|
||||||
|
|
||||||
with pytest.raises(IntegrityError):
|
|
||||||
integration_session.commit()
|
|
||||||
|
|
||||||
# Verify rollback - session should still be usable
|
|
||||||
integration_session.rollback()
|
|
||||||
|
|
||||||
new_user = User(username="new_user", password_hash="hash", role=UserRole.READ_ONLY)
|
|
||||||
integration_session.add(new_user)
|
|
||||||
integration_session.commit()
|
|
||||||
integration_session.refresh(new_user)
|
|
||||||
|
|
||||||
assert new_user.id is not None
|
|
||||||
assert new_user.username == "new_user"
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
def test_read_root(client):
|
|
||||||
"""Test the root endpoint."""
|
|
||||||
response = client.get("/")
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert response.json() == {"message": "CMT API v1"}
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
### Stack
|
|
||||||
HTMX + Tailwind + Alpine.js/Hyperscript
|
|
||||||
Reference in New Issue
Block a user