refactored app structure

This commit is contained in:
Bryan Bailey
2022-04-05 23:12:42 -04:00
parent 56b4ec2670
commit 5ca91452ca
23 changed files with 317 additions and 138 deletions

2
.gitignore vendored
View File

@@ -6,3 +6,5 @@ __pycache__
sample sample
.tox .tox
*.sqlite *.sqlite
migrations
.vscode

29
app/__init__.py Normal file
View File

@@ -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

23
app/email.py Normal file
View File

@@ -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

7
app/main/__init__.py Normal file
View File

@@ -0,0 +1,7 @@
from flask import Blueprint
main = Blueprint('main', __name__)
from . import views, errors

12
app/main/errors.py Normal file
View File

@@ -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

9
app/main/forms.py Normal file
View File

@@ -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')

104
app/main/views.py Normal file
View File

@@ -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/<path:filename>')
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

31
app/models.py Normal file
View File

@@ -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'<Role {self.name!r}>'
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'<User {self.username!r}>'
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))

View File

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 3.3 KiB

View File

@@ -6,13 +6,13 @@
{% for comic in comics %} {% for comic in comics %}
<div class="col col-md-3"> <div class="col col-md-3">
<div class="card"> <div class="card">
<img src="{{ url_for('download_file', filename=comic['cover']) }}" alt="{{ comic['title'] }}" class="card-image-top position-relative"> <img src="{{ url_for('main.download_file', filename=comic['cover']) }}" alt="{{ comic['title'] }}" class="card-image-top position-relative">
<div class="card-body"> <div class="card-body">
<h4 class="card-title">{{ comic['title'] }}</h4> <h4 class="card-title">{{ comic['title'] }}</h4>
<p class="card-text">{{ comic['title'] }}</p> <p class="card-text">{{ comic['title'] }}</p>
</div> </div>
<div class="card-footer text-center"> <div class="card-footer text-center">
<a href="{{url_for('download_file', filename=comic.archive) }}" class="btn btn-info">Download Archive</a> <a href="{{url_for('main.download_file', filename=comic.archive) }}" class="btn btn-info">Download Archive</a>
</div> </div>
</div> </div>
</div> </div>
@@ -29,7 +29,7 @@
{% for comic in comics %} {% for comic in comics %}
<h3>{{ comic['title'] }}</h3> <h3>{{ comic['title'] }}</h3>
<img src="{{ url_for('download_file', filename=comic['cover']) }}"> <img src="{{ url_for('main.download_file', filename=comic['cover']) }}">
{% endfor %} {% endfor %}
{% else %} {% else %}

46
config.py Normal file
View File

@@ -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 <yoinkmediaapp@gmail.com>')
@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
}

View File

@@ -1,6 +1,8 @@
alembic==1.7.7
asgiref==3.5.0 asgiref==3.5.0
attrs==21.4.0 attrs==21.4.0
beautifulsoup4==4.10.0 beautifulsoup4==4.10.0
blinker==1.4
bs4==0.0.1 bs4==0.0.1
certifi==2021.10.8 certifi==2021.10.8
charset-normalizer==2.0.12 charset-normalizer==2.0.12
@@ -10,11 +12,18 @@ coverage==6.3.2
distlib==0.3.4 distlib==0.3.4
filelock==3.6.0 filelock==3.6.0
Flask==2.1.1 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 idna==3.3
importlib-metadata==4.11.3 importlib-metadata==4.11.3
iniconfig==1.1.1 iniconfig==1.1.1
itsdangerous==2.1.2 itsdangerous==2.1.2
Jinja2==3.1.1 Jinja2==3.1.1
Mako==1.2.0
MarkupSafe==2.1.1 MarkupSafe==2.1.1
packaging==21.3 packaging==21.3
platformdirs==2.5.1 platformdirs==2.5.1
@@ -25,10 +34,12 @@ pytest==7.1.0
requests==2.27.1 requests==2.27.1
six==1.16.0 six==1.16.0
soupsieve==2.3.1 soupsieve==2.3.1
SQLAlchemy==1.4.34
toml==0.10.2 toml==0.10.2
tomli==2.0.1 tomli==2.0.1
tox==3.24.5 tox==3.24.5
urllib3==1.26.8 urllib3==1.26.8
virtualenv==20.14.0 virtualenv==20.14.0
Werkzeug==2.1.0 Werkzeug==2.1.0
WTForms==3.0.1
zipp==3.7.0 zipp==3.7.0

View File

@@ -10,7 +10,7 @@ with open('README.md', 'r') as readme:
setuptools.setup( setuptools.setup(
name='yoink', name='yoink',
version='0.2.0', version='0.3.2',
author='Bryan Bailey', author='Bryan Bailey',
author_email='brizzledev@gmail.com', author_email='brizzledev@gmail.com',
description='', description='',

0
tests/__init__.py Normal file
View File

24
tests/test_web.py Normal file
View File

@@ -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'])

147
web.py
View File

@@ -1,141 +1,22 @@
import os 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.config import config
from yoink.comic import Comic from yoink.comic import Comic
class DownloadForm(FlaskForm): app = create_app(os.environ.get('FLASK_CONFIG') or 'default')
url = StringField('Comic URL', validators=[DataRequired()]) migrate = Migrate(app, db)
series = BooleanField('Series? ')
download = SubmitField('Download')
app = Flask(__name__) @app.shell_context_processor
moment = Moment(app) def make_shell_ctx(): return dict(db=db, User=User, Role=Role, ComicMeta=ComicMeta)
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)
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'<Role {self.name!r}>'
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'<User {self.username!r}>'
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/<path:filename>')
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
@app.cli.command()
def test():
''' run unit tests '''
import unittest
tests = unittest.TestLoader().discover('tests')
unittest.TextTestRunner(verbosity=2).run(tests)