From 5ca91452ca080acf26b18a042d93dc7e36bd7a04 Mon Sep 17 00:00:00 2001 From: Bryan Bailey Date: Tue, 5 Apr 2022 23:12:42 -0400 Subject: [PATCH] refactored app structure --- .gitignore | 4 +- app/__init__.py | 29 ++++ app/email.py | 23 +++ app/main/__init__.py | 7 + app/main/errors.py | 12 ++ app/main/forms.py | 9 ++ app/main/views.py | 104 +++++++++++++ app/models.py | 31 ++++ {static => app/static}/comic.png | Bin {static => app/static}/css/yoink.css | 0 {static => app/static}/js/yoink.js | 0 {templates => app/templates}/404.html | 0 {templates => app/templates}/500.html | 0 {templates => app/templates}/base.html | 0 {templates => app/templates}/index.html | 0 .../templates}/macros/comments.html | 0 .../templates}/macros/library.html | 6 +- config.py | 46 ++++++ requirements.txt | 11 ++ setup.py | 2 +- tests/__init__.py | 0 tests/test_web.py | 24 +++ web.py | 147 ++---------------- 23 files changed, 317 insertions(+), 138 deletions(-) create mode 100644 app/__init__.py create mode 100644 app/email.py create mode 100644 app/main/__init__.py create mode 100644 app/main/errors.py create mode 100644 app/main/forms.py create mode 100644 app/main/views.py create mode 100644 app/models.py rename {static => app/static}/comic.png (100%) rename {static => app/static}/css/yoink.css (100%) rename {static => app/static}/js/yoink.js (100%) rename {templates => app/templates}/404.html (100%) rename {templates => app/templates}/500.html (100%) rename {templates => app/templates}/base.html (100%) rename {templates => app/templates}/index.html (100%) rename {templates => app/templates}/macros/comments.html (100%) rename {templates => app/templates}/macros/library.html (72%) create mode 100644 config.py create mode 100644 tests/__init__.py create mode 100644 tests/test_web.py diff --git a/.gitignore b/.gitignore index d2c5a66..657e1f3 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,6 @@ __pycache__ *.egg-info sample .tox -*.sqlite \ No newline at end of file +*.sqlite +migrations +.vscode \ No newline at end of file diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..5ff335f --- /dev/null +++ b/app/__init__.py @@ -0,0 +1,29 @@ +from distutils.command.config import config +from flask import Flask +from flask_mail import Mail +from flask_moment import Moment +from flask_sqlalchemy import SQLAlchemy +from config import configuration, DevelopmentConfig, TestingConfig, ProductionConfig + + + +mail =Mail() +moment = Moment() +db = SQLAlchemy() + + +def create_app(config_name): + app = Flask(__name__) + + app.config.from_object(configuration[config_name]) + configuration[config_name].init_app(app) + + mail.init_app(app) + moment.init_app(app) + db.init_app(app) + + from .main import main as main_blueprint + + app.register_blueprint(main_blueprint) + + return app \ No newline at end of file diff --git a/app/email.py b/app/email.py new file mode 100644 index 0000000..07cf58d --- /dev/null +++ b/app/email.py @@ -0,0 +1,23 @@ +import threading + +from flask import current_app, render_template +from flask_mail import Message + +from app import mail, config + + +def send_async_email(app, msg): + with app.app_context(): + mail.send(msg) + +def send_email(to, subject, template, **kwargs): + msg = Message( + + current_app.config['MAIL_PREFIX'] + subject, + sender=current_app.config['MAIL_SENDER'], recipients=to) + msg.body = render_template(template + '.txt', **kwargs) + msg.html = render_template(template + '.html', **kwargs) + + mail_thread = threading.Thread(target=send_async_email, args=[current_app._get_current_object(), msg]) + mail_thread.start() + return mail_thread diff --git a/app/main/__init__.py b/app/main/__init__.py new file mode 100644 index 0000000..506e0ec --- /dev/null +++ b/app/main/__init__.py @@ -0,0 +1,7 @@ +from flask import Blueprint + + +main = Blueprint('main', __name__) + + +from . import views, errors \ No newline at end of file diff --git a/app/main/errors.py b/app/main/errors.py new file mode 100644 index 0000000..efcface --- /dev/null +++ b/app/main/errors.py @@ -0,0 +1,12 @@ +from flask import render_template +from . import main + + + +@main.app_errorhandler(404) +def not_found(e): + return render_template('404.html'), 404 + +@main.errorhandler(500) +def server_error(e): + return render_template('500.html'), 500 \ No newline at end of file diff --git a/app/main/forms.py b/app/main/forms.py new file mode 100644 index 0000000..fd6bc5d --- /dev/null +++ b/app/main/forms.py @@ -0,0 +1,9 @@ +from flask_wtf import FlaskForm +from wtforms import StringField, BooleanField, SubmitField +from wtforms.validators import DataRequired + + +class DownloadForm(FlaskForm): + url = StringField('Comic URL', validators=[DataRequired()]) + series = BooleanField('Series? ') + download = SubmitField('Download') \ No newline at end of file diff --git a/app/main/views.py b/app/main/views.py new file mode 100644 index 0000000..9b8aa1e --- /dev/null +++ b/app/main/views.py @@ -0,0 +1,104 @@ +import os +from datetime import datetime + +from flask import render_template, session, send_from_directory, redirect, url_for, flash + +from app.main import main +from app.main.forms import DownloadForm +from app import db +from app.email import send_email +from app.models import User, Role, ComicMeta +from yoink.comic import Comic +from yoink.config import config + + +@main.route('/uploads/') +def download_file(filename): + return send_from_directory(os.path.join(config.library_path, 'comics'), filename, as_attachment=True) + + +def get_cover_path(comic): + return [image for image in os.listdir(os.path.join(config.library_path, 'comics', comic.title)) if image.endswith('000.jpg')][0] + + +def get_archive_path(comic): + return [image for image in os.listdir(os.path.join(config.library_path, 'comics', comic.title)) if image.endswith('.cbr')][0] + + +def get_comic_library_meta(): + comic_meta = [] + + for comic in ComicMeta.query.order_by(ComicMeta.id.desc()).all(): + comic_meta.append({ + 'cover': comic.cover_path, + 'title': comic.title, + 'archive': comic.archive_path + }) + + return comic_meta + + +def new_user(username, db, app, form): + user = User.query.filter_by(username=username).first() + + if user is None: + user = User(username=form.username.data, role_id=3) + db.session.add(user) + if app.config['YOINK_ADMIN']: + send_email(app.config['YOINK_ADMIN'], 'New User', 'mail/new_user', user=user) + db.session.commit() + + +def check_setup(): + if User.query.all() is None: + return redirect(url_for('setup')) + + +@main.route('/', methods=['post', 'get']) +def index(): + url = None + series = False + form = DownloadForm() + latest = get_comic_library_meta() + + if form.validate_on_submit(): + url = form.url.data.strip() + series = form.series.data + + comic = Comic(url) + comic_meta = ComicMeta.query.filter_by(title=comic.title).first() + + if comic_meta is None: + comic.archiver.download() + comic.archiver.generate_archive() + + comic_meta = ComicMeta() + comic_meta.title = comic.title + comic_meta.category = comic.category + comic_meta.issue = comic.issue_number + comic_meta.next_issue = comic.next + comic_meta.previous_issue = comic.prev + comic_meta.cover_path = os.path.join(comic.title, get_cover_path(comic)) + comic_meta.archive_path = os.path.join(comic.title, get_archive_path(comic)) + + db.session.add(comic_meta) + db.session.commit() + else: + flash(f'Comic {comic.title} exists') + + latest = get_comic_library_meta() + form.url.data = '' + + return render_template('index.html', form=form, url=url, series=series, latest=latest), 200 + + + if form.series.data: + print('Download the whole damn lot') + + flash(f'{comic.title} downloaded to {os.path.join(config.library_path, "comics/" + comic.title)}') + + latest = get_comic_library_meta() + comic.archiver.cleanup_worktree() + form.url.data = '' + + return render_template('index.html', form=form, url=url, series=series, latest=latest), 200 \ No newline at end of file diff --git a/app/models.py b/app/models.py new file mode 100644 index 0000000..c3da8f4 --- /dev/null +++ b/app/models.py @@ -0,0 +1,31 @@ +from app import db + + +class Role(db.Model): + __tablename__ = 'roles' + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(64), unique=True) + users = db.relationship('User', backref='role', lazy='dynamic') + + def __repr__(self): + return f'' + + +class User(db.Model): + __tablename__ = 'users' + id = db.Column(db.Integer, primary_key=True) + username = db.Column(db.String(64), unique=True, index=True) + role_id = db.Column(db.Integer, db.ForeignKey('roles.id')) + + def __repr__(self): return f'' + +class ComicMeta(db.Model): + __tablename__ = 'comicmeta' + id = db.Column(db.Integer, primary_key=True) + title = db.Column(db.String(256), unique=True, index=True) + issue = db.Column(db.Integer, nullable=True, index=True) + category = db.Column(db.String(128), index=True, nullable=True) + previous_issue = db.Column(db.String(256), nullable=True) + next_issue = db.Column(db.String(256), nullable=True) + cover_path = db.Column(db.String(256)) + archive_path = db.Column(db.String(256)) \ No newline at end of file diff --git a/static/comic.png b/app/static/comic.png similarity index 100% rename from static/comic.png rename to app/static/comic.png diff --git a/static/css/yoink.css b/app/static/css/yoink.css similarity index 100% rename from static/css/yoink.css rename to app/static/css/yoink.css diff --git a/static/js/yoink.js b/app/static/js/yoink.js similarity index 100% rename from static/js/yoink.js rename to app/static/js/yoink.js diff --git a/templates/404.html b/app/templates/404.html similarity index 100% rename from templates/404.html rename to app/templates/404.html diff --git a/templates/500.html b/app/templates/500.html similarity index 100% rename from templates/500.html rename to app/templates/500.html diff --git a/templates/base.html b/app/templates/base.html similarity index 100% rename from templates/base.html rename to app/templates/base.html diff --git a/templates/index.html b/app/templates/index.html similarity index 100% rename from templates/index.html rename to app/templates/index.html diff --git a/templates/macros/comments.html b/app/templates/macros/comments.html similarity index 100% rename from templates/macros/comments.html rename to app/templates/macros/comments.html diff --git a/templates/macros/library.html b/app/templates/macros/library.html similarity index 72% rename from templates/macros/library.html rename to app/templates/macros/library.html index 1b29b91..fb445ce 100644 --- a/templates/macros/library.html +++ b/app/templates/macros/library.html @@ -6,13 +6,13 @@ {% for comic in comics %}
- {{ comic['title'] }} + {{ comic['title'] }}

{{ comic['title'] }}

{{ comic['title'] }}

@@ -29,7 +29,7 @@ {% for comic in comics %}

{{ comic['title'] }}

- + {% endfor %} {% else %} diff --git a/config.py b/config.py new file mode 100644 index 0000000..2ca3c9c --- /dev/null +++ b/config.py @@ -0,0 +1,46 @@ +import os +from yoink.config import config + + + +basedir = os.path.abspath(os.path.dirname(__file__)) + + + +class Config: + SECRET_KEY = os.environ.get('SECRET_KEY', 'snapekilleddumpledork') + SQLALCHEMY_TRACK_MODIFICATIONS = False + MAIL_SERVER = os.environ.get('MAIL_SERVER', 'smtp.gmail.com') + MAIL_PORT = os.environ.get('MAIL_PORT', 465) + MAIL_USE_TLS = os.environ.get('MAIL_USE_TLS', False) + MAIL_USE_SSL = os.environ.get('MAIL_USE_SSL', True) + MAIL_USERNAME = os.environ.get('MAIL_USERNAME') + MAIL_PASSWORD = os.environ.get('MAIL_PASSWORD') + MAIL_PREFIX = os.environ.get('MAIL_PREFIX', '[YO!NK]') + MAIL_SENDER = os.environ.get('MAIL_SENDER', 'YO!NK Admin ') + + + @staticmethod + def init_app(app): + pass + + +class DevelopmentConfig(Config): + DEBUG = True + SQLALCHEMY_DATABASE_URI = os.environ.get('DEV_DATABASE_URL') or f'sqlite:///{os.path.join(config.app_root, "data-dev.sqlite")}' + + +class TestingConfig(Config): + TESTING = True + SQLALCHEMY_DATABASE_URI = os.environ.get('TEST_DATABASE_URL') or f'sqlite:///{os.path.join(config.app_root, "data-test.sqlite")}' + +class ProductionConfig(Config): + SQLALCHEMY_DATABASE_URI = os.environ.get('TEST_DATABASE_URL') or f'sqlite:///{os.path.join(config.app_root, "data.sqlite")}' + + +configuration = { + 'development': DevelopmentConfig, + 'testing': TestingConfig, + 'production': ProductionConfig, + 'default': DevelopmentConfig +} \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 08140a7..319da4b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,8 @@ +alembic==1.7.7 asgiref==3.5.0 attrs==21.4.0 beautifulsoup4==4.10.0 +blinker==1.4 bs4==0.0.1 certifi==2021.10.8 charset-normalizer==2.0.12 @@ -10,11 +12,18 @@ coverage==6.3.2 distlib==0.3.4 filelock==3.6.0 Flask==2.1.1 +Flask-Mail==0.9.1 +Flask-Migrate==3.1.0 +Flask-Moment==1.0.2 +Flask-SQLAlchemy==2.5.1 +Flask-WTF==1.0.1 +greenlet==1.1.2 idna==3.3 importlib-metadata==4.11.3 iniconfig==1.1.1 itsdangerous==2.1.2 Jinja2==3.1.1 +Mako==1.2.0 MarkupSafe==2.1.1 packaging==21.3 platformdirs==2.5.1 @@ -25,10 +34,12 @@ pytest==7.1.0 requests==2.27.1 six==1.16.0 soupsieve==2.3.1 +SQLAlchemy==1.4.34 toml==0.10.2 tomli==2.0.1 tox==3.24.5 urllib3==1.26.8 virtualenv==20.14.0 Werkzeug==2.1.0 +WTForms==3.0.1 zipp==3.7.0 diff --git a/setup.py b/setup.py index e52b072..468ab24 100644 --- a/setup.py +++ b/setup.py @@ -10,7 +10,7 @@ with open('README.md', 'r') as readme: setuptools.setup( name='yoink', - version='0.2.0', + version='0.3.2', author='Bryan Bailey', author_email='brizzledev@gmail.com', description='', diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_web.py b/tests/test_web.py new file mode 100644 index 0000000..b7ccef3 --- /dev/null +++ b/tests/test_web.py @@ -0,0 +1,24 @@ +import unittest + +from flask import current_app + +from app import create_app, db + + +class BasicTestCase(unittest.TestCase): + def setUp(self) -> None: + self.app = create_app('testing') + self.app_context = self.app.app_context() + self.app_context.push() + db.create_all() + + def tearDown(self) -> None: + db.session.remove() + db.drop_all() + self.app_context.pop() + + def test_app_exists(self): + self.assertFalse(current_app is None) + + def test_app_is_testing(self): + self.assertTrue(current_app.config['TESTING']) \ No newline at end of file diff --git a/web.py b/web.py index e60c867..8d67339 100644 --- a/web.py +++ b/web.py @@ -1,141 +1,22 @@ import os -import threading -from flask import Flask, render_template, url_for, request, flash, make_response, redirect, send_from_directory -from flask_moment import Moment -from flask_sqlalchemy import SQLAlchemy -from flask_wtf import FlaskForm -from wtforms import StringField, SubmitField, BooleanField -from wtforms.validators import DataRequired +from flask_migrate import Migrate + +from app import create_app, db +from app.models import User, Role, ComicMeta from yoink.config import config from yoink.comic import Comic -class DownloadForm(FlaskForm): - url = StringField('Comic URL', validators=[DataRequired()]) - series = BooleanField('Series? ') - download = SubmitField('Download') +app = create_app(os.environ.get('FLASK_CONFIG') or 'default') +migrate = Migrate(app, db) -app = Flask(__name__) -moment = Moment(app) -app.config['SECRET_KEY'] = 'snapekilleddumpledork' -app.config['SQLALCHEMY_DATABASE_URI'] = f'sqlite:///{os.path.join(config.app_root, "data.sqlite")}' -app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False -db = SQLAlchemy(app) +@app.shell_context_processor +def make_shell_ctx(): return dict(db=db, User=User, Role=Role, ComicMeta=ComicMeta) - - -class Role(db.Model): - __tablename__ = 'roles' - id = db.Column(db.Integer, primary_key=True) - name = db.Column(db.String(64), unique=True) - users = db.relationship('User', backref='role', lazy='dynamic') - - def __repr__(self): - return f'' - - -class User(db.Model): - __tablename__ = 'users' - id = db.Column(db.Integer, primary_key=True) - username = db.Column(db.String(64), unique=True, index=True) - role_id = db.Column(db.Integer, db.ForeignKey('roles.id')) - - def __repr__(self): return f'' - -class ComicMeta(db.Model): - __tablename__ = 'comicmeta' - id = db.Column(db.Integer, primary_key=True) - title = db.Column(db.String(256), unique=True, index=True) - issue = db.Column(db.Integer, nullable=True, index=True) - category = db.Column(db.String(128), index=True, nullable=True) - previous_issue = db.Column(db.String(256), nullable=True) - next_issue = db.Column(db.String(256), nullable=True) - cover_path = db.Column(db.String(256)) - archive_path = db.Column(db.String(256)) - - -@app.errorhandler(404) -def not_found(e): - return render_template('404.html'), 404 - -@app.errorhandler(500) -def server_error(e): - return render_template('500.html'), 500 - -@app.route('/uploads/') -def download_file(filename): - return send_from_directory(os.path.join(config.library_path, 'comics'), filename, as_attachment=True) - - -def get_cover_path(comic): - return [image for image in os.listdir(os.path.join(config.library_path, 'comics', comic.title)) if image.endswith('000.jpg')][0] - - -def get_archive_path(comic): - return [image for image in os.listdir(os.path.join(config.library_path, 'comics', comic.title)) if image.endswith('.cbr')][0] - - -def get_comic_library_meta(): - comic_meta = [] - - for comic in ComicMeta.query.all(): - comic_meta.append({ - 'cover': comic.cover_path, - 'title': comic.title, - 'archive': comic.archive_path - }) - - return comic_meta - - -@app.route('/', methods=['post','get']) -def index(): - url = None - series = False - form = DownloadForm() - latest = get_comic_library_meta() - - if form.validate_on_submit(): - url = form.url.data.strip() - series = form.series.data - - comic = Comic(url) - comic_meta = ComicMeta.query.filter_by(title=comic.title).first() - - comic.archiver.download() - comic.archiver.generate_archive() - - - if comic_meta is None: - comic_meta = ComicMeta() - comic_meta.title = comic.title - comic_meta.category = comic.category - comic_meta.issue = comic.issue_number - comic_meta.next_issue = comic.next - comic_meta.previous_issue = comic.prev - comic_meta.cover_path = os.path.join(comic.title, get_cover_path(comic)) - comic_meta.archive_path = os.path.join(comic.title, get_archive_path(comic)) - - db.session.add(comic_meta) - db.session.commit() - else: - flash(f'Comic {comic.title} exists') - - latest = get_comic_library_meta() - form.url.data = '' - - return render_template('index.html', form=form, url=url, series=series, latest=latest), 200 - - - if form.series.data: - print('Download the whole damn lot') - - flash(f'{comic.title} downloaded to {os.path.join(config.library_path, "comics/" + comic.title)}') - - latest = get_comic_library_meta() - comic.archiver.cleanup_worktree() - form.url.data = '' - - return render_template('index.html', form=form, url=url, series=series, latest=latest), 200 - \ No newline at end of file +@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