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:
parent
53c7535d92
commit
e87ba8ee09
@ -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()
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
75
database.py
75
database.py
@ -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'
|
||||
|
||||
47
database/migrations/012_add_read_tracking_tables.sql
Normal file
47
database/migrations/012_add_read_tracking_tables.sql
Normal 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;
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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">
|
||||
|
||||
Loading…
Reference in New Issue
Block a user