diff --git a/README.md b/README.md index bb23a317..d979ee20 100644 --- a/README.md +++ b/README.md @@ -47,9 +47,10 @@ Check out our documentation for how to install and run Wizarr: ## 🔧 API Documentation -Wizarr provides a comprehensive REST API for automation and integration. Check out the complete API documentation: +Wizarr provides a comprehensive REST API for automation and integration with **automatic OpenAPI/Swagger documentation**: -📖 [API Documentation](docs/API.md) +📖 **Interactive API Documentation**: `http://your-wizarr-instance/api/docs/` +📋 **OpenAPI Specification**: `http://your-wizarr-instance/api/swagger.json` --- diff --git a/app/blueprints/api/api_routes.py b/app/blueprints/api/api_routes.py index 856f2409..bb2c7a3d 100644 --- a/app/blueprints/api/api_routes.py +++ b/app/blueprints/api/api_routes.py @@ -1,509 +1,583 @@ +"""Flask-RESTX API routes with OpenAPI documentation.""" + import datetime import hashlib import logging import traceback from functools import wraps -from flask import Blueprint, jsonify, request, url_for +from flask import Blueprint, request +from flask_restx import Resource -from app.extensions import db +from app.extensions import api, db from app.models import ApiKey, Invitation, Library, MediaServer, User from app.services.invites import create_invite from app.services.media.service import delete_user, list_users_all_servers +from .models import ( + api_key_list_model, + error_model, + invitation_create_request, + invitation_create_response, + invitation_list_model, + library_list_model, + server_list_model, + status_model, + success_message_model, + user_extend_request, + user_extend_response, + user_list_model, +) + +# Create the Blueprint for the API api_bp = Blueprint("api", __name__, url_prefix="/api") -# Set up logging for API +# Initialize Flask-RESTX with the blueprint +api.init_app(api_bp) + +# Set up logging logger = logging.getLogger("wizarr.api") - -def _generate_invitation_url(code): - """Generate full invitation URL for the given code.""" - try: - # Try to generate URL using url_for with the public blueprint's invite route - invite_path = url_for('public.invite', code=code, _external=False) - - # Get the host from the current request if available - host = request.headers.get('Host') - if host and not host.startswith('localhost'): - # Only generate full URL for non-localhost hosts - scheme = 'https' if request.is_secure else 'http' - full_url = f"{scheme}://{host}{invite_path}" - return full_url - else: - # For localhost or when no host header, return relative URL - return invite_path - - except Exception as e: - logger.warning("Failed to generate invitation URL: %s", str(e)) - # Fallback to basic format - return f"/j/{code}" +# Create namespaces for organizing endpoints +status_ns = api.namespace("status", description="System status operations") +users_ns = api.namespace("users", description="User management operations") +invitations_ns = api.namespace( + "invitations", description="Invitation management operations" +) +libraries_ns = api.namespace("libraries", description="Library information operations") +servers_ns = api.namespace("servers", description="Server information operations") +api_keys_ns = api.namespace("api-keys", description="API key management operations") def require_api_key(f): """Decorator to require valid API key for endpoint access.""" + @wraps(f) def decorated_function(*args, **kwargs): auth_key = request.headers.get("X-API-Key") if not auth_key: logger.warning("API request without API key from %s", request.remote_addr) - return jsonify({"error": "API key required"}), 401 + return {"error": "API key required"}, 401 # Hash the provided key to compare with stored hash key_hash = hashlib.sha256(auth_key.encode()).hexdigest() api_key = ApiKey.query.filter_by(key_hash=key_hash, is_active=True).first() if not api_key: - logger.warning("API request with invalid API key from %s", request.remote_addr) - return jsonify({"error": "Invalid API key"}), 401 + logger.warning( + "API request with invalid API key from %s", request.remote_addr + ) + return {"error": "Invalid API key"}, 401 # Update last used timestamp api_key.last_used_at = datetime.datetime.now(datetime.UTC) db.session.commit() - logger.info("API request authenticated with key '%s' from %s", api_key.name, request.remote_addr) + logger.info( + "API request authenticated with key '%s' from %s", + api_key.name, + request.remote_addr, + ) return f(*args, **kwargs) return decorated_function -@api_bp.route("/users", methods=["GET"]) -@require_api_key -def list_users(): - """List all users across all media servers.""" +def _generate_invitation_url(code): + """Generate full invitation URL for the given code.""" try: - logger.info("API: Listing all users") - users_by_server = list_users_all_servers() + from flask import url_for - # Format response - users_list = [] - for server_id, users in users_by_server.items(): - # Get server info - server = db.session.get(MediaServer, server_id) - if not server: - continue + # Try to generate URL using url_for with the public blueprint's invite route + invite_path = url_for("public.invite", code=code, _external=False) - for user in users: - users_list.append({ - "id": user.id, - "username": user.username, - "email": user.email, - "server": server.name, - "server_type": server.server_type, - "expires": user.expires.isoformat() if user.expires else None, - "created": user.created.isoformat() if hasattr(user, 'created') and user.created else None - }) - - return jsonify({"users": users_list, "count": len(users_list)}) + # Get the host from the current request if available + host = request.headers.get("Host") + if host and not host.startswith("localhost"): + # Only generate full URL for non-localhost hosts + scheme = "https" if request.is_secure else "http" + return f"{scheme}://{host}{invite_path}" + # For localhost or when no host header, return relative URL + return invite_path except Exception as e: - logger.error("Error listing users: %s", str(e)) - traceback.print_exc() - return jsonify({"error": str(e)}), 500 + logger.warning("Failed to generate invitation URL: %s", str(e)) + # Fallback to basic format + return f"/j/{code}" -@api_bp.route("/users/", methods=["DELETE"]) -@require_api_key -def delete_user_endpoint(user_id): - """Delete a user by ID.""" - try: - user = db.session.get(User, user_id) - if not user: - return jsonify({"error": "User not found"}), 404 - - logger.info("API: Deleting user %s (ID: %d)", user.username, user_id) - result = delete_user(user.server_id, user.token) - - if result: - return jsonify({"message": f"User {user.username} deleted successfully"}) - return jsonify({"error": "Failed to delete user"}), 500 - - except Exception as e: - logger.error("Error deleting user %d: %s", user_id, str(e)) - traceback.print_exc() - return jsonify({"error": str(e)}), 500 - - -@api_bp.route("/users//extend", methods=["POST"]) -@require_api_key -def extend_user_expiry(user_id): - """Extend user expiry date.""" - try: - user = db.session.get(User, user_id) - if not user: - return jsonify({"error": "User not found"}), 404 - - data = request.get_json() or {} - days = data.get("days", 30) # Default to 30 days - - if user.expires: - new_expiry = user.expires + datetime.timedelta(days=days) - else: - new_expiry = datetime.datetime.now(datetime.UTC) + datetime.timedelta(days=days) - - user.expires = new_expiry - db.session.commit() - - logger.info("API: Extended user %s expiry by %d days to %s", user.username, days, new_expiry) - return jsonify({ - "message": f"User {user.username} expiry extended by {days} days", - "new_expiry": new_expiry.isoformat() - }) - - except Exception as e: - logger.error("Error extending user %d expiry: %s", user_id, str(e)) - traceback.print_exc() - return jsonify({"error": str(e)}), 500 - - -@api_bp.route("/invitations", methods=["GET"]) -@require_api_key -def list_invitations(): - """List all invitations.""" - try: - logger.info("API: Listing all invitations") - now = datetime.datetime.now(datetime.UTC) - - invitations = Invitation.query.all() - invites_list = [] - - for invite in invitations: - # Determine status - if invite.used: - status = "used" - elif invite.expires: - # Handle timezone comparison - invite_expires = invite.expires - if invite_expires.tzinfo is None: - # If invitation expires is naive, assume UTC - invite_expires = invite_expires.replace(tzinfo=datetime.UTC) - status = "expired" if invite_expires < now else "pending" - else: - status = "pending" - - # Get server information for this invitation - from app.services.server_name_resolver import get_display_name_info - - servers = [] - if invite.servers: - servers = list(invite.servers) - elif invite.server: - servers = [invite.server] - - display_info = get_display_name_info(servers) - - invites_list.append({ - "id": invite.id, - "code": invite.code, - "url": _generate_invitation_url(invite.code), - "status": status, - "created": invite.created.isoformat() if invite.created else None, - "expires": invite.expires.isoformat() if invite.expires else None, - "used_at": invite.used_at.isoformat() if invite.used_at else None, - "used_by": invite.used_by.username if invite.used_by else None, - "duration": invite.duration, - "unlimited": invite.unlimited, - "specific_libraries": invite.specific_libraries, - "display_name": display_info["display_name"], - "server_names": display_info["server_names"], - "uses_global_setting": display_info["uses_global_setting"] - }) - - return jsonify({"invitations": invites_list, "count": len(invites_list)}) - - except Exception as e: - logger.error("Error listing invitations: %s", str(e)) - traceback.print_exc() - return jsonify({"error": str(e)}), 500 - - -@api_bp.route("/invitations", methods=["POST"]) -@require_api_key -def create_invitation(): - """Create a new invitation.""" - try: - # Try to get JSON data, handle missing Content-Type gracefully +@status_ns.route("") +class StatusResource(Resource): + @api.doc("get_status", security="apikey") + @api.marshal_with(status_model) + @api.response(401, "Invalid or missing API key", error_model) + @api.response(500, "Internal server error", error_model) + @require_api_key + def get(self): + """Get overall statistics about your Wizarr instance.""" try: - data = request.get_json() or {} - except Exception: - # If JSON parsing fails due to Content-Type, try force parsing - try: - data = request.get_json(force=True) or {} - except Exception: - data = {} + logger.info("API: Getting system status") - # Extract parameters - expires_in_days = data.get("expires_in_days") - duration = data.get("duration", "unlimited") - library_ids = data.get("library_ids", []) - unlimited = data.get("unlimited", True) - server_ids = data.get("server_ids", []) # Allow explicit server specification + # Get statistics + total_users = User.query.count() + total_invitations = Invitation.query.count() + pending_invitations = Invitation.query.filter_by(status="pending").count() + expired_invitations = Invitation.query.filter_by(status="expired").count() - # Map expires_in_days to the expected format - expires_key = "never" - if expires_in_days == 1: - expires_key = "day" - elif expires_in_days == 7: - expires_key = "week" - elif expires_in_days == 30: - expires_key = "month" - - # Handle server selection logic - server_ids are now required - if not server_ids: - # No server IDs specified - this is now always an error - verified_servers = MediaServer.query.filter_by(verified=True).all() - available_servers = [ - {"id": s.id, "name": s.name, "server_type": s.server_type} - for s in verified_servers - ] - return jsonify({ - "error": "Server selection is required. Please specify server_ids in request.", - "available_servers": available_servers - }), 400 - - # Server IDs explicitly provided - validate them - servers = MediaServer.query.filter( - MediaServer.id.in_(server_ids), - MediaServer.verified - ).all() - if len(servers) != len(server_ids): - return jsonify({"error": "One or more specified servers not found or not verified"}), 400 - selected_server_ids = server_ids - - # Create a form-like object - form_data = { - "duration": duration, - "expires": expires_key, - "unlimited": unlimited, - "server_ids": selected_server_ids, - "libraries": [str(lib_id) for lib_id in library_ids], - "allow_downloads": data.get("allow_downloads", False), - "allow_live_tv": data.get("allow_live_tv", False), - "allow_mobile_uploads": data.get("allow_mobile_uploads", False), - } - - # Create a dict-like object that supports both get() and getlist() - class FormLikeDict(dict): - def getlist(self, key): - value = self.get(key, []) - if isinstance(value, list): - return value - return [value] if value else [] - - form_obj = FormLikeDict(form_data) - - # Create the invitation - logger.info("API: Creating invitation with duration=%s, expires=%s, libraries=%s, servers=%s", - duration, expires_key, library_ids, selected_server_ids) - - invitation = create_invite(form_obj) - db.session.commit() - - # Get server information for the created invitation - from app.services.server_name_resolver import get_display_name_info - - servers = [] - if invitation.servers: - servers = list(invitation.servers) - elif invitation.server: - servers = [invitation.server] - - display_info = get_display_name_info(servers) - - return jsonify({ - "message": "Invitation created successfully", - "invitation": { - "id": invitation.id, - "code": invitation.code, - "url": _generate_invitation_url(invitation.code), - "expires": invitation.expires.isoformat() if invitation.expires else None, - "duration": invitation.duration, - "unlimited": invitation.unlimited, - "display_name": display_info["display_name"], - "server_names": display_info["server_names"], - "uses_global_setting": display_info["uses_global_setting"] + return { + "users": total_users, + "invites": total_invitations, + "pending": pending_invitations, + "expired": expired_invitations, } - }), 201 - - except Exception as e: - logger.error("Error creating invitation: %s", str(e)) - traceback.print_exc() - return jsonify({"error": str(e)}), 500 + except Exception as e: + logger.error("Error getting system status: %s", str(e)) + logger.error(traceback.format_exc()) + return {"error": "Internal server error"}, 500 -@api_bp.route("/invitations/", methods=["DELETE"]) -@require_api_key -def delete_invitation(invitation_id): - """Delete an invitation.""" - try: - invitation = db.session.get(Invitation, invitation_id) - if not invitation: - return jsonify({"error": "Invitation not found"}), 404 +@users_ns.route("") +class UsersListResource(Resource): + @api.doc("list_users", security="apikey") + @api.marshal_with(user_list_model) + @api.response(401, "Invalid or missing API key", error_model) + @api.response(500, "Internal server error", error_model) + @require_api_key + def get(self): + """List all users across all media servers.""" + try: + logger.info("API: Listing all users") + users_by_server = list_users_all_servers() - logger.info("API: Deleting invitation %s (ID: %d)", invitation.code, invitation_id) - db.session.delete(invitation) - db.session.commit() - - return jsonify({"message": f"Invitation {invitation.code} deleted successfully"}) - - except Exception as e: - logger.error("Error deleting invitation %d: %s", invitation_id, str(e)) - traceback.print_exc() - return jsonify({"error": str(e)}), 500 - - -@api_bp.route("/libraries", methods=["GET"]) -@require_api_key -def list_libraries(): - """List all available libraries, scanning servers first if needed.""" - try: - logger.info("API: Listing all libraries") - - # Get all configured servers - servers = MediaServer.query.filter_by(verified=True).all() - - # Check if we need to scan libraries (if no libraries exist for verified servers) - existing_libraries = Library.query.join(MediaServer).filter(MediaServer.verified).count() - - if existing_libraries == 0 and servers: - # No libraries found, scan all verified servers first - logger.info("API: No libraries found, scanning all verified servers first") - from app.services.media.service import scan_libraries_for_server - - for server in servers: - try: - logger.info("API: Scanning libraries for server %s", server.name) - items = scan_libraries_for_server(server) - - # Store the results in the Library table - # items may be dict or list[str] - pairs = ( - items.items() if isinstance(items, dict) else [(name, name) for name in items] - ) - - for fid, name in pairs: - lib = Library.query.filter_by(external_id=fid, server_id=server.id).first() - if lib: - lib.name = name - else: - lib = Library() - lib.external_id = fid - lib.name = name - lib.server_id = server.id - db.session.add(lib) - - db.session.commit() - logger.info("API: Successfully scanned %d libraries for server %s", len(pairs), server.name) - - except Exception as scan_e: - logger.warning("API: Failed to scan libraries for server %s: %s", server.name, str(scan_e)) - # Continue with other servers even if one fails + # Format response + users_list = [] + for server_id, users in users_by_server.items(): + # Get server info + server = db.session.get(MediaServer, server_id) + if not server: continue - # Now get all libraries - libraries = Library.query.all() + for user in users: + users_list.append( + { + "id": user.id, + "username": user.username, + "email": user.email, + "server": server.name, + "server_type": server.server_type, + "expires": user.expires.isoformat() + if user.expires + else None, + "created": user.created.isoformat() + if hasattr(user, "created") and user.created + else None, + } + ) - libraries_list = [] - for lib in libraries: - server = db.session.get(MediaServer, lib.server_id) - libraries_list.append({ - "id": lib.id, - "name": lib.name, - "server_id": lib.server_id, - "server_name": server.name if server else None, - "server_type": server.server_type if server else None - }) + return {"users": users_list, "count": len(users_list)} - return jsonify({"libraries": libraries_list, "count": len(libraries_list)}) - - except Exception as e: - logger.error("Error listing libraries: %s", str(e)) - traceback.print_exc() - return jsonify({"error": str(e)}), 500 + except Exception as e: + logger.error("Error listing users: %s", str(e)) + logger.error(traceback.format_exc()) + return {"error": "Internal server error"}, 500 -@api_bp.route("/servers", methods=["GET"]) -@require_api_key -def list_servers(): - """List all configured media servers.""" - try: - logger.info("API: Listing all media servers") - servers = MediaServer.query.all() +@users_ns.route("/") +class UserResource(Resource): + @api.doc("delete_user", security="apikey") + @api.response(200, "User deleted successfully", success_message_model) + @api.response(401, "Invalid or missing API key", error_model) + @api.response(404, "User not found", error_model) + @api.response(500, "Internal server error", error_model) + @require_api_key + def delete(self, user_id): + """Delete a specific user by ID.""" + try: + logger.info("API: Deleting user %s", user_id) - servers_list = [] - for server in servers: - servers_list.append({ - "id": server.id, - "name": server.name, - "server_type": server.server_type, - "server_url": server.url, - "external_url": server.external_url, - "verified": server.verified, - "allow_downloads": server.allow_downloads, - "allow_live_tv": server.allow_live_tv, - "allow_mobile_uploads": server.allow_mobile_uploads, - "created_at": server.created_at.isoformat() if server.created_at else None - }) + # Find user across all servers + user = User.query.get(user_id) + if not user: + return {"error": "User not found"}, 404 - return jsonify({"servers": servers_list, "count": len(servers_list)}) + # Get server info for the user + server = db.session.get(MediaServer, user.server_id) + if not server: + return {"error": "Server not found for user"}, 404 - except Exception as e: - logger.error("Error listing servers: %s", str(e)) - traceback.print_exc() - return jsonify({"error": str(e)}), 500 + # Delete user using the service (takes only user.id) + delete_user(user.id) + return {"message": f"User {user.username} deleted successfully"} + + except Exception as e: + logger.error("Error deleting user %s: %s", user_id, str(e)) + logger.error(traceback.format_exc()) + return {"error": "Internal server error"}, 500 -@api_bp.route("/api-keys", methods=["GET"]) -@require_api_key -def list_api_keys(): - """List all active API keys (excluding the key values themselves).""" - try: - logger.info("API: Listing all API keys") - api_keys = ApiKey.query.filter_by(is_active=True).order_by(ApiKey.created_at.desc()).all() +@users_ns.route("//extend") +class UserExtendResource(Resource): + @api.doc("extend_user_expiry", security="apikey") + @api.expect(user_extend_request) + @api.marshal_with(user_extend_response) + @api.response(401, "Invalid or missing API key", error_model) + @api.response(404, "User not found", error_model) + @api.response(500, "Internal server error", error_model) + @require_api_key + def post(self, user_id): + """Extend a user's expiry date.""" + try: + logger.info("API: Extending expiry for user %s", user_id) - keys_list = [] - for key in api_keys: - keys_list.append({ - "id": key.id, - "name": key.name, - "created_at": key.created_at.isoformat() if key.created_at else None, - "last_used_at": key.last_used_at.isoformat() if key.last_used_at else None, - "created_by": key.created_by.username if key.created_by else None - }) + # Get request data + data = api.payload or {} + days = data.get("days", 30) - return jsonify({"api_keys": keys_list, "count": len(keys_list)}) + # Find user + user = User.query.get(user_id) + if not user: + return {"error": "User not found"}, 404 - except Exception as e: - logger.error("Error listing API keys: %s", str(e)) - traceback.print_exc() - return jsonify({"error": str(e)}), 500 + # Extend expiry + if user.expires: + new_expiry = user.expires + datetime.timedelta(days=days) + else: + new_expiry = datetime.datetime.now(datetime.UTC) + datetime.timedelta( + days=days + ) + + user.expires = new_expiry + db.session.commit() + + return { + "message": f"User {user.username} expiry extended by {days} days", + "new_expiry": new_expiry.isoformat(), + } + + except Exception as e: + logger.error("Error extending user %s expiry: %s", user_id, str(e)) + logger.error(traceback.format_exc()) + return {"error": "Internal server error"}, 500 -@api_bp.route("/api-keys/", methods=["DELETE"]) -@require_api_key -def delete_api_key_via_api(key_id): - """Delete an API key via API (soft delete by marking as inactive).""" - try: - api_key = db.session.get(ApiKey, key_id) - if not api_key: - return jsonify({"error": "API key not found"}), 404 +@invitations_ns.route("") +class InvitationsListResource(Resource): + @api.doc("list_invitations", security="apikey") + @api.marshal_with(invitation_list_model) + @api.response(401, "Invalid or missing API key", error_model) + @api.response(500, "Internal server error", error_model) + @require_api_key + def get(self): + """List all invitations with their current status and server information.""" + try: + logger.info("API: Listing all invitations") - # Prevent self-deletion by checking if the current request is using this key - auth_key = request.headers.get("X-API-Key") - if auth_key: - key_hash = hashlib.sha256(auth_key.encode()).hexdigest() - if key_hash == api_key.key_hash: - return jsonify({"error": "Cannot delete the API key currently being used"}), 400 + invitations = Invitation.query.all() + invitations_list = [] - # Soft delete by marking as inactive - api_key.is_active = False - db.session.commit() + for invitation in invitations: + # Get server names + server_names = [] + if invitation.server_id: + server = db.session.get(MediaServer, invitation.server_id) + if server: + server_names.append(server.name) - logger.info("API: Deleted API key '%s' (ID: %d)", api_key.name, key_id) + invitations_list.append( + { + "id": invitation.id, + "code": invitation.code, + "url": _generate_invitation_url(invitation.code), + "status": invitation.status, + "created": invitation.created.isoformat() + if invitation.created + else None, + "expires": invitation.expires.isoformat() + if invitation.expires + else None, + "used_at": invitation.used_at.isoformat() + if invitation.used_at + else None, + "used_by": invitation.used_by, + "duration": str(invitation.duration) + if invitation.duration + else "unlimited", + "unlimited": invitation.unlimited, + "specific_libraries": invitation.specific_libraries, + "display_name": ", ".join(server_names) + if server_names + else "Unknown", + "server_names": server_names, + "uses_global_setting": False, # Simplified for now + } + ) - return jsonify({"message": f"API key '{api_key.name}' deleted successfully"}) + return {"invitations": invitations_list, "count": len(invitations_list)} - except Exception as e: - logger.error("Error deleting API key %d: %s", key_id, str(e)) - traceback.print_exc() - return jsonify({"error": str(e)}), 500 + except Exception as e: + logger.error("Error listing invitations: %s", str(e)) + logger.error(traceback.format_exc()) + return {"error": "Internal server error"}, 500 + + @api.doc("create_invitation", security="apikey") + @api.expect(invitation_create_request) + @api.marshal_with(invitation_create_response, code=201) + @api.response(400, "Bad request - missing required fields", error_model) + @api.response(401, "Invalid or missing API key", error_model) + @api.response(500, "Internal server error", error_model) + @require_api_key + def post(self): + """Create a new invitation.""" + try: + logger.info("API: Creating new invitation") + + data = api.payload or {} + server_ids = data.get("server_ids") + + if not server_ids: + # Return available servers for selection + servers = MediaServer.query.filter_by(verified=True).all() + available_servers = [ + {"id": s.id, "name": s.name, "server_type": s.server_type} + for s in servers + ] + return { + "error": "Server selection is required. Please specify server_ids in request.", + "available_servers": available_servers, + }, 400 + + # Create a form-like object that create_invite expects + class FormLike: + def __init__(self, data): + self.data = data + + def get(self, key, default=None): + return self.data.get(key, default) + + def getlist(self, key): + val = self.data.get(key, []) + return ( + val + if isinstance(val, list) + else [val] + if val is not None + else [] + ) + + # Map expires_in_days to the format expected by create_invite + expires_mapping = {1: "day", 7: "week", 30: "month"} + expires_key = expires_mapping.get(data.get("expires_in_days"), "never") + + form_data = FormLike( + { + "server_ids": server_ids, + "expires": expires_key, + "duration": data.get("duration", "unlimited"), + "unlimited": data.get("unlimited", True), + "libraries": [ + str(lid) for lid in data.get("library_ids", []) + ], # Convert to strings + "allow_downloads": data.get("allow_downloads", False), + "allow_live_tv": data.get("allow_live_tv", False), + "allow_mobile_uploads": data.get("allow_mobile_uploads", False), + } + ) + + invitation = create_invite(form_data) + + if invitation: + server = db.session.get(MediaServer, server_ids[0]) + return { + "message": "Invitation created successfully", + "invitation": { + "id": invitation.id, + "code": invitation.code, + "url": _generate_invitation_url(invitation.code), + "expires": invitation.expires.isoformat() + if invitation.expires + else None, + "duration": str(invitation.duration) + if invitation.duration + else "unlimited", + "unlimited": invitation.unlimited, + "display_name": server.name if server else "Unknown", + "server_names": [server.name] if server else [], + "uses_global_setting": False, + }, + }, 201 + return {"error": "Failed to create invitation"}, 500 + + except Exception as e: + logger.error("Error creating invitation: %s", str(e)) + logger.error(traceback.format_exc()) + return {"error": "Internal server error"}, 500 + + +@invitations_ns.route("/") +class InvitationResource(Resource): + @api.doc("delete_invitation", security="apikey") + @api.response(200, "Invitation deleted successfully", success_message_model) + @api.response(401, "Invalid or missing API key", error_model) + @api.response(404, "Invitation not found", error_model) + @api.response(500, "Internal server error", error_model) + @require_api_key + def delete(self, invitation_id): + """Delete a specific invitation.""" + try: + logger.info("API: Deleting invitation %s", invitation_id) + + invitation = Invitation.query.get(invitation_id) + if not invitation: + return {"error": "Invitation not found"}, 404 + + code = invitation.code + db.session.delete(invitation) + db.session.commit() + + return {"message": f"Invitation {code} deleted successfully"} + + except Exception as e: + logger.error("Error deleting invitation %s: %s", invitation_id, str(e)) + logger.error(traceback.format_exc()) + return {"error": "Internal server error"}, 500 + + +@libraries_ns.route("") +class LibrariesResource(Resource): + @api.doc("list_libraries", security="apikey") + @api.marshal_with(library_list_model) + @api.response(401, "Invalid or missing API key", error_model) + @api.response(500, "Internal server error", error_model) + @require_api_key + def get(self): + """List all available libraries across all servers.""" + try: + logger.info("API: Listing all libraries") + + libraries = Library.query.all() + libraries_list = [ + {"id": lib.id, "name": lib.name, "server_id": lib.server_id} + for lib in libraries + ] + + return {"libraries": libraries_list, "count": len(libraries_list)} + + except Exception as e: + logger.error("Error listing libraries: %s", str(e)) + logger.error(traceback.format_exc()) + return {"error": "Internal server error"}, 500 + + +@servers_ns.route("") +class ServersResource(Resource): + @api.doc("list_servers", security="apikey") + @api.marshal_with(server_list_model) + @api.response(401, "Invalid or missing API key", error_model) + @api.response(500, "Internal server error", error_model) + @require_api_key + def get(self): + """List all configured media servers.""" + try: + logger.info("API: Listing all servers") + + servers = MediaServer.query.all() + servers_list = [ + { + "id": server.id, + "name": server.name, + "server_type": server.server_type, + "server_url": server.server_url, + "external_url": getattr(server, "external_url", None), + "verified": server.verified, + "allow_downloads": getattr(server, "allow_downloads", False), + "allow_live_tv": getattr(server, "allow_live_tv", False), + "allow_mobile_uploads": getattr( + server, "allow_mobile_uploads", False + ), + "created_at": server.created_at.isoformat() + if hasattr(server, "created_at") and server.created_at + else None, + } + for server in servers + ] + + return {"servers": servers_list, "count": len(servers_list)} + + except Exception as e: + logger.error("Error listing servers: %s", str(e)) + logger.error(traceback.format_exc()) + return {"error": "Internal server error"}, 500 + + +@api_keys_ns.route("") +class ApiKeysResource(Resource): + @api.doc("list_api_keys", security="apikey") + @api.marshal_with(api_key_list_model) + @api.response(401, "Invalid or missing API key", error_model) + @api.response(500, "Internal server error", error_model) + @require_api_key + def get(self): + """List all active API keys (excluding the actual key values for security).""" + try: + logger.info("API: Listing all API keys") + + api_keys = ApiKey.query.filter_by(is_active=True).all() + keys_list = [ + { + "id": key.id, + "name": key.name, + "created_at": key.created_at.isoformat() + if key.created_at + else None, + "last_used_at": key.last_used_at.isoformat() + if key.last_used_at + else None, + "created_by": getattr(key, "created_by", "admin"), + } + for key in api_keys + ] + + return {"api_keys": keys_list, "count": len(keys_list)} + + except Exception as e: + logger.error("Error listing API keys: %s", str(e)) + logger.error(traceback.format_exc()) + return {"error": "Internal server error"}, 500 + + +@api_keys_ns.route("/") +class ApiKeyResource(Resource): + @api.doc("delete_api_key", security="apikey") + @api.response(200, "API key deleted successfully", success_message_model) + @api.response(401, "Invalid or missing API key", error_model) + @api.response(404, "API key not found", error_model) + @api.response(500, "Internal server error", error_model) + @require_api_key + def delete(self, key_id): + """Delete a specific API key (soft delete - marks as inactive).""" + try: + logger.info("API: Deleting API key %s", key_id) + + api_key = ApiKey.query.get(key_id) + if not api_key: + return {"error": "API key not found"}, 404 + + # Check if trying to delete the currently used key + auth_key = request.headers.get("X-API-Key") + if auth_key: + current_key_hash = hashlib.sha256(auth_key.encode()).hexdigest() + if api_key.key_hash == current_key_hash: + return { + "error": "Cannot delete the API key currently being used" + }, 400 + + key_name = api_key.name + api_key.is_active = False + db.session.commit() + + return {"message": f"API key '{key_name}' deleted successfully"} + + except Exception as e: + logger.error("Error deleting API key %s: %s", key_id, str(e)) + logger.error(traceback.format_exc()) + return {"error": "Internal server error"}, 500 diff --git a/app/extensions.py b/app/extensions.py index 56ae746d..0690ab12 100644 --- a/app/extensions.py +++ b/app/extensions.py @@ -6,6 +6,7 @@ from flask_limiter import Limiter from flask_limiter.util import get_remote_address from flask_login import LoginManager from flask_migrate import Migrate +from flask_restx import Api from flask_sqlalchemy import SQLAlchemy from flask_session import Session @@ -25,6 +26,27 @@ limiter = Limiter( enabled=False, # Explicitly disabled by default ) +# Initialize Flask-RESTX API with OpenAPI configuration +# This will be initialized later with the blueprint in api_routes.py +api = Api( + title="Wizarr API", + version="2.2.1", + description="Multi-server invitation manager for Plex, Jellyfin, Emby & AudiobookShelf", + doc="/docs/", # Swagger UI will be available at /api/docs/ + validate=True, + ordered=True, +) + +# Define API key security scheme for OpenAPI +api.authorizations = { + "apikey": { + "type": "apiKey", + "in": "header", + "name": "X-API-Key", + "description": "API key required for all endpoints", + } +} + # Initialize with app def init_extensions(app): @@ -88,6 +110,7 @@ def init_extensions(app): db.init_app(app) migrate.init_app(app, db) limiter.init_app(app) + # Flask-RESTX API will be initialized with the blueprint @login_manager.user_loader diff --git a/docs/API.md b/docs/API.md index cbc5957f..d5a8ef6a 100644 --- a/docs/API.md +++ b/docs/API.md @@ -1,577 +1,62 @@ # Wizarr API Documentation -This document provides comprehensive documentation for the Wizarr API endpoints, including authentication, request/response formats, and usage examples. +Wizarr provides a comprehensive REST API with **automatic OpenAPI/Swagger documentation**. -## Table of Contents +## Interactive Documentation -- [Authentication](#authentication) -- [API Endpoints](#api-endpoints) - - [Status](#status) - - [Users](#users) - - [Invitations](#invitations) - - [Libraries](#libraries) - - [Servers](#servers) - - [API Keys](#api-keys) -- [Error Handling](#error-handling) -- [Rate Limiting](#rate-limiting) -- [Examples](#examples) +- **📖 Swagger UI**: `http://your-wizarr-instance/api/docs/` +- **📋 OpenAPI Spec**: `http://your-wizarr-instance/api/swagger.json` -## Authentication +The interactive documentation provides: +- Real-time API testing interface +- Complete request/response schemas +- Authentication examples +- Error code explanations -All API endpoints require authentication using an API key. API keys can be created through the Wizarr web interface under Settings → API Keys. +## Quick Start -### API Key Header +### Authentication -Include your API key in the request headers: - -``` -X-API-Key: your_api_key_here -``` - -### Example Request +All API endpoints require authentication using an API key: ```bash -curl -H "X-API-Key: your_api_key_here" https://your-wizarr-instance.com/api/status +curl -H "X-API-Key: your_api_key_here" \ + http://your-wizarr-instance/api/status ``` -## API Endpoints +### API Key Management -### Status +1. Log into your Wizarr web interface +2. Navigate to **Settings → API Keys** +3. Click **"Create API Key"** +4. Copy the generated key (shown only once) -Get overall statistics about your Wizarr instance. +### Available Endpoints -#### GET `/api/status` +The API is organized into the following sections: -Returns basic statistics about users and invitations. +- **Status**: System statistics +- **Users**: User management across media servers +- **Invitations**: Invitation creation and management +- **Libraries**: Media library information +- **Servers**: Configured media server details +- **API Keys**: API key management -**Response:** -```json -{ - "users": 15, - "invites": 8, - "pending": 3, - "expired": 2 -} -``` - -**Example:** -```bash -curl -H "X-API-Key: your_api_key" \ - https://your-wizarr-instance.com/api/status -``` - ---- - -### Users - -Manage users across all configured media servers. - -#### GET `/api/users` - -List all users across all media servers. - -**Response:** -```json -{ - "users": [ - { - "id": 1, - "username": "john_doe", - "email": "john@example.com", - "server": "Main Plex Server", - "server_type": "plex", - "expires": "2024-08-28T12:00:00Z", - "created": "2024-07-28T12:00:00Z" - } - ], - "count": 1 -} -``` - -**Example:** -```bash -curl -H "X-API-Key: your_api_key" \ - https://your-wizarr-instance.com/api/users -``` - -#### DELETE `/api/users/{user_id}` - -Delete a specific user by ID. - -**Parameters:** -- `user_id` (integer) - The ID of the user to delete - -**Response:** -```json -{ - "message": "User john_doe deleted successfully" -} -``` - -**Example:** -```bash -curl -X DELETE \ - -H "X-API-Key: your_api_key" \ - https://your-wizarr-instance.com/api/users/1 -``` - -#### POST `/api/users/{user_id}/extend` - -Extend a user's expiry date. - -**Parameters:** -- `user_id` (integer) - The ID of the user -- `days` (integer, optional) - Number of days to extend (default: 30) - -**Request Body:** -```json -{ - "days": 14 -} -``` - -**Response:** -```json -{ - "message": "User john_doe expiry extended by 14 days", - "new_expiry": "2024-09-11T12:00:00Z" -} -``` - -**Example:** -```bash -curl -X POST \ - -H "X-API-Key: your_api_key" \ - -H "Content-Type: application/json" \ - -d '{"days": 14}' \ - https://your-wizarr-instance.com/api/users/1/extend -``` - ---- - -### Invitations - -Manage invitation codes for new users. - -#### GET `/api/invitations` - -List all invitations with their current status and server information. - -**Response:** -```json -{ - "invitations": [ - { - "id": 1, - "code": "ABC123", - "url": "https://your-wizarr-instance.com/j/ABC123", - "status": "pending", - "created": "2024-07-28T12:00:00Z", - "expires": "2024-08-04T12:00:00Z", - "used_at": null, - "used_by": null, - "duration": "30", - "unlimited": false, - "specific_libraries": null, - "display_name": "My Jellyfin Server", - "server_names": ["My Jellyfin Server"], - "uses_global_setting": false - }, - { - "id": 2, - "code": "DEF456", - "url": "https://your-wizarr-instance.com/j/DEF456", - "status": "pending", - "created": "2024-07-28T13:00:00Z", - "expires": "2024-08-04T13:00:00Z", - "used_at": null, - "used_by": null, - "duration": "unlimited", - "unlimited": true, - "specific_libraries": null, - "display_name": "Plex Server, Jellyfin Server", - "server_names": ["Plex Server", "Jellyfin Server"], - "uses_global_setting": false - } - ], - "count": 2 -} -``` - -**Response Fields:** -- `url` (string) - Ready-to-use invitation URL that users can click -- `display_name` (string) - The resolved display name for the invitation (either global setting or server names) -- `server_names` (array) - List of individual server names associated with the invitation -- `uses_global_setting` (boolean) - Whether the display name comes from global setting or server names - -**Status Values:** -- `pending` - Invitation is active and can be used -- `used` - Invitation has been used -- `expired` - Invitation has expired - -**Example:** -```bash -curl -H "X-API-Key: your_api_key" \ - https://your-wizarr-instance.com/api/invitations -``` - -#### POST `/api/invitations` - -Create a new invitation. - -**Request Body:** -```json -{ - "server_ids": [1, 2], - "expires_in_days": 7, - "duration": "30", - "unlimited": false, - "library_ids": [1, 2], - "allow_downloads": false, - "allow_live_tv": false, - "allow_mobile_uploads": false -} -``` - -**Parameters:** -- `server_ids` (array, **required**) - Array of server IDs to create invitations for -- `expires_in_days` (integer, optional) - Days until invitation expires (1, 7, 30, or null for never) -- `duration` (string, optional) - User access duration in days or "unlimited" (default: "unlimited") -- `unlimited` (boolean, optional) - Whether user access is unlimited (default: true) -- `library_ids` (array, optional) - Array of library IDs to grant access to -- `allow_downloads` (boolean, optional) - Allow user downloads (default: false) -- `allow_live_tv` (boolean, optional) - Allow live TV access (default: false) -- `allow_mobile_uploads` (boolean, optional) - Allow mobile uploads (default: false) - -**Response:** -```json -{ - "message": "Invitation created successfully", - "invitation": { - "id": 2, - "code": "DEF456", - "url": "https://your-wizarr-instance.com/j/DEF456", - "expires": "2024-08-04T12:00:00Z", - "duration": "30", - "unlimited": false, - "display_name": "My Jellyfin Server", - "server_names": ["My Jellyfin Server"], - "uses_global_setting": false - } -} -``` - -**Response Fields:** -- `url` (string) - Ready-to-use invitation URL that users can click -- `display_name` (string) - The resolved display name for the invitation (either global setting or server names) -- `server_names` (array) - List of individual server names associated with the invitation -- `uses_global_setting` (boolean) - Whether the display name comes from global setting or server names - -**Error Response (Missing server_ids):** -```json -{ - "error": "Server selection is required. Please specify server_ids in request.", - "available_servers": [ - { - "id": 1, - "name": "Plex Server", - "server_type": "plex" - } - ] -} -``` - -**Example:** -```bash -curl -X POST \ - -H "X-API-Key: your_api_key" \ - -H "Content-Type: application/json" \ - -d '{ - "server_ids": [1], - "expires_in_days": 7, - "duration": "30", - "unlimited": false, - "library_ids": [1, 2] - }' \ - https://your-wizarr-instance.com/api/invitations -``` - -#### DELETE `/api/invitations/{invitation_id}` - -Delete a specific invitation. - -**Parameters:** -- `invitation_id` (integer) - The ID of the invitation to delete - -**Response:** -```json -{ - "message": "Invitation ABC123 deleted successfully" -} -``` - -**Example:** -```bash -curl -X DELETE \ - -H "X-API-Key: your_api_key" \ - https://your-wizarr-instance.com/api/invitations/1 -``` - ---- - -### Libraries - -Get information about available media libraries. - -#### GET `/api/libraries` - -List all available libraries across all servers. - -**Response:** -```json -{ - "libraries": [ - { - "id": 1, - "name": "Movies", - "server_id": 1 - }, - { - "id": 2, - "name": "TV Shows", - "server_id": 1 - } - ], - "count": 2 -} -``` - -**Example:** -```bash -curl -H "X-API-Key: your_api_key" \ - https://your-wizarr-instance.com/api/libraries -``` - ---- - -### Servers - -Get information about configured media servers. - -#### GET `/api/servers` - -List all configured media servers. - -**Response:** -```json -{ - "servers": [ - { - "id": 1, - "name": "Main Plex Server", - "server_type": "plex", - "server_url": "http://localhost:32400", - "external_url": "https://plex.example.com", - "verified": true, - "allow_downloads": false, - "allow_live_tv": true, - "allow_mobile_uploads": false, - "created_at": "2024-07-28T12:00:00Z" - }, - { - "id": 2, - "name": "Jellyfin Server", - "server_type": "jellyfin", - "server_url": "http://localhost:8096", - "external_url": null, - "verified": true, - "allow_downloads": true, - "allow_live_tv": false, - "allow_mobile_uploads": true, - "created_at": "2024-07-28T13:00:00Z" - } - ], - "count": 2 -} -``` - -**Example:** -```bash -curl -H "X-API-Key: your_api_key" \ - https://your-wizarr-instance.com/api/servers -``` - ---- - -### API Keys - -Manage API keys programmatically (useful for automation and administrative tasks). - -#### GET `/api/api-keys` - -List all active API keys (excluding the actual key values for security). - -**Response:** -```json -{ - "api_keys": [ - { - "id": 1, - "name": "Production API Key", - "created_at": "2024-07-28T12:00:00Z", - "last_used_at": "2024-07-28T14:30:00Z", - "created_by": "admin" - }, - { - "id": 2, - "name": "Development Key", - "created_at": "2024-07-28T13:00:00Z", - "last_used_at": null, - "created_by": "admin" - } - ], - "count": 2 -} -``` - -**Example:** -```bash -curl -H "X-API-Key: your_api_key" \ - https://your-wizarr-instance.com/api/api-keys -``` - -#### DELETE `/api/api-keys/{key_id}` - -Delete a specific API key (soft delete - marks as inactive). - -**Parameters:** -- `key_id` (integer) - The ID of the API key to delete - -**Response:** -```json -{ - "message": "API key 'Development Key' deleted successfully" -} -``` - -**Security Note:** You cannot delete the API key that is currently being used for the request. - -**Example:** -```bash -curl -X DELETE \ - -H "X-API-Key: your_api_key" \ - https://your-wizarr-instance.com/api/api-keys/2 -``` - ---- - -## Error Handling - -The API uses standard HTTP status codes to indicate success or failure of requests. - -### HTTP Status Codes - -- `200 OK` - Request successful -- `201 Created` - Resource created successfully -- `400 Bad Request` - Invalid request parameters -- `401 Unauthorized` - Invalid or missing API key -- `404 Not Found` - Resource not found -- `500 Internal Server Error` - Server error - -### Error Response Format - -```json -{ - "error": "Error message describing what went wrong" -} -``` - -### Common Error Examples - -**Missing API Key:** -```json -{ - "error": "API key required" -} -``` - -**Invalid API Key:** -```json -{ - "error": "Invalid API key" -} -``` - -**Resource Not Found:** -```json -{ - "error": "User not found" -} -``` - ---- - -## Rate Limiting - -Currently, there are no explicit rate limits on the API endpoints. However, it's recommended to make requests responsibly to avoid overwhelming the server. - ---- - -## Examples - -### Complete User Management Workflow - -#### 1. Check System Status -```bash -curl -H "X-API-Key: your_api_key" \ - https://your-wizarr-instance.com/api/status -``` - -#### 2. List All Users -```bash -curl -H "X-API-Key: your_api_key" \ - https://your-wizarr-instance.com/api/users -``` - -#### 3. Create an Invitation -```bash -curl -X POST \ - -H "X-API-Key: your_api_key" \ - -H "Content-Type: application/json" \ - -d '{ - "expires_in_days": 7, - "duration": "30", - "unlimited": false - }' \ - https://your-wizarr-instance.com/api/invitations -``` - -#### 4. Extend User Access -```bash -curl -X POST \ - -H "X-API-Key: your_api_key" \ - -H "Content-Type: application/json" \ - -d '{"days": 30}' \ - https://your-wizarr-instance.com/api/users/1/extend -``` - -### Python Example +### Example Usage ```python import requests API_KEY = "your_api_key_here" -BASE_URL = "https://your-wizarr-instance.com/api" - +BASE_URL = "http://your-wizarr-instance/api" headers = {"X-API-Key": API_KEY} -# Get status +# Get system status response = requests.get(f"{BASE_URL}/status", headers=headers) -status_data = response.json() -print(f"Total users: {status_data['users']}") +print(response.json()) # Create invitation -invitation_data = { +data = { "server_ids": [1], "expires_in_days": 7, "duration": "30", @@ -579,90 +64,22 @@ invitation_data = { } response = requests.post( - f"{BASE_URL}/invitations", + f"{BASE_URL}/invitations", headers={**headers, "Content-Type": "application/json"}, - json=invitation_data + json=data ) if response.status_code == 201: invitation = response.json() - print(f"Created invitation: {invitation['invitation']['code']}") print(f"Invitation URL: {invitation['invitation']['url']}") ``` -### JavaScript Example +## Interactive Testing -```javascript -const API_KEY = 'your_api_key_here'; -const BASE_URL = 'https://your-wizarr-instance.com/api'; +Visit `/api/docs/` on your Wizarr instance to: +- Browse all available endpoints +- Test API calls directly in your browser +- View detailed request/response examples +- Download the OpenAPI specification -const headers = { - 'X-API-Key': API_KEY, - 'Content-Type': 'application/json' -}; - -// Get all users -fetch(`${BASE_URL}/users`, { headers }) - .then(response => response.json()) - .then(data => { - console.log(`Found ${data.count} users`); - data.users.forEach(user => { - console.log(`- ${user.username} (${user.server})`); - }); - }); - -// Create invitation -const invitationData = { - server_ids: [1], - expires_in_days: 7, - duration: "30", - unlimited: false -}; - -fetch(`${BASE_URL}/invitations`, { - method: 'POST', - headers, - body: JSON.stringify(invitationData) -}) -.then(response => response.json()) -.then(data => { - if (data.invitation) { - console.log(`Created invitation: ${data.invitation.code}`); - console.log(`Invitation URL: ${data.invitation.url}`); - } -}); -``` - ---- - -## API Key Management - -### Creating API Keys - -1. Log into your Wizarr web interface -2. Navigate to Settings → API Keys -3. Click "Create API Key" -4. Enter a descriptive name -5. Copy the generated API key (it will only be shown once) - -### Security Best Practices - -- Store API keys securely and never commit them to version control -- Use different API keys for different applications or environments -- Rotate API keys regularly -- Delete unused API keys -- Monitor API key usage through the web interface - -### API Key Permissions - -Currently, all API keys have full access to all endpoints. Future versions may include granular permissions. - ---- - -## Changelog - -- **v2.2.1** - Added initial API documentation - - Added comprehensive endpoint documentation - - Fixed API key deletion UI glitch - - Improved error handling and response formats - - Added comprehensive test coverage \ No newline at end of file +The Swagger UI provides the most up-to-date and complete API documentation. \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index e5578057..202a9ae9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,6 +15,7 @@ dependencies = [ "flask-limiter>=3.8.0", "flask-login>=0.6.3", "flask-migrate>=4.1.0", + "flask-restx>=1.3.0", "flask-session>=0.8.0", "flask-sqlalchemy>=3.1.1", "flask-wtf>=1.2.2", diff --git a/uv.lock b/uv.lock index 97e21f0e..66015f88 100644 --- a/uv.lock +++ b/uv.lock @@ -16,6 +16,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/41/18/d89a443ed1ab9bcda16264716f809c663866d4ca8de218aa78fd50b38ead/alembic-1.15.2-py3-none-any.whl", hash = "sha256:2e76bd916d547f6900ec4bb5a90aeac1485d2c92536923d0b138c02b126edc53", size = 231911, upload-time = "2025-03-28T13:52:02.218Z" }, ] +[[package]] +name = "aniso8601" +version = "10.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8b/8d/52179c4e3f1978d3d9a285f98c706642522750ef343e9738286130423730/aniso8601-10.0.1.tar.gz", hash = "sha256:25488f8663dd1528ae1f54f94ac1ea51ae25b4d531539b8bc707fed184d16845", size = 47190 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/59/75/e0e10dc7ed1408c28e03a6cb2d7a407f99320eb953f229d008a7a6d05546/aniso8601-10.0.1-py2.py3-none-any.whl", hash = "sha256:eb19717fd4e0db6de1aab06f12450ab92144246b257423fe020af5748c0cb89e", size = 52848 }, +] + [[package]] name = "apprise" version = "1.9.4" @@ -54,6 +63,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c9/7f/09065fd9e27da0eda08b4d6897f1c13535066174cc023af248fc2a8d5e5a/asn1crypto-1.5.1-py2.py3-none-any.whl", hash = "sha256:db4e40728b728508912cbb3d44f19ce188f218e9eba635821bb4b68564f8fd67", size = 105045, upload-time = "2022-03-15T14:46:51.055Z" }, ] +[[package]] +name = "attrs" +version = "25.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/b0/1367933a8532ee6ff8d63537de4f1177af4bff9f3e829baf7331f595bb24/attrs-25.3.0.tar.gz", hash = "sha256:75d7cefc7fb576747b2c81b4442d4d4a1ce0900973527c011d1030fd3bf4af1b", size = 812032 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/06/bb80f5f86020c4551da315d78b3ab75e8228f89f0162f2c3a819e407941a/attrs-25.3.0-py3-none-any.whl", hash = "sha256:427318ce031701fea540783410126f03899a97ffc6f61596ad581ac2e40e3bc3", size = 63815 }, +] + [[package]] name = "babel" version = "2.17.0" @@ -373,6 +391,23 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d2/c4/3f329b23d769fe7628a5fc57ad36956f1fb7132cf8837be6da762b197327/Flask_Migrate-4.1.0-py3-none-any.whl", hash = "sha256:24d8051af161782e0743af1b04a152d007bad9772b2bca67b7ec1e8ceeb3910d", size = 21237, upload-time = "2025-01-10T18:51:09.527Z" }, ] +[[package]] +name = "flask-restx" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aniso8601" }, + { name = "flask" }, + { name = "importlib-resources" }, + { name = "jsonschema" }, + { name = "pytz" }, + { name = "werkzeug" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/45/4c/2e7d84e2b406b47cf3bf730f521efe474977b404ee170d8ea68dc37e6733/flask-restx-1.3.0.tar.gz", hash = "sha256:4f3d3fa7b6191fcc715b18c201a12cd875176f92ba4acc61626ccfd571ee1728", size = 2814072 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a5/bf/1907369f2a7ee614dde5152ff8f811159d357e77962aa3f8c2e937f63731/flask_restx-1.3.0-py2.py3-none-any.whl", hash = "sha256:636c56c3fb3f2c1df979e748019f084a938c4da2035a3e535a4673e4fc177691", size = 2798683 }, +] + [[package]] name = "flask-session" version = "0.8.0" @@ -469,6 +504,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" }, ] +[[package]] +name = "importlib-resources" +version = "6.5.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cf/8c/f834fbf984f691b4f7ff60f50b514cc3de5cc08abfc3295564dd89c5e2e7/importlib_resources-6.5.2.tar.gz", hash = "sha256:185f87adef5bcc288449d98fb4fba07cea78bc036455dd44c5fc4a2fe78fed2c", size = 44693 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/ed/1f1afb2e9e7f38a545d628f864d562a5ae64fe6f7a10e28ffb9b185b4e89/importlib_resources-6.5.2-py3-none-any.whl", hash = "sha256:789cfdc3ed28c78b67a06acb8126751ced69a3d5f79c095a98298cd8a760ccec", size = 37461 }, +] + [[package]] name = "iniconfig" version = "2.1.0" @@ -499,6 +543,33 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, ] +[[package]] +name = "jsonschema" +version = "4.25.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rpds-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d5/00/a297a868e9d0784450faa7365c2172a7d6110c763e30ba861867c32ae6a9/jsonschema-4.25.0.tar.gz", hash = "sha256:e63acf5c11762c0e6672ffb61482bdf57f0876684d8d249c0fe2d730d48bc55f", size = 356830 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/54/c86cd8e011fe98803d7e382fd67c0df5ceab8d2b7ad8c5a81524f791551c/jsonschema-4.25.0-py3-none-any.whl", hash = "sha256:24c2e8da302de79c8b9382fee3e76b355e44d2a4364bb207159ce10b517bd716", size = 89184 }, +] + +[[package]] +name = "jsonschema-specifications" +version = "2025.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "referencing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bf/ce/46fbd9c8119cfc3581ee5643ea49464d168028cfb5caff5fc0596d0cf914/jsonschema_specifications-2025.4.1.tar.gz", hash = "sha256:630159c9f4dbea161a6a2205c3011cc4f18ff381b189fff48bb39b9bf26ae608", size = 15513 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/01/0e/b27cdbaccf30b890c40ed1da9fd4a3593a5cf94dae54fb34f8a4b74fcd3f/jsonschema_specifications-2025.4.1-py3-none-any.whl", hash = "sha256:4653bffbd6584f7de83a67e0d620ef16900b390ddc7939d56684d6c81e33f1af", size = 18437 }, +] + [[package]] name = "limits" version = "5.4.0" @@ -824,6 +895,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446, upload-time = "2024-08-06T20:33:04.33Z" }, ] +[[package]] +name = "referencing" +version = "0.36.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "rpds-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2f/db/98b5c277be99dd18bfd91dd04e1b759cad18d1a338188c936e92f921c7e2/referencing-0.36.2.tar.gz", hash = "sha256:df2e89862cd09deabbdba16944cc3f10feb6b3e6f18e902f7cc25609a34775aa", size = 74744 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/b1/3baf80dc6d2b7bc27a95a67752d0208e410351e3feb4eb78de5f77454d8d/referencing-0.36.2-py3-none-any.whl", hash = "sha256:e8699adbbf8b5c7de96d8ffa0eb5c158b3beafce084968e2ea8bb08c6794dcd0", size = 26775 }, +] + [[package]] name = "requests" version = "2.32.4" @@ -865,6 +949,72 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/19/71/39c7c0d87f8d4e6c020a393182060eaefeeae6c01dab6a84ec346f2567df/rich-13.9.4-py3-none-any.whl", hash = "sha256:6049d5e6ec054bf2779ab3358186963bac2ea89175919d699e378b99738c2a90", size = 242424, upload-time = "2024-11-01T16:43:55.817Z" }, ] +[[package]] +name = "rpds-py" +version = "0.27.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1e/d9/991a0dee12d9fc53ed027e26a26a64b151d77252ac477e22666b9688bc16/rpds_py-0.27.0.tar.gz", hash = "sha256:8b23cf252f180cda89220b378d917180f29d313cd6a07b2431c0d3b776aae86f", size = 27420 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/d2/dfdfd42565a923b9e5a29f93501664f5b984a802967d48d49200ad71be36/rpds_py-0.27.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:443d239d02d9ae55b74015234f2cd8eb09e59fbba30bf60baeb3123ad4c6d5ff", size = 362133 }, + { url = "https://files.pythonhosted.org/packages/ac/4a/0a2e2460c4b66021d349ce9f6331df1d6c75d7eea90df9785d333a49df04/rpds_py-0.27.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b8a7acf04fda1f30f1007f3cc96d29d8cf0a53e626e4e1655fdf4eabc082d367", size = 347128 }, + { url = "https://files.pythonhosted.org/packages/35/8d/7d1e4390dfe09d4213b3175a3f5a817514355cb3524593380733204f20b9/rpds_py-0.27.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9d0f92b78cfc3b74a42239fdd8c1266f4715b573204c234d2f9fc3fc7a24f185", size = 384027 }, + { url = "https://files.pythonhosted.org/packages/c1/65/78499d1a62172891c8cd45de737b2a4b84a414b6ad8315ab3ac4945a5b61/rpds_py-0.27.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ce4ed8e0c7dbc5b19352b9c2c6131dd23b95fa8698b5cdd076307a33626b72dc", size = 399973 }, + { url = "https://files.pythonhosted.org/packages/10/a1/1c67c1d8cc889107b19570bb01f75cf49852068e95e6aee80d22915406fc/rpds_py-0.27.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fde355b02934cc6b07200cc3b27ab0c15870a757d1a72fd401aa92e2ea3c6bfe", size = 515295 }, + { url = "https://files.pythonhosted.org/packages/df/27/700ec88e748436b6c7c4a2262d66e80f8c21ab585d5e98c45e02f13f21c0/rpds_py-0.27.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:13bbc4846ae4c993f07c93feb21a24d8ec637573d567a924b1001e81c8ae80f9", size = 406737 }, + { url = "https://files.pythonhosted.org/packages/33/cc/6b0ee8f0ba3f2df2daac1beda17fde5cf10897a7d466f252bd184ef20162/rpds_py-0.27.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:be0744661afbc4099fef7f4e604e7f1ea1be1dd7284f357924af12a705cc7d5c", size = 385898 }, + { url = "https://files.pythonhosted.org/packages/e8/7e/c927b37d7d33c0a0ebf249cc268dc2fcec52864c1b6309ecb960497f2285/rpds_py-0.27.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:069e0384a54f427bd65d7fda83b68a90606a3835901aaff42185fcd94f5a9295", size = 405785 }, + { url = "https://files.pythonhosted.org/packages/5b/d2/8ed50746d909dcf402af3fa58b83d5a590ed43e07251d6b08fad1a535ba6/rpds_py-0.27.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4bc262ace5a1a7dc3e2eac2fa97b8257ae795389f688b5adf22c5db1e2431c43", size = 419760 }, + { url = "https://files.pythonhosted.org/packages/d3/60/2b2071aee781cb3bd49f94d5d35686990b925e9b9f3e3d149235a6f5d5c1/rpds_py-0.27.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2fe6e18e5c8581f0361b35ae575043c7029d0a92cb3429e6e596c2cdde251432", size = 561201 }, + { url = "https://files.pythonhosted.org/packages/98/1f/27b67304272521aaea02be293fecedce13fa351a4e41cdb9290576fc6d81/rpds_py-0.27.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d93ebdb82363d2e7bec64eecdc3632b59e84bd270d74fe5be1659f7787052f9b", size = 591021 }, + { url = "https://files.pythonhosted.org/packages/db/9b/a2fadf823164dd085b1f894be6443b0762a54a7af6f36e98e8fcda69ee50/rpds_py-0.27.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0954e3a92e1d62e83a54ea7b3fdc9efa5d61acef8488a8a3d31fdafbfb00460d", size = 556368 }, + { url = "https://files.pythonhosted.org/packages/24/f3/6d135d46a129cda2e3e6d4c5e91e2cc26ea0428c6cf152763f3f10b6dd05/rpds_py-0.27.0-cp313-cp313-win32.whl", hash = "sha256:2cff9bdd6c7b906cc562a505c04a57d92e82d37200027e8d362518df427f96cd", size = 221236 }, + { url = "https://files.pythonhosted.org/packages/c5/44/65d7494f5448ecc755b545d78b188440f81da98b50ea0447ab5ebfdf9bd6/rpds_py-0.27.0-cp313-cp313-win_amd64.whl", hash = "sha256:dc79d192fb76fc0c84f2c58672c17bbbc383fd26c3cdc29daae16ce3d927e8b2", size = 232634 }, + { url = "https://files.pythonhosted.org/packages/70/d9/23852410fadab2abb611733933401de42a1964ce6600a3badae35fbd573e/rpds_py-0.27.0-cp313-cp313-win_arm64.whl", hash = "sha256:5b3a5c8089eed498a3af23ce87a80805ff98f6ef8f7bdb70bd1b7dae5105f6ac", size = 222783 }, + { url = "https://files.pythonhosted.org/packages/15/75/03447917f78512b34463f4ef11066516067099a0c466545655503bed0c77/rpds_py-0.27.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:90fb790138c1a89a2e58c9282fe1089638401f2f3b8dddd758499041bc6e0774", size = 359154 }, + { url = "https://files.pythonhosted.org/packages/6b/fc/4dac4fa756451f2122ddaf136e2c6aeb758dc6fdbe9ccc4bc95c98451d50/rpds_py-0.27.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:010c4843a3b92b54373e3d2291a7447d6c3fc29f591772cc2ea0e9f5c1da434b", size = 343909 }, + { url = "https://files.pythonhosted.org/packages/7b/81/723c1ed8e6f57ed9d8c0c07578747a2d3d554aaefc1ab89f4e42cfeefa07/rpds_py-0.27.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c9ce7a9e967afc0a2af7caa0d15a3e9c1054815f73d6a8cb9225b61921b419bd", size = 379340 }, + { url = "https://files.pythonhosted.org/packages/98/16/7e3740413de71818ce1997df82ba5f94bae9fff90c0a578c0e24658e6201/rpds_py-0.27.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:aa0bf113d15e8abdfee92aa4db86761b709a09954083afcb5bf0f952d6065fdb", size = 391655 }, + { url = "https://files.pythonhosted.org/packages/e0/63/2a9f510e124d80660f60ecce07953f3f2d5f0b96192c1365443859b9c87f/rpds_py-0.27.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:eb91d252b35004a84670dfeafadb042528b19842a0080d8b53e5ec1128e8f433", size = 513017 }, + { url = "https://files.pythonhosted.org/packages/2c/4e/cf6ff311d09776c53ea1b4f2e6700b9d43bb4e99551006817ade4bbd6f78/rpds_py-0.27.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:db8a6313dbac934193fc17fe7610f70cd8181c542a91382531bef5ed785e5615", size = 402058 }, + { url = "https://files.pythonhosted.org/packages/88/11/5e36096d474cb10f2a2d68b22af60a3bc4164fd8db15078769a568d9d3ac/rpds_py-0.27.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ce96ab0bdfcef1b8c371ada2100767ace6804ea35aacce0aef3aeb4f3f499ca8", size = 383474 }, + { url = "https://files.pythonhosted.org/packages/db/a2/3dff02805b06058760b5eaa6d8cb8db3eb3e46c9e452453ad5fc5b5ad9fe/rpds_py-0.27.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:7451ede3560086abe1aa27dcdcf55cd15c96b56f543fb12e5826eee6f721f858", size = 400067 }, + { url = "https://files.pythonhosted.org/packages/67/87/eed7369b0b265518e21ea836456a4ed4a6744c8c12422ce05bce760bb3cf/rpds_py-0.27.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:32196b5a99821476537b3f7732432d64d93a58d680a52c5e12a190ee0135d8b5", size = 412085 }, + { url = "https://files.pythonhosted.org/packages/8b/48/f50b2ab2fbb422fbb389fe296e70b7a6b5ea31b263ada5c61377e710a924/rpds_py-0.27.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:a029be818059870664157194e46ce0e995082ac49926f1423c1f058534d2aaa9", size = 555928 }, + { url = "https://files.pythonhosted.org/packages/98/41/b18eb51045d06887666c3560cd4bbb6819127b43d758f5adb82b5f56f7d1/rpds_py-0.27.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:3841f66c1ffdc6cebce8aed64e36db71466f1dc23c0d9a5592e2a782a3042c79", size = 585527 }, + { url = "https://files.pythonhosted.org/packages/be/03/a3dd6470fc76499959b00ae56295b76b4bdf7c6ffc60d62006b1217567e1/rpds_py-0.27.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:42894616da0fc0dcb2ec08a77896c3f56e9cb2f4b66acd76fc8992c3557ceb1c", size = 554211 }, + { url = "https://files.pythonhosted.org/packages/bf/d1/ee5fd1be395a07423ac4ca0bcc05280bf95db2b155d03adefeb47d5ebf7e/rpds_py-0.27.0-cp313-cp313t-win32.whl", hash = "sha256:b1fef1f13c842a39a03409e30ca0bf87b39a1e2a305a9924deadb75a43105d23", size = 216624 }, + { url = "https://files.pythonhosted.org/packages/1c/94/4814c4c858833bf46706f87349c37ca45e154da7dbbec9ff09f1abeb08cc/rpds_py-0.27.0-cp313-cp313t-win_amd64.whl", hash = "sha256:183f5e221ba3e283cd36fdfbe311d95cd87699a083330b4f792543987167eff1", size = 230007 }, + { url = "https://files.pythonhosted.org/packages/0e/a5/8fffe1c7dc7c055aa02df310f9fb71cfc693a4d5ccc5de2d3456ea5fb022/rpds_py-0.27.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:f3cd110e02c5bf17d8fb562f6c9df5c20e73029d587cf8602a2da6c5ef1e32cb", size = 362595 }, + { url = "https://files.pythonhosted.org/packages/bc/c7/4e4253fd2d4bb0edbc0b0b10d9f280612ca4f0f990e3c04c599000fe7d71/rpds_py-0.27.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8d0e09cf4863c74106b5265c2c310f36146e2b445ff7b3018a56799f28f39f6f", size = 347252 }, + { url = "https://files.pythonhosted.org/packages/f3/c8/3d1a954d30f0174dd6baf18b57c215da03cf7846a9d6e0143304e784cddc/rpds_py-0.27.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:64f689ab822f9b5eb6dfc69893b4b9366db1d2420f7db1f6a2adf2a9ca15ad64", size = 384886 }, + { url = "https://files.pythonhosted.org/packages/e0/52/3c5835f2df389832b28f9276dd5395b5a965cea34226e7c88c8fbec2093c/rpds_py-0.27.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e36c80c49853b3ffda7aa1831bf175c13356b210c73128c861f3aa93c3cc4015", size = 399716 }, + { url = "https://files.pythonhosted.org/packages/40/73/176e46992461a1749686a2a441e24df51ff86b99c2d34bf39f2a5273b987/rpds_py-0.27.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6de6a7f622860af0146cb9ee148682ff4d0cea0b8fd3ad51ce4d40efb2f061d0", size = 517030 }, + { url = "https://files.pythonhosted.org/packages/79/2a/7266c75840e8c6e70effeb0d38922a45720904f2cd695e68a0150e5407e2/rpds_py-0.27.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4045e2fc4b37ec4b48e8907a5819bdd3380708c139d7cc358f03a3653abedb89", size = 408448 }, + { url = "https://files.pythonhosted.org/packages/e6/5f/a7efc572b8e235093dc6cf39f4dbc8a7f08e65fdbcec7ff4daeb3585eef1/rpds_py-0.27.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9da162b718b12c4219eeeeb68a5b7552fbc7aadedf2efee440f88b9c0e54b45d", size = 387320 }, + { url = "https://files.pythonhosted.org/packages/a2/eb/9ff6bc92efe57cf5a2cb74dee20453ba444b6fdc85275d8c99e0d27239d1/rpds_py-0.27.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:0665be515767dc727ffa5f74bd2ef60b0ff85dad6bb8f50d91eaa6b5fb226f51", size = 407414 }, + { url = "https://files.pythonhosted.org/packages/fb/bd/3b9b19b00d5c6e1bd0f418c229ab0f8d3b110ddf7ec5d9d689ef783d0268/rpds_py-0.27.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:203f581accef67300a942e49a37d74c12ceeef4514874c7cede21b012613ca2c", size = 420766 }, + { url = "https://files.pythonhosted.org/packages/17/6b/521a7b1079ce16258c70805166e3ac6ec4ee2139d023fe07954dc9b2d568/rpds_py-0.27.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7873b65686a6471c0037139aa000d23fe94628e0daaa27b6e40607c90e3f5ec4", size = 562409 }, + { url = "https://files.pythonhosted.org/packages/8b/bf/65db5bfb14ccc55e39de8419a659d05a2a9cd232f0a699a516bb0991da7b/rpds_py-0.27.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:249ab91ceaa6b41abc5f19513cb95b45c6f956f6b89f1fe3d99c81255a849f9e", size = 590793 }, + { url = "https://files.pythonhosted.org/packages/db/b8/82d368b378325191ba7aae8f40f009b78057b598d4394d1f2cdabaf67b3f/rpds_py-0.27.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d2f184336bc1d6abfaaa1262ed42739c3789b1e3a65a29916a615307d22ffd2e", size = 558178 }, + { url = "https://files.pythonhosted.org/packages/f6/ff/f270bddbfbc3812500f8131b1ebbd97afd014cd554b604a3f73f03133a36/rpds_py-0.27.0-cp314-cp314-win32.whl", hash = "sha256:d3c622c39f04d5751408f5b801ecb527e6e0a471b367f420a877f7a660d583f6", size = 222355 }, + { url = "https://files.pythonhosted.org/packages/bf/20/fdab055b1460c02ed356a0e0b0a78c1dd32dc64e82a544f7b31c9ac643dc/rpds_py-0.27.0-cp314-cp314-win_amd64.whl", hash = "sha256:cf824aceaeffff029ccfba0da637d432ca71ab21f13e7f6f5179cd88ebc77a8a", size = 234007 }, + { url = "https://files.pythonhosted.org/packages/4d/a8/694c060005421797a3be4943dab8347c76c2b429a9bef68fb2c87c9e70c7/rpds_py-0.27.0-cp314-cp314-win_arm64.whl", hash = "sha256:86aca1616922b40d8ac1b3073a1ead4255a2f13405e5700c01f7c8d29a03972d", size = 223527 }, + { url = "https://files.pythonhosted.org/packages/1e/f9/77f4c90f79d2c5ca8ce6ec6a76cb4734ee247de6b3a4f337e289e1f00372/rpds_py-0.27.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:341d8acb6724c0c17bdf714319c393bb27f6d23d39bc74f94221b3e59fc31828", size = 359469 }, + { url = "https://files.pythonhosted.org/packages/c0/22/b97878d2f1284286fef4172069e84b0b42b546ea7d053e5fb7adb9ac6494/rpds_py-0.27.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6b96b0b784fe5fd03beffff2b1533dc0d85e92bab8d1b2c24ef3a5dc8fac5669", size = 343960 }, + { url = "https://files.pythonhosted.org/packages/b1/b0/dfd55b5bb480eda0578ae94ef256d3061d20b19a0f5e18c482f03e65464f/rpds_py-0.27.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0c431bfb91478d7cbe368d0a699978050d3b112d7f1d440a41e90faa325557fd", size = 380201 }, + { url = "https://files.pythonhosted.org/packages/28/22/e1fa64e50d58ad2b2053077e3ec81a979147c43428de9e6de68ddf6aff4e/rpds_py-0.27.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:20e222a44ae9f507d0f2678ee3dd0c45ec1e930f6875d99b8459631c24058aec", size = 392111 }, + { url = "https://files.pythonhosted.org/packages/49/f9/43ab7a43e97aedf6cea6af70fdcbe18abbbc41d4ae6cdec1bfc23bbad403/rpds_py-0.27.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:184f0d7b342967f6cda94a07d0e1fae177d11d0b8f17d73e06e36ac02889f303", size = 515863 }, + { url = "https://files.pythonhosted.org/packages/38/9b/9bd59dcc636cd04d86a2d20ad967770bf348f5eb5922a8f29b547c074243/rpds_py-0.27.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a00c91104c173c9043bc46f7b30ee5e6d2f6b1149f11f545580f5d6fdff42c0b", size = 402398 }, + { url = "https://files.pythonhosted.org/packages/71/bf/f099328c6c85667aba6b66fa5c35a8882db06dcd462ea214be72813a0dd2/rpds_py-0.27.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f7a37dd208f0d658e0487522078b1ed68cd6bce20ef4b5a915d2809b9094b410", size = 384665 }, + { url = "https://files.pythonhosted.org/packages/a9/c5/9c1f03121ece6634818490bd3c8be2c82a70928a19de03467fb25a3ae2a8/rpds_py-0.27.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:92f3b3ec3e6008a1fe00b7c0946a170f161ac00645cde35e3c9a68c2475e8156", size = 400405 }, + { url = "https://files.pythonhosted.org/packages/b5/b8/e25d54af3e63ac94f0c16d8fe143779fe71ff209445a0c00d0f6984b6b2c/rpds_py-0.27.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a1b3db5fae5cbce2131b7420a3f83553d4d89514c03d67804ced36161fe8b6b2", size = 413179 }, + { url = "https://files.pythonhosted.org/packages/f9/d1/406b3316433fe49c3021546293a04bc33f1478e3ec7950215a7fce1a1208/rpds_py-0.27.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5355527adaa713ab693cbce7c1e0ec71682f599f61b128cf19d07e5c13c9b1f1", size = 556895 }, + { url = "https://files.pythonhosted.org/packages/5f/bc/3697c0c21fcb9a54d46ae3b735eb2365eea0c2be076b8f770f98e07998de/rpds_py-0.27.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:fcc01c57ce6e70b728af02b2401c5bc853a9e14eb07deda30624374f0aebfe42", size = 585464 }, + { url = "https://files.pythonhosted.org/packages/63/09/ee1bb5536f99f42c839b177d552f6114aa3142d82f49cef49261ed28dbe0/rpds_py-0.27.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3001013dae10f806380ba739d40dee11db1ecb91684febb8406a87c2ded23dae", size = 555090 }, + { url = "https://files.pythonhosted.org/packages/7d/2c/363eada9e89f7059199d3724135a86c47082cbf72790d6ba2f336d146ddb/rpds_py-0.27.0-cp314-cp314t-win32.whl", hash = "sha256:0f401c369186a5743694dd9fc08cba66cf70908757552e1f714bfc5219c655b5", size = 218001 }, + { url = "https://files.pythonhosted.org/packages/e2/3f/d6c216ed5199c9ef79e2a33955601f454ed1e7420a93b89670133bca5ace/rpds_py-0.27.0-cp314-cp314t-win_amd64.whl", hash = "sha256:8a1dca5507fa1337f75dcd5070218b20bc68cf8844271c923c1b79dfcbc20391", size = 230993 }, +] + [[package]] name = "ruff" version = "0.12.8" @@ -1049,6 +1199,7 @@ dependencies = [ { name = "flask-limiter" }, { name = "flask-login" }, { name = "flask-migrate" }, + { name = "flask-restx" }, { name = "flask-session" }, { name = "flask-sqlalchemy" }, { name = "flask-wtf" }, @@ -1089,6 +1240,7 @@ requires-dist = [ { name = "flask-limiter", specifier = ">=3.8.0" }, { name = "flask-login", specifier = ">=0.6.3" }, { name = "flask-migrate", specifier = ">=4.1.0" }, + { name = "flask-restx", specifier = ">=1.3.0" }, { name = "flask-session", specifier = ">=0.8.0" }, { name = "flask-sqlalchemy", specifier = ">=3.1.1" }, { name = "flask-wtf", specifier = ">=1.2.2" },