initial creation

This commit is contained in:
2025-05-20 11:57:43 -04:00
commit c98b612926
8447 changed files with 1862526 additions and 0 deletions
@@ -0,0 +1,69 @@
from flask import render_template
from snakeeyes.extensions import mail
def send_template_message(template=None, ctx=None, *args, **kwargs):
"""
Send a templated e-mail using a similar signature as Flask-Mail:
http://pythonhosted.org/Flask-Mail/
Except, it also supports template rendering. If you want to use a template
then just omit the body and html kwargs to Flask-Mail and instead supply
a path to a template. It will auto-lookup and render text/html messages.
Example:
ctx = {'user': current_user, 'reset_token': token}
send_template_message('Password reset from Foo', ['you@example.com'],
template='user/mail/password_reset', ctx=ctx)
:param subject:
:param recipients:
:param body:
:param html:
:param sender:
:param cc:
:param bcc:
:param attachments:
:param reply_to:
:param date:
:param charset:
:param extra_headers:
:param mail_options:
:param rcpt_options:
:param template: Path to a template without the extension
:param context: Dictionary of anything you want in the template context
:return: None
"""
if ctx is None:
ctx = {}
if template is not None:
if 'body' in kwargs:
raise Exception('You cannot have both a template and body arg.')
elif 'html' in kwargs:
raise Exception('You cannot have both a template and body arg.')
kwargs['body'] = _try_renderer_template(template, **ctx)
kwargs['html'] = _try_renderer_template(template, ext='html', **ctx)
mail.send_message(*args, **kwargs)
return None
def _try_renderer_template(template_path, ext='txt', **kwargs):
"""
Attempt to render a template. We use a try/catch here to avoid having to
do a path exists based on a relative path to the template.
:param template_path: Template path
:type template_path: str
:param ext: File extension
:type ext: str
:return: str
"""
try:
return render_template('{0}.{1}'.format(template_path, ext), **kwargs)
except IOError:
pass
@@ -0,0 +1,158 @@
# -*- coding: utf-8 -*-
from collections import OrderedDict
class Currency(object):
TYPES = OrderedDict([
('usd', u'United States Dollar'),
('aed', u'United Arab Emirates Dirham'),
('afn', u'Afghan Afghani'),
('all', u'Albanian Lek'),
('amd', u'Armenian Dram'),
('ang', u'Netherlands Antillean Gulden'),
('aoa', u'Angolan Kwanza'),
('ars', u'Argentine Peso'),
('aud', u'Australian Dollar'),
('awg', u'Aruban Florin'),
('azn', u'Azerbaijani Manat'),
('bam', u'Bosnia & Herzegovina Convertible Mark'),
('bbd', u'Barbadian Dollar'),
('bdt', u'Bangladeshi Taka'),
('bgn', u'Bulgarian Lev'),
('bif', u'Burundian Franc'),
('bmd', u'Bermudian Dollar'),
('bnd', u'Brunei Dollar'),
('bob', u'Bolivian Boliviano'),
('brl', u'Brazilian Real'),
('bsd', u'Bahamian Dollar'),
('bwp', u'Botswana Pula'),
('bzd', u'Belize Dollar'),
('cad', u'Canadian Dollar'),
('cdf', u'Congolese Franc'),
('chf', u'Swiss Franc'),
('clp', u'Chilean Peso'),
('cny', u'Chinese Renminbi Yuan'),
('cop', u'Colombian Peso'),
('crc', u'Costa Rican Colón'),
('cve', u'Cape Verdean Escudo'),
('czk', u'Czech Koruna'),
('djf', u'Djiboutian Franc'),
('dkk', u'Danish Krone'),
('dop', u'Dominican Peso'),
('dzd', u'Algerian Dinar'),
('eek', u'Estonian Kroon'),
('egp', u'Egyptian Pound'),
('etb', u'Ethiopian Birr'),
('eur', u'Euro'),
('fjd', u'Fijian Dollar'),
('fkp', u'Falkland Islands Pound'),
('gbp', u'British Pound'),
('gel', u'Georgian Lari'),
('gip', u'Gibraltar Pound'),
('gmd', u'Gambian Dalasi'),
('gnf', u'Guinean Franc'),
('gtq', u'Guatemalan Quetzal'),
('gyd', u'Guyanese Dollar'),
('hkd', u'Hong Kong Dollar'),
('hnl', u'Honduran Lempira'),
('hrk', u'Croatian Kuna'),
('htg', u'Haitian Gourde'),
('huf', u'Hungarian Forint'),
('idr', u'Indonesian Rupiah'),
('ils', u'Israeli New Sheqel'),
('inr', u'Indian Rupee'),
('isk', u'Icelandic Króna'),
('jmd', u'Jamaican Dollar'),
('jpy', u'Japanese Yen'),
('kes', u'Kenyan Shilling'),
('kgs', u'Kyrgyzstani Som'),
('khr', u'Cambodian Riel'),
('kmf', u'Comorian Franc'),
('krw', u'South Korean Won'),
('kyd', u'Cayman Islands Dollar'),
('kzt', u'Kazakhstani Tenge'),
('lak', u'Lao Kip'),
('lbp', u'Lebanese Pound'),
('lkr', u'Sri Lankan Rupee'),
('lrd', u'Liberian Dollar'),
('lsl', u'Lesotho Loti'),
('ltl', u'Lithuanian Litas'),
('lvl', u'Latvian Lats'),
('mad', u'Moroccan Dirham'),
('mdl', u'Moldovan Leu'),
('mga', u'Malagasy Ariary'),
('mkd', u'Macedonian Denar'),
('mnt', u'Mongolian Tögrög'),
('mop', u'Macanese Pataca'),
('mro', u'Mauritanian Ouguiya'),
('mur', u'Mauritian Rupee'),
('mvr', u'Maldivian Rufiyaa'),
('mwk', u'Malawian Kwacha'),
('mxn', u'Mexican Peso'),
('myr', u'Malaysian Ringgit'),
('mzn', u'Mozambican Metical'),
('nad', u'Namibian Dollar'),
('ngn', u'Nigerian Naira'),
('nio', u'Nicaraguan Córdoba'),
('nok', u'Norwegian Krone'),
('npr', u'Nepalese Rupee'),
('nzd', u'New Zealand Dollar'),
('pab', u'Panamanian Balboa'),
('pen', u'Peruvian Nuevo Sol'),
('pgk', u'Papua New Guinean Kina'),
('php', u'Philippine Peso'),
('pkr', u'Pakistani Rupee'),
('pln', u'Polish Złoty'),
('pyg', u'Paraguayan Guaraní'),
('qar', u'Qatari Riyal'),
('ron', u'Romanian Leu'),
('rsd', u'Serbian Dinar'),
('rub', u'Russian Ruble'),
('rwf', u'Rwandan Franc'),
('sar', u'Saudi Riyal'),
('sbd', u'Solomon Islands Dollar'),
('scr', u'Seychellois Rupee'),
('sek', u'Swedish Krona'),
('sgd', u'Singapore Dollar'),
('shp', u'Saint Helenian Pound'),
('sll', u'Sierra Leonean Leone'),
('sos', u'Somali Shilling'),
('srd', u'Surinamese Dollar'),
('std', u'São Tomé and Príncipe Dobra'),
('svc', u'Salvadoran Colón'),
('szl', u'Swazi Lilangeni'),
('thb', u'Thai Baht'),
('tjs', u'Tajikistani Somoni'),
('top', u'Tongan Paʻanga'),
('try', u'Turkish Lira'),
('ttd', u'Trinidad and Tobago Dollar'),
('twd', u'New Taiwan Dollar'),
('tzs', u'Tanzanian Shilling'),
('uah', u'Ukrainian Hryvnia'),
('ugx', u'Ugandan Shilling'),
('uyu', u'Uruguayan Peso'),
('uzs', u'Uzbekistani Som'),
('vef', u'Venezuelan Bolívar'),
('vnd', u'Vietnamese Đồng'),
('vuv', u'Vanuatu Vatu'),
('wst', u'Samoan Tala'),
('xaf', u'Central African Cfa Franc'),
('xcd', u'East Caribbean Dollar'),
('xof', u'West African Cfa Franc'),
('xpf', u'Cfp Franc'),
('yer', u'Yemeni Rial'),
('zar', u'South African Rand'),
('zmw', u'Zambian Kwacha')
])
@classmethod
def lookup(cls, currency_code):
"""
Return the full currency name.
:param currency_code: Currency abbreviation
:type currency_code: str
:return: str
"""
return Currency.TYPES[currency_code]
@@ -0,0 +1,20 @@
def cents_to_dollars(cents):
"""
Convert cents to dollars.
:param cents: Amount in cents
:type cents: int
:return: float
"""
return round(cents / 100.0, 2)
def dollars_to_cents(dollars):
"""
Convert dollars to cents.
:param dollars: Amount in dollars
:type dollars: float
:return: int
"""
return int(dollars * 100)
@@ -0,0 +1,19 @@
try:
from urlparse import urljoin
except ImportError:
from urllib.parse import urljoin
from flask import request
def safe_next_url(target):
"""
Ensure a relative URL path is on the same domain as this host.
This protects against the 'Open redirect vulnerability'.
:param target: Relative url (typically supplied by Flask-Login)
:type target: str
:return: str
"""
return urljoin(request.host_url, target)
@@ -0,0 +1,75 @@
import pytest
from flask import url_for
def assert_status_with_message(status_code=200, response=None, message=None):
"""
Check to see if a message is contained within a response.
:param status_code: Status code that defaults to 200
:type status_code: int
:param response: Flask response
:type response: str
:param message: String to check for
:type message: str
:return: None
"""
assert response.status_code == status_code
assert message in str(response.data)
class ViewTestMixin(object):
"""
Automatically load in a session and client, this is common for a lot of
tests that work with views.
"""
@pytest.fixture(autouse=True)
def set_common_fixtures(self, session, client):
self.session = session
self.client = client
def login(self, identity='admin@local.host', password='password'):
"""
Login a specific user.
:return: Flask response
"""
return login(self.client, identity, password)
def logout(self):
"""
Logout a specific user.
:return: Flask response
"""
return logout(self.client)
def login(client, username='', password=''):
"""
Log a specific user in.
:param client: Flask client
:param username: The username
:type username: str
:param password: The password
:type password: str
:return: Flask response
"""
user = dict(identity=username, password=password)
response = client.post(url_for('user.login'), data=user,
follow_redirects=True)
return response
def logout(client):
"""
Log a specific user out.
:param client: Flask client
:return: Flask response
"""
return client.get(url_for('user.logout'), follow_redirects=True)
@@ -0,0 +1,30 @@
import datetime
import pytz
def tzware_datetime():
"""
Return a timezone aware datetime.
:return: Datetime
"""
return datetime.datetime.now(pytz.utc)
def timedelta_months(months, compare_date=None):
"""
Return a new datetime with a month offset applied.
:param months: Amount of months to offset
:type months: int
:param compare_date: Date to compare at
:type compare_date: date
:return: datetime
"""
if compare_date is None:
compare_date = datetime.date.today()
delta = months * 365 / 12
compare_date_with_delta = compare_date + datetime.timedelta(delta)
return compare_date_with_delta
@@ -0,0 +1,21 @@
from flask import jsonify
def render_json(status, *args, **kwargs):
"""
Return a JSON response.
Example usage:
render_json(404, {'error': 'Discount code not found.'})
render_json(200, {'data': coupon.to_json()})
:param status: HTTP status code
:type status: int
:param args:
:param kwargs:
:return: Flask response
"""
response = jsonify(*args, **kwargs)
response.status_code = status
return response
@@ -0,0 +1,139 @@
import datetime
from sqlalchemy import DateTime
from sqlalchemy.types import TypeDecorator
from lib.util_datetime import tzware_datetime
from snakeeyes.extensions import db
class AwareDateTime(TypeDecorator):
"""
A DateTime type which can only store tz-aware DateTimes.
Source:
https://gist.github.com/inklesspen/90b554c864b99340747e
"""
impl = DateTime(timezone=True)
def process_bind_param(self, value, dialect):
if isinstance(value, datetime.datetime) and value.tzinfo is None:
raise ValueError('{!r} must be TZ-aware'.format(value))
return value
def __repr__(self):
return 'AwareDateTime()'
class ResourceMixin(object):
# Keep track when records are created and updated.
created_on = db.Column(AwareDateTime(),
default=tzware_datetime)
updated_on = db.Column(AwareDateTime(),
default=tzware_datetime,
onupdate=tzware_datetime)
@classmethod
def sort_by(cls, field, direction):
"""
Validate the sort field and direction.
:param field: Field name
:type field: str
:param direction: Direction
:type direction: str
:return: tuple
"""
if field not in cls.__table__.columns:
field = 'created_on'
if direction not in ('asc', 'desc'):
direction = 'asc'
return field, direction
@classmethod
def get_bulk_action_ids(cls, scope, ids, omit_ids=[], query=''):
"""
Determine which IDs are to be modified.
:param scope: Affect all or only a subset of items
:type scope: str
:param ids: List of ids to be modified
:type ids: list
:param omit_ids: Remove 1 or more IDs from the list
:type omit_ids: list
:param query: Search query (if applicable)
:type query: str
:return: list
"""
# Hello. This is Nick from the future (July 2020 to be exact). This
# needed to be patched to include a call to list(), otherwise it was
# deleting every user instead of skipping the current_user.
omit_ids = list(map(str, omit_ids))
# Hello. This is Nick from the future (July 2022 to be exact). I added
# the query condition below to make this a bit more intuitive so that
# an empty search query won't delete everything. Prior to this an empty
# query would have been technically all of the results.
if query and scope == 'all_search_results':
# Change the scope to go from selected ids to all search results.
ids = cls.query.with_entities(cls.id).filter(cls.search(query))
# SQLAlchemy returns back a list of tuples, we want a list of strs.
ids = [str(item[0]) for item in ids]
# Remove 1 or more items from the list, this could be useful in spots
# where you may want to protect the current user from deleting themself
# when bulk deleting user accounts.
if omit_ids:
ids = [id for id in ids if id not in omit_ids]
return ids
@classmethod
def bulk_delete(cls, ids):
"""
Delete 1 or more model instances.
:param ids: List of ids to be deleted
:type ids: list
:return: Number of deleted instances
"""
delete_count = cls.query.filter(cls.id.in_(ids)).delete(
synchronize_session=False)
db.session.commit()
return delete_count
def save(self):
"""
Save a model instance.
:return: Model instance
"""
db.session.add(self)
db.session.commit()
return self
def delete(self):
"""
Delete a model instance.
:return: db.session.commit()'s result
"""
db.session.delete(self)
return db.session.commit()
def __str__(self):
"""
Create a human readable version of a class instance.
:return: self
"""
obj_id = hex(id(self))
columns = self.__table__.c.keys()
values = ', '.join("%s=%r" % (n, getattr(self, n)) for n in columns)
return '<%s %s(%s)>' % (obj_id, self.__class__.__name__, values)
@@ -0,0 +1,89 @@
from flask_wtf import Form
class ModelForm(Form):
"""
wtforms_components exposes ModelForm but their ModelForm does not inherit
from flask_wtf's Form, but instead WTForm's Form.
However, in order to get CSRF protection handled by default we need to
inherit from flask_wtf's Form. So let's just copy his class directly.
We modified it by removing the format argument so that wtforms_component
uses its own default which is to pass in request.form automatically.
"""
def __init__(self, obj=None, prefix='', **kwargs):
Form.__init__(
self, obj=obj, prefix=prefix, **kwargs
)
self._obj = obj
def choices_from_dict(source, prepend_blank=True):
"""
Convert a dict to a format that's compatible with WTForm's choices. It also
optionally prepends a "Please select one..." value.
Example:
# Convert this data structure:
STATUS = OrderedDict([
('unread', 'Unread'),
('open', 'Open'),
('contacted', 'Contacted'),
('closed', 'Closed')
])
# Into this:
choices = [('', 'Please select one...'), ('unread', 'Unread) ...]
:param source: Input source
:type source: dict
:param prepend_blank: An optional blank item
:type prepend_blank: bool
:return: list
"""
choices = []
if prepend_blank:
choices.append(('', 'Please select one...'))
for key, value in source.items():
pair = (key, value)
choices.append(pair)
return choices
def choices_from_list(source, prepend_blank=True):
"""
Convert a list to a format that's compatible with WTForm's choices. It also
optionally prepends a "Please select one..." value.
Example:
# Convert this data structure:
TIMEZONES = (
'Africa/Abidjan',
'Africa/Accra',
'Africa/Addis_Ababa'
)
# Into this:
choices = [('', 'Please select one...'),
('Africa/Abidjan', 'Africa/Abidjan) ...]
:param source: Input source
:type source: list or tuple
:param prepend_blank: An optional blank item
:type prepend_blank: bool
:return: list
"""
choices = []
if prepend_blank:
choices.append(('', 'Please select one...'))
for item in source:
pair = (item, item)
choices.append(pair)
return choices