nordabiz/blueprints/messages/group_routes.py
Maciej Pienczyn d86e77aef0
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
feat(messages): delete group and delete individual messages
- Group owner can delete entire group (danger zone in manage panel)
- Message author or group owner can delete individual messages (trash icon on hover)
- CASCADE deletes attachments from disk

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 12:26:50 +01:00

750 lines
28 KiB
Python

"""
Group Messages Routes
=====================
Group chat creation, viewing, and messaging.
"""
import os
from datetime import datetime
from flask import render_template, request, redirect, url_for, flash, jsonify
from flask_login import login_required, current_user
from . import bp
from sqlalchemy.orm import joinedload
from sqlalchemy import func
from database import (SessionLocal, User, Company, UserCompanyPermissions,
MessageGroup, MessageGroupMember, GroupMessage,
MessageAttachment, UserNotification, UserBlock)
from extensions import limiter
from utils.helpers import sanitize_html
from utils.decorators import member_required
from email_service import send_email, build_message_notification_email
from message_upload_service import MessageUploadService
def _get_active_norda_members(db, exclude_user_id):
"""Pobierz aktywnych członków Nordy do wyboru."""
users_with_companies = db.query(
User,
Company.name.label('company_name')
).outerjoin(
UserCompanyPermissions,
UserCompanyPermissions.user_id == User.id
).outerjoin(
Company,
(Company.id == UserCompanyPermissions.company_id) & (Company.status == 'active')
).filter(
User.is_active == True,
User.is_verified == True,
User.id != exclude_user_id
).order_by(User.name).all()
seen_ids = set()
users = []
for user, company_name in users_with_companies:
if user.id not in seen_ids:
seen_ids.add(user.id)
user._company_name = company_name
users.append(user)
return users
def _check_group_access(db, group_id, user_id):
"""Sprawdź czy użytkownik jest członkiem grupy. Zwraca (group, membership) lub (None, None)."""
group = db.query(MessageGroup).filter(MessageGroup.id == group_id).first()
if not group:
return None, None
membership = db.query(MessageGroupMember).filter(
MessageGroupMember.group_id == group_id,
MessageGroupMember.user_id == user_id
).first()
if not membership:
return None, None
return group, membership
def _check_block_for_group(db, group_id, target_user_id):
"""Sprawdź czy dodanie użytkownika do grupy jest zablokowane przez UserBlock."""
member_ids = [m.user_id for m in db.query(MessageGroupMember.user_id).filter(
MessageGroupMember.group_id == group_id
).all()]
block = db.query(UserBlock).filter(
((UserBlock.user_id == target_user_id) & (UserBlock.blocked_user_id.in_(member_ids))) |
((UserBlock.user_id.in_(member_ids)) & (UserBlock.blocked_user_id == target_user_id))
).first()
return block is not None
@bp.route('/wiadomosci/nowa-grupa')
@login_required
@member_required
def group_compose():
"""Formularz tworzenia grupy"""
db = SessionLocal()
try:
users = _get_active_norda_members(db, current_user.id)
return render_template('messages/group_compose.html', users=users)
finally:
db.close()
@bp.route('/wiadomosci/grupa/utworz', methods=['POST'])
@login_required
@member_required
def group_create():
"""Utwórz grupę i wyślij pierwszą wiadomość"""
name = request.form.get('name', '').strip()
content = sanitize_html(request.form.get('content', '').strip())
member_ids = request.form.getlist('members', type=int)
if not content:
flash('Treść pierwszej wiadomości jest wymagana.', 'error')
return redirect(url_for('.group_compose'))
if not member_ids:
flash('Wybierz co najmniej jedną osobę.', 'error')
return redirect(url_for('.group_compose'))
db = SessionLocal()
try:
# Verify all members exist and are active Norda members
valid_members = db.query(User).filter(
User.id.in_(member_ids),
User.is_active == True,
User.is_verified == True
).all()
if not valid_members:
flash('Nie znaleziono wybranych użytkowników.', 'error')
return redirect(url_for('.group_compose'))
# Check blocks
for member in valid_members:
block = db.query(UserBlock).filter(
((UserBlock.user_id == current_user.id) & (UserBlock.blocked_user_id == member.id)) |
((UserBlock.user_id == member.id) & (UserBlock.blocked_user_id == current_user.id))
).first()
if block:
flash(f'Nie można dodać użytkownika {member.name or member.email} do grupy.', 'error')
return redirect(url_for('.group_compose'))
# Create group
group = MessageGroup(
name=name if name else None,
is_named=bool(name),
owner_id=current_user.id
)
db.add(group)
db.flush()
# Add owner as member
owner_member = MessageGroupMember(
group_id=group.id,
user_id=current_user.id,
role='owner',
last_read_at=datetime.now()
)
db.add(owner_member)
# Add selected members
for member in valid_members:
if member.id != current_user.id:
gm = MessageGroupMember(
group_id=group.id,
user_id=member.id,
role='member',
added_by_id=current_user.id
)
db.add(gm)
# Create first message
msg = GroupMessage(
group_id=group.id,
sender_id=current_user.id,
content=content
)
db.add(msg)
db.flush()
# Process attachments
if request.files.getlist('attachments'):
upload_service = MessageUploadService(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
files = [f for f in request.files.getlist('attachments') if f and f.filename]
if files:
valid_files, errors = upload_service.validate_files(files)
if errors:
db.rollback()
for err in errors:
flash(err, 'error')
return redirect(url_for('.group_compose'))
for f, filename, ext, size, file_content in valid_files:
stored_filename, _ = upload_service.save_file(file_content, ext)
att = MessageAttachment(
group_message_id=msg.id,
filename=filename,
stored_filename=stored_filename,
file_size=size,
mime_type=upload_service.get_mime_type(ext)
)
db.add(att)
group.updated_at = datetime.now()
# Notifications for all members
sender_name = current_user.name or current_user.email.split('@')[0]
group_display = name if name else 'Nowa grupa'
for member in valid_members:
if member.id != current_user.id:
notif = UserNotification(
user_id=member.id,
title=f'Dodano do grupy: {group_display}',
message=f'{sender_name} utworzył(a) grupę i wysłał(a) pierwszą wiadomość',
notification_type='message',
related_type='group',
related_id=group.id,
action_url=url_for('.group_view', group_id=group.id)
)
db.add(notif)
# Email notification
if member.notify_email_messages != False and member.email:
try:
message_url = url_for('.group_view', group_id=group.id, _external=True)
settings_url = url_for('auth.konto_prywatnosc', _external=True)
preview = (content[:200] + '...') if len(content) > 200 else content
email_html, email_text = build_message_notification_email(
sender_name=sender_name,
subject=f'Grupa: {group_display}',
content_preview=preview,
message_url=message_url,
settings_url=settings_url
)
send_email(
to=[member.email],
subject=f'Nowa grupa: {group_display} — Norda Biznes',
body_text=email_text,
body_html=email_html,
email_type='message_notification',
user_id=member.id,
recipient_name=member.name
)
except Exception:
import logging
logging.getLogger(__name__).warning(f"Failed to send group email to {member.email}")
db.commit()
flash('Grupa utworzona!', 'success')
return redirect(url_for('.group_view', group_id=group.id))
finally:
db.close()
@bp.route('/wiadomosci/grupa/<int:group_id>')
@login_required
@member_required
def group_view(group_id):
"""Widok czatu grupowego"""
db = SessionLocal()
try:
group, membership = _check_group_access(db, group_id, current_user.id)
if not group:
flash('Grupa nie istnieje lub nie masz dostępu.', 'error')
return redirect(url_for('.messages_inbox'))
# Load messages with senders and attachments
messages = db.query(GroupMessage).options(
joinedload(GroupMessage.sender),
joinedload(GroupMessage.attachments)
).filter(
GroupMessage.group_id == group_id
).order_by(GroupMessage.created_at.asc()).all()
# Load members with user details
members = db.query(MessageGroupMember).options(
joinedload(MessageGroupMember.user)
).filter(
MessageGroupMember.group_id == group_id
).order_by(MessageGroupMember.role, MessageGroupMember.joined_at).all()
# Build read receipts: for each message, find who has read up to that point
# Show avatar at the LAST message they've read (like Teams/WhatsApp)
read_receipts = {} # message_id -> list of (user, avatar_path)
if messages:
other_members = [m for m in members if m.user_id != current_user.id]
for m in other_members:
if not m.last_read_at:
continue
# Find the last message this member has read
last_read_msg = None
for msg in messages:
if msg.created_at <= m.last_read_at:
last_read_msg = msg
else:
break
if last_read_msg:
if last_read_msg.id not in read_receipts:
read_receipts[last_read_msg.id] = []
read_receipts[last_read_msg.id].append(m.user)
# Mark as read
membership.last_read_at = datetime.now()
db.commit()
return render_template('messages/group_view.html',
group=group,
messages=messages,
members=members,
membership=membership,
read_receipts=read_receipts
)
finally:
db.close()
@bp.route('/wiadomosci/grupa/<int:group_id>/wyslij', methods=['POST'])
@login_required
@member_required
def group_send(group_id):
"""Wyślij wiadomość do grupy"""
content = sanitize_html(request.form.get('content', '').strip())
if not content:
flash('Treść jest wymagana.', 'error')
return redirect(url_for('.group_view', group_id=group_id))
db = SessionLocal()
try:
group, membership = _check_group_access(db, group_id, current_user.id)
if not group:
flash('Grupa nie istnieje lub nie masz dostępu.', 'error')
return redirect(url_for('.messages_inbox'))
msg = GroupMessage(
group_id=group_id,
sender_id=current_user.id,
content=content
)
db.add(msg)
db.flush()
# Process attachments
if request.files.getlist('attachments'):
upload_service = MessageUploadService(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
files = [f for f in request.files.getlist('attachments') if f and f.filename]
if files:
valid_files, errors = upload_service.validate_files(files)
if errors:
db.rollback()
for err in errors:
flash(err, 'error')
return redirect(url_for('.group_view', group_id=group_id))
for f, filename, ext, size, file_content in valid_files:
stored_filename, _ = upload_service.save_file(file_content, ext)
att = MessageAttachment(
group_message_id=msg.id,
filename=filename,
stored_filename=stored_filename,
file_size=size,
mime_type=upload_service.get_mime_type(ext)
)
db.add(att)
group.updated_at = datetime.now()
membership.last_read_at = datetime.now()
# Notify all other members
sender_name = current_user.name or current_user.email.split('@')[0]
group_display = group.name or group.display_name
other_members = db.query(MessageGroupMember).options(
joinedload(MessageGroupMember.user)
).filter(
MessageGroupMember.group_id == group_id,
MessageGroupMember.user_id != current_user.id
).all()
for m in other_members:
notif = UserNotification(
user_id=m.user_id,
title=f'{group_display} — nowa wiadomość',
message=f'{sender_name}: {content[:80]}...' if len(content) > 80 else f'{sender_name}: {content}',
notification_type='message',
related_type='group',
related_id=group.id,
action_url=url_for('.group_view', group_id=group_id)
)
db.add(notif)
if m.user and m.user.notify_email_messages != False and m.user.email:
try:
message_url = url_for('.group_view', group_id=group_id, _external=True)
settings_url = url_for('auth.konto_prywatnosc', _external=True)
preview = (content[:200] + '...') if len(content) > 200 else content
email_html, email_text = build_message_notification_email(
sender_name=sender_name,
subject=f'Grupa: {group_display}',
content_preview=preview,
message_url=message_url,
settings_url=settings_url
)
send_email(
to=[m.user.email],
subject=f'{group_display} — nowa wiadomość od {sender_name}',
body_text=email_text,
body_html=email_html,
email_type='message_notification',
user_id=m.user_id,
recipient_name=m.user.name
)
except Exception:
import logging
logging.getLogger(__name__).warning(f"Failed to send group email to {m.user.email}")
db.commit()
return redirect(url_for('.group_view', group_id=group_id))
finally:
db.close()
@bp.route('/api/grupa/<int:group_id>/nowe', methods=['GET'])
@limiter.exempt
@login_required
@member_required
def group_poll_messages(group_id):
"""API: Pobierz nowe wiadomości po danym ID (polling)"""
after_id = request.args.get('after', 0, type=int)
db = SessionLocal()
try:
group, membership = _check_group_access(db, group_id, current_user.id)
if not group:
return jsonify({'messages': []})
new_msgs = db.query(GroupMessage).options(
joinedload(GroupMessage.sender)
).filter(
GroupMessage.group_id == group_id,
GroupMessage.id > after_id
).order_by(GroupMessage.created_at.asc()).all()
# Update read timestamp
if new_msgs:
membership.last_read_at = datetime.now()
db.commit()
# Build read receipts for new messages
members = db.query(MessageGroupMember).options(
joinedload(MessageGroupMember.user)
).filter(
MessageGroupMember.group_id == group_id,
MessageGroupMember.user_id != current_user.id
).all()
read_receipts = {}
for m in members:
if not m.last_read_at:
continue
for msg in reversed(new_msgs):
if msg.created_at <= m.last_read_at:
if msg.id not in read_receipts:
read_receipts[msg.id] = []
read_receipts[msg.id].append({
'name': m.user.name or m.user.email.split('@')[0],
'avatar_url': ('/static/' + m.user.avatar_path) if m.user.avatar_path else None,
'initial': (m.user.name or m.user.email)[0].upper()
})
break
result = []
for msg in new_msgs:
sender = msg.sender
result.append({
'id': msg.id,
'sender_id': msg.sender_id,
'sender_name': sender.name or sender.email.split('@')[0] if sender else 'Ktoś',
'sender_avatar': ('/static/' + sender.avatar_path) if sender and sender.avatar_path else None,
'sender_initial': (sender.name or sender.email)[0].upper() if sender else '?',
'is_me': msg.sender_id == current_user.id,
'content': msg.content,
'time': msg.created_at.strftime('%d.%m.%Y %H:%M'),
'read_by': read_receipts.get(msg.id, [])
})
return jsonify({'messages': result})
finally:
db.close()
# ============================================================
# GROUP MANAGEMENT ROUTES
# ============================================================
@bp.route('/wiadomosci/grupa/<int:group_id>/zarzadzaj')
@login_required
@member_required
def group_manage(group_id):
"""Panel zarządzania członkami grupy"""
db = SessionLocal()
try:
group, membership = _check_group_access(db, group_id, current_user.id)
if not group or not membership.can_manage_members:
flash('Brak uprawnień do zarządzania grupą.', 'error')
return redirect(url_for('.group_view', group_id=group_id))
members = db.query(MessageGroupMember).options(
joinedload(MessageGroupMember.user)
).filter(
MessageGroupMember.group_id == group_id
).order_by(MessageGroupMember.role, MessageGroupMember.joined_at).all()
available_users = _get_active_norda_members(db, current_user.id)
member_ids = {m.user_id for m in members}
available_users = [u for u in available_users if u.id not in member_ids]
return render_template('messages/group_manage.html',
group=group,
members=members,
membership=membership,
available_users=available_users
)
finally:
db.close()
@bp.route('/wiadomosci/grupa/<int:group_id>/dodaj-czlonka', methods=['POST'])
@login_required
@member_required
def group_add_member(group_id):
"""Dodaj osobę do grupy"""
user_id = request.form.get('user_id', type=int)
db = SessionLocal()
try:
group, membership = _check_group_access(db, group_id, current_user.id)
if not group or not membership.can_manage_members:
flash('Brak uprawnień.', 'error')
return redirect(url_for('.group_view', group_id=group_id))
if not user_id:
flash('Nie wybrano użytkownika.', 'error')
return redirect(url_for('.group_manage', group_id=group_id))
# Check user exists and is active
user = db.query(User).filter(User.id == user_id, User.is_active == True).first()
if not user:
flash('Użytkownik nie istnieje.', 'error')
return redirect(url_for('.group_manage', group_id=group_id))
# Check not already member
existing = db.query(MessageGroupMember).filter(
MessageGroupMember.group_id == group_id,
MessageGroupMember.user_id == user_id
).first()
if existing:
flash('Użytkownik jest już członkiem grupy.', 'error')
return redirect(url_for('.group_manage', group_id=group_id))
# Check blocks
if _check_block_for_group(db, group_id, user_id):
flash('Nie można dodać tego użytkownika do grupy.', 'error')
return redirect(url_for('.group_manage', group_id=group_id))
new_member = MessageGroupMember(
group_id=group_id,
user_id=user_id,
role='member',
added_by_id=current_user.id
)
db.add(new_member)
# Notification
adder_name = current_user.name or current_user.email.split('@')[0]
group_display = group.name or group.display_name
notif = UserNotification(
user_id=user_id,
title=f'Dodano do grupy: {group_display}',
message=f'{adder_name} dodał(a) Cię do grupy',
notification_type='message',
related_type='group',
related_id=group.id,
action_url=url_for('.group_view', group_id=group_id)
)
db.add(notif)
db.commit()
flash(f'Dodano {user.name or user.email} do grupy.', 'success')
return redirect(url_for('.group_manage', group_id=group_id))
finally:
db.close()
@bp.route('/wiadomosci/grupa/<int:group_id>/usun-czlonka', methods=['POST'])
@login_required
@member_required
def group_remove_member(group_id):
"""Usuń osobę z grupy"""
user_id = request.form.get('user_id', type=int)
db = SessionLocal()
try:
group, membership = _check_group_access(db, group_id, current_user.id)
if not group or not membership.can_manage_members:
flash('Brak uprawnień.', 'error')
return redirect(url_for('.group_view', group_id=group_id))
if user_id == group.owner_id:
flash('Nie można usunąć właściciela grupy.', 'error')
return redirect(url_for('.group_manage', group_id=group_id))
target = db.query(MessageGroupMember).filter(
MessageGroupMember.group_id == group_id,
MessageGroupMember.user_id == user_id
).first()
if not target:
flash('Użytkownik nie jest członkiem grupy.', 'error')
return redirect(url_for('.group_manage', group_id=group_id))
# Moderator cannot remove another moderator (only owner can)
if target.role == 'moderator' and not membership.is_owner:
flash('Tylko właściciel może usunąć moderatora.', 'error')
return redirect(url_for('.group_manage', group_id=group_id))
user = db.query(User).filter(User.id == user_id).first()
db.delete(target)
db.commit()
flash(f'Usunięto {user.name or user.email if user else "użytkownika"} z grupy.', 'success')
return redirect(url_for('.group_manage', group_id=group_id))
finally:
db.close()
@bp.route('/wiadomosci/grupa/<int:group_id>/zmien-role', methods=['POST'])
@login_required
@member_required
def group_change_role(group_id):
"""Zmień rolę członka (tylko owner)"""
user_id = request.form.get('user_id', type=int)
new_role = request.form.get('role')
if new_role not in ('moderator', 'member'):
flash('Nieprawidłowa rola.', 'error')
return redirect(url_for('.group_manage', group_id=group_id))
db = SessionLocal()
try:
group, membership = _check_group_access(db, group_id, current_user.id)
if not group or not membership.is_owner:
flash('Tylko właściciel może zmieniać role.', 'error')
return redirect(url_for('.group_view', group_id=group_id))
target = db.query(MessageGroupMember).filter(
MessageGroupMember.group_id == group_id,
MessageGroupMember.user_id == user_id
).first()
if not target or target.is_owner:
flash('Nie można zmienić roli tego użytkownika.', 'error')
return redirect(url_for('.group_manage', group_id=group_id))
target.role = new_role
db.commit()
role_label = 'moderatora' if new_role == 'moderator' else 'uczestnika'
user = db.query(User).filter(User.id == user_id).first()
flash(f'{user.name or user.email if user else "Użytkownik"} — rola zmieniona na {role_label}.', 'success')
return redirect(url_for('.group_manage', group_id=group_id))
finally:
db.close()
@bp.route('/wiadomosci/grupa/<int:group_id>/edytuj', methods=['POST'])
@login_required
@member_required
def group_edit(group_id):
"""Edytuj nazwę i opis grupy (tylko owner)"""
name = request.form.get('name', '').strip()
description = request.form.get('description', '').strip()
db = SessionLocal()
try:
group, membership = _check_group_access(db, group_id, current_user.id)
if not group or not membership.is_owner:
flash('Tylko właściciel może edytować grupę.', 'error')
return redirect(url_for('.group_view', group_id=group_id))
group.name = name if name else None
group.is_named = bool(name)
group.description = description if description else None
db.commit()
flash('Grupa zaktualizowana.', 'success')
return redirect(url_for('.group_manage', group_id=group_id))
finally:
db.close()
@bp.route('/wiadomosci/grupa/<int:group_id>/wiadomosc/<int:message_id>/usun', methods=['POST'])
@login_required
@member_required
def group_delete_message(group_id, message_id):
"""Usuń wiadomość (tylko autor lub owner grupy)"""
db = SessionLocal()
try:
group, membership = _check_group_access(db, group_id, current_user.id)
if not group:
flash('Grupa nie istnieje lub nie masz dostępu.', 'error')
return redirect(url_for('.messages_inbox'))
msg = db.query(GroupMessage).filter(
GroupMessage.id == message_id,
GroupMessage.group_id == group_id
).first()
if not msg:
flash('Wiadomość nie istnieje.', 'error')
return redirect(url_for('.group_view', group_id=group_id))
# Only message author or group owner can delete
if msg.sender_id != current_user.id and not membership.is_owner:
flash('Nie masz uprawnień do usunięcia tej wiadomości.', 'error')
return redirect(url_for('.group_view', group_id=group_id))
# Delete attachments from disk
for att in msg.attachments:
try:
filepath = os.path.join('static', 'uploads', 'messages',
att.created_at.strftime('%Y'), att.created_at.strftime('%m'), att.stored_filename)
if os.path.exists(filepath):
os.remove(filepath)
except Exception:
pass
db.delete(msg)
db.commit()
flash('Wiadomość usunięta.', 'success')
return redirect(url_for('.group_view', group_id=group_id))
finally:
db.close()
@bp.route('/wiadomosci/grupa/<int:group_id>/usun', methods=['POST'])
@login_required
@member_required
def group_delete(group_id):
"""Usuń grupę (tylko owner)"""
db = SessionLocal()
try:
group, membership = _check_group_access(db, group_id, current_user.id)
if not group or not membership.is_owner:
flash('Tylko właściciel może usunąć grupę.', 'error')
return redirect(url_for('.messages_inbox'))
group_name = group.name or group.display_name
db.delete(group)
db.commit()
flash(f'Grupa "{group_name}" została usunięta.', 'success')
return redirect(url_for('.messages_inbox'))
finally:
db.close()