39710-vm/core/views.py
2026-04-17 04:26:07 +00:00

706 lines
25 KiB
Python

import json
from datetime import datetime, time
from functools import wraps
from io import BytesIO
from django.contrib import messages
from django.contrib.auth import authenticate, login, logout
from django.contrib.auth.decorators import login_required
from django.core.exceptions import ValidationError
from django.db.models import Count, Sum
from django.http import FileResponse, Http404, JsonResponse
from django.shortcuts import get_object_or_404, redirect, render
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_GET, require_POST, require_http_methods
from django.utils import timezone
from .forms import BusinessProfileForm, LoginForm, ReportFilterForm, SignUpForm, TransactionForm
from .jwt_auth import (
JWTAuthError,
authenticate_authorization_header,
authenticate_refresh_token,
issue_token_pair,
revoke_user_tokens,
)
from .models import BusinessProfile, Transaction
def get_api_authenticated_user(request):
if request.user.is_authenticated:
return request.user
authorization = (request.META.get('HTTP_AUTHORIZATION') or '').strip()
if not authorization:
return None
user, _ = authenticate_authorization_header(authorization)
request.user = user
return user
def api_login_required(view_func):
@wraps(view_func)
def wrapped(request, *args, **kwargs):
try:
user = get_api_authenticated_user(request)
except JWTAuthError as exc:
return JsonResponse({
'ok': False,
'error': exc.message,
'code': exc.code,
}, status=exc.status_code)
if user is None:
return JsonResponse({
'ok': False,
'error': 'Authentication required.',
'code': 'auth_required',
}, status=401)
return view_func(request, *args, **kwargs)
return wrapped
def serialize_transaction(entry):
return {
'id': entry.id,
'client_name': entry.client_name,
'amount': str(entry.amount),
'transaction_type': entry.transaction_type,
'transaction_type_label': entry.get_transaction_type_display(),
'service_charge': str(entry.service_charge),
'notes': entry.notes,
'ecash_before': str(entry.ecash_before),
'ecash_after': str(entry.ecash_after),
'physical_before': str(entry.physical_before),
'physical_after': str(entry.physical_after),
'created_by': entry.created_by.username,
'created_at': timezone.localtime(entry.created_at).isoformat(),
}
def get_profile(user):
profile, _ = BusinessProfile.objects.get_or_create(user=user)
return profile
def build_report_snapshot(profile, params=None):
form = ReportFilterForm(params or None)
form.is_valid()
start_date, end_date = form.get_range() if form.cleaned_data else (timezone.localdate(), timezone.localdate())
start_dt = timezone.make_aware(datetime.combine(start_date, time.min))
end_dt = timezone.make_aware(datetime.combine(end_date, time.max))
entries = profile.transactions.filter(created_at__range=(start_dt, end_dt)).select_related('created_by')
summary_rows = entries.values('transaction_type').annotate(
total_amount=Sum('amount'),
total_fees=Sum('service_charge'),
total_count=Count('id'),
)
summary_map = {row['transaction_type']: row for row in summary_rows}
ordered_summary = []
for value, label in Transaction.TYPE_CHOICES:
row = summary_map.get(value)
ordered_summary.append({
'key': value,
'label': label,
'count': row['total_count'] if row else 0,
'amount': row['total_amount'] if row and row['total_amount'] else 0,
'fees': row['total_fees'] if row and row['total_fees'] else 0,
})
totals = entries.aggregate(total_amount=Sum('amount'), total_fees=Sum('service_charge'), total_count=Count('id'))
latest_entry = entries.order_by('-created_at', '-id').first()
closing_ecash = latest_entry.ecash_after if latest_entry else profile.current_ecash
closing_physical = latest_entry.physical_after if latest_entry else profile.current_physical_cash
return {
'form': form,
'entries': entries.order_by('-created_at', '-id'),
'summary': ordered_summary,
'total_amount': totals['total_amount'] or 0,
'total_fees': totals['total_fees'] or 0,
'total_count': totals['total_count'] or 0,
'start_date': start_date,
'end_date': end_date,
'closing_ecash': closing_ecash,
'closing_physical': closing_physical,
}
def home(request):
context = {
'page_title': 'MoMoLedger | Agent wallet dashboard',
'meta_description': 'Manage MoMo transactions, balances, business branding, and daily-to-yearly reports from one polished dashboard.',
'signup_form': SignUpForm(),
'login_form': LoginForm(request=request),
}
if request.user.is_authenticated:
profile = get_profile(request.user)
report_snapshot = build_report_snapshot(profile, {'period': 'daily'})
context.update({
'profile': profile,
'recent_transactions': profile.transactions.select_related('created_by')[:5],
'today_total': report_snapshot['total_amount'],
'today_fees': report_snapshot['total_fees'],
'today_count': report_snapshot['total_count'],
})
return render(request, 'core/index.html', context)
def signup_view(request):
if request.user.is_authenticated:
return redirect('home')
form = SignUpForm(request.POST or None)
if request.method == 'POST' and form.is_valid():
user = form.save()
BusinessProfile.objects.get_or_create(user=user)
login(request, user)
messages.success(request, 'Welcome! Your account is ready. Set your business details next.')
return redirect('profile')
return render(request, 'core/auth.html', {
'form': form,
'mode': 'signup',
'page_title': 'Create your MoMo account',
'meta_description': 'Create your mobile money business account and start tracking your balances securely.',
})
def login_view(request):
if request.user.is_authenticated:
return redirect('home')
form = LoginForm(request=request, data=request.POST or None)
if request.method == 'POST' and form.is_valid():
login(request, form.get_user())
messages.success(request, 'Welcome back to your MoMo dashboard.')
return redirect('home')
return render(request, 'core/auth.html', {
'form': form,
'mode': 'login',
'page_title': 'Log in to MoMoLedger',
'meta_description': 'Access your mobile money dashboard, balances, transaction log, and reports.',
})
@login_required
def logout_view(request):
logout(request)
messages.info(request, 'You have been logged out.')
return redirect('home')
@login_required
def profile_view(request):
profile = get_profile(request.user)
form = BusinessProfileForm(request.POST or None, request.FILES or None, instance=profile)
if request.method == 'POST' and form.is_valid():
profile = form.save()
messages.success(request, 'Business profile saved. Your branding will now appear on reports.')
return redirect('profile')
return render(request, 'core/profile_form.html', {
'form': form,
'profile': profile,
'page_title': 'Business setup | MoMoLedger',
'meta_description': 'Update your business name, logo, and opening wallet balances for branded reports.',
})
@login_required
def transaction_create_view(request):
profile = get_profile(request.user)
if not profile.business_name:
messages.info(request, 'Start by saving your business profile before logging transactions.')
return redirect('profile')
form = TransactionForm(request.POST or None, business=profile)
if request.method == 'POST' and form.is_valid():
entry = form.save(request.user)
messages.success(request, 'Transaction recorded and balances updated automatically.')
return redirect('transaction_detail', transaction_id=entry.id)
return render(request, 'core/transaction_form.html', {
'form': form,
'profile': profile,
'page_title': 'New transaction | MoMoLedger',
'meta_description': 'Record cash-in, cash-out, sending, airtime, transfer, debt, expenditure, and credit in one place.',
})
@login_required
def transaction_list_view(request):
profile = get_profile(request.user)
entries = profile.transactions.select_related('created_by')
summary = entries.aggregate(total_amount=Sum('amount'), total_fees=Sum('service_charge'), total_count=Count('id'))
return render(request, 'core/transaction_list.html', {
'profile': profile,
'transactions': entries,
'summary': summary,
'page_title': 'Transactions | MoMoLedger',
'meta_description': 'Browse recent mobile money transactions and review balance movements.',
})
@login_required
def transaction_detail_view(request, transaction_id):
profile = get_profile(request.user)
entry = get_object_or_404(profile.transactions.select_related('created_by'), id=transaction_id)
return render(request, 'core/transaction_detail.html', {
'profile': profile,
'transaction': entry,
'page_title': f'{entry.get_transaction_type_display()} details | MoMoLedger',
'meta_description': 'Inspect one MoMo transaction and see the exact e-cash and physical cash balance effect.',
})
@login_required
def reports_view(request):
profile = get_profile(request.user)
snapshot = build_report_snapshot(profile, request.GET or {'period': 'daily'})
context = {
'profile': profile,
'page_title': 'Reports | MoMoLedger',
'meta_description': 'View printable MoMo business reports by day, week, month, year, or a custom date range.',
**snapshot,
}
return render(request, 'core/reports.html', context)
@login_required
def report_pdf_view(request):
try:
from reportlab.lib import colors
from reportlab.lib.pagesizes import A4
from reportlab.lib.units import cm
from reportlab.pdfbase.pdfmetrics import stringWidth
from reportlab.pdfgen import canvas
except Exception as exc: # pragma: no cover
raise Http404('PDF support is not available.') from exc
profile = get_profile(request.user)
snapshot = build_report_snapshot(profile, request.GET or {'period': 'daily'})
buffer = BytesIO()
pdf = canvas.Canvas(buffer, pagesize=A4)
width, height = A4
margin = 1.5 * cm
y = height - margin
pdf.setTitle('MoMo report')
pdf.setFillColor(colors.HexColor('#0f6f5c'))
pdf.rect(0, height - 4 * cm, width, 4 * cm, fill=1, stroke=0)
pdf.setFillColor(colors.white)
pdf.setFont('Helvetica-Bold', 18)
pdf.drawString(margin, height - 1.5 * cm, profile.business_name or 'MoMoLedger Business')
pdf.setFont('Helvetica', 10)
owner_text = f"Owner: {profile.owner_label} | User: {request.user.username} | Email: {request.user.email or 'Not set'}"
pdf.drawString(margin, height - 2.2 * cm, owner_text[:100])
pdf.drawString(margin, height - 2.8 * cm, f"Report window: {snapshot['start_date']} to {snapshot['end_date']}")
if profile.logo:
try:
pdf.drawImage(profile.logo.path, width - 4.5 * cm, height - 3.2 * cm, width=2.5 * cm, height=2.5 * cm, preserveAspectRatio=True, mask='auto')
except Exception:
pass
y = height - 5.2 * cm
pdf.setFillColor(colors.HexColor('#143642'))
pdf.setFont('Helvetica-Bold', 12)
pdf.drawString(margin, y, 'Balance summary')
y -= 0.6 * cm
pdf.setFont('Helvetica', 10)
metrics = [
f"Opening e-cash: {profile.opening_ecash}",
f"Opening physical: {profile.opening_physical_cash}",
f"Closing e-cash: {snapshot['closing_ecash']}",
f"Closing physical: {snapshot['closing_physical']}",
f"Transactions: {snapshot['total_count']}",
f"Gross amount: {snapshot['total_amount']}",
f"Service fees: {snapshot['total_fees']}",
]
for item in metrics:
pdf.drawString(margin, y, item)
y -= 0.45 * cm
y -= 0.2 * cm
pdf.setFont('Helvetica-Bold', 12)
pdf.drawString(margin, y, 'Transaction type totals')
y -= 0.6 * cm
pdf.setFont('Helvetica-Bold', 10)
pdf.drawString(margin, y, 'Type')
pdf.drawString(8.2 * cm, y, 'Count')
pdf.drawString(11 * cm, y, 'Amount')
pdf.drawString(15 * cm, y, 'Fees')
y -= 0.35 * cm
pdf.setStrokeColor(colors.HexColor('#d4dfd6'))
pdf.line(margin, y, width - margin, y)
y -= 0.45 * cm
pdf.setFont('Helvetica', 10)
for row in snapshot['summary']:
if y < 3 * cm:
pdf.showPage()
y = height - margin
pdf.drawString(margin, y, row['label'])
pdf.drawString(8.2 * cm, y, str(row['count']))
pdf.drawString(11 * cm, y, str(row['amount']))
pdf.drawString(15 * cm, y, str(row['fees']))
y -= 0.45 * cm
y -= 0.3 * cm
pdf.setFont('Helvetica-Bold', 12)
pdf.drawString(margin, y, 'Recent entries')
y -= 0.6 * cm
pdf.setFont('Helvetica', 9)
for entry in snapshot['entries'][:12]:
if y < 2.5 * cm:
pdf.showPage()
y = height - margin
label = f"{timezone.localtime(entry.created_at).strftime('%Y-%m-%d %H:%M')} · {entry.get_transaction_type_display()} · {entry.client_name} · {entry.amount}"
max_width = width - (2 * margin)
while stringWidth(label, 'Helvetica', 9) > max_width and len(label) > 3:
label = label[:-4] + '...'
pdf.drawString(margin, y, label)
y -= 0.42 * cm
pdf.showPage()
pdf.save()
buffer.seek(0)
filename = f"momo-report-{snapshot['start_date']}-to-{snapshot['end_date']}.pdf"
return FileResponse(buffer, as_attachment=True, filename=filename)
def parse_api_payload(request):
if (request.content_type or '').startswith('application/json'):
try:
return json.loads(request.body.decode('utf-8') or '{}')
except (json.JSONDecodeError, UnicodeDecodeError):
return None
return request.POST
def serialize_form_errors(form):
errors = {}
for field, items in form.errors.get_json_data().items():
errors[field] = [item['message'] for item in items]
return errors
def build_transaction_summary(profile):
summary = profile.transactions.aggregate(
total_amount=Sum('amount'),
total_fees=Sum('service_charge'),
total_count=Count('id'),
)
return {
'total_amount': str(summary['total_amount'] or 0),
'total_fees': str(summary['total_fees'] or 0),
'total_count': summary['total_count'] or 0,
}
@require_GET
def api_health_view(request):
return JsonResponse({
'ok': True,
'app': 'MoMoLedger API',
'message': 'Android backend starter endpoints are available.',
'server_time': timezone.now().isoformat(),
'authenticated': bool(request.user.is_authenticated or (request.META.get('HTTP_AUTHORIZATION') or '').strip()),
})
@csrf_exempt
@require_POST
def api_login_view(request):
user = request.user if request.user.is_authenticated else None
if user is None:
payload = parse_api_payload(request)
if payload is None:
return JsonResponse({
'ok': False,
'error': 'Invalid JSON body.',
}, status=400)
username = (payload.get('username') or '').strip()
password = payload.get('password') or ''
if not username or not password:
return JsonResponse({
'ok': False,
'error': 'Username and password are required.',
}, status=400)
user = authenticate(request, username=username, password=password)
if user is None:
return JsonResponse({
'ok': False,
'error': 'Invalid username or password.',
}, status=401)
login(request, user)
message = 'Login successful.'
else:
message = 'Already logged in.'
profile = get_profile(user)
tokens = issue_token_pair(user)
return JsonResponse({
'ok': True,
'message': message,
'user': {
'id': user.id,
'username': user.username,
'email': user.email,
'business_name': profile.business_name,
},
'tokens': tokens,
})
@csrf_exempt
@require_POST
def api_token_refresh_view(request):
payload = parse_api_payload(request)
if payload is None:
return JsonResponse({
'ok': False,
'error': 'Invalid JSON body.',
}, status=400)
refresh_token = (payload.get('refresh_token') or payload.get('refresh') or '').strip()
try:
user, profile = authenticate_refresh_token(refresh_token)
except JWTAuthError as exc:
return JsonResponse({
'ok': False,
'error': exc.message,
'code': exc.code,
}, status=exc.status_code)
return JsonResponse({
'ok': True,
'message': 'Token refreshed successfully.',
'user': {
'id': user.id,
'username': user.username,
'email': user.email,
'business_name': profile.business_name,
},
'tokens': issue_token_pair(user),
})
@csrf_exempt
@require_POST
def api_logout_view(request):
payload = parse_api_payload(request)
if payload is None:
return JsonResponse({
'ok': False,
'error': 'Invalid JSON body.',
}, status=400)
user = request.user if request.user.is_authenticated else None
refresh_token = (payload.get('refresh_token') or payload.get('refresh') or '').strip()
if user is None and refresh_token:
try:
user, _ = authenticate_refresh_token(refresh_token)
except JWTAuthError as exc:
return JsonResponse({
'ok': False,
'error': exc.message,
'code': exc.code,
}, status=exc.status_code)
if user is None:
authorization = (request.META.get('HTTP_AUTHORIZATION') or '').strip()
if authorization:
try:
user, _ = authenticate_authorization_header(authorization)
except JWTAuthError as exc:
return JsonResponse({
'ok': False,
'error': exc.message,
'code': exc.code,
}, status=exc.status_code)
if user is None:
return JsonResponse({
'ok': True,
'message': 'No active session.',
})
revoke_user_tokens(user)
if request.user.is_authenticated:
logout(request)
return JsonResponse({
'ok': True,
'message': 'Logout successful. Mobile tokens revoked.',
})
@require_GET
@api_login_required
def api_profile_view(request):
profile = get_profile(request.user)
return JsonResponse({
'ok': True,
'profile': {
'username': request.user.username,
'email': request.user.email,
'owner_label': profile.owner_label,
'business_name': profile.business_name,
'opening_ecash': str(profile.opening_ecash),
'opening_physical_cash': str(profile.opening_physical_cash),
'current_ecash': str(profile.current_ecash),
'current_physical_cash': str(profile.current_physical_cash),
'total_cash': str(profile.total_cash),
'created_at': timezone.localtime(profile.created_at).isoformat(),
'updated_at': timezone.localtime(profile.updated_at).isoformat(),
},
})
@csrf_exempt
@api_login_required
@require_http_methods(['GET', 'POST'])
def api_transactions_view(request):
profile = get_profile(request.user)
if request.method == 'POST':
payload = parse_api_payload(request)
if payload is None:
return JsonResponse({
'ok': False,
'error': 'Invalid JSON body.',
}, status=400)
form = TransactionForm(payload or None, business=profile)
if not form.is_valid():
return JsonResponse({
'ok': False,
'error': 'Validation failed.',
'errors': serialize_form_errors(form),
}, status=400)
try:
entry = form.save(request.user)
except ValidationError as exc:
return JsonResponse({
'ok': False,
'error': exc.messages[0] if exc.messages else 'Could not create transaction.',
}, status=400)
profile.refresh_from_db(fields=['current_ecash', 'current_physical_cash'])
return JsonResponse({
'ok': True,
'message': 'Transaction created successfully.',
'transaction': serialize_transaction(entry),
'balances': {
'current_ecash': str(profile.current_ecash),
'current_physical_cash': str(profile.current_physical_cash),
'total_cash': str(profile.total_cash),
},
'summary': build_transaction_summary(profile),
}, status=201)
try:
limit = int(request.GET.get('limit', 20))
except (TypeError, ValueError):
limit = 20
limit = max(1, min(limit, 100))
entries = list(profile.transactions.select_related('created_by')[:limit])
return JsonResponse({
'ok': True,
'count': len(entries),
'limit': limit,
'summary': build_transaction_summary(profile),
'transactions': [serialize_transaction(entry) for entry in entries],
})
@csrf_exempt
@api_login_required
@require_http_methods(['GET', 'PUT', 'PATCH', 'DELETE'])
def api_transaction_detail_view(request, transaction_id):
profile = get_profile(request.user)
entry = get_object_or_404(profile.transactions.select_related('created_by'), id=transaction_id)
if request.method == 'GET':
return JsonResponse({
'ok': True,
'transaction': serialize_transaction(entry),
'balances': {
'current_ecash': str(profile.current_ecash),
'current_physical_cash': str(profile.current_physical_cash),
'total_cash': str(profile.total_cash),
},
'summary': build_transaction_summary(profile),
})
if request.method in {'PUT', 'PATCH'}:
payload = parse_api_payload(request)
if payload is None:
return JsonResponse({
'ok': False,
'error': 'Invalid JSON body.',
}, status=400)
merged_payload = {
'client_name': entry.client_name,
'amount': str(entry.amount),
'transaction_type': entry.transaction_type,
'notes': entry.notes,
}
merged_payload.update(payload)
form = TransactionForm(merged_payload, business=profile, instance=entry)
if not form.is_valid():
return JsonResponse({
'ok': False,
'error': 'Validation failed.',
'errors': serialize_form_errors(form),
}, status=400)
try:
update_result = form.save(request.user)
except ValidationError as exc:
return JsonResponse({
'ok': False,
'error': exc.messages[0] if exc.messages else 'Could not update transaction.',
}, status=400)
profile = update_result['business']
entry = update_result['transaction']
return JsonResponse({
'ok': True,
'message': 'Transaction updated successfully.',
'transaction': serialize_transaction(entry),
'balances': {
'current_ecash': str(profile.current_ecash),
'current_physical_cash': str(profile.current_physical_cash),
'total_cash': str(profile.total_cash),
},
'summary': build_transaction_summary(profile),
})
deleted_transaction = serialize_transaction(entry)
try:
delete_result = entry.delete_logged_transaction()
except ValidationError as exc:
return JsonResponse({
'ok': False,
'error': exc.messages[0] if exc.messages else 'Could not delete transaction.',
}, status=400)
profile = delete_result['business']
return JsonResponse({
'ok': True,
'message': 'Transaction deleted successfully.',
'deleted_transaction': deleted_transaction,
'deleted_transaction_id': delete_result['transaction_id'],
'balances': {
'current_ecash': str(profile.current_ecash),
'current_physical_cash': str(profile.current_physical_cash),
'total_cash': str(profile.total_cash),
},
'summary': build_transaction_summary(profile),
})