Personal wiki notebook (not under development)
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

Users.py 69KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761
  1. import re
  2. import urllib
  3. import urllib2
  4. import cherrypy
  5. import smtplib
  6. from email import Message
  7. from pytz import utc
  8. from datetime import datetime, timedelta
  9. from model.User import User
  10. from model.Group import Group
  11. from model.Notebook import Notebook
  12. from model.Note import Note
  13. from model.Password_reset import Password_reset
  14. from model.Download_access import Download_access
  15. from model.Invite import Invite
  16. from model.Tag import Tag
  17. from Expose import expose
  18. from Validate import validate, Valid_string, Valid_bool, Valid_int, Validation_error
  19. from Database import Valid_id, end_transaction
  20. from Expire import strongly_expire
  21. from view.Json import Json
  22. from view.Main_page import Main_page
  23. from view.Redeem_reset_note import Redeem_reset_note
  24. from view.Redeem_invite_note import Redeem_invite_note
  25. from view.Blank_page import Blank_page
  26. from view.Thanks_note import Thanks_note
  27. from view.Thanks_error_note import Thanks_error_note
  28. from view.Thanks_download_note import Thanks_download_note
  29. from view.Thanks_download_error_note import Thanks_download_error_note
  30. from view.Processing_note import Processing_note
  31. from view.Processing_download_note import Processing_download_note
  32. from view.Form_submit_page import Form_submit_page
  33. USERNAME_PATTERN = re.compile( "^[a-zA-Z0-9]+$" )
  34. EMAIL_ADDRESS_PATTERN = re.compile( "^[\w.%+-]+@[\w-]+(\.[\w-]+)+$" )
  35. EMBEDDED_EMAIL_ADDRESS_PATTERN = re.compile( "(?:^|[\s,<])([\w.%+-]+@[\w-]+(?:\.[\w-]+)+)(?:[\s,>]|$)" )
  36. WHITESPACE_OR_COMMA_PATTERN = re.compile( "[\s,]" )
  37. def valid_username( username ):
  38. if USERNAME_PATTERN.search( username ) is None:
  39. raise ValueError()
  40. return username
  41. valid_username.message = u"can only contain letters and digits"
  42. def valid_email_address( email_address ):
  43. if email_address == "" or EMAIL_ADDRESS_PATTERN.search( email_address ) is None:
  44. raise ValueError()
  45. return email_address
  46. class Signup_error( Exception ):
  47. def __init__( self, message ):
  48. Exception.__init__( self, message )
  49. self.__message = message
  50. def to_dict( self ):
  51. return dict(
  52. error = self.__message
  53. )
  54. class Authentication_error( Exception ):
  55. def __init__( self, message ):
  56. Exception.__init__( self, message )
  57. self.__message = message
  58. def to_dict( self ):
  59. return dict(
  60. error = self.__message
  61. )
  62. class Password_reset_error( Exception ):
  63. def __init__( self, message ):
  64. Exception.__init__( self, message )
  65. self.__message = message
  66. def to_dict( self ):
  67. return dict(
  68. error = self.__message
  69. )
  70. class Invite_error( Exception ):
  71. def __init__( self, message ):
  72. Exception.__init__( self, message )
  73. self.__message = message
  74. def to_dict( self ):
  75. return dict(
  76. error = self.__message
  77. )
  78. class Access_error( Exception ):
  79. def __init__( self, message = None ):
  80. if message is None:
  81. message = u"Sorry, you don't have access to do that. Please make sure you're logged in as the correct user."
  82. Exception.__init__( self, message )
  83. self.__message = message
  84. def to_dict( self ):
  85. return dict(
  86. error = self.__message
  87. )
  88. class Payment_error( Exception ):
  89. def __init__( self, message, params ):
  90. message += "\n" + unicode( params )
  91. Exception.__init__( self, message )
  92. self.__message = message
  93. def to_dict( self ):
  94. return dict(
  95. error = self.__message
  96. )
  97. def grab_user_id( function ):
  98. """
  99. A decorator to grab the current logged in user id from the cherrypy session and pass it as a
  100. user_id argument to the decorated function. This decorator must be used from within the main
  101. cherrypy request thread.
  102. """
  103. def get_id( *args, **kwargs ):
  104. arg_names = list( function.func_code.co_varnames )
  105. if "user_id" in arg_names:
  106. arg_index = arg_names.index( "user_id" )
  107. args = list( args )
  108. args[ arg_index - 1 ] = cherrypy.session.get( "user_id" )
  109. else:
  110. kwargs[ "user_id" ] = cherrypy.session.get( "user_id" )
  111. try:
  112. return function( *args, **kwargs )
  113. except Access_error:
  114. # if there was an Access_error, and the user isn't logged in, and this is an HTTP GET request,
  115. # redirect to the login page. that is, unless there is an auto-login username
  116. if cherrypy.session.get( "user_id" ) is None and cherrypy.request.method == "GET":
  117. if cherrypy.config.configs[ u"global" ].get( u"luminotes.auto_login_username" ):
  118. raise cherrypy.HTTPRedirect( u"%s/" % cherrypy.request.base )
  119. original_path = cherrypy.request.path + \
  120. ( cherrypy.request.query_string and u"?%s" % cherrypy.request.query_string or "" )
  121. raise cherrypy.HTTPRedirect( u"%s/login?after_login=%s" % ( cherrypy.request.base, urllib.quote( original_path ) ) )
  122. else:
  123. raise
  124. return get_id
  125. def update_auth( function ):
  126. """
  127. Based on the return value of the decorated function, update the current session's authentication
  128. status. This decorator must be used from within the main cherrypy request thread.
  129. If the return value of the decorated function (which is expected to be a dictionary) contains an
  130. "authenticated" key with a User value, then mark the user as logged in. If the return value of the
  131. decorated function contains a "deauthenticated" key with any value, then mark the user as logged
  132. out.
  133. """
  134. def handle_result( *args, **kwargs ):
  135. result = function( *args, **kwargs )
  136. # peek in the function's return value to see if we should tweak authentication status
  137. user = result.get( "authenticated" )
  138. if user:
  139. result.pop( "authenticated", None )
  140. cherrypy.session[ u"user_id" ] = user.object_id
  141. cherrypy.session[ u"username" ] = user.username
  142. if result.get( "deauthenticated" ):
  143. result.pop( "deauthenticated", None )
  144. cherrypy.session.pop( u"user_id", None )
  145. cherrypy.session.pop( u"username", None )
  146. return result
  147. return handle_result
  148. class Users( object ):
  149. """
  150. Controller for dealing with users, corresponding to the "/users" URL.
  151. """
  152. def __init__( self, database, http_url, https_url, support_email, payment_email, rate_plans, download_products ):
  153. """
  154. Create a new Users object.
  155. @type database: controller.Database
  156. @param database: database that users are stored in
  157. @type http_url: unicode
  158. @param http_url: base URL to use for non-SSL http requests, or an empty string
  159. @type https_url: unicode
  160. @param https_url: base URL to use for SSL http requests, or an empty string
  161. @type support_email: unicode
  162. @param support_email: email address for support requests
  163. @type payment_email: unicode
  164. @param payment_email: email address for payment
  165. @type rate_plans: [ { "name": unicode, ... } ]
  166. @param rate_plans: list of configured rate plans
  167. @type download_products: [ { "name": unicode, ... } ]
  168. @param download_products: list of configured downloadable products
  169. @rtype: Users
  170. @return: newly constructed Users
  171. """
  172. self.__database = database
  173. self.__http_url = http_url
  174. self.__https_url = https_url
  175. self.__support_email = support_email
  176. self.__payment_email = payment_email
  177. self.__rate_plans = rate_plans
  178. self.__download_products = download_products
  179. def create_user( self, username, password = None, password_repeat = None, email_address = None, initial_rate_plan = None ):
  180. """
  181. Create a new User based on the given information. Start that user with their own Notebook and a
  182. "welcome to your wiki" Note. This method does not commit the transaction to the database.
  183. @type username: unicode (alphanumeric only)
  184. @param username: username to use for this new user
  185. @type password: unicode or NoneType
  186. @param password: password to use (optional, defaults to None)
  187. @type password_repeat: unicode or NoneType
  188. @param password_repeat: password to use, again (optional, defaults to None)
  189. @type email_address: unicode or NoneType
  190. @param email_address: user's email address (optional, defaults to None)
  191. @type initial_rate_plan: int or NoneType
  192. @param initial_rate_plan: index of rate plan to start the user with before they even subscribe
  193. (defaults to None)
  194. @type user: ( model.User, model.Notebook )
  195. @parm user: ( newly created user, newly created notebook )
  196. @raise Signup_error: passwords don't match or the username is unavailable
  197. @raise Validation_error: the email address is invalid
  198. """
  199. if password != password_repeat:
  200. raise Signup_error( u"The passwords you entered do not match. Please try again." )
  201. user = self.__database.select_one( User, User.sql_load_by_username( username ) )
  202. if user is not None:
  203. raise Signup_error( u"Sorry, that username is not available. Please try something else." )
  204. if email_address:
  205. try:
  206. email_address = valid_email_address( email_address )
  207. except ValueError:
  208. raise Validation_error( "email_address", email_address, valid_email_address )
  209. # create a notebook for this user, along with a trash for that notebook
  210. trash_id = self.__database.next_id( Notebook, commit = False )
  211. trash = Notebook.create( trash_id, u"trash" )
  212. self.__database.save( trash, commit = False )
  213. notebook_id = self.__database.next_id( Notebook, commit = False )
  214. notebook = Notebook.create( notebook_id, u"my notebook", trash_id )
  215. self.__database.save( notebook, commit = False )
  216. # create a startup note for this user's notebook
  217. note_id = self.__database.next_id( Note, commit = False )
  218. note_contents = file( u"static/html/welcome to your wiki.html" ).read()
  219. note = Note.create( note_id, note_contents, notebook_id, startup = True, rank = 0 )
  220. self.__database.save( note, commit = False )
  221. # actually create the new user
  222. user_id = self.__database.next_id( User, commit = False )
  223. user = User.create( user_id, username, password, email_address, rate_plan = initial_rate_plan )
  224. self.__database.save( user, commit = False )
  225. # record the fact that the new user has access to their new notebook
  226. self.__database.execute( user.sql_save_notebook( notebook_id, read_write = True, owner = True, rank = 0 ), commit = False )
  227. self.__database.execute( user.sql_save_notebook( trash_id, read_write = True, owner = True ), commit = False )
  228. return ( user, notebook )
  229. @expose( view = Json )
  230. @end_transaction
  231. @update_auth
  232. @validate(
  233. username = ( Valid_string( min = 1, max = 30 ), valid_username ),
  234. password = Valid_string( min = 1, max = 30 ),
  235. password_repeat = Valid_string( min = 1, max = 30 ),
  236. email_address = ( Valid_string( min = 0, max = 60 ) ),
  237. signup_button = unicode,
  238. invite_id = Valid_id( none_okay = True ),
  239. rate_plan = Valid_int( none_okay = True ),
  240. yearly = Valid_bool( none_okay = True ),
  241. )
  242. def signup( self, username, password, password_repeat, email_address, signup_button, invite_id = None, rate_plan = None, yearly = False ):
  243. """
  244. Create a new User based on the given information. For convenience, login the newly created user
  245. as well.
  246. @type username: unicode (alphanumeric only)
  247. @param username: username to use for this new user
  248. @type password: unicode
  249. @param password: password to use
  250. @type password_repeat: unicode
  251. @param password_repeat: password to use, again
  252. @type email_address: unicode
  253. @param email_address: user's email address
  254. @type signup_button: unicode
  255. @param signup_button: ignored
  256. @type invite_id: unicode
  257. @param invite_id: id of invite to redeem upon signup (optional)
  258. @type rate_plan: int
  259. @param rate_plan: index of rate plan to signup for (optional). if greater than zero, redirect
  260. to PayPal subscribe page after signup
  261. @type yearly: bool
  262. @param yearly: True for a yearly rate plan, False for monthly (optional, defaults to False )
  263. @rtype: json dict
  264. @return: { 'redirect': url, 'authenticated': userdict }
  265. @raise Signup_error: passwords don't match or the username is unavailable
  266. @raise Validation_error: one of the arguments is invalid
  267. """
  268. ( user, notebook ) = self.create_user( username, password, password_repeat, email_address )
  269. self.__database.commit()
  270. # if there's an invite_id, then redeem that invite and redirect to the invite's notebook
  271. if invite_id:
  272. invite = self.__database.load( Invite, invite_id )
  273. if not invite:
  274. raise Signup_error( u"The invite is unknown." )
  275. self.convert_invite_to_access( invite, user.object_id )
  276. redirect = u"/notebooks/%s" % invite.notebook_id
  277. # if there's a requested rate plan, then redirect to the PayPal subscribe page
  278. elif rate_plan and rate_plan > 0:
  279. redirect = u"/users/subscribe?rate_plan=%s&yearly=%s" % ( rate_plan, yearly )
  280. # otherwise, just redirect to the newly created notebook
  281. else:
  282. redirect = u"/notebooks/%s" % notebook.object_id
  283. return dict(
  284. redirect = redirect,
  285. authenticated = user,
  286. )
  287. @expose( view = Json )
  288. @end_transaction
  289. @grab_user_id
  290. @validate(
  291. group_id = Valid_id(),
  292. username = ( Valid_string( min = 1, max = 30 ), valid_username ),
  293. password = Valid_string( min = 1, max = 30 ),
  294. password_repeat = Valid_string( min = 1, max = 30 ),
  295. email_address = ( Valid_string( min = 0, max = 60 ) ),
  296. create_user_button = unicode,
  297. user_id = Valid_id( none_okay = True )
  298. )
  299. def signup_group_member( self, group_id, username, password, password_repeat, email_address, create_user_button, user_id ):
  300. """
  301. Create a new User in a particular group based on the given information. Start that user with
  302. their own Notebook and a "welcome to your wiki" Note. This method is only available to a user
  303. with admin access to the group.
  304. @type group_id: unicode
  305. @param group_id: id of the group to which the new user should be added
  306. @type username: unicode (alphanumeric only)
  307. @param username: username to use for this new user
  308. @type password: unicode
  309. @param password: password to use
  310. @type password_repeat: unicode
  311. @param password_repeat: password to use, again
  312. @type email_address: unicode
  313. @param email_address: user's email address
  314. @type create_user_button: unicode
  315. @param create_user_button: ignored
  316. @type user_id: unicode
  317. @param user_id: id of current logged-in user
  318. @rtype: json dict
  319. @return: { 'message': message }
  320. @raise Signup_error: passwords don't match or the username is unavailable
  321. @raise Validation_error: one of the arguments is invalid
  322. @raise Access_error: the current user doesn't have admin membership to the given group
  323. """
  324. if not self.check_group( user_id, group_id, admin = True ):
  325. raise Access_error()
  326. user = self.__database.load( User, user_id )
  327. if not user:
  328. raise Access_error()
  329. if user.rate_plan < 0 or user.rate_plan >= len( self.__rate_plans ):
  330. raise Access_error()
  331. plan = self.__rate_plans[ user.rate_plan ]
  332. if not plan.get( u"user_admin" ):
  333. raise Access_error()
  334. # the current user's rate plan has a maximum number of included users. make sure we're not
  335. # exceeding that number
  336. included_users_count = plan.get( u"included_users" )
  337. if not included_users_count:
  338. raise Access_error()
  339. group = self.__database.load( Group, group_id )
  340. if not group:
  341. raise Access_error()
  342. # TODO: once multiple groups per account are supported, this needs to count all users in all
  343. # groups of the current admin user
  344. group_users = self.__database.select_many( User, group.sql_load_users() )
  345. if len( group_users ) >= included_users_count:
  346. raise Signup_error( 'Your current rate plan includes a maximum of %s users. Please upgrade your account for additional users.' % included_users_count )
  347. # create a new user with the same rate plan as the currently logged-in user
  348. ( created_user, notebook ) = self.create_user(
  349. username,
  350. password,
  351. password_repeat,
  352. email_address,
  353. initial_rate_plan = user.rate_plan,
  354. )
  355. # add the new user to the group
  356. self.__database.execute( created_user.sql_save_group( group_id, admin = False ), commit = False )
  357. self.__database.commit()
  358. return dict(
  359. message = u"A new group member has been created."
  360. )
  361. @expose( view = Form_submit_page )
  362. @grab_user_id
  363. @validate(
  364. rate_plan = Valid_int(),
  365. yearly = Valid_bool( none_okay = True ),
  366. user_id = Valid_id(),
  367. )
  368. def subscribe( self, rate_plan, yearly = False, user_id = None ):
  369. """
  370. Submit a subscription form to PayPal, allowing the user to subscribe to the given rate plan.
  371. @type rate_plan: int
  372. @param rate_plan: index of rate plan to subscribe to
  373. @type yearly: bool
  374. @param yearly: True for a yearly rate plan, False for monthly (optional, defaults to False )
  375. @type user_id: unicode
  376. @param user_id: id of current logged-in user
  377. @rtype: dict
  378. @return: { 'form': subscription_form_html }
  379. @raise Signup_error: invalid rate plan, no logged-in user, or missing subscribe button
  380. """
  381. if rate_plan == 0 or rate_plan >= len( self.__rate_plans ):
  382. raise Signup_error( u"The rate plan is invalid." )
  383. plan = self.__rate_plans[ rate_plan ]
  384. if yearly:
  385. button = plan.get( u"yearly_button" )
  386. else:
  387. button = plan.get( u"button" )
  388. if not button or not button.strip():
  389. raise Signup_error(
  390. u"Sorry, that rate plan is not configured for subscriptions. Please contact %s." % \
  391. ( self.__support_email or u"support" )
  392. )
  393. return dict(
  394. form = button % ( user_id, 0 ) # 0 = new subscription, 1 = modify an existing subscription
  395. )
  396. @expose()
  397. @end_transaction
  398. @grab_user_id
  399. @update_auth
  400. def demo( self, user_id = None ):
  401. """
  402. Create a new guest User for purposes of the demo. Start that user with their own Notebook and
  403. "welcome to your wiki" and "this is a demo" notes. For convenience, login the newly created
  404. user as well.
  405. If the user is already logged in as a guest user when calling this function, then skip
  406. creating a new user and notebook, and just redirect to the guest user's existing notebook.
  407. @type user_id: unicode
  408. @param user_id: id of current logged-in user (if any), determined by @grab_user_id
  409. @rtype: json dict
  410. @return: { 'redirect': url, 'authenticated': userdict }
  411. """
  412. # if the user is already logged in as a guest, then just redirect to their existing demo
  413. # notebook
  414. if user_id:
  415. user = self.__database.load( User, user_id )
  416. first_notebook = self.__database.select_one( Notebook, user.sql_load_notebooks( parents_only = True, undeleted_only = True ) )
  417. if user.username is None and first_notebook:
  418. redirect = u"/notebooks/%s" % first_notebook.object_id
  419. return dict( redirect = redirect )
  420. # create a demo notebook for this user, along with a trash for that notebook
  421. trash_id = self.__database.next_id( Notebook, commit = False )
  422. trash = Notebook.create( trash_id, u"trash" )
  423. self.__database.save( trash, commit = False )
  424. notebook_id = self.__database.next_id( Notebook, commit = False )
  425. notebook = Notebook.create( notebook_id, u"my notebook", trash_id )
  426. self.__database.save( notebook, commit = False )
  427. # create startup notes for this user's notebook
  428. note_id = self.__database.next_id( Note, commit = False )
  429. note_contents = file( u"static/html/this is a demo.html" ).read()
  430. note = Note.create( note_id, note_contents, notebook_id, startup = True, rank = 0 )
  431. self.__database.save( note, commit = False )
  432. note_id = self.__database.next_id( Note, commit = False )
  433. note_contents = file( u"static/html/welcome to your wiki.html" ).read()
  434. note = Note.create( note_id, note_contents, notebook_id, startup = True, rank = 1 )
  435. self.__database.save( note, commit = False )
  436. # actually create the new user
  437. user_id = self.__database.next_id( User, commit = False )
  438. user = User.create( user_id, username = None, password = None, email_address = None )
  439. self.__database.save( user, commit = False )
  440. # record the fact that the new user has access to their new notebook
  441. self.__database.execute( user.sql_save_notebook( notebook_id, read_write = True, owner = True, rank = 0 ), commit = False )
  442. self.__database.execute( user.sql_save_notebook( trash_id, read_write = True, owner = True ), commit = False )
  443. self.__database.commit()
  444. redirect = u"/notebooks/%s" % notebook.object_id
  445. return dict(
  446. redirect = redirect,
  447. authenticated = user,
  448. )
  449. @expose( view = Json )
  450. @end_transaction
  451. @update_auth
  452. @validate(
  453. username = ( Valid_string( min = 1, max = 30 ), valid_username ),
  454. password = Valid_string( min = 1, max = 30 ),
  455. login_button = unicode,
  456. invite_id = Valid_id( none_okay = True ),
  457. after_login = Valid_string( min = 0, max = 1000 ),
  458. )
  459. def login( self, username, password, login_button, invite_id = None, after_login = None ):
  460. """
  461. Attempt to authenticate the user. If successful, associate the given user with the current
  462. session.
  463. @type username: unicode (alphanumeric only)
  464. @param username: username to login
  465. @type password: unicode
  466. @param password: the user's password
  467. @type invite_id: unicode
  468. @param invite_id: id of invite to redeem upon login (optional)
  469. @type after_login: unicode
  470. @param after_login: URL to redirect to after login (optional, must start with "/")
  471. @rtype: json dict
  472. @return: { 'redirect': url, 'authenticated': userdict }
  473. @raise Authentication_error: invalid username or password
  474. @raise Validation_error: one of the arguments is invalid
  475. """
  476. user = self.__database.select_one( User, User.sql_load_by_username( username ) )
  477. if user is None or user.check_password( password ) is False:
  478. raise Authentication_error( u"Invalid username or password." )
  479. first_notebook = self.__database.select_one( Notebook, user.sql_load_notebooks( parents_only = True, undeleted_only = True ) )
  480. # if there's an invite_id, then redeem that invite and redirect to the invite's notebook
  481. if invite_id:
  482. invite = self.__database.load( Invite, invite_id )
  483. if not invite:
  484. raise Authentication_error( u"The invite is unknown." )
  485. self.convert_invite_to_access( invite, user.object_id )
  486. redirect = u"/notebooks/%s" % invite.notebook_id
  487. # if there's an after_login URL, redirect to it
  488. elif after_login and after_login.startswith( "/" ):
  489. redirect = after_login
  490. # otherwise, just redirect to the user's first notebook (if any)
  491. elif first_notebook:
  492. redirect = u"/notebooks/%s" % first_notebook.object_id
  493. else:
  494. redirect = u"/"
  495. return dict(
  496. redirect = redirect,
  497. authenticated = user,
  498. )
  499. @expose()
  500. @end_transaction
  501. @update_auth
  502. def logout( self ):
  503. """
  504. Deauthenticate the user and log them out of their current session.
  505. @rtype: dict
  506. @return: { 'redirect': url, 'deauthenticated': True }
  507. """
  508. return dict(
  509. redirect = self.__http_url + u"/",
  510. deauthenticated = True,
  511. )
  512. def current( self, user_id ):
  513. """
  514. Return information on the currently logged-in user. If not logged in, default to the anonymous
  515. user.
  516. @type user_id: unicode
  517. @param user_id: id of current logged-in user (if any)
  518. @rtype: json dict
  519. @return: {
  520. 'user': user or None,
  521. 'notebooks': notebookslist,
  522. 'login_url': url,
  523. 'logout_url': url,
  524. 'rate_plan': rateplandict,
  525. 'groups': groups,
  526. }
  527. @raise Validation_error: one of the arguments is invalid
  528. @raise Access_error: user_id or anonymous user unknown
  529. """
  530. # if there's no logged-in user, default to the anonymous user
  531. anonymous = self.__database.select_one( User, User.sql_load_by_username( u"anonymous" ), use_cache = True )
  532. if user_id:
  533. user = self.__database.load( User, user_id )
  534. else:
  535. user = anonymous
  536. if not user or not anonymous:
  537. raise Access_error()
  538. user.group_storage_bytes = self.calculate_group_storage( user )
  539. login_url = None
  540. if user_id and user_id != anonymous.object_id:
  541. notebooks = self.__database.select_many( Notebook, user.sql_load_notebooks( parents_only = True ) )
  542. groups = self.__database.select_many( Group, user.sql_load_groups() )
  543. # if the user is not logged in, return a login URL
  544. else:
  545. notebooks = self.__database.select_many( Notebook, anonymous.sql_load_notebooks( undeleted_only = True ) )
  546. groups = []
  547. if len( notebooks ) > 0 and notebooks[ 0 ]:
  548. main_notebook = notebooks[ 0 ]
  549. login_note = self.__database.select_one( Note, main_notebook.sql_load_note_by_title( u"login" ) )
  550. if login_note:
  551. login_url = "%s/notebooks/%s?note_id=%s" % ( self.__https_url, main_notebook.object_id, login_note.object_id )
  552. for notebook in notebooks:
  553. notebook.tags = \
  554. self.__database.select_many( Tag, notebook.sql_load_tags( user_id ) ) + \
  555. self.__database.select_many( Tag, notebook.sql_load_tags( anonymous.object_id ) )
  556. return dict(
  557. user = user,
  558. notebooks = notebooks,
  559. login_url = login_url,
  560. logout_url = self.__https_url + u"/users/logout",
  561. rate_plan = ( user.rate_plan < len( self.__rate_plans ) ) and self.__rate_plans[ user.rate_plan ] or {},
  562. groups = groups,
  563. )
  564. def calculate_storage( self, user ):
  565. """
  566. Calculate total storage utilization for all notes of the given user, including storage for all
  567. past revisions.
  568. @type user: User
  569. @param user: user for which to calculate storage utilization
  570. @rtype: int
  571. @return: total bytes used for storage
  572. """
  573. return sum( self.__database.select_one( tuple, user.sql_calculate_storage( self.__database.backend ) ), 0 )
  574. def calculate_group_storage( self, user ):
  575. """
  576. Calculate total storage utilization for all groups that the given user is a member of.
  577. @type user: User
  578. @param user: user for which to calculate storage utilization
  579. @rtype: int
  580. @return: total bytes used for group storage
  581. """
  582. return sum( self.__database.select_one( tuple, user.sql_calculate_group_storage() ), 0 )
  583. def update_storage( self, user_id, commit = True ):
  584. """
  585. Calculate and record total storage utilization for the given user.
  586. @type user_id: unicode
  587. @param user_id: id of user for which to calculate storage utilization
  588. @type commit: bool
  589. @param commit: True to automatically commit after the update
  590. @rtype: model.User
  591. @return: object of the user corresponding to user_id
  592. """
  593. user = self.__database.load( User, user_id )
  594. if user is None:
  595. return None
  596. user.storage_bytes = self.calculate_storage( user )
  597. self.__database.save( user, commit )
  598. return user
  599. def load_notebook( self, user_id, notebook_id, read_write = False, owner = False, note_id = None ):
  600. """
  601. Determine whether the given user has access to the given notebook, and if so, return that
  602. notebook.
  603. If the notebook.read_write member is READ_WRITE_FOR_OWN_NOTES, and a particular note_id is
  604. given, then make sure that the given note_id is one of the user's own notes.
  605. @type user_id: unicode
  606. @param user_id: id of user whose access to check
  607. @type notebook_id: unicode
  608. @param notebook_id: id of notebook to check access for
  609. @type read_write: boolean
  610. @param read_write: True if the notebook must be READ_WRITE or READ_WRITE_FOR_OWN_NOTES,
  611. False if read-write access is not to be checked (defaults to False)
  612. @type owner: bool
  613. @param owner: True if owner-level access is being checked (defaults to False)
  614. @type note_id: unicode
  615. @param note_id: id of the note in the given notebook that the user is trying to access.
  616. if the notebook is READ_WRITE_FOR_OWN_NOTES, then the given note is checked
  617. to make sure its user_id is the same as the given user_id. for READ_WRITE
  618. and READ_ONLY notebooks, this note_id parameter is ignored
  619. @rtype: Notebook or NoneType
  620. @return: the loaded notebook if the user has access to it, None otherwise
  621. """
  622. anonymous = self.__database.select_one( User, User.sql_load_by_username( u"anonymous" ), use_cache = True )
  623. user = user_id and self.__database.load( User, user_id ) or anonymous
  624. notebook = None
  625. # first try loading the notebook as the given user (if any)
  626. if user:
  627. notebook = self.__database.select_one( Notebook, user.sql_load_notebooks( notebook_id = notebook_id ) )
  628. # if that doesn't work, try loading the notebook as the anonymous user
  629. if notebook is None:
  630. notebook = self.__database.select_one( Notebook, anonymous.sql_load_notebooks( notebook_id = notebook_id ) )
  631. # if the user has no access to this notebook, bail
  632. if notebook is None:
  633. return None
  634. if read_write and notebook.read_write == Notebook.READ_ONLY:
  635. return None
  636. if owner and not notebook.owner:
  637. return None
  638. # if a particular note_id is given, and the notebook is READ_WRITE_FOR_OWN_NOTES, then check
  639. # that the user is associated with that note (if the note exists). this prevents a user
  640. # from modifying someone else's note in a READ_WRITE_FOR_OWN_NOTES notebook
  641. if note_id and notebook.read_write == Notebook.READ_WRITE_FOR_OWN_NOTES:
  642. note = self.__database.load( Note, note_id )
  643. if note and (
  644. ( note.user_id and user_id != note.user_id ) or
  645. ( note.notebook_id and notebook_id != note.notebook_id )
  646. ):
  647. return None
  648. # also, prevent anonymous/demo read-write or owner access to READ_WRITE_FOR_OWN_NOTES notebooks
  649. if notebook.read_write == Notebook.READ_WRITE_FOR_OWN_NOTES and \
  650. ( read_write is True or owner is True ) and \
  651. ( user is None or user.username is None or user.username == u"anonymous" ):
  652. return None
  653. return notebook
  654. def check_group( self, user_id, group_id, admin = False ):
  655. """
  656. Determine whether the given user has membership to the given group.
  657. @type user_id: unicode
  658. @param user_id: id of user whose membership to check
  659. @type group_id: unicode
  660. @param group_id: id of group to check membership in
  661. @type admin: bool
  662. @param admin: True if admin-level membership is being checked (defaults to False)
  663. @rtype: bool
  664. @return: True if the user has membership
  665. """
  666. # check if the given user has access to this notebook
  667. user = self.__database.load( User, user_id )
  668. if user and self.__database.select_one( bool, user.sql_in_group( group_id, admin ) ):
  669. return True
  670. return False
  671. @expose( view = Json )
  672. @end_transaction
  673. @grab_user_id
  674. @validate(
  675. user_id_to_remove = Valid_id(),
  676. group_id = Valid_id(),
  677. user_id = Valid_id( none_okay = True ),
  678. )
  679. def remove_group( self, user_id_to_remove, group_id, user_id = None ):
  680. """
  681. Remove a user's membership from the given group. For now, this also sets them to the lowest
  682. rate plan.
  683. @type user_id_to_remove: unicode
  684. @param user_id_to_remove: id of the user to remove from the group
  685. @type group_id: unicode
  686. @param group_id: id of the group to remove membership from
  687. @type user_id: unicode
  688. @param user_id: id of current logged-in user (if any)
  689. @raise Validation_error: one of the arguments is invalid
  690. @raise Access_error: the current user doesn't have admin membership to the given group
  691. """
  692. if not self.check_group( user_id, group_id, admin = True ):
  693. raise Access_error()
  694. user = self.__database.load( User, user_id_to_remove )
  695. if not user:
  696. raise Access_error()
  697. self.__database.execute( user.sql_remove_group( group_id ) )
  698. # setting the user's rate plan to 0 upon group removal prevents a group admin from creating
  699. # an unlimited number of users with high-end rate plans
  700. user.rate_plan = 0
  701. self.__database.save( user )
  702. return dict(
  703. message = u"Group membership for %s has been revoked." % user.username,
  704. )
  705. @expose( view = Json )
  706. @end_transaction
  707. @validate(
  708. email_address = ( Valid_string( min = 1, max = 60 ), valid_email_address ),
  709. send_reset_button = unicode,
  710. )
  711. def send_reset( self, email_address, send_reset_button ):
  712. """
  713. Send a password reset email to the given email address.
  714. @type email_address: unicode
  715. @param email_address: an existing user's email address
  716. @type send_reset_button: unicode
  717. @param send_reset_button: ignored
  718. @rtype: json dict
  719. @return: { 'message': message }
  720. @raise Password_reset_error: an error occured when sending the password reset email
  721. @raise Validation_error: one of the arguments is invalid
  722. """
  723. # check whether there are actually any users with the given email address
  724. users = self.__database.select_many( User, User.sql_load_by_email_address( email_address ) )
  725. if len( users ) == 0:
  726. raise Password_reset_error( u"There are no Luminotes users with the email address %s" % email_address )
  727. # record the sending of this reset email
  728. password_reset_id = self.__database.next_id( Password_reset, commit = False )
  729. password_reset = Password_reset.create( password_reset_id, email_address )
  730. self.__database.save( password_reset )
  731. # create an email message with a unique link
  732. message = Message.Message()
  733. message[ u"From" ] = u"Luminotes support <%s>" % self.__support_email
  734. message[ u"To" ] = email_address
  735. message[ u"Subject" ] = u"Luminotes password reset"
  736. message.set_payload(
  737. u"Someone has requested a password reset for a Luminotes user with your email\n" +
  738. u"address. If this someone is you, please visit the following link for a\n" +
  739. u"username reminder or a password reset:\n\n" +
  740. u"%s/r/%s\n\n" % ( self.__https_url or self.__http_url, password_reset.object_id ) +
  741. u"This link will expire in 24 hours.\n\n" +
  742. u"Thanks!"
  743. )
  744. # send the message out through localhost's smtp server
  745. server = smtplib.SMTP()
  746. server.connect()
  747. server.sendmail( message[ u"From" ], [ email_address ], message.as_string() )
  748. server.quit()
  749. return dict(
  750. message = u"Please check your inbox. A password reset email has been sent to %s" % email_address,
  751. )
  752. @expose( view = Main_page )
  753. @strongly_expire
  754. @end_transaction
  755. @validate(
  756. password_reset_id = Valid_id(),
  757. )
  758. def redeem_reset( self, password_reset_id ):
  759. """
  760. Provide the information necessary to display the web site's main page along with a dynamically
  761. generated "complete your password reset" note.
  762. @type password_reset_id: unicode
  763. @param password_reset_id: id of model.Password_reset to redeem
  764. @rtype: unicode
  765. @return: rendered HTML page
  766. @raise Password_reset_error: an error occured when redeeming the password reset, such as an expired link
  767. @raise Validation_error: one of the arguments is invalid
  768. """
  769. anonymous = self.__database.select_one( User, User.sql_load_by_username( u"anonymous" ), use_cache = True )
  770. if anonymous:
  771. main_notebook = self.__database.select_one( Notebook, anonymous.sql_load_notebooks( undeleted_only = True ) )
  772. if not anonymous or not main_notebook:
  773. raise Password_reset_error( "There was an error when completing your password reset. Please contact %s." % self.__support_email )
  774. password_reset = self.__database.load( Password_reset, password_reset_id )
  775. if not password_reset or datetime.now( tz = utc ) - password_reset.revision > timedelta( hours = 25 ):
  776. raise Password_reset_error( "Your password reset link has expired. Please request a new password reset email." )
  777. if password_reset.redeemed:
  778. raise Password_reset_error( "Your password has already been reset. Please request a new password reset email." )
  779. # find the user(s) with the email address from the password reset request
  780. matching_users = self.__database.select_many( User, User.sql_load_by_email_address( password_reset.email_address ) )
  781. if len( matching_users ) == 0:
  782. raise Password_reset_error( u"There are no Luminotes users with the email address %s" % password_reset.email_address )
  783. result = self.current( anonymous.object_id )
  784. result[ "notebook" ] = main_notebook
  785. result[ "startup_notes" ] = self.__database.select_many( Note, main_notebook.sql_load_startup_notes() )
  786. result[ "total_notes_count" ] = self.__database.select_one( Note, main_notebook.sql_count_notes(), use_cache = True )
  787. result[ "note_read_write" ] = False
  788. result[ "notes" ] = [ Note.create(
  789. object_id = u"password_reset",
  790. contents = unicode( Redeem_reset_note( password_reset_id, matching_users ) ),
  791. notebook_id = main_notebook.object_id,
  792. ) ]
  793. result[ "invites" ] = []
  794. return result
  795. @expose( view = Json )
  796. @end_transaction
  797. def reset_password( self, password_reset_id, reset_button, **new_passwords ):
  798. """
  799. Reset all the users with the provided passwords.
  800. @type password_reset_id: unicode
  801. @param password_reset_id: id of model.Password_reset to use
  802. @type reset_button: unicode
  803. @param reset_button: return
  804. @type new_passwords: { userid: [ newpassword, newpasswordrepeat ] }
  805. @param new_passwords: map of user id to new passwords or empty strings
  806. @rtype: json dict
  807. @return: { 'redirect': '/' }
  808. @raise Password_reset_error: an error occured when resetting the passwords, such as an expired link
  809. """
  810. try:
  811. id_validator = Valid_id()
  812. id_validator( password_reset_id )
  813. except ValueError:
  814. raise Validation_error( "password_reset_id", password_reset_id, id_validator, "is not a valid id" )
  815. password_reset = self.__database.load( Password_reset, password_reset_id )
  816. if not password_reset or datetime.now( tz = utc ) - password_reset.revision > timedelta( hours = 25 ):
  817. raise Password_reset_error( "Your password reset link has expired. Please request a new password reset email." )
  818. if password_reset.redeemed:
  819. raise Password_reset_error( "Your password has already been reset. Please request a new password reset email." )
  820. matching_users = self.__database.select_many( User, User.sql_load_by_email_address( password_reset.email_address ) )
  821. allowed_user_ids = [ user.object_id for user in matching_users ]
  822. # reset any passwords that are non-blank
  823. at_least_one_reset = False
  824. for ( user_id, ( new_password, new_password_repeat ) ) in new_passwords.items():
  825. if user_id not in allowed_user_ids:
  826. raise Password_reset_error( "There was an error when resetting your password. Please contact %s." % self.__support_email )
  827. # skip blank passwords
  828. if new_password == u"" and new_password_repeat == u"":
  829. continue
  830. user = self.__database.load( User, user_id )
  831. if not user:
  832. raise Password_reset_error( "There was an error when resetting your password. Please contact %s." % self.__support_email )
  833. # ensure the passwords match
  834. if new_password != new_password_repeat:
  835. raise Password_reset_error( u"The new passwords you entered for user %s do not match. Please try again." % user.username )
  836. # ensure the new password isn't too long
  837. if len( new_password ) > 30:
  838. raise Password_reset_error( u"Your password can be no longer than 30 characters." )
  839. at_least_one_reset = True
  840. user.password = new_password
  841. self.__database.save( user, commit = False )
  842. # if all the new passwords provided are blank, bail
  843. if not at_least_one_reset:
  844. raise Password_reset_error( u"Please enter a new password. Or, if you already know your password, just click the login link above." )
  845. password_reset.redeemed = True
  846. self.__database.save( password_reset, commit = False )
  847. self.__database.commit()
  848. return dict( redirect = u"/" )
  849. @expose( view = Json )
  850. @end_transaction
  851. @grab_user_id
  852. @validate(
  853. notebook_id = Valid_id(),
  854. email_addresses = unicode,
  855. access = Valid_string(),
  856. invite_button = unicode,
  857. user_id = Valid_id( none_okay = True ),
  858. )
  859. def send_invites( self, notebook_id, email_addresses, access, invite_button, user_id = None ):
  860. """
  861. Send notebook invitations to the given email addresses.
  862. @type notebook_id: unicode
  863. @param notebook_id: id of the notebook that the invitation is for
  864. @type email_addresses: unicode
  865. @param email_addresses: a string containing whitespace- or comma-separated email addresses
  866. @type access: unicode
  867. @param access: type of access to grant, either "collaborator", "viewer", or "owner". with
  868. certain rate plans, only "viewer" is allowed
  869. @type invite_button: unicode
  870. @param invite_button: ignored
  871. @type user_id: unicode
  872. @param user_id: id of current logged-in user (if any), determined by @grab_user_id
  873. @rtype: json dict
  874. @return: { 'message': message, 'invites': invites }
  875. @raise Invite_error: an error occured when sending the invite
  876. @raise Validation_error: one of the arguments is invalid
  877. @raise Access_error: user_id doesn't have owner-level notebook access to send an invite or
  878. doesn't have a rate plan supporting notebook collaboration
  879. """
  880. if len( email_addresses ) < 5:
  881. raise Invite_error( u"Please enter at least one valid email address." )
  882. if len( email_addresses ) > 5000:
  883. raise Invite_error( u"Please enter fewer email addresses." )
  884. notebook = self.load_notebook( user_id, notebook_id, read_write = True, owner = True )
  885. if not notebook:
  886. raise Access_error()
  887. # except for viewer-only invites, this feature requires a rate plan above basic
  888. user = self.__database.load( User, user_id )
  889. plan = self.__rate_plans[ user.rate_plan ]
  890. if user is None or user.username is None or ( plan[ u"notebook_collaboration" ] != True and access != u"viewer" ):
  891. raise Access_error()
  892. if access == u"collaborator":
  893. read_write = True
  894. owner = False
  895. elif access == u"viewer":
  896. read_write = False
  897. owner = False
  898. elif access == u"owner":
  899. read_write = True
  900. owner = True
  901. else:
  902. raise Access_error()
  903. # parse email_addresses string into individual email addresses
  904. email_addresses_list = set()
  905. for piece in WHITESPACE_OR_COMMA_PATTERN.split( email_addresses ):
  906. for match in EMBEDDED_EMAIL_ADDRESS_PATTERN.finditer( piece ):
  907. email_addresses_list.add( match.groups( 0 )[ 0 ] )
  908. email_count = len( email_addresses_list )
  909. if email_count == 0:
  910. raise Invite_error( u"Please enter at least one valid email address." )
  911. import smtplib
  912. from email import Message, Charset
  913. for email_address in email_addresses_list:
  914. # record the sending of this invite email
  915. invite_id = self.__database.next_id( Invite, commit = False )
  916. invite = Invite.create( invite_id, user_id, notebook_id, email_address, read_write, owner )
  917. self.__database.save( invite, commit = False )
  918. # update any invitations for this notebook already sent to the same email address
  919. similar_invites = self.__database.select_many( Invite, invite.sql_load_similar() )
  920. for similar in similar_invites:
  921. similar.read_write = read_write
  922. similar.owner = owner
  923. self.__database.save( similar, commit = False )
  924. # if the invite is already redeemed, then update the relevant entry in the user_notebook
  925. # access table as well
  926. if similar.redeemed_user_id is not None:
  927. redeemed_user = self.__database.load( User, similar.redeemed_user_id )
  928. if redeemed_user:
  929. self.__database.execute( redeemed_user.sql_update_access( notebook_id, read_write, owner ) )
  930. notebook = self.__database.load( Notebook, notebook_id )
  931. if notebook:
  932. self.__database.execute( redeemed_user.sql_update_access( notebook.trash_id, read_write, owner ) )
  933. # create an email message with a unique invitation link
  934. notebook_name = notebook.name.strip().replace( "\n", " " ).replace( "\r", " " )
  935. message = Message.Message()
  936. message[ u"From" ] = user.email_address or u"Luminotes personal wiki <%s>" % self.__support_email
  937. if user.email_address:
  938. message[ u"Sender" ] = u"Luminotes personal wiki <%s>" % self.__support_email
  939. message[ u"To" ] = email_address
  940. message[ u"Subject" ] = notebook_name
  941. payload = \
  942. u"I've shared a wiki with you called \"%s\".\n" % notebook_name + \
  943. u"Please visit the following link to view it online:\n\n" + \
  944. u"%s/i/%s\n\n" % ( self.__https_url or self.__http_url, invite.object_id )
  945. # try representing the payload as plain 7-bit ASCII for greatest compatibility
  946. try:
  947. str( notebook_name )
  948. message.set_payload( payload )
  949. # if that doesn't work, encode the payload as UTF-8 instead
  950. except UnicodeEncodeError:
  951. message.set_payload( payload.encode( "utf-8" ) )
  952. charset = Charset.Charset( "utf-8" )
  953. charset.body_encoding = Charset.QP
  954. message.set_charset( charset )
  955. # send the message out through localhost's smtp server
  956. server = smtplib.SMTP()
  957. server.connect()
  958. server.sendmail( message[ u"From" ], [ email_address ], message.as_string() )
  959. server.quit()
  960. self.__database.commit()
  961. invites = self.__database.select_many( Invite, Invite.sql_load_notebook_invites( notebook_id ) )
  962. if email_count == 1:
  963. return dict(
  964. message = u"An invitation has been sent. The person you invited will receive an invite link (shown below) by email. Feel free to copy and paste the invite link to them yourself.",
  965. invites = invites,
  966. )
  967. else:
  968. return dict(
  969. message = u"%s invitations have been sent. The people you invited will each receive an invite link (shown below) by email. Feel free to copy and paste the invite links to them yourself." % email_count,
  970. invites = invites,
  971. )
  972. @expose( view = Json )
  973. @end_transaction
  974. @grab_user_id
  975. @validate(
  976. notebook_id = Valid_id(),
  977. invite_id = Valid_id(),
  978. user_id = Valid_id( none_okay = True ),
  979. )
  980. def revoke_invite( self, notebook_id, invite_id, user_id = None ):
  981. """
  982. Revoke the invite's access to the given notebook.
  983. @type notebook_id: unicode
  984. @param notebook_id: id of the notebook that the invitation is for
  985. @type invite_id: unicode
  986. @param invite_id: id of the invite to revoke
  987. @type user_id: unicode
  988. @param user_id: id of current logged-in user (if any), determined by @grab_user_id
  989. @rtype: json dict
  990. @return: { 'message': message, 'invites': invites }
  991. @raise Validation_error: one of the arguments is invalid
  992. @raise Access_error: user_id doesn't have owner-level notebook access to revoke an invite
  993. """
  994. notebook = self.load_notebook( user_id, notebook_id, read_write = True, owner = True )
  995. if not notebook:
  996. raise Access_error()
  997. invite = self.__database.load( Invite, invite_id )
  998. if not invite or not invite.email_address or invite.notebook_id != notebook_id:
  999. raise Access_error()
  1000. self.__database.execute(
  1001. User.sql_revoke_invite_access( notebook_id, notebook.trash_id, invite.email_address ),
  1002. commit = False,
  1003. )
  1004. self.__database.execute( invite.sql_revoke_invites(), commit = False )
  1005. self.__database.commit()
  1006. invites = self.__database.select_many( Invite, Invite.sql_load_notebook_invites( notebook_id ) )
  1007. return dict(
  1008. message = u"Notebook access for %s has been revoked." % invite.email_address,
  1009. invites = invites,
  1010. )
  1011. @expose( view = Main_page )
  1012. @end_transaction
  1013. @grab_user_id
  1014. @validate(
  1015. invite_id = Valid_id(),
  1016. user_id = Valid_id( none_okay = True ),
  1017. )
  1018. def redeem_invite( self, invite_id, user_id = None ):
  1019. """
  1020. Begin the process of redeeming a notebook invite.
  1021. @type invite_id: unicode
  1022. @param invite_id: id of invite to redeem
  1023. @type user_id: unicode
  1024. @param user_id: id of current logged-in user (if any), determined by @grab_user_id
  1025. @rtype: unicode
  1026. @return: rendered HTML page
  1027. @raise Validation_error: one of the arguments is invalid
  1028. @raise Invite_error: an error occured when redeeming the invite
  1029. """
  1030. invite = self.__database.load( Invite, invite_id )
  1031. if not invite:
  1032. raise Invite_error( "That invite is unknown. Please make sure that you typed the address correctly." )
  1033. if user_id is not None:
  1034. # if the user is logged in but the invite is unredeemed, redeem it and redirect to the notebook
  1035. if invite.redeemed_user_id is None:
  1036. self.convert_invite_to_access( invite, user_id )
  1037. return dict( redirect = u"/notebooks/%s" % invite.notebook_id )
  1038. # if the user is logged in and has already redeemed this invite, then just redirect to the notebook
  1039. if invite.redeemed_user_id == user_id:
  1040. return dict( redirect = u"/notebooks/%s" % invite.notebook_id )
  1041. else:
  1042. raise Invite_error( u"That invite has already been used by someone else." )
  1043. if invite.redeemed_user_id:
  1044. raise Invite_error( u"That invite has already been used. If you were the one who used it, then simply <a href=\"/login\">login</a> to your account." )
  1045. notebook = self.__database.load( Notebook, invite.notebook_id )
  1046. if not notebook:
  1047. raise Invite_error( "That notebook you've been invited to is unknown." )
  1048. anonymous = self.__database.select_one( User, User.sql_load_by_username( u"anonymous" ), use_cache = True )
  1049. if anonymous:
  1050. main_notebook = self.__database.select_one( Notebook, anonymous.sql_load_notebooks( undeleted_only = True ) )
  1051. invite_notebook = self.__database.load( Notebook, invite.notebook_id )
  1052. if not anonymous or not main_notebook or not invite_notebook:
  1053. raise Password_reset_error( "There was an error when redeeming your invite. Please contact %s." % self.__support_email )
  1054. # give the user the option to sign up or login in order to redeem the invite
  1055. result = self.current( anonymous.object_id )
  1056. result[ "notebook" ] = main_notebook
  1057. result[ "startup_notes" ] = self.__database.select_many( Note, main_notebook.sql_load_startup_notes() )
  1058. result[ "total_notes_count" ] = self.__database.select_one( int, main_notebook.sql_count_notes(), use_cache = True )
  1059. result[ "note_read_write" ] = False
  1060. result[ "notes" ] = [ Note.create(
  1061. object_id = u"redeem_invite",
  1062. contents = unicode( Redeem_invite_note( invite, invite_notebook ) ),
  1063. notebook_id = main_notebook.object_id,
  1064. ) ]
  1065. result[ "invites" ] = []
  1066. return result
  1067. def convert_invite_to_access( self, invite, user_id ):
  1068. """
  1069. Grant the given user access to the notebook specified in the invite, and mark that invite as
  1070. redeemed.
  1071. @type invite: model.Invite
  1072. @param invite: invite to convert to notebook access
  1073. @type user_id: unicode
  1074. @param user_id: id of current logged-in user (if any), determined by @grab_user_id
  1075. @raise Invite_error: an error occured when redeeming the invite
  1076. """
  1077. # prevent a user from redeeming their own invite
  1078. if invite.from_user_id == user_id:
  1079. return
  1080. user = self.__database.load( User, user_id )
  1081. notebook = self.__database.load( Notebook, invite.notebook_id )
  1082. if not user or not notebook:
  1083. raise Invite_error( "There was an error when redeeming your invite. Please contact %s." % self.__support_email )
  1084. # if the user doesn't already have access to this notebook, then grant access
  1085. if not self.__database.select_one( bool, user.sql_has_access( notebook.object_id ) ):
  1086. rank = self.__database.select_one( float, user.sql_highest_notebook_rank() ) + 1
  1087. self.__database.execute( user.sql_save_notebook( notebook.object_id, invite.read_write, invite.owner, rank = rank ), commit = False )
  1088. # the same goes for the trash notebook
  1089. if not self.__database.select_one( bool, user.sql_has_access( notebook.trash_id ) ):
  1090. self.__database.execute( user.sql_save_notebook( notebook.trash_id, invite.read_write, invite.owner ), commit = False )
  1091. invite.redeemed_user_id = user_id
  1092. self.__database.save( invite, commit = False )
  1093. self.__database.commit()
  1094. #PAYPAL_URL = u"https://www.sandbox.paypal.com/cgi-bin/webscr"
  1095. PAYPAL_URL = u"https://www.paypal.com/cgi-bin/webscr"
  1096. @expose( view = Blank_page )
  1097. @end_transaction
  1098. def paypal_notify( self, **params ):
  1099. """
  1100. Notify Luminotes of payments, subscriptions, cancellations, refunds, etc.
  1101. This method is responsible for validating the request, POSTing back to
  1102. PayPal to make sure the request is valid, and then updating the user's
  1103. record in the database with their new rate plan. paypal_notify() is
  1104. invoked by PayPal itself.
  1105. """
  1106. # check that payment_status is Completed
  1107. payment_status = params.get( u"payment_status" )
  1108. if payment_status == u"Refunded":
  1109. return dict() # for now, ignore refunds and let paypal handle them
  1110. if payment_status and payment_status != u"Completed":
  1111. raise Payment_error( u"payment_status is not Completed", params )
  1112. # TODO: check that txn_id is not a duplicate
  1113. # check that receiver_email is mine
  1114. if params.get( u"receiver_email" ) != self.__payment_email:
  1115. raise Payment_error( u"incorrect receiver_email", params )
  1116. # verify mc_currency
  1117. if params.get( u"mc_currency" ) != u"USD":
  1118. raise Payment_error( u"unsupported mc_currency", params )
  1119. # verify item_number
  1120. item_number = params.get( u"item_number" )
  1121. if item_number == None or item_number == u"":
  1122. return dict() # ignore this transaction if there's no item number
  1123. try:
  1124. int( item_number )
  1125. except ValueError:
  1126. raise Payment_error( u"invalid item_number", params )
  1127. product = None
  1128. for potential_product in self.__download_products:
  1129. if unicode( item_number ) == potential_product.get( u"item_number" ):
  1130. product = potential_product
  1131. if product:
  1132. self.__paypal_notify_download( params, product, unicode( item_number ) )
  1133. else:
  1134. plan_index = int( item_number )
  1135. try:
  1136. rate_plan = self.__rate_plans[ plan_index ]
  1137. except IndexError:
  1138. raise Payment_error( u"invalid item_number", params )
  1139. self.__paypal_notify_subscribe( params, rate_plan, plan_index )
  1140. return dict()
  1141. TRANSACTION_ID_PATTERN = re.compile( u"^[a-zA-Z0-9]+$" )
  1142. @staticmethod
  1143. def urlencode( params ):
  1144. # unicode-safe wrapper for urllib.urlencode()
  1145. if isinstance( params, dict ):
  1146. params = params.items()
  1147. return urllib.urlencode(
  1148. [ ( key, isinstance( value, unicode ) and value.encode( "utf-8" ) or value )
  1149. for ( key, value ) in params ]
  1150. )
  1151. def __paypal_notify_download( self, params, product, item_number ):
  1152. # verify that quantity * the expected fee == mc_gross
  1153. fee = float( product[ u"fee" ] )
  1154. try:
  1155. mc_gross = float( params.get( u"mc_gross" ) )
  1156. if not mc_gross: raise ValueError()
  1157. except ( TypeError, ValueError ):
  1158. raise Payment_error( u"invalid mc_gross", params )
  1159. try:
  1160. quantity = float( params.get( u"quantity" ) )
  1161. if not quantity: raise ValueError()
  1162. except ( TypeError, ValueError ):
  1163. raise Payment_error( u"invalid quantity", params )
  1164. if quantity * fee != mc_gross:
  1165. raise Payment_error( u"invalid mc_gross", params )
  1166. # verify item_name
  1167. item_name = params.get( u"item_name" )
  1168. if item_name and product[ u"name" ].lower() not in item_name.lower():
  1169. raise Payment_error( u"invalid item_name", params )
  1170. params[ u"cmd" ] = u"_notify-validate"
  1171. encoded_params = self.urlencode( params )
  1172. # verify txn_type
  1173. txn_type = params.get( u"txn_type" )
  1174. if txn_type and txn_type != u"web_accept":
  1175. raise Payment_error( u"invalid txn_type", params )
  1176. # verify txn_id
  1177. txn_id = params.get( u"txn_id" )
  1178. if not self.TRANSACTION_ID_PATTERN.search( txn_id ):
  1179. raise Payment_error( u"invalid txn_id", params )
  1180. # ask paypal to verify the request
  1181. request = urllib2.Request( self.PAYPAL_URL )
  1182. request.add_header( u"Content-type", u"application/x-www-form-urlencoded" )
  1183. request_file = urllib2.urlopen( self.PAYPAL_URL, encoded_params )
  1184. result = request_file.read()
  1185. if result != u"VERIFIED":
  1186. raise Payment_error( result, params )
  1187. # update the database with a record of the transaction, thereby giving the user access to the
  1188. # download
  1189. download_access_id = self.__database.next_id( Download_access, commit = False )
  1190. download_access = Download_access.create( download_access_id, item_number, txn_id )
  1191. self.__database.save( download_access, commit = False )
  1192. self.__database.commit()
  1193. # using the reported payer email, send the user an email with a download link
  1194. email_address = params.get( u"payer_email" )
  1195. if not email_address:
  1196. return
  1197. # create an email message with a unique invitation link
  1198. message = Message.Message()
  1199. message[ u"From" ] = u"Luminotes personal wiki <%s>" % self.__support_email
  1200. message[ u"To" ] = email_address
  1201. message[ u"Subject" ] = u"Luminotes Desktop download"
  1202. payload = \
  1203. u"Thank you for purchasing Luminotes Desktop!\n\n" + \
  1204. u"To download the installer, please follow this link:\n\n" + \
  1205. u"%s/d/%s\n\n" % ( self.__https_url or self.__http_url, download_access_id ) + \
  1206. u"You can use this link anytime to download Luminotes Desktop or upgrade\n" + \
  1207. u"to new versions as they are released. So you should probably keep the\n" + \
  1208. u"link around.\n\n" + \
  1209. u"If you have any questions, please email support@luminotes.com\n\n" + \
  1210. u"Enjoy!"
  1211. message.set_payload( payload )
  1212. # send the message out through localhost's smtp server
  1213. server = smtplib.SMTP()
  1214. server.connect()
  1215. server.sendmail( message[ u"From" ], [ email_address ], message.as_string() )
  1216. server.quit()
  1217. def __paypal_notify_subscribe( self, params, rate_plan, plan_index ):
  1218. # verify mc_gross
  1219. fee = u"%0.2f" % rate_plan[ u"fee" ]
  1220. yearly_fee = u"%0.2f" % rate_plan[ u"yearly_fee" ]
  1221. mc_gross = params.get( u"mc_gross" )
  1222. if mc_gross and mc_gross not in ( fee, yearly_fee ):
  1223. raise Payment_error( u"invalid mc_gross", params )
  1224. # verify mc_amount1 (free 30-day trial)
  1225. mc_amount1 = params.get( u"mc_amount1" )
  1226. if mc_amount1 and mc_amount1 != "0.00":
  1227. raise Payment_error( u"invalid mc_amount1", params )
  1228. # verify mc_amount3 (actual payment)
  1229. mc_amount3 = params.get( u"mc_amount3" )
  1230. if mc_amount3 and mc_amount3 not in ( fee, yearly_fee ):
  1231. raise Payment_error( u"invalid mc_amount3", params )
  1232. # verify item_name
  1233. item_name = params.get( u"item_name" )
  1234. if item_name and item_name.lower() != u"luminotes " + rate_plan[ u"name" ].lower():
  1235. raise Payment_error( u"invalid item_name", params )
  1236. # verify period1 (free 30-day trial)
  1237. period1 = params.get( u"period1" )
  1238. if period1 and period1 != "30 D":
  1239. raise Payment_error( u"invalid period1", params )
  1240. # verify period2 (should not be present)
  1241. if params.get( u"period2" ):
  1242. raise Payment_error( u"invalid period2", params )
  1243. # verify period3
  1244. period3 = params.get( u"period3" )
  1245. if mc_amount3 == yearly_fee:
  1246. if period3 and period3 != u"1 Y": # one-year subscription
  1247. raise Payment_error( u"invalid period3", params )
  1248. else:
  1249. if period3 and period3 != u"1 M": # one-month subscription
  1250. raise Payment_error( u"invalid period3", params )
  1251. params[ u"cmd" ] = u"_notify-validate"
  1252. encoded_params = self.urlencode( params )
  1253. # ask paypal to verify the request
  1254. request = urllib2.Request( self.PAYPAL_URL )
  1255. request.add_header( u"Content-type", u"application/x-www-form-urlencoded" )
  1256. request_file = urllib2.urlopen( self.PAYPAL_URL, encoded_params )
  1257. result = request_file.read()
  1258. if result != u"VERIFIED":
  1259. raise Payment_error( result, params )
  1260. # update the database based on the type of transaction
  1261. txn_type = params.get( u"txn_type" )
  1262. user_id = params.get( u"custom", u"" )
  1263. try:
  1264. user_id = Valid_id()( user_id )
  1265. except ValueError:
  1266. raise Payment_error( u"invalid custom", params )
  1267. user = self.__database.load( User, user_id )
  1268. if not user:
  1269. raise Payment_error( u"unknown custom", params )
  1270. if txn_type in ( u"subscr_signup", u"subscr_modify" ):
  1271. if params.get( u"recurring" ) != u"1":
  1272. raise Payment_error( u"invalid recurring", params )
  1273. user.rate_plan = plan_index
  1274. self.__database.save( user, commit = False )
  1275. self.update_groups( user )
  1276. self.__database.commit()
  1277. elif txn_type == u"subscr_cancel":
  1278. pass # HACK: for now, ignore cancellations
  1279. # user.rate_plan = 0 # return the user to the free account level
  1280. # self.__database.save( user, commit = False )
  1281. # self.update_groups( user )
  1282. # self.__database.commit()
  1283. elif txn_type in ( u"subscr_payment", u"subscr_failed", "subscr_eot" ):
  1284. pass # for now, ignore payments and let paypal handle them
  1285. else:
  1286. raise Payment_error( "unknown txn_type", params )
  1287. def update_groups( self, user ):
  1288. """
  1289. Update a user's group membership as a result of a rate plan change. This method does not commit
  1290. the current database transaction.
  1291. """
  1292. rate_plan = self.__rate_plans[ user.rate_plan ]
  1293. # if the user has a rate plan with admin capabilities
  1294. if rate_plan.get( u"user_admin" ) is True:
  1295. has_an_admin_group = False
  1296. groups = self.__database.select_many( Group, user.sql_load_groups() )
  1297. # determine whether the user is the admin of at least one group
  1298. for group in groups:
  1299. if group.admin is False: continue
  1300. has_an_admin_group = True
  1301. # set all users in this group to the same rate plan as the admin
  1302. group_users = self.__database.select_many( User, group.sql_load_users() )
  1303. for group_user in group_users:
  1304. group_user.rate_plan = user.rate_plan
  1305. self.__database.save( group_user )
  1306. # if the user is not an admin of any group, create one for them and make them the admin
  1307. if has_an_admin_group is False:
  1308. group_id = self.__database.next_id( Group, commit = False )
  1309. group = Group.create( group_id, name = u"my group", admin = True )
  1310. self.__database.save( group, commit = False )
  1311. self.__database.execute( user.sql_save_group( group_id, admin = True ), commit = False )
  1312. return
  1313. # otherwise, downgrade the user's group admin access to normal group membership
  1314. groups = self.__database.select_many( Group, user.sql_load_groups() )
  1315. for group in groups:
  1316. if group.admin is False: continue
  1317. self.__database.execute( user.sql_update_group_admin( group.object_id, admin = False ), commit = False )
  1318. # also return all users in this group to the free account level
  1319. group_users = self.__database.select_many( User, group.sql_load_users() )
  1320. for group_user in group_users:
  1321. group_user.rate_plan = 0
  1322. self.__database.save( group_user )
  1323. @expose( view = Main_page )
  1324. @end_transaction
  1325. @grab_user_id
  1326. def thanks( self, **params ):
  1327. """
  1328. Provide the information necessary to display the subscription thanks page.
  1329. """
  1330. anonymous = self.__database.select_one( User, User.sql_load_by_username( u"anonymous" ), use_cache = True )
  1331. if anonymous:
  1332. main_notebook = self.__database.select_one( Notebook, anonymous.sql_load_notebooks( undeleted_only = True ) )
  1333. else:
  1334. main_notebook = None
  1335. result = self.current( params.get( u"user_id" ) )
  1336. rate_plan = params.get( u"item_number", "" )
  1337. try:
  1338. rate_plan = int( rate_plan )
  1339. except ValueError:
  1340. rate_plan = None
  1341. retry_count = params.get( u"retry_count", "" )
  1342. try:
  1343. retry_count = int( retry_count )
  1344. except ValueError:
  1345. retry_count = None
  1346. # if there's no rate plan or we've retried too many times, give up and display an error
  1347. RETRY_TIMEOUT = 15
  1348. if retry_count > RETRY_TIMEOUT:
  1349. note = Thanks_error_note()
  1350. # if the rate plan of the subscription matches the user's current rate plan, success
  1351. elif rate_plan == result[ u"user" ].rate_plan:
  1352. note = Thanks_note( self.__rate_plans[ rate_plan ][ u"name" ].capitalize() )
  1353. result[ "conversion" ] = "subscribe_%s" % rate_plan
  1354. # if a rate plan is given, display an auto-reloading "processing..." page
  1355. elif rate_plan is not None:
  1356. note = Processing_note( rate_plan, retry_count )
  1357. # otherwise, assume that this is a free trial and default to a generic thanks page
  1358. else:
  1359. note = Thanks_note()
  1360. result[ "notebook" ] = main_notebook
  1361. result[ "startup_notes" ] = self.__database.select_many( Note, main_notebook.sql_load_startup_notes() )
  1362. result[ "total_notes_count" ] = self.__database.select_one( Note, main_notebook.sql_count_notes(), use_cache = True )
  1363. result[ "note_read_write" ] = False
  1364. result[ "notes" ] = [ Note.create(
  1365. object_id = u"thanks",
  1366. contents = unicode( note ),
  1367. notebook_id = main_notebook.object_id,
  1368. ) ]
  1369. result[ "invites" ] = []
  1370. return result
  1371. def rate_plan( self, plan_index ):
  1372. return self.__rate_plans[ plan_index ]
  1373. @expose( view = Main_page )
  1374. @end_transaction
  1375. @grab_user_id
  1376. def thanks_download( self, **params ):
  1377. """
  1378. Provide the information necessary to display the download thanks page, including a product
  1379. download link. This information can be accessed with either a tx (transaction id) or a download
  1380. access_id.
  1381. """
  1382. # if a valid tx is provided, redirect to this page with the corresponding access_id.
  1383. # that way, if the user bookmarks the page, they'll bookmark it with the access_id rather
  1384. # than the tx
  1385. tx = params.get( u"tx" )
  1386. if tx:
  1387. if not self.TRANSACTION_ID_PATTERN.search( tx ):
  1388. raise Payment_error( u"invalid tx", params )
  1389. download_access = self.__database.select_one( Download_access, Download_access.sql_load_by_transaction_id( tx ) )
  1390. if download_access:
  1391. return dict(
  1392. redirect = u"/users/thanks_download?access_id=%s" % download_access.object_id
  1393. )
  1394. download_access_id = params.get( u"access_id" )
  1395. download_url = None
  1396. item_number = None
  1397. if download_access_id:
  1398. try:
  1399. Valid_id()( download_access_id )
  1400. except ValueError:
  1401. raise Payment_error( u"invalid access_id", params )
  1402. download_access = self.__database.load( Download_access, download_access_id )
  1403. if download_access:
  1404. download_url = u"%s/files/download_product?access_id=%s" % \
  1405. ( self.__https_url or self.__http_url, download_access_id )
  1406. item_number = download_access.item_number
  1407. if not tx and not download_access_id:
  1408. raise Payment_error( u"either tx or access_id required", params )
  1409. anonymous = self.__database.select_one( User, User.sql_load_by_username( u"anonymous" ), use_cache = True )
  1410. if anonymous:
  1411. main_notebook = self.__database.select_one( Notebook, anonymous.sql_load_notebooks( undeleted_only = True ) )
  1412. else:
  1413. main_notebook = None
  1414. result = self.current( params.get( u"user_id" ) )
  1415. retry_count = params.get( u"retry_count", "" )
  1416. try:
  1417. retry_count = int( retry_count )
  1418. except ValueError:
  1419. retry_count = None
  1420. # if there's no download access or we've retried too many times, give up and display an error
  1421. RETRY_TIMEOUT = 15
  1422. if download_url is None and retry_count > RETRY_TIMEOUT:
  1423. note = Thanks_download_error_note()
  1424. # if the rate plan of the subscription matches the user's current rate plan, success
  1425. elif download_url:
  1426. note = Thanks_download_note( download_url )
  1427. result[ "conversion" ] = "download_%s" % item_number
  1428. # otherwise, display an auto-reloading "processing..." page
  1429. else:
  1430. note = Processing_download_note( download_access_id, tx, retry_count )
  1431. result[ "notebook" ] = main_notebook
  1432. result[ "startup_notes" ] = self.__database.select_many( Note, main_notebook.sql_load_startup_notes() )
  1433. result[ "total_notes_count" ] = self.__database.select_one( Note, main_notebook.sql_count_notes(), use_cache = True )
  1434. result[ "note_read_write" ] = False
  1435. result[ "notes" ] = [ Note.create(
  1436. object_id = u"thanks",
  1437. contents = unicode( note ),
  1438. notebook_id = main_notebook.object_id,
  1439. ) ]
  1440. result[ "invites" ] = []
  1441. return result
  1442. @expose( view = Json )
  1443. @end_transaction
  1444. @grab_user_id
  1445. @validate(
  1446. email_address = ( Valid_string( min = 0, max = 60 ) ),
  1447. settings_button = unicode,
  1448. user_id = Valid_id( none_okay = True ),
  1449. )
  1450. def update_settings( self, email_address, settings_button, user_id ):
  1451. """
  1452. Update the settings for a particular user.
  1453. @type email_address: unicode
  1454. @param email_address: new email address
  1455. @type settings_button: unicode
  1456. @param settings_button: ignored
  1457. @type user_id: unicode
  1458. @param user_id: id of current logged-in user (if any), determined by @grab_user_id
  1459. @rtype: json dict
  1460. @return: { "email_address": new_email_address }
  1461. @raise Validation_error: one of the arguments is invalid
  1462. @raise Access_error: the given user id is unknown
  1463. """
  1464. if len( email_address ) > 0:
  1465. try:
  1466. email_address = valid_email_address( email_address )
  1467. except ValueError:
  1468. raise Validation_error( "email_address", email_address, valid_email_address )
  1469. else:
  1470. email_address = None
  1471. user = self.__database.load( User, user_id )
  1472. if not user:
  1473. raise Access_error()
  1474. if email_address != user.email_address:
  1475. user.email_address = email_address
  1476. self.__database.save( user )
  1477. return dict(
  1478. email_address = email_address,
  1479. )