forked from pyushkevich/alfabis_server
-
Notifications
You must be signed in to change notification settings - Fork 0
/
app.py
executable file
·2174 lines (1589 loc) · 68.7 KB
/
app.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/bin/env python
import web,sys
import markdown
import json
import os
import shutil
import hashlib
import csv
import StringIO
import glob
import uuid
import mimetypes
import httplib2
import time
import datetime
import argparse
import tempfile
import traceback
from pprint import pprint
from os import walk
from os.path import basename
from oauth2client import client
from apiclient.discovery import build
from git import Repo
from git import Git
# Needed for session support
web.config.debug = False
# Session support
web.config.session_parameters['cookie_name'] = 'webpy_session_id'
web.config.session_parameters['cookie_domain'] = 'dss.itksnap.org'
web.config.session_parameters['timeout'] = 31536000
web.config.session_parameters['ignore_expiry'] = True
web.config.session_parameters['ignore_change_ip'] = True
web.config.session_parameters['secret_key'] = 'Hdx1ym849Zj5Dg3gB8A0'
web.config.session_parameters['expired_message'] = 'Session expired'
# URL mapping
urls = (
r'/', 'index',
r"/token", "TokenRequest",
r"/acceptterms", "AcceptTermsRequest",
r"/logout", "LogoutPage",
r"/services", "ServicesPage",
r"/admin", "AdminPage",
r"/admintickets", "AdminTicketsPage",
r"/adminservices", "AdminServicesPage",
r"/admin/tickets/(\d+)/detail", "AdminTicketsDetailPage",
r"/about", "AboutPage",
r"/api/login", "LoginAPI",
r"/api/oauth2cb", "OAuthCallbackAPI",
r"/api/token", "TokenAPI",
r"/api/services", "ServicesAPI",
r"/api/services/([a-f0-9]+)/detail", "ServicesDetailAPI",
r"/api/services/([a-f0-9]+)/stats", "ServicesStatsAPI",
r"/api/tickets", "TicketsAPI",
r"/api/tickets/(\d+)/files/(input|results)", "TicketFilesAPI",
r"/api/tickets/(\d+)/files/(input|results)/(\d+)", "TicketFileDownloadAPI",
r"/api/tickets/(\d+)/status", "TicketStatusAPI",
r"/api/tickets/(\d+)/log", "TicketLogAPI",
r"/api/tickets/(\d+)/progress", "TicketProgressAPI",
r"/api/tickets/(\d+)/queuepos", "TicketQueuePositionAPI",
r"/api/tickets/(\d+)/detail", "TicketDetailAPI",
r"/api/tickets/(\d+)/delete", "TicketDeleteAPI",
r"/api/tickets/(\d+)/retry", "TicketRetryAPI",
r"/api/tickets/logs/(\d+)/attachments", "TicketLogAttachmentAPI",
r"/api/pro/services", "ProviderServicesAPI",
r"/api/pro/services/([\w\-]+)/tickets", "ProviderServiceTicketsAPI",
r"/api/pro/services/([a-f0-9]+)/claims", "ProviderServiceClaimsAPI",
r"/api/pro/services/claims", "ProviderMultipleServiceClaimsAPI",
r"/api/pro/tickets/(\d+)/files/(input|results)", "ProviderTicketFilesAPI",
r"/api/pro/tickets/(\d+)/files/(input|results)/(\d+)", "ProviderTicketFileDownloadAPI",
r"/api/pro/tickets/(\d+)/status", "ProviderTicketStatusAPI",
r"/api/pro/tickets/(\d+)/(error|warning|info|log)", "ProviderTicketLogAPI",
r"/api/pro/tickets/(\d+)/attachments", "ProviderTicketAttachmentAPI",
r"/api/pro/tickets/(\d+)/progress", "ProviderTicketProgressAPI",
r"/api/admin/providers","AdminProvidersAPI",
r"/api/admin/providers/([\w\-]+)/delete","AdminProviderDeleteAPI",
r"/api/admin/providers/([\w\-]+)/users","AdminProviderUsersAPI",
r"/api/admin/providers/([\w\-]+)/users/(\d+)/delete","AdminProviderUsersDeleteAPI",
r"/api/admin/providers/([\w\-]+)/services","AdminProviderServicesAPI",
r"/api/admin/providers/([\w\-]+)/services/([a-f0-9]+)/delete","AdminProviderServicesDeleteAPI",
r"/api/admin/tickets/purge/(completed|all)","AdminPurgeTicketsAPI",
r"/api/admin/tickets","AdminTicketsAPI",
r"/blobs/([a-f0-9]{8})", "DirectDownloadAPI",
r"/blobs/([a-f0-9]{32})", "DirectDownloadAPI"
)
# Create the web app
app = web.application(urls, globals())
# Connect to the database
db = web.database(
host=os.environ['POSTGRES_PORT_5432_TCP_ADDR'],
port=os.environ['POSTGRES_PORT_5432_TCP_PORT'],
dbn='postgres',
db=os.environ['ALFABIS_DATABASE_NAME'],
user=os.environ['ALFABIS_DATABASE_USERNAME'],
pw=os.environ['ALFABIS_DATABASE_PASSWORD'])
# Configure the session. By default, the session is initialized with nothing
# but in no-auth mode, the session should be initialized as logged in with
# user set
if "ALFABIS_NOAUTH" not in os.environ:
# Blank session
sess = web.session.Session(
app, web.session.DBStore(db, 'sessions'),
initializer={'loggedin': False, 'acceptterms': False, 'is_admin': False})
else:
# Make sure user exists, if not populate in the user table
new_token=os.urandom(24).encode('hex')
db.query(
"insert into users values(DEFAULT,'[email protected]',$new_token,"
" 'Test User', TRUE, 'poweruser') "
"on conflict do nothing", vars=locals())
# Get the user id of the test user
user_id = db.select('users', where="email='[email protected]'")[0].id;
# Prepopulated session
sess = web.session.Session(
app, web.session.DBStore(db, 'sessions'),
initializer={'loggedin': True, 'acceptterms': True, 'is_admin': True,
'email' : '[email protected]', 'user_id' : user_id})
# Configure the template renderer with session support
render = web.template.render(
'temp/',
globals={'markdown': markdown.markdown, 'session': sess},
cache=False);
# Configure the markdown to HTML converter (do we need this really? Why not HTML5?)
md = markdown.Markdown(output_format='html4',
extensions = ['markdown.extensions.meta',
'markdown.extensions.tables'])
# A function to render a markdown template with parameters
def render_markdown(page, *args):
# Render the page requested into a string
text = getattr(render, page)(*args);
# A context dict for menu rendering
ctx = {}
ctx['admin'] = web.ctx.path.startswith('/admin')
ctx['path'] = web.ctx.path
# Render the full page
return render.style(md.convert(unicode(text)), ctx);
# Render markdown page without menus
def render_markdown_nomenus(page, *args):
# Render the page requested into a string
text = getattr(render, page)(*args);
# A context dict for menu rendering
ctx = {}
ctx['admin'] = web.ctx.path.startswith('/admin')
ctx['path'] = web.ctx.path
# Render the full page
return render.bare(md.convert(unicode(text)), ctx);
# Determine mime type from filename or return octet stream
def guess_mimetype(filename):
if mimetypes.inited is False:
mimetypes.init()
mime_type = mimetypes.guess_type(filename)[0]
if mime_type is None:
mime_type="application/octet-stream"
return mime_type
# This class facilitates working with the Google OAuth2 API
class OAuthHelper:
def __init__(self):
self.flow = client.flow_from_clientsecrets(
os.environ['ALFABIS_GOOGLE_CLIENTSECRET'],
scope=[
'https://www.googleapis.com/auth/userinfo.email',
'https://www.googleapis.com/auth/userinfo.profile'],
redirect_uri=web.ctx.home+"/api/oauth2cb")
def auth_url(self):
return self.flow.step1_get_authorize_url()
def authorize(self, auth_code):
# Obtain credentials from the auth code
self.credentials = self.flow.step2_exchange(auth_code)
# Get use information from Google
self.http_auth = self.credentials.authorize(httplib2.Http())
user_info_service = build('oauth2','v2',http=self.http_auth)
user_info = user_info_service.userinfo().get().execute()
return user_info
# Single function to check whether the user is logged in
def is_logged_in():
return sess.loggedin
def is_logged_in_as_admin():
return sess.loggedin and sess.is_admin
# Print session contents
def print_session():
print("*** Session Info ***")
print(" sess.loggedin = %d" % sess.loggedin)
print(" sess.is_admin = %d" % sess.is_admin)
print(" sess.email = %s" % sess.email)
print(" sess.acceptterms= %d" % sess.acceptterms)
# Home Page handler
class index:
def GET(self):
# Create the URL for authorization
auth_url=None
if is_logged_in() == False:
auth_url = OAuthHelper().auth_url()
# The redirect URL for after authentication
sess.return_uri=web.ctx.home
return render_markdown("hohum", False, None, auth_url)
# Handler for accepting terms of service
class AcceptTermsRequest:
def POST(self):
if 'cb' in web.input() and web.input().cb == 'on':
sess.acceptterms = True
raise web.seeother("/token")
else:
sess.acceptterms = False
raise web.seeother("/logout")
# Token request handler
class TokenRequest:
def GET(self):
# Create the URL for authorization
auth_url=None
token=None
if is_logged_in() == False:
auth_url = OAuthHelper().auth_url()
else:
email = sess.email
res = db.select('users', where="email=$email", vars=locals())
token=res[0].passwd
# Get the user to accept the terms of service
sess.return_uri=web.ctx.home + "/token"
return render_markdown("token", auth_url, token)
# API-based token request: only works if you are already logged in
class TokenAPI:
def GET(self):
# Must be logged in and accepted terms, otherwise you have to use the web page
if is_logged_in() is False or sess.acceptterms is False:
raise web.HTTPError("401 unauthorized", {}, "Unauthorized access")
# Retrieve the token
email = sess.email
res = db.select('users', where="email=$email", vars=locals())
token=res[0].passwd
# Return token
return token
class LogoutPage:
def GET(self):
sess.kill()
raise web.seeother('/')
class ServicesPage:
def GET(self):
# We must be logged in, but not much else
if is_logged_in() is not True:
raise web.seeother("/")
web.header('Cache-Control','no-cache, no-store, must-revalidate')
web.header('Pragma','no-cache')
web.header('Expires', 0)
# Get a listing of services with details
services = db.query(
"select name, shortdesc, githash, json, now() - pingtime as since, "
" D.avg as avg_runtime, n_success, n_failed, "
" greatest(0,W.count) as queue_length "
"from services S "
" left join ( "
" select service_githash, avg(runtime) "
" from success_ticket_duration "
" where now() - endtime < interval '1 day' "
" group by service_githash "
" ) D on S.githash = D.service_githash "
" left join ( "
" select service_githash, "
" greatest(0,sum(cast (TH.status = 'success' as int))) as n_success, "
" greatest(0,sum(cast (TH.status in ( 'failed', 'timeout') as int))) as n_failed "
" from ticket_history TH, tickets T "
" where TH.ticket_id = T.id and now() - atime < interval '1 day' "
" group by service_githash "
" ) Q on Q.service_githash = githash "
" left join ( "
" select service_githash, count(id) "
" from tickets where status='ready' group by service_githash "
" ) W on W.service_githash = githash "
"where S.current = true "
"order by n_success desc");
# Parse through the results into something more readable
serv_data = []
for x in services:
s = {}
j = json.loads(x.json)
s['name'] = x.name
s['shortdesc'] = x.shortdesc;
s['longdesc'] = j['longdesc'];
s['url'] = j['url'];
alive_sec = x.since.total_seconds();
s['alive_btn'] = 'green' if alive_sec < 600 else ('yellow' if alive_sec < 3600 else 'red')
s['alive_min'] = round(alive_sec / 60,2)
n_s = x.n_success if x.n_success is not None else 0
n_f = x.n_failed if x.n_failed is not None else 0
if n_s + n_f == 0:
s['srate_btn'] = "cyan"
s['srate_tooltip'] = "No tickets completed in the last 24 hours"
else:
err_rate = n_f * 1.0 / (n_s + n_f)
s['srate_btn'] = "green" if err_rate < 0.1 else ("yellow" if err_rate < 0.2 else "red")
s['srate_tooltip'] = "Success: %d, Failed: %d, Error Rate: %f" % (n_s, n_f, err_rate)
ql = x.queue_length
s['queuelen_btn'] = "cyan" if ql == 0 else ("green" if ql < 5 else ("yellow" if ql < 25 else "red"))
s['queuelen'] = x.queue_length
if x.avg_runtime is not None:
s['runtime'] = "<br>%4.1f min" % (x.avg_runtime.total_seconds() / 60.0)
else:
s['runtime'] = ""
serv_data.append(s)
return render_markdown("services_home", serv_data)
class AdminPage:
def GET(self):
# We must be logged in, but not much else
if is_logged_in_as_admin() is not True:
raise web.HTTPError("401 unauthorized", {}, "Unauthorized access")
return render_markdown("admin")
class AdminTicketsPage:
def format_date(self, dt):
n = datetime.datetime.now()
if n.year == dt.year:
if n.day == dt.day:
return dt.strftime("%H:%M");
return dt.strftime("%b %d %H:%M");
return dt.strftime("%b %d %Y %H:%M");
def format_delta(self, dt1, dt2):
if dt1 is None or dt2 is None:
return None
delta = dt2 - dt1
h = delta.seconds / 3600
m = (delta.seconds / 60) % 60
s = delta.seconds % 60
if h > 0:
return "%02d:%02d:%02d" % (h,m,s)
else:
return "%02d:%02d" % (m,s)
def GET(self):
# We must be logged in, but not much else
if is_logged_in_as_admin() is not True:
raise web.HTTPError("401 unauthorized", {}, "Unauthorized access")
# Get the listing of tickets, this is a hell of a query
q = db.query(
"select T.id, S.name, T.status, email, dispname, "
" Tinit, Tclaimed, Tsuccess, Tfailed, Ttimeout, Tdeleted, progress "
"from tickets T "
" left join users U on T.user_id = U.id "
" left join services S on T.service_githash = S.githash "
" left join (select ticket_id, "
" max(case when status='init' then atime else NULL end) as Tinit, "
" max(case when status='claimed' then atime else NULL end) as Tclaimed, "
" max(case when status='success' then atime else NULL end) as Tsuccess, "
" max(case when status='failed' then atime else NULL end) as Tfailed, "
" max(case when status='timeout' then atime else NULL end) as Ttimeout, "
" max(case when status='deleted' then atime else NULL end) as Tdeleted "
" from ticket_history group by ticket_id) TH on TH.ticket_id = T.id "
" left join (select ticket_id, sum(progress * (chunk_end - chunk_start)) as progress "
" from ticket_progress group by ticket_id) P on P.ticket_id = T.id "
"order by T.id desc limit 100")
# Send this query to the web page
tix_data=[]
for row in q:
# Orgaize the data for this ticket
T = {}
print row
T['id'] = row.id
T['service'] = row.name
T['status'] = row.status
# If the status is 'deleted', show the last useful status
if row.status == 'deleted':
T['deleted'] = True
T['status'] = 'failed' if row.tfailed is not None else (
'timeout' if row.ttimeout is not None else (
'success' if row.tsuccess is not None else 'aborted'))
else:
T['deleted'] = False
T['status'] = row.status
T['status_color'] = 'darkred' if T['status'] in ('failed','timeout') else (
'darkgreen' if T['status'] == 'success' else (
'goldenrod' if T['status'] == 'aborted' else 'gray'))
T['email'] = row.email
T['dispname'] = row.dispname
if len(T['dispname']) == 0:
T['dispname'] = T['email']
T['T_init'] = self.format_date(row.tinit)
T['T_claim'] = self.format_delta(row.tinit, row.tclaimed)
# Figure out the end date
t_end = datetime.datetime.now();
for t_test in (row.tsuccess, row.tfailed, row.ttimeout, row.tdeleted):
if t_test is not None and t_test < t_end:
t_end = t_test
T['T_end'] = self.format_delta(row.tclaimed, t_end)
T['progress'] = row.progress
tix_data.append(T)
return render_markdown("admin_tickets", tix_data)
class AdminTicketsDetailPage:
def GET(self, ticket_id):
# We must be logged in, but not much else
if is_logged_in_as_admin() is not True:
raise web.HTTPError("401 unauthorized", {}, "Unauthorized access")
# Get the detail using the API
qr = TicketLogic(ticket_id).get_detail()
# Get formatted JSON
ticket_json = json.dumps(qr, default=my_json_converter, indent=4)
# Render the page
return render_markdown_nomenus("admin_ticket_detail", ticket_id, ticket_json)
class AdminServicesPage:
def GET(self):
# We must be logged in, but not much else
if is_logged_in_as_admin() is not True:
raise web.HTTPError("401 unauthorized", {}, "Unauthorized access")
# Query the list of providers
r_prov = db.select('providers', where='current is true', vars=locals())
# Create a dictionary of providers
pd = {}
# Go through query filling out provider info
for row_prov in r_prov:
# The name of the current provider
p_id = row_prov.name
# Dictionary for this provider
pd[p_id] = {}
# Query the list of services for this provider
r_serv = db.query(
"select A.name, A.version, A.githash from services A, provider_services B "
"where A.githash = B.service_githash and B.current is TRUE and A.current is TRUE "
" and B.provider_name = $p_id "
"order by A.name, A.version", vars = locals())
pd[p_id]['services'] = query_as_array_of_dict(r_serv, ['name','version','githash'])
# Query the list of users for this provider
r_users = db.query(
"select email,id,dispname,admin from users A, provider_access B "
"where A.id = B.user_id and B.provider=$p_id "
"order by email", vars=locals())
pd[p_id]['users'] = query_as_array_of_dict(r_users, ['email', 'id', 'dispname', 'admin'])
return render_markdown("admin_services", pd)
class AboutPage:
def GET(self):
return render_markdown("about")
# ======================
# Business Logic classes
# ======================
# Logic around tickets. This class is initialized around the ticket ID
class TicketLogic:
def __init__(self, ticket_id):
self.ticket_id = ticket_id
def check_consumer_access(self, user_id, status_list = None):
res = db.select("tickets",where="user_id=$user_id and id=$self.ticket_id",vars=locals())
return len(res) > 0 and (status_list is None or res[0].status in status_list)
def check_provider_access(self, provider_id, status_list = None):
res = db.query(
"select T.id, T.status from tickets T, provider_access PA, provider_services PS "
"where T.id = $self.ticket_id and PA.user_id = $provider_id "
" and T.service_githash = PS.service_githash and PA.provider = PS.provider_name",
vars=locals())
return len(res) > 0 and (status_list is None or res[0].status in status_list)
def is_not_deleted(self):
res = db.select("tickets",where="id=$self.ticket_id", vars=locals())
return len(res) > 0 and (res[0].status != 'deleted')
# Check that the specified provider has actually claimed this ticket
def check_provider_claimed(self, user_id):
res = db.select("claim_history",
where="ticket_id=$self.ticket_id and puser_id=$user_id",
vars=locals());
return len(res) > 0
# Set the status of the ticket. This sets the status in the 'tickets' table but
# also logs the status change in the 'ticket_history' table
def set_status(self, new_status):
with db.transaction():
# Set the status of the ticket in the database
db.update("tickets", where="id = $self.ticket_id", status = new_status, vars=locals())
# Insert the history entry
db.insert("ticket_history", ticket_id = self.ticket_id, status = new_status);
# Return the new status
return db.select("tickets", where="id=$self.ticket_id", vars=locals())[0].status;
# Add a message to the ticket log
def append_log(self, category, message):
log_id = None
# This requires a transaction
with db.transaction():
# Create a log entry for this ticket
log_id = db.insert("ticket_log",
ticket_id=self.ticket_id, category=category, message=message)
# Assign all unfiled attachments to this log message
db.query(
"insert into ticket_log_attachment "
" select $log_id,A.id from ticket_attachment as A "
" left join ticket_log_attachment as B on A.id = B.attachment_id "
" where ticket_id = $self.ticket_id and B.log_id is null",
vars=locals())
# Find all the unassigned attachments for this ticket
return log_id
# Query logs - returns a database result
def get_logs(self, start_id):
return db.query(
"select L.*,count(B.attachment_id) as attachments "
" from ticket_log L left join ticket_log_attachment B on L.id = B.log_id "
" where ticket_id = $self.ticket_id and id > $start_id group by L.id,log_id"
" order by atime",
vars=locals());
# Measure the total progress for a ticket
def total_progress(self):
res = db.query(
"select greatest(0, sum((chunk_end-chunk_start) * progress)) as x from ticket_progress "
"where ticket_id=$self.ticket_id",
vars=locals())
if len(res) > 0:
return float(res[0].x)
else:
return 0
# Measure the total progress for a ticket
def queue_position(self):
res = db.query(
"select count(id) as x from tickets "
"where service_githash = (select service_githash from tickets where id = $self.ticket_id) "
" and status='ready' "
" and id <= $self.ticket_id",
vars=locals())
if len(res) > 0:
return res[0].x
# Update the progress of a ticket for a chunk
def set_chunk_progress(self, chunk_start, chunk_end, progress):
with db.transaction():
# Update the ping on this service
db.query(
"update services as S set pingtime=now() "
"from tickets as T "
"where S.githash = T.service_githash and T.id = $self.ticket_id", vars=locals());
# If progress value inside of the chunk is zero, this triggers an update where all values
# inside of the chunk are deleted, and no further action is taken
if float(progress) == 0.0:
nd = db.delete("ticket_progress",
where="ticket_id = $self.ticket_id and chunk_start >= $chunk_start and chunk_end <= $chunk_end",
vars=locals())
else:
# Try to update the progress line
n = db.update("ticket_progress",
where="ticket_id = $self.ticket_id and chunk_start=$chunk_start and chunk_end=$chunk_end",
progress = progress, vars=locals())
# If nothing got updated, this means we need to insert
if n == 0:
db.insert("ticket_progress",
ticket_id=self.ticket_id, chunk_start=chunk_start,
chunk_end=chunk_end, progress=progress)
# Get the file directory for given area
def get_filedir(self, area):
filedir = 'datastore/tickets/%08d/%s' % (int(self.ticket_id), area)
if not os.path.exists(filedir):
os.makedirs(filedir)
return filedir
# List all the files associated with ticket in a given area
def list_files(self, area):
# List all of the files
filedir = self.get_filedir(area)
# Send the directory contents as CSV
return directory_as_csv(filedir)
# Erase the ticket file directory
def erase_dir(self, area):
filedir = 'datastore/tickets/%08d/%s' % (int(self.ticket_id), area)
if os.path.exists(filedir):
shutil.rmtree(filedir)
# Erase the attachments for a ticket
def erase_attachments(self):
filedir = 'datastore/attachments/%08d' % int(self.ticket_id)
if os.path.exists(filedir):
shutil.rmtree(filedir)
# Receive uploaded files associated with a ticket and area
def receive_file(self, area, fileobj):
# Get the directory to store this in
filedir = self.get_filedir(area)
filepath=fileobj.filename.replace('\\','/') # replaces the windows-style slashes with linux ones.
filename=filepath.split('/')[-1] # splits the and chooses the last part (the filename with extension)
fout = open(filedir +'/'+ filename,'w') # creates the file where the uploaded file should be stored
fout.write(fileobj.file.read()) # writes the uploaded file to the newly created file.
fout.close() # closes the file, upload complete.
# Return the local path to file
return filename
# Serve file from given area
def get_nth_file(self, area, file_index):
# Get the ticket directory
filedir = self.get_filedir(area)
# Get the specified file
filename = get_indexed_file(filedir, int(file_index))
if filename is None:
return self.raise_badrequest("File %s does not exist for ticket %d" % file_index,self.ticket_id)
return filename
# Delete the ticket
def delete_ticket(self):
# Mark the ticket as having been deleted
new_status = self.set_status("deleted")
# Empty the directory for this ticket (in case it exists from a previous DB)
for area in ('input','results'):
self.erase_dir(area)
# Clear the attachments
self.erase_attachments()
return new_status
# Retry ticket
def retry(self):
# Mark the ticket as being ready again
new_status = self.set_status("ready")
# Empty the results directory for the ticket (just in case)
self.erase_dir("results")
# Make a log entry
self.append_log("info", "Retrying ticket")
# Return the new status
return new_status
# Create an attachment entry in the database and get ready to upload attachment
def add_attachment(self, desc, filename, mime_type = None):
# Create a new hash for the attachment
ahash = uuid.uuid4().hex
# Check for mime type
if mime_type is None:
mime_type = guess_mimetype(filename)
# Insert the entry into log_attachment
res = db.insert("ticket_attachment", ticket_id=self.ticket_id,
description=desc, mime_type=mime_type, uuid = ahash)
# Create the directory for the attachment
filedir = 'datastore/attachments/%08d' % int(self.ticket_id)
if not os.path.exists(filedir):
os.makedirs(filedir)
# Create the new filename based on the hash
filebase = basename(filename)
fullext = filebase[filebase.find('.'):]
newname = ahash + fullext;
# Create the filename where this attachment will be stored
afile = filedir +'/'+ newname
# Return a tuple of dbindex, hash, and filename
return (res, ahash, afile)
# Get ticket detail in a JSON-dumpable structure
def get_detail(self):
# Initialize the query result
qr = {}
# Get the status of this ticket
qr['status'] = db.select('tickets', where='id=$self.ticket_id', vars=locals())[0].status;
# Depending on status, assign progress
if qr['status'] == 'claimed':
qr['progress'] = float(TicketLogic(self.ticket_id).total_progress())
elif qr['status'] in ('failed','success','timeout'):
qr['progress'] = 1.0
else:
qr['progress'] = 0.0
# If the status is 'ready', report the queue position (global)
if qr['status'] == 'ready':
qresult = db.query(
"select count(*) from tickets where status='ready' and id <= $self.ticket_id",
vars=locals())
qr['queue_position'] = qresult[0].count
# User can request partial update since given log_id
start_id=0
if "since" in web.input():
start_id=web.input().since
# Get the logs for this ticket
qresult = TicketLogic(self.ticket_id).get_logs(start_id)
# Convert this query to an array
logs = query_as_array_of_dict(qresult, ['id','atime','category','attachments','message'])
# For each entry in the log array, get its attachments
for log_entry in logs:
if log_entry['attachments'] > 0:
qresult = TicketLogLogic(log_entry['id']).get_attachments()
log_entry['attachments'] = query_as_array_of_dict(qresult, ['id','description','mime_type','url'])
else:
log_entry['attachments'] = []
# Store the logs
qr['log'] = logs
return qr
# Logic around ticket log messages.
class TicketLogLogic:
def __init__(self, log_id):
self.log_id = log_id
def check_provider_access(self, provider_id, state_list = None):
# Get the ticket id
res = db.select('ticket_log',where="id=$self.log_id")
if len(res) != 1:
raise_badrequest('Invalid log id')
# Check access to the the ticket
TicketLogic(res[0].ticket_id).check_provider_access(provider_id)
# Check the states
return (state_list is None or res[0].state in state_list)
def check_consumer_access(self, user_id):
res = db.query(
"select L.id from ticket_log L, tickets T "
"where T.user_id = $user_id and T.id = L.ticket_id and L.id = $self.log_id",
vars=locals())
return len(res) > 0
# List all attachments for this log entry with URLs
def get_attachments(self):
urlbase = web.ctx.home + '/blobs/'
return db.query(
"select id,description,mime_type,$urlbase || substr(uuid,0,9) as url "
" from ticket_attachment A left join ticket_log_attachment B "
" on A.id = B.attachment_id "
" where B.log_id = $self.log_id order by id",
vars=locals());
# Create an attachment entry in the database and get ready to upload attachment
def add_attachment(self, desc, filename, mime_type = None):
# Create a new hash for the attachment
ahash = uuid.uuid4().hex
# Check for mime type
if mime_type is None:
mime_type = guess_mimetype(filename)
# Create a database entry
with db.transaction():
# Insert the entry into log_attachment
res = db.insert("ticket_log_attachment", log_id=self.log_id,
description=desc, mime_type=mime_type, uuid = ahash)
# Get the number of attachments
res2 = db.query(
"select count(id) from ticket_log_attachment "
"where log_id = $self.log_id", vars=locals())
# Update the attachment count
db.update("ticket_log", where="id = $self.log_id", attachments=res2[0].count, vars=locals())
# Create the directory for the output
filedir = 'datastore/logdata/%08d' % int(self.log_id)
if not os.path.exists(filedir):
os.makedirs(filedir)
# Create the new filename based on the hash
filebase = basename(filename)
fullext = filebase[filebase.find('.'):]
newname = ahash + fullext;
# Create the filename where this attachment will be stored
afile = filedir +'/'+ newname
# Return a tuple of dbindex, hash, and filename
return (res, ahash, afile)
# Change the status of the log entry
def set_status(self, status):
return db.update("ticket_log", where="id = $self.log_id", state = status, vars=locals())
# Logic around providers and services (which providers offer which service, etc)
class ProviderServiceLogic:
# Check for any services that do not have a provider and mark them not current
def clean_orphaned_services(self):
# Get a list of service githashshes and whether the service has any providers
q = db.query(
"select S.githash, bool_or(PS.current) as any_prov "
"from provider_services PS, services S "
"where S.githash=PS.service_githash "
"GROUP BY S.githash", vars=locals())
# For each service that has been 'orphaned', disable it
for qrow in q:
if qrow.any_prov is False:
db.update("services", where="githash=$qrow.githash", current=False, vars=locals())
# Logic around claiming tickets and scheduling
class ClaimLogic:
def __init__(self, user_id, provider_name, provider_code):
(self.user_id, self.provider_name, self.provider_code) = (user_id, provider_name, provider_code)
def claim_multiservice(self, service_githash_list):
# Put the services back together into a SQL passable string
svc_sql = ",".join("'{0}'".format(w) for w in service_githash_list)
with db.transaction():
# Update the ping on all services
db.query("update services set pingtime=now() where githash in (%s)" % svc_sql)
# Do the SQL call to find the service to use.
# TODO: we need some sort of a fair scheduling scheme. The current scheme is
# pretty ridiculous
res = db.query(
"select id from tickets "
"where service_githash in (%s) "
" and status = 'ready' "
"order by id asc limit 1" % svc_sql)
# Nothing returned? Means there are no ready tickets
if len(res) == 0:
return None
# Now we have a ticket
ticket_id = res[0].id
tl = TicketLogic(ticket_id)
# Make an entry in the claims table, to keep track of this claim
db.insert("claim_history",
ticket_id=ticket_id,
provider=self.provider_name,
provider_code=self.provider_code,
puser_id=self.user_id)
# Mark the ticket as claimed
tl.set_status("claimed")
# Update the log for the user
tl.append_log("info", "Ticket claimed by provider %s instance %s"
% (self.provider_name,self.provider_code))
# Return the ticket id and service hash
return db.select("tickets", where="id=$ticket_id", vars=locals())