first commit
This commit is contained in:
commit
b54624be96
226 files changed
+108840
No files matched your search
Whitespace-only changes.
@@ -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"
|
||||
@@ -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.
@@ -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"])
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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"})
|
||||
@@ -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,
|
||||
]
|
||||
@@ -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)
|
||||
Reference in new issue
Block a user