388 lines
14 KiB
Python
388 lines
14 KiB
Python
#!/usr/bin/env python2
|
|
# - * - coding=utf-8 - * -
|
|
|
|
# Copyright (c) 2015, Sergiusz Bazanski <q3k@q3k.org>
|
|
# Copyright (c) 2015, Remigiusz Marcinkiewicz <enleth@enleth.com>
|
|
# All rights reserved.
|
|
#
|
|
# Redistribution and use in source and binary forms, with or without
|
|
# modification, are permitted provided that the following conditions are met:
|
|
#
|
|
# 1. Redistributions of source code must retain the above copyright notice,
|
|
# this list of conditions and the following disclaimer.
|
|
# 2. Redistributions in binary form must reproduce the above copyright notice,
|
|
# this list of conditions and the following disclaimer in the documentation
|
|
# and/or other materials provided with the distribution.
|
|
#
|
|
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
|
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
|
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
|
|
# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
|
|
# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
|
|
# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
|
|
# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
|
# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
|
|
# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
|
|
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
|
|
# POSSIBILITY OF SUCH DAMAGE.
|
|
|
|
import datetime
|
|
import enum
|
|
import json
|
|
import re
|
|
|
|
from sqlalchemy.orm import subqueryload_all
|
|
from sqlalchemy.sql.expression import or_
|
|
from flask import g
|
|
|
|
from webapp import app, db, mc, cache_enabled
|
|
import directory
|
|
|
|
|
|
|
|
class APIKey(db.Model):
|
|
id = db.Column(db.Integer, primary_key=True)
|
|
secret = db.Column(db.String(64))
|
|
member = db.Column(db.Integer, db.ForeignKey("member.id"))
|
|
description = db.Column(db.Text)
|
|
|
|
|
|
class MemberTransfer(db.Model):
|
|
__tablename__ = "member_transfer"
|
|
id = db.Column(db.Integer, primary_key=True)
|
|
member = db.Column(db.Integer, db.ForeignKey("member.id"))
|
|
transfer_id = db.Column(db.Integer, db.ForeignKey("transfer.id"))
|
|
year = db.Column(db.Integer)
|
|
month = db.Column(db.Integer)
|
|
transfer = db.relationship("Transfer", backref="member_transfers")
|
|
|
|
def __init__(self, _id, year, month, transfer):
|
|
self.id = _id
|
|
self.year = year
|
|
self.month = month
|
|
self.transfer = transfer
|
|
mc.delete('kasownik-stats_for_month-{}-{}'.format(year, month))
|
|
mc.delete('kasownik-cashflow-{}-{}'.format(year, month))
|
|
|
|
|
|
class PaymentStatus(enum.Enum):
|
|
never_paid = 1 # never paid membership fees
|
|
unpaid = 2 # more than 3 fees unapid
|
|
okay = 3 # fees paid
|
|
|
|
class PaymentPolicy(enum.Enum):
|
|
normal = "Normal"
|
|
extended = "Extended Grace Period"
|
|
potato = "Potato"
|
|
disabled = "Disabled"
|
|
|
|
class MembershipType(enum.Enum):
|
|
fatty = "Fatty"
|
|
starving = "Starving"
|
|
supporting = "Supporting"
|
|
|
|
class Member(db.Model):
|
|
id = db.Column(db.Integer, primary_key=True)
|
|
username = db.Column(db.String(64), unique=True)
|
|
type = db.Column(db.Enum("starving", "fatty", "supporting", name="member_types"))
|
|
transfers = db.relationship("MemberTransfer",order_by=[db.asc(MemberTransfer.year), db.asc(MemberTransfer.month)])
|
|
# old field
|
|
active = db.Column(db.Boolean)
|
|
api_keys = db.relationship("APIKey")
|
|
join_year = db.Column(db.Integer)
|
|
join_month = db.Column(db.Integer)
|
|
alias = db.Column(db.String(64))
|
|
# Normal - standard 3 months grace period
|
|
# Extended Grace Period - do not shut off account after grace period
|
|
# Potato - do not ever shut off account, report falsified payment status
|
|
# Disabled - manual disable override, regardless of payment extra
|
|
payment_policy = db.Column(db.Enum(*[p.value for p in PaymentPolicy.__members__.values()],
|
|
name='payment_policy_types'))
|
|
preferred_email = db.Column(db.String(64))
|
|
|
|
def mt_covers(self, mt):
|
|
"""For transfer view - given an mt, should we rowspan?"""
|
|
if mt not in self.transfers:
|
|
return None
|
|
ix = self.transfers.index(mt)
|
|
if ix != 0:
|
|
# check if the previous mt was covered by the same transfer
|
|
if self.transfers[ix-1].transfer.uid == mt.transfer.uid:
|
|
return None
|
|
# check how many next mts use the same transfer
|
|
rowspan = 0
|
|
for ix2 in range(ix+1, len(self.transfers)):
|
|
if self.transfers[ix2].transfer.uid == mt.transfer.uid:
|
|
rowspan += 1
|
|
else:
|
|
break
|
|
if rowspan == 0:
|
|
return None
|
|
else:
|
|
return rowspan + 1
|
|
|
|
@classmethod
|
|
def get_members(kls, deep=False):
|
|
"""Gets all members as an SQLAlchemy query.
|
|
@param(deep) - whether to do a subqueryload_all and load all transfer data
|
|
"""
|
|
if deep:
|
|
return kls.query.options(subqueryload_all(kls.transfers,
|
|
MemberTransfer.transfer)).order_by(kls.username)
|
|
else:
|
|
return kls.query.order_by(kls.username)
|
|
|
|
|
|
def _yearmonth_increment(self, ym):
|
|
y, m = ym
|
|
y2, m2 = y, m+1
|
|
if m2 > 12:
|
|
y2 += 1
|
|
m2 = 1
|
|
return (y2, m2)
|
|
|
|
def _yearmonth_scalar(self, ym):
|
|
y, m = ym
|
|
return y * 12 + (m - 1)
|
|
|
|
def _get_status_uncached(self):
|
|
now_date = datetime.datetime.now()
|
|
now = now_date.year * 12 + (now_date.month - 1)
|
|
del now_date
|
|
|
|
status = {}
|
|
status['username'] = self.username
|
|
status['alias'] = self.alias
|
|
status['type'] = self.type
|
|
status['payment_policy'] = self.payment_policy
|
|
# First check - did we actually get any transfers?
|
|
if not self.transfers or self.transfers[0].transfer.uid == app.config['DUMMY_TRANSFER_UID']:
|
|
status['payment_status'] = PaymentStatus.never_paid.value
|
|
status['months_due'] = None
|
|
status['last_paid'] = (None, None)
|
|
if self.join_year is not None and self.join_month is not None:
|
|
status['joined'] = (self.join_year, self.join_month)
|
|
status['next_unpaid'] = status['joined']
|
|
else:
|
|
status['joined'] = (None, None)
|
|
status['next_unpaid'] = (None, None)
|
|
status['left'] = False
|
|
self._apply_judgement(status)
|
|
return status
|
|
|
|
# Use the join date from SQL, if available
|
|
if self.join_year is not None and self.join_month is not None:
|
|
joined = (self.join_year, self.join_month)
|
|
else:
|
|
joined = (self.transfers[0].year, self.transfers[0].month)
|
|
joined_scalar = self._yearmonth_scalar(joined)
|
|
status['joined'] = joined
|
|
if len(self.transfers[-1].transfer.uid) == 64:
|
|
status['last_transfer_bank'] = 'mBank'
|
|
else:
|
|
status['last_transfer_bank'] = 'IdeaBank'
|
|
|
|
most_recent_transfer = (0, 0)
|
|
unpaid_months = 0
|
|
|
|
# Iterate over all payments and figure out how much months are unpaid
|
|
previous_transfer = (0, 0)
|
|
previous_uid = None
|
|
active_payment = True
|
|
|
|
for mt in self.transfers:
|
|
this_transfer = (mt.year, mt.month)
|
|
this_scalar = self._yearmonth_scalar(this_transfer)
|
|
this_uid = mt.transfer.uid
|
|
|
|
previous_scalar = self._yearmonth_scalar(previous_transfer)
|
|
most_recent_scalar = self._yearmonth_scalar(most_recent_transfer)
|
|
|
|
# Is this transfer a „not a member anymore” transfer?
|
|
if this_uid == app.config['DUMMY_TRANSFER_UID']:
|
|
active_payment = False
|
|
continue
|
|
|
|
# Is this the first transfer? See if it was done on time
|
|
if previous_uid is None:
|
|
unpaid_months += (this_scalar - joined_scalar)
|
|
|
|
# Apply any missing payments
|
|
if active_payment and previous_uid is not None:
|
|
unpaid_months += (this_scalar - previous_scalar) - 1
|
|
|
|
# Is this the most recent payment?
|
|
if this_scalar > most_recent_scalar:
|
|
most_recent_scalar = this_scalar
|
|
most_recent_transfer = this_transfer
|
|
|
|
active_payment = True
|
|
|
|
previous_transfer = this_transfer
|
|
previous_uid = this_uid
|
|
|
|
|
|
# Apply missing payments from now
|
|
if active_payment:
|
|
previous_scalar = self._yearmonth_scalar(previous_transfer)
|
|
unpaid_months += (now - previous_scalar)
|
|
|
|
status['months_due'] = unpaid_months
|
|
status['payment_status'] = PaymentStatus.okay.value if unpaid_months < 4 else PaymentStatus.unpaid.value
|
|
status['last_paid'] = most_recent_transfer
|
|
status['left'] = not active_payment
|
|
|
|
if not active_payment:
|
|
status['next_unpaid'] = (None, None)
|
|
else:
|
|
status['next_unpaid'] = self._yearmonth_increment(status['last_paid'])
|
|
|
|
self._apply_judgement(status)
|
|
return status
|
|
|
|
def get_local_email(self):
|
|
return '{}@hackerspace.pl'.format(self.username)
|
|
|
|
def get_ldap_email(self):
|
|
mra = directory.get_member_fields(g.ldap, self.username,'mailRoutingAddress')
|
|
mra = mra['mailRoutingAddress']
|
|
if not mra:
|
|
return None
|
|
return mra
|
|
|
|
def get_custom_email(self):
|
|
if self.preferred_email not in ['local', 'ldap', '', None]:
|
|
return self.preferred_email
|
|
else:
|
|
return None
|
|
|
|
def uses_local_email(self):
|
|
return self.preferred_email == 'local'
|
|
|
|
def uses_ldap_email(self):
|
|
return self.preferred_email == 'ldap'
|
|
|
|
def uses_custom_email(self):
|
|
return self.get_custom_email() is not None
|
|
|
|
def get_contact_email(self, adrtype = None):
|
|
|
|
if (self.uses_ldap_email() and adrtype is None) or adrtype == 'ldap':
|
|
return self.get_ldap_email()
|
|
elif (self.uses_local_email() and adrtype is None) or adrtype == 'local':
|
|
return self.get_local_email()
|
|
elif (self.uses_custom_email() and adrtype is None) or adrtype == 'custom':
|
|
return self.preferred_email
|
|
else: # fallback
|
|
return self.get_local_email()
|
|
|
|
def get_status(self, force_refresh = False):
|
|
"""It's better to call this after doing a full select of data."""
|
|
cache_key = 'kasownik-payment_status-{}'.format(self.username)
|
|
cache_data = mc.get(cache_key)
|
|
if cache_data and cache_enabled and not force_refresh:
|
|
data = json.loads(cache_data)
|
|
return data
|
|
else:
|
|
cache_data = self._get_status_uncached()
|
|
mc.delete('kasownik-months_due-{}'.format(self.username))
|
|
mc.set(cache_key, json.dumps(cache_data))
|
|
return cache_data
|
|
|
|
def _apply_judgement(self, status):
|
|
"""Check your priviledge, you cisnormative shitlord!"""
|
|
if status['left']:
|
|
status['judgement'] = False
|
|
return
|
|
policy = status['payment_policy']
|
|
if policy == 'Normal':
|
|
if status['payment_status'] == PaymentStatus.okay.value and status['last_paid'][0] is not None:
|
|
status['judgement'] = True
|
|
else:
|
|
status['judgement'] = False
|
|
elif policy == 'Extended Grace Period':
|
|
status['judgement'] = True
|
|
elif policy == 'Potato':
|
|
status['judgement'] = True
|
|
status['months_due'] = 0
|
|
else:
|
|
status['judgement'] = False
|
|
|
|
def get_months_due(self):
|
|
status = self.get_status()
|
|
return status['months_due']
|
|
|
|
def get_last_paid(self):
|
|
status = self.get_status()
|
|
return status['last_paid']
|
|
|
|
def get_next_unpaid(self):
|
|
status = self.get_status()
|
|
return status['next_unpaid']
|
|
|
|
|
|
def __init__(self, _id, _username, _type, _active):
|
|
self.id = _id
|
|
self.username = _username
|
|
self.type = _type
|
|
self.active = _active
|
|
now_date = datetime.datetime.now()
|
|
self.join_year = now_date.year
|
|
self.join_month = now_date.month
|
|
self.payment_policy = PaymentPolicy.normal.value
|
|
|
|
|
|
class Transfer(db.Model):
|
|
id = db.Column(db.Integer, primary_key=True)
|
|
uid = db.Column(db.String(128))
|
|
account_from = db.Column(db.String(32))
|
|
name_from = db.Column(db.String(256))
|
|
amount = db.Column(db.Integer)
|
|
title = db.Column(db.String(256))
|
|
date = db.Column(db.Date)
|
|
ignore = db.Column(db.Boolean)
|
|
|
|
def __init__(self, _id, _uid, _account_from, _name_from, _amount, _title, _date, _ignore):
|
|
self.id = _id
|
|
self.uid = _uid
|
|
self.account_from = _account_from
|
|
self.name_from = _name_from
|
|
self.amount = _amount
|
|
self.title = _title
|
|
self.date = _date
|
|
self.ignore = _ignore
|
|
|
|
def get_short_uid(self):
|
|
return self.uid[:16]
|
|
|
|
def parse_title(self):
|
|
m = re.match(ur"^([a-z0-9ąężźćóżłśń\-_\.]+) *\- *(fatty|starving|superfatty|supporting|supporter) *\- *([0-9a-z\-_ąężźćóżłśń \(\),/\.]+$)", self.title.strip().lower())
|
|
if not m:
|
|
return (None, None, None)
|
|
member, _type, title = m.group(1), m.group(2), m.group(3)
|
|
if title in [u"składka", u"opłata", u"opłata miesięczna", "skladka"]:
|
|
return (member, _type, None)
|
|
return member, _type, title
|
|
|
|
MATCH_OK, MATCH_WRONG_TYPE, MATCH_NO_USER, MATCH_UNPARSEABLE = range(4)
|
|
def get_matchability(self):
|
|
title = self.parse_title()
|
|
if not title[0]:
|
|
return self.MATCH_UNPARSEABLE, self.title
|
|
member_name = title[0]
|
|
member = Member.query.filter(or_(Member.username==member_name, Member.alias==member_name)).first()
|
|
if not member:
|
|
return self.MATCH_NO_USER, member_name, 0
|
|
|
|
if title[2]:
|
|
return self.MATCH_WRONG_TYPE, member, 0
|
|
|
|
if title[1] == 'starving' and self.amount >= (50*100) and (self.amount % (50*100)) == 0:
|
|
return self.MATCH_OK, member, (self.amount/(50*100))
|
|
|
|
if title[1] == 'fatty' and self.amount >= (100*100) and (self.amount % (100*100)) == 0:
|
|
return self.MATCH_OK, member, (self.amount/(100*100))
|
|
|
|
|
|
return self.MATCH_WRONG_TYPE, member, 0
|