178 lines
6.6 KiB
Python
178 lines
6.6 KiB
Python
import uuid
|
|
from decimal import Decimal
|
|
from django.db import models
|
|
from django.contrib.auth.models import User
|
|
from django.utils import timezone
|
|
|
|
|
|
class ChatRoom(models.Model):
|
|
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
|
name = models.CharField(max_length=100)
|
|
description = models.TextField(blank=True)
|
|
invite_code = models.CharField(max_length=12, unique=True, blank=True)
|
|
created_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, related_name='created_rooms')
|
|
created_at = models.DateTimeField(auto_now_add=True)
|
|
|
|
def save(self, *args, **kwargs):
|
|
if not self.invite_code:
|
|
self.invite_code = uuid.uuid4().hex[:8].upper()
|
|
super().save(*args, **kwargs)
|
|
|
|
def __str__(self):
|
|
return self.name
|
|
|
|
@property
|
|
def member_count(self):
|
|
return self.memberships.filter(is_active=True).count()
|
|
|
|
|
|
class Membership(models.Model):
|
|
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='memberships')
|
|
room = models.ForeignKey(ChatRoom, on_delete=models.CASCADE, related_name='memberships')
|
|
joined_at = models.DateTimeField(auto_now_add=True)
|
|
is_active = models.BooleanField(default=True)
|
|
is_admin = models.BooleanField(default=False)
|
|
|
|
class Meta:
|
|
unique_together = ('user', 'room')
|
|
|
|
def __str__(self):
|
|
return f"{self.user.username} in {self.room.name}"
|
|
|
|
|
|
class Message(models.Model):
|
|
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
|
room = models.ForeignKey(ChatRoom, on_delete=models.CASCADE, related_name='messages')
|
|
sender = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, related_name='messages')
|
|
content = models.TextField()
|
|
msg_type = models.CharField(max_length=20, default='chat',
|
|
choices=[('chat', 'Chat'), ('system', 'System'), ('event', 'Event'), ('expense', 'Expense')])
|
|
created_at = models.DateTimeField(auto_now_add=True)
|
|
|
|
class Meta:
|
|
ordering = ['created_at']
|
|
|
|
def __str__(self):
|
|
return f"{self.sender}: {self.content[:50]}"
|
|
|
|
|
|
class Event(models.Model):
|
|
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
|
room = models.ForeignKey(ChatRoom, on_delete=models.CASCADE, related_name='events')
|
|
created_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, related_name='created_events')
|
|
title = models.CharField(max_length=200)
|
|
description = models.TextField(blank=True)
|
|
location = models.CharField(max_length=200, blank=True)
|
|
event_date = models.DateTimeField(null=True, blank=True)
|
|
created_at = models.DateTimeField(auto_now_add=True)
|
|
is_settled = models.BooleanField(default=False)
|
|
|
|
class Meta:
|
|
ordering = ['-created_at']
|
|
|
|
def __str__(self):
|
|
return self.title
|
|
|
|
@property
|
|
def total_amount(self):
|
|
return self.expenses.aggregate(total=models.Sum('amount'))['total'] or Decimal('0')
|
|
|
|
def compute_balances(self):
|
|
"""Returns dict: {user_id: net_balance} positive=owed, negative=owes"""
|
|
balances = {}
|
|
for expense in self.expenses.prefetch_related('splits'):
|
|
payer_id = expense.paid_by_id
|
|
balances[payer_id] = balances.get(payer_id, Decimal('0')) + expense.amount
|
|
for split in expense.splits.all():
|
|
uid = split.user_id
|
|
balances[uid] = balances.get(uid, Decimal('0')) - split.amount
|
|
return balances
|
|
|
|
def compute_settlements(self):
|
|
"""Simplify debts: returns list of (debtor_user, creditor_user, amount)"""
|
|
balances = self.compute_balances()
|
|
# Fetch users
|
|
user_ids = list(balances.keys())
|
|
users = {u.id: u for u in User.objects.filter(id__in=user_ids)}
|
|
|
|
debtors = sorted(
|
|
[(uid, -bal) for uid, bal in balances.items() if bal < -Decimal('0.01')],
|
|
key=lambda x: -x[1]
|
|
)
|
|
creditors = sorted(
|
|
[(uid, bal) for uid, bal in balances.items() if bal > Decimal('0.01')],
|
|
key=lambda x: -x[1]
|
|
)
|
|
|
|
settlements = []
|
|
i, j = 0, 0
|
|
debtors = [[uid, amt] for uid, amt in debtors]
|
|
creditors = [[uid, amt] for uid, amt in creditors]
|
|
|
|
while i < len(debtors) and j < len(creditors):
|
|
debtor_id, debt = debtors[i]
|
|
creditor_id, credit = creditors[j]
|
|
amount = min(debt, credit)
|
|
if amount > Decimal('0.01'):
|
|
settlements.append((users.get(debtor_id), users.get(creditor_id), round(amount, 2)))
|
|
debtors[i][1] -= amount
|
|
creditors[j][1] -= amount
|
|
if debtors[i][1] < Decimal('0.01'):
|
|
i += 1
|
|
if creditors[j][1] < Decimal('0.01'):
|
|
j += 1
|
|
|
|
return settlements
|
|
|
|
|
|
class Expense(models.Model):
|
|
SPLIT_EQUAL = 'equal'
|
|
SPLIT_CUSTOM = 'custom'
|
|
SPLIT_CHOICES = [(SPLIT_EQUAL, 'Equal'), (SPLIT_CUSTOM, 'Custom')]
|
|
|
|
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
|
event = models.ForeignKey(Event, on_delete=models.CASCADE, related_name='expenses')
|
|
description = models.CharField(max_length=200)
|
|
amount = models.DecimalField(max_digits=10, decimal_places=2)
|
|
paid_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, related_name='paid_expenses')
|
|
split_type = models.CharField(max_length=10, choices=SPLIT_CHOICES, default=SPLIT_EQUAL)
|
|
created_at = models.DateTimeField(auto_now_add=True)
|
|
|
|
class Meta:
|
|
ordering = ['-created_at']
|
|
|
|
def __str__(self):
|
|
return f"{self.description}: ${self.amount}"
|
|
|
|
|
|
class ExpenseSplit(models.Model):
|
|
expense = models.ForeignKey(Expense, on_delete=models.CASCADE, related_name='splits')
|
|
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='expense_splits')
|
|
amount = models.DecimalField(max_digits=10, decimal_places=2)
|
|
|
|
class Meta:
|
|
unique_together = ('expense', 'user')
|
|
|
|
def __str__(self):
|
|
return f"{self.user.username}: ${self.amount}"
|
|
|
|
|
|
class UserProfile(models.Model):
|
|
user = models.OneToOneField(User, on_delete=models.CASCADE, related_name='profile')
|
|
display_name = models.CharField(max_length=100, blank=True)
|
|
avatar_color = models.CharField(max_length=7, default='#6366f1')
|
|
|
|
def __str__(self):
|
|
return f"Profile({self.user.username})"
|
|
|
|
@property
|
|
def name(self):
|
|
return self.display_name or self.user.get_full_name() or self.user.username
|
|
|
|
def get_avatar_initials(self):
|
|
name = self.name
|
|
parts = name.split()
|
|
if len(parts) >= 2:
|
|
return (parts[0][0] + parts[-1][0]).upper()
|
|
return name[:2].upper()
|