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'))
|
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():
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
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;
|
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 %}
|
||||||
|
|||||||
@ -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() }}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user