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'):
|
if not self.config.get('STATIC_FILE_HASH'):
|
||||||
self.log.warning('STATIC_FILE_HASH is empty, generating random one')
|
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]
|
h = re.sub(r'[_.~-]', '', secrets.token_urlsafe())[:8]
|
||||||
self.config['STATIC_FILE_HASH'] = h
|
self.config['STATIC_FILE_HASH'] = h
|
||||||
|
|
||||||
|
@@ -47,13 +47,6 @@ def store_subclient_token():
|
|||||||
'subclient_user_id': str(db_user['_id'])}), status
|
'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):
|
def validate_create_user(blender_id_user_id, token, oauth_subclient_id):
|
||||||
"""Validates a user against Blender ID, creating the user in our database.
|
"""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.
|
# We only want to accept Blender Cloud tokens.
|
||||||
payload['client_id'] = current_app.config['OAUTH_CREDENTIALS']['blender-id']['id']
|
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)
|
log.debug('POSTing to %r', url)
|
||||||
|
|
||||||
# Retry a few times when POSTing to BlenderID fails.
|
# Retry a few times when POSTing to BlenderID fails.
|
||||||
# Source: http://stackoverflow.com/a/15431343/875379
|
# Source: http://stackoverflow.com/a/15431343/875379
|
||||||
s = requests.Session()
|
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.
|
# POST to Blender ID, handling errors as negative verification results.
|
||||||
try:
|
try:
|
||||||
@@ -225,7 +218,7 @@ def fetch_blenderid_user() -> dict:
|
|||||||
|
|
||||||
my_log = log.getChild('fetch_blenderid_user')
|
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)
|
my_log.debug('Fetching user info from %s', bid_url)
|
||||||
|
|
||||||
credentials = current_app.config['OAUTH_CREDENTIALS']['blender-id']
|
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:
|
def switch_user_url(next_url: str) -> str:
|
||||||
from urllib.parse import quote
|
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:
|
if next_url:
|
||||||
return '%s?next=%s' % (base_url, quote(next_url))
|
return '%s?next=%s' % (base_url, quote(next_url))
|
||||||
return base_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.
|
# 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.asset import node_type_asset
|
||||||
from pillar.api.node_types.blog import node_type_blog
|
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):
|
def __init__(self):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
|
|
||||||
base_url = current_app.config['OAUTH_CREDENTIALS']['blender-id'].get(
|
base_url = current_app.config['BLENDER_ID_ENDPOINT']
|
||||||
'base_url', 'https://www.blender.org/id/')
|
|
||||||
|
|
||||||
self.service = OAuth2Service(
|
self.service = OAuth2Service(
|
||||||
name='blender-id',
|
name='blender-id',
|
||||||
client_id=self.consumer_id,
|
client_id=self.consumer_id,
|
||||||
client_secret=self.consumer_secret,
|
client_secret=self.consumer_secret,
|
||||||
authorize_url='%soauth/authorize' % base_url,
|
authorize_url='%s/oauth/authorize' % base_url,
|
||||||
access_token_url='%soauth/token' % base_url,
|
access_token_url='%s/oauth/token' % base_url,
|
||||||
base_url='%sapi/' % base_url
|
base_url='%s/api/' % base_url
|
||||||
)
|
)
|
||||||
|
|
||||||
def authorize(self):
|
def authorize(self):
|
||||||
|
@@ -32,7 +32,7 @@ SECRET_KEY = ''
|
|||||||
AUTH_TOKEN_HMAC_KEY = b''
|
AUTH_TOKEN_HMAC_KEY = b''
|
||||||
|
|
||||||
# Authentication settings
|
# Authentication settings
|
||||||
BLENDER_ID_ENDPOINT = 'http://blender-id:8000/'
|
BLENDER_ID_ENDPOINT = 'http://id.local:8000'
|
||||||
|
|
||||||
CDN_USE_URL_SIGNING = True
|
CDN_USE_URL_SIGNING = True
|
||||||
CDN_SERVICE_DOMAIN_PROTOCOL = 'https'
|
CDN_SERVICE_DOMAIN_PROTOCOL = 'https'
|
||||||
@@ -124,9 +124,8 @@ BLENDER_ID_USER_INFO_TOKEN = '-set-in-config-local-'
|
|||||||
# Example entry:
|
# Example entry:
|
||||||
# OAUTH_CREDENTIALS = {
|
# OAUTH_CREDENTIALS = {
|
||||||
# 'blender-id': {
|
# 'blender-id': {
|
||||||
# 'id': 'CLOUD-OF-SNOWFLAKES-43',
|
# 'id': 'CLOUD-OF-SNOWFLAKES-42',
|
||||||
# 'secret': 'thesecret',
|
# 'secret': 'thesecret',
|
||||||
# 'base_url': 'http://blender-id:8000/'
|
|
||||||
# }
|
# }
|
||||||
# }
|
# }
|
||||||
# OAuth providers are defined in pillar.auth.oauth
|
# OAuth providers are defined in pillar.auth.oauth
|
||||||
|
@@ -45,11 +45,15 @@ ALLOWED_STYLES = [
|
|||||||
def markdown(s: str) -> str:
|
def markdown(s: str) -> str:
|
||||||
commented_shortcodes = shortcodes.comment_shortcodes(s)
|
commented_shortcodes = shortcodes.comment_shortcodes(s)
|
||||||
tainted_html = CommonMark.commonmark(commented_shortcodes)
|
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,
|
attributes=ALLOWED_ATTRIBUTES,
|
||||||
styles=ALLOWED_STYLES,
|
styles=ALLOWED_STYLES,
|
||||||
strip_comments=False)
|
strip_comments=False,
|
||||||
|
filters=[bleach.linkifier.LinkifyFilter])
|
||||||
|
|
||||||
|
safe_html = cleaner.clean(tainted_html)
|
||||||
return safe_html
|
return safe_html
|
||||||
|
|
||||||
|
|
||||||
|
@@ -33,18 +33,57 @@ log = logging.getLogger(__name__)
|
|||||||
def shortcode(name: str):
|
def shortcode(name: str):
|
||||||
"""Class decorator for shortcodes."""
|
"""Class decorator for shortcodes."""
|
||||||
|
|
||||||
def decorator(cls):
|
def decorator(decorated):
|
||||||
assert hasattr(cls, '__call__'), '@shortcode should be used on callables.'
|
assert hasattr(decorated, '__call__'), '@shortcode should be used on callables.'
|
||||||
if isinstance(cls, type):
|
if isinstance(decorated, type):
|
||||||
instance = cls()
|
as_callable = decorated()
|
||||||
else:
|
else:
|
||||||
instance = cls
|
as_callable = decorated
|
||||||
shortcodes.register(name)(instance)
|
shortcodes.register(name)(as_callable)
|
||||||
return cls
|
return decorated
|
||||||
|
|
||||||
return decorator
|
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')
|
@shortcode('test')
|
||||||
class Test:
|
class Test:
|
||||||
def __call__(self,
|
def __call__(self,
|
||||||
@@ -68,6 +107,7 @@ class Test:
|
|||||||
|
|
||||||
|
|
||||||
@shortcode('youtube')
|
@shortcode('youtube')
|
||||||
|
@capcheck
|
||||||
class YouTube:
|
class YouTube:
|
||||||
log = log.getChild('YouTube')
|
log = log.getChild('YouTube')
|
||||||
|
|
||||||
@@ -123,12 +163,16 @@ class YouTube:
|
|||||||
return html_module.escape('{youtube invalid YouTube ID/URL}')
|
return html_module.escape('{youtube invalid YouTube ID/URL}')
|
||||||
|
|
||||||
src = f'https://www.youtube.com/embed/{youtube_id}?rel=0'
|
src = f'https://www.youtube.com/embed/{youtube_id}?rel=0'
|
||||||
html = f'<iframe class="shortcode youtube" width="{width}" height="{height}" src="{src}"' \
|
iframe_tag = f'<iframe class="shortcode youtube embed-responsive-item" width="{width}"' \
|
||||||
f' frameborder="0" allow="autoplay; encrypted-media" allowfullscreen></iframe>'
|
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
|
return html
|
||||||
|
|
||||||
|
|
||||||
@shortcode('iframe')
|
@shortcode('iframe')
|
||||||
|
@capcheck
|
||||||
def iframe(context: typing.Any,
|
def iframe(context: typing.Any,
|
||||||
content: str,
|
content: str,
|
||||||
pargs: typing.List[str],
|
pargs: typing.List[str],
|
||||||
@@ -140,16 +184,6 @@ def iframe(context: typing.Any,
|
|||||||
- others: Turned into attributes for the iframe element.
|
- others: Turned into attributes for the iframe element.
|
||||||
"""
|
"""
|
||||||
import xml.etree.ElementTree as ET
|
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()
|
kwargs['class'] = f'shortcode {kwargs.get("class", "")}'.strip()
|
||||||
element = ET.Element('iframe', kwargs)
|
element = ET.Element('iframe', kwargs)
|
||||||
|
@@ -1,6 +1,6 @@
|
|||||||
"""Flask configuration file for unit testing."""
|
"""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'
|
SERVER_NAME = 'localhost'
|
||||||
PILLAR_SERVER_ENDPOINT = 'http://localhost/api/'
|
PILLAR_SERVER_ENDPOINT = 'http://localhost/api/'
|
||||||
@@ -26,7 +26,6 @@ OAUTH_CREDENTIALS = {
|
|||||||
'blender-id': {
|
'blender-id': {
|
||||||
'id': 'blender-id-app-id',
|
'id': 'blender-id-app-id',
|
||||||
'secret': 'blender-id–secret',
|
'secret': 'blender-id–secret',
|
||||||
'base_url': 'http://blender-id:8000/'
|
|
||||||
},
|
},
|
||||||
'facebook': {
|
'facebook': {
|
||||||
'id': 'fb-app-id',
|
'id': 'fb-app-id',
|
||||||
|
@@ -4,7 +4,7 @@ from datetime import datetime
|
|||||||
from datetime import date
|
from datetime import date
|
||||||
import pillarsdk
|
import pillarsdk
|
||||||
from flask import current_app
|
from flask import current_app
|
||||||
from flask_wtf import Form
|
from flask_wtf import FlaskForm
|
||||||
from wtforms import StringField
|
from wtforms import StringField
|
||||||
from wtforms import DateField
|
from wtforms import DateField
|
||||||
from wtforms import SelectField
|
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
|
:param node_type: Describes the node type via dyn_schema, form_schema and
|
||||||
parent
|
parent
|
||||||
"""
|
"""
|
||||||
class ProceduralForm(Form):
|
class ProceduralForm(FlaskForm):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
parent_prop = node_type['parent']
|
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 StringField
|
||||||
from wtforms import BooleanField
|
from wtforms import BooleanField
|
||||||
from wtforms import HiddenField
|
from wtforms import HiddenField
|
||||||
@@ -12,7 +12,7 @@ from pillar.web import system_util
|
|||||||
from pillar.web.utils.forms import FileSelectField, JSONRequired
|
from pillar.web.utils.forms import FileSelectField, JSONRequired
|
||||||
|
|
||||||
|
|
||||||
class ProjectForm(Form):
|
class ProjectForm(FlaskForm):
|
||||||
project_id = HiddenField('project_id', validators=[DataRequired()])
|
project_id = HiddenField('project_id', validators=[DataRequired()])
|
||||||
name = StringField('Name', validators=[DataRequired()])
|
name = StringField('Name', validators=[DataRequired()])
|
||||||
url = StringField('Url', validators=[DataRequired()])
|
url = StringField('Url', validators=[DataRequired()])
|
||||||
@@ -32,7 +32,7 @@ class ProjectForm(Form):
|
|||||||
picture_square = FileSelectField('Picture square', file_format='image')
|
picture_square = FileSelectField('Picture square', file_format='image')
|
||||||
|
|
||||||
def validate(self):
|
def validate(self):
|
||||||
rv = Form.validate(self)
|
rv = FlaskForm.validate(self)
|
||||||
if not rv:
|
if not rv:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@@ -54,7 +54,7 @@ class ProjectForm(Form):
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
class NodeTypeForm(Form):
|
class NodeTypeForm(FlaskForm):
|
||||||
project_id = HiddenField('project_id', validators=[DataRequired()])
|
project_id = HiddenField('project_id', validators=[DataRequired()])
|
||||||
name = StringField('Name', validators=[DataRequired()])
|
name = StringField('Name', validators=[DataRequired()])
|
||||||
parent = StringField('Parent')
|
parent = StringField('Parent')
|
||||||
|
@@ -12,14 +12,6 @@ from pillar.sdk import FlaskInternalApi
|
|||||||
log = logging.getLogger(__name__)
|
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():
|
def pillar_server_endpoint():
|
||||||
"""Gets the endpoint for the authentication API. If the env variable
|
"""Gets the endpoint for the authentication API. If the env variable
|
||||||
is defined, we will use the one from the config object.
|
is defined, we will use the one from the config object.
|
||||||
|
@@ -1,5 +1,5 @@
|
|||||||
from flask_login import current_user
|
from flask_login import current_user
|
||||||
from flask_wtf import Form
|
from flask_wtf import FlaskForm
|
||||||
from pillar.web import system_util
|
from pillar.web import system_util
|
||||||
from pillarsdk.users import User
|
from pillarsdk.users import User
|
||||||
|
|
||||||
@@ -14,7 +14,7 @@ from wtforms.validators import Regexp
|
|||||||
import wtforms.validators as wtvalid
|
import wtforms.validators as wtvalid
|
||||||
|
|
||||||
|
|
||||||
class UserLoginForm(Form):
|
class UserLoginForm(FlaskForm):
|
||||||
username = StringField('Username', validators=[DataRequired()])
|
username = StringField('Username', validators=[DataRequired()])
|
||||||
password = PasswordField('Password', validators=[DataRequired()])
|
password = PasswordField('Password', validators=[DataRequired()])
|
||||||
remember_me = BooleanField('Remember Me')
|
remember_me = BooleanField('Remember Me')
|
||||||
@@ -23,7 +23,7 @@ class UserLoginForm(Form):
|
|||||||
super(UserLoginForm, self).__init__(csrf_enabled=False, *args, **kwargs)
|
super(UserLoginForm, self).__init__(csrf_enabled=False, *args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
class UserProfileForm(Form):
|
class UserProfileForm(FlaskForm):
|
||||||
username = StringField('Username', validators=[DataRequired(), Length(
|
username = StringField('Username', validators=[DataRequired(), Length(
|
||||||
min=3, max=128, message="Min. 3, max. 128 chars please"), Regexp(
|
min=3, max=128, message="Min. 3, max. 128 chars please"), Regexp(
|
||||||
r'^[\w.@+-]+$', message="Please do not use spaces")])
|
r'^[\w.@+-]+$', message="Please do not use spaces")])
|
||||||
@@ -52,7 +52,7 @@ class UserProfileForm(Form):
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
class UserSettingsEmailsForm(Form):
|
class UserSettingsEmailsForm(FlaskForm):
|
||||||
choices = [
|
choices = [
|
||||||
(1, 'Keep me updated with Blender Cloud news.'),
|
(1, 'Keep me updated with Blender Cloud news.'),
|
||||||
(0, 'Do not mail me news update.')]
|
(0, 'Do not mail me news update.')]
|
||||||
@@ -74,7 +74,7 @@ class RolesField(SelectMultipleField):
|
|||||||
return current_app.user_roles
|
return current_app.user_roles
|
||||||
|
|
||||||
|
|
||||||
class UserEditForm(Form):
|
class UserEditForm(FlaskForm):
|
||||||
roles = RolesField('Roles')
|
roles = RolesField('Roles')
|
||||||
email = StringField(
|
email = StringField(
|
||||||
validators=[wtvalid.DataRequired(), wtvalid.Email()],
|
validators=[wtvalid.DataRequired(), wtvalid.Email()],
|
||||||
|
@@ -5,7 +5,7 @@ attrs==16.2.0
|
|||||||
algoliasearch==1.12.0
|
algoliasearch==1.12.0
|
||||||
bcrypt==3.1.3
|
bcrypt==3.1.3
|
||||||
blinker==1.4
|
blinker==1.4
|
||||||
bleach==1.4.3
|
bleach==2.1.3
|
||||||
celery[redis]==4.0.2
|
celery[redis]==4.0.2
|
||||||
CommonMark==0.7.2
|
CommonMark==0.7.2
|
||||||
elasticsearch==6.1.1
|
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
|
-e git+https://github.com/armadillica/cerberus.git@sybren-0.9#egg=Cerberus
|
||||||
Events==0.2.2
|
Events==0.2.2
|
||||||
future==0.15.2
|
future==0.15.2
|
||||||
html5lib==0.9999999
|
html5lib==0.99999999
|
||||||
googleapis-common-protos==1.1.0
|
googleapis-common-protos==1.1.0
|
||||||
itsdangerous==0.24
|
itsdangerous==0.24
|
||||||
Jinja2==2.9.6
|
Jinja2==2.9.6
|
||||||
|
@@ -405,6 +405,10 @@
|
|||||||
bottom: 25px
|
bottom: 25px
|
||||||
top: 25px
|
top: 25px
|
||||||
|
|
||||||
|
&.emoji
|
||||||
|
display: inline-block
|
||||||
|
padding: initial
|
||||||
|
|
||||||
h2
|
h2
|
||||||
margin-bottom: 15px
|
margin-bottom: 15px
|
||||||
|
|
||||||
@@ -443,11 +447,12 @@
|
|||||||
|
|
||||||
li
|
li
|
||||||
margin-bottom: 7px
|
margin-bottom: 7px
|
||||||
|
|
||||||
img
|
img
|
||||||
display: block
|
display: block
|
||||||
padding:
|
padding:
|
||||||
top: 25px
|
|
||||||
bottom: 10px
|
bottom: 10px
|
||||||
|
top: 25px
|
||||||
|
|
||||||
ul, ul li ul
|
ul, ul li ul
|
||||||
margin-top: 15px
|
margin-top: 15px
|
||||||
@@ -455,10 +460,13 @@
|
|||||||
|
|
||||||
code, kbd, pre, samp
|
code, kbd, pre, samp
|
||||||
background-color: rgba($color-primary, .05)
|
background-color: rgba($color-primary, .05)
|
||||||
color: $color-primary
|
color: darken($color-primary, 15%)
|
||||||
font-size: inherit
|
font-size: inherit
|
||||||
white-space: pre-line
|
white-space: pre-line
|
||||||
|
|
||||||
|
code
|
||||||
|
background-color: transparent
|
||||||
|
|
||||||
kbd
|
kbd
|
||||||
border:
|
border:
|
||||||
color: rgba($color-primary, .33)
|
color: rgba($color-primary, .33)
|
||||||
|
@@ -3,7 +3,7 @@
|
|||||||
| {% macro render_blog_post(node, project=None, pages=None) %}
|
| {% macro render_blog_post(node, project=None, pages=None) %}
|
||||||
| {% if node.picture %}
|
| {% if node.picture %}
|
||||||
a.blog_index-header(href="{{ node.url }}")
|
a.blog_index-header(href="{{ node.url }}")
|
||||||
img(src="{{ node.picture.thumbnail('l', api=api) }}")
|
img(src="{{ node.picture.thumbnail('h', api=api) }}")
|
||||||
| {% endif %}
|
| {% endif %}
|
||||||
| {% if project and project._id != config.MAIN_PROJECT_ID %}
|
| {% if project and project._id != config.MAIN_PROJECT_ID %}
|
||||||
| {{ projectmacros.render_secondary_navigation(project, pages=pages) }}
|
| {{ projectmacros.render_secondary_navigation(project, pages=pages) }}
|
||||||
|
@@ -39,7 +39,7 @@
|
|||||||
a(
|
a(
|
||||||
title="{{ _('Handy guide of Markdown syntax') }}",
|
title="{{ _('Handy guide of Markdown syntax') }}",
|
||||||
target="_blank",
|
target="_blank",
|
||||||
href="https://guides.github.com/features/mastering-markdown/")
|
href="http://commonmark.org/help/")
|
||||||
span {{ _('markdown cheatsheet') }}
|
span {{ _('markdown cheatsheet') }}
|
||||||
| {% endblock can_post_comment %}
|
| {% endblock can_post_comment %}
|
||||||
|
|
||||||
|
@@ -1,26 +1,26 @@
|
|||||||
| {% extends 'projects/landing.html' %}
|
| {% extends 'projects/landing.html' %}
|
||||||
|
|
||||||
| {% block body %}
|
| {% block body %}
|
||||||
|
|
||||||
| {% if node.picture %}
|
| {% if node.picture %}
|
||||||
header
|
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 %}
|
| {% endif %}
|
||||||
|
|
||||||
|
| {# Secondary Navigation #}
|
||||||
| {% block navbar_secondary %}
|
| {% block navbar_secondary %}
|
||||||
| {{ super() }}
|
| {{ super() }}
|
||||||
| {% endblock navbar_secondary %}
|
| {% 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
|
| {% if node.description %}
|
||||||
.node-title#node-title
|
|
||||||
| {{node.name}}
|
|
||||||
|
|
||||||
| {% if node.description %}
|
|
||||||
.node-details-description#node-description
|
|
||||||
| {{ node | markdowned('description') }}
|
| {{ node | markdowned('description') }}
|
||||||
| {% endif %}
|
| {% endif %}
|
||||||
|
|
||||||
.node-details-meta.footer
|
.node-details-meta.footer
|
||||||
span.updated(title="created {{ node._created | pretty_date }}") updated {{ node._updated | pretty_date }}
|
span.updated(title="created {{ node._created | pretty_date }}") updated {{ node._updated | pretty_date }}
|
||||||
|
@@ -1,36 +1,41 @@
|
|||||||
| {% macro render_secondary_navigation(project, pages=None) %}
|
| {% macro render_secondary_navigation(project, pages=None) %}
|
||||||
nav.navbar-secondary
|
.container.navbar-secondary
|
||||||
.navbar-container
|
ul.nav.justify-content-left
|
||||||
nav.collapse.navbar-collapse
|
li.nav-item
|
||||||
ul.nav.navbar-nav.navbar-right
|
a.nav-link.nav-title(
|
||||||
li
|
href="{{ url_for('projects.view', project_url=project.url) }}",
|
||||||
a.navbar-item(
|
title="{{ project.name }} Homepage") {{ project.name }}
|
||||||
href="{{ url_for('projects.view', project_url=project.url) }}",
|
li.nav-item
|
||||||
title="{{ project.name }} Homepage")
|
a.nav-link(
|
||||||
span
|
href="{{ url_for('main.project_blog', project_url=project.url) }}",
|
||||||
b {{ project.name }}
|
class="{% if title == 'updates' %}active{% endif %}") Updates
|
||||||
li
|
| {% if pages %}
|
||||||
a.navbar-item(
|
| {% for page in pages %}
|
||||||
href="{{ url_for('main.project_blog', project_url=project.url) }}",
|
li.nav-item
|
||||||
title="Project Blog",
|
a.nav-link(
|
||||||
class="{% if category == 'blog' %}active{% endif %}")
|
href="{{ url_for('projects.view_node', project_url=project.url, node_id=page._id) }}",
|
||||||
span Blog
|
class="{% if title == 'updates' %}active{% endif %}") {{ page.name }}
|
||||||
| {% if pages %}
|
| {% endfor %}
|
||||||
| {% for p in pages %}
|
| {% endif %}
|
||||||
li
|
li.nav-item
|
||||||
a.navbar-item(
|
a.nav-link(
|
||||||
href="{{ url_for('projects.view_node', project_url=project.url, node_id=p._id) }}",
|
href="/projects/gallery.html",
|
||||||
title="{{ p.name }}",
|
class="{% if title == 'gallery' %}active{% endif %}") Gallery
|
||||||
class="{% if category == 'page' %}active{% endif %}")
|
li.nav-item
|
||||||
span {{ p.name }}
|
a.nav-link(
|
||||||
| {% endfor %}
|
href="#",
|
||||||
| {% endif %}
|
class="{% if title == 'assets' %}active{% endif %}") Assets
|
||||||
| {% if project.nodes_featured %}
|
| {% if project.nodes_featured %}
|
||||||
li
|
| {# In some cases featured_nodes might might be embedded #}
|
||||||
a.navbar-item(
|
| {% if '_id' in project.nodes_featured[0] %}
|
||||||
href="{{ url_for('projects.view_node', project_url=project.url, node_id=project.nodes_featured[0]) }}",
|
| {% set featured_node_id=project.nodes_featured[0]._id %}
|
||||||
title="Explore {{ project.name }}",
|
| {% else %}
|
||||||
class="{% if category == 'blog' %}active{% endif %}")
|
| {% set featured_node_id=project.nodes_featured[0] %}
|
||||||
span Explore
|
| {% endif %}
|
||||||
| {% 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 %}
|
| {% 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')
|
oauth_provider = OAuthSignIn.get_provider('blender-id')
|
||||||
self.assertIsInstance(oauth_provider, BlenderIdSignIn)
|
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):
|
def test_provider_not_implemented(self):
|
||||||
from pillar.auth.oauth import OAuthSignIn, ProviderNotImplemented
|
from pillar.auth.oauth import OAuthSignIn, ProviderNotImplemented
|
||||||
@@ -46,11 +46,11 @@ class OAuthTests(AbstractPillarTest):
|
|||||||
def test_provider_callback_happy(self):
|
def test_provider_callback_happy(self):
|
||||||
from pillar.auth.oauth import OAuthSignIn
|
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'},
|
json={'access_token': 'successful-token'},
|
||||||
status=200)
|
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',
|
json={'id': '7',
|
||||||
'email': 'harry@blender.org'},
|
'email': 'harry@blender.org'},
|
||||||
status=200)
|
status=200)
|
||||||
|
@@ -184,3 +184,31 @@ class NodeSetattrTest(unittest.TestCase):
|
|||||||
|
|
||||||
node_setattr(node, 'b.complex', {None: 5})
|
node_setattr(node, 'b.complex', {None: 5})
|
||||||
self.assertEqual({'b': {'complex': {None: 5}}}, node)
|
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 ü="é"}'))
|
self.assertEqual('<dl><dt>test</dt><dt>ü</dt><dd>é</dd></dl>', render('{test ü="é"}'))
|
||||||
|
|
||||||
|
|
||||||
class YouTubeTest(unittest.TestCase):
|
class YouTubeTest(AbstractPillarTest):
|
||||||
def test_missing(self):
|
def test_missing(self):
|
||||||
from pillar.shortcodes import render
|
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"}')
|
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):
|
class IFrameTest(AbstractPillarTest):
|
||||||
def test_missing_cap(self):
|
def test_missing_cap(self):
|
||||||
|
@@ -11,8 +11,8 @@ class MarkdownTest(unittest.TestCase):
|
|||||||
def test_bleached(self):
|
def test_bleached(self):
|
||||||
from pillar.web import jinja
|
from pillar.web import jinja
|
||||||
|
|
||||||
self.assertEqual('<script>alert("hey");<script>',
|
self.assertEqual('<script>alert("hey");</script>',
|
||||||
jinja.do_markdown('<script>alert("hey");<script>').strip())
|
jinja.do_markdown('<script>alert("hey");</script>').strip())
|
||||||
|
|
||||||
def test_degenerate(self):
|
def test_degenerate(self):
|
||||||
from pillar.web import jinja
|
from pillar.web import jinja
|
||||||
|
Reference in New Issue
Block a user