Commit c7c83ef0 authored by serverKhansia's avatar serverKhansia

Merge branch 'main' of https://git.khansia.co.id/gustixjerk/imi-apps into main

parents 924fe1de 04e8dd43
# DESIGN.md — IMI Jawa Barat (public web + partner portal)
Arah gaya: **institusional resmi** (federasi olahraga). Navy putih, tipografi rapi,
layout teratur. Bukan sporty agresif, bukan startup.
Dial: **ENERGY 1 / RHYTHM 2 / MOTION 1**.
Catatan jujur: arah ditulis oleh agen berdasarkan pilihan mood pemilik produk.
Keputusan visual di bawah ini adalah interpretasi agen, bukan identitas brand
resmi yang sudah ditetapkan.
## Identitas
- Nama tampil: teks wordmark "IMI Jawa Barat" (logo asli menyusul, placeholder `[LOGO]` di header).
- Kepribadian: resmi, terpercaya, lugas.
## Palet (maks 3 inti + 1 aksen)
| Warna | Peran | Alasan |
|---|---|---|
| Navy `#0B2545` | inti: header, hero, judul, tombol utama | warna institusi; kontras AA di atas putih |
| Putih `#FFFFFF` + abu `#F4F6F8`, teks sekunder `#4B5563` | alas & teks | netral, tidak bersaing dengan navy |
| Aksen merah `#C1272D` | satu aksen: status penting, fokus CTA | nada merah-putih, dipakai hemat agar hierarki terlihat |
## Tipografi
- Judul: `ui-serif, Georgia, serif` — serif memberi kesan kelembagaan/formal.
- Teks & UI: `system-ui, -apple-system, Segoe UI, sans-serif` — keterbacaan
tanpa dependensi font eksternal (aman offline).
- Skala: judul section 1.5rem, body 1rem / line-height 1.6.
## Struktur halaman (konten menentukan layout)
`web/index.html` (publik, tanpa login): header + anchor nyata → strip banner resmi
→ agenda event → benefit mitra → daftar mitra → artikel → footer.
`web/partner-portal.html`: login (telepon + sandi) → ringkasan saldo party →
statement per periode (ringkasan + tabel revenue + tabel adjustment) → unduh CSV → keluar.
Setiap panel data punya tiga state: memuat, kosong, error (dengan tombol coba lagi).
## Keputusan besar (alasan satu baris)
- Tanpa gambar placeholder: `poster`/`logo` dari API null → tampil blok tipografi (R-23: aset jangan dipalsukan).
- Tanpa angka statistik di homepage: tidak ada sumber data nyata (R-17).
- Motion 1: hanya transisi hover/focus; situs kelembagaan mengutamakan konten.
- RHYTHM 2: grid kartu seragam untuk daftar, strip banner dan tabel statement jadi jeda komposisi.
- Halaman disajikan statis tanpa build step; `API` base dikonfigurasi di awal skrip tiap halaman.
......@@ -24,6 +24,11 @@ JWT_REFRESH_TTL_DAYS=30
# OTP (registrasi/lupa password) — DEV only fallback: OTP akan ditampilkan di response API
OTP_EXPIRY_MINUTES=5
DEV_OTP_ECHO=True
# Gateway OTP HTTP (WhatsApp/SMS provider). Isi untuk produksi; saat terisi,
# DEV_OTP_ECHO default jadi False (kode OTP tidak lagi ikut di response).
OTP_GATEWAY_URL=
OTP_GATEWAY_TOKEN=
OTP_GATEWAY_TIMEOUT=8
# Midtrans Snap (pembayaran tiket) — daftar di dashboard.midtrans.com
MIDTRANS_IS_PRODUCTION=False
......
# Generated by Django 4.2.16 on 2026-10-07 10:10
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('accounts', '0001_initial'),
]
operations = [
migrations.AlterField(
model_name='user',
name='role',
field=models.CharField(blank=True, choices=[('super_admin', 'Super Admin'), ('admin', 'Admin'), ('event', 'Event / Panitia'), ('content', 'Content Admin'), ('partnership', 'Partnership Admin'), ('reporting', 'Reporting'), ('finance', 'Finance / Keuangan'), ('auditor', 'Auditor')], default='', max_length=20, verbose_name='Role'),
),
]
# Generated by Django 4.2.16 on 2026-10-09 09:58
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('accounts', '0002_alter_user_role'),
]
operations = [
migrations.AlterField(
model_name='member',
name='nik',
field=models.CharField(blank=True, max_length=16, verbose_name='NIK'),
),
migrations.AddConstraint(
model_name='member',
constraint=models.UniqueConstraint(condition=models.Q(('nik', ''), _negated=True), fields=('nik',), name='accounts_member_nonblank_nik_unique'),
),
]
......@@ -53,6 +53,8 @@ class User(AbstractUser):
CONTENT = "content", "Content Admin"
PARTNERSHIP = "partnership", "Partnership Admin"
REPORTING = "reporting", "Reporting"
FINANCE = "finance", "Finance / Keuangan"
AUDITOR = "auditor", "Auditor"
username = None # login pakai phone / email / no KTA
phone = models.CharField("Nomor HP", max_length=20, unique=True)
......@@ -155,7 +157,7 @@ class Member(models.Model):
tier = models.CharField(max_length=20, choices=Tier.choices, default=Tier.SILVER)
full_name = models.CharField(max_length=150)
nik = models.CharField("NIK", max_length=16, unique=True)
nik = models.CharField("NIK", max_length=16, blank=True)
phone = models.CharField(max_length=20)
email = models.EmailField(blank=True, null=True)
photo = models.ImageField(upload_to="members/", null=True, blank=True)
......@@ -190,6 +192,8 @@ class Member(models.Model):
class Meta:
ordering = ["-created_at"]
constraints = [models.UniqueConstraint(fields=["nik"], condition=~models.Q(nik=""), name="accounts_member_nonblank_nik_unique")]
def __str__(self):
return self.member_number or self.full_name
......@@ -346,4 +350,4 @@ class AuditLog(models.Model):
ordering = ["-created_at"]
def __str__(self):
return f"{self.action} {self.entity} #{self.entity_id}"
\ No newline at end of file
return f"{self.action} {self.entity} #{self.entity_id}"
......@@ -133,9 +133,7 @@ class LoginSerializer(serializers.Serializer):
user = None
if otp:
from apps.common.utils import generate_qr_token
raise NotImplementedError("OTP login belum dinyalakan; gunakan password.")
raise serializers.ValidationError({"otp": "Login OTP belum tersedia; gunakan password. OTP registrasi/reset tetap melalui endpoint verifikasi."})
user = authenticate(request, username=identifier, password=password or "")
if not user:
raise AuthenticationFailed("Nomor KTA/HP/email atau password salah.")
......@@ -274,4 +272,4 @@ class AdminMemberActionSerializer(serializers.Serializer):
)
license_class = serializers.ChoiceField(choices=MembershipCard.LicenseClass.choices, required=False)
discipline = serializers.CharField(max_length=60, required=False, allow_blank=True)
note = serializers.CharField(max_length=250, required=False, allow_blank=True)
\ No newline at end of file
note = serializers.CharField(max_length=250, required=False, allow_blank=True)
......@@ -20,8 +20,33 @@ def add_years(day, years=1):
return day.replace(year=day.year + years, month=2, day=28)
def _dispatch_otp_gateway(phone: str, code: str, purpose: str) -> None:
"""Kirim OTP via gateway HTTP (env OTP_GATEWAY_URL, mis. Twilio/Fonnte/Wablas).
Gateway gagal = error tegas ke user; kode OTP tersimpan tetap bisa
diverifikasi bila pesan sebenarnya sudah terkirim.
"""
import requests
try:
resp = requests.post(
settings.OTP_GATEWAY_URL,
json={"phone": phone, "code": code, "purpose": purpose},
headers=(
{"Authorization": f"Bearer {settings.OTP_GATEWAY_TOKEN}"}
if settings.OTP_GATEWAY_TOKEN
else {}
),
timeout=settings.OTP_GATEWAY_TIMEOUT,
)
resp.raise_for_status()
except requests.RequestException as exc:
raise ValidationError({"detail": "Gateway OTP tidak dapat dihubungi. Coba lagi nanti."}) from exc
def send_otp(phone: str, purpose: str, echo: bool | None = None) -> dict:
"""Mengirim (simulasi) OTP. echo=True => otp dibalikan di response (DEV saja)."""
"""Kirim OTP. Lewat OTP_GATEWAY_URL bila dikonfigurasi (produksi);
echo=True => otp dibalikan di response (hanya lingkungan dev)."""
OtpCode.objects.filter(phone=phone, purpose=purpose, is_used=False).update(is_used=True)
code = generate_otp()
......@@ -32,7 +57,8 @@ def send_otp(phone: str, purpose: str, echo: bool | None = None) -> dict:
expires_at=otp_expiry(),
)
echo = settings.DEV_OTP_ECHO if echo is None else echo
# TODO: integrasi WhatsApp gateway (Twilio/Wablas/Fonnte) produksi
if settings.OTP_GATEWAY_URL:
_dispatch_otp_gateway(phone, code, purpose)
return {"otp_id": otp.id, "expires_at": otp.expires_at, "echo": code if echo else None}
......
......@@ -15,6 +15,11 @@ class AuthFlowTests(APITestCase):
self.client = APIClient()
self.pengcab = create_pengcab()
def test_unsupported_otp_login_is_validation_error_not_server_error(self):
response = self.client.post('/api/v1/auth/login', {'identifier': '+6281234567891', 'password': 'ignored-password', 'otp': '123456'}, format='json')
self.assertEqual(response.status_code, 400, response.data)
self.assertIn('otp', response.data)
def test_register_verify_otp_login_me(self):
r = self.client.post(
"/api/v1/auth/register",
......@@ -58,6 +63,21 @@ class AuthFlowTests(APITestCase):
self.assertEqual(r.data["member"]["member_number"], "IMI-JB-2026-0001")
self.assertEqual(len(r.data["member"]["cards"]), 1)
def test_multiple_general_accounts_can_register_without_nik(self):
for phone in ["+6281777666001", "+6281777666002"]:
response = self.client.post("/api/v1/auth/register", {"account_type": "general", "full_name": "General Audit", "phone": phone, "password": "Audit123!", "confirm_password": "Audit123!", "terms_accepted": True}, format="json")
self.assertEqual(response.status_code, 201, response.data)
from apps.accounts.models import Member
self.assertEqual(Member.objects.filter(nik="").count(), 2)
def test_supplied_nik_remains_unique_in_database(self):
from django.db import IntegrityError, transaction
from apps.accounts.models import Member
user_a, member_a = make_member("+6281777666011", "First")
user_b, member_b = make_member("+6281777666012", "Second")
with self.assertRaises(IntegrityError), transaction.atomic():
Member.objects.filter(pk=member_b.pk).update(nik=member_a.nik)
def test_register_wajib_match_password(self):
r = self.client.post(
"/api/v1/auth/register",
......@@ -203,4 +223,4 @@ class StaffTests(APITestCase):
self.assertEqual(r.data["rc_payments"], 1)
self.assertEqual(r.data["rc_orders_unmatched"], 1)
self.assertEqual(r.data["rc_payments_unmatched"], 0)
self.assertEqual(r.data["rc_diff"], 60000)
\ No newline at end of file
self.assertEqual(r.data["rc_diff"], 60000)
"""A13: RBAC positif/negatif — akses daftar member/PII dan daftar akun internal.
Matriks read anggota belum ditetapkan formal oleh bisnis; test ini memegang
default least-privilege: hanya Super Admin / Admin yang membaca daftar member
lengkap & data PII (NIK, alamat, kartu/asuransi/kendaraan) serta daftar akun
internal. Lihat docs/audit/2026-10-09-opencode/A13_ROLE_MATRIX_PROPOSAL.md.
"""
from __future__ import annotations
from rest_framework.test import APIClient, APITestCase
from apps.accounts.models import User
from apps.common.testing import create_pengcab, login_token, make_member, make_user
from apps.finance.models import Party
ROLE_STAFF = [
"event",
"content",
"partnership",
"reporting",
"finance",
"auditor",
]
def staff_client(phone: str, role: str) -> APIClient:
make_user(phone, "Staff " + role, role=role)
client = APIClient()
client.credentials(HTTP_AUTHORIZATION="Bearer " + login_token(client, phone))
return client
class AdminRbacPositiveTests(APITestCase):
def setUp(self):
create_pengcab()
make_member("+6281600000101", "Anggota RBAC")
def test_super_admin_admin_bisa_baca_member_pii(self):
for label, phone in [("super_admin", "+6281200000096"), ("admin", "+6281200000097")]:
with self.subTest(role=label):
r = staff_client(phone, label).get("/api/v1/admin/members/")
self.assertEqual(r.status_code, 200)
self.assertGreater(r.data["count"], 0)
row = r.data["results"][0]
self.assertIn("nik", row)
self.assertIn("address", row)
def test_super_admin_baca_daftar_akun_internal(self):
r = staff_client("+6281200000095", "super_admin").get("/api/v1/admin/users/")
self.assertEqual(r.status_code, 200)
self.assertGreater(r.data["count"], 0)
class RbacMemberPiiNegativeTests(APITestCase):
def setUp(self):
create_pengcab()
make_member("+6281600000102", "Anggota RBAC 2")
make_member("+6281600000103", "Anggota RBAC 3")
def test_role_staff_non_admin_tidak_bisa_baca_member_pii(self):
for i, role in enumerate(ROLE_STAFF):
client = staff_client(f"+62812000001{i:02d}", role)
with self.subTest(role=role):
r = client.get("/api/v1/admin/members/")
self.assertEqual(r.status_code, 403, f"{role} tidak boleh baca daftar member")
def test_member_tidak_bisa_baca_admin_members(self):
from apps.common.testing import member_client
r = member_client("+6281600000103")
resp = r.get("/api/v1/admin/members/")
self.assertIn(resp.status_code, (401, 403))
def test_role_staff_non_admin_tidak_bisa_baca_akun_internal(self):
for i, role in enumerate(ROLE_STAFF):
client = staff_client(f"+62812000002{i:02d}", role)
with self.subTest(role=role):
r = client.get("/api/v1/admin/users/")
self.assertEqual(r.status_code, 403, f"{role} tidak boleh baca daftar akun")
class RbacPartnerPortalOwnershipTests(APITestCase):
"""PRD §28 portal partner: akun hanya melihat statement partynya sendiri."""
def setUp(self):
create_pengcab()
self.owner_a = make_user("+6281200000301", "Partner A")
self.owner_b = make_user("+6281200000302", "Partner B")
Party.objects.get_or_create(
code="IMI-JABAR", defaults={"name": "IMI Jawa Barat", "party_type": Party.PartyType.PLATFORM_OWNER}
)
Party.objects.get_or_create(
code="PLATFORM-IMI-JABAR", defaults={"name": "Platform", "party_type": Party.PartyType.PLATFORM_OPERATOR}
)
self.party_a = Party.objects.create(
code="P-A", name="Partner A", party_type=Party.PartyType.PARTNER, portal_user=self.owner_a
)
self.party_b = Party.objects.create(
code="P-B", name="Partner B", party_type=Party.PartyType.PARTNER, portal_user=self.owner_b
)
make_member("+6281600000104", "Anggota RBAC 4")
def _portal(self, phone):
client = APIClient()
client.credentials(HTTP_AUTHORIZATION="Bearer " + login_token(client, phone))
return client
def test_portal_partner_melihat_party_sendiri_saja(self):
r = self._portal("+6281200000301").get("/api/v1/partner/me/")
self.assertEqual(r.status_code, 200)
self.assertEqual(r.data["party"]["code"], "P-A")
stmt = self._portal("+6281200000301").get("/api/v1/partner/statement/")
self.assertEqual(stmt.status_code, 200)
self.assertIn("party", stmt.data)
def test_portal_partner_tidak_bisa_spoof_party_lain(self):
r = self._portal("+6281200000301").get("/api/v1/partner/statement/?party_id=%s" % self.party_b.pk)
self.assertEqual(r.status_code, 403)
def test_non_party_member_tidak_bisa_akses_portal(self):
from apps.common.testing import member_client
r = member_client("+6281600000104")
resp = r.get("/api/v1/partner/me/")
self.assertIn(resp.status_code, (401, 403))
def test_aktif_party_nonaktif_ditutup(self):
self.party_b.is_active = False
self.party_b.save(update_fields=["is_active"])
r = self._portal("+6281200000302").get("/api/v1/partner/me/")
self.assertEqual(r.status_code, 403)
\ No newline at end of file
......@@ -12,7 +12,7 @@ from rest_framework.response import Response
from rest_framework.views import APIView
from rest_framework.exceptions import ValidationError, NotFound
from apps.common.permissions import AnyStaff, IsMember
from apps.common.permissions import AdminOnly, AnyStaff, IsMember
from apps.common.utils import clean_phone, generate_qr_token
from . import services
......@@ -323,7 +323,11 @@ class AdminMemberViewSet(viewsets.ReadOnlyModelViewSet):
queryset = Member.objects.select_related("user", "pengcab").prefetch_related("cards", "vehicles")
serializer_class = MemberDetailSerializer
permission_classes = [AnyStaff]
# A13: daftar & PII member (NIK, alamat, kartu, asuransi, kendaraan) hanya
# untuk role pengelola member (Super Admin / Admin). Role staff lain
# memakai data peserta via endpoint ticketing/events yang diskop sesuai
# domainnya, bukan seluruh database member.
permission_classes = [AdminOnly]
search_fields = ["full_name", "member_number", "nik", "phone", "email"]
filterset_fields = ["status", "account_type", "pengcab"]
......@@ -369,7 +373,8 @@ class AdminUserViewSet(viewsets.ReadOnlyModelViewSet):
queryset = User.objects.all()
serializer_class = AdminUserSerializer
permission_classes = [AnyStaff]
# A13: daftar akun internal (HP/email staff) hanya untuk role admin.
permission_classes = [AdminOnly]
search_fields = ["phone", "full_name", "email"]
filterset_fields = ["role", "is_active", "is_staff"]
......
......@@ -58,4 +58,16 @@ AdminOnly = IsAdminRole
EventStaff = role_allowed(*ADMIN_ROLES, "event")
ContentStaff = role_allowed(*ADMIN_ROLES, "content")
PartnershipStaff = role_allowed(*ADMIN_ROLES, "partnership")
AnyStaff = role_allowed(*ADMIN_ROLES, "event", "content", "partnership", "reporting")
\ No newline at end of file
ReportingStaff = role_allowed(*ADMIN_ROLES, "reporting")
FinanceStaff = role_allowed(*ADMIN_ROLES, "finance")
AuditorStaff = role_allowed(*ADMIN_ROLES, "auditor")
AnyStaff = role_allowed(*ADMIN_ROLES, "event", "content", "partnership", "reporting", "finance", "auditor")
# Finance (PRD v1.0 §33)
# - Konfigurasi model & fee: Super Admin / Admin / Finance (limited) / Partnership
# - Approve/process settlement & payout: Super Admin / Admin / Finance
# - Reconcile & resolve: Super Admin / Admin / Finance / Auditor
FinanceConfigureStaff = role_allowed(*ADMIN_ROLES, "finance", "partnership")
FinanceApproveStaff = role_allowed(*ADMIN_ROLES, "finance")
FinanceReconcileStaff = role_allowed(*ADMIN_ROLES, "finance", "auditor")
FinanceReadStaff = role_allowed(*ADMIN_ROLES, "finance", "reporting", "auditor")
\ No newline at end of file
# Generated by Django 4.2.16 on 2026-10-09 05:38
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('content', '0002_advertisement'),
]
operations = [
migrations.CreateModel(
name='MembershipInfo',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('title', models.CharField(max_length=120)),
('intro', models.CharField(blank=True, default='', max_length=600)),
('requirements', models.JSONField(blank=True, default=list, help_text='List syarat keanggotaan, mis. ["Fotokopi KTP"]')),
('benefits', models.JSONField(blank=True, default=list, help_text='List benefit membership')),
('join_steps', models.JSONField(blank=True, default=list, help_text='Langkah cara menjadi member')),
('contact_text', models.CharField(blank=True, default='', max_length=300)),
('is_active', models.BooleanField(default=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
options={
'ordering': ['-is_active', '-updated_at'],
},
),
]
......@@ -6,6 +6,25 @@ from django.conf import settings
from django.db import models
class MembershipInfo(models.Model):
"""Informasi membership publik (tier, syarat, benefit, cara bergabung)."""
title = models.CharField(max_length=120)
intro = models.CharField(max_length=600, blank=True, default="")
requirements = models.JSONField(default=list, blank=True, help_text='List syarat keanggotaan, mis. ["Fotokopi KTP"]')
benefits = models.JSONField(default=list, blank=True, help_text="List benefit membership")
join_steps = models.JSONField(default=list, blank=True, help_text="Langkah cara menjadi member")
contact_text = models.CharField(max_length=300, blank=True, default="")
is_active = models.BooleanField(default=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ["-is_active", "-updated_at"]
def __str__(self):
return self.title
class Category(models.Model):
code = models.SlugField(max_length=40, unique=True)
name = models.CharField(max_length=80)
......
from rest_framework import serializers
from pathlib import Path
from django.conf import settings
from .models import Advertisement, Article, Banner, Category, MediaAsset
from .models import Advertisement, Article, Banner, Category, MediaAsset, MembershipInfo
class MembershipInfoSerializer(serializers.ModelSerializer):
requirements = serializers.ListField(child=serializers.CharField(max_length=300), required=False)
benefits = serializers.ListField(child=serializers.CharField(max_length=300), required=False)
join_steps = serializers.ListField(child=serializers.CharField(max_length=300), required=False)
class Meta:
model = MembershipInfo
fields = ["id", "title", "intro", "requirements", "benefits", "join_steps",
"contact_text", "is_active", "updated_at"]
read_only_fields = ["id", "updated_at"]
class CategorySerializer(serializers.ModelSerializer):
......@@ -12,6 +25,23 @@ class CategorySerializer(serializers.ModelSerializer):
class MediaAssetSerializer(serializers.ModelSerializer):
kind_display = serializers.CharField(source="get_kind_display", read_only=True)
def validate(self, attrs):
uploaded = attrs.get("uri")
kind = attrs.get("kind", self.instance.kind if self.instance else "image")
if uploaded:
if uploaded.size > getattr(settings, "MEDIA_UPLOAD_MAX_BYTES", 50 * 1024 * 1024):
raise serializers.ValidationError({"uri": "File terlalu besar (maksimal 50 MB pada konfigurasi default)."})
extensions = {"image": {".jpg", ".jpeg", ".png", ".webp", ".gif"}, "video": {".mp4", ".webm", ".mov"}, "document": {".pdf"}}
if Path(uploaded.name).suffix.lower() not in extensions[kind]:
raise serializers.ValidationError({"uri": "Ekstensi file tidak sesuai jenis media."})
content_type = getattr(uploaded, "content_type", "")
expected = {"image": "image/", "video": "video/", "document": "application/pdf"}[kind]
if content_type and not content_type.startswith(expected):
raise serializers.ValidationError({"uri": "Content-Type tidak sesuai jenis media."})
elif self.instance and kind != self.instance.kind:
raise serializers.ValidationError({"uri": "Unggah file yang sesuai ketika mengubah jenis media."})
return attrs
class Meta:
model = MediaAsset
fields = ["id", "title", "description", "kind", "kind_display", "uri", "thumbnail", "event", "created_at"]
......@@ -62,4 +92,4 @@ class AdvertisementSerializer(serializers.ModelSerializer):
def validate_title(self, value):
if not value.strip():
raise serializers.ValidationError("Judul iklan tidak boleh kosong.")
return value.strip()
\ No newline at end of file
return value.strip()
......@@ -2,11 +2,12 @@
from __future__ import annotations
from django.core.files.uploadedfile import SimpleUploadedFile
from django.utils import timezone
from rest_framework.test import APIClient, APITestCase
from apps.common.testing import create_pengcab, make_staff, member_client
from apps.content.models import Advertisement, Article, Banner, Category
from apps.content.models import Advertisement, Article, Banner, Category, MediaAsset, MembershipInfo
class ContentTests(APITestCase):
......@@ -137,4 +138,118 @@ class AdTests(APITestCase):
r = staff_client.patch(f"/api/v1/ads/manage/{self.active.id}/", {"is_active": False}, format="json")
self.assertEqual(r.status_code, 200, r.data)
r = self.client.get("/api/v1/ads/")
self.assertEqual(len(r.data), 0)
\ No newline at end of file
self.assertEqual(len(r.data), 0)
class MediaManageTests(APITestCase):
def setUp(self):
create_pengcab()
self.client = APIClient()
def test_media_publik_read_only(self):
m = MediaAsset.objects.create(title="Foto balap", kind="image", uri="media/1.jpg")
r = self.client.get("/api/v1/media/")
self.assertEqual(r.status_code, 200)
self.assertEqual([x["id"] for x in r.data], [m.id])
roles_client = APIClient()
roles_client.force_authenticate(make_staff())
r = roles_client.post("/api/v1/media/", {"title": "x", "kind": "image", "uri": "media/2.jpg"}, format="json")
self.assertEqual(r.status_code == 405, True, r.data)
def test_media_manage_staff_crud(self):
client = member_client(make_staff().phone)
up = SimpleUploadedFile("hl2.mp4", b"fakemedia", content_type="video/mp4")
r = client.post(
"/api/v1/media/manage/",
{"title": "Highlight Putaran 2", "kind": "video", "uri": up, "description": "Cuplikan balap"},
format="multipart",
)
self.assertEqual(r.status_code, 201, r.data)
mid = r.data["id"]
self.assertEqual(r.data["kind_display"], "Video")
r = client.put(
f"/api/v1/media/manage/{mid}/",
{"title": "Highlight Putaran 2 (final)", "kind": "video", "uri": SimpleUploadedFile("hl2b.mp4", b"fakemedia2", content_type="video/mp4")},
format="multipart",
)
self.assertEqual(r.status_code, 200, r.data)
self.assertEqual(MediaAsset.objects.get(pk=mid).title, "Highlight Putaran 2 (final)")
r = client.patch(f"/api/v1/media/manage/{mid}/", {"description": "Updated without replacing file"}, format="multipart")
self.assertEqual(r.status_code, 200, r.data)
self.assertEqual(MediaAsset.objects.get(pk=mid).description, "Updated without replacing file")
r = self.client.get(f"/api/v1/media/{mid}/")
self.assertEqual(r.status_code, 200)
self.assertEqual(r.data["title"], "Highlight Putaran 2 (final)")
r = client.delete(f"/api/v1/media/manage/{mid}/")
self.assertEqual(r.status_code, 204)
self.assertFalse(MediaAsset.objects.filter(pk=mid).exists())
def test_media_manage_non_content_ditolak(self):
from apps.common.testing import make_user
u = make_user("+6281555500007", role="finance")
client = APIClient()
client.force_authenticate(u)
r = client.post("/api/v1/media/manage/", {"title": "x", "kind": "image", "uri": "media/3.jpg"}, format="json")
self.assertEqual(r.status_code, 403, r.data)
class MembershipInfoTests(APITestCase):
def setUp(self):
create_pengcab()
self.client = APIClient()
def test_publik_hanya_yang_aktif(self):
MembershipInfo.objects.create(title="Member Biasa", is_active=False)
MembershipInfo.objects.create(
title="Keanggotaan IMI Jabar", is_active=True,
intro="Bergabung jadi anggota.",
requirements=["Fotokopi KTP", "Foto 3x4"],
benefits=["KTA digital", "Discount event"],
join_steps=["Isi formulir", "Verifikasi pengurus"],
contact_text="Sekretariat IMI Jabar",
)
r = self.client.get("/api/v1/membership-info/")
self.assertEqual(r.status_code, 200)
self.assertEqual(len(r.data), 1)
self.assertEqual(r.data[0]["title"], "Keanggotaan IMI Jabar")
self.assertEqual(r.data[0]["benefits"][1], "Discount event")
def test_manage_perlu_content_staff(self):
r = self.client.get("/api/v1/membership-info/manage/")
self.assertIn(r.status_code, (401, 403))
client = member_client(make_staff().phone)
r = client.post(
"/api/v1/membership-info/manage/",
{"title": "Keanggotaan", "intro": "Intro", "requirements": ["KTP"], "benefits": ["KTA digital"],
"join_steps": ["Daftar"], "is_active": True},
format="json",
)
self.assertEqual(r.status_code, 201, r.data)
mid = r.data["id"]
r = client.patch(f"/api/v1/membership-info/manage/{mid}/", {"intro": "Intro baru"}, format="json")
self.assertEqual(r.status_code, 200, r.data)
self.assertEqual(MembershipInfo.objects.get(pk=mid).intro, "Intro baru")
from apps.common.testing import make_user
fin = APIClient()
fin.force_authenticate(make_user("+6281555500008", role="finance"))
r = fin.post("/api/v1/membership-info/manage/", {"title": "x"}, format="json")
self.assertEqual(r.status_code, 403, r.data)
def test_membership_lists_cannot_be_strings(self):
self.client.force_authenticate(make_staff())
response = self.client.post('/api/v1/membership-info/manage/', {'title': 'Invalid list', 'requirements': 'KTP'}, format='json')
self.assertEqual(response.status_code, 400, response.data)
self.assertEqual(MembershipInfo.objects.count(), 0)
def test_upload_rejects_html_extension_and_kind_mismatch(self):
self.client.force_authenticate(make_staff())
for kind, name, mime in [('video', 'payload.html', 'video/mp4'), ('image', 'clip.mp4', 'video/mp4')]:
response = self.client.post('/api/v1/media/manage/', {'title': 'Invalid upload', 'kind': kind, 'uri': SimpleUploadedFile(name, b'payload', content_type=mime)}, format='multipart')
self.assertEqual(response.status_code, 400, response.data)
self.assertEqual(MediaAsset.objects.count(), 0)
......@@ -10,7 +10,10 @@ router.register("articles", views.PublicArticleViewSet, basename="article")
router.register("ads/manage", views.AdManageViewSet, basename="ad-manage")
router.register("ads", views.PublicAdViewSet, basename="ad")
router.register("categories", views.CategoryViewSet)
router.register("media/manage", views.MediaManageViewSet, basename="media-manage")
router.register("media", views.MediaAssetViewSet, basename="media")
router.register("membership-info/manage", views.MembershipInfoManageViewSet, basename="membership-info-manage")
router.register("membership-info", views.MembershipInfoViewSet, basename="membership-info")
router.register("banners", views.BannerViewSet, basename="banner")
urlpatterns = [
......
......@@ -11,16 +11,34 @@ from rest_framework.response import Response
from apps.common.permissions import ContentStaff
from apps.common.utils import build_slug
from .models import Advertisement, Article, Banner, Category, MediaAsset
from .models import Advertisement, Article, Banner, Category, MediaAsset, MembershipInfo
from .serializers import (
AdvertisementSerializer,
ArticleSerializer,
BannerSerializer,
CategorySerializer,
MediaAssetSerializer,
MembershipInfoSerializer,
)
class MembershipInfoViewSet(viewsets.ReadOnlyModelViewSet):
"""Informasi membership publik (aktif terbaru ditampilkan pertama)."""
serializer_class = MembershipInfoSerializer
permission_classes = [AllowAny]
pagination_class = None
def get_queryset(self):
return MembershipInfo.objects.filter(is_active=True)
class MembershipInfoManageViewSet(viewsets.ModelViewSet):
queryset = MembershipInfo.objects.all()
serializer_class = MembershipInfoSerializer
permission_classes = [ContentStaff]
class CategoryViewSet(viewsets.ReadOnlyModelViewSet):
queryset = Category.objects.all()
serializer_class = CategorySerializer
......@@ -95,6 +113,20 @@ class MediaAssetViewSet(viewsets.ReadOnlyModelViewSet):
pagination_class = None
class MediaManageViewSet(viewsets.ModelViewSet):
"""Kelola galeri foto/video (content staff)."""
queryset = MediaAsset.objects.select_related("event").all()
serializer_class = MediaAssetSerializer
permission_classes = [ContentStaff]
filterset_fields = ["kind", "event"]
search_fields = ["title", "description"]
http_method_names = ["get", "post", "put", "patch", "delete", "head", "options"]
def perform_create(self, serializer):
serializer.save(uploaded_by=self.request.user)
class BannerViewSet(viewsets.ReadOnlyModelViewSet):
queryset = Banner.objects.none()
serializer_class = BannerSerializer
......@@ -124,4 +156,4 @@ class AdManageViewSet(viewsets.ModelViewSet):
serializer_class = AdvertisementSerializer
permission_classes = [ContentStaff]
filterset_fields = ["is_active"]
search_fields = ["title", "category", "subtitle"]
\ No newline at end of file
search_fields = ["title", "category", "subtitle"]
......@@ -39,7 +39,7 @@ class EventViewSet(viewsets.ModelViewSet):
def get_permissions(self):
if self.action in ["list", "retrieve"]:
return [AllowAny()]
return [IsAdminRole()]
return [_CONTENT_STAFF()]
def get_serializer_class(self):
if self.action in ["list", "retrieve"]:
......@@ -57,7 +57,7 @@ class EventViewSet(viewsets.ModelViewSet):
status = self.request.query_params.get("status")
if status:
qs = qs.filter(status=status)
if self.action == "list":
if self.action in ("list", "retrieve"):
user = self.request.user
is_admin = bool(
user.is_authenticated
......
"""Seed singleton default rows (FinanceSettings, PricingConfig).
Menjamin get_or_create("default") selalu menemukan baris sehingga update
langsung atas baris default tidak hilang (kontrak get_default).
"""
from django.db import migrations
def seed(apps, schema_editor):
FinanceSettings = apps.get_model("finance", "FinanceSettings")
PricingConfig = apps.get_model("finance", "PricingConfig")
FinanceSettings.objects.get_or_create(
key="default",
defaults={
"minimum_payout_amount": 0,
"negative_balance_policy": "carry_forward",
"rounding_method": "half_up",
"gateway_fee_pct": 0,
},
)
PricingConfig.objects.get_or_create(
key="default",
defaults={
"app_fee_pct": 5.0,
"imi_fee_pct": 3.0,
"tax_rate_pct": 10.0,
"service_fee": 5000,
},
)
def noop(apps, schema_editor):
pass
class Migration(migrations.Migration):
dependencies = [
("finance", "0002_party_partyaccount_reconciliationbatch_and_more"),
]
operations = [
migrations.RunPython(seed, noop),
]
\ No newline at end of file
# Generated by Django 4.2.16 on 2026-10-07 12:25
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('finance', '0003_seed_default_settings'),
]
operations = [
migrations.AddField(
model_name='party',
name='portal_user',
field=models.OneToOneField(blank=True, help_text='Akun login portal partner untuk melihat statement sendiri (PRD §28).', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='party_profile', to=settings.AUTH_USER_MODEL),
),
]
# Generated by Django 4.2.16 on 2026-10-09 04:51
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('finance', '0004_party_portal_user'),
]
operations = [
migrations.AddField(
model_name='revenuesharingparticipant',
name='tiers',
field=models.JSONField(blank=True, default=list, help_text='Tiered share (PRD §6): [{min, max, share_type, percentage, fixed_amount}, ...]. Kurung basis [min, max); max null = tanpa batas atas. share_type dalam percentage / fixed / percentage_plus_fixed.'),
),
migrations.AlterField(
model_name='revenuesharingparticipant',
name='share_type',
field=models.CharField(choices=[('percentage', 'Persentase'), ('fixed', 'Fixed Amount'), ('percentage_plus_fixed', 'Persentase + Fixed'), ('tiered', 'Tiered (bertingkat)')], default='percentage', max_length=30),
),
]
# Generated by Django 4.2.16 on 2026-10-09 05:24
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('events', '0001_initial'),
('finance', '0005_revenuesharingparticipant_tiers_and_more'),
]
operations = [
migrations.CreateModel(
name='DiscountProgram',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('code', models.SlugField(max_length=60, unique=True)),
('name', models.CharField(max_length=150)),
('bearer', models.CharField(choices=[('imi', 'IMI'), ('platform', 'Platform'), ('partner', 'Partner'), ('sponsor', 'Sponsor'), ('campaign', 'Campaign')], default='platform', max_length=20)),
('reduces_basis', models.BooleanField(default=True, help_text='True (default): diskon mengurangi nilai transaksi (payment basis). False: diskon ditanggung payer/sponsor di luar pool — basis revenue tetap memakai harga sebelum diskon (subtotal).')),
('kind', models.CharField(choices=[('percentage', 'Persentase'), ('fixed', 'Fixed Amount')], default='percentage', max_length=12)),
('value', models.PositiveIntegerField(default=0, help_text='Persen (%) bila kind=percentage, atau nominal Rp bila fixed')),
('valid_from', models.DateField(blank=True, null=True)),
('valid_to', models.DateField(blank=True, null=True)),
('is_active', models.BooleanField(default=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)),
('event', models.ForeignKey(blank=True, help_text='Kosong = berlaku untuk seluruh event', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='discount_programs', to='events.event')),
],
options={
'ordering': ['-created_at'],
},
),
]
This diff is collapsed.
"""Pembulatan nominal Rupiah untuk domain finance.
Kebijakan pembulatan dikonfigurasi via FinanceSettings.rounding_method
(half_up / half_down / down). Semua perhitungan uang di services memakai modul ini
agar konsisten dan dapat diaudit.
"""
from __future__ import annotations
from decimal import ROUND_DOWN, ROUND_HALF_DOWN, ROUND_HALF_UP, Decimal
from .models import FinanceSettings
def _to_decimal(value) -> Decimal:
if isinstance(value, Decimal):
return value
return Decimal(str(value))
def _rounding(rounding_method: str | None):
if rounding_method == FinanceSettings.RoundingMethod.HALF_DOWN:
return ROUND_HALF_DOWN
return ROUND_HALF_UP
def round_money(value, rounding_method: str | None = None) -> int:
"""Bulatkan ke rupiah (satuan) sesuai metode (default half_up)."""
d = _to_decimal(value)
rounding = _rounding(rounding_method)
return int(d.quantize(Decimal("1"), rounding=rounding))
def round_hundred(value, rounding_method: str | None = None) -> int:
"""Bulatkan nominal ke kelipatan 100 rupiah terdekat."""
d = _to_decimal(value)
if rounding_method == FinanceSettings.RoundingMethod.DOWN:
return int((d / Decimal("100")).quantize(Decimal("1"), rounding=ROUND_DOWN)) * 100
rounding = _rounding(rounding_method)
return int((d / Decimal("100")).quantize(Decimal("1"), rounding=rounding)) * 100
def active_method() -> str:
"""Metode pembulatan aktif dari setting (default half_up bila belum dibuat)."""
return FinanceSettings.get_default().rounding_method
\ No newline at end of file
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
from django.urls import path
from django.urls import include, path
from rest_framework.routers import DefaultRouter
from . import views
app_name = "finance"
router = DefaultRouter()
router.register("finance/parties", views.PartyViewSet, basename="party")
router.register("finance/party-accounts", views.PartyAccountViewSet, basename="party-account")
router.register("finance/discount-programs", views.DiscountProgramViewSet, basename="discount-program")
router.register("revenue-sharing/models", views.RevenueSharingModelViewSet, basename="revenue-sharing-model")
router.register("finance/revenue-allocation", views.RevenueAllocationViewSet, basename="revenue-allocation")
router.register("finance/adjustments", views.AdjustmentViewSet, basename="adjustment")
router.register("settlements", views.SettlementBatchViewSet, basename="settlement")
router.register("reconciliations", views.ReconciliationBatchViewSet, basename="reconciliation")
urlpatterns = [
path("", include(router.urls)),
path("finance/pricing-config/preview/", views.PricingPreviewView.as_view(), name="pricing-preview"),
path("finance/pricing-config/", views.PricingConfigView.as_view(), name="pricing-config"),
path("finance/settings/", views.FinanceSettingsView.as_view(), name="finance-settings"),
path("transactions/", views.TransactionLedgerView.as_view(), name="transaction-ledger"),
path("reports/revenue/", views.RevenueReportView.as_view(), name="report-revenue"),
path("reports/settlement/", views.SettlementReportView.as_view(), name="report-settlement"),
path("reports/reconciliation/", views.ReconciliationReportView.as_view(), name="report-reconciliation"),
path("reports/statement/", views.PartnerStatementView.as_view(), name="report-statement"),
path("partner/me/", views.PartnerMeView.as_view(), name="partner-me"),
path("partner/statement/", views.PartnerPortalStatementView.as_view(), name="partner-statement"),
path("audit-log/", views.AuditReportView.as_view(), name="audit-report"),
path("finance/seed-demo/", views.SeedDemoDataView.as_view(), name="finance-seed-demo"),
]
\ No newline at end of file
This diff is collapsed.
"""Fixture notifikasi untuk kelima kategori PRD v0.2 §2.1 Notification.
Contoh payload yang dikonsumsi Aplikasi: body (alias `message` di respons
API), kind (event/ticket/membership/benefit/news), dan deep_link untuk rute
dalam aplikasi. Aman dijalankan berulang (idempotent per (recipient, kind, title)).
"""
from __future__ import annotations
from django.core.management.base import BaseCommand
from apps.accounts.models import User
from apps.notifications.models import Notification
FIVE_KINDS = [
{
"kind": Notification.Kind.EVENT,
"title": "Pembukaan Gate Kejurnas",
"body": "Gate dibuka pukul 07.30. Bawakan KTA untuk scan masuk paddock.",
"deep_link": "/events",
},
{
"kind": Notification.Kind.TICKET,
"title": "E-Tiket Tersedia",
"body": "Tiket kelas Festival Anda siap ditampilkan. Tunjukkan QR di pintu masuk.",
"deep_link": "/tickets",
},
{
"kind": Notification.Kind.MEMBERSHIP,
"title": "KTA Perpanjangan",
"body": "KTA Anda aktif sampai akhir musim. Perbarui data profil bila ada perubahan.",
"deep_link": "/membership",
},
{
"kind": Notification.Kind.BENEFIT,
"title": "Promo Mitra Bengkel",
"body": "Diskon 20% dyno test di bengkel rekanan untuk anggota IMI Jabar.",
"deep_link": "/benefits",
},
{
"kind": Notification.Kind.NEWS,
"title": "Info Resmi IMI Jawa Barat",
"body": "Gelar Kejurnas musim ini resmi diumumkan. Cek jadwal lengkapnya.",
"deep_link": "/news",
},
]
class Command(BaseCommand):
help = "Isi fixture notifikasi 5 kategori PRD untuk satu user (--phone) atau semua user."
def add_arguments(self, parser):
parser.add_argument("--phone", default="", help="Nomor tujuan (mis. +6281200000001). Kosong = semua user.")
def handle(self, *args, **options):
users = User.objects.filter(phone=options["phone"]) if options["phone"] else User.objects.all()
if not users.exists():
self.stderr.write(f"Tidak ada user untuk phone='{options['phone']}'.")
return
created = 0
for user in users:
for item in FIVE_KINDS:
_, was_created = Notification.objects.get_or_create(
recipient=user,
kind=item["kind"],
title=item["title"],
defaults={
"body": item["body"],
"deep_link": item["deep_link"],
"channel": Notification.Channel.IN_APP,
},
)
created += int(was_created)
self.stdout.write(self.style.SUCCESS(f"Notifikasi fixture: {created} baru untuk {users.count()} user."))
\ No newline at end of file
# Generated by Django 4.2.16 on 2026-10-09 05:33
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('notifications', '0001_initial'),
]
operations = [
migrations.AlterField(
model_name='notification',
name='kind',
field=models.CharField(choices=[('event', 'Informasi Event'), ('ticket', 'Ticket'), ('membership', 'Membership'), ('benefit', 'Benefit/Promo'), ('news', 'Informasi IMI Jawa Barat'), ('campaign', 'Kampanye/Pengumuman'), ('transactional', 'Transaksi'), ('system', 'Sistem/Weather')], default='system', max_length=20),
),
]
......@@ -8,6 +8,13 @@ from django.db import models
class Notification(models.Model):
class Kind(models.TextChoices):
# Kategori PRD v0.2 §2.1 Notification (5 kelompok).
EVENT = "event", "Informasi Event"
TICKET = "ticket", "Ticket"
MEMBERSHIP = "membership", "Membership"
BENEFIT = "benefit", "Benefit/Promo"
NEWS = "news", "Informasi IMI Jawa Barat"
# Kategori legacy (tetap valid untuk histori lama).
CAMPAIGN = "campaign", "Kampanye/Pengumuman"
TRANSACTIONAL = "transactional", "Transaksi"
SYSTEM = "system", "Sistem/Weather"
......
......@@ -6,11 +6,14 @@ from .models import Notification, NotificationPreference
class NotificationSerializer(serializers.ModelSerializer):
kind_display = serializers.CharField(source="get_kind_display", read_only=True)
channel_display = serializers.CharField(source="get_channel_display", read_only=True)
# Alias kompatibel untuk konsumen Aplikasi (kontrak A09): mobile membaca
# `message`, backend menulis `body`. Bawakan dua-duanya tanpa rename field.
message = serializers.CharField(source="body", read_only=True)
class Meta:
model = Notification
fields = ["id", "kind", "kind_display", "channel", "channel_display", "title", "body",
"deep_link", "thumb_url", "extra", "is_read", "read_at", "created_at"]
"message", "deep_link", "thumb_url", "extra", "is_read", "read_at", "created_at"]
read_only_fields = fields
......
......@@ -48,4 +48,59 @@ class NotificationTests(APITestCase):
def test_inbox_milik_pengguna_sendiri(self):
_, other = make_member("+6281600000002", "Lain")
r = member_client("+6281600000002").get("/api/v1/notifications/")
self.assertEqual(r.data["count"], 0)
\ No newline at end of file
self.assertEqual(r.data["count"], 0)
class NotificationContractTests(APITestCase):
"""A09: kontrak body/message, kind & deep_link; mark-read hanya pemilik."""
def setUp(self):
create_pengcab()
self.user, self.member = make_member("+6281600000003", "Notif2")
self.client = member_client("+6281600000003")
self.notif = Notification.objects.create(
recipient=self.user,
kind=Notification.Kind.EVENT,
channel=Notification.Channel.PUSH,
title="Jadwal Event",
body="Gate dibuka pukul 07.30",
deep_link="/tickets/1",
)
def test_body_juga_terekspos_sebagai_message(self):
r = self.client.get("/api/v1/notifications/")
row = r.data["results"][0]
self.assertEqual(row["body"], "Gate dibuka pukul 07.30")
self.assertEqual(row["message"], "Gate dibuka pukul 07.30", "mobile membaca message, bukan body (A09)")
self.assertEqual(row["kind"], Notification.Kind.EVENT)
self.assertEqual(row["deep_link"], "/tickets/1")
self.assertIn("is_read", row)
def test_mark_read_berulang_aman(self):
r1 = self.client.post(f"/api/v1/notifications/{self.notif.pk}/mark_read/", {}, format="json")
self.assertEqual(r1.status_code, 200)
self.notif.refresh_from_db()
read_at = self.notif.read_at
r2 = self.client.post(f"/api/v1/notifications/{self.notif.pk}/mark_read/", {}, format="json")
self.assertEqual(r2.status_code, 200)
self.notif.refresh_from_db()
self.assertEqual(self.notif.read_at, read_at, "repeat mark-read tidak menimpa waktu baca")
def test_mark_read_tidak_bisa_user_lain(self):
_, other = make_member("+6281600000004", "Lain2")
r = member_client("+6281600000004").post(
f"/api/v1/notifications/{self.notif.pk}/mark_read/", {}, format="json"
)
self.assertIn(r.status_code, (401, 403, 404))
self.notif.refresh_from_db()
self.assertFalse(self.notif.is_read)
def test_kelima_kategori_prd(self):
for kind in ["event", "ticket", "membership", "benefit", "news"]:
with self.subTest(kind=kind):
n = Notification.objects.create(
recipient=self.user, kind=kind, title="X", body="isi"
)
r = self.client.get("/api/v1/notifications/")
self.assertTrue(any(x["kind"] == kind for x in r.data["results"]), f"{kind} tersedia")
n.delete()
\ No newline at end of file
......@@ -39,6 +39,12 @@ class WalletRedemptionTests(APITestCase):
self.client = member_client("+6281500000001")
self.staff_client = member_client(self.staff.phone)
def test_points_balance_is_not_formatted_as_money(self):
response = self.client.get('/api/v1/wallet/balance/')
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data['points_balance'], 1450)
self.assertEqual(response.data['points_display'], '1.450 Poin')
def _claim(self, benefit_id, points=0):
return self.client.post(
"/api/v1/wallet/claim/",
......@@ -93,4 +99,68 @@ class WalletRedemptionTests(APITestCase):
r = self._claim(self.benefit.id)
qr = r.data["qr_token"]
r2 = self.client.post("/api/v1/redeem/redeem/", {"scan_code": qr}, format="json")
self.assertIn(r2.status_code, (401, 403))
\ No newline at end of file
self.assertIn(r2.status_code, (401, 403))
class OptionalBlankCreateTests(APITestCase):
"""A07: form partner/benefit mengirim "" untuk optional teks kosong (bukan null)."""
def setUp(self):
self.pengcab = create_pengcab()
self.staff = make_staff()
self.client.force_authenticate(self.staff)
BenefitCategory.objects.create(code="edukasi", name="Training & Edukasi")
def test_create_partner_with_blank_optional_fields(self):
r = self.client.post(
"/api/v1/partners/admin/",
{
"name": "Mitra Kosong Optimal",
"slug": "mitra-kosong-optional",
"website": "",
"contact_phone": "",
"contact_email": "",
"badge": "",
"description": "",
"verified_by_text": "",
},
format="json",
)
self.assertEqual(r.status_code, 201, r.data)
p = Partner.objects.get(pk=r.data["id"])
self.assertEqual(p.website, "")
self.assertEqual(p.category, "workshop")
def test_create_benefit_with_blank_optional_fields(self):
partner = Partner.objects.create(name="Mitra Benefit", slug="mitra-benefit")
r = self.client.post(
"/api/v1/partners/benefits/admin/",
{
"title": "Benefit Minimal",
"partner_id": partner.id,
"category_id": BenefitCategory.objects.get(code="edukasi").id,
"co_brand": "",
"promo_badge": "",
"short_description": "",
"description": "",
"usable_hours": "",
"regions": [],
"sale_price": None,
"non_member_price": None,
"original_price": 0,
"discount_type": "nominal",
"discount_value": 0,
"usable_days": ["mon", "tue"],
"stock_total": 5,
"validity_days": 30,
"is_unlimited": False,
"is_transferable": True,
"requires_kta": True,
"member_only": True,
"is_active": True,
"redemption_type": "voucher",
"status": "draft",
},
format="json",
)
self.assertEqual(r.status_code, 201, r.data)
......@@ -118,7 +118,7 @@ class WalletViewSet(viewsets.GenericViewSet, mixins.ListModelMixin):
).count()
return Response({
"points_balance": member.points_balance,
"points_display": f"Rp {member.points_balance:,}".replace(",", "."),
"points_display": f"{member.points_balance:,} Poin".replace(",", "."),
"active_vouchers": active.count(),
"active_total_value": sum(v.discount_value for v in active) if all_nominal else None,
"expiring_soon": expiring_soon,
......@@ -181,4 +181,4 @@ class VoucherHistoryViewSet(viewsets.ReadOnlyModelViewSet):
permission_classes = [PartnershipStaff]
filterset_fields = ["status", "outlet", "member_voucher__benefit__partner"]
search_fields = ["member_voucher__code", "note"]
ordering_fields = ["redeemed_at"]
\ No newline at end of file
ordering_fields = ["redeemed_at"]
This diff is collapsed.
# Generated by Django 4.2.16 on 2026-10-07 10:22
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('ticketing', '0003_order_app_fee_amount_order_imi_fee_amount'),
]
operations = [
migrations.AlterField(
model_name='payment',
name='status',
field=models.CharField(choices=[('pending', 'Menunggu Pembayaran'), ('verified', 'Lunas / Terverifikasi'), ('failed', 'Gagal'), ('expired', 'Kedaluwarsa'), ('refunded', 'Dikembalikan'), ('chargeback', 'Keberatan / Chargeback')], default='pending', max_length=12),
),
migrations.CreateModel(
name='PaymentWebhookEvent',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('order_code', models.CharField(editable=False, max_length=40)),
('transaction_id', models.CharField(blank=True, db_index=True, default='', max_length=80)),
('status_code', models.CharField(blank=True, default='', max_length=10)),
('transaction_status', models.CharField(blank=True, default='', max_length=40)),
('signature_valid', models.BooleanField(default=False)),
('amount_matches', models.BooleanField(default=False)),
('processed', models.BooleanField(default=False)),
('detail', models.CharField(blank=True, default='', max_length=200)),
('raw', models.JSONField(blank=True, default=dict)),
('created_at', models.DateTimeField(auto_now_add=True)),
('order', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='webhook_events', to='ticketing.order')),
],
options={
'ordering': ['-created_at'],
},
),
]
# Generated by Django 4.2.16 on 2026-10-07 12:18
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('ticketing', '0004_alter_payment_status_paymentwebhookevent'),
]
operations = [
migrations.CreateModel(
name='Refund',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('code', models.CharField(editable=False, max_length=40, unique=True)),
('amount', models.PositiveIntegerField()),
('reason', models.TextField(blank=True, default='')),
('status', models.CharField(choices=[('pending', 'Menunggu'), ('approved', 'Disetujui'), ('rejected', 'Ditolak')], default='approved', max_length=12)),
('refunded_at', models.DateTimeField(blank=True, null=True)),
('transaction_reference', models.CharField(blank=True, default='', max_length=80)),
('adjustment_codes', models.JSONField(blank=True, default=list)),
('created_at', models.DateTimeField(auto_now_add=True)),
('approved_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
('order', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='refunds', to='ticketing.order')),
],
options={
'ordering': ['-created_at'],
},
),
]
# Generated by Django 4.2.16 on 2026-10-08 04:34
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('ticketing', '0005_refund'),
]
operations = [
migrations.AddField(
model_name='order',
name='client_ref',
field=models.CharField(blank=True, db_index=True, default='', help_text='Idempotency key dari klien (checkout attempt); replay mengembalikan order yang sama.', max_length=64),
),
migrations.AddConstraint(
model_name='order',
constraint=models.UniqueConstraint(condition=models.Q(('client_ref', ''), _negated=True), fields=('user', 'client_ref'), name='unique_order_client_ref_per_user'),
),
]
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
......@@ -12,4 +12,5 @@ router.register("checkins", views.CheckInHistoryViewSet, basename="check-in")
urlpatterns = router.urls + [
path("scan/check-in", views.CheckInScanView.as_view(), name="check-in-scan"),
path("payments/midtrans/notification", views.MidtransNotificationView.as_view(), name="midtrans-notification"),
path("refunds", views.RefundCreateView.as_view(), name="refund-create"),
]
\ No newline at end of file
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment