diff --git a/docs/project-roadmap/implementation_roadmap.md b/docs/project-roadmap/implementation_roadmap.md deleted file mode 100644 index cd5ea6af..00000000 --- a/docs/project-roadmap/implementation_roadmap.md +++ /dev/null @@ -1,512 +0,0 @@ -# Implementation Roadmap -## Multi-Tenant Ecommerce Platform - Complete Development Guide - -**Last Updated**: October 11, 2025 -**Project Status**: Slice 1 In Progress (~75% complete) - ---- - -## 📚 Documentation Structure - -Your complete vertical slice documentation is organized as follows: - -``` -docs/slices/ -├── 00_slices_overview.md ← Start here for overview -├── 00_implementation_roadmap.md ← This file - your guide -├── 01_slice1_admin_vendor_foundation.md -├── 02_slice2_marketplace_import.md -├── 03_slice3_product_catalog.md -├── 04_slice4_customer_shopping.md -└── 05_slice5_order_processing.md -``` - ---- - -## 🎯 Quick Start Guide - -### For Current Development (Slice 1) -1. ✅ Read `01_slice1_admin_vendor_foundation.md` -2. ✅ Review what's marked as complete vs. in-progress -3. ⏳ Focus on vendor login and dashboard pages -4. ⏳ Complete testing checklist -5. ⏳ Deploy to staging - -### For Future Slices -1. Complete current slice 100% -2. Read next slice documentation -3. Set up backend (models, schemas, services, APIs) -4. Build frontend (Jinja2 templates + Alpine.js) -5. Test thoroughly -6. Move to next slice - ---- - -## 📊 Current Status Overview - -### Slice 1: Multi-Tenant Foundation (75% Complete) - -#### ✅ Completed -- Backend database models (User, Vendor, Role, VendorUser) -- Authentication system (JWT, bcrypt) -- Admin service layer (vendor creation with owner) -- Admin API endpoints (CRUD, dashboard) -- Vendor context middleware (subdomain + path detection) -- Admin login page (HTML + Alpine.js) -- Admin dashboard (HTML + Alpine.js) -- Admin vendor creation page (HTML + Alpine.js) - -#### ⏳ In Progress -- Vendor login page (frontend) -- Vendor dashboard page (frontend) -- Testing and debugging -- Deployment configuration - -#### 📋 To Do -- Complete vendor login/dashboard -- Full testing (see Slice 1 checklist) -- Documentation updates -- Staging deployment - -### Slices 2-5: Not Started - -All future slices have complete documentation ready to implement. - ---- - -## 🗓️ Development Timeline - -### Week 1: Slice 1 - Foundation ⏳ CURRENT -**Days 1-3**: ✅ Backend complete -**Days 4-5**: ⏳ Frontend completion - -**Deliverable**: Admin can create vendors, vendors can log in - -### Week 2: Slice 2 - Marketplace Import -**Days 1-3**: Backend (CSV import, Celery tasks, MarketplaceProduct model) -**Days 4-5**: Frontend (import UI with Alpine.js, status tracking) - -**Deliverable**: Vendors can import products from Letzshop CSV - -### Week 3: Slice 3 - Product Catalog -**Days 1-3**: Backend (Product model, publishing, inventory) -**Days 4-5**: Frontend (product management, catalog UI) - -**Deliverable**: Vendors can manage product catalog - -### Week 4: Slice 4 - Customer Shopping -**Days 1-3**: Backend (Customer model, Cart, public APIs) -**Days 4-5**: Frontend (shop pages, cart with Alpine.js) - -**Deliverable**: Customers can browse and shop - -### Week 5: Slice 5 - Order Processing -**Days 1-3**: Backend (Order model, checkout, order management) -**Days 4-5**: Frontend (checkout flow, order history) - -**Deliverable**: Complete order workflow, platform ready for production - ---- - -## 🎨 Technology Stack - -### Backend -- **Framework**: FastAPI (Python 3.11+) -- **Database**: PostgreSQL + SQLAlchemy ORM -- **Authentication**: JWT tokens + bcrypt -- **Background Jobs**: Celery + Redis/RabbitMQ -- **API Docs**: Auto-generated OpenAPI/Swagger - -### Frontend -- **Templating**: Jinja2 (server-side rendering) -- **JavaScript**: Alpine.js v3.x (15KB, CDN-based) -- **CSS**: Custom CSS with CSS variables -- **AJAX**: Fetch API (vanilla JavaScript) -- **No Build Step**: Everything runs directly in browser - -### Why This Stack? -- ✅ **Alpine.js**: Lightweight reactivity without build complexity -- ✅ **Jinja2**: Server-side rendering for SEO and performance -- ✅ **No Build Step**: Faster development, easier deployment -- ✅ **FastAPI**: Modern Python, async support, auto-docs -- ✅ **PostgreSQL**: Robust, reliable, feature-rich - ---- - -## 📋 Implementation Checklist - -Use this checklist to track your progress across all slices: - -### Slice 1: Foundation -- [x] Backend models created -- [x] Authentication system working -- [x] Admin service layer complete -- [x] Admin API endpoints working -- [x] Vendor context middleware working -- [x] Admin login page created -- [x] Admin dashboard created -- [x] Admin vendor creation page created -- [ ] Vendor login page created -- [ ] Vendor dashboard page created -- [ ] All tests passing -- [ ] Deployed to staging - -### Slice 2: Marketplace Import -- [ ] MarketplaceProduct model -- [ ] MarketplaceImportJob model -- [ ] CSV processing service -- [ ] Celery tasks configured -- [ ] Import API endpoints -- [ ] Import UI pages -- [ ] Status tracking with Alpine.js -- [ ] All tests passing - -### Slice 3: Product Catalog -- [ ] Product model complete -- [ ] Inventory model complete -- [ ] Product service layer -- [ ] Publishing logic -- [ ] Product API endpoints -- [ ] Product management UI -- [ ] Catalog browsing -- [ ] All tests passing - -### Slice 4: Customer Shopping -- [ ] Customer model -- [ ] Cart model -- [ ] Customer service layer -- [ ] Cart service layer -- [ ] Public product APIs -- [ ] Shop homepage -- [ ] Product detail pages -- [ ] Shopping cart UI -- [ ] Customer registration/login -- [ ] All tests passing - -### Slice 5: Order Processing -- [ ] Order model -- [ ] OrderItem model -- [ ] Order service layer -- [ ] Checkout logic -- [ ] Order API endpoints -- [ ] Checkout UI (multi-step) -- [ ] Customer order history -- [ ] Vendor order management -- [ ] Email notifications -- [ ] Payment integration (Stripe) -- [ ] All tests passing -- [ ] Production ready - ---- - -## 🎯 Each Slice Must Include - -### Backend Checklist -- [ ] Database models defined -- [ ] Pydantic schemas created -- [ ] Service layer implemented -- [ ] API endpoints created -- [ ] Exception handling added -- [ ] Database migrations applied -- [ ] Unit tests written -- [ ] Integration tests written - -### Frontend Checklist -- [ ] Jinja2 templates created -- [ ] Alpine.js components implemented -- [ ] CSS styling applied -- [ ] API integration working -- [ ] Loading states added -- [ ] Error handling added -- [ ] Mobile responsive -- [ ] Browser tested (Chrome, Firefox, Safari) - -### Documentation Checklist -- [ ] Slice documentation updated -- [ ] API endpoints documented -- [ ] Frontend components documented -- [ ] Testing checklist completed -- [ ] Known issues documented -- [ ] Next steps identified - ---- - -## 🔧 Development Workflow - -### Starting a New Slice - -1. **Read Documentation** - ```bash - # Open the slice markdown file - code docs/slices/0X_sliceX_name.md - ``` - -2. **Set Up Backend** - ```bash - # Create database models - # Create Pydantic schema - # Implement service layer - # Create API endpoints - # Write tests - ``` - -3. **Set Up Frontend** - ```bash - # Create Jinja2 templates - # Add Alpine.js components - # Style with CSS - # Test in browser - ``` - -4. **Test Thoroughly** - ```bash - # Run backend tests - pytest tests/ - - # Manual testing - # Use testing checklist in slice docs - ``` - -5. **Deploy & Demo** - ```bash - # Deploy to staging - # Demo to stakeholders - # Gather feedback - ``` - -### Daily Development Flow - -**Morning** -- Review slice documentation -- Identify today's goals (backend or frontend) -- Check testing checklist - -**During Development** -- Follow code patterns from slice docs -- Use Alpine.js examples provided -- Keep vendor isolation in mind -- Test incrementally - -**End of Day** -- Update slice documentation with progress -- Mark completed items in checklist -- Note any blockers or issues -- Commit code with meaningful messages - ---- - -## 🎨 Alpine.js Patterns - -### Basic Component Pattern -```javascript -function myComponent() { - return { - // State - data: [], - loading: false, - error: null, - - // Lifecycle - init() { - this.loadData(); - }, - - // Methods - async loadData() { - this.loading = true; - try { - this.data = await apiClient.get('/api/endpoint'); - } catch (error) { - this.error = error.message; - } finally { - this.loading = false; - } - } - } -} -``` - -### Template Usage -```html -
-
Loading...
-
-
- -
-
-``` - -### Common Directives -- `x-data` - Component state -- `x-init` - Initialization -- `x-show` - Toggle visibility -- `x-if` - Conditional rendering -- `x-for` - Loop through arrays -- `x-model` - Two-way binding -- `@click` - Event handling -- `:class` - Dynamic classes -- `x-text` - Text content -- `x-html` - HTML content - ---- - -## 📚 Key Resources - -### Documentation Files -- `00_slices_overview.md` - Complete overview -- `01_slice1_admin_vendor_foundation.md` - Current work -- `../quick_start_guide.md` - Setup guide -- `../css_structure_guide.txt` - CSS organization -- `../css_quick_reference.txt` - CSS usage -- `../12.project_readme_final.md` - Complete README - -### External Resources -- [Alpine.js Documentation](https://alpinejs.dev/) -- [FastAPI Documentation](https://fastapi.tiangolo.com/) -- [Jinja2 Documentation](https://jinja.palletsprojects.com/) -- [SQLAlchemy Documentation](https://docs.sqlalchemy.org/) - ---- - -## 🚨 Common Pitfalls to Avoid - -### Backend -- ❌ Forgetting vendor isolation in queries -- ❌ Not validating vendor_id in API endpoints -- ❌ Skipping database indexes -- ❌ Not handling edge cases -- ❌ Missing error handling - -### Frontend -- ❌ Not handling loading states -- ❌ Not displaying error messages -- ❌ Forgetting mobile responsiveness -- ❌ Not testing in multiple browsers -- ❌ Mixing vendor contexts - -### General -- ❌ Skipping tests -- ❌ Not updating documentation -- ❌ Moving to next slice before completing current -- ❌ Not following naming conventions -- ❌ Committing without testing - ---- - -## ✅ Quality Gates - -Before moving to the next slice, ensure: - -1. **All Features Complete** - - All user stories implemented - - All acceptance criteria met - - All API endpoints working - -2. **All Tests Pass** - - Backend unit tests - - Backend integration tests - - Frontend manual testing - - Security testing (vendor isolation) - -3. **Documentation Updated** - - Slice documentation current - - API docs updated - - Testing checklist completed - -4. **Code Quality** - - Follows naming conventions - - No console errors - - No security vulnerabilities - - Performance acceptable - -5. **Stakeholder Approval** - - Demo completed - - Feedback incorporated - - Sign-off received - ---- - -## 🎉 Success Metrics - -### After Slice 1 -- ✅ Admin can create vendors -- ✅ Vendors can log in -- ✅ Vendor isolation works -- ✅ Context detection works - -### After Slice 2 -- ✅ Vendors can import CSVs -- ✅ Background processing works -- ✅ Import tracking functional - -### After Slice 3 -- ✅ Products published to catalog -- ✅ Inventory management working -- ✅ Product customization enabled - -### After Slice 4 -- ✅ Customers can browse products -- ✅ Shopping cart functional -- ✅ Customer accounts working - -### After Slice 5 -- ✅ Complete checkout workflow -- ✅ Order management operational -- ✅ **Platform production-ready!** - ---- - -## 🚀 Ready to Start? - -### Current Focus: Complete Slice 1 - -**Your immediate next steps:** - -1. ✅ Read `01_slice1_admin_vendor_foundation.md` -2. ⏳ Complete vendor login page (`templates/vendor/login.html`) -3. ⏳ Complete vendor dashboard (`templates/vendor/dashboard.html`) -4. ⏳ Test complete admin → vendor flow -5. ⏳ Check all items in Slice 1 testing checklist -6. ⏳ Deploy to staging -7. ⏳ Demo to stakeholders -8. ✅ Move to Slice 2 - -### Need Help? - -- Check the slice documentation for detailed implementation -- Review Alpine.js examples in the docs -- Look at CSS guides for styling -- Test frequently and incrementally -- Update documentation as you progress - ---- - -## 💡 Pro Tips - -1. **Work Incrementally**: Complete one component at a time -2. **Test Continuously**: Don't wait until the end to test -3. **Follow Patterns**: Use the examples in slice documentation -4. **Document as You Go**: Update docs while code is fresh -5. **Ask for Reviews**: Get feedback early and often -6. **Celebrate Progress**: Each completed slice is a milestone! - ---- - -## 📞 Support - -If you need assistance: -- Review the slice-specific documentation -- Check the testing checklists -- Look at the example code provided -- Refer to the technology stack documentation - ---- - -**Ready to build an amazing multi-tenant ecommerce platform?** - -**Start with**: `01_slice1_admin_vendor_foundation.md` - -**You've got this!** 🚀 \ No newline at end of file diff --git a/docs/project-roadmap/slice1_doc.md b/docs/project-roadmap/slice1_doc.md deleted file mode 100644 index f51c6af4..00000000 --- a/docs/project-roadmap/slice1_doc.md +++ /dev/null @@ -1,1070 +0,0 @@ -# Slice 1: Multi-Tenant Foundation -## Admin Creates Vendor → Vendor Owner Logs In - -**Status**: 🔄 IN PROGRESS -**Timeline**: Week 1 (5 days) -**Current Progress**: Backend ~90%, Frontend ~60% - -## 🎯 Slice Objectives - -Establish the multi-tenant foundation with complete vendor isolation and admin capabilities. - -### User Stories -- ✅ As a Super Admin, I can create vendors through the admin interface -- [ ] As a Super Admin, I can manage vendor accounts (verify, activate, deactivate) -- ✅ As a Vendor Owner, I can log into my vendor-specific admin interface -- ✅ The system correctly isolates vendor contexts (subdomain + path-based) - -### Success Criteria -- ✅ Admin can log into admin interface -- ✅ Admin can create new vendors with auto-generated owner accounts -- ✅ System generates secure temporary passwords -- ✅ Vendor owner can log into vendor-specific interface -- [ ] Vendor context detection works in dev (path) and prod (subdomain) modes -- [ ] Database properly isolates vendor data -- [ ] All API endpoints protected with JWT authentication -- ✅ Frontend integrates seamlessly with backend - -## 📋 Backend Implementation - -### Database Models (✅ Complete) - -#### User Model (`models/database/user.py`) -```python -class User(Base, TimestampMixin): - __tablename__ = "users" - - id = Column(Integer, primary_key=True, index=True) - email = Column(String, unique=True, nullable=False, index=True) - username = Column(String, unique=True, nullable=False, index=True) - hashed_password = Column(String, nullable=False) - role = Column(String, nullable=False) # 'admin' or 'user' - is_active = Column(Boolean, default=True) - - # Relationships - owned_vendors = relationship("Vendor", back_populates="owner") - vendor_memberships = relationship("VendorUser", back_populates="user") -``` - -#### Vendor Model (`models/database/vendor.py`) -```python -class Vendor(Base, TimestampMixin): - __tablename__ = "vendors" - - id = Column(Integer, primary_key=True, index=True) - vendor_code = Column(String, unique=True, nullable=False, index=True) - subdomain = Column(String(100), unique=True, nullable=False, index=True) - name = Column(String, nullable=False) - description = Column(Text) - owner_user_id = Column(Integer, ForeignKey("users.id"), nullable=False) - - # Business info - business_email = Column(String) - business_phone = Column(String) - website = Column(String) - business_address = Column(Text) - tax_number = Column(String) - - # Status - is_active = Column(Boolean, default=True) - is_verified = Column(Boolean, default=False) - verified_at = Column(DateTime, nullable=True) - - # Configuration - theme_config = Column(JSON, default=dict) - letzshop_csv_url_fr = Column(String) - letzshop_csv_url_en = Column(String) - letzshop_csv_url_de = Column(String) - - # Relationships - owner = relationship("User", back_populates="owned_vendors") - roles = relationship("Role", back_populates="vendor", cascade="all, delete-orphan") - team_members = relationship("VendorUser", back_populates="vendor") -``` - -#### Role Model (`models/database/vendor.py`) -```python -class Role(Base, TimestampMixin): - __tablename__ = "roles" - - id = Column(Integer, primary_key=True, index=True) - vendor_id = Column(Integer, ForeignKey("vendors.id"), nullable=False) - name = Column(String, nullable=False) # Owner, Manager, Editor, Viewer - permissions = Column(JSON, default=list) - - vendor = relationship("Vendor", back_populates="roles") - vendor_users = relationship("VendorUser", back_populates="role") -``` - -### Pydantic Schemas (✅ Complete) - -#### Vendor Schemas (`models/schema/vendor.py`) -```python -class VendorCreate(BaseModel): - vendor_code: str = Field(..., min_length=2, max_length=50) - name: str = Field(..., min_length=2, max_length=200) - subdomain: str = Field(..., min_length=2, max_length=100) - owner_email: EmailStr # NEW for Slice 1 - description: Optional[str] = None - business_email: Optional[EmailStr] = None - business_phone: Optional[str] = None - website: Optional[str] = None - - @validator('vendor_code') - def vendor_code_uppercase(cls, v): - return v.upper() - - @validator('subdomain') - def subdomain_lowercase(cls, v): - return v.lower().strip() - -class VendorCreateResponse(BaseModel): - """Response after creating vendor - includes generated credentials""" - id: int - vendor_code: str - subdomain: str - name: str - owner_user_id: int - owner_email: str - owner_username: str - temporary_password: str # Shown only once! - is_active: bool - is_verified: bool - created_at: datetime -``` - -### Service Layer (✅ Complete) - -#### Admin Service (`app/services/admin_service.py`) - -**Key Method**: `create_vendor_with_owner()` -```python -async def create_vendor_with_owner( - self, - vendor_data: VendorCreate, - db: Session -) -> Dict[str, Any]: - """ - Creates vendor + owner user + default roles - Returns vendor details with temporary password - """ - # 1. Generate owner username - owner_username = f"{vendor_data.subdomain}_owner" - - # 2. Generate secure temporary password - temp_password = self._generate_temp_password() - - # 3. Create owner user - owner_user = User( - email=vendor_data.owner_email, - username=owner_username, - hashed_password=self.auth_manager.hash_password(temp_password), - role="user", - is_active=True - ) - db.add(owner_user) - db.flush() # Get owner_user.id - - # 4. Create vendor - vendor = Vendor( - vendor_code=vendor_data.vendor_code, - name=vendor_data.name, - subdomain=vendor_data.subdomain, - owner_user_id=owner_user.id, - is_verified=True, # Auto-verify admin-created vendors - verified_at=datetime.utcnow(), - # ... other fields - ) - db.add(vendor) - db.flush() # Get vendor.id - - # 5. Create default roles - default_roles = ["Owner", "Manager", "Editor", "Viewer"] - for role_name in default_roles: - role = Role( - vendor_id=vendor.id, - name=role_name, - permissions=self._get_default_permissions(role_name) - ) - db.add(role) - - # 6. Link owner to Owner role - owner_role = db.query(Role).filter( - Role.vendor_id == vendor.id, - Role.name == "Owner" - ).first() - - vendor_user = VendorUser( - vendor_id=vendor.id, - user_id=owner_user.id, - role_id=owner_role.id, - is_active=True - ) - db.add(vendor_user) - - db.commit() - - return { - "vendor": vendor, - "owner_username": owner_username, - "temporary_password": temp_password - } -``` - -### API Endpoints (✅ Complete) - -#### Admin Endpoints (`app/api/v1/admin.py`) - -```python -@router.post("/vendors", response_model=VendorCreateResponse) -async def create_vendor( - vendor_data: VendorCreate, - current_user: User = Depends(get_current_admin_user), - db: Session = Depends(get_db) -): - """Create new vendor with owner account""" - result = await admin_service.create_vendor_with_owner(vendor_data, db) - - return VendorCreateResponse( - id=result["vendor"].id, - vendor_code=result["vendor"].vendor_code, - subdomain=result["vendor"].subdomain, - name=result["vendor"].name, - owner_user_id=result["vendor"].owner_user_id, - owner_email=vendor_data.owner_email, - owner_username=result["owner_username"], - temporary_password=result["temporary_password"], - is_active=result["vendor"].is_active, - is_verified=result["vendor"].is_verified, - created_at=result["vendor"].created_at - ) - -@router.get("/vendors", response_model=VendorListResponse) -async def list_vendors( - skip: int = 0, - limit: int = 100, - is_active: Optional[bool] = None, - is_verified: Optional[bool] = None, - current_user: User = Depends(get_current_admin_user), - db: Session = Depends(get_db) -): - """List all vendors with filtering""" - return await admin_service.get_vendors(db, skip, limit, is_active, is_verified) - -@router.get("/dashboard", response_model=AdminDashboardResponse) -async def get_admin_dashboard( - current_user: User = Depends(get_current_admin_user), - db: Session = Depends(get_db) -): - """Get admin dashboard statistics""" - return await admin_service.get_dashboard_stats(db) - -@router.put("/vendors/{vendor_id}/verify") -async def verify_vendor( - vendor_id: int, - current_user: User = Depends(get_current_admin_user), - db: Session = Depends(get_db) -): - """Verify/unverify vendor""" - return await admin_service.toggle_vendor_verification(vendor_id, db) - -@router.put("/vendors/{vendor_id}/status") -async def toggle_vendor_status( - vendor_id: int, - current_user: User = Depends(get_current_admin_user), - db: Session = Depends(get_db) -): - """Activate/deactivate vendor""" - return await admin_service.toggle_vendor_status(vendor_id, db) -``` - -### Middleware (✅ Complete) - -#### Vendor Context Detection (`middleware/vendor_context.py`) - -```python -async def vendor_context_middleware(request: Request, call_next): - """ - Detects vendor context from: - 1. Subdomain: vendor.platform.com (production) - 2. Path: /vendor/VENDOR_CODE/ (development) - """ - vendor_context = None - - # Skip for admin/API routes - if request.url.path.startswith(("/api/", "/admin/", "/static/")): - response = await call_next(request) - return response - - # 1. Try subdomain detection (production) - host = request.headers.get("host", "").split(":")[0] - parts = host.split(".") - if len(parts) > 2: - subdomain = parts[0] - vendor = get_vendor_by_subdomain(subdomain) - if vendor: - vendor_context = vendor - - # 2. Try path detection (development) - if not vendor_context: - path_parts = request.url.path.split("/") - if len(path_parts) > 2 and path_parts[1] == "vendor": - vendor_code = path_parts[2].upper() - vendor = get_vendor_by_code(vendor_code) - if vendor: - vendor_context = vendor - - request.state.vendor = vendor_context - response = await call_next(request) - return response -``` - -## 🎨 Frontend Implementation - -### Template Structure (Jinja2) - -#### Base Template (`templates/base.html`) - -```html - - - - - - {% block title %}Multi-Tenant Platform{% endblock %} - - - - {% block extra_css %}{% endblock %} - - - - - -{% block content %}{% endblock %} - - - -{% block extra_scripts %}{% endblock %} - - -``` - -### Admin Pages (🔄 In Progress) - -#### 1. Admin Login (`templates/admin/login.html`) - -**Status**: ✅ Complete - -```html -{% extends "base.html" %} - -{% block title %}Admin Login{% endblock %} - -{% block extra_css %} - -{% endblock %} - -{% block content %} -
-
- - -
- -
- - -
- - -
- - -
- - -
- - - -
-
-
-{% endblock %} - -{% block extra_scripts %} - -{% endblock %} -``` - -#### 2. Admin Dashboard (`templates/admin/dashboard.html`) - -**Status**: ⏳ In Progress - -```html -{% extends "admin/base_admin.html" %} - -{% block title %}Admin Dashboard{% endblock %} - -{% block content %} -
- - - - -
- -
-
-
Total Vendors
-
🏪
-
-
-
- active -
-
- - -
-
-
Total Users
-
👥
-
-
-
- active -
-
- - -
-
-
Verified
-
-
-
-
vendors verified
-
-
- - -
-
-

Recent Vendors

- View All -
- - - - -
-
-{% endblock %} - -{% block extra_scripts %} - -{% endblock %} -``` - -#### 3. Vendor Creation (`templates/admin/vendors.html`) - -**Status**: ⏳ In Progress - -Alpine.js component for creating vendors with real-time validation and credential display. - -### Vendor Pages (📋 To Do) - -#### 1. Vendor Login (`templates/vendor/login.html`) - -**Status**: 📋 To Do - -```html -{% extends "base.html" %} - -{% block title %}Vendor Login{% endblock %} - -{% block extra_css %} - -{% endblock %} - -{% block content %} -
-
- - - - - - - - -
-
- - -{% endblock %} - -{% block extra_scripts %} - -{% endblock %} -``` - -#### 2. Vendor Dashboard (`templates/vendor/dashboard.html`) - -**Status**: 📋 To Do - -```html -{% extends "vendor/base_vendor.html" %} - -{% block title %}{{ vendor.name }} Dashboard{% endblock %} - -{% block content %} -
- -
-

Welcome to {{ vendor.name }}

-

Vendor Code: {{ vendor.vendor_code }}

-
- - ${vendor.is_verified ? 'Verified' : 'Pending Verification'} - -
-
- - -
-
-
-
Products
-
📦
-
-
-
in catalog
-
- -
-
-
Orders
-
🛒
-
-
-
total orders
-
- -
-
-
Customers
-
👥
-
-
-
registered
-
-
- - -
-

🚀 Coming in Slice 2

-

- Product import from Letzshop marketplace will be available soon! -

-
-
- - -{% endblock %} - -{% block extra_scripts %} - -{% endblock %} -``` - -## ✅ Testing Checklist - -### Backend Tests - -#### Authentication -- [ ] Admin can login with valid credentials -- [ ] Admin login rejects invalid credentials -- [ ] Non-admin users cannot access admin endpoints -- [ ] JWT tokens expire after configured time -- [ ] Token refresh works correctly - -#### Vendor Management -- [ ] Admin can create vendor with all required fields -- [ ] System generates unique vendor code -- [ ] System generates unique subdomain -- [ ] Owner user account is created automatically -- [ ] Temporary password is generated (12+ characters) -- [ ] Default roles are created (Owner, Manager, Editor, Viewer) -- [ ] Owner is linked to Owner role -- [ ] Vendor is auto-verified when created by admin -- [ ] Duplicate vendor code is rejected -- [ ] Duplicate subdomain is rejected -- [ ] Duplicate owner email is rejected - -#### Vendor Context Detection -- [ ] Subdomain detection works: `vendor.platform.com` -- [ ] Path detection works: `/vendor/VENDORCODE/` -- [ ] Admin routes bypass vendor context -- [ ] API routes bypass vendor context -- [ ] Static routes bypass vendor context -- [ ] Invalid vendor returns appropriate error - -#### API Endpoints -- [ ] `POST /api/v1/admin/vendors` creates vendor -- [ ] `GET /api/v1/admin/vendors` lists vendors with pagination -- [ ] `GET /api/v1/admin/dashboard` returns statistics -- [ ] `PUT /api/v1/admin/vendors/{id}/verify` toggles verification -- [ ] `PUT /api/v1/admin/vendors/{id}/status` toggles active status -- [ ] All endpoints require admin authentication -- [ ] All endpoints return proper error messages - -### Frontend Tests - -#### Admin Login Page -- [ ] Page loads without errors -- [ ] Form validation works -- [ ] Loading state displays during login -- [ ] Error messages display correctly -- [ ] Successful login redirects to dashboard -- [ ] Token is stored in localStorage -- [ ] Page is responsive on mobile - -#### Admin Dashboard -- [ ] Dashboard loads with statistics -- [ ] Vendor count is accurate -- [ ] User count is accurate -- [ ] Recent vendors list displays -- [ ] Navigation works correctly -- [ ] Refresh button updates data -- [ ] Loading states work correctly -- [ ] No console errors - -#### Vendor Creation Page -- [ ] Form loads correctly -- [ ] All fields are validated -- [ ] Vendor code auto-uppercases -- [ ] Subdomain auto-lowercases -- [ ] Email validation works -- [ ] Submit creates vendor successfully -- [ ] Credentials are displayed once -- [ ] Can copy credentials -- [ ] Form resets after creation -- [ ] Error messages display correctly - -#### Vendor Login Page -- [ ] Page loads with vendor context -- [ ] Vendor name displays correctly -- [ ] Form validation works -- [ ] Login succeeds with correct credentials -- [ ] Login fails with wrong credentials -- [ ] Redirects to vendor dashboard -- [ ] Token stored in localStorage -- [ ] "Vendor Not Found" shows for invalid vendor - -#### Vendor Dashboard -- [ ] Dashboard loads successfully -- [ ] Vendor information displays -- [ ] Statistics load correctly -- [ ] Welcome message shows -- [ ] Verification badge shows correct status -- [ ] No console errors - -### Database Tests - -#### Schema Verification -```sql --- Check tables exist -SELECT table_name FROM information_schema.tables -WHERE table_schema = 'public'; --- Expected: users, vendors, roles, vendor_users - --- Check admin user -SELECT * FROM users WHERE role = 'admin'; - --- Check vendor creation -SELECT * FROM vendors WHERE vendor_code = 'TESTVENDOR'; - --- Check owner user -SELECT * FROM users WHERE email = 'owner@testvendor.com'; - --- Check default roles -SELECT * FROM roles WHERE vendor_id = ( - SELECT id FROM vendors WHERE vendor_code = 'TESTVENDOR' -); --- Expected: 4 roles (Owner, Manager, Editor, Viewer) -``` - -### Security Tests - -- [ ] Passwords are hashed with bcrypt -- [ ] JWT tokens are properly signed -- [ ] Admin endpoints reject non-admin users -- [ ] Vendor endpoints require authentication -- [ ] Cross-vendor access is prevented -- [ ] SQL injection is prevented -- [ ] XSS is prevented in forms -- [ ] CSRF protection is enabled - -### Performance Tests - -- [ ] Admin login responds < 1 second -- [ ] Dashboard loads < 2 seconds -- [ ] Vendor creation completes < 3 seconds -- [ ] Vendor list loads < 1 second -- [ ] No N+1 query problems - -## 📝 Documentation Tasks - -- [ ] Update API documentation with new endpoints -- [ ] Document Alpine.js component patterns -- [ ] Document Jinja2 template structure -- [ ] Create deployment guide -- [ ] Update environment variables documentation -- [ ] Document vendor context detection logic - -## 🚀 Deployment Checklist - -### Environment Setup -- [ ] PostgreSQL database created -- [ ] Environment variables configured -- [ ] Static files directory created -- [ ] Template directory created -- [ ] Database migrations applied - -### Configuration -- [ ] `JWT_SECRET_KEY` set to strong random value -- [ ] `DATABASE_URL` configured correctly -- [ ] `DEBUG` set appropriately (False for production) -- [ ] `ALLOWED_HOSTS` configured -- [ ] CORS settings configured - -### Security -- [ ] Change default admin password -- [ ] Enable HTTPS in production -- [ ] Configure secure cookie settings -- [ ] Set up rate limiting -- [ ] Enable request logging - -### DNS (Production Only) -- [ ] Wildcard subdomain configured: `*.platform.com` -- [ ] Admin subdomain configured: `admin.platform.com` -- [ ] SSL certificates installed - -## 🎯 Acceptance Criteria - -Slice 1 is complete when: - -1. **Admin Workflow Works** - - [ ] Admin can log in - - [ ] Admin can view dashboard - - [ ] Admin can create vendors - - [ ] Admin can manage vendors - -2. **Vendor Workflow Works** - - [ ] Vendor owner receives credentials - - [ ] Vendor owner can log in - - [ ] Vendor dashboard displays correctly - - [ ] Vendor context is properly isolated - -3. **Technical Requirements Met** - - [ ] All API endpoints implemented - - [ ] All frontend pages created - - [ ] Database schema complete - - [ ] Tests pass - - [ ] Documentation complete - -4. **Quality Standards Met** - - [ ] Code follows conventions - - [ ] No security vulnerabilities - - [ ] Performance acceptable - - [ ] Mobile responsive - - [ ] Browser compatible - -## 🔄 Known Issues / To Do - -### High Priority -- [ ] Complete vendor login page -- [ ] Complete vendor dashboard page -- [ ] Test vendor context detection thoroughly -- [ ] Add password reset functionality - -### Medium Priority -- [ ] Add vendor list page for admin -- [ ] Add vendor detail page for admin -- [ ] Improve error messages -- [ ] Add loading skeletons - -### Low Priority -- [ ] Add dark mode support -- [ ] Add keyboard shortcuts -- [ ] Add export functionality -- [ ] Add audit logging - -## 📚 Related Documentation - -- `00_slices_overview.md` - Overview of all slices -- `../quick_start_guide.md` - Quick setup guide -- `../css_structure_guide.txt` - CSS organization -- `../12.project_readme_final.md` - Complete README - -## ➡️ Next Steps - -After completing Slice 1: - -1. **Test Thoroughly**: Run through entire testing checklist -2. **Deploy to Staging**: Test in production-like environment -3. **Demo to Stakeholders**: Show admin → vendor creation flow -4. **Document Learnings**: Update this document with lessons learned -5. **Move to Slice 2**: Begin marketplace import implementation - -## 💡 Tips & Best Practices - -### Working with Alpine.js -- Keep components focused and small -- Use `x-data` for reactive state -- Use `x-init` for loading data -- Prefer `x-show` over `x-if` for toggles -- Use `@click`, `@submit` for event handling - -### Working with Jinja2 -- Pass initial data from backend to avoid extra API calls -- Use template inheritance (extends, blocks) -- Keep logic in backend, not templates -- Use filters for formatting - -### API Integration -- Always handle loading states -- Always handle errors gracefully -- Use the shared `apiClient` utility -- Store tokens in localStorage -- Clear tokens on logout - ---- - -**Slice 1 Status**: 🔄 In Progress -**Next Milestone**: Complete vendor login and dashboard pages -**Estimated Completion**: End of Week 1 \ No newline at end of file diff --git a/docs/project-roadmap/slice2_doc.md b/docs/project-roadmap/slice2_doc.md deleted file mode 100644 index 7ca75b02..00000000 --- a/docs/project-roadmap/slice2_doc.md +++ /dev/null @@ -1,808 +0,0 @@ -# Slice 2: Marketplace Product Import -## Vendor Imports Products from Letzshop - -**Status**: 📋 NOT STARTED -**Timeline**: Week 2 (5 days) -**Prerequisites**: Slice 1 complete - -## 🎯 Slice Objectives - -Enable vendors to import product catalogs from Letzshop marketplace via CSV files. - -### User Stories -- As a Vendor Owner, I can configure my Letzshop CSV URL -- As a Vendor Owner, I can trigger product imports from Letzshop -- As a Vendor Owner, I can view import job status and history -- The system processes CSV data in the background -- As a Vendor Owner, I can see real-time import progress - -### Success Criteria -- [ ] Vendor can configure Letzshop CSV URL (FR, EN, DE) -- [ ] Vendor can trigger import jobs manually -- [ ] System downloads and processes CSV files -- [ ] Import status updates in real-time (Alpine.js) -- [ ] Import history is properly tracked -- [ ] Error handling for failed imports -- [ ] Products stored in staging area (MarketplaceProduct table) -- [ ] Large CSV files process without timeout - -## 📋 Backend Implementation - -### Database Models - -#### MarketplaceProduct Model (`models/database/marketplace_product.py`) - -```python -class MarketplaceProduct(Base, TimestampMixin): - """ - Staging table for imported marketplace products - Products stay here until vendor publishes them to catalog - """ - __tablename__ = "marketplace_products" - - id = Column(Integer, primary_key=True, index=True) - vendor_id = Column(Integer, ForeignKey("vendors.id"), nullable=False) - import_job_id = Column(Integer, ForeignKey("marketplace_import_jobs.id")) - - # External identifiers - external_sku = Column(String, nullable=False, index=True) - marketplace = Column(String, default="letzshop") # Future: other marketplaces - - # Product information (from CSV) - title = Column(String, nullable=False) - description = Column(Text) - price = Column(Numeric(10, 2)) - currency = Column(String(3), default="EUR") - - # Categories and attributes - category = Column(String) - brand = Column(String) - attributes = Column(JSON, default=dict) # Store all CSV columns - - # Images - image_urls = Column(JSON, default=list) # List of image URLs - - # Inventory - stock_quantity = Column(Integer) - is_in_stock = Column(Boolean, default=True) - - # Status - is_selected = Column(Boolean, default=False) # Ready to publish? - is_published = Column(Boolean, default=False) # Already in catalog? - published_product_id = Column(Integer, ForeignKey("products.id"), nullable=True) - - # Metadata - language = Column(String(2)) # 'fr', 'en', 'de' - raw_data = Column(JSON) # Store complete CSV row - - # Relationships - vendor = relationship("Vendor", back_populates="marketplace_products") - import_job = relationship("MarketplaceImportJob", back_populates="products") - published_product = relationship("Product", back_populates="marketplace_source") - - # Indexes - __table_args__ = ( - Index('ix_marketplace_vendor_sku', 'vendor_id', 'external_sku'), - Index('ix_marketplace_selected', 'vendor_id', 'is_selected'), - ) -``` - -#### MarketplaceImportJob Model (`models/database/marketplace.py`) - -```python -class MarketplaceImportJob(Base, TimestampMixin): - """Track CSV import jobs""" - __tablename__ = "marketplace_import_jobs" - - id = Column(Integer, primary_key=True, index=True) - vendor_id = Column(Integer, ForeignKey("vendors.id"), nullable=False) - - # Job details - marketplace = Column(String, default="letzshop") - csv_url = Column(String, nullable=False) - language = Column(String(2)) # 'fr', 'en', 'de' - - # Status tracking - status = Column( - String, - default="pending" - ) # pending, processing, completed, failed - - # Progress - total_rows = Column(Integer, default=0) - processed_rows = Column(Integer, default=0) - imported_count = Column(Integer, default=0) - updated_count = Column(Integer, default=0) - error_count = Column(Integer, default=0) - - # Timing - started_at = Column(DateTime, nullable=True) - completed_at = Column(DateTime, nullable=True) - - # Error handling - error_message = Column(Text, nullable=True) - error_details = Column(JSON, nullable=True) - - # Relationships - vendor = relationship("Vendor", back_populates="import_jobs") - products = relationship("MarketplaceProduct", back_populates="import_job") -``` - -### Pydantic Schemas - -#### Import Schemas (`models/schema/marketplace.py`) - -```python -from pydantic import BaseModel, HttpUrl -from typing import Optional, List -from datetime import datetime - -class MarketplaceImportCreate(BaseModel): - """Create new import job""" - csv_url: HttpUrl - language: str = Field(..., regex="^(fr|en|de)$") - marketplace: str = "letzshop" - -class MarketplaceImportJobResponse(BaseModel): - """Import job details""" - id: int - vendor_id: int - marketplace: str - csv_url: str - language: str - status: str - total_rows: int - processed_rows: int - imported_count: int - updated_count: int - error_count: int - started_at: Optional[datetime] - completed_at: Optional[datetime] - error_message: Optional[str] - created_at: datetime - - class Config: - from_attributes = True - -class MarketplaceProductResponse(BaseModel): - """Marketplace product in staging""" - id: int - vendor_id: int - external_sku: str - title: str - description: Optional[str] - price: float - currency: str - category: Optional[str] - brand: Optional[str] - stock_quantity: Optional[int] - is_in_stock: bool - is_selected: bool - is_published: bool - image_urls: List[str] - language: str - created_at: datetime - - class Config: - from_attributes = True -``` - -### Service Layer - -#### Marketplace Service (`app/services/marketplace_service.py`) - -```python -from typing import List, Dict, Any -import csv -import requests -from io import StringIO -from sqlalchemy.orm import Session -from models.database.marketplace_product import MarketplaceProduct -from models.database.marketplace import MarketplaceImportJob - -class MarketplaceService: - """Handle marketplace product imports""" - - async def create_import_job( - self, - vendor_id: int, - csv_url: str, - language: str, - db: Session - ) -> MarketplaceImportJob: - """Create new import job and start processing""" - - # Create job record - job = MarketplaceImportJob( - vendor_id=vendor_id, - csv_url=csv_url, - language=language, - marketplace="letzshop", - status="pending" - ) - db.add(job) - db.commit() - db.refresh(job) - - # Trigger background processing - from tasks.marketplace_import import process_csv_import - process_csv_import.delay(job.id) - - return job - - def process_csv_import(self, job_id: int, db: Session): - """ - Process CSV import (called by Celery task) - This is a long-running operation - """ - job = db.query(MarketplaceImportJob).get(job_id) - if not job: - return - - try: - # Update status - job.status = "processing" - job.started_at = datetime.utcnow() - db.commit() - - # Download CSV - response = requests.get(job.csv_url, timeout=30) - response.raise_for_status() - - # Parse CSV - csv_content = StringIO(response.text) - reader = csv.DictReader(csv_content) - - # Count total rows - rows = list(reader) - job.total_rows = len(rows) - db.commit() - - # Process each row - for idx, row in enumerate(rows): - try: - self._process_csv_row(job, row, db) - job.processed_rows = idx + 1 - - # Commit every 100 rows - if idx % 100 == 0: - db.commit() - - except Exception as e: - job.error_count += 1 - # Log error but continue - - # Final commit - job.status = "completed" - job.completed_at = datetime.utcnow() - db.commit() - - except Exception as e: - job.status = "failed" - job.error_message = str(e) - job.completed_at = datetime.utcnow() - db.commit() - - def _process_csv_row( - self, - job: MarketplaceImportJob, - row: Dict[str, Any], - db: Session - ): - """Process single CSV row""" - - # Extract fields from CSV - external_sku = row.get('SKU') or row.get('sku') - if not external_sku: - raise ValueError("Missing SKU in CSV row") - - # Check if product already exists - existing = db.query(MarketplaceProduct).filter( - MarketplaceProduct.vendor_id == job.vendor_id, - MarketplaceProduct.external_sku == external_sku - ).first() - - # Parse image URLs - image_urls = [] - for i in range(1, 6): # Support up to 5 images - img_url = row.get(f'Image{i}') or row.get(f'image_{i}') - if img_url: - image_urls.append(img_url) - - if existing: - # Update existing product - existing.title = row.get('Title') or row.get('title') - existing.description = row.get('Description') - existing.price = float(row.get('Price', 0)) - existing.stock_quantity = int(row.get('Stock', 0)) - existing.is_in_stock = existing.stock_quantity > 0 - existing.category = row.get('Category') - existing.brand = row.get('Brand') - existing.image_urls = image_urls - existing.raw_data = row - existing.import_job_id = job.id - - job.updated_count += 1 - else: - # Create new product - product = MarketplaceProduct( - vendor_id=job.vendor_id, - import_job_id=job.id, - external_sku=external_sku, - marketplace="letzshop", - title=row.get('Title') or row.get('title'), - description=row.get('Description'), - price=float(row.get('Price', 0)), - currency="EUR", - category=row.get('Category'), - brand=row.get('Brand'), - stock_quantity=int(row.get('Stock', 0)), - is_in_stock=int(row.get('Stock', 0)) > 0, - image_urls=image_urls, - language=job.language, - raw_data=row, - is_selected=False, - is_published=False - ) - db.add(product) - job.imported_count += 1 - - def get_import_jobs( - self, - vendor_id: int, - db: Session, - skip: int = 0, - limit: int = 20 - ) -> List[MarketplaceImportJob]: - """Get import job history for vendor""" - return db.query(MarketplaceImportJob).filter( - MarketplaceImportJob.vendor_id == vendor_id - ).order_by( - MarketplaceImportJob.created_at.desc() - ).offset(skip).limit(limit).all() - - def get_marketplace_products( - self, - vendor_id: int, - db: Session, - import_job_id: Optional[int] = None, - is_selected: Optional[bool] = None, - skip: int = 0, - limit: int = 100 - ) -> List[MarketplaceProduct]: - """Get marketplace products in staging""" - query = db.query(MarketplaceProduct).filter( - MarketplaceProduct.vendor_id == vendor_id, - MarketplaceProduct.is_published == False # Only unpublished - ) - - if import_job_id: - query = query.filter(MarketplaceProduct.import_job_id == import_job_id) - - if is_selected is not None: - query = query.filter(MarketplaceProduct.is_selected == is_selected) - - return query.order_by( - MarketplaceProduct.created_at.desc() - ).offset(skip).limit(limit).all() -``` - -### API Endpoints - -#### Marketplace Endpoints (`app/api/v1/vendor/marketplace.py`) - -```python -from fastapi import APIRouter, Depends, HTTPException -from sqlalchemy.orm import Session -from typing import List, Optional - -router = APIRouter() - -@router.post("/import", response_model=MarketplaceImportJobResponse) -async def trigger_import( - import_data: MarketplaceImportCreate, - current_user: User = Depends(get_current_vendor_user), - vendor: Vendor = Depends(get_current_vendor), - db: Session = Depends(get_db) -): - """Trigger CSV import from marketplace""" - service = MarketplaceService() - job = await service.create_import_job( - vendor_id=vendor.id, - csv_url=str(import_data.csv_url), - language=import_data.language, - db=db - ) - return job - -@router.get("/jobs", response_model=List[MarketplaceImportJobResponse]) -async def get_import_jobs( - skip: int = 0, - limit: int = 20, - current_user: User = Depends(get_current_vendor_user), - vendor: Vendor = Depends(get_current_vendor), - db: Session = Depends(get_db) -): - """Get import job history""" - service = MarketplaceService() - jobs = service.get_import_jobs(vendor.id, db, skip, limit) - return jobs - -@router.get("/jobs/{job_id}", response_model=MarketplaceImportJobResponse) -async def get_import_job( - job_id: int, - current_user: User = Depends(get_current_vendor_user), - vendor: Vendor = Depends(get_current_vendor), - db: Session = Depends(get_db) -): - """Get specific import job status""" - job = db.query(MarketplaceImportJob).filter( - MarketplaceImportJob.id == job_id, - MarketplaceImportJob.vendor_id == vendor.id - ).first() - - if not job: - raise HTTPException(status_code=404, detail="Import job not found") - - return job - -@router.get("/products", response_model=List[MarketplaceProductResponse]) -async def get_marketplace_products( - import_job_id: Optional[int] = None, - is_selected: Optional[bool] = None, - skip: int = 0, - limit: int = 100, - current_user: User = Depends(get_current_vendor_user), - vendor: Vendor = Depends(get_current_vendor), - db: Session = Depends(get_db) -): - """Get products in marketplace staging area""" - service = MarketplaceService() - products = service.get_marketplace_products( - vendor.id, db, import_job_id, is_selected, skip, limit - ) - return products -``` - -### Background Tasks - -#### Celery Task (`tasks/marketplace_import.py`) - -```python -from celery import shared_task -from app.core.database import SessionLocal -from app.services.marketplace_service import MarketplaceService - -@shared_task(bind=True, max_retries=3) -def process_csv_import(self, job_id: int): - """ - Process CSV import in background - This can take several minutes for large files - """ - db = SessionLocal() - try: - service = MarketplaceService() - service.process_csv_import(job_id, db) - except Exception as e: - # Retry on failure - raise self.retry(exc=e, countdown=60) - finally: - db.close() -``` - -## 🎨 Frontend Implementation - -### Templates - -#### Import Dashboard (`templates/vendor/marketplace/imports.html`) - -```html -{% extends "vendor/base_vendor.html" %} - -{% block title %}Product Import{% endblock %} - -{% block content %} -
- - - - -
-

Letzshop CSV URLs

-
-
- - -
-
- - -
-
- - -
-
-

- Configure these URLs in vendor settings -

-
- - -
-

Import History

- - - - -
- - - -
- - -{% endblock %} - -{% block extra_scripts %} - -{% endblock %} -``` - -## ✅ Testing Checklist - -### Backend Tests -- [ ] CSV download works with valid URL -- [ ] CSV parsing handles various formats -- [ ] Products created in staging table -- [ ] Duplicate SKUs are updated, not duplicated -- [ ] Import job status updates correctly -- [ ] Progress tracking is accurate -- [ ] Error handling works for invalid CSV -- [ ] Large CSV files (10,000+ rows) process successfully -- [ ] Celery tasks execute correctly - -### Frontend Tests -- [ ] Import page loads correctly -- [ ] New import modal works -- [ ] Import jobs display in table -- [ ] Real-time progress updates (polling) -- [ ] Completed imports show results -- [ ] Can view products from import -- [ ] Error states display correctly -- [ ] Loading states work correctly - -### Integration Tests -- [ ] Complete import workflow works end-to-end -- [ ] Vendor isolation maintained -- [ ] API endpoints require authentication -- [ ] Vendor can only see their own imports - -## 🚀 Deployment Checklist - -- [ ] Celery worker running -- [ ] Redis/RabbitMQ configured for Celery -- [ ] Database migrations applied -- [ ] CSV download timeout configured -- [ ] Error logging configured -- [ ] Background task monitoring set up - -## ➡️ Next Steps - -After completing Slice 2, move to **Slice 3: Product Catalog Management** to enable vendors to publish imported products to their catalog. - ---- - -**Slice 2 Status**: 📋 Not Started -**Dependencies**: Slice 1 must be complete -**Estimated Duration**: 5 days \ No newline at end of file diff --git a/docs/project-roadmap/slice3_doc.md b/docs/project-roadmap/slice3_doc.md deleted file mode 100644 index c4775b09..00000000 --- a/docs/project-roadmap/slice3_doc.md +++ /dev/null @@ -1,624 +0,0 @@ -# Slice 3: Product Catalog Management -## Vendor Selects and Publishes Products - -**Status**: 📋 NOT STARTED -**Timeline**: Week 3 (5 days) -**Prerequisites**: Slice 1 & 2 complete - -## 🎯 Slice Objectives - -Enable vendors to browse imported products, select which to publish, customize them, and manage their product catalog. - -### User Stories -- As a Vendor Owner, I can browse imported products from staging -- As a Vendor Owner, I can select which products to publish to my catalog -- As a Vendor Owner, I can customize product information (pricing, descriptions) -- As a Vendor Owner, I can manage my published product catalog -- As a Vendor Owner, I can manually add products (not from marketplace) -- As a Vendor Owner, I can manage inventory for catalog products - -### Success Criteria -- [ ] Vendor can browse all imported products in staging -- [ ] Vendor can filter/search staging products -- [ ] Vendor can select products for publishing -- [ ] Vendor can customize product details before/after publishing -- [ ] Published products appear in vendor catalog -- [ ] Vendor can manually create products -- [ ] Vendor can update product inventory -- [ ] Vendor can activate/deactivate products -- [ ] Product operations are properly isolated by vendor - -## 📋 Backend Implementation - -### Database Models - -#### Product Model (`models/database/product.py`) - -```python -class Product(Base, TimestampMixin): - """ - Vendor's published product catalog - These are customer-facing products - """ - __tablename__ = "products" - - id = Column(Integer, primary_key=True, index=True) - vendor_id = Column(Integer, ForeignKey("vendors.id"), nullable=False) - - # Basic information - sku = Column(String, nullable=False, index=True) - title = Column(String, nullable=False) - description = Column(Text) - short_description = Column(String(500)) - - # Pricing - price = Column(Numeric(10, 2), nullable=False) - compare_at_price = Column(Numeric(10, 2)) # Original price for discounts - cost_per_item = Column(Numeric(10, 2)) # For profit tracking - currency = Column(String(3), default="EUR") - - # Categorization - category = Column(String) - subcategory = Column(String) - brand = Column(String) - tags = Column(JSON, default=list) - - # SEO - slug = Column(String, unique=True, index=True) - meta_title = Column(String) - meta_description = Column(String) - - # Images - featured_image = Column(String) # Main product image - image_urls = Column(JSON, default=list) # Additional images - - # Status - is_active = Column(Boolean, default=True) - is_featured = Column(Boolean, default=False) - is_on_sale = Column(Boolean, default=False) - - # Inventory (simple - detailed in Inventory model) - track_inventory = Column(Boolean, default=True) - stock_quantity = Column(Integer, default=0) - low_stock_threshold = Column(Integer, default=10) - - # Marketplace source (if imported) - marketplace_product_id = Column( - Integer, - ForeignKey("marketplace_products.id"), - nullable=True - ) - external_sku = Column(String, nullable=True) # Original marketplace SKU - - # Additional data - attributes = Column(JSON, default=dict) # Custom attributes - weight = Column(Numeric(10, 2)) # For shipping - dimensions = Column(JSON) # {length, width, height} - - # Relationships - vendor = relationship("Vendor", back_populates="products") - marketplace_source = relationship( - "MarketplaceProduct", - back_populates="published_product" - ) - inventory_records = relationship("Inventory", back_populates="product") - order_items = relationship("OrderItem", back_populates="product") - - # Indexes - __table_args__ = ( - Index('ix_product_vendor_sku', 'vendor_id', 'sku'), - Index('ix_product_active', 'vendor_id', 'is_active'), - Index('ix_product_featured', 'vendor_id', 'is_featured'), - ) -``` - -#### Inventory Model (`models/database/inventory.py`) - -```python -class Inventory(Base, TimestampMixin): - """Track product inventory by location""" - __tablename__ = "inventory" - - id = Column(Integer, primary_key=True, index=True) - vendor_id = Column(Integer, ForeignKey("vendors.id"), nullable=False) - product_id = Column(Integer, ForeignKey("products.id"), nullable=False) - - # Location - location_name = Column(String, default="Default") # Warehouse name - - # Quantities - available_quantity = Column(Integer, default=0) - reserved_quantity = Column(Integer, default=0) # Pending orders - - # Relationships - vendor = relationship("Vendor") - product = relationship("Product", back_populates="inventory_records") - movements = relationship("InventoryMovement", back_populates="inventory") - -class InventoryMovement(Base, TimestampMixin): - """Track inventory changes""" - __tablename__ = "inventory_movements" - - id = Column(Integer, primary_key=True, index=True) - inventory_id = Column(Integer, ForeignKey("inventory.id"), nullable=False) - - # Movement details - movement_type = Column(String) # 'received', 'sold', 'adjusted', 'returned' - quantity_change = Column(Integer) # Positive or negative - - # Context - reference_type = Column(String, nullable=True) # 'order', 'import', 'manual' - reference_id = Column(Integer, nullable=True) - notes = Column(Text) - - # Relationships - inventory = relationship("Inventory", back_populates="movements") -``` - -### Pydantic Schemas - -#### Product Schemas (`models/schema/product.py`) - -```python -class ProductCreate(BaseModel): - """Create product from scratch""" - sku: str - title: str - description: Optional[str] = None - price: float = Field(..., gt=0) - compare_at_price: Optional[float] = None - cost_per_item: Optional[float] = None - category: Optional[str] = None - brand: Optional[str] = None - tags: List[str] = [] - image_urls: List[str] = [] - track_inventory: bool = True - stock_quantity: int = 0 - is_active: bool = True - -class ProductPublishFromMarketplace(BaseModel): - """Publish product from marketplace staging""" - marketplace_product_id: int - custom_title: Optional[str] = None - custom_description: Optional[str] = None - custom_price: Optional[float] = None - custom_sku: Optional[str] = None - stock_quantity: int = 0 - is_active: bool = True - -class ProductUpdate(BaseModel): - """Update existing product""" - title: Optional[str] = None - description: Optional[str] = None - short_description: Optional[str] = None - price: Optional[float] = None - compare_at_price: Optional[float] = None - category: Optional[str] = None - brand: Optional[str] = None - tags: Optional[List[str]] = None - image_urls: Optional[List[str]] = None - is_active: Optional[bool] = None - is_featured: Optional[bool] = None - stock_quantity: Optional[int] = None - -class ProductResponse(BaseModel): - """Product details""" - id: int - vendor_id: int - sku: str - title: str - description: Optional[str] - price: float - compare_at_price: Optional[float] - category: Optional[str] - brand: Optional[str] - tags: List[str] - featured_image: Optional[str] - image_urls: List[str] - is_active: bool - is_featured: bool - stock_quantity: int - marketplace_product_id: Optional[int] - created_at: datetime - updated_at: datetime - - class Config: - from_attributes = True -``` - -### Service Layer - -#### Product Service (`app/services/product_service.py`) - -```python -class ProductService: - """Handle product catalog operations""" - - async def publish_from_marketplace( - self, - vendor_id: int, - publish_data: ProductPublishFromMarketplace, - db: Session - ) -> Product: - """Publish marketplace product to catalog""" - - # Get marketplace product - mp_product = db.query(MarketplaceProduct).filter( - MarketplaceProduct.id == publish_data.marketplace_product_id, - MarketplaceProduct.vendor_id == vendor_id, - MarketplaceProduct.is_published == False - ).first() - - if not mp_product: - raise ProductNotFoundError("Marketplace product not found") - - # Check if SKU already exists - sku = publish_data.custom_sku or mp_product.external_sku - existing = db.query(Product).filter( - Product.vendor_id == vendor_id, - Product.sku == sku - ).first() - - if existing: - raise ProductAlreadyExistsError(f"Product with SKU {sku} already exists") - - # Create product - product = Product( - vendor_id=vendor_id, - sku=sku, - title=publish_data.custom_title or mp_product.title, - description=publish_data.custom_description or mp_product.description, - price=publish_data.custom_price or mp_product.price, - currency=mp_product.currency, - category=mp_product.category, - brand=mp_product.brand, - image_urls=mp_product.image_urls, - featured_image=mp_product.image_urls[0] if mp_product.image_urls else None, - marketplace_product_id=mp_product.id, - external_sku=mp_product.external_sku, - stock_quantity=publish_data.stock_quantity, - is_active=publish_data.is_active, - slug=self._generate_slug(publish_data.custom_title or mp_product.title) - ) - - db.add(product) - - # Mark marketplace product as published - mp_product.is_published = True - mp_product.published_product_id = product.id - - # Create initial inventory record - inventory = Inventory( - vendor_id=vendor_id, - product_id=product.id, - location_name="Default", - available_quantity=publish_data.stock_quantity, - reserved_quantity=0 - ) - db.add(inventory) - - # Record inventory movement - if publish_data.stock_quantity > 0: - movement = InventoryMovement( - inventory_id=inventory.id, - movement_type="received", - quantity_change=publish_data.stock_quantity, - reference_type="import", - notes="Initial stock from marketplace import" - ) - db.add(movement) - - db.commit() - db.refresh(product) - - return product - - async def create_product( - self, - vendor_id: int, - product_data: ProductCreate, - db: Session - ) -> Product: - """Create product manually""" - - # Check SKU uniqueness - existing = db.query(Product).filter( - Product.vendor_id == vendor_id, - Product.sku == product_data.sku - ).first() - - if existing: - raise ProductAlreadyExistsError(f"SKU {product_data.sku} already exists") - - product = Product( - vendor_id=vendor_id, - **product_data.dict(), - slug=self._generate_slug(product_data.title), - featured_image=product_data.image_urls[0] if product_data.image_urls else None - ) - - db.add(product) - - # Create inventory - if product_data.track_inventory: - inventory = Inventory( - vendor_id=vendor_id, - product_id=product.id, - available_quantity=product_data.stock_quantity - ) - db.add(inventory) - - db.commit() - db.refresh(product) - - return product - - def get_products( - self, - vendor_id: int, - db: Session, - is_active: Optional[bool] = None, - category: Optional[str] = None, - search: Optional[str] = None, - skip: int = 0, - limit: int = 100 - ) -> List[Product]: - """Get vendor's product catalog""" - - query = db.query(Product).filter(Product.vendor_id == vendor_id) - - if is_active is not None: - query = query.filter(Product.is_active == is_active) - - if category: - query = query.filter(Product.category == category) - - if search: - query = query.filter( - or_( - Product.title.ilike(f"%{search}%"), - Product.sku.ilike(f"%{search}%"), - Product.brand.ilike(f"%{search}%") - ) - ) - - return query.order_by( - Product.created_at.desc() - ).offset(skip).limit(limit).all() - - async def update_product( - self, - vendor_id: int, - product_id: int, - update_data: ProductUpdate, - db: Session - ) -> Product: - """Update product details""" - - product = db.query(Product).filter( - Product.id == product_id, - Product.vendor_id == vendor_id - ).first() - - if not product: - raise ProductNotFoundError() - - # Update fields - update_dict = update_data.dict(exclude_unset=True) - for field, value in update_dict.items(): - setattr(product, field, value) - - # Update stock if changed - if 'stock_quantity' in update_dict: - self._update_inventory(product, update_dict['stock_quantity'], db) - - db.commit() - db.refresh(product) - - return product - - def _generate_slug(self, title: str) -> str: - """Generate URL-friendly slug""" - import re - slug = title.lower() - slug = re.sub(r'[^a-z0-9]+', '-', slug) - slug = slug.strip('-') - return slug - - def _update_inventory( - self, - product: Product, - new_quantity: int, - db: Session - ): - """Update product inventory""" - inventory = db.query(Inventory).filter( - Inventory.product_id == product.id - ).first() - - if inventory: - quantity_change = new_quantity - inventory.available_quantity - inventory.available_quantity = new_quantity - - # Record movement - movement = InventoryMovement( - inventory_id=inventory.id, - movement_type="adjusted", - quantity_change=quantity_change, - reference_type="manual", - notes="Manual adjustment" - ) - db.add(movement) -``` - -### API Endpoints - -#### Product Endpoints (`app/api/v1/vendor/products.py`) - -```python -@router.get("", response_model=List[ProductResponse]) -async def get_products( - is_active: Optional[bool] = None, - category: Optional[str] = None, - search: Optional[str] = None, - skip: int = 0, - limit: int = 100, - current_user: User = Depends(get_current_vendor_user), - vendor: Vendor = Depends(get_current_vendor), - db: Session = Depends(get_db) -): - """Get vendor's product catalog""" - service = ProductService() - products = service.get_products( - vendor.id, db, is_active, category, search, skip, limit - ) - return products - -@router.post("", response_model=ProductResponse) -async def create_product( - product_data: ProductCreate, - current_user: User = Depends(get_current_vendor_user), - vendor: Vendor = Depends(get_current_vendor), - db: Session = Depends(get_db) -): - """Create product manually""" - service = ProductService() - product = await service.create_product(vendor.id, product_data, db) - return product - -@router.post("/from-marketplace", response_model=ProductResponse) -async def publish_from_marketplace( - publish_data: ProductPublishFromMarketplace, - current_user: User = Depends(get_current_vendor_user), - vendor: Vendor = Depends(get_current_vendor), - db: Session = Depends(get_db) -): - """Publish marketplace product to catalog""" - service = ProductService() - product = await service.publish_from_marketplace( - vendor.id, publish_data, db - ) - return product - -@router.get("/{product_id}", response_model=ProductResponse) -async def get_product( - product_id: int, - current_user: User = Depends(get_current_vendor_user), - vendor: Vendor = Depends(get_current_vendor), - db: Session = Depends(get_db) -): - """Get product details""" - product = db.query(Product).filter( - Product.id == product_id, - Product.vendor_id == vendor.id - ).first() - - if not product: - raise HTTPException(status_code=404, detail="Product not found") - - return product - -@router.put("/{product_id}", response_model=ProductResponse) -async def update_product( - product_id: int, - update_data: ProductUpdate, - current_user: User = Depends(get_current_vendor_user), - vendor: Vendor = Depends(get_current_vendor), - db: Session = Depends(get_db) -): - """Update product""" - service = ProductService() - product = await service.update_product(vendor.id, product_id, update_data, db) - return product - -@router.put("/{product_id}/toggle-active") -async def toggle_product_active( - product_id: int, - current_user: User = Depends(get_current_vendor_user), - vendor: Vendor = Depends(get_current_vendor), - db: Session = Depends(get_db) -): - """Activate/deactivate product""" - product = db.query(Product).filter( - Product.id == product_id, - Product.vendor_id == vendor.id - ).first() - - if not product: - raise HTTPException(status_code=404, detail="Product not found") - - product.is_active = not product.is_active - db.commit() - - return {"is_active": product.is_active} - -@router.delete("/{product_id}") -async def delete_product( - product_id: int, - current_user: User = Depends(get_current_vendor_user), - vendor: Vendor = Depends(get_current_vendor), - db: Session = Depends(get_db) -): - """Remove product from catalog""" - product = db.query(Product).filter( - Product.id == product_id, - Product.vendor_id == vendor.id - ).first() - - if not product: - raise HTTPException(status_code=404, detail="Product not found") - - # Mark as inactive instead of deleting - product.is_active = False - db.commit() - - return {"success": True} -``` - -## 🎨 Frontend Implementation - -### Templates - -#### Browse Marketplace Products (`templates/vendor/marketplace/browse.html`) - -Uses Alpine.js for reactive filtering, selection, and bulk publishing. - -#### Product Catalog (`templates/vendor/products/list.html`) - -Product management interface with search, filters, and quick actions. - -#### Product Edit (`templates/vendor/products/edit.html`) - -Detailed product editing with image management and inventory tracking. - -## ✅ Testing Checklist - -### Backend Tests -- [ ] Product publishing from marketplace works -- [ ] Manual product creation works -- [ ] Product updates work correctly -- [ ] Inventory tracking is accurate -- [ ] SKU uniqueness is enforced -- [ ] Vendor isolation maintained -- [ ] Product search/filtering works -- [ ] Slug generation works correctly - -### Frontend Tests -- [ ] Browse marketplace products -- [ ] Select multiple products for publishing -- [ ] Publish single product with customization -- [ ] View product catalog -- [ ] Edit product details -- [ ] Toggle product active status -- [ ] Delete/deactivate products -- [ ] Search and filter products - -## ➡️ Next Steps - -After completing Slice 3, move to **Slice 4: Customer Shopping Experience** to build the public-facing shop. - ---- - -**Slice 3 Status**: 📋 Not Started -**Dependencies**: Slices 1 & 2 must be complete -**Estimated Duration**: 5 days \ No newline at end of file diff --git a/docs/project-roadmap/slice4_doc.md b/docs/project-roadmap/slice4_doc.md deleted file mode 100644 index 9161f9c7..00000000 --- a/docs/project-roadmap/slice4_doc.md +++ /dev/null @@ -1,887 +0,0 @@ -# Slice 4: Customer Shopping Experience -## Customers Browse and Shop on Vendor Stores - -**Status**: 📋 NOT STARTED -**Timeline**: Week 4 (5 days) -**Prerequisites**: Slices 1, 2, & 3 complete - -## 🎯 Slice Objectives - -Build the public-facing customer shop where customers can browse products, register accounts, and add items to cart. - -### User Stories -- As a Customer, I can browse products on a vendor's shop -- As a Customer, I can view detailed product information -- As a Customer, I can search for products -- As a Customer, I can register for a vendor-specific account -- As a Customer, I can log into my account -- As a Customer, I can add products to my shopping cart -- As a Customer, I can manage my cart (update quantities, remove items) -- Cart persists across sessions - -### Success Criteria -- [ ] Customers can browse products without authentication -- [ ] Product catalog displays correctly with images and prices -- [ ] Product detail pages show complete information -- [ ] Search functionality works -- [ ] Customers can register vendor-specific accounts -- [ ] Customer login/logout works -- [ ] Shopping cart is functional with Alpine.js reactivity -- [ ] Cart persists (session-based before login, user-based after) -- [ ] Customer data is properly isolated by vendor -- [ ] Mobile responsive design - -## 📋 Backend Implementation - -### Database Models - -#### Customer Model (`models/database/customer.py`) - -```python -class Customer(Base, TimestampMixin): - """ - Vendor-scoped customer accounts - Each customer belongs to ONE vendor - """ - __tablename__ = "customers" - - id = Column(Integer, primary_key=True, index=True) - vendor_id = Column(Integer, ForeignKey("vendors.id"), nullable=False) - - # Authentication - email = Column(String, nullable=False, index=True) - hashed_password = Column(String, nullable=False) - - # Personal information - first_name = Column(String) - last_name = Column(String) - phone = Column(String) - - # Customer metadata - customer_number = Column(String, unique=True, index=True) # Auto-generated - - # Preferences - language = Column(String(2), default="en") - newsletter_subscribed = Column(Boolean, default=False) - marketing_emails = Column(Boolean, default=True) - preferences = Column(JSON, default=dict) - - # Statistics - total_orders = Column(Integer, default=0) - total_spent = Column(Numeric(10, 2), default=0) - - # Status - is_active = Column(Boolean, default=True) - email_verified = Column(Boolean, default=False) - last_login_at = Column(DateTime, nullable=True) - - # Relationships - vendor = relationship("Vendor", back_populates="customers") - addresses = relationship("CustomerAddress", back_populates="customer", cascade="all, delete-orphan") - orders = relationship("Order", back_populates="customer") - cart = relationship("Cart", back_populates="customer", uselist=False) - - # Indexes - __table_args__ = ( - Index('ix_customer_vendor_email', 'vendor_id', 'email', unique=True), - ) - -class CustomerAddress(Base, TimestampMixin): - """Customer shipping/billing addresses""" - __tablename__ = "customer_addresses" - - id = Column(Integer, primary_key=True, index=True) - customer_id = Column(Integer, ForeignKey("customers.id"), nullable=False) - - # Address type - address_type = Column(String, default="shipping") # shipping, billing, both - is_default = Column(Boolean, default=False) - - # Address details - first_name = Column(String) - last_name = Column(String) - company = Column(String) - address_line1 = Column(String, nullable=False) - address_line2 = Column(String) - city = Column(String, nullable=False) - state_province = Column(String) - postal_code = Column(String, nullable=False) - country = Column(String, nullable=False, default="LU") - phone = Column(String) - - # Relationships - customer = relationship("Customer", back_populates="addresses") -``` - -#### Cart Model (`models/database/cart.py`) - -```python -class Cart(Base, TimestampMixin): - """Shopping cart - session or customer-based""" - __tablename__ = "carts" - - id = Column(Integer, primary_key=True, index=True) - vendor_id = Column(Integer, ForeignKey("vendors.id"), nullable=False) - - # Owner (one of these must be set) - customer_id = Column(Integer, ForeignKey("customers.id"), nullable=True) - session_id = Column(String, nullable=True, index=True) # For guest users - - # Cart metadata - currency = Column(String(3), default="EUR") - - # Relationships - vendor = relationship("Vendor") - customer = relationship("Customer", back_populates="cart") - items = relationship("CartItem", back_populates="cart", cascade="all, delete-orphan") - - # Computed properties - @property - def total_items(self) -> int: - return sum(item.quantity for item in self.items) - - @property - def subtotal(self) -> Decimal: - return sum(item.line_total for item in self.items) - - __table_args__ = ( - Index('ix_cart_vendor_session', 'vendor_id', 'session_id'), - Index('ix_cart_vendor_customer', 'vendor_id', 'customer_id'), - ) - -class CartItem(Base, TimestampMixin): - """Individual items in cart""" - __tablename__ = "cart_items" - - id = Column(Integer, primary_key=True, index=True) - cart_id = Column(Integer, ForeignKey("carts.id"), nullable=False) - product_id = Column(Integer, ForeignKey("products.id"), nullable=False) - - # Item details - quantity = Column(Integer, nullable=False, default=1) - unit_price = Column(Numeric(10, 2), nullable=False) # Snapshot at time of add - - # Relationships - cart = relationship("Cart", back_populates="items") - product = relationship("Product") - - @property - def line_total(self) -> Decimal: - return self.unit_price * self.quantity -``` - -### Pydantic Schemas - -#### Customer Schemas (`models/schema/customer.py`) - -```python -class CustomerRegister(BaseModel): - """Customer registration""" - email: EmailStr - password: str = Field(..., min_length=8) - first_name: str - last_name: str - phone: Optional[str] = None - newsletter_subscribed: bool = False - -class CustomerLogin(BaseModel): - """Customer login""" - email: EmailStr - password: str - -class CustomerResponse(BaseModel): - """Customer details""" - id: int - vendor_id: int - email: str - first_name: str - last_name: str - phone: Optional[str] - customer_number: str - total_orders: int - total_spent: float - is_active: bool - created_at: datetime - - class Config: - from_attributes = True - -class CustomerAddressCreate(BaseModel): - """Create address""" - address_type: str = "shipping" - is_default: bool = False - first_name: str - last_name: str - company: Optional[str] = None - address_line1: str - address_line2: Optional[str] = None - city: str - state_province: Optional[str] = None - postal_code: str - country: str = "LU" - phone: Optional[str] = None - -class CustomerAddressResponse(BaseModel): - """Address details""" - id: int - address_type: str - is_default: bool - first_name: str - last_name: str - company: Optional[str] - address_line1: str - address_line2: Optional[str] - city: str - state_province: Optional[str] - postal_code: str - country: str - phone: Optional[str] - - class Config: - from_attributes = True -``` - -#### Cart Schemas (`models/schema/cart.py`) - -```python -class CartItemAdd(BaseModel): - """Add item to cart""" - product_id: int - quantity: int = Field(..., gt=0) - -class CartItemUpdate(BaseModel): - """Update cart item""" - quantity: int = Field(..., gt=0) - -class CartItemResponse(BaseModel): - """Cart item details""" - id: int - product_id: int - product_title: str - product_image: Optional[str] - product_sku: str - quantity: int - unit_price: float - line_total: float - - class Config: - from_attributes = True - -class CartResponse(BaseModel): - """Complete cart""" - id: int - vendor_id: int - total_items: int - subtotal: float - currency: str - items: List[CartItemResponse] - - class Config: - from_attributes = True -``` - -### Service Layer - -#### Customer Service (`app/services/customer_service.py`) - -```python -class CustomerService: - """Handle customer operations""" - - def __init__(self): - self.auth_manager = AuthManager() - - async def register_customer( - self, - vendor_id: int, - customer_data: CustomerRegister, - db: Session - ) -> Customer: - """Register new customer for vendor""" - - # Check if email already exists for this vendor - existing = db.query(Customer).filter( - Customer.vendor_id == vendor_id, - Customer.email == customer_data.email - ).first() - - if existing: - raise CustomerAlreadyExistsError("Email already registered") - - # Generate customer number - customer_number = self._generate_customer_number(vendor_id, db) - - # Create customer - customer = Customer( - vendor_id=vendor_id, - email=customer_data.email, - hashed_password=self.auth_manager.hash_password(customer_data.password), - first_name=customer_data.first_name, - last_name=customer_data.last_name, - phone=customer_data.phone, - customer_number=customer_number, - newsletter_subscribed=customer_data.newsletter_subscribed, - is_active=True - ) - - db.add(customer) - db.commit() - db.refresh(customer) - - return customer - - async def authenticate_customer( - self, - vendor_id: int, - email: str, - password: str, - db: Session - ) -> Tuple[Customer, str]: - """Authenticate customer and return token""" - - customer = db.query(Customer).filter( - Customer.vendor_id == vendor_id, - Customer.email == email - ).first() - - if not customer: - raise InvalidCredentialsError() - - if not customer.is_active: - raise CustomerInactiveError() - - if not self.auth_manager.verify_password(password, customer.hashed_password): - raise InvalidCredentialsError() - - # Update last login - customer.last_login_at = datetime.utcnow() - db.commit() - - # Generate JWT token - token = self.auth_manager.create_access_token({ - "sub": str(customer.id), - "email": customer.email, - "vendor_id": vendor_id, - "type": "customer" - }) - - return customer, token - - def _generate_customer_number(self, vendor_id: int, db: Session) -> str: - """Generate unique customer number""" - # Format: VENDOR_CODE-YYYYMMDD-XXXX - from models.database.vendor import Vendor - - vendor = db.query(Vendor).get(vendor_id) - date_str = datetime.utcnow().strftime("%Y%m%d") - - # Count customers today - today_start = datetime.utcnow().replace(hour=0, minute=0, second=0) - count = db.query(Customer).filter( - Customer.vendor_id == vendor_id, - Customer.created_at >= today_start - ).count() - - return f"{vendor.vendor_code}-{date_str}-{count+1:04d}" -``` - -#### Cart Service (`app/services/cart_service.py`) - -```python -class CartService: - """Handle shopping cart operations""" - - async def get_or_create_cart( - self, - vendor_id: int, - db: Session, - customer_id: Optional[int] = None, - session_id: Optional[str] = None - ) -> Cart: - """Get existing cart or create new one""" - - if customer_id: - cart = db.query(Cart).filter( - Cart.vendor_id == vendor_id, - Cart.customer_id == customer_id - ).first() - else: - cart = db.query(Cart).filter( - Cart.vendor_id == vendor_id, - Cart.session_id == session_id - ).first() - - if not cart: - cart = Cart( - vendor_id=vendor_id, - customer_id=customer_id, - session_id=session_id - ) - db.add(cart) - db.commit() - db.refresh(cart) - - return cart - - async def add_to_cart( - self, - cart: Cart, - product_id: int, - quantity: int, - db: Session - ) -> CartItem: - """Add product to cart""" - - # Verify product exists and is active - product = db.query(Product).filter( - Product.id == product_id, - Product.vendor_id == cart.vendor_id, - Product.is_active == True - ).first() - - if not product: - raise ProductNotFoundError() - - # Check if product already in cart - existing_item = db.query(CartItem).filter( - CartItem.cart_id == cart.id, - CartItem.product_id == product_id - ).first() - - if existing_item: - # Update quantity - existing_item.quantity += quantity - db.commit() - db.refresh(existing_item) - return existing_item - else: - # Add new item - cart_item = CartItem( - cart_id=cart.id, - product_id=product_id, - quantity=quantity, - unit_price=product.price - ) - db.add(cart_item) - db.commit() - db.refresh(cart_item) - return cart_item - - async def update_cart_item( - self, - cart_item_id: int, - quantity: int, - cart: Cart, - db: Session - ) -> CartItem: - """Update cart item quantity""" - - cart_item = db.query(CartItem).filter( - CartItem.id == cart_item_id, - CartItem.cart_id == cart.id - ).first() - - if not cart_item: - raise CartItemNotFoundError() - - cart_item.quantity = quantity - db.commit() - db.refresh(cart_item) - - return cart_item - - async def remove_from_cart( - self, - cart_item_id: int, - cart: Cart, - db: Session - ): - """Remove item from cart""" - - cart_item = db.query(CartItem).filter( - CartItem.id == cart_item_id, - CartItem.cart_id == cart.id - ).first() - - if not cart_item: - raise CartItemNotFoundError() - - db.delete(cart_item) - db.commit() - - async def clear_cart(self, cart: Cart, db: Session): - """Clear all items from cart""" - - db.query(CartItem).filter(CartItem.cart_id == cart.id).delete() - db.commit() - - async def merge_carts( - self, - session_cart_id: int, - customer_cart_id: int, - db: Session - ): - """Merge session cart into customer cart after login""" - - session_cart = db.query(Cart).get(session_cart_id) - customer_cart = db.query(Cart).get(customer_cart_id) - - if not session_cart or not customer_cart: - return - - # Move items from session cart to customer cart - for item in session_cart.items: - # Check if product already in customer cart - existing = db.query(CartItem).filter( - CartItem.cart_id == customer_cart.id, - CartItem.product_id == item.product_id - ).first() - - if existing: - existing.quantity += item.quantity - else: - item.cart_id = customer_cart.id - - # Delete session cart - db.delete(session_cart) - db.commit() -``` - -### API Endpoints - -#### Public Product Endpoints (`app/api/v1/public/vendors/products.py`) - -```python -@router.get("", response_model=List[ProductResponse]) -async def get_public_products( - vendor_id: int, - category: Optional[str] = None, - search: Optional[str] = None, - min_price: Optional[float] = None, - max_price: Optional[float] = None, - skip: int = 0, - limit: int = 50, - db: Session = Depends(get_db) -): - """Get public product catalog (no auth required)""" - - query = db.query(Product).filter( - Product.vendor_id == vendor_id, - Product.is_active == True - ) - - if category: - query = query.filter(Product.category == category) - - if search: - query = query.filter( - or_( - Product.title.ilike(f"%{search}%"), - Product.description.ilike(f"%{search}%") - ) - ) - - if min_price: - query = query.filter(Product.price >= min_price) - - if max_price: - query = query.filter(Product.price <= max_price) - - products = query.order_by( - Product.is_featured.desc(), - Product.created_at.desc() - ).offset(skip).limit(limit).all() - - return products - -@router.get("/{product_id}", response_model=ProductResponse) -async def get_public_product( - vendor_id: int, - product_id: int, - db: Session = Depends(get_db) -): - """Get product details (no auth required)""" - - product = db.query(Product).filter( - Product.id == product_id, - Product.vendor_id == vendor_id, - Product.is_active == True - ).first() - - if not product: - raise HTTPException(status_code=404, detail="Product not found") - - return product - -@router.get("/search") -async def search_products( - vendor_id: int, - q: str, - db: Session = Depends(get_db) -): - """Search products""" - # Implement search logic - pass -``` - -#### Customer Auth Endpoints (`app/api/v1/public/vendors/auth.py`) - -```python -@router.post("/register", response_model=CustomerResponse) -async def register_customer( - vendor_id: int, - customer_data: CustomerRegister, - db: Session = Depends(get_db) -): - """Register new customer""" - service = CustomerService() - customer = await service.register_customer(vendor_id, customer_data, db) - return customer - -@router.post("/login") -async def login_customer( - vendor_id: int, - credentials: CustomerLogin, - db: Session = Depends(get_db) -): - """Customer login""" - service = CustomerService() - customer, token = await service.authenticate_customer( - vendor_id, credentials.email, credentials.password, db - ) - - return { - "access_token": token, - "token_type": "bearer", - "customer": CustomerResponse.from_orm(customer) - } -``` - -#### Cart Endpoints (`app/api/v1/public/vendors/cart.py`) - -```python -@router.get("/{session_id}", response_model=CartResponse) -async def get_cart( - vendor_id: int, - session_id: str, - current_customer: Optional[Customer] = Depends(get_current_customer_optional), - db: Session = Depends(get_db) -): - """Get cart (session or customer)""" - service = CartService() - cart = await service.get_or_create_cart( - vendor_id, - db, - customer_id=current_customer.id if current_customer else None, - session_id=session_id if not current_customer else None - ) - return cart - -@router.post("/{session_id}/items", response_model=CartItemResponse) -async def add_to_cart( - vendor_id: int, - session_id: str, - item_data: CartItemAdd, - current_customer: Optional[Customer] = Depends(get_current_customer_optional), - db: Session = Depends(get_db) -): - """Add item to cart""" - service = CartService() - cart = await service.get_or_create_cart(vendor_id, db, current_customer.id if current_customer else None, session_id) - item = await service.add_to_cart(cart, item_data.product_id, item_data.quantity, db) - return item -``` - -## 🎨 Frontend Implementation - -### Templates - -#### Shop Homepage (`templates/shop/home.html`) - -```html -{% extends "shop/base_shop.html" %} - -{% block content %} -
- -
-

Welcome to {{ vendor.name }}

-

{{ vendor.description }}

- - Shop Now - -
- - -
-

Featured Products

-
- -
-
-
-{% endblock %} -``` - -#### Product Detail (`templates/shop/product.html`) - -```html -{% extends "shop/base_shop.html" %} - -{% block content %} -
-
- -
- -
- - -
-

-
- - -
- -
- - -
- - - -
- - - -
-
-
- - -{% endblock %} - -{% block extra_scripts %} - -{% endblock %} -``` - -#### Shopping Cart (`templates/shop/cart.html`) - -Full Alpine.js reactive cart with real-time totals and quantity updates. - -## ✅ Testing Checklist - -### Backend Tests -- [ ] Customer registration works -- [ ] Duplicate email prevention works -- [ ] Customer login/authentication works -- [ ] Customer number generation is unique -- [ ] Public product browsing works without auth -- [ ] Product search/filtering works -- [ ] Cart creation works (session and customer) -- [ ] Add to cart works -- [ ] Update cart quantity works -- [ ] Remove from cart works -- [ ] Cart persists across sessions -- [ ] Cart merges after login -- [ ] Vendor isolation maintained - -### Frontend Tests -- [ ] Shop homepage loads -- [ ] Product listing displays -- [ ] Product search works -- [ ] Product detail page works -- [ ] Customer registration form works -- [ ] Customer login works -- [ ] Add to cart works -- [ ] Cart updates in real-time (Alpine.js) -- [ ] Cart icon shows count -- [ ] Mobile responsive - -## ➡️ Next Steps - -After completing Slice 4, move to **Slice 5: Order Processing** to complete the checkout flow and order management. - ---- - -**Slice 4 Status**: 📋 Not Started -**Dependencies**: Slices 1, 2, & 3 must be complete -**Estimated Duration**: 5 days \ No newline at end of file diff --git a/docs/project-roadmap/slice5_doc.md b/docs/project-roadmap/slice5_doc.md deleted file mode 100644 index d437a678..00000000 --- a/docs/project-roadmap/slice5_doc.md +++ /dev/null @@ -1,1628 +0,0 @@ - async placeOrder() { - this.placing = true; - try { - const order = await apiClient.post( - `/api/v1/public/vendors/${window.vendorId}/orders`, - { - shipping_address_id: this.selectedShippingAddress, - billing_address_id: this.selectedBillingAddress, - shipping_method: 'standard', - payment_method: this.paymentMethod, - customer_notes: '' - } - ); - - this.orderNumber = order.order_number; - this.currentStep = 4; - - // Clear cart from localStorage - clearCart(); - } catch (error) { - showNotification(error.message || 'Failed to place order', 'error'); - } finally { - this.placing = false; - } - } - } -} - -{% endblock %} -``` - -#### Customer Order History (`templates/shop/account/orders.html`) - -```html -{% extends "shop/base_shop.html" %} - -{% block content %} -
-
-

My Orders

- - - - - - -
- -
-
-
- - -{% endblock %} - -{% block extra_scripts %} - -{% endblock %} -``` - -#### Vendor Order Management (`templates/vendor/orders/list.html`) - -```html -{% extends "vendor/base_vendor.html" %} - -{% block title %}Order Management{% endblock %} - -{% block content %} -
- - - - -
-
- - -
- -
- - -
- -
- - -
- -
- - -
-
- - -
- - - -
- - - -
-{% endblock %} - -{% block extra_scripts %} - -{% endblock %} -``` - -## ✅ Testing Checklist - -### Backend Tests - -#### Order Creation -- [ ] Order created successfully from cart -- [ ] Order number generated uniquely -- [ ] Order items created with product snapshots -- [ ] Inventory reserved correctly -- [ ] Customer stats updated (total_orders, total_spent) -- [ ] Cart cleared after order placement -- [ ] Order status history recorded -- [ ] Shipping/billing addresses validated -- [ ] Tax calculated correctly -- [ ] Shipping cost calculated correctly - -#### Order Management -- [ ] Customer can view their order history -- [ ] Customer can view order details -- [ ] Vendor can view all orders -- [ ] Order filtering works (status, date, search) -- [ ] Order search works (order number, customer name) -- [ ] Vendor can update order status -- [ ] Status history tracked correctly -- [ ] Tracking number can be added - -#### Payment Integration -- [ ] Payment method validation -- [ ] Payment status tracking -- [ ] Stripe integration ready (placeholder) - -#### Notifications -- [ ] Order confirmation email sent -- [ ] Status update email sent -- [ ] Email contains correct order details - -### Frontend Tests - -#### Checkout Flow -- [ ] Multi-step checkout works -- [ ] Cart review displays correctly -- [ ] Address selection works -- [ ] New address creation works -- [ ] Payment method selection works -- [ ] Order summary calculates correctly -- [ ] Order placement succeeds -- [ ] Confirmation page displays -- [ ] Loading states work -- [ ] Error handling works - -#### Customer Order History -- [ ] Order list displays correctly -- [ ] Order cards show correct information -- [ ] Status badges display correctly -- [ ] Order details link works -- [ ] Pagination works -- [ ] Empty state displays - -#### Vendor Order Management -- [ ] Order table displays correctly -- [ ] Filters work (status, date, search) -- [ ] Order details modal works -- [ ] Status update modal works -- [ ] Status updates successfully -- [ ] Real-time status updates -- [ ] Export functionality (when implemented) - -### Integration Tests - -#### Complete Order Flow -- [ ] Customer adds products to cart -- [ ] Customer proceeds to checkout -- [ ] Customer completes checkout -- [ ] Order appears in customer history -- [ ] Order appears in vendor orders -- [ ] Vendor updates order status -- [ ] Customer sees status update -- [ ] Emails sent at each step - -#### Vendor Isolation -- [ ] Orders scoped to correct vendor -- [ ] Customers can only see their orders -- [ ] Vendors can only manage their orders -- [ ] Cross-vendor access prevented - -#### Data Integrity -- [ ] Inventory correctly reserved/updated -- [ ] Product snapshots preserved -- [ ] Customer stats accurate -- [ ] Order totals calculate correctly -- [ ] Status history maintained - -## 📝 Additional Features (Optional) - -### Payment Integration -- [ ] Stripe payment element integration -- [ ] Payment intent creation -- [ ] Payment confirmation handling -- [ ] Refund processing - -### Email Notifications -- [ ] Order confirmation template -- [ ] Order shipped notification -- [ ] Order delivered notification -- [ ] Order cancelled notification - -### Advanced Features -- [ ] Order notes/communication -- [ ] Partial refunds -- [ ] Order tracking page -- [ ] Invoice generation -- [ ] Bulk order status updates -- [ ] Order analytics dashboard - -## 🚀 Deployment Checklist - -### Environment Setup -- [ ] Email service configured (SendGrid, Mailgun, etc.) -- [ ] Stripe API keys configured (test and live) -- [ ] Order number prefix configured -- [ ] Tax rates configured by country -- [ ] Shipping rates configured - -### Testing -- [ ] Complete checkout flow tested -- [ ] Payment processing tested (test mode) -- [ ] Email notifications tested -- [ ] Order management tested -- [ ] Mobile checkout tested - -### Production Readiness -- [ ] Stripe live mode enabled -- [ ] Email templates reviewed -- [ ] Error logging enabled -- [ ] Order monitoring set up -- [ ] Backup strategy for orders - -## 🎯 Acceptance Criteria - -Slice 5 is complete when: - -1. **Customer Checkout Works** - - [ ] Multi-step checkout functional - - [ ] Address management works - - [ ] Orders can be placed successfully - - [ ] Confirmation displayed - -2. **Customer Order Management Works** - - [ ] Order history displays - - [ ] Order details accessible - - [ ] Order tracking works - -3. **Vendor Order Management Works** - - [ ] All orders visible - - [ ] Filtering and search work - - [ ] Status updates work - - [ ] Order details accessible - -4. **System Integration Complete** - - [ ] Inventory updates correctly - - [ ] Emails sent automatically - - [ ] Data integrity maintained - - [ ] Vendor isolation enforced - -5. **Production Ready** - - [ ] All tests pass - - [ ] Payment integration ready - - [ ] Email system configured - - [ ] Error handling robust - -## 🎉 Platform Complete! - -After completing Slice 5, your multi-tenant ecommerce platform is **production-ready** with: - -✅ **Multi-tenant foundation** - Complete vendor isolation -✅ **Admin management** - Vendor creation and management -✅ **Marketplace integration** - CSV product imports -✅ **Product catalog** - Vendor product management -✅ **Customer shopping** - Browse, search, cart -✅ **Order processing** - Complete checkout and fulfillment - -### Next Steps for Production - -1. **Security Audit** - - Review authentication - - Test vendor isolation - - Verify payment security - - Check data encryption - -2. **Performance Optimization** - - Database indexing - - Query optimization - - Caching strategy - - CDN setup - -3. **Monitoring & Analytics** - - Set up error tracking (Sentry) - - Configure analytics - - Set up uptime monitoring - - Create admin dashboards - -4. **Documentation** - - User guides for vendors - - Admin documentation - - API documentation - - Deployment guide - -5. **Launch Preparation** - - Beta testing with real vendors - - Load testing - - Backup procedures - - Support system - ---- - -**Slice 5 Status**: 📋 Not Started -**Dependencies**: Slices 1, 2, 3, & 4 must be complete -**Estimated Duration**: 5 days -**Platform Status After Completion**: 🚀 Production Ready!# Slice 5: Order Processing -## Complete Checkout and Order Management - -**Status**: 📋 NOT STARTED -**Timeline**: Week 5 (5 days) -**Prerequisites**: Slices 1, 2, 3, & 4 complete - -## 🎯 Slice Objectives - -Complete the ecommerce transaction workflow with checkout, order placement, and order management for both customers and vendors. - -### User Stories -- As a Customer, I can proceed to checkout with my cart -- As a Customer, I can select shipping address -- As a Customer, I can place orders -- As a Customer, I can view my order history -- As a Customer, I can view order details and status -- As a Vendor Owner, I can view all customer orders -- As a Vendor Owner, I can manage orders (update status, view details) -- As a Vendor Owner, I can filter and search orders -- Order confirmation emails are sent automatically - -### Success Criteria -- [ ] Customers can complete multi-step checkout process -- [ ] Address selection/creation works -- [ ] Orders are created with proper vendor isolation -- [ ] Inventory is reserved/updated on order placement -- [ ] Customers can view their order history -- [ ] Customers can track order status -- [ ] Vendors can view all their orders -- [ ] Vendors can update order status -- [ ] Order status changes trigger notifications -- [ ] Order confirmation emails sent -- [ ] Payment integration ready (placeholder for Stripe) - -## 📋 Backend Implementation - -### Database Models - -#### Order Model (`models/database/order.py`) - -```python -class Order(Base, TimestampMixin): - """Customer orders""" - __tablename__ = "orders" - - id = Column(Integer, primary_key=True, index=True) - vendor_id = Column(Integer, ForeignKey("vendors.id"), nullable=False) - customer_id = Column(Integer, ForeignKey("customers.id"), nullable=False) - - # Order identification - order_number = Column(String, unique=True, nullable=False, index=True) - - # Order status - status = Column( - String, - nullable=False, - default="pending" - ) # pending, confirmed, processing, shipped, delivered, cancelled - - # Amounts - subtotal = Column(Numeric(10, 2), nullable=False) - shipping_cost = Column(Numeric(10, 2), default=0) - tax_amount = Column(Numeric(10, 2), default=0) - discount_amount = Column(Numeric(10, 2), default=0) - total_amount = Column(Numeric(10, 2), nullable=False) - currency = Column(String(3), default="EUR") - - # Addresses (snapshot at time of order) - shipping_address_id = Column(Integer, ForeignKey("customer_addresses.id")) - billing_address_id = Column(Integer, ForeignKey("customer_addresses.id")) - - # Shipping - shipping_method = Column(String) - tracking_number = Column(String) - shipped_at = Column(DateTime, nullable=True) - delivered_at = Column(DateTime, nullable=True) - - # Payment - payment_status = Column(String, default="pending") # pending, paid, failed, refunded - payment_method = Column(String) # stripe, paypal, etc. - payment_reference = Column(String) # External payment ID - paid_at = Column(DateTime, nullable=True) - - # Customer notes - customer_notes = Column(Text) - internal_notes = Column(Text) # Vendor-only notes - - # Metadata - ip_address = Column(String) - user_agent = Column(String) - - # Relationships - vendor = relationship("Vendor", back_populates="orders") - customer = relationship("Customer", back_populates="orders") - items = relationship("OrderItem", back_populates="order", cascade="all, delete-orphan") - shipping_address = relationship("CustomerAddress", foreign_keys=[shipping_address_id]) - billing_address = relationship("CustomerAddress", foreign_keys=[billing_address_id]) - status_history = relationship("OrderStatusHistory", back_populates="order") - - # Indexes - __table_args__ = ( - Index('ix_order_vendor_customer', 'vendor_id', 'customer_id'), - Index('ix_order_status', 'vendor_id', 'status'), - Index('ix_order_date', 'vendor_id', 'created_at'), - ) - -class OrderItem(Base, TimestampMixin): - """Items in an order""" - __tablename__ = "order_items" - - id = Column(Integer, primary_key=True, index=True) - order_id = Column(Integer, ForeignKey("orders.id"), nullable=False) - product_id = Column(Integer, ForeignKey("products.id"), nullable=False) - - # Product snapshot at time of order - product_sku = Column(String, nullable=False) - product_title = Column(String, nullable=False) - product_image = Column(String) - - # Pricing - quantity = Column(Integer, nullable=False) - unit_price = Column(Numeric(10, 2), nullable=False) - total_price = Column(Numeric(10, 2), nullable=False) - - # Tax - tax_rate = Column(Numeric(5, 2), default=0) - tax_amount = Column(Numeric(10, 2), default=0) - - # Relationships - order = relationship("Order", back_populates="items") - product = relationship("Product", back_populates="order_items") - -class OrderStatusHistory(Base, TimestampMixin): - """Track order status changes""" - __tablename__ = "order_status_history" - - id = Column(Integer, primary_key=True, index=True) - order_id = Column(Integer, ForeignKey("orders.id"), nullable=False) - - # Status change - from_status = Column(String) - to_status = Column(String, nullable=False) - - # Who made the change - changed_by_type = Column(String) # 'customer', 'vendor', 'system' - changed_by_id = Column(Integer) - - # Notes - notes = Column(Text) - - # Relationships - order = relationship("Order", back_populates="status_history") -``` - -### Pydantic Schemas - -#### Order Schemas (`models/schema/order.py`) - -```python -from pydantic import BaseModel, Field -from typing import Optional, List -from datetime import datetime -from decimal import Decimal - -class OrderItemCreate(BaseModel): - """Order item from cart""" - product_id: int - quantity: int - unit_price: Decimal - -class OrderCreate(BaseModel): - """Create new order""" - shipping_address_id: int - billing_address_id: int - shipping_method: Optional[str] = "standard" - payment_method: str = "stripe" - customer_notes: Optional[str] = None - -class OrderItemResponse(BaseModel): - """Order item details""" - id: int - product_id: int - product_sku: str - product_title: str - product_image: Optional[str] - quantity: int - unit_price: float - total_price: float - tax_amount: float - - class Config: - from_attributes = True - -class OrderResponse(BaseModel): - """Order details""" - id: int - vendor_id: int - customer_id: int - order_number: str - status: str - subtotal: float - shipping_cost: float - tax_amount: float - discount_amount: float - total_amount: float - currency: str - payment_status: str - payment_method: Optional[str] - tracking_number: Optional[str] - customer_notes: Optional[str] - created_at: datetime - updated_at: datetime - items: List[OrderItemResponse] - - class Config: - from_attributes = True - -class OrderStatusUpdate(BaseModel): - """Update order status""" - status: str = Field(..., regex="^(pending|confirmed|processing|shipped|delivered|cancelled)$") - tracking_number: Optional[str] = None - notes: Optional[str] = None - -class OrderListFilters(BaseModel): - """Filters for order list""" - status: Optional[str] = None - customer_id: Optional[int] = None - date_from: Optional[datetime] = None - date_to: Optional[datetime] = None - search: Optional[str] = None # Order number or customer name -``` - -### Service Layer - -#### Order Service (`app/services/order_service.py`) - -```python -from typing import List, Optional -from sqlalchemy.orm import Session -from datetime import datetime -from decimal import Decimal - -class OrderService: - """Handle order operations""" - - async def create_order_from_cart( - self, - vendor_id: int, - customer_id: int, - cart_id: int, - order_data: OrderCreate, - db: Session - ) -> Order: - """Create order from shopping cart""" - - # Get cart with items - cart = db.query(Cart).filter( - Cart.id == cart_id, - Cart.vendor_id == vendor_id - ).first() - - if not cart or not cart.items: - raise CartEmptyError("Cart is empty") - - # Verify addresses belong to customer - shipping_addr = db.query(CustomerAddress).filter( - CustomerAddress.id == order_data.shipping_address_id, - CustomerAddress.customer_id == customer_id - ).first() - - billing_addr = db.query(CustomerAddress).filter( - CustomerAddress.id == order_data.billing_address_id, - CustomerAddress.customer_id == customer_id - ).first() - - if not shipping_addr or not billing_addr: - raise AddressNotFoundError() - - # Calculate totals - subtotal = sum(item.line_total for item in cart.items) - shipping_cost = self._calculate_shipping(order_data.shipping_method) - tax_amount = self._calculate_tax(subtotal, shipping_addr.country) - total_amount = subtotal + shipping_cost + tax_amount - - # Generate order number - order_number = self._generate_order_number(vendor_id, db) - - # Create order - order = Order( - vendor_id=vendor_id, - customer_id=customer_id, - order_number=order_number, - status="pending", - subtotal=subtotal, - shipping_cost=shipping_cost, - tax_amount=tax_amount, - discount_amount=0, - total_amount=total_amount, - currency="EUR", - shipping_address_id=order_data.shipping_address_id, - billing_address_id=order_data.billing_address_id, - shipping_method=order_data.shipping_method, - payment_method=order_data.payment_method, - payment_status="pending", - customer_notes=order_data.customer_notes - ) - - db.add(order) - db.flush() # Get order ID - - # Create order items from cart - for cart_item in cart.items: - # Verify product still available - product = db.query(Product).get(cart_item.product_id) - if not product or not product.is_active: - continue - - # Check inventory - if product.track_inventory and product.stock_quantity < cart_item.quantity: - raise InsufficientInventoryError(f"Not enough stock for {product.title}") - - # Create order item - order_item = OrderItem( - order_id=order.id, - product_id=cart_item.product_id, - product_sku=product.sku, - product_title=product.title, - product_image=product.featured_image, - quantity=cart_item.quantity, - unit_price=cart_item.unit_price, - total_price=cart_item.line_total, - tax_rate=self._get_tax_rate(shipping_addr.country), - tax_amount=cart_item.line_total * self._get_tax_rate(shipping_addr.country) / 100 - ) - db.add(order_item) - - # Reserve inventory - if product.track_inventory: - await self._reserve_inventory(product, cart_item.quantity, order.id, db) - - # Record initial status - status_history = OrderStatusHistory( - order_id=order.id, - from_status=None, - to_status="pending", - changed_by_type="customer", - changed_by_id=customer_id, - notes="Order created" - ) - db.add(status_history) - - # Clear cart - db.query(CartItem).filter(CartItem.cart_id == cart_id).delete() - - # Update customer stats - customer = db.query(Customer).get(customer_id) - customer.total_orders += 1 - customer.total_spent += float(total_amount) - - db.commit() - db.refresh(order) - - # Send order confirmation email - await self._send_order_confirmation(order, db) - - return order - - def get_customer_orders( - self, - vendor_id: int, - customer_id: int, - db: Session, - skip: int = 0, - limit: int = 20 - ) -> List[Order]: - """Get customer's order history""" - - return db.query(Order).filter( - Order.vendor_id == vendor_id, - Order.customer_id == customer_id - ).order_by( - Order.created_at.desc() - ).offset(skip).limit(limit).all() - - def get_vendor_orders( - self, - vendor_id: int, - db: Session, - filters: OrderListFilters, - skip: int = 0, - limit: int = 50 - ) -> List[Order]: - """Get vendor's orders with filters""" - - query = db.query(Order).filter(Order.vendor_id == vendor_id) - - if filters.status: - query = query.filter(Order.status == filters.status) - - if filters.customer_id: - query = query.filter(Order.customer_id == filters.customer_id) - - if filters.date_from: - query = query.filter(Order.created_at >= filters.date_from) - - if filters.date_to: - query = query.filter(Order.created_at <= filters.date_to) - - if filters.search: - query = query.filter( - or_( - Order.order_number.ilike(f"%{filters.search}%"), - Order.customer.has( - or_( - Customer.first_name.ilike(f"%{filters.search}%"), - Customer.last_name.ilike(f"%{filters.search}%"), - Customer.email.ilike(f"%{filters.search}%") - ) - ) - ) - ) - - return query.order_by( - Order.created_at.desc() - ).offset(skip).limit(limit).all() - - async def update_order_status( - self, - vendor_id: int, - order_id: int, - status_update: OrderStatusUpdate, - changed_by_id: int, - db: Session - ) -> Order: - """Update order status""" - - order = db.query(Order).filter( - Order.id == order_id, - Order.vendor_id == vendor_id - ).first() - - if not order: - raise OrderNotFoundError() - - old_status = order.status - new_status = status_update.status - - # Update order - order.status = new_status - - if status_update.tracking_number: - order.tracking_number = status_update.tracking_number - - if new_status == "shipped": - order.shipped_at = datetime.utcnow() - elif new_status == "delivered": - order.delivered_at = datetime.utcnow() - - # Record status change - status_history = OrderStatusHistory( - order_id=order.id, - from_status=old_status, - to_status=new_status, - changed_by_type="vendor", - changed_by_id=changed_by_id, - notes=status_update.notes - ) - db.add(status_history) - - db.commit() - db.refresh(order) - - # Send notification to customer - await self._send_status_update_email(order, old_status, new_status, db) - - return order - - def _generate_order_number(self, vendor_id: int, db: Session) -> str: - """Generate unique order number""" - from models.database.vendor import Vendor - - vendor = db.query(Vendor).get(vendor_id) - today = datetime.utcnow().strftime("%Y%m%d") - - # Count orders today - today_start = datetime.utcnow().replace(hour=0, minute=0, second=0) - count = db.query(Order).filter( - Order.vendor_id == vendor_id, - Order.created_at >= today_start - ).count() - - return f"{vendor.vendor_code}-{today}-{count+1:05d}" - - def _calculate_shipping(self, method: str) -> Decimal: - """Calculate shipping cost""" - shipping_rates = { - "standard": Decimal("5.00"), - "express": Decimal("15.00"), - "free": Decimal("0.00") - } - return shipping_rates.get(method, Decimal("5.00")) - - def _calculate_tax(self, subtotal: Decimal, country: str) -> Decimal: - """Calculate tax amount""" - tax_rate = self._get_tax_rate(country) - return subtotal * tax_rate / 100 - - def _get_tax_rate(self, country: str) -> Decimal: - """Get tax rate by country""" - tax_rates = { - "LU": Decimal("17.00"), # Luxembourg VAT - "FR": Decimal("20.00"), # France VAT - "DE": Decimal("19.00"), # Germany VAT - "BE": Decimal("21.00"), # Belgium VAT - } - return tax_rates.get(country, Decimal("0.00")) - - async def _reserve_inventory( - self, - product: Product, - quantity: int, - order_id: int, - db: Session - ): - """Reserve inventory for order""" - - inventory = db.query(Inventory).filter( - Inventory.product_id == product.id - ).first() - - if inventory: - inventory.available_quantity -= quantity - inventory.reserved_quantity += quantity - - # Record movement - movement = InventoryMovement( - inventory_id=inventory.id, - movement_type="reserved", - quantity_change=-quantity, - reference_type="order", - reference_id=order_id, - notes=f"Reserved for order #{order_id}" - ) - db.add(movement) - - # Update product stock count - product.stock_quantity -= quantity - - async def _send_order_confirmation(self, order: Order, db: Session): - """Send order confirmation email to customer""" - # Implement email sending - # This will be part of notification service - pass - - async def _send_status_update_email( - self, - order: Order, - old_status: str, - new_status: str, - db: Session - ): - """Send order status update email""" - # Implement email sending - pass -``` - -### API Endpoints - -#### Customer Order Endpoints (`app/api/v1/public/vendors/orders.py`) - -```python -from fastapi import APIRouter, Depends, HTTPException -from sqlalchemy.orm import Session -from typing import List - -router = APIRouter() - -@router.post("", response_model=OrderResponse, status_code=201) -async def create_order( - vendor_id: int, - order_data: OrderCreate, - current_customer: Customer = Depends(get_current_customer), - db: Session = Depends(get_db) -): - """Place order from cart""" - - # Get customer's cart - cart = db.query(Cart).filter( - Cart.vendor_id == vendor_id, - Cart.customer_id == current_customer.id - ).first() - - if not cart: - raise HTTPException(status_code=404, detail="Cart not found") - - service = OrderService() - order = await service.create_order_from_cart( - vendor_id, - current_customer.id, - cart.id, - order_data, - db - ) - - return order - -@router.get("/my-orders", response_model=List[OrderResponse]) -async def get_my_orders( - vendor_id: int, - skip: int = 0, - limit: int = 20, - current_customer: Customer = Depends(get_current_customer), - db: Session = Depends(get_db) -): - """Get customer's order history""" - - service = OrderService() - orders = service.get_customer_orders( - vendor_id, - current_customer.id, - db, - skip, - limit - ) - - return orders - -@router.get("/my-orders/{order_id}", response_model=OrderResponse) -async def get_my_order( - vendor_id: int, - order_id: int, - current_customer: Customer = Depends(get_current_customer), - db: Session = Depends(get_db) -): - """Get specific order details""" - - order = db.query(Order).filter( - Order.id == order_id, - Order.vendor_id == vendor_id, - Order.customer_id == current_customer.id - ).first() - - if not order: - raise HTTPException(status_code=404, detail="Order not found") - - return order -``` - -#### Vendor Order Management Endpoints (`app/api/v1/vendor/orders.py`) - -```python -@router.get("", response_model=List[OrderResponse]) -async def get_vendor_orders( - status: Optional[str] = None, - customer_id: Optional[int] = None, - date_from: Optional[datetime] = None, - date_to: Optional[datetime] = None, - search: Optional[str] = None, - skip: int = 0, - limit: int = 50, - current_user: User = Depends(get_current_vendor_user), - vendor: Vendor = Depends(get_current_vendor), - db: Session = Depends(get_db) -): - """Get vendor's orders with filters""" - - filters = OrderListFilters( - status=status, - customer_id=customer_id, - date_from=date_from, - date_to=date_to, - search=search - ) - - service = OrderService() - orders = service.get_vendor_orders(vendor.id, db, filters, skip, limit) - - return orders - -@router.get("/{order_id}", response_model=OrderResponse) -async def get_order_details( - order_id: int, - current_user: User = Depends(get_current_vendor_user), - vendor: Vendor = Depends(get_current_vendor), - db: Session = Depends(get_db) -): - """Get order details""" - - order = db.query(Order).filter( - Order.id == order_id, - Order.vendor_id == vendor.id - ).first() - - if not order: - raise HTTPException(status_code=404, detail="Order not found") - - return order - -@router.put("/{order_id}/status", response_model=OrderResponse) -async def update_order_status( - order_id: int, - status_update: OrderStatusUpdate, - current_user: User = Depends(get_current_vendor_user), - vendor: Vendor = Depends(get_current_vendor), - db: Session = Depends(get_db) -): - """Update order status""" - - service = OrderService() - order = await service.update_order_status( - vendor.id, - order_id, - status_update, - current_user.id, - db - ) - - return order -``` - -## 🎨 Frontend Implementation - -### Templates - -#### Checkout Page (`templates/shop/checkout.html`) - -```html -{% extends "shop/base_shop.html" %} - -{% block content %} -
- -
-
- 1 - Cart Review -
-
- 2 - Shipping -
-
- 3 - Payment -
-
- 4 - Confirmation -
-
- - -
-

Review Your Cart

-
- -
- -
-

Subtotal: €

- -
-
- - -
-

Shipping Address

- - - - - - - -
- -
- -
- - -
-
- - -
-

Payment Method

- -
-
- - -
-
- - -
- -
- -
-

Order Summary

-

Subtotal: €

-

Shipping: €

-

Tax: €

-

Total: €

-
- -
- - -
-
- - -
-
-

✓ Order Confirmed!

-

Thank you for your order!

-

Order Number:

-

We've sent a confirmation email to your address.

- - - View Order Details - -
-
-
- - -{% endblock %} - -{% block extra_scripts %} - - - -{% endblock %} - -{% block scripts %} - -{% endblock %} -``` - -### Alpine.js Component Pattern -```javascript -function componentName() { - return { - // State - data: window.initialData || [], - loading: false, - error: null, - - // Lifecycle - init() { - this.loadData(); - }, - - // Methods - async loadData() { - this.loading = true; - try { - const response = await apiClient.get('/api/endpoint'); - this.data = response; - } catch (error) { - this.error = error.message; - } finally { - this.loading = false; - } - } - } -} -``` - -## 🔑 Key Principles - -### 1. Complete Features -Each slice delivers a complete, working feature from database to UI. - -### 2. Vendor Isolation -All slices maintain strict vendor data isolation and context detection. - -### 3. Progressive Enhancement -- HTML works without JavaScript -- Alpine.js enhances interactivity -- Jinja2 provides server-side rendering - -### 4. API-First Design -- Backend exposes RESTful APIs -- Frontend consumes APIs via Fetch -- Clear separation of concerns - -### 5. Clean Architecture -- Service layer for business logic -- Repository pattern for data access -- Exception-first error handling -- Dependency injection - -## 📖 Documentation Files - -### Slice Files (This Directory) -- `00_slices_overview.md` - This file -- `01_slice1_admin_vendor_foundation.md` -- `02_slice2_marketplace_import.md` -- `03_slice3_product_catalog.md` -- `04_slice4_customer_shopping.md` -- `05_slice5_order_processing.md` - -### Supporting Documentation -- `../quick_start_guide.md` - Get running in 15 minutes -- `../css_structure_guide.txt` - CSS organization -- `../css_quick_reference.txt` - CSS usage guide -- `../12.project_readme_final.md` - Complete project README - -## 🚀 Getting Started - -### For Current Development (Slice 1) -1. Read `01_slice1_admin_vendor_foundation.md` -2. Follow setup in `../quick_start_guide.md` -3. Complete Slice 1 testing checklist -4. Move to Slice 2 - -### For New Features -1. Review this overview -2. Read the relevant slice documentation -3. Follow the implementation pattern -4. Test thoroughly before moving forward - -## 💡 Tips for Success - -### Working with Slices -- ✅ Complete one slice fully before starting the next -- ✅ Test each slice thoroughly -- ✅ Update documentation as you go -- ✅ Commit code after each slice completion -- ✅ Demo each slice to stakeholders - -### Alpine.js Best Practices -- Keep components small and focused -- Use `x-data` for component state -- Use `x-init` for initialization -- Prefer `x-show` over `x-if` for toggles -- Use Alpine directives, not vanilla JS DOM manipulation - -### Jinja2 Best Practices -- Extend base templates -- Use template inheritance -- Pass initial data from backend -- Keep logic in backend, not templates -- Use filters for formatting - -## 🎯 Success Metrics - -### By End of Slice 1 -- Admin can create vendors ✅ -- Vendor owners can log in ⏳ -- Vendor context detection works ✅ -- Complete data isolation verified - -### By End of Slice 2 -- Vendors can import CSV files -- Import jobs tracked in background -- Product staging area functional - -### By End of Slice 3 -- Products published to catalog -- Inventory management working -- Product customization enabled - -### By End of Slice 4 -- Customers can browse products -- Shopping cart functional -- Customer accounts working - -### By End of Slice 5 -- Complete checkout workflow -- Order management operational -- Platform ready for production - ---- - -**Next Steps**: Start with `01_slice1_admin_vendor_foundation.md` to continue your current work on Slice 1.