diff --git a/.gitignore b/.gitignore index f7275bb..691eef1 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,9 @@ +# venv's venv/ + +# DB stuff +migrations/ +instance/ + +# Byte-compiled / optimized / DLL files +__pycache__/ \ No newline at end of file diff --git a/README.md b/README.md index b407ac5..6c547bc 100644 --- a/README.md +++ b/README.md @@ -1 +1,24 @@ -# WebTech \ No newline at end of file +# How to install +## Setting up your virtual environment +### Creating virtual environment +python -m venv venv + +### Activating environment for package installation (windows) +.\venv\Scripts\activate.bat + +### Installing required packages +pip install -r requirements.txt + +## Setting up the database +### Initialize database +flask --app app.py db init + +### Migrate database +flask --app app.py db migrate + +### upgrade database +flask --app app.py db upgrade + +# Development commands +#### Updating requirements.txt +pip freeze > requirements.txt \ No newline at end of file diff --git a/__pycache__/forms.cpython-312.pyc b/__pycache__/forms.cpython-312.pyc new file mode 100644 index 0000000..b1d7f66 Binary files /dev/null and b/__pycache__/forms.cpython-312.pyc differ diff --git a/app.py b/app.py new file mode 100644 index 0000000..89ab1c5 --- /dev/null +++ b/app.py @@ -0,0 +1,15 @@ +from application import app +from flask import redirect, url_for +from flask_login import login_required # type: ignore + + +# home route +@app.route("/") +@login_required +def index(): + return redirect(url_for("dash.index")) + + +# App deployment +if __name__ == "__main__": + app.run(debug=True, port=5000) diff --git a/application/__init__.py b/application/__init__.py new file mode 100644 index 0000000..d4cd08f --- /dev/null +++ b/application/__init__.py @@ -0,0 +1,43 @@ +from flask import Flask +from flask_sqlalchemy import SQLAlchemy +from flask_migrate import Migrate +from flask_login import LoginManager # type: ignore +import os + +# App Config +app = Flask(__name__) +app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///services.sqlite" +app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False # Wat is dit? +app.config["SECRET_KEY"] = "bvjchsygvduycgsyugc" # Andere secret key +app.config["UPLOAD_FOLDER"] = r"application\static\icons" + +# Ensure the upload folder exists +os.makedirs(app.config["UPLOAD_FOLDER"], exist_ok=True) # type: ignore + +# Object Relational Management +db = SQLAlchemy() +db.init_app(app) + +migrate = Migrate(app, db) + +# bp import +from application.auth.views import auth_blueprint +from application.dash.views import dash_blueprint + +# Login manager +from application.auth.models import User + +login_manager = LoginManager() +login_manager.init_app(app) # type: ignore +login_manager.login_view = "auth.login" # type: ignore + + +@login_manager.user_loader # type: ignore +def load_user(user_id): # type: ignore + return User.query.get(int(user_id)) # type: ignore + + +# Blueprint magic + +app.register_blueprint(dash_blueprint, url_prefix="/dash") +app.register_blueprint(auth_blueprint, url_prefix="/auth") diff --git a/application/__pycache__/__init__.cpython-312.pyc b/application/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..295154d Binary files /dev/null and b/application/__pycache__/__init__.cpython-312.pyc differ diff --git a/application/auth/__pycache__/forms.cpython-312.pyc b/application/auth/__pycache__/forms.cpython-312.pyc new file mode 100644 index 0000000..4a3f074 Binary files /dev/null and b/application/auth/__pycache__/forms.cpython-312.pyc differ diff --git a/application/auth/__pycache__/models.cpython-312.pyc b/application/auth/__pycache__/models.cpython-312.pyc new file mode 100644 index 0000000..4a61f2a Binary files /dev/null and b/application/auth/__pycache__/models.cpython-312.pyc differ diff --git a/application/auth/__pycache__/views.cpython-312.pyc b/application/auth/__pycache__/views.cpython-312.pyc new file mode 100644 index 0000000..5a28b60 Binary files /dev/null and b/application/auth/__pycache__/views.cpython-312.pyc differ diff --git a/application/auth/forms.py b/application/auth/forms.py new file mode 100644 index 0000000..90c31fb --- /dev/null +++ b/application/auth/forms.py @@ -0,0 +1,29 @@ +from flask_wtf import FlaskForm # type: ignore +from wtforms import StringField, SubmitField, PasswordField, BooleanField +from wtforms.validators import DataRequired + + +class defaultForm(FlaskForm): + username = StringField("Username", validators=[DataRequired()]) + password = PasswordField("Password", validators=[DataRequired()]) + submit = SubmitField("Submit") + + +class LoginForm(defaultForm): + pass + + +class RegisterForm(defaultForm): + confirm_password = PasswordField( + "Confirm Password", validators=[DataRequired()] + ) + is_admin = BooleanField("Admin") + + +class UpdateForm(defaultForm): + confirm_password = PasswordField( + "Confirm Password", validators=[DataRequired()] + ) + current_password = PasswordField( + "Current Password", validators=[DataRequired()] + ) diff --git a/application/auth/models.py b/application/auth/models.py new file mode 100644 index 0000000..554a4fc --- /dev/null +++ b/application/auth/models.py @@ -0,0 +1,16 @@ +from application import db +from flask_login import UserMixin # type: ignore + + +class User(db.Model, UserMixin): + id = db.Column(db.Integer, primary_key=True) + username = db.Column(db.String(150), unique=True, nullable=False) + password = db.Column(db.String(150), nullable=False) + is_admin = db.Column(db.Boolean, default=False) + + services = db.relationship("Service", backref="user", lazy="joined") + + def __init__(self, username: str, password: str, is_admin: bool = False): + self.username = username + self.password = password + self.is_admin = is_admin diff --git a/application/auth/templates/admin.html b/application/auth/templates/admin.html new file mode 100644 index 0000000..0eb208f --- /dev/null +++ b/application/auth/templates/admin.html @@ -0,0 +1,51 @@ +{% extends 'base_template.html' %} + +{% block title %} +Register +{% endblock %} + +{% block content %} +
+ {{ form.hidden_tag() }} + + + + + + + + + {% if feedback %} + {% if feedback=="User succesfully added" %} + + {% else %} + + {% endif %} + {% endif %} +
+ {{ form.username.label }}
{{ form.username() }} +
+
+ {{ form.password.label }}
{{ form.password() }} +
+
+ {{ form.confirm_password.label }}
{{ form.confirm_password() }} +
+
+ {{ form.is_admin() }} {{ form.is_admin.label }} +
+
+ {{ form.submit() }} +
+
+{% endblock %} \ No newline at end of file diff --git a/application/auth/templates/login.html b/application/auth/templates/login.html new file mode 100644 index 0000000..2b3a94f --- /dev/null +++ b/application/auth/templates/login.html @@ -0,0 +1,33 @@ +{% extends 'base_template.html' %} + +{% block title %} +Login +{% endblock %} + +{% block content %} +
+ {{ form.hidden_tag() }} + + + + + + {% if feedback %} + + {% endif %} +
+ {{ form.username.label }}
{{ form.username() }} +
+
+ {{ form.password.label }}
{{ form.password() }} +
+
+ {{ form.submit() }} +
+
+{% endblock %} \ No newline at end of file diff --git a/application/auth/templates/update_user.html b/application/auth/templates/update_user.html new file mode 100644 index 0000000..674ddd6 --- /dev/null +++ b/application/auth/templates/update_user.html @@ -0,0 +1,26 @@ +{% extends 'base_template.html' %} + +{% block title %} +Update +{% endblock %} + +{% block content %} +
+ {{ form.hidden_tag() }} + {% if feedback %} +

{{feedback}}

+ {% endif %} +
+ Current password
{{ form.current_password() }} +
+
+ New password
{{ form.password() }} +
+
+ Confirm new password
{{ form.confirm_password() }} +
+
+ {{ form.submit() }} +
+
+{% endblock %} \ No newline at end of file diff --git a/application/auth/views.py b/application/auth/views.py new file mode 100644 index 0000000..7652eea --- /dev/null +++ b/application/auth/views.py @@ -0,0 +1,117 @@ +from flask import Blueprint, render_template, redirect, url_for + +from application import db +from application.auth.models import User +from application.auth.forms import LoginForm +from flask_login import ( # type: ignore + login_required, # type: ignore + login_user, # type: ignore + logout_user, + current_user, +) +from werkzeug.security import check_password_hash, generate_password_hash +from application.decorators import admin_required +from application.auth.forms import RegisterForm, UpdateForm + +auth_blueprint = Blueprint("auth", __name__, template_folder="templates") + + +# Routes +@auth_blueprint.route("/register", methods=["GET", "POST"]) +@admin_required +def register(): + register_form = RegisterForm() + + if register_form.validate_on_submit(): # type: ignore + username = register_form.username.data + password = register_form.password.data + confirm_password = register_form.confirm_password.data + is_admin = register_form.is_admin.data + if confirm_password != password: + return render_template( + "admin.html", + form=register_form, + feedback="Passwords don't match, please try again", + active_page="register", + ) + if User.query.filter_by(username=username).first(): + return render_template( + "admin.html", + form=register_form, + feedback="Username is already taken", + active_page="register", + ) + new_user = User( + username=username, # type: ignore + password=generate_password_hash(password), # type: ignore + is_admin=is_admin, + ) + db.session.add(new_user) + db.session.commit() + return render_template( + "admin.html", + form=RegisterForm(formdata=None), + feedback="User succesfully added", + active_page="register", + ) + return render_template( + "admin.html", form=register_form, active_page="register" + ) + + +@auth_blueprint.route("/update_user", methods=["GET", "POST"]) +@login_required +def update(): + form = UpdateForm(username=current_user.username) + if form.validate_on_submit(): # type: ignore + if not check_password_hash( + current_user.password, form.current_password.data # type: ignore + ): + return render_template( + "update_user.html", + form=form, + feedback="Current password incorrect", + active_page="update", + ) + if form.password.data != form.confirm_password.data: + return render_template( + "update_user.html", + form=form, + feedback="New password mismatched", + active_page="update", + ) + current_user.password = generate_password_hash( + form.password.data # type: ignore + ) + db.session.commit() + logout_user() + return redirect(url_for("auth.login")) + return render_template("update_user.html", form=form, active_page="update") + + +@auth_blueprint.route("/login", methods=["GET", "POST"]) +def login(): + login_form = LoginForm() + feedback = None + + if login_form.validate_on_submit(): # type: ignore + username = login_form.username.data + password = login_form.password.data + user = User.query.filter_by(username=username).first() # type: ignore + + if user and check_password_hash( + user.password, password # type: ignore + ): + login_user(user) # type: ignore + return redirect("/") + else: + feedback = "Username or password is incorrect" + + return render_template("login.html", form=login_form, feedback=feedback) + + +@auth_blueprint.route("/logout") +@login_required +def logout(): + logout_user() + return redirect(url_for("index")) diff --git a/application/dash/forms.py b/application/dash/forms.py new file mode 100644 index 0000000..69bfe06 --- /dev/null +++ b/application/dash/forms.py @@ -0,0 +1,16 @@ +from flask_wtf import FlaskForm # type: ignore +from wtforms import StringField, SubmitField, URLField +from wtforms.validators import DataRequired +from flask_wtf.file import FileField, FileAllowed # type: ignore + + +class ServiceForm(FlaskForm): + name = StringField("Service name:", validators=[DataRequired()]) + url = URLField("Service URL:", validators=[DataRequired()]) + image = FileField( + "Icon:", + validators=[ + FileAllowed(["jpg", "jpeg", "png"], "Unsupported file format"), + ], + ) + submit = SubmitField("Submit") diff --git a/application/dash/models.py b/application/dash/models.py new file mode 100644 index 0000000..588a913 --- /dev/null +++ b/application/dash/models.py @@ -0,0 +1,18 @@ +from application import db + + +class Service(db.Model): + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String, nullable=False) + url = db.Column(db.String, nullable=False) + icon = db.Column(db.String, default="google.png") + + user_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False) + + def __init__( + self, name: str, url: str, user_id: int, icon: str = "google.png" + ): + self.name = name + self.url = url + self.user_id = user_id + self.icon = icon diff --git a/application/dash/templates/add_service.html b/application/dash/templates/add_service.html new file mode 100644 index 0000000..a7b07b7 --- /dev/null +++ b/application/dash/templates/add_service.html @@ -0,0 +1,57 @@ +{% extends 'base_template.html' %} + +{% block title %} +Add service +{% endblock %} + +{% block content %} +
+ {{ form.hidden_tag() }} + + + + + + + + + {% if feedback %} + {% if feedback=="Service succesfully added" %} + + {% else %} + + {% endif %} + {% endif %} +
+ {{ form.name.label }}
{{ form.name() }} +
+
+ {{ form.url.label }}
{{ form.url() }} +
+
+ Upload an icon: +
+
+ {{ form.image() }} +
+
+ {{ form.submit() }} +
+
+{% endblock %} \ No newline at end of file diff --git a/application/dash/templates/dashboard.html b/application/dash/templates/dashboard.html new file mode 100644 index 0000000..238932f --- /dev/null +++ b/application/dash/templates/dashboard.html @@ -0,0 +1,48 @@ +{% extends "base_template.html" %} + +{%block title%}Dashboard{%endblock%} + +{%block content%} +
+ {% for service in services%} +
+
+
+ +
+
+
+
+ {{service["name"]}} +
+ +
+
+
+ {{service["url"]}} +
+
+
+
+
+ {% endfor %} +
+{%endblock%} \ No newline at end of file diff --git a/application/dash/templates/edit_service.html b/application/dash/templates/edit_service.html new file mode 100644 index 0000000..2ccd3a6 --- /dev/null +++ b/application/dash/templates/edit_service.html @@ -0,0 +1,26 @@ +{% extends 'base_template.html' %} + +{% block title %} +Edit service +{% endblock %} + +{% block content %} +
+ {{ form.hidden_tag() }} +
+ {{ form.name.label }}
{{ form.name() }} +
+
+ {{ form.url.label }}
{{ form.url() }} +
+
+ Upload an icon: +
+
+ {{ form.image() }} +
+
+ {{ form.submit(value="Edit") }} +
+
+{% endblock %} \ No newline at end of file diff --git a/application/dash/views.py b/application/dash/views.py new file mode 100644 index 0000000..a8192fc --- /dev/null +++ b/application/dash/views.py @@ -0,0 +1,109 @@ +from application import db +from flask import Blueprint, render_template, redirect, url_for +from application.dash.forms import ServiceForm +from flask_login import login_required, current_user # type: ignore +from application.dash.models import Service +from application.utils import saveImage + +dash_blueprint = Blueprint("dash", __name__, template_folder="templates") + + +@dash_blueprint.route("/", methods=["GET", "POST"]) +@login_required +def index(): + services = current_user.services # type: ignore + return render_template( + "dashboard.html", services=services, active_page="dashboard" + ) + + +@dash_blueprint.route("/delete_item/", methods=["POST"]) +@login_required +def delete_item(service_id: int): + service = Service.query.get_or_404(service_id) + + # Check ownership + if service.user_id != current_user.id: + return redirect(url_for("dash.index")) + + db.session.delete(service) + db.session.commit() + return redirect(url_for("dash.index")) + + +@dash_blueprint.route("/service", methods=["GET", "POST"]) +@login_required +def service(): + service_form = ServiceForm() + + if service_form.validate_on_submit(): # type: ignore + image = service_form.image.data + name = service_form.name.data + url = service_form.url.data + filename2 = "google.png" + if image: + filename2 = saveImage(image) + + new_service = Service( + name=name, # type: ignore + url=url, # type: ignore + user_id=current_user.id, + icon=filename2, + ) # type: ignore + db.session.add(new_service) + db.session.commit() + return render_template( + "add_service.html", + form=ServiceForm(formdata=None), + feedback="Service succesfully added", + active_page="service", + ) + return render_template( + "add_service.html", form=service_form, active_page="service" + ) + + +@dash_blueprint.route( + "/edit_service/", methods=["GET", "POST"] +) +@login_required +def edit_service(service_id: int): + service = Service.query.get_or_404(service_id) + + if current_user.id != service.user_id: + redirect(url_for("dash.index")) + + # Correcte gebruiker + form = ServiceForm() + if form.validate_on_submit(): # type: ignore + commit = False + if service.name != form.name.data: + service.name = form.name.data + commit = True + if service.url != form.url.data: + service.url = form.url.data + commit = True + if form.image.data: + service.icon = saveImage(form.image.data) + commit = True + if commit: + db.session.commit() + return redirect(url_for("dash.index")) + # Fill in correct data + form = ServiceForm(name=service.name, url=service.url) + return render_template("edit_service.html", form=form) + + +""" +def saveImage(image: ...): + filename = secure_filename(image.filename) + save_path = os.path.join( + app.config["UPLOAD_FOLDER"], # type: ignore + str(current_user.id), + filename, + ) + os.makedirs(os.path.dirname(save_path), exist_ok=True) + image.save(save_path) # type: ignore + filename2 = str(current_user.id) + "/" + filename + return filename2 +""" diff --git a/application/decorators.py b/application/decorators.py new file mode 100644 index 0000000..285930e --- /dev/null +++ b/application/decorators.py @@ -0,0 +1,16 @@ +from typing import Callable, Any +from flask_login import current_user # type: ignore +from functools import wraps +from flask import redirect, url_for + + +def admin_required(f: Callable[..., Any]) -> Callable[..., Any]: + @wraps(f) + def decorated_function(*args: ..., **kwargs: ...): + if not current_user.is_authenticated: + return redirect(url_for("login")) + if not current_user.is_admin: + return redirect(url_for("index")) + return f(*args, **kwargs) + + return decorated_function diff --git a/application/static/icons/1/123123123.png b/application/static/icons/1/123123123.png new file mode 100644 index 0000000..dfef930 Binary files /dev/null and b/application/static/icons/1/123123123.png differ diff --git a/application/static/icons/1/Google__G__logo.svg.png b/application/static/icons/1/Google__G__logo.svg.png new file mode 100644 index 0000000..ef2d979 Binary files /dev/null and b/application/static/icons/1/Google__G__logo.svg.png differ diff --git a/application/static/icons/google.png b/application/static/icons/google.png new file mode 100644 index 0000000..893d332 Binary files /dev/null and b/application/static/icons/google.png differ diff --git a/application/static/style.css b/application/static/style.css new file mode 100644 index 0000000..2967361 --- /dev/null +++ b/application/static/style.css @@ -0,0 +1,54 @@ +/* Base template */ +body { + background-color: lightslategray; +} + +/* Dashboard page */ +.grid-container { + display: grid; + grid-template-columns: auto auto auto; + gap: 20px; + background-color: lightslategray; + padding-top: 20px; + margin-left: 10%; + margin-right: 10%; +} + +.grid-container > div { + height: fit-content; + width: 400px; + padding-top: 5px; + padding-bottom: 5px; + border: 2px solid black; + border-radius: 10px; +} + +.fit-picture { + width: 45px; + padding-top: 5px; + padding-bottom: 5px; +} + +/* Login page */ +.form { + display: block; + margin-left: auto; + margin-right: auto; + height: fit-content; + width: fit-content; + border: 2px solid black; + border-radius: 10px; + text-align: center; + margin-top: 10px; + padding: 20px; + font-size: 20px; +} + +.padding { + padding-top: 10px; +} + +.feedback { + font-size: 16px; + color: red; +} \ No newline at end of file diff --git a/application/templates/base_template.html b/application/templates/base_template.html new file mode 100644 index 0000000..af81024 --- /dev/null +++ b/application/templates/base_template.html @@ -0,0 +1,59 @@ + + + + + + + + + + {% block title %}{% endblock %} + + + + + {% block content %} + {% endblock %} + + + \ No newline at end of file diff --git a/application/utils.py b/application/utils.py new file mode 100644 index 0000000..9cbc656 --- /dev/null +++ b/application/utils.py @@ -0,0 +1,17 @@ +from werkzeug.utils import secure_filename +import os +from application import app +from flask_login import current_user # type: ignore + + +def saveImage(image: ...): + filename = secure_filename(image.filename) + save_path = os.path.join( + app.config["UPLOAD_FOLDER"], # type: ignore + str(current_user.id), + filename, + ) + os.makedirs(os.path.dirname(save_path), exist_ok=True) + image.save(save_path) # type: ignore + filename2 = str(current_user.id) + "/" + filename + return filename2 diff --git a/requirements.txt b/requirements.txt index af7203e..ca53bb7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,8 +1,19 @@ +alembic==1.15.2 blinker==1.9.0 click==8.1.8 colorama==0.4.6 Flask==3.1.0 +Flask-Login==0.6.3 +Flask-Migrate==4.1.0 +Flask-SQLAlchemy==3.1.1 +Flask-WTF==1.2.2 +greenlet==3.1.1 itsdangerous==2.2.0 Jinja2==3.1.6 +Mako==1.3.9 MarkupSafe==3.0.2 +pillow==11.1.0 +SQLAlchemy==2.0.40 +typing_extensions==4.13.0 Werkzeug==3.1.3 +WTForms==3.2.1 diff --git a/seed.py b/seed.py new file mode 100644 index 0000000..251e061 --- /dev/null +++ b/seed.py @@ -0,0 +1,54 @@ +from application import db, app +from application.dash.models import Service +from application.auth.models import User +from werkzeug.security import generate_password_hash + +""" +new_strikers = [ + se(name="Erik", strike="y", age=44), + Striker(name="Henk", strike="n", age=88), +] + +""" + +new_users = [ + User( + username="admin", + password=generate_password_hash("admin"), + is_admin=True, + ), + User( + username="Daan", + password=generate_password_hash("pass"), + is_admin=True, + ), + User( + username="stef", + password=generate_password_hash("stef123"), + is_admin=False, + ), +] + +new_services = [ + Service(name="test123", url="http://google.com", user_id=1), + # Daan services + Service(name="Google", url="https://google.com", user_id=2), + Service(name="Netflix", url="https://www.netflix.com", user_id=2), + # Stef services + Service(name="Plex", url="https://plex.local", user_id=3), + Service(name="TrueNAS", url="https://truenas.local", user_id=3), + Service(name="Transmission", url="https://transmission.local", user_id=3), + Service(name="Tautulli", url="https://tautulli.local", user_id=3), + Service(name="Overseerr", url="https://overseerr.local", user_id=3), + Service(name="Plex", url="https://plex.local", user_id=3), +] + +with app.app_context(): + # Remove all existing + Service.query.delete() + User.query.delete() + db.session.commit() + # Then add new + db.session.add_all(new_services) + db.session.add_all(new_users) + db.session.commit() diff --git a/test/123123123.png b/test/123123123.png new file mode 100644 index 0000000..dfef930 Binary files /dev/null and b/test/123123123.png differ