adjust registered by to managed by on db aslo remove url and api key on adding sites for gm

This commit is contained in:
Alberto-Audrix committed 2026-09-11 16:18:04 +07:00
1 parent 64b37b6b6d
commit 521f36d22d
15 files changed
+112 -75

No files matched your search

+2 -2
View File
@@ -17,8 +17,8 @@ Main-office **mirror API** for the executive frontend. Domain shapes match site
`bootstrap_admin` / seed `admin` uses status `superuser`. This app does not use `is_staff`. `bootstrap_admin` / seed `admin` uses status `superuser`. This app does not use `is_staff`.
GM↔BUH link: `User.managed_by` (confirmed). **Only `gm`** may request a **`buh`** (not `bu_admin` / `gm_admin`); **only that `buh`** may approve. GM↔BUH link: `User.managed_by` (confirmed). **Only `gm`** may request a **`buh`** (not `bu_admin` / `gm_admin`); **only that `buh`** may approve.
Active site registry ownership: `ActiveSite.registered_by`. Active site registry ownership: `ActiveSite.managed_by`.
GM farm visibility follows the Pengaturan active-site list: only HQ `Site` rows linked to an enabled registered `ActiveSite` are shown (empty list ⇒ no sites). GM farm visibility follows the Pengaturan active-site list: only HQ `Site` rows linked to an enabled `ActiveSite` they manage are shown (empty list ⇒ no sites).
BUH farm visibility is the union of those lists for **confirmed** managed GMs (read-only Pengaturan rollup; optional GM filter in the header). BUH farm visibility is the union of those lists for **confirmed** managed GMs (read-only Pengaturan rollup; optional GM filter in the header).
`POST /api/v1/cycles/{id}/approve-close/` — GM only. `POST /api/v1/cycles/{id}/approve-close/` — GM only.
+5 -5
View File
@@ -2,9 +2,9 @@
- director / director_admin: all sites / active sites / GMs - director / director_admin: all sites / active sites / GMs
- buh / bu_admin: only GMs (and gm_admins) they manage, plus those GMs' sites - buh / bu_admin: only GMs (and gm_admins) they manage, plus those GMs' sites
- gm / gm_admin: only their own sites / active sites they registered - gm / gm_admin: only their own sites / active sites they manage
Farm Site rows are only visible to non-directors when linked to an active Farm Site rows are only visible to non-directors when linked to an enabled
ActiveSite in the viewer's Pengaturan registry. ActiveSite in the viewer's Pengaturan registry.
""" """
@@ -61,7 +61,7 @@ def managed_gm_ids(buh: User) -> list[int]:
def visible_owner_ids(user: User) -> list[int] | None: def visible_owner_ids(user: User) -> list[int] | None:
""" """
Site.user / ActiveSite.registered_by ids this viewer may see. Site.user / ActiveSite.managed_by ids this viewer may see.
Returns None when there is no restriction (director / superuser). Returns None when there is no restriction (director / superuser).
""" """
@@ -89,7 +89,7 @@ def visible_active_site_ids(user: User) -> list[int] | None:
return [] return []
return list( return list(
ActiveSite.objects.filter( ActiveSite.objects.filter(
registered_by_id__in=owners, managed_by_id__in=owners,
is_active=True, is_active=True,
).values_list("pk", flat=True) ).values_list("pk", flat=True)
) )
@@ -132,7 +132,7 @@ def filter_active_sites_queryset(qs: QuerySet, viewer: User) -> QuerySet:
return qs return qs
if not owners: if not owners:
return qs.none() return qs.none()
return qs.filter(registered_by_id__in=owners) return qs.filter(managed_by_id__in=owners)
def filter_kandangs_queryset(qs: QuerySet, viewer: User) -> QuerySet: def filter_kandangs_queryset(qs: QuerySet, viewer: User) -> QuerySet:
+2 -3
View File
@@ -31,13 +31,13 @@ class VisibilityScopeTests(TestCase):
code="a", code="a",
name="Site A", name="Site A",
api_base_url="https://a.example/api/v1", api_base_url="https://a.example/api/v1",
registered_by=self.gm, managed_by=self.gm,
) )
self.active_b = ActiveSite.objects.create( self.active_b = ActiveSite.objects.create(
code="b", code="b",
name="Site B", name="Site B",
api_base_url="https://b.example/api/v1", api_base_url="https://b.example/api/v1",
registered_by=self.gm_other, managed_by=self.gm_other,
) )
self.site = Site.objects.create( self.site = Site.objects.create(
site_name="Site A", user=self.gm, active_site=self.active_a site_name="Site A", user=self.gm, active_site=self.active_a
@@ -98,7 +98,6 @@ class VisibilityScopeTests(TestCase):
{ {
"code": "orphan-seed", "code": "orphan-seed",
"name": "Orphan Seed", "name": "Orphan Seed",
"api_base_url": "https://orphan.example/api/v1",
"is_active": True, "is_active": True,
}, },
format="json", format="json",
@@ -18,9 +18,9 @@ def ensure_hq_site_for_active_site(active_site: ActiveSite) -> Site:
if existing: if existing:
return existing return existing
owner = active_site.registered_by owner = active_site.managed_by
if owner is None: if owner is None:
raise ValueError("ActiveSite.registered_by is required to create an HQ site.") raise ValueError("ActiveSite.managed_by is required to create an HQ site.")
site_name = (active_site.name or active_site.code or "Site")[:30] site_name = (active_site.name or active_site.code or "Site")[:30]
orphan = ( orphan = (
+2 -2
View File
@@ -11,12 +11,12 @@ from apps.sync.models import ActiveSite
def _gm_visible_site(*, owner: User, site_name: str, code: str) -> Site: def _gm_visible_site(*, owner: User, site_name: str, code: str) -> Site:
"""GM scope only sees sites linked to an ActiveSite they registered.""" """GM scope only sees sites linked to an ActiveSite they manage."""
active_site = ActiveSite.objects.create( active_site = ActiveSite.objects.create(
code=code, code=code,
name=site_name, name=site_name,
api_base_url=f"https://{code}.example/api/v1", api_base_url=f"https://{code}.example/api/v1",
registered_by=owner, managed_by=owner,
) )
return Site.objects.create( return Site.objects.create(
site_name=site_name, user=owner, active_site=active_site site_name=site_name, user=owner, active_site=active_site
+1 -1
View File
@@ -48,7 +48,7 @@ def _upsert_mirror_cycle_from_ingest(active_site: ActiveSite, payload: dict) ->
site_name = str(payload.get("site_name") or active_site.name)[:30] site_name = str(payload.get("site_name") or active_site.name)[:30]
kandang_name = str(payload.get("kandang_name") or "Kandang")[:30] kandang_name = str(payload.get("kandang_name") or "Kandang")[:30]
owner = active_site.registered_by owner = active_site.managed_by
if owner is None: if owner is None:
owner = User.objects.filter(status=User.STATUS_GM).first() owner = User.objects.filter(status=User.STATUS_GM).first()
if owner is None: if owner is None:
@@ -108,8 +108,8 @@ class Command(BaseCommand):
if not staff: if not staff:
return return
moved_sites = Site.objects.filter(user=staff).update(user=gm) moved_sites = Site.objects.filter(user=staff).update(user=gm)
moved_active = ActiveSite.objects.filter(registered_by=staff).update( moved_active = ActiveSite.objects.filter(managed_by=staff).update(
registered_by=gm managed_by=gm
) )
ApiKey.objects.filter(user=staff).delete() ApiKey.objects.filter(user=staff).delete()
staff.delete() staff.delete()
@@ -0,0 +1,36 @@
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
("sync", "0003_rename_citysite_to_activesite"),
]
operations = [
migrations.AlterField(
model_name="activesite",
name="api_base_url",
field=models.URLField(
blank=True,
default="",
help_text="Optional site API root including /api/v1 (for outbound sync/push)",
max_length=255,
),
),
migrations.AlterField(
model_name="activesite",
name="registered_by",
field=models.ForeignKey(
blank=True,
help_text="GM / GM Admin who registered this site by name and code",
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="registered_active_sites",
to=settings.AUTH_USER_MODEL,
),
),
]
@@ -0,0 +1,33 @@
# Generated manually: rename ActiveSite.registered_by → managed_by
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
("sync", "0004_activesite_api_base_url_optional"),
]
operations = [
migrations.RenameField(
model_name="activesite",
old_name="registered_by",
new_name="managed_by",
),
migrations.AlterField(
model_name="activesite",
name="managed_by",
field=models.ForeignKey(
blank=True,
help_text="GM / GM Admin who manages this active site",
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="managed_active_sites",
to=settings.AUTH_USER_MODEL,
),
),
]
+6 -4
View File
@@ -17,20 +17,22 @@ class ActiveSite(models.Model):
name = models.CharField(max_length=120) name = models.CharField(max_length=120)
api_base_url = models.URLField( api_base_url = models.URLField(
max_length=255, max_length=255,
help_text="Site API root including /api/v1, e.g. https://site.example/api/v1", blank=True,
default="",
help_text="Optional site API root including /api/v1 (for outbound sync/push)",
) )
api_key = models.CharField( api_key = models.CharField(
max_length=255, max_length=255,
blank=True, blank=True,
help_text="X-API-Key for pulling from the site API (keep server-side only)", help_text="X-API-Key for pulling from the site API (keep server-side only)",
) )
registered_by = models.ForeignKey( managed_by = models.ForeignKey(
settings.AUTH_USER_MODEL, settings.AUTH_USER_MODEL,
null=True, null=True,
blank=True, blank=True,
on_delete=models.SET_NULL, on_delete=models.SET_NULL,
related_name="registered_active_sites", related_name="managed_active_sites",
help_text="GM / GM Admin who added this site dashboard URL", help_text="GM / GM Admin who manages this active site",
) )
is_active = models.BooleanField(default=True) is_active = models.BooleanField(default=True)
last_synced_at = models.DateTimeField(null=True, blank=True) last_synced_at = models.DateTimeField(null=True, blank=True)
+6 -6
View File
@@ -7,8 +7,8 @@ class ActiveSiteSerializer(serializers.ModelSerializer):
id = serializers.IntegerField(source="pk", read_only=True) id = serializers.IntegerField(source="pk", read_only=True)
# Never echo the outbound site API key back to the browser by default. # Never echo the outbound site API key back to the browser by default.
has_api_key = serializers.SerializerMethodField() has_api_key = serializers.SerializerMethodField()
registered_by_name = serializers.CharField( managed_by_name = serializers.CharField(
source="registered_by.display_name", read_only=True, allow_null=True source="managed_by.display_name", read_only=True, allow_null=True
) )
class Meta: class Meta:
@@ -19,8 +19,8 @@ class ActiveSiteSerializer(serializers.ModelSerializer):
"name", "name",
"api_base_url", "api_base_url",
"has_api_key", "has_api_key",
"registered_by", "managed_by",
"registered_by_name", "managed_by_name",
"is_active", "is_active",
"last_synced_at", "last_synced_at",
"last_sync_status", "last_sync_status",
@@ -31,8 +31,8 @@ class ActiveSiteSerializer(serializers.ModelSerializer):
read_only_fields = [ read_only_fields = [
"id", "id",
"has_api_key", "has_api_key",
"registered_by", "managed_by",
"registered_by_name", "managed_by_name",
"last_synced_at", "last_synced_at",
"last_sync_status", "last_sync_status",
"last_sync_message", "last_sync_message",
+2 -2
View File
@@ -22,7 +22,7 @@ class ActiveSiteViewSet(viewsets.ModelViewSet):
executive_sync_action = True executive_sync_action = True
def get_queryset(self): def get_queryset(self):
qs = ActiveSite.objects.select_related("registered_by").all() qs = ActiveSite.objects.select_related("managed_by").all()
return filter_active_sites_queryset(qs, self.request.user) return filter_active_sites_queryset(qs, self.request.user)
def get_permissions(self): def get_permissions(self):
@@ -36,7 +36,7 @@ class ActiveSiteViewSet(viewsets.ModelViewSet):
return ActiveSiteSerializer return ActiveSiteSerializer
def perform_create(self, serializer): def perform_create(self, serializer):
active_site = serializer.save(registered_by=self.request.user) active_site = serializer.save(managed_by=self.request.user)
ensure_hq_site_for_active_site(active_site) ensure_hq_site_for_active_site(active_site)
def perform_update(self, serializer): def perform_update(self, serializer):
+6 -35
View File
@@ -68,10 +68,10 @@ const BuhViewList: React.FC = () => {
const sitesByGm = useMemo(() => { const sitesByGm = useMemo(() => {
const map = new Map<number, ActiveSite[]>(); const map = new Map<number, ActiveSite[]>();
for (const site of activeSites) { for (const site of activeSites) {
if (site.registered_by == null) continue; if (site.managed_by == null) continue;
const rows = map.get(site.registered_by) ?? []; const rows = map.get(site.managed_by) ?? [];
rows.push(site); rows.push(site);
map.set(site.registered_by, rows); map.set(site.managed_by, rows);
} }
return map; return map;
}, [activeSites]); }, [activeSites]);
@@ -224,8 +224,6 @@ const GmViewList: React.FC = () => {
const [name, setName] = useState(''); const [name, setName] = useState('');
const [code, setCode] = useState(''); const [code, setCode] = useState('');
const [codeTouched, setCodeTouched] = useState(false); const [codeTouched, setCodeTouched] = useState(false);
const [apiBaseUrl, setApiBaseUrl] = useState('');
const [apiKey, setApiKey] = useState('');
const flash = (msg: string) => { const flash = (msg: string) => {
setNotice(msg); setNotice(msg);
@@ -263,8 +261,6 @@ const GmViewList: React.FC = () => {
setName(''); setName('');
setCode(''); setCode('');
setCodeTouched(false); setCodeTouched(false);
setApiBaseUrl('');
setApiKey('');
}; };
const wrap = async (fn: () => Promise<void>) => { const wrap = async (fn: () => Promise<void>) => {
@@ -314,7 +310,7 @@ const GmViewList: React.FC = () => {
<div> <div>
<PageHeader <PageHeader
title="Pengaturan" title="Pengaturan"
subtitle="Pilih BUH Anda, lalu daftarkan site ke daftar tampilan" subtitle="Pilih BUH Anda, lalu daftarkan site dengan nama dan kode"
/> />
{error && ( {error && (
<div className="mb-4"> <div className="mb-4">
@@ -400,7 +396,8 @@ const GmViewList: React.FC = () => {
<section className="bg-white rounded-xl shadow-sm p-5"> <section className="bg-white rounded-xl shadow-sm p-5">
<h2 className="font-semibold text-gray-800 mb-1">Tambah site</h2> <h2 className="font-semibold text-gray-800 mb-1">Tambah site</h2>
<p className="text-xs text-gray-500 mb-4"> <p className="text-xs text-gray-500 mb-4">
Daftarkan URL API dasbor kota agar data site muncul di daftar tampilan Anda. Masukkan nama dan kode yang sama dengan yang diisi manajer di dasbor site, agar site
terhubung ke daftar tampilan Anda.
</p> </p>
<form <form
className="space-y-3" className="space-y-3"
@@ -412,8 +409,6 @@ const GmViewList: React.FC = () => {
const created = await api.activeSites.create({ const created = await api.activeSites.create({
code: resolvedCode, code: resolvedCode,
name: name.trim(), name: name.trim(),
api_base_url: apiBaseUrl.trim(),
...(apiKey.trim() ? { api_key: apiKey.trim() } : {}),
is_active: true, is_active: true,
}); });
resetForm(); resetForm();
@@ -448,28 +443,6 @@ const GmViewList: React.FC = () => {
required required
/> />
</label> </label>
<label className="block text-sm text-gray-700">
URL API kota
<input
className={`${inputClass} mt-1`}
type="url"
placeholder="https://site.example/api/v1"
value={apiBaseUrl}
onChange={(e) => setApiBaseUrl(e.target.value)}
required
/>
</label>
<label className="block text-sm text-gray-700">
API key (opsional)
<input
className={`${inputClass} mt-1`}
type="password"
autoComplete="off"
placeholder="X-API-Key untuk sinkron dari kota"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
/>
</label>
<button <button
type="submit" type="submit"
className="bg-red-600 text-white px-4 py-2 rounded-lg text-sm font-semibold disabled:opacity-50" className="bg-red-600 text-white px-4 py-2 rounded-lg text-sm font-semibold disabled:opacity-50"
@@ -496,7 +469,6 @@ const GmViewList: React.FC = () => {
<div className="min-w-0"> <div className="min-w-0">
<p className="text-sm font-semibold text-gray-800 truncate">{site.name}</p> <p className="text-sm font-semibold text-gray-800 truncate">{site.name}</p>
<p className="text-xs text-gray-500 truncate">{site.code}</p> <p className="text-xs text-gray-500 truncate">{site.code}</p>
<p className="text-xs text-gray-400 truncate mt-0.5">{site.api_base_url}</p>
<p className="text-xs mt-1"> <p className="text-xs mt-1">
<span <span
className={ className={
@@ -507,7 +479,6 @@ const GmViewList: React.FC = () => {
> >
{site.is_active ? 'Aktif' : 'Nonaktif'} {site.is_active ? 'Aktif' : 'Nonaktif'}
</span> </span>
{site.has_api_key ? ' · API key tersimpan' : ''}
</p> </p>
</div> </div>
<button <button
+4 -8
View File
@@ -103,8 +103,8 @@ describe('SettingsPage city site registry', () => {
name: 'Sukawarna', name: 'Sukawarna',
api_base_url: 'https://sukawarna.example/api/v1', api_base_url: 'https://sukawarna.example/api/v1',
has_api_key: true, has_api_key: true,
registered_by: 5, managed_by: 5,
registered_by_name: 'gm.user', managed_by_name: 'gm.user',
is_active: true, is_active: true,
last_synced_at: null, last_synced_at: null,
last_sync_status: '', last_sync_status: '',
@@ -119,8 +119,8 @@ describe('SettingsPage city site registry', () => {
name: 'Cikupa', name: 'Cikupa',
api_base_url: 'https://cikupa.example/api/v1', api_base_url: 'https://cikupa.example/api/v1',
has_api_key: false, has_api_key: false,
registered_by: 5, managed_by: 5,
registered_by_name: 'gm.user', managed_by_name: 'gm.user',
is_active: true, is_active: true,
last_synced_at: null, last_synced_at: null,
last_sync_status: '', last_sync_status: '',
@@ -159,16 +159,12 @@ describe('SettingsPage city site registry', () => {
fireEvent.change(screen.getByPlaceholderText('Contoh: Sukawarna'), { fireEvent.change(screen.getByPlaceholderText('Contoh: Sukawarna'), {
target: { value: 'Cikupa' }, target: { value: 'Cikupa' },
}); });
fireEvent.change(screen.getByPlaceholderText('https://site.example/api/v1'), {
target: { value: 'https://cikupa.example/api/v1' },
});
fireEvent.click(screen.getByRole('button', { name: 'Tambah ke daftar' })); fireEvent.click(screen.getByRole('button', { name: 'Tambah ke daftar' }));
await waitFor(() => expect(createMock).toHaveBeenCalledTimes(1)); await waitFor(() => expect(createMock).toHaveBeenCalledTimes(1));
expect(createMock).toHaveBeenCalledWith({ expect(createMock).toHaveBeenCalledWith({
code: 'cikupa', code: 'cikupa',
name: 'Cikupa', name: 'Cikupa',
api_base_url: 'https://cikupa.example/api/v1',
is_active: true, is_active: true,
}); });
}); });
+3 -3
View File
@@ -45,8 +45,8 @@ export type ActiveSite = {
name: string; name: string;
api_base_url: string; api_base_url: string;
has_api_key: boolean; has_api_key: boolean;
registered_by?: number | null; managed_by?: number | null;
registered_by_name?: string | null; managed_by_name?: string | null;
is_active: boolean; is_active: boolean;
last_synced_at: string | null; last_synced_at: string | null;
last_sync_status: string; last_sync_status: string;
@@ -58,7 +58,7 @@ export type ActiveSite = {
export type ActiveSiteWrite = { export type ActiveSiteWrite = {
code: string; code: string;
name: string; name: string;
api_base_url: string; api_base_url?: string;
api_key?: string; api_key?: string;
is_active?: boolean; is_active?: boolean;
}; };