1016 lines
38 KiB
Python
1016 lines
38 KiB
Python
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/<int:gallery_id>/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/<int:gallery_id>/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/<int:gallery_id>", 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/<int:gallery_id>", 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/<int:gallery_id>/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/<int:gallery_id>/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/<int:artwork_id>", 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/<int:gallery_id>/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/<int:artwork_id>", 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/<int:gallery_id>/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/<int:gallery_id>/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/<int:review_id>", 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/<int:artwork_id>/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/<int:artwork_id>/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/<int:review_id>", 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)
|