first commit

This commit is contained in:
Suherdy Yacob 2025-08-17 21:42:40 +07:00
commit 5554fab50f
40 changed files with 3320 additions and 0 deletions

245
README.md Normal file
View 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
View File

@ -0,0 +1,3 @@

6
core/apps.py Normal file
View 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
View 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
View 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
View File

@ -0,0 +1,3 @@

54
inventory/admin.py Normal file
View 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
View 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
View 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
View 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
View File

@ -0,0 +1,3 @@

50
manufacture/admin.py Normal file
View 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
View 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
View 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

View File

@ -0,0 +1,3 @@

16
manufacture_app/asgi.py Normal file
View 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
View 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
View 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
View 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
View File

@ -0,0 +1,3 @@

75
purchase/admin.py Normal file
View 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
View 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
View 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
View 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
View File

@ -0,0 +1,3 @@

78
sales/admin.py Normal file
View 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
View 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
View 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
View 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
View 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>

View 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 %}

View 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
View 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
View File

@ -0,0 +1,3 @@

55
users/admin.py Normal file
View 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
View 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
View 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
View 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
View 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
View 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)