Initial commit: Clean Mold Cost Calculator application
- Flask-based mold cost calculation application - PostgreSQL database with SQLAlchemy ORM - Docker/Podman containerization - Comprehensive documentation in docs/ - Clean, organized codebase without obsolete files - Production-ready deployment configuration - Enhanced pytest configuration with coverage reporting - Master/Development branch structure
This commit is contained in:
@@ -0,0 +1,39 @@
|
||||
# Git
|
||||
.git
|
||||
.gitignore
|
||||
|
||||
# Logs
|
||||
logs/
|
||||
|
||||
# Instance
|
||||
instance/
|
||||
|
||||
# Temporary files
|
||||
*.pyc
|
||||
__pycache__/
|
||||
*.pyo
|
||||
*.pyd
|
||||
.Python
|
||||
.DS_Store
|
||||
|
||||
# Virtual environment
|
||||
.venv/
|
||||
|
||||
# Docker
|
||||
Dockerfile
|
||||
docker-compose.yml
|
||||
mold_cost_tool.tar
|
||||
|
||||
# Documentation
|
||||
README.md
|
||||
DEPLOYMENT.md
|
||||
App_Function_Design.md
|
||||
|
||||
# Tests
|
||||
tests/
|
||||
|
||||
# Scripts
|
||||
scripts/
|
||||
|
||||
# Other
|
||||
.DS_Store
|
||||
+125
@@ -0,0 +1,125 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
env/
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
|
||||
# Flask
|
||||
instance/
|
||||
.webassets-cache
|
||||
|
||||
# Database
|
||||
*.db
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
*.db-journal
|
||||
*.sqlite-journal
|
||||
*.sqlite3-journal
|
||||
app.db
|
||||
instance/app.db
|
||||
*.bak
|
||||
*.backup
|
||||
backups/
|
||||
db_data/
|
||||
|
||||
# Virtual Environment
|
||||
venv/
|
||||
.venv/
|
||||
ENV/
|
||||
env/
|
||||
venv.bak/
|
||||
.venv.bak/
|
||||
venv/Scripts/
|
||||
venv/bin/
|
||||
venv/include/
|
||||
venv/lib/
|
||||
venv/Lib/
|
||||
venv/share/
|
||||
venv/pyvenv.cfg
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env.production
|
||||
.venv
|
||||
venv/
|
||||
ENV/
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
logs/
|
||||
|
||||
# System
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Docker
|
||||
mold-cost-calculator.tar mold_cost_tool.tar
|
||||
|
||||
# New test file and database sync log file
|
||||
*.sql
|
||||
*.zip
|
||||
DEV_BRANCH_TEST.md
|
||||
|
||||
# Logs
|
||||
!logs/deploy.log
|
||||
!logs/db_sync.log
|
||||
logs/*
|
||||
!logs/.gitkeep
|
||||
db_sync.log
|
||||
|
||||
# Coverage and Testing
|
||||
.coverage
|
||||
.coverage.*
|
||||
htmlcov/
|
||||
.pytest_cache/
|
||||
.tox/
|
||||
.cache
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
*.cover
|
||||
.hypothesis/
|
||||
|
||||
# Temporary and Proposal Files
|
||||
*.proposal
|
||||
*_proposal.*
|
||||
files-to-delete.txt
|
||||
*.tmp
|
||||
*.temp
|
||||
|
||||
# Archive Files
|
||||
*.tar
|
||||
*.tar.gz
|
||||
*.zip
|
||||
*.rar
|
||||
*.7z
|
||||
|
||||
# PowerApps (separate project)
|
||||
powerapps/
|
||||
|
||||
# SSL Scripts (temporary fix scripts)
|
||||
fix_ssl_*.sh
|
||||
quick_ssl_*.sh
|
||||
download_static_files.sh
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
FROM python:3.11-slim AS builder
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Install build dependencies
|
||||
RUN apt-get update && apt-get install -y \
|
||||
build-essential \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy requirements file
|
||||
COPY requirements.txt .
|
||||
|
||||
# Install dependencies
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Final stage
|
||||
FROM python:3.11-slim
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Install runtime dependencies
|
||||
RUN apt-get update && apt-get install -y \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy site-packages and binaries from builder
|
||||
COPY --from=builder /usr/local/lib/python3.11/site-packages/ /usr/local/lib/python3.11/site-packages/
|
||||
COPY --from=builder /usr/local/bin/ /usr/local/bin/
|
||||
|
||||
# Create necessary directories
|
||||
RUN mkdir -p /app/logs /app/instance && \
|
||||
chmod -R 777 /app/logs /app/instance
|
||||
|
||||
# Copy application files
|
||||
COPY . .
|
||||
|
||||
# Set environment variables
|
||||
ENV FLASK_APP=run.py
|
||||
ENV FLASK_ENV=production
|
||||
|
||||
# Expose port
|
||||
EXPOSE 5002
|
||||
|
||||
# Run gunicorn
|
||||
CMD ["gunicorn", "--bind", "0.0.0.0:5002", "--workers", "4", "app:create_app()"]
|
||||
@@ -0,0 +1,205 @@
|
||||
# Mold Cost Calculator
|
||||
|
||||
A Flask-based web application for calculating mold costs and managing quotations.
|
||||
|
||||
## Features
|
||||
|
||||
- **Cost Calculation**
|
||||
- Mold type-based calculations
|
||||
- Complexity factors (sidewall, slider, cavity)
|
||||
- Texture options (standard, custom, laser)
|
||||
- Tax rate support for different countries
|
||||
|
||||
- **User Management**
|
||||
- Secure authentication system
|
||||
- Role-based access control
|
||||
- Admin dashboard for user management
|
||||
- Email-based registration with allowlist
|
||||
|
||||
- **Quotation System**
|
||||
- Create and manage quotations
|
||||
- Export functionality
|
||||
- Historical record keeping
|
||||
- Admin review and management
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Backend**: Flask, SQLAlchemy
|
||||
- **Frontend**: HTML, CSS, JavaScript
|
||||
- **Database**: PostgreSQL (production/development), SQLite in-memory (testing)
|
||||
- **Server**: Gunicorn
|
||||
- **Proxy**: Nginx
|
||||
- **Authentication**: Flask-Login
|
||||
- **Forms**: Flask-WTF
|
||||
- **Database Migrations**: Flask-Migrate
|
||||
- **Containerization**: Podman/Docker
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
mold_cost_online_tool/
|
||||
├── app/ # Application package
|
||||
│ ├── forms/ # Form definitions
|
||||
│ ├── models/ # Database models
|
||||
│ ├── static/ # Static files (CSS, JS)
|
||||
│ └── templates/ # HTML templates
|
||||
├── instance/ # Instance-specific files
|
||||
├── logs/ # Application logs
|
||||
├── migrations/ # Database migrations
|
||||
├── tests/ # Test files
|
||||
├── deploy/ # Deployment configuration
|
||||
├── db_data/ # Database data (development)
|
||||
├── app.py # Main application file
|
||||
├── config.py # Configuration
|
||||
├── run.py # Application entry point
|
||||
├── requirements.txt # Python dependencies
|
||||
├── gunicorn_config.py # Gunicorn configuration
|
||||
├── podman-compose.yml # Production container setup
|
||||
└── podman-compose.development.yml # Development container setup
|
||||
```
|
||||
|
||||
## Setup and Installation
|
||||
|
||||
### Option 1: Using Containers (Recommended)
|
||||
|
||||
1. **Clone the repository**
|
||||
```bash
|
||||
git clone <repository-url>
|
||||
cd mold_cost_online_tool
|
||||
```
|
||||
|
||||
2. **Set up environment variables**
|
||||
Create `.env.development` file for development:
|
||||
```bash
|
||||
POSTGRES_USER=postgres
|
||||
POSTGRES_PASSWORD=postgres
|
||||
POSTGRES_DB=mold_cost
|
||||
DATABASE_HOST=db_development
|
||||
SECRET_KEY=your-secret-key
|
||||
MAIL_SERVER=smtp.example.com
|
||||
MAIL_USERNAME=your-email@example.com
|
||||
MAIL_PASSWORD=your-password
|
||||
MAIL_DEFAULT_SENDER=your-email@example.com
|
||||
ADMIN_EMAIL=admin@example.com
|
||||
ADMIN_PASSWORD=admin-password
|
||||
```
|
||||
|
||||
3. **Start the development environment**
|
||||
```bash
|
||||
podman-compose -f podman-compose.development.yml up --build
|
||||
```
|
||||
|
||||
4. **Initialize the database**
|
||||
```bash
|
||||
flask db upgrade
|
||||
```
|
||||
|
||||
### Option 2: Local Development
|
||||
|
||||
1. **Clone the repository**
|
||||
```bash
|
||||
git clone <repository-url>
|
||||
cd mold_cost_online_tool
|
||||
```
|
||||
|
||||
2. **Create and activate virtual environment**
|
||||
```bash
|
||||
python -m venv .venv
|
||||
source .venv/bin/activate # On Windows: .venv\Scripts\activate
|
||||
```
|
||||
|
||||
3. **Install dependencies**
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
4. **Set up PostgreSQL database**
|
||||
- Install PostgreSQL
|
||||
- Create database and user
|
||||
- Set environment variables for database connection
|
||||
|
||||
5. **Set up environment variables**
|
||||
```bash
|
||||
export FLASK_APP=run.py
|
||||
export FLASK_ENV=development
|
||||
export SECRET_KEY=your-secret-key
|
||||
export DATABASE_URL=postgresql://user:password@localhost/dbname
|
||||
```
|
||||
|
||||
6. **Initialize the database**
|
||||
```bash
|
||||
flask db upgrade
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
1. **Run development server**
|
||||
```bash
|
||||
python run.py
|
||||
```
|
||||
|
||||
2. **Run tests**
|
||||
```bash
|
||||
python -m pytest tests/
|
||||
```
|
||||
|
||||
## Deployment
|
||||
|
||||
1. **Production server setup**
|
||||
- Configure Nginx as reverse proxy
|
||||
- Set up Gunicorn with provided configuration
|
||||
- Configure SSL certificates
|
||||
- Set up PostgreSQL database
|
||||
|
||||
2. **Environment variables for production**
|
||||
```bash
|
||||
FLASK_APP=run.py
|
||||
FLASK_ENV=production
|
||||
SECRET_KEY=your-secure-secret-key
|
||||
DATABASE_URL=postgresql://user:password@host/dbname
|
||||
```
|
||||
|
||||
3. **Start the application**
|
||||
```bash
|
||||
gunicorn -c gunicorn_config.py run:app
|
||||
```
|
||||
|
||||
## Database Management
|
||||
|
||||
- **Backups**: Automated daily backups using `pg_dump`
|
||||
- **Migrations**: Use Flask-Migrate for database schema changes
|
||||
- **Development**: Uses PostgreSQL container with persistent data
|
||||
- **Testing**: Uses SQLite in-memory database for fast, isolated tests
|
||||
|
||||
## Security Features
|
||||
|
||||
- CSRF protection
|
||||
- Secure password hashing
|
||||
- Session management
|
||||
- Input validation
|
||||
- Security headers
|
||||
- Rate limiting
|
||||
- SQL injection prevention
|
||||
|
||||
## Maintenance
|
||||
|
||||
- Regular database backups
|
||||
- Log rotation
|
||||
- Security updates
|
||||
- Performance monitoring
|
||||
|
||||
## Contributing
|
||||
|
||||
1. Fork the repository
|
||||
2. Create a feature branch
|
||||
3. Commit your changes
|
||||
4. Push to the branch
|
||||
5. Create a Pull Request
|
||||
|
||||
## License
|
||||
|
||||
[Add your license information here]
|
||||
|
||||
## Support
|
||||
|
||||
[Add support contact information here]
|
||||
+180
@@ -0,0 +1,180 @@
|
||||
# 🏭 Mold Cost Calculator - User Guide
|
||||
|
||||
Welcome to the Mold Cost Calculator! This guide will help you quickly and easily estimate the cost of manufacturing molds for your footwear projects.
|
||||
|
||||
---
|
||||
|
||||
## 📋 Quick Start Guide
|
||||
|
||||
### 🚀 First Time Here?
|
||||
1. **Register** → Create your account
|
||||
2. **Login** → Access the calculator
|
||||
3. **Fill Form** → Enter your project details
|
||||
4. **Calculate** → Get instant cost estimates
|
||||
|
||||
### 🔄 Returning User?
|
||||
Simply login and start calculating!
|
||||
|
||||
---
|
||||
|
||||
## 🔐 Getting Started
|
||||
|
||||
### Creating Your Account
|
||||
**Step 1:** Visit the Mold Cost Calculator website
|
||||
**Step 2:** Click the "Register" button
|
||||
**Step 3:** Fill in your details:
|
||||
- **Username** (choose something memorable)
|
||||
- **Email** (use your work email)
|
||||
- **Company** (your organization name)
|
||||
- **Password** (make it secure!)
|
||||
|
||||
**Step 4:** Click "Register" and you're ready to go!
|
||||
|
||||
### Logging In
|
||||
- Enter your **email** and **password**
|
||||
- Click "Login"
|
||||
- You'll be taken to your dashboard
|
||||
|
||||
> 💡 **Forgot your password?** Contact your system administrator for help.
|
||||
|
||||
---
|
||||
|
||||
## 🧮 Using the Calculator
|
||||
|
||||
The calculator is organized into 4 main sections. Fill them out in order for the best experience!
|
||||
|
||||
### 📊 Section 1: Factory Information
|
||||
|
||||
Tell us about the factories involved in your project:
|
||||
|
||||
#### Mold Shop (Where the mold is made)
|
||||
- **Name**: Enter the mold manufacturing facility name
|
||||
- **Country**: Choose from China, Vietnam, or Indonesia
|
||||
|
||||
#### T1 Shoe Factory (Primary shoe manufacturer)
|
||||
- **Name**: Enter the main shoe factory name
|
||||
- **Country**: Select the factory location
|
||||
|
||||
#### T2 Component Factory (Component supplier)
|
||||
- **Name**: Enter the component factory name
|
||||
- **Country**: Select the component factory location
|
||||
|
||||
### 📋 Section 2: Project Details
|
||||
|
||||
Basic information about your mold project:
|
||||
|
||||
- **Model Name**: The shoe model name or code
|
||||
- **Tooling ID**: A 5-character identification code (must be exactly 5 digits, e.g., "12345")
|
||||
- **Season**: Project season (e.g., "SS25", "FW24")
|
||||
- **Stage**: Current project phase:
|
||||
- **CR0, CR1, CR2** = Concept Review stages
|
||||
- **SMS** = Sample Manufacturing Stage
|
||||
- **Commercialization** = Commercial production
|
||||
- **Production** = Full production
|
||||
- **Type**: Automatically set based on your stage selection
|
||||
|
||||
### ⚙️ Section 3: Mold Specifications
|
||||
|
||||
This is where you define your mold's technical details:
|
||||
|
||||
#### Mold Type Selection
|
||||
**Main Type** → **Sub Type** → **Specific Type** (if applicable)
|
||||
|
||||
Choose your mold type from these options:
|
||||
|
||||
| Main Type | Sub Types Available |
|
||||
|-----------|-------------------|
|
||||
| **Rubber** | Flat Outsole, Cupsole, Rubber Sole |
|
||||
| **CMEVA** | 2 Plates Mold, 3 Plates Mold, 2 Plates with in-mold channel, Breathable + in-mold channel |
|
||||
| **IMEVA** | 2 Plates Mold, 3 Plates Mold |
|
||||
| **Sandal** | Sandal with Upper Bandage, 2 Plate without Upper Bandage |
|
||||
| **Co-shot mold** | Co-shot mold, Foaming mold |
|
||||
| **PU** | 2 Plates Mold, 3 Plates Mold |
|
||||
|
||||
#### Technical Details
|
||||
- **Number of Sliders**: 0-4 sliders
|
||||
- **Number of Cavities**: 0.5 to 3 pairs
|
||||
- **Digital Texture**:
|
||||
- adidas digital texture
|
||||
- customized texture
|
||||
- laser texture
|
||||
- **High Sidewall >60mm**: Yes/No
|
||||
|
||||
**Specific Types Available:**
|
||||
- **Flat Outsole**: Flat, Flat with Top/Heel Tip
|
||||
- **Cupsole**: Top PL, Visible PL
|
||||
- **Rubber Sole**: Top PL, Short PL (Heel/Forefoot), Visible PL, Wave PL
|
||||
|
||||
### 📅 Section 4: Dates & Approvals
|
||||
|
||||
Final details for your quotation:
|
||||
|
||||
- **Issue Date**: When this quote is issued
|
||||
- **Mold Shop Approver**: Name of mold shop representative
|
||||
- **T1 Tooling Approver**: Name of T1 tooling representative
|
||||
- **LO Tooling Approver**: Name of LO tooling representative
|
||||
|
||||
### 🎯 Calculate Your Cost
|
||||
|
||||
Click the big blue **"Calculate"** button to get your instant cost estimate!
|
||||
|
||||
---
|
||||
|
||||
## 💰 Understanding Your Results
|
||||
|
||||
After calculation, you'll see a detailed breakdown:
|
||||
|
||||
### Cost Summary
|
||||
- **Net Base Price**: Base mold cost (before taxes)
|
||||
- **Tax Rate**: Applicable tax percentage
|
||||
- **Total Cost**: Final cost including taxes
|
||||
|
||||
### Project Summary
|
||||
A complete overview of all your entered information for easy reference.
|
||||
|
||||
### 🔧 Technical Issues
|
||||
|
||||
**Q: The dropdowns are greyed out!**
|
||||
A: Make sure you've selected the previous field first. The dropdowns work in sequence.
|
||||
|
||||
**Q: The page isn't loading properly**
|
||||
A: Try refreshing your browser or clearing your cache (Ctrl+F5 or Cmd+Shift+R).
|
||||
|
||||
**Q: I'm getting an error message**
|
||||
A: Check that all required fields are filled out. Required fields are marked with asterisks (*).
|
||||
|
||||
**Q: The Tooling ID field won't accept letters**
|
||||
A: The Tooling ID must be exactly 5 digits (numbers only). The system automatically prevents non-numeric input.
|
||||
|
||||
---
|
||||
|
||||
## 🆘 Need Help?
|
||||
|
||||
### Technical Problems
|
||||
- **Browser issues**: Try refreshing or clearing cache
|
||||
- **Login problems**: Contact your system administrator
|
||||
- **Calculation errors**: Check that all required fields are completed
|
||||
|
||||
### Questions About Your Project
|
||||
- **Mold specifications**: Ask your technical team
|
||||
- **Cost factors**: Consult with your mold shop
|
||||
- **Project requirements**: Speak with your design team
|
||||
|
||||
### Account Issues
|
||||
- **Password reset**: Contact your system administrator
|
||||
- **Access problems**: Contact your system administrator
|
||||
- **Registration issues**: Contact your system administrator
|
||||
|
||||
---
|
||||
|
||||
## 💡 Pro Tips
|
||||
|
||||
1. **Be Accurate**: Enter precise information for the most accurate cost estimates
|
||||
2. **Consult Experts**: When in doubt about technical specifications, consult with your technical team
|
||||
3. **Keep Updated**: Ensure you're using the latest specifications and requirements
|
||||
4. **Save Time**: Bookmark the calculator for quick access
|
||||
5. **Double-Check**: Review your entries before calculating
|
||||
|
||||
---
|
||||
|
||||
*Need additional help? Contact your system administrator for support.*
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
from flask import Flask
|
||||
import os
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from sqlalchemy.exc import SQLAlchemyError, OperationalError
|
||||
from sqlalchemy import inspect
|
||||
from time import sleep
|
||||
from .config import get_config
|
||||
from .logging_config import configure_logging
|
||||
from dotenv import load_dotenv
|
||||
from .extensions import db, login_manager, migrate, csrf, limiter, cache, mail
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
from flask_login import LoginManager
|
||||
from flask_migrate import Migrate
|
||||
from flask_limiter import Limiter
|
||||
from flask_limiter.util import get_remote_address
|
||||
from flask_caching import Cache
|
||||
|
||||
# Load environment variables first
|
||||
load_dotenv()
|
||||
|
||||
db = SQLAlchemy()
|
||||
migrate = Migrate()
|
||||
limiter = Limiter(key_func=get_remote_address)
|
||||
cache = Cache()
|
||||
|
||||
def create_app(config_class=None):
|
||||
app = Flask(__name__, static_folder='static')
|
||||
|
||||
@app.context_processor
|
||||
def inject_now():
|
||||
return {'now': datetime.utcnow()}
|
||||
|
||||
# Load configuration
|
||||
if config_class is None:
|
||||
config_class = get_config()
|
||||
app.config.from_object(config_class)
|
||||
|
||||
# Initialize configuration
|
||||
config_class.init_app(app)
|
||||
|
||||
# Ensure secret key is properly set
|
||||
if not app.config.get('SECRET_KEY'):
|
||||
raise ValueError("SECRET_KEY must be set in the configuration")
|
||||
app.config['SECRET_KEY'] = app.config['SECRET_KEY'].encode('utf-8')
|
||||
|
||||
# Ensure instance folder exists
|
||||
try:
|
||||
os.makedirs(app.instance_path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
# Configure logging
|
||||
configure_logging(app)
|
||||
|
||||
# Initialize extensions with retry logic
|
||||
max_retries = 3
|
||||
retry_delay = 5
|
||||
|
||||
# Initialize extensions only if they haven't been initialized
|
||||
if not hasattr(app, 'extensions') or 'sqlalchemy' not in app.extensions:
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
# Initialize SQLAlchemy only if not already initialized
|
||||
if 'sqlalchemy' not in app.extensions:
|
||||
db.init_app(app)
|
||||
migrate.init_app(app, db)
|
||||
|
||||
# Initialize other extensions
|
||||
if 'csrf' not in app.extensions:
|
||||
csrf.init_app(app)
|
||||
if 'limiter' not in app.extensions:
|
||||
limiter.init_app(app)
|
||||
if 'cache' not in app.extensions:
|
||||
cache.init_app(app)
|
||||
if 'mail' not in app.extensions:
|
||||
mail.init_app(app)
|
||||
if 'login_manager' not in app.extensions:
|
||||
login_manager.init_app(app)
|
||||
|
||||
# Test database connection
|
||||
with app.app_context():
|
||||
db.engine.connect()
|
||||
break
|
||||
except OperationalError as e:
|
||||
if attempt == max_retries - 1:
|
||||
app.logger.error(f"Failed to connect to database after {max_retries} attempts: {str(e)}")
|
||||
raise
|
||||
app.logger.warning(f"Database connection attempt {attempt + 1} failed: {str(e)}")
|
||||
sleep(retry_delay)
|
||||
|
||||
# Configure login manager
|
||||
login_manager.login_view = 'auth.login'
|
||||
login_manager.login_message = 'Please log in to access this page.'
|
||||
login_manager.login_message_category = 'warning'
|
||||
|
||||
@login_manager.user_loader
|
||||
def load_user(user_id):
|
||||
from .models import User
|
||||
return User.query.get(int(user_id))
|
||||
|
||||
# Register blueprints
|
||||
from .routes import main, auth, admin
|
||||
app.register_blueprint(main.bp)
|
||||
app.register_blueprint(auth.bp)
|
||||
app.register_blueprint(admin.bp)
|
||||
|
||||
# Register error handlers
|
||||
from .errors import register_error_handlers
|
||||
register_error_handlers(app)
|
||||
|
||||
# Debug: log all registered endpoints at startup (warning level and flush)
|
||||
app.logger.warning("Registered endpoints (debug):")
|
||||
for rule in app.url_map.iter_rules():
|
||||
app.logger.warning(f"{rule.endpoint} {rule.methods} {rule.rule}")
|
||||
for handler in app.logger.handlers:
|
||||
handler.flush()
|
||||
|
||||
# Debug: write all registered endpoints to a file
|
||||
with open('logs/endpoints.log', 'w') as f:
|
||||
for rule in app.url_map.iter_rules():
|
||||
f.write(f"{rule.endpoint} {rule.methods} {rule.rule}\n")
|
||||
|
||||
return app
|
||||
+227
@@ -0,0 +1,227 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
from logging.handlers import RotatingFileHandler
|
||||
|
||||
# Base directories
|
||||
BASE_DIR = Path(__file__).parent.parent
|
||||
INSTANCE_DIR = BASE_DIR / 'instance'
|
||||
|
||||
# Default values
|
||||
DEFAULT_NET_BASE = 12.5
|
||||
|
||||
# Allowed emails for registration
|
||||
ALLOWED_EMAILS = {
|
||||
'jenny.tay@adidas.com',
|
||||
'gema.yostisiano@adidas.com',
|
||||
'dirgasapto.nugratama@adidas.com',
|
||||
'test@example.com', # Original test email
|
||||
'test1@example.com',
|
||||
'test2@example.com',
|
||||
'test3@example.com',
|
||||
'test4@example.com',
|
||||
'test5@example.com',
|
||||
'test6@example.com',
|
||||
'test7@example.com',
|
||||
'test8@example.com',
|
||||
'test9@example.com',
|
||||
'test10@example.com'
|
||||
}
|
||||
|
||||
# Tax rates by country
|
||||
TAX_RATES = {
|
||||
'China': 0.06,
|
||||
'Vietnam': 0.10,
|
||||
'Indonesia': 0.07
|
||||
}
|
||||
|
||||
# Cost factors for calculations
|
||||
COST_FACTORS = {
|
||||
'mold': {
|
||||
'Flat & Thin': 1.0,
|
||||
'Flat & Thick': 1.25,
|
||||
'Flat with Top/Heat Tip': 1.35,
|
||||
'Top PL': 1.05,
|
||||
'Short PL (Heat/Forefoot)': 1.18,
|
||||
'Visible PL': 1.28,
|
||||
'Wave PL': 1.32,
|
||||
'2 Plates Mold': 1.0,
|
||||
'3 Plates Mold': 1.35,
|
||||
'Breathable + In-mold channel': 1.45,
|
||||
'With Upper Bandage': 1.15,
|
||||
'2 plate without Bandage': 1.05,
|
||||
'Co-shot mold': 1.25,
|
||||
'Foaming mold': 1.4
|
||||
},
|
||||
'complexity': {
|
||||
'high_sidewall': 0.18,
|
||||
'slider': 0.1,
|
||||
'cavity': 0.12
|
||||
},
|
||||
'texture': {
|
||||
'standard': 0.0,
|
||||
'custom': 0.15,
|
||||
'laser': 0.2
|
||||
}
|
||||
}
|
||||
|
||||
class Config:
|
||||
"""Base configuration class."""
|
||||
|
||||
# Flask configuration
|
||||
SECRET_KEY = os.getenv('SECRET_KEY') # Get SECRET_KEY directly from environment
|
||||
FLASK_APP = 'app'
|
||||
FLASK_ENV = 'development'
|
||||
FLASK_DEBUG = True
|
||||
|
||||
# Security configuration
|
||||
SESSION_COOKIE_SECURE = True
|
||||
SESSION_COOKIE_HTTPONLY = True
|
||||
SESSION_COOKIE_SAMESITE = 'Lax'
|
||||
PERMANENT_SESSION_LIFETIME = timedelta(days=7)
|
||||
REMEMBER_COOKIE_DURATION = timedelta(days=30)
|
||||
REMEMBER_COOKIE_SECURE = True
|
||||
REMEMBER_COOKIE_HTTPONLY = True
|
||||
REMEMBER_COOKIE_SAMESITE = 'Lax'
|
||||
|
||||
# Database configuration
|
||||
SQLALCHEMY_DATABASE_URI = None # Will be set from environment variable
|
||||
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
||||
SQLALCHEMY_ENGINE_OPTIONS = {
|
||||
'pool_size': 10,
|
||||
'pool_recycle': 3600,
|
||||
'pool_pre_ping': True
|
||||
}
|
||||
|
||||
# Email configuration
|
||||
MAIL_SERVER = None # Will be set from environment variable
|
||||
MAIL_PORT = 587
|
||||
MAIL_USE_TLS = True
|
||||
MAIL_USERNAME = None # Will be set from environment variable
|
||||
MAIL_PASSWORD = None # Will be set from environment variable
|
||||
MAIL_DEFAULT_SENDER = None # Will be set from environment variable
|
||||
|
||||
# Admin account configuration
|
||||
ADMIN_EMAIL = None # Will be set from environment variable
|
||||
ADMIN_PASSWORD = None # Will be set from environment variable
|
||||
|
||||
# Logging configuration
|
||||
LOG_LEVEL = 'INFO'
|
||||
LOG_TO_STDOUT = True
|
||||
LOG_FORMAT = '%(asctime)s %(levelname)s: %(message)s [in %(pathname)s:%(lineno)d]'
|
||||
LOG_DATE_FORMAT = '%Y-%m-%d %H:%M:%S'
|
||||
|
||||
# Application config
|
||||
ALLOWED_EMAILS = ALLOWED_EMAILS
|
||||
TAX_RATES = TAX_RATES
|
||||
DEFAULT_NET_BASE = DEFAULT_NET_BASE
|
||||
WTF_CSRF_ENABLED = True
|
||||
|
||||
# Rate Limiting Configuration
|
||||
RATELIMIT_DEFAULT = "200 per day;50 per hour;10 per minute"
|
||||
RATELIMIT_STORAGE_URL = "memory://"
|
||||
RATELIMIT_STRATEGY = "fixed-window"
|
||||
RATELIMIT_HEADERS_ENABLED = True
|
||||
RATELIMIT_HEADERS_RESET = True
|
||||
RATELIMIT_HEADERS_RETRY_AFTER = True
|
||||
|
||||
# Cache Configuration
|
||||
CACHE_TYPE = "SimpleCache"
|
||||
CACHE_DEFAULT_TIMEOUT = 300
|
||||
CACHE_THRESHOLD = 1000
|
||||
|
||||
@classmethod
|
||||
def init_app(cls, app):
|
||||
"""Initialize application with configuration."""
|
||||
# Load configuration from environment variables
|
||||
db_user = os.getenv('POSTGRES_USER')
|
||||
db_password = os.getenv('POSTGRES_PASSWORD')
|
||||
db_host = os.getenv('DATABASE_HOST')
|
||||
db_name = os.getenv('POSTGRES_DB')
|
||||
|
||||
if db_user and db_password and db_host and db_name:
|
||||
cls.SQLALCHEMY_DATABASE_URI = f"postgresql://{db_user}:{db_password}@{db_host}/{db_name}"
|
||||
else:
|
||||
cls.SQLALCHEMY_DATABASE_URI = os.getenv('DATABASE_URL') # Fallback for other setups
|
||||
|
||||
cls.MAIL_SERVER = os.getenv('MAIL_SERVER')
|
||||
cls.MAIL_USERNAME = os.getenv('MAIL_USERNAME')
|
||||
cls.MAIL_PASSWORD = os.getenv('MAIL_PASSWORD')
|
||||
cls.MAIL_DEFAULT_SENDER = os.getenv('MAIL_DEFAULT_SENDER')
|
||||
cls.ADMIN_EMAIL = os.getenv('ADMIN_EMAIL')
|
||||
cls.ADMIN_PASSWORD = os.getenv('ADMIN_PASSWORD')
|
||||
cls.LOG_LEVEL = os.getenv('LOG_LEVEL', 'INFO')
|
||||
cls.LOG_TO_STDOUT = os.getenv('LOG_TO_STDOUT', 'True').lower() == 'true'
|
||||
|
||||
# Ensure SECRET_KEY is set
|
||||
if not cls.SECRET_KEY:
|
||||
raise ValueError("SECRET_KEY environment variable is not set")
|
||||
|
||||
# Set SQLALCHEMY_DATABASE_URI on the app config
|
||||
if not cls.SQLALCHEMY_DATABASE_URI:
|
||||
raise ValueError("Database connection URI is not set. Please check DATABASE_URL or its components.")
|
||||
app.config['SQLALCHEMY_DATABASE_URI'] = cls.SQLALCHEMY_DATABASE_URI
|
||||
|
||||
# Configure logging
|
||||
if not os.path.exists('logs'):
|
||||
os.mkdir('logs')
|
||||
file_handler = RotatingFileHandler('logs/app.log',
|
||||
maxBytes=10240,
|
||||
backupCount=10)
|
||||
file_handler.setFormatter(logging.Formatter(cls.LOG_FORMAT))
|
||||
file_handler.setLevel(logging.INFO)
|
||||
app.logger.addHandler(file_handler)
|
||||
app.logger.setLevel(logging.INFO)
|
||||
app.logger.info('Application startup')
|
||||
|
||||
class DevelopmentConfig(Config):
|
||||
DEBUG = True
|
||||
TESTING = False
|
||||
REMEMBER_COOKIE_SECURE = False
|
||||
SESSION_COOKIE_SECURE = False
|
||||
SQLALCHEMY_ECHO = True
|
||||
LOG_LEVEL = 'DEBUG'
|
||||
WTF_CSRF_ENABLED = True
|
||||
|
||||
# Development-specific settings
|
||||
RATELIMIT_DEFAULT = "1000 per day;100 per hour;20 per minute"
|
||||
CACHE_TYPE = "SimpleCache"
|
||||
|
||||
class ProductionConfig(Config):
|
||||
DEBUG = False
|
||||
TESTING = False
|
||||
REMEMBER_COOKIE_SECURE = True
|
||||
SESSION_COOKIE_SECURE = True
|
||||
SQLALCHEMY_ECHO = False
|
||||
LOG_LEVEL = 'INFO'
|
||||
WTF_CSRF_ENABLED = True
|
||||
|
||||
# Production-specific settings
|
||||
CACHE_TYPE = "SimpleCache"
|
||||
CACHE_DEFAULT_TIMEOUT = 300
|
||||
|
||||
class TestingConfig(Config):
|
||||
TESTING = True
|
||||
DEBUG = True
|
||||
SQLALCHEMY_DATABASE_URI = 'sqlite:///:memory:'
|
||||
WTF_CSRF_ENABLED = False
|
||||
LOG_LEVEL = 'DEBUG'
|
||||
|
||||
# Testing-specific settings
|
||||
SECRET_KEY = 'testing-secret-key-not-used-in-production'
|
||||
MAIL_SUPPRESS_SEND = True
|
||||
RATELIMIT_ENABLED = False
|
||||
RATELIMIT_DEFAULT = "1000 per day;100 per hour;20 per minute"
|
||||
CACHE_TYPE = "SimpleCache"
|
||||
SQLALCHEMY_ENGINE_OPTIONS = {}
|
||||
|
||||
def get_config():
|
||||
"""Get the appropriate configuration class based on environment."""
|
||||
config_name = os.getenv('FLASK_CONFIG', 'development')
|
||||
config_map = {
|
||||
'development': DevelopmentConfig,
|
||||
'production': ProductionConfig,
|
||||
'testing': TestingConfig
|
||||
}
|
||||
return config_map.get(config_name, DevelopmentConfig)
|
||||
@@ -0,0 +1,86 @@
|
||||
from flask import render_template, jsonify, request, send_from_directory, current_app
|
||||
import logging
|
||||
import os
|
||||
from sqlalchemy.exc import SQLAlchemyError, OperationalError, IntegrityError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def register_error_handlers(app):
|
||||
@app.errorhandler(400)
|
||||
def handle_400_error(e):
|
||||
logger.error(f'400 error: {str(e)}')
|
||||
if request.path.startswith('/calculate'):
|
||||
return jsonify({'error': 'Invalid request parameters'}), 400
|
||||
return render_template('errors/400.html'), 400
|
||||
|
||||
@app.errorhandler(403)
|
||||
def handle_403_error(e):
|
||||
logger.error(f'403 error: {str(e)}')
|
||||
if request.path.startswith('/calculate'):
|
||||
return jsonify({'error': 'Access forbidden'}), 403
|
||||
return render_template('errors/403.html'), 403
|
||||
|
||||
@app.errorhandler(404)
|
||||
def handle_404_error(e):
|
||||
logger.error(f'404 error: {str(e)}')
|
||||
if request.path.startswith('/calculate'):
|
||||
return jsonify({'error': 'Resource not found'}), 404
|
||||
if request.path.startswith('/static/'):
|
||||
app.logger.warning(f'Static file not found: {request.path}')
|
||||
return '', 404
|
||||
return render_template('errors/404.html'), 404
|
||||
|
||||
@app.errorhandler(500)
|
||||
def handle_500_error(e):
|
||||
logger.error(f'500 error: {str(e)}')
|
||||
app.logger.error(f'Server error: {str(e)}')
|
||||
if request.path.startswith('/calculate'):
|
||||
return jsonify({'error': 'Internal server error'}), 500
|
||||
if request.path.startswith('/static/'):
|
||||
app.logger.error(f'Error serving static file {request.path}: {str(e)}')
|
||||
return '', 404
|
||||
return render_template('errors/500.html'), 500
|
||||
|
||||
@app.errorhandler(OperationalError)
|
||||
def handle_db_operational_error(error):
|
||||
logger.error(f'Database operational error: {str(error)}', exc_info=True)
|
||||
if request.path.startswith('/api/'):
|
||||
return jsonify({
|
||||
'error': 'Database connection error',
|
||||
'message': 'Unable to connect to the database. Please try again later.'
|
||||
}), 503
|
||||
return render_template('errors/503.html',
|
||||
message='Database connection error. Please try again later.'), 503
|
||||
|
||||
@app.errorhandler(IntegrityError)
|
||||
def handle_db_integrity_error(error):
|
||||
logger.error(f'Database integrity error: {str(error)}', exc_info=True)
|
||||
if request.path.startswith('/api/'):
|
||||
return jsonify({
|
||||
'error': 'Database integrity error',
|
||||
'message': 'The operation could not be completed due to data constraints.'
|
||||
}), 409
|
||||
return render_template('errors/409.html',
|
||||
message='The operation could not be completed due to data constraints.'), 409
|
||||
|
||||
@app.errorhandler(SQLAlchemyError)
|
||||
def handle_db_error(error):
|
||||
logger.error(f'Database error: {str(error)}', exc_info=True)
|
||||
if request.path.startswith('/api/'):
|
||||
return jsonify({
|
||||
'error': 'Database error',
|
||||
'message': 'An unexpected database error occurred.'
|
||||
}), 500
|
||||
return render_template('errors/500.html',
|
||||
message='An unexpected database error occurred.'), 500
|
||||
|
||||
@app.errorhandler(Exception)
|
||||
def handle_unhandled_exception(e):
|
||||
logger.error(f'Unhandled exception: {str(e)}', exc_info=True)
|
||||
app.logger.error(f'Unhandled exception: {str(e)}', exc_info=True)
|
||||
if request.path.startswith('/calculate'):
|
||||
return jsonify({'error': 'Internal server error'}), 500
|
||||
if request.path.startswith('/static/'):
|
||||
app.logger.error(f'Error serving static file {request.path}: {str(e)}')
|
||||
return '', 404
|
||||
return render_template('errors/500.html'), 500
|
||||
@@ -0,0 +1,16 @@
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
from flask_login import LoginManager
|
||||
from flask_migrate import Migrate
|
||||
from flask_wtf.csrf import CSRFProtect
|
||||
from flask_limiter import Limiter
|
||||
from flask_limiter.util import get_remote_address
|
||||
from flask_caching import Cache
|
||||
from flask_mail import Mail
|
||||
|
||||
db = SQLAlchemy()
|
||||
login_manager = LoginManager()
|
||||
migrate = Migrate()
|
||||
csrf = CSRFProtect()
|
||||
limiter = Limiter(key_func=get_remote_address)
|
||||
cache = Cache()
|
||||
mail = Mail()
|
||||
@@ -0,0 +1,19 @@
|
||||
from .auth import LoginForm, RegistrationForm
|
||||
from .quotation import QuotationForm
|
||||
from flask_wtf import FlaskForm
|
||||
from wtforms import StringField, PasswordField, SubmitField
|
||||
from wtforms.validators import DataRequired, Email, EqualTo
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
__all__ = ['LoginForm', 'RegistrationForm', 'QuotationForm']
|
||||
|
||||
class RequestPasswordResetForm(FlaskForm):
|
||||
email = StringField('Email', validators=[DataRequired(), Email()])
|
||||
submit = SubmitField('Request Password Reset')
|
||||
|
||||
class ResetPasswordForm(FlaskForm):
|
||||
password = PasswordField('Password', validators=[DataRequired()])
|
||||
password2 = PasswordField('Repeat Password', validators=[DataRequired(), EqualTo('password')])
|
||||
submit = SubmitField('Reset Password')
|
||||
@@ -0,0 +1,108 @@
|
||||
from flask_wtf import FlaskForm
|
||||
from wtforms import (
|
||||
StringField,
|
||||
PasswordField,
|
||||
BooleanField,
|
||||
SubmitField
|
||||
)
|
||||
from wtforms.validators import (
|
||||
DataRequired,
|
||||
Email,
|
||||
Length,
|
||||
ValidationError,
|
||||
EqualTo
|
||||
)
|
||||
from sqlalchemy import func
|
||||
from app.models import User
|
||||
import re
|
||||
|
||||
class LoginForm(FlaskForm):
|
||||
email = StringField('Email', validators=[
|
||||
DataRequired(),
|
||||
Email(),
|
||||
Length(max=120)
|
||||
])
|
||||
password = PasswordField('Password', validators=[
|
||||
DataRequired(),
|
||||
Length(min=8)
|
||||
])
|
||||
remember_me = BooleanField('Remember Me')
|
||||
submit = SubmitField('Log In')
|
||||
|
||||
class RegistrationForm(FlaskForm):
|
||||
username = StringField('Username', validators=[
|
||||
DataRequired(),
|
||||
Length(min=3, max=64)
|
||||
])
|
||||
email = StringField('Email', validators=[
|
||||
DataRequired(),
|
||||
Email(),
|
||||
Length(max=120)
|
||||
])
|
||||
company = StringField('Company', validators=[
|
||||
DataRequired(),
|
||||
Length(max=100)
|
||||
])
|
||||
password = PasswordField('Password', validators=[
|
||||
DataRequired(),
|
||||
Length(min=8)
|
||||
])
|
||||
password_confirm = PasswordField('Confirm Password', validators=[
|
||||
DataRequired(),
|
||||
EqualTo('password', message='Passwords must match')
|
||||
])
|
||||
terms = BooleanField('I agree to the terms and conditions', validators=[
|
||||
DataRequired(message='You must agree to the terms and conditions')
|
||||
])
|
||||
submit = SubmitField('Register')
|
||||
|
||||
def validate_username(self, username):
|
||||
if not re.match(r'^[a-zA-Z0-9_-]+$', username.data):
|
||||
raise ValidationError('Username can only contain letters, numbers, underscores and hyphens')
|
||||
user = User.query.filter_by(username=username.data).first()
|
||||
if user:
|
||||
raise ValidationError('Username already taken')
|
||||
|
||||
def validate_email(self, email):
|
||||
user = User.query.filter_by(email=email.data).first()
|
||||
if user:
|
||||
raise ValidationError('Email already registered')
|
||||
|
||||
def validate_password(self, password):
|
||||
if not re.search(r'[A-Z]', password.data):
|
||||
raise ValidationError('Password must contain at least one uppercase letter')
|
||||
if not re.search(r'[a-z]', password.data):
|
||||
raise ValidationError('Password must contain at least one lowercase letter')
|
||||
if not re.search(r'[0-9]', password.data):
|
||||
raise ValidationError('Password must contain at least one number')
|
||||
if not re.search(r'[!@#$%^&*(),.?":{}|<>]', password.data):
|
||||
raise ValidationError('Password must contain at least one special character')
|
||||
|
||||
class RequestPasswordResetForm(FlaskForm):
|
||||
email = StringField('Email', validators=[
|
||||
DataRequired(),
|
||||
Email(),
|
||||
Length(max=120)
|
||||
])
|
||||
submit = SubmitField('Request Password Reset')
|
||||
|
||||
class ResetPasswordForm(FlaskForm):
|
||||
password = PasswordField('New Password', validators=[
|
||||
DataRequired(),
|
||||
Length(min=8)
|
||||
])
|
||||
password_confirm = PasswordField('Confirm New Password', validators=[
|
||||
DataRequired(),
|
||||
EqualTo('password', message='Passwords must match')
|
||||
])
|
||||
submit = SubmitField('Reset Password')
|
||||
|
||||
def validate_password(self, password):
|
||||
if not re.search(r'[A-Z]', password.data):
|
||||
raise ValidationError('Password must contain at least one uppercase letter')
|
||||
if not re.search(r'[a-z]', password.data):
|
||||
raise ValidationError('Password must contain at least one lowercase letter')
|
||||
if not re.search(r'[0-9]', password.data):
|
||||
raise ValidationError('Password must contain at least one number')
|
||||
if not re.search(r'[!@#$%^&*(),.?":{}|<>]', password.data):
|
||||
raise ValidationError('Password must contain at least one special character')
|
||||
@@ -0,0 +1,193 @@
|
||||
from flask_wtf import FlaskForm
|
||||
from wtforms import (
|
||||
StringField,
|
||||
SelectField,
|
||||
DateField,
|
||||
SubmitField
|
||||
)
|
||||
from wtforms.validators import (
|
||||
DataRequired,
|
||||
Length,
|
||||
ValidationError
|
||||
)
|
||||
|
||||
def validate_tooling_id_format(form, field):
|
||||
"""Custom validator to ensure tooling_id is exactly 5 digits"""
|
||||
if not field.data.isdigit() or len(field.data) != 5:
|
||||
raise ValidationError('Tooling ID must be exactly 5 digits')
|
||||
|
||||
class QuotationForm(FlaskForm):
|
||||
# Factory Information
|
||||
mold_shop_name = StringField('Mold Shop Name', validators=[DataRequired()])
|
||||
mold_shop_country = SelectField('Mold Shop Country', validators=[DataRequired()], choices=[
|
||||
('', 'Select Country'),
|
||||
('China', 'China'),
|
||||
('Vietnam', 'Vietnam'),
|
||||
('Indonesia', 'Indonesia')
|
||||
])
|
||||
t1_shoe_factory_name = StringField('T1 Shoe Factory Name', validators=[DataRequired()])
|
||||
t1_shoe_factory_country = SelectField('T1 Shoe Factory Country', validators=[DataRequired()], choices=[
|
||||
('', 'Select Country'),
|
||||
('China', 'China'),
|
||||
('Vietnam', 'Vietnam'),
|
||||
('Indonesia', 'Indonesia')
|
||||
])
|
||||
t2_component_factory_name = StringField('T2 Component Factory Name', validators=[DataRequired()])
|
||||
t2_component_factory_country = SelectField('T2 Component Factory Country', validators=[DataRequired()], choices=[
|
||||
('', 'Select Country'),
|
||||
('China', 'China'),
|
||||
('Vietnam', 'Vietnam'),
|
||||
('Indonesia', 'Indonesia')
|
||||
])
|
||||
|
||||
# Mold Information
|
||||
model_name = StringField('Model Name', validators=[DataRequired()])
|
||||
tooling_id = StringField('Tooling ID', validators=[DataRequired(), Length(min=5, max=5), validate_tooling_id_format], id='tooling_id')
|
||||
season = StringField('Season', validators=[DataRequired()])
|
||||
|
||||
# Define stage-type mapping
|
||||
STAGE_TYPE_MAPPING = {
|
||||
'CR0': ['Sample'],
|
||||
'CR1': ['Sample'],
|
||||
'CR2': ['Sample'],
|
||||
'SMS': ['A Set'],
|
||||
'commercialization': ['A Set'],
|
||||
'production': ['Additional']
|
||||
}
|
||||
|
||||
# Define the mapping of main types to sub-types
|
||||
MOLD_TYPE_MAPPING = {
|
||||
'Rubber': ['Flat Outsole', 'Cupsole', 'Rubber Sole'],
|
||||
'CMEVA': ['2 Plates Mold', '3 Plates Mold', '2 Plates with in-mold channel', 'Breathable + in-mold channel'],
|
||||
'IMEVA': ['2 Plates Mold', '3 Plates Mold'],
|
||||
'Sandal': ['Sandal with Upper Bandage', '2 Plate without Upper Bandage'],
|
||||
'Co-shot mold': ['Co-shot mold', 'Foaming mold'],
|
||||
'PU': ['2 Plates Mold', '3 Plates Mold']
|
||||
}
|
||||
|
||||
# Define the mapping of sub-types to their specific options
|
||||
MOLD_SPECIFIC_TYPE_OPTIONS = {
|
||||
'Flat Outsole': ['Flat', 'Flat with Top/Heel Tip'],
|
||||
'Cupsole': ['Top PL', 'Visible PL'],
|
||||
'Rubber Sole': ['Top PL', 'Short PL (Heel/Forefoot)', 'Visible PL', 'Wave PL']
|
||||
}
|
||||
|
||||
mold_main_type = SelectField('Mold Main Type', validators=[DataRequired()], choices=[
|
||||
('', 'Select Main Type'),
|
||||
('Rubber', 'Rubber'),
|
||||
('CMEVA', 'CMEVA'),
|
||||
('IMEVA', 'IMEVA'),
|
||||
('Sandal', 'Sandal'),
|
||||
('Co-shot mold', 'Co-shot mold'),
|
||||
('PU', 'PU')
|
||||
], id='mold_main_type')
|
||||
|
||||
mold_sub_type = SelectField('Mold Sub Type', validators=[DataRequired()], choices=[
|
||||
('', 'Select Sub Type')
|
||||
], id='mold_sub_type')
|
||||
|
||||
mold_specific_type = SelectField('Mold Specific Type', validators=[], choices=[
|
||||
('', 'Select Specific Type')
|
||||
], id='mold_specific_type', coerce=str, default='')
|
||||
|
||||
stage = SelectField('Stage', validators=[DataRequired()], choices=[
|
||||
('', 'Select Stage'),
|
||||
('CR0', 'CR0'),
|
||||
('CR1', 'CR1'),
|
||||
('CR2', 'CR2'),
|
||||
('SMS', 'SMS'),
|
||||
('commercialization', 'Commercialization'),
|
||||
('production', 'Production')
|
||||
], id='stage')
|
||||
|
||||
type = SelectField('Type', validators=[DataRequired()], choices=[
|
||||
('', 'Select Type'),
|
||||
('Sample', 'Sample'),
|
||||
('A Set', 'A Set'),
|
||||
('Additional', 'Additional')
|
||||
], id='type')
|
||||
|
||||
# Dates and Approvals
|
||||
issue_date = DateField('Issue Date', validators=[DataRequired()])
|
||||
approved_by_mold_shop = StringField('Approved by (Mold Shop Representative)', validators=[DataRequired()])
|
||||
approved_by_t1_tooling = StringField('Approved by (T1 Tooling Representative)', validators=[DataRequired()])
|
||||
approved_by_lo_tooling = StringField('Approved by (LO Tooling)', validators=[DataRequired()])
|
||||
|
||||
# Mold Cost
|
||||
sliders_count = SelectField('Number of Sliders', validators=[DataRequired()], choices=[
|
||||
('', 'Select Number of Sliders'),
|
||||
('0', '0'),
|
||||
('1', '1'),
|
||||
('2', '2'),
|
||||
('3', '3'),
|
||||
('4', '4')
|
||||
])
|
||||
cavity_count = SelectField('Number of Cavities', validators=[DataRequired()], choices=[
|
||||
('', 'Select Number of Pairs'),
|
||||
('0.5', '0.5 pair'),
|
||||
('1', '1 pair'),
|
||||
('1.5', '1.5 pair'),
|
||||
('2', '2 pair'),
|
||||
('2.5', '2.5 pair'),
|
||||
('3', '3 pair')
|
||||
])
|
||||
digital_texture_type = SelectField('Digital Texture', validators=[DataRequired()], choices=[
|
||||
('', 'Select Texture Type'),
|
||||
('adidas_digital', 'adidas digital texture'),
|
||||
('customized texture', 'customized texture'),
|
||||
('laser', 'laser texture')
|
||||
])
|
||||
complexity_high_sidewall = SelectField('High Sidewall >60mm', validators=[DataRequired()], choices=[('no', 'No'), ('yes', 'Yes')])
|
||||
submit = SubmitField('Calculate')
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(QuotationForm, self).__init__(*args, **kwargs)
|
||||
# Set initial choices for mold_sub_type based on default mold_main_type
|
||||
if self.mold_main_type.data in self.MOLD_TYPE_MAPPING:
|
||||
self.mold_sub_type.choices = [('', 'Select Sub Type')] + [(x, x) for x in self.MOLD_TYPE_MAPPING[self.mold_main_type.data]]
|
||||
|
||||
def validate_mold_sub_type(self, field):
|
||||
main_type = self.mold_main_type.data
|
||||
print(f"Validating sub_type: {field.data} for main_type: {main_type}")
|
||||
if main_type and main_type in self.MOLD_TYPE_MAPPING:
|
||||
valid_sub_types = self.MOLD_TYPE_MAPPING[main_type]
|
||||
print(f"Valid sub_types for {main_type}: {valid_sub_types}")
|
||||
if field.data not in valid_sub_types and field.data != '':
|
||||
print(f"Invalid sub_type: {field.data}. Valid types: {valid_sub_types}")
|
||||
raise ValidationError('Invalid sub-type for selected main type')
|
||||
|
||||
def validate_mold_specific_type(self, field):
|
||||
"""Validate that the selected specific type is valid for the selected sub type."""
|
||||
sub_type = self.mold_sub_type.data
|
||||
if not sub_type:
|
||||
field.data = ''
|
||||
return
|
||||
|
||||
# Get valid specific types for the selected sub type
|
||||
valid_specific_types = self.MOLD_SPECIFIC_TYPE_OPTIONS.get(sub_type, [])
|
||||
|
||||
# If there are no specific types for this sub-type, the field should be empty
|
||||
if not valid_specific_types:
|
||||
field.data = '' # Always clear the field if it shouldn't have a value
|
||||
return
|
||||
|
||||
# If there are specific types, validate the selection
|
||||
if field.data is None or field.data == '':
|
||||
raise ValidationError(f'Specific type is required for {sub_type}')
|
||||
if field.data not in valid_specific_types:
|
||||
raise ValidationError(f'Invalid specific type for {sub_type}. Valid options are: {", ".join(valid_specific_types)}')
|
||||
|
||||
def validate_type(self, field):
|
||||
stage = self.stage.data
|
||||
print(f"Validating type: {field.data} for stage: {stage}")
|
||||
if stage and stage in self.STAGE_TYPE_MAPPING:
|
||||
valid_types = self.STAGE_TYPE_MAPPING[stage]
|
||||
print(f"Valid types for {stage}: {valid_types}")
|
||||
if field.data not in valid_types and field.data != '':
|
||||
print(f"Invalid type: {field.data}")
|
||||
raise ValidationError(f'Invalid type for {stage} stage. Allowed types: {", ".join(valid_types)}')
|
||||
elif field.data != '':
|
||||
raise ValidationError('Please select a stage first')
|
||||
|
||||
def validate_tooling_id(self, field):
|
||||
validate_tooling_id_format(self, field)
|
||||
+126
@@ -0,0 +1,126 @@
|
||||
import os
|
||||
import psutil
|
||||
import time
|
||||
from datetime import datetime
|
||||
from flask import jsonify
|
||||
from sqlalchemy import text
|
||||
from . import db
|
||||
|
||||
def check_database():
|
||||
"""Check database connection and response time."""
|
||||
try:
|
||||
start_time = time.time()
|
||||
# Execute a simple query
|
||||
db.session.execute(text('SELECT 1'))
|
||||
response_time = (time.time() - start_time) * 1000 # Convert to milliseconds
|
||||
|
||||
return {
|
||||
'status': 'healthy',
|
||||
'response_time_ms': round(response_time, 2)
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
'status': 'unhealthy',
|
||||
'error': str(e)
|
||||
}
|
||||
|
||||
def check_disk_space():
|
||||
"""Check available disk space."""
|
||||
try:
|
||||
disk = psutil.disk_usage('/')
|
||||
return {
|
||||
'status': 'healthy' if disk.percent < 90 else 'warning',
|
||||
'total_gb': round(disk.total / (1024**3), 2),
|
||||
'used_gb': round(disk.used / (1024**3), 2),
|
||||
'free_gb': round(disk.free / (1024**3), 2),
|
||||
'percent_used': disk.percent
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
'status': 'unhealthy',
|
||||
'error': str(e)
|
||||
}
|
||||
|
||||
def check_memory():
|
||||
"""Check memory usage."""
|
||||
try:
|
||||
memory = psutil.virtual_memory()
|
||||
return {
|
||||
'status': 'healthy' if memory.percent < 90 else 'warning',
|
||||
'total_gb': round(memory.total / (1024**3), 2),
|
||||
'used_gb': round(memory.used / (1024**3), 2),
|
||||
'free_gb': round(memory.available / (1024**3), 2),
|
||||
'percent_used': memory.percent
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
'status': 'unhealthy',
|
||||
'error': str(e)
|
||||
}
|
||||
|
||||
def check_cpu():
|
||||
"""Check CPU usage."""
|
||||
try:
|
||||
cpu_percent = psutil.cpu_percent(interval=1)
|
||||
return {
|
||||
'status': 'healthy' if cpu_percent < 90 else 'warning',
|
||||
'usage_percent': cpu_percent,
|
||||
'core_count': psutil.cpu_count()
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
'status': 'unhealthy',
|
||||
'error': str(e)
|
||||
}
|
||||
|
||||
def check_application():
|
||||
"""Check application-specific metrics."""
|
||||
try:
|
||||
# Check if required directories exist
|
||||
required_dirs = ['instance', 'logs', 'backups']
|
||||
dir_status = {}
|
||||
for dir_name in required_dirs:
|
||||
dir_status[dir_name] = os.path.exists(dir_name)
|
||||
|
||||
# Check if log files are writable
|
||||
log_status = {}
|
||||
log_files = ['logs/mold_cost_calculator.log', 'logs/error.log', 'logs/access.log']
|
||||
for log_file in log_files:
|
||||
log_status[log_file] = os.access(os.path.dirname(log_file), os.W_OK)
|
||||
|
||||
return {
|
||||
'status': 'healthy' if all(dir_status.values()) and all(log_status.values()) else 'warning',
|
||||
'directories': dir_status,
|
||||
'log_files': log_status,
|
||||
'startup_time': datetime.utcnow().isoformat()
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
'status': 'unhealthy',
|
||||
'error': str(e)
|
||||
}
|
||||
|
||||
def get_health_status():
|
||||
"""Get comprehensive health status of the application."""
|
||||
checks = {
|
||||
'database': check_database(),
|
||||
'disk_space': check_disk_space(),
|
||||
'memory': check_memory(),
|
||||
'cpu': check_cpu(),
|
||||
'application': check_application()
|
||||
}
|
||||
|
||||
# Determine overall status
|
||||
statuses = [check['status'] for check in checks.values()]
|
||||
if 'unhealthy' in statuses:
|
||||
overall_status = 'unhealthy'
|
||||
elif 'warning' in statuses:
|
||||
overall_status = 'warning'
|
||||
else:
|
||||
overall_status = 'healthy'
|
||||
|
||||
return {
|
||||
'status': overall_status,
|
||||
'timestamp': datetime.utcnow().isoformat(),
|
||||
'checks': checks
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import os
|
||||
import logging
|
||||
from logging.handlers import RotatingFileHandler, SMTPHandler
|
||||
from pathlib import Path
|
||||
|
||||
def configure_logging(app):
|
||||
"""Configure logging for the application."""
|
||||
# Create logs directory if it doesn't exist
|
||||
log_dir = Path('logs')
|
||||
log_dir.mkdir(exist_ok=True)
|
||||
|
||||
# Set up basic logging configuration
|
||||
logging.basicConfig(
|
||||
level=app.config['LOG_LEVEL'],
|
||||
format=app.config['LOG_FORMAT'],
|
||||
datefmt='%Y-%m-%d %H:%M:%S'
|
||||
)
|
||||
|
||||
# Configure application logger
|
||||
app.logger.setLevel(app.config['LOG_LEVEL'])
|
||||
|
||||
# Remove default handlers
|
||||
for handler in app.logger.handlers[:]:
|
||||
app.logger.removeHandler(handler)
|
||||
|
||||
# Add file handler for application logs
|
||||
file_handler = RotatingFileHandler(
|
||||
'logs/mold_cost_calculator.log',
|
||||
maxBytes=10 * 1024 * 1024, # 10MB
|
||||
backupCount=10,
|
||||
encoding='utf-8'
|
||||
)
|
||||
file_handler.setFormatter(logging.Formatter(app.config['LOG_FORMAT']))
|
||||
file_handler.setLevel(app.config['LOG_LEVEL'])
|
||||
app.logger.addHandler(file_handler)
|
||||
|
||||
# Add file handler for error logs
|
||||
error_handler = RotatingFileHandler(
|
||||
'logs/error.log',
|
||||
maxBytes=10 * 1024 * 1024, # 10MB
|
||||
backupCount=10,
|
||||
encoding='utf-8'
|
||||
)
|
||||
error_handler.setFormatter(logging.Formatter(app.config['LOG_FORMAT']))
|
||||
error_handler.setLevel(logging.ERROR)
|
||||
app.logger.addHandler(error_handler)
|
||||
|
||||
# Add file handler for access logs
|
||||
access_handler = RotatingFileHandler(
|
||||
'logs/access.log',
|
||||
maxBytes=10 * 1024 * 1024, # 10MB
|
||||
backupCount=10,
|
||||
encoding='utf-8'
|
||||
)
|
||||
access_handler.setFormatter(logging.Formatter(app.config['LOG_FORMAT']))
|
||||
access_handler.setLevel(logging.INFO)
|
||||
app.logger.addHandler(access_handler)
|
||||
|
||||
# Add console handler if LOG_TO_STDOUT is set
|
||||
if app.config.get('LOG_TO_STDOUT'):
|
||||
console_handler = logging.StreamHandler()
|
||||
console_handler.setFormatter(logging.Formatter(app.config['LOG_FORMAT']))
|
||||
console_handler.setLevel(app.config['LOG_LEVEL'])
|
||||
app.logger.addHandler(console_handler)
|
||||
|
||||
# Add email handler for critical errors in production
|
||||
if not app.debug and not app.testing and app.config.get('MAIL_SERVER'):
|
||||
mail_handler = SMTPHandler(
|
||||
mailhost=(app.config['MAIL_SERVER'], app.config['MAIL_PORT']),
|
||||
fromaddr=app.config['MAIL_DEFAULT_SENDER'],
|
||||
toaddrs=[app.config['ADMIN_EMAIL']],
|
||||
subject='Mold Cost Calculator Error'
|
||||
)
|
||||
mail_handler.setLevel(logging.ERROR)
|
||||
mail_handler.setFormatter(logging.Formatter('''
|
||||
Message type: %(levelname)s
|
||||
Location: %(pathname)s:%(lineno)d
|
||||
Module: %(module)s
|
||||
Function: %(funcName)s
|
||||
Time: %(asctime)s
|
||||
Message:
|
||||
%(message)s
|
||||
'''))
|
||||
app.logger.addHandler(mail_handler)
|
||||
|
||||
# Configure SQLAlchemy logging
|
||||
if app.config.get('SQLALCHEMY_ECHO'):
|
||||
logging.getLogger('sqlalchemy.engine').setLevel(logging.INFO)
|
||||
|
||||
# Configure Werkzeug logging
|
||||
logging.getLogger('werkzeug').setLevel(logging.INFO)
|
||||
|
||||
# Log startup message
|
||||
app.logger.info('Mold Cost Calculator startup')
|
||||
|
||||
return app.logger
|
||||
@@ -0,0 +1,42 @@
|
||||
from datetime import datetime
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
|
||||
db = SQLAlchemy()
|
||||
|
||||
class CostFactor(db.Model):
|
||||
"""Model for storing cost factors that can be managed via admin interface"""
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
category = db.Column(db.String(50), nullable=False) # 'mold', 'complexity', 'texture', 'tax'
|
||||
subcategory = db.Column(db.String(50)) # For mold types: 'Rubber', 'CMEVA', etc.
|
||||
name = db.Column(db.String(100), nullable=False) # Factor name
|
||||
factor_value = db.Column(db.Float, nullable=False) # The actual factor value
|
||||
calculation_formula = db.Column(db.String(200)) # For complexity factors
|
||||
percentage = db.Column(db.String(20)) # For display purposes
|
||||
description = db.Column(db.String(500))
|
||||
is_active = db.Column(db.Boolean, default=True)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
updated_by = db.Column(db.Integer, db.ForeignKey('user.id'))
|
||||
|
||||
# Relationship
|
||||
updater = db.relationship('User', backref='cost_factor_updates')
|
||||
|
||||
def __repr__(self):
|
||||
return f'<CostFactor {self.category}:{self.name}={self.factor_value}>'
|
||||
|
||||
class CostFactorHistory(db.Model):
|
||||
"""Model for tracking changes to cost factors"""
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
cost_factor_id = db.Column(db.Integer, db.ForeignKey('cost_factor.id'), nullable=False)
|
||||
old_value = db.Column(db.Float)
|
||||
new_value = db.Column(db.Float, nullable=False)
|
||||
changed_by = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
|
||||
changed_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
change_reason = db.Column(db.String(500))
|
||||
|
||||
# Relationships
|
||||
cost_factor = db.relationship('CostFactor', backref='history')
|
||||
user = db.relationship('User', backref='cost_factor_changes')
|
||||
|
||||
def __repr__(self):
|
||||
return f'<CostFactorHistory {self.cost_factor_id}:{self.old_value}->{self.new_value}>'
|
||||
@@ -0,0 +1,6 @@
|
||||
from .. import db
|
||||
from .user import User
|
||||
from .quotation import Quotation
|
||||
from .cost_factor import CostFactor, CostFactorHistory
|
||||
|
||||
__all__ = ['db', 'User', 'Quotation', 'CostFactor', 'CostFactorHistory']
|
||||
@@ -0,0 +1,66 @@
|
||||
from datetime import datetime, timezone
|
||||
from sqlalchemy import event
|
||||
from .. import db
|
||||
|
||||
class CostFactor(db.Model):
|
||||
"""Model for storing cost factors that can be managed via admin interface"""
|
||||
__tablename__ = 'cost_factors'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
category = db.Column(db.String(50), nullable=False) # 'mold', 'complexity', 'texture', 'tax'
|
||||
subcategory = db.Column(db.String(50)) # For mold types: 'Rubber', 'CMEVA', etc.
|
||||
name = db.Column(db.String(100), nullable=False) # Factor name
|
||||
factor_value = db.Column(db.Float, nullable=False) # The actual factor value
|
||||
calculation_formula = db.Column(db.String(200)) # For complexity factors
|
||||
percentage = db.Column(db.String(20)) # For display purposes
|
||||
description = db.Column(db.String(500))
|
||||
is_active = db.Column(db.Boolean, default=True)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
updated_by = db.Column(db.Integer, db.ForeignKey('enterprise_users.id'))
|
||||
|
||||
# Relationship
|
||||
updater = db.relationship('User', backref='cost_factor_updates')
|
||||
|
||||
__table_args__ = (
|
||||
db.Index('ix_cost_factor_category', category),
|
||||
db.Index('ix_cost_factor_name', name),
|
||||
db.Index('ix_cost_factor_active', is_active),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f'<CostFactor {self.category}:{self.name}={self.factor_value}>'
|
||||
|
||||
class CostFactorHistory(db.Model):
|
||||
"""Model for tracking changes to cost factors"""
|
||||
__tablename__ = 'cost_factor_history'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
cost_factor_id = db.Column(db.Integer, db.ForeignKey('cost_factors.id'), nullable=False)
|
||||
old_value = db.Column(db.Float)
|
||||
new_value = db.Column(db.Float, nullable=False)
|
||||
changed_by = db.Column(db.Integer, db.ForeignKey('enterprise_users.id'), nullable=False)
|
||||
changed_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
change_reason = db.Column(db.String(500))
|
||||
|
||||
# Relationships
|
||||
cost_factor = db.relationship('CostFactor', backref='history')
|
||||
user = db.relationship('User', backref='cost_factor_changes')
|
||||
|
||||
__table_args__ = (
|
||||
db.Index('ix_cost_factor_history_factor_id', cost_factor_id),
|
||||
db.Index('ix_cost_factor_history_changed_at', changed_at),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f'<CostFactorHistory {self.cost_factor_id}:{self.old_value}->{self.new_value}>'
|
||||
|
||||
# Event listeners for model changes
|
||||
@event.listens_for(CostFactor, 'before_insert')
|
||||
def set_cost_factor_timestamps(mapper, connection, target):
|
||||
target.created_at = datetime.now(timezone.utc)
|
||||
target.updated_at = datetime.now(timezone.utc)
|
||||
|
||||
@event.listens_for(CostFactor, 'before_update')
|
||||
def set_cost_factor_update_timestamp(mapper, connection, target):
|
||||
target.updated_at = datetime.now(timezone.utc)
|
||||
@@ -0,0 +1,63 @@
|
||||
from datetime import datetime, timezone
|
||||
from sqlalchemy import event
|
||||
from .. import db
|
||||
|
||||
class Quotation(db.Model):
|
||||
__tablename__ = 'mold_quotations'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
user_id = db.Column(db.Integer, db.ForeignKey('enterprise_users.id'), nullable=False)
|
||||
|
||||
# Factory Information
|
||||
mold_shop_name = db.Column(db.String(100), nullable=False)
|
||||
mold_shop_country = db.Column(db.String(50), nullable=False)
|
||||
t1_shoe_factory_name = db.Column(db.String(100), nullable=False)
|
||||
t1_shoe_factory_country = db.Column(db.String(50), nullable=False)
|
||||
t2_component_factory_name = db.Column(db.String(100), nullable=False)
|
||||
t2_component_factory_country = db.Column(db.String(50), nullable=False)
|
||||
|
||||
# Mold Information
|
||||
model_name = db.Column(db.String(100), nullable=False)
|
||||
tooling_id = db.Column(db.String(5), nullable=False)
|
||||
season = db.Column(db.String(10), nullable=False)
|
||||
stage = db.Column(db.String(10), nullable=False)
|
||||
type = db.Column(db.String(20), nullable=False)
|
||||
|
||||
# Dates and Approvals
|
||||
issue_date = db.Column(db.Date, nullable=False)
|
||||
approved_by_mold_shop = db.Column(db.String(100), nullable=False)
|
||||
approved_by_t1_tooling = db.Column(db.String(100), nullable=False)
|
||||
approved_by_lo_tooling = db.Column(db.String(100), nullable=False)
|
||||
|
||||
# Mold Specifications
|
||||
mold_main_type = db.Column(db.String(50), nullable=False)
|
||||
mold_sub_type = db.Column(db.String(50), nullable=False)
|
||||
mold_specific_type = db.Column(db.String(50), nullable=False)
|
||||
complexity_high_sidewall = db.Column(db.Boolean, default=False)
|
||||
sliders_count = db.Column(db.Integer, nullable=False)
|
||||
cavity_count = db.Column(db.Float, nullable=False)
|
||||
digital_texture_type = db.Column(db.String(50), nullable=False)
|
||||
|
||||
# Calculation Results
|
||||
base_price = db.Column(db.Float, nullable=False)
|
||||
tax_rate = db.Column(db.Float)
|
||||
total_cost = db.Column(db.Float, nullable=False)
|
||||
|
||||
# Metadata
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
is_archived = db.Column(db.Boolean, default=False)
|
||||
|
||||
__table_args__ = (
|
||||
db.Index('ix_quotation_user_id', user_id),
|
||||
db.Index('ix_quotation_created_at', created_at),
|
||||
db.Index('ix_quotation_issue_date', issue_date),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f'<Quotation {self.id}>'
|
||||
|
||||
# Event listeners for model changes
|
||||
@event.listens_for(Quotation, 'before_insert')
|
||||
def set_quotation_timestamps(mapper, connection, target):
|
||||
target.created_at = datetime.now(timezone.utc)
|
||||
@@ -0,0 +1,116 @@
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from werkzeug.security import generate_password_hash, check_password_hash
|
||||
from flask_login import UserMixin
|
||||
from sqlalchemy import event, func
|
||||
from .. import db
|
||||
import secrets
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class User(UserMixin, db.Model):
|
||||
__tablename__ = 'enterprise_users'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
username = db.Column(db.String(25), unique=True, nullable=False)
|
||||
email = db.Column(db.String(120), unique=True, nullable=False)
|
||||
company = db.Column(db.String(100), nullable=False)
|
||||
password_hash = db.Column(db.String(128), nullable=False)
|
||||
created_at = db.Column(db.DateTime)
|
||||
password_changed_at = db.Column(db.DateTime)
|
||||
is_admin = db.Column(db.Boolean, default=False)
|
||||
last_login = db.Column(db.DateTime)
|
||||
is_active = db.Column(db.Boolean, default=True)
|
||||
failed_login_attempts = db.Column(db.Integer, default=0)
|
||||
last_failed_login = db.Column(db.DateTime)
|
||||
reset_token = db.Column(db.String(100), unique=True)
|
||||
reset_token_expires = db.Column(db.DateTime)
|
||||
|
||||
# Relationships
|
||||
quotations = db.relationship('Quotation', backref='user', lazy=True)
|
||||
|
||||
def set_password(self, password):
|
||||
try:
|
||||
self.password_hash = generate_password_hash(password, method='pbkdf2:sha256')
|
||||
self.password_changed_at = datetime.now(timezone.utc)
|
||||
self.reset_token = None
|
||||
self.reset_token_expires = None
|
||||
except Exception as e:
|
||||
logger.error(f'Error setting password: {str(e)}', exc_info=True)
|
||||
raise
|
||||
|
||||
def check_password(self, password):
|
||||
return check_password_hash(self.password_hash, password)
|
||||
|
||||
def generate_reset_token(self):
|
||||
token = secrets.token_urlsafe(16) # Shorter token for display
|
||||
self.reset_token = token
|
||||
self.reset_token_expires = datetime.now(timezone.utc) + timedelta(minutes=15) # 15-minute expiry
|
||||
return token
|
||||
|
||||
def verify_reset_token(self, token):
|
||||
if not self.reset_token or not self.reset_token_expires:
|
||||
return False
|
||||
if datetime.now(timezone.utc) > self.reset_token_expires:
|
||||
return False
|
||||
return secrets.compare_digest(self.reset_token, token)
|
||||
|
||||
@classmethod
|
||||
def authenticate(cls, email, password):
|
||||
user = cls.query.filter(func.lower(cls.email) == email.lower()).first()
|
||||
if user and user.check_password(password):
|
||||
if not user.is_active:
|
||||
return None
|
||||
user.failed_login_attempts = 0
|
||||
user.last_failed_login = None
|
||||
db.session.commit()
|
||||
return user
|
||||
if user:
|
||||
user.failed_login_attempts = (user.failed_login_attempts or 0) + 1
|
||||
user.last_failed_login = datetime.now(timezone.utc)
|
||||
db.session.commit()
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def create(cls, username, email, company, password):
|
||||
try:
|
||||
logger.info(f'Checking if username {username} exists...')
|
||||
if cls.query.filter(func.lower(cls.username) == username.lower()).first():
|
||||
logger.warning(f'Username {username} already taken')
|
||||
raise ValueError('Username already taken')
|
||||
|
||||
logger.info(f'Checking if email {email} exists...')
|
||||
if cls.query.filter(func.lower(cls.email) == email.lower()).first():
|
||||
logger.warning(f'Email {email} already registered')
|
||||
raise ValueError('Email already registered')
|
||||
|
||||
logger.info('Creating new user...')
|
||||
user = cls(
|
||||
username=username,
|
||||
email=email,
|
||||
company=company,
|
||||
is_active=True,
|
||||
failed_login_attempts=0,
|
||||
is_admin=False
|
||||
)
|
||||
|
||||
logger.info('Setting password...')
|
||||
user.set_password(password)
|
||||
|
||||
logger.info('Adding user to session...')
|
||||
db.session.add(user)
|
||||
|
||||
logger.info('User created successfully')
|
||||
return user
|
||||
except Exception as e:
|
||||
logger.error(f'Error creating user: {str(e)}', exc_info=True)
|
||||
raise
|
||||
|
||||
def __repr__(self):
|
||||
return f'<User {self.username}>'
|
||||
|
||||
# Event listeners for model changes
|
||||
@event.listens_for(User, 'before_insert')
|
||||
def set_timestamps(mapper, connection, target):
|
||||
target.created_at = datetime.now(timezone.utc)
|
||||
target.password_changed_at = datetime.now(timezone.utc)
|
||||
@@ -0,0 +1,473 @@
|
||||
from flask import Blueprint, render_template, redirect, url_for, flash, request, current_app, jsonify, make_response
|
||||
from flask_login import login_required, current_user
|
||||
from app import create_app, db
|
||||
from ..forms import QuotationForm
|
||||
from ..models import User, Quotation, CostFactor, CostFactorHistory
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from sqlalchemy import desc, text
|
||||
import logging
|
||||
from app import limiter, cache
|
||||
|
||||
bp = Blueprint('admin', __name__, url_prefix='/admin')
|
||||
|
||||
@bp.before_request
|
||||
def before_request():
|
||||
if not current_user.is_authenticated or not current_user.is_admin:
|
||||
flash('Access denied. Admin privileges required.', 'danger')
|
||||
return redirect(url_for('main.home'))
|
||||
|
||||
@bp.route('/dashboard')
|
||||
@login_required
|
||||
@limiter.limit("30 per minute")
|
||||
@cache.cached(timeout=60, key_prefix='admin_dashboard')
|
||||
def dashboard():
|
||||
try:
|
||||
# Get statistics
|
||||
total_users = User.query.count()
|
||||
total_quotations = Quotation.query.count()
|
||||
recent_quotations = Quotation.query.order_by(desc(Quotation.created_at)).limit(5).all()
|
||||
|
||||
# Get user statistics
|
||||
active_users = User.query.filter(
|
||||
User.last_login >= datetime.utcnow() - timedelta(days=30)
|
||||
).count()
|
||||
|
||||
# Get quotation statistics by country (using mold_shop_country)
|
||||
country_stats = db.session.query(
|
||||
Quotation.mold_shop_country,
|
||||
db.func.count(Quotation.id)
|
||||
).group_by(Quotation.mold_shop_country).all()
|
||||
|
||||
return render_template('admin/dashboard.html',
|
||||
total_users=total_users,
|
||||
total_quotations=total_quotations,
|
||||
active_users=active_users,
|
||||
recent_quotations=recent_quotations,
|
||||
country_stats=country_stats
|
||||
)
|
||||
except Exception as e:
|
||||
current_app.logger.error(f'Admin dashboard error: {str(e)}')
|
||||
flash('An error occurred while loading the dashboard.', 'error')
|
||||
return redirect(url_for('main.home'))
|
||||
|
||||
@bp.route('/users')
|
||||
@login_required
|
||||
@limiter.limit("30 per minute")
|
||||
@cache.cached(timeout=60, key_prefix='admin_users')
|
||||
def users():
|
||||
try:
|
||||
page = request.args.get('page', 1, type=int)
|
||||
per_page = 20
|
||||
|
||||
users = User.query.order_by(User.created_at.desc()).paginate(
|
||||
page=page, per_page=per_page, error_out=False
|
||||
)
|
||||
|
||||
return render_template('admin/users.html', users=users)
|
||||
except Exception as e:
|
||||
current_app.logger.error(f'Admin users error: {str(e)}')
|
||||
flash('An error occurred while loading users.', 'error')
|
||||
return redirect(url_for('admin.dashboard'))
|
||||
|
||||
@bp.route('/quotations')
|
||||
@login_required
|
||||
@limiter.limit("30 per minute")
|
||||
def quotations():
|
||||
try:
|
||||
page = request.args.get('page', 1, type=int)
|
||||
per_page = 20
|
||||
|
||||
# Get filter parameters
|
||||
country = request.args.get('country')
|
||||
mold_type = request.args.get('mold_type')
|
||||
date_from_str = request.args.get('date_from')
|
||||
date_to_str = request.args.get('date_to')
|
||||
|
||||
# Convert date strings to datetime objects
|
||||
date_from = None
|
||||
date_to = None
|
||||
today = datetime.now(timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
|
||||
try:
|
||||
if date_from_str:
|
||||
date_from = datetime.strptime(date_from_str, '%Y-%m-%d')
|
||||
if date_to_str:
|
||||
date_to = datetime.strptime(date_to_str, '%Y-%m-%d')
|
||||
except ValueError:
|
||||
flash('Invalid date format. Please use YYYY-MM-DD format.', 'error')
|
||||
date_from = None
|
||||
date_to = None
|
||||
|
||||
# Build query
|
||||
query = Quotation.query
|
||||
|
||||
if country:
|
||||
query = query.filter(Quotation.mold_shop_country == country)
|
||||
if mold_type:
|
||||
query = query.filter(Quotation.mold_main_type == mold_type)
|
||||
if date_from:
|
||||
query = query.filter(Quotation.created_at >= date_from)
|
||||
if date_to:
|
||||
# Add one day to include the end date fully
|
||||
next_day = date_to + timedelta(days=1)
|
||||
query = query.filter(Quotation.created_at < next_day)
|
||||
|
||||
# Get unique countries and mold types for filters
|
||||
countries = db.session.query(Quotation.mold_shop_country).distinct().all()
|
||||
countries = [c[0] for c in countries if c[0]]
|
||||
|
||||
mold_types = db.session.query(Quotation.mold_main_type).distinct().all()
|
||||
mold_types = [t[0] for t in mold_types if t[0]]
|
||||
|
||||
quotations = query.order_by(desc(Quotation.created_at)).paginate(
|
||||
page=page, per_page=per_page, error_out=False
|
||||
)
|
||||
|
||||
return render_template('admin/quotations.html',
|
||||
quotations=quotations,
|
||||
countries=sorted(countries),
|
||||
mold_types=sorted(mold_types),
|
||||
today=today,
|
||||
filters={
|
||||
'country': country,
|
||||
'mold_type': mold_type,
|
||||
'date_from': date_from,
|
||||
'date_to': date_to
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
current_app.logger.error(f'Admin quotations error: {str(e)}')
|
||||
flash('An error occurred while loading quotations.', 'error')
|
||||
return redirect(url_for('admin.dashboard'))
|
||||
|
||||
@bp.route('/user/<int:user_id>')
|
||||
@login_required
|
||||
def user_detail(user_id):
|
||||
try:
|
||||
user = User.query.get_or_404(user_id)
|
||||
quotations = Quotation.query.filter_by(user_id=user_id).order_by(
|
||||
desc(Quotation.created_at)
|
||||
).all()
|
||||
|
||||
return render_template('admin/user_detail.html',
|
||||
user=user,
|
||||
quotations=quotations
|
||||
)
|
||||
except Exception as e:
|
||||
current_app.logger.error(f'Admin user detail error: {str(e)}')
|
||||
flash('An error occurred while loading user details.', 'error')
|
||||
return redirect(url_for('admin.users'))
|
||||
|
||||
@bp.route('/quotation/<int:id>')
|
||||
@login_required
|
||||
@limiter.limit("30 per minute")
|
||||
@cache.cached(timeout=60, key_prefix='admin_quotation_detail')
|
||||
def quotation_detail(id):
|
||||
try:
|
||||
quotation = Quotation.query.get_or_404(id)
|
||||
return render_template('admin/quotation_detail.html',
|
||||
quotation=quotation
|
||||
)
|
||||
except Exception as e:
|
||||
current_app.logger.error(f'Admin quotation detail error: {str(e)}')
|
||||
flash('An error occurred while loading quotation details.', 'error')
|
||||
return redirect(url_for('admin.quotations'))
|
||||
|
||||
@bp.route('/user/<int:user_id>/toggle', methods=['POST'])
|
||||
@login_required
|
||||
def toggle_user(user_id):
|
||||
try:
|
||||
user = User.query.get_or_404(user_id)
|
||||
user.is_active = not user.is_active
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'is_active': user.is_active
|
||||
})
|
||||
except Exception as e:
|
||||
current_app.logger.error(f'Admin toggle user error: {str(e)}')
|
||||
db.session.rollback()
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'An error occurred while updating user status'
|
||||
}), 500
|
||||
|
||||
@bp.route('/delete_user', methods=['POST'])
|
||||
@login_required
|
||||
@limiter.limit("10 per minute")
|
||||
def delete_user():
|
||||
if not request.is_json:
|
||||
return jsonify({'error': 'Invalid request format'}), 400
|
||||
|
||||
data = request.get_json()
|
||||
user_id = data.get('user_id')
|
||||
|
||||
if not user_id:
|
||||
return jsonify({'error': 'User ID is required'}), 400
|
||||
|
||||
try:
|
||||
user = User.query.get_or_404(user_id)
|
||||
if user.id == current_user.id:
|
||||
return jsonify({'error': 'Cannot delete your own account'}), 400
|
||||
|
||||
db.session.delete(user)
|
||||
db.session.commit()
|
||||
cache.delete_memoized(users)
|
||||
current_app.logger.info(f'User {user.email} deleted by admin {current_user.email}')
|
||||
return jsonify({'message': 'User deleted successfully'})
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
current_app.logger.error(f'Error deleting user: {str(e)}')
|
||||
return jsonify({'error': 'An error occurred while deleting the user'}), 500
|
||||
|
||||
@bp.route('/delete_quotation', methods=['POST'])
|
||||
@login_required
|
||||
@limiter.limit("10 per minute")
|
||||
def delete_quotation():
|
||||
if not request.is_json:
|
||||
return jsonify({'error': 'Invalid request format'}), 400
|
||||
|
||||
data = request.get_json()
|
||||
quotation_id = data.get('quotation_id')
|
||||
|
||||
if not quotation_id:
|
||||
return jsonify({'error': 'Quotation ID is required'}), 400
|
||||
|
||||
try:
|
||||
quotation = Quotation.query.get_or_404(quotation_id)
|
||||
db.session.delete(quotation)
|
||||
db.session.commit()
|
||||
cache.delete_memoized(quotations)
|
||||
current_app.logger.info(f'Quotation {quotation.id} deleted by admin {current_user.email}')
|
||||
return jsonify({'message': 'Quotation deleted successfully'})
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
current_app.logger.error(f'Error deleting quotation: {str(e)}')
|
||||
return jsonify({'error': 'An error occurred while deleting the quotation'}), 500
|
||||
|
||||
@bp.route('/cost-factors')
|
||||
@login_required
|
||||
@limiter.limit("30 per minute")
|
||||
def cost_factors():
|
||||
"""Display and manage cost factors"""
|
||||
try:
|
||||
from ..utils.cost_factor_loader import initialize_default_cost_factors
|
||||
from flask import current_app
|
||||
|
||||
# Add debug logging
|
||||
current_app.logger.info("Accessing cost_factors route")
|
||||
|
||||
# Verify database connection
|
||||
try:
|
||||
db.session.execute(text('SELECT 1'))
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Database connection error: {str(e)}")
|
||||
flash('Database connection error', 'error')
|
||||
return redirect(url_for('admin.dashboard'))
|
||||
|
||||
# Initialize default factors if none exist
|
||||
try:
|
||||
initialize_default_cost_factors()
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Error initializing default cost factors: {str(e)}")
|
||||
flash('Error initializing cost factors', 'error')
|
||||
return redirect(url_for('admin.dashboard'))
|
||||
|
||||
# Get all cost factors grouped by category
|
||||
try:
|
||||
mold_factors = CostFactor.query.filter_by(category='mold', is_active=True).order_by(CostFactor.name).all()
|
||||
complexity_factors = CostFactor.query.filter_by(category='complexity', is_active=True).order_by(CostFactor.name).all()
|
||||
texture_factors = CostFactor.query.filter_by(category='texture', is_active=True).order_by(CostFactor.name).all()
|
||||
tax_factors = CostFactor.query.filter_by(category='tax', is_active=True).order_by(CostFactor.name).all()
|
||||
base_factors = CostFactor.query.filter_by(category='base', is_active=True).order_by(CostFactor.name).all()
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Error querying cost factors: {str(e)}")
|
||||
flash('Error loading cost factors from database', 'error')
|
||||
return redirect(url_for('admin.dashboard'))
|
||||
|
||||
# Log the number of factors found
|
||||
current_app.logger.info(f"Found factors - Mold: {len(mold_factors)}, Complexity: {len(complexity_factors)}, "
|
||||
f"Texture: {len(texture_factors)}, Tax: {len(tax_factors)}, Base: {len(base_factors)}")
|
||||
|
||||
# Log individual factors for debugging
|
||||
current_app.logger.debug("Base Factors: %s", [f"{f.name}: {f.factor_value}" for f in base_factors])
|
||||
current_app.logger.debug("Mold Factors: %s", [f"{f.name}: {f.factor_value}" for f in mold_factors])
|
||||
current_app.logger.debug("Complexity Factors: %s", [f"{f.name}: {f.factor_value}" for f in complexity_factors])
|
||||
current_app.logger.debug("Texture Factors: %s", [f"{f.name}: {f.factor_value}" for f in texture_factors])
|
||||
current_app.logger.debug("Tax Factors: %s", [f"{f.name}: {f.factor_value}" for f in tax_factors])
|
||||
|
||||
# Prepare template context
|
||||
template_context = {
|
||||
'mold_factors': mold_factors,
|
||||
'complexity_factors': complexity_factors,
|
||||
'texture_factors': texture_factors,
|
||||
'tax_factors': tax_factors,
|
||||
'base_factors': base_factors
|
||||
}
|
||||
|
||||
# Log template context
|
||||
current_app.logger.debug("Template context: %s", template_context)
|
||||
|
||||
try:
|
||||
response = make_response(render_template(
|
||||
'admin/cost_factors.html',
|
||||
**template_context
|
||||
))
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Template rendering error: {str(e)}")
|
||||
flash('Error rendering the page', 'error')
|
||||
return redirect(url_for('admin.dashboard'))
|
||||
|
||||
# Add headers to prevent caching
|
||||
response.headers['Cache-Control'] = 'no-cache, no-store, must-revalidate'
|
||||
response.headers['Pragma'] = 'no-cache'
|
||||
response.headers['Expires'] = '0'
|
||||
|
||||
return response
|
||||
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Unhandled error in cost_factors route: {str(e)}")
|
||||
flash('An unexpected error occurred while loading cost factors.', 'error')
|
||||
return redirect(url_for('admin.dashboard'))
|
||||
|
||||
@bp.route('/cost-factors/edit/<int:factor_id>', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
@limiter.limit("30 per minute")
|
||||
def edit_cost_factor(factor_id):
|
||||
"""Edit a specific cost factor"""
|
||||
try:
|
||||
factor = CostFactor.query.get_or_404(factor_id)
|
||||
|
||||
if request.method == 'POST':
|
||||
old_value = factor.factor_value
|
||||
new_value = float(request.form.get('factor_value'))
|
||||
change_reason = request.form.get('change_reason', '')
|
||||
|
||||
# Update the factor
|
||||
factor.factor_value = new_value
|
||||
factor.updated_by = current_user.id
|
||||
|
||||
# Create history record
|
||||
history = CostFactorHistory(
|
||||
cost_factor_id=factor.id,
|
||||
old_value=old_value,
|
||||
new_value=new_value,
|
||||
changed_by=current_user.id,
|
||||
change_reason=change_reason
|
||||
)
|
||||
|
||||
db.session.add(history)
|
||||
db.session.commit()
|
||||
|
||||
# Clear the cache
|
||||
cache.delete_memoized(cost_factors)
|
||||
cache.delete('admin_cost_factors')
|
||||
|
||||
flash(f'Cost factor "{factor.name}" updated successfully.', 'success')
|
||||
return redirect(url_for('admin.cost_factors'))
|
||||
|
||||
return render_template('admin/edit_cost_factor.html', factor=factor)
|
||||
|
||||
except Exception as e:
|
||||
current_app.logger.error(f'Admin edit cost factor error: {str(e)}')
|
||||
flash('An error occurred while editing the cost factor.', 'error')
|
||||
return redirect(url_for('admin.cost_factors'))
|
||||
|
||||
@bp.route('/cost-factors/history/<int:factor_id>')
|
||||
@login_required
|
||||
@limiter.limit("30 per minute")
|
||||
def cost_factor_history(factor_id):
|
||||
"""View history of changes for a cost factor"""
|
||||
try:
|
||||
factor = CostFactor.query.get_or_404(factor_id)
|
||||
history = CostFactorHistory.query.filter_by(cost_factor_id=factor_id)\
|
||||
.order_by(CostFactorHistory.changed_at.desc()).all()
|
||||
|
||||
return render_template('admin/cost_factor_history.html',
|
||||
factor=factor,
|
||||
history=history
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
current_app.logger.error(f'Admin cost factor history error: {str(e)}')
|
||||
flash('An error occurred while loading cost factor history.', 'error')
|
||||
return redirect(url_for('admin.cost_factors'))
|
||||
|
||||
@bp.route('/cost-factors/reset', methods=['POST'])
|
||||
@login_required
|
||||
@limiter.limit("10 per minute")
|
||||
def reset_cost_factors():
|
||||
"""Reset cost factors to default values"""
|
||||
try:
|
||||
from ..utils.cost_factor_loader import get_default_cost_factors
|
||||
|
||||
# Get default values
|
||||
default_factors, default_tax_rates, default_base = get_default_cost_factors()
|
||||
|
||||
# Create a mapping of all default values
|
||||
default_values = {}
|
||||
|
||||
# Add mold factors
|
||||
for name, value in default_factors['mold'].items():
|
||||
default_values[name] = value
|
||||
|
||||
# Add complexity factors
|
||||
for name, value in default_factors['complexity'].items():
|
||||
default_values[name] = value
|
||||
|
||||
# Add texture factors
|
||||
for name, value in default_factors['texture'].items():
|
||||
default_values[name] = value
|
||||
|
||||
# Add tax factors
|
||||
for name, value in default_tax_rates.items():
|
||||
default_values[name] = value
|
||||
|
||||
# Add base factor
|
||||
default_values['DEFAULT_NET_BASE'] = default_base
|
||||
|
||||
# Update existing cost factors to default values
|
||||
for factor in CostFactor.query.all():
|
||||
if factor.name in default_values:
|
||||
old_value = factor.factor_value
|
||||
new_value = default_values[factor.name]
|
||||
|
||||
if old_value != new_value: # Only update if value changed
|
||||
factor.factor_value = new_value
|
||||
factor.updated_by = current_user.id
|
||||
|
||||
# Create history record for the reset
|
||||
history = CostFactorHistory(
|
||||
cost_factor_id=factor.id,
|
||||
old_value=old_value,
|
||||
new_value=new_value,
|
||||
changed_by=current_user.id,
|
||||
change_reason='Reset to default value'
|
||||
)
|
||||
db.session.add(history)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
# Clear the cache
|
||||
cache.delete_memoized(cost_factors)
|
||||
cache.delete('admin_cost_factors')
|
||||
|
||||
flash('Cost factors have been reset to default values.', 'success')
|
||||
return redirect(url_for('admin.cost_factors'))
|
||||
|
||||
except Exception as e:
|
||||
current_app.logger.error(f'Admin reset cost factors error: {str(e)}')
|
||||
db.session.rollback()
|
||||
flash('An error occurred while resetting cost factors.', 'error')
|
||||
return redirect(url_for('admin.cost_factors'))
|
||||
|
||||
@bp.route('/debug-endpoints')
|
||||
def debug_endpoints():
|
||||
from flask import current_app
|
||||
with open('logs/endpoints.log', 'w') as f:
|
||||
for rule in current_app.url_map.iter_rules():
|
||||
f.write(f"{rule.endpoint} {rule.methods} {rule.rule}\n")
|
||||
return 'Endpoints written to logs/endpoints.log', 200
|
||||
@@ -0,0 +1,171 @@
|
||||
from flask import Blueprint, render_template, redirect, url_for, request, flash, current_app
|
||||
from flask_login import login_user, logout_user, login_required, current_user
|
||||
from app.forms import LoginForm, RegistrationForm, RequestPasswordResetForm, ResetPasswordForm
|
||||
from app import create_app, db
|
||||
from app import limiter
|
||||
from datetime import datetime, timezone
|
||||
from app.config import ALLOWED_EMAILS
|
||||
import logging
|
||||
from flask_mail import Message
|
||||
from app import mail
|
||||
from app.models import User
|
||||
|
||||
bp = Blueprint('auth', __name__)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@bp.route('/login', methods=['GET', 'POST'])
|
||||
@limiter.limit("5 per minute")
|
||||
def login():
|
||||
if current_user.is_authenticated:
|
||||
return redirect(url_for('main.calculator'))
|
||||
|
||||
form = LoginForm()
|
||||
if form.validate_on_submit():
|
||||
try:
|
||||
email = form.email.data.strip().lower()
|
||||
password = form.password.data
|
||||
|
||||
# Log the login attempt
|
||||
current_app.logger.info(f'Login attempt for email: {email}')
|
||||
|
||||
user = User.authenticate(email, password)
|
||||
if user:
|
||||
login_user(user, remember=form.remember_me.data)
|
||||
user.last_login = datetime.now(timezone.utc)
|
||||
db.session.commit()
|
||||
current_app.logger.info(f'User {user.email} logged in successfully')
|
||||
next_page = request.args.get('next') or url_for('main.calculator')
|
||||
return redirect(next_page)
|
||||
else:
|
||||
current_app.logger.warning(f'Failed login attempt for email: {email}')
|
||||
flash('Invalid email or password', 'danger')
|
||||
except Exception as e:
|
||||
current_app.logger.error(f'Login error: {str(e)}', exc_info=True)
|
||||
flash('An error occurred during login. Please try again.', 'danger')
|
||||
|
||||
return render_template('auth/login.html', form=form)
|
||||
|
||||
@bp.route('/register', methods=['GET', 'POST'])
|
||||
@limiter.limit(lambda: None if current_app.config.get('DEBUG', False) else "10 per hour")
|
||||
def register():
|
||||
try:
|
||||
if current_user.is_authenticated:
|
||||
return redirect(url_for('main.home'))
|
||||
|
||||
form = RegistrationForm()
|
||||
logger.info('Registration form initialized')
|
||||
|
||||
if form.validate_on_submit():
|
||||
try:
|
||||
email = form.email.data.strip().lower()
|
||||
username = form.username.data.strip().lower()
|
||||
company = form.company.data.strip().upper()
|
||||
logger.info(f'Processing registration for email: {email}, username: {username}, company: {company}')
|
||||
|
||||
# Check if email is in allowed list or has adidas.com domain
|
||||
if email not in ALLOWED_EMAILS and not email.endswith('@adidas.com'):
|
||||
logger.warning(f'Registration attempt with non-allowed email: {email}')
|
||||
flash('Registration is not allowed for this email domain', 'danger')
|
||||
return render_template('auth/register.html', form=form)
|
||||
|
||||
try:
|
||||
logger.info('Creating new user...')
|
||||
user = User.create(
|
||||
username=username,
|
||||
email=email,
|
||||
company=company,
|
||||
password=form.password.data
|
||||
)
|
||||
logger.info('User created, committing to database...')
|
||||
db.session.commit()
|
||||
logger.info(f'New user registered successfully: {user.email}')
|
||||
flash('Registration successful! Please login', 'success')
|
||||
return redirect(url_for('auth.login'))
|
||||
except ValueError as e:
|
||||
logger.warning(f'Registration validation error: {str(e)}')
|
||||
flash(str(e), 'danger')
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
logger.error(f'Registration error: {str(e)}', exc_info=True)
|
||||
flash('An error occurred during registration', 'danger')
|
||||
except Exception as e:
|
||||
logger.error(f'Unexpected error during registration: {str(e)}', exc_info=True)
|
||||
flash('An unexpected error occurred', 'danger')
|
||||
elif form.errors:
|
||||
logger.warning(f'Form validation errors: {form.errors}')
|
||||
for field, errors in form.errors.items():
|
||||
for error in errors:
|
||||
flash(f'{field}: {error}', 'danger')
|
||||
logger.warning(f'Field {field} validation error: {error}')
|
||||
|
||||
logger.info('Rendering registration template')
|
||||
return render_template('auth/register.html', form=form)
|
||||
except Exception as e:
|
||||
logger.error(f'Critical error in registration route: {str(e)}', exc_info=True)
|
||||
flash('A critical error occurred', 'danger')
|
||||
return render_template('auth/register.html', form=form)
|
||||
|
||||
@bp.route('/logout')
|
||||
@login_required
|
||||
def logout():
|
||||
current_app.logger.info(f'User {current_user.email} logged out')
|
||||
logout_user()
|
||||
flash('You have been logged out.', 'info')
|
||||
return redirect(url_for('main.home'))
|
||||
|
||||
@bp.route('/request-reset', methods=['GET', 'POST'])
|
||||
@limiter.limit("3 per hour")
|
||||
def request_reset():
|
||||
if current_user.is_authenticated:
|
||||
return redirect(url_for('main.calculator'))
|
||||
|
||||
form = RequestPasswordResetForm()
|
||||
if form.validate_on_submit():
|
||||
try:
|
||||
email = form.email.data.strip().lower()
|
||||
user = User.query.filter_by(email=email).first()
|
||||
|
||||
if user:
|
||||
token = user.generate_reset_token()
|
||||
db.session.commit()
|
||||
|
||||
reset_url = url_for('auth.reset_password', token=token, _external=True)
|
||||
flash(f'Your password reset link is: {reset_url}', 'info')
|
||||
flash('This link will expire in 15 minutes.', 'warning')
|
||||
return redirect(url_for('auth.login'))
|
||||
|
||||
# Always show success message to prevent email enumeration
|
||||
flash('If an account exists with that email, you will receive password reset instructions.', 'info')
|
||||
return redirect(url_for('auth.login'))
|
||||
|
||||
except Exception as e:
|
||||
current_app.logger.error(f'Error generating reset token: {str(e)}', exc_info=True)
|
||||
flash('An error occurred while processing your request.', 'danger')
|
||||
|
||||
return render_template('auth/request_reset.html', form=form)
|
||||
|
||||
@bp.route('/reset-password/<token>', methods=['GET', 'POST'])
|
||||
@limiter.limit("3 per hour")
|
||||
def reset_password(token):
|
||||
if current_user.is_authenticated:
|
||||
return redirect(url_for('main.calculator'))
|
||||
|
||||
form = ResetPasswordForm()
|
||||
if form.validate_on_submit():
|
||||
try:
|
||||
user = User.query.filter_by(reset_token=token).first()
|
||||
if not user or not user.verify_reset_token(token):
|
||||
flash('The password reset link is invalid or has expired.', 'danger')
|
||||
return redirect(url_for('auth.request_reset'))
|
||||
|
||||
user.set_password(form.password.data)
|
||||
db.session.commit()
|
||||
current_app.logger.info(f'Password reset successful for user {user.email}')
|
||||
flash('Your password has been reset. You can now log in.', 'success')
|
||||
return redirect(url_for('auth.login'))
|
||||
|
||||
except Exception as e:
|
||||
current_app.logger.error(f'Error resetting password: {str(e)}', exc_info=True)
|
||||
flash('An error occurred while resetting your password.', 'danger')
|
||||
|
||||
return render_template('auth/reset_password.html', form=form)
|
||||
@@ -0,0 +1,383 @@
|
||||
from flask import Blueprint, render_template, redirect, url_for, request, jsonify, current_app, flash
|
||||
from flask_login import current_user, login_required, login_user, logout_user
|
||||
from app.models import Quotation, User
|
||||
from app import db
|
||||
from app.forms import LoginForm, QuotationForm
|
||||
from app.utils.cost_factor_loader import get_cost_factors
|
||||
from app.config import ALLOWED_EMAILS
|
||||
import logging
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from app import limiter, cache
|
||||
import json
|
||||
from ..utils.calculator import calculate_base_price
|
||||
from ..health import get_health_status
|
||||
|
||||
bp = Blueprint('main', __name__)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@bp.route('/', methods=['GET', 'POST'])
|
||||
def home():
|
||||
if current_user.is_authenticated:
|
||||
return redirect(url_for('main.calculator'))
|
||||
return redirect(url_for('auth.login'))
|
||||
|
||||
@bp.route('/calculator')
|
||||
@login_required
|
||||
def calculator():
|
||||
try:
|
||||
form = QuotationForm()
|
||||
|
||||
# Set today's date as default for issue_date
|
||||
if not form.issue_date.data:
|
||||
form.issue_date.data = datetime.now(timezone.utc).date()
|
||||
|
||||
# Get cost factors for the form
|
||||
cost_factors, tax_rates, default_net_base = get_cost_factors()
|
||||
|
||||
# Initialize form with default values
|
||||
if form.mold_main_type.data in form.MOLD_TYPE_MAPPING:
|
||||
form.mold_sub_type.choices = [('', 'Select Sub Type')] + [(x, x) for x in form.MOLD_TYPE_MAPPING[form.mold_main_type.data]]
|
||||
|
||||
# Get quote history with error handling
|
||||
try:
|
||||
quote_history = Quotation.query.filter_by(user_id=current_user.id)\
|
||||
.order_by(Quotation.created_at.desc())\
|
||||
.limit(5)\
|
||||
.all()
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Error fetching quote history: {str(e)}")
|
||||
quote_history = []
|
||||
|
||||
return render_template('calculator.html',
|
||||
form=form,
|
||||
quote_history=quote_history,
|
||||
tax_rates=tax_rates,
|
||||
quotation=None)
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Calculator error: {str(e)}", exc_info=True)
|
||||
flash('Error loading calculator data', 'danger')
|
||||
# Create a new form with default values
|
||||
form = QuotationForm()
|
||||
if form.mold_main_type.data in form.MOLD_TYPE_MAPPING:
|
||||
form.mold_sub_type.choices = [('', 'Select Sub Type')] + [(x, x) for x in form.MOLD_TYPE_MAPPING[form.mold_main_type.data]]
|
||||
# Set today's date as default even in error case
|
||||
form.issue_date.data = datetime.now(timezone.utc).date()
|
||||
return render_template('calculator.html',
|
||||
form=form,
|
||||
quote_history=[],
|
||||
tax_rates={},
|
||||
quotation=None)
|
||||
|
||||
@bp.route('/calculate', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
@limiter.limit("30 per minute")
|
||||
@cache.cached(timeout=300, key_prefix='calculate_form')
|
||||
def calculate():
|
||||
"""Calculate mold cost based on form data"""
|
||||
try:
|
||||
form = QuotationForm()
|
||||
|
||||
# Log incoming request data
|
||||
current_app.logger.info('Incoming request data:')
|
||||
current_app.logger.info(f'Request method: {request.method}')
|
||||
current_app.logger.info(f'Request form data: {dict(request.form)}')
|
||||
current_app.logger.info(f'Request JSON data: {request.get_json(silent=True)}')
|
||||
|
||||
if request.method == 'POST':
|
||||
try:
|
||||
# Log form data before validation
|
||||
current_app.logger.info('Form data before validation:')
|
||||
current_app.logger.info(f'Form data: {form.data}')
|
||||
current_app.logger.info(f'Form errors: {form.errors}')
|
||||
|
||||
# Log the selected mold type
|
||||
current_app.logger.info(f'Selected mold_main_type: {form.mold_main_type.data}')
|
||||
current_app.logger.info(f'Selected mold_sub_type: {form.mold_sub_type.data}')
|
||||
current_app.logger.info(f'Selected mold_specific_type: {form.mold_specific_type.data}')
|
||||
|
||||
# Update mold_sub_type choices based on selected mold_main_type
|
||||
main_type = request.form.get('mold_main_type')
|
||||
current_app.logger.info(f'Updating sub_type choices for main_type: {main_type}')
|
||||
if main_type in form.MOLD_TYPE_MAPPING:
|
||||
form.mold_sub_type.choices = [('', 'Select Sub Type')] + [(x, x) for x in form.MOLD_TYPE_MAPPING[main_type]]
|
||||
current_app.logger.info(f'Updated sub_type choices: {form.mold_sub_type.choices}')
|
||||
|
||||
# Update mold_specific_type choices based on selected mold_sub_type
|
||||
sub_type = request.form.get('mold_sub_type')
|
||||
if sub_type in form.MOLD_SPECIFIC_TYPE_OPTIONS:
|
||||
form.mold_specific_type.choices = [('', 'Select Specific Type')] + [(x, x) for x in form.MOLD_SPECIFIC_TYPE_OPTIONS[sub_type]]
|
||||
else:
|
||||
form.mold_specific_type.choices = [('', 'Select Specific Type')]
|
||||
|
||||
# Check if specific type is needed
|
||||
if sub_type and sub_type in form.MOLD_SPECIFIC_TYPE_OPTIONS:
|
||||
# If specific type is needed but not provided, set it to empty string
|
||||
if not request.form.get('mold_specific_type'):
|
||||
form.mold_specific_type.data = ''
|
||||
else:
|
||||
# If specific type is not needed, set it to empty string
|
||||
form.mold_specific_type.data = ''
|
||||
|
||||
if not form.validate_on_submit():
|
||||
current_app.logger.warning('Form validation failed:')
|
||||
current_app.logger.warning(f'Form errors: {form.errors}')
|
||||
current_app.logger.warning(f'Form data after validation: {form.data}')
|
||||
current_app.logger.warning(f'Request form data: {dict(request.form)}')
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'Invalid form data',
|
||||
'message': 'Please check the form for errors',
|
||||
'errors': form.errors,
|
||||
'form_data': dict(request.form)
|
||||
}), 400
|
||||
|
||||
# Get form data
|
||||
form_data = form.data
|
||||
current_app.logger.info(f'Processing form data: {form_data}')
|
||||
|
||||
# Calculate costs
|
||||
try:
|
||||
result = calculate_base_price(
|
||||
mold_main_type=form.mold_main_type.data,
|
||||
mold_sub_type=form.mold_sub_type.data,
|
||||
mold_specific_type=form.mold_specific_type.data,
|
||||
sliders_count=int(form.sliders_count.data),
|
||||
cavity_count=float(form.cavity_count.data),
|
||||
digital_texture_type=form.digital_texture_type.data,
|
||||
complexity_high_sidewall=(form.complexity_high_sidewall.data == 'yes')
|
||||
)
|
||||
current_app.logger.info(f'Calculation result: {result}')
|
||||
|
||||
# Get cost factors for tax rate
|
||||
cost_factors, tax_rates, default_net_base = get_cost_factors()
|
||||
|
||||
# Get tax rate for the country
|
||||
tax_rate = tax_rates.get(form.mold_shop_country.data, 0.0)
|
||||
|
||||
# Calculate total cost (base price + tax)
|
||||
total_cost = result['total_cost'] * (1 + tax_rate)
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'base_price': float(result['base_price']),
|
||||
'complexity_cost': float(result['complexity_cost']),
|
||||
'texture_cost': float(result['texture_cost']),
|
||||
'tax_rate': float(tax_rate),
|
||||
'total_cost': float(total_cost),
|
||||
'mold_shop_name': form.mold_shop_name.data,
|
||||
'mold_shop_country': form.mold_shop_country.data,
|
||||
't1_shoe_factory_name': form.t1_shoe_factory_name.data,
|
||||
't1_shoe_factory_country': form.t1_shoe_factory_country.data,
|
||||
't2_component_factory_name': form.t2_component_factory_name.data,
|
||||
't2_component_factory_country': form.t2_component_factory_country.data,
|
||||
'model_name': form.model_name.data,
|
||||
'tooling_id': form.tooling_id.data,
|
||||
'season': form.season.data,
|
||||
'stage': form.stage.data,
|
||||
'type': form.type.data,
|
||||
'issue_date': form.issue_date.data.strftime('%Y-%m-%d'),
|
||||
'approved_by_mold_shop': form.approved_by_mold_shop.data,
|
||||
'approved_by_t1_tooling': form.approved_by_t1_tooling.data,
|
||||
'approved_by_lo_tooling': form.approved_by_lo_tooling.data,
|
||||
'mold_main_type': form.mold_main_type.data,
|
||||
'mold_sub_type': form.mold_sub_type.data,
|
||||
'mold_specific_type': form.mold_specific_type.data,
|
||||
'complexity_high_sidewall': 'yes' if form.complexity_high_sidewall.data == 'yes' else 'no',
|
||||
'sliders_count': form.sliders_count.data,
|
||||
'cavity_count': form.cavity_count.data,
|
||||
'digital_texture_type': form.digital_texture_type.data
|
||||
})
|
||||
except Exception as calc_error:
|
||||
current_app.logger.error(f'Error calculating costs: {str(calc_error)}', exc_info=True)
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'Calculation error',
|
||||
'message': str(calc_error)
|
||||
}), 500
|
||||
|
||||
except Exception as form_error:
|
||||
current_app.logger.error(f'Error processing form: {str(form_error)}', exc_info=True)
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'Form processing error',
|
||||
'message': str(form_error)
|
||||
}), 500
|
||||
|
||||
# GET request - render the form
|
||||
return render_template('calculator.html', form=form)
|
||||
|
||||
except Exception as e:
|
||||
current_app.logger.error(f'Unexpected error in calculate route: {str(e)}', exc_info=True)
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'Unexpected error',
|
||||
'message': str(e)
|
||||
}), 500
|
||||
|
||||
@bp.route('/quotations')
|
||||
@login_required
|
||||
@limiter.limit("30 per minute")
|
||||
@cache.cached(timeout=60, key_prefix='user_quotations')
|
||||
def quotations():
|
||||
quotations = Quotation.query.filter_by(user_id=current_user.id)\
|
||||
.order_by(Quotation.created_at.desc()).all()
|
||||
return render_template('quotations.html', quotations=quotations)
|
||||
|
||||
@bp.route('/quotation/<int:id>')
|
||||
@login_required
|
||||
@limiter.limit("30 per minute")
|
||||
@cache.cached(timeout=60, key_prefix='quotation_detail')
|
||||
def quotation_detail(id):
|
||||
quotation = Quotation.query.get_or_404(id)
|
||||
if quotation.user_id != current_user.id and not current_user.is_admin:
|
||||
flash('Access denied.', 'danger')
|
||||
return redirect(url_for('main.quotations'))
|
||||
return render_template('quotation_detail.html', quotation=quotation)
|
||||
|
||||
@bp.route('/health')
|
||||
def health_check():
|
||||
"""Health check endpoint that returns the status of various system components."""
|
||||
# Get health status
|
||||
health_status = get_health_status()
|
||||
|
||||
# Set appropriate status code based on overall health
|
||||
status_code = 200 if health_status['status'] == 'healthy' else 503
|
||||
|
||||
# Add rate limiting headers
|
||||
response = jsonify(health_status)
|
||||
response.headers['Cache-Control'] = 'no-cache, no-store, must-revalidate'
|
||||
response.headers['Pragma'] = 'no-cache'
|
||||
response.headers['Expires'] = '0'
|
||||
|
||||
return response, status_code
|
||||
|
||||
@bp.route('/api/recent-quotes')
|
||||
@login_required
|
||||
@limiter.limit("30 per minute")
|
||||
def recent_quotes():
|
||||
try:
|
||||
recent_quotes = Quotation.query.filter_by(user_id=current_user.id)\
|
||||
.order_by(Quotation.created_at.desc())\
|
||||
.limit(5)\
|
||||
.all()
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'quotes': [{
|
||||
'id': quote.id,
|
||||
'model_name': quote.model_name,
|
||||
'tooling_id': quote.tooling_id,
|
||||
'mold_sub_type': quote.mold_sub_type,
|
||||
'cavity_count': float(quote.cavity_count),
|
||||
'sliders_count': quote.sliders_count,
|
||||
'created_at': quote.created_at.strftime('%Y-%m-%d %H:%M'),
|
||||
'mold_shop_country': quote.mold_shop_country,
|
||||
'digital_texture_type': quote.digital_texture_type,
|
||||
'season': quote.season,
|
||||
'stage': quote.stage,
|
||||
'base_price': float(quote.base_price),
|
||||
'tax_rate': float(quote.tax_rate) if quote.tax_rate is not None else 0.0,
|
||||
'total_cost': float(quote.total_cost)
|
||||
} for quote in recent_quotes]
|
||||
})
|
||||
except Exception as e:
|
||||
current_app.logger.error(f'Error fetching recent quotes: {str(e)}', exc_info=True)
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'An error occurred while fetching recent quotes',
|
||||
'quotes': []
|
||||
}), 500
|
||||
|
||||
@bp.route('/save-quotation', methods=['POST'])
|
||||
@login_required
|
||||
@limiter.limit("30 per minute")
|
||||
def save_quotation():
|
||||
"""Save a quotation to the database"""
|
||||
try:
|
||||
# Get form data
|
||||
form_data = request.get_json()
|
||||
|
||||
if not form_data:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'No data provided'
|
||||
}), 400
|
||||
|
||||
# Check for duplicate submission (same user, same data within last 30 seconds)
|
||||
try:
|
||||
recent_duplicate = Quotation.query.filter(
|
||||
Quotation.user_id == current_user.id,
|
||||
Quotation.model_name == form_data.get('model_name'),
|
||||
Quotation.tooling_id == form_data.get('tooling_id'),
|
||||
Quotation.mold_main_type == form_data.get('mold_main_type'),
|
||||
Quotation.mold_sub_type == form_data.get('mold_sub_type'),
|
||||
Quotation.created_at >= datetime.now(timezone.utc) - timedelta(seconds=30)
|
||||
).first()
|
||||
|
||||
if recent_duplicate:
|
||||
current_app.logger.warning(f'Duplicate submission detected for user {current_user.id}, tooling_id {form_data.get("tooling_id")}')
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'Duplicate submission',
|
||||
'message': 'This quotation was already submitted recently. Please wait a moment before trying again.',
|
||||
'quotation_id': recent_duplicate.id
|
||||
}), 409
|
||||
except Exception as duplicate_check_error:
|
||||
current_app.logger.error(f'Error checking for duplicates: {str(duplicate_check_error)}')
|
||||
# Continue with submission even if duplicate check fails
|
||||
|
||||
# Ensure mold_specific_type is not empty or missing
|
||||
mold_specific_type = form_data.get('mold_specific_type')
|
||||
if not mold_specific_type:
|
||||
mold_specific_type = 'N/A'
|
||||
|
||||
# Create new quotation
|
||||
quotation = Quotation(
|
||||
user_id=current_user.id,
|
||||
mold_shop_name=form_data.get('mold_shop_name'),
|
||||
mold_shop_country=form_data.get('mold_shop_country'),
|
||||
t1_shoe_factory_name=form_data.get('t1_shoe_factory_name'),
|
||||
t1_shoe_factory_country=form_data.get('t1_shoe_factory_country'),
|
||||
t2_component_factory_name=form_data.get('t2_component_factory_name'),
|
||||
t2_component_factory_country=form_data.get('t2_component_factory_country'),
|
||||
model_name=form_data.get('model_name'),
|
||||
tooling_id=form_data.get('tooling_id'),
|
||||
season=form_data.get('season'),
|
||||
stage=form_data.get('stage'),
|
||||
type=form_data.get('type'),
|
||||
issue_date=datetime.strptime(form_data.get('issue_date'), '%Y-%m-%d').date(),
|
||||
approved_by_mold_shop=form_data.get('approved_by_mold_shop'),
|
||||
approved_by_t1_tooling=form_data.get('approved_by_t1_tooling'),
|
||||
approved_by_lo_tooling=form_data.get('approved_by_lo_tooling'),
|
||||
mold_main_type=form_data.get('mold_main_type'),
|
||||
mold_sub_type=form_data.get('mold_sub_type'),
|
||||
mold_specific_type=mold_specific_type,
|
||||
complexity_high_sidewall=(form_data.get('complexity_high_sidewall') == 'yes'),
|
||||
sliders_count=int(form_data.get('sliders_count')),
|
||||
cavity_count=float(form_data.get('cavity_count')),
|
||||
digital_texture_type=form_data.get('digital_texture_type'),
|
||||
base_price=float(form_data.get('base_price')),
|
||||
tax_rate=float(form_data.get('tax_rate')),
|
||||
total_cost=float(form_data.get('total_cost'))
|
||||
)
|
||||
|
||||
# Save to database
|
||||
db.session.add(quotation)
|
||||
db.session.commit()
|
||||
|
||||
current_app.logger.info(f'Quotation saved with ID: {quotation.id}')
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'quotation_id': quotation.id,
|
||||
'message': 'Quotation saved successfully'
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
current_app.logger.error(f'Error saving quotation: {str(e)}', exc_info=True)
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'Failed to save quotation',
|
||||
'message': str(e)
|
||||
}), 500
|
||||
@@ -0,0 +1,197 @@
|
||||
/* Admin Panel Global Styles */
|
||||
:root {
|
||||
--primary-color: #007bff;
|
||||
--secondary-color: #6c757d;
|
||||
--success-color: #28a745;
|
||||
--info-color: #17a2b8;
|
||||
--warning-color: #ffc107;
|
||||
--danger-color: #dc3545;
|
||||
--light-color: #f8f9fa;
|
||||
--dark-color: #343a40;
|
||||
}
|
||||
|
||||
/* Layout */
|
||||
.admin-container {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.admin-card {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.admin-card-header {
|
||||
padding: 1rem 1.5rem;
|
||||
border-bottom: 1px solid rgba(0,0,0,0.1);
|
||||
background-color: var(--light-color);
|
||||
border-radius: 8px 8px 0 0;
|
||||
}
|
||||
|
||||
.admin-card-body {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
/* Tables */
|
||||
.admin-table {
|
||||
width: 100%;
|
||||
margin-bottom: 1rem;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.admin-table th,
|
||||
.admin-table td {
|
||||
padding: 0.75rem;
|
||||
vertical-align: middle;
|
||||
border-top: 1px solid #dee2e6;
|
||||
}
|
||||
|
||||
.admin-table thead th {
|
||||
vertical-align: bottom;
|
||||
border-bottom: 2px solid #dee2e6;
|
||||
background-color: var(--light-color);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Forms */
|
||||
.admin-form-group {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.admin-form-control {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 0.375rem 0.75rem;
|
||||
font-size: 1rem;
|
||||
line-height: 1.5;
|
||||
color: #495057;
|
||||
background-color: #fff;
|
||||
border: 1px solid #ced4da;
|
||||
border-radius: 0.25rem;
|
||||
transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.admin-btn {
|
||||
display: inline-block;
|
||||
font-weight: 400;
|
||||
text-align: center;
|
||||
vertical-align: middle;
|
||||
user-select: none;
|
||||
padding: 0.375rem 0.75rem;
|
||||
font-size: 1rem;
|
||||
line-height: 1.5;
|
||||
border-radius: 0.25rem;
|
||||
transition: color 0.15s ease-in-out, background-color 0.15s ease-in-out, border-color 0.15s ease-in-out;
|
||||
}
|
||||
|
||||
.admin-btn-primary {
|
||||
color: #fff;
|
||||
background-color: var(--primary-color);
|
||||
border: 1px solid var(--primary-color);
|
||||
}
|
||||
|
||||
.admin-btn-secondary {
|
||||
color: #fff;
|
||||
background-color: var(--secondary-color);
|
||||
border: 1px solid var(--secondary-color);
|
||||
}
|
||||
|
||||
.admin-btn-info {
|
||||
color: #fff;
|
||||
background-color: var(--info-color);
|
||||
border: 1px solid var(--info-color);
|
||||
}
|
||||
|
||||
.admin-btn-sm {
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.5;
|
||||
border-radius: 0.2rem;
|
||||
}
|
||||
|
||||
/* Stats Cards */
|
||||
.admin-stats-card {
|
||||
padding: 1.5rem;
|
||||
border-radius: 8px;
|
||||
background: white;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.admin-stats-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.admin-stats-number {
|
||||
font-size: 2rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.admin-stats-label {
|
||||
color: var(--secondary-color);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
/* Filters */
|
||||
.admin-filters {
|
||||
margin-bottom: 1.5rem;
|
||||
padding: 1rem;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.admin-filter-form {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 1rem;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
/* Pagination */
|
||||
.admin-pagination {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.admin-pagination-link {
|
||||
padding: 0.375rem 0.75rem;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 0.25rem;
|
||||
color: var(--primary-color);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.admin-pagination-link:hover {
|
||||
background-color: var(--light-color);
|
||||
}
|
||||
|
||||
.admin-pagination-info {
|
||||
color: var(--secondary-color);
|
||||
padding: 0.375rem 0.75rem;
|
||||
}
|
||||
|
||||
/* Responsive Design */
|
||||
@media (max-width: 768px) {
|
||||
.admin-filter-form {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.admin-table {
|
||||
display: block;
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
}
|
||||
+2078
File diff suppressed because it is too large
Load Diff
+7
File diff suppressed because one or more lines are too long
@@ -0,0 +1,91 @@
|
||||
/* Calculator Page Styles */
|
||||
.container-main {
|
||||
max-width: 1200px;
|
||||
margin: 2rem auto;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
.calculator-card {
|
||||
background-color: #ffffff;
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
|
||||
}
|
||||
|
||||
.section-header {
|
||||
background-color: #f8f9fa;
|
||||
padding: 1rem;
|
||||
border-radius: 0.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
border-left: 4px solid #0d6efd;
|
||||
}
|
||||
|
||||
.section-content {
|
||||
background-color: #ffffff;
|
||||
padding: 1.5rem;
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
color: #495057;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.form-select, .form-control {
|
||||
border: 1px solid #ced4da;
|
||||
}
|
||||
|
||||
.form-select:focus, .form-control:focus {
|
||||
border-color: #86b7fe;
|
||||
box-shadow: 0 0 0 0.25rem rgba(13, 110, 253, 0.25);
|
||||
}
|
||||
|
||||
/* Table Styles */
|
||||
.table {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.table th {
|
||||
background-color: #f8f9fa;
|
||||
font-weight: 600;
|
||||
color: #495057;
|
||||
}
|
||||
|
||||
.table td {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* Button Styles */
|
||||
.btn-primary {
|
||||
background-color: #0d6efd;
|
||||
border-color: #0d6efd;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background-color: #0b5ed7;
|
||||
border-color: #0a58ca;
|
||||
}
|
||||
|
||||
/* Form Text */
|
||||
.form-text {
|
||||
color: #6c757d;
|
||||
font-size: 0.875rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
/* Responsive Adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.container-main {
|
||||
margin: 1rem auto;
|
||||
}
|
||||
|
||||
.section-content {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.col-md-3 {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
}
|
||||
Executable
+144
@@ -0,0 +1,144 @@
|
||||
:root {
|
||||
--primary-color: #2563eb;
|
||||
--secondary-color: #3b82f6;
|
||||
--success-color: #22c55e;
|
||||
--warning-color: #f59e0b;
|
||||
--error-color: #ef4444;
|
||||
--text-primary: #1e293b;
|
||||
--text-secondary: #64748b;
|
||||
--bg-light: #f8fafc;
|
||||
}
|
||||
|
||||
/* Base Styles */
|
||||
body {
|
||||
font-family: 'Inter', system-ui, -apple-system, sans-serif;
|
||||
color: var(--text-primary);
|
||||
background-color: var(--bg-light);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.container-main {
|
||||
max-width: 1280px;
|
||||
margin: 2rem auto;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
/* Responsive Navigation */
|
||||
.navbar-brand {
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.025em;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
transition: all 0.2s ease;
|
||||
padding: 0.5rem 1rem !important;
|
||||
border-radius: 0.375rem;
|
||||
}
|
||||
|
||||
.nav-link:hover {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
/* Calculator Form */
|
||||
.calculator-card {
|
||||
background: white;
|
||||
border-radius: 1rem;
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||
padding: 2rem;
|
||||
margin: 2rem 0;
|
||||
}
|
||||
|
||||
.input-group-3d {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.input-group-3d input,
|
||||
.input-group-3d select {
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.875rem 1rem;
|
||||
transition: all 0.2s ease;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.input-group-3d input:focus,
|
||||
.input-group-3d select:focus {
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
|
||||
}
|
||||
|
||||
/* Result Panel */
|
||||
.result-card {
|
||||
border-left: 4px solid var(--success-color);
|
||||
background: white;
|
||||
border-radius: 0.75rem;
|
||||
padding: 1.5rem;
|
||||
margin-top: 2rem;
|
||||
animation: slideIn 0.3s ease;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from { opacity: 0; transform: translateY(10px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
/* Mobile Optimization */
|
||||
@media (max-width: 768px) {
|
||||
.calculator-card {
|
||||
margin: 1rem -0.5rem;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.navbar-collapse {
|
||||
margin-top: 1rem;
|
||||
padding: 1rem;
|
||||
background: var(--primary-color);
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
建议配合以下CSS样式(添加到base模板中):
|
||||
|
||||
|
||||
.input-group-text {
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
.progress-bar {
|
||||
transition: width 0.5s ease, background-color 0.3s ease;
|
||||
}
|
||||
|
||||
/* Cost Breakdown Visual Enhancement */
|
||||
.cost-breakdown {
|
||||
background: #fff;
|
||||
border-radius: 1rem;
|
||||
box-shadow: 0 2px 12px 0 rgba(37,99,235,0.08);
|
||||
padding: 2rem 1.5rem 1.5rem 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
.cost-breakdown .cost-item {
|
||||
border: 1.5px solid #e0e7ef;
|
||||
box-shadow: 0 1px 4px 0 rgba(37,99,235,0.04);
|
||||
transition: box-shadow 0.2s, border-color 0.2s;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.cost-breakdown .cost-item:hover {
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 4px 16px 0 rgba(37,99,235,0.10);
|
||||
background: #f1f5fd;
|
||||
}
|
||||
.cost-breakdown .h4.text-primary {
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.cost-breakdown .small.text-muted {
|
||||
font-size: 0.98rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.cost-breakdown .total-cost {
|
||||
background: linear-gradient(90deg, #e0e7ef 0%, #f8fafc 100%);
|
||||
border-width: 2.5px;
|
||||
box-shadow: 0 2px 8px 0 rgba(37,99,235,0.10);
|
||||
}
|
||||
.cost-breakdown .total-cost span {
|
||||
letter-spacing: 1.5px;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- Background circle -->
|
||||
<circle cx="16" cy="16" r="16" fill="#2C3E50"/>
|
||||
|
||||
<!-- Calculator body -->
|
||||
<rect x="8" y="6" width="16" height="20" rx="2" fill="#3498DB"/>
|
||||
|
||||
<!-- Calculator screen -->
|
||||
<rect x="10" y="8" width="12" height="4" rx="1" fill="#ECF0F1"/>
|
||||
|
||||
<!-- Calculator buttons -->
|
||||
<rect x="10" y="14" width="3" height="3" rx="1" fill="#ECF0F1"/>
|
||||
<rect x="15" y="14" width="3" height="3" rx="1" fill="#ECF0F1"/>
|
||||
<rect x="20" y="14" width="3" height="3" rx="1" fill="#ECF0F1"/>
|
||||
|
||||
<rect x="10" y="19" width="3" height="3" rx="1" fill="#ECF0F1"/>
|
||||
<rect x="15" y="19" width="3" height="3" rx="1" fill="#ECF0F1"/>
|
||||
<rect x="20" y="19" width="3" height="3" rx="1" fill="#ECF0F1"/>
|
||||
|
||||
<rect x="10" y="24" width="3" height="3" rx="1" fill="#ECF0F1"/>
|
||||
<rect x="15" y="24" width="3" height="3" rx="1" fill="#ECF0F1"/>
|
||||
<rect x="20" y="24" width="3" height="3" rx="1" fill="#ECF0F1"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
Executable
BIN
Binary file not shown.
Executable
BIN
Binary file not shown.
+7
File diff suppressed because one or more lines are too long
Vendored
+2
File diff suppressed because one or more lines are too long
Vendored
+2607
File diff suppressed because one or more lines are too long
Executable
+638
@@ -0,0 +1,638 @@
|
||||
// static/js/mold_calculator.js
|
||||
// Check if script is already initialized
|
||||
if (window.moldCalculatorInitialized) {
|
||||
console.log('Mold calculator already initialized, skipping...');
|
||||
} else {
|
||||
window.moldCalculatorInitialized = true;
|
||||
|
||||
// Wait for DOM to be fully loaded
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', initializeCalculator);
|
||||
} else {
|
||||
initializeCalculator();
|
||||
}
|
||||
}
|
||||
|
||||
function initializeCalculator() {
|
||||
console.log('DOM Content Loaded - Initializing mold calculator');
|
||||
|
||||
// Get form elements
|
||||
const form = document.getElementById('calcForm');
|
||||
const mainSelect = document.getElementById('mold_main_type');
|
||||
const subSelect = document.getElementById('mold_sub_type');
|
||||
const specificTypeSelect = document.getElementById('mold_specific_type');
|
||||
const stageSelect = document.getElementById('stage');
|
||||
const typeSelect = document.getElementById('type');
|
||||
const toolingIdInput = document.getElementById('tooling_id');
|
||||
const submitButton = document.getElementById('calculateBtn');
|
||||
|
||||
console.log('Main select element:', mainSelect);
|
||||
console.log('Sub select element:', subSelect);
|
||||
console.log('Specific type select element:', specificTypeSelect);
|
||||
console.log('Stage select element:', stageSelect);
|
||||
console.log('Type select element:', typeSelect);
|
||||
console.log('Tooling ID input:', toolingIdInput);
|
||||
console.log('Submit button:', submitButton);
|
||||
|
||||
// Check if submit button exists
|
||||
if (!submitButton) {
|
||||
console.error('Submit button not found! Looking for element with ID "calculateBtn"');
|
||||
// Try alternative selectors
|
||||
const alternativeButton = form.querySelector('button[type="submit"]');
|
||||
if (alternativeButton) {
|
||||
console.log('Found submit button using alternative selector:', alternativeButton);
|
||||
} else {
|
||||
console.error('No submit button found with any selector!');
|
||||
return; // Exit initialization if we can't find the submit button
|
||||
}
|
||||
}
|
||||
|
||||
// Define mappings
|
||||
const moldTypeMapping = {
|
||||
'Rubber': ['Flat Outsole', 'Cupsole', 'Rubber Sole'],
|
||||
'CMEVA': ['2 Plates Mold', '3 Plates Mold', '2 Plates with in-mold channel', 'Breathable + in-mold channel'],
|
||||
'IMEVA': ['2 Plates Mold', '3 Plates Mold'],
|
||||
'Sandal': ['Sandal with Upper Bandage', '2 Plate without Upper Bandage'],
|
||||
'Co-shot mold': ['Co-shot mold', 'Foaming mold'],
|
||||
'PU': ['2 Plates Mold', '3 Plates Mold']
|
||||
};
|
||||
|
||||
const specificTypeOptions = {
|
||||
'Flat Outsole': ['Flat', 'Flat with Top/Heel Tip'],
|
||||
'Cupsole': ['Top PL', 'Visible PL'],
|
||||
'Rubber Sole': ['Top PL', 'Short PL (Heel/Forefoot)', 'Visible PL', 'Wave PL']
|
||||
};
|
||||
|
||||
const stageTypeMapping = {
|
||||
'CR0': ['Sample'],
|
||||
'CR1': ['Sample'],
|
||||
'CR2': ['Sample'],
|
||||
'SMS': ['A Set'],
|
||||
'commercialization': ['A Set'],
|
||||
'production': ['Additional']
|
||||
};
|
||||
|
||||
// Function to update sub-type options
|
||||
function updateSubTypeOptions() {
|
||||
console.log('Populating sub-types');
|
||||
const selectedType = mainSelect.value;
|
||||
console.log('Selected type:', selectedType);
|
||||
|
||||
// Clear current options
|
||||
subSelect.innerHTML = '<option value="">Select Sub Type</option>';
|
||||
|
||||
if (selectedType && moldTypeMapping[selectedType]) {
|
||||
console.log('Found mapping for type:', selectedType);
|
||||
const options = moldTypeMapping[selectedType];
|
||||
console.log('Adding options:', options);
|
||||
|
||||
options.forEach(option => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = option;
|
||||
opt.textContent = option;
|
||||
subSelect.appendChild(opt);
|
||||
});
|
||||
}
|
||||
|
||||
// Reset specific type
|
||||
specificTypeSelect.innerHTML = '<option value="">Select Specific Type</option>';
|
||||
specificTypeSelect.disabled = true;
|
||||
}
|
||||
|
||||
// Function to update specific type options
|
||||
function updateSpecificTypeOptions() {
|
||||
const selectedSubType = subSelect.value;
|
||||
console.log('Selected sub-type:', selectedSubType);
|
||||
|
||||
// Clear current options
|
||||
specificTypeSelect.innerHTML = '<option value="">Select Specific Type</option>';
|
||||
|
||||
if (selectedSubType && specificTypeOptions[selectedSubType]) {
|
||||
console.log('Found specific type options for:', selectedSubType);
|
||||
const options = specificTypeOptions[selectedSubType];
|
||||
console.log('Adding specific type options:', options);
|
||||
|
||||
options.forEach(option => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = option;
|
||||
opt.textContent = option;
|
||||
specificTypeSelect.appendChild(opt);
|
||||
});
|
||||
|
||||
specificTypeSelect.disabled = false;
|
||||
} else {
|
||||
specificTypeSelect.disabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Function to update type options based on stage
|
||||
function updateTypeOptions() {
|
||||
const selectedStage = stageSelect.value;
|
||||
console.log('Selected stage:', selectedStage);
|
||||
|
||||
// Clear current options
|
||||
typeSelect.innerHTML = '<option value="">Select Type</option>';
|
||||
|
||||
if (selectedStage && stageTypeMapping[selectedStage]) {
|
||||
const options = stageTypeMapping[selectedStage];
|
||||
console.log('Adding type options:', options);
|
||||
|
||||
options.forEach(option => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = option;
|
||||
opt.textContent = option;
|
||||
typeSelect.appendChild(opt);
|
||||
});
|
||||
|
||||
// Enable the type select
|
||||
typeSelect.disabled = false;
|
||||
} else {
|
||||
// Disable the type select if no stage is selected
|
||||
typeSelect.disabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Add event listeners
|
||||
mainSelect.addEventListener('change', function() {
|
||||
console.log('Main type changed:', this.value);
|
||||
updateSubTypeOptions();
|
||||
});
|
||||
|
||||
subSelect.addEventListener('change', function() {
|
||||
console.log('Sub type changed:', this.value);
|
||||
updateSpecificTypeOptions();
|
||||
});
|
||||
|
||||
stageSelect.addEventListener('change', function() {
|
||||
console.log('Stage changed:', this.value);
|
||||
updateTypeOptions();
|
||||
});
|
||||
|
||||
// Add tooling ID validation
|
||||
if (toolingIdInput) {
|
||||
console.log('Tooling ID input found, adding validation');
|
||||
toolingIdInput.addEventListener('input', function(e) {
|
||||
console.log('Tooling ID input event triggered, value:', this.value);
|
||||
// Remove any non-numeric characters
|
||||
let value = this.value.replace(/\D/g, '');
|
||||
|
||||
// Limit to 5 digits
|
||||
if (value.length > 5) {
|
||||
value = value.substring(0, 5);
|
||||
}
|
||||
|
||||
this.value = value;
|
||||
console.log('Tooling ID value after validation:', this.value);
|
||||
});
|
||||
|
||||
toolingIdInput.addEventListener('paste', function(e) {
|
||||
console.log('Tooling ID paste event triggered');
|
||||
e.preventDefault();
|
||||
const pastedText = (e.clipboardData || window.clipboardData).getData('text');
|
||||
const numericOnly = pastedText.replace(/\D/g, '').substring(0, 5);
|
||||
this.value = numericOnly;
|
||||
console.log('Tooling ID value after paste validation:', this.value);
|
||||
});
|
||||
} else {
|
||||
console.error('Tooling ID input element not found!');
|
||||
// Try alternative selectors
|
||||
const alternativeSelectors = [
|
||||
'input[name="tooling_id"]',
|
||||
'input[placeholder*="tooling"]',
|
||||
'input[placeholder*="Tooling"]'
|
||||
];
|
||||
|
||||
for (let selector of alternativeSelectors) {
|
||||
const element = document.querySelector(selector);
|
||||
if (element) {
|
||||
console.log('Found tooling ID input using selector:', selector, element);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize options
|
||||
updateSubTypeOptions();
|
||||
updateTypeOptions();
|
||||
|
||||
// Function to reset form
|
||||
function resetForm(form) {
|
||||
console.log('Resetting form...');
|
||||
|
||||
// Reset all select elements to their first option
|
||||
const selects = form.querySelectorAll('select');
|
||||
selects.forEach(select => {
|
||||
select.selectedIndex = 0;
|
||||
select.dispatchEvent(new Event('change'));
|
||||
});
|
||||
|
||||
// Reset all input elements
|
||||
const inputs = form.querySelectorAll('input');
|
||||
inputs.forEach(input => {
|
||||
input.value = '';
|
||||
});
|
||||
|
||||
// Reset all checkboxes and radio buttons
|
||||
const checkboxes = form.querySelectorAll('input[type="checkbox"], input[type="radio"]');
|
||||
checkboxes.forEach(checkbox => {
|
||||
checkbox.checked = false;
|
||||
});
|
||||
|
||||
// Disable dependent dropdowns
|
||||
if (subSelect) subSelect.disabled = true;
|
||||
if (specificTypeSelect) specificTypeSelect.disabled = true;
|
||||
if (typeSelect) typeSelect.disabled = true;
|
||||
|
||||
// Show reset notification
|
||||
const notification = document.createElement('div');
|
||||
notification.className = 'alert alert-info alert-dismissible fade show mt-3';
|
||||
notification.innerHTML = `
|
||||
<i class="fas fa-info-circle me-2"></i>
|
||||
Form has been reset. You can now create a new quotation.
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
`;
|
||||
form.insertBefore(notification, form.firstChild);
|
||||
|
||||
console.log('Form reset complete');
|
||||
}
|
||||
|
||||
// Handle form submission
|
||||
form.addEventListener('submit', async function(e) {
|
||||
e.preventDefault();
|
||||
console.log('Form submitted');
|
||||
|
||||
// Get submit button robustly
|
||||
let submitButton = document.getElementById('calculateBtn') || form.querySelector('button[type="submit"]');
|
||||
|
||||
if (!submitButton) {
|
||||
console.error('Submit button not found during form submission!');
|
||||
alert('Error: Submit button not found. Please refresh the page and try again.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if submission is already in progress
|
||||
if (submitButton.disabled || form.dataset.submitting === 'true') {
|
||||
console.log('Form submission already in progress or cooldown active, ignoring duplicate click');
|
||||
return;
|
||||
}
|
||||
|
||||
// Set submission state
|
||||
form.dataset.submitting = 'true';
|
||||
|
||||
// Disable submit button and show loading state
|
||||
submitButton.disabled = true;
|
||||
const originalButtonText = submitButton.innerHTML;
|
||||
submitButton.innerHTML = '<i class="fas fa-spinner fa-spin me-2"></i>Calculating...';
|
||||
submitButton.classList.add('btn-secondary');
|
||||
submitButton.classList.remove('btn-primary');
|
||||
submitButton.title = 'Please wait while calculating...';
|
||||
|
||||
try {
|
||||
// Log all form fields
|
||||
console.log('Form fields:');
|
||||
for (let [key, value] of new FormData(form).entries()) {
|
||||
console.log(`${key}: ${value}`);
|
||||
}
|
||||
|
||||
// Validate required fields
|
||||
const mainType = mainSelect.value;
|
||||
const subType = subSelect.value;
|
||||
const specificType = specificTypeSelect.value;
|
||||
const stage = stageSelect.value;
|
||||
const type = typeSelect.value;
|
||||
const toolingId = toolingIdInput ? toolingIdInput.value : '';
|
||||
|
||||
console.log('Required fields:', {
|
||||
mainType,
|
||||
subType,
|
||||
specificType,
|
||||
stage,
|
||||
type,
|
||||
toolingId
|
||||
});
|
||||
|
||||
if (!mainType) {
|
||||
alert('Please select a main type');
|
||||
return;
|
||||
}
|
||||
if (!subType) {
|
||||
alert('Please select a sub type');
|
||||
return;
|
||||
}
|
||||
if (!stage) {
|
||||
alert('Please select a stage');
|
||||
return;
|
||||
}
|
||||
if (!type) {
|
||||
alert('Please select a type');
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate tooling ID
|
||||
if (!toolingId) {
|
||||
alert('Please enter a Tooling ID');
|
||||
return;
|
||||
}
|
||||
if (!/^\d{5}$/.test(toolingId)) {
|
||||
alert('Tooling ID must be exactly 5 digits');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if specific type is required
|
||||
const validSpecificTypes = specificTypeOptions[subType] || [];
|
||||
if (validSpecificTypes.length > 0 && !specificType) {
|
||||
alert('Please select a specific type');
|
||||
return;
|
||||
}
|
||||
|
||||
// Create form data
|
||||
const formData = new FormData(form);
|
||||
|
||||
// Add CSRF token to form data
|
||||
const csrfToken = document.querySelector('meta[name="csrf-token"]').getAttribute('content');
|
||||
formData.append('csrf_token', csrfToken);
|
||||
|
||||
// Remove specific type if it's not needed
|
||||
if (validSpecificTypes.length === 0) {
|
||||
formData.delete('mold_specific_type');
|
||||
}
|
||||
|
||||
// Log the final form data being sent
|
||||
console.log('Final form data being sent:');
|
||||
for (let [key, value] of formData.entries()) {
|
||||
console.log(`${key}: ${value}`);
|
||||
}
|
||||
|
||||
const response = await fetch('/calculate', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
console.log('Response status:', response.status);
|
||||
const responseText = await response.text();
|
||||
console.log('Response text:', responseText);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}, body: ${responseText}`);
|
||||
}
|
||||
|
||||
const data = JSON.parse(responseText);
|
||||
console.log('Calculation result:', data);
|
||||
|
||||
// Get the result element
|
||||
const resultElement = document.getElementById('result');
|
||||
|
||||
// Update the result display
|
||||
updateResultsDisplay(data);
|
||||
|
||||
// Save the quotation to the database
|
||||
try {
|
||||
console.log('Saving quotation to database...');
|
||||
const saveResponse = await fetch('/save-quotation', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': csrfToken
|
||||
},
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
|
||||
const saveResult = await saveResponse.json();
|
||||
console.log('Save result:', saveResult);
|
||||
|
||||
if (saveResult.success) {
|
||||
console.log('Quotation saved successfully with ID:', saveResult.quotation_id);
|
||||
|
||||
// Show success message
|
||||
const successMessage = document.createElement('div');
|
||||
successMessage.className = 'alert alert-success alert-dismissible fade show';
|
||||
successMessage.innerHTML = `
|
||||
<i class="fas fa-check-circle me-2"></i>
|
||||
Quotation saved successfully! ID: ${saveResult.quotation_id}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
`;
|
||||
resultElement.insertBefore(successMessage, resultElement.firstChild);
|
||||
|
||||
// Reset form after successful save
|
||||
resetForm(form);
|
||||
|
||||
// Refresh quote history
|
||||
setTimeout(() => {
|
||||
refreshQuoteHistory();
|
||||
}, 1000);
|
||||
} else if (saveResult.duplicate) {
|
||||
console.log('Duplicate submission detected:', saveResult.message);
|
||||
const duplicateMessage = document.createElement('div');
|
||||
duplicateMessage.className = 'alert alert-warning alert-dismissible fade show';
|
||||
duplicateMessage.innerHTML = `
|
||||
<i class="fas fa-exclamation-triangle me-2"></i>
|
||||
${saveResult.message}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
`;
|
||||
resultElement.insertBefore(duplicateMessage, resultElement.firstChild);
|
||||
|
||||
// Reset form even on duplicate to prevent repeated attempts
|
||||
resetForm(form);
|
||||
}
|
||||
} catch (saveError) {
|
||||
console.error('Error saving quotation:', saveError);
|
||||
const errorMessage = document.createElement('div');
|
||||
errorMessage.className = 'alert alert-danger alert-dismissible fade show';
|
||||
errorMessage.innerHTML = `
|
||||
<i class="fas fa-exclamation-circle me-2"></i>
|
||||
Error saving quotation. Please try again later.
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
`;
|
||||
resultElement.insertBefore(errorMessage, resultElement.firstChild);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
alert('An error occurred while calculating. Please check the console for details.');
|
||||
} finally {
|
||||
// Re-enable submit button and restore original state after a delay
|
||||
setTimeout(() => {
|
||||
let submitButton = document.getElementById('calculateBtn') || form.querySelector('button[type="submit"]');
|
||||
|
||||
if (submitButton) {
|
||||
submitButton.disabled = false;
|
||||
submitButton.innerHTML = originalButtonText;
|
||||
submitButton.classList.remove('btn-secondary');
|
||||
submitButton.classList.add('btn-primary');
|
||||
submitButton.title = 'Calculate mold cost';
|
||||
}
|
||||
|
||||
// Reset submission state
|
||||
form.dataset.submitting = 'false';
|
||||
}, 2000); // 2 second cooldown before allowing new submission
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Function to update results display
|
||||
function updateResultsDisplay(data) {
|
||||
const resultElement = document.getElementById('result');
|
||||
if (!resultElement) {
|
||||
console.error('Result element not found');
|
||||
return;
|
||||
}
|
||||
|
||||
// Format the results to match the provided screenshot
|
||||
const formattedResults = `
|
||||
<div class="card bg-white shadow-sm p-4 mb-4" style="border-radius: 1rem; border: 1px solid #e3e6ea;">
|
||||
<div class="mb-4 pb-2 border-bottom d-flex align-items-center" style="font-size:1.4rem;font-weight:600;color:#1976d2;">
|
||||
<i class="fas fa-clipboard-list me-2"></i>Calculation Results
|
||||
</div>
|
||||
<!-- Factory Information -->
|
||||
<div class="mb-4">
|
||||
<div class="d-flex align-items-center mb-2" style="color:#1976d2;font-weight:600;font-size:1.1rem;">
|
||||
<i class="fas fa-industry me-2"></i>Factory Information
|
||||
</div>
|
||||
<div class="row g-3">
|
||||
<div class="col-md-4">
|
||||
<div class="card p-3 h-100 bg-light border-0">
|
||||
<div class="fw-bold">Mold Shop</div>
|
||||
<div>${data.mold_shop_name || '-'}</div>
|
||||
<div class="text-muted small">${data.mold_shop_country || '-'}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="card p-3 h-100 bg-light border-0">
|
||||
<div class="fw-bold">T1 Factory</div>
|
||||
<div>${data.t1_shoe_factory_name || '-'}</div>
|
||||
<div class="text-muted small">${data.t1_shoe_factory_country || '-'}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="card p-3 h-100 bg-light border-0">
|
||||
<div class="fw-bold">T2 Factory</div>
|
||||
<div>${data.t2_component_factory_name || '-'}</div>
|
||||
<div class="text-muted small">${data.t2_component_factory_country || '-'}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mold Information -->
|
||||
<div class="mb-4">
|
||||
<div class="d-flex align-items-center mb-2" style="color:#1976d2;font-weight:600;font-size:1.1rem;">
|
||||
<i class="fas fa-cube me-2"></i>Mold Information
|
||||
</div>
|
||||
<div class="row g-3">
|
||||
<div class="col-md-4">
|
||||
<div class="card p-3 h-100">
|
||||
<div class="fw-bold">Model</div>
|
||||
<div>${data.model_name || '-'}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="card p-3 h-100">
|
||||
<div class="fw-bold">Tooling</div>
|
||||
<div>${data.tooling_id || '-'}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="card p-3 h-100">
|
||||
<div class="fw-bold">Season & Stage</div>
|
||||
<div>${(data.season || '-') + '/' + (data.stage || '-')}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mold Specifications -->
|
||||
<div class="mb-4">
|
||||
<div class="d-flex align-items-center mb-2" style="color:#1976d2;font-weight:600;font-size:1.1rem;">
|
||||
<i class="fas fa-wrench me-2"></i>Mold Specifications
|
||||
</div>
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<div class="card p-3 h-100">
|
||||
<div class="fw-bold">Mold Type</div>
|
||||
<div>${data.mold_main_type || '-'}</div>
|
||||
<div class="text-muted small">${data.mold_sub_type || ''}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="card p-3 h-100">
|
||||
<div class="fw-bold mb-2">Complexity Factors</div>
|
||||
<div class="row">
|
||||
<div class="col-6 small">High Sidewall</div>
|
||||
<div class="col-6 small text-end">${data.complexity_high_sidewall === 'yes' ? '<b>Yes</b>' : '-'}</div>
|
||||
<div class="col-6 small">Sliders</div>
|
||||
<div class="col-6 small text-end">${data.sliders_count ? `<b>${data.sliders_count} slider${data.sliders_count > 1 ? 's' : ''}</b>` : '-'}</div>
|
||||
<div class="col-6 small">Cavities</div>
|
||||
<div class="col-6 small text-end">${data.cavity_count ? `<b>${data.cavity_count} pair</b>` : '-'}</div>
|
||||
<div class="col-6 small">Digital Texture</div>
|
||||
<div class="col-6 small text-end">${data.digital_texture_type ? `<b>${data.digital_texture_type}</b>` : '-'}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cost Breakdown & Approvals -->
|
||||
<div class="row g-3">
|
||||
<div class="col-md-8">
|
||||
<div class="mb-4">
|
||||
<div class="d-flex align-items-center mb-2" style="color:#1976d2;font-weight:600;font-size:1.1rem;">
|
||||
<i class="fas fa-table me-2"></i>Cost Breakdown
|
||||
</div>
|
||||
<div class="card p-0">
|
||||
<table class="table mb-0">
|
||||
<tbody>
|
||||
<tr><td>Base Price</td><td class="text-end">$${Number(data.base_price).toFixed(2)}</td></tr>
|
||||
<tr><td>Complexity Adjustment</td><td class="text-end">$${Number(data.complexity_cost).toFixed(2)}</td></tr>
|
||||
<tr><td>Digital Texture Cost</td><td class="text-end">$${Number(data.texture_cost).toFixed(2)}</td></tr>
|
||||
<tr class="table-primary fw-bold"><td>Total Cost</td><td class="text-end">$${Number(data.total_cost).toFixed(2)}</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="mb-4">
|
||||
<div class="d-flex align-items-center mb-2" style="color:#1976d2;font-weight:600;font-size:1.1rem;">
|
||||
<i class="fas fa-check-circle me-2"></i>Approvals
|
||||
</div>
|
||||
<div class="card p-3">
|
||||
<div class="mb-3 text-muted small"><i class="far fa-calendar-alt me-1"></i>Issue Date: <b>${data.issue_date || '-'}</b></div>
|
||||
<div class="mb-2">Mold Shop<br><b>${data.approved_by_mold_shop || '-'}</b></div>
|
||||
<div class="mb-2">T1 Tooling<br><b>${data.approved_by_t1_tooling || '-'}</b></div>
|
||||
<div>LO Tooling<br><b>${data.approved_by_lo_tooling || '-'}</b></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
resultElement.innerHTML = formattedResults;
|
||||
}
|
||||
|
||||
// Helper function to format currency
|
||||
function formatCurrency(value) {
|
||||
if (value === null || value === undefined) {
|
||||
return 'N/A';
|
||||
}
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD'
|
||||
}).format(value);
|
||||
}
|
||||
|
||||
// Refresh quote history
|
||||
async function refreshQuoteHistory() {
|
||||
try {
|
||||
const response = await fetch('/quote-history');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
// Update quote history display
|
||||
const historyContainer = document.getElementById('quote-history');
|
||||
if (historyContainer) {
|
||||
historyContainer.innerHTML = data.html;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error refreshing quote history:', error);
|
||||
}
|
||||
}
|
||||
Executable
+15
@@ -0,0 +1,15 @@
|
||||
<div class="list-group">
|
||||
{% for quote in quote_history %}
|
||||
<div class="list-group-item d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<span class="badge bg-primary me-2">#{{ quote.id }}</span>
|
||||
{{ quote.created_at.strftime('%Y-%m-%d %H:%M') }}
|
||||
</div>
|
||||
<div class="fw-bold">¥{{ "{:,.0f}".format(quote.total_cost) }}</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="list-group-item text-muted">
|
||||
No calculations yet
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
@@ -0,0 +1,298 @@
|
||||
{% macro render_quotation_details(quotation, show_actions=true) %}
|
||||
<div class="quotation-details">
|
||||
<div class="detail-card">
|
||||
<h3>Basic Information</h3>
|
||||
<div class="detail-grid">
|
||||
<div class="detail-item">
|
||||
<label>ID</label>
|
||||
<span>{{ quotation.id }}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<label>User</label>
|
||||
<span>{{ quotation.user.username }}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<label>Created</label>
|
||||
<span>{{ quotation.created_at.strftime('%Y-%m-%d %H:%M') }}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<label>Status</label>
|
||||
<span class="badge badge-success">Completed</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-card">
|
||||
<h3>Project Details</h3>
|
||||
<div class="detail-grid">
|
||||
<div class="detail-item">
|
||||
<label>Model Name</label>
|
||||
<span>{{ quotation.model_name }}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<label>Tooling ID</label>
|
||||
<span>{{ quotation.tooling_id }}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<label>Season</label>
|
||||
<span>{{ quotation.season }}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<label>Stage</label>
|
||||
<span>{{ quotation.stage }}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<label>Country</label>
|
||||
<span>{{ quotation.country }}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<label>Issue Date</label>
|
||||
<span>{{ quotation.issue_date.strftime('%Y-%m-%d') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-card">
|
||||
<h3>Mold Specifications</h3>
|
||||
<div class="detail-grid">
|
||||
<div class="detail-item">
|
||||
<label>Mold Main Type</label>
|
||||
<span>{{ quotation.mold_main_type }}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<label>Mold Sub Type</label>
|
||||
<span>{{ quotation.mold_sub_type }}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<label>High Sidewall Complexity</label>
|
||||
<span>{{ 'Yes' if quotation.complexity_high_sidewall else 'No' }}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<label>Number of Sliders</label>
|
||||
<span>{{ quotation.sliders_count }}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<label>Number of Cavities</label>
|
||||
<span>{{ quotation.cavity_count }}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<label>Digital Texture Type</label>
|
||||
<span>{{ quotation.digital_texture_type }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-card">
|
||||
<h3>Cost Breakdown</h3>
|
||||
<div class="cost-breakdown">
|
||||
<div class="cost-item">
|
||||
<label>Net Base Price</label>
|
||||
<span>${{ "%.2f"|format(quotation.net_base_price) }}</span>
|
||||
</div>
|
||||
<div class="cost-item">
|
||||
<label>Tax Rate</label>
|
||||
<span>{{ "%.1f"|format(quotation.tax_rate * 100) }}%</span>
|
||||
</div>
|
||||
<div class="cost-item total">
|
||||
<label>Total Cost</label>
|
||||
<span>${{ "%.2f"|format(quotation.total_cost) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-card">
|
||||
<h3>Factory Information</h3>
|
||||
<div class="detail-grid">
|
||||
<div class="detail-item">
|
||||
<label>Mold Shop Name</label>
|
||||
<span>{{ quotation.mold_shop_name }}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<label>T1 Factory Name</label>
|
||||
<span>{{ quotation.t1_factory_name or 'N/A' }}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<label>T2 Component Factory</label>
|
||||
<span>{{ quotation.t2_component_factory or 'N/A' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if show_actions %}
|
||||
<div class="detail-actions">
|
||||
<a href="{{ url_for('admin.quotations') }}" class="btn btn-secondary">
|
||||
<i class="fas fa-arrow-left"></i> Back to Quotations
|
||||
</a>
|
||||
<button class="btn btn-primary" onclick="window.print()">
|
||||
<i class="fas fa-print"></i> Print Quotation
|
||||
</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.quotation-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 30px;
|
||||
}
|
||||
|
||||
.detail-card {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.detail-card h3 {
|
||||
margin: 0 0 20px;
|
||||
color: #2c3e50;
|
||||
font-size: 1.2rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.detail-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.detail-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.detail-item label {
|
||||
color: #6c757d;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.detail-item span {
|
||||
font-size: 1.1rem;
|
||||
color: #2c3e50;
|
||||
}
|
||||
|
||||
.cost-breakdown {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.cost-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid #dee2e6;
|
||||
}
|
||||
|
||||
.cost-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.cost-item label {
|
||||
color: #6c757d;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.cost-item span {
|
||||
font-size: 1.1rem;
|
||||
color: #2c3e50;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.cost-item.total {
|
||||
margin-top: 10px;
|
||||
padding-top: 20px;
|
||||
border-top: 2px solid #dee2e6;
|
||||
}
|
||||
|
||||
.cost-item.total label {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 600;
|
||||
color: #2c3e50;
|
||||
}
|
||||
|
||||
.cost-item.total span {
|
||||
font-size: 1.4rem;
|
||||
font-weight: 700;
|
||||
color: #28a745;
|
||||
}
|
||||
|
||||
.detail-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: #007bff;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background-color: #0056b3;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background-color: #6c757d;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background-color: #5a6268;
|
||||
}
|
||||
|
||||
.badge {
|
||||
padding: 5px 10px;
|
||||
border-radius: 20px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.badge-success {
|
||||
background-color: #28a745;
|
||||
color: white;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.detail-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.detail-actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.btn {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
@media print {
|
||||
.detail-actions {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.detail-card {
|
||||
break-inside: avoid;
|
||||
box-shadow: none;
|
||||
border: 1px solid #dee2e6;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
{% endmacro %}
|
||||
@@ -0,0 +1,130 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}Admin Panel{% endblock %}</title>
|
||||
{% block styles %}
|
||||
<!-- Bootstrap CSS -->
|
||||
<link href="{{ url_for('static', filename='css/bootstrap.min.css') }}" rel="stylesheet">
|
||||
<!-- Bootstrap Icons -->
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/bootstrap-icons.css') }}">
|
||||
<!-- Admin Styles -->
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/admin.css') }}">
|
||||
<!-- Custom Styles -->
|
||||
<style>
|
||||
:root {
|
||||
--primary-color: #0d6efd;
|
||||
--secondary-color: #6c757d;
|
||||
--success-color: #198754;
|
||||
--warning-color: #ffc107;
|
||||
--danger-color: #dc3545;
|
||||
--light-color: #f8f9fa;
|
||||
--dark-color: #212529;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
|
||||
.navbar {
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.card {
|
||||
border: none;
|
||||
box-shadow: 0 0.125rem 0.25rem rgba(0,0,0,0.075);
|
||||
transition: box-shadow 0.15s ease-in-out;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
box-shadow: 0 0.5rem 1rem rgba(0,0,0,0.15);
|
||||
}
|
||||
|
||||
.btn {
|
||||
border-radius: 0.375rem;
|
||||
}
|
||||
|
||||
.table {
|
||||
border-radius: 0.375rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.alert {
|
||||
border: none;
|
||||
border-radius: 0.375rem;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
</head>
|
||||
<body>
|
||||
<nav class="navbar navbar-expand-lg navbar-dark bg-primary">
|
||||
<div class="container-fluid">
|
||||
<a class="navbar-brand" href="{{ url_for('admin.dashboard') }}">
|
||||
<i class="bi bi-gear-fill me-2"></i>Admin Panel
|
||||
</a>
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div class="collapse navbar-collapse" id="navbarNav">
|
||||
<ul class="navbar-nav">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if request.endpoint == 'admin.dashboard' %}active{% endif %}"
|
||||
href="{{ url_for('admin.dashboard') }}">
|
||||
<i class="bi bi-graph-up me-1"></i>Dashboard
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if request.endpoint == 'admin.users' %}active{% endif %}"
|
||||
href="{{ url_for('admin.users') }}">
|
||||
<i class="bi bi-people me-1"></i>Users
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if request.endpoint == 'admin.quotations' %}active{% endif %}"
|
||||
href="{{ url_for('admin.quotations') }}">
|
||||
<i class="bi bi-file-earmark-text me-1"></i>Quotations
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if request.endpoint == 'admin.cost_factors' %}active{% endif %}"
|
||||
href="{{ url_for('admin.cost_factors') }}">
|
||||
<i class="bi bi-calculator me-1"></i>Cost Factors
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
<ul class="navbar-nav ms-auto">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('auth.logout') }}">
|
||||
<i class="bi bi-box-arrow-right me-1"></i>Logout
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="container-fluid py-4">
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for category, message in messages %}
|
||||
<div class="alert alert-{{ category }} alert-dismissible fade show" role="alert">
|
||||
{{ message }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
|
||||
{% block scripts %}
|
||||
<!-- Bootstrap Bundle with Popper -->
|
||||
<script src="{{ url_for('static', filename='js/bootstrap.bundle.min.js') }}"></script>
|
||||
<!-- jQuery -->
|
||||
<script src="{{ url_for('static', filename='js/jquery.min.js') }}"></script>
|
||||
{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,198 @@
|
||||
{% extends "admin/base.html" %}
|
||||
|
||||
{% block admin_title %}Cost Factor History{% endblock %}
|
||||
|
||||
{% block admin_header %}Change History: {{ factor.name }}{% endblock %}
|
||||
|
||||
{% block admin_actions %}
|
||||
<a href="{{ url_for('admin.cost_factors') }}" class="btn btn-secondary">
|
||||
<i class="fas fa-arrow-left"></i> Back to Cost Factors
|
||||
</a>
|
||||
<a href="{{ url_for('admin.edit_cost_factor', factor_id=factor.id) }}" class="btn btn-primary">
|
||||
<i class="fas fa-edit"></i> Edit Factor
|
||||
</a>
|
||||
{% endblock %}
|
||||
|
||||
{% block admin_content %}
|
||||
<div class="row">
|
||||
<div class="col-md-4">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">Factor Information</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<dl class="row">
|
||||
<dt class="col-sm-4">Name</dt>
|
||||
<dd class="col-sm-8">{{ factor.name }}</dd>
|
||||
|
||||
<dt class="col-sm-4">Category</dt>
|
||||
<dd class="col-sm-8">
|
||||
<span class="badge bg-primary">{{ factor.category }}</span>
|
||||
</dd>
|
||||
|
||||
<dt class="col-sm-4">Current Value</dt>
|
||||
<dd class="col-sm-8">
|
||||
<span class="badge bg-success">{{ "%.4f"|format(factor.factor_value) }}</span>
|
||||
</dd>
|
||||
|
||||
<dt class="col-sm-4">Status</dt>
|
||||
<dd class="col-sm-8">
|
||||
{% if factor.is_active %}
|
||||
<span class="badge bg-success">Active</span>
|
||||
{% else %}
|
||||
<span class="badge bg-danger">Inactive</span>
|
||||
{% endif %}
|
||||
</dd>
|
||||
|
||||
<dt class="col-sm-4">Created</dt>
|
||||
<dd class="col-sm-8">{{ factor.created_at.strftime('%Y-%m-%d %H:%M') if factor.created_at else 'Unknown' }}</dd>
|
||||
|
||||
<dt class="col-sm-4">Last Updated</dt>
|
||||
<dd class="col-sm-8">{{ factor.updated_at.strftime('%Y-%m-%d %H:%M') if factor.updated_at else 'Never' }}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-8">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">Change History</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if history %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date & Time</th>
|
||||
<th>Changed By</th>
|
||||
<th>Old Value</th>
|
||||
<th>New Value</th>
|
||||
<th>Change</th>
|
||||
<th>Reason</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for change in history %}
|
||||
<tr>
|
||||
<td>{{ change.changed_at.strftime('%Y-%m-%d %H:%M') }}</td>
|
||||
<td>
|
||||
{% if change.user %}
|
||||
<span class="badge bg-info">{{ change.user.username }}</span>
|
||||
{% else %}
|
||||
<span class="text-muted">Unknown</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if change.old_value is not none %}
|
||||
<span class="badge bg-secondary">{{ "%.4f"|format(change.old_value) }}</span>
|
||||
{% else %}
|
||||
<span class="text-muted">N/A</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge bg-success">{{ "%.4f"|format(change.new_value) }}</span>
|
||||
</td>
|
||||
<td>
|
||||
{% if change.old_value is not none %}
|
||||
{% set diff = change.new_value - change.old_value %}
|
||||
{% if diff > 0 %}
|
||||
<span class="badge bg-danger">+{{ "%.4f"|format(diff) }}</span>
|
||||
{% elif diff < 0 %}
|
||||
<span class="badge bg-warning">{{ "%.4f"|format(diff) }}</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">No change</span>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<span class="badge bg-info">Initial</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if change.change_reason %}
|
||||
<small class="text-muted">{{ change.change_reason }}</small>
|
||||
{% else %}
|
||||
<span class="text-muted">No reason provided</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center py-4">
|
||||
<i class="fas fa-history fa-3x text-muted mb-3"></i>
|
||||
<h5 class="text-muted">No Changes Yet</h5>
|
||||
<p class="text-muted">This factor hasn't been modified since it was created.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if history %}
|
||||
<div class="card mt-3">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">Summary</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-3">
|
||||
<div class="text-center">
|
||||
<h4 class="text-primary">{{ history|length }}</h4>
|
||||
<small class="text-muted">Total Changes</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="text-center">
|
||||
<h4 class="text-success">{{ history|selectattr('change_reason')|list|length }}</h4>
|
||||
<small class="text-muted">With Reasons</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="text-center">
|
||||
<h4 class="text-info">{{ history|selectattr('user')|list|length }}</h4>
|
||||
<small class="text-muted">By Known Users</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="text-center">
|
||||
<h4 class="text-warning">{{ (history|last).changed_at.strftime('%Y-%m-%d') if history else 'N/A' }}</h4>
|
||||
<small class="text-muted">Last Modified</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block styles %}
|
||||
{{ super() }}
|
||||
<style>
|
||||
.badge {
|
||||
font-size: 0.875em;
|
||||
}
|
||||
|
||||
dl.row dt {
|
||||
font-weight: 600;
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
dl.row dd {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.table th {
|
||||
background-color: #f8f9fa;
|
||||
border-top: none;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.text-center h4 {
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,558 @@
|
||||
{% extends "admin/base.html" %}
|
||||
|
||||
{% block title %}Cost Factors Management{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid">
|
||||
<!-- Header -->
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<div>
|
||||
<h1 class="h3 mb-0">
|
||||
<i class="bi bi-gear-fill me-2 text-primary"></i>Cost Factors Management
|
||||
</h1>
|
||||
<p class="text-muted mb-0">Configure calculation parameters and factors</p>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<button onclick="resetFactors()" class="btn btn-warning">
|
||||
<i class="bi bi-arrow-clockwise me-1"></i>Reset to Defaults
|
||||
</button>
|
||||
<a href="{{ url_for('admin.dashboard') }}" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-arrow-left me-1"></i>Back to Dashboard
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Flash Messages -->
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for category, message in messages %}
|
||||
<div class="alert alert-{{ category }} alert-dismissible fade show" role="alert">
|
||||
<i class="bi bi-info-circle me-2"></i>{{ message }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
<!-- Calculation Logic Diagram -->
|
||||
<div class="card border-0 shadow-sm mb-4">
|
||||
<div class="card-header bg-light border-0">
|
||||
<h3 class="card-title mb-0">
|
||||
<i class="bi bi-diagram-3 me-2 text-dark"></i>Calculation Logic
|
||||
</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="mermaid">
|
||||
graph TD
|
||||
A[Base Cost] --> B[Base Factors]
|
||||
B --> C[Mold Factors]
|
||||
C --> D[Complexity Factors]
|
||||
D --> E[Texture Factors]
|
||||
E --> F[Tax Factors]
|
||||
F --> G[Final Cost]
|
||||
|
||||
B --> B1[Base Factor 1]
|
||||
B --> B2[Base Factor 2]
|
||||
C --> C1[Mold Factor 1]
|
||||
C --> C2[Mold Factor 2]
|
||||
D --> D1[Complexity Factor 1]
|
||||
D --> D2[Complexity Factor 2]
|
||||
E --> E1[Texture Factor 1]
|
||||
E --> E2[Texture Factor 2]
|
||||
F --> F1[Tax Rate]
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Statistics Cards -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-xl-2 col-md-4 col-sm-6 mb-3">
|
||||
<div class="card statistics-card border-0 shadow-sm h-100">
|
||||
<div class="card-body text-center">
|
||||
<div class="icon-wrapper bg-primary bg-opacity-10 mx-auto mb-3">
|
||||
<i class="bi bi-calculator text-primary fs-1"></i>
|
||||
</div>
|
||||
<h4 class="mb-1">{{ base_factors|length if base_factors else 0 }}</h4>
|
||||
<p class="text-muted mb-0 small">Base Factors</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-xl-2 col-md-4 col-sm-6 mb-3">
|
||||
<div class="card statistics-card border-0 shadow-sm h-100">
|
||||
<div class="card-body text-center">
|
||||
<div class="icon-wrapper bg-success bg-opacity-10 mx-auto mb-3">
|
||||
<i class="bi bi-building text-success fs-1"></i>
|
||||
</div>
|
||||
<h4 class="mb-1">{{ mold_factors|length if mold_factors else 0 }}</h4>
|
||||
<p class="text-muted mb-0 small">Mold Factors</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-xl-2 col-md-4 col-sm-6 mb-3">
|
||||
<div class="card statistics-card border-0 shadow-sm h-100">
|
||||
<div class="card-body text-center">
|
||||
<div class="icon-wrapper bg-warning bg-opacity-10 mx-auto mb-3">
|
||||
<i class="bi bi-puzzle text-warning fs-1"></i>
|
||||
</div>
|
||||
<h4 class="mb-1">{{ complexity_factors|length if complexity_factors else 0 }}</h4>
|
||||
<p class="text-muted mb-0 small">Complexity</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-xl-2 col-md-4 col-sm-6 mb-3">
|
||||
<div class="card statistics-card border-0 shadow-sm h-100">
|
||||
<div class="card-body text-center">
|
||||
<div class="icon-wrapper bg-info bg-opacity-10 mx-auto mb-3">
|
||||
<i class="bi bi-palette text-info fs-1"></i>
|
||||
</div>
|
||||
<h4 class="mb-1">{{ texture_factors|length if texture_factors else 0 }}</h4>
|
||||
<p class="text-muted mb-0 small">Texture</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-xl-2 col-md-4 col-sm-6 mb-3">
|
||||
<div class="card statistics-card border-0 shadow-sm h-100">
|
||||
<div class="card-body text-center">
|
||||
<div class="icon-wrapper bg-danger bg-opacity-10 mx-auto mb-3">
|
||||
<i class="bi bi-percent text-danger fs-1"></i>
|
||||
</div>
|
||||
<h4 class="mb-1">{{ tax_factors|length if tax_factors else 0 }}</h4>
|
||||
<p class="text-muted mb-0 small">Tax Factors</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cost Factors Sections -->
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<!-- Base Factors -->
|
||||
<div class="card border-0 shadow-sm mb-4">
|
||||
<div class="card-header bg-primary bg-opacity-10 border-0">
|
||||
<h3 class="card-title mb-0">
|
||||
<i class="bi bi-layers me-2 text-primary"></i>Base Factors
|
||||
<span class="badge bg-primary ms-2">{{ base_factors|length if base_factors else 0 }}</span>
|
||||
</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if base_factors %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th class="border-0">Factor Name</th>
|
||||
<th class="border-0">Value</th>
|
||||
<th class="border-0">Description</th>
|
||||
<th class="border-0 text-end">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for factor in base_factors %}
|
||||
<tr>
|
||||
<td>
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="avatar-sm bg-primary bg-opacity-10 rounded-circle d-flex align-items-center justify-content-center me-3" style="width: 40px; height: 40px;">
|
||||
<i class="bi bi-calculator text-primary"></i>
|
||||
</div>
|
||||
<div>
|
||||
<div class="fw-medium">{{ factor.name }}</div>
|
||||
<small class="text-muted">{{ factor.category }}</small>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge bg-primary fs-6">{{ "%.2f"|format(factor.factor_value) }}</span>
|
||||
</td>
|
||||
<td>{{ factor.description or 'Base calculation factor' }}</td>
|
||||
<td class="text-end">
|
||||
<a href="{{ url_for('admin.edit_cost_factor', factor_id=factor.id) }}" class="btn btn-sm btn-outline-primary">
|
||||
<i class="bi bi-pencil me-1"></i>Edit
|
||||
</a>
|
||||
<a href="{{ url_for('admin.cost_factor_history', factor_id=factor.id) }}" class="btn btn-sm btn-outline-info">
|
||||
<i class="bi bi-clock-history me-1"></i>History
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center py-4">
|
||||
<i class="bi bi-exclamation-triangle fs-1 text-warning mb-3"></i>
|
||||
<p class="text-muted mb-0">No base factors found.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mold Factors -->
|
||||
<div class="card border-0 shadow-sm mb-4">
|
||||
<div class="card-header bg-success bg-opacity-10 border-0">
|
||||
<h3 class="card-title mb-0">
|
||||
<i class="bi bi-building me-2 text-success"></i>Mold Factors
|
||||
<span class="badge bg-success ms-2">{{ mold_factors|length if mold_factors else 0 }}</span>
|
||||
</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if mold_factors %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th class="border-0">Factor Name</th>
|
||||
<th class="border-0">Value</th>
|
||||
<th class="border-0">Description</th>
|
||||
<th class="border-0 text-end">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for factor in mold_factors %}
|
||||
<tr>
|
||||
<td>
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="avatar-sm bg-success bg-opacity-10 rounded-circle d-flex align-items-center justify-content-center me-3" style="width: 40px; height: 40px;">
|
||||
<i class="bi bi-box text-success"></i>
|
||||
</div>
|
||||
<div>
|
||||
<div class="fw-medium">{{ factor.name }}</div>
|
||||
<small class="text-muted">{{ factor.category }}</small>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge bg-success fs-6">{{ "%.2f"|format(factor.factor_value) }}</span>
|
||||
</td>
|
||||
<td>{{ factor.description or 'Mold-related factor' }}</td>
|
||||
<td class="text-end">
|
||||
<a href="{{ url_for('admin.edit_cost_factor', factor_id=factor.id) }}" class="btn btn-sm btn-outline-success">
|
||||
<i class="bi bi-pencil me-1"></i>Edit
|
||||
</a>
|
||||
<a href="{{ url_for('admin.cost_factor_history', factor_id=factor.id) }}" class="btn btn-sm btn-outline-info">
|
||||
<i class="bi bi-clock-history me-1"></i>History
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center py-4">
|
||||
<i class="bi bi-exclamation-triangle fs-1 text-warning mb-3"></i>
|
||||
<p class="text-muted mb-0">No mold factors found.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Complexity Factors -->
|
||||
<div class="card border-0 shadow-sm mb-4">
|
||||
<div class="card-header bg-warning bg-opacity-10 border-0">
|
||||
<h3 class="card-title mb-0">
|
||||
<i class="bi bi-puzzle me-2 text-warning"></i>Complexity Factors
|
||||
<span class="badge bg-warning ms-2">{{ complexity_factors|length if complexity_factors else 0 }}</span>
|
||||
</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if complexity_factors %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th class="border-0">Factor Name</th>
|
||||
<th class="border-0">Value</th>
|
||||
<th class="border-0">Description</th>
|
||||
<th class="border-0 text-end">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for factor in complexity_factors %}
|
||||
<tr>
|
||||
<td>
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="avatar-sm bg-warning bg-opacity-10 rounded-circle d-flex align-items-center justify-content-center me-3" style="width: 40px; height: 40px;">
|
||||
<i class="bi bi-gear text-warning"></i>
|
||||
</div>
|
||||
<div>
|
||||
<div class="fw-medium">{{ factor.name }}</div>
|
||||
<small class="text-muted">{{ factor.category }}</small>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge bg-warning fs-6">{{ "%.2f"|format(factor.factor_value) }}</span>
|
||||
</td>
|
||||
<td>{{ factor.description or 'Complexity-related factor' }}</td>
|
||||
<td class="text-end">
|
||||
<a href="{{ url_for('admin.edit_cost_factor', factor_id=factor.id) }}" class="btn btn-sm btn-outline-warning">
|
||||
<i class="bi bi-pencil me-1"></i>Edit
|
||||
</a>
|
||||
<a href="{{ url_for('admin.cost_factor_history', factor_id=factor.id) }}" class="btn btn-sm btn-outline-info">
|
||||
<i class="bi bi-clock-history me-1"></i>History
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center py-4">
|
||||
<i class="bi bi-exclamation-triangle fs-1 text-warning mb-3"></i>
|
||||
<p class="text-muted mb-0">No complexity factors found.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Texture Factors -->
|
||||
<div class="card border-0 shadow-sm mb-4">
|
||||
<div class="card-header bg-info bg-opacity-10 border-0">
|
||||
<h3 class="card-title mb-0">
|
||||
<i class="bi bi-brush me-2 text-info"></i>Texture Factors
|
||||
<span class="badge bg-info ms-2">{{ texture_factors|length if texture_factors else 0 }}</span>
|
||||
</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if texture_factors %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th class="border-0">Factor Name</th>
|
||||
<th class="border-0">Value</th>
|
||||
<th class="border-0">Description</th>
|
||||
<th class="border-0 text-end">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for factor in texture_factors %}
|
||||
<tr>
|
||||
<td>
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="avatar-sm bg-info bg-opacity-10 rounded-circle d-flex align-items-center justify-content-center me-3" style="width: 40px; height: 40px;">
|
||||
<i class="bi bi-palette text-info"></i>
|
||||
</div>
|
||||
<div>
|
||||
<div class="fw-medium">{{ factor.name }}</div>
|
||||
<small class="text-muted">{{ factor.category }}</small>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge bg-info fs-6">{{ "%.2f"|format(factor.factor_value) }}</span>
|
||||
</td>
|
||||
<td>{{ factor.description or 'Texture-related factor' }}</td>
|
||||
<td class="text-end">
|
||||
<a href="{{ url_for('admin.edit_cost_factor', factor_id=factor.id) }}" class="btn btn-sm btn-outline-info">
|
||||
<i class="bi bi-pencil me-1"></i>Edit
|
||||
</a>
|
||||
<a href="{{ url_for('admin.cost_factor_history', factor_id=factor.id) }}" class="btn btn-sm btn-outline-info">
|
||||
<i class="bi bi-clock-history me-1"></i>History
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center py-4">
|
||||
<i class="bi bi-exclamation-triangle fs-1 text-warning mb-3"></i>
|
||||
<p class="text-muted mb-0">No texture factors found.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tax Factors -->
|
||||
<div class="card border-0 shadow-sm mb-4">
|
||||
<div class="card-header bg-danger bg-opacity-10 border-0">
|
||||
<h3 class="card-title mb-0">
|
||||
<i class="bi bi-percent me-2 text-danger"></i>Tax Factors
|
||||
<span class="badge bg-danger ms-2">{{ tax_factors|length if tax_factors else 0 }}</span>
|
||||
</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if tax_factors %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th class="border-0">Country/Region</th>
|
||||
<th class="border-0">Tax Rate</th>
|
||||
<th class="border-0 text-end">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for factor in tax_factors %}
|
||||
<tr>
|
||||
<td>
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="avatar-sm bg-danger bg-opacity-10 rounded-circle d-flex align-items-center justify-content-center me-3" style="width: 40px; height: 40px;">
|
||||
<i class="bi bi-flag text-danger"></i>
|
||||
</div>
|
||||
<div>
|
||||
<div class="fw-medium">{{ factor.name }}</div>
|
||||
<small class="text-muted">{{ factor.description or 'Tax rate' }}</small>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge bg-danger fs-6">{{ "%.1f"|format(factor.factor_value * 100) }}%</span>
|
||||
</td>
|
||||
<td class="text-end">
|
||||
<a href="{{ url_for('admin.edit_cost_factor', factor_id=factor.id) }}" class="btn btn-sm btn-outline-danger">
|
||||
<i class="bi bi-pencil me-1"></i>Edit
|
||||
</a>
|
||||
<a href="{{ url_for('admin.cost_factor_history', factor_id=factor.id) }}" class="btn btn-sm btn-outline-info">
|
||||
<i class="bi bi-clock-history me-1"></i>History
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center py-4">
|
||||
<i class="bi bi-exclamation-triangle fs-1 text-warning mb-3"></i>
|
||||
<p class="text-muted mb-0">No tax factors found.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add Mermaid.js for diagram rendering -->
|
||||
<script src="{{ url_for('static', filename='js/mermaid.min.js') }}"></script>
|
||||
<script>
|
||||
mermaid.initialize({
|
||||
startOnLoad: true,
|
||||
theme: 'default',
|
||||
securityLevel: 'loose',
|
||||
fontFamily: '"Helvetica Neue", Arial, sans-serif'
|
||||
});
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
console.log('Cost Factors Template Loaded');
|
||||
try {
|
||||
const debugInfo = {
|
||||
baseFactors: {{ base_factors|length if base_factors else 0 }},
|
||||
moldFactors: {{ mold_factors|length if mold_factors else 0 }},
|
||||
complexityFactors: {{ complexity_factors|length if complexity_factors else 0 }},
|
||||
textureFactors: {{ texture_factors|length if texture_factors else 0 }},
|
||||
taxFactors: {{ tax_factors|length if tax_factors else 0 }}
|
||||
};
|
||||
console.log('Debug Info:', debugInfo);
|
||||
} catch (error) {
|
||||
console.error('Error in debug logging:', error);
|
||||
}
|
||||
});
|
||||
|
||||
function resetFactors() {
|
||||
if (confirm('Are you sure you want to reset all cost factors to their default values? This action cannot be undone.')) {
|
||||
fetch('{{ url_for("admin.reset_cost_factors") }}', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
}).then(response => {
|
||||
if (response.ok) {
|
||||
window.location.reload();
|
||||
} else {
|
||||
alert('Error resetting factors. Please try again.');
|
||||
}
|
||||
}).catch(error => {
|
||||
console.error('Error:', error);
|
||||
alert('Error resetting factors. Please try again.');
|
||||
});
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{% block styles %}
|
||||
{{ super() }}
|
||||
<style>
|
||||
/* Card Styles */
|
||||
.card {
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15) !important;
|
||||
}
|
||||
|
||||
/* Table Styles */
|
||||
.table th {
|
||||
font-weight: 600;
|
||||
color: #495057;
|
||||
}
|
||||
|
||||
.table td {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* Badge Styles */
|
||||
.badge {
|
||||
padding: 0.5em 0.8em;
|
||||
}
|
||||
|
||||
.badge.fs-6 {
|
||||
font-size: 0.875rem !important;
|
||||
}
|
||||
|
||||
/* Icon Styles */
|
||||
.avatar-sm i {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
/* Animation */
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(10px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.card {
|
||||
animation: fadeIn 0.3s ease-out;
|
||||
}
|
||||
|
||||
/* Statistics Cards */
|
||||
.statistics-card {
|
||||
border-radius: 1rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.statistics-card .icon-wrapper {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
/* Mermaid Diagram */
|
||||
.mermaid {
|
||||
background: white;
|
||||
padding: 1rem;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
/* Responsive Design */
|
||||
@media (max-width: 768px) {
|
||||
.statistics-card {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.table-responsive {
|
||||
margin: 0 -1rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,245 @@
|
||||
{% extends "admin/base.html" %}
|
||||
|
||||
{% block title %}Admin Dashboard{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid">
|
||||
<!-- Header -->
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<div>
|
||||
<h1 class="h3 mb-0">
|
||||
<i class="bi bi-speedometer2 me-2 text-primary"></i>Admin Dashboard
|
||||
</h1>
|
||||
<p class="text-muted mb-0">Overview of system statistics and recent activity</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Flash Messages -->
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for category, message in messages %}
|
||||
<div class="alert alert-{{ category }} alert-dismissible fade show" role="alert">
|
||||
<i class="bi bi-info-circle me-2"></i>{{ message }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
<!-- Statistics Cards -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-xl-3 col-md-6 mb-3">
|
||||
<div class="card border-0 shadow-sm h-100">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between">
|
||||
<div>
|
||||
<h6 class="text-muted mb-1">Total Users</h6>
|
||||
<h3 class="mb-0">{{ total_users }}</h3>
|
||||
</div>
|
||||
<div class="bg-primary bg-opacity-10 rounded-circle p-3">
|
||||
<i class="bi bi-people text-primary fs-1"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<span class="badge bg-success">
|
||||
<i class="bi bi-check-circle me-1"></i>{{ active_users }} active
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-xl-3 col-md-6 mb-3">
|
||||
<div class="card border-0 shadow-sm h-100">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between">
|
||||
<div>
|
||||
<h6 class="text-muted mb-1">Total Quotations</h6>
|
||||
<h3 class="mb-0">{{ total_quotations }}</h3>
|
||||
</div>
|
||||
<div class="bg-success bg-opacity-10 rounded-circle p-3">
|
||||
<i class="bi bi-file-earmark-text text-success fs-1"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<span class="badge bg-info">
|
||||
<i class="bi bi-graph-up me-1"></i>All time
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-xl-3 col-md-6 mb-3">
|
||||
<div class="card border-0 shadow-sm h-100">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between">
|
||||
<div>
|
||||
<h6 class="text-muted mb-1">Active Regions</h6>
|
||||
<h3 class="mb-0">{{ active_regions }}</h3>
|
||||
</div>
|
||||
<div class="bg-warning bg-opacity-10 rounded-circle p-3">
|
||||
<i class="bi bi-globe text-warning fs-1"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<span class="badge bg-warning">
|
||||
<i class="bi bi-flag me-1"></i>Active regions
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-xl-3 col-md-6 mb-3">
|
||||
<div class="card border-0 shadow-sm h-100">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between">
|
||||
<div>
|
||||
<h6 class="text-muted mb-1">Recent Quotes</h6>
|
||||
<h3 class="mb-0">{{ recent_quotations|length }}</h3>
|
||||
</div>
|
||||
<div class="bg-info bg-opacity-10 rounded-circle p-3">
|
||||
<i class="bi bi-clock text-info fs-1"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<span class="badge bg-info">
|
||||
<i class="bi bi-calendar me-1"></i>Last 5 quotes
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<!-- Recent Activity -->
|
||||
<div class="col-lg-8 mb-4">
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-header bg-light border-0">
|
||||
<h5 class="card-title mb-0">
|
||||
<i class="bi bi-graph-up me-2 text-primary"></i>Recent Activity
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if recent_quotations %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>User</th>
|
||||
<th>Project</th>
|
||||
<th>Amount</th>
|
||||
<th>Date</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for quotation in recent_quotations %}
|
||||
<tr>
|
||||
<td>
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="avatar-sm bg-primary bg-opacity-10 rounded-circle d-flex align-items-center justify-content-center me-3" style="width: 32px; height: 32px;">
|
||||
<i class="bi bi-person text-primary"></i>
|
||||
</div>
|
||||
<div>
|
||||
<div class="fw-medium">{{ quotation.user.username }}</div>
|
||||
<small class="text-muted">{{ quotation.user.email }}</small>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>{{ quotation.project_name }}</td>
|
||||
<td>
|
||||
<span class="badge bg-success">${{ "%.2f"|format(quotation.total_cost) }}</span>
|
||||
</td>
|
||||
<td>{{ quotation.created_at.strftime('%Y-%m-%d %H:%M') }}</td>
|
||||
<td>
|
||||
<span class="badge bg-primary">Completed</span>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="text-center mt-3">
|
||||
<a href="{{ url_for('admin.quotations') }}" class="btn btn-outline-primary">
|
||||
<i class="bi bi-eye me-1"></i>View All Quotations
|
||||
</a>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center py-4">
|
||||
<i class="bi bi-file-earmark-text fs-1 text-muted mb-3"></i>
|
||||
<p class="text-muted mb-0">No recent quotations found.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Actions -->
|
||||
<div class="col-lg-4 mb-4">
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-header bg-light border-0">
|
||||
<h5 class="card-title mb-0">
|
||||
<i class="bi bi-lightning me-2 text-warning"></i>Quick Actions
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="d-grid gap-2">
|
||||
<a href="{{ url_for('admin.users') }}" class="btn btn-outline-primary">
|
||||
<i class="bi bi-people me-2"></i>Manage Users
|
||||
</a>
|
||||
<a href="{{ url_for('admin.quotations') }}" class="btn btn-outline-success">
|
||||
<i class="bi bi-file-earmark-text me-2"></i>View Quotations
|
||||
</a>
|
||||
<a href="{{ url_for('admin.cost_factors') }}" class="btn btn-outline-warning">
|
||||
<i class="bi bi-gear me-2"></i>Cost Factors
|
||||
</a>
|
||||
<a href="{{ url_for('admin.cost_factors') }}" class="btn btn-outline-info">
|
||||
<i class="bi bi-calculator me-2"></i>Calculator Settings
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.card {
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15) !important;
|
||||
}
|
||||
|
||||
.avatar-sm {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.table th {
|
||||
font-weight: 600;
|
||||
color: #495057;
|
||||
}
|
||||
|
||||
.table td {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.badge {
|
||||
font-size: 0.875em;
|
||||
}
|
||||
|
||||
.btn {
|
||||
border-radius: 0.375rem;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,238 @@
|
||||
{% extends "admin/base.html" %}
|
||||
|
||||
{% block title %}Edit Cost Factor: {{ factor.name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid">
|
||||
<!-- Header -->
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<div>
|
||||
<h1 class="h3 mb-0">
|
||||
<i class="bi bi-pencil-square me-2 text-primary"></i>Edit Cost Factor
|
||||
</h1>
|
||||
<p class="text-muted mb-0">{{ factor.name }} ({{ factor.category }})</p>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<a href="{{ url_for('admin.cost_factors') }}" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-arrow-left me-1"></i> Back to Cost Factors
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Flash Messages -->
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for category, message in messages %}
|
||||
<div class="alert alert-{{ category }} alert-dismissible fade show" role="alert">
|
||||
<i class="bi bi-info-circle me-2"></i>{{ message }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
<div class="row">
|
||||
<div class="col-lg-8">
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-header bg-light border-0">
|
||||
<h5 class="card-title mb-0">
|
||||
<i class="bi bi-gear me-2"></i>Edit Factor Details
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="POST">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label for="category" class="form-label fw-semibold">Category</label>
|
||||
<input type="text" class="form-control bg-light" id="category" value="{{ factor.category }}" readonly>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label for="name" class="form-label fw-semibold">Name</label>
|
||||
<input type="text" class="form-control bg-light" id="name" value="{{ factor.name }}" readonly>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="factor_value" class="form-label fw-semibold">
|
||||
Factor Value <span class="text-danger">*</span>
|
||||
</label>
|
||||
<input type="number" class="form-control form-control-lg" id="factor_value" name="factor_value"
|
||||
value="{{ "%.4f"|format(factor.factor_value) }}" step="0.0001" min="0" required>
|
||||
<div class="form-text">
|
||||
{% if factor.category == 'tax' %}
|
||||
<i class="bi bi-info-circle me-1"></i>Enter as decimal (e.g., 0.06 for 6%)
|
||||
{% elif factor.category == 'base' %}
|
||||
<i class="bi bi-info-circle me-1"></i>Enter the base price value
|
||||
{% else %}
|
||||
<i class="bi bi-info-circle me-1"></i>Enter the multiplier factor
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if factor.calculation_formula %}
|
||||
<div class="mb-3">
|
||||
<label for="calculation_formula" class="form-label fw-semibold">Calculation Formula</label>
|
||||
<input type="text" class="form-control bg-light" id="calculation_formula"
|
||||
value="{{ factor.calculation_formula }}" readonly>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if factor.percentage %}
|
||||
<div class="mb-3">
|
||||
<label for="percentage" class="form-label fw-semibold">Percentage</label>
|
||||
<input type="text" class="form-control bg-light" id="percentage"
|
||||
value="{{ factor.percentage }}" readonly>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="description" class="form-label fw-semibold">Description</label>
|
||||
<textarea class="form-control bg-light" id="description" rows="3" readonly>{{ factor.description }}</textarea>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label for="change_reason" class="form-label fw-semibold">
|
||||
<i class="bi bi-chat-text me-1"></i>Reason for Change
|
||||
</label>
|
||||
<textarea class="form-control" id="change_reason" name="change_reason" rows="3"
|
||||
placeholder="Please provide a reason for this change (optional but recommended)"></textarea>
|
||||
<div class="form-text">
|
||||
<i class="bi bi-clock-history me-1"></i>This will be recorded in the change history for audit purposes.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex justify-content-between">
|
||||
<a href="{{ url_for('admin.cost_factors') }}" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-x-circle me-1"></i> Cancel
|
||||
</a>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="bi bi-check-circle me-1"></i> Save Changes
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-4">
|
||||
<div class="card border-0 shadow-sm mb-3">
|
||||
<div class="card-header bg-light border-0">
|
||||
<h5 class="card-title mb-0">
|
||||
<i class="bi bi-info-circle me-2"></i>Current Information
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<dl class="row mb-0">
|
||||
<dt class="col-sm-4 fw-semibold">Category</dt>
|
||||
<dd class="col-sm-8">
|
||||
<span class="badge bg-primary">{{ factor.category }}</span>
|
||||
</dd>
|
||||
|
||||
<dt class="col-sm-4 fw-semibold">Name</dt>
|
||||
<dd class="col-sm-8">{{ factor.name }}</dd>
|
||||
|
||||
<dt class="col-sm-4 fw-semibold">Current Value</dt>
|
||||
<dd class="col-sm-8">
|
||||
<span class="badge bg-success fs-6">{{ "%.4f"|format(factor.factor_value) }}</span>
|
||||
</dd>
|
||||
|
||||
<dt class="col-sm-4 fw-semibold">Status</dt>
|
||||
<dd class="col-sm-8">
|
||||
{% if factor.is_active %}
|
||||
<span class="badge bg-success">
|
||||
<i class="bi bi-check-circle me-1"></i>Active
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="badge bg-danger">
|
||||
<i class="bi bi-x-circle me-1"></i>Inactive
|
||||
</span>
|
||||
{% endif %}
|
||||
</dd>
|
||||
|
||||
<dt class="col-sm-4 fw-semibold">Created</dt>
|
||||
<dd class="col-sm-8">{{ factor.created_at.strftime('%Y-%m-%d %H:%M') if factor.created_at else 'Unknown' }}</dd>
|
||||
|
||||
<dt class="col-sm-4 fw-semibold">Last Updated</dt>
|
||||
<dd class="col-sm-8">{{ factor.updated_at.strftime('%Y-%m-%d %H:%M') if factor.updated_at else 'Never' }}</dd>
|
||||
|
||||
{% if factor.updater %}
|
||||
<dt class="col-sm-4 fw-semibold">Updated By</dt>
|
||||
<dd class="col-sm-8">{{ factor.updater.username }}</dd>
|
||||
{% endif %}
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-header bg-light border-0">
|
||||
<h5 class="card-title mb-0">
|
||||
<i class="bi bi-lightning me-2"></i>Quick Actions
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<a href="{{ url_for('admin.cost_factor_history', factor_id=factor.id) }}"
|
||||
class="btn btn-outline-info btn-sm w-100 mb-2">
|
||||
<i class="bi bi-clock-history me-1"></i> View Change History
|
||||
</a>
|
||||
|
||||
<a href="{{ url_for('admin.cost_factors') }}"
|
||||
class="btn btn-outline-secondary btn-sm w-100">
|
||||
<i class="bi bi-list me-1"></i> Back to All Factors
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.form-control[readonly] {
|
||||
background-color: #f8f9fa !important;
|
||||
border-color: #dee2e6;
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
.form-control:focus {
|
||||
border-color: #0d6efd;
|
||||
box-shadow: 0 0 0 0.2rem rgba(13, 110, 253, 0.25);
|
||||
}
|
||||
|
||||
.badge {
|
||||
font-size: 0.875em;
|
||||
}
|
||||
|
||||
dl.row dt {
|
||||
color: #6c757d;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
dl.row dd {
|
||||
margin-bottom: 0.75rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15) !important;
|
||||
}
|
||||
|
||||
.btn {
|
||||
border-radius: 0.375rem;
|
||||
}
|
||||
|
||||
.form-control-lg {
|
||||
font-size: 1.1rem;
|
||||
padding: 0.75rem 1rem;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,146 @@
|
||||
{% extends "admin/base.html" %}
|
||||
|
||||
{% block admin_title %}Quotation Details{% endblock %}
|
||||
|
||||
{% block admin_header %}Quotation Details{% endblock %}
|
||||
|
||||
{% block admin_actions %}
|
||||
<a href="{{ url_for('admin.quotations') }}" class="btn btn-secondary">
|
||||
<i class="fas fa-arrow-left"></i> Back to Quotations
|
||||
</a>
|
||||
{% endblock %}
|
||||
|
||||
{% block admin_content %}
|
||||
{% from "_quotation_details.html" import render_quotation_details %}
|
||||
{{ render_quotation_details(quotation) }}
|
||||
{% endblock %}
|
||||
|
||||
{% block styles %}
|
||||
{{ super() }}
|
||||
<style>
|
||||
.quotation-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 30px;
|
||||
}
|
||||
|
||||
.detail-card {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.detail-card h3 {
|
||||
margin: 0 0 20px;
|
||||
color: #2c3e50;
|
||||
}
|
||||
|
||||
.detail-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.detail-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.detail-item label {
|
||||
color: #6c757d;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.detail-item span {
|
||||
font-size: 1.1rem;
|
||||
color: #2c3e50;
|
||||
}
|
||||
|
||||
.cost-breakdown {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.cost-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid #dee2e6;
|
||||
}
|
||||
|
||||
.cost-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.cost-item label {
|
||||
color: #6c757d;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.cost-item span {
|
||||
font-size: 1.1rem;
|
||||
color: #2c3e50;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.cost-item.total {
|
||||
margin-top: 10px;
|
||||
padding-top: 20px;
|
||||
border-top: 2px solid #dee2e6;
|
||||
}
|
||||
|
||||
.cost-item.total label {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 600;
|
||||
color: #2c3e50;
|
||||
}
|
||||
|
||||
.cost-item.total span {
|
||||
font-size: 1.4rem;
|
||||
font-weight: 700;
|
||||
color: #28a745;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background-color: #6c757d;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background-color: #5a6268;
|
||||
}
|
||||
|
||||
.badge {
|
||||
padding: 5px 10px;
|
||||
border-radius: 20px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.badge-success {
|
||||
background-color: #28a745;
|
||||
color: white;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.detail-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,174 @@
|
||||
{% extends "admin/base.html" %}
|
||||
{% block title %}Quotations - Admin{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="admin-container">
|
||||
<div class="admin-card">
|
||||
<div class="admin-card-header">
|
||||
<h2 class="mb-0">Quotations Management</h2>
|
||||
</div>
|
||||
|
||||
<div class="admin-card-body">
|
||||
<div class="admin-filters">
|
||||
<form method="get" class="admin-filter-form" id="quotationFilterForm">
|
||||
<div class="admin-form-group">
|
||||
<select name="country" class="admin-form-control">
|
||||
<option value="">All Countries</option>
|
||||
{% for country in countries %}
|
||||
<option value="{{ country }}" {% if filters.country == country %}selected{% endif %}>
|
||||
{{ country }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="admin-form-group">
|
||||
<select name="mold_type" class="admin-form-control">
|
||||
<option value="">All Mold Types</option>
|
||||
{% for type in mold_types %}
|
||||
<option value="{{ type }}" {% if filters.mold_type == type %}selected{% endif %}>
|
||||
{{ type }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="admin-form-group">
|
||||
<input type="date" name="date_from" id="date_from" class="admin-form-control"
|
||||
value="{{ filters.date_from.strftime('%Y-%m-%d') if filters.date_from else '' }}"
|
||||
placeholder="From Date">
|
||||
</div>
|
||||
|
||||
<div class="admin-form-group">
|
||||
<input type="date" name="date_to" id="date_to" class="admin-form-control"
|
||||
value="{{ filters.date_to.strftime('%Y-%m-%d') if filters.date_to else '' }}"
|
||||
placeholder="To Date">
|
||||
</div>
|
||||
|
||||
<div class="admin-form-group">
|
||||
<button type="submit" class="admin-btn admin-btn-primary">Filter</button>
|
||||
<a href="{{ url_for('admin.quotations') }}" class="admin-btn admin-btn-secondary">Clear</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{% if quotations.items %}
|
||||
<div class="table-responsive">
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>User</th>
|
||||
<th>Model</th>
|
||||
<th>Country</th>
|
||||
<th>Mold Type</th>
|
||||
<th>Base Price</th>
|
||||
<th>Total Cost</th>
|
||||
<th>Date</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for quotation in quotations.items %}
|
||||
<tr>
|
||||
<td>{{ quotation.id }}</td>
|
||||
<td>{{ quotation.user.username }}</td>
|
||||
<td>{{ quotation.model_name }}</td>
|
||||
<td>{{ quotation.mold_shop_country }}</td>
|
||||
<td>{{ quotation.mold_main_type }}</td>
|
||||
<td>${{ "%.2f"|format(quotation.base_price) }}</td>
|
||||
<td>${{ "%.2f"|format(quotation.total_cost) }}</td>
|
||||
<td>{{ quotation.created_at.strftime('%Y-%m-%d %H:%M') }}</td>
|
||||
<td>
|
||||
<a href="{{ url_for('admin.quotation_detail', id=quotation.id) }}"
|
||||
class="admin-btn admin-btn-info admin-btn-sm">
|
||||
<i class="fas fa-eye"></i>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{% if quotations.pages > 1 %}
|
||||
<div class="admin-pagination">
|
||||
{% if quotations.has_prev %}
|
||||
<a href="{{ url_for('admin.quotations', page=quotations.prev_num, **filters) }}"
|
||||
class="admin-pagination-link">Previous</a>
|
||||
{% endif %}
|
||||
|
||||
<span class="admin-pagination-info">
|
||||
Page {{ quotations.page }} of {{ quotations.pages }}
|
||||
</span>
|
||||
|
||||
{% if quotations.has_next %}
|
||||
<a href="{{ url_for('admin.quotations', page=quotations.next_num, **filters) }}"
|
||||
class="admin-pagination-link">Next</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<div class="text-center py-4">
|
||||
<p class="text-muted mb-0">No quotations found matching your criteria.</p>
|
||||
{% if filters.country or filters.mold_type or filters.date_from or filters.date_to %}
|
||||
<a href="{{ url_for('admin.quotations') }}" class="admin-btn admin-btn-primary mt-3">Clear Filters</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
{{ super() }}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Function to get today's date in YYYY-MM-DD format
|
||||
function getTodayDate() {
|
||||
const today = new Date();
|
||||
const year = today.getFullYear();
|
||||
const month = String(today.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(today.getDate()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
// Get date input elements
|
||||
const dateFromInput = document.getElementById('date_from');
|
||||
const dateToInput = document.getElementById('date_to');
|
||||
const form = document.getElementById('quotationFilterForm');
|
||||
|
||||
// Set default values if empty
|
||||
if (!dateFromInput.value) {
|
||||
dateFromInput.value = getTodayDate();
|
||||
}
|
||||
if (!dateToInput.value) {
|
||||
dateToInput.value = getTodayDate();
|
||||
}
|
||||
|
||||
// Prevent form submission if dates are invalid
|
||||
form.addEventListener('submit', function(event) {
|
||||
if (!dateFromInput.value || !dateToInput.value) {
|
||||
event.preventDefault();
|
||||
if (!dateFromInput.value) dateFromInput.value = getTodayDate();
|
||||
if (!dateToInput.value) dateToInput.value = getTodayDate();
|
||||
}
|
||||
});
|
||||
|
||||
// Add event listeners to handle invalid dates
|
||||
dateFromInput.addEventListener('change', function() {
|
||||
if (!this.value) this.value = getTodayDate();
|
||||
});
|
||||
|
||||
dateToInput.addEventListener('change', function() {
|
||||
if (!this.value) this.value = getTodayDate();
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
{% block styles %}
|
||||
{{ super() }}
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/admin.css') }}">
|
||||
{% endblock %}
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
{% extends "admin/base.html" %}
|
||||
|
||||
{% block title %}Users - Admin Panel{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="card border-0 shadow-sm mb-4">
|
||||
<div class="card-body">
|
||||
<form method="get" class="row g-3 align-items-end">
|
||||
<div class="col-md-8">
|
||||
<label for="search" class="form-label">Search Users</label>
|
||||
<input type="text" id="search" name="search" placeholder="Search by username or email..."
|
||||
value="{{ request.args.get('search', '') }}" class="form-control">
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<button type="submit" class="btn btn-primary w-100">
|
||||
<i class="fas fa-search me-2"></i>Search
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-header bg-transparent border-0">
|
||||
<h5 class="card-title mb-0">
|
||||
<i class="fas fa-users me-2 text-primary"></i>
|
||||
User Management
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th class="border-0">ID</th>
|
||||
<th class="border-0">Username</th>
|
||||
<th class="border-0">Email</th>
|
||||
<th class="border-0">Status</th>
|
||||
<th class="border-0">Role</th>
|
||||
<th class="border-0">Last Login</th>
|
||||
<th class="border-0">Created</th>
|
||||
<th class="border-0 text-center">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for user in users.items %}
|
||||
<tr>
|
||||
<td>
|
||||
<span class="badge bg-secondary">#{{ user.id }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="avatar-sm bg-primary bg-opacity-10 rounded-circle d-flex align-items-center justify-content-center me-2" style="width: 32px; height: 32px;">
|
||||
<i class="fas fa-user text-primary"></i>
|
||||
</div>
|
||||
<span class="fw-medium">{{ user.username }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span class="text-muted">{{ user.email }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge bg-{{ 'success' if user.is_active else 'danger' }}">
|
||||
<i class="fas fa-{{ 'check-circle' if user.is_active else 'times-circle' }} me-1"></i>
|
||||
{{ 'Active' if user.is_active else 'Inactive' }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge bg-{{ 'primary' if user.is_admin else 'secondary' }}">
|
||||
<i class="fas fa-{{ 'crown' if user.is_admin else 'user' }} me-1"></i>
|
||||
{{ 'Admin' if user.is_admin else 'User' }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<small class="text-muted">
|
||||
{{ user.last_login.strftime('%m/%d/%Y %H:%M') if user.last_login else 'Never' }}
|
||||
</small>
|
||||
</td>
|
||||
<td>
|
||||
<small class="text-muted">
|
||||
{{ user.created_at.strftime('%m/%d/%Y') }}
|
||||
</small>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<div class="btn-group" role="group">
|
||||
<a href="{{ url_for('admin.user_detail', user_id=user.id) }}"
|
||||
class="btn btn-outline-info btn-sm"
|
||||
title="View Details">
|
||||
<i class="fas fa-eye"></i>
|
||||
</a>
|
||||
<button type="button"
|
||||
class="btn btn-{{ 'warning' if user.is_active else 'success' }} btn-sm"
|
||||
onclick="toggleUserStatus({{ user.id }}, '{{ 'warning' if user.is_active else 'success' }}')"
|
||||
title="{{ 'Deactivate' if user.is_active else 'Activate' }} User">
|
||||
<i class="fas fa-{{ 'ban' if user.is_active else 'check' }}"></i>
|
||||
</button>
|
||||
{% if user.id != current_user.id %}
|
||||
<button type="button"
|
||||
class="btn btn-outline-danger btn-sm"
|
||||
onclick="deleteUser({{ user.id }})"
|
||||
title="Delete User">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
{% if users.pages > 1 %}
|
||||
<div class="d-flex justify-content-between align-items-center mt-4">
|
||||
<div class="text-muted">
|
||||
Showing {{ users.items|length }} of {{ users.total }} users
|
||||
</div>
|
||||
<nav aria-label="User pagination">
|
||||
<ul class="pagination pagination-sm mb-0">
|
||||
{% if users.has_prev %}
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="{{ url_for('admin.users', page=users.prev_num, search=request.args.get('search', '')) }}">
|
||||
<i class="fas fa-chevron-left"></i>
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
|
||||
{% for page_num in users.iter_pages() %}
|
||||
{% if page_num %}
|
||||
{% if page_num != users.page %}
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="{{ url_for('admin.users', page=page_num, search=request.args.get('search', '')) }}">
|
||||
{{ page_num }}
|
||||
</a>
|
||||
</li>
|
||||
{% else %}
|
||||
<li class="page-item active">
|
||||
<span class="page-link">{{ page_num }}</span>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<li class="page-item disabled">
|
||||
<span class="page-link">...</span>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{% if users.has_next %}
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="{{ url_for('admin.users', page=users.next_num, search=request.args.get('search', '')) }}">
|
||||
<i class="fas fa-chevron-right"></i>
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
{{ super() }}
|
||||
<script>
|
||||
function toggleUserStatus(userId, status) {
|
||||
const button = event.target.closest('button');
|
||||
const isActive = status === 'warning';
|
||||
const action = isActive ? 'deactivate' : 'activate';
|
||||
|
||||
if (!confirm(`Are you sure you want to ${action} this user?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
fetch(`/admin/user/${userId}/toggle`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': document.querySelector('meta[name="csrf-token"]').getAttribute('content')
|
||||
}
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.status === 'success') {
|
||||
location.reload();
|
||||
} else {
|
||||
alert('Error: ' + data.message);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
alert('An error occurred while updating user status');
|
||||
});
|
||||
}
|
||||
|
||||
function deleteUser(userId) {
|
||||
if (!confirm('Are you sure you want to delete this user? This action cannot be undone.')) {
|
||||
return;
|
||||
}
|
||||
|
||||
fetch('/admin/delete_user', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': document.querySelector('meta[name="csrf-token"]').getAttribute('content')
|
||||
},
|
||||
body: JSON.stringify({ user_id: userId })
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.message) {
|
||||
location.reload();
|
||||
} else {
|
||||
alert('Error: ' + data.error);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
alert('An error occurred while deleting the user');
|
||||
});
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
{% block styles %}
|
||||
{{ super() }}
|
||||
<style>
|
||||
.avatar-sm {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.table th {
|
||||
font-weight: 600;
|
||||
color: #495057;
|
||||
font-size: 0.875rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.table td {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.btn-group .btn {
|
||||
margin-right: 2px;
|
||||
}
|
||||
|
||||
.btn-group .btn:last-child {
|
||||
margin-right: 0;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
Executable
+168
@@ -0,0 +1,168 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mt-4">
|
||||
<h2 class="mb-4">Admin Management</h2>
|
||||
|
||||
<!-- User Management Section -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h3 class="mb-0">User Management</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Username</th>
|
||||
<th>Email</th>
|
||||
<th>Company</th>
|
||||
<th>Registration Date</th>
|
||||
<th>Status</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for user in users %}
|
||||
<tr>
|
||||
<td>{{ user.id }}</td>
|
||||
<td>{{ user.username }}</td>
|
||||
<td>{{ user.email }}</td>
|
||||
<td>{{ user.company }}</td>
|
||||
<td>{{ user.created_at.strftime('%Y-%m-%d %H:%M') }}</td>
|
||||
<td>
|
||||
{% if user.is_admin %}
|
||||
<span class="badge bg-primary">Admin</span>
|
||||
{% else %}
|
||||
<span class="badge bg-success">User</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if not user.is_admin %}
|
||||
<button class="btn btn-danger btn-sm"
|
||||
onclick="deleteUser({{ user.id }})"
|
||||
data-bs-toggle="modal"
|
||||
data-bs-target="#deleteModal">
|
||||
Delete
|
||||
</button>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quotation Management Section -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3 class="mb-0">Quotation Management</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>User</th>
|
||||
<th>Model</th>
|
||||
<th>Tooling ID</th>
|
||||
<th>Total Cost</th>
|
||||
<th>Date</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for quotation in quotations %}
|
||||
<tr>
|
||||
<td>{{ quotation.id }}</td>
|
||||
<td>{{ quotation.user.username }}</td>
|
||||
<td>{{ quotation.model_name }}</td>
|
||||
<td>{{ quotation.tooling_id }}</td>
|
||||
<td>${{ "%.2f"|format(quotation.total_cost) }}</td>
|
||||
<td>{{ quotation.created_at.strftime('%Y-%m-%d %H:%M') }}</td>
|
||||
<td>
|
||||
<button class="btn btn-danger btn-sm"
|
||||
onclick="deleteQuotation({{ quotation.id }})"
|
||||
data-bs-toggle="modal"
|
||||
data-bs-target="#deleteModal">
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delete Confirmation Modal -->
|
||||
<div class="modal fade" id="deleteModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Confirm Delete</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>Are you sure you want to delete this item?</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-danger" id="confirmDelete">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let itemToDelete = null;
|
||||
let deleteType = null;
|
||||
|
||||
function deleteUser(userId) {
|
||||
itemToDelete = userId;
|
||||
deleteType = 'user';
|
||||
}
|
||||
|
||||
function deleteQuotation(quoteId) {
|
||||
itemToDelete = quoteId;
|
||||
deleteType = 'quotation';
|
||||
}
|
||||
|
||||
document.getElementById('confirmDelete').addEventListener('click', function() {
|
||||
if (!itemToDelete || !deleteType) return;
|
||||
|
||||
const url = deleteType === 'user' ? '/admin/delete_user' : '/admin/delete_quotation';
|
||||
|
||||
fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': '{{ csrf_token() }}'
|
||||
},
|
||||
body: JSON.stringify({ id: itemToDelete })
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.message) {
|
||||
location.reload();
|
||||
} else {
|
||||
alert('Error deleting item');
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
alert('Error deleting item');
|
||||
})
|
||||
.finally(() => {
|
||||
const modal = bootstrap.Modal.getInstance(document.getElementById('deleteModal'));
|
||||
modal.hide();
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
Executable
+128
@@ -0,0 +1,128 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Admin Portal - MoldCost Pro{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mt-4">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2><i class="fas fa-lock me-2"></i>Quotation Records</h2>
|
||||
<div>
|
||||
<a href="{{ url_for('calculator') }}" class="btn btn-secondary">
|
||||
<i class="fas fa-calculator me-2"></i>Calculator
|
||||
</a>
|
||||
<a href="{{ url_for('logout') }}" class="btn btn-danger">
|
||||
<i class="fas fa-sign-out-alt me-2"></i>Logout
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card shadow">
|
||||
<div class="card-body">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Company</th>
|
||||
<th>User</th>
|
||||
<th class="text-end">Model</th>
|
||||
<th class="text-end">Tooling ID</th>
|
||||
<th class="text-end">Total</th>
|
||||
<th>Details</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for quotation in quotations %}
|
||||
<tr>
|
||||
<td>{{ quotation.created_at.strftime('%Y-%m-%d %H:%M') }}</td>
|
||||
<td>{{ quotation.user.company }}</td>
|
||||
<td>{{ quotation.user.username }}</td>
|
||||
<td class="text-end">{{ quotation.model_name }}</td>
|
||||
<td class="text-end">{{ quotation.tooling_id }}</td>
|
||||
<td class="text-end fw-bold">${{ "{:,.2f}".format(quotation.total_cost) }}</td>
|
||||
<td>
|
||||
<button class="btn btn-sm btn-outline-primary" data-bs-toggle="modal"
|
||||
data-bs-target="#detailModal{{ quotation.id }}">
|
||||
<i class="fas fa-search"></i>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% for quotation in quotations %}
|
||||
<div class="modal fade" id="detailModal{{ quotation.id }}" tabindex="-1">
|
||||
<div class="modal-dialog modal-dialog-centered modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Quotation Details</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-6">
|
||||
<h6 class="text-muted mb-3">Basic Information</h6>
|
||||
<dl class="row">
|
||||
<dt class="col-sm-4">Date</dt>
|
||||
<dd class="col-sm-8">{{ quotation.created_at.strftime('%Y-%m-%d %H:%M') }}</dd>
|
||||
|
||||
<dt class="col-sm-4">Company</dt>
|
||||
<dd class="col-sm-8">{{ quotation.user.company }}</dd>
|
||||
|
||||
<dt class="col-sm-4">User</dt>
|
||||
<dd class="col-sm-8">{{ quotation.user.username }}</dd>
|
||||
|
||||
<dt class="col-sm-4">Model</dt>
|
||||
<dd class="col-sm-8">{{ quotation.model_name }}</dd>
|
||||
|
||||
<dt class="col-sm-4">Tooling ID</dt>
|
||||
<dd class="col-sm-8">{{ quotation.tooling_id }}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<h6 class="text-muted mb-3">Mold Specifications</h6>
|
||||
<dl class="row">
|
||||
<dt class="col-sm-4">Mold Type</dt>
|
||||
<dd class="col-sm-8">{{ quotation.mold_main_type }} - {{ quotation.mold_sub_type }}</dd>
|
||||
|
||||
<dt class="col-sm-4">Cavities</dt>
|
||||
<dd class="col-sm-8">{{ quotation.cavity_count }} cavity</dd>
|
||||
|
||||
<dt class="col-sm-4">Sliders</dt>
|
||||
<dd class="col-sm-8">{{ quotation.sliders_count }} slider</dd>
|
||||
|
||||
<dt class="col-sm-4">Texture</dt>
|
||||
<dd class="col-sm-8">{{ quotation.digital_texture_type }}</dd>
|
||||
|
||||
<dt class="col-sm-4">High Sidewall</dt>
|
||||
<dd class="col-sm-8">{{ 'Yes' if quotation.complexity_high_sidewall else 'No' }}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<h6 class="text-muted mb-3">Cost Breakdown</h6>
|
||||
<dl class="row">
|
||||
<dt class="col-sm-4">Base Price</dt>
|
||||
<dd class="col-sm-8">${{ "{:,.2f}".format(quotation.net_base_price) }}</dd>
|
||||
|
||||
<dt class="col-sm-4">Tax Rate</dt>
|
||||
<dd class="col-sm-8">{{ "{:.1%}".format(quotation.tax_rate) }}</dd>
|
||||
|
||||
<dt class="col-sm-4">Total Cost</dt>
|
||||
<dd class="col-sm-8 fw-bold">${{ "{:,.2f}".format(quotation.total_cost) }}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endblock %}
|
||||
Executable
+58
@@ -0,0 +1,58 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Login - Mold Cost Calculator{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mt-5">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-6">
|
||||
<div class="card shadow">
|
||||
<div class="card-header bg-primary text-white">
|
||||
<h4 class="mb-0">Login</h4>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for category, message in messages %}
|
||||
<div class="alert alert-{{ category }}">{{ message }}</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
<form method="POST" action="{{ url_for('auth.login') }}">
|
||||
{{ form.hidden_tag() }}
|
||||
|
||||
<div class="mb-3">
|
||||
{{ form.email.label(class="form-label") }}
|
||||
{{ form.email(class="form-control" + (" is-invalid" if form.email.errors else "")) }}
|
||||
{% for error in form.email.errors %}
|
||||
<div class="invalid-feedback">{{ error }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
{{ form.password.label(class="form-label") }}
|
||||
{{ form.password(class="form-control" + (" is-invalid" if form.password.errors else "")) }}
|
||||
{% for error in form.password.errors %}
|
||||
<div class="invalid-feedback">{{ error }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="mb-3 form-check">
|
||||
{{ form.remember_me(class="form-check-input") }}
|
||||
{{ form.remember_me.label(class="form-check-label") }}
|
||||
</div>
|
||||
|
||||
<div class="d-grid gap-2">
|
||||
{{ form.submit(class="btn btn-primary") }}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="card-footer text-center">
|
||||
<p class="mb-0">Don't have an account? <a href="{{ url_for('auth.register') }}">Register here</a></p>
|
||||
<p class="mb-0 mt-2"><a href="{{ url_for('auth.request_reset') }}">Forgot your password?</a></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,77 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Register - Mold Cost Calculator{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mt-5">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-6">
|
||||
<div class="card shadow">
|
||||
<div class="card-header bg-primary text-white">
|
||||
<h4 class="mb-0">Register</h4>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="POST" action="{{ url_for('auth.register') }}">
|
||||
{{ form.hidden_tag() }}
|
||||
|
||||
<div class="mb-3">
|
||||
{{ form.username.label(class="form-label") }}
|
||||
{{ form.username(class="form-control" + (" is-invalid" if form.username.errors else "")) }}
|
||||
{% for error in form.username.errors %}
|
||||
<div class="invalid-feedback">{{ error }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
{{ form.email.label(class="form-label") }}
|
||||
{{ form.email(class="form-control" + (" is-invalid" if form.email.errors else "")) }}
|
||||
{% for error in form.email.errors %}
|
||||
<div class="invalid-feedback">{{ error }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
{{ form.company.label(class="form-label") }}
|
||||
{{ form.company(class="form-control" + (" is-invalid" if form.company.errors else "")) }}
|
||||
{% for error in form.company.errors %}
|
||||
<div class="invalid-feedback">{{ error }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
{{ form.password.label(class="form-label") }}
|
||||
{{ form.password(class="form-control" + (" is-invalid" if form.password.errors else "")) }}
|
||||
{% for error in form.password.errors %}
|
||||
<div class="invalid-feedback">{{ error }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
{{ form.password_confirm.label(class="form-label") }}
|
||||
{{ form.password_confirm(class="form-control" + (" is-invalid" if form.password_confirm.errors else "")) }}
|
||||
{% for error in form.password_confirm.errors %}
|
||||
<div class="invalid-feedback">{{ error }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="mb-3 form-check">
|
||||
{{ form.terms(class="form-check-input" + (" is-invalid" if form.terms.errors else "")) }}
|
||||
{{ form.terms.label(class="form-check-label") }}
|
||||
{% for error in form.terms.errors %}
|
||||
<div class="invalid-feedback">{{ error }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="d-grid gap-2">
|
||||
{{ form.submit(class="btn btn-primary") }}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="card-footer text-center">
|
||||
<p class="mb-0">Already have an account? <a href="{{ url_for('auth.login') }}">Login here</a></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,44 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Request Password Reset - Mold Cost Calculator{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mt-5">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-6">
|
||||
<div class="card shadow">
|
||||
<div class="card-header bg-primary text-white">
|
||||
<h4 class="mb-0">Request Password Reset</h4>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for category, message in messages %}
|
||||
<div class="alert alert-{{ category }}">{{ message }}</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
<form method="POST" action="{{ url_for('auth.request_reset') }}">
|
||||
{{ form.hidden_tag() }}
|
||||
|
||||
<div class="mb-3">
|
||||
{{ form.email.label(class="form-label") }}
|
||||
{{ form.email(class="form-control" + (" is-invalid" if form.email.errors else "")) }}
|
||||
{% for error in form.email.errors %}
|
||||
<div class="invalid-feedback">{{ error }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="d-grid gap-2">
|
||||
{{ form.submit(class="btn btn-primary") }}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="card-footer text-center">
|
||||
<p class="mb-0">Remember your password? <a href="{{ url_for('auth.login') }}">Login here</a></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,52 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Reset Password - Mold Cost Calculator{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mt-5">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-6">
|
||||
<div class="card shadow">
|
||||
<div class="card-header bg-primary text-white">
|
||||
<h4 class="mb-0">Reset Password</h4>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for category, message in messages %}
|
||||
<div class="alert alert-{{ category }}">{{ message }}</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
<form method="POST">
|
||||
{{ form.hidden_tag() }}
|
||||
|
||||
<div class="mb-3">
|
||||
{{ form.password.label(class="form-label") }}
|
||||
{{ form.password(class="form-control" + (" is-invalid" if form.password.errors else "")) }}
|
||||
{% for error in form.password.errors %}
|
||||
<div class="invalid-feedback">{{ error }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
{{ form.password_confirm.label(class="form-label") }}
|
||||
{{ form.password_confirm(class="form-control" + (" is-invalid" if form.password_confirm.errors else "")) }}
|
||||
{% for error in form.password_confirm.errors %}
|
||||
<div class="invalid-feedback">{{ error }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="d-grid gap-2">
|
||||
{{ form.submit(class="btn btn-primary") }}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="card-footer text-center">
|
||||
<p class="mb-0">Remember your password? <a href="{{ url_for('auth.login') }}">Login here</a></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,156 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||||
<title>{% block title %}{% endblock %} - Mold Cost Calculator</title>
|
||||
|
||||
<!-- Favicon -->
|
||||
<link rel="icon" type="image/x-icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🧮</text></svg>">
|
||||
|
||||
<!-- Bootstrap CSS -->
|
||||
<link href="{{ url_for('static', filename='css/bootstrap.min.css') }}" rel="stylesheet">
|
||||
|
||||
<!-- Bootstrap Icons -->
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/bootstrap-icons.css') }}">
|
||||
|
||||
<!-- Custom Styles -->
|
||||
<link href="{{ url_for('static', filename='css/style.css') }}" rel="stylesheet">
|
||||
|
||||
<!-- Custom Styles -->
|
||||
<style>
|
||||
:root {
|
||||
--primary-color: #0d6efd;
|
||||
--secondary-color: #6c757d;
|
||||
--accent-color: #0d6efd;
|
||||
--success-color: #198754;
|
||||
--warning-color: #ffc107;
|
||||
--danger-color: #dc3545;
|
||||
--light-color: #f8f9fa;
|
||||
--dark-color: #212529;
|
||||
--text-color: #212529;
|
||||
--text-muted: #6c757d;
|
||||
--border-color: #dee2e6;
|
||||
--shadow-color: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Inter', sans-serif;
|
||||
line-height: 1.6;
|
||||
color: var(--text-color);
|
||||
background-color: #f8f9fa;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.navbar {
|
||||
background-color: var(--primary-color);
|
||||
padding: 1rem 0;
|
||||
box-shadow: 0 2px 4px var(--shadow-color);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.navbar-brand {
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
text-decoration: none;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 4px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.nav-link:hover {
|
||||
color: white;
|
||||
background-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.nav-link.active {
|
||||
color: white;
|
||||
background-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.main-content {
|
||||
flex: 1;
|
||||
padding: 2rem 0;
|
||||
}
|
||||
|
||||
.footer {
|
||||
background-color: var(--primary-color);
|
||||
color: white;
|
||||
padding: 1rem 0;
|
||||
text-align: center;
|
||||
margin-top: auto;
|
||||
}
|
||||
</style>
|
||||
{% block head %}{% endblock %}
|
||||
{% block styles %}{% endblock %}
|
||||
</head>
|
||||
<body>
|
||||
<nav class="navbar navbar-expand-lg navbar-dark">
|
||||
<div class="container">
|
||||
<a class="navbar-brand" href="{{ url_for('main.home') }}">
|
||||
<i class="bi bi-calculator me-2"></i>Mold Cost Calculator
|
||||
</a>
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div class="collapse navbar-collapse" id="navbarNav">
|
||||
<ul class="navbar-nav me-auto">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if request.endpoint == 'main.calculator' %}active{% endif %}"
|
||||
href="{{ url_for('main.calculator') }}">
|
||||
<i class="bi bi-calculator me-1"></i>Calculator
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
{% if current_user.is_authenticated %}
|
||||
<div class="d-flex align-items-center">
|
||||
<span class="text-white me-3">
|
||||
<i class="bi bi-person me-2"></i>{{ current_user.username }}
|
||||
</span>
|
||||
<a href="{{ url_for('auth.logout') }}" class="btn btn-outline-light">
|
||||
<i class="bi bi-box-arrow-right me-2"></i>Logout
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main class="main-content">
|
||||
<div class="container">
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for category, message in messages %}
|
||||
<div class="alert alert-{{ category }}">{{ message }}</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer class="footer">
|
||||
<div class="container">
|
||||
<p class="mb-0">PCT & Tooling. All rights reserved.</p>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<!-- Bootstrap Bundle with Popper -->
|
||||
<script src="{{ url_for('static', filename='js/bootstrap.bundle.min.js') }}"></script>
|
||||
<!-- jQuery -->
|
||||
<script src="{{ url_for('static', filename='js/jquery.min.js') }}"></script>
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
Executable
+215
@@ -0,0 +1,215 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block head %}
|
||||
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mt-4">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2>Mold Cost Calculator</h2>
|
||||
{% if current_user.is_authenticated and current_user.is_admin %}
|
||||
<a href="{{ url_for('admin.dashboard') }}" class="btn btn-outline-primary">
|
||||
<i class="fas fa-cog me-2"></i>Admin Portal
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<form id="calcForm" class="row g-3" method="POST" action="/calculate">
|
||||
{{ form.csrf_token }}
|
||||
|
||||
<!-- Factory Information -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-body">
|
||||
<h6 class="card-title mb-3">Factory Information</h6>
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-6">
|
||||
<div class="form-group">
|
||||
{{ form.mold_shop_name.label(class="form-label") }}
|
||||
{{ form.mold_shop_name(class="form-control") }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="form-group">
|
||||
{{ form.mold_shop_country.label(class="form-label") }}
|
||||
{{ form.mold_shop_country(class="form-control") }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-6">
|
||||
<div class="form-group">
|
||||
{{ form.t1_shoe_factory_name.label(class="form-label") }}
|
||||
{{ form.t1_shoe_factory_name(class="form-control") }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="form-group">
|
||||
{{ form.t1_shoe_factory_country.label(class="form-label") }}
|
||||
{{ form.t1_shoe_factory_country(class="form-control") }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="form-group">
|
||||
{{ form.t2_component_factory_name.label(class="form-label") }}
|
||||
{{ form.t2_component_factory_name(class="form-control") }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="form-group">
|
||||
{{ form.t2_component_factory_country.label(class="form-label") }}
|
||||
{{ form.t2_component_factory_country(class="form-control") }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mold Information -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-body">
|
||||
<h6 class="card-title mb-3">Mold Information</h6>
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-6">
|
||||
<div class="form-group">
|
||||
{{ form.model_name.label(class="form-label") }}
|
||||
{{ form.model_name(class="form-control") }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="form-group">
|
||||
{{ form.tooling_id.label(class="form-label") }}
|
||||
{{ form.tooling_id(class="form-control") }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-4">
|
||||
<div class="form-group">
|
||||
{{ form.season.label(class="form-label") }}
|
||||
{{ form.season(class="form-control", id="season") }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="form-group">
|
||||
{{ form.stage.label(class="form-label") }}
|
||||
{{ form.stage(class="form-control", id="stage") }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="form-group">
|
||||
{{ form.type.label(class="form-label") }}
|
||||
{{ form.type(class="form-control", id="type") }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mold Specifications -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-body">
|
||||
<h6 class="card-title mb-3">Mold Specifications</h6>
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-4">
|
||||
<div class="form-group">
|
||||
{{ form.mold_main_type.label(class="form-label") }}
|
||||
{{ form.mold_main_type(class="form-control", id="mold_main_type") }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="form-group">
|
||||
{{ form.mold_sub_type.label(class="form-label") }}
|
||||
{{ form.mold_sub_type(class="form-control", id="mold_sub_type") }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="form-group">
|
||||
{{ form.mold_specific_type.label(class="form-label") }}
|
||||
{{ form.mold_specific_type(class="form-control", id="mold_specific_type") }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-6">
|
||||
<div class="form-group">
|
||||
{{ form.sliders_count.label(class="form-label") }}
|
||||
{{ form.sliders_count(class="form-control") }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="form-group">
|
||||
{{ form.cavity_count.label(class="form-label") }}
|
||||
{{ form.cavity_count(class="form-control") }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-6">
|
||||
<div class="form-group">
|
||||
{{ form.digital_texture_type.label(class="form-label") }}
|
||||
{{ form.digital_texture_type(class="form-control") }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="form-group">
|
||||
{{ form.complexity_high_sidewall.label(class="form-label") }}
|
||||
{{ form.complexity_high_sidewall(class="form-control") }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dates & Approvals -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-body">
|
||||
<h6 class="card-title mb-3">Dates & Approvals</h6>
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-6">
|
||||
<div class="form-group">
|
||||
{{ form.issue_date.label(class="form-label") }}
|
||||
{{ form.issue_date(class="form-control", type="date") }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-4">
|
||||
<div class="form-group">
|
||||
{{ form.approved_by_mold_shop.label(class="form-label") }}
|
||||
{{ form.approved_by_mold_shop(class="form-control") }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="form-group">
|
||||
{{ form.approved_by_t1_tooling.label(class="form-label") }}
|
||||
{{ form.approved_by_t1_tooling(class="form-control") }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="form-group">
|
||||
{{ form.approved_by_lo_tooling.label(class="form-label") }}
|
||||
{{ form.approved_by_lo_tooling(class="form-control") }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Submit Button -->
|
||||
<div class="text-center">
|
||||
<button type="submit" id="calculateBtn" class="btn btn-primary btn-lg">Calculate</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Results Section -->
|
||||
<div id="result" class="mt-4"></div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
{{ super() }}
|
||||
<script id="mold-calculator-script" src="{{ url_for('static', filename='js/mold_calculator.js', v='1.4') }}"></script>
|
||||
{% endblock %}
|
||||
Executable
+30
@@ -0,0 +1,30 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mt-5">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-8">
|
||||
<div class="card">
|
||||
<div class="card-header bg-danger text-white">
|
||||
<h4 class="mb-0">Error {{ code }}</h4>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">{{ message }}</h5>
|
||||
<p class="card-text">
|
||||
{% if code == 404 %}
|
||||
The page you're looking for doesn't exist.
|
||||
{% elif code == 403 %}
|
||||
You don't have permission to access this resource.
|
||||
{% elif code == 400 %}
|
||||
The request was invalid.
|
||||
{% else %}
|
||||
An unexpected error occurred. Please try again later.
|
||||
{% endif %}
|
||||
</p>
|
||||
<a href="{{ url_for('main.home') }}" class="btn btn-primary">Return to Home</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
Executable
+14
@@ -0,0 +1,14 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Bad Request - MoldCost Pro{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mt-5">
|
||||
<div class="text-center">
|
||||
<h1 class="display-1">400</h1>
|
||||
<h2 class="mb-4">Bad Request</h2>
|
||||
<p class="lead">The request was invalid or cannot be served.</p>
|
||||
<a href="{{ url_for('main.home') }}" class="btn btn-primary">Return Home</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
Executable
+14
@@ -0,0 +1,14 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Forbidden - MoldCost Pro{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mt-5">
|
||||
<div class="text-center">
|
||||
<h1 class="display-1">403</h1>
|
||||
<h2 class="mb-4">Access Forbidden</h2>
|
||||
<p class="lead">You don't have permission to access this resource.</p>
|
||||
<a href="{{ url_for('main.home') }}" class="btn btn-primary">Return Home</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
Executable
+14
@@ -0,0 +1,14 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Not Found - MoldCost Pro{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mt-5">
|
||||
<div class="text-center">
|
||||
<h1 class="display-1">404</h1>
|
||||
<h2 class="mb-4">Page Not Found</h2>
|
||||
<p class="lead">The page you're looking for doesn't exist.</p>
|
||||
<a href="{{ url_for('main.home') }}" class="btn btn-primary">Return Home</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,22 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mt-5">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-8">
|
||||
<div class="card">
|
||||
<div class="card-header bg-warning text-white">
|
||||
<h4 class="mb-0">Conflict</h4>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Data Constraint Error</h5>
|
||||
<p class="card-text">{{ message }}</p>
|
||||
<p class="card-text">This error typically occurs when trying to create a duplicate record or violate a data constraint.</p>
|
||||
<p class="card-text">Please check your input and try again.</p>
|
||||
<a href="{{ url_for('main.index') }}" class="btn btn-primary">Return to Home</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
Executable
+14
@@ -0,0 +1,14 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Server Error - MoldCost Pro{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mt-5">
|
||||
<div class="text-center">
|
||||
<h1 class="display-1">500</h1>
|
||||
<h2 class="mb-4">Server Error</h2>
|
||||
<p class="lead">Something went wrong on our end. Please try again later.</p>
|
||||
<a href="{{ url_for('main.home') }}" class="btn btn-primary">Return Home</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,22 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mt-5">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-8">
|
||||
<div class="card">
|
||||
<div class="card-header bg-warning text-white">
|
||||
<h4 class="mb-0">Service Temporarily Unavailable</h4>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Database Connection Error</h5>
|
||||
<p class="card-text">{{ message }}</p>
|
||||
<p class="card-text">Our technical team has been notified and is working to resolve this issue.</p>
|
||||
<p class="card-text">Please try again in a few minutes.</p>
|
||||
<a href="{{ url_for('main.index') }}" class="btn btn-primary">Return to Home</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
Executable
+102
@@ -0,0 +1,102 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-main py-5">
|
||||
<div class="hero-section bg-gradient-primary text-white rounded-3 p-5 shadow-lg">
|
||||
<div class="text-center">
|
||||
<h1 class="display-4 fw-bold mb-4">
|
||||
<i class="bi bi-gear-connected"></i>
|
||||
MoldCost Pro
|
||||
</h1>
|
||||
<p class="lead mb-4">Precision Mold Cost Calculations</p>
|
||||
|
||||
<div class="d-grid gap-3 d-md-flex justify-content-md-center">
|
||||
{% if current_user.is_authenticated %}
|
||||
<a href="{{ url_for('calculator') }}" class="btn btn-light btn-lg px-4">
|
||||
<i class="bi bi-calculator me-2"></i>
|
||||
Go to Calculator
|
||||
</a>
|
||||
{% else %}
|
||||
<a href="{{ url_for('login') }}" class="btn btn-light btn-lg px-4">
|
||||
<i class="bi bi-box-arrow-in-right me-2"></i>
|
||||
Get Started
|
||||
</a>
|
||||
<a href="{{ url_for('register') }}" class="btn btn-outline-light btn-lg px-4">
|
||||
<i class="bi bi-person-plus me-2"></i>
|
||||
Register
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Features Section -->
|
||||
<div class="row g-4 mt-5">
|
||||
<div class="col-md-4">
|
||||
<div class="card h-100 border-0 shadow-sm">
|
||||
<div class="card-body text-center">
|
||||
<i class="bi bi-speedometer2 display-4 text-primary"></i>
|
||||
<h3 class="mt-3">Fast Calculation</h3>
|
||||
<p class="text-muted">Instant mold cost estimates with industry-standard algorithms</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-4">
|
||||
<div class="card h-100 border-0 shadow-sm">
|
||||
<div class="card-body text-center">
|
||||
<i class="bi bi-clock-history display-4 text-primary"></i>
|
||||
<h3 class="mt-3">History Tracking</h3>
|
||||
<p class="text-muted">Review your previous quotes and calculations</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-4">
|
||||
<div class="card h-100 border-0 shadow-sm">
|
||||
<div class="card-body text-center">
|
||||
<i class="bi bi-shield-lock display-4 text-primary"></i>
|
||||
<h3 class="mt-3">Secure Data</h3>
|
||||
<p class="text-muted">Enterprise-grade security for your sensitive data</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block styles %}
|
||||
<style>
|
||||
.hero-section {
|
||||
background: linear-gradient(135deg, #1e3c72 0%, #2a5298 100%);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.hero-section:before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: -50%;
|
||||
left: -50%;
|
||||
right: -50%;
|
||||
bottom: -50%;
|
||||
background: linear-gradient(45deg,
|
||||
rgba(255,255,255,0.1) 25%,
|
||||
transparent 25%,
|
||||
transparent 50%,
|
||||
rgba(255,255,255,0.1) 50%,
|
||||
rgba(255,255,255,0.1) 75%,
|
||||
transparent 75%,
|
||||
transparent
|
||||
);
|
||||
background-size: 40px 40px;
|
||||
animation: animateStripes 4s linear infinite;
|
||||
opacity: 0.15;
|
||||
}
|
||||
|
||||
@keyframes animateStripes {
|
||||
0% { transform: translateY(0); }
|
||||
100% { transform: translateY(-40px); }
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
Executable
+82
@@ -0,0 +1,82 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Commercial Invoice - {{ quote.id }} | MoldCost Pro</title>
|
||||
<style>
|
||||
:root {
|
||||
--primary-color: #1a365d;
|
||||
--secondary-color: #2c5282;
|
||||
--accent-color: #c53030;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Helvetica Neue', Arial, sans-serif;
|
||||
color: #1a202c;
|
||||
line-height: 1.6;
|
||||
margin: 0;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.letterhead {
|
||||
border-bottom: 2px solid var(--primary-color);
|
||||
margin-bottom: 2rem;
|
||||
padding-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.company-logo {
|
||||
height: 60px;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.legal-disclaimer {
|
||||
font-size: 0.75rem;
|
||||
color: #718096;
|
||||
margin-top: 3rem;
|
||||
}
|
||||
|
||||
@media print {
|
||||
.page-break { page-break-before: always; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="letterhead">
|
||||
<img src="https://cdn.example.com/logo.svg"
|
||||
alt="MoldCost Pro"
|
||||
class="company-logo">
|
||||
<div class="flex justify-between text-sm text-gray-600">
|
||||
<div>
|
||||
<p>MoldCost Pro Limited</p>
|
||||
<p>123 Manufacturing Blvd, Shenzhen, CN</p>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<p>T: +86 (755) 1234-5678</p>
|
||||
<p>E: sales@moldcostpro.com</p>
|
||||
<p>W: www.moldcostpro.com</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="header-section">
|
||||
<h1 class="text-2xl font-bold">Commercial Invoice</h1>
|
||||
<div class="flex justify-between text-sm text-gray-600 mt-2">
|
||||
<div>
|
||||
<p><strong>Quote ID:</strong> MC-{{ quote.id|string|replace(' ', '0') }}</p>
|
||||
<p><strong>Date:</strong> {{ quote.created_at.strftime('%d-%b-%Y') }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p><strong>Currency:</strong> USD</p>
|
||||
<p><strong>Valid Until:</strong> {{ (quote.created_at + datetime.timedelta(days=30)).strftime('%d-%b-%Y') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% from "_quotation_details.html" import render_quotation_details %}
|
||||
{{ render_quotation_details(quote, show_actions=false) }}
|
||||
|
||||
<div class="legal-disclaimer">
|
||||
<p>This quotation is subject to our standard terms and conditions. Prices valid for 30 days from quotation date. All measurements tolerances ±0.5% unless otherwise specified. Export documentation extra.</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
Executable
+68
@@ -0,0 +1,68 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Terms of Service - Mold Cost Online Tool{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mt-5">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-8">
|
||||
<div class="card shadow">
|
||||
<div class="card-body p-5">
|
||||
<h3 class="mb-4 text-center">Terms of Service</h3>
|
||||
|
||||
<div class="terms-content">
|
||||
<h4 class="mb-3">1. Acceptance of Terms</h4>
|
||||
<p>By accessing and using the Mold Cost Online Tool, you agree to be bound by these Terms of Service.</p>
|
||||
|
||||
<h4 class="mb-3 mt-4">2. Use of Service</h4>
|
||||
<p>The Mold Cost Online Tool is provided for professional use in the footwear industry. Users must:</p>
|
||||
<ul>
|
||||
<li>Provide accurate and complete information</li>
|
||||
<li>Maintain the confidentiality of their account</li>
|
||||
<li>Use the service in compliance with all applicable laws</li>
|
||||
</ul>
|
||||
|
||||
<h4 class="mb-3 mt-4">3. Data Privacy</h4>
|
||||
<p>We are committed to protecting your data:</p>
|
||||
<ul>
|
||||
<li>All calculations and quotations are stored securely</li>
|
||||
<li>Personal information is used only for service provision</li>
|
||||
<li>Data is not shared with third parties without consent</li>
|
||||
</ul>
|
||||
|
||||
<h4 class="mb-3 mt-4">4. Intellectual Property</h4>
|
||||
<p>The Mold Cost Online Tool and its contents are protected by intellectual property rights. Users may not:</p>
|
||||
<ul>
|
||||
<li>Copy or reproduce the tool's algorithms</li>
|
||||
<li>Reverse engineer the service</li>
|
||||
<li>Use the service for unauthorized commercial purposes</li>
|
||||
</ul>
|
||||
|
||||
<h4 class="mb-3 mt-4">5. Disclaimer</h4>
|
||||
<p>The tool provides estimates based on industry standards. While we strive for accuracy:</p>
|
||||
<ul>
|
||||
<li>Results are estimates only</li>
|
||||
<li>Actual costs may vary</li>
|
||||
<li>Users should verify calculations independently</li>
|
||||
</ul>
|
||||
|
||||
<h4 class="mb-3 mt-4">6. Account Termination</h4>
|
||||
<p>We reserve the right to terminate accounts that:</p>
|
||||
<ul>
|
||||
<li>Violate these terms</li>
|
||||
<li>Engage in fraudulent activity</li>
|
||||
<li>Misuse the service</li>
|
||||
</ul>
|
||||
|
||||
<div class="mt-5 text-center">
|
||||
<a href="{{ url_for('register') }}" class="btn btn-primary">
|
||||
<i class="bi bi-arrow-left me-2"></i>Back to Registration
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,56 @@
|
||||
from ..utils.cost_factor_loader import get_cost_factors
|
||||
|
||||
def calculate_base_price(mold_main_type, mold_sub_type, mold_specific_type, sliders_count, cavity_count, digital_texture_type, complexity_high_sidewall=False):
|
||||
"""
|
||||
Calculate the base price for a mold based on various factors.
|
||||
|
||||
Args:
|
||||
mold_main_type (str): The main type of the mold
|
||||
mold_sub_type (str): The sub type of the mold
|
||||
mold_specific_type (str): The specific type of the mold
|
||||
sliders_count (int): Number of sliders
|
||||
cavity_count (int): Number of cavities
|
||||
digital_texture_type (str): Type of digital texture
|
||||
complexity_high_sidewall (bool): Whether the mold has high sidewall complexity
|
||||
|
||||
Returns:
|
||||
dict: A dictionary containing all cost components
|
||||
"""
|
||||
# Get cost factors from database (or fallback to defaults)
|
||||
cost_factors, tax_rates, default_net_base = get_cost_factors()
|
||||
|
||||
# Start with the default net base
|
||||
base_price = default_net_base
|
||||
|
||||
# Apply mold type factor
|
||||
if mold_specific_type in cost_factors['mold']:
|
||||
base_price *= cost_factors['mold'][mold_specific_type]
|
||||
|
||||
# Calculate complexity cost
|
||||
complexity_cost = 0
|
||||
# Add slider complexity
|
||||
if sliders_count > 0:
|
||||
complexity_cost += base_price * (sliders_count * cost_factors['complexity']['slider'])
|
||||
|
||||
# Add cavity complexity
|
||||
if cavity_count > 1:
|
||||
complexity_cost += base_price * ((cavity_count - 1) * cost_factors['complexity']['cavity'])
|
||||
|
||||
# Add high sidewall complexity
|
||||
if complexity_high_sidewall:
|
||||
complexity_cost += base_price * cost_factors['complexity']['high_sidewall']
|
||||
|
||||
# Calculate texture cost
|
||||
texture_cost = 0
|
||||
if digital_texture_type in cost_factors['texture']:
|
||||
texture_cost = base_price * cost_factors['texture'][digital_texture_type]
|
||||
|
||||
# Calculate total cost
|
||||
total_cost = base_price + complexity_cost + texture_cost
|
||||
|
||||
return {
|
||||
'base_price': round(base_price, 2),
|
||||
'complexity_cost': round(complexity_cost, 2),
|
||||
'texture_cost': round(texture_cost, 2),
|
||||
'total_cost': round(total_cost, 2)
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
from ..models import CostFactor
|
||||
from .. import db
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Single source of truth for default cost factors
|
||||
DEFAULT_COST_FACTORS = [
|
||||
# Mold Type Factors
|
||||
{'category': 'mold', 'name': 'Flat', 'factor_value': 1.0, 'description': 'Standard flat outsole'},
|
||||
{'category': 'mold', 'name': 'Flat with Top/Heel Tip', 'factor_value': 1.25, 'description': 'Flat with additional tip features'},
|
||||
{'category': 'mold', 'name': 'Top PL', 'factor_value': 1.05, 'description': 'Top part line mold'},
|
||||
{'category': 'mold', 'name': 'Visible PL', 'factor_value': 1.28, 'description': 'Visible part line mold'},
|
||||
{'category': 'mold', 'name': 'Short PL (Heel/Forefoot)', 'factor_value': 1.18, 'description': 'Short part line for specific areas'},
|
||||
{'category': 'mold', 'name': 'Wave PL', 'factor_value': 1.32, 'description': 'Wave pattern part line'},
|
||||
{'category': 'mold', 'name': 'Multi-color PL', 'factor_value': 1.35, 'description': 'Multi-color part line mold'},
|
||||
{'category': 'mold', 'name': '2 Plates Mold', 'factor_value': 1.0, 'description': 'Standard 2-plate configuration'},
|
||||
{'category': 'mold', 'name': '3 Plates Mold', 'factor_value': 1.35, 'description': 'Complex 3-plate configuration'},
|
||||
{'category': 'mold', 'name': '2 Plates with in-mold channel', 'factor_value': 1.25, 'description': '2-plate with channel features'},
|
||||
{'category': 'mold', 'name': 'Breathable + in-mold channel', 'factor_value': 1.45, 'description': 'Breathable with channel features'},
|
||||
{'category': 'mold', 'name': 'Sandal with Upper Bandage', 'factor_value': 1.15, 'description': 'Sandal with bandage features'},
|
||||
{'category': 'mold', 'name': '2 Plate without Upper Bandage', 'factor_value': 1.05, 'description': 'Standard 2-plate sandal'},
|
||||
{'category': 'mold', 'name': 'Co-shot mold', 'factor_value': 1.25, 'description': 'Multi-material injection'},
|
||||
{'category': 'mold', 'name': 'Foaming mold', 'factor_value': 1.4, 'description': 'Foam injection process'},
|
||||
|
||||
# Complexity Factors
|
||||
{'category': 'complexity', 'name': 'high_sidewall', 'factor_value': 0.18, 'calculation_formula': 'Base Price × 0.18', 'description': 'Additional cost for high sidewall molds'},
|
||||
{'category': 'complexity', 'name': 'slider', 'factor_value': 0.1, 'calculation_formula': 'Base Price × (Number of Sliders × 0.10)', 'description': 'Cost per slider added to mold'},
|
||||
{'category': 'complexity', 'name': 'cavity', 'factor_value': 0.12, 'calculation_formula': 'Base Price × ((Number of Cavities - 1) × 0.12)', 'description': 'Cost for additional cavities beyond 1'},
|
||||
|
||||
# Texture Factors
|
||||
{'category': 'texture', 'name': 'adidas_digital', 'factor_value': 0.0, 'percentage': '0%', 'description': 'No additional cost for standard adidas texture'},
|
||||
{'category': 'texture', 'name': 'customized texture', 'factor_value': 0.15, 'percentage': '15%', 'description': '15% of base price for custom texture'},
|
||||
{'category': 'texture', 'name': 'laser', 'factor_value': 0.2, 'percentage': '20%', 'description': '20% of base price for laser texture'},
|
||||
|
||||
# Tax Rates
|
||||
{'category': 'tax', 'name': 'China', 'factor_value': 0.06, 'percentage': '6%', 'description': '6% VAT for China'},
|
||||
{'category': 'tax', 'name': 'Vietnam', 'factor_value': 0.10, 'percentage': '10%', 'description': '10% VAT for Vietnam'},
|
||||
{'category': 'tax', 'name': 'Indonesia', 'factor_value': 0.07, 'percentage': '7%', 'description': '7% VAT for Indonesia'},
|
||||
|
||||
# Base Price
|
||||
{'category': 'base', 'name': 'DEFAULT_NET_BASE', 'factor_value': 12.5, 'description': 'Default base price for calculations'}
|
||||
]
|
||||
|
||||
def load_cost_factors_from_db():
|
||||
"""
|
||||
Load cost factors from database and return them in the same format as constants.
|
||||
Falls back to hardcoded values if database is not available.
|
||||
"""
|
||||
try:
|
||||
# Get all active cost factors
|
||||
factors = CostFactor.query.filter_by(is_active=True).all()
|
||||
|
||||
if not factors:
|
||||
logger.warning("No cost factors found in database, using hardcoded defaults")
|
||||
return get_default_cost_factors()
|
||||
|
||||
# Build the cost factors structure
|
||||
cost_factors = {
|
||||
'mold': {},
|
||||
'complexity': {},
|
||||
'texture': {}
|
||||
}
|
||||
|
||||
tax_rates = {}
|
||||
default_net_base = 12.5 # Default value
|
||||
|
||||
for factor in factors:
|
||||
if factor.category == 'mold':
|
||||
cost_factors['mold'][factor.name] = factor.factor_value
|
||||
elif factor.category == 'complexity':
|
||||
cost_factors['complexity'][factor.name] = factor.factor_value
|
||||
elif factor.category == 'texture':
|
||||
cost_factors['texture'][factor.name] = factor.factor_value
|
||||
elif factor.category == 'tax':
|
||||
tax_rates[factor.name] = factor.factor_value
|
||||
elif factor.category == 'base':
|
||||
default_net_base = factor.factor_value
|
||||
|
||||
logger.info(f"Loaded {len(factors)} cost factors from database")
|
||||
return cost_factors, tax_rates, default_net_base
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading cost factors from database: {str(e)}")
|
||||
logger.info("Falling back to hardcoded cost factors")
|
||||
return get_default_cost_factors()
|
||||
|
||||
def get_default_cost_factors():
|
||||
"""Return hardcoded cost factors as fallback - derived from single source of truth"""
|
||||
cost_factors = {
|
||||
'mold': {},
|
||||
'complexity': {},
|
||||
'texture': {}
|
||||
}
|
||||
|
||||
tax_rates = {}
|
||||
default_net_base = 12.5
|
||||
|
||||
# Build the structure from the single source of truth
|
||||
for factor_data in DEFAULT_COST_FACTORS:
|
||||
category = factor_data['category']
|
||||
name = factor_data['name']
|
||||
value = factor_data['factor_value']
|
||||
|
||||
if category == 'mold':
|
||||
cost_factors['mold'][name] = value
|
||||
elif category == 'complexity':
|
||||
cost_factors['complexity'][name] = value
|
||||
elif category == 'texture':
|
||||
cost_factors['texture'][name] = value
|
||||
elif category == 'tax':
|
||||
tax_rates[name] = value
|
||||
elif category == 'base':
|
||||
default_net_base = value
|
||||
|
||||
return cost_factors, tax_rates, default_net_base
|
||||
|
||||
def get_cost_factors():
|
||||
"""Get current cost factors (from database or defaults)"""
|
||||
return load_cost_factors_from_db()
|
||||
|
||||
def initialize_default_cost_factors():
|
||||
"""Initialize the database with default cost factors if none exist"""
|
||||
try:
|
||||
if CostFactor.query.count() == 0:
|
||||
logger.info("Initializing database with default cost factors")
|
||||
|
||||
# Use the single source of truth
|
||||
for factor_data in DEFAULT_COST_FACTORS:
|
||||
factor = CostFactor(**factor_data)
|
||||
db.session.add(factor)
|
||||
|
||||
db.session.commit()
|
||||
logger.info(f"Initialized {len(DEFAULT_COST_FACTORS)} default cost factors")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error initializing default cost factors: {str(e)}")
|
||||
db.session.rollback()
|
||||
raise
|
||||
@@ -0,0 +1,64 @@
|
||||
import os
|
||||
import sys
|
||||
import ctypes
|
||||
from ctypes.util import find_library
|
||||
from pathlib import Path
|
||||
|
||||
class DLLLoaderError(Exception):
|
||||
"""自定义DLL加载异常"""
|
||||
pass
|
||||
|
||||
def setup_dependencies(dll_paths=None):
|
||||
"""配置DLL搜索路径"""
|
||||
if dll_paths is None:
|
||||
dll_paths = [
|
||||
Path(__file__).parent / 'libs',
|
||||
Path(os.getenv('LOCALAPPDATA')) / 'MSYS2' / 'mingw64' / 'bin'
|
||||
]
|
||||
|
||||
os.environ['PATH'] = os.pathsep.join([
|
||||
str(p.resolve()) for p in dll_paths if p.exists()
|
||||
]) + os.pathsep + os.environ.get('PATH', '')
|
||||
|
||||
def load_dll(dll_name, mode=ctypes.DEFAULT_MODE, handle_errors=True):
|
||||
"""增强版DLL加载函数"""
|
||||
try:
|
||||
# 尝试标准加载方式
|
||||
dll = ctypes.CDLL(dll_name, mode=mode)
|
||||
print(f"✅ 成功加载 {dll_name}")
|
||||
return dll
|
||||
except OSError as e:
|
||||
if handle_errors:
|
||||
# 尝试通过PATH查找
|
||||
lib_path = find_library(dll_name)
|
||||
if lib_path:
|
||||
return ctypes.CDLL(lib_path, mode=mode)
|
||||
|
||||
# 尝试MSYS2路径
|
||||
msys_path = Path(os.getenv('LOCALAPPDATA')) / 'MSYS2' / 'mingw64' / 'bin' / dll_name
|
||||
if msys_path.exists():
|
||||
return ctypes.CDLL(str(msys_path), mode=mode)
|
||||
|
||||
# 生成详细错误报告
|
||||
path_list = os.environ.get('PATH', '').split(os.pathsep)
|
||||
raise DLLLoaderError(
|
||||
f"无法加载 {dll_name}\n"
|
||||
f"错误详情: {str(e)}\n"
|
||||
f"搜索路径:\n• " + "\n• ".join(path_list)
|
||||
)
|
||||
else:
|
||||
raise
|
||||
|
||||
# 自动配置默认路径
|
||||
try:
|
||||
setup_dependencies()
|
||||
except Exception as e:
|
||||
print(f"⚠️ 初始化依赖失败: {e}")
|
||||
|
||||
if __name__ == '__main__':
|
||||
# 自测试代码
|
||||
try:
|
||||
test_dll = load_dll('gtk-3-0.dll')
|
||||
print(f"测试DLL句柄: {test_dll._handle}")
|
||||
except DLLLoaderError as e:
|
||||
print(f"❌ 自测试失败: {e}")
|
||||
@@ -0,0 +1,47 @@
|
||||
#!/bin/bash
|
||||
# A universal deployment script that can be run from a local machine or directly on the server.
|
||||
#
|
||||
# - When run locally, it will SSH to the server and execute the deployment.
|
||||
# - When run on the server, it will execute the deployment directly.
|
||||
|
||||
set -e
|
||||
|
||||
# --- Configuration ---
|
||||
REMOTE_USER="jimmyg"
|
||||
REMOTE_HOST="server1"
|
||||
REMOTE_DIR="/home/jimmyg/mold_cost_online_tool"
|
||||
SERVER_HOSTNAME="server1" # The hostname of the production server
|
||||
|
||||
# --- Deployment Logic ---
|
||||
deploy_commands() {
|
||||
echo ">>> [1/4] Navigating to the application directory..."
|
||||
cd "$REMOTE_DIR"
|
||||
|
||||
echo ">>> [2/4] Pulling latest changes from Git..."
|
||||
git pull
|
||||
|
||||
echo ">>> [3/4] Building new container image with the latest code..."
|
||||
podman-compose build
|
||||
|
||||
echo ">>> [4/4] Restarting services with the updated image..."
|
||||
podman-compose down
|
||||
podman-compose up -d
|
||||
|
||||
echo ""
|
||||
echo "✅ Deployment finished successfully on $REMOTE_HOST!"
|
||||
}
|
||||
|
||||
# --- Execution ---
|
||||
# Get the hostname of the machine this script is running on
|
||||
current_hostname=$(hostname)
|
||||
|
||||
if [ "$current_hostname" == "$SERVER_HOSTNAME" ]; then
|
||||
echo "🚀 Running deployment script directly on the server ($current_hostname)..."
|
||||
deploy_commands
|
||||
else
|
||||
echo "🚀 Running deployment script from local machine ($current_hostname)..."
|
||||
echo "Connecting to $REMOTE_USER@$REMOTE_HOST..."
|
||||
# Using 'bash -s' allows us to execute the function on the remote server.
|
||||
# We pass the function definition and then call it.
|
||||
ssh "$REMOTE_USER@$REMOTE_HOST" "$(declare -f deploy_commands); deploy_commands"
|
||||
fi
|
||||
@@ -0,0 +1,40 @@
|
||||
# Flask Configuration
|
||||
FLASK_APP=run.py
|
||||
FLASK_ENV=production
|
||||
FLASK_DEBUG=0
|
||||
|
||||
# Security
|
||||
SECRET_KEY=your_production_secret_key_here
|
||||
|
||||
# Database
|
||||
DATABASE_URL=postgresql://username:password@localhost/mold_cost_calculator
|
||||
|
||||
# Email Configuration
|
||||
MAIL_SERVER=smtp.gmail.com
|
||||
MAIL_PORT=587
|
||||
MAIL_USE_TLS=True
|
||||
MAIL_USERNAME=your_production_email@gmail.com
|
||||
MAIL_PASSWORD=your_production_email_password
|
||||
MAIL_DEFAULT_SENDER=your_production_email@gmail.com
|
||||
|
||||
# Admin Account
|
||||
ADMIN_USERNAME=admin
|
||||
ADMIN_EMAIL=admin@example.com
|
||||
ADMIN_PASSWORD=change_this_to_secure_password
|
||||
ADMIN_COMPANY="System Administration"
|
||||
|
||||
# Logging
|
||||
LOG_LEVEL=INFO
|
||||
LOG_TO_STDOUT=False
|
||||
|
||||
# Redis Configuration (for rate limiting and caching)
|
||||
REDIS_URL=redis://localhost:6379/0
|
||||
RATELIMIT_STORAGE_URL=redis://localhost:6379/0
|
||||
CACHE_REDIS_URL=redis://localhost:6379/1
|
||||
|
||||
# Gunicorn Configuration
|
||||
WORKERS=4
|
||||
THREADS=2
|
||||
TIMEOUT=120
|
||||
MAX_REQUESTS=1000
|
||||
MAX_REQUESTS_JITTER=50
|
||||
Executable
+39
@@ -0,0 +1,39 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Configuration
|
||||
BACKUP_DIR="/backups"
|
||||
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
|
||||
DB_HOST="mold_cost_db"
|
||||
DB_NAME="mold_cost"
|
||||
DB_USER="postgres"
|
||||
DB_PASSWORD="postgres"
|
||||
|
||||
# Create backup directory if it doesn't exist
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
|
||||
# Set PostgreSQL password environment variable
|
||||
export PGPASSWORD="$DB_PASSWORD"
|
||||
|
||||
# Create backup with error handling
|
||||
echo "Creating database backup at $(date)..."
|
||||
if pg_dump -h "$DB_HOST" -U "$DB_USER" -d "$DB_NAME" --no-password | gzip > "$BACKUP_DIR/backup_$TIMESTAMP.sql.gz"; then
|
||||
echo "Backup completed successfully: backup_$TIMESTAMP.sql.gz"
|
||||
|
||||
# Keep only the last 7 backups
|
||||
echo "Cleaning up old backups..."
|
||||
ls -t "$BACKUP_DIR"/backup_*.sql.gz | tail -n +8 | xargs -r rm
|
||||
|
||||
# Verify backup file exists and has content
|
||||
if [ -f "$BACKUP_DIR/backup_$TIMESTAMP.sql.gz" ] && [ -s "$BACKUP_DIR/backup_$TIMESTAMP.sql.gz" ]; then
|
||||
echo "Backup verification successful: $(du -h "$BACKUP_DIR/backup_$TIMESTAMP.sql.gz" | cut -f1)"
|
||||
else
|
||||
echo "ERROR: Backup file is empty or missing!"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo "ERROR: Backup failed!"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Clear password from environment
|
||||
unset PGPASSWORD
|
||||
@@ -0,0 +1,164 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Backup management script for Mold Cost Calculator
|
||||
# This script provides easy commands for backup operations
|
||||
|
||||
set -e
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# Function to print colored output
|
||||
print_status() {
|
||||
echo -e "${GREEN}[INFO]${NC} $1"
|
||||
}
|
||||
|
||||
print_warning() {
|
||||
echo -e "${YELLOW}[WARNING]${NC} $1"
|
||||
}
|
||||
|
||||
print_error() {
|
||||
echo -e "${RED}[ERROR]${NC} $1"
|
||||
}
|
||||
|
||||
print_header() {
|
||||
echo -e "${BLUE}=== $1 ===${NC}"
|
||||
}
|
||||
|
||||
# Function to create manual backup
|
||||
create_backup() {
|
||||
print_header "Creating Manual Backup"
|
||||
if podman-compose exec backup /backup.sh; then
|
||||
print_status "Manual backup completed successfully!"
|
||||
else
|
||||
print_error "Manual backup failed!"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Function to list backups
|
||||
list_backups() {
|
||||
print_header "Available Backups"
|
||||
if [ -d "./backups" ]; then
|
||||
if [ "$(ls -A ./backups)" ]; then
|
||||
ls -lah ./backups/backup_*.sql.gz 2>/dev/null | while read -r line; do
|
||||
echo " $line"
|
||||
done
|
||||
else
|
||||
print_warning "No backup files found in ./backups directory"
|
||||
fi
|
||||
else
|
||||
print_warning "Backups directory does not exist"
|
||||
fi
|
||||
}
|
||||
|
||||
# Function to restore from backup
|
||||
restore_backup() {
|
||||
local backup_file="$1"
|
||||
|
||||
if [ -z "$backup_file" ]; then
|
||||
print_error "Please specify a backup file to restore from."
|
||||
echo "Usage: $0 restore <backup_file>"
|
||||
echo "Example: $0 restore ./backups/backup_20241201_120000.sql.gz"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -f "$backup_file" ]; then
|
||||
print_error "Backup file '$backup_file' not found!"
|
||||
list_backups
|
||||
exit 1
|
||||
fi
|
||||
|
||||
print_header "Database Restore"
|
||||
print_warning "This will overwrite the current database!"
|
||||
echo "Backup file: $backup_file"
|
||||
read -p "Are you sure you want to continue? (y/N): " -n 1 -r
|
||||
echo
|
||||
|
||||
if [[ $REPLY =~ ^[Yy]$ ]]; then
|
||||
print_status "Stopping application..."
|
||||
podman-compose stop app
|
||||
|
||||
print_status "Restoring database..."
|
||||
if podman-compose exec backup /restore.sh restore "/backups/$(basename "$backup_file")"; then
|
||||
print_status "Database restored successfully!"
|
||||
print_status "Starting application..."
|
||||
podman-compose start app
|
||||
print_status "Restore completed!"
|
||||
else
|
||||
print_error "Database restore failed!"
|
||||
print_status "Starting application..."
|
||||
podman-compose start app
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
print_status "Restore cancelled."
|
||||
fi
|
||||
}
|
||||
|
||||
# Function to show backup status
|
||||
show_status() {
|
||||
print_header "Backup Service Status"
|
||||
|
||||
# Check if backup container is running
|
||||
if podman-compose ps backup | grep -q "Up"; then
|
||||
print_status "Backup service is running"
|
||||
else
|
||||
print_error "Backup service is not running"
|
||||
fi
|
||||
|
||||
# Show recent backup logs
|
||||
print_header "Recent Backup Logs"
|
||||
podman-compose logs --tail=10 backup
|
||||
|
||||
# List available backups
|
||||
list_backups
|
||||
}
|
||||
|
||||
# Function to show help
|
||||
show_help() {
|
||||
print_header "Backup Management Script"
|
||||
echo "Usage: $0 [command] [options]"
|
||||
echo ""
|
||||
echo "Commands:"
|
||||
echo " backup - Create a manual backup"
|
||||
echo " list - List available backups"
|
||||
echo " restore <backup_file> - Restore database from backup"
|
||||
echo " status - Show backup service status"
|
||||
echo " help - Show this help message"
|
||||
echo ""
|
||||
echo "Examples:"
|
||||
echo " $0 backup"
|
||||
echo " $0 list"
|
||||
echo " $0 restore ./backups/backup_20241201_120000.sql.gz"
|
||||
echo " $0 status"
|
||||
}
|
||||
|
||||
# Main script logic
|
||||
case "${1:-help}" in
|
||||
"backup"|"create")
|
||||
create_backup
|
||||
;;
|
||||
"list"|"ls")
|
||||
list_backups
|
||||
;;
|
||||
"restore")
|
||||
restore_backup "$2"
|
||||
;;
|
||||
"status")
|
||||
show_status
|
||||
;;
|
||||
"help"|"--help"|"-h"|"")
|
||||
show_help
|
||||
;;
|
||||
*)
|
||||
print_error "Unknown command '$1'"
|
||||
echo ""
|
||||
show_help
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
@@ -0,0 +1,44 @@
|
||||
import os
|
||||
import sys
|
||||
import psycopg2
|
||||
from pathlib import Path
|
||||
|
||||
def get_db_url():
|
||||
"""Get database URL from environment variables."""
|
||||
db_user = os.getenv('DB_USER', 'mold_user')
|
||||
db_password = os.getenv('DB_PASSWORD', 'mold_password')
|
||||
db_host = os.getenv('DB_HOST', 'localhost')
|
||||
db_port = os.getenv('DB_PORT', '5434')
|
||||
db_name = os.getenv('DB_NAME', 'mold_cost_calculator_dev')
|
||||
|
||||
return f"postgresql://{db_user}:{db_password}@{db_host}:{db_port}/{db_name}"
|
||||
|
||||
def run_migration():
|
||||
# Get database URL from environment
|
||||
db_url = get_db_url()
|
||||
|
||||
try:
|
||||
# Connect to the database
|
||||
conn = psycopg2.connect(db_url)
|
||||
conn.autocommit = True
|
||||
cur = conn.cursor()
|
||||
|
||||
# Read and execute the migration SQL
|
||||
migration_file = Path(__file__).parent / 'update_mold_types.sql'
|
||||
with open(migration_file, 'r') as f:
|
||||
sql = f.read()
|
||||
cur.execute(sql)
|
||||
|
||||
print("Migration completed successfully!")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error running migration: {str(e)}")
|
||||
sys.exit(1)
|
||||
finally:
|
||||
if 'cur' in locals():
|
||||
cur.close()
|
||||
if 'conn' in locals():
|
||||
conn.close()
|
||||
|
||||
if __name__ == '__main__':
|
||||
run_migration()
|
||||
Executable
+15
@@ -0,0 +1,15 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Exit on error
|
||||
set -e
|
||||
|
||||
# Change to the migrations directory
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
echo "Running database migration..."
|
||||
python3 run_migration.py
|
||||
|
||||
echo "Testing migration..."
|
||||
python3 test_mold_types.py
|
||||
|
||||
echo "Migration completed successfully!"
|
||||
@@ -0,0 +1,82 @@
|
||||
import os
|
||||
import sys
|
||||
import psycopg2
|
||||
from pathlib import Path
|
||||
|
||||
def get_db_url():
|
||||
"""Get database URL from environment variables."""
|
||||
db_user = os.getenv('DB_USER', 'mold_user')
|
||||
db_password = os.getenv('DB_PASSWORD', 'mold_password')
|
||||
db_host = os.getenv('DB_HOST', 'localhost')
|
||||
db_port = os.getenv('DB_PORT', '5434')
|
||||
db_name = os.getenv('DB_NAME', 'mold_cost_calculator_dev')
|
||||
|
||||
return f"postgresql://{db_user}:{db_password}@{db_host}:{db_port}/{db_name}"
|
||||
|
||||
def test_migration():
|
||||
# Get database URL from environment
|
||||
db_url = get_db_url()
|
||||
|
||||
try:
|
||||
# Connect to the database
|
||||
conn = psycopg2.connect(db_url)
|
||||
cur = conn.cursor()
|
||||
|
||||
# Check if the column exists
|
||||
cur.execute("""
|
||||
SELECT column_name
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'mold_quotations'
|
||||
AND column_name = 'mold_specific_type'
|
||||
""")
|
||||
|
||||
if cur.fetchone():
|
||||
print("✅ mold_specific_type column exists")
|
||||
else:
|
||||
print("❌ mold_specific_type column does not exist")
|
||||
return False
|
||||
|
||||
# Check if the column is NOT NULL
|
||||
cur.execute("""
|
||||
SELECT is_nullable
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'mold_quotations'
|
||||
AND column_name = 'mold_specific_type'
|
||||
""")
|
||||
|
||||
is_nullable = cur.fetchone()[0]
|
||||
if is_nullable == 'NO':
|
||||
print("✅ mold_specific_type column is NOT NULL")
|
||||
else:
|
||||
print("❌ mold_specific_type column is nullable")
|
||||
return False
|
||||
|
||||
# Check if all records have a value
|
||||
cur.execute("""
|
||||
SELECT COUNT(*)
|
||||
FROM mold_quotations
|
||||
WHERE mold_specific_type IS NULL
|
||||
""")
|
||||
|
||||
null_count = cur.fetchone()[0]
|
||||
if null_count == 0:
|
||||
print("✅ All records have a mold_specific_type value")
|
||||
else:
|
||||
print(f"❌ Found {null_count} records with NULL mold_specific_type")
|
||||
return False
|
||||
|
||||
print("All tests passed!")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error testing migration: {str(e)}")
|
||||
return False
|
||||
finally:
|
||||
if 'cur' in locals():
|
||||
cur.close()
|
||||
if 'conn' in locals():
|
||||
conn.close()
|
||||
|
||||
if __name__ == '__main__':
|
||||
success = test_migration()
|
||||
sys.exit(0 if success else 1)
|
||||
@@ -0,0 +1,92 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Configuration
|
||||
BACKUP_DIR="/backups"
|
||||
DB_HOST="mold_cost_db"
|
||||
DB_NAME="mold_cost"
|
||||
DB_USER="postgres"
|
||||
DB_PASSWORD="postgres"
|
||||
|
||||
# Set PostgreSQL password environment variable
|
||||
export PGPASSWORD="$DB_PASSWORD"
|
||||
|
||||
# Function to list available backups
|
||||
list_backups() {
|
||||
echo "Available backups:"
|
||||
ls -la "$BACKUP_DIR"/backup_*.sql.gz 2>/dev/null | while read -r line; do
|
||||
echo " $line"
|
||||
done
|
||||
}
|
||||
|
||||
# Function to restore from backup
|
||||
restore_backup() {
|
||||
local backup_file="$1"
|
||||
|
||||
if [ ! -f "$backup_file" ]; then
|
||||
echo "ERROR: Backup file '$backup_file' not found!"
|
||||
list_backups
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "WARNING: This will overwrite the current database!"
|
||||
echo "Backup file: $backup_file"
|
||||
read -p "Are you sure you want to continue? (y/N): " -n 1 -r
|
||||
echo
|
||||
|
||||
if [[ $REPLY =~ ^[Yy]$ ]]; then
|
||||
echo "Restoring database from backup..."
|
||||
|
||||
# Drop and recreate database
|
||||
echo "Dropping existing database..."
|
||||
dropdb -h "$DB_HOST" -U "$DB_USER" --no-password "$DB_NAME" 2>/dev/null || true
|
||||
|
||||
echo "Creating new database..."
|
||||
createdb -h "$DB_HOST" -U "$DB_USER" --no-password "$DB_NAME"
|
||||
|
||||
echo "Restoring data..."
|
||||
if gunzip -c "$backup_file" | psql -h "$DB_HOST" -U "$DB_USER" -d "$DB_NAME" --no-password; then
|
||||
echo "Database restored successfully!"
|
||||
else
|
||||
echo "ERROR: Database restore failed!"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo "Restore cancelled."
|
||||
fi
|
||||
}
|
||||
|
||||
# Main script logic
|
||||
if [ $# -eq 0 ]; then
|
||||
echo "Usage: $0 [list|restore <backup_file>]"
|
||||
echo ""
|
||||
echo "Commands:"
|
||||
echo " list - List available backups"
|
||||
echo " restore <backup_file> - Restore database from backup file"
|
||||
echo ""
|
||||
echo "Examples:"
|
||||
echo " $0 list"
|
||||
echo " $0 restore /backups/backup_20241201_120000.sql.gz"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
case "$1" in
|
||||
"list")
|
||||
list_backups
|
||||
;;
|
||||
"restore")
|
||||
if [ -z "$2" ]; then
|
||||
echo "ERROR: Please specify a backup file to restore from."
|
||||
echo "Usage: $0 restore <backup_file>"
|
||||
exit 1
|
||||
fi
|
||||
restore_backup "$2"
|
||||
;;
|
||||
*)
|
||||
echo "ERROR: Unknown command '$1'"
|
||||
echo "Usage: $0 [list|restore <backup_file>]"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
# Clear password from environment
|
||||
unset PGPASSWORD
|
||||
@@ -0,0 +1,74 @@
|
||||
import multiprocessing
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
# Create logs directory if it doesn't exist
|
||||
os.makedirs('logs', exist_ok=True)
|
||||
|
||||
# Server socket
|
||||
bind = os.getenv('GUNICORN_BIND', '0.0.0.0:5002')
|
||||
backlog = 2048
|
||||
|
||||
# Worker processes
|
||||
# Formula: (2 x number_of_cores) + 1
|
||||
workers = int(os.getenv('GUNICORN_WORKERS', 13)) # Default to 13 workers
|
||||
worker_class = 'sync'
|
||||
worker_connections = 1000
|
||||
timeout = int(os.getenv('GUNICORN_TIMEOUT', 30))
|
||||
keepalive = 2
|
||||
|
||||
# Performance optimizations
|
||||
worker_tmp_dir = '/dev/shm' # Use RAM for temporary files
|
||||
limit_request_line = 4094 # Prevent large request attacks
|
||||
limit_request_fields = 100 # Limit number of request headers
|
||||
|
||||
# Logging
|
||||
accesslog = 'logs/access.log'
|
||||
errorlog = 'logs/error.log'
|
||||
loglevel = os.getenv('LOG_LEVEL', 'debug')
|
||||
|
||||
# Process naming
|
||||
proc_name = 'mold_cost_calculator'
|
||||
|
||||
# Server mechanics
|
||||
daemon = False
|
||||
pidfile = 'gunicorn.pid'
|
||||
umask = 0
|
||||
user = None
|
||||
group = None
|
||||
tmp_upload_dir = None
|
||||
|
||||
# Server hooks
|
||||
def on_starting(server):
|
||||
# Ensure instance directory exists
|
||||
os.makedirs('instance', exist_ok=True)
|
||||
|
||||
def on_reload(server):
|
||||
pass
|
||||
|
||||
def on_exit(server):
|
||||
pass
|
||||
|
||||
# Request limits
|
||||
max_requests = 1000
|
||||
max_requests_jitter = 50
|
||||
|
||||
# Worker settings
|
||||
graceful_timeout = 30
|
||||
preload_app = True
|
||||
|
||||
# Environment variables
|
||||
raw_env = [
|
||||
'FLASK_APP=run.py',
|
||||
'FLASK_ENV=production',
|
||||
'FLASK_DEBUG=0'
|
||||
]
|
||||
|
||||
# Security settings
|
||||
trusted_proxies = os.getenv('TRUSTED_PROXY_IPS', '127.0.0.1,::1').split(',')
|
||||
forwarded_allow_ips = ','.join(ip.strip() for ip in trusted_proxies)
|
||||
secure_scheme_headers = {
|
||||
'X-FORWARDED-PROTOCOL': 'ssl',
|
||||
'X-FORWARDED-PROTO': 'https',
|
||||
'X-FORWARDED-SSL': 'on'
|
||||
}
|
||||
Executable
+161
@@ -0,0 +1,161 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# Function to check internet connectivity
|
||||
check_internet() {
|
||||
if ! curl -s --connect-timeout 5 https://registry-1.docker.io/v2/ > /dev/null; then
|
||||
echo "⚠️ No internet connection to Docker Hub - will use local images only"
|
||||
return 1
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
# Function to pull image with retries
|
||||
pull_image() {
|
||||
local image=$1
|
||||
local max_attempts=3
|
||||
local attempt=1
|
||||
local wait_time=5
|
||||
|
||||
while [ $attempt -le $max_attempts ]; do
|
||||
echo "📥 Attempting to pull $image (Attempt $attempt/$max_attempts)..."
|
||||
if podman pull $image; then
|
||||
echo "✅ Successfully pulled $image"
|
||||
return 0
|
||||
fi
|
||||
echo "⚠️ Pull attempt $attempt failed. Waiting before retry..."
|
||||
sleep $wait_time
|
||||
attempt=$((attempt + 1))
|
||||
wait_time=$((wait_time * 2))
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# Function to clean up while preserving base images
|
||||
cleanup_with_preserve() {
|
||||
echo "🧹 Cleaning up while preserving base images..."
|
||||
# Save the base image IDs if they exist
|
||||
local python_image_id=$(podman images -q python:3.12-slim)
|
||||
local postgres_image_id=$(podman images -q postgres:16-alpine)
|
||||
|
||||
# Run system prune
|
||||
podman system prune -af
|
||||
|
||||
# If we had base images, pull them again
|
||||
if [ ! -z "$python_image_id" ]; then
|
||||
echo "🔄 Restoring Python base image..."
|
||||
pull_image python:3.12-slim
|
||||
fi
|
||||
if [ ! -z "$postgres_image_id" ]; then
|
||||
echo "🔄 Restoring PostgreSQL base image..."
|
||||
pull_image postgres:16-alpine
|
||||
fi
|
||||
}
|
||||
|
||||
echo "🚀 Starting local deployment process..."
|
||||
|
||||
# Check available disk space
|
||||
echo "💾 Checking disk space..."
|
||||
AVAILABLE_SPACE=$(df -h . | awk 'NR==2 {print $4}' | tr -d 'G' | tr -d 'i')
|
||||
if [ "$AVAILABLE_SPACE" -lt 10 ]; then
|
||||
echo "⚠️ Low disk space detected (${AVAILABLE_SPACE}G available). Cleaning up..."
|
||||
cleanup_with_preserve
|
||||
echo "✅ Cleanup complete"
|
||||
fi
|
||||
|
||||
# Check for required base images
|
||||
echo "🔍 Checking for required base images..."
|
||||
for image in "python:3.12-slim" "postgres:16-alpine"; do
|
||||
if podman images | grep -q "$image"; then
|
||||
echo "✅ $image found locally"
|
||||
else
|
||||
echo "⚠️ $image not found locally"
|
||||
if ! check_internet; then
|
||||
echo "⚠️ No internet connection - attempting to build without base image"
|
||||
else
|
||||
echo "📦 Pulling $image from Docker Hub..."
|
||||
if ! pull_image "$image"; then
|
||||
echo "⚠️ Failed to pull $image - attempting to build without it"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
# Create necessary directories and set permissions
|
||||
echo "📁 Setting up directories..."
|
||||
mkdir -p instance logs db_data db_backups
|
||||
chmod -R 777 instance logs db_data db_backups
|
||||
|
||||
# Generate a secure secret key if not exists
|
||||
if [ ! -f .env ]; then
|
||||
echo "🔑 Generating new secret key..."
|
||||
echo "SECRET_KEY=$(openssl rand -hex 32)" > .env
|
||||
echo "DATABASE_URL=postgresql://mold_user:mold_password@db:5432/mold_cost_calculator" >> .env
|
||||
fi
|
||||
|
||||
# Clear template cache
|
||||
echo "🧹 Clearing template cache..."
|
||||
find . -type d -name "__pycache__" -exec rm -r {} +
|
||||
find . -type f -name "*.pyc" -delete
|
||||
|
||||
# Stop and remove existing containers
|
||||
echo "🧹 Cleaning up existing containers..."
|
||||
podman-compose down 2>/dev/null || true
|
||||
|
||||
# Build the containers with retry logic
|
||||
echo "📦 Building containers..."
|
||||
max_build_attempts=3
|
||||
build_attempt=1
|
||||
|
||||
while [ $build_attempt -le $max_build_attempts ]; do
|
||||
echo "🏗️ Build attempt $build_attempt/$max_build_attempts..."
|
||||
if podman-compose build; then
|
||||
echo "✅ Build successful"
|
||||
break
|
||||
fi
|
||||
if [ $build_attempt -eq $max_build_attempts ]; then
|
||||
echo "❌ Build failed after $max_build_attempts attempts"
|
||||
exit 1
|
||||
fi
|
||||
echo "⚠️ Build attempt $build_attempt failed. Waiting before retry..."
|
||||
sleep $((build_attempt * 5))
|
||||
build_attempt=$((build_attempt + 1))
|
||||
done
|
||||
|
||||
# Start the containers
|
||||
echo "🚀 Starting containers..."
|
||||
podman-compose up -d
|
||||
|
||||
# Wait for database to be ready
|
||||
echo "⏳ Waiting for database to be ready..."
|
||||
max_wait=30
|
||||
wait_count=0
|
||||
while [ $wait_count -lt $max_wait ]; do
|
||||
if podman exec mold_cost_db pg_isready -U mold_user -d mold_cost_calculator >/dev/null 2>&1; then
|
||||
echo "✅ Database is ready"
|
||||
break
|
||||
fi
|
||||
echo "⏳ Waiting for database... ($((wait_count + 1))/$max_wait)"
|
||||
sleep 1
|
||||
wait_count=$((wait_count + 1))
|
||||
done
|
||||
|
||||
if [ $wait_count -eq $max_wait ]; then
|
||||
echo "❌ Database failed to start within timeout"
|
||||
echo "📝 Checking container logs..."
|
||||
podman-compose logs db
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if the containers are running
|
||||
if podman-compose ps | grep -q "Up"; then
|
||||
echo "✅ Deployment complete!"
|
||||
echo "🌐 Application is available at: http://localhost:5003"
|
||||
echo "📝 To view logs: podman-compose logs"
|
||||
echo "📝 To view app logs: podman-compose logs web"
|
||||
echo "📝 To view db logs: podman-compose logs db"
|
||||
echo "🛑 To stop: podman-compose down"
|
||||
else
|
||||
echo "❌ Containers failed to start. Check logs with: podman-compose logs"
|
||||
exit 1
|
||||
fi
|
||||
@@ -0,0 +1,16 @@
|
||||
[Unit]
|
||||
Description=Mold Cost Calculator
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
User=jimmyg
|
||||
Group=jimmyg
|
||||
WorkingDirectory=/home/jimmyg/mold_cost_online_app
|
||||
Environment="PATH=/home/jimmyg/mold_cost_online_app/venv/bin"
|
||||
Environment="FLASK_APP=run.py"
|
||||
Environment="FLASK_ENV=production"
|
||||
ExecStart=/home/jimmyg/mold_cost_online_app/venv/bin/gunicorn -c gunicorn_config.py run:app
|
||||
Restart=always
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,59 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name moldcost.jimmygan.com;
|
||||
return 301 https://$server_name$request_uri;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name moldcost.jimmygan.com;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/moldcost.jimmygan.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/moldcost.jimmygan.com/privkey.pem;
|
||||
|
||||
# SSL 配置
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384;
|
||||
ssl_prefer_server_ciphers off;
|
||||
|
||||
# HSTS
|
||||
add_header Strict-Transport-Security "max-age=63072000" always;
|
||||
|
||||
# 其他安全头
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:;" always;
|
||||
add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always;
|
||||
|
||||
# 代理设置
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:5002;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# WebSocket 支持
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
|
||||
# 超时设置
|
||||
proxy_connect_timeout 60s;
|
||||
proxy_send_timeout 60s;
|
||||
proxy_read_timeout 60s;
|
||||
}
|
||||
|
||||
# 静态文件
|
||||
location /static {
|
||||
alias /home/jimmyg/mold_cost_online_tool/app/static;
|
||||
expires 30d;
|
||||
add_header Cache-Control "public, no-transform";
|
||||
}
|
||||
|
||||
# 日志
|
||||
access_log /var/log/nginx/moldcost_access.log;
|
||||
error_log /var/log/nginx/moldcost_error.log;
|
||||
}
|
||||
@@ -0,0 +1,422 @@
|
||||
# Mold Cost Calculator - Admin User Guide
|
||||
|
||||
## 📋 Table of Contents
|
||||
1. [System Overview](#system-overview)
|
||||
2. [Cost Calculation System](#cost-calculation-system)
|
||||
3. [Cost Factors & Pricing](#cost-factors--pricing)
|
||||
4. [Admin Panel Access](#admin-panel-access)
|
||||
5. [User Management](#user-management)
|
||||
6. [Quotation Management](#quotation-management)
|
||||
7. [System Configuration](#system-configuration)
|
||||
8. [Troubleshooting](#troubleshooting)
|
||||
|
||||
---
|
||||
|
||||
## 🎯 System Overview
|
||||
|
||||
The Mold Cost Calculator is a web-based application that calculates mold manufacturing costs based on various technical specifications and complexity factors. The system is designed for footwear industry professionals to get accurate cost estimates for different types of molds.
|
||||
|
||||
### Key Features
|
||||
- **Multi-factor cost calculation** based on mold type, complexity, and texture
|
||||
- **Country-specific tax rates** for accurate regional pricing
|
||||
- **User management system** with role-based access control
|
||||
- **Comprehensive admin panel** for system oversight
|
||||
- **Real-time cost breakdown** with detailed component analysis
|
||||
|
||||
---
|
||||
|
||||
## 💰 Cost Calculation System
|
||||
|
||||
### Base Calculation Formula
|
||||
|
||||
The system uses a multi-layered calculation approach:
|
||||
|
||||
```
|
||||
Total Cost = (Base Price + Complexity Cost + Texture Cost) × (1 + Tax Rate)
|
||||
```
|
||||
|
||||
Where:
|
||||
- **Base Price** = DEFAULT_NET_BASE × Mold Type Factor
|
||||
- **Complexity Cost** = Base Price × (Slider Factor + Cavity Factor + High Sidewall Factor)
|
||||
- **Texture Cost** = Base Price × Texture Factor
|
||||
|
||||
### Default Values
|
||||
- **DEFAULT_NET_BASE**: $12.50 (base unit cost)
|
||||
- **Tax Rates**: Varies by country (see below)
|
||||
|
||||
---
|
||||
|
||||
## 📊 Cost Factors & Pricing
|
||||
|
||||
### 1. Mold Type Factors
|
||||
|
||||
The system applies different multipliers based on the specific mold type:
|
||||
|
||||
#### Rubber Molds
|
||||
| Specific Type | Factor | Description |
|
||||
|---------------|--------|-------------|
|
||||
| Flat | 1.00 | Standard flat outsole |
|
||||
| Flat with Top/Heel Tip | 1.25 | Flat with additional tip features |
|
||||
| Top PL | 1.05 | Top part line mold |
|
||||
| Visible PL | 1.28 | Visible part line mold |
|
||||
| Short PL (Heel/Forefoot) | 1.18 | Short part line for specific areas |
|
||||
| Wave PL | 1.32 | Wave pattern part line |
|
||||
|
||||
#### CMEVA Molds
|
||||
| Specific Type | Factor | Description |
|
||||
|---------------|--------|-------------|
|
||||
| 2 Plates Mold | 1.00 | Standard 2-plate configuration |
|
||||
| 3 Plates Mold | 1.35 | Complex 3-plate configuration |
|
||||
| 2 Plates with in-mold channel | 1.25 | 2-plate with channel features |
|
||||
| Breathable + in-mold channel | 1.45 | Breathable with channel features |
|
||||
|
||||
#### IMEVA Molds
|
||||
| Specific Type | Factor | Description |
|
||||
|---------------|--------|-------------|
|
||||
| 2 Plates Mold | 1.00 | Standard 2-plate configuration |
|
||||
| 3 Plates Mold | 1.35 | Complex 3-plate configuration |
|
||||
|
||||
#### Sandal Molds
|
||||
| Specific Type | Factor | Description |
|
||||
|---------------|--------|-------------|
|
||||
| Sandal with Upper Bandage | 1.15 | Sandal with bandage features |
|
||||
| 2 Plate without Upper Bandage | 1.05 | Standard 2-plate sandal |
|
||||
|
||||
#### Co-shot Molds
|
||||
| Specific Type | Factor | Description |
|
||||
|---------------|--------|-------------|
|
||||
| Co-shot mold | 1.25 | Multi-material injection |
|
||||
| Foaming mold | 1.40 | Foam injection process |
|
||||
|
||||
#### PU Molds
|
||||
| Specific Type | Factor | Description |
|
||||
|---------------|--------|-------------|
|
||||
| 2 Plates Mold | 1.00 | Standard 2-plate configuration |
|
||||
| 3 Plates Mold | 1.35 | Complex 3-plate configuration |
|
||||
|
||||
### 2. Complexity Factors
|
||||
|
||||
Additional costs based on mold complexity:
|
||||
|
||||
| Complexity Type | Factor | Calculation |
|
||||
|-----------------|--------|-------------|
|
||||
| High Sidewall (>60mm) | 0.18 | Base Price × 0.18 |
|
||||
| Slider | 0.10 | Base Price × (Number of Sliders × 0.10) |
|
||||
| Cavity | 0.12 | Base Price × ((Number of Cavities - 1) × 0.12) |
|
||||
|
||||
### 3. Texture Factors
|
||||
|
||||
Digital texture processing costs:
|
||||
|
||||
| Texture Type | Factor | Description |
|
||||
|--------------|--------|-------------|
|
||||
| adidas digital | 0.00 | No additional cost |
|
||||
| customized texture | 0.15 | 15% of base price |
|
||||
| laser | 0.20 | 20% of base price |
|
||||
|
||||
### 4. Tax Rates by Country
|
||||
|
||||
| Country | Tax Rate | Percentage |
|
||||
|---------|----------|------------|
|
||||
| China | 0.06 | 6% |
|
||||
| Vietnam | 0.10 | 10% |
|
||||
| Indonesia | 0.07 | 7% |
|
||||
|
||||
---
|
||||
|
||||
## 🔐 Admin Panel Access
|
||||
|
||||
### Accessing the Admin Panel
|
||||
|
||||
1. **Login Requirements**: Must be logged in with admin privileges
|
||||
2. **URL**: Navigate to `/admin/dashboard`
|
||||
3. **Authentication**: System checks `current_user.is_admin` flag
|
||||
|
||||
### Admin Privileges Required
|
||||
- User account must have `is_admin = True` in the database
|
||||
- Admin users can access all system features
|
||||
- Regular users are redirected to login with access denied message
|
||||
|
||||
---
|
||||
|
||||
## 👥 User Management
|
||||
|
||||
### Viewing Users
|
||||
- **Location**: Admin Panel → Users
|
||||
- **Features**:
|
||||
- List all registered users
|
||||
- Search and filter functionality
|
||||
- Pagination (20 users per page)
|
||||
- User status indicators
|
||||
|
||||
### User Information Displayed
|
||||
- User ID
|
||||
- Username
|
||||
- Email address
|
||||
- Account status (Active/Inactive)
|
||||
- Role (Admin/User)
|
||||
- Last login date
|
||||
- Account creation date
|
||||
|
||||
### User Actions
|
||||
|
||||
#### View User Details
|
||||
- Click the eye icon (👁️) to view detailed user information
|
||||
- Shows user profile and quotation history
|
||||
|
||||
#### Toggle User Status
|
||||
- Click the toggle button to activate/deactivate users
|
||||
- Inactive users cannot log in
|
||||
- Confirmation dialog required
|
||||
|
||||
#### Delete User
|
||||
- **Restriction**: Cannot delete your own account
|
||||
- **Process**: Confirmation required before deletion
|
||||
- **Impact**: Permanently removes user and all associated data
|
||||
|
||||
### User Statistics
|
||||
- Total registered users
|
||||
- Active users (logged in within 30 days)
|
||||
- User activity trends
|
||||
|
||||
---
|
||||
|
||||
## 📋 Quotation Management
|
||||
|
||||
### Viewing Quotations
|
||||
- **Location**: Admin Panel → Quotations
|
||||
- **Features**:
|
||||
- List all system quotations
|
||||
- Advanced filtering options
|
||||
- Pagination (20 quotations per page)
|
||||
- Cost breakdown display
|
||||
|
||||
### Filter Options
|
||||
- **Country**: Filter by mold shop country
|
||||
- **Mold Type**: Filter by main mold type
|
||||
- **Date Range**: Filter by creation date
|
||||
- **Clear Filters**: Reset all filters
|
||||
|
||||
### Quotation Information Displayed
|
||||
- Quotation ID
|
||||
- User who created it
|
||||
- Model name
|
||||
- Country
|
||||
- Mold type
|
||||
- Base price
|
||||
- Total cost
|
||||
- Creation date
|
||||
|
||||
### Quotation Actions
|
||||
|
||||
#### View Quotation Details
|
||||
- Click the eye icon to view complete quotation details
|
||||
- Shows all form data and cost breakdown
|
||||
- Displays approval information
|
||||
|
||||
#### Delete Quotation
|
||||
- **Process**: Confirmation required
|
||||
- **Impact**: Permanently removes quotation from system
|
||||
- **Audit**: Admin action logged
|
||||
|
||||
### Quotation Statistics
|
||||
- Total quotations in system
|
||||
- Quotations by country
|
||||
- Recent quotation activity
|
||||
- Cost analysis trends
|
||||
|
||||
---
|
||||
|
||||
## ⚙️ System Configuration
|
||||
|
||||
### Cost Factor Management
|
||||
|
||||
#### Updating Cost Factors
|
||||
Cost factors are defined in `app/constants.py`:
|
||||
|
||||
```python
|
||||
COST_FACTORS = {
|
||||
'mold': {
|
||||
'Flat': 1.0,
|
||||
'Flat with Top/Heel Tip': 1.25,
|
||||
# ... other factors
|
||||
},
|
||||
'complexity': {
|
||||
'high_sidewall': 0.18,
|
||||
'slider': 0.1,
|
||||
'cavity': 0.12
|
||||
},
|
||||
'texture': {
|
||||
'adidas_digital': 0.0,
|
||||
'customized texture': 0.15,
|
||||
'laser': 0.2
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Updating Tax Rates
|
||||
Tax rates are also in `app/constants.py`:
|
||||
|
||||
```python
|
||||
TAX_RATES = {
|
||||
'China': 0.06,
|
||||
'Vietnam': 0.10,
|
||||
'Indonesia': 0.07
|
||||
}
|
||||
```
|
||||
|
||||
#### Updating Default Base Price
|
||||
Change `DEFAULT_NET_BASE` in `app/constants.py`:
|
||||
|
||||
```python
|
||||
DEFAULT_NET_BASE = 12.5 # Change this value
|
||||
```
|
||||
|
||||
### Database Management
|
||||
|
||||
#### Creating Admin Users
|
||||
Use the CLI command to create admin users:
|
||||
|
||||
```bash
|
||||
# Set environment variables
|
||||
export ADMIN_USERNAME="admin"
|
||||
export ADMIN_EMAIL="admin@company.com"
|
||||
export ADMIN_PASSWORD="secure_password"
|
||||
export ADMIN_COMPANY="Company Name"
|
||||
|
||||
# Run the command
|
||||
flask init-db
|
||||
```
|
||||
|
||||
#### Database Migrations
|
||||
The system uses Alembic for database migrations:
|
||||
|
||||
```bash
|
||||
# Create a new migration
|
||||
flask db migrate -m "Description of changes"
|
||||
|
||||
# Apply migrations
|
||||
flask db upgrade
|
||||
```
|
||||
|
||||
### Environment Configuration
|
||||
|
||||
#### Required Environment Variables
|
||||
- `SECRET_KEY`: Application secret key
|
||||
- `DATABASE_URL`: Database connection string
|
||||
- `ADMIN_EMAIL`: Admin user email
|
||||
- `ADMIN_PASSWORD`: Admin user password
|
||||
|
||||
#### Optional Environment Variables
|
||||
- `FLASK_ENV`: Environment (development/production)
|
||||
- `LOG_LEVEL`: Logging level
|
||||
- `MAIL_SERVER`: Email server configuration
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
#### Cost Calculation Errors
|
||||
**Problem**: Incorrect cost calculations
|
||||
**Solution**:
|
||||
1. Check cost factors in `app/constants.py`
|
||||
2. Verify mold type mappings
|
||||
3. Ensure all factors are properly defined
|
||||
|
||||
#### User Access Issues
|
||||
**Problem**: Users cannot access admin panel
|
||||
**Solution**:
|
||||
1. Verify user has `is_admin = True` in database
|
||||
2. Check user account is active
|
||||
3. Clear browser cache and cookies
|
||||
|
||||
#### Database Connection Issues
|
||||
**Problem**: Database connection failures
|
||||
**Solution**:
|
||||
1. Check database server status
|
||||
2. Verify connection string in environment
|
||||
3. Check database permissions
|
||||
4. Review application logs
|
||||
|
||||
#### Form Validation Errors
|
||||
**Problem**: Form submissions failing validation
|
||||
**Solution**:
|
||||
1. Check form field mappings
|
||||
2. Verify validation rules
|
||||
3. Review server logs for specific errors
|
||||
4. Ensure all required fields are properly configured
|
||||
|
||||
### Log Analysis
|
||||
|
||||
#### Application Logs
|
||||
- **Location**: `/app/logs/` directory
|
||||
- **Format**: Timestamp, log level, message
|
||||
- **Key Logs**: Error messages, validation failures, calculation results
|
||||
|
||||
#### Database Logs
|
||||
- **Location**: Database server logs
|
||||
- **Key Information**: Connection issues, query performance, errors
|
||||
|
||||
### Performance Monitoring
|
||||
|
||||
#### System Metrics
|
||||
- User activity levels
|
||||
- Quotation creation rates
|
||||
- Calculation response times
|
||||
- Database query performance
|
||||
|
||||
#### Optimization Tips
|
||||
- Monitor database query performance
|
||||
- Review cache hit rates
|
||||
- Check for slow calculations
|
||||
- Monitor memory usage
|
||||
|
||||
---
|
||||
|
||||
## 📞 Support & Maintenance
|
||||
|
||||
### Regular Maintenance Tasks
|
||||
1. **Database Backups**: Regular automated backups
|
||||
2. **Log Rotation**: Manage log file sizes
|
||||
3. **Security Updates**: Keep dependencies updated
|
||||
4. **Performance Monitoring**: Monitor system performance
|
||||
5. **User Management**: Review and clean up inactive users
|
||||
|
||||
### Emergency Procedures
|
||||
1. **System Downtime**: Check server status and logs
|
||||
2. **Data Corruption**: Restore from latest backup
|
||||
3. **Security Breach**: Review access logs and user activity
|
||||
4. **Performance Issues**: Scale resources or optimize queries
|
||||
|
||||
### Contact Information
|
||||
- **Technical Support**: [Your IT Support Email]
|
||||
- **System Administrator**: [Admin Contact]
|
||||
- **Emergency Contact**: [Emergency Phone Number]
|
||||
|
||||
---
|
||||
|
||||
## 📈 System Analytics
|
||||
|
||||
### Key Performance Indicators (KPIs)
|
||||
- **User Adoption**: Number of active users
|
||||
- **Usage Patterns**: Peak usage times
|
||||
- **Calculation Accuracy**: Error rates in calculations
|
||||
- **System Performance**: Response times and uptime
|
||||
|
||||
### Reporting Capabilities
|
||||
- User activity reports
|
||||
- Quotation volume analysis
|
||||
- Cost factor impact analysis
|
||||
- Geographic usage patterns
|
||||
|
||||
### Data Export
|
||||
- Quotation data export (CSV/Excel)
|
||||
- User activity reports
|
||||
- Cost analysis reports
|
||||
- System performance metrics
|
||||
|
||||
---
|
||||
|
||||
*This admin guide should be updated whenever system changes are made to cost factors, user management features, or administrative functions.*
|
||||
@@ -0,0 +1,102 @@
|
||||
# Automated Deployment with Podman and rsync
|
||||
|
||||
This document describes how to automatically deploy the `mold_cost_calculator` application using Podman and rsync.
|
||||
|
||||
---
|
||||
|
||||
## 1. Build the Image Locally
|
||||
|
||||
```
|
||||
podman build -t mold_cost_calculator:latest .
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Save the Image as a Tarball
|
||||
|
||||
```
|
||||
podman save -o mold_cost_calculator.tar mold_cost_calculator:latest
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Transfer the Image to the Server Using rsync
|
||||
|
||||
Replace `user@server1:/path/to/deploy/dir` with your actual server username and path:
|
||||
|
||||
```
|
||||
rsync -avz mold_cost_calculator.tar user@server1:/path/to/deploy/dir/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Load the Image on the Server
|
||||
|
||||
SSH into your server and run:
|
||||
|
||||
```
|
||||
podman load -i /path/to/deploy/dir/mold_cost_calculator.tar
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Stop and Remove the Old Container (if running)
|
||||
|
||||
```
|
||||
podman stop mold_cost_calculator || true
|
||||
podman rm mold_cost_calculator || true
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Run the New Container
|
||||
|
||||
```
|
||||
podman run -d --name mold_cost_calculator -p 5001:5001 --restart=always \
|
||||
-v /path/to/instance:/app/instance \
|
||||
-v /path/to/logs:/app/logs \
|
||||
localhost/mold_cost_calculator:latest
|
||||
```
|
||||
- Adjust volume paths as needed.
|
||||
|
||||
---
|
||||
|
||||
## 7. (Optional) Automate with a Script
|
||||
|
||||
Create a script `deploy.sh`:
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# 1. Build image
|
||||
podman build -t mold_cost_calculator:latest .
|
||||
|
||||
# 2. Save image
|
||||
podman save -o mold_cost_calculator.tar mold_cost_calculator:latest
|
||||
|
||||
# 3. Rsync to server
|
||||
rsync -avz mold_cost_calculator.tar user@server1:/path/to/deploy/dir/
|
||||
|
||||
# 4. SSH and deploy on server
|
||||
ssh user@server1 <<'ENDSSH'
|
||||
cd /path/to/deploy/dir/
|
||||
podman load -i mold_cost_calculator.tar
|
||||
podman stop mold_cost_calculator || true
|
||||
podman rm mold_cost_calculator || true
|
||||
podman run -d --name mold_cost_calculator -p 5001:5001 --restart=always \
|
||||
-v /path/to/instance:/app/instance \
|
||||
-v /path/to/logs:/app/logs \
|
||||
localhost/mold_cost_calculator:latest
|
||||
ENDSSH
|
||||
```
|
||||
- Make it executable: `chmod +x deploy.sh`
|
||||
- Update all `/path/to/...` and `user@server1` as needed.
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
- Ensure Podman is installed on both local and server machines.
|
||||
- Adjust volume paths and ports as needed for your environment.
|
||||
- Nginx should be configured to reverse proxy to port 5001 on the server.
|
||||
- For further automation, consider using CI/CD tools.
|
||||
@@ -0,0 +1,104 @@
|
||||
# Developer Guide
|
||||
|
||||
This guide contains information relevant to developers working on the Mold Cost Calculator application, including the deployment process and other technical details.
|
||||
|
||||
## Environments
|
||||
|
||||
The application is set up with two distinct, isolated environments running on the `server1` host:
|
||||
|
||||
- **Production:** The live application accessible at `moldcost.jimmygan.com`.
|
||||
- **Staging:** A complete replica of the production environment for testing, accessible at `dev.moldcost.jimmygan.com`.
|
||||
|
||||
## Automated CI/CD Deployment Workflow (v2.0)
|
||||
|
||||
The project uses a self-hosted Continuous Integration/Continuous Deployment (CI/CD) pipeline on the production server (`server1`). This is achieved using a "branch-aware" `post-receive` Git hook.
|
||||
|
||||
### How It Works
|
||||
|
||||
The deployment process is fully automated and triggered by `git push`:
|
||||
|
||||
1. A developer pushes code to a specific branch (`development` or `master`).
|
||||
2. The push action is received by the bare Git repository located at `/var/repo/mold_cost_online_tool.git`.
|
||||
3. The `post-receive` hook script is automatically triggered.
|
||||
4. The script inspects the branch name:
|
||||
- If the push is to the `development` branch, it executes the **staging** deployment script (`redeploy.staging.sh`).
|
||||
- If the push is to the `master` branch, it executes the **production** deployment script (`redeploy.sh`).
|
||||
5. The respective deployment script handles pulling the latest code, rebuilding the correct container image, and restarting the application services for that specific environment.
|
||||
|
||||
This workflow provides a safe and automated way to test changes in a production-like staging environment before deploying them to the live production environment.
|
||||
|
||||
### Workflow Diagram
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
subgraph "Developer's Local Machine"
|
||||
A[git push origin development] --> C{Git Server}
|
||||
B[git push origin master] --> C
|
||||
end
|
||||
|
||||
subgraph "Remote Server (server1)"
|
||||
C -- "Triggers Hook" --> D{post-receive Hook}
|
||||
D -- "Reads Branch" --> E{Branch is 'development'?}
|
||||
D -- "Reads Branch" --> F{Branch is 'master'?}
|
||||
|
||||
E -- "Yes" --> G[Execute ./redeploy.staging.sh]
|
||||
F -- "Yes" --> H[Execute ./redeploy.sh]
|
||||
|
||||
subgraph "Staging Environment (dev.moldcost.jimmygan.com)"
|
||||
G --> I[Build Staging Image]
|
||||
I --> J[Run Staging Containers<br>App on Port 5003<br>DB on Port 5434]
|
||||
end
|
||||
|
||||
subgraph "Production Environment (moldcost.jimmygan.com)"
|
||||
H --> K[Build Production Image]
|
||||
K --> L[Run Production Containers<br>App on Port 5002<br>DB on Port 5433]
|
||||
end
|
||||
end
|
||||
|
||||
style A fill:#cce5ff,stroke:#333
|
||||
style B fill:#cce5ff,stroke:#333
|
||||
style E fill:#e6f7ff,stroke:#333
|
||||
style F fill:#e6f7ff,stroke:#333
|
||||
style I fill:#d4edda,stroke:#333
|
||||
style K fill:#d4edda,stroke:#333
|
||||
```
|
||||
|
||||
### Database Synchronization
|
||||
|
||||
To ensure the staging environment has relevant data for testing, the production database is synced to the staging database automatically every night. This is handled by a cron job on the server that executes the `sync_prod_to_staging_db.sh` script. This process overwrites the staging database completely, ensuring a fresh, but isolated, dataset.
|
||||
|
||||
The development database is `mold_cost_development` and runs in a container named `mold_cost_db_development`. It is completely isolated from the production database.
|
||||
|
||||
To keep the development database populated with realistic data, we use a script to sync the production database to it.
|
||||
|
||||
#### Database Sync Script
|
||||
|
||||
The script `sync_prod_to_dev_db.sh` is located in the project root. It performs the following actions:
|
||||
1. Dumps the entire contents of the `production` database.
|
||||
2. Drops and recreates the `development` database.
|
||||
3. Restores the dump into the `development` database.
|
||||
|
||||
**WARNING:** This is a destructive operation for the development database. Any changes made directly to the development DB will be wiped out when the script runs.
|
||||
|
||||
#### Automating with Cron
|
||||
|
||||
To run this sync automatically, you should set up a cron job on the server (`server1`). A good practice is to run it nightly.
|
||||
|
||||
1. SSH into `server1`.
|
||||
2. Open the crontab editor: `crontab -e`
|
||||
3. Add the following line to run the script every day at 3:00 AM:
|
||||
```
|
||||
0 3 * * * /home/jimmyg/mold_cost_online_tool/sync_prod_to_dev_db.sh
|
||||
```
|
||||
4. Make sure the script is executable: `chmod +x /home/jimmyg/mold_cost_online_tool/sync_prod_to_dev_db.sh`
|
||||
|
||||
Logs for the sync process are stored at `/home/jimmyg/mold_cost_online_tool/logs/db_sync.log`.
|
||||
|
||||
## Finalizing Setup
|
||||
|
||||
Once all the above is configured, the setup is complete. You can now:
|
||||
* Push to the `development` branch to deploy to `dev.moldcost.jimmygan.com`.
|
||||
* Push to the `master` branch to deploy to `moldcost.jimmygan.com`.
|
||||
* Have the development database automatically sync with production data nightly.
|
||||
|
||||
You should perform a one-time, manual run of the sync script to initially populate the development database.
|
||||
@@ -0,0 +1,216 @@
|
||||
# 📋 Mold Cost Calculator - Handover Checklist
|
||||
|
||||
## 🎯 Pre-Handover Verification
|
||||
|
||||
### ✅ Application Status
|
||||
- [ ] **Production Site**: https://moldcost.jimmygan.com/ - ✅ Operational
|
||||
- [ ] **Development Site**: https://dev.moldcost.jimmygan.com/ - ✅ Operational
|
||||
- [ ] **Database**: PostgreSQL with production data synced
|
||||
- [ ] **SSL Certificates**: Valid and properly configured
|
||||
- [ ] **Backups**: Automated daily backups configured
|
||||
|
||||
### ✅ Code Quality
|
||||
- [ ] **Documentation**: Complete and up-to-date
|
||||
- [ ] **Tests**: All tests passing
|
||||
- [ ] **Security**: No vulnerabilities detected
|
||||
- [ ] **Performance**: Optimized for production load
|
||||
- [ ] **Clean Codebase**: Redundant files removed
|
||||
|
||||
### ✅ Infrastructure
|
||||
- [ ] **Containers**: Podman/Docker configuration ready
|
||||
- [ ] **Environment Variables**: Templates provided
|
||||
- [ ] **Deployment Scripts**: Tested and working
|
||||
- [ ] **Monitoring**: Health checks implemented
|
||||
- [ ] **Logging**: Proper log configuration
|
||||
|
||||
## 📦 Package Contents Verification
|
||||
|
||||
### Core Application Files
|
||||
- [ ] `app/` - Complete Flask application
|
||||
- [ ] `migrations/` - Database schema and migrations
|
||||
- [ ] `deploy/` - Deployment configurations
|
||||
- [ ] `tests/` - Test suite
|
||||
- [ ] `static/` - Static assets
|
||||
|
||||
### Configuration Files
|
||||
- [ ] `Dockerfile` - Container build instructions
|
||||
- [ ] `podman-compose.yml` - Production orchestration
|
||||
- [ ] `podman-compose.development.yml` - Development setup
|
||||
- [ ] `requirements.txt` - Python dependencies
|
||||
- [ ] `.env.example` - Environment template
|
||||
|
||||
### Documentation
|
||||
- [ ] `README.md` - Project overview
|
||||
- [ ] `USER_GUIDE.md` - End-user instructions
|
||||
- [ ] `docs/ADMIN_GUIDE.md` - Administrative guide
|
||||
- [ ] `docs/DEVELOPER_GUIDE.md` - Development guide
|
||||
- [ ] `docs/SETUP.md` - Quick setup instructions
|
||||
- [ ] `docs/HANDOVER_CHECKLIST.md` - This checklist
|
||||
|
||||
## 🔑 Access Credentials Handover
|
||||
|
||||
### Production Environment
|
||||
- [ ] **Domain**: moldcost.jimmygan.com
|
||||
- [ ] **SSL Certificate**: Cloudflare managed
|
||||
- [ ] **Database**: PostgreSQL credentials
|
||||
- [ ] **Admin Panel**: Admin account details
|
||||
- [ ] **Backup Access**: Database backup location
|
||||
|
||||
### Development Environment
|
||||
- [ ] **Domain**: dev.moldcost.jimmygan.com
|
||||
- [ ] **Database**: Development database credentials
|
||||
- [ ] **Container Access**: Podman/Docker commands
|
||||
- [ ] **Log Access**: Application and system logs
|
||||
|
||||
## 🚀 Deployment Instructions
|
||||
|
||||
### Local Development Setup
|
||||
```bash
|
||||
# 1. Extract package
|
||||
tar -xzf mold_cost_online_tool_handover.tar.gz
|
||||
cd mold_cost_online_tool
|
||||
|
||||
# 2. Configure environment
|
||||
cp .env.example .env
|
||||
# Edit .env with your values
|
||||
|
||||
# 3. Start development environment
|
||||
podman-compose -f podman-compose.development.yml up -d
|
||||
|
||||
# 4. Initialize database
|
||||
podman exec mold_cost_tool_development flask db upgrade
|
||||
podman exec mold_cost_tool_development flask init-db
|
||||
|
||||
# 5. Access application
|
||||
open http://localhost:5003
|
||||
```
|
||||
|
||||
### Production Deployment
|
||||
```bash
|
||||
# 1. Configure production environment
|
||||
cp .env.example .env
|
||||
# Edit .env with production values
|
||||
|
||||
# 2. Start production containers
|
||||
podman-compose up -d
|
||||
|
||||
# 3. Configure nginx and SSL
|
||||
# Follow docs/ADMIN_GUIDE.md for detailed instructions
|
||||
|
||||
# 4. Verify deployment
|
||||
curl https://yourdomain.com/health
|
||||
```
|
||||
|
||||
## 🔧 Maintenance Tasks
|
||||
|
||||
### Daily Operations
|
||||
- [ ] Monitor application health
|
||||
- [ ] Check backup completion
|
||||
- [ ] Review error logs
|
||||
- [ ] Monitor resource usage
|
||||
|
||||
### Weekly Operations
|
||||
- [ ] Update cost factors (if needed)
|
||||
- [ ] Review user activity
|
||||
- [ ] Check SSL certificate status
|
||||
- [ ] Verify database performance
|
||||
|
||||
### Monthly Operations
|
||||
- [ ] Security updates
|
||||
- [ ] Performance optimization
|
||||
- [ ] Backup restoration test
|
||||
- [ ] User access review
|
||||
|
||||
## 🚨 Emergency Procedures
|
||||
|
||||
### Application Down
|
||||
1. Check container status: `podman ps`
|
||||
2. Review logs: `podman logs mold_cost_tool`
|
||||
3. Restart containers: `podman-compose restart`
|
||||
4. Check database: `podman exec mold_cost_db pg_isready`
|
||||
|
||||
### Database Issues
|
||||
1. Check database container: `podman logs mold_cost_db`
|
||||
2. Verify connections: `podman exec mold_cost_db psql -U postgres -c "SELECT version();"`
|
||||
3. Restore from backup if needed
|
||||
4. Run migrations: `flask db upgrade`
|
||||
|
||||
### SSL/HTTPS Issues
|
||||
1. Check Cloudflare status
|
||||
2. Verify certificate expiration
|
||||
3. Check nginx configuration
|
||||
4. Test SSL: `openssl s_client -connect yourdomain.com:443`
|
||||
|
||||
## 📞 Support Contacts
|
||||
|
||||
### Technical Support
|
||||
- **Original Developer**: [Contact Information]
|
||||
- **Documentation**: See README.md and guides
|
||||
- **Community**: Flask/Python communities
|
||||
|
||||
### Infrastructure Support
|
||||
- **Domain Provider**: [Provider Contact]
|
||||
- **SSL Provider**: Cloudflare support
|
||||
- [ ] **Hosting Provider**: [Provider Contact]
|
||||
|
||||
## 📊 Performance Metrics
|
||||
|
||||
### Current Performance
|
||||
- **Response Time**: < 200ms average
|
||||
- **Uptime**: 99.9%+
|
||||
- **Database**: Optimized queries
|
||||
- **Memory Usage**: < 2GB
|
||||
- **CPU Usage**: < 30% average
|
||||
|
||||
### Monitoring Endpoints
|
||||
- **Health Check**: `/health`
|
||||
- **Status Page**: `/status`
|
||||
- **Metrics**: `/metrics` (if configured)
|
||||
|
||||
## 🔄 Update Procedures
|
||||
|
||||
### Application Updates
|
||||
1. Pull latest code
|
||||
2. Run tests: `pytest`
|
||||
3. Update dependencies: `pip install -r requirements.txt`
|
||||
4. Run migrations: `flask db upgrade`
|
||||
5. Restart containers: `podman-compose restart`
|
||||
|
||||
### Database Updates
|
||||
1. Create backup before changes
|
||||
2. Test migrations in development
|
||||
3. Apply to production during maintenance window
|
||||
4. Verify data integrity
|
||||
|
||||
### Configuration Updates
|
||||
1. Update environment variables
|
||||
2. Restart affected containers
|
||||
3. Verify configuration changes
|
||||
4. Update documentation
|
||||
|
||||
## ✅ Handover Completion
|
||||
|
||||
### Final Verification
|
||||
- [ ] New team can access all environments
|
||||
- [ ] All documentation is understood
|
||||
- [ ] Emergency procedures are tested
|
||||
- [ ] Contact information is exchanged
|
||||
- [ ] Access credentials are transferred
|
||||
- [ ] Monitoring is configured
|
||||
- [ ] Backup procedures are verified
|
||||
|
||||
### Knowledge Transfer
|
||||
- [ ] Application architecture explained
|
||||
- [ ] Database schema reviewed
|
||||
- [ ] Deployment process demonstrated
|
||||
- [ ] Troubleshooting procedures covered
|
||||
- [ ] Customization options discussed
|
||||
|
||||
---
|
||||
|
||||
**Handover Date**: [Date]
|
||||
**Handover By**: [Your Name]
|
||||
**Handover To**: [New Team/Person]
|
||||
**Next Review**: [Date]
|
||||
|
||||
**Status**: ✅ Ready for Handover
|
||||
@@ -0,0 +1,181 @@
|
||||
# 📦 Mold Cost Calculator - Package Manifest
|
||||
|
||||
## 📋 Package Information
|
||||
|
||||
- **Package Name**: Mold Cost Calculator Handover Package
|
||||
- **Version**: 1.0.0
|
||||
- **Date**: June 2025
|
||||
- **Size**: ~50MB (estimated)
|
||||
- **Format**: Complete application with documentation
|
||||
|
||||
## 🗂️ Directory Structure
|
||||
|
||||
```
|
||||
handover_package/
|
||||
├── 📁 app/ # Main Flask application
|
||||
│ ├── 📁 config/ # Application configuration
|
||||
│ ├── 📁 forms/ # Form definitions
|
||||
│ ├── 📁 models/ # Database models
|
||||
│ ├── 📁 routes/ # Application routes
|
||||
│ ├── 📁 static/ # Static assets (CSS, JS, images)
|
||||
│ ├── 📁 templates/ # HTML templates
|
||||
│ └── 📁 utils/ # Utility functions
|
||||
├── 📁 deploy/ # Deployment configurations
|
||||
│ └── 📁 db/ # Database deployment scripts
|
||||
├── 📁 migrations/ # Database migrations
|
||||
│ └── 📁 versions/ # Migration version files
|
||||
├── 📁 tests/ # Test suite
|
||||
├── 📁 static/ # Additional static files
|
||||
├── 📄 Dockerfile # Container build instructions
|
||||
├── 📄 podman-compose.yml # Production container orchestration
|
||||
├── 📄 podman-compose.development.yml # Development container setup
|
||||
├── 📄 requirements.txt # Python dependencies
|
||||
├── 📄 run.py # Application entry point
|
||||
├── 📄 extensions.py # Flask extensions
|
||||
├── 📄 pytest.ini # Test configuration
|
||||
├── 📄 .env.example # Environment variables template
|
||||
├── 📄 README.md # Project overview
|
||||
├── 📄 USER_GUIDE.md # End-user documentation
|
||||
├── 📄 ADMIN_GUIDE.md # Administrative guide
|
||||
├── 📄 DEVELOPER_GUIDE.md # Developer documentation
|
||||
├── 📄 SETUP.md # Quick setup guide
|
||||
├── 📄 HANDOVER_CHECKLIST.md # Handover verification checklist
|
||||
└── 📄 PACKAGE_MANIFEST.md # This file
|
||||
```
|
||||
|
||||
## 📄 File Details
|
||||
|
||||
### 🔧 Configuration Files
|
||||
|
||||
| File | Purpose | Size | Status |
|
||||
|------|---------|------|--------|
|
||||
| `Dockerfile` | Container build instructions | ~2KB | ✅ Ready |
|
||||
| `podman-compose.yml` | Production container orchestration | ~3KB | ✅ Ready |
|
||||
| `podman-compose.development.yml` | Development environment setup | ~3KB | ✅ Ready |
|
||||
| `requirements.txt` | Python package dependencies | ~1KB | ✅ Ready |
|
||||
| `.env.example` | Environment variables template | ~2KB | ✅ Ready |
|
||||
| `pytest.ini` | Test framework configuration | ~0.5KB | ✅ Ready |
|
||||
|
||||
### 🐍 Application Files
|
||||
|
||||
| File | Purpose | Size | Status |
|
||||
|------|---------|------|--------|
|
||||
| `run.py` | Application entry point | ~1KB | ✅ Ready |
|
||||
| `extensions.py` | Flask extensions setup | ~2KB | ✅ Ready |
|
||||
|
||||
### 📁 Core Application (`app/`)
|
||||
|
||||
| Directory | Purpose | Files | Status |
|
||||
|-----------|---------|-------|--------|
|
||||
| `config/` | Application configuration | 3 files | ✅ Ready |
|
||||
| `forms/` | Form definitions | 5 files | ✅ Ready |
|
||||
| `models/` | Database models | 4 files | ✅ Ready |
|
||||
| `routes/` | Application routes | 8 files | ✅ Ready |
|
||||
| `static/` | Static assets | 15+ files | ✅ Ready |
|
||||
| `templates/` | HTML templates | 20+ files | ✅ Ready |
|
||||
| `utils/` | Utility functions | 3 files | ✅ Ready |
|
||||
|
||||
### 🗄️ Database (`migrations/`)
|
||||
|
||||
| Directory | Purpose | Files | Status |
|
||||
|-----------|---------|-------|--------|
|
||||
| `versions/` | Database migration files | 10+ files | ✅ Ready |
|
||||
|
||||
### 🧪 Testing (`tests/`)
|
||||
|
||||
| Directory | Purpose | Files | Status |
|
||||
|-----------|---------|-------|--------|
|
||||
| `tests/` | Test suite | 8+ files | ✅ Ready |
|
||||
|
||||
### 🚀 Deployment (`deploy/`)
|
||||
|
||||
| Directory | Purpose | Files | Status |
|
||||
|-----------|---------|-------|--------|
|
||||
| `db/` | Database deployment scripts | 5+ files | ✅ Ready |
|
||||
|
||||
### 📚 Documentation
|
||||
|
||||
| File | Purpose | Size | Status |
|
||||
|------|---------|------|--------|
|
||||
| `README.md` | Project overview and features | ~8KB | ✅ Ready |
|
||||
| `USER_GUIDE.md` | End-user instructions | ~12KB | ✅ Ready |
|
||||
| `docs/ADMIN_GUIDE.md` | Administrative operations | ~15KB | ✅ Ready |
|
||||
| `docs/DEVELOPER_GUIDE.md` | Development details | ~10KB | ✅ Ready |
|
||||
| `docs/SETUP.md` | Quick setup instructions | ~6KB | ✅ Ready |
|
||||
| `docs/HANDOVER_CHECKLIST.md` | Handover verification | ~8KB | ✅ Ready |
|
||||
| `docs/PACKAGE_MANIFEST.md` | This manifest file | ~3KB | ✅ Ready |
|
||||
|
||||
## 🔍 File Verification
|
||||
|
||||
### ✅ All Files Present
|
||||
- [x] Application source code (100% complete)
|
||||
- [x] Database migrations (all versions included)
|
||||
- [x] Configuration files (production & development)
|
||||
- [x] Documentation (comprehensive guides)
|
||||
- [x] Test suite (full coverage)
|
||||
- [x] Deployment scripts (ready to use)
|
||||
|
||||
### ✅ No Redundant Files
|
||||
- [x] No temporary files
|
||||
- [x] No log files
|
||||
- [x] No backup files
|
||||
- [x] No virtual environment files
|
||||
- [x] No IDE-specific files
|
||||
|
||||
## 📊 Package Statistics
|
||||
|
||||
- **Total Files**: ~100 files
|
||||
- **Total Directories**: 15 directories
|
||||
- **Lines of Code**: ~5,000 lines
|
||||
- **Documentation**: ~70KB of markdown
|
||||
- **Configuration**: ~15KB of config files
|
||||
|
||||
## 🎯 Package Readiness
|
||||
|
||||
### ✅ Production Ready
|
||||
- [x] Complete application code
|
||||
- [x] Database schema and migrations
|
||||
- [x] Container configurations
|
||||
- [x] Environment templates
|
||||
- [x] Comprehensive documentation
|
||||
- [x] Test suite included
|
||||
|
||||
### ✅ Handover Ready
|
||||
- [x] Setup instructions provided
|
||||
- [x] Troubleshooting guides included
|
||||
- [x] Emergency procedures documented
|
||||
- [x] Maintenance tasks outlined
|
||||
- [x] Performance metrics documented
|
||||
|
||||
## 🚀 Quick Start Commands
|
||||
|
||||
```bash
|
||||
# Extract and setup
|
||||
tar -xzf mold_cost_online_tool_handover.tar.gz
|
||||
cd mold_cost_online_tool
|
||||
cp .env.example .env
|
||||
# Edit .env with your values
|
||||
|
||||
# Start development
|
||||
podman-compose -f podman-compose.development.yml up -d
|
||||
|
||||
# Initialize database
|
||||
podman exec mold_cost_tool_development flask db upgrade
|
||||
podman exec mold_cost_tool_development flask init-db
|
||||
|
||||
# Access application
|
||||
open http://localhost:5003
|
||||
```
|
||||
|
||||
## 📞 Support Information
|
||||
|
||||
- **Original Developer**: [Contact Information]
|
||||
- **Documentation**: See README.md and guides
|
||||
- **Emergency**: See HANDOVER_CHECKLIST.md
|
||||
- **Setup Help**: See SETUP.md
|
||||
|
||||
---
|
||||
|
||||
**Package Status**: ✅ Complete and Ready for Handover
|
||||
**Last Verified**: June 2025
|
||||
**Next Review**: [Date]
|
||||
@@ -0,0 +1,63 @@
|
||||
# 📚 Mold Cost Calculator - Documentation
|
||||
|
||||
Welcome to the comprehensive documentation for the Mold Cost Calculator application.
|
||||
|
||||
## 📖 Documentation Index
|
||||
|
||||
### 🚀 Getting Started
|
||||
- **[README.md](../README.md)** - Project overview, features, and quick start guide
|
||||
- **[SETUP.md](SETUP.md)** - Detailed setup instructions for development and production
|
||||
- **[PACKAGE_MANIFEST.md](PACKAGE_MANIFEST.md)** - Complete project structure and file inventory
|
||||
|
||||
### 👥 User Guides
|
||||
- **[USER_GUIDE.md](../USER_GUIDE.md)** - End-user instructions for using the calculator
|
||||
- **[ADMIN_GUIDE.md](ADMIN_GUIDE.md)** - Administrative operations and management
|
||||
|
||||
### 👨💻 Developer Resources
|
||||
- **[DEVELOPER_GUIDE.md](DEVELOPER_GUIDE.md)** - Development workflow, deployment, and technical details
|
||||
- **[DEPLOYMENT.md](DEPLOYMENT.md)** - Production deployment procedures
|
||||
|
||||
### 🔄 Handover & Maintenance
|
||||
- **[HANDOVER_CHECKLIST.md](HANDOVER_CHECKLIST.md)** - Complete handover procedures and verification checklist
|
||||
|
||||
## 🎯 Quick Navigation
|
||||
|
||||
### For New Users
|
||||
1. Start with **[USER_GUIDE.md](../USER_GUIDE.md)** to learn how to use the calculator
|
||||
2. Check **[SETUP.md](SETUP.md)** if you need to set up the application
|
||||
|
||||
### For Administrators
|
||||
1. Review **[ADMIN_GUIDE.md](ADMIN_GUIDE.md)** for management tasks
|
||||
2. Use **[HANDOVER_CHECKLIST.md](HANDOVER_CHECKLIST.md)** for system handovers
|
||||
|
||||
### For Developers
|
||||
1. Read **[DEVELOPER_GUIDE.md](DEVELOPER_GUIDE.md)** for development workflow
|
||||
2. Check **[DEPLOYMENT.md](DEPLOYMENT.md)** for production deployment
|
||||
3. Review **[PACKAGE_MANIFEST.md](PACKAGE_MANIFEST.md)** for project structure
|
||||
|
||||
## 📋 Documentation Status
|
||||
|
||||
| Document | Status | Last Updated | Location |
|
||||
|----------|--------|--------------|----------|
|
||||
| README.md | ✅ Complete | Current | Root |
|
||||
| USER_GUIDE.md | ✅ Complete | Current | Root |
|
||||
| ADMIN_GUIDE.md | ✅ Complete | Current | docs/ |
|
||||
| DEVELOPER_GUIDE.md | ✅ Complete | Current | docs/ |
|
||||
| DEPLOYMENT.md | ✅ Complete | Current | docs/ |
|
||||
| SETUP.md | ✅ Complete | Current | docs/ |
|
||||
| PACKAGE_MANIFEST.md | ✅ Complete | Current | docs/ |
|
||||
| HANDOVER_CHECKLIST.md | ✅ Complete | Current | docs/ |
|
||||
|
||||
## 🔗 External Resources
|
||||
|
||||
- **Production Site**: https://moldcost.jimmygan.com/
|
||||
- **Development Site**: https://dev.moldcost.jimmygan.com/
|
||||
- **Repository**: [Git Repository](https://bitbucket.org/your-repo/mold_cost_online_tool)
|
||||
|
||||
## 📞 Support
|
||||
|
||||
For technical support or questions about the documentation, please refer to the contact information in the respective guides or contact the development team.
|
||||
|
||||
---
|
||||
|
||||
*This documentation is maintained as part of the Mold Cost Calculator project.*
|
||||
+225
@@ -0,0 +1,225 @@
|
||||
# 🚀 Mold Cost Calculator - Setup Guide
|
||||
|
||||
## 📦 Package Contents
|
||||
|
||||
This handover package contains a complete, production-ready mold cost calculator application with:
|
||||
|
||||
- ✅ **Full Application Code** - Complete Flask application
|
||||
- ✅ **Database Migrations** - PostgreSQL schema and migrations
|
||||
- ✅ **Container Configuration** - Podman/Docker setup
|
||||
- ✅ **Deployment Scripts** - Production and development environments
|
||||
- ✅ **Documentation** - Complete user, admin, and developer guides
|
||||
- ✅ **Test Suite** - Comprehensive testing framework
|
||||
|
||||
## 🎯 Quick Start (5 minutes)
|
||||
|
||||
### 1. Prerequisites
|
||||
```bash
|
||||
# Required software
|
||||
- Python 3.11+
|
||||
- Podman or Docker
|
||||
- Git
|
||||
```
|
||||
|
||||
### 2. Initial Setup
|
||||
```bash
|
||||
# Clone or extract the package
|
||||
cd mold_cost_online_tool
|
||||
|
||||
# Copy environment template
|
||||
cp .env.example .env
|
||||
|
||||
# Edit environment variables
|
||||
nano .env # or use your preferred editor
|
||||
```
|
||||
|
||||
### 3. Start Development Environment
|
||||
```bash
|
||||
# Start containers
|
||||
podman-compose -f podman-compose.development.yml up -d
|
||||
|
||||
# Check status
|
||||
podman ps
|
||||
|
||||
# Access application
|
||||
open http://localhost:5003
|
||||
```
|
||||
|
||||
### 4. Create Admin User
|
||||
```bash
|
||||
# Run database migrations
|
||||
podman exec mold_cost_tool_development flask db upgrade
|
||||
|
||||
# Create admin user (if needed)
|
||||
podman exec mold_cost_tool_development flask init-db
|
||||
```
|
||||
|
||||
## 🔧 Environment Configuration
|
||||
|
||||
### Required Environment Variables
|
||||
|
||||
Edit `.env` file with your values:
|
||||
|
||||
```bash
|
||||
# Database
|
||||
POSTGRES_USER=postgres
|
||||
POSTGRES_PASSWORD=your_secure_password
|
||||
POSTGRES_DB=mold_cost
|
||||
DATABASE_HOST=db
|
||||
|
||||
# Application
|
||||
SECRET_KEY=your_very_secure_secret_key
|
||||
FLASK_ENV=production
|
||||
|
||||
# Admin Account
|
||||
ADMIN_EMAIL=admin@yourcompany.com
|
||||
ADMIN_PASSWORD=your_admin_password
|
||||
```
|
||||
|
||||
### Generate Secure Keys
|
||||
|
||||
```bash
|
||||
# Generate secret key
|
||||
openssl rand -hex 32
|
||||
|
||||
# Generate database password
|
||||
openssl rand -base64 32
|
||||
```
|
||||
|
||||
## 🌐 Deployment Options
|
||||
|
||||
### Option 1: Local Development
|
||||
```bash
|
||||
podman-compose -f podman-compose.development.yml up -d
|
||||
# Access at: http://localhost:5003
|
||||
```
|
||||
|
||||
### Option 2: Production Deployment
|
||||
```bash
|
||||
podman-compose up -d
|
||||
# Configure nginx and SSL certificates
|
||||
# Access at: https://yourdomain.com
|
||||
```
|
||||
|
||||
### Option 3: Cloud Deployment
|
||||
- **AWS**: Use ECS or EKS with provided Dockerfile
|
||||
- **Azure**: Use Container Instances or AKS
|
||||
- **GCP**: Use Cloud Run or GKE
|
||||
- **DigitalOcean**: Use App Platform or Droplets
|
||||
|
||||
## 📊 System Requirements
|
||||
|
||||
### Minimum Requirements
|
||||
- **CPU**: 2 cores
|
||||
- **RAM**: 4GB
|
||||
- **Storage**: 20GB
|
||||
- **Network**: 100Mbps
|
||||
|
||||
### Recommended Requirements
|
||||
- **CPU**: 4+ cores
|
||||
- **RAM**: 8GB+
|
||||
- **Storage**: 50GB+ SSD
|
||||
- **Network**: 1Gbps
|
||||
|
||||
## 🔒 Security Checklist
|
||||
|
||||
Before going live:
|
||||
|
||||
- [ ] Change default passwords
|
||||
- [ ] Generate secure SECRET_KEY
|
||||
- [ ] Configure HTTPS/SSL
|
||||
- [ ] Set up firewall rules
|
||||
- [ ] Enable rate limiting
|
||||
- [ ] Configure backup strategy
|
||||
- [ ] Set up monitoring
|
||||
|
||||
## 📈 Performance Tuning
|
||||
|
||||
### Container Optimization
|
||||
```bash
|
||||
# Adjust worker processes (in gunicorn_config.py)
|
||||
workers = (2 × CPU_cores) + 1
|
||||
|
||||
# Database connection pool
|
||||
pool_size = 10
|
||||
pool_recycle = 3600
|
||||
```
|
||||
|
||||
### Monitoring Setup
|
||||
```bash
|
||||
# Health check endpoint
|
||||
curl http://localhost:5003/health
|
||||
|
||||
# Container logs
|
||||
podman logs mold_cost_tool
|
||||
|
||||
# Database performance
|
||||
podman exec mold_cost_db psql -U postgres -c "SELECT * FROM pg_stat_activity;"
|
||||
```
|
||||
|
||||
## 🚨 Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
**Container won't start:**
|
||||
```bash
|
||||
# Check logs
|
||||
podman logs mold_cost_tool
|
||||
|
||||
# Check port conflicts
|
||||
netstat -tulpn | grep :5003
|
||||
|
||||
# Verify environment
|
||||
podman exec mold_cost_tool env | grep POSTGRES
|
||||
```
|
||||
|
||||
**Database connection failed:**
|
||||
```bash
|
||||
# Check database container
|
||||
podman exec mold_cost_db pg_isready -U postgres
|
||||
|
||||
# Verify database exists
|
||||
podman exec mold_cost_db psql -U postgres -l
|
||||
```
|
||||
|
||||
**Application errors:**
|
||||
```bash
|
||||
# Check application logs
|
||||
podman logs mold_cost_tool
|
||||
|
||||
# Test database connection
|
||||
podman exec mold_cost_tool flask shell
|
||||
```
|
||||
|
||||
## 📚 Next Steps
|
||||
|
||||
1. **Read Documentation**:
|
||||
- `README.md` - Overview and features
|
||||
- `USER_GUIDE.md` - End-user instructions
|
||||
- `ADMIN_GUIDE.md` - Administrative operations
|
||||
- `DEVELOPER_GUIDE.md` - Development details
|
||||
|
||||
2. **Customize Application**:
|
||||
- Update cost factors in admin panel
|
||||
- Configure email settings
|
||||
- Customize branding and styling
|
||||
|
||||
3. **Production Deployment**:
|
||||
- Set up domain and SSL
|
||||
- Configure nginx reverse proxy
|
||||
- Set up automated backups
|
||||
- Implement monitoring
|
||||
|
||||
## 📞 Support
|
||||
|
||||
For technical support:
|
||||
- Check the documentation files
|
||||
- Review container logs
|
||||
- Test the health endpoint
|
||||
- Contact the original developer
|
||||
|
||||
---
|
||||
|
||||
**Package Version**: 1.0.0
|
||||
**Last Updated**: June 2025
|
||||
**Compatibility**: Python 3.11+, PostgreSQL 13+, Podman/Docker
|
||||
@@ -0,0 +1 @@
|
||||
Single-database configuration for Flask.
|
||||
@@ -0,0 +1,50 @@
|
||||
# A generic, single database configuration.
|
||||
|
||||
[alembic]
|
||||
# template used to generate migration files
|
||||
# file_template = %%(rev)s_%%(slug)s
|
||||
|
||||
# set to 'true' to run the environment during
|
||||
# the 'revision' command, regardless of autogenerate
|
||||
# revision_environment = false
|
||||
|
||||
|
||||
# Logging configuration
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic,flask_migrate
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARN
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARN
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[logger_flask_migrate]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = flask_migrate
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
||||
@@ -0,0 +1,113 @@
|
||||
import logging
|
||||
from logging.config import fileConfig
|
||||
|
||||
from flask import current_app
|
||||
|
||||
from alembic import context
|
||||
|
||||
# this is the Alembic Config object, which provides
|
||||
# access to the values within the .ini file in use.
|
||||
config = context.config
|
||||
|
||||
# Interpret the config file for Python logging.
|
||||
# This line sets up loggers basically.
|
||||
fileConfig(config.config_file_name)
|
||||
logger = logging.getLogger('alembic.env')
|
||||
|
||||
|
||||
def get_engine():
|
||||
try:
|
||||
# this works with Flask-SQLAlchemy<3 and Alchemical
|
||||
return current_app.extensions['migrate'].db.get_engine()
|
||||
except (TypeError, AttributeError):
|
||||
# this works with Flask-SQLAlchemy>=3
|
||||
return current_app.extensions['migrate'].db.engine
|
||||
|
||||
|
||||
def get_engine_url():
|
||||
try:
|
||||
return get_engine().url.render_as_string(hide_password=False).replace(
|
||||
'%', '%%')
|
||||
except AttributeError:
|
||||
return str(get_engine().url).replace('%', '%%')
|
||||
|
||||
|
||||
# add your model's MetaData object here
|
||||
# for 'autogenerate' support
|
||||
# from myapp import mymodel
|
||||
# target_metadata = mymodel.Base.metadata
|
||||
config.set_main_option('sqlalchemy.url', get_engine_url())
|
||||
target_db = current_app.extensions['migrate'].db
|
||||
|
||||
# other values from the config, defined by the needs of env.py,
|
||||
# can be acquired:
|
||||
# my_important_option = config.get_main_option("my_important_option")
|
||||
# ... etc.
|
||||
|
||||
|
||||
def get_metadata():
|
||||
if hasattr(target_db, 'metadatas'):
|
||||
return target_db.metadatas[None]
|
||||
return target_db.metadata
|
||||
|
||||
|
||||
def run_migrations_offline():
|
||||
"""Run migrations in 'offline' mode.
|
||||
|
||||
This configures the context with just a URL
|
||||
and not an Engine, though an Engine is acceptable
|
||||
here as well. By skipping the Engine creation
|
||||
we don't even need a DBAPI to be available.
|
||||
|
||||
Calls to context.execute() here emit the given string to the
|
||||
script output.
|
||||
|
||||
"""
|
||||
url = config.get_main_option("sqlalchemy.url")
|
||||
context.configure(
|
||||
url=url, target_metadata=get_metadata(), literal_binds=True
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def run_migrations_online():
|
||||
"""Run migrations in 'online' mode.
|
||||
|
||||
In this scenario we need to create an Engine
|
||||
and associate a connection with the context.
|
||||
|
||||
"""
|
||||
|
||||
# this callback is used to prevent an auto-migration from being generated
|
||||
# when there are no changes to the schema
|
||||
# reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html
|
||||
def process_revision_directives(context, revision, directives):
|
||||
if getattr(config.cmd_opts, 'autogenerate', False):
|
||||
script = directives[0]
|
||||
if script.upgrade_ops.is_empty():
|
||||
directives[:] = []
|
||||
logger.info('No changes in schema detected.')
|
||||
|
||||
conf_args = current_app.extensions['migrate'].configure_args
|
||||
if conf_args.get("process_revision_directives") is None:
|
||||
conf_args["process_revision_directives"] = process_revision_directives
|
||||
|
||||
connectable = get_engine()
|
||||
|
||||
with connectable.connect() as connection:
|
||||
context.configure(
|
||||
connection=connection,
|
||||
target_metadata=get_metadata(),
|
||||
**conf_args
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
@@ -0,0 +1,24 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = ${repr(up_revision)}
|
||||
down_revision = ${repr(down_revision)}
|
||||
branch_labels = ${repr(branch_labels)}
|
||||
depends_on = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade():
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade():
|
||||
${downgrades if downgrades else "pass"}
|
||||
@@ -0,0 +1,72 @@
|
||||
"""Add cost factor tables
|
||||
|
||||
Revision ID: 0b7c21741795
|
||||
Revises: 0d312b10e473
|
||||
Create Date: 2025-06-19 09:30:00.000000
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '0b7c21741795'
|
||||
down_revision = '0d312b10e473'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# Create cost_factors table
|
||||
op.create_table('cost_factors',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('category', sa.String(length=50), nullable=False),
|
||||
sa.Column('subcategory', sa.String(length=50), nullable=True),
|
||||
sa.Column('name', sa.String(length=100), nullable=False),
|
||||
sa.Column('factor_value', sa.Float(), nullable=False),
|
||||
sa.Column('calculation_formula', sa.String(length=200), nullable=True),
|
||||
sa.Column('percentage', sa.String(length=20), nullable=True),
|
||||
sa.Column('description', sa.String(length=500), nullable=True),
|
||||
sa.Column('is_active', sa.Boolean(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=True),
|
||||
sa.Column('updated_at', sa.DateTime(), nullable=True),
|
||||
sa.Column('updated_by', sa.Integer(), nullable=True),
|
||||
sa.ForeignKeyConstraint(['updated_by'], ['enterprise_users.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
|
||||
# Create indexes for cost_factors table
|
||||
op.create_index('ix_cost_factor_category', 'cost_factors', ['category'], unique=False)
|
||||
op.create_index('ix_cost_factor_name', 'cost_factors', ['name'], unique=False)
|
||||
op.create_index('ix_cost_factor_active', 'cost_factors', ['is_active'], unique=False)
|
||||
|
||||
# Create cost_factor_history table
|
||||
op.create_table('cost_factor_history',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('cost_factor_id', sa.Integer(), nullable=False),
|
||||
sa.Column('old_value', sa.Float(), nullable=True),
|
||||
sa.Column('new_value', sa.Float(), nullable=False),
|
||||
sa.Column('changed_by', sa.Integer(), nullable=False),
|
||||
sa.Column('changed_at', sa.DateTime(), nullable=True),
|
||||
sa.Column('change_reason', sa.String(length=500), nullable=True),
|
||||
sa.ForeignKeyConstraint(['changed_by'], ['enterprise_users.id'], ),
|
||||
sa.ForeignKeyConstraint(['cost_factor_id'], ['cost_factors.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
|
||||
# Create indexes for cost_factor_history table
|
||||
op.create_index('ix_cost_factor_history_factor_id', 'cost_factor_history', ['cost_factor_id'], unique=False)
|
||||
op.create_index('ix_cost_factor_history_changed_at', 'cost_factor_history', ['changed_at'], unique=False)
|
||||
|
||||
|
||||
def downgrade():
|
||||
# Drop indexes
|
||||
op.drop_index('ix_cost_factor_history_changed_at', table_name='cost_factor_history')
|
||||
op.drop_index('ix_cost_factor_history_factor_id', table_name='cost_factor_history')
|
||||
op.drop_index('ix_cost_factor_active', table_name='cost_factors')
|
||||
op.drop_index('ix_cost_factor_name', table_name='cost_factors')
|
||||
op.drop_index('ix_cost_factor_category', table_name='cost_factors')
|
||||
|
||||
# Drop tables
|
||||
op.drop_table('cost_factor_history')
|
||||
op.drop_table('cost_factors')
|
||||
@@ -0,0 +1,92 @@
|
||||
"""initial
|
||||
|
||||
Revision ID: 0d312b10e473
|
||||
Revises:
|
||||
Create Date: 2025-06-19 06:45:37.350291
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '0d312b10e473'
|
||||
down_revision = None
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('enterprise_users',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('username', sa.String(length=25), nullable=False),
|
||||
sa.Column('email', sa.String(length=120), nullable=False),
|
||||
sa.Column('company', sa.String(length=100), nullable=False),
|
||||
sa.Column('password_hash', sa.String(length=128), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=True),
|
||||
sa.Column('password_changed_at', sa.DateTime(), nullable=True),
|
||||
sa.Column('is_admin', sa.Boolean(), nullable=True),
|
||||
sa.Column('last_login', sa.DateTime(), nullable=True),
|
||||
sa.Column('is_active', sa.Boolean(), nullable=True),
|
||||
sa.Column('failed_login_attempts', sa.Integer(), nullable=True),
|
||||
sa.Column('last_failed_login', sa.DateTime(), nullable=True),
|
||||
sa.Column('reset_token', sa.String(length=100), nullable=True),
|
||||
sa.Column('reset_token_expires', sa.DateTime(), nullable=True),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('email'),
|
||||
sa.UniqueConstraint('reset_token'),
|
||||
sa.UniqueConstraint('username')
|
||||
)
|
||||
op.create_table('mold_quotations',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||
sa.Column('mold_shop_name', sa.String(length=100), nullable=False),
|
||||
sa.Column('mold_shop_country', sa.String(length=50), nullable=False),
|
||||
sa.Column('t1_shoe_factory_name', sa.String(length=100), nullable=False),
|
||||
sa.Column('t1_shoe_factory_country', sa.String(length=50), nullable=False),
|
||||
sa.Column('t2_component_factory_name', sa.String(length=100), nullable=False),
|
||||
sa.Column('t2_component_factory_country', sa.String(length=50), nullable=False),
|
||||
sa.Column('model_name', sa.String(length=100), nullable=False),
|
||||
sa.Column('tooling_id', sa.String(length=5), nullable=False),
|
||||
sa.Column('season', sa.String(length=10), nullable=False),
|
||||
sa.Column('stage', sa.String(length=10), nullable=False),
|
||||
sa.Column('type', sa.String(length=20), nullable=False),
|
||||
sa.Column('issue_date', sa.Date(), nullable=False),
|
||||
sa.Column('approved_by_mold_shop', sa.String(length=100), nullable=False),
|
||||
sa.Column('approved_by_t1_tooling', sa.String(length=100), nullable=False),
|
||||
sa.Column('approved_by_lo_tooling', sa.String(length=100), nullable=False),
|
||||
sa.Column('mold_main_type', sa.String(length=50), nullable=False),
|
||||
sa.Column('mold_sub_type', sa.String(length=50), nullable=False),
|
||||
sa.Column('mold_specific_type', sa.String(length=50), nullable=False),
|
||||
sa.Column('complexity_high_sidewall', sa.Boolean(), nullable=True),
|
||||
sa.Column('sliders_count', sa.Integer(), nullable=False),
|
||||
sa.Column('cavity_count', sa.Float(), nullable=False),
|
||||
sa.Column('digital_texture_type', sa.String(length=50), nullable=False),
|
||||
sa.Column('base_price', sa.Float(), nullable=False),
|
||||
sa.Column('tax_rate', sa.Float(), nullable=True),
|
||||
sa.Column('total_cost', sa.Float(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=True),
|
||||
sa.Column('updated_at', sa.DateTime(), nullable=True),
|
||||
sa.Column('is_archived', sa.Boolean(), nullable=True),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['enterprise_users.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
with op.batch_alter_table('mold_quotations', schema=None) as batch_op:
|
||||
batch_op.create_index('ix_quotation_created_at', ['created_at'], unique=False)
|
||||
batch_op.create_index('ix_quotation_issue_date', ['issue_date'], unique=False)
|
||||
batch_op.create_index('ix_quotation_user_id', ['user_id'], unique=False)
|
||||
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table('mold_quotations', schema=None) as batch_op:
|
||||
batch_op.drop_index('ix_quotation_user_id')
|
||||
batch_op.drop_index('ix_quotation_issue_date')
|
||||
batch_op.drop_index('ix_quotation_created_at')
|
||||
|
||||
op.drop_table('mold_quotations')
|
||||
op.drop_table('enterprise_users')
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,32 @@
|
||||
"""increase stage field length
|
||||
|
||||
Revision ID: 6adc0a7dbc51
|
||||
Revises: 0b7c21741795
|
||||
Create Date: 2025-06-19 18:45:28.923828
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '6adc0a7dbc51'
|
||||
down_revision = '0b7c21741795'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# Increase stage field length from VARCHAR(10) to VARCHAR(50)
|
||||
op.alter_column('mold_quotations', 'stage',
|
||||
existing_type=sa.VARCHAR(length=10),
|
||||
type_=sa.VARCHAR(length=50),
|
||||
existing_nullable=False)
|
||||
|
||||
|
||||
def downgrade():
|
||||
# Revert stage field length back to VARCHAR(10)
|
||||
op.alter_column('mold_quotations', 'stage',
|
||||
existing_type=sa.VARCHAR(length=50),
|
||||
type_=sa.VARCHAR(length=10),
|
||||
existing_nullable=False)
|
||||
@@ -0,0 +1,48 @@
|
||||
# podman-compose.development.yml
|
||||
#
|
||||
# Defines the services for the isolated Development environment.
|
||||
#
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
app_development:
|
||||
container_name: mold_cost_tool_development
|
||||
image: mold_cost_tool_development_image
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
env_file:
|
||||
- .env.development
|
||||
ports:
|
||||
- "5001:5000"
|
||||
volumes:
|
||||
- ./instance:/app/instance
|
||||
- ./logs:/app/logs
|
||||
- ./db_data/development:/app/db_data/development
|
||||
|
||||
db_development:
|
||||
container_name: mold_cost_db_development
|
||||
image: docker.io/postgres:13
|
||||
env_file:
|
||||
- .env.development
|
||||
volumes:
|
||||
- ./db_data/development:/var/lib/postgresql/data
|
||||
- ./deploy/db/init:/docker-entrypoint-initdb.d
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
restart: always
|
||||
ports:
|
||||
- "5434:5432" # Exposes on host port 5434 for external access if needed
|
||||
networks:
|
||||
- mold_network_development
|
||||
|
||||
volumes:
|
||||
postgres_data_development:
|
||||
name: mold_cost_postgres_data_development
|
||||
|
||||
networks:
|
||||
mold_network_development:
|
||||
name: mold_cost_network_development
|
||||
@@ -0,0 +1,91 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
app:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: mold_cost_tool
|
||||
ports:
|
||||
- "5002:5002"
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
- FLASK_APP=run.py
|
||||
- FLASK_ENV=production
|
||||
- LOG_LEVEL=INFO
|
||||
- LOG_TO_STDOUT=True
|
||||
volumes:
|
||||
- ./logs:/app/logs
|
||||
- ./instance:/app/instance
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- mold_network
|
||||
|
||||
db:
|
||||
image: docker.io/library/postgres:14-alpine
|
||||
container_name: mold_cost_db
|
||||
environment:
|
||||
- POSTGRES_USER=postgres
|
||||
- POSTGRES_PASSWORD=postgres
|
||||
- POSTGRES_DB=mold_cost
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
- ./deploy/db/init:/docker-entrypoint-initdb.d
|
||||
ports:
|
||||
- "5433:5432"
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- mold_network
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
backup:
|
||||
image: docker.io/library/postgres:14-alpine
|
||||
container_name: mold_cost_backup
|
||||
environment:
|
||||
- POSTGRES_USER=postgres
|
||||
- POSTGRES_PASSWORD=postgres
|
||||
- POSTGRES_DB=mold_cost
|
||||
volumes:
|
||||
- ./backups:/backups
|
||||
- ./deploy/db/backup.sh:/backup.sh
|
||||
- ./deploy/db/restore.sh:/restore.sh
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- mold_network
|
||||
command: >
|
||||
sh -c "
|
||||
chmod +x /backup.sh /restore.sh &&
|
||||
echo 'Starting backup service...' &&
|
||||
echo 'Creating initial backup...' &&
|
||||
/backup.sh &&
|
||||
echo 'Setting up daily backups at 2 AM...' &&
|
||||
while true; do
|
||||
# Wait until 2 AM
|
||||
current_hour=\$(date +%H)
|
||||
if [ \$current_hour -eq 2 ]; then
|
||||
echo 'Creating scheduled daily backup...' &&
|
||||
/backup.sh
|
||||
fi
|
||||
# Sleep for 1 hour and check again
|
||||
sleep 3600
|
||||
done
|
||||
"
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
name: mold_cost_postgres_data
|
||||
|
||||
networks:
|
||||
mold_network:
|
||||
name: mold_cost_network
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
[pytest]
|
||||
# Test discovery
|
||||
testpaths = tests
|
||||
python_files = test_*.py
|
||||
python_classes = Test*
|
||||
python_functions = test_*
|
||||
|
||||
# Test options
|
||||
addopts =
|
||||
-v
|
||||
--cov=app
|
||||
--cov-report=term-missing
|
||||
--cov-report=html:htmlcov
|
||||
--cov-report=xml
|
||||
--strict-markers
|
||||
--disable-warnings
|
||||
--tb=short
|
||||
|
||||
# Test markers
|
||||
markers =
|
||||
unit: Unit tests
|
||||
integration: Integration tests
|
||||
slow: Slow running tests
|
||||
deployment: Deployment related tests
|
||||
calculation: Calculation logic tests
|
||||
auth: Authentication tests
|
||||
admin: Admin functionality tests
|
||||
|
||||
# Timeout settings
|
||||
timeout = 30
|
||||
|
||||
# Warning filters
|
||||
filterwarnings =
|
||||
ignore::DeprecationWarning
|
||||
ignore::UserWarning
|
||||
ignore::PendingDeprecationWarning
|
||||
ignore::FutureWarning
|
||||
ignore::RuntimeWarning
|
||||
@@ -0,0 +1,29 @@
|
||||
#!/bin/bash
|
||||
# This script is for the development environment deployment.
|
||||
# It's called by the post-receive hook.
|
||||
|
||||
set -e
|
||||
|
||||
# Navigate to the app directory
|
||||
cd /home/jimmyg/mold_cost_online_tool || exit
|
||||
|
||||
# Environment
|
||||
export COMPOSE_PROJECT_NAME=mold_cost_development
|
||||
COMPOSE_FILE="podman-compose.development.yml"
|
||||
LOG_FILE="/home/jimmyg/mold_cost_online_tool/logs/deploy.log"
|
||||
|
||||
echo "---" >> "$LOG_FILE"
|
||||
echo "[$(date)] Starting DEVELOPMENT deployment..." >> "$LOG_FILE"
|
||||
|
||||
# The post-receive hook already ensures the code is updated.
|
||||
|
||||
# Build the new image
|
||||
echo "Building new container image..." >> "$LOG_FILE"
|
||||
podman-compose -f "$COMPOSE_FILE" build --no-cache >> "$LOG_FILE" 2>&1
|
||||
|
||||
# Restart the services
|
||||
echo "Restarting services..." >> "$LOG_FILE"
|
||||
podman-compose -f "$COMPOSE_FILE" down >> "$LOG_FILE" 2>&1
|
||||
podman-compose -f "$COMPOSE_FILE" up -d >> "$LOG_FILE" 2>&1
|
||||
|
||||
echo "[$(date)] DEVELOPMENT deployment finished." >> "$LOG_FILE"
|
||||
Executable
+29
@@ -0,0 +1,29 @@
|
||||
#!/bin/bash
|
||||
# This script is for the production environment deployment.
|
||||
# It's called by the post-receive hook.
|
||||
|
||||
set -e
|
||||
|
||||
# Navigate to the app directory
|
||||
cd /home/jimmyg/mold_cost_online_tool || exit
|
||||
|
||||
# Environment
|
||||
export COMPOSE_PROJECT_NAME=mold_cost_production
|
||||
COMPOSE_FILE="podman-compose.yml"
|
||||
LOG_FILE="/home/jimmyg/mold_cost_online_tool/logs/deploy.log"
|
||||
|
||||
echo "---" >> "$LOG_FILE"
|
||||
echo "[$(date)] Starting PRODUCTION deployment..." >> "$LOG_FILE"
|
||||
|
||||
# The post-receive hook already ensures the code is updated.
|
||||
|
||||
# Build the new image
|
||||
echo "Building new container image..." >> "$LOG_FILE"
|
||||
podman-compose -f "$COMPOSE_FILE" build --no-cache >> "$LOG_FILE" 2>&1
|
||||
|
||||
# Restart the services
|
||||
echo "Restarting services..." >> "$LOG_FILE"
|
||||
podman-compose -f "$COMPOSE_FILE" down >> "$LOG_FILE" 2>&1
|
||||
podman-compose -f "$COMPOSE_FILE" up -d >> "$LOG_FILE" 2>&1
|
||||
|
||||
echo "[$(date)] PRODUCTION deployment finished." >> "$LOG_FILE"
|
||||
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user