Skip to content
Snippets Groups Projects
app.py 36.9 KiB
Newer Older
#!/usr/bin/env python3
import datetime
import enum
import operator
Cédric Krier's avatar
Cédric Krier committed
import os
import re
import unicodedata
from collections import OrderedDict, namedtuple
Cédric Krier's avatar
Cédric Krier committed
from functools import partial
from http import HTTPStatus
from logging.handlers import SMTPHandler
from operator import attrgetter
from random import choice, sample, shuffle
from urllib.parse import quote, urljoin, urlparse
Cédric Krier's avatar
Cédric Krier committed

import requests
from colorthief import ColorThief
from flask import (
    Flask, abort, make_response, redirect, render_template, request, url_for)
from flask.logging import default_handler
Cédric Krier's avatar
Cédric Krier committed
from flask_caching import Cache
from flask_cdn import CDN
from flask_cdn import url_for as _cdn_url_for
Cédric Krier's avatar
Cédric Krier committed
from flask_gravatar import Gravatar
from flask_sitemap import Sitemap
from icalendar import Calendar, Event
from jinja2 import TemplateNotFound
from lxml import html, objectify
from werkzeug.middleware.proxy_fix import ProxyFix
Cédric Krier's avatar
Cédric Krier committed

NEWS_URL = 'https://discuss.tryton.org/c/news'
NEWS_RSS_URL = NEWS_URL + '.rss'
Cédric Krier's avatar
Cédric Krier committed
CALENDAR_URL = 'https://discuss.tryton.org/upcoming-events'
CALENDAR_JSON = 'https://discuss.tryton.org/discourse-post-event/events.json'
Cédric Krier's avatar
Cédric Krier committed
SUPPORTERS_URL = (
    'https://foundation.tryton.org:9000/foundation/foundation/1/supporters')
DONATORS_URL = (
    'https://foundation.tryton.org:9000/foundation/foundation/1/donators'
    '?account=732&account=734&duration=730')
Cédric Krier's avatar
Cédric Krier committed
DONATIONS_URL = (
    'https://foundation.tryton.org:9000/foundation/foundation/1/donations'
    '?account=732&account=734')
Cédric Krier's avatar
Cédric Krier committed

CRITICAL_CSS_DIR = os.environ.get('CRITICAL_CSS')
CRITICAL_CSS_COOKIE = 'critical-css'
cache = Cache(config={
        'CACHE_TYPE': (
            'null' if ast.literal_eval(os.environ.get('DEBUG', 'True'))
            else 'simple')})
if os.environ.get('MEMCACHED'):
    cache.config['CACHE_TYPE'] = 'memcached'
    cache.config['CACHE_MEMCACHED_SERVERS'] = (
        os.environ['MEMCACHED'].split(','))
app = Flask(__name__)
app.wsgi_app = ProxyFix(app.wsgi_app)
app.config['TEMPLATES_AUTO_RELOAD'] = True
app.config['SEND_FILE_MAX_AGE_DEFAULT'] = datetime.timedelta(days=365)
app.config['CACHE_DEFAULT_TIMEOUT'] = 60 * 60
app.config['PREFERRED_URL_SCHEME'] = 'https'
app.config['SERVER_NAME'] = os.environ.get('SERVER_NAME')
Cédric Krier's avatar
Cédric Krier committed
app.config['SITEMAP_INCLUDE_RULES_WITHOUT_PARAMS'] = True
app.config['SITEMAP_VIEW_DECORATORS'] = [cache.cached()]
app.config['SITEMAP_IGNORE_ENDPOINTS'] = {
    'contribute-alt',
    'donate-alt',
    'donate_cancel',
    'donate_thanks',
    'download-alt',
    'event-alt',
    'events',
    'events-alt',
    'events-ics',
    'favicon',
    'flask_sitemap.page',
    'flask_sitemap.sitemap',
    'foundation-alt',
    'news-alt',
    'news_rss',
    'presentations-alt',
    'robots',
    'service_providers-alt',
    'success_stories-alt',
    'supporters-alt',
    'warmup',
    }
app.config['SITEMAP_URL_SCHEME'] = 'https'
app.config['DOWNLOADS_DOMAIN'] = os.environ.get(
    'DOWNLOADS_DOMAIN', 'downloads.tryton.org')
app.config['VIDEOS_DOMAIN'] = os.environ.get(
    'VIDEOS_DOMAIN', 'videos.tryton.org')
Cédric Krier's avatar
Cédric Krier committed
app.config['CDN_DOMAIN'] = os.environ.get('CDN_DOMAIN')
app.config['CDN_HTTPS'] = ast.literal_eval(os.environ.get('CDN_HTTPS', 'True'))
Cédric Krier's avatar
Cédric Krier committed
app.config['GRAVATAR_SIZE'] = 198
app.config['GRAVATAR_DEFAULT'] = 'mp'
app.config['GRAVATAR_USE_SSL'] = True
if app.config['CDN_DOMAIN']:
    app.config['GRAVATAR_BASE_URL'] = '%s://%s/' % (
        'https' if app.config['CDN_HTTPS'] else 'http',
        app.config['CDN_DOMAIN'])
else:
    app.config['GRAVATAR_BASE_URL'] = '/'
Cédric Krier's avatar
Cédric Krier committed
app.jinja_env.lstrip_blocks = True
app.jinja_env.trim_blocks = True
app.jinja_env.autoescape = (
    lambda filename: (
        app.select_jinja_autoescape(filename)
        or filename.endswith('.html.jinja')))
Cédric Krier's avatar
Cédric Krier committed
cache.init_app(app)
Cédric Krier's avatar
Cédric Krier committed
CDN(app)
gravatar = Gravatar(app)
Cédric Krier's avatar
Cédric Krier committed
sitemap = Sitemap(app=app)
Cédric Krier's avatar
Cédric Krier committed

def url_for_self(**args):
    return url_for(request.endpoint, **dict(request.args, **args))


@app.context_processor
def inject_self():
    return dict(url_for_self=url_for_self)


def json_default(o):
    if hasattr(o, '__json__'):
        return o.__json__()
    raise TypeError(f'Object of type {o.__class__.__name__} '
        f'is not JSON serializable')


app.jinja_env.policies['json.dumps_kwargs'] = {
    'sort_keys': True,
    'default': json_default,
    }


_slugify_strip_re = re.compile(r'[^\w\s-]')
_slugify_hyphenate_re = re.compile(r'[-\s]+')


@app.template_filter('slugify')
def slugify(value):
    if not isinstance(value, str):
        value = str(value)
    value = unicodedata.normalize('NFKD', value)
    value = str(_slugify_strip_re.sub('', value).strip())
    return _slugify_hyphenate_re.sub('-', value)

Cédric Krier's avatar
Cédric Krier committed

def url_for_downloads(*args):
    return urljoin(
        '//' + app.config['DOWNLOADS_DOMAIN'], os.path.join(*map(quote, args)))


@app.context_processor
def inject_url_for_dowloads():
    return dict(url_for_downloads=url_for_downloads)


def url_for_videos(*args):
    return urljoin(
        '//' + app.config['VIDEOS_DOMAIN'], os.path.join(*map(quote, args)))


@app.context_processor
def inject_url_for_videos():
    return dict(url_for_videos=url_for_videos)


def cdn_url_for(*args, **kwargs):
    if app.config['CDN_DOMAIN']:
        return _cdn_url_for(*args, **kwargs)
    else:
        return url_for(*args, **kwargs)


def cache_key_prefix_view():
    scheme = 'https' if request.is_secure else 'http'
    if not request.cookies.get(CRITICAL_CSS_COOKIE):
        return 'view/%s/%s/%s' % (
            scheme, request.path, critical_css(timestamp=True))
    else:
        return 'view/%s/%s' % (scheme, request.path)
LinkHeader = namedtuple(
    'LinkHeader', ['endpoint', 'values', 'params'])
PRECONNECT_HEADERS = [
    LinkHeader('index', {}, {'rel': 'preconnect'}),
    LinkHeader('static', {'filename': 'js/main.js'}, {
            'rel': 'preload', 'as': 'script', 'nopush': True}),
    ]
CSS_LINK_HEADERS = [
    LinkHeader(
        'static', {'filename': 'css/main.css'}, {
            'rel': 'preload', 'as': 'style', 'nopush': True}),
    LinkHeader(
Cédric Krier's avatar
Cédric Krier committed
        'static', {'filename': 'fonts/Roboto.woff2'}, {
            'rel': 'preload', 'as': 'font', 'nopush': True,
        'static', {'filename': 'fonts/material-icons.woff2'}, {
            'rel': 'preload', 'as': 'font', 'nopush': True,
    ]


def add_links(links):
    def format_param(param):
        key, value = param
        if value is True:
            return key
        else:
            return '%s=%s' % (key, value)

    def decorator(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            response = make_response(func(*args, **kwargs))
            for link in links:
                if (link.endpoint == 'index'
                        or (link.endpoint == 'static'
                            and link.values.get(
                                'filename', '').startswith('fonts/'))):
                    if (app.config['CDN_DOMAIN']
                            and not app.config['CDN_DEBUG']):
                        urls = app.url_map.bind(
                            app.config['CDN_DOMAIN'], url_scheme='https')
                        url = urls.build(
                            link.endpoint, link.values, force_external=True)
                    else:
                        url = url_for(link.endpoint, **link.values)
                else:
                    url = cdn_url_for(link.endpoint, **link.values)
                params = '; '.join(map(format_param, link.params.items()))
                value = '<{url}>; {params}'.format(
                    url=url,
                    params=params)
                response.headers.add('Link', value)
            return response
        return wrapper
    return decorator


@app.after_request
def add_cache_control_header(response):
    if 'Cache-Control' not in response.headers:
        response.cache_control.max_age = app.config['CACHE_DEFAULT_TIMEOUT']
        response.cache_control.public = True
    return response


def url_for_canonical(endpoint=None, **values):
    if endpoint is None:
        endpoint = request.endpoint
    if not endpoint:
        return ''
    scheme = 'https' if not app.debug else None
    return url_for(endpoint, _external=True, _scheme=scheme, **values)


@app.context_processor
def inject_canonical():
    return dict(url_for_canonical=url_for_canonical)


def critical_css(timestamp=False):
    if (CRITICAL_CSS_DIR
            and request.endpoint
            and not request.cookies.get(CRITICAL_CSS_COOKIE)):
        file = os.path.join(CRITICAL_CSS_DIR, request.endpoint + '.css')
        if os.path.exists(file):
            if timestamp:
                return int(os.path.getmtime(file))
            else:
                return open(file, 'r').read()


@app.after_request
def add_critical_css_cookie(response):
    if (CRITICAL_CSS_DIR
            and response.mimetype == 'text/html'
            and not request.cookies.get(CRITICAL_CSS_COOKIE)):
        response.set_cookie(CRITICAL_CSS_COOKIE, '1')
    return response


@app.context_processor
def inject_critical_css():
    return dict(critical_css=critical_css)


@cache.memoize(timeout=365 * 24 * 60 * 60)
def dominant_color(path):
    if app.debug:
        return '#000'
Cédric Krier's avatar
Cédric Krier committed
    try:
        color = ColorThief(
            os.path.join(app.static_folder, path)).get_color(quality=1)
    except Exception:
        return '#000'
    return '#%02x%02x%02x' % color


@app.context_processor
def inject_dominant_color():
    return dict(dominant_color=dominant_color)


Cédric Krier's avatar
Cédric Krier committed
HEART = ('<span '
Cédric Krier's avatar
Cédric Krier committed
    'class="material-icons beat" aria-hidden="true" '
Cédric Krier's avatar
Cédric Krier committed
    'style="color:#d9534f; font-size: inherit; vertical-align: middle">'
    'favorite'
    '</span>')
Cédric Krier's avatar
Cédric Krier committed
@app.route('/')
@cache.cached(key_prefix=cache_key_prefix_view)
@add_links(PRECONNECT_HEADERS + JS_LINK_HEADERS + CSS_LINK_HEADERS)
Cédric Krier's avatar
Cédric Krier committed
def index():
    return render_template(
        'index.html.jinja',
Cédric Krier's avatar
Cédric Krier committed
        news=list(news_items(3)),
        next_events=next_events(3))


@app.context_processor
def inject_menu():
Cédric Krier's avatar
Cédric Krier committed
    menu = OrderedDict()
    menu['Tryton'] = [
        ('Success Stories', url_for('success_stories')),
        ('News', url_for('news')),
        ('Try it', url_for('demo')),
Cédric Krier's avatar
Cédric Krier committed
        ('Get Tryton', url_for('download')),
Cédric Krier's avatar
Cédric Krier committed
        ('Documentation', '//docs.tryton.org/'),
        ('Code', '//code.tryton.org/'),
Cédric Krier's avatar
Cédric Krier committed
        ]
    menu['Community'] = [
        ('Forum', url_for('forum')),
Cédric Krier's avatar
Cédric Krier committed
        ('Presentations', url_for('presentations')),
        ('Get Help', 'https://discuss.tryton.org/c/support'),
Cédric Krier's avatar
Cédric Krier committed
        ('Contribute', url_for('contribute')),
        ('How to Develop', url_for('develop')),
Cédric Krier's avatar
Cédric Krier committed
        ]
    menu['Foundation'] = [
Cédric Krier's avatar
Cédric Krier committed
        ('About', url_for('foundation')),
Cédric Krier's avatar
Cédric Krier committed
        ('Supporters', url_for('supporters')),
Cédric Krier's avatar
Cédric Krier committed
        (HEART + ' Donations', url_for('donate')),
Cédric Krier's avatar
Cédric Krier committed
        ]
    menu['Services'] = [
        ('Service Providers', url_for('service_providers')),
        ('Become a Service Provider', url_for('service_providers_start')),
Cédric Krier's avatar
Cédric Krier committed
    return dict(menu=menu, datetime=datetime)
Cédric Krier's avatar
Cédric Krier committed


@app.context_processor
def inject_copyright_dates():
    return dict(copyright_dates='2008-%s' % datetime.date.today().year)


@app.context_processor
def inject_heart():
    return dict(heart=HEART)


Cédric Krier's avatar
Cédric Krier committed
@app.route('/robots.txt')
@cache.cached()
def robots():
    response = make_response(render_template('robots.txt.jinja'))
Cédric Krier's avatar
Cédric Krier committed
    response.mimetype = 'text/plain'
    return response


Cédric Krier's avatar
Cédric Krier committed
@app.route('/news')
@app.route('/news/index.html', endpoint='news-alt')
Cédric Krier's avatar
Cédric Krier committed
def news():
    return redirect(NEWS_URL)


@app.route('/news.rss')
@app.route('/rss.xml')
def news_rss():
    return redirect(NEWS_RSS_URL)
@cache.memoize(timeout=60 * 60)
def fetch_news_items():
    return requests.get(NEWS_RSS_URL).content


def news_items(size=-1):
    try:
        root = objectify.fromstring(fetch_news_items())
    except Exception:
        app.logger.error('fail to fetch news', exc_info=True)
Cédric Krier's avatar
Cédric Krier committed
    for item in root.xpath('/rss/channel/item')[:size]:
        yield item


@app.template_filter('news_text')
def news_text(content):
    block = html.fromstring(str(content))
Cédric Krier's avatar
Cédric Krier committed
    for box in block.find_class('lightbox-wrapper'):
        box.drop_tree()
Cédric Krier's avatar
Cédric Krier committed
    return block.text_content().strip()
Cédric Krier's avatar
Cédric Krier committed


@app.route('/events')
@app.route('/events.html', endpoint='events-alt')
Cédric Krier's avatar
Cédric Krier committed
def events():
    return redirect(CALENDAR_URL)


@app.route('/events.ics', endpoint='events-ics')
def events_ics():
    response = make_response(fetch_events())
    response.mimetype = 'text/calendar'
    return response


@cache.memoize(timeout=60 * 60 * 24)
def fetch_events():
    def parse_dt(value):
        return datetime.datetime.fromisoformat(value.rstrip('Z'))
    data = requests.get(CALENDAR_JSON).json()
    calendar = Calendar()
    for data in requests.get(CALENDAR_JSON).json()['events']:
        event = Event()
        if data.get('starts_at'):
            event.add('dtstart', parse_dt(data['starts_at']))
        if data.get('ends_at'):
            event.add('dtend', parse_dt(data['ends_at']))
        event['summary'] = data['name']
        event['url'] = urljoin(CALENDAR_JSON, data['post']['url'])
        calendar.add_component(event)
    return calendar.to_ical()


def next_events(size=-1):
    today = datetime.date.today()
    try:
        cal = Calendar.from_ical(fetch_events())
    except Exception:
        app.logger.error('fail to fetch events', exc_info=True)
Loading
Loading full blame...