Files
kwh-fln/dashboard/views.py
2026-10-01 15:50:43 +07:00

1346 lines
50 KiB
Python

import json
# TAMBAHKAN get_object_or_404 di baris ini
from django.shortcuts import render, get_object_or_404
from django.http import JsonResponse
from django.views.decorators.csrf import csrf_exempt
from django.utils import timezone
from django.db.models.functions import TruncHour, TruncDay, TruncMonth
from django.db.models import Max, Min, Avg, Sum
from .models import KwhReadings, Mesin
# TAMBAHKAN baris import ini untuk menangani dekorator @require_POST
from django.views.decorators.http import require_POST
# TAMBAHKAN baris import ini untuk Export excel
import openpyxl
from openpyxl.styles import Font, Alignment, PatternFill, Border, Side
from django.http import HttpResponse
from .models import Mesin, Area
import uuid
import os # Ditambahkan untuk membaca file path lokal di server
from django.conf import settings # Ditambahkan untuk mencari jalur folder static
from openpyxl.drawing.image import Image as OpenpyxlImage # Modul untuk menyisipkan gambar
# TAMBAHKAN baris import ini untuk date custom
from django.utils.dateparse import parse_datetime
# Import pdf
from django.shortcuts import render
# Halaman pertama aksses ke Login
from django.contrib.auth.decorators import login_required
from django.shortcuts import render
# 1. View untuk menampilkan halaman HTML Log Data
def log_kwh_view(request):
return render(request, 'dashboard/log_kwh.html')
def api_log_data(request):
period = request.GET.get('period', 'hourly')
start_date_str = request.GET.get('start_date')
end_date_str = request.GET.get('end_date')
# 1. Ambil parameter mesin_id dari URL
mesin_id = request.GET.get('mesin_id', None)
queryset = KwhReadings.objects.all()
# 2. Filter data berdasarkan mesin_id jika user memilih salah satu mesin secara spesifik
if mesin_id and mesin_id.strip() != '' and mesin_id != 'all' and mesin_id != 'undefined':
queryset = queryset.filter(mesin_id=mesin_id)
use_max_diff_logic = False # Penanda apakah perlu pakai logika selisih antar periode
# ─── LOGIKA CUSTOM DATE YANG FLEKSIBEL (Tetap Utuh) ───
if period == 'custom' and start_date_str and end_date_str:
start_date = parse_datetime(start_date_str)
end_date = parse_datetime(end_date_str)
if start_date and end_date:
queryset = queryset.filter(timestamp__range=(start_date, end_date))
selisih_waktu = end_date - start_date
total_hari = selisih_waktu.days
if total_hari > 31:
trunc_func = TruncMonth('timestamp')
date_format = '%b %Y'
elif total_hari >= 2:
trunc_func = TruncDay('timestamp')
date_format = '%d %b %Y'
else:
trunc_func = TruncHour('timestamp')
date_format = '%H:%M (%d %b)'
use_max_diff_logic = True # Custom rentang jam butuh logika ini
limit = None
# ─── KONDISI DEFAULT (TOMBOL NAVIGASI - Tetap Utuh) ───
elif period == 'daily':
trunc_func = TruncDay('timestamp')
date_format = '%d %b %Y'
limit = 30
elif period == 'monthly':
trunc_func = TruncMonth('timestamp')
date_format = '%b %Y'
limit = 12
else: # Default 'hourly'
trunc_func = TruncHour('timestamp')
date_format = '%H:%M (%d %b)'
limit = 24
use_max_diff_logic = True # Mode per jam wajib menggunakan logika ini
# ─── PROSES AGREGASI DATABASE ───
logs_query = (
queryset
.annotate(period_label=trunc_func)
.values('period_label')
.annotate(
max_val=Max('active_power_kwh'),
min_val=Min('active_power_kwh')
)
)
# 💡 PINTAR: Semua mode yang memiliki limit (hourly, daily, monthly)
# sekarang mengambil 1 data ekstra ke belakang untuk acuan nilai sebelumnya.
if limit:
logs = logs_query.order_by('-period_label')[:limit + 1]
logs = list(logs)
logs.reverse() # Urutkan maju kronologis (Past lama -> Baru) SEBELUM dihitung
else:
logs = logs_query.order_by('period_label')
labels = []
values = []
prev_max_kwh = None
for entry in logs:
if entry['period_label']:
max_kwh = entry['max_val'] or 0
min_kwh = entry['min_val'] or 0
# 💡 ANTI-MENGUAP: Berlaku untuk per jam, per hari, per bulan, dan kustom rentang jam
if use_max_diff_logic or period in ['daily', 'monthly']:
if prev_max_kwh is None:
# Baris paling pertama/tertua di grafik menggunakan selisih internalnya sendiri
konsumsi_asli = max_kwh - min_kwh
else:
# Baris berikutnya selalu dikurangi nilai MAX periode sebelumnya
konsumsi_asli = max_kwh - prev_max_kwh
# Pengaman jika kWh meter fisik diganti baru atau di-reset ke 0
if konsumsi_asli < 0:
konsumsi_asli = max_kwh - min_kwh
else:
# Fallback untuk kustom range yang sangat panjang (>2 hari)
konsumsi_asli = max_kwh - min_kwh
if konsumsi_asli < 0:
konsumsi_asli = max_kwh
labels.append(entry['period_label'].strftime(date_format))
values.append(round(konsumsi_asli, 4))
# Kunci utama: Simpan Max periode ini untuk pengurang periode selanjutnya
prev_max_kwh = max_kwh
# Potong data ekstra indeks pertama agar total batang grafik pas sesuai limit asli (24, 30, atau 12)
if limit and len(values) > limit:
labels = labels[1:]
values = values[1:]
# ─── FITUR BARU: HITUNG JUMLAH TOTAL AKUMULASI KONSUMSI GRAFIK ───
total_sum = sum(values)
# 3. KONTROL PENCEGAHAN GRAFIK KOSONG
if not labels:
return JsonResponse({'labels': ['Belum Ada Data'], 'values': [], 'total_sum': 0})
# Kembalikan response lengkap termasuk total_sum baru ke frontend
return JsonResponse({
'labels': labels,
'values': values,
'total_sum': round(total_sum, 4)
})
# 2. Karena data diambil dari yang terbaru (mundur), kita balik (reverse)
# agar di grafik Chart.js tampilannya berurutan maju (kronologis)
logs = list(logs)
logs.reverse()
labels = []
values = []
for entry in logs:
if entry['period_label']:
labels.append(entry['period_label'].strftime(date_format))
# Pengaman jika nilai Max atau Min None
max_kwh = entry['max_val'] or 0
min_kwh = entry['min_val'] or 0
# Logika dasar Max - Min
konsumsi_asli = max_kwh - min_kwh
# ANTISIPASI RESET: Jika kWh sempat di-reset ke 0 oleh ESP32
# (Gunakan ini hanya jika ESP32 Anda sering reset/mati)
if konsumsi_asli < 0:
konsumsi_asli = max_kwh # Mengasumsikan nilai setelah reset dimulai dari 0
values.append(round(konsumsi_asli, 4))
return JsonResponse({'labels': labels, 'values': values})
# 3. View untuk menampilkan halaman HTML Dashboard Utama
def dashboard_view(request):
# Mengambil semua daftar mesin untuk dikirim ke base.html (jika belum menggunakan context processor)
mesin_global_list = Mesin.objects.select_related('area').all()
context = {
'mesin_global_list': mesin_global_list
}
return render(request, 'dashboard/index.html', context)
# 4. View API untuk menampilkan 144 data realtime terakhir di halaman depan (Terfilter) 24 jam terakhir
def api_latest_data(request):
# 1. Tangkap parameter 'mesin_id' dari JavaScript fetch melalui URL
mesin_id = request.GET.get('mesin_id', None)
# 2. Ambil 144 data TERBARU berdasarkan ID terbesar (-id) agar data yang mati berjam-jam tidak terlewat
if mesin_id and mesin_id.strip() != '' and mesin_id != 'all':
# Jika memilih mesin tertentu, ambil 144 data terakhir milik mesin tersebut
raw_data = KwhReadings.objects.filter(mesin_id=mesin_id).order_by('-id')[:144]
else:
# Jika global, ambil 144 data terakhir campuran secara default
raw_data = KwhReadings.objects.all().order_by('-id')[:144]
# 3. BALIKKAN urutan list data (dari Desending ke Ascending)
# Ini WAJIB agar data yang paling baru posisinya bergeser ke sisi paling kanan grafik Chart.js
data_list = list(reversed(raw_data))
# Bungkus data ke dalam list kosong (Sama persis seperti struktur asli Anda)
timestamps = []
apparent_power = []
total_current = []
power_factor = []
active_power = []
active_power_kwh = []
# Iterasi data untuk dimasukkan ke struktur JSON grafik
for item in data_list:
# Konversi waktu ke jam lokal agar tampilan teks di sumbu X grafik sesuai WIB
lokal_time = timezone.localtime(item.timestamp) if timezone.is_aware(item.timestamp) else item.timestamp
timestamps.append(lokal_time.strftime('%d %b %H:%M'))
apparent_power.append(item.apparent_power_va)
total_current.append(item.total_current_a)
power_factor.append(item.power_factor)
active_power.append(round(item.real_active_power_kw, 4))
active_power_kwh.append(round(item.active_power_kwh, 4))
# Kembalikan response berupa format JSON terstruktur ke frontend
return JsonResponse({
'timestamps': timestamps,
'apparent_power': apparent_power,
'total_current': total_current,
'power_factor': power_factor,
'active_power': active_power,
'active_power_kwh': active_power_kwh
})
# 5. Endpoint penerima kiriman data dari ESP32 (SINKRON 100%)
@csrf_exempt
def receive_kwh_data(request):
if request.method == 'POST':
# 1. Ambil token keamanan dari HTTP Header
token_diterima = request.META.get('HTTP_X_ESP32_TOKEN')
if not token_diterima:
return JsonResponse({'status': 'error', 'message': 'Token keamanan tidak ditemukan!'}, status=401)
try:
data = json.loads(request.body)
# 2. Ambil nilai device_id dari JSON payload ESP32
device_id_diterima = data.get('device_id')
if not device_id_diterima:
return JsonResponse({'status': 'error', 'message': 'JSON payload harus menyertakan device_id'}, status=400)
# 3. VALIDASI GANDA: Cocokkan Token AND Nama Mesin di Database
mesin_terdaftar = Mesin.objects.filter(
token=token_diterima,
nama_mesin=device_id_diterima
).first()
# Jika kombinasi token dan nama tidak cocok, langsung tolak
if not mesin_terdaftar:
return JsonResponse({
'status': 'error',
'message': 'Validasi Gagal! Kombinasi Token dan Nama Mesin tidak cocok atau tidak terdaftar.'
}, status=403)
# 4. Ambil data sensor lainnya
active_power_kwh = float(data.get('active_power_kwh', 0))
apparent_power_va = float(data.get('apparent_power_va', 0))
power_factor = float(data.get('power_factor', 0))
total_current_a = float(data.get('total_current_a', 0))
# Hitung Nilai Daya Aktif Sesaat (kW)
real_active_power_kw = (apparent_power_va * power_factor) / 1000.0
# 5. Simpan ke database dengan menempelkan objek mesin yang sudah divalidasi
KwhReadings.objects.create(
mesin=mesin_terdaftar, # Mengisi kolom ForeignKey dengan ID Mesin secara otomatis
apparent_power_va=apparent_power_va,
total_current_a=total_current_a,
power_factor=power_factor,
active_power_kwh=active_power_kwh,
real_active_power_kw=real_active_power_kw
)
return JsonResponse({
'status': 'success',
'message': f'Data untuk mesin {mesin_terdaftar.nama_mesin} sukses disimpan ke database'
}, status=201)
except Exception as e:
print("\n❌ [ERROR DATABASE DJANGO]:", str(e), "\n")
return JsonResponse({'status': 'error', 'message': str(e)}, status=400)
return JsonResponse({'status': 'invalid method'}, status=405)
# Export Excel KWH
def export_kwh_excel(request):
# 1. Ambil parameter dari request
period = request.GET.get('period', 'hourly')
start_date_str = request.GET.get('start_date')
end_date_str = request.GET.get('end_date')
mesin_id = request.GET.get('mesin_id', None)
queryset = KwhReadings.objects.all()
limit = None
# Ambil Informasi Lengkap Mesin & Area
nama_mesin_info = "Semua Unit Mesin"
area_info = "-"
keterangan_info = "-"
if mesin_id and mesin_id.strip() != '' and mesin_id != 'all' and mesin_id != 'undefined':
queryset = queryset.filter(mesin_id=mesin_id)
try:
mesin_obj = Mesin.objects.select_related('area').get(id=mesin_id)
nama_mesin_info = mesin_obj.nama_mesin
keterangan_info = mesin_obj.keterangan or "-"
if mesin_obj.area:
area_info = mesin_obj.area.nama_area
except Mesin.DoesNotExist:
pass
# 2. Logika Filter & Agregasi Sesuai Grafik (Hourly, Daily, Monthly, Custom)
if period == 'custom' and start_date_str and end_date_str:
start_date = parse_datetime(start_date_str)
end_date = parse_datetime(end_date_str)
if start_date and end_date:
queryset = queryset.filter(timestamp__range=(start_date, end_date))
selisih_waktu = end_date - start_date
total_hari = selisih_waktu.days
if total_hari > 31:
trunc_func = TruncMonth('timestamp')
date_format = '%d-%m-%Y'
title_period = "Kustom Bulanan"
elif total_hari >= 2:
trunc_func = TruncDay('timestamp')
date_format = '%d-%m-%Y'
title_period = "Kustom Harian"
else:
trunc_func = TruncHour('timestamp')
date_format = '%d-%m-%Y %H:00'
title_period = "Kustom Per Jam"
elif period == 'daily':
trunc_func = TruncDay('timestamp')
date_format = '%d-%m-%Y'
title_period = "Harian (30 Data Terakhir)"
limit = 30
elif period == 'monthly':
trunc_func = TruncMonth('timestamp')
date_format = '%m-%Y'
title_period = "Bulanan (12 Data Terakhir)"
limit = 12
else:
trunc_func = TruncHour('timestamp')
date_format = '%d-%m-%Y %H:00'
title_period = "Per Jam (24 Data Terakhir)"
limit = 24
# 3. Proses Agregasi Database
logs_query = (
queryset
.annotate(period_label=trunc_func)
.values('period_label')
.annotate(
max_val=Max('active_power_kwh'),
min_val=Min('active_power_kwh')
)
)
if limit:
logs = logs_query.order_by('-period_label')[:limit]
logs = list(logs)
logs.reverse()
else:
logs = logs_query.order_by('period_label')
# 4. Inisialisasi Workbook Excel & Styling
wb = openpyxl.Workbook()
ws = wb.active
ws.title = "Report kWh"
# Font & Style Setup
title_font = Font(name='Calibri', size=14, bold=True, color='1F2937')
company_font = Font(name='Calibri', size=11, bold=True, color='4B5563')
info_font_bold = Font(name='Calibri', size=11, bold=True)
info_font_regular = Font(name='Calibri', size=11, bold=False)
header_font = Font(name='Calibri', size=11, bold=True, color='FFFFFF')
header_fill = PatternFill(start_color='1F2937', end_color='1F2937', fill_type='solid')
align_center = Alignment(horizontal='center', vertical='center')
align_left = Alignment(horizontal='left', vertical='center')
border_thin = Border(
left=Side(style='thin', color='D1D5DB'), right=Side(style='thin', color='D1D5DB'),
top=Side(style='thin', color='D1D5DB'), bottom=Side(style='thin', color='D1D5DB')
)
# ─── FITUR LOGO PERUSAHAAN DI CELL A1 ───
logo_path = os.path.join(settings.BASE_DIR, 'dashboard', 'static', 'images', 'FLN.png')
if os.path.exists(logo_path):
img = OpenpyxlImage(logo_path)
target_height = 55
target_width = int((img.width / img.height) * target_height)
img.width = target_width
img.height = target_height
ws.add_image(img, 'A1')
ws.row_dimensions[1].height = 25
ws.row_dimensions[2].height = 20
ws.row_dimensions[3].height = 15
# ─── TEKS JUDUL KOLOM B ───
ws['B1'] = "Sistem Informasi Monitoring Energi"
ws['B1'].font = title_font
ws['B2'] = "PT. Frina Lestari Nusantara"
ws['B2'].font = company_font
# ─── BLOK INFORMASI MESIN BARIS 5 - 7 ───
ws['A5'] = "Nama Mesin:"
ws['B5'] = nama_mesin_info
ws['A6'] = "Area/Lokasi:"
ws['B6'] = area_info
ws['A7'] = "Keterangan:"
ws['B7'] = keterangan_info
for r in range(5, 8):
ws.cell(row=r, column=1).font = info_font_bold
ws.cell(row=r, column=2).font = info_font_regular
ws.cell(row=r, column=1).alignment = align_left
ws.cell(row=r, column=2).alignment = align_left
# 5. ─── TABEL UTAMA PADA BARIS KE-9 ───
headers = ['No', f'Periode ({title_period})', 'Min kWh', 'Max kWh', 'Total Konsumsi (kWh)']
for col_num, header in enumerate(headers, 1):
cell = ws.cell(row=9, column=col_num)
cell.value = header
cell.font = header_font
cell.fill = header_fill
cell.alignment = align_center
# 6. ─── ISI DATA ROW MULAI DARI BARIS KE-10 ───
row_num = 10
total_excel_kwh = 0
prev_max_kwh = None # Pembantu kalkulasi rentang antar jam
# Ambil logs yang sudah diurutkan dari database
for idx, entry in enumerate(logs, 1):
if entry['period_label']:
min_kwh = entry['min_val'] or 0
max_kwh = entry['max_val'] or 0
if prev_max_kwh is None:
konsumsi_asli = max_kwh - min_kwh
else:
konsumsi_asli = max_kwh - prev_max_kwh
if konsumsi_asli < 0:
konsumsi_asli = max_kwh - min_kwh
date_str = entry['period_label'].strftime(date_format)
total_excel_kwh += konsumsi_asli
row = [idx, date_str, round(min_kwh, 4), round(max_kwh, 4), round(konsumsi_asli, 4)]
for col_num, value in enumerate(row, 1):
cell = ws.cell(row=row_num, column=col_num)
cell.value = value
cell.border = border_thin
if col_num in (1, 2):
cell.alignment = align_center
row_num += 1
prev_max_kwh = max_kwh # Update penanda data sebelumnya
# ─── FITUR BARU: SUNTIKKAN BARIS REKAP TOTAL DI PALING BAWAH TABEL EXCEL ───
# Satukan kolom A sampai D (kolom 1 sampai 4) untuk teks label total
ws.merge_cells(start_row=row_num, start_column=1, end_row=row_num, end_column=4)
cell_label = ws.cell(row=row_num, column=1)
cell_label.value = "TOTAL KONSUMSI ENERGI"
cell_label.font = Font(name='Calibri', size=11, bold=True)
cell_label.alignment = Alignment(horizontal='right', vertical='center')
# Isi angka total kWh di kolom E (kolom 5) dan beri warna background amber/kuning soft
cell_total = ws.cell(row=row_num, column=5)
cell_total.value = round(total_excel_kwh, 4)
cell_total.font = Font(name='Calibri', size=11, bold=True)
cell_total.fill = PatternFill(start_color='FEF3C7', end_color='FEF3C7', fill_type='solid')
# Aplikasikan border luar untuk seluruh kolom pada baris total tersebut
for col_idx in range(1, 6):
ws.cell(row=row_num, column=col_idx).border = border_thin
# Autofit lebar kolom (Bebas Eror)
for col in ws.columns:
max_len = max(len(str(cell.value or '')) for cell in col)
col_letter = openpyxl.utils.get_column_letter(col[0].column)
ws.column_dimensions[col_letter].width = max(max_len + 4, 12)
# ─── COPYRIGHT PADA FOOTER CETAK DOKUMEN (KIRI BAWAH) ───
ws.oddFooter.left.text = "Copyright System IT-Dept-2026 | PT. Frina Lestari Nusantara"
ws.oddFooter.left.size = 9
ws.oddFooter.left.font = "Calibri"
# 7. Response Download Sheet
response = HttpResponse(
content_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
)
filename = f"Report_kWh_{period}_{timezone.now().strftime('%Y%m%d_%H%M')}.xlsx"
response['Content-Disposition'] = f'attachment; filename="{filename}"'
wb.save(response)
return response
# 1. View Render Halaman Analisis Grafik
def analisis_grafik_view(request):
return render(request, 'dashboard/analisis_grafik.html')
# 2. API Data Analisis (Rata-rata Apparent, PF, Current per Periode)
def api_analysis_data(request):
period = request.GET.get('period', 'hourly')
start_date_str = request.GET.get('start_date')
end_date_str = request.GET.get('end_date')
# 1. Ambil parameter mesin_id dari URL
mesin_id = request.GET.get('mesin_id', None)
queryset = KwhReadings.objects.all()
limit = None
# 2. Filter data berdasarkan mesin_id jika user memilih salah satu mesin secara spesifik
if mesin_id and mesin_id.strip() != '' and mesin_id != 'all' and mesin_id != 'undefined':
queryset = queryset.filter(mesin_id=mesin_id)
# ─── LOGIKA CUSTOM DATE YANG FLEKSIBEL ───
if period == 'custom' and start_date_str and end_date_str:
start_date = parse_datetime(start_date_str)
end_date = parse_datetime(end_date_str)
if start_date and end_date:
queryset = queryset.filter(timestamp__range=(start_date, end_date))
selisih_waktu = end_date - start_date
total_hari = selisih_waktu.days
if total_hari > 31:
trunc_func = TruncMonth('timestamp')
date_format = '%b %Y'
elif total_hari >= 2:
trunc_func = TruncDay('timestamp')
date_format = '%d %b %Y'
else:
trunc_func = TruncHour('timestamp')
date_format = '%H:%M (%d %b)'
# ─── KONDISI DEFAULT DENGAN BATASAN LIMIT DATA ───
elif period == 'daily':
trunc_func = TruncDay('timestamp')
date_format = '%d %b %Y'
limit = 7
elif period == 'monthly':
trunc_func = TruncMonth('timestamp')
date_format = '%b %Y'
limit = 12
else: # Default 'hourly'
trunc_func = TruncHour('timestamp')
date_format = '%H:%M (%d %b)'
limit = 24
# ─── PROSES AGREGASI DATABASE (RATA-RATA) ───
logs_query = (
queryset
.annotate(period_label=trunc_func)
.values('period_label')
.annotate(
avg_apparent=Avg('apparent_power_va'),
avg_pf=Avg('power_factor'),
avg_current=Avg('total_current_a')
)
)
if limit:
logs = logs_query.order_by('-period_label')[:limit]
logs = list(logs)
logs.reverse()
else:
logs = logs_query.order_by('period_label')
labels = []
apparent_power = []
power_factor = []
total_current = []
for entry in logs:
if entry['period_label']:
labels.append(entry['period_label'].strftime(date_format))
apparent_power.append(round(entry['avg_apparent'] or 0, 2))
power_factor.append(round(entry['avg_pf'] or 0, 3))
total_current.append(round(entry['avg_current'] or 0, 4))
# Proteksi jika hasil query kosong agar Chart.js frontend tidak rusak
# ─── KODE PERBAIKAN UNTUK FUNGSI api_analysis_data ───
if not labels:
return JsonResponse({
'labels': ['Belum Ada Data'],
'apparent_power': [],
'power_factor': [],
'total_current': []
})
return JsonResponse({
'labels': labels,
'apparent_power': apparent_power,
'power_factor': power_factor,
'total_current': total_current
})
# 3. Export Excel Data Analisis Parameter
# Export Excel Analisis Parameter Listrik
def export_analysis_excel(request):
period = request.GET.get('period', 'hourly')
start_date_str = request.GET.get('start_date')
end_date_str = request.GET.get('end_date')
mesin_id = request.GET.get('mesin_id', None)
queryset = KwhReadings.objects.all()
limit = None
# Ambil Informasi Lengkap Mesin & Area
nama_mesin_info = "Semua Unit Mesin"
area_info = "-"
keterangan_info = "-"
if mesin_id and mesin_id.strip() != '' and mesin_id != 'all' and mesin_id != 'undefined':
queryset = queryset.filter(mesin_id=mesin_id)
try:
mesin_obj = Mesin.objects.select_related('area').get(id=mesin_id)
nama_mesin_info = mesin_obj.nama_mesin
keterangan_info = mesin_obj.keterangan or "-"
if mesin_obj.area:
area_info = mesin_obj.area.nama_area
except Mesin.DoesNotExist:
pass
# ─── LOGIKA FILTER & AGREGASI SINKRON DENGAN GRAFIK ───
if period == 'custom' and start_date_str and end_date_str:
start_date = parse_datetime(start_date_str)
end_date = parse_datetime(end_date_str)
if start_date and end_date:
queryset = queryset.filter(timestamp__range=(start_date, end_date))
selisih_waktu = end_date - start_date
total_hari = selisih_waktu.days
if total_hari > 31:
trunc_func = TruncMonth('timestamp')
date_format = '%d-%m-%Y'
title_period = "Kustom Bulanan"
elif total_hari >= 2:
trunc_func = TruncDay('timestamp')
date_format = '%d-%m-%Y'
title_period = "Kustom Harian"
else:
trunc_func = TruncHour('timestamp')
date_format = '%d-%m-%Y %H:00'
title_period = "Kustom Per Jam"
elif period == 'daily':
trunc_func = TruncDay('timestamp')
date_format = '%d-%m-%Y'
title_period = "Harian (7 Data Terakhir)"
limit = 7
elif period == 'monthly':
trunc_func = TruncMonth('timestamp')
date_format = '%m-%Y'
title_period = "Bulanan (12 Data Terakhir)"
limit = 12
else: # Default 'hourly'
trunc_func = TruncHour('timestamp')
date_format = '%d-%m-%Y %H:00'
title_period = "Per Jam (24 Data Terakhir)"
limit = 24
# ─── PROSES AGREGASI DATABASE (AVERAGE) ───
logs_query = (
queryset
.annotate(period_label=trunc_func)
.values('period_label')
.annotate(
avg_apparent=Avg('apparent_power_va'),
avg_pf=Avg('power_factor'),
avg_current=Avg('total_current_a')
)
)
if limit:
logs = logs_query.order_by('-period_label')[:limit]
logs = list(logs)
logs.reverse()
else:
logs = logs_query.order_by('period_label')
# ─── INISIALISASI WORKBOOK EXCEL & STYLING ───
wb = openpyxl.Workbook()
ws = wb.active
ws.title = "Analisis Parameter"
# Font & Style Setup
title_font = Font(name='Calibri', size=14, bold=True, color='1F2937')
company_font = Font(name='Calibri', size=11, bold=True, color='4B5563')
info_font_bold = Font(name='Calibri', size=11, bold=True)
info_font_regular = Font(name='Calibri', size=11, bold=False)
header_font = Font(name='Calibri', size=11, bold=True, color='FFFFFF')
header_fill = PatternFill(start_color='1F2937', end_color='1F2937', fill_type='solid')
align_center = Alignment(horizontal='center', vertical='center')
align_left = Alignment(horizontal='left', vertical='center')
border_thin = Border(
left=Side(style='thin', color='D1D5DB'), right=Side(style='thin', color='D1D5DB'),
top=Side(style='thin', color='D1D5DB'), bottom=Side(style='thin', color='D1D5DB')
)
# ─── FITUR LOGO PERUSAHAAN (PROPORSIONAL DI CELL A1) ───
logo_path = os.path.join(settings.BASE_DIR, 'dashboard', 'static', 'images', 'FLN.png')
if os.path.exists(logo_path):
img = OpenpyxlImage(logo_path)
target_height = 55
target_width = int((img.width / img.height) * target_height)
img.width = target_width
img.height = target_height
ws.add_image(img, 'A1')
ws.row_dimensions[1].height = 25
ws.row_dimensions[2].height = 20
ws.row_dimensions[3].height = 15
# ─── TEKS JUDUL LAPORAN ───
ws['B1'] = "Sistem Informasi Monitoring Energi"
ws['B1'].font = title_font
ws['B2'] = "PT. Frina Lestari Nusantara"
ws['B2'].font = company_font
# ─── BLOK INFORMASI TERINTEGRASI MESIN (BARIS 5 - 7) ───
ws['A5'] = "Nama Mesin:"
ws['B5'] = nama_mesin_info
ws['A6'] = "Area/Lokasi:"
ws['B6'] = area_info
ws['A7'] = "Keterangan:"
ws['B7'] = keterangan_info
for r in range(5, 8):
ws.cell(row=r, column=1).font = info_font_bold
ws.cell(row=r, column=2).font = info_font_regular
ws.cell(row=r, column=1).alignment = align_left
ws.cell(row=r, column=2).alignment = align_left
# ─── TABEL UTAMA DIMULAI PADA BARIS KE-9 ───
headers = ['No', f'Periode ({title_period})', 'Avg Apparent Power (VA)', 'Avg Power Factor', 'Avg Total Current (A)']
for col_num, header in enumerate(headers, 1):
cell = ws.cell(row=9, column=col_num)
cell.value = header
cell.font = header_font
cell.fill = header_fill
cell.alignment = align_center
# ─── ISI DATA TABEL EXCEL ───
row_num = 10
for idx, entry in enumerate(logs, 1):
if entry['period_label']:
date_str = entry['period_label'].strftime(date_format)
row = [
idx,
date_str,
round(entry['avg_apparent'] or 0, 2),
round(entry['avg_pf'] or 0, 3),
round(entry['avg_current'] or 0, 4)
]
for col_num, value in enumerate(row, 1):
cell = ws.cell(row=row_num, column=col_num)
cell.value = value
cell.border = border_thin
if col_num in (1, 2):
cell.alignment = align_center
row_num += 1
# Autofit lebar kolom (Bebas Eror Tuple)
for col in ws.columns:
max_len = max(len(str(cell.value or '')) for cell in col)
col_letter = openpyxl.utils.get_column_letter(col[0].column)
ws.column_dimensions[col_letter].width = max(max_len + 4, 12)
# ─── COPYRIGHT PADA FOOTER CETAK DOKUMEN ───
ws.oddFooter.left.text = "Copyright System IT-Dept-2026 | PT. Frina Lestari Nusantara"
ws.oddFooter.left.size = 9
ws.oddFooter.left.font = "Calibri"
# Response Download Sheet
response = HttpResponse(
content_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
)
filename = f"Analisis_Parameter_{period}_{timezone.now().strftime('%Y%m%d_%H%M')}.xlsx"
response['Content-Disposition'] = f'attachment; filename="{filename}"'
wb.save(response)
return response
# View untuk menampilkan halaman list mesin & daftar area
def manajemen_mesin_view(request):
daftar_mesin = Mesin.objects.select_related('area').all().order_by('-created_at')
daftar_area = Area.objects.all().order_by('nama_area')
context = {
'daftar_mesin': daftar_mesin,
'daftar_area': daftar_area
}
return render(request, 'dashboard/manajemen_mesin.html', context)
# API untuk menyimpan data mesin baru + area + token dari popup
@csrf_exempt
def add_mesin_api(request):
if request.method == 'POST':
try:
data = json.loads(request.body)
nama = data.get('nama_mesin')
area_id = data.get('area_id')
keterangan = data.get('keterangan', '')
if not nama:
return JsonResponse({'status': 'error', 'message': 'Nama mesin wajib diisi'}, status=400)
# Generate Token Keamanan Unik untuk ESP32
token_esp32 = str(uuid.uuid4()).replace('-', '')
# Cari objek Area berdasarkan ID jika dipilih
area_obj = Area.objects.filter(id=area_id).first() if area_id else None
# Simpan mesin baru
mesin_baru = Mesin.objects.create(
nama_mesin=nama,
area=area_obj,
keterangan=keterangan,
token=token_esp32
)
return JsonResponse({
'status': 'success',
'message': 'Mesin berhasil ditambahkan!',
'data': {
'id': mesin_baru.id,
'nama': mesin_baru.nama_mesin,
'area': mesin_baru.area.nama_area if mesin_baru.area else '-',
'keterangan': mesin_baru.keterangan,
'token': mesin_baru.token
}
})
except Exception as e:
return JsonResponse({'status': 'error', 'message': str(e)}, status=500)
return JsonResponse({'status': 'error', 'message': 'Method tidak diizinkan'}, status=405)
# API untuk menyimpan data Area baru
@csrf_exempt
def add_area_api(request):
if request.method == 'POST':
try:
data = json.loads(request.body)
nama_area = data.get('nama_area')
keterangan = data.get('keterangan', '')
if not nama_area:
return JsonResponse(
{'status': 'error', 'message': 'Nama area wajib diisi'},
status=400,
)
area_baru = Area.objects.create(
nama_area=nama_area, keterangan=keterangan
)
return JsonResponse({
'status': 'success',
'message': 'Area berhasil ditambahkan!',
'data': {'id': area_baru.id, 'nama_area': area_baru.nama_area},
})
except Exception as e:
return JsonResponse(
{'status': 'error', 'message': str(e)}, status=500
)
return JsonResponse(
{'status': 'error', 'message': 'Method tidak diizinkan'}, status=405
)
# API untuk edit keterangan
@require_POST
def update_keterangan_view(request, mesin_id):
try:
data = json.loads(request.body)
keterangan_baru = data.get('keterangan', '').strip()
# Ambil data mesin berdasarkan ID
mesin = get_object_or_404(Mesin, id=mesin_id)
mesin.keterangan = keterangan_baru
mesin.save(update_fields=['keterangan']) # Hanya simpan kolom keterangan saja agar efisien
return JsonResponse({
'status': 'success',
'keterangan': mesin.keterangan
})
except Exception as e:
return JsonResponse({
'status': 'error',
'message': str(e)
}, status=400)
# UNtuk halaman Report
def report_per_area(request):
# Logika data bisa diisi di sini nanti
return render(request, 'dashboard/report_per_area.html')
def report_all(request):
# Logika data bisa diisi di sini nanti
return render(request, 'dashboard/report_all.html')
# 1. View untuk Render Halaman Report Area HTML
def report_per_area_view(request):
# Mengambil list area untuk dropdown filter awal
areas = Area.objects.all().order_by('nama_area')
# SINKRONISASI DATABASE RIIL: Ambil tanggal unik berdasarkan bulan secara kronologis (Terbaru di atas)
# Ini mendeteksi field 'timestamp' di KwhReadings, mengambil bulan uniknya saja, dan mencegah duplikasi
bulan_db = (
KwhReadings.objects
.dates('timestamp', 'month', order='DESC')
)
months_list = []
for date_obj in bulan_db:
months_list.append({
'value': date_obj.strftime('%Y-%m'), # Nilai untuk sistem filter (Contoh: "2026-09")
'label': date_obj.strftime('%B %Y') # Label teks yang dibaca user (Contoh: "September 2026")
})
# Pengaman jika seandainya database baru dideploy dan masih kosong total
if not months_list:
import datetime
today = datetime.date.today()
months_list.append({
'value': today.strftime('%Y-%m'),
'label': today.strftime('%B %Y')
})
context = {
'areas': areas,
'months': months_list
}
return render(request, 'dashboard/report_per_area.html', context)
# 2. API Endpoint untuk mengambil data report via AJAX (Tetap Utuh)
def api_report_per_area(request):
selected_month = request.GET.get('month') # Format: "YYYY-MM"
area_id = request.GET.get('area_id')
if not selected_month or not area_id:
return JsonResponse({'status': 'error', 'message': 'Parameter tidak lengkap'}, status=400)
try:
year, month = map(int, selected_month.split('-'))
except ValueError:
return JsonResponse({'status': 'error', 'message': 'Format bulan salah'}, status=400)
# Menggunakan .exclude() menjamin data status NULL atau 'normal' tetap dihitung
daftar_mesin = Mesin.objects.filter(area_id=area_id).exclude(status='review').order_by('nama_mesin')
labels_mesin = []
values_kwh = []
tabel_data = []
total_akumulasi_area = 0
# 2. Hitung kwh per masing-masing mesin pada bulan tersebut
for mesin in daftar_mesin:
# Filter log berdasarkan mesin dan range bulan terpilih
queryset = KwhReadings.objects.filter(
mesin_id=mesin.id,
timestamp__year=year,
timestamp__month=month
)
# Agregasi Max dan Min pada bulan itu
agregasi = queryset.aggregate(
max_val=Max('active_power_kwh'),
min_val=Min('active_power_kwh')
)
max_kwh = agregasi['max_val'] or 0
min_kwh = agregasi['min_val'] or 0
# Logika hitung konsumsi riil bulanan (seperti view log_kwh Anda)
konsumsi_bulan = max_kwh - min_kwh
if konsumsi_bulan < 0:
konsumsi_bulan = max_kwh
konsumsi_bulan = round(konsumsi_bulan, 4)
total_akumulasi_area += konsumsi_bulan
labels_mesin.append(mesin.nama_mesin)
values_kwh.append(konsumsi_bulan)
# Masukkan ke list untuk kebutuhan render tabel data di HTML
tabel_data.append({
'nama_mesin': mesin.nama_mesin,
'keterangan': mesin.keterangan or '-',
'min_kwh': round(min_kwh, 4),
'max_kwh': round(max_kwh, 4),
'konsumsi': konsumsi_bulan
})
return JsonResponse({
'labels': labels_mesin,
'values': values_kwh,
'tabel_data': tabel_data,
'total_sum': round(total_akumulasi_area, 4)
})
# EXPORT EXCEL PER AREA
def export_report_area_excel(request):
# 1. Ambil parameter filter dari request URL
selected_month = request.GET.get('month') # Format: "YYYY-MM"
area_id = request.GET.get('area_id')
if not selected_month or not area_id:
return HttpResponse("Parameter tidak lengkap untuk export excel.", status=400)
try:
year, month = map(int, selected_month.split('-'))
# Membuat label nama bulan untuk judul/info (Contoh: "September 2026")
dt_obj = timezone.datetime(year, month, 1)
nama_bulan_info = dt_obj.strftime('%B %Y')
except ValueError:
return HttpResponse("Format bulan salah.", status=400)
# Ambil Informasi Area untuk Header Excel
nama_area_info = "-"
try:
area_obj = Area.objects.get(id=area_id)
nama_area_info = area_obj.nama_area
except Area.DoesNotExist:
return HttpResponse("Area tidak ditemukan.", status=404)
# Ganti baris pengambil daftar_mesin untuk excel:
daftar_mesin = Mesin.objects.filter(area_id=area_id).exclude(status='review').order_by('nama_mesin')
# 3. Inisialisasi Workbook Excel & Styling (Konsisten dengan tema sebelumnya)
wb = openpyxl.Workbook()
ws = wb.active
ws.title = "Report Per Area"
# Font & Style Setup
title_font = Font(name='Calibri', size=14, bold=True, color='1F2937')
company_font = Font(name='Calibri', size=11, bold=True, color='4B5563')
info_font_bold = Font(name='Calibri', size=11, bold=True)
info_font_regular = Font(name='Calibri', size=11, bold=False)
header_font = Font(name='Calibri', size=11, bold=True, color='FFFFFF')
header_fill = PatternFill(start_color='1F2937', end_color='1F2937', fill_type='solid')
align_center = Alignment(horizontal='center', vertical='center')
align_left = Alignment(horizontal='left', vertical='center')
align_right = Alignment(horizontal='right', vertical='center')
border_thin = Border(
left=Side(style='thin', color='D1D5DB'), right=Side(style='thin', color='D1D5DB'),
top=Side(style='thin', color='D1D5DB'), bottom=Side(style='thin', color='D1D5DB')
)
# ─── FITUR LOGO PERUSAHAAN DI CELL A1 ───
logo_path = os.path.join(settings.BASE_DIR, 'dashboard', 'static', 'images', 'FLN.png')
if os.path.exists(logo_path):
img = OpenpyxlImage(logo_path)
target_height = 55
target_width = int((img.width / img.height) * target_height)
img.width = target_width
img.height = target_height
ws.add_image(img, 'A1')
ws.row_dimensions[1].height = 25
ws.row_dimensions[2].height = 20
ws.row_dimensions[3].height = 15
# ─── TEKS JUDUL KOLOM B ───
ws['B1'] = "Sistem Informasi Monitoring Energi"
ws['B1'].font = title_font
ws['B2'] = "PT. Frina Lestari Nusantara"
ws['B2'].font = company_font
# ─── BLOK INFORMASI AREA & PERIODE BARIS 5 - 6 ───
ws['A5'] = "Nama Area:"
ws['B5'] = nama_area_info
ws['A6'] = "Periode Bulan:"
ws['B6'] = nama_bulan_info
for r in range(5, 7):
ws.cell(row=r, column=1).font = info_font_bold
ws.cell(row=r, column=2).font = info_font_regular
ws.cell(row=r, column=1).alignment = align_left
ws.cell(row=r, column=2).alignment = align_left
# 4. ─── TABEL UTAMA PADA BARIS KE-8 ───
headers = ['No', 'Nama Mesin', 'Keterangan', 'Min kWh Mula', 'Max kWh Akhir', 'Total Konsumsi (kWh)']
for col_num, header in enumerate(headers, 1):
cell = ws.cell(row=8, column=col_num)
cell.value = header
cell.font = header_font
cell.fill = header_fill
cell.alignment = align_center
# 5. ─── ISI DATA ROW MULAI DARI BARIS KE-9 ───
row_num = 9
total_akumulasi_area = 0
for idx, mesin in enumerate(daftar_mesin, 1):
# Ambil data kwh_readings berdasarkan mesin dan rentang bulan terpilih
queryset = KwhReadings.objects.filter(
mesin_id=mesin.id,
timestamp__year=year,
timestamp__month=month
)
# Agregasi data Min dan Max pada bulan berjalan
agregasi = queryset.aggregate(
max_val=Max('active_power_kwh'),
min_val=Min('active_power_kwh')
)
min_kwh = agregasi['min_val'] or 0
max_kwh = agregasi['max_val'] or 0
# Logika hitung konsumsi bulanan (sejalan dengan grafik & aturan log_kwh)
konsumsi_bulan = max_kwh - min_kwh
if konsumsi_bulan < 0:
konsumsi_bulan = max_kwh
total_akumulasi_area += konsumsi_bulan
# Susun baris data
row_data = [
idx,
mesin.nama_mesin,
mesin.keterangan or "-",
round(min_kwh, 4),
round(max_kwh, 4),
round(konsumsi_bulan, 4)
]
for col_num, value in enumerate(row_data, 1):
cell = ws.cell(row=row_num, column=col_num)
cell.value = value
cell.border = border_thin
# Styling perataan kolom
if col_num == 1:
cell.alignment = align_center
elif col_num in (2, 3):
cell.alignment = align_left
else:
cell.alignment = align_right
row_num += 1
# 6. ─── SUNTIKKAN BARIS REKAP TOTAL DI PALING BAWAH TABEL EXCEL ───
# Satukan kolom A sampai E (kolom 1 sampai 5) untuk label TOTAL
ws.merge_cells(start_row=row_num, start_column=1, end_row=row_num, end_column=5)
cell_label = ws.cell(row=row_num, column=1)
cell_label.value = f"TOTAL KONSUMSI ENERGI {nama_area_info.upper()}"
cell_label.font = Font(name='Calibri', size=11, bold=True)
cell_label.alignment = align_right
# Isi angka total akumulasi di kolom F (kolom 6) dan beri warna background amber soft (FEF3C7)
cell_total = ws.cell(row=row_num, column=6)
cell_total.value = round(total_akumulasi_area, 4)
cell_total.font = Font(name='Calibri', size=11, bold=True)
cell_total.fill = PatternFill(start_color='FEF3C7', end_color='FEF3C7', fill_type='solid')
cell_total.alignment = align_right
# Terapkan border tipis untuk baris total akumulasi ini
for col_idx in range(1, 7):
ws.cell(row=row_num, column=col_idx).border = border_thin
# Autofit lebar kolom agar tulisan tidak terpotong
for col in ws.columns:
max_len = max(len(str(cell.value or '')) for cell in col)
col_letter = openpyxl.utils.get_column_letter(col[0].column)
ws.column_dimensions[col_letter].width = max(max_len + 4, 12)
# COPYRIGHT PADA FOOTER CETAK DOKUMEN (KIRI BAWAH)
ws.oddFooter.left.text = "Copyright System IT-Dept-2026 | PT. Frina Lestari Nusantara"
ws.oddFooter.left.size = 9
ws.oddFooter.left.font = "Calibri"
# 7. Kirim Response ke Browser sebagai File Unduhan Excel
response = HttpResponse(
content_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
)
filename = f"Report_Area_{nama_area_info.replace(' ', '_')}_{selected_month}.xlsx"
response['Content-Disposition'] = f'attachment; filename="{filename}"'
wb.save(response)
return response
# Halaman Report All
def report_all_view(request):
bulan_db = KwhReadings.objects.dates('timestamp', 'month', order='DESC')
months_list = []
for date_obj in bulan_db:
months_list.append({
'value': date_obj.strftime('%Y-%m'),
'label': date_obj.strftime('%B %Y')
})
if not months_list:
import datetime
today = datetime.date.today()
months_list.append({
'value': today.strftime('%Y-%m'),
'label': today.strftime('%B %Y')
})
context = {
'months': months_list
}
return render(request, 'dashboard/report_all.html', context)
# API Data Report All dengan Perbaikan Bug
def api_report_all_data(request):
selected_month = request.GET.get('month') # Format: "YYYY-MM"
if not selected_month:
return JsonResponse({'status': 'error', 'message': 'Bulan harus dipilih'}, status=400)
try:
year, month = map(int, selected_month.split('-'))
except ValueError:
return JsonResponse({'status': 'error', 'message': 'Format bulan salah'}, status=400)
daftar_area = Area.objects.all().order_by('nama_area')
labels_area = []
values_kwh = []
tabel_data = []
total_seluruh_pabrik = 0
for area in daftar_area:
# 🟢 PERBAIKAN 1: Mengubah area_id menjadi area.id agar terbaca dari loop objek 'area'
mesin_di_area = Mesin.objects.filter(area_id=area.id).exclude(status='review')
total_kwh_area = 0
for mesin in mesin_di_area:
queryset = KwhReadings.objects.filter(
mesin_id=mesin.id,
timestamp__year=year,
timestamp__month=month
)
# 🟢 PERBAIKAN 2: Fungsi Max & Min di sini dijamin aman karena sudah di-import di atas
agregasi = queryset.aggregate(
max_val=Max('active_power_kwh'),
min_val=Min('active_power_kwh')
)
max_kwh = agregasi['max_val'] or 0
min_kwh = agregasi['min_val'] or 0
konsumsi = max_kwh - min_kwh
if konsumsi < 0:
konsumsi = max_kwh
total_kwh_area += konsumsi
total_kwh_area = round(total_kwh_area, 2)
total_seluruh_pabrik += total_kwh_area
labels_area.append(area.nama_area)
values_kwh.append(total_kwh_area)
tabel_data.append({
'nama_area': area.nama_area,
'jumlah_mesin': mesin_di_area.count(),
'total_konsumsi': total_kwh_area
})
return JsonResponse({
'labels': labels_area,
'values': values_kwh,
'tabel_data': tabel_data,
'total_sum': round(total_seluruh_pabrik, 2)
})
# 📌 Pastikan dekorator ditulis rata kiri, sejajar dengan kata 'def'
@login_required
def dashboard_view(request):
# ... isi kode dashboard Anda saat ini ...
return render(request, 'dashboard/index.html')
# API baru untuk mengubah status mesin (Normal / Review) dari dropdown tabel web
@require_POST
def update_status_view(request, mesin_id):
try:
data = json.loads(request.body)
status_baru = data.get('status', 'normal').strip()
if status_baru not in ['normal', 'review']:
return JsonResponse({'status': 'error', 'message': 'Status tidak valid'}, status=400)
# PERBAIKAN TOTAL: Paksa update langsung tembus ke baris tabel database asli
Mesin.objects.filter(id=mesin_id).update(status=status_baru)
return JsonResponse({'status': 'success', 'message': 'Status mesin berhasil diperbarui'})
except Exception as e:
return JsonResponse({'status': 'error', 'message': str(e)}, status=400)