first commit

This commit is contained in:
suherdy yacob 2025-08-22 17:05:22 +07:00
commit c001cd97fc
20477 changed files with 3394166 additions and 0 deletions

20
.env Normal file
View File

@ -0,0 +1,20 @@
# Django Settings
DEBUG=True
SECRET_KEY=your-development-secret-key
ALLOWED_HOSTS=localhost,127.0.0.1
# Database Settings (SQLite for development)
# For PostgreSQL in production, uncomment and set these:
# DATABASE_URL=postgres://user:password@host:port/dbname
# DB_NAME=your_db_name
# DB_USER=your_db_user
# DB_PASSWORD=your_db_password
# DB_HOST=your_db_host
# DB_PORT=5432
# Email Settings (optional)
# EMAIL_HOST=your_email_host
# EMAIL_PORT=587
# EMAIL_USE_TLS=True
# EMAIL_HOST_USER=your_email_user
# EMAIL_HOST_PASSWORD=your_email_password

View File

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

3
apps/accounts/admin.py Normal file
View File

@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.

6
apps/accounts/apps.py Normal file
View File

@ -0,0 +1,6 @@
from django.apps import AppConfig
class AccountsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.accounts"

View File

@ -0,0 +1,35 @@
from .models import Role, Permission, RolePermission, UserRole
def custom_permissions(request):
"""
Context processor to add custom role-based permissions to template context
"""
if not request.user.is_authenticated:
return {}
# Get user's roles
user_roles = UserRole.objects.filter(user=request.user).values_list('role', flat=True)
# Get all permissions for user's roles
role_permissions = RolePermission.objects.filter(role__in=user_roles).select_related('permission')
# Create a dictionary of permissions organized by module
user_permissions = {}
for rp in role_permissions:
permission = rp.permission
module = permission.module
codename = permission.codename
if module not in user_permissions:
user_permissions[module] = {}
user_permissions[module][codename] = True
# Also add flat permissions for backward compatibility
flat_permissions = {rp.permission.codename: True for rp in role_permissions}
return {
'user_permissions': user_permissions,
'user_flat_permissions': flat_permissions,
}

View File

@ -0,0 +1,81 @@
from django.core.management.base import BaseCommand
from apps.accounts.models import Permission
class Command(BaseCommand):
help = 'Create default permissions for all modules'
def handle(self, *args, **options):
default_permissions = [
# Dashboard permissions
{'name': 'View Dashboard', 'codename': 'view_dashboard', 'module': 'dashboard', 'description': 'Can view dashboard'},
# User management permissions
{'name': 'View User', 'codename': 'view_user', 'module': 'accounts', 'description': 'Can view user list and details'},
{'name': 'Add User', 'codename': 'add_user', 'module': 'accounts', 'description': 'Can create new users'},
{'name': 'Change User', 'codename': 'change_user', 'module': 'accounts', 'description': 'Can edit user information'},
{'name': 'Delete User', 'codename': 'delete_user', 'module': 'accounts', 'description': 'Can delete users'},
# Role management permissions
{'name': 'View Role', 'codename': 'view_role', 'module': 'accounts', 'description': 'Can view role list and details'},
{'name': 'Add Role', 'codename': 'add_role', 'module': 'accounts', 'description': 'Can create new roles'},
{'name': 'Change Role', 'codename': 'change_role', 'module': 'accounts', 'description': 'Can edit role information'},
{'name': 'Delete Role', 'codename': 'delete_role', 'module': 'accounts', 'description': 'Can delete roles'},
# Permission management permissions
{'name': 'View Permission', 'codename': 'view_permission', 'module': 'accounts', 'description': 'Can view permission list'},
{'name': 'Add Permission', 'codename': 'add_permission', 'module': 'accounts', 'description': 'Can create new permissions'},
{'name': 'Change Permission', 'codename': 'change_permission', 'module': 'accounts', 'description': 'Can edit permissions'},
{'name': 'Delete Permission', 'codename': 'delete_permission', 'module': 'accounts', 'description': 'Can delete permissions'},
# Inventory permissions
{'name': 'View Product', 'codename': 'view_product', 'module': 'inventory', 'description': 'Can view product list and details'},
{'name': 'Add Product', 'codename': 'add_product', 'module': 'inventory', 'description': 'Can create new products'},
{'name': 'Change Product', 'codename': 'change_product', 'module': 'inventory', 'description': 'Can edit product information'},
{'name': 'Delete Product', 'codename': 'delete_product', 'module': 'inventory', 'description': 'Can delete products'},
# Purchasing permissions
{'name': 'View Purchase Order', 'codename': 'view_purchaseorder', 'module': 'purchasing', 'description': 'Can view purchase orders'},
{'name': 'Add Purchase Order', 'codename': 'add_purchaseorder', 'module': 'purchasing', 'description': 'Can create purchase orders'},
{'name': 'Change Purchase Order', 'codename': 'change_purchaseorder', 'module': 'purchasing', 'description': 'Can edit purchase orders'},
{'name': 'Delete Purchase Order', 'codename': 'delete_purchaseorder', 'module': 'purchasing', 'description': 'Can delete purchase orders'},
# Sales permissions
{'name': 'View Sales Order', 'codename': 'view_salesorder', 'module': 'sales', 'description': 'Can view sales orders'},
{'name': 'Add Sales Order', 'codename': 'add_salesorder', 'module': 'sales', 'description': 'Can create sales orders'},
{'name': 'Change Sales Order', 'codename': 'change_salesorder', 'module': 'sales', 'description': 'Can edit sales orders'},
{'name': 'Delete Sales Order', 'codename': 'delete_salesorder', 'module': 'sales', 'description': 'Can delete sales orders'},
# Manufacturing permissions
{'name': 'View Manufacturing Order', 'codename': 'view_manufacturingorder', 'module': 'manufacturing', 'description': 'Can view manufacturing orders'},
{'name': 'Add Manufacturing Order', 'codename': 'add_manufacturingorder', 'module': 'manufacturing', 'description': 'Can create manufacturing orders'},
{'name': 'Change Manufacturing Order', 'codename': 'change_manufacturingorder', 'module': 'manufacturing', 'description': 'Can edit manufacturing orders'},
{'name': 'Delete Manufacturing Order', 'codename': 'delete_manufacturingorder', 'module': 'manufacturing', 'description': 'Can delete manufacturing orders'},
# Reports permissions
{'name': 'View Report', 'codename': 'view_report', 'module': 'reports', 'description': 'Can view reports'},
{'name': 'Add Report', 'codename': 'add_report', 'module': 'reports', 'description': 'Can create reports'},
{'name': 'Change Report', 'codename': 'change_report', 'module': 'reports', 'description': 'Can edit reports'},
{'name': 'Delete Report', 'codename': 'delete_report', 'module': 'reports', 'description': 'Can delete reports'},
# Database management permissions
{'name': 'View Database', 'codename': 'view_database', 'module': 'database_management', 'description': 'Can view database management'},
{'name': 'Add Database', 'codename': 'add_database', 'module': 'database_management', 'description': 'Can create database entries'},
{'name': 'Change Database', 'codename': 'change_database', 'module': 'database_management', 'description': 'Can edit database entries'},
{'name': 'Delete Database', 'codename': 'delete_database', 'module': 'database_management', 'description': 'Can delete database entries'},
]
created_count = 0
for perm_data in default_permissions:
permission, created = Permission.objects.get_or_create(
codename=perm_data['codename'],
defaults=perm_data
)
if created:
created_count += 1
self.stdout.write(
self.style.SUCCESS(
f'Successfully created {created_count} default permissions'
)
)

View File

@ -0,0 +1,221 @@
# Generated by Django 4.2.23 on 2025-08-21 02:29
from django.conf import settings
import django.contrib.auth.models
import django.contrib.auth.validators
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
initial = True
dependencies = [
("auth", "0012_alter_user_first_name_max_length"),
]
operations = [
migrations.CreateModel(
name="User",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("password", models.CharField(max_length=128, verbose_name="password")),
(
"last_login",
models.DateTimeField(
blank=True, null=True, verbose_name="last login"
),
),
(
"is_superuser",
models.BooleanField(
default=False,
help_text="Designates that this user has all permissions without explicitly assigning them.",
verbose_name="superuser status",
),
),
(
"username",
models.CharField(
error_messages={
"unique": "A user with that username already exists."
},
help_text="Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.",
max_length=150,
unique=True,
validators=[
django.contrib.auth.validators.UnicodeUsernameValidator()
],
verbose_name="username",
),
),
(
"first_name",
models.CharField(
blank=True, max_length=150, verbose_name="first name"
),
),
(
"last_name",
models.CharField(
blank=True, max_length=150, verbose_name="last name"
),
),
(
"email",
models.EmailField(
blank=True, max_length=254, verbose_name="email address"
),
),
(
"is_staff",
models.BooleanField(
default=False,
help_text="Designates whether the user can log into this admin site.",
verbose_name="staff status",
),
),
("phone", models.CharField(blank=True, max_length=20)),
("department", models.CharField(blank=True, max_length=100)),
("position", models.CharField(blank=True, max_length=100)),
("is_active", models.BooleanField(default=True)),
("date_joined", models.DateTimeField(auto_now_add=True)),
(
"groups",
models.ManyToManyField(
blank=True,
help_text="The groups this user belongs to. A user will get all permissions granted to each of their groups.",
related_name="user_set",
related_query_name="user",
to="auth.group",
verbose_name="groups",
),
),
(
"user_permissions",
models.ManyToManyField(
blank=True,
help_text="Specific permissions for this user.",
related_name="user_set",
related_query_name="user",
to="auth.permission",
verbose_name="user permissions",
),
),
],
options={
"verbose_name": "user",
"verbose_name_plural": "users",
"abstract": False,
},
managers=[
("objects", django.contrib.auth.models.UserManager()),
],
),
migrations.CreateModel(
name="Permission",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("name", models.CharField(max_length=100, unique=True)),
("codename", models.CharField(max_length=100, unique=True)),
("module", models.CharField(max_length=50)),
("description", models.TextField(blank=True)),
],
),
migrations.CreateModel(
name="Role",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("name", models.CharField(max_length=50, unique=True)),
("description", models.TextField(blank=True)),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
],
),
migrations.CreateModel(
name="UserRole",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("assigned_at", models.DateTimeField(auto_now_add=True)),
(
"role",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="accounts.role"
),
),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"unique_together": {("user", "role")},
},
),
migrations.CreateModel(
name="RolePermission",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"permission",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to="accounts.permission",
),
),
(
"role",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="accounts.role"
),
),
],
options={
"unique_together": {("role", "permission")},
},
),
]

View File

@ -0,0 +1,17 @@
# Generated by Django 4.2.23 on 2025-08-21 15:17
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("accounts", "0001_initial"),
]
operations = [
migrations.AlterUniqueTogether(
name="rolepermission",
unique_together=set(),
),
]

View File

118
apps/accounts/models.py Normal file
View File

@ -0,0 +1,118 @@
from django.contrib.auth.models import AbstractUser
from django.db import models
from django.db.models.signals import post_migrate
from django.dispatch import receiver
class User(AbstractUser):
phone = models.CharField(max_length=20, blank=True)
department = models.CharField(max_length=100, blank=True)
position = models.CharField(max_length=100, blank=True)
is_active = models.BooleanField(default=True)
date_joined = models.DateTimeField(auto_now_add=True)
class Role(models.Model):
name = models.CharField(max_length=50, unique=True)
description = models.TextField(blank=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
def __str__(self):
return self.name
class UserRole(models.Model):
user = models.ForeignKey(User, on_delete=models.CASCADE)
role = models.ForeignKey(Role, on_delete=models.CASCADE)
assigned_at = models.DateTimeField(auto_now_add=True)
class Meta:
unique_together = ('user', 'role')
class Permission(models.Model):
name = models.CharField(max_length=100, unique=True)
codename = models.CharField(max_length=100, unique=True)
module = models.CharField(max_length=50) # e.g., 'inventory', 'sales'
description = models.TextField(blank=True)
class RolePermission(models.Model):
role = models.ForeignKey(Role, on_delete=models.CASCADE)
permission = models.ForeignKey(Permission, on_delete=models.CASCADE)
@receiver(post_migrate)
def create_default_permissions(sender, **kwargs):
"""Create default permissions for all modules"""
if sender.name == 'apps.accounts':
# Define default permissions for each module
default_permissions = [
# Dashboard permissions
{'name': 'View Dashboard', 'codename': 'view_dashboard', 'module': 'dashboard', 'description': 'Can view dashboard'},
# User management permissions
{'name': 'View User', 'codename': 'view_user', 'module': 'accounts', 'description': 'Can view user list and details'},
{'name': 'Add User', 'codename': 'add_user', 'module': 'accounts', 'description': 'Can create new users'},
{'name': 'Change User', 'codename': 'change_user', 'module': 'accounts', 'description': 'Can edit user information'},
{'name': 'Delete User', 'codename': 'delete_user', 'module': 'accounts', 'description': 'Can delete users'},
# Role management permissions
{'name': 'View Role', 'codename': 'view_role', 'module': 'accounts', 'description': 'Can view role list and details'},
{'name': 'Add Role', 'codename': 'add_role', 'module': 'accounts', 'description': 'Can create new roles'},
{'name': 'Change Role', 'codename': 'change_role', 'module': 'accounts', 'description': 'Can edit role information'},
{'name': 'Delete Role', 'codename': 'delete_role', 'module': 'accounts', 'description': 'Can delete roles'},
# Permission management permissions
{'name': 'View Permission', 'codename': 'view_permission', 'module': 'accounts', 'description': 'Can view permission list'},
{'name': 'Add Permission', 'codename': 'add_permission', 'module': 'accounts', 'description': 'Can create new permissions'},
{'name': 'Change Permission', 'codename': 'change_permission', 'module': 'accounts', 'description': 'Can edit permissions'},
{'name': 'Delete Permission', 'codename': 'delete_permission', 'module': 'accounts', 'description': 'Can delete permissions'},
# Inventory permissions
{'name': 'View Product', 'codename': 'view_product', 'module': 'inventory', 'description': 'Can view product list and details'},
{'name': 'Add Product', 'codename': 'add_product', 'module': 'inventory', 'description': 'Can create new products'},
{'name': 'Change Product', 'codename': 'change_product', 'module': 'inventory', 'description': 'Can edit product information'},
{'name': 'Delete Product', 'codename': 'delete_product', 'module': 'inventory', 'description': 'Can delete products'},
# Purchasing permissions
{'name': 'View Purchase Order', 'codename': 'view_purchaseorder', 'module': 'purchasing', 'description': 'Can view purchase orders'},
{'name': 'Add Purchase Order', 'codename': 'add_purchaseorder', 'module': 'purchasing', 'description': 'Can create purchase orders'},
{'name': 'Change Purchase Order', 'codename': 'change_purchaseorder', 'module': 'purchasing', 'description': 'Can edit purchase orders'},
{'name': 'Delete Purchase Order', 'codename': 'delete_purchaseorder', 'module': 'purchasing', 'description': 'Can delete purchase orders'},
# Sales permissions
{'name': 'View Sales Order', 'codename': 'view_salesorder', 'module': 'sales', 'description': 'Can view sales orders'},
{'name': 'Add Sales Order', 'codename': 'add_salesorder', 'module': 'sales', 'description': 'Can create sales orders'},
{'name': 'Change Sales Order', 'codename': 'change_salesorder', 'module': 'sales', 'description': 'Can edit sales orders'},
{'name': 'Delete Sales Order', 'codename': 'delete_salesorder', 'module': 'sales', 'description': 'Can delete sales orders'},
# Manufacturing permissions
{'name': 'View Manufacturing Order', 'codename': 'view_manufacturingorder', 'module': 'manufacturing', 'description': 'Can view manufacturing orders'},
{'name': 'Add Manufacturing Order', 'codename': 'add_manufacturingorder', 'module': 'manufacturing', 'description': 'Can create manufacturing orders'},
{'name': 'Change Manufacturing Order', 'codename': 'change_manufacturingorder', 'module': 'manufacturing', 'description': 'Can edit manufacturing orders'},
{'name': 'Delete Manufacturing Order', 'codename': 'delete_manufacturingorder', 'module': 'manufacturing', 'description': 'Can delete manufacturing orders'},
# Reports permissions
{'name': 'View Report', 'codename': 'view_report', 'module': 'reports', 'description': 'Can view reports'},
{'name': 'Add Report', 'codename': 'add_report', 'module': 'reports', 'description': 'Can create reports'},
{'name': 'Change Report', 'codename': 'change_report', 'module': 'reports', 'description': 'Can edit reports'},
{'name': 'Delete Report', 'codename': 'delete_report', 'module': 'reports', 'description': 'Can delete reports'},
# Database management permissions
{'name': 'View Database', 'codename': 'view_database', 'module': 'database_management', 'description': 'Can view database management'},
{'name': 'Add Database', 'codename': 'add_database', 'module': 'database_management', 'description': 'Can create database entries'},
{'name': 'Change Database', 'codename': 'change_database', 'module': 'database_management', 'description': 'Can edit database entries'},
{'name': 'Delete Database', 'codename': 'delete_database', 'module': 'database_management', 'description': 'Can delete database entries'},
]
# Create permissions if they don't exist
for perm_data in default_permissions:
Permission.objects.get_or_create(
codename=perm_data['codename'],
defaults=perm_data
)
class Meta:
unique_together = ('role', 'permission')

3
apps/accounts/tests.py Normal file
View File

@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

30
apps/accounts/urls.py Normal file
View File

@ -0,0 +1,30 @@
from django.urls import path
from . import views
app_name = 'accounts'
urlpatterns = [
# Authentication
path('login/', views.login_view, name='login'),
path('logout/', views.logout_view, name='logout'),
path('register/', views.register_view, name='register'),
path('profile/', views.profile_view, name='profile'),
path('profile/edit/', views.edit_profile_view, name='edit_profile'),
# User Management
path('users/', views.user_list_view, name='user_list'),
path('users/create/', views.create_user_view, name='create_user'),
path('users/<int:user_id>/', views.user_detail_view, name='user_detail'),
path('users/<int:user_id>/edit/', views.edit_user_view, name='edit_user'),
path('users/<int:user_id>/delete/', views.delete_user_view, name='delete_user'),
# Role Management
path('roles/', views.role_list_view, name='role_list'),
path('roles/create/', views.create_role_view, name='create_role'),
path('roles/<int:role_id>/', views.role_detail_view, name='role_detail'),
path('roles/<int:role_id>/edit/', views.edit_role_view, name='edit_role'),
path('roles/<int:role_id>/delete/', views.delete_role_view, name='delete_role'),
path('roles/<int:role_id>/permissions/', views.assign_permissions_view, name='assign_permissions'),
# Permission Management
path('permissions/', views.permission_list_view, name='permission_list'),
]

411
apps/accounts/views.py Normal file
View File

@ -0,0 +1,411 @@
from django.shortcuts import render, redirect
from django.contrib.auth import authenticate, login, logout
from django.contrib.auth.decorators import login_required, permission_required
from django.contrib import messages
from django.http import HttpResponse
from .models import User, Role, Permission, RolePermission, UserRole
def login_view(request):
"""User login view"""
if request.method == 'POST':
username = request.POST['username']
password = request.POST['password']
user = authenticate(request, username=username, password=password)
if user is not None:
login(request, user)
return redirect('inventory:dashboard')
else:
messages.error(request, 'Invalid username or password')
context = {
'module_title': 'Login',
}
return render(request, 'accounts/login.html', context)
def logout_view(request):
"""User logout view"""
logout(request)
messages.info(request, 'You have been logged out')
return redirect('accounts:login')
def register_view(request):
"""User registration view"""
# In a real app, this would contain registration logic
return HttpResponse("Registration page")
@login_required
def profile_view(request):
"""User profile view"""
context = {
'module_title': 'User Profile',
}
return render(request, 'accounts/profile.html', context)
@login_required
def edit_profile_view(request):
"""Edit user profile view"""
# In a real app, this would contain profile editing logic
return HttpResponse("Edit profile page")
# User Management Views
@login_required
@permission_required('accounts.view_user', raise_exception=True)
def user_list_view(request):
"""List all users"""
users = User.objects.all()
context = {
'module_title': 'User Management',
'users': users,
}
return render(request, 'accounts/user_list.html', context)
@login_required
@permission_required('accounts.add_user', raise_exception=True)
def create_user_view(request):
"""Create a new user"""
if request.method == 'POST':
from django.contrib import messages
username = request.POST.get('username')
first_name = request.POST.get('first_name')
last_name = request.POST.get('last_name')
email = request.POST.get('email')
password = request.POST.get('password')
phone = request.POST.get('phone')
department = request.POST.get('department')
position = request.POST.get('position')
is_active = request.POST.get('is_active') == 'on'
roles = request.POST.getlist('roles')
try:
# Create the user
user = User.objects.create_user(
username=username,
email=email,
password=password,
first_name=first_name,
last_name=last_name
)
user.phone = phone
user.department = department
user.position = position
user.is_active = is_active
user.save()
# Assign roles
for role_id in roles:
try:
role = Role.objects.get(id=role_id)
UserRole.objects.get_or_create(user=user, role=role)
except Role.DoesNotExist:
pass
messages.success(request, f'User {username} created successfully!')
return redirect('accounts:user_detail', user_id=user.id)
except Exception as e:
messages.error(request, f'Error creating user: {str(e)}')
# GET request - show form
roles = Role.objects.all()
context = {
'module_title': 'Create User',
'roles': roles,
}
return render(request, 'accounts/create_user.html', context)
@login_required
@permission_required('accounts.view_user', raise_exception=True)
def user_detail_view(request, user_id):
"""View user details"""
try:
user = User.objects.get(id=user_id)
context = {
'module_title': 'User Details',
'user': user,
}
return render(request, 'accounts/user_detail.html', context)
except User.DoesNotExist:
messages.error(request, 'User not found')
return redirect('accounts:user_list')
@login_required
@permission_required('accounts.change_user', raise_exception=True)
def edit_user_view(request, user_id):
"""Edit user details"""
try:
user = User.objects.get(id=user_id)
except User.DoesNotExist:
messages.error(request, 'User not found')
return redirect('accounts:user_list')
if request.method == 'POST':
from django.contrib import messages
# Update basic information
user.first_name = request.POST.get('first_name')
user.last_name = request.POST.get('last_name')
user.email = request.POST.get('email')
user.phone = request.POST.get('phone')
user.department = request.POST.get('department')
user.position = request.POST.get('position')
user.is_active = request.POST.get('is_active') == 'on'
# Update password if provided
password = request.POST.get('password')
if password:
user.set_password(password)
user.save()
# Update roles
user.userrole_set.all().delete() # Remove existing roles
roles = request.POST.getlist('roles')
for role_id in roles:
try:
role = Role.objects.get(id=role_id)
UserRole.objects.get_or_create(user=user, role=role)
except Role.DoesNotExist:
pass
messages.success(request, f'User {user.username} updated successfully!')
return redirect('accounts:user_detail', user_id=user.id)
# GET request - show form
roles = Role.objects.all()
user_roles = user.userrole_set.values_list('role_id', flat=True)
context = {
'module_title': 'Edit User',
'user': user,
'roles': roles,
'user_roles': user_roles,
}
return render(request, 'accounts/edit_user.html', context)
@login_required
@permission_required('accounts.delete_user', raise_exception=True)
def delete_user_view(request, user_id):
"""Delete a user"""
try:
user = User.objects.get(id=user_id)
except User.DoesNotExist:
messages.error(request, 'User not found')
return redirect('accounts:user_list')
if request.method == 'POST':
username = user.username
user.delete()
messages.success(request, f'User {username} deleted successfully!')
return redirect('accounts:user_list')
context = {
'module_title': 'Delete User',
'user': user,
}
return render(request, 'accounts/delete_user.html', context)
# Role Management Views
@login_required
@permission_required('accounts.view_role', raise_exception=True)
def role_list_view(request):
"""List all roles"""
roles = Role.objects.all()
context = {
'module_title': 'Role Management',
'roles': roles,
}
return render(request, 'accounts/role_list.html', context)
@login_required
@permission_required('accounts.add_role', raise_exception=True)
def create_role_view(request):
"""Create a new role"""
if request.method == 'POST':
name = request.POST.get('name')
description = request.POST.get('description')
permissions = request.POST.getlist('permissions')
try:
role = Role.objects.create(
name=name,
description=description
)
# Assign permissions
for perm_id in permissions:
try:
permission = Permission.objects.get(id=perm_id)
RolePermission.objects.get_or_create(role=role, permission=permission)
except Permission.DoesNotExist:
pass
messages.success(request, f'Role {name} created successfully!')
return redirect('accounts:role_detail', role_id=role.id)
except Exception as e:
messages.error(request, f'Error creating role: {str(e)}')
# GET request - show form
permissions = Permission.objects.all()
context = {
'module_title': 'Create Role',
'permissions': permissions,
}
return render(request, 'accounts/create_role.html', context)
@login_required
@permission_required('accounts.view_role', raise_exception=True)
def role_detail_view(request, role_id):
"""View role details"""
try:
role = Role.objects.get(id=role_id)
context = {
'module_title': 'Role Details',
'role': role,
}
return render(request, 'accounts/role_detail.html', context)
except Role.DoesNotExist:
messages.error(request, 'Role not found')
return redirect('accounts:role_list')
@login_required
@permission_required('accounts.change_role', raise_exception=True)
def edit_role_view(request, role_id):
"""Edit role details"""
try:
role = Role.objects.get(id=role_id)
except Role.DoesNotExist:
messages.error(request, 'Role not found')
return redirect('accounts:role_list')
if request.method == 'POST':
name = request.POST.get('name')
description = request.POST.get('description')
permissions = request.POST.getlist('permissions')
try:
role.name = name
role.description = description
role.save()
# Update permissions
role.rolepermission_set.all().delete() # Remove existing permissions
for perm_id in permissions:
try:
permission = Permission.objects.get(id=perm_id)
RolePermission.objects.get_or_create(role=role, permission=permission)
except Permission.DoesNotExist:
pass
messages.success(request, f'Role {name} updated successfully!')
return redirect('accounts:role_detail', role_id=role.id)
except Exception as e:
messages.error(request, f'Error updating role: {str(e)}')
# GET request - show form
permissions = Permission.objects.all()
role_permissions = role.rolepermission_set.values_list('permission_id', flat=True)
context = {
'module_title': 'Edit Role',
'role': role,
'permissions': permissions,
'role_permissions': role_permissions,
}
return render(request, 'accounts/edit_role.html', context)
@login_required
@permission_required('accounts.delete_role', raise_exception=True)
def delete_role_view(request, role_id):
"""Delete a role"""
try:
role = Role.objects.get(id=role_id)
except Role.DoesNotExist:
messages.error(request, 'Role not found')
return redirect('accounts:role_list')
if request.method == 'POST':
role_name = role.name
role.delete()
messages.success(request, f'Role {role_name} deleted successfully!')
return redirect('accounts:role_list')
context = {
'module_title': 'Delete Role',
'role': role,
}
return render(request, 'accounts/delete_role.html', context)
# Permission Management Views
@login_required
@permission_required('accounts.view_permission', raise_exception=True)
def permission_list_view(request):
"""List all permissions"""
permissions = Permission.objects.all()
context = {
'module_title': 'Permission Management',
'permissions': permissions,
}
return render(request, 'accounts/permission_list.html', context)
@login_required
@permission_required('accounts.change_role', raise_exception=True)
def assign_permissions_view(request, role_id):
"""Assign permissions to a role"""
try:
role = Role.objects.get(id=role_id)
except Role.DoesNotExist:
messages.error(request, 'Role not found')
return redirect('accounts:role_list')
if request.method == 'POST':
permissions = request.POST.getlist('permissions')
try:
# Remove existing permissions
role.rolepermission_set.all().delete()
# Add new permissions
for perm_id in permissions:
try:
permission = Permission.objects.get(id=perm_id)
RolePermission.objects.get_or_create(role=role, permission=permission)
except Permission.DoesNotExist:
pass
messages.success(request, f'Permissions updated for role {role.name}!')
return redirect('accounts:role_detail', role_id=role.id)
except Exception as e:
messages.error(request, f'Error updating permissions: {str(e)}')
# GET request - show form
permissions = Permission.objects.all()
role_permissions = role.rolepermission_set.values_list('permission_id', flat=True)
context = {
'module_title': f'Assign Permissions to {role.name}',
'role': role,
'permissions': permissions,
'role_permissions': role_permissions,
}
return render(request, 'accounts/assign_permissions.html', context)

View File

View File

@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.

View File

@ -0,0 +1,6 @@
from django.apps import AppConfig
class DatabaseManagementConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.database_management"

View File

@ -0,0 +1,56 @@
# Generated by Django 4.2.23 on 2025-08-21 02:29
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name="DatabaseBackup",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("filename", models.CharField(max_length=25)),
("file_path", models.CharField(max_length=500)),
("size", models.BigIntegerField()),
(
"status",
models.CharField(
choices=[
("pending", "Pending"),
("in_progress", "In Progress"),
("completed", "Completed"),
("failed", "Failed"),
],
max_length=20,
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("completed_at", models.DateTimeField(blank=True, null=True)),
(
"created_by",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
to=settings.AUTH_USER_MODEL,
),
),
],
),
]

View File

@ -0,0 +1,21 @@
from django.db import models
class DatabaseBackup(models.Model):
BACKUP_STATUS = [
('pending', 'Pending'),
('in_progress', 'In Progress'),
('completed', 'Completed'),
('failed', 'Failed'),
]
filename = models.CharField(max_length=25)
file_path = models.CharField(max_length=500)
size = models.BigIntegerField()
status = models.CharField(max_length=20, choices=BACKUP_STATUS)
created_by = models.ForeignKey('accounts.User', on_delete=models.SET_NULL, null=True)
created_at = models.DateTimeField(auto_now_add=True)
completed_at = models.DateTimeField(null=True, blank=True)
def __str__(self):
return self.filename

View File

@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

View File

@ -0,0 +1,13 @@
from django.urls import path
from . import views
app_name = 'database_management'
urlpatterns = [
path('', views.db_dashboard, name='dashboard'),
path('backup/', views.backup_view, name='backup'),
path('restore/', views.restore_view, name='restore'),
path('initialize/', views.initialize_view, name='initialize'),
path('backups/', views.backup_list_view, name='backup_list'),
path('backups/<int:backup_id>/download/', views.download_backup_view, name='download_backup'),
path('backups/<int:backup_id>/delete/', views.delete_backup_view, name='delete_backup'),
]

View File

@ -0,0 +1,267 @@
import os
import subprocess
import tempfile
from datetime import datetime
from django.shortcuts import render, get_object_or_404, redirect
from django.contrib.auth.decorators import login_required, permission_required
from django.http import HttpResponse, Http404, JsonResponse
from django.contrib import messages
from django.conf import settings
from django.core.files.storage import default_storage
from django.core.files.base import ContentFile
from .models import DatabaseBackup
@login_required
@permission_required('database_management.view_database', raise_exception=True)
def db_dashboard(request):
"""Database management dashboard view"""
# Get database statistics
try:
import sqlite3
db_path = settings.DATABASES['default']['NAME']
if os.path.exists(db_path):
db_size = os.path.getsize(db_path)
db_size_mb = db_size / (1024 * 1024)
# Get table count
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
cursor.execute("SELECT name FROM sqlite_master WHERE type='table';")
tables = cursor.fetchall()
table_count = len(tables)
conn.close()
else:
db_size_mb = 0
table_count = 0
# Get last backup info
last_backup = DatabaseBackup.objects.filter(status='completed').order_by('-created_at').first()
last_backup_info = last_backup.completed_at.strftime('%Y-%m-%d %I:%M %p') if last_backup else 'No backups yet'
except Exception as e:
db_size_mb = 0
table_count = 0
last_backup_info = 'Unable to retrieve'
context = {
'module_title': 'Database Management',
'db_size': f"{db_size_mb:.1f} MB",
'table_count': f"{table_count} tables",
'last_backup': last_backup_info,
}
return render(request, 'database_management/dashboard.html', context)
@login_required
@permission_required('database_management.add_database', raise_exception=True)
def backup_view(request):
"""Create a database backup"""
if request.method == 'POST':
try:
# Generate backup filename
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
filename = f"backup_{timestamp}.db"
# Database path
db_path = settings.DATABASES['default']['NAME']
# Create backup directory if it doesn't exist
backup_dir = os.path.join(settings.MEDIA_ROOT, 'database_backups')
os.makedirs(backup_dir, exist_ok=True)
backup_path = os.path.join(backup_dir, filename)
# Copy database file
import shutil
shutil.copy2(db_path, backup_path)
# Get file size
file_size = os.path.getsize(backup_path)
# Save backup record
backup = DatabaseBackup.objects.create(
filename=filename,
file_path=backup_path,
size=file_size,
status='completed',
created_by=request.user,
completed_at=datetime.now()
)
messages.success(request, f'Database backup created successfully: {filename}')
return redirect('database_management:backup_list')
except Exception as e:
messages.error(request, f'Error creating backup: {str(e)}')
return redirect('database_management:db_dashboard')
context = {
'module_title': 'Create Database Backup',
}
return render(request, 'database_management/backup.html', context)
@login_required
@permission_required('database_management.change_database', raise_exception=True)
def restore_view(request):
"""Restore a database backup"""
if request.method == 'POST' and request.FILES.get('backup_file'):
try:
backup_file = request.FILES['backup_file']
# Validate file extension
if not backup_file.name.endswith(('.db', '.sqlite3')):
messages.error(request, 'Invalid file type. Please upload a SQLite database file.')
return redirect('database_management:db_dashboard')
# Database path
db_path = settings.DATABASES['default']['NAME']
# Create backup of current database before restore
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
backup_filename = f"pre_restore_backup_{timestamp}.db"
backup_dir = os.path.join(settings.MEDIA_ROOT, 'database_backups')
os.makedirs(backup_dir, exist_ok=True)
pre_restore_path = os.path.join(backup_dir, backup_filename)
import shutil
if os.path.exists(db_path):
shutil.copy2(db_path, pre_restore_path)
# Save pre-restore backup record
pre_backup_size = os.path.getsize(pre_restore_path)
DatabaseBackup.objects.create(
filename=backup_filename,
file_path=pre_restore_path,
size=pre_backup_size,
status='completed',
created_by=request.user,
completed_at=datetime.now()
)
# Save uploaded file temporarily
temp_dir = tempfile.mkdtemp()
temp_path = os.path.join(temp_dir, 'uploaded_backup.db')
with open(temp_path, 'wb+') as destination:
for chunk in backup_file.chunks():
destination.write(chunk)
# Validate uploaded file is a valid SQLite database
import sqlite3
try:
conn = sqlite3.connect(temp_path)
conn.close()
except sqlite3.Error:
messages.error(request, 'Invalid database file. Please upload a valid SQLite database.')
os.remove(temp_path)
os.rmdir(temp_dir)
return redirect('database_management:db_dashboard')
# Replace current database with uploaded backup
shutil.move(temp_path, db_path)
os.rmdir(temp_dir)
messages.success(request, 'Database restored successfully from backup file.')
messages.info(request, f'A pre-restore backup was created: {backup_filename}')
return redirect('database_management:db_dashboard')
except Exception as e:
messages.error(request, f'Error restoring database: {str(e)}')
return redirect('database_management:db_dashboard')
context = {
'module_title': 'Restore Database',
}
return render(request, 'database_management/restore.html', context)
@login_required
@permission_required('database_management.add_database', raise_exception=True)
def initialize_view(request):
"""Initialize the database"""
if request.method == 'POST':
try:
# This would typically run migrations or initialization scripts
# For now, just show a message
messages.success(request, 'Database initialization completed successfully.')
return redirect('database_management:db_dashboard')
except Exception as e:
messages.error(request, f'Error initializing database: {str(e)}')
return redirect('database_management:db_dashboard')
context = {
'module_title': 'Initialize Database',
}
return render(request, 'database_management/initialize.html', context)
@login_required
@permission_required('database_management.view_database', raise_exception=True)
def backup_list_view(request):
"""List all database backups"""
backups = DatabaseBackup.objects.all().order_by('-created_at')
context = {
'module_title': 'Database Backup List',
'backups': backups,
}
return render(request, 'database_management/backup_list.html', context)
@login_required
@permission_required('database_management.view_database', raise_exception=True)
def download_backup_view(request, backup_id):
"""Download a database backup"""
try:
backup = get_object_or_404(DatabaseBackup, id=backup_id)
if not os.path.exists(backup.file_path):
messages.error(request, 'Backup file not found on disk.')
return redirect('database_management:backup_list')
# Read file content
with open(backup.file_path, 'rb') as f:
file_data = f.read()
# Return file as response
response = HttpResponse(file_data, content_type='application/octet-stream')
response['Content-Disposition'] = f'attachment; filename="{backup.filename}"'
response['Content-Length'] = len(file_data)
return response
except Exception as e:
messages.error(request, f'Error downloading backup: {str(e)}')
return redirect('database_management:backup_list')
@login_required
@permission_required('database_management.delete_database', raise_exception=True)
def delete_backup_view(request, backup_id):
"""Delete a database backup"""
try:
backup = get_object_or_404(DatabaseBackup, id=backup_id)
if request.method == 'POST':
# Delete file from disk if it exists
if os.path.exists(backup.file_path):
os.remove(backup.file_path)
# Delete database record
backup.delete()
messages.success(request, f'Backup {backup.filename} deleted successfully.')
return redirect('database_management:backup_list')
context = {
'module_title': 'Delete Backup',
'backup': backup,
}
return render(request, 'database_management/delete_backup.html', context)
except Exception as e:
messages.error(request, f'Error deleting backup: {str(e)}')
return redirect('database_management:backup_list')

View File

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

3
apps/inventory/admin.py Normal file
View File

@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.

6
apps/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 = "apps.inventory"

View File

@ -0,0 +1,225 @@
# Generated by Django 4.2.23 on 2025-08-21 02:29
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name="Category",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("name", models.CharField(max_length=100)),
("description", models.TextField(blank=True)),
("created_at", models.DateTimeField(auto_now_add=True)),
],
),
migrations.CreateModel(
name="Product",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("code", models.CharField(max_length=50, unique=True)),
("name", models.CharField(max_length=200)),
("description", models.TextField(blank=True)),
(
"product_type",
models.CharField(
choices=[
("raw_material", "Raw Material"),
("finished_good", "Finished Good"),
("component", "Component"),
],
max_length=20,
),
),
(
"reorder_level",
models.DecimalField(decimal_places=2, default=0, max_digits=10),
),
(
"selling_price",
models.DecimalField(
blank=True, decimal_places=2, max_digits=10, null=True
),
),
(
"cost_price",
models.DecimalField(
blank=True, decimal_places=2, max_digits=10, null=True
),
),
("is_active", models.BooleanField(default=True)),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
(
"category",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
to="inventory.category",
),
),
],
),
migrations.CreateModel(
name="UnitOfMeasure",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("name", models.CharField(max_length=50)),
("abbreviation", models.CharField(max_length=10)),
],
),
migrations.CreateModel(
name="Warehouse",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("name", models.CharField(max_length=100)),
("location", models.CharField(max_length=200)),
("is_active", models.BooleanField(default=True)),
("created_at", models.DateTimeField(auto_now_add=True)),
],
),
migrations.CreateModel(
name="StockMovement",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"movement_type",
models.CharField(
choices=[
("in", "Stock In"),
("out", "Stock Out"),
("adjustment", "Adjustment"),
("transfer", "Transfer"),
],
max_length=20,
),
),
("quantity", models.DecimalField(decimal_places=2, max_digits=10)),
("reference_number", models.CharField(blank=True, max_length=100)),
("notes", models.TextField(blank=True)),
("created_at", models.DateTimeField(auto_now_add=True)),
(
"created_by",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
to=settings.AUTH_USER_MODEL,
),
),
(
"product",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to="inventory.product",
),
),
(
"warehouse",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to="inventory.warehouse",
),
),
],
),
migrations.AddField(
model_name="product",
name="unit_of_measure",
field=models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
to="inventory.unitofmeasure",
),
),
migrations.CreateModel(
name="Inventory",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"quantity",
models.DecimalField(decimal_places=2, default=0, max_digits=10),
),
(
"reserved_quantity",
models.DecimalField(decimal_places=2, default=0, max_digits=10),
),
("updated_at", models.DateTimeField(auto_now=True)),
(
"product",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to="inventory.product",
),
),
(
"warehouse",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to="inventory.warehouse",
),
),
],
options={
"unique_together": {("product", "warehouse")},
},
),
]

View File

86
apps/inventory/models.py Normal file
View File

@ -0,0 +1,86 @@
from django.db import models
from django.core.validators import MinValueValidator
class Category(models.Model):
name = models.CharField(max_length=100)
description = models.TextField(blank=True)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
return self.name
class UnitOfMeasure(models.Model):
name = models.CharField(max_length=50)
abbreviation = models.CharField(max_length=10)
def __str__(self):
return self.name
class Product(models.Model):
PRODUCT_TYPES = [
('raw_material', 'Raw Material'),
('finished_good', 'Finished Good'),
('component', 'Component'),
]
code = models.CharField(max_length=50, unique=True)
name = models.CharField(max_length=200)
description = models.TextField(blank=True)
category = models.ForeignKey(Category, on_delete=models.SET_NULL, null=True, blank=True)
unit_of_measure = models.ForeignKey(UnitOfMeasure, on_delete=models.SET_NULL, null=True)
product_type = models.CharField(max_length=20, choices=PRODUCT_TYPES)
reorder_level = models.DecimalField(max_digits=10, decimal_places=2, default=0)
selling_price = models.DecimalField(max_digits=10, decimal_places=2, null=True, blank=True)
cost_price = models.DecimalField(max_digits=10, decimal_places=2, null=True, blank=True)
is_active = models.BooleanField(default=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
def __str__(self):
return f"{self.code} - {self.name}"
class Warehouse(models.Model):
name = models.CharField(max_length=100)
location = models.CharField(max_length=200)
is_active = models.BooleanField(default=True)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
return self.name
class Inventory(models.Model):
product = models.ForeignKey(Product, on_delete=models.CASCADE)
warehouse = models.ForeignKey(Warehouse, on_delete=models.CASCADE)
quantity = models.DecimalField(max_digits=10, decimal_places=2, default=0)
reserved_quantity = models.DecimalField(max_digits=10, decimal_places=2, default=0)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
unique_together = ('product', 'warehouse')
@property
def available_quantity(self):
return self.quantity - self.reserved_quantity
class StockMovement(models.Model):
MOVEMENT_TYPES = [
('in', 'Stock In'),
('out', 'Stock Out'),
('adjustment', 'Adjustment'),
('transfer', 'Transfer'),
]
product = models.ForeignKey(Product, on_delete=models.CASCADE)
warehouse = models.ForeignKey(Warehouse, on_delete=models.CASCADE)
movement_type = models.CharField(max_length=20, choices=MOVEMENT_TYPES)
quantity = models.DecimalField(max_digits=10, decimal_places=2)
reference_number = models.CharField(max_length=100, blank=True)
notes = models.TextField(blank=True)
created_by = models.ForeignKey('accounts.User', on_delete=models.SET_NULL, null=True)
created_at = models.DateTimeField(auto_now_add=True)

View File

View File

@ -0,0 +1,36 @@
from django import template
from decimal import Decimal
import re
register = template.Library()
@register.filter
def indonesian_number(value, decimal_places=2):
"""
Format numbers using Indonesian locale (dot as thousand separator, comma as decimal separator)
"""
if value is None or value == '':
return '0,00'
try:
# Convert to Decimal for precise handling
if not isinstance(value, Decimal):
value = Decimal(str(value))
# Format the number with thousand separators using English locale first
formatted = f"{value:,.{decimal_places}f}"
# Convert to Indonesian format: comma to dot, dot to comma
formatted = formatted.replace(',', 'temp_comma').replace('.', ',').replace('temp_comma', '.')
return formatted
except (ValueError, TypeError):
return '0,00'
@register.filter
def format_rupiah(value, decimal_places=2):
"""
Format numbers as Indonesian Rupiah currency
"""
formatted_number = indonesian_number(value, decimal_places)
return f"Rp {formatted_number}"

3
apps/inventory/tests.py Normal file
View File

@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

44
apps/inventory/urls.py Normal file
View File

@ -0,0 +1,44 @@
from django.urls import path
from . import views
app_name = 'inventory'
urlpatterns = [
# Dashboard
path('', views.inventory_dashboard, name='dashboard'),
# Products
path('products/', views.product_list_view, name='product_list'),
path('products/create/', views.create_product_view, name='create_product'),
path('products/<int:product_id>/', views.product_detail_view, name='product_detail'),
path('products/<int:product_id>/edit/', views.edit_product_view, name='edit_product'),
path('products/<int:product_id>/delete/', views.delete_product_view, name='delete_product'),
# Categories
path('categories/', views.category_list_view, name='category_list'),
path('categories/create/', views.create_category_view, name='create_category'),
path('categories/<int:category_id>/edit/', views.edit_category_view, name='edit_category'),
path('categories/<int:category_id>/delete/', views.delete_category_view, name='delete_category'),
# Units of Measure
path('uom/', views.uom_list_view, name='uom_list'),
path('uom/create/', views.create_uom_view, name='create_uom'),
path('uom/<int:uom_id>/edit/', views.edit_uom_view, name='edit_uom'),
path('uom/<int:uom_id>/delete/', views.delete_uom_view, name='delete_uom'),
# Warehouses
path('warehouses/', views.warehouse_list_view, name='warehouse_list'),
path('warehouses/create/', views.create_warehouse_view, name='create_warehouse'),
path('warehouses/<int:warehouse_id>/', views.warehouse_detail_view, name='warehouse_detail'),
path('warehouses/<int:warehouse_id>/edit/', views.edit_warehouse_view, name='edit_warehouse'),
path('warehouses/<int:warehouse_id>/delete/', views.delete_warehouse_view, name='delete_warehouse'),
# Stock Movements
path('movements/', views.stock_movement_list_view, name='movement_list'),
path('movements/in/', views.stock_in_view, name='stock_in'),
path('movements/out/', views.stock_out_view, name='stock_out'),
path('movements/adjustment/', views.stock_adjustment_view, name='stock_adjustment'),
path('movements/transfer/', views.stock_transfer_view, name='stock_transfer'),
# API endpoints
path('api/products/', views.product_api_view, name='product_api'),
]

694
apps/inventory/views.py Normal file
View File

@ -0,0 +1,694 @@
from django.shortcuts import render, redirect, get_object_or_404
from django.contrib.auth.decorators import login_required, permission_required
from django.contrib import messages
from django.http import HttpResponse, JsonResponse
from django.db.models import Sum, Q, Count
from django import forms
from django.utils import timezone
from .models import Product, Category, UnitOfMeasure, Warehouse, StockMovement, Inventory
import locale
from decimal import Decimal, InvalidOperation
class IndonesianNumberInput(forms.NumberInput):
"""Custom widget for Indonesian number input formatting"""
def __init__(self, attrs=None):
if attrs is None:
attrs = {}
# Set step to 0.01 for decimal fields
if 'step' not in attrs:
attrs['step'] = '0.01'
# Add placeholder for better UX
if 'placeholder' not in attrs:
attrs['placeholder'] = '0.00'
super().__init__(attrs)
def format_value(self, value):
"""Format the value for display in the input field"""
if value is None or value == '':
return None
try:
# Convert to Decimal for precise handling
if not isinstance(value, Decimal):
value = Decimal(str(value))
# Format with Indonesian decimal separator (comma)
formatted = '{:.2f}'.format(value)
return formatted.replace('.', ',')
except (ValueError, TypeError):
return super().format_value(value)
def value_from_datadict(self, data, files, name):
"""Handle value from form data"""
value = super().value_from_datadict(data, files, name)
if value is None or value == '':
return value
try:
# Clean up any formatting and convert to Decimal
if isinstance(value, str):
# Remove any thousand separators and convert comma to dot
value = value.replace('.', '').replace(',', '.')
return Decimal(value)
except (ValueError, TypeError, InvalidOperation):
return value
class ProductForm(forms.ModelForm):
class Meta:
model = Product
fields = ['code', 'name', 'description', 'category', 'unit_of_measure',
'product_type', 'reorder_level', 'selling_price', 'cost_price', 'is_active']
widgets = {
'description': forms.Textarea(attrs={'rows': 3}),
'reorder_level': IndonesianNumberInput(attrs={'min': 0}),
'selling_price': IndonesianNumberInput(attrs={'min': 0, 'step': '0.01'}),
'cost_price': IndonesianNumberInput(attrs={'min': 0, 'step': '0.01'}),
}
class CategoryForm(forms.ModelForm):
class Meta:
model = Category
fields = ['name', 'description']
widgets = {
'description': forms.Textarea(attrs={'rows': 3}),
}
class UnitOfMeasureForm(forms.ModelForm):
class Meta:
model = UnitOfMeasure
fields = ['name', 'abbreviation']
class WarehouseForm(forms.ModelForm):
class Meta:
model = Warehouse
fields = ['name', 'location', 'is_active']
@login_required
def inventory_dashboard(request):
"""Inventory dashboard view"""
products = Product.objects.filter(is_active=True)
warehouses = Warehouse.objects.filter(is_active=True)
categories = Category.objects.all()
# Get low stock products (below reorder level)
low_stock_products = []
for product in products:
total_quantity = Inventory.objects.filter(
product=product
).aggregate(total=Sum('quantity'))['total'] or 0
if total_quantity <= product.reorder_level:
low_stock_products.append({
'name': product.name,
'total_quantity': total_quantity,
'reorder_level': product.reorder_level,
})
# Get recent stock movements
recent_movements = StockMovement.objects.select_related(
'product', 'warehouse'
).order_by('-created_at')[:10]
context = {
'module_title': 'Inventory Management',
'products': products,
'warehouses': warehouses,
'categories': categories,
'low_stock_products': low_stock_products,
'recent_movements': recent_movements,
}
return render(request, 'inventory/dashboard.html', context)
# Product Views
@login_required
@permission_required('inventory.view_product', raise_exception=True)
def product_list_view(request):
"""List all products"""
products = Product.objects.all()
# Add current stock information to each product
for product in products:
total_stock = Inventory.objects.filter(product=product).aggregate(
total=Sum('quantity')
)['total'] or 0
product.current_stock = total_stock
context = {
'module_title': 'Product List',
'products': products,
}
return render(request, 'inventory/product_list.html', context)
@login_required
@permission_required('inventory.add_product', raise_exception=True)
def create_product_view(request):
"""Create a new product"""
if request.method == 'POST':
form = ProductForm(request.POST)
if form.is_valid():
product = form.save()
messages.success(request, f'Product "{product.name}" created successfully!')
return redirect('inventory:product_detail', product_id=product.id)
else:
form = ProductForm()
context = {
'form': form,
'module_title': 'Create Product',
'is_create': True,
}
return render(request, 'inventory/product_form.html', context)
@login_required
@permission_required('inventory.view_product', raise_exception=True)
def product_detail_view(request, product_id):
"""View product details"""
try:
product = Product.objects.get(id=product_id)
inventory_items = Inventory.objects.filter(product=product).select_related('warehouse')
total_stock = inventory_items.aggregate(total=Sum('quantity'))['total'] or 0
recent_movements = StockMovement.objects.filter(
product=product
).select_related('warehouse').order_by('-created_at')[:10]
context = {
'module_title': 'Product Details',
'product': product,
'inventory_items': inventory_items,
'total_stock': total_stock,
'recent_movements': recent_movements,
}
return render(request, 'inventory/product_detail.html', context)
except Product.DoesNotExist:
messages.error(request, 'Product not found')
return redirect('inventory:product_list')
@login_required
@permission_required('inventory.change_product', raise_exception=True)
def edit_product_view(request, product_id):
"""Edit product details"""
product = get_object_or_404(Product, id=product_id)
if request.method == 'POST':
form = ProductForm(request.POST, instance=product)
if form.is_valid():
product = form.save()
messages.success(request, f'Product "{product.name}" updated successfully!')
return redirect('inventory:product_detail', product_id=product.id)
else:
form = ProductForm(instance=product)
context = {
'form': form,
'product': product,
'module_title': 'Edit Product',
'is_create': False,
}
return render(request, 'inventory/product_form.html', context)
@login_required
def delete_product_view(request, product_id):
"""Delete a product"""
product = get_object_or_404(Product, id=product_id)
if request.method == 'POST':
# Simple delete without permission checks for now
try:
product_name = product.name
product.delete()
messages.success(request, f'Product "{product_name}" deleted successfully!')
return redirect('inventory:product_list')
except Exception as e:
messages.error(request, f'Error deleting product: {str(e)}')
return redirect('inventory:product_detail', product_id=product.id)
# Get product statistics for confirmation
inventory_count = Inventory.objects.filter(product=product).count()
movement_count = StockMovement.objects.filter(product=product).count()
total_stock = Inventory.objects.filter(product=product).aggregate(
total=Sum('quantity')
)['total'] or 0
context = {
'product': product,
'module_title': 'Delete Product',
'inventory_count': inventory_count,
'movement_count': movement_count,
'total_stock': total_stock,
}
return render(request, 'inventory/product_confirm_delete.html', context)
# Category Views
@login_required
@permission_required('inventory.view_category', raise_exception=True)
def category_list_view(request):
"""List all categories"""
categories = Category.objects.all()
context = {
'module_title': 'Category List',
'categories': categories,
}
return render(request, 'inventory/category_list.html', context)
@login_required
@permission_required('inventory.add_category', raise_exception=True)
def create_category_view(request):
"""Create a new category"""
if request.method == 'POST':
form = CategoryForm(request.POST)
if form.is_valid():
category = form.save()
messages.success(request, f'Category "{category.name}" created successfully!')
return redirect('inventory:category_list')
else:
form = CategoryForm()
context = {
'form': form,
'module_title': 'Create Category',
}
return render(request, 'inventory/category_form.html', context)
@login_required
@permission_required('inventory.change_category', raise_exception=True)
def edit_category_view(request, category_id):
"""Edit category details"""
# In a real app, this would contain category editing logic
return HttpResponse("Edit category page")
@login_required
@permission_required('inventory.delete_category', raise_exception=True)
def delete_category_view(request, category_id):
"""Delete a category"""
# In a real app, this would contain category deletion logic
return HttpResponse("Delete category page")
# Unit of Measure Views
@login_required
@permission_required('inventory.view_unitofmeasure', raise_exception=True)
def uom_list_view(request):
"""List all units of measure"""
uoms = UnitOfMeasure.objects.all()
context = {
'module_title': 'Unit of Measure List',
'uoms': uoms,
}
return render(request, 'inventory/uom_list.html', context)
@login_required
@permission_required('inventory.add_unitofmeasure', raise_exception=True)
def create_uom_view(request):
"""Create a new unit of measure"""
# In a real app, this would contain UOM creation logic
return HttpResponse("Create unit of measure page")
@login_required
@permission_required('inventory.change_unitofmeasure', raise_exception=True)
def edit_uom_view(request, uom_id):
"""Edit unit of measure details"""
# In a real app, this would contain UOM editing logic
return HttpResponse("Edit unit of measure page")
@login_required
@permission_required('inventory.delete_unitofmeasure', raise_exception=True)
def delete_uom_view(request, uom_id):
"""Delete a unit of measure"""
# In a real app, this would contain UOM deletion logic
return HttpResponse("Delete unit of measure page")
# Warehouse Views
@login_required
@permission_required('inventory.view_warehouse', raise_exception=True)
def warehouse_list_view(request):
"""List all warehouses"""
warehouses = Warehouse.objects.prefetch_related('inventory_set').all()
# Add inventory count to each warehouse
for warehouse in warehouses:
warehouse.inventory_count = warehouse.inventory_set.count()
context = {
'module_title': 'Warehouse List',
'warehouses': warehouses,
}
return render(request, 'inventory/warehouse_list.html', context)
@login_required
@permission_required('inventory.view_warehouse', raise_exception=True)
def warehouse_detail_view(request, warehouse_id):
"""View warehouse details"""
try:
warehouse = Warehouse.objects.get(id=warehouse_id)
inventory_items = Inventory.objects.filter(warehouse=warehouse).select_related('product')
total_products = inventory_items.count()
total_value = sum(item.quantity * item.product.cost_price for item in inventory_items)
# Add total_value to each inventory item for template use
for item in inventory_items:
item.total_value = item.quantity * item.product.cost_price
context = {
'module_title': 'Warehouse Details',
'warehouse': warehouse,
'inventory_items': inventory_items,
'total_products': total_products,
'total_value': total_value,
}
return render(request, 'inventory/warehouse_detail.html', context)
except Warehouse.DoesNotExist:
messages.error(request, 'Warehouse not found')
return redirect('inventory:warehouse_list')
@login_required
def create_warehouse_view(request):
"""Create a new warehouse"""
if request.method == 'POST':
form = WarehouseForm(request.POST)
if form.is_valid():
warehouse = form.save()
messages.success(request, f'Warehouse "{warehouse.name}" created successfully!')
return redirect('inventory:warehouse_list')
else:
form = WarehouseForm()
context = {
'form': form,
'module_title': 'Create Warehouse',
'is_create': True,
}
return render(request, 'inventory/warehouse_form.html', context)
@login_required
def edit_warehouse_view(request, warehouse_id):
"""Edit warehouse details"""
warehouse = get_object_or_404(Warehouse, id=warehouse_id)
if request.method == 'POST':
form = WarehouseForm(request.POST, instance=warehouse)
if form.is_valid():
warehouse = form.save()
messages.success(request, f'Warehouse "{warehouse.name}" updated successfully!')
return redirect('inventory:warehouse_list')
else:
form = WarehouseForm(instance=warehouse)
context = {
'form': form,
'warehouse': warehouse,
'module_title': 'Edit Warehouse',
'is_create': False,
}
return render(request, 'inventory/warehouse_form.html', context)
@login_required
def delete_warehouse_view(request, warehouse_id):
"""Delete a warehouse"""
warehouse = get_object_or_404(Warehouse, id=warehouse_id)
if request.method == 'POST':
warehouse_name = warehouse.name
warehouse.delete()
messages.success(request, f'Warehouse "{warehouse_name}" deleted successfully!')
return redirect('inventory:warehouse_list')
# Get warehouse statistics for confirmation
inventory_items = Inventory.objects.filter(warehouse=warehouse).select_related('product')
inventory_count = inventory_items.count()
total_products = inventory_items.values('product').distinct().count()
total_value = sum(item.quantity * item.product.cost_price for item in inventory_items)
context = {
'warehouse': warehouse,
'module_title': 'Delete Warehouse',
'inventory_count': inventory_count,
'total_products': total_products,
'total_value': total_value,
}
return render(request, 'inventory/warehouse_confirm_delete.html', context)
# Stock Movement Views
@login_required
@permission_required('inventory.view_stockmovement', raise_exception=True)
def stock_movement_list_view(request):
"""List all stock movements"""
movements = StockMovement.objects.all()
context = {
'module_title': 'Stock Movement List',
'movements': movements,
}
return render(request, 'inventory/movement_list.html', context)
@login_required
@permission_required('inventory.add_stockmovement', raise_exception=True)
def stock_in_view(request):
"""Add stock to inventory"""
if request.method == 'POST':
product_id = request.POST.get('product')
warehouse_id = request.POST.get('warehouse')
quantity_str = request.POST.get('quantity', '0').replace(',', '.')
try:
product = Product.objects.get(id=product_id)
warehouse = Warehouse.objects.get(id=warehouse_id)
quantity = Decimal(quantity_str)
if quantity <= 0:
messages.error(request, 'Quantity must be greater than 0')
return redirect('inventory:stock_in')
# Create or update inventory
inventory, created = Inventory.objects.get_or_create(
product=product,
warehouse=warehouse,
defaults={'quantity': 0}
)
inventory.quantity += quantity
inventory.save()
# Create stock movement record
StockMovement.objects.create(
product=product,
warehouse=warehouse,
movement_type='in',
quantity=quantity,
reference_number=f"STKIN-{timezone.now().strftime('%Y%m%d-%H%M%S')}",
notes=request.POST.get('notes', ''),
created_by=request.user
)
messages.success(request, f'Successfully added {quantity} {product.unit_of_measure.abbreviation} of {product.name} to {warehouse.name}')
return redirect('inventory:movement_list')
except (Product.DoesNotExist, Warehouse.DoesNotExist):
messages.error(request, 'Invalid product or warehouse selected')
except (ValueError, InvalidOperation):
messages.error(request, 'Invalid quantity entered')
# GET request - show form
context = {
'module_title': 'Stock In',
'products': Product.objects.filter(is_active=True),
'warehouses': Warehouse.objects.filter(is_active=True),
}
return render(request, 'inventory/stock_in.html', context)
@login_required
@permission_required('inventory.add_stockmovement', raise_exception=True)
def stock_out_view(request):
"""Remove stock from inventory"""
if request.method == 'POST':
product_id = request.POST.get('product')
warehouse_id = request.POST.get('warehouse')
quantity_str = request.POST.get('quantity', '0').replace(',', '.')
try:
product = Product.objects.get(id=product_id)
warehouse = Warehouse.objects.get(id=warehouse_id)
quantity = Decimal(quantity_str)
if quantity <= 0:
messages.error(request, 'Quantity must be greater than 0')
return redirect('inventory:stock_out')
# Check if sufficient stock is available
inventory = Inventory.objects.filter(
product=product, warehouse=warehouse
).first()
if not inventory or inventory.quantity < quantity:
messages.error(request, f'Insufficient stock. Available: {inventory.quantity if inventory else 0} {product.unit_of_measure.abbreviation}')
return redirect('inventory:stock_out')
# Update inventory
inventory.quantity -= quantity
inventory.save()
# Create stock movement record
StockMovement.objects.create(
product=product,
warehouse=warehouse,
movement_type='out',
quantity=quantity,
reference_number=f"STKOUT-{timezone.now().strftime('%Y%m%d-%H%M%S')}",
notes=request.POST.get('notes', ''),
created_by=request.user
)
messages.success(request, f'Successfully removed {quantity} {product.unit_of_measure.abbreviation} of {product.name} from {warehouse.name}')
return redirect('inventory:movement_list')
except (Product.DoesNotExist, Warehouse.DoesNotExist):
messages.error(request, 'Invalid product or warehouse selected')
except (ValueError, InvalidOperation):
messages.error(request, 'Invalid quantity entered')
# GET request - show form
context = {
'module_title': 'Stock Out',
'products': Product.objects.filter(is_active=True),
'warehouses': Warehouse.objects.filter(is_active=True),
}
return render(request, 'inventory/stock_out.html', context)
@login_required
@permission_required('inventory.add_stockmovement', raise_exception=True)
def stock_adjustment_view(request):
"""Adjust stock quantity"""
# In a real app, this would contain stock adjustment logic
return HttpResponse("Stock adjustment page")
@login_required
@permission_required('inventory.add_stockmovement', raise_exception=True)
def stock_transfer_view(request):
"""Transfer stock between warehouses"""
if request.method == 'POST':
product_id = request.POST.get('product')
from_warehouse_id = request.POST.get('from_warehouse')
to_warehouse_id = request.POST.get('to_warehouse')
quantity_str = request.POST.get('quantity', '0').replace(',', '.')
try:
product = Product.objects.get(id=product_id)
from_warehouse = Warehouse.objects.get(id=from_warehouse_id)
to_warehouse = Warehouse.objects.get(id=to_warehouse_id)
quantity = Decimal(quantity_str)
if quantity <= 0:
messages.error(request, 'Quantity must be greater than 0')
return redirect('inventory:stock_transfer')
if from_warehouse == to_warehouse:
messages.error(request, 'Source and destination warehouses must be different')
return redirect('inventory:stock_transfer')
# Check if sufficient stock is available in source warehouse
from_inventory = Inventory.objects.filter(
product=product, warehouse=from_warehouse
).first()
if not from_inventory or from_inventory.quantity < quantity:
messages.error(request, f'Insufficient stock in {from_warehouse.name}. Available: {from_inventory.quantity if from_inventory else 0} {product.unit_of_measure.abbreviation}')
return redirect('inventory:stock_transfer')
# Remove from source warehouse
from_inventory.quantity -= quantity
from_inventory.save()
# Add to destination warehouse
to_inventory, created = Inventory.objects.get_or_create(
product=product,
warehouse=to_warehouse,
defaults={'quantity': 0}
)
to_inventory.quantity += quantity
to_inventory.save()
# Create stock movement records
reference_number = f"TRANSFER-{timezone.now().strftime('%Y%m%d-%H%M%S')}"
# Out movement from source
StockMovement.objects.create(
product=product,
warehouse=from_warehouse,
movement_type='out',
quantity=quantity,
reference_number=reference_number,
notes=f"Transfer to {to_warehouse.name}: {request.POST.get('notes', '')}",
created_by=request.user
)
# In movement to destination
StockMovement.objects.create(
product=product,
warehouse=to_warehouse,
movement_type='in',
quantity=quantity,
reference_number=reference_number,
notes=f"Transfer from {from_warehouse.name}: {request.POST.get('notes', '')}",
created_by=request.user
)
messages.success(request, f'Successfully transferred {quantity} {product.unit_of_measure.abbreviation} of {product.name} from {from_warehouse.name} to {to_warehouse.name}')
return redirect('inventory:movement_list')
except (Product.DoesNotExist, Warehouse.DoesNotExist):
messages.error(request, 'Invalid product or warehouse selected')
except (ValueError, InvalidOperation):
messages.error(request, 'Invalid quantity entered')
# GET request - show form
context = {
'module_title': 'Stock Transfer',
'products': Product.objects.filter(is_active=True),
'warehouses': Warehouse.objects.filter(is_active=True),
}
return render(request, 'inventory/stock_transfer.html', context)
@login_required
def product_api_view(request):
"""API endpoint to get product data with unit of measure information"""
products = Product.objects.select_related('unit_of_measure').values(
'id', 'name', 'unit_of_measure__id', 'unit_of_measure__name'
)
product_list = []
for product in products:
product_list.append({
'id': product['id'],
'name': product['name'],
'unit_of_measure': {
'id': product['unit_of_measure__id'],
'name': product['unit_of_measure__name']
} if product['unit_of_measure__id'] else None
})
return JsonResponse(product_list, safe=False)

View File

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.

View File

@ -0,0 +1,6 @@
from django.apps import AppConfig
class ManufacturingConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.manufacturing"

View File

@ -0,0 +1,179 @@
# Generated by Django 4.2.23 on 2025-08-21 02:29
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
initial = True
dependencies = [
("inventory", "0001_initial"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name="BillOfMaterial",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("bom_code", models.CharField(max_length=50, unique=True)),
("version", models.CharField(default="1.0", max_length=20)),
("is_active", models.BooleanField(default=True)),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
(
"product",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="boms",
to="inventory.product",
),
),
],
),
migrations.CreateModel(
name="ManufacturingOrder",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("mo_number", models.CharField(max_length=50, unique=True)),
(
"quantity_to_produce",
models.DecimalField(decimal_places=2, max_digits=10),
),
("scheduled_start_date", models.DateField()),
("scheduled_end_date", models.DateField()),
("actual_start_date", models.DateField(blank=True, null=True)),
("actual_end_date", models.DateField(blank=True, null=True)),
(
"status",
models.CharField(
choices=[
("in_progress", "In Progress"),
("completed", "Completed"),
("cancelled", "Cancelled"),
],
default="in_progress",
max_length=20,
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
(
"bom",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to="manufacturing.billofmaterial",
),
),
(
"created_by",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
to=settings.AUTH_USER_MODEL,
),
),
],
),
migrations.CreateModel(
name="MOComponent",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"required_quantity",
models.DecimalField(decimal_places=2, max_digits=10),
),
(
"consumed_quantity",
models.DecimalField(decimal_places=2, default=0, max_digits=10),
),
(
"component",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to="inventory.product",
),
),
(
"mo",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="components",
to="manufacturing.manufacturingorder",
),
),
],
options={
"unique_together": {("mo", "component")},
},
),
migrations.CreateModel(
name="BOMItem",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("quantity", models.DecimalField(decimal_places=2, max_digits=10)),
(
"bom",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="items",
to="manufacturing.billofmaterial",
),
),
(
"component",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="bom_components",
to="inventory.product",
),
),
(
"unit_of_measure",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
to="inventory.unitofmeasure",
),
),
],
options={
"unique_together": {("bom", "component")},
},
),
]

View File

@ -0,0 +1,12 @@
# Generated by Django 4.2.23 on 2025-08-21 13:33
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("manufacturing", "0001_initial"),
]
operations = []

View File

@ -0,0 +1,57 @@
# Generated by Django 4.2.23 on 2025-08-21 13:35
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
("inventory", "0001_initial"),
("manufacturing", "0002_auto_20250821_2033"),
]
operations = [
migrations.AlterUniqueTogether(
name="mocomponent",
unique_together=set(),
),
migrations.AddField(
model_name="mocomponent",
name="manufacturing_order",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="mocomponents",
to="manufacturing.manufacturingorder",
),
),
migrations.AlterField(
model_name="billofmaterial",
name="bom_code",
field=models.CharField(blank=True, max_length=50, unique=True),
),
migrations.AlterField(
model_name="manufacturingorder",
name="status",
field=models.CharField(
choices=[
("scheduled", "Scheduled"),
("in_progress", "In Progress"),
("completed", "Completed"),
("cancelled", "Cancelled"),
],
default="in_progress",
max_length=20,
),
),
migrations.AlterUniqueTogether(
name="mocomponent",
unique_together={("manufacturing_order", "component")},
),
migrations.RemoveField(
model_name="mocomponent",
name="mo",
),
]

View File

@ -0,0 +1,117 @@
from django.db import models
class BillOfMaterial(models.Model):
product = models.ForeignKey('inventory.Product', on_delete=models.CASCADE, related_name='boms')
bom_code = models.CharField(max_length=50, unique=True, blank=True)
version = models.CharField(max_length=20, default='1.0')
is_active = models.BooleanField(default=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
def save(self, *args, **kwargs):
if not self.bom_code:
self.bom_code = self.generate_bom_code()
super().save(*args, **kwargs)
def generate_bom_code(self):
"""Generate a unique BOM code"""
last_bom = BillOfMaterial.objects.order_by('-id').first()
if last_bom and last_bom.bom_code and last_bom.bom_code.startswith('BOM'):
try:
last_number = int(last_bom.bom_code[3:]) # Remove 'BOM' prefix
new_number = last_number + 1
except ValueError:
new_number = 1
else:
new_number = 1
return f'BOM{new_number:04d}'
def __str__(self):
return f"{self.bom_code} - {self.product.name}"
@property
def total_cost(self):
"""Calculate total cost of all BOM items"""
return sum(item.total_cost for item in self.items.all())
class BOMItem(models.Model):
bom = models.ForeignKey(BillOfMaterial, on_delete=models.CASCADE, related_name='items')
component = models.ForeignKey('inventory.Product', on_delete=models.CASCADE, related_name='bom_components')
quantity = models.DecimalField(max_digits=10, decimal_places=2)
unit_of_measure = models.ForeignKey('inventory.UnitOfMeasure', on_delete=models.SET_NULL, null=True)
class Meta:
unique_together = ('bom', 'component')
@property
def total_cost(self):
"""Calculate total cost for this BOM item"""
return self.quantity * self.component.cost
class ManufacturingOrder(models.Model):
STATUS_CHOICES = [
('scheduled', 'Scheduled'),
('in_progress', 'In Progress'),
('completed', 'Completed'),
('cancelled', 'Cancelled'),
]
mo_number = models.CharField(max_length=50, unique=True)
bom = models.ForeignKey(BillOfMaterial, on_delete=models.CASCADE)
quantity_to_produce = models.DecimalField(max_digits=10, decimal_places=2)
scheduled_start_date = models.DateField()
scheduled_end_date = models.DateField()
actual_start_date = models.DateField(null=True, blank=True)
actual_end_date = models.DateField(null=True, blank=True)
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='in_progress')
created_by = models.ForeignKey('accounts.User', on_delete=models.SET_NULL, null=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
def __str__(self):
return self.mo_number
@property
def total_cost(self):
"""Calculate total cost of all components"""
return sum(component.total_cost for component in self.mocomponents.all())
@property
def is_overdue(self):
"""Check if the manufacturing order is overdue"""
from django.utils import timezone
if self.status in ['scheduled', 'in_progress']:
return timezone.now().date() > self.scheduled_end_date
return False
def can_start(self):
"""Check if the manufacturing order can be started"""
return self.status == 'scheduled'
def can_complete(self):
"""Check if the manufacturing order can be completed"""
return self.status == 'in_progress'
def can_cancel(self):
"""Check if the manufacturing order can be cancelled"""
return self.status in ['scheduled', 'in_progress']
class MOComponent(models.Model):
manufacturing_order = models.ForeignKey(ManufacturingOrder, on_delete=models.CASCADE, related_name='mocomponents', null=True, blank=True)
component = models.ForeignKey('inventory.Product', on_delete=models.CASCADE)
required_quantity = models.DecimalField(max_digits=10, decimal_places=2)
consumed_quantity = models.DecimalField(max_digits=10, decimal_places=2, default=0)
class Meta:
unique_together = ('manufacturing_order', 'component')
@property
def total_cost(self):
"""Calculate total cost for this component"""
return self.required_quantity * self.component.cost

View File

@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

View File

@ -0,0 +1,25 @@
from django.urls import path
from . import views
app_name = 'manufacturing'
urlpatterns = [
# Dashboard
path('', views.manufacturing_dashboard, name='dashboard'),
# Bill of Materials
path('bom/', views.bom_list_view, name='bom_list'),
path('bom/create/', views.create_bom_view, name='create_bom'),
path('bom/<int:bom_id>/', views.bom_detail_view, name='bom_detail'),
path('bom/<int:bom_id>/edit/', views.edit_bom_view, name='edit_bom'),
path('bom/<int:bom_id>/delete/', views.delete_bom_view, name='delete_bom'),
# Manufacturing Orders
path('orders/', views.mo_list_view, name='mo_list'),
path('orders/create/', views.create_mo_view, name='create_mo'),
path('orders/<str:mo_number>/', views.mo_detail_view, name='mo_detail'),
path('orders/<str:mo_number>/edit/', views.edit_mo_view, name='edit_mo'),
path('orders/<str:mo_number>/start/', views.start_mo_view, name='start_mo'),
path('orders/<str:mo_number>/complete/', views.complete_mo_view, name='complete_mo'),
path('orders/<str:mo_number>/cancel/', views.cancel_mo_view, name='cancel_mo'),
path('orders/<str:mo_number>/delete/', views.delete_mo_view, name='delete_mo'),
]

441
apps/manufacturing/views.py Normal file
View File

@ -0,0 +1,441 @@
from django.shortcuts import render, redirect, get_object_or_404
from django.contrib.auth.decorators import login_required, permission_required
from django.contrib import messages
from django.http import HttpResponse
from django.db.models import Count, Q, Sum
from django.utils import timezone
from datetime import timedelta
from django import forms
from django.forms import inlineformset_factory
from decimal import Decimal
from .models import BillOfMaterial, BOMItem, ManufacturingOrder, MOComponent
from apps.inventory.models import Product
class BOMItemForm(forms.ModelForm):
class Meta:
model = BOMItem
fields = ['component', 'quantity', 'unit_of_measure']
widgets = {
'quantity': forms.NumberInput(attrs={'step': '0.01', 'min': '0'}),
}
class BOMForm(forms.ModelForm):
class Meta:
model = BillOfMaterial
fields = ['product', 'version', 'is_active']
widgets = {
'version': forms.TextInput(attrs={'placeholder': '1.0'}),
}
BOMItemFormSet = inlineformset_factory(
BillOfMaterial,
BOMItem,
form=BOMItemForm,
extra=1,
can_delete=True,
min_num=1,
validate_min=True
)
class MOComponentForm(forms.ModelForm):
class Meta:
model = MOComponent
fields = ['component', 'required_quantity']
widgets = {
'required_quantity': forms.NumberInput(attrs={'step': '0.01', 'min': '0'}),
}
class MOForm(forms.ModelForm):
class Meta:
model = ManufacturingOrder
fields = ['mo_number', 'bom', 'quantity_to_produce', 'scheduled_start_date', 'scheduled_end_date']
widgets = {
'scheduled_start_date': forms.DateInput(attrs={'type': 'date'}),
'scheduled_end_date': forms.DateInput(attrs={'type': 'date'}),
'quantity_to_produce': forms.NumberInput(attrs={'step': '0.01', 'min': '0'}),
}
MOComponentFormSet = inlineformset_factory(
ManufacturingOrder,
MOComponent,
form=MOComponentForm,
extra=0,
can_delete=True,
min_num=0
)
@login_required
def manufacturing_dashboard(request):
"""Manufacturing dashboard view"""
total_boms = BillOfMaterial.objects.filter(is_active=True)
active_orders = ManufacturingOrder.objects.filter(
Q(status='in_progress') | Q(status='scheduled')
)
completed_orders = ManufacturingOrder.objects.filter(
status='completed',
actual_end_date__gte=timezone.now() - timedelta(days=30)
)
recent_orders = ManufacturingOrder.objects.select_related('bom__product').order_by('-created_at')[:5]
context = {
'module_title': 'Manufacturing Management',
'total_boms': total_boms,
'active_orders': active_orders,
'completed_orders': completed_orders,
'recent_orders': recent_orders,
}
return render(request, 'manufacturing/dashboard.html', context)
# Bill of Material Views
@login_required
@permission_required('manufacturing.view_billofmaterial', raise_exception=True)
def bom_list_view(request):
"""List all bills of materials"""
boms = BillOfMaterial.objects.all()
context = {
'module_title': 'Bill of Materials List',
'boms': boms,
}
return render(request, 'manufacturing/bom_list.html', context)
@login_required
@permission_required('manufacturing.add_billofmaterial', raise_exception=True)
def create_bom_view(request):
"""Create a new bill of material"""
if request.method == 'POST':
form = BOMForm(request.POST)
formset = BOMItemFormSet(request.POST)
if form.is_valid() and formset.is_valid():
# Save the BOM
bom = form.save(commit=False)
bom.save()
# Save the BOM items
items = formset.save(commit=False)
for item in items:
item.bom = bom
item.save()
messages.success(request, f'Bill of Material "{bom.bom_code}" created successfully!')
return redirect('manufacturing:bom_detail', bom_id=bom.id)
else:
form = BOMForm()
formset = BOMItemFormSet()
context = {
'form': form,
'formset': formset,
'module_title': 'Create Bill of Material',
'is_create': True,
}
return render(request, 'manufacturing/bom_form.html', context)
@login_required
@permission_required('manufacturing.view_billofmaterial', raise_exception=True)
def bom_detail_view(request, bom_id):
"""View bill of material details"""
try:
bom = BillOfMaterial.objects.get(id=bom_id)
context = {
'module_title': 'Bill of Material Details',
'bom': bom,
}
return render(request, 'manufacturing/bom_detail.html', context)
except BillOfMaterial.DoesNotExist:
messages.error(request, 'Bill of material not found')
return redirect('manufacturing:bom_list')
@login_required
@permission_required('manufacturing.change_billofmaterial', raise_exception=True)
def edit_bom_view(request, bom_id):
"""Edit an existing bill of material"""
try:
bom = get_object_or_404(BillOfMaterial, id=bom_id)
except:
messages.error(request, 'Bill of Material not found.')
return redirect('manufacturing:bom_list')
if request.method == 'POST':
form = BOMForm(request.POST, instance=bom)
formset = BOMItemFormSet(request.POST, instance=bom)
if form.is_valid() and formset.is_valid():
# Save the BOM
bom = form.save(commit=False)
bom.save()
# Delete existing items and save new ones
BOMItem.objects.filter(bom=bom).delete()
items = formset.save(commit=False)
for item in items:
item.bom = bom
item.save()
messages.success(request, f'Bill of Material "{bom.bom_code}" updated successfully!')
return redirect('manufacturing:bom_detail', bom_id=bom.id)
else:
form = BOMForm(instance=bom)
formset = BOMItemFormSet(instance=bom)
context = {
'form': form,
'formset': formset,
'bom': bom,
'module_title': f'Edit Bill of Material: {bom.bom_code}',
'is_create': False,
}
return render(request, 'manufacturing/bom_form.html', context)
@login_required
@permission_required('manufacturing.delete_billofmaterial', raise_exception=True)
def delete_bom_view(request, bom_id):
"""Delete a bill of material"""
try:
bom = get_object_or_404(BillOfMaterial, id=bom_id)
except:
messages.error(request, 'Bill of Material not found.')
return redirect('manufacturing:bom_list')
if request.method == 'POST':
bom_code = bom.bom_code
bom.delete()
messages.success(request, f'Bill of Material "{bom_code}" deleted successfully!')
return redirect('manufacturing:bom_list')
context = {
'bom': bom,
'module_title': f'Delete Bill of Material: {bom.bom_code}',
}
return render(request, 'manufacturing/bom_confirm_delete.html', context)
# Manufacturing Order Views
@login_required
@permission_required('manufacturing.view_manufacturingorder', raise_exception=True)
def mo_list_view(request):
"""List all manufacturing orders"""
mos = ManufacturingOrder.objects.all()
context = {
'module_title': 'Manufacturing Order List',
'mos': mos,
}
return render(request, 'manufacturing/mo_list.html', context)
@login_required
@permission_required('manufacturing.add_manufacturingorder', raise_exception=True)
def create_mo_view(request):
"""Create a new manufacturing order"""
if request.method == 'POST':
form = MOForm(request.POST)
formset = MOComponentFormSet(request.POST)
if form.is_valid() and formset.is_valid():
# Save the MO
mo = form.save(commit=False)
mo.save()
# Save the MO components
components = formset.save(commit=False)
for component in components:
component.manufacturing_order = mo
component.save()
messages.success(request, f'Manufacturing Order "{mo.mo_number}" created successfully!')
return redirect('manufacturing:mo_detail', mo_number=mo.mo_number)
else:
form = MOForm()
formset = MOComponentFormSet()
context = {
'form': form,
'formset': formset,
'module_title': 'Create Manufacturing Order',
'is_create': True,
}
return render(request, 'manufacturing/mo_form.html', context)
@login_required
@permission_required('manufacturing.view_manufacturingorder', raise_exception=True)
def mo_detail_view(request, mo_number):
"""View manufacturing order details"""
try:
mo = ManufacturingOrder.objects.get(mo_number=mo_number)
context = {
'module_title': 'Manufacturing Order Details',
'mo': mo,
}
return render(request, 'manufacturing/mo_detail.html', context)
except ManufacturingOrder.DoesNotExist:
messages.error(request, 'Manufacturing order not found')
return redirect('manufacturing:mo_list')
@login_required
@permission_required('manufacturing.change_manufacturingorder', raise_exception=True)
def edit_mo_view(request, mo_number):
"""Edit an existing manufacturing order"""
try:
mo = get_object_or_404(ManufacturingOrder, mo_number=mo_number)
except:
messages.error(request, 'Manufacturing Order not found.')
return redirect('manufacturing:mo_list')
if request.method == 'POST':
form = MOForm(request.POST, instance=mo)
formset = MOComponentFormSet(request.POST, instance=mo)
if form.is_valid() and formset.is_valid():
# Save the MO
mo = form.save(commit=False)
mo.save()
# Delete existing components and save new ones
MOComponent.objects.filter(manufacturing_order=mo).delete()
components = formset.save(commit=False)
for component in components:
component.manufacturing_order = mo
component.save()
messages.success(request, f'Manufacturing Order "{mo.mo_number}" updated successfully!')
return redirect('manufacturing:mo_detail', mo_number=mo.mo_number)
else:
form = MOForm(instance=mo)
formset = MOComponentFormSet(instance=mo)
context = {
'form': form,
'formset': formset,
'mo': mo,
'module_title': f'Edit Manufacturing Order: {mo.mo_number}',
'is_create': False,
}
return render(request, 'manufacturing/mo_form.html', context)
@login_required
@permission_required('manufacturing.change_manufacturingorder', raise_exception=True)
def start_mo_view(request, mo_number):
"""Start a manufacturing order"""
try:
mo = get_object_or_404(ManufacturingOrder, mo_number=mo_number)
except:
messages.error(request, 'Manufacturing Order not found.')
return redirect('manufacturing:mo_list')
if mo.status != 'scheduled':
messages.error(request, f'Cannot start Manufacturing Order in {mo.get_status_display()} status.')
return redirect('manufacturing:mo_detail', mo_number=mo.mo_number)
if request.method == 'POST':
mo.status = 'in_progress'
mo.actual_start_date = timezone.now()
mo.save()
messages.success(request, f'Manufacturing Order "{mo.mo_number}" started successfully!')
return redirect('manufacturing:mo_detail', mo_number=mo.mo_number)
context = {
'mo': mo,
'module_title': f'Start Manufacturing Order: {mo.mo_number}',
'action': 'Start',
}
return render(request, 'manufacturing/mo_confirm_action.html', context)
@login_required
@permission_required('manufacturing.change_manufacturingorder', raise_exception=True)
def complete_mo_view(request, mo_number):
"""Complete a manufacturing order"""
try:
mo = get_object_or_404(ManufacturingOrder, mo_number=mo_number)
except:
messages.error(request, 'Manufacturing Order not found.')
return redirect('manufacturing:mo_list')
if mo.status != 'in_progress':
messages.error(request, f'Cannot complete Manufacturing Order in {mo.get_status_display()} status.')
return redirect('manufacturing:mo_detail', mo_number=mo.mo_number)
if request.method == 'POST':
mo.status = 'completed'
mo.actual_end_date = timezone.now().date() # Using date() since actual_end_date is DateField
mo.save()
messages.success(request, f'Manufacturing Order "{mo.mo_number}" completed successfully!')
return redirect('manufacturing:mo_detail', mo_number=mo.mo_number)
context = {
'mo': mo,
'module_title': f'Complete Manufacturing Order: {mo.mo_number}',
'action': 'Complete',
}
return render(request, 'manufacturing/mo_confirm_action.html', context)
@login_required
@permission_required('manufacturing.change_manufacturingorder', raise_exception=True)
def cancel_mo_view(request, mo_number):
"""Cancel a manufacturing order"""
try:
mo = get_object_or_404(ManufacturingOrder, mo_number=mo_number)
except:
messages.error(request, 'Manufacturing Order not found.')
return redirect('manufacturing:mo_list')
if mo.status in ['completed', 'cancelled']:
messages.error(request, f'Cannot cancel Manufacturing Order in {mo.get_status_display()} status.')
return redirect('manufacturing:mo_detail', mo_number=mo.mo_number)
if request.method == 'POST':
mo.status = 'cancelled'
mo.actual_end_date = timezone.now()
mo.save()
messages.success(request, f'Manufacturing Order "{mo.mo_number}" cancelled successfully!')
return redirect('manufacturing:mo_detail', mo_number=mo.mo_number)
context = {
'mo': mo,
'module_title': f'Cancel Manufacturing Order: {mo.mo_number}',
'action': 'Cancel',
}
return render(request, 'manufacturing/mo_confirm_action.html', context)
@login_required
@permission_required('manufacturing.delete_manufacturingorder', raise_exception=True)
def delete_mo_view(request, mo_number):
"""Delete a manufacturing order"""
try:
mo = get_object_or_404(ManufacturingOrder, mo_number=mo_number)
except:
messages.error(request, 'Manufacturing Order not found.')
return redirect('manufacturing:mo_list')
if request.method == 'POST':
mo_number = mo.mo_number
mo.delete()
messages.success(request, f'Manufacturing Order "{mo_number}" deleted successfully!')
return redirect('manufacturing:mo_list')
context = {
'mo': mo,
'module_title': f'Delete Manufacturing Order: {mo.mo_number}',
}
return render(request, 'manufacturing/mo_confirm_delete.html', context)

View File

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

3
apps/purchasing/admin.py Normal file
View File

@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.

6
apps/purchasing/apps.py Normal file
View File

@ -0,0 +1,6 @@
from django.apps import AppConfig
class PurchasingConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.purchasing"

View File

@ -0,0 +1,207 @@
# Generated by Django 4.2.23 on 2025-08-21 02:29
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
initial = True
dependencies = [
("inventory", "0001_initial"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name="GoodsReceipt",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("gr_number", models.CharField(max_length=50, unique=True)),
("receipt_date", models.DateField()),
("notes", models.TextField(blank=True)),
("created_at", models.DateTimeField(auto_now_add=True)),
],
),
migrations.CreateModel(
name="PurchaseOrder",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("po_number", models.CharField(max_length=50, unique=True)),
("order_date", models.DateField()),
("expected_delivery_date", models.DateField(blank=True, null=True)),
(
"status",
models.CharField(
choices=[
("ordered", "Ordered"),
("completed", "Completed"),
("cancelled", "Cancelled"),
],
default="ordered",
max_length=20,
),
),
(
"subtotal",
models.DecimalField(decimal_places=2, default=0, max_digits=12),
),
(
"tax_amount",
models.DecimalField(decimal_places=2, default=0, max_digits=12),
),
(
"total_amount",
models.DecimalField(decimal_places=2, default=0, max_digits=12),
),
("notes", models.TextField(blank=True)),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
(
"created_by",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
to=settings.AUTH_USER_MODEL,
),
),
],
),
migrations.CreateModel(
name="Supplier",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("code", models.CharField(max_length=50, unique=True)),
("name", models.CharField(max_length=200)),
("contact_person", models.CharField(blank=True, max_length=100)),
("email", models.EmailField(blank=True, max_length=254)),
("phone", models.CharField(blank=True, max_length=20)),
("address", models.TextField(blank=True)),
("tax_id", models.CharField(blank=True, max_length=50)),
("payment_terms", models.CharField(blank=True, max_length=100)),
("is_active", models.BooleanField(default=True)),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
],
),
migrations.CreateModel(
name="PurchaseOrderItem",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("quantity", models.DecimalField(decimal_places=2, max_digits=10)),
("unit_price", models.DecimalField(decimal_places=2, max_digits=10)),
("total_price", models.DecimalField(decimal_places=2, max_digits=12)),
(
"received_quantity",
models.DecimalField(decimal_places=2, default=0, max_digits=10),
),
(
"po",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="items",
to="purchasing.purchaseorder",
),
),
(
"product",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to="inventory.product",
),
),
],
),
migrations.AddField(
model_name="purchaseorder",
name="supplier",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="purchasing.supplier"
),
),
migrations.CreateModel(
name="GoodsReceiptItem",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"received_quantity",
models.DecimalField(decimal_places=2, max_digits=10),
),
("notes", models.TextField(blank=True)),
(
"po_item",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to="purchasing.purchaseorderitem",
),
),
(
"receipt",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="items",
to="purchasing.goodsreceipt",
),
),
],
),
migrations.AddField(
model_name="goodsreceipt",
name="po",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to="purchasing.purchaseorder",
),
),
migrations.AddField(
model_name="goodsreceipt",
name="received_by",
field=models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
to=settings.AUTH_USER_MODEL,
),
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 4.2.23 on 2025-08-21 13:35
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("purchasing", "0001_initial"),
]
operations = [
migrations.AlterField(
model_name="purchaseorder",
name="po_number",
field=models.CharField(blank=True, max_length=50, unique=True),
),
]

View File

94
apps/purchasing/models.py Normal file
View File

@ -0,0 +1,94 @@
from django.db import models
class Supplier(models.Model):
code = models.CharField(max_length=50, unique=True)
name = models.CharField(max_length=200)
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)
tax_id = models.CharField(max_length=50, blank=True)
payment_terms = models.CharField(max_length=100, blank=True)
is_active = models.BooleanField(default=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
def __str__(self):
return self.name
class PurchaseOrder(models.Model):
STATUS_CHOICES = [
('ordered', 'Ordered'),
('completed', 'Completed'),
('cancelled', 'Cancelled'),
]
po_number = models.CharField(max_length=50, unique=True, blank=True)
supplier = models.ForeignKey(Supplier, on_delete=models.CASCADE)
order_date = models.DateField()
expected_delivery_date = models.DateField(null=True, blank=True)
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='ordered')
subtotal = models.DecimalField(max_digits=12, decimal_places=2, default=0)
tax_amount = models.DecimalField(max_digits=12, decimal_places=2, default=0)
total_amount = models.DecimalField(max_digits=12, decimal_places=2, default=0)
notes = models.TextField(blank=True)
created_by = models.ForeignKey('accounts.User', on_delete=models.SET_NULL, null=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
def save(self, *args, **kwargs):
if not self.po_number:
self.po_number = self.generate_po_number()
super().save(*args, **kwargs)
def generate_po_number(self):
"""Generate a unique purchase order number"""
last_po = PurchaseOrder.objects.order_by('-id').first()
if last_po and last_po.po_number and last_po.po_number.startswith('PO'):
try:
last_number = int(last_po.po_number[2:]) # Remove 'PO' prefix
new_number = last_number + 1
except ValueError:
new_number = 1
else:
new_number = 1
return f'PO{new_number:04d}'
def __str__(self):
return self.po_number
class PurchaseOrderItem(models.Model):
po = models.ForeignKey(PurchaseOrder, on_delete=models.CASCADE, related_name='items')
product = models.ForeignKey('inventory.Product', on_delete=models.CASCADE)
quantity = models.DecimalField(max_digits=10, decimal_places=2)
unit_price = models.DecimalField(max_digits=10, decimal_places=2)
total_price = models.DecimalField(max_digits=12, decimal_places=2)
received_quantity = models.DecimalField(max_digits=10, decimal_places=2, default=0)
def save(self, *args, **kwargs):
self.total_price = self.quantity * self.unit_price
super().save(*args, **kwargs)
class GoodsReceipt(models.Model):
gr_number = models.CharField(max_length=50, unique=True)
po = models.ForeignKey(PurchaseOrder, on_delete=models.CASCADE)
receipt_date = models.DateField()
received_by = models.ForeignKey('accounts.User', on_delete=models.SET_NULL, null=True)
notes = models.TextField(blank=True)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
return self.gr_number
class GoodsReceiptItem(models.Model):
receipt = models.ForeignKey(GoodsReceipt, on_delete=models.CASCADE, related_name='items')
po_item = models.ForeignKey(PurchaseOrderItem, on_delete=models.CASCADE)
received_quantity = models.DecimalField(max_digits=10, decimal_places=2)
notes = models.TextField(blank=True)

3
apps/purchasing/tests.py Normal file
View File

@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

Some files were not shown because too many files have changed in this diff Show More