first commit
This commit is contained in:
commit
b54624be96
226 files changed
+108840
No files matched your search
@@ -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
|
||||
@@ -0,0 +1,2 @@
|
||||
# Local dev: Vite proxies /api → HQ Django :8001
|
||||
VITE_API_BASE=/api/v1
|
||||
@@ -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
@@ -0,0 +1,12 @@
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
.env
|
||||
coverage
|
||||
.vite
|
||||
backup
|
||||
__pycache__
|
||||
docker-compose.override.yml
|
||||
docs/
|
||||
.superpowers/
|
||||
@@ -0,0 +1,8 @@
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
coverage
|
||||
.vite
|
||||
backend/.venv
|
||||
backend/.pytest_cache
|
||||
backend/**/node_modules
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"semi": true,
|
||||
"singleQuote": true,
|
||||
"tabWidth": 2,
|
||||
"printWidth": 100,
|
||||
"trailingComma": "es5",
|
||||
"arrowParens": "always",
|
||||
"endOfLine": "lf"
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
@@ -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).
|
||||
@@ -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
|
||||
@@ -0,0 +1,10 @@
|
||||
.venv/
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.env
|
||||
db.sqlite3
|
||||
data/
|
||||
media/
|
||||
staticfiles/
|
||||
*.egg-info/
|
||||
.Python
|
||||
@@ -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` |
|
||||
Whitespace-only changes.
Whitespace-only changes.
Whitespace-only changes.
@@ -0,0 +1,8 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class AccountsConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "apps.accounts"
|
||||
label = "accounts"
|
||||
verbose_name = "Accounts"
|
||||
@@ -0,0 +1,32 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from rest_framework import authentication, exceptions
|
||||
|
||||
from apps.accounts.models import ApiKey
|
||||
|
||||
|
||||
class ApiKeyAuthentication(authentication.BaseAuthentication):
|
||||
"""Authenticate via X-API-Key header (hashed lookup)."""
|
||||
|
||||
keyword = "X-API-Key"
|
||||
|
||||
def authenticate(self, request):
|
||||
raw_key = request.headers.get(self.keyword) or request.META.get("HTTP_X_API_KEY")
|
||||
if not raw_key:
|
||||
return None
|
||||
if len(raw_key) < 8:
|
||||
raise exceptions.AuthenticationFailed(_("Invalid API key"))
|
||||
|
||||
prefix = raw_key[:8]
|
||||
candidates = ApiKey.objects.select_related("user").filter(
|
||||
prefix=prefix, is_active=True, user__is_active=True
|
||||
)
|
||||
for api_key in candidates:
|
||||
if api_key.verify(raw_key):
|
||||
api_key.touch()
|
||||
return (api_key.user, api_key)
|
||||
raise exceptions.AuthenticationFailed(_("Invalid API key"))
|
||||
|
||||
def authenticate_header(self, request):
|
||||
return self.keyword
|
||||
@@ -0,0 +1,55 @@
|
||||
# Generated by Django 5.2.17 on 2026-08-10 08:31
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('auth', '0012_alter_user_first_name_max_length'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='User',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('password', models.CharField(max_length=128, verbose_name='password')),
|
||||
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
|
||||
('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
|
||||
('user_name', models.CharField(max_length=30, unique=True)),
|
||||
('is_staff', models.BooleanField(default=False)),
|
||||
('is_active', models.BooleanField(default=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')),
|
||||
('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')),
|
||||
],
|
||||
options={
|
||||
'db_table': 'user_access',
|
||||
'ordering': ['user_name'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ApiKey',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=100)),
|
||||
('prefix', models.CharField(db_index=True, max_length=12)),
|
||||
('key_hash', models.CharField(max_length=64)),
|
||||
('is_active', models.BooleanField(default=True)),
|
||||
('last_used_at', models.DateTimeField(blank=True, null=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='api_keys', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'db_table': 'api_keys',
|
||||
'ordering': ['-created_at'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,16 @@
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("accounts", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RenameField(
|
||||
model_name="user",
|
||||
old_name="id",
|
||||
new_name="user_id",
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,39 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def backfill_user_status(apps, schema_editor):
|
||||
User = apps.get_model("accounts", "User")
|
||||
for user in User.objects.all():
|
||||
if user.is_superuser:
|
||||
status = "superadmin"
|
||||
flags = {"is_active": True, "is_staff": True, "is_superuser": True}
|
||||
elif not user.is_active:
|
||||
status = "inactive"
|
||||
flags = {"is_active": False, "is_staff": False, "is_superuser": False}
|
||||
else:
|
||||
status = "active"
|
||||
flags = {"is_active": True, "is_staff": False, "is_superuser": False}
|
||||
User.objects.filter(pk=user.pk).update(status=status, **flags)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("accounts", "0002_erd_named_primary_keys"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="user",
|
||||
name="status",
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
("active", "Active"),
|
||||
("inactive", "Inactive"),
|
||||
("superadmin", "Super Admin"),
|
||||
],
|
||||
default="active",
|
||||
max_length=30,
|
||||
),
|
||||
),
|
||||
migrations.RunPython(backfill_user_status, migrations.RunPython.noop),
|
||||
]
|
||||
@@ -0,0 +1,29 @@
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def sync_status_flags(apps, schema_editor):
|
||||
User = apps.get_model("accounts", "User")
|
||||
for user in User.objects.all():
|
||||
if user.status == "inactive":
|
||||
user.is_active = False
|
||||
user.is_staff = False
|
||||
user.is_superuser = False
|
||||
elif user.status == "superadmin":
|
||||
user.is_active = True
|
||||
user.is_staff = False
|
||||
user.is_superuser = True
|
||||
else:
|
||||
user.is_active = True
|
||||
user.is_superuser = False
|
||||
user.save(update_fields=["is_active", "is_staff", "is_superuser", "updated_at"])
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("accounts", "0003_user_status"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(sync_status_flags, migrations.RunPython.noop),
|
||||
]
|
||||
@@ -0,0 +1,45 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def migrate_legacy_statuses(apps, schema_editor):
|
||||
User = apps.get_model("accounts", "User")
|
||||
for user in User.objects.all():
|
||||
if user.status == "superadmin":
|
||||
new_status = "director_admin"
|
||||
flags = {"is_active": True, "is_staff": True, "is_superuser": True}
|
||||
elif user.status == "active":
|
||||
new_status = "gm"
|
||||
flags = {"is_active": True, "is_staff": False, "is_superuser": False}
|
||||
elif user.status == "inactive":
|
||||
new_status = "inactive"
|
||||
flags = {"is_active": False, "is_staff": False, "is_superuser": False}
|
||||
else:
|
||||
continue
|
||||
User.objects.filter(pk=user.pk).update(status=new_status, **flags)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("accounts", "0004_sync_status_flags"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="user",
|
||||
name="status",
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
("inactive", "Inactive"),
|
||||
("gm", "GM"),
|
||||
("gm_admin", "GM Admin"),
|
||||
("buh", "BUH"),
|
||||
("bu_admin", "BU Admin"),
|
||||
("director", "Director"),
|
||||
("director_admin", "Director Admin"),
|
||||
],
|
||||
default="gm",
|
||||
max_length=30,
|
||||
),
|
||||
),
|
||||
migrations.RunPython(migrate_legacy_statuses, migrations.RunPython.noop),
|
||||
]
|
||||
@@ -0,0 +1,26 @@
|
||||
# Generated for HQ visibility scoping
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("accounts", "0005_executive_roles"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="user",
|
||||
name="managed_by",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
limit_choices_to={"status__in": ("buh", "bu_admin")},
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="managed_gms",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,28 @@
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("accounts", "0006_user_managed_by"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="user",
|
||||
name="managed_by_pending",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
limit_choices_to={"status__in": ("buh", "bu_admin")},
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="pending_managed_gms",
|
||||
to="accounts.user",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="user",
|
||||
name="managed_by_requested_at",
|
||||
field=models.DateTimeField(blank=True, null=True),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,25 @@
|
||||
# Generated manually for managed_by_pending BUH-only limit
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("accounts", "0007_user_managed_by_pending"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="user",
|
||||
name="managed_by_pending",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
limit_choices_to={"status": "buh"},
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="pending_managed_gms",
|
||||
to="accounts.user",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,30 @@
|
||||
# Generated manually for account_admin status
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("accounts", "0008_alter_managed_by_pending_buh_only"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="user",
|
||||
name="status",
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
("inactive", "Inactive"),
|
||||
("gm", "GM"),
|
||||
("gm_admin", "GM Admin"),
|
||||
("buh", "BUH"),
|
||||
("bu_admin", "BU Admin"),
|
||||
("director", "Director"),
|
||||
("director_admin", "Director Admin"),
|
||||
("account_admin", "Account Admin"),
|
||||
],
|
||||
default="gm",
|
||||
max_length=30,
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,22 @@
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def strip_director_admin_superuser(apps, schema_editor):
|
||||
User = apps.get_model("accounts", "User")
|
||||
User.objects.filter(status="director_admin", is_superuser=True).update(
|
||||
is_superuser=False
|
||||
)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("accounts", "0009_account_admin_status"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(strip_director_admin_superuser, migrations.RunPython.noop),
|
||||
migrations.RemoveField(
|
||||
model_name="user",
|
||||
name="is_staff",
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,39 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def migrate_break_glass_to_superuser_status(apps, schema_editor):
|
||||
User = apps.get_model("accounts", "User")
|
||||
User.objects.filter(is_superuser=True).exclude(status="inactive").update(
|
||||
status="superuser"
|
||||
)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("accounts", "0010_remove_is_staff_decouple_director_admin"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="user",
|
||||
name="status",
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
("inactive", "Inactive"),
|
||||
("gm", "GM"),
|
||||
("gm_admin", "GM Admin"),
|
||||
("buh", "BUH"),
|
||||
("bu_admin", "BU Admin"),
|
||||
("director", "Director"),
|
||||
("director_admin", "Director Admin"),
|
||||
("account_admin", "Account Admin"),
|
||||
("superuser", "Superuser"),
|
||||
],
|
||||
default="gm",
|
||||
max_length=30,
|
||||
),
|
||||
),
|
||||
migrations.RunPython(
|
||||
migrate_break_glass_to_superuser_status, migrations.RunPython.noop
|
||||
),
|
||||
]
|
||||
Whitespace-only changes.
@@ -0,0 +1,239 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import secrets
|
||||
|
||||
from django.contrib.auth.models import AbstractBaseUser, BaseUserManager, PermissionsMixin
|
||||
from django.db import models
|
||||
from django.utils import timezone
|
||||
|
||||
|
||||
class UserManager(BaseUserManager):
|
||||
def create_user(self, user_name: str, password: str | None = None, **extra_fields):
|
||||
if not user_name:
|
||||
raise ValueError("user_name is required")
|
||||
extra_fields.setdefault("status", User.STATUS_GM)
|
||||
extra_fields.setdefault("is_superuser", False)
|
||||
user = self.model(user_name=user_name, **extra_fields)
|
||||
user.set_password(password)
|
||||
user.save(using=self._db)
|
||||
return user
|
||||
|
||||
def create_superuser(self, user_name: str, password: str | None = None, **extra_fields):
|
||||
"""Break-glass: status=superuser (ops + user management). Hidden from user lists."""
|
||||
extra_fields["status"] = User.STATUS_SUPERUSER
|
||||
extra_fields["is_superuser"] = True
|
||||
return self.create_user(user_name, password, **extra_fields)
|
||||
|
||||
|
||||
class User(AbstractBaseUser, PermissionsMixin):
|
||||
"""HQ executive user. Roles are distinct from site dashboard active/superadmin."""
|
||||
|
||||
STATUS_INACTIVE = "inactive"
|
||||
STATUS_GM = "gm"
|
||||
STATUS_GM_ADMIN = "gm_admin"
|
||||
STATUS_BUH = "buh"
|
||||
STATUS_BU_ADMIN = "bu_admin"
|
||||
STATUS_DIRECTOR = "director"
|
||||
STATUS_DIRECTOR_ADMIN = "director_admin"
|
||||
STATUS_ACCOUNT_ADMIN = "account_admin"
|
||||
STATUS_SUPERUSER = "superuser"
|
||||
|
||||
STATUS_CHOICES = [
|
||||
(STATUS_INACTIVE, "Inactive"),
|
||||
(STATUS_GM, "GM"),
|
||||
(STATUS_GM_ADMIN, "GM Admin"),
|
||||
(STATUS_BUH, "BUH"),
|
||||
(STATUS_BU_ADMIN, "BU Admin"),
|
||||
(STATUS_DIRECTOR, "Director"),
|
||||
(STATUS_DIRECTOR_ADMIN, "Director Admin"),
|
||||
(STATUS_ACCOUNT_ADMIN, "Account Admin"),
|
||||
(STATUS_SUPERUSER, "Superuser"),
|
||||
]
|
||||
|
||||
# Roles that may sign in and use the executive dashboard.
|
||||
EXECUTIVE_STATUSES = frozenset(
|
||||
{
|
||||
STATUS_GM,
|
||||
STATUS_GM_ADMIN,
|
||||
STATUS_BUH,
|
||||
STATUS_BU_ADMIN,
|
||||
STATUS_DIRECTOR,
|
||||
STATUS_DIRECTOR_ADMIN,
|
||||
STATUS_ACCOUNT_ADMIN,
|
||||
STATUS_SUPERUSER,
|
||||
}
|
||||
)
|
||||
|
||||
# BU / Director "*_admin" are view-only (same as buh / director).
|
||||
# Kept for labeling only — no extra write powers.
|
||||
ADMIN_STATUSES = frozenset(
|
||||
{
|
||||
STATUS_BU_ADMIN,
|
||||
STATUS_DIRECTOR_ADMIN,
|
||||
}
|
||||
)
|
||||
|
||||
user_id = models.BigAutoField(primary_key=True)
|
||||
user_name = models.CharField(max_length=30, unique=True)
|
||||
status = models.CharField(max_length=30, choices=STATUS_CHOICES, default=STATUS_GM)
|
||||
# BUH who manages this GM / GM Admin (unused for director / buh themselves).
|
||||
managed_by = models.ForeignKey(
|
||||
"self",
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="managed_gms",
|
||||
limit_choices_to={"status__in": ("buh", "bu_admin")},
|
||||
)
|
||||
# GM-requested BUH waiting for confirmation (BUH only — not BU Admin).
|
||||
managed_by_pending = models.ForeignKey(
|
||||
"self",
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="pending_managed_gms",
|
||||
limit_choices_to={"status": "buh"},
|
||||
)
|
||||
managed_by_requested_at = models.DateTimeField(null=True, blank=True)
|
||||
is_active = models.BooleanField(default=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
objects = UserManager()
|
||||
|
||||
USERNAME_FIELD = "user_name"
|
||||
REQUIRED_FIELDS: list[str] = []
|
||||
|
||||
class Meta:
|
||||
db_table = "user_access"
|
||||
ordering = ["user_name"]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.user_name
|
||||
|
||||
@property
|
||||
def is_executive(self) -> bool:
|
||||
return self.status in self.EXECUTIVE_STATUSES
|
||||
|
||||
@property
|
||||
def is_executive_admin(self) -> bool:
|
||||
return self.status in self.ADMIN_STATUSES
|
||||
|
||||
def request_managed_by(self, buh: "User") -> None:
|
||||
"""GM asks to report under a BUH (pending until that BUH confirms)."""
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
if self.status != self.STATUS_GM:
|
||||
raise ValidationError("Only GM can request a BUH.")
|
||||
if buh.status != self.STATUS_BUH:
|
||||
raise ValidationError("Target must be a BUH (not BU Admin).")
|
||||
if self.managed_by_id == buh.pk and self.managed_by_pending_id is None:
|
||||
raise ValidationError("Already managed by this BUH.")
|
||||
self.managed_by_pending = buh
|
||||
self.managed_by_requested_at = timezone.now()
|
||||
self.save(
|
||||
update_fields=["managed_by_pending", "managed_by_requested_at", "updated_at"]
|
||||
)
|
||||
|
||||
def cancel_managed_by_request(self) -> None:
|
||||
if self.managed_by_pending_id is None:
|
||||
return
|
||||
self.managed_by_pending = None
|
||||
self.managed_by_requested_at = None
|
||||
self.save(
|
||||
update_fields=["managed_by_pending", "managed_by_requested_at", "updated_at"]
|
||||
)
|
||||
|
||||
def approve_managed_by(self, approver: "User") -> None:
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
if self.managed_by_pending_id is None:
|
||||
raise ValidationError("No pending BUH request.")
|
||||
if approver.pk != self.managed_by_pending_id:
|
||||
raise ValidationError("Only the requested BUH can approve this request.")
|
||||
if approver.status != self.STATUS_BUH:
|
||||
raise ValidationError("Only a BUH (not BU Admin) can approve this request.")
|
||||
self.managed_by = self.managed_by_pending
|
||||
self.managed_by_pending = None
|
||||
self.managed_by_requested_at = None
|
||||
self.save(
|
||||
update_fields=[
|
||||
"managed_by",
|
||||
"managed_by_pending",
|
||||
"managed_by_requested_at",
|
||||
"updated_at",
|
||||
]
|
||||
)
|
||||
|
||||
def reject_managed_by(self, approver: "User") -> None:
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
if self.managed_by_pending_id is None:
|
||||
raise ValidationError("No pending BUH request.")
|
||||
if approver.pk != self.managed_by_pending_id:
|
||||
raise ValidationError("Only the requested BUH can reject this request.")
|
||||
if approver.status != self.STATUS_BUH:
|
||||
raise ValidationError("Only a BUH (not BU Admin) can reject this request.")
|
||||
self.managed_by_pending = None
|
||||
self.managed_by_requested_at = None
|
||||
self.save(
|
||||
update_fields=["managed_by_pending", "managed_by_requested_at", "updated_at"]
|
||||
)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
# Product roles never use Django is_staff. status=superuser is break-glass
|
||||
# (ops + user management) and always carries is_superuser.
|
||||
if self.status == self.STATUS_INACTIVE:
|
||||
self.is_active = False
|
||||
self.is_superuser = False
|
||||
elif self.status == self.STATUS_SUPERUSER:
|
||||
self.is_active = True
|
||||
self.is_superuser = True
|
||||
else:
|
||||
self.is_active = True
|
||||
self.is_superuser = False
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
|
||||
class ApiKey(models.Model):
|
||||
"""Hashed API keys for X-API-Key authentication (inbound to HQ API)."""
|
||||
|
||||
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="api_keys")
|
||||
name = models.CharField(max_length=100)
|
||||
prefix = models.CharField(max_length=12, db_index=True)
|
||||
key_hash = models.CharField(max_length=64)
|
||||
is_active = models.BooleanField(default=True)
|
||||
last_used_at = models.DateTimeField(null=True, blank=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
db_table = "api_keys"
|
||||
ordering = ["-created_at"]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.name} ({self.prefix}…)"
|
||||
|
||||
@staticmethod
|
||||
def hash_key(raw_key: str) -> str:
|
||||
return hashlib.sha256(raw_key.encode("utf-8")).hexdigest()
|
||||
|
||||
@classmethod
|
||||
def generate(cls, user: User, name: str) -> tuple[ApiKey, str]:
|
||||
raw = secrets.token_urlsafe(32)
|
||||
prefix = raw[:8]
|
||||
instance = cls.objects.create(
|
||||
user=user,
|
||||
name=name,
|
||||
prefix=prefix,
|
||||
key_hash=cls.hash_key(raw),
|
||||
)
|
||||
return instance, raw
|
||||
|
||||
def verify(self, raw_key: str) -> bool:
|
||||
return secrets.compare_digest(self.key_hash, self.hash_key(raw_key))
|
||||
|
||||
def touch(self) -> None:
|
||||
self.last_used_at = timezone.now()
|
||||
self.save(update_fields=["last_used_at", "updated_at"])
|
||||
@@ -0,0 +1,145 @@
|
||||
"""HQ executive role helpers and DRF permission classes.
|
||||
|
||||
Settled matrix (v1):
|
||||
- buh, director (+ their admins until specified otherwise): view only
|
||||
- gm + gm_admin: view + register city sites (URL) + sync from cities
|
||||
- gm only (not gm_admin): approve cycle close
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from rest_framework import permissions
|
||||
|
||||
from apps.accounts.models import User
|
||||
|
||||
# View-only executives (no site registry, sync, or approvals).
|
||||
VIEW_ONLY_STATUSES = frozenset(
|
||||
{
|
||||
User.STATUS_BUH,
|
||||
User.STATUS_BU_ADMIN,
|
||||
User.STATUS_DIRECTOR,
|
||||
User.STATUS_DIRECTOR_ADMIN,
|
||||
}
|
||||
)
|
||||
|
||||
# Can register city dashboard URLs into the HQ mirror registry.
|
||||
SITE_REGISTRY_STATUSES = frozenset(
|
||||
{
|
||||
User.STATUS_GM,
|
||||
User.STATUS_GM_ADMIN,
|
||||
}
|
||||
)
|
||||
|
||||
# Can trigger city → HQ mirror sync.
|
||||
SYNC_STATUSES = frozenset(
|
||||
{
|
||||
User.STATUS_GM,
|
||||
User.STATUS_GM_ADMIN,
|
||||
}
|
||||
)
|
||||
|
||||
# Cycle close approval — GM only (explicitly not gm_admin).
|
||||
CYCLE_CLOSE_APPROVER_STATUSES = frozenset(
|
||||
{
|
||||
User.STATUS_GM,
|
||||
}
|
||||
)
|
||||
|
||||
# Product user-management role (IT Central). Django superuser remains break-glass.
|
||||
USER_ADMIN_STATUSES = frozenset(
|
||||
{
|
||||
User.STATUS_ACCOUNT_ADMIN,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _status(user) -> str | None:
|
||||
return getattr(user, "status", None) if user else None
|
||||
|
||||
|
||||
def executive_user(user) -> bool:
|
||||
return bool(
|
||||
user
|
||||
and getattr(user, "is_authenticated", False)
|
||||
and _status(user) in User.EXECUTIVE_STATUSES
|
||||
)
|
||||
|
||||
|
||||
def can_sync(user) -> bool:
|
||||
return bool(
|
||||
user
|
||||
and getattr(user, "is_authenticated", False)
|
||||
and _status(user) in SYNC_STATUSES
|
||||
)
|
||||
|
||||
|
||||
def can_register_city_site(user) -> bool:
|
||||
"""GM / GM Admin may add city dashboard URLs to the HQ registry."""
|
||||
return bool(
|
||||
user
|
||||
and getattr(user, "is_authenticated", False)
|
||||
and _status(user) in SITE_REGISTRY_STATUSES
|
||||
)
|
||||
|
||||
|
||||
def can_approve_cycle_close(user) -> bool:
|
||||
"""Only GM (not gm_admin) may approve closing a cycle."""
|
||||
return bool(
|
||||
user
|
||||
and getattr(user, "is_authenticated", False)
|
||||
and _status(user) in CYCLE_CLOSE_APPROVER_STATUSES
|
||||
)
|
||||
|
||||
|
||||
def can_write_operations(user) -> bool:
|
||||
"""No manual ops create/edit/delete for executive roles."""
|
||||
return False
|
||||
|
||||
|
||||
def can_manage_users(user) -> bool:
|
||||
"""Account Admin (IT Central) or Django superuser break-glass."""
|
||||
if not user or not getattr(user, "is_authenticated", False):
|
||||
return False
|
||||
if getattr(user, "is_superuser", False):
|
||||
return True
|
||||
return _status(user) in USER_ADMIN_STATUSES
|
||||
|
||||
|
||||
def can_manage_city_registry(user) -> bool:
|
||||
"""Alias used by city-site write endpoints — same as register permission."""
|
||||
return can_register_city_site(user) or bool(getattr(user, "is_superuser", False))
|
||||
|
||||
|
||||
class IsExecutive(permissions.BasePermission):
|
||||
def has_permission(self, request, view):
|
||||
return executive_user(request.user)
|
||||
|
||||
|
||||
class IsExecutiveAdmin(permissions.BasePermission):
|
||||
def has_permission(self, request, view):
|
||||
return can_manage_users(request.user)
|
||||
|
||||
|
||||
class CanRegisterCitySite(permissions.BasePermission):
|
||||
def has_permission(self, request, view):
|
||||
return can_register_city_site(request.user) or bool(
|
||||
getattr(request.user, "is_superuser", False)
|
||||
)
|
||||
|
||||
|
||||
class CanApproveCycleClose(permissions.BasePermission):
|
||||
def has_permission(self, request, view):
|
||||
return can_approve_cycle_close(request.user)
|
||||
|
||||
|
||||
class ReadOrSyncOnly(permissions.BasePermission):
|
||||
"""Safe methods for all executives; sync actions for GM / GM Admin only."""
|
||||
|
||||
def has_permission(self, request, view):
|
||||
if not executive_user(request.user):
|
||||
return False
|
||||
if request.method in permissions.SAFE_METHODS:
|
||||
return True
|
||||
if getattr(view, "executive_sync_action", False):
|
||||
return can_sync(request.user)
|
||||
return can_write_operations(request.user)
|
||||
@@ -0,0 +1,177 @@
|
||||
"""Visibility scoping for HQ executives.
|
||||
|
||||
- director / director_admin: all sites / city sites / GMs
|
||||
- buh / bu_admin: only GMs (and gm_admins) they manage, plus those GMs' sites
|
||||
- gm / gm_admin: only their own sites / city sites they registered
|
||||
|
||||
Farm Site rows are only visible to non-directors when linked to an active
|
||||
CitySite in the viewer's Pengaturan registry.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from django.db.models import QuerySet
|
||||
|
||||
from apps.accounts.models import User
|
||||
|
||||
DIRECTOR_STATUSES = frozenset(
|
||||
{
|
||||
User.STATUS_DIRECTOR,
|
||||
User.STATUS_DIRECTOR_ADMIN,
|
||||
User.STATUS_SUPERUSER,
|
||||
}
|
||||
)
|
||||
|
||||
BUH_STATUSES = frozenset(
|
||||
{
|
||||
User.STATUS_BUH,
|
||||
User.STATUS_BU_ADMIN,
|
||||
}
|
||||
)
|
||||
|
||||
GM_STATUSES = frozenset(
|
||||
{
|
||||
User.STATUS_GM,
|
||||
User.STATUS_GM_ADMIN,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def is_director_scope(user: User) -> bool:
|
||||
return bool(user and user.status in DIRECTOR_STATUSES)
|
||||
|
||||
|
||||
def is_buh_scope(user: User) -> bool:
|
||||
return bool(user and user.status in BUH_STATUSES)
|
||||
|
||||
|
||||
def is_gm_scope(user: User) -> bool:
|
||||
return bool(user and user.status in GM_STATUSES)
|
||||
|
||||
|
||||
def managed_gm_ids(buh: User) -> list[int]:
|
||||
"""User pks for GMs / GM Admins reporting to this BUH."""
|
||||
return list(
|
||||
User.objects.filter(
|
||||
managed_by=buh,
|
||||
status__in=GM_STATUSES,
|
||||
).values_list("pk", flat=True)
|
||||
)
|
||||
|
||||
|
||||
def visible_owner_ids(user: User) -> list[int] | None:
|
||||
"""
|
||||
Site.user / CitySite.registered_by ids this viewer may see.
|
||||
|
||||
Returns None when there is no restriction (director / superuser).
|
||||
"""
|
||||
if is_director_scope(user):
|
||||
return None
|
||||
if is_buh_scope(user):
|
||||
return managed_gm_ids(user)
|
||||
if is_gm_scope(user):
|
||||
return [user.pk]
|
||||
return []
|
||||
|
||||
|
||||
def visible_active_city_site_ids(user: User) -> list[int] | None:
|
||||
"""
|
||||
Active CitySite pks in this viewer's Pengaturan registry.
|
||||
|
||||
Returns None for directors (no restriction). Empty list means no farm sites.
|
||||
"""
|
||||
from apps.sync.models import CitySite
|
||||
|
||||
owners = visible_owner_ids(user)
|
||||
if owners is None:
|
||||
return None
|
||||
if not owners:
|
||||
return []
|
||||
return list(
|
||||
CitySite.objects.filter(
|
||||
registered_by_id__in=owners,
|
||||
is_active=True,
|
||||
).values_list("pk", flat=True)
|
||||
)
|
||||
|
||||
|
||||
def is_account_admin_scope(user: User) -> bool:
|
||||
return bool(user and user.status == User.STATUS_ACCOUNT_ADMIN)
|
||||
|
||||
|
||||
def filter_users_queryset(qs: QuerySet, viewer: User) -> QuerySet:
|
||||
"""BUH sees managed GMs; director / account_admin see product users.
|
||||
|
||||
Break-glass status=superuser is never listed in the user directory.
|
||||
"""
|
||||
qs = qs.exclude(status=User.STATUS_SUPERUSER)
|
||||
if is_director_scope(viewer) or is_account_admin_scope(viewer):
|
||||
return qs
|
||||
if is_buh_scope(viewer):
|
||||
return qs.filter(pk__in=managed_gm_ids(viewer))
|
||||
if is_gm_scope(viewer):
|
||||
return qs.filter(pk=viewer.pk)
|
||||
return qs.none()
|
||||
|
||||
|
||||
def filter_sites_queryset(qs: QuerySet, viewer: User) -> QuerySet:
|
||||
owners = visible_owner_ids(viewer)
|
||||
if owners is None:
|
||||
return qs
|
||||
if not owners:
|
||||
return qs.none()
|
||||
city_ids = visible_active_city_site_ids(viewer)
|
||||
if not city_ids:
|
||||
return qs.none()
|
||||
return qs.filter(user_id__in=owners, city_site_id__in=city_ids)
|
||||
|
||||
|
||||
def filter_city_sites_queryset(qs: QuerySet, viewer: User) -> QuerySet:
|
||||
owners = visible_owner_ids(viewer)
|
||||
if owners is None:
|
||||
return qs
|
||||
if not owners:
|
||||
return qs.none()
|
||||
return qs.filter(registered_by_id__in=owners)
|
||||
|
||||
|
||||
def filter_kandangs_queryset(qs: QuerySet, viewer: User) -> QuerySet:
|
||||
owners = visible_owner_ids(viewer)
|
||||
if owners is None:
|
||||
return qs
|
||||
if not owners:
|
||||
return qs.none()
|
||||
city_ids = visible_active_city_site_ids(viewer)
|
||||
if not city_ids:
|
||||
return qs.none()
|
||||
return qs.filter(site__user_id__in=owners, site__city_site_id__in=city_ids)
|
||||
|
||||
|
||||
def filter_flocks_queryset(qs: QuerySet, viewer: User) -> QuerySet:
|
||||
owners = visible_owner_ids(viewer)
|
||||
if owners is None:
|
||||
return qs
|
||||
if not owners:
|
||||
return qs.none()
|
||||
city_ids = visible_active_city_site_ids(viewer)
|
||||
if not city_ids:
|
||||
return qs.none()
|
||||
return qs.filter(
|
||||
kandang__site__user_id__in=owners,
|
||||
kandang__site__city_site_id__in=city_ids,
|
||||
)
|
||||
|
||||
|
||||
def filter_cycles_queryset(qs: QuerySet, viewer: User) -> QuerySet:
|
||||
owners = visible_owner_ids(viewer)
|
||||
if owners is None:
|
||||
return qs
|
||||
if not owners:
|
||||
return qs.none()
|
||||
city_ids = visible_active_city_site_ids(viewer)
|
||||
if not city_ids:
|
||||
return qs.none()
|
||||
return qs.filter(
|
||||
kandang__site__user_id__in=owners,
|
||||
kandang__site__city_site_id__in=city_ids,
|
||||
)
|
||||
@@ -0,0 +1,184 @@
|
||||
from django.contrib.auth import get_user_model
|
||||
from rest_framework import serializers
|
||||
|
||||
from apps.accounts.models import ApiKey
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class PkAsIdMixin(serializers.Serializer):
|
||||
"""Keep JSON `id` stable after ERD-named primary keys."""
|
||||
|
||||
id = serializers.IntegerField(source="pk", read_only=True)
|
||||
|
||||
|
||||
class UserSerializer(PkAsIdMixin, serializers.ModelSerializer):
|
||||
managed_by = serializers.PrimaryKeyRelatedField(
|
||||
queryset=User.objects.filter(status__in=["buh", "bu_admin"]),
|
||||
allow_null=True,
|
||||
required=False,
|
||||
)
|
||||
managed_by_name = serializers.CharField(
|
||||
source="managed_by.user_name", read_only=True, allow_null=True
|
||||
)
|
||||
managed_by_pending = serializers.PrimaryKeyRelatedField(read_only=True)
|
||||
managed_by_pending_name = serializers.CharField(
|
||||
source="managed_by_pending.user_name", read_only=True, allow_null=True
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
fields = [
|
||||
"id",
|
||||
"user_name",
|
||||
"status",
|
||||
"is_superuser",
|
||||
"managed_by",
|
||||
"managed_by_name",
|
||||
"managed_by_pending",
|
||||
"managed_by_pending_name",
|
||||
"managed_by_requested_at",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]
|
||||
read_only_fields = [
|
||||
"id",
|
||||
"is_superuser",
|
||||
"managed_by_name",
|
||||
"managed_by_pending",
|
||||
"managed_by_pending_name",
|
||||
"managed_by_requested_at",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]
|
||||
|
||||
|
||||
class UserWriteSerializer(PkAsIdMixin, serializers.ModelSerializer):
|
||||
"""Create/update users for Account Admin. Password required on create."""
|
||||
|
||||
password = serializers.CharField(write_only=True, required=False, allow_blank=False)
|
||||
managed_by = serializers.PrimaryKeyRelatedField(
|
||||
queryset=User.objects.filter(status__in=["buh", "bu_admin"]),
|
||||
allow_null=True,
|
||||
required=False,
|
||||
)
|
||||
managed_by_name = serializers.CharField(
|
||||
source="managed_by.user_name", read_only=True, allow_null=True
|
||||
)
|
||||
managed_by_pending = serializers.PrimaryKeyRelatedField(read_only=True)
|
||||
managed_by_pending_name = serializers.CharField(
|
||||
source="managed_by_pending.user_name", read_only=True, allow_null=True
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
fields = [
|
||||
"id",
|
||||
"user_name",
|
||||
"status",
|
||||
"is_superuser",
|
||||
"password",
|
||||
"managed_by",
|
||||
"managed_by_name",
|
||||
"managed_by_pending",
|
||||
"managed_by_pending_name",
|
||||
"managed_by_requested_at",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]
|
||||
read_only_fields = [
|
||||
"id",
|
||||
"is_superuser",
|
||||
"managed_by_name",
|
||||
"managed_by_pending",
|
||||
"managed_by_pending_name",
|
||||
"managed_by_requested_at",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]
|
||||
|
||||
def validate_password(self, value: str) -> str:
|
||||
if value is not None and not str(value).strip():
|
||||
raise serializers.ValidationError("Password cannot be blank.")
|
||||
return value
|
||||
|
||||
def validate(self, attrs):
|
||||
if self.instance is None and not attrs.get("password"):
|
||||
raise serializers.ValidationError({"password": "This field is required."})
|
||||
|
||||
new_status = attrs.get("status")
|
||||
if new_status == User.STATUS_SUPERUSER:
|
||||
raise serializers.ValidationError(
|
||||
{"status": "Superuser accounts cannot be created or assigned here."}
|
||||
)
|
||||
|
||||
request = self.context.get("request")
|
||||
actor = getattr(request, "user", None) if request else None
|
||||
instance = self.instance
|
||||
if (
|
||||
instance is not None
|
||||
and actor is not None
|
||||
and getattr(actor, "is_authenticated", False)
|
||||
and instance.pk == actor.pk
|
||||
and "status" in attrs
|
||||
and attrs["status"] != instance.status
|
||||
):
|
||||
if attrs["status"] == User.STATUS_INACTIVE:
|
||||
raise serializers.ValidationError(
|
||||
{"status": "You cannot deactivate your own account."}
|
||||
)
|
||||
raise serializers.ValidationError(
|
||||
{"status": "You cannot change your own account status."}
|
||||
)
|
||||
return attrs
|
||||
|
||||
def create(self, validated_data):
|
||||
password = validated_data.pop("password")
|
||||
user_name = validated_data.pop("user_name")
|
||||
return User.objects.create_user(
|
||||
user_name=user_name, password=password, **validated_data
|
||||
)
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
password = validated_data.pop("password", None)
|
||||
for attr, value in validated_data.items():
|
||||
setattr(instance, attr, value)
|
||||
if password:
|
||||
instance.set_password(password)
|
||||
instance.save()
|
||||
return instance
|
||||
|
||||
|
||||
class SetPasswordSerializer(serializers.Serializer):
|
||||
password = serializers.CharField(write_only=True, allow_blank=False)
|
||||
|
||||
def validate_password(self, value: str) -> str:
|
||||
if not str(value).strip():
|
||||
raise serializers.ValidationError("Password cannot be blank.")
|
||||
return value
|
||||
|
||||
|
||||
class LoginSerializer(serializers.Serializer):
|
||||
user_name = serializers.CharField(max_length=30)
|
||||
password = serializers.CharField(write_only=True)
|
||||
|
||||
|
||||
class ApiKeySerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = ApiKey
|
||||
fields = [
|
||||
"id",
|
||||
"name",
|
||||
"prefix",
|
||||
"is_active",
|
||||
"last_used_at",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"user",
|
||||
]
|
||||
read_only_fields = fields
|
||||
|
||||
|
||||
class ApiKeyCreateSerializer(serializers.Serializer):
|
||||
name = serializers.CharField(max_length=100)
|
||||
user_id = serializers.IntegerField(required=False)
|
||||
@@ -0,0 +1,227 @@
|
||||
from django.contrib.auth import authenticate
|
||||
from django.test import TestCase
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from apps.accounts.models import User
|
||||
|
||||
|
||||
class AccountAdminModelTests(TestCase):
|
||||
def test_account_admin_flags_and_executive(self):
|
||||
user = User.objects.create_user(
|
||||
user_name="acct1",
|
||||
password="secret",
|
||||
status=User.STATUS_ACCOUNT_ADMIN,
|
||||
)
|
||||
self.assertTrue(user.is_active)
|
||||
self.assertFalse(user.is_superuser)
|
||||
self.assertTrue(user.is_executive)
|
||||
|
||||
def test_inactive_clears_flags(self):
|
||||
user = User.objects.create_user(
|
||||
user_name="acct2",
|
||||
password="secret",
|
||||
status=User.STATUS_ACCOUNT_ADMIN,
|
||||
)
|
||||
user.status = User.STATUS_INACTIVE
|
||||
user.save()
|
||||
user.refresh_from_db()
|
||||
self.assertFalse(user.is_active)
|
||||
self.assertFalse(user.is_superuser)
|
||||
|
||||
def test_director_admin_is_not_superuser(self):
|
||||
user = User.objects.create_user(
|
||||
user_name="diradmin1",
|
||||
password="secret",
|
||||
status=User.STATUS_DIRECTOR_ADMIN,
|
||||
)
|
||||
self.assertEqual(user.status, User.STATUS_DIRECTOR_ADMIN)
|
||||
self.assertFalse(user.is_superuser)
|
||||
|
||||
|
||||
class AccountAdminApiTests(TestCase):
|
||||
def setUp(self):
|
||||
self.client = APIClient()
|
||||
self.account_admin = User.objects.create_user(
|
||||
user_name="acctadmin",
|
||||
password="acct123",
|
||||
status=User.STATUS_ACCOUNT_ADMIN,
|
||||
)
|
||||
self.gm = User.objects.create_user(
|
||||
user_name="gm1", password="gm123", status=User.STATUS_GM
|
||||
)
|
||||
self.buh = User.objects.create_user(
|
||||
user_name="buh1", password="buh123", status=User.STATUS_BUH
|
||||
)
|
||||
self.director = User.objects.create_user(
|
||||
user_name="dir1", password="dir123", status=User.STATUS_DIRECTOR
|
||||
)
|
||||
self.superuser = User.objects.create_superuser(
|
||||
user_name="admin1",
|
||||
password="admin123",
|
||||
)
|
||||
self.assertTrue(self.superuser.is_superuser)
|
||||
self.assertEqual(self.superuser.status, User.STATUS_SUPERUSER)
|
||||
|
||||
def test_account_admin_creates_user_with_password(self):
|
||||
self.client.force_authenticate(self.account_admin)
|
||||
response = self.client.post(
|
||||
"/api/v1/users/",
|
||||
{
|
||||
"user_name": "newgm",
|
||||
"status": User.STATUS_GM,
|
||||
"password": "temp-pass",
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(response.status_code, 201, response.data)
|
||||
created = User.objects.get(user_name="newgm")
|
||||
self.assertEqual(created.status, User.STATUS_GM)
|
||||
self.assertIsNotNone(
|
||||
authenticate(username="newgm", password="temp-pass")
|
||||
)
|
||||
|
||||
def test_account_admin_create_requires_password(self):
|
||||
self.client.force_authenticate(self.account_admin)
|
||||
response = self.client.post(
|
||||
"/api/v1/users/",
|
||||
{"user_name": "nopass", "status": User.STATUS_GM},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertIn("password", response.data)
|
||||
|
||||
def test_gm_cannot_create_user(self):
|
||||
self.client.force_authenticate(self.gm)
|
||||
response = self.client.post(
|
||||
"/api/v1/users/",
|
||||
{
|
||||
"user_name": "blocked",
|
||||
"status": User.STATUS_GM,
|
||||
"password": "x",
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
def test_account_admin_lists_all_users(self):
|
||||
self.client.force_authenticate(self.account_admin)
|
||||
response = self.client.get("/api/v1/users/")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
names = {row["user_name"] for row in response.data["results"]}
|
||||
self.assertIn("gm1", names)
|
||||
self.assertIn("buh1", names)
|
||||
self.assertIn("dir1", names)
|
||||
self.assertIn("acctadmin", names)
|
||||
|
||||
def test_account_admin_set_password(self):
|
||||
self.client.force_authenticate(self.account_admin)
|
||||
response = self.client.post(
|
||||
f"/api/v1/users/{self.gm.pk}/set-password/",
|
||||
{"password": "new-gm-pass"},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(response.status_code, 200, response.data)
|
||||
self.assertIsNotNone(
|
||||
authenticate(username="gm1", password="new-gm-pass")
|
||||
)
|
||||
self.assertIsNone(authenticate(username="gm1", password="gm123"))
|
||||
|
||||
def test_account_admin_deactivate_blocks_login(self):
|
||||
self.client.force_authenticate(self.account_admin)
|
||||
response = self.client.patch(
|
||||
f"/api/v1/users/{self.gm.pk}/",
|
||||
{"status": User.STATUS_INACTIVE},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(response.status_code, 200, response.data)
|
||||
self.gm.refresh_from_db()
|
||||
self.assertFalse(self.gm.is_active)
|
||||
self.assertIsNone(authenticate(username="gm1", password="gm123"))
|
||||
|
||||
def test_buh_and_director_cannot_set_password(self):
|
||||
for actor in (self.buh, self.director):
|
||||
self.client.force_authenticate(actor)
|
||||
response = self.client.post(
|
||||
f"/api/v1/users/{self.gm.pk}/set-password/",
|
||||
{"password": "hack"},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(response.status_code, 403, actor.user_name)
|
||||
|
||||
def test_superuser_break_glass_create(self):
|
||||
self.client.force_authenticate(self.superuser)
|
||||
response = self.client.post(
|
||||
"/api/v1/users/",
|
||||
{
|
||||
"user_name": "breakglass",
|
||||
"status": User.STATUS_BUH,
|
||||
"password": "bg-pass",
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(response.status_code, 201, response.data)
|
||||
|
||||
def test_account_admin_cannot_deactivate_self(self):
|
||||
self.client.force_authenticate(self.account_admin)
|
||||
response = self.client.patch(
|
||||
f"/api/v1/users/{self.account_admin.pk}/",
|
||||
{"status": User.STATUS_INACTIVE},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertIn("status", response.data)
|
||||
self.account_admin.refresh_from_db()
|
||||
self.assertEqual(self.account_admin.status, User.STATUS_ACCOUNT_ADMIN)
|
||||
self.assertTrue(self.account_admin.is_active)
|
||||
|
||||
def test_account_admin_cannot_change_own_status(self):
|
||||
self.client.force_authenticate(self.account_admin)
|
||||
response = self.client.patch(
|
||||
f"/api/v1/users/{self.account_admin.pk}/",
|
||||
{"status": User.STATUS_GM},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertIn("status", response.data)
|
||||
self.account_admin.refresh_from_db()
|
||||
self.assertEqual(self.account_admin.status, User.STATUS_ACCOUNT_ADMIN)
|
||||
|
||||
def test_peer_account_admin_can_deactivate_other_account_admin(self):
|
||||
peer = User.objects.create_user(
|
||||
user_name="acctadmin2",
|
||||
password="acct123",
|
||||
status=User.STATUS_ACCOUNT_ADMIN,
|
||||
)
|
||||
self.client.force_authenticate(self.account_admin)
|
||||
response = self.client.patch(
|
||||
f"/api/v1/users/{peer.pk}/",
|
||||
{"status": User.STATUS_INACTIVE},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(response.status_code, 200, response.data)
|
||||
peer.refresh_from_db()
|
||||
self.assertEqual(peer.status, User.STATUS_INACTIVE)
|
||||
self.assertFalse(peer.is_active)
|
||||
|
||||
def test_superuser_hidden_from_user_list(self):
|
||||
User.objects.create_superuser(user_name="hidden_su", password="x")
|
||||
self.client.force_authenticate(self.account_admin)
|
||||
response = self.client.get("/api/v1/users/")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
names = {row["user_name"] for row in response.data["results"]}
|
||||
self.assertNotIn("hidden_su", names)
|
||||
self.assertNotIn("admin1", names)
|
||||
|
||||
def test_cannot_assign_superuser_status_via_api(self):
|
||||
self.client.force_authenticate(self.account_admin)
|
||||
response = self.client.post(
|
||||
"/api/v1/users/",
|
||||
{
|
||||
"user_name": "bad_su",
|
||||
"status": User.STATUS_SUPERUSER,
|
||||
"password": "x",
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertIn("status", response.data)
|
||||
@@ -0,0 +1,99 @@
|
||||
from django.test import TestCase
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from apps.accounts.models import User
|
||||
|
||||
|
||||
class ManagedByRequestTests(TestCase):
|
||||
def setUp(self):
|
||||
self.client = APIClient()
|
||||
self.buh = User.objects.create_user(
|
||||
user_name="buh1", password="x", status=User.STATUS_BUH
|
||||
)
|
||||
self.buh_other = User.objects.create_user(
|
||||
user_name="buh2", password="x", status=User.STATUS_BUH
|
||||
)
|
||||
self.gm = User.objects.create_user(
|
||||
user_name="gm1", password="x", status=User.STATUS_GM
|
||||
)
|
||||
|
||||
def test_gm_requests_and_buh_approves(self):
|
||||
self.client.force_authenticate(self.gm)
|
||||
response = self.client.post(
|
||||
"/api/v1/users/request-managed-by/", {"buh_id": self.buh.pk}, format="json"
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.gm.refresh_from_db()
|
||||
self.assertIsNone(self.gm.managed_by_id)
|
||||
self.assertEqual(self.gm.managed_by_pending_id, self.buh.pk)
|
||||
|
||||
self.client.force_authenticate(self.buh)
|
||||
pending = self.client.get("/api/v1/users/pending-managed/")
|
||||
self.assertEqual(pending.status_code, 200)
|
||||
self.assertEqual({row["user_name"] for row in pending.data}, {"gm1"})
|
||||
|
||||
approve = self.client.post(f"/api/v1/users/{self.gm.pk}/approve-managed-by/", {})
|
||||
self.assertEqual(approve.status_code, 200)
|
||||
self.gm.refresh_from_db()
|
||||
self.assertEqual(self.gm.managed_by_id, self.buh.pk)
|
||||
self.assertIsNone(self.gm.managed_by_pending_id)
|
||||
|
||||
managed = self.client.get("/api/v1/users/")
|
||||
self.assertEqual({row["user_name"] for row in managed.data["results"]}, {"gm1"})
|
||||
|
||||
def test_other_buh_cannot_approve(self):
|
||||
self.gm.request_managed_by(self.buh)
|
||||
self.client.force_authenticate(self.buh_other)
|
||||
response = self.client.post(f"/api/v1/users/{self.gm.pk}/approve-managed-by/", {})
|
||||
self.assertEqual(response.status_code, 404)
|
||||
self.gm.refresh_from_db()
|
||||
self.assertIsNone(self.gm.managed_by_id)
|
||||
self.assertEqual(self.gm.managed_by_pending_id, self.buh.pk)
|
||||
|
||||
def test_gm_can_cancel_request(self):
|
||||
self.gm.request_managed_by(self.buh)
|
||||
self.client.force_authenticate(self.gm)
|
||||
response = self.client.post("/api/v1/users/cancel-managed-by-request/", {})
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.gm.refresh_from_db()
|
||||
self.assertIsNone(self.gm.managed_by_pending_id)
|
||||
|
||||
def test_gm_lists_buhs_only(self):
|
||||
User.objects.create_user(
|
||||
user_name="buadmin1", password="x", status=User.STATUS_BU_ADMIN
|
||||
)
|
||||
self.client.force_authenticate(self.gm)
|
||||
response = self.client.get("/api/v1/users/buhs/")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
names = {row["user_name"] for row in response.data}
|
||||
self.assertEqual(names, {"buh1", "buh2"})
|
||||
|
||||
def test_gm_admin_cannot_request_buh(self):
|
||||
gm_admin = User.objects.create_user(
|
||||
user_name="gma1", password="x", status=User.STATUS_GM_ADMIN
|
||||
)
|
||||
self.client.force_authenticate(gm_admin)
|
||||
response = self.client.post(
|
||||
"/api/v1/users/request-managed-by/", {"buh_id": self.buh.pk}, format="json"
|
||||
)
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
def test_gm_cannot_request_bu_admin(self):
|
||||
bu_admin = User.objects.create_user(
|
||||
user_name="buadmin1", password="x", status=User.STATUS_BU_ADMIN
|
||||
)
|
||||
self.client.force_authenticate(self.gm)
|
||||
response = self.client.post(
|
||||
"/api/v1/users/request-managed-by/", {"buh_id": bu_admin.pk}, format="json"
|
||||
)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
def test_bu_admin_cannot_approve(self):
|
||||
bu_admin = User.objects.create_user(
|
||||
user_name="buadmin1", password="x", status=User.STATUS_BU_ADMIN
|
||||
)
|
||||
self.gm.managed_by_pending = bu_admin
|
||||
self.gm.save(update_fields=["managed_by_pending", "updated_at"])
|
||||
self.client.force_authenticate(bu_admin)
|
||||
response = self.client.post(f"/api/v1/users/{self.gm.pk}/approve-managed-by/", {})
|
||||
self.assertEqual(response.status_code, 403)
|
||||
@@ -0,0 +1,118 @@
|
||||
from django.test import TestCase
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from apps.accounts.models import User
|
||||
from apps.farms.models import Kandang, Site
|
||||
from apps.sync.models import CitySite
|
||||
|
||||
|
||||
class VisibilityScopeTests(TestCase):
|
||||
def setUp(self):
|
||||
self.client = APIClient()
|
||||
self.director = User.objects.create_user(
|
||||
user_name="dir1", password="x", status=User.STATUS_DIRECTOR
|
||||
)
|
||||
self.buh = User.objects.create_user(
|
||||
user_name="buh1", password="x", status=User.STATUS_BUH
|
||||
)
|
||||
self.buh_other = User.objects.create_user(
|
||||
user_name="buh2", password="x", status=User.STATUS_BUH
|
||||
)
|
||||
self.gm = User.objects.create_user(
|
||||
user_name="gm1", password="x", status=User.STATUS_GM, managed_by=self.buh
|
||||
)
|
||||
self.gm_other = User.objects.create_user(
|
||||
user_name="gm2",
|
||||
password="x",
|
||||
status=User.STATUS_GM,
|
||||
managed_by=self.buh_other,
|
||||
)
|
||||
self.city_a = CitySite.objects.create(
|
||||
code="a",
|
||||
name="City A",
|
||||
api_base_url="https://a.example/api/v1",
|
||||
registered_by=self.gm,
|
||||
)
|
||||
self.city_b = CitySite.objects.create(
|
||||
code="b",
|
||||
name="City B",
|
||||
api_base_url="https://b.example/api/v1",
|
||||
registered_by=self.gm_other,
|
||||
)
|
||||
self.site = Site.objects.create(
|
||||
site_name="Site A", user=self.gm, city_site=self.city_a
|
||||
)
|
||||
self.site_other = Site.objects.create(
|
||||
site_name="Site B", user=self.gm_other, city_site=self.city_b
|
||||
)
|
||||
self.orphan = Site.objects.create(site_name="Orphan Seed", user=self.gm)
|
||||
Kandang.objects.create(kandang_name="K1", site=self.site)
|
||||
Kandang.objects.create(kandang_name="K2", site=self.site_other)
|
||||
|
||||
def test_director_sees_all_sites(self):
|
||||
self.client.force_authenticate(self.director)
|
||||
response = self.client.get("/api/v1/sites/")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
names = {row["site_name"] for row in response.data["results"]}
|
||||
self.assertEqual(names, {"Site A", "Site B", "Orphan Seed"})
|
||||
|
||||
def test_buh_sees_only_managed_gm_registered_sites(self):
|
||||
self.client.force_authenticate(self.buh)
|
||||
response = self.client.get("/api/v1/sites/")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
names = {row["site_name"] for row in response.data["results"]}
|
||||
self.assertEqual(names, {"Site A"})
|
||||
|
||||
def test_buh_sees_only_managed_gms(self):
|
||||
self.client.force_authenticate(self.buh)
|
||||
response = self.client.get("/api/v1/users/")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
names = {row["user_name"] for row in response.data["results"]}
|
||||
self.assertEqual(names, {"gm1"})
|
||||
|
||||
def test_gm_sees_own_city_sites_only(self):
|
||||
self.client.force_authenticate(self.gm)
|
||||
response = self.client.get("/api/v1/city-sites/")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
codes = {row["code"] for row in response.data["results"]}
|
||||
self.assertEqual(codes, {"a"})
|
||||
|
||||
def test_gm_sees_only_registered_linked_sites(self):
|
||||
self.client.force_authenticate(self.gm)
|
||||
response = self.client.get("/api/v1/sites/")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
names = {row["site_name"] for row in response.data["results"]}
|
||||
self.assertEqual(names, {"Site A"})
|
||||
|
||||
def test_gm_with_empty_registry_sees_no_sites(self):
|
||||
self.city_a.delete()
|
||||
self.client.force_authenticate(self.gm)
|
||||
response = self.client.get("/api/v1/sites/")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.data["results"], [])
|
||||
|
||||
def test_registering_city_site_attaches_matching_seed_site(self):
|
||||
self.client.force_authenticate(self.gm)
|
||||
response = self.client.post(
|
||||
"/api/v1/city-sites/",
|
||||
{
|
||||
"code": "orphan-seed",
|
||||
"name": "Orphan Seed",
|
||||
"api_base_url": "https://orphan.example/api/v1",
|
||||
"is_active": True,
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(response.status_code, 201)
|
||||
self.orphan.refresh_from_db()
|
||||
self.assertEqual(self.orphan.city_site_id, response.data["id"])
|
||||
listed = self.client.get("/api/v1/sites/")
|
||||
names = {row["site_name"] for row in listed.data["results"]}
|
||||
self.assertIn("Orphan Seed", names)
|
||||
|
||||
def test_director_sees_all_city_sites(self):
|
||||
self.client.force_authenticate(self.director)
|
||||
response = self.client.get("/api/v1/city-sites/")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
codes = {row["code"] for row in response.data["results"]}
|
||||
self.assertEqual(codes, {"a", "b"})
|
||||
@@ -0,0 +1,23 @@
|
||||
from django.urls import path
|
||||
from rest_framework.routers import DefaultRouter
|
||||
|
||||
from apps.accounts.views import (
|
||||
ApiKeyViewSet,
|
||||
CsrfView,
|
||||
LoginView,
|
||||
LogoutView,
|
||||
MeView,
|
||||
UserViewSet,
|
||||
)
|
||||
|
||||
router = DefaultRouter()
|
||||
router.register("users", UserViewSet, basename="user")
|
||||
router.register("api-keys", ApiKeyViewSet, basename="api-key")
|
||||
|
||||
urlpatterns = [
|
||||
path("auth/csrf/", CsrfView.as_view(), name="auth-csrf"),
|
||||
path("auth/login/", LoginView.as_view(), name="auth-login"),
|
||||
path("auth/logout/", LogoutView.as_view(), name="auth-logout"),
|
||||
path("auth/me/", MeView.as_view(), name="auth-me"),
|
||||
*router.urls,
|
||||
]
|
||||
@@ -0,0 +1,258 @@
|
||||
from django.contrib.auth import authenticate, login, logout
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.middleware.csrf import get_token
|
||||
from django.views.decorators.csrf import ensure_csrf_cookie
|
||||
from django.utils.decorators import method_decorator
|
||||
from rest_framework import permissions, status, viewsets
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from apps.accounts.models import ApiKey, User
|
||||
from apps.accounts.permissions import IsExecutive
|
||||
from apps.accounts.scoping import filter_users_queryset
|
||||
from apps.accounts.serializers import (
|
||||
ApiKeyCreateSerializer,
|
||||
ApiKeySerializer,
|
||||
LoginSerializer,
|
||||
SetPasswordSerializer,
|
||||
UserSerializer,
|
||||
UserWriteSerializer,
|
||||
)
|
||||
|
||||
|
||||
def _validation_detail(exc: ValidationError):
|
||||
if hasattr(exc, "message_dict"):
|
||||
return exc.message_dict
|
||||
if hasattr(exc, "messages"):
|
||||
return "; ".join(str(m) for m in exc.messages)
|
||||
return str(getattr(exc, "message", exc))
|
||||
|
||||
|
||||
class IsOwnerOrStaff(permissions.BasePermission):
|
||||
def has_object_permission(self, request, view, obj):
|
||||
if request.user.is_superuser:
|
||||
return True
|
||||
return getattr(obj, "user_id", None) == request.user.pk or obj == request.user
|
||||
|
||||
|
||||
class IsAdminUser(permissions.BasePermission):
|
||||
"""Write access for Account Admin or Django superuser break-glass."""
|
||||
|
||||
def has_permission(self, request, view):
|
||||
from apps.accounts.permissions import can_manage_users
|
||||
|
||||
return can_manage_users(request.user)
|
||||
|
||||
|
||||
class UserViewSet(viewsets.ModelViewSet):
|
||||
"""
|
||||
List/retrieve: scoped for executives (BUH → managed GMs; director / account_admin → all).
|
||||
Create/update/delete/set-password: Account Admin or superuser.
|
||||
|
||||
GM/GM Admin can request a BUH; that BUH can approve/reject.
|
||||
"""
|
||||
|
||||
serializer_class = UserSerializer
|
||||
|
||||
def get_queryset(self):
|
||||
qs = User.objects.select_related("managed_by", "managed_by_pending").order_by(
|
||||
"user_name"
|
||||
)
|
||||
return filter_users_queryset(qs, self.request.user)
|
||||
|
||||
def get_serializer_class(self):
|
||||
if self.action in {"create", "update", "partial_update"}:
|
||||
return UserWriteSerializer
|
||||
return UserSerializer
|
||||
|
||||
def get_permissions(self):
|
||||
if self.action in {
|
||||
"list",
|
||||
"retrieve",
|
||||
"buhs",
|
||||
"pending_managed",
|
||||
"request_managed_by",
|
||||
"cancel_managed_by_request",
|
||||
"approve_managed_by",
|
||||
"reject_managed_by",
|
||||
}:
|
||||
return [IsExecutive()]
|
||||
return [IsAdminUser()]
|
||||
|
||||
@action(detail=True, methods=["post"], url_path="set-password")
|
||||
def set_password(self, request, pk=None):
|
||||
user = self.get_object()
|
||||
serializer = SetPasswordSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
user.set_password(serializer.validated_data["password"])
|
||||
user.save(update_fields=["password", "updated_at"])
|
||||
return Response(UserSerializer(user).data)
|
||||
|
||||
@action(detail=False, methods=["get"], url_path="buhs")
|
||||
def buhs(self, request):
|
||||
"""List BUH accounts a GM can request to report under (not BU Admin)."""
|
||||
if request.user.status != User.STATUS_GM and not request.user.is_superuser:
|
||||
if request.user.status not in {
|
||||
User.STATUS_DIRECTOR,
|
||||
User.STATUS_DIRECTOR_ADMIN,
|
||||
User.STATUS_BUH,
|
||||
User.STATUS_BU_ADMIN,
|
||||
}:
|
||||
return Response({"detail": "Not allowed."}, status=status.HTTP_403_FORBIDDEN)
|
||||
rows = User.objects.filter(
|
||||
status=User.STATUS_BUH,
|
||||
is_active=True,
|
||||
).order_by("user_name")
|
||||
return Response(UserSerializer(rows, many=True).data)
|
||||
|
||||
@action(detail=False, methods=["get"], url_path="pending-managed")
|
||||
def pending_managed(self, request):
|
||||
"""BUH only: GMs who requested to report under this account."""
|
||||
if request.user.status != User.STATUS_BUH:
|
||||
return Response({"detail": "Not allowed."}, status=status.HTTP_403_FORBIDDEN)
|
||||
rows = User.objects.filter(
|
||||
managed_by_pending=request.user,
|
||||
status=User.STATUS_GM,
|
||||
).select_related("managed_by", "managed_by_pending").order_by("user_name")
|
||||
return Response(UserSerializer(rows, many=True).data)
|
||||
|
||||
@action(detail=False, methods=["post"], url_path="request-managed-by")
|
||||
def request_managed_by(self, request):
|
||||
"""GM only: request to report under a BUH (not BU Admin)."""
|
||||
if request.user.status != User.STATUS_GM:
|
||||
return Response({"detail": "Not allowed."}, status=status.HTTP_403_FORBIDDEN)
|
||||
buh_id = request.data.get("buh_id")
|
||||
try:
|
||||
buh = User.objects.get(
|
||||
pk=buh_id,
|
||||
status=User.STATUS_BUH,
|
||||
is_active=True,
|
||||
)
|
||||
except (User.DoesNotExist, TypeError, ValueError):
|
||||
return Response({"detail": "Invalid buh_id."}, status=status.HTTP_400_BAD_REQUEST)
|
||||
try:
|
||||
request.user.request_managed_by(buh)
|
||||
except ValidationError as exc:
|
||||
return Response({"detail": _validation_detail(exc)}, status=status.HTTP_400_BAD_REQUEST)
|
||||
return Response(UserSerializer(request.user).data)
|
||||
|
||||
@action(detail=False, methods=["post"], url_path="cancel-managed-by-request")
|
||||
def cancel_managed_by_request(self, request):
|
||||
if request.user.status != User.STATUS_GM:
|
||||
return Response({"detail": "Not allowed."}, status=status.HTTP_403_FORBIDDEN)
|
||||
request.user.cancel_managed_by_request()
|
||||
return Response(UserSerializer(request.user).data)
|
||||
|
||||
@action(detail=True, methods=["post"], url_path="approve-managed-by")
|
||||
def approve_managed_by(self, request, pk=None):
|
||||
if request.user.status != User.STATUS_BUH:
|
||||
return Response({"detail": "Not allowed."}, status=status.HTTP_403_FORBIDDEN)
|
||||
gm = User.objects.filter(
|
||||
pk=pk,
|
||||
managed_by_pending=request.user,
|
||||
status=User.STATUS_GM,
|
||||
).first()
|
||||
if gm is None:
|
||||
return Response({"detail": "Not found."}, status=status.HTTP_404_NOT_FOUND)
|
||||
try:
|
||||
gm.approve_managed_by(request.user)
|
||||
except ValidationError as exc:
|
||||
return Response({"detail": _validation_detail(exc)}, status=status.HTTP_400_BAD_REQUEST)
|
||||
return Response(UserSerializer(gm).data)
|
||||
|
||||
@action(detail=True, methods=["post"], url_path="reject-managed-by")
|
||||
def reject_managed_by(self, request, pk=None):
|
||||
if request.user.status != User.STATUS_BUH:
|
||||
return Response({"detail": "Not allowed."}, status=status.HTTP_403_FORBIDDEN)
|
||||
gm = User.objects.filter(
|
||||
pk=pk,
|
||||
managed_by_pending=request.user,
|
||||
status=User.STATUS_GM,
|
||||
).first()
|
||||
if gm is None:
|
||||
return Response({"detail": "Not found."}, status=status.HTTP_404_NOT_FOUND)
|
||||
try:
|
||||
gm.reject_managed_by(request.user)
|
||||
except ValidationError as exc:
|
||||
return Response({"detail": _validation_detail(exc)}, status=status.HTTP_400_BAD_REQUEST)
|
||||
return Response(UserSerializer(gm).data)
|
||||
|
||||
|
||||
class ApiKeyViewSet(viewsets.ModelViewSet):
|
||||
http_method_names = ["get", "post", "delete", "head", "options"]
|
||||
|
||||
def get_queryset(self):
|
||||
qs = ApiKey.objects.select_related("user").order_by("-created_at")
|
||||
if self.request.user.is_superuser:
|
||||
return qs
|
||||
return qs.filter(user=self.request.user)
|
||||
|
||||
def get_serializer_class(self):
|
||||
if self.action == "create":
|
||||
return ApiKeyCreateSerializer
|
||||
return ApiKeySerializer
|
||||
|
||||
def get_permissions(self):
|
||||
return [permissions.IsAuthenticated()]
|
||||
|
||||
def create(self, request, *args, **kwargs):
|
||||
serializer = self.get_serializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
name = serializer.validated_data["name"]
|
||||
user = request.user
|
||||
if request.user.is_superuser and serializer.validated_data.get("user_id"):
|
||||
user = User.objects.get(pk=serializer.validated_data["user_id"])
|
||||
api_key, raw = ApiKey.generate(user=user, name=name)
|
||||
data = ApiKeySerializer(api_key).data
|
||||
data["raw_key"] = raw
|
||||
return Response(data, status=status.HTTP_201_CREATED)
|
||||
|
||||
def perform_destroy(self, instance):
|
||||
if (
|
||||
not self.request.user.is_superuser
|
||||
and instance.user_id != self.request.user.pk
|
||||
):
|
||||
raise permissions.PermissionDenied()
|
||||
instance.delete()
|
||||
|
||||
|
||||
@method_decorator(ensure_csrf_cookie, name="dispatch")
|
||||
class CsrfView(APIView):
|
||||
permission_classes = [permissions.AllowAny]
|
||||
authentication_classes = []
|
||||
|
||||
def get(self, request):
|
||||
return Response({"csrfToken": get_token(request)})
|
||||
|
||||
|
||||
class LoginView(APIView):
|
||||
permission_classes = [permissions.AllowAny]
|
||||
authentication_classes = []
|
||||
|
||||
def post(self, request):
|
||||
serializer = LoginSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
user = authenticate(
|
||||
request,
|
||||
username=serializer.validated_data["user_name"],
|
||||
password=serializer.validated_data["password"],
|
||||
)
|
||||
if user is None:
|
||||
return Response(
|
||||
{"detail": "Invalid credentials"},
|
||||
status=status.HTTP_401_UNAUTHORIZED,
|
||||
)
|
||||
login(request, user)
|
||||
return Response(UserSerializer(user).data)
|
||||
|
||||
|
||||
class LogoutView(APIView):
|
||||
def post(self, request):
|
||||
logout(request)
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
class MeView(APIView):
|
||||
def get(self, request):
|
||||
return Response(UserSerializer(request.user).data)
|
||||
Whitespace-only changes.
@@ -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.
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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,
|
||||
}
|
||||
)
|
||||
Whitespace-only changes.
@@ -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)"))
|
||||
@@ -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
|
||||
@@ -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")
|
||||
Whitespace-only changes.
@@ -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.
@@ -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")
|
||||
]
|
||||
@@ -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
Reference in new issue
Block a user