/ 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)