/ app.py
app.py
  1  import os
  2  import json
  3  import csv
  4  from collections import defaultdict
  5  from datetime import datetime, timedelta
  6  
  7  from elements.core import Bottle, chevron_template, static_file
  8  from elements.core import request, redirect, abort, TEMPLATE_PATH
  9  from elements.core import Timestamp, Config, Element
 10  from elements.posts import Post, Posts
 11  from elements.media import Picture
 12  from elements.accounts import Account, Accounts
 13  from elements.responses import Response, Responses
 14  from elements.notifications import notify
 15  from elements.notes import Note, Notes
 16  from elements.bookings import Booking, Bookings
 17  from elements.places import Place, Places
 18  
 19  from elements.accounts.utilities import lookup
 20  from elements.sessions.utilities import session
 21  from elements.api import login, logout, endpoint
 22  from elements.api.accounts import register
 23  from elements.api.responses import rsvp, unrsvp
 24  from elements.api.posts import post
 25  
 26  ##################################################
 27  ### Application Setup ############################
 28  ##################################################
 29  
 30  app = Bottle()
 31  c = Config.load()
 32  members = Accounts.all()
 33  posts = Posts.load('posts')
 34  
 35  # The tag is what defines this application in
 36  # the backend of the system - routes, redirects,
 37  # and scripted filesystem management.
 38  tag = 'cafe'
 39  path = f'/{tag}/'
 40  
 41  app.data = {
 42          'name': 'Credenso',
 43          'host': c.host,
 44          'static': c.static_path + f'{tag}/',
 45          'path': path,
 46          'root': path,
 47          'public': True,
 48          'session': None
 49  }
 50  
 51  # This value refers to the folder that this file is stored in.
 52  # We use it to find the local 'views/' folder and append it
 53  # to the template path. It is important to keep the views in
 54  # a unique subfolder so they don't conflict with any other
 55  # files on the template path (there are many different
 56  # index.html files on a solar system!)
 57  here = os.path.dirname(os.path.realpath(__file__))
 58  TEMPLATE_PATH.append(f'{here}/views')
 59  
 60  def defaults(new_data={}):
 61      # Get any flashed messages.
 62      flashes = request.app.get_flashed_messages()
 63  
 64      # The 'context' is the object being acted on
 65      context, action = request.urlparts.path.rsplit('/', 1)
 66  
 67      # We need to update the data, but overwrite
 68      # the app data without changing it.
 69      data = { 
 70          **app.data, 
 71          **new_data,
 72          'context': context + '/',
 73          'flashes': flashes,
 74          'markers': Places.all().render()
 75      }
 76  
 77      return {
 78              'partials_path': os.path.join('planets', tag, 'views', tag, 'components', ''),
 79              'partials_ext': 'mo',
 80              'data': data
 81      }
 82  
 83  ##################################################
 84  ### API Section ##################################
 85  ##################################################
 86  
 87  app.endpoint('endpoint', endpoint)
 88  app.endpoint('post', post)
 89  app.endpoint('rsvp', rsvp)
 90  app.endpoint('unreserve', unrsvp)
 91  
 92  
 93  def booking(path):
 94      author = request.forms.pop('author')
 95      b = Booking(**request.forms, author=members.find(author))
 96      img = request.files.get('image')
 97      if img and img.filename != "empty":
 98          b.attach(img)
 99      b.save()
100  
101      return redirect(app.data.get('path') + 'admin/')
102  
103  def duplicate(path):
104      b = Booking.load(path)
105      b.duplicate()
106  
107      return redirect(app.data.get('path') + 'admin/')
108  
109  app.endpoint('duplicate', duplicate)
110  app.endpoint('booking', booking)
111  
112  # This may be refined more in the future, but for now
113  # endpoint() works as a constructor for a basic API that
114  # works with a designated Element type.
115  def member_endpoint(path):
116      return endpoint(path, element=Account)
117  
118  app.endpoint('member', member_endpoint)
119  
120  def bread_order(path):
121      f = request.forms
122      honey = f.get('honey')
123      if honey != '':
124          return redirect(request.environ.get('HTTP_REFERER'))
125  
126      f['d'] = f.get('author')
127      note = Element(f)
128      note.save(path="bread_orders")
129  
130      baker = lookup('z')
131      notify(baker, content=f'New bread order - {f.get("author")}', link=f'/{tag}/bread/')
132  
133      return redirect(f'/{tag}/thanks/')
134  
135  app.endpoint('order', bread_order)
136  
137  def comments(path):
138      s = session()
139  
140      if request.method == "GET":
141          comments = Notes.load(os.path.join('notes', path))
142          replies = defaultdict(list)
143          top_level = []
144  
145          last = int(request.query.get('last') or 0)
146  
147          for comment in comments.content:
148              if s and (comment.author.name == s.member.name or "admin" in s.member.role):
149                  comment.delete = True
150              replying_to = comment.tags.getfirst('e')
151              if replying_to is not None:
152                  replies[replying_to].append(comment)
153              else:
154                  top_level.append(comment)
155  
156          for comment in top_level:
157              comment.replies = replies[comment.clean_path]
158  
159              # We need this so we can refer to it in the template
160              comment.reply_target = comment.clean_path
161              comment.comment_name = comment.name
162  
163          if last:
164              top_level = top_level[:-last]
165          
166          return chevron_template('cafe/components/comments.mo', **defaults({ 'comments': top_level, 'session': s, 'post_path': path }))
167                 
168      if request.method == "POST":
169          # Unauthenticated comments are unallowed
170          if s is None:
171              return abort(401)
172          n = Note(**request.forms)
173          e = Note.load(path.strip('/'))
174          n.save(path=os.path.join('notes', path))
175  
176          msg = f'{s.member.display_name} replied to your post.'
177          link = f'{app.data["path"]}posts/{e.author.name}/{e.name}/#{n.name}'
178          notify(e.author, content=msg, link=link, notifier=s.member.name)
179  
180          return chevron_template('cafe/components/comments.mo', **defaults({ 'comments': [n] }))
181  
182  app.endpoint('comments', comments)
183  
184  def reply(path):
185      s = session()
186      if s is None:
187          return abort(401)
188  
189      # Return reply box
190      if request.method == "GET":
191          target = request.query.get('target')
192          
193          return chevron_template('cafe/components/new_reply.mo', **defaults({ 'post_path': path, 'target': target, 'session': s }))
194                 
195      if request.method == "POST":
196          n = Note(**request.forms)
197          p = n.save(path=os.path.join('notes', path))
198  
199          post = Post.load(path.strip('/'))
200          e = Note.load(request.forms.get('e'))
201  
202          msg = f'{s.member.display_name} replied to your comment.'
203          link = f'{app.data["path"]}posts/{post.author.name}/{post.name}/#{n.name}'
204          notify(e.author, content=msg, link=link, notifier=s.member.name)
205          return chevron_template('cafe/components/comments.mo', **defaults({ 'session': s, 'comments': [n] }))
206  
207  app.endpoint('reply', reply)
208  
209  @app.delete('/<path:path>')
210  def delete(path):
211      e = Note.load(path)
212      # session validation
213      e.unsave()
214      print('e', e.flatten())
215  
216  # When deployed, this path is managed by NGINX
217  @app.route('/static/<filepath:path>')
218  def static(filepath):
219      return static_file(filepath, root=f'{os.getcwd()}/static')
220  
221  ##################################################
222  ### HTMX Fragments ###############################
223  ##################################################
224  def events(path):
225      bookings = Bookings.all()
226  
227      places = Places.all()
228  
229      data = {
230          'bookings': bookings.content,
231          'members': members.content,
232          'places': places.content
233      }
234  
235      template_path = os.path.join(tag, 'components', 'admin', 'events.mo')
236      return chevron_template(template_path, **defaults(data));
237  
238  app.endpoint('events', events)
239  
240  def places(path):
241      places = Places.all()
242          
243      data = { 'places': places.content }
244      template_path = os.path.join(tag, 'components', 'admin', 'places.mo')
245      return chevron_template(template_path, **defaults(data));
246  
247  app.endpoint('places', places)
248  
249  def place(path):
250      s = session()
251  
252      if request.method == "POST":
253          p = Place.new(author=s.member, **request.forms)
254      elif request.method == "PATCH":
255          p = Place.load(path)
256          p.update(request.forms)
257      else:
258          p = Place.load(path)
259  
260      image = request.files.get('image')
261      if image:
262          pic = Picture.new(image.file, name=image.filename, author=s.member)
263          pic.save()
264          p.content['properties']['image'] = pic.url
265      p.save()
266  
267  
268      data = {
269          'place': p
270      }
271  
272      template_path = os.path.join(tag, 'components', 'admin', 'place.mo')
273      return chevron_template(template_path, **defaults(data));
274  
275  app.endpoint('place', place)
276  
277  def event_actions(path):
278      s = session()
279      # If no context was supplied, get it from the
280      # request environment (the url it was sent from)
281      if path == "":
282          src = request.environ.get("HTTP_REFERER")
283          host, path = src.split(app.data['root'])
284  
285      booking = Booking.load(path)
286      responses = Responses.all()
287      post_responses = responses.find(path, 'target') or []
288      count = len(post_responses)
289      rsvp = None
290      who = []
291  
292      # Find out who already RSVP'd
293      for r in post_responses:
294          who.append(r.author)
295          if s and r.author.name == s.member.name:
296              rsvp = r
297      #    
298      ## POST to actions -> wave (no unwave)
299      if request.method == "POST":
300          if s is None:
301              return abort(401)
302  
303          if rsvp is None:
304              rsvp = Response.new(author=s.member, target=booking)
305              rsvp.save()
306              who.append(s.member)
307  
308              msg = f'{s.member.display_name} is going to your event.'
309              link = f'{app.data["path"]}{booking.url}'
310              notify(booking.author, content=msg, link=link, notifier=s.member.name)
311              count = count + 1
312          else:
313              rsvp.unsave()
314              rsvp = None
315              who.remove(s.member)
316              count = count - 1
317  
318      return chevron_template('cafe/components/event_actions.mo', **defaults({ 'session': s, 'rsvp': rsvp, 'count': count, 'who': who }))
319  
320  app.endpoint('/event_actions', event_actions)
321  
322  
323  
324  ##################################################
325  ### Frontend Routes ##############################
326  ##################################################
327  
328  @app.route('/')
329  def index():
330      s = session()
331      
332      data = { 'session': s, 'alt_nav': True }
333      places = Places.all()
334  
335      template_path = os.path.join(tag, 'index.html')
336      return chevron_template(template_path, **defaults(data));
337  
338  @app.route('/admin/')
339  def admin():
340      s = session()
341      if s is None:
342          data = { 'redirect_url': request.path }
343          return chevron_template(tag + '/login.html', **defaults(data));
344  
345      elif "admin" not in s.member.role:
346          template_path = os.path.join(tag, 'not_admin.html')
347          return chevron_template(template_path, **defaults({ 'session': s }));
348  
349      bookings = Bookings.all()
350  
351      data = {
352              'session': s,
353              'members': members,
354              'bookings': bookings.content,
355              'label': 'Admin'
356      }
357  
358      template_path = os.path.join(tag, 'admin.html')
359      return chevron_template(template_path, **defaults(data));
360  
361  @app.route('/details/')
362  def details():
363      s = session()
364      
365      data = {
366              'session': s,
367              'label': 'Café'
368      }
369  
370      template_path = os.path.join(tag, 'details.html')
371      return chevron_template(template_path, **defaults(data));
372  
373  @app.route('/accounts/')
374  def hub():
375      s = session()
376  
377      if s is None:
378          data = { 'redirect_url': request.path }
379          return chevron_template(tag + '/login.html', **defaults(data));
380  
381      data = {
382              'session': s,
383              'members': members,
384              'label': 'Hub'
385      }
386  
387      template_path = os.path.join(tag, 'hub.html')
388      return chevron_template(template_path, **defaults(data));
389  
390  @app.route('/accounts/<name>/')
391  def member(name):
392      s = session()
393  
394  #    if s is None:
395  #        data = { 'redirect_url': request.path }
396  #        return chevron_template(tag + '/login.html', **defaults(data));
397  
398      account = members.find(name)
399      if account.profile.skills:
400          skills = account.profile.skills.split(',')
401      else:
402          skills = []
403      place = account
404      bookings = Bookings.all().find(account.name, 'author') or []
405      now = Timestamp()
406      upcoming = []
407      previous = []
408      bookings.sort(key=lambda b: b.start)
409      for b in bookings:
410          if b.start > now:
411              upcoming.append(b)
412          else:
413              previous.append(b)
414  
415      if len(upcoming) == 0:
416          upcoming = None
417  
418      data = {
419              'session': s,
420              'account': account,
421              'skills': skills,
422              'upcoming': upcoming,
423              'previous': reversed(previous),
424              'label': 'Hub'
425      }
426  
427      template_path = os.path.join(tag, 'member.html')
428      return chevron_template(template_path, **defaults(data));
429  
430  @app.route('/bookings/')
431  def events():
432      s = session()
433      bookings = Bookings.all()
434      calendar_data = json.dumps(bookings.render())
435  
436      #if s is None:
437      #    return redirect(path + 'admin/')
438  
439      data = {
440              'session': s,
441              'members': members,
442              'label': 'Events',
443              'calendar_data': calendar_data
444      }
445  
446      template_path = os.path.join(tag, 'events.html')
447      return chevron_template(template_path, **defaults(data));
448  
449  @app.route('/bookings/<name>/')
450  def events(name):
451      s = session()
452      booking_path = 'bookings/' + name
453      booking = Booking.load(booking_path)
454  
455      comments = Notes.load(os.path.join('notes', tag, booking_path))
456      responses = Responses.load(os.path.join('responses', booking_path))
457      
458      actions = event_actions(booking.url.strip('/'))
459      rsvps = []
460      for rsvp in responses:
461          rsvps.append(rsvp.author)
462  
463      #if s is None:
464      #    return redirect(path + 'admin/')
465  
466      data = {
467              'session': s,
468              'label': 'Events',
469              'comments': comments.content,
470              'actions': actions,
471              'booking': booking
472      }
473  
474      template_path = os.path.join(tag, 'event.html')
475      return chevron_template(template_path, **defaults(data));
476  
477  @app.route('/places/<name>/')
478  def places(name):
479      s = session()
480  
481      place = Places.all().find(name)
482      bookings = Bookings.all().find(place, 'location') or []
483      now = Timestamp()
484      upcoming = []
485      for b in bookings:
486          if b.start > now:
487              upcoming.append(b)
488  
489      if len(upcoming) == 0:
490          upcoming = None
491  
492      buttons = []
493  
494      phone = place.properties.get('phone')
495      if phone:
496          buttons.append({'icon': 'fa-phone', 'href': 'tel:' + phone, 'label': 'Phone' })
497  
498      website = place.properties.get('site')
499      if website:
500          buttons.append({'icon': 'fa-globe', 'href': website, 'label': 'Website' })
501  
502      email = place.properties.get('email')
503      if email:
504          buttons.append({'icon': 'fa-envelope', 'href': "mailto:" + email, 'label': 'Mail' })
505  
506      if place.coordinates[0] is not None:
507          map_target = { 
508              'lat': place.coordinates[1], 
509              'lng': place.coordinates[0]
510          }
511      else:
512          map_target = None
513          buttons.append({'icon': 'fa-map-marker-alt', 'href': "#hmm", 'label': 'Open in Map' })
514  
515  
516  
517      data = {
518              'session': s,
519              'label': 'Events',
520              'place': place,
521              'buttons': buttons,
522              'map_target': map_target,
523              'upcoming': upcoming
524      }
525  
526      template_path = os.path.join(tag, 'place.html')
527      return chevron_template(template_path, **defaults(data));
528  
529  @app.route('/login/')
530  def index():
531      s = session()
532      if s is not None:
533          return redirect(path + 'bookings/')
534      
535      data = {}
536      return chevron_template(os.path.join(tag, 'login.html'), **defaults(data));
537  
538  @app.route('/account/')
539  def index():
540      s = session()
541      if s is None:
542          return redirect(path + 'login/')
543      
544      data = { 'session': s }
545      return chevron_template(os.path.join(tag, 'account.html'), **defaults(data));
546  
547  @app.route('/bread/')
548  def index():
549      s = session()
550      now = datetime.today()
551      next_month = (now.replace(day=1) + timedelta(days=32))
552      
553      data = { 'session': s, 'next_month': next_month.strftime('%B %Y') }
554      return chevron_template(os.path.join(tag, 'bread.html'), **defaults(data));
555  
556  @app.route('/tasks/')
557  def index():
558      s = session()
559      
560      data = { 'session': s, 'tasks': [] }
561      return chevron_template(os.path.join(tag, 'tasks.html'), **defaults(data));
562  
563  @app.route('/finances/')
564  def finances():
565      budget_info = defaultdict(list)
566      totals = defaultdict(int)
567      ledger = []
568  
569      with open(f'{here}/credenso_budget.csv', newline='') as csvfile:
570              budget = csv.DictReader(csvfile)
571              for row in budget:
572                  # parse the date
573                  date = datetime.strptime(row['Date'], '%Y-%m-%d')
574                  year = date.strftime('%Y')
575                  day = date.strftime('%b %d, %Y')
576                  description = row['Description']
577                  link = row['Link']
578  
579                  amount = float(row['Amount'])
580  
581                  totals[year] += amount
582  
583                  negative = amount < 0
584  
585                  budget_info[year].append({
586                      'date': date,
587                      'day': day,
588                      'description': description,
589                      'amount': "{:0.2f}".format(abs(amount)),
590                      'negative': negative,
591                      'link': link
592                  })
593  
594              for year in budget_info:
595                  ledger.append({'year': year, 'entries': reversed(budget_info[year]), 'total': "{:0.2f}".format(abs(totals[year])), 'neg': totals[year] < 0 })
596  
597      data = {
598              'name': 'Credenso',
599              'label': 'Finances',
600              'ledger': reversed(ledger)
601      }
602      return chevron_template(os.path.join(tag, 'finances.html'), **defaults(data));
603  
604  @app.route('/<template>/')
605  def generic(template):
606      s = session()
607      
608      data = { 'session': s, 'alt_nav': False }
609  
610      template_path = os.path.join(tag, f'{template}.html')
611      return chevron_template(template_path, **defaults(data));
612  #
613  ## About page
614  #@app.route('/about/')
615  #def about_view():
616  #    s = session()
617  #    data = { 'session': s }
618  #    return chevron_template('vine/about.html', **defaults(data));
619  #
620  #@app.get('/login/')
621  #def login_view():
622  #    if session() is not None:
623  #        return redirect(path)
624  #
625  #    data = { 'redirect_url': request.environ.get('HTTP_REFERER') }
626  #    return chevron_template('vine/login.html', **defaults(data));
627  #
628  #@app.get('/register/')
629  #def register_view():
630  #    data = {
631  #        'accounts': json.dumps(members.dict())
632  #    }
633  #    return chevron_template('vine/register.html', **defaults(data));
634  #
635  #@app.route('/generic/')
636  #def elements():
637  #    return chevron_template('vine/generic.html', **defaults());
638  
639  ##################################################
640  ### Error Handling ###############################
641  ##################################################
642  
643  ## These are the routes that expect to render content for the client
644  #@app.error(404)
645  #def error404(error):
646  #    data = { "error": error }
647  #    return chevron_template('flagship/404.html', **defaults(data));
648  
649  
650  ##################################################
651  ### Running the code #############################
652  ##################################################
653  # Run the server!
654  
655  if __name__ == "__main__":
656      # If there's space, display a logo when the server boots
657      if os.get_terminal_size().columns > 100:
658          with open('static/assets/logo.txt', 'r') as art:
659              for line in art.readlines():
660                  print(line, end="")
661  
662      app.run(host='0.0.0.0', port=1618, debug=True)