diff --git a/Dockerfile b/Dockerfile index 95e5a63..1bbcd72 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.11.0-alpine3.16 +FROM python:3.11.2-alpine3.17 COPY ./app /app WORKDIR /app RUN apk add gcc musl-dev mariadb-connector-c-dev diff --git a/app/__version__ b/app/__version__ index 7f20734..1cc5f65 100644 --- a/app/__version__ +++ b/app/__version__ @@ -1 +1 @@ -1.0.1 \ No newline at end of file +1.1.0 \ No newline at end of file diff --git a/app/app.py b/app/app.py index 79351ef..4893c55 100644 --- a/app/app.py +++ b/app/app.py @@ -1,12 +1,19 @@ import os +import re from flask import Flask, render_template, redirect, url_for, request, flash from flask_migrate import Migrate from werkzeug.security import generate_password_hash, check_password_hash from flask_login import (LoginManager, login_user, login_required, logout_user, current_user) from misc import datetime, date, time, currDay, prevDay, ZoneInfo, currVersion, currCommit from db import (db, Period, Task, Event, User) +from sqlalchemy import inspect from forms import (TaskForm, EventForm, PeriodForm, SignupForm, LoginForm, SettingsForm) -from create_events import createEvents +from log import appLogger + +from worker import runCreateEvents + +# init logger +logger = appLogger('app') basedir = os.path.abspath(os.path.dirname(__file__)) @@ -25,10 +32,6 @@ db.init_app(app) migrate = Migrate() migrate.init_app(app, db) -# Schedule creation of events every hour -#with app.app_context(): -# scheduleCreateEvents(app, db, currDay, Period, Event, createEvents) - # Authentication stuff login_manager = LoginManager() @@ -70,14 +73,38 @@ def forbidden(e): def ISEerror(e): return render_template('errors/500.html', NODE_NAME=NODE_NAME, POD_NAME=POD_NAME), 500 +# Pod Status route +@app.route('/podStatus') +def podStatus(): + blankPage = "" + try: + db.engine.connect().close() + return blankPage, 200 + except: + return blankPage, 500 + # Index route -@app.route('/') +@app.route('/', methods=['GET', 'POST']) def index(): - return redirect('/events') + # Check for empty database, go to setup page if not setup + global tablesSetup + required_tables = ['period', 'task', 'event', 'user'] + inspector = inspect(db.engine) + tables = inspector.get_table_names() + tablesSetup = all(table in tables for table in required_tables) + if not tablesSetup: + # Initial setup page, should only appear if database is ready but not set up + db.create_all() + logger.info('DB initialized on first run') + return redirect(url_for('createAccount')) + # Otherwise, redirect to /events + else: + return redirect('/events') # Authentication routes @app.route('/login', methods=['GET', 'POST']) def login(): + sourceIP = request.environ.get('HTTP_X_REAL_IP', request.remote_addr) form = LoginForm() if form.validate_on_submit(): userName = form.userName.data @@ -87,13 +114,16 @@ def login(): user = User.query.filter_by(userName=userName).first() if not user or not check_password_hash(user.password, password): flash('Credentials incorrect! Please try again') + logger.info(f'User \'{userName}\' logon FAILED (bad password) from {sourceIP}') return redirect(url_for('login')) login_user(user, remember=remember) + logger.info(f'User \'{userName}\' logged in successfully from {sourceIP}') return redirect(url_for('events')) return render_template('login.html', form=form) @app.route('/createaccount', methods=['GET', 'POST']) def createAccount(): + sourceIP = request.environ.get('HTTP_X_REAL_IP', request.remote_addr) if signup_enabled == True: form = SignupForm() if form.validate_on_submit(): @@ -105,20 +135,24 @@ def createAccount(): password=generate_password_hash(form.password.data, method='sha256')) db.session.add(new_user) db.session.commit() + logger.info(f'New user \'{new_user.userName}\' created from {sourceIP}') return redirect(url_for('login')) return render_template('createAccount.html', form=form) else: - return 'Account creation is currently disabled' + return 'Account creation is currently disabled.
Set env var SIGNUP_ENABLED=YES to enable account creation' @app.route('/logout') @login_required def logout(): + sourceIP = request.environ.get('HTTP_X_REAL_IP', request.remote_addr) + logger.info(f'User \'{current_user.userName}\' logged out from {sourceIP} ') logout_user() return redirect(url_for('index')) @app.route('/settings', methods=('GET', 'POST')) @login_required def settings(): + sourceIP = request.environ.get('HTTP_X_REAL_IP', request.remote_addr) user = User.query.get_or_404(current_user.id) form = SettingsForm(obj=user) if form.validate_on_submit(): @@ -127,6 +161,7 @@ def settings(): if form.password.data != '': user.password = generate_password_hash(form.password.data, method='sha256') db.session.commit() + logger.info(f'User \'{current_user.userName}\' settings changed from {sourceIP}') return render_template('settings.html', form=form) # Periods routes @@ -139,6 +174,7 @@ def periods(): @app.route('/period/new', methods=('GET', 'POST')) @login_required def newPeriod(): + sourceIP = request.environ.get('HTTP_X_REAL_IP', request.remote_addr) form = PeriodForm() if form.validate_on_submit(): period = Period(periodTime=form.periodTime.data, @@ -146,8 +182,9 @@ def newPeriod(): ) db.session.add(period) db.session.commit() + logger.info(f'New period added by \'{current_user.userName}\' from {sourceIP}') # Run createEvents upon adding new period - createEvents(db, currDay, Period, Event) + runCreateEvents() return redirect(f'/period/edit/{period.period}') return render_template('newPeriod.html', form=form) @@ -155,21 +192,25 @@ def newPeriod(): @app.route('/period/edit/', methods=('GET', 'POST')) @login_required def editPeriod(periodNum): + sourceIP = request.environ.get('HTTP_X_REAL_IP', request.remote_addr) period = Period.query.get_or_404(periodNum) form = PeriodForm(obj=period) if form.validate_on_submit(): period.periodTime = form.periodTime.data period.weekendSchedule = form.weekendSchedule.data db.session.commit() + logger.info(f'Period {periodNum} edited by \'{current_user.userName}\' from {sourceIP}') return redirect(f'/period/edit/{periodNum}') return render_template('editPeriod.html', period=period, form=form, datetime=datetime) @app.post('/period/delete/') @login_required def delete_period(periodNum): + sourceIP = request.environ.get('HTTP_X_REAL_IP', request.remote_addr) period = Period.query.get_or_404(periodNum) db.session.delete(period) db.session.commit() + logger.info(f'Period {periodNum} deleted by \'{current_user.userName}\' from {sourceIP}') return redirect('/periods') @@ -180,11 +221,12 @@ def events(): events = Event.query.all() periods = Period.query.all() - return render_template('events.html', events=events, periods=periods, datetime=datetime, date=date, ZoneInfo=ZoneInfo) + return render_template('events.html', events=events, periods=periods, datetime=datetime, date=date, ZoneInfo=ZoneInfo, re=re) @app.route('/event/edit//', methods=('GET', 'POST')) @login_required def editEvent(event_id): + sourceIP = request.environ.get('HTTP_X_REAL_IP', request.remote_addr) event = Event.query.get_or_404(event_id) form = EventForm(obj=event) if form.validate_on_submit(): @@ -193,6 +235,7 @@ def editEvent(event_id): else: event.task_id = None db.session.commit() + logger.info(f'Event {event_id} edited by \'{current_user.userName}\' from {sourceIP}') return redirect('/events') return render_template('editEvent.html', event=event, form=form, datetime=datetime) @@ -201,17 +244,18 @@ def editEvent(event_id): @login_required def tasks(): tasks = Task.query.all() - return render_template('tasks.html', str=str, tasks=tasks, datetime=datetime, date=date) + return render_template('tasks.html', str=str, tasks=tasks, datetime=datetime, date=date, re=re) @app.route('/task//') @login_required def task(task_id): task = Task.query.get_or_404(task_id) - return render_template('task.html', str=str, task=task, datetime=datetime, date=date) + return render_template('task.html', str=str, task=task, datetime=datetime, date=date, re=re) @app.route('/task/new', methods=('GET', 'POST')) @login_required def newTask(): + sourceIP = request.environ.get('HTTP_X_REAL_IP', request.remote_addr) form = TaskForm() if form.validate_on_submit(): task = Task(title=form.title.data, @@ -219,24 +263,29 @@ def newTask(): created_timestamp=int(time.time())) db.session.add(task) db.session.commit() + logger.info(f'New task added by \'{current_user.userName}\' from {sourceIP}') return redirect(f'/task/{task.id}') return render_template('newtask.html', form=form) @app.route('/task//edit', methods=('GET', 'POST')) @login_required def editTask(task_id): + sourceIP = request.environ.get('HTTP_X_REAL_IP', request.remote_addr) task = Task.query.get_or_404(task_id) form = TaskForm(obj=task) if form.validate_on_submit(): task.title=form.title.data task.description=form.description.data db.session.commit() + logger.info(f'Task {task_id} edited by \'{current_user.userName}\' from {sourceIP}') return redirect(f'/task/{task_id}') return render_template('edittask.html', task=task, form=form) @app.post('/task//delete') @login_required def delete_task(task_id): + sourceIP = request.environ.get('HTTP_X_REAL_IP', request.remote_addr) task = Task.query.get_or_404(task_id) db.session.delete(task) db.session.commit() + logger.info(f'Task {task_id} deleted by \'{current_user.userName}\' from {sourceIP}') return redirect('/tasks') \ No newline at end of file diff --git a/app/cleanup_events.py b/app/cleanup_events.py new file mode 100644 index 0000000..69feb19 --- /dev/null +++ b/app/cleanup_events.py @@ -0,0 +1,16 @@ +from datetime import datetime, timedelta + +def cleanupEvents(db, Event): + # calculate the date one month ago + one_month_ago = datetime.now() - timedelta(days=30) + + # get events older than one month + old_events = db.session.query(Event).filter( + db.func.STR_TO_DATE(Event.scheduled_date, '%m-%d-%Y') < one_month_ago.date() + ).all() + + # delete old events + for event in old_events: + db.session.delete(event) + + db.session.commit() \ No newline at end of file diff --git a/app/create_events.py b/app/create_events.py index 2033c0d..05b89bf 100644 --- a/app/create_events.py +++ b/app/create_events.py @@ -1,3 +1,6 @@ +from log import appLogger + +logger = appLogger('app') def createEvents(db, currDay, Period, Event): periods = Period.query.all() for period in periods: @@ -10,4 +13,4 @@ def createEvents(db, currDay, Period, Event): db.session.add(event) db.session.commit() - print("createEvents script ran successfully") \ No newline at end of file + logger.info('createEvents script ran successfully') \ No newline at end of file diff --git a/app/log.py b/app/log.py new file mode 100644 index 0000000..94ab670 --- /dev/null +++ b/app/log.py @@ -0,0 +1,14 @@ +import logging +import sys + +def appLogger(name): + formatter = logging.Formatter(fmt='%(asctime)s %(levelname)-8s %(message)s', + datefmt='%Y-%m-%d %H:%M:%S') + screen_handler = logging.StreamHandler(stream=sys.stdout) + screen_handler.setFormatter(formatter) + logger = logging.getLogger(name) + logger.setLevel(logging.DEBUG) + if (logger.hasHandlers()): + logger.handlers.clear() + logger.addHandler(screen_handler) + return logger \ No newline at end of file diff --git a/app/requirements.txt b/app/requirements.txt index bd4eaad..95eab1e 100644 --- a/app/requirements.txt +++ b/app/requirements.txt @@ -6,31 +6,35 @@ gunicorn==20.1.0 # Flask Framework click==8.1.3 -Flask==2.2.2 +Flask==2.2.3 itsdangerous==2.1.2 Jinja2==3.1.2 -MarkupSafe==2.1.1 -Werkzeug==2.2.2 +MarkupSafe==2.1.2 +Werkzeug==2.2.3 # Flask Packages Flask-Login==0.6.2 -Flask-Migrate==3.1.0 +Flask-Migrate==4.0.4 Flask-Script==2.0.6 -Flask-SQLAlchemy==3.0.2 -Flask-WTF==1.0.1 +Flask-SQLAlchemy==3.0.3 +Flask-WTF==1.1.1 Flask-User==1.0.2.2 # WTForms Extensions WTForms-SQLAlchemy==0.3.0 # APScheduler automated scheduler -APScheduler==3.9.1.post1 +APScheduler==3.10.1 + +# Celery Task Queue +celery[redis]==5.2.7 +redis==4.5.4 # Python Time packages -pytz==2022.6 +pytz==2022.7.1 # Automated tests -pytest==7.2.0 +pytest==7.2.2 pytest-cov==4.0.0 # MySQL Package diff --git a/app/templates/events.html b/app/templates/events.html index f0dea65..366845c 100644 --- a/app/templates/events.html +++ b/app/templates/events.html @@ -21,7 +21,17 @@
{% if event.tasks.description != None %} -

{{ event.tasks.description }}

+ {% set description = event.tasks.description %} + {% set url_regex = '(https?://[^\s]+)' %} + + {% set match = re.search(url_regex, description) %} + {% if match %} + {% set url = match.group(0) %} + {% set rest = description[match.end(0):] %} +

{{ description[:match.start(0)] }}{{ url }}{{ rest }}

+ {% else %} +

{{ description }}

+ {% endif %} {% endif %}
{% else %} diff --git a/app/templates/task.html b/app/templates/task.html index 886659b..d9246a5 100644 --- a/app/templates/task.html +++ b/app/templates/task.html @@ -1,6 +1,7 @@ {% extends 'base.html' %} {% set createdTime = datetime.fromtimestamp(task.created_timestamp) %} {% set createdTime = datetime.strptime(str(createdTime), '%Y-%m-%d %H:%M:%S') %} +{% set createdTime = createdTime.astimezone(ZoneInfo(current_user.timezone)) %} {% block content %}

{% block title %} {{ task.title }} {% endblock %}

@@ -9,7 +10,17 @@

{% if task.description != None %} -

{{ task.description }}

+ {% set description = task.description %} + {% set url_regex = '(https?://[^\s]+)' %} + + {% set match = re.search(url_regex, description) %} + {% if match %} + {% set url = match.group(0) %} + {% set rest = description[match.end(0):] %} +

{{ description[:match.start(0)] }}{{ url }}{{ rest }}

+ {% else %} +

{{ description }}

+ {% endif %} {% endif %}
diff --git a/app/templates/tasks.html b/app/templates/tasks.html index e7f0db8..f403efd 100644 --- a/app/templates/tasks.html +++ b/app/templates/tasks.html @@ -6,13 +6,24 @@ {% for task in tasks %} {% set createdTime = datetime.fromtimestamp(task.created_timestamp) %} {% set createdTime = datetime.strptime(str(createdTime), '%Y-%m-%d %H:%M:%S') %} + {% set createdTime = createdTime.astimezone(ZoneInfo(current_user.timezone)) %}

{{ task.title }}

{% if task.description != None %} - {{ task.description }} + {% set description = task.description %} + {% set url_regex = '(https?://[^\s]+)' %} + + {% set match = re.search(url_regex, description) %} + {% if match %} + {% set url = match.group(0) %} + {% set rest = description[match.end(0):] %} +

{{ description[:match.start(0)] }}{{ url }}{{ rest }}

+ {% else %} +

{{ description }}

+ {% endif %} {% endif %}
diff --git a/app/worker.py b/app/worker.py index 288a29e..6e1e617 100644 --- a/app/worker.py +++ b/app/worker.py @@ -1,9 +1,11 @@ +from celery import Celery +from celery.schedules import crontab +from create_events import createEvents +from cleanup_events import cleanupEvents import os from flask import Flask from misc import currDay, datetime, time, timedelta from db import db, Period, Event -from create_events import createEvents -from apscheduler.schedulers.background import BlockingScheduler basedir = os.path.abspath(os.path.dirname(__file__)) @@ -18,18 +20,33 @@ app.config['SQLALCHEMY_DATABASE_URI'] =\ app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False db.init_app(app) -# Define function to run create_events with app context -def run_create_events(): +redis_url = 'redis://' + \ + os.environ['REDIS_HOST'] + \ + ':' + os.environ['REDIS_PORT'] + \ + '/' + os.environ['REDIS_DBNUM'] + +celerymsg = Celery('tasks', backend=redis_url, broker=redis_url) + +# Task definitions +@celerymsg.task +def runCreateEvents(): with app.app_context(): createEvents(db, currDay, Period, Event) -# Call createEvents on initial launch of the script -run_create_events() +def runCleanupEvents(): + with app.app_context(): + cleanupEvents(db, Event) - -# Set up scheduler to run function at 59th minute of every hour -scheduler = BlockingScheduler() -scheduler.add_job(run_create_events, 'cron', minute=59) - -# Start scheduler -scheduler.start() \ No newline at end of file +# Scheduled tasks +celerymsg.conf.beat_schedule = { + 'hourly-createevents': { + 'task': 'worker.runCreateEvents', + # Run hourly + 'schedule': crontab(hour="*", minute="59"), + }, + 'monthly-cleanupevents': { + 'task': 'worker.runCleanupEvents', + # Run monthly + 'schedule': crontab(day_of_month="29") + } +} \ No newline at end of file diff --git a/docker-compose.yaml b/docker-compose.yaml index c811c2a..600d3dc 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -9,31 +9,43 @@ services: - MYSQL_HOST=db - MYSQL_PORT=3306 - MYSQL_DB=bellscheduler + - REDIS_HOST=redis + - REDIS_PORT=6379 + - REDIS_DBNUM=0 - SECRET_KEY=notasecuresecretkeyonlyuseforlocaldevelopment - NODE_NAME=local - POD_NAME=local - FLASK_ENV=development - FLASK_DEBUG=1 - PYTHONUNBUFFERED=1 + - SIGNUP_ENABLED=YES ports: - 127.0.0.1:80:80 worker: image: container-registry.infra.dubyatp.xyz/bellscheduler/app:latest-testing restart: always - entrypoint: python3 - command: "-m worker" + entrypoint: celery + command: "-A worker.celerymsg worker --loglevel=DEBUG -B" environment: - MYSQL_USER=root - MYSQL_PASSWORD=notasecuresecretkeyonlyuseforlocaldevelopment - MYSQL_HOST=db - MYSQL_PORT=3306 - MYSQL_DB=bellscheduler + - REDIS_HOST=redis + - REDIS_PORT=6379 + - REDIS_DBNUM=0 - SECRET_KEY=notasecuresecretkeyonlyuseforlocaldevelopment - NODE_NAME=local - POD_NAME=local - FLASK_ENV=development - FLASK_DEBUG=1 - PYTHONUNBUFFERED=1 + redis: + image: redis:7.0.10-alpine3.17 + restart: always + ports: + - 127.0.0.1:6379:6379 db: image: mariadb:10.7.8-focal restart: always