remove unused products code and add decorator to serialize models
This commit is contained in:
parent
7b32a8a6ec
commit
67d3e586ec
31 changed files with 340 additions and 2304 deletions
|
|
@ -1,338 +0,0 @@
|
|||
# Epic & Wiki Feature Implementation Summary
|
||||
|
||||
## Overview
|
||||
This document summarizes the implementation of the Epic and Wiki features for the Kanban application.
|
||||
|
||||
## What Was Implemented
|
||||
|
||||
### Backend (Flask)
|
||||
|
||||
#### 1. Database Models
|
||||
|
||||
**Epic Model** (`backend/app/models/epic.py`)
|
||||
- Rich text content support (JSON for Slate.js)
|
||||
- Hierarchical structure (parent_epic_id for nesting)
|
||||
- Color coding
|
||||
- Position ordering
|
||||
- Board-level scoping
|
||||
- Soft delete support (closed field)
|
||||
- Depth limit for hierarchy control
|
||||
|
||||
**Wiki Model** (`backend/app/models/wiki.py`)
|
||||
- Rich text content (JSON for Slate.js)
|
||||
- URL-friendly slugs
|
||||
- Summary field
|
||||
- Categories for organization
|
||||
- Tags support
|
||||
- Created by / Updated by tracking
|
||||
- Board-level scoping
|
||||
|
||||
**Card Model Update** (`backend/app/models/card.py`)
|
||||
- Added `epic_id` foreign key to link cards to epics
|
||||
|
||||
**Association Table** (`wiki_entity_links`)
|
||||
- Many-to-many relationship between Wikis and entity types
|
||||
- Supports linking Wikis to Cards, Epics, and future entity types
|
||||
|
||||
#### 2. Database Migration
|
||||
- Created migration file: `6fc439155ced_add_epic_and_wiki_models.py`
|
||||
- Adds all new tables and relationships
|
||||
|
||||
### Frontend (React/TypeScript)
|
||||
|
||||
#### 1. TypeScript Types (`frontend/src/types/epic.ts`)
|
||||
```typescript
|
||||
- Epic interface
|
||||
- Wiki interface
|
||||
- CreateEpicRequest / UpdateEpicRequest
|
||||
- CreateWikiRequest / UpdateWikiRequest
|
||||
- WikiEntityLink interface
|
||||
```
|
||||
|
||||
#### 2. Components
|
||||
|
||||
**RichTextEditor** (`frontend/src/components/RichTextEditor.tsx`)
|
||||
- Slate.js-based rich text editor
|
||||
- Editable with proper styling
|
||||
- Dark mode support
|
||||
- Placeholder support
|
||||
- Read-only mode option
|
||||
|
||||
**RichTextContent** (`frontend/src/components/RichTextContent.tsx`)
|
||||
- Read-only renderer for Slate.js content
|
||||
- Supports paragraphs, lists, blockquotes
|
||||
- Text formatting (bold, italic, underline, code)
|
||||
- Dark mode styling
|
||||
|
||||
#### 3. Custom Hook
|
||||
|
||||
**useEpics** (`frontend/src/hooks/useEpics.ts`)
|
||||
- Fetch all epics for a board
|
||||
- Create new epic
|
||||
- Update existing epic
|
||||
- Delete epic
|
||||
- Integrated with global loader and toast notifications
|
||||
- Error handling with user-friendly messages
|
||||
|
||||
#### 4. API Integration
|
||||
|
||||
Updated `useApi` hook with epic methods:
|
||||
```typescript
|
||||
- getEpics(boardId)
|
||||
- createEpic(boardId, epicData)
|
||||
- getEpic(epicId)
|
||||
- updateEpic(epicId, epicData)
|
||||
- deleteEpic(epicId)
|
||||
- addEpicToCard(cardId, epicId)
|
||||
- removeEpicFromCard(cardId, epicId)
|
||||
```
|
||||
|
||||
## Key Design Decisions
|
||||
|
||||
### 1. Epic Hierarchy
|
||||
- **Decision**: Self-referential foreign key (`parent_epic_id`)
|
||||
- **Rationale**: Allows flexible nesting of epics with arbitrary depth
|
||||
- **Feature**: `depth_limit` field to control maximum nesting depth
|
||||
|
||||
### 2. Rich Text Storage
|
||||
- **Decision**: Store as JSON (compatible with Slate.js)
|
||||
- **Rationale**:
|
||||
- Slate.js natively uses JSON format
|
||||
- No serialization/deserialization overhead
|
||||
- Easy to query and modify content structure
|
||||
|
||||
### 3. Wiki vs Document Naming
|
||||
- **Decision**: Use "Wiki" instead of "Document"
|
||||
- **Rationale**: Avoids confusion with file attachments
|
||||
- **Meaning**: Wiki implies knowledge base / documentation repository
|
||||
|
||||
### 4. Entity Linking Strategy
|
||||
- **Decision**: Polymorphic association table (`wiki_entity_links`)
|
||||
- **Rationale**:
|
||||
- Single table handles all entity types
|
||||
- Easy to add new entity types in future
|
||||
- Avoids circular imports and complex schema changes
|
||||
|
||||
### 5. Epic-Card Relationship
|
||||
- **Decision**: One-way reference (Card → Epic)
|
||||
- **Rationale**:
|
||||
- Simpler than many-to-many (epics contain cards)
|
||||
- Cards can belong to one epic at a time
|
||||
- Consistent with Jira's parent/child pattern
|
||||
|
||||
## Architecture
|
||||
|
||||
### Backend Data Flow
|
||||
|
||||
```
|
||||
Board (1) ───────┬─────── (1) Epic
|
||||
│ │
|
||||
│ │─── (0..*) Card (via epic_id)
|
||||
│
|
||||
└───────────────┬─────── (0..*) Wiki
|
||||
│
|
||||
└─── wiki_entity_links ───┬── Card
|
||||
├── Epic
|
||||
└── (future entities)
|
||||
```
|
||||
|
||||
### Frontend Component Structure
|
||||
|
||||
```
|
||||
BoardEpics (page)
|
||||
├── EpicList
|
||||
│ └── EpicCard
|
||||
└── CreateEpicModal
|
||||
├── EpicForm
|
||||
│ ├── EpicNameInput
|
||||
│ ├── EpicDescriptionInput
|
||||
│ └── RichTextEditor (content)
|
||||
└── ColorPicker
|
||||
```
|
||||
|
||||
## Next Steps
|
||||
|
||||
### Immediate Actions Required
|
||||
|
||||
1. **Run Database Migration**
|
||||
```bash
|
||||
cd backend
|
||||
flask db upgrade
|
||||
```
|
||||
|
||||
2. **Create Backend Routes** (`backend/app/routes/kanban/epics.py`)
|
||||
- CRUD operations for Epics
|
||||
- Epic-Card linking endpoints
|
||||
- Wiki CRUD operations
|
||||
- Wiki-Entity linking endpoints
|
||||
|
||||
3. **Create Backend Schemas** (`backend/app/schemas/epic.py`, `wiki.py`)
|
||||
- Marshmallow schemas for serialization
|
||||
- Input validation
|
||||
|
||||
4. **Update BoardEpics Page** (`frontend/src/pages/BoardEpics.tsx`)
|
||||
- Implement epic list view
|
||||
- Add create epic modal
|
||||
- Add epic detail view
|
||||
- Include RichTextEditor for epic content
|
||||
|
||||
5. **Update Card Detail Page** (`frontend/src/pages/CardDetail.tsx`)
|
||||
- Add epic selector dropdown
|
||||
- Display linked epic information
|
||||
- Add wiki links
|
||||
|
||||
### Future Enhancements
|
||||
|
||||
1. **Wiki Features**
|
||||
- Wiki page with sidebar navigation
|
||||
- Markdown export/import
|
||||
- Wiki search functionality
|
||||
- Version history
|
||||
|
||||
2. **Epic Features**
|
||||
- Epic progress tracking (cards completed vs total)
|
||||
- Epic metrics dashboard
|
||||
- Epic drag-and-drop reordering
|
||||
- Epic timeline view
|
||||
|
||||
3. **UI/UX Improvements**
|
||||
- Epic color picker with presets
|
||||
- Epic hierarchy tree view
|
||||
- Inline card epic assignment
|
||||
- Epic templates
|
||||
|
||||
4. **Analytics**
|
||||
- Epic completion rate
|
||||
- Time spent on epic
|
||||
- Epic size distribution
|
||||
- Wiki usage statistics
|
||||
|
||||
## Database Schema
|
||||
|
||||
### Epic Table
|
||||
```sql
|
||||
CREATE TABLE epics (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(200) NOT NULL,
|
||||
description TEXT,
|
||||
content JSONB,
|
||||
color VARCHAR(7),
|
||||
closed BOOLEAN DEFAULT FALSE,
|
||||
pos FLOAT,
|
||||
depth_limit INTEGER,
|
||||
board_id INTEGER REFERENCES boards(id),
|
||||
parent_epic_id INTEGER REFERENCES epics(id),
|
||||
date_last_activity TIMESTAMP,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
```
|
||||
|
||||
### Wiki Table
|
||||
```sql
|
||||
CREATE TABLE wikis (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(200) NOT NULL,
|
||||
slug VARCHAR(200) UNIQUE,
|
||||
content JSONB NOT NULL,
|
||||
summary TEXT,
|
||||
category VARCHAR(100),
|
||||
board_id INTEGER REFERENCES boards(id),
|
||||
created_by INTEGER REFERENCES users(id),
|
||||
updated_by INTEGER REFERENCES users(id),
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
```
|
||||
|
||||
### Wiki Entity Links Table
|
||||
```sql
|
||||
CREATE TABLE wiki_entity_links (
|
||||
wiki_id INTEGER REFERENCES wikis(id) ON DELETE CASCADE,
|
||||
entity_type VARCHAR(50) NOT NULL,
|
||||
entity_id INTEGER NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
linked_by INTEGER REFERENCES users(id),
|
||||
PRIMARY KEY (wiki_id, entity_type, entity_id)
|
||||
);
|
||||
```
|
||||
|
||||
## API Endpoints (To Be Implemented)
|
||||
|
||||
### Epic Endpoints
|
||||
```
|
||||
GET /api/boards/<board_id>/epics
|
||||
POST /api/boards/<board_id>/epics
|
||||
GET /api/epics/<epic_id>
|
||||
PUT /api/epics/<epic_id>
|
||||
DELETE /api/epics/<epic_id>
|
||||
```
|
||||
|
||||
### Epic-Card Linking
|
||||
```
|
||||
POST /api/cards/<card_id>/epics
|
||||
DELETE /api/cards/<card_id>/epics/<epic_id>
|
||||
```
|
||||
|
||||
### Wiki Endpoints
|
||||
```
|
||||
GET /api/boards/<board_id>/wikis
|
||||
POST /api/boards/<board_id>/wikis
|
||||
GET /api/wikis/<wiki_id>
|
||||
PUT /api/wikis/<wiki_id>
|
||||
DELETE /api/wikis/<wiki_id>
|
||||
GET /api/wikis/<wiki_id>/content
|
||||
```
|
||||
|
||||
### Wiki-Entity Linking
|
||||
```
|
||||
POST /api/wikis/<wiki_id>/links
|
||||
DELETE /api/wikis/<wiki_id>/links/<entity_type>/<entity_id>
|
||||
```
|
||||
|
||||
## Testing Considerations
|
||||
|
||||
### Unit Tests
|
||||
- Epic model creation and relationships
|
||||
- Wiki model creation and relationships
|
||||
- Epic hierarchy validation
|
||||
- Wiki slug generation
|
||||
- Rich text content serialization
|
||||
|
||||
### Integration Tests
|
||||
- Epic CRUD operations
|
||||
- Wiki CRUD operations
|
||||
- Epic-Card linking
|
||||
- Wiki-Entity linking
|
||||
- Epic hierarchy operations
|
||||
|
||||
### Frontend Tests
|
||||
- RichTextEditor component
|
||||
- RichTextContent component
|
||||
- useEpics hook
|
||||
- Epic list rendering
|
||||
- Epic creation form
|
||||
|
||||
## Notes
|
||||
|
||||
- **Slate.js Content**: Content is stored as raw Slate.js JSON format, which includes element types, text nodes, and formatting information
|
||||
- **Hierarchical Epics**: While supported, UI for nesting epics is not yet implemented
|
||||
- **Wiki Slugs**: Slugs should be auto-generated from wiki names and checked for uniqueness
|
||||
- **Color Codes**: Epic colors should be valid hex codes (e.g., "#ef4444")
|
||||
- **Position**: Epic positions use float values for flexible reordering (similar to Trello)
|
||||
|
||||
## Related Files
|
||||
|
||||
### Backend
|
||||
- `backend/app/models/epic.py` - Epic model
|
||||
- `backend/app/models/wiki.py` - Wiki model
|
||||
- `backend/app/models/card.py` - Updated Card model
|
||||
- `backend/app/models/__init__.py` - Model imports
|
||||
- `backend/migrations/versions/6fc439155ced_add_epic_and_wiki_models.py` - Migration
|
||||
|
||||
### Frontend
|
||||
- `frontend/src/types/epic.ts` - TypeScript interfaces
|
||||
- `frontend/src/components/RichTextEditor.tsx` - Editor component
|
||||
- `frontend/src/components/RichTextContent.tsx` - Content renderer
|
||||
- `frontend/src/hooks/useEpics.ts` - Epic custom hook
|
||||
- `frontend/src/hooks/useApi.ts` - Updated API methods
|
||||
|
|
@ -1,238 +0,0 @@
|
|||
# Epic and Wiki Models - Implementation Summary
|
||||
|
||||
## Overview
|
||||
This document summarizes the implementation of Epic and Wiki models for the Kanban application.
|
||||
|
||||
## What Was Implemented
|
||||
|
||||
### 1. Epic Model (`backend/app/models/epic.py`)
|
||||
- Tracks large features across multiple cards
|
||||
- Hierarchical structure (parent-child epics)
|
||||
- Rich text content support (Slate.js JSON)
|
||||
- Color-coded badges for visual identification
|
||||
- Metrics tracking (card count)
|
||||
|
||||
**Key Fields:**
|
||||
- `id`, `name`, `description`, `content` (JSONB)
|
||||
- `color` (hex code for epic badge)
|
||||
- `closed`, `pos`, `depth_limit` (default 5)
|
||||
- `board_id`, `parent_epic_id`
|
||||
- `date_last_activity`, `created_at`, `updated_at`
|
||||
- `metrics` (JSONB - stores card_count)
|
||||
|
||||
**Relationships:**
|
||||
- Board: One-to-many (Board has many Epics)
|
||||
- Cards: One-to-many (Epic has many Cards)
|
||||
- Parent Epic: Self-referential (hierarchical)
|
||||
- File Attachments: Polymorphic (like Cards)
|
||||
|
||||
### 2. Wiki Model (`backend/app/models/wiki.py`)
|
||||
- Reusable rich text content within a board
|
||||
- Board-scoped (not global across all boards)
|
||||
- Polymorphic links to entities (Card, Epic, etc.)
|
||||
- Categorization and tagging support
|
||||
|
||||
**Key Fields:**
|
||||
- `id`, `name`, `slug` (URL-friendly)
|
||||
- `content` (JSONB - rich text)
|
||||
- `summary`, `category`, `tags` (JSONB)
|
||||
- `board_id`, `created_by`, `updated_by`
|
||||
- `created_at`, `updated_at`
|
||||
|
||||
**Relationships:**
|
||||
- Board: One-to-many (Board has many Wikis)
|
||||
- Entities: Many-to-many polymorphic (via wiki_entity_links)
|
||||
|
||||
### 3. Card Model Updates (`backend/app/models/card.py`)
|
||||
- Added `epic_id` foreign key (nullable)
|
||||
- Updated `to_dict()` to include `epic_id`
|
||||
- One-to-one relationship: Card belongs to one Epic
|
||||
|
||||
### 4. Association Table (`wiki_entity_links`)
|
||||
- Polymorphic many-to-many table
|
||||
- Links wikis to any entity type
|
||||
- Fields: `wiki_id`, `entity_type`, `entity_id`, `created_at`, `linked_by`
|
||||
|
||||
## Database Schema
|
||||
|
||||
### New Tables
|
||||
1. **epics** - Epic records
|
||||
2. **wikis** - Wiki content records
|
||||
3. **wiki_entity_links** - Wiki-to-entity associations
|
||||
|
||||
### Modified Tables
|
||||
1. **cards** - Added `epic_id` foreign key
|
||||
|
||||
### Relationships Diagram
|
||||
```
|
||||
Board (1) ----< (N) Epic
|
||||
Epic (1) ----< (N) Card (each card belongs to one epic)
|
||||
Epic (1) ----< (N) Epic (parent-child hierarchy)
|
||||
|
||||
Board (1) ----< (N) Wiki
|
||||
Wiki (M) ----> (M) Entity (polymorphic: Card, Epic)
|
||||
```
|
||||
|
||||
## Database Migration
|
||||
|
||||
**File:** `backend/migrations/versions/add_epic_and_wiki_models.py`
|
||||
|
||||
**Creates:**
|
||||
- `epics` table with indexes on `board_id`, `closed`, `name`
|
||||
- `wikis` table with indexes on `board_id`, `name`, `slug`
|
||||
- `wiki_entity_links` table with composite primary key
|
||||
- `epic_id` column in `cards` table with index and foreign key
|
||||
|
||||
**To apply migration:**
|
||||
```bash
|
||||
cd backend
|
||||
flask db upgrade
|
||||
```
|
||||
|
||||
**To rollback:**
|
||||
```bash
|
||||
flask db downgrade
|
||||
```
|
||||
|
||||
## Model Exports
|
||||
|
||||
Updated `backend/app/models/__init__.py` to include:
|
||||
- `from app.models.epic import Epic`
|
||||
- `from app.models.wiki import Wiki`
|
||||
|
||||
Added to `__all__` list: `"Epic"`, `"Wiki"`
|
||||
|
||||
## Design Decisions
|
||||
|
||||
### Why "Wiki" instead of "Document"?
|
||||
- Avoids confusion with file attachments
|
||||
- Emphasizes reusable knowledge content
|
||||
- Better semantic meaning for rich text resources
|
||||
|
||||
### One-to-Many Epic-Card Relationship
|
||||
- Simpler, clearer ownership
|
||||
- Each card belongs to one epic
|
||||
- Easier to query and display
|
||||
|
||||
### Board-Scoped Wikis
|
||||
- Wikis belong to a specific board
|
||||
- Not global across all boards
|
||||
- Better organization and access control
|
||||
|
||||
### Epic Hierarchy Depth
|
||||
- Default depth limit: 5 levels
|
||||
- Configurable per epic
|
||||
- Backend should enforce when creating child epics
|
||||
|
||||
### Simplified Metrics
|
||||
- Currently only tracks `card_count`
|
||||
- Stored in JSONB field: `{"card_count": 10}`
|
||||
- Easy to extend with more metrics later
|
||||
|
||||
## Next Steps
|
||||
|
||||
### Backend Implementation
|
||||
1. ✅ Create models - **DONE**
|
||||
2. ✅ Create database migration - **DONE**
|
||||
3. ⏭️ Create schemas for serialization
|
||||
4. ⏭️ Create API routes (CRUD operations)
|
||||
5. ⏭️ Create services for business logic
|
||||
6. ⏭️ Add validation for epic depth limit
|
||||
7. ⏭️ Update epic metrics when cards change
|
||||
8. ⏭️ Write tests for models and routes
|
||||
|
||||
### Frontend Implementation
|
||||
1. ⏭️ Update TypeScript types
|
||||
2. ⏭️ Create Epic page/component
|
||||
3. ⏭️ Create Wiki page/component
|
||||
4. ⏭️ Add epic dropdown to card detail
|
||||
5. ⏭️ Create epic list on board detail
|
||||
6. ⏭️ Implement rich text editor (Slate.js)
|
||||
7. ⏭️ Add wiki linking UI
|
||||
|
||||
### API Endpoints (Future)
|
||||
|
||||
#### Epic Endpoints
|
||||
```
|
||||
GET /api/boards/{board_id}/epics # List all epics for board
|
||||
POST /api/boards/{board_id}/epics # Create epic
|
||||
GET /api/epics/{epic_id} # Get epic details with cards
|
||||
PUT /api/epics/{epic_id} # Update epic
|
||||
DELETE /api/epics/{epic_id} # Delete epic
|
||||
POST /api/epics/{epic_id}/cards # Create card directly in epic
|
||||
GET /api/epics/{epic_id}/tree # Get epic hierarchy tree
|
||||
```
|
||||
|
||||
#### Wiki Endpoints
|
||||
```
|
||||
GET /api/boards/{board_id}/wikis # List all wikis for board
|
||||
POST /api/boards/{board_id}/wikis # Create wiki
|
||||
GET /api/wikis/{wiki_id} # Get wiki details
|
||||
PUT /api/wikis/{wiki_id} # Update wiki
|
||||
DELETE /api/wikis/{wiki_id} # Delete wiki
|
||||
POST /api/wikis/{wiki_id}/links # Link wiki to entity
|
||||
DELETE /api/wikis/{wiki_id}/links/{link_id} # Unlink from entity
|
||||
GET /api/{entity_type}/{entity_id}/wikis # Get wikis for entity
|
||||
```
|
||||
|
||||
#### Card Endpoints (Updated)
|
||||
```
|
||||
PUT /api/cards/{card_id}/epic # Link card to epic (or null to unlink)
|
||||
GET /api/cards/{card_id}/epic # Get card's epic
|
||||
```
|
||||
|
||||
## Files Created/Modified
|
||||
|
||||
### Created
|
||||
- `backend/app/models/epic.py`
|
||||
- `backend/app/models/wiki.py`
|
||||
- `backend/migrations/versions/add_epic_and_wiki_models.py`
|
||||
|
||||
### Modified
|
||||
- `backend/app/models/card.py` (added epic_id)
|
||||
- `backend/app/models/__init__.py` (added Epic, Wiki imports)
|
||||
|
||||
## Testing the Implementation
|
||||
|
||||
1. **Apply migration:**
|
||||
```bash
|
||||
cd backend
|
||||
source venv/bin/activate
|
||||
flask db upgrade
|
||||
```
|
||||
|
||||
2. **Verify tables created:**
|
||||
```bash
|
||||
flask dbcurrent
|
||||
# Should show: add_epic_and_wiki_models
|
||||
```
|
||||
|
||||
3. **Test in Python shell:**
|
||||
```python
|
||||
from app import create_app, db
|
||||
from app.models import Epic, Wiki, Card, Board
|
||||
|
||||
app = create_app()
|
||||
with app.app_context():
|
||||
# Create an epic
|
||||
epic = Epic(name="My Epic", board_id=1, color="#3b82f6")
|
||||
db.session.add(epic)
|
||||
db.session.commit()
|
||||
|
||||
# Create a wiki
|
||||
wiki = Wiki(name="Security Guide", board_id=1, content={})
|
||||
db.session.add(wiki)
|
||||
db.session.commit()
|
||||
|
||||
print(f"Epic created: {epic.to_dict()}")
|
||||
print(f"Wiki created: {wiki.to_dict()}")
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- All models follow the existing project patterns
|
||||
- Uses `db` from `app` module (not `flask_sqlalchemy` directly)
|
||||
- Proper foreign key constraints with CASCADE/SET NULL
|
||||
- Timestamps use UTC timezone
|
||||
- JSONB fields for flexible data storage
|
||||
- Indexed for optimal query performance
|
||||
|
|
@ -88,7 +88,7 @@ class TestingConfig(Config):
|
|||
|
||||
# Conservative connection pool settings for testing
|
||||
SQLALCHEMY_ENGINE_OPTIONS = {
|
||||
"pool_size": 4, # Only one connection in the pool
|
||||
"pool_size": 1, # Only one connection in the pool
|
||||
"max_overflow": 0, # No overflow connections allowed
|
||||
"pool_timeout": 30,
|
||||
"pool_recycle": 3600, # Recycle after 1 hour
|
||||
|
|
|
|||
21
backend/app/decorators/__init__.py
Normal file
21
backend/app/decorators/__init__.py
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
from app.decorators.owned import (load_board_owned, load_card_owned,
|
||||
load_check_item_owned, load_checklist_owned,
|
||||
load_comment_owned, load_file_owned,
|
||||
load_list_owned)
|
||||
|
||||
from .decorators import get_current_user_id, load_file_accessible_by_uuid
|
||||
from .schema_validations import serialize_list_response, serialize_response
|
||||
|
||||
__all__ = [
|
||||
"load_board_owned",
|
||||
"load_card_owned",
|
||||
"load_list_owned",
|
||||
"load_checklist_owned",
|
||||
"load_check_item_owned",
|
||||
"load_comment_owned",
|
||||
"load_file_owned",
|
||||
"get_current_user_id",
|
||||
"load_file_accessible_by_uuid",
|
||||
"serialize_response",
|
||||
"serialize_list_response",
|
||||
]
|
||||
|
|
@ -3,8 +3,7 @@ from functools import wraps
|
|||
from flask import abort, g
|
||||
from flask_jwt_extended import get_jwt_identity
|
||||
|
||||
from app.models import (Board, Card, CheckItem, Checklist, Comment,
|
||||
FileAttachment, List)
|
||||
from app.models import Board, Card, Comment, FileAttachment
|
||||
|
||||
|
||||
def get_current_user_id():
|
||||
|
|
@ -15,176 +14,6 @@ def get_current_user_id():
|
|||
return g.jwt_identity
|
||||
|
||||
|
||||
def load_board_owned(f):
|
||||
"""
|
||||
Loads a Board and ensures it belongs to the current user.
|
||||
Injects 'board' into the route kwargs.
|
||||
Aborts with 404 if not found or not owned.
|
||||
"""
|
||||
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
user_id = get_current_user_id()
|
||||
board_id = kwargs.get("board_id")
|
||||
|
||||
# SECURE QUERY: Filter by ID *and* User ID in the DB
|
||||
board = Board.query.filter_by(id=board_id, user_id=user_id).first()
|
||||
|
||||
if not board:
|
||||
abort(404)
|
||||
|
||||
kwargs["board"] = board
|
||||
return f(*args, **kwargs)
|
||||
|
||||
return decorated_function
|
||||
|
||||
|
||||
def load_card_owned(f):
|
||||
"""
|
||||
Loads a Card and ensures its Parent Board belongs to the current user.
|
||||
Injects 'card' into the route kwargs.
|
||||
"""
|
||||
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
user_id = get_current_user_id()
|
||||
card_id = kwargs.get("card_id")
|
||||
|
||||
# Join Board to check ownership securely in one query
|
||||
card = (
|
||||
Card.query.join(Board)
|
||||
.filter(Card.id == card_id, Board.user_id == user_id)
|
||||
.first()
|
||||
)
|
||||
|
||||
if not card:
|
||||
abort(404)
|
||||
|
||||
kwargs["card"] = card
|
||||
return f(*args, **kwargs)
|
||||
|
||||
return decorated_function
|
||||
|
||||
|
||||
def load_list_owned(f):
|
||||
"""Loads a List ensuring Parent Board ownership."""
|
||||
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
user_id = get_current_user_id()
|
||||
list_id = kwargs.get("list_id")
|
||||
|
||||
lst = (
|
||||
List.query.join(Board)
|
||||
.filter(List.id == list_id, Board.user_id == user_id)
|
||||
.first()
|
||||
)
|
||||
|
||||
if not lst:
|
||||
abort(404)
|
||||
|
||||
kwargs["lst"] = lst
|
||||
return f(*args, **kwargs)
|
||||
|
||||
return decorated_function
|
||||
|
||||
|
||||
def load_checklist_owned(f):
|
||||
"""Loads a Checklist ensuring Parent Board ownership."""
|
||||
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
user_id = get_current_user_id()
|
||||
checklist_id = kwargs.get("checklist_id")
|
||||
|
||||
checklist = (
|
||||
Checklist.query.join(Card)
|
||||
.join(Board)
|
||||
.filter(Checklist.id == checklist_id, Board.user_id == user_id)
|
||||
.first()
|
||||
)
|
||||
|
||||
if not checklist:
|
||||
abort(404)
|
||||
|
||||
kwargs["checklist"] = checklist
|
||||
return f(*args, **kwargs)
|
||||
|
||||
return decorated_function
|
||||
|
||||
|
||||
def load_check_item_owned(f):
|
||||
"""Loads a CheckItem ensuring Parent Board ownership."""
|
||||
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
user_id = get_current_user_id()
|
||||
item_id = kwargs.get("item_id")
|
||||
|
||||
check_item = (
|
||||
CheckItem.query.join(Checklist)
|
||||
.join(Card)
|
||||
.join(Board)
|
||||
.filter(CheckItem.id == item_id, Board.user_id == user_id)
|
||||
.first()
|
||||
)
|
||||
|
||||
if not check_item:
|
||||
abort(404)
|
||||
|
||||
kwargs["check_item"] = check_item
|
||||
return f(*args, **kwargs)
|
||||
|
||||
return decorated_function
|
||||
|
||||
|
||||
def load_comment_owned(f):
|
||||
"""
|
||||
Loads a Comment ensuring the Comment itself belongs to the user.
|
||||
(Based on schema where Comment has user_id)
|
||||
"""
|
||||
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
user_id = get_current_user_id()
|
||||
comment_id = kwargs.get("comment_id")
|
||||
|
||||
comment = Comment.query.filter_by(id=comment_id, user_id=user_id).first()
|
||||
|
||||
if not comment:
|
||||
abort(404)
|
||||
|
||||
kwargs["comment"] = comment
|
||||
return f(*args, **kwargs)
|
||||
|
||||
return decorated_function
|
||||
|
||||
|
||||
def load_file_owned(f):
|
||||
"""
|
||||
Loads a FileAttachment ensuring it belongs to the user.
|
||||
Aborts with 404 if not found or not owned.
|
||||
"""
|
||||
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
user_id = get_current_user_id()
|
||||
file_id = kwargs.get("file_id")
|
||||
|
||||
# Filter by ID and user ID
|
||||
attachment = FileAttachment.query.filter_by(
|
||||
id=file_id, uploaded_by=user_id
|
||||
).first()
|
||||
|
||||
if not attachment:
|
||||
abort(404)
|
||||
|
||||
kwargs["file"] = attachment
|
||||
return f(*args, **kwargs)
|
||||
|
||||
return decorated_function
|
||||
|
||||
|
||||
def load_file_accessible(f):
|
||||
"""
|
||||
Loads a FileAttachment ensuring the user can access it.
|
||||
177
backend/app/decorators/owned.py
Normal file
177
backend/app/decorators/owned.py
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
from functools import wraps
|
||||
|
||||
from flask import abort
|
||||
|
||||
from app.decorators.decorators import get_current_user_id
|
||||
from app.models import (Board, Card, CheckItem, Checklist, Comment,
|
||||
FileAttachment, List)
|
||||
|
||||
|
||||
def load_board_owned(f):
|
||||
"""
|
||||
Loads a Board and ensures it belongs to the current user.
|
||||
Injects 'board' into the route kwargs.
|
||||
Aborts with 404 if not found or not owned.
|
||||
"""
|
||||
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
user_id = get_current_user_id()
|
||||
board_id = kwargs.get("board_id")
|
||||
|
||||
# SECURE QUERY: Filter by ID *and* User ID in the DB
|
||||
board = Board.query.filter_by(id=board_id, user_id=user_id).first()
|
||||
|
||||
if not board:
|
||||
abort(404)
|
||||
|
||||
kwargs["board"] = board
|
||||
return f(*args, **kwargs)
|
||||
|
||||
return decorated_function
|
||||
|
||||
|
||||
def load_card_owned(f):
|
||||
"""
|
||||
Loads a Card and ensures its Parent Board belongs to the current user.
|
||||
Injects 'card' into the route kwargs.
|
||||
"""
|
||||
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
user_id = get_current_user_id()
|
||||
card_id = kwargs.get("card_id")
|
||||
|
||||
# Join Board to check ownership securely in one query
|
||||
card = (
|
||||
Card.query.join(Board)
|
||||
.filter(Card.id == card_id, Board.user_id == user_id)
|
||||
.first()
|
||||
)
|
||||
|
||||
if not card:
|
||||
abort(404)
|
||||
|
||||
kwargs["card"] = card
|
||||
return f(*args, **kwargs)
|
||||
|
||||
return decorated_function
|
||||
|
||||
|
||||
def load_list_owned(f):
|
||||
"""Loads a List ensuring Parent Board ownership."""
|
||||
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
user_id = get_current_user_id()
|
||||
list_id = kwargs.get("list_id")
|
||||
|
||||
lst = (
|
||||
List.query.join(Board)
|
||||
.filter(List.id == list_id, Board.user_id == user_id)
|
||||
.first()
|
||||
)
|
||||
|
||||
if not lst:
|
||||
abort(404)
|
||||
|
||||
kwargs["lst"] = lst
|
||||
return f(*args, **kwargs)
|
||||
|
||||
return decorated_function
|
||||
|
||||
|
||||
def load_checklist_owned(f):
|
||||
"""Loads a Checklist ensuring Parent Board ownership."""
|
||||
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
user_id = get_current_user_id()
|
||||
checklist_id = kwargs.get("checklist_id")
|
||||
|
||||
checklist = (
|
||||
Checklist.query.join(Card)
|
||||
.join(Board)
|
||||
.filter(Checklist.id == checklist_id, Board.user_id == user_id)
|
||||
.first()
|
||||
)
|
||||
|
||||
if not checklist:
|
||||
abort(404)
|
||||
|
||||
kwargs["checklist"] = checklist
|
||||
return f(*args, **kwargs)
|
||||
|
||||
return decorated_function
|
||||
|
||||
|
||||
def load_check_item_owned(f):
|
||||
"""Loads a CheckItem ensuring Parent Board ownership."""
|
||||
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
user_id = get_current_user_id()
|
||||
item_id = kwargs.get("item_id")
|
||||
|
||||
check_item = (
|
||||
CheckItem.query.join(Checklist)
|
||||
.join(Card)
|
||||
.join(Board)
|
||||
.filter(CheckItem.id == item_id, Board.user_id == user_id)
|
||||
.first()
|
||||
)
|
||||
|
||||
if not check_item:
|
||||
abort(404)
|
||||
|
||||
kwargs["check_item"] = check_item
|
||||
return f(*args, **kwargs)
|
||||
|
||||
return decorated_function
|
||||
|
||||
|
||||
def load_comment_owned(f):
|
||||
"""
|
||||
Loads a Comment ensuring the Comment itself belongs to the user.
|
||||
(Based on schema where Comment has user_id)
|
||||
"""
|
||||
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
user_id = get_current_user_id()
|
||||
comment_id = kwargs.get("comment_id")
|
||||
|
||||
comment = Comment.query.filter_by(id=comment_id, user_id=user_id).first()
|
||||
|
||||
if not comment:
|
||||
abort(404)
|
||||
|
||||
kwargs["comment"] = comment
|
||||
return f(*args, **kwargs)
|
||||
|
||||
return decorated_function
|
||||
|
||||
|
||||
def load_file_owned(f):
|
||||
"""
|
||||
Loads a FileAttachment ensuring it belongs to the user.
|
||||
Aborts with 404 if not found or not owned.
|
||||
"""
|
||||
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
user_id = get_current_user_id()
|
||||
file_id = kwargs.get("file_id")
|
||||
|
||||
# Filter by ID and user ID
|
||||
attachment = FileAttachment.query.filter_by(
|
||||
id=file_id, uploaded_by=user_id
|
||||
).first()
|
||||
|
||||
if not attachment:
|
||||
abort(404)
|
||||
|
||||
kwargs["file"] = attachment
|
||||
return f(*args, **kwargs)
|
||||
|
||||
return decorated_function
|
||||
38
backend/app/decorators/schema_validations.py
Normal file
38
backend/app/decorators/schema_validations.py
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
from functools import wraps
|
||||
|
||||
from flask import jsonify
|
||||
|
||||
|
||||
def serialize_response(model):
|
||||
"""Decorator to serialize single entity response"""
|
||||
|
||||
def decorator(f):
|
||||
@wraps(f)
|
||||
def wrapper(*args, **kwargs):
|
||||
result, status = f(*args, **kwargs)
|
||||
if status not in [200, 201]:
|
||||
return result, status
|
||||
return jsonify(model.model_validate(result).model_dump()), status
|
||||
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def serialize_list_response(model):
|
||||
"""Decorator to serialize list response"""
|
||||
|
||||
def decorator(f):
|
||||
@wraps(f)
|
||||
def wrapper(*args, **kwargs):
|
||||
results, status = f(*args, **kwargs)
|
||||
if status not in [200, 201]:
|
||||
return results, status
|
||||
return (
|
||||
jsonify([model.model_validate(item).model_dump() for item in results]),
|
||||
status,
|
||||
)
|
||||
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
|
|
@ -8,16 +8,11 @@ from app.models.epic import Epic
|
|||
from app.models.file_attachment import FileAttachment
|
||||
from app.models.label import Label
|
||||
from app.models.list_model import List
|
||||
from app.models.order import Order, OrderItem
|
||||
from app.models.product import Product
|
||||
from app.models.user import User
|
||||
from app.models.wiki import Wiki, wiki_entity_links
|
||||
|
||||
__all__ = [
|
||||
"User",
|
||||
"Product",
|
||||
"Order",
|
||||
"OrderItem",
|
||||
"Board",
|
||||
"List",
|
||||
"Card",
|
||||
|
|
|
|||
|
|
@ -1,75 +0,0 @@
|
|||
from datetime import UTC, datetime
|
||||
|
||||
from app import db
|
||||
|
||||
|
||||
class Order(db.Model):
|
||||
"""Order model"""
|
||||
|
||||
__tablename__ = "orders"
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False)
|
||||
status = db.Column(db.String(20), default="pending", index=True)
|
||||
total_amount = db.Column(db.Numeric(10, 2), nullable=False)
|
||||
shipping_address = db.Column(db.Text)
|
||||
created_at = db.Column(db.DateTime, default=lambda: datetime.now(UTC))
|
||||
updated_at = db.Column(
|
||||
db.DateTime,
|
||||
default=lambda: datetime.now(UTC),
|
||||
onupdate=lambda: datetime.now(UTC),
|
||||
)
|
||||
|
||||
# Relationships
|
||||
user = db.relationship("User", back_populates="orders")
|
||||
items = db.relationship(
|
||||
"OrderItem",
|
||||
back_populates="order",
|
||||
lazy="dynamic",
|
||||
cascade="all, delete-orphan",
|
||||
)
|
||||
|
||||
def to_dict(self):
|
||||
"""Convert order to dictionary"""
|
||||
return {
|
||||
"id": self.id,
|
||||
"user_id": self.user_id,
|
||||
"status": self.status,
|
||||
"total_amount": float(self.total_amount) if self.total_amount else None,
|
||||
"shipping_address": self.shipping_address,
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
|
||||
"items": [item.to_dict() for item in self.items],
|
||||
}
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Order {self.id}>"
|
||||
|
||||
|
||||
class OrderItem(db.Model):
|
||||
"""Order Item model"""
|
||||
|
||||
__tablename__ = "order_items"
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
order_id = db.Column(db.Integer, db.ForeignKey("orders.id"), nullable=False)
|
||||
product_id = db.Column(db.Integer, db.ForeignKey("products.id"), nullable=False)
|
||||
quantity = db.Column(db.Integer, nullable=False)
|
||||
price = db.Column(db.Numeric(10, 2), nullable=False)
|
||||
|
||||
# Relationships
|
||||
order = db.relationship("Order", back_populates="items")
|
||||
product = db.relationship("Product", back_populates="order_items")
|
||||
|
||||
def to_dict(self):
|
||||
"""Convert order item to dictionary"""
|
||||
return {
|
||||
"id": self.id,
|
||||
"order_id": self.order_id,
|
||||
"product_id": self.product_id,
|
||||
"quantity": self.quantity,
|
||||
"price": float(self.price) if self.price else None,
|
||||
}
|
||||
|
||||
def __repr__(self):
|
||||
return f"<OrderItem {self.id}>"
|
||||
|
|
@ -1,43 +0,0 @@
|
|||
from datetime import UTC, datetime
|
||||
|
||||
from app import db
|
||||
|
||||
|
||||
class Product(db.Model):
|
||||
"""Product model"""
|
||||
|
||||
__tablename__ = "products"
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
name = db.Column(db.String(200), nullable=False, index=True)
|
||||
description = db.Column(db.Text)
|
||||
price = db.Column(db.Numeric(10, 2), nullable=False)
|
||||
stock = db.Column(db.Integer, default=0)
|
||||
image_url = db.Column(db.String(500))
|
||||
is_active = db.Column(db.Boolean, default=True)
|
||||
created_at = db.Column(db.DateTime, default=lambda: datetime.now(UTC))
|
||||
updated_at = db.Column(
|
||||
db.DateTime,
|
||||
default=lambda: datetime.now(UTC),
|
||||
onupdate=lambda: datetime.now(UTC),
|
||||
)
|
||||
|
||||
# Relationships
|
||||
order_items = db.relationship("OrderItem", back_populates="product", lazy="dynamic")
|
||||
|
||||
def to_dict(self):
|
||||
"""Convert product to dictionary"""
|
||||
return {
|
||||
"id": self.id,
|
||||
"name": self.name,
|
||||
"description": self.description,
|
||||
"price": float(self.price) if self.price else None,
|
||||
"stock": self.stock,
|
||||
"image_url": self.image_url,
|
||||
"is_active": self.is_active,
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
|
||||
}
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Product {self.name}>"
|
||||
|
|
@ -26,7 +26,6 @@ class User(db.Model):
|
|||
)
|
||||
|
||||
# Relationships
|
||||
orders = db.relationship("Order", back_populates="user", lazy="dynamic")
|
||||
boards = db.relationship(
|
||||
"Board", backref="user", cascade="all, delete-orphan", lazy="dynamic"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -76,336 +76,3 @@ def get_current_user():
|
|||
return jsonify({"error": "User not found"}), 404
|
||||
|
||||
return jsonify(user.to_dict()), 200
|
||||
|
||||
|
||||
# # Product Routes
|
||||
# @api_bp.route("/products", methods=["GET"])
|
||||
# def get_products():
|
||||
# """Get all products"""
|
||||
|
||||
# # time.sleep(5) # This adds a 5 second delay
|
||||
|
||||
# products = Product.query.filter_by(is_active=True).all()
|
||||
|
||||
# return jsonify([product.to_dict() for product in products]), 200
|
||||
|
||||
|
||||
# @api_bp.route("/products/<int:product_id>", methods=["GET"])
|
||||
# def get_product(product_id):
|
||||
# """Get a single product"""
|
||||
# product = db.session.get(Product, product_id)
|
||||
# if not product:
|
||||
# return jsonify({"error": "Product not found"}), 404
|
||||
# return jsonify(product.to_dict()), 200
|
||||
|
||||
|
||||
# @api_bp.route("/products", methods=["POST"])
|
||||
# @jwt_required()
|
||||
# def create_product():
|
||||
# """Create a new product (admin only)"""
|
||||
# user_id = int(get_jwt_identity())
|
||||
# user = db.session.get(User, user_id)
|
||||
|
||||
# if not user or not user.is_admin:
|
||||
# return jsonify({"error": "Admin access required"}), 403
|
||||
|
||||
# try:
|
||||
# # Validate request data using Pydantic schema
|
||||
# product_data = ProductCreateRequest(**request.get_json())
|
||||
|
||||
# product = Product(
|
||||
# name=product_data.name,
|
||||
# description=product_data.description,
|
||||
# price=product_data.price,
|
||||
# stock=product_data.stock,
|
||||
# image_url=product_data.image_url,
|
||||
# )
|
||||
|
||||
# db.session.add(product)
|
||||
# db.session.commit()
|
||||
|
||||
# # Use Pydantic schema for response
|
||||
# response = ProductResponse.model_validate(product)
|
||||
# return jsonify(response.model_dump()), 201
|
||||
|
||||
# except ValidationError as e:
|
||||
# print(f"Pydantic Validation Error: {e.errors()}")
|
||||
# return jsonify({"error": "Validation error", "details": e.errors()}), 400
|
||||
|
||||
|
||||
# @api_bp.route("/products/<int:product_id>", methods=["PUT"])
|
||||
# @jwt_required()
|
||||
# def update_product(product_id):
|
||||
# """Update a product (admin only)"""
|
||||
# user_id = int(get_jwt_identity())
|
||||
# user = db.session.get(User, user_id)
|
||||
|
||||
# if not user or not user.is_admin:
|
||||
# return jsonify({"error": "Admin access required"}), 403
|
||||
|
||||
# product = db.session.get(Product, product_id)
|
||||
# if not product:
|
||||
# return jsonify({"error": "Product not found"}), 404
|
||||
|
||||
# data = request.get_json()
|
||||
|
||||
# product.name = data.get("name", product.name)
|
||||
# product.description = data.get("description", product.description)
|
||||
# product.price = data.get("price", product.price)
|
||||
# product.stock = data.get("stock", product.stock)
|
||||
# product.image_url = data.get("image_url", product.image_url)
|
||||
# product.is_active = data.get("is_active", product.is_active)
|
||||
|
||||
# db.session.commit()
|
||||
|
||||
# return jsonify(product.to_dict()), 200
|
||||
|
||||
|
||||
# @api_bp.route("/products/<int:product_id>", methods=["DELETE"])
|
||||
# @jwt_required()
|
||||
# def delete_product(product_id):
|
||||
# """Delete a product (admin only)"""
|
||||
# user_id = int(get_jwt_identity())
|
||||
# user = db.session.get(User, user_id)
|
||||
|
||||
# if not user or not user.is_admin:
|
||||
# return jsonify({"error": "Admin access required"}), 403
|
||||
|
||||
# product = db.session.get(Product, product_id)
|
||||
# if not product:
|
||||
# return jsonify({"error": "Product not found"}), 404
|
||||
|
||||
# db.session.delete(product)
|
||||
# db.session.commit()
|
||||
|
||||
# return jsonify({"message": "Product deleted"}), 200
|
||||
|
||||
|
||||
# # Order Routes
|
||||
# @api_bp.route("/orders", methods=["GET"])
|
||||
# @jwt_required()
|
||||
# def get_orders():
|
||||
# """Get all orders for current user"""
|
||||
# user_id = int(get_jwt_identity())
|
||||
# orders = Order.query.filter_by(user_id=user_id).all()
|
||||
# return jsonify([order.to_dict() for order in orders]), 200
|
||||
|
||||
|
||||
# @api_bp.route("/orders", methods=["POST"])
|
||||
# @jwt_required()
|
||||
# def create_order():
|
||||
# """Create a new order"""
|
||||
# user_id = int(get_jwt_identity())
|
||||
# data = request.get_json()
|
||||
|
||||
# if not data or not data.get("items"):
|
||||
# return jsonify({"error": "Order items are required"}), 400
|
||||
|
||||
# total_amount = 0
|
||||
# order_items = []
|
||||
|
||||
# for item_data in data["items"]:
|
||||
# product = db.session.get(Product, item_data["product_id"])
|
||||
# if not product:
|
||||
# return (
|
||||
# jsonify({"error": f'Product {item_data["product_id"]} not found'}),
|
||||
# 404,
|
||||
# )
|
||||
# if product.stock < item_data["quantity"]:
|
||||
# return jsonify({"error": f"Insufficient stock for {product.name}"}), 400
|
||||
|
||||
# item_total = product.price * item_data["quantity"]
|
||||
# total_amount += item_total
|
||||
# order_items.append(
|
||||
# {
|
||||
# "product": product,
|
||||
# "quantity": item_data["quantity"],
|
||||
# "price": product.price,
|
||||
# }
|
||||
# )
|
||||
|
||||
# order = Order(
|
||||
# user_id=user_id,
|
||||
# total_amount=total_amount,
|
||||
# shipping_address=data.get("shipping_address"),
|
||||
# )
|
||||
|
||||
# db.session.add(order)
|
||||
# db.session.flush()
|
||||
|
||||
# for item_data in order_items:
|
||||
# order_item = OrderItem(
|
||||
# order_id=order.id,
|
||||
# product_id=item_data["product"].id,
|
||||
# quantity=item_data["quantity"],
|
||||
# price=item_data["price"],
|
||||
# )
|
||||
# item_data["product"].stock -= item_data["quantity"]
|
||||
# db.session.add(order_item)
|
||||
|
||||
# db.session.commit()
|
||||
|
||||
# return jsonify(order.to_dict()), 201
|
||||
|
||||
|
||||
# @api_bp.route("/orders/<int:order_id>", methods=["GET"])
|
||||
# @jwt_required()
|
||||
# def get_order(order_id):
|
||||
# """Get a single order"""
|
||||
# user_id = int(get_jwt_identity())
|
||||
# order = db.session.get(Order, order_id)
|
||||
# if not order:
|
||||
# return jsonify({"error": "Order not found"}), 404
|
||||
|
||||
# if order.user_id != user_id:
|
||||
# user = db.session.get(User, user_id)
|
||||
# if not user or not user.is_admin:
|
||||
# return jsonify({"error": "Access denied"}), 403
|
||||
|
||||
# return jsonify(order.to_dict()), 200
|
||||
|
||||
|
||||
# # Celery Task Routes
|
||||
# @api_bp.route("/tasks/hello", methods=["POST"])
|
||||
# @jwt_required()
|
||||
# def trigger_hello_task():
|
||||
# """Trigger the hello task"""
|
||||
# data = request.get_json() or {}
|
||||
# name = data.get("name", "World")
|
||||
|
||||
# task = celery.send_task("tasks.print_hello", args=[name])
|
||||
|
||||
# return (
|
||||
# jsonify(
|
||||
# {"message": "Hello task triggered", "task_id": task.id, "status":
|
||||
# "pending"}
|
||||
# ),
|
||||
# 202,
|
||||
# )
|
||||
|
||||
|
||||
# @api_bp.route("/tasks/divide", methods=["POST"])
|
||||
# @jwt_required()
|
||||
# def trigger_divide_task():
|
||||
# """Trigger the divide numbers task"""
|
||||
# data = request.get_json() or {}
|
||||
# x = data.get("x", 10)
|
||||
# y = data.get("y", 2)
|
||||
|
||||
# task = celery.send_task("tasks.divide_numbers", args=[x, y])
|
||||
|
||||
# return (
|
||||
# jsonify(
|
||||
# {
|
||||
# "message": "Divide task triggered",
|
||||
# "task_id": task.id,
|
||||
# "operation": f"{x} / {y}",
|
||||
# "status": "pending",
|
||||
# }
|
||||
# ),
|
||||
# 202,
|
||||
# )
|
||||
|
||||
|
||||
# @api_bp.route("/tasks/report", methods=["POST"])
|
||||
# @jwt_required()
|
||||
# def trigger_report_task():
|
||||
# """Trigger the daily report task"""
|
||||
# task = celery.send_task("tasks.send_daily_report")
|
||||
|
||||
# return (
|
||||
# jsonify(
|
||||
# {
|
||||
# "message": "Daily report task triggered",
|
||||
# "task_id": task.id,
|
||||
# "status": "pending",
|
||||
# }
|
||||
# ),
|
||||
# 202,
|
||||
# )
|
||||
|
||||
|
||||
# @api_bp.route("/tasks/stats", methods=["POST"])
|
||||
# @jwt_required()
|
||||
# def trigger_stats_task():
|
||||
# """Trigger product statistics update task"""
|
||||
# data = request.get_json() or {}
|
||||
# product_id = data.get("product_id")
|
||||
|
||||
# if product_id:
|
||||
# task = celery.send_task("tasks.update_product_statistics", args=[product_id])
|
||||
# message = f"Product statistics update triggered for product {product_id}"
|
||||
# else:
|
||||
# task = celery.send_task("tasks.update_product_statistics", args=[None])
|
||||
# message = "Product statistics update triggered for all products"
|
||||
|
||||
# return jsonify({"message": message, "task_id": task.id, "status": "pending"}), 202
|
||||
|
||||
|
||||
# @api_bp.route("/tasks/long-running", methods=["POST"])
|
||||
# @jwt_required()
|
||||
# def trigger_long_running_task():
|
||||
# """Trigger a long-running task"""
|
||||
# data = request.get_json() or {}
|
||||
# iterations = data.get("iterations", 10)
|
||||
|
||||
# task = celery.send_task("tasks.long_running_task", args=[iterations])
|
||||
|
||||
# return (
|
||||
# jsonify(
|
||||
# {
|
||||
# "message": f"Long-running task triggered with {iterations}
|
||||
# iterations",
|
||||
# "task_id": task.id,
|
||||
# "status": "pending",
|
||||
# }
|
||||
# ),
|
||||
# 202,
|
||||
# )
|
||||
|
||||
|
||||
# @api_bp.route("/tasks/<task_id>", methods=["GET"])
|
||||
# @jwt_required()
|
||||
# def get_task_status(task_id):
|
||||
# """Get the status of a Celery task"""
|
||||
# task_result = celery.AsyncResult(task_id)
|
||||
|
||||
# response = {
|
||||
# "task_id": task_id,
|
||||
# "status": task_result.status,
|
||||
# "ready": task_result.ready(),
|
||||
# }
|
||||
|
||||
# if task_result.ready():
|
||||
# if task_result.successful():
|
||||
# response["result"] = task_result.result
|
||||
# else:
|
||||
# response["error"] = str(task_result.result)
|
||||
# response["traceback"] = task_result.traceback
|
||||
|
||||
# return jsonify(response), 200
|
||||
|
||||
|
||||
# @api_bp.route("/tasks/health", methods=["GET"])
|
||||
# def celery_health():
|
||||
# """Check Celery health"""
|
||||
# try:
|
||||
# # Try to ping the worker
|
||||
# inspector = celery.control.inspect()
|
||||
# stats = inspector.stats()
|
||||
|
||||
# if stats:
|
||||
# return (
|
||||
# jsonify(
|
||||
# {"status": "healthy", "workers": len(stats), "workers_info":
|
||||
# stats}
|
||||
# ),
|
||||
# 200,
|
||||
# )
|
||||
# else:
|
||||
# return (
|
||||
# jsonify({"status": "unhealthy", "message": "No workers available"}),
|
||||
# 503,
|
||||
# )
|
||||
# except Exception as e:
|
||||
# return jsonify({"status": "error", "message": str(e)}), 500
|
||||
|
|
|
|||
|
|
@ -5,7 +5,8 @@ from flask_jwt_extended import jwt_required
|
|||
from flask_pydantic import validate
|
||||
|
||||
from app import db
|
||||
from app.decorators import get_current_user_id, load_board_owned
|
||||
from app.decorators import load_board_owned
|
||||
from app.decorators.decorators import get_current_user_id
|
||||
from app.models import Board, Card, CardLabel, Label, List
|
||||
from app.schemas import (BoardCreateRequest, BoardResponse,
|
||||
BoardWithDetailsResponse)
|
||||
|
|
|
|||
|
|
@ -5,14 +5,17 @@ from flask_jwt_extended import jwt_required
|
|||
from flask_pydantic import validate
|
||||
|
||||
from app import db
|
||||
from app.decorators import serialize_list_response, serialize_response
|
||||
from app.models import Board, Card, Epic
|
||||
from app.schemas import EpicCreateRequest, EpicResponse, EpicUpdateRequest
|
||||
from app.schemas import (CardResponse, EpicCreateRequest, EpicResponse,
|
||||
EpicUpdateRequest)
|
||||
|
||||
from . import kanban_bp
|
||||
|
||||
|
||||
@kanban_bp.route("/boards/<int:board_id>/epics", methods=["GET"])
|
||||
@jwt_required()
|
||||
@serialize_list_response(EpicResponse)
|
||||
def get_board_epics(board_id):
|
||||
"""Get all epics for a board."""
|
||||
board = db.session.get(Board, board_id)
|
||||
|
|
@ -20,12 +23,13 @@ def get_board_epics(board_id):
|
|||
return {"error": "Board not found"}, 404
|
||||
|
||||
epics = Epic.query.filter_by(board_id=board_id).all()
|
||||
return [EpicResponse.model_validate(epic).model_dump() for epic in epics], 200
|
||||
return epics, 200
|
||||
|
||||
|
||||
@kanban_bp.route("/boards/<int:board_id>/epics", methods=["POST"])
|
||||
@jwt_required()
|
||||
@validate(body=EpicCreateRequest)
|
||||
@serialize_response(EpicResponse)
|
||||
def create_epic(board_id, body: EpicCreateRequest):
|
||||
"""Create a new epic for a board."""
|
||||
board = db.session.get(Board, board_id)
|
||||
|
|
@ -49,7 +53,7 @@ def create_epic(board_id, body: EpicCreateRequest):
|
|||
db.session.add(epic)
|
||||
db.session.commit()
|
||||
|
||||
return EpicResponse.model_validate(epic).model_dump(), 201
|
||||
return epic, 201
|
||||
|
||||
|
||||
@kanban_bp.route("/epics/<int:epic_id>", methods=["GET"])
|
||||
|
|
@ -93,6 +97,7 @@ def _update_epic_fields(epic: Epic, body: EpicUpdateRequest) -> None:
|
|||
@kanban_bp.route("/epics/<int:epic_id>", methods=["PUT"])
|
||||
@jwt_required()
|
||||
@validate(body=EpicUpdateRequest)
|
||||
@serialize_response(EpicResponse)
|
||||
def update_epic(epic_id, body: EpicUpdateRequest):
|
||||
"""Update an epic."""
|
||||
epic = db.session.get(Epic, epic_id)
|
||||
|
|
@ -102,7 +107,7 @@ def update_epic(epic_id, body: EpicUpdateRequest):
|
|||
_update_epic_fields(epic, body)
|
||||
db.session.commit()
|
||||
|
||||
return EpicResponse.model_validate(epic).model_dump(), 200
|
||||
return epic, 200
|
||||
|
||||
|
||||
@kanban_bp.route("/epics/<int:epic_id>", methods=["DELETE"])
|
||||
|
|
@ -125,6 +130,7 @@ def delete_epic(epic_id):
|
|||
|
||||
@kanban_bp.route("/cards/<int:card_id>/epics", methods=["POST"])
|
||||
@jwt_required()
|
||||
@serialize_response(CardResponse)
|
||||
def add_epic_to_card(card_id):
|
||||
"""Link an epic to a card."""
|
||||
card = db.session.get(Card, card_id)
|
||||
|
|
@ -144,7 +150,7 @@ def add_epic_to_card(card_id):
|
|||
card.epic_id = epic_id
|
||||
db.session.commit()
|
||||
|
||||
return card.to_dict(), 200
|
||||
return card, 200
|
||||
|
||||
|
||||
@kanban_bp.route("/cards/<int:card_id>/epics/<int:epic_id>", methods=["DELETE"])
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ from sqlalchemy import delete as sql_delete
|
|||
from sqlalchemy import select
|
||||
|
||||
from app import db
|
||||
from app.decorators import serialize_list_response, serialize_response
|
||||
from app.models import Board, Wiki, wiki_entity_links
|
||||
from app.models.card import Card
|
||||
from app.models.epic import Epic
|
||||
|
|
@ -29,6 +30,7 @@ def generate_slug(name):
|
|||
|
||||
@kanban_bp.route("/boards/<int:board_id>/wikis", methods=["GET"])
|
||||
@jwt_required()
|
||||
@serialize_list_response(WikiResponse)
|
||||
def get_board_wikis(board_id):
|
||||
"""Get all wikis for a board."""
|
||||
board = db.session.get(Board, board_id)
|
||||
|
|
@ -36,12 +38,13 @@ def get_board_wikis(board_id):
|
|||
return {"error": "Board not found"}, 404
|
||||
|
||||
wikis = Wiki.query.filter_by(board_id=board_id).all()
|
||||
return [WikiResponse.model_validate(wiki).model_dump() for wiki in wikis], 200
|
||||
return wikis, 200
|
||||
|
||||
|
||||
@kanban_bp.route("/boards/<int:board_id>/wikis", methods=["POST"])
|
||||
@jwt_required()
|
||||
@validate(body=WikiCreateRequest)
|
||||
@serialize_response(WikiResponse)
|
||||
def create_wiki(board_id, body: WikiCreateRequest):
|
||||
"""Create a new wiki for a board."""
|
||||
board = db.session.get(Board, board_id)
|
||||
|
|
@ -75,7 +78,7 @@ def create_wiki(board_id, body: WikiCreateRequest):
|
|||
db.session.add(wiki)
|
||||
db.session.commit()
|
||||
|
||||
return WikiResponse.model_validate(wiki).model_dump(), 201
|
||||
return wiki, 201
|
||||
|
||||
|
||||
@kanban_bp.route("/wikis/<int:wiki_id>", methods=["GET"])
|
||||
|
|
@ -108,6 +111,7 @@ def get_wiki(wiki_id):
|
|||
@kanban_bp.route("/wikis/<int:wiki_id>", methods=["PUT"])
|
||||
@jwt_required()
|
||||
@validate(body=WikiUpdateRequest)
|
||||
@serialize_response(WikiResponse)
|
||||
def update_wiki(wiki_id, body: WikiUpdateRequest):
|
||||
"""Update a wiki."""
|
||||
wiki = db.session.get(Wiki, wiki_id)
|
||||
|
|
@ -131,7 +135,7 @@ def update_wiki(wiki_id, body: WikiUpdateRequest):
|
|||
wiki.updated_by = get_jwt_identity()
|
||||
db.session.commit()
|
||||
|
||||
return WikiResponse.model_validate(wiki).model_dump(), 200
|
||||
return wiki, 200
|
||||
|
||||
|
||||
@kanban_bp.route("/wikis/<int:wiki_id>", methods=["DELETE"])
|
||||
|
|
|
|||
|
|
@ -15,14 +15,11 @@ from app.schemas.file_attachment import (FileAttachmentWithUrlResponse,
|
|||
ValidationError)
|
||||
from app.schemas.label import LabelCreateRequest, LabelResponse
|
||||
from app.schemas.list_model import ListCreateRequest, ListResponse
|
||||
from app.schemas.product import ProductCreateRequest, ProductResponse
|
||||
from app.schemas.wiki import (WikiCreateRequest, WikiEntityLink,
|
||||
WikiLinkRequest, WikiResponse, WikiUpdateRequest)
|
||||
|
||||
__all__ = [
|
||||
# Product schemas
|
||||
"ProductCreateRequest",
|
||||
"ProductResponse",
|
||||
# Board schemas
|
||||
"BoardCreateRequest",
|
||||
"BoardResponse",
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ class CardResponse(BaseModel):
|
|||
"pos": 65535.0,
|
||||
"list_id": 1,
|
||||
"board_id": 1,
|
||||
"epic_id": 1,
|
||||
"due": "2024-12-31T23:59:59",
|
||||
"due_complete": False,
|
||||
"badges": {"votes": 0},
|
||||
|
|
@ -64,6 +65,7 @@ class CardResponse(BaseModel):
|
|||
pos: float
|
||||
list_id: int
|
||||
board_id: int
|
||||
epic_id: Optional[int]
|
||||
due: Optional[datetime] = None
|
||||
due_complete: bool
|
||||
badges: Optional[Dict[str, Any]] = None
|
||||
|
|
|
|||
|
|
@ -1,71 +0,0 @@
|
|||
"""Pydantic schemas for Product model"""
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
|
||||
|
||||
class ProductCreateRequest(BaseModel):
|
||||
"""Schema for creating a new product"""
|
||||
|
||||
model_config = ConfigDict(
|
||||
json_schema_extra={
|
||||
"example": {
|
||||
"name": "Handcrafted Wooden Bowl",
|
||||
"description": "A beautiful handcrafted bowl made from oak",
|
||||
"price": 45.99,
|
||||
"stock": 10,
|
||||
"image_url": "https://example.com/bowl.jpg",
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
name: str = Field(..., min_length=1, max_length=200, description="Product name")
|
||||
description: Optional[str] = Field(None, description="Product description")
|
||||
price: Decimal = Field(
|
||||
..., gt=0, description="Product price (must be greater than 0)"
|
||||
)
|
||||
stock: int = Field(default=0, ge=0, description="Product stock quantity")
|
||||
image_url: Optional[str] = Field(
|
||||
None, max_length=500, description="Product image URL"
|
||||
)
|
||||
|
||||
@field_validator("price")
|
||||
@classmethod
|
||||
def validate_price(cls, v: Decimal) -> Decimal:
|
||||
"""Validate that price has at most 2 decimal places"""
|
||||
if v.as_tuple().exponent < -2:
|
||||
raise ValueError("Price must have at most 2 decimal places")
|
||||
return v
|
||||
|
||||
|
||||
class ProductResponse(BaseModel):
|
||||
"""Schema for product response"""
|
||||
|
||||
model_config = ConfigDict(
|
||||
from_attributes=True,
|
||||
json_schema_extra={
|
||||
"example": {
|
||||
"id": 1,
|
||||
"name": "Handcrafted Wooden Bowl",
|
||||
"description": "A beautiful handcrafted bowl made from oak",
|
||||
"price": 45.99,
|
||||
"stock": 10,
|
||||
"image_url": "https://example.com/bowl.jpg",
|
||||
"is_active": True,
|
||||
"created_at": "2024-01-15T10:30:00",
|
||||
"updated_at": "2024-01-15T10:30:00",
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
id: int
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
price: float
|
||||
stock: int
|
||||
image_url: Optional[str] = None
|
||||
is_active: bool
|
||||
created_at: Optional[datetime] = None
|
||||
updated_at: Optional[datetime] = None
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
"""remove products tables
|
||||
|
||||
Revision ID: a9709e7ed22d
|
||||
Revises: 84551733ccb0
|
||||
Create Date: 2026-04-12 12:55:32.064593
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'a9709e7ed22d'
|
||||
down_revision = '84551733ccb0'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table('products', schema=None) as batch_op:
|
||||
batch_op.drop_index(batch_op.f('ix_products_name'))
|
||||
|
||||
op.drop_table('products')
|
||||
op.drop_table('order_items')
|
||||
with op.batch_alter_table('orders', schema=None) as batch_op:
|
||||
batch_op.drop_index(batch_op.f('ix_orders_status'))
|
||||
|
||||
op.drop_table('orders')
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('orders',
|
||||
sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False),
|
||||
sa.Column('user_id', sa.INTEGER(), autoincrement=False, nullable=False),
|
||||
sa.Column('status', sa.VARCHAR(length=20), autoincrement=False, nullable=True),
|
||||
sa.Column('total_amount', sa.NUMERIC(precision=10, scale=2), autoincrement=False, nullable=False),
|
||||
sa.Column('shipping_address', sa.TEXT(), autoincrement=False, nullable=True),
|
||||
sa.Column('created_at', postgresql.TIMESTAMP(), autoincrement=False, nullable=True),
|
||||
sa.Column('updated_at', postgresql.TIMESTAMP(), autoincrement=False, nullable=True),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], name=op.f('orders_user_id_fkey')),
|
||||
sa.PrimaryKeyConstraint('id', name=op.f('orders_pkey'))
|
||||
)
|
||||
with op.batch_alter_table('orders', schema=None) as batch_op:
|
||||
batch_op.create_index(batch_op.f('ix_orders_status'), ['status'], unique=False)
|
||||
|
||||
op.create_table('order_items',
|
||||
sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False),
|
||||
sa.Column('order_id', sa.INTEGER(), autoincrement=False, nullable=False),
|
||||
sa.Column('product_id', sa.INTEGER(), autoincrement=False, nullable=False),
|
||||
sa.Column('quantity', sa.INTEGER(), autoincrement=False, nullable=False),
|
||||
sa.Column('price', sa.NUMERIC(precision=10, scale=2), autoincrement=False, nullable=False),
|
||||
sa.ForeignKeyConstraint(['order_id'], ['orders.id'], name=op.f('order_items_order_id_fkey')),
|
||||
sa.ForeignKeyConstraint(['product_id'], ['products.id'], name=op.f('order_items_product_id_fkey')),
|
||||
sa.PrimaryKeyConstraint('id', name=op.f('order_items_pkey'))
|
||||
)
|
||||
op.create_table('products',
|
||||
sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False),
|
||||
sa.Column('name', sa.VARCHAR(length=200), autoincrement=False, nullable=False),
|
||||
sa.Column('description', sa.TEXT(), autoincrement=False, nullable=True),
|
||||
sa.Column('price', sa.NUMERIC(precision=10, scale=2), autoincrement=False, nullable=False),
|
||||
sa.Column('stock', sa.INTEGER(), autoincrement=False, nullable=True),
|
||||
sa.Column('image_url', sa.VARCHAR(length=500), autoincrement=False, nullable=True),
|
||||
sa.Column('is_active', sa.BOOLEAN(), autoincrement=False, nullable=True),
|
||||
sa.Column('created_at', postgresql.TIMESTAMP(), autoincrement=False, nullable=True),
|
||||
sa.Column('updated_at', postgresql.TIMESTAMP(), autoincrement=False, nullable=True),
|
||||
sa.PrimaryKeyConstraint('id', name=op.f('products_pkey'))
|
||||
)
|
||||
with op.batch_alter_table('products', schema=None) as batch_op:
|
||||
batch_op.create_index(batch_op.f('ix_products_name'), ['name'], unique=False)
|
||||
|
||||
# ### end Alembic commands ###
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
"""Pytest configuration and fixtures"""
|
||||
import logging
|
||||
import time
|
||||
from io import BytesIO
|
||||
|
||||
import pytest
|
||||
|
|
@ -7,8 +8,7 @@ from faker import Faker
|
|||
from PIL import Image
|
||||
|
||||
from app import create_app, db
|
||||
from app.models import (Board, Card, Comment, List, Order, OrderItem, Product,
|
||||
User)
|
||||
from app.models import Board, Card, Comment, List, User
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
fake = Faker()
|
||||
|
|
@ -116,7 +116,7 @@ def regular_user(db_session):
|
|||
"""Create a regular user for testing"""
|
||||
user = User(
|
||||
email=fake.email(),
|
||||
username=fake.user_name(),
|
||||
username=f"{fake.user_name()}_{int(time.time() * 1000)}",
|
||||
first_name=fake.first_name(),
|
||||
last_name=fake.last_name(),
|
||||
is_admin=False,
|
||||
|
|
@ -145,39 +145,6 @@ def inactive_user(db_session):
|
|||
return user
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def product(db_session):
|
||||
"""Create a product for testing"""
|
||||
product = Product(
|
||||
name=fake.sentence(nb_words=4)[:-1], # Remove period
|
||||
description=fake.paragraph(),
|
||||
price=fake.pydecimal(left_digits=2, right_digits=2, positive=True),
|
||||
stock=fake.pyint(min_value=0, max_value=100),
|
||||
image_url=fake.url(),
|
||||
)
|
||||
db_session.add(product)
|
||||
db_session.commit()
|
||||
return product
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def products(db_session):
|
||||
"""Create multiple products for testing"""
|
||||
products = []
|
||||
for _ in range(5):
|
||||
product = Product(
|
||||
name=fake.sentence(nb_words=4)[:-1],
|
||||
description=fake.paragraph(),
|
||||
price=fake.pydecimal(left_digits=2, right_digits=2, positive=True),
|
||||
stock=fake.pyint(min_value=20, max_value=100),
|
||||
image_url=fake.url(),
|
||||
)
|
||||
db_session.add(product)
|
||||
products.append(product)
|
||||
db_session.commit()
|
||||
return products
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def auth_headers(client, regular_user):
|
||||
"""Get authentication headers for a regular user"""
|
||||
|
|
@ -203,33 +170,6 @@ def admin_headers(client, admin_user):
|
|||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def order(db_session, regular_user, products):
|
||||
print("-----order-created------")
|
||||
"""Create an order for testing"""
|
||||
order = Order(
|
||||
user_id=regular_user.id, total_amount=0.0, shipping_address=fake.address()
|
||||
)
|
||||
db_session.add(order)
|
||||
db_session.flush()
|
||||
|
||||
total_amount = 0
|
||||
for i, product in enumerate(products[:2]):
|
||||
quantity = fake.pyint(min_value=1, max_value=5)
|
||||
order_item = OrderItem(
|
||||
order_id=order.id,
|
||||
product_id=product.id,
|
||||
quantity=quantity,
|
||||
price=product.price,
|
||||
)
|
||||
total_amount += float(product.price) * quantity
|
||||
db_session.add(order_item)
|
||||
|
||||
order.total_amount = total_amount
|
||||
db_session.commit()
|
||||
return order
|
||||
|
||||
|
||||
# ============ File Upload Fixtures ============
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,8 @@
|
|||
"""Test models"""
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
|
||||
from app.models import Order, OrderItem, Product, User
|
||||
from app.models import User
|
||||
|
||||
|
||||
class TestUserModel:
|
||||
|
|
@ -69,132 +68,3 @@ class TestUserModel:
|
|||
db_session.commit()
|
||||
|
||||
assert repr(user) == "<User testuser3>"
|
||||
|
||||
|
||||
class TestProductModel:
|
||||
"""Test Product model"""
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_product_creation(self, db_session):
|
||||
"""Test creating a product"""
|
||||
product = Product(
|
||||
name="Test Product",
|
||||
description="A test product",
|
||||
price=Decimal("99.99"),
|
||||
stock=10,
|
||||
image_url="https://example.com/product.jpg",
|
||||
)
|
||||
db_session.add(product)
|
||||
db_session.commit()
|
||||
|
||||
assert product.id is not None
|
||||
assert product.name == "Test Product"
|
||||
assert product.price == Decimal("99.99")
|
||||
assert product.stock == 10
|
||||
assert product.is_active is True
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_product_to_dict(self, db_session):
|
||||
"""Test product serialization to dictionary"""
|
||||
product = Product(
|
||||
name="Test Product",
|
||||
description="A test product",
|
||||
price=Decimal("99.99"),
|
||||
stock=10,
|
||||
)
|
||||
db_session.add(product)
|
||||
db_session.commit()
|
||||
|
||||
product_dict = product.to_dict()
|
||||
assert product_dict["name"] == "Test Product"
|
||||
assert product_dict["price"] == 99.99
|
||||
assert isinstance(product_dict["created_at"], str)
|
||||
assert isinstance(product_dict["updated_at"], str)
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_product_defaults(self, db_session):
|
||||
"""Test product default values"""
|
||||
product = Product(name="Test Product", price=Decimal("9.99"))
|
||||
db_session.add(product)
|
||||
db_session.commit()
|
||||
|
||||
assert product.stock == 0
|
||||
assert product.is_active is True
|
||||
assert product.description is None
|
||||
assert product.image_url is None
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_product_repr(self, db_session):
|
||||
"""Test product string representation"""
|
||||
product = Product(name="Test Product", price=Decimal("9.99"))
|
||||
db_session.add(product)
|
||||
db_session.commit()
|
||||
|
||||
assert repr(product) == "<Product Test Product>"
|
||||
|
||||
|
||||
class TestOrderModel:
|
||||
"""Test Order model"""
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_order_creation(self, db_session, regular_user):
|
||||
"""Test creating an order"""
|
||||
order = Order(
|
||||
user_id=regular_user.id,
|
||||
total_amount=Decimal("199.99"),
|
||||
shipping_address="123 Test St",
|
||||
)
|
||||
db_session.add(order)
|
||||
db_session.commit()
|
||||
|
||||
assert order.id is not None
|
||||
assert order.user_id == regular_user.id
|
||||
assert order.total_amount == Decimal("199.99")
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_order_to_dict(self, db_session, regular_user):
|
||||
"""Test order serialization to dictionary"""
|
||||
order = Order(
|
||||
user_id=regular_user.id,
|
||||
total_amount=Decimal("199.99"),
|
||||
shipping_address="123 Test St",
|
||||
)
|
||||
db_session.add(order)
|
||||
db_session.commit()
|
||||
|
||||
order_dict = order.to_dict()
|
||||
assert order_dict["user_id"] == regular_user.id
|
||||
assert order_dict["total_amount"] == 199.99
|
||||
assert isinstance(order_dict["created_at"], str)
|
||||
|
||||
|
||||
class TestOrderItemModel:
|
||||
"""Test OrderItem model"""
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_order_item_creation(self, db_session, order, product):
|
||||
"""Test creating an order item"""
|
||||
order_item = OrderItem(
|
||||
order_id=order.id, product_id=product.id, quantity=2, price=product.price
|
||||
)
|
||||
db_session.add(order_item)
|
||||
db_session.commit()
|
||||
|
||||
assert order_item.id is not None
|
||||
assert order_item.order_id == order.id
|
||||
assert order_item.product_id == product.id
|
||||
assert order_item.quantity == 2
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_order_item_to_dict(self, db_session, order, product):
|
||||
"""Test order item serialization to dictionary"""
|
||||
order_item = OrderItem(
|
||||
order_id=order.id, product_id=product.id, quantity=2, price=product.price
|
||||
)
|
||||
db_session.add(order_item)
|
||||
db_session.commit()
|
||||
|
||||
item_dict = order_item.to_dict()
|
||||
assert item_dict["order_id"] == order.id
|
||||
assert item_dict["product_id"] == product.id
|
||||
assert item_dict["quantity"] == 2
|
||||
|
|
|
|||
|
|
@ -112,235 +112,3 @@ class TestAuthRoutes:
|
|||
"""Test getting current user without authentication"""
|
||||
response = client.get("/api/users/me")
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
# class TestProductRoutes:
|
||||
# """Test product routes"""
|
||||
|
||||
# @pytest.mark.product
|
||||
# def test_get_products(self, app, client, products):
|
||||
# """Test getting all products"""
|
||||
# from app.models import Product
|
||||
|
||||
# before_count = Product.query.count()
|
||||
# response = client.get("/api/products")
|
||||
|
||||
# assert response.status_code == 200
|
||||
# data = response.get_json()
|
||||
# assert len(data) == before_count
|
||||
|
||||
# @pytest.mark.product
|
||||
# def test_get_products_empty(self, client):
|
||||
# """Test getting products when none exist"""
|
||||
# from app.models import Product
|
||||
|
||||
# before_count = Product.query.count()
|
||||
# response = client.get("/api/products")
|
||||
|
||||
# assert response.status_code == 200
|
||||
# data = response.get_json()
|
||||
# assert len(data) == before_count
|
||||
|
||||
# @pytest.mark.product
|
||||
# def test_get_single_product(self, client, product):
|
||||
# """Test getting a single product"""
|
||||
# response = client.get(f"/api/products/{product.id}")
|
||||
|
||||
# assert response.status_code == 200
|
||||
# data = response.get_json()
|
||||
# assert data["id"] == product.id
|
||||
# assert data["name"] == product.name
|
||||
|
||||
# @pytest.mark.product
|
||||
# def test_get_product_not_found(self, client):
|
||||
# """Test getting non-existent product"""
|
||||
# response = client.get("/api/products/999")
|
||||
# assert response.status_code == 404
|
||||
|
||||
# @pytest.mark.product
|
||||
# def test_create_product_admin(self, client, admin_headers):
|
||||
# """Test creating product as admin"""
|
||||
# response = client.post(
|
||||
# "/api/products",
|
||||
# headers=admin_headers,
|
||||
# json={
|
||||
# "name": "New Product",
|
||||
# "description": "A new product",
|
||||
# "price": 29.99,
|
||||
# "stock": 10,
|
||||
# },
|
||||
# )
|
||||
|
||||
# assert response.status_code == 201
|
||||
# data = response.get_json()
|
||||
# assert data["name"] == "New Product"
|
||||
# assert data["price"] == 29.99
|
||||
|
||||
# @pytest.mark.product
|
||||
# def test_create_product_regular_user(self, client, auth_headers):
|
||||
# """Test creating product as regular user (should fail)"""
|
||||
# response = client.post(
|
||||
# "/api/products",
|
||||
# headers=auth_headers,
|
||||
# json={"name": "New Product", "price": 29.99},
|
||||
# )
|
||||
|
||||
# assert response.status_code == 403
|
||||
# data = response.get_json()
|
||||
# assert "admin" in data["error"].lower()
|
||||
|
||||
# @pytest.mark.product
|
||||
# def test_create_product_unauthorized(self, client):
|
||||
# """Test creating product without authentication"""
|
||||
# response = client.post(
|
||||
# "/api/products", json={"name": "New Product", "price": 29.99}
|
||||
# )
|
||||
|
||||
# assert response.status_code == 401
|
||||
|
||||
# @pytest.mark.product
|
||||
# def test_create_product_validation_error(self, client, admin_headers):
|
||||
# """Test creating product with invalid data"""
|
||||
# response = client.post(
|
||||
# "/api/products",
|
||||
# headers=admin_headers,
|
||||
# json={"name": "New Product", "price": -10.99},
|
||||
# )
|
||||
|
||||
# assert response.status_code == 400
|
||||
# data = response.get_json()
|
||||
# assert "Validation error" in data["error"]
|
||||
|
||||
# @pytest.mark.product
|
||||
# def test_create_product_missing_required_fields(self, client, admin_headers):
|
||||
# """Test creating product with missing required fields"""
|
||||
# response = client.post(
|
||||
# "/api/products",
|
||||
# headers=admin_headers,
|
||||
# json={"description": "Missing name and price"},
|
||||
# )
|
||||
|
||||
# assert response.status_code == 400
|
||||
# data = response.get_json()
|
||||
# assert "Validation error" in data["error"]
|
||||
|
||||
# @pytest.mark.product
|
||||
# def test_create_product_minimal_data(self, client, admin_headers):
|
||||
# """Test creating product with minimal valid data"""
|
||||
# response = client.post(
|
||||
# "/api/products",
|
||||
# headers=admin_headers,
|
||||
# json={"name": "Minimal Product", "price": 19.99},
|
||||
# )
|
||||
|
||||
# assert response.status_code == 201
|
||||
# data = response.get_json()
|
||||
# assert data["name"] == "Minimal Product"
|
||||
# assert data["stock"] == 0 # Default value
|
||||
|
||||
# @pytest.mark.product
|
||||
# def test_update_product_admin(self, client, admin_headers, product):
|
||||
# """Test updating product as admin"""
|
||||
# response = client.put(
|
||||
# f"/api/products/{product.id}",
|
||||
# headers=admin_headers,
|
||||
# json={"name": "Updated Product", "price": 39.99},
|
||||
# )
|
||||
|
||||
# assert response.status_code == 200
|
||||
# data = response.get_json()
|
||||
# assert data["name"] == "Updated Product"
|
||||
# assert data["price"] == 39.99
|
||||
|
||||
# @pytest.mark.product
|
||||
# def test_delete_product_admin(self, client, admin_headers, product):
|
||||
# """Test deleting product as admin"""
|
||||
# response = client.delete(f"/api/products/{product.id}", headers=admin_headers)
|
||||
# assert response.status_code == 200
|
||||
|
||||
# # Verify product is deleted
|
||||
# response = client.get(f"/api/products/{product.id}")
|
||||
# assert response.status_code == 404
|
||||
|
||||
|
||||
# class TestOrderRoutes:
|
||||
# """Test order routes"""
|
||||
|
||||
# @pytest.mark.order
|
||||
# def test_get_orders(self, client, auth_headers, order):
|
||||
# """Test getting orders for current user"""
|
||||
# response = client.get("/api/orders", headers=auth_headers)
|
||||
|
||||
# assert response.status_code == 200
|
||||
# data = response.get_json()
|
||||
# assert len(data) >= 1
|
||||
|
||||
# @pytest.mark.order
|
||||
# def test_get_orders_unauthorized(self, client):
|
||||
# """Test getting orders without authentication"""
|
||||
# response = client.get("/api/orders")
|
||||
# assert response.status_code == 401
|
||||
|
||||
# @pytest.mark.order
|
||||
# def test_create_order(self, client, auth_headers, products):
|
||||
# """Test creating an order"""
|
||||
# response = client.post(
|
||||
# "/api/orders",
|
||||
# headers=auth_headers,
|
||||
# json={
|
||||
# "items": [
|
||||
# {"product_id": products[0].id, "quantity": 2},
|
||||
# {"product_id": products[1].id, "quantity": 1},
|
||||
# ],
|
||||
# "shipping_address": "123 Test St",
|
||||
# },
|
||||
# )
|
||||
|
||||
# assert response.status_code == 201
|
||||
# data = response.get_json()
|
||||
# assert "id" in data
|
||||
# assert len(data["items"]) == 2
|
||||
|
||||
# @pytest.mark.order
|
||||
# def test_create_order_insufficient_stock(
|
||||
# self, client, auth_headers, db_session, products
|
||||
# ):
|
||||
# """Test creating order with insufficient stock"""
|
||||
# # Set stock to 0
|
||||
# products[0].stock = 0
|
||||
# db_session.commit()
|
||||
|
||||
# response = client.post(
|
||||
# "/api/orders",
|
||||
# headers=auth_headers,
|
||||
# json={"items": [{"product_id": products[0].id, "quantity": 2}]},
|
||||
# )
|
||||
|
||||
# assert response.status_code == 400
|
||||
# data = response.get_json()
|
||||
# assert "insufficient" in data["error"].lower()
|
||||
|
||||
# @pytest.mark.order
|
||||
# def test_get_single_order(self, client, auth_headers, order):
|
||||
# """Test getting a single order"""
|
||||
# response = client.get(f"/api/orders/{order.id}", headers=auth_headers)
|
||||
|
||||
# print("test_get_single_order", response.get_json())
|
||||
|
||||
# assert response.status_code == 200
|
||||
# data = response.get_json()
|
||||
# assert data["id"] == order.id
|
||||
|
||||
# @pytest.mark.order
|
||||
# def test_get_other_users_order(self, client, admin_headers, regular_user,
|
||||
# products):
|
||||
# """Test admin accessing another user's order"""
|
||||
# # Create an order for regular_user
|
||||
# client.post(
|
||||
# "/api/auth/login",
|
||||
# json={"email": regular_user.email, "password": "password123"},
|
||||
# )
|
||||
|
||||
# # Admin should be able to access any order
|
||||
# # This test assumes order exists, adjust as needed
|
||||
# pass
|
||||
|
|
|
|||
|
|
@ -1,249 +0,0 @@
|
|||
"""Test Pydantic schemas"""
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from app.schemas import ProductCreateRequest, ProductResponse
|
||||
|
||||
|
||||
class TestProductCreateRequestSchema:
|
||||
"""Test ProductCreateRequest schema"""
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_valid_product_request(self):
|
||||
"""Test valid product creation request"""
|
||||
data = {
|
||||
"name": "Handcrafted Wooden Bowl",
|
||||
"description": "A beautiful handcrafted bowl",
|
||||
"price": 45.99,
|
||||
"stock": 10,
|
||||
"image_url": "https://example.com/bowl.jpg",
|
||||
}
|
||||
|
||||
product = ProductCreateRequest(**data)
|
||||
assert product.name == data["name"]
|
||||
assert product.description == data["description"]
|
||||
assert product.price == Decimal("45.99")
|
||||
assert product.stock == 10
|
||||
assert product.image_url == data["image_url"]
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_minimal_valid_request(self):
|
||||
"""Test minimal valid request (only required fields)"""
|
||||
data = {"name": "Simple Product", "price": 19.99}
|
||||
|
||||
product = ProductCreateRequest(**data)
|
||||
assert product.name == "Simple Product"
|
||||
assert product.price == Decimal("19.99")
|
||||
assert product.stock == 0
|
||||
assert product.description is None
|
||||
assert product.image_url is None
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_missing_name(self):
|
||||
"""Test request with missing name"""
|
||||
data = {"price": 19.99}
|
||||
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
ProductCreateRequest(**data)
|
||||
|
||||
errors = exc_info.value.errors()
|
||||
assert any(error["loc"] == ("name",) for error in errors)
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_missing_price(self):
|
||||
"""Test request with missing price"""
|
||||
data = {"name": "Test Product"}
|
||||
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
ProductCreateRequest(**data)
|
||||
|
||||
errors = exc_info.value.errors()
|
||||
assert any(error["loc"] == ("price",) for error in errors)
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_invalid_price_negative(self):
|
||||
"""Test request with negative price"""
|
||||
data = {"name": "Test Product", "price": -10.99}
|
||||
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
ProductCreateRequest(**data)
|
||||
|
||||
errors = exc_info.value.errors()
|
||||
assert any(error["type"] == "greater_than" for error in errors)
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_invalid_price_zero(self):
|
||||
"""Test request with zero price"""
|
||||
data = {"name": "Test Product", "price": 0.0}
|
||||
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
ProductCreateRequest(**data)
|
||||
|
||||
errors = exc_info.value.errors()
|
||||
assert any(error["type"] == "greater_than" for error in errors)
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_invalid_price_too_many_decimals(self):
|
||||
"""Test request with too many decimal places"""
|
||||
data = {"name": "Test Product", "price": 10.999}
|
||||
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
ProductCreateRequest(**data)
|
||||
|
||||
errors = exc_info.value.errors()
|
||||
assert any("decimal places" in str(error).lower() for error in errors)
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_invalid_stock_negative(self):
|
||||
"""Test request with negative stock"""
|
||||
data = {"name": "Test Product", "price": 19.99, "stock": -5}
|
||||
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
ProductCreateRequest(**data)
|
||||
|
||||
errors = exc_info.value.errors()
|
||||
assert any(error["type"] == "greater_than_equal" for error in errors)
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_name_too_long(self):
|
||||
"""Test request with name exceeding max length"""
|
||||
data = {"name": "A" * 201, "price": 19.99} # Exceeds 200 character limit
|
||||
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
ProductCreateRequest(**data)
|
||||
|
||||
errors = exc_info.value.errors()
|
||||
assert any(error["loc"] == ("name",) for error in errors)
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_image_url_too_long(self):
|
||||
"""Test request with image_url exceeding max length"""
|
||||
data = {
|
||||
"name": "Test Product",
|
||||
"price": 19.99,
|
||||
"image_url": "A" * 501, # Exceeds 500 character limit
|
||||
}
|
||||
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
ProductCreateRequest(**data)
|
||||
|
||||
errors = exc_info.value.errors()
|
||||
assert any(error["loc"] == ("image_url",) for error in errors)
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_price_string_conversion(self):
|
||||
"""Test price string to Decimal conversion"""
|
||||
data = {"name": "Test Product", "price": "29.99"}
|
||||
|
||||
product = ProductCreateRequest(**data)
|
||||
assert product.price == Decimal("29.99")
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_stock_string_conversion(self):
|
||||
"""Test stock string to int conversion"""
|
||||
data = {"name": "Test Product", "price": 19.99, "stock": "10"}
|
||||
|
||||
product = ProductCreateRequest(**data)
|
||||
assert product.stock == 10
|
||||
assert isinstance(product.stock, int)
|
||||
|
||||
|
||||
class TestProductResponseSchema:
|
||||
"""Test ProductResponse schema"""
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_valid_product_response(self):
|
||||
"""Test valid product response"""
|
||||
data = {
|
||||
"id": 1,
|
||||
"name": "Test Product",
|
||||
"description": "A test product",
|
||||
"price": 45.99,
|
||||
"stock": 10,
|
||||
"image_url": "https://example.com/product.jpg",
|
||||
"is_active": True,
|
||||
"created_at": "2024-01-15T10:30:00",
|
||||
"updated_at": "2024-01-15T10:30:00",
|
||||
}
|
||||
|
||||
product = ProductResponse(**data)
|
||||
assert product.id == 1
|
||||
assert product.name == "Test Product"
|
||||
assert product.price == 45.99
|
||||
assert product.stock == 10
|
||||
assert product.is_active is True
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_product_response_with_none_fields(self):
|
||||
"""Test product response with optional None fields"""
|
||||
data = {
|
||||
"id": 1,
|
||||
"name": "Test Product",
|
||||
"price": 19.99,
|
||||
"stock": 0,
|
||||
"is_active": True,
|
||||
}
|
||||
|
||||
product = ProductResponse(**data)
|
||||
assert product.description is None
|
||||
assert product.image_url is None
|
||||
assert product.created_at is None
|
||||
assert product.updated_at is None
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_model_validate_from_sqlalchemy(self, db_session):
|
||||
"""Test validating SQLAlchemy model to Pydantic schema"""
|
||||
from app.models import Product
|
||||
|
||||
db_product = Product(
|
||||
name="Test Product",
|
||||
description="A test product",
|
||||
price=Decimal("45.99"),
|
||||
stock=10,
|
||||
)
|
||||
db_session.add(db_product)
|
||||
db_session.commit()
|
||||
|
||||
# Validate using model_validate (for SQLAlchemy models)
|
||||
response = ProductResponse.model_validate(db_product)
|
||||
assert response.name == "Test Product"
|
||||
assert response.price == 45.99
|
||||
assert response.stock == 10
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_model_dump(self):
|
||||
"""Test model_dump method"""
|
||||
data = {
|
||||
"id": 1,
|
||||
"name": "Test Product",
|
||||
"price": 19.99,
|
||||
"stock": 5,
|
||||
"is_active": True,
|
||||
}
|
||||
|
||||
product = ProductResponse(**data)
|
||||
dumped = product.model_dump()
|
||||
|
||||
assert isinstance(dumped, dict)
|
||||
assert dumped["id"] == 1
|
||||
assert dumped["name"] == "Test Product"
|
||||
assert dumped["price"] == 19.99
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_model_dump_json(self):
|
||||
"""Test model_dump_json method"""
|
||||
data = {
|
||||
"id": 1,
|
||||
"name": "Test Product",
|
||||
"price": 19.99,
|
||||
"stock": 5,
|
||||
"is_active": True,
|
||||
}
|
||||
|
||||
product = ProductResponse(**data)
|
||||
json_str = product.model_dump_json()
|
||||
|
||||
assert isinstance(json_str, str)
|
||||
assert "Test Product" in json_str
|
||||
|
|
@ -133,7 +133,7 @@ export function KanbanColumn({
|
|||
>
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
className={`min-h-[200px] flex-1 transition-colors ${isOver ? 'bg-gray-750' : ''} maxc-h-[calc(100vh-280px)] overflow-auto scrollbar-custom px-4`}
|
||||
className={`min-h-[200px] flex-1 transition-colors ${isOver ? 'bg-gray-750' : ''} maxc-h-[calc(100vh-280px)] overflow-auto scrollbar-custom pl-4 pr-2`}
|
||||
>
|
||||
{cards.map((card) => (
|
||||
<KanbanCard key={card.id} card={card} onOpenModal={() => onOpenCardModal(card)} />
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import axios from 'axios';
|
||||
import { RegisterData, UserData, ProductData, OrderData, AuthResponse } from '../types';
|
||||
import { RegisterData, UserData, AuthResponse } from '../types';
|
||||
import {
|
||||
Board,
|
||||
BoardWithDetails,
|
||||
|
|
@ -69,44 +69,6 @@ export function useApi() {
|
|||
return response.data;
|
||||
},
|
||||
|
||||
// Products
|
||||
getProducts: async (): Promise<ProductData[]> => {
|
||||
const response = await api.get<ProductData[]>('/products');
|
||||
return response.data;
|
||||
},
|
||||
getProduct: async (id: string): Promise<ProductData> => {
|
||||
const response = await api.get<ProductData>(`/products/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
createProduct: async (productData: Omit<ProductData, 'id'>): Promise<ProductData> => {
|
||||
const response = await api.post<ProductData>('/products', productData);
|
||||
return response.data;
|
||||
},
|
||||
updateProduct: async (
|
||||
id: string,
|
||||
productData: Partial<ProductData>
|
||||
): Promise<ProductData> => {
|
||||
const response = await api.put<ProductData>(`/products/${id}`, productData);
|
||||
return response.data;
|
||||
},
|
||||
deleteProduct: async (id: string): Promise<void> => {
|
||||
await api.delete(`/products/${id}`);
|
||||
},
|
||||
|
||||
// Orders
|
||||
getOrders: async (): Promise<OrderData[]> => {
|
||||
const response = await api.get<OrderData[]>('/orders');
|
||||
return response.data;
|
||||
},
|
||||
getOrder: async (id: string): Promise<OrderData> => {
|
||||
const response = await api.get<OrderData>(`/orders/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
createOrder: async (orderData: Omit<OrderData, 'id'>): Promise<OrderData> => {
|
||||
const response = await api.post<OrderData>('/orders', orderData);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Boards
|
||||
getBoards: async (): Promise<Board[]> => {
|
||||
const response = await api.get<Board[]>('/boards');
|
||||
|
|
|
|||
|
|
@ -1,61 +0,0 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import { useApi } from './useApi';
|
||||
import { useLoader } from '../context/loaders/useLoader';
|
||||
import { useToast } from '../context/toasts/useToast';
|
||||
import { ProductData } from '../types';
|
||||
|
||||
export function useProducts() {
|
||||
const [products, setProducts] = useState<ProductData[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const { getProducts } = useApi();
|
||||
const { withLoader } = useLoader();
|
||||
const { addNotification } = useToast();
|
||||
|
||||
const fetchProducts = async () => {
|
||||
try {
|
||||
setError(null);
|
||||
|
||||
// Use withLoader to show loading state and handle errors
|
||||
const data = await withLoader(() => getProducts(), 'Loading products...');
|
||||
|
||||
setProducts(data);
|
||||
|
||||
// // Show success toast
|
||||
// addNotification({
|
||||
// type: 'success',
|
||||
// title: 'Products Loaded',
|
||||
// message: `Successfully loaded ${data.length} products.`,
|
||||
// duration: 3000,
|
||||
// });
|
||||
|
||||
return data;
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Failed to load products';
|
||||
setError(errorMessage);
|
||||
|
||||
// Show error toast
|
||||
addNotification({
|
||||
type: 'error',
|
||||
title: 'Error Loading Products',
|
||||
message: errorMessage,
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
// Optionally auto-fetch on mount
|
||||
useEffect(() => {
|
||||
fetchProducts();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
return {
|
||||
products,
|
||||
error,
|
||||
loading: false, // Loading is handled by the global loader
|
||||
refetch: fetchProducts,
|
||||
};
|
||||
}
|
||||
|
|
@ -1,122 +0,0 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useApp } from '../context/AppContext';
|
||||
import { useApi } from '../hooks/useApi';
|
||||
import { OrderData } from '../types';
|
||||
|
||||
export function Orders() {
|
||||
const [orders, setOrders] = useState<OrderData[]>([]);
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const navigate = useNavigate();
|
||||
const { user } = useApp();
|
||||
const { getOrders } = useApi();
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) {
|
||||
navigate('/login');
|
||||
return;
|
||||
}
|
||||
fetchOrders();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [user, navigate]);
|
||||
|
||||
const fetchOrders = async () => {
|
||||
try {
|
||||
const data = await getOrders();
|
||||
setOrders(data);
|
||||
} catch (error) {
|
||||
console.error('Error fetching orders:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColor = (status: string): string => {
|
||||
const colors: Record<string, string> = {
|
||||
pending: 'bg-yellow-900 text-yellow-200 border-yellow-700',
|
||||
processing: 'bg-blue-900 text-blue-200 border-blue-700',
|
||||
shipped: 'bg-purple-900 text-purple-200 border-purple-700',
|
||||
delivered: 'bg-green-900 text-green-200 border-green-700',
|
||||
cancelled: 'bg-red-900 text-red-200 border-red-700',
|
||||
};
|
||||
return colors[status] || 'bg-gray-900 text-gray-200 border-gray-700';
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<div className="text-gray-400">Loading orders...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-white mb-8">My Orders</h1>
|
||||
|
||||
{orders.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-gray-400 mb-8">You have no orders yet</p>
|
||||
<button
|
||||
onClick={() => navigate('/products')}
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white px-6 py-2 rounded-lg transition-colors"
|
||||
>
|
||||
Browse Products
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{orders.map((order) => (
|
||||
<div
|
||||
key={order.id}
|
||||
className="bg-gray-800 rounded-lg border border-gray-700 overflow-hidden"
|
||||
>
|
||||
<div className="p-4 border-b border-gray-700 flex justify-between items-center">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-white">Order #{order.id}</h3>
|
||||
<p className="text-sm text-gray-400">
|
||||
{new Date(order.created_at).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
className={`px-3 py-1 rounded-full text-sm font-medium border ${getStatusColor(
|
||||
order.status
|
||||
)}`}
|
||||
>
|
||||
{order.status.charAt(0).toUpperCase() + order.status.slice(1)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="p-4">
|
||||
{order.items.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="flex justify-between items-center py-2 border-b border-gray-700 last:border-b-0"
|
||||
>
|
||||
<div>
|
||||
<p className="text-white font-medium">Product #{item.product_id}</p>
|
||||
<p className="text-sm text-gray-400">Quantity: {item.quantity}</p>
|
||||
</div>
|
||||
<p className="text-white font-bold">
|
||||
${(item.price * item.quantity).toFixed(2)}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-gray-750 border-t border-gray-700 flex justify-between items-center">
|
||||
<div className="text-sm text-gray-400">
|
||||
{order.shipping_address && <span>Ship to: {order.shipping_address}</span>}
|
||||
</div>
|
||||
<div className="text-xl">
|
||||
<span className="text-gray-400">Total:</span>{' '}
|
||||
<span className="text-white font-bold">${order.total_amount}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,66 +0,0 @@
|
|||
import { useApp } from '../context/AppContext';
|
||||
import { useProducts } from '../hooks/useProducts';
|
||||
import { CartItem } from '../types';
|
||||
|
||||
export function Products() {
|
||||
const { products, refetch } = useProducts();
|
||||
const { addToCart } = useApp();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-8">
|
||||
<h1 className="text-3xl font-bold text-white">Products</h1>
|
||||
<button
|
||||
onClick={() => refetch()}
|
||||
className="bg-gray-700 hover:bg-gray-600 text-white px-4 py-2 rounded-lg transition-colors"
|
||||
>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
||||
{products.map((product) => (
|
||||
<div
|
||||
key={product.id}
|
||||
className="bg-gray-800 rounded-lg overflow-hidden border border-gray-700 hover:border-blue-500 transition-colors"
|
||||
>
|
||||
{product.image_url && (
|
||||
<img
|
||||
src={product.image_url}
|
||||
alt={product.name}
|
||||
className="w-full h-48 object-cover"
|
||||
/>
|
||||
)}
|
||||
<div className="p-4">
|
||||
<h3 className="text-lg font-semibold text-white mb-2">{product.name}</h3>
|
||||
<p className="text-gray-400 text-sm mb-3 line-clamp-2">{product.description}</p>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xl font-bold text-blue-400">${product.price}</span>
|
||||
<span className="text-sm text-gray-400">Stock: {product.stock}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
const cartItem: CartItem = {
|
||||
id: parseInt(product.id!),
|
||||
name: product.name,
|
||||
price: product.price,
|
||||
quantity: 1,
|
||||
image_url: product.image_url,
|
||||
};
|
||||
addToCart(cartItem);
|
||||
}}
|
||||
className="mt-4 w-full bg-blue-600 hover:bg-blue-700 text-white py-2 px-4 rounded-lg transition-colors"
|
||||
>
|
||||
Add to Cart
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{products.length === 0 && (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-gray-400">No products available</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
export interface OrderItem {
|
||||
id: string;
|
||||
product_id: string;
|
||||
quantity: number;
|
||||
price: number;
|
||||
}
|
||||
|
||||
export interface OrderData {
|
||||
id: string;
|
||||
status: 'pending' | 'processing' | 'shipped' | 'delivered' | 'cancelled';
|
||||
total_amount: number;
|
||||
created_at: string;
|
||||
shipping_address?: string;
|
||||
items: OrderItem[];
|
||||
}
|
||||
|
||||
export interface Order {
|
||||
id: number;
|
||||
created_at: string;
|
||||
status: string;
|
||||
total_amount: number;
|
||||
shipping_address: string;
|
||||
items: OrderItem[];
|
||||
}
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
export interface Product {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
price: number;
|
||||
stock: number;
|
||||
image_url?: string;
|
||||
}
|
||||
|
||||
export interface ProductData {
|
||||
id?: string;
|
||||
name: string;
|
||||
description: string;
|
||||
price: number;
|
||||
stock: number;
|
||||
image_url?: string;
|
||||
}
|
||||
|
||||
export interface CartItem {
|
||||
id: number;
|
||||
name: string;
|
||||
price: number;
|
||||
quantity: number;
|
||||
image_url?: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
|
@ -21,7 +21,6 @@
|
|||
"noFallthroughCasesInSwitch": true,
|
||||
|
||||
/* Path Aliases */
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue