first commit
This commit is contained in:
commit
5554fab50f
245
README.md
Normal file
245
README.md
Normal file
@ -0,0 +1,245 @@
|
||||
# Manufacturing App
|
||||
|
||||
A comprehensive Django-based manufacturing management application with modern UI, user permissions, and database management capabilities.
|
||||
|
||||
## Features
|
||||
|
||||
### 🏭 Core Manufacturing
|
||||
- **Simple Manufacturing Orders**: Input end product results without complex BOM requirements
|
||||
- **Production Tracking**: Monitor daily, weekly, and monthly production metrics
|
||||
- **Cost Management**: Track labor and overhead costs for manufacturing orders
|
||||
|
||||
### 📦 Inventory Management
|
||||
- **Product Management**: Complete product catalog with categories, pricing, and stock levels
|
||||
- **Stock Movements**: Track all inventory movements (in/out, adjustments, sales, purchases)
|
||||
- **Low Stock Alerts**: Automatic notifications for products below minimum stock levels
|
||||
|
||||
### 🛒 Purchase Management
|
||||
- **Supplier Management**: Comprehensive supplier database with ratings and contact information
|
||||
- **Purchase Orders**: Create and track purchase orders with multiple items
|
||||
- **Receipt Management**: Track received goods and update inventory automatically
|
||||
|
||||
### 💰 Sales Management
|
||||
- **Customer Management**: Customer database with different types (retail, wholesale, corporate)
|
||||
- **Sales Orders**: Create sales orders with discounts and shipping costs
|
||||
- **Delivery Tracking**: Monitor order status and delivery progress
|
||||
|
||||
### 👥 User Management
|
||||
- **Role-Based Access**: Four user types (Admin, Manager, Operator, Viewer)
|
||||
- **Permission System**: Granular permissions for different modules
|
||||
- **User Groups**: Custom permission groups for team management
|
||||
|
||||
### 📊 Dashboard & Reporting
|
||||
- **Real-time Dashboard**: Live production, sales, and purchase statistics
|
||||
- **Charts & Graphs**: Visual representation of key metrics using Chart.js
|
||||
- **Profit/Loss Analysis**: Simple profit margin calculations
|
||||
- **Inventory Turnover**: Track stock movement patterns
|
||||
|
||||
### 🗄️ Database Management
|
||||
- **Backup & Restore**: Full database backup and restoration capabilities
|
||||
- **Database Duplication**: Create copies for testing and development
|
||||
- **SQLite Support**: Currently uses SQLite (easily switchable to PostgreSQL)
|
||||
|
||||
## Technology Stack
|
||||
|
||||
- **Backend**: Django 4.2.7
|
||||
- **Database**: SQLite (PostgreSQL ready)
|
||||
- **Frontend**: Bootstrap 5, Chart.js
|
||||
- **Authentication**: Django's built-in auth with custom user model
|
||||
- **Forms**: Django Crispy Forms with Bootstrap 5 styling
|
||||
|
||||
## Installation & Setup
|
||||
|
||||
### Prerequisites
|
||||
- Python 3.8 or higher
|
||||
- pip (Python package installer)
|
||||
|
||||
### 1. Clone the Repository
|
||||
```bash
|
||||
git clone <repository-url>
|
||||
cd basic_manufacture_app
|
||||
```
|
||||
|
||||
### 2. Create Virtual Environment
|
||||
```bash
|
||||
python -m venv venv
|
||||
|
||||
# On Windows
|
||||
venv\Scripts\activate
|
||||
|
||||
# On macOS/Linux
|
||||
source venv/bin/activate
|
||||
```
|
||||
|
||||
### 3. Install Dependencies
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
### 4. Environment Configuration
|
||||
Create a `.env` file in the project root:
|
||||
```env
|
||||
SECRET_KEY=your-secret-key-here
|
||||
DEBUG=True
|
||||
ALLOWED_HOSTS=localhost,127.0.0.1
|
||||
```
|
||||
|
||||
### 5. Database Setup
|
||||
```bash
|
||||
# Create database tables
|
||||
python manage.py makemigrations
|
||||
python manage.py migrate
|
||||
|
||||
# Create superuser
|
||||
python manage.py createsuperuser
|
||||
```
|
||||
|
||||
### 6. Run the Application
|
||||
```bash
|
||||
python manage.py runserver
|
||||
```
|
||||
|
||||
The application will be available at `http://127.0.0.1:8000/`
|
||||
|
||||
## User Types & Permissions
|
||||
|
||||
### 👑 Administrator
|
||||
- Full access to all modules
|
||||
- Database management capabilities
|
||||
- User and group management
|
||||
- System configuration
|
||||
|
||||
### 👨💼 Manager
|
||||
- Manufacturing, inventory, purchase, and sales access
|
||||
- User management (limited)
|
||||
- Reporting and analytics
|
||||
- Cannot access database management
|
||||
|
||||
### 🔧 Operator
|
||||
- Manufacturing operations
|
||||
- Inventory management
|
||||
- Sales operations
|
||||
- Basic reporting access
|
||||
|
||||
### 👁️ Viewer
|
||||
- Read-only access to reports
|
||||
- Dashboard viewing
|
||||
- No modification capabilities
|
||||
|
||||
## Database Management
|
||||
|
||||
### Backup Database
|
||||
1. Navigate to Database Management (Admin only)
|
||||
2. Click "Create Backup"
|
||||
3. Backup files are saved in the `backups/` folder with timestamps
|
||||
|
||||
### Restore Database
|
||||
1. Select a backup file (.sqlite3 or .db)
|
||||
2. Confirm the restoration
|
||||
3. A safety backup is automatically created before restoration
|
||||
|
||||
### Duplicate Database
|
||||
1. Create a copy for testing/development
|
||||
2. Files are saved with "duplicate" prefix
|
||||
|
||||
## Switching to PostgreSQL
|
||||
|
||||
To switch from SQLite to PostgreSQL:
|
||||
|
||||
1. Install PostgreSQL adapter:
|
||||
```bash
|
||||
pip install psycopg2-binary
|
||||
```
|
||||
|
||||
2. Update `settings.py`:
|
||||
```python
|
||||
DATABASES = {
|
||||
'default': {
|
||||
'ENGINE': 'django.db.backends.postgresql',
|
||||
'NAME': 'your_db_name',
|
||||
'USER': 'your_db_user',
|
||||
'PASSWORD': 'your_db_password',
|
||||
'HOST': 'localhost',
|
||||
'PORT': '5432',
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
3. Run migrations:
|
||||
```bash
|
||||
python manage.py migrate
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
basic_manufacture_app/
|
||||
├── manufacture_app/ # Main Django project
|
||||
│ ├── settings.py # Project settings
|
||||
│ ├── urls.py # Main URL configuration
|
||||
│ └── wsgi.py # WSGI configuration
|
||||
├── core/ # Core app (dashboard, database management)
|
||||
├── users/ # User management app
|
||||
├── inventory/ # Inventory management app
|
||||
├── manufacture/ # Manufacturing app
|
||||
├── purchase/ # Purchase management app
|
||||
├── sales/ # Sales management app
|
||||
├── templates/ # HTML templates
|
||||
├── static/ # Static files (CSS, JS, images)
|
||||
├── requirements.txt # Python dependencies
|
||||
└── README.md # This file
|
||||
```
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Creating a Manufacturing Order
|
||||
1. Navigate to Manufacturing → New Order
|
||||
2. Select product and enter quantity
|
||||
3. Add labor and overhead costs
|
||||
4. Save the order (automatically updates inventory)
|
||||
|
||||
### Managing Inventory
|
||||
1. Go to Inventory → Products
|
||||
2. Add new products with categories and pricing
|
||||
3. Set minimum and maximum stock levels
|
||||
4. Monitor stock movements and alerts
|
||||
|
||||
### Processing Sales
|
||||
1. Create sales order with customer details
|
||||
2. Add products with quantities and prices
|
||||
3. Apply discounts if applicable
|
||||
4. Update order status as it progresses
|
||||
|
||||
## Contributing
|
||||
|
||||
1. Fork the repository
|
||||
2. Create a feature branch
|
||||
3. Make your changes
|
||||
4. Add tests if applicable
|
||||
5. Submit a pull request
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the MIT License - see the LICENSE file for details.
|
||||
|
||||
## Support
|
||||
|
||||
For support and questions:
|
||||
- Create an issue in the repository
|
||||
- Contact the development team
|
||||
- Check the documentation
|
||||
|
||||
## Roadmap
|
||||
|
||||
- [ ] Advanced BOM (Bill of Materials) support
|
||||
- [ ] Work order scheduling
|
||||
- [ ] Quality control management
|
||||
- [ ] Advanced reporting and analytics
|
||||
- [ ] Mobile application
|
||||
- [ ] API endpoints for external integrations
|
||||
- [ ] Multi-warehouse support
|
||||
- [ ] Advanced forecasting tools
|
||||
|
||||
---
|
||||
|
||||
**Note**: This is a basic manufacturing application designed for simplicity and ease of use. For complex manufacturing operations, consider additional features like BOM management, work order scheduling, and quality control systems.
|
||||
3
core/__init__.py
Normal file
3
core/__init__.py
Normal file
@ -0,0 +1,3 @@
|
||||
|
||||
|
||||
|
||||
6
core/apps.py
Normal file
6
core/apps.py
Normal file
@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class CoreConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'core'
|
||||
14
core/urls.py
Normal file
14
core/urls.py
Normal file
@ -0,0 +1,14 @@
|
||||
from django.urls import path
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from . import views
|
||||
|
||||
app_name = 'core'
|
||||
|
||||
urlpatterns = [
|
||||
path('', login_required(views.DashboardView.as_view()), name='dashboard'),
|
||||
path('dashboard/', login_required(views.DashboardView.as_view()), name='dashboard'),
|
||||
path('database/', login_required(views.DatabaseManagementView.as_view()), name='database_management'),
|
||||
path('database/backup/', login_required(views.backup_database), name='backup_database'),
|
||||
path('database/restore/', login_required(views.restore_database), name='restore_database'),
|
||||
path('database/duplicate/', login_required(views.duplicate_database), name='duplicate_database'),
|
||||
]
|
||||
207
core/views.py
Normal file
207
core/views.py
Normal file
@ -0,0 +1,207 @@
|
||||
from django.shortcuts import render, redirect
|
||||
from django.contrib.auth.decorators import login_required, user_passes_test
|
||||
from django.contrib import messages
|
||||
from django.http import JsonResponse
|
||||
from django.views.generic import TemplateView
|
||||
from django.db import connection
|
||||
from django.conf import settings
|
||||
import sqlite3
|
||||
import os
|
||||
import shutil
|
||||
from datetime import datetime, timedelta
|
||||
from django.utils import timezone
|
||||
from django.db.models import Sum, Count, Avg
|
||||
from decimal import Decimal
|
||||
|
||||
from inventory.models import Product, StockMovement
|
||||
from purchase.models import PurchaseOrder, PurchaseOrderItem
|
||||
from manufacture.models import ManufacturingOrder
|
||||
from sales.models import SaleOrder, SaleOrderItem
|
||||
|
||||
def is_admin(user):
|
||||
return user.is_superuser
|
||||
|
||||
class DashboardView(TemplateView):
|
||||
template_name = 'core/dashboard.html'
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
|
||||
# Get current date and calculate date ranges
|
||||
today = timezone.now().date()
|
||||
week_ago = today - timedelta(days=7)
|
||||
month_ago = today - timedelta(days=30)
|
||||
|
||||
# Production data
|
||||
context['daily_production'] = ManufacturingOrder.objects.filter(
|
||||
date=today
|
||||
).aggregate(
|
||||
total_quantity=Sum('quantity'),
|
||||
total_orders=Count('id')
|
||||
)
|
||||
|
||||
context['weekly_production'] = ManufacturingOrder.objects.filter(
|
||||
date__gte=week_ago
|
||||
).aggregate(
|
||||
total_quantity=Sum('quantity'),
|
||||
total_orders=Count('id')
|
||||
)
|
||||
|
||||
context['monthly_production'] = ManufacturingOrder.objects.filter(
|
||||
date__gte=month_ago
|
||||
).aggregate(
|
||||
total_quantity=Sum('quantity'),
|
||||
total_orders=Count('id')
|
||||
)
|
||||
|
||||
# Sales data
|
||||
context['daily_sales'] = SaleOrder.objects.filter(
|
||||
date=today
|
||||
).aggregate(
|
||||
total_amount=Sum('total_amount'),
|
||||
total_orders=Count('id')
|
||||
)
|
||||
|
||||
context['weekly_sales'] = SaleOrder.objects.filter(
|
||||
date__gte=week_ago
|
||||
).aggregate(
|
||||
total_amount=Sum('total_amount'),
|
||||
total_orders=Count('id')
|
||||
)
|
||||
|
||||
context['monthly_sales'] = SaleOrder.objects.filter(
|
||||
date__gte=month_ago
|
||||
).aggregate(
|
||||
total_amount=Sum('total_amount'),
|
||||
total_orders=Count('id')
|
||||
)
|
||||
|
||||
# Purchase data
|
||||
context['daily_purchases'] = PurchaseOrder.objects.filter(
|
||||
date=today
|
||||
).aggregate(
|
||||
total_amount=Sum('total_amount'),
|
||||
total_orders=Count('id')
|
||||
)
|
||||
|
||||
context['weekly_purchases'] = PurchaseOrder.objects.filter(
|
||||
date__gte=week_ago
|
||||
).aggregate(
|
||||
total_amount=Sum('total_amount'),
|
||||
total_orders=Count('id')
|
||||
)
|
||||
|
||||
context['monthly_purchases'] = PurchaseOrder.objects.filter(
|
||||
date__gte=month_ago
|
||||
).aggregate(
|
||||
total_amount=Sum('total_amount'),
|
||||
total_orders=Count('id')
|
||||
)
|
||||
|
||||
# Inventory turnover (last 30 days)
|
||||
context['inventory_turnover'] = StockMovement.objects.filter(
|
||||
date__gte=month_ago
|
||||
).aggregate(
|
||||
total_movements=Count('id'),
|
||||
total_quantity=Sum('quantity')
|
||||
)
|
||||
|
||||
# Profit/Loss calculation (simplified)
|
||||
total_sales = context['monthly_sales']['total_amount'] or Decimal('0')
|
||||
total_purchases = context['monthly_purchases']['total_amount'] or Decimal('0')
|
||||
context['monthly_profit'] = total_sales - total_purchases
|
||||
|
||||
# Recent activities
|
||||
context['recent_manufacturing'] = ManufacturingOrder.objects.order_by('-date')[:5]
|
||||
context['recent_sales'] = SaleOrder.objects.order_by('-date')[:5]
|
||||
context['recent_purchases'] = PurchaseOrder.objects.order_by('-date')[:5]
|
||||
|
||||
# Low stock alerts
|
||||
context['low_stock_products'] = Product.objects.filter(
|
||||
current_stock__lte=10
|
||||
)[:5]
|
||||
|
||||
return context
|
||||
|
||||
class DatabaseManagementView(TemplateView):
|
||||
template_name = 'core/database_management.html'
|
||||
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
if not is_admin(request.user):
|
||||
messages.error(request, 'Access denied. Admin privileges required.')
|
||||
return redirect('core:dashboard')
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
|
||||
@login_required
|
||||
@user_passes_test(is_admin)
|
||||
def backup_database(request):
|
||||
if request.method == 'POST':
|
||||
try:
|
||||
# Create backup directory if it doesn't exist
|
||||
backup_dir = os.path.join(settings.BASE_DIR, 'backups')
|
||||
os.makedirs(backup_dir, exist_ok=True)
|
||||
|
||||
# Generate backup filename with timestamp
|
||||
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
||||
backup_filename = f'manufacture_app_backup_{timestamp}.sqlite3'
|
||||
backup_path = os.path.join(backup_dir, backup_filename)
|
||||
|
||||
# Copy the database file
|
||||
db_path = os.path.join(settings.BASE_DIR, 'db.sqlite3')
|
||||
shutil.copy2(db_path, backup_path)
|
||||
|
||||
messages.success(request, f'Database backed up successfully to {backup_filename}')
|
||||
except Exception as e:
|
||||
messages.error(request, f'Backup failed: {str(e)}')
|
||||
|
||||
return redirect('core:database_management')
|
||||
|
||||
@login_required
|
||||
@user_passes_test(is_admin)
|
||||
def restore_database(request):
|
||||
if request.method == 'POST':
|
||||
try:
|
||||
backup_file = request.FILES.get('backup_file')
|
||||
if backup_file:
|
||||
# Create backup of current database first
|
||||
current_db = os.path.join(settings.BASE_DIR, 'db.sqlite3')
|
||||
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
||||
safety_backup = os.path.join(settings.BASE_DIR, f'safety_backup_{timestamp}.sqlite3')
|
||||
shutil.copy2(current_db, safety_backup)
|
||||
|
||||
# Restore from uploaded backup
|
||||
with open(current_db, 'wb') as f:
|
||||
for chunk in backup_file.chunks():
|
||||
f.write(chunk)
|
||||
|
||||
messages.success(request, 'Database restored successfully. Previous database backed up as safety measure.')
|
||||
else:
|
||||
messages.error(request, 'No backup file provided.')
|
||||
except Exception as e:
|
||||
messages.error(request, f'Restore failed: {str(e)}')
|
||||
|
||||
return redirect('core:database_management')
|
||||
|
||||
@login_required
|
||||
@user_passes_test(is_admin)
|
||||
def duplicate_database(request):
|
||||
if request.method == 'POST':
|
||||
try:
|
||||
# Create backup directory if it doesn't exist
|
||||
backup_dir = os.path.join(settings.BASE_DIR, 'backups')
|
||||
os.makedirs(backup_dir, exist_ok=True)
|
||||
|
||||
# Generate duplicate filename with timestamp
|
||||
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
||||
duplicate_filename = f'manufacture_app_duplicate_{timestamp}.sqlite3'
|
||||
duplicate_path = os.path.join(backup_dir, duplicate_filename)
|
||||
|
||||
# Copy the database file
|
||||
db_path = os.path.join(settings.BASE_DIR, 'db.sqlite3')
|
||||
shutil.copy2(db_path, duplicate_path)
|
||||
|
||||
messages.success(request, f'Database duplicated successfully to {duplicate_filename}')
|
||||
except Exception as e:
|
||||
messages.error(request, f'Duplication failed: {str(e)}')
|
||||
|
||||
return redirect('core:database_management')
|
||||
3
inventory/__init__.py
Normal file
3
inventory/__init__.py
Normal file
@ -0,0 +1,3 @@
|
||||
|
||||
|
||||
|
||||
54
inventory/admin.py
Normal file
54
inventory/admin.py
Normal file
@ -0,0 +1,54 @@
|
||||
from django.contrib import admin
|
||||
from .models import Category, Product, StockMovement
|
||||
|
||||
@admin.register(Category)
|
||||
class CategoryAdmin(admin.ModelAdmin):
|
||||
list_display = ('name', 'parent', 'is_active', 'created_at')
|
||||
list_filter = ('is_active', 'parent', 'created_at')
|
||||
search_fields = ('name', 'description')
|
||||
ordering = ('name',)
|
||||
|
||||
fieldsets = (
|
||||
(None, {'fields': ('name', 'description', 'parent')}),
|
||||
('Status', {'fields': ('is_active',)}),
|
||||
)
|
||||
|
||||
@admin.register(Product)
|
||||
class ProductAdmin(admin.ModelAdmin):
|
||||
list_display = ('name', 'code', 'category', 'current_stock', 'cost_price',
|
||||
'selling_price', 'unit', 'is_active', 'is_manufactured')
|
||||
list_filter = ('category', 'unit', 'is_active', 'is_manufactured', 'created_at')
|
||||
search_fields = ('name', 'code', 'description')
|
||||
ordering = ('name',)
|
||||
|
||||
fieldsets = (
|
||||
('Basic Information', {'fields': ('name', 'code', 'description', 'category', 'unit')}),
|
||||
('Stock Information', {'fields': ('current_stock', 'min_stock_level', 'max_stock_level')}),
|
||||
('Pricing', {'fields': ('cost_price', 'selling_price')}),
|
||||
('Product Details', {'fields': ('weight', 'dimensions')}),
|
||||
('Status', {'fields': ('is_active', 'is_manufactured')}),
|
||||
)
|
||||
|
||||
readonly_fields = ('current_stock',)
|
||||
|
||||
def get_queryset(self, request):
|
||||
return super().get_queryset(request).select_related('category')
|
||||
|
||||
@admin.register(StockMovement)
|
||||
class StockMovementAdmin(admin.ModelAdmin):
|
||||
list_display = ('product', 'movement_type', 'quantity', 'unit_price',
|
||||
'total_value', 'date', 'created_by')
|
||||
list_filter = ('movement_type', 'date', 'created_by', 'product__category')
|
||||
search_fields = ('product__name', 'product__code', 'notes', 'reference_note')
|
||||
ordering = ('-date', '-created_at')
|
||||
|
||||
fieldsets = (
|
||||
('Movement Details', {'fields': ('product', 'movement_type', 'quantity', 'unit_price')}),
|
||||
('Reference Information', {'fields': ('reference_type', 'reference_id', 'reference_note')}),
|
||||
('Additional Information', {'fields': ('date', 'notes', 'created_by')}),
|
||||
)
|
||||
|
||||
readonly_fields = ('total_value',)
|
||||
|
||||
def get_queryset(self, request):
|
||||
return super().get_queryset(request).select_related('product', 'created_by')
|
||||
6
inventory/apps.py
Normal file
6
inventory/apps.py
Normal file
@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class InventoryConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'inventory'
|
||||
198
inventory/models.py
Normal file
198
inventory/models.py
Normal file
@ -0,0 +1,198 @@
|
||||
from django.db import models
|
||||
from django.core.validators import MinValueValidator
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from decimal import Decimal
|
||||
|
||||
class Category(models.Model):
|
||||
"""Product categories"""
|
||||
name = models.CharField(max_length=100, unique=True)
|
||||
description = models.TextField(blank=True)
|
||||
parent = models.ForeignKey('self', on_delete=models.CASCADE, blank=True, null=True, related_name='children')
|
||||
is_active = models.BooleanField(default=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _('Category')
|
||||
verbose_name_plural = _('Categories')
|
||||
ordering = ['name']
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def get_full_path(self):
|
||||
"""Get full category path including parent categories"""
|
||||
if self.parent:
|
||||
return f"{self.parent.get_full_path()} > {self.name}"
|
||||
return self.name
|
||||
|
||||
class Product(models.Model):
|
||||
"""Product model for inventory management"""
|
||||
|
||||
UNIT_CHOICES = [
|
||||
('pcs', 'Pieces'),
|
||||
('kg', 'Kilograms'),
|
||||
('m', 'Meters'),
|
||||
('l', 'Liters'),
|
||||
('box', 'Boxes'),
|
||||
('set', 'Sets'),
|
||||
]
|
||||
|
||||
name = models.CharField(max_length=200)
|
||||
code = models.CharField(max_length=50, unique=True, help_text='Unique product code/SKU')
|
||||
description = models.TextField(blank=True)
|
||||
category = models.ForeignKey(Category, on_delete=models.SET_NULL, null=True, blank=True)
|
||||
|
||||
# Stock information
|
||||
current_stock = models.DecimalField(
|
||||
max_digits=10,
|
||||
decimal_places=2,
|
||||
default=0,
|
||||
validators=[MinValueValidator(Decimal('0'))]
|
||||
)
|
||||
min_stock_level = models.DecimalField(
|
||||
max_digits=10,
|
||||
decimal_places=2,
|
||||
default=0,
|
||||
validators=[MinValueValidator(Decimal('0'))]
|
||||
)
|
||||
max_stock_level = models.DecimalField(
|
||||
max_digits=10,
|
||||
decimal_places=2,
|
||||
default=1000,
|
||||
validators=[MinValueValidator(Decimal('0'))]
|
||||
)
|
||||
|
||||
# Pricing
|
||||
cost_price = models.DecimalField(
|
||||
max_digits=10,
|
||||
decimal_places=2,
|
||||
default=0,
|
||||
validators=[MinValueValidator(Decimal('0'))]
|
||||
)
|
||||
selling_price = models.DecimalField(
|
||||
max_digits=10,
|
||||
decimal_places=2,
|
||||
default=0,
|
||||
validators=[MinValueValidator(Decimal('0'))]
|
||||
)
|
||||
|
||||
# Product details
|
||||
unit = models.CharField(max_length=10, choices=UNIT_CHOICES, default='pcs')
|
||||
weight = models.DecimalField(max_digits=8, decimal_places=2, blank=True, null=True)
|
||||
dimensions = models.CharField(max_length=100, blank=True, help_text='Format: LxWxH in cm')
|
||||
|
||||
# Status
|
||||
is_active = models.BooleanField(default=True)
|
||||
is_manufactured = models.BooleanField(default=False, help_text='Is this a manufactured product?')
|
||||
|
||||
# Timestamps
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ['name']
|
||||
verbose_name = _('Product')
|
||||
verbose_name_plural = _('Products')
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.name} ({self.code})"
|
||||
|
||||
def get_stock_status(self):
|
||||
"""Get stock status for the product"""
|
||||
if self.current_stock <= self.min_stock_level:
|
||||
return 'low'
|
||||
elif self.current_stock >= self.max_stock_level:
|
||||
return 'high'
|
||||
else:
|
||||
return 'normal'
|
||||
|
||||
def get_stock_value(self):
|
||||
"""Calculate total stock value"""
|
||||
return self.current_stock * self.cost_price
|
||||
|
||||
def is_low_stock(self):
|
||||
"""Check if product has low stock"""
|
||||
return self.current_stock <= self.min_stock_level
|
||||
|
||||
def is_overstocked(self):
|
||||
"""Check if product is overstocked"""
|
||||
return self.current_stock >= self.max_stock_level
|
||||
|
||||
class StockMovement(models.Model):
|
||||
"""Track all stock movements (in/out)"""
|
||||
|
||||
MOVEMENT_TYPES = [
|
||||
('in', 'Stock In'),
|
||||
('out', 'Stock Out'),
|
||||
('adjustment', 'Stock Adjustment'),
|
||||
('manufacturing', 'Manufacturing Output'),
|
||||
('sale', 'Sale'),
|
||||
('purchase', 'Purchase'),
|
||||
]
|
||||
|
||||
product = models.ForeignKey(Product, on_delete=models.CASCADE, related_name='stock_movements')
|
||||
movement_type = models.CharField(max_length=20, choices=MOVEMENT_TYPES)
|
||||
quantity = models.DecimalField(max_digits=10, decimal_places=2)
|
||||
unit_price = models.DecimalField(max_digits=10, decimal_places=2, blank=True, null=True)
|
||||
total_value = models.DecimalField(max_digits=12, decimal_places=2, blank=True, null=True)
|
||||
|
||||
# Reference fields for different movement types
|
||||
reference_type = models.CharField(max_length=50, blank=True, help_text='Type of reference document')
|
||||
reference_id = models.IntegerField(blank=True, null=True, help_text='ID of reference document')
|
||||
reference_note = models.CharField(max_length=200, blank=True)
|
||||
|
||||
# Movement details
|
||||
date = models.DateField()
|
||||
notes = models.TextField(blank=True)
|
||||
|
||||
# User tracking
|
||||
created_by = models.ForeignKey('users.CustomUser', on_delete=models.SET_NULL, null=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ['-date', '-created_at']
|
||||
verbose_name = _('Stock Movement')
|
||||
verbose_name_plural = _('Stock Movements')
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.product.name} - {self.get_movement_type_display()} ({self.quantity})"
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""Override save to calculate total value and update product stock"""
|
||||
if self.unit_price and self.quantity:
|
||||
self.total_value = self.unit_price * self.quantity
|
||||
|
||||
# Update product stock
|
||||
if self.pk: # Existing movement - revert old quantity
|
||||
old_movement = StockMovement.objects.get(pk=self.pk)
|
||||
if old_movement.movement_type == 'in':
|
||||
self.product.current_stock -= old_movement.quantity
|
||||
elif old_movement.movement_type == 'out':
|
||||
self.product.current_stock += old_movement.quantity
|
||||
|
||||
# Apply new movement
|
||||
if self.movement_type == 'in':
|
||||
self.product.current_stock += self.quantity
|
||||
elif self.movement_type == 'out':
|
||||
self.product.current_stock -= self.quantity
|
||||
elif self.movement_type == 'adjustment':
|
||||
# For adjustments, quantity is the new stock level
|
||||
self.product.current_stock = self.quantity
|
||||
|
||||
# Ensure stock doesn't go negative
|
||||
if self.product.current_stock < 0:
|
||||
self.product.current_stock = 0
|
||||
|
||||
self.product.save()
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def delete(self, *args, **kwargs):
|
||||
"""Override delete to revert stock changes"""
|
||||
if self.movement_type == 'in':
|
||||
self.product.current_stock -= self.quantity
|
||||
elif self.movement_type == 'out':
|
||||
self.product.current_stock += self.quantity
|
||||
|
||||
self.product.save()
|
||||
super().delete(*args, **kwargs)
|
||||
22
manage.py
Normal file
22
manage.py
Normal file
@ -0,0 +1,22 @@
|
||||
#!/usr/bin/env python
|
||||
"""Django's command-line utility for administrative tasks."""
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
def main():
|
||||
"""Run administrative tasks."""
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'manufacture_app.settings')
|
||||
try:
|
||||
from django.core.management import execute_from_command_line
|
||||
except ImportError as exc:
|
||||
raise ImportError(
|
||||
"Couldn't import Django. Are you sure it's installed and "
|
||||
"available on your PYTHONPATH environment variable? Did you "
|
||||
"forget to activate a virtual environment?"
|
||||
) from exc
|
||||
execute_from_command_line(sys.argv)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
3
manufacture/__init__.py
Normal file
3
manufacture/__init__.py
Normal file
@ -0,0 +1,3 @@
|
||||
|
||||
|
||||
|
||||
50
manufacture/admin.py
Normal file
50
manufacture/admin.py
Normal file
@ -0,0 +1,50 @@
|
||||
from django.contrib import admin
|
||||
from .models import ManufacturingOrder, ManufacturingLine, ManufacturingOrderLine
|
||||
|
||||
@admin.register(ManufacturingOrder)
|
||||
class ManufacturingOrderAdmin(admin.ModelAdmin):
|
||||
list_display = ('order_number', 'product', 'quantity', 'date', 'status',
|
||||
'total_cost', 'created_by', 'created_at')
|
||||
list_filter = ('status', 'date', 'product__category', 'created_by', 'created_at')
|
||||
search_fields = ('order_number', 'product__name', 'product__code', 'notes')
|
||||
ordering = ('-date', '-created_at')
|
||||
|
||||
fieldsets = (
|
||||
('Order Information', {'fields': ('order_number', 'date', 'product', 'quantity', 'status')}),
|
||||
('Cost Information', {'fields': ('labor_cost', 'overhead_cost', 'total_cost')}),
|
||||
('Additional Information', {'fields': ('notes', 'created_by')}),
|
||||
)
|
||||
|
||||
readonly_fields = ('total_cost',)
|
||||
|
||||
def get_queryset(self, request):
|
||||
return super().get_queryset(request).select_related('product', 'created_by')
|
||||
|
||||
@admin.register(ManufacturingLine)
|
||||
class ManufacturingLineAdmin(admin.ModelAdmin):
|
||||
list_display = ('name', 'capacity_per_hour', 'is_active', 'created_at')
|
||||
list_filter = ('is_active', 'created_at')
|
||||
search_fields = ('name', 'description')
|
||||
ordering = ('name',)
|
||||
|
||||
fieldsets = (
|
||||
(None, {'fields': ('name', 'description', 'capacity_per_hour')}),
|
||||
('Status', {'fields': ('is_active',)}),
|
||||
)
|
||||
|
||||
@admin.register(ManufacturingOrderLine)
|
||||
class ManufacturingOrderLineAdmin(admin.ModelAdmin):
|
||||
list_display = ('manufacturing_order', 'manufacturing_line', 'actual_quantity',
|
||||
'start_time', 'end_time')
|
||||
list_filter = ('manufacturing_line', 'start_time', 'end_time')
|
||||
search_fields = ('manufacturing_order__order_number', 'notes')
|
||||
ordering = ('-start_time',)
|
||||
|
||||
fieldsets = (
|
||||
('Line Information', {'fields': ('manufacturing_order', 'manufacturing_line')}),
|
||||
('Timing', {'fields': ('start_time', 'end_time')}),
|
||||
('Production', {'fields': ('actual_quantity', 'notes')}),
|
||||
)
|
||||
|
||||
def get_queryset(self, request):
|
||||
return super().get_queryset(request).select_related('manufacturing_order', 'manufacturing_line')
|
||||
6
manufacture/apps.py
Normal file
6
manufacture/apps.py
Normal file
@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class ManufactureConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'manufacture'
|
||||
153
manufacture/models.py
Normal file
153
manufacture/models.py
Normal file
@ -0,0 +1,153 @@
|
||||
from django.db import models
|
||||
from django.core.validators import MinValueValidator
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from decimal import Decimal
|
||||
|
||||
class ManufacturingOrder(models.Model):
|
||||
"""Simple manufacturing order without BOM - just input end product result"""
|
||||
|
||||
ORDER_STATUS = [
|
||||
('completed', 'Completed'),
|
||||
('in_progress', 'In Progress'),
|
||||
('cancelled', 'Cancelled'),
|
||||
]
|
||||
|
||||
order_number = models.CharField(max_length=50, unique=True, help_text='Unique manufacturing order number')
|
||||
date = models.DateField()
|
||||
product = models.ForeignKey('inventory.Product', on_delete=models.CASCADE, related_name='manufacturing_orders')
|
||||
quantity = models.DecimalField(
|
||||
max_digits=10,
|
||||
decimal_places=2,
|
||||
validators=[MinValueValidator(Decimal('0.01'))]
|
||||
)
|
||||
|
||||
# Manufacturing details
|
||||
notes = models.TextField(blank=True, help_text='Manufacturing notes and instructions')
|
||||
status = models.CharField(max_length=20, choices=ORDER_STATUS, default='completed')
|
||||
|
||||
# Cost tracking (optional)
|
||||
labor_cost = models.DecimalField(
|
||||
max_digits=10,
|
||||
decimal_places=2,
|
||||
default=0,
|
||||
validators=[MinValueValidator(Decimal('0'))]
|
||||
)
|
||||
overhead_cost = models.DecimalField(
|
||||
max_digits=10,
|
||||
decimal_places=2,
|
||||
default=0,
|
||||
validators=[MinValueValidator(Decimal('0'))]
|
||||
)
|
||||
total_cost = models.DecimalField(
|
||||
max_digits=10,
|
||||
decimal_places=2,
|
||||
default=0,
|
||||
validators=[MinValueValidator(Decimal('0'))]
|
||||
)
|
||||
|
||||
# User tracking
|
||||
created_by = models.ForeignKey('users.CustomUser', on_delete=models.SET_NULL, null=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ['-date', '-created_at']
|
||||
verbose_name = _('Manufacturing Order')
|
||||
verbose_name_plural = _('Manufacturing Orders')
|
||||
|
||||
def __str__(self):
|
||||
return f"MO-{self.order_number} - {self.product.name} ({self.quantity})"
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""Override save to calculate total cost and update product stock"""
|
||||
# Calculate total cost
|
||||
self.total_cost = self.labor_cost + self.overhead_cost
|
||||
|
||||
# If this is a new order and status is completed, add to inventory
|
||||
if not self.pk and self.status == 'completed':
|
||||
# Update product stock
|
||||
self.product.current_stock += self.quantity
|
||||
|
||||
# Update product cost price if this is the first manufacturing order
|
||||
if self.product.manufacturing_orders.count() == 0:
|
||||
self.product.cost_price = self.total_cost / self.quantity
|
||||
else:
|
||||
# Calculate weighted average cost
|
||||
total_quantity = self.product.manufacturing_orders.aggregate(
|
||||
total=models.Sum('quantity')
|
||||
)['total'] or 0
|
||||
total_cost = self.product.manufacturing_orders.aggregate(
|
||||
total=models.Sum('total_cost')
|
||||
)['total'] or 0
|
||||
|
||||
if total_quantity > 0:
|
||||
self.product.cost_price = total_cost / total_quantity
|
||||
|
||||
self.product.save()
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def get_unit_cost(self):
|
||||
"""Calculate unit cost for this manufacturing order"""
|
||||
if self.quantity > 0:
|
||||
return self.total_cost / self.quantity
|
||||
return Decimal('0')
|
||||
|
||||
def get_profit_margin(self):
|
||||
"""Calculate potential profit margin if product has selling price"""
|
||||
if self.product.selling_price > 0:
|
||||
unit_cost = self.get_unit_cost()
|
||||
if unit_cost > 0:
|
||||
return ((self.product.selling_price - unit_cost) / unit_cost) * 100
|
||||
return Decimal('0')
|
||||
|
||||
class ManufacturingLine(models.Model):
|
||||
"""Manufacturing line/workstation information"""
|
||||
|
||||
name = models.CharField(max_length=100)
|
||||
description = models.TextField(blank=True)
|
||||
capacity_per_hour = models.DecimalField(
|
||||
max_digits=8,
|
||||
decimal_places=2,
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text='Production capacity per hour'
|
||||
)
|
||||
is_active = models.BooleanField(default=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ['name']
|
||||
verbose_name = _('Manufacturing Line')
|
||||
verbose_name_plural = _('Manufacturing Lines')
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
class ManufacturingOrderLine(models.Model):
|
||||
"""Individual line items for manufacturing orders (optional for future expansion)"""
|
||||
|
||||
manufacturing_order = models.ForeignKey(ManufacturingOrder, on_delete=models.CASCADE, related_name='lines')
|
||||
manufacturing_line = models.ForeignKey(ManufacturingLine, on_delete=models.SET_NULL, null=True, blank=True)
|
||||
start_time = models.DateTimeField(blank=True, null=True)
|
||||
end_time = models.DateTimeField(blank=True, null=True)
|
||||
actual_quantity = models.DecimalField(
|
||||
max_digits=10,
|
||||
decimal_places=2,
|
||||
validators=[MinValueValidator(Decimal('0'))]
|
||||
)
|
||||
notes = models.TextField(blank=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _('Manufacturing Order Line')
|
||||
verbose_name_plural = _('Manufacturing Order Lines')
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.manufacturing_order.order_number} - Line {self.id}"
|
||||
|
||||
def get_duration(self):
|
||||
"""Calculate duration of manufacturing line operation"""
|
||||
if self.start_time and self.end_time:
|
||||
return self.end_time - self.start_time
|
||||
return None
|
||||
3
manufacture_app/__init__.py
Normal file
3
manufacture_app/__init__.py
Normal file
@ -0,0 +1,3 @@
|
||||
|
||||
|
||||
|
||||
16
manufacture_app/asgi.py
Normal file
16
manufacture_app/asgi.py
Normal file
@ -0,0 +1,16 @@
|
||||
"""
|
||||
ASGI config for manufacture_app project.
|
||||
|
||||
It exposes the ASGI callable as a module-level variable named ``application``.
|
||||
|
||||
For more information on this file, see
|
||||
https://docs.djangoproject.com/en/4.2/howto/deployment/asgi/
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from django.core.asgi import get_asgi_application
|
||||
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'manufacture_app.settings')
|
||||
|
||||
application = get_asgi_application()
|
||||
129
manufacture_app/settings.py
Normal file
129
manufacture_app/settings.py
Normal file
@ -0,0 +1,129 @@
|
||||
"""
|
||||
Django settings for manufacture_app project.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
import os
|
||||
from decouple import config
|
||||
|
||||
# Build paths inside the project like this: BASE_DIR / 'subdir'.
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
|
||||
# SECURITY WARNING: keep the secret key used in production secret!
|
||||
SECRET_KEY = config('SECRET_KEY', default='django-insecure-your-secret-key-here-change-in-production')
|
||||
|
||||
# SECURITY WARNING: don't run with debug turned on in production!
|
||||
DEBUG = config('DEBUG', default=True, cast=bool)
|
||||
|
||||
ALLOWED_HOSTS = config('ALLOWED_HOSTS', default='localhost,127.0.0.1', cast=lambda v: [s.strip() for s in v.split(',')])
|
||||
|
||||
# Application definition
|
||||
INSTALLED_APPS = [
|
||||
'django.contrib.admin',
|
||||
'django.contrib.auth',
|
||||
'django.contrib.contenttypes',
|
||||
'django.contrib.sessions',
|
||||
'django.contrib.messages',
|
||||
'django.contrib.staticfiles',
|
||||
'django_extensions',
|
||||
'crispy_forms',
|
||||
'crispy_bootstrap5',
|
||||
|
||||
# Custom apps
|
||||
'core',
|
||||
'users',
|
||||
'inventory',
|
||||
'purchase',
|
||||
'manufacture',
|
||||
'sales',
|
||||
]
|
||||
|
||||
MIDDLEWARE = [
|
||||
'django.middleware.security.SecurityMiddleware',
|
||||
'django.contrib.sessions.middleware.SessionMiddleware',
|
||||
'django.middleware.common.CommonMiddleware',
|
||||
'django.middleware.csrf.CsrfViewMiddleware',
|
||||
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
||||
'django.contrib.messages.middleware.MessageMiddleware',
|
||||
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
||||
]
|
||||
|
||||
ROOT_URLCONF = 'manufacture_app.urls'
|
||||
|
||||
TEMPLATES = [
|
||||
{
|
||||
'BACKEND': 'django.template.backends.django.DjangoTemplates',
|
||||
'DIRS': [BASE_DIR / 'templates'],
|
||||
'APP_DIRS': True,
|
||||
'OPTIONS': {
|
||||
'context_processors': [
|
||||
'django.template.context_processors.debug',
|
||||
'django.template.context_processors.request',
|
||||
'django.contrib.auth.context_processors.auth',
|
||||
'django.contrib.messages.context_processors.messages',
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
WSGI_APPLICATION = 'manufacture_app.wsgi.application'
|
||||
|
||||
# Database
|
||||
DATABASES = {
|
||||
'default': {
|
||||
'ENGINE': 'django.db.backends.sqlite3',
|
||||
'NAME': BASE_DIR / 'db.sqlite3',
|
||||
}
|
||||
}
|
||||
|
||||
# Password validation
|
||||
AUTH_PASSWORD_VALIDATORS = [
|
||||
{
|
||||
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
|
||||
},
|
||||
{
|
||||
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
|
||||
},
|
||||
{
|
||||
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
|
||||
},
|
||||
{
|
||||
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
|
||||
},
|
||||
]
|
||||
|
||||
# Internationalization
|
||||
LANGUAGE_CODE = 'en-us'
|
||||
TIME_ZONE = 'UTC'
|
||||
USE_I18N = True
|
||||
USE_TZ = True
|
||||
|
||||
# Static files (CSS, JavaScript, Images)
|
||||
STATIC_URL = 'static/'
|
||||
STATIC_ROOT = BASE_DIR / 'staticfiles'
|
||||
STATICFILES_DIRS = [
|
||||
BASE_DIR / 'static',
|
||||
]
|
||||
|
||||
# Media files
|
||||
MEDIA_URL = 'media/'
|
||||
MEDIA_ROOT = BASE_DIR / 'media'
|
||||
|
||||
# Default primary key field type
|
||||
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
|
||||
|
||||
# Crispy Forms
|
||||
CRISPY_ALLOWED_TEMPLATE_PACKS = "bootstrap5"
|
||||
CRISPY_TEMPLATE_PACK = "bootstrap5"
|
||||
|
||||
# Login URLs
|
||||
LOGIN_URL = '/login/'
|
||||
LOGIN_REDIRECT_URL = '/dashboard/'
|
||||
LOGOUT_REDIRECT_URL = '/login/'
|
||||
|
||||
# Custom user model
|
||||
AUTH_USER_MODEL = 'users.CustomUser'
|
||||
|
||||
# Session settings
|
||||
SESSION_COOKIE_AGE = 3600 # 1 hour
|
||||
SESSION_EXPIRE_AT_BROWSER_CLOSE = True
|
||||
21
manufacture_app/urls.py
Normal file
21
manufacture_app/urls.py
Normal file
@ -0,0 +1,21 @@
|
||||
"""
|
||||
URL configuration for manufacture_app project.
|
||||
"""
|
||||
from django.contrib import admin
|
||||
from django.urls import path, include
|
||||
from django.conf import settings
|
||||
from django.conf.urls.static import static
|
||||
|
||||
urlpatterns = [
|
||||
path('admin/', admin.site.urls),
|
||||
path('', include('core.urls')),
|
||||
path('users/', include('users.urls')),
|
||||
path('inventory/', include('inventory.urls')),
|
||||
path('purchase/', include('purchase.urls')),
|
||||
path('manufacture/', include('manufacture.urls')),
|
||||
path('sales/', include('sales.urls')),
|
||||
]
|
||||
|
||||
if settings.DEBUG:
|
||||
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
|
||||
urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
|
||||
16
manufacture_app/wsgi.py
Normal file
16
manufacture_app/wsgi.py
Normal file
@ -0,0 +1,16 @@
|
||||
"""
|
||||
WSGI config for manufacture_app project.
|
||||
|
||||
It exposes the WSGI callable as a module-level variable named ``application``.
|
||||
|
||||
For more information on this file, see
|
||||
https://docs.djangoproject.com/en/4.2/howto/deployment/wsgi/
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from django.core.wsgi import get_wsgi_application
|
||||
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'manufacture_app.settings')
|
||||
|
||||
application = get_wsgi_application()
|
||||
3
purchase/__init__.py
Normal file
3
purchase/__init__.py
Normal file
@ -0,0 +1,3 @@
|
||||
|
||||
|
||||
|
||||
75
purchase/admin.py
Normal file
75
purchase/admin.py
Normal file
@ -0,0 +1,75 @@
|
||||
from django.contrib import admin
|
||||
from .models import Supplier, PurchaseOrder, PurchaseOrderItem, PurchaseReceipt
|
||||
|
||||
class PurchaseOrderItemInline(admin.TabularInline):
|
||||
model = PurchaseOrderItem
|
||||
extra = 1
|
||||
fields = ('product', 'quantity', 'unit_price', 'total_price', 'description', 'notes')
|
||||
|
||||
@admin.register(Supplier)
|
||||
class SupplierAdmin(admin.ModelAdmin):
|
||||
list_display = ('name', 'code', 'contact_person', 'email', 'phone',
|
||||
'rating', 'is_active', 'created_at')
|
||||
list_filter = ('is_active', 'rating', 'created_at')
|
||||
search_fields = ('name', 'code', 'contact_person', 'email', 'phone')
|
||||
ordering = ('name',)
|
||||
|
||||
fieldsets = (
|
||||
('Basic Information', {'fields': ('name', 'code', 'contact_person', 'email', 'phone')}),
|
||||
('Address', {'fields': ('address',)}),
|
||||
('Business Information', {'fields': ('tax_id', 'payment_terms', 'credit_limit')}),
|
||||
('Status', {'fields': ('is_active', 'rating')}),
|
||||
)
|
||||
|
||||
@admin.register(PurchaseOrder)
|
||||
class PurchaseOrderAdmin(admin.ModelAdmin):
|
||||
list_display = ('order_number', 'supplier', 'date', 'expected_delivery_date',
|
||||
'status', 'total_amount', 'created_by', 'created_at')
|
||||
list_filter = ('status', 'date', 'expected_delivery_date', 'supplier', 'created_by', 'created_at')
|
||||
search_fields = ('order_number', 'supplier__name', 'supplier__code', 'notes')
|
||||
ordering = ('-date', '-created_at')
|
||||
|
||||
fieldsets = (
|
||||
('Order Information', {'fields': ('order_number', 'supplier', 'date', 'expected_delivery_date', 'status')}),
|
||||
('Financial Information', {'fields': ('subtotal', 'tax_amount', 'shipping_cost', 'total_amount')}),
|
||||
('Additional Information', {'fields': ('notes', 'terms_conditions', 'created_by')}),
|
||||
)
|
||||
|
||||
readonly_fields = ('subtotal', 'total_amount')
|
||||
inlines = [PurchaseOrderItemInline]
|
||||
|
||||
def get_queryset(self, request):
|
||||
return super().get_queryset(request).select_related('supplier', 'created_by')
|
||||
|
||||
@admin.register(PurchaseOrderItem)
|
||||
class PurchaseOrderItemAdmin(admin.ModelAdmin):
|
||||
list_display = ('purchase_order', 'product', 'quantity', 'unit_price',
|
||||
'total_price', 'description')
|
||||
list_filter = ('purchase_order__status', 'purchase_order__date')
|
||||
search_fields = ('purchase_order__order_number', 'product__name', 'product__code')
|
||||
ordering = ('-purchase_order__date',)
|
||||
|
||||
fieldsets = (
|
||||
('Item Information', {'fields': ('purchase_order', 'product', 'quantity', 'unit_price')}),
|
||||
('Additional Information', {'fields': ('description', 'notes')}),
|
||||
)
|
||||
|
||||
readonly_fields = ('total_price',)
|
||||
|
||||
def get_queryset(self, request):
|
||||
return super().get_queryset(request).select_related('purchase_order', 'product')
|
||||
|
||||
@admin.register(PurchaseReceipt)
|
||||
class PurchaseReceiptAdmin(admin.ModelAdmin):
|
||||
list_display = ('receipt_number', 'purchase_order', 'receipt_date', 'received_by', 'created_at')
|
||||
list_filter = ('receipt_date', 'received_by', 'created_at')
|
||||
search_fields = ('receipt_number', 'purchase_order__order_number')
|
||||
ordering = ('-receipt_date',)
|
||||
|
||||
fieldsets = (
|
||||
('Receipt Information', {'fields': ('receipt_number', 'purchase_order', 'receipt_date')}),
|
||||
('Additional Information', {'fields': ('notes', 'received_by')}),
|
||||
)
|
||||
|
||||
def get_queryset(self, request):
|
||||
return super().get_queryset(request).select_related('purchase_order', 'received_by')
|
||||
6
purchase/apps.py
Normal file
6
purchase/apps.py
Normal file
@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class PurchaseConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'purchase'
|
||||
191
purchase/models.py
Normal file
191
purchase/models.py
Normal file
@ -0,0 +1,191 @@
|
||||
from django.db import models
|
||||
from django.core.validators import MinValueValidator
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from decimal import Decimal
|
||||
|
||||
class Supplier(models.Model):
|
||||
"""Supplier information"""
|
||||
|
||||
name = models.CharField(max_length=200)
|
||||
code = models.CharField(max_length=50, unique=True, help_text='Unique supplier code')
|
||||
contact_person = models.CharField(max_length=100, blank=True)
|
||||
email = models.EmailField(blank=True)
|
||||
phone = models.CharField(max_length=20, blank=True)
|
||||
address = models.TextField(blank=True)
|
||||
|
||||
# Business information
|
||||
tax_id = models.CharField(max_length=50, blank=True)
|
||||
payment_terms = models.CharField(max_length=100, blank=True, help_text='e.g., Net 30, Net 60')
|
||||
credit_limit = models.DecimalField(
|
||||
max_digits=12,
|
||||
decimal_places=2,
|
||||
default=0,
|
||||
validators=[MinValueValidator(Decimal('0'))]
|
||||
)
|
||||
|
||||
# Status
|
||||
is_active = models.BooleanField(default=True)
|
||||
rating = models.IntegerField(
|
||||
choices=[(i, i) for i in range(1, 6)],
|
||||
default=3,
|
||||
help_text='Supplier rating from 1-5'
|
||||
)
|
||||
|
||||
# Timestamps
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ['name']
|
||||
verbose_name = _('Supplier')
|
||||
verbose_name_plural = _('Suppliers')
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.name} ({self.code})"
|
||||
|
||||
def get_total_purchases(self):
|
||||
"""Get total purchases from this supplier"""
|
||||
return self.purchase_orders.aggregate(
|
||||
total=models.Sum('total_amount')
|
||||
)['total'] or Decimal('0')
|
||||
|
||||
class PurchaseOrder(models.Model):
|
||||
"""Purchase order model"""
|
||||
|
||||
ORDER_STATUS = [
|
||||
('draft', 'Draft'),
|
||||
('sent', 'Sent to Supplier'),
|
||||
('confirmed', 'Confirmed by Supplier'),
|
||||
('received', 'Received'),
|
||||
('cancelled', 'Cancelled'),
|
||||
]
|
||||
|
||||
order_number = models.CharField(max_length=50, unique=True, help_text='Unique purchase order number')
|
||||
supplier = models.ForeignKey(Supplier, on_delete=models.CASCADE, related_name='purchase_orders')
|
||||
date = models.DateField()
|
||||
expected_delivery_date = models.DateField(blank=True, null=True)
|
||||
status = models.CharField(max_length=20, choices=ORDER_STATUS, default='draft')
|
||||
|
||||
# Financial information
|
||||
subtotal = models.DecimalField(
|
||||
max_digits=12,
|
||||
decimal_places=2,
|
||||
default=0,
|
||||
validators=[MinValueValidator(Decimal('0'))]
|
||||
)
|
||||
tax_amount = models.DecimalField(
|
||||
max_digits=12,
|
||||
decimal_places=2,
|
||||
default=0,
|
||||
validators=[MinValueValidator(Decimal('0'))]
|
||||
)
|
||||
shipping_cost = models.DecimalField(
|
||||
max_digits=12,
|
||||
decimal_places=2,
|
||||
default=0,
|
||||
validators=[MinValueValidator(Decimal('0'))]
|
||||
)
|
||||
total_amount = models.DecimalField(
|
||||
max_digits=12,
|
||||
decimal_places=2,
|
||||
default=0,
|
||||
validators=[MinValueValidator(Decimal('0'))]
|
||||
)
|
||||
|
||||
# Additional information
|
||||
notes = models.TextField(blank=True)
|
||||
terms_conditions = models.TextField(blank=True)
|
||||
|
||||
# User tracking
|
||||
created_by = models.ForeignKey('users.CustomUser', on_delete=models.SET_NULL, null=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ['-date', '-created_at']
|
||||
verbose_name = _('Purchase Order')
|
||||
verbose_name_plural = _('Purchase Orders')
|
||||
|
||||
def __str__(self):
|
||||
return f"PO-{self.order_number} - {self.supplier.name}"
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""Override save to calculate total amount"""
|
||||
self.total_amount = self.subtotal + self.tax_amount + self.shipping_cost
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def get_item_count(self):
|
||||
"""Get total number of items in this purchase order"""
|
||||
return self.items.count()
|
||||
|
||||
def is_overdue(self):
|
||||
"""Check if delivery is overdue"""
|
||||
if self.expected_delivery_date and self.status not in ['received', 'cancelled']:
|
||||
from django.utils import timezone
|
||||
return timezone.now().date() > self.expected_delivery_date
|
||||
return False
|
||||
|
||||
class PurchaseOrderItem(models.Model):
|
||||
"""Individual items in purchase orders"""
|
||||
|
||||
purchase_order = models.ForeignKey(PurchaseOrder, on_delete=models.CASCADE, related_name='items')
|
||||
product = models.ForeignKey('inventory.Product', on_delete=models.CASCADE, related_name='purchase_order_items')
|
||||
quantity = models.DecimalField(
|
||||
max_digits=10,
|
||||
decimal_places=2,
|
||||
validators=[MinValueValidator(Decimal('0.01'))]
|
||||
)
|
||||
unit_price = models.DecimalField(
|
||||
max_digits=10,
|
||||
decimal_places=2,
|
||||
validators=[MinValueValidator(Decimal('0'))]
|
||||
)
|
||||
total_price = models.DecimalField(
|
||||
max_digits=12,
|
||||
decimal_places=2,
|
||||
validators=[MinValueValidator(Decimal('0'))]
|
||||
)
|
||||
|
||||
# Additional information
|
||||
description = models.CharField(max_length=200, blank=True)
|
||||
notes = models.TextField(blank=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _('Purchase Order Item')
|
||||
verbose_name_plural = _('Purchase Order Items')
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.purchase_order.order_number} - {self.product.name} ({self.quantity})"
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""Override save to calculate total price"""
|
||||
self.total_price = self.quantity * self.unit_price
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
# Update purchase order subtotal
|
||||
self.purchase_order.subtotal = self.purchase_order.items.aggregate(
|
||||
total=models.Sum('total_price')
|
||||
)['total'] or Decimal('0')
|
||||
self.purchase_order.save()
|
||||
|
||||
class PurchaseReceipt(models.Model):
|
||||
"""Receipt for received purchase orders"""
|
||||
|
||||
receipt_number = models.CharField(max_length=50, unique=True)
|
||||
purchase_order = models.ForeignKey(PurchaseOrder, on_delete=models.CASCADE, related_name='receipts')
|
||||
receipt_date = models.DateField()
|
||||
|
||||
# Receipt details
|
||||
notes = models.TextField(blank=True)
|
||||
received_by = models.ForeignKey('users.CustomUser', on_delete=models.SET_NULL, null=True)
|
||||
|
||||
# Timestamps
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ['-receipt_date']
|
||||
verbose_name = _('Purchase Receipt')
|
||||
verbose_name_plural = _('Purchase Receipts')
|
||||
|
||||
def __str__(self):
|
||||
return f"Receipt-{self.receipt_number} for PO-{self.purchase_order.order_number}"
|
||||
8
requirements.txt
Normal file
8
requirements.txt
Normal file
@ -0,0 +1,8 @@
|
||||
Django==4.2.7
|
||||
django-crispy-forms==2.1
|
||||
crispy-bootstrap5==0.7
|
||||
psycopg2-binary==2.9.7
|
||||
python-decouple==3.8
|
||||
Pillow==10.0.1
|
||||
django-extensions==3.2.3
|
||||
django-debug-toolbar==4.2.0
|
||||
3
sales/__init__.py
Normal file
3
sales/__init__.py
Normal file
@ -0,0 +1,3 @@
|
||||
|
||||
|
||||
|
||||
78
sales/admin.py
Normal file
78
sales/admin.py
Normal file
@ -0,0 +1,78 @@
|
||||
from django.contrib import admin
|
||||
from .models import Customer, SaleOrder, SaleOrderItem, DeliveryNote
|
||||
|
||||
class SaleOrderItemInline(admin.TabularInline):
|
||||
model = SaleOrderItem
|
||||
extra = 1
|
||||
fields = ('product', 'quantity', 'unit_price', 'discount_percent', 'total_price', 'description', 'notes')
|
||||
|
||||
@admin.register(Customer)
|
||||
class CustomerAdmin(admin.ModelAdmin):
|
||||
list_display = ('name', 'code', 'customer_type', 'contact_person', 'email', 'phone',
|
||||
'rating', 'is_active', 'created_at')
|
||||
list_filter = ('customer_type', 'is_active', 'rating', 'created_at')
|
||||
search_fields = ('name', 'code', 'contact_person', 'email', 'phone')
|
||||
ordering = ('name',)
|
||||
|
||||
fieldsets = (
|
||||
('Basic Information', {'fields': ('name', 'code', 'customer_type', 'contact_person', 'email', 'phone')}),
|
||||
('Address', {'fields': ('address',)}),
|
||||
('Business Information', {'fields': ('tax_id', 'payment_terms', 'credit_limit')}),
|
||||
('Status', {'fields': ('is_active', 'rating')}),
|
||||
)
|
||||
|
||||
@admin.register(SaleOrder)
|
||||
class SaleOrderAdmin(admin.ModelAdmin):
|
||||
list_display = ('order_number', 'customer', 'date', 'expected_delivery_date',
|
||||
'status', 'total_amount', 'created_by', 'created_at')
|
||||
list_filter = ('status', 'date', 'expected_delivery_date', 'customer__customer_type', 'created_by', 'created_at')
|
||||
search_fields = ('order_number', 'customer__name', 'customer__code', 'notes')
|
||||
ordering = ('-date', '-created_at')
|
||||
|
||||
fieldsets = (
|
||||
('Order Information', {'fields': ('order_number', 'customer', 'date', 'expected_delivery_date', 'status')}),
|
||||
('Financial Information', {'fields': ('subtotal', 'tax_amount', 'discount_amount', 'shipping_cost', 'total_amount')}),
|
||||
('Additional Information', {'fields': ('notes', 'terms_conditions', 'created_by')}),
|
||||
)
|
||||
|
||||
readonly_fields = ('subtotal', 'total_amount')
|
||||
inlines = [SaleOrderItemInline]
|
||||
|
||||
def get_queryset(self, request):
|
||||
return super().get_queryset(request).select_related('customer', 'created_by')
|
||||
|
||||
@admin.register(SaleOrderItem)
|
||||
class SaleOrderItemAdmin(admin.ModelAdmin):
|
||||
list_display = ('sale_order', 'product', 'quantity', 'unit_price', 'discount_percent',
|
||||
'total_price', 'description')
|
||||
list_filter = ('sale_order__status', 'sale_order__date', 'product__category')
|
||||
search_fields = ('sale_order__order_number', 'product__name', 'product__code')
|
||||
ordering = ('-sale_order__date',)
|
||||
|
||||
fieldsets = (
|
||||
('Item Information', {'fields': ('sale_order', 'product', 'quantity', 'unit_price')}),
|
||||
('Pricing', {'fields': ('discount_percent', 'total_price')}),
|
||||
('Additional Information', {'fields': ('description', 'notes')}),
|
||||
)
|
||||
|
||||
readonly_fields = ('total_price',)
|
||||
|
||||
def get_queryset(self, request):
|
||||
return super().get_queryset(request).select_related('sale_order', 'product')
|
||||
|
||||
@admin.register(DeliveryNote)
|
||||
class DeliveryNoteAdmin(admin.ModelAdmin):
|
||||
list_display = ('delivery_number', 'sale_order', 'delivery_date', 'shipping_method',
|
||||
'tracking_number', 'delivered_by', 'created_at')
|
||||
list_filter = ('delivery_date', 'shipping_method', 'delivered_by', 'created_at')
|
||||
search_fields = ('delivery_number', 'sale_order__order_number', 'tracking_number')
|
||||
ordering = ('-delivery_date',)
|
||||
|
||||
fieldsets = (
|
||||
('Delivery Information', {'fields': ('delivery_number', 'sale_order', 'delivery_date')}),
|
||||
('Shipping Details', {'fields': ('shipping_method', 'tracking_number')}),
|
||||
('Additional Information', {'fields': ('notes', 'delivered_by')}),
|
||||
)
|
||||
|
||||
def get_queryset(self, request):
|
||||
return super().get_queryset(request).select_related('sale_order', 'delivered_by')
|
||||
6
sales/apps.py
Normal file
6
sales/apps.py
Normal file
@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class SalesConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'sales'
|
||||
248
sales/models.py
Normal file
248
sales/models.py
Normal file
@ -0,0 +1,248 @@
|
||||
from django.db import models
|
||||
from django.core.validators import MinValueValidator
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from decimal import Decimal
|
||||
|
||||
class Customer(models.Model):
|
||||
"""Customer information"""
|
||||
|
||||
CUSTOMER_TYPE_CHOICES = [
|
||||
('retail', 'Retail'),
|
||||
('wholesale', 'Wholesale'),
|
||||
('distributor', 'Distributor'),
|
||||
('corporate', 'Corporate'),
|
||||
]
|
||||
|
||||
name = models.CharField(max_length=200)
|
||||
code = models.CharField(max_length=50, unique=True, help_text='Unique customer code')
|
||||
customer_type = models.CharField(max_length=20, choices=CUSTOMER_TYPE_CHOICES, default='retail')
|
||||
|
||||
# Contact information
|
||||
contact_person = models.CharField(max_length=100, blank=True)
|
||||
email = models.EmailField(blank=True)
|
||||
phone = models.CharField(max_length=20, blank=True)
|
||||
address = models.TextField(blank=True)
|
||||
|
||||
# Business information
|
||||
tax_id = models.CharField(max_length=50, blank=True)
|
||||
payment_terms = models.CharField(max_length=100, blank=True, help_text='e.g., Net 30, Net 60')
|
||||
credit_limit = models.DecimalField(
|
||||
max_digits=12,
|
||||
decimal_places=2,
|
||||
default=0,
|
||||
validators=[MinValueValidator(Decimal('0'))]
|
||||
)
|
||||
|
||||
# Status
|
||||
is_active = models.BooleanField(default=True)
|
||||
rating = models.IntegerField(
|
||||
choices=[(i, i) for i in range(1, 6)],
|
||||
default=3,
|
||||
help_text='Customer rating from 1-5'
|
||||
)
|
||||
|
||||
# Timestamps
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ['name']
|
||||
verbose_name = _('Customer')
|
||||
verbose_name_plural = _('Customers')
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.name} ({self.code})"
|
||||
|
||||
def get_total_sales(self):
|
||||
"""Get total sales to this customer"""
|
||||
return self.sale_orders.aggregate(
|
||||
total=models.Sum('total_amount')
|
||||
)['total'] or Decimal('0')
|
||||
|
||||
class SaleOrder(models.Model):
|
||||
"""Sales order model"""
|
||||
|
||||
ORDER_STATUS = [
|
||||
('draft', 'Draft'),
|
||||
('confirmed', 'Confirmed'),
|
||||
('shipped', 'Shipped'),
|
||||
('delivered', 'Delivered'),
|
||||
('cancelled', 'Cancelled'),
|
||||
]
|
||||
|
||||
order_number = models.CharField(max_length=50, unique=True, help_text='Unique sales order number')
|
||||
customer = models.ForeignKey(Customer, on_delete=models.CASCADE, related_name='sale_orders')
|
||||
date = models.DateField()
|
||||
expected_delivery_date = models.DateField(blank=True, null=True)
|
||||
status = models.CharField(max_length=20, choices=ORDER_STATUS, default='draft')
|
||||
|
||||
# Financial information
|
||||
subtotal = models.DecimalField(
|
||||
max_digits=12,
|
||||
decimal_places=2,
|
||||
default=0,
|
||||
validators=[MinValueValidator(Decimal('0'))]
|
||||
)
|
||||
tax_amount = models.DecimalField(
|
||||
max_digits=12,
|
||||
decimal_places=2,
|
||||
default=0,
|
||||
validators=[MinValueValidator(Decimal('0'))]
|
||||
)
|
||||
discount_amount = models.DecimalField(
|
||||
max_digits=12,
|
||||
decimal_places=2,
|
||||
default=0,
|
||||
validators=[MinValueValidator(Decimal('0'))]
|
||||
)
|
||||
shipping_cost = models.DecimalField(
|
||||
max_digits=12,
|
||||
decimal_places=2,
|
||||
default=0,
|
||||
validators=[MinValueValidator(Decimal('0'))]
|
||||
)
|
||||
total_amount = models.DecimalField(
|
||||
max_digits=12,
|
||||
decimal_places=2,
|
||||
default=0,
|
||||
validators=[MinValueValidator(Decimal('0'))]
|
||||
)
|
||||
|
||||
# Additional information
|
||||
notes = models.TextField(blank=True)
|
||||
terms_conditions = models.TextField(blank=True)
|
||||
|
||||
# User tracking
|
||||
created_by = models.ForeignKey('users.CustomUser', on_delete=models.SET_NULL, null=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ['-date', '-created_at']
|
||||
verbose_name = _('Sales Order')
|
||||
verbose_name_plural = _('Sales Orders')
|
||||
|
||||
def __str__(self):
|
||||
return f"SO-{self.order_number} - {self.customer.name}"
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""Override save to calculate total amount"""
|
||||
self.total_amount = self.subtotal + self.tax_amount - self.discount_amount + self.shipping_cost
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def get_item_count(self):
|
||||
"""Get total number of items in this sales order"""
|
||||
return self.items.count()
|
||||
|
||||
def is_overdue(self):
|
||||
"""Check if delivery is overdue"""
|
||||
if self.expected_delivery_date and self.status not in ['delivered', 'cancelled']:
|
||||
from django.utils import timezone
|
||||
return timezone.now().date() > self.expected_delivery_date
|
||||
return False
|
||||
|
||||
def get_profit_margin(self):
|
||||
"""Calculate profit margin for this order"""
|
||||
total_cost = Decimal('0')
|
||||
for item in self.items.all():
|
||||
if item.product.cost_price:
|
||||
total_cost += item.quantity * item.product.cost_price
|
||||
|
||||
if total_cost > 0:
|
||||
return ((self.total_amount - total_cost) / total_cost) * 100
|
||||
return Decimal('0')
|
||||
|
||||
class SaleOrderItem(models.Model):
|
||||
"""Individual items in sales orders"""
|
||||
|
||||
sale_order = models.ForeignKey(SaleOrder, on_delete=models.CASCADE, related_name='items')
|
||||
product = models.ForeignKey('inventory.Product', on_delete=models.CASCADE, related_name='sale_order_items')
|
||||
quantity = models.DecimalField(
|
||||
max_digits=10,
|
||||
decimal_places=2,
|
||||
validators=[MinValueValidator(Decimal('0.01'))]
|
||||
)
|
||||
unit_price = models.DecimalField(
|
||||
max_digits=10,
|
||||
decimal_places=2,
|
||||
validators=[MinValueValidator(Decimal('0'))]
|
||||
)
|
||||
discount_percent = models.DecimalField(
|
||||
max_digits=5,
|
||||
decimal_places=2,
|
||||
default=0,
|
||||
validators=[MinValueValidator(Decimal('0'))],
|
||||
help_text='Discount percentage (0-100)'
|
||||
)
|
||||
total_price = models.DecimalField(
|
||||
max_digits=12,
|
||||
decimal_places=2,
|
||||
validators=[MinValueValidator(Decimal('0'))]
|
||||
)
|
||||
|
||||
# Additional information
|
||||
description = models.CharField(max_length=200, blank=True)
|
||||
notes = models.TextField(blank=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _('Sales Order Item')
|
||||
verbose_name_plural = _('Sales Order Items')
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.sale_order.order_number} - {self.product.name} ({self.quantity})"
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""Override save to calculate total price and update stock"""
|
||||
# Calculate total price with discount
|
||||
discount_amount = (self.unit_price * self.quantity * self.discount_percent) / 100
|
||||
self.total_price = (self.unit_price * self.quantity) - discount_amount
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
# Update sales order subtotal
|
||||
self.sale_order.subtotal = self.sale_order.items.aggregate(
|
||||
total=models.Sum('total_price')
|
||||
)['total'] or Decimal('0')
|
||||
self.sale_order.save()
|
||||
|
||||
# Update product stock if order is confirmed or shipped
|
||||
if self.sale_order.status in ['confirmed', 'shipped', 'delivered']:
|
||||
# Create stock movement
|
||||
from inventory.models import StockMovement
|
||||
StockMovement.objects.create(
|
||||
product=self.product,
|
||||
movement_type='sale',
|
||||
quantity=self.quantity,
|
||||
unit_price=self.unit_price,
|
||||
reference_type='SaleOrder',
|
||||
reference_id=self.sale_order.id,
|
||||
date=self.sale_order.date,
|
||||
notes=f"Sale order {self.sale_order.order_number}",
|
||||
created_by=self.sale_order.created_by
|
||||
)
|
||||
|
||||
class DeliveryNote(models.Model):
|
||||
"""Delivery note for shipped sales orders"""
|
||||
|
||||
delivery_number = models.CharField(max_length=50, unique=True)
|
||||
sale_order = models.ForeignKey(SaleOrder, on_delete=models.CASCADE, related_name='deliveries')
|
||||
delivery_date = models.DateField()
|
||||
|
||||
# Delivery details
|
||||
shipping_method = models.CharField(max_length=100, blank=True)
|
||||
tracking_number = models.CharField(max_length=100, blank=True)
|
||||
notes = models.TextField(blank=True)
|
||||
|
||||
# User tracking
|
||||
delivered_by = models.ForeignKey('users.CustomUser', on_delete=models.SET_NULL, null=True)
|
||||
|
||||
# Timestamps
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ['-delivery_date']
|
||||
verbose_name = _('Delivery Note')
|
||||
verbose_name_plural = _('Delivery Notes')
|
||||
|
||||
def __str__(self):
|
||||
return f"DN-{self.delivery_number} for SO-{self.sale_order.order_number}"
|
||||
155
setup.py
Normal file
155
setup.py
Normal file
@ -0,0 +1,155 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Setup script for Manufacturing App
|
||||
This script helps with initial project configuration and setup.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import subprocess
|
||||
import secrets
|
||||
from pathlib import Path
|
||||
|
||||
def run_command(command, description):
|
||||
"""Run a shell command and handle errors"""
|
||||
print(f"🔄 {description}...")
|
||||
try:
|
||||
result = subprocess.run(command, shell=True, check=True, capture_output=True, text=True)
|
||||
print(f"✅ {description} completed successfully")
|
||||
return True
|
||||
except subprocess.CalledProcessError as e:
|
||||
print(f"❌ {description} failed: {e}")
|
||||
print(f"Error output: {e.stderr}")
|
||||
return False
|
||||
|
||||
def create_env_file():
|
||||
"""Create .env file with secure settings"""
|
||||
env_file = Path('.env')
|
||||
if env_file.exists():
|
||||
print("📝 .env file already exists, skipping creation")
|
||||
return True
|
||||
|
||||
# Generate a secure secret key
|
||||
secret_key = ''.join(secrets.choice('abcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*(-_=+)') for i in range(50))
|
||||
|
||||
env_content = f"""# Django Settings
|
||||
SECRET_KEY={secret_key}
|
||||
DEBUG=True
|
||||
ALLOWED_HOSTS=localhost,127.0.0.1
|
||||
|
||||
# Database Settings (for future PostgreSQL migration)
|
||||
# DB_ENGINE=django.db.backends.postgresql
|
||||
# DB_NAME=manufacture_db
|
||||
# DB_USER=manufacture_user
|
||||
# DB_PASSWORD=your_password_here
|
||||
# DB_HOST=localhost
|
||||
# DB_PORT=5432
|
||||
"""
|
||||
|
||||
try:
|
||||
with open(env_file, 'w') as f:
|
||||
f.write(env_content)
|
||||
print("✅ .env file created successfully")
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"❌ Failed to create .env file: {e}")
|
||||
return False
|
||||
|
||||
def create_directories():
|
||||
"""Create necessary directories"""
|
||||
directories = ['static', 'media', 'backups', 'logs']
|
||||
|
||||
for directory in directories:
|
||||
dir_path = Path(directory)
|
||||
if not dir_path.exists():
|
||||
dir_path.mkdir(exist_ok=True)
|
||||
print(f"📁 Created directory: {directory}")
|
||||
else:
|
||||
print(f"📁 Directory already exists: {directory}")
|
||||
|
||||
def check_python_version():
|
||||
"""Check if Python version is compatible"""
|
||||
if sys.version_info < (3, 8):
|
||||
print("❌ Python 3.8 or higher is required")
|
||||
print(f"Current version: {sys.version}")
|
||||
return False
|
||||
|
||||
print(f"✅ Python version {sys.version_info.major}.{sys.version_info.minor} is compatible")
|
||||
return True
|
||||
|
||||
def install_dependencies():
|
||||
"""Install Python dependencies"""
|
||||
if not run_command("pip install -r requirements.txt", "Installing dependencies"):
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def setup_database():
|
||||
"""Set up the database"""
|
||||
commands = [
|
||||
("python manage.py makemigrations", "Creating database migrations"),
|
||||
("python manage.py migrate", "Applying database migrations"),
|
||||
]
|
||||
|
||||
for command, description in commands:
|
||||
if not run_command(command, description):
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def create_superuser():
|
||||
"""Create a superuser account"""
|
||||
print("👤 Creating superuser account...")
|
||||
print("Please enter the following information:")
|
||||
|
||||
try:
|
||||
# Run the createsuperuser command interactively
|
||||
subprocess.run("python manage.py createsuperuser", shell=True, check=True)
|
||||
print("✅ Superuser created successfully")
|
||||
return True
|
||||
except subprocess.CalledProcessError:
|
||||
print("❌ Failed to create superuser")
|
||||
return False
|
||||
|
||||
def main():
|
||||
"""Main setup function"""
|
||||
print("🚀 Manufacturing App Setup")
|
||||
print("=" * 40)
|
||||
|
||||
# Check Python version
|
||||
if not check_python_version():
|
||||
sys.exit(1)
|
||||
|
||||
# Create necessary directories
|
||||
create_directories()
|
||||
|
||||
# Create .env file
|
||||
if not create_env_file():
|
||||
sys.exit(1)
|
||||
|
||||
# Install dependencies
|
||||
if not install_dependencies():
|
||||
print("❌ Setup failed at dependency installation")
|
||||
sys.exit(1)
|
||||
|
||||
# Setup database
|
||||
if not setup_database():
|
||||
print("❌ Setup failed at database setup")
|
||||
sys.exit(1)
|
||||
|
||||
# Create superuser
|
||||
if not create_superuser():
|
||||
print("❌ Setup failed at superuser creation")
|
||||
sys.exit(1)
|
||||
|
||||
print("\n🎉 Setup completed successfully!")
|
||||
print("\nNext steps:")
|
||||
print("1. Run the development server: python manage.py runserver")
|
||||
print("2. Open your browser and go to: http://127.0.0.1:8000/")
|
||||
print("3. Log in with your superuser credentials")
|
||||
print("4. Start using the Manufacturing App!")
|
||||
|
||||
print("\n📚 For more information, check the README.md file")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
238
templates/base.html
Normal file
238
templates/base.html
Normal file
@ -0,0 +1,238 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}Manufacturing App{% endblock %}</title>
|
||||
|
||||
<!-- Bootstrap 5 CSS -->
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<!-- Bootstrap Icons -->
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.0/font/bootstrap-icons.css" rel="stylesheet">
|
||||
<!-- Chart.js -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||
|
||||
<style>
|
||||
.sidebar {
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
}
|
||||
|
||||
.sidebar .nav-link {
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 0.5rem;
|
||||
margin: 0.25rem 0;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.sidebar .nav-link:hover,
|
||||
.sidebar .nav-link.active {
|
||||
color: white;
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
transform: translateX(5px);
|
||||
}
|
||||
|
||||
.sidebar .nav-link i {
|
||||
margin-right: 0.75rem;
|
||||
width: 20px;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
background-color: #f8f9fa;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.card {
|
||||
border: none;
|
||||
border-radius: 1rem;
|
||||
box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.stat-card.success {
|
||||
background: linear-gradient(135deg, #11998e 0%, #38ef7d 100%);
|
||||
}
|
||||
|
||||
.stat-card.warning {
|
||||
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
|
||||
}
|
||||
|
||||
.stat-card.info {
|
||||
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
|
||||
}
|
||||
|
||||
.navbar-brand {
|
||||
font-weight: 700;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.user-profile {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background-color: rgba(255, 255, 255, 0.2);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
}
|
||||
</style>
|
||||
|
||||
{% block extra_css %}{% endblock %}
|
||||
</head>
|
||||
<body>
|
||||
<div class="container-fluid">
|
||||
<div class="row">
|
||||
<!-- Sidebar -->
|
||||
<nav class="col-md-3 col-lg-2 d-md-block sidebar collapse">
|
||||
<div class="position-sticky pt-3">
|
||||
<div class="text-center mb-4">
|
||||
<h4 class="text-white fw-bold">
|
||||
<i class="bi bi-gear-fill me-2"></i>
|
||||
Manufacturing
|
||||
</h4>
|
||||
</div>
|
||||
|
||||
<ul class="nav flex-column">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if request.resolver_match.url_name == 'dashboard' %}active{% endif %}"
|
||||
href="{% url 'core:dashboard' %}">
|
||||
<i class="bi bi-speedometer2"></i>
|
||||
Dashboard
|
||||
</a>
|
||||
</li>
|
||||
|
||||
{% if user.has_manufacturing_permission %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{% url 'manufacture:manufacturing_list' %}">
|
||||
<i class="bi bi-tools"></i>
|
||||
Manufacturing
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
|
||||
{% if user.has_inventory_permission %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{% url 'inventory:product_list' %}">
|
||||
<i class="bi bi-box-seam"></i>
|
||||
Inventory
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
|
||||
{% if user.has_purchase_permission %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{% url 'purchase:purchase_list' %}">
|
||||
<i class="bi bi-cart-plus"></i>
|
||||
Purchases
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
|
||||
{% if user.has_sales_permission %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{% url 'sales:sales_list' %}">
|
||||
<i class="bi bi-cart-check"></i>
|
||||
Sales
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
|
||||
{% if user.has_user_management_permission %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{% url 'users:user_list' %}">
|
||||
<i class="bi bi-people"></i>
|
||||
Users
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
|
||||
{% if user.is_superuser %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{% url 'core:database_management' %}">
|
||||
<i class="bi bi-database"></i>
|
||||
Database
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Main content -->
|
||||
<main class="col-md-9 ms-sm-auto col-lg-10 px-md-4 main-content">
|
||||
<!-- Top navbar -->
|
||||
<nav class="navbar navbar-expand-lg navbar-light bg-white shadow-sm rounded-3 my-3">
|
||||
<div class="container-fluid">
|
||||
<button class="navbar-toggler d-md-none collapsed" type="button" data-bs-toggle="collapse"
|
||||
data-bs-target=".sidebar" aria-controls="sidebar" aria-expanded="false"
|
||||
aria-label="Toggle navigation">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
|
||||
<div class="navbar-nav ms-auto">
|
||||
<div class="nav-item dropdown">
|
||||
<a class="nav-link dropdown-toggle user-profile" href="#" role="button"
|
||||
data-bs-toggle="dropdown" aria-expanded="false">
|
||||
<div class="user-avatar">
|
||||
{{ user.first_name|first|upper }}{{ user.last_name|first|upper }}
|
||||
</div>
|
||||
<span class="d-none d-md-inline">{{ user.get_full_name }}</span>
|
||||
</a>
|
||||
<ul class="dropdown-menu dropdown-menu-end">
|
||||
<li><a class="dropdown-item" href="{% url 'users:profile' %}">
|
||||
<i class="bi bi-person me-2"></i>Profile
|
||||
</a></li>
|
||||
<li><a class="dropdown-item" href="{% url 'users:profile_edit' %}">
|
||||
<i class="bi bi-pencil me-2"></i>Edit Profile
|
||||
</a></li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li><a class="dropdown-item" href="{% url 'users:logout' %}">
|
||||
<i class="bi bi-box-arrow-right me-2"></i>Logout
|
||||
</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Messages -->
|
||||
{% if messages %}
|
||||
{% for message in messages %}
|
||||
<div class="alert alert-{{ message.tags }} alert-dismissible fade show" role="alert">
|
||||
{{ message }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
<!-- Page content -->
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bootstrap 5 JS -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||
|
||||
{% block extra_js %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
324
templates/core/dashboard.html
Normal file
324
templates/core/dashboard.html
Normal file
@ -0,0 +1,324 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block title %}Dashboard - Manufacturing App{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid">
|
||||
<!-- Page header -->
|
||||
<div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom">
|
||||
<h1 class="h2">
|
||||
<i class="bi bi-speedometer2 me-2"></i>
|
||||
Dashboard
|
||||
</h1>
|
||||
<div class="btn-toolbar mb-2 mb-md-0">
|
||||
<div class="btn-group me-2">
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary">Export</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary">Print</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Statistics Cards -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-xl-3 col-md-6 mb-4">
|
||||
<div class="card stat-card h-100">
|
||||
<div class="card-body">
|
||||
<div class="row no-gutters align-items-center">
|
||||
<div class="col mr-2">
|
||||
<div class="text-xs font-weight-bold text-white-50 text-uppercase mb-1">
|
||||
Daily Production
|
||||
</div>
|
||||
<div class="h5 mb-0 font-weight-bold text-white">
|
||||
{{ daily_production.total_quantity|default:"0" }}
|
||||
</div>
|
||||
<div class="text-white-50">
|
||||
{{ daily_production.total_orders|default:"0" }} orders
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<i class="bi bi-tools fa-2x text-white-50"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-xl-3 col-md-6 mb-4">
|
||||
<div class="card stat-card success h-100">
|
||||
<div class="card-body">
|
||||
<div class="row no-gutters align-items-center">
|
||||
<div class="col mr-2">
|
||||
<div class="text-xs font-weight-bold text-white-50 text-uppercase mb-1">
|
||||
Daily Sales
|
||||
</div>
|
||||
<div class="h5 mb-0 font-weight-bold text-white">
|
||||
${{ daily_sales.total_amount|default:"0" }}
|
||||
</div>
|
||||
<div class="text-white-50">
|
||||
{{ daily_sales.total_orders|default:"0" }} orders
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<i class="bi bi-cart-check fa-2x text-white-50"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-xl-3 col-md-6 mb-4">
|
||||
<div class="card stat-card warning h-100">
|
||||
<div class="card-body">
|
||||
<div class="row no-gutters align-items-center">
|
||||
<div class="col mr-2">
|
||||
<div class="text-xs font-weight-bold text-white-50 text-uppercase mb-1">
|
||||
Daily Purchases
|
||||
</div>
|
||||
<div class="h5 mb-0 font-weight-bold text-white">
|
||||
${{ daily_purchases.total_amount|default:"0" }}
|
||||
</div>
|
||||
<div class="text-white-50">
|
||||
{{ daily_purchases.total_orders|default:"0" }} orders
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<i class="bi bi-cart-plus fa-2x text-white-50"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-xl-3 col-md-6 mb-4">
|
||||
<div class="card stat-card info h-100">
|
||||
<div class="card-body">
|
||||
<div class="row no-gutters align-items-center">
|
||||
<div class="col mr-2">
|
||||
<div class="text-xs font-weight-bold text-white-50 text-uppercase mb-1">
|
||||
Monthly Profit
|
||||
</div>
|
||||
<div class="h5 mb-0 font-weight-bold text-white">
|
||||
${{ monthly_profit|default:"0" }}
|
||||
</div>
|
||||
<div class="text-white-50">
|
||||
This month
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<i class="bi bi-graph-up fa-2x text-white-50"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Charts Row -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-xl-8 col-lg-7">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h6 class="m-0 font-weight-bold text-primary">Production Overview</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<canvas id="productionChart" width="400" height="200"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-xl-4 col-lg-5">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h6 class="m-0 font-weight-bold text-primary">Inventory Status</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<canvas id="inventoryChart" width="400" height="200"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Activities and Alerts -->
|
||||
<div class="row">
|
||||
<div class="col-lg-6">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h6 class="m-0 font-weight-bold text-primary">Recent Manufacturing Orders</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if recent_manufacturing %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Order</th>
|
||||
<th>Product</th>
|
||||
<th>Quantity</th>
|
||||
<th>Date</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for order in recent_manufacturing %}
|
||||
<tr>
|
||||
<td>{{ order.order_number }}</td>
|
||||
<td>{{ order.product.name }}</td>
|
||||
<td>{{ order.quantity }}</td>
|
||||
<td>{{ order.date }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-muted">No recent manufacturing orders.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-6">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h6 class="m-0 font-weight-bold text-primary">Low Stock Alerts</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if low_stock_products %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Product</th>
|
||||
<th>Current Stock</th>
|
||||
<th>Min Level</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for product in low_stock_products %}
|
||||
<tr>
|
||||
<td>{{ product.name }}</td>
|
||||
<td>{{ product.current_stock }}</td>
|
||||
<td>{{ product.min_stock_level }}</td>
|
||||
<td>
|
||||
<span class="badge bg-danger">Low Stock</span>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-muted">All products have sufficient stock.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Weekly and Monthly Summary -->
|
||||
<div class="row mt-4">
|
||||
<div class="col-lg-6">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h6 class="m-0 font-weight-bold text-primary">Weekly Summary</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row text-center">
|
||||
<div class="col-6">
|
||||
<h4 class="text-primary">{{ weekly_production.total_quantity|default:"0" }}</h4>
|
||||
<p class="text-muted">Production</p>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<h4 class="text-success">${{ weekly_sales.total_amount|default:"0" }}</h4>
|
||||
<p class="text-muted">Sales</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-6">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h6 class="m-0 font-weight-bold text-primary">Monthly Summary</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row text-center">
|
||||
<div class="col-6">
|
||||
<h4 class="text-primary">{{ monthly_production.total_quantity|default:"0" }}</h4>
|
||||
<p class="text-muted">Production</p>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<h4 class="text-success">${{ monthly_sales.total_amount|default:"0" }}</h4>
|
||||
<p class="text-muted">Sales</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
// Production Chart
|
||||
const productionCtx = document.getElementById('productionChart').getContext('2d');
|
||||
const productionChart = new Chart(productionCtx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: ['Daily', 'Weekly', 'Monthly'],
|
||||
datasets: [{
|
||||
label: 'Production',
|
||||
data: [
|
||||
{{ daily_production.total_quantity|default:"0" }},
|
||||
{{ weekly_production.total_quantity|default:"0" }},
|
||||
{{ monthly_production.total_quantity|default:"0" }}
|
||||
],
|
||||
borderColor: 'rgb(75, 192, 192)',
|
||||
backgroundColor: 'rgba(75, 192, 192, 0.2)',
|
||||
tension: 0.1
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Inventory Chart
|
||||
const inventoryCtx = document.getElementById('inventoryChart').getContext('2d');
|
||||
const inventoryChart = new Chart(inventoryCtx, {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels: ['Low Stock', 'Normal', 'Overstocked'],
|
||||
datasets: [{
|
||||
data: [
|
||||
{{ low_stock_products|length }},
|
||||
10,
|
||||
5
|
||||
],
|
||||
backgroundColor: [
|
||||
'#dc3545',
|
||||
'#28a745',
|
||||
'#ffc107'
|
||||
]
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'bottom'
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
249
templates/core/database_management.html
Normal file
249
templates/core/database_management.html
Normal file
@ -0,0 +1,249 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block title %}Database Management - Manufacturing App{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid">
|
||||
<!-- Page header -->
|
||||
<div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom">
|
||||
<h1 class="h2">
|
||||
<i class="bi bi-database me-2"></i>
|
||||
Database Management
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<!-- Database Information -->
|
||||
<div class="col-lg-4 mb-4">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h6 class="m-0 font-weight-bold text-primary">
|
||||
<i class="bi bi-info-circle me-2"></i>
|
||||
Database Information
|
||||
</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="mb-3">
|
||||
<strong>Database Engine:</strong>
|
||||
<span class="badge bg-primary ms-2">SQLite</span>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<strong>Database File:</strong>
|
||||
<code class="ms-2">db.sqlite3</code>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<strong>Location:</strong>
|
||||
<code class="ms-2">Project Root</code>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<strong>Size:</strong>
|
||||
<span class="ms-2">{{ db_size|default:"Calculating..." }}</span>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<strong>Last Modified:</strong>
|
||||
<span class="ms-2">{{ db_modified|default:"Unknown" }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Backup Database -->
|
||||
<div class="col-lg-4 mb-4">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h6 class="m-0 font-weight-bold text-success">
|
||||
<i class="bi bi-download me-2"></i>
|
||||
Backup Database
|
||||
</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="text-muted">
|
||||
Create a backup of the current database. The backup will be saved in the 'backups' folder.
|
||||
</p>
|
||||
<form method="post" action="{% url 'core:backup_database' %}">
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="btn btn-success w-100">
|
||||
<i class="bi bi-download me-2"></i>
|
||||
Create Backup
|
||||
</button>
|
||||
</form>
|
||||
<small class="text-muted">
|
||||
Backup files are saved with timestamps for easy identification.
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Duplicate Database -->
|
||||
<div class="col-lg-4 mb-4">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h6 class="m-0 font-weight-bold text-info">
|
||||
<i class="bi bi-files me-2"></i>
|
||||
Duplicate Database
|
||||
</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="text-muted">
|
||||
Create a duplicate copy of the current database for testing or development purposes.
|
||||
</p>
|
||||
<form method="post" action="{% url 'core:duplicate_database' %}">
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="btn btn-info w-100">
|
||||
<i class="bi bi-files me-2"></i>
|
||||
Duplicate Database
|
||||
</button>
|
||||
</form>
|
||||
<small class="text-muted">
|
||||
Duplicate files are saved in the 'backups' folder.
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Restore Database -->
|
||||
<div class="row">
|
||||
<div class="col-lg-8">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h6 class="m-0 font-weight-bold text-warning">
|
||||
<i class="bi bi-upload me-2"></i>
|
||||
Restore Database
|
||||
</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="alert alert-warning">
|
||||
<i class="bi bi-exclamation-triangle me-2"></i>
|
||||
<strong>Warning:</strong> Restoring a database will overwrite the current database.
|
||||
A safety backup of the current database will be created automatically before restoration.
|
||||
</div>
|
||||
|
||||
<form method="post" action="{% url 'core:restore_database' %}" enctype="multipart/form-data">
|
||||
{% csrf_token %}
|
||||
<div class="mb-3">
|
||||
<label for="backup_file" class="form-label">Select Backup File</label>
|
||||
<input type="file" class="form-control" id="backup_file" name="backup_file"
|
||||
accept=".sqlite3,.db" required>
|
||||
<div class="form-text">
|
||||
Only SQLite database files (.sqlite3, .db) are supported.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="confirm_restore" required>
|
||||
<label class="form-check-label" for="confirm_restore">
|
||||
I understand that this will overwrite the current database
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-warning" id="restore_btn" disabled>
|
||||
<i class="bi bi-upload me-2"></i>
|
||||
Restore Database
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Available Backups -->
|
||||
<div class="col-lg-4">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h6 class="m-0 font-weight-bold text-primary">
|
||||
<i class="bi bi-folder me-2"></i>
|
||||
Available Backups
|
||||
</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if available_backups %}
|
||||
<div class="list-group list-group-flush">
|
||||
{% for backup in available_backups %}
|
||||
<div class="list-group-item d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<small class="text-muted">{{ backup.name }}</small>
|
||||
<br>
|
||||
<small class="text-muted">{{ backup.size }}</small>
|
||||
</div>
|
||||
<small class="text-muted">{{ backup.modified }}</small>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-muted">No backup files found.</p>
|
||||
{% endif %}
|
||||
|
||||
<div class="mt-3">
|
||||
<a href="#" class="btn btn-outline-primary btn-sm w-100">
|
||||
<i class="bi bi-folder-open me-2"></i>
|
||||
Open Backups Folder
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Database Statistics -->
|
||||
<div class="row mt-4">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h6 class="m-0 font-weight-bold text-primary">
|
||||
<i class="bi bi-bar-chart me-2"></i>
|
||||
Database Statistics
|
||||
</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row text-center">
|
||||
<div class="col-md-3 mb-3">
|
||||
<h4 class="text-primary">{{ total_users|default:"0" }}</h4>
|
||||
<p class="text-muted">Total Users</p>
|
||||
</div>
|
||||
<div class="col-md-3 mb-3">
|
||||
<h4 class="text-success">{{ total_products|default:"0" }}</h4>
|
||||
<p class="text-muted">Total Products</p>
|
||||
</div>
|
||||
<div class="col-md-3 mb-3">
|
||||
<h4 class="text-info">{{ total_orders|default:"0" }}</h4>
|
||||
<p class="text-muted">Total Orders</p>
|
||||
</div>
|
||||
<div class="col-md-3 mb-3">
|
||||
<h4 class="text-warning">{{ total_customers|default:"0" }}</h4>
|
||||
<p class="text-muted">Total Customers</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
// Enable/disable restore button based on checkbox
|
||||
document.getElementById('confirm_restore').addEventListener('change', function() {
|
||||
document.getElementById('restore_btn').disabled = !this.checked;
|
||||
});
|
||||
|
||||
// File input validation
|
||||
document.getElementById('backup_file').addEventListener('change', function() {
|
||||
const file = this.files[0];
|
||||
if (file) {
|
||||
const allowedTypes = ['application/x-sqlite3', 'application/vnd.sqlite3', 'application/octet-stream'];
|
||||
const allowedExtensions = ['.sqlite3', '.db'];
|
||||
|
||||
const isValidType = allowedTypes.includes(file.type) ||
|
||||
allowedExtensions.some(ext => file.name.toLowerCase().endsWith(ext));
|
||||
|
||||
if (!isValidType) {
|
||||
alert('Please select a valid SQLite database file (.sqlite3 or .db)');
|
||||
this.value = '';
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
142
templates/users/login.html
Normal file
142
templates/users/login.html
Normal file
@ -0,0 +1,142 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Login - Manufacturing App</title>
|
||||
|
||||
<!-- Bootstrap 5 CSS -->
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<!-- Bootstrap Icons -->
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.0/font/bootstrap-icons.css" rel="stylesheet">
|
||||
|
||||
<style>
|
||||
body {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.login-container {
|
||||
background: white;
|
||||
border-radius: 1rem;
|
||||
box-shadow: 0 1rem 3rem rgba(0, 0, 0, 0.175);
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.login-header {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.login-header h1 {
|
||||
margin: 0;
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.login-header p {
|
||||
margin: 0.5rem 0 0 0;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.login-body {
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.form-floating {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.btn-login {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
border: none;
|
||||
padding: 0.75rem;
|
||||
font-weight: 600;
|
||||
width: 100%;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.btn-login:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.alert {
|
||||
border-radius: 0.5rem;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.form-control:focus {
|
||||
border-color: #667eea;
|
||||
box-shadow: 0 0 0 0.2rem rgba(102, 126, 234, 0.25);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="login-container">
|
||||
<div class="login-header">
|
||||
<h1>
|
||||
<i class="bi bi-gear-fill me-2"></i>
|
||||
Manufacturing
|
||||
</h1>
|
||||
<p>Sign in to your account</p>
|
||||
</div>
|
||||
|
||||
<div class="login-body">
|
||||
{% if messages %}
|
||||
{% for message in messages %}
|
||||
<div class="alert alert-{{ message.tags }} alert-dismissible fade show" role="alert">
|
||||
{{ message }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
{% if form.errors %}
|
||||
<div class="alert alert-danger">
|
||||
<i class="bi bi-exclamation-triangle me-2"></i>
|
||||
<strong>Login Error:</strong> Please check your username and password.
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
|
||||
<div class="form-floating">
|
||||
<input type="text" class="form-control" id="id_username" name="username"
|
||||
placeholder="Username" required autofocus>
|
||||
<label for="id_username">Username</label>
|
||||
</div>
|
||||
|
||||
<div class="form-floating">
|
||||
<input type="password" class="form-control" id="id_password" name="password"
|
||||
placeholder="Password" required>
|
||||
<label for="id_password">Password</label>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary btn-login">
|
||||
<i class="bi bi-box-arrow-in-right me-2"></i>
|
||||
Sign In
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="text-center mt-3">
|
||||
<small class="text-muted">
|
||||
<i class="bi bi-shield-lock me-1"></i>
|
||||
Secure login system
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bootstrap 5 JS -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
3
users/__init__.py
Normal file
3
users/__init__.py
Normal file
@ -0,0 +1,3 @@
|
||||
|
||||
|
||||
|
||||
55
users/admin.py
Normal file
55
users/admin.py
Normal file
@ -0,0 +1,55 @@
|
||||
from django.contrib import admin
|
||||
from django.contrib.auth.admin import UserAdmin
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from .models import CustomUser, UserGroup
|
||||
|
||||
@admin.register(CustomUser)
|
||||
class CustomUserAdmin(UserAdmin):
|
||||
"""Admin configuration for CustomUser model"""
|
||||
|
||||
list_display = ('username', 'email', 'first_name', 'last_name', 'user_type',
|
||||
'department', 'employee_id', 'is_active', 'date_joined')
|
||||
list_filter = ('user_type', 'department', 'is_active', 'date_joined')
|
||||
search_fields = ('username', 'first_name', 'last_name', 'email', 'employee_id')
|
||||
ordering = ('username',)
|
||||
|
||||
fieldsets = (
|
||||
(None, {'fields': ('username', 'password')}),
|
||||
(_('Personal info'), {'fields': ('first_name', 'last_name', 'email', 'phone',
|
||||
'department', 'employee_id', 'profile_picture')}),
|
||||
(_('Permissions'), {'fields': ('user_type', 'is_active', 'is_staff', 'is_superuser',
|
||||
'groups', 'user_permissions')}),
|
||||
(_('Important dates'), {'fields': ('last_login', 'date_joined')}),
|
||||
)
|
||||
|
||||
add_fieldsets = (
|
||||
(None, {
|
||||
'classes': ('wide',),
|
||||
'fields': ('username', 'password1', 'password2', 'email', 'first_name',
|
||||
'last_name', 'user_type', 'department', 'employee_id'),
|
||||
}),
|
||||
)
|
||||
|
||||
def get_queryset(self, request):
|
||||
return super().get_queryset(request).select_related()
|
||||
|
||||
@admin.register(UserGroup)
|
||||
class UserGroupAdmin(admin.ModelAdmin):
|
||||
"""Admin configuration for UserGroup model"""
|
||||
|
||||
list_display = ('name', 'description', 'user_count', 'created_at', 'updated_at')
|
||||
list_filter = ('created_at', 'updated_at')
|
||||
search_fields = ('name', 'description')
|
||||
ordering = ('name',)
|
||||
|
||||
fieldsets = (
|
||||
(None, {'fields': ('name', 'description')}),
|
||||
(_('Users'), {'fields': ('users',)}),
|
||||
(_('Permissions'), {'fields': ('permissions',)}),
|
||||
)
|
||||
|
||||
filter_horizontal = ('users',)
|
||||
|
||||
def user_count(self, obj):
|
||||
return obj.users.count()
|
||||
user_count.short_description = 'Number of Users'
|
||||
6
users/apps.py
Normal file
6
users/apps.py
Normal file
@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class UsersConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'users'
|
||||
96
users/forms.py
Normal file
96
users/forms.py
Normal file
@ -0,0 +1,96 @@
|
||||
from django import forms
|
||||
from django.contrib.auth.forms import UserCreationForm, UserChangeForm
|
||||
from django.contrib.auth import get_user_model
|
||||
from .models import UserGroup
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
class CustomUserCreationForm(UserCreationForm):
|
||||
"""Form for creating new users"""
|
||||
|
||||
class Meta(UserCreationForm.Meta):
|
||||
model = User
|
||||
fields = ('username', 'email', 'first_name', 'last_name', 'user_type',
|
||||
'department', 'employee_id', 'phone')
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
# Make password fields required
|
||||
self.fields['password1'].required = True
|
||||
self.fields['password2'].required = True
|
||||
|
||||
# Add Bootstrap classes
|
||||
for field in self.fields.values():
|
||||
field.widget.attrs.update({'class': 'form-control'})
|
||||
|
||||
class CustomUserForm(UserChangeForm):
|
||||
"""Form for editing existing users"""
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
fields = ('username', 'email', 'first_name', 'last_name', 'user_type',
|
||||
'department', 'employee_id', 'phone', 'is_active', 'groups')
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
# Remove password field from edit form
|
||||
if 'password' in self.fields:
|
||||
del self.fields['password']
|
||||
|
||||
# Add Bootstrap classes
|
||||
for field in self.fields.values():
|
||||
if isinstance(field.widget, forms.CheckboxInput):
|
||||
field.widget.attrs.update({'class': 'form-check-input'})
|
||||
else:
|
||||
field.widget.attrs.update({'class': 'form-control'})
|
||||
|
||||
class UserGroupForm(forms.ModelForm):
|
||||
"""Form for user groups"""
|
||||
|
||||
class Meta:
|
||||
model = UserGroup
|
||||
fields = ('name', 'description', 'users', 'permissions')
|
||||
widgets = {
|
||||
'description': forms.Textarea(attrs={'rows': 3}),
|
||||
'permissions': forms.Textarea(attrs={'rows': 5, 'placeholder': 'Enter JSON permissions'}),
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
# Add Bootstrap classes
|
||||
for field in self.fields.values():
|
||||
if isinstance(field.widget, forms.CheckboxSelectMultiple):
|
||||
field.widget.attrs.update({'class': 'form-check-input'})
|
||||
elif isinstance(field.widget, forms.SelectMultiple):
|
||||
field.widget.attrs.update({'class': 'form-select'})
|
||||
else:
|
||||
field.widget.attrs.update({'class': 'form-control'})
|
||||
|
||||
def clean_permissions(self):
|
||||
"""Validate permissions JSON field"""
|
||||
permissions = self.cleaned_data.get('permissions')
|
||||
if permissions:
|
||||
try:
|
||||
import json
|
||||
if isinstance(permissions, str):
|
||||
json.loads(permissions)
|
||||
return permissions
|
||||
except json.JSONDecodeError:
|
||||
raise forms.ValidationError("Invalid JSON format for permissions")
|
||||
return permissions
|
||||
|
||||
class ProfileEditForm(forms.ModelForm):
|
||||
"""Form for editing user profile"""
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
fields = ('first_name', 'last_name', 'email', 'phone', 'department', 'profile_picture')
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
# Add Bootstrap classes
|
||||
for field in self.fields.values():
|
||||
if isinstance(field.widget, forms.FileInput):
|
||||
field.widget.attrs.update({'class': 'form-control'})
|
||||
else:
|
||||
field.widget.attrs.update({'class': 'form-control'})
|
||||
96
users/models.py
Normal file
96
users/models.py
Normal file
@ -0,0 +1,96 @@
|
||||
from django.contrib.auth.models import AbstractUser
|
||||
from django.db import models
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
class CustomUser(AbstractUser):
|
||||
"""Custom user model with extended fields"""
|
||||
|
||||
# User types
|
||||
USER_TYPE_CHOICES = [
|
||||
('admin', 'Administrator'),
|
||||
('manager', 'Manager'),
|
||||
('operator', 'Operator'),
|
||||
('viewer', 'Viewer'),
|
||||
]
|
||||
|
||||
user_type = models.CharField(
|
||||
max_length=20,
|
||||
choices=USER_TYPE_CHOICES,
|
||||
default='operator'
|
||||
)
|
||||
|
||||
phone = models.CharField(max_length=20, blank=True, null=True)
|
||||
department = models.CharField(max_length=100, blank=True, null=True)
|
||||
employee_id = models.CharField(max_length=20, blank=True, null=True, unique=True)
|
||||
is_active = models.BooleanField(default=True)
|
||||
date_joined = models.DateTimeField(auto_now_add=True)
|
||||
last_login = models.DateTimeField(auto_now=True)
|
||||
|
||||
# Profile picture
|
||||
profile_picture = models.ImageField(
|
||||
upload_to='profile_pictures/',
|
||||
blank=True,
|
||||
null=True
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _('User')
|
||||
verbose_name_plural = _('Users')
|
||||
ordering = ['username']
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.username} ({self.get_user_type_display()})"
|
||||
|
||||
def get_full_name(self):
|
||||
if self.first_name and self.last_name:
|
||||
return f"{self.first_name} {self.last_name}"
|
||||
return self.username
|
||||
|
||||
def has_manufacturing_permission(self):
|
||||
"""Check if user has manufacturing permissions"""
|
||||
return self.user_type in ['admin', 'manager', 'operator']
|
||||
|
||||
def has_inventory_permission(self):
|
||||
"""Check if user has inventory permissions"""
|
||||
return self.user_type in ['admin', 'manager', 'operator']
|
||||
|
||||
def has_purchase_permission(self):
|
||||
"""Check if user has purchase permissions"""
|
||||
return self.user_type in ['admin', 'manager']
|
||||
|
||||
def has_sales_permission(self):
|
||||
"""Check if user has sales permissions"""
|
||||
return self.user_type in ['admin', 'manager', 'operator']
|
||||
|
||||
def has_user_management_permission(self):
|
||||
"""Check if user has user management permissions"""
|
||||
return self.user_type in ['admin', 'manager']
|
||||
|
||||
def has_reporting_permission(self):
|
||||
"""Check if user has reporting permissions"""
|
||||
return self.user_type in ['admin', 'manager', 'viewer']
|
||||
|
||||
class UserGroup(models.Model):
|
||||
"""Custom user groups for specific permissions"""
|
||||
|
||||
name = models.CharField(max_length=100, unique=True)
|
||||
description = models.TextField(blank=True)
|
||||
users = models.ManyToManyField(CustomUser, related_name='custom_groups')
|
||||
permissions = models.JSONField(default=dict) # Store permissions as JSON
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ['name']
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def get_permissions(self):
|
||||
"""Get permissions for this group"""
|
||||
return self.permissions or {}
|
||||
|
||||
def has_permission(self, permission_name):
|
||||
"""Check if group has specific permission"""
|
||||
permissions = self.get_permissions()
|
||||
return permissions.get(permission_name, False)
|
||||
20
users/urls.py
Normal file
20
users/urls.py
Normal file
@ -0,0 +1,20 @@
|
||||
from django.urls import path
|
||||
from django.contrib.auth import views as auth_views
|
||||
from . import views
|
||||
|
||||
app_name = 'users'
|
||||
|
||||
urlpatterns = [
|
||||
path('login/', auth_views.LoginView.as_view(template_name='users/login.html'), name='login'),
|
||||
path('logout/', auth_views.LogoutView.as_view(), name='logout'),
|
||||
path('profile/', views.ProfileView.as_view(), name='profile'),
|
||||
path('profile/edit/', views.ProfileEditView.as_view(), name='profile_edit'),
|
||||
path('change-password/', views.ChangePasswordView.as_view(), name='change_password'),
|
||||
path('users/', views.UserListView.as_view(), name='user_list'),
|
||||
path('users/<int:pk>/', views.UserDetailView.as_view(), name='user_detail'),
|
||||
path('users/<int:pk>/edit/', views.UserEditView.as_view(), name='user_edit'),
|
||||
path('users/create/', views.UserCreateView.as_view(), name='user_create'),
|
||||
path('groups/', views.GroupListView.as_view(), name='group_list'),
|
||||
path('groups/<int:pk>/', views.GroupDetailView.as_view(), name='group_detail'),
|
||||
path('groups/create/', views.GroupCreateView.as_view(), name='group_create'),
|
||||
]
|
||||
163
users/views.py
Normal file
163
users/views.py
Normal file
@ -0,0 +1,163 @@
|
||||
from django.shortcuts import render, redirect, get_object_or_404
|
||||
from django.contrib.auth.decorators import login_required, user_passes_test
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin
|
||||
from django.contrib import messages
|
||||
from django.views.generic import (
|
||||
ListView, DetailView, CreateView, UpdateView, DeleteView
|
||||
)
|
||||
from django.urls import reverse_lazy
|
||||
from django.contrib.auth import update_session_auth_hash
|
||||
from django.contrib.auth.forms import PasswordChangeForm
|
||||
from django.db.models import Q
|
||||
|
||||
from .models import CustomUser, UserGroup
|
||||
from .forms import CustomUserForm, UserGroupForm
|
||||
|
||||
def is_admin_or_manager(user):
|
||||
"""Check if user is admin or manager"""
|
||||
return user.user_type in ['admin', 'manager']
|
||||
|
||||
def is_admin(user):
|
||||
"""Check if user is admin"""
|
||||
return user.user_type == 'admin'
|
||||
|
||||
class ProfileView(LoginRequiredMixin, DetailView):
|
||||
"""User profile view"""
|
||||
model = CustomUser
|
||||
template_name = 'users/profile.html'
|
||||
context_object_name = 'user_profile'
|
||||
|
||||
def get_object(self):
|
||||
return self.request.user
|
||||
|
||||
class ProfileEditView(LoginRequiredMixin, UpdateView):
|
||||
"""Edit user profile"""
|
||||
model = CustomUser
|
||||
template_name = 'users/profile_edit.html'
|
||||
fields = ['first_name', 'last_name', 'email', 'phone', 'department', 'profile_picture']
|
||||
success_url = reverse_lazy('users:profile')
|
||||
|
||||
def get_object(self):
|
||||
return self.request.user
|
||||
|
||||
def form_valid(self, form):
|
||||
messages.success(self.request, 'Profile updated successfully!')
|
||||
return super().form_valid(form)
|
||||
|
||||
class ChangePasswordView(LoginRequiredMixin, UpdateView):
|
||||
"""Change user password"""
|
||||
model = CustomUser
|
||||
template_name = 'users/change_password.html'
|
||||
success_url = reverse_lazy('users:profile')
|
||||
|
||||
def get_object(self):
|
||||
return self.request.user
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
context['form'] = PasswordChangeForm(self.request.user)
|
||||
return context
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
form = PasswordChangeForm(request.user, request.POST)
|
||||
if form.is_valid():
|
||||
user = form.save()
|
||||
update_session_auth_hash(request, user)
|
||||
messages.success(request, 'Password changed successfully!')
|
||||
return redirect('users:profile')
|
||||
else:
|
||||
messages.error(request, 'Please correct the errors below.')
|
||||
return self.render_to_response(self.get_context_data(form=form))
|
||||
|
||||
class UserListView(LoginRequiredMixin, UserPassesTestMixin, ListView):
|
||||
"""List all users (admin/manager only)"""
|
||||
model = CustomUser
|
||||
template_name = 'users/user_list.html'
|
||||
context_object_name = 'users'
|
||||
paginate_by = 20
|
||||
|
||||
def test_func(self):
|
||||
return is_admin_or_manager(self.request.user)
|
||||
|
||||
def get_queryset(self):
|
||||
queryset = CustomUser.objects.all().order_by('username')
|
||||
search_query = self.request.GET.get('search', '')
|
||||
if search_query:
|
||||
queryset = queryset.filter(
|
||||
Q(username__icontains=search_query) |
|
||||
Q(first_name__icontains=search_query) |
|
||||
Q(last_name__icontains=search_query) |
|
||||
Q(email__icontains=search_query) |
|
||||
Q(employee_id__icontains=search_query)
|
||||
)
|
||||
return queryset
|
||||
|
||||
class UserDetailView(LoginRequiredMixin, UserPassesTestMixin, DetailView):
|
||||
"""User detail view (admin/manager only)"""
|
||||
model = CustomUser
|
||||
template_name = 'users/user_detail.html'
|
||||
context_object_name = 'user_detail'
|
||||
|
||||
def test_func(self):
|
||||
return is_admin_or_manager(self.request.user)
|
||||
|
||||
class UserCreateView(LoginRequiredMixin, UserPassesTestMixin, CreateView):
|
||||
"""Create new user (admin/manager only)"""
|
||||
model = CustomUser
|
||||
form_class = CustomUserForm
|
||||
template_name = 'users/user_form.html'
|
||||
success_url = reverse_lazy('users:user_list')
|
||||
|
||||
def test_func(self):
|
||||
return is_admin_or_manager(self.request.user)
|
||||
|
||||
def form_valid(self, form):
|
||||
messages.success(self.request, 'User created successfully!')
|
||||
return super().form_valid(form)
|
||||
|
||||
class UserEditView(LoginRequiredMixin, UserPassesTestMixin, UpdateView):
|
||||
"""Edit user (admin/manager only)"""
|
||||
model = CustomUser
|
||||
form_class = CustomUserForm
|
||||
template_name = 'users/user_form.html'
|
||||
success_url = reverse_lazy('users:user_list')
|
||||
|
||||
def test_func(self):
|
||||
return is_admin_or_manager(self.request.user)
|
||||
|
||||
def form_valid(self, form):
|
||||
messages.success(self.request, 'User updated successfully!')
|
||||
return super().form_valid(form)
|
||||
|
||||
class GroupListView(LoginRequiredMixin, UserPassesTestMixin, ListView):
|
||||
"""List all user groups (admin/manager only)"""
|
||||
model = UserGroup
|
||||
template_name = 'users/group_list.html'
|
||||
context_object_name = 'groups'
|
||||
paginate_by = 20
|
||||
|
||||
def test_func(self):
|
||||
return is_admin_or_manager(self.request.user)
|
||||
|
||||
class GroupDetailView(LoginRequiredMixin, UserPassesTestMixin, DetailView):
|
||||
"""User group detail view (admin/manager only)"""
|
||||
model = UserGroup
|
||||
template_name = 'users/group_detail.html'
|
||||
context_object_name = 'group_detail'
|
||||
|
||||
def test_func(self):
|
||||
return is_admin_or_manager(self.request.user)
|
||||
|
||||
class GroupCreateView(LoginRequiredMixin, UserPassesTestMixin, CreateView):
|
||||
"""Create new user group (admin/manager only)"""
|
||||
model = UserGroup
|
||||
form_class = UserGroupForm
|
||||
template_name = 'users/group_form.html'
|
||||
success_url = reverse_lazy('users:group_list')
|
||||
|
||||
def test_func(self):
|
||||
return is_admin_or_manager(self.request.user)
|
||||
|
||||
def form_valid(self, form):
|
||||
messages.success(self.request, 'User group created successfully!')
|
||||
return super().form_valid(form)
|
||||
Loading…
Reference in New Issue
Block a user