diff --git a/backend/apps/accounts/migrations/0005_user_display_name.py b/backend/apps/accounts/migrations/0005_user_display_name.py new file mode 100644 index 0000000..7d5218e --- /dev/null +++ b/backend/apps/accounts/migrations/0005_user_display_name.py @@ -0,0 +1,25 @@ +from django.db import migrations, models + + +def backfill_display_name(apps, schema_editor): + User = apps.get_model("accounts", "User") + for user in User.objects.all().iterator(): + if not (user.display_name or "").strip(): + User.objects.filter(pk=user.pk).update(display_name=user.user_name) + + +class Migration(migrations.Migration): + + dependencies = [ + ("accounts", "0004_sync_status_flags"), + ] + + operations = [ + migrations.AddField( + model_name="user", + name="display_name", + field=models.CharField(default="", max_length=100), + preserve_default=False, + ), + migrations.RunPython(backfill_display_name, migrations.RunPython.noop), + ] diff --git a/backend/apps/accounts/models.py b/backend/apps/accounts/models.py index 91f6e35..657b5d6 100644 --- a/backend/apps/accounts/models.py +++ b/backend/apps/accounts/models.py @@ -13,6 +13,7 @@ class UserManager(BaseUserManager): if not user_name: raise ValueError("user_name is required") extra_fields.setdefault("status", User.STATUS_ACTIVE) + extra_fields.setdefault("display_name", user_name) user = self.model(user_name=user_name, **extra_fields) user.set_password(password) user.save(using=self._db) @@ -39,6 +40,7 @@ class User(AbstractBaseUser, PermissionsMixin): user_id = models.BigAutoField(primary_key=True) user_name = models.CharField(max_length=30, unique=True) + display_name = models.CharField(max_length=100) status = models.CharField(max_length=30, choices=STATUS_CHOICES, default=STATUS_ACTIVE) is_staff = models.BooleanField(default=False) is_active = models.BooleanField(default=True) diff --git a/backend/apps/accounts/serializers.py b/backend/apps/accounts/serializers.py index 8d437c8..b9e8d8b 100644 --- a/backend/apps/accounts/serializers.py +++ b/backend/apps/accounts/serializers.py @@ -15,8 +15,8 @@ class PkAsIdMixin(serializers.Serializer): class UserSerializer(PkAsIdMixin, serializers.ModelSerializer): class Meta: model = User - fields = ["id", "user_name", "status", "created_at", "updated_at"] - read_only_fields = ["id", "created_at", "updated_at"] + fields = ["id", "user_name", "display_name", "status", "created_at", "updated_at"] + read_only_fields = ["id", "user_name", "created_at", "updated_at"] class LoginSerializer(serializers.Serializer): diff --git a/backend/apps/jobs/management/commands/bootstrap_admin.py b/backend/apps/jobs/management/commands/bootstrap_admin.py index 182ba40..b83301b 100644 --- a/backend/apps/jobs/management/commands/bootstrap_admin.py +++ b/backend/apps/jobs/management/commands/bootstrap_admin.py @@ -18,6 +18,7 @@ def ensure_bootstrap_user( user, created = User.objects.get_or_create( user_name=user_name, defaults={ + "display_name": user_name, "status": status, "is_staff": is_staff, "is_superuser": is_superuser, @@ -31,6 +32,8 @@ def ensure_bootstrap_user( ) should_set_password = created or not user.has_usable_password() or force_password + if not (user.display_name or "").strip(): + user.display_name = user_name if should_set_password: user.set_password(password) user.status = status @@ -43,7 +46,9 @@ def ensure_bootstrap_user( user.status = status user.is_staff = is_staff user.is_superuser = is_superuser - user.save(update_fields=["status", "is_staff", "is_superuser", "updated_at"]) + user.save( + update_fields=["display_name", "status", "is_staff", "is_superuser", "updated_at"] + ) stdout.write(f"User {user_name} already exists (password unchanged)") return user diff --git a/backend/apps/jobs/management/commands/seed_demo.py b/backend/apps/jobs/management/commands/seed_demo.py index 85fdc73..9d07f5b 100644 --- a/backend/apps/jobs/management/commands/seed_demo.py +++ b/backend/apps/jobs/management/commands/seed_demo.py @@ -16,13 +16,23 @@ class Command(BaseCommand): def handle(self, *args, **options): user, created = User.objects.get_or_create( user_name="admin", - defaults={"status": User.STATUS_SUPERADMIN, "is_staff": True, "is_superuser": True}, + defaults={ + "display_name": "Admin", + "status": User.STATUS_SUPERADMIN, + "is_staff": True, + "is_superuser": True, + }, ) if created or not user.has_usable_password(): user.set_password("admin123") user.status = User.STATUS_SUPERADMIN + if not (user.display_name or "").strip(): + user.display_name = "Admin" user.save() self.stdout.write("Created/updated user admin / admin123") + elif not (user.display_name or "").strip(): + user.display_name = "Admin" + user.save(update_fields=["display_name", "updated_at"]) raw_key = None bootstrap = settings.BOOTSTRAP_API_KEY diff --git a/backend/apps/jobs/management/commands/seed_sukawarna_kandang4.py b/backend/apps/jobs/management/commands/seed_sukawarna_kandang4.py index 9f788dd..0fb40d2 100644 --- a/backend/apps/jobs/management/commands/seed_sukawarna_kandang4.py +++ b/backend/apps/jobs/management/commands/seed_sukawarna_kandang4.py @@ -178,10 +178,16 @@ class Command(BaseCommand): user, created = User.objects.get_or_create( user_name="staff", - defaults={"status": User.STATUS_ACTIVE, "is_staff": True}, + defaults={ + "display_name": "Staff", + "status": User.STATUS_ACTIVE, + "is_staff": True, + }, ) user.status = User.STATUS_ACTIVE user.is_staff = True + if not (user.display_name or "").strip(): + user.display_name = "Staff" user.set_password(password) user.save() if created: diff --git a/backend/apps/jobs/management/commands/seed_sukawarna_kandang5.py b/backend/apps/jobs/management/commands/seed_sukawarna_kandang5.py index ce3a62e..5d08af4 100644 --- a/backend/apps/jobs/management/commands/seed_sukawarna_kandang5.py +++ b/backend/apps/jobs/management/commands/seed_sukawarna_kandang5.py @@ -65,10 +65,16 @@ class Command(BaseCommand): user, created = User.objects.get_or_create( user_name="staff", - defaults={"status": User.STATUS_ACTIVE, "is_staff": True}, + defaults={ + "display_name": "Staff", + "status": User.STATUS_ACTIVE, + "is_staff": True, + }, ) user.status = User.STATUS_ACTIVE user.is_staff = True + if not (user.display_name or "").strip(): + user.display_name = "Staff" user.set_password(password) user.save() if created: diff --git a/backend/database/ERD_dashboard.png b/backend/database/ERD_dashboard.png index 8add559..17a28c0 100644 Binary files a/backend/database/ERD_dashboard.png and b/backend/database/ERD_dashboard.png differ diff --git a/backend/database/erd_schema.sql b/backend/database/erd_schema.sql index 20786e7..d383b85 100644 --- a/backend/database/erd_schema.sql +++ b/backend/database/erd_schema.sql @@ -3,11 +3,13 @@ -- Source: ERD_dashboard.png (updated 2026-09-09 for GM cycle-close flow) -- -- user_access.status: active | inactive | superadmin +-- user_access.display_name: shown in dashboard UI (login still uses user_name) -- (Django syncs is_active/is_staff/is_superuser from status on save: -- inactive => all false; superadmin => is_active/is_superuser true, -- is_staff false; active => is_active true, is_superuser false, is_staff -- left untouched so operators can be marked staff) -- user_access → sites → kandang → cycles +-- user_access → api_keys (X-API-Key auth; hashed at rest) -- cycles.status: active | pending_close | closed (GM approves end_date) -- cycles.close_requested_by_id → user_access (optional) -- kandang → flock → iot_panel @@ -26,6 +28,7 @@ CREATE TABLE IF NOT EXISTS user_access ( user_id SERIAL PRIMARY KEY, user_name VARCHAR(30) NOT NULL UNIQUE, + display_name VARCHAR(100) NOT NULL, password VARCHAR(128) NOT NULL, last_login TIMESTAMP WITH TIME ZONE, is_superuser BOOLEAN NOT NULL DEFAULT FALSE, @@ -51,6 +54,24 @@ CREATE TABLE IF NOT EXISTS user_access_user_permissions ( permission_id INTEGER NOT NULL REFERENCES auth_permission (id) ON DELETE CASCADE ); +-- ----------------------------------------------------------------------------- +-- 1b. API Keys (machine auth via X-API-Key; FK → user_access) +-- ----------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS api_keys ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES user_access (user_id) ON DELETE CASCADE, + name VARCHAR(100) NOT NULL, + prefix VARCHAR(12) NOT NULL, + key_hash VARCHAR(64) NOT NULL, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + last_used_at TIMESTAMP WITH TIME ZONE, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_api_keys_user_id ON api_keys (user_id); +CREATE INDEX IF NOT EXISTS idx_api_keys_prefix ON api_keys (prefix); + -- ----------------------------------------------------------------------------- -- 2. Sites -- ----------------------------------------------------------------------------- diff --git a/components/Header.tsx b/components/Header.tsx index 14a22b7..6df7cd7 100644 --- a/components/Header.tsx +++ b/components/Header.tsx @@ -129,10 +129,12 @@ const Header: React.FC = ({ className="flex items-center space-x-2 focus:outline-none" >
- {user.user_name.slice(0, 1).toUpperCase()} + {(user.display_name || user.user_name).slice(0, 1).toUpperCase()}
-

{user.user_name}

+

+ {user.display_name || user.user_name} +

{user.status === 'superadmin' ? 'Admin' : 'Pengguna'}

@@ -142,7 +144,9 @@ const Header: React.FC = ({ {open && (
-

{user.user_name}

+

+ {user.display_name || user.user_name} +

{user.status === 'superadmin' ? 'Admin' : 'Pengguna'}

diff --git a/components/__tests__/SettingsPage.test.tsx b/components/__tests__/SettingsPage.test.tsx index 60f5171..61d5e0b 100644 --- a/components/__tests__/SettingsPage.test.tsx +++ b/components/__tests__/SettingsPage.test.tsx @@ -62,6 +62,7 @@ vi.mock('../../context/AuthContext.tsx', () => ({ user: { id: 99, user_name: 'tester', + display_name: 'Tester', status: 'superadmin', created_at: '', updated_at: '', diff --git a/services/__tests__/apiClient.test.ts b/services/__tests__/apiClient.test.ts index 2cd09a8..3726cf2 100644 --- a/services/__tests__/apiClient.test.ts +++ b/services/__tests__/apiClient.test.ts @@ -42,7 +42,7 @@ describe('apiClient', () => { it('sends CSRF token on POST', async () => { document.cookie = 'csrftoken=abc123; path=/'; - const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ id: 1, user_name: 'admin' })); + const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ id: 1, user_name: 'admin', display_name: 'Admin' })); vi.stubGlobal('fetch', fetchMock); await api.auth.login('admin', 'admin123'); diff --git a/types/api.ts b/types/api.ts index 48e0556..b5c8a37 100644 --- a/types/api.ts +++ b/types/api.ts @@ -10,6 +10,7 @@ export type UserStatus = 'active' | 'inactive' | 'superadmin'; export type User = { id: number; user_name: string; + display_name: string; status: UserStatus; created_at: string; updated_at: string;