diff --git a/.gitignore b/.gitignore index 801834b6b..4aae66f4c 100644 --- a/.gitignore +++ b/.gitignore @@ -444,5 +444,5 @@ poetry.toml testing/core apps/wizarr-backend-next/ apps/wizarr-backend-old/ -database/ +/apps/wizarr-backend/database/ .sentryclirc diff --git a/apps/wizarr-backend/wizarr_backend/api/routes/__init__.py b/apps/wizarr-backend/wizarr_backend/api/routes/__init__.py index 9562f3124..d42008bd3 100644 --- a/apps/wizarr-backend/wizarr_backend/api/routes/__init__.py +++ b/apps/wizarr-backend/wizarr_backend/api/routes/__init__.py @@ -13,6 +13,7 @@ from .authentication_api import api as authentication_api # REVIEW - This is almost completed from .backup_api import api as backup_api from .discord_api import api as discord_api +from .image_api import api as image_api from .invitations_api import api as invitations_api # REVIEW - This is almost completed from .libraries_api import api as libraries_api from .notifications_api import api as notifications_api @@ -27,6 +28,7 @@ from .webhooks_api import api as webhooks_api from .logging_api import api as logging_api from .oauth_api import api as oauth_api +from .onboarding_api import api as onboarding_api from .mfa_api import api as mfa_api from .utilities_api import api as utilities_api from .jellyfin_api import api as jellyfin_api @@ -111,6 +113,7 @@ def handle_request_exception(error): api.add_namespace(discord_api) api.add_namespace(emby_api) api.add_namespace(healthcheck_api) +api.add_namespace(image_api) api.add_namespace(invitations_api) api.add_namespace(jellyfin_api) api.add_namespace(libraries_api) @@ -118,6 +121,7 @@ def handle_request_exception(error): api.add_namespace(mfa_api) api.add_namespace(notifications_api) api.add_namespace(oauth_api) +api.add_namespace(onboarding_api) api.add_namespace(plex_api) api.add_namespace(requests_api) api.add_namespace(scan_libraries_api) @@ -135,4 +139,3 @@ def handle_request_exception(error): # TODO: Tasks API # TODO: API API -# TODO: HTML API diff --git a/apps/wizarr-backend/wizarr_backend/api/routes/image_api.py b/apps/wizarr-backend/wizarr_backend/api/routes/image_api.py new file mode 100644 index 000000000..0def422d7 --- /dev/null +++ b/apps/wizarr-backend/wizarr_backend/api/routes/image_api.py @@ -0,0 +1,85 @@ +import os +from json import dumps, loads +from uuid import uuid4 +from flask import send_from_directory, current_app, request +from flask_jwt_extended import jwt_required +from flask_restx import Namespace, Resource, reqparse +from werkzeug.utils import secure_filename +from werkzeug.datastructures import FileStorage + +api = Namespace("Image", description="Image related operations", path="/image") + +# Define the file upload parser +file_upload_parser = reqparse.RequestParser() +file_upload_parser.add_argument('file', location='files', + type=FileStorage, required=True, + help='Image file') + +@api.route("") +class ImageListApi(Resource): + """API resource for all images""" + + @jwt_required() + @api.doc(security="jwt") + @api.expect(file_upload_parser) + def post(self): + """Upload image""" + # Check if the post request has the file part + if 'file' not in request.files: + return {"message": "No file part"}, 400 + file = request.files['file'] + # If the user does not select a file, the browser submits an + # empty file without a filename. + if file.filename == '': + return {"message": "No selected file"}, 400 + if file: + # Extract the file extension + file_extension = os.path.splitext(secure_filename(file.filename))[1].lower() + if file_extension not in ['.png', '.jpg', '.jpeg']: + return {"message": "Unsupported file format"}, 400 + + upload_folder = current_app.config['UPLOAD_FOLDER'] + if not os.path.exists(upload_folder): + os.makedirs(upload_folder) + # Generate a unique filename using UUID + filename = f"{uuid4()}{file_extension}" + + # Check if the file exists and generate a new UUID if it does + while os.path.exists(os.path.join(upload_folder, filename)): + filename = f"{uuid4()}{file_extension}" + file_path = os.path.join(upload_folder, filename) + file.save(file_path) + return {"message": f"File {filename} uploaded successfully", "filename": filename}, 201 + + +@api.route("/") +class ImageAPI(Resource): + """API resource for a single image""" + + @api.response(404, "Image not found") + @api.response(500, "Internal server error") + def get(self, filename): + """Get image""" + # Assuming images are stored in a directory specified by UPLOAD_FOLDER config + upload_folder = current_app.config['UPLOAD_FOLDER'] + image_path = os.path.join(upload_folder, filename) + if os.path.exists(image_path): + return send_from_directory(upload_folder, filename) + else: + return {"message": "Image not found"}, 404 + + @jwt_required() + @api.doc(description="Delete a single image") + @api.response(404, "Image not found") + @api.response(500, "Internal server error") + def delete(self, filename): + """Delete image""" + upload_folder = current_app.config['UPLOAD_FOLDER'] + image_path = os.path.join(upload_folder, filename) + + # Check if the file exists + if not os.path.exists(image_path): + return {"message": "Image not found"}, 404 + + os.remove(image_path) + return {"message": "Image deleted successfully"}, 200 diff --git a/apps/wizarr-backend/wizarr_backend/api/routes/onboarding_api.py b/apps/wizarr-backend/wizarr_backend/api/routes/onboarding_api.py new file mode 100644 index 000000000..096a5c358 --- /dev/null +++ b/apps/wizarr-backend/wizarr_backend/api/routes/onboarding_api.py @@ -0,0 +1,106 @@ +from json import dumps, loads +from flask import request +from flask_jwt_extended import jwt_required +from flask_restx import Namespace, Resource +from playhouse.shortcuts import model_to_dict +from peewee import fn +from app.models.database import db + +from app.models.database.onboarding import Onboarding as OnboardingDB + +api = Namespace("Onboarding", description="Onboarding related operations", path="/onboarding") + +@api.route("") +class OnboardingListApi(Resource): + """API resource for all onboarding pages""" + + @api.doc(security="jwt") + def get(self): + """Get onboarding pages""" + response = list(OnboardingDB.select().order_by(OnboardingDB.order).dicts()) + return loads(dumps(response, indent=4, sort_keys=True, default=str)), 200 + + @api.doc(security="jwt") + @jwt_required() + def post(self): + """Create onboarding page""" + value = request.form.get("value") + enabled = request.form.get("enabled") in ["true", "True", "1"] + max_order = OnboardingDB.select(fn.MAX(OnboardingDB.order)).scalar() or 0 + new_order = max_order + 1 + onboarding_page = OnboardingDB.create(order=new_order, value=value, enabled=enabled) + onboarding_page.save() + return { "message": "Onboarding page created", "page": model_to_dict(onboarding_page) }, 200 + + +@api.route("/") +class OnboardingAPI(Resource): + """API resource for a single onboarding page""" + + method_decorators = [jwt_required()] + + @api.doc(description="Updates a single onboarding page") + @api.response(404, "Onboarding page not found") + @api.response(500, "Internal server error") + def put(self, onboarding_id: int): + value = request.form.get("value") + enabled = request.form.get("enabled") + order = request.form.get("order", type=int) + + with db.atomic() as transaction: + page = OnboardingDB.get_or_none(OnboardingDB.id == onboarding_id) + if not page: + return {"error": "Onboarding page not found"}, 404 + + if(value is not None): + page.value = value + if(enabled is not None): + page.enabled = enabled in ["true", "True", "1"] + + if order is not None and page.order != order: + step = 1 if page.order > order else -1 + start, end = sorted([page.order, order]) + + # Update orders of affected pages + affected_pages = OnboardingDB.select().where( + OnboardingDB.id != onboarding_id, + OnboardingDB.order >= start, + OnboardingDB.order <= end, + ) + + for p in affected_pages: + p.order += step + p.save() # Save each affected page + + # Update the target page + page.order = order + page.save() # Save the target page + + try: + transaction.commit() # Commit the transaction + except Exception as e: + transaction.rollback() # Rollback in case of error + return {"error": str(e)}, 500 + return loads(dumps(model_to_dict(page), indent=4, sort_keys=True, default=str)), 200 + + @api.doc(description="Delete a single onboarding page") + @api.response(404, "Invite not found") + @api.response(500, "Internal server error") + def delete(self, onboarding_id): + """Delete onboarding page""" + # Select the invite from the database + onboarding_page = OnboardingDB.get_or_none(OnboardingDB.id == onboarding_id) + + # Check if the invite exists + if not onboarding_page: + return {"message": "Onboarding page not found"}, 404 + + onboarding_page.delete_instance() + + # Update order of subsequent pages + subsequent_pages = OnboardingDB.select().where(OnboardingDB.order > onboarding_page.order) + for page in subsequent_pages: + page.order -= 1 + page.save() + + return { "message": "Onboarding page deleted successfully" }, 200 diff --git a/apps/wizarr-backend/wizarr_backend/app/migrator/migrations/2024-07-30_11-02-04.py b/apps/wizarr-backend/wizarr_backend/app/migrator/migrations/2024-07-30_11-02-04.py new file mode 100644 index 000000000..779b95bb1 --- /dev/null +++ b/apps/wizarr-backend/wizarr_backend/app/migrator/migrations/2024-07-30_11-02-04.py @@ -0,0 +1,40 @@ +# +# CREATED ON VERSION: V4.1.1 +# MIGRATION: 2024-07-30_11-02-04 +# CREATED: Tue Jul 30 2024 +# + +from peewee import * +from playhouse.migrate import * + +from app import db + +# Do not change the name of this file, +# migrations are run in order of their filenames date and time + +def run(): + # Use migrator to perform actions on the database + migrator = SqliteMigrator(db) + + # Create new table 'onboarding' + with db.transaction(): + # Check if the table exists + cursor = db.cursor() + cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='onboarding';") + table_exists = cursor.fetchone() + + if not table_exists: + db.execute_sql(""" + CREATE TABLE "onboarding" ( + "id" INTEGER NOT NULL UNIQUE, + "value" TEXT NOT NULL, + "order" INTEGER NOT NULL UNIQUE, + "enabled" INTEGER NOT NULL DEFAULT 1, + PRIMARY KEY("id") + ) + """) + print("Table 'onboarding' created successfully") + else: + print("Table 'onboarding' already exists") + + print("Migration 2024-07-30_11-02-04 complete") diff --git a/apps/wizarr-backend/wizarr_backend/app/models/database/onboarding.py b/apps/wizarr-backend/wizarr_backend/app/models/database/onboarding.py new file mode 100644 index 000000000..fcb78a8a0 --- /dev/null +++ b/apps/wizarr-backend/wizarr_backend/app/models/database/onboarding.py @@ -0,0 +1,8 @@ +from peewee import BooleanField, CharField, IntegerField +from app.models.database.base import BaseModel + +class Onboarding(BaseModel): + id = IntegerField(primary_key=True, unique=True) + value = CharField(null=False) + order = IntegerField(null=False, unique=True) + enabled = BooleanField(default=False) diff --git a/apps/wizarr-frontend/src/assets/scss/main.scss b/apps/wizarr-frontend/src/assets/scss/main.scss index 0bd4d62d5..2382a0ac8 100644 --- a/apps/wizarr-frontend/src/assets/scss/main.scss +++ b/apps/wizarr-frontend/src/assets/scss/main.scss @@ -2,6 +2,7 @@ // Internal libraries @import "./tailwind.scss"; +@import "./md-editor-v3.scss"; @import "./extend.scss"; @import "./animations.scss"; @import "./xterm.scss"; diff --git a/apps/wizarr-frontend/src/assets/scss/md-editor-v3.scss b/apps/wizarr-frontend/src/assets/scss/md-editor-v3.scss new file mode 100644 index 000000000..4691025ca --- /dev/null +++ b/apps/wizarr-frontend/src/assets/scss/md-editor-v3.scss @@ -0,0 +1,30 @@ +.md-editor-preview { + --md-theme-quote-border: 5px solid rgb(208, 49, 67) !important; + --md-theme-link-color: rgb(208, 49, 67) !important; + --md-theme-link-hover-color: rgb(208, 49, 67) !important; +} + +.md-editor-preview { + word-break: normal !important; + + h1, h2, h3, h4, h5, h6 { + word-break: normal !important; + } + h1, h2, h3, h4, h5, h6 { + &:first-child { + margin-top: 0; + } + } +} + +.md-editor.md-editor-dark, .md-editor-modal-container[data-theme='dark'] { + --md-color: #fff; +} + +.md-editor.md-editor-previewOnly { + background-color: inherit !important; +} + +.md-editor.md-editor-previewOnly .md-editor-preview-wrapper { + padding: inherit !important; +} \ No newline at end of file diff --git a/apps/wizarr-frontend/src/components/Carousel.vue b/apps/wizarr-frontend/src/components/Carousel.vue index 8ebc195fb..71c13051d 100644 --- a/apps/wizarr-frontend/src/components/Carousel.vue +++ b/apps/wizarr-frontend/src/components/Carousel.vue @@ -1,5 +1,5 @@