feat: user avatar upload with crop, resize, and EXIF strip
Some checks are pending
NordaBiz Tests / Unit & Integration Tests (push) Waiting to run
NordaBiz Tests / E2E Tests (Playwright) (push) Blocked by required conditions
NordaBiz Tests / Smoke Tests (Production) (push) Blocked by required conditions
NordaBiz Tests / Send Failure Notification (push) Blocked by required conditions

- POST /konto/avatar: upload, center-crop to square, resize 300x300
- POST /konto/avatar/delete: remove file and clear DB
- dane.html: interactive avatar editor with hover overlay
- person_detail.html: show photo if available, fallback to initials
- Migration 070: avatar_path column on users table

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Maciej Pienczyn 2026-03-12 10:18:29 +01:00
parent da5f93368f
commit 3a7faa782b
6 changed files with 268 additions and 0 deletions

View File

@ -763,6 +763,125 @@ def konto_dane_post():
return redirect(url_for('auth.konto_dane')) 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']) @bp.route('/konto/prywatnosc', methods=['GET', 'POST'])
@login_required @login_required
def konto_prywatnosc(): def konto_prywatnosc():

View File

@ -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_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_norda_member = Column(Boolean, default=False)
is_rada_member = Column(Boolean, default=False) # Member of Rada Izby (Board Council) 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 # Timestamps
created_at = Column(DateTime, default=datetime.now) created_at = Column(DateTime, default=datetime.now)

View File

@ -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/';

View File

View File

@ -40,6 +40,98 @@
justify-content: center; justify-content: center;
font-size: 20px; font-size: 20px;
font-weight: 600; 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 { .konto-sidebar-name {
@ -234,13 +326,45 @@
<aside class="konto-sidebar"> <aside class="konto-sidebar">
<div class="konto-sidebar-header"> <div class="konto-sidebar-header">
<div class="konto-avatar"> <div class="konto-avatar">
{% if current_user.avatar_path %}
<img src="{{ url_for('static', filename=current_user.avatar_path) }}" alt="Zdjęcie profilowe">
{% else %}
{{ (current_user.name or current_user.email)[0].upper() }} {{ (current_user.name or current_user.email)[0].upper() }}
{% endif %}
</div> </div>
<div> <div>
<div class="konto-sidebar-name">{{ current_user.name or 'Użytkownik' }}</div> <div class="konto-sidebar-name">{{ current_user.name or 'Użytkownik' }}</div>
<div class="konto-sidebar-email">{{ current_user.email }}</div> <div class="konto-sidebar-email">{{ current_user.email }}</div>
</div> </div>
</div> </div>
<div class="avatar-edit-section">
<label for="avatar-input" class="avatar-edit-large" title="Zmień zdjęcie profilowe">
{% if current_user.avatar_path %}
<img src="{{ url_for('static', filename=current_user.avatar_path) }}" alt="Zdjęcie profilowe">
{% else %}
{{ (current_user.name or current_user.email)[0].upper() }}
{% endif %}
<div class="avatar-overlay">
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z"/>
<circle cx="12" cy="13" r="3"/>
</svg>
</div>
</label>
<form id="avatar-form" method="POST" action="{{ url_for('auth.konto_avatar_upload') }}" enctype="multipart/form-data" style="display:none;">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="file" id="avatar-input" name="avatar" accept="image/jpeg,image/png,image/gif">
</form>
<div class="avatar-actions">
<label for="avatar-input" style="cursor:pointer;">Zmień zdjęcie</label>
{% if current_user.avatar_path %}
<form method="POST" action="{{ url_for('auth.konto_avatar_delete') }}" style="display:inline;" onsubmit="return confirm('Usunąć zdjęcie profilowe?')">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn-delete-avatar">Usuń</button>
</form>
{% endif %}
</div>
</div>
<nav class="konto-nav"> <nav class="konto-nav">
<a href="{{ url_for('konto_dane') }}" class="active"> <a href="{{ url_for('konto_dane') }}" class="active">
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"> <svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
@ -380,3 +504,16 @@
</main> </main>
</div> </div>
{% endblock %} {% endblock %}
{% block extra_js %}
document.getElementById('avatar-input').addEventListener('change', function() {
if (this.files && this.files[0]) {
var file = this.files[0];
if (file.size > 5 * 1024 * 1024) {
alert('Plik jest za duży (max 5MB)');
return;
}
document.getElementById('avatar-form').submit();
}
});
{% endblock %}

View File

@ -237,7 +237,11 @@
<!-- Person Header --> <!-- Person Header -->
<div class="person-header"> <div class="person-header">
<div class="person-avatar"> <div class="person-avatar">
{% if portal_user and portal_user.avatar_path %}
<img src="{{ url_for('static', filename=portal_user.avatar_path) }}" alt="{{ person.full_name() }}" style="width:100%;height:100%;object-fit:cover;border-radius:50%;">
{% else %}
{{ person.imiona[0] }}{{ person.nazwisko[0] }} {{ person.imiona[0] }}{{ person.nazwisko[0] }}
{% endif %}
</div> </div>
<h1 class="person-name"> <h1 class="person-name">
{{ person.full_name() }} {{ person.full_name() }}