Commit 13d2aa7a authored by gustixjerk's avatar gustixjerk

feat: initial commit for IMI Jabar digital ecosystem (backend, docs, mobile, web-admin)

parents
Pipeline #2130 canceled with stages
# OS
.DS_Store
Thumbs.db
# Build artifacts & APK
*.apk
build/
mobile/build/
mobile/.dart_tool/
mobile/.flutter-plugins
mobile/.flutter-plugins-dependencies
mobile/.pub/
mobile/.pub-cache/
# Python & Backend
**/__pycache__/
*.py[cod]
backend/.venv/
backend/venv/
backend/.env
backend/data/*.sqlite3
backend/data/*.sqlite3-*
backend/media/
# Non-requested folders
anti-slop/
web/
scratch/
# ===== IMI Jabar Backend — Environment Variables =====
# Copy this file to .env and adjust values.
# Django
DJANGO_SECRET_KEY=change-me-in-production
DJANGO_DEBUG=True
DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1,0.0.0.0
DJANGO_APP_BASE_URL=http://localhost:8000
# Database
# SQLite (local/dev default) — file at backend/data/db.sqlite3
DATABASE_URL=sqlite:///data/db.sqlite3
# PostgreSQL production example:
# DATABASE_URL=postgres://imi_user:imi_password@localhost:5432/imi_jabar
# CORS — origins for mobile web / admin web / local dev
CORS_ALLOWED_ORIGINS=http://localhost:3000,http://localhost:5173,http://localhost:8000,http://127.0.0.1:5173
# JWT
JWT_ACCESS_TTL_MINUTES=60
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
\ No newline at end of file
# Python
__pycache__/
*.py[cod]
.venv/
venv/
*.egg-info/
dist/
build/
# Environment
.env
# Database (SQLite local)
data/*.sqlite3
data/*.sqlite3-*
# Media
media/
# IDE / OS
.DS_Store
.idea/
.vscode/
\ No newline at end of file
# IMI Jawa Barat — Backend API
Backend aplikasi digital IMI Pengprov Jawa Barat (tiket event, keanggotaan/KTA,
benefit partner, poin, konten, notifikasi). Django 4.2 + Django REST Framework.
## Stack
- Python 3.9 / Django 4.2 / DRF 3.16 / SimpleJWT
- drf-spectacular (OpenAPI + Swagger/ReDoc), django-filter
- SQLite untuk dev (default), PostgreSQL via `DATABASE_URL`
- OTP simulasi: di DEV kode OTP di-echo di response (`DEV_OTP_ECHO=True`)
## Quickstart
```bash
cd backend
python3 -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
cp .env.example .env # sesuaikan bila perlu
python manage.py migrate
python manage.py seed_dev # data contoh (opsional)
python manage.py runserver # http://127.0.0.1:8000
```
### Akun seed (`seed_dev`)
| Akun | No. HP | Password | Catatan |
|-----------------|-------------------|------------|------------------------------|
| Admin IMI | `+6281200000001` | `Admin123!`| super_user (semua staff API) |
| Member demo | `+6281200000002` | `Member123!`| KTA GOLD, 1.450 poin, KTA digital + asuransi |
Seed juga membuat: 27 Pengcab, 1 event demo (5 tier), 2 partner + benefit,
kategori konten, artikel resmi, banner.
### Menjalankan test
```bash
python manage.py test
```
25 test mencakup: auth (register→OTP→login), profil/KTA, alur checkout
tiket→payment→confirm→e-ticket→check-in, duplikat/bogus scan, wallet & redeem
partner, konten, notifikasi.
## Konfigurasi
Variabel di `.env` (lihat `.env.example`):
| Variabel | Keterangan |
|---------------------------|---------------------------------------------------|
| `DJANGO_SECRET_KEY` | ganti di produksi |
| `DJANGO_DEBUG` | default `True` (dev) |
| `DATABASE_URL` | `sqlite:///data/db.sqlite3` atau `postgres://...` |
| `CORS_ALLOWED_ORIGINS` | origin web/mobile |
| `JWT_ACCESS_TTL_MINUTES` | masa berlaku access token (default 60) |
| `OTP_EXPIRY_MINUTES` | masa berlaku OTP (default 5) |
| `DEV_OTP_ECHO` | `True` = OTP tampil di response (dev) |
PostgreSQL: cukup set `DATABASE_URL=postgres://user:pass@host:5432/db` —
driver `psycopg2-binary` sudah di `requirements.txt` (wajib `pip install`).
## Konvensi API
- Base URL: `http://<host>:8000/api/v1/`
- Autentikasi: `Authorization: Bearer <access>`
- **Trailing slash wajib** untuk route router (orders, events, partners, wallet,
dst.). Route `path()` berikut TANPA trailing slash: `auth/*`, `me`,
`me/notification-prefs`, `dashboard/stats`, `notification-preferences`,
`scan/check-in`.
- Pagination: endpoint koleksi mengembalikan `{count, next, previous, results}`.
- Konstanta bisnis terkonsentrasi di `apps/common/constants.py`.
Dokumentasi interaktif: `/api/docs/` (Swagger), `/api/redoc/`, schema `/api/schema/`.
## Alur Auth & OTP
1. `POST /api/v1/auth/register` — `account_type` (kta|non_kta), `full_name`,
`phone`, `nik` (16 digit), `pengcab_id`, `password` + `confirm_password`,
`terms_accepted: true`. Response menyertakan `otp.echo` (dev).
2. `POST /api/v1/auth/verify-otp` — `{phone, otp}` → `tokens.{access, refresh}`,
member aktif.
3. `POST /api/v1/auth/login` — `{identifier, password}`; identifier = nomor HP /
email / nomor KTA.
4. `POST /api/v1/auth/forgot-password` → OTP tujuan `reset`;
`POST /api/v1/auth/reset-password` — `{phone, otp, password, confirm_password}`.
5. `POST /api/v1/auth/token/refresh` — `{refresh}` → access baru.
## Endpoint by Domain
### Member & Akun (`apps.accounts`)
| Method | Path | Auth | Catatan |
|--------|-------------------------------|-------|--------------------------------------|
| GET | `/api/v1/me` | user | profil + member + KTA + asuransi |
| PATCH | `/api/v1/me` | user | update profil member |
| GET | `/api/v1/pengcab/` | - | daftar Pengcab |
| GET | `/api/v1/members/cards/` | user | kartu KTA/lisensi propos |
| POST | `/api/v1/members/cards/{id}/rotate_qr/` | user | putar QR KTA |
| POST | `/api/v1/members/cards/issue_license/` | member | terbit lisensi balap |
| GET/POST| `/api/v1/members/vehicles/` | member| daftar kendaraan |
| GET | `/api/v1/admin/members/` | staff | daftar member |
| POST | `/api/v1/admin/members/{id}/action/` | staff | verify/suspend/activate/expire/issue_license |
| GET | `/api/v1/dashboard/stats` | staff | ringkasan statistik platform |
### Event & Tiket Katalog (`apps.events`)
`GET /api/v1/events/`, `GET /api/v1/events/{slug}/`, `GET /api/v1/circuits/`,
`GET /api/v1/ticket-tiers/` (filter `event`).
### Checkout Tiket (`apps.ticketing`)
Urutan: **create order → payment → confirm → e-ticket → check-in**.
1. `POST /api/v1/orders/` (member) body:
```json
{"event_id": 1, "terms_accepted": true,
"lines": [{"tier_id": 3, "session_id": 3, "quantity": 2, "receiver_name": "Uji"}]}
```
Perhitungan: harga sesi/kuota per tier, diskon member KTA otomatis 15%,
service fee Rp5.000, PB1 10% dari nilai setelah diskon. Order hold 15 menit.
2. `POST /api/v1/orders/{id}/payment/` — `{"method": "qris"|"va"|"cc"}` → instrumen
bayar (NMID/VA/CC).
3. `POST /api/v1/orders/{id}/confirm/` — simulasi verifikasi gateway → status
`paid`, e-tiket + QR diterbitkan.
4. `DELETE /api/v1/orders/{id}/` — batalkan order, kuota tier dirilis.
5. `GET /api/v1/tickets/?scope=active|history` — e-tiket user.
6. `POST /api/v1/scan/check-in` (staff) — `{"scan_code": "<qr_token|hash|kode>", "gate": "GATE-1"}`.
Respons: `success`, `duplicate` (sudah pernah scan), `rejected` (QR tak dikenal / status tiket tidak membolehkan).
### Partner, Wallet, Poin (`apps.partners`)
| Method | Path | Auth | Catatan |
|--------|----------------------------|--------|----------------------------------------|
| GET | `/api/v1/partners/` | - | katalog mitra (slug lookup) |
| GET | `/api/v1/partners/{slug}/` | - | detail mitra; `.../{slug}/outlets/` |
| GET | `/api/v1/partners/benefits/` | - | katalog benefit (filter `category`, `region`) |
| GET | `/api/v1/partners/{...}/benefits/admin/` | staff | kelola benefit |
| GET | `/api/v1/wallet/balance/` | member | poin + ringkasan voucher |
| POST | `/api/v1/wallet/claim/` | member | `{"benefit_id":1,"points_redeem":0}`; 0 poin = e-voucher + cashback 50 poin |
| GET | `/api/v1/wallet/points/` | member | saldo + riwayat poin |
| POST | `/api/v1/wallet/{id}/book/`| member | booking slot `{outlet_id, scheduled_at}` |
| POST | `/api/v1/redeem/redeem/` | staff | `{"scan_code":"VQ-…"}` kasir partner |
| GET | `/api/v1/redemptions/` | staff | riwayat penggunaan voucher |
### Konten (`apps.content`)
- `GET /api/v1/articles/` (publik, filter/status published), `GET /api/v1/articles/{slug}/` (retrieve menambah views).
- `GET/POST/PATCH /api/v1/articles/manage/` (staff) — terbaca; slug kosong → auto dari judul; status `published` → `published_at` otomatis.
- `GET /api/v1/categories/`, `GET /api/v1/banners/` (aktif + jendela waktu), `GET /api/v1/media/`.
### Notifikasi (`apps.notifications`)
- `GET /api/v1/notifications/`, `GET /api/v1/notifications/unread_count/`,
`POST /api/v1/notifications/{id}/mark_read/`, `POST /api/v1/notifications/mark_all_read/`.
- `GET/PATCH /api/v1/notification-preferences` (atau alias `me/notification-prefs`).
## Peran & Izin
- `member` — checkout tiket, wallet, profil.
- Staff: `super_admin` / `admin`, plus `event`, `content`, `partnership`,
`reporting` (RBAC di `apps/common/permissions.py`). Superuser melewati semua.
- `AnyStaff` — scan check-in, dashboard, admin member.
- `PartnershipStaff` — admin partner/benefit, redeem voucher.
## Struktur Proyek
```
backend/
config/ # settings + root urls (api/v1, api/schema, api/docs)
apps/
accounts/ # user, auth+OTP, member, KTA, dashboard
events/ # event, circuit, sesi, tier tiket
ticketing/ # order, payment, e-ticket, check-in
partners/ # mitra, benefit, voucher, poin, redemption
content/ # artikel, media, banner, kategori
notifications/ # inbox & preferensi
common/ # konstanta bisnis, utils, permissions, seed_dev, test helper
common/management/commands/seed_dev.py
```
Catatan handover:
- E-tiket QR bersifat statik per transaksi (`qr_token` `TQ-…`, `hash_code` `HASH-…`,
keduanya diterima di scan).
- Belum ada integrasi gateway pembayaran/WhatsApp (OTP) nyata — titik integrasi
ditandai TODO di `apps/ticketing/services.py` & `apps/accounts/services.py`.
\ No newline at end of file
from django.contrib import admin
from .models import AuditLog, InsurancePolicy, Member, MembershipCard, MemberVehicle, OtpCode, Pengcab, User
@admin.register(User)
class UserAdmin(admin.ModelAdmin):
list_display = ["id", "full_name", "phone", "email", "role", "is_active", "is_staff", "date_joined"]
list_filter = ["role", "is_active", "is_staff"]
search_fields = ["phone", "email", "full_name"]
readonly_fields = ["date_joined", "last_login"]
@admin.register(Member)
class MemberAdmin(admin.ModelAdmin):
list_display = ["id", "full_name", "member_number", "tier", "account_type", "points_balance", "status"]
list_filter = ["status", "tier", "account_type", "pengcab"]
search_fields = ["full_name", "member_number", "nik", "phone", "email"]
@admin.register(MembershipCard)
class MembershipCardAdmin(admin.ModelAdmin):
list_display = ["id", "member", "card_number", "card_type", "status", "valid_until"]
list_filter = ["card_type", "status"]
search_fields = ["card_number", "member__member_number"]
@admin.register(InsurancePolicy)
class InsurancePolicyAdmin(admin.ModelAdmin):
list_display = ["member", "policy_number", "provider", "status", "coverage_amount", "valid_until"]
list_filter = ["provider", "status"]
search_fields = ["member__member_number", "policy_number"]
@admin.register(MemberVehicle)
class MemberVehicleAdmin(admin.ModelAdmin):
list_display = ["member", "vehicle_name", "category", "start_number", "scrutineering_status"]
list_filter = ["category", "scrutineering_status", "discipline"]
search_fields = ["vehicle_name", "member__member_number"]
@admin.register(Pengcab)
class PengcabAdmin(admin.ModelAdmin):
list_display = ["id", "name", "city", "code", "sort_order", "is_active"]
search_fields = ["name", "city", "code"]
@admin.register(OtpCode)
class OtpCodeAdmin(admin.ModelAdmin):
list_display = ["phone", "purpose", "is_used", "created_at", "expires_at", "attempts"]
list_filter = ["purpose", "is_used"]
search_fields = ["phone"]
@admin.register(AuditLog)
class AuditLogAdmin(admin.ModelAdmin):
list_display = ["id", "user", "action", "entity", "entity_id", "ip", "created_at"]
list_filter = ["action", "entity"]
search_fields = ["user__phone", "entity", "action"]
\ No newline at end of file
from django.apps import AppConfig
class AccountsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.accounts"
verbose_name = "Accounts, Member & KTA"
\ No newline at end of file
"""Auth backend: login dengan KTA number / email / nomor HP."""
from __future__ import annotations
from django.contrib.auth.backends import ModelBackend
from apps.common.utils import clean_phone
class KTAPhoneEmailBackend(ModelBackend):
"""Autentikasi dengan identifier lain selain HP:
- Nomor KTA (IMI-JB-...)
- email
- nomor HP (raw / +62 / 08)
"""
def authenticate(self, request, username=None, password=None, **kwargs):
if username is None or password is None:
return None
user = self.get_user_by_identifier(username)
if user and user.check_password(password) and self.user_can_authenticate(user):
return user
return None
def get_user_by_identifier(self, identifier: str):
from apps.accounts.models import Member, User
from django.contrib.auth import get_user_model
UserModel = get_user_model()
identifier = identifier.strip()
if "@" in identifier:
return UserModel.objects.filter(email__iexact=identifier).first()
try_phone = clean_phone(identifier)
user = UserModel.objects.filter(phone=try_phone).first()
if user:
return user
member = Member.objects.filter(member_number__iexact=identifier).first()
return member.user if member else None
\ No newline at end of file
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
"""Business logic: registrasi, OTP, pembuatan member/KTA, poin."""
from __future__ import annotations
from django.conf import settings
from django.db import transaction
from django.utils import timezone
from rest_framework.exceptions import ValidationError
from apps.common.utils import generate_otp, generate_qr_token, otp_expiry
from .models import InsurancePolicy, Member, MembershipCard, OtpCode, Pengcab, User
# ---------------------------------------------------------------- OTP
def add_years(day, years=1):
try:
return day.replace(year=day.year + years)
except ValueError:
return day.replace(year=day.year + years, month=2, day=28)
def send_otp(phone: str, purpose: str, echo: bool | None = None) -> dict:
"""Mengirim (simulasi) OTP. echo=True => otp dibalikan di response (DEV saja)."""
OtpCode.objects.filter(phone=phone, purpose=purpose, is_used=False).update(is_used=True)
code = generate_otp()
otp = OtpCode.objects.create(
phone=phone,
code=code,
purpose=purpose,
expires_at=otp_expiry(),
)
echo = settings.DEV_OTP_ECHO if echo is None else echo
# TODO: integrasi WhatsApp gateway (Twilio/Wablas/Fonnte) produksi
return {"otp_id": otp.id, "expires_at": otp.expires_at, "echo": code if echo else None}
def verify_otp(phone: str, code: str, purpose: str) -> OtpCode:
otp = (
OtpCode.objects.filter(phone=phone, purpose=purpose, is_used=False)
.order_by("-created_at")
.first()
)
if not otp:
raise ValidationError({"otp": "Kode OTP tidak ditemukan."})
if otp.is_expired:
raise ValidationError({"otp": "Kode OTP telah kedaluwarsa. Mohon minta kode baru."})
if otp.attempts >= 5:
raise ValidationError({"otp": "Terlalu banyak percobaan. Minta kode OTP baru."})
if otp.code != code:
otp.attempts += 1
otp.save(update_fields=["attempts"])
raise ValidationError({"otp": "Kode OTP salah."})
otp.is_used = True
otp.save(update_fields=["is_used"])
return otp
# ---------------------------------------------------------------- member
def build_member_number(user: User, season: str) -> str:
"""IMI-JB-<tahun>-<seq>. Ambil seq tertinggi lalu +1."""
year = season.split("/")[0] if "/" in season else season
latest = 0
prefix = f"IMI-JB-{year}-"
for num in Member.objects.filter(member_number__startswith=prefix).values_list("member_number", flat=True):
try:
latest = max(latest, int(num.split("-")[-1]))
except (ValueError, IndexError):
pass
return f"{prefix}{latest + 1:04d}"
def current_season() -> str:
now = timezone.now()
year = now.year
return f"{year}/{year + 1}"
@transaction.atomic
def create_member_account(user: User, data: dict) -> Member:
"""Membuat profil member + kartu KTA digital setelah OTP terverifikasi."""
season = current_season()
member = Member.objects.create(
user=user,
full_name=data.get("full_name") or user.full_name,
account_type=data.get("account_type", Member.AccountType.KTA),
nik=data.get("nik", ""),
phone=user.phone,
email=user.email,
birth_date=data.get("birth_date"),
gender=data.get("gender", ""),
blood_type=data.get("blood_type", ""),
address=data.get("address", ""),
city=data.get("city", ""),
province=data.get("province", "Jawa Barat"),
postal_code=data.get("postal_code", ""),
pengcab_id=data.get("pengcab_id"),
club=data.get("club", ""),
affiliation=data.get("affiliation", ""),
membership_season=season,
member_number=build_member_number(user, season),
terms_accepted=bool(data.get("terms_accepted")),
)
valid_from = timezone.now().date()
valid_until = add_years(valid_from, 1)
MembershipCard.objects.create(
member=member,
card_type=MembershipCard.CardType.KTA,
card_number=member.member_number,
valid_from=valid_from,
valid_until=valid_until,
qr_token=generate_qr_token("QR-"),
)
InsurancePolicy.objects.create(
member=member,
policy_number=f"#IMI-JB-{timezone.now().year}-{member.pk:05d}",
coverage_amount=data.get("coverage_amount", 50_000_000),
valid_until=valid_until,
)
return member
def issue_race_license(member: Member, license_class: str, discipline: str = "") -> MembershipCard:
"""Menerbitkan Lisensi Balap (SIM IMI) untuk member yang sudah KTA verified."""
season = current_season()
year = season.split("/")[0]
latest = 0
prefix = f"SIM-JB-{year}-"
for num in MembershipCard.objects.filter(card_number__startswith=prefix).values_list("card_number", flat=True):
try:
latest = max(latest, int(num.split("-")[-1]))
except (ValueError, IndexError):
pass
valid_from = timezone.now().date()
valid_until = add_years(valid_from, 1)
return MembershipCard.objects.create(
member=member,
card_type=MembershipCard.CardType.RACE_LICENSE,
card_number=f"{prefix}{latest + 1:05d}",
valid_from=valid_from,
valid_until=valid_until,
license_class=license_class,
discipline=discipline,
qr_token=generate_qr_token("QR-"),
is_primary=False,
)
# ---------------------------------------------------------------- points
def change_points(member: Member, delta: int, reason: str, ref: str = "") -> None:
from apps.partners.models import PointsLedger
member.points_balance = max(member.points_balance + delta, 0)
member.save(update_fields=["points_balance", "updated_at"])
PointsLedger.objects.create(
member=member,
delta=delta,
balance_after=member.points_balance,
reason=reason,
ref=ref,
)
\ No newline at end of file
"""Test alur autentikasi & profil: register -> OTP -> login -> me."""
from __future__ import annotations
from rest_framework.test import APIClient, APITestCase
from apps.common.testing import create_pengcab, make_member, make_staff, member_client
from apps.common.utils import generate_qr_token
from apps.events.models import Event, EventCategory, TicketTier
from apps.ticketing.models import Order, Ticket
class AuthFlowTests(APITestCase):
def setUp(self):
self.client = APIClient()
self.pengcab = create_pengcab()
def test_register_verify_otp_login_me(self):
r = self.client.post(
"/api/v1/auth/register",
{
"account_type": "kta",
"full_name": "Uji OTP",
"phone": "+6281234567891",
"nik": "3204011501990001",
"pengcab_id": self.pengcab.id,
"club": "Bikers Lodaya",
"gender": "L",
"password": "Uji12345!",
"confirm_password": "Uji12345!",
"terms_accepted": True,
},
format="json",
)
self.assertEqual(r.status_code, 201, r.data)
otp = r.data["otp"]["echo"]
self.assertTrue(otp)
r = self.client.post(
"/api/v1/auth/verify-otp",
{"phone": "+6281234567891", "otp": otp},
format="json",
)
self.assertEqual(r.status_code, 200, r.data)
self.assertIn("access", r.data["tokens"])
self.assertEqual(r.data["member"]["status"], "active")
r = self.client.post(
"/api/v1/auth/login",
{"identifier": "+6281234567891", "password": "Uji12345!"},
format="json",
)
self.assertEqual(r.status_code, 200, r.data)
me = member_client("+6281234567891", "Uji12345!")
r = me.get("/api/v1/me")
self.assertEqual(r.status_code, 200)
self.assertEqual(r.data["member"]["member_number"], "IMI-JB-2026-0001")
self.assertEqual(len(r.data["member"]["cards"]), 1)
def test_register_wajib_match_password(self):
r = self.client.post(
"/api/v1/auth/register",
{
"account_type": "kta",
"full_name": "Gagal",
"phone": "+6281234567892",
"password": "abc12345",
"confirm_password": "beda1234",
"terms_accepted": True,
},
format="json",
)
self.assertEqual(r.status_code, 400)
self.assertIn("confirm_password", r.data)
def test_register_wajib_terms(self):
r = self.client.post(
"/api/v1/auth/register",
{
"account_type": "kta",
"full_name": "Gagal",
"phone": "+6281234567893",
"password": "abc12345",
"confirm_password": "abc12345",
"terms_accepted": False,
},
format="json",
)
self.assertEqual(r.status_code, 400)
self.assertIn("terms_accepted", r.data)
def test_login_salah_password(self):
r = self.client.post(
"/api/v1/auth/login",
{"identifier": "+6281234567893", "password": "salah123!"},
format="json",
)
self.assertEqual(r.status_code, 401)
class ProfileTests(APITestCase):
def setUp(self):
self.pengcab = create_pengcab()
self.user, self.member = make_member("+6281300000001", "Profil")
self.client = member_client("+6281300000001")
def test_patch_profile(self):
r = self.client.patch("/api/v1/me", {"club": "Roda Kencang", "blood_type": "B"}, format="json")
self.assertEqual(r.status_code, 200)
self.assertEqual(r.data["club"], "Roda Kencang")
def test_kartu_pengcab_terexpose(self):
r = self.client.get("/api/v1/pengcab/")
self.assertEqual(r.status_code, 200)
self.assertEqual(r.data["count"], 1)
class StaffTests(APITestCase):
def test_dashboard_stats_hanya_staff(self):
staff = make_staff()
client = member_client(staff.phone)
r = client.get("/api/v1/dashboard/stats")
self.assertEqual(r.status_code, 200)
self.assertIn("members_total", r.data)
_, member = make_member("+6281400000001", "Biasa")
r2 = member_client("+6281400000001").get("/api/v1/dashboard/stats")
self.assertEqual(r2.status_code, 403)
def test_revenue_total_dihitung_per_order_paid(self):
staff = make_staff("+6281200000088")
client = member_client("+6281200000088")
user, _ = make_member("+6281400000012", "Pembeli")
cat = EventCategory.objects.create(code="rev", name="Road Race")
ev = Event.objects.create(
title="GP Revenue",
slug="gp-revenue",
category=cat,
status=Event.Status.PUBLISHED,
start_date="2026-12-01",
end_date="2026-12-02",
)
tier = TicketTier.objects.create(event=ev, code="early", name="Early Bird", price=50000)
order = Order.objects.create(
user=user, event=ev, status="paid",
subtotal=100000, discount_amount=0, service_fee=0, tax_amount=0, total=50000,
)
Ticket.objects.create(code="T-0001", order=order, tier=tier, event=ev, holder_name="Satu", qr_token=generate_qr_token())
Ticket.objects.create(code="T-0002", order=order, tier=tier, event=ev, holder_name="Dua", qr_token=generate_qr_token())
r = client.get("/api/v1/dashboard/stats")
self.assertEqual(r.status_code, 200)
self.assertEqual(r.data["revenue_total"], 50000)
self.assertIn("members_unverified", r.data)
\ No newline at end of file
from django.urls import include, path
from rest_framework.routers import DefaultRouter
from rest_framework_simplejwt.views import TokenRefreshView, TokenVerifyView
from . import views
router = DefaultRouter()
router.register("pengcab", views.PengcabViewSet, basename="pengcab")
router.register("members/cards", views.MembershipCardViewSet, basename="membership-card")
router.register("members/vehicles", views.VehicleViewSet, basename="member-vehicle")
router.register("admin/members", views.AdminMemberViewSet, basename="admin-member")
router.register("admin/users", views.AdminUserViewSet, basename="admin-user")
urlpatterns = [
path("auth/register", views.RegisterView.as_view(), name="auth-register"),
path("auth/verify-otp", views.VerifyOtpView.as_view(), name="auth-verify-otp"),
path("auth/resend-otp", views.ResendOtpView.as_view(), name="auth-resend-otp"),
path("auth/login", views.LoginView.as_view(), name="auth-login"),
path("auth/token/refresh", TokenRefreshView.as_view(), name="auth-token-refresh"),
path("auth/token/verify", TokenVerifyView.as_view(), name="auth-token-verify"),
path("auth/forgot-password", views.ForgotPasswordView.as_view(), name="auth-forgot"),
path("auth/reset-password", views.ResetPasswordView.as_view(), name="auth-reset"),
path("auth/change-password", views.ChangePasswordView.as_view(), name="auth-change-password"),
path("me", views.MeView.as_view(), name="me"),
path("me/notification-prefs", views.NotificationPrefsView.as_view(), name="me-notif-prefs"),
path("dashboard/stats", views.DashboardStatsView.as_view(), name="dashboard-stats"),
path("", include(router.urls)),
]
\ No newline at end of file
This diff is collapsed.
from django.apps import AppConfig
class CommonConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.common"
verbose_name = "Common"
\ No newline at end of file
"""Konstanta & data master untuk aplikasi."""
# 27 Pengcab IMI Jawa Barat (kota/kabupaten)
PENGCABS = [
{"code": "bdg", "name": "Pengcab Kota Bandung", "city": "Kota Bandung", "circuit_name": ""},
{"code": "kab-bdg", "name": "Pengcab Kabupaten Bandung", "city": "Kab. Bandung", "circuit_name": ""},
{"code": "kbb", "name": "Pengcab Kab. Bandung Barat", "city": "Kab. Bandung Barat", "circuit_name": ""},
{"code": "bogor", "name": "Pengcab Kab. / Kota Bogor", "city": "Bogor", "circuit_name": "Sirkuit Sentul"},
{"code": "sentul", "name": "Pengcab Bogor - Sentul Paddock", "city": "Sentul", "circuit_name": "Sentul International Circuit"},
{"code": "subang", "name": "Pengcab Kab. Subang", "city": "Subang", "circuit_name": "Sirkuit Gery Mang"},
{"code": "cirebon", "name": "Pengcab Cirebon Raya", "city": "Cirebon", "circuit_name": ""},
{"code": "tasik", "name": "Pengcab Tasikmalaya", "city": "Tasikmalaya", "circuit_name": "Sirkuit Bukit Peusar"},
{"code": "bekasi", "name": "Pengcab Kab. / Kota Bekasi", "city": "Bekasi", "circuit_name": ""},
{"code": "karawang", "name": "Pengcab Karawang", "city": "Karawang", "circuit_name": ""},
{"code": "majalengka", "name": "Pengcab Majalengka", "city": "Majalengka", "circuit_name": ""},
{"code": "cimahi", "name": "Pengcab Kota Cimahi", "city": "Cimahi", "circuit_name": ""},
{"code": "sukabumi", "name": "Pengcab Kab. / Kota Sukabumi", "city": "Sukabumi", "circuit_name": ""},
{"code": "garut", "name": "Pengcab Garut", "city": "Garut", "circuit_name": ""},
{"code": "purwakarta", "name": "Pengcab Purwakarta", "city": "Purwakarta", "circuit_name": ""},
{"code": "pangandaran", "name": "Pengcab Pangandaran", "city": "Pangandaran", "circuit_name": ""},
{"code": "banjar", "name": "Pengcab Kota Banjar", "city": "Banjar", "circuit_name": ""},
{"code": "indramayu", "name": "Pengcab Indramayu", "city": "Indramayu", "circuit_name": ""},
{"code": "sumedang", "name": "Pengcab Sumedang", "city": "Sumedang", "circuit_name": ""},
{"code": "kuningan", "name": "Pengcab Kuningan", "city": "Kuningan", "circuit_name": ""},
{"code": "cianjur", "name": "Pengcab Cianjur", "city": "Cianjur", "circuit_name": ""},
{"code": "depok", "name": "Pengcab Kota Depok", "city": "Depok", "circuit_name": ""},
{"code": "bandung-raya", "name": "Pengcab Bandung Raya", "city": "Bandung Raya", "circuit_name": ""},
{"code": "bogor-barat", "name": "Pengcab Bogor Barat", "city": "Bogor Barat", "circuit_name": ""},
{"code": "jababeka", "name": "Pengcab Jababeka", "city": "Cikarang", "circuit_name": "Meikarta Central Park Circuit"},
{"code": "puncak", "name": "Pengcab Puncak & Cianjur Selatan", "city": "Cianjur", "circuit_name": ""},
{"code": "priangan-timur", "name": "Pengcab Priangan Timur", "city": "Priangan", "circuit_name": ""},
]
# Kategori benefit / voucher
BENEFIT_CATEGORIES = [
{"code": "workshop", "name": "Bengkel & Dyno"},
{"code": "tires", "name": "Ban & Kompon"},
{"code": "gear", "name": "Apparel & Helm"},
{"code": "circuit", "name": "Sirkuit & Track"},
{"code": "fnb", "name": "F&B Pitstop"},
]
# Kategori event
EVENT_CATEGORIES = [
{"code": "road-race", "name": "Road Race"},
{"code": "motocross", "name": "Motocross"},
{"code": "slalom-drift", "name": "Slalom & Drift"},
{"code": "touring-rally", "name": "Touring & Rally"},
{"code": "drag-bike", "name": "Drag Bike"},
{"code": "modifikasi", "name": "Modifikasi"},
{"code": "speed-offroad", "name": "Speed Offroad"},
]
# Kategori konten/berita
CONTENT_CATEGORIES = [
{"code": "berita-resmi", "name": "Berita Resmi"},
{"code": "press-release", "name": "Press Release"},
{"code": "artikel", "name": "Artikel"},
{"code": "video", "name": "Video"},
{"code": "pengumuman", "name": "Pengumuman"},
]
# Konstanta bisnis
MEMBER_DISCOUNT_PERCENT = 15 # diskon anggota KTA di checkout tiket
MAX_TICKETS_PER_ORDER = 4
SERVICE_FEE_TICKET = 5_000 # biaya layanan & asuransi sirkuit (flat)
TAX_RATE_HIBURAN = 0.10 # PB1
POINTS_CLAIM_BENEFIT = 50 # poin cashback saat klaim benefit
POINTS_RATE_PER_1000_IDR = 10 # 500 poin = Rp 50.000
POINTS_REDEEM_AMOUNT = 50_000 # nominal per 500 poin
POINTS_REDEEM_QTY = 500
MIN_AGE_FREE_TICKET = 6 # anak < 6 th gratis
ORDER_HOLD_MINUTES = 15 # hold kuota pembayaran
QR_TICKET_TTL_SECONDS = 60
\ No newline at end of file
This diff is collapsed.
"""Permission helper berbasis role RBAC."""
from __future__ import annotations
from rest_framework.permissions import BasePermission, SAFE_METHODS
ADMIN_ROLES = {"super_admin", "admin"}
class IsMember(BasePermission):
"""User harus punya profil member."""
def has_permission(self, request, view):
user = request.user
return bool(user and user.is_authenticated and hasattr(user, "member"))
class IsAdminRole(BasePermission):
"""Super Admin / Admin."""
def has_permission(self, request, view):
user = request.user
return bool(user and user.is_authenticated and user.is_admin_role)
class AdminOrReadOnly(BasePermission):
"""Baca terbuka untuk semua; tulis hanya admin."""
def has_permission(self, request, view):
if request.method in SAFE_METHODS:
return True
user = request.user
return bool(user and user.is_authenticated and user.is_admin_role)
def role_allowed(*roles: str) -> type[BasePermission]:
"""Membuat permission class custom yang mengizinkan kumpulan role tertentu."""
class RolePermission(BasePermission):
def has_permission(self, request, view):
user = request.user
if not (user and user.is_authenticated):
return False
if user.is_superuser:
return True
return user.role in roles
RolePermission.__name__ = "Role_" + "_".join(roles)
return RolePermission
class IsEventStaff(role_allowed(*ADMIN_ROLES, "event")):
pass
# Ringkas:
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
"""Helper pembuatan data test (dipakai module tests di seluruh app)."""
from __future__ import annotations
from rest_framework.test import APIClient
from apps.accounts.models import Member, User, Pengcab
from apps.accounts.services import create_member_account
def create_pengcab(code: str = "bdg", name: str = "Pengcab Kota Bandung", city: str = "Kota Bandung") -> Pengcab:
return Pengcab.objects.create(code=code, name=name, city=city)
def make_user(phone: str, full_name: str = "User Test", password: str = "Pass12345!", role: str = "member", superuser: bool = False) -> User:
user = User(phone=phone, full_name=full_name, role=role, is_phone_verified=True)
user.set_password(password)
if superuser:
user.is_superuser = True
user.save()
return user
def make_member(
phone: str,
full_name: str = "Member Test",
password: str = "Pass12345!",
pengcab: Pengcab | None = None,
verified: bool = True,
points: int = 0,
account_type: str = Member.AccountType.KTA,
):
user = make_user(phone, full_name, password)
# NIK 16 digit unik per nomor HP (hindari clash di test DB)
digits = "".join(ch for ch in phone if ch.isdigit())
nik = ("0" * 16 + digits)[-16:]
member = create_member_account(
user,
{
"full_name": full_name,
"account_type": account_type,
"nik": nik,
"pengcab_id": pengcab.id if pengcab else None,
"club": "Club Test",
"city": "Kota Bandung",
"terms_accepted": True,
},
)
member.status = Member.Status.ACTIVE
member.verified = verified
member.points_balance = points
member.save(update_fields=["status", "verified", "points_balance", "updated_at"])
return user, member
def make_staff(phone: str = "+6281200000001", password: str = "Pass12345!") -> User:
return make_user(phone, "Staff IMI", password, role="super_admin", superuser=True)
def login_token(client: APIClient, identifier: str, password: str = "Pass12345!") -> str:
resp = client.post("/api/v1/auth/login", {"identifier": identifier, "password": password}, format="json")
assert resp.status_code == 200, resp.data
return resp.data["tokens"]["access"]
def member_client(phone: str, password: str = "Pass12345!") -> APIClient:
client = APIClient()
client.credentials(HTTP_AUTHORIZATION="Bearer " + login_token(client, phone, password))
return client
\ No newline at end of file
"""Utilitas bersama untuk seluruh apps."""
from __future__ import annotations
import hashlib
import random
import re
import secrets
import string
from datetime import datetime, timedelta
from django.conf import settings
from django.utils import timezone
IDR_TAX_RATE = 0.10 # Pajak Hiburan Daerah (PB1) 10%
PHONE_RE = re.compile(r"^\+?[0-9]{8,15}$")
def clean_phone(value: str) -> str:
"""Normalisasi nomor HP ke format +628xxxx."""
v = value.strip().replace(" ", "").replace("-", "")
if v.startswith("08"):
v = "+62" + v[1:]
elif v.startswith("8"):
v = "+62" + v
elif v.startswith("62") and not v.startswith("+62"):
v = "+" + v
if not v.startswith("+"):
v = "+" + v
return v.replace(" ", "")
def is_valid_phone(value: str) -> bool:
return bool(PHONE_RE.match(clean_phone(value)))
def generate_otp() -> str:
return f"{random.SystemRandom().randint(0, 999999):06d}"
def otp_expiry() -> datetime:
return timezone.now() + timedelta(minutes=settings.OTP_EXPIRY_MINUTES)
def generate_qr_token(prefix: str = "") -> str:
raw = secrets.token_urlsafe(24)
digest = hashlib.sha256(raw.encode()).hexdigest()[:24].upper()
return f"{prefix}{digest}" if prefix else digest
def generate_seq_code(prefix: str, latest: int, width: int = 4) -> str:
"""prefix + zero-padded sequence, contoh IMI-JB-2024-0001."""
return f"{prefix}-{latest:0{width}d}"
def generate_random_code(prefix: str, length: int = 8) -> str:
chars = string.ascii_uppercase + string.digits
return f"{prefix}-{''.join(secrets.choice(chars) for _ in range(length))}"
def next_sequence(queryset, field: str, prefix_year: str | None = None) -> int:
"""Sequence terakhir untuk suatu kolom dengan prefix substring."""
latest = 0
for obj in queryset.values_list(field, flat=True):
try:
seq = int(str(obj).split("-")[-1])
except (ValueError, IndexError):
seq = 0
latest = max(latest, seq)
return latest + 1
def round_idr(value: float | int) -> int:
"""Bulatkan nominal ke ratusan terdekat (konvensi UI PB1)."""
return int(round(int(value) / 100.0) * 100)
def compute_ticket_price(
subtotal: int,
discount_amount: int = 0,
service_fee: int = 0,
tax_rate: float = IDR_TAX_RATE,
) -> dict:
"""Menghitung rincian harga: diskon, pajak (dari subtotal setelah diskon), total."""
taxable = max(subtotal - discount_amount, 0)
tax = round_idr(taxable * tax_rate)
total = subtotal - discount_amount + service_fee + tax
return {
"subtotal": subtotal,
"discount_amount": discount_amount,
"service_fee": service_fee,
"tax_amount": tax,
"total": total,
}
def humanize_rupiah(value: int) -> str:
return f"Rp {value:,.0f}".replace(",", ".")
_ACCENT_MAP = {
ord("á"): "a", ord("à"): "a", ord("â"): "a", ord("ä"): "a", ord("ã"): "a", ord("å"): "a",
ord("é"): "e", ord("è"): "e", ord("ê"): "e", ord("ë"): "e",
ord("í"): "i", ord("ì"): "i", ord("î"): "i", ord("ï"): "i",
ord("ó"): "o", ord("ò"): "o", ord("ô"): "o", ord("ö"): "o", ord("õ"): "o", ord("ø"): "o",
ord("ú"): "u", ord("ù"): "u", ord("û"): "u", ord("ü"): "u", ord("ñ"): "n", ord("ç"): "c",
}
def build_slug(value: str, max_length: int = 200, allow_unicode: bool = False) -> str:
"""Buat slug unik dari teks (kuat untuk aksen tanpa dependency eksternal)."""
from django.utils.text import slugify
base = str(value).translate(_ACCENT_MAP)
if allow_unicode:
base = str(value)
slug = slugify(base, allow_unicode=allow_unicode)[:max_length] or "untitled"
return slug
\ No newline at end of file
from django.contrib import admin
from .models import Article, Banner, Category, MediaAsset
@admin.register(Category)
class CategoryAdmin(admin.ModelAdmin):
list_display = ["code", "name", "sort_order"]
search_fields = ["code", "name"]
@admin.register(MediaAsset)
class MediaAssetAdmin(admin.ModelAdmin):
list_display = ["title", "kind", "event", "created_at"]
list_filter = ["kind"]
search_fields = ["title"]
@admin.register(Article)
class ArticleAdmin(admin.ModelAdmin):
list_display = ["title", "category", "author", "status", "is_featured", "is_official", "published_at"]
list_filter = ["status", "is_featured", "is_breaking", "is_official", "category"]
search_fields = ["title", "slug", "excerpt"]
prepopulated_fields = {"slug": ("title",)}
@admin.register(Banner)
class BannerAdmin(admin.ModelAdmin):
list_display = ["title", "link_type", "link_value", "ordinal", "is_active"]
list_filter = ["link_type", "is_active"]
search_fields = ["title"]
\ No newline at end of file
from django.apps import AppConfig
class ContentConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.content"
verbose_name = "Konten Berita & Media"
\ No newline at end of file
# Generated by Django 4.2.16 on 2026-10-06 09:09
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('events', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='Banner',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('title', models.CharField(blank=True, default='', max_length=150)),
('image', models.ImageField(upload_to='banners/')),
('link_type', models.CharField(default='event', help_text='event|benefit|article|url', max_length=16)),
('link_value', models.CharField(blank=True, default='', max_length=300)),
('ordinal', models.PositiveIntegerField(default=0)),
('is_active', models.BooleanField(default=True)),
('starts_at', models.DateTimeField(blank=True, null=True)),
('ends_at', models.DateTimeField(blank=True, null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
],
options={
'ordering': ['ordinal', '-created_at'],
},
),
migrations.CreateModel(
name='Category',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('code', models.SlugField(max_length=40, unique=True)),
('name', models.CharField(max_length=80)),
('icon', models.CharField(blank=True, default='', max_length=40)),
('sort_order', models.PositiveIntegerField(default=0)),
],
options={
'verbose_name_plural': 'categories',
'ordering': ['sort_order', 'name'],
},
),
migrations.CreateModel(
name='MediaAsset',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('title', models.CharField(max_length=200)),
('description', models.CharField(blank=True, default='', max_length=300)),
('kind', models.CharField(choices=[('image', 'Image'), ('video', 'Video'), ('document', 'Dokumen')], default='image', max_length=12)),
('uri', models.FileField(upload_to='media/')),
('thumbnail', models.ImageField(blank=True, null=True, upload_to='media/thumbs/')),
('created_at', models.DateTimeField(auto_now_add=True)),
('event', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='media', to='events.event')),
('uploaded_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ['-created_at'],
},
),
migrations.CreateModel(
name='Article',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('title', models.CharField(max_length=250)),
('slug', models.SlugField(max_length=250, unique=True)),
('excerpt', models.CharField(blank=True, default='', max_length=400)),
('body', models.TextField(default='')),
('author_byline', models.CharField(blank=True, default='', max_length=120)),
('featured_image', models.ImageField(blank=True, null=True, upload_to='articles/')),
('gallery', models.JSONField(blank=True, default=list, help_text='[{uri, caption}]')),
('is_featured', models.BooleanField(default=False)),
('is_breaking', models.BooleanField(default=False)),
('is_official', models.BooleanField(default=False, help_text='Pengumuman resmi PB IMI / IMI Jabar')),
('tags', models.JSONField(blank=True, default=list)),
('status', models.CharField(choices=[('draft', 'Draft'), ('published', 'Published'), ('archived', 'Arsip')], default='draft', max_length=12)),
('views_count', models.PositiveIntegerField(default=0)),
('published_at', models.DateTimeField(blank=True, null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('author', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='articles', to=settings.AUTH_USER_MODEL)),
('category', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='articles', to='content.category')),
('media', models.ManyToManyField(blank=True, related_name='articles_in', to='content.mediaasset')),
],
options={
'ordering': ['-published_at', '-created_at'],
},
),
]
"""Model konten: berita/artikel, galeri media & banner."""
from __future__ import annotations
from django.conf import settings
from django.db import models
class Category(models.Model):
code = models.SlugField(max_length=40, unique=True)
name = models.CharField(max_length=80)
icon = models.CharField(max_length=40, blank=True, default="")
sort_order = models.PositiveIntegerField(default=0)
class Meta:
ordering = ["sort_order", "name"]
verbose_name_plural = "categories"
def __str__(self):
return self.name
class MediaAsset(models.Model):
"""Asset media tunggal (galeri foto/video)."""
class Kind(models.TextChoices):
IMAGE = "image", "Image"
VIDEO = "video", "Video"
DOCUMENT = "document", "Dokumen"
title = models.CharField(max_length=200)
description = models.CharField(max_length=300, blank=True, default="")
kind = models.CharField(max_length=12, choices=Kind.choices, default=Kind.IMAGE)
uri = models.FileField(upload_to="media/")
thumbnail = models.ImageField(upload_to="media/thumbs/", null=True, blank=True)
event = models.ForeignKey("events.Event", null=True, blank=True, on_delete=models.SET_NULL, related_name="media")
uploaded_by = models.ForeignKey(settings.AUTH_USER_MODEL, null=True, blank=True, on_delete=models.SET_NULL)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ["-created_at"]
def __str__(self):
return self.title
class Article(models.Model):
"""Berita/artikel & pengumuman resmi."""
class Status(models.TextChoices):
DRAFT = "draft", "Draft"
PUBLISHED = "published", "Published"
ARCHIVED = "archived", "Arsip"
category = models.ForeignKey(Category, null=True, blank=True, on_delete=models.SET_NULL, related_name="articles")
title = models.CharField(max_length=250)
slug = models.SlugField(max_length=250, unique=True)
excerpt = models.CharField(max_length=400, blank=True, default="")
body = models.TextField(default="")
author = models.ForeignKey(settings.AUTH_USER_MODEL, null=True, blank=True, on_delete=models.SET_NULL, related_name="articles")
author_byline = models.CharField(max_length=120, blank=True, default="")
featured_image = models.ImageField(upload_to="articles/", null=True, blank=True)
media = models.ManyToManyField(MediaAsset, blank=True, related_name="articles_in")
gallery = models.JSONField(default=list, blank=True, help_text="[{uri, caption}]")
is_featured = models.BooleanField(default=False)
is_breaking = models.BooleanField(default=False)
is_official = models.BooleanField(default=False, help_text="Pengumuman resmi PB IMI / IMI Jabar")
tags = models.JSONField(default=list, blank=True)
status = models.CharField(max_length=12, choices=Status.choices, default=Status.DRAFT)
views_count = models.PositiveIntegerField(default=0)
published_at = models.DateTimeField(null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ["-published_at", "-created_at"]
def __str__(self):
return self.title
class Banner(models.Model):
"""Banner hero / carousel."""
title = models.CharField(max_length=150, blank=True, default="")
image = models.ImageField(upload_to="banners/")
link_type = models.CharField(max_length=16, default="event", help_text="event|benefit|article|url")
link_value = models.CharField(max_length=300, blank=True, default="")
ordinal = models.PositiveIntegerField(default=0)
is_active = models.BooleanField(default=True)
starts_at = models.DateTimeField(null=True, blank=True)
ends_at = models.DateTimeField(null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ["ordinal", "-created_at"]
def __str__(self):
return self.title or f"Banner #{self.pk}"
\ No newline at end of file
from rest_framework import serializers
from .models import Article, Banner, Category, MediaAsset
class CategorySerializer(serializers.ModelSerializer):
class Meta:
model = Category
fields = ["id", "code", "name", "icon"]
class MediaAssetSerializer(serializers.ModelSerializer):
kind_display = serializers.CharField(source="get_kind_display", read_only=True)
class Meta:
model = MediaAsset
fields = ["id", "title", "description", "kind", "kind_display", "uri", "thumbnail", "event", "created_at"]
read_only_fields = ["id", "created_at"]
class ArticleSerializer(serializers.ModelSerializer):
category_code = serializers.CharField(source="category.code", read_only=True, default=None)
category_name = serializers.CharField(source="category.name", read_only=True, default=None)
status_display = serializers.CharField(source="get_status_display", read_only=True)
author_name = serializers.CharField(source="author.full_name", read_only=True, default=None)
slug = serializers.SlugField(required=False, allow_blank=True, help_text="Kosongkan untuk auto-generate dari judul")
class Meta:
model = Article
fields = [
"id", "category", "category_code", "category_name", "title", "slug", "excerpt", "body",
"author", "author_name", "author_byline", "featured_image", "gallery", "tags",
"is_featured", "is_breaking", "is_official", "status", "status_display",
"views_count", "published_at", "created_at", "updated_at",
]
read_only_fields = ["views_count", "published_at"]
def validate_title(self, value):
if not value.strip():
raise serializers.ValidationError("Judul tidak boleh kosong.")
return value.strip()
class BannerSerializer(serializers.ModelSerializer):
class Meta:
model = Banner
fields = ["id", "title", "image", "link_type", "link_value", "ordinal",
"is_active", "starts_at", "ends_at"]
read_only_fields = ["id"]
\ No newline at end of file
"""Test endpoint konten publik & pengelolaan artikel (staff)."""
from __future__ import annotations
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 Article, Banner, Category
class ContentTests(APITestCase):
def setUp(self):
create_pengcab()
self.category = Category.objects.create(code="berita", name="Berita", sort_order=1)
self.article = Article.objects.create(
title="IMI Jabar Umumkan Kalender 2026",
slug="imi-jabar-kalender-2026",
category=self.category,
excerpt="Excerpt",
body="Isi berita.",
status=Article.Status.PUBLISHED,
is_official=True,
published_at=timezone.now(),
)
self.banner = Banner.objects.create(title="Banner 1", image="banners/1.png", is_active=True, link_type="article", link_value=str(self.article.pk))
self.client = APIClient()
def test_artikel_publik_dan_baca_meningkat(self):
r = self.client.get("/api/v1/articles/")
self.assertEqual(r.status_code, 200)
self.assertEqual(r.data["count"], 1)
self.assertEqual(r.data["results"][0]["title"], self.article.title)
r = self.client.get(f"/api/v1/articles/{self.article.slug}/")
self.assertEqual(r.status_code, 200)
self.assertEqual(r.data["views_count"], 1)
def test_banner_dan_kategori_publik(self):
r = self.client.get("/api/v1/banners/")
self.assertEqual(r.status_code, 200)
self.assertTrue(any(b["title"] == "Banner 1" for b in r.data))
r = self.client.get("/api/v1/categories/")
self.assertEqual(r.status_code, 200)
self.assertEqual(r.data["count"], 1)
def test_articles_manage_perlu_staff(self):
anonymous = self.client
r = anonymous.get("/api/v1/articles/manage/")
self.assertIn(r.status_code, (401, 403))
staff = make_staff()
staff_client = member_client(staff.phone)
r = staff_client.get("/api/v1/articles/manage/")
self.assertEqual(r.status_code, 200)
self.assertEqual(r.data["count"], 1)
def test_artikel_publish_otomatis_set_published_at(self):
staff = make_staff()
staff_client = member_client(staff.phone)
r = staff_client.post(
"/api/v1/articles/manage/",
{"title": "Berita Baru", "status": "published", "body": "Isi", "category": self.category.pk},
format="json",
)
self.assertEqual(r.status_code, 201, r.data)
self.assertIsNotNone(r.data["published_at"])
r = self.client.get("/api/v1/articles/")
titles = [a["title"] for a in r.data["results"]]
self.assertIn("Berita Baru", titles)
\ No newline at end of file
from django.urls import include, path
from rest_framework.routers import DefaultRouter
from . import views
router = DefaultRouter()
# Prefix spesifik didaftarkan lebih dulu (hindari tertelan regex detail articles/{pk}).
router.register("articles/manage", views.ArticleManageViewSet, basename="article-manage")
router.register("articles", views.PublicArticleViewSet, basename="article")
router.register("categories", views.CategoryViewSet)
router.register("media", views.MediaAssetViewSet, basename="media")
router.register("banners", views.BannerViewSet, basename="banner")
urlpatterns = [
path("", include(router.urls)),
]
\ No newline at end of file
"""View konten: berita, media, banner (publik) & kelola (content staff)."""
from __future__ import annotations
from django.db.models import F, Q
from django.utils import timezone
from rest_framework import viewsets
from rest_framework.permissions import AllowAny
from rest_framework.response import Response
from apps.common.permissions import ContentStaff
from apps.common.utils import build_slug
from .models import Article, Banner, Category, MediaAsset
from .serializers import (
ArticleSerializer,
BannerSerializer,
CategorySerializer,
MediaAssetSerializer,
)
class CategoryViewSet(viewsets.ReadOnlyModelViewSet):
queryset = Category.objects.all()
serializer_class = CategorySerializer
permission_classes = [AllowAny]
class PublicArticleViewSet(viewsets.ReadOnlyModelViewSet):
serializer_class = ArticleSerializer
permission_classes = [AllowAny]
lookup_field = "slug"
filterset_fields = ["category", "is_featured", "is_breaking", "is_official"]
search_fields = ["title", "excerpt", "tags"]
ordering_fields = ["published_at", "views_count"]
def get_queryset(self):
return Article.objects.filter(
status=Article.Status.PUBLISHED,
published_at__lte=timezone.now(),
).select_related("category", "author")
def list(self, request, *args, **kwargs):
media = request.query_params.get("media", "")
if media.lower() in ("1", "true"):
data = [
{
"id": a.pk, "title": a.title, "slug": a.slug, "body": a.body,
"featured_image": f"{a.featured_image.url}" if a.featured_image else None,
"published_at": a.published_at,
}
for a in self.filter_queryset(self.get_queryset())
]
return Response(data)
return super().list(request, *args, **kwargs)
def retrieve(self, request, *args, **kwargs):
instance = self.get_object()
Article.objects.filter(pk=instance.pk).update(views_count=F("views_count") + 1)
instance.views_count += 1
return Response(self.get_serializer(instance).data)
class ArticleManageViewSet(viewsets.ModelViewSet):
queryset = Article.objects.select_related("category", "author").all()
serializer_class = ArticleSerializer
permission_classes = [ContentStaff]
filterset_fields = ["status", "category", "is_official"]
search_fields = ["title", "tags", "author__full_name"]
def perform_create(self, serializer):
instance = serializer.save(author=self.request.user)
if not instance.slug and serializer.validated_data.get("title"):
instance.slug = build_slug(instance.title)
instance.save(update_fields=["slug"])
self._autopublish(instance)
def perform_update(self, serializer):
instance = serializer.save()
self._autopublish(instance)
def _autopublish(self, instance):
if instance.status == Article.Status.PUBLISHED and not instance.published_at:
instance.published_at = timezone.now()
instance.save(update_fields=["published_at"])
class MediaAssetViewSet(viewsets.ReadOnlyModelViewSet):
queryset = MediaAsset.objects.all()
serializer_class = MediaAssetSerializer
permission_classes = [AllowAny]
filterset_fields = ["kind", "event"]
ordering_fields = ["created_at"]
pagination_class = None
class BannerViewSet(viewsets.ReadOnlyModelViewSet):
queryset = Banner.objects.none()
serializer_class = BannerSerializer
permission_classes = [AllowAny]
pagination_class = None
def get_queryset(self):
now = timezone.now()
qs = Banner.objects.filter(is_active=True)
qs = qs.filter(Q(starts_at__isnull=True) | Q(starts_at__lte=now))
qs = qs.filter(Q(ends_at__isnull=True) | Q(ends_at__gte=now))
return qs
\ No newline at end of file
from django.contrib import admin
from .models import Circuit, Event, EventCategory, EventSession, SessionPrice, TicketTier, TierBenefit
class TicketTierInline(admin.TabularInline):
model = TicketTier
extra = 0
class EventSessionInline(admin.TabularInline):
model = EventSession
extra = 0
class SessionPriceInline(admin.TabularInline):
model = SessionPrice
extra = 0
@admin.register(EventCategory)
class EventCategoryAdmin(admin.ModelAdmin):
list_display = ["code", "name", "color", "sort_order"]
search_fields = ["code", "name"]
@admin.register(Circuit)
class CircuitAdmin(admin.ModelAdmin):
list_display = ["name", "city", "length_meters", "corner_count", "is_active"]
search_fields = ["name", "city"]
prepopulated_fields = {"slug": ("name",)}
@admin.register(Event)
class EventAdmin(admin.ModelAdmin):
list_display = ["title", "category", "circuit", "start_date", "end_date", "status"]
list_filter = ["category", "status", "circuit", "featured", "is_official"]
search_fields = ["title", "slug"]
prepopulated_fields = {"slug": ("title",)}
inlines = [TicketTierInline, EventSessionInline]
@admin.register(TicketTier)
class TicketTierAdmin(admin.ModelAdmin):
list_display = ["event", "name", "code", "price", "quota", "sold_count", "status"]
list_filter = ["event", "code", "member_only"]
search_fields = ["name", "code"]
inlines = [SessionPriceInline]
@admin.register(EventSession)
class EventSessionAdmin(admin.ModelAdmin):
list_display = ["event", "code", "name", "date", "is_best", "sort_order"]
search_fields = ["event__title", "name"]
@admin.register(TierBenefit)
class TierBenefitAdmin(admin.ModelAdmin):
list_display = ["tier", "text", "sort_order"]
search_fields = ["text"]
@admin.register(SessionPrice)
class SessionPriceAdmin(admin.ModelAdmin):
list_display = ["tier", "session", "price", "quota"]
search_fields = ["tier__code", "session__name"]
\ No newline at end of file
from django.apps import AppConfig
class EventsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.events"
verbose_name = "Event & Ticketing Content"
\ No newline at end of file
from __future__ import annotations
from django.db import models
class EventQueryset(models.QuerySet):
def public(self):
"""Event yang layak tampil di aplikasi / web public."""
return self.filter(
status__in=[
"published",
"registration_open",
"early_bird",
"low_stock",
"sold_out",
"ongoing",
]
)
def upcoming(self):
from django.utils import timezone
return self.filter(end_date__gte=timezone.now().date())
def by_category(self, code: str):
return self.filter(category__code=code) if code else self
def by_city(self, city: str):
return self.filter(circuit__city__icontains=city) if city else self
\ No newline at end of file
This diff is collapsed.
"""Model event, sirkuit/venue, sesi, kategori tiket (tier)."""
from __future__ import annotations
from django.db import models
from django.utils import timezone
from .managers import EventQueryset
class EventCategory(models.Model):
code = models.CharField(max_length=30, unique=True)
name = models.CharField(max_length=80)
icon = models.CharField(max_length=40, blank=True, default="")
color = models.CharField(max_length=7, blank=True, default="")
sort_order = models.PositiveIntegerField(default=0)
class Meta:
ordering = ["sort_order", "name"]
def __str__(self):
return self.name
class Circuit(models.Model):
"""Venue / sirkuit."""
name = models.CharField(max_length=150)
slug = models.SlugField(max_length=150, unique=True)
address = models.CharField(max_length=250, blank=True, default="")
city = models.CharField(max_length=60, blank=True, default="")
province = models.CharField(max_length=40, blank=True, default="Jawa Barat")
pengcab = models.ForeignKey(
"accounts.Pengcab", null=True, blank=True, on_delete=models.SET_NULL, related_name="circuits"
)
lat = models.DecimalField(max_digits=10, decimal_places=7, null=True, blank=True)
lng = models.DecimalField(max_digits=10, decimal_places=7, null=True, blank=True)
length_meters = models.PositiveIntegerField(null=True, blank=True, help_text="Panjang lintasan (meter)")
corner_count = models.PositiveSmallIntegerField(null=True, blank=True)
map_image = models.ImageField(upload_to="circuits/", null=True, blank=True)
gates = models.JSONField(
default=list, blank=True, help_text="List pintu gate: [{name, area, map_url}]"
)
is_active = models.BooleanField(default=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ["name"]
def __str__(self):
return self.name
class Event(models.Model):
class Status(models.TextChoices):
DRAFT = "draft", "Draft"
PUBLISHED = "published", "Published"
REGISTRATION_OPEN = "registration_open", "Pendaftaran Dibuka"
EARLY_BIRD = "early_bird", "Early Bird"
LOW_STOCK = "low_stock", "Hampir Habis"
SOLD_OUT = "sold_out", "Sold Out"
ONGOING = "ongoing", "Sedang Berlangsung"
COMPLETED = "completed", "Selesai"
CANCELLED = "cancelled", "Dibatalkan"
title = models.CharField(max_length=200)
slug = models.SlugField(max_length=200, unique=True)
category = models.ForeignKey(EventCategory, on_delete=models.PROTECT, related_name="events")
series = models.CharField("Seri/round", max_length=80, blank=True, default="")
series_order = models.PositiveIntegerField("Urutan seri", default=1)
description = models.TextField(blank=True, default="")
short_description = models.CharField(max_length=300, blank=True, default="")
poster = models.ImageField(upload_to="events/", null=True, blank=True)
status = models.CharField(max_length=30, choices=Status.choices, default=Status.DRAFT)
start_date = models.DateField()
end_date = models.DateField()
gate_open_time = models.TimeField(null=True, blank=True, help_text="Jam buka gate, contoh 07:30")
featured = models.BooleanField(default=False)
is_official = models.BooleanField(default=True, help_text="Sanctioned IMI Jabar")
circuit = models.ForeignKey(Circuit, null=True, blank=True, on_delete=models.SET_NULL, related_name="events")
venue_name = models.CharField(max_length=200, blank=True, default="")
location_address = models.CharField(max_length=250, blank=True, default="")
organizer = models.CharField(max_length=150, blank=True, default="")
organizer_contact = models.CharField(max_length=50, blank=True, default="")
contact_whatsapp = models.CharField(max_length=20, blank=True, default="")
sale_start_at = models.DateTimeField(null=True, blank=True)
sale_end_at = models.DateTimeField(null=True, blank=True)
max_ticket_per_user = models.PositiveSmallIntegerField(default=4)
min_age_free = models.PositiveSmallIntegerField(default=6, help_text="Anak di bawah umur ini gratis")
regulations = models.TextField(blank=True, default="")
meta = models.JSONField(
default=dict, blank=True,
help_text="Metadata variabel per kategori: prize_pool, classes_count, format, distance route, facilities, dll",
)
created_by = models.ForeignKey(
"accounts.User", null=True, blank=True, on_delete=models.SET_NULL, related_name="created_events"
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
objects = EventQueryset.as_manager()
class Meta:
ordering = ["-start_date"]
def __str__(self):
return self.title
@property
def min_ticket_price(self):
price = self.tiers.aggregate(models.Min("price")).get("price__min")
return price or 0
class EventSession(models.Model):
"""Sesi/hari balap: Day 1, Day 2, 2-Day Pass."""
event = models.ForeignKey(Event, on_delete=models.CASCADE, related_name="sessions")
code = models.SlugField(max_length=30)
name = models.CharField(max_length=60)
label = models.CharField(max_length=120, blank=True, default="")
date = models.DateField(null=True, blank=True)
description = models.CharField(max_length=200, blank=True, default="")
is_best = models.BooleanField(default=False, help_text="Badge 'Best' di UI")
sort_order = models.PositiveIntegerField(default=0)
class Meta:
unique_together = ("event", "code")
ordering = ["sort_order"]
def __str__(self):
return f"{self.event.title} / {self.name}"
class TicketTier(models.Model):
"""Jenis/kelas tiket dalam sebuah event (VIP, Grandstand, Festival, Pit Crew)."""
class Status(models.TextChoices):
ENABLED = "enabled", "Tersedia"
LOW_STOCK = "low_stock", "Sisa Sedikit"
SOLD_OUT = "sold_out", "Habis"
DISABLED = "disabled", "Nonaktif"
event = models.ForeignKey(Event, on_delete=models.CASCADE, related_name="tiers")
code = models.SlugField(max_length=30)
name = models.CharField(max_length=120)
description = models.CharField(max_length=300, blank=True, default="")
unit_label = models.CharField(max_length=40, default="/ orang", help_text="/ orang, / personil, / pass 2 hari")
price = models.PositiveIntegerField()
sort_order = models.PositiveIntegerField(default=0)
status = models.CharField(max_length=20, choices=Status.choices, default=Status.ENABLED)
quota = models.PositiveIntegerField(null=True, blank=True, help_text="null = unlimited")
sold_count = models.PositiveIntegerField(default=0)
low_stock_threshold = models.PositiveIntegerField(default=15)
requires_license = models.BooleanField(default=False, help_text="Pit Crew / Racer Pass -> butuh KTA balap")
member_only = models.BooleanField(default=False)
max_per_order = models.PositiveSmallIntegerField(default=4)
is_free_child = models.BooleanField(default=False)
sale_start = models.DateTimeField(null=True, blank=True)
sale_end = models.DateTimeField(null=True, blank=True)
class Meta:
unique_together = ("event", "code")
ordering = ["sort_order"]
def __str__(self):
return f"{self.event.title} / {self.name}"
@property
def remaining(self) -> int | None:
if self.quota is None:
return None
return max(self.quota - self.sold_count, 0)
@property
def availability_status(self) -> str:
if self.sold_count > 0 and self.quota is not None and self.remaining <= 0:
return TicketTier.Status.SOLD_OUT
if self.quota is not None and self.remaining <= self.low_stock_threshold:
return TicketTier.Status.LOW_STOCK
return self.status
class TierBenefit(models.Model):
"""Benefit/fasilitas yang termasuk dalam sebuah tier."""
tier = models.ForeignKey(TicketTier, on_delete=models.CASCADE, related_name="benefits")
text = models.CharField(max_length=300)
sort_order = models.PositiveIntegerField(default=0)
class Meta:
ordering = ["sort_order"]
def __str__(self):
return self.text
class SessionPrice(models.Model):
"""Harga/kuota variant tier per sesi (Day 1/Day 2/2-Day)."""
session = models.ForeignKey(EventSession, on_delete=models.CASCADE, related_name="prices")
tier = models.ForeignKey(TicketTier, on_delete=models.CASCADE, related_name="session_prices")
price = models.PositiveIntegerField()
quota = models.PositiveIntegerField(null=True, blank=True)
class Meta:
unique_together = ("session", "tier")
def __str__(self):
return f"{self.tier.name} @ {self.session.name}"
\ No newline at end of file
"""Serializers untuk event, sirkuit, tier & sesi."""
from __future__ import annotations
from rest_framework import serializers
from apps.common.utils import build_slug, humanize_rupiah
from .models import Circuit, Event, EventCategory, EventSession, SessionPrice, TicketTier, TierBenefit
class EventCategorySerializer(serializers.ModelSerializer):
class Meta:
model = EventCategory
fields = ["id", "code", "name", "icon", "color"]
class CircuitSerializer(serializers.ModelSerializer):
class Meta:
model = Circuit
fields = ["id", "name", "slug", "address", "city", "province", "lat", "lng",
"length_meters", "corner_count", "map_image", "gates"]
class TierBenefitSerializer(serializers.ModelSerializer):
class Meta:
model = TierBenefit
fields = ["id", "text"]
class SessionPriceSerializer(serializers.ModelSerializer):
class Meta:
model = SessionPrice
fields = ["id", "session", "price", "quota"]
class EventSessionSerializer(serializers.ModelSerializer):
class Meta:
model = EventSession
fields = ["id", "code", "name", "label", "date", "description", "is_best", "sort_order"]
class TicketTierSerializer(serializers.ModelSerializer):
benefits = TierBenefitSerializer(many=True, read_only=True)
availability_status = serializers.CharField(read_only=True)
remaining = serializers.IntegerField(read_only=True, allow_null=True)
price_display = serializers.SerializerMethodField()
class Meta:
model = TicketTier
fields = [
"id", "code", "name", "description", "unit_label", "price", "price_display",
"status", "availability_status", "remaining", "quota", "low_stock_threshold",
"requires_license", "member_only", "max_per_order", "is_free_child",
"sale_start", "sale_end", "sort_order", "benefits",
]
read_only_fields = ["sold_count"]
def get_price_display(self, obj):
return humanize_rupiah(obj.price)
class EventListSerializer(serializers.ModelSerializer):
category = EventCategorySerializer(read_only=True)
circuit = CircuitSerializer(read_only=True)
min_ticket_price = serializers.IntegerField(read_only=True)
min_ticket_price_display = serializers.SerializerMethodField()
class Meta:
model = Event
fields = [
"id", "title", "slug", "category", "series", "series_order", "short_description",
"poster", "status", "start_date", "end_date", "circuit", "venue_name",
"location_address", "featured", "is_official", "min_ticket_price",
"min_ticket_price_display",
]
read_only_fields = fields
def get_min_ticket_price_display(self, obj):
return humanize_rupiah(obj.min_ticket_price or 0)
class EventDetailSerializer(EventListSerializer):
tiers = serializers.SerializerMethodField()
class Meta(EventListSerializer.Meta):
fields = EventListSerializer.Meta.fields + [
"description", "gate_open_time", "sale_start_at", "sale_end_at",
"max_ticket_per_user", "min_age_free", "regulations", "meta",
"organizer", "organizer_contact", "contact_whatsapp", "sessions", "tiers",
]
def get_tiers(self, obj):
tiers = obj.tiers.filter(status__in=[TicketTier.Status.ENABLED, TicketTier.Status.LOW_STOCK, TicketTier.Status.SOLD_OUT])
return TicketTierSerializer(tiers, many=True).data
class AdminEventWriteSerializer(serializers.ModelSerializer):
slug = serializers.SlugField(
required=False, allow_blank=True, help_text="Kosongkan untuk auto-generate dari judul"
)
class Meta:
model = Event
fields = [
"id", "title", "slug", "category", "series", "series_order", "description",
"short_description", "poster", "status", "start_date", "end_date",
"gate_open_time", "featured", "is_official", "circuit", "venue_name",
"location_address", "organizer", "organizer_contact", "contact_whatsapp",
"sale_start_at", "sale_end_at", "max_ticket_per_user", "min_age_free",
"regulations", "meta",
]
def validate_slug(self, value):
value = (value or "").strip()
if value:
qs = Event.objects.filter(slug=value)
if self.instance:
qs = qs.exclude(pk=self.instance.pk)
if qs.exists():
raise serializers.ValidationError("Slug sudah dipakai.")
return value or None
def create(self, validated_data):
if not validated_data.get("slug"):
validated_data["slug"] = build_slug(validated_data["title"])
return super().create(validated_data)
def update(self, instance, validated_data):
if "slug" in validated_data and not validated_data.get("slug"):
validated_data["slug"] = build_slug(
validated_data.get("title") or instance.title
)
return super().update(instance, validated_data)
class AdminTicketTierWriteSerializer(serializers.ModelSerializer):
benefits = TierBenefitSerializer(many=True, required=False)
class Meta:
model = TicketTier
fields = [
"id", "event", "code", "name", "description", "unit_label", "price",
"sort_order", "status", "quota", "low_stock_threshold", "requires_license",
"member_only", "max_per_order", "is_free_child", "sale_start", "sale_end",
"benefits",
]
read_only_fields = ["sold_count"]
def create(self, validated_data):
benefits = validated_data.pop("benefits", [])
tier = super().create(validated_data)
for i, item in enumerate(benefits):
TierBenefit.objects.create(tier=tier, text=item["text"], sort_order=i)
return tier
def update(self, instance, validated_data):
benefits = validated_data.pop("benefits", None)
tier = super().update(instance, validated_data)
if benefits is not None:
instance.benefits.all().delete()
for i, item in enumerate(benefits):
TierBenefit.objects.create(tier=tier, text=item["text"], sort_order=i)
return tier
\ No newline at end of file
"""Regression: daftar event publik vs admin (status draft/completed/cancelled)."""
from __future__ import annotations
from datetime import timedelta
from django.utils import timezone
from rest_framework.test import APIClient, APITestCase
from apps.common.testing import make_staff, member_client
from .models import Event, EventCategory
def make_category():
return EventCategory.objects.create(code="reg", name="Road Race")
def make_event(category, slug, status, days_ahead=30):
d = timezone.now().date() + timedelta(days=days_ahead)
return Event.objects.create(
title=slug.replace("-", " ").title(),
slug=slug,
category=category,
status=status,
start_date=d,
end_date=d + timedelta(days=1),
)
class EventListVisibilityTests(APITestCase):
def test_public_list_hanya_status_publik(self):
cat = make_category()
make_event(cat, "draft-event", Event.Status.DRAFT)
make_event(cat, "public-event", Event.Status.PUBLISHED)
r = APIClient().get("/api/v1/events/")
self.assertEqual(r.status_code, 200)
slugs = [e["slug"] for e in r.data["results"]]
self.assertIn("public-event", slugs)
self.assertNotIn("draft-event", slugs)
def test_admin_list_melihat_semua_status_dan_fungsi_filter(self):
cat = make_category()
make_event(cat, "draft-event", Event.Status.DRAFT)
make_event(cat, "done-event", Event.Status.COMPLETED)
make_event(cat, "cancel-event", Event.Status.CANCELLED)
make_event(cat, "public-event", Event.Status.PUBLISHED)
staff = make_staff("+6281200000099")
client = member_client("+6281200000099")
r = client.get("/api/v1/events/")
self.assertEqual(r.status_code, 200)
slugs = [e["slug"] for e in r.data["results"]]
for want in ("draft-event", "done-event", "cancel-event", "public-event"):
self.assertIn(want, slugs)
r2 = client.get("/api/v1/events/", {"status": "draft"})
self.assertEqual(len(r2.data["results"]), 1)
self.assertEqual(r2.data["results"][0]["slug"], "draft-event")
\ No newline at end of file
from rest_framework.routers import DefaultRouter
from . import views
router = DefaultRouter()
router.register("events", views.EventViewSet, basename="event")
router.register("event-categories", views.EventCategoryViewSet, basename="event-category")
router.register("circuits", views.CircuitViewSet, basename="circuit")
router.register("ticket-tiers", views.TicketTierViewSet, basename="ticket-tier")
urlpatterns = router.urls
\ No newline at end of file
"""View event — publik read-only, admin full CRUD."""
from __future__ import annotations
from django_filters.rest_framework import DjangoFilterBackend
from rest_framework import filters, viewsets
from rest_framework.permissions import AllowAny
from rest_framework.response import Response
from apps.common.permissions import IsAdminRole, role_allowed
from .models import Circuit, Event, EventCategory, TicketTier
from .serializers import (
AdminEventWriteSerializer,
AdminTicketTierWriteSerializer,
CircuitSerializer,
EventCategorySerializer,
EventDetailSerializer,
EventListSerializer,
)
_CONTENT_STAFF = role_allowed("super_admin", "admin", "content", "event")
class EventViewSet(viewsets.ModelViewSet):
"""Kalender event. GET & mutation handler dibedakan:
- List/detail: publik (AllowAny)
- Create/update/delete: hanya staff (admin/content/event)
"""
queryset = Event.objects.all()
filter_backends = [DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter]
filterset_fields = ["status", "category__code", "featured", "is_official", "circuit__city"]
search_fields = ["title", "series", "venue_name", "location_address", "circuit__name"]
ordering_fields = ["start_date", "end_date", "created_at"]
lookup_field = "slug"
def get_permissions(self):
if self.action in ["list", "retrieve"]:
return [AllowAny()]
return [IsAdminRole()]
def get_serializer_class(self):
if self.action in ["list", "retrieve"]:
return EventListSerializer
return AdminEventWriteSerializer
_PUBLIC_STATUSES = [
"published", "registration_open", "early_bird", "low_stock", "sold_out", "ongoing",
]
def get_queryset(self):
qs = super().get_queryset().select_related("category", "circuit").prefetch_related(
"tiers", "tiers__benefits", "sessions"
)
status = self.request.query_params.get("status")
if status:
qs = qs.filter(status=status)
if self.action == "list":
user = self.request.user
is_admin = bool(
user.is_authenticated
and (user.is_superuser or user.is_admin_role or user.role in ("event", "content"))
)
if not is_admin:
qs = qs.filter(status__in=self._PUBLIC_STATUSES)
return qs
def get_serializer_class_detail(self):
return EventDetailSerializer
def retrieve(self, request, *args, **kwargs):
instance = self.get_object()
return Response(EventDetailSerializer(instance, context={"request": request}).data)
def perform_create(self, serializer):
serializer.save(created_by=self.request.user)
class EventCategoryViewSet(viewsets.ReadOnlyModelViewSet):
"""Kategori event (untuk form admin)."""
queryset = EventCategory.objects.all()
serializer_class = EventCategorySerializer
permission_classes = [AllowAny]
class CircuitViewSet(viewsets.ModelViewSet):
queryset = Circuit.objects.filter(is_active=True)
serializer_class = CircuitSerializer
def get_permissions(self):
if self.action in ["list", "retrieve"]:
return [AllowAny()]
return [IsAdminRole()]
class TicketTierViewSet(viewsets.ModelViewSet):
queryset = TicketTier.objects.all()
serializer_class = AdminTicketTierWriteSerializer
def get_permissions(self):
if self.action in ["list", "retrieve"]:
return [AllowAny()]
return [_CONTENT_STAFF]
def get_queryset(self):
qs = super().get_queryset()
event = self.request.query_params.get("event")
if event:
qs = qs.filter(event_id=event)
return qs
\ No newline at end of file
from django.contrib import admin
from .models import Notification, NotificationPreference
@admin.register(Notification)
class NotificationAdmin(admin.ModelAdmin):
list_display = ["recipient", "kind", "channel", "title", "is_read", "created_at"]
list_filter = ["kind", "channel", "is_read"]
search_fields = ["recipient__phone", "recipient__email", "title"]
@admin.register(NotificationPreference)
class NotificationPreferenceAdmin(admin.ModelAdmin):
list_display = ["user", "push_enabled", "email_enabled", "event_reminders", "promo_benefits"]
search_fields = ["user__phone"]
\ No newline at end of file
from django.apps import AppConfig
class NotificationsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.notifications"
verbose_name = "Notifikasi"
\ No newline at end of file
# Generated by Django 4.2.16 on 2026-10-06 09:09
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='NotificationPreference',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('push_enabled', models.BooleanField(default=True)),
('email_enabled', models.BooleanField(default=True)),
('event_reminders', models.BooleanField(default=True)),
('promo_benefits', models.BooleanField(default=True)),
('weather_alert', models.BooleanField(default=True)),
('kta_expiry_reminder', models.BooleanField(default=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='notification_prefs', to=settings.AUTH_USER_MODEL)),
],
),
migrations.CreateModel(
name='Notification',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('kind', models.CharField(choices=[('campaign', 'Kampanye/Pengumuman'), ('transactional', 'Transaksi'), ('system', 'Sistem/Weather')], default='system', max_length=20)),
('channel', models.CharField(choices=[('in_app', 'In-App'), ('push', 'Push'), ('email', 'Email')], default='in_app', max_length=20)),
('title', models.CharField(max_length=200)),
('body', models.TextField(default='')),
('deep_link', models.CharField(blank=True, default='', max_length=300)),
('thumb_url', models.URLField(blank=True, default='')),
('extra', models.JSONField(blank=True, default=dict)),
('is_read', models.BooleanField(default=False)),
('read_at', models.DateTimeField(blank=True, null=True)),
('sent_at', models.DateTimeField(blank=True, null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('recipient', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='notifications', to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ['-created_at'],
},
),
]
"""Model notifikasi in-app & push weather."""
from __future__ import annotations
from django.conf import settings
from django.db import models
class Notification(models.Model):
class Kind(models.TextChoices):
CAMPAIGN = "campaign", "Kampanye/Pengumuman"
TRANSACTIONAL = "transactional", "Transaksi"
SYSTEM = "system", "Sistem/Weather"
class Channel(models.TextChoices):
IN_APP = "in_app", "In-App"
PUSH = "push", "Push"
EMAIL = "email", "Email"
recipient = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="notifications"
)
kind = models.CharField(max_length=20, choices=Kind.choices, default=Kind.SYSTEM)
channel = models.CharField(max_length=20, choices=Channel.choices, default=Channel.IN_APP)
title = models.CharField(max_length=200)
body = models.TextField(default="")
deep_link = models.CharField(max_length=300, blank=True, default="")
thumb_url = models.URLField(blank=True, default="")
extra = models.JSONField(default=dict, blank=True)
is_read = models.BooleanField(default=False)
read_at = models.DateTimeField(null=True, blank=True)
sent_at = models.DateTimeField(null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ["-created_at"]
def __str__(self):
return f"{self.recipient_id} {self.title}"
class NotificationPreference(models.Model):
user = models.OneToOneField(
settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="notification_prefs"
)
push_enabled = models.BooleanField(default=True)
email_enabled = models.BooleanField(default=True)
event_reminders = models.BooleanField(default=True)
promo_benefits = models.BooleanField(default=True)
weather_alert = models.BooleanField(default=True)
kta_expiry_reminder = models.BooleanField(default=True)
updated_at = models.DateTimeField(auto_now=True)
def __str__(self):
return f"prefs:{self.user_id}"
def to_map(self) -> dict:
return {
"push_enabled": self.push_enabled,
"email_enabled": self.email_enabled,
"event_reminders": self.event_reminders,
"promo_benefits": self.promo_benefits,
"weather_alert": self.weather_alert,
"kta_expiry_reminder": self.kta_expiry_reminder,
}
\ No newline at end of file
from rest_framework import serializers
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)
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"]
read_only_fields = fields
class NotificationPreferenceSerializer(serializers.ModelSerializer):
class Meta:
model = NotificationPreference
fields = ["push_enabled", "email_enabled", "event_reminders", "promo_benefits",
"weather_alert", "kta_expiry_reminder"]
\ No newline at end of file
"""Test notifikasi: inbox member & preferensi."""
from __future__ import annotations
from rest_framework.test import APITestCase
from apps.common.testing import create_pengcab, make_member, member_client
from apps.notifications.models import Notification
class NotificationTests(APITestCase):
def setUp(self):
create_pengcab()
self.user, self.member = make_member("+6281600000001", "Notif")
self.client = member_client("+6281600000001")
self.notif = Notification.objects.create(
recipient=self.user, kind="event", channel="push", title="Event Baru", body="Ada event minggu ini"
)
def test_preferensi_get_patch(self):
r = self.client.get("/api/v1/notification-preferences")
self.assertEqual(r.status_code, 200)
self.assertTrue(r.data["weather_alert"])
r = self.client.patch("/api/v1/notification-preferences", {"weather_alert": False}, format="json")
self.assertEqual(r.status_code, 200)
self.assertFalse(r.data["weather_alert"])
# twin accounts endpoint
r = self.client.get("/api/v1/me/notification-prefs")
self.assertEqual(r.status_code, 200)
self.assertFalse(r.data["weather_alert"])
def test_inbox_unread_dan_mark_read(self):
r = self.client.get("/api/v1/notifications/")
self.assertEqual(r.data["count"], 1)
self.assertFalse(r.data["results"][0]["is_read"])
r = self.client.get("/api/v1/notifications/unread_count/")
self.assertEqual(r.data["unread"], 1)
r = self.client.post(f"/api/v1/notifications/{self.notif.pk}/mark_read/", {}, format="json")
self.assertEqual(r.status_code, 200)
r = self.client.get("/api/v1/notifications/unread_count/")
self.assertEqual(r.data["unread"], 0)
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
from django.urls import include, path
from rest_framework.routers import DefaultRouter
from . import views
router = DefaultRouter()
router.register("notifications", views.NotificationViewSet, basename="notification")
urlpatterns = [
path("", include(router.urls)),
path("notification-preferences", views.NotificationPreferenceView.as_view(), name="notification-prefs"),
]
\ No newline at end of file
"""View notifikasi: inbox member & prefs."""
from __future__ import annotations
from django.utils import timezone
from rest_framework import mixins, viewsets
from rest_framework.decorators import action
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
from .models import Notification, NotificationPreference
from .serializers import NotificationPreferenceSerializer, NotificationSerializer
class NotificationViewSet(viewsets.GenericViewSet, mixins.ListModelMixin, mixins.RetrieveModelMixin):
serializer_class = NotificationSerializer
permission_classes = [IsAuthenticated]
def get_queryset(self):
return Notification.objects.filter(recipient=self.request.user)
@action(detail=False, methods=["get"])
def unread_count(self, request):
return Response({"unread": self.get_queryset().filter(is_read=False).count()})
@action(detail=True, methods=["post"])
def mark_read(self, request, pk=None):
notification = self.get_object()
if not notification.is_read:
notification.is_read = True
notification.read_at = timezone.now()
notification.save(update_fields=["is_read", "read_at"])
return Response({"detail": "ok", "id": notification.pk})
@action(detail=False, methods=["post"])
def mark_all_read(self, request):
now = timezone.now()
self.get_queryset().filter(is_read=False).update(is_read=True, read_at=now)
return Response({"detail": "ok"})
class NotificationPreferenceView(APIView):
"""Preferensi notifikasi user (single resource di /notification-preferences)."""
permission_classes = [IsAuthenticated]
def get(self, request):
prefs, _ = NotificationPreference.objects.get_or_create(user=request.user)
return Response(prefs.to_map())
def patch(self, request):
prefs, _ = NotificationPreference.objects.get_or_create(user=request.user)
ser = NotificationPreferenceSerializer(prefs, data=request.data, partial=True)
ser.is_valid(raise_exception=True)
ser.save()
return Response(prefs.to_map())
\ No newline at end of file
from django.contrib import admin
from .models import (
Benefit,
BenefitCategory,
BenefitItem,
BenefitOutlet,
Booking,
MemberVoucher,
Partner,
PartnerOutlet,
PointsLedger,
Redemption,
)
class BenefitItemInline(admin.TabularInline):
model = BenefitItem
extra = 0
class BenefitOutletInline(admin.TabularInline):
model = BenefitOutlet
extra = 0
@admin.register(Partner)
class PartnerAdmin(admin.ModelAdmin):
list_display = ["name", "category", "status", "rating", "is_verified"]
list_filter = ["status", "category", "is_verified"]
search_fields = ["name", "slug"]
@admin.register(PartnerOutlet)
class PartnerOutletAdmin(admin.ModelAdmin):
list_display = ["name", "partner", "city", "region_label", "is_primary", "is_active"]
list_filter = ["is_active", "region_label", "city"]
search_fields = ["name", "partner__name"]
@admin.register(BenefitCategory)
class BenefitCategoryAdmin(admin.ModelAdmin):
list_display = ["code", "name", "sort_order"]
search_fields = ["code", "name"]
@admin.register(Benefit)
class BenefitAdmin(admin.ModelAdmin):
list_display = ["title", "partner", "category", "price", "stock_remaining", "status"]
list_filter = ["status", "member_only", "redemption_type"]
search_fields = ["title", "partner__name"]
inlines = [BenefitItemInline, BenefitOutletInline]
@admin.register(MemberVoucher)
class MemberVoucherAdmin(admin.ModelAdmin):
list_display = ["code", "member", "benefit", "status", "expires_at"]
list_filter = ["status", "kind"]
search_fields = ["code", "member__member_number", "benefit__title"]
@admin.register(Redemption)
class RedemptionAdmin(admin.ModelAdmin):
list_display = ["id", "member_voucher", "outlet", "status", "redeemed_at"]
list_filter = ["status"]
search_fields = ["member_voucher__code"]
@admin.register(PointsLedger)
class PointsLedgerAdmin(admin.ModelAdmin):
list_display = ["member", "delta", "balance_after", "reason", "created_at"]
list_filter = ["reason"]
search_fields = ["member__member_number", "ref"]
@admin.register(Booking)
class BookingAdmin(admin.ModelAdmin):
list_display = ["member_voucher", "outlet", "scheduled_at", "status"]
list_filter = ["status"]
search_fields = ["member_voucher__code"]
\ No newline at end of file
from django.apps import AppConfig
class PartnersConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.partners"
verbose_name = "Partner, Benefit & Voucher"
\ No newline at end of file
This diff is collapsed.
# Generated by Django 4.2.16 on 2026-10-06 09:18
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('partners', '0001_initial'),
]
operations = [
migrations.AlterField(
model_name='redemption',
name='member_voucher',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='redemptions', to='partners.membervoucher'),
),
]
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
"""Test wallet member & redemption: klaim benefit -> redeem -> duplikat/Tak dikenal."""
from __future__ import annotations
from rest_framework.test import APITestCase
from apps.common.constants import POINTS_CLAIM_BENEFIT
from apps.common.testing import create_pengcab, make_member, make_staff, member_client
from apps.partners.models import Benefit, BenefitCategory, MemberVoucher, Partner, Redemption
def _partner_fixtures():
category = BenefitCategory.objects.create(code="workshop", name="Bengkel & Dyno")
partner = Partner.objects.create(name="Pertamina Turbo", slug="pertamina-turbo", category="workshop")
benefit = Benefit.objects.create(
partner=partner,
category=category,
title="Paket Dyno Tuning Test",
short_description="Paket dinamo + laporan daya",
original_price=250_000,
sale_price=200_000,
discount_type=Benefit.DiscountType.NOMINAL,
discount_value=50_000,
validity_days=30,
stock_total=10,
stock_remaining=10,
requires_kta=True,
status=Benefit.Status.PUBLISHED,
)
return partner, benefit
class WalletRedemptionTests(APITestCase):
def setUp(self):
self.pengcab = create_pengcab()
self.member_user, self.member = make_member("+6281500000001", "Member", points=1_450)
self.staff = make_staff()
self.partner, self.benefit = _partner_fixtures()
self.client = member_client("+6281500000001")
self.staff_client = member_client(self.staff.phone)
def _claim(self, benefit_id, points=0):
return self.client.post(
"/api/v1/wallet/claim/",
{"benefit_id": benefit_id, "points_redeem": points},
format="json",
)
def test_klaim_dan_redeem_voucher(self):
r = self._claim(self.benefit.id)
self.assertEqual(r.status_code, 201, r.data)
self.assertEqual(r.data["status"], MemberVoucher.Status.AVAILABLE)
self.member.refresh_from_db()
self.assertEqual(self.member.points_balance, 1_450 + POINTS_CLAIM_BENEFIT)
self.benefit.refresh_from_db()
self.assertEqual(self.benefit.stock_remaining, 9)
r = self.staff_client.post(
"/api/v1/redeem/redeem/",
{"scan_code": r.data["qr_token"]},
format="json",
)
self.assertEqual(r.data["status"], Redemption.Status.SUCCESS)
voucher = MemberVoucher.objects.get(pk=r.data["member_voucher"])
self.assertEqual(voucher.status, MemberVoucher.Status.USED)
def test_redeem_duplikat_lalu_kode_tak_dikenal(self):
r = self._claim(self.benefit.id)
qr = r.data["qr_token"]
self.staff_client.post("/api/v1/redeem/redeem/", {"scan_code": qr}, format="json")
r = self.staff_client.post("/api/v1/redeem/redeem/", {"scan_code": qr}, format="json")
self.assertEqual(r.data["status"], Redemption.Status.DUPLICATE)
r = self.staff_client.post("/api/v1/redeem/redeem/", {"scan_code": "VCR-BOGUS"}, format="json")
self.assertEqual(r.data["status"], Redemption.Status.REJECTED)
self.assertIsNone(r.data["member_voucher"])
def test_klaim_tanpa_cukup_poin(self):
r = self._claim(self.benefit.id, points=9_999_999)
self.assertEqual(r.status_code, 400)
def test_wallet_balance_default(self):
r = self.client.get("/api/v1/wallet/balance/")
self.assertEqual(r.status_code, 200)
self.assertEqual(r.data["points_balance"], 1_450)
self.assertEqual(r.data["active_vouchers"], 0)
def test_redeem_wajib_staff(self):
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
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
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