import json from decimal import Decimal from asgiref.sync import async_to_sync from channels.layers import get_channel_layer from django.shortcuts import render, redirect, get_object_or_404 from django.contrib.auth import login, logout, authenticate from django.contrib.auth.decorators import login_required from django.contrib.auth.forms import AuthenticationForm from django.contrib.auth.models import User from django.contrib import messages from django.http import JsonResponse from django.views.decorators.http import require_POST, require_GET from django.db.models import Sum, Q from django.utils import timezone from .models import ChatRoom, Membership, Message, Event, Expense, ExpenseSplit, UserProfile from .forms import SignUpForm, ChatRoomForm, EventForm, ExpenseForm AVATAR_COLORS = [ '#6366f1', '#8b5cf6', '#ec4899', '#f43f5e', '#f97316', '#eab308', '#22c55e', '#14b8a6', '#3b82f6' ] def get_or_create_profile(user): profile, created = UserProfile.objects.get_or_create(user=user) if created: color_idx = user.id % len(AVATAR_COLORS) profile.avatar_color = AVATAR_COLORS[color_idx] profile.save() return profile # ─── Auth ─────────────────────────────────────────────────────────────────── def signup_view(request): if request.user.is_authenticated: return redirect('dashboard') form = SignUpForm(request.POST or None) if request.method == 'POST' and form.is_valid(): user = form.save() profile = get_or_create_profile(user) login(request, user) messages.success(request, f'Welcome, {user.username}!') return redirect('dashboard') return render(request, 'core/auth.html', {'form': form, 'mode': 'signup'}) def login_view(request): if request.user.is_authenticated: return redirect('dashboard') form = AuthenticationForm(request, request.POST or None) if request.method == 'POST' and form.is_valid(): user = form.get_user() get_or_create_profile(user) login(request, user) return redirect(request.GET.get('next', 'dashboard')) return render(request, 'core/auth.html', {'form': form, 'mode': 'login'}) @login_required def logout_view(request): logout(request) return redirect('login') # ─── Dashboard ─────────────────────────────────────────────────────────────── @login_required def dashboard(request): profile = get_or_create_profile(request.user) rooms = ChatRoom.objects.filter( memberships__user=request.user, memberships__is_active=True ).prefetch_related('memberships') upcoming_events = Event.objects.filter( room__memberships__user=request.user, room__memberships__is_active=True, is_settled=False, ).select_related('room').order_by('event_date', '-created_at')[:10] # Compute overall balance total_owed = Decimal('0') # others owe me total_owing = Decimal('0') # I owe others events_with_balance = Event.objects.filter( room__memberships__user=request.user, room__memberships__is_active=True, is_settled=False, ) for event in events_with_balance: balances = event.compute_balances() bal = balances.get(request.user.id, Decimal('0')) if bal > 0: total_owed += bal else: total_owing += abs(bal) return render(request, 'core/dashboard.html', { 'rooms': rooms, 'upcoming_events': upcoming_events, 'total_owed': total_owed, 'total_owing': total_owing, 'net_balance': total_owed - total_owing, 'profile': profile, }) # ─── Rooms ─────────────────────────────────────────────────────────────────── @login_required def room_create(request): form = ChatRoomForm(request.POST or None) if request.method == 'POST' and form.is_valid(): room = form.save(commit=False) room.created_by = request.user room.save() Membership.objects.create(user=request.user, room=room, is_admin=True) messages.success(request, f'Room "{room.name}" created!') return redirect('room_detail', room_id=str(room.id)) return render(request, 'core/room_form.html', {'form': form, 'action': 'Create'}) @login_required def room_join(request): if request.method == 'POST': code = request.POST.get('invite_code', '').strip().upper() try: room = ChatRoom.objects.get(invite_code=code) membership, created = Membership.objects.get_or_create( user=request.user, room=room, defaults={'is_active': True} ) if not created and not membership.is_active: membership.is_active = True membership.save() messages.success(request, f'Joined "{room.name}"!') return redirect('room_detail', room_id=str(room.id)) except ChatRoom.DoesNotExist: messages.error(request, 'Invalid invite code.') return render(request, 'core/room_join.html') @login_required def room_join_link(request, invite_code): room = get_object_or_404(ChatRoom, invite_code=invite_code) membership, created = Membership.objects.get_or_create( user=request.user, room=room, defaults={'is_active': True} ) if not created and not membership.is_active: membership.is_active = True membership.save() return redirect('room_detail', room_id=str(room.id)) @login_required def room_detail(request, room_id): room = get_object_or_404(ChatRoom, id=room_id) membership = get_object_or_404(Membership, user=request.user, room=room, is_active=True) profile = get_or_create_profile(request.user) messages_qs = Message.objects.filter(room=room).select_related('sender', 'sender__profile').order_by('created_at') members = User.objects.filter(memberships__room=room, memberships__is_active=True).select_related('profile') events = room.events.select_related('created_by').all() # Enrich members with profiles for m in members: get_or_create_profile(m) return render(request, 'core/room_detail.html', { 'room': room, 'membership': membership, 'messages': messages_qs, 'members': members, 'events': events, 'profile': profile, 'user': request.user, }) # ─── Events ────────────────────────────────────────────────────────────────── @login_required def event_create(request, room_id): room = get_object_or_404(ChatRoom, id=room_id) get_object_or_404(Membership, user=request.user, room=room, is_active=True) form = EventForm(request.POST or None) if request.method == 'POST' and form.is_valid(): event = form.save(commit=False) event.room = room event.created_by = request.user event.save() # Send system message to room msg = Message.objects.create( room=room, sender=request.user, content=f'📅 Created event: **{event.title}**', msg_type='event' ) _broadcast_room_update(room_id, 'new_event', { 'event_id': str(event.id), 'event_title': event.title, 'created_by': request.user.username, }) messages.success(request, f'Event "{event.title}" created!') return redirect('event_detail', room_id=str(room.id), event_id=str(event.id)) return render(request, 'core/event_form.html', {'form': form, 'room': room}) @login_required def event_detail(request, room_id, event_id): room = get_object_or_404(ChatRoom, id=room_id) get_object_or_404(Membership, user=request.user, room=room, is_active=True) event = get_object_or_404(Event, id=event_id, room=room) expenses = event.expenses.select_related('paid_by').prefetch_related('splits__user') members = User.objects.filter(memberships__room=room, memberships__is_active=True) for m in members: get_or_create_profile(m) balances = event.compute_balances() settlements = event.compute_settlements() expense_form = ExpenseForm(room=room) # Enrich balances with user objects balance_display = [] for uid, amount in balances.items(): try: u = User.objects.select_related('profile').get(id=uid) get_or_create_profile(u) balance_display.append({'user': u, 'amount': amount}) except User.DoesNotExist: pass return render(request, 'core/event_detail.html', { 'room': room, 'event': event, 'expenses': expenses, 'members': members, 'balance_display': sorted(balance_display, key=lambda x: x['amount'], reverse=True), 'settlements': settlements, 'expense_form': expense_form, 'profile': get_or_create_profile(request.user), }) @login_required @require_POST def expense_create(request, room_id, event_id): room = get_object_or_404(ChatRoom, id=room_id) get_object_or_404(Membership, user=request.user, room=room, is_active=True) event = get_object_or_404(Event, id=event_id, room=room) form = ExpenseForm(request.POST, room=room) if form.is_valid(): amount = form.cleaned_data['amount'] paid_by = form.cleaned_data['paid_by'] participants = form.cleaned_data['participants'] split_type = form.cleaned_data['split_type'] description = form.cleaned_data['description'] expense = Expense.objects.create( event=event, description=description, amount=amount, paid_by=paid_by, split_type=split_type, ) if split_type == Expense.SPLIT_EQUAL: per_person = (amount / len(participants)).quantize(Decimal('0.01')) for participant in participants: ExpenseSplit.objects.create(expense=expense, user=participant, amount=per_person) else: # Custom: equal for now, can be extended per_person = (amount / len(participants)).quantize(Decimal('0.01')) for participant in participants: custom_key = f'custom_{participant.id}' custom_amount = request.POST.get(custom_key) if custom_amount: try: amt = Decimal(custom_amount) except Exception: amt = per_person else: amt = per_person ExpenseSplit.objects.create(expense=expense, user=participant, amount=amt) _broadcast_room_update(room_id, 'new_expense', { 'event_id': str(event.id), 'event_title': event.title, 'expense_description': description, 'amount': str(amount), 'paid_by': paid_by.username, }) messages.success(request, f'Expense "${amount} – {description}" added!') else: messages.error(request, 'Invalid expense data.') return redirect('event_detail', room_id=str(room.id), event_id=str(event.id)) @login_required @require_POST def event_settle(request, room_id, event_id): room = get_object_or_404(ChatRoom, id=room_id) get_object_or_404(Membership, user=request.user, room=room, is_active=True) event = get_object_or_404(Event, id=event_id, room=room) event.is_settled = True event.save() messages.success(request, f'Event "{event.title}" marked as settled!') return redirect('event_detail', room_id=str(room.id), event_id=str(event.id)) # ─── API endpoints ─────────────────────────────────────────────────────────── @login_required def api_room_messages(request, room_id): room = get_object_or_404(ChatRoom, id=room_id) get_object_or_404(Membership, user=request.user, room=room, is_active=True) msgs = Message.objects.filter(room=room).select_related('sender', 'sender__profile').order_by('-created_at')[:50] data = [{ 'id': str(m.id), 'content': m.content, 'sender': m.sender.username if m.sender else 'deleted', 'display_name': (m.sender.profile.name if hasattr(m.sender, 'profile') else m.sender.username) if m.sender else 'deleted', 'timestamp': m.created_at.strftime('%H:%M'), 'type': m.msg_type, } for m in reversed(list(msgs))] return JsonResponse({'messages': data}) # ─── Helpers ───────────────────────────────────────────────────────────────── def _broadcast_room_update(room_id, update_type, payload): channel_layer = get_channel_layer() if channel_layer: try: async_to_sync(channel_layer.group_send)( f'chat_{room_id}', {'type': 'room_update', 'update_type': update_type, **payload} ) except Exception: pass