society-self-service/sstreasury/models.py

376 lines
11 KiB
Python

# Society Self-Service
# Copyright © 2018–2023 Yingtong Li (RunasSudo)
# Copyright © 2023 MUMUS Inc.
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from django.contrib.auth.models import User
from django.conf import settings
from django.db import models
from django.utils import timezone
from jsonfield import JSONField
from decimal import Decimal
from enum import Enum
class DescriptionEnum(Enum):
def __new__(cls, value, description):
obj = object.__new__(cls)
obj._value_ = value
obj.description = description
return obj
class Budget(models.Model):
pass
class BudgetComment(models.Model):
budget = models.ForeignKey(Budget, on_delete=models.CASCADE)
author = models.ForeignKey(User, on_delete=models.PROTECT, related_name='+')
time = models.DateTimeField()
content = models.TextField()
class Meta:
ordering = ['id']
class BudgetState(DescriptionEnum):
DRAFT = 10, 'Draft'
WITHDRAWN = 15, 'Withdrawn'
RESUBMIT = 20, 'Returned for redrafting'
AWAIT_REVIEW = 30, 'Awaiting Treasury review'
ENDORSED = 40, 'Endorsed by Treasury, awaiting committee approval'
APPROVED = 50, 'Approved'
CANCELLED = 60, 'Cancelled'
class BudgetAction(DescriptionEnum):
CREATE = 5, 'Created'
EDIT = 10, 'Edited'
UPDATE_STATE = 20, 'Updated state'
class BudgetRevision(models.Model):
budget = models.ForeignKey(Budget, on_delete=models.CASCADE)
name = models.CharField(max_length=100)
date = models.DateField()
contributors = models.ManyToManyField(User, related_name='+')
comments = models.TextField()
author = models.ForeignKey(User, on_delete=models.PROTECT, related_name='+')
time = models.DateTimeField()
approver = models.CharField(max_length=100)
event_dt = models.DateTimeField(null=True)
event_attendees = models.CharField(max_length=20, null=True)
state = models.IntegerField(choices=[(v.value, v.description) for v in BudgetState])
revenue = JSONField(default=[])
revenue_comments = models.TextField()
expense = JSONField(default=[])
expense_no_emergency_fund = models.BooleanField()
expense_comments = models.TextField()
ticketing_fee_proportion = models.FloatField()
ticketing_fee_fixed = models.FloatField()
action = models.IntegerField(choices=[(v.value, v.description) for v in BudgetAction])
class Meta:
ordering = ['id']
def copy(self):
contributors = list(self.contributors.all())
self.pk, self.id = None, None
self.save()
self.contributors.add(*contributors)
def update_state(self, user, state):
self.copy()
self.author = user
self.time = timezone.now()
self.state = state.value
self.action = BudgetAction.UPDATE_STATE.value
self.save()
def get_revenue_total(self):
total = Decimal(0)
for item in self.revenue:
try:
total += Decimal(item['Unit price']) * Decimal(item['Units'])
if item['IWT'] and item['Unit price'] > 0:
total -= (Decimal(item['Unit price']) * Decimal(self.ticketing_fee_proportion) + Decimal(self.ticketing_fee_fixed)) * item['Units']
except TypeError:
# Invalid unit price, etc.
pass
return total
def get_expense_total(self):
total = Decimal(0)
for item in self.expense:
try:
total += Decimal(item['Unit price']) * Decimal(item['Units'])
except TypeError:
# Invalid unit price, etc.
pass
if not self.expense_no_emergency_fund:
total *= Decimal('1.05')
return total
# Access control
def can_view(self, user):
if user == self.author:
return True
if user in self.contributors.all():
return True
if user.groups.filter(name='Treasury').exists():
return True
if self.state in (BudgetState.AWAIT_REVIEW.value, BudgetState.ENDORSED.value, BudgetState.APPROVED.value) and user.groups.filter(name='Committee').exists():
return True
return False
def can_edit(self, user):
# Cannot edit if cannot view
if not self.can_view(user):
return False
# No one can edit if already approved
if self.state == BudgetState.APPROVED.value:
return False
# Only Treasurer or Secretary may edit if submitted
if self.state not in (BudgetState.DRAFT.value, BudgetState.RESUBMIT.value, BudgetState.WITHDRAWN.value):
if user.groups.filter(name='Treasury').exists() or user.groups.filter(name='Secretary').exists():
return True
return False
# Otherwise the submitter may edit
if user == self.author:
return True
if user in self.contributors.all():
return True
if user.groups.filter(name='Treasury').exists():
return True
# Otherwise cannot edit
return False
def can_submit(self, user):
if not self.can_edit(user):
return False
if self.state in (BudgetState.DRAFT.value, BudgetState.RESUBMIT.value, BudgetState.WITHDRAWN.value):
return True
return False
def can_withdraw(self, user):
if not self.can_view(user):
return False
if user != self.author and user not in self.contributors.all() and not user.groups.filter(name='Treasury').exists():
return False
if self.state in (BudgetState.DRAFT.value, BudgetState.RESUBMIT.value, BudgetState.AWAIT_REVIEW.value, BudgetState.ENDORSED.value):
return True
return False
def can_endorse(self, user):
if not self.can_edit(user):
return False
if not user.groups.filter(name='Treasury').exists():
return False
if self.state in (BudgetState.AWAIT_REVIEW.value, BudgetState.DRAFT.value, BudgetState.RESUBMIT.value, BudgetState.WITHDRAWN.value):
return True
return False
def can_return(self, user):
if self.state != BudgetState.AWAIT_REVIEW.value:
return False
return self.can_endorse(user)
def can_approve(self, user):
if not self.can_edit(user):
return False
if not user.groups.filter(name='Secretary').exists() and not user.groups.filter(name='Treasury').exists():
return False
if self.state != BudgetState.APPROVED.value:
return True
return False
def can_cmtereturn(self, user):
if self.state == BudgetState.APPROVED.value:
return False
return self.can_approve(user)
def can_cancel(self, user):
if not self.can_view(user):
return False
if not user.groups.filter(name='Secretary').exists() and not user.groups.filter(name='Treasury').exists():
return False
if self.state != BudgetState.APPROVED.value:
return False
return True
def can_vote(self, user):
if not settings.BUDGET_ENABLE_VOTING:
return False
if self.state == BudgetState.ENDORSED.value and user.groups.filter(name=self.approver).exists():
return True
return False
class BudgetVoteType(DescriptionEnum):
IN_FAVOUR = 1, 'In favour'
AGAINST = -1, 'Against'
ABSTAIN = 0, 'Abstain'
class BudgetVote(models.Model):
revision = models.ForeignKey(BudgetRevision, on_delete=models.CASCADE)
voter = models.ForeignKey(User, on_delete=models.PROTECT, related_name='+')
time = models.DateTimeField()
is_current = models.BooleanField()
vote_type = models.IntegerField(choices=[(v.value, v.description) for v in BudgetVoteType])
class ClaimState(DescriptionEnum):
DRAFT = 10, 'Draft'
WITHDRAWN = 15, 'Withdrawn'
RESUBMIT = 20, 'Returned for redrafting'
AWAIT_REVIEW = 30, 'Awaiting Treasury approval'
APPROVED = 40, 'Approved by Treasury, awaiting payment'
PAID = 50, 'Paid'
class ClaimAction(DescriptionEnum):
CREATE = 5, 'Created'
EDIT = 10, 'Edited'
UPDATE_STATE = 20, 'Updated state'
class ReimbursementClaim(models.Model):
purpose = models.CharField(max_length=100)
date = models.DateField()
budget_id = models.CharField(max_length=20)
comments = models.TextField()
author = models.ForeignKey(User, on_delete=models.PROTECT, related_name='+')
time = models.DateTimeField()
state = models.IntegerField(choices=[(v.value, v.description) for v in ClaimState])
items = JSONField(default=[])
payee_name = models.TextField()
payee_bsb = models.CharField(max_length=7)
payee_account = models.TextField(max_length=20)
def get_total(self):
total = Decimal(0)
for item in self.items:
try:
total += Decimal(item['Unit price']) * Decimal(item['Units'])
except TypeError:
# Invalid unit price, etc.
pass
return total
def update_state(self, user, state):
self.state = state.value
self.save()
claim_history = ClaimHistory()
claim_history.claim = self
claim_history.author = user
claim_history.state = self.state
claim_history.time = timezone.now()
claim_history.action = ClaimAction.UPDATE_STATE.value
claim_history.save()
# Access control
def can_view(self, user):
if user == self.author:
return True
if user.groups.filter(name='Treasury').exists():
return True
return False
def can_edit(self, user):
# Cannot edit if cannot view
if not self.can_view(user):
return False
# No one can edit if already paid
if self.state == ClaimState.PAID.value:
return False
# Only Treasurer may edit if submitted
if self.state not in (ClaimState.DRAFT.value, ClaimState.RESUBMIT.value, ClaimState.WITHDRAWN.value):
if user.groups.filter(name='Treasury').exists():
return True
return False
# Otherwise the submitter or Treasurer may edit
if user == self.author:
return True
if user.groups.filter(name='Treasury').exists():
return True
# Otherwise cannot edit
return False
def can_submit(self, user):
if not self.can_edit(user):
return False
if self.state in (ClaimState.DRAFT.value, ClaimState.RESUBMIT.value, ClaimState.WITHDRAWN.value):
return True
return False
def can_withdraw(self, user):
if not self.can_view(user):
return False
if user != self.author and not user.groups.filter(name='Treasury').exists():
return False
if self.state in (ClaimState.AWAIT_REVIEW.value, ClaimState.APPROVED.value, ClaimState.DRAFT.value, ClaimState.RESUBMIT.value):
return True
return False
def can_approve(self, user):
if not self.can_edit(user):
return False
if not user.groups.filter(name='Treasury').exists():
return False
if self.state in (ClaimState.DRAFT.value, ClaimState.RESUBMIT.value, ClaimState.AWAIT_REVIEW.value, ClaimState.WITHDRAWN.value):
return True
return False
def can_return(self, user):
return self.can_approve(user)
class ClaimReceipt(models.Model):
claim = models.ForeignKey(ReimbursementClaim, on_delete=models.CASCADE)
uploaded_file = models.FileField(upload_to='receipt_uploads/%Y/%m/%d/')
class ClaimComment(models.Model):
claim = models.ForeignKey(ReimbursementClaim, on_delete=models.CASCADE)
author = models.ForeignKey(User, on_delete=models.PROTECT, related_name='+')
time = models.DateTimeField()
content = models.TextField()
class Meta:
ordering = ['id']
class ClaimHistory(models.Model):
claim = models.ForeignKey(ReimbursementClaim, on_delete=models.CASCADE)
author = models.ForeignKey(User, on_delete=models.PROTECT, related_name='+')
state = models.IntegerField(choices=[(v.value, v.description) for v in ClaimState])
time = models.DateTimeField()
action = models.IntegerField(choices=[(v.value, v.description) for v in ClaimAction])