diff --git a/blueprints/auth/routes.py b/blueprints/auth/routes.py index d1f4627..69b88f3 100644 --- a/blueprints/auth/routes.py +++ b/blueprints/auth/routes.py @@ -763,6 +763,125 @@ def konto_dane_post(): return redirect(url_for('auth.konto_dane')) +@bp.route('/konto/avatar', methods=['POST']) +@login_required +def konto_avatar_upload(): + """Upload or replace user avatar photo""" + from file_upload_service import FileUploadService, _BASE_DIR + from PIL import Image + + file = request.files.get('avatar') + if not file or file.filename == '': + flash('Nie wybrano pliku.', 'error') + return redirect(url_for('auth.konto_dane')) + + # Validate using existing service + is_valid, error = FileUploadService.validate_file(file) + if not is_valid: + flash(error, 'error') + return redirect(url_for('auth.konto_dane')) + + db = SessionLocal() + try: + user = db.query(User).filter_by(id=current_user.id).first() + if not user: + flash('Nie znaleziono użytkownika.', 'error') + return redirect(url_for('auth.konto_dane')) + + # Generate filename and path + ext = file.filename.rsplit('.', 1)[-1].lower() + if ext == 'jpeg': + ext = 'jpg' + import uuid + stored_filename = f"{uuid.uuid4()}.{ext}" + + now = datetime.now() + avatar_dir = os.path.join(_BASE_DIR, 'static', 'uploads', 'avatars', str(now.year), f"{now.month:02d}") + os.makedirs(avatar_dir, exist_ok=True) + file_path = os.path.join(avatar_dir, stored_filename) + + # Resize to 300x300 square crop, strip EXIF + img = Image.open(file) + if img.mode in ('RGBA', 'LA', 'P') and ext == 'jpg': + img = img.convert('RGB') + elif img.mode not in ('RGB', 'RGBA'): + img = img.convert('RGB') + + # Center crop to square + w, h = img.size + side = min(w, h) + left = (w - side) // 2 + top = (h - side) // 2 + img = img.crop((left, top, left + side, top + side)) + img = img.resize((300, 300), Image.LANCZOS) + + # Save + save_kwargs = {'optimize': True} + if ext == 'jpg': + save_kwargs['quality'] = 85 + img.save(file_path, **save_kwargs) + + # Delete old avatar if exists + if user.avatar_path: + old_path = os.path.join(_BASE_DIR, 'static', user.avatar_path) + if os.path.exists(old_path): + try: + os.remove(old_path) + except OSError: + pass + + # Save relative path + user.avatar_path = os.path.relpath(file_path, os.path.join(_BASE_DIR, 'static')) + db.commit() + current_user.avatar_path = user.avatar_path + + logger.info(f"Avatar uploaded for user {user.id}: {user.avatar_path}") + flash('Zdjęcie profilowe zostało zapisane.', 'success') + + except Exception as e: + db.rollback() + logger.error(f"Avatar upload error: {e}") + flash('Wystąpił błąd podczas zapisywania zdjęcia.', 'error') + finally: + db.close() + + return redirect(url_for('auth.konto_dane')) + + +@bp.route('/konto/avatar/delete', methods=['POST']) +@login_required +def konto_avatar_delete(): + """Delete user avatar photo""" + from file_upload_service import _BASE_DIR + + db = SessionLocal() + try: + user = db.query(User).filter_by(id=current_user.id).first() + if user and user.avatar_path: + # Delete file + old_path = os.path.join(_BASE_DIR, 'static', user.avatar_path) + if os.path.exists(old_path): + try: + os.remove(old_path) + except OSError: + pass + + user.avatar_path = None + db.commit() + current_user.avatar_path = None + + logger.info(f"Avatar deleted for user {user.id}") + flash('Zdjęcie profilowe zostało usunięte.', 'success') + except Exception as e: + db.rollback() + logger.error(f"Avatar delete error: {e}") + flash('Wystąpił błąd podczas usuwania zdjęcia.', 'error') + finally: + db.close() + + return redirect(url_for('auth.konto_dane')) + + @bp.route('/konto/prywatnosc', methods=['GET', 'POST']) @login_required def konto_prywatnosc(): diff --git a/database.py b/database.py index 710f8b7..2e9386c 100644 --- a/database.py +++ b/database.py @@ -289,6 +289,7 @@ class User(Base, UserMixin): is_admin = Column(Boolean, default=False) # DEPRECATED: synced by set_role() for backward compat. Use has_role(SystemRole.ADMIN) instead. Will be removed in future migration. is_norda_member = Column(Boolean, default=False) is_rada_member = Column(Boolean, default=False) # Member of Rada Izby (Board Council) + avatar_path = Column(String(500)) # Path to profile photo (relative to static/uploads/) # Timestamps created_at = Column(DateTime, default=datetime.now) diff --git a/database/migrations/070_user_avatar.sql b/database/migrations/070_user_avatar.sql new file mode 100644 index 0000000..178e429 --- /dev/null +++ b/database/migrations/070_user_avatar.sql @@ -0,0 +1,7 @@ +-- Migration: Add avatar_path to users table +-- Author: Maciej Pienczyn +-- Date: 2026-03-12 + +ALTER TABLE users ADD COLUMN IF NOT EXISTS avatar_path VARCHAR(500); + +COMMENT ON COLUMN users.avatar_path IS 'Path to profile photo relative to static/uploads/'; diff --git a/static/uploads/avatars/.gitkeep b/static/uploads/avatars/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/templates/konto/dane.html b/templates/konto/dane.html index b3971f5..35ef896 100644 --- a/templates/konto/dane.html +++ b/templates/konto/dane.html @@ -40,6 +40,98 @@ justify-content: center; font-size: 20px; font-weight: 600; + position: relative; + overflow: hidden; + } + + .konto-avatar img { + width: 100%; + height: 100%; + object-fit: cover; + } + + .avatar-edit-section { + text-align: center; + padding: var(--spacing-md) 0; + margin-bottom: var(--spacing-md); + border-bottom: 1px solid var(--border); + } + + .avatar-edit-large { + width: 96px; + height: 96px; + border-radius: 50%; + background: linear-gradient(135deg, var(--primary), #1e40af); + color: white; + display: flex; + align-items: center; + justify-content: center; + font-size: 36px; + font-weight: 600; + margin: 0 auto var(--spacing-sm); + position: relative; + overflow: hidden; + cursor: pointer; + } + + .avatar-edit-large img { + width: 100%; + height: 100%; + object-fit: cover; + } + + .avatar-edit-large:hover .avatar-overlay { + opacity: 1; + } + + .avatar-overlay { + position: absolute; + inset: 0; + background: rgba(0,0,0,0.5); + display: flex; + align-items: center; + justify-content: center; + opacity: 0; + transition: opacity 0.2s; + border-radius: 50%; + } + + .avatar-overlay svg { + width: 24px; + height: 24px; + color: white; + } + + .avatar-actions { + display: flex; + gap: var(--spacing-xs); + justify-content: center; + margin-top: var(--spacing-sm); + } + + .avatar-actions button, .avatar-actions label { + font-size: 11px; + padding: 4px 10px; + border-radius: var(--radius); + cursor: pointer; + border: 1px solid var(--border); + background: var(--surface); + color: var(--text-secondary); + transition: all 0.2s; + } + + .avatar-actions label:hover, .avatar-actions button:hover { + background: var(--background); + color: var(--text-primary); + } + + .avatar-actions .btn-delete-avatar { + color: #dc2626; + border-color: #fecaca; + } + + .avatar-actions .btn-delete-avatar:hover { + background: #fef2f2; } .konto-sidebar-name { @@ -234,13 +326,45 @@