refactored app structure
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -6,3 +6,5 @@ __pycache__
|
||||
sample
|
||||
.tox
|
||||
*.sqlite
|
||||
migrations
|
||||
.vscode
|
||||
29
app/__init__.py
Normal file
29
app/__init__.py
Normal 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
23
app/email.py
Normal 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
7
app/main/__init__.py
Normal file
@@ -0,0 +1,7 @@
|
||||
from flask import Blueprint
|
||||
|
||||
|
||||
main = Blueprint('main', __name__)
|
||||
|
||||
|
||||
from . import views, errors
|
||||
12
app/main/errors.py
Normal file
12
app/main/errors.py
Normal 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
9
app/main/forms.py
Normal 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
104
app/main/views.py
Normal 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
31
app/models.py
Normal 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))
|
||||
|
Before Width: | Height: | Size: 3.3 KiB After Width: | Height: | Size: 3.3 KiB |
@@ -6,13 +6,13 @@
|
||||
{% for comic in comics %}
|
||||
<div class="col col-md-3">
|
||||
<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">
|
||||
<h4 class="card-title">{{ comic['title'] }}</h4>
|
||||
<p class="card-text">{{ comic['title'] }}</p>
|
||||
</div>
|
||||
<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>
|
||||
@@ -29,7 +29,7 @@
|
||||
{% for comic in comics %}
|
||||
|
||||
<h3>{{ comic['title'] }}</h3>
|
||||
<img src="{{ url_for('download_file', filename=comic['cover']) }}">
|
||||
<img src="{{ url_for('main.download_file', filename=comic['cover']) }}">
|
||||
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
46
config.py
Normal file
46
config.py
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
2
setup.py
2
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='',
|
||||
|
||||
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
24
tests/test_web.py
Normal file
24
tests/test_web.py
Normal 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
147
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)
|
||||
|
||||
|
||||
|
||||
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.shell_context_processor
|
||||
def make_shell_ctx(): return dict(db=db, User=User, Role=Role, ComicMeta=ComicMeta)
|
||||
|
||||
@app.cli.command()
|
||||
def test():
|
||||
''' run unit tests '''
|
||||
import unittest
|
||||
tests = unittest.TestLoader().discover('tests')
|
||||
unittest.TextTestRunner(verbosity=2).run(tests)
|
||||
Reference in New Issue
Block a user