from flask import Blueprint, jsonify, request, session, current_app, Response from extensions import db from itsdangerous import URLSafeSerializer from models.notepad import Notepad from models.note import Note from models.collaborator import Collaborator from models.user import User from models.favorite import Favorite from sqlalchemy import func import csv from io import StringIO api_bp = Blueprint('api', __name__, url_prefix='/api') def get_share_token(item_type, item_id): s = URLSafeSerializer(current_app.config['SECRET_KEY'], salt='share-link') return s.dumps({'type': item_type, 'id': item_id}) def validate_share_token(token, item_type, item_id): s = URLSafeSerializer(current_app.config['SECRET_KEY'], salt='share-link') try: data = s.loads(token) return data.get('type') == item_type and data.get('id') == item_id except: return False def check_pad_read_permission(notepad, user_id, token=None): is_shared_by_link = token and validate_share_token(token, 'notepad', notepad.id) if notepad.is_public or is_shared_by_link: return True if not user_id: return False if notepad.user_id == user_id: return True # Check for collaborator (any permission level grants read) if Collaborator.query.filter_by(user_id=user_id, notepad_id=notepad.id).first(): return True return False def check_pad_write_permission(notepad, user_id): if not user_id: return False if notepad.user_id == user_id: return True # Check for collaborator with 'write' permission collab = Collaborator.query.filter_by(user_id=user_id, notepad_id=notepad.id, permission='write').first() return collab is not None @api_bp.route('/notepads', methods=['GET']) def get_notepads(): user_id = session.get('user_id') filter_type = request.args.get('type') query = Notepad.query if filter_type == 'favorites': query = Notepad.query.join(Favorite).filter(Favorite.user_id == user_id) if user_id else Notepad.query.filter_by(id=-1) elif filter_type == 'mine': query = query.filter_by(user_id=user_id) if user_id else Notepad.query.filter_by(id=-1) elif filter_type == 'shared': query = Notepad.query.join(Collaborator).filter(Collaborator.user_id == user_id) if user_id else Notepad.query.filter_by(id=-1) elif filter_type == 'public': query = query.filter_by(is_public=True) else: # Default behavior (union) if no type specified if user_id: query = query.filter((Notepad.is_public == True) | (Notepad.user_id == user_id)) else: query = query.filter_by(is_public=True) pads = query.order_by(Notepad.name).all() # Optimize note count query (O(1) database hit instead of O(N)) note_counts = dict(db.session.query(Note.notepad_id, func.count(Note.id)).filter(Note.is_hidden == False).group_by(Note.notepad_id).all()) # Optimize write permissions check collab_write_pad_ids = set() if user_id: collab_write_pad_ids = {c.notepad_id for c in Collaborator.query.filter_by(user_id=user_id, permission='write').all()} results = [] for pad in pads: note_count = note_counts.get(pad.id, 0) p_dict = pad.to_dict(note_count=note_count) if user_id: if pad.user_id == user_id: p_dict['share_token'] = get_share_token('notepad', pad.id) p_dict['can_write'] = (pad.user_id == user_id) or (pad.id in collab_write_pad_ids) else: p_dict['can_write'] = False results.append(p_dict) return jsonify(results) @api_bp.route('/notepads/', methods=['GET']) def get_notepad(pad_id): notepad = Notepad.query.get_or_404(pad_id) user_id = session.get('user_id') token = request.args.get('token') if not check_pad_read_permission(notepad, user_id, token): return jsonify({'error': 'Unauthorized'}), 403 p_dict = notepad.to_dict() p_dict['can_write'] = check_pad_write_permission(notepad, user_id) if user_id and notepad.user_id == user_id: p_dict['share_token'] = get_share_token('notepad', pad_id) return jsonify(p_dict) @api_bp.route('/notepads', methods=['POST']) def create_notepad(): user_id = session.get('user_id') if not user_id: return jsonify({'error': 'Unauthorized'}), 401 data = request.get_json() new_pad = Notepad(name=data['name'], is_public=data.get('is_public', False), user_id=user_id, color=data.get('color', '#3388ff')) db.session.add(new_pad) db.session.commit() return jsonify(new_pad.to_dict()), 201 @api_bp.route('/notepads//notes', methods=['GET']) def get_notes(pad_id): notepad = Notepad.query.get_or_404(pad_id) user_id = session.get('user_id') token = request.args.get('token') if not check_pad_read_permission(notepad, user_id, token): return jsonify({'error': 'Unauthorized'}), 403 notes = notepad.notes.filter_by(is_hidden=False).all() results = [] for note in notes: n_dict = note.to_dict() if user_id and notepad.user_id == user_id: n_dict['share_token'] = get_share_token('note', note.id) results.append(n_dict) return jsonify(results) @api_bp.route('/notes/', methods=['GET']) def get_note(note_id): note = Note.query.get_or_404(note_id) notepad = Notepad.query.get(note.notepad_id) user_id = session.get('user_id') token = request.args.get('token') is_shared = token and validate_share_token(token, 'note', note_id) if not notepad.is_public and (not user_id or notepad.user_id != user_id) and not is_shared: return jsonify({'error': 'Unauthorized'}), 403 n_dict = note.to_dict() n_dict['can_write'] = check_pad_write_permission(notepad, user_id) pad_dict = notepad.to_dict() # Redact name if private and not owner if not notepad.is_public and (not user_id or notepad.user_id != user_id): pad_dict['name'] = "Private Notepad" n_dict['notepad'] = pad_dict n_dict['owner_id'] = notepad.user_id n_dict['is_public'] = notepad.is_public if user_id and notepad.user_id == user_id: n_dict['share_token'] = get_share_token('note', note_id) return jsonify(n_dict) @api_bp.route('/notes/mine', methods=['GET']) def get_my_all_notes(): user_id = session.get('user_id') if not user_id: return jsonify({'error': 'Unauthorized'}), 401 notes = Note.query.join(Notepad).filter(Notepad.user_id == user_id, Note.is_hidden == False).all() # Eagerly load note counts to avoid N+1 queries when calling pad.to_dict() note_counts = dict(db.session.query(Note.notepad_id, func.count(Note.id)).filter(Note.is_hidden == False).group_by(Note.notepad_id).all()) results = [] for note in notes: n_dict = note.to_dict() n_dict['notepad'] = note.notepad.to_dict(note_count=note_counts.get(note.notepad_id, 0)) n_dict['owner_id'] = user_id n_dict['is_public'] = note.notepad.is_public n_dict['share_token'] = get_share_token('note', note.id) results.append(n_dict) return jsonify(results) @api_bp.route('/notes/default', methods=['GET']) def get_default_notes(): user_id = session.get('user_id') if user_id: # Get owned notepads + favorite notepads owned_pad_ids = [p.id for p in Notepad.query.filter_by(user_id=user_id).all()] fav_pad_ids = [f.notepad_id for f in Favorite.query.filter_by(user_id=user_id).all()] all_visible_pad_ids = list(set(owned_pad_ids + fav_pad_ids)) notes = Note.query.filter(Note.notepad_id.in_(all_visible_pad_ids), Note.is_hidden == False).all() else: # Logged out: All public notes notes = Note.query.join(Notepad).filter(Notepad.is_public == True, Note.is_hidden == False).all() # Eagerly load note counts to avoid N+1 queries when calling pad.to_dict() note_counts = dict(db.session.query(Note.notepad_id, func.count(Note.id)).filter(Note.is_hidden == False).group_by(Note.notepad_id).all()) results = [] for note in notes: n_dict = note.to_dict() n_dict['notepad'] = note.notepad.to_dict(note_count=note_counts.get(note.notepad_id, 0)) n_dict['owner_id'] = note.notepad.user_id n_dict['is_public'] = note.notepad.is_public if user_id and note.notepad.user_id == user_id: n_dict['share_token'] = get_share_token('note', note.id) results.append(n_dict) return jsonify(results) @api_bp.route('/notes', methods=['POST']) def create_note(): user_id = session.get('user_id') if not user_id: return jsonify({'error': 'Unauthorized'}), 401 data = request.get_json() notepad_id = data.get('notepad_id') if not notepad_id: return jsonify({'error': 'notepad_id is required'}), 400 notepad = Notepad.query.get_or_404(notepad_id) if not check_pad_write_permission(notepad, user_id): return jsonify({'error': 'Forbidden'}), 403 new_note = Note( title=data.get('title', 'Untitled'), content=data['content'], lat=data['lat'], lng=data['lng'], notepad_id=notepad_id ) db.session.add(new_note) db.session.commit() return jsonify(new_note.to_dict()), 201 @api_bp.route('/notes/', methods=['PUT']) def update_note(note_id): user_id = session.get('user_id') if not user_id: return jsonify({'error': 'Unauthorized'}), 401 note = Note.query.get_or_404(note_id) notepad = Notepad.query.get(note.notepad_id) if not check_pad_write_permission(notepad, user_id): return jsonify({'error': 'Forbidden'}), 403 data = request.get_json() note.title = data.get('title', note.title) note.content = data.get('content', note.content) db.session.commit() return jsonify(note.to_dict()) @api_bp.route('/notepads/', methods=['PUT']) def update_notepad(pad_id): user_id = session.get('user_id') if not user_id: return jsonify({'error': 'Unauthorized'}), 401 notepad = Notepad.query.get_or_404(pad_id) # Only owner can edit notepad metadata if notepad.user_id != user_id: return jsonify({'error': 'Forbidden'}), 403 data = request.get_json() notepad.name = data.get('name', notepad.name) notepad.is_public = data.get('is_public', notepad.is_public) notepad.color = data.get('color', notepad.color) db.session.commit() return jsonify(notepad.to_dict()) @api_bp.route('/notes//report', methods=['POST']) def report_note(note_id): note = Note.query.get_or_404(note_id) if not note.notepad.is_public: return jsonify({'error': 'Can only report public notes'}), 400 note.is_hidden = True db.session.commit() return jsonify({'message': 'Note reported'}), 200 @api_bp.route('/notepads//favorite', methods=['POST']) def favorite_notepad(pad_id): user_id = session.get('user_id') if not user_id: return jsonify({'error': 'Unauthorized'}), 401 existing_favorite = Favorite.query.filter_by(user_id=user_id, notepad_id=pad_id).first() if existing_favorite: return jsonify({'message': 'Notepad already favorited'}), 200 new_favorite = Favorite(user_id=user_id, notepad_id=pad_id) db.session.add(new_favorite) db.session.commit() return jsonify({'message': 'Notepad favorited'}), 201 @api_bp.route('/notepads//unfavorite', methods=['POST']) def unfavorite_notepad(pad_id): user_id = session.get('user_id') favorite = Favorite.query.filter_by(user_id=user_id, notepad_id=pad_id).first() if favorite: db.session.delete(favorite) db.session.commit() return jsonify({'message': 'Notepad unfavorited'}), 200 @api_bp.route('/notepads//collaborators', methods=['GET']) def get_collaborators(pad_id): user_id = session.get('user_id') notepad = Notepad.query.get_or_404(pad_id) # Only owner can see collaborators if notepad.user_id != user_id: return jsonify({'error': 'Forbidden'}), 403 collaborators = Collaborator.query.join(User).filter(Collaborator.notepad_id == pad_id).all() results = [] for collab in collaborators: results.append({ 'user_id': collab.user.id, 'email': collab.user.email, 'permission': collab.permission }) return jsonify(results) @api_bp.route('/notepads//collaborators', methods=['POST']) def add_collaborator(pad_id): user_id = session.get('user_id') notepad = Notepad.query.get_or_404(pad_id) if notepad.user_id != user_id: return jsonify({'error': 'Forbidden'}), 403 data = request.get_json() email = data.get('email') permission = data.get('permission') if not email or not permission or permission not in ['read', 'write']: return jsonify({'error': 'Email and permission (read/write) are required'}), 400 collaborator_user = User.query.filter_by(email=email).first() if not collaborator_user: return jsonify({'error': 'User not found'}), 404 if collaborator_user.id == user_id: return jsonify({'error': 'Cannot add yourself as a collaborator'}), 400 existing_collab = Collaborator.query.filter_by(notepad_id=pad_id, user_id=collaborator_user.id).first() if existing_collab: existing_collab.permission = permission else: new_collab = Collaborator(notepad_id=pad_id, user_id=collaborator_user.id, permission=permission) db.session.add(new_collab) db.session.commit() return jsonify({'message': 'Collaborator added/updated successfully'}), 200 @api_bp.route('/notepads//collaborators/', methods=['DELETE']) def remove_collaborator(pad_id, collaborator_user_id): user_id = session.get('user_id') notepad = Notepad.query.get_or_404(pad_id) if notepad.user_id != user_id: return jsonify({'error': 'Forbidden'}), 403 collab_to_remove = Collaborator.query.filter_by(notepad_id=pad_id, user_id=collaborator_user_id).first() if not collab_to_remove: return jsonify({'error': 'Collaborator not found'}), 404 db.session.delete(collab_to_remove) db.session.commit() return jsonify({'message': 'Collaborator removed'}), 200 @api_bp.route('/notepads//export', methods=['GET']) def export_notepad(pad_id): notepad = Notepad.query.get_or_404(pad_id) user_id = session.get('user_id') token = request.args.get('token') if not check_pad_read_permission(notepad, user_id, token): return jsonify({'error': 'Unauthorized'}), 403 si = StringIO() cw = csv.writer(si) cw.writerow(['Note ID', 'Title', 'Content', 'Latitude', 'Longitude', 'Created At']) for note in notepad.notes.filter_by(is_hidden=False): cw.writerow([note.id, note.title, note.content, note.lat, note.lng, note.created_at]) output = si.getvalue() return Response( output, mimetype="text/csv", headers={"Content-Disposition": f"attachment;filename=notepad_{pad_id}_export.csv"} ) @api_bp.route('/notepads/', methods=['DELETE']) def delete_notepad(pad_id): user_id = session.get('user_id') if not user_id: return jsonify({'error': 'Unauthorized'}), 401 notepad = Notepad.query.get_or_404(pad_id) if notepad.user_id != user_id: return jsonify({'error': 'Forbidden'}), 403 for note in notepad.notes: db.session.delete(note) Favorite.query.filter_by(notepad_id=notepad.id).delete() db.session.delete(notepad) db.session.commit() return jsonify({'message': 'Notepad deleted'}), 200 @api_bp.route('/notes/', methods=['DELETE']) def delete_note(note_id): user_id = session.get('user_id') if not user_id: return jsonify({'error': 'Unauthorized'}), 401 note = Note.query.get_or_404(note_id) notepad = Notepad.query.get(note.notepad_id) if not check_pad_write_permission(notepad, user_id): return jsonify({'error': 'Forbidden'}), 403 db.session.delete(note) db.session.commit() return jsonify({'message': 'Note deleted'}), 200