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:
parent
d443fe312e
commit
a172f7af49
38
app.py
38
app.py
@ -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()
|
||||
|
||||
26
database.py
26
database.py
@ -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)
|
||||
# ============================================================
|
||||
|
||||
30
database/migrations/019_announcement_reads.sql
Normal file
30
database/migrations/019_announcement_reads.sql
Normal 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';
|
||||
@ -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">
|
||||
👁 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>
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user