You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

527 lines
16 KiB

import logging
logger = logging.getLogger(__name__)
import io
import json
import requests
import time
from datetime import datetime, timedelta
from rest_framework.exceptions import ValidationError
from rest_framework.views import exception_handler
from dateutil import relativedelta
from uuid import uuid4
from PIL import Image, ImageDraw, ImageFont, ImageOps, JpegImagePlugin
JpegImagePlugin._getmp = lambda x: None
from bleach.sanitizer import Cleaner
from PyPDF2 import PdfFileWriter, PdfFileReader
from reportlab.pdfgen import canvas
from reportlab.lib.pagesizes import letter
from django.db.models import Sum
from django.core.cache import cache
from django.utils.timezone import now, pytz
from . import models, serializers, utils_ldap, utils_stats, utils_auth, utils, utils_email
from .. import settings, secrets
STATIC_FOLDER = 'data/static/'
TIMEZONE_CALGARY = pytz.timezone('America/Edmonton')
def today_alberta_tz():
return datetime.now(TIMEZONE_CALGARY).date()
def now_alberta_tz():
return datetime.now(TIMEZONE_CALGARY)
def alert_tanner(message):
try:
logger.info('Alerting Tanner: ' + message)
params = dict(spaceport=message)
requests.get('https://tbot.tannercollin.com/message', params=params, timeout=4)
except BaseException as e:
logger.error('Problem alerting Tanner: ' + str(e))
def spaceporter_host(message):
logger.info('Spaceporter bot sending to host chat: ' + message)
if secrets.SPACEPORTER_HOST_TOKEN:
url = 'https://forum.protospace.ca/chat/hooks/{}.json'.format(
secrets.SPACEPORTER_HOST_TOKEN,
)
else:
logger.info('Aborting Spaceporter bot message, no token.')
return
try:
data = dict(text=message)
requests.post(url, json=data, timeout=4)
except BaseException as e:
logger.error('Problem with bot: ' + str(e))
def num_months_spanned(d1, d2):
'''
Return number of month thresholds two dates span.
Order of arguments is same as subtraction
ie. Feb 2, Jan 29 returns 1
'''
return (d1.year - d2.year) * 12 + d1.month - d2.month
def num_months_difference(d1, d2):
'''
Return number of whole months between two dates.
Order of arguments is same as subtraction
ie. Feb 2, Jan 29 returns 0
'''
r = relativedelta.relativedelta(d1, d2)
return r.months + 12 * r.years
def calc_member_status(expire_date, fake_date=None):
'''
Return: member status
'''
today = fake_date or today_alberta_tz()
difference = num_months_difference(expire_date, today)
if today + timedelta(days=29) < expire_date:
return 'Prepaid'
elif difference <= -3:
return 'Expired Member'
elif today - timedelta(days=29) >= expire_date:
return 'Overdue'
elif today < expire_date:
return 'Current'
elif today >= expire_date:
return 'Due'
else:
raise()
def add_months(date, num_months):
return date + relativedelta.relativedelta(months=num_months)
def tally_membership_months(member, fake_date=None):
'''
Sum together member's dues and calculate their new expire date and status
Doesn't work if member is paused.
'''
if member.paused_date: return False
start_date = member.current_start_date
if not start_date: return False
txs = models.Transaction.objects.filter(
user__member=member,
date__gte=start_date,
)
total_months_agg = txs.aggregate(Sum('number_of_membership_months'))
total_months = total_months_agg['number_of_membership_months__sum'] or 0
expire_date = add_months(start_date, total_months)
status = calc_member_status(expire_date, fake_date)
if member.expire_date != expire_date or member.status != status:
previous_status = member.status
member.expire_date = expire_date
member.status = status
if status == 'Expired Member':
member.paused_date = today_alberta_tz()
msg = 'Member has expired: {} {}'.format(member.preferred_name, member.last_name)
alert_tanner(msg)
logger.info(msg)
if status == 'Overdue':
if previous_status == 'Due':
msg = 'Member has become Overdue: {} {}'.format(member.preferred_name, member.last_name)
alert_tanner(msg)
logger.info(msg)
utils_email.send_overdue_email(member)
else:
logger.info('Skipping email because member wasn\'t due before.')
member.save()
logging.debug('Tallied %s membership months: updated.', member)
else:
logging.debug('Tallied %s membership months: no changes.', member)
return True
def gen_search_strings():
'''
Generate a cache dict of names to member ids for rapid string matching
'''
start = time.time()
search_strings = {}
for m in models.Member.objects.order_by('-expire_date').prefetch_related('user__storage'):
string = '{} {} | {} {}'.format(
m.preferred_name,
m.last_name,
m.first_name,
m.last_name,
)
string += ' | ' + m.user.email
if m.discourse_username:
string += ' | ' + m.discourse_username
string += ' | ' + str(m.id)
for s in m.user.storage.all():
string += ' | ' + s.shelf_id
string = string.lower()
search_strings[string] = m.id
cache.set('search_strings', search_strings)
logger.info('Generated search strings in %s s.', time.time() - start)
LARGE_SIZE = 1080
MEDIUM_SIZE = 220
SMALL_SIZE = 110
def process_image_upload(upload, crop):
'''
Save an image upload in small, medium, large sizes and return filenames
'''
try:
pic = Image.open(upload)
except OSError:
raise serializers.ValidationError(dict(non_field_errors='Invalid image file.'))
logging.info('Detected format: %s', pic.format)
if pic.format == 'PNG':
ext = '.png'
elif pic.format == 'JPEG':
ext = '.jpg'
else:
raise serializers.ValidationError(dict(non_field_errors='Image must be a jpg or png.'))
pic = ImageOps.exif_transpose(pic)
if crop:
crop = json.loads(crop)
pic_x, pic_y = pic.size
left = pic_x * crop['x']/100.0
top = pic_y * crop['y']/100.0
right = left + pic_x * crop['width']/100.0
bottom = top + pic_y * crop['height']/100.0
pic = pic.crop((left, top, right, bottom))
large = str(uuid4()) + ext
pic.thumbnail([LARGE_SIZE, LARGE_SIZE], Image.ANTIALIAS)
pic.save(STATIC_FOLDER + large)
medium = str(uuid4()) + ext
pic.thumbnail([MEDIUM_SIZE, MEDIUM_SIZE], Image.ANTIALIAS)
pic.save(STATIC_FOLDER + medium)
small = str(uuid4()) + ext
pic.thumbnail([SMALL_SIZE, SMALL_SIZE], Image.ANTIALIAS)
pic.save(STATIC_FOLDER + small)
return small, medium, large
GARDEN_MEDIUM_SIZE = 500
def process_garden_image(upload):
try:
pic = Image.open(upload)
except OSError:
raise serializers.ValidationError(dict(non_field_errors='Invalid image file.'))
logging.debug('Detected format: %s', pic.format)
if pic.format == 'PNG':
ext = '.png'
elif pic.format == 'JPEG':
ext = '.jpg'
else:
raise serializers.ValidationError(dict(non_field_errors='Image must be a jpg or png.'))
pic = ImageOps.exif_transpose(pic)
draw = ImageDraw.Draw(pic)
timestamp = now_alberta_tz().strftime('%a %b %-d, %Y %-I:%M %p')
font = ImageFont.truetype('DejaVuSans.ttf', 60)
draw.text((10, 10), timestamp, (0,0,0), font=font)
large = 'garden-large' + ext
pic.save(STATIC_FOLDER + large)
medium = 'garden-medium' + ext
pic.thumbnail([GARDEN_MEDIUM_SIZE, GARDEN_MEDIUM_SIZE], Image.ANTIALIAS)
pic.save(STATIC_FOLDER + medium)
return medium, large
CARD_TEMPLATE_FILE = 'misc/member_card_template.jpg'
CARD_PHOTO_SIZE = 425
CARD_PHOTO_MARGIN_TOP = 75
CARD_PHOTO_MARGIN_SIDE = 30
CARD_TEXT_SIZE_LIMIT = 550
def gen_card_photo(member):
card_template = Image.open(CARD_TEMPLATE_FILE)
member_photo = Image.open(STATIC_FOLDER + member.photo_large)
member_photo.thumbnail([CARD_PHOTO_SIZE, CARD_PHOTO_SIZE], Image.ANTIALIAS)
member_photo = ImageOps.expand(member_photo, border=10)
mx, my = member_photo.size
x = CARD_PHOTO_MARGIN_SIDE
y = CARD_PHOTO_MARGIN_TOP
card_template.paste(member_photo, (x, y))
draw = ImageDraw.Draw(card_template)
# check font size
font_sizes = (60, 72)
font = ImageFont.truetype('DejaVuSans-Bold.ttf', font_sizes[1])
size = draw.textsize(str(member.last_name), font=font)
if size[0] > CARD_TEXT_SIZE_LIMIT:
font_sizes = (36, 48)
font = ImageFont.truetype('DejaVuSans.ttf', font_sizes[0])
x = CARD_PHOTO_MARGIN_SIDE
y = my + CARD_PHOTO_MARGIN_TOP + CARD_PHOTO_MARGIN_SIDE
draw.text((x, y), str(member.preferred_name), (0,0,0), font=font)
font = ImageFont.truetype('DejaVuSans-Bold.ttf', font_sizes[1])
y = my + CARD_PHOTO_MARGIN_TOP + CARD_PHOTO_MARGIN_SIDE + font_sizes[1]
draw.text((x, y), str(member.last_name), (0,0,0), font=font)
font = ImageFont.truetype('DejaVuSans.ttf', 36)
draw.text((x, 800), 'Joined: ' + str(member.application_date or 'Unknown'), (0,0,0), font=font)
y = CARD_PHOTO_MARGIN_SIDE
draw.text((475, y), str(member.id), (0,0,0), font=font)
bio = io.BytesIO()
card_template.save(bio, 'JPEG', quality=95)
bio.seek(0)
return bio
ALLOWED_TAGS = [
'h3',
'p',
'br',
'strong',
'em',
'u',
'code',
'ol',
'li',
'ul',
'a',
]
clean = Cleaner(tags=ALLOWED_TAGS).clean
def is_request_from_protospace(request):
# TODO: pull to config
whitelist = ['24.66.110.96', '205.233.15.76', '205.233.15.69', '70.75.142.145']
if settings.DEBUG:
return True
# set (not appended) directly by nginx so we can trust it
real_ip = request.META.get('HTTP_X_REAL_IP', False)
return real_ip in whitelist
def create_new_member(data, user):
members = models.Member.objects
if members.filter(old_email__iexact=data['email']).exists():
msg = 'Account was found in old portal.'
logger.info(msg)
raise ValidationError(dict(email=msg))
if utils_ldap.is_configured():
if data['request_id']: utils_stats.set_progress(data['request_id'], 'Creating LDAP account...')
result = utils_ldap.find_user(user.username)
if result == 200:
msg = 'Username was found in old portal.'
logger.info(msg)
raise ValidationError(dict(username=msg))
elif result == 404:
pass
else:
msg = 'Problem connecting to LDAP server.'
alert_tanner(msg)
logger.info(msg)
raise ValidationError(dict(non_field_errors=msg))
if utils_ldap.create_user(data) != 200:
msg = 'Problem connecting to LDAP server: create.'
alert_tanner(msg)
logger.info(msg)
raise ValidationError(dict(non_field_errors=msg))
if data['request_id']: utils_stats.set_progress(data['request_id'], 'Creating new member...')
models.Member.objects.create(
user=user,
first_name=data['first_name'],
last_name=data['last_name'],
preferred_name=data['preferred_name'],
)
def register_user(data, user):
data = data.copy()
data['first_name'] = data['first_name'].title().strip()
data['last_name'] = data['last_name'].title().strip()
data['preferred_name'] = data['preferred_name'].title().strip()
# Sometimes during demos, a user makes a fake account then then has to be cleaned out
# Notify me that this has happened so I can go clean out the database
if 'test' in data['username']:
msg = 'Someone created a test account: {} {} {} {}'.format(
data['username'],
data['first_name'],
data['last_name'],
data['email'],
)
logger.info(msg)
alert_tanner(msg)
try:
logger.info('Creating new member...')
create_new_member(data, user)
except:
user.delete()
raise
auth_data = dict(
username=data['username'],
password=data['password1'],
email=data['email'],
first_name=data['preferred_name'],
)
if utils_auth.wiki_is_configured():
if data['request_id']: utils_stats.set_progress(data['request_id'], 'Creating Wiki account...')
if utils_auth.set_wiki_password(auth_data) != 200:
msg = 'Problem connecting to Wiki Auth server: set.'
utils.alert_tanner(msg)
logger.info(msg)
if utils_auth.discourse_is_configured():
if data['request_id']: utils_stats.set_progress(data['request_id'], 'Creating Discourse account...')
if utils_auth.set_discourse_password(auth_data) != 200:
msg = 'Problem connecting to Discourse Auth server: set.'
utils.alert_tanner(msg)
logger.info(msg)
if not user.member.discourse_username:
user.member.discourse_username = user.username
user.member.save()
if utils_auth.discourse_is_configured():
if data['request_id']: utils_stats.set_progress(data['request_id'], 'Adding to Discourse group...')
if utils_auth.add_discourse_group_members('protospace_members', [data['username']]) != 200:
msg = 'Problem connecting to Discourse Auth server: add.'
utils.alert_tanner(msg)
logger.info(msg)
if data['request_id']: utils_stats.set_progress(data['request_id'], 'Sending welcome email...')
try:
utils_email.send_welcome_email(user.member)
except BaseException as e:
msg = 'Problem sending welcome email: ' + str(e)
logger.exception(msg)
alert_tanner(msg)
if data['request_id']: utils_stats.set_progress(data['request_id'], 'Done!')
gen_search_strings()
cache.set('sign', 'Welcome to Protospace, {}!'.format(data['preferred_name']))
BLANK_FORM = 'misc/blank_member_form.pdf'
def gen_member_forms(member):
serializer = serializers.MemberSerializer(member)
data = serializer.data
packet = io.BytesIO()
can = canvas.Canvas(packet, pagesize=letter)
can.drawString(34, 683, data['first_name'])
can.drawString(218, 683, data['last_name'])
can.drawString(403, 683, data['preferred_name'])
can.drawString(34, 626, data['email'])
can.drawString(332, 626, data['phone'])
can.drawString(34, 570, data['emergency_contact_name'])
can.drawString(332, 570, data['emergency_contact_phone'])
can.save()
packet.seek(0)
info_pdf = PdfFileReader(packet)
packet = io.BytesIO()
can = canvas.Canvas(packet, pagesize=letter)
can.drawRightString(600, 770, '{} {} ({})'.format(
data['preferred_name'],
data['last_name'],
data['id'],
))
can.save()
packet.seek(0)
topright_pdf = PdfFileReader(packet)
existing_pdf = PdfFileReader(open(BLANK_FORM, 'rb'))
output = PdfFileWriter()
page = existing_pdf.getPage(0)
page.mergePage(info_pdf.getPage(0))
page.mergePage(topright_pdf.getPage(0))
output.addPage(page)
page = existing_pdf.getPage(1)
page.mergePage(topright_pdf.getPage(0))
output.addPage(page)
page = existing_pdf.getPage(2)
page.mergePage(topright_pdf.getPage(0))
output.addPage(page)
file_name = str(uuid4()) + '.pdf'
outputStream = open(STATIC_FOLDER + file_name, 'wb')
output.write(outputStream)
member.member_forms = file_name
member.save()
def custom_exception_handler(exc, context):
response = exception_handler(exc, context)
if response is not None:
if hasattr(exc, 'detail'):
logging.warning('Response: %s', json.dumps(exc.detail))
else:
logging.warning('Response: %s', exc)
return response
def log_transaction(tx):
msg = 'Transaction log | {} | {} | {} | {} | {} | {} | {} | {} | {}'.format(
tx.id,
tx.user.username,
tx.user.member.id,
tx.account_type,
tx.amount,
tx.protocoin,
tx.category,
tx.reference_number,
tx.memo,
)
logging.info(msg)