feat: Add read tracking for Forum topics/replies and B2B classifieds

- Add ForumTopicRead, ForumReplyRead, ClassifiedRead models
- Add SQL migration for new tables
- Record reads when user views forum topic (topic + all visible replies)
- Record reads when user views B2B classified
- Display "Seen by" avatars in forum topic and B2B detail pages

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Maciej Pienczyn 2026-01-31 20:50:27 +01:00
parent 53c7535d92
commit e87ba8ee09
6 changed files with 292 additions and 7 deletions

View File

@ -10,7 +10,8 @@ from flask import render_template, request, redirect, url_for, flash, jsonify
from flask_login import login_required, current_user
from . import bp
from database import SessionLocal, Classified
from database import SessionLocal, Classified, ClassifiedRead
from sqlalchemy import desc
from utils.helpers import sanitize_input
@ -121,9 +122,32 @@ def view(classified_id):
# Zwiększ licznik wyświetleń (handle NULL)
classified.views_count = (classified.views_count or 0) + 1
# Zapisz odczyt przez zalogowanego użytkownika
existing_read = db.query(ClassifiedRead).filter(
ClassifiedRead.classified_id == classified.id,
ClassifiedRead.user_id == current_user.id
).first()
if not existing_read:
new_read = ClassifiedRead(
classified_id=classified.id,
user_id=current_user.id
)
db.add(new_read)
db.commit()
return render_template('classifieds/view.html', classified=classified)
# Pobierz listę czytelników
readers = db.query(ClassifiedRead).filter(
ClassifiedRead.classified_id == classified.id
).order_by(desc(ClassifiedRead.read_at)).all()
readers_count = len(readers)
return render_template('classifieds/view.html',
classified=classified,
readers=readers,
readers_count=readers_count)
finally:
db.close()

View File

@ -14,7 +14,8 @@ from flask_login import login_required, current_user
from . import bp
from database import (
SessionLocal, ForumTopic, ForumReply, ForumAttachment,
ForumTopicSubscription, ForumReport, ForumEditHistory, User
ForumTopicSubscription, ForumReport, ForumEditHistory, User,
ForumTopicRead, ForumReplyRead
)
from utils.helpers import sanitize_input
from utils.notifications import (
@ -227,21 +228,46 @@ def forum_topic(topic_id):
# Increment view count (handle NULL)
topic.views_count = (topic.views_count or 0) + 1
# Record topic read by current user
existing_topic_read = db.query(ForumTopicRead).filter(
ForumTopicRead.topic_id == topic.id,
ForumTopicRead.user_id == current_user.id
).first()
if not existing_topic_read:
db.add(ForumTopicRead(topic_id=topic.id, user_id=current_user.id))
# Filter soft-deleted replies for non-admins
visible_replies = [r for r in topic.replies
if not r.is_deleted or current_user.is_admin]
# Record read for all visible replies
for reply in visible_replies:
existing_reply_read = db.query(ForumReplyRead).filter(
ForumReplyRead.reply_id == reply.id,
ForumReplyRead.user_id == current_user.id
).first()
if not existing_reply_read:
db.add(ForumReplyRead(reply_id=reply.id, user_id=current_user.id))
db.commit()
# Get topic readers
from sqlalchemy import desc
topic_readers = db.query(ForumTopicRead).filter(
ForumTopicRead.topic_id == topic.id
).order_by(desc(ForumTopicRead.read_at)).all()
# Check subscription status
is_subscribed = db.query(ForumTopicSubscription).filter(
ForumTopicSubscription.topic_id == topic_id,
ForumTopicSubscription.user_id == current_user.id
).first() is not None
# Filter soft-deleted replies for non-admins
visible_replies = [r for r in topic.replies
if not r.is_deleted or current_user.is_admin]
return render_template('forum/topic.html',
topic=topic,
visible_replies=visible_replies,
topic_readers=topic_readers,
is_subscribed=is_subscribed,
category_labels=ForumTopic.CATEGORY_LABELS,
status_labels=ForumTopic.STATUS_LABELS,

View File

@ -1165,6 +1165,56 @@ class ForumEditHistory(Base):
reply = relationship('ForumReply')
class ForumTopicRead(Base):
"""
Śledzenie odczytów wątków forum (seen by).
Zapisuje kto i kiedy przeczytał dany wątek.
"""
__tablename__ = 'forum_topic_reads'
id = Column(Integer, primary_key=True)
topic_id = Column(Integer, ForeignKey('forum_topics.id', ondelete='CASCADE'), nullable=False)
user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'), nullable=False)
read_at = Column(DateTime, default=datetime.now)
# Relationships
topic = relationship('ForumTopic', backref='readers')
user = relationship('User')
# Unique constraint
__table_args__ = (
UniqueConstraint('topic_id', 'user_id', name='uq_forum_topic_user_read'),
)
def __repr__(self):
return f"<ForumTopicRead topic={self.topic_id} user={self.user_id}>"
class ForumReplyRead(Base):
"""
Śledzenie odczytów odpowiedzi na forum (seen by).
Zapisuje kto i kiedy przeczytał daną odpowiedź.
"""
__tablename__ = 'forum_reply_reads'
id = Column(Integer, primary_key=True)
reply_id = Column(Integer, ForeignKey('forum_replies.id', ondelete='CASCADE'), nullable=False)
user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'), nullable=False)
read_at = Column(DateTime, default=datetime.now)
# Relationships
reply = relationship('ForumReply', backref='readers')
user = relationship('User')
# Unique constraint
__table_args__ = (
UniqueConstraint('reply_id', 'user_id', name='uq_forum_reply_user_read'),
)
def __repr__(self):
return f"<ForumReplyRead reply={self.reply_id} user={self.user_id}>"
class AIAPICostLog(Base):
"""API cost tracking"""
__tablename__ = 'ai_api_costs'
@ -1336,6 +1386,31 @@ class Classified(Base):
return False
class ClassifiedRead(Base):
"""
Śledzenie odczytów ogłoszeń B2B (seen by).
Zapisuje kto i kiedy przeczytał dane ogłoszenie.
"""
__tablename__ = 'classified_reads'
id = Column(Integer, primary_key=True)
classified_id = Column(Integer, ForeignKey('classifieds.id', ondelete='CASCADE'), nullable=False)
user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'), nullable=False)
read_at = Column(DateTime, default=datetime.now)
# Relationships
classified = relationship('Classified', backref='readers')
user = relationship('User')
# Unique constraint
__table_args__ = (
UniqueConstraint('classified_id', 'user_id', name='uq_classified_user_read'),
)
def __repr__(self):
return f"<ClassifiedRead classified={self.classified_id} user={self.user_id}>"
class CompanyContact(Base):
"""Multiple contacts (phones, emails) per company with source tracking"""
__tablename__ = 'company_contacts'

View File

@ -0,0 +1,47 @@
-- Migration: Add read tracking tables for Forum and B2B
-- Date: 2026-01-31
-- Description: Adds tables to track who has read forum topics, replies, and B2B classifieds
-- Forum Topic Reads
CREATE TABLE IF NOT EXISTS forum_topic_reads (
id SERIAL PRIMARY KEY,
topic_id INTEGER NOT NULL REFERENCES forum_topics(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
read_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT uq_forum_topic_user_read UNIQUE (topic_id, user_id)
);
CREATE INDEX IF NOT EXISTS idx_forum_topic_reads_topic ON forum_topic_reads(topic_id);
CREATE INDEX IF NOT EXISTS idx_forum_topic_reads_user ON forum_topic_reads(user_id);
-- Forum Reply Reads
CREATE TABLE IF NOT EXISTS forum_reply_reads (
id SERIAL PRIMARY KEY,
reply_id INTEGER NOT NULL REFERENCES forum_replies(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
read_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT uq_forum_reply_user_read UNIQUE (reply_id, user_id)
);
CREATE INDEX IF NOT EXISTS idx_forum_reply_reads_reply ON forum_reply_reads(reply_id);
CREATE INDEX IF NOT EXISTS idx_forum_reply_reads_user ON forum_reply_reads(user_id);
-- Classified (B2B) Reads
CREATE TABLE IF NOT EXISTS classified_reads (
id SERIAL PRIMARY KEY,
classified_id INTEGER NOT NULL REFERENCES classifieds(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
read_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT uq_classified_user_read UNIQUE (classified_id, user_id)
);
CREATE INDEX IF NOT EXISTS idx_classified_reads_classified ON classified_reads(classified_id);
CREATE INDEX IF NOT EXISTS idx_classified_reads_user ON classified_reads(user_id);
-- Grant permissions
GRANT ALL ON TABLE forum_topic_reads TO nordabiz_app;
GRANT ALL ON TABLE forum_reply_reads TO nordabiz_app;
GRANT ALL ON TABLE classified_reads TO nordabiz_app;
GRANT USAGE, SELECT ON SEQUENCE forum_topic_reads_id_seq TO nordabiz_app;
GRANT USAGE, SELECT ON SEQUENCE forum_reply_reads_id_seq TO nordabiz_app;
GRANT USAGE, SELECT ON SEQUENCE classified_reads_id_seq TO nordabiz_app;

View File

@ -199,6 +199,42 @@
margin-top: var(--spacing-lg);
}
.seen-by-section {
margin-top: var(--spacing-lg);
padding-top: var(--spacing-lg);
border-top: 1px solid var(--border);
}
.seen-by-label {
font-size: var(--font-size-sm);
color: var(--text-secondary);
margin-bottom: var(--spacing-sm);
}
.seen-by-avatars {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.reader-avatar {
width: 32px;
height: 32px;
border-radius: 50%;
color: white;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 600;
cursor: default;
}
.reader-avatar.more {
background: var(--text-secondary);
font-size: 10px;
}
.close-btn {
margin-left: auto;
}
@ -366,6 +402,26 @@
<span>Wygasa: {{ classified.expires_at.strftime('%d.%m.%Y') }}</span>
{% endif %}
</div>
{% if readers %}
<div class="seen-by-section">
<div class="seen-by-label">Widziane przez {{ readers_count }} {{ 'osobę' if readers_count == 1 else 'osoby' if readers_count < 5 else 'osób' }}:</div>
<div class="seen-by-avatars">
{% for read in readers[:20] %}
<div class="reader-avatar"
title="{{ read.user.name or read.user.email.split('@')[0] }}{% if current_user.is_authenticated and read.user.id == current_user.id %} (Ty){% endif %}"
style="background: hsl({{ (read.user.id * 137) % 360 }}, 65%, 50%);">
{{ (read.user.name or read.user.email)[0]|upper }}
</div>
{% endfor %}
{% if readers_count > 20 %}
<div class="reader-avatar more" title="i {{ readers_count - 20 }} innych">
+{{ readers_count - 20 }}
</div>
{% endif %}
</div>
</div>
{% endif %}
</div>
</div>

View File

@ -780,6 +780,42 @@
margin-top: var(--spacing-md);
}
.seen-by-section {
margin-top: var(--spacing-lg);
padding-top: var(--spacing-md);
border-top: 1px solid var(--border);
}
.seen-by-label {
font-size: var(--font-size-sm);
color: var(--text-secondary);
margin-bottom: var(--spacing-sm);
}
.seen-by-avatars {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.reader-avatar {
width: 28px;
height: 28px;
border-radius: 50%;
color: white;
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
font-weight: 600;
cursor: default;
}
.reader-avatar.more {
background: var(--text-secondary);
font-size: 9px;
}
.reaction-btn {
display: inline-flex;
align-items: center;
@ -1030,6 +1066,27 @@
{% endfor %}
</div>
<!-- Seen by section for topic -->
{% if topic_readers %}
<div class="seen-by-section">
<div class="seen-by-label">Widziane przez {{ topic_readers|length }} {{ 'osobę' if topic_readers|length == 1 else 'osoby' if topic_readers|length < 5 else 'osób' }}:</div>
<div class="seen-by-avatars">
{% for read in topic_readers[:20] %}
<div class="reader-avatar"
title="{{ read.user.name or read.user.email.split('@')[0] }}{% if current_user.is_authenticated and read.user.id == current_user.id %} (Ty){% endif %}"
style="background: hsl({{ (read.user.id * 137) % 360 }}, 65%, 50%);">
{{ (read.user.name or read.user.email)[0]|upper }}
</div>
{% endfor %}
{% if topic_readers|length > 20 %}
<div class="reader-avatar more" title="i {{ topic_readers|length - 20 }} innych">
+{{ topic_readers|length - 20 }}
</div>
{% endif %}
</div>
</div>
{% endif %}
<!-- User actions for topic -->
{% if not topic.is_locked %}
<div class="user-actions">