feat(announcements): Śledzenie odczytów ogłoszeń (seen by)

- Nowa tabela announcement_reads do śledzenia kto przeczytał
- Avatary użytkowników z inicjałami (tooltip z nazwiskiem)
- Statystyki: liczba i procent użytkowników którzy przeczytali
- Progress bar wizualizujący zasięg ogłoszenia
- Automatyczny zapis odczytu przy otwarciu ogłoszenia

Autor: Maciej Pienczyn
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Maciej Pienczyn 2026-01-27 08:19:39 +01:00
parent d443fe312e
commit a172f7af49
4 changed files with 219 additions and 3 deletions

38
app.py
View File

@ -13744,8 +13744,8 @@ def announcements_list():
@limiter.limit("60 per minute")
def announcement_detail(slug):
"""Szczegóły ogłoszenia dla zalogowanych członków"""
from database import Announcement
from sqlalchemy import or_, desc
from database import Announcement, AnnouncementRead, User
from sqlalchemy import or_, desc, func
db = SessionLocal()
try:
@ -13764,8 +13764,36 @@ def announcement_detail(slug):
# Increment views counter
announcement.views_count = (announcement.views_count or 0) + 1
# Record read by current user (if not already recorded)
existing_read = db.query(AnnouncementRead).filter(
AnnouncementRead.announcement_id == announcement.id,
AnnouncementRead.user_id == current_user.id
).first()
if not existing_read:
new_read = AnnouncementRead(
announcement_id=announcement.id,
user_id=current_user.id
)
db.add(new_read)
db.commit()
# Get readers (users who read this announcement)
readers = db.query(AnnouncementRead).filter(
AnnouncementRead.announcement_id == announcement.id
).order_by(desc(AnnouncementRead.read_at)).all()
# Get total registered users count for percentage calculation
total_users = db.query(func.count(User.id)).filter(
User.is_active == True,
User.is_verified == True
).scalar() or 1
readers_count = len(readers)
read_percentage = round((readers_count / total_users) * 100, 1) if total_users > 0 else 0
# Get other recent announcements for sidebar
other_announcements = db.query(Announcement).filter(
Announcement.status == 'published',
@ -13779,7 +13807,11 @@ def announcement_detail(slug):
return render_template('announcements/detail.html',
announcement=announcement,
other_announcements=other_announcements,
category_labels=Announcement.CATEGORY_LABELS)
category_labels=Announcement.CATEGORY_LABELS,
readers=readers,
readers_count=readers_count,
total_users=total_users,
read_percentage=read_percentage)
finally:
db.close()

View File

@ -3046,6 +3046,7 @@ class Announcement(Base):
# Relationships
author = relationship('User', foreign_keys=[created_by])
readers = relationship('AnnouncementRead', back_populates='announcement', cascade='all, delete-orphan')
# Constants
CATEGORIES = ['general', 'event', 'opportunity', 'member_news', 'partnership']
@ -3086,6 +3087,31 @@ class Announcement(Base):
return f"<Announcement {self.id} '{self.title[:50]}' ({self.status})>"
class AnnouncementRead(Base):
"""
Śledzenie odczytów ogłoszeń (seen by).
Zapisuje kto i kiedy przeczytał dane ogłoszenie.
"""
__tablename__ = 'announcement_reads'
id = Column(Integer, primary_key=True)
announcement_id = Column(Integer, ForeignKey('announcements.id', ondelete='CASCADE'), nullable=False)
user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'), nullable=False)
read_at = Column(DateTime, default=datetime.now)
# Relationships
announcement = relationship('Announcement', back_populates='readers')
user = relationship('User')
# Unique constraint
__table_args__ = (
UniqueConstraint('announcement_id', 'user_id', name='uq_announcement_user_read'),
)
def __repr__(self):
return f"<AnnouncementRead announcement={self.announcement_id} user={self.user_id}>"
# ============================================================
# ZOPK MILESTONES (Timeline)
# ============================================================

View File

@ -0,0 +1,30 @@
-- ============================================================
-- Migration: 019_announcement_reads.sql
-- Description: Śledzenie odczytów ogłoszeń (seen by)
-- Author: Claude
-- Date: 2026-01-27
-- ============================================================
-- Tabela przechowująca informacje o odczytach ogłoszeń
CREATE TABLE IF NOT EXISTS announcement_reads (
id SERIAL PRIMARY KEY,
announcement_id INTEGER NOT NULL REFERENCES announcements(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
read_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
-- Unikalna kombinacja - użytkownik może przeczytać ogłoszenie tylko raz
UNIQUE(announcement_id, user_id)
);
-- Indeksy dla szybkiego wyszukiwania
CREATE INDEX IF NOT EXISTS idx_announcement_reads_announcement ON announcement_reads(announcement_id);
CREATE INDEX IF NOT EXISTS idx_announcement_reads_user ON announcement_reads(user_id);
CREATE INDEX IF NOT EXISTS idx_announcement_reads_read_at ON announcement_reads(read_at DESC);
-- Uprawnienia
GRANT ALL ON TABLE announcement_reads TO nordabiz_app;
GRANT USAGE, SELECT ON SEQUENCE announcement_reads_id_seq TO nordabiz_app;
-- Komentarze
COMMENT ON TABLE announcement_reads IS 'Śledzenie kto przeczytał które ogłoszenie (seen by)';
COMMENT ON COLUMN announcement_reads.read_at IS 'Data i czas pierwszego odczytu';

View File

@ -252,6 +252,103 @@
color: var(--primary);
font-weight: 500;
}
/* Seen by section */
.seen-by-section {
margin-top: var(--spacing-xl);
padding-top: var(--spacing-lg);
border-top: 1px solid var(--border);
}
.seen-by-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--spacing-md);
}
.seen-by-title {
font-size: var(--font-size-sm);
font-weight: 600;
color: var(--text-secondary);
display: flex;
align-items: center;
gap: var(--spacing-xs);
}
.seen-by-stats {
font-size: var(--font-size-xs);
color: var(--text-muted);
background: var(--background);
padding: var(--spacing-xs) var(--spacing-sm);
border-radius: var(--radius-full);
}
.seen-by-avatars {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-xs);
}
.reader-avatar {
width: 36px;
height: 36px;
border-radius: 50%;
background: var(--primary);
color: white;
display: flex;
align-items: center;
justify-content: center;
font-size: var(--font-size-sm);
font-weight: 600;
cursor: pointer;
transition: transform 0.2s ease, box-shadow 0.2s ease;
position: relative;
}
.reader-avatar:hover {
transform: scale(1.1);
box-shadow: var(--shadow-md);
z-index: 10;
}
.reader-avatar[data-tooltip]:hover::after {
content: attr(data-tooltip);
position: absolute;
bottom: 100%;
left: 50%;
transform: translateX(-50%);
background: var(--text-primary);
color: white;
padding: var(--spacing-xs) var(--spacing-sm);
border-radius: var(--radius-sm);
font-size: var(--font-size-xs);
white-space: nowrap;
margin-bottom: 4px;
z-index: 100;
}
.reader-avatar.more {
background: var(--surface-secondary);
color: var(--text-secondary);
border: 1px solid var(--border);
}
.progress-bar-container {
width: 100%;
height: 6px;
background: var(--border);
border-radius: 3px;
margin-top: var(--spacing-sm);
overflow: hidden;
}
.progress-bar-fill {
height: 100%;
background: linear-gradient(90deg, var(--primary) 0%, var(--success) 100%);
border-radius: 3px;
transition: width 0.5s ease;
}
</style>
{% endblock %}
@ -313,6 +410,37 @@
</a>
</div>
{% endif %}
<!-- Seen by section -->
<div class="seen-by-section">
<div class="seen-by-header">
<div class="seen-by-title">
&#128065; Przeczytane przez
</div>
<div class="seen-by-stats">
{{ readers_count }} z {{ total_users }} ({{ read_percentage }}%)
</div>
</div>
<div class="seen-by-avatars">
{% for read in readers[:20] %}
<div class="reader-avatar"
data-tooltip="{{ read.user.name or read.user.email.split('@')[0] }}{% if loop.first %} (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" data-tooltip="i {{ readers_count - 20 }} innych">
+{{ readers_count - 20 }}
</div>
{% endif %}
</div>
<div class="progress-bar-container">
<div class="progress-bar-fill" style="width: {{ read_percentage }}%;"></div>
</div>
</div>
</div>
</article>