@ -47,7 +47,6 @@ def cors(app):
return handle
return handle
def authenticate ( f ) :
def authenticate ( f ) :
""" Authenticate a token against the database.
""" Authenticate a token against the database.
@ -82,15 +81,16 @@ def authenticate(f):
return f ( * args , editor = email , * * kwargs )
return f ( * args , editor = email , * * kwargs )
return auth_wrapper
return auth_wrapper
@app.route ( ' /thrimshim/auth-test ' , methods = [ ' POST ' ] )
@app.route ( ' /thrimshim/auth-test ' , methods = [ ' POST ' ] )
@request_stats
@request_stats
@authenticate
@authenticate
def test ( editor = None ) :
def test ( editor = None ) :
return json . dumps ( editor )
return json . dumps ( editor )
# To make nginx proxying simpler, we want to allow /metrics/* to work
# To make nginx proxying simpler, we want to allow /metrics/* to work
@app.route ( ' /metrics/<trailing> ' )
@app.route ( ' /metrics/<trailing> ' )
@request_stats
@request_stats
@ -98,12 +98,14 @@ def metrics_with_trailing(trailing):
""" Expose Prometheus metrics. """
""" Expose Prometheus metrics. """
return prometheus_client . generate_latest ( )
return prometheus_client . generate_latest ( )
@app.route ( ' /metrics ' )
@app.route ( ' /metrics ' )
@request_stats
@request_stats
def metrics ( ) :
def metrics ( ) :
""" Expose Prometheus metrics. """
""" Expose Prometheus metrics. """
return prometheus_client . generate_latest ( )
return prometheus_client . generate_latest ( )
@app.route ( ' /thrimshim ' )
@app.route ( ' /thrimshim ' )
@request_stats
@request_stats
def get_all_rows ( ) :
def get_all_rows ( ) :
@ -112,7 +114,8 @@ def get_all_rows():
results = database . query ( conn , """
results = database . query ( conn , """
SELECT *
SELECT *
FROM events
FROM events
ORDER BY event_start """ )
ORDER BY event_start
""" )
rows = [ ]
rows = [ ]
for row in results :
for row in results :
row = row . _asdict ( )
row = row . _asdict ( )
@ -149,7 +152,8 @@ def get_row(ident):
results = database . query ( conn , """
results = database . query ( conn , """
SELECT *
SELECT *
FROM events
FROM events
WHERE id = % s """ , ident)
WHERE id = % s
""" , ident)
row = results . fetchone ( )
row = results . fetchone ( )
if row is None :
if row is None :
return ' Row id = {} not found ' . format ( ident ) , 404
return ' Row id = {} not found ' . format ( ident ) , 404
@ -187,30 +191,33 @@ def get_row(ident):
logging . info ( ' Row {} fetched ' . format ( ident ) )
logging . info ( ' Row {} fetched ' . format ( ident ) )
return json . dumps ( response )
return json . dumps ( response )
@app.route ( ' /thrimshim/<uuid:ident> ' , methods = [ ' POST ' ] )
@app.route ( ' /thrimshim/<uuid:ident> ' , methods = [ ' POST ' ] )
@request_stats
@request_stats
@authenticate
@authenticate
def update_row ( ident , editor = None ) :
def update_row ( ident , editor = None ) :
""" Updates row of database with id = ident with the edit columns in new_row. """
new_row = flask . request . json
new_row = flask . request . json
override_changes = new_row . get ( ' override_changes ' , False )
override_changes = new_row . get ( ' override_changes ' , False )
""" Updates row of database with id = ident with the edit columns in
new_row . """
state_columns = [ ' state ' , ' uploader ' , ' error ' , ' video_link ' ]
state_columns = [ ' state ' , ' uploader ' , ' error ' , ' video_link ' ]
#these have to be set before a video can be set as 'EDITED'
# These have to be set before a video can be set as 'EDITED'
non_null_columns = [ ' upload_location ' , ' video_start ' , ' video_end ' ,
non_null_columns = [
' video_channel ' , ' video_quality ' , ' video_title ' , ' video_description ' , ' video_tags ' ]
' upload_location ' , ' video_start ' , ' video_end ' ,
' video_channel ' , ' video_quality ' , ' video_title ' ,
' video_description ' , ' video_tags ' ,
]
edit_columns = non_null_columns + [ ' allow_holes ' , ' uploader_whitelist ' ]
edit_columns = non_null_columns + [ ' allow_holes ' , ' uploader_whitelist ' ]
sheet_columns = [
sheet_columns = [
' sheet_name ' , ' event_start ' , ' event_end ' , ' category ' , ' description ' , ' notes ' , ' tags '
' sheet_name ' , ' event_start ' , ' event_end ' ,
]
' category ' , ' description ' , ' notes ' , ' tags ' ,
]
# c heck vital edit columns are in new_row
# C heck vital edit columns are in new_row
wanted = set ( non_null_columns + [ ' state ' ] + sheet_columns )
wanted = set ( non_null_columns + [ ' state ' ] + sheet_columns )
missing = wanted - set ( new_row )
missing = wanted - set ( new_row )
if missing :
if missing :
return ' Fields missing in JSON: {} ' . format ( ' , ' . join ( missing ) ) , 400
return ' Fields missing in JSON: {} ' . format ( ' , ' . join ( missing ) ) , 400
# g et rid of irrelevant columns
# G et rid of irrelevant columns
extras = set ( new_row ) - set ( edit_columns + state_columns + sheet_columns )
extras = set ( new_row ) - set ( edit_columns + state_columns + sheet_columns )
for extra in extras :
for extra in extras :
del new_row [ extra ]
del new_row [ extra ]
@ -221,15 +228,15 @@ def update_row(ident, editor=None):
if ' video_description ' in new_row :
if ' video_description ' in new_row :
new_row [ ' video_description ' ] + = app . description_footer
new_row [ ' video_description ' ] + = app . description_footer
# v alidate title length
# V alidate title length
if len ( new_row [ ' video_title ' ] ) > MAX_TITLE_LENGTH :
if len ( new_row [ ' video_title ' ] ) > MAX_TITLE_LENGTH :
return ' Title must be {} characters or less, including prefix ' . format ( MAX_TITLE_LENGTH ) , 400
return ' Title must be {} characters or less, including prefix ' . format ( MAX_TITLE_LENGTH ) , 400
# v alidate start time is less than end time
# V alidate start time is less than end time
if new_row [ ' video_start ' ] > new_row [ ' video_end ' ] :
if new_row [ ' video_start ' ] > new_row [ ' video_end ' ] :
return ' Video Start must be less than Video End. ' , 400
return ' Video Start must be less than Video End. ' , 400
conn = app . db_manager . get_conn ( )
conn = app . db_manager . get_conn ( )
# c heck a row with id = ident is in the database
# C heck a row with id = ident is in the database
built_query = sql . SQL ( """
built_query = sql . SQL ( """
SELECT id , state , { }
SELECT id , state , { }
FROM events
FROM events
@ -299,6 +306,7 @@ def update_row(ident, editor=None):
logging . info ( ' Row {} updated to state {} ' . format ( ident , new_row [ ' state ' ] ) )
logging . info ( ' Row {} updated to state {} ' . format ( ident , new_row [ ' state ' ] ) )
return ' '
return ' '
@app.route ( ' /thrimshim/manual-link/<uuid:ident> ' , methods = [ ' POST ' ] )
@app.route ( ' /thrimshim/manual-link/<uuid:ident> ' , methods = [ ' POST ' ] )
@request_stats
@request_stats
@authenticate
@authenticate
@ -308,7 +316,6 @@ def manual_link(ident, editor=None):
link = flask . request . json [ ' link ' ]
link = flask . request . json [ ' link ' ]
upload_location = flask . request . json . get ( ' upload_location ' , ' manual ' )
upload_location = flask . request . json . get ( ' upload_location ' , ' manual ' )
if upload_location == ' youtube-manual ' :
if upload_location == ' youtube-manual ' :
YOUTUBE_URL_RE = r ' ^https?://(?:youtu \ .be/|youtube.com/watch \ ?v=)([a-zA-Z0-9_-] {11} )$ '
YOUTUBE_URL_RE = r ' ^https?://(?:youtu \ .be/|youtube.com/watch \ ?v=)([a-zA-Z0-9_-] {11} )$ '
match = re . match ( YOUTUBE_URL_RE , link )
match = re . match ( YOUTUBE_URL_RE , link )
@ -356,9 +363,9 @@ def reset_row(ident, editor=None):
UPDATE events
UPDATE events
SET state = ' UNEDITED ' , error = NULL , video_id = NULL , video_link = NULL ,
SET state = ' UNEDITED ' , error = NULL , video_id = NULL , video_link = NULL ,
uploader = NULL , editor = NULL , edit_time = NULL , upload_time = NULL
uploader = NULL , editor = NULL , edit_time = NULL , upload_time = NULL
WHERE id = % s { }
WHERE id = % s { }
""" .format(
""" .format(
" " if force else " AND state IN (' UNEDITED ' , ' EDITED ' , ' CLAIMED ' ) " ,
" " if force else " AND state IN (' UNEDITED ' , ' EDITED ' , ' CLAIMED ' ) " ,
)
)
results = database . query ( conn , query , ident )
results = database . query ( conn , query , ident )
if results . rowcount != 1 :
if results . rowcount != 1 :
@ -370,16 +377,17 @@ def reset_row(ident, editor=None):
@argh.arg ( ' --host ' , help = ' Address or socket server will listen to. Default is 0.0.0.0 (everything on the local machine). ' )
@argh.arg ( ' --host ' , help = ' Address or socket server will listen to. Default is 0.0.0.0 (everything on the local machine). ' )
@argh.arg ( ' --port ' , help = ' Port server will listen on. Default is 8004. ' )
@argh.arg ( ' --port ' , help = ' Port server will listen on. Default is 8004. ' )
@argh.arg ( ' connection-string ' , help = ' Postgres connection string, which is either a space-separated list of key=value pairs, or a URI like: postgresql://USER:PASSWORD@HOST/DBNAME?KEY=VALUE ' )
@argh.arg ( ' connection-string ' , help = ' Postgres connection string, which is either a space-separated list of key=value pairs, or a URI like: postgresql://USER:PASSWORD@HOST/DBNAME?KEY=VALUE ' )
@argh.arg ( ' default-channel ' , help = ' The default channel this instance will serve events for ' )
@argh.arg ( ' default-channel ' , help = ' The default video_channel sent to the editor and assumed if not given on write ' )
@argh.arg ( ' bustime-start ' , help = ' The start time in UTC for the event, for UTC-Bustime conversion ' )
@argh.arg ( ' bustime-start ' , help = ' The start time in UTC for the event, for UTC-Bustime conversion ' )
@argh.arg ( ' --backdoor-port ' , help = ' Port for gevent.backdoor access. By default disabled. ' )
@argh.arg ( ' --backdoor-port ' , help = ' Port for gevent.backdoor access. By default disabled. ' )
@argh.arg ( ' --no-authentication ' , help = ' Do not authenticate ' )
@argh.arg ( ' --no-authentication ' , help = ' Bypass authentication (act as though all calls are authenticated) ' )
@argh.arg ( ' --title-header ' , help = ' A header to prefix all titles with, seperated from the submitted title by " - " ' )
@argh.arg ( ' --title-header ' , help = ' A header to prefix all titles with, seperated from the submitted title by " - " ' )
@argh.arg ( ' --description-footer ' , help = ' A footer to suffix all descriptions with, seperated from the submitted description by a blank line. ' )
@argh.arg ( ' --description-footer ' , help = ' A footer to suffix all descriptions with, seperated from the submitted description by a blank line. ' )
@argh.arg ( ' --upload-locations ' , help = ' A comma-seperated list of valid upload locations, to pass to thrimbletrimmer. The first is the default. Note this is NOT validated on write. ' )
@argh.arg ( ' --upload-locations ' , help = ' A comma-seperated list of valid upload locations, to pass to thrimbletrimmer. The first is the default. Note this is NOT validated on write. ' )
def main ( connection_string , default_channel , bustime_start , host = ' 0.0.0.0 ' , port = 8004 , backdoor_port = 0 ,
def main (
no_authentication = False , title_header = None , description_footer = None , upload_locations = ' ' ) :
connection_string , default_channel , bustime_start , host = ' 0.0.0.0 ' , port = 8004 , backdoor_port = 0 ,
""" Thrimshim service. """
no_authentication = False , title_header = None , description_footer = None , upload_locations = ' ' ,
) :
server = WSGIServer ( ( host , port ) , cors ( app ) )
server = WSGIServer ( ( host , port ) , cors ( app ) )
app . no_authentication = no_authentication
app . no_authentication = no_authentication