Compare commits
	
		
			20 Commits
		
	
	
		
			wip-open-p
			...
			wip-fronte
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| ec8f5032e9 | |||
| 7a5af9282c | |||
| 466adabbb0 | |||
| 5fb40eb32b | |||
| 9f380751f5 | |||
| 49075cbc60 | |||
| 81848c2c44 | |||
| 9ee7b742ab | |||
| 58c33074c3 | |||
| 756427b34e | |||
| 7e06212cd5 | |||
| ef3912b647 | |||
| 151484dee3 | |||
| bec1f209ba | |||
| 0e14bdd09f | |||
| ce6df542cc | |||
| 530302b74f | |||
| 1bfb6cd2f6 | |||
| 53b6210531 | |||
| aeaa03ed80 | 
							
								
								
									
										5452
									
								
								package-lock.json
									
									
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										5452
									
								
								package-lock.json
									
									
									
										generated
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							@@ -184,7 +184,6 @@ class PillarServer(BlinkerCompatibleEve):
 | 
			
		||||
 | 
			
		||||
        if not self.config.get('STATIC_FILE_HASH'):
 | 
			
		||||
            self.log.warning('STATIC_FILE_HASH is empty, generating random one')
 | 
			
		||||
            f = open('/data/git/blender-cloud/config_local.py', 'a')
 | 
			
		||||
            h = re.sub(r'[_.~-]', '', secrets.token_urlsafe())[:8]
 | 
			
		||||
            self.config['STATIC_FILE_HASH'] = h
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -47,13 +47,6 @@ def store_subclient_token():
 | 
			
		||||
                    'subclient_user_id': str(db_user['_id'])}), status
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def blender_id_endpoint():
 | 
			
		||||
    """Gets the endpoint for the authentication API. If the env variable
 | 
			
		||||
    is defined, it's possible to override the (default) production address.
 | 
			
		||||
    """
 | 
			
		||||
    return current_app.config['BLENDER_ID_ENDPOINT'].rstrip('/')
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def validate_create_user(blender_id_user_id, token, oauth_subclient_id):
 | 
			
		||||
    """Validates a user against Blender ID, creating the user in our database.
 | 
			
		||||
 | 
			
		||||
@@ -121,13 +114,13 @@ def validate_token(user_id, token, oauth_subclient_id):
 | 
			
		||||
        # We only want to accept Blender Cloud tokens.
 | 
			
		||||
        payload['client_id'] = current_app.config['OAUTH_CREDENTIALS']['blender-id']['id']
 | 
			
		||||
 | 
			
		||||
    url = '{0}/u/validate_token'.format(blender_id_endpoint())
 | 
			
		||||
    url = '{0}/u/validate_token'.format(current_app.config['BLENDER_ID_ENDPOINT'])
 | 
			
		||||
    log.debug('POSTing to %r', url)
 | 
			
		||||
 | 
			
		||||
    # Retry a few times when POSTing to BlenderID fails.
 | 
			
		||||
    # Source: http://stackoverflow.com/a/15431343/875379
 | 
			
		||||
    s = requests.Session()
 | 
			
		||||
    s.mount(blender_id_endpoint(), HTTPAdapter(max_retries=5))
 | 
			
		||||
    s.mount(current_app.config['BLENDER_ID_ENDPOINT'], HTTPAdapter(max_retries=5))
 | 
			
		||||
 | 
			
		||||
    # POST to Blender ID, handling errors as negative verification results.
 | 
			
		||||
    try:
 | 
			
		||||
@@ -225,7 +218,7 @@ def fetch_blenderid_user() -> dict:
 | 
			
		||||
 | 
			
		||||
    my_log = log.getChild('fetch_blenderid_user')
 | 
			
		||||
 | 
			
		||||
    bid_url = '%s/api/user' % blender_id_endpoint()
 | 
			
		||||
    bid_url = '%s/api/user' % current_app.config['BLENDER_ID_ENDPOINT']
 | 
			
		||||
    my_log.debug('Fetching user info from %s', bid_url)
 | 
			
		||||
 | 
			
		||||
    credentials = current_app.config['OAUTH_CREDENTIALS']['blender-id']
 | 
			
		||||
@@ -270,7 +263,7 @@ def setup_app(app, url_prefix):
 | 
			
		||||
def switch_user_url(next_url: str) -> str:
 | 
			
		||||
    from urllib.parse import quote
 | 
			
		||||
 | 
			
		||||
    base_url = '%s/switch' % blender_id_endpoint()
 | 
			
		||||
    base_url = '%s/switch' % current_app.config['BLENDER_ID_ENDPOINT']
 | 
			
		||||
    if next_url:
 | 
			
		||||
        return '%s?next=%s' % (base_url, quote(next_url))
 | 
			
		||||
    return base_url
 | 
			
		||||
 
 | 
			
		||||
@@ -40,6 +40,51 @@ attachments_embedded_schema = {
 | 
			
		||||
    },
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
# TODO (fsiddi) reference this schema in all node_types that allow ratings
 | 
			
		||||
ratings_embedded_schema = {
 | 
			
		||||
    'type': 'dict',
 | 
			
		||||
    # Total count of positive ratings (updated at every rating action)
 | 
			
		||||
    'schema': {
 | 
			
		||||
        'positive': {
 | 
			
		||||
            'type': 'integer',
 | 
			
		||||
        },
 | 
			
		||||
        # Total count of negative ratings (updated at every rating action)
 | 
			
		||||
        'negative': {
 | 
			
		||||
            'type': 'integer',
 | 
			
		||||
        },
 | 
			
		||||
        # Collection of ratings, keyed by user
 | 
			
		||||
        'ratings': {
 | 
			
		||||
            'type': 'list',
 | 
			
		||||
            'schema': {
 | 
			
		||||
                'type': 'dict',
 | 
			
		||||
                'schema': {
 | 
			
		||||
                    'user': {
 | 
			
		||||
                        'type': 'objectid',
 | 
			
		||||
                        'data_relation': {
 | 
			
		||||
                            'resource': 'users',
 | 
			
		||||
                            'field': '_id',
 | 
			
		||||
                            'embeddable': False
 | 
			
		||||
                        }
 | 
			
		||||
                    },
 | 
			
		||||
                    'is_positive': {
 | 
			
		||||
                        'type': 'boolean'
 | 
			
		||||
                    },
 | 
			
		||||
                    # Weight of the rating based on user rep and the context.
 | 
			
		||||
                    # Currently we have the following weights:
 | 
			
		||||
                    # - 1 auto null
 | 
			
		||||
                    # - 2 manual null
 | 
			
		||||
                    # - 3 auto valid
 | 
			
		||||
                    # - 4 manual valid
 | 
			
		||||
                    'weight': {
 | 
			
		||||
                        'type': 'integer'
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        },
 | 
			
		||||
        'hot': {'type': 'float'},
 | 
			
		||||
    },
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
# Import after defining the common embedded schemas, to prevent dependency cycles.
 | 
			
		||||
from pillar.api.node_types.asset import node_type_asset
 | 
			
		||||
from pillar.api.node_types.blog import node_type_blog
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										87
									
								
								pillar/api/utils/rating.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										87
									
								
								pillar/api/utils/rating.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,87 @@
 | 
			
		||||
# These functions come from Reddit
 | 
			
		||||
# https://github.com/reddit/reddit/blob/master/r2/r2/lib/db/_sorts.pyx
 | 
			
		||||
 | 
			
		||||
# Additional resources
 | 
			
		||||
# http://www.redditblog.com/2009/10/reddits-new-comment-sorting-system.html
 | 
			
		||||
# http://www.evanmiller.org/how-not-to-sort-by-average-rating.html
 | 
			
		||||
# http://amix.dk/blog/post/19588
 | 
			
		||||
 | 
			
		||||
from datetime import datetime, timezone
 | 
			
		||||
from math import log
 | 
			
		||||
from math import sqrt
 | 
			
		||||
 | 
			
		||||
epoch = datetime(1970, 1, 1, 0, 0, 0, 0, timezone.utc)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def epoch_seconds(date):
 | 
			
		||||
    """Returns the number of seconds from the epoch to date."""
 | 
			
		||||
    td = date - epoch
 | 
			
		||||
    return td.days * 86400 + td.seconds + (float(td.microseconds) / 1000000)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def score(ups, downs):
 | 
			
		||||
    return ups - downs
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def hot(ups, downs, date):
 | 
			
		||||
    """The hot formula. Reddit's hot ranking uses the logarithm function to
 | 
			
		||||
    weight the first votes higher than the rest.
 | 
			
		||||
    The first 10 upvotes have the same weight as the next 100 upvotes which
 | 
			
		||||
    have the same weight as the next 1000, etc.
 | 
			
		||||
 | 
			
		||||
    Dillo authors: we modified the formula to give more weight to negative
 | 
			
		||||
    votes when an entry is controversial.
 | 
			
		||||
 | 
			
		||||
    TODO: make this function more dynamic so that different defaults can be
 | 
			
		||||
    specified depending on the item that is being rated.
 | 
			
		||||
    """
 | 
			
		||||
 | 
			
		||||
    s = score(ups, downs)
 | 
			
		||||
    order = log(max(abs(s), 1), 10)
 | 
			
		||||
    sign = 1 if s > 0 else -1 if s < 0 else 0
 | 
			
		||||
    seconds = epoch_seconds(date) - 1134028003
 | 
			
		||||
    base_hot = round(sign * order + seconds / 45000, 7)
 | 
			
		||||
 | 
			
		||||
    if downs > 1:
 | 
			
		||||
        rating_delta = 100 * (downs - ups) / downs
 | 
			
		||||
        if rating_delta < 25:
 | 
			
		||||
            # The post is controversial
 | 
			
		||||
            return base_hot
 | 
			
		||||
        base_hot = base_hot - (downs * 6)
 | 
			
		||||
 | 
			
		||||
    return base_hot
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def _confidence(ups, downs):
 | 
			
		||||
    n = ups + downs
 | 
			
		||||
 | 
			
		||||
    if n == 0:
 | 
			
		||||
        return 0
 | 
			
		||||
 | 
			
		||||
    z = 1.0 #1.0 = 85%, 1.6 = 95%
 | 
			
		||||
    phat = float(ups) / n
 | 
			
		||||
    return sqrt(phat+z*z/(2*n)-z*((phat*(1-phat)+z*z/(4*n))/n))/(1+z*z/n)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def confidence(ups, downs):
 | 
			
		||||
    if ups + downs == 0:
 | 
			
		||||
        return 0
 | 
			
		||||
    else:
 | 
			
		||||
        return _confidence(ups, downs)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def update_hot(document):
 | 
			
		||||
    """Update the hotness of a document given its current ratings.
 | 
			
		||||
 | 
			
		||||
    We expect the document to implement the ratings_embedded_schema in
 | 
			
		||||
    a 'ratings' property.
 | 
			
		||||
    """
 | 
			
		||||
 | 
			
		||||
    dt = document['_created']
 | 
			
		||||
    dt = dt.replace(tzinfo=timezone.utc)
 | 
			
		||||
 | 
			
		||||
    document['properties']['ratings']['hot'] = hot(
 | 
			
		||||
        document['properties']['ratings']['positive'],
 | 
			
		||||
        document['properties']['ratings']['negative'],
 | 
			
		||||
        dt,
 | 
			
		||||
    )
 | 
			
		||||
@@ -131,16 +131,15 @@ class BlenderIdSignIn(OAuthSignIn):
 | 
			
		||||
    def __init__(self):
 | 
			
		||||
        super().__init__()
 | 
			
		||||
 | 
			
		||||
        base_url = current_app.config['OAUTH_CREDENTIALS']['blender-id'].get(
 | 
			
		||||
            'base_url', 'https://www.blender.org/id/')
 | 
			
		||||
        base_url = current_app.config['BLENDER_ID_ENDPOINT']
 | 
			
		||||
 | 
			
		||||
        self.service = OAuth2Service(
 | 
			
		||||
            name='blender-id',
 | 
			
		||||
            client_id=self.consumer_id,
 | 
			
		||||
            client_secret=self.consumer_secret,
 | 
			
		||||
            authorize_url='%soauth/authorize' % base_url,
 | 
			
		||||
            access_token_url='%soauth/token' % base_url,
 | 
			
		||||
            base_url='%sapi/' % base_url
 | 
			
		||||
            authorize_url='%s/oauth/authorize' % base_url,
 | 
			
		||||
            access_token_url='%s/oauth/token' % base_url,
 | 
			
		||||
            base_url='%s/api/' % base_url
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    def authorize(self):
 | 
			
		||||
 
 | 
			
		||||
@@ -32,7 +32,7 @@ SECRET_KEY = ''
 | 
			
		||||
AUTH_TOKEN_HMAC_KEY = b''
 | 
			
		||||
 | 
			
		||||
# Authentication settings
 | 
			
		||||
BLENDER_ID_ENDPOINT = 'http://blender-id:8000/'
 | 
			
		||||
BLENDER_ID_ENDPOINT = 'http://id.local:8000'
 | 
			
		||||
 | 
			
		||||
CDN_USE_URL_SIGNING = True
 | 
			
		||||
CDN_SERVICE_DOMAIN_PROTOCOL = 'https'
 | 
			
		||||
@@ -124,9 +124,8 @@ BLENDER_ID_USER_INFO_TOKEN = '-set-in-config-local-'
 | 
			
		||||
# Example entry:
 | 
			
		||||
# OAUTH_CREDENTIALS = {
 | 
			
		||||
#    'blender-id': {
 | 
			
		||||
#        'id': 'CLOUD-OF-SNOWFLAKES-43',
 | 
			
		||||
#        'id': 'CLOUD-OF-SNOWFLAKES-42',
 | 
			
		||||
#        'secret': 'thesecret',
 | 
			
		||||
#        'base_url': 'http://blender-id:8000/'
 | 
			
		||||
#     }
 | 
			
		||||
# }
 | 
			
		||||
# OAuth providers are defined in pillar.auth.oauth
 | 
			
		||||
 
 | 
			
		||||
@@ -45,11 +45,15 @@ ALLOWED_STYLES = [
 | 
			
		||||
def markdown(s: str) -> str:
 | 
			
		||||
    commented_shortcodes = shortcodes.comment_shortcodes(s)
 | 
			
		||||
    tainted_html = CommonMark.commonmark(commented_shortcodes)
 | 
			
		||||
    safe_html = bleach.clean(tainted_html,
 | 
			
		||||
                             tags=ALLOWED_TAGS,
 | 
			
		||||
 | 
			
		||||
    # Create a Cleaner that supports parsing of bare links (see filters).
 | 
			
		||||
    cleaner = bleach.Cleaner(tags=ALLOWED_TAGS,
 | 
			
		||||
                             attributes=ALLOWED_ATTRIBUTES,
 | 
			
		||||
                             styles=ALLOWED_STYLES,
 | 
			
		||||
                             strip_comments=False)
 | 
			
		||||
                             strip_comments=False,
 | 
			
		||||
                             filters=[bleach.linkifier.LinkifyFilter])
 | 
			
		||||
 | 
			
		||||
    safe_html = cleaner.clean(tainted_html)
 | 
			
		||||
    return safe_html
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -33,18 +33,57 @@ log = logging.getLogger(__name__)
 | 
			
		||||
def shortcode(name: str):
 | 
			
		||||
    """Class decorator for shortcodes."""
 | 
			
		||||
 | 
			
		||||
    def decorator(cls):
 | 
			
		||||
        assert hasattr(cls, '__call__'), '@shortcode should be used on callables.'
 | 
			
		||||
        if isinstance(cls, type):
 | 
			
		||||
            instance = cls()
 | 
			
		||||
    def decorator(decorated):
 | 
			
		||||
        assert hasattr(decorated, '__call__'), '@shortcode should be used on callables.'
 | 
			
		||||
        if isinstance(decorated, type):
 | 
			
		||||
            as_callable = decorated()
 | 
			
		||||
        else:
 | 
			
		||||
            instance = cls
 | 
			
		||||
        shortcodes.register(name)(instance)
 | 
			
		||||
        return cls
 | 
			
		||||
            as_callable = decorated
 | 
			
		||||
        shortcodes.register(name)(as_callable)
 | 
			
		||||
        return decorated
 | 
			
		||||
 | 
			
		||||
    return decorator
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class capcheck:
 | 
			
		||||
    """Decorator for shortcodes.
 | 
			
		||||
 | 
			
		||||
    On call, check for capabilities before calling the function. If the user does not
 | 
			
		||||
    have a capability, display a message insdead of the content.
 | 
			
		||||
 | 
			
		||||
    kwargs:
 | 
			
		||||
        - 'cap': Capability required for viewing.
 | 
			
		||||
        - 'nocap': Optional, text shown when the user does not have this capability.
 | 
			
		||||
        - others: Passed to the decorated shortcode.
 | 
			
		||||
    """
 | 
			
		||||
 | 
			
		||||
    def __init__(self, decorated):
 | 
			
		||||
        assert hasattr(decorated, '__call__'), '@capcheck should be used on callables.'
 | 
			
		||||
        if isinstance(decorated, type):
 | 
			
		||||
            as_callable = decorated()
 | 
			
		||||
        else:
 | 
			
		||||
            as_callable = decorated
 | 
			
		||||
        self.decorated = as_callable
 | 
			
		||||
 | 
			
		||||
    def __call__(self,
 | 
			
		||||
                 context: typing.Any,
 | 
			
		||||
                 content: str,
 | 
			
		||||
                 pargs: typing.List[str],
 | 
			
		||||
                 kwargs: typing.Dict[str, str]) -> str:
 | 
			
		||||
        from pillar.auth import current_user
 | 
			
		||||
 | 
			
		||||
        cap = kwargs.pop('cap', '')
 | 
			
		||||
        if cap:
 | 
			
		||||
            nocap = kwargs.pop('nocap', '')
 | 
			
		||||
            if not current_user.has_cap(cap):
 | 
			
		||||
                if not nocap:
 | 
			
		||||
                    return ''
 | 
			
		||||
                html = html_module.escape(nocap)
 | 
			
		||||
                return f'<p class="shortcode nocap">{html}</p>'
 | 
			
		||||
 | 
			
		||||
        return self.decorated(context, content, pargs, kwargs)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@shortcode('test')
 | 
			
		||||
class Test:
 | 
			
		||||
    def __call__(self,
 | 
			
		||||
@@ -68,6 +107,7 @@ class Test:
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@shortcode('youtube')
 | 
			
		||||
@capcheck
 | 
			
		||||
class YouTube:
 | 
			
		||||
    log = log.getChild('YouTube')
 | 
			
		||||
 | 
			
		||||
@@ -123,12 +163,16 @@ class YouTube:
 | 
			
		||||
            return html_module.escape('{youtube invalid YouTube ID/URL}')
 | 
			
		||||
 | 
			
		||||
        src = f'https://www.youtube.com/embed/{youtube_id}?rel=0'
 | 
			
		||||
        html = f'<iframe class="shortcode youtube" width="{width}" height="{height}" src="{src}"' \
 | 
			
		||||
               f' frameborder="0" allow="autoplay; encrypted-media" allowfullscreen></iframe>'
 | 
			
		||||
        iframe_tag = f'<iframe class="shortcode youtube embed-responsive-item" width="{width}"' \
 | 
			
		||||
                     f' height="{height}" src="{src}" frameborder="0" allow="autoplay; encrypted-media"' \
 | 
			
		||||
                     f' allowfullscreen></iframe>'
 | 
			
		||||
        # Embed the iframe in a container, to allow easier styling
 | 
			
		||||
        html = f'<div class="embed-responsive embed-responsive-16by9">{iframe_tag}</div>'
 | 
			
		||||
        return html
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@shortcode('iframe')
 | 
			
		||||
@capcheck
 | 
			
		||||
def iframe(context: typing.Any,
 | 
			
		||||
           content: str,
 | 
			
		||||
           pargs: typing.List[str],
 | 
			
		||||
@@ -140,16 +184,6 @@ def iframe(context: typing.Any,
 | 
			
		||||
        - others: Turned into attributes for the iframe element.
 | 
			
		||||
    """
 | 
			
		||||
    import xml.etree.ElementTree as ET
 | 
			
		||||
    from pillar.auth import current_user
 | 
			
		||||
 | 
			
		||||
    cap = kwargs.pop('cap', '')
 | 
			
		||||
    if cap:
 | 
			
		||||
        nocap = kwargs.pop('nocap', '')
 | 
			
		||||
        if not current_user.has_cap(cap):
 | 
			
		||||
            if not nocap:
 | 
			
		||||
                return ''
 | 
			
		||||
            html = html_module.escape(nocap)
 | 
			
		||||
            return f'<p class="shortcode nocap">{html}</p>'
 | 
			
		||||
 | 
			
		||||
    kwargs['class'] = f'shortcode {kwargs.get("class", "")}'.strip()
 | 
			
		||||
    element = ET.Element('iframe', kwargs)
 | 
			
		||||
 
 | 
			
		||||
@@ -1,6 +1,6 @@
 | 
			
		||||
"""Flask configuration file for unit testing."""
 | 
			
		||||
 | 
			
		||||
BLENDER_ID_ENDPOINT = 'http://127.0.0.1:8001'  # nonexistant server, no trailing slash!
 | 
			
		||||
BLENDER_ID_ENDPOINT = 'http://id.local:8001'  # Non existant server
 | 
			
		||||
 | 
			
		||||
SERVER_NAME = 'localhost'
 | 
			
		||||
PILLAR_SERVER_ENDPOINT = 'http://localhost/api/'
 | 
			
		||||
@@ -26,7 +26,6 @@ OAUTH_CREDENTIALS = {
 | 
			
		||||
    'blender-id': {
 | 
			
		||||
        'id': 'blender-id-app-id',
 | 
			
		||||
        'secret': 'blender-id–secret',
 | 
			
		||||
        'base_url': 'http://blender-id:8000/'
 | 
			
		||||
    },
 | 
			
		||||
    'facebook': {
 | 
			
		||||
        'id': 'fb-app-id',
 | 
			
		||||
 
 | 
			
		||||
@@ -4,7 +4,7 @@ from datetime import datetime
 | 
			
		||||
from datetime import date
 | 
			
		||||
import pillarsdk
 | 
			
		||||
from flask import current_app
 | 
			
		||||
from flask_wtf import Form
 | 
			
		||||
from flask_wtf import FlaskForm
 | 
			
		||||
from wtforms import StringField
 | 
			
		||||
from wtforms import DateField
 | 
			
		||||
from wtforms import SelectField
 | 
			
		||||
@@ -110,7 +110,7 @@ def get_node_form(node_type):
 | 
			
		||||
    :param node_type: Describes the node type via dyn_schema, form_schema and
 | 
			
		||||
    parent
 | 
			
		||||
    """
 | 
			
		||||
    class ProceduralForm(Form):
 | 
			
		||||
    class ProceduralForm(FlaskForm):
 | 
			
		||||
        pass
 | 
			
		||||
 | 
			
		||||
    parent_prop = node_type['parent']
 | 
			
		||||
 
 | 
			
		||||
@@ -1,4 +1,4 @@
 | 
			
		||||
from flask_wtf import Form
 | 
			
		||||
from flask_wtf import FlaskForm
 | 
			
		||||
from wtforms import StringField
 | 
			
		||||
from wtforms import BooleanField
 | 
			
		||||
from wtforms import HiddenField
 | 
			
		||||
@@ -12,7 +12,7 @@ from pillar.web import system_util
 | 
			
		||||
from pillar.web.utils.forms import FileSelectField, JSONRequired
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class ProjectForm(Form):
 | 
			
		||||
class ProjectForm(FlaskForm):
 | 
			
		||||
    project_id = HiddenField('project_id', validators=[DataRequired()])
 | 
			
		||||
    name = StringField('Name', validators=[DataRequired()])
 | 
			
		||||
    url = StringField('Url', validators=[DataRequired()])
 | 
			
		||||
@@ -32,7 +32,7 @@ class ProjectForm(Form):
 | 
			
		||||
    picture_square = FileSelectField('Picture square', file_format='image')
 | 
			
		||||
 | 
			
		||||
    def validate(self):
 | 
			
		||||
        rv = Form.validate(self)
 | 
			
		||||
        rv = FlaskForm.validate(self)
 | 
			
		||||
        if not rv:
 | 
			
		||||
            return False
 | 
			
		||||
 | 
			
		||||
@@ -54,7 +54,7 @@ class ProjectForm(Form):
 | 
			
		||||
        return True
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class NodeTypeForm(Form):
 | 
			
		||||
class NodeTypeForm(FlaskForm):
 | 
			
		||||
    project_id = HiddenField('project_id', validators=[DataRequired()])
 | 
			
		||||
    name = StringField('Name', validators=[DataRequired()])
 | 
			
		||||
    parent = StringField('Parent')
 | 
			
		||||
 
 | 
			
		||||
@@ -12,14 +12,6 @@ from pillar.sdk import FlaskInternalApi
 | 
			
		||||
log = logging.getLogger(__name__)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def blender_id_endpoint():
 | 
			
		||||
    """Gets the endpoint for the authentication API. If the env variable
 | 
			
		||||
    is defined, it's possible to override the (default) production address.
 | 
			
		||||
    """
 | 
			
		||||
    return os.environ.get('BLENDER_ID_ENDPOINT',
 | 
			
		||||
                          "https://www.blender.org/id").rstrip('/')
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def pillar_server_endpoint():
 | 
			
		||||
    """Gets the endpoint for the authentication API. If the env variable
 | 
			
		||||
    is defined, we will use the one from the config object.
 | 
			
		||||
 
 | 
			
		||||
@@ -1,5 +1,5 @@
 | 
			
		||||
from flask_login import current_user
 | 
			
		||||
from flask_wtf import Form
 | 
			
		||||
from flask_wtf import FlaskForm
 | 
			
		||||
from pillar.web import system_util
 | 
			
		||||
from pillarsdk.users import User
 | 
			
		||||
 | 
			
		||||
@@ -14,7 +14,7 @@ from wtforms.validators import Regexp
 | 
			
		||||
import wtforms.validators as wtvalid
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class UserLoginForm(Form):
 | 
			
		||||
class UserLoginForm(FlaskForm):
 | 
			
		||||
    username = StringField('Username', validators=[DataRequired()])
 | 
			
		||||
    password = PasswordField('Password', validators=[DataRequired()])
 | 
			
		||||
    remember_me = BooleanField('Remember Me')
 | 
			
		||||
@@ -23,7 +23,7 @@ class UserLoginForm(Form):
 | 
			
		||||
        super(UserLoginForm, self).__init__(csrf_enabled=False, *args, **kwargs)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class UserProfileForm(Form):
 | 
			
		||||
class UserProfileForm(FlaskForm):
 | 
			
		||||
    username = StringField('Username', validators=[DataRequired(), Length(
 | 
			
		||||
        min=3, max=128, message="Min. 3, max. 128 chars please"), Regexp(
 | 
			
		||||
        r'^[\w.@+-]+$', message="Please do not use spaces")])
 | 
			
		||||
@@ -52,7 +52,7 @@ class UserProfileForm(Form):
 | 
			
		||||
        return True
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class UserSettingsEmailsForm(Form):
 | 
			
		||||
class UserSettingsEmailsForm(FlaskForm):
 | 
			
		||||
    choices = [
 | 
			
		||||
        (1, 'Keep me updated with Blender Cloud news.'),
 | 
			
		||||
        (0, 'Do not mail me news update.')]
 | 
			
		||||
@@ -74,7 +74,7 @@ class RolesField(SelectMultipleField):
 | 
			
		||||
        return current_app.user_roles
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class UserEditForm(Form):
 | 
			
		||||
class UserEditForm(FlaskForm):
 | 
			
		||||
    roles = RolesField('Roles')
 | 
			
		||||
    email = StringField(
 | 
			
		||||
        validators=[wtvalid.DataRequired(), wtvalid.Email()],
 | 
			
		||||
 
 | 
			
		||||
@@ -5,7 +5,7 @@ attrs==16.2.0
 | 
			
		||||
algoliasearch==1.12.0
 | 
			
		||||
bcrypt==3.1.3
 | 
			
		||||
blinker==1.4
 | 
			
		||||
bleach==1.4.3
 | 
			
		||||
bleach==2.1.3
 | 
			
		||||
celery[redis]==4.0.2
 | 
			
		||||
CommonMark==0.7.2
 | 
			
		||||
elasticsearch==6.1.1
 | 
			
		||||
@@ -40,7 +40,7 @@ Flask-PyMongo==0.4.1
 | 
			
		||||
-e git+https://github.com/armadillica/cerberus.git@sybren-0.9#egg=Cerberus
 | 
			
		||||
Events==0.2.2
 | 
			
		||||
future==0.15.2
 | 
			
		||||
html5lib==0.9999999
 | 
			
		||||
html5lib==0.99999999
 | 
			
		||||
googleapis-common-protos==1.1.0
 | 
			
		||||
itsdangerous==0.24
 | 
			
		||||
Jinja2==2.9.6
 | 
			
		||||
 
 | 
			
		||||
@@ -405,6 +405,10 @@
 | 
			
		||||
			bottom: 25px
 | 
			
		||||
			top: 25px
 | 
			
		||||
 | 
			
		||||
		&.emoji
 | 
			
		||||
			display: inline-block
 | 
			
		||||
			padding: initial
 | 
			
		||||
 | 
			
		||||
	h2
 | 
			
		||||
		margin-bottom: 15px
 | 
			
		||||
 | 
			
		||||
@@ -443,11 +447,12 @@
 | 
			
		||||
 | 
			
		||||
		li
 | 
			
		||||
			margin-bottom: 7px
 | 
			
		||||
 | 
			
		||||
			img
 | 
			
		||||
				display: block
 | 
			
		||||
				padding:
 | 
			
		||||
					top: 25px
 | 
			
		||||
					bottom: 10px
 | 
			
		||||
					top: 25px
 | 
			
		||||
 | 
			
		||||
			ul, ul li ul
 | 
			
		||||
				margin-top: 15px
 | 
			
		||||
@@ -455,10 +460,13 @@
 | 
			
		||||
 | 
			
		||||
	code, kbd, pre, samp
 | 
			
		||||
		background-color: rgba($color-primary, .05)
 | 
			
		||||
		color: $color-primary
 | 
			
		||||
		color: darken($color-primary, 15%)
 | 
			
		||||
		font-size: inherit
 | 
			
		||||
		white-space: pre-line
 | 
			
		||||
 | 
			
		||||
		code
 | 
			
		||||
			background-color: transparent
 | 
			
		||||
 | 
			
		||||
	kbd
 | 
			
		||||
		border:
 | 
			
		||||
			color: rgba($color-primary, .33)
 | 
			
		||||
 
 | 
			
		||||
@@ -3,7 +3,7 @@
 | 
			
		||||
| {% macro render_blog_post(node, project=None, pages=None) %}
 | 
			
		||||
| {% if node.picture %}
 | 
			
		||||
a.blog_index-header(href="{{ node.url }}")
 | 
			
		||||
	img(src="{{ node.picture.thumbnail('l', api=api) }}")
 | 
			
		||||
	img(src="{{ node.picture.thumbnail('h', api=api) }}")
 | 
			
		||||
| {% endif %}
 | 
			
		||||
| {% if project and project._id != config.MAIN_PROJECT_ID %}
 | 
			
		||||
| {{ projectmacros.render_secondary_navigation(project, pages=pages) }}
 | 
			
		||||
 
 | 
			
		||||
@@ -39,7 +39,7 @@
 | 
			
		||||
						a(
 | 
			
		||||
							title="{{ _('Handy guide of Markdown syntax') }}",
 | 
			
		||||
							target="_blank",
 | 
			
		||||
							href="https://guides.github.com/features/mastering-markdown/")
 | 
			
		||||
							href="http://commonmark.org/help/")
 | 
			
		||||
							span {{ _('markdown cheatsheet') }}
 | 
			
		||||
			| {% endblock can_post_comment %}
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -1,26 +1,26 @@
 | 
			
		||||
| {% extends 'projects/landing.html' %}
 | 
			
		||||
 | 
			
		||||
| {% block body %}
 | 
			
		||||
 | 
			
		||||
| {% if node.picture %}
 | 
			
		||||
header
 | 
			
		||||
	img.header(src="{{ node.picture.thumbnail('l', api=api) }}")
 | 
			
		||||
	.jumbotron.jumbotron-fluid(
 | 
			
		||||
		style="background-image: url('{{ node.picture.thumbnail('h', api=api) }}'); background-position: 50% 50%;")
 | 
			
		||||
| {% endif  %}
 | 
			
		||||
 | 
			
		||||
| {# Secondary Navigation #}
 | 
			
		||||
| {% block navbar_secondary %}
 | 
			
		||||
| {{ super() }}
 | 
			
		||||
| {% endblock navbar_secondary %}
 | 
			
		||||
#node-container
 | 
			
		||||
	#node-overlay
 | 
			
		||||
 | 
			
		||||
	section.node-details-container.page
 | 
			
		||||
.container.landing
 | 
			
		||||
	section.node-details-container.project
 | 
			
		||||
		.node-details-title.container
 | 
			
		||||
			h1 {{node.name}}
 | 
			
		||||
 | 
			
		||||
		.node-details-header
 | 
			
		||||
			.node-title#node-title
 | 
			
		||||
				| {{node.name}}
 | 
			
		||||
 | 
			
		||||
		| {% if node.description %}
 | 
			
		||||
		.node-details-description#node-description
 | 
			
		||||
			| {% if node.description %}
 | 
			
		||||
			| {{ node | markdowned('description') }}
 | 
			
		||||
		| {% endif %}
 | 
			
		||||
			| {% endif %}
 | 
			
		||||
 | 
			
		||||
		.node-details-meta.footer
 | 
			
		||||
			span.updated(title="created {{ node._created | pretty_date }}") updated {{ node._updated | pretty_date }}
 | 
			
		||||
 
 | 
			
		||||
@@ -1,36 +1,41 @@
 | 
			
		||||
| {% macro render_secondary_navigation(project, pages=None) %}
 | 
			
		||||
nav.navbar-secondary
 | 
			
		||||
	.navbar-container
 | 
			
		||||
		nav.collapse.navbar-collapse
 | 
			
		||||
			ul.nav.navbar-nav.navbar-right
 | 
			
		||||
				li
 | 
			
		||||
					a.navbar-item(
 | 
			
		||||
						href="{{ url_for('projects.view', project_url=project.url) }}",
 | 
			
		||||
						title="{{ project.name }} Homepage")
 | 
			
		||||
						span
 | 
			
		||||
							b {{ project.name }}
 | 
			
		||||
				li
 | 
			
		||||
					a.navbar-item(
 | 
			
		||||
					href="{{ url_for('main.project_blog', project_url=project.url) }}",
 | 
			
		||||
					title="Project Blog",
 | 
			
		||||
					class="{% if category == 'blog' %}active{% endif %}")
 | 
			
		||||
						span Blog
 | 
			
		||||
				| {% if pages %}
 | 
			
		||||
				| {% for p in pages %}
 | 
			
		||||
				li
 | 
			
		||||
					a.navbar-item(
 | 
			
		||||
					href="{{ url_for('projects.view_node', project_url=project.url, node_id=p._id) }}",
 | 
			
		||||
					title="{{ p.name }}",
 | 
			
		||||
					class="{% if category == 'page' %}active{% endif %}")
 | 
			
		||||
						span {{ p.name }}
 | 
			
		||||
				| {% endfor %}
 | 
			
		||||
				| {% endif %}
 | 
			
		||||
				| {% if project.nodes_featured %}
 | 
			
		||||
				li
 | 
			
		||||
					a.navbar-item(
 | 
			
		||||
					href="{{ url_for('projects.view_node', project_url=project.url, node_id=project.nodes_featured[0]) }}",
 | 
			
		||||
					title="Explore {{ project.name }}",
 | 
			
		||||
					class="{% if category == 'blog' %}active{% endif %}")
 | 
			
		||||
						span Explore
 | 
			
		||||
				| {% endif %}
 | 
			
		||||
.container.navbar-secondary
 | 
			
		||||
	ul.nav.justify-content-left
 | 
			
		||||
		li.nav-item
 | 
			
		||||
			a.nav-link.nav-title(
 | 
			
		||||
				href="{{ url_for('projects.view', project_url=project.url) }}",
 | 
			
		||||
				title="{{ project.name }} Homepage") {{ project.name }}
 | 
			
		||||
		li.nav-item
 | 
			
		||||
			a.nav-link(
 | 
			
		||||
				href="{{ url_for('main.project_blog', project_url=project.url) }}",
 | 
			
		||||
				class="{% if title == 'updates' %}active{% endif %}") Updates
 | 
			
		||||
		| {% if pages %}
 | 
			
		||||
		| {% for page in pages %}
 | 
			
		||||
		li.nav-item
 | 
			
		||||
			a.nav-link(
 | 
			
		||||
				href="{{ url_for('projects.view_node', project_url=project.url, node_id=page._id) }}",
 | 
			
		||||
				class="{% if title == 'updates' %}active{% endif %}") {{ page.name }}
 | 
			
		||||
		| {% endfor %}
 | 
			
		||||
		| {% endif %}
 | 
			
		||||
		li.nav-item
 | 
			
		||||
			a.nav-link(
 | 
			
		||||
				href="/projects/gallery.html",
 | 
			
		||||
				class="{% if title == 'gallery' %}active{% endif %}") Gallery
 | 
			
		||||
		li.nav-item
 | 
			
		||||
			a.nav-link(
 | 
			
		||||
				href="#",
 | 
			
		||||
				class="{% if title == 'assets' %}active{% endif %}") Assets
 | 
			
		||||
		| {% if project.nodes_featured %}
 | 
			
		||||
		| {# In some cases featured_nodes might might be embedded #}
 | 
			
		||||
		| {% if '_id' in project.nodes_featured[0] %}
 | 
			
		||||
		| {% set featured_node_id=project.nodes_featured[0]._id %}
 | 
			
		||||
		| {% else %}
 | 
			
		||||
		| {% set featured_node_id=project.nodes_featured[0] %}
 | 
			
		||||
		| {% endif %}
 | 
			
		||||
		| {% endif %}
 | 
			
		||||
		li.nav-item
 | 
			
		||||
			a.nav-link(
 | 
			
		||||
			href="{{ url_for('projects.view_node', project_url=project.url, node_id=featured_node_id) }}",
 | 
			
		||||
			title="Explore {{ project.name }}") Dashboard
 | 
			
		||||
 | 
			
		||||
| {% endmacro %}
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										199
									
								
								tests/test_api/test_cerberus.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										199
									
								
								tests/test_api/test_cerberus.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,199 @@
 | 
			
		||||
"""Test that what we feed to Cerberus actually works.
 | 
			
		||||
 | 
			
		||||
This'll help us upgrade to new versions of Cerberus.
 | 
			
		||||
"""
 | 
			
		||||
 | 
			
		||||
import unittest
 | 
			
		||||
from pillar.tests import AbstractPillarTest
 | 
			
		||||
 | 
			
		||||
from bson import ObjectId
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class CerberusCanaryTest(unittest.TestCase):
 | 
			
		||||
 | 
			
		||||
    def _canary_test(self, validator):
 | 
			
		||||
        groups_schema = {'name': {'type': 'string', 'required': True}}
 | 
			
		||||
 | 
			
		||||
        # On error, validate_schema() raises ValidationError
 | 
			
		||||
        validator.validate_schema(groups_schema)
 | 
			
		||||
 | 
			
		||||
        # On error, validate() returns False
 | 
			
		||||
        self.assertTrue(validator.validate({'name': 'je moeder'}, groups_schema))
 | 
			
		||||
        self.assertFalse(validator.validate({'je moeder': 'op je hoofd'}, groups_schema))
 | 
			
		||||
 | 
			
		||||
    def test_canary(self):
 | 
			
		||||
        import cerberus
 | 
			
		||||
 | 
			
		||||
        validator = cerberus.Validator()
 | 
			
		||||
        self._canary_test(validator)
 | 
			
		||||
 | 
			
		||||
    def test_our_validator_simple(self):
 | 
			
		||||
        from pillar.api import custom_field_validation
 | 
			
		||||
 | 
			
		||||
        validator = custom_field_validation.ValidateCustomFields()
 | 
			
		||||
        self._canary_test(validator)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class ValidationTest(AbstractPillarTest):
 | 
			
		||||
    def setUp(self):
 | 
			
		||||
        super().setUp()
 | 
			
		||||
 | 
			
		||||
        from pillar.api import custom_field_validation
 | 
			
		||||
 | 
			
		||||
        self.validator = custom_field_validation.ValidateCustomFields()
 | 
			
		||||
        self.user_id = ObjectId(8 * 'abc')
 | 
			
		||||
        self.ensure_user_exists(self.user_id, 'Tést Üsâh')
 | 
			
		||||
 | 
			
		||||
    def assertValid(self, document, schema):
 | 
			
		||||
        with self.app.app_context():
 | 
			
		||||
            is_valid = self.validator.validate(document, schema)
 | 
			
		||||
        self.assertTrue(is_valid, f'errors: {self.validator.errors}')
 | 
			
		||||
 | 
			
		||||
    def assertInvalid(self, document, schema):
 | 
			
		||||
        with self.app.app_context():
 | 
			
		||||
            is_valid = self.validator.validate(document, schema)
 | 
			
		||||
        self.assertFalse(is_valid)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class ProjectValidationTest(ValidationTest):
 | 
			
		||||
 | 
			
		||||
    def test_empty(self):
 | 
			
		||||
        from pillar.api.eve_settings import projects_schema
 | 
			
		||||
        self.assertInvalid({}, projects_schema)
 | 
			
		||||
 | 
			
		||||
    def test_simple_project(self):
 | 
			
		||||
        from pillar.api.eve_settings import projects_schema
 | 
			
		||||
 | 
			
		||||
        project = {
 | 
			
		||||
            'name': 'Té Ærhüs',
 | 
			
		||||
            'user': self.user_id,
 | 
			
		||||
            'category': 'assets',
 | 
			
		||||
            'is_private': False,
 | 
			
		||||
            'status': 'published',
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        self.assertValid(project, projects_schema)
 | 
			
		||||
 | 
			
		||||
    def test_with_node_types(self):
 | 
			
		||||
        from pillar.api.eve_settings import projects_schema
 | 
			
		||||
        from pillar.api import node_types
 | 
			
		||||
 | 
			
		||||
        project = {
 | 
			
		||||
            'name': 'Té Ærhüs',
 | 
			
		||||
            'user': self.user_id,
 | 
			
		||||
            'category': 'assets',
 | 
			
		||||
            'is_private': False,
 | 
			
		||||
            'status': 'published',
 | 
			
		||||
            'node_types': [node_types.node_type_asset,
 | 
			
		||||
                           node_types.node_type_comment]
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        self.assertValid(project, projects_schema)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class NodeValidationTest(ValidationTest):
 | 
			
		||||
    def setUp(self):
 | 
			
		||||
        super().setUp()
 | 
			
		||||
        self.pid, self.project = self.ensure_project_exists()
 | 
			
		||||
 | 
			
		||||
    def test_empty(self):
 | 
			
		||||
        from pillar.api.eve_settings import nodes_schema
 | 
			
		||||
        self.assertInvalid({}, nodes_schema)
 | 
			
		||||
 | 
			
		||||
    def test_asset(self):
 | 
			
		||||
        from pillar.api.eve_settings import nodes_schema
 | 
			
		||||
 | 
			
		||||
        file_id, _ = self.ensure_file_exists()
 | 
			
		||||
 | 
			
		||||
        node = {
 | 
			
		||||
            'name': '"The Harmless Prototype™"',
 | 
			
		||||
            'project': self.pid,
 | 
			
		||||
            'node_type': 'asset',
 | 
			
		||||
            'properties': {
 | 
			
		||||
                'status': 'published',
 | 
			
		||||
                'content_type': 'image',
 | 
			
		||||
                'file': file_id,
 | 
			
		||||
            },
 | 
			
		||||
            'user': self.user_id,
 | 
			
		||||
            'short_code': 'ABC333',
 | 
			
		||||
        }
 | 
			
		||||
        self.assertValid(node, nodes_schema)
 | 
			
		||||
 | 
			
		||||
    def test_asset_invalid_properties(self):
 | 
			
		||||
        from pillar.api.eve_settings import nodes_schema
 | 
			
		||||
 | 
			
		||||
        file_id, _ = self.ensure_file_exists()
 | 
			
		||||
 | 
			
		||||
        node = {
 | 
			
		||||
            'name': '"The Harmless Prototype™"',
 | 
			
		||||
            'project': self.pid,
 | 
			
		||||
            'node_type': 'asset',
 | 
			
		||||
            'properties': {
 | 
			
		||||
                'status': 'invalid-status',
 | 
			
		||||
                'content_type': 'image',
 | 
			
		||||
                'file': file_id,
 | 
			
		||||
            },
 | 
			
		||||
            'user': self.user_id,
 | 
			
		||||
            'short_code': 'ABC333',
 | 
			
		||||
        }
 | 
			
		||||
        self.assertInvalid(node, nodes_schema)
 | 
			
		||||
 | 
			
		||||
    def test_comment(self):
 | 
			
		||||
        from pillar.api.eve_settings import nodes_schema
 | 
			
		||||
 | 
			
		||||
        file_id, _ = self.ensure_file_exists()
 | 
			
		||||
 | 
			
		||||
        node = {
 | 
			
		||||
            'name': '"The Harmless Prototype™"',
 | 
			
		||||
            'project': self.pid,
 | 
			
		||||
            'node_type': 'asset',
 | 
			
		||||
            'properties': {
 | 
			
		||||
                'status': 'published',
 | 
			
		||||
                'content_type': 'image',
 | 
			
		||||
                'file': file_id,
 | 
			
		||||
            },
 | 
			
		||||
            'user': self.user_id,
 | 
			
		||||
            'short_code': 'ABC333',
 | 
			
		||||
        }
 | 
			
		||||
        node_id = self.create_node(node)
 | 
			
		||||
 | 
			
		||||
        comment = {
 | 
			
		||||
            'name': 'comment on some node',
 | 
			
		||||
            'project': self.pid,
 | 
			
		||||
            'node_type': 'comment',
 | 
			
		||||
            'properties': {
 | 
			
		||||
                'content': 'this is a comment',
 | 
			
		||||
                'status': 'published',
 | 
			
		||||
            },
 | 
			
		||||
            'parent': node_id,
 | 
			
		||||
        }
 | 
			
		||||
        self.assertValid(comment, nodes_schema)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class IPRangeValidatorTest(ValidationTest):
 | 
			
		||||
    schema = {'iprange': {'type': 'iprange', 'required': True}}
 | 
			
		||||
 | 
			
		||||
    def assertValid(self, document, schema=None):
 | 
			
		||||
        return super().assertValid(document, schema or self.schema)
 | 
			
		||||
 | 
			
		||||
    def assertInvalid(self, document, schema=None):
 | 
			
		||||
        return super().assertInvalid(document, schema or self.schema)
 | 
			
		||||
 | 
			
		||||
    def test_ipv6(self):
 | 
			
		||||
        self.assertValid({'iprange': '2a03:b0c0:0:1010::8fe:6ef1'})
 | 
			
		||||
        self.assertValid({'iprange': '0:0:0:0:0:ffff:102:304'})
 | 
			
		||||
        self.assertValid({'iprange': '2a03:b0c0:0:1010::8fe:6ef1/120'})
 | 
			
		||||
        self.assertValid({'iprange': 'ff06::/8'})
 | 
			
		||||
        self.assertValid({'iprange': '::/8'})
 | 
			
		||||
        self.assertValid({'iprange': '::/1'})
 | 
			
		||||
        self.assertValid({'iprange': '::1/128'})
 | 
			
		||||
        self.assertValid({'iprange': '::'})
 | 
			
		||||
        self.assertInvalid({'iprange': '::/0'})
 | 
			
		||||
        self.assertInvalid({'iprange': 'barbled'})
 | 
			
		||||
 | 
			
		||||
    def test_ipv4(self):
 | 
			
		||||
        self.assertValid({'iprange': '1.2.3.4'})
 | 
			
		||||
        self.assertValid({'iprange': '1.2.3.4/24'})
 | 
			
		||||
        self.assertValid({'iprange': '127.0.0.0/8'})
 | 
			
		||||
        self.assertInvalid({'iprange': '127.0.0.0/0'})
 | 
			
		||||
        self.assertInvalid({'iprange': 'garbled'})
 | 
			
		||||
@@ -12,7 +12,7 @@ class OAuthTests(AbstractPillarTest):
 | 
			
		||||
 | 
			
		||||
        oauth_provider = OAuthSignIn.get_provider('blender-id')
 | 
			
		||||
        self.assertIsInstance(oauth_provider, BlenderIdSignIn)
 | 
			
		||||
        self.assertEqual(oauth_provider.service.base_url, 'http://blender-id:8000/api/')
 | 
			
		||||
        self.assertEqual(oauth_provider.service.base_url, 'http://id.local:8001/api/')
 | 
			
		||||
 | 
			
		||||
    def test_provider_not_implemented(self):
 | 
			
		||||
        from pillar.auth.oauth import OAuthSignIn, ProviderNotImplemented
 | 
			
		||||
@@ -46,11 +46,11 @@ class OAuthTests(AbstractPillarTest):
 | 
			
		||||
    def test_provider_callback_happy(self):
 | 
			
		||||
        from pillar.auth.oauth import OAuthSignIn
 | 
			
		||||
 | 
			
		||||
        responses.add(responses.POST, 'http://blender-id:8000/oauth/token',
 | 
			
		||||
        responses.add(responses.POST, 'http://id.local:8001/oauth/token',
 | 
			
		||||
                      json={'access_token': 'successful-token'},
 | 
			
		||||
                      status=200)
 | 
			
		||||
 | 
			
		||||
        responses.add(responses.GET, 'http://blender-id:8000/api/user',
 | 
			
		||||
        responses.add(responses.GET, 'http://id.local:8001/api/user',
 | 
			
		||||
                      json={'id': '7',
 | 
			
		||||
                            'email': 'harry@blender.org'},
 | 
			
		||||
                      status=200)
 | 
			
		||||
 
 | 
			
		||||
@@ -184,3 +184,31 @@ class NodeSetattrTest(unittest.TestCase):
 | 
			
		||||
 | 
			
		||||
        node_setattr(node, 'b.complex', {None: 5})
 | 
			
		||||
        self.assertEqual({'b': {'complex': {None: 5}}}, node)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class TestRating(unittest.TestCase):
 | 
			
		||||
    def test_hotness(self):
 | 
			
		||||
        """We expect the sorted values to reflect the original order in the
 | 
			
		||||
        list.
 | 
			
		||||
        """
 | 
			
		||||
        from datetime import datetime, timezone
 | 
			
		||||
        from pillar.api.utils.rating import hot
 | 
			
		||||
        t = datetime(2017, 2, 11, 0, 0, 0, 0, timezone.utc)
 | 
			
		||||
        y = datetime(2017, 2, 10, 0, 0, 0, 0, timezone.utc)
 | 
			
		||||
        w = datetime(2017, 2, 5, 0, 0, 0, 0, timezone.utc)
 | 
			
		||||
        cases = [
 | 
			
		||||
            (hot(1, 8, t), 'today super bad'),
 | 
			
		||||
            (hot(0, 3, t), 'today slightly worse'),
 | 
			
		||||
            (hot(0, 2, y), 'yesterday bad'),
 | 
			
		||||
            (hot(0, 2, t), 'today bad'),
 | 
			
		||||
            (hot(4, 4, w), 'last week controversial'),
 | 
			
		||||
            (hot(7, 1, w), 'last week very good'),
 | 
			
		||||
            (hot(5, 1, y), 'yesterday medium'),
 | 
			
		||||
            (hot(5, 0, y), 'yesterday good'),
 | 
			
		||||
            (hot(7, 1, y), 'yesterday very good'),
 | 
			
		||||
            (hot(4, 4, t), 'today controversial'),
 | 
			
		||||
            (hot(7, 1, t), 'today very good'),
 | 
			
		||||
        ]
 | 
			
		||||
        sorted_by_hot = sorted(cases, key=lambda tup: tup[0])
 | 
			
		||||
        for idx, t in enumerate(sorted_by_hot):
 | 
			
		||||
            self.assertEqual(cases[idx][0], t[0])
 | 
			
		||||
 
 | 
			
		||||
@@ -40,7 +40,7 @@ class DemoTest(unittest.TestCase):
 | 
			
		||||
        self.assertEqual('<dl><dt>test</dt><dt>ü</dt><dd>é</dd></dl>', render('{test ü="é"}'))
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class YouTubeTest(unittest.TestCase):
 | 
			
		||||
class YouTubeTest(AbstractPillarTest):
 | 
			
		||||
    def test_missing(self):
 | 
			
		||||
        from pillar.shortcodes import render
 | 
			
		||||
 | 
			
		||||
@@ -104,6 +104,19 @@ class YouTubeTest(unittest.TestCase):
 | 
			
		||||
            render('{youtube "https://www.youtube.com/watch?v=NwVGvcIrNWA" width=5 height="3"}')
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    def test_user_no_cap(self):
 | 
			
		||||
        from pillar.shortcodes import render
 | 
			
		||||
 | 
			
		||||
        with self.app.app_context():
 | 
			
		||||
            # Anonymous user, so no subscriber capability.
 | 
			
		||||
            self.assertEqual('', render('{youtube ABCDEF cap=subscriber}'))
 | 
			
		||||
            self.assertEqual('', render('{youtube ABCDEF cap="subscriber"}'))
 | 
			
		||||
            self.assertEqual(
 | 
			
		||||
                '<p class="shortcode nocap">Aðeins áskrifendur hafa aðgang að þessu efni.</p>',
 | 
			
		||||
                render('{youtube ABCDEF'
 | 
			
		||||
                       ' cap="subscriber"'
 | 
			
		||||
                       ' nocap="Aðeins áskrifendur hafa aðgang að þessu efni."}'))
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class IFrameTest(AbstractPillarTest):
 | 
			
		||||
    def test_missing_cap(self):
 | 
			
		||||
 
 | 
			
		||||
@@ -11,8 +11,8 @@ class MarkdownTest(unittest.TestCase):
 | 
			
		||||
    def test_bleached(self):
 | 
			
		||||
        from pillar.web import jinja
 | 
			
		||||
 | 
			
		||||
        self.assertEqual('<script>alert("hey");<script>',
 | 
			
		||||
                         jinja.do_markdown('<script>alert("hey");<script>').strip())
 | 
			
		||||
        self.assertEqual('<script>alert("hey");</script>',
 | 
			
		||||
                         jinja.do_markdown('<script>alert("hey");</script>').strip())
 | 
			
		||||
 | 
			
		||||
    def test_degenerate(self):
 | 
			
		||||
        from pillar.web import jinja
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user