from flask import Flask, request, jsonify, g, abort, redirect from datetime import datetime from flask_sqlalchemy import SQLAlchemy from sqlalchemy.exc import IntegrityError from jose import jwt, JWTError import requests import time import pymysql import redis import json from functools import wraps app = Flask(__name__) app.config['SQLALCHEMY_DATABASE_URI'] = 'mysql+pymysql://myuser:mypassword@mysql:3306/mydb' app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False db = SQLAlchemy(app) MYSQL_HOST = "mysql" MYSQL_PORT = 3306 MYSQL_USER = "myuser" MYSQL_PASSWORD = "mypassword" MYSQL_DB = "mydb" # Connexion Redis redis_client = redis.Redis(host='redis', port=6379, decode_responses=True) while True: try: conn = pymysql.connect( host=MYSQL_HOST, port=MYSQL_PORT, user=MYSQL_USER, password=MYSQL_PASSWORD, database=MYSQL_DB ) conn.close() print("MySQL is up - continuing.") break except pymysql.err.OperationalError as e: print("Waiting for MySQL...", e) time.sleep(2) print('Creating DB') # Keycloak config KEYCLOAK_REALM = "master" KEYCLOAK_URL = "http://keycloak:8080" CLIENT_ID = "soa" # Use external URL for issuer validation (matches what's in the token) ISSUER = f"https://auth.local/realms/{KEYCLOAK_REALM}" # But use internal URL for JWKS endpoint JWKS_URL = f"{KEYCLOAK_URL}/realms/{KEYCLOAK_REALM}/protocol/openid-connect/certs" for _ in range(30): try: r = requests.get("http://keycloak:8080/realms/master/.well-known/openid-configuration") if r.status_code == 200: break except Exception: pass time.sleep(2) else: raise Exception("Keycloak is not available after waiting") jwks = requests.get(JWKS_URL).json()["keys"] def get_signing_key(token): unverified_header = jwt.get_unverified_header(token) kid = unverified_header.get("kid") for key in jwks: if key["kid"] == kid: return key raise Exception("Public key not found.") def validate_bearer_token(token): """Validate Bearer token against Keycloak""" print(f"=== JWT TOKEN VALIDATION DEBUG ===") print(f"Token received: {token[:50]}...") print(f"Expected issuer: {ISSUER}") print(f"JWKS URL: {JWKS_URL}") try: # First decode without verification to see the claims unverified_header = jwt.get_unverified_header(token) unverified_claims = jwt.get_unverified_claims(token) print(f"Token header: {unverified_header}") print(f"Token claims: {unverified_claims}") print(f"Token issuer from claims: {unverified_claims.get('iss')}") print(f"Token audience from claims: {unverified_claims.get('aud')}") # Get the signing key print("Getting signing key...") signing_key = get_signing_key(token) print(f"Signing key found: {signing_key.get('kid') if signing_key else 'None'}") # Try validation with different options print("Attempting JWT validation...") try: # First try with strict validation claims = jwt.decode( token, signing_key, algorithms=["RS256"], issuer=ISSUER, options={"verify_aud": False} ) print("JWT validation SUCCESS with expected issuer") return claims except JWTError as e1: print(f"Validation failed with expected issuer: {e1}") # Try with token's own issuer token_issuer = unverified_claims.get('iss') if token_issuer and token_issuer != ISSUER: print(f"Trying with token's issuer: {token_issuer}") try: claims = jwt.decode( token, signing_key, algorithms=["RS256"], issuer=token_issuer, options={"verify_aud": False} ) print("JWT validation SUCCESS with token's issuer") return claims except JWTError as e2: print(f"Validation failed with token's issuer: {e2}") # Try with no issuer validation try: claims = jwt.decode( token, signing_key, algorithms=["RS256"], options={"verify_aud": False, "verify_iss": False} ) print("JWT validation SUCCESS with no issuer check") return claims except JWTError as e3: print(f"Validation failed with no issuer check: {e3}") return None except Exception as e: print(f"JWT validation error: {e}") import traceback traceback.print_exc() return None # Decorator for authentication (supports both OIDC headers and Bearer tokens) def auth_required(f): @wraps(f) def wrapper(*args, **kwargs): import sys print(f"=== AUTH DEBUG for {request.path} ===", flush=True) print(f"All headers: {dict(request.headers)}", flush=True) sys.stdout.flush() user_email = None username = None # Check for Bearer token first auth_header = request.headers.get("Authorization") print(f"Authorization header: {auth_header}", flush=True) if auth_header and auth_header.startswith("Bearer "): print("Processing Bearer token...", flush=True) try: token = auth_header.split(" ")[1] print(f"Token extracted, length: {len(token)}", flush=True) claims = validate_bearer_token(token) print(f"validate_bearer_token returned: {claims}", flush=True) if claims: print(f"Token validation SUCCESS, claims: {claims}", flush=True) user_email = claims.get("email") username = claims.get("preferred_username") or claims.get("sub") print(f"Extracted from token - email: {user_email}, username: {username}", flush=True) else: print("Token validation FAILED", flush=True) return jsonify({"error": "Invalid token"}), 401 except Exception as e: print(f"Exception in Bearer token processing: {e}", flush=True) import traceback traceback.print_exc() return jsonify({"error": "Token processing error"}), 401 else: print("No Bearer token, checking OIDC headers...") # Fall back to OIDC headers from Apache user_email = request.headers.get("OIDC-email") or request.headers.get("X-User-Email") username = request.headers.get("OIDC-preferred_username") or request.headers.get("X-User-Name") or user_email print(f"OIDC headers - email: {user_email}, username: {username}") if not username: print(f"Authentication FAILED - email: {user_email}, username: {username}") return jsonify({"error": "Not authenticated"}), 401 # If no email, use username as email for user lookup if not user_email: user_email = username print(f"No email in token, using username as email: {user_email}", flush=True) # Find or create user in DB user = User.query.filter_by(email=user_email).first() if not user: user = User( username=username, email=user_email, alias=username, ) db.session.add(user) db.session.commit() event = { "type": "user_created", "data": {"id": user.id, "alias": user.alias} } redis_client.publish('events', json.dumps(event)) g.db_user = user return f(*args, **kwargs) return wrapper # Keep old decorator name for compatibility oidc_required = auth_required @app.route("/api/private/debug-headers") def debug_headers(): print(f"=== DEBUG HEADERS ROUTE ===") print(f"All headers: {dict(request.headers)}") print(f"Authorization: {request.headers.get('Authorization')}") return jsonify(dict(request.headers)) @app.route("/api/private/test-auth") @auth_required def test_auth(): return jsonify({"message": "Auth test successful", "user": g.db_user.email}) class User(db.Model): __tablename__ = "users" id = db.Column(db.Integer, primary_key=True) username = db.Column(db.String(50), unique=True, nullable=False) email = db.Column(db.String(255), unique=True, nullable=False) alias = db.Column(db.String(255), nullable=False) first_name = db.Column(db.String(100)) last_name = db.Column(db.String(100)) bio = db.Column(db.Text) profile_picture_url = db.Column(db.String(255)) created_at = db.Column(db.DateTime, server_default=db.func.now()) updated_at = db.Column(db.DateTime, server_default=db.func.now(), onupdate=db.func.now()) class Gallery(db.Model): __tablename__ = "galleries" id = db.Column(db.Integer, primary_key=True) owner_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) title = db.Column(db.String(255), nullable=False) description = db.Column(db.Text) is_public = db.Column(db.Boolean, default=False) publication_date = db.Column(db.DateTime) created_at = db.Column(db.DateTime, server_default=db.func.now()) updated_at = db.Column(db.DateTime, server_default=db.func.now(), onupdate=db.func.now()) class Artwork(db.Model): __tablename__ = "artworks" id = db.Column(db.Integer, primary_key=True) gallery_id = db.Column(db.Integer, db.ForeignKey('galleries.id'), nullable=False) creator_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) title = db.Column(db.String(255), nullable=False) description = db.Column(db.Text) image_url = db.Column(db.String(255), nullable=False) medium = db.Column(db.String(100)) dimensions = db.Column(db.String(50)) creation_year = db.Column(db.Integer) price = db.Column(db.Numeric(10, 2)) is_visible = db.Column(db.Boolean, default=True) is_for_sale = db.Column(db.Boolean, default=False) created_at = db.Column(db.DateTime, server_default=db.func.now()) updated_at = db.Column(db.DateTime, server_default=db.func.now(), onupdate=db.func.now()) class GalleryMember(db.Model): __tablename__ = "gallery_members" gallery_id = db.Column(db.Integer, db.ForeignKey('galleries.id'), primary_key=True) user_id = db.Column(db.Integer, db.ForeignKey('users.id'), primary_key=True) role = db.Column(db.String(50), nullable=False, default='viewer') status = db.Column(db.String(50), nullable=False, default='pending') invited_at = db.Column(db.DateTime, server_default=db.func.now()) entered_at = db.Column(db.DateTime) updated_at = db.Column(db.DateTime, server_default=db.func.now(), onupdate=db.func.now()) class ArtworkReview(db.Model): __tablename__ = "artwork_reviews" id = db.Column(db.Integer, primary_key=True) artwork_id = db.Column(db.Integer, db.ForeignKey('artworks.id'), nullable=False) author_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) grade = db.Column(db.Integer) description = db.Column(db.Text) parent_ar_id = db.Column(db.Integer, db.ForeignKey('artwork_reviews.id')) created_at = db.Column(db.DateTime, server_default=db.func.now()) updated_at = db.Column(db.DateTime, server_default=db.func.now(), onupdate=db.func.now()) __table_args__ = (db.CheckConstraint('grade >= 0 AND grade <= 5', name='check_grade_range_artwork'),) class GalleryReview(db.Model): __tablename__ = "gallery_reviews" id = db.Column(db.Integer, primary_key=True) gallery_id = db.Column(db.Integer, db.ForeignKey('galleries.id'), nullable=False) author_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) grade = db.Column(db.Integer) description = db.Column(db.Text) parent_gr_id = db.Column(db.Integer, db.ForeignKey('gallery_reviews.id')) created_at = db.Column(db.DateTime, server_default=db.func.now()) updated_at = db.Column(db.DateTime, server_default=db.func.now(), onupdate=db.func.now()) __table_args__ = (db.CheckConstraint('grade >= 0 AND grade <= 5', name='check_grade_range_gallery'),) with app.app_context(): db.create_all() # Ensure all tables are created if they do not exist @app.route("/api/private/redirect") def oidc_redirect(): code = request.args.get("code") if not code: return "Missing code", 400 # Exchange code for tokens token_url = "https://auth.local/realms/master/protocol/openid-connect/token" data = { "grant_type": "authorization_code", "code": code, "redirect_uri": "https://api.local/api/private/redirect", "client_id": "soa", "client_secret": "mysecret" } resp = requests.post(token_url, data=data) if resp.status_code != 200: return "Token exchange failed", 400 tokens = resp.json() # Store tokens in session, or set as cookie, or return to frontend # Example: set as cookie (not for production, just for demo) response = redirect("/") # or wherever you want response.set_cookie("access_token", tokens["access_token"], httponly=True, secure=True) return response # User profile # Retrieve the authenticated user's profile information. @app.route("/api/private/me", methods=["GET"]) @oidc_required def get_me(): user = g.db_user return jsonify({ "id": user.id, "username": user.username, "email": user.email, "alias": user.alias, "first_name": user.first_name, "last_name": user.last_name, "bio": user.bio, "profile_picture_url": user.profile_picture_url, "created_at": user.created_at, "updated_at": user.updated_at }) # Update the authenticated user's editable profile fields (alias, first name, last name, bio, profile picture). @app.route("/api/private/me", methods=["PUT"]) @oidc_required def update_me(): data = request.json user = g.db_user user.alias = data.get("alias", user.alias) user.first_name = data.get("first_name", user.first_name) user.last_name = data.get("last_name", user.last_name) user.bio = data.get("bio", user.bio) user.profile_picture_url = data.get("profile_picture_url", user.profile_picture_url) db.session.commit() event = { "type": "user_updated", "data": {"id": user.id, "alias": user.alias} } redis_client.publish('events', json.dumps(event)) return jsonify({"message": "Profile updated"}) # Invitations # Send an invitation to another user to join the specified gallery. @app.route("/api/private/gallery//invite", methods=["POST"]) @oidc_required def invite_user(gallery_id): data = request.json invited_user_id = data.get("user_id") role = data.get("role", "viewer") gallery = Gallery.query.get_or_404(gallery_id) if gallery.owner_id != g.db_user.id: abort(403) user = User.query.get(invited_user_id) if not user: abort(404) invitation = GalleryMember( gallery_id=gallery_id, user_id=invited_user_id, role=role, status="pending" ) try: db.session.add(invitation) db.session.commit() event = { "type": "invitation_sent", "data": {"user_id": invitation.user_id, "gallery_id": invitation.gallery_id} } redis_client.publish('events', json.dumps(event)) except IntegrityError: db.session.rollback() return jsonify({"error": "Invitation already exists"}), 409 return jsonify({"message": "Invitation sent"}), 201 # Allow an invited user to accept or reject a gallery invitation. @app.route("/api/private/invitations//respond", methods=["PUT"]) @oidc_required def respond_invitation(gallery_id): data = request.json status = data.get("status") if status not in ["accepted", "rejected"]: abort(400) invitation = GalleryMember.query.filter_by(gallery_id=gallery_id, user_id=g.db_user.id).first_or_404() if invitation.status != "pending": abort(403) invitation.status = status if status == "accepted": invitation.entered_at = db.func.now() db.session.commit() event = { "type": "invitation_answered", "data": {"user_id": invitation.user_id, "gallery_id": invitation.gallery_id, "answer": invitation.status} } redis_client.publish('events', json.dumps(event)) return jsonify({"message": f"Invitation {status}"}) # List all invitations received by the authenticated user (with status). @app.route("/api/private/invitations/received", methods=["GET"]) @oidc_required def get_received_invitations(): invitations = GalleryMember.query.filter_by(user_id=g.db_user.id).all() result = [] for inv in invitations: gal = Gallery.query.get(inv.gallery_id) own = User.query.get(gal.owner_id) result.append({ "gallery_id": inv.gallery_id, "gallery_title": gal.title, "gallery_description": gal.description, "owner": own.alias, "role": inv.role, "status": inv.status, "invited_at": inv.invited_at, "entered_at": inv.entered_at, "updated_at": inv.updated_at }) return jsonify(result) # Galleries # List all galleries accessible to the user (public, owned, or where they are a member). @app.route("/api/private/galleries", methods=["GET"]) @oidc_required def get_galleries(): user_id = g.db_user.id public = Gallery.query.filter_by(is_public=True) owned = Gallery.query.filter_by(owner_id=user_id) member = Gallery.query.join(GalleryMember, Gallery.id==GalleryMember.gallery_id).filter(GalleryMember.user_id==user_id, GalleryMember.status=="accepted") galleries = public.union(owned).union(member).all() result = [] for gal in galleries: own = User.query.get(gal.owner_id) result.append({ "id": gal.id, "title": gal.title, "description": gal.description, "owner": own.alias, "is_public": gal.is_public, "publication_date": gal.publication_date, }) return jsonify(result) # Show details of a single gallery (enforcing public or member access). @app.route("/api/private/gallery/", methods=["GET"]) @oidc_required def get_gallery(gallery_id): gal = Gallery.query.get_or_404(gallery_id) if not gal.is_public: member = GalleryMember.query.filter_by(gallery_id=gallery_id, user_id=g.db_user.id, status="accepted").first() if gal.owner_id != g.db_user.id and not member: abort(403) own = User.query.get(gal.owner_id) return jsonify({ "id": gal.id, "title": gal.title, "description": gal.description, "owner": own.alias, "is_public": gal.is_public, "publication_date": gal.publication_date, "created_at": gal.created_at, "updated_at": gal.updated_at }) def parse_iso_datetime(date_string): """Convert ISO 8601 datetime string to datetime object for MySQL""" if not date_string: return None try: # Handle ISO 8601 format with Z timezone if date_string.endswith('Z'): date_string = date_string[:-1] + '+00:00' # Parse the datetime and convert to MySQL format dt = datetime.fromisoformat(date_string.replace('Z', '+00:00')) return dt except ValueError: return None # Create a new gallery for the authenticated user. @app.route("/api/private/gallery", methods=["POST"]) @oidc_required def create_gallery(): data = request.json # Parse publication_date if provided pub_date = None if data.get("publication_date"): pub_date = parse_iso_datetime(data.get("publication_date")) gallery = Gallery( owner_id=g.db_user.id, title=data.get("title"), description=data.get("description"), is_public=data.get("is_public", False), publication_date=pub_date ) db.session.add(gallery) db.session.commit() event = { "type": "gallery_created", "data": {"user_id": gallery.owner_id, "gallery_id": gallery.id} } redis_client.publish('events', json.dumps(event)) return jsonify({"id": gallery.id, "message": "Gallery created"}), 201 # Update a gallery's title, description, public flag, and publication date (owner only). @app.route("/api/private/gallery/", methods=["PUT"]) @oidc_required def update_gallery(gallery_id): gal = Gallery.query.get_or_404(gallery_id) if gal.owner_id != g.db_user.id: abort(403) data = request.json gal.title = data.get("title", gal.title) gal.description = data.get("description", gal.description) gal.is_public = data.get("is_public", gal.is_public) gal.publication_date = data.get("publication_date", gal.publication_date) db.session.commit() event = { "type": "gallery_updated", "data": {"user_id": gal.owner_id, "gallery_id": gal.id} } redis_client.publish('events', json.dumps(event)) return jsonify({"message": "Gallery updated"}) # Retrieve the list of galleries owned by the authenticated user. @app.route("/api/private/galleries/mine", methods=["GET"]) @oidc_required def get_my_galleries(): user_id = g.db_user.id galleries = Gallery.query.filter_by(owner_id=user_id).all() result = [] for gal in galleries: result.append({ "id": gal.id, "title": gal.title, "description": gal.description, "is_public": gal.is_public, "publication_date": gal.publication_date, "created_at": gal.created_at, "updated_at": gal.updated_at }) return jsonify(result) # List all members of a gallery (including the owner), with roles and join dates. @app.route("/api/private/gallery//members", methods=["GET"]) @oidc_required def get_gallery_members(gallery_id): gal = Gallery.query.get_or_404(gallery_id) if not gal.is_public: member = GalleryMember.query.filter_by(gallery_id=gallery_id, user_id=g.db_user.id, status="accepted").first() if gal.owner_id != g.db_user.id and not member: abort(403) members = GalleryMember.query.filter_by(gallery_id=gallery_id, status="accepted").all() result = [] owner = User.query.get(gal.owner_id) result.append({ "user_id": owner.id, "alias": owner.alias, "bio": owner.bio, "profile_picture_url": owner.profile_picture_url, "role": "owner", "entered_at": gal.created_at }) for mem in members: user = User.query.get(mem.user_id) result.append({ "user_id": user.id, "alias": user.alias, "bio": user.bio, "profile_picture_url": user.profile_picture_url, "role": mem.role, "entered_at": mem.entered_at }) return jsonify(result) # Artworks # List artworks in a gallery, filtering by visibility and access. @app.route("/api/private/gallery//artworks", methods=["GET"]) @oidc_required def get_gallery_artworks(gallery_id): gal = Gallery.query.get_or_404(gallery_id) if not gal.is_public: member = GalleryMember.query.filter_by(gallery_id=gallery_id, user_id=g.db_user.id, status="accepted").first() if gal.owner_id != g.db_user.id and not member: abort(403) if gal.owner_id != g.db_user.id: artworks = Artwork.query.filter_by(gallery_id=gallery_id, is_visible=True).all() else: artworks = Artwork.query.filter_by(gallery_id=gallery_id).all() result = [] for art in artworks: cre = User.query.get(art.creator_id) result.append({ "id": art.id, "title": art.title, "description": art.description, "creator": cre.alias, "image_url": art.image_url, "medium": art.medium, "dimensions": art.dimensions, "creation_year": art.creation_year, "is_visible": art.is_visible, "price": art.price, "is_for_sale": art.is_for_sale }) return jsonify(result) # Retrieve detailed information about a single artwork (visibility & access checks). @app.route("/api/private/artwork/", methods=["GET"]) @oidc_required def get_artwork(artwork_id): art = Artwork.query.get_or_404(artwork_id) gal = Gallery.query.get(art.gallery_id) if not gal.is_public: member = GalleryMember.query.filter_by(gallery_id=art.gallery_id, user_id=g.db_user.id, status="accepted").first() if gal.owner_id != g.db_user.id and not member: abort(403) if art.creator_id != g.db_user.id and not art.is_visible: abort(404) cre = User.query.get(art.creator_id) return jsonify({ "id": art.id, "gallery_id": art.gallery_id, "creator": cre.alias, "title": art.title, "description": art.description, "image_url": art.image_url, "medium": art.medium, "dimensions": art.dimensions, "creation_year": art.creation_year, "is_visible": art.is_visible, "price": art.price, "is_for_sale": art.is_for_sale, "created_at": art.created_at, "updated_at": art.updated_at }) # Add a new artwork to the specified gallery (owner only). @app.route("/api/private/gallery//artwork", methods=["POST"]) @oidc_required def create_artwork(gallery_id): gallery = Gallery.query.get_or_404(gallery_id) if gallery.owner_id != g.db_user.id: abort(403) data = request.json artwork = Artwork( gallery_id=gallery_id, creator_id=g.db_user.id, title=data.get("title"), description=data.get("description"), image_url=data.get("image_url"), medium=data.get("medium"), dimensions=data.get("dimensions"), creation_year=data.get("creation_year"), price=data.get("price"), is_visible=data.get("is_visible", True), is_for_sale=data.get("is_for_sale", False) ) db.session.add(artwork) db.session.commit() event = { "type": "artwork_created", "data": {"user_id": artwork.creator_id, "artwork_id": artwork.id} } redis_client.publish('events', json.dumps(event)) return jsonify({"id": artwork.id, "message": "Artwork created"}), 201 # Update an existing artwork's details (creator only). @app.route("/api/private/artwork/", methods=["PUT"]) @oidc_required def update_artwork(artwork_id): art = Artwork.query.get_or_404(artwork_id) if art.creator_id != g.db_user.id: abort(403) data = request.json art.title = data.get("title", art.title) art.description = data.get("description", art.description) art.image_url = data.get("image_url", art.image_url) art.medium = data.get("medium", art.medium) art.dimensions = data.get("dimensions", art.dimensions) art.creation_year = data.get("creation_year", art.creation_year) art.price = data.get("price", art.price) art.is_visible = data.get("is_visible", art.is_visible) art.is_for_sale = data.get("is_for_sale", art.is_for_sale) db.session.commit() event = { "type": "artwork_updated", "data": {"user_id": art.creator_id, "artwork_id": art.id} } redis_client.publish('events', json.dumps(event)) return jsonify({"message": "Artwork updated"}) # List all artworks created by the authenticated user. @app.route("/api/private/artworks/mine", methods=["GET"]) @oidc_required def get_my_artworks(): artworks = Artwork.query.filter_by(creator_id=g.db_user.id).all() result = [] for art in artworks: result.append({ "id": art.id, "gallery_id": art.gallery_id, "title": art.title, "description": art.description, "image_url": art.image_url, "medium": art.medium, "dimensions": art.dimensions, "creation_year": art.creation_year, "is_visible": art.is_visible, "price": art.price, "is_for_sale": art.is_for_sale, "created_at": art.created_at, "updated_at": art.updated_at }) return jsonify(result) # Gallery reviews # List all reviews for a given gallery (with access checks). @app.route("/api/private/gallery//reviews", methods=["GET"]) @oidc_required def get_gallery_reviews(gallery_id): gal = Gallery.query.get_or_404(gallery_id) if not gal.is_public: member = GalleryMember.query.filter_by(gallery_id=gallery_id, user_id=g.db_user.id, status="accepted").first() if gal.owner_id != g.db_user.id and not member: abort(403) reviews = GalleryReview.query.filter_by(gallery_id=gal.id).all() result = [] for rev in reviews: aut = User.query.get(rev.author_id) result.append({ "id": rev.id, "author": aut.alias, "grade": rev.grade, "description": rev.description, "parent_gr_id": rev.parent_gr_id, "created_at": rev.created_at, "updated_at": rev.updated_at }) return jsonify(result) # Submit a new review for the specified gallery (access enforced). @app.route("/api/private/gallery//review", methods=["POST"]) @oidc_required def create_gallery_review(gallery_id): gal = Gallery.query.get_or_404(gallery_id) if not gal.is_public: member = GalleryMember.query.filter_by(gallery_id=gallery_id, user_id=g.db_user.id, status="accepted").first() if gal.owner_id != g.db_user.id and not member: abort(403) data = request.json review = GalleryReview( gallery_id=gal.id, author_id=g.db_user.id, grade=data.get("grade"), description=data.get("description"), parent_gr_id=data.get("parent_gr_id") ) db.session.add(review) db.session.commit() event = { "type": "gallery_review_created", "data": {"user_id": review.author_id, "gallery_id": review.gallery_id, "gallery_review_id": review.id} } redis_client.publish('events', json.dumps(event)) return jsonify({"id": review.id, "message": "Review created"}), 201 # Edit an existing gallery review (author only). @app.route("/api/private/galleries/review/", methods=["PUT"]) @oidc_required def update_gallery_review(review_id): rev = GalleryReview.query.get_or_404(review_id) if rev.author_id != g.db_user.id: abort(403) gal = Gallery.query.get_or_404(rev.gallery_id) if not gal.is_public: member = GalleryMember.query.filter_by(gallery_id=rev.gallery_id, user_id=g.db_user.id, status="accepted").first() if gal.owner_id != g.db_user.id and not member: abort(403) data = request.json rev.grade = data.get("grade", rev.grade) rev.description = data.get("description", rev.description) db.session.commit() event = { "type": "gallery_review_updated", "data": {"user_id": rev.author_id, "gallery_id": rev.gallery_id, "gallery_review_id": rev.id} } redis_client.publish('events', json.dumps(event)) return jsonify({"message": "Review updated"}) # Retrieve all gallery reviews written by the authenticated user. @app.route("/api/private/galleries/reviews/given", methods=["GET"]) @oidc_required def get_given_gallery_reviews(): reviews = GalleryReview.query.filter_by(author_id=g.db_user.id).all() result = [] for rev in reviews: gal = Gallery.query.get(rev.gallery_id) result.append({ "review_id": rev.id, "gallery_id": gal.id, "gallery_title": gal.title, "grade": rev.grade, "description": rev.description, "parent_gr_id": rev.parent_gr_id, "created_at": rev.created_at, "updated_at": rev.updated_at }) return jsonify(result) # List all reviews received on galleries owned by the authenticated user. @app.route("/api/private/galleries/reviews/received", methods=["GET"]) @oidc_required def get_received_gallery_reviews(): galleries = Gallery.query.filter_by(owner_id=g.db_user.id).all() result = [] for gal in galleries: reviews = GalleryReview.query.filter_by(gallery_id=gal.id).all() for rev in reviews: author = User.query.get(rev.author_id) result.append({ "review_id": rev.id, "gallery_id": gal.id, "gallery_title": gal.title, "author": author.alias, "grade": rev.grade, "description": rev.description, "parent_gr_id": rev.parent_gr_id, "created_at": rev.created_at, "updated_at": rev.updated_at }) return jsonify(result) # Artwork reviews # List all reviews for a given artwork (with access checks). @app.route("/api/private/artwork//reviews", methods=["GET"]) @oidc_required def get_artwork_reviews(artwork_id): art = Artwork.query.get_or_404(artwork_id) gal = Gallery.query.get(art.gallery_id) if not gal.is_public: member = GalleryMember.query.filter_by(gallery_id=art.gallery_id, user_id=g.db_user.id, status="accepted").first() if gal.owner_id != g.db_user.id and not member: abort(403) if art.creator_id != g.db_user.id and not art.is_visible: abort(404) reviews = ArtworkReview.query.filter_by(artwork_id=art.id).all() result = [] for rev in reviews: aut = User.query.get(rev.author_id) result.append({ "id": rev.id, "author": aut.alias, "grade": rev.grade, "description": rev.description, "parent_ar_id": rev.parent_ar_id, "created_at": rev.created_at, "updated_at": rev.updated_at }) return jsonify(result) # Submit a new review for the specified artwork (access to gallery enforced). @app.route("/api/private/artwork//review", methods=["POST"]) @oidc_required def create_artwork_review(artwork_id): art = Artwork.query.get_or_404(artwork_id) gal = Gallery.query.get(art.gallery_id) if not gal.is_public: member = GalleryMember.query.filter_by(gallery_id=art.gallery_id, user_id=g.db_user.id, status="accepted").first() if gal.owner_id != g.db_user.id and not member: abort(403) if art.creator_id != g.db_user.id and not art.is_visible: abort(404) data = request.json review = ArtworkReview( artwork_id=art.id, author_id=g.db_user.id, grade=data.get("grade"), description=data.get("description"), parent_ar_id=data.get("parent_ar_id") ) db.session.add(review) db.session.commit() event = { "type": "artwork_review_created", "data": {"user_id": review.author_id, "artwork_id": review.artwork_id, "artwork_review_id": review.id} } redis_client.publish('events', json.dumps(event)) return jsonify({"id": review.id, "message": "Review created"}), 201 # Edit an existing artwork review (author only). @app.route("/api/private/artworks/review/", methods=["PUT"]) @oidc_required def update_artwork_review(review_id): rev = ArtworkReview.query.get_or_404(review_id) if rev.author_id != g.db_user.id: abort(403) art = Artwork.query.get_or_404(rev.artwork_id) gal = Gallery.query.get(art.gallery_id) if not gal.is_public: member = GalleryMember.query.filter_by(gallery_id=art.gallery_id, user_id=g.db_user.id, status="accepted").first() if gal.owner_id != g.db_user.id and not member: abort(403) if art.creator_id != g.db_user.id and not art.is_visible: abort(404) data = request.json rev.grade = data.get("grade", rev.grade) rev.description = data.get("description", rev.description) db.session.commit() event = { "type": "artwork_review_updated", "data": {"user_id": rev.author_id, "artwork_id": rev.artwork_id, "artwork_review_id": rev.id} } redis_client.publish('events', json.dumps(event)) return jsonify({"message": "Review updated"}) # Retrieve all artwork reviews written by the authenticated user. @app.route("/api/private/artworks/reviews/given", methods=["GET"]) @oidc_required def get_given_artwork_reviews(): reviews = ArtworkReview.query.filter_by(author_id=g.db_user.id).all() result = [] for rev in reviews: art = Artwork.query.get(rev.artwork_id) result.append({ "review_id": rev.id, "artwork_id": art.id, "artwork_title": art.title, "grade": rev.grade, "description": rev.description, "parent_ar_id": rev.parent_ar_id, "created_at": rev.created_at, "updated_at": rev.updated_at }) return jsonify(result) # List all reviews received on artworks owned by the authenticated user. @app.route("/api/private/artworks/reviews/received", methods=["GET"]) @oidc_required def get_received_artwork_reviews(): artworks = Artwork.query.filter_by(creator_id=g.db_user.id).all() result = [] for art in artworks: reviews = ArtworkReview.query.filter_by(artwork_id=art.id).all() for rev in reviews: author = User.query.get(rev.author_id) result.append({ "review_id": rev.id, "artwork_id": art.id, "artwork_title": art.title, "author": author.alias, "grade": rev.grade, "description": rev.description, "parent_ar_id": rev.parent_ar_id, "created_at": rev.created_at, "updated_at": rev.updated_at }) return jsonify(result) if __name__ == "__main__": app.run(host='0.0.0.0',port=5002, debug=True)