Update atst to atat

This commit is contained in:
leigh-mil
2020-02-28 16:01:45 -05:00
parent 6eb48239cf
commit c2814416fb
215 changed files with 735 additions and 746 deletions

49
atat/utils/__init__.py Normal file
View File

@@ -0,0 +1,49 @@
import hashlib
import re
from sqlalchemy.exc import IntegrityError
from atat.database import db
from atat.domain.exceptions import AlreadyExistsError
def first_or_none(predicate, lst):
return next((x for x in lst if predicate(x)), None)
def getattr_path(obj, path, default=None):
_obj = obj
for item in path.split("."):
if isinstance(_obj, dict):
_obj = _obj.get(item)
else:
_obj = getattr(_obj, item, default)
return _obj
def camel_to_snake(camel_cased):
s1 = re.sub("(.)([A-Z][a-z]+)", r"\1_\2", camel_cased)
return re.sub("([a-z0-9])([A-Z])", r"\1_\2", s1).lower()
def snake_to_camel(snake_cased):
parts = snake_cased.split("_")
return f"{parts[0]}{''.join([w.capitalize() for w in parts[1:]])}"
def pick(keys, dct):
_keys = set(keys)
return {k: v for (k, v) in dct.items() if k in _keys}
def commit_or_raise_already_exists_error(message):
try:
db.session.commit()
except IntegrityError:
db.session.rollback()
raise AlreadyExistsError(message)
def sha256_hex(string):
hsh = hashlib.sha256(string.encode())
return hsh.digest().hex()

View File

@@ -0,0 +1,128 @@
from flask import g
from sqlalchemy.orm.exc import NoResultFound
from atat.database import db
from atat.domain.authz import Authorization
from atat.domain.exceptions import NotFoundError
from atat.domain.portfolios.scopes import ScopedPortfolio
from atat.models import (
Application,
Environment,
Permissions,
Portfolio,
PortfolioInvitation,
PortfolioRole,
TaskOrder,
)
def get_resources_from_context(view_args):
query = None
if view_args is None:
view_args = {}
if "portfolio_token" in view_args:
query = (
db.session.query(Portfolio)
.join(PortfolioRole, PortfolioRole.portfolio_id == Portfolio.id)
.join(
PortfolioInvitation,
PortfolioInvitation.portfolio_role_id == PortfolioRole.id,
)
.filter(PortfolioInvitation.token == view_args["portfolio_token"])
)
elif "portfolio_id" in view_args:
query = db.session.query(Portfolio).filter(
Portfolio.id == view_args["portfolio_id"]
)
elif "application_id" in view_args:
query = (
db.session.query(Portfolio, Application)
.join(Application, Application.portfolio_id == Portfolio.id)
.filter(Application.id == view_args["application_id"])
)
elif "environment_id" in view_args:
query = (
db.session.query(Portfolio, Application)
.join(Application, Application.portfolio_id == Portfolio.id)
.join(Environment, Environment.application_id == Application.id)
.filter(Environment.id == view_args["environment_id"])
)
elif "task_order_id" in view_args:
query = (
db.session.query(Portfolio, TaskOrder)
.join(TaskOrder, TaskOrder.portfolio_id == Portfolio.id)
.filter(TaskOrder.id == view_args["task_order_id"])
)
if query:
try:
return query.only_return_tuples(True).one()
except NoResultFound:
raise NotFoundError("portfolio")
def assign_resources(view_args):
g.portfolio = None
g.application = None
g.task_order = None
resources = get_resources_from_context(view_args)
if resources:
for resource in resources:
if isinstance(resource, Portfolio):
g.portfolio = ScopedPortfolio(g.current_user, resource)
elif isinstance(resource, Application):
g.application = resource
elif isinstance(resource, TaskOrder):
g.task_order = resource
def user_can_view(permission):
if g.application:
return Authorization.has_application_permission(
g.current_user, g.application, permission
)
elif g.portfolio:
return Authorization.has_portfolio_permission(
g.current_user, g.portfolio, permission
)
else:
return Authorization.has_atat_permission(g.current_user, permission)
def portfolio():
if g.current_user is None:
return {}
elif g.portfolio is not None:
active_task_orders = [
task_order for task_order in g.portfolio.task_orders if task_order.is_active
]
funding_end_date = (
# TODO: fix task order -- reimplement logic to get end date from CLINs
# sorted(active_task_orders, key=attrgetter("end_date"))[-1].end_date
# if active_task_orders
# else None
None
)
funded = len(active_task_orders) > 1
else:
funding_end_date = None
funded = None
return {
"portfolio": g.portfolio,
"permissions": Permissions,
"user_can": user_can_view,
"funding_end_date": funding_end_date,
"funded": funded,
}
def atat():
return {"permissions": Permissions, "user_can": user_can_view}

208
atat/utils/flash.py Normal file
View File

@@ -0,0 +1,208 @@
from flask import flash
from atat.utils.localization import translate
MESSAGES = {
"application_created": {
"title": "flash.application.created.title",
"message": "flash.application.created.message",
"category": "success",
},
"application_updated": {
"title": "flash.success",
"message": "flash.application.updated",
"category": "success",
},
"application_environments_name_error": {
"title": None,
"message": "flash.application.env_name_error.message",
"category": "error",
},
"application_environments_updated": {
"title": "flash.environment.updated.title",
"message": "flash.environment.updated.message",
"category": "success",
},
"application_invite_error": {
"title": "flash.application_invite.error.title",
"message": "flash.application_invite.error.message",
"category": "error",
},
"application_invite_resent": {
"title": None,
"message": "flash.application_invite.resent.message",
"category": "success",
},
"application_member_removed": {
"title": "flash.application_member.removed.title",
"message": "flash.application_member.removed.message",
"category": "success",
},
"application_member_update_error": {
"title": "flash.application_member.update_error.title",
"message": "flash.application_member.update_error.message",
"category": "error",
},
"application_member_updated": {
"title": "flash.application_member.updated.title",
"message": "flash.application_member.updated.message",
"category": "success",
},
"application_name_error": {
"title": None,
"message": "flash.application.name_error.message",
"category": "error",
},
"ccpo_user_added": {
"title": "flash.success",
"message": "flash.ccpo_user.added.message",
"category": "success",
},
"ccpo_user_not_found": {
"title": "ccpo.form.user_not_found_title",
"message": "ccpo.form.user_not_found_text",
"category": "info",
},
"ccpo_user_removed": {
"title": "flash.success",
"message": "flash.ccpo_user.removed.message",
"category": "success",
},
"environment_added": {
"title": "flash.success",
"message": "flash.environment_added",
"category": "success",
},
"environment_deleted": {
"title": "flash.environment.deleted.title",
"message": "flash.environment.deleted.message",
"category": "success",
},
"environment_subscription_failure": {
"title": "flash.environment.subscription_failure.title",
"message": "flash.environment.subscription_failure.message",
"category": "error",
},
"environment_subscription_success": {
"title": "flash.environment.subscription_success.title",
"message": "flash.environment.subscription_success.message",
"category": "success",
},
"form_errors": {
"title": "flash.form.errors.title",
"message": "flash.form.errors.message",
"category": "error",
},
"insufficient_funds": {
"title": "flash.task_order.insufficient_funds.title",
"message": None,
"category": "warning",
},
"invite_revoked": {
"title": "flash.invite_revoked.title",
"message": "flash.invite_revoked.message",
"category": "success",
},
"logged_out": {
"title": "flash.logged_out.title",
"message": "flash.logged_out.message",
"category": "info",
},
"login_next": {
"title": "flash.login_required_title",
"message": "flash.login_required_message",
"category": "warning",
},
"new_application_member": {
"title": "flash.new_application_member.title",
"message": "flash.new_application_member.message",
"category": "success",
},
"new_portfolio_member": {
"title": "flash.new_portfolio_member.title",
"message": "flash.new_portfolio_member.message",
"category": "success",
},
"portfolio_member_removed": {
"title": "flash.deleted_member",
"message": "flash.delete_member_success",
"category": "success",
},
"primary_point_of_contact_changed": {
"title": "flash.new_ppoc_title",
"message": "flash.new_ppoc_message",
"category": "success",
},
"resend_portfolio_invitation": {
"title": None,
"message": "flash.portfolio_invite.resent.message",
"category": "success",
},
"resend_portfolio_invitation_error": {
"title": "flash.portfolio_invite.error.title",
"message": "flash.portfolio_invite.error.message",
"category": "error",
},
"revoked_portfolio_access": {
"title": "flash.portfolio_member.revoked.title",
"message": "flash.portfolio_member.revoked.message",
"category": "success",
},
"session_expired": {
"title": "flash.session_expired.title",
"message": "flash.session_expired.message",
"category": "error",
},
"task_order_draft": {
"title": "task_orders.form.draft_alert_title",
"message": "task_orders.form.draft_alert_message",
"category": "warning",
},
"task_order_number_error": {
"title": None,
"message": "flash.task_order_number_error.message",
"category": "error",
},
"task_order_submitted": {
"title": "flash.task_order.submitted.title",
"message": "flash.task_order.submitted.message",
"category": "success",
},
"update_portfolio_member": {
"title": "flash.portfolio_member.update.title",
"message": "flash.portfolio_member.update.message",
"category": "success",
},
"update_portfolio_member_error": {
"title": "flash.portfolio_member.update_error.title",
"message": "flash.portfolio_member.update_error.message",
"category": "error",
},
"updated_application_team_settings": {
"title": "flash.success",
"message": "flash.updated_application_team_settings",
"category": "success",
},
"user_must_complete_profile": {
"title": "flash.user.complete_profile.title",
"message": "flash.user.complete_profile.message",
"category": "info",
},
"user_updated": {
"title": "flash.user.updated.title",
"message": None,
"category": "success",
},
}
def formatted_flash(message_name, **message_args):
config = MESSAGES[message_name]
title = translate(config["title"], message_args) if config["title"] else None
message = translate(config["message"], message_args) if config["message"] else None
actions = (
translate(config["actions"], message_args) if config.get("actions") else None
)
flash({"title": title, "message": message, "actions": actions}, config["category"])

40
atat/utils/form_cache.py Normal file
View File

@@ -0,0 +1,40 @@
from hashlib import sha256
import json
from werkzeug.datastructures import MultiDict
DEFAULT_CACHE_NAME = "formcache"
class FormCache(object):
PARAM_NAME = "formCache"
def __init__(self, redis):
self.redis = redis
def from_request(self, http_request):
cache_key = http_request.args.get(self.PARAM_NAME)
if cache_key:
return self.read(cache_key)
return MultiDict()
def write(self, formdata, expiry_seconds=3600, key_prefix=DEFAULT_CACHE_NAME):
value = json.dumps(formdata)
hash_ = self._hash()
self.redis.setex(
name=self._key(key_prefix, hash_), value=value, time=expiry_seconds
)
return hash_
def read(self, formdata_key, key_prefix=DEFAULT_CACHE_NAME):
data = self.redis.get(self._key(key_prefix, formdata_key))
dict_data = json.loads(data) if data is not None else {}
return MultiDict(dict_data)
@staticmethod
def _key(prefix, hash_):
return "{}:{}".format(prefix, hash_)
@staticmethod
def _hash():
return sha256().hexdigest()

28
atat/utils/json.py Normal file
View File

@@ -0,0 +1,28 @@
from flask.json import JSONEncoder
import json
from werkzeug.datastructures import FileStorage
from datetime import date
from enum import Enum
from atat.models.attachment import Attachment
class CustomJSONEncoder(JSONEncoder):
def default(self, obj):
if isinstance(obj, Attachment):
return obj.filename
elif isinstance(obj, date):
return obj.strftime("%Y-%m-%d")
elif isinstance(obj, FileStorage):
return obj.filename
return JSONEncoder.default(self, obj)
def sqlalchemy_dumps(dct):
def _default(obj):
if isinstance(obj, Enum):
return obj.name
else:
raise TypeError()
return json.dumps(dct, default=_default)

View File

@@ -0,0 +1,56 @@
import yaml
from functools import lru_cache
from flask import current_app as app
from atat.utils import getattr_path
class LocalizationInvalidKeyError(Exception):
def __init__(self, key, variables):
self.key = key
self.variables = variables
def __str__(self):
return "Requested {key} and variables {variables} with but an error occured".format(
key=self.key, variables=self.variables
)
@lru_cache(maxsize=None)
def _translations_file():
file_name = "translations.yaml"
if app:
file_name = app.config.get("DEFAULT_TRANSLATIONS_FILE", file_name)
f = open(file_name)
return yaml.safe_load(f)
def all_keys():
translations = _translations_file()
keys = []
def _recursive_key_lookup(chain):
results = getattr_path(translations, chain)
if isinstance(results, str):
keys.append(chain)
else:
[_recursive_key_lookup(".".join([chain, result])) for result in results]
[_recursive_key_lookup(key) for key in translations]
return keys
def translate(key, variables=None):
translations = _translations_file()
value = getattr_path(translations, key)
if variables is None:
variables = {}
if value is None:
raise LocalizationInvalidKeyError(key, variables)
return value.format(**variables).replace("\n", "")

68
atat/utils/logging.py Normal file
View File

@@ -0,0 +1,68 @@
import datetime
import json
import logging
from flask import g, request, has_request_context, session
class RequestContextFilter(logging.Filter):
def filter(self, record):
if has_request_context():
if getattr(g, "current_user", None):
record.dod_edipi = g.current_user.dod_id
user_id = session.get("user_id")
if user_id:
record.user_id = str(user_id)
record.logged_in = True
else:
record.logged_in = False
if request.environ.get("HTTP_X_REQUEST_ID"):
record.request_id = request.environ.get("HTTP_X_REQUEST_ID")
return True
def epoch_to_iso8601(ts):
dt = datetime.datetime.utcfromtimestamp(ts)
return dt.replace(tzinfo=datetime.timezone.utc).isoformat()
class JsonFormatter(logging.Formatter):
_DEFAULT_RECORD_FIELDS = [
("timestamp", lambda r: epoch_to_iso8601(r.created)),
("version", lambda r: 1),
("request_id", lambda r: r.__dict__.get("request_id")),
("user_id", lambda r: r.__dict__.get("user_id")),
("dod_edipi", lambda r: r.__dict__.get("dod_edipi")),
("logged_in", lambda r: r.__dict__.get("logged_in")),
("severity", lambda r: r.levelname),
("tags", lambda r: r.__dict__.get("tags")),
("audit_event", lambda r: r.__dict__.get("audit_event")),
]
def __init__(self, *args, source="atat", **kwargs):
self.source = source
super().__init__(self)
def format(self, record, *args, **kwargs):
message_dict = {"source": self.source}
for field, func in self._DEFAULT_RECORD_FIELDS:
result = func(record)
if result is not None:
message_dict[field] = result
if record.args:
message_dict["message"] = record.msg % record.args
else:
message_dict["message"] = record.msg
if record.__dict__.get("exc_info") is not None:
message_dict["details"] = {
"backtrace": self.formatException(record.exc_info),
"exception": str(record.exc_info[1]),
}
return json.dumps(message_dict)

112
atat/utils/mailer.py Normal file
View File

@@ -0,0 +1,112 @@
from contextlib import contextmanager
import smtplib
import io
from email.message import EmailMessage
class MailConnection(object):
def send(self, message):
raise NotImplementedError()
@property
def messages(self):
raise NotImplementedError()
class SMTPConnection(MailConnection):
def __init__(self, server, port, username, password, use_tls=False):
self.server = server
self.port = port
self.username = username
self.password = password
self.use_tls = use_tls
@contextmanager
def _connected_host(self):
host = None
if self.use_tls:
host = smtplib.SMTP(self.server, self.port)
host.starttls()
else:
host = smtplib.SMTP_SSL(self.server, self.port)
host.login(self.username, self.password)
yield host
host.quit()
@property
def messages(self):
return []
def send(self, message):
with self._connected_host() as host:
host.send_message(message)
class RedisConnection(MailConnection):
def __init__(self, redis, **kwargs):
super().__init__(**kwargs)
self.redis = redis
self._reset()
def _reset(self):
self.redis.delete("atat_inbox")
@property
def messages(self):
return [msg.decode() for msg in self.redis.lrange("atat_inbox", 0, -1)]
def send(self, message):
self.redis.lpush("atat_inbox", str(message))
class Mailer(object):
def __init__(self, connection, sender):
self.connection = connection
self.sender = sender
def _build_message(self, recipients, subject, body):
msg = EmailMessage()
msg.set_content(body)
msg["From"] = self.sender
msg["To"] = ", ".join(recipients)
msg["Subject"] = subject
return msg
def _add_attachment(self, message, content, filename, maintype, subtype):
with io.BytesIO(content) as bytes_:
message.add_attachment(
bytes_.read(), filename=filename, maintype=maintype, subtype=subtype
)
def send(self, recipients, subject, body, attachments=[]):
"""
Send a message, optionally with attachments.
Attachments should be provided as a list of dictionaries of the form:
{
content: bytes,
maintype: string,
subtype: string,
filename: string,
}
"""
message = self._build_message(recipients, subject, body)
if attachments:
message.make_mixed()
for attachment in attachments:
self._add_attachment(
message,
content=attachment["content"],
filename=attachment["filename"],
maintype=attachment.get("maintype", "application"),
subtype=attachment.get("subtype", "octet-stream"),
)
self.connection.send(message)
@property
def messages(self):
return self.connection.messages

View File

@@ -0,0 +1,17 @@
from sqlalchemy import select
from atat.jobs import send_notification_mail
from atat.database import db
from atat.models.notification_recipient import NotificationRecipient
class NotificationSender(object):
EMAIL_SUBJECT = "ATAT notification"
def send(self, body, type_=None):
recipients = self._get_recipients(type_)
send_notification_mail.delay(recipients, self.EMAIL_SUBJECT, body)
def _get_recipients(self, type_):
query = select([NotificationRecipient.email])
return db.session.execute(query).fetchone()

View File

@@ -0,0 +1,20 @@
from atat.domain.users import Users
class SessionLimiter(object):
def __init__(self, config, session, redis):
self.limit_logins = config["LIMIT_CONCURRENT_SESSIONS"]
self.session_prefix = config.get("SESSION_KEY_PREFIX", "session:")
self.session = session
self.redis = redis
def on_login(self, user):
if not self.limit_logins:
return
session_id = self.session.sid
self._delete_session(user.last_session_id)
Users.update_last_session_id(user, session_id)
def _delete_session(self, session_id):
self.redis.delete(f"{self.session_prefix}{session_id}")