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
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:
parent
da5f93368f
commit
3a7faa782b
@ -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():
|
||||
|
||||
@ -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)
|
||||
|
||||
7
database/migrations/070_user_avatar.sql
Normal file
7
database/migrations/070_user_avatar.sql
Normal 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/';
|
||||
0
static/uploads/avatars/.gitkeep
Normal file
0
static/uploads/avatars/.gitkeep
Normal file
@ -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 @@
|
||||
<aside class="konto-sidebar">
|
||||
<div class="konto-sidebar-header">
|
||||
<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() }}
|
||||
{% endif %}
|
||||
</div>
|
||||
<div>
|
||||
<div class="konto-sidebar-name">{{ current_user.name or 'Użytkownik' }}</div>
|
||||
<div class="konto-sidebar-email">{{ current_user.email }}</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">
|
||||
<a href="{{ url_for('konto_dane') }}" class="active">
|
||||
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
@ -380,3 +504,16 @@
|
||||
</main>
|
||||
</div>
|
||||
{% 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 %}
|
||||
|
||||
@ -237,7 +237,11 @@
|
||||
<!-- Person Header -->
|
||||
<div class="person-header">
|
||||
<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] }}
|
||||
{% endif %}
|
||||
</div>
|
||||
<h1 class="person-name">
|
||||
{{ person.full_name() }}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user