diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index ba6c178..61bb700 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -10,7 +10,7 @@ jobs: strategy: matrix: os: [ubuntu-latest, windows-latest] - python-version: ['3.7', '3.8', '3.9'] + python-version: ['3.8', '3.9', '3.10'] steps: - uses: actions/checkout@v2 diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 5233bbe..126e635 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -15,5 +15,5 @@ test: script: - pytest --junitxml report.xml tests/test_basic.py - pytest --junitxml report.xml tests/test_web.py - - pytest --junitxml report.xml tests/test_user_model.py + - pytest --junitxml report.xml tests/test_model_user.py diff --git a/app/__init__.py b/app/__init__.py index ebb575d..d960230 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -1,9 +1,14 @@ -from distutils.command.config import config -from flask import Flask +import atexit + +from apscheduler.schedulers.background import BackgroundScheduler +import requests + +from flask import Flask, current_app, g from flask_login import LoginManager from flask_mail import Mail from flask_moment import Moment from flask_sqlalchemy import SQLAlchemy + from config import configuration, DevelopmentConfig, TestingConfig, ProductionConfig @@ -14,6 +19,14 @@ db = SQLAlchemy() login_manager = LoginManager() login_manager.login_view = 'auth.login' +def scheduled_task(func, seconds=60, args=None): + + scheduler = BackgroundScheduler() + scheduler.add_job(func=func, trigger='interval', seconds=seconds, args=args) + scheduler.start() + + atexit.register(lambda: scheduler.shutdown()) + def create_app(config_name): app = Flask(__name__) diff --git a/app/auth/views.py b/app/auth/views.py index 5afeb86..43e37b2 100644 --- a/app/auth/views.py +++ b/app/auth/views.py @@ -1,13 +1,26 @@ from flask import render_template, redirect, request, url_for, flash -from flask_login import login_user, logout_user, login_required +from flask_login import login_user, logout_user, login_required, current_user from app import db from app.auth import auth +from app.email import send_email from app.models import User from app.auth.forms import LoginForm, RegistrationForm +@auth.before_app_request +def before_request(): + if current_user.is_authenticated and not current_user.confirmed and request.blueprint != 'auth' and request.endpoint != 'static': + return redirect(url_for('auth.unconfirmed')) + + +@auth.route('/unconfirmed') +def unconfirmed(): + if current_user.is_anonymous or current_user.confirmed: + return redirect(url_for('main.index')) + return render_template('auth/unconfirmed.html') + @auth.route('/login', methods=['get', 'post']) def login(): form = LoginForm() @@ -43,8 +56,46 @@ def register(): if form.validate_on_submit(): user = User(email=form.email.data, username=form.username.data, password=form.password.data) db.session.add(user) + db.session.commit() - flash('You can now login', category='success') + + token = user.generate_confirmation_token() + + send_email(user.email, 'Confirm Your Yo!nk Account', 'auth/email/confirm', user=user, token=token) + + flash(f'A confirmation email was sent to {user.email}', category='success') return redirect(url_for('auth.login')) - return render_template('auth/register.html', form=form) \ No newline at end of file + return render_template('auth/register.html', form=form) + + +@auth.route('/confirm/') +@login_required +def confirm(token): + if current_user.confirmed: + return redirect(url_for('main.index')) + + if current_user.confirm(token): + db.session.commit() + flash('Your account has been confirmed. Thank you!') + else: + flash('Invalid or expired confirmation link') + + return redirect(url_for('main.index')) + + +@auth.route('/confirm') +@login_required +def resend_confirm(): + token = current_user.generate_confirmation_token() + + send_email(current_user.email, 'Confirm Your Yo!nk Account', 'auth/email/confirm', user=current_user, token=token) + + flash(f'A new confirmation email was sent to {current_user.email}', category='success') + + return redirect(url_for('main.index')) + + +# TODO: password updates +# TODO: password reset +# TODO: email updates \ No newline at end of file diff --git a/app/email.py b/app/email.py index 07cf58d..eb66d9a 100644 --- a/app/email.py +++ b/app/email.py @@ -3,7 +3,7 @@ import threading from flask import current_app, render_template from flask_mail import Message -from app import mail, config +from app import mail def send_async_email(app, msg): diff --git a/app/main/views.py b/app/main/views.py index 9b8aa1e..124ca1b 100644 --- a/app/main/views.py +++ b/app/main/views.py @@ -1,7 +1,7 @@ import os from datetime import datetime -from flask import render_template, session, send_from_directory, redirect, url_for, flash +from flask import render_template, session, send_from_directory, redirect, url_for, flash, g from app.main import main from app.main.forms import DownloadForm @@ -89,7 +89,7 @@ def index(): latest = get_comic_library_meta() form.url.data = '' - return render_template('index.html', form=form, url=url, series=series, latest=latest), 200 + return render_template('index.html', form=form, url=url, series=series, latest=latest, status=g.status_code), 200 if form.series.data: diff --git a/app/static/down.png b/app/static/down.png new file mode 100644 index 0000000..b009715 Binary files /dev/null and b/app/static/down.png differ diff --git a/app/static/up.png b/app/static/up.png new file mode 100644 index 0000000..06e2efb Binary files /dev/null and b/app/static/up.png differ diff --git a/app/templates/auth/email/confirm.html b/app/templates/auth/email/confirm.html new file mode 100644 index 0000000..83e2285 --- /dev/null +++ b/app/templates/auth/email/confirm.html @@ -0,0 +1,12 @@ +Welcome to Yo!nk {{ user.username }}, + +To confirm your account please click on the following link: + +{{ url_for('auth.confirm', token=token, _external=True) }} + +Sincerely, + +The Yo!nk Team + + +Note: replies to this email address are unmonitored. \ No newline at end of file diff --git a/app/templates/auth/register.html b/app/templates/auth/register.html new file mode 100644 index 0000000..7308301 --- /dev/null +++ b/app/templates/auth/register.html @@ -0,0 +1,14 @@ +{% extends "base.html" %} + +{% block meta %} +{{ super() }} + +{% endblock %} + +{% block title %}Testing{% endblock %} + +{% block page_content %} + +{% endblock %} \ No newline at end of file diff --git a/app/templates/base.html b/app/templates/base.html index d697777..9deb294 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -1,3 +1,5 @@ +{% import 'macros/connection_status.html' as connection %} + @@ -15,7 +17,7 @@ {{ moment.include_moment() }} {% endblock %} - + {% block navbar %} diff --git a/app/templates/index.html b/app/templates/index.html index f4d3bb3..3abf25b 100644 --- a/app/templates/index.html +++ b/app/templates/index.html @@ -1,5 +1,7 @@ {% extends "base.html" %} {% import 'macros/library.html' as library %} +{% import 'macros/connection_status.html' as connection %} + {% block meta %} {{ super() }} @@ -12,7 +14,6 @@ -
{{ form.hidden_tag() }}
diff --git a/app/templates/macros/connection_status.html b/app/templates/macros/connection_status.html new file mode 100644 index 0000000..c7bd2ac --- /dev/null +++ b/app/templates/macros/connection_status.html @@ -0,0 +1,7 @@ +{% macro toggle_connection_status(status_code) %} +{% if status_code == 200 %} +{{ url_for('static', filename='up.png') }} +{% else %} +{{ url_for('static', filename='down.png') }} +{% endif %} +{% endmacro %} \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 769d4ed..7b27edd 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,5 @@ alembic==1.7.7 +APScheduler==3.9.1 asgiref==3.5.0 attrs==21.4.0 beautifulsoup4==4.10.0 @@ -34,6 +35,8 @@ pluggy==1.0.0 py==1.11.0 pyparsing==3.0.7 pytest==7.1.0 +pytz==2022.1 +pytz-deprecation-shim==0.1.0.post0 requests==2.27.1 six==1.16.0 soupsieve==2.3.1 @@ -41,6 +44,8 @@ SQLAlchemy==1.4.34 toml==0.10.2 tomli==2.0.1 tox==3.24.5 +tzdata==2022.1 +tzlocal==4.2 urllib3==1.26.8 virtualenv==20.14.0 Werkzeug==2.1.0 diff --git a/tox.ini b/tox.ini index 9a0b55a..4754c0b 100644 --- a/tox.ini +++ b/tox.ini @@ -1,13 +1,13 @@ [tox] minversion = 3.8.0 -envlist = py37, py38, py39 +envlist = py38, py39, py310 isolated_build = true [gh-actions] python = - 3.7: py37 3.8: py38 3.9: py39 + 3.10: py310 [testenv] setenv = @@ -16,3 +16,5 @@ deps = -r{toxinidir}/requirements_dev.txt commands = pytest --junitxml report.xml yoink/tests/test_basic.py + pytest --junitxml report.xml tests/test_web.py + pytest --junitxml report.xml tests/test_model_user.py diff --git a/web.py b/web.py index 8d67339..9170cc7 100644 --- a/web.py +++ b/web.py @@ -1,5 +1,10 @@ +import atexit +import click import os +import time +import requests +from apscheduler.schedulers.background import BackgroundScheduler from flask_migrate import Migrate from app import create_app, db @@ -8,15 +13,24 @@ from yoink.config import config from yoink.comic import Comic +def ping(url: str): + ''' check url for connection status''' + return requests.get(url).status_code + + app = create_app(os.environ.get('FLASK_CONFIG') or 'default') migrate = Migrate(app, db) @app.shell_context_processor def make_shell_ctx(): return dict(db=db, User=User, Role=Role, ComicMeta=ComicMeta) +@app.context_processor +def inject_status_code(): + return dict(status_code=ping('http://readallcomics.com')) + @app.cli.command() def test(): ''' run unit tests ''' import unittest tests = unittest.TestLoader().discover('tests') - unittest.TextTestRunner(verbosity=2).run(tests) \ No newline at end of file + unittest.TextTestRunner(verbosity=2).run(tests)