nordabiz/blueprints/messages/group_routes.py
Maciej Pienczyn 3a18ebcb28
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): group messaging and search
- Search bar in inbox/sent: filters by subject, content, sender/recipient
- Group chats: create named or ad-hoc groups with Norda members
- Group roles: owner, moderator, member with permission hierarchy
- Group management: add/remove members, change roles
- Photo avatars in message list (fallback to initials)
- Unread count API extended to include group messages
- Migration 088: message_group, message_group_member, group_message tables

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

595 lines
22 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()
# 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
)
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()
# ============================================================
# 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()