summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitignore4
-rw-r--r--app/.gitignore4
-rw-r--r--app/__init__.py42
-rw-r--r--app/forms.py33
-rw-r--r--app/main.py147
-rw-r--r--app/models.py63
-rw-r--r--app/modules/__init__.py4
-rw-r--r--app/modules/auth/forms.py34
-rw-r--r--app/modules/auth/routes.py73
-rw-r--r--app/modules/common.py75
-rw-r--r--app/static/fonts/CourierPrime-Bold.ttfbin0 -> 91368 bytes
-rw-r--r--app/static/fonts/CourierPrime-BoldItalic.ttfbin0 -> 91908 bytes
-rw-r--r--app/static/fonts/CourierPrime-Italic.ttfbin0 -> 96196 bytes
-rw-r--r--app/static/fonts/CourierPrime-Regular.ttfbin0 -> 98156 bytes
-rw-r--r--app/static/fonts/Inconsolata.otfbin0 -> 58464 bytes
-rw-r--r--app/static/fonts/PublicSans-Black.otfbin0 -> 49640 bytes
-rw-r--r--app/static/fonts/PublicSans-BlackItalic.otfbin0 -> 51720 bytes
-rw-r--r--app/static/fonts/PublicSans-Bold.otfbin0 -> 56032 bytes
-rw-r--r--app/static/fonts/PublicSans-BoldItalic.otfbin0 -> 60100 bytes
-rw-r--r--app/static/fonts/PublicSans-ExtraBold.otfbin0 -> 56468 bytes
-rw-r--r--app/static/fonts/PublicSans-ExtraBoldItalic.otfbin0 -> 60340 bytes
-rw-r--r--app/static/fonts/PublicSans-ExtraLight.otfbin0 -> 56584 bytes
-rw-r--r--app/static/fonts/PublicSans-ExtraLightItalic.otfbin0 -> 60244 bytes
-rw-r--r--app/static/fonts/PublicSans-Italic.otfbin0 -> 60316 bytes
-rw-r--r--app/static/fonts/PublicSans-Light.otfbin0 -> 55120 bytes
-rw-r--r--app/static/fonts/PublicSans-LightItalic.otfbin0 -> 58588 bytes
-rw-r--r--app/static/fonts/PublicSans-Medium.otfbin0 -> 56180 bytes
-rw-r--r--app/static/fonts/PublicSans-MediumItalic.otfbin0 -> 59748 bytes
-rw-r--r--app/static/fonts/PublicSans-Regular.otfbin0 -> 56792 bytes
-rw-r--r--app/static/fonts/PublicSans-SemiBold.otfbin0 -> 56720 bytes
-rw-r--r--app/static/fonts/PublicSans-SemiBoldItalic.otfbin0 -> 60168 bytes
-rw-r--r--app/static/fonts/PublicSans-Thin.otfbin0 -> 50824 bytes
-rw-r--r--app/static/fonts/PublicSans-ThinItalic.otfbin0 -> 52780 bytes
-rw-r--r--app/static/styles/style.css192
-rw-r--r--app/templates/base.html50
-rw-r--r--app/templates/home.html90
-rw-r--r--app/templates/modules/add-item.html19
-rw-r--r--app/templates/modules/invoices.html80
-rw-r--r--app/templates/modules/login.html16
-rw-r--r--app/templates/modules/register.html16
-rw-r--r--app/templates/modules/settings.html45
-rw-r--r--config.py8
-rwxr-xr-xdeploy.sh7
-rw-r--r--initialize_database.py24
-rwxr-xr-xrun.sh7
45 files changed, 1033 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..e4add26
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,4 @@
+# .gitignore for mdl
+
+.venv/
+downloads/ \ No newline at end of file
diff --git a/app/.gitignore b/app/.gitignore
new file mode 100644
index 0000000..1ac0ed9
--- /dev/null
+++ b/app/.gitignore
@@ -0,0 +1,4 @@
+# .gitignore for app
+
+*__pycache__
+*.db \ No newline at end of file
diff --git a/app/__init__.py b/app/__init__.py
new file mode 100644
index 0000000..bd18038
--- /dev/null
+++ b/app/__init__.py
@@ -0,0 +1,42 @@
+# -*- mode: python; -*-
+
+"""This is the application factory.
+
+When the mdl app package is imported, Flask uses the create_app
+function to instantiate the web app.
+
+"""
+
+from flask import Flask
+from flask_sqlalchemy import SQLAlchemy
+from flask_login import LoginManager
+
+from .models import db
+
+
+def create_app():
+ app = Flask(__name__)
+ app.config.from_pyfile("../config.py")
+
+ db.init_app(app)
+
+ login_manager = LoginManager()
+ login_manager.login_view = "auth.login"
+ login_manager.init_app(app)
+
+ from .models import User
+
+ @login_manager.user_loader
+ def load_user(user_id):
+ return User.query.get(int(user_id))
+
+ from .main import main
+
+ app.register_blueprint(main)
+
+ from .modules import common, auth
+
+ app.register_blueprint(common)
+ app.register_blueprint(auth)
+
+ return app
diff --git a/app/forms.py b/app/forms.py
new file mode 100644
index 0000000..a093d5d
--- /dev/null
+++ b/app/forms.py
@@ -0,0 +1,33 @@
+from flask_wtf import FlaskForm
+from wtforms import (
+ SubmitField,
+ SelectField,
+ HiddenField,
+ StringField,
+ PasswordField,
+ IntegerField,
+ FloatField,
+ BooleanField,
+ DateTimeField,
+)
+from wtforms.validators import (
+ InputRequired,
+ Length,
+ NumberRange,
+ EqualTo,
+ ValidationError,
+)
+
+
+class DownloadToRemote(FlaskForm):
+ url = StringField("Link URL", validators=[InputRequired()])
+ download_remote = SubmitField("Download on remote")
+
+
+class ManageRemote(FlaskForm):
+ file_name = HiddenField()
+ download_local = SubmitField("Download locally")
+ remove_remote = SubmitField("Remove remote")
+
+ # def __init__(self, name):
+ # self.name = HiddenField(name)
diff --git a/app/main.py b/app/main.py
new file mode 100644
index 0000000..149ef64
--- /dev/null
+++ b/app/main.py
@@ -0,0 +1,147 @@
+# -*- mode: python; -*-
+
+"""
+routes.py module
+----------------
+
+This Python module contains the logic supporting:
+1. Navigating between website pages
+2. Interpreting user requests to the server
+3. Dispatching requested content back to the user
+
+Python dependencies:
+- flask: provides web application features
+- forms: provides secure user form submission
+- sqlalchemy: provides communication with database on server.
+
+Personal imports:
+These are used to avoid cluttering this file with
+placeholder data for posts' content.
+"""
+
+import os
+import time
+import glob
+from datetime import datetime
+from concurrent.futures import ThreadPoolExecutor
+
+from flask import (
+ Blueprint,
+ render_template,
+ send_from_directory,
+ request,
+ redirect,
+ flash,
+ url_for,
+ jsonify,
+ abort,
+)
+from flask_login import login_required, current_user
+import youtube_dl
+
+from .models import db, Download
+from .forms import DownloadToRemote, ManageRemote
+
+main = Blueprint("main", __name__)
+executor = ThreadPoolExecutor(4)
+
+
+@main.route("/")
+@main.route("/index")
+@login_required
+def home():
+ """Prompt for video URL."""
+ # https://www.youtube.com/watch?v=86khmc6y1yE&list=RDGMEMYH9CUrFO7CfLJpaD7UR85w&index=13
+ download_history = Download.query.order_by(Download.primary_key.desc()).all()
+ downloaded_files = [
+ file for file in os.listdir("downloads") if file.endswith((".mp3", ".m4a"))
+ ]
+ pending_files = [file for file in os.listdir("downloads") if file.endswith(".part")]
+ return render_template(
+ "home.html",
+ user=current_user,
+ form_download_remote=DownloadToRemote(),
+ form_manage_remote=ManageRemote(),
+ downloaded_files=downloaded_files,
+ pending_files=pending_files,
+ download_history=download_history,
+ )
+
+
+@main.route("/download-remote", methods=["POST"])
+@login_required
+def download_remote():
+ """Download audio from URL onto server."""
+ form = DownloadToRemote()
+ if form.validate_on_submit():
+ url = request.form["url"]
+ ydl_opts = {
+ "format": "bestaudio/best",
+ "outtmpl": os.path.join(
+ os.getcwd(),
+ "downloads",
+ "%(title)s.%(ext)s",
+ ),
+ "noplaylist": True,
+ "postprocessors": [
+ {
+ "key": "FFmpegExtractAudio",
+ "preferredcodec": "mp3",
+ "preferredquality": "192",
+ }
+ ],
+ }
+ executor.submit(youtube_dl.YoutubeDL(ydl_opts).download, [url])
+ with youtube_dl.YoutubeDL(ydl_opts) as ydl:
+ # time.sleep(1)
+ info = ydl.extract_info(url, download=False)
+ title = info.get("title")
+ new_download = Download(
+ title=title,
+ url=url,
+ user_id=current_user.primary_key,
+ )
+ db.session.add(new_download)
+ db.session.commit()
+ flash(f"Successfully started downloading {title}.")
+ return redirect("/index")
+ flash(f"Couldn't download {title}.")
+ return redirect("/index")
+
+
+@main.route("/manage-remote/", methods=["POST"])
+@login_required
+def manage_remote():
+ """Manage all files downloaded on remote device.
+
+ The value of the submit button pressed is checked, then the
+ appropriate redirection is performed.
+
+ """
+ form = ManageRemote()
+ if form.validate_on_submit():
+ file_name = request.form.get("file_name")
+ if form.download_local.data:
+ return redirect(url_for("main.download_local", file=file_name))
+ elif form.remove_remote.data:
+ return redirect(url_for("main.remove_remote", file=file_name))
+ flash("Couldn't manage remote file.", "error")
+ return redirect("/index")
+
+
+@main.route("/download-local/<file>", methods=["GET", "POST"])
+@login_required
+def download_local(file):
+ """Download file from remote to local device."""
+ downloads = os.path.join(os.getcwd(), "downloads")
+ return send_from_directory(downloads, file, as_attachment=True)
+
+
+@main.route("/remove-remote/<file>", methods=["GET", "POST"])
+@login_required
+def remove_remote(file):
+ """Remove file from remote device."""
+ file_to_remove = os.path.join(os.getcwd(), "downloads", file)
+ os.remove(file_to_remove)
+ flash(f"Successfully removed file {file}.")
+ return redirect("/index")
diff --git a/app/models.py b/app/models.py
new file mode 100644
index 0000000..5b61045
--- /dev/null
+++ b/app/models.py
@@ -0,0 +1,63 @@
+# -*- mode: python; -*-
+
+
+from flask_sqlalchemy import SQLAlchemy
+from sqlalchemy.sql import func
+
+from flask_login import UserMixin
+
+db = SQLAlchemy()
+
+
+class User(UserMixin, db.Model):
+ """UserMixin inheritance required for features described here:
+
+ https://stackoverflow.com/questions/63231163/what-is-the-usermixin-in-flask"""
+
+ __tablename__ = "User"
+
+ def get_id(self):
+ return self.primary_key
+
+ primary_key = db.Column("UserId", db.Integer, primary_key=True)
+ username = db.Column("Username", db.String(20), nullable=False)
+ hashed_password = db.Column("HashedPassword", db.String(100), nullable=False)
+ name_first = db.Column("NameFirst", db.String(20), nullable=False)
+ name_last = db.Column("NameLast", db.String(20), nullable=False)
+ date_time_created = db.Column(
+ "DateTimeCreated", db.String, server_default=func.now()
+ )
+ date_time_updated = db.Column(
+ "DateTimeUpdated", db.String, server_onupdate=func.now()
+ )
+ downloads = db.relationship("Download", back_populates="user")
+
+ def __repr__(self):
+ return f"<User {self.name_first} {self.name_last}>"
+
+
+class Download(db.Model):
+ """One record per file downloaded."""
+
+ __tablename__ = "Download"
+ primary_key = db.Column("DownloadId", db.Integer, primary_key=True)
+ title = db.Column("Title", db.String(20), nullable=False)
+ url = db.Column("URL", db.String, nullable=False)
+ date_time_downloaded = db.Column(
+ "DateTimeDownloaded", db.String, server_default=func.now()
+ )
+ user_id = db.Column("UserId", db.Integer, db.ForeignKey("User.UserId"))
+ user = db.relationship("User", back_populates="downloads")
+
+ def __init__(
+ self,
+ title,
+ url,
+ user_id,
+ ):
+ self.title = title
+ self.url = url
+ self.user_id = user_id
+
+ def __repr__(self):
+ return f"<Download {self.user.title} for {self.user.first_name} downloaded {self.date_time_downloaded}>"
diff --git a/app/modules/__init__.py b/app/modules/__init__.py
new file mode 100644
index 0000000..fcd1d43
--- /dev/null
+++ b/app/modules/__init__.py
@@ -0,0 +1,4 @@
+# -*- mode: python; -*-
+
+from .common import common
+from .auth.routes import auth
diff --git a/app/modules/auth/forms.py b/app/modules/auth/forms.py
new file mode 100644
index 0000000..a4b4555
--- /dev/null
+++ b/app/modules/auth/forms.py
@@ -0,0 +1,34 @@
+# -*- mode: python; -*-
+
+
+from flask_wtf import FlaskForm
+from wtforms import (
+ SubmitField,
+ HiddenField,
+ StringField,
+ PasswordField,
+ BooleanField,
+)
+from wtforms.validators import (
+ InputRequired,
+ Length,
+ ValidationError,
+)
+
+
+class LoginForm(FlaskForm):
+ username = StringField("Username", validators=[InputRequired()])
+ password = PasswordField("Password", validators=[InputRequired()])
+ remember = BooleanField("Remember")
+ submit = SubmitField("Login")
+
+
+class RegisterForm(LoginForm):
+ def validate_invite_code(self, field):
+ if field.data != "mdltesters2022":
+ raise ValidationError("Invitation code does not match")
+
+ invitation_code = StringField("Invitation code", validators=[InputRequired()])
+ name_first = StringField("First name", validators=[InputRequired()])
+ name_last = StringField("Last name", validators=[InputRequired()])
+ submit = SubmitField("Register")
diff --git a/app/modules/auth/routes.py b/app/modules/auth/routes.py
new file mode 100644
index 0000000..db59bf1
--- /dev/null
+++ b/app/modules/auth/routes.py
@@ -0,0 +1,73 @@
+# -*- mode: python; -*-
+
+from flask import Blueprint, render_template, redirect, url_for, request, flash
+from flask_login import login_user, login_required, logout_user
+from werkzeug.security import generate_password_hash, check_password_hash
+
+from ... import db
+from ...models import User
+from .forms import LoginForm, RegisterForm
+
+
+auth = Blueprint("auth", __name__)
+
+
+@auth.route("/login", methods=["GET", "POST"])
+def login():
+ form = LoginForm()
+ if form.validate_on_submit():
+ req = request.form
+ # print(req["remember"])
+ remember = True if req.get("remember") else False
+ user = User.query.filter_by(username=req["username"]).first()
+ if user is None:
+ flash("User not registered.", "error")
+ return redirect(url_for("auth.register"))
+ if check_password_hash(user.hashed_password, req["password"]) is False:
+ flash("Wrong password.", "error")
+ return redirect(url_for("auth.login"))
+ login_user(user, remember=remember)
+ flash(
+ f"Logged in as user {user.username} successfully. "
+ + f"You will{' not ' if remember is False else ' '}be remembered next time!"
+ )
+ return redirect(url_for("main.home"))
+ return render_template("modules/login.html", form=form)
+
+
+@auth.route("/register", methods=["GET", "POST"])
+def register():
+ form = RegisterForm()
+ if form.validate_on_submit():
+ req = request.form
+ user_already_exists = User.query.filter_by(
+ name_first=req["name_first"],
+ name_last=req["name_last"],
+ ).first()
+ if user_already_exists:
+ flash(
+ f"User {req['name_first']} {req['name_last']} already exists.", "error"
+ )
+ return redirect(url_for("auth.login"))
+ if req["invitation_code"] != "mdltesters2022":
+ flash("Wrong invitation code.", "error")
+ return redirect(url_for("auth.register"))
+ new_user = User(
+ username=req["username"],
+ hashed_password=generate_password_hash(req["password"], method="sha256"),
+ name_first=req["name_first"],
+ name_last=req["name_last"],
+ )
+ db.session.add(new_user)
+ db.session.commit()
+ flash(f"Created user {req['name_first']} {req['name_last']} successfully.")
+ return redirect(url_for("main.home"))
+ return render_template("modules/register.html", form=form)
+
+
+@auth.route("/logout")
+@login_required
+def logout():
+ logout_user()
+ flash(f"Logged out successfully.")
+ return redirect(url_for("main.home"))
diff --git a/app/modules/common.py b/app/modules/common.py
new file mode 100644
index 0000000..1250878
--- /dev/null
+++ b/app/modules/common.py
@@ -0,0 +1,75 @@
+# -*- mode: python; -*-
+
+import inspect
+from flask import Blueprint, request, render_template, redirect, flash, jsonify
+from flask_login import login_required, current_user
+
+from .. import db
+from .. import models
+# from . import forms
+
+from wtforms import SelectField
+
+
+common = Blueprint("common", __name__)
+
+
+@common.route("/modules/<module>/add/<table>", methods=["GET", "POST"])
+@login_required
+def add_item(module, table):
+ """Add new item to table accessible via module."""
+ # print("db table keys are", db.metadata.tables.keys())
+ if table not in db.metadata.tables.keys():
+ return render_template("errors/item-not-found.html", table=table)
+ form = getattr(forms, f"Add{table}")()
+ if form.validate_on_submit():
+ model = getattr(models, table)
+ table_fields = inspect.signature(model).parameters
+ form_values = {key: request.form.get(key) for key in table_fields}
+ print(f"Ready to insert in {table} from {module} {form_values}")
+ record = model(**form_values)
+ db.session.add(record)
+ db.session.commit()
+ item_pk = model.query.order_by(model.primary_key.desc()).first().primary_key
+ flash(f"Successfully added item #{item_pk} to {table} table.", "info")
+ return redirect(f"/modules/{module}")
+ return render_template("modules/add-item.html", table=table, form=form)
+
+
+@common.route("/modules/<module>/edit/<table>/<int:pk>", methods=["GET", "POST"])
+@login_required
+def edit_item(module, table, pk):
+ """Edit existing item in table accessible via module."""
+ if table not in db.metadata.tables.keys():
+ return render_template("errors/item-not-found.html", table=table)
+ model = getattr(models, table)
+ item = model.query.filter_by(primary_key=pk).first()
+ # Instantiate form with selected item's field values.
+ form = getattr(forms, f"Add{table}")(**item.__dict__)
+ if form.validate_on_submit():
+ table_fields = inspect.signature(model).parameters
+ form_values = {key: request.form.get(key) for key in table_fields}
+ print(f"Ready to update {form_values}")
+ model.query.filter_by(primary_key=pk).update(form_values)
+ db.session.commit()
+ flash(f"Successfully edited item #{pk} in {table} table.", "info")
+ return redirect(f"/modules/{module}")
+ return render_template("modules/edit-item.html", table=table, pk=pk, form=form)
+
+
+@common.route("/modules/<module>/delete/<table>/<int:pk>", methods=["POST"])
+@login_required
+def delete_item(module, table, pk):
+ """Delete item with Primary Key = pk from table in module."""
+ model = getattr(models, table)
+ record = model.query.filter_by(primary_key=pk).first()
+ db.session.delete(record)
+ db.session.commit()
+ flash(f"Successfully removed item #{pk} from {table} table.", "info")
+ return redirect(f"/modules/{module}")
+
+
+@common.route("/modules/settings")
+@login_required
+def settings():
+ return render_template("modules/settings.html", user=current_user)
diff --git a/app/static/fonts/CourierPrime-Bold.ttf b/app/static/fonts/CourierPrime-Bold.ttf
new file mode 100644
index 0000000..1b0888c
--- /dev/null
+++ b/app/static/fonts/CourierPrime-Bold.ttf
Binary files differ
diff --git a/app/static/fonts/CourierPrime-BoldItalic.ttf b/app/static/fonts/CourierPrime-BoldItalic.ttf
new file mode 100644
index 0000000..d4e7186
--- /dev/null
+++ b/app/static/fonts/CourierPrime-BoldItalic.ttf
Binary files differ
diff --git a/app/static/fonts/CourierPrime-Italic.ttf b/app/static/fonts/CourierPrime-Italic.ttf
new file mode 100644
index 0000000..75a1343
--- /dev/null
+++ b/app/static/fonts/CourierPrime-Italic.ttf
Binary files differ
diff --git a/app/static/fonts/CourierPrime-Regular.ttf b/app/static/fonts/CourierPrime-Regular.ttf
new file mode 100644
index 0000000..db4e6c1
--- /dev/null
+++ b/app/static/fonts/CourierPrime-Regular.ttf
Binary files differ
diff --git a/app/static/fonts/Inconsolata.otf b/app/static/fonts/Inconsolata.otf
new file mode 100644
index 0000000..3488898
--- /dev/null
+++ b/app/static/fonts/Inconsolata.otf
Binary files differ
diff --git a/app/static/fonts/PublicSans-Black.otf b/app/static/fonts/PublicSans-Black.otf
new file mode 100644
index 0000000..bbbaa26
--- /dev/null
+++ b/app/static/fonts/PublicSans-Black.otf
Binary files differ
diff --git a/app/static/fonts/PublicSans-BlackItalic.otf b/app/static/fonts/PublicSans-BlackItalic.otf
new file mode 100644
index 0000000..46e3f71
--- /dev/null
+++ b/app/static/fonts/PublicSans-BlackItalic.otf
Binary files differ
diff --git a/app/static/fonts/PublicSans-Bold.otf b/app/static/fonts/PublicSans-Bold.otf
new file mode 100644
index 0000000..7a2b62b
--- /dev/null
+++ b/app/static/fonts/PublicSans-Bold.otf
Binary files differ
diff --git a/app/static/fonts/PublicSans-BoldItalic.otf b/app/static/fonts/PublicSans-BoldItalic.otf
new file mode 100644
index 0000000..718357f
--- /dev/null
+++ b/app/static/fonts/PublicSans-BoldItalic.otf
Binary files differ
diff --git a/app/static/fonts/PublicSans-ExtraBold.otf b/app/static/fonts/PublicSans-ExtraBold.otf
new file mode 100644
index 0000000..09b52dd
--- /dev/null
+++ b/app/static/fonts/PublicSans-ExtraBold.otf
Binary files differ
diff --git a/app/static/fonts/PublicSans-ExtraBoldItalic.otf b/app/static/fonts/PublicSans-ExtraBoldItalic.otf
new file mode 100644
index 0000000..5b33222
--- /dev/null
+++ b/app/static/fonts/PublicSans-ExtraBoldItalic.otf
Binary files differ
diff --git a/app/static/fonts/PublicSans-ExtraLight.otf b/app/static/fonts/PublicSans-ExtraLight.otf
new file mode 100644
index 0000000..49b407d
--- /dev/null
+++ b/app/static/fonts/PublicSans-ExtraLight.otf
Binary files differ
diff --git a/app/static/fonts/PublicSans-ExtraLightItalic.otf b/app/static/fonts/PublicSans-ExtraLightItalic.otf
new file mode 100644
index 0000000..c76e855
--- /dev/null
+++ b/app/static/fonts/PublicSans-ExtraLightItalic.otf
Binary files differ
diff --git a/app/static/fonts/PublicSans-Italic.otf b/app/static/fonts/PublicSans-Italic.otf
new file mode 100644
index 0000000..38996ad
--- /dev/null
+++ b/app/static/fonts/PublicSans-Italic.otf
Binary files differ
diff --git a/app/static/fonts/PublicSans-Light.otf b/app/static/fonts/PublicSans-Light.otf
new file mode 100644
index 0000000..126544e
--- /dev/null
+++ b/app/static/fonts/PublicSans-Light.otf
Binary files differ
diff --git a/app/static/fonts/PublicSans-LightItalic.otf b/app/static/fonts/PublicSans-LightItalic.otf
new file mode 100644
index 0000000..1e6aa6f
--- /dev/null
+++ b/app/static/fonts/PublicSans-LightItalic.otf
Binary files differ
diff --git a/app/static/fonts/PublicSans-Medium.otf b/app/static/fonts/PublicSans-Medium.otf
new file mode 100644
index 0000000..93507a5
--- /dev/null
+++ b/app/static/fonts/PublicSans-Medium.otf
Binary files differ
diff --git a/app/static/fonts/PublicSans-MediumItalic.otf b/app/static/fonts/PublicSans-MediumItalic.otf
new file mode 100644
index 0000000..f5ddb90
--- /dev/null
+++ b/app/static/fonts/PublicSans-MediumItalic.otf
Binary files differ
diff --git a/app/static/fonts/PublicSans-Regular.otf b/app/static/fonts/PublicSans-Regular.otf
new file mode 100644
index 0000000..d2b3f16
--- /dev/null
+++ b/app/static/fonts/PublicSans-Regular.otf
Binary files differ
diff --git a/app/static/fonts/PublicSans-SemiBold.otf b/app/static/fonts/PublicSans-SemiBold.otf
new file mode 100644
index 0000000..4ab6b89
--- /dev/null
+++ b/app/static/fonts/PublicSans-SemiBold.otf
Binary files differ
diff --git a/app/static/fonts/PublicSans-SemiBoldItalic.otf b/app/static/fonts/PublicSans-SemiBoldItalic.otf
new file mode 100644
index 0000000..a28f6c0
--- /dev/null
+++ b/app/static/fonts/PublicSans-SemiBoldItalic.otf
Binary files differ
diff --git a/app/static/fonts/PublicSans-Thin.otf b/app/static/fonts/PublicSans-Thin.otf
new file mode 100644
index 0000000..dee0ae2
--- /dev/null
+++ b/app/static/fonts/PublicSans-Thin.otf
Binary files differ
diff --git a/app/static/fonts/PublicSans-ThinItalic.otf b/app/static/fonts/PublicSans-ThinItalic.otf
new file mode 100644
index 0000000..c6b481a
--- /dev/null
+++ b/app/static/fonts/PublicSans-ThinItalic.otf
Binary files differ
diff --git a/app/static/styles/style.css b/app/static/styles/style.css
new file mode 100644
index 0000000..328a1aa
--- /dev/null
+++ b/app/static/styles/style.css
@@ -0,0 +1,192 @@
+/* -*- mode: web; -*- */
+
+
+:root {
+ --primary-color: #003B5C;
+ --secondary-color: #C3D7EE;
+ --home: #c8c8c8;
+ --yes: #80FF80;
+ --no: #FF8080;
+ font-size: 18;
+ --fast-speed: 0.2s;
+ --med-speed: 0.4s;
+ --slow-speed: 1s;
+}
+
+body {
+ font-family: "Public Sans", sans-serif;
+ line-height: 1.2;
+ margin: 0;
+ padding: 0;
+}
+
+
+@font-face {
+ font-family: "Public Sans";
+ src: url("/static/fonts/PublicSans-Regular.otf");
+}
+
+@font-face {
+ font-family: "Inconsolata";
+ src: url("/static/fonts/Inconsolata.otf");
+}
+
+
+h1 {
+ margin: 0.25em;
+}
+
+nav {
+ /* display: flex; */
+ background: darkgrey;
+ color: white;
+ /* margin: 0.5em; */
+ padding: 0 0.5em;
+ /* justify-content: space-between; */
+}
+
+nav#user {
+ /* background: red; */
+ display: flex;
+ justify-content: space-between;
+}
+
+nav#user ul {
+ justify-content: end;
+}
+
+nav#modules {
+ /* left: 0; */
+}
+
+nav#actions {
+ top: 0;
+ position: sticky;
+}
+
+
+nav ul {
+ display: flex;
+ flex-wrap: wrap;
+ margin: 0;
+ padding: 0.25em 0;
+ list-style: none;
+}
+
+nav ul li {
+ margin: 0.25em;
+ /* margin: 0 0 0.5em 0; */
+ /* padding: 0.5em 0; */
+}
+
+
+
+
+.button {
+ display: inline-block;
+ padding: 0.5em;
+ background: dimgray;
+ color: white;
+ border-radius: 12px;
+ text-decoration: none;
+ border: 1px dimgray solid;
+}
+
+.button-light {
+ background: white;
+ color: dimgray;
+}
+
+.button:hover {
+ background: white;
+ color: black;
+ border: 1px dimgray solid;
+}
+
+#content {
+ max-width: 60vw;
+ margin: 0 auto;
+}
+
+table {
+ font-family: "Inconsolata";
+ line-height: 1.5;
+ /* border-collapse: collapse; */
+ /* margin: 2em auto; */
+ width: 100%;
+}
+
+table thead {
+ background: dimgray;
+ color: white;
+}
+
+table tr:nth-child(even) {
+ background: lightgray;
+}
+
+#flash {
+ position: fixed;
+ max-width: 16em;
+ bottom: 0;
+ right: 0;
+ padding: 0 0.5em;
+}
+
+#flash ul {
+ margin: 0;
+ padding: 0.25em 0;
+ list-style-type: none;
+}
+
+#flash ul li {
+ margin: 0.25em;
+}
+
+@keyframes fadeIn {
+ 0% {
+ opacity: 0;
+ transform: translateY(100%);
+ }
+ 100% {
+ opacity: 0.8;
+ transform: translateY(0);
+ }
+}
+
+.alert {
+ padding: 1em;
+ margin: 0.5em;
+ border-radius: 8px;
+ /* border: 1px dimgray solid; */
+ opacity: 0.8;
+ animation: 0.5s ease-out 0s 1 fadeIn;
+}
+
+/* The default alert category */
+.alert-message {
+ background: cornflowerblue;
+ color: black;
+}
+
+.alert-info {
+ background: darkblue;
+ color: white;
+}
+
+.alert-error {
+ background: maroon;
+ color: white;
+}
+#downloads {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: space-between;
+ gap: 1em;
+}
+fieldset {
+ margin: 0 auto;
+ max-width: 16em;
+ display: flex;
+ flex-direction: column;
+}
diff --git a/app/templates/base.html b/app/templates/base.html
new file mode 100644
index 0000000..7a63ddf
--- /dev/null
+++ b/app/templates/base.html
@@ -0,0 +1,50 @@
+{# -*- mode: web; -*- #}
+
+<!doctype html>
+
+<html lang="en">
+ <head>
+ <meta charset="utf-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to">
+ <title>mdl</title>
+ <meta name="author" content="Marius Peter">
+ <meta name="description" content="A draft page for mdl.">
+ <!-- <link rel="icon" href="{{ url_for('static', filename='img/favicon.png') }}"> -->
+ <link rel="stylesheet" href="{{ url_for('static', filename='styles/style.css') }}">
+ </head>
+ <body>
+ <nav id="user">
+ <div style="display: flex; align-items: baseline;">
+ <ul>
+ <li><a href="https:apps.mlnp.fr" class="button">Apps</a></li>
+ </ul>
+ <h1>{% block title %}{% endblock %}</h1>
+ </div>
+ <ul>
+ {% if current_user.is_authenticated %}
+ <li><a href="{{ url_for('common.settings') }}" class="button">Settings</a></li>
+ <li><a href="{{ url_for('auth.logout') }}" class="button">Logout</a></li>
+ {% else %}
+ <li><a href="{{ url_for('auth.login') }}" class="button">Login</a></li>
+ <li><a href="{{ url_for('auth.register') }}" class="button">Register</a></li>
+ {% endif %}
+ </ul>
+ </nav>
+ <div id="content">
+ {% block content %}{% endblock %}
+ </div>
+ {# Flashed messages added last, so that they appear on top of the content. #}
+ {% with messages = get_flashed_messages(with_categories=true) %}
+ {% if messages %}
+ <div id="flash">
+ <ul>
+ {% for category, message in messages %}
+ <li class="alert alert-{{ category }}">{{ message }}</li>
+ {% endfor %}
+ </ul>
+ </div>
+ {% endif %}
+ {% endwith %}
+ <!-- <script src="js/scripts.js"></script> -->
+ </body>
+</html>
diff --git a/app/templates/home.html b/app/templates/home.html
new file mode 100644
index 0000000..e4e690f
--- /dev/null
+++ b/app/templates/home.html
@@ -0,0 +1,90 @@
+{# -*- mode: web; -*- #}
+
+{% extends "base.html" %}
+
+{% block title %}
+{% if current_user.is_authenticated %}
+Welcome, {{ user.name_first }} {{ user.name_last }}!
+{% else %}
+Welcome to <code>mdl</code>, the music downloader
+{% endif %}
+{% endblock %}
+
+{% block actions %}
+{# <li><a href="{{ url_for('main.download_database') }}" class="button">Download database</a></li> #}
+{% endblock %}
+
+{% block content %}
+{% if current_user.is_authenticated %}
+<div id="downloads">
+ <div id="download-new">
+ <h2>New</h2>
+ <form action="{{ url_for('main.download_remote') }}" method="POST">
+ <fieldset>
+ <legend>Download audio from a URL to the server.</legend>
+ {% with form = form_download_remote %}
+ {{ form.csrf_token }}
+ {{ form.url.label() }}
+ {{ form.url() }}
+ {{ form.download_remote() }}
+ {% endwith %}
+ </fieldset>
+ </form>
+ </div>
+ {% if pending_files %}
+ <div id="download-pending">
+ <h2>Pending</h2>
+ {% for file in pending_files %}
+ <form action="#" method="POST">
+ <fieldset>
+ <legend>{{ file }}</legend>
+ </fieldset>
+ </form>
+ {% endfor %}
+ </div>
+ {% endif %}
+ {% if downloaded_files %}
+ <div id="download-finished">
+ <h2>Finished</h2>
+ {% for file in downloaded_files %}
+ <form action="{{ url_for('main.manage_remote') }}" method="POST">
+ <fieldset>
+ <legend>{{ file }}</legend>
+ {% with form = form_manage_remote %}
+ {{ form.csrf_token }}
+ {{ form.file_name(value=file) }}
+ {{ form.download_local() }}
+ {{ form.remove_remote() }}
+ {% endwith %}
+ </fieldset>
+ </form>
+ {% endfor %}
+ </div>
+ {% endif %}
+</div>
+<h2>Download history</h2>
+<table>
+ <thead>
+ <tr>
+ <th>ID</th>
+ <th>Title</th>
+ <th>Downloaded</th>
+ <th>User</th>
+ </tr>
+ </thead>
+ <tbody>
+ {% for file in download_history %}
+ <tr>
+ <td>{{ file.primary_key }}</td>
+ <td><a href="{{ file.url }}">{{ file.title }}</a></td>
+ <td>{{ file.date_time_downloaded }}</td>
+ <td>{{ file.user.username }}</td>
+ </tr>
+ {% endfor %}
+ </tbody>
+</table>
+{% else %}
+<p>You need to be logged in before using this web app.</p>
+{% endif %}
+
+{% endblock %}
diff --git a/app/templates/modules/add-item.html b/app/templates/modules/add-item.html
new file mode 100644
index 0000000..4eaee3d
--- /dev/null
+++ b/app/templates/modules/add-item.html
@@ -0,0 +1,19 @@
+{# -*- mode: web; -*- #}
+
+{% extends "base.html" %}
+
+
+{% block title %}Add {{ table }} item{% endblock %}
+{% block content %}
+
+<form action="{{ request.path }}" method="POST">
+ <fieldset>
+ <legend>Add a new item to our {{ table }} table.</legend>
+ {% for field in form %}
+ {{ field.label() }}<br/>
+ {{ field() }}<br/>
+ {% endfor %}
+ </fieldset>
+</form>
+
+{% endblock %}
diff --git a/app/templates/modules/invoices.html b/app/templates/modules/invoices.html
new file mode 100644
index 0000000..9e8f765
--- /dev/null
+++ b/app/templates/modules/invoices.html
@@ -0,0 +1,80 @@
+{# -*- mode: web; -*- #}
+
+{% extends "base.html" %}
+{% block title %}
+Invoices
+{% endblock %}
+
+{% block actions %}
+<li><a href="{{ url_for('common.add_item', module='invoices', table='Invoice') }}" class="button">Add invoice</a></li>
+<li></li>
+{% endblock %}
+
+{% block content %}
+<i>Track your invoices and create new ones here.</i><br/>
+
+{# Pagination Links #}
+{# gotten from https://betterprogramming.pub/simple-flask-pagination-example-4190b12c2e2e #}
+<center>
+ {# Loop through the number of pages to display a link for each #}
+ {% for page_num in invoices.iter_pages(left_edge=1, right_edge=1, left_current=1, right_current=2) %}
+ {% if page_num %}
+ {# Check for the active page and set the link to "Active" #}
+ {% if invoices.page == page_num %}
+ <a href="{{ url_for('invoices.view', page=page_num) }}"
+ class="button">
+ {{ page_num }}
+ </a>
+ {% else %}
+ <a href="{{ url_for('invoices.view', page=page_num) }}"
+ class="button button-light">
+ {{ page_num }}
+ </a>
+ {% endif %}
+ {% else %}
+ ...
+ {% endif %}
+ {% endfor %}
+</center>
+<table>
+ <thead>
+ <tr>
+ <th></th>
+ <th>ID</th>
+ <th>Created</th>
+ <th>Alternative Invoice ID</th>
+ <th>Customer Name</th>
+ <th>Customer Reference</th>
+ <th>Date Billed</th>
+ <th>Date Due</th>
+ <th>Amount (Net €)</th>
+ <th>Amount (Gross €)</th>
+ <th>Tax Amount (€)</th>
+ </tr>
+ </thead>
+ <tbody>
+ {% for invoice in invoices.items %}
+ <tr {% if invoice.archive == True %} style="color: dimgray" {% endif %}>
+ <td>
+ <form method="post" action="{{ url_for('common.edit_item', module='invoices', pk=invoice.primary_key, table='Invoice') }}">
+ <button>archive</button>
+ </form>
+ <form method="get" action="{{ url_for('invoices.preview', pk=invoice.primary_key) }}">
+ <button>preview</button>
+ </form>
+ </td>
+ <td>{{ invoice.primary_key }}</td>
+ <td>{{ invoice.date_time_created }}</td>
+ <td>{{ invoice.invoice_id_alt }}</td>
+ <td>{{ invoice.customer.name }}</td>
+ <td>{{ invoice.customer_reference }}</td>
+ <td>{{ invoice.date_billed }}</td>
+ <td>{{ invoice.date_due }}</td>
+ <td>{{ invoice.amount_net }}</td>
+ <td>{{ invoice.amount_gross }}</td>
+ <td>{{ invoice.amount_tax }}</td>
+ </tr>
+ {% endfor %}
+ </tbody>
+</table>
+{% endblock %}
diff --git a/app/templates/modules/login.html b/app/templates/modules/login.html
new file mode 100644
index 0000000..e66e6a4
--- /dev/null
+++ b/app/templates/modules/login.html
@@ -0,0 +1,16 @@
+{# -*- mode: web; -*- #}
+
+{% extends "base.html" %}
+
+{# the login form #}
+{% block content %}
+<form action="{{ url_for('auth.login') }}" method="POST">
+ <fieldset>
+ <legend>Login</legend>
+ {% for field in form %}
+ {{ field.label() }}
+ {{ field() }}<br/>
+ {% endfor %}
+ </fieldset>
+</form>
+{% endblock %}
diff --git a/app/templates/modules/register.html b/app/templates/modules/register.html
new file mode 100644
index 0000000..76fc1e2
--- /dev/null
+++ b/app/templates/modules/register.html
@@ -0,0 +1,16 @@
+{# -*- mode: web; -*- #}
+
+{% extends "base.html" %}
+
+{# the register form #}
+{% block content %}
+<form action="{{ url_for('auth.register') }}" method="POST">
+ <fieldset>
+ <legend>Register</legend>
+ {% for field in form %}
+ {{ field.label() }}
+ {{ field() }}<br/>
+ {% endfor %}
+ </fieldset>
+</form>
+{% endblock %}
diff --git a/app/templates/modules/settings.html b/app/templates/modules/settings.html
new file mode 100644
index 0000000..392ab47
--- /dev/null
+++ b/app/templates/modules/settings.html
@@ -0,0 +1,45 @@
+{# -*- mode: web; -*- #}
+
+{% extends "base.html" %}
+
+{% block title %}
+Settings for user {{ current_user.username }}
+{% endblock %}
+
+{% block content %}
+
+<p>Welcome, {{ current_user.name_first }} {{ current_user.name_last }}!</p>
+
+<h2>User profile</h2>
+
+<form method="post" action="{{ url_for('common.edit_item', module='settings', pk=current_user.primary_key, table='User' ) }}">
+ <button>edit</button>
+</form>
+
+<table>
+ <thead>
+ <tr>
+ <th>Field</th>
+ <th>Value</th>
+ </tr>
+ </thead>
+ <tbody>
+ <tr>
+ <td>Username</td>
+ <td>{{ current_user.username }}</td>
+ </tr>
+ <tr>
+ <td>First Name</td>
+ <td>{{ current_user.name_first }}</td>
+ </tr>
+ <tr>
+ <td>Last Name</td>
+ <td>{{ current_user.name_last }}</td>
+ </tr>
+ <tr>
+ <td> Last Updated</td>
+ <td>{{ current_user.date_time_updated }}</td>
+ </tr>
+ </tbody>
+</table>
+{% endblock %}
diff --git a/config.py b/config.py
new file mode 100644
index 0000000..984a0c5
--- /dev/null
+++ b/config.py
@@ -0,0 +1,8 @@
+# -*- mode: python; -*-
+
+
+# DEBUG = True
+APPLICATION_ROOT = "/mdl"
+SECRET_KEY = "Scooby_Lu,_where_are_you?"
+SQLALCHEMY_DATABASE_URI = "sqlite:///" + "mdl.db"
+SQLALCHEMY_TRACK_MODIFICATIONS = True
diff --git a/deploy.sh b/deploy.sh
new file mode 100755
index 0000000..218da17
--- /dev/null
+++ b/deploy.sh
@@ -0,0 +1,7 @@
+#! /bin/bash
+
+remote='root@192.162.71.223:/var/www/apps.mlnp.fr/mdl'
+
+echo Deploying now.
+rsync -razvP . $remote
+echo successfully deployed.
diff --git a/initialize_database.py b/initialize_database.py
new file mode 100644
index 0000000..7b7226b
--- /dev/null
+++ b/initialize_database.py
@@ -0,0 +1,24 @@
+# -*- mode: python; -*-
+
+import os
+from app import create_app, db
+
+
+app = create_app()
+DB_NAME = "mdl.db"
+app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///" + DB_NAME
+db_path = f"app/{DB_NAME}"
+
+
+if os.path.exists(db_path):
+ os.remove(db_path)
+ print(f"Existing database {db_path} has been deleted successfully.")
+else:
+ print(f"Database {db_path} does not exist yet, creating now.")
+
+with app.app_context():
+ print(f"Creating database {db_path}...")
+ db.create_all()
+
+
+print(f"Database {db_path} created successfully.")
diff --git a/run.sh b/run.sh
new file mode 100755
index 0000000..7927234
--- /dev/null
+++ b/run.sh
@@ -0,0 +1,7 @@
+#! /bin/bash
+
+source .venv/bin/activate
+export FLASK_APP=app
+export FLASK_ENV=development
+# export FLASK_DEBUG=1
+flask run
Copyright 2019--2024 Marius PETER