first commit

This commit is contained in:
Alberto-Audrix committed 2026-09-08 15:21:23 +07:00
commit b54624be96
226 files changed
+108840

No files matched your search

View File
Whitespace-only changes.
+8
View File
@@ -0,0 +1,8 @@
from django.apps import AppConfig
class AccountsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.accounts"
label = "accounts"
verbose_name = "Accounts"
+32
View File
@@ -0,0 +1,32 @@
from __future__ import annotations
from django.utils.translation import gettext_lazy as _
from rest_framework import authentication, exceptions
from apps.accounts.models import ApiKey
class ApiKeyAuthentication(authentication.BaseAuthentication):
"""Authenticate via X-API-Key header (hashed lookup)."""
keyword = "X-API-Key"
def authenticate(self, request):
raw_key = request.headers.get(self.keyword) or request.META.get("HTTP_X_API_KEY")
if not raw_key:
return None
if len(raw_key) < 8:
raise exceptions.AuthenticationFailed(_("Invalid API key"))
prefix = raw_key[:8]
candidates = ApiKey.objects.select_related("user").filter(
prefix=prefix, is_active=True, user__is_active=True
)
for api_key in candidates:
if api_key.verify(raw_key):
api_key.touch()
return (api_key.user, api_key)
raise exceptions.AuthenticationFailed(_("Invalid API key"))
def authenticate_header(self, request):
return self.keyword
@@ -0,0 +1,55 @@
# Generated by Django 5.2.17 on 2026-08-10 08:31
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
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')),
('user_name', models.CharField(max_length=30, unique=True)),
('is_staff', models.BooleanField(default=False)),
('is_active', models.BooleanField(default=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=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={
'db_table': 'user_access',
'ordering': ['user_name'],
},
),
migrations.CreateModel(
name='ApiKey',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=100)),
('prefix', models.CharField(db_index=True, max_length=12)),
('key_hash', models.CharField(max_length=64)),
('is_active', models.BooleanField(default=True)),
('last_used_at', models.DateTimeField(blank=True, null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='api_keys', to=settings.AUTH_USER_MODEL)),
],
options={
'db_table': 'api_keys',
'ordering': ['-created_at'],
},
),
]
@@ -0,0 +1,16 @@
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("accounts", "0001_initial"),
]
operations = [
migrations.RenameField(
model_name="user",
old_name="id",
new_name="user_id",
),
]
@@ -0,0 +1,39 @@
from django.db import migrations, models
def backfill_user_status(apps, schema_editor):
User = apps.get_model("accounts", "User")
for user in User.objects.all():
if user.is_superuser:
status = "superadmin"
flags = {"is_active": True, "is_staff": True, "is_superuser": True}
elif not user.is_active:
status = "inactive"
flags = {"is_active": False, "is_staff": False, "is_superuser": False}
else:
status = "active"
flags = {"is_active": True, "is_staff": False, "is_superuser": False}
User.objects.filter(pk=user.pk).update(status=status, **flags)
class Migration(migrations.Migration):
dependencies = [
("accounts", "0002_erd_named_primary_keys"),
]
operations = [
migrations.AddField(
model_name="user",
name="status",
field=models.CharField(
choices=[
("active", "Active"),
("inactive", "Inactive"),
("superadmin", "Super Admin"),
],
default="active",
max_length=30,
),
),
migrations.RunPython(backfill_user_status, migrations.RunPython.noop),
]
@@ -0,0 +1,29 @@
from django.db import migrations
def sync_status_flags(apps, schema_editor):
User = apps.get_model("accounts", "User")
for user in User.objects.all():
if user.status == "inactive":
user.is_active = False
user.is_staff = False
user.is_superuser = False
elif user.status == "superadmin":
user.is_active = True
user.is_staff = False
user.is_superuser = True
else:
user.is_active = True
user.is_superuser = False
user.save(update_fields=["is_active", "is_staff", "is_superuser", "updated_at"])
class Migration(migrations.Migration):
dependencies = [
("accounts", "0003_user_status"),
]
operations = [
migrations.RunPython(sync_status_flags, migrations.RunPython.noop),
]
@@ -0,0 +1,45 @@
from django.db import migrations, models
def migrate_legacy_statuses(apps, schema_editor):
User = apps.get_model("accounts", "User")
for user in User.objects.all():
if user.status == "superadmin":
new_status = "director_admin"
flags = {"is_active": True, "is_staff": True, "is_superuser": True}
elif user.status == "active":
new_status = "gm"
flags = {"is_active": True, "is_staff": False, "is_superuser": False}
elif user.status == "inactive":
new_status = "inactive"
flags = {"is_active": False, "is_staff": False, "is_superuser": False}
else:
continue
User.objects.filter(pk=user.pk).update(status=new_status, **flags)
class Migration(migrations.Migration):
dependencies = [
("accounts", "0004_sync_status_flags"),
]
operations = [
migrations.AlterField(
model_name="user",
name="status",
field=models.CharField(
choices=[
("inactive", "Inactive"),
("gm", "GM"),
("gm_admin", "GM Admin"),
("buh", "BUH"),
("bu_admin", "BU Admin"),
("director", "Director"),
("director_admin", "Director Admin"),
],
default="gm",
max_length=30,
),
),
migrations.RunPython(migrate_legacy_statuses, migrations.RunPython.noop),
]
@@ -0,0 +1,26 @@
# Generated for HQ visibility scoping
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("accounts", "0005_executive_roles"),
]
operations = [
migrations.AddField(
model_name="user",
name="managed_by",
field=models.ForeignKey(
blank=True,
limit_choices_to={"status__in": ("buh", "bu_admin")},
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="managed_gms",
to=settings.AUTH_USER_MODEL,
),
),
]
@@ -0,0 +1,28 @@
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
("accounts", "0006_user_managed_by"),
]
operations = [
migrations.AddField(
model_name="user",
name="managed_by_pending",
field=models.ForeignKey(
blank=True,
limit_choices_to={"status__in": ("buh", "bu_admin")},
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="pending_managed_gms",
to="accounts.user",
),
),
migrations.AddField(
model_name="user",
name="managed_by_requested_at",
field=models.DateTimeField(blank=True, null=True),
),
]
@@ -0,0 +1,25 @@
# Generated manually for managed_by_pending BUH-only limit
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
("accounts", "0007_user_managed_by_pending"),
]
operations = [
migrations.AlterField(
model_name="user",
name="managed_by_pending",
field=models.ForeignKey(
blank=True,
limit_choices_to={"status": "buh"},
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="pending_managed_gms",
to="accounts.user",
),
),
]
@@ -0,0 +1,30 @@
# Generated manually for account_admin status
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("accounts", "0008_alter_managed_by_pending_buh_only"),
]
operations = [
migrations.AlterField(
model_name="user",
name="status",
field=models.CharField(
choices=[
("inactive", "Inactive"),
("gm", "GM"),
("gm_admin", "GM Admin"),
("buh", "BUH"),
("bu_admin", "BU Admin"),
("director", "Director"),
("director_admin", "Director Admin"),
("account_admin", "Account Admin"),
],
default="gm",
max_length=30,
),
),
]
@@ -0,0 +1,22 @@
from django.db import migrations
def strip_director_admin_superuser(apps, schema_editor):
User = apps.get_model("accounts", "User")
User.objects.filter(status="director_admin", is_superuser=True).update(
is_superuser=False
)
class Migration(migrations.Migration):
dependencies = [
("accounts", "0009_account_admin_status"),
]
operations = [
migrations.RunPython(strip_director_admin_superuser, migrations.RunPython.noop),
migrations.RemoveField(
model_name="user",
name="is_staff",
),
]
@@ -0,0 +1,39 @@
from django.db import migrations, models
def migrate_break_glass_to_superuser_status(apps, schema_editor):
User = apps.get_model("accounts", "User")
User.objects.filter(is_superuser=True).exclude(status="inactive").update(
status="superuser"
)
class Migration(migrations.Migration):
dependencies = [
("accounts", "0010_remove_is_staff_decouple_director_admin"),
]
operations = [
migrations.AlterField(
model_name="user",
name="status",
field=models.CharField(
choices=[
("inactive", "Inactive"),
("gm", "GM"),
("gm_admin", "GM Admin"),
("buh", "BUH"),
("bu_admin", "BU Admin"),
("director", "Director"),
("director_admin", "Director Admin"),
("account_admin", "Account Admin"),
("superuser", "Superuser"),
],
default="gm",
max_length=30,
),
),
migrations.RunPython(
migrate_break_glass_to_superuser_status, migrations.RunPython.noop
),
]
Whitespace-only changes.
+239
View File
@@ -0,0 +1,239 @@
from __future__ import annotations
import hashlib
import secrets
from django.contrib.auth.models import AbstractBaseUser, BaseUserManager, PermissionsMixin
from django.db import models
from django.utils import timezone
class UserManager(BaseUserManager):
def create_user(self, user_name: str, password: str | None = None, **extra_fields):
if not user_name:
raise ValueError("user_name is required")
extra_fields.setdefault("status", User.STATUS_GM)
extra_fields.setdefault("is_superuser", False)
user = self.model(user_name=user_name, **extra_fields)
user.set_password(password)
user.save(using=self._db)
return user
def create_superuser(self, user_name: str, password: str | None = None, **extra_fields):
"""Break-glass: status=superuser (ops + user management). Hidden from user lists."""
extra_fields["status"] = User.STATUS_SUPERUSER
extra_fields["is_superuser"] = True
return self.create_user(user_name, password, **extra_fields)
class User(AbstractBaseUser, PermissionsMixin):
"""HQ executive user. Roles are distinct from site dashboard active/superadmin."""
STATUS_INACTIVE = "inactive"
STATUS_GM = "gm"
STATUS_GM_ADMIN = "gm_admin"
STATUS_BUH = "buh"
STATUS_BU_ADMIN = "bu_admin"
STATUS_DIRECTOR = "director"
STATUS_DIRECTOR_ADMIN = "director_admin"
STATUS_ACCOUNT_ADMIN = "account_admin"
STATUS_SUPERUSER = "superuser"
STATUS_CHOICES = [
(STATUS_INACTIVE, "Inactive"),
(STATUS_GM, "GM"),
(STATUS_GM_ADMIN, "GM Admin"),
(STATUS_BUH, "BUH"),
(STATUS_BU_ADMIN, "BU Admin"),
(STATUS_DIRECTOR, "Director"),
(STATUS_DIRECTOR_ADMIN, "Director Admin"),
(STATUS_ACCOUNT_ADMIN, "Account Admin"),
(STATUS_SUPERUSER, "Superuser"),
]
# Roles that may sign in and use the executive dashboard.
EXECUTIVE_STATUSES = frozenset(
{
STATUS_GM,
STATUS_GM_ADMIN,
STATUS_BUH,
STATUS_BU_ADMIN,
STATUS_DIRECTOR,
STATUS_DIRECTOR_ADMIN,
STATUS_ACCOUNT_ADMIN,
STATUS_SUPERUSER,
}
)
# BU / Director "*_admin" are view-only (same as buh / director).
# Kept for labeling only — no extra write powers.
ADMIN_STATUSES = frozenset(
{
STATUS_BU_ADMIN,
STATUS_DIRECTOR_ADMIN,
}
)
user_id = models.BigAutoField(primary_key=True)
user_name = models.CharField(max_length=30, unique=True)
status = models.CharField(max_length=30, choices=STATUS_CHOICES, default=STATUS_GM)
# BUH who manages this GM / GM Admin (unused for director / buh themselves).
managed_by = models.ForeignKey(
"self",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="managed_gms",
limit_choices_to={"status__in": ("buh", "bu_admin")},
)
# GM-requested BUH waiting for confirmation (BUH only — not BU Admin).
managed_by_pending = models.ForeignKey(
"self",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="pending_managed_gms",
limit_choices_to={"status": "buh"},
)
managed_by_requested_at = models.DateTimeField(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)
objects = UserManager()
USERNAME_FIELD = "user_name"
REQUIRED_FIELDS: list[str] = []
class Meta:
db_table = "user_access"
ordering = ["user_name"]
def __str__(self) -> str:
return self.user_name
@property
def is_executive(self) -> bool:
return self.status in self.EXECUTIVE_STATUSES
@property
def is_executive_admin(self) -> bool:
return self.status in self.ADMIN_STATUSES
def request_managed_by(self, buh: "User") -> None:
"""GM asks to report under a BUH (pending until that BUH confirms)."""
from django.core.exceptions import ValidationError
if self.status != self.STATUS_GM:
raise ValidationError("Only GM can request a BUH.")
if buh.status != self.STATUS_BUH:
raise ValidationError("Target must be a BUH (not BU Admin).")
if self.managed_by_id == buh.pk and self.managed_by_pending_id is None:
raise ValidationError("Already managed by this BUH.")
self.managed_by_pending = buh
self.managed_by_requested_at = timezone.now()
self.save(
update_fields=["managed_by_pending", "managed_by_requested_at", "updated_at"]
)
def cancel_managed_by_request(self) -> None:
if self.managed_by_pending_id is None:
return
self.managed_by_pending = None
self.managed_by_requested_at = None
self.save(
update_fields=["managed_by_pending", "managed_by_requested_at", "updated_at"]
)
def approve_managed_by(self, approver: "User") -> None:
from django.core.exceptions import ValidationError
if self.managed_by_pending_id is None:
raise ValidationError("No pending BUH request.")
if approver.pk != self.managed_by_pending_id:
raise ValidationError("Only the requested BUH can approve this request.")
if approver.status != self.STATUS_BUH:
raise ValidationError("Only a BUH (not BU Admin) can approve this request.")
self.managed_by = self.managed_by_pending
self.managed_by_pending = None
self.managed_by_requested_at = None
self.save(
update_fields=[
"managed_by",
"managed_by_pending",
"managed_by_requested_at",
"updated_at",
]
)
def reject_managed_by(self, approver: "User") -> None:
from django.core.exceptions import ValidationError
if self.managed_by_pending_id is None:
raise ValidationError("No pending BUH request.")
if approver.pk != self.managed_by_pending_id:
raise ValidationError("Only the requested BUH can reject this request.")
if approver.status != self.STATUS_BUH:
raise ValidationError("Only a BUH (not BU Admin) can reject this request.")
self.managed_by_pending = None
self.managed_by_requested_at = None
self.save(
update_fields=["managed_by_pending", "managed_by_requested_at", "updated_at"]
)
def save(self, *args, **kwargs):
# Product roles never use Django is_staff. status=superuser is break-glass
# (ops + user management) and always carries is_superuser.
if self.status == self.STATUS_INACTIVE:
self.is_active = False
self.is_superuser = False
elif self.status == self.STATUS_SUPERUSER:
self.is_active = True
self.is_superuser = True
else:
self.is_active = True
self.is_superuser = False
super().save(*args, **kwargs)
class ApiKey(models.Model):
"""Hashed API keys for X-API-Key authentication (inbound to HQ API)."""
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="api_keys")
name = models.CharField(max_length=100)
prefix = models.CharField(max_length=12, db_index=True)
key_hash = models.CharField(max_length=64)
is_active = models.BooleanField(default=True)
last_used_at = models.DateTimeField(null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = "api_keys"
ordering = ["-created_at"]
def __str__(self) -> str:
return f"{self.name} ({self.prefix}…)"
@staticmethod
def hash_key(raw_key: str) -> str:
return hashlib.sha256(raw_key.encode("utf-8")).hexdigest()
@classmethod
def generate(cls, user: User, name: str) -> tuple[ApiKey, str]:
raw = secrets.token_urlsafe(32)
prefix = raw[:8]
instance = cls.objects.create(
user=user,
name=name,
prefix=prefix,
key_hash=cls.hash_key(raw),
)
return instance, raw
def verify(self, raw_key: str) -> bool:
return secrets.compare_digest(self.key_hash, self.hash_key(raw_key))
def touch(self) -> None:
self.last_used_at = timezone.now()
self.save(update_fields=["last_used_at", "updated_at"])
+145
View File
@@ -0,0 +1,145 @@
"""HQ executive role helpers and DRF permission classes.
Settled matrix (v1):
- buh, director (+ their admins until specified otherwise): view only
- gm + gm_admin: view + register city sites (URL) + sync from cities
- gm only (not gm_admin): approve cycle close
"""
from __future__ import annotations
from rest_framework import permissions
from apps.accounts.models import User
# View-only executives (no site registry, sync, or approvals).
VIEW_ONLY_STATUSES = frozenset(
{
User.STATUS_BUH,
User.STATUS_BU_ADMIN,
User.STATUS_DIRECTOR,
User.STATUS_DIRECTOR_ADMIN,
}
)
# Can register city dashboard URLs into the HQ mirror registry.
SITE_REGISTRY_STATUSES = frozenset(
{
User.STATUS_GM,
User.STATUS_GM_ADMIN,
}
)
# Can trigger city → HQ mirror sync.
SYNC_STATUSES = frozenset(
{
User.STATUS_GM,
User.STATUS_GM_ADMIN,
}
)
# Cycle close approval — GM only (explicitly not gm_admin).
CYCLE_CLOSE_APPROVER_STATUSES = frozenset(
{
User.STATUS_GM,
}
)
# Product user-management role (IT Central). Django superuser remains break-glass.
USER_ADMIN_STATUSES = frozenset(
{
User.STATUS_ACCOUNT_ADMIN,
}
)
def _status(user) -> str | None:
return getattr(user, "status", None) if user else None
def executive_user(user) -> bool:
return bool(
user
and getattr(user, "is_authenticated", False)
and _status(user) in User.EXECUTIVE_STATUSES
)
def can_sync(user) -> bool:
return bool(
user
and getattr(user, "is_authenticated", False)
and _status(user) in SYNC_STATUSES
)
def can_register_city_site(user) -> bool:
"""GM / GM Admin may add city dashboard URLs to the HQ registry."""
return bool(
user
and getattr(user, "is_authenticated", False)
and _status(user) in SITE_REGISTRY_STATUSES
)
def can_approve_cycle_close(user) -> bool:
"""Only GM (not gm_admin) may approve closing a cycle."""
return bool(
user
and getattr(user, "is_authenticated", False)
and _status(user) in CYCLE_CLOSE_APPROVER_STATUSES
)
def can_write_operations(user) -> bool:
"""No manual ops create/edit/delete for executive roles."""
return False
def can_manage_users(user) -> bool:
"""Account Admin (IT Central) or Django superuser break-glass."""
if not user or not getattr(user, "is_authenticated", False):
return False
if getattr(user, "is_superuser", False):
return True
return _status(user) in USER_ADMIN_STATUSES
def can_manage_city_registry(user) -> bool:
"""Alias used by city-site write endpoints — same as register permission."""
return can_register_city_site(user) or bool(getattr(user, "is_superuser", False))
class IsExecutive(permissions.BasePermission):
def has_permission(self, request, view):
return executive_user(request.user)
class IsExecutiveAdmin(permissions.BasePermission):
def has_permission(self, request, view):
return can_manage_users(request.user)
class CanRegisterCitySite(permissions.BasePermission):
def has_permission(self, request, view):
return can_register_city_site(request.user) or bool(
getattr(request.user, "is_superuser", False)
)
class CanApproveCycleClose(permissions.BasePermission):
def has_permission(self, request, view):
return can_approve_cycle_close(request.user)
class ReadOrSyncOnly(permissions.BasePermission):
"""Safe methods for all executives; sync actions for GM / GM Admin only."""
def has_permission(self, request, view):
if not executive_user(request.user):
return False
if request.method in permissions.SAFE_METHODS:
return True
if getattr(view, "executive_sync_action", False):
return can_sync(request.user)
return can_write_operations(request.user)
+177
View File
@@ -0,0 +1,177 @@
"""Visibility scoping for HQ executives.
- director / director_admin: all sites / city sites / GMs
- buh / bu_admin: only GMs (and gm_admins) they manage, plus those GMs' sites
- gm / gm_admin: only their own sites / city sites they registered
Farm Site rows are only visible to non-directors when linked to an active
CitySite in the viewer's Pengaturan registry.
"""
from __future__ import annotations
from django.db.models import QuerySet
from apps.accounts.models import User
DIRECTOR_STATUSES = frozenset(
{
User.STATUS_DIRECTOR,
User.STATUS_DIRECTOR_ADMIN,
User.STATUS_SUPERUSER,
}
)
BUH_STATUSES = frozenset(
{
User.STATUS_BUH,
User.STATUS_BU_ADMIN,
}
)
GM_STATUSES = frozenset(
{
User.STATUS_GM,
User.STATUS_GM_ADMIN,
}
)
def is_director_scope(user: User) -> bool:
return bool(user and user.status in DIRECTOR_STATUSES)
def is_buh_scope(user: User) -> bool:
return bool(user and user.status in BUH_STATUSES)
def is_gm_scope(user: User) -> bool:
return bool(user and user.status in GM_STATUSES)
def managed_gm_ids(buh: User) -> list[int]:
"""User pks for GMs / GM Admins reporting to this BUH."""
return list(
User.objects.filter(
managed_by=buh,
status__in=GM_STATUSES,
).values_list("pk", flat=True)
)
def visible_owner_ids(user: User) -> list[int] | None:
"""
Site.user / CitySite.registered_by ids this viewer may see.
Returns None when there is no restriction (director / superuser).
"""
if is_director_scope(user):
return None
if is_buh_scope(user):
return managed_gm_ids(user)
if is_gm_scope(user):
return [user.pk]
return []
def visible_active_city_site_ids(user: User) -> list[int] | None:
"""
Active CitySite pks in this viewer's Pengaturan registry.
Returns None for directors (no restriction). Empty list means no farm sites.
"""
from apps.sync.models import CitySite
owners = visible_owner_ids(user)
if owners is None:
return None
if not owners:
return []
return list(
CitySite.objects.filter(
registered_by_id__in=owners,
is_active=True,
).values_list("pk", flat=True)
)
def is_account_admin_scope(user: User) -> bool:
return bool(user and user.status == User.STATUS_ACCOUNT_ADMIN)
def filter_users_queryset(qs: QuerySet, viewer: User) -> QuerySet:
"""BUH sees managed GMs; director / account_admin see product users.
Break-glass status=superuser is never listed in the user directory.
"""
qs = qs.exclude(status=User.STATUS_SUPERUSER)
if is_director_scope(viewer) or is_account_admin_scope(viewer):
return qs
if is_buh_scope(viewer):
return qs.filter(pk__in=managed_gm_ids(viewer))
if is_gm_scope(viewer):
return qs.filter(pk=viewer.pk)
return qs.none()
def filter_sites_queryset(qs: QuerySet, viewer: User) -> QuerySet:
owners = visible_owner_ids(viewer)
if owners is None:
return qs
if not owners:
return qs.none()
city_ids = visible_active_city_site_ids(viewer)
if not city_ids:
return qs.none()
return qs.filter(user_id__in=owners, city_site_id__in=city_ids)
def filter_city_sites_queryset(qs: QuerySet, viewer: User) -> QuerySet:
owners = visible_owner_ids(viewer)
if owners is None:
return qs
if not owners:
return qs.none()
return qs.filter(registered_by_id__in=owners)
def filter_kandangs_queryset(qs: QuerySet, viewer: User) -> QuerySet:
owners = visible_owner_ids(viewer)
if owners is None:
return qs
if not owners:
return qs.none()
city_ids = visible_active_city_site_ids(viewer)
if not city_ids:
return qs.none()
return qs.filter(site__user_id__in=owners, site__city_site_id__in=city_ids)
def filter_flocks_queryset(qs: QuerySet, viewer: User) -> QuerySet:
owners = visible_owner_ids(viewer)
if owners is None:
return qs
if not owners:
return qs.none()
city_ids = visible_active_city_site_ids(viewer)
if not city_ids:
return qs.none()
return qs.filter(
kandang__site__user_id__in=owners,
kandang__site__city_site_id__in=city_ids,
)
def filter_cycles_queryset(qs: QuerySet, viewer: User) -> QuerySet:
owners = visible_owner_ids(viewer)
if owners is None:
return qs
if not owners:
return qs.none()
city_ids = visible_active_city_site_ids(viewer)
if not city_ids:
return qs.none()
return qs.filter(
kandang__site__user_id__in=owners,
kandang__site__city_site_id__in=city_ids,
)
+184
View File
@@ -0,0 +1,184 @@
from django.contrib.auth import get_user_model
from rest_framework import serializers
from apps.accounts.models import ApiKey
User = get_user_model()
class PkAsIdMixin(serializers.Serializer):
"""Keep JSON `id` stable after ERD-named primary keys."""
id = serializers.IntegerField(source="pk", read_only=True)
class UserSerializer(PkAsIdMixin, serializers.ModelSerializer):
managed_by = serializers.PrimaryKeyRelatedField(
queryset=User.objects.filter(status__in=["buh", "bu_admin"]),
allow_null=True,
required=False,
)
managed_by_name = serializers.CharField(
source="managed_by.user_name", read_only=True, allow_null=True
)
managed_by_pending = serializers.PrimaryKeyRelatedField(read_only=True)
managed_by_pending_name = serializers.CharField(
source="managed_by_pending.user_name", read_only=True, allow_null=True
)
class Meta:
model = User
fields = [
"id",
"user_name",
"status",
"is_superuser",
"managed_by",
"managed_by_name",
"managed_by_pending",
"managed_by_pending_name",
"managed_by_requested_at",
"created_at",
"updated_at",
]
read_only_fields = [
"id",
"is_superuser",
"managed_by_name",
"managed_by_pending",
"managed_by_pending_name",
"managed_by_requested_at",
"created_at",
"updated_at",
]
class UserWriteSerializer(PkAsIdMixin, serializers.ModelSerializer):
"""Create/update users for Account Admin. Password required on create."""
password = serializers.CharField(write_only=True, required=False, allow_blank=False)
managed_by = serializers.PrimaryKeyRelatedField(
queryset=User.objects.filter(status__in=["buh", "bu_admin"]),
allow_null=True,
required=False,
)
managed_by_name = serializers.CharField(
source="managed_by.user_name", read_only=True, allow_null=True
)
managed_by_pending = serializers.PrimaryKeyRelatedField(read_only=True)
managed_by_pending_name = serializers.CharField(
source="managed_by_pending.user_name", read_only=True, allow_null=True
)
class Meta:
model = User
fields = [
"id",
"user_name",
"status",
"is_superuser",
"password",
"managed_by",
"managed_by_name",
"managed_by_pending",
"managed_by_pending_name",
"managed_by_requested_at",
"created_at",
"updated_at",
]
read_only_fields = [
"id",
"is_superuser",
"managed_by_name",
"managed_by_pending",
"managed_by_pending_name",
"managed_by_requested_at",
"created_at",
"updated_at",
]
def validate_password(self, value: str) -> str:
if value is not None and not str(value).strip():
raise serializers.ValidationError("Password cannot be blank.")
return value
def validate(self, attrs):
if self.instance is None and not attrs.get("password"):
raise serializers.ValidationError({"password": "This field is required."})
new_status = attrs.get("status")
if new_status == User.STATUS_SUPERUSER:
raise serializers.ValidationError(
{"status": "Superuser accounts cannot be created or assigned here."}
)
request = self.context.get("request")
actor = getattr(request, "user", None) if request else None
instance = self.instance
if (
instance is not None
and actor is not None
and getattr(actor, "is_authenticated", False)
and instance.pk == actor.pk
and "status" in attrs
and attrs["status"] != instance.status
):
if attrs["status"] == User.STATUS_INACTIVE:
raise serializers.ValidationError(
{"status": "You cannot deactivate your own account."}
)
raise serializers.ValidationError(
{"status": "You cannot change your own account status."}
)
return attrs
def create(self, validated_data):
password = validated_data.pop("password")
user_name = validated_data.pop("user_name")
return User.objects.create_user(
user_name=user_name, password=password, **validated_data
)
def update(self, instance, validated_data):
password = validated_data.pop("password", None)
for attr, value in validated_data.items():
setattr(instance, attr, value)
if password:
instance.set_password(password)
instance.save()
return instance
class SetPasswordSerializer(serializers.Serializer):
password = serializers.CharField(write_only=True, allow_blank=False)
def validate_password(self, value: str) -> str:
if not str(value).strip():
raise serializers.ValidationError("Password cannot be blank.")
return value
class LoginSerializer(serializers.Serializer):
user_name = serializers.CharField(max_length=30)
password = serializers.CharField(write_only=True)
class ApiKeySerializer(serializers.ModelSerializer):
class Meta:
model = ApiKey
fields = [
"id",
"name",
"prefix",
"is_active",
"last_used_at",
"created_at",
"updated_at",
"user",
]
read_only_fields = fields
class ApiKeyCreateSerializer(serializers.Serializer):
name = serializers.CharField(max_length=100)
user_id = serializers.IntegerField(required=False)
@@ -0,0 +1,227 @@
from django.contrib.auth import authenticate
from django.test import TestCase
from rest_framework.test import APIClient
from apps.accounts.models import User
class AccountAdminModelTests(TestCase):
def test_account_admin_flags_and_executive(self):
user = User.objects.create_user(
user_name="acct1",
password="secret",
status=User.STATUS_ACCOUNT_ADMIN,
)
self.assertTrue(user.is_active)
self.assertFalse(user.is_superuser)
self.assertTrue(user.is_executive)
def test_inactive_clears_flags(self):
user = User.objects.create_user(
user_name="acct2",
password="secret",
status=User.STATUS_ACCOUNT_ADMIN,
)
user.status = User.STATUS_INACTIVE
user.save()
user.refresh_from_db()
self.assertFalse(user.is_active)
self.assertFalse(user.is_superuser)
def test_director_admin_is_not_superuser(self):
user = User.objects.create_user(
user_name="diradmin1",
password="secret",
status=User.STATUS_DIRECTOR_ADMIN,
)
self.assertEqual(user.status, User.STATUS_DIRECTOR_ADMIN)
self.assertFalse(user.is_superuser)
class AccountAdminApiTests(TestCase):
def setUp(self):
self.client = APIClient()
self.account_admin = User.objects.create_user(
user_name="acctadmin",
password="acct123",
status=User.STATUS_ACCOUNT_ADMIN,
)
self.gm = User.objects.create_user(
user_name="gm1", password="gm123", status=User.STATUS_GM
)
self.buh = User.objects.create_user(
user_name="buh1", password="buh123", status=User.STATUS_BUH
)
self.director = User.objects.create_user(
user_name="dir1", password="dir123", status=User.STATUS_DIRECTOR
)
self.superuser = User.objects.create_superuser(
user_name="admin1",
password="admin123",
)
self.assertTrue(self.superuser.is_superuser)
self.assertEqual(self.superuser.status, User.STATUS_SUPERUSER)
def test_account_admin_creates_user_with_password(self):
self.client.force_authenticate(self.account_admin)
response = self.client.post(
"/api/v1/users/",
{
"user_name": "newgm",
"status": User.STATUS_GM,
"password": "temp-pass",
},
format="json",
)
self.assertEqual(response.status_code, 201, response.data)
created = User.objects.get(user_name="newgm")
self.assertEqual(created.status, User.STATUS_GM)
self.assertIsNotNone(
authenticate(username="newgm", password="temp-pass")
)
def test_account_admin_create_requires_password(self):
self.client.force_authenticate(self.account_admin)
response = self.client.post(
"/api/v1/users/",
{"user_name": "nopass", "status": User.STATUS_GM},
format="json",
)
self.assertEqual(response.status_code, 400)
self.assertIn("password", response.data)
def test_gm_cannot_create_user(self):
self.client.force_authenticate(self.gm)
response = self.client.post(
"/api/v1/users/",
{
"user_name": "blocked",
"status": User.STATUS_GM,
"password": "x",
},
format="json",
)
self.assertEqual(response.status_code, 403)
def test_account_admin_lists_all_users(self):
self.client.force_authenticate(self.account_admin)
response = self.client.get("/api/v1/users/")
self.assertEqual(response.status_code, 200)
names = {row["user_name"] for row in response.data["results"]}
self.assertIn("gm1", names)
self.assertIn("buh1", names)
self.assertIn("dir1", names)
self.assertIn("acctadmin", names)
def test_account_admin_set_password(self):
self.client.force_authenticate(self.account_admin)
response = self.client.post(
f"/api/v1/users/{self.gm.pk}/set-password/",
{"password": "new-gm-pass"},
format="json",
)
self.assertEqual(response.status_code, 200, response.data)
self.assertIsNotNone(
authenticate(username="gm1", password="new-gm-pass")
)
self.assertIsNone(authenticate(username="gm1", password="gm123"))
def test_account_admin_deactivate_blocks_login(self):
self.client.force_authenticate(self.account_admin)
response = self.client.patch(
f"/api/v1/users/{self.gm.pk}/",
{"status": User.STATUS_INACTIVE},
format="json",
)
self.assertEqual(response.status_code, 200, response.data)
self.gm.refresh_from_db()
self.assertFalse(self.gm.is_active)
self.assertIsNone(authenticate(username="gm1", password="gm123"))
def test_buh_and_director_cannot_set_password(self):
for actor in (self.buh, self.director):
self.client.force_authenticate(actor)
response = self.client.post(
f"/api/v1/users/{self.gm.pk}/set-password/",
{"password": "hack"},
format="json",
)
self.assertEqual(response.status_code, 403, actor.user_name)
def test_superuser_break_glass_create(self):
self.client.force_authenticate(self.superuser)
response = self.client.post(
"/api/v1/users/",
{
"user_name": "breakglass",
"status": User.STATUS_BUH,
"password": "bg-pass",
},
format="json",
)
self.assertEqual(response.status_code, 201, response.data)
def test_account_admin_cannot_deactivate_self(self):
self.client.force_authenticate(self.account_admin)
response = self.client.patch(
f"/api/v1/users/{self.account_admin.pk}/",
{"status": User.STATUS_INACTIVE},
format="json",
)
self.assertEqual(response.status_code, 400)
self.assertIn("status", response.data)
self.account_admin.refresh_from_db()
self.assertEqual(self.account_admin.status, User.STATUS_ACCOUNT_ADMIN)
self.assertTrue(self.account_admin.is_active)
def test_account_admin_cannot_change_own_status(self):
self.client.force_authenticate(self.account_admin)
response = self.client.patch(
f"/api/v1/users/{self.account_admin.pk}/",
{"status": User.STATUS_GM},
format="json",
)
self.assertEqual(response.status_code, 400)
self.assertIn("status", response.data)
self.account_admin.refresh_from_db()
self.assertEqual(self.account_admin.status, User.STATUS_ACCOUNT_ADMIN)
def test_peer_account_admin_can_deactivate_other_account_admin(self):
peer = User.objects.create_user(
user_name="acctadmin2",
password="acct123",
status=User.STATUS_ACCOUNT_ADMIN,
)
self.client.force_authenticate(self.account_admin)
response = self.client.patch(
f"/api/v1/users/{peer.pk}/",
{"status": User.STATUS_INACTIVE},
format="json",
)
self.assertEqual(response.status_code, 200, response.data)
peer.refresh_from_db()
self.assertEqual(peer.status, User.STATUS_INACTIVE)
self.assertFalse(peer.is_active)
def test_superuser_hidden_from_user_list(self):
User.objects.create_superuser(user_name="hidden_su", password="x")
self.client.force_authenticate(self.account_admin)
response = self.client.get("/api/v1/users/")
self.assertEqual(response.status_code, 200)
names = {row["user_name"] for row in response.data["results"]}
self.assertNotIn("hidden_su", names)
self.assertNotIn("admin1", names)
def test_cannot_assign_superuser_status_via_api(self):
self.client.force_authenticate(self.account_admin)
response = self.client.post(
"/api/v1/users/",
{
"user_name": "bad_su",
"status": User.STATUS_SUPERUSER,
"password": "x",
},
format="json",
)
self.assertEqual(response.status_code, 400)
self.assertIn("status", response.data)
+99
View File
@@ -0,0 +1,99 @@
from django.test import TestCase
from rest_framework.test import APIClient
from apps.accounts.models import User
class ManagedByRequestTests(TestCase):
def setUp(self):
self.client = APIClient()
self.buh = User.objects.create_user(
user_name="buh1", password="x", status=User.STATUS_BUH
)
self.buh_other = User.objects.create_user(
user_name="buh2", password="x", status=User.STATUS_BUH
)
self.gm = User.objects.create_user(
user_name="gm1", password="x", status=User.STATUS_GM
)
def test_gm_requests_and_buh_approves(self):
self.client.force_authenticate(self.gm)
response = self.client.post(
"/api/v1/users/request-managed-by/", {"buh_id": self.buh.pk}, format="json"
)
self.assertEqual(response.status_code, 200)
self.gm.refresh_from_db()
self.assertIsNone(self.gm.managed_by_id)
self.assertEqual(self.gm.managed_by_pending_id, self.buh.pk)
self.client.force_authenticate(self.buh)
pending = self.client.get("/api/v1/users/pending-managed/")
self.assertEqual(pending.status_code, 200)
self.assertEqual({row["user_name"] for row in pending.data}, {"gm1"})
approve = self.client.post(f"/api/v1/users/{self.gm.pk}/approve-managed-by/", {})
self.assertEqual(approve.status_code, 200)
self.gm.refresh_from_db()
self.assertEqual(self.gm.managed_by_id, self.buh.pk)
self.assertIsNone(self.gm.managed_by_pending_id)
managed = self.client.get("/api/v1/users/")
self.assertEqual({row["user_name"] for row in managed.data["results"]}, {"gm1"})
def test_other_buh_cannot_approve(self):
self.gm.request_managed_by(self.buh)
self.client.force_authenticate(self.buh_other)
response = self.client.post(f"/api/v1/users/{self.gm.pk}/approve-managed-by/", {})
self.assertEqual(response.status_code, 404)
self.gm.refresh_from_db()
self.assertIsNone(self.gm.managed_by_id)
self.assertEqual(self.gm.managed_by_pending_id, self.buh.pk)
def test_gm_can_cancel_request(self):
self.gm.request_managed_by(self.buh)
self.client.force_authenticate(self.gm)
response = self.client.post("/api/v1/users/cancel-managed-by-request/", {})
self.assertEqual(response.status_code, 200)
self.gm.refresh_from_db()
self.assertIsNone(self.gm.managed_by_pending_id)
def test_gm_lists_buhs_only(self):
User.objects.create_user(
user_name="buadmin1", password="x", status=User.STATUS_BU_ADMIN
)
self.client.force_authenticate(self.gm)
response = self.client.get("/api/v1/users/buhs/")
self.assertEqual(response.status_code, 200)
names = {row["user_name"] for row in response.data}
self.assertEqual(names, {"buh1", "buh2"})
def test_gm_admin_cannot_request_buh(self):
gm_admin = User.objects.create_user(
user_name="gma1", password="x", status=User.STATUS_GM_ADMIN
)
self.client.force_authenticate(gm_admin)
response = self.client.post(
"/api/v1/users/request-managed-by/", {"buh_id": self.buh.pk}, format="json"
)
self.assertEqual(response.status_code, 403)
def test_gm_cannot_request_bu_admin(self):
bu_admin = User.objects.create_user(
user_name="buadmin1", password="x", status=User.STATUS_BU_ADMIN
)
self.client.force_authenticate(self.gm)
response = self.client.post(
"/api/v1/users/request-managed-by/", {"buh_id": bu_admin.pk}, format="json"
)
self.assertEqual(response.status_code, 400)
def test_bu_admin_cannot_approve(self):
bu_admin = User.objects.create_user(
user_name="buadmin1", password="x", status=User.STATUS_BU_ADMIN
)
self.gm.managed_by_pending = bu_admin
self.gm.save(update_fields=["managed_by_pending", "updated_at"])
self.client.force_authenticate(bu_admin)
response = self.client.post(f"/api/v1/users/{self.gm.pk}/approve-managed-by/", {})
self.assertEqual(response.status_code, 403)
+118
View File
@@ -0,0 +1,118 @@
from django.test import TestCase
from rest_framework.test import APIClient
from apps.accounts.models import User
from apps.farms.models import Kandang, Site
from apps.sync.models import CitySite
class VisibilityScopeTests(TestCase):
def setUp(self):
self.client = APIClient()
self.director = User.objects.create_user(
user_name="dir1", password="x", status=User.STATUS_DIRECTOR
)
self.buh = User.objects.create_user(
user_name="buh1", password="x", status=User.STATUS_BUH
)
self.buh_other = User.objects.create_user(
user_name="buh2", password="x", status=User.STATUS_BUH
)
self.gm = User.objects.create_user(
user_name="gm1", password="x", status=User.STATUS_GM, managed_by=self.buh
)
self.gm_other = User.objects.create_user(
user_name="gm2",
password="x",
status=User.STATUS_GM,
managed_by=self.buh_other,
)
self.city_a = CitySite.objects.create(
code="a",
name="City A",
api_base_url="https://a.example/api/v1",
registered_by=self.gm,
)
self.city_b = CitySite.objects.create(
code="b",
name="City B",
api_base_url="https://b.example/api/v1",
registered_by=self.gm_other,
)
self.site = Site.objects.create(
site_name="Site A", user=self.gm, city_site=self.city_a
)
self.site_other = Site.objects.create(
site_name="Site B", user=self.gm_other, city_site=self.city_b
)
self.orphan = Site.objects.create(site_name="Orphan Seed", user=self.gm)
Kandang.objects.create(kandang_name="K1", site=self.site)
Kandang.objects.create(kandang_name="K2", site=self.site_other)
def test_director_sees_all_sites(self):
self.client.force_authenticate(self.director)
response = self.client.get("/api/v1/sites/")
self.assertEqual(response.status_code, 200)
names = {row["site_name"] for row in response.data["results"]}
self.assertEqual(names, {"Site A", "Site B", "Orphan Seed"})
def test_buh_sees_only_managed_gm_registered_sites(self):
self.client.force_authenticate(self.buh)
response = self.client.get("/api/v1/sites/")
self.assertEqual(response.status_code, 200)
names = {row["site_name"] for row in response.data["results"]}
self.assertEqual(names, {"Site A"})
def test_buh_sees_only_managed_gms(self):
self.client.force_authenticate(self.buh)
response = self.client.get("/api/v1/users/")
self.assertEqual(response.status_code, 200)
names = {row["user_name"] for row in response.data["results"]}
self.assertEqual(names, {"gm1"})
def test_gm_sees_own_city_sites_only(self):
self.client.force_authenticate(self.gm)
response = self.client.get("/api/v1/city-sites/")
self.assertEqual(response.status_code, 200)
codes = {row["code"] for row in response.data["results"]}
self.assertEqual(codes, {"a"})
def test_gm_sees_only_registered_linked_sites(self):
self.client.force_authenticate(self.gm)
response = self.client.get("/api/v1/sites/")
self.assertEqual(response.status_code, 200)
names = {row["site_name"] for row in response.data["results"]}
self.assertEqual(names, {"Site A"})
def test_gm_with_empty_registry_sees_no_sites(self):
self.city_a.delete()
self.client.force_authenticate(self.gm)
response = self.client.get("/api/v1/sites/")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["results"], [])
def test_registering_city_site_attaches_matching_seed_site(self):
self.client.force_authenticate(self.gm)
response = self.client.post(
"/api/v1/city-sites/",
{
"code": "orphan-seed",
"name": "Orphan Seed",
"api_base_url": "https://orphan.example/api/v1",
"is_active": True,
},
format="json",
)
self.assertEqual(response.status_code, 201)
self.orphan.refresh_from_db()
self.assertEqual(self.orphan.city_site_id, response.data["id"])
listed = self.client.get("/api/v1/sites/")
names = {row["site_name"] for row in listed.data["results"]}
self.assertIn("Orphan Seed", names)
def test_director_sees_all_city_sites(self):
self.client.force_authenticate(self.director)
response = self.client.get("/api/v1/city-sites/")
self.assertEqual(response.status_code, 200)
codes = {row["code"] for row in response.data["results"]}
self.assertEqual(codes, {"a", "b"})
+23
View File
@@ -0,0 +1,23 @@
from django.urls import path
from rest_framework.routers import DefaultRouter
from apps.accounts.views import (
ApiKeyViewSet,
CsrfView,
LoginView,
LogoutView,
MeView,
UserViewSet,
)
router = DefaultRouter()
router.register("users", UserViewSet, basename="user")
router.register("api-keys", ApiKeyViewSet, basename="api-key")
urlpatterns = [
path("auth/csrf/", CsrfView.as_view(), name="auth-csrf"),
path("auth/login/", LoginView.as_view(), name="auth-login"),
path("auth/logout/", LogoutView.as_view(), name="auth-logout"),
path("auth/me/", MeView.as_view(), name="auth-me"),
*router.urls,
]
+258
View File
@@ -0,0 +1,258 @@
from django.contrib.auth import authenticate, login, logout
from django.core.exceptions import ValidationError
from django.middleware.csrf import get_token
from django.views.decorators.csrf import ensure_csrf_cookie
from django.utils.decorators import method_decorator
from rest_framework import permissions, status, viewsets
from rest_framework.decorators import action
from rest_framework.response import Response
from rest_framework.views import APIView
from apps.accounts.models import ApiKey, User
from apps.accounts.permissions import IsExecutive
from apps.accounts.scoping import filter_users_queryset
from apps.accounts.serializers import (
ApiKeyCreateSerializer,
ApiKeySerializer,
LoginSerializer,
SetPasswordSerializer,
UserSerializer,
UserWriteSerializer,
)
def _validation_detail(exc: ValidationError):
if hasattr(exc, "message_dict"):
return exc.message_dict
if hasattr(exc, "messages"):
return "; ".join(str(m) for m in exc.messages)
return str(getattr(exc, "message", exc))
class IsOwnerOrStaff(permissions.BasePermission):
def has_object_permission(self, request, view, obj):
if request.user.is_superuser:
return True
return getattr(obj, "user_id", None) == request.user.pk or obj == request.user
class IsAdminUser(permissions.BasePermission):
"""Write access for Account Admin or Django superuser break-glass."""
def has_permission(self, request, view):
from apps.accounts.permissions import can_manage_users
return can_manage_users(request.user)
class UserViewSet(viewsets.ModelViewSet):
"""
List/retrieve: scoped for executives (BUH → managed GMs; director / account_admin → all).
Create/update/delete/set-password: Account Admin or superuser.
GM/GM Admin can request a BUH; that BUH can approve/reject.
"""
serializer_class = UserSerializer
def get_queryset(self):
qs = User.objects.select_related("managed_by", "managed_by_pending").order_by(
"user_name"
)
return filter_users_queryset(qs, self.request.user)
def get_serializer_class(self):
if self.action in {"create", "update", "partial_update"}:
return UserWriteSerializer
return UserSerializer
def get_permissions(self):
if self.action in {
"list",
"retrieve",
"buhs",
"pending_managed",
"request_managed_by",
"cancel_managed_by_request",
"approve_managed_by",
"reject_managed_by",
}:
return [IsExecutive()]
return [IsAdminUser()]
@action(detail=True, methods=["post"], url_path="set-password")
def set_password(self, request, pk=None):
user = self.get_object()
serializer = SetPasswordSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
user.set_password(serializer.validated_data["password"])
user.save(update_fields=["password", "updated_at"])
return Response(UserSerializer(user).data)
@action(detail=False, methods=["get"], url_path="buhs")
def buhs(self, request):
"""List BUH accounts a GM can request to report under (not BU Admin)."""
if request.user.status != User.STATUS_GM and not request.user.is_superuser:
if request.user.status not in {
User.STATUS_DIRECTOR,
User.STATUS_DIRECTOR_ADMIN,
User.STATUS_BUH,
User.STATUS_BU_ADMIN,
}:
return Response({"detail": "Not allowed."}, status=status.HTTP_403_FORBIDDEN)
rows = User.objects.filter(
status=User.STATUS_BUH,
is_active=True,
).order_by("user_name")
return Response(UserSerializer(rows, many=True).data)
@action(detail=False, methods=["get"], url_path="pending-managed")
def pending_managed(self, request):
"""BUH only: GMs who requested to report under this account."""
if request.user.status != User.STATUS_BUH:
return Response({"detail": "Not allowed."}, status=status.HTTP_403_FORBIDDEN)
rows = User.objects.filter(
managed_by_pending=request.user,
status=User.STATUS_GM,
).select_related("managed_by", "managed_by_pending").order_by("user_name")
return Response(UserSerializer(rows, many=True).data)
@action(detail=False, methods=["post"], url_path="request-managed-by")
def request_managed_by(self, request):
"""GM only: request to report under a BUH (not BU Admin)."""
if request.user.status != User.STATUS_GM:
return Response({"detail": "Not allowed."}, status=status.HTTP_403_FORBIDDEN)
buh_id = request.data.get("buh_id")
try:
buh = User.objects.get(
pk=buh_id,
status=User.STATUS_BUH,
is_active=True,
)
except (User.DoesNotExist, TypeError, ValueError):
return Response({"detail": "Invalid buh_id."}, status=status.HTTP_400_BAD_REQUEST)
try:
request.user.request_managed_by(buh)
except ValidationError as exc:
return Response({"detail": _validation_detail(exc)}, status=status.HTTP_400_BAD_REQUEST)
return Response(UserSerializer(request.user).data)
@action(detail=False, methods=["post"], url_path="cancel-managed-by-request")
def cancel_managed_by_request(self, request):
if request.user.status != User.STATUS_GM:
return Response({"detail": "Not allowed."}, status=status.HTTP_403_FORBIDDEN)
request.user.cancel_managed_by_request()
return Response(UserSerializer(request.user).data)
@action(detail=True, methods=["post"], url_path="approve-managed-by")
def approve_managed_by(self, request, pk=None):
if request.user.status != User.STATUS_BUH:
return Response({"detail": "Not allowed."}, status=status.HTTP_403_FORBIDDEN)
gm = User.objects.filter(
pk=pk,
managed_by_pending=request.user,
status=User.STATUS_GM,
).first()
if gm is None:
return Response({"detail": "Not found."}, status=status.HTTP_404_NOT_FOUND)
try:
gm.approve_managed_by(request.user)
except ValidationError as exc:
return Response({"detail": _validation_detail(exc)}, status=status.HTTP_400_BAD_REQUEST)
return Response(UserSerializer(gm).data)
@action(detail=True, methods=["post"], url_path="reject-managed-by")
def reject_managed_by(self, request, pk=None):
if request.user.status != User.STATUS_BUH:
return Response({"detail": "Not allowed."}, status=status.HTTP_403_FORBIDDEN)
gm = User.objects.filter(
pk=pk,
managed_by_pending=request.user,
status=User.STATUS_GM,
).first()
if gm is None:
return Response({"detail": "Not found."}, status=status.HTTP_404_NOT_FOUND)
try:
gm.reject_managed_by(request.user)
except ValidationError as exc:
return Response({"detail": _validation_detail(exc)}, status=status.HTTP_400_BAD_REQUEST)
return Response(UserSerializer(gm).data)
class ApiKeyViewSet(viewsets.ModelViewSet):
http_method_names = ["get", "post", "delete", "head", "options"]
def get_queryset(self):
qs = ApiKey.objects.select_related("user").order_by("-created_at")
if self.request.user.is_superuser:
return qs
return qs.filter(user=self.request.user)
def get_serializer_class(self):
if self.action == "create":
return ApiKeyCreateSerializer
return ApiKeySerializer
def get_permissions(self):
return [permissions.IsAuthenticated()]
def create(self, request, *args, **kwargs):
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
name = serializer.validated_data["name"]
user = request.user
if request.user.is_superuser and serializer.validated_data.get("user_id"):
user = User.objects.get(pk=serializer.validated_data["user_id"])
api_key, raw = ApiKey.generate(user=user, name=name)
data = ApiKeySerializer(api_key).data
data["raw_key"] = raw
return Response(data, status=status.HTTP_201_CREATED)
def perform_destroy(self, instance):
if (
not self.request.user.is_superuser
and instance.user_id != self.request.user.pk
):
raise permissions.PermissionDenied()
instance.delete()
@method_decorator(ensure_csrf_cookie, name="dispatch")
class CsrfView(APIView):
permission_classes = [permissions.AllowAny]
authentication_classes = []
def get(self, request):
return Response({"csrfToken": get_token(request)})
class LoginView(APIView):
permission_classes = [permissions.AllowAny]
authentication_classes = []
def post(self, request):
serializer = LoginSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
user = authenticate(
request,
username=serializer.validated_data["user_name"],
password=serializer.validated_data["password"],
)
if user is None:
return Response(
{"detail": "Invalid credentials"},
status=status.HTTP_401_UNAUTHORIZED,
)
login(request, user)
return Response(UserSerializer(user).data)
class LogoutView(APIView):
def post(self, request):
logout(request)
return Response(status=status.HTTP_204_NO_CONTENT)
class MeView(APIView):
def get(self, request):
return Response(UserSerializer(request.user).data)