349 lines
13 KiB
Python
349 lines
13 KiB
Python
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
|