first commit

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

No files matched your search

+25
View File
@@ -0,0 +1,25 @@
node_modules
npm-debug.log
.env
.env.local
.env.*.local
dist
dist-ssr
coverage
.vite
*.local
.git
.gitignore
docker-compose*.yml
DOCKER.md
README.md
# Backend artifacts (api image copies backend/ explicitly)
backend/.venv
backend/venv
backend/__pycache__
backend/db.sqlite3
backend/data
backend/media
backend/staticfiles
backend/*.egg-info
+2
View File
@@ -0,0 +1,2 @@
# Local dev: Vite proxies /api → HQ Django :8001
VITE_API_BASE=/api/v1
+42
View File
@@ -0,0 +1,42 @@
{
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"plugin:react/recommended",
"plugin:react-hooks/recommended",
"prettier"
],
"parser": "@typescript-eslint/parser",
"parserOptions": {
"ecmaVersion": "latest",
"sourceType": "module",
"ecmaFeatures": {
"jsx": true
}
},
"plugins": ["@typescript-eslint", "react", "react-hooks", "prettier"],
"rules": {
"prettier/prettier": "error",
"@typescript-eslint/no-explicit-any": "warn",
"@typescript-eslint/no-unused-vars": [
"error",
{
"argsIgnorePattern": "^_",
"varsIgnorePattern": "^_"
}
],
"react/react-in-jsx-scope": "off",
"react/prop-types": "off",
"react-hooks/exhaustive-deps": "warn",
"react-hooks/static-components": "off",
"react-hooks/purity": "off",
"react-hooks/set-state-in-effect": "off",
"react/no-unescaped-entities": "off"
},
"settings": {
"react": {
"version": "detect"
}
},
"ignorePatterns": ["dist", "node_modules", "*.config.js", "*.config.ts"]
}
+12
View File
@@ -0,0 +1,12 @@
node_modules
dist
dist-ssr
*.local
.env
coverage
.vite
backup
__pycache__
docker-compose.override.yml
docs/
.superpowers/
+8
View File
@@ -0,0 +1,8 @@
node_modules
dist
dist-ssr
coverage
.vite
backend/.venv
backend/.pytest_cache
backend/**/node_modules
+9
View File
@@ -0,0 +1,9 @@
{
"semi": true,
"singleQuote": true,
"tabWidth": 2,
"printWidth": 100,
"trailingComma": "es5",
"arrowParens": "always",
"endOfLine": "lf"
}
+36
View File
@@ -0,0 +1,36 @@
import React from 'react';
import AppContent from './AppContent.tsx';
import AccountAdminShell from './components/AccountAdminShell.tsx';
import { AuthProvider, useAuth } from './context/AuthContext.tsx';
import { FarmProvider } from './context/FarmContext.tsx';
import AuthPage from './components/auth/AuthPage.tsx';
import { Spinner } from './components/ui/Feedback.tsx';
const Gate: React.FC = () => {
const { user, loading } = useAuth();
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-100">
<Spinner />
</div>
);
}
if (!user) return <AuthPage />;
// Account Admin: User Management only. Superuser uses ops shell + Manajemen Akun nav.
if (user.status === 'account_admin') {
return <AccountAdminShell />;
}
return (
<FarmProvider>
<AppContent />
</FarmProvider>
);
};
const App: React.FC = () => (
<AuthProvider>
<Gate />
</AuthProvider>
);
export default App;
+111
View File
@@ -0,0 +1,111 @@
import React, { useState } from 'react';
import type { View } from './types/common.ts';
import { useFarm } from './context/FarmContext.tsx';
import Sidebar from './components/Sidebar.tsx';
import Header from './components/Header.tsx';
import CycleTimeline from './components/CycleTimeline.tsx';
import Dashboard from './components/Dashboard.tsx';
import DataManagementPage from './components/counting/DataManagementPage.tsx';
import WeightMonitoringPage from './components/WeightMonitoringPage.tsx';
import WeightStandardPage from './components/WeightStandardPage.tsx';
import FeedSackCountingPage from './components/FeedSackCountingPage.tsx';
import CobbStandardPage from './components/CobbStandardPage.tsx';
import IotPanelPage from './components/IotPanelPage.tsx';
import FcrDashboardPage from './components/FcrDashboardPage.tsx';
import EefDashboardPage from './components/EefDashboardPage.tsx';
import KandangRankingPage from './components/KandangRankingPage.tsx';
import SettingsPage from './components/SettingsPage.tsx';
import UserManagementPage from './components/UserManagementPage.tsx';
import { Banner } from './components/ui/Feedback.tsx';
const AppContent: React.FC = () => {
const { error, loading } = useFarm();
const [view, setView] = useState<View>('dashboard');
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
const [isTimelineVisible, setIsTimelineVisible] = useState(false);
const goSettings = () => setView('kandangSettings');
const renderView = () => {
switch (view) {
case 'dashboard':
return <Dashboard setView={setView} />;
case 'dataManagement':
return <DataManagementPage onSettings={goSettings} />;
case 'weightMonitoring':
return <WeightMonitoringPage onSettings={goSettings} />;
case 'weightStandard':
return <WeightStandardPage />;
case 'feedSackCounting':
return <FeedSackCountingPage onSettings={goSettings} />;
case 'cobbStandard':
return <CobbStandardPage />;
case 'iotPanel':
return <IotPanelPage onSettings={goSettings} />;
case 'fcrDashboard':
return <FcrDashboardPage onSettings={goSettings} />;
case 'eefDashboard':
return <EefDashboardPage onSettings={goSettings} />;
case 'kandangRanking':
return <KandangRankingPage setView={setView} />;
case 'kandangSettings':
return <SettingsPage />;
case 'userManagement':
return <UserManagementPage />;
default:
return <Dashboard setView={setView} />;
}
};
return (
<div className="flex h-screen bg-gray-100">
<Sidebar
isCollapsed={isSidebarCollapsed}
view={view}
setView={setView}
onToggleCollapse={() => setIsSidebarCollapsed((v) => !v)}
/>
<div className="flex-1 flex flex-col overflow-hidden">
<Header
onToggleSidebar={() => setIsSidebarCollapsed((v) => !v)}
isTimelineVisible={isTimelineVisible}
onToggleTimeline={() => setIsTimelineVisible((v) => !v)}
/>
<div className="flex-1 overflow-x-hidden overflow-y-auto">
{isTimelineVisible && (
<div className="px-4 sm:px-6 lg:px-8 pt-4 sm:pt-6 lg:pt-8">
<CycleTimeline />
</div>
)}
<div
className={
isTimelineVisible
? 'px-4 sm:px-6 lg:px-8 pb-8'
: 'px-4 sm:px-6 lg:px-8 pt-4 sm:pt-6 lg:pt-8 pb-8'
}
>
{error && (
<div className="mb-4">
<Banner kind="error">{error}</Banner>
</div>
)}
{renderView()}
</div>
</div>
</div>
{loading && (
<div className="fixed inset-0 bg-gray-900 bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-8 shadow-2xl flex flex-col items-center">
<div className="relative w-16 h-16 mb-4">
<div className="absolute inset-0 border-4 border-gray-200 rounded-full"></div>
<div className="absolute inset-0 border-4 border-red-600 rounded-full border-t-transparent animate-spin"></div>
</div>
<p className="text-gray-700 font-semibold text-lg">Memuat Data...</p>
<p className="text-gray-500 text-sm mt-2">Mohon tunggu sebentar</p>
</div>
</div>
)}
</div>
);
};
export default AppContent;
+55
View File
@@ -0,0 +1,55 @@
# dashboard-cpsp-executive
Main-office executive dashboard. Same frontend look as site `dashboard-cpsp`, HQ mirror API in `backend/`.
## Architecture
```text
City A/B/C (API+DB) --API key sync--> HQ mirror API+DB --session--> Executive FE (:3002)
```
- **Roles**
- `director` / `director_admin`: view all sites (`director_admin` is **not** Django superuser)
- `buh` / `bu_admin`: view only GMs they manage (`managed_by`) and those GMs' sites
- `gm` / `gm_admin`: register city sites + sync; **only `gm`** approves cycle close
- `account_admin`: IT Central — User Management only; no ops dashboards
- `superuser`: break-glass (`bootstrap_admin` / seed `admin`) — director-level ops + Manajemen Akun; hidden from user lists
- `inactive`: locked out
- No `is_staff`
- **City registry:** `/api/v1/city-sites/` (GM / GM Admin)
- **Approve close:** `POST /api/v1/cycles/{id}/approve-close/` (GM only)
## Ports
| App | UI | API |
|-----|----|-----|
| Site `dashboard-cpsp` | `:3001` | `:8000` |
| HQ `dashboard-cpsp-executive` | `:3002` | `:8001` |
## Setup
**Backend**
```bash
cd backend
python -m venv .venv
.venv\Scripts\activate
pip install -r requirements.txt
cp .env.example .env
python manage.py migrate
python manage.py seed_demo
npm run dev
```
**Frontend**
```bash
cd ..
cp .env.example .env
npm install
npm run dev
```
Open [http://localhost:3002](http://localhost:3002).
Seed accounts: `admin`/`admin123` (`superuser` break-glass: ops + Manajemen Akun), `director`/`director123`, `director_admin`/`directoradmin123`, `buh`/`buh123`, `bu_admin`/`buadmin123`, `gm`/`gm123`, `gm_admin`/`gmadmin123`, `account_admin`/`accountadmin123` (IT user management only).
+32
View File
@@ -0,0 +1,32 @@
DEBUG=True
SECRET_KEY=change-me-in-production
ALLOWED_HOSTS=localhost,127.0.0.1,api
CSRF_TRUSTED_ORIGINS=http://localhost:8001,http://127.0.0.1:8001,http://localhost:3002,http://127.0.0.1:3002
CORS_ALLOWED_ORIGINS=http://localhost:3002,http://127.0.0.1:3002
CORS_ALLOW_CREDENTIALS=True
# SQLite (local HQ mirror)
DB_ENGINE=django.db.backends.sqlite3
DB_NAME=db.sqlite3
API_PAGE_SIZE=50
SESSION_COOKIE_SECURE=False
CSRF_COOKIE_SECURE=False
BOOTSTRAP_API_KEY=
BOOTSTRAP_ADMIN_USER=admin
BOOTSTRAP_ADMIN_PASSWORD=
# Optional second login (seeded as GM when using bootstrap_admin)
BOOTSTRAP_STAFF_USER=
BOOTSTRAP_STAFF_PASSWORD=
# City-edge syncs off at HQ (mirror via city-sites instead)
KARUNG_WEB_ADMIN_SYNC_ENABLED=false
IOT_SYNC_ENABLED=false
CHICKEN_COUNTING_EDGE_COUNTING_SYNC_ENABLED=false
CHICKEN_COUNTING_EDGE_MORTALITY_SYNC_ENABLED=false
DASHBOARD_PUBLISH_HOUR=17
DASHBOARD_PUBLISH_MINUTE=0
+10
View File
@@ -0,0 +1,10 @@
.venv/
__pycache__/
*.py[cod]
.env
db.sqlite3
data/
media/
staticfiles/
*.egg-info/
.Python
+68
View File
@@ -0,0 +1,68 @@
# HQ backend — dashboard-cpsp-executive
Main-office **mirror API** for the executive frontend. Domain shapes match site `dashboard-cpsp` so the copied UI can talk to `/api/v1/`.
## Roles
| Status | Capabilities | Data scope |
|--------|----------------|------------|
| `director`, `director_admin` | **View only** (director_admin may repair farm structure writes). Not Django superuser. | **All** sites / GMs |
| `buh`, `bu_admin` | **View only** | Rollup of managed GMs' registered city sites |
| `gm` | View + register city URLs + sync + **approve cycle close** | Own sites / city sites |
| `gm_admin` | Same as GM **except** no cycle-close approval | Own sites / city sites |
| `account_admin` | **User Management only** (create / deactivate / reset password). No ops dashboards. Temp passwords shared out-of-band. | Users list (all product users; never lists `superuser`). |
| `superuser` | Break-glass: **ops dashboards (director scope) + Manajemen Akun**. Not creatable via UI; hidden from user lists. | All sites / GMs |
| `inactive` | Locked out | — |
`bootstrap_admin` / seed `admin` uses status `superuser`. This app does not use `is_staff`.
GM↔BUH link: `User.managed_by` (confirmed). **Only `gm`** may request a **`buh`** (not `bu_admin` / `gm_admin`); **only that `buh`** may approve.
City registry ownership: `CitySite.registered_by`.
GM farm visibility follows the Pengaturan city-site list: only HQ `Site` rows linked to an active registered `CitySite` are shown (empty list ⇒ no sites).
BUH farm visibility is the union of those lists for **confirmed** managed GMs (read-only Pengaturan rollup; optional GM filter in the header).
`POST /api/v1/cycles/{id}/approve-close/` — GM only.
`POST /api/v1/city-sites/` — GM / GM Admin.
## City registry
`GET/POST /api/v1/city-sites/` — register city dashboard APIs.
`POST /api/v1/city-sites/{id}/sync/` and `POST /api/v1/city-sites/sync-all/` — stubs (`501`) until mirror pull is built.
## Setup
```bash
cd backend
python -m venv .venv
# Windows: .venv\Scripts\activate
pip install -r requirements.txt
cp .env.example .env
python manage.py migrate
python manage.py seed_demo
npm run dev
# → http://127.0.0.1:8001
```
Seed logins:
| Username | Password | Role |
|----------|----------|------|
| `admin` | `admin123` | `superuser` (break-glass: ops + Manajemen Akun; hidden from user lists) |
| `director` | `director123` | `director` |
| `director_admin` | `directoradmin123` | `director_admin` (product; not superuser) |
| `buh` | `buh123` | `buh` |
| `bu_admin` | `buadmin123` | `bu_admin` |
| `gm` | `gm123` | `gm` (owns Sukawarna demo data; managed by `buh`) |
| `gm_admin` | `gmadmin123` | `gm_admin` (sites only; no BUH request) |
| `account_admin` | `accountadmin123` | `account_admin` (IT Central user management only) |
Legacy `staff` login is removed by `seed_demo` (sites reassigned to `gm`).
## Ports
| Service | Port |
|---------|------|
| HQ API | `:8001` |
| Executive FE | `:3002` |
| Site API (other repo) | `:8000` |
| Site FE | `:3001` |
View File
Whitespace-only changes.
View File
Whitespace-only changes.
View File
Whitespace-only changes.
+8
View File
@@ -0,0 +1,8 @@
from django.apps import AppConfig
class AccountsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.accounts"
label = "accounts"
verbose_name = "Accounts"
+32
View File
@@ -0,0 +1,32 @@
from __future__ import annotations
from django.utils.translation import gettext_lazy as _
from rest_framework import authentication, exceptions
from apps.accounts.models import ApiKey
class ApiKeyAuthentication(authentication.BaseAuthentication):
"""Authenticate via X-API-Key header (hashed lookup)."""
keyword = "X-API-Key"
def authenticate(self, request):
raw_key = request.headers.get(self.keyword) or request.META.get("HTTP_X_API_KEY")
if not raw_key:
return None
if len(raw_key) < 8:
raise exceptions.AuthenticationFailed(_("Invalid API key"))
prefix = raw_key[:8]
candidates = ApiKey.objects.select_related("user").filter(
prefix=prefix, is_active=True, user__is_active=True
)
for api_key in candidates:
if api_key.verify(raw_key):
api_key.touch()
return (api_key.user, api_key)
raise exceptions.AuthenticationFailed(_("Invalid API key"))
def authenticate_header(self, request):
return self.keyword
@@ -0,0 +1,55 @@
# Generated by Django 5.2.17 on 2026-08-10 08:31
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('auth', '0012_alter_user_first_name_max_length'),
]
operations = [
migrations.CreateModel(
name='User',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('password', models.CharField(max_length=128, verbose_name='password')),
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
('user_name', models.CharField(max_length=30, unique=True)),
('is_staff', models.BooleanField(default=False)),
('is_active', models.BooleanField(default=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')),
('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')),
],
options={
'db_table': 'user_access',
'ordering': ['user_name'],
},
),
migrations.CreateModel(
name='ApiKey',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=100)),
('prefix', models.CharField(db_index=True, max_length=12)),
('key_hash', models.CharField(max_length=64)),
('is_active', models.BooleanField(default=True)),
('last_used_at', models.DateTimeField(blank=True, null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='api_keys', to=settings.AUTH_USER_MODEL)),
],
options={
'db_table': 'api_keys',
'ordering': ['-created_at'],
},
),
]
@@ -0,0 +1,16 @@
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("accounts", "0001_initial"),
]
operations = [
migrations.RenameField(
model_name="user",
old_name="id",
new_name="user_id",
),
]
@@ -0,0 +1,39 @@
from django.db import migrations, models
def backfill_user_status(apps, schema_editor):
User = apps.get_model("accounts", "User")
for user in User.objects.all():
if user.is_superuser:
status = "superadmin"
flags = {"is_active": True, "is_staff": True, "is_superuser": True}
elif not user.is_active:
status = "inactive"
flags = {"is_active": False, "is_staff": False, "is_superuser": False}
else:
status = "active"
flags = {"is_active": True, "is_staff": False, "is_superuser": False}
User.objects.filter(pk=user.pk).update(status=status, **flags)
class Migration(migrations.Migration):
dependencies = [
("accounts", "0002_erd_named_primary_keys"),
]
operations = [
migrations.AddField(
model_name="user",
name="status",
field=models.CharField(
choices=[
("active", "Active"),
("inactive", "Inactive"),
("superadmin", "Super Admin"),
],
default="active",
max_length=30,
),
),
migrations.RunPython(backfill_user_status, migrations.RunPython.noop),
]
@@ -0,0 +1,29 @@
from django.db import migrations
def sync_status_flags(apps, schema_editor):
User = apps.get_model("accounts", "User")
for user in User.objects.all():
if user.status == "inactive":
user.is_active = False
user.is_staff = False
user.is_superuser = False
elif user.status == "superadmin":
user.is_active = True
user.is_staff = False
user.is_superuser = True
else:
user.is_active = True
user.is_superuser = False
user.save(update_fields=["is_active", "is_staff", "is_superuser", "updated_at"])
class Migration(migrations.Migration):
dependencies = [
("accounts", "0003_user_status"),
]
operations = [
migrations.RunPython(sync_status_flags, migrations.RunPython.noop),
]
@@ -0,0 +1,45 @@
from django.db import migrations, models
def migrate_legacy_statuses(apps, schema_editor):
User = apps.get_model("accounts", "User")
for user in User.objects.all():
if user.status == "superadmin":
new_status = "director_admin"
flags = {"is_active": True, "is_staff": True, "is_superuser": True}
elif user.status == "active":
new_status = "gm"
flags = {"is_active": True, "is_staff": False, "is_superuser": False}
elif user.status == "inactive":
new_status = "inactive"
flags = {"is_active": False, "is_staff": False, "is_superuser": False}
else:
continue
User.objects.filter(pk=user.pk).update(status=new_status, **flags)
class Migration(migrations.Migration):
dependencies = [
("accounts", "0004_sync_status_flags"),
]
operations = [
migrations.AlterField(
model_name="user",
name="status",
field=models.CharField(
choices=[
("inactive", "Inactive"),
("gm", "GM"),
("gm_admin", "GM Admin"),
("buh", "BUH"),
("bu_admin", "BU Admin"),
("director", "Director"),
("director_admin", "Director Admin"),
],
default="gm",
max_length=30,
),
),
migrations.RunPython(migrate_legacy_statuses, migrations.RunPython.noop),
]
@@ -0,0 +1,26 @@
# Generated for HQ visibility scoping
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("accounts", "0005_executive_roles"),
]
operations = [
migrations.AddField(
model_name="user",
name="managed_by",
field=models.ForeignKey(
blank=True,
limit_choices_to={"status__in": ("buh", "bu_admin")},
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="managed_gms",
to=settings.AUTH_USER_MODEL,
),
),
]
@@ -0,0 +1,28 @@
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
("accounts", "0006_user_managed_by"),
]
operations = [
migrations.AddField(
model_name="user",
name="managed_by_pending",
field=models.ForeignKey(
blank=True,
limit_choices_to={"status__in": ("buh", "bu_admin")},
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="pending_managed_gms",
to="accounts.user",
),
),
migrations.AddField(
model_name="user",
name="managed_by_requested_at",
field=models.DateTimeField(blank=True, null=True),
),
]
@@ -0,0 +1,25 @@
# Generated manually for managed_by_pending BUH-only limit
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
("accounts", "0007_user_managed_by_pending"),
]
operations = [
migrations.AlterField(
model_name="user",
name="managed_by_pending",
field=models.ForeignKey(
blank=True,
limit_choices_to={"status": "buh"},
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="pending_managed_gms",
to="accounts.user",
),
),
]
@@ -0,0 +1,30 @@
# Generated manually for account_admin status
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("accounts", "0008_alter_managed_by_pending_buh_only"),
]
operations = [
migrations.AlterField(
model_name="user",
name="status",
field=models.CharField(
choices=[
("inactive", "Inactive"),
("gm", "GM"),
("gm_admin", "GM Admin"),
("buh", "BUH"),
("bu_admin", "BU Admin"),
("director", "Director"),
("director_admin", "Director Admin"),
("account_admin", "Account Admin"),
],
default="gm",
max_length=30,
),
),
]
@@ -0,0 +1,22 @@
from django.db import migrations
def strip_director_admin_superuser(apps, schema_editor):
User = apps.get_model("accounts", "User")
User.objects.filter(status="director_admin", is_superuser=True).update(
is_superuser=False
)
class Migration(migrations.Migration):
dependencies = [
("accounts", "0009_account_admin_status"),
]
operations = [
migrations.RunPython(strip_director_admin_superuser, migrations.RunPython.noop),
migrations.RemoveField(
model_name="user",
name="is_staff",
),
]
@@ -0,0 +1,39 @@
from django.db import migrations, models
def migrate_break_glass_to_superuser_status(apps, schema_editor):
User = apps.get_model("accounts", "User")
User.objects.filter(is_superuser=True).exclude(status="inactive").update(
status="superuser"
)
class Migration(migrations.Migration):
dependencies = [
("accounts", "0010_remove_is_staff_decouple_director_admin"),
]
operations = [
migrations.AlterField(
model_name="user",
name="status",
field=models.CharField(
choices=[
("inactive", "Inactive"),
("gm", "GM"),
("gm_admin", "GM Admin"),
("buh", "BUH"),
("bu_admin", "BU Admin"),
("director", "Director"),
("director_admin", "Director Admin"),
("account_admin", "Account Admin"),
("superuser", "Superuser"),
],
default="gm",
max_length=30,
),
),
migrations.RunPython(
migrate_break_glass_to_superuser_status, migrations.RunPython.noop
),
]
Whitespace-only changes.
+239
View File
@@ -0,0 +1,239 @@
from __future__ import annotations
import hashlib
import secrets
from django.contrib.auth.models import AbstractBaseUser, BaseUserManager, PermissionsMixin
from django.db import models
from django.utils import timezone
class UserManager(BaseUserManager):
def create_user(self, user_name: str, password: str | None = None, **extra_fields):
if not user_name:
raise ValueError("user_name is required")
extra_fields.setdefault("status", User.STATUS_GM)
extra_fields.setdefault("is_superuser", False)
user = self.model(user_name=user_name, **extra_fields)
user.set_password(password)
user.save(using=self._db)
return user
def create_superuser(self, user_name: str, password: str | None = None, **extra_fields):
"""Break-glass: status=superuser (ops + user management). Hidden from user lists."""
extra_fields["status"] = User.STATUS_SUPERUSER
extra_fields["is_superuser"] = True
return self.create_user(user_name, password, **extra_fields)
class User(AbstractBaseUser, PermissionsMixin):
"""HQ executive user. Roles are distinct from site dashboard active/superadmin."""
STATUS_INACTIVE = "inactive"
STATUS_GM = "gm"
STATUS_GM_ADMIN = "gm_admin"
STATUS_BUH = "buh"
STATUS_BU_ADMIN = "bu_admin"
STATUS_DIRECTOR = "director"
STATUS_DIRECTOR_ADMIN = "director_admin"
STATUS_ACCOUNT_ADMIN = "account_admin"
STATUS_SUPERUSER = "superuser"
STATUS_CHOICES = [
(STATUS_INACTIVE, "Inactive"),
(STATUS_GM, "GM"),
(STATUS_GM_ADMIN, "GM Admin"),
(STATUS_BUH, "BUH"),
(STATUS_BU_ADMIN, "BU Admin"),
(STATUS_DIRECTOR, "Director"),
(STATUS_DIRECTOR_ADMIN, "Director Admin"),
(STATUS_ACCOUNT_ADMIN, "Account Admin"),
(STATUS_SUPERUSER, "Superuser"),
]
# Roles that may sign in and use the executive dashboard.
EXECUTIVE_STATUSES = frozenset(
{
STATUS_GM,
STATUS_GM_ADMIN,
STATUS_BUH,
STATUS_BU_ADMIN,
STATUS_DIRECTOR,
STATUS_DIRECTOR_ADMIN,
STATUS_ACCOUNT_ADMIN,
STATUS_SUPERUSER,
}
)
# BU / Director "*_admin" are view-only (same as buh / director).
# Kept for labeling only — no extra write powers.
ADMIN_STATUSES = frozenset(
{
STATUS_BU_ADMIN,
STATUS_DIRECTOR_ADMIN,
}
)
user_id = models.BigAutoField(primary_key=True)
user_name = models.CharField(max_length=30, unique=True)
status = models.CharField(max_length=30, choices=STATUS_CHOICES, default=STATUS_GM)
# BUH who manages this GM / GM Admin (unused for director / buh themselves).
managed_by = models.ForeignKey(
"self",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="managed_gms",
limit_choices_to={"status__in": ("buh", "bu_admin")},
)
# GM-requested BUH waiting for confirmation (BUH only — not BU Admin).
managed_by_pending = models.ForeignKey(
"self",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="pending_managed_gms",
limit_choices_to={"status": "buh"},
)
managed_by_requested_at = models.DateTimeField(null=True, blank=True)
is_active = models.BooleanField(default=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
objects = UserManager()
USERNAME_FIELD = "user_name"
REQUIRED_FIELDS: list[str] = []
class Meta:
db_table = "user_access"
ordering = ["user_name"]
def __str__(self) -> str:
return self.user_name
@property
def is_executive(self) -> bool:
return self.status in self.EXECUTIVE_STATUSES
@property
def is_executive_admin(self) -> bool:
return self.status in self.ADMIN_STATUSES
def request_managed_by(self, buh: "User") -> None:
"""GM asks to report under a BUH (pending until that BUH confirms)."""
from django.core.exceptions import ValidationError
if self.status != self.STATUS_GM:
raise ValidationError("Only GM can request a BUH.")
if buh.status != self.STATUS_BUH:
raise ValidationError("Target must be a BUH (not BU Admin).")
if self.managed_by_id == buh.pk and self.managed_by_pending_id is None:
raise ValidationError("Already managed by this BUH.")
self.managed_by_pending = buh
self.managed_by_requested_at = timezone.now()
self.save(
update_fields=["managed_by_pending", "managed_by_requested_at", "updated_at"]
)
def cancel_managed_by_request(self) -> None:
if self.managed_by_pending_id is None:
return
self.managed_by_pending = None
self.managed_by_requested_at = None
self.save(
update_fields=["managed_by_pending", "managed_by_requested_at", "updated_at"]
)
def approve_managed_by(self, approver: "User") -> None:
from django.core.exceptions import ValidationError
if self.managed_by_pending_id is None:
raise ValidationError("No pending BUH request.")
if approver.pk != self.managed_by_pending_id:
raise ValidationError("Only the requested BUH can approve this request.")
if approver.status != self.STATUS_BUH:
raise ValidationError("Only a BUH (not BU Admin) can approve this request.")
self.managed_by = self.managed_by_pending
self.managed_by_pending = None
self.managed_by_requested_at = None
self.save(
update_fields=[
"managed_by",
"managed_by_pending",
"managed_by_requested_at",
"updated_at",
]
)
def reject_managed_by(self, approver: "User") -> None:
from django.core.exceptions import ValidationError
if self.managed_by_pending_id is None:
raise ValidationError("No pending BUH request.")
if approver.pk != self.managed_by_pending_id:
raise ValidationError("Only the requested BUH can reject this request.")
if approver.status != self.STATUS_BUH:
raise ValidationError("Only a BUH (not BU Admin) can reject this request.")
self.managed_by_pending = None
self.managed_by_requested_at = None
self.save(
update_fields=["managed_by_pending", "managed_by_requested_at", "updated_at"]
)
def save(self, *args, **kwargs):
# Product roles never use Django is_staff. status=superuser is break-glass
# (ops + user management) and always carries is_superuser.
if self.status == self.STATUS_INACTIVE:
self.is_active = False
self.is_superuser = False
elif self.status == self.STATUS_SUPERUSER:
self.is_active = True
self.is_superuser = True
else:
self.is_active = True
self.is_superuser = False
super().save(*args, **kwargs)
class ApiKey(models.Model):
"""Hashed API keys for X-API-Key authentication (inbound to HQ API)."""
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="api_keys")
name = models.CharField(max_length=100)
prefix = models.CharField(max_length=12, db_index=True)
key_hash = models.CharField(max_length=64)
is_active = models.BooleanField(default=True)
last_used_at = models.DateTimeField(null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = "api_keys"
ordering = ["-created_at"]
def __str__(self) -> str:
return f"{self.name} ({self.prefix}…)"
@staticmethod
def hash_key(raw_key: str) -> str:
return hashlib.sha256(raw_key.encode("utf-8")).hexdigest()
@classmethod
def generate(cls, user: User, name: str) -> tuple[ApiKey, str]:
raw = secrets.token_urlsafe(32)
prefix = raw[:8]
instance = cls.objects.create(
user=user,
name=name,
prefix=prefix,
key_hash=cls.hash_key(raw),
)
return instance, raw
def verify(self, raw_key: str) -> bool:
return secrets.compare_digest(self.key_hash, self.hash_key(raw_key))
def touch(self) -> None:
self.last_used_at = timezone.now()
self.save(update_fields=["last_used_at", "updated_at"])
+145
View File
@@ -0,0 +1,145 @@
"""HQ executive role helpers and DRF permission classes.
Settled matrix (v1):
- buh, director (+ their admins until specified otherwise): view only
- gm + gm_admin: view + register city sites (URL) + sync from cities
- gm only (not gm_admin): approve cycle close
"""
from __future__ import annotations
from rest_framework import permissions
from apps.accounts.models import User
# View-only executives (no site registry, sync, or approvals).
VIEW_ONLY_STATUSES = frozenset(
{
User.STATUS_BUH,
User.STATUS_BU_ADMIN,
User.STATUS_DIRECTOR,
User.STATUS_DIRECTOR_ADMIN,
}
)
# Can register city dashboard URLs into the HQ mirror registry.
SITE_REGISTRY_STATUSES = frozenset(
{
User.STATUS_GM,
User.STATUS_GM_ADMIN,
}
)
# Can trigger city → HQ mirror sync.
SYNC_STATUSES = frozenset(
{
User.STATUS_GM,
User.STATUS_GM_ADMIN,
}
)
# Cycle close approval — GM only (explicitly not gm_admin).
CYCLE_CLOSE_APPROVER_STATUSES = frozenset(
{
User.STATUS_GM,
}
)
# Product user-management role (IT Central). Django superuser remains break-glass.
USER_ADMIN_STATUSES = frozenset(
{
User.STATUS_ACCOUNT_ADMIN,
}
)
def _status(user) -> str | None:
return getattr(user, "status", None) if user else None
def executive_user(user) -> bool:
return bool(
user
and getattr(user, "is_authenticated", False)
and _status(user) in User.EXECUTIVE_STATUSES
)
def can_sync(user) -> bool:
return bool(
user
and getattr(user, "is_authenticated", False)
and _status(user) in SYNC_STATUSES
)
def can_register_city_site(user) -> bool:
"""GM / GM Admin may add city dashboard URLs to the HQ registry."""
return bool(
user
and getattr(user, "is_authenticated", False)
and _status(user) in SITE_REGISTRY_STATUSES
)
def can_approve_cycle_close(user) -> bool:
"""Only GM (not gm_admin) may approve closing a cycle."""
return bool(
user
and getattr(user, "is_authenticated", False)
and _status(user) in CYCLE_CLOSE_APPROVER_STATUSES
)
def can_write_operations(user) -> bool:
"""No manual ops create/edit/delete for executive roles."""
return False
def can_manage_users(user) -> bool:
"""Account Admin (IT Central) or Django superuser break-glass."""
if not user or not getattr(user, "is_authenticated", False):
return False
if getattr(user, "is_superuser", False):
return True
return _status(user) in USER_ADMIN_STATUSES
def can_manage_city_registry(user) -> bool:
"""Alias used by city-site write endpoints — same as register permission."""
return can_register_city_site(user) or bool(getattr(user, "is_superuser", False))
class IsExecutive(permissions.BasePermission):
def has_permission(self, request, view):
return executive_user(request.user)
class IsExecutiveAdmin(permissions.BasePermission):
def has_permission(self, request, view):
return can_manage_users(request.user)
class CanRegisterCitySite(permissions.BasePermission):
def has_permission(self, request, view):
return can_register_city_site(request.user) or bool(
getattr(request.user, "is_superuser", False)
)
class CanApproveCycleClose(permissions.BasePermission):
def has_permission(self, request, view):
return can_approve_cycle_close(request.user)
class ReadOrSyncOnly(permissions.BasePermission):
"""Safe methods for all executives; sync actions for GM / GM Admin only."""
def has_permission(self, request, view):
if not executive_user(request.user):
return False
if request.method in permissions.SAFE_METHODS:
return True
if getattr(view, "executive_sync_action", False):
return can_sync(request.user)
return can_write_operations(request.user)
+177
View File
@@ -0,0 +1,177 @@
"""Visibility scoping for HQ executives.
- director / director_admin: all sites / city sites / GMs
- buh / bu_admin: only GMs (and gm_admins) they manage, plus those GMs' sites
- gm / gm_admin: only their own sites / city sites they registered
Farm Site rows are only visible to non-directors when linked to an active
CitySite in the viewer's Pengaturan registry.
"""
from __future__ import annotations
from django.db.models import QuerySet
from apps.accounts.models import User
DIRECTOR_STATUSES = frozenset(
{
User.STATUS_DIRECTOR,
User.STATUS_DIRECTOR_ADMIN,
User.STATUS_SUPERUSER,
}
)
BUH_STATUSES = frozenset(
{
User.STATUS_BUH,
User.STATUS_BU_ADMIN,
}
)
GM_STATUSES = frozenset(
{
User.STATUS_GM,
User.STATUS_GM_ADMIN,
}
)
def is_director_scope(user: User) -> bool:
return bool(user and user.status in DIRECTOR_STATUSES)
def is_buh_scope(user: User) -> bool:
return bool(user and user.status in BUH_STATUSES)
def is_gm_scope(user: User) -> bool:
return bool(user and user.status in GM_STATUSES)
def managed_gm_ids(buh: User) -> list[int]:
"""User pks for GMs / GM Admins reporting to this BUH."""
return list(
User.objects.filter(
managed_by=buh,
status__in=GM_STATUSES,
).values_list("pk", flat=True)
)
def visible_owner_ids(user: User) -> list[int] | None:
"""
Site.user / CitySite.registered_by ids this viewer may see.
Returns None when there is no restriction (director / superuser).
"""
if is_director_scope(user):
return None
if is_buh_scope(user):
return managed_gm_ids(user)
if is_gm_scope(user):
return [user.pk]
return []
def visible_active_city_site_ids(user: User) -> list[int] | None:
"""
Active CitySite pks in this viewer's Pengaturan registry.
Returns None for directors (no restriction). Empty list means no farm sites.
"""
from apps.sync.models import CitySite
owners = visible_owner_ids(user)
if owners is None:
return None
if not owners:
return []
return list(
CitySite.objects.filter(
registered_by_id__in=owners,
is_active=True,
).values_list("pk", flat=True)
)
def is_account_admin_scope(user: User) -> bool:
return bool(user and user.status == User.STATUS_ACCOUNT_ADMIN)
def filter_users_queryset(qs: QuerySet, viewer: User) -> QuerySet:
"""BUH sees managed GMs; director / account_admin see product users.
Break-glass status=superuser is never listed in the user directory.
"""
qs = qs.exclude(status=User.STATUS_SUPERUSER)
if is_director_scope(viewer) or is_account_admin_scope(viewer):
return qs
if is_buh_scope(viewer):
return qs.filter(pk__in=managed_gm_ids(viewer))
if is_gm_scope(viewer):
return qs.filter(pk=viewer.pk)
return qs.none()
def filter_sites_queryset(qs: QuerySet, viewer: User) -> QuerySet:
owners = visible_owner_ids(viewer)
if owners is None:
return qs
if not owners:
return qs.none()
city_ids = visible_active_city_site_ids(viewer)
if not city_ids:
return qs.none()
return qs.filter(user_id__in=owners, city_site_id__in=city_ids)
def filter_city_sites_queryset(qs: QuerySet, viewer: User) -> QuerySet:
owners = visible_owner_ids(viewer)
if owners is None:
return qs
if not owners:
return qs.none()
return qs.filter(registered_by_id__in=owners)
def filter_kandangs_queryset(qs: QuerySet, viewer: User) -> QuerySet:
owners = visible_owner_ids(viewer)
if owners is None:
return qs
if not owners:
return qs.none()
city_ids = visible_active_city_site_ids(viewer)
if not city_ids:
return qs.none()
return qs.filter(site__user_id__in=owners, site__city_site_id__in=city_ids)
def filter_flocks_queryset(qs: QuerySet, viewer: User) -> QuerySet:
owners = visible_owner_ids(viewer)
if owners is None:
return qs
if not owners:
return qs.none()
city_ids = visible_active_city_site_ids(viewer)
if not city_ids:
return qs.none()
return qs.filter(
kandang__site__user_id__in=owners,
kandang__site__city_site_id__in=city_ids,
)
def filter_cycles_queryset(qs: QuerySet, viewer: User) -> QuerySet:
owners = visible_owner_ids(viewer)
if owners is None:
return qs
if not owners:
return qs.none()
city_ids = visible_active_city_site_ids(viewer)
if not city_ids:
return qs.none()
return qs.filter(
kandang__site__user_id__in=owners,
kandang__site__city_site_id__in=city_ids,
)
+184
View File
@@ -0,0 +1,184 @@
from django.contrib.auth import get_user_model
from rest_framework import serializers
from apps.accounts.models import ApiKey
User = get_user_model()
class PkAsIdMixin(serializers.Serializer):
"""Keep JSON `id` stable after ERD-named primary keys."""
id = serializers.IntegerField(source="pk", read_only=True)
class UserSerializer(PkAsIdMixin, serializers.ModelSerializer):
managed_by = serializers.PrimaryKeyRelatedField(
queryset=User.objects.filter(status__in=["buh", "bu_admin"]),
allow_null=True,
required=False,
)
managed_by_name = serializers.CharField(
source="managed_by.user_name", read_only=True, allow_null=True
)
managed_by_pending = serializers.PrimaryKeyRelatedField(read_only=True)
managed_by_pending_name = serializers.CharField(
source="managed_by_pending.user_name", read_only=True, allow_null=True
)
class Meta:
model = User
fields = [
"id",
"user_name",
"status",
"is_superuser",
"managed_by",
"managed_by_name",
"managed_by_pending",
"managed_by_pending_name",
"managed_by_requested_at",
"created_at",
"updated_at",
]
read_only_fields = [
"id",
"is_superuser",
"managed_by_name",
"managed_by_pending",
"managed_by_pending_name",
"managed_by_requested_at",
"created_at",
"updated_at",
]
class UserWriteSerializer(PkAsIdMixin, serializers.ModelSerializer):
"""Create/update users for Account Admin. Password required on create."""
password = serializers.CharField(write_only=True, required=False, allow_blank=False)
managed_by = serializers.PrimaryKeyRelatedField(
queryset=User.objects.filter(status__in=["buh", "bu_admin"]),
allow_null=True,
required=False,
)
managed_by_name = serializers.CharField(
source="managed_by.user_name", read_only=True, allow_null=True
)
managed_by_pending = serializers.PrimaryKeyRelatedField(read_only=True)
managed_by_pending_name = serializers.CharField(
source="managed_by_pending.user_name", read_only=True, allow_null=True
)
class Meta:
model = User
fields = [
"id",
"user_name",
"status",
"is_superuser",
"password",
"managed_by",
"managed_by_name",
"managed_by_pending",
"managed_by_pending_name",
"managed_by_requested_at",
"created_at",
"updated_at",
]
read_only_fields = [
"id",
"is_superuser",
"managed_by_name",
"managed_by_pending",
"managed_by_pending_name",
"managed_by_requested_at",
"created_at",
"updated_at",
]
def validate_password(self, value: str) -> str:
if value is not None and not str(value).strip():
raise serializers.ValidationError("Password cannot be blank.")
return value
def validate(self, attrs):
if self.instance is None and not attrs.get("password"):
raise serializers.ValidationError({"password": "This field is required."})
new_status = attrs.get("status")
if new_status == User.STATUS_SUPERUSER:
raise serializers.ValidationError(
{"status": "Superuser accounts cannot be created or assigned here."}
)
request = self.context.get("request")
actor = getattr(request, "user", None) if request else None
instance = self.instance
if (
instance is not None
and actor is not None
and getattr(actor, "is_authenticated", False)
and instance.pk == actor.pk
and "status" in attrs
and attrs["status"] != instance.status
):
if attrs["status"] == User.STATUS_INACTIVE:
raise serializers.ValidationError(
{"status": "You cannot deactivate your own account."}
)
raise serializers.ValidationError(
{"status": "You cannot change your own account status."}
)
return attrs
def create(self, validated_data):
password = validated_data.pop("password")
user_name = validated_data.pop("user_name")
return User.objects.create_user(
user_name=user_name, password=password, **validated_data
)
def update(self, instance, validated_data):
password = validated_data.pop("password", None)
for attr, value in validated_data.items():
setattr(instance, attr, value)
if password:
instance.set_password(password)
instance.save()
return instance
class SetPasswordSerializer(serializers.Serializer):
password = serializers.CharField(write_only=True, allow_blank=False)
def validate_password(self, value: str) -> str:
if not str(value).strip():
raise serializers.ValidationError("Password cannot be blank.")
return value
class LoginSerializer(serializers.Serializer):
user_name = serializers.CharField(max_length=30)
password = serializers.CharField(write_only=True)
class ApiKeySerializer(serializers.ModelSerializer):
class Meta:
model = ApiKey
fields = [
"id",
"name",
"prefix",
"is_active",
"last_used_at",
"created_at",
"updated_at",
"user",
]
read_only_fields = fields
class ApiKeyCreateSerializer(serializers.Serializer):
name = serializers.CharField(max_length=100)
user_id = serializers.IntegerField(required=False)
@@ -0,0 +1,227 @@
from django.contrib.auth import authenticate
from django.test import TestCase
from rest_framework.test import APIClient
from apps.accounts.models import User
class AccountAdminModelTests(TestCase):
def test_account_admin_flags_and_executive(self):
user = User.objects.create_user(
user_name="acct1",
password="secret",
status=User.STATUS_ACCOUNT_ADMIN,
)
self.assertTrue(user.is_active)
self.assertFalse(user.is_superuser)
self.assertTrue(user.is_executive)
def test_inactive_clears_flags(self):
user = User.objects.create_user(
user_name="acct2",
password="secret",
status=User.STATUS_ACCOUNT_ADMIN,
)
user.status = User.STATUS_INACTIVE
user.save()
user.refresh_from_db()
self.assertFalse(user.is_active)
self.assertFalse(user.is_superuser)
def test_director_admin_is_not_superuser(self):
user = User.objects.create_user(
user_name="diradmin1",
password="secret",
status=User.STATUS_DIRECTOR_ADMIN,
)
self.assertEqual(user.status, User.STATUS_DIRECTOR_ADMIN)
self.assertFalse(user.is_superuser)
class AccountAdminApiTests(TestCase):
def setUp(self):
self.client = APIClient()
self.account_admin = User.objects.create_user(
user_name="acctadmin",
password="acct123",
status=User.STATUS_ACCOUNT_ADMIN,
)
self.gm = User.objects.create_user(
user_name="gm1", password="gm123", status=User.STATUS_GM
)
self.buh = User.objects.create_user(
user_name="buh1", password="buh123", status=User.STATUS_BUH
)
self.director = User.objects.create_user(
user_name="dir1", password="dir123", status=User.STATUS_DIRECTOR
)
self.superuser = User.objects.create_superuser(
user_name="admin1",
password="admin123",
)
self.assertTrue(self.superuser.is_superuser)
self.assertEqual(self.superuser.status, User.STATUS_SUPERUSER)
def test_account_admin_creates_user_with_password(self):
self.client.force_authenticate(self.account_admin)
response = self.client.post(
"/api/v1/users/",
{
"user_name": "newgm",
"status": User.STATUS_GM,
"password": "temp-pass",
},
format="json",
)
self.assertEqual(response.status_code, 201, response.data)
created = User.objects.get(user_name="newgm")
self.assertEqual(created.status, User.STATUS_GM)
self.assertIsNotNone(
authenticate(username="newgm", password="temp-pass")
)
def test_account_admin_create_requires_password(self):
self.client.force_authenticate(self.account_admin)
response = self.client.post(
"/api/v1/users/",
{"user_name": "nopass", "status": User.STATUS_GM},
format="json",
)
self.assertEqual(response.status_code, 400)
self.assertIn("password", response.data)
def test_gm_cannot_create_user(self):
self.client.force_authenticate(self.gm)
response = self.client.post(
"/api/v1/users/",
{
"user_name": "blocked",
"status": User.STATUS_GM,
"password": "x",
},
format="json",
)
self.assertEqual(response.status_code, 403)
def test_account_admin_lists_all_users(self):
self.client.force_authenticate(self.account_admin)
response = self.client.get("/api/v1/users/")
self.assertEqual(response.status_code, 200)
names = {row["user_name"] for row in response.data["results"]}
self.assertIn("gm1", names)
self.assertIn("buh1", names)
self.assertIn("dir1", names)
self.assertIn("acctadmin", names)
def test_account_admin_set_password(self):
self.client.force_authenticate(self.account_admin)
response = self.client.post(
f"/api/v1/users/{self.gm.pk}/set-password/",
{"password": "new-gm-pass"},
format="json",
)
self.assertEqual(response.status_code, 200, response.data)
self.assertIsNotNone(
authenticate(username="gm1", password="new-gm-pass")
)
self.assertIsNone(authenticate(username="gm1", password="gm123"))
def test_account_admin_deactivate_blocks_login(self):
self.client.force_authenticate(self.account_admin)
response = self.client.patch(
f"/api/v1/users/{self.gm.pk}/",
{"status": User.STATUS_INACTIVE},
format="json",
)
self.assertEqual(response.status_code, 200, response.data)
self.gm.refresh_from_db()
self.assertFalse(self.gm.is_active)
self.assertIsNone(authenticate(username="gm1", password="gm123"))
def test_buh_and_director_cannot_set_password(self):
for actor in (self.buh, self.director):
self.client.force_authenticate(actor)
response = self.client.post(
f"/api/v1/users/{self.gm.pk}/set-password/",
{"password": "hack"},
format="json",
)
self.assertEqual(response.status_code, 403, actor.user_name)
def test_superuser_break_glass_create(self):
self.client.force_authenticate(self.superuser)
response = self.client.post(
"/api/v1/users/",
{
"user_name": "breakglass",
"status": User.STATUS_BUH,
"password": "bg-pass",
},
format="json",
)
self.assertEqual(response.status_code, 201, response.data)
def test_account_admin_cannot_deactivate_self(self):
self.client.force_authenticate(self.account_admin)
response = self.client.patch(
f"/api/v1/users/{self.account_admin.pk}/",
{"status": User.STATUS_INACTIVE},
format="json",
)
self.assertEqual(response.status_code, 400)
self.assertIn("status", response.data)
self.account_admin.refresh_from_db()
self.assertEqual(self.account_admin.status, User.STATUS_ACCOUNT_ADMIN)
self.assertTrue(self.account_admin.is_active)
def test_account_admin_cannot_change_own_status(self):
self.client.force_authenticate(self.account_admin)
response = self.client.patch(
f"/api/v1/users/{self.account_admin.pk}/",
{"status": User.STATUS_GM},
format="json",
)
self.assertEqual(response.status_code, 400)
self.assertIn("status", response.data)
self.account_admin.refresh_from_db()
self.assertEqual(self.account_admin.status, User.STATUS_ACCOUNT_ADMIN)
def test_peer_account_admin_can_deactivate_other_account_admin(self):
peer = User.objects.create_user(
user_name="acctadmin2",
password="acct123",
status=User.STATUS_ACCOUNT_ADMIN,
)
self.client.force_authenticate(self.account_admin)
response = self.client.patch(
f"/api/v1/users/{peer.pk}/",
{"status": User.STATUS_INACTIVE},
format="json",
)
self.assertEqual(response.status_code, 200, response.data)
peer.refresh_from_db()
self.assertEqual(peer.status, User.STATUS_INACTIVE)
self.assertFalse(peer.is_active)
def test_superuser_hidden_from_user_list(self):
User.objects.create_superuser(user_name="hidden_su", password="x")
self.client.force_authenticate(self.account_admin)
response = self.client.get("/api/v1/users/")
self.assertEqual(response.status_code, 200)
names = {row["user_name"] for row in response.data["results"]}
self.assertNotIn("hidden_su", names)
self.assertNotIn("admin1", names)
def test_cannot_assign_superuser_status_via_api(self):
self.client.force_authenticate(self.account_admin)
response = self.client.post(
"/api/v1/users/",
{
"user_name": "bad_su",
"status": User.STATUS_SUPERUSER,
"password": "x",
},
format="json",
)
self.assertEqual(response.status_code, 400)
self.assertIn("status", response.data)
+99
View File
@@ -0,0 +1,99 @@
from django.test import TestCase
from rest_framework.test import APIClient
from apps.accounts.models import User
class ManagedByRequestTests(TestCase):
def setUp(self):
self.client = APIClient()
self.buh = User.objects.create_user(
user_name="buh1", password="x", status=User.STATUS_BUH
)
self.buh_other = User.objects.create_user(
user_name="buh2", password="x", status=User.STATUS_BUH
)
self.gm = User.objects.create_user(
user_name="gm1", password="x", status=User.STATUS_GM
)
def test_gm_requests_and_buh_approves(self):
self.client.force_authenticate(self.gm)
response = self.client.post(
"/api/v1/users/request-managed-by/", {"buh_id": self.buh.pk}, format="json"
)
self.assertEqual(response.status_code, 200)
self.gm.refresh_from_db()
self.assertIsNone(self.gm.managed_by_id)
self.assertEqual(self.gm.managed_by_pending_id, self.buh.pk)
self.client.force_authenticate(self.buh)
pending = self.client.get("/api/v1/users/pending-managed/")
self.assertEqual(pending.status_code, 200)
self.assertEqual({row["user_name"] for row in pending.data}, {"gm1"})
approve = self.client.post(f"/api/v1/users/{self.gm.pk}/approve-managed-by/", {})
self.assertEqual(approve.status_code, 200)
self.gm.refresh_from_db()
self.assertEqual(self.gm.managed_by_id, self.buh.pk)
self.assertIsNone(self.gm.managed_by_pending_id)
managed = self.client.get("/api/v1/users/")
self.assertEqual({row["user_name"] for row in managed.data["results"]}, {"gm1"})
def test_other_buh_cannot_approve(self):
self.gm.request_managed_by(self.buh)
self.client.force_authenticate(self.buh_other)
response = self.client.post(f"/api/v1/users/{self.gm.pk}/approve-managed-by/", {})
self.assertEqual(response.status_code, 404)
self.gm.refresh_from_db()
self.assertIsNone(self.gm.managed_by_id)
self.assertEqual(self.gm.managed_by_pending_id, self.buh.pk)
def test_gm_can_cancel_request(self):
self.gm.request_managed_by(self.buh)
self.client.force_authenticate(self.gm)
response = self.client.post("/api/v1/users/cancel-managed-by-request/", {})
self.assertEqual(response.status_code, 200)
self.gm.refresh_from_db()
self.assertIsNone(self.gm.managed_by_pending_id)
def test_gm_lists_buhs_only(self):
User.objects.create_user(
user_name="buadmin1", password="x", status=User.STATUS_BU_ADMIN
)
self.client.force_authenticate(self.gm)
response = self.client.get("/api/v1/users/buhs/")
self.assertEqual(response.status_code, 200)
names = {row["user_name"] for row in response.data}
self.assertEqual(names, {"buh1", "buh2"})
def test_gm_admin_cannot_request_buh(self):
gm_admin = User.objects.create_user(
user_name="gma1", password="x", status=User.STATUS_GM_ADMIN
)
self.client.force_authenticate(gm_admin)
response = self.client.post(
"/api/v1/users/request-managed-by/", {"buh_id": self.buh.pk}, format="json"
)
self.assertEqual(response.status_code, 403)
def test_gm_cannot_request_bu_admin(self):
bu_admin = User.objects.create_user(
user_name="buadmin1", password="x", status=User.STATUS_BU_ADMIN
)
self.client.force_authenticate(self.gm)
response = self.client.post(
"/api/v1/users/request-managed-by/", {"buh_id": bu_admin.pk}, format="json"
)
self.assertEqual(response.status_code, 400)
def test_bu_admin_cannot_approve(self):
bu_admin = User.objects.create_user(
user_name="buadmin1", password="x", status=User.STATUS_BU_ADMIN
)
self.gm.managed_by_pending = bu_admin
self.gm.save(update_fields=["managed_by_pending", "updated_at"])
self.client.force_authenticate(bu_admin)
response = self.client.post(f"/api/v1/users/{self.gm.pk}/approve-managed-by/", {})
self.assertEqual(response.status_code, 403)
+118
View File
@@ -0,0 +1,118 @@
from django.test import TestCase
from rest_framework.test import APIClient
from apps.accounts.models import User
from apps.farms.models import Kandang, Site
from apps.sync.models import CitySite
class VisibilityScopeTests(TestCase):
def setUp(self):
self.client = APIClient()
self.director = User.objects.create_user(
user_name="dir1", password="x", status=User.STATUS_DIRECTOR
)
self.buh = User.objects.create_user(
user_name="buh1", password="x", status=User.STATUS_BUH
)
self.buh_other = User.objects.create_user(
user_name="buh2", password="x", status=User.STATUS_BUH
)
self.gm = User.objects.create_user(
user_name="gm1", password="x", status=User.STATUS_GM, managed_by=self.buh
)
self.gm_other = User.objects.create_user(
user_name="gm2",
password="x",
status=User.STATUS_GM,
managed_by=self.buh_other,
)
self.city_a = CitySite.objects.create(
code="a",
name="City A",
api_base_url="https://a.example/api/v1",
registered_by=self.gm,
)
self.city_b = CitySite.objects.create(
code="b",
name="City B",
api_base_url="https://b.example/api/v1",
registered_by=self.gm_other,
)
self.site = Site.objects.create(
site_name="Site A", user=self.gm, city_site=self.city_a
)
self.site_other = Site.objects.create(
site_name="Site B", user=self.gm_other, city_site=self.city_b
)
self.orphan = Site.objects.create(site_name="Orphan Seed", user=self.gm)
Kandang.objects.create(kandang_name="K1", site=self.site)
Kandang.objects.create(kandang_name="K2", site=self.site_other)
def test_director_sees_all_sites(self):
self.client.force_authenticate(self.director)
response = self.client.get("/api/v1/sites/")
self.assertEqual(response.status_code, 200)
names = {row["site_name"] for row in response.data["results"]}
self.assertEqual(names, {"Site A", "Site B", "Orphan Seed"})
def test_buh_sees_only_managed_gm_registered_sites(self):
self.client.force_authenticate(self.buh)
response = self.client.get("/api/v1/sites/")
self.assertEqual(response.status_code, 200)
names = {row["site_name"] for row in response.data["results"]}
self.assertEqual(names, {"Site A"})
def test_buh_sees_only_managed_gms(self):
self.client.force_authenticate(self.buh)
response = self.client.get("/api/v1/users/")
self.assertEqual(response.status_code, 200)
names = {row["user_name"] for row in response.data["results"]}
self.assertEqual(names, {"gm1"})
def test_gm_sees_own_city_sites_only(self):
self.client.force_authenticate(self.gm)
response = self.client.get("/api/v1/city-sites/")
self.assertEqual(response.status_code, 200)
codes = {row["code"] for row in response.data["results"]}
self.assertEqual(codes, {"a"})
def test_gm_sees_only_registered_linked_sites(self):
self.client.force_authenticate(self.gm)
response = self.client.get("/api/v1/sites/")
self.assertEqual(response.status_code, 200)
names = {row["site_name"] for row in response.data["results"]}
self.assertEqual(names, {"Site A"})
def test_gm_with_empty_registry_sees_no_sites(self):
self.city_a.delete()
self.client.force_authenticate(self.gm)
response = self.client.get("/api/v1/sites/")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["results"], [])
def test_registering_city_site_attaches_matching_seed_site(self):
self.client.force_authenticate(self.gm)
response = self.client.post(
"/api/v1/city-sites/",
{
"code": "orphan-seed",
"name": "Orphan Seed",
"api_base_url": "https://orphan.example/api/v1",
"is_active": True,
},
format="json",
)
self.assertEqual(response.status_code, 201)
self.orphan.refresh_from_db()
self.assertEqual(self.orphan.city_site_id, response.data["id"])
listed = self.client.get("/api/v1/sites/")
names = {row["site_name"] for row in listed.data["results"]}
self.assertIn("Orphan Seed", names)
def test_director_sees_all_city_sites(self):
self.client.force_authenticate(self.director)
response = self.client.get("/api/v1/city-sites/")
self.assertEqual(response.status_code, 200)
codes = {row["code"] for row in response.data["results"]}
self.assertEqual(codes, {"a", "b"})
+23
View File
@@ -0,0 +1,23 @@
from django.urls import path
from rest_framework.routers import DefaultRouter
from apps.accounts.views import (
ApiKeyViewSet,
CsrfView,
LoginView,
LogoutView,
MeView,
UserViewSet,
)
router = DefaultRouter()
router.register("users", UserViewSet, basename="user")
router.register("api-keys", ApiKeyViewSet, basename="api-key")
urlpatterns = [
path("auth/csrf/", CsrfView.as_view(), name="auth-csrf"),
path("auth/login/", LoginView.as_view(), name="auth-login"),
path("auth/logout/", LogoutView.as_view(), name="auth-logout"),
path("auth/me/", MeView.as_view(), name="auth-me"),
*router.urls,
]
+258
View File
@@ -0,0 +1,258 @@
from django.contrib.auth import authenticate, login, logout
from django.core.exceptions import ValidationError
from django.middleware.csrf import get_token
from django.views.decorators.csrf import ensure_csrf_cookie
from django.utils.decorators import method_decorator
from rest_framework import permissions, status, viewsets
from rest_framework.decorators import action
from rest_framework.response import Response
from rest_framework.views import APIView
from apps.accounts.models import ApiKey, User
from apps.accounts.permissions import IsExecutive
from apps.accounts.scoping import filter_users_queryset
from apps.accounts.serializers import (
ApiKeyCreateSerializer,
ApiKeySerializer,
LoginSerializer,
SetPasswordSerializer,
UserSerializer,
UserWriteSerializer,
)
def _validation_detail(exc: ValidationError):
if hasattr(exc, "message_dict"):
return exc.message_dict
if hasattr(exc, "messages"):
return "; ".join(str(m) for m in exc.messages)
return str(getattr(exc, "message", exc))
class IsOwnerOrStaff(permissions.BasePermission):
def has_object_permission(self, request, view, obj):
if request.user.is_superuser:
return True
return getattr(obj, "user_id", None) == request.user.pk or obj == request.user
class IsAdminUser(permissions.BasePermission):
"""Write access for Account Admin or Django superuser break-glass."""
def has_permission(self, request, view):
from apps.accounts.permissions import can_manage_users
return can_manage_users(request.user)
class UserViewSet(viewsets.ModelViewSet):
"""
List/retrieve: scoped for executives (BUH → managed GMs; director / account_admin → all).
Create/update/delete/set-password: Account Admin or superuser.
GM/GM Admin can request a BUH; that BUH can approve/reject.
"""
serializer_class = UserSerializer
def get_queryset(self):
qs = User.objects.select_related("managed_by", "managed_by_pending").order_by(
"user_name"
)
return filter_users_queryset(qs, self.request.user)
def get_serializer_class(self):
if self.action in {"create", "update", "partial_update"}:
return UserWriteSerializer
return UserSerializer
def get_permissions(self):
if self.action in {
"list",
"retrieve",
"buhs",
"pending_managed",
"request_managed_by",
"cancel_managed_by_request",
"approve_managed_by",
"reject_managed_by",
}:
return [IsExecutive()]
return [IsAdminUser()]
@action(detail=True, methods=["post"], url_path="set-password")
def set_password(self, request, pk=None):
user = self.get_object()
serializer = SetPasswordSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
user.set_password(serializer.validated_data["password"])
user.save(update_fields=["password", "updated_at"])
return Response(UserSerializer(user).data)
@action(detail=False, methods=["get"], url_path="buhs")
def buhs(self, request):
"""List BUH accounts a GM can request to report under (not BU Admin)."""
if request.user.status != User.STATUS_GM and not request.user.is_superuser:
if request.user.status not in {
User.STATUS_DIRECTOR,
User.STATUS_DIRECTOR_ADMIN,
User.STATUS_BUH,
User.STATUS_BU_ADMIN,
}:
return Response({"detail": "Not allowed."}, status=status.HTTP_403_FORBIDDEN)
rows = User.objects.filter(
status=User.STATUS_BUH,
is_active=True,
).order_by("user_name")
return Response(UserSerializer(rows, many=True).data)
@action(detail=False, methods=["get"], url_path="pending-managed")
def pending_managed(self, request):
"""BUH only: GMs who requested to report under this account."""
if request.user.status != User.STATUS_BUH:
return Response({"detail": "Not allowed."}, status=status.HTTP_403_FORBIDDEN)
rows = User.objects.filter(
managed_by_pending=request.user,
status=User.STATUS_GM,
).select_related("managed_by", "managed_by_pending").order_by("user_name")
return Response(UserSerializer(rows, many=True).data)
@action(detail=False, methods=["post"], url_path="request-managed-by")
def request_managed_by(self, request):
"""GM only: request to report under a BUH (not BU Admin)."""
if request.user.status != User.STATUS_GM:
return Response({"detail": "Not allowed."}, status=status.HTTP_403_FORBIDDEN)
buh_id = request.data.get("buh_id")
try:
buh = User.objects.get(
pk=buh_id,
status=User.STATUS_BUH,
is_active=True,
)
except (User.DoesNotExist, TypeError, ValueError):
return Response({"detail": "Invalid buh_id."}, status=status.HTTP_400_BAD_REQUEST)
try:
request.user.request_managed_by(buh)
except ValidationError as exc:
return Response({"detail": _validation_detail(exc)}, status=status.HTTP_400_BAD_REQUEST)
return Response(UserSerializer(request.user).data)
@action(detail=False, methods=["post"], url_path="cancel-managed-by-request")
def cancel_managed_by_request(self, request):
if request.user.status != User.STATUS_GM:
return Response({"detail": "Not allowed."}, status=status.HTTP_403_FORBIDDEN)
request.user.cancel_managed_by_request()
return Response(UserSerializer(request.user).data)
@action(detail=True, methods=["post"], url_path="approve-managed-by")
def approve_managed_by(self, request, pk=None):
if request.user.status != User.STATUS_BUH:
return Response({"detail": "Not allowed."}, status=status.HTTP_403_FORBIDDEN)
gm = User.objects.filter(
pk=pk,
managed_by_pending=request.user,
status=User.STATUS_GM,
).first()
if gm is None:
return Response({"detail": "Not found."}, status=status.HTTP_404_NOT_FOUND)
try:
gm.approve_managed_by(request.user)
except ValidationError as exc:
return Response({"detail": _validation_detail(exc)}, status=status.HTTP_400_BAD_REQUEST)
return Response(UserSerializer(gm).data)
@action(detail=True, methods=["post"], url_path="reject-managed-by")
def reject_managed_by(self, request, pk=None):
if request.user.status != User.STATUS_BUH:
return Response({"detail": "Not allowed."}, status=status.HTTP_403_FORBIDDEN)
gm = User.objects.filter(
pk=pk,
managed_by_pending=request.user,
status=User.STATUS_GM,
).first()
if gm is None:
return Response({"detail": "Not found."}, status=status.HTTP_404_NOT_FOUND)
try:
gm.reject_managed_by(request.user)
except ValidationError as exc:
return Response({"detail": _validation_detail(exc)}, status=status.HTTP_400_BAD_REQUEST)
return Response(UserSerializer(gm).data)
class ApiKeyViewSet(viewsets.ModelViewSet):
http_method_names = ["get", "post", "delete", "head", "options"]
def get_queryset(self):
qs = ApiKey.objects.select_related("user").order_by("-created_at")
if self.request.user.is_superuser:
return qs
return qs.filter(user=self.request.user)
def get_serializer_class(self):
if self.action == "create":
return ApiKeyCreateSerializer
return ApiKeySerializer
def get_permissions(self):
return [permissions.IsAuthenticated()]
def create(self, request, *args, **kwargs):
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
name = serializer.validated_data["name"]
user = request.user
if request.user.is_superuser and serializer.validated_data.get("user_id"):
user = User.objects.get(pk=serializer.validated_data["user_id"])
api_key, raw = ApiKey.generate(user=user, name=name)
data = ApiKeySerializer(api_key).data
data["raw_key"] = raw
return Response(data, status=status.HTTP_201_CREATED)
def perform_destroy(self, instance):
if (
not self.request.user.is_superuser
and instance.user_id != self.request.user.pk
):
raise permissions.PermissionDenied()
instance.delete()
@method_decorator(ensure_csrf_cookie, name="dispatch")
class CsrfView(APIView):
permission_classes = [permissions.AllowAny]
authentication_classes = []
def get(self, request):
return Response({"csrfToken": get_token(request)})
class LoginView(APIView):
permission_classes = [permissions.AllowAny]
authentication_classes = []
def post(self, request):
serializer = LoginSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
user = authenticate(
request,
username=serializer.validated_data["user_name"],
password=serializer.validated_data["password"],
)
if user is None:
return Response(
{"detail": "Invalid credentials"},
status=status.HTTP_401_UNAUTHORIZED,
)
login(request, user)
return Response(UserSerializer(user).data)
class LogoutView(APIView):
def post(self, request):
logout(request)
return Response(status=status.HTTP_204_NO_CONTENT)
class MeView(APIView):
def get(self, request):
return Response(UserSerializer(request.user).data)
View File
Whitespace-only changes.
+8
View File
@@ -0,0 +1,8 @@
from django.apps import AppConfig
class FarmsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.farms"
label = "farms"
verbose_name = "Farms"
@@ -0,0 +1,91 @@
# Generated by Django 5.2.17 on 2026-08-18 08:01
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Cycle',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('total_days', models.IntegerField()),
('start_date', models.DateField()),
('end_date', models.DateField()),
('doc_in_weight', models.IntegerField()),
('doc_in_count', models.IntegerField()),
('status', models.CharField(choices=[('active', 'Active'), ('closed', 'Closed')], default='active', max_length=30)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
options={
'db_table': 'cycles',
'ordering': ['-start_date'],
},
),
migrations.CreateModel(
name='Flock',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('flock_name', models.CharField(max_length=30)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('cycle', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.RESTRICT, related_name='flock_set', to='farms.cycle')),
],
options={
'db_table': 'flock',
'ordering': ['flock_name'],
},
),
migrations.AddField(
model_name='cycle',
name='flock',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='cycles', to='farms.flock'),
),
migrations.CreateModel(
name='Site',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('site_name', models.CharField(max_length=30)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sites', to=settings.AUTH_USER_MODEL)),
],
options={
'db_table': 'sites',
'ordering': ['site_name'],
},
),
migrations.CreateModel(
name='Kandang',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('kandang_name', models.CharField(max_length=30)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('site', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='kandangs', to='farms.site')),
],
options={
'db_table': 'kandang',
'ordering': ['kandang_name'],
},
),
migrations.AddField(
model_name='cycle',
name='kandang',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='cycles', to='farms.kandang'),
),
migrations.AddConstraint(
model_name='cycle',
constraint=models.CheckConstraint(condition=models.Q(('end_date__gte', models.F('start_date'))), name='chk_cycles_date_range'),
),
]
@@ -0,0 +1,81 @@
# Generated by Django 5.2.17 on 2026-08-19
import django.db.models.deletion
from django.db import migrations, models
def _column_names(connection, table):
with connection.cursor() as cursor:
if connection.vendor == "sqlite":
cursor.execute(f"PRAGMA table_info({table})")
return {row[1] for row in cursor.fetchall()}
cursor.execute(
"""
SELECT column_name
FROM information_schema.columns
WHERE table_name = %s
""",
[table],
)
return {row[0] for row in cursor.fetchall()}
def ensure_cycle_kandang_id(apps, schema_editor):
connection = schema_editor.connection
if "kandang_id" in _column_names(connection, "cycles"):
return
with connection.cursor() as cursor:
cursor.execute(
"ALTER TABLE cycles ADD COLUMN kandang_id bigint NULL REFERENCES kandang(id)"
)
def backfill_cycle_flocks(apps, schema_editor):
Cycle = apps.get_model("farms", "Cycle")
Flock = apps.get_model("farms", "Flock")
for cycle_id in Cycle.objects.filter(flock_id__isnull=True).values_list("pk", flat=True):
flock = Flock.objects.create(flock_name=f"Cycle {cycle_id}")
Cycle.objects.filter(pk=cycle_id).update(flock_id=flock.pk)
Flock.objects.filter(pk=flock.pk).update(cycle_id=cycle_id)
class Migration(migrations.Migration):
dependencies = [
("farms", "0001_initial"),
]
operations = [
migrations.RunPython(ensure_cycle_kandang_id, migrations.RunPython.noop),
migrations.RunPython(backfill_cycle_flocks, migrations.RunPython.noop),
migrations.AlterField(
model_name="cycle",
name="flock",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="cycles",
to="farms.flock",
),
),
migrations.AlterField(
model_name="cycle",
name="status",
field=models.CharField(
choices=[("active", "Active"), ("closed", "Closed")],
db_index=True,
default="active",
max_length=30,
),
),
migrations.AlterField(
model_name="flock",
name="cycle",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="flock_set",
to="farms.cycle",
),
),
]
@@ -0,0 +1,52 @@
# Generated by Django 5.2.17 on 2026-08-19
from django.db import migrations
def _sqlite_flock_cycle_notnull(connection):
with connection.cursor() as cursor:
cursor.execute("PRAGMA table_info(flock)")
cols = {row[1]: row for row in cursor.fetchall()}
cycle_col = cols.get("cycle_id")
return bool(cycle_col and cycle_col[3] == 1)
def make_flock_cycle_nullable(apps, schema_editor):
connection = schema_editor.connection
if connection.vendor != "sqlite":
return
if not _sqlite_flock_cycle_notnull(connection):
return
with connection.cursor() as cursor:
cursor.execute("PRAGMA foreign_keys=OFF")
cursor.execute("ALTER TABLE flock RENAME TO flock_old")
cursor.execute(
"""
CREATE TABLE flock (
id INTEGER PRIMARY KEY AUTOINCREMENT,
flock_name varchar(30) NOT NULL,
created_at datetime NOT NULL,
updated_at datetime NOT NULL,
cycle_id bigint NULL REFERENCES cycles(id)
)
"""
)
cursor.execute(
"""
INSERT INTO flock (id, flock_name, created_at, updated_at, cycle_id)
SELECT id, flock_name, created_at, updated_at, cycle_id FROM flock_old
"""
)
cursor.execute("DROP TABLE flock_old")
cursor.execute("PRAGMA foreign_keys=ON")
class Migration(migrations.Migration):
dependencies = [
("farms", "0002_cycle_requires_flock"),
]
operations = [
migrations.RunPython(make_flock_cycle_nullable, migrations.RunPython.noop),
]
@@ -0,0 +1,59 @@
# Generated by Django 5.2.17 on 2026-08-19
from django.db import migrations
def retarget_cycles_flock_fk(apps, schema_editor):
connection = schema_editor.connection
if connection.vendor != "sqlite":
return
with connection.cursor() as cursor:
cursor.execute("SELECT sql FROM sqlite_master WHERE name='cycles'")
row = cursor.fetchone()
if not row or "flock_old" not in (row[0] or ""):
return
cursor.execute("PRAGMA foreign_keys=OFF")
cursor.execute("ALTER TABLE cycles RENAME TO cycles_old")
cursor.execute(
"""
CREATE TABLE cycles (
id INTEGER PRIMARY KEY AUTOINCREMENT,
total_days integer NOT NULL,
start_date date NOT NULL,
end_date date NOT NULL,
doc_in_weight integer NOT NULL,
doc_in_count integer NOT NULL,
created_at datetime NOT NULL,
updated_at datetime NOT NULL,
flock_id bigint NOT NULL REFERENCES flock(id) DEFERRABLE INITIALLY DEFERRED,
kandang_id bigint NOT NULL REFERENCES kandang(id) DEFERRABLE INITIALLY DEFERRED,
status varchar(30) NOT NULL,
CONSTRAINT chk_cycles_date_range CHECK (end_date >= start_date)
)
"""
)
cursor.execute(
"""
INSERT INTO cycles (
id, total_days, start_date, end_date, doc_in_weight, doc_in_count,
created_at, updated_at, flock_id, kandang_id, status
)
SELECT
id, total_days, start_date, end_date, doc_in_weight, doc_in_count,
created_at, updated_at, flock_id, kandang_id, status
FROM cycles_old
"""
)
cursor.execute("DROP TABLE cycles_old")
cursor.execute("PRAGMA foreign_keys=ON")
class Migration(migrations.Migration):
dependencies = [
("farms", "0003_flock_cycle_nullable"),
]
operations = [
migrations.RunPython(retarget_cycles_flock_fk, migrations.RunPython.noop),
]
@@ -0,0 +1,16 @@
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("accounts", "0002_erd_named_primary_keys"),
("farms", "0004_retarget_cycles_flock_fk"),
]
operations = [
migrations.RenameField(model_name="site", old_name="id", new_name="site_id"),
migrations.RenameField(model_name="kandang", old_name="id", new_name="kandang_id"),
migrations.RenameField(model_name="flock", old_name="id", new_name="flock_id"),
migrations.RenameField(model_name="cycle", old_name="id", new_name="cycle_id"),
]
@@ -0,0 +1,40 @@
from django.db import migrations, models
import django.db.models.deletion
def backfill_flock_cycle_from_cycles(apps, schema_editor):
Cycle = apps.get_model("farms", "Cycle")
Flock = apps.get_model("farms", "Flock")
for cycle in Cycle.objects.exclude(flock_id__isnull=True).iterator():
Flock.objects.filter(pk=cycle.flock_id).update(cycle_id=cycle.pk)
Flock.objects.filter(cycle_id__isnull=True).delete()
class Migration(migrations.Migration):
dependencies = [
("farms", "0005_erd_named_primary_keys"),
]
operations = [
migrations.RunPython(backfill_flock_cycle_from_cycles, migrations.RunPython.noop),
migrations.AlterField(
model_name="flock",
name="cycle",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="flocks",
to="farms.cycle",
),
),
migrations.RemoveField(
model_name="cycle",
name="flock",
),
migrations.AlterField(
model_name="cycle",
name="doc_in_weight",
field=models.FloatField(),
),
]
@@ -0,0 +1,16 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("farms", "0006_erd_flock_belongs_to_cycle"),
]
operations = [
migrations.AlterField(
model_name="cycle",
name="doc_in_weight",
field=models.IntegerField(),
),
]
@@ -0,0 +1,16 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("farms", "0007_cycle_doc_in_weight_int"),
]
operations = [
migrations.AddField(
model_name="cycle",
name="feed_initial_balance",
field=models.IntegerField(default=0),
),
]
@@ -0,0 +1,50 @@
from django.db import migrations, models
import django.db.models.deletion
def backfill_flock_kandang(apps, schema_editor):
Flock = apps.get_model("farms", "Flock")
for flock in Flock.objects.select_related("cycle").iterator():
if flock.cycle_id and flock.kandang_id is None:
flock.kandang_id = flock.cycle.kandang_id
flock.save(update_fields=["kandang_id"])
class Migration(migrations.Migration):
dependencies = [
("farms", "0008_cycle_feed_initial_balance"),
]
operations = [
migrations.AddField(
model_name="flock",
name="kandang",
field=models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="flocks",
to="farms.kandang",
),
),
migrations.RunPython(backfill_flock_kandang, migrations.RunPython.noop),
migrations.AlterField(
model_name="flock",
name="kandang",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="flocks",
to="farms.kandang",
),
),
migrations.AlterField(
model_name="flock",
name="cycle",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="flocks",
to="farms.cycle",
),
),
]
@@ -0,0 +1,32 @@
from django.db import migrations, models
import django.db.models.deletion
def remove_orphan_flocks(apps, schema_editor):
Flock = apps.get_model("farms", "Flock")
Flock.objects.filter(cycle_id__isnull=True).delete()
class Migration(migrations.Migration):
atomic = False
dependencies = [
("farms", "0009_flock_belongs_to_kandang"),
]
operations = [
migrations.RunPython(remove_orphan_flocks, migrations.RunPython.noop),
migrations.RemoveField(
model_name="flock",
name="kandang",
),
migrations.AlterField(
model_name="flock",
name="cycle",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="flocks",
to="farms.cycle",
),
),
]
@@ -0,0 +1,45 @@
from django.db import migrations, models
import django.db.models.deletion
def backfill_flock_kandang_from_cycle(apps, schema_editor):
Flock = apps.get_model("farms", "Flock")
for flock in Flock.objects.select_related("cycle").iterator():
if flock.cycle_id and flock.kandang_id is None:
flock.kandang_id = flock.cycle.kandang_id
flock.save(update_fields=["kandang_id"])
class Migration(migrations.Migration):
atomic = False
dependencies = [
("farms", "0010_restore_flock_cycle_erd"),
]
operations = [
migrations.AddField(
model_name="flock",
name="kandang",
field=models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="flocks",
to="farms.kandang",
),
),
migrations.RunPython(backfill_flock_kandang_from_cycle, migrations.RunPython.noop),
migrations.AlterField(
model_name="flock",
name="kandang",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="flocks",
to="farms.kandang",
),
),
migrations.RemoveField(
model_name="flock",
name="cycle",
),
]
@@ -0,0 +1,15 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("farms", "0011_flock_belongs_to_kandang"),
]
operations = [
migrations.AddField(
model_name="cycle",
name="feed_initial_balance_date",
field=models.DateField(blank=True, null=True),
),
]
@@ -0,0 +1,15 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("farms", "0012_cycle_feed_initial_balance_date"),
]
operations = [
migrations.AddField(
model_name="kandang",
name="feed_in_button_url",
field=models.URLField(blank=True, default="", max_length=500),
),
]
@@ -0,0 +1,29 @@
from django.db import migrations, models
def migrate_single_url_to_list(apps, schema_editor):
Kandang = apps.get_model("farms", "Kandang")
for kandang in Kandang.objects.exclude(feed_in_button_url="").exclude(feed_in_button_url__isnull=True):
url = (kandang.feed_in_button_url or "").strip()
if url:
kandang.feed_in_button_urls = [{"label": "", "url": url}]
kandang.save(update_fields=["feed_in_button_urls"])
class Migration(migrations.Migration):
dependencies = [
("farms", "0013_kandang_feed_in_button_url"),
]
operations = [
migrations.AddField(
model_name="kandang",
name="feed_in_button_urls",
field=models.JSONField(blank=True, default=list),
),
migrations.RunPython(migrate_single_url_to_list, migrations.RunPython.noop),
migrations.RemoveField(
model_name="kandang",
name="feed_in_button_url",
),
]
@@ -0,0 +1,31 @@
# Generated by Django 5.1 for HQ GM cycle-close approval
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
("accounts", "0005_executive_roles"),
("farms", "0014_kandang_feed_in_button_urls"),
]
operations = [
migrations.AddField(
model_name="cycle",
name="close_approved_at",
field=models.DateTimeField(blank=True, null=True),
),
migrations.AddField(
model_name="cycle",
name="close_approved_by",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="approved_cycle_closes",
to=settings.AUTH_USER_MODEL,
),
),
]
@@ -0,0 +1,109 @@
# Generated manually for cycle close request channel
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
def null_end_dates_for_open_cycles(apps, schema_editor):
Cycle = apps.get_model("farms", "Cycle")
Cycle.objects.exclude(status="closed").update(end_date=None)
class Migration(migrations.Migration):
dependencies = [
("farms", "0015_cycle_close_approval"),
("sync", "0002_citysite_registered_by"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.RemoveConstraint(
model_name="cycle",
name="chk_cycles_date_range",
),
migrations.AlterField(
model_name="cycle",
name="end_date",
field=models.DateField(blank=True, null=True),
),
migrations.AlterField(
model_name="cycle",
name="total_days",
field=models.IntegerField(default=1),
),
migrations.AlterField(
model_name="cycle",
name="status",
field=models.CharField(
choices=[
("active", "Active"),
("pending_close", "Pending close"),
("closed", "Closed"),
],
db_index=True,
default="active",
max_length=30,
),
),
migrations.AddField(
model_name="cycle",
name="city_site",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="mirrored_cycles",
to="sync.citysite",
),
),
migrations.AddField(
model_name="cycle",
name="source_cycle_id",
field=models.BigIntegerField(
blank=True,
help_text="cycle_id on the city dashboard-cpsp deployment",
null=True,
),
),
migrations.AddField(
model_name="cycle",
name="proposed_end_date",
field=models.DateField(blank=True, null=True),
),
migrations.AddField(
model_name="cycle",
name="close_requested_at",
field=models.DateTimeField(blank=True, null=True),
),
migrations.AddField(
model_name="cycle",
name="close_requested_by",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="requested_cycle_closes",
to=settings.AUTH_USER_MODEL,
),
),
migrations.AddConstraint(
model_name="cycle",
constraint=models.CheckConstraint(
condition=models.Q(("end_date__isnull", True))
| models.Q(("end_date__gte", models.F("start_date"))),
name="chk_cycles_date_range",
),
),
migrations.AddConstraint(
model_name="cycle",
constraint=models.UniqueConstraint(
condition=models.Q(
("city_site__isnull", False), ("source_cycle_id__isnull", False)
),
fields=("city_site", "source_cycle_id"),
name="uniq_cycle_city_source",
),
),
migrations.RunPython(null_end_dates_for_open_cycles, migrations.RunPython.noop),
]
@@ -0,0 +1,23 @@
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
("farms", "0016_cycle_close_request_channel"),
("sync", "0002_citysite_registered_by"),
]
operations = [
migrations.AddField(
model_name="site",
name="city_site",
field=models.OneToOneField(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="hq_site",
to="sync.citysite",
),
),
]
Whitespace-only changes.
+276
View File
@@ -0,0 +1,276 @@
from datetime import date
from django.core.exceptions import ValidationError
from django.db import models
from django.utils import timezone
from apps.accounts.models import User
class Site(models.Model):
site_id = models.BigAutoField(primary_key=True)
site_name = models.CharField(max_length=30)
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="sites")
# Linked when GM/GM Admin adds the city dashboard to their Pengaturan view list.
city_site = models.OneToOneField(
"sync.CitySite",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="hq_site",
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = "sites"
ordering = ["site_name"]
def __str__(self) -> str:
return self.site_name
class Kandang(models.Model):
kandang_id = models.BigAutoField(primary_key=True)
kandang_name = models.CharField(max_length=30)
feed_in_button_urls = models.JSONField(default=list, blank=True)
site = models.ForeignKey(Site, on_delete=models.CASCADE, related_name="kandangs")
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = "kandang"
ordering = ["kandang_name"]
def __str__(self) -> str:
return self.kandang_name
class Flock(models.Model):
flock_id = models.BigAutoField(primary_key=True)
flock_name = models.CharField(max_length=30)
kandang = models.ForeignKey(
Kandang,
on_delete=models.CASCADE,
related_name="flocks",
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = "flock"
ordering = ["flock_name"]
def __str__(self) -> str:
return self.flock_name
class Cycle(models.Model):
STATUS_ACTIVE = "active"
STATUS_PENDING_CLOSE = "pending_close"
STATUS_CLOSED = "closed"
STATUS_CHOICES = [
(STATUS_ACTIVE, "Active"),
(STATUS_PENDING_CLOSE, "Pending close"),
(STATUS_CLOSED, "Closed"),
]
cycle_id = models.BigAutoField(primary_key=True)
total_days = models.IntegerField(default=1)
start_date = models.DateField()
end_date = models.DateField(null=True, blank=True)
doc_in_weight = models.IntegerField()
doc_in_count = models.IntegerField()
feed_initial_balance = models.IntegerField(default=0)
feed_initial_balance_date = models.DateField(null=True, blank=True)
status = models.CharField(
max_length=30, choices=STATUS_CHOICES, default=STATUS_ACTIVE, db_index=True
)
kandang = models.ForeignKey(Kandang, on_delete=models.CASCADE, related_name="cycles")
city_site = models.ForeignKey(
"sync.CitySite",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="mirrored_cycles",
)
source_cycle_id = models.BigIntegerField(
null=True,
blank=True,
help_text="cycle_id on the city dashboard-cpsp deployment",
)
proposed_end_date = models.DateField(null=True, blank=True)
close_requested_at = models.DateTimeField(null=True, blank=True)
close_requested_by = models.ForeignKey(
User,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="requested_cycle_closes",
)
close_approved_by = models.ForeignKey(
User,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="approved_cycle_closes",
)
close_approved_at = models.DateTimeField(null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = "cycles"
ordering = ["-start_date"]
constraints = [
models.CheckConstraint(
check=models.Q(end_date__isnull=True)
| models.Q(end_date__gte=models.F("start_date")),
name="chk_cycles_date_range",
),
models.UniqueConstraint(
fields=["city_site", "source_cycle_id"],
condition=models.Q(city_site__isnull=False, source_cycle_id__isnull=False),
name="uniq_cycle_city_source",
),
]
def __str__(self) -> str:
return f"Cycle #{self.pk} ({self.status})"
def clean(self) -> None:
if self.end_date and self.start_date and self.end_date < self.start_date:
raise ValidationError({"end_date": "Must be on or after start_date"})
if (
self.proposed_end_date
and self.start_date
and self.proposed_end_date < self.start_date
):
raise ValidationError(
{"proposed_end_date": "Must be on or after start_date"}
)
def soft_end_date(self) -> date:
if self.end_date:
return self.end_date
from apps.operations.services.visibility import visible_through_date
return visible_through_date()
def resolved_status(self) -> str:
return self.status
def sync_status(self) -> bool:
return False
def save(self, *args, **kwargs):
self.sync_status()
if kwargs.get("update_fields") is None:
if self.end_date and self.start_date:
self.total_days = max((self.end_date - self.start_date).days + 1, 1)
elif self.start_date and not self.end_date:
soft = self.soft_end_date()
self.total_days = max((soft - self.start_date).days + 1, 1)
super().save(*args, **kwargs)
def clear_close_request_fields(self) -> None:
self.proposed_end_date = None
self.close_requested_at = None
self.close_requested_by = None
def mark_pending_close(self, proposed_end_date: date, requested_at=None) -> None:
if proposed_end_date < self.start_date:
raise ValidationError(
{"proposed_end_date": "Must be on or after start_date"}
)
self.status = self.STATUS_PENDING_CLOSE
self.proposed_end_date = proposed_end_date
self.close_requested_at = requested_at or timezone.now()
self.close_approved_by = None
self.close_approved_at = None
self.save(
update_fields=[
"status",
"proposed_end_date",
"close_requested_at",
"close_approved_by",
"close_approved_at",
"updated_at",
]
)
def cancel_pending_close(self) -> None:
if self.status == self.STATUS_ACTIVE and self.proposed_end_date is None:
return
if self.status == self.STATUS_CLOSED:
raise ValidationError("Cycle is already closed.")
self.status = self.STATUS_ACTIVE
self.clear_close_request_fields()
self.save(
update_fields=[
"status",
"proposed_end_date",
"close_requested_at",
"close_requested_by",
"updated_at",
]
)
def approve_close(self, approver: User) -> None:
if self.status != self.STATUS_PENDING_CLOSE:
raise ValidationError("Cycle is not pending close approval.")
if not self.proposed_end_date:
raise ValidationError("proposed_end_date is missing.")
self.status = self.STATUS_CLOSED
self.end_date = self.proposed_end_date
self.total_days = max((self.end_date - self.start_date).days + 1, 1)
self.close_approved_by = approver
self.close_approved_at = timezone.now()
self.clear_close_request_fields()
self.save(
update_fields=[
"status",
"end_date",
"total_days",
"close_approved_by",
"close_approved_at",
"proposed_end_date",
"close_requested_at",
"close_requested_by",
"updated_at",
]
)
def reject_close(self, _actor: User) -> None:
if self.status != self.STATUS_PENDING_CLOSE:
raise ValidationError("Cycle is not pending close approval.")
self.status = self.STATUS_ACTIVE
self.clear_close_request_fields()
self.save(
update_fields=[
"status",
"proposed_end_date",
"close_requested_at",
"close_requested_by",
"updated_at",
]
)
@property
def current_day(self) -> int:
from apps.operations.services.visibility import visible_through_date
effective = visible_through_date()
if effective < self.start_date:
return 0
elapsed = (effective - self.start_date).days
if self.end_date:
max_day = max(self.total_days - 1, 0)
return min(max(elapsed, 0), max_day)
return max(elapsed, 0)
@property
def visible_through_date(self) -> date:
from apps.operations.services.visibility import visible_through_date as cutoff_date
return cutoff_date()
+152
View File
@@ -0,0 +1,152 @@
from rest_framework import serializers
from apps.accounts.serializers import PkAsIdMixin
from apps.farms.models import Cycle, Flock, Kandang, Site
class SiteSerializer(PkAsIdMixin, serializers.ModelSerializer):
owner_name = serializers.CharField(source="user.user_name", read_only=True)
class Meta:
model = Site
fields = [
"id",
"site_name",
"user",
"owner_name",
"city_site",
"created_at",
"updated_at",
]
read_only_fields = ["id", "owner_name", "city_site", "created_at", "updated_at"]
class FeedInButtonUrlSerializer(serializers.Serializer):
label = serializers.CharField(max_length=50, required=False, allow_blank=True, default="")
url = serializers.URLField(max_length=500)
class KandangSerializer(PkAsIdMixin, serializers.ModelSerializer):
feed_in_button_urls = FeedInButtonUrlSerializer(many=True, required=False)
class Meta:
model = Kandang
fields = [
"id",
"kandang_name",
"feed_in_button_urls",
"site",
"created_at",
"updated_at",
]
read_only_fields = ["id", "created_at", "updated_at"]
def validate_feed_in_button_urls(self, value):
cleaned = []
for item in value or []:
url = str(item.get("url", "")).strip()
if not url:
continue
label = str(item.get("label", "") or "").strip()[:50]
cleaned.append({"label": label, "url": url})
return cleaned
def update(self, instance, validated_data):
urls = validated_data.pop("feed_in_button_urls", None)
instance = super().update(instance, validated_data)
if urls is not None:
instance.feed_in_button_urls = urls
instance.save(update_fields=["feed_in_button_urls", "updated_at"])
return instance
def to_representation(self, instance):
data = super().to_representation(instance)
urls = data.get("feed_in_button_urls") or []
if not isinstance(urls, list):
urls = []
data["feed_in_button_urls"] = [
{"label": str(item.get("label", "") or ""), "url": str(item.get("url", "") or "")}
for item in urls
if isinstance(item, dict) and str(item.get("url", "") or "").strip()
]
return data
class FlockSerializer(PkAsIdMixin, serializers.ModelSerializer):
class Meta:
model = Flock
fields = ["id", "flock_name", "kandang", "created_at", "updated_at"]
read_only_fields = ["id", "created_at", "updated_at"]
class CycleSerializer(PkAsIdMixin, serializers.ModelSerializer):
kandang = serializers.PrimaryKeyRelatedField(queryset=Kandang.objects.all())
kandang_name = serializers.CharField(source="kandang.kandang_name", read_only=True)
chick_in_weight = serializers.IntegerField(source="doc_in_weight")
current_day = serializers.IntegerField(read_only=True)
visible_through_date = serializers.DateField(read_only=True)
soft_end_date = serializers.SerializerMethodField()
class Meta:
model = Cycle
fields = [
"id",
"kandang",
"kandang_name",
"total_days",
"current_day",
"visible_through_date",
"soft_end_date",
"start_date",
"end_date",
"chick_in_weight",
"doc_in_count",
"feed_initial_balance",
"feed_initial_balance_date",
"status",
"city_site",
"source_cycle_id",
"proposed_end_date",
"close_requested_at",
"close_requested_by",
"close_approved_by",
"close_approved_at",
"created_at",
"updated_at",
]
read_only_fields = [
"id",
"kandang_name",
"feed_initial_balance",
"feed_initial_balance_date",
"status",
"city_site",
"source_cycle_id",
"proposed_end_date",
"close_requested_at",
"close_requested_by",
"close_approved_by",
"close_approved_at",
"created_at",
"updated_at",
"soft_end_date",
]
def get_soft_end_date(self, obj: Cycle):
return obj.soft_end_date()
def validate(self, attrs):
attrs.pop("status", None)
if self.instance is None:
attrs.pop("end_date", None)
attrs.setdefault("total_days", 1)
else:
attrs.pop("end_date", None)
start = attrs.get("start_date", getattr(self.instance, "start_date", None))
end = attrs.get("end_date", getattr(self.instance, "end_date", None))
if start and end and end < start:
raise serializers.ValidationError({"end_date": "Must be on or after start_date."})
return attrs
def to_representation(self, instance):
return super().to_representation(instance)
Whitespace-only changes.
@@ -0,0 +1,43 @@
"""Link HQ Site rows to CitySite registry entries (Pengaturan view list)."""
from __future__ import annotations
from apps.farms.models import Site
from apps.sync.models import CitySite
def ensure_hq_site_for_city(city: CitySite) -> Site:
"""
Ensure the city registry entry has an HQ Site for farm visibility.
Prefers an existing linked site, then an unlinked site owned by the
registrar with a matching name (so seeded Sukawarna attaches when
registered), otherwise creates a new Site.
"""
existing = Site.objects.filter(city_site=city).first()
if existing:
return existing
owner = city.registered_by
if owner is None:
raise ValueError("CitySite.registered_by is required to create an HQ site.")
site_name = (city.name or city.code or "Site")[:30]
orphan = (
Site.objects.filter(user=owner, city_site__isnull=True, site_name__iexact=site_name)
.order_by("pk")
.first()
)
if orphan is None and city.code:
# e.g. code "sukawarna" ↔ site_name "Sukawarna"
orphan = (
Site.objects.filter(user=owner, city_site__isnull=True, site_name__iexact=city.code)
.order_by("pk")
.first()
)
if orphan is not None:
orphan.city_site = city
orphan.save(update_fields=["city_site", "updated_at"])
return orphan
return Site.objects.create(site_name=site_name, user=owner, city_site=city)
+49
View File
@@ -0,0 +1,49 @@
"""Push close decisions from pusat to city dashboard-cpsp."""
from __future__ import annotations
import logging
import requests
from apps.farms.models import Cycle
logger = logging.getLogger(__name__)
class SiteClosePushError(Exception):
def __init__(self, message: str):
self.message = message
super().__init__(message)
def push_close_decision(cycle: Cycle, *, approved: bool) -> None:
city = cycle.city_site
if not city or not city.api_base_url or not city.api_key or not cycle.source_cycle_id:
logger.info("No city channel configured for cycle %s; skip push", cycle.pk)
return
base = city.api_base_url.rstrip("/")
url = f"{base}/cycles/{cycle.source_cycle_id}/apply-close-decision/"
payload = {"decision": "approve" if approved else "reject"}
if approved:
if not cycle.end_date:
raise SiteClosePushError("Approved cycle is missing end_date.")
payload["end_date"] = cycle.end_date.isoformat()
try:
response = requests.post(
url,
json=payload,
headers={"X-API-Key": city.api_key, "Content-Type": "application/json"},
timeout=30,
)
if response.status_code >= 400:
raise SiteClosePushError(
f"Gagal kirim keputusan ke situs ({response.status_code}: {response.text[:200]})"
)
except SiteClosePushError:
raise
except requests.RequestException as exc:
logger.exception("Push close decision failed for cycle %s", cycle.pk)
raise SiteClosePushError("Gagal kirim keputusan ke situs — coba lagi") from exc
+394
View File
@@ -0,0 +1,394 @@
from datetime import date, timedelta
from unittest.mock import patch
from django.test import TestCase
from rest_framework.test import APIClient
from apps.accounts.models import User
from apps.farms.models import Cycle, Flock, Kandang, Site
from apps.operations.models import FeedSacks, ManualInput
class FarmAPITests(TestCase):
def setUp(self):
self.user = User.objects.create_user(user_name="tester", password="secret")
self.admin = User.objects.create_user(
user_name="admin",
password="secret",
status=User.STATUS_DIRECTOR_ADMIN,
)
self.client = APIClient()
self.site = Site.objects.create(site_name="Sukawarna", user=self.user)
self.kandang = Kandang.objects.create(kandang_name="Kandang 1", site=self.site)
def test_create_flock_under_kandang_requires_director_admin(self):
self.client.force_authenticate(self.admin)
response = self.client.post(
"/api/v1/flocks/",
{"flock_name": "Lantai 1", "kandang": self.kandang.pk},
format="json",
)
self.assertEqual(response.status_code, 201, response.data)
self.assertEqual(response.data["flock_name"], "Lantai 1")
self.assertEqual(response.data["kandang"], self.kandang.pk)
def test_staff_cannot_create_flock(self):
self.client.force_authenticate(self.user)
response = self.client.post(
"/api/v1/flocks/",
{"flock_name": "Lantai 1", "kandang": self.kandang.pk},
format="json",
)
self.assertEqual(response.status_code, 403, response.data)
def test_create_cycle_without_flock(self):
self.client.force_authenticate(self.user)
start = date.today()
response = self.client.post(
"/api/v1/cycles/",
{
"kandang": self.kandang.pk,
"total_days": 35,
"start_date": start.isoformat(),
"end_date": (start + timedelta(days=34)).isoformat(),
"chick_in_weight": 42,
"doc_in_count": 10000,
"status": "active",
},
format="json",
)
self.assertEqual(response.status_code, 201, response.data)
self.assertNotIn("flock", response.data)
self.assertNotIn("flock_name", response.data)
def test_update_cycle(self):
self.client.force_authenticate(self.user)
start = date(2026, 8, 1)
cycle = Cycle.objects.create(
kandang=self.kandang,
total_days=35,
start_date=start,
end_date=start + timedelta(days=34),
doc_in_weight=40,
doc_in_count=9000,
status=Cycle.STATUS_ACTIVE,
)
new_end = start + timedelta(days=39)
response = self.client.patch(
f"/api/v1/cycles/{cycle.pk}/",
{
"end_date": new_end.isoformat(),
"total_days": 40,
"doc_in_count": 9500,
"chick_in_weight": 42,
},
format="json",
)
self.assertEqual(response.status_code, 200, response.data)
cycle.refresh_from_db()
self.assertEqual(cycle.end_date, new_end)
self.assertEqual(cycle.total_days, 40)
self.assertEqual(cycle.doc_in_count, 9500)
self.assertEqual(cycle.doc_in_weight, 42)
def test_cycle_stays_active_without_gm_approval(self):
"""HQ does not auto-close by publish cutoff — GM approve-close is required."""
self.client.force_authenticate(self.user)
start = date(2026, 8, 1)
cycle = Cycle.objects.create(
kandang=self.kandang,
total_days=35,
start_date=start,
end_date=None,
doc_in_weight=40,
doc_in_count=9000,
status=Cycle.STATUS_ACTIVE,
)
response = self.client.get(f"/api/v1/cycles/{cycle.pk}/")
self.assertEqual(response.status_code, 200, response.data)
self.assertEqual(response.data["status"], Cycle.STATUS_ACTIVE)
cycle.refresh_from_db()
self.assertEqual(cycle.status, Cycle.STATUS_ACTIVE)
def test_gm_can_approve_cycle_close(self):
gm = User.objects.create_user(user_name="gm_approver", password="x", status=User.STATUS_GM)
site = Site.objects.create(site_name="GM Site", user=gm)
kandang = Kandang.objects.create(kandang_name="Kandang GM", site=site)
self.client.force_authenticate(gm)
start = date(2026, 8, 1)
proposed = start + timedelta(days=34)
cycle = Cycle.objects.create(
kandang=kandang,
total_days=1,
start_date=start,
end_date=None,
doc_in_weight=40,
doc_in_count=9000,
status=Cycle.STATUS_PENDING_CLOSE,
proposed_end_date=proposed,
)
response = self.client.post(f"/api/v1/cycles/{cycle.pk}/approve-close/")
self.assertEqual(response.status_code, 200, response.data)
body = response.data.get("cycle", response.data)
self.assertEqual(body["status"], Cycle.STATUS_CLOSED)
self.assertEqual(body["end_date"], proposed.isoformat())
cycle.refresh_from_db()
self.assertEqual(cycle.status, Cycle.STATUS_CLOSED)
self.assertEqual(cycle.close_approved_by_id, gm.pk)
self.assertIsNotNone(cycle.close_approved_at)
self.assertEqual(cycle.end_date, proposed)
def test_gm_can_reject_cycle_close(self):
gm = User.objects.create_user(user_name="gm_rejector", password="x", status=User.STATUS_GM)
site = Site.objects.create(site_name="GM Reject Site", user=gm)
kandang = Kandang.objects.create(kandang_name="Kandang R", site=site)
self.client.force_authenticate(gm)
start = date(2026, 8, 1)
cycle = Cycle.objects.create(
kandang=kandang,
total_days=1,
start_date=start,
end_date=None,
doc_in_weight=40,
doc_in_count=9000,
status=Cycle.STATUS_PENDING_CLOSE,
proposed_end_date=start + timedelta(days=20),
)
response = self.client.post(f"/api/v1/cycles/{cycle.pk}/reject-close/")
self.assertEqual(response.status_code, 200, response.data)
body = response.data.get("cycle", response.data)
self.assertEqual(body["status"], Cycle.STATUS_ACTIVE)
cycle.refresh_from_db()
self.assertEqual(cycle.status, Cycle.STATUS_ACTIVE)
self.assertIsNone(cycle.proposed_end_date)
def test_gm_admin_cannot_approve_cycle_close(self):
gm_admin = User.objects.create_user(
user_name="gm_admin_user", password="x", status=User.STATUS_GM_ADMIN
)
site = Site.objects.create(site_name="GM Admin Site", user=gm_admin)
kandang = Kandang.objects.create(kandang_name="Kandang GMA", site=site)
self.client.force_authenticate(gm_admin)
start = date(2026, 8, 1)
cycle = Cycle.objects.create(
kandang=kandang,
total_days=1,
start_date=start,
end_date=None,
doc_in_weight=40,
doc_in_count=9000,
status=Cycle.STATUS_PENDING_CLOSE,
proposed_end_date=start + timedelta(days=34),
)
response = self.client.post(f"/api/v1/cycles/{cycle.pk}/approve-close/")
self.assertEqual(response.status_code, 403)
cycle.refresh_from_db()
self.assertEqual(cycle.status, Cycle.STATUS_PENDING_CLOSE)
def test_flock_persists_across_cycles(self):
flock = Flock.objects.create(flock_name="Lantai 1", kandang=self.kandang)
start = date.today()
for offset in (0, 40):
Cycle.objects.create(
kandang=self.kandang,
total_days=35,
start_date=start + timedelta(days=offset),
end_date=start + timedelta(days=offset + 34),
doc_in_weight=40,
doc_in_count=9000,
status=Cycle.STATUS_ACTIVE,
)
self.assertEqual(Flock.objects.filter(kandang=self.kandang).count(), 1)
self.assertEqual(flock.kandang_id, self.kandang.pk)
def test_current_day_is_zero_based_from_start_date(self):
start = date(2026, 9, 2)
cycle = Cycle.objects.create(
kandang=self.kandang,
total_days=35,
start_date=start,
end_date=start + timedelta(days=34),
doc_in_weight=40,
doc_in_count=9000,
status=Cycle.STATUS_ACTIVE,
)
with patch(
"apps.operations.services.visibility.visible_through_date",
return_value=start,
):
self.assertEqual(cycle.current_day, 0)
with patch(
"apps.operations.services.visibility.visible_through_date",
return_value=start + timedelta(days=1),
):
self.assertEqual(cycle.current_day, 1)
def test_list_flocks_by_kandang(self):
self.client.force_authenticate(self.user)
Flock.objects.create(flock_name="Lantai 1", kandang=self.kandang)
Flock.objects.create(flock_name="Lantai 2", kandang=self.kandang)
other_kandang = Kandang.objects.create(kandang_name="Kandang 2", site=self.site)
Flock.objects.create(flock_name="Other", kandang=other_kandang)
response = self.client.get("/api/v1/flocks/", {"kandang_id": self.kandang.pk})
self.assertEqual(response.status_code, 200, response.data)
results = response.data["results"] if isinstance(response.data, dict) else response.data
self.assertEqual(len(results), 2)
def test_set_initial_balance_does_not_create_feed_sack_row(self):
start = date.today()
cycle = Cycle.objects.create(
kandang=self.kandang,
total_days=35,
start_date=start,
end_date=start + timedelta(days=34),
doc_in_weight=40,
doc_in_count=9000,
status=Cycle.STATUS_ACTIVE,
)
self.client.force_authenticate(self.user)
response = self.client.post(
f"/api/v1/cycles/{cycle.pk}/initial-balance/",
{"date": start.isoformat(), "feed_in_manual": 120},
format="json",
)
self.assertEqual(response.status_code, 200, response.data)
self.assertEqual(response.data["feed_initial_balance_date"], start.isoformat())
cycle.refresh_from_db()
self.assertEqual(cycle.feed_initial_balance, 120)
self.assertFalse(FeedSacks.objects.filter(cycle=cycle).exists())
self.assertFalse(ManualInput.objects.filter(cycle=cycle).exists())
def test_set_initial_balance_does_not_change_existing_feed_sack_totals(self):
start = date.today()
cycle = Cycle.objects.create(
kandang=self.kandang,
total_days=35,
start_date=start,
end_date=start + timedelta(days=34),
doc_in_weight=40,
doc_in_count=9000,
status=Cycle.STATUS_ACTIVE,
)
feed_row = FeedSacks.objects.create(
cycle=cycle,
date=start + timedelta(days=1),
in_today=4,
in_total=4,
out_today=0,
out_total=0,
feed_use_today=0,
feed_use_total=0,
)
self.client.force_authenticate(self.user)
response = self.client.post(
f"/api/v1/cycles/{cycle.pk}/initial-balance/",
{"date": start.isoformat(), "feed_in_manual": 120},
format="json",
)
self.assertEqual(response.status_code, 200, response.data)
feed_row.refresh_from_db()
self.assertEqual(feed_row.in_total, 4)
def test_set_initial_balance_twice_on_different_dates_keeps_daily_totals(self):
start = date(2026, 8, 26)
cycle = Cycle.objects.create(
kandang=self.kandang,
total_days=35,
start_date=start,
end_date=start + timedelta(days=34),
doc_in_weight=40,
doc_in_count=9000,
status=Cycle.STATUS_ACTIVE,
)
feed_row = FeedSacks.objects.create(
cycle=cycle,
date=start + timedelta(days=2),
in_today=6,
in_total=6,
out_today=0,
out_total=0,
feed_use_today=0,
feed_use_total=0,
)
self.client.force_authenticate(self.user)
first = self.client.post(
f"/api/v1/cycles/{cycle.pk}/initial-balance/",
{"date": start.isoformat(), "feed_in_manual": 120},
format="json",
)
self.assertEqual(first.status_code, 200, first.data)
second = self.client.post(
f"/api/v1/cycles/{cycle.pk}/initial-balance/",
{"date": (start + timedelta(days=1)).isoformat(), "feed_in_manual": 80},
format="json",
)
self.assertEqual(second.status_code, 200, second.data)
cycle.refresh_from_db()
self.assertEqual(cycle.feed_initial_balance, 80)
self.assertEqual(cycle.feed_initial_balance_date, start + timedelta(days=1))
self.assertEqual(FeedSacks.objects.filter(cycle=cycle).count(), 1)
feed_row.refresh_from_db()
self.assertEqual(feed_row.in_total, 6)
@patch("apps.farms.views.pull_initial_balance_iot", return_value=(115, None))
def test_set_initial_balance_compares_manual_against_combined_api_iot(self, mock_pull):
start = date(2026, 8, 26)
cycle = Cycle.objects.create(
kandang=self.kandang,
total_days=35,
start_date=start,
end_date=start + timedelta(days=34),
doc_in_weight=40,
doc_in_count=9000,
status=Cycle.STATUS_ACTIVE,
)
self.client.force_authenticate(self.user)
response = self.client.post(
f"/api/v1/cycles/{cycle.pk}/initial-balance/",
{"date": start.isoformat(), "feed_in_manual": 120},
format="json",
)
self.assertEqual(response.status_code, 200, response.data)
self.assertEqual(response.data["iot"], 115)
self.assertEqual(response.data["accuracy"], 95.8)
mock_pull.assert_called_once_with(cycle, start)
@patch("apps.farms.views.pull_initial_balance_iot", return_value=(112, None))
def test_compare_initial_balance_returns_iot_and_accuracy(self, mock_pull):
start = date(2026, 8, 27)
cycle = Cycle.objects.create(
kandang=self.kandang,
total_days=35,
start_date=start,
end_date=start + timedelta(days=34),
doc_in_weight=40,
doc_in_count=9000,
feed_initial_balance=120,
status=Cycle.STATUS_ACTIVE,
)
self.client.force_authenticate(self.user)
response = self.client.get(
f"/api/v1/cycles/{cycle.pk}/initial-balance-compare/",
{"date": start.isoformat(), "manual": 120},
)
self.assertEqual(response.status_code, 200, response.data)
self.assertEqual(response.data["iot"], 112)
self.assertEqual(response.data["accuracy"], 93.3)
mock_pull.assert_called_once_with(cycle, start)
@@ -0,0 +1,75 @@
from django.test import Client, TestCase
from apps.accounts.models import User
from apps.farms.models import Kandang, Site
class KandangFeedInButtonUrlTests(TestCase):
def setUp(self):
self.owner = User.objects.create_user(user_name="owner", password="secret")
self.admin = User.objects.create_user(
user_name="admin",
password="admin123",
status=User.STATUS_DIRECTOR_ADMIN,
)
self.staff = User.objects.create_user(
user_name="staff",
password="staff123",
status=User.STATUS_GM,
)
self.site = Site.objects.create(site_name="Site A", user=self.owner)
self.kandang = Kandang.objects.create(kandang_name="Kandang 1", site=self.site)
self.payload = {
"feed_in_button_urls": [{"label": "Gudang A", "url": "http://192.168.1.10:8080/"}]
}
def _csrf_headers(self, client: Client) -> dict[str, str]:
token = client.cookies["csrftoken"].value
return {"HTTP_X_CSRFTOKEN": token}
def _login(self, client: Client, user_name: str, password: str) -> None:
client.get("/api/v1/auth/csrf/")
response = client.post(
"/api/v1/auth/login/",
{"user_name": user_name, "password": password},
content_type="application/json",
**self._csrf_headers(client),
)
self.assertEqual(response.status_code, 200, response.content)
def test_director_admin_can_save_feed_in_button_urls(self):
client = Client(enforce_csrf_checks=True)
self._login(client, "admin", "admin123")
response = client.patch(
f"/api/v1/kandangs/{self.kandang.pk}/",
self.payload,
content_type="application/json",
**self._csrf_headers(client),
)
self.assertEqual(response.status_code, 200, response.content)
self.kandang.refresh_from_db()
self.assertEqual(self.kandang.feed_in_button_urls, self.payload["feed_in_button_urls"])
def test_staff_cannot_save_feed_in_button_urls(self):
client = Client(enforce_csrf_checks=True)
self._login(client, "staff", "staff123")
response = client.patch(
f"/api/v1/kandangs/{self.kandang.pk}/",
self.payload,
content_type="application/json",
**self._csrf_headers(client),
)
self.assertEqual(response.status_code, 403, response.content)
self.kandang.refresh_from_db()
self.assertEqual(self.kandang.feed_in_button_urls, [])
def test_staff_cannot_rename_kandang(self):
client = Client(enforce_csrf_checks=True)
self._login(client, "staff", "staff123")
response = client.patch(
f"/api/v1/kandangs/{self.kandang.pk}/",
{"kandang_name": "Renamed"},
content_type="application/json",
**self._csrf_headers(client),
)
self.assertEqual(response.status_code, 403, response.content)
+22
View File
@@ -0,0 +1,22 @@
from django.urls import path
from rest_framework.routers import DefaultRouter
from apps.farms.views import (
CycleViewSet,
FlockViewSet,
KandangViewSet,
SiteViewSet,
cycle_close_cancel,
cycle_close_ingest,
)
router = DefaultRouter()
router.register("sites", SiteViewSet, basename="site")
router.register("kandangs", KandangViewSet, basename="kandang")
router.register("flocks", FlockViewSet, basename="flock")
router.register("cycles", CycleViewSet, basename="cycle")
urlpatterns = [
path("cycle-close/ingest/", cycle_close_ingest, name="cycle-close-ingest"),
path("cycle-close/cancel/", cycle_close_cancel, name="cycle-close-cancel"),
] + router.urls
+382
View File
@@ -0,0 +1,382 @@
from django.core.exceptions import ValidationError
from django.utils.dateparse import parse_date, parse_datetime
from rest_framework import permissions, status, viewsets
from rest_framework.decorators import action, api_view, permission_classes
from rest_framework.response import Response
from apps.accounts.models import User
from apps.accounts.permissions import CanApproveCycleClose, can_approve_cycle_close
from apps.accounts.scoping import (
filter_cycles_queryset,
filter_flocks_queryset,
filter_kandangs_queryset,
filter_sites_queryset,
)
from apps.farms.models import Cycle, Flock, Kandang, Site
from apps.farms.serializers import CycleSerializer, FlockSerializer, KandangSerializer, SiteSerializer
from apps.farms.services.site_close import SiteClosePushError, push_close_decision
from apps.operations.services.feed_sacks import (
cleanup_opening_balance_feed_rows,
recompute_feed_sack_in_totals,
)
from apps.operations.services.karung_web import (
initial_balance_accuracy,
pull_initial_balance_iot,
)
from apps.farms.services.city_registry import ensure_hq_site_for_city
from apps.sync.models import CitySite
def _validation_detail(exc: ValidationError):
if hasattr(exc, "message_dict"):
return exc.message_dict
if hasattr(exc, "messages"):
return "; ".join(str(m) for m in exc.messages)
return str(getattr(exc, "message", exc))
def _upsert_mirror_cycle_from_ingest(city: CitySite, payload: dict) -> Cycle:
source_cycle_id = payload.get("source_cycle_id")
if not source_cycle_id:
raise ValidationError("source_cycle_id is required.")
proposed = parse_date(str(payload.get("proposed_end_date") or ""))
if proposed is None:
raise ValidationError("proposed_end_date is required.")
start = parse_date(str(payload.get("start_date") or ""))
if start is None:
raise ValidationError("start_date is required.")
site_name = str(payload.get("site_name") or city.name)[:30]
kandang_name = str(payload.get("kandang_name") or "Kandang")[:30]
owner = city.registered_by
if owner is None:
owner = User.objects.filter(status=User.STATUS_GM).first()
if owner is None:
raise ValidationError("No GM available to own mirrored site.")
site = ensure_hq_site_for_city(city)
if site.site_name != site_name and not Site.objects.filter(
user=owner, site_name=site_name
).exclude(pk=site.pk).exists():
# Prefer ingest site_name when it does not collide.
site.site_name = site_name
site.save(update_fields=["site_name", "updated_at"])
kandang, _ = Kandang.objects.get_or_create(
site=site,
kandang_name=kandang_name,
defaults={},
)
cycle = Cycle.objects.filter(city_site=city, source_cycle_id=source_cycle_id).first()
if cycle is None:
cycle = Cycle(
kandang=kandang,
city_site=city,
source_cycle_id=source_cycle_id,
start_date=start,
end_date=None,
doc_in_weight=int(payload.get("doc_in_weight") or 0),
doc_in_count=int(payload.get("doc_in_count") or 0),
total_days=int(payload.get("total_days") or 1),
status=Cycle.STATUS_ACTIVE,
)
cycle.save()
else:
cycle.kandang = kandang
cycle.start_date = start
cycle.doc_in_weight = int(payload.get("doc_in_weight") or cycle.doc_in_weight)
cycle.doc_in_count = int(payload.get("doc_in_count") or cycle.doc_in_count)
cycle.save()
requested_dt = parse_datetime(str(payload.get("requested_at") or "")) or None
cycle.mark_pending_close(proposed, requested_at=requested_dt)
return cycle
@api_view(["POST"])
@permission_classes([permissions.IsAuthenticated])
def cycle_close_ingest(request):
"""Site → pusat: register/update a pending close request."""
city_site_id = request.data.get("city_site_id")
try:
city = CitySite.objects.get(pk=city_site_id, is_active=True)
except (CitySite.DoesNotExist, TypeError, ValueError):
return Response({"detail": "Invalid city_site_id."}, status=status.HTTP_400_BAD_REQUEST)
try:
cycle = _upsert_mirror_cycle_from_ingest(city, request.data)
except ValidationError as exc:
return Response({"detail": _validation_detail(exc)}, status=status.HTTP_400_BAD_REQUEST)
return Response(CycleSerializer(cycle).data)
@api_view(["POST"])
@permission_classes([permissions.IsAuthenticated])
def cycle_close_cancel(request):
"""Site → pusat: cancel a pending close request."""
city_site_id = request.data.get("city_site_id")
source_cycle_id = request.data.get("source_cycle_id")
try:
city = CitySite.objects.get(pk=city_site_id, is_active=True)
except (CitySite.DoesNotExist, TypeError, ValueError):
return Response({"detail": "Invalid city_site_id."}, status=status.HTTP_400_BAD_REQUEST)
cycle = Cycle.objects.filter(city_site=city, source_cycle_id=source_cycle_id).first()
if cycle is None:
return Response({"detail": "Mirror cycle not found."}, status=status.HTTP_404_NOT_FOUND)
try:
cycle.cancel_pending_close()
except ValidationError as exc:
return Response({"detail": _validation_detail(exc)}, status=status.HTTP_400_BAD_REQUEST)
return Response(CycleSerializer(cycle).data)
class IsDirectorAdminForWrite(permissions.BasePermission):
"""Reads for any authenticated executive; structure writes for director_admin.
Mirrored farm structures should normally arrive via city sync. Manual
create/edit/delete is reserved for director_admin (bootstrap / repair).
"""
def has_permission(self, request, view):
if not request.user or not request.user.is_authenticated:
return False
if request.method in permissions.SAFE_METHODS:
return True
return bool(
request.user.is_superuser
or getattr(request.user, "status", None)
in {
User.STATUS_DIRECTOR_ADMIN,
User.STATUS_SUPERUSER,
}
)
class SiteViewSet(viewsets.ModelViewSet):
queryset = Site.objects.select_related("user").all()
serializer_class = SiteSerializer
permission_classes = [IsDirectorAdminForWrite]
def get_queryset(self):
qs = filter_sites_queryset(super().get_queryset(), self.request.user)
user_id = self.request.query_params.get("user_id")
if user_id:
qs = qs.filter(user_id=user_id)
return qs
class KandangViewSet(viewsets.ModelViewSet):
queryset = Kandang.objects.select_related("site").all()
serializer_class = KandangSerializer
permission_classes = [IsDirectorAdminForWrite]
def get_queryset(self):
qs = filter_kandangs_queryset(super().get_queryset(), self.request.user)
site_id = self.request.query_params.get("site_id")
if site_id:
qs = qs.filter(site_id=site_id)
return qs
class FlockViewSet(viewsets.ModelViewSet):
queryset = Flock.objects.select_related("kandang").all()
serializer_class = FlockSerializer
permission_classes = [IsDirectorAdminForWrite]
def get_queryset(self):
qs = filter_flocks_queryset(super().get_queryset(), self.request.user)
kandang_id = self.request.query_params.get("kandang_id")
if kandang_id:
qs = qs.filter(kandang_id=kandang_id)
return qs
class CycleViewSet(viewsets.ModelViewSet):
queryset = Cycle.objects.select_related(
"kandang", "close_approved_by", "city_site", "kandang__site"
).all()
serializer_class = CycleSerializer
def get_queryset(self):
qs = filter_cycles_queryset(super().get_queryset(), self.request.user)
params = self.request.query_params
if params.get("kandang_id"):
qs = qs.filter(kandang_id=params["kandang_id"])
if params.get("status"):
qs = qs.filter(status=params["status"])
return qs
@action(
detail=True,
methods=["post"],
url_path="approve-close",
permission_classes=[CanApproveCycleClose],
)
def approve_close(self, request, pk=None):
"""GM-only: approve closing a cycle on the pusat mirror."""
if not can_approve_cycle_close(request.user):
return Response({"detail": "Only GM may approve cycle close."}, status=status.HTTP_403_FORBIDDEN)
cycle = self.get_object()
try:
cycle.approve_close(request.user)
except ValidationError as exc:
return Response({"detail": _validation_detail(exc)}, status=status.HTTP_400_BAD_REQUEST)
site_error = None
try:
push_close_decision(cycle, approved=True)
except SiteClosePushError as exc:
site_error = exc.message
data = CycleSerializer(cycle).data
if site_error:
return Response({"cycle": data, "site_error": site_error})
return Response(data)
@action(
detail=True,
methods=["post"],
url_path="reject-close",
permission_classes=[CanApproveCycleClose],
)
def reject_close(self, request, pk=None):
"""GM-only: reject a pending close request."""
if not can_approve_cycle_close(request.user):
return Response({"detail": "Only GM may reject cycle close."}, status=status.HTTP_403_FORBIDDEN)
cycle = self.get_object()
try:
cycle.reject_close(request.user)
except ValidationError as exc:
return Response({"detail": _validation_detail(exc)}, status=status.HTTP_400_BAD_REQUEST)
site_error = None
try:
push_close_decision(cycle, approved=False)
except SiteClosePushError as exc:
site_error = exc.message
data = CycleSerializer(cycle).data
if site_error:
return Response({"cycle": data, "site_error": site_error})
return Response(data)
@action(detail=True, methods=["get"], url_path="initial-balance-compare")
def compare_initial_balance(self, request, pk=None):
"""Pull IoT masuk for a date and compare against the cycle saldo awal."""
cycle = self.get_object()
date_str = request.query_params.get("date")
if not date_str:
return Response({"detail": "date is required."}, status=status.HTTP_400_BAD_REQUEST)
balance_date = parse_date(str(date_str))
if balance_date is None:
return Response(
{"detail": "date must be in YYYY-MM-DD format."},
status=status.HTTP_400_BAD_REQUEST,
)
manual_raw = request.query_params.get("manual")
if manual_raw is None:
feed_in_manual = cycle.feed_initial_balance
else:
try:
feed_in_manual = int(manual_raw)
except (TypeError, ValueError):
return Response(
{"detail": "manual must be an integer."},
status=status.HTTP_400_BAD_REQUEST,
)
iot_in, sync_error = pull_initial_balance_iot(cycle, balance_date)
return Response(
{
"cycle": cycle.pk,
"date": balance_date.isoformat(),
"feed_initial_balance": cycle.feed_initial_balance,
"feed_initial_balance_date": (
cycle.feed_initial_balance_date.isoformat()
if cycle.feed_initial_balance_date
else None
),
"manual": feed_in_manual,
"manual_total": feed_in_manual,
"iot": iot_in,
"accuracy": initial_balance_accuracy(feed_in_manual, iot_in),
"sync_error": sync_error,
}
)
@action(detail=True, methods=["post"], url_path="initial-balance")
def set_initial_balance(self, request, pk=None):
"""Save the feed-in balance for the cycle (Saldo Awal).
Sets ``feed_initial_balance`` on the cycle (the only cycle field this
feature writes — never during cycle creation). Opening balance is kept on
the cycle for saldo only and is not written into daily FeedSacks rows.
Pulls the IoT-detected "in" for that date from karung-web-admin
``/api/combined`` and returns a manual vs IoT accuracy figure.
"""
cycle = self.get_object()
date_str = request.data.get("date")
if not date_str:
return Response({"detail": "date is required."}, status=status.HTTP_400_BAD_REQUEST)
balance_date = parse_date(str(date_str))
if balance_date is None:
return Response(
{"detail": "date must be in YYYY-MM-DD format."},
status=status.HTTP_400_BAD_REQUEST,
)
try:
feed_in_manual = int(request.data.get("feed_in_manual"))
except (TypeError, ValueError):
return Response(
{"detail": "feed_in_manual must be an integer."},
status=status.HTTP_400_BAD_REQUEST,
)
if feed_in_manual < 0:
return Response(
{"detail": "feed_in_manual cannot be negative."},
status=status.HTTP_400_BAD_REQUEST,
)
previous_date = cycle.feed_initial_balance_date
previous_balance = cycle.feed_initial_balance
cycle.feed_initial_balance = feed_in_manual
cycle.feed_initial_balance_date = balance_date
cycle.save(
update_fields=["feed_initial_balance", "feed_initial_balance_date", "updated_at"]
)
# Pull IoT "in" at the balance date from karung-web-admin /api/combined.
iot_in, sync_error = pull_initial_balance_iot(cycle, balance_date)
cleanup_opening_balance_feed_rows(
cycle,
previous_date=previous_date,
previous_balance=previous_balance,
balance_date=balance_date,
balance_amount=feed_in_manual,
)
recompute_feed_sack_in_totals(cycle)
accuracy = initial_balance_accuracy(feed_in_manual, iot_in)
return Response(
{
"cycle": cycle.pk,
"date": balance_date.isoformat(),
"feed_initial_balance": cycle.feed_initial_balance,
"feed_initial_balance_date": (
cycle.feed_initial_balance_date.isoformat()
if cycle.feed_initial_balance_date
else None
),
"manual": feed_in_manual,
"manual_total": feed_in_manual,
"iot": iot_in,
"accuracy": accuracy,
"sync_error": sync_error,
}
)
View File
Whitespace-only changes.
+8
View File
@@ -0,0 +1,8 @@
from django.apps import AppConfig
class JobsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.jobs"
label = "jobs"
verbose_name = "Jobs"
Whitespace-only changes.
Whitespace-only changes.
@@ -0,0 +1,11 @@
from django.conf import settings
from django.core.management.base import BaseCommand
from apps.farms.models import Cycle
class Command(BaseCommand):
help = "Placeholder — advance_cycle_days no longer applicable (current_day removed from ERD)"
def handle(self, *args, **options):
self.stdout.write("current_day field removed; this command is a no-op now.")
@@ -0,0 +1,117 @@
from django.conf import settings
from django.core.management.base import BaseCommand, CommandError
from django.db import transaction
from apps.accounts.models import ApiKey, User
def ensure_bootstrap_user(
*,
user_name: str,
password: str,
status: str,
is_superuser: bool,
force_password: bool,
stdout,
) -> User:
user, created = User.objects.get_or_create(
user_name=user_name,
defaults={
"status": status,
"is_superuser": is_superuser,
},
)
if not created and user.status != status:
stdout.write(
f"Updating {user_name!r} status {user.status!r} → {status!r}"
)
should_set_password = created or not user.has_usable_password() or force_password
if should_set_password:
user.set_password(password)
user.status = status
user.is_superuser = is_superuser
user.save()
action = "Created" if created else "Updated password for"
stdout.write(f"{action} {status} user {user_name}")
else:
user.status = status
user.is_superuser = is_superuser
user.save(update_fields=["status", "is_superuser", "updated_at"])
stdout.write(f"User {user_name} already exists (password unchanged)")
return user
class Command(BaseCommand):
help = (
"Create production login accounts only (no demo sites/cycles/data). "
"BOOTSTRAP_ADMIN_* creates the break-glass superuser (ops dashboards + "
"user management; hidden from user lists). Optional BOOTSTRAP_STAFF_* creates a GM."
)
def add_arguments(self, parser):
parser.add_argument(
"--force-password",
action="store_true",
help="Reset password even when the user already has one",
)
@transaction.atomic
def handle(self, *args, **options):
force_password = options["force_password"]
admin_user_name = (settings.BOOTSTRAP_ADMIN_USER or "admin").strip()
admin_password = settings.BOOTSTRAP_ADMIN_PASSWORD or ""
if not admin_password:
raise CommandError(
"BOOTSTRAP_ADMIN_PASSWORD is required. Set it in .env before running bootstrap_admin."
)
admin = ensure_bootstrap_user(
user_name=admin_user_name,
password=admin_password,
status=User.STATUS_SUPERUSER,
is_superuser=True,
force_password=force_password,
stdout=self.stdout,
)
staff_user_name = (settings.BOOTSTRAP_STAFF_USER or "").strip()
staff_password = settings.BOOTSTRAP_STAFF_PASSWORD or ""
if staff_user_name:
if staff_user_name == admin_user_name:
raise CommandError(
"BOOTSTRAP_STAFF_USER must differ from BOOTSTRAP_ADMIN_USER."
)
if not staff_password:
raise CommandError(
"BOOTSTRAP_STAFF_PASSWORD is required when BOOTSTRAP_STAFF_USER is set."
)
ensure_bootstrap_user(
user_name=staff_user_name,
password=staff_password,
status=User.STATUS_GM,
is_superuser=False,
force_password=force_password,
stdout=self.stdout,
)
bootstrap_key = settings.BOOTSTRAP_API_KEY
if bootstrap_key:
api_key, _ = ApiKey.generate(admin, "bootstrap")
api_key.prefix = bootstrap_key[:8]
api_key.key_hash = ApiKey.hash_key(bootstrap_key)
api_key.save(update_fields=["prefix", "key_hash", "updated_at"])
self.stdout.write(f"Bootstrap API key installed (prefix={api_key.prefix})")
accounts = admin_user_name
if staff_user_name:
accounts = f"{admin_user_name}, {staff_user_name}"
self.stdout.write(
self.style.SUCCESS(
f"Bootstrap OK: login={accounts} "
"(no demo farm data — use Pengaturan to add sites/kandang/cycles)"
)
)
@@ -0,0 +1,58 @@
from django.core.management.base import BaseCommand
from django.db.models import Sum
from apps.farms.models import Cycle
from apps.operations.models import ChickenCounting, FeedSacks, KPI
from apps.operations.services.visibility import visible_through_date
class Command(BaseCommand):
help = "Recompute simple KPI rollups from ops tables for active cycles"
def handle(self, *args, **options):
cutoff = visible_through_date()
count = 0
for cycle in Cycle.objects.filter(status=Cycle.STATUS_ACTIVE):
def visible(model):
return model.objects.filter(cycle=cycle, date__lte=cutoff)
counting = visible(ChickenCounting).aggregate(
mortality=Sum("mortality_count"),
life=Sum("total_count"),
)
feed_sack = visible(FeedSacks).order_by("-date", "-pk").first()
latest_cc = visible(ChickenCounting).order_by("-date").first()
latest_cw = None
latest_fs = feed_sack
latest_mi = None
from apps.operations.models import ChickenWeight, ManualInput
latest_cw = visible(ChickenWeight).order_by("-date").first()
latest_mi = visible(ManualInput).order_by("-date").first()
if not all([latest_cc, latest_cw, latest_fs, latest_mi]):
self.stdout.write(f"cycle={cycle.pk} skipped (missing source records)")
continue
kpi, _ = KPI.objects.update_or_create(
cycle=cycle,
date=latest_cc.date,
defaults={
"cc": latest_cc,
"cw": latest_cw,
"fs": latest_fs,
"manual": latest_mi,
"mortality_total": counting["mortality"] or 0,
"chicken_life": counting["life"] or cycle.doc_in_count,
"feed_total": feed_sack.feed_use_total if feed_sack else 0,
"chicken_life_percentage": (
((counting["life"] or cycle.doc_in_count) / cycle.doc_in_count * 100.0)
if cycle.doc_in_count
else 0.0
),
},
)
count += 1
self.stdout.write(f"KPI {kpi.pk} for cycle {cycle.pk}")
self.stdout.write(self.style.SUCCESS(f"Recomputed {count} KPI row(s)"))
@@ -0,0 +1,146 @@
from django.conf import settings
from django.core.management import call_command
from django.core.management.base import BaseCommand
from django.db import transaction
from apps.accounts.models import ApiKey, User
from apps.farms.models import Site
from apps.sync.models import CitySite
# Every executive role + inactive, for local HQ demos.
SEED_USERS = (
{
# Break-glass: status=superuser (ops + Manajemen Akun). Hidden from user lists.
"user_name": "admin",
"password": "admin123",
"status": User.STATUS_SUPERUSER,
"is_superuser": True,
},
{
"user_name": "director",
"password": "director123",
"status": User.STATUS_DIRECTOR,
},
{
"user_name": "director_admin",
"password": "directoradmin123",
"status": User.STATUS_DIRECTOR_ADMIN,
},
{
"user_name": "buh",
"password": "buh123",
"status": User.STATUS_BUH,
},
{
"user_name": "bu_admin",
"password": "buadmin123",
"status": User.STATUS_BU_ADMIN,
},
{
"user_name": "gm",
"password": "gm123",
"status": User.STATUS_GM,
"managed_by": "buh",
},
{
"user_name": "gm_admin",
"password": "gmadmin123",
"status": User.STATUS_GM_ADMIN,
"managed_by": None, # not on BUH org chart; only GM requests/confirm
},
{
"user_name": "account_admin",
"password": "accountadmin123",
"status": User.STATUS_ACCOUNT_ADMIN,
},
)
class Command(BaseCommand):
help = (
"Seed all HQ executive role logins + API key, then Sukawarna Kandang 1 "
"and Kandang 2 as default closed cycles (local mirror demo data). "
"Removes legacy staff login if present."
)
def _ensure_user(self, spec: dict, by_name: dict[str, User]) -> User:
managed_by_name = spec.get("managed_by")
managed_by = by_name.get(managed_by_name) if managed_by_name else None
defaults = {
"status": spec["status"],
"is_superuser": spec.get("is_superuser", False),
}
if managed_by is not None:
defaults["managed_by"] = managed_by
user, created = User.objects.get_or_create(
user_name=spec["user_name"],
defaults=defaults,
)
user.status = spec["status"]
user.is_superuser = spec.get("is_superuser", False)
if managed_by is not None:
user.managed_by = managed_by
elif "managed_by" in spec:
user.managed_by = None
user.set_password(spec["password"])
user.save()
role = spec["status"]
if user.is_superuser:
role = f"{role}+superuser"
link = f", managed_by={managed_by.user_name}" if managed_by else ""
action = "Created" if created else "Updated"
self.stdout.write(
f"{action} user {spec['user_name']} / {spec['password']} ({role}{link})"
)
return user
def _remove_legacy_staff(self, gm: User) -> None:
staff = User.objects.filter(user_name="staff").first()
if not staff:
return
moved_sites = Site.objects.filter(user=staff).update(user=gm)
moved_cities = CitySite.objects.filter(registered_by=staff).update(registered_by=gm)
ApiKey.objects.filter(user=staff).delete()
staff.delete()
self.stdout.write(
self.style.WARNING(
f"Removed legacy staff login "
f"(reassigned sites={moved_sites}, city_sites={moved_cities} to gm)"
)
)
@transaction.atomic
def handle(self, *args, **options):
by_name: dict[str, User] = {}
for spec in SEED_USERS:
user = self._ensure_user(spec, by_name)
by_name[user.user_name] = user
admin = by_name["admin"]
gm = by_name["gm"]
self._remove_legacy_staff(gm)
bootstrap = settings.BOOTSTRAP_API_KEY
if bootstrap:
api_key, _raw_key = ApiKey.generate(admin, "bootstrap")
api_key.prefix = bootstrap[:8]
api_key.key_hash = ApiKey.hash_key(bootstrap)
api_key.save(update_fields=["prefix", "key_hash", "updated_at"])
self.stdout.write(f"Bootstrap API key installed (prefix={api_key.prefix})")
elif not ApiKey.objects.filter(user=admin).exists():
_api_key, raw_key = ApiKey.generate(admin, "seed")
self.stdout.write(self.style.WARNING(f"Demo API key (save now): {raw_key}"))
call_command("seed_sukawarna_kandang4")
call_command("seed_sukawarna_kandang5")
call_command("seed_iot_10min")
self.stdout.write(
self.style.SUCCESS(
"Seed OK: all executive roles + Sukawarna cycles + IoT panels "
"(Sukawarna owned by gm)"
)
)
@@ -0,0 +1,239 @@
"""Seed 10-minute IoT panel readings (144/day) for every flock.
Kandang 2 Lantai 1 is seeded from real sensor snapshots pulled from
``dashboard.cpsp.id/api/iot/flocks/`` (cached in ``scripts/iot_panel_10min_extract.json``);
every other flock/floor is generated from that data with a deterministic per-house
jitter so each cycle day holds a full 00:00..23:50 run of 144 readings.
Run ``scripts/extract_iot_10min.py`` to refresh the cache against the live API.
"""
from __future__ import annotations
import json
import random
from datetime import date as date_cls
from datetime import datetime, time, timedelta
from pathlib import Path
from django.conf import settings
from django.core.management.base import BaseCommand, CommandError
from django.db import transaction
from django.utils import timezone
from apps.farms.models import Cycle, Flock
SLOTS_PER_DAY = 144 # 24h * 6
CHILL_FACTOR_POINTS = (
(0, 8.0),
(1, 8.0),
(7, 7.0),
(14, 6.0),
(21, 4.5),
(28, 3.5),
(35, 3.5),
(42, 3.0),
)
def chicken_chill_factor(age: float) -> float:
if age <= CHILL_FACTOR_POINTS[0][0]:
return CHILL_FACTOR_POINTS[0][1]
for idx in range(1, len(CHILL_FACTOR_POINTS)):
prev_age, prev_factor = CHILL_FACTOR_POINTS[idx - 1]
next_age, next_factor = CHILL_FACTOR_POINTS[idx]
if age > next_age:
continue
span = next_age - prev_age
if span <= 0:
return next_factor
progress = (age - prev_age) / span
return prev_factor + (next_factor - prev_factor) * progress
return CHILL_FACTOR_POINTS[-1][1]
def experience_temperature(
*, average_temperature: float, wind_speed: float, humidity_pct: float, age_days: float
) -> float:
chill_factor = chicken_chill_factor(age_days)
rh_adjustment = (humidity_pct - 70.0) / 5.0
chill_effect = wind_speed * chill_factor - rh_adjustment
return average_temperature - chill_effect
def lerp(a: float, b: float, frac: float) -> float:
return a + (b - a) * frac
def slot_timestamp(day: date_cls, slot: int) -> datetime:
naive = datetime.combine(day, time(0, 0)) + timedelta(minutes=10 * slot)
return timezone.make_aware(naive, timezone.get_current_timezone())
def gap_fill_day(day_rows: dict[int, dict]) -> dict[int, dict]:
"""Return 144 slots for a day, interpolating any missing 10-minute windows."""
present = sorted(day_rows.items())
filled: dict[int, dict] = {}
for slot in range(SLOTS_PER_DAY):
if slot in day_rows:
filled[slot] = dict(day_rows[slot])
continue
prev = next = None
for s, row in present:
if s < slot:
prev = (s, row)
elif s > slot and next is None:
next = (s, row)
break
if prev is None and next is not None:
filled[slot] = dict(next[1])
elif next is None and prev is not None:
filled[slot] = dict(prev[1])
elif prev is not None and next is not None:
ps, prow = prev
ns, nrow = next
frac = (slot - ps) / (ns - ps)
filled[slot] = {
"wind_speed": lerp(prow["wind_speed"], nrow["wind_speed"], frac),
"humidity": lerp(prow["humidity"], nrow["humidity"], frac),
"water_total": lerp(prow["water_total"], nrow["water_total"], frac),
"average_temperature": lerp(
prow["average_temperature"], nrow["average_temperature"], frac
),
}
return filled
class Command(BaseCommand):
help = "Seed 10-minute IoT panel readings (144/day) for all flocks"
def add_arguments(self, parser):
parser.add_argument("--iot-json", default="")
parser.add_argument("--cycle-start", default="2026-05-22")
parser.add_argument("--no-reset", action="store_true")
parser.add_argument("--jitter-seed", type=int, default=7)
def load_real_rows(self, path: Path) -> dict[str, dict[int, dict]]:
if not path.is_file():
raise CommandError(f"IoT 10-min extract not found: {path}")
data = json.loads(path.read_text(encoding="utf-8"))
by_date: dict[str, dict[int, dict]] = {}
for row in data["rows"]:
ts = datetime.fromisoformat(row["timestamp"])
slot = (ts.hour * 60 + ts.minute) // 10
by_date.setdefault(row["date"], {})[slot] = row
return by_date
@transaction.atomic
def handle(self, *args, **options):
iot_json = options["iot_json"] or str(
Path(settings.BASE_DIR).resolve().parent / "scripts" / "iot_panel_10min_extract.json"
)
cycle_start = date_cls.fromisoformat(options["cycle_start"])
real_by_date = self.load_real_rows(Path(iot_json))
cycles = Cycle.objects.filter(
start_date=cycle_start, kandang__flocks__isnull=False
).distinct()
if not cycles:
raise CommandError(f"No cycles starting {cycle_start} with flocks")
seeded = 0
for cycle in cycles:
kandang_name = cycle.kandang.kandang_name
for flock in cycle.kandang.flocks.order_by("flock_id"):
profile = self.profile_for(kandang_name, flock.flock_name)
rng = random.Random(options["jitter_seed"] + flock.pk)
rows = self.build_flock_rows(flock, cycle, real_by_date, profile, rng)
if not options["no_reset"]:
flock.iot_panels.all().delete()
for chunk in range(0, len(rows), 500):
flock.iot_panels.bulk_create(rows[chunk : chunk + 500])
seeded += len(rows)
self.stdout.write(
f" {kandang_name} / {flock.flock_name}: {len(rows)} readings"
)
self.stdout.write(self.style.SUCCESS(f"Seeded {seeded} IoT panel readings"))
def profile_for(self, kandang_name: str, flock_name: str) -> dict:
"""Per-house transform applied to the real Kandang 2 Lantai 1 readings."""
is_lantai1 = flock_name == "Lantai 1"
if kandang_name == "Kandang 2":
if is_lantai1:
return {"real": True}
return {
"wind_delta": -0.05,
"humidity_delta": 1.2,
"temperature_delta": -0.3,
"water_factor": 0.96,
}
# Kandang 1: deterministic jitter (same approach as seed_sukawarna_kandang4).
return {
"temperature_jitter": (-0.4, 0.4),
"humidity_jitter": (-3.0, 3.0),
"wind_jitter": (-0.1, 0.1),
"water_factor": 0.9,
"water_jitter_abs": (-120.0, 120.0),
"humidity_delta": 1.2 if not is_lantai1 else 0.0,
"temperature_delta": -0.3 if not is_lantai1 else 0.0,
}
def build_flock_rows(self, flock: Flock, cycle: Cycle, real: dict, profile: dict, rng: random.Random) -> list:
from apps.operations.models import IotPanel
day = cycle.start_date
rows: list[IotPanel] = []
while day <= cycle.end_date:
iso = day.isoformat()
age_days = (day - cycle.start_date).days + 1
if profile.get("real"):
source = gap_fill_day(real.get(iso, {}))
else:
source = gap_fill_day(real.get(iso, {}))
if not source:
day += timedelta(days=1)
continue
real_total = source.get(SLOTS_PER_DAY - 1, {}).get("water_total", 0.0) or 0.0
target_total = real_total * profile.get("water_factor", 1.0)
if profile.get("water_jitter_abs"):
target_total += rng.uniform(*profile["water_jitter_abs"])
target_total = max(0.0, target_total)
water_scale = target_total / real_total if real_total else 0.0
for slot in range(SLOTS_PER_DAY):
base = source[slot]
avg_temp = base.get("average_temperature", 0.0) + profile.get("temperature_delta", 0.0)
if profile.get("temperature_jitter"):
avg_temp += rng.uniform(*profile["temperature_jitter"])
humidity = base.get("humidity", 0.0) + profile.get("humidity_delta", 0.0)
if profile.get("humidity_jitter"):
humidity += rng.uniform(*profile["humidity_jitter"])
humidity = min(100.0, max(0.0, humidity))
wind = base.get("wind_speed", 0.0) + profile.get("wind_delta", 0.0)
if profile.get("wind_jitter"):
wind += rng.uniform(*profile["wind_jitter"])
wind = max(0.0, wind)
water = base.get("water_total", 0.0) * water_scale
exp_temp = experience_temperature(
average_temperature=avg_temp,
wind_speed=wind,
humidity_pct=humidity,
age_days=age_days,
)
rows.append(
IotPanel(
flock=flock,
date=day,
timestamp=slot_timestamp(day, slot),
wind_speed=round(wind, 2),
humidity=round(humidity, 1),
water_total=round(water, 1),
average_temperature=round(avg_temp, 2),
experience_temperature=round(exp_temp, 2),
)
)
day += timedelta(days=1)
return rows
@@ -0,0 +1,435 @@
"""Seed Sukawarna / Kandang 1 closed cycle.
Kandang 1 is a generated dataset modeled on the Kandang 2 seed
(``seed_sukawarna_kandang5``). The two houses run the same 49-day window at
the same site, but Kandang 1 is stocked with 45,000 DOC and performs slightly
better (lower mortality, marginally better FCR / EEF).
Every daily series is hand-tuned so the numbers stay internally consistent:
* ``chicken_counting`` – daily mortality sums to the cumulative total; camera
count tracks live population (DOC - mortality - harvest) with coverage noise.
* ``chicken_weight`` – smooth growth curve; weighing continues through
close-out (day 38-48), flock average declines as heavy birds are harvested.
* ``feed_sacks`` – daily use ramps up to ~98 sacks and falls off during
close-out; total use (1,941 sacks) matches close-out FCR ≈ 1.71.
* ``harvest`` – 20 partial harvests day 29-48, 40,700 birds,
57.47 t live weight, avg ~1,412 g/bird at ~33.1 days; tail harvests leave
fewer than 50 birds in the house at close-out.
* ``kpi`` / ``manual`` – FCR/EEF/lot-weight follow the same formulas as the
Kandang 2 preview:
FCR = feed_total_kg / (harvest_weight_total_g + lot_weight_g)
EEF = life_pct * (avg_harvest_weight/1000) / (FCR * avg_harvest_day) * 100
lot = chicken_life * average_weight
* ``iot_panel`` – derived from the real Kandang 2 sensor extract with
deterministic per-house jitter (same site, same season).
"""
from __future__ import annotations
import json
import math
import random
from datetime import date, timedelta
from pathlib import Path
from django.core.management.base import BaseCommand, CommandError
from django.db import transaction
from apps.accounts.models import User
from apps.farms.models import Cycle, Flock, Kandang, Site
from apps.jobs.sukawarna_preview import IOT_EXTRACT_JSON
from apps.operations.models import (
ChickenCounting,
ChickenWeight,
FeedSacks,
IotPanel,
KPI,
ManualInput,
)
START_DATE = date(2026, 5, 22)
END_DATE = date(2026, 7, 9)
TOTAL_DAYS = 49
DOC_IN_WEIGHT = 35
DOC_IN_COUNT = 45_000
FLOCK_NAMES = ("Lantai 1", "Lantai 2")
# --- Daily mortality per age 0..48 (sums to 4,269 ≈ 9.49 % deplesi) ---------
MORTALITY = [
42, 58, 74, 78, 68, 62, 60,
52, 54, 47, 50, 44, 46, 48,
42, 45, 47, 50, 52, 48, 46,
44, 47, 42, 50, 58, 65, 72,
95, 120, 150, 175, 200, 185, 215,
240, 255, 235, 220, 140, 115, 100,
85, 60, 65, 50, 45, 28, 0,
]
# --- Flock average weight (g) per age 0..48 ---------------------------------
# Weighing continues through close-out (day 38-48). As heavy birds are
# harvested first, the remaining flock average weight declines.
AVG_WEIGHT = [
35, 47, 62, 80, 100, 123, 148,
175, 204, 235, 268, 303, 340, 379,
420, 463, 508, 555, 604, 655, 708,
763, 820, 879, 940, 1003, 1068, 1135,
1204, 1275, 1348, 1423, 1500, 1570, 1530,
1470, 1420, 1360, 1310, 1270, 1230, 1220,
1200, 1180, 1160, 1140, 1120, 1100, 1080,
]
# --- Weigh-sample size per age 0..48 (declines with live flock during close-out) ---
SAMPLE_COUNT = [
229, 214, 250, 230, 260, 240, 220,
280, 210, 250, 230, 260, 240, 220,
270, 250, 230, 260, 240, 220, 280,
250, 230, 270, 240, 220, 260, 240,
280, 250, 230, 270, 250, 230, 260,
240, 220, 250, 180, 150, 120, 100,
80, 60, 50, 40, 30, 20, 15,
]
# --- Uniformity (%) per age 0..48 --------------------------------------------
UNIFORMITY = [
0, 45, 62, 70, 66, 74, 71,
78, 69, 73, 81, 68, 75, 72,
70, 77, 74, 69, 72, 76, 71,
73, 68, 74, 70, 72, 66, 69,
73, 67, 71, 69, 72, 68, 65,
70, 67, 63, 62, 61, 60, 59,
58, 57, 56, 55, 54, 53, 52,
]
# --- Feed used (sacks, 50 kg each) per age 0..48; sums to 1,941 --------------
FEED_TODAY = [
24, 11, 14, 22, 24, 27, 31,
34, 38, 40, 42, 36, 38, 41,
44, 48, 59, 66, 74, 85, 89,
90, 57, 98, 92, 84, 72, 79,
80, 71, 71, 65, 57, 24, 29,
14, 13, 12, 8, 3, 3, 5, 8, 0, 8, 2, 4, 4, 1,
]
# --- Feed delivered (sacks) on delivery days (age -> sacks) ------------------
# Cumulative intake (1,910 sacks) ends just below usage, a small stock draw.
FEED_IN = {
1: 120, 4: 130, 7: 140, 10: 150, 13: 160, 16: 170, 19: 150,
22: 140, 25: 130, 28: 120, 31: 110, 34: 100, 37: 90, 40: 80, 43: 120,
}
# --- Karung keluar (out) per age 0..48: only during the final week, small ---
# "Out" is NOT the same as usage (tuang). It only happens at the very end of the
# cycle and totals ~20 sacks per house, with a handful of sacks per day.
OUT_TODAY = [0] * 42 + [3, 4, 5, 3, 2, 2, 1]
# --- Harvest plan: age -> (birds, avg live weight g/bird) --------------------
# 20 partial harvests day 29-48; the tail harvests clear out the house so the
# leftover flock (chicken_life) at close-out stays under 50 birds.
HARVEST = {
29: (4_500, 1_310),
30: (5_000, 1_360),
31: (5_000, 1_430),
32: (5_000, 1_490),
33: (5_000, 1_540),
34: (4_500, 1_500),
35: (3_500, 1_440),
36: (2_500, 1_390),
37: (2_000, 1_330),
38: (1_500, 1_280),
39: (1_000, 1_240),
40: (500, 1_200),
41: (120, 1_190),
42: (100, 1_170),
43: (90, 1_150),
44: (80, 1_130),
45: (90, 1_110),
46: (80, 1_090),
47: (80, 1_070),
48: (60, 1_050),
}
def _round(value: float, ndigits: int = 2) -> float:
if math.isclose(value, 0.0, abs_tol=1e-12):
return 0.0
return round(value, ndigits)
def _site_owner() -> User:
gm = User.objects.filter(user_name="gm", status=User.STATUS_GM).first()
if gm is None:
raise CommandError(
"User 'gm' is required. Run `python manage.py seed_demo` first."
)
return gm
class Command(BaseCommand):
help = "Seed Sukawarna / Kandang 1 closed cycle (generated, modeled on Kandang 2)"
def add_arguments(self, parser):
parser.add_argument("--iot-json", default=str(IOT_EXTRACT_JSON))
parser.add_argument("--no-reset", action="store_true")
parser.add_argument("--jitter-seed", type=int, default=7)
@transaction.atomic
def handle(self, *args, **options):
iot_path = Path(options["iot_json"])
if not iot_path.is_file():
raise CommandError(f"IoT extract not found: {iot_path}")
iot = json.loads(iot_path.read_text(encoding="utf-8"))
rng = random.Random(options["jitter_seed"])
user = _site_owner()
site, _ = Site.objects.get_or_create(site_name="Sukawarna", defaults={"user": user})
if site.user_id != user.pk:
site.user = user
site.save(update_fields=["user", "updated_at"])
self.stdout.write(f"Sukawarna site owner: {user.user_name} ({user.status})")
kandang, _ = Kandang.objects.get_or_create(
kandang_name="Kandang 1",
site=site,
)
existing = Cycle.objects.filter(kandang=kandang, start_date=START_DATE).first()
if existing and not options["no_reset"]:
KPI.objects.filter(cycle=existing).delete()
ChickenCounting.objects.filter(cycle=existing).delete()
ChickenWeight.objects.filter(cycle=existing).delete()
FeedSacks.objects.filter(cycle=existing).delete()
ManualInput.objects.filter(cycle=existing).delete()
IotPanel.objects.filter(
flock__kandang=kandang,
date__gte=existing.start_date,
date__lte=existing.end_date,
).delete()
existing.delete()
self.stdout.write("Reset previous Kandang 1 cycle")
cycle, cycle_created = Cycle.objects.get_or_create(
kandang=kandang,
start_date=START_DATE,
defaults={
"end_date": END_DATE,
"total_days": TOTAL_DAYS,
"doc_in_weight": DOC_IN_WEIGHT,
"doc_in_count": DOC_IN_COUNT,
"status": Cycle.STATUS_CLOSED,
},
)
if not cycle_created:
cycle.end_date = END_DATE
cycle.total_days = TOTAL_DAYS
cycle.doc_in_weight = DOC_IN_WEIGHT
cycle.doc_in_count = DOC_IN_COUNT
cycle.status = Cycle.STATUS_CLOSED
cycle.save()
flocks_by_name: dict[str, Flock] = {}
for name in FLOCK_NAMES:
flock, _ = Flock.objects.get_or_create(flock_name=name, kandang=kandang)
flocks_by_name[name] = flock
dates = [START_DATE + timedelta(days=i) for i in range(TOTAL_DAYS)]
# --- cumulative series -------------------------------------------------
mort_total = 0
harvest_total = 0
harvest_weight_total = 0.0
feed_total = 0
feed_in_total = 0
out_total = 0
cc_by_date: dict[date, ChickenCounting] = {}
cw_by_date: dict[date, ChickenWeight] = {}
fs_by_date: dict[date, FeedSacks] = {}
manual_by_date: dict[date, ManualInput] = {}
harvest_day_accum = 0.0 # Σ (harvest × age)
harvest_wt_accum = 0.0 # Σ (harvest × batch avg weight)
for i, day in enumerate(dates):
age = i
live = DOC_IN_COUNT - mort_total - harvest_total
# --- chicken_counting -----------------------------------------
quality = "ok" if age < 34 else "atas_only"
coverage = 0.82 + 0.16 * math.sin(i / 1.9) - 0.011 * min(i, 36)
camera_count = max(1, int(round(live * max(coverage, 0.3))))
cc, _ = ChickenCounting.objects.update_or_create(
cycle=cycle,
date=day,
defaults={
"total_count": camera_count,
"mortality_count": MORTALITY[i],
},
)
cc_by_date[day] = cc
# --- chicken_weight --------------------------------------------
weight = AVG_WEIGHT[i]
prev_weight = AVG_WEIGHT[i - 1] if i > 0 else DOC_IN_WEIGHT
adg = _round(weight - prev_weight, 2) if weight else 0.0
cw, _ = ChickenWeight.objects.update_or_create(
cycle=cycle,
date=day,
defaults={
"age": age,
"doc_weight": DOC_IN_WEIGHT,
"average_weight": weight,
"chicken_count": SAMPLE_COUNT[i],
"uniformity": UNIFORMITY[i],
"average_daily_gain": adg,
},
)
cw_by_date[day] = cw
# --- feed_sacks ------------------------------------------------
feed_today = FEED_TODAY[i]
in_today = FEED_IN.get(age, 0)
out_today = OUT_TODAY[i]
feed_total += feed_today
feed_in_total += in_today
out_total += out_today
fs, _ = FeedSacks.objects.update_or_create(
cycle=cycle,
date=day,
defaults={
"in_today": in_today,
"out_today": out_today,
"in_total": feed_in_total,
"out_total": out_total,
"feed_use_today": feed_today,
"feed_use_total": feed_total,
},
)
fs_by_date[day] = fs
# --- harvest (manual) ------------------------------------------
harvest_today, harvest_wt_today = HARVEST.get(age, (0, 0))
if harvest_today:
harvest_total += harvest_today
harvest_weight_total += harvest_today * harvest_wt_today
harvest_day_accum += harvest_today * age
harvest_wt_accum += harvest_today * harvest_wt_today
mort_today = MORTALITY[i]
mort_total_after = mort_total + mort_today
life = DOC_IN_COUNT - mort_total_after - harvest_total
# Liveability for EEF: birds still accounted for (DOC minus mortality),
# not remaining in-house stock (which drops during harvest).
life_pct = (DOC_IN_COUNT - mort_total_after) / DOC_IN_COUNT * 100.0 if DOC_IN_COUNT else 0.0
# Running averages: flock weight before the first harvest, then the
# harvest-weighted running mean carried forward on idle days.
if harvest_today:
avg_hday = harvest_day_accum / harvest_total
avg_hwt = harvest_wt_accum / harvest_total
elif harvest_total == 0:
avg_hday = float(age)
avg_hwt = float(weight) if weight else 0.0
else:
avg_hday = harvest_day_accum / harvest_total
avg_hwt = harvest_wt_accum / harvest_total
lot_weight_g = life * weight if weight else 0.0
denom = harvest_weight_total + lot_weight_g
fcr = (feed_total * 50_000) / denom if denom > 0 else 0.0
if avg_hday > 0 and fcr > 0:
eef = life_pct * (avg_hwt / 1000.0) / (fcr * avg_hday) * 100.0
else:
eef = 0.0
manual, _ = ManualInput.objects.update_or_create(
cycle=cycle,
date=day,
defaults={
"age_manual": age,
"mortality_manual": mort_today,
"mortality_manual_total": mort_total_after,
"feed_in_manual": in_today,
"feed_in_manual_total": feed_in_total,
"feed_use_manual": feed_today,
"feed_use_manual_total": feed_total,
"feed_out_manual": out_today,
"feed_out_manual_total": out_total,
"harvest_manual": harvest_today,
"harvest_manual_total": harvest_total,
"harvest_weight_manual": float(harvest_today * harvest_wt_today),
"harvest_weight_manual_total": harvest_weight_total,
"manual_weight": weight,
"average_harvest_day_manual": _round(avg_hday, 4),
"average_harvest_weight_manual": _round(avg_hwt, 4),
"fcr_manual": _round(fcr, 4),
"eef_manual": _round(eef, 4),
},
)
manual_by_date[day] = manual
# --- KPI ---------------------------------------------------------
KPI.objects.update_or_create(
cycle=cycle,
date=day,
defaults={
"age": age,
"mortality": mort_today,
"mortality_total": mort_total_after,
"feed": feed_today,
"feed_total": feed_total,
"harvest": harvest_today,
"harvest_total": harvest_total,
"harvest_weight": float(harvest_today * harvest_wt_today),
"harvest_weight_total": harvest_weight_total,
"chicken_life": life,
"chicken_life_percentage": _round(life_pct, 3),
"iot_weight": _round(lot_weight_g, 2),
"average_harvest_day": _round(avg_hday, 4),
"average_harvest_weight": _round(avg_hwt, 4),
"fcr": _round(fcr, 4),
"eef": _round(eef, 4),
"cc": cc,
"cw": cw,
"fs": fs,
"manual": manual,
},
)
mort_total = mort_total_after
# --- IoT panel: Kandang 2 sensors with per-house jitter -------------
panel_count = 0
for flock_block in iot["flocks"]:
flock = flocks_by_name[flock_block["name"]]
for row in flock_block["rows"]:
day = date.fromisoformat(row["date"])
avg_temp = float(row["average_temperature"]) + rng.uniform(-0.4, 0.4)
humidity = min(max(float(row["humidity"]) + rng.uniform(-3.0, 3.0), 0.0), 100.0)
wind = max(float(row["wind_speed"]) + rng.uniform(-0.1, 0.1), 0.0)
water = max(float(row["water_total"]) * 0.9 + rng.uniform(-120.0, 120.0), 0.0)
chill = float(row["chill_factor"])
exp_temp = avg_temp - (70.0 - humidity) / 5.0 - wind * chill
IotPanel.objects.update_or_create(
flock=flock,
date=day,
defaults={
"wind_speed": _round(wind, 2),
"humidity": _round(humidity, 1),
"water_total": _round(water, 1),
"average_temperature": _round(avg_temp, 2),
"experience_temperature": _round(exp_temp, 4),
},
)
panel_count += 1
last_kpi = KPI.objects.filter(cycle=cycle).order_by("-date").first()
self.stdout.write(
self.style.SUCCESS(
f"Seeded cycle={cycle.pk} site={site.pk} kandang={kandang.pk} "
f"flocks={Flock.objects.filter(kandang=kandang).count()} "
f"cc={len(cc_by_date)} cw={len(cw_by_date)} fs={len(fs_by_date)} "
f"manual={len(manual_by_date)} kpi={KPI.objects.filter(cycle=cycle).count()} "
f"iot_panel={panel_count} closeout_fcr={last_kpi.fcr if last_kpi else None} "
f"closeout_eef={last_kpi.eef if last_kpi else None}"
)
)
@@ -0,0 +1,293 @@
from __future__ import annotations
import json
from datetime import date
from pathlib import Path
from django.core.management.base import BaseCommand, CommandError
from django.db import transaction
from apps.accounts.models import User
from apps.farms.models import Cycle, Flock, Kandang, Site
from apps.jobs.sukawarna_preview import (
FEED_REFERENCE_XLSX,
IOT_EXTRACT_JSON,
PREVIEW_XLSX,
as_number,
load_feed_reference,
load_preview,
)
from apps.operations.models import (
ChickenCounting,
ChickenWeight,
FeedSacks,
IotPanel,
KPI,
ManualInput,
)
# --- Karung keluar (out) per cycle day 0..48: only during the final week ----
# "Out" is NOT the same as usage (tuang). It only happens at the very end of the
# cycle and totals ~20 sacks per house, with a handful of sacks per day.
OUT_TODAY = [0] * 42 + [4, 5, 4, 3, 2, 1, 1]
def _site_owner() -> User:
gm = User.objects.filter(user_name="gm", status=User.STATUS_GM).first()
if gm is None:
raise CommandError(
"User 'gm' is required. Run `python manage.py seed_demo` first."
)
return gm
class Command(BaseCommand):
help = "Seed Sukawarna / Kandang 2 closed cycle from preview xlsx + IoT extract"
def add_arguments(self, parser):
parser.add_argument("--xlsx", default=str(PREVIEW_XLSX))
parser.add_argument("--iot-json", default=str(IOT_EXTRACT_JSON))
parser.add_argument("--feed-reference", default=str(FEED_REFERENCE_XLSX))
parser.add_argument("--no-reset", action="store_true")
@transaction.atomic
def handle(self, *args, **options):
xlsx = Path(options["xlsx"])
iot_path = Path(options["iot_json"])
feed_reference_path = Path(options["feed_reference"])
if not xlsx.is_file():
raise CommandError(f"Preview not found: {xlsx}")
if not iot_path.is_file():
raise CommandError(f"IoT extract not found: {iot_path}")
sheets = load_preview(xlsx)
iot = json.loads(iot_path.read_text(encoding="utf-8"))
feed_reference = {}
if feed_reference_path.is_file():
feed_reference = load_feed_reference(feed_reference_path)
else:
self.stderr.write(f"Feed reference not found, continuing without it: {feed_reference_path}")
user = _site_owner()
site, _ = Site.objects.get_or_create(site_name="Sukawarna", defaults={"user": user})
if site.user_id != user.pk:
site.user = user
site.save(update_fields=["user", "updated_at"])
self.stdout.write(f"Sukawarna site owner: {user.user_name} ({user.status})")
kandang, _ = Kandang.objects.get_or_create(
kandang_name="Kandang 2",
site=site,
)
start_date = date(2026, 5, 22)
end_date = date(2026, 7, 9)
existing = Cycle.objects.filter(kandang=kandang, start_date=start_date).first()
if existing and not options["no_reset"]:
KPI.objects.filter(cycle=existing).delete()
ChickenCounting.objects.filter(cycle=existing).delete()
ChickenWeight.objects.filter(cycle=existing).delete()
FeedSacks.objects.filter(cycle=existing).delete()
ManualInput.objects.filter(cycle=existing).delete()
IotPanel.objects.filter(
flock__kandang=kandang,
date__gte=existing.start_date,
date__lte=existing.end_date,
).delete()
existing.delete()
self.stdout.write("Reset previous Kandang 2 cycle")
cycle, cycle_created = Cycle.objects.get_or_create(
kandang=kandang,
start_date=start_date,
defaults={
"end_date": end_date,
"total_days": 49,
"doc_in_weight": 35,
"doc_in_count": 50000,
"status": Cycle.STATUS_CLOSED,
},
)
if not cycle_created:
cycle.end_date = end_date
cycle.total_days = 49
cycle.doc_in_weight = 35
cycle.doc_in_count = 50000
cycle.status = Cycle.STATUS_CLOSED
cycle.save()
flocks_by_name: dict[str, Flock] = {}
for name in ("Lantai 1", "Lantai 2"):
flock, _ = Flock.objects.get_or_create(flock_name=name, kandang=kandang)
flocks_by_name[name] = flock
cc_by_date = {}
for row in sheets["chicken_counting"]:
obj, _ = ChickenCounting.objects.update_or_create(
cycle=cycle,
date=row["date"],
defaults={
"total_count": int(as_number(row.get("total_count"))),
"mortality_count": int(as_number(row.get("mortality_count"))),
},
)
cc_by_date[row["date"]] = obj
cw_by_date = {}
for row in sheets["chicken_weight"]:
obj, _ = ChickenWeight.objects.update_or_create(
cycle=cycle,
date=row["date"],
defaults={
"age": int(as_number(row.get("age"))),
"doc_weight": int(as_number(row.get("doc_weight"))),
"average_weight": float(as_number(row.get("average_weight"))),
"chicken_count": int(as_number(row.get("chicken_count"))),
"uniformity": float(as_number(row.get("uniformity"))),
"average_daily_gain": float(as_number(row.get("average_daily_gain"))),
},
)
cw_by_date[row["date"]] = obj
fs_by_date = {}
fs_in_total = 0
fs_use_total = 0
fs_out_total = 0
for row in sheets["feed_sacks"]:
day = row["date"]
ref = feed_reference.get(day.isoformat())
in_today = int(ref["iot_in"]) if ref else int(as_number(row.get("in_today")))
feed_use_today = (
int(ref["iot_use"]) if ref else int(as_number(row.get("out_today")))
)
out_today = OUT_TODAY[(day - start_date).days]
fs_in_total += in_today
fs_use_total += feed_use_today
fs_out_total += out_today
obj, _ = FeedSacks.objects.update_or_create(
cycle=cycle,
date=day,
defaults={
"in_today": in_today,
"out_today": out_today,
"in_total": fs_in_total,
"out_total": fs_out_total,
"feed_use_today": feed_use_today,
"feed_use_total": fs_use_total,
},
)
fs_by_date[day] = obj
manual_by_date = {}
manual_in_total = 0
manual_use_total = 0
manual_out_total = 0
for row in sheets["manual_input"]:
day = row["date"]
ref = feed_reference.get(day.isoformat())
feed_in_manual = int(ref["manual_in"]) if ref else (
int(fs_row.in_today) if (fs_row := fs_by_date.get(day)) else 0
)
feed_use_manual = int(ref["manual_use"]) if ref else int(as_number(row.get("feed_manual")))
feed_out_manual = OUT_TODAY[(day - start_date).days]
manual_in_total += feed_in_manual
manual_use_total += feed_use_manual
manual_out_total += feed_out_manual
obj, _ = ManualInput.objects.update_or_create(
cycle=cycle,
date=day,
defaults={
"age_manual": int(as_number(row.get("age_manual"))),
"mortality_manual": int(as_number(row.get("mortality_manual"))),
"mortality_manual_total": int(as_number(row.get("mortality_manual_total"))),
"feed_in_manual": feed_in_manual,
"feed_in_manual_total": manual_in_total,
"feed_use_manual": feed_use_manual,
"feed_use_manual_total": manual_use_total,
"feed_out_manual": feed_out_manual,
"feed_out_manual_total": manual_out_total,
"harvest_manual": int(as_number(row.get("harvest_manual"))),
"harvest_manual_total": int(as_number(row.get("harvest_manual_total"))),
"harvest_weight_manual": float(as_number(row.get("harvest_weight_manual"))),
"harvest_weight_manual_total": float(
as_number(row.get("harvest_weight_manual_total"))
),
"manual_weight": float(
as_number(row.get("manual_weight_float") or row.get("manual_weight"))
),
"average_harvest_day_manual": float(
as_number(row.get("average_harvest_day_manual"))
),
"average_harvest_weight_manual": float(
as_number(row.get("average_harvest_weight_manual"))
),
"fcr_manual": float(as_number(row.get("fcr_manual"))),
"eef_manual": float(as_number(row.get("eef_manual"))),
},
)
manual_by_date[day] = obj
missing = []
for row in sheets["kpi_preview"]:
day = row["date"]
try:
KPI.objects.update_or_create(
cycle=cycle,
date=day,
defaults={
"age": int(as_number(row.get("age"))),
"mortality": int(as_number(row.get("deplesi"))),
"mortality_total": int(as_number(row.get("deplesi_total"))),
"feed": int(as_number(row.get("feed_use_manual"))),
"feed_total": int(as_number(row.get("feed_use_total_manual"))),
"harvest": int(as_number(row.get("panen"))),
"harvest_total": int(as_number(row.get("panen_total"))),
"harvest_weight": float(as_number(row.get("harvest_weight_g"))),
"harvest_weight_total": float(as_number(row.get("harvest_weight_total_g"))),
"chicken_life": int(as_number(row.get("chicken_life"))),
"chicken_life_percentage": float(as_number(row.get("chicken_life_pct"))),
"iot_weight": float(as_number(row.get("lot_weight_g"))),
"average_harvest_day": float(as_number(row.get("avg_harvest_day"))),
"average_harvest_weight": float(as_number(row.get("avg_harvest_weight_g"))),
"fcr": float(as_number(row.get("fcr"))),
"eef": float(as_number(row.get("eef"))),
"cc": cc_by_date[day],
"cw": cw_by_date[day],
"fs": fs_by_date[day],
"manual": manual_by_date[day],
},
)
except KeyError:
missing.append(str(day))
if missing:
raise CommandError(f"KPI dates missing source rows: {missing}")
panel_count = 0
for flock_block in iot["flocks"]:
flock = flocks_by_name[flock_block["name"]]
for row in flock_block["rows"]:
day = date.fromisoformat(row["date"])
IotPanel.objects.update_or_create(
flock=flock,
date=day,
defaults={
"wind_speed": float(row["wind_speed"]),
"humidity": float(row["humidity"]),
"water_total": float(row["water_total"]),
"average_temperature": float(row["average_temperature"]),
"experience_temperature": float(row["experience_temperature"]),
},
)
panel_count += 1
last_kpi = KPI.objects.filter(cycle=cycle).order_by("-date").first()
self.stdout.write(
self.style.SUCCESS(
f"Seeded cycle={cycle.pk} site={site.pk} kandang={kandang.pk} "
f"flocks={Flock.objects.filter(kandang=kandang).count()} "
f"cc={len(cc_by_date)} cw={len(cw_by_date)} fs={len(fs_by_date)} "
f"manual={len(manual_by_date)} kpi={KPI.objects.filter(cycle=cycle).count()} "
f"iot_panel={panel_count} closeout_fcr={last_kpi.fcr if last_kpi else None}"
)
)
@@ -0,0 +1,45 @@
from django.conf import settings
from django.core.management.base import BaseCommand
from apps.farms.models import Cycle
from apps.operations.services.chicken_counting_edge import (
ChickenCountingEdgeError,
sync_chicken_counting_for_cycle,
)
class Command(BaseCommand):
help = "Sync chicken counting + mortality IoT from the edge vision API"
def add_arguments(self, parser):
parser.add_argument("--cycle-id", type=int, default=None)
def handle(self, *args, **options):
counting_on = settings.CHICKEN_COUNTING_EDGE_COUNTING_SYNC_ENABLED
mortality_on = settings.CHICKEN_COUNTING_EDGE_MORTALITY_SYNC_ENABLED
if not counting_on and not mortality_on:
self.stdout.write(
"CHICKEN_COUNTING_EDGE_*_SYNC_ENABLED=false; skipping"
)
return
cycle_id = options.get("cycle_id")
if cycle_id:
cycles = Cycle.objects.filter(pk=cycle_id).select_related("kandang")
else:
cycles = Cycle.objects.filter(status=Cycle.STATUS_ACTIVE).select_related(
"kandang"
)
for cycle in cycles:
try:
summary = sync_chicken_counting_for_cycle(cycle)
except ChickenCountingEdgeError as exc:
self.stderr.write(f"cycle={cycle.pk}: {exc}")
continue
self.stdout.write(
self.style.SUCCESS(
f"cycle={cycle.pk} upserted={summary['upserted']} "
f"dates={','.join(d.isoformat() for d in summary['dates']) or '-'}"
)
)
@@ -0,0 +1,23 @@
from django.conf import settings
from django.core.management.base import BaseCommand
from apps.operations.services.iot_api import IotApiError, sync_iot_panels
class Command(BaseCommand):
help = "Sync IoT panel readings from the external IoT flocks API (10-minute snapshots)"
def handle(self, *args, **options):
if not settings.IOT_SYNC_ENABLED:
self.stdout.write("IOT_SYNC_ENABLED=false; skipping")
return
try:
summary = sync_iot_panels()
except IotApiError as exc:
self.stderr.write(str(exc))
return
self.stdout.write(
self.style.SUCCESS(
f"IoT sync OK: upserted={summary.upserted} skipped={summary.skipped} pages={summary.pages}"
)
)
@@ -0,0 +1,29 @@
from django.conf import settings
from django.core.management.base import BaseCommand
from apps.farms.models import Cycle
from apps.operations.services.karung_web import KarungWebError, sync_karung_for_cycle
class Command(BaseCommand):
help = "Sync FeedSacks rows from karung-web-admin (today sections + combined history)"
def handle(self, *args, **options):
if not settings.KARUNG_WEB_ADMIN_SYNC_ENABLED:
self.stdout.write("KARUNG_WEB_ADMIN_SYNC_ENABLED=false; skipping")
return
synced = 0
for cycle in Cycle.objects.filter(status=Cycle.STATUS_ACTIVE):
try:
feed_sack, summary = sync_karung_for_cycle(cycle, section="today")
synced += 1
self.stdout.write(
f"cycle={cycle.pk} in_today={feed_sack.in_today} "
f"out_today={feed_sack.out_today} date={summary.get('snapshot_date')} "
f"history={','.join(summary.get('history_dates') or [])}"
)
except KarungWebError as exc:
self.stderr.write(f"cycle={cycle.pk} error={exc}")
except Exception as exc: # noqa: BLE001
self.stderr.write(f"cycle={cycle.pk} unexpected={exc}")
self.stdout.write(self.style.SUCCESS(f"Synced {synced} feed sack row(s)"))
+104
View File
@@ -0,0 +1,104 @@
"""Load Sukawarna / Kandang 2 preview sheets into ORM-ready row dicts."""
from __future__ import annotations
from datetime import date, datetime
from pathlib import Path
from typing import Any
from openpyxl import load_workbook
REPO_ROOT = Path(__file__).resolve().parents[3]
PREVIEW_XLSX = REPO_ROOT / "backup" / "kandang5_sukawarna_preview.xlsx"
IOT_EXTRACT_JSON = REPO_ROOT / "scripts" / "iot_panel_cycle_extract.json"
# Final-cycle report (reference for manual feed use on Kandang 2).
FEED_REFERENCE_XLSX = (
REPO_ROOT
/ "backup"
/ "Laporan_Akhir-Siklus_Sukawarna_Kandang-Atas_Akhir-Siklus_2026-05-22_sd_2026-07-09.xlsx"
)
def as_date(value: Any) -> date | None:
if value is None:
return None
if isinstance(value, datetime):
return value.date()
if isinstance(value, date):
return value
text = str(value).strip()
if len(text) >= 10 and text[0].isdigit() and text[4] == "-":
return date.fromisoformat(text[:10])
return None
def as_number(value: Any, default: float | int = 0):
if value is None or value == "":
return default
if isinstance(value, (int, float)) and not isinstance(value, bool):
return value
try:
if isinstance(value, str) and "." not in value:
return int(value)
return float(value)
except (TypeError, ValueError):
return default
def dated_rows(ws) -> list[dict[str, Any]]:
headers = [cell.value for cell in next(ws.iter_rows(min_row=1, max_row=1))]
rows: list[dict[str, Any]] = []
for raw in ws.iter_rows(min_row=2, values_only=True):
mapped = {str(headers[i]): raw[i] for i in range(len(headers))}
parsed = as_date(mapped.get("date"))
if parsed is None:
continue
mapped["date"] = parsed
rows.append(mapped)
return rows
def load_preview(path: Path | None = None) -> dict[str, list[dict[str, Any]]]:
workbook = load_workbook(path or PREVIEW_XLSX, data_only=True)
return {
"chicken_counting": dated_rows(workbook["chicken_counting"]),
"chicken_weight": dated_rows(workbook["chicken_weight"]),
"feed_sacks": dated_rows(workbook["feed_sacks"]),
"manual_input": dated_rows(workbook["manual_input"]),
"kpi_preview": dated_rows(workbook["kpi_preview"]),
}
def load_feed_reference(path: Path | None = None) -> dict[str, dict[str, int]]:
"""Load the final-cycle report's Feed In / Feed Use sheets.
Returns ``{date: {manual_in, iot_in, manual_use, iot_use}}``. These values are
the authoritative reference for the karung "Manual" / "IOT" columns.
"""
workbook = load_workbook(path or FEED_REFERENCE_XLSX, data_only=True)
feed_in: dict[str, dict[str, int]] = {}
for raw in workbook["Feed In"].iter_rows(min_row=3, values_only=True):
parsed = as_date(raw[0])
if parsed is None:
continue
feed_in[parsed.isoformat()] = {
"manual_in": int(as_number(raw[1])),
"iot_in": int(as_number(raw[2])),
}
feed_use: dict[str, dict[str, int]] = {}
for raw in workbook["Feed Use"].iter_rows(min_row=3, values_only=True):
parsed = as_date(raw[0])
if parsed is None:
continue
feed_use[parsed.isoformat()] = {
"manual_use": int(as_number(raw[2])),
"iot_use": int(as_number(raw[3])),
}
merged: dict[str, dict[str, int]] = {}
for key in feed_in:
if key in feed_use:
merged[key] = {**feed_in[key], **feed_use[key]}
return merged
+55
View File
@@ -0,0 +1,55 @@
from django.core.management.base import CommandError
from django.test import TestCase, override_settings
from apps.accounts.models import User
from apps.farms.models import Cycle, Site
from django.core.management import call_command
@override_settings(
BOOTSTRAP_ADMIN_USER="admin",
BOOTSTRAP_ADMIN_PASSWORD="Pr04dm1n",
BOOTSTRAP_STAFF_USER="",
BOOTSTRAP_STAFF_PASSWORD="",
)
class BootstrapAdminTests(TestCase):
def test_creates_break_glass_superuser_without_demo_data(self):
call_command("bootstrap_admin")
user = User.objects.get(user_name="admin")
self.assertEqual(user.status, User.STATUS_SUPERUSER)
self.assertTrue(user.is_superuser)
self.assertTrue(user.check_password("Pr04dm1n"))
self.assertFalse(Site.objects.exists())
self.assertFalse(Cycle.objects.exists())
def test_requires_password(self):
with self.settings(BOOTSTRAP_ADMIN_PASSWORD=""):
with self.assertRaises(CommandError):
call_command("bootstrap_admin")
@override_settings(
BOOTSTRAP_STAFF_USER="staff",
BOOTSTRAP_STAFF_PASSWORD="St4ff!",
)
def test_creates_gm_when_configured(self):
call_command("bootstrap_admin")
staff = User.objects.get(user_name="staff")
self.assertEqual(staff.status, User.STATUS_GM)
self.assertFalse(staff.is_superuser)
self.assertTrue(staff.check_password("St4ff!"))
@override_settings(
BOOTSTRAP_STAFF_USER="staff",
BOOTSTRAP_STAFF_PASSWORD="",
)
def test_requires_staff_password_when_user_set(self):
with self.assertRaises(CommandError):
call_command("bootstrap_admin")
@override_settings(
BOOTSTRAP_STAFF_USER="admin",
BOOTSTRAP_STAFF_PASSWORD="St4ff!",
)
def test_rejects_staff_same_as_admin(self):
with self.assertRaises(CommandError):
call_command("bootstrap_admin")
View File
Whitespace-only changes.
+8
View File
@@ -0,0 +1,8 @@
from django.apps import AppConfig
class OperationsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.operations"
label = "operations"
verbose_name = "Operations"
@@ -0,0 +1,199 @@
# Generated by Django 5.2.17 on 2026-08-18 08:01
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('farms', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='AIInsight',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('date', models.DateField()),
('insight_text', models.TextField()),
('alert', models.CharField(max_length=100)),
('section', models.CharField(max_length=100)),
('session', models.CharField(max_length=100)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('cycle', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='ai_insights', to='farms.cycle')),
],
options={
'db_table': 'ai_insight',
'ordering': ['-date'],
},
),
migrations.CreateModel(
name='ChickenCounting',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('date', models.DateField()),
('total_count', models.IntegerField(default=0)),
('mortality_count', models.IntegerField(default=0)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('cycle', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='chicken_countings', to='farms.cycle')),
],
options={
'db_table': 'chicken_counting',
'ordering': ['-date'],
},
),
migrations.CreateModel(
name='ChickenWeight',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('date', models.DateField()),
('age', models.IntegerField()),
('doc_weight', models.IntegerField()),
('average_weight', models.FloatField()),
('chicken_count', models.IntegerField()),
('uniformity', models.FloatField()),
('average_daily_gain', models.FloatField()),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('cycle', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='chicken_weights', to='farms.cycle')),
],
options={
'db_table': 'chicken_weight',
'ordering': ['-date'],
},
),
migrations.CreateModel(
name='FeedSacks',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('date', models.DateField()),
('in_today', models.IntegerField(default=0)),
('out_today', models.IntegerField(default=0)),
('in_total', models.IntegerField(default=0)),
('out_total', models.IntegerField(default=0)),
('feed_use_today', models.IntegerField(default=0)),
('feed_use_total', models.IntegerField(default=0)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('cycle', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='feed_sacks', to='farms.cycle')),
],
options={
'verbose_name_plural': 'Feed sacks',
'db_table': 'feed_sacks',
'ordering': ['-date'],
},
),
migrations.CreateModel(
name='IotPanel',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('date', models.DateField()),
('wind_speed', models.FloatField(default=0.0)),
('humidity', models.FloatField(default=0.0)),
('water_total', models.FloatField(default=0.0)),
('average_temperature', models.FloatField(default=0.0)),
('experience_temperature', models.FloatField(default=0.0)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('flock', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='iot_panels', to='farms.flock')),
],
options={
'verbose_name': 'IoT Panel',
'verbose_name_plural': 'IoT Panels',
'db_table': 'iot_panel',
'ordering': ['-date'],
},
),
migrations.CreateModel(
name='ManualInput',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('date', models.DateField()),
('age_manual', models.IntegerField(default=0)),
('mortality_manual', models.IntegerField(default=0)),
('mortality_manual_total', models.IntegerField(default=0)),
('feed_manual', models.IntegerField(default=0)),
('feed_manual_total', models.IntegerField(default=0)),
('harvest_manual', models.IntegerField(default=0)),
('harvest_manual_total', models.IntegerField(default=0)),
('harvest_weight_manual', models.IntegerField(default=0)),
('harvest_weight_manual_total', models.IntegerField(default=0)),
('manual_weight_float', models.FloatField(default=0.0)),
('average_harvest_day_manual', models.FloatField(default=0.0)),
('average_harvest_weight_manual', models.FloatField(default=0.0)),
('fcr_manual', models.FloatField(default=0.0)),
('eef_manual', models.FloatField(default=0.0)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('cycle', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='manual_inputs', to='farms.cycle')),
],
options={
'db_table': 'manual_input',
'ordering': ['-date'],
},
),
migrations.CreateModel(
name='KPI',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('date', models.DateField()),
('age', models.IntegerField(default=0)),
('mortality', models.IntegerField(default=0)),
('mortality_total', models.IntegerField(default=0)),
('feed', models.IntegerField(default=0)),
('feed_total', models.IntegerField(default=0)),
('harvest', models.IntegerField(default=0)),
('harvest_total', models.IntegerField(default=0)),
('harvest_weight', models.IntegerField(default=0)),
('harvest_weight_total', models.IntegerField(default=0)),
('chicken_life', models.IntegerField(default=0)),
('chicken_life_percentage', models.FloatField(default=0.0)),
('lot_weight', models.FloatField(default=0.0)),
('average_harvest_day', models.FloatField(default=0.0)),
('average_harvest_weight', models.FloatField(default=0.0)),
('fcr', models.FloatField(default=0.0)),
('eef', models.FloatField(default=0.0)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('cc', models.ForeignKey(on_delete=django.db.models.deletion.RESTRICT, related_name='kpis', to='operations.chickencounting')),
('cw', models.ForeignKey(on_delete=django.db.models.deletion.RESTRICT, related_name='kpis', to='operations.chickenweight')),
('cycle', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='kpis', to='farms.cycle')),
('fs', models.ForeignKey(on_delete=django.db.models.deletion.RESTRICT, related_name='kpis', to='operations.feedsacks')),
('manual', models.ForeignKey(on_delete=django.db.models.deletion.RESTRICT, related_name='kpis', to='operations.manualinput')),
],
options={
'verbose_name_plural': 'KPIs',
'db_table': 'kpi',
'ordering': ['-date'],
},
),
migrations.AddConstraint(
model_name='chickencounting',
constraint=models.UniqueConstraint(fields=('cycle', 'date'), name='uniq_chicken_counting_cycle_date'),
),
migrations.AddConstraint(
model_name='chickenweight',
constraint=models.UniqueConstraint(fields=('cycle', 'date'), name='uniq_cw_cycle_date'),
),
migrations.AddConstraint(
model_name='feedsacks',
constraint=models.UniqueConstraint(fields=('cycle', 'date'), name='uniq_feed_sacks_cycle_date'),
),
migrations.AddConstraint(
model_name='iotpanel',
constraint=models.UniqueConstraint(fields=('flock', 'date'), name='uniq_iot_panel_flock_date'),
),
migrations.AddConstraint(
model_name='manualinput',
constraint=models.UniqueConstraint(fields=('cycle', 'date'), name='uniq_manual_input_cycle_date'),
),
migrations.AddConstraint(
model_name='kpi',
constraint=models.UniqueConstraint(fields=('cycle', 'date'), name='uniq_kpi_cycle_date'),
),
]
@@ -0,0 +1,33 @@
# Generated by Django 5.2.17 on 2026-08-19 07:52
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('operations', '0001_initial'),
]
operations = [
migrations.AlterField(
model_name='kpi',
name='harvest_weight',
field=models.FloatField(default=0.0),
),
migrations.AlterField(
model_name='kpi',
name='harvest_weight_total',
field=models.FloatField(default=0.0),
),
migrations.AlterField(
model_name='manualinput',
name='harvest_weight_manual',
field=models.FloatField(default=0.0),
),
migrations.AlterField(
model_name='manualinput',
name='harvest_weight_manual_total',
field=models.FloatField(default=0.0),
),
]
@@ -0,0 +1,18 @@
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("farms", "0005_erd_named_primary_keys"),
("operations", "0002_harvest_weight_float"),
]
operations = [
migrations.RenameField(model_name="iotpanel", old_name="id", new_name="panel_id"),
migrations.RenameField(model_name="feedsacks", old_name="id", new_name="fs_id"),
migrations.RenameField(model_name="chickencounting", old_name="id", new_name="cc_id"),
migrations.RenameField(model_name="chickenweight", old_name="id", new_name="cw_id"),
migrations.RenameField(model_name="manualinput", old_name="id", new_name="manual_id"),
migrations.RenameField(model_name="kpi", old_name="id", new_name="kpi_id"),
]
@@ -0,0 +1,25 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("operations", "0003_erd_named_primary_keys"),
]
operations = [
migrations.RenameField(
model_name="manualinput",
old_name="manual_weight_float",
new_name="manual_weight",
),
migrations.RenameField(
model_name="kpi",
old_name="lot_weight",
new_name="iot_weight",
),
migrations.AlterField(
model_name="chickenweight",
name="doc_weight",
field=models.FloatField(),
),
]
@@ -0,0 +1,55 @@
from django.db import migrations, models
def backfill_feed_in_from_feed_sacks(apps, schema_editor):
ManualInput = apps.get_model("operations", "ManualInput")
FeedSacks = apps.get_model("operations", "FeedSacks")
feed_by_cycle_date = {
(fs.cycle_id, fs.date): fs
for fs in FeedSacks.objects.all().only("cycle_id", "date", "in_today", "in_total")
}
for manual in ManualInput.objects.all().only("cycle_id", "date"):
fs = feed_by_cycle_date.get((manual.cycle_id, manual.date))
if fs is None:
continue
manual.feed_in_manual = fs.in_today
manual.feed_in_manual_total = fs.in_total
manual.save(update_fields=["feed_in_manual", "feed_in_manual_total", "updated_at"])
class Migration(migrations.Migration):
dependencies = [
("operations", "0004_erd_column_updates"),
]
operations = [
migrations.RenameField(
model_name="manualinput",
old_name="feed_manual",
new_name="feed_use_manual",
),
migrations.RenameField(
model_name="manualinput",
old_name="feed_manual_total",
new_name="feed_use_manual_total",
),
migrations.AddField(
model_name="manualinput",
name="feed_in_manual",
field=models.IntegerField(default=0),
),
migrations.AddField(
model_name="manualinput",
name="feed_in_manual_total",
field=models.IntegerField(default=0),
),
migrations.RunPython(backfill_feed_in_from_feed_sacks, migrations.RunPython.noop),
migrations.AlterField(
model_name="chickenweight",
name="doc_weight",
field=models.IntegerField(),
),
]
@@ -0,0 +1,27 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("farms", "0007_cycle_doc_in_weight_int"),
("operations", "0005_erd_feed_and_doc_weight"),
]
operations = [
migrations.RemoveConstraint(
model_name="iotpanel",
name="uniq_iot_panel_flock_date",
),
migrations.AddField(
model_name="iotpanel",
name="timestamp",
field=models.DateTimeField(blank=True, null=True),
),
migrations.AddConstraint(
model_name="iotpanel",
constraint=models.UniqueConstraint(
fields=("flock", "timestamp"), name="uniq_iot_panel_flock_timestamp"
),
),
]
@@ -0,0 +1,29 @@
from datetime import datetime, time
from django.db import migrations
from django.utils import timezone
def backfill_timestamps(apps, schema_editor):
IotPanel = apps.get_model("operations", "IotPanel")
for row in IotPanel.objects.filter(timestamp__isnull=True):
row.timestamp = timezone.make_aware(
datetime.combine(row.date, time(0, 0)), timezone.get_current_timezone()
)
row.save(update_fields=["timestamp"])
def unset_timestamps(apps, schema_editor):
IotPanel = apps.get_model("operations", "IotPanel")
IotPanel.objects.update(timestamp=None)
class Migration(migrations.Migration):
dependencies = [
("operations", "0006_iotpanel_timestamp"),
]
operations = [
migrations.RunPython(backfill_timestamps, unset_timestamps),
]
@@ -0,0 +1,21 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("operations", "0007_backfill_iotpanel_timestamp"),
]
operations = [
migrations.AddField(
model_name="manualinput",
name="feed_out_manual",
field=models.IntegerField(default=0),
),
migrations.AddField(
model_name="manualinput",
name="feed_out_manual_total",
field=models.IntegerField(default=0),
),
]
@@ -0,0 +1,61 @@
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("farms", "0008_cycle_feed_initial_balance"),
("operations", "0008_manualinput_feed_out_manual"),
]
operations = [
migrations.CreateModel(
name="ReportSnapshot",
fields=[
(
"id",
models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID"),
),
(
"report_type",
models.CharField(
choices=[("daily", "Daily"), ("end_cycle", "End Cycle")], max_length=20
),
),
("up_to_day", models.IntegerField(default=0)),
("payload", models.JSONField(default=dict)),
("generated_at", models.DateTimeField()),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
(
"cycle",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="report_snapshots",
to="farms.cycle",
),
),
(
"kandang",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="report_snapshots",
to="farms.kandang",
),
),
],
options={
"verbose_name": "Report Snapshot",
"verbose_name_plural": "Report Snapshots",
"db_table": "report_snapshot",
"ordering": ["-updated_at"],
"constraints": [
models.UniqueConstraint(
fields=("cycle", "kandang", "report_type"),
name="uniq_report_snapshot_cycle_kandang_type",
)
],
},
),
]
@@ -0,0 +1,14 @@
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("operations", "0009_reportsnapshot"),
]
operations = [
migrations.DeleteModel(
name="ReportSnapshot",
),
]
Whitespace-only changes.
+170
View File
@@ -0,0 +1,170 @@
from django.db import models
from apps.farms.models import Cycle, Flock
class IotPanel(models.Model):
panel_id = models.BigAutoField(primary_key=True)
date = models.DateField()
timestamp = models.DateTimeField(null=True, blank=True)
wind_speed = models.FloatField(default=0.0)
humidity = models.FloatField(default=0.0)
water_total = models.FloatField(default=0.0)
average_temperature = models.FloatField(default=0.0)
experience_temperature = models.FloatField(default=0.0)
flock = models.ForeignKey(Flock, on_delete=models.CASCADE, related_name="iot_panels")
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = "iot_panel"
ordering = ["-date"]
verbose_name = "IoT Panel"
verbose_name_plural = "IoT Panels"
constraints = [
models.UniqueConstraint(fields=["flock", "timestamp"], name="uniq_iot_panel_flock_timestamp")
]
class AIInsight(models.Model):
date = models.DateField()
insight_text = models.TextField()
alert = models.CharField(max_length=100)
section = models.CharField(max_length=100)
session = models.CharField(max_length=100)
cycle = models.ForeignKey(Cycle, on_delete=models.CASCADE, related_name="ai_insights")
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = "ai_insight"
ordering = ["-date"]
class FeedSacks(models.Model):
fs_id = models.BigAutoField(primary_key=True)
date = models.DateField()
in_today = models.IntegerField(default=0)
out_today = models.IntegerField(default=0)
in_total = models.IntegerField(default=0)
out_total = models.IntegerField(default=0)
feed_use_today = models.IntegerField(default=0)
feed_use_total = models.IntegerField(default=0)
cycle = models.ForeignKey(Cycle, on_delete=models.CASCADE, related_name="feed_sacks")
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = "feed_sacks"
ordering = ["-date"]
verbose_name_plural = "Feed sacks"
constraints = [
models.UniqueConstraint(fields=["cycle", "date"], name="uniq_feed_sacks_cycle_date")
]
class ChickenCounting(models.Model):
cc_id = models.BigAutoField(primary_key=True)
date = models.DateField()
total_count = models.IntegerField(default=0)
mortality_count = models.IntegerField(default=0)
cycle = models.ForeignKey(Cycle, on_delete=models.CASCADE, related_name="chicken_countings")
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = "chicken_counting"
ordering = ["-date"]
constraints = [
models.UniqueConstraint(fields=["cycle", "date"], name="uniq_chicken_counting_cycle_date")
]
class ChickenWeight(models.Model):
cw_id = models.BigAutoField(primary_key=True)
date = models.DateField()
age = models.IntegerField()
doc_weight = models.IntegerField()
average_weight = models.FloatField()
chicken_count = models.IntegerField()
uniformity = models.FloatField()
average_daily_gain = models.FloatField()
cycle = models.ForeignKey(Cycle, on_delete=models.CASCADE, related_name="chicken_weights")
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = "chicken_weight"
ordering = ["-date"]
constraints = [
models.UniqueConstraint(fields=["cycle", "date"], name="uniq_cw_cycle_date")
]
class ManualInput(models.Model):
manual_id = models.BigAutoField(primary_key=True)
date = models.DateField()
age_manual = models.IntegerField(default=0)
mortality_manual = models.IntegerField(default=0)
mortality_manual_total = models.IntegerField(default=0)
feed_in_manual = models.IntegerField(default=0)
feed_use_manual = models.IntegerField(default=0)
feed_in_manual_total = models.IntegerField(default=0)
feed_use_manual_total = models.IntegerField(default=0)
feed_out_manual = models.IntegerField(default=0)
feed_out_manual_total = models.IntegerField(default=0)
harvest_manual = models.IntegerField(default=0)
harvest_manual_total = models.IntegerField(default=0)
harvest_weight_manual = models.FloatField(default=0.0)
harvest_weight_manual_total = models.FloatField(default=0.0)
manual_weight = models.FloatField(default=0.0)
average_harvest_day_manual = models.FloatField(default=0.0)
average_harvest_weight_manual = models.FloatField(default=0.0)
fcr_manual = models.FloatField(default=0.0)
eef_manual = models.FloatField(default=0.0)
cycle = models.ForeignKey(Cycle, on_delete=models.CASCADE, related_name="manual_inputs")
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = "manual_input"
ordering = ["-date"]
constraints = [
models.UniqueConstraint(fields=["cycle", "date"], name="uniq_manual_input_cycle_date")
]
class KPI(models.Model):
kpi_id = models.BigAutoField(primary_key=True)
date = models.DateField()
age = models.IntegerField(default=0)
mortality = models.IntegerField(default=0)
mortality_total = models.IntegerField(default=0)
feed = models.IntegerField(default=0)
feed_total = models.IntegerField(default=0)
harvest = models.IntegerField(default=0)
harvest_total = models.IntegerField(default=0)
harvest_weight = models.FloatField(default=0.0)
harvest_weight_total = models.FloatField(default=0.0)
chicken_life = models.IntegerField(default=0)
chicken_life_percentage = models.FloatField(default=0.0)
iot_weight = models.FloatField(default=0.0)
average_harvest_day = models.FloatField(default=0.0)
average_harvest_weight = models.FloatField(default=0.0)
fcr = models.FloatField(default=0.0)
eef = models.FloatField(default=0.0)
cycle = models.ForeignKey(Cycle, on_delete=models.CASCADE, related_name="kpis")
cc = models.ForeignKey(ChickenCounting, on_delete=models.RESTRICT, related_name="kpis")
cw = models.ForeignKey(ChickenWeight, on_delete=models.RESTRICT, related_name="kpis")
fs = models.ForeignKey(FeedSacks, on_delete=models.RESTRICT, related_name="kpis")
manual = models.ForeignKey(ManualInput, on_delete=models.RESTRICT, related_name="kpis")
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = "kpi"
ordering = ["-date"]
verbose_name_plural = "KPIs"
constraints = [
models.UniqueConstraint(fields=["cycle", "date"], name="uniq_kpi_cycle_date")
]
+138
View File
@@ -0,0 +1,138 @@
from django.db import transaction
from rest_framework import serializers
from apps.operations.models import (
AIInsight,
ChickenCounting,
ChickenWeight,
FeedSacks,
IotPanel,
KPI,
ManualInput,
)
from apps.accounts.serializers import PkAsIdMixin
from apps.operations.services.manual_input import recompute_manual_totals
class CycleContextMixin(serializers.Serializer):
kandang = serializers.IntegerField(source="cycle.kandang_id", read_only=True)
flock_id = serializers.SerializerMethodField()
def get_flock_id(self, obj):
flock = obj.cycle.kandang.flocks.order_by("flock_id").first()
return flock.pk if flock is not None else None
class KPISerializer(PkAsIdMixin, CycleContextMixin, serializers.ModelSerializer):
class Meta:
model = KPI
fields = "__all__"
read_only_fields = ["id", "created_at", "updated_at"]
class ManualInputSerializer(PkAsIdMixin, CycleContextMixin, serializers.ModelSerializer):
class Meta:
model = ManualInput
fields = "__all__"
read_only_fields = [
"id",
"created_at",
"updated_at",
"mortality_manual_total",
"feed_in_manual_total",
"feed_use_manual_total",
"feed_out_manual_total",
"harvest_manual_total",
"harvest_weight_manual_total",
"average_harvest_day_manual",
"average_harvest_weight_manual",
"fcr_manual",
"eef_manual",
]
validators = []
def create(self, validated_data):
return self._upsert(validated_data)
def update(self, instance, validated_data):
validated_data.setdefault("cycle", instance.cycle)
validated_data.setdefault("date", instance.date)
return self._upsert(validated_data, instance=instance)
def _upsert(self, validated_data, instance=None):
cycle = validated_data.get("cycle") or (instance.cycle if instance else None)
row_date = validated_data.get("date") or (instance.date if instance else None)
defaults = {
"age_manual": validated_data.get("age_manual", instance.age_manual if instance else 0),
"mortality_manual": validated_data.get(
"mortality_manual", instance.mortality_manual if instance else 0
),
"feed_in_manual": validated_data.get(
"feed_in_manual", instance.feed_in_manual if instance else 0
),
"feed_use_manual": validated_data.get(
"feed_use_manual", instance.feed_use_manual if instance else 0
),
"feed_out_manual": validated_data.get(
"feed_out_manual", instance.feed_out_manual if instance else 0
),
"harvest_manual": validated_data.get(
"harvest_manual", instance.harvest_manual if instance else 0
),
"harvest_weight_manual": validated_data.get(
"harvest_weight_manual", instance.harvest_weight_manual if instance else 0
),
"manual_weight": validated_data.get(
"manual_weight", instance.manual_weight if instance else 0.0
),
}
with transaction.atomic():
row, _created = ManualInput.objects.update_or_create(
cycle=cycle,
date=row_date,
defaults=defaults,
)
recompute_manual_totals(cycle)
row.refresh_from_db()
return row
class ChickenCountingSerializer(PkAsIdMixin, CycleContextMixin, serializers.ModelSerializer):
class Meta:
model = ChickenCounting
fields = "__all__"
read_only_fields = ["id", "created_at", "updated_at"]
class ChickenWeightSerializer(PkAsIdMixin, CycleContextMixin, serializers.ModelSerializer):
class Meta:
model = ChickenWeight
fields = "__all__"
read_only_fields = ["id", "created_at", "updated_at"]
class FeedSacksSerializer(PkAsIdMixin, CycleContextMixin, serializers.ModelSerializer):
class Meta:
model = FeedSacks
fields = "__all__"
read_only_fields = ["id", "created_at", "updated_at"]
class IotPanelSerializer(PkAsIdMixin, serializers.ModelSerializer):
kandang = serializers.SerializerMethodField()
flock_id = serializers.IntegerField(read_only=True)
class Meta:
model = IotPanel
fields = "__all__"
read_only_fields = ["id", "created_at", "updated_at"]
def get_kandang(self, obj):
return obj.flock.kandang_id
class AIInsightSerializer(PkAsIdMixin, CycleContextMixin, serializers.ModelSerializer):
class Meta:
model = AIInsight
fields = "__all__"
read_only_fields = ["id", "created_at", "updated_at"]
Whitespace-only changes.
@@ -0,0 +1,214 @@
from __future__ import annotations
import re
from datetime import date
from typing import Any
from urllib.parse import urljoin
import httpx
from django.conf import settings
from django.core.exceptions import ValidationError
from django.db import transaction
from django.utils import timezone
from apps.farms.models import Cycle
from apps.operations.models import ChickenCounting
class ChickenCountingEdgeError(Exception):
def __init__(self, message: str, status_code: int | None = None):
super().__init__(message)
self.status_code = status_code
def kandang_index(kandang_name: str) -> str:
match = re.search(r"(\d+)", kandang_name or "")
if not match:
raise ValidationError(
f"Cannot derive edge kandang index from kandang name: {kandang_name!r}"
)
return match.group(1)
def location_prefix_for_kandang(kandang_name: str) -> str:
"""``Kandang 1`` → ``K1`` (matches floor prefixes like ``K1-L1``)."""
return f"K{kandang_index(kandang_name)}"
def coop_for_kandang(kandang_name: str) -> str:
"""``Kandang 1`` → ``K1`` (mortality ``coop`` field)."""
return location_prefix_for_kandang(kandang_name)
def _parse_history_date(raw: Any) -> date | None:
if raw is None:
return None
try:
return date.fromisoformat(str(raw)[:10])
except ValueError:
return None
def aggregate_counting_by_date(
rows: list[dict[str, Any]] | None,
*,
kandang_name: str,
) -> dict[date, int]:
"""Sum ``total`` for floors whose location starts with ``KN-`` / equals ``KN``."""
prefix = location_prefix_for_kandang(kandang_name)
by_date: dict[date, int] = {}
for row in rows or []:
day = _parse_history_date(row.get("date"))
if day is None:
continue
location = str(row.get("location") or "").strip()
if location != prefix and not location.startswith(f"{prefix}-"):
continue
by_date[day] = by_date.get(day, 0) + int(row.get("total") or 0)
return by_date
def aggregate_mortality_by_date(
rows: list[dict[str, Any]] | None,
*,
kandang_name: str,
) -> dict[date, int]:
"""Map date → ``total_mortality_count`` for the matching coop."""
expected = coop_for_kandang(kandang_name).casefold()
by_date: dict[date, int] = {}
for row in rows or []:
day = _parse_history_date(row.get("date"))
if day is None:
continue
coop = str(row.get("coop") or row.get("location") or "").strip()
if coop.casefold() != expected:
continue
by_date[day] = int(row.get("total_mortality_count") or 0)
return by_date
def cycle_history_window(cycle: Cycle, *, today: date | None = None) -> tuple[date, date]:
if today is None:
today = timezone.localdate()
end = min(today, cycle.end_date)
return cycle.start_date, end
class ChickenCountingEdgeClient:
"""Outbound client for the chicken-counting edge box."""
DB_HISTORY_PATH = "/api/db/history"
MORTALITY_HISTORY_PATH = "/api/mortality/history"
def __init__(self, base_url: str | None = None, timeout: float | None = None):
self.base_url = (
base_url or getattr(settings, "CHICKEN_COUNTING_EDGE_BASE_URL", "") or ""
).rstrip("/")
self.timeout = timeout or float(
getattr(settings, "CHICKEN_COUNTING_EDGE_TIMEOUT_SECONDS", 30) or 30
)
def _get_json(self, path: str) -> Any:
if not self.base_url:
raise ChickenCountingEdgeError("CHICKEN_COUNTING_EDGE_BASE_URL is not configured")
url = urljoin(self.base_url + "/", path.lstrip("/"))
try:
with httpx.Client(timeout=self.timeout) as client:
response = client.get(url)
except httpx.HTTPError as exc:
raise ChickenCountingEdgeError(f"chicken counting edge request failed: {exc}") from exc
if response.status_code >= 400:
raise ChickenCountingEdgeError(
f"chicken counting edge HTTP {response.status_code}: {response.text[:300]}",
status_code=response.status_code,
)
return response.json()
def fetch_db_history(self) -> list[dict[str, Any]]:
payload = self._get_json(self.DB_HISTORY_PATH)
if isinstance(payload, list):
return payload
return list(payload.get("results") or payload.get("history") or [])
def fetch_mortality_history(self) -> list[dict[str, Any]]:
payload = self._get_json(self.MORTALITY_HISTORY_PATH)
if isinstance(payload, list):
return payload
return list(payload.get("results") or payload.get("history") or [])
@transaction.atomic
def sync_chicken_counting_for_cycle(
cycle: Cycle,
*,
db_history: list[dict[str, Any]] | None = None,
mortality_history: list[dict[str, Any]] | None = None,
) -> dict[str, Any]:
"""Upsert ChickenCounting from edge history within the cycle window."""
counting_enabled = bool(
getattr(settings, "CHICKEN_COUNTING_EDGE_COUNTING_SYNC_ENABLED", True)
)
mortality_enabled = bool(
getattr(settings, "CHICKEN_COUNTING_EDGE_MORTALITY_SYNC_ENABLED", True)
)
if not counting_enabled and not mortality_enabled:
return {"dates": [], "upserted": 0, "skipped": True}
client = ChickenCountingEdgeClient()
if db_history is None and counting_enabled:
db_history = client.fetch_db_history()
if mortality_history is None and mortality_enabled:
mortality_history = client.fetch_mortality_history()
kandang_name = cycle.kandang.kandang_name
counting_by_date = (
aggregate_counting_by_date(db_history, kandang_name=kandang_name)
if counting_enabled
else {}
)
mortality_by_date = (
aggregate_mortality_by_date(mortality_history, kandang_name=kandang_name)
if mortality_enabled
else {}
)
start, end = cycle_history_window(cycle)
dates = sorted(set(counting_by_date) | set(mortality_by_date))
upserted: list[date] = []
for day in dates:
if day < start or day > end:
continue
defaults: dict[str, int] = {}
if counting_enabled and day in counting_by_date:
defaults["total_count"] = counting_by_date[day]
if mortality_enabled and day in mortality_by_date:
defaults["mortality_count"] = mortality_by_date[day]
if not defaults:
continue
obj, created = ChickenCounting.objects.get_or_create(
cycle=cycle,
date=day,
defaults={
"total_count": defaults.get("total_count", 0),
"mortality_count": defaults.get("mortality_count", 0),
},
)
if not created:
update_fields = []
if "total_count" in defaults and obj.total_count != defaults["total_count"]:
obj.total_count = defaults["total_count"]
update_fields.append("total_count")
if (
"mortality_count" in defaults
and obj.mortality_count != defaults["mortality_count"]
):
obj.mortality_count = defaults["mortality_count"]
update_fields.append("mortality_count")
if update_fields:
update_fields.append("updated_at")
obj.save(update_fields=update_fields)
upserted.append(day)
return {"dates": upserted, "upserted": len(upserted), "skipped": False}
Loaded 100 of 226 files, more files were not shown because too many files have changed in this diff. Show more